feat(consent): add interactive consent and CI mode (Phase 3)#13
Conversation
- Prompt per stdio MCP server before launching it, showing the command and redacted env, and skip servers the user declines - Add --dangerously-run-mcp-servers to start all servers without prompting - Add --ci (requires --dangerously-run-mcp-servers) to exit non-zero on findings or runtime failures, with --ignore-issues-codes to exclude codes - Auto-skip prompts for automated runs (control server with x-client-id) - Gate stdio startup in the pipeline via a consent hook on discovered clients Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds interactive, per-stdio-server consent gating before launching MCP stdio servers as subprocesses, plus a CI mode that converts remaining findings/runtime failures into a non-zero exit code. This extends the scan/inspect pipeline with a consent hook and introduces CLI validation/exit semantics intended for automated environments.
Changes:
- Add
internal/consentprompting + redacted command/env rendering, and wire consent decisions into the pipeline/inspector. - Add CLI flags
--dangerously-run-mcp-servers,--ci, and--ignore-issues-codes, including validation and CI exit behavior. - Update docs and add unit tests for consent prompting, declined-server skipping, flag validation, and CI exit logic.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents new consent/CI flags and interactive behavior. |
| internal/pipeline/pipeline.go | Adds ConsentFn hook and attaches declined-set onto clients before inspection. |
| internal/models/client.go | Introduces ServerRef and ClientToInspect.Declined runtime state. |
| internal/inspect/inspector.go | Skips inspection (and thus subprocess startup) for declined stdio servers. |
| internal/inspect/inspector_test.go | Tests declined-stdio skipping and remote servers unaffected by declines. |
| internal/consent/consent.go | Implements consent UI, deterministic enumeration, shell quoting, and env redaction. |
| internal/consent/consent_test.go | Tests consent allow/decline flow, EOF behavior, quoting, and env redaction. |
| internal/cli/scan.go | Wires consent/CI validation + CI exit evaluation into scan. |
| internal/cli/inspect.go | Wires consent/CI validation + CI exit evaluation into inspect. |
| internal/cli/flags.go | Adds common flags for dangerous-run, CI, and ignore issue codes. |
| internal/cli/consent.go | Implements flag validation, push-key automation detection, consent hook builder, CI exit logic. |
| internal/cli/consent_test.go | Tests flag validation, push-key detection, consent hook branching, and CI exit filtering. |
Comments suppressed due to low confidence (1)
internal/consent/consent.go:95
- When there are no stdio servers (remote-only scan), the summary prints "Proceeding with 0 of 0 stdio servers." which is confusing/no-op. Consider only printing this summary when
len(stdioItems) > 0, or adjusting the summary to mention remote servers instead.
allowed := len(stdioItems) - len(declined)
msg := fmt.Sprintf("\nProceeding with %d of %d stdio servers.", allowed, len(stdioItems))
if len(declined) > 0 {
msg += fmt.Sprintf(" Skipped: %d.", len(declined))
}
fmt.Fprintln(out, msg)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Match the x-client-id control header exactly (case-insensitive) so headers like X-Client-Identity no longer trigger automated mode - Only print the stdio launch banner and proceeding summary when stdio servers are present, avoiding a misleading "0 of 0" message on remote-only scans Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Apply --ignore-issues-codes before formatting so printed output matches the CI evaluation; make ciExitError side-effect free - Restrict push-key detection to headers attached to a configured control server, so stray --control-server-H values cannot mark a run automated - Correct the consent tip: server output is hidden by default, use --suppress-mcpserver-io=false to show it Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
internal/cli/scan.go:47
- The 5-minute context timeout starts before interactive consent prompts run (ConsentFn is invoked inside the pipeline). If a user takes time to review commands/answer prompts, the context may expire and later discovery/inspection will be canceled unexpectedly. Consider starting the timeout only after consent collection (or using a longer/no timeout for interactive runs, while keeping a timeout for CI/non-interactive runs).
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute)
defer cancel()
- Escape control characters and ANSI sequences in displayed server name, command, and env so a malicious config cannot spoof the consent prompt - Skip the overall scan/inspect timeout on interactive runs so a slow consent answer cannot cancel later inspection; keep it for non-interactive runs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
internal/consent/consent.go:141
enumerateappends servers without de-duplicating bymodels.ServerRef. If the same config file is scanned twice (e.g., duplicate CLI paths), the user may be prompted multiple times for the same stdio server and the final decision becomes order-dependent. Consider de-duping stdio/remote items by(configPath, name)before prompting (while preserving deterministic ordering).
for _, client := range clients {
// Deterministic ordering: sort config paths, then server names.
paths := make([]string, 0, len(client.MCPConfigs))
for path := range client.MCPConfigs {
paths = append(paths, path)
}
sort.Strings(paths)
for _, configPath := range paths {
configOrErr := client.MCPConfigs[configPath]
if configOrErr.Error != nil || configOrErr.Config == nil {
continue
}
servers := configOrErr.Config.GetServers()
names := make([]string, 0, len(servers))
for name := range servers {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
switch s := servers[name].(type) {
case *models.StdioServer:
stdioItems = append(stdioItems, stdioItem{configPath, name, s})
case *models.RemoteServer:
remoteItems = append(remoteItems, remoteItem{configPath, name, s})
}
Prompt for each (configPath, name) at most once so duplicate scan paths or multiple clients referencing the same config do not double-prompt or make the decline decision order-dependent. First occurrence wins. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Remove the --suppress-mcpserver-io reference from the consent tip; the flag is not wired to server output, so the suggestion was misleading - Sort clients by path/name before enumeration so duplicate de-duplication and prompt order are stable regardless of discovery order Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reword the doc comment to reflect that the timeout applies whenever no consent prompt blocks on human input (push-key automation or --dangerously-run-mcp-servers), not only non-interactive runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The rendered command is shown for human review, not executed, so note the quoting is POSIX-style for legibility and not Windows-shell correct. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Escape control runes above U+FFFF with \U%08x (defensive; control chars are all <= U+009F today, so the branch is otherwise unreachable) - Clarify applyIgnoreCodes / CI comments only filter issue findings, not runtime-failure X-codes - Clarify in the README that x-client-id automated detection applies to scan Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Phase 3 of the agent-scan → Go port: adds interactive per-server consent before launching stdio MCP servers as subprocesses, and a CI mode that turns findings/runtime failures into a non-zero exit. This is a fully local feature with no backend dependency.
AI Authorship
plan.mdChange classification
internal/pipeline/,internal/inspect/, andinternal/models/(the inspection path used by bothscanandinspect). A bug here affects every scan.Plan reference
Phase 3 of
plan.md("Interactive consent + CI mode"). Scope implemented:internal/consent/—CollectConsent(clients, out, in)mirroring the Pythonconsent.py: enumerate stdio/remote servers, render shell-quoted command + redacted env, prompt[y/N]per stdio server (empty/EOF = decline), auto-allow remote, return declined set.--dangerously-run-mcp-servers(skip prompts, run all),--ci,--ignore-issues-codes.--cirequires--dangerously-run-mcp-servers;--ignore-issues-codesonly valid with--ci(both → exit code 2).ConsentFnhook; inspector skips declined stdio servers (remote always inspected).Context — why Phases 1 & 2 were skipped: the user confirmed this fork's backend has no
client-bootstrap,guard-enabled, orpush-keyendpoints (plan.md Open Question #1), so Bootstrap (Phase 1) and Guard (Phase 2) would be permanently inert. Phase 3 and the upcoming Phase 4 are backend-independent and proceed normally.Interactive detection mirrors agent-scan:
inspectis always interactive;scanis interactive unless a control server carries anx-client-idheader (automated run → prompts auto-skipped).Verification
buildConsentFnbranches; CI exit logic (ignored W001 passes, E-code & runtime-failure fail); inspector skip-declined behaviormake fmt,make lint(0 issues),make test(race + coverage) all pass.scan <cfg> --dangerously-run-mcp-servers→ no prompt, prints the "without prompting" bannerscan <cfg>+ pipedn→ prompts, env shown asKEY=***(secret never echoed), server declined/skippedscan --ci(no--dangerously) → exit 2 with a clear messagescan --ignore-issues-codes W001(no--ci) → exit 2Security check
KEY=***)y/yesallow; everything else (incl. EOF) declinesRisk & rollback
scan/inspectnow prompt before starting stdio servers in interactive runs. Automated/piped runs with no TTY input will decline all stdio servers unless--dangerously-run-mcp-servers(or anx-client-idcontrol-server header) is set. CI pipelines must add--dangerously-run-mcp-servers.Reviewer guide
internal/consent/consent.go— prompt flow, env redaction (no value leakage), shell quoting, EOF=declineinternal/cli/consent.go— flag validation (--ci⇒dangerous;--ignore-issues-codes⇒--ci), interactive/push-key detection, CI exit semantics, theos.Exitpathsinternal/inspect/inspector.go— the declined-stdio skip (and that remote is unaffected)internal/pipeline/pipeline.go—ConsentFnhook placement (runs before any subprocess starts)internal/cli/{scan,inspect,flags}.gowiring,internal/models/client.go(ServerRef+Declinedfield), README Options update, the test files🤖 Generated with Claude Code