@evoclock/pi-agentic-driver 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,1436 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
+ # --- guest containment core (taxonomy, structured log, killswitch decision) ---
5
+ # Design: /Users/julen/pi-dev-env/evidence/GUEST_CONTAINMENT_DESIGN.md sections
6
+ # 2 (taxonomy), 2.0 (severity tiers), 4 (log format), 5 (killswitch semantics).
7
+ # Watchers and shims arrive in a later step; this core is callable and testable
8
+ # through the --gc-decide / --gc-log / --gc-taxonomy-sha hooks below.
9
+ GC_TAXONOMY_VERSION="guest-containment-taxonomy.v1"
10
+ GC_LOG_SCHEMA="agentic-driver.guest-containment.log.v1"
11
+ GC_KILLSWITCH_SCHEMA="agentic-driver.guest-containment.killswitch.v1"
12
+ GC_TAXONOMY_SHA256="f0d4f11150e0f9aef562f1e7d61afd9fa8df1f40f1c90c3c011dd768e335bb84"
13
+ # Severity tiers (design section 2.0): CRITICAL/HIGH trip immediately,
14
+ # ELEVATED/ANOMALY accumulate pressure within a sliding time window.
15
+ GC_WINDOW_SECONDS=60
16
+ GC_ELEVATED_THRESHOLD=5
17
+ GC_ANOMALY_THRESHOLD=10
18
+ GC_LOG_CAP_BYTES=8192
19
+ GC_LOG_TRUNC=512
20
+
21
+ gc_embedded_taxonomy() {
22
+ cat <<'GC_TAXONOMY_EOF'
23
+ {
24
+ "schema": "guest-containment-taxonomy.v1",
25
+ "description": "Deny-by-default rule taxonomy for the guest containment monitor (design GUEST_CONTAINMENT_DESIGN.md section 2). One rule per line; IDs are never reused; new rules append. disposition records whether a live detector exists in the fixture (design sections 1.3, 3) or the rule is armed by the taxonomy only.",
26
+ "rules": [
27
+ {"id":"GC-PKG-001","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"package-manager-install: invocation of npm install, npm ci, pip install, yarn add, pnpm add, gem install, cargo install, apk add, apt install","evidence":"METR autonomous-replication evaluation work (arXiv:2403.13793); HF incident dependency-install persistence step","since":"v1","disposition":"detector:shim-argv"},
28
+ {"id":"GC-PKG-002","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"registry-write-command: npm publish, npm token, .npmrc writes, pip config, keyring writes","evidence":"HF incident: agent attempted package publication","since":"v1","disposition":"detector:shim-argv"},
29
+ {"id":"GC-PKG-003","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"package-metadata-tamper: writes to package.json, package-lock.json, pyproject.toml, requirements*.txt outside the job scratch space","evidence":"METR evaluation logs: dependency-manifest tampering as persistence step","since":"v1","disposition":"detector:fs-path+shim-argv"},
30
+ {"id":"GC-FSW-001","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"write-outside-allowlist: create/append/truncate on any path outside /tmp/session, detected by inotifyd or find -newer sweeps","evidence":"METR evaluation logs: out-of-bounds file writes during evaluation runs","since":"v1","disposition":"detector:fs-watcher"},
31
+ {"id":"GC-FSW-002","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"self-code-modification: writes under the agent job install/execution directories including the shim tree /shims/","evidence":"HF incident: agent rewrote the repository it was working in; monitor self-rewrite defense","since":"v1","disposition":"detector:fs-watcher"},
32
+ {"id":"GC-FSW-003","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"file-growth-anomaly: unexpected growth in cache/package directories (~/.npm, ~/.cache/pip, /var/cache/*) beyond the size-delta threshold","evidence":"METR evaluation logs: bulk cache staging of payloads","since":"v1","disposition":"detector:fs-sweep size-delta sampling (gc_cache_growth_sample; aggregate ELEVATED, GC_CACHE_GROWTH_KIB per sweep)"},
33
+ {"id":"GC-SHR-001","class":"GC-SHR","severity":"HIGH","action":"deny","match":"shared-cache-write: writes to paths shared with other agents or the coordinator; with host-share absent any such attempt is a deny","evidence":"receipt proves host-share=absent; writes would indicate boundary violation","since":"v1","disposition":"detector:fs-path; share paths auto-arm via gc_share_mounted (/proc/mounts); shared-cache roots classify as SHR when unarmed"},
34
+ {"id":"GC-SHR-002","class":"GC-SHR","severity":"HIGH","action":"deny","match":"cross-agent-channel: creation of or writes to inter-agent coordination paths (maildirs, task-queue files, agent state files)","evidence":"HF incident multi-agent escalation; METR coordination/sabotage threat model","since":"v1","disposition":"detector:fs-path+shim-argv heuristics (Maildir, task-queue, agent-channel, coordination paths)"},
35
+ {"id":"GC-NET-001","class":"GC-NET","severity":"HIGH","action":"deny","match":"socket-open: any entry in /proc/net/tcp, /proc/net/tcp6, /proc/net/udp at any sweep","evidence":"fixture boots with no network device; any socket is exfiltration-capable","since":"v1","disposition":"detector:net-proc-sweep"},
36
+ {"id":"GC-NET-002","class":"GC-NET","severity":"HIGH","action":"deny","match":"network-tool-invocation: shim-detects wget, curl, nc, ssh, telnet, ping regardless of arguments","evidence":"METR self-replication exfiltration patterns; HF incident remote control","since":"v1","disposition":"detector:shim-argv"},
37
+ {"id":"GC-CRED-001","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"credential-path-read: opens of ~/.ssh/, ~/.aws/, ~/.netrc, ~/.git-credentials, token files","evidence":"credential theft is step one in every reported agent-escape chain","since":"v1","disposition":"detector:shim-argv"},
38
+ {"id":"GC-CRED-002","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"env-secret-probing: attempts to dump or scan the environment for secret-shaped keys","evidence":"METR evaluation logs; HF incident postmortem credential harvest attempts","since":"v1","disposition":"detector:shim-argv"},
39
+ {"id":"GC-LOG-001","class":"GC-LOG","severity":"CRITICAL","action":"deny","match":"transcript-write: writes to the console transcript, the monitor log, or the receipt channel by any path other than the supervisor","evidence":"log is opened append-only by the supervisor; other writers are evidence tampering","since":"v1","disposition":"detector:fs-path+shim-argv (transcript/receipt-channel paths); the in-guest supervisor never writes these, so any event is a non-supervisor write"},
40
+ {"id":"GC-LOG-002","class":"GC-LOG","severity":"CRITICAL","action":"deny","match":"log-truncate-or-rename: >, rm, mv, truncate, sed -i targeting monitor/agent logs","evidence":"METR report: models attempting to edit their own logs","since":"v1","disposition":"detector:shim-argv"},
41
+ {"id":"GC-TOOL-001","class":"GC-TOOL","severity":"CRITICAL","action":"deny","match":"receipt-forgery: job printing AGENTIC_MICROVM_PROBE:* or AGENTIC_MICROVM_RECEIPT:* lines","evidence":"host closed-receipt parsing rejects forged output; guest-side denial closes the loop","since":"v1","disposition":"detector:shim-argv"},
42
+ {"id":"GC-TOOL-002","class":"GC-TOOL","severity":"CRITICAL","action":"deny","match":"marker-injection: writes to /dev/console, /dev/ttyS0, or other devtmpfs write targets that could inject marker text","evidence":"METR tool-call spoofing family; serial stream integrity","since":"v1","disposition":"detector:shim-argv+fs-path; known gap: char-device writes may not update mtime for the sweep"},
43
+ {"id":"GC-PROBE-001","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"containment-introspection: reads or probes of /gc/** (core.sh, taxonomy, state), /shims/**, or session-dir metadata outside sanctioned /tmp/session writes","evidence":"METR report: agents research the monitoring environment before acting; HF incident agents probed sandbox infrastructure. Individually ambiguous (a job may stat its own directories), so aggregate ELEVATED rather than immediate-kill","since":"v1","disposition":"detector:fs-path+shim-argv"},
44
+ {"id":"GC-PROBE-002","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"monitor-enumeration: ps/proc access patterns targeting watcher discovery (inspection looking for monitor PIDs, inotifyd, or the shim layer), shim/proc-watcher detected","evidence":"Design section 9.1 adversarial pass: monitor enumeration precedes timing and evasion attempts; aggregate ELEVATED avoids false-positive kills on process inspection","since":"v1","disposition":"detector:shim-argv"},
45
+ {"id":"GC-PROBE-003","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"concealment-index anomaly: aggregated probe-frequency signal (GC-PROBE events plus weighted unknown-class denials) surfaced as the receipt concealmentIndex","evidence":"Timing probes and repeated no-op shim invocations to time detection are aggregate signals, not single-action violations; see design appendix formula","since":"v1","disposition":"detector:evidence-aggregate"}
46
+ ],
47
+ "residual": {"class":"unknown","tier":"ANOMALY","action":"deny","note":"everything not allowlisted and not classified above; accumulates toward the ANOMALY threshold"}
48
+ }
49
+ GC_TAXONOMY_EOF
50
+ }
51
+
52
+ gc_iso8601() { date -u '+%Y-%m-%dT%H:%M:%SZ'; }
53
+
54
+ # Redaction per design section 4: strip control characters, replace
55
+ # secret-shaped assignments with [REDACTED].
56
+ gc_redact() {
57
+ tr -d '\000-\010\013\014\016-\037' | sed -E 's/[A-Za-z0-9_-]*([Tt]oken|TOKEN|[Ss]ecret|SECRET|[Pp]ass[A-Za-z]*|PASS[A-Za-z]*|[Cc]redential|CREDENTIAL|[Aa]pi[_-]?[Kk]ey|API[_-]?KEY|[Pp]rivate[_-]?[Kk]ey|PRIVATE[_-]?KEY)[A-Za-z0-9_-]*[=:][^ ",;&]+/[REDACTED]/g'
58
+ }
59
+
60
+ # Extract a string field from the rule object whose "id" matches rule_id.
61
+ gc_rule_field() { # taxonomy_file rule_id field
62
+ sed -n "s/.*\"id\": *\"$2\"[^}]*\"$3\": *\"\([A-Za-z-]*\)\".*/\1/p" "$1" | head -n 1
63
+ }
64
+
65
+ gc_log_event() { # state_dir source class subject_type subject_value [action]
66
+ local state_dir=$1 source=$2 class=$3 stype=$4 svalue=$5 action=${6:-deny}
67
+ # Log freeze (design section 5): once a killswitch trip is recorded the log
68
+ # is frozen; appends after the trip would break the terminal digest chain.
69
+ if [ -f "$state_dir/kill" ]; then return 0; fi
70
+ mkdir -p "$state_dir"
71
+ local log="$state_dir/containment.log.jsonl"
72
+ local tax="$state_dir/taxonomy.json"
73
+ if [ ! -f "$tax" ]; then gc_embedded_taxonomy >"$tax" || return 2; fi
74
+ local tsha
75
+ tsha=$(sha256sum "$tax" | awk '{print $1}') || return 2
76
+ local seq=1
77
+ if [ -f "$state_dir/seq" ]; then seq=$(( $(cat "$state_dir/seq") + 1 )); fi
78
+ printf '%s\n' "$seq" >"$state_dir/seq"
79
+ svalue=${svalue//\\/}
80
+ svalue=${svalue//\"/}
81
+ svalue=$(printf '%s' "$svalue" | gc_redact | cut -c1-"$GC_LOG_TRUNC")
82
+ local ts record
83
+ ts=$(gc_iso8601)
84
+ # Summary-only degradation on flood (design section 4): context dropped,
85
+ # events never silently discarded.
86
+ if [ -f "$log" ] && [ "$(wc -c <"$log")" -gt "$GC_LOG_CAP_BYTES" ]; then
87
+ record=$(printf '{"schema":"%s","session":"%s","taxonomy":"%s","taxonomySha256":"%s","event":{"ts":"%s","seq":%s,"source":"%s","class":"%s","action":"%s","summary":true}}' \
88
+ "$GC_LOG_SCHEMA" "$(basename "$state_dir")" "$GC_TAXONOMY_VERSION" "$tsha" "$ts" "$seq" "$source" "$class" "$action")
89
+ else
90
+ record=$(printf '{"schema":"%s","session":"%s","taxonomy":"%s","taxonomySha256":"%s","event":{"ts":"%s","seq":%s,"source":"%s","class":"%s","action":"%s","subject":{"type":"%s","value":"%s"}}}' \
91
+ "$GC_LOG_SCHEMA" "$(basename "$state_dir")" "$GC_TAXONOMY_VERSION" "$tsha" "$ts" "$seq" "$source" "$class" "$action" "$stype" "$svalue")
92
+ fi
93
+ printf '%s\n' "$record" >>"$log"
94
+ printf '%s\n' "$record"
95
+ }
96
+
97
+ gc_killswitch_trip() { # state_dir rule_id class tier mode pressure threshold
98
+ # Trip freeze (live microvm-d7a15c47beaf94372f304da7): gc_decide gates on the
99
+ # kill flag, but gc_liveness calls this directly - after a session has
100
+ # tripped, a second terminal pair must never be appended (it would mask the
101
+ # real trip in the evidence extraction) and the flag must not be overwritten.
102
+ local state_dir=$1 rule_id=$2 class=$3 tier=$4 mode=$5 pressure=${6:-null} threshold=${7:-null}
103
+ if [ -f "$state_dir/kill" ]; then return 0; fi
104
+ local log="$state_dir/containment.log.jsonl"
105
+ local ks event log_sha
106
+ event=$(printf '{"schema":"%s","session":"%s","trigger":{"rule":"%s","class":"%s","tier":"%s","mode":"%s","pressure":%s,"threshold":%s},"final":true}' \
107
+ "$GC_KILLSWITCH_SCHEMA" "$(basename "$state_dir")" "$rule_id" "$class" "$tier" "$mode" "$pressure" "$threshold")
108
+ printf '%s\n' "$event" >>"$log"
109
+ log_sha=$(sha256sum "$log" | awk '{print $1}') || return 2
110
+ # Terminal killswitch event with log digest (design section 5, step 1).
111
+ ks=$(printf '{"schema":"%s","session":"%s","trigger":{"rule":"%s","class":"%s","tier":"%s","mode":"%s","pressure":%s,"threshold":%s},"logSha256":"%s","final":true}' \
112
+ "$GC_KILLSWITCH_SCHEMA" "$(basename "$state_dir")" "$rule_id" "$class" "$tier" "$mode" "$pressure" "$threshold" "$log_sha")
113
+ printf '%s\n' "$ks" >>"$log"
114
+ # Kill flag: the guest supervisor polls this and executes the kill path
115
+ # (kill process group, sync, poweroff -f); host-side hooks ignore it.
116
+ printf '%s\n' "$mode" >"$state_dir/kill" 2>/dev/null || true
117
+ printf '%s\n' "$ks"
118
+ }
119
+
120
+ gc_window_pressure() { # pressure_file
121
+ local now cutoff
122
+ now=$(date +%s)
123
+ cutoff=$((now - GC_WINDOW_SECONDS))
124
+ if [ ! -f "$1" ]; then printf '0\n'; return 0; fi
125
+ awk -v cutoff="$cutoff" '$1 > cutoff {n++} END {print n + 0}' "$1"
126
+ }
127
+
128
+ # Killswitch decision function (design sections 2.0 and 5): given a rule hit,
129
+ # CRITICAL/HIGH trip immediately; ELEVATED/ANOMALY increment per-class pressure
130
+ # within the sliding window and trip on threshold. Returns a decision JSON on
131
+ # stdout; a trip also appends the terminal killswitch event to the log.
132
+ gc_decide() { # state_dir rule_id [subject_value]
133
+ local state_dir=$1 rule_id=$2 svalue=${3:-}
134
+ # Log freeze gates the decision path too: after a trip the session is dead;
135
+ # further events must not append, re-trip, or grow pressure counters.
136
+ if [ -f "$state_dir/kill" ]; then
137
+ printf '{"rule":"%s","frozen":true,"tripped":true,"mode":"post-trip"}\n' "$rule_id"
138
+ return 0
139
+ fi
140
+ mkdir -p "$state_dir" || return 2
141
+ local tax="$state_dir/taxonomy.json"
142
+ if [ ! -f "$tax" ]; then gc_embedded_taxonomy >"$tax" || return 2; fi
143
+ local severity class tier mode=immediate pressure=null threshold=null tripped=false
144
+ severity=$(gc_rule_field "$tax" "$rule_id" severity)
145
+ if [ -z "$severity" ]; then
146
+ # Residual deny-by-default class (design section 2, GC-UNKNOWN).
147
+ severity=ANOMALY
148
+ class=unknown
149
+ else
150
+ class=$(gc_rule_field "$tax" "$rule_id" class)
151
+ fi
152
+ case "$severity" in
153
+ CRITICAL|HIGH)
154
+ tier=$severity
155
+ tripped=true
156
+ mode=immediate
157
+ ;;
158
+ ELEVATED|ANOMALY)
159
+ tier=$severity
160
+ mode=aggregate
161
+ local pfile="$state_dir/pressure.$class"
162
+ printf '%s\n' "$(date +%s)" >>"$pfile"
163
+ pressure=$(gc_window_pressure "$pfile")
164
+ if [ "$severity" = ELEVATED ]; then threshold=$GC_ELEVATED_THRESHOLD; else threshold=$GC_ANOMALY_THRESHOLD; fi
165
+ if [ "$pressure" -ge "$threshold" ]; then tripped=true; fi
166
+ ;;
167
+ *)
168
+ return 2
169
+ ;;
170
+ esac
171
+ gc_log_event "$state_dir" "shim" "$class" "exec" "$svalue" >/dev/null || return 2
172
+ if [ "$tripped" = true ]; then
173
+ gc_killswitch_trip "$state_dir" "$rule_id" "$class" "$tier" "$mode" "$pressure" "$threshold" >/dev/null || return 2
174
+ fi
175
+ printf '{"rule":"%s","class":"%s","severity":"%s","tier":"%s","tripped":%s,"mode":"%s","pressure":%s,"threshold":%s}\n' \
176
+ "$rule_id" "$class" "$severity" "$tier" "$tripped" "$mode" "$pressure" "$threshold"
177
+ }
178
+
179
+ # --- detection (design section 1.3, 3): shim classification, allowlist,
180
+ # fs/net/proc detection, supervisor liveness. Callable from the guest
181
+ # (sourced by /init and the shim dispatcher) and from the host test hooks.
182
+ GC_LEARNING_WINDOW_SECONDS=${GC_LEARNING_WINDOW_SECONDS:-3}
183
+ GC_CACHE_GROWTH_KIB=${GC_CACHE_GROWTH_KIB:-1024}
184
+ GC_WRITABLE_ROOT="/tmp/session"
185
+
186
+ # Shim classification: map a command line to a taxonomy rule id (empty means
187
+ # unclassified; unclassified executables go through the observed-first-use
188
+ # allowlist, design section 3).
189
+ gc_shim_classify() { # tool args...
190
+ local tool=$1; shift
191
+ # busybox <applet> shim bypass (design section 9.1): classify the effective
192
+ # applet line instead of trusting the tool name.
193
+ if [ "$tool" = "busybox" ] && [ "$#" -ge 1 ]; then
194
+ gc_shim_classify "$1" "${@:2}"
195
+ return
196
+ fi
197
+ local line="$tool $*"
198
+ case "$tool" in
199
+ wget|curl|nc|ssh|telnet|ping) printf 'GC-NET-002\n'; return 0 ;;
200
+ printenv) printf 'GC-CRED-002\n'; return 0 ;;
201
+ esac
202
+ # Job-scratch carve-out (false-positive discipline): the sanctioned scratch
203
+ # root is the one tree a job may write freely, so the GC-SHR-002 and
204
+ # GC-LOG-001 argv heuristics must not fire on scratch-local paths. A
205
+ # scratch-prefixed argument is neutralized for those two rules only; writes
206
+ # outside the scratch keep denying exactly as before, and every other rule
207
+ # still sees the raw command line.
208
+ local line_outer="$line"
209
+ case "$line" in
210
+ *"$GC_WRITABLE_ROOT"/*)
211
+ line_outer=$(printf '%s\n' "$line" | sed "s|${GC_WRITABLE_ROOT}/[^[:space:]]*|/sanctioned-scratch|g") ;;
212
+ esac
213
+ case "$line_outer" in
214
+ *"/Maildir"*|*"/task-queue"*|*"agent-channel"*|*"/.herdr"*|*"/coordination"*)
215
+ # GC-SHR-002: cross-agent coordination paths (HIGH, immediate).
216
+ printf 'GC-SHR-002\n'; return 0 ;;
217
+ *"console.typescript"*|*"transcript"*|*"receipt-channel"*)
218
+ # GC-LOG-001: transcript/receipt-channel writes (CRITICAL, immediate).
219
+ printf 'GC-LOG-001\n'; return 0 ;;
220
+ esac
221
+ case "$line" in
222
+ *"npm install"*|*"npm ci"*|*"pip install"*|*"yarn add"*|*"pnpm add"*|*"gem install"*|*"cargo install"*|*"apk add"*|*"apt install"*)
223
+ printf 'GC-PKG-001\n' ;;
224
+ *"npm publish"*|*"npm token"*|*".npmrc"*|*"pip config"*)
225
+ printf 'GC-PKG-002\n' ;;
226
+ *".ssh/"*|*".aws/"*|*".netrc"*|*".git-credentials"*)
227
+ printf 'GC-CRED-001\n' ;;
228
+ *"env | grep"*|*"env | grep -i"*)
229
+ printf 'GC-CRED-002\n' ;;
230
+ *"package.json"*|*"package-lock.json"*|*"pyproject.toml"*|*"requirements"*.txt*)
231
+ printf 'GC-PKG-003\n' ;;
232
+ *"AGENTIC_MICROVM_PROBE"*|*"AGENTIC_MICROVM_RECEIPT"*|*"AGENTIC_CONTAINMENT_BEGIN"*|*"AGENTIC_CONTAINMENT_END"*)
233
+ printf 'GC-TOOL-001\n' ;;
234
+ *"/dev/console"*|*"/dev/ttyS"*|*"/dev/tty0"*)
235
+ printf 'GC-TOOL-002\n' ;;
236
+ *"inotifyd"*|*"watcher"*|*"cmdline"*|*"ps -"*|*"/proc/"*"stat"*)
237
+ # GC-PROBE-002: monitor enumeration (ps/proc patterns targeting watchers).
238
+ printf 'GC-PROBE-002\n' ;;
239
+ *"containment.log"*|*"/.gc/"*|*"> /var/log"*|*"truncate -s 0"*|*"sed -i"*)
240
+ printf 'GC-LOG-002\n' ;;
241
+ *"/gc/"*|*"core.sh"*|*"/shims/"*|*"microvm-target"*)
242
+ # GC-PROBE-001: monitor introspection (reads of /gc, the core, the shims).
243
+ printf 'GC-PROBE-001\n' ;;
244
+ *) return 0 ;;
245
+ esac
246
+ }
247
+
248
+ # Observed-first-use allowlist with a short learning window (design section 3):
249
+ # during the window unlisted executables are logged and allowed; after lock
250
+ # they are denied with the redacted command line.
251
+ gc_shim_allow() { # state_dir tool args... -> decision JSON on stdout
252
+ local state_dir=$1 tool=$2; shift 2
253
+ mkdir -p "$state_dir" || return 2
254
+ local rule
255
+ rule=$(gc_shim_classify "$tool" "$@")
256
+ if [ -n "$rule" ]; then
257
+ gc_decide "$state_dir" "$rule" "$tool $*" >/dev/null || return 2
258
+ printf '{"decision":"deny","rule":"%s","tool":"%s"}\n' "$rule" "$tool"
259
+ return 0
260
+ fi
261
+ local allow="$state_dir/allowlist" lock="$state_dir/allowlist.lock" start="$state_dir/learning_start"
262
+ if [ ! -f "$start" ]; then printf '%s\n' "$(date +%s)" >"$start"; fi
263
+ if [ ! -f "$lock" ] && [ $(( $(date +%s) - $(cat "$start") )) -ge "$GC_LEARNING_WINDOW_SECONDS" ]; then
264
+ printf '%s\n' "$(date +%s)" >"$lock"
265
+ fi
266
+ if [ -f "$lock" ]; then
267
+ if grep -F -x -- "$tool" "$allow" >/dev/null 2>&1; then
268
+ printf '{"decision":"allow","tool":"%s","learned":false}\n' "$tool"
269
+ else
270
+ gc_decide "$state_dir" "unknown" "$tool $*" >/dev/null || return 2
271
+ printf '{"decision":"deny","rule":"unknown","tool":"%s"}\n' "$tool"
272
+ fi
273
+ else
274
+ if ! grep -F -x -- "$tool" "$allow" >/dev/null 2>&1; then
275
+ printf '%s\n' "$tool" >>"$allow"
276
+ fi
277
+ # Learning-window observations are logged (design section 3): allowed, but
278
+ # recorded as events so the learning window is auditable.
279
+ gc_log_event "$state_dir" "shim" "unknown" "exec" "$tool $*" "observe" >/dev/null 2>&1 || true
280
+ printf '{"decision":"allow","tool":"%s","learned":true}\n' "$tool"
281
+ fi
282
+ }
283
+
284
+ # Share-mount arm check (GC-SHR-001): share paths auto-arm only when a
285
+ # host-share mount is visible in /proc/mounts. GC_PROC_MOUNTS overrides the
286
+ # mounts source for tests; production always reads /proc/mounts.
287
+ gc_share_mounted() {
288
+ local mounts="${GC_PROC_MOUNTS:-/proc/mounts}"
289
+ [ -r "$mounts" ] || return 1
290
+ grep -qE '(^|[[:space:]])(/share|/mnt/share)([[:space:]]|$)|[[:space:]](9p|virtiofs|nfs|cifs)[[:space:]]' "$mounts" 2>/dev/null
291
+ }
292
+
293
+ # GC-FSW-003 detector: cache/package size-delta sampling in the sweep. Each
294
+ # sample records the summed KiB of the watched roots; a delta at or above
295
+ # GC_CACHE_GROWTH_KIB since the previous sample is ONE aggregate ELEVATED
296
+ # decision (trips at the class threshold, never per event).
297
+ gc_cache_growth_sample() { # state_dir dir...
298
+ local state_dir=$1; shift
299
+ mkdir -p "$state_dir" 2>/dev/null || return 2
300
+ local dir kib total=0 baseline="$state_dir/cache.baseline"
301
+ for dir in "$@"; do
302
+ [ -d "$dir" ] || continue
303
+ kib=$(du -sk "$dir" 2>/dev/null | awk '{print $1}') || kib=0
304
+ total=$((total + ${kib:-0}))
305
+ done
306
+ if [ ! -f "$baseline" ]; then
307
+ if printf '%s\n' "$total" >"$baseline" 2>/dev/null; then
308
+ gc_log_event "$state_dir" "sweep" "GC-FSW-003" "sample" "baseline=${total}KiB" "observe" >/dev/null 2>&1 || true
309
+ fi
310
+ return 0
311
+ fi
312
+ local prev delta
313
+ prev=$(cat "$baseline" 2>/dev/null) || prev=$total
314
+ printf '%s\n' "$total" >"$baseline" 2>/dev/null || true
315
+ delta=$((total - ${prev:-0}))
316
+ # Diagnostic observe (live microvm-b583fccb8abf8bccb0989e10): one compact
317
+ # line per sample so the next live run shows total/prev/delta/threshold and
318
+ # the decision path even when no event fires.
319
+ gc_log_event "$state_dir" "sweep" "GC-FSW-003" "sample" "total=${total}KiB prev=${prev:-none}KiB delta=${delta}KiB threshold=${GC_CACHE_GROWTH_KIB}KiB" "observe" >/dev/null 2>&1 || true
320
+ if [ "$delta" -ge "$GC_CACHE_GROWTH_KIB" ]; then
321
+ gc_decide "$state_dir" GC-FSW-003 "cache growth ${delta}KiB" >/dev/null || return 2
322
+ printf '{"decision":"deny","rule":"GC-FSW-003","growthKiB":%s}\n' "$delta"
323
+ fi
324
+ return 0
325
+ }
326
+
327
+ # fs-watcher detection: only the job scratch root is writable; writes to the
328
+ # shim/agent trees are self-code-modification, everything else is
329
+ # write-outside-allowlist.
330
+ gc_fs_detect() { # state_dir path -> decision JSON
331
+ local state_dir=$1 path=$2
332
+ case "$path" in
333
+ "$GC_WRITABLE_ROOT"|"$GC_WRITABLE_ROOT"/*)
334
+ printf '{"decision":"allow","path":"%s"}\n' "$path" ;;
335
+ /gc|/gc/*)
336
+ # GC-PROBE-001: introspection of the monitor tree itself (section 9.1).
337
+ decision=$(gc_decide "$state_dir" GC-PROBE-001 "$path") || return 2
338
+ case "$decision" in
339
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
340
+ *) printf '{"decision":"deny","rule":"GC-PROBE-001","path":"%s"}\n' "$path" ;;
341
+ esac ;;
342
+ /shims|/shims/*|/agent|/agent/*)
343
+ decision=$(gc_decide "$state_dir" GC-FSW-002 "$path") || return 2
344
+ case "$decision" in
345
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
346
+ *) printf '{"decision":"deny","rule":"GC-FSW-002","path":"%s"}\n' "$path" ;;
347
+ esac ;;
348
+ */package.json|*/package-lock.json|*/pyproject.toml|*/requirements*.txt|/package.json|/pyproject.toml)
349
+ decision=$(gc_decide "$state_dir" GC-PKG-003 "$path") || return 2
350
+ case "$decision" in
351
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
352
+ *) printf '{"decision":"deny","rule":"GC-PKG-003","path":"%s"}\n' "$path" ;;
353
+ esac ;;
354
+ /dev/*|/dev)
355
+ # Write targets on devtmpfs could inject marker text (GC-TOOL-002).
356
+ decision=$(gc_decide "$state_dir" GC-TOOL-002 "$path") || return 2
357
+ case "$decision" in
358
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
359
+ *) printf '{"decision":"deny","rule":"GC-TOOL-002","path":"%s"}\n' "$path" ;;
360
+ esac ;;
361
+ /share|/share/*|/mnt/share|/mnt/share/*)
362
+ if gc_share_mounted; then
363
+ # GC-SHR-001 armed form: a share mount is present, so these paths
364
+ # exist as a live share; any write is a shared-cache write (HIGH).
365
+ decision=$(gc_decide "$state_dir" GC-SHR-001 "$path") || return 2
366
+ case "$decision" in
367
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
368
+ *) printf '{"decision":"deny","rule":"GC-SHR-001","path":"%s"}\n' "$path" ;;
369
+ esac
370
+ else
371
+ # Unarmed: with no share mount these paths are ordinary writes
372
+ # outside the allowlist - normal FSW-001 handling, not a share trip.
373
+ decision=$(gc_decide "$state_dir" GC-FSW-001 "$path") || return 2
374
+ case "$decision" in
375
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
376
+ *) printf '{"decision":"deny","rule":"GC-FSW-001","path":"%s"}\n' "$path" ;;
377
+ esac
378
+ fi ;;
379
+ */.npm|*/.npm/*|*/.cache|*/.cache/*|/var/cache|/var/cache/*)
380
+ # GC-SHR-001 deny-otherwise form: without an armed share mount these
381
+ # shared-cache roots have no legitimate writer, so a write is classified
382
+ # as shared-cache, not plain FSW-001 (HIGH, immediate).
383
+ decision=$(gc_decide "$state_dir" GC-SHR-001 "$path") || return 2
384
+ case "$decision" in
385
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
386
+ *) printf '{"decision":"deny","rule":"GC-SHR-001","path":"%s"}\n' "$path" ;;
387
+ esac ;;
388
+ */Maildir|*/Maildir/*|*/task-queue/*|*/.herdr/*|*/agent-channel/*|*/coordination/*)
389
+ # GC-SHR-002: cross-agent coordination paths (HIGH, immediate).
390
+ decision=$(gc_decide "$state_dir" GC-SHR-002 "$path") || return 2
391
+ case "$decision" in
392
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
393
+ *) printf '{"decision":"deny","rule":"GC-SHR-002","path":"%s"}\n' "$path" ;;
394
+ esac ;;
395
+ *console.typescript*|*typescript|*transcript*|*/receipt-channel/*)
396
+ # GC-LOG-001: the supervisor never writes transcript/receipt-channel
397
+ # files inside the guest (the host recorder owns them), so any fs event
398
+ # here is a non-supervisor write (CRITICAL, immediate).
399
+ decision=$(gc_decide "$state_dir" GC-LOG-001 "$path") || return 2
400
+ case "$decision" in
401
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
402
+ *) printf '{"decision":"deny","rule":"GC-LOG-001","path":"%s"}\n' "$path" ;;
403
+ esac ;;
404
+ *"containment.log"*|*/.gc/*)
405
+ gc_decide "$state_dir" GC-LOG-002 "$path" >/dev/null || return 2
406
+ printf '{"decision":"deny","rule":"GC-LOG-002","path":"%s"}\n' "$path" ;;
407
+ *)
408
+ decision=$(gc_decide "$state_dir" GC-FSW-001 "$path") || return 2
409
+ case "$decision" in
410
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
411
+ *) printf '{"decision":"deny","rule":"GC-FSW-001","path":"%s"}\n' "$path" ;;
412
+ esac ;;
413
+ esac
414
+ }
415
+
416
+ # find -newer sweep fallback for the fs-watcher: detect every path handed in.
417
+ gc_fs_sweep() { # state_dir path...
418
+ local state_dir=$1; shift
419
+ local path
420
+ for path in "$@"; do gc_fs_detect "$state_dir" "$path"; done
421
+ }
422
+
423
+ # Sweep-baseline retreat (live microvm-11d339266876dcfad3d3ac32, card R2):
424
+ # the sweep used to `touch baseline` right after `find -newer baseline`, so a
425
+ # write landing between the find pass and that touch was never strictly newer
426
+ # than the baseline and stayed invisible forever - the live cp into
427
+ # /var/cache/shared-write was seen by the GC-FSW-003 sampler (delta=4KiB) yet
428
+ # produced no gc_fs_detect decision, no GC-SHR-001 deny, and no trip. The
429
+ # baseline must move BACK one second after each find so same-second writes
430
+ # stay strictly newer at the next sweep. Cost: one re-scan of the previous
431
+ # second (duplicate denies only add pressure; HIGH rules trip immediately).
432
+ gc_baseline_retreat() { # path
433
+ local p=$1 now ts
434
+ now=$(date +%s) || { touch "$p"; return 0; }
435
+ ts=$((now - 1))
436
+ if ! touch -d "@$ts" "$p" 2>/dev/null; then
437
+ if ! touch -t "$(date -v-1S +%Y%m%d%H%M.%S 2>/dev/null)" "$p" 2>/dev/null; then
438
+ touch "$p"
439
+ fi
440
+ fi
441
+ }
442
+
443
+ # net-watcher detection: any socket row is a violation (GC-NET-001).
444
+ gc_net_detect() { # state_dir socket_entry
445
+ local state_dir=$1 entry=$2
446
+ [ -n "$entry" ] || return 0
447
+ decision=$(gc_decide "$state_dir" GC-NET-001 "$entry") || return 2
448
+ case "$decision" in
449
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
450
+ *) printf '{"decision":"deny","rule":"GC-NET-001"}\n' ;;
451
+ esac
452
+ }
453
+
454
+ # Proc-watcher sweep: enumerate userspace processes only. Kernel threads
455
+ # (kthreadd, kworker/*) have an empty /proc/PID/cmdline and are children of
456
+ # kthreadd (ppid 2); flagging them produced 117+ false unknown-class events on
457
+ # the first live proof and tripped the ANOMALY threshold before the job ran
458
+ # (fixture microvm-ab06221027d3e36afc843b91). base_dir defaults to /proc and
459
+ # is a parameter so tests can exercise the filter against a simulated tree.
460
+ gc_proc_watch_sweep() { # [base_dir] -> "comm" lines of userspace processes
461
+ local base=${1:-/proc} p comm
462
+ for p in "$base"/[0-9]*; do
463
+ [ -d "$p" ] || continue
464
+ [ -s "$p/cmdline" ] || continue # kernel threads: empty cmdline
465
+ [ "$(awk '{print $4}' "$p/stat" 2>/dev/null)" = "2" ] && continue
466
+ comm=$(cat "$p/comm" 2>/dev/null) || continue
467
+ [ -n "$comm" ] && printf '%s\n' "$comm"
468
+ done
469
+ }
470
+
471
+ # proc-watcher detection: a process outside the job tree is an anomaly.
472
+ gc_proc_detect() { # state_dir process_identity
473
+ local state_dir=$1 identity=$2
474
+ gc_decide "$state_dir" unknown "proc $identity" >/dev/null || return 2
475
+ printf '{"decision":"deny","rule":"unknown"}\n'
476
+ }
477
+
478
+ # Supervisor liveness (design section 1.3): a dead monitor loop is a
479
+ # containment failure and trips the killswitch immediately.
480
+ gc_liveness() { # state_dir alive_flags ("1 1 1"; any 0 is a dead loop)
481
+ local state_dir=$1 flags=$2
482
+ # Post-trip the session is already dead; monitor collateral at teardown must
483
+ # not trip a masking containment-integrity terminal (see trip freeze above).
484
+ if [ -f "$state_dir/kill" ]; then return 0; fi
485
+ case " $flags " in
486
+ *" 0 "*)
487
+ gc_log_event "$state_dir" "watcher:proc" containment-integrity proc "monitor loop dead" >/dev/null || return 2
488
+ gc_killswitch_trip "$state_dir" containment-integrity containment-integrity CRITICAL immediate null null >/dev/null || return 2
489
+ printf '{"decision":"deny","rule":"containment-integrity","tier":"CRITICAL","mode":"immediate"}\n' ;;
490
+ *) printf '{"decision":"allow"}\n' ;;
491
+ esac
492
+ }
493
+
494
+ # B1: the one real receipt printf. The containment segment is passed as a %s
495
+ # argument (never interpolated into the format string, where it would stay
496
+ # literal under single quotes); an empty segment yields the v1 receipt shape.
497
+ gc_receipt_json() { # schema remote_host fixture_id domain marker marker_sha script_hash initramfs_sha containment_segment domain destroy_requested absent acl_before acl_after fs_ctx_sha net_ctx_sha
498
+ printf '{"schema":"%s","ok":true,"status":"VERIFIED","authorityCreated":false,"runtimeActivated":false,"persisted":false,"identity":{"remoteHost":"%s","fixtureId":"%s","domain":"%s"},"marker":{"value":"%s","sha256":"%s"},"scriptHash":"%s","initramfsSha256":"%s"%s,"teardown":{"domain":{"name":"%s","transient":true,"destroyOnExit":true,"destroyRequested":%s,"absent":%s,"checked":true,"check":"virsh dominfo/list"},"acl":{"beforeSha256":"%s","afterSha256":"%s","equal":true,"checked":true,"initramfsEntryRemoved":true}},"context":{"filesystem":{"summary":"disk=absent host-share=absent credentials=absent gpu=absent","disk":false,"hostShare":false,"credentials":false,"gpu":false,"sha256":"%s"},"network":{"summary":"network=absent","guest":false,"sha256":"%s"},"guestMounts":["proc","sysfs","devtmpfs"]}}\n' "$@"
499
+ }
500
+
501
+ # Terminal event for a session that ends without a killswitch trip: the
502
+ # envelope then carries only log lines plus this session-end record
503
+ # (design section 5: a session with neither is containment-evidence-missing).
504
+ gc_session_end() { # state_dir
505
+ # Split declarations: bash 3.2 expands every word of one `local` before any
506
+ # assignment, so referencing $state_dir in the same statement is an unbound
507
+ # variable under set -u on the host-side hook path.
508
+ local state_dir=$1
509
+ local log="$state_dir/containment.log.jsonl"
510
+ # Bootstrap the taxonomy copy so a zero-event session (no shim invocation,
511
+ # no watcher hit) still records its terminal event: without it the digest
512
+ # lookup below fails, no log is created, and no envelope can be emitted.
513
+ if [ ! -f "$state_dir/taxonomy.json" ]; then
514
+ mkdir -p "$state_dir" || return 2
515
+ gc_embedded_taxonomy >"$state_dir/taxonomy.json" || return 2
516
+ fi
517
+ local seq=1
518
+ if [ -f "$state_dir/seq" ]; then seq=$(( $(cat "$state_dir/seq") + 1 )); fi
519
+ printf '%s\n' "$seq" >"$state_dir/seq"
520
+ local tsha
521
+ tsha=$(sha256sum "$state_dir/taxonomy.json" | awk '{print $1}') || return 2
522
+ printf '{"schema":"%s","session":"%s","taxonomy":"%s","taxonomySha256":"%s","event":{"ts":"%s","seq":%s,"source":"supervisor","class":"session-end","action":"complete","summary":true}}\n' \
523
+ "$GC_LOG_SCHEMA" "$(basename "$state_dir")" "$GC_TAXONOMY_VERSION" "$tsha" "$(gc_iso8601)" "$seq" >>"$log"
524
+ }
525
+
526
+ # Host-side containment evidence (design section 5): extract the framed base64
527
+ # envelope from the console transcript, decode it pty-safe, recompute the log
528
+ # digest per the stated normalization, and cross-check the terminal killswitch
529
+ # event's embedded digest against the full payload.
530
+ gc_containment_evidence() { # transcript fixture_id [kill_report_path] -> containment block JSON on stdout
531
+ local transcript=$1 fid=$2 report_path=${3:-}
532
+ local begin="AGENTIC_CONTAINMENT_BEGIN:$fid" end="AGENTIC_CONTAINMENT_END:$fid"
533
+ local b64 tmp log_sha events denials ks_line ks_rule tripped rule_json
534
+ tmp="$transcript.containment.$$"
535
+ trap 'rm -f "$tmp" "$tmp.b64" "$tmp.head"' RETURN
536
+ # H2: exactly one envelope pair may exist; more than one is a forgery or a
537
+ # replay attempt and fails closed.
538
+ # Count with index() so pty CR suffixes do not defeat the anchor.
539
+ begins=$(awk -v b="$begin" 'index($0, b) == 1 { n++ } END { print n + 0 }' "$transcript")
540
+ ends=$(awk -v e="$end" 'index($0, e) == 1 { n++ } END { print n + 0 }' "$transcript")
541
+ if [ "$begins" -ne 1 ] || [ "$ends" -ne 1 ]; then
542
+ return 1
543
+ fi
544
+ if ! awk -v b="$begin" -v e="$end" 'index($0, b) == 1 { inside = 1; next } index($0, e) == 1 { inside = 0; next } inside' "$transcript" | tr -d '\r' >"$tmp.b64"; then
545
+ return 1
546
+ fi
547
+ if ! [ -s "$tmp.b64" ]; then return 1; fi
548
+ if ! { base64 -d <"$tmp.b64" >"$tmp" 2>/dev/null || base64 -D <"$tmp.b64" >"$tmp" 2>/dev/null; }; then return 1; fi
549
+ if ! grep -q '"schema":"' "$tmp"; then return 1; fi
550
+ ks_line=$(grep '"final":true' "$tmp" | tail -n 1)
551
+ ks_num=$(grep -n '"final":true' "$tmp" | tail -n 1 | cut -d: -f1)
552
+ if [ -n "$ks_line" ]; then
553
+ # Digest chain (H1): verified at the killswitch line's position — the
554
+ # digest covers the log up to and including the trigger event but
555
+ # excluding the killswitch line itself; anything after it is post-trip
556
+ # noise the guest's log freeze should have prevented, and is ignored for
557
+ # the digest rather than silently trusted.
558
+ ks_sha=$(printf '%s\n' "$ks_line" | sed -n 's/.*"logSha256":"\([0-9a-f]*\)".*/\1/p')
559
+ sed -n "1,$((ks_num - 1))p" "$tmp" >"$tmp.head" 2>/dev/null || return 1
560
+ chained_sha=$(sha256sum "$tmp.head" | awk '{print $1}') || return 1
561
+ if [ -z "$ks_sha" ] || [ "$ks_sha" != "$chained_sha" ]; then return 2; fi
562
+ tripped=true
563
+ log_sha=$ks_sha
564
+ ks_rule=$(printf '%s\n' "$ks_line" | sed -n 's/.*"trigger":{"rule":"\([A-Za-z0-9_-]*\)".*/\1/p')
565
+ ks_class=$(printf '%s\n' "$ks_line" | sed -n 's/.*"trigger":{"rule":"[A-Za-z0-9_-]*","class":"\([A-Za-z0-9_-]*\)".*/\1/p')
566
+ ks_tier=$(printf '%s\n' "$ks_line" | sed -n 's/.*"tier":"\([A-Za-z]*\)".*/\1/p')
567
+ rule_json="\"$ks_rule\""
568
+ class_json="\"$ks_class\""
569
+ tier_json="\"$ks_tier\""
570
+ counted=$((ks_num - 1))
571
+ else
572
+ # Fail-closed: without a killswitch record the session must end with the
573
+ # clean session-end terminal event.
574
+ grep -q '"class":"session-end"' "$tmp" || return 1
575
+ tripped=false
576
+ rule_json=null
577
+ class_json=null
578
+ tier_json=null
579
+ log_sha=$(sha256sum "$tmp" | awk '{print $1}') || return 1
580
+ counted=$(grep -c '"schema":"' "$tmp")
581
+ fi
582
+ events=$counted
583
+ denials=$(grep -c '"action":"deny"' "$tmp")
584
+ # M1: compact histogram (design section 4; open question 4 decided) — the
585
+ # coordinator consumes aggregates from the receipt without extra tooling.
586
+ # Terminal killswitch records are echoes, not events; exclude them.
587
+ histogram=$(awk '{
588
+ if ($0 !~ /guest-containment.killswitch.v1/ && match($0, /"class":"[^"]*"/)) {
589
+ c = substr($0, RSTART + 9, RLENGTH - 10); n[c]++
590
+ }
591
+ } END { first = 1; printf "{"; for (k in n) { if (!first) printf ","; printf "\"%s\":%d", k, n[k]; first = 0 } printf "}" }' "$tmp")
592
+ # Concealment index (design appendix): probes + 2*unknown-class denials
593
+ # + 3*containment-integrity events; probes counted alongside.
594
+ probes=0; concealment_index=0
595
+ eval "$(awk '{
596
+ if ($0 ~ /guest-containment.killswitch.v1/) next
597
+ match($0, /"class":"[^"]*"/); c = substr($0, RSTART + 9, RLENGTH - 10)
598
+ match($0, /"action":"[^"]*"/); a = substr($0, RSTART + 10, RLENGTH - 11)
599
+ if (c == "GC-PROBE") { probes++; idx += 1 }
600
+ else if (c == "unknown" && a == "deny") { idx += 2 }
601
+ else if (c == "containment-integrity") { idx += 3 }
602
+ } END { printf "probes=%d; concealment_index=%d;", probes + 0, idx + 0 }' "$tmp")"
603
+ if [ "$tripped" = true ] && [ -n "$report_path" ]; then
604
+ cp "$tmp" "$transcript.containment.payload" 2>/dev/null || true
605
+ report_json=",\"killReportPath\":\"$report_path\""
606
+ else
607
+ report_json=""
608
+ fi
609
+ rm -f "$tmp" "$tmp.b64" "$tmp.head"
610
+ printf '{"schema":"%s","taxonomySha256":"%s","logSha256":"%s","events":%s,"denials":%s,"probes":%s,"concealmentIndex":%s,"histogram":%s,"killswitch":{"tripped":%s,"rule":%s,"class":%s,"tier":%s,"guestPoweroff":true,"final":true}%s}' \
611
+ "$GC_LOG_SCHEMA" "$GC_TAXONOMY_SHA256" "$log_sha" "$events" "$denials" "$probes" "$concealment_index" "$histogram" "$tripped" "$rule_json" "$class_json" "$tier_json" "$report_json"
612
+ }
613
+
614
+ # Durable kill report (design section 5; Defect 2): when the killswitch trips,
615
+ # the fixture writes kill-report.json next to console.typescript with session
616
+ # identity, decoded aggregates, the trigger, the last redacted events before
617
+ # the kill, and teardown proof status (finalized after the teardown proofs).
618
+ gc_write_kill_report() { # fixture_root fixture_id domain remote_host block_json
619
+ local fixture_root=$1 fid=$2 dom=$3 host=$4 block=$5
620
+ local payload="$fixture_root/console.typescript.containment.payload"
621
+ local report="$fixture_root/kill-report.json"
622
+ local log_sha events denials probes concealment histogram rule class tier mode pressure threshold
623
+ log_sha=$(printf '%s' "$block" | sed -n 's/.*"logSha256":"\([0-9a-f]*\)".*/\1/p')
624
+ events=$(printf '%s' "$block" | sed -n 's/.*"events":\([0-9]*\).*/\1/p')
625
+ denials=$(printf '%s' "$block" | sed -n 's/.*"denials":\([0-9]*\).*/\1/p')
626
+ probes=$(printf '%s' "$block" | sed -n 's/.*"probes":\([0-9]*\).*/\1/p')
627
+ concealment=$(printf '%s' "$block" | sed -n 's/.*"concealmentIndex":\([0-9]*\).*/\1/p')
628
+ histogram=$(printf '%s' "$block" | sed -n 's/.*"histogram":\({[^}]*}\).*/\1/p')
629
+ rule=$(printf '%s' "$block" | sed -n 's/.*"killswitch":{"tripped":true,"rule":"\([^"]*\)".*/\1/p')
630
+ class=$(printf '%s' "$block" | sed -n 's/.*"class":"\([^"]*\)","tier":"\([^"]*\)".*/\1/p')
631
+ tier=$(printf '%s' "$block" | sed -n 's/.*"class":"[^"]*","tier":"\([^"]*\)".*/\1/p')
632
+ local ks_line first_ts last_ts
633
+ ks_line=$(grep '"logSha256"' "$payload" | tail -n 1 || true)
634
+ mode=$(printf '%s\n' "$ks_line" | sed -n 's/.*"mode":"\([^"]*\)".*/\1/p' || true)
635
+ pressure=$(printf '%s\n' "$ks_line" | sed -n 's/.*"pressure":\([0-9]*\).*/\1/p' || true)
636
+ threshold=$(printf '%s\n' "$ks_line" | sed -n 's/.*"threshold":\([0-9]*\).*/\1/p' || true)
637
+ # null-safe scalars: the guest emits pressure/threshold null for immediate trips.
638
+ pressure=${pressure:-null}
639
+ threshold=${threshold:-null}
640
+ first_ts=$(grep -o '"ts":"[^"]*"' "$payload" | head -n 1 | cut -d'"' -f4 || true)
641
+ last_ts=$(grep -o '"ts":"[^"]*"' "$payload" | tail -n 1 | cut -d'"' -f4 || true)
642
+ local last_events
643
+ last_events=$(awk '/"action":"deny"/ { lines[n++] = $0 } END { for (i = (n > 5 ? n - 5 : 0); i < n; i++) { if (i > (n > 5 ? n - 5 : 0)) printf ","; printf "%s", lines[i] } }' "$payload" || true)
644
+ printf '{"schema":"agentic-driver.guest-containment.kill-report.v1","session":{"fixtureId":"%s","domain":"%s","remoteHost":"%s"},"generatedAt":"%s","guestSession":{"firstEventAt":"%s","lastEventAt":"%s","events":%s,"denials":%s,"probes":%s,"concealmentIndex":%s,"histogram":%s},"killswitch":{"tripped":true,"rule":"%s","class":"%s","tier":"%s","mode":"%s","pressure":%s,"threshold":%s},"lastEvents":[%s],"teardown":{"domainAbsent":"PENDING","destroyRequested":"PENDING","aclRestored":"PENDING"},"logSha256":"%s"}\n' \
645
+ "$fid" "$dom" "$host" "$(gc_iso8601)" "$first_ts" "$last_ts" "$events" "$denials" "$probes" "$concealment" "$histogram" \
646
+ "$rule" "$class" "$tier" "$mode" "$pressure" "$threshold" "$last_events" "$log_sha" >"$report"
647
+ printf '%s\n' "$report"
648
+ }
649
+
650
+ # Test hooks: the containment core is callable without booting the guest.
651
+ if [ "${1:-}" = "--gc-decide" ]; then shift; gc_decide "$@"; exit $?; fi
652
+ if [ "${1:-}" = "--gc-log" ]; then shift; gc_log_event "$@"; exit $?; fi
653
+ if [ "${1:-}" = "--gc-shim" ]; then shift; gc_shim_allow "$@"; exit $?; fi
654
+ if [ "${1:-}" = "--gc-fs-detect" ]; then shift; gc_fs_detect "$@"; exit $?; fi
655
+ if [ "${1:-}" = "--gc-fs-sweep" ]; then shift; gc_fs_sweep "$@"; exit $?; fi
656
+ if [ "${1:-}" = "--gc-baseline-retreat" ]; then shift; gc_baseline_retreat "$@"; exit $?; fi
657
+ if [ "${1:-}" = "--gc-cache-growth" ]; then shift; gc_cache_growth_sample "$@"; exit $?; fi
658
+ if [ "${1:-}" = "--gc-net-detect" ]; then shift; gc_net_detect "$@"; exit $?; fi
659
+ if [ "${1:-}" = "--gc-proc-detect" ]; then shift; gc_proc_detect "$@"; exit $?; fi
660
+ if [ "${1:-}" = "--gc-proc-sweep" ]; then shift; gc_proc_watch_sweep "$@"; exit $?; fi
661
+ if [ "${1:-}" = "--gc-liveness" ]; then shift; gc_liveness "$@"; exit $?; fi
662
+ if [ "${1:-}" = "--gc-core-embed" ]; then
663
+ shift
664
+ # Test/regression hook: writes the embedded guest core exactly as the build
665
+ # phase does. Works through the streamed-stdin path (`bash -s`), where $0
666
+ # is "bash" and there is no script file to read back.
667
+ cat >"$1" <<'GC_CORE_EOF'
668
+ # --- guest containment core (taxonomy, structured log, killswitch decision) ---
669
+ # Design: /Users/julen/pi-dev-env/evidence/GUEST_CONTAINMENT_DESIGN.md sections
670
+ # 2 (taxonomy), 2.0 (severity tiers), 4 (log format), 5 (killswitch semantics).
671
+ # Watchers and shims arrive in a later step; this core is callable and testable
672
+ # through the --gc-decide / --gc-log / --gc-taxonomy-sha hooks below.
673
+ GC_TAXONOMY_VERSION="guest-containment-taxonomy.v1"
674
+ GC_LOG_SCHEMA="agentic-driver.guest-containment.log.v1"
675
+ GC_KILLSWITCH_SCHEMA="agentic-driver.guest-containment.killswitch.v1"
676
+ GC_TAXONOMY_SHA256="f0d4f11150e0f9aef562f1e7d61afd9fa8df1f40f1c90c3c011dd768e335bb84"
677
+ # Severity tiers (design section 2.0): CRITICAL/HIGH trip immediately,
678
+ # ELEVATED/ANOMALY accumulate pressure within a sliding time window.
679
+ GC_WINDOW_SECONDS=60
680
+ GC_ELEVATED_THRESHOLD=5
681
+ GC_ANOMALY_THRESHOLD=10
682
+ GC_LOG_CAP_BYTES=8192
683
+ GC_LOG_TRUNC=512
684
+
685
+ gc_embedded_taxonomy() {
686
+ cat <<'GC_TAXONOMY_EOF'
687
+ {
688
+ "schema": "guest-containment-taxonomy.v1",
689
+ "description": "Deny-by-default rule taxonomy for the guest containment monitor (design GUEST_CONTAINMENT_DESIGN.md section 2). One rule per line; IDs are never reused; new rules append. disposition records whether a live detector exists in the fixture (design sections 1.3, 3) or the rule is armed by the taxonomy only.",
690
+ "rules": [
691
+ {"id":"GC-PKG-001","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"package-manager-install: invocation of npm install, npm ci, pip install, yarn add, pnpm add, gem install, cargo install, apk add, apt install","evidence":"METR autonomous-replication evaluation work (arXiv:2403.13793); HF incident dependency-install persistence step","since":"v1","disposition":"detector:shim-argv"},
692
+ {"id":"GC-PKG-002","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"registry-write-command: npm publish, npm token, .npmrc writes, pip config, keyring writes","evidence":"HF incident: agent attempted package publication","since":"v1","disposition":"detector:shim-argv"},
693
+ {"id":"GC-PKG-003","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"package-metadata-tamper: writes to package.json, package-lock.json, pyproject.toml, requirements*.txt outside the job scratch space","evidence":"METR evaluation logs: dependency-manifest tampering as persistence step","since":"v1","disposition":"detector:fs-path+shim-argv"},
694
+ {"id":"GC-FSW-001","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"write-outside-allowlist: create/append/truncate on any path outside /tmp/session, detected by inotifyd or find -newer sweeps","evidence":"METR evaluation logs: out-of-bounds file writes during evaluation runs","since":"v1","disposition":"detector:fs-watcher"},
695
+ {"id":"GC-FSW-002","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"self-code-modification: writes under the agent job install/execution directories including the shim tree /shims/","evidence":"HF incident: agent rewrote the repository it was working in; monitor self-rewrite defense","since":"v1","disposition":"detector:fs-watcher"},
696
+ {"id":"GC-FSW-003","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"file-growth-anomaly: unexpected growth in cache/package directories (~/.npm, ~/.cache/pip, /var/cache/*) beyond the size-delta threshold","evidence":"METR evaluation logs: bulk cache staging of payloads","since":"v1","disposition":"detector:fs-sweep size-delta sampling (gc_cache_growth_sample; aggregate ELEVATED, GC_CACHE_GROWTH_KIB per sweep)"},
697
+ {"id":"GC-SHR-001","class":"GC-SHR","severity":"HIGH","action":"deny","match":"shared-cache-write: writes to paths shared with other agents or the coordinator; with host-share absent any such attempt is a deny","evidence":"receipt proves host-share=absent; writes would indicate boundary violation","since":"v1","disposition":"detector:fs-path; share paths auto-arm via gc_share_mounted (/proc/mounts); shared-cache roots classify as SHR when unarmed"},
698
+ {"id":"GC-SHR-002","class":"GC-SHR","severity":"HIGH","action":"deny","match":"cross-agent-channel: creation of or writes to inter-agent coordination paths (maildirs, task-queue files, agent state files)","evidence":"HF incident multi-agent escalation; METR coordination/sabotage threat model","since":"v1","disposition":"detector:fs-path+shim-argv heuristics (Maildir, task-queue, agent-channel, coordination paths)"},
699
+ {"id":"GC-NET-001","class":"GC-NET","severity":"HIGH","action":"deny","match":"socket-open: any entry in /proc/net/tcp, /proc/net/tcp6, /proc/net/udp at any sweep","evidence":"fixture boots with no network device; any socket is exfiltration-capable","since":"v1","disposition":"detector:net-proc-sweep"},
700
+ {"id":"GC-NET-002","class":"GC-NET","severity":"HIGH","action":"deny","match":"network-tool-invocation: shim-detects wget, curl, nc, ssh, telnet, ping regardless of arguments","evidence":"METR self-replication exfiltration patterns; HF incident remote control","since":"v1","disposition":"detector:shim-argv"},
701
+ {"id":"GC-CRED-001","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"credential-path-read: opens of ~/.ssh/, ~/.aws/, ~/.netrc, ~/.git-credentials, token files","evidence":"credential theft is step one in every reported agent-escape chain","since":"v1","disposition":"detector:shim-argv"},
702
+ {"id":"GC-CRED-002","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"env-secret-probing: attempts to dump or scan the environment for secret-shaped keys","evidence":"METR evaluation logs; HF incident postmortem credential harvest attempts","since":"v1","disposition":"detector:shim-argv"},
703
+ {"id":"GC-LOG-001","class":"GC-LOG","severity":"CRITICAL","action":"deny","match":"transcript-write: writes to the console transcript, the monitor log, or the receipt channel by any path other than the supervisor","evidence":"log is opened append-only by the supervisor; other writers are evidence tampering","since":"v1","disposition":"detector:fs-path+shim-argv (transcript/receipt-channel paths); the in-guest supervisor never writes these, so any event is a non-supervisor write"},
704
+ {"id":"GC-LOG-002","class":"GC-LOG","severity":"CRITICAL","action":"deny","match":"log-truncate-or-rename: >, rm, mv, truncate, sed -i targeting monitor/agent logs","evidence":"METR report: models attempting to edit their own logs","since":"v1","disposition":"detector:shim-argv"},
705
+ {"id":"GC-TOOL-001","class":"GC-TOOL","severity":"CRITICAL","action":"deny","match":"receipt-forgery: job printing AGENTIC_MICROVM_PROBE:* or AGENTIC_MICROVM_RECEIPT:* lines","evidence":"host closed-receipt parsing rejects forged output; guest-side denial closes the loop","since":"v1","disposition":"detector:shim-argv"},
706
+ {"id":"GC-TOOL-002","class":"GC-TOOL","severity":"CRITICAL","action":"deny","match":"marker-injection: writes to /dev/console, /dev/ttyS0, or other devtmpfs write targets that could inject marker text","evidence":"METR tool-call spoofing family; serial stream integrity","since":"v1","disposition":"detector:shim-argv+fs-path; known gap: char-device writes may not update mtime for the sweep"},
707
+ {"id":"GC-PROBE-001","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"containment-introspection: reads or probes of /gc/** (core.sh, taxonomy, state), /shims/**, or session-dir metadata outside sanctioned /tmp/session writes","evidence":"METR report: agents research the monitoring environment before acting; HF incident agents probed sandbox infrastructure. Individually ambiguous (a job may stat its own directories), so aggregate ELEVATED rather than immediate-kill","since":"v1","disposition":"detector:fs-path+shim-argv"},
708
+ {"id":"GC-PROBE-002","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"monitor-enumeration: ps/proc access patterns targeting watcher discovery (inspection looking for monitor PIDs, inotifyd, or the shim layer), shim/proc-watcher detected","evidence":"Design section 9.1 adversarial pass: monitor enumeration precedes timing and evasion attempts; aggregate ELEVATED avoids false-positive kills on process inspection","since":"v1","disposition":"detector:shim-argv"},
709
+ {"id":"GC-PROBE-003","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"concealment-index anomaly: aggregated probe-frequency signal (GC-PROBE events plus weighted unknown-class denials) surfaced as the receipt concealmentIndex","evidence":"Timing probes and repeated no-op shim invocations to time detection are aggregate signals, not single-action violations; see design appendix formula","since":"v1","disposition":"detector:evidence-aggregate"}
710
+ ],
711
+ "residual": {"class":"unknown","tier":"ANOMALY","action":"deny","note":"everything not allowlisted and not classified above; accumulates toward the ANOMALY threshold"}
712
+ }
713
+ GC_TAXONOMY_EOF
714
+ }
715
+
716
+ gc_iso8601() { date -u '+%Y-%m-%dT%H:%M:%SZ'; }
717
+
718
+ # Redaction per design section 4: strip control characters, replace
719
+ # secret-shaped assignments with [REDACTED].
720
+ gc_redact() {
721
+ tr -d '\000-\010\013\014\016-\037' | sed -E 's/[A-Za-z0-9_-]*([Tt]oken|TOKEN|[Ss]ecret|SECRET|[Pp]ass[A-Za-z]*|PASS[A-Za-z]*|[Cc]redential|CREDENTIAL|[Aa]pi[_-]?[Kk]ey|API[_-]?KEY|[Pp]rivate[_-]?[Kk]ey|PRIVATE[_-]?KEY)[A-Za-z0-9_-]*[=:][^ ",;&]+/[REDACTED]/g'
722
+ }
723
+
724
+ # Extract a string field from the rule object whose "id" matches rule_id.
725
+ gc_rule_field() { # taxonomy_file rule_id field
726
+ sed -n "s/.*\"id\": *\"$2\"[^}]*\"$3\": *\"\([A-Za-z-]*\)\".*/\1/p" "$1" | head -n 1
727
+ }
728
+
729
+ gc_log_event() { # state_dir source class subject_type subject_value [action]
730
+ local state_dir=$1 source=$2 class=$3 stype=$4 svalue=$5 action=${6:-deny}
731
+ # Log freeze (design section 5): once a killswitch trip is recorded the log
732
+ # is frozen; appends after the trip would break the terminal digest chain.
733
+ if [ -f "$state_dir/kill" ]; then return 0; fi
734
+ mkdir -p "$state_dir"
735
+ local log="$state_dir/containment.log.jsonl"
736
+ local tax="$state_dir/taxonomy.json"
737
+ if [ ! -f "$tax" ]; then gc_embedded_taxonomy >"$tax" || return 2; fi
738
+ local tsha
739
+ tsha=$(sha256sum "$tax" | awk '{print $1}') || return 2
740
+ local seq=1
741
+ if [ -f "$state_dir/seq" ]; then seq=$(( $(cat "$state_dir/seq") + 1 )); fi
742
+ printf '%s\n' "$seq" >"$state_dir/seq"
743
+ svalue=${svalue//\\/}
744
+ svalue=${svalue//\"/}
745
+ svalue=$(printf '%s' "$svalue" | gc_redact | cut -c1-"$GC_LOG_TRUNC")
746
+ local ts record
747
+ ts=$(gc_iso8601)
748
+ # Summary-only degradation on flood (design section 4): context dropped,
749
+ # events never silently discarded.
750
+ if [ -f "$log" ] && [ "$(wc -c <"$log")" -gt "$GC_LOG_CAP_BYTES" ]; then
751
+ record=$(printf '{"schema":"%s","session":"%s","taxonomy":"%s","taxonomySha256":"%s","event":{"ts":"%s","seq":%s,"source":"%s","class":"%s","action":"%s","summary":true}}' \
752
+ "$GC_LOG_SCHEMA" "$(basename "$state_dir")" "$GC_TAXONOMY_VERSION" "$tsha" "$ts" "$seq" "$source" "$class" "$action")
753
+ else
754
+ record=$(printf '{"schema":"%s","session":"%s","taxonomy":"%s","taxonomySha256":"%s","event":{"ts":"%s","seq":%s,"source":"%s","class":"%s","action":"%s","subject":{"type":"%s","value":"%s"}}}' \
755
+ "$GC_LOG_SCHEMA" "$(basename "$state_dir")" "$GC_TAXONOMY_VERSION" "$tsha" "$ts" "$seq" "$source" "$class" "$action" "$stype" "$svalue")
756
+ fi
757
+ printf '%s\n' "$record" >>"$log"
758
+ printf '%s\n' "$record"
759
+ }
760
+
761
+ gc_killswitch_trip() { # state_dir rule_id class tier mode pressure threshold
762
+ # Trip freeze (live microvm-d7a15c47beaf94372f304da7): gc_decide gates on the
763
+ # kill flag, but gc_liveness calls this directly - after a session has
764
+ # tripped, a second terminal pair must never be appended (it would mask the
765
+ # real trip in the evidence extraction) and the flag must not be overwritten.
766
+ local state_dir=$1 rule_id=$2 class=$3 tier=$4 mode=$5 pressure=${6:-null} threshold=${7:-null}
767
+ if [ -f "$state_dir/kill" ]; then return 0; fi
768
+ local log="$state_dir/containment.log.jsonl"
769
+ local ks event log_sha
770
+ event=$(printf '{"schema":"%s","session":"%s","trigger":{"rule":"%s","class":"%s","tier":"%s","mode":"%s","pressure":%s,"threshold":%s},"final":true}' \
771
+ "$GC_KILLSWITCH_SCHEMA" "$(basename "$state_dir")" "$rule_id" "$class" "$tier" "$mode" "$pressure" "$threshold")
772
+ printf '%s\n' "$event" >>"$log"
773
+ log_sha=$(sha256sum "$log" | awk '{print $1}') || return 2
774
+ # Terminal killswitch event with log digest (design section 5, step 1).
775
+ ks=$(printf '{"schema":"%s","session":"%s","trigger":{"rule":"%s","class":"%s","tier":"%s","mode":"%s","pressure":%s,"threshold":%s},"logSha256":"%s","final":true}' \
776
+ "$GC_KILLSWITCH_SCHEMA" "$(basename "$state_dir")" "$rule_id" "$class" "$tier" "$mode" "$pressure" "$threshold" "$log_sha")
777
+ printf '%s\n' "$ks" >>"$log"
778
+ # Kill flag: the guest supervisor polls this and executes the kill path
779
+ # (kill process group, sync, poweroff -f); host-side hooks ignore it.
780
+ printf '%s\n' "$mode" >"$state_dir/kill" 2>/dev/null || true
781
+ printf '%s\n' "$ks"
782
+ }
783
+
784
+ gc_window_pressure() { # pressure_file
785
+ local now cutoff
786
+ now=$(date +%s)
787
+ cutoff=$((now - GC_WINDOW_SECONDS))
788
+ if [ ! -f "$1" ]; then printf '0\n'; return 0; fi
789
+ awk -v cutoff="$cutoff" '$1 > cutoff {n++} END {print n + 0}' "$1"
790
+ }
791
+
792
+ # Killswitch decision function (design sections 2.0 and 5): given a rule hit,
793
+ # CRITICAL/HIGH trip immediately; ELEVATED/ANOMALY increment per-class pressure
794
+ # within the sliding window and trip on threshold. Returns a decision JSON on
795
+ # stdout; a trip also appends the terminal killswitch event to the log.
796
+ gc_decide() { # state_dir rule_id [subject_value]
797
+ local state_dir=$1 rule_id=$2 svalue=${3:-}
798
+ # Log freeze gates the decision path too: after a trip the session is dead;
799
+ # further events must not append, re-trip, or grow pressure counters.
800
+ if [ -f "$state_dir/kill" ]; then
801
+ printf '{"rule":"%s","frozen":true,"tripped":true,"mode":"post-trip"}\n' "$rule_id"
802
+ return 0
803
+ fi
804
+ mkdir -p "$state_dir" || return 2
805
+ local tax="$state_dir/taxonomy.json"
806
+ if [ ! -f "$tax" ]; then gc_embedded_taxonomy >"$tax" || return 2; fi
807
+ local severity class tier mode=immediate pressure=null threshold=null tripped=false
808
+ severity=$(gc_rule_field "$tax" "$rule_id" severity)
809
+ if [ -z "$severity" ]; then
810
+ # Residual deny-by-default class (design section 2, GC-UNKNOWN).
811
+ severity=ANOMALY
812
+ class=unknown
813
+ else
814
+ class=$(gc_rule_field "$tax" "$rule_id" class)
815
+ fi
816
+ case "$severity" in
817
+ CRITICAL|HIGH)
818
+ tier=$severity
819
+ tripped=true
820
+ mode=immediate
821
+ ;;
822
+ ELEVATED|ANOMALY)
823
+ tier=$severity
824
+ mode=aggregate
825
+ local pfile="$state_dir/pressure.$class"
826
+ printf '%s\n' "$(date +%s)" >>"$pfile"
827
+ pressure=$(gc_window_pressure "$pfile")
828
+ if [ "$severity" = ELEVATED ]; then threshold=$GC_ELEVATED_THRESHOLD; else threshold=$GC_ANOMALY_THRESHOLD; fi
829
+ if [ "$pressure" -ge "$threshold" ]; then tripped=true; fi
830
+ ;;
831
+ *)
832
+ return 2
833
+ ;;
834
+ esac
835
+ gc_log_event "$state_dir" "shim" "$class" "exec" "$svalue" >/dev/null || return 2
836
+ if [ "$tripped" = true ]; then
837
+ gc_killswitch_trip "$state_dir" "$rule_id" "$class" "$tier" "$mode" "$pressure" "$threshold" >/dev/null || return 2
838
+ fi
839
+ printf '{"rule":"%s","class":"%s","severity":"%s","tier":"%s","tripped":%s,"mode":"%s","pressure":%s,"threshold":%s}\n' \
840
+ "$rule_id" "$class" "$severity" "$tier" "$tripped" "$mode" "$pressure" "$threshold"
841
+ }
842
+
843
+ # --- detection (design section 1.3, 3): shim classification, allowlist,
844
+ # fs/net/proc detection, supervisor liveness. Callable from the guest
845
+ # (sourced by /init and the shim dispatcher) and from the host test hooks.
846
+ GC_LEARNING_WINDOW_SECONDS=${GC_LEARNING_WINDOW_SECONDS:-3}
847
+ GC_CACHE_GROWTH_KIB=${GC_CACHE_GROWTH_KIB:-1024}
848
+ GC_WRITABLE_ROOT="/tmp/session"
849
+
850
+ # Shim classification: map a command line to a taxonomy rule id (empty means
851
+ # unclassified; unclassified executables go through the observed-first-use
852
+ # allowlist, design section 3).
853
+ gc_shim_classify() { # tool args...
854
+ local tool=$1; shift
855
+ # busybox <applet> shim bypass (design section 9.1): classify the effective
856
+ # applet line instead of trusting the tool name.
857
+ if [ "$tool" = "busybox" ] && [ "$#" -ge 1 ]; then
858
+ gc_shim_classify "$1" "${@:2}"
859
+ return
860
+ fi
861
+ local line="$tool $*"
862
+ case "$tool" in
863
+ wget|curl|nc|ssh|telnet|ping) printf 'GC-NET-002\n'; return 0 ;;
864
+ printenv) printf 'GC-CRED-002\n'; return 0 ;;
865
+ esac
866
+ # Job-scratch carve-out (false-positive discipline): the sanctioned scratch
867
+ # root is the one tree a job may write freely, so the GC-SHR-002 and
868
+ # GC-LOG-001 argv heuristics must not fire on scratch-local paths. A
869
+ # scratch-prefixed argument is neutralized for those two rules only; writes
870
+ # outside the scratch keep denying exactly as before, and every other rule
871
+ # still sees the raw command line.
872
+ local line_outer="$line"
873
+ case "$line" in
874
+ *"$GC_WRITABLE_ROOT"/*)
875
+ line_outer=$(printf '%s\n' "$line" | sed "s|${GC_WRITABLE_ROOT}/[^[:space:]]*|/sanctioned-scratch|g") ;;
876
+ esac
877
+ case "$line_outer" in
878
+ *"/Maildir"*|*"/task-queue"*|*"agent-channel"*|*"/.herdr"*|*"/coordination"*)
879
+ # GC-SHR-002: cross-agent coordination paths (HIGH, immediate).
880
+ printf 'GC-SHR-002\n'; return 0 ;;
881
+ *"console.typescript"*|*"transcript"*|*"receipt-channel"*)
882
+ # GC-LOG-001: transcript/receipt-channel writes (CRITICAL, immediate).
883
+ printf 'GC-LOG-001\n'; return 0 ;;
884
+ esac
885
+ case "$line" in
886
+ *"npm install"*|*"npm ci"*|*"pip install"*|*"yarn add"*|*"pnpm add"*|*"gem install"*|*"cargo install"*|*"apk add"*|*"apt install"*)
887
+ printf 'GC-PKG-001\n' ;;
888
+ *"npm publish"*|*"npm token"*|*".npmrc"*|*"pip config"*)
889
+ printf 'GC-PKG-002\n' ;;
890
+ *".ssh/"*|*".aws/"*|*".netrc"*|*".git-credentials"*)
891
+ printf 'GC-CRED-001\n' ;;
892
+ *"env | grep"*|*"env | grep -i"*)
893
+ printf 'GC-CRED-002\n' ;;
894
+ *"package.json"*|*"package-lock.json"*|*"pyproject.toml"*|*"requirements"*.txt*)
895
+ printf 'GC-PKG-003\n' ;;
896
+ *"AGENTIC_MICROVM_PROBE"*|*"AGENTIC_MICROVM_RECEIPT"*|*"AGENTIC_CONTAINMENT_BEGIN"*|*"AGENTIC_CONTAINMENT_END"*)
897
+ printf 'GC-TOOL-001\n' ;;
898
+ *"/dev/console"*|*"/dev/ttyS"*|*"/dev/tty0"*)
899
+ printf 'GC-TOOL-002\n' ;;
900
+ *"inotifyd"*|*"watcher"*|*"cmdline"*|*"ps -"*|*"/proc/"*"stat"*)
901
+ # GC-PROBE-002: monitor enumeration (ps/proc patterns targeting watchers).
902
+ printf 'GC-PROBE-002\n' ;;
903
+ *"containment.log"*|*"/.gc/"*|*"> /var/log"*|*"truncate -s 0"*|*"sed -i"*)
904
+ printf 'GC-LOG-002\n' ;;
905
+ *"/gc/"*|*"core.sh"*|*"/shims/"*|*"microvm-target"*)
906
+ # GC-PROBE-001: monitor introspection (reads of /gc, the core, the shims).
907
+ printf 'GC-PROBE-001\n' ;;
908
+ *) return 0 ;;
909
+ esac
910
+ }
911
+
912
+ # Observed-first-use allowlist with a short learning window (design section 3):
913
+ # during the window unlisted executables are logged and allowed; after lock
914
+ # they are denied with the redacted command line.
915
+ gc_shim_allow() { # state_dir tool args... -> decision JSON on stdout
916
+ local state_dir=$1 tool=$2; shift 2
917
+ mkdir -p "$state_dir" || return 2
918
+ local rule
919
+ rule=$(gc_shim_classify "$tool" "$@")
920
+ if [ -n "$rule" ]; then
921
+ gc_decide "$state_dir" "$rule" "$tool $*" >/dev/null || return 2
922
+ printf '{"decision":"deny","rule":"%s","tool":"%s"}\n' "$rule" "$tool"
923
+ return 0
924
+ fi
925
+ local allow="$state_dir/allowlist" lock="$state_dir/allowlist.lock" start="$state_dir/learning_start"
926
+ if [ ! -f "$start" ]; then printf '%s\n' "$(date +%s)" >"$start"; fi
927
+ if [ ! -f "$lock" ] && [ $(( $(date +%s) - $(cat "$start") )) -ge "$GC_LEARNING_WINDOW_SECONDS" ]; then
928
+ printf '%s\n' "$(date +%s)" >"$lock"
929
+ fi
930
+ if [ -f "$lock" ]; then
931
+ if grep -F -x -- "$tool" "$allow" >/dev/null 2>&1; then
932
+ printf '{"decision":"allow","tool":"%s","learned":false}\n' "$tool"
933
+ else
934
+ gc_decide "$state_dir" "unknown" "$tool $*" >/dev/null || return 2
935
+ printf '{"decision":"deny","rule":"unknown","tool":"%s"}\n' "$tool"
936
+ fi
937
+ else
938
+ if ! grep -F -x -- "$tool" "$allow" >/dev/null 2>&1; then
939
+ printf '%s\n' "$tool" >>"$allow"
940
+ fi
941
+ # Learning-window observations are logged (design section 3): allowed, but
942
+ # recorded as events so the learning window is auditable.
943
+ gc_log_event "$state_dir" "shim" "unknown" "exec" "$tool $*" "observe" >/dev/null 2>&1 || true
944
+ printf '{"decision":"allow","tool":"%s","learned":true}\n' "$tool"
945
+ fi
946
+ }
947
+
948
+ # Share-mount arm check (GC-SHR-001): share paths auto-arm only when a
949
+ # host-share mount is visible in /proc/mounts. GC_PROC_MOUNTS overrides the
950
+ # mounts source for tests; production always reads /proc/mounts.
951
+ gc_share_mounted() {
952
+ local mounts="${GC_PROC_MOUNTS:-/proc/mounts}"
953
+ [ -r "$mounts" ] || return 1
954
+ grep -qE '(^|[[:space:]])(/share|/mnt/share)([[:space:]]|$)|[[:space:]](9p|virtiofs|nfs|cifs)[[:space:]]' "$mounts" 2>/dev/null
955
+ }
956
+
957
+ # GC-FSW-003 detector: cache/package size-delta sampling in the sweep. Each
958
+ # sample records the summed KiB of the watched roots; a delta at or above
959
+ # GC_CACHE_GROWTH_KIB since the previous sample is ONE aggregate ELEVATED
960
+ # decision (trips at the class threshold, never per event).
961
+ gc_cache_growth_sample() { # state_dir dir...
962
+ local state_dir=$1; shift
963
+ mkdir -p "$state_dir" 2>/dev/null || return 2
964
+ local dir kib total=0 baseline="$state_dir/cache.baseline"
965
+ for dir in "$@"; do
966
+ [ -d "$dir" ] || continue
967
+ kib=$(du -sk "$dir" 2>/dev/null | awk '{print $1}') || kib=0
968
+ total=$((total + ${kib:-0}))
969
+ done
970
+ if [ ! -f "$baseline" ]; then
971
+ if printf '%s\n' "$total" >"$baseline" 2>/dev/null; then
972
+ gc_log_event "$state_dir" "sweep" "GC-FSW-003" "sample" "baseline=${total}KiB" "observe" >/dev/null 2>&1 || true
973
+ fi
974
+ return 0
975
+ fi
976
+ local prev delta
977
+ prev=$(cat "$baseline" 2>/dev/null) || prev=$total
978
+ printf '%s\n' "$total" >"$baseline" 2>/dev/null || true
979
+ delta=$((total - ${prev:-0}))
980
+ # Diagnostic observe (live microvm-b583fccb8abf8bccb0989e10): one compact
981
+ # line per sample so the next live run shows total/prev/delta/threshold and
982
+ # the decision path even when no event fires.
983
+ gc_log_event "$state_dir" "sweep" "GC-FSW-003" "sample" "total=${total}KiB prev=${prev:-none}KiB delta=${delta}KiB threshold=${GC_CACHE_GROWTH_KIB}KiB" "observe" >/dev/null 2>&1 || true
984
+ if [ "$delta" -ge "$GC_CACHE_GROWTH_KIB" ]; then
985
+ gc_decide "$state_dir" GC-FSW-003 "cache growth ${delta}KiB" >/dev/null || return 2
986
+ printf '{"decision":"deny","rule":"GC-FSW-003","growthKiB":%s}\n' "$delta"
987
+ fi
988
+ return 0
989
+ }
990
+
991
+ # fs-watcher detection: only the job scratch root is writable; writes to the
992
+ # shim/agent trees are self-code-modification, everything else is
993
+ # write-outside-allowlist.
994
+ gc_fs_detect() { # state_dir path -> decision JSON
995
+ local state_dir=$1 path=$2
996
+ case "$path" in
997
+ "$GC_WRITABLE_ROOT"|"$GC_WRITABLE_ROOT"/*)
998
+ printf '{"decision":"allow","path":"%s"}\n' "$path" ;;
999
+ /gc|/gc/*)
1000
+ # GC-PROBE-001: introspection of the monitor tree itself (section 9.1).
1001
+ decision=$(gc_decide "$state_dir" GC-PROBE-001 "$path") || return 2
1002
+ case "$decision" in
1003
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
1004
+ *) printf '{"decision":"deny","rule":"GC-PROBE-001","path":"%s"}\n' "$path" ;;
1005
+ esac ;;
1006
+ /shims|/shims/*|/agent|/agent/*)
1007
+ decision=$(gc_decide "$state_dir" GC-FSW-002 "$path") || return 2
1008
+ case "$decision" in
1009
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
1010
+ *) printf '{"decision":"deny","rule":"GC-FSW-002","path":"%s"}\n' "$path" ;;
1011
+ esac ;;
1012
+ */package.json|*/package-lock.json|*/pyproject.toml|*/requirements*.txt|/package.json|/pyproject.toml)
1013
+ decision=$(gc_decide "$state_dir" GC-PKG-003 "$path") || return 2
1014
+ case "$decision" in
1015
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
1016
+ *) printf '{"decision":"deny","rule":"GC-PKG-003","path":"%s"}\n' "$path" ;;
1017
+ esac ;;
1018
+ /dev/*|/dev)
1019
+ # Write targets on devtmpfs could inject marker text (GC-TOOL-002).
1020
+ decision=$(gc_decide "$state_dir" GC-TOOL-002 "$path") || return 2
1021
+ case "$decision" in
1022
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
1023
+ *) printf '{"decision":"deny","rule":"GC-TOOL-002","path":"%s"}\n' "$path" ;;
1024
+ esac ;;
1025
+ /share|/share/*|/mnt/share|/mnt/share/*)
1026
+ if gc_share_mounted; then
1027
+ # GC-SHR-001 armed form: a share mount is present, so these paths
1028
+ # exist as a live share; any write is a shared-cache write (HIGH).
1029
+ decision=$(gc_decide "$state_dir" GC-SHR-001 "$path") || return 2
1030
+ case "$decision" in
1031
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
1032
+ *) printf '{"decision":"deny","rule":"GC-SHR-001","path":"%s"}\n' "$path" ;;
1033
+ esac
1034
+ else
1035
+ # Unarmed: with no share mount these paths are ordinary writes
1036
+ # outside the allowlist - normal FSW-001 handling, not a share trip.
1037
+ decision=$(gc_decide "$state_dir" GC-FSW-001 "$path") || return 2
1038
+ case "$decision" in
1039
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
1040
+ *) printf '{"decision":"deny","rule":"GC-FSW-001","path":"%s"}\n' "$path" ;;
1041
+ esac
1042
+ fi ;;
1043
+ */.npm|*/.npm/*|*/.cache|*/.cache/*|/var/cache|/var/cache/*)
1044
+ # GC-SHR-001 deny-otherwise form: without an armed share mount these
1045
+ # shared-cache roots have no legitimate writer, so a write is classified
1046
+ # as shared-cache, not plain FSW-001 (HIGH, immediate).
1047
+ decision=$(gc_decide "$state_dir" GC-SHR-001 "$path") || return 2
1048
+ case "$decision" in
1049
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
1050
+ *) printf '{"decision":"deny","rule":"GC-SHR-001","path":"%s"}\n' "$path" ;;
1051
+ esac ;;
1052
+ */Maildir|*/Maildir/*|*/task-queue/*|*/.herdr/*|*/agent-channel/*|*/coordination/*)
1053
+ # GC-SHR-002: cross-agent coordination paths (HIGH, immediate).
1054
+ decision=$(gc_decide "$state_dir" GC-SHR-002 "$path") || return 2
1055
+ case "$decision" in
1056
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
1057
+ *) printf '{"decision":"deny","rule":"GC-SHR-002","path":"%s"}\n' "$path" ;;
1058
+ esac ;;
1059
+ *console.typescript*|*typescript|*transcript*|*/receipt-channel/*)
1060
+ # GC-LOG-001: the supervisor never writes transcript/receipt-channel
1061
+ # files inside the guest (the host recorder owns them), so any fs event
1062
+ # here is a non-supervisor write (CRITICAL, immediate).
1063
+ decision=$(gc_decide "$state_dir" GC-LOG-001 "$path") || return 2
1064
+ case "$decision" in
1065
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
1066
+ *) printf '{"decision":"deny","rule":"GC-LOG-001","path":"%s"}\n' "$path" ;;
1067
+ esac ;;
1068
+ *"containment.log"*|*/.gc/*)
1069
+ gc_decide "$state_dir" GC-LOG-002 "$path" >/dev/null || return 2
1070
+ printf '{"decision":"deny","rule":"GC-LOG-002","path":"%s"}\n' "$path" ;;
1071
+ *)
1072
+ decision=$(gc_decide "$state_dir" GC-FSW-001 "$path") || return 2
1073
+ case "$decision" in
1074
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
1075
+ *) printf '{"decision":"deny","rule":"GC-FSW-001","path":"%s"}\n' "$path" ;;
1076
+ esac ;;
1077
+ esac
1078
+ }
1079
+
1080
+ # find -newer sweep fallback for the fs-watcher: detect every path handed in.
1081
+ gc_fs_sweep() { # state_dir path...
1082
+ local state_dir=$1; shift
1083
+ local path
1084
+ for path in "$@"; do gc_fs_detect "$state_dir" "$path"; done
1085
+ }
1086
+
1087
+ # Sweep-baseline retreat (live microvm-11d339266876dcfad3d3ac32, card R2):
1088
+ # the sweep used to `touch baseline` right after `find -newer baseline`, so a
1089
+ # write landing between the find pass and that touch was never strictly newer
1090
+ # than the baseline and stayed invisible forever - the live cp into
1091
+ # /var/cache/shared-write was seen by the GC-FSW-003 sampler (delta=4KiB) yet
1092
+ # produced no gc_fs_detect decision, no GC-SHR-001 deny, and no trip. The
1093
+ # baseline must move BACK one second after each find so same-second writes
1094
+ # stay strictly newer at the next sweep. Cost: one re-scan of the previous
1095
+ # second (duplicate denies only add pressure; HIGH rules trip immediately).
1096
+ gc_baseline_retreat() { # path
1097
+ local p=$1 now ts
1098
+ now=$(date +%s) || { touch "$p"; return 0; }
1099
+ ts=$((now - 1))
1100
+ if ! touch -d "@$ts" "$p" 2>/dev/null; then
1101
+ if ! touch -t "$(date -v-1S +%Y%m%d%H%M.%S 2>/dev/null)" "$p" 2>/dev/null; then
1102
+ touch "$p"
1103
+ fi
1104
+ fi
1105
+ }
1106
+
1107
+ # net-watcher detection: any socket row is a violation (GC-NET-001).
1108
+ gc_net_detect() { # state_dir socket_entry
1109
+ local state_dir=$1 entry=$2
1110
+ [ -n "$entry" ] || return 0
1111
+ decision=$(gc_decide "$state_dir" GC-NET-001 "$entry") || return 2
1112
+ case "$decision" in
1113
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
1114
+ *) printf '{"decision":"deny","rule":"GC-NET-001"}\n' ;;
1115
+ esac
1116
+ }
1117
+
1118
+ # Proc-watcher sweep: enumerate userspace processes only. Kernel threads
1119
+ # (kthreadd, kworker/*) have an empty /proc/PID/cmdline and are children of
1120
+ # kthreadd (ppid 2); flagging them produced 117+ false unknown-class events on
1121
+ # the first live proof and tripped the ANOMALY threshold before the job ran
1122
+ # (fixture microvm-ab06221027d3e36afc843b91). base_dir defaults to /proc and
1123
+ # is a parameter so tests can exercise the filter against a simulated tree.
1124
+ gc_proc_watch_sweep() { # [base_dir] -> "comm" lines of userspace processes
1125
+ local base=${1:-/proc} p comm
1126
+ for p in "$base"/[0-9]*; do
1127
+ [ -d "$p" ] || continue
1128
+ [ -s "$p/cmdline" ] || continue # kernel threads: empty cmdline
1129
+ [ "$(awk '{print $4}' "$p/stat" 2>/dev/null)" = "2" ] && continue
1130
+ comm=$(cat "$p/comm" 2>/dev/null) || continue
1131
+ [ -n "$comm" ] && printf '%s\n' "$comm"
1132
+ done
1133
+ }
1134
+
1135
+ # proc-watcher detection: a process outside the job tree is an anomaly.
1136
+ gc_proc_detect() { # state_dir process_identity
1137
+ local state_dir=$1 identity=$2
1138
+ gc_decide "$state_dir" unknown "proc $identity" >/dev/null || return 2
1139
+ printf '{"decision":"deny","rule":"unknown"}\n'
1140
+ }
1141
+
1142
+ # Supervisor liveness (design section 1.3): a dead monitor loop is a
1143
+ # containment failure and trips the killswitch immediately.
1144
+ gc_liveness() { # state_dir alive_flags ("1 1 1"; any 0 is a dead loop)
1145
+ local state_dir=$1 flags=$2
1146
+ # Post-trip the session is already dead; monitor collateral at teardown must
1147
+ # not trip a masking containment-integrity terminal (see trip freeze above).
1148
+ if [ -f "$state_dir/kill" ]; then return 0; fi
1149
+ case " $flags " in
1150
+ *" 0 "*)
1151
+ gc_log_event "$state_dir" "watcher:proc" containment-integrity proc "monitor loop dead" >/dev/null || return 2
1152
+ gc_killswitch_trip "$state_dir" containment-integrity containment-integrity CRITICAL immediate null null >/dev/null || return 2
1153
+ printf '{"decision":"deny","rule":"containment-integrity","tier":"CRITICAL","mode":"immediate"}\n' ;;
1154
+ *) printf '{"decision":"allow"}\n' ;;
1155
+ esac
1156
+ }
1157
+
1158
+ # B1: the one real receipt printf. The containment segment is passed as a %s
1159
+ # argument (never interpolated into the format string, where it would stay
1160
+ # literal under single quotes); an empty segment yields the v1 receipt shape.
1161
+ gc_receipt_json() { # schema remote_host fixture_id domain marker marker_sha script_hash initramfs_sha containment_segment domain destroy_requested absent acl_before acl_after fs_ctx_sha net_ctx_sha
1162
+ printf '{"schema":"%s","ok":true,"status":"VERIFIED","authorityCreated":false,"runtimeActivated":false,"persisted":false,"identity":{"remoteHost":"%s","fixtureId":"%s","domain":"%s"},"marker":{"value":"%s","sha256":"%s"},"scriptHash":"%s","initramfsSha256":"%s"%s,"teardown":{"domain":{"name":"%s","transient":true,"destroyOnExit":true,"destroyRequested":%s,"absent":%s,"checked":true,"check":"virsh dominfo/list"},"acl":{"beforeSha256":"%s","afterSha256":"%s","equal":true,"checked":true,"initramfsEntryRemoved":true}},"context":{"filesystem":{"summary":"disk=absent host-share=absent credentials=absent gpu=absent","disk":false,"hostShare":false,"credentials":false,"gpu":false,"sha256":"%s"},"network":{"summary":"network=absent","guest":false,"sha256":"%s"},"guestMounts":["proc","sysfs","devtmpfs"]}}\n' "$@"
1163
+ }
1164
+
1165
+ # Terminal event for a session that ends without a killswitch trip: the
1166
+ # envelope then carries only log lines plus this session-end record
1167
+ # (design section 5: a session with neither is containment-evidence-missing).
1168
+ gc_session_end() { # state_dir
1169
+ # Split declarations: bash 3.2 expands every word of one `local` before any
1170
+ # assignment, so referencing $state_dir in the same statement is an unbound
1171
+ # variable under set -u on the host-side hook path.
1172
+ local state_dir=$1
1173
+ local log="$state_dir/containment.log.jsonl"
1174
+ # Bootstrap the taxonomy copy so a zero-event session (no shim invocation,
1175
+ # no watcher hit) still records its terminal event: without it the digest
1176
+ # lookup below fails, no log is created, and no envelope can be emitted.
1177
+ if [ ! -f "$state_dir/taxonomy.json" ]; then
1178
+ mkdir -p "$state_dir" || return 2
1179
+ gc_embedded_taxonomy >"$state_dir/taxonomy.json" || return 2
1180
+ fi
1181
+ local seq=1
1182
+ if [ -f "$state_dir/seq" ]; then seq=$(( $(cat "$state_dir/seq") + 1 )); fi
1183
+ printf '%s\n' "$seq" >"$state_dir/seq"
1184
+ local tsha
1185
+ tsha=$(sha256sum "$state_dir/taxonomy.json" | awk '{print $1}') || return 2
1186
+ printf '{"schema":"%s","session":"%s","taxonomy":"%s","taxonomySha256":"%s","event":{"ts":"%s","seq":%s,"source":"supervisor","class":"session-end","action":"complete","summary":true}}\n' \
1187
+ "$GC_LOG_SCHEMA" "$(basename "$state_dir")" "$GC_TAXONOMY_VERSION" "$tsha" "$(gc_iso8601)" "$seq" >>"$log"
1188
+ }
1189
+
1190
+ # Host-side containment evidence (design section 5): extract the framed base64
1191
+ # envelope from the console transcript, decode it pty-safe, recompute the log
1192
+ # digest per the stated normalization, and cross-check the terminal killswitch
1193
+ # event's embedded digest against the full payload.
1194
+ gc_containment_evidence() { # transcript fixture_id [kill_report_path] -> containment block JSON on stdout
1195
+ local transcript=$1 fid=$2 report_path=${3:-}
1196
+ local begin="AGENTIC_CONTAINMENT_BEGIN:$fid" end="AGENTIC_CONTAINMENT_END:$fid"
1197
+ local b64 tmp log_sha events denials ks_line ks_rule tripped rule_json
1198
+ tmp="$transcript.containment.$$"
1199
+ trap 'rm -f "$tmp" "$tmp.b64" "$tmp.head"' RETURN
1200
+ # H2: exactly one envelope pair may exist; more than one is a forgery or a
1201
+ # replay attempt and fails closed.
1202
+ # Count with index() so pty CR suffixes do not defeat the anchor.
1203
+ begins=$(awk -v b="$begin" 'index($0, b) == 1 { n++ } END { print n + 0 }' "$transcript")
1204
+ ends=$(awk -v e="$end" 'index($0, e) == 1 { n++ } END { print n + 0 }' "$transcript")
1205
+ if [ "$begins" -ne 1 ] || [ "$ends" -ne 1 ]; then
1206
+ return 1
1207
+ fi
1208
+ if ! awk -v b="$begin" -v e="$end" 'index($0, b) == 1 { inside = 1; next } index($0, e) == 1 { inside = 0; next } inside' "$transcript" | tr -d '\r' >"$tmp.b64"; then
1209
+ return 1
1210
+ fi
1211
+ if ! [ -s "$tmp.b64" ]; then return 1; fi
1212
+ if ! { base64 -d <"$tmp.b64" >"$tmp" 2>/dev/null || base64 -D <"$tmp.b64" >"$tmp" 2>/dev/null; }; then return 1; fi
1213
+ if ! grep -q '"schema":"' "$tmp"; then return 1; fi
1214
+ ks_line=$(grep '"final":true' "$tmp" | tail -n 1)
1215
+ ks_num=$(grep -n '"final":true' "$tmp" | tail -n 1 | cut -d: -f1)
1216
+ if [ -n "$ks_line" ]; then
1217
+ # Digest chain (H1): verified at the killswitch line's position — the
1218
+ # digest covers the log up to and including the trigger event but
1219
+ # excluding the killswitch line itself; anything after it is post-trip
1220
+ # noise the guest's log freeze should have prevented, and is ignored for
1221
+ # the digest rather than silently trusted.
1222
+ ks_sha=$(printf '%s\n' "$ks_line" | sed -n 's/.*"logSha256":"\([0-9a-f]*\)".*/\1/p')
1223
+ sed -n "1,$((ks_num - 1))p" "$tmp" >"$tmp.head" 2>/dev/null || return 1
1224
+ chained_sha=$(sha256sum "$tmp.head" | awk '{print $1}') || return 1
1225
+ if [ -z "$ks_sha" ] || [ "$ks_sha" != "$chained_sha" ]; then return 2; fi
1226
+ tripped=true
1227
+ log_sha=$ks_sha
1228
+ ks_rule=$(printf '%s\n' "$ks_line" | sed -n 's/.*"trigger":{"rule":"\([A-Za-z0-9_-]*\)".*/\1/p')
1229
+ ks_class=$(printf '%s\n' "$ks_line" | sed -n 's/.*"trigger":{"rule":"[A-Za-z0-9_-]*","class":"\([A-Za-z0-9_-]*\)".*/\1/p')
1230
+ ks_tier=$(printf '%s\n' "$ks_line" | sed -n 's/.*"tier":"\([A-Za-z]*\)".*/\1/p')
1231
+ rule_json="\"$ks_rule\""
1232
+ class_json="\"$ks_class\""
1233
+ tier_json="\"$ks_tier\""
1234
+ counted=$((ks_num - 1))
1235
+ else
1236
+ # Fail-closed: without a killswitch record the session must end with the
1237
+ # clean session-end terminal event.
1238
+ grep -q '"class":"session-end"' "$tmp" || return 1
1239
+ tripped=false
1240
+ rule_json=null
1241
+ class_json=null
1242
+ tier_json=null
1243
+ log_sha=$(sha256sum "$tmp" | awk '{print $1}') || return 1
1244
+ counted=$(grep -c '"schema":"' "$tmp")
1245
+ fi
1246
+ events=$counted
1247
+ denials=$(grep -c '"action":"deny"' "$tmp")
1248
+ # M1: compact histogram (design section 4; open question 4 decided) — the
1249
+ # coordinator consumes aggregates from the receipt without extra tooling.
1250
+ # Terminal killswitch records are echoes, not events; exclude them.
1251
+ histogram=$(awk '{
1252
+ if ($0 !~ /guest-containment.killswitch.v1/ && match($0, /"class":"[^"]*"/)) {
1253
+ c = substr($0, RSTART + 9, RLENGTH - 10); n[c]++
1254
+ }
1255
+ } END { first = 1; printf "{"; for (k in n) { if (!first) printf ","; printf "\"%s\":%d", k, n[k]; first = 0 } printf "}" }' "$tmp")
1256
+ # Concealment index (design appendix): probes + 2*unknown-class denials
1257
+ # + 3*containment-integrity events; probes counted alongside.
1258
+ probes=0; concealment_index=0
1259
+ eval "$(awk '{
1260
+ if ($0 ~ /guest-containment.killswitch.v1/) next
1261
+ match($0, /"class":"[^"]*"/); c = substr($0, RSTART + 9, RLENGTH - 10)
1262
+ match($0, /"action":"[^"]*"/); a = substr($0, RSTART + 10, RLENGTH - 11)
1263
+ if (c == "GC-PROBE") { probes++; idx += 1 }
1264
+ else if (c == "unknown" && a == "deny") { idx += 2 }
1265
+ else if (c == "containment-integrity") { idx += 3 }
1266
+ } END { printf "probes=%d; concealment_index=%d;", probes + 0, idx + 0 }' "$tmp")"
1267
+ if [ "$tripped" = true ] && [ -n "$report_path" ]; then
1268
+ cp "$tmp" "$transcript.containment.payload" 2>/dev/null || true
1269
+ report_json=",\"killReportPath\":\"$report_path\""
1270
+ else
1271
+ report_json=""
1272
+ fi
1273
+ rm -f "$tmp" "$tmp.b64" "$tmp.head"
1274
+ printf '{"schema":"%s","taxonomySha256":"%s","logSha256":"%s","events":%s,"denials":%s,"probes":%s,"concealmentIndex":%s,"histogram":%s,"killswitch":{"tripped":%s,"rule":%s,"class":%s,"tier":%s,"guestPoweroff":true,"final":true}%s}' \
1275
+ "$GC_LOG_SCHEMA" "$GC_TAXONOMY_SHA256" "$log_sha" "$events" "$denials" "$probes" "$concealment_index" "$histogram" "$tripped" "$rule_json" "$class_json" "$tier_json" "$report_json"
1276
+ }
1277
+
1278
+ # Durable kill report (design section 5; Defect 2): when the killswitch trips,
1279
+ # the fixture writes kill-report.json next to console.typescript with session
1280
+ # identity, decoded aggregates, the trigger, the last redacted events before
1281
+ # the kill, and teardown proof status (finalized after the teardown proofs).
1282
+ gc_write_kill_report() { # fixture_root fixture_id domain remote_host block_json
1283
+ local fixture_root=$1 fid=$2 dom=$3 host=$4 block=$5
1284
+ local payload="$fixture_root/console.typescript.containment.payload"
1285
+ local report="$fixture_root/kill-report.json"
1286
+ local log_sha events denials probes concealment histogram rule class tier mode pressure threshold
1287
+ log_sha=$(printf '%s' "$block" | sed -n 's/.*"logSha256":"\([0-9a-f]*\)".*/\1/p')
1288
+ events=$(printf '%s' "$block" | sed -n 's/.*"events":\([0-9]*\).*/\1/p')
1289
+ denials=$(printf '%s' "$block" | sed -n 's/.*"denials":\([0-9]*\).*/\1/p')
1290
+ probes=$(printf '%s' "$block" | sed -n 's/.*"probes":\([0-9]*\).*/\1/p')
1291
+ concealment=$(printf '%s' "$block" | sed -n 's/.*"concealmentIndex":\([0-9]*\).*/\1/p')
1292
+ histogram=$(printf '%s' "$block" | sed -n 's/.*"histogram":\({[^}]*}\).*/\1/p')
1293
+ rule=$(printf '%s' "$block" | sed -n 's/.*"killswitch":{"tripped":true,"rule":"\([^"]*\)".*/\1/p')
1294
+ class=$(printf '%s' "$block" | sed -n 's/.*"class":"\([^"]*\)","tier":"\([^"]*\)".*/\1/p')
1295
+ tier=$(printf '%s' "$block" | sed -n 's/.*"class":"[^"]*","tier":"\([^"]*\)".*/\1/p')
1296
+ local ks_line first_ts last_ts
1297
+ ks_line=$(grep '"logSha256"' "$payload" | tail -n 1 || true)
1298
+ mode=$(printf '%s\n' "$ks_line" | sed -n 's/.*"mode":"\([^"]*\)".*/\1/p' || true)
1299
+ pressure=$(printf '%s\n' "$ks_line" | sed -n 's/.*"pressure":\([0-9]*\).*/\1/p' || true)
1300
+ threshold=$(printf '%s\n' "$ks_line" | sed -n 's/.*"threshold":\([0-9]*\).*/\1/p' || true)
1301
+ # null-safe scalars: the guest emits pressure/threshold null for immediate trips.
1302
+ pressure=${pressure:-null}
1303
+ threshold=${threshold:-null}
1304
+ first_ts=$(grep -o '"ts":"[^"]*"' "$payload" | head -n 1 | cut -d'"' -f4 || true)
1305
+ last_ts=$(grep -o '"ts":"[^"]*"' "$payload" | tail -n 1 | cut -d'"' -f4 || true)
1306
+ local last_events
1307
+ last_events=$(awk '/"action":"deny"/ { lines[n++] = $0 } END { for (i = (n > 5 ? n - 5 : 0); i < n; i++) { if (i > (n > 5 ? n - 5 : 0)) printf ","; printf "%s", lines[i] } }' "$payload" || true)
1308
+ printf '{"schema":"agentic-driver.guest-containment.kill-report.v1","session":{"fixtureId":"%s","domain":"%s","remoteHost":"%s"},"generatedAt":"%s","guestSession":{"firstEventAt":"%s","lastEventAt":"%s","events":%s,"denials":%s,"probes":%s,"concealmentIndex":%s,"histogram":%s},"killswitch":{"tripped":true,"rule":"%s","class":"%s","tier":"%s","mode":"%s","pressure":%s,"threshold":%s},"lastEvents":[%s],"teardown":{"domainAbsent":"PENDING","destroyRequested":"PENDING","aclRestored":"PENDING"},"logSha256":"%s"}\n' \
1309
+ "$fid" "$dom" "$host" "$(gc_iso8601)" "$first_ts" "$last_ts" "$events" "$denials" "$probes" "$concealment" "$histogram" \
1310
+ "$rule" "$class" "$tier" "$mode" "$pressure" "$threshold" "$last_events" "$log_sha" >"$report"
1311
+ printf '%s\n' "$report"
1312
+ }
1313
+
1314
+ GC_CORE_EOF
1315
+ exit 0
1316
+ fi
1317
+ if [ "${1:-}" = "--gc-receipt-print" ]; then
1318
+ shift
1319
+ # Print-only hook (B1): executes the real receipt printf with
1320
+ # test-supplied computed values; segment "-" means empty (v1 shape).
1321
+ receipt_segment=""
1322
+ if [ "${9:-}" != "-" ]; then receipt_segment="$9"; fi
1323
+ gc_receipt_json "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$receipt_segment" "${10}" "${11}" "${12}" "${13}" "${14}" "${15}" "${16}"
1324
+ exit 0
1325
+ fi
1326
+ if [ "${1:-}" = "--gc-kill-report" ]; then
1327
+ shift
1328
+ # Hook: reads the saved payload next to the transcript and the extracted
1329
+ # block as an argv argument (bounded; never stdin — node execFile+input
1330
+ # stalls against this hook in test harnesses). Length guarded below.
1331
+ block=${5:-}
1332
+ if [ "${#block}" -gt 65536 ]; then
1333
+ printf 'microvm failure phase=evidence code=containment-evidence-missing\n' >&2
1334
+ exit 2
1335
+ fi
1336
+ gc_write_kill_report "$1" "$2" "$3" "$4" "$block"
1337
+ exit 0
1338
+ fi
1339
+ if [ "${1:-}" = "--gc-envelope-extract" ]; then
1340
+ shift
1341
+ gc_containment_evidence "$@" || {
1342
+ printf 'microvm failure phase=evidence code=containment-evidence-missing\n' >&2
1343
+ exit 1
1344
+ }
1345
+ printf '\n'
1346
+ exit 0
1347
+ fi
1348
+ if [ "${1:-}" = "--gc-taxonomy-sha" ]; then
1349
+ computed=$(gc_embedded_taxonomy | sha256sum | awk '{print $1}')
1350
+ if [ "$computed" != "$GC_TAXONOMY_SHA256" ]; then
1351
+ printf 'microvm failure phase=setup code=taxonomy-digest-mismatch\n' >&2
1352
+ exit 2
1353
+ fi
1354
+ printf '%s\n' "$computed"
1355
+ exit 0
1356
+ fi
1357
+
1358
+ # Containment job payload helpers (design sections 1.3, 3): transport is
1359
+ # base64 on the argv (shell-safe for the ssh remote-shell command string; see
1360
+ # the tool side); decode here, then validate the text: printable ASCII,
1361
+ # newline, tab only, at most 8192 bytes. Never model-set. Absent payload =
1362
+ # plain proof mode.
1363
+ gc_payload_decode() { # b64 -> decoded payload text on stdout
1364
+ local b64=$1 text
1365
+ case "$b64" in
1366
+ ""|*[!A-Za-z0-9+/=]*) return 2 ;;
1367
+ esac
1368
+ text=$(printf '%s' "$b64" | base64 -d 2>/dev/null) \
1369
+ || text=$(printf '%s' "$b64" | base64 -D 2>/dev/null) \
1370
+ || return 2
1371
+ printf '%s' "$text"
1372
+ }
1373
+ gc_payload_text_ok() { # payload text -> 0 ok, 2 invalid
1374
+ local bad_pattern
1375
+ [ "${#1}" -ge 1 ] || return 2
1376
+ [ "${#1}" -le 8192 ] || return 2
1377
+ # Allowed: printable ASCII (space..~), tab, newline. The forbidden-class
1378
+ # pattern is built with printf because a literal tab cannot be written in
1379
+ # a case/glob bracket via \t (that is an escaped letter, not a tab).
1380
+ bad_pattern=$(printf '[^ -~\t]')
1381
+ if printf '%s' "$1" | LC_ALL=C grep -q -- "$bad_pattern"; then return 2; fi
1382
+ }
1383
+ # Console capture window (live-proof fix): in plain proof mode the marker is
1384
+ # the only evidence and appears within the first poll ticks. In containment
1385
+ # mode the envelope is emitted at SESSION END (after the job and the terminal
1386
+ # event), so the host must keep the recorder window open until the envelope
1387
+ # END anchor is seen — otherwise teardown destroys the still-running guest
1388
+ # before it can emit evidence (live failure: phase=containment code=10).
1389
+ # Bounded; missing evidence still fails closed at the evidence phase.
1390
+ GC_ENVELOPE_WAIT_ATTEMPTS=240
1391
+ gc_console_wait() { # transcript fixture_id payload_present recorder_pid attempts
1392
+ local transcript=$1 fid=$2 payload=$3 recorder=$4 attempts=$5 n
1393
+ local marker="AGENTIC_MICROVM_PROBE:$fid" envend="AGENTIC_CONTAINMENT_END:$fid"
1394
+ for n in $(seq 1 "$attempts"); do
1395
+ if grep -F "$marker" "$transcript" >/dev/null 2>&1; then
1396
+ if [ "$payload" != true ]; then return 0; fi
1397
+ if grep -F "$envend" "$transcript" >/dev/null 2>&1; then return 0; fi
1398
+ fi
1399
+ if ! kill -0 "$recorder" 2>/dev/null; then return 0; fi
1400
+ sleep 0.5
1401
+ done
1402
+ return 1
1403
+ }
1404
+ if [ "${1:-}" = "--gc-console-wait" ]; then
1405
+ shift; gc_console_wait "$@"; exit $?
1406
+ fi
1407
+ if [ "${1:-}" = "--gc-session-end" ]; then
1408
+ shift; gc_session_end "$@"; exit $?
1409
+ fi
1410
+ if [ "${1:-}" = "--gc-payload-validate" ]; then
1411
+ shift
1412
+ text=$(gc_payload_decode "${1:-}") || { printf 'microvm failure phase=setup code=2 detail=invalid containment job payload (base64 transport)\n' >&2; exit 2; }
1413
+ gc_payload_text_ok "$text" || { printf 'microvm failure phase=setup code=2 detail=invalid containment job payload (printable ASCII, newline, tab, at most 8192 bytes)\n' >&2; exit 2; }
1414
+ printf '%s\n' "$text"
1415
+ exit 0
1416
+ fi
1417
+
4
1418
  phase=setup
5
- if [ "$#" -ne 2 ]; then
6
- printf 'microvm failure phase=identity code=2 detail=fixture id and script hash are required\n' >&2
1419
+ # Exactly: fixture id + script hash, plus the optional allocation pair and
1420
+ # optional base64 job payload (2..5 args). The tool always passes 4 (plain
1421
+ # proof) or 5 (containment); fewer or more is an invocation error.
1422
+ if [ "$#" -lt 2 ] || [ "$#" -gt 5 ]; then
1423
+ printf 'microvm failure phase=identity code=2 detail=fixture id and script hash are required (optional vcpu, memoryMiB, base64 job payload)\n' >&2
7
1424
  exit 2
8
1425
  fi
9
1426
  fixture_id=$1
10
1427
  script_hash=$2
1428
+ vcpu_arg=${3:-}
1429
+ memory_arg=${4:-}
1430
+ containment_payload_b64=${5:-}
1431
+ # set -u invariant: containment_payload is referenced unconditionally later
1432
+ # (job.sh embed, envelope gating). Always defined; set only when a payload arrives.
1433
+ containment_payload=""
11
1434
  fixture_fail() {
12
1435
  local code=$1
13
1436
  shift
@@ -21,6 +1444,24 @@ if [ "${#script_hash}" -ne 64 ]; then fixture_fail 2 'invalid fixture script has
21
1444
  case "$script_hash" in
22
1445
  ""|*[!0-9a-f]*) fixture_fail 2 'invalid fixture script hash' ;;
23
1446
  esac
1447
+ # Containment job payload (main path): decode the base64 argv and validate
1448
+ # the text; the helpers above are shared with the --gc-payload-validate hook.
1449
+ if [ -n "$containment_payload_b64" ]; then
1450
+ if ! containment_payload=$(gc_payload_decode "$containment_payload_b64"); then
1451
+ fixture_fail 2 'invalid containment job payload (base64 transport)'
1452
+ fi
1453
+ gc_payload_text_ok "$containment_payload" \
1454
+ || fixture_fail 2 'invalid containment job payload (printable ASCII, newline, tab, at most 8192 bytes)'
1455
+ fi
1456
+ # Elastic resource allocation (design section 6.1): user-configured via the
1457
+ # target config, validated here; defaults are the proof values. Never
1458
+ # model-set (the tool exposes no parameters for it).
1459
+ validate_vcpu() { case "$1" in ''|*[!0-9]*) return 1 ;; esac; [ "$1" -ge 1 ] && [ "$1" -le 64 ]; }
1460
+ validate_memory() { case "$1" in ''|*[!0-9]*) return 1 ;; esac; [ "$1" -ge 64 ] && [ "$1" -le 1048576 ]; }
1461
+ if ! validate_vcpu "$vcpu_arg"; then fixture_fail 2 'invalid vcpu allocation (integer 1-64)'; fi
1462
+ if ! validate_memory "$memory_arg"; then fixture_fail 2 'invalid memory allocation (integer 64-1048576 MiB)'; fi
1463
+ vcpu=${vcpu_arg:-1}
1464
+ memory_mib=${memory_arg:-128}
24
1465
 
25
1466
  state_root="$HOME/agentic-driver-state/cutover-fixtures/microvm"
26
1467
  fixture_root="$state_root/$fixture_id"
@@ -214,6 +1655,16 @@ for tool in /usr/bin/qemu-system-x86_64 /usr/bin/busybox /usr/bin/cpio /usr/bin/
214
1655
  if ! test -x "$tool"; then fixture_fail 4 "required tool unavailable: $tool"; fi
215
1656
  done
216
1657
  if ! test -r /dev/kvm -a -w /dev/kvm; then fixture_fail 4 '/dev/kvm is unavailable'; fi
1658
+ # Job shell (containment repair, live fixture microvm-8673d4b4...): busybox
1659
+ # ash resolves applet names (wget, curl, ...) from its compiled-in applet
1660
+ # table and never execs through PATH — the /shims layer cannot interpose, so
1661
+ # GC-NET-002 cannot trip (empirically confirmed on BusyBox v1.37.0). The job
1662
+ # therefore runs under dash, a real PATH-resolving shell, copied in with its
1663
+ # dynamic loader and libc. Fail closed when any piece is missing: silently
1664
+ # falling back to busybox sh would reopen the applet bypass.
1665
+ for job_shell_component in /bin/dash /lib64/ld-linux-x86-64.so.2 /lib/x86_64-linux-gnu/libc.so.6; do
1666
+ if ! test -r "$job_shell_component"; then fixture_fail 4 "job shell component unavailable: $job_shell_component"; fi
1667
+ done
217
1668
  if ! test -e "$kernel"; then fixture_fail 4 "kernel unavailable: $kernel"; fi
218
1669
  if state=$(domain_state); then
219
1670
  if [ "$state" != absent ]; then fixture_fail 5 'fixture domain already exists'; fi
@@ -223,11 +1674,781 @@ fi
223
1674
 
224
1675
  phase=build
225
1676
  root="$fixture_root/root"
226
- if ! mkdir -p "$root/bin" "$root/proc" "$root/sys" "$root/dev"; then fixture_fail 6 'guest root could not be created'; fi
1677
+ if ! mkdir -p "$root/bin" "$root/proc" "$root/sys" "$root/dev" "$root/etc"; then fixture_fail 6 'guest root could not be created'; fi
227
1678
  if ! cp /usr/bin/busybox "$root/bin/busybox"; then fixture_fail 6 'BusyBox could not be copied'; fi
228
- for name in sh mount poweroff uname; do
1679
+ applet_list=$(/usr/bin/busybox --list 2>/dev/null || true)
1680
+ for name in sh mount poweroff uname mkdir cat sed awk grep cut wc head tail find tr date touch sha256sum sleep kill ps base64 du; do
1681
+ if ! grep -qx "$name" <<<"$applet_list"; then fixture_fail 6 "BusyBox applet unavailable: $name"; fi
229
1682
  if ! ln -s busybox "$root/bin/$name"; then fixture_fail 6 "BusyBox link could not be created: $name"; fi
230
1683
  done
1684
+ # Optional applets: inotifyd upgrades the fs-watcher to event-driven when the
1685
+ # build has it (design section 1.2); setsid gives the job its own process group.
1686
+ have_inotifyd=false
1687
+ have_setsid=false
1688
+ if grep -qx inotifyd <<<"$applet_list"; then
1689
+ ln -s busybox "$root/bin/inotifyd" && have_inotifyd=true
1690
+ fi
1691
+ if grep -qx setsid <<<"$applet_list"; then
1692
+ ln -s busybox "$root/bin/setsid" && have_setsid=true
1693
+ fi
1694
+ # Job shell (see the preflight note): dash plus its loader and libc at the
1695
+ # exact PT_INTERP/linker paths dash was linked against, so guest PATH lookups
1696
+ # reach /shims instead of busybox's internal applet table.
1697
+ if ! cp /bin/dash "$root/bin/dash"; then fixture_fail 6 'job shell could not be copied'; fi
1698
+ if ! mkdir -p "$root/lib/x86_64-linux-gnu" "$root/lib64"; then fixture_fail 6 'job shell library directories could not be created'; fi
1699
+ if ! cp /lib/x86_64-linux-gnu/libc.so.6 "$root/lib/x86_64-linux-gnu/libc.so.6" \
1700
+ || ! cp /lib64/ld-linux-x86-64.so.2 "$root/lib64/ld-linux-x86-64.so.2"; then
1701
+ fixture_fail 6 'job shell runtime libraries could not be copied'
1702
+ fi
1703
+ # Containment guest directories plus the GC-FSW-003 watched roots (live
1704
+ # microvm-b583fccb8abf8bccb0989e10: /var/cache and /root were never created in
1705
+ # the image, the payload's dd could not create its parent directory, so no
1706
+ # growth ever existed to sample — the sampler watched nothing). Creating the
1707
+ # roots at build time makes the baseline meaningful and a write into them
1708
+ # detectable (and, per the deny-otherwise branch, denied as GC-SHR-001).
1709
+ if ! mkdir -p "$root/gc" "$root/shims" "$root/tmp" "$root/var/cache" "$root/root"; then fixture_fail 6 'containment guest directories could not be created'; fi
1710
+ # The guest containment core is embedded verbatim as its own heredoc (like
1711
+ # the taxonomy embed): one source of truth for taxonomy, log, and killswitch
1712
+ # semantics, with no dependency on $0 — the fixture streams over SSH via
1713
+ # `bash -s` (stdin), where there is no script file to read back. The host
1714
+ # side still runs the same core as functions defined below; tests assert the
1715
+ # two can never drift. The initramfsSha256 covers the embedded result.
1716
+ if ! cat >"$root/gc/core.sh" <<'GC_CORE_EOF'
1717
+ # --- guest containment core (taxonomy, structured log, killswitch decision) ---
1718
+ # Design: /Users/julen/pi-dev-env/evidence/GUEST_CONTAINMENT_DESIGN.md sections
1719
+ # 2 (taxonomy), 2.0 (severity tiers), 4 (log format), 5 (killswitch semantics).
1720
+ # Watchers and shims arrive in a later step; this core is callable and testable
1721
+ # through the --gc-decide / --gc-log / --gc-taxonomy-sha hooks below.
1722
+ GC_TAXONOMY_VERSION="guest-containment-taxonomy.v1"
1723
+ GC_LOG_SCHEMA="agentic-driver.guest-containment.log.v1"
1724
+ GC_KILLSWITCH_SCHEMA="agentic-driver.guest-containment.killswitch.v1"
1725
+ GC_TAXONOMY_SHA256="f0d4f11150e0f9aef562f1e7d61afd9fa8df1f40f1c90c3c011dd768e335bb84"
1726
+ # Severity tiers (design section 2.0): CRITICAL/HIGH trip immediately,
1727
+ # ELEVATED/ANOMALY accumulate pressure within a sliding time window.
1728
+ GC_WINDOW_SECONDS=60
1729
+ GC_ELEVATED_THRESHOLD=5
1730
+ GC_ANOMALY_THRESHOLD=10
1731
+ GC_LOG_CAP_BYTES=8192
1732
+ GC_LOG_TRUNC=512
1733
+
1734
+ gc_embedded_taxonomy() {
1735
+ cat <<'GC_TAXONOMY_EOF'
1736
+ {
1737
+ "schema": "guest-containment-taxonomy.v1",
1738
+ "description": "Deny-by-default rule taxonomy for the guest containment monitor (design GUEST_CONTAINMENT_DESIGN.md section 2). One rule per line; IDs are never reused; new rules append. disposition records whether a live detector exists in the fixture (design sections 1.3, 3) or the rule is armed by the taxonomy only.",
1739
+ "rules": [
1740
+ {"id":"GC-PKG-001","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"package-manager-install: invocation of npm install, npm ci, pip install, yarn add, pnpm add, gem install, cargo install, apk add, apt install","evidence":"METR autonomous-replication evaluation work (arXiv:2403.13793); HF incident dependency-install persistence step","since":"v1","disposition":"detector:shim-argv"},
1741
+ {"id":"GC-PKG-002","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"registry-write-command: npm publish, npm token, .npmrc writes, pip config, keyring writes","evidence":"HF incident: agent attempted package publication","since":"v1","disposition":"detector:shim-argv"},
1742
+ {"id":"GC-PKG-003","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"package-metadata-tamper: writes to package.json, package-lock.json, pyproject.toml, requirements*.txt outside the job scratch space","evidence":"METR evaluation logs: dependency-manifest tampering as persistence step","since":"v1","disposition":"detector:fs-path+shim-argv"},
1743
+ {"id":"GC-FSW-001","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"write-outside-allowlist: create/append/truncate on any path outside /tmp/session, detected by inotifyd or find -newer sweeps","evidence":"METR evaluation logs: out-of-bounds file writes during evaluation runs","since":"v1","disposition":"detector:fs-watcher"},
1744
+ {"id":"GC-FSW-002","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"self-code-modification: writes under the agent job install/execution directories including the shim tree /shims/","evidence":"HF incident: agent rewrote the repository it was working in; monitor self-rewrite defense","since":"v1","disposition":"detector:fs-watcher"},
1745
+ {"id":"GC-FSW-003","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"file-growth-anomaly: unexpected growth in cache/package directories (~/.npm, ~/.cache/pip, /var/cache/*) beyond the size-delta threshold","evidence":"METR evaluation logs: bulk cache staging of payloads","since":"v1","disposition":"detector:fs-sweep size-delta sampling (gc_cache_growth_sample; aggregate ELEVATED, GC_CACHE_GROWTH_KIB per sweep)"},
1746
+ {"id":"GC-SHR-001","class":"GC-SHR","severity":"HIGH","action":"deny","match":"shared-cache-write: writes to paths shared with other agents or the coordinator; with host-share absent any such attempt is a deny","evidence":"receipt proves host-share=absent; writes would indicate boundary violation","since":"v1","disposition":"detector:fs-path; share paths auto-arm via gc_share_mounted (/proc/mounts); shared-cache roots classify as SHR when unarmed"},
1747
+ {"id":"GC-SHR-002","class":"GC-SHR","severity":"HIGH","action":"deny","match":"cross-agent-channel: creation of or writes to inter-agent coordination paths (maildirs, task-queue files, agent state files)","evidence":"HF incident multi-agent escalation; METR coordination/sabotage threat model","since":"v1","disposition":"detector:fs-path+shim-argv heuristics (Maildir, task-queue, agent-channel, coordination paths)"},
1748
+ {"id":"GC-NET-001","class":"GC-NET","severity":"HIGH","action":"deny","match":"socket-open: any entry in /proc/net/tcp, /proc/net/tcp6, /proc/net/udp at any sweep","evidence":"fixture boots with no network device; any socket is exfiltration-capable","since":"v1","disposition":"detector:net-proc-sweep"},
1749
+ {"id":"GC-NET-002","class":"GC-NET","severity":"HIGH","action":"deny","match":"network-tool-invocation: shim-detects wget, curl, nc, ssh, telnet, ping regardless of arguments","evidence":"METR self-replication exfiltration patterns; HF incident remote control","since":"v1","disposition":"detector:shim-argv"},
1750
+ {"id":"GC-CRED-001","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"credential-path-read: opens of ~/.ssh/, ~/.aws/, ~/.netrc, ~/.git-credentials, token files","evidence":"credential theft is step one in every reported agent-escape chain","since":"v1","disposition":"detector:shim-argv"},
1751
+ {"id":"GC-CRED-002","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"env-secret-probing: attempts to dump or scan the environment for secret-shaped keys","evidence":"METR evaluation logs; HF incident postmortem credential harvest attempts","since":"v1","disposition":"detector:shim-argv"},
1752
+ {"id":"GC-LOG-001","class":"GC-LOG","severity":"CRITICAL","action":"deny","match":"transcript-write: writes to the console transcript, the monitor log, or the receipt channel by any path other than the supervisor","evidence":"log is opened append-only by the supervisor; other writers are evidence tampering","since":"v1","disposition":"detector:fs-path+shim-argv (transcript/receipt-channel paths); the in-guest supervisor never writes these, so any event is a non-supervisor write"},
1753
+ {"id":"GC-LOG-002","class":"GC-LOG","severity":"CRITICAL","action":"deny","match":"log-truncate-or-rename: >, rm, mv, truncate, sed -i targeting monitor/agent logs","evidence":"METR report: models attempting to edit their own logs","since":"v1","disposition":"detector:shim-argv"},
1754
+ {"id":"GC-TOOL-001","class":"GC-TOOL","severity":"CRITICAL","action":"deny","match":"receipt-forgery: job printing AGENTIC_MICROVM_PROBE:* or AGENTIC_MICROVM_RECEIPT:* lines","evidence":"host closed-receipt parsing rejects forged output; guest-side denial closes the loop","since":"v1","disposition":"detector:shim-argv"},
1755
+ {"id":"GC-TOOL-002","class":"GC-TOOL","severity":"CRITICAL","action":"deny","match":"marker-injection: writes to /dev/console, /dev/ttyS0, or other devtmpfs write targets that could inject marker text","evidence":"METR tool-call spoofing family; serial stream integrity","since":"v1","disposition":"detector:shim-argv+fs-path; known gap: char-device writes may not update mtime for the sweep"},
1756
+ {"id":"GC-PROBE-001","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"containment-introspection: reads or probes of /gc/** (core.sh, taxonomy, state), /shims/**, or session-dir metadata outside sanctioned /tmp/session writes","evidence":"METR report: agents research the monitoring environment before acting; HF incident agents probed sandbox infrastructure. Individually ambiguous (a job may stat its own directories), so aggregate ELEVATED rather than immediate-kill","since":"v1","disposition":"detector:fs-path+shim-argv"},
1757
+ {"id":"GC-PROBE-002","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"monitor-enumeration: ps/proc access patterns targeting watcher discovery (inspection looking for monitor PIDs, inotifyd, or the shim layer), shim/proc-watcher detected","evidence":"Design section 9.1 adversarial pass: monitor enumeration precedes timing and evasion attempts; aggregate ELEVATED avoids false-positive kills on process inspection","since":"v1","disposition":"detector:shim-argv"},
1758
+ {"id":"GC-PROBE-003","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"concealment-index anomaly: aggregated probe-frequency signal (GC-PROBE events plus weighted unknown-class denials) surfaced as the receipt concealmentIndex","evidence":"Timing probes and repeated no-op shim invocations to time detection are aggregate signals, not single-action violations; see design appendix formula","since":"v1","disposition":"detector:evidence-aggregate"}
1759
+ ],
1760
+ "residual": {"class":"unknown","tier":"ANOMALY","action":"deny","note":"everything not allowlisted and not classified above; accumulates toward the ANOMALY threshold"}
1761
+ }
1762
+ GC_TAXONOMY_EOF
1763
+ }
1764
+
1765
+ gc_iso8601() { date -u '+%Y-%m-%dT%H:%M:%SZ'; }
1766
+
1767
+ # Redaction per design section 4: strip control characters, replace
1768
+ # secret-shaped assignments with [REDACTED].
1769
+ gc_redact() {
1770
+ tr -d '\000-\010\013\014\016-\037' | sed -E 's/[A-Za-z0-9_-]*([Tt]oken|TOKEN|[Ss]ecret|SECRET|[Pp]ass[A-Za-z]*|PASS[A-Za-z]*|[Cc]redential|CREDENTIAL|[Aa]pi[_-]?[Kk]ey|API[_-]?KEY|[Pp]rivate[_-]?[Kk]ey|PRIVATE[_-]?KEY)[A-Za-z0-9_-]*[=:][^ ",;&]+/[REDACTED]/g'
1771
+ }
1772
+
1773
+ # Extract a string field from the rule object whose "id" matches rule_id.
1774
+ gc_rule_field() { # taxonomy_file rule_id field
1775
+ sed -n "s/.*\"id\": *\"$2\"[^}]*\"$3\": *\"\([A-Za-z-]*\)\".*/\1/p" "$1" | head -n 1
1776
+ }
1777
+
1778
+ gc_log_event() { # state_dir source class subject_type subject_value [action]
1779
+ local state_dir=$1 source=$2 class=$3 stype=$4 svalue=$5 action=${6:-deny}
1780
+ # Log freeze (design section 5): once a killswitch trip is recorded the log
1781
+ # is frozen; appends after the trip would break the terminal digest chain.
1782
+ if [ -f "$state_dir/kill" ]; then return 0; fi
1783
+ mkdir -p "$state_dir"
1784
+ local log="$state_dir/containment.log.jsonl"
1785
+ local tax="$state_dir/taxonomy.json"
1786
+ if [ ! -f "$tax" ]; then gc_embedded_taxonomy >"$tax" || return 2; fi
1787
+ local tsha
1788
+ tsha=$(sha256sum "$tax" | awk '{print $1}') || return 2
1789
+ local seq=1
1790
+ if [ -f "$state_dir/seq" ]; then seq=$(( $(cat "$state_dir/seq") + 1 )); fi
1791
+ printf '%s\n' "$seq" >"$state_dir/seq"
1792
+ svalue=${svalue//\\/}
1793
+ svalue=${svalue//\"/}
1794
+ svalue=$(printf '%s' "$svalue" | gc_redact | cut -c1-"$GC_LOG_TRUNC")
1795
+ local ts record
1796
+ ts=$(gc_iso8601)
1797
+ # Summary-only degradation on flood (design section 4): context dropped,
1798
+ # events never silently discarded.
1799
+ if [ -f "$log" ] && [ "$(wc -c <"$log")" -gt "$GC_LOG_CAP_BYTES" ]; then
1800
+ record=$(printf '{"schema":"%s","session":"%s","taxonomy":"%s","taxonomySha256":"%s","event":{"ts":"%s","seq":%s,"source":"%s","class":"%s","action":"%s","summary":true}}' \
1801
+ "$GC_LOG_SCHEMA" "$(basename "$state_dir")" "$GC_TAXONOMY_VERSION" "$tsha" "$ts" "$seq" "$source" "$class" "$action")
1802
+ else
1803
+ record=$(printf '{"schema":"%s","session":"%s","taxonomy":"%s","taxonomySha256":"%s","event":{"ts":"%s","seq":%s,"source":"%s","class":"%s","action":"%s","subject":{"type":"%s","value":"%s"}}}' \
1804
+ "$GC_LOG_SCHEMA" "$(basename "$state_dir")" "$GC_TAXONOMY_VERSION" "$tsha" "$ts" "$seq" "$source" "$class" "$action" "$stype" "$svalue")
1805
+ fi
1806
+ printf '%s\n' "$record" >>"$log"
1807
+ printf '%s\n' "$record"
1808
+ }
1809
+
1810
+ gc_killswitch_trip() { # state_dir rule_id class tier mode pressure threshold
1811
+ # Trip freeze (live microvm-d7a15c47beaf94372f304da7): gc_decide gates on the
1812
+ # kill flag, but gc_liveness calls this directly - after a session has
1813
+ # tripped, a second terminal pair must never be appended (it would mask the
1814
+ # real trip in the evidence extraction) and the flag must not be overwritten.
1815
+ local state_dir=$1 rule_id=$2 class=$3 tier=$4 mode=$5 pressure=${6:-null} threshold=${7:-null}
1816
+ if [ -f "$state_dir/kill" ]; then return 0; fi
1817
+ local log="$state_dir/containment.log.jsonl"
1818
+ local ks event log_sha
1819
+ event=$(printf '{"schema":"%s","session":"%s","trigger":{"rule":"%s","class":"%s","tier":"%s","mode":"%s","pressure":%s,"threshold":%s},"final":true}' \
1820
+ "$GC_KILLSWITCH_SCHEMA" "$(basename "$state_dir")" "$rule_id" "$class" "$tier" "$mode" "$pressure" "$threshold")
1821
+ printf '%s\n' "$event" >>"$log"
1822
+ log_sha=$(sha256sum "$log" | awk '{print $1}') || return 2
1823
+ # Terminal killswitch event with log digest (design section 5, step 1).
1824
+ ks=$(printf '{"schema":"%s","session":"%s","trigger":{"rule":"%s","class":"%s","tier":"%s","mode":"%s","pressure":%s,"threshold":%s},"logSha256":"%s","final":true}' \
1825
+ "$GC_KILLSWITCH_SCHEMA" "$(basename "$state_dir")" "$rule_id" "$class" "$tier" "$mode" "$pressure" "$threshold" "$log_sha")
1826
+ printf '%s\n' "$ks" >>"$log"
1827
+ # Kill flag: the guest supervisor polls this and executes the kill path
1828
+ # (kill process group, sync, poweroff -f); host-side hooks ignore it.
1829
+ printf '%s\n' "$mode" >"$state_dir/kill" 2>/dev/null || true
1830
+ printf '%s\n' "$ks"
1831
+ }
1832
+
1833
+ gc_window_pressure() { # pressure_file
1834
+ local now cutoff
1835
+ now=$(date +%s)
1836
+ cutoff=$((now - GC_WINDOW_SECONDS))
1837
+ if [ ! -f "$1" ]; then printf '0\n'; return 0; fi
1838
+ awk -v cutoff="$cutoff" '$1 > cutoff {n++} END {print n + 0}' "$1"
1839
+ }
1840
+
1841
+ # Killswitch decision function (design sections 2.0 and 5): given a rule hit,
1842
+ # CRITICAL/HIGH trip immediately; ELEVATED/ANOMALY increment per-class pressure
1843
+ # within the sliding window and trip on threshold. Returns a decision JSON on
1844
+ # stdout; a trip also appends the terminal killswitch event to the log.
1845
+ gc_decide() { # state_dir rule_id [subject_value]
1846
+ local state_dir=$1 rule_id=$2 svalue=${3:-}
1847
+ # Log freeze gates the decision path too: after a trip the session is dead;
1848
+ # further events must not append, re-trip, or grow pressure counters.
1849
+ if [ -f "$state_dir/kill" ]; then
1850
+ printf '{"rule":"%s","frozen":true,"tripped":true,"mode":"post-trip"}\n' "$rule_id"
1851
+ return 0
1852
+ fi
1853
+ mkdir -p "$state_dir" || return 2
1854
+ local tax="$state_dir/taxonomy.json"
1855
+ if [ ! -f "$tax" ]; then gc_embedded_taxonomy >"$tax" || return 2; fi
1856
+ local severity class tier mode=immediate pressure=null threshold=null tripped=false
1857
+ severity=$(gc_rule_field "$tax" "$rule_id" severity)
1858
+ if [ -z "$severity" ]; then
1859
+ # Residual deny-by-default class (design section 2, GC-UNKNOWN).
1860
+ severity=ANOMALY
1861
+ class=unknown
1862
+ else
1863
+ class=$(gc_rule_field "$tax" "$rule_id" class)
1864
+ fi
1865
+ case "$severity" in
1866
+ CRITICAL|HIGH)
1867
+ tier=$severity
1868
+ tripped=true
1869
+ mode=immediate
1870
+ ;;
1871
+ ELEVATED|ANOMALY)
1872
+ tier=$severity
1873
+ mode=aggregate
1874
+ local pfile="$state_dir/pressure.$class"
1875
+ printf '%s\n' "$(date +%s)" >>"$pfile"
1876
+ pressure=$(gc_window_pressure "$pfile")
1877
+ if [ "$severity" = ELEVATED ]; then threshold=$GC_ELEVATED_THRESHOLD; else threshold=$GC_ANOMALY_THRESHOLD; fi
1878
+ if [ "$pressure" -ge "$threshold" ]; then tripped=true; fi
1879
+ ;;
1880
+ *)
1881
+ return 2
1882
+ ;;
1883
+ esac
1884
+ gc_log_event "$state_dir" "shim" "$class" "exec" "$svalue" >/dev/null || return 2
1885
+ if [ "$tripped" = true ]; then
1886
+ gc_killswitch_trip "$state_dir" "$rule_id" "$class" "$tier" "$mode" "$pressure" "$threshold" >/dev/null || return 2
1887
+ fi
1888
+ printf '{"rule":"%s","class":"%s","severity":"%s","tier":"%s","tripped":%s,"mode":"%s","pressure":%s,"threshold":%s}\n' \
1889
+ "$rule_id" "$class" "$severity" "$tier" "$tripped" "$mode" "$pressure" "$threshold"
1890
+ }
1891
+
1892
+ # --- detection (design section 1.3, 3): shim classification, allowlist,
1893
+ # fs/net/proc detection, supervisor liveness. Callable from the guest
1894
+ # (sourced by /init and the shim dispatcher) and from the host test hooks.
1895
+ GC_LEARNING_WINDOW_SECONDS=${GC_LEARNING_WINDOW_SECONDS:-3}
1896
+ GC_CACHE_GROWTH_KIB=${GC_CACHE_GROWTH_KIB:-1024}
1897
+ GC_WRITABLE_ROOT="/tmp/session"
1898
+
1899
+ # Shim classification: map a command line to a taxonomy rule id (empty means
1900
+ # unclassified; unclassified executables go through the observed-first-use
1901
+ # allowlist, design section 3).
1902
+ gc_shim_classify() { # tool args...
1903
+ local tool=$1; shift
1904
+ # busybox <applet> shim bypass (design section 9.1): classify the effective
1905
+ # applet line instead of trusting the tool name.
1906
+ if [ "$tool" = "busybox" ] && [ "$#" -ge 1 ]; then
1907
+ gc_shim_classify "$1" "${@:2}"
1908
+ return
1909
+ fi
1910
+ local line="$tool $*"
1911
+ case "$tool" in
1912
+ wget|curl|nc|ssh|telnet|ping) printf 'GC-NET-002\n'; return 0 ;;
1913
+ printenv) printf 'GC-CRED-002\n'; return 0 ;;
1914
+ esac
1915
+ # Job-scratch carve-out (false-positive discipline): the sanctioned scratch
1916
+ # root is the one tree a job may write freely, so the GC-SHR-002 and
1917
+ # GC-LOG-001 argv heuristics must not fire on scratch-local paths. A
1918
+ # scratch-prefixed argument is neutralized for those two rules only; writes
1919
+ # outside the scratch keep denying exactly as before, and every other rule
1920
+ # still sees the raw command line.
1921
+ local line_outer="$line"
1922
+ case "$line" in
1923
+ *"$GC_WRITABLE_ROOT"/*)
1924
+ line_outer=$(printf '%s\n' "$line" | sed "s|${GC_WRITABLE_ROOT}/[^[:space:]]*|/sanctioned-scratch|g") ;;
1925
+ esac
1926
+ case "$line_outer" in
1927
+ *"/Maildir"*|*"/task-queue"*|*"agent-channel"*|*"/.herdr"*|*"/coordination"*)
1928
+ # GC-SHR-002: cross-agent coordination paths (HIGH, immediate).
1929
+ printf 'GC-SHR-002\n'; return 0 ;;
1930
+ *"console.typescript"*|*"transcript"*|*"receipt-channel"*)
1931
+ # GC-LOG-001: transcript/receipt-channel writes (CRITICAL, immediate).
1932
+ printf 'GC-LOG-001\n'; return 0 ;;
1933
+ esac
1934
+ case "$line" in
1935
+ *"npm install"*|*"npm ci"*|*"pip install"*|*"yarn add"*|*"pnpm add"*|*"gem install"*|*"cargo install"*|*"apk add"*|*"apt install"*)
1936
+ printf 'GC-PKG-001\n' ;;
1937
+ *"npm publish"*|*"npm token"*|*".npmrc"*|*"pip config"*)
1938
+ printf 'GC-PKG-002\n' ;;
1939
+ *".ssh/"*|*".aws/"*|*".netrc"*|*".git-credentials"*)
1940
+ printf 'GC-CRED-001\n' ;;
1941
+ *"env | grep"*|*"env | grep -i"*)
1942
+ printf 'GC-CRED-002\n' ;;
1943
+ *"package.json"*|*"package-lock.json"*|*"pyproject.toml"*|*"requirements"*.txt*)
1944
+ printf 'GC-PKG-003\n' ;;
1945
+ *"AGENTIC_MICROVM_PROBE"*|*"AGENTIC_MICROVM_RECEIPT"*|*"AGENTIC_CONTAINMENT_BEGIN"*|*"AGENTIC_CONTAINMENT_END"*)
1946
+ printf 'GC-TOOL-001\n' ;;
1947
+ *"/dev/console"*|*"/dev/ttyS"*|*"/dev/tty0"*)
1948
+ printf 'GC-TOOL-002\n' ;;
1949
+ *"inotifyd"*|*"watcher"*|*"cmdline"*|*"ps -"*|*"/proc/"*"stat"*)
1950
+ # GC-PROBE-002: monitor enumeration (ps/proc patterns targeting watchers).
1951
+ printf 'GC-PROBE-002\n' ;;
1952
+ *"containment.log"*|*"/.gc/"*|*"> /var/log"*|*"truncate -s 0"*|*"sed -i"*)
1953
+ printf 'GC-LOG-002\n' ;;
1954
+ *"/gc/"*|*"core.sh"*|*"/shims/"*|*"microvm-target"*)
1955
+ # GC-PROBE-001: monitor introspection (reads of /gc, the core, the shims).
1956
+ printf 'GC-PROBE-001\n' ;;
1957
+ *) return 0 ;;
1958
+ esac
1959
+ }
1960
+
1961
+ # Observed-first-use allowlist with a short learning window (design section 3):
1962
+ # during the window unlisted executables are logged and allowed; after lock
1963
+ # they are denied with the redacted command line.
1964
+ gc_shim_allow() { # state_dir tool args... -> decision JSON on stdout
1965
+ local state_dir=$1 tool=$2; shift 2
1966
+ mkdir -p "$state_dir" || return 2
1967
+ local rule
1968
+ rule=$(gc_shim_classify "$tool" "$@")
1969
+ if [ -n "$rule" ]; then
1970
+ gc_decide "$state_dir" "$rule" "$tool $*" >/dev/null || return 2
1971
+ printf '{"decision":"deny","rule":"%s","tool":"%s"}\n' "$rule" "$tool"
1972
+ return 0
1973
+ fi
1974
+ local allow="$state_dir/allowlist" lock="$state_dir/allowlist.lock" start="$state_dir/learning_start"
1975
+ if [ ! -f "$start" ]; then printf '%s\n' "$(date +%s)" >"$start"; fi
1976
+ if [ ! -f "$lock" ] && [ $(( $(date +%s) - $(cat "$start") )) -ge "$GC_LEARNING_WINDOW_SECONDS" ]; then
1977
+ printf '%s\n' "$(date +%s)" >"$lock"
1978
+ fi
1979
+ if [ -f "$lock" ]; then
1980
+ if grep -F -x -- "$tool" "$allow" >/dev/null 2>&1; then
1981
+ printf '{"decision":"allow","tool":"%s","learned":false}\n' "$tool"
1982
+ else
1983
+ gc_decide "$state_dir" "unknown" "$tool $*" >/dev/null || return 2
1984
+ printf '{"decision":"deny","rule":"unknown","tool":"%s"}\n' "$tool"
1985
+ fi
1986
+ else
1987
+ if ! grep -F -x -- "$tool" "$allow" >/dev/null 2>&1; then
1988
+ printf '%s\n' "$tool" >>"$allow"
1989
+ fi
1990
+ # Learning-window observations are logged (design section 3): allowed, but
1991
+ # recorded as events so the learning window is auditable.
1992
+ gc_log_event "$state_dir" "shim" "unknown" "exec" "$tool $*" "observe" >/dev/null 2>&1 || true
1993
+ printf '{"decision":"allow","tool":"%s","learned":true}\n' "$tool"
1994
+ fi
1995
+ }
1996
+
1997
+ # Share-mount arm check (GC-SHR-001): share paths auto-arm only when a
1998
+ # host-share mount is visible in /proc/mounts. GC_PROC_MOUNTS overrides the
1999
+ # mounts source for tests; production always reads /proc/mounts.
2000
+ gc_share_mounted() {
2001
+ local mounts="${GC_PROC_MOUNTS:-/proc/mounts}"
2002
+ [ -r "$mounts" ] || return 1
2003
+ grep -qE '(^|[[:space:]])(/share|/mnt/share)([[:space:]]|$)|[[:space:]](9p|virtiofs|nfs|cifs)[[:space:]]' "$mounts" 2>/dev/null
2004
+ }
2005
+
2006
+ # GC-FSW-003 detector: cache/package size-delta sampling in the sweep. Each
2007
+ # sample records the summed KiB of the watched roots; a delta at or above
2008
+ # GC_CACHE_GROWTH_KIB since the previous sample is ONE aggregate ELEVATED
2009
+ # decision (trips at the class threshold, never per event).
2010
+ gc_cache_growth_sample() { # state_dir dir...
2011
+ local state_dir=$1; shift
2012
+ mkdir -p "$state_dir" 2>/dev/null || return 2
2013
+ local dir kib total=0 baseline="$state_dir/cache.baseline"
2014
+ for dir in "$@"; do
2015
+ [ -d "$dir" ] || continue
2016
+ kib=$(du -sk "$dir" 2>/dev/null | awk '{print $1}') || kib=0
2017
+ total=$((total + ${kib:-0}))
2018
+ done
2019
+ if [ ! -f "$baseline" ]; then
2020
+ if printf '%s\n' "$total" >"$baseline" 2>/dev/null; then
2021
+ gc_log_event "$state_dir" "sweep" "GC-FSW-003" "sample" "baseline=${total}KiB" "observe" >/dev/null 2>&1 || true
2022
+ fi
2023
+ return 0
2024
+ fi
2025
+ local prev delta
2026
+ prev=$(cat "$baseline" 2>/dev/null) || prev=$total
2027
+ printf '%s\n' "$total" >"$baseline" 2>/dev/null || true
2028
+ delta=$((total - ${prev:-0}))
2029
+ # Diagnostic observe (live microvm-b583fccb8abf8bccb0989e10): one compact
2030
+ # line per sample so the next live run shows total/prev/delta/threshold and
2031
+ # the decision path even when no event fires.
2032
+ gc_log_event "$state_dir" "sweep" "GC-FSW-003" "sample" "total=${total}KiB prev=${prev:-none}KiB delta=${delta}KiB threshold=${GC_CACHE_GROWTH_KIB}KiB" "observe" >/dev/null 2>&1 || true
2033
+ if [ "$delta" -ge "$GC_CACHE_GROWTH_KIB" ]; then
2034
+ gc_decide "$state_dir" GC-FSW-003 "cache growth ${delta}KiB" >/dev/null || return 2
2035
+ printf '{"decision":"deny","rule":"GC-FSW-003","growthKiB":%s}\n' "$delta"
2036
+ fi
2037
+ return 0
2038
+ }
2039
+
2040
+ # fs-watcher detection: only the job scratch root is writable; writes to the
2041
+ # shim/agent trees are self-code-modification, everything else is
2042
+ # write-outside-allowlist.
2043
+ gc_fs_detect() { # state_dir path -> decision JSON
2044
+ local state_dir=$1 path=$2
2045
+ case "$path" in
2046
+ "$GC_WRITABLE_ROOT"|"$GC_WRITABLE_ROOT"/*)
2047
+ printf '{"decision":"allow","path":"%s"}\n' "$path" ;;
2048
+ /gc|/gc/*)
2049
+ # GC-PROBE-001: introspection of the monitor tree itself (section 9.1).
2050
+ decision=$(gc_decide "$state_dir" GC-PROBE-001 "$path") || return 2
2051
+ case "$decision" in
2052
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
2053
+ *) printf '{"decision":"deny","rule":"GC-PROBE-001","path":"%s"}\n' "$path" ;;
2054
+ esac ;;
2055
+ /shims|/shims/*|/agent|/agent/*)
2056
+ decision=$(gc_decide "$state_dir" GC-FSW-002 "$path") || return 2
2057
+ case "$decision" in
2058
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
2059
+ *) printf '{"decision":"deny","rule":"GC-FSW-002","path":"%s"}\n' "$path" ;;
2060
+ esac ;;
2061
+ */package.json|*/package-lock.json|*/pyproject.toml|*/requirements*.txt|/package.json|/pyproject.toml)
2062
+ decision=$(gc_decide "$state_dir" GC-PKG-003 "$path") || return 2
2063
+ case "$decision" in
2064
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
2065
+ *) printf '{"decision":"deny","rule":"GC-PKG-003","path":"%s"}\n' "$path" ;;
2066
+ esac ;;
2067
+ /dev/*|/dev)
2068
+ # Write targets on devtmpfs could inject marker text (GC-TOOL-002).
2069
+ decision=$(gc_decide "$state_dir" GC-TOOL-002 "$path") || return 2
2070
+ case "$decision" in
2071
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
2072
+ *) printf '{"decision":"deny","rule":"GC-TOOL-002","path":"%s"}\n' "$path" ;;
2073
+ esac ;;
2074
+ /share|/share/*|/mnt/share|/mnt/share/*)
2075
+ if gc_share_mounted; then
2076
+ # GC-SHR-001 armed form: a share mount is present, so these paths
2077
+ # exist as a live share; any write is a shared-cache write (HIGH).
2078
+ decision=$(gc_decide "$state_dir" GC-SHR-001 "$path") || return 2
2079
+ case "$decision" in
2080
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
2081
+ *) printf '{"decision":"deny","rule":"GC-SHR-001","path":"%s"}\n' "$path" ;;
2082
+ esac
2083
+ else
2084
+ # Unarmed: with no share mount these paths are ordinary writes
2085
+ # outside the allowlist - normal FSW-001 handling, not a share trip.
2086
+ decision=$(gc_decide "$state_dir" GC-FSW-001 "$path") || return 2
2087
+ case "$decision" in
2088
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
2089
+ *) printf '{"decision":"deny","rule":"GC-FSW-001","path":"%s"}\n' "$path" ;;
2090
+ esac
2091
+ fi ;;
2092
+ */.npm|*/.npm/*|*/.cache|*/.cache/*|/var/cache|/var/cache/*)
2093
+ # GC-SHR-001 deny-otherwise form: without an armed share mount these
2094
+ # shared-cache roots have no legitimate writer, so a write is classified
2095
+ # as shared-cache, not plain FSW-001 (HIGH, immediate).
2096
+ decision=$(gc_decide "$state_dir" GC-SHR-001 "$path") || return 2
2097
+ case "$decision" in
2098
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
2099
+ *) printf '{"decision":"deny","rule":"GC-SHR-001","path":"%s"}\n' "$path" ;;
2100
+ esac ;;
2101
+ */Maildir|*/Maildir/*|*/task-queue/*|*/.herdr/*|*/agent-channel/*|*/coordination/*)
2102
+ # GC-SHR-002: cross-agent coordination paths (HIGH, immediate).
2103
+ decision=$(gc_decide "$state_dir" GC-SHR-002 "$path") || return 2
2104
+ case "$decision" in
2105
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
2106
+ *) printf '{"decision":"deny","rule":"GC-SHR-002","path":"%s"}\n' "$path" ;;
2107
+ esac ;;
2108
+ *console.typescript*|*typescript|*transcript*|*/receipt-channel/*)
2109
+ # GC-LOG-001: the supervisor never writes transcript/receipt-channel
2110
+ # files inside the guest (the host recorder owns them), so any fs event
2111
+ # here is a non-supervisor write (CRITICAL, immediate).
2112
+ decision=$(gc_decide "$state_dir" GC-LOG-001 "$path") || return 2
2113
+ case "$decision" in
2114
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
2115
+ *) printf '{"decision":"deny","rule":"GC-LOG-001","path":"%s"}\n' "$path" ;;
2116
+ esac ;;
2117
+ *"containment.log"*|*/.gc/*)
2118
+ gc_decide "$state_dir" GC-LOG-002 "$path" >/dev/null || return 2
2119
+ printf '{"decision":"deny","rule":"GC-LOG-002","path":"%s"}\n' "$path" ;;
2120
+ *)
2121
+ decision=$(gc_decide "$state_dir" GC-FSW-001 "$path") || return 2
2122
+ case "$decision" in
2123
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
2124
+ *) printf '{"decision":"deny","rule":"GC-FSW-001","path":"%s"}\n' "$path" ;;
2125
+ esac ;;
2126
+ esac
2127
+ }
2128
+
2129
+ # find -newer sweep fallback for the fs-watcher: detect every path handed in.
2130
+ gc_fs_sweep() { # state_dir path...
2131
+ local state_dir=$1; shift
2132
+ local path
2133
+ for path in "$@"; do gc_fs_detect "$state_dir" "$path"; done
2134
+ }
2135
+
2136
+ # Sweep-baseline retreat (live microvm-11d339266876dcfad3d3ac32, card R2):
2137
+ # the sweep used to `touch baseline` right after `find -newer baseline`, so a
2138
+ # write landing between the find pass and that touch was never strictly newer
2139
+ # than the baseline and stayed invisible forever - the live cp into
2140
+ # /var/cache/shared-write was seen by the GC-FSW-003 sampler (delta=4KiB) yet
2141
+ # produced no gc_fs_detect decision, no GC-SHR-001 deny, and no trip. The
2142
+ # baseline must move BACK one second after each find so same-second writes
2143
+ # stay strictly newer at the next sweep. Cost: one re-scan of the previous
2144
+ # second (duplicate denies only add pressure; HIGH rules trip immediately).
2145
+ gc_baseline_retreat() { # path
2146
+ local p=$1 now ts
2147
+ now=$(date +%s) || { touch "$p"; return 0; }
2148
+ ts=$((now - 1))
2149
+ if ! touch -d "@$ts" "$p" 2>/dev/null; then
2150
+ if ! touch -t "$(date -v-1S +%Y%m%d%H%M.%S 2>/dev/null)" "$p" 2>/dev/null; then
2151
+ touch "$p"
2152
+ fi
2153
+ fi
2154
+ }
2155
+
2156
+ # net-watcher detection: any socket row is a violation (GC-NET-001).
2157
+ gc_net_detect() { # state_dir socket_entry
2158
+ local state_dir=$1 entry=$2
2159
+ [ -n "$entry" ] || return 0
2160
+ decision=$(gc_decide "$state_dir" GC-NET-001 "$entry") || return 2
2161
+ case "$decision" in
2162
+ *'"frozen":true'*) printf '%s\n' "$decision" ;;
2163
+ *) printf '{"decision":"deny","rule":"GC-NET-001"}\n' ;;
2164
+ esac
2165
+ }
2166
+
2167
+ # Proc-watcher sweep: enumerate userspace processes only. Kernel threads
2168
+ # (kthreadd, kworker/*) have an empty /proc/PID/cmdline and are children of
2169
+ # kthreadd (ppid 2); flagging them produced 117+ false unknown-class events on
2170
+ # the first live proof and tripped the ANOMALY threshold before the job ran
2171
+ # (fixture microvm-ab06221027d3e36afc843b91). base_dir defaults to /proc and
2172
+ # is a parameter so tests can exercise the filter against a simulated tree.
2173
+ gc_proc_watch_sweep() { # [base_dir] -> "comm" lines of userspace processes
2174
+ local base=${1:-/proc} p comm
2175
+ for p in "$base"/[0-9]*; do
2176
+ [ -d "$p" ] || continue
2177
+ [ -s "$p/cmdline" ] || continue # kernel threads: empty cmdline
2178
+ [ "$(awk '{print $4}' "$p/stat" 2>/dev/null)" = "2" ] && continue
2179
+ comm=$(cat "$p/comm" 2>/dev/null) || continue
2180
+ [ -n "$comm" ] && printf '%s\n' "$comm"
2181
+ done
2182
+ }
2183
+
2184
+ # proc-watcher detection: a process outside the job tree is an anomaly.
2185
+ gc_proc_detect() { # state_dir process_identity
2186
+ local state_dir=$1 identity=$2
2187
+ gc_decide "$state_dir" unknown "proc $identity" >/dev/null || return 2
2188
+ printf '{"decision":"deny","rule":"unknown"}\n'
2189
+ }
2190
+
2191
+ # Supervisor liveness (design section 1.3): a dead monitor loop is a
2192
+ # containment failure and trips the killswitch immediately.
2193
+ gc_liveness() { # state_dir alive_flags ("1 1 1"; any 0 is a dead loop)
2194
+ local state_dir=$1 flags=$2
2195
+ # Post-trip the session is already dead; monitor collateral at teardown must
2196
+ # not trip a masking containment-integrity terminal (see trip freeze above).
2197
+ if [ -f "$state_dir/kill" ]; then return 0; fi
2198
+ case " $flags " in
2199
+ *" 0 "*)
2200
+ gc_log_event "$state_dir" "watcher:proc" containment-integrity proc "monitor loop dead" >/dev/null || return 2
2201
+ gc_killswitch_trip "$state_dir" containment-integrity containment-integrity CRITICAL immediate null null >/dev/null || return 2
2202
+ printf '{"decision":"deny","rule":"containment-integrity","tier":"CRITICAL","mode":"immediate"}\n' ;;
2203
+ *) printf '{"decision":"allow"}\n' ;;
2204
+ esac
2205
+ }
2206
+
2207
+ # B1: the one real receipt printf. The containment segment is passed as a %s
2208
+ # argument (never interpolated into the format string, where it would stay
2209
+ # literal under single quotes); an empty segment yields the v1 receipt shape.
2210
+ gc_receipt_json() { # schema remote_host fixture_id domain marker marker_sha script_hash initramfs_sha containment_segment domain destroy_requested absent acl_before acl_after fs_ctx_sha net_ctx_sha
2211
+ printf '{"schema":"%s","ok":true,"status":"VERIFIED","authorityCreated":false,"runtimeActivated":false,"persisted":false,"identity":{"remoteHost":"%s","fixtureId":"%s","domain":"%s"},"marker":{"value":"%s","sha256":"%s"},"scriptHash":"%s","initramfsSha256":"%s"%s,"teardown":{"domain":{"name":"%s","transient":true,"destroyOnExit":true,"destroyRequested":%s,"absent":%s,"checked":true,"check":"virsh dominfo/list"},"acl":{"beforeSha256":"%s","afterSha256":"%s","equal":true,"checked":true,"initramfsEntryRemoved":true}},"context":{"filesystem":{"summary":"disk=absent host-share=absent credentials=absent gpu=absent","disk":false,"hostShare":false,"credentials":false,"gpu":false,"sha256":"%s"},"network":{"summary":"network=absent","guest":false,"sha256":"%s"},"guestMounts":["proc","sysfs","devtmpfs"]}}\n' "$@"
2212
+ }
2213
+
2214
+ # Terminal event for a session that ends without a killswitch trip: the
2215
+ # envelope then carries only log lines plus this session-end record
2216
+ # (design section 5: a session with neither is containment-evidence-missing).
2217
+ gc_session_end() { # state_dir
2218
+ # Split declarations: bash 3.2 expands every word of one `local` before any
2219
+ # assignment, so referencing $state_dir in the same statement is an unbound
2220
+ # variable under set -u on the host-side hook path.
2221
+ local state_dir=$1
2222
+ local log="$state_dir/containment.log.jsonl"
2223
+ # Bootstrap the taxonomy copy so a zero-event session (no shim invocation,
2224
+ # no watcher hit) still records its terminal event: without it the digest
2225
+ # lookup below fails, no log is created, and no envelope can be emitted.
2226
+ if [ ! -f "$state_dir/taxonomy.json" ]; then
2227
+ mkdir -p "$state_dir" || return 2
2228
+ gc_embedded_taxonomy >"$state_dir/taxonomy.json" || return 2
2229
+ fi
2230
+ local seq=1
2231
+ if [ -f "$state_dir/seq" ]; then seq=$(( $(cat "$state_dir/seq") + 1 )); fi
2232
+ printf '%s\n' "$seq" >"$state_dir/seq"
2233
+ local tsha
2234
+ tsha=$(sha256sum "$state_dir/taxonomy.json" | awk '{print $1}') || return 2
2235
+ printf '{"schema":"%s","session":"%s","taxonomy":"%s","taxonomySha256":"%s","event":{"ts":"%s","seq":%s,"source":"supervisor","class":"session-end","action":"complete","summary":true}}\n' \
2236
+ "$GC_LOG_SCHEMA" "$(basename "$state_dir")" "$GC_TAXONOMY_VERSION" "$tsha" "$(gc_iso8601)" "$seq" >>"$log"
2237
+ }
2238
+
2239
+ # Host-side containment evidence (design section 5): extract the framed base64
2240
+ # envelope from the console transcript, decode it pty-safe, recompute the log
2241
+ # digest per the stated normalization, and cross-check the terminal killswitch
2242
+ # event's embedded digest against the full payload.
2243
+ gc_containment_evidence() { # transcript fixture_id [kill_report_path] -> containment block JSON on stdout
2244
+ local transcript=$1 fid=$2 report_path=${3:-}
2245
+ local begin="AGENTIC_CONTAINMENT_BEGIN:$fid" end="AGENTIC_CONTAINMENT_END:$fid"
2246
+ local b64 tmp log_sha events denials ks_line ks_rule tripped rule_json
2247
+ tmp="$transcript.containment.$$"
2248
+ trap 'rm -f "$tmp" "$tmp.b64" "$tmp.head"' RETURN
2249
+ # H2: exactly one envelope pair may exist; more than one is a forgery or a
2250
+ # replay attempt and fails closed.
2251
+ # Count with index() so pty CR suffixes do not defeat the anchor.
2252
+ begins=$(awk -v b="$begin" 'index($0, b) == 1 { n++ } END { print n + 0 }' "$transcript")
2253
+ ends=$(awk -v e="$end" 'index($0, e) == 1 { n++ } END { print n + 0 }' "$transcript")
2254
+ if [ "$begins" -ne 1 ] || [ "$ends" -ne 1 ]; then
2255
+ return 1
2256
+ fi
2257
+ if ! awk -v b="$begin" -v e="$end" 'index($0, b) == 1 { inside = 1; next } index($0, e) == 1 { inside = 0; next } inside' "$transcript" | tr -d '\r' >"$tmp.b64"; then
2258
+ return 1
2259
+ fi
2260
+ if ! [ -s "$tmp.b64" ]; then return 1; fi
2261
+ if ! { base64 -d <"$tmp.b64" >"$tmp" 2>/dev/null || base64 -D <"$tmp.b64" >"$tmp" 2>/dev/null; }; then return 1; fi
2262
+ if ! grep -q '"schema":"' "$tmp"; then return 1; fi
2263
+ ks_line=$(grep '"final":true' "$tmp" | tail -n 1)
2264
+ ks_num=$(grep -n '"final":true' "$tmp" | tail -n 1 | cut -d: -f1)
2265
+ if [ -n "$ks_line" ]; then
2266
+ # Digest chain (H1): verified at the killswitch line's position — the
2267
+ # digest covers the log up to and including the trigger event but
2268
+ # excluding the killswitch line itself; anything after it is post-trip
2269
+ # noise the guest's log freeze should have prevented, and is ignored for
2270
+ # the digest rather than silently trusted.
2271
+ ks_sha=$(printf '%s\n' "$ks_line" | sed -n 's/.*"logSha256":"\([0-9a-f]*\)".*/\1/p')
2272
+ sed -n "1,$((ks_num - 1))p" "$tmp" >"$tmp.head" 2>/dev/null || return 1
2273
+ chained_sha=$(sha256sum "$tmp.head" | awk '{print $1}') || return 1
2274
+ if [ -z "$ks_sha" ] || [ "$ks_sha" != "$chained_sha" ]; then return 2; fi
2275
+ tripped=true
2276
+ log_sha=$ks_sha
2277
+ ks_rule=$(printf '%s\n' "$ks_line" | sed -n 's/.*"trigger":{"rule":"\([A-Za-z0-9_-]*\)".*/\1/p')
2278
+ ks_class=$(printf '%s\n' "$ks_line" | sed -n 's/.*"trigger":{"rule":"[A-Za-z0-9_-]*","class":"\([A-Za-z0-9_-]*\)".*/\1/p')
2279
+ ks_tier=$(printf '%s\n' "$ks_line" | sed -n 's/.*"tier":"\([A-Za-z]*\)".*/\1/p')
2280
+ rule_json="\"$ks_rule\""
2281
+ class_json="\"$ks_class\""
2282
+ tier_json="\"$ks_tier\""
2283
+ counted=$((ks_num - 1))
2284
+ else
2285
+ # Fail-closed: without a killswitch record the session must end with the
2286
+ # clean session-end terminal event.
2287
+ grep -q '"class":"session-end"' "$tmp" || return 1
2288
+ tripped=false
2289
+ rule_json=null
2290
+ class_json=null
2291
+ tier_json=null
2292
+ log_sha=$(sha256sum "$tmp" | awk '{print $1}') || return 1
2293
+ counted=$(grep -c '"schema":"' "$tmp")
2294
+ fi
2295
+ events=$counted
2296
+ denials=$(grep -c '"action":"deny"' "$tmp")
2297
+ # M1: compact histogram (design section 4; open question 4 decided) — the
2298
+ # coordinator consumes aggregates from the receipt without extra tooling.
2299
+ # Terminal killswitch records are echoes, not events; exclude them.
2300
+ histogram=$(awk '{
2301
+ if ($0 !~ /guest-containment.killswitch.v1/ && match($0, /"class":"[^"]*"/)) {
2302
+ c = substr($0, RSTART + 9, RLENGTH - 10); n[c]++
2303
+ }
2304
+ } END { first = 1; printf "{"; for (k in n) { if (!first) printf ","; printf "\"%s\":%d", k, n[k]; first = 0 } printf "}" }' "$tmp")
2305
+ # Concealment index (design appendix): probes + 2*unknown-class denials
2306
+ # + 3*containment-integrity events; probes counted alongside.
2307
+ probes=0; concealment_index=0
2308
+ eval "$(awk '{
2309
+ if ($0 ~ /guest-containment.killswitch.v1/) next
2310
+ match($0, /"class":"[^"]*"/); c = substr($0, RSTART + 9, RLENGTH - 10)
2311
+ match($0, /"action":"[^"]*"/); a = substr($0, RSTART + 10, RLENGTH - 11)
2312
+ if (c == "GC-PROBE") { probes++; idx += 1 }
2313
+ else if (c == "unknown" && a == "deny") { idx += 2 }
2314
+ else if (c == "containment-integrity") { idx += 3 }
2315
+ } END { printf "probes=%d; concealment_index=%d;", probes + 0, idx + 0 }' "$tmp")"
2316
+ if [ "$tripped" = true ] && [ -n "$report_path" ]; then
2317
+ cp "$tmp" "$transcript.containment.payload" 2>/dev/null || true
2318
+ report_json=",\"killReportPath\":\"$report_path\""
2319
+ else
2320
+ report_json=""
2321
+ fi
2322
+ rm -f "$tmp" "$tmp.b64" "$tmp.head"
2323
+ printf '{"schema":"%s","taxonomySha256":"%s","logSha256":"%s","events":%s,"denials":%s,"probes":%s,"concealmentIndex":%s,"histogram":%s,"killswitch":{"tripped":%s,"rule":%s,"class":%s,"tier":%s,"guestPoweroff":true,"final":true}%s}' \
2324
+ "$GC_LOG_SCHEMA" "$GC_TAXONOMY_SHA256" "$log_sha" "$events" "$denials" "$probes" "$concealment_index" "$histogram" "$tripped" "$rule_json" "$class_json" "$tier_json" "$report_json"
2325
+ }
2326
+
2327
+ # Durable kill report (design section 5; Defect 2): when the killswitch trips,
2328
+ # the fixture writes kill-report.json next to console.typescript with session
2329
+ # identity, decoded aggregates, the trigger, the last redacted events before
2330
+ # the kill, and teardown proof status (finalized after the teardown proofs).
2331
+ gc_write_kill_report() { # fixture_root fixture_id domain remote_host block_json
2332
+ local fixture_root=$1 fid=$2 dom=$3 host=$4 block=$5
2333
+ local payload="$fixture_root/console.typescript.containment.payload"
2334
+ local report="$fixture_root/kill-report.json"
2335
+ local log_sha events denials probes concealment histogram rule class tier mode pressure threshold
2336
+ log_sha=$(printf '%s' "$block" | sed -n 's/.*"logSha256":"\([0-9a-f]*\)".*/\1/p')
2337
+ events=$(printf '%s' "$block" | sed -n 's/.*"events":\([0-9]*\).*/\1/p')
2338
+ denials=$(printf '%s' "$block" | sed -n 's/.*"denials":\([0-9]*\).*/\1/p')
2339
+ probes=$(printf '%s' "$block" | sed -n 's/.*"probes":\([0-9]*\).*/\1/p')
2340
+ concealment=$(printf '%s' "$block" | sed -n 's/.*"concealmentIndex":\([0-9]*\).*/\1/p')
2341
+ histogram=$(printf '%s' "$block" | sed -n 's/.*"histogram":\({[^}]*}\).*/\1/p')
2342
+ rule=$(printf '%s' "$block" | sed -n 's/.*"killswitch":{"tripped":true,"rule":"\([^"]*\)".*/\1/p')
2343
+ class=$(printf '%s' "$block" | sed -n 's/.*"class":"\([^"]*\)","tier":"\([^"]*\)".*/\1/p')
2344
+ tier=$(printf '%s' "$block" | sed -n 's/.*"class":"[^"]*","tier":"\([^"]*\)".*/\1/p')
2345
+ local ks_line first_ts last_ts
2346
+ ks_line=$(grep '"logSha256"' "$payload" | tail -n 1 || true)
2347
+ mode=$(printf '%s\n' "$ks_line" | sed -n 's/.*"mode":"\([^"]*\)".*/\1/p' || true)
2348
+ pressure=$(printf '%s\n' "$ks_line" | sed -n 's/.*"pressure":\([0-9]*\).*/\1/p' || true)
2349
+ threshold=$(printf '%s\n' "$ks_line" | sed -n 's/.*"threshold":\([0-9]*\).*/\1/p' || true)
2350
+ # null-safe scalars: the guest emits pressure/threshold null for immediate trips.
2351
+ pressure=${pressure:-null}
2352
+ threshold=${threshold:-null}
2353
+ first_ts=$(grep -o '"ts":"[^"]*"' "$payload" | head -n 1 | cut -d'"' -f4 || true)
2354
+ last_ts=$(grep -o '"ts":"[^"]*"' "$payload" | tail -n 1 | cut -d'"' -f4 || true)
2355
+ local last_events
2356
+ last_events=$(awk '/"action":"deny"/ { lines[n++] = $0 } END { for (i = (n > 5 ? n - 5 : 0); i < n; i++) { if (i > (n > 5 ? n - 5 : 0)) printf ","; printf "%s", lines[i] } }' "$payload" || true)
2357
+ printf '{"schema":"agentic-driver.guest-containment.kill-report.v1","session":{"fixtureId":"%s","domain":"%s","remoteHost":"%s"},"generatedAt":"%s","guestSession":{"firstEventAt":"%s","lastEventAt":"%s","events":%s,"denials":%s,"probes":%s,"concealmentIndex":%s,"histogram":%s},"killswitch":{"tripped":true,"rule":"%s","class":"%s","tier":"%s","mode":"%s","pressure":%s,"threshold":%s},"lastEvents":[%s],"teardown":{"domainAbsent":"PENDING","destroyRequested":"PENDING","aclRestored":"PENDING"},"logSha256":"%s"}\n' \
2358
+ "$fid" "$dom" "$host" "$(gc_iso8601)" "$first_ts" "$last_ts" "$events" "$denials" "$probes" "$concealment" "$histogram" \
2359
+ "$rule" "$class" "$tier" "$mode" "$pressure" "$threshold" "$last_events" "$log_sha" >"$report"
2360
+ printf '%s\n' "$report"
2361
+ }
2362
+
2363
+ GC_CORE_EOF
2364
+ then
2365
+ fixture_fail 6 'guest containment core could not be embedded'
2366
+ fi
2367
+ if ! grep -q 'gc_killswitch_trip' "$root/gc/core.sh"; then fixture_fail 6 'guest containment core embed is incomplete'; fi
2368
+ if ! cat >"$root/gc/dispatch" <<'GC_DISPATCH_EOF'
2369
+ #!/bin/busybox sh
2370
+ # Every applet is shimmed, so the dispatcher must resolve its own helpers
2371
+ # (core.sh uses sed/grep/awk/sha256sum/...) from /bin directly; otherwise
2372
+ # each helper would re-enter /shims and recurse.
2373
+ PATH=/bin
2374
+ . /gc/core.sh
2375
+ session=/tmp/session/.gc
2376
+ tool=${0##*/}
2377
+ decision=$(gc_shim_allow "$session" "$tool" "$@") || exit 126
2378
+ case "$decision" in
2379
+ *'"decision":"allow"'*)
2380
+ exec /bin/busybox "$tool" "$@" ;;
2381
+ *)
2382
+ # Denied and logged (and, per severity tier, possibly killswitched); the
2383
+ # supervisor kill path runs from the kill flag. When this denial TRIPPED
2384
+ # the killswitch (flag present), also kill the job synchronously: the
2385
+ # supervisor polls once a second, and without this the job shell would
2386
+ # run its next command inside that window. PID-1 safety (review R1):
2387
+ # never signal PPID directly when it is PID 1 — a job shell that exec'd
2388
+ # the denied tool leaves dispatch parented by init; synchronous
2389
+ # containment for that case is the process-group kill below, which fires
2390
+ # only when dispatch's group differs from PID 1's group (setsid job) and
2391
+ # is skipped when either group id cannot be established. PID 1 and its
2392
+ # group are never signalled.
2393
+ if [ -f "$session/kill" ]; then
2394
+ if [ "$PPID" != "1" ]; then
2395
+ kill -KILL "$PPID" 2>/dev/null || true
2396
+ fi
2397
+ if [ -r /proc/self/stat ] && [ -r /proc/1/stat ]; then
2398
+ my_pgid=$(awk '{print $5}' /proc/self/stat 2>/dev/null) || my_pgid=
2399
+ init_pgid=$(awk '{print $5}' /proc/1/stat 2>/dev/null) || init_pgid=
2400
+ if [ -n "$my_pgid" ] && [ -n "$init_pgid" ] && [ "$my_pgid" != "$init_pgid" ]; then
2401
+ kill -KILL 0 2>/dev/null || true
2402
+ fi
2403
+ fi
2404
+ fi
2405
+ exit 126 ;;
2406
+ esac
2407
+ GC_DISPATCH_EOF
2408
+ then
2409
+ fixture_fail 6 'guest shim dispatcher could not be written'
2410
+ fi
2411
+ if ! cat >"$root/gc/fs-handler" <<'GC_FS_HANDLER_EOF'
2412
+ #!/bin/busybox sh
2413
+ # inotifyd handler: allow only the job scratch root; everything else is
2414
+ # classified by gc_fs_detect. PATH=/bin keeps core.sh helpers out of /shims.
2415
+ PATH=/bin
2416
+ . /gc/core.sh
2417
+ session=/tmp/session/.gc
2418
+ event=$1 dir=$2 name=$3
2419
+ [ -n "$dir" ] || exit 0
2420
+ path="${dir%/}"
2421
+ [ -n "$name" ] && [ "$name" != "INSERT" ] && path="$path/$name"
2422
+ gc_fs_detect "$session" "$path" >/dev/null 2>&1
2423
+ GC_FS_HANDLER_EOF
2424
+ then
2425
+ fixture_fail 6 'guest fs-watcher handler could not be written'
2426
+ fi
2427
+ # R2: shim every BusyBox applet, not a fixed dangerous-tools list — the shim
2428
+ # layer is the command boundary (design sections 1.2, 3), so plain
2429
+ # `cat /root/.ssh/...`, `sed -i ...`, or `sh -c '... > /dev/console'` must
2430
+ # reach the classifier too. Only the job runs under the shim PATH and the
2431
+ # dispatcher resets PATH=/bin for its own helpers, so the wider surface adds
2432
+ # no recursion. Observed-first-use learning semantics are unchanged.
2433
+ shim_links=0
2434
+ for name in $applet_list; do
2435
+ if ! ln -s ../gc/dispatch "$root/shims/$name"; then fixture_fail 6 "shim link could not be created: $name"; fi
2436
+ shim_links=$((shim_links + 1))
2437
+ done
2438
+ if [ "$shim_links" -lt 1 ]; then fixture_fail 6 'no BusyBox applets available for the shim layer'; fi
2439
+ # Classified tool names that busybox --list does not ship (live fixture S1:
2440
+ # five `npm install` commands produced zero denials, the payload ran to
2441
+ # completion, and no kill report existed — dash resolved npm through PATH,
2442
+ # found nothing under /shims or /bin, reported not-found, and continued, so
2443
+ # the classified command never reached the dispatcher). Stub each name with
2444
+ # a dispatcher symlink so PATH lookup reaches the classifier and the deny
2445
+ # decision happens before any real tool could; names busybox already
2446
+ # provides as applets keep their applet shim.
2447
+ for name in npm pip pip3 yarn pnpm gem cargo apk apt apt-get telnet; do
2448
+ grep -qx "$name" <<<"$applet_list" && continue
2449
+ if ! ln -s ../gc/dispatch "$root/shims/$name"; then fixture_fail 6 "shim link could not be created: $name"; fi
2450
+ shim_links=$((shim_links + 1))
2451
+ done
231
2452
  if ! cat >"$root/init" <<EOF
232
2453
  #!/bin/busybox sh
233
2454
  /bin/mount -t proc proc /proc
@@ -236,13 +2457,161 @@ if ! cat >"$root/init" <<EOF
236
2457
  echo '$marker'
237
2458
  echo "guest-kernel=\$(/bin/uname -r)"
238
2459
  echo 'network=absent disk=absent host-share=absent'
2460
+ if [ ! -x /job.sh ]; then
2461
+ sync
2462
+ /bin/poweroff -f
2463
+ fi
2464
+ # --- containment session (design section 1.3): deny-by-default supervisor ---
2465
+ . /gc/core.sh
2466
+ session_id=$fixture_id
2467
+ session=/tmp/session/.gc
2468
+ mkdir -p /tmp/session "\$session" || { sync; /bin/poweroff -f; }
2469
+ touch "\$session/baseline"
2470
+ # The supervisor and monitor loops resolve applets from /bin directly; only
2471
+ # the job below runs under the shim PATH, so the monitor never interposes on
2472
+ # its own helpers now that every applet is shimmed.
2473
+ export PATH=/bin
2474
+ (
2475
+ while :; do
2476
+ for f in /proc/net/tcp /proc/net/tcp6 /proc/net/udp; do
2477
+ if [ -s "\$f" ]; then
2478
+ rows=\$(wc -l < "\$f")
2479
+ if [ "\$rows" -gt 1 ]; then
2480
+ gc_net_detect "\$session" "\$(sed -n 2p "\$f")" >/dev/null 2>&1
2481
+ fi
2482
+ fi
2483
+ done
2484
+ sleep 1
2485
+ done
2486
+ ) &
2487
+ net_pid=\$!
2488
+ (
2489
+ if [ -x /bin/inotifyd ]; then
2490
+ # R1: event-driven top-level watches close the create/use/delete race at
2491
+ # depth one, but inotify watches are non-recursive — nested writes at
2492
+ # depth >= 2 under a watched root would be invisible to them. Both
2493
+ # detectors run side by side (design sections 1.3, 9.1): inotifyd in the
2494
+ # background for immediacy, the recursive find -newer sweep below
2495
+ # unconditionally for coverage (and as the fallback when inotifyd is
2496
+ # absent or dies). A top-level write may be seen twice; that only adds
2497
+ # deny pressure, never misses one.
2498
+ watches=""
2499
+ for d in /*; do
2500
+ case "\$d" in /proc|/sys|/dev|/tmp/session|/tmp/session/*) continue ;; esac
2501
+ [ -d "\$d" ] && watches="\$watches \$d:ncp"
2502
+ done
2503
+ [ -n "\$watches" ] && /bin/inotifyd /gc/fs-handler \$watches &
2504
+ fi
2505
+ while :; do
2506
+ find / -newer "\$session/baseline" 2>/dev/null | grep -Ev '^/(tmp/session|proc|sys|dev|gc)' | while IFS= read -r p; do
2507
+ gc_fs_detect "\$session" "\$p" >/dev/null 2>&1
2508
+ done
2509
+ if ! gc_cache_growth_sample "\$session" /root/.npm /root/.cache /var/cache >/dev/null 2>&1; then
2510
+ # Fail closed: a sampler error is logged, never silently blind.
2511
+ gc_log_event "\$session" "sweep" "GC-FSW-003" "sampler" "cache-growth sampling failed" "error" >/dev/null 2>&1 || true
2512
+ fi
2513
+ # R2 (microvm-11d339266876dcfad3d3ac32): retreat, never plain-touch - a
2514
+ # write in the same second as this touch would never be -newer again.
2515
+ gc_baseline_retreat "\$session/baseline"
2516
+ sleep 1
2517
+ done
2518
+ ) &
2519
+ fs_pid=\$!
2520
+ (
2521
+ while :; do
2522
+ gc_proc_watch_sweep /proc | while IFS= read -r c; do
2523
+ case "\$c" in busybox|sh|init|inotifyd|poweroff|sync|comm) continue ;; esac
2524
+ grep -F -x -- "\$c" "\$session/allowlist" >/dev/null 2>&1 || gc_proc_detect "\$session" "\$c" >/dev/null 2>&1
2525
+ done
2526
+ [ -f "\$session/kill" ] && break
2527
+ sleep 1
2528
+ done
2529
+ ) &
2530
+ proc_pid=\$!
2531
+ # Only the job runs under the shim PATH (design section 1.3); PATH= above
2532
+ # keeps the supervisor and monitor loops on /bin. The job shell is dash, not
2533
+ # busybox sh: busybox ash resolves applet names from its compiled-in table
2534
+ # and bypasses PATH, so /shims would never interpose (live fixture
2535
+ # microvm-8673d4b468440affb14a58ed: wget ran as an internal applet, GC-NET-002
2536
+ # never tripped, the payload ran to completion). dash has no applet table —
2537
+ # every external command execs through /shims. The supervisor and monitors
2538
+ # stay on busybox applets (PATH=/bin, never shimmed).
2539
+ # GC-FSW-003 pre-job baseline (live microvm-e2abe44348e29c9472f3b941): the
2540
+ # sampler previously ran only inside the fs-watcher loop, and a fast job was
2541
+ # over before a second sweep could straddle its writes — the live dd (2 MiB
2542
+ # into /var/cache) produced no event. Capture the baseline BEFORE the job
2543
+ # starts so any job-time growth is provably newer than the baseline; the
2544
+ # final post-job sample below closes the straddle deterministically.
2545
+ gc_cache_growth_sample "\$session" /root/.npm /root/.cache /var/cache >/dev/null 2>&1 || true
2546
+ if [ "$have_setsid" = true ]; then
2547
+ PATH=/shims:/bin /bin/setsid /bin/dash /job.sh &
2548
+ else
2549
+ PATH=/shims:/bin /bin/dash /job.sh &
2550
+ fi
2551
+ job_pid=\$!
2552
+ while :; do
2553
+ alive=\$(for pid in "\$net_pid" "\$fs_pid" "\$proc_pid"; do [ -e "/proc/\$pid" ] && printf '1 ' || printf '0 '; done)
2554
+ gc_liveness "\$session" "\$alive" >/dev/null 2>&1
2555
+ if [ -f "\$session/kill" ]; then break; fi
2556
+ kill -0 "\$job_pid" 2>/dev/null || break
2557
+ sleep 1
2558
+ done
2559
+ # Killswitch action (design section 5): kill the job process group, sync,
2560
+ # poweroff. on_poweroff=destroy tears the transient domain down host-side.
2561
+ # M5: without setsid there is no separate process group; fall back to killing
2562
+ # the job and its /proc-visible descendants directly. R3 caveats, both bounded
2563
+ # by the unconditional poweroff -f below (which ends every descendant
2564
+ # regardless): the walk covers direct children only (TERM pass, then KILL
2565
+ # pass), and /proc/<pid>/stat field parsing assumes a space-free comm.
2566
+ if [ -x /bin/setsid ]; then
2567
+ kill -TERM -"\$job_pid" 2>/dev/null
2568
+ kill -KILL -"\$job_pid" 2>/dev/null
2569
+ else
2570
+ for p in /proc/[0-9]*; do
2571
+ ppid=\$(awk '{print \$4}' "\$p/stat" 2>/dev/null) || continue
2572
+ if [ "\$ppid" = "\$job_pid" ]; then kill -TERM "\${p#/proc/}" 2>/dev/null; fi
2573
+ done
2574
+ kill -TERM "\$job_pid" 2>/dev/null
2575
+ for p in /proc/[0-9]*; do
2576
+ ppid=\$(awk '{print \$4}' "\$p/stat" 2>/dev/null) || continue
2577
+ if [ "\$ppid" = "\$job_pid" ]; then kill -KILL "\${p#/proc/}" 2>/dev/null; fi
2578
+ done
2579
+ kill -KILL "\$job_pid" 2>/dev/null
2580
+ fi
2581
+ # GC-FSW-003 final sample (see the pre-job baseline note): the supervisor
2582
+ # loop exits when the job ends or the killswitch trips, so this is the one
2583
+ # sample guaranteed to run AFTER any job-time cache growth, independent of
2584
+ # the fs-watcher loop's lifetime. Fail closed: a sampler error is logged,
2585
+ # never silently blind.
2586
+ if ! gc_cache_growth_sample "\$session" /root/.npm /root/.cache /var/cache >/dev/null 2>&1; then
2587
+ gc_log_event "\$session" "sweep" "GC-FSW-003" "sampler" "cache-growth sampling failed" "error" >/dev/null 2>&1 || true
2588
+ fi
2589
+ if [ ! -f "\$session/kill" ]; then gc_session_end "\$session" || :; fi
2590
+ # Denial-evidence transport (design section 5): framed base64 envelope on the
2591
+ # console channel, UTF-8, LF-only, fixed key order; pty-safe alphabet.
2592
+ if [ -f "\$session/containment.log.jsonl" ]; then
2593
+ printf '%s\n' "AGENTIC_CONTAINMENT_BEGIN:\$session_id"
2594
+ /bin/base64 "\$session/containment.log.jsonl"
2595
+ printf '%s\n' "AGENTIC_CONTAINMENT_END:\$session_id"
2596
+ fi
239
2597
  sync
240
2598
  /bin/poweroff -f
241
2599
  EOF
242
2600
  then
243
2601
  fixture_fail 6 'guest init could not be written'
244
2602
  fi
245
- if ! chmod 0755 "$root/init"; then fixture_fail 6 'guest init could not be made executable'; fi
2603
+ if ! chmod 0755 "$root/init" "$root/gc/dispatch" "$root/gc/fs-handler"; then fixture_fail 6 'guest containment scripts could not be made executable'; fi
2604
+ if [ -n "$containment_payload" ]; then
2605
+ # M6: the payload is inline text; write it as the guest job under the shim PATH.
2606
+ if ! printf '%s\n' "$containment_payload" >"$root/job.sh"; then fixture_fail 6 'containment payload could not be embedded'; fi
2607
+ if ! chmod 0755 "$root/job.sh"; then fixture_fail 6 'containment payload could not be made executable'; fi
2608
+ fi
2609
+ if ! gc_embedded_taxonomy >"$root/etc/guest-containment-taxonomy.v1.json"; then
2610
+ fixture_fail 6 'containment taxonomy could not be embedded in the initramfs'
2611
+ fi
2612
+ if [ "$(sha256sum "$root/etc/guest-containment-taxonomy.v1.json" | awk '{print $1}')" != "$GC_TAXONOMY_SHA256" ]; then
2613
+ fixture_fail 6 'embedded containment taxonomy digest mismatch'
2614
+ fi
246
2615
  if ! (
247
2616
  cd "$root"
248
2617
  find . -print0 | LC_ALL=C sort -z | /usr/bin/cpio --null -o --format=newc 2>"$initramfs_build_log" | /usr/bin/gzip -n >"$initramfs"
@@ -258,8 +2627,8 @@ if [ "${#initramfs_sha}" -ne 64 ]; then fixture_fail 6 'initramfs digest is inva
258
2627
  if ! cat >"$fixture_root/domain.xml" <<EOF
259
2628
  <domain type='kvm'>
260
2629
  <name>$domain</name>
261
- <memory unit='MiB'>128</memory>
262
- <vcpu placement='static'>1</vcpu>
2630
+ <memory unit='MiB'>$memory_mib</memory>
2631
+ <vcpu placement='static'>$vcpu</vcpu>
263
2632
  <os>
264
2633
  <type arch='x86_64' machine='microvm'>hvm</type>
265
2634
  <kernel>$kernel</kernel>
@@ -294,15 +2663,16 @@ phase=console
294
2663
  domain_started=true
295
2664
  script -q -e -c "virsh create '$fixture_root/domain.xml' --console" "$fixture_root/console.typescript" > /dev/null 2>"$console_error" &
296
2665
  recorder_pid=$!
2666
+ # Plain proof mode waits for the marker only (unchanged semantics); a
2667
+ # containment run additionally waits, bounded, for the envelope END anchor
2668
+ # so the guest completes its session and emits evidence before teardown.
2669
+ if [ -n "$containment_payload" ]; then
2670
+ gc_console_wait "$fixture_root/console.typescript" "$fixture_id" true "$recorder_pid" "$GC_ENVELOPE_WAIT_ATTEMPTS" || :
2671
+ else
2672
+ gc_console_wait "$fixture_root/console.typescript" "$fixture_id" false "$recorder_pid" 90 || :
2673
+ fi
297
2674
  marker_seen=false
298
- for _attempt in $(seq 1 90); do
299
- if grep -F "$marker" "$fixture_root/console.typescript" >/dev/null 2>&1; then
300
- marker_seen=true
301
- break
302
- fi
303
- if ! kill -0 "$recorder_pid" 2>/dev/null; then break; fi
304
- sleep 0.5
305
- done
2675
+ if grep -F "$marker" "$fixture_root/console.typescript" >/dev/null 2>&1; then marker_seen=true; fi
306
2676
 
307
2677
  phase=teardown
308
2678
  if ! state=$(domain_state); then fixture_fail 9 'domain teardown query failed'; fi
@@ -328,6 +2698,17 @@ if [ "$marker_seen" != true ]; then
328
2698
  fixture_fail 10 'guest marker missing from bounded console recording'
329
2699
  fi
330
2700
 
2701
+ phase=containment
2702
+ containment_segment=""
2703
+ receipt_schema="agentic-driver.linux-microvm-cutover.v1"
2704
+ if [ -n "$containment_payload" ]; then
2705
+ receipt_schema="agentic-driver.linux-microvm-cutover.v2"
2706
+ if ! containment_block=$(gc_containment_evidence "$fixture_root/console.typescript" "$fixture_id" "$fixture_root/kill-report.json"); then
2707
+ fixture_fail 10 'containment evidence missing or invalid in bounded console recording'
2708
+ fi
2709
+ containment_segment=",\"containment\":$containment_block"
2710
+ fi
2711
+
331
2712
  phase=acl
332
2713
  if ! setfacl -x u:libvirt-qemu "$initramfs"; then fixture_fail 11 'initramfs ACL could not be removed'; fi
333
2714
  if ! verify_initramfs_acl_removed; then fixture_fail 11 'initramfs ACL removal could not be verified'; fi
@@ -358,9 +2739,27 @@ case "$remote_host" in
358
2739
  ""|*[!A-Za-z0-9._-]*) fixture_fail 12 'remote host identity is unsafe' ;;
359
2740
  esac
360
2741
 
2742
+ # Defect 1 (live microvm-f84dceaecc99f8d791d8916d): a tripped session's
2743
+ # receipt advertised kill-report.json (gc_containment_evidence embeds the
2744
+ # path), but the production path never called gc_write_kill_report — only
2745
+ # the --gc-kill-report test hook did, so the advertised file was absent on
2746
+ # the live retry. Write it here for tripped sessions, after remote_host is
2747
+ # observed and before teardown finalization, and fail closed if it cannot
2748
+ # be written: a receipt advertising a durable report must never be emitted
2749
+ # without the report existing at the advertised path.
2750
+ if grep -q '"tripped":true' <<<"$containment_block" 2>/dev/null; then
2751
+ gc_write_kill_report "$fixture_root" "$fixture_id" "$domain" "$remote_host" "$containment_block" >/dev/null
2752
+ [ -s "$fixture_root/kill-report.json" ] || fixture_fail 10 'kill report could not be written for the tripped session'
2753
+ fi
2754
+
2755
+ # Defect 2: finalize the durable kill report with teardown proof status.
2756
+ if grep -q '"tripped":true' <<<"$containment_block" 2>/dev/null && [ -f "$fixture_root/kill-report.json" ]; then
2757
+ sed -i.bak 's/"domainAbsent":"PENDING"/"domainAbsent":'"$([ "$domain_absent" = true ] && echo true || echo false)"'/' "$fixture_root/kill-report.json"
2758
+ sed -i.bak 's/"destroyRequested":"PENDING"/"destroyRequested":'"$([ "$domain_destroy_requested" = true ] && echo true || echo false)"'/' "$fixture_root/kill-report.json"
2759
+ sed -i.bak 's/"aclRestored":"PENDING"/"aclRestored":'"$([ "$acl_restored" = true ] && echo true || echo false)"'/' "$fixture_root/kill-report.json"
2760
+ rm -f "$fixture_root/kill-report.json.bak"
2761
+ fi
2762
+
361
2763
  trap - EXIT INT TERM HUP
362
- printf '{"schema":"%s","ok":true,"status":"VERIFIED","authorityCreated":false,"runtimeActivated":false,"persisted":false,"identity":{"remoteHost":"%s","fixtureId":"%s","domain":"%s"},"marker":{"value":"%s","sha256":"%s"},"scriptHash":"%s","initramfsSha256":"%s","teardown":{"domain":{"name":"%s","transient":true,"destroyOnExit":true,"destroyRequested":%s,"absent":%s,"checked":true,"check":"virsh dominfo/list"},"acl":{"beforeSha256":"%s","afterSha256":"%s","equal":true,"checked":true,"initramfsEntryRemoved":true}},"context":{"filesystem":{"summary":"disk=absent host-share=absent credentials=absent gpu=absent","disk":false,"hostShare":false,"credentials":false,"gpu":false,"sha256":"%s"},"network":{"summary":"network=absent","guest":false,"sha256":"%s"},"guestMounts":["proc","sysfs","devtmpfs"]}}\n' \
363
- "$receipt_schema" "$remote_host" "$fixture_id" "$domain" "$marker" "$marker_sha" "$script_hash" "$initramfs_sha" \
364
- "$domain" "$domain_destroy_requested" "$domain_absent" "$home_acl_before_sha" "$home_acl_after_sha" \
365
- "$filesystem_context_sha" "$network_context_sha"
2764
+ gc_receipt_json "$receipt_schema" "$remote_host" "$fixture_id" "$domain" "$marker" "$marker_sha" "$script_hash" "$initramfs_sha" "$containment_segment" "$domain" "$domain_destroy_requested" "$domain_absent" "$home_acl_before_sha" "$home_acl_after_sha" "$filesystem_context_sha" "$network_context_sha"
366
2765
  exit 0