Skip to content

feat: agent-driven policy management MVP#1151

Open
zredlined wants to merge 21 commits intomainfrom
feat/agent-driven-policy-management
Open

feat: agent-driven policy management MVP#1151
zredlined wants to merge 21 commits intomainfrom
feat/agent-driven-policy-management

Conversation

@zredlined
Copy link
Copy Markdown
Collaborator

@zredlined zredlined commented May 4, 2026

Summary

MVP of agent-driven policy management — the loop where an in-sandbox agent
hits a policy block, drafts a narrow rule, submits it via policy.local,
and the developer approves out-of-band. Implements the vertical slice
described in RFC 0001 and
tracked under #1062, with the build plan in
architecture/plans/agent-driven-policy-management-v1.md.

The full loop demoed end-to-end with Codex inside an OpenShell sandbox
writes a markdown file to GitHub via a proposal-and-approval round-trip
that takes about two minutes total.

Related Issue

Refs #1062.

Demo output

Real run from a local dev gateway against a scratch GitHub repo. The whole
loop is bash examples/agent-driven-policy-management/demo.sh with no
arguments — defaults resolve from gh and ~/.codex/auth.json.

==> Preflight

  gateway:  connected · 0.0.37-dev.66+g91014bce
  github:   <owner>/openshell-policy-demo @ main (ab75c15)
  providers created (codex, github) — credentials injected as env vars only

==> Run summary

  repo:     <owner>/openshell-policy-demo
  branch:   main
  target:   openshell-policy-advisor-demo/<run-id>.md
  sandbox:  policy-demo-<run-id>

==> Launching sandbox; agent will hit a policy block and draft a proposal

  initial policy:  read-only access to api.github.com (no PUT)
  agent task:      PUT /repos/<owner>/openshell-policy-demo/contents/...
  live log:        /tmp/openshell-policy-demo.XXXXXX/agent.log

==> Waiting for the agent to draft a policy proposal

  Inside the sandbox right now:

    [1] agent: curl -X PUT https://api.github.com/repos/<owner>/.../contents/...
    [2] L7 proxy denies the write and returns a structured 403 the
        agent can parse and act on:
        {
          "error":      "policy_denied",
          "layer":      "l7",
          "method":     "PUT",
          "path":       "/repos/<owner>/.../contents/<run-id>.md",
          "rule_missing": { "type": "rest_allow", "host": "api.github.com", "port": 443, "method": "PUT", ... },
          "next_steps": [
            { "action": "read_skill",      "path": "/etc/openshell/skills/policy_advisor.md" },
            { "action": "submit_proposal", "url":  "http://policy.local/v1/proposals" }
          ]
        }
    [3] agent reads the skill, drafts a narrow addRule for exactly that path
    [4] agent POSTs the proposal to http://policy.local/v1/proposals
    [5] supervisor forwards it to the gateway as a pending draft

  Polling for the pending draft...

  proposal received:
  Binary: /usr/bin/curl
  Rationale: Allow /usr/bin/curl to write one GitHub Contents API file for run <run-id> in <owner>/openshell-policy-demo only.
  Endpoints: api.github.com:443 [L7 rest, allow PUT /repos/<owner>/openshell-policy-demo/contents/openshell-policy-advisor-demo/<run-id>.md]

==> Approving and waiting for the agent to retry

  OK 1 chunk(s) approved, 0 skipped. Policy version: 2
  agent retried after policy hot-reload — write succeeded

==> Verifying GitHub write

  file: openshell-policy-advisor-demo/<run-id>.md
  url:  https://github.com/<owner>/openshell-policy-demo/blob/main/...

==> Policy decision trace (OCSF)

  [t+0]  HTTP:PUT [MED] DENIED  PUT http://api.github.com:443/repos/.../...md [policy:github_api_readonly engine:l7] [reason:L7_REQUEST deny PUT ...]
  [t+26] CONFIG:LOADED [INFO] Policy reloaded successfully [policy_hash:...]
  [t+50] HTTP:PUT [INFO] ALLOWED PUT http://api.github.com:443/repos/.../...md [policy:github_api_readonly engine:l7]

✓ Demo complete.

Two things worth calling out from this output:

  1. The Endpoints: line shows the actual L7 grant[L7 rest, allow PUT /repos/.../...md]. Codex submits a method/path-scoped REST rule, not a broad L4 allow. Before this PR, openshell rule get rendered only host:port and dropped protocol, access, and the rules array, so a developer at approval time couldn't tell L4 from L7. The CLI rendering fix surfaces what the agent already submits.
  2. The structured 403 body is the contract. It carries layer, method, path, rule_missing, and next_steps — enough for the agent to recover without prompt scaffolding telling it which file to read. Reading the skill is one of the next_steps the response itself names.

