Skip to content

Commit e6f39f5

Browse files
committed
feat(sandbox): emit OCSF audit events for policy proposal lifecycle
The demo's policy decision trace previously showed only the proxy enforcement story (HTTP:PUT DENIED, CONFIG:LOADED, HTTP:PUT ALLOWED). It was silent about who proposed what or who decided what — the audit-trail receipts for the agent feedback loop were missing. policy.local now emits sandbox-side OCSF events at the observation moments, into the same stream as the existing CONFIG:LOADED: - CONFIG:PROPOSED on submit_proposal acceptance. Per accepted chunk: the message names the chunk_id, target endpoint, L7 method/path, and binary so the trace correlates against the inbox card via chunk_id. - CONFIG:APPROVED on /wait observation of approved status. - CONFIG:REJECTED on /wait observation of rejected status. Carries the reviewer's free-form rejection_reason in the message AND as an unmapped field, both sanitized (control chars stripped, capped at 200 chars with an ellipsis marker). The agent still reads the raw text via GET /v1/proposals/{id}; sanitization is audit-side only, per AGENTS.md's no-secrets-in-OCSF rule. The submit path defends the audit_summaries / accepted_chunk_ids index pairing against a future gateway change that compresses past rejected chunks (the proto doesn't promise 1:1 ordering with the request). Today client-side validation makes the lengths always match; if they don't, the pairing falls back to a generic per-id event rather than mis-attribute. The wait handler's emit site fires once per terminal-status observation. Multiple concurrent waiters on the same chunk would each emit one event; acceptable for single-waiter-per-chunk demos and the right place to dedup is the SIEM. demo.sh's trace filter now surfaces the four CONFIG: events alongside HTTP:PUT, so the trace at the end of every run tells the full story from deny to allow via propose -> approve. wait-smoke.sh's prereq notes recommend redirecting kubectl port-forward output so its "Handling connection for 8090" lines don't bleed into demo narration. Three new unit tests on the sandbox-side helpers — summary builder happy path, fallback, and the rejection_reason sanitizer. Signed-off-by: Alexander Watson <zredlined@gmail.com>
1 parent 0c18450 commit e6f39f5

3 files changed

Lines changed: 228 additions & 3 deletions

File tree

‎crates/openshell-sandbox/src/policy_local.rs‎

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use openshell_core::proto::{
88
L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, PolicyChunk,
99
SandboxPolicy as ProtoSandboxPolicy,
1010
};
11+
use openshell_ocsf::{ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ocsf_emit};
1112
use serde::Deserialize;
1213
use std::collections::HashMap;
1314
use std::path::{Path, PathBuf};
@@ -480,6 +481,12 @@ async fn submit_proposal(ctx: &PolicyLocalContext, body: &[u8]) -> (u16, serde_j
480481
}
481482
};
482483

484+
// Pre-compute the audit summaries before handing `chunks` to the
485+
// gateway client (which consumes the vec). The summaries pair up with
486+
// the gateway's `accepted_chunk_ids` by index for the propose events
487+
// emitted after submit returns.
488+
let audit_summaries: Vec<String> = chunks.iter().map(summarize_chunk_for_audit).collect();
489+
483490
let response = match client
484491
.submit_policy_analysis(sandbox_name, vec![], chunks, "agent_authored")
485492
.await
@@ -496,6 +503,27 @@ async fn submit_proposal(ctx: &PolicyLocalContext, body: &[u8]) -> (u16, serde_j
496503
}
497504
};
498505

