Skip to content

Audit nine more agents, plus what an agent's grants add up to - #23

Open
p4gs wants to merge 11 commits into
OpenVanta:mainfrom
p4gs:feat/multi-agent-support
Open

Audit nine more agents, plus what an agent's grants add up to#23
p4gs wants to merge 11 commits into
OpenVanta:mainfrom
p4gs:feat/multi-agent-support

Conversation

@p4gs

@p4gs p4gs commented Aug 3, 2026

Copy link
Copy Markdown

Summary

Two things here, and they stack: audit coverage for nine more AI coding agents, then a set of checks that look at what an agent's grants add up to rather than judging each rule on its own.

Coverage beyond Claude Code. Codex, Cursor, OpenCode, Antigravity, Pi, Hermes, Copilot, Windsurf, and OpenClaw all accumulate standing permissions in their own config files. Each gets a reader under grantguard/core/agents/, and grants flatten into the same rule strings the existing detectors already classify. Sources are editable only where a rebuild-write is provably safe (strict JSON); TOML, YAML, JSONC, and anything holding secrets is surfaced read-only, since a stdlib rewrite would eat the user's comments. Two small readers (tomlread.py, yamlread.py) cover the formats stdlib does not, so the zero-dependency rule holds.

Claude settings that sit outside permissions.allow. defaultMode, hooks, apiKeyHelper, env, and permissions.additionalDirectories grant standing power that no allow rule mentions, and none of them were being read. bypassPermissions skips the prompt entirely, which means the allow list stops being the boundary at all. A hook is a shell command that fires on its own. These run through the detectors that already exist, so a hook reading a credential store or an env entry holding a literal token gets caught by the patterns already in the file. They report in their own "review by hand" section and --fix never touches them: pruning an allow rule just makes the agent ask again, but deleting a hook changes what the agent does.

MCP servers. A server is a capability the agent holds on every run, and its credentials sit in plaintext in files that often get committed. Claude's own MCP config was not read at all. Now discovered from a project .mcp.json and from ~/.claude.json (top level and per project, plus the enable/disable trust lists), with env, args, headers, and url scanned, including query strings and userinfo. Detection keys on what the value looks like, never on the name of the key holding it, so "API_KEY": "${MY_KEY}" stays quiet. That false-positive case has its own tests, because getting it wrong makes the whole feature unusable.

Combined exposure. The part I think matters most. Reading a credential store is unremarkable. Running curl is unremarkable. An agent holding both, that also reads a repo full of text somebody else wrote, is a different thing, and no per-rule check can see it. Grants now fold into one profile per agent and report when an agent can reach private data and send data off the machine.

