Skip to content
VeritioDocs

Risk signals from agent capture

Kind
guide
For
developer · governance
Verified against
@veritio/core@0.4.7

Agent capture classifies each captured step into metadata.riskSignals before the event is canonicalized and hashed. That ordering makes the command-to-class mapping part of the record bytes, not a display concern layered on afterwards. A second implementation that classifies the same command differently produces a different record hash for the same agent activity, so the mapping is a normative capture contract.

Claude Code hook payload (raw prompt, command, file paths)
↓ classify
riskSignals: frozen enums only, no raw text
↓ redact
hashes + stable ids only
↓ createAuditEvent
canonical JSON (veritio-json-v1)
↓ SHA-256
record hash

Classification runs first because the signal values are ordinary metadata. Once withRiskSignals stamps the normalized signals onto metadata.riskSignals, they sit inside the canonical bytes like any other field. Nothing downstream can re-classify a stored record without changing its hash.

The vocabulary is frozen by spec/risk-signals.schema.json. operationType is one of read, create, update, config, bulk, permission, delete, destructive. reversibility is reversible, recoverable, or irreversible. envCriticality is sandbox, development, staging, or production. The three magnitude fields (dataVolume, fanOut, referenceCount) are non-negative integers. There is no free-text field anywhere in the shape.

bashRiskSignals(command, environment) evaluates four pattern classes in a fixed order and returns on the first match. Precedence is part of the contract: a command matching several classes must be reported as the earliest one.

# Class Patterns (case-insensitive) operationType reversibility
1 Destructive rm with a recursive short-flag group, rm … --recursive, rimraf, find … -delete, a hard git reset, a forced git clean, a forced git push, drop table / drop database / drop schema, truncate table, terraform destroy, kubectl delete, mkfs, dd if= destructive irreversible
2 Delete rm, rmdir, unlink, git branch -D delete recoverable
3 Permission chmod, chown, sudo permission reversible
4 Config git config, npm config, wrangler secret, export VAR= config reversible

An unmatched command returns undefined and the tool call carries no riskSignals key at all. That is deliberate: reads, builds, test runs, and status checks are the overwhelming majority of an agent turn, and giving them a low-but-nonzero score would let a long quiet session drift upward for no reason. Absence of signals is the normal case.

Two consequences follow from precedence, and both fall out of the patterns rather than being special-cased.

sudo rm -rf /var/tmp/build is destructive, not permission. Class 1 is tested first, so the most severe applicable class always wins, and prefixing a command with sudo can never soften it.

Class 2 catches the residue class 1 missed. A plain rm build/artifact.tar has no recursive flag, so it falls through to delete / recoverable. The two classes together mean any rm is classified; only its severity varies.

The rm … --recursive and find … -delete alternatives are scoped to one shell word group — the pattern refuses to cross |, ;, or &. Without that, a -delete flag belonging to a later pipeline segment would smear onto an unrelated leading command.

The forced-push alternative is not scoped that way. It matches a git push followed by anything on the line that contains --force or a bare -f, so a push chained with grep -f patterns.txt src/ classifies as destructive. Classes 2 through 4 are bare word matches with no argument parsing at all, so npm run rm-cache matches rm and lands on delete.

This is a pattern matcher, not a shell parser, and it errs toward over-classification. Over-classifying raises an episode score and surfaces a step for review; under-classifying hides it. When you tune the patterns, verify that your change moves the error in that direction and not the other.

fileChangeRiskSignals(files, environment) classifies a whole batch at once rather than a file at a time, and always returns signals — there is no unmatched case.

  • Any file in the batch with action: "delete" makes the batch delete / recoverable. Deletes dominate; one deletion is not averaged away by nine edits.
  • Otherwise, a batch where every file is action: "create" is create / reversible.
  • Every other batch, including any mix of creates and upserts, is update / reversible.

dataVolume is set on every result and is exactly files.length. It is a file count, not a byte count, line count, or diff size. A 4000-line rewrite of one file and a one-character fix to one file both carry dataVolume: 1. Reading it as anything other than “how many files this batch touched” will misread every captured score.

