Crawdad 1.7 adds a second, narrower question alongside detection. Detection asks “was that action malicious?” Intent asks “is that action what this agent said it was for?” You issue a signed statement of an agent’s purpose, bind it fail-closed to a running agent identity, and Crawdad compares every tool call the agent makes against that declaration. When the observed behavior drifts, the drifting action can be held, denied, or blocked on the wire before it reaches upstream. It is off by default.
An intent descriptor is a signed, structured declaration issued for exactly one agent identity and valid for a bounded window. It carries:
read_only or read_write;This is declared meaning, not statistical normalcy. There is no learned baseline of “normal” behavior to poison or wait out — the reference is a signed statement of purpose the agent cannot see or edit, and the response stops the drifting action rather than revoking a learned reputation. An omitted class list means that axis is unconstrained — you constrain only what you declare.
Five steps, all driven by crawdad intent: generate an issuer key, trust it, issue a descriptor, restart to bind it, then optionally set enforcement thresholds.
The issuer key is an Ed25519 keypair; the private key signs descriptors, the public key becomes the trusted key in config. keygen is fail-closed by design: it refuses to write to the boot volume, your home directory, or a temp directory, and refuses any volume it cannot confirm is encrypted. The tested recipe is a dedicated encrypted APFS volume.
crawdad intent keygen --out /Volumes/IntentKeys/intent-issuer.key
It prints the issuer public key as hex — that is the value you trust in the next step.
crawdad intent enable --pubkey <64-hex-public-key-from-keygen>
This safely sets intent_descriptor.enabled = true and the trusted public key, preserving every other config key. There is deliberately no disable subcommand: turning the feature off loosens governance, so it stays a deliberate manual edit. Fail-closed reminder: enabled without a valid 32-byte key verifies nothing — every descriptor fails closed and nothing binds.
crawdad intent issue \ --purpose "Nightly dependency-audit bot" \ --access-mode read_only \ --for-agent claude-code --for-exe /Users/you/.local/share/claude \ --tool-classes fs.read,http.get \ --egress-classes vuln-db \ --data-classes dependency-manifest \ --valid-days 90 \ --key /Volumes/IntentKeys/intent-issuer.key
issue builds the descriptor, signs it, writes it, then re-reads it from disk and verifies + binds it through the real store load path before reporting success — so a reported success means the file genuinely round-trips and binds, not just that it was written.
reload refreshes the operator surface only. A sidecar RESTART binds new or changed descriptors on the live proxy enforcement plane. The operator surface (the dashboard’s intent panel, crawdad intent list, the reload endpoint) reads the descriptor directory live, so a freshly-issued descriptor shows up there immediately. But the live enforcement store is loaded once at startup, so a descriptor only affects live traffic after a restart.crawdad restart
crawdad intent issue … --reload (or POST /api/v1/intent-descriptors/reload) validates the on-disk set and reports exactly why any file did not bind. It runs in a throwaway store, never disturbs live traffic, and is not a substitute for the restart.
With no thresholds set, the feature is observe-and-record only: it scores drift and writes it to the decision log, but changes no wire decision. To make it act, map divergence scores to enforcement verbs under on_intent_drift.
{ "intent_descriptor": {
"enabled": true,
"trusted_pubkey": "…",
"on_intent_drift": { "ask": 0.70, "deny": 0.85, "kill": 0.95 } } }
Each verb is an optional threshold on the [0, 1] score; a value outside [0, 1] is dropped, so a malformed knob can never fabricate an always-on block. Tightening is easy; loosening pays a ceremony. Adding a verb or lowering a threshold is a plain administrator write. Removing a verb or raising a threshold is a loosening and additionally requires step-up — the administrator token re-presented as an explicit bearer credential; a login cookie alone is not enough. Friction always favors caution.
Divergence is measured against your declaration, per axis. The engine is deliberately false-positive-safe: it favors a missed drift over a fabricated one, because a drift can block a real action.
read_only agent that performs a local-state mutation — a write, delete, or code execution — diverges (write_under_read_only). A network send alone is deliberately not an access-mode violation: the sidecar cannot tell a benign GET from an exfil POST at that layer, so network drift belongs to the egress axis.undeclared_tool_class).169.254.169.254, metadata.google.internal, and the other canonical SSRF/credential-pivot destinations). It does not yet verify arbitrary allowlist classes, which needs an operator-supplied class→domain map that does not exist yet. An unclassifiable host is never mislabeled as a drift..env, .ssh, id_rsa, .aws, credentials, tokens, …). General path→data-class labeling has no observed-side classifier yet.Each reason carries a weight; the score is the strongest reason’s weight, nudged up slightly per additional distinct reason, capped at 1.0.
| Reason | Weight |
|---|---|
undeclared_egress_domain_class (cloud-metadata) | 0.95 |
write_under_read_only | 0.90 |
undeclared_tool_class | 0.80 |
undeclared_data_class (secret-material) | 0.65 |
The “hard” purpose violations sit at or above the arbiter’s single-high-confidence bar (0.70), so one of them is block-worthy on its own. The coarse data-class signal sits below the bar: alone it only corroborates and accumulates. A run of individually-borderline drifts is folded into a decayed per-session total (15-minute half-life). Once that total crosses the elevate/block points, the live drift’s score is lifted over the bar — the trajectory case: four sub-bar credential reads that no single action would block, caught as a sequence.
An operator threshold resolves the strictest crossed verb (evaluated kill → deny → ask, so a mis-ordered policy still resolves correctly).
| Verb | At the tool-call boundary |
|---|---|
| ask | Held for human review. There is no interactive prompt-and-wait at the wire — the call is blocked and recorded requires_review, surfacing in the governance hold-review flow. |
| deny | The offending call is denied — the plain block path. |
| kill | The strictest verb. Blocks the offending request and records a terminate-severity, highest-rank forensic decision. It does not send an OS signal to the agent process — see the honest-language note below. |
Every enforced verb is attributable in the decision log to the exact drift and the exact operator threshold that fired it — the score, the reasons, the verb, and the threshold all land in the record, keyed by agent identity.
The intent check runs first in the shared tool-call enforcement path, ahead of the MCP allowlist, guardrail, and capability-policy checks — and that path is the single source of truth for both the streaming and the non-streaming proxy routes, so a streamed tool call and a non-streamed one decide identically. When no operator verb is configured, a lone high-divergence drift still reaches a block through the arbiter’s existing single-high-confidence bar, exactly like a lone ML or dynamic-signature hit. Enforcement is deterministic per rule: intent drift is decided by the declared descriptor and the observed action, not by a classifier. The ML / L1 detection layers are a separate, opt-in concern, off the intent critical path.
A descriptor binds to an agent by the proxy’s own identity key: identity_id = SHA256(agent_type || canonical_exe_path)[..16] — the same 16-hex value the proxy independently resolves for in-flight traffic. This is the anti-spoofing crux: a descriptor is only ever bound against the identity the proxy independently resolved. A valid, correctly-signed descriptor issued for agent A, presented on traffic the proxy resolved to agent B, is rejected (identity mismatch) and binds to neither — a property proven by test. Tampering with any signed field invalidates the signature.
NoDescriptor is a defined, least-trusted state — never an unbounded grant. Absence of a bound descriptor means “no declared purpose”; the intent layer simply emits nothing (it abstains). It never weakens or alters detection for that agent. Unattributed traffic likewise yields NoDescriptor.These framings are load-bearing. They are what the code actually does.
kill verb is the strictest request block and a terminate-severity forensic record — there is no OS process-kill at the tool-call boundary.reload refreshes the operator surface; a RESTART binds live enforcement. Do not describe reload as applying a descriptor to live traffic.NoDescriptor is the least-trusted / abstain state, not an unbounded grant. An empty declared axis is unconstrained on that axis — you constrain only what you declare.The evidence behind any “we stop the drifting agent” statement is the GATE-1.7 result: a real streaming Claude Code agent, running under a read_only intent descriptor, was blocked mid-stream from writing a file. The write scored 0.90 on the write_under_read_only axis, which crossed the operator’s deny threshold of 0.85, so the streamed tool call was denied on the wire — and the file was never created.
This matters specifically because it is the streaming path. The single shared enforcement function is invoked identically by the streaming and non-streaming routes, so a streamed write under a read-only mandate is caught exactly as a non-streamed one would be. (This closed a prior seam in which the streaming route performed zero intent checking; the fix and its parity are verified in-tree.)
Scope it honestly: this is per-action wire enforcement — Posture A, not capability-by-construction. It stops the drifting action; it does not make the action physically impossible.
Declared intent is a specific, defensible combination:
Frame it against anomaly-baseline detection: Crawdad governs against declared meaning, not statistical normalcy — there is no learned “normal” to poison or wait out — and it stops the action, it does not revoke a learned identity reputation.
kill is a request block, not a process kill. No OS signal is sent at the tool-call boundary.