Skip to content

Commit 897eba0

Browse files
committed
feat(gateway): enforce image_digest bans at cage run create + claim (#1678)
The agent_bans store has accepted image_digest targets since migration 0029 but never consulted them. A banned sandbox image digest now blocks fail-closed (403) at both moments that matter: - POST /v1/agent-cage/runs — when cage_spec.image_digest is present, registration is denied before the run record or start command exist; - POST /v1/agent-cage/runs/:id/claim — re-checked against the digest stored on the run record, so a ban created after registration still blocks the sandbox from ever starting. Storage errors during the check are 500s (unreadable enforcement state blocks). Tests written RED-first: banned digest denied at create, late ban denied at claim, unrelated digest ban does not block. Honesty ledger: fingerprint has no carrier field at any choke point today (mTLS yields CN only — needs a protobuf-first contract change) and prompt_hash appears only at telemetry ingest, where blocking would destroy evidence (enforcement belongs to SOC auto-response). Both stay stored-but-unenforced, now documented as such with the reason.
1 parent 3159e07 commit 897eba0

3 files changed

Lines changed: 179 additions & 6 deletions

File tree

.claude/PRPs/tasks/task.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,10 @@ Blocks multi-replica and enterprise-ops claims.
6969
- [x] **Ban/quarantine enforcement** at all choke points (authorize, broker,
7070
cage start/claim, egress) + agent-ban propagation to live runs as signed
7171
`kill_run` commands — see `feat/ban-enforcement-choke-points`.
72-
Remaining: `fingerprint`/`image_digest`/`prompt_hash` ban target types
73-
are stored but not yet consulted anywhere.
72+
`image_digest` bans now enforced at cage run create + claim
73+
(`feat/ban-image-digest-enforcement`). Remaining: `fingerprint` (no
74+
carrier field at any choke point yet) and `prompt_hash` (telemetry-only;
75+
enforcement = SOC auto-response design) stay stored-but-unenforced.
7476
- [ ] **Standalone MCP proxy binary** (optional product line; Lite remains prod).
7577
- [ ] **Branch hygiene** — merge-or-close pass on ~80 remote branches (no bulk
7678
delete without one-by-one review).

docs/Implementation_Status.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ and is not production-authoritative.
6464
| Agent runs registry (cage control-plane API) | Partial | `lib/storage/src/db/agent_runs.rs`, migration `0026`; `routes/runtime.rs` (`/v1/agent-cage/runs` + controls) | claim/heartbeat/lease APIs + executor not on main (WIP branch) | #1681 | route + storage | beta | cage execution PR |
6565
| Runtime events ingest + timeline | Partial | `POST /v1/ingest/runtime-events`, `list/query` ASE APIs | rich producers (process/fs/net) on sensor | #1681 | storage + route | beta | sensor collectors |
6666
| Control commands (signed kill/pause/quarantine) | Partial | store + protocol + gateway issue routes; sensor poll/verify + **host ProcessEnforcer** (SIGTERM/STOP/CONT for registered PIDs); cage-runner Docker kill path | auto PID discovery/collectors; grace_period from command payload | Phased PR plan §5 | storage + sensor unit (real child kill) | beta | collectors |
67-
| Ban system (first-class store) | Implemented | `lib/storage/src/db/agent_bans.rs`, migration `0029`; enforced at every choke point: `POST /v1/authorize` (agent + tool bans → durable deterministic deny via `write_decision_and_audit`), `POST /v1/broker/execute` (tool ban → 403 before approval consumption), `POST /v1/agent-cage/runs` create + claim (agent ban → 403; claim re-checks so a ban created after registration still blocks start), `POST /v1/egress/check` (destination ban, pre-existing); sensor prop: `POST /v1/bans` with `target_type=agent` issues signed `kill_run` control commands for the agent's active runs (`list_active_agent_runs_for_agent`) | `fingerprint`/`image_digest`/`prompt_hash` target types stored but not yet consulted at a choke point | #1678 | storage + route (authorize/broker/runtime/control/egress) | beta | remaining target types |
67+
| Ban system (first-class store) | Implemented | `lib/storage/src/db/agent_bans.rs`, migration `0029`; enforced at every choke point: `POST /v1/authorize` (agent + tool bans → durable deterministic deny via `write_decision_and_audit`), `POST /v1/broker/execute` (tool ban → 403 before approval consumption), `POST /v1/agent-cage/runs` create + claim (agent ban → 403; claim re-checks so a ban created after registration still blocks start), `POST /v1/egress/check` (destination ban, pre-existing); sensor prop: `POST /v1/bans` with `target_type=agent` issues signed `kill_run` control commands for the agent's active runs (`list_active_agent_runs_for_agent`) | `image_digest` bans enforced at cage run create + claim (`image_digest_ban_denial`, re-checked at claim so a ban created after registration still blocks start); `fingerprint`/`prompt_hash` target types stored but not consulted — `fingerprint` has no carrier at any choke point today (mTLS yields CN only; needs a wire field, i.e. a protobuf-first contract change), `prompt_hash` only appears at telemetry ingest where blocking would destroy evidence (enforcement belongs to SOC auto-response, a separate design) | #1678 | storage + route (authorize/broker/runtime/control/egress) | beta | remaining target types |
6868
| Quarantine records | Partial | `lib/storage/src/db/quarantine.rs`, migration `0030`; agent-status quarantine; `quarantine_records` enforced at `POST /v1/authorize` (agent), `POST /v1/broker/execute` (tool), cage run create + claim (agent + run), egress (run/sandbox/agent, pre-existing) | workspace/sandbox evidence-freeze semantics (needs cage) | #1679 | storage + route | beta | cage integration |
6969
| Node sensor | Implemented | `bins/aegis-node-sensor` (main, spool, shipper, command_receiver); real `process`/`net`/`fs`/`secret` collectors (`AEGIS_RUN_ID`-tagged process discovery -> `ProcessEnforcer` registration + `network_connection`/filesystem/secret-signal runtime events), all polled from the main loop; Dockerfile, Helm, compose; `tests/real_host_integration.rs` proves `scan_host_aegis_processes`/`ProcessCollector`/`NetCollector`/`FsCollector`/`SecretCollector` against a real Linux host's actual `/proc`, a real signal-killed child, a real established TCP socket, a real open file descriptor, and a real secret-shaped env var (name reported, value never leaves the host) -- not the synthetic `/proc` tempdir fixtures the module unit tests use; `scripts/sensor-soak.sh` + CI `sensor-soak.yml` (nightly 15-min + dispatchable multi-hour + PR smoke) soak the real sensor on the runner's real `/proc` under continuous collector workload, signed-kill round-trips, and a mid-soak gateway outage, asserting bounded RSS/fds/spool disk, zero panics, full spool drain, and unattended recovery; steady-state spool compaction (`compact_if_reclaimable`, wired into the ship tick) keeps lane files from growing without bound on long-lived hosts | extended-duration soak on a production-grade deployment (the nightly CI soak runs on a CI runner, not a production host under production load) | Phased PR plan §5 | unit (89 tests) + 5 real-host integration tests + nightly CI soak | beta | production-deployment soak |
7070
| Agent cage runner | Partial | binary + DockerRuntime + Dockerfile + compose `cage` + Helm; claim lifecycle smoke; host-Docker review + hardened create; `scripts/cage-docker-e2e.sh` + CI job (quick finish + signed kill against real Docker) | sensor↔runner IPC; forced egress netns; product “untrusted→incident” narrative e2e | Phased PR plan §6 | unit (lib) + claim lifecycle + cage-docker-e2e + helm lint | beta (local/k8s) | forced egress + sensor enforce |

src/src/routes/runtime.rs

Lines changed: 174 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,31 @@ async fn agent_ban_denial(
323323
}
324324
}
325325

