wip: forward proxy fix, mapper allowed_ips, TUI polish, CTF rewrite

This commit is contained in:
John Myers
2026-03-11 16:54:21 -07:00
parent b2ebad675c
commit 158cd103ee
12 changed files with 666 additions and 344 deletions
+4 -4
View File
@@ -29,7 +29,7 @@ The `DenialAggregator` (`crates/navigator-sandbox/src/denial_aggregator.rs`) run
2. Deduplicates events by `(host, port, binary)` key with running counters
3. Periodically flushes accumulated summaries to the gateway via `SubmitPolicyAnalysis` gRPC
The flush interval defaults to 30 seconds (configurable via `OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS`).
The flush interval defaults to 10 seconds (configurable via `OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS`).
### Denial Event Sources
@@ -132,7 +132,7 @@ When a chunk is approved, the server:
5. Supersedes older policy versions
6. Notifies watchers (triggers sandbox policy poll)
The sandbox picks up the new policy on its next poll cycle (default 30 seconds) and hot-reloads the OPA engine.
The sandbox picks up the new policy on its next poll cycle (default 10 seconds) and hot-reloads the OPA engine.
## User Interfaces
@@ -175,8 +175,8 @@ Navigation: `[j/k]` to select chunks, `[p]` to return to policy, `[l]` for logs.
| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| `OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS` | `30` | How often the aggregator flushes to the gateway |
| `OPENSHELL_POLICY_POLL_INTERVAL_SECS` | `30` | How often the sandbox polls for policy updates |
| `OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS` | `10` | How often the aggregator flushes to the gateway |
| `OPENSHELL_POLICY_POLL_INTERVAL_SECS` | `10` | How often the sandbox polls for policy updates |
## Future Work (Issue #205)
+2 -2
View File
@@ -319,7 +319,7 @@ sequenceDiagram
GW-->>PL: policy + version + hash
PL->>PL: Store initial version
loop Every OPENSHELL_POLICY_POLL_INTERVAL_SECS (default 30)
loop Every OPENSHELL_POLICY_POLL_INTERVAL_SECS (default 10)
PL->>GW: GetSandboxPolicy(sandbox_id)
GW-->>PL: policy + version + hash
alt version > current_version
@@ -389,7 +389,7 @@ Proto messages involved:
| Initial version fetch fails | Log warning, retry on next interval (poll loop continues) |
| `reload_from_proto()` fails (L7 validation error) | Log warning, keep last-known-good engine, report FAILED status |
| Status report RPC fails | Log warning, poll loop continues unaffected |
| Poll interval env var unparseable | Fall back to default (30 seconds) |
| Poll interval env var unparseable | Fall back to default (10 seconds) |
## Linux Enforcement
+1 -1
View File
@@ -181,7 +181,7 @@ In gRPC mode, the sandbox spawns a background task that periodically polls the g
| Parameter | Default | Override |
|-----------|---------|----------|
| Poll interval | 30 seconds | `OPENSHELL_POLICY_POLL_INTERVAL_SECS` environment variable |
| Poll interval | 10 seconds | `OPENSHELL_POLICY_POLL_INTERVAL_SECS` environment variable |
The poll loop:
+2 -2
View File
@@ -534,7 +534,7 @@ pub async fn run_sandbox(
let poll_interval_secs: u64 = std::env::var("OPENSHELL_POLICY_POLL_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(30);
.unwrap_or(10);
tokio::spawn(async move {
if let Err(e) =
@@ -553,7 +553,7 @@ pub async fn run_sandbox(
let flush_interval_secs: u64 = std::env::var("OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(30);
.unwrap_or(10);
let aggregator = denial_aggregator::DenialAggregator::new(rx, flush_interval_secs);
+158 -86
View File
@@ -1484,9 +1484,10 @@ fn rewrite_forward_request(raw: &[u8], used: usize, path: &str) -> Vec<u8> {
/// Handle a plain HTTP forward proxy request (non-CONNECT).
///
/// Restricted to private IP endpoints with explicit `allowed_ips` policy.
/// Rewrites the absolute-form request to origin-form, connects upstream,
/// and relays the response using `copy_bidirectional` for streaming support.
/// Public IPs are allowed through when the endpoint passes OPA evaluation.
/// Private IPs require explicit `allowed_ips` on the endpoint config (SSRF
/// override). Rewrites the absolute-form request to origin-form, connects
/// upstream, and relays the response using `copy_bidirectional` for streaming.
async fn handle_forward_proxy(
method: &str,
target_uri: &str,
@@ -1603,50 +1604,67 @@ async fn handle_forward_proxy(
};
let policy_str = matched_policy.as_deref().unwrap_or("-");
// 5. Require allowed_ips (forward proxy only works with explicit SSRF override)
// 5. DNS resolution + SSRF defence (mirrors the CONNECT path logic).
// - If allowed_ips is set: validate resolved IPs against the allowlist
// (this is the SSRF override for private IP destinations).
// - If allowed_ips is empty: reject internal IPs, allow public IPs through.
let raw_allowed_ips = query_allowed_ips(&opa_engine, &decision, &host_lc, port);
if raw_allowed_ips.is_empty() {
info!(
src_addr = %peer_addr.ip(),
src_port = peer_addr.port(),
proxy_addr = %local_addr,
dst_host = %host_lc,
dst_port = port,
method = %method,
path = %path,
binary = %binary_str,
binary_pid = %pid_str,
ancestors = %ancestors_str,
cmdline = %cmdline_str,
action = "deny",
engine = "opa",
policy = %policy_str,
reason = "forward proxy requires allowed_ips on endpoint",
"FORWARD",
);
emit_denial_simple(
denial_tx,
&host_lc,
port,
&binary_str,
&decision,
"forward proxy requires allowed_ips on endpoint",
"forward",
);
respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?;
return Ok(());
}
// 6. Resolve DNS and validate against allowed_ips
let addrs = match parse_allowed_ips(&raw_allowed_ips) {
Ok(nets) => match resolve_and_check_allowed_ips(&host, port, &nets).await {
Ok(addrs) => addrs,
let addrs = if !raw_allowed_ips.is_empty() {
// allowed_ips mode: validate resolved IPs against CIDR allowlist.
match parse_allowed_ips(&raw_allowed_ips) {
Ok(nets) => match resolve_and_check_allowed_ips(&host, port, &nets).await {
Ok(addrs) => addrs,
Err(reason) => {
warn!(
dst_host = %host_lc,
dst_port = port,
reason = %reason,
"FORWARD blocked: allowed_ips check failed"
);
emit_denial_simple(
denial_tx,
&host_lc,
port,
&binary_str,
&decision,
&reason,
"ssrf",
);
respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?;
return Ok(());
}
},
Err(reason) => {
warn!(
dst_host = %host_lc,
dst_port = port,
reason = %reason,
"FORWARD blocked: allowed_ips check failed"
"FORWARD blocked: invalid allowed_ips in policy"
);
emit_denial_simple(
denial_tx,
&host_lc,
port,
&binary_str,
&decision,
&reason,
"ssrf",
);
respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?;
return Ok(());
}
}
} else {
// No allowed_ips: reject internal IPs, allow public IPs through.
match resolve_and_reject_internal(&host, port).await {
Ok(addrs) => addrs,
Err(reason) => {
warn!(
dst_host = %host_lc,
dst_port = port,
reason = %reason,
"FORWARD blocked: internal IP without allowed_ips"
);
emit_denial_simple(
denial_tx,
@@ -1660,53 +1678,10 @@ async fn handle_forward_proxy(
respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?;
return Ok(());
}
},
Err(reason) => {
warn!(
dst_host = %host_lc,
dst_port = port,
reason = %reason,
"FORWARD blocked: invalid allowed_ips in policy"
);
emit_denial_simple(
denial_tx,
&host_lc,
port,
&binary_str,
&decision,
&reason,
"ssrf",
);
respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?;
return Ok(());
}
};
// 7. Private-IP gate: forward proxy only to RFC 1918 private addresses
if !addrs.iter().all(|a| is_internal_ip(a.ip())) {
info!(
src_addr = %peer_addr.ip(),
src_port = peer_addr.port(),
proxy_addr = %local_addr,
dst_host = %host_lc,
dst_port = port,
method = %method,
path = %path,
binary = %binary_str,
binary_pid = %pid_str,
ancestors = %ancestors_str,
cmdline = %cmdline_str,
action = "deny",
engine = "opa",
policy = %policy_str,
reason = "forward proxy restricted to private IP endpoints",
"FORWARD",
);
respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?;
return Ok(());
}
// 8. Connect upstream
// 6. Connect upstream
let mut upstream = match TcpStream::connect(addrs.as_slice()).await {
Ok(s) => s,
Err(e) => {
@@ -1741,11 +1716,11 @@ async fn handle_forward_proxy(
"FORWARD",
);
// 9. Rewrite request and forward to upstream
// 7. Rewrite request and forward to upstream
let rewritten = rewrite_forward_request(buf, used, &path);
upstream.write_all(&rewritten).await.into_diagnostic()?;
// 10. Relay remaining traffic bidirectionally (supports streaming)
// 8. Relay remaining traffic bidirectionally (supports streaming)
let _ = tokio::io::copy_bidirectional(client, &mut upstream)
.await
.into_diagnostic()?;
@@ -2410,4 +2385,101 @@ mod tests {
// Should not add a second Via header
assert!(!result_str.contains("Via: 1.1 navigator-sandbox"));
}
// --- Forward proxy SSRF defence tests ---
//
// The forward proxy handler uses the same SSRF logic as the CONNECT path:
// - No allowed_ips: resolve_and_reject_internal blocks private IPs, allows public.
// - With allowed_ips: resolve_and_check_allowed_ips validates against allowlist.
//
// These tests document that contract for the forward proxy path specifically.
#[tokio::test]
async fn test_forward_public_ip_allowed_without_allowed_ips() {
// Public IPs (e.g. dns.google -> 8.8.8.8) should pass through
// resolve_and_reject_internal without needing allowed_ips.
let result = resolve_and_reject_internal("dns.google", 80).await;
assert!(
result.is_ok(),
"Public IP should be allowed without allowed_ips: {result:?}"
);
let addrs = result.unwrap();
assert!(!addrs.is_empty(), "Should resolve to at least one address");
// All resolved addresses should be public.
for addr in &addrs {
assert!(
!is_internal_ip(addr.ip()),
"dns.google should resolve to public IPs, got {}",
addr.ip()
);
}
}
#[tokio::test]
async fn test_forward_private_ip_rejected_without_allowed_ips() {
// Private IP literals should be rejected by resolve_and_reject_internal.
let result = resolve_and_reject_internal("10.0.0.1", 80).await;
assert!(
result.is_err(),
"Private IP should be rejected without allowed_ips"
);
let err = result.unwrap_err();
assert!(
err.contains("internal address"),
"expected 'internal address' in error: {err}"
);
}
#[tokio::test]
async fn test_forward_private_ip_accepted_with_allowed_ips() {
// Private IP with matching allowed_ips should pass through.
let nets = parse_allowed_ips(&["10.0.0.0/8".to_string()]).unwrap();
let result = resolve_and_check_allowed_ips("10.0.0.1", 80, &nets).await;
assert!(
result.is_ok(),
"Private IP with matching allowed_ips should be accepted: {result:?}"
);
}
#[tokio::test]
async fn test_forward_private_ip_rejected_with_wrong_allowed_ips() {
// Private IP not in allowed_ips should be rejected.
let nets = parse_allowed_ips(&["192.168.0.0/16".to_string()]).unwrap();
let result = resolve_and_check_allowed_ips("10.0.0.1", 80, &nets).await;
assert!(
result.is_err(),
"Private IP not in allowed_ips should be rejected"
);
let err = result.unwrap_err();
assert!(
err.contains("not in allowed_ips"),
"expected 'not in allowed_ips' in error: {err}"
);
}
#[tokio::test]
async fn test_forward_loopback_always_blocked_even_with_allowed_ips() {
// Loopback addresses are always blocked, even if in allowed_ips.
let nets = parse_allowed_ips(&["127.0.0.0/8".to_string()]).unwrap();
let result = resolve_and_check_allowed_ips("127.0.0.1", 80, &nets).await;
assert!(result.is_err(), "Loopback should be always blocked");
let err = result.unwrap_err();
assert!(
err.contains("always-blocked"),
"expected 'always-blocked' in error: {err}"
);
}
#[tokio::test]
async fn test_forward_link_local_always_blocked_even_with_allowed_ips() {
// Link-local / cloud metadata addresses are always blocked.
let nets = parse_allowed_ips(&["169.254.0.0/16".to_string()]).unwrap();
let result = resolve_and_check_allowed_ips("169.254.169.254", 80, &nets).await;
assert!(result.is_err(), "Link-local should be always blocked");
let err = result.unwrap_err();
assert!(
err.contains("always-blocked"),
"expected 'always-blocked' in error: {err}"
);
}
}
+1 -1
View File
@@ -1424,7 +1424,7 @@ impl Navigator for NavigatorService {
.map(|c| c.rule_name.clone())
.collect();
crate::mechanistic_mapper::generate_proposals(&req.summaries, &existing_names)
crate::mechanistic_mapper::generate_proposals(&req.summaries, &existing_names).await
} else {
req.proposed_chunks.clone()
};
+159 -12
View File
@@ -16,6 +16,7 @@ use navigator_core::proto::{
DenialSummary, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, PolicyChunk,
};
use std::collections::HashMap;
use std::net::IpAddr;
/// Well-known ports that get higher confidence scores.
const WELL_KNOWN_PORTS: &[(u16, &str)] = &[
@@ -45,8 +46,12 @@ const WELL_KNOWN_PORTS: &[(u16, &str)] = &[
/// `PolicyChunk` with a `NetworkPolicyRule` allowing that endpoint for the
/// observed binaries.
///
/// When a host resolves to a private IP (RFC 1918, loopback, link-local),
/// the proposed endpoint includes `allowed_ips` so the proxy's SSRF override
/// accepts the connection. Public IPs do not need `allowed_ips`.
///
/// Returns an empty vec if there are no actionable denials.
pub fn generate_proposals(
pub async fn generate_proposals(
summaries: &[DenialSummary],
existing_rule_names: &[String],
) -> Vec<PolicyChunk> {
@@ -97,6 +102,11 @@ pub fn generate_proposals(
}
}
// Resolve the host and check if any IP is private. When a host
// resolves to private IP space, the proxy requires `allowed_ips` as an
// explicit SSRF override. Public IPs don't need this.
let allowed_ips = resolve_allowed_ips_if_private(host, *port).await;
// Build proposed NetworkPolicyRule.
let l7_rules = build_l7_rules(&l7_methods);
let endpoint = if has_l7 && !l7_rules.is_empty() {
@@ -107,12 +117,14 @@ pub fn generate_proposals(
tls: "terminate".to_string(),
enforcement: "enforce".to_string(),
rules: l7_rules,
allowed_ips: allowed_ips.clone(),
..Default::default()
}
} else {
NetworkEndpoint {
host: host.clone(),
port: *port,
allowed_ips: allowed_ips.clone(),
..Default::default()
}
};
@@ -154,18 +166,27 @@ pub fn generate_proposals(
.map(|(_, name)| format!(" ({name})"))
.unwrap_or_default();
let private_ip_note = if !allowed_ips.is_empty() {
format!(
" Host resolves to private IP ({}); allowed_ips included for SSRF override.",
allowed_ips.join(", ")
)
} else {
String::new()
};
let rationale = if has_l7 && !l7_methods.is_empty() {
let paths: Vec<String> = l7_methods.keys().map(|(m, p)| format!("{m} {p}")).collect();
format!(
"Allow {binary_list} to connect to {host}:{port}{port_name} \
with L7 inspection. Observed {total_count} denied connection(s). \
Allowed paths: {}.",
Allowed paths: {}.{private_ip_note}",
paths.join(", ")
)
} else {
format!(
"Allow {binary_list} to connect to {host}:{port}{port_name}. \
Observed {total_count} denied connection(s)."
Observed {total_count} denied connection(s).{private_ip_note}"
)
};
@@ -395,6 +416,77 @@ fn short_binary_name(path: &str) -> String {
path.rsplit('/').next().unwrap_or(path).to_string()
}
/// Check if an IP address is in private/internal space.
///
/// Matches the same ranges as the proxy's `is_internal_ip`: loopback,
/// RFC 1918 private, link-local (IPv4), plus loopback, link-local, and
/// ULA (IPv6). IPv4-mapped IPv6 addresses are unwrapped and checked.
fn is_internal_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
IpAddr::V6(v6) => {
if v6.is_loopback() {
return true;
}
// fe80::/10 — IPv6 link-local
if (v6.segments()[0] & 0xffc0) == 0xfe80 {
return true;
}
// fc00::/7 — IPv6 unique local addresses (ULA)
if (v6.segments()[0] & 0xfe00) == 0xfc00 {
return true;
}
// Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
if let Some(v4) = v6.to_ipv4_mapped() {
return v4.is_loopback() || v4.is_private() || v4.is_link_local();
}
false
}
}
}
/// Resolve a hostname and return the IPs as `allowed_ips` strings only if any
/// resolved address is in private IP space.
///
/// When a host resolves entirely to public IPs, the proxy doesn't need
/// `allowed_ips` — it passes public traffic through after the OPA check.
/// When any resolved IP is private/internal, the proxy requires `allowed_ips`
/// as an explicit SSRF override, so the mapper includes them.
///
/// Returns an empty vec for public hosts or on DNS resolution failure.
async fn resolve_allowed_ips_if_private(host: &str, port: u32) -> Vec<String> {
let addr = format!("{host}:{port}");
let addrs = match tokio::net::lookup_host(&addr).await {
Ok(addrs) => addrs.collect::<Vec<_>>(),
Err(e) => {
tracing::warn!(host, port, error = %e, "DNS resolution failed for allowed_ips check");
return Vec::new();
}
};
if addrs.is_empty() {
tracing::warn!(host, port, "DNS resolution returned no addresses");
return Vec::new();
}
let has_private = addrs.iter().any(|a| is_internal_ip(a.ip()));
if !has_private {
return Vec::new();
}
// Host has private IPs — include all resolved IPs in allowed_ips.
let mut ips: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
ips.sort();
ips.dedup();
tracing::debug!(
host,
port,
?ips,
"Host resolves to private IP; adding allowed_ips"
);
ips
}
#[cfg(test)]
mod tests {
use super::*;
@@ -430,14 +522,14 @@ mod tests {
assert!(notes.contains("SSRF"));
}
#[test]
fn test_generate_proposals_empty() {
let proposals = generate_proposals(&[], &[]);
#[tokio::test]
async fn test_generate_proposals_empty() {
let proposals = generate_proposals(&[], &[]).await;
assert!(proposals.is_empty());
}
#[test]
fn test_generate_proposals_basic() {
#[tokio::test]
async fn test_generate_proposals_basic() {
let summaries = vec![DenialSummary {
sandbox_id: "test".to_string(),
host: "api.example.com".to_string(),
@@ -458,7 +550,7 @@ mod tests {
l7_inspection_active: false,
}];
let proposals = generate_proposals(&summaries, &[]);
let proposals = generate_proposals(&summaries, &[]).await;
assert_eq!(proposals.len(), 1);
assert_eq!(proposals[0].rule_name, "allow_api_example_com_443");
assert!(proposals[0].proposed_rule.is_some());
@@ -473,10 +565,16 @@ mod tests {
// No L7 fields when no samples provided.
assert!(rule.endpoints[0].protocol.is_empty());
assert!(rule.endpoints[0].rules.is_empty());
// Public host should NOT have allowed_ips.
assert!(
rule.endpoints[0].allowed_ips.is_empty(),
"Public host should not get allowed_ips"
);
}
#[test]
fn test_generate_proposals_with_l7_samples() {
#[tokio::test]
async fn test_generate_proposals_with_l7_samples() {
use navigator_core::proto::L7RequestSample;
let summaries = vec![DenialSummary {
@@ -512,7 +610,7 @@ mod tests {
l7_inspection_active: true,
}];
let proposals = generate_proposals(&summaries, &[]);
let proposals = generate_proposals(&summaries, &[]).await;
assert_eq!(proposals.len(), 1);
let rule = proposals[0].proposed_rule.as_ref().unwrap();
@@ -540,6 +638,55 @@ mod tests {
assert!(proposals[0].rationale.contains("L7"));
}
// -- is_internal_ip tests -------------------------------------------------
#[test]
fn test_is_internal_ip_private_v4() {
use std::net::Ipv4Addr;
// RFC 1918 ranges
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(10, 110, 50, 3))));
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(172, 31, 255, 255))));
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))));
}
#[test]
fn test_is_internal_ip_loopback_and_link_local() {
use std::net::Ipv4Addr;
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::LOCALHOST)));
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(
169, 254, 169, 254
))));
}
#[test]
fn test_is_internal_ip_public_v4() {
use std::net::Ipv4Addr;
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(208, 95, 112, 1))));
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))));
}
#[test]
fn test_is_internal_ip_v6() {
use std::net::Ipv6Addr;
// Loopback
assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)));
// Link-local fe80::1
assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::new(
0xfe80, 0, 0, 0, 0, 0, 0, 1
))));
// ULA fd00::1
assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::new(
0xfd00, 0, 0, 0, 0, 0, 0, 1
))));
// Public 2001:db8::1
assert!(!is_internal_ip(IpAddr::V6(Ipv6Addr::new(
0x2001, 0xdb8, 0, 0, 0, 0, 0, 1
))));
}
#[test]
fn test_generalise_path() {
// Exact path preserved.
+6
View File
@@ -902,6 +902,12 @@ impl App {
self.sandbox_log_scroll = 0;
self.log_cursor = 0;
}
KeyCode::Char('r') => {
self.focus = Focus::SandboxDraft;
}
KeyCode::Char('p') => {
self.focus = Focus::SandboxPolicy;
}
_ => {}
}
}
@@ -100,6 +100,23 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) {
let mut lines = vec![Line::from(""), row1, row2, row3, row4];
// Show pending policy requests prompt.
if pending_count > 0 {
lines.push(Line::from(vec![
Span::styled(" ", styles::TEXT),
Span::styled(
format!(
"{pending_count} pending policy request{}",
if pending_count == 1 { "" } else { "s" }
),
styles::ACCENT,
),
Span::styled(" — press ", styles::MUTED),
Span::styled("[r]", styles::KEY_HINT),
Span::styled(" to review", styles::MUTED),
]));
}
// Delete confirmation in title area (same pattern as provider delete).
if app.confirm_delete {
lines.push(Line::from(""));
+4 -1
View File
@@ -51,7 +51,10 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) {
if draft_count > 0 {
name_spans.push(Span::raw(" "));
name_spans.push(Span::styled(
format!(" {draft_count} pending "),
format!(
" {draft_count} policy request{} pending ",
if draft_count == 1 { "" } else { "s" }
),
styles::BADGE,
));
}
+39 -27
View File
@@ -5,21 +5,22 @@
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 public endpoints -- and
fails. The sandbox proxy detects each denial, aggregates it, and sends it to
the gateway where the mechanistic mapper turns it into a concrete
`NetworkPolicyRule` recommendation. You approve those recommendations one by
one in the TUI, and the script progresses through each gate.
`api.anthropic.com`. A Python script tries to reach 6 endpoints -- and fails.
The sandbox proxy detects each denial, aggregates it, and sends it to the
gateway where the mechanistic mapper turns it into a concrete
`NetworkPolicyRule` recommendation. 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 ~30 seconds it flushes denial
2. **DenialAggregator batches events** -- every ~10 seconds it flushes denial
summaries to the gateway via `SubmitPolicyAnalysis`.
3. **Mechanistic mapper generates proposals** -- each unique `(host, port)`
pair becomes a `PolicyChunk` with a proposed `NetworkPolicyRule`, confidence
score, and rationale.
score, and rationale. If the host resolves to a private IP, the mapper
includes `allowed_ips` for the SSRF override.
4. **TUI shows recommendations** -- navigate to the sandbox's draft panel to
see pending proposals.
5. **You approve** -- the approved rule merges into the active sandbox policy
@@ -31,20 +32,26 @@ one in the TUI, and the script progresses through each gate.
| File | Description |
|---|---|
| `sandbox-policy.yaml` | Restrictive policy that only allows `api.anthropic.com:443` |
| `ctf.py` | Python script with 7 sequential network gates to public services |
| `ctf.py` | Python script with 6 network gates |
| `README.md` | This walkthrough |
## Gates
| # | Name | Target | Notes |
|---|---|---|---|
| 1 | The Ping | `httpbin.org:443` | HTTPS echo service |
| 2 | The Oracle | `api.github.com:443` | GitHub zen quotes |
| 3 | The Jester | `icanhazdadjoke.com:443` | Random dad joke |
| 4 | The Scribe | `jsonplaceholder.typicode.com:443` | POST request (not just GETs) |
| 5 | The Sphinx | `catfact.ninja:443` | Cat facts |
| 6 | The Cartographer | `ip-api.com:80` | Plain HTTP on port 80 (forward-proxy path) |
| 7 | The Guardian | `dog.ceo:443` | Random dog breed |
| 1 | The Ping | `httpbin.org:443` | HTTPS (CONNECT tunnel path) |
| 2 | The Cartographer | `ip-api.com:80` | Plain HTTP (forward proxy path) |
| 3 | The Oracle | `api.github.com:443` | Concurrent with 4 and 5 |
| 4 | The Jester | `icanhazdadjoke.com:443` | Concurrent with 3 and 5 |
| 5 | The Sphinx | `catfact.ninja:443` | Concurrent with 3 and 4 |
| 6 | The Vault | `gitlab-master.nvidia.com:443` | Internal IP -- mapper adds `allowed_ips` |
Gates 1 and 2 run sequentially so you can observe the single-approval flow.
Gates 3-5 fire concurrently so all three denials arrive together -- use
`[A]` (approve all) in the TUI to unlock them in one shot.
Gate 6 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
@@ -88,31 +95,34 @@ seconds.
### 3. Approve recommendations in the TUI
Switch to the TUI terminal. Within ~30 seconds you should see the sandbox
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 policy view** -- press `p`.
3. **Open the draft recommendations panel** -- press `r`.
4. **Approve a recommendation** -- highlight the pending chunk for
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 immediately. On the next retry
The policy update propagates to the sandbox within seconds. On the next retry
the script passes Gate 1 and moves on to Gate 2.
Repeat for each gate. You can also press `A` to approve all pending
recommendations at once if you want to skip ahead.
When Gates 3-5 start, all three denials arrive together. Press `A` to approve
all pending recommendations at once.
Gate 6 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.
Once all 6 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 30 seconds by
- **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
@@ -122,9 +132,11 @@ Once all 7 gates are unlocked the script prints a victory banner.
openshell draft approve advisor-ctf --chunk-id ID # approve one
openshell draft approve-all advisor-ctf # approve all
```
- **Gate 6 is different** -- it uses plain HTTP on port 80, which exercises
the forward-proxy deny path instead of the CONNECT tunnel used by HTTPS.
This generates a separate recommendation with port 80.
- **Gate 2 is different** -- it uses plain HTTP on port 80, which exercises
the forward proxy path instead of the CONNECT tunnel used by HTTPS.
- **Gate 6 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
+273 -208
View File
@@ -9,10 +9,9 @@ A capture-the-flag challenge that exercises OpenShell's policy recommendation
pipeline. Run this inside a sandbox with the restrictive policy, then use the
TUI to approve mechanistic recommendations and unlock each gate.
Every gate makes a network call to a public service. The sandbox proxy blocks
the call, the DenialAggregator reports it to the gateway, and the mechanistic
mapper generates a policy recommendation. Approve it in the TUI and the next
retry succeeds.
Gate 1 tests the basic HTTPS flow (CONNECT tunnel).
Gate 2 tests plain HTTP (forward proxy path).
Gates 3-5 fire concurrently to test batch approval.
Usage:
python3 ctf.py # run all gates
@@ -24,13 +23,14 @@ from __future__ import annotations
import json
import socket
import sys
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime
# ── Terminal formatting ──────────────────────────────────────────────────────
# -- Terminal formatting -------------------------------------------------------
GREEN = "\033[92m"
RED = "\033[91m"
@@ -62,17 +62,10 @@ def log(level: str, msg: str, **kv: object) -> None:
print(f" {DIM}{ts}{RESET} {c}{level:4}{RESET} {msg} {extra}", flush=True)
# ── Gate definitions ─────────────────────────────────────────────────────────
#
# Each gate targets a different public endpoint. The variety exercises the
# mechanistic mapper across hosts, ports, and HTTP methods.
#
# Gates 1-5 : HTTPS on port 443 (CONNECT tunnel path)
# Gate 6 : plain HTTP on port 80 (forward-proxy path)
# Gate 7 : HTTPS on port 443 (final gate)
# -- Gate definitions ----------------------------------------------------------
GATES: list[dict] = [
# ── Gate 1 ───────────────────────────────────────────────────
# -- Gate 1: HTTPS on 443 (CONNECT tunnel) --------------------------------
{
"num": 1,
"name": "The Ping",
@@ -82,78 +75,12 @@ GATES: list[dict] = [
"method": "GET",
"headers": {"Accept": "application/json"},
"body": None,
"hint": "A simple echo to prove you're alive.",
"hint": "A simple HTTPS echo to prove the CONNECT tunnel works.",
"extract": lambda d: f"origin = {json.loads(d).get('origin', '?')}",
},
# ── Gate 2 ───────────────────────────────────────────────────
# -- Gate 2: plain HTTP on 80 (forward proxy) -----------------------------
{
"num": 2,
"name": "The Oracle",
"host": "api.github.com",
"port": 443,
"url": "https://api.github.com/zen",
"method": "GET",
"headers": {
"User-Agent": "openshell-ctf",
"Accept": "application/vnd.github+json",
},
"body": None,
"hint": "Ancient wisdom from the code forge.",
"extract": lambda d: d.strip()[:80],
},
# ── Gate 3 ───────────────────────────────────────────────────
{
"num": 3,
"name": "The Jester",
"host": "icanhazdadjoke.com",
"port": 443,
"url": "https://icanhazdadjoke.com/",
"method": "GET",
"headers": {"Accept": "application/json", "User-Agent": "openshell-ctf"},
"body": None,
"hint": "Laughter unlocks the third seal.",
"extract": lambda d: json.loads(d).get("joke", "?")[:120],
},
# ── Gate 4 ───────────────────────────────────────────────────
{
"num": 4,
"name": "The Scribe",
"host": "jsonplaceholder.typicode.com",
"port": 443,
"url": "https://jsonplaceholder.typicode.com/posts",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Accept": "application/json",
},
"body": json.dumps(
{
"title": "CTF Gate 4",
"body": "The scribe records your passage.",
"userId": 1,
}
),
"hint": "Leave your mark -- POST, don't just GET.",
"extract": lambda d: f"created post id = {json.loads(d).get('id', '?')}",
},
# ── Gate 5 ───────────────────────────────────────────────────
{
"num": 5,
"name": "The Sphinx",
"host": "catfact.ninja",
"port": 443,
"url": "https://catfact.ninja/fact",
"method": "GET",
"headers": {"Accept": "application/json", "User-Agent": "openshell-ctf"},
"body": None,
"hint": "Answer the Sphinx's riddle.",
"extract": lambda d: json.loads(d).get("fact", "?")[:120],
},
# ── Gate 6 ───────────────────────────────────────────────────
# Plain HTTP on port 80 -- exercises the forward-proxy deny path
# instead of the CONNECT tunnel path used by HTTPS.
{
"num": 6,
"name": "The Cartographer",
"host": "ip-api.com",
"port": 80,
@@ -168,34 +95,63 @@ GATES: list[dict] = [
else json.loads(d).get("message", "?")
),
},
# ── Gate 7 ───────────────────────────────────────────────────
# -- Gates 3-5: concurrent HTTPS requests (batch approval) ----------------
{
"num": 7,
"name": "The Guardian",
"host": "dog.ceo",
"num": 3,
"name": "The Oracle",
"host": "api.github.com",
"port": 443,
"url": "https://dog.ceo/api/breeds/image/random",
"url": "https://api.github.com/zen",
"method": "GET",
"headers": {"Accept": "application/json"},
"headers": {
"User-Agent": "openshell-ctf",
"Accept": "application/vnd.github+json",
},
"body": None,
"hint": "The final guardian reveals its true form.",
"extract": lambda d: _extract_breed(json.loads(d).get("message", "")),
"hint": "Ancient wisdom from the code forge.",
"extract": lambda d: d.strip()[:80],
},
{
"num": 4,
"name": "The Jester",
"host": "icanhazdadjoke.com",
"port": 443,
"url": "https://icanhazdadjoke.com/",
"method": "GET",
"headers": {"Accept": "application/json", "User-Agent": "openshell-ctf"},
"body": None,
"hint": "Laughter unlocks the fourth seal.",
"extract": lambda d: json.loads(d).get("joke", "?")[:120],
},
{
"num": 5,
"name": "The Sphinx",
"host": "catfact.ninja",
"port": 443,
"url": "https://catfact.ninja/fact",
"method": "GET",
"headers": {"Accept": "application/json", "User-Agent": "openshell-ctf"},
"body": None,
"hint": "Answer the Sphinx's riddle.",
"extract": lambda d: json.loads(d).get("fact", "?")[:120],
},
# -- Gate 6: HTTPS to internal IP (allowed_ips SSRF override) -------------
{
"num": 6,
"name": "The Vault",
"host": "gitlab-master.nvidia.com",
"port": 443,
"url": "https://gitlab-master.nvidia.com/",
"method": "GET",
"headers": {"User-Agent": "openshell-ctf"},
"body": None,
"hint": "Reach behind the firewall -- this host resolves to a private IP.",
"extract": lambda d: f"page length = {len(d)} bytes",
},
]
def _extract_breed(url: str) -> str:
"""Pull the breed name out of a dog.ceo image URL."""
# URL pattern: https://images.dog.ceo/breeds/<breed>/image.jpg
parts = url.split("/")
try:
idx = parts.index("breeds")
return parts[idx + 1].replace("-", " ").title()
except (ValueError, IndexError):
return url
# ── Network request logic ────────────────────────────────────────────────────
# -- Network request logic ----------------------------------------------------
def _is_proxy_block(exc: Exception) -> bool:
@@ -231,7 +187,6 @@ def attempt_gate(gate: dict) -> tuple[str, str]:
except urllib.error.HTTPError as exc:
if exc.code == 403:
return "blocked", "blocked by sandbox proxy (403)"
# A real HTTP error from the upstream service — not retryable.
return "error", f"HTTP {exc.code} from {gate['host']}"
except urllib.error.URLError as exc:
@@ -251,46 +206,47 @@ def attempt_gate(gate: dict) -> tuple[str, str]:
return "error", f"unexpected error ({exc})"
# ── Banner / victory ─────────────────────────────────────────────────────────
# -- Banner / victory ----------------------------------------------------------
BANNER = f"""
{CYAN}{BOLD}\
╔════════════════════════════════════════════════════════════╗
║ POLICY ADVISOR CTF -- MECHANISTIC MODE ║
╠════════════════════════════════════════════════════════════╣
║ ║
║ Your sandbox blocks all traffic except api.anthropic.com ║
║ 7 gates stand between you and victory. ║
║ ║
║ Each gate needs a network call to a public service. ║
║ Approve the policy recommendations in the TUI to ║
║ unlock each gate, one by one. ║
║ ║
╚════════════════════════════════════════════════════════════╝\
+============================================================+
| POLICY ADVISOR CTF -- MECHANISTIC MODE |
+============================================================+
| |
| Your sandbox blocks all traffic except api.anthropic.com |
| 6 gates stand between you and victory. |
| |
| Gate 1 HTTPS endpoint (CONNECT tunnel) |
| Gate 2 HTTP endpoint (forward proxy) |
| Gate 3-5 Concurrent requests (batch approval) |
| Gate 6 Internal IP endpoint (allowed_ips SSRF override) |
| |
+============================================================+\
{RESET}
"""
VICTORY = f"""
{GREEN}{BOLD}\
╔════════════════════════════════════════════════════════════╗
║ ║
║ * ALL 7 GATES UNLOCKED * ║
║ ║
║ You've mastered mechanistic policy recommendations. ║
║ ║
║ Each denied connection was detected by the sandbox ║
║ proxy, aggregated into a denial summary, transported ║
║ to the gateway, and mapped into a NetworkPolicyRule ║
║ for your approval. ║
║ ║
║ Next up: AI-assisted mode (issue #205). ║
║ ║
╚════════════════════════════════════════════════════════════╝\
+============================================================+
| |
| * ALL 6 GATES UNLOCKED * |
| |
| You've mastered mechanistic policy recommendations. |
| |
| Each denied connection was detected by the sandbox |
| proxy, aggregated into a denial summary, transported |
| to the gateway, and mapped into a NetworkPolicyRule |
| for your approval. |
| |
| Next up: AI-assisted mode (issue #205). |
| |
+============================================================+\
{RESET}
"""
# ── Dry-run ──────────────────────────────────────────────────────────────────
# -- Dry-run -------------------------------------------------------------------
def dry_run() -> None:
@@ -300,17 +256,179 @@ def dry_run() -> None:
print()
for g in GATES:
proto = "HTTPS" if g["port"] == 443 else "HTTP"
concurrent = " (concurrent)" if g["num"] >= 3 else ""
print(
f" {CYAN}Gate {g['num']}{RESET} "
f"{BOLD}{g['name']}{RESET} "
f"{DIM}{g['host']}:{g['port']} {g['method']} {proto}{RESET}"
f"{DIM}{g['host']}:{g['port']} {g['method']} {proto}{concurrent}{RESET}"
)
print(f" {DIM}{g['hint']}{RESET}")
print()
log("INFO", "Run without --dry-run inside a sandbox to start the challenge")
# ── Main CTF loop ────────────────────────────────────────────────────────────
# -- Sequential gate runner ----------------------------------------------------
def run_gate(gate: dict) -> bool:
"""Run a single gate with retry loop. Returns True on success."""
num = gate["num"]
total = len(GATES)
print(f" {CYAN}{BOLD}{'=' * 60}{RESET}")
print(f" {CYAN}{BOLD} GATE {num}/{total}: {gate['name'].upper()}{RESET}")
proto = "https" if gate["port"] == 443 else "http"
print(
f" {DIM} {gate['hint']}{RESET}\n"
f" {DIM} target: {proto}://{gate['host']}:{gate['port']} "
f"({gate['method']}){RESET}"
)
print(f" {CYAN}{BOLD}{'=' * 60}{RESET}")
print()
for attempt in range(1, MAX_RETRIES + 1):
log(
"GATE",
f"Gate {num} attempt #{attempt}",
host=gate["host"],
port=gate["port"],
)
status, result = attempt_gate(gate)
if status == "pass":
log("PASS", f"Gate {num} UNLOCKED")
log("FLAG", result)
print()
return True
if status == "error":
log("WARN", f"Gate {num} skipped: {result}")
log("INFO", "This is an upstream error, not a proxy block")
print()
return True
log("FAIL", f"Gate {num}: {result}")
if attempt == 1:
log("WARN", "Approve the recommendation in the TUI to proceed")
log(
"INFO",
"TUI: select sandbox -> [r] drafts -> [a] approve",
)
for remaining in range(RETRY_INTERVAL, 0, -1):
print(
f"\r {DIM} retrying in {remaining:>2}s ...{RESET}",
end="",
flush=True,
)
time.sleep(1)
print("\r" + " " * 50 + "\r", end="", flush=True)
log("FAIL", f"Gate {num} timed out after {MAX_RETRIES} attempts")
return False
# -- Concurrent gate runner ----------------------------------------------------
def run_gates_concurrent(gates: list[dict]) -> int:
"""Run multiple gates concurrently, retrying until all pass.
Returns the number of gates that passed.
"""
nums = ", ".join(str(g["num"]) for g in gates)
total = len(GATES)
print(f" {CYAN}{BOLD}{'=' * 60}{RESET}")
print(f" {CYAN}{BOLD} GATES {nums} (CONCURRENT):{RESET}")
for g in gates:
proto = "https" if g["port"] == 443 else "http"
print(
f" {DIM} Gate {g['num']}: {g['name']} "
f"{proto}://{g['host']}:{g['port']}{RESET}"
)
print(f" {CYAN}{BOLD}{'=' * 60}{RESET}")
print()
log(
"INFO",
f"Firing {len(gates)} requests concurrently -- approve them all at once",
)
log("INFO", "Tip: press [A] in the TUI draft panel to approve all pending")
print()
remaining_gates = list(gates)
for attempt in range(1, MAX_RETRIES + 1):
log(
"GATE",
f"Concurrent attempt #{attempt}",
pending=len(remaining_gates),
)
# Fire all remaining gates concurrently.
results: dict[int, tuple[str, str]] = {}
threads: list[threading.Thread] = []
def _attempt(g: dict) -> None:
results[g["num"]] = attempt_gate(g)
for g in remaining_gates:
t = threading.Thread(target=_attempt, args=(g,))
threads.append(t)
t.start()
for t in threads:
t.join(timeout=30)
# Process results.
still_blocked = []
for g in remaining_gates:
num = g["num"]
status, result = results.get(num, ("blocked", "no result"))
if status == "pass":
log("PASS", f"Gate {num}/{total} UNLOCKED ({g['name']})")
log("FLAG", result)
elif status == "error":
log("WARN", f"Gate {num}/{total} skipped: {result}")
else:
log("FAIL", f"Gate {num}/{total}: {result}")
still_blocked.append(g)
passed = len(remaining_gates) - len(still_blocked)
if passed > 0:
print()
if not still_blocked:
return len(gates)
remaining_gates = still_blocked
if attempt == 1:
log("WARN", "Approve the recommendations in the TUI to proceed")
log(
"INFO",
"TUI: select sandbox -> [r] drafts -> [A] approve all",
)
for secs in range(RETRY_INTERVAL, 0, -1):
print(
f"\r {DIM} retrying in {secs:>2}s ...{RESET}",
end="",
flush=True,
)
time.sleep(1)
print("\r" + " " * 50 + "\r", end="", flush=True)
failed = len(remaining_gates)
log("FAIL", f"{failed} gate(s) timed out after {MAX_RETRIES} attempts")
return len(gates) - failed
# -- Main CTF loop -------------------------------------------------------------
def run_ctf() -> int:
@@ -326,88 +444,35 @@ def run_ctf() -> int:
completed = 0
for gate in GATES:
num = gate["num"]
total = len(GATES)
# Gate 1: single HTTPS endpoint
if not run_gate(GATES[0]):
return 1
completed += 1
# Gate header
print(f" {CYAN}{BOLD}{'─' * 60}{RESET}")
print(f" {CYAN}{BOLD} GATE {num}/{total}: {gate['name'].upper()}{RESET}")
proto = "https" if gate["port"] == 443 else "http"
print(
f" {DIM} {gate['hint']}{RESET}\n"
f" {DIM} target: {proto}://{gate['host']}:{gate['port']} "
f"({gate['method']}){RESET}"
)
print(f" {CYAN}{BOLD}{'─' * 60}{RESET}")
print()
# Gate 2: single HTTP endpoint
if not run_gate(GATES[1]):
return 1
completed += 1
# Retry loop
for attempt in range(1, MAX_RETRIES + 1):
log(
"GATE",
f"Gate {num} attempt #{attempt}",
host=gate["host"],
port=gate["port"],
)
# Gates 3-5: concurrent
completed += run_gates_concurrent(GATES[2:5])
status, result = attempt_gate(gate)
# Gate 6: internal IP endpoint (allowed_ips SSRF override)
if not run_gate(GATES[5]):
return 1
completed += 1
if status == "pass":
log("PASS", f"Gate {num} UNLOCKED")
log("FLAG", result)
completed += 1
print()
break
# Done
if completed >= len(GATES):
print(VICTORY)
log("INFO", "CTF complete", gates_passed=f"{completed}/{len(GATES)}")
return 0
if status == "error":
# Real upstream failure — not a proxy block. Skip the gate
# so the demo doesn't stall on a flaky third-party service.
log("WARN", f"Gate {num} skipped: {result}")
log("INFO", "This is an upstream error, not a proxy block")
completed += 1
print()
break
# status == "blocked" — proxy denied the connection; retryable.
log("FAIL", f"Gate {num}: {result}")
if attempt == 1:
log("WARN", "Approve the recommendation in the TUI to proceed")
log(
"INFO",
"TUI: select sandbox -> [p] policy -> [r] drafts -> [a] approve",
)
# Countdown before retry
for remaining in range(RETRY_INTERVAL, 0, -1):
print(
f"\r {DIM} retrying in {remaining:>2}s ...{RESET}",
end="",
flush=True,
)
time.sleep(1)
# Clear the countdown line
print("\r" + " " * 50 + "\r", end="", flush=True)
else:
log(
"FAIL",
f"Gate {num} timed out after {MAX_RETRIES} attempts",
)
log(
"WARN",
"Check that the gateway is running and the TUI is connected.",
)
return 1
# All gates passed
print(VICTORY)
log("INFO", "CTF complete", gates_passed=f"{completed}/{len(GATES)}")
return 0
log("WARN", f"CTF incomplete: {completed}/{len(GATES)} gates passed")
return 1
# ── Entry point ──────────────────────────────────────────────────────────────
# -- Entry point ---------------------------------------------------------------
if __name__ == "__main__":
try: