The agent

It has to be inside to be useful, so it comes on a short leash

A webhook can only reason about the text of an alert that already fired. An agent can query the reject table and read the mapping — which is why it ships with hard limits rather than promises.

Why it has to be inside

The evidence that settles an ETL investigation is not in your alert payload. It is in tables and log files that never leave your network.

Consider what the worked investigation actually needed: a count from a reject table grouped by reason, the modify_date of a target object, a file manifest against a trailing median, and the contents of a mapping file as deployed. None of that is in a webhook. An alert says row count below threshold; it cannot tell you where the rows went, because the thing that knows is a table on a server in your estate.

So the choice is between a tool that reasons about alert text — which is to say, produces the plausible prose this product exists to replace — and one that can read. There is no third option, and pretending otherwise by calling it "agentless" would just move the same access somewhere less inspectable.

Which makes the interesting question not whether something runs inside, but what it is structurally incapable of doing. The rest of this page is that answer.

Posture

Network posture

The agent initiates every connection. There is no inbound port, no listening socket, and no mechanism by which we contact your network — including for support. If your egress policy allows one hostname on 443 through a proxy, that is the entire network requirement.

What the agent can reach, and what can reach it
Surface Position Detail
Inbound portsNoneNo listening socket. Nothing dials into your network, including us.
Outbound443/TCP to one hostnameProxy-aware; pins our certificate. Allow-list a single destination.
InitiationAlways the agentIt polls for work. There is no channel for us to push a command.
Database accessRead-onlyExplicit DENY on write verbs, in addition to withheld grants.
FilesystemRead-only mountsLog directories and config paths you name. Nothing else is visible.
Data at restNothing persisted locallyEvidence is redacted, transmitted, and dropped. No local evidence store.
Catalogue

The query catalogue

A file on your host listing every statement the agent may execute. Not a filter over arbitrary SQL — the arbitrary path does not exist.

This is the control that matters most, so it is worth being precise about how it works. The agent does not receive SQL and check it against rules. It receives an identifier, looks that identifier up in a local file, and executes the pinned statement with bound parameters. A statement absent from the file is not rejected; it is unreachable.

/etc/decim/catalogue.yaml yaml
# Every query the agent may run is declared here, reviewed by you, and
# pinned by hash. A statement not in this file cannot execute — the agent has
# no code path that sends arbitrary SQL.

- id: reject-profile-by-reason
  purpose: Group reject rows by reason and hour to spot a new failure mode.
  engine: mssql
  parameters: [schema, table, since_utc, until_utc]
  max_rows: 5000
  timeout_seconds: 30
  statement: |
    SELECT Reason, COUNT(*) AS Rows,
           MIN(RejectedAtUtc) AS FirstSeen,
           MAX(RejectedAtUtc) AS LastSeen
    FROM {{schema}}.{{table}}
    WHERE RejectedAtUtc >= @since_utc AND RejectedAtUtc < @until_utc
    GROUP BY Reason
    ORDER BY Rows DESC;

- id: object-modify-date
  purpose: Establish when a table was last altered, to test schema-change causes.
  engine: mssql
  parameters: [object_name]
  max_rows: 1
  timeout_seconds: 10
  statement: |
    SELECT name, modify_date
    FROM sys.objects
    WHERE name = @object_name AND type = 'U';

Every entry carries a purpose in plain language, because a reviewer approving this file should not have to infer intent from SQL. Parameters are bound, never interpolated — the identifiers in braces are validated against the schema before substitution rather than pasted in. max_rows and timeout_seconds are mandatory, which is as much about protecting your production instance from a pathological query as about limiting what leaves.

Adding a query is a pull request against a file on your infrastructure. That is deliberately the same friction as any other production change, and it means the answer to "what could this thing read?" is a file your team owns rather than an assurance we give.

Permissions

Exactly which grants it needs

Written out in full, because 'read-only access' is not a specification.

