Search guides, workflows, and reference pages.

Docs/tools

vetted-ops

Capability: substrate:sandbox

Harness: agnostic

A dispatcher for fixed, policy-scoped forge operations, so an agent session needs one allowlist entry instead of a dozen wildcard ask rules.

Prerequisites

  • Runtime: Python 3.11+ via uv. The package itself is stdlib-only.
  • CLIs: the gh CLI on PATH, authenticated for the repositories the policy names. HTTP operations do not use gh or curl; they use the urllib.request standard library module.
  • Credentials: whatever gh already uses (~/.config/gh/). This tool reads no credential of its own and stores none.
  • Network: github.com / api.github.com for gh operations. HTTP operations connect to the domains configured in the [endpoints] table (e.g., api.osv.dev, cveawg.mitre.org).
  • Configuration: a policy TOML (see Configuration). Without one, every operation refuses.

Why

RFC-AI-0002 Layer 3 forces confirmation on every outward-visible action:

"ask": [ "Bash(gh issue edit *)", "Bash(gh issue comment *)",
         "Bash(gh issue close *)", "Bash(gh api * -X *)", … ]

The wildcard is doing the work, and it is also the cost. gh issue edit * covers an unbounded argument surface — --add-label, --body, --repo <somewhere else> — so a human has to read every invocation to know what it does. On a sweep that touches thirty trackers, that is a hundred prompts, and the hundredth gets the same attention as the first. Prompt fatigue is itself a security problem.

This tool removes the wildcard rather than the confirmation. Each operation is a closed shape: a name, typed parameters, and a builder that returns an argv list. The repository comes from policy, not the command line. Labels, milestones, assignees, board columns and close reasons must be values the policy declares. Because the effect set is bounded by construction, the dispatcher can be allowed once instead of asked every time.

What it actually guarantees

Being precise, because a security tool that overstates itself is worse than none:

  • A parameter can never become a command. Builders return list[str], executed with subprocess.run(..., shell=False). There is no string interpolation anywhere in the path, so 1; rm -rf / is simply an invalid issue number.
  • A parameter can never become a flag. Any parameter starting with - is refused before validation, which closes flag-injection into gh.
  • The repo is not addressable. No operation takes a repository parameter; it is read from policy. An operation cannot be pointed at another repository, and parameters that reach an API path — repo paths and git refs — refuse .., so none of them can walk out of the pinned repository either.
  • Body content is free; body location and timing are not. Comment bodies may contain anything — backticks, $(…), newlines — because content is never interpolated into a command. What is constrained is which file may be read and when. The file must resolve inside the configured workspace, that workspace must be owned by you and closed to group and world, and a symlink is refused (O_NOFOLLOW, plus a realpath check for the parents). The dispatcher then reads the bytes itself and pipes them to gh on stdin, so there is exactly one open. Handing gh a path would leave a window between the check and the send in which the file could be swapped; the bytes that were validated are the bytes that get published.
  • Writes cannot be reached through the read entry point. vetted-op-read refuses any operation with writes=True before it consults the policy or looks at --caller, so the refusal cannot be argued out of it by argv. This is the one property that makes an unattended allow defensible — see below.
  • GraphQL is named, not written. gh api graphql normally takes a query as a string — the widest surface gh offers. Here a caller names one of the documents shipped in queries/, the dispatcher supplies the text, and owner/name come from policy. A caller can choose among the allowlisted queries; it cannot write one, and cannot re-aim one at another repository.
  • A parameter can never become a URL path traversal. For HTTP operations, URLs are built from closed templates and injected parameters are strictly validated to refuse .. and shell characters.
  • HTTP operations are read-only by construction. The backend "http-read" implies writes=False, enforced by the dispatcher.
  • No curl or wget is involved. The urllib.request implementation automatically obeys HTTP_PROXY and HTTPS_PROXY environment variables (egress gateway).
  • HTTP responses are streamed to stdout, never to files. There is no local filesystem exposure for downloaded data.
  • Read-modify-write procedures stay on the tracker. The procedure backend (see Tracker procedures) runs a fixed sequence of gh calls, and every call passes a runner check first: gh only, no shell, gh issue view|comment|edit only with --repo <tracker>, gh api only under repos/<tracker>/, no -F key=@<file> or --body-file <path> (bodies travel on stdin), and no write at all from an operation declared read-only.
  • The catalogue is closed. Widening the surface means editing ops.py — a reviewed code change, not a runtime decision.

