Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Combined-exposure analysis.** Every check until now judged one rule at a
time, which cannot answer what an agent adds up to. GrantGuard now folds an
agent's grants into one capability profile and reports when it can reach
private data *and* send data off the machine — the pairing that turns text
somebody else wrote into your credentials leaving the box. Each capability
tracks the individual grants providing it, so denying one command does not
clear a capability other grants still supply; a capability closes only when
every enabler present is denied. `permissions.deny` is now read for this
subtraction, and is still never classified, flagged, or removed. Agents whose
scope layering is established are folded into one profile; others are
reported per file rather than guessing at a merge. New
`grantguard/core/capability.py`.
- **MCP servers are audited.** An MCP server is a capability the agent holds on
every run, and its credentials sit in plain text in files that are often
committed. GrantGuard now discovers them from a project `.mcp.json` and from
`~/.claude.json` (top level and per project), including the standing
enable/disable trust lists, and scans each definition's `env`, `args`,
`headers`, and `url` — query strings and userinfo included. New secret
patterns cover URL-embedded credentials and JWTs. Detection keys on the shape
of the value, never the name of the key holding it, so `"${MY_KEY}"` and
omitted values stay quiet. New `grantguard/core/mcp.py`.
- **Claude settings outside `permissions.allow` are audited.** `defaultMode`
(`bypassPermissions` / `acceptEdits`), `hooks`, `apiKeyHelper`, `env`, and
`permissions.additionalDirectories` grant standing power that 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 the patterns that already recognize those
shapes. New `grantguard/core/claudepolicy.py`.
- New `INTERCEPT` risk category for settings that reroute the agent's traffic
or move data off-box unprompted (`ANTHROPIC_BASE_URL`, proxy and
`NODE_OPTIONS` overrides, and hooks that reach a non-loopback host).

### Changed
- Findings that name a *setting* rather than a list entry are reported in their
own "review by hand" section and are excluded from bulk removal: deleting a
hook or an `env` entry changes what the agent does, unlike pruning an
accumulated allow rule, which only makes it ask again. `audit --fix` leaves
them untouched.
- `audit` exits non-zero when there are review or combined-exposure findings,
not only flagged rules — a check that reports something must not tell a CI
pipeline the machine is clean.