Most of what an investigation needs is metadata — what objects exist, when they changed, what a procedure references, which tables took writes. That requires catalog permissions, not data permissions. Row access is needed in a small number of named places: the reject table, the intake counts, and column-scoped counting on fact tables.

Grants for a SQL Server instance sql
-- The whole grant set for a SQL Server instance. No db_datareader on user
-- data: the agent reads catalog metadata plus the specific tables you list.

CREATE LOGIN decim_agent WITH PASSWORD = '...';
CREATE USER  decim_agent FOR LOGIN decim_agent;

-- Catalog metadata: topology discovery and schema-change tests.
GRANT VIEW DEFINITION      TO decim_agent;   -- sys.sql_modules, sys.objects
GRANT VIEW DATABASE STATE  TO decim_agent;   -- sys.dm_db_index_usage_stats

-- Scheduler metadata, on msdb only.
USE msdb;
GRANT SELECT ON dbo.sysjobs      TO decim_agent;
GRANT SELECT ON dbo.sysjobsteps  TO decim_agent;
GRANT SELECT ON dbo.sysjobhistory TO decim_agent;

-- Reconciliation tables, named explicitly. Nothing wildcarded.
USE Sales;
GRANT SELECT ON dbo.IntakeBatch          TO decim_agent;
GRANT SELECT ON dbo.RejectedTransactions TO decim_agent;

-- Counts only from the fact table: a column-scoped grant, not the rows.
GRANT SELECT ON dbo.[Transaction](BatchId, LoadedAtUtc) TO decim_agent;

DENY  ALTER, DELETE, INSERT, UPDATE, EXECUTE TO decim_agent;

Note the last two statements. GRANT SELECT ON dbo.[Transaction](BatchId, LoadedAtUtc) is a column-scoped grant: enough to count rows per batch and establish when they landed, and structurally incapable of reading a transaction amount or a customer identifier. The explicit DENY is redundant against grants never given, and it is there anyway — a permission the agent must never hold is worth stating twice, because the second statement survives someone later adding a role membership by mistake.

What is collected, and in what form
Category Scope Note
Log linesMatched against the incident windowWhole files are never shipped — only lines the window and pattern select
Query resultsFrom the catalogue only, row-cappedAggregates and counts wherever they answer the question
Catalog metadataObject, column and module definitionsStructure, not contents
Scheduler entriesJobs, steps, historyFrom msdb, cron, systemd, Quartz, Hangfire
ConfigurationNamed files, redactedThe highest-value source and the likeliest to hold secrets
Commit metadataHashes, authors, changed pathsFile contents fetched only for a path already implicated
Redaction

Redaction, and where it happens

At the agent, before transmission. Redacting on ingest would mean the data had already left, which is the event you were trying to prevent.

This distinction is the whole control. A vendor that scrubs data when it arrives has, by definition, received it unscrubbed — the secret crossed your boundary and existed in their systems, however briefly, and no retention policy undoes that. Redaction here runs in your network, in the agent process, before anything is queued to send.

/etc/decim/redaction.yaml yaml
# Redaction runs at the agent, before anything is queued for transmission.
# Rules are yours to extend; the built-ins cannot be switched off.

builtin:
  - connection_strings      # Password=, Pwd=, User ID= in any config value
  - bearer_tokens           # Authorization: Bearer, api_key=, x-api-key
  - private_keys            # PEM blocks, PKCS#8, PuTTY .ppk
  - pan                     # 13–19 digit runs passing a Luhn check

custom:
  - id: customer-email
    applies_to: [log_lines, query_results]
    match: '[\w.+-]+@[\w-]+\.[\w.]+'
    replace: '<email:{{sha256:8}}>'      # stable hash — still correlatable

  - id: national-insurance
    applies_to: [query_results]
    match: '\b[A-CEGHJ-PR-TW-Z]{2}\d{6}[A-D]\b'
    replace: '<redacted:ni>'

on_rule_error: drop_field   # never transmit a field a rule failed to process

