← Docs

Declared intent

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.

What this is, precisely. 1.7 is per-action wire enforcement — each tool call is compared, at the proxy, against a signed intent descriptor, and a drift is stopped on the wire. It is not capability-by-construction: Crawdad does not yet make an out-of-intent action physically impossible by sandbox or capability substrate. It stops the drifting action — a determined, deterministic gate, not an unescapable one. Read the honest boundaries before you rely on it.

What a descriptor declares

An intent descriptor is a signed, structured declaration issued for exactly one agent identity and valid for a bounded window. It carries:

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.

The end-to-end flow

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.

1. Generate the issuer key (encrypted volume only)

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.

2. Trust the issuer and turn the feature on

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.

3. Issue a descriptor

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.

4. Restart to bind on the enforcement plane

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.

5. Set enforcement thresholds (optional)

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.

How enforcement decides

The divergence axes

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.

Scoring, and the trajectory case

Each reason carries a weight; the score is the strongest reason’s weight, nudged up slightly per additional distinct reason, capped at 1.0.

ReasonWeight
undeclared_egress_domain_class (cloud-metadata)0.95
write_under_read_only0.90
undeclared_tool_class0.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.

The verbs

An operator threshold resolves the strictest crossed verb (evaluated kill → deny → ask, so a mis-ordered policy still resolves correctly).

VerbAt the tool-call boundary
askHeld 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.
denyThe offending call is denied — the plain block path.
killThe 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.

Where it sits in the pipeline

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.

Identity binding

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.

Honest language

These framings are load-bearing. They are what the code actually does.

Proof point (GATE-1.7)

A streaming agent blocked mid-write

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.

Positioning

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.

Honest boundaries