N
Nexus API Referencev2.4.1

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:

  1. Deny rules (lines 182–189). If any deny rule matches the request, immediate deny. Deny rules win above everything else.
  2. PermissionContext override (lines 196–242). A hook can attach a PermissionContext to the request with override_decision: PermissionOverride::{Deny, Ask, Allow}. Deny short-circuits to deny; Ask forces a prompt even when the mode would have allowed; Allow continues the flow.
  3. 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."
  4. Allow rules + mode sufficiency (lines 259–264). If a specific allow rule matches, or active_mode >= required_mode, allow.
  5. Mode escalation prompts (lines 266–283). If we're in Prompt mode, or escalating from WorkspaceWrite to DangerFullAccess, surface a prompt.
  6. 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, lsReadOnly).

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 mode
  • validate_mode() (line 284) — enforces general permission-mode constraints
  • validate_sed() (line 336) — validates sed expressions for known dangerous patterns (e.g., -i in-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–54validate_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_ADMIN isn'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).


Continue: Extensibility (Hooks, Plugins, Skills, MCP)

Last updated: May 14, 2026