From 66b3bbb287a670b36dcfd595f2d50b4a61ee72a4 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Thu, 21 May 2026 09:14:38 -0400 Subject: [PATCH] refactor(sandbox): remove binary path allowlisting entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binary identity enforcement via `/proc//exe` is nearly impossible to make reliable. There is a reason that security systems like SELinux assign security "domains" to a process that are preserved across `execve()`. One of the biggest is `LD_PRELOAD` and `LD_LIBRARY_PATH`: the glibc dynamic linker will easily load arbitrary shared libraries into the address space even for "fixed purpose" binaries. And most uses of agents will have *some* writable directory. But a much bigger problem is that many binaries (including `claude`) effectively include the ability to execute arbitrary code inside that process - they are interpreters. For example, `claude` is a Bun single-file executable. A sandbox policy allowlisting only `claude` to reach api.anthropic.com is bypassed with: printf "process.stderr.write('INJECTED\n'" > /tmp/e.js BUN_OPTIONS="--preload /tmp/e.js" claude --version # => INJECTED # => 2.1.146 (Claude Code) Arbitrary JS runs inside the claude process before the app starts — with full access to credentials and sockets — while `/proc//exe` still shows `claude`. The binary check passes; the exfiltration is permitted. It would absolutely be possible to try to craft an execution environment for an agent that closed some of these loopholes, but my opinion is that anyone doing that is already in the business of minimizing what code goes into the container, and at which point they are actually doing something better: restricting what binaries *are present at all*. That said, to do anything truly strong here gets into things like [trusted_for](https://lwn.net/Articles/832959/) etc. Policy evaluation is now just enforced based on code running within the sandbox - the same way that many other network enforcement tools work. We do not claim that we can reliably drill down into the binary-name level. Signed-off-by: Colin Walters --- .../skills/generate-sandbox-policy/SKILL.md | 36 +- .../generate-sandbox-policy/examples.md | 64 +- .agents/skills/openshell-cli/SKILL.md | 4 +- .agents/skills/openshell-cli/cli-reference.md | 1 - Cargo.lock | 138 +- architecture/README.md | 2 +- architecture/sandbox.md | 6 +- architecture/security-policy.md | 11 +- crates/openshell-cli/src/main.rs | 6 - crates/openshell-cli/src/policy_update.rs | 105 +- crates/openshell-cli/src/run.rs | 9 - .../tests/provider_commands_integration.rs | 7 - crates/openshell-policy/Cargo.toml | 2 + crates/openshell-policy/src/compose.rs | 1 - crates/openshell-policy/src/lib.rs | 122 +- crates/openshell-policy/src/merge.rs | 388 +---- .../registry/binaries/claude.yaml | 30 - .../registry/binaries/curl.yaml | 20 - .../registry/binaries/gh.yaml | 19 - .../registry/binaries/git.yaml | 28 - .../registry/binaries/nc.yaml | 18 - .../registry/binaries/node.yaml | 21 - .../registry/binaries/python3.yaml | 24 - .../registry/binaries/ssh.yaml | 18 - .../registry/binaries/wget.yaml | 17 - crates/openshell-prover/src/lib.rs | 40 +- crates/openshell-prover/src/model.rs | 176 +- crates/openshell-prover/src/policy.rs | 58 +- crates/openshell-prover/src/queries.rs | 173 +- crates/openshell-prover/src/registry.rs | 264 +-- crates/openshell-prover/testdata/policy.yaml | 6 - crates/openshell-providers/src/profiles.rs | 104 +- crates/openshell-sandbox/Cargo.toml | 2 - .../data/sandbox-policy.rego | 61 +- .../openshell-sandbox/src/bypass_monitor.rs | 8 - .../src/denial_aggregator.rs | 22 +- crates/openshell-sandbox/src/identity.rs | 329 ---- crates/openshell-sandbox/src/l7/graphql.rs | 4 - crates/openshell-sandbox/src/l7/mod.rs | 80 +- crates/openshell-sandbox/src/l7/relay.rs | 47 +- crates/openshell-sandbox/src/l7/rest.rs | 12 - crates/openshell-sandbox/src/l7/websocket.rs | 8 - crates/openshell-sandbox/src/lib.rs | 91 +- .../src/mechanistic_mapper.rs | 71 +- crates/openshell-sandbox/src/opa.rs | 1454 +---------------- crates/openshell-sandbox/src/policy_local.rs | 57 +- crates/openshell-sandbox/src/procfs.rs | 122 +- crates/openshell-sandbox/src/proxy.rs | 853 +--------- .../testdata/sandbox-policy.yaml | 12 - crates/openshell-server/src/grpc/policy.rs | 170 +- crates/openshell-server/src/grpc/provider.rs | 15 +- .../openshell-server/src/grpc/validation.rs | 1 - crates/openshell-server/src/policy_store.rs | 6 +- crates/openshell-tui/src/lib.rs | 9 - crates/openshell-tui/src/ui/sandbox_draft.rs | 33 +- docs/about/how-it-works.mdx | 2 +- docs/about/supported-agents.mdx | 4 +- .../tutorials/first-network-policy.mdx | 11 +- docs/get-started/tutorials/github-sandbox.mdx | 26 - docs/observability/logging.mdx | 2 +- docs/reference/default-policy.mdx | 6 +- docs/reference/policy-schema.mdx | 25 +- docs/sandboxes/policies.mdx | 49 +- docs/security/best-practices.mdx | 20 +- e2e/policy-advisor/policy.template.yaml | 2 - e2e/policy-advisor/sandbox-runner.sh | 8 +- e2e/python/test_sandbox_policy.py | 47 - e2e/rust/tests/forward_proxy_graphql_l7.rs | 4 - e2e/rust/tests/forward_proxy_l7_bypass.rs | 5 - e2e/rust/tests/host_gateway_alias.rs | 2 - e2e/rust/tests/live_policy_update.rs | 2 - e2e/rust/tests/websocket_conformance.rs | 4 - .../policy.template.yaml | 10 - examples/local-inference/sandbox-policy.yaml | 4 - .../multi-agent-notepad/policy.template.yaml | 10 - .../sandbox-policy-quickstart/policy.yaml | 2 - proto/openshell.proto | 43 +- proto/sandbox.proto | 15 +- providers/claude-code.yaml | 1 - providers/github.yaml | 1 - providers/nvidia.yaml | 1 - scripts/smoke-test-network-policy.sh | 8 - 82 files changed, 506 insertions(+), 5193 deletions(-) delete mode 100644 crates/openshell-prover/registry/binaries/claude.yaml delete mode 100644 crates/openshell-prover/registry/binaries/curl.yaml delete mode 100644 crates/openshell-prover/registry/binaries/gh.yaml delete mode 100644 crates/openshell-prover/registry/binaries/git.yaml delete mode 100644 crates/openshell-prover/registry/binaries/nc.yaml delete mode 100644 crates/openshell-prover/registry/binaries/node.yaml delete mode 100644 crates/openshell-prover/registry/binaries/python3.yaml delete mode 100644 crates/openshell-prover/registry/binaries/ssh.yaml delete mode 100644 crates/openshell-prover/registry/binaries/wget.yaml delete mode 100644 crates/openshell-sandbox/src/identity.rs diff --git a/.agents/skills/generate-sandbox-policy/SKILL.md b/.agents/skills/generate-sandbox-policy/SKILL.md index 97f6dbe31..c923a4026 100644 --- a/.agents/skills/generate-sandbox-policy/SKILL.md +++ b/.agents/skills/generate-sandbox-policy/SKILL.md @@ -78,9 +78,6 @@ Regardless of tier, extract (or infer) these from the user's description: | **Methods** | Specific HTTP methods to allow | Only for custom/fine-grained | | **Paths** | Specific URL paths or patterns | Only for custom/fine-grained | | **Enforcement** | `enforce` or `audit`? Default to `enforce`. | No — has a default | -| **Binary** | Which binary/process should have access | Yes — ask if not stated | - -If the host and access level are clear but binaries are not specified, ask the user which binary or process will be making the requests. Suggest common defaults like `/usr/bin/curl`, `/usr/local/bin/claude`, etc. ## Step 2: Refine Scope (Clarification Loop) @@ -92,7 +89,6 @@ Always ask about these if the user hasn't already specified them: | Missing info | Question to ask | |-------------|----------------| -| **Binary** not specified | "Which binary or process will make these requests? (e.g., `/usr/bin/curl`, `/usr/local/bin/claude`)" | | **Port** not specified | "Which port does this API use? (443 for HTTPS is typical)" | | **Enforcement** not stated | "Should policy violations be blocked (`enforce`) or just logged for review (`audit`)? I'll default to `enforce` if you're not sure." | @@ -105,7 +101,6 @@ Ask these when the user's intent is broad and more specificity is possible: | "Full access" / "allow everything" | "Do you actually need DELETE access, or would read-write (everything except DELETE) be enough?" | | "Allow access to api.example.com" (no method/path detail) | "Do you know which specific API paths or operations you need? If so, I can lock the policy down to just those. Otherwise I'll use a broad preset." | | L4-only / "just pass it through" | "L4-only means the proxy won't inspect HTTP traffic at all — any method and path will be allowed. Are you sure you don't want at least read-only or read-write restriction?" | -| Wildcard binary (`/usr/bin/*`) | "A wildcard binary pattern means any binary in that directory can use this policy. Can you narrow it to specific binaries?" | | Multiple hosts in one policy | "Do all of these hosts need the same access level? If some need tighter restrictions, I can split them into separate policies." | | `access: full` with `enforcement: audit` | "Full access in audit mode means nothing is actually restricted — all traffic flows through and violations are only logged. Is that intentional, or did you want to enforce restrictions?" | | `**` path glob on all rules | "Using `**` on all paths allows any URL path. Do you know the specific API path prefixes you need (e.g., `/api/v1/`)?" | @@ -262,10 +257,10 @@ network_policies: # Optional: allow private IP destinations (CIDR or exact IP) # allowed_ips: # - "10.0.5.0/24" - binaries: - - { path: } ``` +> **Note:** Binary allowlisting has been removed. The `binaries:` field is accepted in policy YAML for backward compatibility but is silently ignored — do not generate it in new policies. + ### Deny Rules Use `deny_rules` to block specific dangerous operations while allowing broad access. Deny rules are evaluated after allow rules and take precedence. This is the inverse of the `rules` approach — instead of enumerating every allowed operation, you grant broad access and block a small set of dangerous ones. @@ -287,8 +282,6 @@ github_api: path: "/repos/*/branches/*/protection" - method: "*" path: "/repos/*/rulesets" - binaries: - - { path: /usr/bin/curl } ``` Deny rules support the same matching capabilities as allow rules: `method`, `path`, `command` (SQL), and `query` parameter matchers. When generating policies, prefer deny rules when the user needs broad access with a small set of blocked operations — it produces a shorter, more maintainable policy than enumerating 60+ allow rules. @@ -311,8 +304,6 @@ internal_api: port: 8080 allowed_ips: - "10.0.5.0/24" - binaries: - - { path: /usr/bin/curl } ``` ### Policy Key Naming @@ -345,9 +336,8 @@ Before presenting the policy to the user, verify correctness **and** flag breadt ### Structural Checks -- [ ] Every policy has `name`, `endpoints`, and `binaries` +- [ ] Every policy has `name` and `endpoints` - [ ] Every endpoint has `host` and `port` -- [ ] Every binary has `path` - [ ] Policy key matches `name` field ### Breadth Warnings @@ -360,7 +350,6 @@ Evaluate the generated policy for overly broad access and **include warnings in | **`access: full`** | "This policy allows all HTTP methods (including DELETE) on all paths. If you don't need DELETE, `read-write` is safer. If you only need to read, `read-only` is the most restrictive option." | | **`access: full` + `enforcement: audit`** | "Full access in audit mode provides no actual restriction — all traffic flows through. This is effectively a monitoring-only policy." | | **`access: read-write`** when user hasn't confirmed write need | "This policy allows POST, PUT, and PATCH on all paths. If you only need to read data, `read-only` is more restrictive." | -| **Wildcard binary** (`*` or `**` in binary path) | "This policy allows any binary matching the glob pattern. A compromised or unexpected binary in that directory could use this policy. Consider listing specific binary paths." | | **`**` path glob** on all explicit rules | "All rules use `**` path patterns, which match any URL path. This is equivalent to a preset — consider using `access: read-only` (or similar) for clarity, or narrowing paths if you know the API structure." | | **Multiple broad endpoints** in one policy | "This policy grants the same broad access to N different hosts. If any of these hosts needs tighter restrictions later, you'll need to split the policy." | | **Hostless `allowed_ips`** (no `host` field) | "This endpoint has no `host` — any domain resolving to the allowed IP range on this port will be permitted. Consider adding a `host` field to restrict which domains can use this allowlist." | @@ -396,12 +385,12 @@ The policy needs to go somewhere. Determine which mode applies: - Whether the file uses compact (`{ host: ..., port: ... }`) or expanded YAML style 2. **Check for conflicts**: - - Does a policy with the same key already exist? If so, ask the user whether to **replace** it, **merge** new endpoints/binaries into it, or use a different key. + - Does a policy with the same key already exist? If so, ask the user whether to **replace** it, **merge** new endpoints into it, or use a different key. - Does an existing policy already cover the same host:port? Warn the user — overlapping endpoint coverage across policies causes OPA evaluation errors (complete rule conflict). 3. **Apply the change**: - **Adding a new policy**: Insert the new policy block under `network_policies`, maintaining the file's existing indentation and style. - - **Modifying an existing policy**: Edit the specific policy in place — add/remove endpoints, change access presets, update rules, add binaries, etc. + - **Modifying an existing policy**: Edit the specific policy in place — add/remove endpoints, change access presets, update rules, etc. - **Removing a policy**: Delete the policy block if the user asks. 4. **Preserve everything else**: Do not modify `filesystem_policy`, `landlock`, `process`, or other policies unless the user explicitly asks. @@ -458,7 +447,7 @@ Show the generated policy YAML with: After presenting or applying the policy, ask if the user wants to: - Tighten or loosen any rules -- Add more endpoints or binaries +- Add more endpoints - Switch between enforce/audit mode - Move from a preset to explicit rules (or vice versa) - Apply the policy to a file (if presented only) @@ -473,8 +462,6 @@ my_api: name: my_api endpoints: - { host: api.example.com, port: 443 } - binaries: - - { path: /usr/bin/curl } ``` ### HTTPS API with Read-Only Preset @@ -489,8 +476,6 @@ my_api_readonly: tls: terminate enforcement: enforce access: read-only - binaries: - - { path: /usr/bin/curl } ``` ### HTTPS API with Explicit Rules @@ -511,9 +496,6 @@ my_api_custom: - allow: method: POST path: "/api/v1/data" - binaries: - - { path: /usr/bin/curl } - - { path: /usr/local/bin/myapp } ``` ### HTTP (non-TLS) Internal API @@ -533,8 +515,6 @@ internal_svc: - allow: method: POST path: "/api/v1/jobs" - binaries: - - { path: /usr/bin/curl } ``` ### Private IP Access (Host + Allowlist) @@ -547,8 +527,6 @@ internal_db: port: 5432 allowed_ips: - "10.0.5.0/24" - binaries: - - { path: /usr/bin/curl } ``` ### Private IP Access (Hostless — Any Domain in Range) @@ -561,8 +539,6 @@ private_services: allowed_ips: - "10.0.5.0/24" - "10.0.6.0/24" - binaries: - - { path: /usr/bin/curl } ``` ## Additional Resources diff --git a/.agents/skills/generate-sandbox-policy/examples.md b/.agents/skills/generate-sandbox-policy/examples.md index b6acbee8b..d8b49d5da 100644 --- a/.agents/skills/generate-sandbox-policy/examples.md +++ b/.agents/skills/generate-sandbox-policy/examples.md @@ -26,11 +26,9 @@ network_policies: endpoints: - { host: api.anthropic.com, port: 443 } - { host: statsig.anthropic.com, port: 443 } - binaries: - - { path: /usr/local/bin/claude } ``` -No `protocol`, `rules`, or `access` — this is pure L4 (host:port + binary identity check). +No `protocol`, `rules`, or `access` — this is pure L4 (host:port check). --- @@ -50,8 +48,6 @@ network_policies: protocol: rest enforcement: enforce access: read-only - binaries: - - { path: /usr/bin/curl } ``` Allows GET, HEAD, OPTIONS on all paths. Blocks POST, PUT, PATCH, DELETE. @@ -72,8 +68,6 @@ network_policies: protocol: rest enforcement: enforce access: full - binaries: - - { path: /usr/local/bin/opencode } ``` Allows all HTTP methods on all paths. @@ -94,8 +88,6 @@ network_policies: protocol: rest enforcement: audit access: read-write - binaries: - - { path: /app/bin/worker } ``` Allows GET, HEAD, OPTIONS, POST, PUT, PATCH. Blocks DELETE. Audit mode logs violations without blocking. @@ -121,11 +113,9 @@ network_policies: protocol: rest enforcement: enforce access: read-only - binaries: - - { path: /usr/bin/curl } ``` -Same preset applied to multiple hosts in one policy because the binary set is the same. +Same preset applied to multiple hosts in one policy. --- @@ -169,8 +159,6 @@ network_policies: - allow: method: GET path: "/v1/models/*" - binaries: - - { path: /usr/bin/curl } ``` Without auto-discovery, this would have been `access: full` or `access: read-write`. The search enabled a much tighter policy. @@ -215,8 +203,6 @@ network_policies: - allow: method: POST path: "/v1/chat/completions" - binaries: - - { path: /usr/bin/curl } ``` Note: added `/v1/models/*` alongside `/v1/models` since listing models and getting a specific model are both common read operations. @@ -251,8 +237,6 @@ network_policies: - allow: method: POST path: "/repos/*/issues" - binaries: - - { path: /usr/bin/curl } ``` Cannot use `access: read-only` here because we also need POST on one path. The explicit rules replicate the read-only preset and add the targeted write. @@ -304,8 +288,6 @@ network_policies: protocol: rest enforcement: enforce access: read-only - binaries: - - { path: /usr/bin/curl } ``` **What this allows**: GET, HEAD, OPTIONS on any path. @@ -341,7 +323,6 @@ Endpoints: - Scope: `integrate.api.nvidia.com:443` - Methods: POST on `/v1/chat/completions`, GET on `/v1/models` and `/v1/models/*` - No preset fits — need explicit rules -- Two binaries ### Output @@ -364,9 +345,6 @@ network_policies: - allow: method: POST path: "/v1/chat/completions" - binaries: - - { path: /usr/bin/curl } - - { path: /usr/local/bin/opencode } ``` **What this allows**: GET `/v1/models`, GET `/v1/models/{id}`, POST `/v1/chat/completions`. @@ -415,8 +393,6 @@ network_policies: protocol: rest enforcement: audit access: read-write - binaries: - - { path: /usr/bin/curl } ``` **What this allows**: GET, HEAD, OPTIONS, POST, PUT, PATCH on all paths. @@ -535,8 +511,6 @@ network_policies: - allow: method: GET path: "/projects/*/members" - binaries: - - { path: /app/bin/pm-cli } ``` **What this allows**: Full CRUD on projects and tasks, GET-only on members. @@ -552,9 +526,9 @@ network_policies: ### Analysis -- Anthropic API: L4-only (no inspection), standard claude binary +- Anthropic API: L4-only (no inspection) - Internal docs: L7 with read-only, HTTP so no TLS config needed -- Two separate policies because different binaries +- Two separate policies for different access levels ### Output @@ -565,8 +539,6 @@ network_policies: endpoints: - { host: api.anthropic.com, port: 443 } - { host: statsig.anthropic.com, port: 443 } - binaries: - - { path: /usr/local/bin/claude } internal_docs_readonly: name: internal_docs_readonly @@ -576,19 +548,17 @@ network_policies: protocol: rest enforcement: enforce access: read-only - binaries: - - { path: /usr/local/bin/claude } ``` **Note**: The first policy has no `protocol` field — this means L4-only (host:port check, no HTTP inspection). The second policy has `protocol: rest` so every HTTP request is inspected. --- -## Example 6: Wildcard Binary Patterns +## Example 6: Targeted Read Metrics Policy ### Input: User Intent -> "Any binary under /usr/local/bin/ should be able to hit our metrics endpoint at metrics.corp.com:443, but only GET /metrics and GET /api/v1/query. Enforce it." +> "Allow access to our metrics endpoint at metrics.corp.com:443, but only GET /metrics and GET /api/v1/query. Enforce it." ### Output @@ -608,12 +578,8 @@ network_policies: - allow: method: GET path: "/api/v1/query" - binaries: - - { path: "/usr/local/bin/*" } ``` -**Note**: The `*` glob in the binary path matches any binary directly inside `/usr/local/bin/` but does not cross `/` boundaries. Use `/usr/local/**` to match recursively. - --- ## File Operation Examples (update existing / create new) @@ -635,8 +601,6 @@ network_policies: port: 8080 allowed_ips: - "10.0.5.0/24" - binaries: - - { path: /usr/bin/curl } ``` **What this allows**: Connections to `api.internal.corp:8080` when DNS resolves to any IP in `10.0.5.0/24`. @@ -659,8 +623,6 @@ network_policies: allowed_ips: - "10.0.5.0/24" - "10.0.6.0/24" - binaries: - - { path: /usr/local/bin/opencode } ``` **What this allows**: Any hostname on port 8080 whose DNS resolves to `10.0.5.0/24` or `10.0.6.0/24`. @@ -688,8 +650,6 @@ network_policies: access: read-only allowed_ips: - "172.16.1.50" - binaries: - - { path: /usr/bin/curl } ``` **What this allows**: GET, HEAD, OPTIONS on any path to `db-proxy.internal:3128`, only if it resolves to `172.16.1.50`. @@ -710,8 +670,6 @@ network_policies: port: 9090 allowed_ips: - "10.0.5.20" - binaries: - - { path: /usr/bin/curl } ``` An exact IP is treated as `/32` — only that specific address is permitted. @@ -742,8 +700,6 @@ An exact IP is treated as `/32` — only that specific address is permitted. protocol: rest enforcement: enforce access: read-only - binaries: - - { path: /usr/bin/curl } ``` The agent uses `StrReplace` to insert after the last existing policy in the `network_policies` block. All other sections (`filesystem_policy`, `landlock`, `process`) are untouched. @@ -768,8 +724,6 @@ Before: endpoints: - { host: api.anthropic.com, port: 443 } - { host: statsig.anthropic.com, port: 443 } - binaries: - - { path: /usr/local/bin/claude } ``` After: @@ -780,8 +734,6 @@ After: - { host: api.anthropic.com, port: 443 } - { host: statsig.anthropic.com, port: 443 } - { host: sentry.io, port: 443 } - binaries: - - { path: /usr/local/bin/claude } ``` --- @@ -843,8 +795,6 @@ network_policies: protocol: rest enforcement: enforce access: read-only - binaries: - - { path: /usr/local/bin/claude } anthropic_full: name: anthropic_full @@ -854,8 +804,6 @@ network_policies: protocol: rest enforcement: enforce access: full - binaries: - - { path: /usr/local/bin/claude } ``` The agent notes that `filesystem_policy`, `landlock`, and `process` are sensible defaults that may need adjustment, and that gateway inference is configured separately via `openshell inference set/get` rather than an `inference` policy block. diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index 4b7501c1f..b4d2c807d 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -418,7 +418,7 @@ Watch for `deny` actions that indicate the user's work is being blocked by polic When denied actions are observed: 1. Prefer incremental updates for additive network changes: - `openshell policy update work-session --add-endpoint api.github.com:443:read-only:rest:enforce --binary /usr/bin/gh --wait` + `openshell policy update work-session --add-endpoint api.github.com:443:read-only:rest:enforce --wait` `openshell policy update work-session --add-allow 'api.github.com:443:POST:/repos/*/issues' --wait` 2. Use full YAML replacement when the change is broad or touches non-network fields: `openshell policy get work-session --full > policy.yaml` @@ -537,7 +537,7 @@ $ openshell sandbox upload --help | Create with custom policy | `openshell sandbox create --policy ./p.yaml` | | Connect to sandbox | `openshell sandbox connect ` | | Stream live logs | `openshell logs --tail` | -| Incremental policy update | `openshell policy update --add-endpoint host:443:read-only:rest:enforce --binary /usr/bin/curl --wait` | +| Incremental policy update | `openshell policy update --add-endpoint host:443:read-only:rest:enforce --wait` | | Pull current policy | `openshell policy get --full > p.yaml` | | Push updated policy | `openshell policy set --policy p.yaml --wait` | | Policy revision history | `openshell policy list ` | diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index 799850232..c00ddd12b 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -256,7 +256,6 @@ Incrementally merge live network policy changes into the current sandbox policy. | `--add-allow ` | repeatable | `host:port:METHOD:path_glob`. Adds REST allow rules to an existing `protocol: rest` endpoint. | | `--add-deny ` | repeatable | `host:port:METHOD:path_glob`. Adds REST deny rules to an existing `protocol: rest` endpoint that already has an allow base. | | `--remove-rule ` | repeatable | Deletes a named network rule. | -| `--binary ` | repeatable | Adds binaries to each `--add-endpoint` rule. Valid only with `--add-endpoint`. | | `--rule-name ` | none | Overrides the generated rule name. Valid only when exactly one `--add-endpoint` is provided. | | `--dry-run` | false | Preview the merged policy locally without sending an update to the gateway. | | `--wait` | false | Wait for the sandbox to confirm the new policy revision is loaded. | diff --git a/Cargo.lock b/Cargo.lock index 9e73bce83..dae59ae0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -147,9 +147,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "apollo-parser" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "947e21ff51879f8a40d7519dfe619268de2afba4042a8a43878276de3cb910f0" +checksum = "11f6e2be4b3474e5890d34d3e483d49ec9b5cbf9e358bc62c9cc5423376df54b" dependencies = [ "memchr", "rowan", @@ -671,12 +671,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cexpr" version = "0.6.0" @@ -2485,27 +2479,32 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror 2.0.18", "walkdir", - "windows-sys 0.45.0", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", ] [[package]] @@ -2592,9 +2591,9 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "10.3.0" +version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "base64 0.22.1", "getrandom 0.2.17", @@ -2602,6 +2601,7 @@ dependencies = [ "serde", "serde_json", "signature 2.2.0", + "zeroize", ] [[package]] @@ -3313,12 +3313,12 @@ dependencies = [ "futures-util", "http", "http-auth", - "jsonwebtoken 10.3.0", + "jsonwebtoken 10.4.0", "lazy_static", "oci-spec", "olpc-cjson", "regex", - "reqwest 0.13.2", + "reqwest 0.13.3", "serde", "serde_json", "sha2 0.10.9", @@ -3584,7 +3584,9 @@ dependencies = [ "miette", "openshell-core", "serde", + "serde_json", "serde_yml", + "tracing", ] [[package]] @@ -3641,7 +3643,6 @@ dependencies = [ "flate2", "futures", "glob", - "hex", "hmac", "ipnet", "landlock", @@ -3663,7 +3664,6 @@ dependencies = [ "serde_json", "serde_yml", "sha1 0.10.6", - "sha2 0.10.9", "temp-env", "tempfile", "thiserror 2.0.18", @@ -4630,9 +4630,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64 0.22.1", "bytes", @@ -4942,9 +4942,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -5395,6 +5395,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -6977,15 +6993,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -7031,21 +7038,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.48.5" @@ -7103,12 +7095,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -7127,12 +7113,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -7151,12 +7131,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -7187,12 +7161,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -7211,12 +7179,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -7235,12 +7197,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -7259,12 +7215,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" diff --git a/architecture/README.md b/architecture/README.md index 5a1d82831..66c76fda2 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -96,7 +96,7 @@ flowchart TB | Control-plane identity | Authentication and authorization for users, operators, and API clients. External identity verification belongs to identity drivers. | | Sandbox identity | Workload identity for supervisors and sandbox-to-sandbox authorization. Identity issuance or verification belongs to sandbox identity drivers. | | Supervisor | Sandbox-local security boundary. It prepares isolation, fetches config, injects credentials, runs relay endpoints, starts the proxy, and launches restricted agent processes. | -| Policy proxy | Mandatory egress path for agent traffic. It enforces destination, binary identity, SSRF, TLS/L7, credential injection, and inference interception rules. | +| Policy proxy | Mandatory egress path for agent traffic. It enforces destination, SSRF, TLS/L7, credential injection, and inference interception rules. | | Inference router | Sandbox-local forwarding for `https://inference.local` to configured model backends. | ## Integrating with the Ecosystem diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 71dd35227..6bcdbd8db 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -39,7 +39,7 @@ OpenShell uses overlapping controls rather than a single sandbox primitive: | Process policy | The child process runs as a non-root user with reduced privileges. | | Seccomp | Blocks dangerous syscalls, including raw socket paths that bypass the proxy. | | Network namespace | Forces ordinary agent egress through the local CONNECT proxy. | -| Policy proxy | Evaluates destination, binary identity, TLS/L7 rules, SSRF checks, and inference interception. | +| Policy proxy | Evaluates destination, TLS/L7 rules, SSRF checks, and inference interception. | The supervisor may enrich baseline filesystem allowances for runtime-required paths, such as proxy support files or GPU device paths when a GPU is present. @@ -47,8 +47,8 @@ paths, such as proxy support files or GPU device paths when a GPU is present. ## Network and Inference All ordinary agent egress is routed through the sandbox proxy. The proxy -identifies the calling binary, checks trust-on-first-use binary identity, rejects -unsafe internal destinations, and evaluates the active policy. +rejects unsafe internal destinations and evaluates the active policy against the +destination host and port. `https://inference.local` is special. It bypasses OPA network policy and is handled by the inference interception path: diff --git a/architecture/security-policy.md b/architecture/security-policy.md index b0d56e3a2..f1398051f 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -14,7 +14,7 @@ For the field-by-field YAML reference, use |---|---| | Filesystem | Landlock restricts read-only and read-write paths. | | Process | The supervisor launches the agent as an unprivileged user with reduced capabilities. | -| Network | The proxy evaluates destination, port, calling binary, and optional L7 rules. | +| Network | The proxy evaluates destination, port, and optional L7 rules. | | Inference | `inference.local` is configured through gateway inference settings, not OPA network policy. | | Runtime settings | Typed settings are delivered with policy and can be global or sandbox scoped. | @@ -26,12 +26,11 @@ dynamic and can be hot-reloaded when the new policy validates successfully. Ordinary network traffic follows this order: 1. Force traffic through the sandbox proxy with namespace and seccomp controls. -2. Identify the calling binary and compare its trusted identity. -3. Reject hard-blocked destinations, including unsafe internal IP ranges unless +2. Reject hard-blocked destinations, including unsafe internal IP ranges unless explicitly allowed. -4. Match the destination and binary against network policy blocks. -5. Apply optional HTTP/L7 rules for endpoints that enable protocol inspection. -6. Allow, deny, audit, or log according to the matched policy. +3. Match the destination against network policy blocks. +4. Apply optional HTTP/L7 rules for endpoints that enable protocol inspection. +5. Allow, deny, audit, or log according to the matched policy. Explicit deny and hardening checks win over allow rules. If no rule matches, the request is denied. diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 3a8c344d3..4646656a2 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1555,10 +1555,6 @@ enum PolicyCommands { #[arg(long = "remove-rule")] remove_rules: Vec, - /// Add binaries to each --add-endpoint rule. - #[arg(long = "binary", value_hint = ValueHint::FilePath)] - binaries: Vec, - /// Override the generated rule name when exactly one --add-endpoint is provided. #[arg(long = "rule-name")] rule_name: Option, @@ -2239,7 +2235,6 @@ async fn main() -> Result<()> { add_allow, add_deny, remove_rules, - binaries, rule_name, dry_run, wait, @@ -2254,7 +2249,6 @@ async fn main() -> Result<()> { &add_deny, &add_allow, &remove_rules, - &binaries, rule_name.as_deref(), dry_run, wait, diff --git a/crates/openshell-cli/src/policy_update.rs b/crates/openshell-cli/src/policy_update.rs index 57656b878..703a016bd 100644 --- a/crates/openshell-cli/src/policy_update.rs +++ b/crates/openshell-cli/src/policy_update.rs @@ -6,9 +6,8 @@ use std::collections::{BTreeMap, HashMap}; use miette::{Result, miette}; use openshell_core::proto::policy_merge_operation; use openshell_core::proto::{ - AddAllowRules, AddDenyRules, AddNetworkRule, L7Allow, L7DenyRule, L7Rule, NetworkBinary, - NetworkEndpoint, NetworkPolicyRule, PolicyMergeOperation, RemoveNetworkEndpoint, - RemoveNetworkRule, + AddAllowRules, AddDenyRules, AddNetworkRule, L7Allow, L7DenyRule, L7Rule, NetworkEndpoint, + NetworkPolicyRule, PolicyMergeOperation, RemoveNetworkEndpoint, RemoveNetworkRule, }; use openshell_policy::{PolicyMergeOp, generated_rule_name}; @@ -25,15 +24,8 @@ pub fn build_policy_update_plan( add_deny: &[String], add_allow: &[String], remove_rules: &[String], - binaries: &[String], rule_name: Option<&str>, ) -> Result { - if binaries.iter().any(|binary| binary.trim().is_empty()) { - return Err(miette!("--binary values must not be empty")); - } - if !binaries.is_empty() && add_endpoints.is_empty() { - return Err(miette!("--binary can only be used with --add-endpoint")); - } if rule_name.is_some() && add_endpoints.is_empty() { return Err(miette!("--rule-name can only be used with --add-endpoint")); } @@ -45,7 +37,6 @@ pub fn build_policy_update_plan( let mut merge_operations = Vec::new(); let mut preview_operations = Vec::new(); - let deduped_binaries = dedup_strings(binaries); for spec in add_endpoints { let endpoint = parse_add_endpoint_spec(spec)?; let target_rule_name = rule_name @@ -58,13 +49,6 @@ pub fn build_policy_update_plan( let rule = NetworkPolicyRule { name: target_rule_name.clone(), endpoints: vec![endpoint.clone()], - binaries: deduped_binaries - .iter() - .map(|path| NetworkBinary { - path: path.clone(), - ..Default::default() - }) - .collect(), }; merge_operations.push(PolicyMergeOperation { operation: Some(policy_merge_operation::Operation::AddRule(AddNetworkRule { @@ -437,17 +421,6 @@ fn parse_port(flag: &str, spec: &str, port: &str) -> Result { Ok(parsed) } -fn dedup_strings(values: &[String]) -> Vec { - let mut deduped = Vec::new(); - for value in values { - let trimmed = value.trim(); - if !trimmed.is_empty() && !deduped.iter().any(|existing| existing == trimmed) { - deduped.push(trimmed.to_string()); - } - } - deduped -} - #[cfg(test)] mod tests { use super::{ @@ -461,7 +434,6 @@ mod tests { add_deny: &[String], add_allow: &[String], remove_rules: &[String], - binaries: &[String], rule_name: Option<&str>, ) -> miette::Result { build_policy_update_plan_with_options( @@ -470,16 +442,14 @@ mod tests { add_deny, add_allow, remove_rules, - binaries, rule_name, ) } #[test] fn parse_add_endpoint_basic_l4() { - let plan = - build_policy_update_plan(&["ghcr.io:443".to_string()], &[], &[], &[], &[], &[], None) - .expect("plan should build"); + let plan = build_policy_update_plan(&["ghcr.io:443".to_string()], &[], &[], &[], &[], None) + .expect("plan should build"); assert_eq!(plan.merge_operations.len(), 1); assert_eq!(plan.preview_operations.len(), 1); } @@ -492,7 +462,6 @@ mod tests { &[], &[], &[], - &[], None, ) .expect_err("plan should fail"); @@ -507,7 +476,6 @@ mod tests { &[], &[], &[], - &[], None, ) .expect("plan should build"); @@ -521,7 +489,6 @@ mod tests { &[], &[], &[], - &[], None, ) .expect("plan should build"); @@ -538,16 +505,8 @@ mod tests { #[test] fn parse_add_endpoint_enables_websocket_credential_rewrite() { - let plan = build_policy_update_plan( - &["realtime.example.com:443:read-write:websocket:enforce:websocket-credential-rewrite" - .to_string()], - &[], - &[], - &[], - &[], - &[], - None, - ) + let plan = build_policy_update_plan(&["realtime.example.com:443:read-write:websocket:enforce:websocket-credential-rewrite" + .to_string()], &[], &[], &[], &[], None) .expect("plan should build"); let PolicyMergeOp::AddRule { rule, .. } = &plan.preview_operations[0] else { @@ -567,7 +526,6 @@ mod tests { &[], &[], &[], - &[], None, ) .expect("plan should build"); @@ -589,7 +547,6 @@ mod tests { &[], &[], &[], - &[], None, ) .expect("plan should build"); @@ -604,18 +561,10 @@ mod tests { #[test] fn parse_add_endpoint_merges_allowed_ips_with_websocket_options() { - let plan = build_policy_update_plan( - &[ - "realtime.example.com:443:read-write:websocket:enforce:websocket-credential-rewrite,allowed-ip=10.0.0.0/8,allowed-ip=172.16.0.0/12,allowed-ip=10.0.0.0/8" - .to_string(), - ], - &[], - &[], - &[], - &[], - &[], - None, - ) + let plan = build_policy_update_plan(&[ + "realtime.example.com:443:read-write:websocket:enforce:websocket-credential-rewrite,allowed-ip=10.0.0.0/8,allowed-ip=172.16.0.0/12,allowed-ip=10.0.0.0/8" + .to_string(), + ], &[], &[], &[], &[], None) .expect("plan should build"); let PolicyMergeOp::AddRule { rule, .. } = &plan.preview_operations[0] else { @@ -637,7 +586,6 @@ mod tests { &[], &[], &[], - &[], None, ) .expect("plan should build"); @@ -656,7 +604,6 @@ mod tests { &[], &[], &[], - &[], None, ) .expect_err("plan should fail"); @@ -671,7 +618,6 @@ mod tests { &[], &[], &[], - &[], None, ) .expect_err("plan should fail"); @@ -680,18 +626,10 @@ mod tests { #[test] fn request_body_credential_rewrite_rejects_non_rest_endpoint() { - let error = build_policy_update_plan( - &[ - "realtime.example.com:443:read-write:websocket:enforce:request-body-credential-rewrite" - .to_string(), - ], - &[], - &[], - &[], - &[], - &[], - None, - ) + let error = build_policy_update_plan(&[ + "realtime.example.com:443:read-write:websocket:enforce:request-body-credential-rewrite" + .to_string(), + ], &[], &[], &[], &[], None) .expect_err("plan should fail"); assert!(error.to_string().contains("protocol segment")); @@ -706,7 +644,6 @@ mod tests { &[], &[], &[], - &[], None, ) .expect_err("plan should fail"); @@ -721,7 +658,6 @@ mod tests { &[], &["realtime.example.com:443:websocket_text:/v1/messages/**".to_string()], &[], - &[], None, ) .expect("plan should build"); @@ -744,7 +680,6 @@ mod tests { &["api.github.com:443::/repos/**".to_string()], &[], &[], - &[], None, ) .expect_err("plan should fail"); @@ -759,7 +694,6 @@ mod tests { &[], &["api.github.com:443:GET:repos/**".to_string()], &[], - &[], None, ) .expect_err("plan should fail"); @@ -774,7 +708,6 @@ mod tests { &[], &[], &[], - &[], None, ) .expect_err("plan should fail"); @@ -793,21 +726,12 @@ mod tests { &[], &[], &[], - &[], None, ) .expect_err("plan should fail"); assert!(error.to_string().contains("range 1-65535")); } - #[test] - fn binary_requires_add_endpoint() { - let error = - build_policy_update_plan(&[], &[], &[], &[], &[], &["/usr/bin/gh".to_string()], None) - .expect_err("plan should fail"); - assert!(error.to_string().contains("--binary")); - } - #[test] fn rule_name_rejects_multiple_add_endpoints() { let error = build_policy_update_plan( @@ -816,7 +740,6 @@ mod tests { &[], &[], &[], - &[], Some("shared"), ) .expect_err("plan should fail"); diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 2e3cb0531..c3e735195 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -5915,7 +5915,6 @@ pub async fn sandbox_policy_update( add_deny: &[String], add_allow: &[String], remove_rules: &[String], - binaries: &[String], rule_name: Option<&str>, dry_run: bool, wait: bool, @@ -5932,7 +5931,6 @@ pub async fn sandbox_policy_update( add_deny, add_allow, remove_rules, - binaries, rule_name, )?; @@ -6446,9 +6444,6 @@ pub async fn sandbox_draft_get( println!(" {} {}", "Chunk:".dimmed(), chunk.id); println!(" {} {}", "Status:".dimmed(), status_colored); println!(" {} {}", "Rule:".dimmed(), chunk.rule_name); - if !chunk.binary.is_empty() { - println!(" {} {}", "Binary:".dimmed(), chunk.binary); - } println!( " {} {:.0}%", "Confidence:".dimmed(), @@ -6466,10 +6461,6 @@ pub async fn sandbox_draft_get( if let Some(ref rule) = chunk.proposed_rule { println!(" {} {}", "Endpoints:".dimmed(), format_endpoints(rule)); - if !rule.binaries.is_empty() { - let bins: Vec<&str> = rule.binaries.iter().map(|b| b.path.as_str()).collect(); - println!(" {} {}", "Binaries:".dimmed(), bins.join(", ")); - } } if chunk.hit_count > 1 { diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index cb2b3cb18..cc5ed4530 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -1148,7 +1148,6 @@ credentials: endpoints: - host: api.custom.example port: 443 -binaries: [/usr/bin/custom] ", ) .unwrap(); @@ -1208,7 +1207,6 @@ category: other endpoints: - host: api.yaml.example port: 443 -binaries: [/usr/bin/yaml-client] ", ) .unwrap(); @@ -1221,7 +1219,6 @@ binaries: [/usr/bin/yaml-client] "category": "other", "credentials": [], "endpoints": [{"host": "api.json.example", "port": 443}], - "binaries": ["/usr/bin/json-client"], "inference_capable": false }"#, ) @@ -1268,9 +1265,6 @@ endpoints: path: /admin/** allow_encoded_slash: true path: /v1 -binaries: - - path: /usr/bin/advanced - harness: true ", ) .unwrap(); @@ -1298,7 +1292,6 @@ binaries: assert_eq!(endpoint.allowed_ips, vec!["10.0.0.0/24"]); assert!(endpoint.allow_encoded_slash); assert_eq!(endpoint.path, "/v1"); - assert!(profile.binaries[0].harness); } #[tokio::test] diff --git a/crates/openshell-policy/Cargo.toml b/crates/openshell-policy/Cargo.toml index f26136c6b..0aef003e1 100644 --- a/crates/openshell-policy/Cargo.toml +++ b/crates/openshell-policy/Cargo.toml @@ -13,8 +13,10 @@ repository.workspace = true [dependencies] openshell-core = { path = "../openshell-core" } serde = { workspace = true } +serde_json = { workspace = true } serde_yml = { workspace = true } miette = { workspace = true } +tracing = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-policy/src/compose.rs b/crates/openshell-policy/src/compose.rs index 427fa4cda..2f8397e60 100644 --- a/crates/openshell-policy/src/compose.rs +++ b/crates/openshell-policy/src/compose.rs @@ -96,7 +96,6 @@ mod tests { allow_encoded_slash: false, ..Default::default() }], - binaries: Vec::new(), } } diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 632170c0b..73352bf34 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -19,8 +19,7 @@ use std::path::Path; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::proto::{ FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, - LandlockPolicy, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProcessPolicy, - SandboxPolicy, + LandlockPolicy, NetworkEndpoint, NetworkPolicyRule, ProcessPolicy, SandboxPolicy, }; use serde::{Deserialize, Serialize}; @@ -82,8 +81,11 @@ struct NetworkPolicyRuleDef { name: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] endpoints: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - binaries: Vec, + /// Accepted for backwards compatibility with policies written before binary + /// allowlisting was removed. The value is ignored — policy evaluation is + /// now purely destination-based (host + port). + #[serde(default, skip_serializing)] + binaries: Vec, } #[derive(Debug, Serialize, Deserialize)] @@ -218,16 +220,6 @@ struct L7DenyRuleDef { fields: Vec, } -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct NetworkBinaryDef { - path: String, - /// Deprecated: ignored. Kept for backward compat with existing YAML files. - #[serde(default, skip_serializing)] - #[allow(dead_code)] - harness: bool, -} - // --------------------------------------------------------------------------- // YAML → proto conversion // --------------------------------------------------------------------------- @@ -237,6 +229,15 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { .network_policies .into_iter() .map(|(key, rule)| { + if !rule.binaries.is_empty() { + tracing::info!( + rule = %key, + "policy rule contains 'binaries' field which is no longer \ + evaluated; binary allowlisting has been removed and this \ + field is ignored — policy enforcement is now destination-based \ + (host + port) only" + ); + } let proto_rule = NetworkPolicyRule { name: if rule.name.is_empty() { key.clone() @@ -347,14 +348,6 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { } }) .collect(), - binaries: rule - .binaries - .into_iter() - .map(|b| NetworkBinary { - path: b.path, - ..Default::default() - }) - .collect(), }; (key, proto_rule) }) @@ -512,14 +505,7 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { } }) .collect(), - binaries: rule - .binaries - .iter() - .map(|b| NetworkBinaryDef { - path: b.path.clone(), - harness: false, - }) - .collect(), + binaries: Vec::new(), }; (key.clone(), yaml_rule) }) @@ -895,8 +881,6 @@ network_policies: allowed_ips: - "10.0.5.0/24" - "10.0.6.0/24" - binaries: - - path: /usr/bin/curl "#; let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); @@ -917,10 +901,8 @@ network_policies: my_api: name: my-custom-api-name endpoints: - - host: api.example.com - port: 443 - binaries: - - path: /usr/bin/curl + - host: api.example.com + port: 443 "; let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); assert_eq!(proto1.network_policies["my_api"].name, "my-custom-api-name"); @@ -997,8 +979,6 @@ network_policies: name: test_policy endpoints: - { host: example.com, port: 443 } - binaries: - - { path: /usr/bin/curl } "; let policy = parse_sandbox_policy(yaml).expect("should parse"); assert_eq!(policy.network_policies.len(), 1); @@ -1007,8 +987,6 @@ network_policies: assert_eq!(rule.endpoints.len(), 1); assert_eq!(rule.endpoints[0].host, "example.com"); assert_eq!(rule.endpoints[0].port, 443); - assert_eq!(rule.binaries.len(), 1); - assert_eq!(rule.binaries[0].path, "/usr/bin/curl"); } #[test] @@ -1030,8 +1008,6 @@ network_policies: slug: "my-*" tag: any: ["foo-*", "bar-*"] - binaries: - - path: /usr/bin/curl "#; let proto = parse_sandbox_policy(yaml).expect("parse failed"); let allow = proto.network_policies["query_test"].endpoints[0].rules[0] @@ -1060,6 +1036,36 @@ network_policies: assert!(parse_sandbox_policy(yaml).is_err()); } + #[test] + fn parse_accepts_binaries_field_and_ignores_it() { + // Policies written before binary allowlisting was removed may contain + // a `binaries:` field. It must be accepted (not rejected) and silently + // dropped — policy enforcement is now destination-based only. + let yaml = r" +version: 1 +network_policies: + my_api: + endpoints: + - host: api.example.com + ports: [443] + binaries: + - path: /usr/bin/curl + harness: false + - path: /usr/bin/node + harness: true +"; + let proto = parse_sandbox_policy(yaml).expect("should accept binaries field"); + // The endpoint is preserved; the binaries field is silently dropped. + assert_eq!( + proto.network_policies["my_api"].endpoints[0].host, + "api.example.com" + ); + assert_eq!( + proto.network_policies["my_api"].endpoints[0].ports, + vec![443] + ); + } + #[test] fn ensure_sandbox_process_identity_fills_defaults() { let mut policy = restrictive_default_policy(); @@ -1277,7 +1283,6 @@ network_policies: port: 443, ..Default::default() }], - ..Default::default() }, ); let violations = validate_sandbox_policy(&policy).unwrap_err(); @@ -1300,7 +1305,6 @@ network_policies: port: 443, ..Default::default() }], - ..Default::default() }, ); let violations = validate_sandbox_policy(&policy).unwrap_err(); @@ -1323,7 +1327,6 @@ network_policies: port: 443, ..Default::default() }], - ..Default::default() }, ); assert!(validate_sandbox_policy(&policy).is_ok()); @@ -1341,7 +1344,6 @@ network_policies: port: 443, ..Default::default() }], - ..Default::default() }, ); assert!(validate_sandbox_policy(&policy).is_ok()); @@ -1383,8 +1385,6 @@ network_policies: name: test endpoints: - { host: api.example.com, ports: [80, 443] } - binaries: - - { path: /usr/bin/curl } "; let policy = parse_sandbox_policy(yaml).expect("should parse"); let ep = &policy.network_policies["test"].endpoints[0]; @@ -1402,8 +1402,6 @@ network_policies: name: test endpoints: - { host: api.example.com, port: 443 } - binaries: - - { path: /usr/bin/curl } "; let policy = parse_sandbox_policy(yaml).expect("should parse"); let ep = &policy.network_policies["test"].endpoints[0]; @@ -1426,8 +1424,6 @@ network_policies: rules: - allow: operation_type: query - binaries: - - { path: /usr/bin/curl } "#; let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); @@ -1451,8 +1447,6 @@ network_policies: ports: - 80 - 443 - binaries: - - { path: /usr/bin/curl } "; let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); @@ -1473,8 +1467,6 @@ network_policies: name: test endpoints: - { host: api.example.com, port: 443 } - binaries: - - { path: /usr/bin/curl } "; let proto = parse_sandbox_policy(yaml).expect("parse failed"); let yaml_out = serialize_sandbox_policy(&proto).expect("serialize failed"); @@ -1498,8 +1490,6 @@ network_policies: name: test endpoints: - { host: "*.example.com", port: 443 } - binaries: - - { path: /usr/bin/curl } "#; let policy = parse_sandbox_policy(yaml).expect("should parse"); let ep = &policy.network_policies["test"].endpoints[0]; @@ -1516,8 +1506,6 @@ network_policies: endpoints: - host: "*.example.com" port: 443 - binaries: - - { path: /usr/bin/curl } "#; let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); @@ -1545,8 +1533,6 @@ network_policies: path: "/repos/*/pulls/*/reviews" - method: PUT path: "/repos/*/branches/*/protection" - binaries: - - path: /usr/bin/curl "#; let proto = parse_sandbox_policy(yaml).expect("parse failed"); let ep = &proto.network_policies["github"].endpoints[0]; @@ -1576,8 +1562,6 @@ network_policies: path: "/repos/*/branches/*/protection" query: force: "true" - binaries: - - path: /usr/bin/curl "#; let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); @@ -1610,8 +1594,6 @@ network_policies: query: type: any: ["admin-*", "root-*"] - binaries: - - path: /usr/bin/curl "#; let proto = parse_sandbox_policy(yaml).expect("parse failed"); let deny = &proto.network_policies["test"].endpoints[0].deny_rules[0]; @@ -1648,8 +1630,6 @@ network_policies: deny_rules: - operation_type: mutation fields: [deleteRepository] - binaries: - - path: /usr/bin/curl "; let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); @@ -1683,8 +1663,6 @@ network_policies: enforcement: enforce access: full websocket_credential_rewrite: true - binaries: - - path: /usr/bin/node "; let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); @@ -1710,8 +1688,6 @@ network_policies: enforcement: enforce access: read-write request_body_credential_rewrite: true - binaries: - - path: /usr/bin/node "; let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); @@ -1734,8 +1710,6 @@ network_policies: port: 443 protocol: rest access: full - binaries: - - path: /usr/bin/node "; let proto = parse_sandbox_policy(yaml).expect("parse failed"); let ep = &proto.network_policies["gateway"].endpoints[0]; diff --git a/crates/openshell-policy/src/merge.rs b/crates/openshell-policy/src/merge.rs index c01445b11..bdc5ac960 100644 --- a/crates/openshell-policy/src/merge.rs +++ b/crates/openshell-policy/src/merge.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use openshell_core::proto::{ - L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, + L7Allow, L7DenyRule, L7Rule, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, }; #[derive(Debug, Clone, PartialEq)] @@ -31,10 +31,6 @@ pub enum PolicyMergeOp { port: u32, rules: Vec, }, - RemoveBinary { - rule_name: String, - binary_path: String, - }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -213,8 +209,7 @@ pub struct PolicyMergeResult { /// "Contains" means: for every endpoint in `proposed`, some rule in /// `policy.network_policies` has an endpoint with overlapping /// host/path/port set AND containing every L7 allow (method/path) the -/// proposed endpoint requested, and that rule's binaries cover every -/// binary in `proposed`. +/// proposed endpoint requested. /// /// The sandbox's `policy.local /wait` long-poll uses this to decide when /// the local supervisor has actually loaded a policy that includes the @@ -242,10 +237,6 @@ pub fn policy_covers_rule(policy: &SandboxPolicy, proposed: &NetworkPolicyRule) rule.endpoints.iter().any(|endpoint| { endpoints_overlap(endpoint, target_endpoint) && endpoint_l7_covers(endpoint, target_endpoint) - }) && proposed.binaries.iter().all(|target_binary| { - rule.binaries - .iter() - .any(|binary| binary.path == target_binary.path) }) }) }) @@ -357,21 +348,6 @@ fn apply_operation( expand_existing_access(endpoint, host, *port, warnings)?; append_unique_l7_rules(&mut endpoint.rules, rules); } - PolicyMergeOp::RemoveBinary { - rule_name, - binary_path, - } => { - let should_remove = if let Some(rule) = policy.network_policies.get_mut(rule_name) { - let original_len = rule.binaries.len(); - rule.binaries.retain(|binary| binary.path != *binary_path); - original_len != rule.binaries.len() && rule.binaries.is_empty() - } else { - false - }; - if should_remove { - policy.network_policies.remove(rule_name); - } - } } Ok(()) } @@ -425,8 +401,6 @@ fn merge_rules( incoming_rule: &NetworkPolicyRule, warnings: &mut Vec, ) -> Result<(), PolicyMergeError> { - append_unique_binaries(&mut existing_rule.binaries, &incoming_rule.binaries); - for incoming_endpoint in &incoming_rule.endpoints { let mut incoming_endpoint = incoming_endpoint.clone(); normalize_endpoint(&mut incoming_endpoint); @@ -720,15 +694,6 @@ fn expand_access_preset(protocol: &str, access: &str) -> Option> { ) } -fn append_unique_binaries(existing: &mut Vec, incoming: &[NetworkBinary]) { - let mut seen: HashSet = existing.iter().map(|binary| binary.path.clone()).collect(); - for binary in incoming { - if seen.insert(binary.path.clone()) { - existing.push(binary.clone()); - } - } -} - fn append_unique_strings(existing: &mut Vec, incoming: &[String]) { let mut seen: HashSet = existing.iter().cloned().collect(); for value in incoming { @@ -758,7 +723,6 @@ fn normalize_rule(rule: &mut NetworkPolicyRule) { for endpoint in &mut rule.endpoints { normalize_endpoint(endpoint); } - dedup_binaries(&mut rule.binaries); } fn normalize_endpoint(endpoint: &mut NetworkEndpoint) { @@ -777,11 +741,6 @@ fn dedup_strings(values: &mut Vec) { values.retain(|value| seen.insert(value.clone())); } -fn dedup_binaries(values: &mut Vec) { - let mut seen = HashSet::new(); - values.retain(|binary| seen.insert(binary.path.clone())); -} - fn dedup_l7_rules(values: &mut Vec) { let mut deduped = Vec::with_capacity(values.len()); for value in std::mem::take(values) { @@ -854,12 +813,9 @@ mod tests { use super::{ PolicyMergeError, PolicyMergeOp, PolicyMergeWarning, generated_rule_name, merge_policy, - policy_covers_rule, }; use crate::restrictive_default_policy; - use openshell_core::proto::{ - L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, - }; + use openshell_core::proto::{L7Allow, L7DenyRule, L7Rule, NetworkEndpoint, NetworkPolicyRule}; fn endpoint(host: &str, port: u32) -> NetworkEndpoint { NetworkEndpoint { @@ -874,7 +830,6 @@ mod tests { NetworkPolicyRule { name: name.to_string(), endpoints: vec![endpoint(host, port)], - ..Default::default() } } @@ -908,10 +863,6 @@ mod tests { NetworkPolicyRule { name: "existing".to_string(), endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }, ); @@ -926,10 +877,6 @@ mod tests { rules: vec![rest_rule("GET", "/repos/**")], ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/gh".to_string(), - ..Default::default() - }], }; let result = merge_policy( @@ -946,7 +893,6 @@ mod tests { assert_eq!(endpoint.protocol, "rest"); assert_eq!(endpoint.enforcement, "enforce"); assert_eq!(endpoint.rules.len(), 1); - assert_eq!(rule.binaries.len(), 2); } #[test] @@ -964,7 +910,6 @@ mod tests { access: "read-write".to_string(), ..Default::default() }], - ..Default::default() }, ); @@ -978,7 +923,6 @@ mod tests { websocket_credential_rewrite: true, ..Default::default() }], - ..Default::default() }; let result = merge_policy( @@ -1009,7 +953,6 @@ mod tests { access: "read-write".to_string(), ..Default::default() }], - ..Default::default() }, ); @@ -1023,7 +966,6 @@ mod tests { request_body_credential_rewrite: true, ..Default::default() }], - ..Default::default() }; let result = merge_policy( @@ -1054,7 +996,6 @@ mod tests { access: "read-only".to_string(), ..Default::default() }], - ..Default::default() }, ); @@ -1092,7 +1033,6 @@ mod tests { access: "read-write".to_string(), ..Default::default() }], - ..Default::default() }, ); @@ -1138,7 +1078,6 @@ mod tests { access: "read-write".to_string(), ..Default::default() }], - ..Default::default() }, ); @@ -1177,7 +1116,6 @@ mod tests { access: "full".to_string(), ..Default::default() }], - ..Default::default() }, ); @@ -1214,7 +1152,6 @@ mod tests { ports: vec![80, 443], ..Default::default() }], - ..Default::default() }, ); @@ -1233,325 +1170,6 @@ mod tests { assert_eq!(endpoint.port, 80); } - #[test] - fn remove_binary_removes_rule_when_last_binary_is_deleted() { - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "github".to_string(), - NetworkPolicyRule { - name: "github".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/gh".to_string(), - ..Default::default() - }], - }, - ); - - let result = merge_policy( - policy, - &[PolicyMergeOp::RemoveBinary { - rule_name: "github".to_string(), - binary_path: "/usr/bin/gh".to_string(), - }], - ) - .expect("merge should succeed"); - - assert!(!result.policy.network_policies.contains_key("github")); - } - - #[test] - fn policy_covers_rule_returns_true_when_merged_rule_present() { - let proposed = NetworkPolicyRule { - name: "agent_proposed".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }; - - let merged = merge_policy( - restrictive_default_policy(), - &[PolicyMergeOp::AddRule { - rule_name: "allow_api_github_com_443".to_string(), - rule: proposed.clone(), - }], - ) - .expect("merge should succeed"); - - assert!(policy_covers_rule(&merged.policy, &proposed)); - } - - #[test] - fn policy_covers_rule_returns_false_when_unrelated_rule_present() { - let proposed = NetworkPolicyRule { - name: "agent_proposed".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }; - - // Merge an *unrelated* rule for a different host. The proposed rule - // for api.github.com is still not present — this is John's - // "false-wakeup" case: an unrelated policy reload must not signal - // that the agent's rule is loaded. - let merged = merge_policy( - restrictive_default_policy(), - &[PolicyMergeOp::AddRule { - rule_name: "allow_api_example_com_443".to_string(), - rule: rule_with_endpoint("unrelated", "api.example.com", 443), - }], - ) - .expect("merge should succeed"); - - assert!(!policy_covers_rule(&merged.policy, &proposed)); - } - - #[test] - fn policy_covers_rule_handles_merge_into_existing_endpoint() { - // The merge logic folds a new rule into an existing rule when their - // endpoints overlap, even under a different network_policies key. - // Coverage must survive that fold — name-keyed checks would miss it. - let proposed = NetworkPolicyRule { - name: "agent_proposed".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }; - - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "preexisting_github".to_string(), - NetworkPolicyRule { - name: "preexisting_github".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/git".to_string(), - ..Default::default() - }], - }, - ); - - let merged = merge_policy( - policy, - &[PolicyMergeOp::AddRule { - rule_name: "allow_api_github_com_443".to_string(), - rule: proposed.clone(), - }], - ) - .expect("merge should succeed"); - - assert!( - !merged - .policy - .network_policies - .contains_key("allow_api_github_com_443"), - "proposed rule should have been folded into the existing key" - ); - assert!(policy_covers_rule(&merged.policy, &proposed)); - } - - #[test] - fn policy_covers_rule_returns_false_when_binary_missing() { - let proposed = NetworkPolicyRule { - name: "agent_proposed".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }; - - // Endpoint exists in the policy but with a *different* binary. The - // agent's retry would still be denied; reload coverage should - // reflect that. - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "existing".to_string(), - NetworkPolicyRule { - name: "existing".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/git".to_string(), - ..Default::default() - }], - }, - ); - - assert!(!policy_covers_rule(&policy, &proposed)); - } - - #[test] - fn policy_covers_rule_returns_false_for_empty_proposed_endpoints() { - // Defensive: a rule with no endpoints carries no signal we can match - // on, so coverage is never true. - let proposed = NetworkPolicyRule::default(); - let policy = restrictive_default_policy(); - assert!(!policy_covers_rule(&policy, &proposed)); - } - - #[test] - fn policy_covers_rule_returns_false_when_proposed_l7_method_not_loaded() { - // John's false-wakeup mode at L7: the supervisor has an - // overlapping endpoint loaded (e.g. read-only GET), but the - // chunk's proposed PUT method is not in the merged endpoint's - // rules yet. Coverage must NOT return true here, or the agent - // retries the PUT and hits another policy_denied. - let proposed = NetworkPolicyRule { - name: "agent_put".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, - ports: vec![443], - protocol: "rest".to_string(), - rules: vec![rest_rule("PUT", "/repos/foo/bar/contents/x.md")], - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }; - - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "existing_readonly".to_string(), - NetworkPolicyRule { - name: "existing_readonly".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, - ports: vec![443], - protocol: "rest".to_string(), - rules: vec![rest_rule("GET", "/repos/foo/bar/contents/x.md")], - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }, - ); - - assert!( - !policy_covers_rule(&policy, &proposed), - "endpoint overlaps but L7 PUT not loaded yet; must not signal coverage" - ); - } - - #[test] - fn policy_covers_rule_returns_true_after_l7_merge_lands() { - // Same setup as above, but with the proposed L7 rule merged in. - // Coverage must now return true. - let proposed = NetworkPolicyRule { - name: "agent_put".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, - ports: vec![443], - protocol: "rest".to_string(), - rules: vec![rest_rule("PUT", "/repos/foo/bar/contents/x.md")], - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }; - - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "existing".to_string(), - NetworkPolicyRule { - name: "existing".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, - ports: vec![443], - protocol: "rest".to_string(), - rules: vec![ - rest_rule("GET", "/repos/foo/bar/contents/x.md"), - rest_rule("PUT", "/repos/foo/bar/contents/x.md"), - ], - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }, - ); - - assert!(policy_covers_rule(&policy, &proposed)); - } - - #[test] - fn policy_covers_rule_returns_true_for_l4_only_proposed_when_endpoint_present() { - // A chunk that targets a non-REST surface (no L7 rules) needs - // only the L4 endpoint match to be considered covered. Empty - // proposed.rules must not be treated as "no method matches". - let proposed = NetworkPolicyRule { - name: "ssh_clone".to_string(), - endpoints: vec![NetworkEndpoint { - host: "github.com".to_string(), - port: 22, - ports: vec![22], - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/git".to_string(), - ..Default::default() - }], - }; - - let merged = merge_policy( - restrictive_default_policy(), - &[PolicyMergeOp::AddRule { - rule_name: "allow_github_com_22".to_string(), - rule: proposed.clone(), - }], - ) - .expect("merge should succeed"); - - assert!(policy_covers_rule(&merged.policy, &proposed)); - } - - #[test] - fn policy_covers_rule_treats_empty_proposed_binaries_as_any_binary() { - // A proposed rule with no binaries is the "any binary" shape. - // The merged rule keeps its own binaries; coverage holds iff - // endpoint and (vacuously satisfied) binary set match. Document - // the semantics so a future reader doesn't flip it accidentally. - let proposed = NetworkPolicyRule { - name: "any_binary_rule".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![], - }; - - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "existing".to_string(), - NetworkPolicyRule { - name: "existing".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }, - ); - - assert!( - policy_covers_rule(&policy, &proposed), - "empty proposed binaries should match any merged binary set" - ); - } - #[test] fn add_rule_without_existing_match_inserts_requested_key() { let policy = restrictive_default_policy(); diff --git a/crates/openshell-prover/registry/binaries/claude.yaml b/crates/openshell-prover/registry/binaries/claude.yaml deleted file mode 100644 index e9d1b1d31..000000000 --- a/crates/openshell-prover/registry/binaries/claude.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/local/bin/claude -description: "Claude Code CLI — AI coding agent" -protocols: - - name: anthropic-api - transport: https - description: "Anthropic API calls for inference" - bypasses_l7: false - actions: - - { name: inference, type: write, description: "Send prompts and receive completions" } - - name: http-via-tools - transport: https - description: "Claude can make HTTP requests via tool use (Bash, WebFetch)" - bypasses_l7: false - actions: - - { name: get, type: read } - - { name: post, type: write } - - { name: put, type: write } -spawns: - - /usr/local/bin/node - - /usr/local/bin/python3.13 - - /usr/bin/git - - /usr/bin/curl - - /usr/bin/ssh - - /usr/bin/nc -can_exfiltrate: true -exfil_mechanism: "Can read files and send contents via tool use, API calls, or spawned subprocesses" -can_construct_http: true diff --git a/crates/openshell-prover/registry/binaries/curl.yaml b/crates/openshell-prover/registry/binaries/curl.yaml deleted file mode 100644 index 4cfadb8ac..000000000 --- a/crates/openshell-prover/registry/binaries/curl.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/curl -description: "Command-line HTTP client" -protocols: - - name: http - transport: https - description: "HTTP/HTTPS requests — can construct arbitrary method, path, headers, body" - bypasses_l7: false - actions: - - { name: get, type: read, description: "HTTP GET request" } - - { name: post, type: write, description: "HTTP POST with arbitrary body" } - - { name: put, type: write, description: "HTTP PUT with arbitrary body" } - - { name: delete, type: destructive, description: "HTTP DELETE request" } - - { name: upload, type: write, description: "File upload via multipart POST" } -spawns: [] -can_exfiltrate: true -exfil_mechanism: "POST/PUT file contents to any reachable endpoint, or encode in URL query parameters" -can_construct_http: true diff --git a/crates/openshell-prover/registry/binaries/gh.yaml b/crates/openshell-prover/registry/binaries/gh.yaml deleted file mode 100644 index 8ad7cb392..000000000 --- a/crates/openshell-prover/registry/binaries/gh.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/gh -description: "GitHub CLI — REST API client for GitHub" -protocols: - - name: github-rest - transport: https - description: "GitHub REST API via gh cli — uses standard HTTP, subject to L7 inspection" - bypasses_l7: false - actions: - - { name: api_read, type: read, description: "GET requests to GitHub API" } - - { name: api_write, type: write, description: "POST/PUT/PATCH requests (create issues, PRs, etc.)" } - - { name: api_delete, type: destructive, description: "DELETE requests (delete repos, branches, etc.)" } -spawns: - - /usr/bin/git -can_exfiltrate: true -exfil_mechanism: "Create gists, issues, or PRs with file contents" -can_construct_http: true diff --git a/crates/openshell-prover/registry/binaries/git.yaml b/crates/openshell-prover/registry/binaries/git.yaml deleted file mode 100644 index 2c3480cbe..000000000 --- a/crates/openshell-prover/registry/binaries/git.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/git -description: "Distributed version control system" -protocols: - - name: git-smart-http - transport: https - description: "Git smart HTTP protocol over HTTPS CONNECT tunnel — not REST, bypasses L7 HTTP inspection" - bypasses_l7: true - actions: - - { name: clone, type: read, description: "Clone/fetch repository" } - - { name: push, type: write, description: "Push commits to remote" } - - { name: force_push, type: destructive, description: "Force push, rewriting remote history" } - - name: git-ssh - transport: ssh - description: "Git over SSH" - bypasses_l7: true - actions: - - { name: clone, type: read } - - { name: push, type: write } - - { name: force_push, type: destructive } -spawns: - - /usr/lib/git-core/git-remote-https - - /usr/bin/ssh -can_exfiltrate: true -exfil_mechanism: "Commit data to repo and push, or encode in branch/tag names" -can_construct_http: false diff --git a/crates/openshell-prover/registry/binaries/nc.yaml b/crates/openshell-prover/registry/binaries/nc.yaml deleted file mode 100644 index 8f98d7639..000000000 --- a/crates/openshell-prover/registry/binaries/nc.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/nc -description: "Netcat — arbitrary TCP/UDP connections" -protocols: - - name: raw-tcp - transport: tcp - description: "Raw TCP connection — can send/receive arbitrary data" - bypasses_l7: true - actions: - - { name: connect, type: read, description: "Establish TCP connection" } - - { name: send, type: write, description: "Send arbitrary data over TCP" } - - { name: listen, type: read, description: "Listen for incoming connections" } -spawns: [] -can_exfiltrate: true -exfil_mechanism: "Stream arbitrary data over raw TCP connection" -can_construct_http: false diff --git a/crates/openshell-prover/registry/binaries/node.yaml b/crates/openshell-prover/registry/binaries/node.yaml deleted file mode 100644 index cfa83f2be..000000000 --- a/crates/openshell-prover/registry/binaries/node.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/local/bin/node -description: "Node.js runtime — general-purpose JavaScript runtime" -protocols: - - name: http-programmatic - transport: https - description: "Node can construct arbitrary HTTP requests via fetch, axios, http module" - bypasses_l7: false - actions: - - { name: get, type: read } - - { name: post, type: write } - - { name: put, type: write } - - { name: delete, type: destructive } -spawns: - - /usr/bin/curl - - /usr/bin/git -can_exfiltrate: true -exfil_mechanism: "Construct arbitrary HTTP requests, spawn subprocesses" -can_construct_http: true diff --git a/crates/openshell-prover/registry/binaries/python3.yaml b/crates/openshell-prover/registry/binaries/python3.yaml deleted file mode 100644 index 3cdf9cd3b..000000000 --- a/crates/openshell-prover/registry/binaries/python3.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/local/bin/python3.13 -description: "Python interpreter — general-purpose runtime" -protocols: - - name: http-programmatic - transport: https - description: "Python can construct arbitrary HTTP requests via urllib, httpx, requests" - bypasses_l7: false - actions: - - { name: get, type: read } - - { name: post, type: write } - - { name: put, type: write } - - { name: delete, type: destructive } -spawns: - - /usr/bin/curl - - /usr/bin/git - - /usr/bin/ssh - - /usr/bin/nc - - /usr/bin/wget -can_exfiltrate: true -exfil_mechanism: "Construct arbitrary HTTP requests, write to files, spawn subprocesses" -can_construct_http: true diff --git a/crates/openshell-prover/registry/binaries/ssh.yaml b/crates/openshell-prover/registry/binaries/ssh.yaml deleted file mode 100644 index b53065d88..000000000 --- a/crates/openshell-prover/registry/binaries/ssh.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/ssh -description: "OpenSSH client" -protocols: - - name: ssh - transport: ssh - description: "SSH protocol — encrypted tunnel, can forward ports and transfer files" - bypasses_l7: true - actions: - - { name: connect, type: read, description: "SSH shell connection" } - - { name: tunnel, type: write, description: "Port forwarding / tunnel" } - - { name: scp, type: write, description: "File transfer via SCP" } -spawns: [] -can_exfiltrate: true -exfil_mechanism: "SCP files or pipe data through SSH tunnel" -can_construct_http: false diff --git a/crates/openshell-prover/registry/binaries/wget.yaml b/crates/openshell-prover/registry/binaries/wget.yaml deleted file mode 100644 index 0741998b7..000000000 --- a/crates/openshell-prover/registry/binaries/wget.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/wget -description: "Non-interactive network downloader" -protocols: - - name: http - transport: https - description: "HTTP/HTTPS downloads — can also POST with --post-data" - bypasses_l7: false - actions: - - { name: download, type: read } - - { name: post, type: write, description: "POST via --post-data or --post-file" } -spawns: [] -can_exfiltrate: true -exfil_mechanism: "POST file contents via --post-file to any reachable endpoint" -can_construct_http: true diff --git a/crates/openshell-prover/src/lib.rs b/crates/openshell-prover/src/lib.rs index 82922253d..6d6f54b4d 100644 --- a/crates/openshell-prover/src/lib.rs +++ b/crates/openshell-prover/src/lib.rs @@ -3,9 +3,9 @@ //! Formal policy verification for `OpenShell` sandboxes. //! -//! Encodes sandbox policies, binary capabilities, and credential scopes as Z3 -//! SMT constraints, then checks reachability queries to detect data exfiltration -//! paths and write-bypass violations. +//! Encodes sandbox policies and credential scopes as Z3 SMT constraints, +//! then checks reachability queries to detect data exfiltration paths and +//! write-bypass violations. pub mod accepted_risks; pub mod credentials; @@ -32,7 +32,7 @@ use report::{render_compact, render_report}; /// - `1` — fail (critical or high findings present) /// - `2` — input error /// -/// Binary and API capability registries are embedded at compile time. +/// The API capability registry is embedded at compile time. /// Pass `registry_dir` to override with a custom filesystem registry. pub fn prove( policy_path: &str, @@ -43,21 +43,14 @@ pub fn prove( ) -> Result { let policy = parse_policy(Path::new(policy_path))?; - let (credential_set, binary_registry) = match registry_dir { + let credential_set = match registry_dir { Some(dir) => { - let dir = Path::new(dir); - ( - credentials::load_credential_set_from_dir(Path::new(credentials_path), dir)?, - registry::load_binary_registry_from_dir(dir)?, - ) + credentials::load_credential_set_from_dir(Path::new(credentials_path), Path::new(dir))? } - None => ( - credentials::load_credential_set_embedded(Path::new(credentials_path))?, - registry::load_embedded_binary_registry()?, - ), + None => credentials::load_credential_set_embedded(Path::new(credentials_path))?, }; - let z3_model = build_model(policy, credential_set, binary_registry); + let z3_model = build_model(policy, credential_set); let mut findings = run_all_queries(&z3_model); if let Some(ar_path) = accepted_risks_path { @@ -97,7 +90,6 @@ mod tests { let rule = &model.network_policies["github_api"]; assert_eq!(rule.name, "github-api"); assert_eq!(rule.endpoints.len(), 2); - assert!(rule.binaries.len() >= 4); } // 2. Verify readable_paths. @@ -157,28 +149,23 @@ filesystem_policy: assert_eq!(sandbox_count, 1); } - // 6. End-to-end: git push bypass findings detected (uses embedded registry). + // 6. End-to-end: L4-only endpoint findings detected. #[test] - fn test_git_push_bypass_findings() { + fn test_l4_only_findings() { let policy_path = testdata_dir().join("policy.yaml"); let creds_path = testdata_dir().join("credentials.yaml"); let pol = parse_policy(&policy_path).expect("parse policy"); let cred_set = credentials::load_credential_set_embedded(&creds_path).expect("load creds"); - let bin_reg = registry::load_embedded_binary_registry().expect("load registry"); - let z3_model = build_model(pol, cred_set, bin_reg); + let z3_model = build_model(pol, cred_set); let findings = run_all_queries(&z3_model); let query_types: std::collections::HashSet<&str> = findings.iter().map(|f| f.query.as_str()).collect(); assert!( query_types.contains("data_exfiltration"), - "expected data_exfiltration finding" - ); - assert!( - query_types.contains("write_bypass"), - "expected write_bypass finding" + "expected data_exfiltration finding: L4-only endpoint with readable filesystem" ); assert!( findings.iter().any(|f| matches!( @@ -197,9 +184,8 @@ filesystem_policy: let pol = parse_policy(&policy_path).expect("parse policy"); let cred_set = credentials::load_credential_set_embedded(&creds_path).expect("load creds"); - let bin_reg = registry::load_embedded_binary_registry().expect("load registry"); - let z3_model = build_model(pol, cred_set, bin_reg); + let z3_model = build_model(pol, cred_set); let findings = run_all_queries(&z3_model); assert!( diff --git a/crates/openshell-prover/src/model.rs b/crates/openshell-prover/src/model.rs index bf52993d4..9e9675482 100644 --- a/crates/openshell-prover/src/model.rs +++ b/crates/openshell-prover/src/model.rs @@ -10,7 +10,6 @@ use z3::{Context, SatResult, Solver}; use crate::credentials::CredentialSet; use crate::policy::{PolicyModel, WRITE_METHODS}; -use crate::registry::BinaryRegistry; /// Unique identifier for a network endpoint in the model. #[derive(Debug, Clone, Hash, PartialEq, Eq)] @@ -31,23 +30,16 @@ impl EndpointId { pub struct ReachabilityModel { pub policy: PolicyModel, pub credentials: CredentialSet, - pub binary_registry: BinaryRegistry, // Indexed facts pub endpoints: Vec, - pub binary_paths: Vec, // Z3 solver solver: Solver, // Boolean variable maps - policy_allows: HashMap, l7_enforced: HashMap, l7_allows_write: HashMap, - binary_bypasses_l7: HashMap, - binary_can_write: HashMap, - binary_can_exfil: HashMap, - binary_can_construct_http: HashMap, credential_has_write: HashMap, #[allow(dead_code)] credential_has_destructive: HashMap, @@ -57,26 +49,15 @@ pub struct ReachabilityModel { impl ReachabilityModel { /// Build a new reachability model from the given inputs. - pub fn new( - policy: PolicyModel, - credentials: CredentialSet, - binary_registry: BinaryRegistry, - ) -> Self { + pub fn new(policy: PolicyModel, credentials: CredentialSet) -> Self { let solver = Solver::new(); let mut model = Self { policy, credentials, - binary_registry, endpoints: Vec::new(), - binary_paths: Vec::new(), solver, - policy_allows: HashMap::new(), l7_enforced: HashMap::new(), l7_allows_write: HashMap::new(), - binary_bypasses_l7: HashMap::new(), - binary_can_write: HashMap::new(), - binary_can_exfil: HashMap::new(), - binary_can_construct_http: HashMap::new(), credential_has_write: HashMap::new(), credential_has_destructive: HashMap::new(), filesystem_readable: HashMap::new(), @@ -87,10 +68,7 @@ impl ReachabilityModel { fn build(&mut self) { self.index_endpoints(); - self.index_binaries(); - self.encode_policy_allows(); self.encode_l7_enforcement(); - self.encode_binary_capabilities(); self.encode_credentials(); self.encode_filesystem(); } @@ -109,37 +87,6 @@ impl ReachabilityModel { } } - fn index_binaries(&mut self) { - let mut seen = HashSet::new(); - for rule in self.policy.network_policies.values() { - for b in &rule.binaries { - if seen.insert(b.path.clone()) { - self.binary_paths.push(b.path.clone()); - } - } - } - } - - fn encode_policy_allows(&mut self) { - for (policy_name, rule) in &self.policy.network_policies { - for ep in &rule.endpoints { - for port in ep.effective_ports() { - let eid = EndpointId { - policy_name: policy_name.clone(), - host: ep.host.clone(), - port, - }; - for b in &rule.binaries { - let key = format!("{}:{}", b.path, eid.key()); - let var = Bool::new_const(format!("policy_allows_{key}")); - self.solver.assert(&var); - self.policy_allows.insert(key, var); - } - } - } - } - } - fn encode_l7_enforcement(&mut self) { for (policy_name, rule) in &self.policy.network_policies { for ep in &rule.endpoints { @@ -186,45 +133,6 @@ impl ReachabilityModel { } } - fn encode_binary_capabilities(&mut self) { - for bpath in &self.binary_paths.clone() { - let cap = self.binary_registry.get_or_unknown(bpath); - - let bypass_var = Bool::new_const(format!("binary_bypasses_l7_{bpath}")); - if cap.bypasses_l7() { - self.solver.assert(&bypass_var); - } else { - self.solver.assert(&!bypass_var.clone()); - } - self.binary_bypasses_l7.insert(bpath.clone(), bypass_var); - - let write_var = Bool::new_const(format!("binary_can_write_{bpath}")); - if cap.can_write() { - self.solver.assert(&write_var); - } else { - self.solver.assert(&!write_var.clone()); - } - self.binary_can_write.insert(bpath.clone(), write_var); - - let exfil_var = Bool::new_const(format!("binary_can_exfil_{bpath}")); - if cap.can_exfiltrate { - self.solver.assert(&exfil_var); - } else { - self.solver.assert(&!exfil_var.clone()); - } - self.binary_can_exfil.insert(bpath.clone(), exfil_var); - - let http_var = Bool::new_const(format!("binary_can_construct_http_{bpath}")); - if cap.can_construct_http { - self.solver.assert(&http_var); - } else { - self.solver.assert(&!http_var.clone()); - } - self.binary_can_construct_http - .insert(bpath.clone(), http_var); - } - } - fn encode_credentials(&mut self) { let hosts: HashSet = self.endpoints.iter().map(|e| e.host.clone()).collect(); @@ -281,21 +189,10 @@ impl ReachabilityModel { Bool::from_bool(false) } - /// Build a Z3 expression for whether a binary can write to an endpoint. - pub fn can_write_to_endpoint(&self, bpath: &str, eid: &EndpointId) -> Bool { + /// Build a Z3 expression for whether an endpoint allows write operations. + pub fn endpoint_allows_write(&self, eid: &EndpointId) -> Bool { let ek = eid.key(); - let access_key = format!("{bpath}:{ek}"); - - let has_access = match self.policy_allows.get(&access_key) { - Some(v) => v.clone(), - None => return Self::false_val(), - }; - let bypass = self - .binary_bypasses_l7 - .get(bpath) - .cloned() - .unwrap_or_else(Self::false_val); let l7_enforced = self .l7_enforced .get(&ek) @@ -306,70 +203,13 @@ impl ReachabilityModel { .get(&ek) .cloned() .unwrap_or_else(Self::false_val); - let binary_write = self - .binary_can_write - .get(bpath) - .cloned() - .unwrap_or_else(Self::false_val); let cred_write = self .credential_has_write .get(&eid.host) .cloned() .unwrap_or_else(Self::false_val); - Bool::and(&[ - has_access, - binary_write, - Bool::or(&[!l7_enforced, l7_write, bypass]), - cred_write, - ]) - } - - /// Build a Z3 expression for whether data can be exfiltrated via this path. - pub fn can_exfil_via_endpoint(&self, bpath: &str, eid: &EndpointId) -> Bool { - let ek = eid.key(); - let access_key = format!("{bpath}:{ek}"); - - let has_access = match self.policy_allows.get(&access_key) { - Some(v) => v.clone(), - None => return Self::false_val(), - }; - - let exfil = self - .binary_can_exfil - .get(bpath) - .cloned() - .unwrap_or_else(Self::false_val); - let bypass = self - .binary_bypasses_l7 - .get(bpath) - .cloned() - .unwrap_or_else(Self::false_val); - let l7_enforced = self - .l7_enforced - .get(&ek) - .cloned() - .unwrap_or_else(Self::false_val); - let l7_write = self - .l7_allows_write - .get(&ek) - .cloned() - .unwrap_or_else(Self::false_val); - let http = self - .binary_can_construct_http - .get(bpath) - .cloned() - .unwrap_or_else(Self::false_val); - - Bool::and(&[ - has_access, - exfil, - Bool::or(&[ - Bool::and(&[!l7_enforced, http.clone()]), - Bool::and(&[l7_write, http]), - bypass, - ]), - ]) + Bool::and(&[Bool::or(&[!l7_enforced, l7_write]), cred_write]) } /// Check satisfiability of an expression against the base constraints. @@ -383,12 +223,8 @@ impl ReachabilityModel { } /// Build a reachability model from the given inputs. -pub fn build_model( - policy: PolicyModel, - credentials: CredentialSet, - binary_registry: BinaryRegistry, -) -> ReachabilityModel { +pub fn build_model(policy: PolicyModel, credentials: CredentialSet) -> ReachabilityModel { // Ensure the thread-local Z3 context is initialized let _ctx = Context::thread_local(); - ReachabilityModel::new(policy, credentials, binary_registry) + ReachabilityModel::new(policy, credentials) } diff --git a/crates/openshell-prover/src/policy.rs b/crates/openshell-prover/src/policy.rs index 8aea4b7d0..40a7dcd7a 100644 --- a/crates/openshell-prover/src/policy.rs +++ b/crates/openshell-prover/src/policy.rs @@ -82,8 +82,6 @@ struct NetworkPolicyRuleDef { name: Option, #[serde(default)] endpoints: Vec, - #[serde(default)] - binaries: Vec, } #[derive(Debug, Deserialize)] @@ -123,11 +121,6 @@ struct L7AllowDef { command: String, } -#[derive(Debug, Deserialize)] -struct BinaryDef { - path: String, -} - // --------------------------------------------------------------------------- // Public model types // --------------------------------------------------------------------------- @@ -234,18 +227,11 @@ impl Endpoint { } } -/// A binary path entry in a network policy rule. -#[derive(Debug, Clone)] -pub struct Binary { - pub path: String, -} - -/// A named network policy rule containing endpoints and binaries. +/// A named network policy rule containing endpoints. #[derive(Debug, Clone)] pub struct NetworkPolicyRule { pub name: String, pub endpoints: Vec, - pub binaries: Vec, } /// Filesystem access policy. @@ -302,33 +288,6 @@ impl PolicyModel { } result } - - /// Deduplicated list of all binary paths across all policies. - pub fn all_binaries(&self) -> Vec<&Binary> { - let mut seen = HashSet::new(); - let mut result = Vec::new(); - for rule in self.network_policies.values() { - for b in &rule.binaries { - if seen.insert(&b.path) { - result.push(b); - } - } - } - result - } - - /// All (binary, `policy_name`, endpoint) triples. - pub fn binary_endpoint_pairs(&self) -> Vec<(&Binary, &str, &Endpoint)> { - let mut result = Vec::new(); - for (name, rule) in &self.network_policies { - for b in &rule.binaries { - for ep in &rule.endpoints { - result.push((b, name.as_str(), ep)); - } - } - } - result - } } // --------------------------------------------------------------------------- @@ -388,21 +347,8 @@ pub fn parse_policy_str(yaml: &str) -> Result { }) .collect(); - let binaries = rule_raw - .binaries - .into_iter() - .map(|b| Binary { path: b.path }) - .collect(); - let name = rule_raw.name.unwrap_or_else(|| key.clone()); - network_policies.insert( - key, - NetworkPolicyRule { - name, - endpoints, - binaries, - }, - ); + network_policies.insert(key, NetworkPolicyRule { name, endpoints }); } } diff --git a/crates/openshell-prover/src/queries.rs b/crates/openshell-prover/src/queries.rs index 6a0c7f6a6..8e9e85410 100644 --- a/crates/openshell-prover/src/queries.rs +++ b/crates/openshell-prover/src/queries.rs @@ -11,6 +11,10 @@ use crate::policy::PolicyIntent; /// Check for data exfiltration paths from readable filesystem to writable /// egress channels. +/// +/// Without binary allowlisting, this flags any L4-only endpoint when the +/// sandbox has readable filesystem paths — the agent can use any tool +/// available in its environment to send data over an uninspected channel. pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { if model.policy.filesystem_policy.readable_paths().is_empty() { return Vec::new(); @@ -18,54 +22,24 @@ pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { let mut exfil_paths: Vec = Vec::new(); - for bpath in &model.binary_paths { - let cap = model.binary_registry.get_or_unknown(bpath); - if !cap.can_exfiltrate { - continue; - } - - for eid in &model.endpoints { - let expr = model.can_exfil_via_endpoint(bpath, eid); - - if model.check_sat(&expr) == SatResult::Sat { - // Determine L7 status and mechanism - let ep_is_l7 = is_endpoint_l7_enforced(&model.policy, &eid.host, eid.port); - let bypass = cap.bypasses_l7(); - - let (l7_status, mut mechanism) = if bypass { - ( - "l7_bypassed".to_owned(), - format!( - "{} — uses non-HTTP protocol, bypasses L7 inspection", - cap.description - ), - ) - } else if !ep_is_l7 { - ( - "l4_only".to_owned(), - format!( - "L4-only endpoint — no HTTP inspection, {bpath} can send arbitrary data" - ), - ) - } else { - // L7 is enforced and allows write — policy is - // working as intended. Not a finding. - continue; - }; - - if !cap.exfil_mechanism.is_empty() { - mechanism = format!("{}. Exfil via: {}", mechanism, cap.exfil_mechanism); - } - - exfil_paths.push(ExfilPath { - binary: bpath.clone(), - endpoint_host: eid.host.clone(), - endpoint_port: eid.port, - mechanism, - policy_name: eid.policy_name.clone(), - l7_status, - }); - } + for eid in &model.endpoints { + let ep_is_l7 = is_endpoint_l7_enforced(&model.policy, &eid.host, eid.port); + + // L4-only endpoints allow arbitrary data egress — any process in the + // sandbox can open a TCP connection and send filesystem contents. + if !ep_is_l7 { + exfil_paths.push(ExfilPath { + binary: String::new(), + endpoint_host: eid.host.clone(), + endpoint_port: eid.port, + mechanism: format!( + "L4-only endpoint — no HTTP inspection; any sandbox process can \ + send arbitrary data to {}:{}", + eid.host, eid.port + ), + policy_name: eid.policy_name.clone(), + l7_status: "l4_only".to_owned(), + }); } } @@ -74,99 +48,79 @@ pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { } let readable = model.policy.filesystem_policy.readable_paths(); - let has_l4_only = exfil_paths.iter().any(|p| p.l7_status == "l4_only"); - let has_bypass = exfil_paths.iter().any(|p| p.l7_status == "l7_bypassed"); - let risk = if has_l4_only || has_bypass { - RiskLevel::Critical - } else { - RiskLevel::High - }; - - let mut remediation = Vec::new(); - if has_l4_only { - remediation.push( - "Add `protocol: rest` with specific L7 rules to L4-only endpoints \ - to enable HTTP inspection and restrict to safe methods/paths." - .to_owned(), - ); - } - if has_bypass { - remediation.push( - "Binaries using non-HTTP protocols (git, ssh, nc) bypass L7 inspection. \ - Remove these binaries from the policy if write access is not intended, \ - or restrict credential scopes to read-only." - .to_owned(), - ); - } - remediation - .push("Restrict filesystem read access to only the paths the agent needs.".to_owned()); - + let n_paths = exfil_paths.len(); let paths: Vec = exfil_paths.into_iter().map(FindingPath::Exfil).collect(); - let n_paths = paths.len(); vec![Finding { query: "data_exfiltration".to_owned(), title: "Data Exfiltration Paths Detected".to_owned(), description: format!( - "{n_paths} exfiltration path(s) found from {} readable filesystem path(s) to external endpoints.", + "{n_paths} exfiltration path(s) found from {} readable filesystem path(s) to \ + L4-only external endpoints.", readable.len() ), - risk, + risk: RiskLevel::Critical, paths, - remediation, + remediation: vec![ + "Add `protocol: rest` with specific L7 rules to L4-only endpoints \ + to enable HTTP inspection and restrict to safe methods/paths." + .to_owned(), + "Restrict filesystem read access to only the paths the agent needs.".to_owned(), + ], accepted: false, accepted_reason: String::new(), }] } /// Check for write capabilities that bypass read-only policy intent. +/// +/// Without binary allowlisting this checks whether: +/// - A read-only-intent endpoint is L4-only (any process can bypass method filtering), or +/// - A read-only-intent endpoint has credentials with write scopes. pub fn check_write_bypass(model: &ReachabilityModel) -> Vec { let mut bypass_paths: Vec = Vec::new(); for (policy_name, rule) in &model.policy.network_policies { for ep in &rule.endpoints { - // Only check endpoints where the intent is read-only or L4-only let intent = ep.intent(); if !matches!(intent, PolicyIntent::ReadOnly) { continue; } for port in ep.effective_ports() { - for b in &rule.binaries { - let cap = model.binary_registry.get_or_unknown(&b.path); - - // Check: binary bypasses L7 and can write - if cap.bypasses_l7() && cap.can_write() { - let cred_actions = collect_credential_actions(model, &ep.host, &cap); - if !cred_actions.is_empty() - || model.credentials.credentials_for_host(&ep.host).is_empty() - { - bypass_paths.push(WriteBypassPath { - binary: b.path.clone(), - endpoint_host: ep.host.clone(), - endpoint_port: port, - policy_name: policy_name.clone(), - policy_intent: intent.to_string(), - bypass_reason: "l7_bypass_protocol".to_owned(), - credential_actions: cred_actions, - }); - } - } + let eid = crate::model::EndpointId { + policy_name: policy_name.clone(), + host: ep.host.clone(), + port, + }; - // Check: L4-only endpoint + binary can construct HTTP + credential has write - if !ep.is_l7_enforced() && cap.can_construct_http { - let cred_actions = collect_credential_actions(model, &ep.host, &cap); + let expr = model.endpoint_allows_write(&eid); + if model.check_sat(&expr) == SatResult::Sat { + if ep.is_l7_enforced() { + // L7 enforced but write methods allowed and credential has write scope + let cred_actions = collect_credential_actions(model, &ep.host); if !cred_actions.is_empty() { bypass_paths.push(WriteBypassPath { - binary: b.path.clone(), + binary: String::new(), endpoint_host: ep.host.clone(), endpoint_port: port, policy_name: policy_name.clone(), policy_intent: intent.to_string(), - bypass_reason: "l4_only".to_owned(), + bypass_reason: "credential_write_scope".to_owned(), credential_actions: cred_actions, }); } + } else { + // L4-only: no HTTP method filtering — any process can send writes + bypass_paths.push(WriteBypassPath { + binary: String::new(), + endpoint_host: ep.host.clone(), + endpoint_port: port, + policy_name: policy_name.clone(), + policy_intent: intent.to_string(), + bypass_reason: "l4_only".to_owned(), + credential_actions: collect_credential_actions(model, &ep.host), + }); } } } @@ -193,9 +147,6 @@ pub fn check_write_bypass(model: &ReachabilityModel) -> Vec { "For L4-only endpoints: add `protocol: rest` with `access: read-only` \ to enable HTTP method filtering." .to_owned(), - "For L7-bypassing binaries (git, ssh, nc): remove them from the policy's \ - binary list if write access is not intended." - .to_owned(), "Restrict credential scopes to read-only where possible.".to_owned(), ], accepted: false, @@ -228,11 +179,7 @@ fn is_endpoint_l7_enforced(policy: &crate::policy::PolicyModel, host: &str, port } /// Collect human-readable credential action descriptions for a host. -fn collect_credential_actions( - model: &ReachabilityModel, - host: &str, - _cap: &crate::registry::BinaryCapability, -) -> Vec { +fn collect_credential_actions(model: &ReachabilityModel, host: &str) -> Vec { let creds = model.credentials.credentials_for_host(host); let api = model.credentials.api_for_host(host); let mut actions = Vec::new(); diff --git a/crates/openshell-prover/src/registry.rs b/crates/openshell-prover/src/registry.rs index 63a3fce3b..594331540 100644 --- a/crates/openshell-prover/src/registry.rs +++ b/crates/openshell-prover/src/registry.rs @@ -1,277 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Binary capability registry — loads YAML descriptors that describe what each -//! binary can do (protocols, exfiltration, HTTP construction, etc.). +//! Registry of embedded data files (API descriptors, etc.). //! //! The built-in registry is embedded at compile time via `include_dir!`. //! A filesystem override can be provided at runtime for custom registries. -use std::collections::HashMap; - use include_dir::{Dir, include_dir}; -use miette::{IntoDiagnostic, Result, WrapErr}; -use serde::Deserialize; static EMBEDDED_REGISTRY: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/registry"); -// --------------------------------------------------------------------------- -// Serde types -// --------------------------------------------------------------------------- - -#[derive(Debug, Deserialize)] -struct BinaryCapabilityDef { - binary: String, - #[serde(default)] - description: String, - #[serde(default)] - protocols: Vec, - #[serde(default)] - spawns: Vec, - #[serde(default)] - can_exfiltrate: bool, - #[serde(default)] - exfil_mechanism: String, - #[serde(default)] - can_construct_http: bool, -} - -#[derive(Debug, Deserialize)] -struct BinaryProtocolDef { - #[serde(default)] - name: String, - #[serde(default)] - transport: String, - #[serde(default)] - description: String, - #[serde(default)] - bypasses_l7: bool, - #[serde(default)] - actions: Vec, -} - -#[derive(Debug, Deserialize)] -struct BinaryActionDef { - #[serde(default)] - name: String, - #[serde(default, rename = "type")] - action_type: String, - #[serde(default)] - description: String, -} - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -/// Type of action a binary can perform. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ActionType { - Read, - Write, - Destructive, -} - -impl ActionType { - fn from_str(s: &str) -> Self { - match s { - "write" => Self::Write, - "destructive" => Self::Destructive, - _ => Self::Read, - } - } -} - -/// A single action a binary protocol supports. -#[derive(Debug, Clone)] -pub struct BinaryAction { - pub name: String, - pub action_type: ActionType, - pub description: String, -} - -/// A protocol supported by a binary. -#[derive(Debug, Clone)] -pub struct BinaryProtocol { - pub name: String, - pub transport: String, - pub description: String, - pub bypasses_l7: bool, - pub actions: Vec, -} - -impl BinaryProtocol { - /// Whether any action in this protocol is a write or destructive action. - pub fn can_write(&self) -> bool { - self.actions - .iter() - .any(|a| matches!(a.action_type, ActionType::Write | ActionType::Destructive)) - } -} - -/// Capability descriptor for a single binary. -#[derive(Debug, Clone)] -pub struct BinaryCapability { - pub path: String, - pub description: String, - pub protocols: Vec, - pub spawns: Vec, - pub can_exfiltrate: bool, - pub exfil_mechanism: String, - pub can_construct_http: bool, -} - -impl BinaryCapability { - /// Whether any protocol bypasses L7 inspection. - pub fn bypasses_l7(&self) -> bool { - self.protocols.iter().any(|p| p.bypasses_l7) - } - - /// Whether the binary can perform write actions. - pub fn can_write(&self) -> bool { - self.protocols.iter().any(BinaryProtocol::can_write) || self.can_construct_http - } - - /// Short mechanisms by which this binary can write. - pub fn write_mechanisms(&self) -> Vec { - let mut mechanisms = Vec::new(); - for p in &self.protocols { - if p.can_write() { - for a in &p.actions { - if matches!(a.action_type, ActionType::Write | ActionType::Destructive) { - mechanisms.push(format!("{}: {}", p.name, a.name)); - } - } - } - } - if self.can_construct_http { - mechanisms.push("arbitrary HTTP request construction".to_owned()); - } - mechanisms - } -} - -/// Registry of binary capability descriptors. -#[derive(Debug, Clone, Default)] -pub struct BinaryRegistry { - binaries: HashMap, -} - -impl BinaryRegistry { - /// Look up a binary by exact path. - pub fn get(&self, path: &str) -> Option<&BinaryCapability> { - self.binaries.get(path) - } - - /// Look up a binary, falling back to glob matching, then to a conservative - /// unknown descriptor. - pub fn get_or_unknown(&self, path: &str) -> BinaryCapability { - if let Some(cap) = self.binaries.get(path) { - return cap.clone(); - } - for (reg_path, cap) in &self.binaries { - if reg_path.contains('*') - && let Ok(pattern) = glob::Pattern::new(reg_path) - && pattern.matches(path) - { - return cap.clone(); - } - } - BinaryCapability { - path: path.to_owned(), - description: "Unknown binary — not in registry".to_owned(), - protocols: Vec::new(), - spawns: Vec::new(), - can_exfiltrate: true, - exfil_mechanism: String::new(), - can_construct_http: true, - } - } -} - -// --------------------------------------------------------------------------- -// Loading -// --------------------------------------------------------------------------- - -fn parse_binary_capability(contents: &str, source: &str) -> Result { - let raw: BinaryCapabilityDef = serde_yml::from_str(contents) - .into_diagnostic() - .wrap_err_with(|| format!("parsing binary descriptor {source}"))?; - - let protocols = raw - .protocols - .into_iter() - .map(|p| { - let actions = p - .actions - .into_iter() - .map(|a| BinaryAction { - name: a.name, - action_type: ActionType::from_str(&a.action_type), - description: a.description, - }) - .collect(); - BinaryProtocol { - name: p.name, - transport: p.transport, - description: p.description, - bypasses_l7: p.bypasses_l7, - actions, - } - }) - .collect(); - - Ok(BinaryCapability { - path: raw.binary, - description: raw.description, - protocols, - spawns: raw.spawns, - can_exfiltrate: raw.can_exfiltrate, - exfil_mechanism: raw.exfil_mechanism, - can_construct_http: raw.can_construct_http, - }) -} - -/// Load binary registry from the compile-time embedded registry data. -pub fn load_embedded_binary_registry() -> Result { - let mut binaries = HashMap::new(); - if let Some(dir) = EMBEDDED_REGISTRY.get_dir("binaries") { - for file in dir.files() { - if file.path().extension().is_some_and(|ext| ext == "yaml") { - let contents = file.contents_utf8().ok_or_else(|| { - miette::miette!("non-UTF8 registry file: {}", file.path().display()) - })?; - let cap = parse_binary_capability(contents, &file.path().display().to_string())?; - binaries.insert(cap.path.clone(), cap); - } - } - } - Ok(BinaryRegistry { binaries }) -} - -/// Load binary registry from a filesystem directory override. -pub fn load_binary_registry_from_dir(registry_dir: &std::path::Path) -> Result { - let mut binaries = HashMap::new(); - let binaries_dir = registry_dir.join("binaries"); - if binaries_dir.is_dir() { - let entries = std::fs::read_dir(&binaries_dir) - .into_diagnostic() - .wrap_err_with(|| format!("reading directory {}", binaries_dir.display()))?; - for entry in entries { - let entry = entry.into_diagnostic()?; - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "yaml") { - let contents = std::fs::read_to_string(&path) - .into_diagnostic() - .wrap_err_with(|| format!("reading {}", path.display()))?; - let cap = parse_binary_capability(&contents, &path.display().to_string())?; - binaries.insert(cap.path.clone(), cap); - } - } - } - Ok(BinaryRegistry { binaries }) -} - /// Accessor for the embedded registry (used by credentials module for API descriptors). pub fn embedded_registry() -> &'static Dir<'static> { &EMBEDDED_REGISTRY diff --git a/crates/openshell-prover/testdata/policy.yaml b/crates/openshell-prover/testdata/policy.yaml index e98d95319..abc3d15fb 100644 --- a/crates/openshell-prover/testdata/policy.yaml +++ b/crates/openshell-prover/testdata/policy.yaml @@ -42,9 +42,3 @@ network_policies: # No protocol field — L4 only. - host: github.com port: 443 - binaries: - - { path: /usr/local/bin/claude } - - { path: /usr/bin/git } - - { path: /usr/bin/curl } - - { path: /usr/bin/gh } - - { path: /sandbox/.uv/python/**/python3* } diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 65dd33bea..96159d0bc 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -3,15 +3,12 @@ //! Declarative provider type profiles. -#![allow(deprecated)] // NetworkBinary::harness remains in the public proto for compatibility. - use openshell_core::proto::{ - GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, NetworkBinary, NetworkEndpoint, + GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, NetworkEndpoint, NetworkPolicyRule, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, }; -use serde::ser::SerializeStruct; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use std::collections::{HashMap, HashSet}; use std::sync::OnceLock; @@ -117,8 +114,8 @@ pub struct CredentialRefreshMaterialProfile { // These YAML/JSON DTOs mirror the network policy protos intentionally. Keep // every lossless conversion below in sync with proto/sandbox.proto. If a field // is added to NetworkEndpoint, L7Rule, L7Allow, L7DenyRule, L7QueryMatcher, -// GraphqlOperation, or NetworkBinary, add it here and in both conversion -// directions unless the import/lint path explicitly rejects it. +// or GraphqlOperation, add it here and in both conversion directions unless +// the import/lint path explicitly rejects it. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct EndpointProfile { pub host: String, @@ -216,12 +213,6 @@ pub struct GraphqlOperationProfile { pub fields: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BinaryProfile { - pub path: String, - pub harness: bool, -} - #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct ProviderTypeProfile { pub id: String, @@ -239,8 +230,6 @@ pub struct ProviderTypeProfile { #[serde(default)] pub endpoints: Vec, #[serde(default)] - pub binaries: Vec, - #[serde(default)] pub inference_capable: bool, } @@ -275,7 +264,6 @@ impl ProviderTypeProfile { }) .collect(), endpoints: profile.endpoints.iter().map(endpoint_from_proto).collect(), - binaries: profile.binaries.iter().map(binary_from_proto).collect(), inference_capable: profile.inference_capable, } } @@ -315,7 +303,6 @@ impl ProviderTypeProfile { }) .collect(), endpoints: self.endpoints.iter().map(endpoint_to_proto).collect(), - binaries: self.binaries.iter().map(binary_to_proto).collect(), inference_capable: self.inference_capable, } } @@ -325,54 +312,6 @@ impl ProviderTypeProfile { NetworkPolicyRule { name: rule_name.to_string(), endpoints: self.endpoints.iter().map(endpoint_to_proto).collect(), - binaries: self.binaries.iter().map(binary_to_proto).collect(), - } - } -} - -impl Serialize for BinaryProfile { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - if !self.harness { - return serializer.serialize_str(&self.path); - } - let mut state = serializer.serialize_struct("BinaryProfile", 2)?; - state.serialize_field("path", &self.path)?; - state.serialize_field("harness", &self.harness)?; - state.end() - } -} - -impl<'de> Deserialize<'de> for BinaryProfile { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(untagged)] - enum BinaryProfileInput { - Path(String), - Object(BinaryProfileObject), - } - - #[derive(Deserialize)] - struct BinaryProfileObject { - path: String, - #[serde(default)] - harness: bool, - } - - match BinaryProfileInput::deserialize(deserializer)? { - BinaryProfileInput::Path(path) => Ok(Self { - path, - harness: false, - }), - BinaryProfileInput::Object(binary) => Ok(Self { - path: binary.path, - harness: binary.harness, - }), } } } @@ -597,20 +536,6 @@ fn endpoint_from_proto(endpoint: &NetworkEndpoint) -> EndpointProfile { } } -fn binary_to_proto(binary: &BinaryProfile) -> NetworkBinary { - NetworkBinary { - path: binary.path.clone(), - harness: binary.harness, - } -} - -fn binary_from_proto(binary: &NetworkBinary) -> BinaryProfile { - BinaryProfile { - path: binary.path.clone(), - harness: binary.harness, - } -} - fn rule_to_proto(rule: &L7RuleProfile) -> L7Rule { L7Rule { allow: rule.allow.as_ref().map(allow_to_proto), @@ -983,17 +908,6 @@ pub fn validate_profile_set( )); } } - - for (index, binary) in profile.binaries.iter().enumerate() { - if binary.path.trim().is_empty() { - diagnostics.push(ProfileValidationDiagnostic::error( - source, - profile_id, - format!("binaries[{index}]"), - "binary path must not be empty", - )); - } - } } diagnostics } @@ -1062,7 +976,6 @@ mod tests { ProviderProfileCategory::SourceControl as i32 ); assert_eq!(proto.endpoints.len(), 2); - assert_eq!(proto.binaries.len(), 4); } #[test] @@ -1142,7 +1055,6 @@ credentials: assert_eq!(parsed.id, "github"); assert_eq!(parsed.category, ProviderProfileCategory::SourceControl); - assert_eq!(parsed.binaries[0].path, "/usr/bin/gh"); } #[test] @@ -1179,9 +1091,6 @@ endpoints: fields: [viewer] graphql_max_body_bytes: 131072 path: /graphql -binaries: - - path: /usr/bin/custom - harness: true ", ) .expect("profile should parse"); @@ -1217,7 +1126,6 @@ binaries: .map(|operation| operation.operation_name.as_str()), Some("Viewer") ); - assert!(proto.binaries[0].harness); let reparsed = parse_profile_yaml(&profile_to_yaml(&profile).expect("serialize YAML")) .expect("serialized profile should parse"); @@ -1225,7 +1133,6 @@ binaries: assert_eq!(reprotoo.endpoints[0].rules.len(), 1); assert_eq!(reprotoo.endpoints[0].deny_rules.len(), 1); assert_eq!(reprotoo.endpoints[0].ports, vec![443, 8443]); - assert!(reprotoo.binaries[0].harness); } #[test] @@ -1244,7 +1151,6 @@ credentials: endpoints: - host: "" port: 0 -binaries: ["", /usr/bin/broken] "#, ) .expect("profile should parse"); @@ -1265,7 +1171,6 @@ binaries: ["", /usr/bin/broken] .iter() .any(|message| message.starts_with("invalid endpoint")) ); - assert!(messages.contains(&"binary path must not be empty")); } #[test] @@ -1280,7 +1185,6 @@ binaries: ["", /usr/bin/broken] category: ProviderProfileCategory::Other, credentials: Vec::new(), endpoints: Vec::new(), - binaries: Vec::new(), inference_capable: false, }, ), @@ -1293,7 +1197,6 @@ binaries: ["", /usr/bin/broken] category: ProviderProfileCategory::Other, credentials: Vec::new(), endpoints: Vec::new(), - binaries: Vec::new(), inference_capable: false, }, ), @@ -1306,7 +1209,6 @@ binaries: ["", /usr/bin/broken] category: ProviderProfileCategory::Other, credentials: Vec::new(), endpoints: Vec::new(), - binaries: Vec::new(), inference_capable: false, }, ), diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index b90a9221b..cf65cc63a 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -35,8 +35,6 @@ miette = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } hmac = "0.12" -sha2 = { workspace = true } -hex = "0.4" russh = "0.57" rand_core = "0.6" diff --git a/crates/openshell-sandbox/data/sandbox-policy.rego b/crates/openshell-sandbox/data/sandbox-policy.rego index 0fa1e6be7..4e8e53919 100644 --- a/crates/openshell-sandbox/data/sandbox-policy.rego +++ b/crates/openshell-sandbox/data/sandbox-policy.rego @@ -25,14 +25,8 @@ deny_reason := "missing input.network" if { not input.network } -deny_reason := "missing input.exec" if { - input.network - not input.exec -} - deny_reason := reason if { input.network - input.exec not network_policy_for_request endpoint_misses := [r | some name @@ -40,23 +34,12 @@ deny_reason := reason if { not endpoint_allowed(policy, input.network) r := sprintf("endpoint %s:%d not in policy '%s'", [input.network.host, input.network.port, name]) ] - ancestors_str := concat(" -> ", input.exec.ancestors) - cmdline_str := concat(", ", input.exec.cmdline_paths) - binary_misses := [r | - some name - policy := data.network_policies[name] - endpoint_allowed(policy, input.network) - not binary_allowed(policy, input.exec) - r := sprintf("binary '%s' not allowed in policy '%s' (ancestors: [%s], cmdline: [%s]). SYMLINK HINT: the binary path is the kernel-resolved target from /proc//exe, not the symlink. If your policy specifies a symlink (e.g., /usr/bin/python3) but the actual binary is /usr/bin/python3.11, either: (1) use the canonical path in your policy (run 'readlink -f /usr/bin/python3' inside the sandbox), or (2) ensure symlink resolution is working (check sandbox logs for 'Cannot access container filesystem')", [input.exec.path, name, ancestors_str, cmdline_str]) - ] - all_reasons := array.concat(endpoint_misses, binary_misses) - count(all_reasons) > 0 - reason := concat("; ", all_reasons) + count(endpoint_misses) > 0 + reason := concat("; ", endpoint_misses) } deny_reason := "network connections not allowed by policy" if { input.network - input.exec not network_policy_for_request count(data.network_policies) == 0 } @@ -72,7 +55,6 @@ _matching_policy_names contains name if { some name policy := data.network_policies[name] endpoint_allowed(policy, input.network) - binary_allowed(policy, input.exec) } matched_network_policy := min(_matching_policy_names) if { @@ -81,14 +63,13 @@ matched_network_policy := min(_matching_policy_names) if { # --- Core matching logic --- -# True when at least one network policy matches the request (endpoint + binary). +# True when at least one network policy matches the request (by endpoint). # Expressed as a boolean so that multiple matching policies don't cause a # "complete rule conflict". network_policy_for_request if { some name data.network_policies[name] endpoint_allowed(data.network_policies[name], input.network) - binary_allowed(data.network_policies[name], input.exec) } # Endpoint matching: exact host (case-insensitive) + port in ports list. @@ -122,45 +103,15 @@ endpoint_allowed(policy, network) if { endpoint.ports[_] == network.port } -# Binary matching: exact path. -# SHA256 integrity is enforced in Rust via trust-on-first-use (TOFU) cache, -# not in Rego. The proxy computes and caches binary hashes at runtime. -binary_allowed(policy, exec) if { - some b - b := policy.binaries[_] - not contains(b.path, "*") - b.path == exec.path -} - -# Binary matching: ancestor exact path (e.g., claude spawns node). -binary_allowed(policy, exec) if { - some b - b := policy.binaries[_] - not contains(b.path, "*") - ancestor := exec.ancestors[_] - b.path == ancestor -} - -# Binary matching: glob pattern against exe path or any ancestor. -# NOTE: cmdline_paths are intentionally excluded — argv[0] is trivially -# spoofable via execve and must not be used as a grant-access signal. -binary_allowed(policy, exec) if { - some b in policy.binaries - contains(b.path, "*") - all_paths := array.concat([exec.path], exec.ancestors) - some p in all_paths - glob.match(b.path, ["/"], p) -} - # --- Network action (allow / deny) --- # # These rules are mutually exclusive by construction: -# - "allow" requires `network_policy_for_request` (binary+endpoint matched) +# - "allow" requires `network_policy_for_request` (endpoint matched) # - default is "deny" when no policy matches. default network_action := "deny" -# Explicitly allowed: endpoint + binary match in a network policy → allow. +# Explicitly allowed: endpoint matched in a network policy → allow. network_action := "allow" if { network_policy_for_request } @@ -189,7 +140,6 @@ allow_request if { some name policy := data.network_policies[name] endpoint_allowed(policy, input.network) - binary_allowed(policy, input.exec) _policy_allows_l7(policy) not deny_request } @@ -215,7 +165,6 @@ deny_request if { some name policy := data.network_policies[name] endpoint_allowed(policy, input.network) - binary_allowed(policy, input.exec) _policy_denies_l7(policy) } diff --git a/crates/openshell-sandbox/src/bypass_monitor.rs b/crates/openshell-sandbox/src/bypass_monitor.rs index 9e37ef27c..79ec5aeb6 100644 --- a/crates/openshell-sandbox/src/bypass_monitor.rs +++ b/crates/openshell-sandbox/src/bypass_monitor.rs @@ -260,17 +260,9 @@ pub fn spawn( // Send to denial aggregator if available. if let Some(ref tx) = denial_tx { - let ancestors_vec: Vec = if ancestors == "-" { - vec![] - } else { - ancestors.split(" -> ").map(String::from).collect() - }; - let _ = tx.send(DenialEvent { host: event.dst_addr.clone(), port: event.dst_port, - binary: binary.clone(), - ancestors: ancestors_vec, deny_reason: "direct connection bypassed HTTP CONNECT proxy".to_string(), denial_stage: "bypass".to_string(), l7_method: None, diff --git a/crates/openshell-sandbox/src/denial_aggregator.rs b/crates/openshell-sandbox/src/denial_aggregator.rs index 5d41adffd..a3eb10586 100644 --- a/crates/openshell-sandbox/src/denial_aggregator.rs +++ b/crates/openshell-sandbox/src/denial_aggregator.rs @@ -5,7 +5,7 @@ //! //! The proxy emits a [`DenialEvent`] each time a connection or request is //! denied. The [`DenialAggregator`] receives these events via an MPSC channel, -//! deduplicates them by `(host, port, binary)` key, and maintains running +//! deduplicates them by `(host, port)` key, and maintains running //! counters. Periodically, the aggregator flushes accumulated summaries //! upstream to the gateway via `SubmitPolicyAnalysis`. @@ -21,10 +21,6 @@ pub struct DenialEvent { pub host: String, /// Destination port that was denied. pub port: u16, - /// Binary path that initiated the connection (if resolved). - pub binary: String, - /// Ancestor binary paths from process tree walk. - pub ancestors: Vec, /// Reason for denial (e.g. "no matching policy", "internal address"). pub deny_reason: String, /// Denial stage: "connect", "forward", "ssrf", "l7", "bypass". @@ -35,13 +31,11 @@ pub struct DenialEvent { pub l7_path: Option, } -/// Aggregated denial summary keyed by `(host, port, binary)`. +/// Aggregated denial summary keyed by `(host, port)`. #[derive(Debug, Clone)] struct AggregatedDenial { host: String, port: u16, - binary: String, - ancestors: Vec, deny_reason: String, denial_stage: String, first_seen_ms: i64, @@ -63,8 +57,8 @@ struct L7Sample { /// summaries. It is designed to be spawned as a background tokio task. pub struct DenialAggregator { rx: mpsc::UnboundedReceiver, - /// Accumulated denials keyed by `(host, port, binary)`. - summaries: HashMap<(String, u16, String), AggregatedDenial>, + /// Accumulated denials keyed by `(host, port)`. + summaries: HashMap<(String, u16), AggregatedDenial>, /// Flush interval in seconds. flush_interval_secs: u64, } @@ -124,7 +118,7 @@ impl DenialAggregator { /// a new one. fn ingest(&mut self, event: DenialEvent) { let now_ms = openshell_core::time::now_ms(); - let key = (event.host.clone(), event.port, event.binary.clone()); + let key = (event.host.clone(), event.port); let entry = self .summaries @@ -132,8 +126,6 @@ impl DenialAggregator { .or_insert_with(|| AggregatedDenial { host: event.host.clone(), port: event.port, - binary: event.binary.clone(), - ancestors: event.ancestors.clone(), deny_reason: event.deny_reason.clone(), denial_stage: event.denial_stage.clone(), first_seen_ms: now_ms, @@ -171,8 +163,6 @@ impl DenialAggregator { .map(|(_, v)| FlushableDenialSummary { host: v.host, port: v.port, - binary: v.binary, - ancestors: v.ancestors, deny_reason: v.deny_reason, denial_stage: v.denial_stage, first_seen_ms: v.first_seen_ms, @@ -198,8 +188,6 @@ impl DenialAggregator { pub struct FlushableDenialSummary { pub host: String, pub port: u16, - pub binary: String, - pub ancestors: Vec, pub deny_reason: String, pub denial_stage: String, pub first_seen_ms: i64, diff --git a/crates/openshell-sandbox/src/identity.rs b/crates/openshell-sandbox/src/identity.rs deleted file mode 100644 index 49809f95b..000000000 --- a/crates/openshell-sandbox/src/identity.rs +++ /dev/null @@ -1,329 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! SHA256 trust-on-first-use (TOFU) binary identity cache. -//! -//! On first network request from a binary, the proxy computes its SHA256 hash -//! and caches it as the "golden" hash. Subsequent requests from the same binary -//! path must match the cached hash. A mismatch indicates the binary was replaced -//! mid-sandbox and the request is denied. - -use crate::procfs; -use miette::Result; -use std::collections::HashMap; -use std::fs::Metadata; -#[cfg(unix)] -use std::os::unix::fs::MetadataExt; -use std::path::{Path, PathBuf}; -use std::sync::Mutex; -use tracing::debug; - -#[derive(Clone)] -struct FileFingerprint { - len: u64, - mtime_sec: i64, - mtime_nsec: i64, - ctime_sec: i64, - ctime_nsec: i64, - #[cfg(unix)] - dev: u64, - #[cfg(unix)] - ino: u64, -} - -impl FileFingerprint { - fn from_metadata(metadata: &Metadata) -> Self { - Self { - len: metadata.len(), - mtime_sec: metadata.mtime(), - mtime_nsec: metadata.mtime_nsec(), - ctime_sec: metadata.ctime(), - ctime_nsec: metadata.ctime_nsec(), - #[cfg(unix)] - dev: metadata.dev(), - #[cfg(unix)] - ino: metadata.ino(), - } - } -} - -impl PartialEq for FileFingerprint { - fn eq(&self, other: &Self) -> bool { - self.len == other.len - && self.mtime_sec == other.mtime_sec - && self.mtime_nsec == other.mtime_nsec - && self.ctime_sec == other.ctime_sec - && self.ctime_nsec == other.ctime_nsec - && { - #[cfg(unix)] - { - self.dev == other.dev && self.ino == other.ino - } - #[cfg(not(unix))] - { - true - } - } - } -} - -#[derive(Clone)] -struct CachedBinary { - hash: String, - fingerprint: FileFingerprint, -} - -/// Thread-safe cache of binary SHA256 hashes for TOFU enforcement. -pub struct BinaryIdentityCache { - #[cfg_attr(not(target_os = "linux"), allow(dead_code))] - hashes: Mutex>, -} - -impl BinaryIdentityCache { - pub fn new() -> Self { - Self { - hashes: Mutex::new(HashMap::new()), - } - } - - /// Verify a binary's integrity or cache its hash on first use. - /// - /// - First call for a given path: computes SHA256, caches it, returns the hash. - /// - Subsequent calls: returns cached hash when file fingerprint is unchanged. - /// Recomputes SHA256 only when fingerprint changes. - /// Returns `Ok(hash)` if it matches, `Err` if the hash changed (binary tampered). - #[cfg_attr(not(target_os = "linux"), allow(dead_code))] - pub fn verify_or_cache(&self, path: &Path) -> Result { - self.verify_or_cache_with_hasher(path, procfs::file_sha256) - } - - fn verify_or_cache_with_hasher(&self, path: &Path, mut hash_file: F) -> Result - where - F: FnMut(&Path) -> Result, - { - let start = std::time::Instant::now(); - let metadata = std::fs::metadata(path) - .map_err(|error| miette::miette!("Failed to stat {}: {error}", path.display()))?; - let fingerprint = FileFingerprint::from_metadata(&metadata); - - let cached = self - .hashes - .lock() - .map_err(|_| miette::miette!("Binary identity cache lock poisoned"))? - .get(path) - .cloned(); - - if let Some(cached_binary) = &cached - && cached_binary.fingerprint == fingerprint - { - debug!( - " verify_or_cache: {}ms CACHE HIT path={}", - start.elapsed().as_millis(), - path.display() - ); - return Ok(cached_binary.hash.clone()); - } - - debug!( - " verify_or_cache: CACHE MISS size={} path={}", - metadata.len(), - path.display() - ); - - let current_hash = hash_file(path)?; - - let mut hashes = self - .hashes - .lock() - .map_err(|_| miette::miette!("Binary identity cache lock poisoned"))?; - - if let Some(existing) = hashes.get(path) - && existing.hash != current_hash - { - return Err(miette::miette!( - "Binary integrity violation: {} hash changed (cached: {}, current: {})", - path.display(), - existing.hash, - current_hash - )); - } - - hashes.insert( - path.to_path_buf(), - CachedBinary { - hash: current_hash.clone(), - fingerprint, - }, - ); - - debug!( - " verify_or_cache TOTAL (cold): {}ms path={}", - start.elapsed().as_millis(), - path.display() - ); - - Ok(current_hash) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::procfs; - use std::io::Write; - use std::time::Duration; - - #[test] - fn first_call_caches_hash() { - let mut tmp = tempfile::NamedTempFile::new().unwrap(); - tmp.write_all(b"binary content").unwrap(); - tmp.flush().unwrap(); - - let cache = BinaryIdentityCache::new(); - let hash = cache.verify_or_cache(tmp.path()).unwrap(); - assert!(!hash.is_empty()); - } - - #[test] - fn second_call_matches_cached() { - let mut tmp = tempfile::NamedTempFile::new().unwrap(); - tmp.write_all(b"binary content").unwrap(); - tmp.flush().unwrap(); - - let cache = BinaryIdentityCache::new(); - let hash1 = cache.verify_or_cache(tmp.path()).unwrap(); - let hash2 = cache.verify_or_cache(tmp.path()).unwrap(); - assert_eq!(hash1, hash2); - } - - #[test] - fn unchanged_fingerprint_skips_rehash() { - let mut tmp = tempfile::NamedTempFile::new().unwrap(); - tmp.write_all(b"binary content").unwrap(); - tmp.flush().unwrap(); - - let cache = BinaryIdentityCache::new(); - let mut hash_calls = 0; - - let hash1 = cache - .verify_or_cache_with_hasher(tmp.path(), |path| { - hash_calls += 1; - procfs::file_sha256(path) - }) - .unwrap(); - let hash2 = cache - .verify_or_cache_with_hasher(tmp.path(), |path| { - hash_calls += 1; - procfs::file_sha256(path) - }) - .unwrap(); - - assert_eq!(hash1, hash2); - assert_eq!(hash_calls, 1); - } - - #[test] - fn changed_fingerprint_triggers_rehash() { - let mut tmp = tempfile::NamedTempFile::new().unwrap(); - tmp.write_all(b"binary content").unwrap(); - tmp.flush().unwrap(); - - let cache = BinaryIdentityCache::new(); - let mut hash_calls = 0; - - let hash1 = cache - .verify_or_cache_with_hasher(tmp.path(), |path| { - hash_calls += 1; - procfs::file_sha256(path) - }) - .unwrap(); - - let modified = std::fs::metadata(tmp.path()).unwrap().modified().unwrap(); - let bumped_modified = modified.checked_add(Duration::from_secs(2)).unwrap(); - std::fs::OpenOptions::new() - .write(true) - .open(tmp.path()) - .unwrap() - .set_modified(bumped_modified) - .unwrap(); - - let hash2 = cache - .verify_or_cache_with_hasher(tmp.path(), |path| { - hash_calls += 1; - procfs::file_sha256(path) - }) - .unwrap(); - - assert_eq!(hash1, hash2); - assert_eq!(hash_calls, 2); - } - - #[test] - fn restoring_mtime_still_detects_tamper() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("binary"); - std::fs::write(&path, b"0123456789abcdef").unwrap(); - - let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap(); - let cache = BinaryIdentityCache::new(); - let mut hash_calls = 0; - - cache - .verify_or_cache_with_hasher(&path, |path| { - hash_calls += 1; - procfs::file_sha256(path) - }) - .unwrap(); - - std::thread::sleep(Duration::from_millis(5)); - // Use different-length content so the fingerprint's `len` field - // always differs, regardless of filesystem timestamp resolution. - std::fs::write(&path, b"tampered").unwrap(); - std::fs::OpenOptions::new() - .write(true) - .open(&path) - .unwrap() - .set_modified(original_mtime) - .unwrap(); - - let result = cache.verify_or_cache_with_hasher(&path, |path| { - hash_calls += 1; - procfs::file_sha256(path) - }); - - assert!(result.is_err()); - assert_eq!(hash_calls, 2); - } - - #[test] - fn hash_mismatch_returns_error() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("binary"); - - // Write initial content and cache it - std::fs::write(&path, b"original content").unwrap(); - let initial_mtime = std::fs::metadata(&path).unwrap().modified().unwrap(); - let cache = BinaryIdentityCache::new(); - let _hash = cache.verify_or_cache(&path).unwrap(); - - // Modify the file to simulate binary replacement. - // Force mtime to move forward so the fingerprint changes on filesystems - // with coarse timestamp resolution. - std::fs::write(&path, b"tampered content").unwrap(); - let bumped_mtime = initial_mtime.checked_add(Duration::from_secs(2)).unwrap(); - std::fs::OpenOptions::new() - .write(true) - .open(&path) - .unwrap() - .set_modified(bumped_mtime) - .unwrap(); - - let result = cache.verify_or_cache(&path); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("integrity violation"), - "Expected integrity violation error, got: {err}" - ); - } -} diff --git a/crates/openshell-sandbox/src/l7/graphql.rs b/crates/openshell-sandbox/src/l7/graphql.rs index 5d0746d01..298db4889 100644 --- a/crates/openshell-sandbox/src/l7/graphql.rs +++ b/crates/openshell-sandbox/src/l7/graphql.rs @@ -785,8 +785,6 @@ network_policies: deny_rules: - operation_type: mutation fields: [deleteRepository] - binaries: - - { path: /usr/bin/python3 } "; let engine = crate::opa::OpaEngine::from_strings( include_str!("../../data/sandbox-policy.rego"), @@ -797,8 +795,6 @@ network_policies: host: "host.openshell.internal".to_string(), port: 8080, policy_name: "test_graphql_l7".to_string(), - binary_path: "/usr/bin/python3".to_string(), - ancestors: Vec::new(), cmdline_paths: Vec::new(), secret_resolver: None, }; diff --git a/crates/openshell-sandbox/src/l7/mod.rs b/crates/openshell-sandbox/src/l7/mod.rs index 09278b4f8..8b1aa6c47 100644 --- a/crates/openshell-sandbox/src/l7/mod.rs +++ b/crates/openshell-sandbox/src/l7/mod.rs @@ -1298,7 +1298,7 @@ mod tests { "port": 443, "websocket_credential_rewrite": true }], - "binaries": [] + } } }); @@ -1322,7 +1322,7 @@ mod tests { "protocol": "websocket", "request_body_credential_rewrite": true }], - "binaries": [] + } } }); @@ -1346,7 +1346,7 @@ mod tests { "protocol": "websocket", "access": "read-write" }], - "binaries": [] + } } }); @@ -1377,7 +1377,7 @@ mod tests { {"allow": {"operation_type": "subscription", "fields": ["messageAdded"]}} ] }], - "binaries": [] + } } }); @@ -1400,7 +1400,7 @@ mod tests { {"allow": {"fields": ["messageAdded"]}} ] }], - "binaries": [] + } } }); @@ -1425,7 +1425,7 @@ mod tests { {"allow": {"method": "WEBSOCKET_TEXT", "path": "/graphql", "operation_type": "subscription"}} ] }], - "binaries": [] + } } }); @@ -1451,7 +1451,7 @@ mod tests { {"allow": {"operation_type": "query"}} ] }], - "binaries": [] + } } }); @@ -1476,7 +1476,7 @@ mod tests { "access": "read-only", "rules": [{"allow": {"method": "GET", "path": "**"}}] }], - "binaries": [] + } } }); @@ -1494,7 +1494,7 @@ mod tests { "port": 443, "protocol": "rest" }], - "binaries": [] + } } }); @@ -1518,7 +1518,7 @@ mod tests { "enforcement": "enforce", "rules": [{"allow": {"command": "SELECT"}}] }], - "binaries": [] + } } }); @@ -1538,7 +1538,7 @@ mod tests { "protocol": "rest", "access": "full" }], - "binaries": [] + } } }); @@ -1565,7 +1565,7 @@ mod tests { "protocol": "rest", "access": "read-only" }], - "binaries": [] + } } }); @@ -1589,7 +1589,7 @@ mod tests { "protocol": "rest", "access": "read-only" }], - "binaries": [] + } } }); @@ -1612,7 +1612,7 @@ mod tests { "protocol": "rest", "access": "read-only" }], - "binaries": [] + } } }); @@ -1641,7 +1641,7 @@ mod tests { "protocol": "rest", "access": "full" }], - "binaries": [] + } } }); @@ -1665,7 +1665,7 @@ mod tests { "protocol": "graphql", "access": "read-only" }], - "binaries": [] + } } }); @@ -1695,7 +1695,7 @@ mod tests { } }] }], - "binaries": [] + } } }); @@ -1718,7 +1718,7 @@ mod tests { "access": "full", "persisted_queries": "allow_all" }], - "binaries": [] + } } }); @@ -1738,7 +1738,7 @@ mod tests { "host": "api.example.com", "port": 443 }], - "binaries": [] + } } }); @@ -1761,7 +1761,7 @@ mod tests { "host": "*", "port": 443 }], - "binaries": [] + } } }); @@ -1781,7 +1781,7 @@ mod tests { "host": "**", "port": 443 }], - "binaries": [] + } } }); @@ -1801,7 +1801,7 @@ mod tests { "host": "*com", "port": 443 }], - "binaries": [] + } } }); @@ -1821,7 +1821,7 @@ mod tests { "host": "*.com", "port": 443 }], - "binaries": [] + } } }); @@ -1841,7 +1841,7 @@ mod tests { "host": "**.org", "port": 443 }], - "binaries": [] + } } }); @@ -1861,7 +1861,7 @@ mod tests { "host": "*.example.com", "port": 443 }], - "binaries": [] + } } }); @@ -1886,7 +1886,7 @@ mod tests { "port": 443, "ports": [443, 8443] }], - "binaries": [] + } } }); @@ -1911,7 +1911,7 @@ mod tests { "protocol": "rest", "access": "read-only" }], - "binaries": [] + } } }); @@ -1942,7 +1942,7 @@ mod tests { } }] }], - "binaries": [] + } } }); @@ -1972,7 +1972,7 @@ mod tests { } }] }], - "binaries": [] + } } }); @@ -2002,7 +2002,7 @@ mod tests { } }] }], - "binaries": [] + } } }); @@ -2038,7 +2038,7 @@ mod tests { } }] }], - "binaries": [] + } } }); @@ -2074,7 +2074,7 @@ mod tests { } }] }], - "binaries": [] + } } }); @@ -2112,7 +2112,7 @@ mod tests { } }] }], - "binaries": [] + } } }); @@ -2135,7 +2135,7 @@ mod tests { "port": 443, "deny_rules": [{ "method": "POST", "path": "/admin" }] }], - "binaries": [] + } } }); @@ -2159,7 +2159,7 @@ mod tests { "protocol": "rest", "deny_rules": [{ "method": "POST", "path": "/admin" }] }], - "binaries": [] + } } }); @@ -2184,7 +2184,7 @@ mod tests { "access": "full", "deny_rules": [] }], - "binaries": [] + } } }); @@ -2212,7 +2212,7 @@ mod tests { { "method": "PUT", "path": "/repos/*/branches/*/protection" } ] }], - "binaries": [] + } } }); @@ -2239,7 +2239,7 @@ mod tests { "query": { "type": { "any": [] } } }] }], - "binaries": [] + } } }); @@ -2268,7 +2268,7 @@ mod tests { "query": { "force": 123 } }] }], - "binaries": [] + } } }); @@ -2301,7 +2301,7 @@ mod tests { } }] }], - "binaries": [] + } } }); diff --git a/crates/openshell-sandbox/src/l7/relay.rs b/crates/openshell-sandbox/src/l7/relay.rs index 6d271af21..8c6ab89ed 100644 --- a/crates/openshell-sandbox/src/l7/relay.rs +++ b/crates/openshell-sandbox/src/l7/relay.rs @@ -29,11 +29,7 @@ pub struct L7EvalContext { pub port: u16, /// Matched policy name from L4 evaluation. pub policy_name: String, - /// Binary path (for cross-layer Rego evaluation). - pub binary_path: String, - /// Ancestor paths. - pub ancestors: Vec, - /// Cmdline paths. + /// Cmdline-derived paths for script detection / observability. pub cmdline_paths: Vec, /// Supervisor-only placeholder resolver for outbound headers. pub(crate) secret_resolver: Option>, @@ -295,7 +291,6 @@ where Some(crate::l7::rest::DenyResponseContext { host: Some(&ctx.host), port: Some(ctx.port), - binary: Some(&ctx.binary_path), }), ) .await?; @@ -387,7 +382,6 @@ where Some(crate::l7::rest::DenyResponseContext { host: Some(&ctx.host), port: Some(ctx.port), - binary: Some(&ctx.binary_path), }), ) .await?; @@ -685,7 +679,6 @@ where Some(crate::l7::rest::DenyResponseContext { host: Some(&ctx.host), port: Some(ctx.port), - binary: Some(&ctx.binary_path), }), ) .await?; @@ -813,7 +806,6 @@ where Some(crate::l7::rest::DenyResponseContext { host: Some(&ctx.host), port: Some(ctx.port), - binary: Some(&ctx.binary_path), }), ) .await?; @@ -1036,7 +1028,6 @@ where Some(crate::l7::rest::DenyResponseContext { host: Some(&ctx.host), port: Some(ctx.port), - binary: Some(&ctx.binary_path), }), ) .await?; @@ -1112,11 +1103,6 @@ pub fn evaluate_l7_request( "host": ctx.host, "port": ctx.port, }, - "exec": { - "path": ctx.binary_path, - "ancestors": ctx.ancestors, - "cmdline_paths": ctx.cmdline_paths, - }, "request": { "method": request.action, "path": request.target, @@ -1293,7 +1279,6 @@ where mod tests { use super::*; use crate::opa::{NetworkInput, OpaEngine}; - use std::path::PathBuf; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; const TEST_POLICY: &str = include_str!("../../data/sandbox-policy.rego"); @@ -1346,16 +1331,11 @@ network_policies: - allow: method: GET path: "/ws" - binaries: - - { path: /usr/bin/node } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); let input = NetworkInput { host: "gateway.example.test".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let generation = engine @@ -1367,8 +1347,6 @@ network_policies: host: "gateway.example.test".into(), port: 443, policy_name: "ws_api".into(), - binary_path: "/usr/bin/node".into(), - ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, }; @@ -1400,8 +1378,6 @@ network_policies: - allow: method: GET path: "/ws" - binaries: - - { path: /usr/bin/node } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); let tunnel_engine = engine @@ -1422,8 +1398,6 @@ network_policies: host: "gateway.example.test".into(), port: 443, policy_name: "route_api".into(), - binary_path: "/usr/bin/node".into(), - ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, }; @@ -1500,8 +1474,6 @@ network_policies: method: WEBSOCKET_TEXT path: "/ws" websocket_credential_rewrite: true - binaries: - - { path: /usr/bin/node } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); let tunnel_engine = engine @@ -1526,8 +1498,6 @@ network_policies: host: "gateway.example.test".into(), port: 443, policy_name: "route_api".into(), - binary_path: "/usr/bin/node".into(), - ancestors: vec![], cmdline_paths: vec![], secret_resolver: resolver.map(Arc::new), }; @@ -1617,8 +1587,6 @@ network_policies: operation_type: query fields: [viewer] websocket_credential_rewrite: true - binaries: - - { path: /usr/bin/node } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); let tunnel_engine = engine @@ -1643,8 +1611,6 @@ network_policies: host: "gateway.example.test".into(), port: 443, policy_name: "route_api".into(), - binary_path: "/usr/bin/node".into(), - ancestors: vec![], cmdline_paths: vec![], secret_resolver: resolver.map(Arc::new), }; @@ -1776,8 +1742,6 @@ network_policies: - allow: method: POST path: "/write" - binaries: - - { path: /usr/bin/curl } "#; let reloaded_data = r#" network_policies: @@ -1792,16 +1756,11 @@ network_policies: - allow: method: GET path: "/write" - binaries: - - { path: /usr/bin/curl } "#; let engine = OpaEngine::from_strings(TEST_POLICY, initial_data).unwrap(); let input = NetworkInput { host: "api.example.test".into(), port: 8080, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let (endpoint_config, generation) = engine @@ -1813,8 +1772,6 @@ network_policies: host: "api.example.test".into(), port: 8080, policy_name: "rest_api".into(), - binary_path: "/usr/bin/curl".into(), - ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, }; @@ -1900,8 +1857,6 @@ network_policies: host: "api.example.test".into(), port: 8080, policy_name: "rest_api".into(), - binary_path: "/usr/bin/curl".into(), - ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, }; diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index c513499f4..181b95ed2 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -87,7 +87,6 @@ impl L7Provider for RestProvider { pub(crate) struct DenyResponseContext<'a> { pub(crate) host: Option<&'a str>, pub(crate) port: Option, - pub(crate) binary: Option<&'a str>, } impl RestProvider { @@ -1267,7 +1266,6 @@ fn deny_response_body( let target = redacted_target.unwrap_or(&req.target); let context = context.unwrap_or_default(); let host = non_empty(context.host); - let binary = non_empty(context.binary); let mut rule_missing = serde_json::Map::new(); rule_missing.insert("type".to_string(), serde_json::json!("rest_allow")); @@ -1280,9 +1278,6 @@ fn deny_response_body( if let Some(port) = context.port { rule_missing.insert("port".to_string(), serde_json::json!(port)); } - if let Some(binary) = binary { - rule_missing.insert("binary".to_string(), serde_json::json!(binary)); - } let mut body = serde_json::Map::new(); body.insert("error".to_string(), serde_json::json!("policy_denied")); @@ -1302,9 +1297,6 @@ fn deny_response_body( if let Some(port) = context.port { body.insert("port".to_string(), serde_json::json!(port)); } - if let Some(binary) = binary { - body.insert("binary".to_string(), serde_json::json!(binary)); - } body.insert( "rule_missing".to_string(), serde_json::Value::Object(rule_missing), @@ -2300,7 +2292,6 @@ mod tests { Some(DenyResponseContext { host: Some("api.github.com"), port: Some(443), - binary: Some("/usr/bin/gh"), }), ); @@ -2311,7 +2302,6 @@ mod tests { assert_eq!(body["method"], "PUT"); assert_eq!(body["host"], "api.github.com"); assert_eq!(body["port"], 443); - assert_eq!(body["binary"], "/usr/bin/gh"); assert_eq!(body["path"], "/repos/NVIDIA/OpenShell/contents/README.md"); assert_eq!( body["rule"], @@ -2326,7 +2316,6 @@ mod tests { ); assert_eq!(body["rule_missing"]["host"], "api.github.com"); assert_eq!(body["rule_missing"]["port"], 443); - assert_eq!(body["rule_missing"]["binary"], "/usr/bin/gh"); assert_eq!(body["next_steps"][0]["action"], "read_skill"); assert_eq!( body["next_steps"][0]["path"], @@ -2361,7 +2350,6 @@ mod tests { Some(DenyResponseContext { host: Some("api.github.com"), port: Some(443), - binary: Some("/usr/bin/gh"), }), ) .await diff --git a/crates/openshell-sandbox/src/l7/websocket.rs b/crates/openshell-sandbox/src/l7/websocket.rs index 2dc1b25c3..15c19aedf 100644 --- a/crates/openshell-sandbox/src/l7/websocket.rs +++ b/crates/openshell-sandbox/src/l7/websocket.rs @@ -1106,7 +1106,6 @@ mod tests { use crate::l7::relay::L7EvalContext; use crate::opa::{NetworkInput, OpaEngine}; use crate::secrets::SecretResolver; - use std::path::PathBuf; use tokio::io::{AsyncReadExt, AsyncWriteExt}; const TEST_POLICY: &str = include_str!("../../data/sandbox-policy.rego"); @@ -1130,8 +1129,6 @@ network_policies: - allow: operation_type: subscription fields: [messageAdded] - binaries: - - { path: /usr/bin/node } "#; fn resolver() -> (HashMap, SecretResolver) { @@ -1250,9 +1247,6 @@ network_policies: let network_input = NetworkInput { host: "realtime.graphql.test".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let generation = engine @@ -1266,8 +1260,6 @@ network_policies: host: "realtime.graphql.test".into(), port: 443, policy_name: "graphql_ws".into(), - binary_path: "/usr/bin/node".into(), - ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, }; diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index ded56ce9e..763ceec42 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -9,7 +9,6 @@ pub mod bypass_monitor; mod child_env; pub mod denial_aggregator; mod grpc_client; -mod identity; pub mod l7; pub mod log_push; pub mod mechanistic_mapper; @@ -167,7 +166,6 @@ pub(crate) mod test_helpers { } } -use crate::identity::BinaryIdentityCache; use crate::l7::tls::{ CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config, read_system_ca_bundle, write_ca_files, @@ -405,11 +403,6 @@ pub async fn run_sandbox( ); let provider_env = provider_credentials.snapshot().child_env.clone(); - // Create identity cache for SHA256 TOFU when OPA is active - let identity_cache = opa_engine - .as_ref() - .map(|_| Arc::new(BinaryIdentityCache::new())); - // Prepare filesystem: create and chown read_write directories prepare_filesystem(&policy)?; @@ -581,10 +574,6 @@ pub async fn run_sandbox( miette::miette!("Proxy mode requires an OPA engine (--rego-policy and --rego-data)") })?; - let cache = identity_cache.clone().ok_or_else(|| { - miette::miette!("Proxy mode requires an identity cache (OPA engine must be configured)") - })?; - // If we have a network namespace, bind to the veth host IP so sandboxed // processes can reach the proxy via TCP. #[cfg(target_os = "linux")] @@ -618,7 +607,6 @@ pub async fn run_sandbox( proxy_policy, bind_addr, engine, - cache, entrypoint_pid.clone(), tls_state, inference_ctx, @@ -868,66 +856,6 @@ pub async fn run_sandbox( .build() ); - // Spawn a task to resolve policy binary symlinks after the container - // filesystem becomes accessible via /proc//root/. This expands - // symlinks like /usr/bin/python3 → /usr/bin/python3.11 in the OPA - // policy data so that either path matches at evaluation time. - // - // We cannot do this synchronously here because the child process has - // just been spawned and its mount namespace / procfs entries may not - // be fully populated yet. Instead, we probe with retries until - // /proc//root/ is accessible or we exhaust attempts. - if let (Some(engine), Some(proto)) = (&opa_engine, &retained_proto) { - let resolve_engine = engine.clone(); - let resolve_proto = proto.clone(); - let resolve_pid = entrypoint_pid.clone(); - tokio::spawn(async move { - let pid = resolve_pid.load(Ordering::Acquire); - let probe_path = format!("/proc/{pid}/root/"); - // Retry up to 10 times with 500ms intervals (5s total). - // The child's mount namespace is typically ready within a - // few hundred ms of spawn. - for attempt in 1..=10 { - tokio::time::sleep(Duration::from_millis(500)).await; - if std::fs::metadata(&probe_path).is_ok() { - info!( - pid = pid, - attempt = attempt, - "Container filesystem accessible, resolving policy binary symlinks" - ); - match resolve_engine.reload_from_proto_with_pid(&resolve_proto, pid) { - Ok(()) => { - info!( - pid = pid, - "Policy binary symlink resolution complete \ - (check logs above for per-binary results)" - ); - } - Err(e) => { - warn!( - "Failed to rebuild OPA engine with symlink resolution \ - (non-fatal, falling back to literal path matching): {e}" - ); - } - } - return; - } - debug!( - pid = pid, - attempt = attempt, - probe_path = %probe_path, - "Container filesystem not yet accessible, retrying symlink resolution" - ); - } - warn!( - "Container filesystem /proc/{pid}/root/ not accessible after 10 attempts (5s); \ - binary symlink resolution skipped. Policy binary paths will be matched literally. \ - If binaries are symlinks, use canonical paths in your policy \ - (run 'readlink -f ' inside the sandbox)" - ); - }); - } - // Spawn background policy poll task (gRPC mode only). if let (Some(id), Some(endpoint), Some(engine)) = (&sandbox_id, &openshell_endpoint, &opa_engine) @@ -936,7 +864,6 @@ pub async fn run_sandbox( let poll_endpoint = endpoint.clone(); let poll_engine = engine.clone(); let poll_ocsf_enabled = ocsf_enabled.clone(); - let poll_pid = entrypoint_pid.clone(); let poll_provider_credentials = provider_credentials.clone(); let poll_policy_local = policy_local_ctx.clone(); let poll_interval_secs: u64 = std::env::var("OPENSHELL_POLICY_POLL_INTERVAL_SECS") @@ -947,7 +874,6 @@ pub async fn run_sandbox( endpoint: poll_endpoint, sandbox_id: poll_id, opa_engine: poll_engine, - entrypoint_pid: poll_pid, interval_secs: poll_interval_secs, ocsf_enabled: poll_ocsf_enabled, provider_credentials: poll_provider_credentials, @@ -1691,7 +1617,6 @@ mod baseline_tests { port: 443, ..Default::default() }], - ..Default::default() }, ); @@ -2239,8 +2164,6 @@ async fn flush_proposals_to_gateway( sandbox_id: String::new(), host: s.host, port: u32::from(s.port), - binary: s.binary, - ancestors: s.ancestors, deny_reason: s.deny_reason, first_seen_ms: s.first_seen_ms, last_seen_ms: s.last_seen_ms, @@ -2248,7 +2171,6 @@ async fn flush_proposals_to_gateway( suppressed_count: 0, total_count: s.count, sample_cmdlines: s.sample_cmdlines, - binary_sha256: String::new(), persistent: false, denial_stage: s.denial_stage, l7_request_samples: s @@ -2284,16 +2206,12 @@ async fn flush_proposals_to_gateway( Ok(()) } -/// `reload_from_proto_with_pid()`. Reports load success/failure back to the -/// server. On failure, the previous engine is untouched (LKG behavior). -/// -/// When the entrypoint PID is available, policy reloads include symlink -/// resolution for binary paths via the container filesystem. +/// Context for the background policy poll loop. Reports load success/failure +/// back to the server. On failure, the previous engine is untouched (LKG behavior). struct PolicyPollLoopContext { endpoint: String, sandbox_id: String, opa_engine: Arc, - entrypoint_pid: Arc, interval_secs: u64, ocsf_enabled: Arc, provider_credentials: provider_credentials::ProviderCredentialState, @@ -2415,8 +2333,7 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { continue; }; - let pid = ctx.entrypoint_pid.load(Ordering::Acquire); - match ctx.opa_engine.reload_from_proto_with_pid(policy, pid) { + match ctx.opa_engine.reload_from_proto(policy) { Ok(()) => { if let Some(policy_local_ctx) = ctx.policy_local_ctx.as_ref() { policy_local_ctx.set_current_policy(policy.clone()).await; @@ -2920,8 +2837,6 @@ network_policies: name: test endpoints: - { host: example.com, port: 443 } - binaries: - - { path: /usr/bin/curl } "#, ) .unwrap(); diff --git a/crates/openshell-sandbox/src/mechanistic_mapper.rs b/crates/openshell-sandbox/src/mechanistic_mapper.rs index 521c882a0..671c38b35 100644 --- a/crates/openshell-sandbox/src/mechanistic_mapper.rs +++ b/crates/openshell-sandbox/src/mechanistic_mapper.rs @@ -5,8 +5,8 @@ //! draft `NetworkPolicyRule` proposals. //! //! This is the "zero-LLM" baseline for policy recommendations. It inspects -//! denial patterns (host, port, binary, frequency) and generates concrete rules -//! that would allow the denied connections, annotated with confidence scores and +//! denial patterns (host, port, frequency) and generates concrete rules that +//! would allow the denied connections, annotated with confidence scores and //! security notes. //! //! The LLM-powered `PolicyAdvisor` (issue #205) wraps and enriches these @@ -14,7 +14,7 @@ use openshell_core::net::is_always_blocked_ip; use openshell_core::proto::{ - DenialSummary, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, PolicyChunk, + DenialSummary, L7Allow, L7Rule, NetworkEndpoint, NetworkPolicyRule, PolicyChunk, }; use std::collections::HashMap; use std::net::IpAddr; @@ -43,10 +43,9 @@ const WELL_KNOWN_PORTS: &[(u16, &str)] = &[ /// Generate draft `PolicyChunk` proposals from denial summaries. /// -/// Groups denials by `(host, port, binary)`, then for each group generates a -/// `PolicyChunk` with a `NetworkPolicyRule` allowing that endpoint for that -/// single binary. This produces one proposal per binary so each -/// `(sandbox_id, host, port, binary)` maps to exactly one DB row. +/// Groups denials by `(host, port)`, then for each group generates a +/// `PolicyChunk` with a `NetworkPolicyRule` allowing that endpoint. +/// Each `(sandbox_id, host, port)` maps to exactly one DB row. /// /// Proposals never include `allowed_ips`. If the user applies a proposed rule /// and the host resolves to a private IP, the proxy's SSRF defense will deny @@ -57,24 +56,19 @@ const WELL_KNOWN_PORTS: &[(u16, &str)] = &[ /// /// Returns an empty vec if there are no actionable denials. pub fn generate_proposals(summaries: &[DenialSummary]) -> Vec { - // Group denials by (host, port, binary). - let mut groups: HashMap<(String, u32, String), Vec<&DenialSummary>> = HashMap::new(); + // Group denials by (host, port). + let mut groups: HashMap<(String, u32), Vec<&DenialSummary>> = HashMap::new(); for summary in summaries { - let binary_key = if summary.binary.is_empty() { - String::new() - } else { - summary.binary.clone() - }; groups - .entry((summary.host.clone(), summary.port, binary_key)) + .entry((summary.host.clone(), summary.port)) .or_default() .push(summary); } let mut proposals = Vec::new(); - for ((host, port, binary), denials) in &groups { + for ((host, port), denials) in &groups { let rule_name = generate_rule_name(host, *port); let mut total_count: u32 = 0; @@ -140,32 +134,15 @@ pub fn generate_proposals(summaries: &[DenialSummary]) -> Vec { } }; - let binaries: Vec = if binary.is_empty() { - vec![] - } else { - vec![NetworkBinary { - path: binary.clone(), - ..Default::default() - }] - }; - let proposed_rule = NetworkPolicyRule { name: rule_name.clone(), endpoints: vec![endpoint], - binaries, }; // Compute confidence. #[allow(clippy::cast_possible_truncation)] let confidence = compute_confidence(total_count, *port as u16, is_ssrf); - // Generate rationale. - let binary_list = if binary.is_empty() { - "unknown binary".to_string() - } else { - short_binary_name(binary) - }; - #[allow(clippy::cast_possible_truncation)] let port_u16 = *port as u16; let port_name = WELL_KNOWN_PORTS @@ -179,16 +156,13 @@ pub fn generate_proposals(summaries: &[DenialSummary]) -> Vec { let rationale = if has_l7 && !l7_methods.is_empty() { let paths: Vec = l7_methods.keys().map(|(m, p)| format!("{m} {p}")).collect(); format!( - "Allow {binary_list} to connect to {host}:{port}{port_name} \ + "Allow connections to {host}:{port}{port_name} \ with L7 inspection. \ Allowed paths: {}.", paths.join(", ") ) } else { - format!( - "Allow {binary_list} to connect to \ - {host}:{port}{port_name}." - ) + format!("Allow connections to {host}:{port}{port_name}.") }; // Generate security notes. @@ -216,7 +190,6 @@ pub fn generate_proposals(summaries: &[DenialSummary]) -> Vec { hit_count: total_count.cast_signed(), first_seen_ms, last_seen_ms, - binary: binary.clone(), validation_result: String::new(), rejection_reason: String::new(), }); @@ -232,11 +205,10 @@ pub fn generate_proposals(summaries: &[DenialSummary]) -> Vec { proposals } -/// Generate a rule name that doesn't conflict with existing rules. /// Generate a deterministic, idempotent rule name from host and port. /// /// The same `(host, port)` always produces the same name. DB-level dedup on -/// `(sandbox_id, host, port, binary)` handles collisions — no need to check +/// `(sandbox_id, host, port)` handles collisions — no need to check /// existing rule names. fn generate_rule_name(host: &str, port: u32) -> String { let sanitized = host @@ -408,11 +380,6 @@ fn looks_like_id(segment: &str) -> bool { false } -/// Extract just the binary name from a full path. -fn short_binary_name(path: &str) -> String { - path.rsplit('/').next().unwrap_or(path).to_string() -} - /// Check if a destination host is always-blocked. /// /// For literal IP hosts, checks against [`is_always_blocked_ip`]. @@ -473,8 +440,6 @@ mod tests { sandbox_id: "test".to_string(), host: "api.example.com".to_string(), port: 443, - binary: "/usr/bin/curl".to_string(), - ancestors: vec![], deny_reason: "no matching policy".to_string(), first_seen_ms: 1000, last_seen_ms: 2000, @@ -482,7 +447,6 @@ mod tests { suppressed_count: 0, total_count: 5, sample_cmdlines: vec![], - binary_sha256: String::new(), persistent: false, denial_stage: "connect".to_string(), l7_request_samples: vec![], @@ -498,8 +462,6 @@ mod tests { assert_eq!(rule.endpoints.len(), 1); assert_eq!(rule.endpoints[0].host, "api.example.com"); assert_eq!(rule.endpoints[0].port, 443); - assert_eq!(rule.binaries.len(), 1); - assert_eq!(rule.binaries[0].path, "/usr/bin/curl"); // No L7 fields when no samples provided. assert!(rule.endpoints[0].protocol.is_empty()); @@ -517,8 +479,6 @@ mod tests { sandbox_id: "test".to_string(), host: "icanhazdadjoke.com".to_string(), port: 443, - binary: "/usr/bin/python3".to_string(), - ancestors: vec![], deny_reason: "l7 deny".to_string(), first_seen_ms: 1000, last_seen_ms: 2000, @@ -526,7 +486,6 @@ mod tests { suppressed_count: 0, total_count: 3, sample_cmdlines: vec![], - binary_sha256: String::new(), persistent: false, denial_stage: "l7_deny".to_string(), l7_request_samples: vec![ @@ -616,7 +575,6 @@ mod tests { let summaries = vec![DenialSummary { host: "127.0.0.1".to_string(), port: 80, - binary: "/usr/bin/curl".to_string(), count: 5, first_seen_ms: 1000, last_seen_ms: 2000, @@ -636,7 +594,6 @@ mod tests { let summaries = vec![DenialSummary { host: "169.254.169.254".to_string(), port: 80, - binary: "/usr/bin/curl".to_string(), count: 5, first_seen_ms: 1000, last_seen_ms: 2000, @@ -656,7 +613,6 @@ mod tests { let summaries = vec![DenialSummary { host: "localhost".to_string(), port: 8080, - binary: "/usr/bin/curl".to_string(), count: 3, first_seen_ms: 1000, last_seen_ms: 2000, @@ -676,7 +632,6 @@ mod tests { let summaries = vec![DenialSummary { host: "api.github.com".to_string(), port: 443, - binary: "/usr/bin/curl".to_string(), count: 5, first_seen_ms: 1000, last_seen_ms: 2000, diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index e9058372d..859b829e9 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -30,7 +30,7 @@ pub struct PolicyDecision { /// Network action returned by OPA `network_action` rule. /// -/// - `Allow`: endpoint + binary explicitly matched in a network policy +/// - `Allow`: endpoint matched in a network policy /// - `Deny`: no matching policy #[derive(Debug, Clone, PartialEq, Eq)] pub enum NetworkAction { @@ -42,10 +42,6 @@ pub enum NetworkAction { 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, /// Absolute paths extracted from `/proc//cmdline` of the socket-owning /// process and its ancestors. Captures script paths (e.g. `/usr/local/bin/claude`) /// that don't appear in `/proc//exe` because the interpreter (node) is the exe. @@ -179,18 +175,7 @@ impl OpaEngine { /// /// Expands access presets and validates L7 config. pub fn from_proto(proto: &ProtoSandboxPolicy) -> Result { - Self::from_proto_with_pid(proto, 0) - } - - /// Create OPA engine from a typed proto policy with symlink resolution. - /// - /// When `entrypoint_pid` is non-zero, binary paths in the policy that are - /// symlinks inside the container filesystem are resolved via - /// `/proc//root/` and added as additional entries. This bridges the - /// gap between user-specified symlink paths (e.g., `/usr/bin/python3`) and - /// kernel-resolved canonical paths (e.g., `/usr/bin/python3.11`). - pub fn from_proto_with_pid(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> Result { - let data_json_str = proto_to_opa_data_json(proto, entrypoint_pid); + let data_json_str = proto_to_opa_data_json(proto); // Parse back to Value for preprocessing, then re-serialize let mut data: serde_json::Value = serde_json::from_str(&data_json_str) @@ -239,22 +224,7 @@ impl OpaEngine { /// `allow_network` rule, and returns a `PolicyDecision` with the result, /// deny reason, and matched policy name. pub fn evaluate_network(&self, input: &NetworkInput) -> Result { - let ancestor_strs: Vec = input - .ancestors - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - let cmdline_strs: Vec = input - .cmdline_paths - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); let input_json = serde_json::json!({ - "exec": { - "path": input.binary_path.to_string_lossy(), - "ancestors": ancestor_strs, - "cmdline_paths": cmdline_strs, - }, "network": { "host": input.host, "port": input.port, @@ -309,22 +279,7 @@ impl OpaEngine { &self, input: &NetworkInput, ) -> Result<(NetworkAction, u64)> { - let ancestor_strs: Vec = input - .ancestors - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - let cmdline_strs: Vec = input - .cmdline_paths - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); let input_json = serde_json::json!({ - "exec": { - "path": input.binary_path.to_string_lossy(), - "ancestors": ancestor_strs, - "cmdline_paths": cmdline_strs, - }, "network": { "host": input.host, "port": input.port, @@ -394,21 +349,8 @@ impl OpaEngine { /// validation guarantees as initial load. Atomically replaces the inner /// engine on success; on failure the previous engine is untouched (LKG). pub fn reload_from_proto(&self, proto: &ProtoSandboxPolicy) -> Result<()> { - self.reload_from_proto_with_pid(proto, 0) - } - - /// Reload policy from a proto with symlink resolution. - /// - /// When `entrypoint_pid` is non-zero, binary paths that are symlinks - /// inside the container filesystem are resolved and added as additional - /// match entries. See [`from_proto_with_pid`] for details. - pub fn reload_from_proto_with_pid( - &self, - proto: &ProtoSandboxPolicy, - entrypoint_pid: u32, - ) -> Result<()> { // Build a complete new engine through the same validated pipeline. - let new = Self::from_proto_with_pid(proto, entrypoint_pid)?; + let new = Self::from_proto(proto)?; let new_engine = new .engine .into_inner() @@ -500,22 +442,7 @@ impl OpaEngine { &self, input: &NetworkInput, ) -> Result<(Vec, u64)> { - let ancestor_strs: Vec = input - .ancestors - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - let cmdline_strs: Vec = input - .cmdline_paths - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); let input_json = serde_json::json!({ - "exec": { - "path": input.binary_path.to_string_lossy(), - "ancestors": ancestor_strs, - "cmdline_paths": cmdline_strs, - }, "network": { "host": input.host, "port": input.port, @@ -751,133 +678,7 @@ fn normalize_endpoint_ports(data: &mut serde_json::Value) { } } -/// Resolve a policy binary path through the container's root filesystem. -/// -/// On Linux, `/proc//root/` provides access to the container's mount -/// namespace. If the policy path is a symlink inside the container -/// (e.g., `/usr/bin/python3` → `/usr/bin/python3.11`), returns the -/// canonical target path. Returns `None` if: -/// - Not on Linux -/// - `entrypoint_pid` is 0 (container not yet started) -/// - Path contains glob characters -/// - Path is not a symlink -/// - Resolution fails (binary doesn't exist in container) -/// - Resolved path equals the original -/// -/// Normalize a path by resolving `.` and `..` components without touching -/// the filesystem. Only works correctly for absolute paths. -#[cfg(any(target_os = "linux", test))] -fn normalize_path(path: &Path) -> PathBuf { - let mut result = PathBuf::new(); - for component in path.components() { - match component { - std::path::Component::ParentDir => { - result.pop(); - } - std::path::Component::CurDir => {} - other => result.push(other), - } - } - result -} - -#[cfg(target_os = "linux")] -fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option { - if policy_path.contains('*') || entrypoint_pid == 0 { - return None; - } - - // Walk the symlink chain inside the container filesystem using - // read_link rather than canonicalize. canonicalize resolves - // /proc//root itself (a kernel pseudo-symlink to /) which - // strips the prefix we need. read_link only reads the target of - // the specified symlink, keeping us in the container's namespace. - let mut resolved = PathBuf::from(policy_path); - - // Linux SYMLOOP_MAX is 40; stop before infinite loops - for _ in 0..40 { - let container_path = format!("/proc/{entrypoint_pid}/root{}", resolved.display()); - - tracing::debug!( - "Symlink resolution: probing container_path={container_path} for policy_path={policy_path} pid={entrypoint_pid}" - ); - - let meta = match std::fs::symlink_metadata(&container_path) { - Ok(m) => m, - Err(e) => { - // Only warn on the first iteration (the original policy path). - // On subsequent iterations, the intermediate target may - // legitimately not exist (broken symlink chain). - if resolved.as_os_str() == policy_path { - tracing::warn!( - "Cannot access container filesystem for symlink resolution: \ - path={policy_path} container_path={container_path} pid={entrypoint_pid} \ - error={e}. Binary paths in policy will be matched literally. \ - If this binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \ - use the canonical path instead, or run with CAP_SYS_PTRACE." - ); - } else { - tracing::warn!( - "Symlink chain broken during resolution: \ - original={policy_path} current={} pid={entrypoint_pid} error={e}. \ - Binary will be matched by original path only.", - resolved.display() - ); - } - return None; - } - }; - - if !meta.file_type().is_symlink() { - // Reached a non-symlink — this is the final resolved target - break; - } - - let target = match std::fs::read_link(&container_path) { - Ok(t) => t, - Err(e) => { - tracing::warn!( - "Symlink detected but read_link failed: \ - path={policy_path} current={} pid={entrypoint_pid} error={e}. \ - Binary will be matched by original path only.", - resolved.display() - ); - return None; - } - }; - - if target.is_absolute() { - resolved = target; - } else { - // Relative symlink: resolve against the containing directory - // e.g., /usr/bin/python3 -> python3.11 becomes /usr/bin/python3.11 - if let Some(parent) = resolved.parent() { - resolved = normalize_path(&parent.join(&target)); - } else { - break; - } - } - } - - let resolved_str = resolved.to_string_lossy().into_owned(); - - if resolved_str == policy_path { - None - } else { - tracing::info!( - "Resolved policy binary symlink via container filesystem: \ - original={policy_path} resolved={resolved_str} pid={entrypoint_pid}" - ); - Some(resolved_str) - } -} - -#[cfg(not(target_os = "linux"))] -fn resolve_binary_in_container(_policy_path: &str, _entrypoint_pid: u32) -> Option { - None -} - -/// Convert typed proto policy fields to JSON suitable for `engine.add_data_json()`. +/// Serialize a typed proto `SandboxPolicy` to OPA data JSON. /// /// The rego rules reference `data.*` directly, so the JSON structure has /// top-level keys matching the data expectations: @@ -886,13 +687,7 @@ fn resolve_binary_in_container(_policy_path: &str, _entrypoint_pid: u32) -> Opti /// - `data.process` /// - `data.network_policies` /// -/// When `entrypoint_pid` is non-zero, binary paths that are symlinks inside -/// the container filesystem are resolved via `/proc//root/` and added -/// as additional entries alongside the original path. This ensures that -/// user-specified symlink paths (e.g., `/usr/bin/python3`) match the -/// kernel-resolved canonical paths reported by `/proc//exe` (e.g., -/// `/usr/bin/python3.11`). -fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> String { +fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy) -> String { let filesystem_policy = proto.filesystem.as_ref().map_or_else( || { serde_json::json!({ @@ -1093,23 +888,11 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St ep }) .collect(); - let binaries: Vec = rule - .binaries - .iter() - .flat_map(|b| { - let mut entries = vec![serde_json::json!({"path": &b.path})]; - if let Some(resolved) = resolve_binary_in_container(&b.path, entrypoint_pid) { - entries.push(serde_json::json!({"path": resolved})); - } - entries - }) - .collect(); ( key.clone(), serde_json::json!({ "name": rule.name, "endpoints": endpoints, - "binaries": binaries, }), ) }) @@ -1136,9 +919,8 @@ mod tests { use super::*; use openshell_core::proto::{ - FilesystemPolicy as ProtoFs, L7Allow, L7QueryMatcher, L7Rule, NetworkBinary, - NetworkEndpoint, NetworkPolicyRule, ProcessPolicy as ProtoProc, - SandboxPolicy as ProtoSandboxPolicy, + FilesystemPolicy as ProtoFs, L7Allow, L7QueryMatcher, L7Rule, NetworkEndpoint, + NetworkPolicyRule, ProcessPolicy as ProtoProc, SandboxPolicy as ProtoSandboxPolicy, }; const TEST_POLICY: &str = include_str!("../data/sandbox-policy.rego"); @@ -1166,10 +948,6 @@ mod tests { ..Default::default() }, ], - binaries: vec![NetworkBinary { - path: "/usr/local/bin/claude".to_string(), - ..Default::default() - }], }, ); network_policies.insert( @@ -1181,10 +959,6 @@ mod tests { port: 443, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/glab".to_string(), - ..Default::default() - }], }, ); ProtoSandboxPolicy { @@ -1206,16 +980,12 @@ mod tests { } #[test] - fn allowed_binary_and_endpoint() { + fn allowed_endpoint() { let engine = test_engine(); - // Simulates Claude Code: exe is /usr/bin/node, script is /usr/local/bin/claude let input = NetworkInput { host: "api.anthropic.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![PathBuf::from("/usr/local/bin/claude")], + cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); assert!( @@ -1226,35 +996,12 @@ mod tests { assert_eq!(decision.matched_policy.as_deref(), Some("claude_code")); } - #[test] - fn wrong_binary_denied() { - let engine = test_engine(); - let input = NetworkInput { - host: "api.anthropic.com".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/python3"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!(!decision.allowed); - assert!( - decision.reason.contains("not allowed"), - "Expected specific deny reason, got: {}", - decision.reason - ); - } - #[test] fn wrong_endpoint_denied() { let engine = test_engine(); let input = NetworkInput { host: "evil.example.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -1266,30 +1013,12 @@ mod tests { ); } - #[test] - fn unknown_binary_default_deny() { - let engine = test_engine(); - let input = NetworkInput { - host: "api.anthropic.com".into(), - port: 443, - binary_path: PathBuf::from("/tmp/malicious"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!(!decision.allowed); - } - #[test] fn github_policy_allows_git() { let engine = test_engine(); let input = NetworkInput { host: "github.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/git"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -1298,9 +1027,11 @@ mod tests { "Expected allow, got deny: {}", decision.reason ); - assert_eq!( - decision.matched_policy.as_deref(), - Some("github_ssh_over_https") + // Multiple policies (github_ssh_over_https, copilot) cover github.com:443; + // rego picks the lexicographically smallest name. + assert!( + decision.matched_policy.is_some(), + "Expected a matched policy name" ); } @@ -1310,10 +1041,7 @@ mod tests { let input = NetworkInput { host: "API.ANTHROPIC.COM".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![PathBuf::from("/usr/local/bin/claude")], + cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); assert!( @@ -1329,9 +1057,6 @@ mod tests { let input = NetworkInput { host: "api.anthropic.com".into(), port: 80, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -1367,10 +1092,7 @@ mod tests { let input = NetworkInput { host: "api.anthropic.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![PathBuf::from("/usr/local/bin/claude")], + cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); assert!(decision.allowed); @@ -1384,10 +1106,7 @@ mod tests { let input = NetworkInput { host: "api.anthropic.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![PathBuf::from("/usr/local/bin/claude")], + cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); assert!(decision.allowed); @@ -1414,256 +1133,6 @@ network_policies: {} ); } - #[test] - fn ancestor_binary_allowed() { - // Use github policy: binary /usr/bin/git is the policy binary. - // If the socket process is /usr/bin/python3 but its ancestor is /usr/bin/git, allow. - let engine = test_engine(); - let input = NetworkInput { - host: "github.com".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/python3"), - binary_sha256: "unused".into(), - ancestors: vec![PathBuf::from("/usr/bin/git")], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!( - decision.allowed, - "Expected allow via ancestor match, got deny: {}", - decision.reason - ); - assert_eq!( - decision.matched_policy.as_deref(), - Some("github_ssh_over_https") - ); - } - - #[test] - fn no_ancestor_match_denied() { - let engine = test_engine(); - let input = NetworkInput { - host: "github.com".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/python3"), - binary_sha256: "unused".into(), - ancestors: vec![PathBuf::from("/usr/bin/bash")], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!(!decision.allowed); - assert!( - decision.reason.contains("not allowed"), - "Expected 'not allowed' in deny reason, got: {}", - decision.reason - ); - } - - #[test] - fn deep_ancestor_chain_matches() { - let engine = test_engine(); - let input = NetworkInput { - host: "github.com".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/python3"), - binary_sha256: "unused".into(), - ancestors: vec![PathBuf::from("/usr/bin/sh"), PathBuf::from("/usr/bin/git")], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!( - decision.allowed, - "Expected allow via deep ancestor match, got deny: {}", - decision.reason - ); - } - - #[test] - fn empty_ancestors_falls_back_to_direct() { - let engine = test_engine(); - // Direct binary path match still works with empty ancestors and cmdline - let input = NetworkInput { - host: "api.anthropic.com".into(), - port: 443, - binary_path: PathBuf::from("/usr/local/bin/claude"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!( - decision.allowed, - "Direct path match should still work with empty ancestors" - ); - } - - #[test] - fn glob_pattern_matches_binary() { - // Test with a policy that uses glob patterns - let glob_data = r#" -network_policies: - glob_test: - name: glob_test - endpoints: - - { host: example.com, port: 443 } - binaries: - - { path: "/usr/bin/*" } -"#; - let engine = OpaEngine::from_strings(TEST_POLICY, glob_data).unwrap(); - let input = NetworkInput { - host: "example.com".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!( - decision.allowed, - "Expected glob pattern to match binary, got deny: {}", - decision.reason - ); - } - - #[test] - fn glob_pattern_matches_ancestor() { - let glob_data = r#" -network_policies: - glob_test: - name: glob_test - endpoints: - - { host: example.com, port: 443 } - binaries: - - { path: "/usr/local/bin/*" } -"#; - let engine = OpaEngine::from_strings(TEST_POLICY, glob_data).unwrap(); - let input = NetworkInput { - host: "example.com".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![PathBuf::from("/usr/local/bin/claude")], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!( - decision.allowed, - "Expected glob pattern to match ancestor, got deny: {}", - decision.reason - ); - } - - #[test] - fn glob_pattern_no_cross_segment() { - // * should NOT match across / boundaries - let glob_data = r#" -network_policies: - glob_test: - name: glob_test - endpoints: - - { host: example.com, port: 443 } - binaries: - - { path: "/usr/bin/*" } -"#; - let engine = OpaEngine::from_strings(TEST_POLICY, glob_data).unwrap(); - let input = NetworkInput { - host: "example.com".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/subdir/node"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!(!decision.allowed, "Glob * should not cross / boundaries"); - } - - #[test] - fn cmdline_path_does_not_grant_access() { - // Simulates: node runs /usr/local/bin/my-tool (a script with shebang). - // exe = /usr/bin/node, cmdline contains /usr/local/bin/my-tool. - // cmdline_paths are attacker-controlled (argv[0] spoofing) and must - // NOT be used as a grant-access signal. - let cmdline_data = r" -network_policies: - script_test: - name: script_test - endpoints: - - { host: example.com, port: 443 } - binaries: - - { path: /usr/local/bin/my-tool } -"; - let engine = OpaEngine::from_strings(TEST_POLICY, cmdline_data).unwrap(); - let input = NetworkInput { - host: "example.com".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![PathBuf::from("/usr/bin/bash")], - cmdline_paths: vec![PathBuf::from("/usr/local/bin/my-tool")], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!( - !decision.allowed, - "cmdline_paths must not grant network access (argv[0] is spoofable)" - ); - } - - #[test] - fn cmdline_path_no_match_denied() { - let cmdline_data = r" -network_policies: - script_test: - name: script_test - endpoints: - - { host: example.com, port: 443 } - binaries: - - { path: /usr/local/bin/my-tool } -"; - let engine = OpaEngine::from_strings(TEST_POLICY, cmdline_data).unwrap(); - let input = NetworkInput { - host: "example.com".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![PathBuf::from("/usr/bin/bash")], - cmdline_paths: vec![ - PathBuf::from("/usr/bin/node"), - PathBuf::from("/tmp/script.js"), - ], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!(!decision.allowed); - } - - #[test] - fn cmdline_glob_pattern_does_not_grant_access() { - let glob_data = r#" -network_policies: - glob_test: - name: glob_test - endpoints: - - { host: example.com, port: 443 } - binaries: - - { path: "/usr/local/bin/*" } -"#; - let engine = OpaEngine::from_strings(TEST_POLICY, glob_data).unwrap(); - let input = NetworkInput { - host: "example.com".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![PathBuf::from("/usr/local/bin/claude")], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!( - !decision.allowed, - "cmdline_paths must not match globs for granting access (argv[0] is spoofable)" - ); - } - #[test] fn from_proto_allows_matching_request() { let proto = test_proto(); @@ -1671,9 +1140,6 @@ network_policies: let input = NetworkInput { host: "api.anthropic.com".into(), port: 443, - binary_path: PathBuf::from("/usr/local/bin/claude"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -1692,9 +1158,6 @@ network_policies: let input = NetworkInput { host: "evil.example.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -1738,8 +1201,6 @@ network_policies: - allow: method: POST path: "/repos/*/issues" - binaries: - - { path: /usr/bin/curl } readonly_api: name: readonly_api endpoints: @@ -1748,8 +1209,6 @@ network_policies: protocol: rest enforcement: enforce access: read-only - binaries: - - { path: /usr/bin/curl } full_api: name: full_api endpoints: @@ -1758,8 +1217,6 @@ network_policies: protocol: rest enforcement: audit access: full - binaries: - - { path: /usr/bin/curl } query_api: name: query_api endpoints: @@ -1779,8 +1236,6 @@ network_policies: query: tag: any: ["foo-*", "bar-*"] - binaries: - - { path: /usr/bin/curl } graphql_api: name: graphql_api endpoints: @@ -1805,8 +1260,6 @@ network_policies: deny_rules: - operation_type: mutation fields: [deleteRepository] - binaries: - - { path: /usr/bin/curl } graphql_readonly: name: graphql_readonly endpoints: @@ -1815,8 +1268,6 @@ network_policies: protocol: graphql enforcement: enforce access: read-only - binaries: - - { path: /usr/bin/curl } graphql_ws: name: graphql_ws endpoints: @@ -1837,14 +1288,10 @@ network_policies: fields: [messageAdded] deny_rules: - operation_type: mutation - binaries: - - { path: /usr/bin/curl } l4_only: name: l4_only endpoints: - { host: l4only.example.com, port: 443 } - binaries: - - { path: /usr/bin/curl } filesystem_policy: include_workdir: true read_only: [] @@ -1873,11 +1320,6 @@ process: ) -> serde_json::Value { serde_json::json!({ "network": { "host": host, "port": port }, - "exec": { - "path": "/usr/bin/curl", - "ancestors": [], - "cmdline_paths": [] - }, "request": { "method": method, "path": path, @@ -1889,11 +1331,6 @@ process: fn l7_graphql_input(host: &str, operations: serde_json::Value) -> serde_json::Value { serde_json::json!({ "network": { "host": host, "port": 443 }, - "exec": { - "path": "/usr/bin/curl", - "ancestors": [], - "cmdline_paths": [] - }, "request": { "method": "POST", "path": "/graphql", @@ -1908,11 +1345,6 @@ process: fn l7_graphql_error_input(host: &str, error: &str) -> serde_json::Value { serde_json::json!({ "network": { "host": host, "port": 443 }, - "exec": { - "path": "/usr/bin/curl", - "ancestors": [], - "cmdline_paths": [] - }, "request": { "method": "POST", "path": "/graphql", @@ -1928,11 +1360,6 @@ process: fn l7_websocket_graphql_input(host: &str, operations: serde_json::Value) -> serde_json::Value { serde_json::json!({ "network": { "host": host, "port": 443 }, - "exec": { - "path": "/usr/bin/curl", - "ancestors": [], - "cmdline_paths": [] - }, "request": { "method": "WEBSOCKET_TEXT", "path": "/graphql", @@ -2247,8 +1674,6 @@ network_policies: - allow: operation_type: query fields: [viewer] - binaries: - - { path: /usr/bin/curl } "#; let data_json: serde_json::Value = serde_yml::from_str(data).expect("fixture should parse as YAML"); @@ -2296,8 +1721,6 @@ network_policies: rules: - allow: operation_type: query - binaries: - - { path: /usr/bin/curl } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); @@ -2436,10 +1859,6 @@ network_policies: }], ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }, ); @@ -2488,24 +1907,6 @@ network_policies: assert!(!eval_l7(&engine, &input)); } - #[test] - fn l7_wrong_binary_denied_even_with_matching_rules() { - let engine = l7_engine(); - let input = serde_json::json!({ - "network": { "host": "api.example.com", "port": 8080 }, - "exec": { - "path": "/usr/bin/python3", - "ancestors": [], - "cmdline_paths": [] - }, - "request": { - "method": "GET", - "path": "/repos/myorg/foo" - } - }); - assert!(!eval_l7(&engine, &input)); - } - #[test] fn l7_deny_reason_populated() { let engine = l7_engine(); @@ -2531,9 +1932,6 @@ network_policies: let input = NetworkInput { host: "api.example.com".into(), port: 8080, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let config = engine.query_endpoint_config(&input).unwrap(); @@ -2560,10 +1958,6 @@ network_policies: allow_encoded_slash: true, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/node".to_string(), - ..Default::default() - }], }, ); let proto = ProtoSandboxPolicy { @@ -2587,9 +1981,6 @@ network_policies: let input = NetworkInput { host: "registry.npmjs.org".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; @@ -2617,10 +2008,6 @@ network_policies: websocket_credential_rewrite: true, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/node".to_string(), - ..Default::default() - }], }, ); let proto = ProtoSandboxPolicy { @@ -2644,9 +2031,6 @@ network_policies: let input = NetworkInput { host: "gateway.example.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; @@ -2674,10 +2058,6 @@ network_policies: request_body_credential_rewrite: true, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/node".to_string(), - ..Default::default() - }], }, ); let proto = ProtoSandboxPolicy { @@ -2701,9 +2081,6 @@ network_policies: let input = NetworkInput { host: "slack.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; @@ -2721,9 +2098,6 @@ network_policies: let input = NetworkInput { host: "l4only.example.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let config = engine.query_endpoint_config(&input).unwrap(); @@ -2773,8 +2147,6 @@ network_policies: - host: api.example.com port: 8080 protocol: invalid-protocol - binaries: - - { path: /usr/bin/curl } "#; assert!(engine.reload(TEST_POLICY, invalid_l7_data).is_err()); assert_eq!(engine.current_generation(), 1); @@ -2797,9 +2169,6 @@ network_policies: let input = NetworkInput { host: "api.example.com".into(), port: 8080, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; @@ -2849,8 +2218,6 @@ network_policies: path: "/repos/*/branches/*/protection" - method: "*" path: "/repos/*/rulesets" - binaries: - - { path: /usr/bin/curl } deny_with_query: name: deny_with_query endpoints: @@ -2864,8 +2231,6 @@ network_policies: path: "/admin/**" query: force: "true" - binaries: - - { path: /usr/bin/curl } filesystem_policy: include_workdir: true read_only: [] @@ -3109,8 +2474,6 @@ network_policies: - allow: method: GET path: "**" - binaries: - - { path: /usr/bin/curl } allow_192_168_1_100_8567: name: allow_192_168_1_100_8567 endpoints: @@ -3124,8 +2487,6 @@ network_policies: - allow: method: GET path: "**" - binaries: - - { path: /usr/bin/curl } filesystem_policy: include_workdir: true read_only: [] @@ -3162,9 +2523,6 @@ process: let input = NetworkInput { host: "192.168.1.100".into(), port: 8567, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: String::new(), - ancestors: vec![], cmdline_paths: vec![], }; // Should return config from one of the entries without error. @@ -3185,14 +2543,10 @@ network_policies: name: claude_code endpoints: - { host: api.anthropic.com, port: 443 } - binaries: - - { path: /usr/local/bin/claude } gitlab: name: gitlab endpoints: - { host: gitlab.com, port: 443 } - binaries: - - { path: /usr/bin/glab } filesystem_policy: include_workdir: true read_only: [] @@ -3210,8 +2564,6 @@ network_policies: name: gitlab endpoints: - { host: gitlab.com, port: 443 } - binaries: - - { path: /usr/bin/glab } filesystem_policy: include_workdir: true read_only: [] @@ -3239,9 +2591,6 @@ process: let input = NetworkInput { host: "api.anthropic.com".into(), port: 443, - binary_path: PathBuf::from("/usr/local/bin/claude"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let action = engine.evaluate_network_action(&input).unwrap(); @@ -3259,9 +2608,6 @@ process: let input = NetworkInput { host: "api.openai.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/python3"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let action = engine.evaluate_network_action(&input).unwrap(); @@ -3277,9 +2623,6 @@ process: let input = NetworkInput { host: "api.openai.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/python3"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let action = engine.evaluate_network_action(&input).unwrap(); @@ -3290,53 +2633,12 @@ process: } #[test] - fn endpoint_in_policy_binary_not_allowed_returns_deny() { - // api.anthropic.com is declared but python3 is not in the binary list. - // With binary allow/deny, this is denied. - let engine = inference_engine(); + fn from_proto_explicitly_allowed_returns_allow() { + let proto = test_proto(); + let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); let input = NetworkInput { host: "api.anthropic.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/python3"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - let action = engine.evaluate_network_action(&input).unwrap(); - match &action { - NetworkAction::Deny { .. } => {} - other => panic!("Expected Deny, got: {other:?}"), - } - } - - #[test] - fn endpoint_in_policy_binary_not_allowed_without_inference_returns_deny() { - let engine = no_inference_engine(); - let input = NetworkInput { - host: "gitlab.com".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/python3"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - let action = engine.evaluate_network_action(&input).unwrap(); - match &action { - NetworkAction::Deny { .. } => {} - other => panic!("Expected Deny, got: {other:?}"), - } - } - - #[test] - fn from_proto_explicitly_allowed_returns_allow() { - let proto = test_proto(); - let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); - let input = NetworkInput { - host: "api.anthropic.com".into(), - port: 443, - binary_path: PathBuf::from("/usr/local/bin/claude"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let action = engine.evaluate_network_action(&input).unwrap(); @@ -3355,9 +2657,6 @@ process: let input = NetworkInput { host: "api.openai.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/python3"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let action = engine.evaluate_network_action(&input).unwrap(); @@ -3374,9 +2673,6 @@ process: let input = NetworkInput { host: "api.anthropic.com".into(), port: 443, - binary_path: PathBuf::from("/usr/local/bin/claude"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let action = engine.evaluate_network_action(&input).unwrap(); @@ -3387,22 +2683,19 @@ process: }, ); - // git to github.com → allow + // git to github.com → allow (multiple policies cover github.com:443) let input = NetworkInput { host: "github.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/git"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let action = engine.evaluate_network_action(&input).unwrap(); - assert_eq!( - action, - NetworkAction::Allow { - matched_policy: Some("github_ssh_over_https".to_string()) - }, - ); + match &action { + NetworkAction::Allow { matched_policy } => { + assert!(matched_policy.is_some(), "Expected a matched policy"); + } + other => panic!("Expected Allow for github.com:443, got: {other:?}"), + } } // ======================================================================== @@ -3418,23 +2711,17 @@ network_policies: - host: my-service.corp.net port: 8080 allowed_ips: ["10.0.5.0/24"] - binaries: - - { path: /usr/bin/curl } # Mode 3: allowed_ips only (no host) — uses port 9443 to avoid overlap private_network: name: private_network endpoints: - port: 9443 allowed_ips: ["172.16.0.0/12", "192.168.1.1"] - binaries: - - { path: /usr/bin/curl } # Mode 1: host only (no allowed_ips) — standard behavior public_api: name: public_api endpoints: - { host: api.github.com, port: 443 } - binaries: - - { path: /usr/bin/curl } filesystem_policy: include_workdir: true read_only: [] @@ -3457,9 +2744,6 @@ process: let input = NetworkInput { host: "my-service.corp.net".into(), port: 8080, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -3477,9 +2761,6 @@ process: let input = NetworkInput { host: "my-service.corp.net".into(), port: 8080, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let ips = engine.query_allowed_ips(&input).unwrap(); @@ -3493,9 +2774,6 @@ process: let input = NetworkInput { host: "anything.example.com".into(), port: 9443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -3512,9 +2790,6 @@ process: let input = NetworkInput { host: "anything.example.com".into(), port: 9443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let ips = engine.query_allowed_ips(&input).unwrap(); @@ -3527,9 +2802,6 @@ process: let input = NetworkInput { host: "api.github.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let ips = engine.query_allowed_ips(&input).unwrap(); @@ -3543,9 +2815,6 @@ process: let input = NetworkInput { host: "anything.example.com".into(), port: 12345, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -3566,10 +2835,6 @@ process: allowed_ips: vec!["10.0.5.0/24".to_string(), "10.0.6.0/24".to_string()], ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }, ); let proto = ProtoSandboxPolicy { @@ -3593,9 +2858,6 @@ process: let input = NetworkInput { host: "internal.corp.net".into(), port: 8080, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let ips = engine.query_allowed_ips(&input).unwrap(); @@ -3614,16 +2876,11 @@ network_policies: name: multi endpoints: - { host: api.example.com, ports: [443, 8443] } - binaries: - - { path: /usr/bin/curl } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); let input = NetworkInput { host: "api.example.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -3642,16 +2899,11 @@ network_policies: name: multi endpoints: - { host: api.example.com, ports: [443, 8443] } - binaries: - - { path: /usr/bin/curl } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); let input = NetworkInput { host: "api.example.com".into(), port: 8443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -3670,16 +2922,11 @@ network_policies: name: multi endpoints: - { host: api.example.com, ports: [443, 8443] } - binaries: - - { path: /usr/bin/curl } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); let input = NetworkInput { host: "api.example.com".into(), port: 80, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -3695,16 +2942,11 @@ network_policies: name: compat endpoints: - { host: api.example.com, port: 443 } - binaries: - - { path: /usr/bin/curl } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); let input = NetworkInput { host: "api.example.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -3718,9 +2960,6 @@ network_policies: let input_bad = NetworkInput { host: "api.example.com".into(), port: 80, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input_bad).unwrap(); @@ -3736,17 +2975,12 @@ network_policies: endpoints: - ports: [80, 443] allowed_ips: ["10.0.0.0/8"] - binaries: - - { path: /usr/bin/curl } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); // Port 80 let input80 = NetworkInput { host: "anything.internal".into(), port: 80, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input80).unwrap(); @@ -3759,9 +2993,6 @@ network_policies: let input443 = NetworkInput { host: "anything.internal".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input443).unwrap(); @@ -3774,9 +3005,6 @@ network_policies: let input_bad = NetworkInput { host: "anything.internal".into(), port: 8080, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input_bad).unwrap(); @@ -3796,10 +3024,6 @@ network_policies: ports: vec![443, 8443], ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }, ); let proto = ProtoSandboxPolicy { @@ -3823,9 +3047,6 @@ network_policies: let input443 = NetworkInput { host: "api.example.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; assert!(engine.evaluate_network(&input443).unwrap().allowed); @@ -3833,9 +3054,6 @@ network_policies: let input8443 = NetworkInput { host: "api.example.com".into(), port: 8443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; assert!(engine.evaluate_network(&input8443).unwrap().allowed); @@ -3843,9 +3061,6 @@ network_policies: let input80 = NetworkInput { host: "api.example.com".into(), port: 80, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; assert!(!engine.evaluate_network(&input80).unwrap().allowed); @@ -3863,16 +3078,11 @@ network_policies: name: wildcard endpoints: - { host: "*.example.com", port: 443 } - binaries: - - { path: /usr/bin/curl } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); let input = NetworkInput { host: "api.example.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -3892,16 +3102,11 @@ network_policies: name: wildcard endpoints: - { host: "*.example.com", port: 443 } - binaries: - - { path: /usr/bin/curl } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); let input = NetworkInput { host: "deep.sub.example.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -3919,16 +3124,11 @@ network_policies: name: wildcard endpoints: - { host: "*.example.com", port: 443 } - binaries: - - { path: /usr/bin/curl } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); let input = NetworkInput { host: "example.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -3946,16 +3146,11 @@ network_policies: name: wildcard endpoints: - { host: "*.EXAMPLE.COM", port: 443 } - binaries: - - { path: /usr/bin/curl } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); let input = NetworkInput { host: "api.example.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -3974,17 +3169,12 @@ network_policies: name: wildcard endpoints: - { host: "*.example.com", port: 443 } - binaries: - - { path: /usr/bin/curl } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); // Right host, wrong port let input = NetworkInput { host: "api.example.com".into(), port: 80, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -3999,16 +3189,11 @@ network_policies: name: wildcard endpoints: - { host: "*.example.com", ports: [443, 8443] } - binaries: - - { path: /usr/bin/curl } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); let input = NetworkInput { host: "api.example.com".into(), port: 8443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); @@ -4035,8 +3220,6 @@ network_policies: - allow: method: GET path: "/api/**" - binaries: - - { path: /usr/bin/curl } filesystem_policy: include_workdir: true read_only: [] @@ -4078,8 +3261,6 @@ network_policies: - allow: method: GET path: "**" - binaries: - - { path: /usr/bin/curl } filesystem_policy: include_workdir: true read_only: [] @@ -4094,9 +3275,6 @@ process: let input = NetworkInput { host: "api.example.com".into(), port: 8080, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let config = engine.query_endpoint_config(&input).unwrap(); @@ -4126,8 +3304,6 @@ network_policies: - allow: method: GET path: "**" - binaries: - - { path: /usr/bin/curl } filesystem_policy: include_workdir: true read_only: [] @@ -4153,229 +3329,8 @@ process: ); } - // ======================================================================== - // Symlink resolution tests (issue #770) - // ======================================================================== - #[test] - fn normalize_path_resolves_parent_and_current() { - use std::path::{Path, PathBuf}; - assert_eq!( - normalize_path(Path::new("/usr/bin/../lib/python3")), - PathBuf::from("/usr/lib/python3") - ); - assert_eq!( - normalize_path(Path::new("/usr/bin/./python3")), - PathBuf::from("/usr/bin/python3") - ); - assert_eq!( - normalize_path(Path::new("/a/b/c/../../d")), - PathBuf::from("/a/d") - ); - assert_eq!( - normalize_path(Path::new("/usr/bin/python3")), - PathBuf::from("/usr/bin/python3") - ); - } - - #[test] - fn resolve_binary_skips_glob_paths() { - // Glob patterns should never be resolved — they're matched differently - assert!(resolve_binary_in_container("/usr/bin/*", 1).is_none()); - assert!(resolve_binary_in_container("/usr/local/bin/**", 1).is_none()); - } - - #[test] - fn resolve_binary_skips_pid_zero() { - // pid=0 means the container hasn't started yet - assert!(resolve_binary_in_container("/usr/bin/python3", 0).is_none()); - } - - #[test] - fn resolve_binary_returns_none_for_nonexistent_path() { - // A path that doesn't exist in any container should gracefully return None - assert!( - resolve_binary_in_container("/nonexistent/binary/path/that/will/never/exist", 1) - .is_none() - ); - } - - #[test] - fn proto_to_opa_data_json_pid_zero_no_expansion() { - // With pid=0, proto_to_opa_data_json should produce the same output - // as the original (no symlink expansion) - let proto = test_proto(); - let data_no_pid = proto_to_opa_data_json(&proto, 0); - let parsed: serde_json::Value = serde_json::from_str(&data_no_pid).unwrap(); - - // Verify the claude_code policy has exactly 1 binary entry (no expansion) - let binaries = parsed["network_policies"]["claude_code"]["binaries"] - .as_array() - .unwrap(); - assert_eq!( - binaries.len(), - 1, - "With pid=0, should have no expanded binaries" - ); - assert_eq!(binaries[0]["path"], "/usr/local/bin/claude"); - } - - #[test] - fn symlink_expanded_binary_allows_resolved_path() { - // Simulate what happens after symlink resolution: the OPA data - // contains both the original symlink path and the resolved path. - // A request using the resolved path should be allowed. - let data = r#" -network_policies: - python_policy: - name: python_policy - endpoints: - - { host: pypi.org, port: 443 } - binaries: - - { path: /usr/bin/python3 } - - { path: /usr/bin/python3.11 } -"#; - let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); - - // Request with the resolved path (what the kernel reports) - let input = NetworkInput { - host: "pypi.org".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/python3.11"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!( - decision.allowed, - "Resolved symlink path should be allowed: {}", - decision.reason - ); - assert_eq!(decision.matched_policy.as_deref(), Some("python_policy")); - } - - #[test] - fn symlink_expanded_binary_still_allows_original_path() { - // Even with expansion, the original path must still work - let data = r#" -network_policies: - python_policy: - name: python_policy - endpoints: - - { host: pypi.org, port: 443 } - binaries: - - { path: /usr/bin/python3 } - - { path: /usr/bin/python3.11 } -"#; - let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); - - // Request with the original symlink path (unlikely at runtime, but must not break) - let input = NetworkInput { - host: "pypi.org".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/python3"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!( - decision.allowed, - "Original symlink path should still be allowed: {}", - decision.reason - ); - } - - #[test] - fn symlink_expanded_binary_does_not_weaken_security() { - // A binary NOT in the policy should still be denied, even if - // the expanded entries exist for other binaries. - let data = r#" -network_policies: - python_policy: - name: python_policy - endpoints: - - { host: pypi.org, port: 443 } - binaries: - - { path: /usr/bin/python3 } - - { path: /usr/bin/python3.11 } -"#; - let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); - - let input = NetworkInput { - host: "pypi.org".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!(!decision.allowed, "Unrelated binary should still be denied"); - } - - #[test] - fn symlink_expansion_works_with_ancestors() { - // Ancestor binary matching should also work with expanded paths - let data = r#" -network_policies: - python_policy: - name: python_policy - endpoints: - - { host: pypi.org, port: 443 } - binaries: - - { path: /usr/bin/python3 } - - { path: /usr/bin/python3.11 } -"#; - let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); - - // The exe is curl, but an ancestor is the resolved python3.11 - let input = NetworkInput { - host: "pypi.org".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![PathBuf::from("/usr/bin/python3.11")], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!( - decision.allowed, - "Resolved symlink path should match as ancestor: {}", - decision.reason - ); - } - - #[test] - fn symlink_expansion_via_proto_with_pid_zero() { - // from_proto_with_pid(proto, 0) should produce same results as from_proto(proto) - let proto = test_proto(); - let engine_default = OpaEngine::from_proto(&proto).expect("from_proto should succeed"); - let engine_pid0 = OpaEngine::from_proto_with_pid(&proto, 0) - .expect("from_proto_with_pid(0) should succeed"); - - let input = NetworkInput { - host: "api.anthropic.com".into(), - port: 443, - binary_path: PathBuf::from("/usr/local/bin/claude"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - - let decision_default = engine_default.evaluate_network(&input).unwrap(); - let decision_pid0 = engine_pid0.evaluate_network(&input).unwrap(); - - assert_eq!( - decision_default.allowed, decision_pid0.allowed, - "from_proto and from_proto_with_pid(0) should produce identical results" - ); - } - - #[test] - fn reload_from_proto_with_pid_zero_works() { - // reload_from_proto_with_pid(proto, 0) should function identically to reload_from_proto + fn reload_from_proto_works() { let proto = test_proto(); let engine = OpaEngine::from_proto(&proto).expect("from_proto should succeed"); @@ -4383,34 +3338,26 @@ network_policies: let input = NetworkInput { host: "api.anthropic.com".into(), port: 443, - binary_path: PathBuf::from("/usr/local/bin/claude"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); assert!(decision.allowed); - // Reload with same proto at pid=0 + // Reload with same proto engine - .reload_from_proto_with_pid(&proto, 0) - .expect("reload_from_proto_with_pid should succeed"); + .reload_from_proto(&proto) + .expect("reload_from_proto should succeed"); // Should still work let decision = engine.evaluate_network(&input).unwrap(); assert!( decision.allowed, - "reload_from_proto_with_pid(0) should preserve behavior" + "reload_from_proto should preserve behavior" ); } #[test] - fn hot_reload_preserves_symlink_expansion_behavior() { - // Simulates the hot-reload path: initial load at pid=0, then reload - // with a new proto that would have expanded binaries at a real PID. - // Since we can't mock /proc//root/ in unit tests, we test - // that reload_from_proto_with_pid at pid=0 still works correctly - // and that the engine is properly replaced. + fn hot_reload_picks_up_new_policies() { let proto = test_proto(); let engine = OpaEngine::from_proto(&proto).expect("initial load should succeed"); @@ -4418,9 +3365,6 @@ network_policies: let claude_input = NetworkInput { host: "api.anthropic.com".into(), port: 443, - binary_path: PathBuf::from("/usr/local/bin/claude"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; assert!(engine.evaluate_network(&claude_input).unwrap().allowed); @@ -4436,16 +3380,12 @@ network_policies: port: 443, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/python3".to_string(), - ..Default::default() - }], }, ); - // Hot-reload with pid=0 + // Hot-reload engine - .reload_from_proto_with_pid(&new_proto, 0) + .reload_from_proto(&new_proto) .expect("hot-reload should succeed"); // Old policy should still work @@ -4458,9 +3398,6 @@ network_policies: let python_input = NetworkInput { host: "pypi.org".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/python3"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; assert!( @@ -4471,23 +3408,20 @@ network_policies: #[test] fn hot_reload_replaces_engine_atomically() { - // Test that a failed reload preserves the last-known-good engine + // Test that a successful reload preserves the engine behavior let proto = test_proto(); let engine = OpaEngine::from_proto(&proto).expect("initial load should succeed"); let claude_input = NetworkInput { host: "api.anthropic.com".into(), port: 443, - binary_path: PathBuf::from("/usr/local/bin/claude"), - binary_sha256: "unused".into(), - ancestors: vec![], cmdline_paths: vec![], }; assert!(engine.evaluate_network(&claude_input).unwrap().allowed); // Reload with same proto — should succeed and preserve behavior engine - .reload_from_proto_with_pid(&proto, 0) + .reload_from_proto(&proto) .expect("reload should succeed"); assert!( @@ -4496,316 +3430,6 @@ network_policies: ); } - #[test] - fn deny_reason_includes_symlink_hint() { - // Verify the deny reason includes an actionable symlink hint - let engine = test_engine(); - let input = NetworkInput { - host: "api.anthropic.com".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/python3.11"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!(!decision.allowed); - assert!( - decision.reason.contains("SYMLINK HINT"), - "Deny reason should include prominent symlink hint, got: {}", - decision.reason - ); - assert!( - decision.reason.contains("readlink -f"), - "Deny reason should include actionable fix command, got: {}", - decision.reason - ); - } - - /// Check if symlink resolution through `/proc//root/` actually works. - /// Creates a real symlink in a tempdir and attempts to resolve it via - /// the procfs root path. This catches environments where the probe path - /// is readable but canonicalization/read_link fails (e.g., containers - /// with restricted ptrace scope, rootless containers). - #[cfg(target_os = "linux")] - fn procfs_root_accessible() -> bool { - use std::os::unix::fs::symlink; - let Ok(dir) = tempfile::tempdir() else { - return false; - }; - let target = dir.path().join("probe_target"); - let link = dir.path().join("probe_link"); - if std::fs::write(&target, b"probe").is_err() { - return false; - } - if symlink(&target, &link).is_err() { - return false; - } - let pid = std::process::id(); - let link_path = link.to_string_lossy().to_string(); - // Actually attempt the same resolution our production code uses - resolve_binary_in_container(&link_path, pid).is_some() - } - - #[cfg(target_os = "linux")] - #[test] - fn resolve_binary_with_real_symlink() { - use std::os::unix::fs::symlink; - - if !procfs_root_accessible() { - eprintln!("Skipping: /proc//root/ not accessible in this environment"); - return; - } - - // Create a real symlink in a temp directory and verify resolution - // works through /proc/self/root (which maps to / on the host) - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("python3.11"); - let link = dir.path().join("python3"); - - // Create the target file - std::fs::write(&target, b"#!/usr/bin/env python3\n").unwrap(); - // Create symlink - symlink(&target, &link).unwrap(); - - // Use our own PID — /proc//root/ points to / - let our_pid = std::process::id(); - let link_path = link.to_string_lossy().to_string(); - let result = resolve_binary_in_container(&link_path, our_pid); - - assert!( - result.is_some(), - "Should resolve symlink via /proc//root/" - ); - let resolved = result.unwrap(); - assert!( - resolved.ends_with("python3.11"), - "Resolved path should point to target: {resolved}" - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn resolve_binary_non_symlink_returns_none() { - use std::io::Write; - - if !procfs_root_accessible() { - eprintln!("Skipping: /proc//root/ not accessible in this environment"); - return; - } - - // A regular file should return None (no expansion needed) - let mut tmp = tempfile::NamedTempFile::new().unwrap(); - tmp.write_all(b"regular file").unwrap(); - tmp.flush().unwrap(); - - let our_pid = std::process::id(); - let path = tmp.path().to_string_lossy().to_string(); - let result = resolve_binary_in_container(&path, our_pid); - - assert!( - result.is_none(), - "Non-symlink file should return None, got: {result:?}" - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn resolve_binary_multi_level_symlink() { - use std::os::unix::fs::symlink; - - if !procfs_root_accessible() { - eprintln!("Skipping: /proc//root/ not accessible in this environment"); - return; - } - - // Test multi-level symlink resolution: python3 -> python3.11 -> cpython3.11 - let dir = tempfile::tempdir().unwrap(); - let final_target = dir.path().join("cpython3.11"); - let mid_link = dir.path().join("python3.11"); - let top_link = dir.path().join("python3"); - - std::fs::write(&final_target, b"final binary").unwrap(); - symlink(&final_target, &mid_link).unwrap(); - symlink(&mid_link, &top_link).unwrap(); - - let our_pid = std::process::id(); - let link_path = top_link.to_string_lossy().to_string(); - let result = resolve_binary_in_container(&link_path, our_pid); - - assert!(result.is_some(), "Should resolve multi-level symlink chain"); - let resolved = result.unwrap(); - assert!( - resolved.ends_with("cpython3.11"), - "Should resolve to final target: {resolved}" - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn from_proto_with_pid_expands_symlinks_in_container() { - use std::os::unix::fs::symlink; - - if !procfs_root_accessible() { - eprintln!("Skipping: /proc//root/ not accessible in this environment"); - return; - } - - // End-to-end test: create a symlink, build engine with our PID, - // verify the resolved path is allowed - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("node22"); - let link = dir.path().join("node"); - - std::fs::write(&target, b"node binary").unwrap(); - symlink(&target, &link).unwrap(); - - let link_path = link.to_string_lossy().to_string(); - let target_path = target.to_string_lossy().to_string(); - - let mut network_policies = std::collections::HashMap::new(); - network_policies.insert( - "test".to_string(), - NetworkPolicyRule { - name: "test".to_string(), - endpoints: vec![NetworkEndpoint { - host: "example.com".to_string(), - port: 443, - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: link_path, - ..Default::default() - }], - }, - ); - let proto = ProtoSandboxPolicy { - version: 1, - filesystem: Some(ProtoFs { - include_workdir: true, - read_only: vec![], - read_write: vec![], - }), - landlock: Some(openshell_core::proto::LandlockPolicy { - compatibility: "best_effort".to_string(), - }), - process: Some(ProtoProc { - run_as_user: "sandbox".to_string(), - run_as_group: "sandbox".to_string(), - }), - network_policies, - }; - - // Build engine with our PID (symlink resolution will work via /proc/self/root/) - let our_pid = std::process::id(); - let engine = OpaEngine::from_proto_with_pid(&proto, our_pid) - .expect("from_proto_with_pid should succeed"); - - // Request using the resolved target path should be allowed - let input = NetworkInput { - host: "example.com".into(), - port: 443, - binary_path: PathBuf::from(&target_path), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input).unwrap(); - assert!( - decision.allowed, - "Resolved symlink target should be allowed after expansion: {}", - decision.reason - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn reload_from_proto_with_pid_resolves_symlinks() { - use std::os::unix::fs::symlink; - - if !procfs_root_accessible() { - eprintln!("Skipping: /proc//root/ not accessible in this environment"); - return; - } - - // Test hot-reload path: initial engine at pid=0, then reload with - // real PID to trigger symlink resolution - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("python3.11"); - let link = dir.path().join("python3"); - - std::fs::write(&target, b"python binary").unwrap(); - symlink(&target, &link).unwrap(); - - let link_path = link.to_string_lossy().to_string(); - let target_path = target.to_string_lossy().to_string(); - - let mut network_policies = std::collections::HashMap::new(); - network_policies.insert( - "python".to_string(), - NetworkPolicyRule { - name: "python".to_string(), - endpoints: vec![NetworkEndpoint { - host: "pypi.org".to_string(), - port: 443, - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: link_path, - ..Default::default() - }], - }, - ); - let proto = ProtoSandboxPolicy { - version: 1, - filesystem: Some(ProtoFs { - include_workdir: true, - read_only: vec![], - read_write: vec![], - }), - landlock: Some(openshell_core::proto::LandlockPolicy { - compatibility: "best_effort".to_string(), - }), - process: Some(ProtoProc { - run_as_user: "sandbox".to_string(), - run_as_group: "sandbox".to_string(), - }), - network_policies, - }; - - // Initial load at pid=0 — no symlink expansion - let engine = OpaEngine::from_proto(&proto).expect("initial load"); - - // Request with resolved path should be DENIED (no expansion yet) - let input_resolved = NetworkInput { - host: "pypi.org".into(), - port: 443, - binary_path: PathBuf::from(&target_path), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - let decision = engine.evaluate_network(&input_resolved).unwrap(); - assert!( - !decision.allowed, - "Before reload with PID, resolved path should be denied" - ); - - // Hot-reload with real PID — symlinks resolved - let our_pid = std::process::id(); - engine - .reload_from_proto_with_pid(&proto, our_pid) - .expect("reload with PID"); - - // Now the resolved path should be ALLOWED - let decision = engine.evaluate_network(&input_resolved).unwrap(); - assert!( - decision.allowed, - "After reload with PID, resolved path should be allowed: {}", - decision.reason - ); - } - #[test] fn l7_head_allowed_where_get_is_allowed() { let engine = l7_engine(); @@ -4817,7 +3441,7 @@ network_policies: fn l7_head_denied_when_only_post_allowed() { let engine = OpaEngine::from_strings( TEST_POLICY, - "network_policies:\n p:\n name: p\n endpoints:\n - host: h.test\n port: 80\n protocol: rest\n enforcement: enforce\n rules:\n - allow: {method: POST, path: \"/\"}\n binaries:\n - {path: /usr/bin/curl}\n", + "network_policies:\n p:\n name: p\n endpoints:\n - host: h.test\n port: 80\n protocol: rest\n enforcement: enforce\n rules:\n - allow: {method: POST, path: \"/\"}\n", ) .unwrap(); let input = l7_input("h.test", 80, "HEAD", "/"); @@ -4836,7 +3460,7 @@ network_policies: // deny_rules use method_matches() too; a deny on GET must also block HEAD. let engine = OpaEngine::from_strings( TEST_POLICY, - "network_policies:\n p:\n name: p\n endpoints:\n - host: h.test\n port: 80\n protocol: rest\n enforcement: enforce\n access: full\n deny_rules:\n - method: GET\n path: \"/protected\"\n binaries:\n - {path: /usr/bin/curl}\n", + "network_policies:\n p:\n name: p\n endpoints:\n - host: h.test\n port: 80\n protocol: rest\n enforcement: enforce\n access: full\n deny_rules:\n - method: GET\n path: \"/protected\"\n", ) .unwrap(); let input = l7_input("h.test", 80, "HEAD", "/protected"); diff --git a/crates/openshell-sandbox/src/policy_local.rs b/crates/openshell-sandbox/src/policy_local.rs index 657fd760f..5751d2b89 100644 --- a/crates/openshell-sandbox/src/policy_local.rs +++ b/crates/openshell-sandbox/src/policy_local.rs @@ -5,7 +5,7 @@ use miette::{IntoDiagnostic, Result}; use openshell_core::proto::{ - L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, PolicyChunk, + L7Allow, L7DenyRule, L7Rule, NetworkEndpoint, NetworkPolicyRule, PolicyChunk, SandboxPolicy as ProtoSandboxPolicy, }; use openshell_ocsf::{ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ocsf_emit}; @@ -650,12 +650,7 @@ fn summarize_chunk_for_audit(chunk: &PolicyChunk) -> String { .and_then(|r| r.allow.as_ref()) .map(|a| format!(" {} {}", a.method, a.path)) .unwrap_or_default(); - let binary = if chunk.binary.is_empty() { - String::new() - } else { - format!(" by {}", chunk.binary) - }; - format!("on {endpoint}{l7}{binary}") + format!("on {endpoint}{l7}") } /// `GET /v1/proposals/{chunk_id}` — immediate state. One gateway call, no loop. @@ -781,7 +776,6 @@ fn chunk_state_payload( "chunk_id": chunk.id, "status": chunk.status, "rule_name": chunk.rule_name, - "binary": chunk.binary, "rejection_reason": chunk.rejection_reason, "validation_result": chunk.validation_result, }); @@ -966,12 +960,6 @@ fn policy_chunk_from_add_rule( rule.name.clone_from(&rule_name); } - let binary = rule - .binaries - .first() - .map(|binary| binary.path.clone()) - .unwrap_or_default(); - Ok(PolicyChunk { id: String::new(), status: "pending".to_string(), @@ -988,7 +976,6 @@ fn policy_chunk_from_add_rule( hit_count: 1, first_seen_ms: 0, last_seen_ms: 0, - binary, validation_result: String::new(), rejection_reason: String::new(), }) @@ -1006,19 +993,10 @@ fn network_rule_from_json( .into_iter() .map(network_endpoint_from_json) .collect::, _>>()?; - let binaries = rule - .binaries - .into_iter() - .map(|binary| NetworkBinary { - path: binary.path, - ..Default::default() - }) - .collect(); Ok(NetworkPolicyRule { name: rule.name.unwrap_or_default(), endpoints, - binaries, }) } @@ -1227,8 +1205,6 @@ struct NetworkPolicyRuleJson { name: Option, #[serde(default)] endpoints: Vec, - #[serde(default)] - binaries: Vec, } #[derive(Debug, Deserialize)] @@ -1256,11 +1232,6 @@ struct NetworkEndpointJson { allow_encoded_slash: bool, } -#[derive(Debug, Deserialize)] -struct NetworkBinaryJson { - path: String, -} - #[derive(Debug, Deserialize)] struct L7RuleJson { allow: L7AllowJson, @@ -1315,11 +1286,6 @@ mod tests { } ] } - ], - "binaries": [ - { - "path": "/usr/bin/gh" - } ] } } @@ -1332,7 +1298,6 @@ mod tests { assert_eq!(chunks.len(), 1); assert_eq!(chunks[0].rule_name, "github_api_repo_create"); assert_eq!(chunks[0].rationale, "Allow gh to create one repo."); - assert_eq!(chunks[0].binary, "/usr/bin/gh"); let rule = chunks[0].proposed_rule.as_ref().unwrap(); assert_eq!(rule.name, "github_api_repo_create"); assert_eq!(rule.endpoints[0].host, "api.github.com"); @@ -1656,7 +1621,6 @@ mod tests { id: "chunk-x".to_string(), status: "rejected".to_string(), rule_name: "allow_example".to_string(), - binary: "/usr/bin/curl".to_string(), rejection_reason: "scope too broad".to_string(), validation_result: "no exfil paths".to_string(), ..Default::default() @@ -1683,7 +1647,6 @@ mod tests { id: "chunk-y".to_string(), status: "approved".to_string(), rule_name: "allow_github".to_string(), - binary: "/usr/bin/curl".to_string(), ..Default::default() }; let reloaded = chunk_state_payload(&chunk, false, true); @@ -1750,11 +1713,10 @@ mod tests { } #[test] - fn summarize_chunk_for_audit_includes_endpoint_l7_path_and_binary() { + fn summarize_chunk_for_audit_includes_endpoint_l7_path() { let chunk = PolicyChunk { id: "ignored".to_string(), rule_name: "github_write".to_string(), - binary: "/usr/bin/curl".to_string(), proposed_rule: Some(NetworkPolicyRule { name: "github_write".to_string(), endpoints: vec![NetworkEndpoint { @@ -1769,17 +1731,12 @@ mod tests { }], ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }), ..Default::default() }; let summary = summarize_chunk_for_audit(&chunk); assert!(summary.contains("api.github.com:443")); assert!(summary.contains("PUT /repos/foo/bar/contents/x.md")); - assert!(summary.contains("/usr/bin/curl")); } // Helpers — synthetic proposed rule + policy with that rule already @@ -1793,10 +1750,6 @@ mod tests { ports: vec![443], ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], } } @@ -1856,10 +1809,6 @@ mod tests { ports: vec![443], ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], })); }) }; diff --git a/crates/openshell-sandbox/src/procfs.rs b/crates/openshell-sandbox/src/procfs.rs index 3ac8dbe14..5933b7ad9 100644 --- a/crates/openshell-sandbox/src/procfs.rs +++ b/crates/openshell-sandbox/src/procfs.rs @@ -3,16 +3,23 @@ //! Linux `/proc` filesystem reading for process identity. //! -//! Provides functions to resolve binary paths and compute file hashes -//! for process-identity binding in the OPA proxy policy engine. +//! Provides functions to resolve binary paths for observability logging in +//! the OPA proxy policy engine. +//! +//! NOTE: Binary allowlisting using `/proc//exe` (both SHA256 TOFU and +//! path matching) was removed because it is bypassable via `LD_PRELOAD`. An +//! attacker writes `evil.so` to `/tmp/evil.so` (writable by default policy) +//! and runs `LD_PRELOAD=/tmp/evil.so /usr/bin/curl https://example.com`. The +//! proxy sees `/usr/bin/curl` as the binary, the path matches — but +//! `evil.so`'s constructor already ran before `main()` and can make arbitrary +//! syscalls. Binary path data is kept for observability only; it is not used +//! for security gating. use miette::Result; #[cfg(target_os = "linux")] use std::collections::HashSet; -use std::path::Path; #[cfg(target_os = "linux")] use std::path::PathBuf; -use tracing::debug; /// Where a socket owner was discovered while scanning `/proc`. #[cfg(target_os = "linux")] @@ -116,14 +123,6 @@ pub fn binary_path(pid: i32) -> Result { /// Resolve the binary path of the TCP peer inside a sandbox network namespace. /// /// Uses `/proc//net/tcp` to find the socket inode for the given -/// ephemeral port, then scans the entrypoint process tree to find which PID owns -/// that socket, and finally reads `/proc//exe` to get the binary path. -#[cfg(target_os = "linux")] -pub fn resolve_tcp_peer_binary(entrypoint_pid: u32, peer_port: u16) -> Result { - let owner = resolve_single_tcp_peer_owner(entrypoint_pid, peer_port)?; - binary_path(owner.pid.cast_signed()) -} - /// Resolve all process owners for the TCP peer inside a sandbox network namespace. /// /// Multiple processes can legitimately hold the same socket inode after `fork()` @@ -139,37 +138,6 @@ pub fn resolve_tcp_peer_socket_owners( Ok(TcpPeerSocketOwners { inode, owners }) } -/// Resolve exactly one owner for the TCP peer, failing closed on ambiguity. -#[cfg(target_os = "linux")] -fn resolve_single_tcp_peer_owner(entrypoint_pid: u32, peer_port: u16) -> Result { - let socket_owners = resolve_tcp_peer_socket_owners(entrypoint_pid, peer_port)?; - match socket_owners.owners.as_slice() { - [owner] => Ok(owner.clone()), - owners => { - let mut pids: Vec = owners.iter().map(|owner| owner.pid).collect(); - pids.sort_unstable(); - Err(miette::miette!( - "Ambiguous socket ownership for inode {}: PIDs [{}] all hold the same socket", - socket_owners.inode, - pids.iter() - .map(u32::to_string) - .collect::>() - .join(", ") - )) - } - } -} - -/// Like `resolve_tcp_peer_binary`, but also returns the PID that owns the socket. -/// -/// Needed for the ancestor walk: we must know the PID to walk `/proc//status` `PPid` chain. -#[cfg(target_os = "linux")] -pub fn resolve_tcp_peer_identity(entrypoint_pid: u32, peer_port: u16) -> Result<(PathBuf, u32)> { - let owner = resolve_single_tcp_peer_owner(entrypoint_pid, peer_port)?; - let path = binary_path(owner.pid.cast_signed())?; - Ok((path, owner.pid)) -} - /// Read the `PPid` (parent PID) from `/proc//status`. #[cfg(target_os = "linux")] pub fn read_ppid(pid: u32) -> Option { @@ -466,47 +434,10 @@ fn collect_descendant_pids_with_depth(root_pid: u32) -> Vec { pids } -/// Compute the SHA256 hash of a file, returned as a hex-encoded string. -/// -/// Used for binary integrity verification in the trust-on-first-use (TOFU) -/// model: the proxy hashes a binary on first network request and caches the -/// result. Subsequent requests from the same binary path must produce the -/// same hash, or the request is denied. -pub fn file_sha256(path: &Path) -> Result { - use sha2::{Digest, Sha256}; - use std::io::Read; - - let start = std::time::Instant::now(); - let mut file = std::fs::File::open(path) - .map_err(|e| miette::miette!("Failed to open {}: {e}", path.display()))?; - let mut hasher = Sha256::new(); - let mut buf = vec![0u8; 65536].into_boxed_slice(); - let mut total_read = 0u64; - loop { - let n = file - .read(&mut buf) - .map_err(|e| miette::miette!("Failed to read {}: {e}", path.display()))?; - if n == 0 { - break; - } - total_read += n as u64; - hasher.update(&buf[..n]); - } - - let hash = hasher.finalize(); - debug!( - " file_sha256: {}ms size={} path={}", - start.elapsed().as_millis(), - total_read, - path.display() - ); - Ok(hex::encode(hash)) -} - #[cfg(test)] mod tests { use super::*; - use std::io::Write; + use std::path::Path; /// Block until `/proc//exe` points at `target`. `Command::spawn` returns /// once the child is scheduled, not once it has completed `exec()`; on @@ -554,35 +485,6 @@ mod tests { } } - #[test] - fn file_sha256_computes_correct_hash() { - let mut tmp = tempfile::NamedTempFile::new().unwrap(); - tmp.write_all(b"hello world").unwrap(); - tmp.flush().unwrap(); - - let hash = file_sha256(tmp.path()).unwrap(); - // SHA256 of "hello world" - assert_eq!( - hash, - "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" - ); - } - - #[test] - fn file_sha256_different_content_different_hash() { - let mut tmp1 = tempfile::NamedTempFile::new().unwrap(); - tmp1.write_all(b"content a").unwrap(); - tmp1.flush().unwrap(); - - let mut tmp2 = tempfile::NamedTempFile::new().unwrap(); - tmp2.write_all(b"content b").unwrap(); - tmp2.flush().unwrap(); - - let hash1 = file_sha256(tmp1.path()).unwrap(); - let hash2 = file_sha256(tmp2.path()).unwrap(); - assert_ne!(hash1, hash2); - } - #[cfg(target_os = "linux")] #[test] fn binary_path_reads_current_process() { diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index 81ed0322f..39579b399 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! HTTP CONNECT proxy with OPA policy evaluation and process-identity binding. +//! HTTP CONNECT proxy with OPA policy evaluation. use crate::denial_aggregator::DenialEvent; -use crate::identity::BinaryIdentityCache; use crate::l7::tls::ProxyTlsState; use crate::opa::{NetworkAction, OpaEngine, PolicyGenerationGuard}; use crate::policy::ProxyPolicy; @@ -74,13 +73,7 @@ struct ConnectDecision { action: NetworkAction, /// Policy generation used for the L4 network decision. generation: u64, - /// Resolved binary path. - binary: Option, - /// PID owning the socket. - binary_pid: Option, - /// Ancestor binary paths from process tree walk. - ancestors: Vec, - /// Cmdline-derived absolute paths (for script detection). + /// Cmdline-derived absolute paths (for script detection / observability). cmdline_paths: Vec, } @@ -179,7 +172,6 @@ impl ProxyHandle { policy: &ProxyPolicy, bind_addr: Option, opa_engine: Arc, - identity_cache: Arc, entrypoint_pid: Arc, tls_state: Option>, inference_ctx: Option>, @@ -231,7 +223,6 @@ impl ProxyHandle { match listener.accept().await { Ok((stream, _addr)) => { let opa = opa_engine.clone(); - let cache = identity_cache.clone(); let spid = entrypoint_pid.clone(); let tls = tls_state.clone(); let inf = inference_ctx.clone(); @@ -245,7 +236,6 @@ impl ProxyHandle { if let Err(err) = handle_tcp_connection( stream, opa, - cache, spid, tls, inf, @@ -304,8 +294,6 @@ fn emit_denial( tx: &Option>, host: &str, port: u16, - binary: &str, - decision: &ConnectDecision, reason: &str, stage: &str, ) { @@ -313,12 +301,6 @@ fn emit_denial( let _ = tx.send(DenialEvent { host: host.to_string(), port, - binary: binary.to_string(), - ancestors: decision - .ancestors - .iter() - .map(|p| p.display().to_string()) - .collect(), deny_reason: reason.to_string(), denial_stage: stage.to_string(), l7_method: None, @@ -333,8 +315,6 @@ fn emit_denial_simple( tx: Option<&mpsc::UnboundedSender>, host: &str, port: u16, - binary: &str, - decision: &ConnectDecision, reason: &str, stage: &str, ) { @@ -342,12 +322,6 @@ fn emit_denial_simple( let _ = tx.send(DenialEvent { host: host.to_string(), port, - binary: binary.to_string(), - ancestors: decision - .ancestors - .iter() - .map(|p| p.display().to_string()) - .collect(), deny_reason: reason.to_string(), denial_stage: stage.to_string(), l7_method: None, @@ -363,7 +337,6 @@ fn emit_denial_simple( async fn handle_tcp_connection( mut client: TcpStream, opa_engine: Arc, - identity_cache: Arc, entrypoint_pid: Arc, tls_state: Option>, inference_ctx: Option>, @@ -411,7 +384,6 @@ async fn handle_tcp_connection( used, &mut client, opa_engine, - identity_cache, entrypoint_pid, policy_local_ctx, trusted_host_gateway, @@ -455,20 +427,12 @@ async fn handle_tcp_connection( // Evaluate OPA policy with process-identity binding. // Wrapped in spawn_blocking because identity resolution does heavy sync I/O: - // /proc scanning + SHA256 hashing of binaries (e.g. node at 124MB). + // /proc scanning for process-identity binding via /proc/net/tcp. let opa_clone = opa_engine.clone(); - let cache_clone = identity_cache.clone(); let pid_clone = entrypoint_pid.clone(); let host_clone = host_lc.clone(); let decision = tokio::task::spawn_blocking(move || { - evaluate_opa_tcp( - peer_addr, - &opa_clone, - &cache_clone, - &pid_clone, - &host_clone, - port, - ) + evaluate_opa_tcp(peer_addr, &opa_clone, &pid_clone, &host_clone, port) }) .await .map_err(|e| miette::miette!("identity resolution task panicked: {e}"))?; @@ -480,23 +444,6 @@ async fn handle_tcp_connection( }; // Build log context fields (shared by deny log below and deferred allow log after L7 check) - let binary_str = decision - .binary - .as_ref() - .map_or_else(|| "-".to_string(), |p| p.display().to_string()); - let pid_str = decision - .binary_pid - .map_or_else(|| "-".to_string(), |p| p.to_string()); - let ancestors_str = if decision.ancestors.is_empty() { - "-".to_string() - } else { - decision - .ancestors - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(" -> ") - }; let cmdline_str = if decision.cmdline_paths.is_empty() { "-".to_string() } else { @@ -521,24 +468,13 @@ async fn handle_tcp_connection( .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) + .actor_process(Process::new("-", 0).with_cmd_line(&cmdline_str)) .firewall_rule("-", "opa") .message(format!("CONNECT denied {host_lc}:{port}")) .status_detail(&deny_reason) .build(); ocsf_emit!(event); - emit_denial( - &denial_tx, - &host_lc, - port, - &binary_str, - &decision, - &deny_reason, - "connect", - ); + emit_denial(&denial_tx, &host_lc, port, &deny_reason, "connect"); respond( &mut client, &build_json_error_response( @@ -591,10 +527,7 @@ async fn handle_tcp_connection( .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) + .actor_process(Process::new("-", 0).with_cmd_line(&cmdline_str)) .firewall_rule("-", "ssrf") .message(format!( "CONNECT blocked: trusted-gateway check failed for {host_lc}:{port}" @@ -603,15 +536,7 @@ async fn handle_tcp_connection( .build(); ocsf_emit!(event); } - emit_denial( - &denial_tx, - &host_lc, - port, - &binary_str, - &decision, - &reason, - "ssrf", - ); + emit_denial(&denial_tx, &host_lc, port, &reason, "ssrf"); respond( &mut client, &build_json_error_response( @@ -646,10 +571,7 @@ async fn handle_tcp_connection( .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) + .actor_process(Process::new("-", 0).with_cmd_line(&cmdline_str)) .firewall_rule("-", "ssrf") .message(format!( "CONNECT blocked: allowed_ips check failed for {host_lc}:{port}" @@ -658,15 +580,7 @@ async fn handle_tcp_connection( .build(); ocsf_emit!(event); } - emit_denial( - &denial_tx, - &host_lc, - port, - &binary_str, - &decision, - &reason, - "ssrf", - ); + emit_denial(&denial_tx, &host_lc, port, &reason, "ssrf"); respond( &mut client, &build_json_error_response( @@ -693,10 +607,7 @@ async fn handle_tcp_connection( .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) + .actor_process(Process::new("-", 0).with_cmd_line(&cmdline_str)) .firewall_rule("-", "ssrf") .message(format!( "CONNECT blocked: invalid allowed_ips in policy for {host_lc}:{port}" @@ -705,15 +616,7 @@ async fn handle_tcp_connection( .build(); ocsf_emit!(event); } - emit_denial( - &denial_tx, - &host_lc, - port, - &binary_str, - &decision, - &reason, - "ssrf", - ); + emit_denial(&denial_tx, &host_lc, port, &reason, "ssrf"); respond( &mut client, &build_json_error_response( @@ -743,10 +646,7 @@ async fn handle_tcp_connection( .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) + .actor_process(Process::new("-", 0).with_cmd_line(&cmdline_str)) .firewall_rule("-", "ssrf") .message(format!( "CONNECT blocked: internal address {host_lc}:{port}" @@ -755,15 +655,7 @@ async fn handle_tcp_connection( .build(); ocsf_emit!(event); } - emit_denial( - &denial_tx, - &host_lc, - port, - &binary_str, - &decision, - &reason, - "ssrf", - ); + emit_denial(&denial_tx, &host_lc, port, &reason, "ssrf"); respond( &mut client, &build_json_error_response( @@ -809,10 +701,7 @@ async fn handle_tcp_connection( .status(StatusId::Success) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) + .actor_process(Process::new("-", 0).with_cmd_line(&cmdline_str)) .firewall_rule(policy_str, "opa") .message(format!("{connect_msg} allowed {host_lc}:{port}")) .build(); @@ -829,16 +718,6 @@ async fn handle_tcp_connection( host: host_lc.clone(), port, policy_name: matched_policy.clone().unwrap_or_default(), - binary_path: decision - .binary - .as_ref() - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_default(), - ancestors: decision - .ancestors - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(), cmdline_paths: decision .cmdline_paths .iter() @@ -1067,191 +946,31 @@ async fn handle_tcp_connection( Ok(()) } -/// Resolved process identity for a TCP peer: binary path, PID, ancestor chain, -/// cmdline paths, and the TOFU-verified binary hash. -/// -/// Produced by [`resolve_process_identity`]; consumed by [`evaluate_opa_tcp`] -/// and by the identity-chain regression tests. -#[cfg(target_os = "linux")] -struct ResolvedIdentity { - bin_path: PathBuf, - binary_pid: u32, - ancestors: Vec, - cmdline_paths: Vec, - bin_hash: String, -} - -#[cfg(target_os = "linux")] -#[derive(Debug, Eq, PartialEq)] -struct PolicyIdentityKey { - bin_path: PathBuf, - ancestors: Vec, - cmdline_paths: Vec, - bin_hash: String, -} - -#[cfg(target_os = "linux")] -impl ResolvedIdentity { - fn policy_key(&self) -> PolicyIdentityKey { - PolicyIdentityKey { - bin_path: self.bin_path.clone(), - ancestors: self.ancestors.clone(), - cmdline_paths: self.cmdline_paths.clone(), - bin_hash: self.bin_hash.clone(), - } - } -} - -/// Error from [`resolve_process_identity`]. Carries the deny reason and -/// whatever partial identity data was resolved before the failure so the -/// caller can include it in the [`ConnectDecision`] and OCSF event. -#[cfg(target_os = "linux")] -struct IdentityError { - reason: String, - binary: Option, - binary_pid: Option, - ancestors: Vec, -} - -#[cfg(target_os = "linux")] -fn resolve_owner_identity( - owner_pid: u32, - entrypoint_pid: u32, - identity_cache: &BinaryIdentityCache, -) -> std::result::Result { - let bin_path = - crate::procfs::binary_path(owner_pid.cast_signed()).map_err(|e| IdentityError { - reason: format!("failed to resolve peer binary for PID {owner_pid}: {e}"), - binary: None, - binary_pid: Some(owner_pid), - ancestors: vec![], - })?; - - let bin_hash = identity_cache - .verify_or_cache(&bin_path) - .map_err(|e| IdentityError { - reason: format!("binary integrity check failed: {e}"), - binary: Some(bin_path.clone()), - binary_pid: Some(owner_pid), - ancestors: vec![], - })?; - - let ancestors = crate::procfs::collect_ancestor_binaries(owner_pid, entrypoint_pid); - - for ancestor in &ancestors { - identity_cache - .verify_or_cache(ancestor) - .map_err(|e| IdentityError { - reason: format!( - "ancestor integrity check failed for {}: {e}", - ancestor.display() - ), - binary: Some(bin_path.clone()), - binary_pid: Some(owner_pid), - ancestors: ancestors.clone(), - })?; - } - - let mut exclude = ancestors.clone(); - exclude.push(bin_path.clone()); - let cmdline_paths = crate::procfs::collect_cmdline_paths(owner_pid, entrypoint_pid, &exclude); - - Ok(ResolvedIdentity { - bin_path, - binary_pid: owner_pid, - ancestors, - cmdline_paths, - bin_hash, - }) -} - -/// Resolve the identity of the process owning a TCP peer connection. +/// Collect cmdline-derived paths for the process owning a TCP peer connection. /// /// Walks `/proc//net/tcp` to find the socket inode, locates -/// every owning PID, reads `/proc//exe`, TOFU-verifies each binary hash, -/// walks each ancestor chain verifying every ancestor, and collects -/// cmdline-derived absolute paths for script detection. -/// -/// This is the identity-resolution block of [`evaluate_opa_tcp`] extracted -/// into a standalone helper so it can be exercised by Linux-only regression -/// tests without a full OPA engine. The key invariant under test is that on -/// a hot-swap of the peer binary, the failure mode is -/// `"Binary integrity violation"` (from the identity cache) rather than -/// `"Failed to stat ... (deleted)"` (from the kernel-tainted path). +/// the owning PID, and collects cmdline-derived absolute paths for script +/// detection / observability. Returns an empty vec on any failure. #[cfg(target_os = "linux")] -fn resolve_process_identity( - entrypoint_pid: u32, - peer_port: u16, - identity_cache: &BinaryIdentityCache, -) -> std::result::Result { - let socket_owners = crate::procfs::resolve_tcp_peer_socket_owners(entrypoint_pid, peer_port) - .map_err(|e| IdentityError { - reason: format!("failed to resolve peer binary: {e}"), - binary: None, - binary_pid: None, - ancestors: vec![], - })?; - - let mut identities = Vec::with_capacity(socket_owners.owners.len()); - for owner in &socket_owners.owners { - identities.push(resolve_owner_identity( - owner.pid, - entrypoint_pid, - identity_cache, - )?); - } - - let Some(first_identity) = identities.first() else { - return Err(IdentityError { - reason: format!( - "failed to resolve peer binary: no process found owning socket inode {}", - socket_owners.inode - ), - binary: None, - binary_pid: None, - ancestors: vec![], - }); +fn collect_peer_cmdline_paths(entrypoint_pid: u32, peer_port: u16) -> Vec { + let Ok(socket_owners) = + crate::procfs::resolve_tcp_peer_socket_owners(entrypoint_pid, peer_port) + else { + return vec![]; }; - let first_key = first_identity.policy_key(); - if identities - .iter() - .skip(1) - .any(|identity| identity.policy_key() != first_key) - { - let mut pids: Vec = identities - .iter() - .map(|identity| identity.binary_pid) - .collect(); - pids.sort_unstable(); - return Err(IdentityError { - reason: format!( - "ambiguous shared socket ownership: inode {} is held by PIDs [{}] with different policy identities", - socket_owners.inode, - pids.iter() - .map(u32::to_string) - .collect::>() - .join(", ") - ), - binary: None, - binary_pid: None, - ancestors: vec![], - }); - } + let Some(owner) = socket_owners.owners.first() else { + return vec![]; + }; - let mut identity = identities.swap_remove(0); - if let Some(lowest_pid) = socket_owners.owners.iter().map(|owner| owner.pid).min() { - identity.binary_pid = lowest_pid; - } - Ok(identity) + crate::procfs::collect_cmdline_paths(owner.pid, entrypoint_pid, &[]) } -/// Evaluate OPA policy for a TCP connection with identity binding via /proc/net/tcp. +/// Evaluate OPA policy for a TCP connection. #[cfg(target_os = "linux")] fn evaluate_opa_tcp( peer_addr: SocketAddr, engine: &OpaEngine, - identity_cache: &BinaryIdentityCache, entrypoint_pid: &AtomicU32, host: &str, port: u16, @@ -1259,63 +978,25 @@ fn evaluate_opa_tcp( use crate::opa::NetworkInput; use std::sync::atomic::Ordering; - let deny = |reason: String, - binary: Option, - binary_pid: Option, - ancestors: Vec, - cmdline_paths: Vec| - -> ConnectDecision { - ConnectDecision { - action: NetworkAction::Deny { reason }, - generation: engine.current_generation(), - binary, - binary_pid, - ancestors, - cmdline_paths, - } - }; - let pid = entrypoint_pid.load(Ordering::Acquire); if pid == 0 { - return deny( - "entrypoint process not yet spawned".into(), - None, - None, - vec![], - vec![], - ); + return ConnectDecision { + action: NetworkAction::Deny { + reason: "entrypoint process not yet spawned".into(), + }, + generation: engine.current_generation(), + cmdline_paths: vec![], + }; } let total_start = std::time::Instant::now(); let peer_port = peer_addr.port(); - let identity = match resolve_process_identity(pid, peer_port, identity_cache) { - Ok(id) => id, - Err(err) => { - return deny( - err.reason, - err.binary, - err.binary_pid, - err.ancestors, - vec![], - ); - } - }; - - let ResolvedIdentity { - bin_path, - binary_pid, - ancestors, - cmdline_paths, - bin_hash, - } = identity; + let cmdline_paths = collect_peer_cmdline_paths(pid, peer_port); let input = NetworkInput { host: host.to_string(), port, - binary_path: bin_path.clone(), - binary_sha256: bin_hash, - ancestors: ancestors.clone(), cmdline_paths: cmdline_paths.clone(), }; @@ -1323,18 +1004,15 @@ fn evaluate_opa_tcp( Ok((action, generation)) => ConnectDecision { action, generation, - binary: Some(bin_path), - binary_pid: Some(binary_pid), - ancestors, cmdline_paths, }, - Err(e) => deny( - format!("policy evaluation error: {e}"), - Some(bin_path), - Some(binary_pid), - ancestors, + Err(e) => ConnectDecision { + action: NetworkAction::Deny { + reason: format!("policy evaluation error: {e}"), + }, + generation: engine.current_generation(), cmdline_paths, - ), + }, }; debug!( "evaluate_opa_tcp TOTAL: {}ms host={host} port={port}", @@ -1343,24 +1021,20 @@ fn evaluate_opa_tcp( result } -/// Non-Linux stub: OPA identity binding requires /proc. +/// Non-Linux stub: OPA evaluation requires /proc for peer identification. #[cfg(not(target_os = "linux"))] fn evaluate_opa_tcp( _peer_addr: SocketAddr, engine: &OpaEngine, - _identity_cache: &BinaryIdentityCache, _entrypoint_pid: &AtomicU32, _host: &str, _port: u16, ) -> ConnectDecision { ConnectDecision { action: NetworkAction::Deny { - reason: "identity binding unavailable on this platform".into(), + reason: "proxy evaluation unavailable on this platform".into(), }, generation: engine.current_generation(), - binary: None, - binary_pid: None, - ancestors: vec![], cmdline_paths: vec![], } } @@ -1809,9 +1483,6 @@ fn query_l7_route_snapshot( let input = crate::opa::NetworkInput { host: host.to_string(), port, - binary_path: decision.binary.clone().unwrap_or_default(), - binary_sha256: String::new(), - ancestors: decision.ancestors.clone(), cmdline_paths: decision.cmdline_paths.clone(), }; @@ -1868,9 +1539,6 @@ fn query_tls_mode( let input = crate::opa::NetworkInput { host: host.to_string(), port, - binary_path: decision.binary.clone().unwrap_or_default(), - binary_sha256: String::new(), - ancestors: decision.ancestors.clone(), cmdline_paths: decision.cmdline_paths.clone(), }; @@ -2364,9 +2032,6 @@ fn query_allowed_ips( let input = crate::opa::NetworkInput { host: host.to_string(), port, - binary_path: decision.binary.clone().unwrap_or_default(), - binary_sha256: String::new(), - ancestors: decision.ancestors.clone(), cmdline_paths: decision.cmdline_paths.clone(), }; @@ -2693,7 +2358,6 @@ async fn handle_forward_proxy( used: usize, client: &mut TcpStream, opa_engine: Arc, - identity_cache: Arc, entrypoint_pid: Arc, policy_local_ctx: Option>, trusted_host_gateway: Arc>, @@ -2781,40 +2445,15 @@ async fn handle_forward_proxy( let _local_addr = client.local_addr().into_diagnostic()?; let opa_clone = opa_engine.clone(); - let cache_clone = identity_cache.clone(); let pid_clone = entrypoint_pid.clone(); let host_clone = host_lc.clone(); let decision = tokio::task::spawn_blocking(move || { - evaluate_opa_tcp( - peer_addr, - &opa_clone, - &cache_clone, - &pid_clone, - &host_clone, - port, - ) + evaluate_opa_tcp(peer_addr, &opa_clone, &pid_clone, &host_clone, port) }) .await .map_err(|e| miette::miette!("identity resolution task panicked: {e}"))?; // Build log context - let binary_str = decision - .binary - .as_ref() - .map_or_else(|| "-".to_string(), |p| p.display().to_string()); - let pid_str = decision - .binary_pid - .map_or_else(|| "-".to_string(), |p| p.to_string()); - let ancestors_str = if decision.ancestors.is_empty() { - "-".to_string() - } else { - decision - .ancestors - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(" -> ") - }; let cmdline_str = if decision.cmdline_paths.is_empty() { "-".to_string() } else { @@ -2843,24 +2482,13 @@ async fn handle_forward_proxy( )) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) + .actor_process(Process::new("-", 0).with_cmd_line(&cmdline_str)) .firewall_rule("-", "opa") .message(format!("FORWARD denied {method} {host_lc}:{port}{path}")) .build(); ocsf_emit!(event); } - emit_denial_simple( - denial_tx, - &host_lc, - port, - &binary_str, - &decision, - reason, - "forward", - ); + emit_denial_simple(denial_tx, &host_lc, port, reason, "forward"); respond( client, &build_json_error_response( @@ -2907,16 +2535,6 @@ async fn handle_forward_proxy( host: host_lc.clone(), port, policy_name: matched_policy.clone().unwrap_or_default(), - binary_path: decision - .binary - .as_ref() - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_default(), - ancestors: decision - .ancestors - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(), cmdline_paths: decision .cmdline_paths .iter() @@ -3171,7 +2789,7 @@ async fn handle_forward_proxy( .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) + Process::new("-", 0) .with_cmd_line(&cmdline_str), ) .firewall_rule(policy_str, engine_type) @@ -3186,15 +2804,7 @@ async fn handle_forward_proxy( || (!allowed && l7_config.config.enforcement == crate::l7::EnforcementMode::Enforce); if effectively_denied { - emit_denial_simple( - denial_tx, - &host_lc, - port, - &binary_str, - &decision, - &reason, - "forward-l7-deny", - ); + emit_denial_simple(denial_tx, &host_lc, port, &reason, "forward-l7-deny"); respond( client, &build_json_error_response( @@ -3246,10 +2856,7 @@ async fn handle_forward_proxy( )) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) + .actor_process(Process::new("-", 0).with_cmd_line(&cmdline_str)) .firewall_rule(policy_str, "ssrf") .message(format!( "FORWARD blocked: trusted-gateway check failed for {host_lc}:{port}" @@ -3258,15 +2865,7 @@ async fn handle_forward_proxy( .build(); ocsf_emit!(event); } - emit_denial_simple( - denial_tx, - &host_lc, - port, - &binary_str, - &decision, - &reason, - "ssrf", - ); + emit_denial_simple(denial_tx, &host_lc, port, &reason, "ssrf"); respond( client, &build_json_error_response( @@ -3302,10 +2901,7 @@ async fn handle_forward_proxy( )) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) + .actor_process(Process::new("-", 0).with_cmd_line(&cmdline_str)) .firewall_rule(policy_str, "ssrf") .message(format!( "FORWARD blocked: allowed_ips check failed for {host_lc}:{port}" @@ -3314,15 +2910,7 @@ async fn handle_forward_proxy( .build(); ocsf_emit!(event); } - emit_denial_simple( - denial_tx, - &host_lc, - port, - &binary_str, - &decision, - &reason, - "ssrf", - ); + emit_denial_simple(denial_tx, &host_lc, port, &reason, "ssrf"); respond( client, &build_json_error_response( @@ -3353,10 +2941,7 @@ async fn handle_forward_proxy( )) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) + .actor_process(Process::new("-", 0).with_cmd_line(&cmdline_str)) .firewall_rule(policy_str, "ssrf") .message(format!( "FORWARD blocked: invalid allowed_ips in policy for {host_lc}:{port}" @@ -3365,15 +2950,7 @@ async fn handle_forward_proxy( .build(); ocsf_emit!(event); } - emit_denial_simple( - denial_tx, - &host_lc, - port, - &binary_str, - &decision, - &reason, - "ssrf", - ); + emit_denial_simple(denial_tx, &host_lc, port, &reason, "ssrf"); respond( client, &build_json_error_response( @@ -3407,10 +2984,7 @@ async fn handle_forward_proxy( )) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) + .actor_process(Process::new("-", 0).with_cmd_line(&cmdline_str)) .firewall_rule(policy_str, "ssrf") .message(format!( "FORWARD blocked: internal IP without allowed_ips for {host_lc}:{port}" @@ -3419,15 +2993,7 @@ async fn handle_forward_proxy( .build(); ocsf_emit!(event); } - emit_denial_simple( - denial_tx, - &host_lc, - port, - &binary_str, - &decision, - &reason, - "ssrf", - ); + emit_denial_simple(denial_tx, &host_lc, port, &reason, "ssrf"); respond( client, &build_json_error_response( @@ -3472,10 +3038,7 @@ async fn handle_forward_proxy( )) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) + .actor_process(Process::new("-", 0).with_cmd_line(&cmdline_str)) .message(format!( "FORWARD upstream connect failed for {host_lc}:{port}: {e}" )) @@ -3509,10 +3072,7 @@ async fn handle_forward_proxy( )) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) + .actor_process(Process::new("-", 0).with_cmd_line(&cmdline_str)) .firewall_rule(policy_str, "opa") .message(format!("FORWARD allowed {method} {host_lc}:{port}{path}")) .build(); @@ -3806,9 +3366,6 @@ mod tests { matched_policy: Some(policy_name.to_string()), }, generation: engine.current_generation(), - binary: Some(PathBuf::from("/usr/bin/node")), - binary_pid: None, - ancestors: vec![], cmdline_paths: vec![], }; let route = @@ -3824,8 +3381,6 @@ mod tests { host: host.to_string(), port, policy_name: policy_name.to_string(), - binary_path: "/usr/bin/node".to_string(), - ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, }; @@ -3989,8 +3544,6 @@ mod tests { host: "gateway.example.test".to_string(), port: 80, policy_name: "ws_api".to_string(), - binary_path: "/usr/bin/node".to_string(), - ancestors: vec![], cmdline_paths: vec![], secret_resolver: resolver, }; @@ -4029,8 +3582,6 @@ mod tests { host: "gateway.example.test".to_string(), port: 80, policy_name: "rest_api".to_string(), - binary_path: "/usr/bin/node".to_string(), - ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, }; @@ -4076,8 +3627,6 @@ network_policies: deny_rules: - method: WEBSOCKET_TEXT path: "/ws" - binaries: - - { path: /usr/bin/node } "#; let (config, tunnel_engine, ctx) = forward_websocket_policy_parts(data, "gateway.example.test", 80, "/ws", "ws_api"); @@ -4120,8 +3669,6 @@ network_policies: deny_rules: - operation_type: query fields: [admin] - binaries: - - { path: /usr/bin/node } "#; let (config, tunnel_engine, ctx) = forward_websocket_policy_parts( data, @@ -6350,286 +5897,4 @@ network_policies: let body_start = resp_str.find("\r\n\r\n").unwrap() + 4; assert_eq!(resp_str[body_start..].len(), cl); } - - /// End-to-end regression for the `docker cp` hot-swap hazard that - /// motivated `binary_path()` stripping the kernel's `" (deleted)"` - /// suffix (PR #844). - /// - /// Before the strip, the identity-resolution chain inside - /// `evaluate_opa_tcp` failed with `"Failed to stat - /// /opt/openshell/bin/openshell-sandbox (deleted)"` because - /// `BinaryIdentityCache::verify_or_cache()` tried to `metadata()` the - /// tainted path. That masked the real security signal: a live process - /// was now bound to a *different* binary on disk than the one that was - /// TOFU-cached. After the strip, `binary_path()` returns a path that - /// stats fine, the cache rehashes the new bytes, and the hash mismatch - /// surfaces as a `Binary integrity violation` error — the contract this - /// PR is trying to establish. - /// - /// Test shape (from the review comment on the initial PR): - /// 1. Start a `TcpListener` in the test process. - /// 2. Copy `/bin/bash` to a temp path we control. - /// 3. Prime `BinaryIdentityCache` with that temp binary's hash. - /// 4. Spawn the temp bash as a child with a `/dev/tcp` one-liner that - /// opens a real TCP connection to the listener and holds it open. - /// 5. Accept the connection on the listener side and capture the peer's - /// ephemeral port — that's what `resolve_process_identity` uses to - /// walk `/proc/net/tcp` back to the child PID. - /// 6. Overwrite the temp bash on disk with different bytes to simulate - /// a `docker cp` hot-swap. The running child is unaffected (it still - /// executes from its in-memory image), but `/proc//exe` will - /// now readlink to `" (deleted)"` OR the overwritten file, depending - /// on whether the filesystem reused the inode. - /// 7. Call `resolve_process_identity` and assert: - /// - the error reason contains `"Binary integrity violation"` (the - /// cache detected the tampered on-disk bytes), and - /// - the error reason does NOT contain `"Failed to stat"` or - /// `"(deleted)"` (the old pre-strip failure mode). - #[cfg(target_os = "linux")] - #[test] - fn resolve_process_identity_surfaces_binary_integrity_violation_on_hot_swap() { - use crate::identity::BinaryIdentityCache; - use std::io::Read; - use std::net::TcpListener; - use std::os::unix::fs::PermissionsExt; - use std::process::{Command, Stdio}; - use std::time::Duration; - - // Skip if /bin/bash is not present (e.g. minimal containers). - if !std::path::Path::new("/bin/bash").exists() { - eprintln!("skipping: /bin/bash not available"); - return; - } - - // 1. Start a listener on loopback. - let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); - let listener_port = listener.local_addr().unwrap().port(); - - // 2. Copy /bin/bash to a temp path. - let tmp = tempfile::TempDir::new().unwrap(); - let bash_v1 = tmp.path().join("hotswap-bash"); - std::fs::copy("/bin/bash", &bash_v1).expect("copy bash"); - std::fs::set_permissions(&bash_v1, std::fs::Permissions::from_mode(0o755)).unwrap(); - - // 3. Prime the cache with the v1 hash of the temp bash. - let cache = BinaryIdentityCache::new(); - let v1_hash = cache - .verify_or_cache(&bash_v1) - .expect("prime cache with v1 bash hash"); - assert!(!v1_hash.is_empty()); - - // 4. Spawn the temp bash with a /dev/tcp one-liner that opens a real - // connection to the listener and sleeps to keep it open. The - // `read -t` blocks on stdin so the shell stays resident. - let script = format!("exec 3<>/dev/tcp/127.0.0.1/{listener_port}; sleep 30 <&3"); - let mut child = Command::new(&bash_v1) - .arg("-c") - .arg(&script) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn hotswap-bash child"); - - // 5. Accept on the listener side, capture the peer port. - listener.set_nonblocking(false).expect("blocking listener"); - let (mut stream, peer_addr) = match listener.accept() { - Ok(pair) => pair, - Err(e) => { - let _ = child.kill(); - let _ = child.wait(); - panic!("failed to accept child connection: {e}"); - } - }; - let peer_port = peer_addr.port(); - // Drain any spurious data; we just need the socket open. - stream - .set_read_timeout(Some(Duration::from_millis(50))) - .ok(); - let mut buf = [0u8; 16]; - let _ = stream.read(&mut buf); - - // Give the kernel a moment so /proc//net/tcp and - // /proc//fd/ both reflect the ESTABLISHED socket. - std::thread::sleep(Duration::from_millis(50)); - - // 6. Simulate `docker cp`: unlink the running binary and create a - // fresh file with different bytes at the same path. Writing - // in place via O_TRUNC is rejected by the kernel with ETXTBSY - // because the inode is still being executed. Unlink is cheap: - // the inode persists in memory via the child's exec mapping, - // so the child keeps running, but a new inode now lives at - // `bash_v1` with a different SHA-256. - std::fs::remove_file(&bash_v1).expect("unlink running bash_v1"); - let tampered_bytes = b"#!/bin/sh\n# tampered bash v2 from hotswap test\nexit 0\n"; - std::fs::write(&bash_v1, tampered_bytes).expect("write replacement bytes"); - - // 7. Resolve identity through the real helper and assert the - // contract: we want "Binary integrity violation", not - // "Failed to stat ... (deleted)". - let test_pid = std::process::id(); - let result = resolve_process_identity(test_pid, peer_port, &cache); - - // Always clean up the child before asserting so a failure doesn't - // leak a sleeping process across test runs. - let _ = child.kill(); - let _ = child.wait(); - - match result { - Ok(_) => panic!( - "resolve_process_identity unexpectedly succeeded after hot-swap; \ - the cache should have detected the tampered on-disk bytes" - ), - Err(err) => { - assert!( - err.reason.contains("Binary integrity violation"), - "expected 'Binary integrity violation' error, got: {}", - err.reason - ); - assert!( - !err.reason.contains("Failed to stat"), - "pre-PR-#844 failure mode leaked: {}", - err.reason - ); - assert!( - !err.reason.contains("(deleted)"), - "resolved path still contains '(deleted)' suffix: {}", - err.reason - ); - // The binary field should be populated — we did resolve a - // path before failing. - assert!( - err.binary.is_some(), - "expected resolved binary path on integrity failure" - ); - if let Some(path) = &err.binary { - assert!( - !path.to_string_lossy().contains("(deleted)"), - "resolved binary path still tainted: {}", - path.display() - ); - } - } - } - } - - #[cfg(target_os = "linux")] - #[test] - fn resolve_process_identity_denies_fork_exec_shared_socket_ambiguity() { - use crate::identity::BinaryIdentityCache; - use std::ffi::CString; - use std::net::{TcpListener, TcpStream}; - use std::os::fd::AsRawFd; - use std::time::{Duration, Instant}; - - if !std::path::Path::new("/bin/sleep").exists() { - eprintln!("skipping: /bin/sleep not available"); - return; - } - - let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener"); - let listener_port = listener.local_addr().unwrap().port(); - let stream = TcpStream::connect(("127.0.0.1", listener_port)).expect("connect"); - let peer_port = stream.local_addr().unwrap().port(); - let (_accepted, _) = listener.accept().expect("accept"); - - let fd = stream.as_raw_fd(); - // libc/syscall FFI requires unsafe - #[allow(unsafe_code)] - unsafe { - let flags = libc::fcntl(fd, libc::F_GETFD); - assert!(flags >= 0, "F_GETFD failed"); - assert_eq!( - libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC), - 0, - "F_SETFD failed" - ); - } - - let sleep_path = CString::new("/bin/sleep").unwrap(); - let arg0 = CString::new("sleep").unwrap(); - let arg1 = CString::new("30").unwrap(); - // libc/syscall FFI requires unsafe - #[allow(unsafe_code)] - let child_pid = unsafe { libc::fork() }; - assert!(child_pid >= 0, "fork failed"); - if child_pid == 0 { - // libc/syscall FFI requires unsafe - #[allow(unsafe_code)] - unsafe { - libc::execl( - sleep_path.as_ptr(), - arg0.as_ptr(), - arg1.as_ptr(), - std::ptr::null::(), - ); - libc::_exit(127); - } - } - - let deadline = Instant::now() + Duration::from_secs(2); - loop { - if let Ok(link) = std::fs::read_link(format!("/proc/{child_pid}/exe")) - && link.to_string_lossy().contains("sleep") - { - break; - } - assert!( - Instant::now() < deadline, - "child pid {child_pid} did not exec into sleep within 2s" - ); - std::thread::sleep(Duration::from_millis(20)); - } - - let cache = BinaryIdentityCache::new(); - - // Resolve with a brief retry loop — under heavy CI load the child's - // procfs entry can momentarily fail to resolve even though the loop - // above just verified `/proc//exe` pointed at `sleep`. Retry a - // few times before declaring failure so the test is not flaky. - let mut result = resolve_process_identity(std::process::id(), peer_port, &cache); - for _ in 0..5 { - match &result { - Err(err) - if err.reason.contains("No such file or directory") - || err.reason.contains("os error 2") => - { - std::thread::sleep(Duration::from_millis(50)); - result = resolve_process_identity(std::process::id(), peer_port, &cache); - } - _ => break, - } - } - - // libc/syscall FFI requires unsafe - #[allow(unsafe_code)] - unsafe { - libc::kill(child_pid, libc::SIGKILL); - libc::waitpid(child_pid, std::ptr::null_mut(), 0); - } - - match result { - Ok(identity) => panic!( - "resolve_process_identity unexpectedly succeeded for shared socket owned by PID {}", - identity.binary_pid - ), - Err(err) => { - assert!( - err.reason.contains("ambiguous shared socket ownership"), - "expected ambiguous socket ownership error, got: {}", - err.reason - ); - assert!( - err.reason.contains(&std::process::id().to_string()), - "error should include parent PID; got: {}", - err.reason - ); - assert!( - err.reason.contains(&child_pid.to_string()), - "error should include child PID; got: {}", - err.reason - ); - } - } - } } diff --git a/crates/openshell-sandbox/testdata/sandbox-policy.yaml b/crates/openshell-sandbox/testdata/sandbox-policy.yaml index 297face21..3b30203fb 100644 --- a/crates/openshell-sandbox/testdata/sandbox-policy.yaml +++ b/crates/openshell-sandbox/testdata/sandbox-policy.yaml @@ -35,9 +35,6 @@ network_policies: endpoints: - { host: api.anthropic.com, port: 443 } - { host: statsig.anthropic.com, port: 443 } - binaries: - - { path: /usr/local/bin/claude } - - { path: /usr/bin/node } github_ssh_over_https: name: github-ssh-over-https @@ -53,8 +50,6 @@ network_policies: - allow: method: POST path: "/**/git-upload-pack" - binaries: - - { path: /usr/bin/git } copilot: name: copilot @@ -70,15 +65,8 @@ network_policies: - { host: default.exp-tas.com, port: 443 } - { host: origin-tracker.githubusercontent.com, port: 443 } - { host: release-assets.githubusercontent.com, port: 443 } - binaries: - - { path: "/usr/lib/node_modules/@github/copilot/node_modules/@github/**/copilot" } - - { path: /usr/local/bin/copilot } - - { path: "/home/*/.local/bin/copilot" } - - { path: /usr/bin/node } gitlab: name: gitlab endpoints: - { host: gitlab.com, port: 443 } - binaries: - - { path: /usr/bin/glab } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 412febb96..cc86dbba9 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -33,7 +33,7 @@ use openshell_core::proto::{ UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, }; use openshell_core::proto::{ - L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, Provider, Sandbox, + L7DenyRule, L7Rule, NetworkEndpoint, NetworkPolicyRule, Provider, Sandbox, SandboxPolicy as ProtoSandboxPolicy, }; use openshell_core::{ @@ -163,10 +163,6 @@ fn summarize_cli_policy_merge_op(operation: &PolicyMergeOp) -> String { .collect::>() .join(", ") ), - PolicyMergeOp::RemoveBinary { - rule_name, - binary_path, - } => format!("remove-binary {rule_name} {binary_path}"), } } @@ -187,8 +183,7 @@ fn summarize_add_endpoint(rule_name: &str, rule: &NetworkPolicyRule) -> String { .map(summarize_endpoint) .collect::>() .join(", "); - let binaries = summarize_binaries(&rule.binaries); - format!("add-endpoint {rule_name} endpoints=[{endpoints}] binaries=[{binaries}]") + format!("add-endpoint {rule_name} endpoints=[{endpoints}]") } fn summarize_add_rule(rule_name: &str, rule: &NetworkPolicyRule) -> String { @@ -198,8 +193,7 @@ fn summarize_add_rule(rule_name: &str, rule: &NetworkPolicyRule) -> String { .map(summarize_endpoint) .collect::>() .join(", "); - let binaries = summarize_binaries(&rule.binaries); - format!("add-rule {rule_name} endpoints=[{endpoints}] binaries=[{binaries}]") + format!("add-rule {rule_name} endpoints=[{endpoints}]") } fn summarize_endpoint(endpoint: &NetworkEndpoint) -> String { @@ -290,14 +284,6 @@ fn summarize_l7_match(method: &str, path: &str, command: &str, query_count: usiz } } -fn summarize_binaries(binaries: &[NetworkBinary]) -> String { - binaries - .iter() - .map(|binary| binary.path.as_str()) - .collect::>() - .join(", ") -} - fn summarize_draft_chunk_rule(chunk: &DraftChunkRecord) -> Result { let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()) .map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))?; @@ -1396,10 +1382,6 @@ pub(super) async fn handle_submit_policy_analysis( .and_then(|r| r.endpoints.first()) .map(|ep| (ep.host.to_lowercase(), ep.port as i32)) .unwrap_or_default(); - let ep_binary = rule_ref - .and_then(|r| r.binaries.first()) - .map(|b| b.path.clone()) - .unwrap_or_default(); let record = DraftChunkRecord { // The handler proposes an id; the store may swap it for an @@ -1421,7 +1403,6 @@ pub(super) async fn handle_submit_policy_analysis( decided_at_ms: None, host: ep_host, port: ep_port, - binary: ep_binary, hit_count: chunk.hit_count.clamp(1, 100), first_seen_ms: if chunk.first_seen_ms > 0 { chunk.first_seen_ms @@ -1671,8 +1652,8 @@ pub(super) async fn handle_reject_draft_chunk( sandbox.object_name(), "removed", format!( - "gateway removed previously approved draft chunk {}: remove-binary {} {}", - req.chunk_id, chunk.rule_name, chunk.binary + "gateway removed previously approved draft chunk {}: rule {}", + req.chunk_id, chunk.rule_name ), version, &hash, @@ -1929,8 +1910,8 @@ pub(super) async fn handle_undo_draft_chunk( sandbox.object_name(), "removed", format!( - "gateway reverted approved draft chunk {}: remove-binary {} {}", - req.chunk_id, chunk.rule_name, chunk.binary + "gateway reverted approved draft chunk {}: rule {}", + req.chunk_id, chunk.rule_name ), version, &hash, @@ -2140,7 +2121,6 @@ fn draft_chunk_record_to_proto(record: &DraftChunkRecord) -> Result { parse_proto_add_allow_rules(index, add_allow_rules) } - policy_merge_operation::Operation::RemoveBinary(remove_binary) => { - let rule_name = remove_binary.rule_name.trim(); - let binary_path = remove_binary.binary_path.trim(); - if rule_name.is_empty() || binary_path.is_empty() { - return Err(Status::invalid_argument(format!( - "merge_operations[{index}].remove_binary requires rule_name and binary_path" - ))); - } - Ok(PolicyMergeOp::RemoveBinary { - rule_name: rule_name.to_string(), - binary_path: binary_path.to_string(), - }) - } + } }) .collect() @@ -2548,9 +2516,8 @@ async fn remove_chunk_from_policy( state.store.as_ref(), sandbox_id, None, - &[PolicyMergeOp::RemoveBinary { + &[PolicyMergeOp::RemoveRule { rule_name: chunk.rule_name.clone(), - binary_path: chunk.binary.clone(), }], ) .await @@ -2937,7 +2904,6 @@ mod tests { port: 443, ..Default::default() }], - ..Default::default() }, )) .collect(), @@ -3042,7 +3008,6 @@ mod tests { port: 443, ..Default::default() }], - binaries: Vec::new(), inference_capable: false, }), }) @@ -3095,10 +3060,6 @@ mod tests { path: "/v1".to_string(), ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/custom".to_string(), - harness: true, - }], inference_capable: false, }), }) @@ -3117,7 +3078,6 @@ mod tests { assert_eq!(layers[0].rule.endpoints[0].allowed_ips, vec!["10.0.0.0/24"]); assert!(layers[0].rule.endpoints[0].allow_encoded_slash); assert_eq!(layers[0].rule.endpoints[0].path, "/v1"); - assert!(layers[0].rule.binaries[0].harness); } #[tokio::test] @@ -3147,7 +3107,6 @@ mod tests { port: 443, ..Default::default() }], - binaries: Vec::new(), inference_capable: false, }), }) @@ -3635,9 +3594,8 @@ mod tests { }; use openshell_core::proto::{ AttachSandboxProviderRequest, DetachSandboxProviderRequest, - GetSandboxProviderEnvironmentRequest, ImportProviderProfilesRequest, NetworkBinary, - ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, - ProviderProfileImportItem, + GetSandboxProviderEnvironmentRequest, ImportProviderProfilesRequest, ProviderProfile, + ProviderProfileCategory, ProviderProfileCredential, ProviderProfileImportItem, }; let state = test_server_state().await; @@ -3673,10 +3631,6 @@ mod tests { }], ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/custom".to_string(), - harness: true, - }], inference_capable: false, }), }], @@ -3735,7 +3689,6 @@ mod tests { assert_eq!(custom_rule.endpoints[0].host, "api.custom.example"); assert_eq!(custom_rule.endpoints[0].protocol, "rest"); assert_eq!(custom_rule.endpoints[0].rules.len(), 1); - assert_eq!(custom_rule.binaries[0].path, "/usr/bin/custom"); let attached_env = handle_get_sandbox_provider_environment( &state, @@ -3812,7 +3765,6 @@ mod tests { port: 443, ..Default::default() }], - ..Default::default() }, )) .collect(), @@ -3846,7 +3798,6 @@ mod tests { port: 443, ..Default::default() }], - ..Default::default() }, )) .collect(), @@ -3965,7 +3916,7 @@ mod tests { #[tokio::test] async fn draft_chunk_handler_lifecycle_round_trip() { use openshell_core::proto::{ - GetDraftPolicyRequest, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec, + GetDraftPolicyRequest, NetworkEndpoint, SandboxPhase, SandboxSpec, }; let state = test_server_state().await; @@ -3994,10 +3945,6 @@ mod tests { port: 443, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }; let submit = handle_submit_policy_analysis( @@ -4012,7 +3959,6 @@ mod tests { hit_count: 3, first_seen_ms: 100, last_seen_ms: 200, - binary: "/usr/bin/curl".to_string(), ..Default::default() }], ..Default::default() @@ -4176,7 +4122,7 @@ mod tests { /// feedback loop hangs off this guarantee. #[tokio::test] async fn reject_with_reason_persists_into_chunk_for_agent_readback() { - use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec}; + use openshell_core::proto::{NetworkEndpoint, SandboxPhase, SandboxSpec}; let state = test_server_state().await; let sandbox_name = "agent-feedback-loop".to_string(); @@ -4204,10 +4150,6 @@ mod tests { port: 443, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }; let submit = handle_submit_policy_analysis( @@ -4274,7 +4216,7 @@ mod tests { /// find it because the SQL ON CONFLICT had silently kept the prior row. #[tokio::test] async fn agent_authored_submits_for_same_endpoint_do_not_dedup() { - use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec}; + use openshell_core::proto::{NetworkEndpoint, SandboxPhase, SandboxSpec}; let state = test_server_state().await; let sandbox_name = "redraft-loop".to_string(); @@ -4308,10 +4250,6 @@ mod tests { port: 443, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }; let submit_one = |rule_name: &str, rule: NetworkPolicyRule| { @@ -4388,7 +4326,7 @@ mod tests { /// mechanistic dedup. #[tokio::test] async fn mechanistic_submits_for_same_endpoint_dedup_into_one_chunk() { - use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec}; + use openshell_core::proto::{NetworkEndpoint, SandboxPhase, SandboxSpec}; let state = test_server_state().await; let sandbox_name = "mechanistic-dedup".to_string(); @@ -4416,10 +4354,6 @@ mod tests { port: 443, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }; let submit_one = || { let state = state.clone(); @@ -4462,7 +4396,7 @@ mod tests { assert_eq!( draft.chunks.len(), 1, - "two mechanistic submits for the same host|port|binary must dedup; got {} chunks", + "two mechanistic submits for the same host|port must dedup; got {} chunks", draft.chunks.len() ); // Both submits must report the same effective id — the id of the @@ -4488,7 +4422,7 @@ mod tests { /// undo, so the test walks that sequence. #[tokio::test] async fn undo_after_reject_clears_stale_rejection_reason() { - use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec}; + use openshell_core::proto::{NetworkEndpoint, SandboxPhase, SandboxSpec}; let state = test_server_state().await; let sandbox_name = "undo-clears-reason".to_string(); @@ -4516,10 +4450,6 @@ mod tests { port: 443, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }; let submit = handle_submit_policy_analysis( @@ -4595,7 +4525,7 @@ mod tests { #[tokio::test] async fn draft_chunk_handlers_reject_cross_sandbox_chunk_ids() { - use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec}; + use openshell_core::proto::{NetworkEndpoint, SandboxPhase, SandboxSpec}; let state = test_server_state().await; let sandbox_a = Sandbox { @@ -4638,10 +4568,6 @@ mod tests { port: 443, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }; handle_submit_policy_analysis( @@ -4656,7 +4582,6 @@ mod tests { hit_count: 3, first_seen_ms: 100, last_seen_ms: 200, - binary: "/usr/bin/curl".to_string(), ..Default::default() }], ..Default::default() @@ -4790,16 +4715,12 @@ mod tests { enforcement: "enforce".to_string(), ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }, }; assert_eq!( summarize_cli_policy_merge_op(&operation), - "add-endpoint github_api endpoints=[api.github.com:443 protocol=rest access=read-only enforcement=enforce] binaries=[/usr/bin/curl]" + "add-endpoint github_api endpoints=[api.github.com:443 protocol=rest access=read-only enforcement=enforce]" ); } @@ -4818,16 +4739,12 @@ mod tests { websocket_credential_rewrite: true, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/node".to_string(), - ..Default::default() - }], }, }; assert_eq!( summarize_cli_policy_merge_op(&operation), - "add-endpoint realtime_api endpoints=[realtime.example.com:443 protocol=websocket access=read-write enforcement=enforce websocket_credential_rewrite=true] binaries=[/usr/bin/node]" + "add-endpoint realtime_api endpoints=[realtime.example.com:443 protocol=websocket access=read-write enforcement=enforce websocket_credential_rewrite=true]" ); } @@ -4846,16 +4763,12 @@ mod tests { request_body_credential_rewrite: true, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/node".to_string(), - ..Default::default() - }], }, }; assert_eq!( summarize_cli_policy_merge_op(&operation), - "add-endpoint slack_api endpoints=[slack.com:443 protocol=rest access=read-write enforcement=enforce request_body_credential_rewrite=true] binaries=[/usr/bin/node]" + "add-endpoint slack_api endpoints=[slack.com:443 protocol=rest access=read-write enforcement=enforce request_body_credential_rewrite=true]" ); } @@ -4863,7 +4776,7 @@ mod tests { #[tokio::test] async fn merge_chunk_into_policy_adds_first_network_rule_to_empty_policy() { - use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; let store = Store::connect("sqlite::memory:").await.unwrap(); let rule = NetworkPolicyRule { @@ -4873,10 +4786,6 @@ mod tests { port: 443, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }; let chunk = DraftChunkRecord { id: "chunk-1".to_string(), @@ -4892,7 +4801,6 @@ mod tests { decided_at_ms: None, host: "google.com".to_string(), port: 443, - binary: "/usr/bin/curl".to_string(), hit_count: 1, first_seen_ms: 0, last_seen_ms: 0, @@ -4919,14 +4827,11 @@ mod tests { .expect("merged rule should be present"); assert_eq!(stored_rule.endpoints[0].host, "google.com"); assert_eq!(stored_rule.endpoints[0].port, 443); - assert_eq!(stored_rule.binaries[0].path, "/usr/bin/curl"); } #[tokio::test] async fn merge_chunk_merges_into_existing_rule_by_host_port() { - use openshell_core::proto::{ - NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, - }; + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule, SandboxPolicy}; let store = Store::connect("sqlite::memory:").await.unwrap(); let sandbox_id = "sb-merge"; @@ -4941,10 +4846,6 @@ mod tests { port: 8567, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }, )) .collect(), @@ -4969,10 +4870,6 @@ mod tests { allowed_ips: vec!["192.168.1.100".to_string()], ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }; let chunk = DraftChunkRecord { id: "chunk-merge".to_string(), @@ -4988,7 +4885,6 @@ mod tests { decided_at_ms: None, host: "192.168.1.100".to_string(), port: 8567, - binary: "/usr/bin/curl".to_string(), hit_count: 1, first_seen_ms: 0, last_seen_ms: 0, @@ -5025,9 +4921,7 @@ mod tests { #[tokio::test] async fn merge_chunk_new_host_port_inserts_new_entry() { - use openshell_core::proto::{ - NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, - }; + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule, SandboxPolicy}; let store = Store::connect("sqlite::memory:").await.unwrap(); let sandbox_id = "sb-new"; @@ -5042,10 +4936,6 @@ mod tests { port: 443, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }, )) .collect(), @@ -5070,10 +4960,6 @@ mod tests { allowed_ips: vec!["10.0.0.5".to_string()], ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], }; let chunk = DraftChunkRecord { id: "chunk-new".to_string(), @@ -5089,7 +4975,6 @@ mod tests { decided_at_ms: None, host: "10.0.0.5".to_string(), port: 8080, - binary: "/usr/bin/curl".to_string(), hit_count: 1, first_seen_ms: 0, last_seen_ms: 0, @@ -5132,7 +5017,6 @@ mod tests { access: "read-only".to_string(), ..Default::default() }], - ..Default::default() }, )) .collect(), @@ -5209,7 +5093,6 @@ mod tests { allowed_ips: vec!["127.0.0.1".to_string()], ..Default::default() }], - binaries: vec![], }; let result = validate_rule_not_always_blocked(&rule); assert!(result.is_err()); @@ -5230,7 +5113,6 @@ mod tests { allowed_ips: vec!["169.254.169.254".to_string()], ..Default::default() }], - binaries: vec![], }; let result = validate_rule_not_always_blocked(&rule); assert!(result.is_err()); @@ -5248,7 +5130,6 @@ mod tests { port: 80, ..Default::default() }], - binaries: vec![], }; let result = validate_rule_not_always_blocked(&rule); assert!(result.is_err()); @@ -5266,7 +5147,6 @@ mod tests { port: 8080, ..Default::default() }], - binaries: vec![], }; let result = validate_rule_not_always_blocked(&rule); assert!(result.is_err()); @@ -5285,7 +5165,6 @@ mod tests { allowed_ips: vec!["10.0.5.0/24".to_string()], ..Default::default() }], - binaries: vec![], }; let result = validate_rule_not_always_blocked(&rule); assert!(result.is_ok()); @@ -5302,7 +5181,6 @@ mod tests { port: 443, ..Default::default() }], - binaries: vec![], }; let result = validate_rule_not_always_blocked(&rule); assert!(result.is_ok()); diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index cd85e31b1..0ba3fc021 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1458,10 +1458,10 @@ mod tests { use crate::grpc::test_support::test_server_state; use openshell_core::proto::{ DeleteProviderProfileRequest, GetProviderProfileRequest, ImportProviderProfilesRequest, - L7Allow, L7Rule, LintProviderProfilesRequest, ListProviderProfilesRequest, NetworkBinary, - NetworkEndpoint, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, - ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, - ProviderProfileImportItem, Sandbox, SandboxSpec, + L7Allow, L7Rule, LintProviderProfilesRequest, ListProviderProfilesRequest, NetworkEndpoint, + ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, ProviderProfile, + ProviderProfileCategory, ProviderProfileCredential, ProviderProfileImportItem, Sandbox, + SandboxSpec, }; use openshell_core::{ObjectId, ObjectName}; use std::collections::HashMap; @@ -1518,7 +1518,6 @@ mod tests { category: ProviderProfileCategory::Other as i32, credentials: Vec::new(), endpoints: Vec::new(), - binaries: Vec::new(), inference_capable: false, } } @@ -1925,10 +1924,6 @@ mod tests { path: "/v1".to_string(), ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/advanced".to_string(), - harness: true, - }], inference_capable: false, }), source: "advanced-api.yaml".to_string(), @@ -1965,7 +1960,6 @@ mod tests { ); assert!(endpoint.allow_encoded_slash); assert_eq!(endpoint.path, "/v1"); - assert!(fetched.binaries[0].harness); } #[tokio::test] @@ -2962,7 +2956,6 @@ mod tests { }), }], endpoints: vec![], - binaries: vec![], inference_capable: false, }), source: "delegated-refresh-api.yaml".to_string(), diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 53f292053..4f82aaeea 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -1360,7 +1360,6 @@ mod tests { port: 443, ..Default::default() }], - ..Default::default() }, ); let err = validate_policy_safety(&policy).unwrap_err(); diff --git a/crates/openshell-server/src/policy_store.rs b/crates/openshell-server/src/policy_store.rs index 9a6333543..7363b9f55 100644 --- a/crates/openshell-server/src/policy_store.rs +++ b/crates/openshell-server/src/policy_store.rs @@ -329,14 +329,14 @@ pub fn policy_record_from_parts( }) } -/// Observation-mode dedup key: `host|port|binary`. Used by the mechanistic +/// Observation-mode dedup key: `host|port`. Used by the mechanistic /// mapper path where N denials targeting the same endpoint should fold into /// one chunk instead of N near-identical chunks. Agent-authored proposals /// pass `None` for the `dedup_key` argument to `put_draft_chunk` so each /// proposal lands as its own chunk regardless of target — the redraft loop /// depends on this. pub fn observation_dedup_key(chunk: &DraftChunkRecord) -> String { - format!("{}|{}|{}", chunk.host, chunk.port, chunk.binary) + format!("{}|{}", chunk.host, chunk.port) } pub fn draft_chunk_payload_from_record(chunk: &DraftChunkRecord) -> PersistenceResult> { @@ -361,7 +361,6 @@ pub fn draft_chunk_payload_from_record(chunk: &DraftChunkRecord) -> PersistenceR decided_at_ms: chunk.decided_at_ms.unwrap_or(0), host: chunk.host.clone(), port: chunk.port, - binary: chunk.binary.clone(), draft_version: chunk.draft_version, validation_result: chunk.validation_result.clone(), rejection_reason: chunk.rejection_reason.clone(), @@ -401,7 +400,6 @@ pub fn draft_chunk_record_from_parts( decided_at_ms: (wrapper.decided_at_ms > 0).then_some(wrapper.decided_at_ms), host: wrapper.host, port: wrapper.port, - binary: wrapper.binary, hit_count: i32::try_from(hit_count).unwrap_or(i32::MAX), first_seen_ms: created_at_ms, last_seen_ms: updated_at_ms, diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 1969715ce..4253d7a87 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1282,15 +1282,6 @@ fn render_policy_lines( } } - // Binaries. - let binary_paths: Vec<&str> = rule.binaries.iter().map(|b| b.path.as_str()).collect(); - if !binary_paths.is_empty() { - lines.push(Line::from(vec![ - Span::styled(" Binaries: ", t.muted), - Span::styled(binary_paths.join(", "), t.text), - ])); - } - lines.push(Line::from("")); } } diff --git a/crates/openshell-tui/src/ui/sandbox_draft.rs b/crates/openshell-tui/src/ui/sandbox_draft.rs index a9cd6b0c9..b49523f28 100644 --- a/crates/openshell-tui/src/ui/sandbox_draft.rs +++ b/crates/openshell-tui/src/ui/sandbox_draft.rs @@ -124,12 +124,6 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) { spans.push(Span::styled(" ", t.muted)); spans.push(Span::styled(endpoint_str, t.accent)); } - // Show binary name (just the filename, not full path) if present. - if !chunk.binary.is_empty() { - let bin_short = chunk.binary.rsplit('/').next().unwrap_or(&chunk.binary); - spans.push(Span::styled(" ", t.muted)); - spans.push(Span::styled(format!("({bin_short})"), t.muted)); - } spans.push(Span::raw(" ")); spans.push(Span::styled(format!("[{}]", chunk.status), status_style)); spans.push(Span::styled( @@ -198,14 +192,6 @@ pub fn draw_detail_popup( ]), ]; - // Binary (denormalized from the denial). - if !chunk.binary.is_empty() { - lines.push(Line::from(vec![ - Span::styled("Binary: ", t.muted), - Span::styled(&chunk.binary, t.text), - ])); - } - // Hit count (accumulated real denial count) and first/last seen. lines.push(Line::from(vec![ Span::styled("Denied: ", t.muted), @@ -238,18 +224,6 @@ pub fn draw_detail_popup( Span::styled(format!("{}:{}", ep.host, ep.port), t.accent), ])); } - - // Binaries. - if !rule.binaries.is_empty() { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled("Binaries:", t.muted))); - for b in &rule.binaries { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(&b.path, t.text), - ])); - } - } } // Rationale. @@ -392,17 +366,12 @@ pub fn draw_approve_all_popup( (chunk.rule_name.clone(), endpoint_str) }; - let mut row_spans = vec![ + let row_spans = vec![ Span::styled(" -> ", t.muted), Span::styled(name_str, t.text), Span::styled(" ", t.muted), Span::styled(ep_str, t.accent), ]; - if !chunk.binary.is_empty() { - let bin_short = chunk.binary.rsplit('/').next().unwrap_or(&chunk.binary); - row_spans.push(Span::styled(" ", t.muted)); - row_spans.push(Span::styled(format!("({bin_short})"), t.muted)); - } lines.push(Line::from(row_spans)); } diff --git a/docs/about/how-it-works.mdx b/docs/about/how-it-works.mdx index 5223072a2..ea7601be2 100644 --- a/docs/about/how-it-works.mdx +++ b/docs/about/how-it-works.mdx @@ -112,7 +112,7 @@ then launches the agent under the active policy. |---|---| | Process | Drops privileges, applies process identity rules, disables privilege escalation paths, and starts the agent as a restricted child process. | | Filesystem | Applies filesystem policy before the agent starts so undeclared paths are inaccessible and declared paths are read-only or read-write as configured. | -| Network | Routes ordinary egress through the policy proxy so destination, port, binary identity, and L7 request rules can be evaluated before traffic leaves the sandbox. | +| Network | Routes ordinary egress through the policy proxy so destination, port, and L7 request rules can be evaluated before traffic leaves the sandbox. | | Credentials | Receives credential material from the gateway and injects it only through configured policy paths or request-time proxy rules. | | Inference | Intercepts `https://inference.local` and forwards model traffic through the configured inference route instead of exposing provider credentials to the agent. | | Observability | Emits local security and lifecycle logs, pushes sandbox logs to the gateway, and keeps relay endpoints available for connect, exec, and file transfer operations. | diff --git a/docs/about/supported-agents.mdx b/docs/about/supported-agents.mdx index c97e1e677..87e2ed0e7 100644 --- a/docs/about/supported-agents.mdx +++ b/docs/about/supported-agents.mdx @@ -11,8 +11,8 @@ The following table summarizes the agents that run in OpenShell sandboxes. All a | Agent | Source | Default Policy | Notes | |---|---|---|---| | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Full coverage | Works out of the box. Requires `ANTHROPIC_API_KEY`. | -| [OpenCode](https://opencode.ai/) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Partial coverage | Pre-installed. Add `opencode.ai` endpoint and OpenCode binary paths to the policy for full functionality. | -| [Codex](https://developers.openai.com/codex) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | No coverage | Pre-installed. Requires a custom policy with OpenAI endpoints and Codex binary paths. Requires `OPENAI_API_KEY`. | +| [OpenCode](https://opencode.ai/) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Partial coverage | Pre-installed. Add `opencode.ai` endpoint to the policy for full functionality. | +| [Codex](https://developers.openai.com/codex) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | No coverage | Pre-installed. Requires a custom policy with OpenAI endpoints. Requires `OPENAI_API_KEY`. | | [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Full coverage | Pre-installed. Works out of the box. Requires `GITHUB_TOKEN` or `COPILOT_GITHUB_TOKEN`. | | [OpenClaw](https://openclaw.ai/) | [`openclaw`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/openclaw) | Bundled | Agent orchestration layer. Launch with `openshell sandbox create --from openclaw`. | | [Ollama](https://ollama.com/) | [`ollama`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/ollama) | Bundled | Run cloud and local models. Includes Claude Code, Codex, and OpenCode. Launch with `openshell sandbox create --from ollama`. | diff --git a/docs/get-started/tutorials/first-network-policy.mdx b/docs/get-started/tutorials/first-network-policy.mdx index 3e4593308..8de03b86f 100644 --- a/docs/get-started/tutorials/first-network-policy.mdx +++ b/docs/get-started/tutorials/first-network-policy.mdx @@ -82,14 +82,14 @@ openshell logs demo --since 5m You see a line like: ```text -action=deny dst_host=api.github.com dst_port=443 binary=/usr/bin/curl deny_reason="no matching network policy" +action=deny dst_host=api.github.com dst_port=443 deny_reason="no matching network policy" ``` -Every denied connection is logged with the destination, the binary that attempted it, and the reason. Nothing gets out silently. +Every denied connection is logged with the destination and reason. Nothing gets out silently. ## Apply a Read-Only GitHub API Policy -To allow the sandbox to reach the GitHub API, define a network policy that grants read-only access. The policy specifies which host, port, binary, and HTTP methods are permitted. Create a file called `github_readonly.yaml` with the following content: +To allow the sandbox to reach the GitHub API, define a network policy that grants read-only access. The policy specifies which host, port, and HTTP methods are permitted. Create a file called `github_readonly.yaml` with the following content: ```yaml version: 1 @@ -113,11 +113,9 @@ network_policies: protocol: rest enforcement: enforce access: read-only - binaries: - - { path: /usr/bin/curl } ``` -The `filesystem_policy`, `landlock`, and `process` sections preserve the default sandbox settings. This is required because `policy set` replaces the entire policy. The `network_policies` section is the key part: `curl` can make GET, HEAD, and OPTIONS requests to `api.github.com` over HTTPS. Everything else is denied. The proxy auto-detects TLS on HTTPS endpoints and terminates it to inspect each HTTP request and enforce the `read-only` access preset at the method level. +The `filesystem_policy`, `landlock`, and `process` sections preserve the default sandbox settings. This is required because `policy set` replaces the entire policy. The `network_policies` section is the key part: the sandbox can make GET, HEAD, and OPTIONS requests to `api.github.com` over HTTPS. Everything else is denied. The proxy auto-detects TLS on HTTPS endpoints and terminates it to inspect each HTTP request and enforce the `read-only` access preset at the method level. Apply it: @@ -130,7 +128,6 @@ openshell policy set demo --policy github_readonly.yaml --wait This tutorial uses `curl` and `read-only` access to keep things simple. When building policies for real workloads: -- To scope the policy to an agent, replace the `binaries` section with your agent's binary, such as `/usr/local/bin/claude`, instead of `curl`. - To grant write access, change `access: read-only` to `read-write` or add explicit `rules` for specific paths. Refer to the [Policy Schema](/reference/policy-schema). - To allow additional endpoints, stack multiple policies in the same file for PyPI, npm, or your internal APIs. Refer to [Policies](/sandboxes/policies) for examples. diff --git a/docs/get-started/tutorials/github-sandbox.mdx b/docs/get-started/tutorials/github-sandbox.mdx index 7c76d4e41..2c57f64f0 100644 --- a/docs/get-started/tutorials/github-sandbox.mdx +++ b/docs/get-started/tutorials/github-sandbox.mdx @@ -204,19 +204,12 @@ network_policies: - { host: sentry.io, port: 443 } - { host: raw.githubusercontent.com, port: 443 } - { host: platform.claude.com, port: 443 } - binaries: - - { path: /usr/local/bin/claude } - - { path: /usr/bin/node } # NVIDIA inference endpoint nvidia_inference: name: nvidia-inference endpoints: - { host: integrate.api.nvidia.com, port: 443 } - binaries: - - { path: /usr/bin/curl } - - { path: /bin/bash } - - { path: /usr/local/bin/opencode } # ── GitHub: git operations (clone, fetch, push) ────────────── @@ -237,8 +230,6 @@ network_policies: - allow: method: POST path: "//.git/git-receive-pack" - binaries: - - { path: /usr/bin/git } # ── GitHub: REST API ───────────────────────────────────────── @@ -270,11 +261,6 @@ network_policies: deny_rules: - operation_type: mutation fields: [deleteRepository, deleteRef, updateBranchProtectionRule] - binaries: - - { path: /usr/local/bin/claude } - - { path: /usr/local/bin/opencode } - - { path: /usr/bin/gh } - - { path: /usr/bin/curl } # ── Package managers ───────────────────────────────────────── @@ -287,13 +273,6 @@ network_policies: - { host: objects.githubusercontent.com, port: 443 } - { host: api.github.com, port: 443 } - { host: downloads.python.org, port: 443 } - binaries: - - { path: /sandbox/.venv/bin/python } - - { path: /sandbox/.venv/bin/python3 } - - { path: /sandbox/.venv/bin/pip } - - { path: "/sandbox/.uv/python/**/python*" } - - { path: /usr/local/bin/uv } - - { path: "/sandbox/.uv/python/**" } # ── VS Code Remote ────────────────────────────────────────── @@ -305,11 +284,6 @@ network_policies: - { host: vscode.download.prss.microsoft.com, port: 443 } - { host: marketplace.visualstudio.com, port: 443 } - { host: "*.gallerycdn.vsassets.io", port: 443 } - binaries: - - { path: /usr/bin/curl } - - { path: /usr/bin/wget } - - { path: "/sandbox/.vscode-server/**" } - - { path: "/sandbox/.vscode-remote-containers/**" } ``` The following table summarizes the two GitHub-specific blocks: diff --git a/docs/observability/logging.mdx b/docs/observability/logging.mdx index 81c47248c..9fdfff447 100644 --- a/docs/observability/logging.mdx +++ b/docs/observability/logging.mdx @@ -89,7 +89,7 @@ CLASS:ACTIVITY [SEVERITY] ACTION DETAILS [CONTEXT] **Details** vary by event class: -- Network: `process(pid) -> host:port` with the process identity and destination +- Network: `process(pid) -> host:port` with the initiating process and destination - HTTP: `METHOD url` with the HTTP method and target - SSH: peer address and authentication type - Process: `name(pid)` with exit code or command line diff --git a/docs/reference/default-policy.mdx b/docs/reference/default-policy.mdx index 73dedf89c..e252da006 100644 --- a/docs/reference/default-policy.mdx +++ b/docs/reference/default-policy.mdx @@ -16,11 +16,11 @@ The following table shows the coverage of the default policy for common agents. | Agent | Coverage | Action Required | |---|---|---| | Claude Code | Full | None. Works out of the box. | -| OpenCode | Partial | Add `opencode.ai` endpoint and OpenCode binary paths. | -| Codex | None | Provide a complete custom policy with OpenAI endpoints and Codex binary paths. | +| OpenCode | Partial | Add `opencode.ai` endpoint to the policy for full functionality. | +| Codex | None | Provide a complete custom policy with OpenAI endpoints. | -If you run a non-Claude agent without a custom policy, the agent's API calls are denied by the proxy. You must provide a policy that declares the agent's endpoints and binaries. +If you run a non-Claude agent without a custom policy, the agent's API calls are denied by the proxy. You must provide a policy that declares the agent's endpoints. diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 747b2b236..bff4cb2ea 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -28,7 +28,7 @@ network_policies: { ... } | `filesystem_policy` | object | No | Static | Controls which directories the agent can read and write. | | `landlock` | object | No | Static | Configures Landlock LSM enforcement behavior. | | `process` | object | No | Static | Sets the user and group the agent process runs as. | -| `network_policies` | map | No | Dynamic | Declares which binaries can reach which network endpoints. | +| `network_policies` | map | No | Dynamic | Declares which network endpoints sandboxes can reach. | Static fields are set at sandbox creation time. Changing them requires destroying and recreating the sandbox. Dynamic fields can be updated on a running sandbox with `openshell policy update` for incremental merges or `openshell policy set` for full replacement, and take effect without restarting. @@ -134,7 +134,11 @@ process: **Category:** Dynamic -A map of named network policy entries. Each entry declares a set of endpoints and a set of binaries. Only the listed binaries are permitted to connect to the listed endpoints. The map key is a logical identifier. The `name` field inside the entry is the display name used in logs. +A map of named network policy entries. Each entry declares a set of endpoints the sandbox can reach. The map key is a logical identifier. The `name` field inside the entry is the display name used in logs. + + +**Removed in this release:** The `binaries` field previously restricted which process paths could reach an endpoint, using `/proc//exe` lookups and SHA-256 TOFU hashing. This control was removed because it is bypassable: runtime injection via environment variables (for example, `BUN_OPTIONS`, `NODE_OPTIONS`) or the dynamic linker (`LD_PRELOAD`) allows arbitrary code to execute inside a trusted binary's process while the kernel-reported executable path remains unchanged. Policy enforcement is now destination-based only (host and port). Existing `binaries:` fields in policy YAML are accepted and silently ignored. + ### Network Policy Entry @@ -144,7 +148,7 @@ Each entry in the `network_policies` map has the following fields: |---|---|---|---| | `name` | string | No | Display name for the policy entry. Used in log output. Defaults to the map key. | | `endpoints` | list of endpoint objects | Yes | Hosts and ports this entry permits. | -| `binaries` | list of binary objects | Yes | Executables allowed to connect to these endpoints. | +| `binaries` | list of binary objects | No | Accepted in YAML for backward compatibility but silently ignored. Policy enforcement is destination-based only. | ### Endpoint Object @@ -358,11 +362,11 @@ endpoints: ### Binary Object -Identifies an executable that is permitted to use the associated endpoints. +The `binaries` field is accepted for backward compatibility but is silently ignored. Binary path allowlisting was removed because it is bypassable. See the [Network Policies](#network-policies) section for details. -| Field | Type | Required | Description | -|---|---|---|---| -| `path` | string | Yes | Filesystem path to the executable. Supports glob patterns with `*` and `**`. For example, `/sandbox/.vscode-server/**` matches any executable under that directory tree. | +| Field | Type | Description | +|---|---|---| +| `path` | string | Accepted but ignored. Was previously the filesystem path to the executable. | ### Full Example @@ -378,10 +382,6 @@ network_policies: protocol: rest enforcement: enforce access: read-only - binaries: - - path: /usr/local/bin/claude - - path: /usr/bin/node - - path: /usr/bin/gh npm_registry: name: npm-registry endpoints: @@ -390,7 +390,4 @@ network_policies: protocol: rest access: read-only allow_encoded_slash: true - binaries: - - path: /usr/bin/npm - - path: /usr/bin/node ``` diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 4ead66a5e..c5401daa9 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -31,7 +31,7 @@ process: run_as_user: sandbox run_as_group: sandbox -# Dynamic: hot-reloadable. Named blocks of endpoints + binaries allowed to reach them. +# Dynamic: hot-reloadable. Named blocks of endpoints the sandbox can reach. network_policies: my_api: name: my-api @@ -41,8 +41,6 @@ network_policies: protocol: rest enforcement: enforce access: full - binaries: - - path: /usr/bin/curl ``` @@ -56,7 +54,7 @@ Raw streams are connection-scoped and outside L7 live-reload guarantees. This in | `filesystem_policy` | Static | Controls which directories the agent can access on disk. Paths are split into `read_only` and `read_write` lists. Any path not listed in either list is inaccessible. Set `include_workdir: true` to automatically add the agent's working directory to `read_write`. [Landlock LSM](https://docs.kernel.org/security/landlock.html) enforces these restrictions at the kernel level. | | `landlock` | Static | Configures Landlock LSM enforcement behavior. Set `compatibility` to `best_effort` (skip individual inaccessible paths while applying remaining rules) or `hard_requirement` (fail if any path is inaccessible or the required kernel ABI is unavailable). Refer to the [Policy Schema Reference](/reference/policy-schema#landlock) for the full behavior table. | | `process` | Static | Sets the OS-level identity for the agent process. `run_as_user` and `run_as_group` default to `sandbox`. Root (`root` or `0`) is rejected. The agent also runs with seccomp filters that block dangerous system calls. | -| `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection except `https://inference.local` goes through the proxy, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints without `protocol` allow the TCP stream through without inspecting payloads.
If no endpoint matches, the connection is denied. Configure managed inference separately through [Inference Routing](/sandboxes/inference-routing). | +| `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name and a list of endpoints (host, port, protocol, and optional rules).
Every outbound connection except `https://inference.local` goes through the proxy, which queries the [policy engine](/about/how-it-works#core-components) with the destination host and port. A connection is allowed when the destination matches an endpoint in a policy block.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints without `protocol` allow the TCP stream through without inspecting payloads.
If no endpoint matches, the connection is denied. Configure managed inference separately through [Inference Routing](/sandboxes/inference-routing). | ## Baseline Filesystem Paths @@ -117,18 +115,17 @@ The following steps outline the hot-reload policy update workflow. 1. Create the sandbox with your initial policy by following [Apply a Custom Policy](#apply-a-custom-policy) above (or set `OPENSHELL_SANDBOX_POLICY`). -2. Monitor denials. Each log entry shows host, port, binary, and reason. Alternatively, use `openshell term` for a live dashboard. +2. Monitor denials. Each log entry shows host, port, and reason. Alternatively, use `openshell term` for a live dashboard. ```shell openshell logs --tail --source sandbox ``` -3. For additive network changes, use `openshell policy update`. This is the fastest path for adding endpoints, binaries, or REST and WebSocket allow/deny rules without replacing the full policy. The full option and format reference is in [Incremental Policy Updates](#incremental-policy-updates). +3. For additive network changes, use `openshell policy update`. This is the fastest path for adding endpoints or REST and WebSocket allow/deny rules without replacing the full policy. The full option and format reference is in [Incremental Policy Updates](#incremental-policy-updates). ```shell openshell policy update \ --add-endpoint api.github.com:443:read-only:rest:enforce \ - --binary /usr/bin/gh \ --wait openshell policy update \ @@ -144,7 +141,7 @@ The following steps outline the hot-reload policy update workflow. openshell policy get --full > current-policy.yaml ``` -5. Edit the YAML: add or adjust `network_policies` entries, binaries, `access`, or `rules`. +5. Edit the YAML: add or adjust `network_policies` entries, `access`, or `rules`. 6. Push the updated policy when you need a full replacement. Exit codes: 0 = loaded, 1 = validation failed, 124 = timeout. @@ -177,12 +174,11 @@ The incremental update surface is split into endpoint-level operations and metho | Flag | What it changes | Typical use | |---|---|---| -| `--add-endpoint ` | Creates or merges a network rule and endpoint. | Allow a new host and port, optionally with `access`, `protocol`, `enforcement`, endpoint options, and binaries. | +| `--add-endpoint ` | Creates or merges a network rule and endpoint. | Allow a new host and port, optionally with `access`, `protocol`, `enforcement`, and endpoint options. | | `--remove-endpoint ` | Removes one host and port match from the current policy. | Drop a stale endpoint or remove one port from a multi-port endpoint. | | `--remove-rule ` | Deletes a named `network_policies` entry. | Remove a whole rule by name when you no longer need it. | | `--add-allow ` | Appends method/path allow rules to an existing REST or WebSocket endpoint. | Permit one additional REST method/path or WebSocket `WEBSOCKET_TEXT` path on an API that is already configured. | | `--add-deny ` | Appends method/path deny rules to an existing REST or WebSocket endpoint. | Block a sensitive REST path or WebSocket text-message path under an endpoint that is otherwise allowed. | -| `--binary ` | Adds binaries to every `--add-endpoint` rule in the same command. | Bind a new endpoint to one or more executables. | | `--rule-name ` | Overrides the generated rule name. | Keep a stable human-chosen rule name when adding exactly one endpoint. | | `--dry-run` | Shows the merged policy locally and does not call the gateway. | Review the result before persisting it. | | `--wait` | Polls until the sandbox reports that the new revision loaded. | Confirm the change took effect before continuing. | @@ -192,13 +188,13 @@ The incremental update surface is split into endpoint-level operations and metho ### Add Endpoint Compared to Allow and Deny -`--add-endpoint` works at the endpoint and rule level. It creates a new `network_policies` entry when needed, or merges into an existing rule that already covers the same host and port. Use it when you define where traffic can go and which binaries can send it. +`--add-endpoint` works at the endpoint and rule level. It creates a new `network_policies` entry when needed, or merges into an existing rule that already covers the same host and port. Use it when you define where traffic can go. -`--add-allow` and `--add-deny` work at the method/path rule level. They do not create binaries, and they do not create a new endpoint. They modify an existing endpoint that already has `protocol: rest` or `protocol: websocket`. +`--add-allow` and `--add-deny` work at the method/path rule level. They do not create a new endpoint. They modify an existing endpoint that already has `protocol: rest` or `protocol: websocket`. This is the practical difference: -- Use `--add-endpoint` to say "allow this binary to reach `api.github.com:443`." +- Use `--add-endpoint` to say "allow traffic to reach `api.github.com:443`." - Use `--add-allow` to say "for that existing REST endpoint, also allow `POST /repos/*/issues`." - Use `--add-deny` to say "for that existing REST endpoint, explicitly deny `POST /admin/**`." - Use `--add-allow` to say "for that existing WebSocket endpoint, also allow client text messages on `/v1/realtime/**`." @@ -252,7 +248,7 @@ For example: - `realtime.example.com:443:read-write:websocket` is valid. - `api.github.com:443::rest` is invalid. It does not mean "allow all traffic." An L7 endpoint with `protocol` but no `access` or `rules` is rejected when the policy loads. -Endpoint options belong to the individual `--add-endpoint` spec. When you pass multiple `--add-endpoint` flags in one command, every `--binary` value applies to every added endpoint in that command. If different endpoints need different binaries, use separate `policy update` commands. +Endpoint options belong to the individual `--add-endpoint` spec. When you pass multiple `--add-endpoint` flags in one command, OpenShell applies them as one atomic merge batch. If you do not pass `--rule-name`, OpenShell generates one from the host and port, such as `allow_api_github_com_443`. @@ -295,7 +291,7 @@ Path globs follow the same semantics as YAML allow and deny rules: - `/repos/*/issues` matches one repository owner or name segment in the middle. - `/repos/**` matches everything under `/repos/`. -The rule-level commands only modify method and path constraints. They do not change binaries, hostnames, ports, protocol settings, or WebSocket message payload matching. +The rule-level commands only modify method and path constraints. They do not change hostnames, ports, protocol settings, or WebSocket message payload matching. ### Common Workflows @@ -309,12 +305,10 @@ Use `--add-endpoint` when you need a new host and port and do not need REST insp openshell policy update demo \ --add-endpoint pypi.org:443 \ --add-endpoint files.pythonhosted.org:443 \ - --binary /usr/bin/pip \ - --binary /usr/local/bin/uv \ --wait ``` -This creates or merges endpoint entries and binds them to the listed binaries. It does not create inspected method/path rules. +This creates or merges endpoint entries. It does not create inspected method/path rules. #### Create a REST endpoint with a base allow set @@ -323,7 +317,6 @@ Use `--add-endpoint` first when the endpoint does not exist yet. ```shell openshell policy update demo \ --add-endpoint api.github.com:443:read-only:rest:enforce \ - --binary /usr/bin/gh \ --wait ``` @@ -339,7 +332,7 @@ openshell policy update demo \ --wait ``` -This keeps the existing endpoint definition and appends one new allow rule. It does not add binaries or change the endpoint host and port. +This keeps the existing endpoint definition and appends one new allow rule. It does not change the endpoint host and port. #### Add a REST deny rule under an allowed endpoint @@ -360,7 +353,6 @@ Use `--add-endpoint` with `protocol: websocket` when the destination is an RFC 6 ```shell openshell policy update demo \ --add-endpoint realtime.example.com:443:read-write:websocket:enforce:websocket-credential-rewrite \ - --binary /usr/bin/node \ --wait ``` @@ -446,12 +438,11 @@ openshell settings get ## Debug Denied Requests -Check `openshell logs --tail --source sandbox` for the denied host, path, and binary. +Check `openshell logs --tail --source sandbox` for the denied host and path. When triaging denied requests, check: - Destination host and port to confirm which endpoint is missing. -- Calling binary path to confirm which `binaries` entry needs to be added or adjusted. - HTTP method and path for REST endpoints, or `GET` / `WEBSOCKET_TEXT` and the upgraded request path for WebSocket endpoints, to confirm which `rules` entry needs to be added or adjusted. Then push the updated policy as described above. @@ -479,9 +470,6 @@ Allow `pip install` and `uv pip install` to reach PyPI: port: 443 - host: files.pythonhosted.org port: 443 - binaries: - - { path: /usr/bin/pip } - - { path: /usr/local/bin/uv } ``` Endpoints without `protocol` use TCP passthrough, where the proxy allows the stream without inspecting payloads. If the stream is HTTP and TLS is auto-terminated, the proxy can still rewrite configured credential placeholders and closes keep-alive passthrough tunnels on policy reload before forwarding another request. WebSocket text-frame policy requires an explicit `protocol: websocket` endpoint. WebSocket payload credential rewrite can also be enabled on a `protocol: rest` compatibility endpoint with `websocket_credential_rewrite: true`. REST request body credential rewrite requires an inspected `protocol: rest` endpoint with `request_body_credential_rewrite: true`. @@ -537,9 +525,6 @@ For an end-to-end walkthrough that combines this policy with a GitHub credential deny_rules: - operation_type: mutation fields: [deleteRepository, deleteRef, updateBranchProtectionRule] - binaries: - - { path: /usr/local/bin/claude } - - { path: /usr/bin/gh } ``` Endpoints with `protocol: rest` enable HTTP request inspection and can opt in to supported text request body credential rewrite. Endpoints with `protocol: websocket` validate WebSocket upgrades and inspect client text messages on the upgraded request path. WebSocket endpoints can also classify GraphQL-over-WebSocket operation messages with the same operation rules used by GraphQL-over-HTTP. Endpoints with `protocol: graphql` parse GraphQL-over-HTTP payloads before evaluating rules. The endpoint-level `path` field lets these protocols share `api.github.com:443` without treating GraphQL payloads as plain REST `POST /graphql` requests. @@ -567,8 +552,6 @@ REST rules can also constrain query parameter values: slug: "skill-*" version: any: ["1.*", "2.*"] - binaries: - - { path: /usr/bin/curl } ``` `query` matchers are case-sensitive and run on decoded values. If a request has duplicate keys (for example, `tag=a&tag=b`), every value for that key must match the configured glob(s). @@ -599,8 +582,6 @@ GraphQL endpoint policies currently require full policy YAML applied with `opens deny_rules: - operation_type: mutation fields: [deleteRepository] - binaries: - - { path: /usr/bin/gh } ``` For allow rules, every selected root field in an operation must match one of the configured `fields` globs. For deny rules, one matching root field blocks the request. Batched GraphQL requests are fail-closed: if any operation is malformed, denied, or unregistered, the whole HTTP request is denied. @@ -631,8 +612,6 @@ Some APIs carry GraphQL operations over RFC 6455 WebSockets, commonly for subscr operation_type: query fields: [viewer] websocket_credential_rewrite: true - binaries: - - { path: /usr/bin/node } ``` When a WebSocket endpoint has GraphQL operation policy, client operation messages are fail-closed on malformed JSON, unsupported message types, parse errors, unregistered hash-only persisted queries, or unallowed operations. Use GraphQL operation rules for client messages rather than a raw `WEBSOCKET_TEXT` allow rule. Protocol lifecycle messages such as `connection_init`, `ping`, `pong`, and `complete` are allowed without payload logging; if `websocket_credential_rewrite: true` is set, placeholders inside those text messages are resolved before forwarding. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 12ad00520..c35553c63 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -41,7 +41,7 @@ The CONNECT proxy and OPA policy engine enforce all network controls at the gate Every outbound connection from the sandbox goes through the CONNECT proxy. The proxy evaluates each connection against the OPA policy engine. -If no `network_policies` entry matches the destination host, port, and calling binary, the proxy denies the connection. +If no `network_policies` entry matches the destination host and port, the proxy denies the connection. | Aspect | Detail | |---|---| @@ -77,18 +77,9 @@ This provides defense-in-depth: even if a container escape vulnerability exists, | Risk if enabled with GPU | NVIDIA device plugin compatibility with user namespaces is unverified. OpenShell logs a warning when both GPU and user namespaces are active on the same sandbox. | | Recommendation | Enable on non-GPU clusters running Kubernetes with user namespace support available (1.33+ beta, 1.36+ GA) for stronger host isolation. Test GPU workloads separately before enabling on GPU clusters. | -### Binary Identity Binding +### Binary Allowlisting (Removed) -The proxy identifies which binary initiated each connection by reading `/proc//exe` (the kernel-trusted executable path). -It walks the process tree for ancestor binaries and parses `/proc//cmdline` for script interpreters. -The proxy SHA256-hashes each binary on first use (trust-on-first-use). If someone replaces a binary mid-session, the hash mismatch triggers an immediate deny. - -| Aspect | Detail | -|---|---| -| Default | Every `network_policies` entry requires a `binaries` list. Only listed binaries can reach the associated endpoints. Binary paths support glob patterns (`*` for one path component, `**` for recursive). | -| What you can change | Add binaries to an endpoint entry. Use glob patterns for directory-scoped access (for example, `/sandbox/.vscode-server/**`). | -| Risk if relaxed | Broad glob patterns (like `/**`) allow any binary to reach the endpoint, defeating the purpose of binary-scoped enforcement. | -| Recommendation | Scope binaries to the specific executables that need each endpoint. Use narrow globs when the exact path varies (for example, across Python virtual environments). | +Binary path allowlisting was previously supported to restrict which process paths could reach each endpoint, using `/proc//exe` lookups and SHA-256 TOFU hashing. This control was removed because it is bypassable: runtime injection via environment variables such as `BUN_OPTIONS` or `NODE_OPTIONS`, or the dynamic linker (`LD_PRELOAD`), allows arbitrary code to execute inside a trusted binary's process while the kernel-reported executable path remains unchanged. The `binaries:` field in policy YAML is accepted for backward compatibility and silently ignored. Policy enforcement is now destination-based only (host and port). ### L4-Only vs L7 Inspection @@ -96,7 +87,7 @@ The `protocol` field on an endpoint controls whether the proxy inspects individu | Aspect | Detail | |---|---| -| Default | Endpoints without a `protocol` field use L4-only enforcement: the proxy checks host, port, and binary, then relays the TCP stream without inspecting payloads. | +| Default | Endpoints without a `protocol` field use L4-only enforcement: the proxy checks host and port, then relays the TCP stream without inspecting payloads. | | What you can change | Add `protocol: rest` to enable per-request HTTP method/path inspection, `protocol: websocket` to inspect RFC 6455 upgrade handshakes and client text messages, or `protocol: graphql` to inspect GraphQL-over-HTTP operation type, operation name, and root fields. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket messages. Pair inspected protocols with `rules` or access presets (`full`, `read-only`, `read-write`). REST endpoints that need credential placeholders in supported text request bodies can set `request_body_credential_rewrite: true`. | | Risk if relaxed | L4-only endpoints allow the agent to send any data through the tunnel after the initial connection is permitted. The proxy cannot see HTTP methods, paths, or GraphQL operations. Adding `access: full` with L7 inspection enables observability but permits all inspected actions. | | Recommendation | Use `protocol: rest` with specific `rules` for APIs where intent is encoded in method and path. Add `request_body_credential_rewrite: true` only for REST APIs that require OpenShell-managed credentials in UTF-8 JSON, form, or text request bodies. Use `protocol: graphql` for GraphQL-over-HTTP APIs where destructive operations are body-encoded. Use `protocol: websocket` for RFC 6455 endpoints, with explicit `GET` and `WEBSOCKET_TEXT` rules for raw text protocols or explicit GraphQL operation rules for GraphQL-over-WebSocket. Prefer `access: read-only` or explicit allowlists, and deny hash-only persisted queries unless you maintain a trusted registry. Omit `protocol` for non-HTTP protocols. For WebSocket endpoints that must carry placeholder credentials in client text frames, add `websocket_credential_rewrite: true`. | @@ -283,10 +274,9 @@ The following patterns weaken security without providing meaningful benefit. | Mistake | Why it matters | What to do instead | |---------|---------------|-------------------| -| Omitting an inspected protocol on REST or WebSocket API endpoints | Without `protocol: rest` or `protocol: websocket`, the proxy uses L4-only enforcement. It allows the TCP stream through after checking host, port, and binary, but cannot inspect individual HTTP requests or WebSocket text messages. | Add `protocol: rest` or `protocol: websocket` with specific `rules` to enable method and path control. | +| Omitting an inspected protocol on REST or WebSocket API endpoints | Without `protocol: rest` or `protocol: websocket`, the proxy uses L4-only enforcement. It allows the TCP stream through after checking host and port, but cannot inspect individual HTTP requests or WebSocket text messages. | Add `protocol: rest` or `protocol: websocket` with specific `rules` to enable method and path control. | | Using `access: full` when finer rules would suffice | `access: full` with `protocol: rest` or `protocol: websocket` enables inspection but allows all methods and paths for that protocol. | Use `access: read-only` or explicit `rules` to restrict what the agent can do at the L7 level. | | Adding endpoints permanently when operator approval would suffice | Adding endpoints to the policy YAML makes them permanently reachable across all instances. | Use operator approval. Approved endpoints persist within the sandbox instance but reset on re-creation. | -| Using broad binary globs | A glob like `/**` allows any binary to reach the endpoint, defeating binary-scoped enforcement. | Scope globs to specific directories (for example, `/sandbox/.vscode-server/**`). | | Skipping TLS termination on HTTPS APIs | Setting `tls: skip` disables credential injection and L7 inspection. | Use the default auto-detect behavior unless the upstream requires client-certificate mTLS. | | Setting `enforcement: enforce` before auditing | Jumping to `enforce` without first running in `audit` mode risks breaking the agent's workflow. | Start with `audit`, review the logs, and switch to `enforce` after you validate the rules. | diff --git a/e2e/policy-advisor/policy.template.yaml b/e2e/policy-advisor/policy.template.yaml index 6452cb01c..7f67a9fd3 100644 --- a/e2e/policy-advisor/policy.template.yaml +++ b/e2e/policy-advisor/policy.template.yaml @@ -24,5 +24,3 @@ network_policies: protocol: rest enforcement: enforce access: read-only - binaries: - - { path: /usr/bin/curl } diff --git a/e2e/policy-advisor/sandbox-runner.sh b/e2e/policy-advisor/sandbox-runner.sh index 947d2163c..bb459d968 100755 --- a/e2e/policy-advisor/sandbox-runner.sh +++ b/e2e/policy-advisor/sandbox-runner.sh @@ -112,11 +112,7 @@ payload = { ], } ], - "binaries": [ - { - "path": "/usr/bin/curl", - } - ], + }, } } @@ -164,7 +160,7 @@ payload = { "allow": {"method": "GET", "path": f"/{rule_id}"} }], }], - "binaries": [{"path": "/usr/bin/curl"}], + }, } }], diff --git a/e2e/python/test_sandbox_policy.py b/e2e/python/test_sandbox_policy.py index 6d797b49b..e006face1 100644 --- a/e2e/python/test_sandbox_policy.py +++ b/e2e/python/test_sandbox_policy.py @@ -57,9 +57,6 @@ def _policy_for_python_proxy_tests() -> sandbox_pb2.SandboxPolicy: endpoints=[ sandbox_pb2.NetworkEndpoint(host="api.openai.com", port=443) ], - binaries=[ - sandbox_pb2.NetworkBinary(path="/sandbox/.uv/python/**/python*") - ], ) }, ) @@ -429,7 +426,6 @@ def test_l4_no_policy_denies_all( endpoints=[ sandbox_pb2.NetworkEndpoint(host="example.com", port=443), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -451,7 +447,6 @@ def test_l4_wildcard_binary_allows_any_binary( endpoints=[ sandbox_pb2.NetworkEndpoint(host="api.anthropic.com", port=443), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -483,7 +478,6 @@ def test_l4_binary_restricted_denies_wrong_binary( endpoints=[ sandbox_pb2.NetworkEndpoint(host="api.anthropic.com", port=443), ], - binaries=[sandbox_pb2.NetworkBinary(path="/usr/bin/curl")], ), }, ) @@ -506,7 +500,6 @@ def test_l4_wrong_port_denied( endpoints=[ sandbox_pb2.NetworkEndpoint(host="api.anthropic.com", port=443), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -539,16 +532,12 @@ def test_l4_cross_policy_denied( endpoints=[ sandbox_pb2.NetworkEndpoint(host="api.anthropic.com", port=443), ], - binaries=[ - sandbox_pb2.NetworkBinary(path="/sandbox/.uv/python/**/python*") - ], ), "other": sandbox_pb2.NetworkPolicyRule( name="other", endpoints=[ sandbox_pb2.NetworkEndpoint(host="example.com", port=443), ], - binaries=[sandbox_pb2.NetworkBinary(path="/usr/bin/curl")], ), }, ) @@ -589,7 +578,6 @@ def send_get_to_proxy() -> str: endpoints=[ sandbox_pb2.NetworkEndpoint(host="example.com", port=443), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -611,7 +599,6 @@ def test_l4_log_fields( endpoints=[ sandbox_pb2.NetworkEndpoint(host="api.anthropic.com", port=443), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -660,7 +647,6 @@ def test_ssrf_blocks_loopback_despite_policy_allow( endpoints=[ sandbox_pb2.NetworkEndpoint(host="127.0.0.1", port=80), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -682,7 +668,6 @@ def test_ssrf_blocks_metadata_endpoint_despite_policy_allow( endpoints=[ sandbox_pb2.NetworkEndpoint(host="169.254.169.254", port=80), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -710,7 +695,6 @@ def test_ssrf_log_shows_blocked_address( endpoints=[ sandbox_pb2.NetworkEndpoint(host="127.0.0.1", port=80), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -766,7 +750,6 @@ def test_ssrf_allowed_ips_permits_private_ip( allowed_ips=["10.200.0.0/24"], ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -800,7 +783,6 @@ def test_ssrf_allowed_ips_hostless_permits_private_ip( allowed_ips=["10.200.0.0/24"], ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -830,7 +812,6 @@ def test_ssrf_private_ip_allowed_with_literal_ip_host( # No allowed_ips — but host is a literal IP, so implicit sandbox_pb2.NetworkEndpoint(host="10.200.0.1", port=19999), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -867,7 +848,6 @@ def test_ssrf_loopback_blocked_even_with_allowed_ips( allowed_ips=["127.0.0.0/8"], ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -918,7 +898,6 @@ def test_l7_tls_full_access_allows_all( access="full", ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -954,7 +933,6 @@ def test_l7_tls_read_only_denies_post( access="read-only", ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -998,7 +976,6 @@ def test_l7_tls_audit_mode_allows_but_logs( access="read-only", ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1045,7 +1022,6 @@ def test_l7_tls_explicit_path_rules( ], ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1106,7 +1082,6 @@ def check_ca_env() -> str: endpoints=[ sandbox_pb2.NetworkEndpoint(host="example.com", port=443), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1139,7 +1114,6 @@ def test_l7_tls_deny_response_format( access="read-only", ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1183,7 +1157,6 @@ def test_l7_tls_log_fields( access="full", ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1243,7 +1216,6 @@ def test_l7_query_matchers_enforced( ], ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1340,7 +1312,6 @@ def test_l7_rule_without_query_matcher_allows_any_query_params( ], ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1392,7 +1363,6 @@ def test_forward_proxy_allows_private_ip_with_allowed_ips( allowed_ips=["10.200.0.0/24"], ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1431,7 +1401,6 @@ def test_forward_proxy_allows_private_ip_host_without_allowed_ips( port=_FORWARD_PROXY_PORT, ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1468,7 +1437,6 @@ def test_forward_proxy_rejects_https_scheme( allowed_ips=["10.200.0.0/24"], ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1504,7 +1472,6 @@ def test_forward_proxy_denied_no_policy_match( allowed_ips=["10.200.0.0/24"], ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1543,7 +1510,6 @@ def test_forward_proxy_public_ip_denied( allowed_ips=["93.184.0.0/16"], ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1574,7 +1540,6 @@ def test_forward_proxy_log_fields( allowed_ips=["10.200.0.0/24"], ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1670,7 +1635,6 @@ def test_baseline_enrichment_missing_filesystem_policy( endpoints=[ sandbox_pb2.NetworkEndpoint(host="example.com", port=443), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ), @@ -1716,7 +1680,6 @@ def test_baseline_enrichment_incomplete_filesystem_policy( endpoints=[ sandbox_pb2.NetworkEndpoint(host="example.com", port=443), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ), @@ -1765,7 +1728,6 @@ def test_multi_port_allows_all_listed_ports( host="api.anthropic.com", ports=[443, 80] ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1795,7 +1757,6 @@ def test_multi_port_denies_unlisted_port( host="api.anthropic.com", ports=[443, 80] ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1818,7 +1779,6 @@ def test_single_port_backwards_compat( endpoints=[ sandbox_pb2.NetworkEndpoint(host="api.anthropic.com", port=443), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1859,7 +1819,6 @@ def test_host_wildcard_matches_subdomain( port=443, ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1904,7 +1863,6 @@ def test_host_wildcard_rejects_bare_domain( port=443, ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1935,7 +1893,6 @@ def test_host_wildcard_rejects_deep_subdomain( port=443, ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -1975,7 +1932,6 @@ def test_overlapping_policies_do_not_crash_opa( port=_FORWARD_PROXY_PORT, ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), "approved_rule": sandbox_pb2.NetworkPolicyRule( name="approved_rule", @@ -1986,7 +1942,6 @@ def test_overlapping_policies_do_not_crash_opa( allowed_ips=["10.200.0.0/24"], ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) @@ -2023,7 +1978,6 @@ def test_overlapping_policies_l7_connect_does_not_crash( access="read-only", ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), "auto_approved_api": sandbox_pb2.NetworkPolicyRule( name="auto_approved_api", @@ -2036,7 +1990,6 @@ def test_overlapping_policies_l7_connect_does_not_crash( access="read-only", ), ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], ), }, ) diff --git a/e2e/rust/tests/forward_proxy_graphql_l7.rs b/e2e/rust/tests/forward_proxy_graphql_l7.rs index aeb3648b0..e0201c19b 100644 --- a/e2e/rust/tests/forward_proxy_graphql_l7.rs +++ b/e2e/rust/tests/forward_proxy_graphql_l7.rs @@ -115,10 +115,6 @@ network_policies: deny_rules: - operation_type: mutation fields: [deleteRepository] - binaries: - - path: /usr/bin/python* - - path: /usr/local/bin/python* - - path: /sandbox/.uv/python/*/bin/python* "# ); file.write_all(policy.as_bytes()) diff --git a/e2e/rust/tests/forward_proxy_l7_bypass.rs b/e2e/rust/tests/forward_proxy_l7_bypass.rs index 6cbaca1eb..c0a628ac6 100644 --- a/e2e/rust/tests/forward_proxy_l7_bypass.rs +++ b/e2e/rust/tests/forward_proxy_l7_bypass.rs @@ -81,11 +81,6 @@ network_policies: - allow: method: GET path: /allowed - binaries: - - path: /usr/bin/curl - - path: /usr/bin/python* - - path: /usr/local/bin/python* - - path: /sandbox/.uv/python/*/bin/python* "# ); file.write_all(policy.as_bytes()) diff --git a/e2e/rust/tests/host_gateway_alias.rs b/e2e/rust/tests/host_gateway_alias.rs index 2dbdbf1dc..3bb985079 100644 --- a/e2e/rust/tests/host_gateway_alias.rs +++ b/e2e/rust/tests/host_gateway_alias.rs @@ -177,8 +177,6 @@ network_policies: - "172.0.0.0/8" - "192.168.0.0/16" - "fc00::/7" - binaries: - - path: /usr/bin/curl "# ); file.write_all(policy.as_bytes()) diff --git a/e2e/rust/tests/live_policy_update.rs b/e2e/rust/tests/live_policy_update.rs index c60b29548..eb2c19afd 100644 --- a/e2e/rust/tests/live_policy_update.rs +++ b/e2e/rust/tests/live_policy_update.rs @@ -52,8 +52,6 @@ fn write_policy(hosts: &[&str]) -> Result { endpoints: - host: {host} port: 443 - binaries: - - path: "/**" "# ); } diff --git a/e2e/rust/tests/websocket_conformance.rs b/e2e/rust/tests/websocket_conformance.rs index 65ba19aa1..28d99610a 100644 --- a/e2e/rust/tests/websocket_conformance.rs +++ b/e2e/rust/tests/websocket_conformance.rs @@ -292,10 +292,6 @@ network_policies: - "172.0.0.0/8" - "192.168.0.0/16" - "fc00::/7" - binaries: - - path: /usr/bin/python* - - path: /usr/local/bin/python* - - path: /sandbox/.uv/python/*/bin/python* "# ); file.write_all(policy.as_bytes()) diff --git a/examples/agent-driven-policy-management/policy.template.yaml b/examples/agent-driven-policy-management/policy.template.yaml index e920277b5..6c3890d4a 100644 --- a/examples/agent-driven-policy-management/policy.template.yaml +++ b/examples/agent-driven-policy-management/policy.template.yaml @@ -33,10 +33,6 @@ network_policies: - { host: auth.openai.com, port: 443, protocol: rest, enforcement: enforce, access: full } - { host: chatgpt.com, port: 443, protocol: rest, enforcement: enforce, access: full } - { host: ab.chatgpt.com, port: 443, protocol: rest, enforcement: enforce, access: full } - binaries: - - { path: /usr/bin/codex } - - { path: /usr/bin/node } - - { path: "/usr/lib/node_modules/@openai/**" } codex_plugins: name: codex-plugins @@ -52,10 +48,6 @@ network_policies: - allow: method: POST path: "/openai/plugins.git/git-upload-pack" - binaries: - - { path: /usr/bin/git } - - { path: /usr/lib/git-core/git-remote-http } - - { path: "/usr/lib/node_modules/@openai/**" } github_api_readonly: name: github-api-readonly @@ -65,5 +57,3 @@ network_policies: protocol: rest enforcement: enforce access: read-only - binaries: - - { path: /usr/bin/curl } diff --git a/examples/local-inference/sandbox-policy.yaml b/examples/local-inference/sandbox-policy.yaml index 79fde8ea2..aaef4f945 100644 --- a/examples/local-inference/sandbox-policy.yaml +++ b/examples/local-inference/sandbox-policy.yaml @@ -32,8 +32,6 @@ network_policies: endpoints: - { host: pypi.org, port: 443 } - { host: files.pythonhosted.org, port: 443 } - binaries: - - path: /usr/bin/python3.13 # Direct access to the NVIDIA inference API (L7 TLS intercept). # Used to verify that the L7 REST relay path streams responses correctly @@ -47,5 +45,3 @@ network_policies: tls: terminate enforcement: enforce access: full - binaries: - - path: /usr/bin/python3.13 diff --git a/examples/multi-agent-notepad/policy.template.yaml b/examples/multi-agent-notepad/policy.template.yaml index bb1286367..fd265a08c 100644 --- a/examples/multi-agent-notepad/policy.template.yaml +++ b/examples/multi-agent-notepad/policy.template.yaml @@ -23,10 +23,6 @@ network_policies: - { host: auth.openai.com, port: 443, protocol: rest, enforcement: enforce, access: full } - { host: chatgpt.com, port: 443, protocol: rest, enforcement: enforce, access: full } - { host: ab.chatgpt.com, port: 443, protocol: rest, enforcement: enforce, access: full } - binaries: - - { path: /usr/bin/codex } - - { path: /usr/bin/node } - - { path: "/usr/lib/node_modules/@openai/**" } codex_plugins: name: codex-plugins @@ -42,10 +38,6 @@ network_policies: - allow: method: POST path: "/openai/plugins.git/git-upload-pack" - binaries: - - { path: /usr/bin/git } - - { path: /usr/lib/git-core/git-remote-http } - - { path: "/usr/lib/node_modules/@openai/**" } github_memory: name: github-memory @@ -67,5 +59,3 @@ network_policies: - allow: method: PUT path: "/repos/__OWNER__/__REPO__/contents/runs/__RUN_ID__/**" - binaries: - - { path: /usr/bin/curl } diff --git a/examples/sandbox-policy-quickstart/policy.yaml b/examples/sandbox-policy-quickstart/policy.yaml index 6bb0cb7d0..3dfc57d1e 100644 --- a/examples/sandbox-policy-quickstart/policy.yaml +++ b/examples/sandbox-policy-quickstart/policy.yaml @@ -28,5 +28,3 @@ network_policies: protocol: rest enforcement: enforce access: read-only - binaries: - - { path: /usr/bin/curl } diff --git a/proto/openshell.proto b/proto/openshell.proto index ca62646e3..87bf5c3f2 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -960,7 +960,10 @@ message ProviderProfile { ProviderProfileCategory category = 4; repeated ProviderProfileCredential credentials = 5; repeated openshell.sandbox.v1.NetworkEndpoint endpoints = 6; - repeated openshell.sandbox.v1.NetworkBinary binaries = 7; + // Field 7 (binaries / NetworkBinary) removed: binary allowlisting has been + // removed as a security control (bypassable via LD_PRELOAD). + reserved 7; + reserved "binaries"; bool inference_capable = 8; } @@ -1078,8 +1081,11 @@ message PolicyMergeOperation { RemoveNetworkRule remove_rule = 3; AddDenyRules add_deny_rules = 4; AddAllowRules add_allow_rules = 5; - RemoveNetworkBinary remove_binary = 6; + // Field 6 (remove_binary / RemoveNetworkBinary) removed: binary allowlisting + // was removed as a security control (bypassable via LD_PRELOAD). } + reserved 6; + reserved "remove_binary"; } message AddNetworkRule { @@ -1109,10 +1115,9 @@ message AddAllowRules { repeated openshell.sandbox.v1.L7Rule rules = 3; } -message RemoveNetworkBinary { - string rule_name = 1; - string binary_path = 2; -} +// RemoveNetworkBinary was removed. Binary allowlisting has been removed +// as a security control (bypassable via LD_PRELOAD). +// message RemoveNetworkBinary { string rule_name = 1; string binary_path = 2; } // Update sandbox policy response. message UpdateConfigResponse { @@ -1396,10 +1401,9 @@ message DenialSummary { string host = 2; // Denied destination port. uint32 port = 3; - // Binary that attempted the connection. - string binary = 4; - // Process ancestor chain. - repeated string ancestors = 5; + // Fields 4 (binary) and 5 (ancestors) removed: binary allowlisting has been removed. + reserved 4, 5; + reserved "binary", "ancestors"; // Denial reason from OPA evaluation. string deny_reason = 6; // First denial timestamp (ms since epoch). @@ -1414,8 +1418,9 @@ message DenialSummary { uint32 total_count = 11; // Distinct cmdline strings observed (sanitized of credentials). repeated string sample_cmdlines = 12; - // SHA-256 of the binary for audit trail. - string binary_sha256 = 13; + // Field 13 (binary_sha256) removed: binary allowlisting has been removed. + reserved 13; + reserved "binary_sha256"; // True if emitted by stale-flush rather than threshold. bool persistent = 14; // Denial category: "l4_deny", "l7_deny", "l7_audit", "ssrf". @@ -1458,8 +1463,9 @@ message PolicyChunk { int64 first_seen_ms = 14; // Most recent time this endpoint was re-proposed (ms since epoch). int64 last_seen_ms = 15; - // Binary path that triggered the denial (denormalized for display convenience). - string binary = 16; + // Field 16 (binary) removed: binary allowlisting has been removed. + reserved 16; + reserved "binary"; // Validation verdict from gateway-side static checks (prover output). // Free-form summary string for human consumption in the inbox card. // Empty until the prover has run for this chunk. @@ -1670,8 +1676,9 @@ message DraftChunkPayload { string host = 7; // Denormalized endpoint port for dedup and display. int32 port = 8; - // Binary path that triggered the denial. - string binary = 9; + // Field 9 (binary) removed: binary allowlisting has been removed. + reserved 9; + reserved "binary"; // Current draft version for the owning sandbox. int64 draft_version = 10; // Gateway prover verdict for this chunk; empty until prover runs. @@ -1710,7 +1717,9 @@ message StoredDraftChunk { optional int64 decided_at_ms = 11; string host = 12; int32 port = 13; - string binary = 14; + // Field 14 (binary) removed: binary allowlisting has been removed. + reserved 14; + reserved "binary"; int32 hit_count = 15; int64 first_seen_ms = 16; int64 last_seen_ms = 17; diff --git a/proto/sandbox.proto b/proto/sandbox.proto index b40d95cb1..b42b1d7a1 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -57,8 +57,10 @@ message NetworkPolicyRule { string name = 1; // Allowed endpoint (host:port) pairs. repeated NetworkEndpoint endpoints = 2; - // Allowed binary identities. - repeated NetworkBinary binaries = 3; + // Binary allowlisting was removed (field number 3 is reserved/tombstoned). + // Do not reuse field number 3. + reserved 3; + reserved "binaries"; } // A network endpoint (host + port) with optional L7 inspection config. @@ -192,12 +194,9 @@ message L7QueryMatcher { repeated string any = 2; } -// A binary identity for network policy matching. -message NetworkBinary { - string path = 1; - // Deprecated: the harness concept has been removed. This field is ignored. - bool harness = 2 [deprecated = true]; -} +// REMOVED MESSAGE: NetworkBinary (was a top-level message, no field numbers to reserve). +// Binary allowlisting via /proc//exe was removed as bypassable via LD_PRELOAD. +// Do not reintroduce a message named NetworkBinary. // Request to get sandbox settings by sandbox ID. message GetSandboxConfigRequest { diff --git a/providers/claude-code.yaml b/providers/claude-code.yaml index b835f3d45..ca6405e17 100644 --- a/providers/claude-code.yaml +++ b/providers/claude-code.yaml @@ -29,4 +29,3 @@ endpoints: protocol: rest access: read-write enforcement: enforce -binaries: [/usr/bin/claude, /usr/local/bin/claude] diff --git a/providers/github.yaml b/providers/github.yaml index cc24ae922..681a5a58c 100644 --- a/providers/github.yaml +++ b/providers/github.yaml @@ -23,4 +23,3 @@ endpoints: protocol: rest access: read-only enforcement: enforce -binaries: [/usr/bin/gh, /usr/local/bin/gh, /usr/bin/git, /usr/local/bin/git] diff --git a/providers/nvidia.yaml b/providers/nvidia.yaml index 42ea7f7df..15191a56e 100644 --- a/providers/nvidia.yaml +++ b/providers/nvidia.yaml @@ -19,4 +19,3 @@ endpoints: protocol: rest access: read-write enforcement: enforce -binaries: [/usr/bin/curl, /usr/local/bin/curl] diff --git a/scripts/smoke-test-network-policy.sh b/scripts/smoke-test-network-policy.sh index 3e82980aa..5e0e1890e 100755 --- a/scripts/smoke-test-network-policy.sh +++ b/scripts/smoke-test-network-policy.sh @@ -209,8 +209,6 @@ network_policies: protocol: rest enforcement: enforce access: full - binaries: - - { path: /usr/bin/curl } YAML ) @@ -234,8 +232,6 @@ network_policies: protocol: rest enforcement: enforce access: read-only - binaries: - - { path: /usr/bin/curl } YAML ) @@ -259,8 +255,6 @@ network_policies: protocol: rest enforcement: enforce access: full - binaries: - - { path: /usr/bin/curl } YAML ) @@ -282,8 +276,6 @@ network_policies: - host: api.github.com port: 443 tls: skip - binaries: - - { path: /usr/bin/curl } YAML )