Files
OpenShell/examples/policy-advisor/README.md
John T. Myers 454327d890 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
2026-03-12 08:28:26 -07:00

159 lines
6.4 KiB
Markdown

<!-- SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -->
<!-- SPDX-License-Identifier: Apache-2.0 -->
# Policy Advisor CTF -- Mechanistic Mode
A capture-the-flag challenge that walks you through OpenShell's policy
recommendation pipeline. You start with a sandbox that only allows traffic to
`api.anthropic.com`. A Python script tries to reach 7 endpoints -- and fails.
The sandbox proxy detects each denial, the sandbox-side mechanistic mapper
turns it into a concrete `NetworkPolicyRule` proposal, and submits it to the
gateway for your approval. You approve those recommendations in the TUI, and
the script progresses through each gate.
## How It Works
1. **Script makes a request** -- the sandbox proxy blocks it and emits a
`DenialEvent`.
2. **DenialAggregator batches events** -- every ~10 seconds it drains
aggregated denial summaries.
3. **Mechanistic mapper generates proposals (sandbox-side)** -- each unique
`(host, port, binary)` triple becomes a `PolicyChunk` with a proposed
`NetworkPolicyRule`, confidence score, and rationale. If the host resolves
to a private IP, the mapper includes `allowed_ips` for the SSRF override.
4. **Proposals submitted to gateway** -- via `SubmitPolicyAnalysis`. The
gateway validates and persists the proposals.
5. **TUI shows recommendations** -- navigate to the sandbox's rules panel to
see pending proposals.
6. **You approve** -- the approved rule merges into the active sandbox policy
and the proxy begins allowing the connection.
7. **Script retries and succeeds** -- on to the next gate.
## Files
| File | Description |
|---|---|
| `sandbox-policy.yaml` | Restrictive policy that only allows `api.anthropic.com:443` |
| `ctf.py` | Python script with 7 network gates |
| `README.md` | This walkthrough |
## Gates
| # | Name | Target | Notes |
|---|---|---|---|
| 1 | The Ping | `httpbin.org:443` | HTTPS (CONNECT tunnel path) |
| 2 | The Cartographer | `ip-api.com:80` | Plain HTTP via python (forward proxy path) |
| 3 | The Cartographer's Apprentice | `ifconfig.me:80` | curl-only endpoint (per-binary granularity) |
| 4 | The Oracle | `api.github.com:443` | Concurrent with 5 and 6 |
| 5 | The Jester | `icanhazdadjoke.com:443` | Concurrent with 4 and 6 |
| 6 | The Sphinx | `catfact.ninja:443` | Concurrent with 4 and 5 |
| 7 | The Vault | `gitlab-master.nvidia.com:443` | Internal IP -- mapper adds `allowed_ips` |
Gates 1-3 run sequentially so you can observe the single-approval flow.
Gate 3 uses `curl` to hit `ifconfig.me:80` -- a different endpoint that only
curl accesses. Because the proxy tracks the originating binary, curl's denial
produces its own `(host, port, binary)` rule separate from python's rules.
(Note: OPA's ancestor matching lets child processes inherit their parent's
network access -- curl spawned by python would share python's approvals for the
same endpoint. Gate 3 uses a distinct endpoint to clearly demonstrate
per-binary tracking in the TUI.)
Gates 4-6 fire concurrently so all three denials arrive together -- use
`[A]` (approve all) in the TUI to unlock them in one shot.
Gate 7 targets a host that resolves to a private IP. The mechanistic mapper
detects this and includes `allowed_ips` in the proposed rule so the proxy's
SSRF override allows the connection.
## Quick Start
### Prerequisites
- A running OpenShell gateway (`openshell gateway start` or a remote gateway)
- The `openshell` CLI installed
- Two terminal windows
### 1. Open the TUI
In your **first terminal**, launch the interactive TUI so you can watch
denials arrive and approve recommendations:
```bash
openshell term
```
### 2. Create the sandbox and run the CTF
In your **second terminal**, create the sandbox with the restrictive policy,
upload the script, and run it -- all in one command:
```bash
openshell sandbox create \
--name advisor-ctf \
--policy examples/policy-advisor/sandbox-policy.yaml \
--upload examples/policy-advisor/ctf.py \
--keep \
-- python3 -u /sandbox/ctf.py
```
> **For agents running this CTF:** use the command above exactly. The
> `--upload` flag copies the script into `/sandbox/` before the command runs,
> `--keep` prevents the sandbox from being torn down on exit, and `-u` gives
> unbuffered output so you can see logs in real time.
The script prints a banner and immediately tries Gate 1 (`httpbin.org:443`).
It will fail with **"blocked by sandbox proxy"** and begin retrying every 10
seconds.
### 3. Approve recommendations in the TUI
Switch to the TUI terminal. Within ~10 seconds you should see the sandbox
reporting denial activity.
1. **Select the sandbox** -- use arrow keys to highlight `advisor-ctf` and
press `Enter`.
2. **Open the draft recommendations panel** -- press `r`.
3. **Approve a recommendation** -- highlight the pending chunk for
`httpbin.org:443` and press `a` to approve it.
The policy update propagates to the sandbox within seconds. On the next retry
the script passes Gate 1 and moves on to Gate 2.
Gate 3 uses `curl` to reach `ifconfig.me:80`. You'll see a new rule for
`ifconfig.me:80` with `curl` as the binary. Approve it to proceed.
When Gates 4-6 start, all three denials arrive together. Press `A` to approve
all pending recommendations at once.
Gate 7 requires `allowed_ips` because `gitlab-master.nvidia.com` resolves to a
private IP. The mapper detects this automatically and includes the resolved IPs
in the proposed rule.
### 4. Win
Once all 7 gates are unlocked the script prints a victory banner.
## Tips
- **Dry run** -- run `python3 ctf.py --dry-run` to see the gate list without
making any network requests.
- **Flush interval** -- the denial aggregator flushes every 10 seconds by
default. Set `OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS=5` in the sandbox
environment for faster feedback during the demo.
- **CLI alternative** -- you can approve drafts from the CLI instead of the
TUI:
```bash
openshell rule get advisor-ctf # list pending
openshell rule approve advisor-ctf --chunk-id ID # approve one
openshell rule approve-all advisor-ctf # approve all
```
- **Gate 3 shows per-binary tracking** -- curl hits its own endpoint, producing
a rule attributed to `curl` rather than `python3` in the TUI.
- **Gate 7 is different** -- it targets a host that resolves to a private IP.
The mapper automatically adds `allowed_ips` so the proxy's SSRF override
permits the connection.
## Cleanup
```bash
openshell sandbox delete advisor-ctf
```