Changes

feat(sandbox): wire policy.local denials to OCSF JSONL log

  • GET /v1/denials?last=N on the sandbox-local API now reads the OCSF
    JSONL log at /var/log/openshell-ocsf.YYYY-MM-DD.log, filters to network
    and L7 denials (action_id=2, class_uid 4001/4002), and returns a
    compact summary newest-first. Default limit 10, capped at 100. Runs in
    spawn_blocking so file I/O does not stall the policy.local handler.
  • POST /v1/proposals now uses the typed grpc_client wrapper instead of
    raw_client. Wrapper return type extended to the response struct so
    accepted/rejected counts surface uniformly.
  • Dropped the add_rule snake_case alias in proposal JSON; canonical
    form is addRule, matching PolicyMergeOperation convention used
    elsewhere in the codebase.
  • skills/policy_advisor.md updated to document the now-real
    /v1/denials?last=10 endpoint and use addRule consistently.

feat(cli): show L7 protocol/method/path in rule get output

format_endpoint() previously rendered only host:port, dropping
protocol, access, and the L7 rules array. That made
openshell rule get text output unable to distinguish a broad L4 grant
from a method/path-scoped L7 REST rule — exactly the distinction a
developer needs at approval time.

New rendering tags each endpoint with its enforcement layer and surfaces
allow/deny rules:

bare L4:           api.example:443 [L4]
L7 read-only:      api.example:443 [L7 rest, access=read-only]
L7 method/path:    api.example:443 [L7 rest, allow PUT /v1/foo/bar]

Pure display change: no proto, gateway, or behavior changes. Unit test
covers all three cases with synthetic fixtures.

refactor(examples): rewrite policy demo as Codex-default loop

Re-shaped examples/agent-driven-policy-management/ as a single, clean
end-to-end demonstration with smart defaults — bash demo.sh works after
gh auth login and codex login, with no .env ceremony or required
arguments. Defaults resolve from gh (owner via gh api user, repo
defaults to openshell-policy-demo, token from gh auth token).

Demo output narrates the loop for a developer reading along: structured
deny body the agent receives, the agent's drafted proposal (now showing
the L7 method/path), the policy hot-reload, and the OCSF trace at the end
filtered to the three story-relevant events.

Moved the deterministic no-LLM regression harness out of examples/ into
e2e/policy-advisor/ — it was a parallel demo, not an example. Same loop
without the LLM, useful for iterating on the proxy and policy.local API.

The README documents the trust model honestly: structured rule is the
contract, agent rationale is a hint, prover validation badge in progress
per RFC 0001 Phase 3.

Testing

  • cargo test -p openshell-sandbox --lib (650 tests, all pass; 13 in
    policy_local::tests)
  • cargo test -p openshell-cli format_endpoint (renderer unit test
    covers L4, L7 read-only, L7 method/path)
  • cargo clippy -p openshell-sandbox --lib --tests -- -D warnings (clean)
  • shellcheck clean on demo.sh, sandbox-agent.sh,
    e2e/policy-advisor/test.sh, e2e/policy-advisor/sandbox-runner.sh
  • Manual end-to-end: bash examples/agent-driven-policy-management/demo.sh
    against a local gateway with Codex auth and a scratch GitHub repo.
    Confirmed Codex submits a method/path-scoped L7 REST rule (visible
    after the CLI rendering fix) and that hot-reload + retry works.

Update (review iteration, May 6)