The boundary is the entry point, not --caller

Per-caller scoping is least-privilege, not a security boundary. The policy declares which operations each caller may run, and --caller is required. But within one agent session the agent is a single principal that chooses what to pass as --caller. This protects against the wrong skill reaching for the wrong operation — a real and common failure, especially under prompt injection, where a hostile issue body talks a read-only triage pass into closing something. It does not stop a determined agent, and a permission rule must never be written as though it does. Concretely: allowlisting vetted-op * on the strength of a read-only caller name grants every operation in the catalogue, because the next invocation can name a different caller.

What can be bound is the entry point, because a permission rule keys on the command and argv cannot change which binary is running. Hence two console scripts over one catalogue:

Entry point Can write? Intended permission
vetted-op-read never — refused before policy is consulted allow
vetted-op-tracker only the tracker procedures — everything else refused before policy is consulted ask, and excluded from the sandbox
vetted-op yes, subject to policy ask (or unlisted)

That split is what lets the read path lose its prompts without the write path losing its gate, and it is why --caller repeated twice is an error rather than a last-wins convenience: argparse keeps the last occurrence, so repetition would let a command match a rule written against a read-only prefix while resolving to a privileged caller.

For writes, the confirmation the harness puts in front of vetted-op is still doing the work. A finer boundary — per-skill scope bound by the runtime — needs something no portable mechanism offers yet: plugin manifests cannot declare permissions, and permissionMode is unsupported for plugin-shipped agents. The closest primitive is a subagent with a restricted tools list, which is Claude Code-specific. See tools/spec-loop/specs/vetted-command-surface.md for that trajectory.

The policy file is only as protected as the filesystem makes it. Rewriting the policy cannot reach a write through vetted-op-read — that refusal ignores the config entirely — but it can widen which repository the reads point at. If the policy lives somewhere the agent’s shell can write, treat its contents as advisory rather than enforced.

This is not a substitute for the sandbox. It reduces prompt volume at Layer 3. Layers 0–2 are unchanged and still carry the load.

Configuration

Upgrade Note: Existing adopters upgrading to use osv-query-package must add ecosystems = ["PyPI", "Maven", "npm", ...] to the [values] table in their policy TOML; without it, vetted-op-read refuses package queries during parameter validation.

Adopter-owned, at .apache-magpie-overrides/tools/vetted-ops/config.toml by default:

# Body files must resolve inside this directory.
workspace = "/tmp/agent-scratch"

[endpoints]
osv_api = "https://api.osv.dev/v1"
cve_services_api = "https://cveawg.mitre.org/api"

[repos]
tracker  = "acme/tracker"   # optional — see below
upstream = "acme/product"

[values]
labels        = ["needs triage", "cve allocated", "pr merged"]
upstream_labels = ["ready for maintainer review", "area:scheduler"]  # issues *and* PRs
milestones    = ["1.2.3", "1.3.0"]
assignees     = ["alice", "bob"]
issue_states  = ["open", "closed", "all"]
pr_states     = ["open", "closed", "merged", "all"]
close_reasons = ["completed", "not planned"]
ecosystems    = ["PyPI", "Maven", "npm", "Go"]

board_project_id      = "PVT_kwDO…"   # ProjectV2 node id
board_status_field_id = "PVTSSF_…"    # its Status field id

[values.board_columns]           # column name -> single-select option id
"Assessed"      = "aee65beb"
"CVE allocated" = "aae2beb3"

[callers]                        # caller -> operations it may run
"security-issue-sync"   = ["issue-view", "issue-comments", "issue-add-label",
                           "issue-set-milestone", "issue-comment", "comment-update",
                           "rollup-append", "rollup-amend-latest", "rollup-fold",
                           "body-field-get", "body-field-set",
                           "cve-check-published"]
