Skip to content

feat(vetted-ops): publish as a substrate plugin, with the privilege boundary on the entry point - #1188

Open
potiuk wants to merge 3 commits into
apache:mainfrom
potiuk:vetted-ops-plugin-packaging
Open

feat(vetted-ops): publish as a substrate plugin, with the privilege boundary on the entry point#1188
potiuk wants to merge 3 commits into
apache:mainfrom
potiuk:vetted-ops-plugin-packaging

Conversation

@potiuk

@potiuk potiuk commented Sep 9, 2026

Copy link
Copy Markdown
Member

Two commits: packaging, then the security fix the packaging exposed.

1. Publish vetted-ops as a substrate plugin. The tool shipped only in the framework checkout, so adopters installing from the marketplace had no copy of it. It now ships as magpie-vetted-ops, following magpie-agent-guard: reached through a narrow symlink from the installed plugin root, which is neither a path the agent edits nor inside any sandbox write root. Substrate plugins previously all carried a hook and the generator required one; publishing a tool from the plugin root is the general case, a hook one instance, so hooks becomes optional.

2. Move the privilege boundary off --caller. Wiring the packaging surfaced that the natural next step — allowlisting the dispatcher so a session needs one rule instead of a dozen wildcard asks — does not hold as written. --caller is an argv string chosen by whoever runs the command, so an allow on vetted-op grants every operation in the catalogue by naming a different caller, turning confirmed gh writes into unprompted ones. That is worse than the ask rules it replaces. The README's "What it does not guarantee" section already said per-caller scoping was not a boundary; the tempting recommendation was written as though it were.

The fix is to bind what the agent cannot choose. A permission rule keys on the command, and argv cannot change which binary is running:

Entry point Can write? Permission
vetted-op-read never — refused before policy or --caller is consulted allow
vetted-op yes, subject to policy ask

Repeated --caller is now an error: argparse keeps the last occurrence, so --caller read-only … --caller privileged would match a rule written against the read-only prefix while resolving to the privileged entry.

Body files were the same class of problem. Validating a path and handing it to gh left the checked file and the published file free to differ. read_body now requires a workspace this user owns that group and world cannot write, refuses symlinks (O_NOFOLLOW plus realpath for parents), reads the bytes once, and pipes them on stdin.

Isolated setup proposes the split and the exclusion at install, drift-checks both on update, and verifies them as check 9 — with vetted-op appearing in allow called out as a stop-and-say-so failure.

Verification: prek --all-files rc=0 · 47 tests (7 new: escalation via the read dispatcher under the most privileged caller, refusal for a caller absent from policy entirely, repeated --caller, symlinked body, group-writable workspace, swap-after-validation) · ruff · mypy · lychee 0 errors.

Found by an adversarial review pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RPzMV43UCu75XF2c9GmcUN

The vetted-ops dispatcher trades a dozen wildcard `ask` rules for one
`allow`. That trade only holds while the agent holding the `allow`
cannot rewrite what it is permitted to do — but the tool shipped only
in the framework checkout, so adopters installing from the marketplace
had no copy of it at all, and anyone vendoring it into their own repo
put the operation catalogue inside the tree the agent edits.

Publish it as the `magpie-vetted-ops` substrate plugin, the pattern
`magpie-agent-guard` already established: the tool is reached through a
narrow symlink from the installed plugin root, which is neither a path
the agent edits nor inside any sandbox write root.

Substrate plugins previously all carried a hook, and the generator
required one. Publishing a tool from the plugin root is the general
reason to be a substrate plugin; wiring a hook is one instance of it.
Make `hooks` optional so a dispatcher can be published without
pretending to be a hook, and make the must-resolve diagnostic name the
right consequence for each shape.

Wire the exclusion through isolated setup — proposed with the `allow`
at install, drift-checked on update, and check 9 on verify. It covers
two surfaces, because they are two halves of one bound: an agent that
adds an op to the catalogue and an agent that adds itself to a caller
list in the policy arrive at the same place.

The docs state the asymmetry rather than rounding it off: the catalogue
is protected twice (deny rules, plus sitting outside every allowWrite
root), the policy only once. The policy lives in the sandbox-writable
project root, so a Bash-level write slips past an Edit/Write deny. That
is a real limit of the mechanism and adopters need it to judge the
trade, so it is documented, not implied.
…-caller

The packaging commit wired an `allow` for the dispatcher on the strength of a
read-only *caller name*. That does not hold. `--caller` is an argv string
chosen by whoever runs the command, so an agent handed that `allow` reaches
every operation in the catalogue by naming a different caller — including
issue-close and the review submissions. It converts a confirmed `gh` write into
an unprompted one, which is worse than the wildcard `ask` rules it replaced.
The tool's own README already said per-caller scoping was not a boundary; the
recommendation was written as if it were.

Split the dispatcher instead. `vetted-op-read` refuses any operation with
writes=True before it consults the policy or looks at --caller, so no argv can
argue it into a mutation, and it is safe to allowlist outright. `vetted-op`
keeps its confirmation. A permission rule keys on the command, and argv cannot
change which binary is running — so the entry point can carry a boundary that a
parameter never could.

Repeated --caller is now an error. argparse keeps the last occurrence, so
`--caller read-only … --caller privileged` would match a rule written against
the read-only prefix while resolving to the privileged entry: a bypass of
exactly the mechanism this commit adds.

Body files were the other half. Validating a path and handing it to `gh` left
the checked file and the published file free to differ. read_body now requires
a workspace this user owns and that group and world cannot write, refuses
symlinks (O_NOFOLLOW, plus realpath for the parents), reads the bytes once, and
pipes them to `gh` on stdin. The bytes that were validated are the bytes that
get sent.

Docs say which dispatcher may be allowlisted and why the other may not, and
name per-caller scoping as least-privilege hygiene rather than isolation —
that misreading is what produced the unsafe rule in the first place.
…ility

RFC-AI-0002 described four layers and stopped at `permissions.ask`. Layer 3's
real failure mode is not its rules but its volume: a wildcard that prompts for
`gh issue view` as loudly as for `gh issue close` produces a hundred prompts on
a thirty-tracker sweep, and the hundredth gets the attention the first deserved.
Prompt fatigue is the mechanism by which Layer 3 stops working, so the tool that
addresses it belongs in the RFC rather than only in its own README.

Record it as Layer 3a, and state where the boundary sits. `--caller` is an
argument, so it can never be one — on any runtime. What every harness can bind
is the command, which is why the dispatcher is split into a read entry point
that refuses writes structurally and a write entry point that keeps its
confirmation.

The harness table is the point of the section: the split is portable precisely
because it asks each runtime for a permission decision keyed on a command
string, which all of them have, and asks none of them to bind a decision to
which skill is calling, which none of them offer. Claude Code, OpenCode, Kiro
and Codex each express it in two rules, with the matching semantics that matter
noted per harness — last-match-wins on OpenCode, anchored regex on Kiro, and
Kiro's prompt-by-default making omission the safe state.

Two residual risks join the list, both previously unstated: a session is a
single principal, so argument-expressed scoping is advisory everywhere; and the
policy file sits in the project tree, which a sandboxed shell can write. The
second is survivable only because of the first fix — the read dispatcher ignores
policy when refusing writes — and saying so is the point. The open question is
whether per-skill scope can ever become a real boundary, which needs a runtime
primitive no shipping harness has.
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