326+
/// Image-digest ban enforcement shared by run registration and claim
327+
/// (#1678: ban target types beyond agent/tool). A banned sandbox image is
328+
/// blocked before it ever starts; a storage error is a 500 (fail-closed —
329+
/// unreadable enforcement state blocks).
330+
async fn image_digest_ban_denial(
331+
state: &AppState,
332+
tenant_id: &str,
333+
image_digest: &str,
334+
) -> Result<Option<StatusError>, StatusError> {
335+
match state
336+
.storage
337+
.is_banned(tenant_id, "image_digest", image_digest, Utc::now())
338+
.await
339+
{
340+
Err(e) => {
341+
error!("Failed to check image-digest ban state: {:?}", e);
342+
Err(StatusError::internal("Database error"))
343+
}
344+
Ok(true) => Ok(Some(StatusError::forbidden(
345+
"sandbox image digest is banned; runs are denied (fail-closed)",
346+
))),
347+
Ok(false) => Ok(None),
348+
}
349+
}
350+
326351
pub async fn create_agent_run(
327352
State(state): State<Arc<AppState>>,
328353
TenantId(tenant_id): TenantId,
@@ -342,6 +367,17 @@ pub async fn create_agent_run(
342367
Err(e) => return e.into_response(),
343368
}
344369
}
370+
if let Some(digest) = req
371+
.cage_spec
372+
.as_ref()
373+
.and_then(|spec| spec.image_digest.as_deref())
374+
{
375+
match image_digest_ban_denial(&state, &tenant_id, digest).await {
376+
Ok(Some(denial)) => return denial.into_response(),
377+
Ok(None) => {}
378+
Err(e) => return e.into_response(),
379+
}
380+
}
345381

