Pixir.Permissions.WriteDenials (pixir v0.1.13)

Copy Markdown View Source

The mandatory write_denials confession for bounded-write runs (#446).

A bounded-write denial is recoverable feedback: the model receives the structured denial as a tool output and may adapt inside the same Turn. The second denial in that Turn is turn-fatal. Because a run can therefore complete cleanly after a denial, a completed status alone no longer proves the worker never pushed at its boundary — so every bounded-write envelope and every workflow checkpoint carries this confession, present even when empty.

Absence is a schema violation, not an ambiguous silence. A completed status with a non-empty confession is a signal for the coordinator to check whether the worker's scope was correct, not a defect in the worker.

The confession is derived from the Log, which stays the source of truth: every denial — recoverable or fatal — is a permission_decision Event with decision: "deny" and gate: "write_policy".

One write_policy-gated denial is deliberately not confessed: the bash_disabled kind. The shell being disabled is a property of the bounded write mode rather than a write-allowlist violation (#218), it never strikes in Turn, and confessing it would report a worker that merely tried to shell out as if it had probed its write boundary. The confession classifies exactly the events the strike counter classifies.

What each denial names as its target

Every entry carries a normalized_path, and every entry's normalized_path is what the call aimed at — never where the refusal happened to stop. An entry with no target at all is one the coordinator cannot reconcile against the worker's transcript, so the key is populated for every kind:

matched_ruleTarget in normalized_pathAlso carries
no_allow_match, deny_match, protected_paththe requested write path
workspace_root_not_writablethe requested path ("." normalized)
path_outside_workspacethe requested path; it never normalizes
symlink_path_componentthe requested pathsymlink_component
path_not_inspectablethe requested pathuninspectable_component
outside_workspace (bash)the requested commandtoken
child_policy_override_unsupportedthe tool name, "spawn_agent"
unsupported_mutating_toolthe tool name

The last two rows name a tool, not a path, because the denial has neither a path nor a command: the worker was refused for handing a child a different policy, or for calling a mutating tool the policy does not model. The target is therefore ambiguous on its face — "spawn_agent" reads the same whether it is a tool name or a file of that name — and it is disambiguated by the two keys that are always present beside it: matched_rule (one of exactly those two rules) together with tool. That pair is the contract; no separate target-kind field is stamped, so a consumer that needs to know what it is looking at reads matched_rule rather than guessing from the string.

Where a confinement walk stopped is a second fact about the same denial, and it lives in its own key (symlink_component, uninspectable_component) precisely so it can never be mistaken for the target. A write to link/deep/out.txt refused at the link component confesses the full requested path and reports symlink_component: "link" beside it; confessing "link" as the target would name a write no call ever made.

Summary

Functions

The empty confession. Present, not absent, when nothing was denied.

Build the confession from an already-folded Session history.

Build the confession by folding session_id's Log under workspace.

The confession of a Log that could not be read. Well-formed, never a zero.

Functions

empty()

@spec empty() :: map()

The empty confession. Present, not absent, when nothing was denied.

from_history(history)

@spec from_history(list()) :: map()

Build the confession from an already-folded Session history.

Denials are reported in Log order. Each carries the denied tool, the normalized path (or the denied command), the matched rule, the policy identity, and a disposition of "recovered", "fatal", or "unresolved".

The fatal strike is the denial the Turn actually died on: the last denial of a Turn that recorded a write_policy_denied turn_failed. Every other denial of a Turn that reached a terminal event was survived, so it reads as "recovered" — including denials in earlier Turns of the same Session, whose strike counters reset.

A denial whose own Turn never reached a terminal event in the Log reads as "unresolved". A Turn ends at its terminal event or at the user_message that opens the next Turn, whichever comes first: an interrupted Turn (and a Turn lost to a Task DOWN) records neither assistant_message nor turn_failed, so only the boundary closes it. The Log is the source of truth and an incomplete Log — cut off by a crash between the denial and the Turn's terminal event, ended by an interrupt, copied partially, or folded while the Session is still running — does not say whether the worker adapted. Reporting such a denial as "recovered" on the strength of a later Turn's terminal event would be a fail-open claim on missing evidence; "unresolved" tells the coordinator to go read the Log.

from_session(session_id, workspace)

@spec from_session(String.t(), String.t()) :: map()

Build the confession by folding session_id's Log under workspace.

An unreadable Log yields the unavailable confession rather than raising or reporting zero: %{"status" => "unavailable", "error" => …, "denials" => []}, with no count key at all. "Nothing was denied" and "the evidence could not be read" are different facts, and a coordinator that reads only the confession must not be able to mistake the second for the first — that is the fail-open the whole confession exists to close. The absent count makes the difference impossible to miss for a consumer that sums counts.

A missing Log is not an unreadable one. Log.fold/2 folds a Session that never wrote an Event to {:ok, []}, and a Session that recorded nothing genuinely denied nothing, so it confesses %{"count" => 0, "denials" => []} like any other clean run. unavailable is reserved for the cases Log.fold/2 actually reports as errors — a corrupt Log, an unsafe or rejected session id, an unreadable file — plus a non-binary session_id or workspace.

unavailable(error)

@spec unavailable(term()) :: map()

The confession of a Log that could not be read. Well-formed, never a zero.