Volume matters because it feeds the saturating magnitude curve in per-step scoring. Under veritio.reference.v1, a single-file production update scores 0.1812 and a forty-file production update scores 0.2143. Batch size moves the number, but it cannot dominate it — the operation class and the environment do most of the work.

The two capture paths produce very different volumes. The PostToolUse path for Edit, Write, and MultiEdit records exactly one file, so it always yields dataVolume: 1. The Stop turn scan reads the working tree’s porcelain status to catch files that Bash wrote without any edit tool, and batches everything that turn changed, so its dataVolume is the turn’s changed-file count.

The PostToolUse path also never produces a delete batch. Its action is create when Write had no pre-image and upsert otherwise. Deletions reach the graph only through the Stop scan, which is why removing a file by shell command shows up twice: once as a destructive- or delete-class Bash tool call, and once as a delete-class file-change event in that turn’s scan.

envCriticalityOf(environment) lowercases the configured label and tests substrings in order: prod gives production, then stag gives staging, then sandbox gives sandbox, and anything else gives development. Every signal the classifier emits carries the result.

Order decides ambiguous labels. preproduction contains prod and maps to production. A label like staging-sandbox matches stag first and maps to staging.

The fallback is the failure mode worth knowing. normalizeRiskSignals defaults an absent envCriticality to production, the most conservative class. But envCriticalityOf never returns absent — an unrecognized label returns development, the second-lowest multiplier. Configuring a production capture with an environment label of live or main therefore scores everything at the development multiplier. The same destructive command scores 1 under production and 0.663 under development; that is the difference between critical and high. Use a label containing prod.

The command text never leaves the classifier

Section titled “The command text never leaves the classifier”

bashRiskSignals receives the command string, tests it against four regular expressions, and returns enums. The string is not returned, stored, logged, or hashed by that function. What travels with the tool call is inputHash, a sha256:-prefixed digest of the JSON-serialized tool input.

That leaves the classification as the only externalized description of what the command did. A reviewer sees operationType: "destructive", reversibility: "irreversible", envCriticality: "production" — and nothing else. The inputHash proves that a specific command produced that record if you still hold the original text, and proves nothing about the text on its own. This is the intended trade: a bounded, non-PII signal instead of a stored shell history that may contain paths, hostnames, or secrets.

Signals are attached exactly once, on the record that represents the effect.

Bash tool call → riskSignals on the tool_call event
(no file-change event unless the Stop scan finds one)
Edit / Write tool call → NO riskSignals on the tool_call event
→ riskSignals on the file_change event it produced
Stop turn scan → riskSignals on that turn's file_change event

The edit path is the one that is easy to get wrong. buildToolCall classifies only when the tool is Bash; for edit tools it builds the file-change record and puts the signals there, with changedBy pointing back at the tool call. The tool call is the cause, the file change is the effect, and only the effect is scored. Double-stamping would make one edit contribute two steps to the episode rollup and inflate momentum for activity that happened once. The same rule governs the file edges: they live on the file change, never also on toolCall.modifies, so the modified-file edge is emitted exactly once.

Every event a capture session emits carries metadata.activityEpisodeId, derived deterministically as ep_ plus the sanitized session id and stamped by the recorder after caller metadata so a host cannot shadow it. Hook invocations are separate processes with no shared memory, so deriving the id from the session id is what lets them agree without coordination.

A read model groups records by that key, scores each step’s metadata.riskSignals with scoreRiskSignals, and rolls the scored steps up with rollupEpisodeRisk. The rollup takes the maximum of peak and velocityScore: the worst single step, or the worst decayed momentum burst, whichever is higher. It is never a sum and never an average, so padding an episode with quiet steps cannot dilute one bad one.

