Documentation
¶
Overview ¶
* ChatCLI - Denial Tracker * Copyright (c) 2024 Edilson Freitas * License: Apache-2.0 * * Tracks consecutive and total denials to prevent infinite permission prompting. * Inspired by openclaude's denial tracking with configurable thresholds. * * Behavior: * - After N consecutive denials for the same tool: auto-deny for the rest of the session * - After M total denials across all tools: switch to "ask everything" mode (slower but safer) * - Reset on explicit user action (/policy reset-denials) * - Reset consecutive count on any successful approval
* ChatCLI - Read-Only Command Allowlist * Copyright (c) 2024 Edilson Freitas * License: Apache-2.0 * * Centralized allowlist of commands that are safe to execute without user * approval because they only read data and have no side effects. * * Inspired by openclaude's readOnlyValidation.ts which maintains a * COMMAND_ALLOWLIST with per-flag safe values. * * Commands in this list are auto-allowed in the policy check flow, * bypassing the interactive prompt. This significantly reduces prompt * fatigue for common read-only operations.
* ChatCLI - Safety Bypass Immunity * Copyright (c) 2024 Edilson Freitas * License: Apache-2.0 * * Defines operations that ALWAYS require user confirmation, regardless of * any allow rules in the policy. These are "bypass-immune" patterns that * protect against catastrophic mistakes. * * Inspired by openclaude's safety checks that are immune to bypassPermissions mode. * * Even if a user has "@coder exec" in their allow list, operations matching * these patterns will always prompt for confirmation.
Index ¶
- Constants
- func GetImmuneReason(toolName, args string) string
- func GetSuggestedPattern(toolName, args string) string
- func IsReadOnlyCommand(cmdLine string) bool
- func IsSafetyImmune(toolName, args string) bool
- func NormalizeCoderArgs(args string) (subcommand string, normalized string)
- func RestoreCookedMode() bool
- func SetPluginCapabilityResolver(r PluginCapabilityResolver)
- func SetSecurityPromptLogger(logger *zap.Logger)
- type Action
- type DenialLevel
- type DenialStats
- type DenialTracker
- func (dt *DenialTracker) GetLevel(toolKey string) DenialLevel
- func (dt *DenialTracker) IsBlocked(toolKey string) bool
- func (dt *DenialTracker) IsEscalated() bool
- func (dt *DenialTracker) RecordApproval(toolKey string)
- func (dt *DenialTracker) RecordDenial(toolKey string) DenialLevel
- func (dt *DenialTracker) Reset()
- func (dt *DenialTracker) Stats() DenialStats
- type DenialTrackerConfig
- type InputGuard
- func (g *InputGuard) DrainStdinChannel(ch <-chan string) []string
- func (g *InputGuard) FlushTTYInput() error
- func (g *InputGuard) Guard(ch <-chan string) bool
- func (g *InputGuard) IntentDebounce(ctx context.Context, ch <-chan string) int
- func (g *InputGuard) WithDebounceWindow(d time.Duration) *InputGuard
- type PluginCapabilityResolver
- type PluginCapabilityResult
- type PolicyManager
- func (pm *PolicyManager) ActivePolicyPath() string
- func (pm *PolicyManager) AddRule(pattern string, action Action) error
- func (pm *PolicyManager) Check(toolName, args string) Action
- func (pm *PolicyManager) DeleteRule(pattern string) (bool, error)
- func (pm *PolicyManager) LastMatchedRule() (Rule, bool)
- func (pm *PolicyManager) LocalMergeEnabled() bool
- func (pm *PolicyManager) LocalPolicyPath() string
- func (pm *PolicyManager) RulesCount() int
- func (pm *PolicyManager) RulesSnapshot() []Rule
- type ReadOnlyCommand
- type Rule
- type SecurityContext
- type SecurityDecision
- func PromptSecurityCheck(ctx context.Context, toolName, args string, inputCh <-chan string) SecurityDecision
- func PromptSecurityCheckGuarded(ctx context.Context, toolName, args string, inputCh <-chan string) SecurityDecision
- func PromptSecurityCheckWithContext(ctx context.Context, toolName, args string, secCtx *SecurityContext, ...) SecurityDecision
- func PromptSecurityCheckWithContextGuarded(ctx context.Context, toolName, args string, secCtx *SecurityContext, ...) SecurityDecision
Constants ¶
const DefaultIntentDebounce = 250 * time.Millisecond
DefaultIntentDebounce is the post-mount window during which any input arriving on the channel is treated as accidental typeahead and discarded. 250ms is long enough to catch keystrokes the user emitted *while* the security prompt was rendering, but short enough to be invisible during deliberate interaction.
Variables ¶
This section is empty.
Functions ¶
func GetImmuneReason ¶ added in v1.99.0
GetImmuneReason returns a human-readable reason why the command is immune, or empty string if it's not immune.
func GetSuggestedPattern ¶
GetSuggestedPattern returns a suggested policy pattern for the given tool invocation. For exec commands, it returns empty string to prevent "Allow Always" from being offered -- exec should always require per-command approval since any shell command could be destructive.
func IsReadOnlyCommand ¶ added in v1.99.0
IsReadOnlyCommand checks if a shell command is safe to auto-approve. Returns true if the command is in the read-only allowlist and doesn't contain any unsafe flags.
func IsSafetyImmune ¶ added in v1.99.0
IsSafetyImmune checks if the given tool command matches any safety bypass immunity pattern. If true, the operation MUST always prompt the user, regardless of any allow rules in the policy.
func NormalizeCoderArgs ¶ added in v1.53.1
NormalizeCoderArgs parses raw tool call args (JSON or CLI format) and returns:
- subcommand: the extracted subcommand name (e.g., "read", "exec")
- normalized: the full normalized CLI-style string with sorted flags (e.g., "read --file main.go") suitable for deterministic prefix matching.
When the subcommand cannot be determined, both return values are empty. This is a safe default because Check() will fall through to ActionAsk.
func RestoreCookedMode ¶ added in v1.118.0
func RestoreCookedMode() bool
RestoreCookedMode is the exported entry-point that callers outside the coder package use to reset the controlling terminal to canonical (cooked, echo-on) mode. Used by the agent loop at the start of every ReAct run to recover from a prior go-prompt teardown that may have left the TTY in raw mode (no echo, ICRNL off) — a state where keystrokes typed during the spinner land in the kernel buffer but never echo to the user's screen, producing the "looks frozen / am I typing?" UX bug.
The name avoids the "ResetTTY" prefix on purpose: the private resetTTYToSane() helper in security_ui.go would otherwise be flagged by the revive confusing-naming check (the two names differ only by capitalization). RestoreCookedMode is also more descriptive of what the call actually achieves.
This file lives separately from security_ui.go so that adding new exported callers doesn't drag security_ui.go into the QG cyclo-new scan — the file has a pre-existing high-complexity formatActionDetails function the gate would flag as soon as the file shows up in any diff.
Returns true when the reset was applied. Failures are intentionally silent: this is best-effort UX and any error degrades to the previous (occasionally-broken-on-resume) behavior, which is what we are trying to improve.
func SetPluginCapabilityResolver ¶ added in v1.118.0
func SetPluginCapabilityResolver(r PluginCapabilityResolver)
SetPluginCapabilityResolver wires the resolver. Called once from cli.NewChatCLI after the plugin manager is constructed. Passing nil explicitly unwires (useful at process shutdown or in tests).
func SetSecurityPromptLogger ¶ added in v1.118.0
SetSecurityPromptLogger installs a zap logger into the package-level input guard. Callers that have a logger (the CLI initializer) should set this at startup; otherwise the guard falls back to a no-op logger.
Idempotent: subsequent calls overwrite the active logger and guard.
Types ¶
type DenialLevel ¶ added in v1.99.0
type DenialLevel int
DenialLevel indicates the current denial tracking state.
const ( // DenialNormal means denial counts are within normal limits. DenialNormal DenialLevel = iota // DenialToolBlocked means a specific tool hit its consecutive denial threshold. DenialToolBlocked // DenialSessionEscalated means total denials exceeded the session threshold. // All tools should require explicit approval (no auto-allow). DenialSessionEscalated )
type DenialStats ¶ added in v1.99.0
DenialStats is a snapshot of denial tracking state for display.
type DenialTracker ¶ added in v1.99.0
type DenialTracker struct {
// contains filtered or unexported fields
}
DenialTracker tracks permission denials to prevent infinite prompting.
func NewDenialTracker ¶ added in v1.99.0
func NewDenialTracker(config DenialTrackerConfig) *DenialTracker
NewDenialTracker creates a new denial tracker.
func (*DenialTracker) GetLevel ¶ added in v1.99.0
func (dt *DenialTracker) GetLevel(toolKey string) DenialLevel
GetLevel returns the current denial level for a tool.
func (*DenialTracker) IsBlocked ¶ added in v1.99.0
func (dt *DenialTracker) IsBlocked(toolKey string) bool
IsBlocked returns true if the given tool is auto-blocked due to consecutive denials.
func (*DenialTracker) IsEscalated ¶ added in v1.99.0
func (dt *DenialTracker) IsEscalated() bool
IsEscalated returns true if the session is in escalated mode (all tools require approval).
func (*DenialTracker) RecordApproval ¶ added in v1.99.0
func (dt *DenialTracker) RecordApproval(toolKey string)
RecordApproval records an approval for the given tool. Resets the consecutive denial count for that tool.
func (*DenialTracker) RecordDenial ¶ added in v1.99.0
func (dt *DenialTracker) RecordDenial(toolKey string) DenialLevel
RecordDenial records a denial for the given tool. Returns the current denial level after recording.
func (*DenialTracker) Reset ¶ added in v1.99.0
func (dt *DenialTracker) Reset()
Reset clears all denial tracking state.
func (*DenialTracker) Stats ¶ added in v1.99.0
func (dt *DenialTracker) Stats() DenialStats
Stats returns a snapshot of denial tracking statistics.
type DenialTrackerConfig ¶ added in v1.99.0
type DenialTrackerConfig struct {
// MaxConsecutiveDenials per tool before auto-deny for the session.
MaxConsecutiveDenials int
// MaxTotalDenials across all tools before escalating the session.
MaxTotalDenials int
}
DenialTrackerConfig controls denial tracking thresholds.
func DefaultDenialTrackerConfig ¶ added in v1.99.0
func DefaultDenialTrackerConfig() DenialTrackerConfig
DefaultDenialTrackerConfig returns the default configuration. Override via environment variables.
type InputGuard ¶ added in v1.118.0
type InputGuard struct {
// contains filtered or unexported fields
}
InputGuard hardens user-facing confirmation prompts against accidental answers caused by typeahead. The user's threat model here is not malicious: it's the user typing during an LLM stream and unintentionally pre-answering the next security prompt with whatever happened to be in the buffer.
The guard works in three layers, each defeating a different race:
- FlushTTYInput — clears the kernel-side TTY input buffer (chars typed before our reader goroutine consumed them).
- DrainStdinChannel — drains the buffered channel between the reader goroutine and the prompt handler (chars already consumed by us but not yet read by the prompt).
- IntentDebounce — after the UI is on screen, discard any input that arrives within a short window (catches keystrokes that were *already in flight* when steps 1+2 ran, and gives the user time to react to the prompt before their typing counts).
All three are best-effort: failures are logged at DEBUG and the prompt continues. The worst case if every layer fails is the legacy behavior (which is what we are improving), not a security regression.
func NewInputGuard ¶ added in v1.118.0
func NewInputGuard(logger *zap.Logger) *InputGuard
NewInputGuard constructs a guard with sensible defaults. A nil logger is replaced with a no-op logger so callers in non-logged paths don't crash.
func (*InputGuard) DrainStdinChannel ¶ added in v1.118.0
func (g *InputGuard) DrainStdinChannel(ch <-chan string) []string
DrainStdinChannel non-blockingly empties the channel and returns the discarded lines. The caller is responsible for any logging beyond the DEBUG-level summary emitted here.
func (*InputGuard) FlushTTYInput ¶ added in v1.118.0
func (g *InputGuard) FlushTTYInput() error
FlushTTYInput discards any unread input bytes still buffered by the kernel for the controlling terminal. On platforms where the operation is not available (no TTY, sandboxed CI, Windows without a console), it returns nil — the higher layers (channel drain + debounce) still apply.
func (*InputGuard) Guard ¶ added in v1.118.0
func (g *InputGuard) Guard(ch <-chan string) bool
Guard runs the full pre-prompt sequence: flush TTY → drain channel. Callers should invoke this BEFORE rendering the UI, then call IntentDebounce AFTER the UI is rendered.
Returns true if any layer discarded user input — callers may want to surface this in the UI ("ignored prefilled input").
func (*InputGuard) IntentDebounce ¶ added in v1.118.0
func (g *InputGuard) IntentDebounce(ctx context.Context, ch <-chan string) int
IntentDebounce reads-and-discards any input arriving on ch during the configured window. Returns the count of discarded lines. The context is honored: cancellation aborts the wait without blocking.
func (*InputGuard) WithDebounceWindow ¶ added in v1.118.0
func (g *InputGuard) WithDebounceWindow(d time.Duration) *InputGuard
WithDebounceWindow overrides the post-mount debounce duration. A value of zero or negative disables debouncing entirely (useful for unit tests).
type PluginCapabilityResolver ¶ added in v1.118.0
type PluginCapabilityResolver func(toolName string, args string) PluginCapabilityResult
PluginCapabilityResolver is the read-only view of the plugin manager the policy_manager uses to decide whether an unmatched tool call can be auto-allowed (read-only plugins) or must default to ask.
The cli/coder package cannot import cli/plugins without a cycle (plugins → coder via the input guard package). We use a function pointer instead, set at startup by the cli package which DOES have access to both worlds.
type PluginCapabilityResult ¶ added in v1.118.0
type PluginCapabilityResult struct {
// Known is true when the resolver could find the plugin and ask it
// for its capability flags. False means the policy should fall
// back to its default (ask).
Known bool
// ReadOnly is true when the plugin advertises IsReadOnly for this
// specific args payload. Only meaningful when Known is true.
ReadOnly bool
}
PluginCapabilityResult is the resolver's verdict for one tool call.
type PolicyManager ¶
type PolicyManager struct {
Rules []Rule `json:"rules"`
// contains filtered or unexported fields
}
func NewPolicyManager ¶
func NewPolicyManager(logger *zap.Logger) (*PolicyManager, error)
func (*PolicyManager) ActivePolicyPath ¶ added in v1.52.0
func (pm *PolicyManager) ActivePolicyPath() string
func (*PolicyManager) AddRule ¶
func (pm *PolicyManager) AddRule(pattern string, action Action) error
func (*PolicyManager) Check ¶
func (pm *PolicyManager) Check(toolName, args string) Action
func (*PolicyManager) DeleteRule ¶ added in v1.109.0
func (pm *PolicyManager) DeleteRule(pattern string) (bool, error)
DeleteRule removes the rule whose Pattern matches exactly. Returns (true, nil) when a rule was removed, (false, nil) when no rule matched (so callers can distinguish "nothing to do" from an error), and (false, err) on persistence failure.
Used by the /config security forget subcommand so operators can retract an Allow / Deny without hand-editing coder_policy.json.
func (*PolicyManager) LastMatchedRule ¶ added in v1.52.0
func (pm *PolicyManager) LastMatchedRule() (Rule, bool)
func (*PolicyManager) LocalMergeEnabled ¶ added in v1.52.0
func (pm *PolicyManager) LocalMergeEnabled() bool
func (*PolicyManager) LocalPolicyPath ¶ added in v1.52.0
func (pm *PolicyManager) LocalPolicyPath() string
func (*PolicyManager) RulesCount ¶ added in v1.52.0
func (pm *PolicyManager) RulesCount() int
func (*PolicyManager) RulesSnapshot ¶ added in v1.109.0
func (pm *PolicyManager) RulesSnapshot() []Rule
RulesSnapshot returns a copy of the current rule set. Safe to show to UI code or write to disk without holding the PolicyManager lock (each caller gets its own slice).
type ReadOnlyCommand ¶ added in v1.99.0
type ReadOnlyCommand struct {
// Name is the base command name (e.g., "git", "ls", "cat")
Name string
// SafeSubcommands are subcommands that are read-only.
// If empty, the command itself is read-only (e.g., "ls").
// If non-empty, only these subcommands are auto-allowed.
SafeSubcommands []string
// UnsafeFlags are flags that make even a safe command unsafe.
// If the command contains any of these, it's NOT auto-allowed.
UnsafeFlags []string
}
ReadOnlyCommand defines a command that is safe for auto-approval.
type SecurityContext ¶ added in v1.64.0
type SecurityContext struct {
AgentName string // e.g., "shell", "coder", "tester"
TaskDesc string // natural language task description
}
SecurityContext provides optional metadata for richer security prompts. When provided, the prompt shows which agent is requesting the action and why.
type SecurityDecision ¶
type SecurityDecision int
const ( DecisionRunOnce SecurityDecision = iota DecisionAllowAlways DecisionDenyOnce DecisionDenyForever DecisionCanceled // user pressed Ctrl+C; action can be retried later )
func PromptSecurityCheck ¶
func PromptSecurityCheck(ctx context.Context, toolName, args string, inputCh <-chan string) SecurityDecision
PromptSecurityCheck prompts the user for a security decision (no agent context).
func PromptSecurityCheckGuarded ¶ added in v1.118.0
func PromptSecurityCheckGuarded(ctx context.Context, toolName, args string, inputCh <-chan string) SecurityDecision
PromptSecurityCheckGuarded wraps PromptSecurityCheck with the typeahead defense layers from InputGuard: kernel TTY flush, channel drain, and post-render intent debounce. Use this from every agent or coder call site that prompts the user for a security decision — without it, keystrokes the user typed while the LLM was streaming would be consumed by the very next <-inputCh as the y/n answer.
func PromptSecurityCheckWithContext ¶ added in v1.64.0
func PromptSecurityCheckWithContext(ctx context.Context, toolName, args string, secCtx *SecurityContext, inputCh <-chan string) SecurityDecision
PromptSecurityCheckWithContext prompts the user with full context about what is being attempted, which agent is requesting it, and the parsed command details. When inputCh is provided, input is read from the channel instead of spawning a goroutine with bufio.Scanner on stdin. This avoids orphaned goroutines that steal stdin from go-prompt after agent mode exits (e.g., on Ctrl+C).
func PromptSecurityCheckWithContextGuarded ¶ added in v1.118.0
func PromptSecurityCheckWithContextGuarded(ctx context.Context, toolName, args string, secCtx *SecurityContext, inputCh <-chan string) SecurityDecision
PromptSecurityCheckWithContextGuarded mirrors PromptSecurityCheckGuarded for the richer prompt variant that carries SecurityContext (agent name + task description shown to the user). Same guard semantics: flush + drain before the prompt renders, debounce after the answer is read.