"security-cve-allocate" = ["issue-view", "issue-add-label", "comment-update",
                           "rollup-append", "rollup-fold",
                           "body-field-get", "body-field-set"]
"security-issue-fix"    = ["rollup-append", "rollup-fold",
                           "body-field-get", "body-field-set"]
"security-issue-import" = ["rollup-append"]
"security-issue-import-from-md" = ["rollup-append"]
"security-issue-import-from-pr" = ["pr-view-with-body", "rollup-append"]
"security-issue-invalidate" = ["rollup-append", "rollup-amend-latest"]
"security-issue-triage" = ["issue-view", "issue-comments",
                           "osv-get-vuln", "osv-query-package"]
"security-issue-deduplicate" = ["osv-get-vuln", "rollup-append", "rollup-fold"]
"dependency-audit"      = ["osv-query-package", "osv-query-commit", "osv-query-batch"]
"pr-management-triage"  = ["pr-list", "pr-view", "pr-checks", "gql-pr-liveness",
                           "pr-add-label", "pr-remove-label", "pr-draft", "pr-ready",
                           "pr-comment", "pr-update-branch", "run-rerun-failed",
                           "workflow-approve"]
"pr-management-code-review" = ["viewer", "upstream-permission", "pr-review-context",
                               "pr-files", "pr-diff", "pr-comments", "pr-reviews",
                               "pr-checks", "pr-list", "pr-list-label",
                               "pr-search-review-requested", "pr-search-mentions",
                               "pr-search-reviewed-by", "pr-search-team-review-requested",
                               "commits-by-path", "repo-file", "label-list",
                               "gql-pr-review-threads", "pr-review-approve",
                               "pr-review-request-changes", "pr-review-comment"]
"pr-management-stats"   = ["pr-list", "pr-view", "gql-pr-review-threads"]
"issue-triage"          = ["repo-issue-view", "repo-issue-comments", "repo-issue-list",
                           "repo-issue-add-label", "repo-issue-remove-label",
                           "repo-issue-comment"]
"issue-stale-sweep"     = ["repo-issue-list", "repo-issue-view", "repo-issue-comment",
                           "repo-issue-close", "repo-issue-reopen"]
"issue-backlog-stats"   = ["repo-issue-list"]
"license-compliance-audit" = ["repo-view", "repo-tree", "repo-file"]
"flaky-test-triage"     = ["run-list", "run-view", "pr-view"]
"release-verify-rc"     = ["release-list", "release-view", "tags", "repo-file"]
"release-rc-cut"        = ["tags", "repo-issue-comment"]
"contributor-nomination" = ["user-profile", "pr-list", "repo-issue-list"]
"mentoring-welcome"     = ["repo-issue-view", "repo-issue-comment", "pr-view"]

mentoring-welcome needs no operation of its own: it works on upstream issues and PRs, which the issue and PR families already cover. A family earns new operations only when it has a shape the catalogue lacks.

Grant the narrowest set that lets a skill finish its job: pr-management-stats is a read-only dashboard, so it gets no write operation at all, and a prompt-injection payload in a PR title cannot talk it into one.

The policy contents are never supplied on the command line: no flag adds a repo, a caller, or an operation. What --config selects is which policy file applies, so a caller’s reach is bounded by the policy files that already exist on disk and by who may write them — not by the absence of a flag. The deny rules and the file’s location are what close that surface; see Wiring it into settings, which points --config at a policy outside the sandbox-writable root on purpose.

When a repo holds more than one policy, bind the flag in the permission rule so the grant names the policy it was reasoned about:

"allow": [
  "Bash(… vetted-op-read --config .apache-magpie-overrides/tools/vetted-ops/config.toml *)"
]

upstream is required; tracker is optional. A project whose skills only touch public work has no tracker to name, and requiring one would fail every operation at load time over a value most of them never read — including upstream-only reads like viewer and pr-diff. Omit it and those keep working; an operation that addresses the tracker refuses by name instead:

