Skip to content

feat(ai-hook): enforce MCP permissions natively - #1405

Open
amascia-gg wants to merge 2 commits into
mainfrom
amascia/rust-mcp-policy
Open

feat(ai-hook): enforce MCP permissions natively#1405
amascia-gg wants to merge 2 commits into
mainfrom
amascia/rust-mcp-policy

Conversation

@amascia-gg

Copy link
Copy Markdown
Member

Stacked on #1392. Addresses @clement-tourriere's comment there: "org denies mcp__github__delete_repository, the args have no secret, multiscan is clean, we allow. IMO this needs to be implemented, not skipped."

What it does

The native hook now asks both questions an MCP tool call raises, not one:

  • the arguments, via /v1/multiscan — already there;
  • the call itself, via POST /v1/agent-activity/mcp-activity — new. A {"allowed": false, "reason": ...} blocks with that reason; the scan verdict wins per payload, in payload order, as in _scan_payloads(). A failed request still allows, matching mcp.py.

MCP telemetry therefore reaches the dashboard again.

Why the discovery walk is not ported

parse_mcp_activity needs only ai_config.user and ai_config.servers, and both already sit in the ai_discovery.json that Python writes with a one hour TTL. So the ~2,700-line walk stays in Python and the new ggshield-discovery crate just reads what it left: the inventory, its freshness check (same TTL and skew tolerance as cache.py), and the per-agent server-name resolution.

With a stale or absent inventory the event is declined to ggshield-py via the existing Outcome::Delegate, which walks, submits and rewrites the file — so the next hour runs natively. Never allowed: failing open there would let anyone lift an administrator's block by deleting a cache file. Fail-open stays correct only for a failed request.

Full native discovery is future work, behind the crate boundary — ggshield ai discover, the history backfill and agent activity all read the same file.

Notes

  • Vibe MCP detection lands with the separate Vibe work; any payload already classified as Tool::Mcp flows through this path whatever the agent.
  • The freshness check moved ahead of the payload debounce: handing over replays the same stdin, and a debounce entry we had already written would make ggshield-py exit 0 with no verdict.

Verification

cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace (138 tests) all clean. The equivalence harness prints RESULT: EQUIVALENT, now including a new mcp matrix — 5 agents x {permitted, denied}, comparing the verdict and the activity report body, which is what proves the server name each dialect resolves to is the same on both sides.

@amascia-gg
amascia-gg requested a review from a team as a code owner August 11, 2026 15:23
@amascia-gg amascia-gg self-assigned this Aug 11, 2026
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.98%. Comparing base (3f2c3a2) to head (4bfdbdc).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1405   +/-   ##
=======================================
  Coverage   93.98%   93.98%           
=======================================
  Files         200      200           
  Lines       12638    12638           
=======================================
  Hits        11878    11878           
  Misses        760      760           
Flag Coverage Δ
unittests 93.98% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@amascia-gg
amascia-gg force-pushed the amascia/ggshield-rust-dispatcher branch from 29e2b83 to 9f67833 Compare August 12, 2026 11:11
@amascia-gg
amascia-gg requested a review from a team as a code owner August 12, 2026 11:11
@amascia-gg
amascia-gg force-pushed the amascia/rust-mcp-policy branch 3 times, most recently from 21684c5 to 9b4b3b4 Compare August 12, 2026 12:02
@amascia-gg
amascia-gg force-pushed the amascia/ggshield-rust-dispatcher branch from 365a641 to 614848c Compare August 12, 2026 12:32
@amascia-gg
amascia-gg force-pushed the amascia/rust-mcp-policy branch from 346caf2 to 57e8096 Compare August 12, 2026 13:02
@amascia-gg
amascia-gg force-pushed the amascia/ggshield-rust-dispatcher branch from 614848c to 03b286a Compare August 12, 2026 13:23
@amascia-gg
amascia-gg force-pushed the amascia/rust-mcp-policy branch from 57e8096 to fa44941 Compare August 12, 2026 13:23
@amascia-gg
amascia-gg force-pushed the amascia/ggshield-rust-dispatcher branch 2 times, most recently from 2eb452c to 0610e72 Compare August 17, 2026 09:37
@amascia-gg
amascia-gg force-pushed the amascia/rust-mcp-policy branch 3 times, most recently from 3ef4c4b to b116fcc Compare August 18, 2026 12:16
@amascia-gg
amascia-gg force-pushed the amascia/ggshield-rust-dispatcher branch from 5b4f75b to 2e6ea62 Compare August 18, 2026 12:38
@amascia-gg
amascia-gg force-pushed the amascia/rust-mcp-policy branch from b116fcc to 84490dc Compare August 18, 2026 12:39
@amascia-gg
amascia-gg force-pushed the amascia/ggshield-rust-dispatcher branch from 2e6ea62 to 0dfa9d9 Compare August 18, 2026 13:03
@amascia-gg
amascia-gg force-pushed the amascia/rust-mcp-policy branch from 84490dc to 3464160 Compare August 18, 2026 13:06
Base automatically changed from amascia/ggshield-rust-dispatcher to main August 18, 2026 14:14
amascia-gg and others added 2 commits August 20, 2026 10:42
The native hook only scanned an MCP tool call's arguments for secrets; the call
itself went unchecked, so a tool the organization denies ran anyway. It now
reports every MCP tool call to /v1/agent-activity/mcp-activity and honors the
verdict, like mcp.py.

Splitting a mangled tool name back into (server, tool) is a lookup against
ai_discovery.json rather than a split: every agent joins and mangles the two
halves its own way, and either half can contain the separator. A new
`ggshield-discovery` crate reads that inventory, and when two configuration
names mangle to the same key the last one wins, as Python's dict build does.

The inventory is deliberately not fail-open: without a fresh one the whole event
is handed to the bundled Python implementation, which owns the walk. Allowing
instead would make deleting one cache file enough to lift an administrator's
block. An unreadable token warns rather than allows, since an argument-less MCP
call has nothing else that needs the credential.

Precedence, where this deviates from `_scan_payloads()`: a secret and a denial
are settled per payload, the earlier index winning and a tie going to the scan.
A chunk the API refused — the fail-open `Err` — is reported only when nothing
blocks, so a denial already in hand is not thrown away by a later chunk that
could not be scanned. Python overlaps the two requests on a thread pool and can
lose the denial that way; keeping it is strictly safer, and unobservable from
the equivalence gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Formatting only: the MCP inventory fixture came out of a merge with lines the
formatter splits. `Lint package` was failing on `black` alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@amascia-gg
amascia-gg force-pushed the amascia/rust-mcp-policy branch from 3464160 to 4bfdbdc Compare August 20, 2026 08:44
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