After the initial draft and a quick sync with @johntmyers on the design,
two follow-up commits land on this branch:

  1. docs(policy-advisor): refresh L7 deny note for structured 403 contract
    architecture/policy-advisor.md line 47 was stale (still framed L7
    deny as future work under feat: LLM-powered PolicyAdvisor agent harness for intelligent policy recommendations #205). Updated to reflect the structured 403
    body that this PR delivers against feat(policy): add agent-readable L7 deny body #1090; LLM enrichment remains the
    only piece left for feat: LLM-powered PolicyAdvisor agent harness for intelligent policy recommendations #205.

  2. feat(sandbox): switch /v1/denials to shorthand log pass-through — the
    sandbox-local /v1/denials endpoint previously parsed
    /var/log/openshell-ocsf.*.log (OCSF JSONL), which is opt-in via
    ocsf_json_enabled. By default the endpoint returned an empty list with
    a "log not enabled" hint, breaking the inspect_recent_denials step in
    the structured 403's next_steps array out of the box.

    Two ways to fix this: flip ocsf_json_enabled=true by default, or read
    the always-on shorthand log instead. The first opens a defaults /
    compliance discussion (every sandbox would persist a daily-rotated
    audit trail on upgrade). The second is what the team picked: read the
    shorthand log and pass raw lines through to the agent — same content,
    no defaults change, easier to extend (adding fields to the shorthand
    renderer is a single-file change here, no schema rev).

    Verified end-to-end against a fresh sandbox: /v1/denials now returns
    an array of raw shorthand strings, newest-first, with log_available: true out of the box.

    The principal-engineer review also caught two correctness issues that
    are addressed in the same commit:

    • UTF-8 boundary: byte-naive &line[..MAX] would panic on
      multi-byte sequences. Now uses an is_char_boundary walk-down via
      truncate_at_char_boundary, with a multi-byte test.
    • Query-string leak (consumer-side fix): the FORWARD deny path in
      proxy.rs populates OcsfUrl::new(...) and .message(...) with the
      raw request path including ?query=..., unlike the L7 path which
      uses redacted_target. To keep secrets out of /v1/denials while
      the upstream emit sites are tightened separately,
      redact_query_strings strips ?<query> to ?[redacted] from each
      surfaced line, before truncation so a cut cannot slice mid-secret.
      Hardening the upstream emit sites in proxy.rs so the on-disk
      shorthand log is itself clean is tracked as a follow-up — the
      consumer-side redaction is defense-in-depth.

Net result: the agent loop ergonomics are now live by default with no
sandbox setting changes, and the pre-existing FORWARD-path leak is gated
out of the new agent surface.

Checklist

  • Follows Conventional Commits (feat(sandbox), feat(cli),
    refactor(examples))
  • Commits signed off — happy to amend with --signoff if maintainers
    want; matched the existing branch style for now
  • Architecture docs current — architecture/policy-advisor.md updated
    to reflect the structured 403 deny body delivered against feat(policy): add agent-readable L7 deny body #1090;
    RFC 0001 and the build plan describe the broader scope.
  • Adds neither prescriptive prompt scaffolding nor per-host heuristics.
    The renderer surfaces what the agent submitted; if a future agent
    defaults to L4 against a known-REST host, that signal belongs in the
    gateway-side prover (Phase 3), not in the prompt.

Out of scope (deferred per the build plan)

  • Multi-sandbox push inbox (TUI polling 2s for now)
  • Slack / web inbox adapters
  • Supervisor UDS API (sandbox-local HTTP at policy.local is the agent
    surface this PR ships)
  • In-process prover optimization
  • Org ceiling and trusted external auto-apply
  • Per-binary auto-approve patterns

Side note

While testing this branch I noticed examples/multi-agent-notepad/demo.sh
regressed against main after #952 / #1028 changed --upload <dir>:<dir>
semantics. Filed as #1147 with a five-line suggested fix. Not in scope here.

Wires GET /v1/denials?last=N on the sandbox-local policy advisor API to read
recent OCSF JSONL events from /var/log/openshell-ocsf.YYYY-MM-DD.log, filter
to network/L7 denials (action_id=2, class_uid 4001/4002), and return a
compact summary newest-first. Default limit is 10, capped at 100. Ran inside
spawn_blocking so file I/O does not block the policy.local handler.

Other cleanup:

- POST /v1/proposals now uses the typed grpc_client wrapper instead of
  raw_client, so accepted/rejected counts surface to the agent uniformly.
  Wrapper return type extended to the response struct.
- Drop the 'add_rule' snake_case alias in the proposal JSON; canonical form
  is camelCase 'addRule', matching the PolicyMergeOperation convention used
  elsewhere.
- skills/policy_advisor.md updated to match: documents the now-real
  /v1/denials?last=10 endpoint and uses 'addRule' consistently.
- skills.rs test asserts on the canonical 'addRule' phrase rather than the
  removed 'PolicyMergeOperation' substring.
format_endpoint() previously rendered only host:port, dropping protocol,
access, and the L7 rules array. That made openshell rule get text output
unable to distinguish a broad L4 grant from a method/path-scoped L7 REST
rule -- exactly the distinction a developer needs at approval time.

New rendering tags each endpoint with its enforcement layer and surfaces
allow/deny rules:

  bare L4:           api.example:443 [L4]
  L7 read-only:      api.example:443 [L7 rest, access=read-only]
  L7 method/path:    api.example:443 [L7 rest, allow PUT /v1/foo/bar]

Pure display change: no proto, gateway, or behavior changes. Unit test
covers all three rendering cases with synthetic fixtures.
Re-shape examples/agent-driven-policy-management/ to be a single, clean
end-to-end demonstration of the agent-driven policy loop. A Codex agent
inside an OpenShell sandbox attempts a GitHub Contents API write, hits a
structured 403 from the L7 proxy, reads the policy_advisor skill, drafts a
narrow addRule proposal via http://policy.local/v1/proposals, the host
auto-approves, the sandbox hot-reloads policy, and the agent's retry
succeeds. Whole loop runs in roughly two minutes.

Demo cleanup:

- Drop .env file ceremony. Defaults resolve from gh: owner via
  'gh api user --jq .login', repo defaults to 'openshell-policy-demo',
  token from gh auth token / GITHUB_TOKEN / GH_TOKEN. With gh auth login
  and codex login already done, 'bash demo.sh' Just Works.
- Codex-specific. Bootstraps ~/.codex/auth.json from credentials injected
  by the OpenShell provider, runs codex exec --sandbox danger-full-access
  (OpenShell is the actual security boundary; bwrap nesting cannot create
  user namespaces inside the sandbox container).
- Tighter narrative output: a single 'Preflight' step, a run summary banner
  before launch, an inline narration of what's happening inside the sandbox
  while we poll for the proposal (including the literal structured 403
  body the agent acts on), and an OCSF trace at the end filtered to the
  three events that tell the story (DENY, RELOAD, ALLOW).
- Replace Python heredoc templating with sed; uploads use the single-flag
  pattern (--upload "${PAYLOAD_DIR}:/sandbox") with files referenced at
  the basename-prefixed path that #952 / #1028 established.
- README documents the trust model honestly: structured rule is the
  contract, agent rationale is a hint, prover validation badge in
  progress per RFC 0001.

Move the deterministic no-LLM regression harness out of examples/ into
e2e/policy-advisor/ -- it was a parallel demo, not an example. Same loop
without the LLM, useful for iterating on the proxy and policy.local API.
@copy-pr-bot
Copy link
Copy Markdown

copy-pr-bot Bot commented May 4, 2026

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@zredlined zredlined self-assigned this May 4, 2026
@zredlined zredlined added state:in-progress Work is currently in progress topic:l7 Application-layer policy and inspection work area:policy Policy engine and policy lifecycle work labels May 4, 2026
zredlined added 2 commits May 4, 2026 11:56
Whitespace-only fixups caught by mise run pre-commit. No functional change.
The demo task is mechanical (one HTTP request, parse a structured 403,
post a JSON proposal, retry). Codex's default high-effort reasoning
roughly doubles the demo's wall time without improving outcomes; running
at 'low' lands the same minimal L7 grant in roughly half the time.

Override with DEMO_CODEX_REASONING=medium (or higher) to compare runs.
Comment thread crates/openshell-sandbox/src/skills/policy_advisor.md
Comment thread crates/openshell-sandbox/src/l7/rest.rs Outdated
zredlined added 5 commits May 4, 2026 13:00
Three changes addressing review feedback before merging the agent-driven
policy management MVP:

- Distinguish "OCSF JSONL enabled, no denials" from "OCSF JSONL disabled,
  nothing to read." The endpoint now returns a `log_available` flag and an
  explanatory `note` when the log file is missing, so the in-sandbox agent
  can give the developer an accurate hint instead of a misleading empty
  list.
- Stop echoing the OCSF `message` field in the per-denial summary. The
  proxy's denial messages can include the request path with query string
  (e.g., `?access_token=...`); the structured `host`/`port`/`method`/
  `path`/`binary` fields carry everything the agent needs to draft a
  proposal, and `path` is sourced from `http_request.url.path` which
  already excludes the query string.
- Cap `read_request_body` at a 15s timeout. Bounds slowloris-style stalls
  from a misbehaving in-sandbox process. The proxy listener only accepts
  loopback connections so practical impact is small, but this is cheap
  defense-in-depth.

New tests cover the missing-log signal and the message-redaction guarantee.
…_DIR

Two small hardening passes on the policy management demo:

- `fail()` now pipes the agent log tail through a redactor that masks the
  GitHub token and Codex credential triple before printing. Codex itself is
  well-behaved about not echoing the token, but a misbehaving tool call
  could leak it; this is a final safety net before the log hits the
  developer's terminal (and any clipboard or chat history that follows).
- `validate_env` now regex-checks DEMO_FILE_DIR with the same allow-list
  the other path-shaped variables use. The value is interpolated through
  sed with `|` as the delimiter when rendering the agent task; rejecting
  unsupported characters keeps the templating predictable and stops a
  user-supplied value from breaking out into a shell context.
Addresses review feedback that the deny body's `next_steps` array and the
route table could drift apart. The route paths and skill location now live
as `pub const`s in `policy_local.rs` and feed both:

- the dispatcher in `route_request` that matches against them
- a new `agent_next_steps()` helper that builds the JSON the L7 deny body
  embeds

`l7/rest.rs::deny_response_body` calls `policy_local::agent_next_steps()`
instead of inlining the array, so adding or renaming a route is a one-line
change in `policy_local.rs` and the agent contract follows automatically.
Update architecture/policy-advisor.md to reflect that L7 per-request
denials now ship as a structured 403 body (layer/method/path/host/port/
binary/rule_missing/next_steps), not as untyped tracing output. Cite
RFC 0001 / #1151 for the contract; LLM-powered enrichment remains
future work under #205.
Previously /v1/denials parsed `/var/log/openshell-ocsf.*.log` (OCSF JSONL)
and returned structured per-event objects. JSONL is opt-in via
`ocsf_json_enabled`, so the endpoint returned an empty list with a "log
not enabled" hint by default — agents had to navigate a setup step before
the inspect-recent-denials guidance was useful.

Switch to reading the shorthand log at `/var/log/openshell.*.log`, which
is always-on and the same human-readable format `openshell logs` displays.
The endpoint now returns raw shorthand lines (newest first) — the agent
reads them directly, no field parsing.

Tradeoffs:
- Removes the JSONL-on-by-default debate: shorthand is already on, no
  defaults change.
- Updating shorthand is a single-file change in this repo; no schema rev
  needed when we want to add fields.

Implementation:
- `read_recent_denial_lines` walks shorthand log files newest-first,
  filters lines with ` OCSF ` AND ` DENIED ` (the OCSF action label,
  uppercase, space-bounded).
- `collect_shorthand_log_files` matches `openshell.<date>.log`; the
  trailing dot in `SHORTHAND_LOG_PREFIX = "openshell."` excludes
  `openshell-ocsf.<date>.log` so JSONL-on doesn't bleed into responses.
- 4096-byte cap per surfaced line as defense against pathological inputs.
- Skill doc updated to reflect that `/v1/denials` returns raw shorthand
  lines, not structured fields.

Defense-in-depth on query-string secrets:
- `redact_query_strings` strips `?<query>` to `?[redacted]` from each
  surfaced line. The L7 relay path emits OCSF events using
  `redacted_target` (secret-placeholder redaction), but the FORWARD deny
  path in `proxy.rs` populates `OcsfUrl::new("http", host, path, port)`
  and `.message(...)` with the raw request path — query string included.
  Stripping queries at the consumer guards `/v1/denials` regardless of
  whether the upstream emit sites are tightened. The on-disk log is not
  rewritten by this change; that is a separate hardening task tracked
  for the FORWARD path emit sites in proxy.rs.
- `truncate_at_char_boundary` is UTF-8 safe; redaction runs before
  truncation so a cut cannot slice mid-secret.

Tests:
- `recent_denials_returns_newest_first_from_shorthand_lines` covers the
  happy path with mixed allowed/denied/non-OCSF lines.
- `recent_denials_skips_jsonl_log_files` confirms JSONL files don't
  surface even if present.
- `recent_denials_truncates_pathological_lines` covers the cap.
- `is_ocsf_denial_line_filters_correctly` covers the line-level filter.
- `redact_query_strings_removes_query_from_url_token` and
  `redact_query_strings_removes_query_in_reason_tag` cover the redaction
  in both URL token and `[reason:...]` contexts.
- `truncate_at_char_boundary_does_not_panic_on_multibyte` covers the
  UTF-8 safety.
@zredlined zredlined marked this pull request as ready for review May 6, 2026 19:37
@zredlined zredlined requested a review from johntmyers May 6, 2026 19:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:policy Policy engine and policy lifecycle work state:in-progress Work is currently in progress topic:l7 Application-layer policy and inspection work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants