Skip to content

Commit 795aa48

Browse files
authored
feat(policy): add policy recommendation plumbing (#204) (#222)
* feat(policy): add policy recommendation plumbing — denial aggregation, transport, approval pipeline, and mechanistic recommendations Implement the infrastructure layer for automated policy recommendations (#204): - Proto: 9 new RPCs and messages for draft policy lifecycle (submit, get, approve, reject, approve-all, edit, undo, clear, history) - Persistence: SQLite/Postgres migrations and store methods for draft_policy_chunks and denial_summaries tables - Server: Full gRPC handler implementations with mechanistic mapper that auto-generates NetworkPolicyRule proposals from denial summaries - Sandbox: DenialAggregator with MPSC channel, deduplication, periodic flush to gateway via SubmitPolicyAnalysis - CLI: 'openshell draft' subcommand with get/approve/reject/approve-all/undo/clear/history operations - TUI: Draft recommendations panel accessible from sandbox policy view - Docs: Architecture documentation in architecture/policy-advisor.md * feat(policy): add L7-aware mechanistic mapper and policy advisor CTF example Add L7 rule generation to mechanistic mapper (build_l7_rules, generalise_path, looks_like_id) with 3 new unit tests. Add examples/policy-advisor/ with a 7-gate CTF script, restrictive sandbox policy, and walkthrough README. * fix(policy): use sandbox name for denial flush and add TUI draft badges Fix denial aggregator passing sandbox UUID instead of name to SubmitPolicyAnalysis, which caused 'sandbox not found' errors on flush. Add notification badges to the TUI sandbox list and detail header showing pending draft recommendation counts. * fix(policy): deduplicate draft chunks and tolerate overlapping OPA rules Skip draft chunk creation when a pending/approved chunk already covers the same host:port endpoint, preventing duplicate rules across denial aggregator flush cycles. Rewrite three OPA complete rules (network_policy_for_request, matched_network_policy, matched_endpoint_config) to tolerate multiple matching policies without triggering a "complete rule conflict" error. network_policy_for_request becomes a boolean, matched_network_policy uses a set comprehension with min(), and matched_endpoint_config uses an array comprehension with index-0 selection. * feat(tui): interactive draft actions, highlight bar, and detail popup Rework the draft recommendations panel to match the logs UX: - Highlight bar (green accent + background) instead of arrow marker - Viewport-aware j/k scrolling with g/G for top/bottom - Enter opens a full-screen detail popup showing endpoints, binaries, rationale, security notes, and action hints Add approve/reject/approve-all draft actions: - [a] approve selected chunk, [x] reject, [A] approve all pending - Actions work from both the list view and the detail popup - gRPC calls run async; result updates status bar and refreshes data - Nav bar shows all available keybindings Fix draft count refresh: sandbox_draft_counts now refreshes on every tick (not just Dashboard), so the detail header badge updates in real time. Improve badge labels: show 'N pending' instead of a bare number in both the dashboard sandbox list and sandbox detail header. * refactor(policy): DB-level draft chunk dedup with hit counter and timestamps Replace the in-memory HashSet dedup in SubmitPolicyAnalysis with a database-level upsert. New denormalized columns on draft_policy_chunks: - host, port: extracted from proposed_rule at insert time - hit_count: incremented on conflict (same sandbox + host + port) - first_seen_ms, last_seen_ms: track when the endpoint was first and most recently proposed A partial unique index (WHERE status IN ('pending','approved')) ensures only one active chunk per endpoint per sandbox; rejected/superseded chunks don't block new proposals. Surface hit_count and first/last_seen in: - CLI: 'openshell draft get' shows 'Hits: N (first ..., last ...)' - TUI: detail popup shows hits row; list view shows 'Nx' suffix * fix(policy): optimistic retry on policy version conflicts + structured logging merge_chunk_into_policy and remove_chunk_from_policy now retry up to 5 times on UNIQUE constraint violations (version conflicts from concurrent approvals). Each attempt re-reads the latest policy, re-merges the rule, and increments the version. This eliminates the race condition where rapid successive approvals would fail with a DB error. Add structured tracing to all draft action handlers: - ApproveDraftChunk: logs rule_name, host, port, hit_count before merge and version + policy_hash after success - RejectDraftChunk: logs rule_name, host, port, reason - ApproveAllDraftChunks: logs pending_count at start, per-chunk merge progress, and final summary with chunks_approved/skipped - UndoDraftChunk: logs before/after with rule_name and version - Retry attempts log as warnings with attempt number and conflicting version * wip: forward proxy fix, mapper allowed_ips, TUI polish, CTF rewrite * fix(tui): use correct --gateway flag for ssh-proxy ProxyCommand * chore: add Docker cleanup script for stale images, volumes, and build cache * feat(tui): approve-all confirmation modal and CTF cleanup Add [A] confirmation popup that snapshots pending chunks, shows a scrollable list, and approves each chunk individually on confirm. This prevents approving chunks that arrived after the modal opened. Remove transient issue #205 reference from CTF victory banner. * fix(tui): correct import ordering for rustfmt * wip: stateful toggle model, rename to network rules Draft chunks now follow a toggle state machine: pending -> approved | rejected (initial decision) approved <-> rejected (toggle) One row per (sandbox_id, host, port) via expanded unique index. Rejecting an approved rule removes it from the active policy. Re-approving a rejected rule merges it back. Rename CLI from 'draft' to 'rule', TUI from 'Draft Recommendations' to 'Network Rules'. State-aware keybindings: approved shows [x] Revoke, rejected shows [a] Approve. Fix sandbox detail hiding delete confirmation behind pending message. * refactor(policy): move mapper sandbox-side, slim schema, per-binary granularity Move mechanistic mapper from gateway to sandbox so all analysis runs sandbox-side (N sandboxes = N independent pipelines). Gateway is now a thin validate + persist + approval layer. Architectural changes: - Move mechanistic_mapper.rs from navigator-server to navigator-sandbox - Sandbox flush flow: aggregator drains -> mapper runs -> proposals sent - Gateway SubmitPolicyAnalysis: validate + persist only, no mapper - Drop denial_summaries table (write-only, zero readers) - Consolidate migrations 003+004+005 into single 003 Schema slimming: - Drop 5 unused columns from draft_policy_chunks (stage, denial_refs, supersedes_chunk_id, analysis_mode, decided_by) - Add per-binary granularity: binary column, widen unique index to (sandbox_id, host, port, binary) - Mapper groups by (host, port, binary), one proposal per triple - Merge appends binary to existing rule; revoke removes just that binary CTF & UX: - 7-gate CTF: add Gate 3 (curl -> ifconfig.me:80) for per-binary demo - TUI shows binary short name in list, full path in detail popup - CLI output shows binary field - Idempotent rule names, hit_count accumulates real denial counts - Rationale text no longer bakes in stale denial count
1 parent 4a8346c commit 795aa48

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+6443
-124
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,3 +196,4 @@ architecture/plans
196196
# Claude
197197
.claude/settings.local.json.claude/worktrees/
198198
.claude/worktrees/
199+
rfc.md

architecture/gateway.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,20 @@ These RPCs are called by sandbox pods at startup to bootstrap themselves.
234234
| `GetSandboxPolicy` | Returns the `SandboxPolicy` from a sandbox's spec, looked up by sandbox ID. |
235235
| `GetSandboxProviderEnvironment` | Resolves provider credentials into environment variables for a sandbox. Iterates the sandbox's `spec.providers` list, fetches each `Provider`, and collects credential key-value pairs. First provider wins on duplicate keys. Skips credential keys that do not match `^[A-Za-z_][A-Za-z0-9_]*$`. |
236236

237+
#### Policy Recommendation (Network Rules)
238+
239+
These RPCs support the sandbox-initiated policy recommendation pipeline. The sandbox generates proposals via its mechanistic mapper and submits them; the gateway validates, persists, and manages the approval workflow. See [architecture/policy-advisor.md](policy-advisor.md) for the full pipeline design.
240+
241+
| RPC | Description |
242+
|-----|-------------|
243+
| `SubmitPolicyAnalysis` | Receives pre-formed `PolicyChunk` proposals from a sandbox. Validates each chunk, persists via upsert on `(sandbox_id, host, port, binary)` dedup key, notifies watch bus. |
244+
| `GetDraftPolicy` | Returns all draft chunks for a sandbox with current draft version. |
245+
| `ApproveDraftChunk` | Approves a pending or rejected chunk. Merges the proposed rule into the active policy (appends binary to existing rule or inserts new rule). |
246+
| `RejectDraftChunk` | Rejects a pending chunk or revokes an approved chunk. If revoking, removes the binary from the active policy rule. |
247+
| `ApproveAllDraftChunks` | Bulk approves all pending chunks for a sandbox. |
248+
| `EditDraftChunk` | Updates the proposed rule on a pending chunk. |
249+
| `GetDraftHistory` | Returns all chunks (including rejected) for audit trail. |
250+
237251
### Inference Service
238252

239253
Defined in `proto/inference.proto`, implemented in `crates/navigator-server/src/inference.rs` as `InferenceService`.

architecture/policy-advisor.md

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
# Policy Advisor
2+
3+
The Policy Advisor is a recommendation system that observes denied connections in a sandbox and proposes policy updates to allow legitimate traffic. It operates as a feedback loop: denials are detected, aggregated, analyzed sandbox-side, and submitted to the gateway for the user to review and approve.
4+
5+
This document covers the plumbing layer (issue #204). The LLM-powered agent harness that enriches recommendations with context-aware rationale is covered separately (issue #205).
6+
7+
## Overview
8+
9+
```mermaid
10+
flowchart LR
11+
PROXY[Sandbox Proxy] -->|DenialEvent| AGG[DenialAggregator]
12+
AGG -->|drain| MAPPER[Mechanistic Mapper]
13+
MAPPER -->|SubmitPolicyAnalysis| GW[Gateway Server]
14+
GW --> STORE[(SQLite/Postgres)]
15+
STORE --> CLI[CLI: openshell rule]
16+
STORE --> TUI[TUI: Network Rules]
17+
CLI -->|approve/reject| GW
18+
TUI -->|approve/reject| GW
19+
GW -->|merge rule| POLICY[Active Policy]
20+
```
21+
22+
The key architectural decision: **all analysis runs sandbox-side**. The gateway is a thin persistence + validation + approval layer. It never generates proposals or calls an LLM. Each sandbox handles its own analysis — N sandboxes = N independent pipelines, horizontal scale for free.
23+
24+
## Components
25+
26+
### Denial Aggregator (Sandbox Side)
27+
28+
The `DenialAggregator` (`crates/navigator-sandbox/src/denial_aggregator.rs`) runs as a background tokio task inside the sandbox supervisor. It:
29+
30+
1. Receives `DenialEvent` structs from the proxy via an unbounded MPSC channel
31+
2. Deduplicates events by `(host, port, binary)` key with running counters
32+
3. Periodically drains accumulated summaries, runs the mechanistic mapper, and submits proposals to the gateway via `SubmitPolicyAnalysis` gRPC
33+
34+
The flush interval defaults to 10 seconds (configurable via `OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS`).
35+
36+
### Denial Event Sources
37+
38+
Events are emitted at four denial points in the proxy:
39+
40+
| Source | Stage | File | Description |
41+
|--------|-------|------|-------------|
42+
| CONNECT OPA deny | `connect` | `proxy.rs` | No matching network policy rule |
43+
| CONNECT SSRF deny | `ssrf` | `proxy.rs` | Resolved IP is internal/private |
44+
| FORWARD OPA deny | `forward` | `proxy.rs` | Forward proxy policy deny |
45+
| FORWARD SSRF deny | `ssrf` | `proxy.rs` | Forward proxy SSRF check failed |
46+
47+
L7 (per-request) denials from `l7/relay.rs` are captured via tracing in the current implementation, with structured channel support planned for issue #205.
48+
49+
### Mechanistic Mapper (Sandbox Side)
50+
51+
The `mechanistic_mapper` module (`crates/navigator-sandbox/src/mechanistic_mapper.rs`) generates draft policy recommendations deterministically, without requiring an LLM:
52+
53+
1. Groups denial summaries by `(host, port, binary)` — one proposal per unique triple
54+
2. For each group, generates a `NetworkPolicyRule` allowing that endpoint for that binary
55+
3. Generates idempotent rule names via `generate_rule_name(host, port)` producing deterministic names like `allow_httpbin_org_443` — DB-level dedup handles uniqueness, no collision checking needed
56+
4. Resolves each host via DNS; if any resolved IP is private (RFC 1918, loopback, link-local), populates `allowed_ips` in the proposed endpoint for the SSRF override
57+
5. Computes confidence scores based on:
58+
- Denial count (higher count = higher confidence)
59+
- Port recognition (well-known ports like 443, 5432 get a boost)
60+
- SSRF origin (SSRF denials get lower confidence)
61+
6. Generates security notes for private IPs, database ports, and ephemeral port ranges
62+
7. If L7 request samples are present, generates specific L7 rules (method + path) with `protocol: rest` and `tls: terminate` (plumbed but not yet fed data — see issue #205)
63+
64+
The mapper runs in `flush_proposals_to_gateway` after the aggregator drains. It produces `PolicyChunk` protos that are sent alongside the raw `DenialSummary` protos to the gateway.
65+
66+
### Gateway: Validate and Persist
67+
68+
The gateway's `SubmitPolicyAnalysis` handler (`crates/navigator-server/src/grpc.rs`) is deliberately thin:
69+
70+
1. Receives proposed chunks and denial summaries from the sandbox
71+
2. Validates each chunk (rejects missing `rule_name` or `proposed_rule`)
72+
3. Extracts `(host, port, binary)` from the proposed rule for the dedup key
73+
4. Persists via upsert — `ON CONFLICT (sandbox_id, host, port, binary) DO UPDATE SET hit_count = hit_count + excluded.hit_count, last_seen_ms = excluded.last_seen_ms`
74+
5. Notifies watchers so the TUI refreshes
75+
76+
The gateway does not store denial summaries (they are included in the request for future audit trail use but not persisted today). It does not run the mapper or any analysis.
77+
78+
### Persistence
79+
80+
Draft chunks are stored in the gateway database:
81+
82+
```sql
83+
CREATE TABLE draft_policy_chunks (
84+
id TEXT PRIMARY KEY,
85+
sandbox_id TEXT NOT NULL,
86+
draft_version INTEGER NOT NULL,
87+
status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | rejected
88+
rule_name TEXT NOT NULL,
89+
proposed_rule BLOB NOT NULL, -- protobuf-encoded NetworkPolicyRule
90+
rationale TEXT NOT NULL DEFAULT '',
91+
security_notes TEXT NOT NULL DEFAULT '',
92+
confidence REAL NOT NULL DEFAULT 0.0,
93+
host TEXT NOT NULL DEFAULT '', -- denormalized for dedup
94+
port INTEGER NOT NULL DEFAULT 0,
95+
binary TEXT NOT NULL DEFAULT '', -- per-binary granularity
96+
hit_count INTEGER NOT NULL DEFAULT 1, -- accumulated real denial count
97+
first_seen_ms INTEGER NOT NULL,
98+
last_seen_ms INTEGER NOT NULL,
99+
created_at_ms INTEGER NOT NULL,
100+
decided_at_ms INTEGER
101+
);
102+
103+
-- One active chunk per (sandbox, endpoint, binary).
104+
CREATE UNIQUE INDEX idx_draft_chunks_endpoint
105+
ON draft_policy_chunks (sandbox_id, host, port, binary)
106+
WHERE status IN ('pending', 'approved', 'rejected');
107+
```
108+
109+
Schema lives in `crates/navigator-server/migrations/{sqlite,postgres}/003_create_policy_recommendations.sql`.
110+
111+
### Per-Binary Granularity
112+
113+
Each `(sandbox_id, host, port, binary)` gets its own row. Two unrelated processes hitting the same endpoint (e.g. `python3` and a separately launched `curl` both denied for `ip-api.com:80`) produce two separate rules in the TUI. Approving one doesn't approve the other. When both are approved, they share the same `NetworkPolicyRule` in the active policy with two entries in the `binaries` list. Revoking one removes only that binary from the rule; if no binaries remain, the entire rule is removed.
114+
115+
Note: OPA's `binary_allowed` rule includes ancestor matching — a child process (e.g. curl spawned by python via `subprocess.run`) inherits its parent's network access because the parent binary appears in the child's `/proc` ancestor chain. This means a child process won't generate a separate denial for endpoints its parent is already approved for. Per-binary granularity is most visible when different binaries independently access distinct endpoints.
116+
117+
## Approval Workflow
118+
119+
Draft chunks follow a toggle model:
120+
121+
```mermaid
122+
stateDiagram-v2
123+
[*] --> pending: proposed
124+
pending --> approved: approve
125+
pending --> rejected: reject
126+
approved --> rejected: revoke
127+
rejected --> approved: approve
128+
```
129+
130+
There is no "undo" — reject is the revoke. Re-denials of a rejected endpoint bump `hit_count` and `last_seen_ms` but don't change status.
131+
132+
### Approval Actions
133+
134+
| Action | CLI Command | gRPC RPC | Effect |
135+
|--------|-------------|----------|--------|
136+
| View rules | `openshell rule get <name>` | `GetDraftPolicy` | List pending/approved/rejected chunks |
137+
| Approve one | `openshell rule approve <name> --chunk-id X` | `ApproveDraftChunk` | Merge rule into active policy, mark approved |
138+
| Reject one | `openshell rule reject <name> --chunk-id X` | `RejectDraftChunk` | Mark rejected (no policy change) |
139+
| Approve all | `openshell rule approve-all <name>` | `ApproveAllDraftChunks` | Bulk approve all pending chunks |
140+
| History | `openshell rule history <name>` | `GetDraftHistory` | Show timeline of proposals and decisions |
141+
142+
### Policy Merge
143+
144+
When a chunk is approved, the server:
145+
146+
1. Decodes the chunk's `proposed_rule` (protobuf `NetworkPolicyRule`)
147+
2. Fetches the current active `SandboxPolicy`
148+
3. Looks up the rule by `rule_name` in `network_policies`:
149+
- If the rule exists, **appends** the chunk's binary to the rule's `binaries` list
150+
- If no rule exists, inserts the whole proposed rule
151+
4. Persists a new policy revision with deterministic hash (optimistic retry up to 5 attempts on version conflicts)
152+
5. Supersedes older policy versions
153+
6. Notifies watchers (triggers sandbox policy poll)
154+
155+
When a chunk is revoked (approved → rejected), the server calls `remove_chunk_from_policy`:
156+
157+
1. Finds the rule by `rule_name`
158+
2. Removes just this chunk's binary from the rule's `binaries` list
159+
3. If no binaries remain, removes the entire rule
160+
4. Persists a new policy revision
161+
162+
The sandbox picks up the new policy on its next poll cycle (default 10 seconds) and hot-reloads the OPA engine.
163+
164+
## User Interfaces
165+
166+
### CLI
167+
168+
The `openshell rule` command group provides review and approval:
169+
170+
```bash
171+
# View pending recommendations
172+
openshell rule get my-sandbox
173+
174+
# Approve a specific chunk
175+
openshell rule approve my-sandbox --chunk-id abc123
176+
177+
# Approve all pending
178+
openshell rule approve-all my-sandbox
179+
180+
# Reject a chunk
181+
openshell rule reject my-sandbox --chunk-id xyz789
182+
```
183+
184+
### TUI
185+
186+
The TUI sandbox screen includes a "Network Rules" panel accessible via `[r]` from the sandbox detail view. It displays:
187+
188+
- List of rules with endpoint, binary name (short), and status badge (pending/approved/rejected)
189+
- Hit count and first/last seen timestamps
190+
- Expanded detail popup with full binary path, rationale, security notes, and proposed rule
191+
192+
Keybindings are state-aware:
193+
- **Pending**`[a]` approve, `[x]` reject, `[A]` approve all
194+
- **Approved**`[x]` revoke
195+
- **Rejected**`[a]` approve
196+
197+
## Configuration
198+
199+
| Environment Variable | Default | Description |
200+
|---------------------|---------|-------------|
201+
| `OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS` | `10` | How often the aggregator flushes and submits proposals |
202+
| `OPENSHELL_POLICY_POLL_INTERVAL_SECS` | `10` | How often the sandbox polls for policy updates |
203+
204+
## Future Work (Issue #205)
205+
206+
The LLM PolicyAdvisor agent will run sandbox-side via `inference.local`:
207+
208+
- Wrap the mechanistic mapper with LLM-powered analysis
209+
- Generate context-aware rationale explaining *why* each rule is recommended
210+
- Group related denials into higher-level recommendations
211+
- Detect patterns (e.g., "this looks like a pip install") and suggest broader rules
212+
- Validate proposals against the local OPA engine before submission
213+
- Progressive L7 visibility: Stage 1 audit-mode rules → Stage 2 data-driven L7 refinement

architecture/sandbox.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ All paths are relative to `crates/navigator-sandbox/src/`.
1818
| `ssh.rs` | Embedded SSH server (`russh` crate) with PTY support and handshake verification |
1919
| `identity.rs` | `BinaryIdentityCache` -- SHA256 trust-on-first-use binary integrity |
2020
| `procfs.rs` | `/proc` filesystem reading for TCP peer identity resolution and ancestor chain walking |
21-
| `grpc_client.rs` | gRPC client for fetching policy, provider environment, inference route bundles, policy polling/status reporting, and log push (`CachedNavigatorClient`) |
21+
| `grpc_client.rs` | gRPC client for fetching policy, provider environment, inference route bundles, policy polling/status reporting, proposal submission, and log push (`CachedNavigatorClient`) |
22+
| `denial_aggregator.rs` | `DenialAggregator` background task -- receives `DenialEvent`s from the proxy, deduplicates by `(host, port, binary)`, drains on flush interval |
23+
| `mechanistic_mapper.rs` | Deterministic policy recommendation generator -- converts denial summaries to `PolicyChunk` proposals with confidence scores, rationale, and SSRF/private-IP detection |
2224
| `sandbox/mod.rs` | Platform abstraction -- dispatches to Linux or no-op |
2325
| `sandbox/linux/mod.rs` | Linux composition: Landlock then seccomp |
2426
| `sandbox/linux/landlock.rs` | Filesystem isolation via Landlock LSM (ABI V1) |
@@ -319,7 +321,7 @@ sequenceDiagram
319321
GW-->>PL: policy + version + hash
320322
PL->>PL: Store initial version
321323
322-
loop Every OPENSHELL_POLICY_POLL_INTERVAL_SECS (default 30)
324+
loop Every OPENSHELL_POLICY_POLL_INTERVAL_SECS (default 10)
323325
PL->>GW: GetSandboxPolicy(sandbox_id)
324326
GW-->>PL: policy + version + hash
325327
alt version > current_version
@@ -389,7 +391,7 @@ Proto messages involved:
389391
| Initial version fetch fails | Log warning, retry on next interval (poll loop continues) |
390392
| `reload_from_proto()` fails (L7 validation error) | Log warning, keep last-known-good engine, report FAILED status |
391393
| Status report RPC fails | Log warning, poll loop continues unaffected |
392-
| Poll interval env var unparseable | Fall back to default (30 seconds) |
394+
| Poll interval env var unparseable | Fall back to default (10 seconds) |
393395

394396
## Linux Enforcement
395397

architecture/security-policy.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ In gRPC mode, the sandbox spawns a background task that periodically polls the g
181181

182182
| Parameter | Default | Override |
183183
|-----------|---------|----------|
184-
| Poll interval | 30 seconds | `OPENSHELL_POLICY_POLL_INTERVAL_SECS` environment variable |
184+
| Poll interval | 10 seconds | `OPENSHELL_POLICY_POLL_INTERVAL_SECS` environment variable |
185185

186186
The poll loop:
187187

0 commit comments

Comments
 (0)