Permissions & Sandboxing
Five tiers, several gates, one capability probe. Together they form the security model.
The five-tier mode
crates/runtime/src/permissions.rs:9–15:
class PermissionMode(IntEnum):
READ_ONLY = 1
WORKSPACE_WRITE = 2
DANGER_FULL_ACCESS = 3
PROMPT = 4
ALLOW = 5
The first three are tool-declared requirements (in ToolSpec.required_permission). The last two are session modes — Prompt means "ask before allowing escalation," Allow means "permit anything tool-declared up to its level."
A session has an active_mode; a tool has a required_permission. The decision is whether active_mode >= required_permission.
The decision tree
crates/runtime/src/permissions.rs:148–291 is PermissionPolicy::authorize(). Order of evaluation:
- Deny rules (lines 182–189). If any deny rule matches the request, immediate deny. Deny rules win above everything else.
- PermissionContext override (lines 196–242). A hook can attach a
PermissionContextto the request withoverride_decision: PermissionOverride::{Deny, Ask, Allow}.Denyshort-circuits to deny;Askforces a prompt even when the mode would have allowed;Allowcontinues the flow. - Ask rules (lines 244–257). If matched, force a prompt regardless of mode sufficiency. Used for "we'll allow this, but always confirm with the user first."
- Allow rules + mode sufficiency (lines 259–264). If a specific allow rule matches, or
active_mode >= required_mode, allow. - Mode escalation prompts (lines 266–283). If we're in
Promptmode, or escalating fromWorkspaceWritetoDangerFullAccess, surface a prompt. - Final deny (lines 285–291). All checks failed; deny with a reason.
The user-facing PermissionPrompter trait is at permissions.rs:86–88:
class PermissionPrompter(Protocol):
def decide(self, request: PermissionRequest) -> PermissionPromptDecision: ...
The CLI's prompter implementation isn't in the runtime crate (it's in rusty-claude-cli); the runtime crate just defines the trait.
The enforcer
crates/runtime/src/permission_enforcer.rs:39–61:
class PermissionEnforcer:
def check(self, tool_name: str, input: str) -> EnforcementResult: ...
def check_with_required_mode(
self, tool_name: str, input: str, required_mode: PermissionMode
) -> EnforcementResult: ...
def check_file_write(self, path: str, workspace_root: str) -> EnforcementResult: ...
def check_bash(self, command: str) -> EnforcementResult: ...
EnforcementResult (lines 14–24) is a tagged union: Allowed, or Denied(tool, active_mode, required_mode, reason) — serializable, useful for telemetry.
check_with_required_mode is the variant used for bash and PowerShell, where the required mode is determined dynamically by inspecting the command (e.g., rm -rf / → DangerFullAccess, ls → ReadOnly).
Bash classification + validation
This is the highest-stakes path; bash can do anything. Two layers:
Classification at crates/tools/src/lib.rs:1210 via classify_bash_permission(). Heuristic match on the command string. Returns the PermissionMode the command requires.
Validation at crates/runtime/src/bash_validation.rs (1004 LOC). The submodules:
validate_read_only()(line 103) — blocks write-like commands when in read-only modevalidate_mode()(line 284) — enforces general permission-mode constraintsvalidate_sed()(line 336) — validatessedexpressions for known dangerous patterns (e.g.,-iin-place edits without explicit allow)validate_paths()(line 360) — flags suspicious paths (parent traversals, system roots)validate_command()(line 594) — aggregator that calls all of the above
bash_validation.rs exists; the integration with bash.rs is partial. crates/runtime/src/bash.rs:71 defines execute_bash(), which calls sandbox_status_for_input() and spawns the command but does not appear to call validate_command() directly in the visible code paths. This is one of the parity gaps PARITY.md flags: claw-code has 1 of upstream Claude Code's 18 bash submodules, and the integration into the run path is incomplete.
What real Claude Code likely has in those 17 missing submodules: command-injection detection (e.g., backtick subshells in unexpected positions), env-var validation (e.g., LD_PRELOAD overrides), pipe-chain analysis (e.g., curl | bash patterns), redirect-target validation (writes to /etc, /var, etc.), and probably each shell builtin's own gate.
File ops gating
crates/runtime/src/file_ops.rs:42–54 — validate_workspace_boundary(resolved: &Path, workspace_root: &Path). Confirms resolved.starts_with(workspace_root) after canonicalization. Rejects .. escapes.
Lines 669–687 — is_symlink_escape(path, workspace_root). Resolves the symlink target, then checks if the canonical form points outside the workspace. Symlinks pointing outside are rejected — a non-obvious but important check, because ln -s /etc/passwd link.txt && cat link.txt would otherwise read arbitrary files via a workspace-relative path.
Size limits at lines 14–17:
MAX_READ_SIZE = 10 * 1024 * 1024 # 10 MB
MAX_WRITE_SIZE = 10 * 1024 * 1024 # 10 MB
Binary detection at lines 30–36 — is_binary_file() reads the first 8192 bytes and searches for NUL. Standard heuristic; not foolproof but cheap.
Sandbox capability probing
crates/runtime/src/sandbox.rs:156–303. The interesting bit is unshare_user_namespace_works() at line 288:
@functools.cache
def unshare_user_namespace_works() -> bool:
try:
result = subprocess.run(
["unshare", "--user", "--map-root-user", "true"],
capture_output=True, timeout=5,
)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
The default-naive approach to detecting "do we have sandbox support?" is to check if the unshare binary exists. This fails when:
- The binary exists but
CAP_SYS_ADMINisn't granted to non-root processes - The kernel was built without
CONFIG_USER_NS /proc/sys/kernel/unprivileged_userns_clone = 0- Container runtime restrictions
So unshare_user_namespace_works runs the actual call (true is a no-op command) inside the proposed namespace and checks the exit code. If it succeeds, sandboxing is genuinely available; if it fails, there's a real reason and we fall back unsandboxed.
The probe result is cached in a OnceLock — runs once per process startup, never re-checked.
When sandboxed, the command runs as:
unshare --user --ipc --pid --uts --mount [--net] sh -lc "<command>"
with HOME and TMPDIR redirected to workspace-local sandbox paths. Network namespace is optional (controlled by network_isolation).
This is Linux-only. Non-Linux platforms (macOS, Windows) return supported: false, active: false — bash runs unsandboxed. That's a real gap, but the harness at least knows it's running unsandboxed and can surface that to the user.
The integration gap (be aware)
A grep through lib.rs reveals trust_resolver is #[cfg(test)] — it's compiled only in test builds, not in production. The integration into the conversation runtime is incomplete. In practice this means folder-trust prompts (e.g. "is this directory safe to operate in?") aren't gated by claw-code's runtime; they're stubbed and would need to be wired up.
Real Claude Code presumably has this fully integrated. For your own agent, the lesson is: the trust-resolution layer is separate from the per-tool permission layer. Trust is "should this session exist for this workspace?" Permission is "should this call succeed within this trusted session?" Conflating them gets you a security model that's either too strict (every call prompts) or too loose (one trust check, then anything goes).