mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
chore: ignore plans for now
This commit is contained in:
@@ -182,3 +182,6 @@ deploy/docker/.build/
|
||||
|
||||
# Local mise settings
|
||||
mise.local.toml
|
||||
|
||||
# Ignore plans for now
|
||||
architecture/plans
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user