verified output
{
"policy": {
"policyVersion": "veritio.reference.v1",
"bands": {
"low": 0.05,
"medium": 0.25,
"high": 0.5,
"critical": 0.75
},
"rollup": {
"windowSeconds": 60,
"decayPerWindow": 0.5,
"velocityNormalizer": 3,
"frequencyRuleCount": 0
}
},
"steps": [
{
"stepId": "step_01_read_config",
"occurredAt": "2026-08-09T10:00:00.000Z",
"stepScore": 0.0393,
"gapSeconds": null,
"wholeWindows": 0,
"decayApplied": 1,
"carriedIn": 0,
"momentum": 0.0393,
"runningPeak": 0.0393,
"runningVelocityScore": 0.0131,
"runningScore": 0.0393,
"runningLevel": "none"
},
{
"stepId": "step_02_update_rows",
"occurredAt": "2026-08-09T10:00:30.000Z",
"stepScore": 0.366,
"gapSeconds": 30,
"wholeWindows": 0,
"decayApplied": 1,
"carriedIn": 0.0393,
"momentum": 0.4053,
"runningPeak": 0.366,
"runningVelocityScore": 0.1351,
"runningScore": 0.366,
"runningLevel": "medium"
},
{
"stepId": "step_03_config_change",
"occurredAt": "2026-08-09T10:01:00.000Z",
"stepScore": 0.4732,
"gapSeconds": 30,
"wholeWindows": 0,
"decayApplied": 1,
"carriedIn": 0.4053,
"momentum": 0.8785,
"runningPeak": 0.4732,
"runningVelocityScore": 0.2928,
"runningScore": 0.4732,
"runningLevel": "medium"
},
{
"stepId": "step_04_bulk_update",
"occurredAt": "2026-08-09T10:03:00.000Z",
"stepScore": 0.48,
"gapSeconds": 120,
"wholeWindows": 2,
"decayApplied": 0.25,
"carriedIn": 0.2196,
"momentum": 0.6996,
"runningPeak": 0.48,
"runningVelocityScore": 0.2928,
"runningScore": 0.48,
"runningLevel": "medium"
},
{
"stepId": "step_05_grant_admin",
"occurredAt": "2026-08-09T10:03:20.000Z",
"stepScore": 0.8016,
"gapSeconds": 20,
"wholeWindows": 0,
"decayApplied": 1,
"carriedIn": 0.6996,
"momentum": 1.5012,
"runningPeak": 0.8016,
"runningVelocityScore": 0.5004,
"runningScore": 0.8016,
"runningLevel": "critical"
},
{
"stepId": "step_06_read_audit",
"occurredAt": "2026-08-09T10:13:00.000Z",
"stepScore": 0.03,
"gapSeconds": 580,
"wholeWindows": 9,
"decayApplied": 0.002,
"carriedIn": 0.0029,
"momentum": 0.0329,
"runningPeak": 0.8016,
"runningVelocityScore": 0.5004,
"runningScore": 0.8016,
"runningLevel": "critical"
}
],
"episode": {
"score": 0.8016,
"level": "critical",
"peak": 0.8016,
"velocityScore": 0.5004,
"stepCount": 6,
"policyVersion": "veritio.reference.v1"
},
"dominance": {
"peak": 0.8016,
"velocityScore": 0.5004,
"decidedBy": "peak",
"peakStepId": "step_05_grant_admin",
"momentumPeakStepId": "step_05_grant_admin",
"velocityFromMomentum": 0.5004
},
"orderIndependent": {
"note": "rollupEpisodeRisk sorts by occurredAt on a non-mutating copy, so caller array order cannot move the episode score.",
"reversedInputMatches": true
},
"knobEffects": [
{
"label": "windowSeconds 15 (four times as many decay windows)",
"changed": {
"windowSeconds": 15
},
"rollup": {
"score": 0.8016,
"level": "critical",
"peak": 0.8016,
"velocityScore": 0.3476,
"stepCount": 6,
"policyVersion": "docs.rollup.window15"
}
},
{
"label": "windowSeconds 600 (every gap lands inside one window)",
"changed": {
"windowSeconds": 600
},
"rollup": {
"score": 0.8016,
"level": "critical",
"peak": 0.8016,
"velocityScore": 0.73,
"stepCount": 6,
"policyVersion": "docs.rollup.window600"
}
},
{
"label": "decayPerWindow 1 (momentum never cools)",
"changed": {
"decayPerWindow": 1
},
"rollup": {
"score": 0.8016,
"level": "critical",
"peak": 0.8016,
"velocityScore": 0.73,
"stepCount": 6,
"policyVersion": "docs.rollup.decay1"
}
},
{
"label": "decayPerWindow 0 (any full window resets carry)",
"changed": {
"decayPerWindow": 0
},
"rollup": {
"score": 0.8016,
"level": "critical",
"peak": 0.8016,
"velocityScore": 0.4272,
"stepCount": 6,
"policyVersion": "docs.rollup.decay0"
}
},
{
"label": "velocityNormalizer 1.5 (burst out-ranks the peak)",
"changed": {
"velocityNormalizer": 1.5
},
"rollup": {
"score": 1,
"level": "critical",
"peak": 0.8016,
"velocityScore": 1,
"stepCount": 6,
"policyVersion": "docs.rollup.velocity1_5"
}
},
{
"label": "velocityNormalizer 6 (burst can never out-rank the peak)",
"changed": {
"velocityNormalizer": 6
},
"rollup": {
"score": 0.8016,
"level": "critical",
"peak": 0.8016,
"velocityScore": 0.2502,
"stepCount": 6,
"policyVersion": "docs.rollup.velocity6"
}
}
]
}