346382
let mut image_ref = None;
347383
let mut image_digest = None;
@@ -490,6 +526,13 @@ pub async fn claim_run(
490526
Err(e) => return e.into_response(),
491527
}
492528
}
529+
if let Some(digest) = run.image_digest.as_deref() {
530+
match image_digest_ban_denial(&state, &tenant_id, digest).await {
531+
Ok(Some(denial)) => return denial.into_response(),
532+
Ok(None) => {}
533+
Err(e) => return e.into_response(),
534+
}
535+
}
493536
match state
494537
.storage
495538
.is_quarantined(&tenant_id, "run", &run_id)
@@ -1822,14 +1865,19 @@ mod tests {
18221865

18231866
// ── Ban / quarantine enforcement at cage start (Phase 2.4/2.5) ──────────
18241867

1825-
async fn ban_agent(state: &Arc<AppState>, tenant_id: &str, agent_id: &str) {
1868+
async fn ban_target(
1869+
state: &Arc<AppState>,
1870+
tenant_id: &str,
1871+
target_type: &str,
1872+
target_value: &str,
1873+
) {
18261874
state
18271875
.storage
18281876
.insert_ban(&AgentBanRecord {
18291877
id: Uuid::new_v4().to_string(),
18301878
tenant_id: tenant_id.to_string(),
1831-
target_type: "agent".to_string(),
1832-
target_value: agent_id.to_string(),
1879+
target_type: target_type.to_string(),
1880+
target_value: target_value.to_string(),
18331881
scope: "tenant".to_string(),
18341882
reason: Some("test ban".to_string()),
18351883
actor: "test-operator".to_string(),
@@ -1843,6 +1891,10 @@ mod tests {
18431891
.unwrap();
18441892
}
18451893

1894+
async fn ban_agent(state: &Arc<AppState>, tenant_id: &str, agent_id: &str) {
1895+
ban_target(state, tenant_id, "agent", agent_id).await;
1896+
}
1897+
18461898
async fn quarantine_target(
18471899
state: &Arc<AppState>,
18481900
tenant_id: &str,
@@ -1918,6 +1970,125 @@ mod tests {
19181970
assert_eq!(response.status(), StatusCode::FORBIDDEN);
19191971
}
19201972

1973+
/// Registering a cage run whose `image_digest` is banned is a fail-closed
1974+
/// 403 — the `agent_bans` store promises image-digest enforcement
1975+
/// "before sandbox start" (#1678: target types beyond agent/tool).
1976+
#[tokio::test]
1977+
async fn create_agent_run_denies_banned_image_digest() {
1978+
let (state, tenant_id, _agent_token) = setup_state_with_command_signing_key(
1979+
"cage_create_banned_image",
1980+
TEST_COMMAND_SIGNING_SECRET_HEX,
1981+
)
1982+
.await;
1983+
let digest = "sha256:feedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface";
1984+
ban_target(&state, &tenant_id, "image_digest", digest).await;
1985+
1986+
let mut spec = sample_cage_spec();
1987+
spec.image_digest = Some(digest.to_string());
1988+
let response = create_agent_run(
1989+
State(state.clone()),
1990+
TenantId(tenant_id.clone()),
1991+
Json(CreateAgentRunRequest {
1992+
run_key: "run-banned-image".to_string(),
1993+
agent_id: None,
1994+
source_component: "cage-runner".to_string(),
1995+
mode: None,
1996+
root_trace_id: None,
1997+
root_trust_level: None,
1998+
cage_spec: Some(spec),
1999+
}),
2000+
)
2001+
.await
2002+
.into_response();
2003+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
2004+
}
2005+
2006+
/// A ban on a different image digest must not block an unrelated run.
2007+
#[tokio::test]
2008+
async fn create_agent_run_allows_unrelated_image_digest_ban() {
2009+
let (state, tenant_id, _agent_token) = setup_state_with_command_signing_key(
2010+
"cage_create_other_image_ban",
2011+
TEST_COMMAND_SIGNING_SECRET_HEX,
2012+
)
2013+
.await;
2014+
ban_target(
2015+
&state,
2016+
&tenant_id,
2017+
"image_digest",
2018+
"sha256:0000000000000000000000000000000000000000000000000000000000000000",
2019+
)
2020+
.await;
2021+
2022+
let mut spec = sample_cage_spec();
2023+
spec.image_digest = Some(
2024+
"sha256:1111111111111111111111111111111111111111111111111111111111111111".to_string(),
2025+
);
2026+
let response = create_agent_run(
2027+
State(state.clone()),
2028+
TenantId(tenant_id.clone()),
2029+
Json(CreateAgentRunRequest {
2030+
run_key: "run-other-image".to_string(),
2031+
agent_id: None,
2032+
source_component: "cage-runner".to_string(),
2033+
mode: None,
2034+
root_trace_id: None,
2035+
root_trust_level: None,
2036+
cage_spec: Some(spec),
2037+
}),
2038+
)
2039+
.await
2040+
.into_response();
2041+
assert_eq!(response.status(), StatusCode::CREATED);
2042+
}
2043+
2044+
/// An image-digest ban created AFTER the run was registered still blocks
2045+
/// the claim — claiming is the moment execution actually starts, so it
2046+
/// re-checks against the digest stored on the run record.
2047+
#[tokio::test]
2048+
async fn claim_run_denies_image_digest_banned_after_registration() {
2049+
let (state, tenant_id, _agent_token) = setup_state_with_command_signing_key(
2050+
"cage_claim_banned_image",
2051+
TEST_COMMAND_SIGNING_SECRET_HEX,
2052+
)
2053+
.await;
2054+
let digest = "sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2055+
2056+
let mut spec = sample_cage_spec();
2057+
spec.image_digest = Some(digest.to_string());
2058+
let response = create_agent_run(
2059+
State(state.clone()),
2060+
TenantId(tenant_id.clone()),
2061+
Json(CreateAgentRunRequest {
2062+
run_key: "run-claim-image-ban".to_string(),
2063+
agent_id: None,
2064+
source_component: "cage-runner".to_string(),
2065+
mode: None,
2066+
root_trace_id: None,
2067+
root_trust_level: None,
2068+
cage_spec: Some(spec),
2069+
}),
2070+
)
2071+
.await
2072+
.into_response();
2073+
assert_eq!(response.status(), StatusCode::CREATED);
2074+
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
2075+
let run: AgentRunRecord = serde_json::from_slice(&body).unwrap();
2076+
2077+
ban_target(&state, &tenant_id, "image_digest", digest).await;
2078+
2079+
let response = claim_run(
2080+
State(state.clone()),
2081+
TenantId(tenant_id.clone()),
2082+
Path(run.id.clone()),
2083+
Json(RunnerIdRequest {
2084+
runner_id: "runner-1".to_string(),
2085+
}),
2086+
)
2087+
.await
2088+
.into_response();
2089+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
2090+
}
2091+
19212092
/// A ban created AFTER the run was registered still blocks the claim —
19222093
/// claiming is the moment execution actually starts, so it re-checks.
19232094
#[tokio::test]

0 commit comments

Comments
 (0)