vetted-op: refused: this operation addresses the tracker, which the policy
does not configure. Declare `tracker` under [repos] to enable it;
upstream-only operations are unaffected.

The refusal happens in the builder, before any argv exists, so a tracker operation can never fall back to upstream — which is the outcome that would matter, since it would publish embargoed content to a public repository. Absent is allowed; present but malformed is still a load error, because a typo’d repo points operations somewhere unintended.

CLI

vetted-op list-ops                          # the catalogue
vetted-op policy                            # resolved policy, as JSON
vetted-op --caller <name> <operation> [param …]
vetted-op --caller <name> <operation> [param …] --dry-run   # print argv, run nothing

Exit codes: 0 ok, 2 usage, 3 refused by policy or validation, 4 the underlying command failed.

Tracker procedures: rollup and body-field writes

The status rollup and the ### Field sections of a tracker issue body are read-modify-write updates: the write depends on what a read returned. The github-rollup and github-body-field tools do them without bringing the rollup or the body into agent context, but they call gh from a uv run subprocess, which under the secure setup stays sandboxed and fails. These operations are the same procedures as reviewed catalogue entries:

Operation Kind Parameters Same as
rollup-append write number action body github-rollup append
rollup-amend-latest write number action body github-rollup amend-latest
rollup-fold write number comment_id action github-rollup fold
body-field-set write number field value github-body-field set
body-field-get read number field github-body-field get
uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-sync rollup-append 212 "Sync" <scratch>/entry.md
uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-sync rollup-amend-latest 212 "Sync" <scratch>/entry.md
uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-issue-sync rollup-fold 212 2890012345 "Legacy status"
uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker --caller security-cve-allocate body-field-set 212 "CVE tool link" <scratch>/value.md
uv run --project ~/.claude/magpie/vetted-ops vetted-op-read --caller security-issue-sync body-field-get 212 "Severity"