The rule that keeps that honest: a denial removes an enabler, not a capability. deny: Bash(curl:*) does not mean data cannot leave while wget, git push, and a remote MCP server are still granted, and a denial scoped to one target (deny: Bash(curl http://169.254.169.254/*)) does not close off curl generally. A capability clears only when every enabler present is denied. A check that one narrow deny could switch off would report clean on a machine that isn't, which is worse than not shipping it, so there are regression tests pinning exactly that. permissions.deny is read for this subtraction only, and is still never classified, flagged, or removed. Agents whose scope layering I could verify get merged into one profile; the rest report per file rather than guessing at a merge.

Two smaller things worth calling out. There's a new INTERCEPT category for settings that reroute the agent's traffic or move data off-box unprompted (ANTHROPIC_BASE_URL, proxy and NODE_OPTIONS overrides, hooks that reach a non-loopback host). And audit now exits non-zero when a source could not be read: an unexamined source is not a clean one, and telling CI otherwise defeats the point of running it there.

Related issue

Partially addresses #11. The combined-exposure layer catches Bash(*) as a high-severity finding, since an unrestricted shell provides both sides of the pairing. But the per-rule detector still classifies Bash(*) as SAFE, and colon-syntax rules (Bash(nc:*)) are still missed, so the normalization that issue asks for is not done here and is worth its own change.

Type of change

  • Bug fix
  • New risk detector / detection improvement
  • New feature
  • Docs / chore

Checklist

  • uv run python -m compileall -q grantguard grantguard.py passes
  • No new runtime dependencies (stdlib only)
  • New secret-matching patterns are also covered by redact()
  • Docs updated if behavior changed
  • I verified the change manually (CLI and/or UI)

Notes on the checklist, since a few deserve detail:

  • Tests. uv run python -m unittest discover -s tests passes 462 tests, up from 88 on main. Every behavior added here has tests, and the ones covering the deny-subtraction rule are the ones I'd read first if you're reviewing this.
  • Redaction. There is no redact() function in the tree anymore; the equivalent is RedactingPatternDetector, and all 12 secret detectors use it, so every secret shape masks on display. Verified in the UI that a plaintext MCP credential renders as <REDACTED> with the raw value absent from the DOM.
  • New category. INTERCEPT is registered everywhere CONTRIBUTING lists: RiskCategory, RISK_CATEGORY_INFO, RISK_CATEGORY_ORDER, both tolerance tables, PATTERN_DETECTORS, and REASONS / REASON_SVG / REASON_ORDER in web/app.js.
  • Manual verification. Ran the CLI against a fixture covering every new finding type and confirmed --fix removed only allow entries, leaving hooks, env, defaultMode, the deny list, and .mcp.json byte-identical. Opened the UI in Chrome and confirmed the new category renders with its label and icon, .mcp.json shows up as a source, and the review tier populates.
  • Front-end tooling. pnpm check reports the same 33 type errors before and after my changes, so nothing new. pnpm fmt:check fails on app.js, but it also fails on main before any of my commits; my three additions come out of oxfmt unchanged, so I left the pre-existing drift alone rather than burying this diff in a whole-file reformat. Happy to do that as a separate cleanup if you want it.

One process note: the combined-exposure code got an adversarial review after I first pushed it, which found four real defects, including the deny-subtraction scope bug described above and a quadratic-time regex reachable from a .mcp.json in a cloned repo. Both are fixed in b36f751 with regression tests. Flagging it because the review was worth more than the original implementation, and the fixes are the parts most worth scrutinizing.

p4gs and others added 11 commits July 18, 2026 07:46
Agent configs arrive as TOML (Codex) and YAML (Hermes), but tomllib only
exists on Python 3.11+ (the project floor is 3.10) and the stdlib has no
YAML parser at all. Both readers cover the narrow subset machine-written
agent configs use and degrade safely: anything out of subset is skipped,
never guessed, so an audit cannot report a value a file does not contain.
Audited TOML/YAML sources are read-only throughout.
New grantguard/core/agents/ package with per-agent PermissionDocument
sources for OpenAI Codex, Cursor, OpenCode, Google Antigravity, Pi, and
Hermes Agent, discovered alongside the Claude Code sources in the default
user-level audit (and at project scope for explicit targets). Grants are
flattened into rule strings and classified by the existing detectors.

Editability follows one rule: rewrite only what provably round-trips.
Strict-JSON grant arrays/maps (Cursor allowlists, Pi trust/settings,
Antigravity grant arrays, OpenCode .json) rebuild the JSON preserving
every unrelated key, exactly like the Claude settings document. TOML,
YAML, JSONC, secret-bearing, and app-state sources are surfaced
read-only, mirroring the ~/.claude.json precedent. Protective entries
(deny/ask rules) are never surfaced or removed. Hermes .env is read for
a fixed set of policy key NAMES only; allowed-user IDs surface as counts
and secret values never enter rule text.

Adds a new AUTONOMY risk category (approvals/review disabled — e.g.
approval_policy = never, approvals.mode: off, approvalMode:
unrestricted, blanket permission = allow, defaultProjectTrust = always)
flagged for removal under both tolerances, with matching detectors for
agent policy grants and network/exfiltration overbreadth.
Three more per-agent permission sources in grantguard/core/agents/:

- GitHub Copilot: the CLI's saved tool-approval store
  (~/.copilot/permissions-config.json) is strict JSON and editable —
  per-directory command/read/write/MCP approvals and allowed directories.
  A commands approval covering several identifiers now removes only the
  flagged identifier, deleting the whole approval object only once it is
  empty, so removing one grant never silently drops its siblings. Allowed
  URLs, trusted folders, MCP servers, and VS Code agent-mode
  chat.tools.* auto-approve keys are surfaced read-only.
- Windsurf: Cascade autoExecutionPolicy (turbo) and command allowlist plus
  MCP servers, read-only (VS Code-fork JSONC settings).
- OpenClaw: exec/elevated/filesystem/channel-admission/gateway/plugin grants
  from the JSON5 openclaw.json (read-only), and the strict-JSON
  exec-approvals.json allowlist (editable), honoring OPENCLAW_CONFIG_PATH /
  OPENCLAW_STATE_DIR and the legacy ~/.clawdbot directory.

AUTONOMY and overbroad detectors extended for each agent's approval-bypass
and network/exfiltration shapes (chat.tools.global.autoApprove, Windsurf
turbo, OpenClaw exec.security=full / open dmPolicy / gateway.auth.mode=none,
blanket Copilot write/read and command-family wildcards, blanket trusted
folders and allowed directories).
GrantGuard audits arbitrary user-authored configs, so a key expected to be
a list but holding a stray string or non-iterable must never crash the audit
or emit per-character junk rules. Add a shared _base.str_list() guard and
route every agent module's list iteration through it, and broaden the
document read-path exception guard to include TypeError. Found by the
per-module test authors while covering the six existing agents; the fix is a
class sweep across all of them, with a regression test.
Add an `agent` field to PermissionDocumentInfo (default "Claude Code")
and stamp per-agent sources with their AGENT_NAME in the discovery
aggregator, surfacing it in the /api/audit JSON. This lets the web UI
group sources by the distinct agent they belong to.
The sidebar Sources list and the All-rules list can now group by the
distinct AI agent each config belongs to, as a collapsible tree:
Agent -> Folder path -> File name -> Permission category (-> rules, in
All rules). A per-session toggle (persisted to localStorage) switches
between the nested tree and the original flat list. In the All-rules
tree, group headers carry a select checkbox that selects/deselects every
editable rule beneath them, showing indeterminate on a partial selection.
Agents/folders/files holding a flagged grant auto-expand on load.

Also de-Claude-Code the now-inaccurate copy (the audit covers nine-plus
agents): scan-scope subtitle, overview/empty-state text, share caption,
and the removal-confirm prompt.
Every check until now judged a single rule string, which answers "should this
entry exist?" but not "what can this agent do?". Three blind spots followed
from that, each closed here.

Combined exposure (new core/capability.py). Grants are folded into one profile
per agent and reported when it can both reach private data and send data off
the machine — the pairing that turns text somebody else wrote into credentials
leaving the box. Each capability tracks the individual grants providing it, so
a denial removes an enabler rather than a capability: denying curl does not
mean data cannot leave while wget, git push, or a remote server are still
granted, and a capability clears only when every enabler present is denied. A
detector one narrow denial could switch off would report clean on a machine
that is not, so there is a regression test pinning exactly that. deny lists are
now read for this subtraction and are still never classified or removed. Agents
whose scope layering is established are merged; others are reported per file
rather than guessing at a merge.

MCP servers (new core/mcp.py). A server is a capability held on every run, and
its credentials sit in plain text in files that are often committed. They are
now discovered from a project .mcp.json and from ~/.claude.json, top level and
per project, including the standing enable/disable trust lists, and each
definition's env, args, headers and url are scanned — query strings and
userinfo included, plus JWTs. Detection keys on the shape of the value and
never on the name of the key holding it, so ${VAR} indirection and omitted
values stay quiet; that false-positive case has its own tests.

Settings beside the allow list (new core/claudepolicy.py). defaultMode, hooks,
apiKeyHelper, env and additionalDirectories grant standing power no allow rule
mentions and were previously unread. They flow through the existing detectors,
so a hook that reads a credential store or an env entry holding a literal token
is caught by patterns that already recognize those shapes. Because these name a
setting rather than a list entry, they are reported in their own review-by-hand
section and excluded from bulk removal: deleting a hook changes what the agent
does, unlike pruning an allow rule, which only makes it ask again.

Also: a new INTERCEPT category for settings that reroute the agent's traffic or
move data off-box unprompted, and audit now exits non-zero when a source could
not be read — an unexamined source is not a clean one, and telling a pipeline
otherwise is the failure this whole change is about.

444 tests pass, up from 365; the three new modules are at 100% statement
coverage. No new runtime dependency.
A review of the previous commit found the headline guarantee did not actually
hold, plus a denial-of-service reachable from a cloned repository. All four are
confirmed by test and fixed here.

A denial now has to be as broad as the grant before it subtracts anything.
Previously any deny string containing the command name cleared the whole
enabler, so `deny: Bash(curl http://169.254.169.254/*)` — blocking curl against
one address — reported that data could not leave at all, and
`deny: WebFetch(domain:curl.se)` did the same by merely containing the word.
Both produced a clean result on a machine that was not clean, which is exactly
what the module docstring promises cannot happen. Denials are now matched in
the position they actually govern, and only subtract when what remains after
the command is a wildcard rather than a specific target. Broad denials still
close a capability, so this does not over-correct into ignoring real ones.

A grant of the whole shell now provides capability. `Bash(*)` previously
contributed nothing to the analysis, so the single most dangerous grant
possible could never raise a combined finding.

Scanning is bounded. enablers_in ran uncapped while the detector registry caps
at MAX_SCAN_LEN, and the loopback exemption used a negative lookahead
containing `.*`, which re-scans from every start position. Together that was
quadratic: a large value in a project .mcp.json, a file that arrives with a
checkout, took seconds to minutes and stalled the audit. The lookaheads are now
literal substring exclusions and the scan is capped; 64 KB went from unusable
to under a millisecond.

Three credential shapes were reported without masking: a token in a named
request header, one in an inline -H argument (hooks are free-form shell and a
likely place for one), and URL parameters beyond the original keyword list —
sig, signature, session, auth, credential, pat. All now redact.

Also corrected an overclaim in the mcp module docstring. It said detection keys
on the shape of the value and never on the name, which read as a stronger
guarantee than the code gives: a high-entropy value under a name carrying no
cue is not detected. The docstring now states that gap plainly instead of
implying it away.

456 tests pass. Every defect above has a regression test.
CONTRIBUTING asks for a test in tests/test_detectors.py when a detector is
added; the coverage for these existed but sat in the per-feature test modules.
This puts it where the guide says to look for it: bucket assertions for the new
category, the loopback exemption, each new credential shape, and the
placeholder and variable-indirection cases that must stay quiet.
@p4gs
p4gs requested a review from a team as a code owner August 3, 2026 15:27
@p4gs

p4gs commented Aug 3, 2026

Copy link
Copy Markdown
Author

Heads up: CI and CodeQL are sitting at action_required since this is a first-time fork contribution, so they need a maintainer to approve the workflow runs before anything executes. Socket and Semgrep ran on their own and passed.

While that waits, I ran CI's own steps locally against both ends of its matrix:

  • Python 3.14: compileall and unittest discover both exit 0, 462 tests pass.
  • Python 3.10: the interpreter isn't installable on this machine, so I checked it the two ways that are left. Every file parses against the 3.10 grammar, and the only 3.11+ stdlib API anywhere in the tree is tomllib, which tomlread.py already guards with a fallback reader. I forced that fallback on (the state 3.10 actually sees) and the full suite still passes, so the path CI takes on 3.10 is exercised rather than assumed.

Only thing I can't cover locally is the Linux and Windows legs.

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