506+
// One OCSF event per accepted chunk so the audit trace in
507+
// `openshell logs <sandbox>` carries the propose beat alongside the
508+
// proxy deny and policy reload that bracket it.
509+
//
510+
// The gateway compresses its `accepted_chunk_ids` by skipping rejected
511+
// chunks (`grpc/policy.rs:1357-1436`); the proto does not promise 1:1
512+
// ordering against the request. Today client-side validation catches
513+
// both rejection causes (missing rule_name, missing proposed_rule)
514+
// before submit, so the lengths match in practice. If they don't, we
515+
// can't safely pair audit_summaries by index — fall back to a generic
516+
// event per accepted chunk_id rather than mis-attribute a summary.
517+
let pairing_is_safe = response.accepted_chunk_ids.len() == audit_summaries.len();
518+
for (idx, chunk_id) in response.accepted_chunk_ids.iter().enumerate() {
519+
let summary = if pairing_is_safe {
520+
audit_summaries[idx].as_str()
521+
} else {
522+
"(summary unavailable: gateway partially accepted)"
523+
};
524+
emit_policy_propose_event(chunk_id, summary);
525+
}
526+
499527
(
500528
202,
501529
serde_json::json!({
@@ -508,6 +536,122 @@ async fn submit_proposal(ctx: &PolicyLocalContext, body: &[u8]) -> (u16, serde_j
508536
)
509537
}
510538

539+
/// Emit one CONFIG:PROPOSED audit event for an agent-authored proposal that
540+
/// the gateway just accepted. The message names the `chunk_id`, the binary,
541+
/// and the endpoint the agent is asking to reach — what a developer needs
542+
/// to see in the audit trace to correlate against the inbox card.
543+
fn emit_policy_propose_event(chunk_id: &str, summary: &str) {
544+
ocsf_emit!(
545+
ConfigStateChangeBuilder::new(crate::ocsf_ctx())
546+
.severity(SeverityId::Informational)
547+
.status(StatusId::Success)
548+
.state(StateId::Other, "PROPOSED")
549+
.unmapped("chunk_id", serde_json::json!(chunk_id))
550+
.message(format!(
551+
"agent_authored proposal chunk:{chunk_id} {summary}"
552+
))
553+
.build()
554+
);
555+
}
556+
557+
/// Emit one CONFIG:APPROVED or CONFIG:REJECTED audit event observed by the
558+
/// `/wait` poll loop. The reviewer's free-form `rejection_reason` (if any)
559+
/// is included verbatim so the audit trace shows what guidance the agent
560+
/// received.
561+
fn emit_policy_decision_event(chunk: &PolicyChunk) {
562+
let summary = summarize_chunk_for_audit(chunk);
563+
match chunk.status.as_str() {
564+
"approved" => ocsf_emit!(
565+
ConfigStateChangeBuilder::new(crate::ocsf_ctx())
566+
.severity(SeverityId::Informational)
567+
.status(StatusId::Success)
568+
.state(StateId::Enabled, "APPROVED")
569+
.unmapped("chunk_id", serde_json::json!(chunk.id))
570+
.message(format!("chunk:{} approved {summary}", chunk.id))
571+
.build()
572+
),
573+
"rejected" => {
574+
// The reviewer's free-form rejection_reason is opaque user
575+
// input. The agent reads the raw text via `GET /v1/proposals/
576+
// {id}` to redraft; the OCSF surface (which can be shipped to
577+
// external SIEMs per AGENTS.md) gets a sanitized copy — caps
578+
// length and strips control characters so a stray credential
579+
// or escape sequence cannot leak into the audit log.
580+
let sanitized = sanitize_reason_for_audit(&chunk.rejection_reason);
581+
let reason_display = if sanitized.is_empty() {
582+
"(no guidance)".to_string()
583+
} else {
584+
format!("\"{sanitized}\"")
585+
};
586+
ocsf_emit!(
587+
ConfigStateChangeBuilder::new(crate::ocsf_ctx())
588+
.severity(SeverityId::Low)
589+
.status(StatusId::Success)
590+
.state(StateId::Disabled, "REJECTED")
591+
.unmapped("chunk_id", serde_json::json!(chunk.id))
592+
.unmapped("rejection_reason", serde_json::json!(sanitized))
593+
.message(format!(
594+
"chunk:{} rejected {summary} reason:{reason_display}",
595+
chunk.id
596+
))
597+
.build()
598+
);
599+
}
600+
// Caller is gated on `is_terminal_status`, so a non-terminal status
601+
// here is a code change that broke the invariant. Warn loudly so
602+
// the audit gap doesn't go silent.
603+
other => tracing::warn!(
604+
chunk_id = %chunk.id,
605+
status = %other,
606+
"emit_policy_decision_event called on non-terminal status; no audit event emitted"
607+
),
608+
}
609+
}
610+
611+
/// Sanitize a free-form reviewer-typed string before it lands in the OCSF
612+
/// audit surface. The agent still reads the raw text via the API — this is
613+
/// audit-side defense only.
614+
fn sanitize_reason_for_audit(raw: &str) -> String {
615+
const MAX_CHARS: usize = 200;
616+
let cleaned: String = raw
617+
.chars()
618+
.filter(|c| !c.is_control() || *c == ' ')
619+
.take(MAX_CHARS)
620+
.collect();
621+
if raw.chars().count() > MAX_CHARS {
622+
format!("{cleaned}…")
623+
} else {
624+
cleaned
625+
}
626+
}
627+
628+
/// One-line audit description of a chunk's target: binary, host, port, and
629+
/// L7 method/path if present. Used by both the propose and approve/reject
630+
/// audit events so the trace can be grepped by endpoint without parsing
631+
/// JSON.
632+
fn summarize_chunk_for_audit(chunk: &PolicyChunk) -> String {
633+
let Some(rule) = chunk.proposed_rule.as_ref() else {
634+
return format!("rule_name:{}", chunk.rule_name);
635+
};
636+
let endpoint = rule.endpoints.first().map_or_else(
637+
|| "unknown".to_string(),
638+
|ep| format!("{}:{}", ep.host, ep.port),
639+
);
640+
let l7 = rule
641+
.endpoints
642+
.first()
643+
.and_then(|ep| ep.rules.first())
644+
.and_then(|r| r.allow.as_ref())
645+
.map(|a| format!(" {} {}", a.method, a.path))
646+
.unwrap_or_default();
647+
let binary = if chunk.binary.is_empty() {
648+
String::new()
649+
} else {
650+
format!(" by {}", chunk.binary)
651+
};
652+
format!("on {endpoint}{l7}{binary}")
653+
}
654+
511655
/// `GET /v1/proposals/{chunk_id}` — immediate state. One gateway call, no loop.
512656
async fn proposal_status_response(
513657
ctx: &PolicyLocalContext,
@@ -539,6 +683,11 @@ async fn proposal_wait_response(
539683
loop {
540684
match fetch_chunk(&session, chunk_id).await {
541685
Ok(Some(chunk)) if is_terminal_status(&chunk.status) => {
686+
// Audit beat: emit at the moment this sandbox observes the
687+
// decision so the trace correlates with the proxy events
688+
// bracketing the loop. Multiple waiters on the same chunk
689+
// each fire one event — acceptable for a wakeup audit.
690+
emit_policy_decision_event(&chunk);
542691
return (200, chunk_state_payload(&chunk, false));
543692
}
544693
Ok(Some(chunk)) => {
@@ -1482,4 +1631,70 @@ mod tests {
14821631
let (status, _) = route_request(&ctx, "GET", "/v1/proposals/abc/wait", &[]).await;
14831632
assert_eq!(status, 404);
14841633
}
1634+
1635+
#[test]
1636+
fn summarize_chunk_for_audit_includes_endpoint_l7_path_and_binary() {
1637+
let chunk = PolicyChunk {
1638+
id: "ignored".to_string(),
1639+
rule_name: "github_write".to_string(),
1640+
binary: "/usr/bin/curl".to_string(),
1641+
proposed_rule: Some(NetworkPolicyRule {
1642+
name: "github_write".to_string(),
1643+
endpoints: vec![NetworkEndpoint {
1644+
host: "api.github.com".to_string(),
1645+
port: 443,
1646+
rules: vec![L7Rule {
1647+
allow: Some(L7Allow {
1648+
method: "PUT".to_string(),
1649+
path: "/repos/foo/bar/contents/x.md".to_string(),
1650+
..Default::default()
1651+
}),
1652+
}],
1653+
..Default::default()
1654+
}],
1655+
binaries: vec![NetworkBinary {
1656+
path: "/usr/bin/curl".to_string(),
1657+
..Default::default()
1658+
}],
1659+
}),
1660+
..Default::default()
1661+
};
1662+
let summary = summarize_chunk_for_audit(&chunk);
1663+
assert!(summary.contains("api.github.com:443"));
1664+
assert!(summary.contains("PUT /repos/foo/bar/contents/x.md"));
1665+
assert!(summary.contains("/usr/bin/curl"));
1666+
}
1667+
1668+
#[test]
1669+
fn sanitize_reason_for_audit_strips_control_chars_and_caps_length() {
1670+
// Tabs and newlines are stripped; ordinary printable chars survive;
1671+
// multi-byte characters count as one char in the cap.
1672+
let raw = "line one\nline\ttwo\u{0001}\u{0007}";
1673+
let cleaned = sanitize_reason_for_audit(raw);
1674+
assert!(!cleaned.contains('\n'));
1675+
assert!(!cleaned.contains('\t'));
1676+
assert!(!cleaned.contains('\u{0001}'));
1677+
assert!(cleaned.contains("line one"));
1678+
assert!(cleaned.contains("linetwo"));
1679+
1680+
// Length cap with ellipsis marker so a downstream reader can tell
1681+
// the audit string is truncated.
1682+
let long: String = "x".repeat(500);
1683+
let capped = sanitize_reason_for_audit(&long);
1684+
assert!(capped.chars().count() <= 201);
1685+
assert!(capped.ends_with('…'));
1686+
1687+
// Empty input maps to empty output (caller renders "(no guidance)").
1688+
assert_eq!(sanitize_reason_for_audit(""), "");
1689+
}
1690+
1691+
#[test]
1692+
fn summarize_chunk_for_audit_falls_back_to_rule_name_without_rule() {
1693+
let chunk = PolicyChunk {
1694+
rule_name: "fallback".to_string(),
1695+
proposed_rule: None,
1696+
..Default::default()
1697+
};
1698+
assert_eq!(summarize_chunk_for_audit(&chunk), "rule_name:fallback");
1699+
}
14851700
}

‎e2e/policy-advisor/wait-smoke.sh‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@
3131
# Prereqs:
3232
# - A running gateway and the openshell CLI built from this branch.
3333
# The simplest local path is `mise run helm:skaffold:run`, then
34-
# `kubectl -n openshell port-forward svc/openshell 8090:8080 &` and
35-
# `cargo build -p openshell-cli`.
34+
# `cargo build -p openshell-cli` and start the port-forward with its
35+
# output redirected so kubectl's "Handling connection for 8090" lines
36+
# don't bleed into your terminal:
37+
# KUBECONFIG=kubeconfig kubectl -n openshell \
38+
# port-forward svc/openshell 8090:8080 >/dev/null 2>&1 &
3639
# - The agent-proposals feature flag enabled. Run once:
3740
# openshell settings set --global \
3841
# --key agent_policy_proposals_enabled --value true --yes

‎examples/agent-driven-policy-management/demo.sh‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,8 +417,15 @@ verify_github_write() {
417417
# OpenShell's logging — keep it as-is rather than re-formatting.
418418
show_logs() {
419419
step "Policy decision trace (OCSF)"
420+
# Filter to the events that tell the loop's story end-to-end, ordered by
421+
# the trace's own timestamps:
422+
# HTTP:PUT DENIED — initial proxy enforcement
423+
# CONFIG:PROPOSED — agent submitted a chunk to the gateway
424+
# CONFIG:APPROVED/REJECTED — developer decided; agent's /wait woke up
425+
# CONFIG:LOADED — supervisor hot-reloaded the merged policy
426+
# HTTP:PUT ALLOWED — agent's retry succeeded
420427
"$OPENSHELL_BIN" logs "$DEMO_SANDBOX_NAME" --since 10m -n 200 2>&1 \
421-
| grep -E 'HTTP:PUT.*(DENIED|ALLOWED)|CONFIG:LOADED.*Policy reloaded' \
428+
| grep -E 'HTTP:PUT.*(DENIED|ALLOWED)|CONFIG:(PROPOSED|APPROVED|REJECTED|LOADED)' \
422429
| sed 's/^/ /' || true
423430
}
424431

0 commit comments

Comments
 (0)