Documentation
¶
Index ¶
- Constants
- Variables
- func CategorizeToolName(name string) string
- func EscalationReasonText(result ClassificationResult) string
- func ExpandEnvVars(cmd string, env map[string]string) string
- func ExtractInnerCommand(prog string, args []string) string
- type ClassificationContext
- type ClassificationDecision
- type ClassificationResult
- type Classifier
- type CommandCriteria
- type CommandInfo
- type EscalationCategory
- type ParsedCommand
- type PermissionRequestPayload
- type PythonInfo
- type RiskLevel
- type Rule
- type RuleBasedClassifier
- func (c *RuleBasedClassifier) AddRules(rules []Rule)
- func (c *RuleBasedClassifier) BuildContext(cwd string) ClassificationContext
- func (c *RuleBasedClassifier) Classify(payload PermissionRequestPayload, ctx ClassificationContext) ClassificationResult
- func (c *RuleBasedClassifier) ReplaceRules(rules []Rule)
- func (c *RuleBasedClassifier) Rules() []Rule
- type RuleSource
- type SecurityFinding
Constants ¶
const ( // ToolCategoryAny matches any tool (empty string — default behaviour). ToolCategoryAny = "" // ToolCategoryBuiltin matches any Claude Code built-in tool (no "__" in name). // Examples: Bash, Read, Write, Edit, Glob, Grep, Task, WebFetch, WebSearch, ToolSearch. ToolCategoryBuiltin = "builtin" // ToolCategoryBuiltinAgent matches planning / task-management built-ins that pose no risk. // Examples: ExitPlanMode, EnterPlanMode, AskUserQuestion, TodoWrite, Task*, Skill, NotebookEdit. ToolCategoryBuiltinAgent = "builtin-agent" // ToolCategoryMCP matches any MCP tool (name contains "__"). ToolCategoryMCP = "mcp" // ToolCategoryMCPRead matches MCP tools whose operation names are read-only. // Determined by CategorizeToolName; covers context7, sequential-thinking, and // filesystem/repomix read operations. ToolCategoryMCPRead = "mcp-read" // ToolCategoryMCPWrite matches MCP tools whose operation names mutate state. ToolCategoryMCPWrite = "mcp-write" )
ToolCategory constants classify tool names into coarse groups for use in Rule.ToolCategory. This lets seed rules match whole classes of tools without fragile long regex patterns.
const ( // RuleIDNewDomainCheck is emitted by the domain-age escalation branch in // approval_handler.go. RuleIDNewDomainCheck = "new-domain-check" // RuleIDSecretScan is emitted by the plaintext secret scan auto-deny // branch in approval_handler.go. RuleIDSecretScan = "secret-scan" // RuleIDShellExpansionProgram is emitted by classifier.go when a command's // program cannot be statically determined because it is a shell expansion. RuleIDShellExpansionProgram = "shell-expansion-program" // RuleIDUnexpectedDecision is synthetic — it is never emitted by the // classifier itself. It is set only by HandlePermissionRequest's default: // arm (Epic 2.1) when a ClassificationResult's Decision doesn't match any // expected value, to distinguish an internal classifier bug from a // genuine no-match coverage gap. RuleIDUnexpectedDecision = "internal-unexpected-decision" )
Shared sentinel RuleID constants. These are the single source of truth for the RuleID literals emitted at the escalation/auto-deny call sites in approval_handler.go and classifier.go, so a future rename is a single edit instead of a silent categorization drift.
Variables ¶
var PythonPrograms = map[string]bool{ "python": true, "python3": true, "python2": true, "pypy": true, "pypy3": true, }
PythonPrograms is the set of program names that invoke a Python interpreter.
Functions ¶
func CategorizeToolName ¶
CategorizeToolName returns the ToolCategory constant for a given tool name. The classification uses Claude Code naming conventions:
- MCP tools follow the pattern "mcp__<server>__<operation>" (contains "__").
- Built-in tools never contain "__".
- Agent tools are a named subset of built-ins.
func EscalationReasonText ¶ added in v1.41.0
func EscalationReasonText(result ClassificationResult) string
EscalationReasonText returns the human-readable sentence explaining why a result was escalated. It returns result.Reason verbatim when present. When Reason is empty, it falls back to a category-aware sentence — an empty Reason on a real rule match (explicit-rule/domain-age/unclassifiable) must never render the no-match sentence, since that would falsely claim no rule matched.
func ExpandEnvVars ¶ added in v1.35.0
ExpandEnvVars replaces $VAR and ${VAR} references in cmd, mirroring Python's os.path.expandvars behaviour:
- env map (caller overrides) — checked first.
- OS environment (os.LookupEnv) — fallback, so real env vars work without having to enumerate them in the map.
- Unknown variables are left verbatim (original $VAR / ${VAR} form preserved).
Command substitutions ($(...)) are not expanded — only simple variable references.
func ExtractInnerCommand ¶ added in v1.35.0
ExtractInnerCommand extracts the inner command string from a recursive-eval wrapper invocation (e.g. xargs, parallel, timeout, sudo, nice, env, rtk). It skips the wrapper's own flags (and value tokens for flagArgs entries), then any skipPositionals tokens, then any env-var assignments (when skipEnvAssignments is true), then any leading passthroughSubcmds token. Returns the remaining tokens joined as a string.
Returns "" when:
- prog is not in recursiveEvalPrograms
- no inner command remains after skipping flags/positionals/env-vars
- a parallel input separator (:::) appears before any inner command token
Types ¶
type ClassificationContext ¶
type ClassificationContext struct {
Cwd string
IsGitRepo bool
RepoRoot string
IsWorktree bool
// Env is an optional map of environment variable names to values used to expand
// $VAR and ${VAR} references in Bash commands before classification. This is
// primarily useful in tests and CI to evaluate commands that contain dynamic
// variables (e.g. BRANCH=main "git checkout $BRANCH" → "git checkout main").
// Variables not present in the map are left unexpanded.
Env map[string]string
// CIStatus is the requesting session's GitHub CI check conclusion:
// "success"/"failure"/"pending"/"neutral"/"". "" means no PR or unknown
// (including stale data — see ApprovalHandler's staleness guard).
CIStatus string
// SessionIdleMinutes is the computed idle-minutes value for the session being
// classified. 0 (unset) means unknown/unavailable and must never accidentally
// satisfy a MinSessionIdleMinutes > 0 condition — see ApprovalHandler's population
// logic for how this is populated from a live instance.
SessionIdleMinutes int
}
ClassificationContext provides local-environment context to the classifier.
type ClassificationDecision ¶
type ClassificationDecision int
ClassificationDecision is the action taken by the classifier.
const ( // AutoAllow bypasses the manual review queue and immediately allows the request. AutoAllow ClassificationDecision = iota // AutoDeny immediately denies the request, optionally suggesting an alternative. AutoDeny // Escalate sends the request to the manual review queue for human review. Escalate )
type ClassificationResult ¶
type ClassificationResult struct {
Decision ClassificationDecision
RiskLevel RiskLevel
Reason string
Alternative string
RuleID string
RuleName string
Source string // rule source: "seed", "user", or "claude-settings"
}
ClassificationResult holds the outcome of classifying a tool use request.
type Classifier ¶
type Classifier interface {
Classify(payload PermissionRequestPayload, ctx ClassificationContext) ClassificationResult
BuildContext(cwd string) ClassificationContext
}
Classifier classifies a PermissionRequestPayload to determine the action to take.
type CommandCriteria ¶
type CommandCriteria struct {
// Programs lists the allowed primary programs. Empty means any program matches.
// Prefix matching handles versioned interpreters (e.g., "python3" matches "python3.11").
Programs []string
// Subcommands lists allowed subcommand values. Empty means any (or no) subcommand matches.
// For deep-subcommand programs (gh, aws, etc.) multi-word entries are supported ("pr view").
Subcommands []string
// BlockedSubcommands lists subcommands that prevent this rule from matching.
BlockedSubcommands []string
// RequiredFlags: at least one of the listed flags must be present in the command args.
// Uses exact token matching (e.g., RequiredFlags: ["--hard"] matches git reset --hard only).
RequiredFlags []string
// RequiredFlagPrefixes: like RequiredFlags but uses prefix matching.
// Useful when a flag accepts an optional inline value (e.g., sed -i.bak satisfies prefix "-i").
RequiredFlagPrefixes []string
// ForbiddenFlags: if any of these flags appear in args, the rule does not match.
ForbiddenFlags []string
// PythonModes restricts matching to specific Python invocation modes.
// Valid values: "inline" (-c), "module" (-m), "version" (-V/--version), "script" (*.py).
// Empty means no Python-mode check is performed.
PythonModes []string
// SafePythonImportsOnly restricts inline Python (-c) matches to commands whose
// import statements use only known-safe stdlib modules (see safeStdlibModules).
// When true the rule will not match if any import is outside the safelist, or if
// the invocation is not inline. Combine with PythonModes: ["inline"].
SafePythonImportsOnly bool
// RedirectionPattern matches against any file paths targeted by shell redirections.
RedirectionPattern *regexp.Regexp
}
CommandCriteria provides structured, composable matching criteria for Bash commands. It is evaluated against a ParsedCommand and allows precise rules without complex regex. When multiple fields are set, all must match (AND semantics).
func (*CommandCriteria) Matches ¶
func (cc *CommandCriteria) Matches(pc ParsedCommand) bool
Matches returns true if pc satisfies all criteria fields.
type CommandInfo ¶
type CommandInfo struct {
// Program is the primary executable being invoked (first non-env-var, non-wrapper token).
Program string
// Subcommand is the first positional argument after the program, if it looks like a
// subcommand (i.e., does not start with '-').
Subcommand string
// Category classifies Program into a high-level category (e.g., "vcs", "runtime").
Category string
// AllPrograms contains all distinct programs found across the full command line,
// including across pipes, semicolons, and logical operators.
AllPrograms []string
}
CommandInfo contains parsed information extracted from a Bash command string.
func ParseBashCommand ¶
func ParseBashCommand(command string) CommandInfo
ParseBashCommand extracts structured categorization information from a Bash command. It uses the mvdan.cc/sh AST parser (via ExtractAllCommands) as the primary path, which correctly handles subshells, pipelines, compound commands, and env-var prefixes. On parse error, ExtractAllCommands falls back to splitCommandParts automatically.
The primary program and subcommand are taken from the first CallExpr in the AST. AllPrograms collects all distinct programs across the full command.
type EscalationCategory ¶ added in v1.41.0
type EscalationCategory string
EscalationCategory classifies why a request was escalated for manual review, independent of which code path (classifier rule match, classifier no-match fallback, or the domain-age synthetic escalation) produced the escalation.
const ( // EscalationNoMatch means no rule matched the request; the classifier's // default-escalate fallback applied. EscalationNoMatch EscalationCategory = "no-match" // EscalationExplicitRule means a named rule (seed, user, or claude-settings // sourced) explicitly matched and its Decision was Escalate. EscalationExplicitRule EscalationCategory = "explicit-rule" // EscalationDomainAge means the domain-age checker flagged a newly // registered domain referenced by the command. EscalationDomainAge EscalationCategory = "domain-age" // EscalationSecretScan means the plaintext secret scanner flagged the // command. (Secret scan results are normally AutoDeny, not Escalate, but // the RuleID is shared taxonomy so it is categorized here too.) EscalationSecretScan EscalationCategory = "secret-scan" // EscalationUnclassifiable means the command's actual executable could not // be statically determined (e.g. a shell-expansion program). EscalationUnclassifiable EscalationCategory = "unclassifiable" // EscalationUnexpected means an internal classifier bug produced a result // that doesn't fit any known escalation path — see RuleIDUnexpectedDecision. EscalationUnexpected EscalationCategory = "unexpected" )
func CategorizeEscalationRuleID ¶ added in v1.41.0
func CategorizeEscalationRuleID(ruleID string) EscalationCategory
CategorizeEscalationRuleID maps a ClassificationResult's RuleID to its EscalationCategory. An empty RuleID is EscalationNoMatch. Any non-empty RuleID that isn't one of the known sentinel values falls back to EscalationExplicitRule — never a silent no-op — since named rules (seed, user, or claude-settings sourced) are the common case for an unrecognized RuleID.
type ParsedCommand ¶
type ParsedCommand struct {
// Program is the primary executable (path-stripped).
Program string
// Args is the list of remaining tokens.
Args []string
// Raw is the reconstructed "program arg1 arg2 …" string for pattern matching.
Raw string
// Redirections lists the targets of any shell redirections (e.g., "> file", ">> file").
Redirections []string
// FromCmdSubst is true when this command was extracted from inside a $(...) or `...`
// substitution. Such commands are evaluated as argument values for the outer command,
// not as independent top-level commands.
FromCmdSubst bool
// HasShellExpansionProgram is true when the program token itself is an unresolvable
// shell expansion — either a simple variable ($VAR, ${VAR}) or a command substitution
// ($(cmd)) that could not be resolved to a known program name by path-stripping.
// Examples: "$SCRIPT", "$(which python)", "${CMD}".
HasShellExpansionProgram bool
}
ParsedCommand is a single simple command extracted from a (potentially compound) shell command.
func ExtractAllCommands ¶
func ExtractAllCommands(cmd string) []ParsedCommand
ExtractAllCommands parses cmd with mvdan.cc/sh and recursively walks the AST, returning all CallExpr nodes — including those inside $(), backticks, and process substitutions. Falls back to splitCommandParts() on parse error.
Commands extracted from inside $(...) substitutions have FromCmdSubst=true. Commands whose program token is itself a shell expansion ($VAR, $(cmd)) have HasShellExpansionProgram=true.
type PermissionRequestPayload ¶
type PermissionRequestPayload struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
Cwd string `json:"cwd"`
PermissionMode string `json:"permission_mode"`
HookEventName string `json:"hook_event_name"`
ToolName string `json:"tool_name"`
ToolInput map[string]interface{} `json:"tool_input"`
// Source identifies which agent's hook sent this payload: "claude" or "pi".
// Optional and backward compatible — Claude's existing curl-based hook
// command is unmodified and simply omits it (empty string). Defaulted to
// "claude" only at the audit/analytics recording boundary, never here.
// See pi-support Epic 4.3 / ADR-related plan.md Story 4.3.1.
Source string `json:"source,omitempty"`
}
PermissionRequestPayload is the JSON payload from Claude Code's PermissionRequest HTTP hook.
type PythonInfo ¶
type PythonInfo struct {
// Imports contains top-level module names imported in inline Python code.
// Only populated when -c is used (inline code), not for script files.
Imports []string
// IsInline is true when code was passed via the -c flag.
IsInline bool
// Code is the extracted Python source passed via -c, with surrounding quotes stripped.
Code string
// CodeWithoutComments is Code with whole-line Python comments (lines whose first
// non-whitespace character is '#') removed. Use this for banned-pattern detection
// so that a comment like "# open() is dangerous" does not trigger a false positive.
CodeWithoutComments string
}
PythonInfo contains information extracted from a Python command invocation.
func ParsePythonCommand ¶
func ParsePythonCommand(command string) PythonInfo
ParsePythonCommand extracts Python import information from a python/python3 invocation. Only parses inline code passed via the -c flag; script files are not read.
type RiskLevel ¶
type RiskLevel int
RiskLevel indicates the severity of a tool use request.
WARNING: RiskLevel is persisted as an int column by ApprovalRule.risk_level (session/ent/schema/approvalrule.go) and converted to/from persisted strings elsewhere by riskLevelString/parseRiskLevel (server/services) for ClassificationAnalytics.RiskLevel, PendingApproval.RiskLevel, and PersistedApproval.RiskLevel. New values MUST be appended to the end of this iota block — inserting or reordering values would silently corrupt the int-column data and desync the int↔string mapping for the string-typed surfaces.
type Rule ¶
type Rule struct {
ID string
Name string
// ToolName is an exact match on the tool name (case-insensitive). If non-empty, ToolPattern is ignored.
ToolName string
// ToolPattern matches against the tool name when ToolName is empty.
ToolPattern *regexp.Regexp
// ToolCategory matches against the structural category returned by CategorizeToolName.
// Evaluated after ToolName/ToolPattern (those take precedence when non-empty).
// Use one of the ToolCategory* constants. Empty string means any category matches.
ToolCategory string
// Criteria provides structured matching for Bash command programs, subcommands and flags.
// When set alongside CommandPattern, both must match (AND semantics).
Criteria *CommandCriteria
// CommandPattern matches against tool_input["command"]. nil means any command matches.
CommandPattern *regexp.Regexp
// FilePattern matches against tool_input["file_path"]. nil means any file path matches.
FilePattern *regexp.Regexp
// RequireCIPassing, when true, only matches if ClassificationContext.CIStatus == "success".
// ANDed with all other conditions on this rule.
RequireCIPassing bool
// MinSessionIdleMinutes matches only if ClassificationContext.SessionIdleMinutes >=
// MinSessionIdleMinutes. 0 = condition not applied. ANDed with all other conditions
// on this rule.
MinSessionIdleMinutes int32
Decision ClassificationDecision
RiskLevel RiskLevel
Reason string
Alternative string
// Priority determines rule evaluation order. Higher values are evaluated first.
Priority int
Enabled bool
// Source tracks how the rule was loaded: SourceSeed, SourceUser, or SourceClaudeSettings.
Source string
}
Rule is a single classification rule evaluated against a tool use request.
func SeedRules ¶
func SeedRules() []Rule
SeedRules returns the built-in rule set, sorted by Priority descending. Priority tiers:
1000 — AutoDeny (critical, must fire before any allow) 500 — Escalate-before-allow (targeted escalations that override allow rules at 100) 100 — AutoAllow (standard development operations) 50 — Escalate catch-all (operations with no allow rule; provides a helpful reason)
Criteria-based rules provide precise matching without complex regex; CommandPattern is retained only where regex expressiveness is needed.
type RuleBasedClassifier ¶
type RuleBasedClassifier struct {
// contains filtered or unexported fields
}
RuleBasedClassifier evaluates a priority-ordered list of Rules.
func NewRuleBasedClassifier ¶
func NewRuleBasedClassifier() *RuleBasedClassifier
NewRuleBasedClassifier creates a classifier pre-loaded with seed rules.
func (*RuleBasedClassifier) AddRules ¶
func (c *RuleBasedClassifier) AddRules(rules []Rule)
AddRules appends additional rules and re-sorts by priority.
func (*RuleBasedClassifier) BuildContext ¶
func (c *RuleBasedClassifier) BuildContext(cwd string) ClassificationContext
BuildContext detects git repository state for the given working directory and populates Env from the current OS environment so that simple variable expansions ($VAR, ${VAR}) in Bash commands are resolved before classification. This lets commands like "$RUSTC --version" expand to "rustc --version" and match seed rules instead of hitting the generic HasShellExpansionProgram escalation path.
func (*RuleBasedClassifier) Classify ¶
func (c *RuleBasedClassifier) Classify(payload PermissionRequestPayload, ctx ClassificationContext) ClassificationResult
Classify acquires the read lock and evaluates payload against all rules. For Bash commands, compound commands (&&, |, ;, $(), etc.) are split and each sub-command evaluated independently. Recursive-eval programs (xargs, sudo, rtk, …) have their inner command extracted and classified through the full rule engine. If no rule matches, returns Escalate for human review.
func (*RuleBasedClassifier) ReplaceRules ¶
func (c *RuleBasedClassifier) ReplaceRules(rules []Rule)
ReplaceRules atomically replaces all rules with the provided list.
func (*RuleBasedClassifier) Rules ¶
func (c *RuleBasedClassifier) Rules() []Rule
Rules returns a copy of the current rule set.
type RuleSource ¶ added in v1.47.0
type RuleSource string
RuleSource identifies where a Rule was loaded from. A defined type (not an alias) so a filter helper's signature documents intent, even though existing Rule.Source literals ("user", "seed", ...) remain untyped strings — a repo-wide migration of that field is out of scope for the new call sites this type serves.
const ( SourceSeed RuleSource = "seed" SourceUser RuleSource = "user" SourceClaudeSettings RuleSource = "claude-settings" )
type SecurityFinding ¶
type SecurityFinding struct {
ID string
Name string
RiskLevel RiskLevel
Reason string
Alternative string
}
SecurityFinding represents a potential security risk discovered during deep AST analysis.
func AuditCommand ¶
func AuditCommand(cmd string, cwd string) []SecurityFinding
AuditCommand performs deep AST analysis on a shell command to identify dangerous patterns.