Policy & Sandboxing — Design
Mirrored from
docs/design/policy-sandbox/2_design.md. Edit the source document in the repository, not this generated page.
This document describes Orbit’s shipped policy and sandboxing implementation: v2 PolicyDef, profile resolution, last-match-wins path evaluation, HTTP-tool enforcement, activity/job fsProfile binding, macOS CLI sandbox wrapping, and orbit-exec supervision. See 1_overview.md for purpose and 3_vision.md for forward-looking gaps.
1. Policy Schema
Section titled “1. Policy Schema”PolicyDef in crates/orbit-common/src/types/policy_def.rs is v2-only. crates/orbit-common/src/types/resource.rs rejects schema v1 with a migration message that names spec.denyRead, spec.denyModify, and spec.fsProfiles.
A valid policy declares name, optional description, global denyRead / denyModify, and fsProfiles mapping names to FsProfile { read, modify }.
PolicyDef::validate enforces:
- Every profile name is non-empty.
- Every positive
modifyrule is covered by a positivereadrule in the same profile. - Profile rules do not exactly duplicate global deny entries.
PolicyDef::merged(global, workspace) lets workspace fsProfiles overwrite globals by name while global denies accumulate. The merged policy is revalidated.
2. Profile Resolution
Section titled “2. Profile Resolution”PolicyDef::effective_profile(profile_name) returns a ResolvedFsProfile { name, read, modify } after applying three transformations:
- Lookup. Use the named profile. If the missing name is
unrestricted, synthesizeread: ["./**"]andmodify: ["./**"]; other missing profiles returnOrbitError::InvalidInput. - Normalization. Trim, convert backslashes, strip leading
./, reject absolute,~, and parent-traversal rules, then compile the narrow glob syntax to regex. - Deny injection. Append
denyReadtoreadanddenyModifytomodifyas negated rules (!<rule>), so global denies participate in the same ordered list.
The implicit unrestricted profile appears only when an activity omitted fsProfile: and the policy did not define unrestricted. A real profile with that name shadows the fallback.
3. Path Evaluation
Section titled “3. Path Evaluation”PolicyDef::check_path(profile, op, path) returns an FsCheckResult { allowed, matched_rule }. The algorithm:
- Resolve the profile (via §2).
- Pick the rule list by operation (
readormodify). - If the list is empty, deny with
matched_rule = "[]". - Walk rules in order and record the most recent match against the normalized workspace-relative path. Later matches override earlier ones.
- Use the last match’s negation flag. If no rule matched but a positive rule exists, deny with
<no matching rule>; if only negated rules exist, deny with[].
Path normalization (normalize_path) trims, flips slashes, strips ./ prefixes, and rejects absolute paths or ~-anchored paths. Tool callers are expected to canonicalize first and then express the path workspace-relative — crates/orbit-tools/src/builtin/fs/mod.rs::workspace_relative_path handles that on the call site.
The glob translator supports *, **, ?, and <prefix>/**. It is intentionally narrower than POSIX glob syntax.
4. PolicyEngine Facade
Section titled “4. PolicyEngine Facade”crates/orbit-policy/src/lib.rs re-exports PolicyEngine, FsPolicyEvaluation, and PolicyDecision. PolicyEngine wraps a validated PolicyDef and exposes:
PolicyEngine::check(profile, operation, path) -> FsPolicyEvaluationFsPolicyEvaluation carries { profile, operation, path, allowed, matched_rule }. evaluator.rs currently passes through to PolicyDef::check_path; the indirection leaves room for caching or layered evaluators later.
PolicyDecision (crates/orbit-common/src/types/policy_decision.rs) is a separate Allow | Deny { reason } enum for broader policy/RBAC callers. PolicyEngine::check does not produce it; fs callers use FsPolicyEvaluation.
5. Tool-Layer Enforcement
Section titled “5. Tool-Layer Enforcement”crates/orbit-tools/src/builtin/fs/mod.rs::enforce_fs_policy is the only place fs operations consult the policy engine today. It reads ctx.fs_profile and ctx.policy_engine; if either is missing, it returns Ok(None) so fs work proceeds unguarded. That path is for unit tests / no-policy contexts, not the real v2 host path. Otherwise the helper converts the canonical path to workspace-relative form, calls policy_engine.check, emits a request or denied FsCallEvent, and returns either an FsPolicyAllowance { profile, op, path, matched_rule } or OrbitError::PolicyDenied.
The audit emission goes through ctx.fs_audit: Option<Arc<dyn FsAuditLogger>> (crates/orbit-tools/src/lib.rs). The v2 dispatcher wires this to v2_fs_audit_logger(audit.clone()), which converts each FsCallEvent into a V2AuditEvent filesystem entry. The full audit-channel description belongs to auditability; this folder owns the enforcement contract, not the storage contract.
FsCallEvent carries { kind, profile, op, path, allowed, matched_rule }. There is no persisted negation flag; consumers that need to distinguish explicit deny matches from “no rule matched” must compare matched_rule with the policy denies. The exec layer does not consult the policy engine, so there is no proc.spawn policy gate today.
Backend scope. This enforcement fires only under backend: http when a builtin fs tool runs. backend: cli spawns Claude Code, Codex CLI, Gemini, or another harness via cli_runner.rs, emits tool_allowlist.harness_delegated, and trusts that harness for tool allowlists. On macOS, executors declaring sandbox: macos-sandbox-exec also get the OS-level wrapper in §7, so fsProfile: can still narrow CLI filesystem writes.
6. Activity / Job fsProfile Binding
Section titled “6. Activity / Job fsProfile Binding”The fsProfile: field on an activity flows through crates/orbit-engine/src/activity_job/:
dispatcher.rscarriesfs_profile: Option<&str>onDispatchInputand threads it intorun_activity_job_dispatch,run_loop_step_dispatch, andrun_agent_loop_via_driver.job_executor.rsreadst.fs_profile.as_deref()from the activity spec at the call site of every step type.agent_loop_driver.rsandgroundhog.rsinvokehost.tool_context_for_activity(fs_profile, audit_logger)to construct theToolContextthat fs builtins read from.
crates/orbit-core/src/runtime/v2_host.rs::tool_context_for_activity is the single materialization point:
fs_profile: Some(fs_profile.unwrap_or(UNRESTRICTED_FS_PROFILE).to_string())This is the implicit-unrestricted rule from §2.2 in code form. Every v2 dispatcher path that constructs a ToolContext reaches this line, so omitting fsProfile: means “unrestricted within policy,” not “no policy.”
Legacy pipeline contexts are different. crates/orbit-core/src/runtime/pipeline.rs fills a missing profile from ORBIT_ACTIVITY_FS_PROFILE; if the variable is unset, ctx.fs_profile stays None and enforce_fs_policy returns Ok(None). That unguarded path is a real gap, not another spelling of unrestricted (see §9).
7. Sandbox / Exec Primitives
Section titled “7. Sandbox / Exec Primitives”orbit-exec is the process-spawn layer. The public surface is in crates/orbit-exec/src/lib.rs:
ExecRequest { program, args, current_dir, timeout_ms, stdin_mode, environment_mode, debug }.EnvironmentMode::InheritorClearAndSet(Vec<(String, String)>); debug output redacts sensitive env values.StdinMode::Inherit/Null/Bytes(Vec<u8>).Sandbox::validate(req) -> Result<()>; the defaultNoSandboxalways returnsOk.run_process(req, sandbox) -> ExecutionResult.
run_process calls sandbox.validate, then process::spawn, then supervision::wait_with_optional_timeout. Spawn applies the requested environment, pipes stdout/stderr, and on Unix calls command.process_group(0) so cleanup can kill orphan subprocesses.
ExecutionResult { success, stdout, stderr, exit_code, duration_ms, output } is defined in orbit-common. Captured bytes use String::from_utf8_lossy, so non-UTF-8 output becomes replacement characters instead of failing the call.
The Sandbox trait remains the seam for generic run_process callers, but CLI-backed agent_loop invocations use a separate executor wrapper when the executor declares sandbox: macos-sandbox-exec ([T20260427-51]). The v2 host resolves the activity fsProfile; the engine converts workspace-relative rules to absolute roots and compiles SBPL before spawning the provider CLI.
The compiled macOS profile denies by default, allows broad reads required by agent CLIs and system libraries, allows process/signal/ipc/network/sysctl/iokit operations, and allows writes to:
- scratch/cache roots (
/tmp,/private/tmp,/private/var/folders,/dev,$HOME/Library/Caches) $HOME/.orbitfor inherited Orbit subprocess audit/state- provider state dirs: Codex (
$CODEX_HOMEor$HOME/.codex), Claude ($CLAUDE_CONFIG_DIRor$HOME/.claude), and Gemini ($HOME/.gemini) - positive
modifyroots from the resolved profile - Codex side-write roots from runtime provider config, appended after policy denies so workflow state remains writable under the outer sandbox
Negated read / modify rules become explicit SBPL denies after ordinary profile allows to preserve last-match-wins. Simple path and /** subtree denials compile to subpath; non-subpath globs such as **/*.env compile to regex. Host-owned provider side roots are the exception because the provider CLI and inherited Orbit subprocesses must write workflow state.
8. Process Supervision
Section titled “8. Process Supervision”crates/orbit-exec/src/supervision/wait.rs::wait_with_optional_timeout drains stdout/stderr in background threads, writes stdin bytes when requested, installs Unix SIGINT/SIGTERM handling, and polls child.wait_timeout every WAIT_POLL_INTERVAL = 100ms. Clean exits still call kill_process_group(child.id()) to reap orphans. Parent signals terminate the group and report exit_code = Some(128 + signal) with annotated stderr; deadlines terminate with SIGTERM and append process timed out.
crates/orbit-exec/src/supervision/cleanup.rs is the termination layer. The escalation policy:
- Send
SIGTERM(or the supplied signal) to the entire process group viakillpg. - Poll
process_group_is_alive(pid)for up toTERMINATION_GRACE_PERIOD = 5 seconds. - If the group is gone, return success.
- Otherwise send
SIGKILLto the group, then callchild.kill()andchild.wait()to reap.
process_group_is_alive uses killpg(pid, 0), treats ESRCH as “all gone,” and treats other errno values as “still alive” so cleanup errs toward SIGKILL.
SignalHandlerGuard is RAII: install acquires a global Mutex, creates a pipe, swaps in handlers, and stores prior sigaction structs; Drop restores handlers, closes the pipe, and releases the mutex. The handler performs only an atomic load plus one-byte write, both async-signal-safe.
Non-Unix builds use a fallback terminate_process_group that just calls child.kill().ok(); child.wait().ok(); — process-group semantics do not apply on Windows, so orphan reaping is best-effort.
9. Concerns & Honest Limitations
Section titled “9. Concerns & Honest Limitations”- OS-level CLI sandboxing is macOS-only. Linux (
bwrap), Docker, and other wrappers remain future work; genericrun_processstill defaults toNoSandbox. - CLI tool allowlists are delegated. The macOS wrapper narrows writes, but Orbit still trusts Claude/Codex/Gemini harnesses for declared
tools:. - Provider state directories are trusted write roots.
$HOME/.orbitplus Codex, Claude, and Gemini state dirs are outside the activity workspace and emitted unconditionally. - Codex side-root appends are config-coupled. If Codex is configured without the workspace-write side roots, inherited Orbit subprocesses can hit
.orbitwrite denials. - macOS provenance syscall allowances are private.
vnguardandSandbox/67 mirror current Codex startup needs and may require review after OS changes. - Pipeline env fallback can leave
fs_profile = None. Legacy contexts withoutORBIT_ACTIVITY_FS_PROFILEstill bypassenforce_fs_policy. - HTTP enforcement is helper-based. A future builtin or non-builtin tool that skips
enforce_fs_policyis unguarded. - Exec has no policy hook.
proc.spawnprogram allowlists are activity-layer data, not part ofPolicyDeforeffective_profile. - Symlink semantics are implicit.
workspace_relative_pathfollows symlinks and rejects out-of-workspace targets, but no spec states that invariant. - Glob syntax is narrow. Character classes, brace expansion, and POSIX bracket expressions are unsupported.
- Policy result shapes are parallel.
PolicyDecisionandFsPolicyEvaluationhave no bridge for future non-fs evaluators. - Empty rule sets are safe but opaque. A profile with only deny rules reports
matched_rule = "[]", not the matching deny rule. - Signal handling is process-global.
SignalHandlerGuardserializes installs with a globalMutex, which constrains future worker-pool exec. - Workspace canonicalization errors collapse to denial. A missing workspace root can surface as
PolicyDenied("path is outside workspace")rather than a clearer root-missing error.
Task References
Section titled “Task References”- [T20260416-0728] — Align policy contract with runtime enforcement; established v2 schema and effective-profile resolution.
- [T20260417-0550] — Decompose
orbit-execsupervision modules. - [T20260417-0557] — Harden Orbit path boundaries and dependency advisories.
- [T20260417-0558-4] / [T20260417-0558-5] — Harden
orbit-execsupervision (signal-pipe handler and process-group reaping). - [T20260419-0503] — Enforce
fsProfilesacross runtime and CLI; introduced thetool_context_for_activitymaterialization. - [T20260328-221810] — Agent subprocess termination on Ctrl+C / job-run cancel; predecessor of the current signal-pipe design.
- [T20260426-0605] — Auditability design folder cross-linked from §5.
- [T20260426-0622] — Add this policy & sandboxing design folder and document the current contract.
- [T20260427-51] — Wrap cli-backend agent invocations in
sandbox-execon macOS. - [T20260428-10] — Allow Codex CLI state writes under the macOS sandbox.
- [T20260428-14] — Extend the macOS sandbox state-dir allowance to Claude (
~/.claude/$CLAUDE_CONFIG_DIR) and Gemini (~/.gemini), and document why side-write roots remain Codex-only. - [T20260430-23] — Shorten the policy sandbox design docs while preserving the shipped contract and ADR history.
Resolve any task above with
orbit task show <ID>orgit log --grep=<ID>.