Skills run the four writes through vetted-op-tracker, a third entry point that refuses every operation except these procedures before it reads the policy, the same way vetted-op-read refuses writes. That is what lets it be excluded from the sandbox — the writes need a gh that can verify TLS — while vetted-op is not: its catalogue is closed to tracker-only procedures whose runner refuses any gh call outside repos/<tracker>/. It stays in ask, so every write still prompts. vetted-op can run the same operations, but only inside the sandbox.

  • body and value are body files, read from the workspace like any other body.
  • action is one printable line of 1-80 characters, with no leading - and no <, > or · (the summary-line separator); field is a heading name of 1-80 characters with no leading - and no #.
  • rollup-append attributes the entry to the authenticated gh login and today’s UTC date, and creates the rollup (marker line for the tracker’s name) when the issue has none. It finds a rollup written under any tracker name.
  • rollup-amend-latest refuses unless the latest entry’s action equals action, so a concurrent entry by someone else is never overwritten.
  • rollup-fold keeps the legacy comment’s date and author, left-trims its lines, refuses the rollup itself and a comment belonging to another issue, and deletes the legacy comment only after the append succeeded.
  • body-field-set refuses when the field is absent or appears more than once, and writes nothing when the value is unchanged.
  • Only a one-line summary reaches stderr. On stdout, body-field-get prints the one value, and rollup-append / rollup-amend-latest print the rollup comment’s URL (…#issuecomment-<id>) so a caller can link to it without reading the rollup. Refusals exit 3, a failed gh call exits 4.
  • --dry-run runs the reads (the plan depends on them) and prints each write instead of running it, with any body reported by size, never by content.

The parsing and composing code is vendored byte-for-byte from the two tools (rollup_format.py, body_field_format.py), because the dispatcher installs as a copy of its own directory and cannot import them; a test fails if a copy drifts from its source.

Wiring it into settings

Replace the wildcard ask rules with one allow entry, and keep an ask on anything still invoked directly:

"permissions": {
  // Only the READ dispatcher is allowlisted. Allowlisting `vetted-op` itself
  // would grant every write in the catalogue, since --caller is argv.
  "allow": [
    "Bash(uv run --project ~/.claude/magpie/vetted-ops vetted-op-read *)"
  ],
  "ask": [
    "Bash(uv run --project ~/.claude/magpie/vetted-ops vetted-op *)",
    // The tracker procedures. Excluded from the sandbox below, so never `allow`.
    "Bash(uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker *)",
    "Bash(uvx --from ~/.claude/magpie/vetted-ops vetted-op-tracker *)"
  ],
  "deny": [
    // `Edit(path)` is the path rule for every file-writing tool — Write and
    // NotebookEdit included. A `Write(path)` rule is NOT matched by the file
    // permission check, so adding one buys nothing and reads as coverage the
    // deny list does not have.
    "Edit(~/.claude/plugins/cache/apache-magpie/magpie-vetted-ops/**)",
    "Edit(~/.claude/magpie/**)",
    "Edit(.apache-magpie-overrides/tools/vetted-ops/**)"
  ]
}

The read dispatcher also needs a sandbox.excludedCommands entry, or the allow rule above buys nothing. The framework already excludes bare gh * because a sandboxed gh cannot verify TLS — on macOS it fails with x509: OSStatus -26276. A gh spawned inside uv run … vetted-op-read is not a bare gh invocation, so it stays sandboxed and every read operation fails the same way:

"sandbox": {
  "excludedCommands": [
    "gh *",
    "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read *",
    "uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker *",
    "uvx --from ~/.claude/magpie/vetted-ops vetted-op-tracker *"
  ]
}

vetted-op-read is excluded, never vetted-op — the same reasoning as the allow rule. The read dispatcher refuses writes before it consults the policy at all, so running it outside the sandbox exposes only the fixed read operations; excluding the write dispatcher would run the whole catalogue there. vetted-op-tracker is the one write-capable exception, and it follows the same reasoning: it refuses everything but the tracker procedures before consulting the policy, and their runner refuses any gh call outside repos/<tracker>/, so the exclusion exposes those procedures and nothing else. The tracker itself comes from the policy, which is why the entry point keeps its ask.

Every rule names the fixed path ~/.claude/magpie/vetted-ops, not the versioned plugin-cache directory. The plugin’s SessionStart hook (hooks/link-stable-path.sh) points that path at the installed version each session, so an upgrade changes no rule. Do not glob the version instead (…/magpie-vetted-ops/*/tools/vetted-ops …): a * also matches spaces, so it approves a command with extra uv options spliced in at that position (--with <any package>, a second --from), and the exclusion then runs it outside the sandbox. The fixed path is Edit-denied like the catalogue it points at, and sits outside every sandbox.filesystem.allowWrite root.

The dispatcher must live where the agent cannot rewrite it — otherwise an agent that edits ops.py has defeated the whole design. That is why it ships as the magpie-vetted-ops substrate plugin: the installed plugin tree is not a path the agent edits, and it sits outside every sandbox.filesystem.allowWrite root, so sandboxed Bash cannot write there either.

The deny rules above are the other half, and the allow is not defensible without them. Two surfaces define what the dispatcher may do — the operation catalogue and the policy, which names the caller→operation grants. An agent able to edit either one can grant itself the write access the wildcard ask rules were removed for; adding an op to the catalogue and adding itself to a caller list reach the same place.

Be clear about the asymmetry: the catalogue is protected twice (deny rules plus the sandbox), the policy only once. The policy lives inside the adopter repo, which is sandbox-writable by design, so a Bash-level write (sed -i, a heredoc) is not covered by an Edit/Write deny. If that gap matters, keep the policy outside the writable root and point --config at it. Vendoring the dispatcher itself into a repo the agent edits reduces it to the same single layer.

Tests

uv run --project . python -m pytest

The suite asserts the properties above rather than the plumbing: hostile parameters are refused, every builder produces a gh argv, the repo cannot be influenced by a parameter, body files outside the workspace are rejected while hostile body content passes through untouched, and a caller cannot run an operation outside its manifest.

Referenced by

Suggest a change