classifier

package
v1.44.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 17, 2026 License: AGPL-3.0 Imports: 11 Imported by: 0

Documentation

Index

Constants

View Source
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.

View Source
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

View Source
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

func CategorizeToolName(name string) string

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

func ExpandEnvVars(cmd string, env map[string]string) string

ExpandEnvVars replaces $VAR and ${VAR} references in cmd, mirroring Python's os.path.expandvars behaviour:

  1. env map (caller overrides) — checked first.
  2. OS environment (os.LookupEnv) — fallback, so real env vars work without having to enumerate them in the map.
  3. 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

func ExtractInnerCommand(prog string, args []string) string

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"`
}

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.

const (
	RiskLow RiskLevel = iota
	RiskMedium
	RiskHigh
	RiskCritical
)

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: "seed", "user", or "claude-settings".
	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

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 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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL