Skip to main content
A single step can look fine while the sequence is wrong. Reading an untrusted page and then sending an email is an exfiltration that no per-step check can see. Bounds judges a proposed call against the path of its task, using three fields the caller sets on every proposal:

The rules file

Rules live in .attesso/rules.json, versioned with your code. Every entry records the digest of the rules it was judged under, so a later rule change is visible against old decisions.
The file is validated before it is used. An unknown capability, an unknown trust level, a rule with no condition, or a non-integer ceiling is a hard error, not a silently ignored rule.

Evaluation order

Rules are evaluated in file order and the first match wins, so specific rules belong above broad ones. For each rule:
  1. Capability filter. A rule only judges a proposal whose capability it denies. A max_steps on a send rule is checked when a send is proposed, against the whole task history.
  2. Step ceiling. max_steps compares the task’s history length. At or over the ceiling: DENY, code STEP_LIMIT, tainted_by is the step count. This is the rule that ends a runaway loop.
  3. Repeat ceiling. max_repeats counts earlier entries with the same subject and the same argument fingerprint. At or over the ceiling: DENY, code REPEAT_LIMIT, tainted_by is the first repeated step. Without an argument fingerprint a repeat cannot be identified, so the check is skipped rather than counting every call with the same subject.
  4. Path match. The most recent earlier step that matches trust, capability, subject and same_arguments, inside within_minutes, taints the path. On a match: DENY, code PATH_DENIED, tainted_by names that step.
Naming the step is the difference between a refusal you can act on and a mystery: #2 DENY mail.send partner@example.com (PATH_DENIED by step #1 under no-send-after-untrusted-read).
Without within_minutes a taint lasts for the whole task. A broad rule should always carry a window; that is why both shipped profiles do.

Shipped profiles

init writes balanced. init --profile strict writes the conservative variant. An existing ledger can adopt either:
The previous file is kept as rules.json.bak, and the command prints the new digest.
balanced blocks the outbound boundary and duplicate fetches; it leaves local edits and commands alone. strict adds write and execute to the first rule, for an untrusted environment where a shell command itself can exfiltrate.

Ceilings for loops

The repeat rule above is narrow on purpose: one subject, same arguments, thirty minutes. A ceiling is the general form and belongs on the task:
max_steps counts the task’s history, so it stops the afternoon, not one call. max_repeats needs the caller to supply an argument fingerprint; a harness that sends args_hash gets repeat detection, one that does not is left with max_steps.

Find rules from the ledger

suggest counts identical calls in the ledger and prints the rule its own counts justify:
explain opens one entry in full: the verdict, the rule, the step that tainted the path, and what to do about it.
The advice is specific to the code: PATH_DENIED names the step and the rule, STEP_LIMIT suggests splitting the task or raising the ceiling, REPEAT_LIMIT suggests changing the call, and a policy refusal points at the amount, payee or window instead.

Test a change before it blocks work

replay re-judges the whole ledger against a candidate policy and rules file, using the history that preceded each decision:
The summary names how many decisions would flip. --max-newly-denied N turns a budget into an exit code, so CI can refuse a rule change that would have blocked real work. Replay is a diagnostic: it uses the history the ledger holds and cannot prove that history is complete.

Where the policy ends and the rules begin

  • policy.json decides one action: action name, subjects, amount, currency, window. Its codes are ACTION_MISMATCH, REQUIRED_ATTRIBUTE_MISSING, VALUE_NOT_ALLOWED, VALUE_MISMATCH, ABOVE_MAXIMUM, OUTSIDE_DATE_RANGE.
  • rules.json decides what a task may not do after a step, plus the step and repeat ceilings. Its codes are PATH_DENIED, STEP_LIMIT, REPEAT_LIMIT.
  • paths.json decides which files a file tool may touch. Its codes are PATH_SCOPE_DENIED, PATH_REQUIRED and SHELL_SCOPE_DENIED. See Scope file tools.
All of these run on every proposal, and the first refusal wins. The order is fixed:
  1. Control. A paused or stopped task is refused with TASK_PAUSED or TASK_STOPPED, even in observe mode.
  2. Tool pin. A changed or unpinned tool is refused with TOOL_CHANGED before any other rule is read. See MCP servers.
  3. Path scope. paths.json is checked next: PATH_SCOPE_DENIED, PATH_REQUIRED, SHELL_SCOPE_DENIED. See Scope file tools.
  4. Policy. policy.json decides the action itself: ACTION_MISMATCH, REQUIRED_ATTRIBUTE_MISSING, VALUE_NOT_ALLOWED, VALUE_MISMATCH, ABOVE_MAXIMUM, OUTSIDE_DATE_RANGE.
  5. Path rules. rules.json is only consulted when the policy allows: PATH_DENIED, STEP_LIMIT, REPEAT_LIMIT.
The recorded entry carries the code and the rule field of whichever step refused it.