Permission systems are the safety layer between model intent and real-world action. They answer one question before every tool call: should this be allowed? The answer depends on who is asking, what mode the agent is in, and what the tool does. Without this layer, an autonomous agent is just a script with a language model attached — capable of deleting databases, overwriting system files, or executing arbitrary shell commands without any human in the loop. The permission system is what makes the difference between a demo and a production agent.
Claude Code defines five permission modes, each representing a different balance between automation and user control:
// src/types/permissions.ts
// Five modes covering the full spectrum from fully interactive to fully automated
export type PermissionMode =
| 'default' // Ask the user before any potentially dangerous operation
| 'auto' // Classifier decides; no user interaction (headless/batch use)
| 'plan' // User approves a high-level plan first; tools run within that scope
| 'acceptEdits' // Auto-approve safe file edits; still ask about destructive ops
| 'bubble' // Subagent mode — inherit parent's permission context
default
is for interactive sessions where the user wants full control. auto
is for CI pipelines and batch jobs where no human is present. plan
is for high-stakes operations where the user wants to review the strategy before any tool fires. acceptEdits
is optimized for code review workflows. bubble
ensures subagents cannot exceed the permissions of their parent — a critical safety property in multi-agent systems.
canUseTool
PipelineEvery tool call passes through canUseTool
before execution. The pipeline is layered:
// src/hooks/useCanUseTool.tsx
// Pipeline: alwaysAllowRules → alwaysDenyRules → tool.checkPermissions → classifier → user dialog
const decisionPromise = forceDecision !== undefined
? Promise.resolve(forceDecision) // caller override
: hasPermissionsToUseTool(tool, input, toolUseContext, assistantMessage, toolUseID)
return decisionPromise.then(async result => {
if (result.behavior === "allow") {
// Log the decision for audit trail
ctx.logDecision({ decision: "accept", source: "config" })
resolve(ctx.buildAllow(result.updatedInput ?? input, {
decisionReason: result.decisionReason
}))
return
}
if (result.behavior === "deny") {
// In auto mode, record the denial for analytics and notify the user
recordAutoModeDenial({ toolName: tool.name, reason: result.decisionReason?.reason })
resolve(result)
return
}
// behavior === "ask": route to the right handler
if (appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) {
// Coordinator mode: wait for automated checks before showing any dialog
const coordinatorDecision = await handleCoordinatorPermission({ ctx, ... })
if (coordinatorDecision) { resolve(coordinatorDecision); return }
}
// Swarm worker: delegate the decision up to the team lead
const swarmDecision = await handleSwarmWorkerPermission({ ctx, ... })
if (swarmDecision) { resolve(swarmDecision); return }
// Bash classifier: race a speculative check against a 2-second timeout
if (feature("BASH_CLASSIFIER") && result.pendingClassifierCheck) {
const raceResult = await Promise.race([
speculativePromise.then(r => ({ type: 'result', result: r })),
new Promise(r => setTimeout(r, 2000, { type: 'timeout' }))
])
if (raceResult.type === 'result' && raceResult.result.confidence === 'high') {
resolve(ctx.buildAllow(input, { decisionReason: { type: 'classifier' } }))
return
}
}
// Fall through to interactive dialog
handleInteractivePermission({ ... }, resolve)
})
The pipeline is designed so that the cheapest checks run first. Static rules (always-allow, always-deny) are evaluated before classifiers, and classifiers run before interactive dialogs. This minimizes latency for common cases while preserving safety for ambiguous ones.
Rules come from four sources with a defined priority order:
// src/Tool.ts
// Rules are keyed by pattern; each carries its source and decision
export type ToolPermissionRulesBySource = {
[pattern: string]: {
source: 'config' | 'user' | 'org' | 'classifier'
decision: 'allow' | 'deny' | 'ask'
reason?: string
}
}
// Priority: deny > ask > allow; org > user > config > classifier
org
rules are set by administrators and cannot be overridden by users. user
rules are set interactively. config
rules come from the application config file. classifier
rules are generated dynamically by the safety classifier. Deny rules always win over allow rules at the same priority level.
In multi-agent deployments, awaitAutomatedChecksBeforeDialog
enables centralized permission management. Instead of each agent showing its own dialog, the coordinator collects all requests and presents them through a unified interface. Swarm workers delegate decisions up to the team lead, ensuring that no worker can approve actions the team lead would reject.
approval.py
)Hermes takes a regex-based approach to dangerous command detection. The DANGEROUS_PATTERNS
list is the single source of truth:
# hermes-agent/tools/approval.py
# Each tuple is (regex_pattern, human_readable_description)
# Covers filesystem destruction, SQL drops, shell injection, and self-termination
DANGEROUS_PATTERNS = [
(r'\brm\s+-[^\s]*r', "recursive delete"),
(r'\bDROP\s+(TABLE|DATABASE)\b', "SQL DROP"),
(r'\bDELETE\s+FROM\b(?!.*\bWHERE\b)', "SQL DELETE without WHERE"),
(r'\bTRUNCATE\s+(TABLE)?\s*\w', "SQL TRUNCATE"),
(r':\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:', "fork bomb"),
(r'\b(curl|wget)\b.*\|\s*(ba)?sh\b', "pipe remote content to shell"),
(r'\b(pkill|killall)\b.*\b(hermes|gateway|cli\.py)\b', "kill hermes/gateway process"),
# ... 30+ more patterns
]
def detect_dangerous_command(command: str) -> tuple:
"""Returns (is_dangerous, pattern_key, description) or (False, None, None).
Normalizes the command first: strips ANSI escapes, null bytes, and
Unicode fullwidth characters to prevent obfuscation bypasses.
"""
command_lower = _normalize_command_for_detection(command).lower()
for pattern, description in DANGEROUS_PATTERNS:
if re.search(pattern, command_lower, re.IGNORECASE | re.DOTALL):
return (True, description, description)
return (False, None, None)
The normalization step is important — without it, a command like rm -rf /
(using Unicode fullwidth characters) could bypass ASCII-only pattern matching.
Once a user approves a pattern, it can be stored at three scopes:
# hermes-agent/tools/approval.py
# Three approval scopes: once (no storage), session (in-memory), always (config file)
def approve_session(session_key: str, pattern_key: str):
"""Approve a pattern for this session only — stored in memory."""
with _lock:
_session_approved.setdefault(session_key, set()).add(pattern_key)
def approve_permanent(pattern_key: str):
"""Add a pattern to the permanent allowlist — persisted to config.yaml."""
with _lock:
_permanent_approved.add(pattern_key)
def is_approved(session_key: str, pattern_key: str) -> bool:
"""Check both permanent and session-scoped approvals.
Also checks legacy regex-derived keys for backwards compatibility
with older config.yaml entries.
"""
aliases = _approval_key_aliases(pattern_key)
with _lock:
if any(alias in _permanent_approved for alias in aliases):
return True
session_approvals = _session_approved.get(session_key, set())
return any(alias in session_approvals for alias in aliases)
The alias system handles backwards compatibility: older config files stored regex-derived keys, newer ones store human-readable descriptions. Both are accepted.
approval_callback
: Interactive TUI ApprovalThe approval_callback
in callbacks.py
bridges the synchronous approval check into prompt_toolkit's async TUI:
# hermes-agent/hermes_cli/callbacks.py
def approval_callback(cli, command: str, description: str) -> str:
"""Prompt for dangerous command approval through the TUI.
Serializes concurrent requests via _approval_lock so parallel
subagent tasks don't stomp on each other's prompts.
Returns: 'once' | 'session' | 'always' | 'deny'
"""
lock = getattr(cli, "_approval_lock", None)
if lock is None:
import threading
cli._approval_lock = threading.Lock()
lock = cli._approval_lock
with lock:
timeout = CLI_CONFIG.get("approvals", {}).get("timeout", 60)
response_queue = queue.Queue()
choices = ["once", "session", "always", "deny"]
if len(command) > 70:
choices.append("view") # let user inspect long commands before deciding
# Push state into the CLI so the TUI can render the approval widget
cli._approval_state = {
"command": command,
"description": description,
"choices": choices,
"selected": 0,
"response_queue": response_queue,
}
cli._approval_deadline = _time.monotonic() + timeout
cli._app.invalidate() # trigger TUI redraw
# Block until the user responds or the timeout fires
while True:
try:
result = response_queue.get(timeout=1)
cli._approval_state = None
return result
except queue.Empty:
if _time.monotonic() > cli._approval_deadline:
return "deny" # timeout defaults to deny
clarify_callback
: Open-Ended QuestionsFor non-approval questions — "which environment should I target?" — clarify_callback
handles both multiple-choice and free-text input:
# hermes-agent/hermes_cli/callbacks.py
def clarify_callback(cli, question, choices):
"""Prompt for a clarifying question through the TUI.
When choices is empty, renders a free-text input field.
When choices is non-empty, renders a selection widget.
Blocks until the user responds or the 120-second timeout fires.
"""
is_open_ended = not choices
cli._clarify_state = {
"question": question,
"choices": choices if not is_open_ended else [],
"selected": 0,
"response_queue": queue.Queue(),
}
cli._clarify_freetext = is_open_ended
cli._app.invalidate()
# ... blocking poll loop identical to approval_callback
terminal_tool.py
The terminal tool calls _check_all_guards
before executing any command. This runs both the regex-based dangerous command check and the optional tirith security scanner:
# hermes-agent/tools/terminal_tool.py (around line 1155)
# Pre-exec guard: runs before every shell command, skipped only when force=True
if not force:
approval = _check_all_guards(command, env_type)
if not approval["approved"]:
if approval.get("status") == "approval_required":
# Gateway mode: return a structured response asking the user
return json.dumps({
"output": "",
"exit_code": -1,
"error": approval.get("message", "Waiting for user approval"),
"status": "approval_required",
"command": approval.get("command", command),
"description": approval.get("description", "command flagged"),
})
# CLI mode: command was blocked after user denied
return json.dumps({
"output": "",
"exit_code": -1,
"error": approval.get("message", "Command denied"),
"status": "blocked"
})
Container environments (docker
, singularity
, modal
) bypass the approval check entirely — the container boundary is treated as sufficient isolation.