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
Open
feat(vetted-ops): publish as a substrate plugin, with the privilege boundary on the entry point#1188potiuk wants to merge 3 commits into
potiuk wants to merge 3 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two commits: packaging, then the security fix the packaging exposed.
1. Publish
vetted-opsas 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 asmagpie-vetted-ops, followingmagpie-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, sohooksbecomes 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 wildcardasks — does not hold as written.--calleris an argv string chosen by whoever runs the command, so anallowonvetted-opgrants every operation in the catalogue by naming a different caller, turning confirmedghwrites into unprompted ones. That is worse than theaskrules 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:
vetted-op-read--calleris consultedallowvetted-opaskRepeated
--calleris now an error: argparse keeps the last occurrence, so--caller read-only … --caller privilegedwould 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
ghleft the checked file and the published file free to differ.read_bodynow requires a workspace this user owns that group and world cannot write, refuses symlinks (O_NOFOLLOWplus 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-opappearing inallowcalled out as a stop-and-say-so failure.Verification:
prek --all-filesrc=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