### Added
- Three more agents added to the multi-agent audit: **GitHub Copilot** (CLI
`~/.copilot/permissions-config.json` saved tool approvals — editable — plus
read-only `settings.json` allowed URLs, `config.json` trusted folders, MCP
servers, and VS Code agent-mode `chat.tools.*` auto-approve keys),
**Windsurf** (Cascade `autoExecutionPolicy` / command allowlist and MCP
servers, read-only), and **OpenClaw** (JSON5 `openclaw.json` exec/elevated/
channel-admission/gateway/plugin grants, read-only; strict-JSON
`exec-approvals.json` allowlist, editable). AUTONOMY detectors extended for
each (`chat.tools.global.autoApprove`, Windsurf `turbo`, OpenClaw
`exec.security = full` / open `dmPolicy` / `gateway.auth.mode = none`).
- Multi-agent support: the default audit now also inspects the standing-permission
surfaces of six additional AI coding agents when present — OpenAI Codex
(`~/.codex/config.toml`, execpolicy `rules/*.rules`), Cursor (CLI
`cli-config.json` / `cli.json`, IDE `permissions.json`), OpenCode
(`opencode.json[c]` user/project/managed `permission` blocks), Google
Antigravity (`~/.gemini/config/` grants, policies, MCP registry), Pi
(project-trust store and settings grants), and Hermes Agent (`config.yaml`
allowlist/approvals, `.env` policy keys — names only, never secret values).
New `grantguard/core/agents/` package; sources are editable only where a
rebuild-write is provably safe (strict JSON), read-only otherwise.
- New risk category **AUTONOMY** (🤖 "Disables approval/review — unrestricted
autonomy") for grants that turn off human review entirely — e.g. Codex
`approval_policy = never` / `sandbox_mode = danger-full-access`, Hermes
`approvals.mode: off` / YOLO env, Cursor `approvalMode: unrestricted`,
OpenCode blanket `permission = allow`, Pi `defaultProjectTrust = always`,
Antigravity eager auto-execution. Flagged **remove** under both tolerances.
- Dependency-free minimal TOML and YAML readers (`core/tomlread.py`,
`core/yamlread.py`) so agent configs parse on Python 3.10 with no
third-party packages; both degrade safely (skip, never guess) on
out-of-subset syntax.

## [0.1.0] - 2026-07-07

Initial release.
Expand Down
65 changes: 62 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@

# GrantGuard

Audit, review, and clean up your Claude Code standing permissions.
Audit, review, and clean up your AI coding agents' standing permissions.

GrantGuard audits the permission allowlists that AI coding agents build up as you click
"always allow" — Claude Code first and foremost, plus the standing-permission surfaces of
OpenAI Codex, Cursor, OpenCode, Google Antigravity, Pi, Hermes Agent, GitHub
Copilot, Windsurf, and OpenClaw (see [Additional agents](#additional-agents)).

GrantGuard audits the permission allowlist that Claude Code builds up as you click "always allow."
Hidden away in settings files that are rarely audited, these permissions strings can contain
Expand Down Expand Up @@ -153,13 +158,41 @@ flagged rules selected by the active tolerance. Managed settings and

| Category | Verdict | Example |
|---|---|---|
| 🔑 Inline credential or API key | remove | `curl -H "Authorization: Bearer <token>" …` |
| 🔑 Inline credential or API key | remove | `curl -H "Authorization: Bearer <token>" …`, a token in an MCP server's `env`, `args`, `headers`, or `url` |
| 🗝️ Credential-store read | remove | `security find-generic-password *` (macOS), `secret-tool …` (Linux), `cmdkey` (Windows) |
| 🤖 Approvals/review disabled | remove | `approval_policy = never` (Codex), `approvals.mode = off` (Hermes), `approvalMode = unrestricted` (Cursor), blanket `permission = allow` (OpenCode), `defaultMode: bypassPermissions` (Claude Code) |
| 🕳️ Traffic rerouted / data sent out | remove | `ANTHROPIC_BASE_URL`, `HTTPS_PROXY` or `NODE_OPTIONS` in `env`; a hook that `curl`s a non-loopback host |
| 💣 Destructive wildcards | remove | `git reset *`, `rm -rf …`, `pkill` |
| 🚀 Unprompted remote push | remove | `git push *` |
| 🌫️ Overly broad wildcards | review | `npm install *`, `gh api *` |
| ✅ Scoped or read-only | keep | `Bash(npm run build)`, `Read(...)` |

### Beyond the allow list

Two kinds of finding don't fit the table above, because they aren't about a
single entry in a list.

**Settings that grant standing power.** `permissions.defaultMode`, `hooks`,
`apiKeyHelper`, `env`, and `permissions.additionalDirectories` sit beside the
allow list and grant as much as it does or more — a hook is a shell command the
agent runs on its own, and an `env` entry can hold a live credential or point
the agent's API traffic somewhere else. These are reported in a **review by
hand** section and are never removed by `--fix`: deleting one changes what the
agent does, unlike pruning an allow rule, which only makes it ask again.

**Combined exposure.** Some risk only exists in aggregate. An agent that can
reach private data, is steered by content it did not author, and can send data
outward is exposed in a way that none of those grants is alone — reading a
credential store is unremarkable, running `curl` is unremarkable, holding both
is not. GrantGuard folds each agent's grants into one profile and reports that
pairing, naming the specific grants providing each side.

A denial removes an enabler, not a capability: `deny: Bash(curl:*)` does not
mean data cannot leave while `wget`, `git push`, or a remote MCP server are
still granted. A capability is only cleared when every grant providing it is
denied — a check that one narrow denial could switch off would report a clean
result on a machine that isn't.

Use `--tolerance permissive` to treat the "review" category as safe and act only
on the "remove" categories.

Expand Down Expand Up @@ -189,12 +222,38 @@ GrantGuard is scoped to local Claude Code permission allowlists. It reads settin
| Home-local grants | `~/.claude/settings.local.json` | Yes, by default and when passed explicitly | Yes, but only selected `permissions.allow` entries after confirmation or `--fix` |
| Project shared settings | `<repo>/.claude/settings.json` | Yes, when a target repo is passed explicitly or discovered with `--scan` / `--deep-scan` | Yes, but only selected `permissions.allow` entries after confirmation or `--fix` |
| Project local settings | `<repo>/.claude/settings.local.json` | Yes, when a target repo is passed explicitly or discovered with `--scan` / `--deep-scan` | Yes, but only selected `permissions.allow` entries after confirmation or `--fix` |
| Claude state file | `~/.claude.json` | Yes, by default and during broad `--deep-scan`; top-level `allowedTools` and `projects[*].allowedTools` are surfaced, and other state is ignored | No; GrantGuard reports this as read-only because the file also contains unrelated Claude Code state |
| Claude state file | `~/.claude.json` | Yes, by default and during broad `--deep-scan`; top-level and per-project `allowedTools` and `mcpServers` are surfaced, along with the `enabledMcpjsonServers` / `disabledMcpjsonServers` trust lists; other state is ignored | No; GrantGuard reports this as read-only because the file also contains unrelated Claude Code state |
| Project MCP servers | `<repo>/.mcp.json` | Yes, when a target repo is passed explicitly or resolved as the project root | No; this file is normally committed, so its servers are the project's declared dependencies rather than grants this machine accumulated |
| File-based managed settings | macOS `/Library/Application Support/ClaudeCode/managed-settings.json`; Linux/WSL `/etc/claude-code/managed-settings.json`; Windows: GrantGuard currently checks `C:\ProgramData\ClaudeCode\managed-settings.json` | Yes, if the platform-specific file GrantGuard knows about exists | No; GrantGuard reports recognized managed settings as read-only |
| File-based managed drop-ins | `managed-settings.d/*.json` beside `managed-settings.json` | No | No |
| Server-managed settings | Delivered by the Claude.ai admin console, with no local JSON file to inspect | No | No |
| MDM / OS policy settings | macOS `com.anthropic.claudecode` managed preferences; Windows `HKLM` / `HKCU` policy registry | No | No |

## Additional agents

Beyond Claude Code, the default audit also inspects the user-scope standing-permission
surfaces of six other AI coding agents when their config files exist. Grants are
flattened into rule strings and classified by the same detectors; sources are editable
only where a rebuild-write is provably safe (strict JSON), and are otherwise surfaced
read-only (TOML/YAML/JSONC configs, whose comments a stdlib rewrite would destroy, and
secret-bearing files).

| Agent | Source | What is audited | Editable? |
|---|---|---|---|
| OpenAI Codex | `~/.codex/config.toml`, `~/.codex/rules/*.rules` | `approval_policy`, `sandbox_mode`, workspace-write network/roots, per-project `trust_level`, profile overrides; execpolicy `allow` rules | No (read-only) |
| Cursor | `~/.cursor/cli-config.json`, `~/.cursor/permissions.json`, `<repo>/.cursor/cli.json`, `<repo>/.cursor/permissions.json` | CLI `permissions.allow` patterns, `approvalMode`, sandbox/web-fetch policy; IDE `terminalAllowlist`, `mcpAllowlist`, auto-run instructions | Allowlists yes; policy read-only |
| OpenCode | `~/.config/opencode/opencode.json[c]`, `<repo>/opencode.json[c]`, `<repo>/.opencode/opencode.json[c]`, managed config | `permission` blocks (blanket, per-tool, and glob-pattern maps), per-agent overrides | `.json` yes; `.jsonc`/managed read-only |
| Google Antigravity | `~/.gemini/config/config.json`, `~/.gemini/config/projects/*.json`, `~/.gemini/config/mcp_config.json` | `allowedCommands`, permission grants, auto-execution/review/internet policies, registered MCP servers | Grant arrays yes; policy/projects/MCP read-only |
| Pi | `~/.pi/agent/trust.json`, `~/.pi/agent/settings.json` | Project-trust grants (incl. blanket ancestor trust), `defaultProjectTrust`, standing package/extension/skill loads | Yes |
| Hermes Agent | `~/.hermes/config.yaml`, `~/.hermes/.env` | `command_allowlist`, approvals mode/cron auto-approve, subagent auto-approve; YOLO/allow-all-users env policy keys (never secret values) | No (read-only) |
| GitHub Copilot | `~/.copilot/permissions-config.json`, `~/.copilot/settings.json`, `~/.copilot/config.json`, `~/.copilot/mcp-config.json`, VS Code `settings.json` (user + workspace), repo `.github/copilot/settings.json` | CLI saved tool approvals (commands, blanket read/write, MCP, allowed dirs), allowed URLs, trusted folders, MCP servers; VS Code `chat.tools.global.autoApprove` and `chat.tools.terminal.autoApprove` | CLI `permissions-config.json` yes; JSONC/state/VS Code read-only |
| Windsurf | `~/Library/Application Support/Windsurf/User/settings.json`, `~/.codeium/windsurf/mcp_config.json` | Cascade `autoExecutionPolicy` (turbo), `cascadeCommandsAllowList`, MCP servers | No (read-only) |
| OpenClaw | `~/.openclaw/openclaw.json`, `~/.openclaw/exec-approvals.json` | `tools.exec` security/ask, elevated exec, tool allowlist, filesystem scope, channel admission (`dmPolicy: open`), gateway auth mode, node auto-pair CIDRs, plugin trust; exec-approval allowlist patterns | `exec-approvals.json` allowlist yes; JSON5 config read-only |

Protective entries (deny lists, `ask` rules) are never flagged or removed. Project-scope
agent files are audited when a target directory is passed explicitly. Deny-list and
tolerance semantics match the Claude Code audit.

## Privacy

GrantGuard runs entirely on your machine. The UI server binds to `127.0.0.1`, makes no
Expand Down
45 changes: 39 additions & 6 deletions grantguard/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from .core import audit as audit_core
from .core import sources
from .core import capability
from .core.tolerance import tolerance_from_name
from .core.types import (
RISK_CATEGORY_INFO, RISK_CATEGORY_ORDER, RemovalStatus, RuleReadStatus,
Expand Down Expand Up @@ -95,15 +96,15 @@ def _select_documents(args):
def run(argv=None):
ap = add_audit_args(argparse.ArgumentParser(
prog="grantguard",
description="🛡️ GrantGuard — audit & clean your Claude Code permission allowlist",
description="🛡️ GrantGuard — audit & clean your AI coding agents' permission allowlists",
))
return run_args(ap.parse_args(argv))


def run_args(args):
"""Execute an audit from an already-parsed args namespace; return exit code."""
print("═" * 70)
print("🛡️ GRANTGUARD — Claude Code allowlist audit")
print("🛡️ GRANTGUARD — AI agent allowlist audit")
print(f" {'FIX (writing changes)' if args.fix else 'dry run — no changes'}")

try:
Expand All @@ -124,7 +125,7 @@ def run_args(args):
elif targets:
print(" targets:", ", ".join(targets))
else:
print(" inspecting user-level Claude settings sources")
print(" inspecting user-level agent settings sources")

report = audit_core.audit_documents(documents, tolerance, project_root=None)
print(f" platform: {report.platform}")
Expand All @@ -138,6 +139,8 @@ def run_args(args):

total_removed, any_secret, total_flagged, write_failed = 0, False, 0, False
editable_flagged_after_fix = 0
unreadable = sum(1 for da in report.document_audits
if da.read_result.status is RuleReadStatus.ERROR_FILE_IO)
for da in report.document_audits:
flagged = da.flagged()
total_flagged += len(flagged)
Expand All @@ -148,17 +151,47 @@ def run_args(args):
editable_flagged_after_fix += len(flagged) - removed
write_failed = True

review = report.needs_review()
if review:
print("─" * 70)
print("🔎 REVIEW BY HAND — settings that grant standing power")
print(" These name a setting rather than a list entry, so a fix never")
print(" removes them for you; changing one changes what the agent does.")
for assessment in review:
info = RISK_CATEGORY_INFO[assessment.category]
print(f" {info.emoji} {assessment.display_text}")

combined = capability.analyze_report(report)
if combined:
print("─" * 70)
print("🧮 COMBINED EXPOSURE — risk from grants held together")
for finding in combined:
print(f" [{finding.severity.upper()}] {finding.summary}")
for line in finding.why():
print(f" · {line}")
print(" No single grant above is the problem; holding them at once is.")

if unreadable:
print("─" * 70)
print(f"⚠️ {unreadable} source(s) could not be read and were NOT audited.")
print(" These are unexamined, not clean — resolve them before trusting this run.")

print("═" * 70)
if args.fix:
print(f"✅ Removed {total_removed} rule(s) across {len(report.document_audits)} source(s).")
if any_secret:
print("🔴 Live credentials removed — ROTATE them; deletion doesn’t un-leak them.")
print("═" * 70)
return 1 if write_failed or editable_flagged_after_fix else 0
print(f"Found {total_flagged} flagged rule(s) across {len(report.document_audits)} source(s). "
return 1 if (write_failed or editable_flagged_after_fix or unreadable) else 0
print(f"Found {total_flagged} flagged rule(s), {len(review)} to review by hand, and "
f"{len(combined)} combined-exposure finding(s) across "
f"{len(report.document_audits)} source(s). "
f"Re-run with --fix to remove editable findings, or use the UI: grantguard ui")
print("═" * 70)
return 1 if total_flagged else 0 # non-zero = drift (handy for CI)
# Non-zero = drift (handy for CI). Review findings, combined findings, and
# unreadable sources all count: exiting 0 while any of them stands would
# tell a pipeline the machine is clean when it was never fully examined.
return 1 if (total_flagged or review or combined or unreadable) else 0


if __name__ == "__main__":
Expand Down
Loading