Built-in rules cover the categories that cause the most damage and are the least likely to be anticipated: connection strings in configuration, bearer tokens in log lines, private key blocks, and card numbers validated by Luhn rather than matched by length alone. They cannot be disabled.

Custom rules are yours. The replace template is worth noticing — substituting a stable truncated hash rather than a fixed placeholder keeps a value correlatable without being readable. If the same customer appears in a log line and a query result, an investigation can still establish they are the same subject, which is often the analytical point, while nothing recoverable leaves your network.

on_rule_error: drop_field is the default and the right one. A rule that throws is a rule whose output nobody can reason about, and the cost of losing a field is always lower than the cost of shipping the thing it existed to remove.

Deploying it

Windows Service, systemd unit, Docker or Kubernetes. It runs where your pipelines already do.

One agent per network segment that needs reaching, identified by a site id. Enrolment is a one-time token; after that the agent holds its own credential and rotates it.

docker-compose.yaml yaml
services:
  decim-agent:
    image: ghcr.io/ryware/decim-agent:1.x
    restart: unless-stopped
    environment:
      DECIM_ENROLMENT_TOKEN: ${DECIM_ENROLMENT_TOKEN}
      DECIM_SITE_ID: sql-prod-weu
      DECIM_EGRESS_PROXY: http://proxy.internal:3128
    volumes:
      - ./catalogue.yaml:/etc/decim/catalogue.yaml:ro
      - ./redaction.yaml:/etc/decim/redaction.yaml:ro
      - /var/log/etl:/var/log/etl:ro          # read-only log mount
    cap_drop: [ALL]
    read_only: true
    user: "10001:10001"

The container drops all capabilities, runs as a non-root user with a read-only root filesystem, and mounts log directories read-only. Nothing here is unusual — it is the configuration you would require of any third-party agent, written down so your review does not have to ask.

What gets recorded

Every collection, on both sides.

The agent writes a local audit line for each query it executes — catalogue id, parameters, row count, duration, and the investigation that requested it — to your own logging, where your SIEM can see it. The same record exists server-side and is visible in the investigation timeline.

Two copies is the point. A record that only we hold is a record you have to take on trust; one written into your logging is one you can reconcile against independently. If those two ever disagree, that is a finding, and you should be able to detect it without asking us.

Common questions

Can the agent run arbitrary SQL if you push it a new instruction?
No, and this is a structural property rather than a policy. The agent has no code path that accepts a statement from the server — it resolves an identifier against the local catalogue file and refuses anything it cannot find there. Changing what it can run means changing a file on your host, which is a change your review process sees.
Does it need db_datareader?
No. It needs VIEW DEFINITION and VIEW DATABASE STATE for metadata, read on the scheduler tables in msdb, and SELECT on the specific reconciliation tables you name. Fact tables are usually granted at column scope for counting, not row scope. A blanket db_datareader grant is the thing this design exists to avoid.
What happens if a redaction rule has a bug?
The field is dropped rather than transmitted. on_rule_error: drop_field is the default and we would push back on changing it — a rule that throws is a rule whose output you cannot reason about, and losing one field costs far less than shipping the thing it was meant to remove.
Can we run it in a network with no internet access at all?
It needs to reach one hostname on 443, directly or through your proxy. For genuinely air-gapped environments, talk to us before anything else — that is an architecture conversation, not a configuration flag, and we would rather have it early.
How much does it cost to run?
It is idle almost all the time. Collection is scoped to an incident and a window rather than streamed continuously, so the load pattern is bursts of read-only queries with row caps and timeouts, not a background tap on your production database.
Who can see the evidence once it leaves?
It is scoped to your tenant and addressable by id from the investigation that cited it. Access, retention and the full data-handling position are on the security page, which is written for the person who has to sign off on this rather than the person who wants to buy it.

Related: security for the threat model and data handling; evidence for how collected artefacts are cited; and pipeline topology for what the read-only metadata is used to build.

Get started

Security review is the first conversation, not the last

We would rather hand your team the agent's permission model up front than discover an objection three months in.