-
Notifications
You must be signed in to change notification settings - Fork 1
feat(consent): add interactive consent and CI mode (Phase 3) #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
1ba1423
feat(consent): add interactive consent and CI mode for scans
appleboy 0a6c14f
fix(consent): exact push-key match and stdio-only banner
appleboy aeb7889
fix(consent): filter ignored codes before output and tighten push-key
appleboy 30c012f
fix(consent): sanitize prompt output and skip timeout when prompting
appleboy eb6c50b
fix(consent): de-duplicate servers by ServerRef before prompting
appleboy d00b261
fix(consent): drop dead flag from tip and sort clients deterministically
appleboy 8e26941
docs(consent): clarify scanContext bounded semantics
appleboy bbf325a
docs(consent): clarify shellQuote is POSIX display-only quoting
appleboy 01774b7
docs(consent): clarify ignore-codes scope and fix wide-rune escape
appleboy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "os" | ||
| "sort" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/go-authgate/agent-scanner/internal/consent" | ||
| "github.com/go-authgate/agent-scanner/internal/models" | ||
| ) | ||
|
|
||
| // scanRunTimeout bounds a non-interactive scan/inspect run. | ||
| const scanRunTimeout = 5 * time.Minute | ||
|
|
||
| // scanContext derives the run context. When bounded (no consent prompt will | ||
| // block on human input — e.g. a push key marks an automated run, or | ||
| // --dangerously-run-mcp-servers skips prompts), it applies scanRunTimeout; | ||
| // otherwise it returns the parent unbounded so interactive consent prompts are | ||
| // not raced by the deadline. Per-server connection timeouts still bound each | ||
| // server either way. | ||
| func scanContext(parent context.Context, bounded bool) (context.Context, context.CancelFunc) { | ||
| if bounded { | ||
| return context.WithTimeout(parent, scanRunTimeout) | ||
| } | ||
| return context.WithCancel(parent) | ||
| } | ||
|
|
||
| // consentExitError carries a process exit code out of a command so the caller | ||
| // can exit with the exact code interactive/CI runs require. | ||
| type consentExitError struct { | ||
| code int | ||
| msg string | ||
| } | ||
|
|
||
| func (e *consentExitError) Error() string { return e.msg } | ||
|
|
||
| // exitOnConsentError prints the message and exits with the carried code when err | ||
| // is a consentExitError; otherwise it does nothing. | ||
| func exitOnConsentError(err error) { | ||
| var ce *consentExitError | ||
| if errors.As(err, &ce) { | ||
| fmt.Fprintln(os.Stderr, ce.msg) | ||
| os.Exit(ce.code) | ||
| } | ||
| } | ||
|
|
||
| // validateConsentFlags enforces the CI flag relationships: | ||
| // - --ci requires --dangerously-run-mcp-servers (CI cannot answer prompts) | ||
| // - --ignore-issues-codes is only valid together with --ci | ||
| // | ||
| // On violation it returns a consentExitError with exit code 2. | ||
| func validateConsentFlags() error { | ||
| if commonFlags.CI && !commonFlags.DangerouslyRun { | ||
| return &consentExitError{ | ||
| code: 2, | ||
| msg: "running with --ci requires --dangerously-run-mcp-servers; " + | ||
| "CI runs start a subprocess for every stdio MCP server, so trust must be confirmed explicitly", | ||
| } | ||
| } | ||
| if commonFlags.IgnoreIssueCodes != "" && !commonFlags.CI { | ||
| return &consentExitError{code: 2, msg: "--ignore-issues-codes can only be used with --ci"} | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // hasPushKey reports whether any configured control server carries an | ||
| // x-client-id header, which marks an automated (non-interactive) run that skips | ||
| // consent prompts. Only headers index-aligned with an actual --control-server | ||
| // count (matching parseControlServers); stray --control-server-H values do not. | ||
| func hasPushKey() bool { | ||
| for _, cs := range parseControlServers() { | ||
| for header := range cs.Headers { | ||
| if strings.EqualFold(header, "x-client-id") { | ||
| return true | ||
| } | ||
| } | ||
|
appleboy marked this conversation as resolved.
|
||
| } | ||
| return false | ||
| } | ||
|
|
||
| // buildConsentFn returns the pipeline consent hook, printing the appropriate | ||
| // banner. It returns nil (run every server, no gating) when prompts are skipped: | ||
| // when --dangerously-run-mcp-servers is set, or when the run is non-interactive | ||
| // (a push key is configured). interactive=true with no dangerous flag yields the | ||
| // interactive prompt collector. | ||
| func buildConsentFn(interactive bool) func([]*models.ClientToInspect) map[models.ServerRef]bool { | ||
| if commonFlags.DangerouslyRun { | ||
| if interactive { | ||
| fmt.Fprint(os.Stderr, | ||
| "--dangerously-run-mcp-servers is set: starting every stdio MCP server "+ | ||
| "in the scanned configs without prompting.\n\n") | ||
| } | ||
| return nil | ||
| } | ||
| if !interactive { | ||
| return nil | ||
| } | ||
| return func(clients []*models.ClientToInspect) map[models.ServerRef]bool { | ||
| return consent.CollectConsent(clients, os.Stderr, os.Stdin) | ||
| } | ||
| } | ||
|
|
||
| // parseIgnoreCodes splits --ignore-issues-codes into a set. | ||
| func parseIgnoreCodes() map[string]bool { | ||
| codes := map[string]bool{} | ||
| for c := range strings.SplitSeq(commonFlags.IgnoreIssueCodes, ",") { | ||
| if c = strings.TrimSpace(c); c != "" { | ||
| codes[c] = true | ||
| } | ||
| } | ||
| return codes | ||
| } | ||
|
|
||
| // applyIgnoreCodes removes issue findings whose code is in --ignore-issues-codes | ||
| // so they are not printed. It filters only Issues; runtime-failure X-codes | ||
| // (surfaced via result/server errors) are not filtered here and may still be | ||
| // shown under --print-errors. No-op outside CI mode; call before formatting. | ||
| func applyIgnoreCodes(results []models.ScanPathResult) { | ||
| if !commonFlags.CI { | ||
| return | ||
| } | ||
| ignore := parseIgnoreCodes() | ||
| if len(ignore) == 0 { | ||
| return | ||
| } | ||
| for i := range results { | ||
| kept := results[i].Issues[:0] | ||
| for _, issue := range results[i].Issues { | ||
| if !ignore[issue.Code] { | ||
| kept = append(kept, issue) | ||
| } | ||
| } | ||
| results[i].Issues = kept | ||
| } | ||
| } | ||
|
|
||
| // ciExitError returns a consentExitError (exit code 1) if, in CI mode, any issue | ||
| // or runtime-failure code remains after --ignore-issues-codes. It does not | ||
| // mutate results; call applyIgnoreCodes first to filter the printed output. | ||
| func ciExitError(results []models.ScanPathResult) error { | ||
| if !commonFlags.CI { | ||
| return nil | ||
| } | ||
| ignore := parseIgnoreCodes() | ||
|
|
||
| remaining := map[string]bool{} | ||
| for i := range results { | ||
| for _, issue := range results[i].Issues { | ||
| if issue.Code != "" && !ignore[issue.Code] { | ||
| remaining[issue.Code] = true | ||
| } | ||
| } | ||
| // Runtime failures surface as X-codes on the path and its servers. | ||
| if results[i].Error != nil && results[i].Error.IsFailure { | ||
| if code := models.ErrorToIssueCode(results[i].Error.Category); !ignore[code] { | ||
| remaining[code] = true | ||
| } | ||
| } | ||
| for _, s := range results[i].Servers { | ||
| if s.Error != nil && s.Error.IsFailure { | ||
| if code := models.ErrorToIssueCode(s.Error.Category); !ignore[code] { | ||
| remaining[code] = true | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if len(remaining) == 0 { | ||
| return nil | ||
| } | ||
| codes := make([]string, 0, len(remaining)) | ||
| for c := range remaining { | ||
| codes = append(codes, c) | ||
| } | ||
| sort.Strings(codes) | ||
| return &consentExitError{ | ||
| code: 1, | ||
| msg: fmt.Sprintf( | ||
| "CI (--ci): exiting with code 1 (issue codes: %s)", | ||
| strings.Join(codes, ", "), | ||
| ), | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.