That fixture uses hand-pinned signals rather than signals produced by the capture classifier, so read it as proof of the rollup arithmetic, not of the classification. The mechanism it demonstrates is exactly what captured episodes go through: momentum carries between steps, gaps are floored into whole 60-second windows, decayPerWindow is applied once per whole window by repeated multiplication, and velocityNormalizer alone decides whether a burst can out-rank the peak. Ordering is taken from occurredAt on a non-mutating copy, so the order hooks happened to append records in cannot move the published score.

One detail applies to captured episodes specifically. The classifier emits only operationType, reversibility, envCriticality, and — for file changes — dataVolume. It never emits fanOut or referenceCount. Captured Bash steps therefore receive no magnitude boost at all, and their score is exactly the operation base times the two multipliers. Under the reference policy that gives 0.7 for a production delete, 0.36 for a production permission change, and 0.27 for a production config change.

The classifier exists in TypeScript only, in the Claude Code adapter’s map.ts. There is no Python or Go capture adapter yet. This is recorded as an open parity obligation alongside the metadata.sessionId and metadata.activityEpisodeId stamping conventions.

Because classification is hash-affecting, parity here is stricter than for most SDK features. A second implementation must reproduce the pattern set, the case-insensitivity, the word-group scoping, the four-class precedence, the unmatched-means-no-signal rule, the batch rules with dataVolume as the file count, the envCriticalityOf substring order, and the decision to attach signals to the effect record only. Any divergence in any of those produces different canonical bytes, a different record hash, and a chain that a verifier written against the other implementation cannot reproduce. Approximate classification is a protocol break, not a cosmetic difference.

What a captured signal does and does not establish

Section titled “What a captured signal does and does not establish”

A stamped riskSignals value establishes that the capture adapter matched a specific pattern class against a command or a batch at capture time, and that the classification was fixed into the record bytes before hashing. Verifying the chain then establishes that the classification has not been edited since.

It does not establish that the command did what its class suggests. A recursive delete of an empty scratch directory and a recursive delete of a data volume classify identically. It does not establish intent, authorization, or outcome — a destructive step may have been reviewed and approved, and a step with no signals at all may have been the one that mattered. And it does not establish completeness: the classifier only sees what the hooks delivered, so activity outside the captured session is simply absent from the episode.

Read the score as a ranking device for human attention, and treat the underlying evidence, not the band, as the thing under review.

Continue with Activity episodes for how episodes group and close, Risk scoring for the per-step math these signals feed, and Claude Code capture to configure the adapter that emits them.