|
| 1 | +# Sandbox Policy Refactor: Single YAML + Typed Proto + Baked Rules |
| 2 | + |
| 3 | +**Status:** Implemented |
| 4 | +**Date:** 2026-02-11 |
| 5 | + |
| 6 | +## Goal |
| 7 | + |
| 8 | +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 as a user-facing artifact. |
| 9 | + |
| 10 | +## Design Summary |
| 11 | + |
| 12 | +### Single YAML policy file |
| 13 | + |
| 14 | +The user maintains one file (`dev-sandbox-policy.yaml`) containing everything: |
| 15 | + |
| 16 | +```yaml |
| 17 | +version: 1 |
| 18 | +inference: |
| 19 | + allowed_routing_hints: |
| 20 | + - local |
| 21 | +filesystem: |
| 22 | + include_workdir: true |
| 23 | + read_only: ["/usr", "/lib"] |
| 24 | + read_write: ["/sandbox", "/tmp"] |
| 25 | +landlock: |
| 26 | + compatibility: best_effort |
| 27 | +process: |
| 28 | + run_as_user: sandbox |
| 29 | + run_as_group: sandbox |
| 30 | +network_policies: |
| 31 | + claude_code: |
| 32 | + endpoints: |
| 33 | + - { host: api.anthropic.com, port: 443 } |
| 34 | + - { host: statsig.anthropic.com, port: 443 } |
| 35 | + - { host: sentry.io, port: 443 } |
| 36 | + - { host: raw.githubusercontent.com, port: 443 } |
| 37 | + - { host: platform.claude.com, port: 443 } |
| 38 | + binaries: |
| 39 | + - { path: /usr/local/bin/claude } |
| 40 | + gitlab: |
| 41 | + endpoints: |
| 42 | + - { host: gitlab.com, port: 443 } |
| 43 | + - { host: gitlab.mycorp.com, port: 443 } |
| 44 | + binaries: |
| 45 | + - { path: /usr/bin/glab } |
| 46 | +``` |
| 47 | +
|
| 48 | +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. |
| 49 | + |
| 50 | +### Proto (`sandbox.proto`) |
| 51 | + |
| 52 | +Fully typed, reusing tags 2-5 (no backward-compat constraint): |
| 53 | + |
| 54 | +```protobuf |
| 55 | +message SandboxPolicy { |
| 56 | + uint32 version = 1; |
| 57 | + FilesystemPolicy filesystem = 2; |
| 58 | + LandlockPolicy landlock = 3; |
| 59 | + ProcessPolicy process = 4; |
| 60 | + map<string, NetworkPolicyRule> network_policies = 5; |
| 61 | + InferencePolicy inference = 6; |
| 62 | +} |
| 63 | +``` |
| 64 | + |
| 65 | +New messages for network policies: `NetworkPolicyRule`, `NetworkEndpoint`, `NetworkBinary`. |
| 66 | +`LandlockPolicy.compatibility` changes from enum to string. |
| 67 | +Old `NetworkPolicy`/`NetworkMode`/`ProxyPolicy` removed from proto (sandbox-internal concern). |
| 68 | + |
| 69 | +### Data flow |
| 70 | + |
| 71 | +``` |
| 72 | +YAML ──[CLI]──> Proto (typed) ──[server stores]──> Proto ──[sandbox fetches via gRPC]──> OPA engine |
| 73 | +``` |
| 74 | +
|
| 75 | +1. **CLI**: Parses YAML, populates typed `SandboxPolicy` proto, sends to server at sandbox creation. |
| 76 | +2. **Server**: Stores proto as-is. Reads `inference` field directly for routing authorization. Returns full proto on `GetSandboxPolicy`. |
| 77 | +3. **Sandbox**: Fetches proto via gRPC. Converts typed proto fields to JSON, wraps under `{"sandbox": {...}}` key, feeds to `engine.add_data_json()`. Uses baked-in rego rules (via `include_str!`). Rego rules are unchanged — they still reference `data.sandbox.*`. |
| 78 | +
|
| 79 | +### Baked-in rego rules |
| 80 | +
|
| 81 | +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 + JSON data derived from the proto. The `--rego-policy`/`--rego-data` CLI args on the sandbox binary are kept as dev-only overrides. |
| 82 | +
|
| 83 | +### TODO (future) |
| 84 | +
|
| 85 | +- Drop rego passthrough rules for filesystem/landlock/process — deserialize directly from proto with serde instead of querying OPA for static config. |
| 86 | +- Remove the `--rego-policy`/`--rego-data` sandbox args once the gRPC path is fully proven. |
| 87 | +- Delete `dev-sandbox-policy-data.rego` once all tests are migrated to use `from_proto` or inline rego data. |
| 88 | +
|
| 89 | +### Questions for review |
| 90 | +
|
| 91 | +1. **`dev-sandbox-policy-data.rego` kept for now** — it's still used by the existing `from_strings` OPA tests and the `--rego-policy`/`--rego-data` dev override path. Should we migrate the `from_strings` tests to `from_proto` and remove the file? |
| 92 | +2. **`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? |
| 93 | +3. **`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? |
| 94 | +
|
| 95 | +## Implementation Steps |
| 96 | +
|
| 97 | +### Step 1: Update `sandbox.proto` |
| 98 | +
|
| 99 | +- Rewrite `SandboxPolicy` with typed fields on tags 1-6 |
| 100 | +- Add new messages: `NetworkPolicyRule`, `NetworkEndpoint`, `NetworkBinary` |
| 101 | +- Change `LandlockPolicy.compatibility` from enum to string |
| 102 | +- Remove old `NetworkPolicy`, `NetworkMode`, `ProxyPolicy`, `ProxyConfig` messages from proto |
| 103 | +- Keep `InferencePolicy`, `GetSandboxPolicyRequest`, `GetSandboxPolicyResponse` as-is |
| 104 | +- Keep `LandlockCompatibility` enum removed (replaced by string field) |
| 105 | +
|
| 106 | +### Step 2: Regenerate proto code |
| 107 | +
|
| 108 | +- Run `mise run build` (or `cargo build -p navigator-core`) to trigger `tonic_build` codegen |
| 109 | +- The generated `navigator.sandbox.v1.rs` will reflect the new proto shape |
| 110 | +
|
| 111 | +### Step 3: Update `dev-sandbox-policy.yaml` |
| 112 | +
|
| 113 | +- Expand to include all policy fields (filesystem, landlock, process, network_policies) |
| 114 | +- This becomes the single source of truth |
| 115 | +
|
| 116 | +### Step 4: Update CLI (`navigator-cli`) |
| 117 | +
|
| 118 | +- Bake `dev-sandbox-policy.yaml` via `include_str!` in `run.rs` |
| 119 | +- Rewrite `DevSandboxPolicyFile` struct and `load_dev_sandbox_policy()` to match new YAML shape |
| 120 | +- Convert parsed YAML → typed `SandboxPolicy` proto (using new proto messages) |
| 121 | +- Support `--sandbox-policy <path>` flag / `NAVIGATOR_SANDBOX_POLICY` env var to override |
| 122 | +- Update `print_sandbox_policy()` and `policy_to_yaml()` for the new proto shape |
| 123 | +- Remove old `DevFilesystemPolicy`, `DevNetworkPolicy`, `DevProxyPolicy`, `DevLandlockPolicy`, `DevProcessPolicy` structs (replace with new ones matching the flat YAML) |
| 124 | +
|
| 125 | +### Step 5: Update sandbox (`navigator-sandbox`) |
| 126 | +
|
| 127 | +**`policy.rs`:** |
| 128 | +- Update `SandboxPolicy` (internal) and `TryFrom<ProtoSandboxPolicy>` conversion |
| 129 | +- `NetworkMode` / `NetworkPolicy` / `ProxyPolicy` remain as internal types but are no longer derived from proto — instead, the sandbox sets `NetworkMode::Proxy` when `network_policies` is non-empty, `NetworkMode::Block` otherwise |
| 130 | +- Update `FilesystemPolicy`, `LandlockPolicy`, `ProcessPolicy` conversions for new proto shape |
| 131 | +
|
| 132 | +**`opa.rs`:** |
| 133 | +- Bake `dev-sandbox-policy.rego` via `include_str!` as `const BAKED_POLICY_RULES: &str` |
| 134 | +- Add a new constructor: `OpaEngine::from_policy_proto(proto: &ProtoSandboxPolicy) -> Result<Self>` that: |
| 135 | + 1. Loads baked rules via `engine.add_policy()` |
| 136 | + 2. Converts proto `network_policies` (and filesystem/landlock/process for rego passthrough compatibility) to JSON matching the `data.sandbox.*` shape the rego rules expect |
| 137 | + 3. Loads the JSON via `engine.add_data_json()` |
| 138 | +- Keep `from_files()` and `from_strings()` for dev/testing |
| 139 | +
|
| 140 | +**`lib.rs` (`load_policy`):** |
| 141 | +- In gRPC mode: after fetching proto, construct `OpaEngine` from proto (using new constructor) instead of returning `None` |
| 142 | +- In rego file mode: keep as-is (dev override) |
| 143 | +
|
| 144 | +### Step 6: Update server (`navigator-server`) |
| 145 | +
|
| 146 | +**`inference.rs`:** |
| 147 | +- The `InferencePolicy` extraction path stays the same (it reads `sandbox.spec.policy.inference`) |
| 148 | +- No changes needed — the server is a passthrough for the policy proto |
| 149 | +
|
| 150 | +**`grpc.rs`:** |
| 151 | +- `GetSandboxPolicy` handler stays the same — returns stored proto |
| 152 | +
|
| 153 | +### Step 7: Fix compilation across crates |
| 154 | +
|
| 155 | +- `navigator-sandbox/src/process.rs` — update references to `policy.network.mode` |
| 156 | +- `navigator-sandbox/src/sandbox/linux/seccomp.rs` — same |
| 157 | +- `navigator-sandbox/src/proxy.rs` — update `ProxyPolicy` usage |
| 158 | +- `navigator-sandbox/src/ssh.rs` — update `SandboxPolicy` usage |
| 159 | +- Test files in `navigator-cli/tests/` and `navigator-server/tests/` — update mock `SandboxPolicy` construction |
| 160 | +- `navigator-core/src/proto/mod.rs` — update re-exports if needed |
| 161 | +
|
| 162 | +### Step 8: Delete obsolete files |
| 163 | +
|
| 164 | +- `dev-sandbox-policy-data.rego` — replaced by YAML → proto → JSON flow |
| 165 | +- The rego rules file (`dev-sandbox-policy.rego`) stays in the repo but is now baked into the sandbox binary |
| 166 | +
|
| 167 | +### Step 9: Tests |
| 168 | +
|
| 169 | +- Update existing OPA engine tests to work with proto-based constructor |
| 170 | +- Update CLI policy loading tests |
| 171 | +- Update server integration test mocks for new proto shape |
| 172 | +- Verify `mise run test:rust` passes |
| 173 | +
|
| 174 | +### Step 10: Pre-commit and build verification |
| 175 | +
|
| 176 | +- `mise run pre-commit` |
| 177 | +- `mise run build` |
| 178 | +- `mise run test` |
0 commit comments