Skip to content

policy: Holds, for an allowance on what a call says - #191

Closed
kam-validmind wants to merge 2 commits into
mainfrom
policy/export-holds
Closed

kam-validmind wants to merge 2 commits into
mainfrom
policy/export-holds

Conversation

@kam-validmind

@kam-validmind kam-validmind commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

What this adds, in one paragraph

A new exported function, policy.Holds(when, call), that reports whether a set of conditions holds for a call in every pass the engine takes: as the agent sent it, with the text cleaned up, and with paths cleaned too. Evaluate already does all that cleaning, but uses it to make a deny stricter. An embedder that lets a person allow calls by what they say needs the opposite, and until now had to copy the engine's cleaning to get it. Agent Authority's session approvals are that embedder (validmind/agent-authority#161 carries the copy this replaces).

Why Evaluate alone isn't enough for an allow

Evaluate decides a call three times, as sent and twice cleaned, and keeps the strictest answer. That is exactly right for a deny: if /tmp/../etc/passwd matches prefix /etc/ once cleaned, the call is denied, however it was spelled.

An allow is the mirror image. If a person says "allow writes under /tmp/", then /tmp/../etc/passwd matches as sent but names /etc/passwd once cleaned. The right answer is "not allowed", and no single Evaluate call gives it: an allow rule's match in the cleaned pass is never stricter than the as-sent result, so the engine simply reports the as-sent match.

flowchart LR
    subgraph deny["Evaluate (a deny): one pass is enough"]
        A1["as sent: no match"] --> R1["deny"]
        A2["cleaned: matches"] --> R1
    end
    subgraph allow["Holds (an allowance): every pass must hold"]
        B1["as sent: holds"] --> R2["not allowed"]
        B2["cleaned: fails"] --> R2
    end
Loading

What Holds says

Condition: args.path prefix /tmp/.

Call's path As sent Cleaned Holds
/tmp/build/out.log holds holds true
/tmp/a/../b.log holds /tmp/b.log holds true
/tmp/100%2Efile holds /tmp/100.file holds true
/tmp/../etc/passwd holds /etc/passwd fails false
/tmp/%2E%2E/etc/passwd holds /etc/passwd fails false
/tmp/​../etc/passwd holds invisible character dropped, /etc/passwd fails false
/TMP/x fails false: an allowance is read as written

The same holds for eq, contains and in, for nested arguments, and for a command that contains a path (cat /tmp/../etc/passwd does not satisfy contains /tmp/). Facts are compared as they are, as in Evaluate.

What changed

File Change
pkg/policy/holds.go New. Holds runs condMatches with the engine's three cleaners (nil, plainText, canonicalText) and requires every condition to hold in each. Eleven lines; no new cleaning logic.
pkg/policy/holds_test.go New. The table above, every operator, nested arguments, facts, several conditions, and a check that Holds never allows a spelling that a deny on the same condition would not catch.
CHANGELOG.md Entry under Unreleased.
.github/workflows/unit-tests.yml Runs on changes under pkg/** too. It listed only cmd/** and internal/**, so a change to the policy engine did not run the unit tests in CI.

No behaviour of Evaluate changes. Nothing in the CLI or UI changes.

How I validated it

  • go test ./pkg/policy and the full go test ./....
  • gofmt, go vet.
  • Against the cases that found the Agent Authority bug: with Covers implemented as policy.Holds (a local Go workspace), the session-approval property test there ("an allowance on a prefix never covers a call whose path resolves outside it") passes. Two of its example rows read differently: AA's interim resolver treats /tmp/..<zero-width space>/etc/passwd and full-width dots as literal file names and covers them; Holds drops the invisible character and folds the full-width dots, reads both as /etc/passwd, and holds the call. The stricter reading is the one AA will adopt, so those two rows were dropped from its table.

Follow-ups

  • Agent Authority: once this is tagged and the pin moves, review.SessionApproval.Covers becomes a call to policy.Holds and internal/review/resolve.go is deleted (marked ponytail there).
  • Separate from this PR: cleanPath reads //./ at the start of a POSIX path as a Windows device prefix and turns the path relative, so //./etc/passwd escapes a prefix /etc/ deny. Found by the same property tests; I'll open it on its own.

For the reviewer

  • pkg/policy/holds.go is the whole change. The doc comment is the contract.
  • holds_test.go has the table as executable examples; TestHoldsAgreesWithEvaluate is the safety argument.

Evaluate decides a call as sent and twice cleaned and keeps the strictest
answer, which makes a deny stricter: /tmp/../etc/passwd matches a deny on
prefix /etc/ once cleaned. An embedder that lets a person allow calls by
what they say needs the mirror image, and had to copy the cleaning to get
it: Agent Authority's session approvals covered /tmp/../etc/passwd under
"args.path prefix /tmp/" because the as-sent spelling matched.

Holds(when, call) reports whether every condition holds in every pass, so
such an allowance covers /tmp/a/../b.log and not /tmp/../etc/passwd nor
/tmp/%2E%2E/etc/passwd; a spelling that fails as sent (/TMP/x) is never
allowed. No new cleaning: the engine's three cleaners, required together.
@kam-validmind

Copy link
Copy Markdown
Contributor Author

Closing: Agent Authority is dropping conditions from session approvals (see validmind/agent-authority#161), so no embedder needs the allow-side check. The unit-tests workflow fix in this PR (run on pkg/** too) is worth landing on its own; separate PR to follow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant