Skip to content
Merged
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,19 @@ agent-scanner inspect
--print-full-descriptions Show full entity descriptions
--analysis-url URL Remote verification server URL
--control-server URL Upload results to control server
--dangerously-run-mcp-servers Start every stdio MCP server without consent prompts
--ci Exit non-zero on findings/failures (requires --dangerously-run-mcp-servers)
--ignore-issues-codes CODES Comma-separated issue codes to ignore for the CI exit (only with --ci)
```

By default, `scan` and `inspect` prompt before launching each stdio MCP server as
a subprocess (the command and redacted env are shown). Pass
`--dangerously-run-mcp-servers` to start them all without prompting — required for
non-interactive `--ci` runs. When `scan` is given a control server with an
`x-client-id` header, the run is treated as automated and prompts are skipped
(`inspect` has no control servers, so it always prompts unless
`--dangerously-run-mcp-servers` is set).

Comment thread
appleboy marked this conversation as resolved.
### JSON output

```bash
Expand Down
187 changes: 187 additions & 0 deletions internal/cli/consent.go
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
}
}
Comment thread
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, ", "),
),
}
}
Loading
Loading