chore: ignore plans for now

This commit is contained in:
Drew Newberry
2026-02-12 23:33:25 -08:00
parent d2f3ca71d4
commit ae7378e5ce
4 changed files with 3 additions and 701 deletions
+3
View File
@@ -182,3 +182,6 @@ deploy/docker/.build/
# Local mise settings
mise.local.toml
# Ignore plans for now
architecture/plans
-144
View File
@@ -1,144 +0,0 @@
# Inference Router Plan
Status: In Progress
Date: 2026-02-09
## Goals
- Use entity-managed inference configuration.
- Use `routing_hint` as the user-facing signal.
- Start with a simple and strict v1 model.
- Use fail-closed sandbox authorization.
## Design principles
- `routing_hint` is advisory intent from userland, not an internal route ID.
- Use a single entity in v1 (`InferenceRoute`) to keep control plane simple.
- v1 is intentionally small: 1:1 route-to-model mapping.
- Add richer route modes only after route-only CRUD + tests are solid.
## Naming
- `Inference`: top-level namespace/service name.
- `routing_hint`: request field used by callers.
- `InferenceRoute`: maps hint -> routing behavior.
- `InferencePolicy`: sandbox authorization settings.
## Entity model
### InferenceRoute
Represents how a `routing_hint` resolves.
Fields:
- `id`
- `spec`
- `routing_hint`
- `base_url`
- `protocol` (`openai_chat_completions` initially)
- `api_key` (plaintext for now)
- `model_id`
- `enabled`
Persistence:
- Store as protobuf payloads in the existing `objects` table.
- Route auth is plaintext in v1; future update will replace `api_key` with secret references.
## Request semantics
`CompletionRequest` remains centered on `routing_hint`.
- `routing_hint` is optional/advisory; server resolves an effective route.
- `model_id` is resolved from route `spec.model_id` in v1.
v1 behavior:
- Route contains full upstream target + model config.
- Userland sends `routing_hint` + messages only.
v2 behavior:
- Add optional passthrough model mode for downstream-router scenarios.
## Sandbox policy model
v1 control:
- `allowed_routing_hints`
Planned extension:
- optional `allowed_model_ids`
- optional provider/capability dimensions if needed later
Enforcement order:
1. Authenticate request identity.
2. Resolve route from `routing_hint`.
3. Apply sandbox policy checks.
4. Call upstream.
## API plan (entity management)
Routes:
- `CreateInferenceRoute`
- `UpdateInferenceRoute`
- `DeleteInferenceRoute`
- `ListInferenceRoutes`
CLI:
- Use `nav inference create|update|delete|list` for route CRUD and inspection.
## Router behavior
- Resolve by `routing_hint`.
- Load active route from entity store.
- Support dynamic refresh without restart (watch or polling).
- Perform protocol mapping to upstream API shape.
## Responsibility split
- Server responsibilities:
- authenticate sandbox request
- enforce `InferencePolicy` (`allowed_routing_hints`)
- load enabled, policy-allowed route candidates from store
- Router responsibilities:
- select route from candidate set using request context (`routing_hint` today)
- execute upstream inference call
- remain the single place for future routing logic (fallbacks, scoring, policy-aware strategy)
## AuthZ and governance
Required decisions:
- RBAC for inference entity CRUD.
- Audit trail for route mutations.
## Implementation phases
### Phase 1
- Define entities and protobufs.
- Implement CRUD APIs for routes.
- Implement router resolution from entities.
- Use fixed route-to-model mapping only.
- Enforce sandbox `allowed_routing_hints`.
### Phase 2
- Add optional passthrough-model route mode.
- Add optional per-sandbox `allowed_model_ids`.
- Add richer provider/capability policy dimensions if needed.
## Implementation status
- Done: `InferenceRoute` entity with `spec` shape (`id + spec`).
- Done: route-only gRPC CRUD (`Create/Update/Delete/ListInferenceRoute`).
- Done: completion path resolves route from store by `routing_hint`.
- Done: CLI route CRUD commands (`nav inference create|update|delete|list`).
- Done: sandbox policy enforcement remains `allowed_routing_hints`.
- Pending: expand integration tests for CRUD + completion + policy edges.
## Validation and testing
- Unit: entity validation, route resolution, policy evaluation.
- Integration: CRUD, router refresh, completion path.
- Security: API key redaction in logs/outputs.
## Open decisions
- Missing/unknown `routing_hint`: strict error or default route.
- Whether `routing_hint` must be globally unique or namespaced.
- Principal identity source and RBAC model for non-sandbox clients.
-433
View File
@@ -1,433 +0,0 @@
---
name: sandbox-policy-opa-v1
overview: >
Migrate sandbox network policy from a naive YAML hostname-allowlist to an
embedded OPA (Rego) policy engine with process-identity binding via
/proc, and SHA256 trust-on-first-use.
status: implemented
---
# OPA Sandbox Policy Engine — v1 Implementation Plan
## Threat Model
Agents running inside the sandbox can generate and execute arbitrary code
(Python scripts, curl, compiled binaries, etc.). This code must not be able to
reach network endpoints that are reserved for specific trusted binaries.
**Example:** Only `/usr/bin/node` should be able to reach
`api.anthropic.com:443`. A Python script written by an agent that attempts to
`curl api.anthropic.com` must be denied by the proxy even though the endpoint
is in the allowlist — because the calling binary is not `/usr/bin/node`.
~~**Note on interpreted apps:** Many tools are installed via package managers that
create shell-script wrappers. For example, the `claude` npm package installs a
shell script at `/usr/local/bin/claude` that `exec`s into `node`, so
`/proc/<pid>/exe` reports `/usr/bin/node` (the interpreter), not the wrapper.
Because the wrapper uses `exec`, the wrapper path disappears from the process
entirely — it is not in `/proc/<pid>/exe` or `/proc/<pid>/cmdline`. For
npm-style tools, policies should match the interpreter binary directly (e.g.
`/usr/bin/node`) rather than the wrapper path.~~
We now rely on installing Claude as a native binary, so the policy can directly allow connections from `/usr/local/bin/claude`.
**Layered defense model:**
- **Primary boundary:** Linux namespace isolation — only processes launched
inside the sandbox can reach the proxy. The network namespace + veth pair
ensures all traffic is funneled through the proxy; there is no direct
internet access from within the sandbox.
- **Defense-in-depth:** Process identity binding via `/proc/<pid>/exe` — maps
(binary, endpoint) pairs to named policies. The proxy resolves the calling
binary and its ancestor chain for every CONNECT request.
- **Integrity:** SHA256 TOFU — on first use of a binary, the proxy computes
its SHA256 hash and caches it as the "golden" hash for the lifetime of the
sandbox. Any subsequent request from the same binary path must match the
cached hash. A mismatch means the binary was replaced mid-sandbox and the
request is denied. This is a trust-on-first-use model — the sandbox
runtime is trusted at launch time, so binaries present at launch are assumed
good.
## Current State
| Component | File | Behavior |
|-----------|------|----------|
| Policy loading | `crates/navigator-sandbox/src/policy.rs` | YAML deserialization via serde, or protobuf via gRPC |
| OPA engine | `crates/navigator-sandbox/src/opa.rs` | Embedded `regorus::Engine` with `Mutex` for thread safety |
| Process identity | `crates/navigator-sandbox/src/procfs.rs` | `/proc` reading: binary path (exe readlink, fail-closed), ancestor walk, TCP peer resolution. Also collects cmdline paths as a low-trust convenience signal. |
| Binary integrity | `crates/navigator-sandbox/src/identity.rs` | SHA256 TOFU cache with `Mutex<HashMap<PathBuf, String>>` |
| Proxy | `crates/navigator-sandbox/src/proxy.rs` | HTTP CONNECT proxy with OPA policy evaluation and legacy hostname allowlist fallback |
| Network isolation | `crates/navigator-sandbox/src/sandbox/linux/netns.rs` | Network namespace + veth pair forces all traffic through proxy |
| Syscall filtering | `crates/navigator-sandbox/src/sandbox/linux/seccomp.rs` | Blocks AF_INET/AF_INET6 direct socket creation in non-Allow mode |
| Orchestration | `crates/navigator-sandbox/src/lib.rs` | `run_sandbox()` → load policy → prepare fs → create netns → start proxy → spawn process |
| CLI | `crates/navigator-sandbox/src/main.rs` | `--policy-rules` + `--policy-data` or `--sandbox-id` + `--navigator-endpoint` (gRPC) |
| Proto | `proto/sandbox.proto` | `SandboxPolicy`, `ProxyPolicy.allow_hosts` as repeated string |
## Architectural Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| OPA runtime | **Embedded via `regorus` crate** (`regorus = { version = "0.9", default-features = false, features = ["std", "arc", "glob", "yaml"] }`). Thread safety: `Mutex<regorus::Engine>` because `eval` requires `&mut self`. | Pure-Rust OPA evaluator. No sidecar process, no latency penalty, no new failure domain. Self-contained proxy. |
| Process identity | **`/proc/<pid>/exe` readlink (fail-closed) + ancestor walk.** No fallback to `argv[0]` — if the exe symlink is unreadable the request is denied. Requires same-user or `CAP_SYS_PTRACE`. Cmdline paths are collected as a low-trust convenience signal (see Security Considerations). | Required by threat model — agent code must not reach binary-restricted endpoints. `argv[0]` is trivially spoofable and must never be used as a trusted identity source. |
| Parent-chain matching | **Implemented.** Ancestor walk via `/proc/<pid>/status` PPid chain with 64-depth safety limit. Stops at sandbox root PID or PID 1. | Enables policies like "allow Python subprocess spawned by glab to reach gitlab.com". |
| Cmdline path extraction | **Implemented.** Captures absolute paths from `/proc/<pid>/cmdline` for interpreter-based apps. The interpreter is the exe; the script path appears in cmdline. **Caveat:** cmdline is a convenience heuristic, not a security boundary — `argv` is writable by the owning process. Also, npm-style wrappers that `exec` into the interpreter may not leave the wrapper path in the final process's cmdline (e.g. `exec node cli.mjs` replaces the shell, so cmdline shows `node cli.mjs` not the wrapper path). For npm tools, prefer matching the interpreter binary directly (e.g. `/usr/bin/node`) or use glob patterns on module paths. | Covers shebang scripts and tools that pass script paths via argv. Not relied upon as a security boundary — namespace isolation is the primary control. |
| Glob patterns | **Implemented.** `glob.match(pattern, ["/"], path)` in Rego via regorus `glob` feature. `*` matches within a path segment, `**` across segments. `["/"]` delimiter prevents `*` from crossing `/` boundaries. | Enables policies like `"/usr/bin/*"` to match any binary in a directory without listing each one. |
| SHA256 enforcement | **Trust-on-first-use (TOFU)** | Policy lists binary paths only. Proxy hashes on first use, caches, enforces for sandbox lifetime. No build-pipeline coupling. |
| Policy loading | **`--policy-rules` + `--policy-data` CLI flags** | Explicit, no auto-detection. Rego rules file + YAML data file. |
| gRPC policy fetch | **Deferred, but API designed for it** | `OpaEngine::reload()` method ready for future hot-reload from navigator gateway. |
## Request Flow (TCP via Network Namespace + OPA)
```
1. Accept TCP connection from sandbox netns (via veth pair)
2. Resolve peer PID: /proc/<sandbox_pid>/net/tcp → socket inode → FD scan → PID
3. Binary identity: /proc/<pid>/exe → binary path (fail-closed, no argv[0] fallback)
4. Ancestor walk: PPid chain → ancestor binary paths (up to 64 levels, stop at sandbox root)
5. Cmdline paths: /proc/<pid>/cmdline + ancestor cmdlines → absolute paths (excl. exe paths)
6. SHA256 TOFU: verify_or_cache(binary) + verify_or_cache(each ancestor)
7. Build OPA input: {exec: {path, ancestors, cmdline_paths}, network: {host, port}}
8. regorus eval → allow_network, deny_reason, matched_network_policy
9. Log: binary, pid, ancestors, cmdline, action, engine, policy, reason
```
## Implementation
### Dependencies
In `crates/navigator-sandbox/Cargo.toml`:
```toml
# OPA policy evaluation (no default features — full-opa pulls in opa-runtime which requires git in build)
regorus = { version = "0.9", default-features = false, features = ["std", "arc", "glob", "yaml"] }
```
Existing deps already provide: `sha2` (0.10), `hex` (0.4), `tokio` (workspace),
`nix` (workspace).
### Module: `crates/navigator-sandbox/src/opa.rs`
Wraps `regorus::Engine` for OPA policy evaluation. Thread-safe via
`Mutex<regorus::Engine>` because `eval` requires `&mut self`.
```rust
pub struct OpaEngine {
engine: Mutex<regorus::Engine>,
}
impl OpaEngine {
/// Load policy from a `.rego` rules file and data from a YAML file.
pub fn from_files(policy_path: &Path, data_path: &Path) -> Result<Self>;
/// Load policy rules and data from strings (data is YAML).
pub fn from_strings(policy: &str, data_yaml: &str) -> Result<Self>;
/// Evaluate a network access request.
/// Returns PolicyDecision { allowed, reason, matched_policy }.
pub fn evaluate_network(&self, input: &NetworkInput) -> Result<PolicyDecision>;
/// Reload policy and data from strings (data is YAML, for future gRPC hot-reload).
/// Replaces the entire engine atomically.
pub fn reload(&self, policy: &str, data_yaml: &str) -> Result<()>;
/// Query static policy data (filesystem, landlock, process config).
/// Used at startup to extract sandbox setup configuration from Rego data.
pub fn query_sandbox_config(&self) -> Result<SandboxConfig>;
}
pub struct NetworkInput {
pub host: String,
pub port: u16,
pub binary_path: PathBuf,
pub binary_sha256: String,
/// Ancestor binary paths from process tree walk (parent, grandparent, ...).
pub ancestors: Vec<PathBuf>,
/// Absolute paths extracted from /proc/<pid>/cmdline of the socket-owning
/// process and its ancestors.
pub cmdline_paths: Vec<PathBuf>,
}
pub struct PolicyDecision {
pub allowed: bool,
pub reason: String,
pub matched_policy: Option<String>,
}
pub struct SandboxConfig {
pub filesystem: FilesystemPolicy,
pub landlock: LandlockPolicy,
pub process: ProcessPolicy,
}
```
**OPA input JSON shape:**
```json
{
"exec": {
"path": "/usr/bin/node",
"ancestors": ["/usr/bin/bash"],
"cmdline_paths": ["/home/sandbox/.npm/lib/node_modules/claude/cli.mjs"]
},
"network": {
"host": "api.anthropic.com",
"port": 443
}
}
```
Note: `cmdline_paths` captures absolute paths from `/proc/<pid>/cmdline` argv
entries. This is a low-trust convenience signal — see Security Considerations.
For npm tools where the wrapper uses `exec`, the wrapper path (e.g.
`/usr/local/bin/claude`) will not appear here; only paths visible in the final
process's argv are captured.
### Module: `crates/navigator-sandbox/src/procfs.rs`
Linux-only `/proc` reading for process identity. Functions gated with
`#[cfg(target_os = "linux")]` except `file_sha256` which is cross-platform.
**Fail-closed identity resolution:** `binary_path()` uses only
`/proc/<pid>/exe` readlink. It never falls back to `/proc/<pid>/cmdline`
(`argv[0]`) because argv is trivially spoofable by any process. If the exe
symlink is unreadable (e.g. different user without `CAP_SYS_PTRACE`), the
function returns an error and the proxy denies the request.
```rust
/// Read binary path via /proc/<pid>/exe readlink. Fails hard (no argv[0] fallback)
/// if the exe symlink is unreadable — request will be denied.
pub fn binary_path(pid: i32) -> Result<PathBuf>;
/// Resolve TCP peer binary path and PID via /proc/<sandbox_pid>/net/tcp → socket
/// inode → FD scan → PID → /proc/<pid>/exe.
pub fn resolve_tcp_peer_identity(sandbox_pid: u32, peer_port: u16) -> Result<(PathBuf, u32)>;
/// Read the PPid (parent PID) from /proc/<pid>/status.
pub fn read_ppid(pid: u32) -> Option<u32>;
/// Walk the process tree upward from pid, collecting ancestor binary paths.
/// Stops at PID 1, stop_pid (sandbox root), or after 64 ancestors.
pub fn collect_ancestor_binaries(pid: u32, stop_pid: u32) -> Vec<PathBuf>;
/// Extract absolute paths from /proc/<pid>/cmdline (argv entries starting with /).
/// Note: cmdline is a convenience heuristic, not a security boundary.
pub fn cmdline_absolute_paths(pid: u32) -> Vec<PathBuf>;
/// Collect deduplicated cmdline paths for PID + ancestors, excluding paths
/// already captured via exe (to avoid duplicates with binary/ancestor lists).
pub fn collect_cmdline_paths(pid: u32, stop_pid: u32, exclude: &[PathBuf]) -> Vec<PathBuf>;
/// Compute SHA256 hash of a file, hex-encoded (cross-platform).
pub fn file_sha256(path: &Path) -> Result<String>;
```
### Module: `crates/navigator-sandbox/src/identity.rs`
SHA256 trust-on-first-use cache. Thread-safe for concurrent proxy connections.
```rust
pub struct BinaryIdentityCache {
hashes: Mutex<HashMap<PathBuf, String>>,
}
impl BinaryIdentityCache {
pub fn new() -> Self;
/// On first call for a path: compute SHA256, cache, return hash.
/// On subsequent calls: compute SHA256, compare with cached value.
/// Returns Ok(hash) if valid, Err if hash mismatch (binary tampered).
pub fn verify_or_cache(&self, path: &Path) -> Result<String>;
}
```
### Modified: `crates/navigator-sandbox/src/proxy.rs`
The proxy evaluates policy via `ConnectDecision` with full identity context:
```rust
struct ConnectDecision {
allowed: bool,
/// "opa" or "legacy"
engine: &'static str,
/// Resolved binary path (OPA only).
binary: Option<PathBuf>,
/// PID owning the socket (OPA only).
binary_pid: Option<u32>,
/// Ancestor binary paths from process tree walk (OPA only).
ancestors: Vec<PathBuf>,
/// Cmdline-derived absolute paths (OPA only).
cmdline_paths: Vec<PathBuf>,
/// Name of the matched policy rule (OPA allow only).
matched_policy: Option<String>,
/// Deny reason or error context.
reason: String,
}
```
**Key functions:**
- `evaluate_opa_tcp` (Linux): full flow — resolve peer identity via
`/proc/net/tcp` → ancestor walk → cmdline paths → TOFU verify all binaries →
OPA eval. Uses the sandbox PID stored in `AtomicU32` to scope the
`/proc/net/tcp` lookup and ancestor walk stop condition.
- `evaluate_opa_tcp` (non-Linux): stub returning deny with
"identity binding unavailable on this platform".
- `evaluate_legacy`: hostname allowlist via `is_allowed()` (unchanged).
**Unified log line:** Every CONNECT request produces a single `info!` log with:
`src_addr`, `src_port`, `proxy_addr`, `dst_host`, `dst_port`, `binary`,
`binary_pid`, `ancestors`, `cmdline`, `action`, `engine`, `policy`, `reason`.
### Modified: `crates/navigator-sandbox/src/lib.rs`
`run_sandbox()` accepts `policy_rules` and `policy_data` parameters. When provided,
`load_policy()` initializes the OPA engine and identity cache:
```rust
pub async fn run_sandbox(
// ... existing params ...
policy_rules: Option<String>,
policy_data: Option<String>,
// ... rest ...
) -> Result<i32> {
let (policy, opa_engine) = load_policy(
sandbox_id, navigator_endpoint,
policy_rules, policy_data,
).await?;
let identity_cache = opa_engine.as_ref().map(|_| {
Arc::new(BinaryIdentityCache::new())
});
// Shared PID: set after process spawn so the proxy can look up
// the sandbox process's /proc/net/tcp for identity binding.
let sandbox_pid = Arc::new(AtomicU32::new(0));
// ... proxy and process startup ...
// sandbox_pid.store(handle.pid(), Ordering::Release);
}
```
`load_policy()` priority: local files (rego rules + YAML data) → gRPC → error.
### Modified: `crates/navigator-sandbox/src/main.rs`
CLI flags:
```rust
/// Path to Rego policy file for OPA-based network access control.
/// Requires --policy-data to also be set.
#[arg(long, env = "NAVIGATOR_POLICY_RULES")]
policy_rules: Option<String>,
/// Path to YAML data file containing network policies and sandbox config.
/// Requires --policy-rules to also be set.
#[arg(long, env = "NAVIGATOR_POLICY_DATA")]
policy_data: Option<String>,
```
## Rego Policy
The Rego policy (`sandbox-policy.rego`) implements four `binary_allowed` clauses:
1. **Exact path match** (high trust) — guarded by `not contains(b.path, "*")`,
matches `b.path == exec.path`. Source: `/proc/<pid>/exe` readlink.
2. **Ancestor exact path match** (high trust) — matches any
`exec.ancestors[_]` against `b.path` (e.g., Python subprocess spawned by
glab can reach gitlab.com). Source: `/proc/<pid>/exe` readlink per ancestor.
3. **Cmdline exact path match** (low trust) — matches any
`exec.cmdline_paths[_]` against `b.path`. Source: `/proc/<pid>/cmdline`
which is spoofable. Useful as a convenience heuristic for shebang scripts
but should not be relied upon as a security boundary.
4. **Glob pattern match** (trust depends on source) — when `b.path` contains
`*`, matches against all paths (binary + ancestors + cmdline) via
`glob.match(b.path, ["/"], p)`.
Endpoint matching is host (case-insensitive via `lower()` on both sides) + port.
## Migration Strategy
| Path | Policy source | Identity binding | OPA evaluation |
|------|--------------|------------------|----------------|
| `--sandbox-id` + `--navigator-endpoint` | gRPC protobuf | Yes | Yes |
| `--policy-rules` + `--policy-data` | Rego rules + YAML data files | Yes | Yes |
Both paths use the OPA engine for network policy evaluation with process identity binding.
## Testing
### Test counts by module
| Module | Tests | Notes |
|--------|-------|-------|
| `opa.rs` | 21 | Policy evaluation, ancestors, cmdline, glob, reload, sandbox config |
| `procfs.rs` | 11 | SHA256 hashing (cross-platform), Linux-only `/proc` reading |
| `identity.rs` | 3 | TOFU cache, hash mismatch detection |
| `proxy.rs` | 3 | Allowlist matching, proxy allow/deny (Linux-only) |
### OPA tests added for v1 features
| Test | What it verifies |
|------|-----------------|
| `ancestor_binary_allowed` | Ancestor match allows access (glab → python subprocess) |
| `no_ancestor_match_denied` | Non-matching ancestor is denied |
| `deep_ancestor_chain_matches` | Match works at depth > 1 in ancestor chain |
| `empty_ancestors_falls_back_to_direct` | Direct path match still works with empty ancestors |
| `glob_pattern_matches_binary` | `"/usr/bin/*"` matches `/usr/bin/node` |
| `glob_pattern_matches_ancestor` | `"/usr/local/bin/*"` matches ancestor `/usr/local/bin/claude` |
| `glob_pattern_no_cross_segment` | `"/usr/bin/*"` does NOT match `/usr/bin/subdir/node` |
| `cmdline_path_matches_script_binary` | Cmdline path `/usr/local/bin/my-tool` matches policy binary |
| `cmdline_path_no_match_denied` | Non-matching cmdline path is denied |
| `cmdline_glob_pattern_matches` | Glob pattern matches via cmdline path |
## Security Considerations
**Trust boundaries and what each layer guarantees:**
| Layer | Source | Trust level | Notes |
|-------|--------|-------------|-------|
| Binary path | `/proc/<pid>/exe` readlink | **High** — kernel-maintained, not spoofable by the process | Primary identity signal. Fails closed if unreadable. |
| Ancestor chain | `/proc/<pid>/status` PPid walk + exe readlink | **High** — same kernel-maintained source per ancestor | Enables subprocess delegation (e.g. glab spawns python). |
| SHA256 TOFU | Hash of file at exe path | **High** — detects binary replacement mid-sandbox | Does not protect against pre-existing malicious binaries at sandbox launch. |
| Cmdline paths | `/proc/<pid>/cmdline` | **Low** — `argv` is writable by the owning process | Convenience heuristic for script detection. Not a security boundary. |
| Namespace isolation | Linux netns + veth | **High** — kernel-enforced, only sandbox traffic reaches proxy | Primary security boundary. Identity binding is defense-in-depth. |
**Known limitations:**
- **Cmdline spoofability:** A malicious process can set `argv[0]` to any value.
Cmdline matching is a convenience for identifying interpreter-based tools, not
a security control. The namespace boundary is the primary defense.
- **npm wrapper cmdline:** npm-style wrappers that use `exec node cli.mjs`
replace the shell process, so the wrapper path (e.g. `/usr/local/bin/claude`)
does not appear in node's final cmdline. For npm tools, match the interpreter
binary directly (`/usr/bin/node`) or use glob patterns on module paths.
- **TOFU scope:** Only exe binaries (from `/proc/<pid>/exe`) and their ancestors
are SHA256-hashed. Script files referenced via cmdline are path-matched but
not integrity-checked. See v2 roadmap for cmdline script TOFU.
## v2 Roadmap (Out of Scope)
These items are explicitly deferred:
- **Cmdline script TOFU** — SHA256 hash script files (e.g. `cli.mjs`) not just
interpreter binaries. Currently only exe binaries are hashed; scripts
referenced via cmdline are matched by path but not integrity-checked.
- **Namespace inode assertion** — verify `/proc/<pid>/ns/net` matches sandbox
netns inode for defense against PID reuse races.
- **gRPC policy bundles** — serving Rego from navigator gateway.
- **Policy hot-reload** — `OpaEngine::reload()` exists but not wired to gRPC.
- **Multi-tenant policy** — per-tenant policy namespacing in OPA data.
- **OPA sidecar mode** — external OPA server for centralized decision logging.
## References
- Dev policy rules: [`dev-sandbox-policy.rego`](/dev-sandbox-policy.rego)
- Dev policy data: [`dev-sandbox-policy.yaml`](/dev-sandbox-policy.yaml)
- OPA engine: [`crates/navigator-sandbox/src/opa.rs`](/crates/navigator-sandbox/src/opa.rs)
- Process identity: [`crates/navigator-sandbox/src/procfs.rs`](/crates/navigator-sandbox/src/procfs.rs)
- Binary integrity: [`crates/navigator-sandbox/src/identity.rs`](/crates/navigator-sandbox/src/identity.rs)
- Proxy implementation: [`crates/navigator-sandbox/src/proxy.rs`](/crates/navigator-sandbox/src/proxy.rs)
- Sandbox orchestration: [`crates/navigator-sandbox/src/lib.rs`](/crates/navigator-sandbox/src/lib.rs)
- Policy loading: [`crates/navigator-sandbox/src/policy.rs`](/crates/navigator-sandbox/src/policy.rs)
- Network namespace: [`crates/navigator-sandbox/src/sandbox/linux/netns.rs`](/crates/navigator-sandbox/src/sandbox/linux/netns.rs)
- Seccomp rules: [`crates/navigator-sandbox/src/sandbox/linux/seccomp.rs`](/crates/navigator-sandbox/src/sandbox/linux/seccomp.rs)
- Regorus crate: [github.com/microsoft/regorus](https://github.com/microsoft/regorus)
@@ -1,124 +0,0 @@
# Sandbox Policy Refactor: Single YAML + Typed Proto + Baked Rules
**Status:** Implemented
**Date:** 2026-02-11 (updated 2026-02-12)
## Goal
Consolidate sandbox policy into a single YAML file parsed by the CLI, transmitted as a fully-typed proto, and consumed by the sandbox with baked-in OPA rules. Eliminate the separate rego data file (`dev-sandbox-policy-data.rego`) as a user-facing artifact.
## Design Summary
### Single YAML policy file
The user maintains one file (`dev-sandbox-policy.yaml`) containing everything:
```yaml
version: 1
filesystem_policy:
include_workdir: true
read_only: ["/usr", "/lib"]
read_write: ["/sandbox", "/tmp"]
landlock:
compatibility: best_effort
process:
run_as_user: sandbox
run_as_group: sandbox
inference:
allowed_routing_hints:
- local
network_policies:
claude_code:
name: claude_code
endpoints:
- { host: api.anthropic.com, port: 443 }
- { host: statsig.anthropic.com, port: 443 }
- { host: sentry.io, port: 443 }
- { host: raw.githubusercontent.com, port: 443 }
- { host: platform.claude.com, port: 443 }
binaries:
- { path: /usr/local/bin/claude }
gitlab:
name: gitlab
endpoints:
- { host: gitlab.com, port: 443 }
- { host: gitlab.mycorp.com, port: 443 }
binaries:
- { path: /usr/bin/glab }
```
This file is **baked into the CLI** as the default (via `include_str!`). Users can override with `--sandbox-policy <path>` or `NAVIGATOR_SANDBOX_POLICY` env var.
This same file can also be loaded directly by the sandbox binary via `--policy-rules` + `--policy-data` for local development. Regorus reads the YAML natively (via its `yaml` feature).
### Proto (`sandbox.proto`)
Fully typed, reusing tags 2-5 (no backward-compat constraint):
```protobuf
message SandboxPolicy {
uint32 version = 1;
FilesystemPolicy filesystem = 2;
LandlockPolicy landlock = 3;
ProcessPolicy process = 4;
map<string, NetworkPolicyRule> network_policies = 5;
InferencePolicy inference = 6;
}
```
New messages for network policies: `NetworkPolicyRule`, `NetworkEndpoint`, `NetworkBinary`.
`LandlockPolicy.compatibility` changes from enum to string.
Old `NetworkPolicy`/`NetworkMode`/`ProxyPolicy` removed from proto (sandbox-internal concern).
### Data flow
```
YAML ──[CLI]──> Proto (typed) ──[server stores]──> Proto ──[sandbox fetches via gRPC]──> OPA engine
```
1. **CLI**: Parses YAML, populates typed `SandboxPolicy` proto, sends to server at sandbox creation.
2. **Server**: Stores proto as-is. Reads `inference` field directly for routing authorization. Returns full proto on `GetSandboxPolicy`.
3. **Sandbox**: Fetches proto via gRPC. Converts typed proto fields to JSON data at the root level, feeds to `engine.add_data_json()`. Uses baked-in rego rules (via `include_str!`). Rego rules reference `data.filesystem_policy`, `data.network_policies`, etc.
### Local dev override
For local development (`mise run sandbox`), the sandbox binary can load policy directly from files via `--policy-rules` + `--policy-data` (or `NAVIGATOR_POLICY_RULES` / `NAVIGATOR_POLICY_DATA` env vars). The rego rules file is loaded as the policy, and the YAML data file is loaded natively by regorus (via its `yaml` feature).
### Baked-in rego rules
The rego rules file (`dev-sandbox-policy.rego`) is baked into the **sandbox binary** via `include_str!`. The OPA engine is constructed from baked rules + data (either JSON from proto conversion, or YAML from the data file).
### TODO (future)
- Drop rego passthrough rules for filesystem/landlock/process — deserialize directly from proto with serde instead of querying OPA for static config.
### Open questions
1. **`NetworkMode`/`ProxyPolicy` still internal to sandbox** — the sandbox derives `NetworkMode::Proxy` when `network_policies` is non-empty in the proto. The proxy's bind address is still hardcoded/auto-detected. Is this the right default, or should there be an explicit way to set proxy config?
2. **`name` field in `NetworkPolicyRule`** — the proto has both the map key and a `name` field inside the message. The CLI defaults `name` to the map key if not set. Should we remove the `name` field from the proto and just use the map key?
## What was done
### Phase 1 (2026-02-11): Typed proto + baked rules
- Rewrote `sandbox.proto` with typed fields (tags 1-6)
- Added `NetworkPolicyRule`, `NetworkEndpoint`, `NetworkBinary` messages
- CLI parses YAML → typed `SandboxPolicy` proto via `DevSandboxPolicyFile` structs
- Sandbox bakes `dev-sandbox-policy.rego` via `include_str!`
- Added `OpaEngine::from_proto()` — converts proto fields to JSON data for regorus
- gRPC mode: sandbox fetches proto, constructs OPA engine from baked rules + proto-derived data
- File mode: sandbox loads rego rules + data file directly
### Phase 2 (2026-02-12): Delete rego data file, consolidate on YAML
- Deleted `dev-sandbox-policy-data.rego` — YAML is now the single source of truth for policy data
- Flattened `dev-sandbox-policy.yaml` — top-level keys (`filesystem_policy`, `landlock`, `process`, `inference`, `network_policies`), no nesting wrapper
- Renamed `filesystem` → `filesystem_policy` in YAML to match rego convention
- Updated rego rules: `data.sandbox.*` → `data.*` (dropped `sandbox.` prefix from data paths)
- Enabled regorus `yaml` feature — `from_files()` loads YAML data natively via `Value::from_yaml_file()`
- Updated `from_strings()` and `reload()` to accept YAML data (via `Value::from_yaml_str()`) instead of rego policies
- Removed `"sandbox"` wrapper from `proto_to_opa_data_json()` — JSON data emitted at root level
- Renamed sandbox CLI args: `--rego-policy`/`--rego-data` → `--policy-rules`/`--policy-data`
- Renamed env vars: `NAVIGATOR_REGO_POLICY`/`NAVIGATOR_REGO_DATA` → `NAVIGATOR_POLICY_RULES`/`NAVIGATOR_POLICY_DATA`
- Converted all inline test data from rego/JSON to YAML