@consiliency/agent-board-cli 1.28.0 → 1.29.1

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.
package/keyslot/status.sh CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/bin/bash
2
- # status.sh --harness <h> [--slot <s>] [--json] [--scan-cache fresh|use|only] [--scan-timeout <s>]:
2
+ # status.sh --harness <h> [--slot <s>] [--json] [--scan-cache fresh|use|only] [--scan-timeout <s>]
3
+ # or status.sh --slots <a,b,...> --json [--scan-cache fresh|use|only] [--scan-timeout <s>]:
3
4
  # what does slot <s> hold, measured, and is it chained and loaded? (IF-0-KEYSEAL-3,
4
5
  # IF-0-KEYSEAL-6). <s> defaults to <h>.
5
6
  #
@@ -11,6 +12,17 @@
11
12
  # scan stage runs under one `timeout` (--scan-timeout, default 1800 s); a scan that gives up is
12
13
  # exit 2 `scan_timed_out` and is never cached. Progress goes to stderr; stdout stays JSON.
13
14
  #
15
+ # --slots <a,b,...> (#552-ONBOARD-SL3, IF-0-ONBOARD-1 "a key scan at most once per host"): judge N
16
+ # slots in one run. Checks 1-5 run per slot, unchanged; check 6 walks the filesystem ONCE for
17
+ # every slot named (one `find`/`xargs`/`grep` stage under one `timeout`), then classifies each
18
+ # slot's candidates separately against that single walk's output, so N slots never cost N walks.
19
+ # `--slots` requires --json and cannot combine with --harness/--slot; each name is both the
20
+ # harness and the slot. Output is one JSON object per line, in the order given, each carrying its
21
+ # own "exit"; the process exit is the worst of the per-slot exits (1 beats 2 beats 0, as below). A
22
+ # single name behaves exactly as --harness <name>: N=1 is "N slots sharing one walk" with N=1, so
23
+ # it is the SAME code path, not a special case, and its output and cache are byte-identical to
24
+ # today's.
25
+ #
14
26
  # MODE, from provenance only, never from what provision was asked (A1):
15
27
  # sealed <s>.cred + <s>.pub, the pin derives from a PIPED decrypt of the blob, no
16
28
  # <s>.key, no opt-in
@@ -54,21 +66,41 @@ here=${BASH_SOURCE[0]%/*}
54
66
  # shellcheck source=lib.sh
55
67
  . "$here/lib.sh"
56
68
 
57
- harness= slot= json=no scan_cache=fresh scan_timeout=$KEYSEAL_SCAN_TIMEOUT_S
58
- usage="usage: status.sh --harness <h> [--slot <s>] [--json] [--scan-cache fresh|use|only] [--scan-timeout <s>]"
69
+ harness= slot= slots_arg= json=no scan_cache=fresh scan_timeout=$KEYSEAL_SCAN_TIMEOUT_S
70
+ usage="usage: status.sh --harness <h> [--slot <s>] [--json] [--scan-cache fresh|use|only] [--scan-timeout <s>] | status.sh --slots <a,b,...> --json [--scan-cache fresh|use|only] [--scan-timeout <s>]"
59
71
  while (($#)); do
60
72
  case $1 in
61
73
  --harness) harness=${2-}; shift 2 || keyseal_die "usage: --harness <h>" ;;
62
74
  --slot) slot=${2-}; shift 2 || keyseal_die "usage: --slot <s>" ;;
75
+ --slots) slots_arg=${2-}; shift 2 || keyseal_die "usage: --slots <a,b,...>" ;;
63
76
  --json) json=yes; shift ;;
64
77
  --scan-cache) scan_cache=${2-}; shift 2 || keyseal_die "$usage" ;;
65
78
  --scan-timeout) scan_timeout=${2-}; shift 2 || keyseal_die "$usage" ;;
66
79
  *) keyseal_die "$usage" ;;
67
80
  esac
68
81
  done
69
- [[ $harness =~ $KEYSEAL_SLOT_RE ]] || keyseal_die "bad_harness"
70
- slot=${slot:-$harness}
71
- [[ $slot =~ $KEYSEAL_SLOT_RE ]] || keyseal_die "bad_slot"
82
+
83
+ slots=() harness_of=()
84
+ if [[ -n $slots_arg ]]; then
85
+ [[ -z $harness && -z $slot ]] || keyseal_die "usage: --slots cannot combine with --harness/--slot"
86
+ [[ $json == yes ]] || keyseal_die "usage: --slots requires --json"
87
+ IFS=, read -ra slots <<<"$slots_arg"
88
+ ((${#slots[@]} >= 1)) || keyseal_die "usage: --slots <a,b,...>"
89
+ declare -A seen_slot=()
90
+ for s in "${slots[@]}"; do
91
+ [[ $s =~ $KEYSEAL_SLOT_RE ]] || keyseal_die "bad_slot: $s"
92
+ [[ -z ${seen_slot[$s]-} ]] || keyseal_die "duplicate_slot: $s"
93
+ seen_slot[$s]=1
94
+ done
95
+ harness_of=("${slots[@]}")
96
+ else
97
+ [[ $harness =~ $KEYSEAL_SLOT_RE ]] || keyseal_die "bad_harness"
98
+ slot=${slot:-$harness}
99
+ [[ $slot =~ $KEYSEAL_SLOT_RE ]] || keyseal_die "bad_slot"
100
+ slots=("$slot")
101
+ harness_of=("$harness")
102
+ fi
103
+ n=${#slots[@]}
72
104
  [[ $scan_cache == fresh || $scan_cache == use || $scan_cache == only ]] || keyseal_die "bad_scan_cache: $usage"
73
105
  [[ $scan_timeout =~ ^[1-9][0-9]{0,6}$ ]] || keyseal_die "bad_scan_timeout: $usage"
74
106
  [[ -n ${XDG_RUNTIME_DIR-} ]] || keyseal_die "runtime_dir_unset: XDG_RUNTIME_DIR is unset, so the slot's agent socket cannot be located; run from a login session or export XDG_RUNTIME_DIR=/run/user/$(id -u)"
@@ -79,14 +111,14 @@ trap keyseal_scan_cleanup EXIT
79
111
  trap 'exit 130' INT
80
112
  trap 'exit 143' TERM
81
113
 
82
- K=$(keyseal_key_dir)
83
- cred=$K/$slot.cred pub=$K/$slot.pub key=$K/$slot.key optin=$K/$slot.unsealed-optin
84
- base_unit=$KEYSEAL_BASE_UNIT@$slot.service
85
- load_unit=$KEYSEAL_LOAD_UNIT@$slot.service
86
- declare -A ok detail
87
- exit2=()
88
114
  has() { [[ -e $1 || -L $1 ]]; }
89
115
  str_or_null() { if [[ -n $1 ]]; then keyseal_json_str "$1"; else printf null; fi; }
116
+ bool() { if [[ $1 == yes ]]; then printf true; else printf false; fi; }
117
+ state() { "$SYSTEMCTL" --user "$1" "$2" 2>/dev/null || true; }
118
+
119
+ # ---- host-level, computed at most once per run (IF-0-ONBOARD-1) --------------------------------
120
+
121
+ K=$(keyseal_key_dir)
90
122
 
91
123
  probe_out=$("$here/probe.sh" --json 2>/dev/null) && probe_rc=0 || probe_rc=$?
92
124
  outcome=error
@@ -94,262 +126,395 @@ outcome=error
94
126
  round_trip=unknown
95
127
  [[ $probe_out =~ \"round_trip\":\"([a-z_]+)\" ]] && round_trip=${BASH_REMATCH[1]}
96
128
 
97
- # 1 committed: the provenance table. The pin is read whatever the provenance, so a
98
- # contradiction still scans for copies of the pinned key (review round 1, Opus S3).
99
- contra=()
100
- mode=undetermined optin_json=null
101
- pin=$(keyseal_read_pin "$pub") || pin=
102
- has "$cred" && has "$key" && contra+=("a sealed blob and an unsealed key are both present")
103
- has "$cred" && has "$optin" && contra+=("an opt-in record beside a sealed blob")
104
- has "$optin" && ! has "$key" && contra+=("an opt-in record without $slot.key")
105
- if has "$optin"; then
106
- optin_ok=true
107
- optin_problems=$(keyseal_optin_check "$slot") || optin_ok=false
108
- plist=
109
- while IFS= read -r line; do [[ -n $line ]] && plist+="${plist:+,}$(keyseal_json_str "$line")"; done <<<"$optin_problems"
110
- optin_json="{\"ok\":$optin_ok,\"problems\":[$plist]}"
111
- fi
112
- if has "$key" && ! has "$optin"; then contra+=("$slot.key without an opt-in record"); fi
113
- if has "$K/$slot.cred.staging" || has "$K/$slot.pub.staging" || has "$K/$slot.unsealed-optin.staging"; then
114
- contra+=("staging file present")
115
- fi
116
- if ((${#contra[@]} == 0)); then
117
- if ! has "$cred" && ! has "$pub" && ! has "$key" && ! has "$optin"; then
118
- exit2+=(nothing_committed)
119
- elif has "$key"; then
120
- # The loader's own predicate (optin.sh): what it would refuse to load is a contradiction.
121
- if problems=$(keyseal_unsealed_check "$slot"); then mode=unsealed
122
- else contra+=("the unsealed slot does not verify: ${problems//$'\n'/; }")
123
- fi
124
- elif ! has "$cred"; then contra+=("blob missing")
125
- elif [[ -z $pin ]]; then contra+=("pin missing or malformed")
126
- else
127
- drc=0
128
- derived=$(keyseal_derive_pub "$slot") || drc=$?
129
- if ((drc == 0)); then
130
- if [[ $derived == "$pin" ]]; then mode=sealed; else contra+=("pin does not derive from the blob"); fi
131
- elif [[ $outcome == capable ]]; then
132
- contra+=("the blob does not decrypt to a key, though sealing works here")
129
+ boot_id=$(</proc/sys/kernel/random/boot_id)
130
+ machine_id=$(keyseal_machine_id) || machine_id=
131
+ seam_j=$(keyseal_seam_json)
132
+ host_j=$(keyseal_json_str "$(uname -n)")
133
+ uid_j=$(keyseal_uid)
134
+ machine_id_j=$(str_or_null "$machine_id")
135
+ boot_id_j=$(keyseal_json_str "$boot_id")
136
+
137
+ stale_temp=$(keyseal_scan_stale_temp)
138
+ keyseal_scan_roots
139
+ scope_sha_j=$(keyseal_json_str "$(keyseal_sha256_str "$(keyseal_scan_scope_json)")")
140
+ scanner_sha_j=$(str_or_null "$(keyseal_files_sha "$here" lib.sh status.sh || true)")
141
+
142
+ linger=$(keyseal_linger)
143
+ if [[ $linger == yes ]]; then ok_linger=true detail_linger=""; else ok_linger=false detail_linger="linger is $linger"; fi
144
+
145
+ # ---- phase A: checks 1-5 and the cache judge, per slot ------------------------------------------
146
+
147
+ declare -a A_MODE=() A_PIN=() A_EXEMPT=() A_EXEMPT_J=() A_PINSHA_J=() A_BLOBSHA_J=()
148
+ declare -a A_OK_COMMITTED=() A_DET_COMMITTED=()
149
+ declare -a A_OK_CHAIN=() A_DET_CHAIN=() A_CHAIN_LEGACY=() A_LEGACY=() A_LOADER_STATE=() A_LOADER_REC=() A_LOADER_RUN=()
150
+ declare -a A_OK_UNITS=() A_DET_UNITS=()
151
+ declare -a A_OK_AGENT=() A_DET_AGENT=() A_AGENT_EMPTY=()
152
+ declare -a A_OPTIN_J=() A_UPGRADE=() A_EXIT2=()
153
+ declare -a A_CACHE_TOKEN=() A_CACHE_STATE=() A_CACHE_REASON=() A_CACHE_SCAN=() A_CACHE_SCANNED_AT=() A_CACHE_DURATION=() A_CACHE_LAST=()
154
+ declare -a A_NEEDS_SCAN=()
155
+
156
+ for i in "${!slots[@]}"; do
157
+ slot=${slots[i]}
158
+ cred=$K/$slot.cred pub=$K/$slot.pub key=$K/$slot.key optin=$K/$slot.unsealed-optin
159
+ base_unit=$KEYSEAL_BASE_UNIT@$slot.service
160
+ load_unit=$KEYSEAL_LOAD_UNIT@$slot.service
161
+
162
+ # 1 committed: the provenance table. The pin is read whatever the provenance, so a
163
+ # contradiction still scans for copies of the pinned key (review round 1, Opus S3).
164
+ contra=()
165
+ mode=undetermined optin_json=null
166
+ pin=$(keyseal_read_pin "$pub") || pin=
167
+ has "$cred" && has "$key" && contra+=("a sealed blob and an unsealed key are both present")
168
+ has "$cred" && has "$optin" && contra+=("an opt-in record beside a sealed blob")
169
+ has "$optin" && ! has "$key" && contra+=("an opt-in record without $slot.key")
170
+ if has "$optin"; then
171
+ optin_ok=true
172
+ optin_problems=$(keyseal_optin_check "$slot") || optin_ok=false
173
+ plist=
174
+ while IFS= read -r line; do [[ -n $line ]] && plist+="${plist:+,}$(keyseal_json_str "$line")"; done <<<"$optin_problems"
175
+ optin_json="{\"ok\":$optin_ok,\"problems\":[$plist]}"
176
+ fi
177
+ if has "$key" && ! has "$optin"; then contra+=("$slot.key without an opt-in record"); fi
178
+ if has "$K/$slot.cred.staging" || has "$K/$slot.pub.staging" || has "$K/$slot.unsealed-optin.staging"; then
179
+ contra+=("staging file present")
180
+ fi
181
+ exit2_i=()
182
+ if ((${#contra[@]} == 0)); then
183
+ if ! has "$cred" && ! has "$pub" && ! has "$key" && ! has "$optin"; then
184
+ exit2_i+=(nothing_committed)
185
+ elif has "$key"; then
186
+ # The loader's own predicate (optin.sh): what it would refuse to load is a contradiction.
187
+ if problems=$(keyseal_unsealed_check "$slot"); then mode=unsealed
188
+ else contra+=("the unsealed slot does not verify: ${problems//$'\n'/; }")
189
+ fi
190
+ elif ! has "$cred"; then contra+=("blob missing")
191
+ elif [[ -z $pin ]]; then contra+=("pin missing or malformed")
133
192
  else
134
- exit2+=(undeterminable)
193
+ drc=0
194
+ derived=$(keyseal_derive_pub "$slot") || drc=$?
195
+ if ((drc == 0)); then
196
+ if [[ $derived == "$pin" ]]; then mode=sealed; else contra+=("pin does not derive from the blob"); fi
197
+ elif [[ $outcome == capable ]]; then
198
+ contra+=("the blob does not decrypt to a key, though sealing works here")
199
+ else
200
+ exit2_i+=(undeterminable)
201
+ fi
135
202
  fi
136
203
  fi
137
- fi
138
- if ((${#contra[@]})); then
139
- mode=contradiction ok[committed]=false
140
- detail[committed]=$(printf '%s; ' "${contra[@]}")
141
- detail[committed]=${detail[committed]%; }
142
- else
143
- ok[committed]=true detail[committed]=""
144
- fi
204
+ if ((${#contra[@]})); then
205
+ mode=contradiction ok_committed_i=false
206
+ detail_committed_i=$(printf '%s; ' "${contra[@]}")
207
+ detail_committed_i=${detail_committed_i%; }
208
+ else
209
+ ok_committed_i=true detail_committed_i=""
210
+ fi
145
211
 
146
- # 2 chain, including the slot's loader version
147
- if keyseal_chain_check "$slot" "$here"; then ok[chain]=true detail[chain]=""
148
- else ok[chain]=false detail[chain]=${KEYSEAL_CHAIN_PROBLEMS%$'\n'}
149
- fi
150
- legacy=no
151
- if [[ $KEYSEAL_CHAIN_LEGACY == yes ]] || keyseal_legacy_present; then legacy=yes exit2+=(legacy_loader_present); fi
152
- if [[ $KEYSEAL_CHAIN_LEGACY == no ]]; then
153
- case $KEYSEAL_LOADER_STATE in
154
- outdated) exit2+=(loader_outdated) ;;
155
- unrecorded) exit2+=(loader_unrecorded) ;;
212
+ # 2 chain, including the slot's loader version
213
+ if keyseal_chain_check "$slot" "$here"; then ok_chain_i=true detail_chain_i=""
214
+ else ok_chain_i=false detail_chain_i=${KEYSEAL_CHAIN_PROBLEMS%$'\n'}
215
+ fi
216
+ legacy_i=no
217
+ if [[ $KEYSEAL_CHAIN_LEGACY == yes ]] || keyseal_legacy_present; then legacy_i=yes exit2_i+=(legacy_loader_present); fi
218
+ if [[ $KEYSEAL_CHAIN_LEGACY == no ]]; then
219
+ case $KEYSEAL_LOADER_STATE in
220
+ outdated) exit2_i+=(loader_outdated) ;;
221
+ unrecorded) exit2_i+=(loader_unrecorded) ;;
222
+ esac
223
+ fi
224
+
225
+ # 3 units
226
+ sub=$("$SYSTEMCTL" --user show -p SubState "$load_unit" 2>/dev/null || true)
227
+ if [[ $(state is-enabled "$base_unit") == enabled && $(state is-enabled "$load_unit") == enabled &&
228
+ $(state is-active "$base_unit") == active && $(state is-active "$load_unit") == active && $sub == SubState=exited ]]; then
229
+ ok_units_i=true detail_units_i=""
230
+ else
231
+ ok_units_i=false detail_units_i="base $(state is-enabled "$base_unit")/$(state is-active "$base_unit"), loader $(state is-enabled "$load_unit")/$(state is-active "$load_unit") ${sub#SubState=}"
232
+ fi
233
+
234
+ # 4 agent: exactly one identity, and it is the pin. `empty` is told apart from a wrong or
235
+ # extra key: an empty agent can be the CONSEQUENCE of an exit-2 cause (below).
236
+ agent_empty=no
237
+ if [[ -n $pin ]] && keyseal_agent_holds_exactly "$slot" "$pin"; then ok_agent_i=true detail_agent_i=""
238
+ else
239
+ held=$(keyseal_agent_keys "$slot" 2>/dev/null) || held="?"
240
+ if [[ -z $held ]]; then agent_empty=yes; fi
241
+ nheld=$(printf '%s' "$held" | "$KEYSEAL_BIN_GREP" -c . || true)
242
+ [[ $held == "?" ]] && nheld=
243
+ ok_agent_i=false detail_agent_i="the slot agent holds ${nheld:-an unreadable set of} identities, not exactly the pin"
244
+ fi
245
+
246
+ # 5 linger (shared, host-level), and capability for the measured mode
247
+ [[ $linger == yes ]] || exit2_i+=(linger_off)
248
+ upgrade_i=no
249
+ case $mode in
250
+ sealed) [[ $outcome == capable ]] || exit2_i+=("capability_$outcome") ;;
251
+ unsealed)
252
+ if [[ $outcome == capable ]]; then upgrade_i=yes exit2_i+=(upgrade_available)
253
+ elif [[ $outcome != incapable ]]; then exit2_i+=("capability_$outcome")
254
+ fi
255
+ ;;
156
256
  esac
157
- fi
158
257
 
159
- # 3 units
160
- state() { "$SYSTEMCTL" --user "$1" "$2" 2>/dev/null || true; }
161
- sub=$("$SYSTEMCTL" --user show -p SubState "$load_unit" 2>/dev/null || true)
162
- if [[ $(state is-enabled "$base_unit") == enabled && $(state is-enabled "$load_unit") == enabled &&
163
- $(state is-active "$base_unit") == active && $(state is-active "$load_unit") == active && $sub == SubState=exited ]]; then
164
- ok[units]=true detail[units]=""
165
- else
166
- ok[units]=false detail[units]="base $(state is-enabled "$base_unit")/$(state is-active "$base_unit"), loader $(state is-enabled "$load_unit")/$(state is-active "$load_unit") ${sub#SubState=}"
167
- fi
258
+ pin_sha=null blob_sha=null
259
+ [[ -n $pin ]] && pin_sha=$(keyseal_json_str "$(keyseal_sha256_str "$pin")")
260
+ [[ -f $cred ]] && blob_sha=$(keyseal_json_str "$(keyseal_sha256_file "$cred")")
168
261
 
169
- # 4 agent: exactly one identity, and it is the pin. `empty` is told apart from a wrong or
170
- # extra key: an empty agent can be the CONSEQUENCE of an exit-2 cause (below).
171
- agent_empty=no
172
- if [[ -n $pin ]] && keyseal_agent_holds_exactly "$slot" "$pin"; then ok[agent]=true detail[agent]=""
173
- else
174
- held=$(keyseal_agent_keys "$slot" 2>/dev/null) || held="?"
175
- if [[ -z $held ]]; then agent_empty=yes; fi
176
- n=$(printf '%s' "$held" | "$KEYSEAL_BIN_GREP" -c . || true)
177
- [[ $held == "?" ]] && n=
178
- ok[agent]=false detail[agent]="the slot agent holds ${n:-an unreadable set of} identities, not exactly the pin"
179
- fi
262
+ # 6 scan setup: the unsealed slot's own key file is the one exemption. Judge the cache now (IF-
263
+ # 552-2); the walk, shared across every slot needing one, runs once after this loop.
264
+ exempt=
265
+ [[ $mode == unsealed ]] && exempt=$key
266
+ exempt_j=$(if [[ -n $exempt ]]; then keyseal_json_str "$(keyseal_sha256_str "$exempt")"; else printf null; fi)
267
+ KEYSEAL_INPUT=(
268
+ [seam]=$seam_j
269
+ [uid]=$uid_j
270
+ [machine_id]=$machine_id_j
271
+ [slot]=$(keyseal_json_str "$slot")
272
+ [mode]=$(keyseal_json_str "$mode")
273
+ [exempt]=$exempt_j
274
+ [pin_sha256]=$pin_sha
275
+ [blob_sha256]=$blob_sha
276
+ [scope_sha256]=$scope_sha_j
277
+ [scanner_sha256]=$scanner_sha_j
278
+ )
279
+ keyseal_cache_read "$slot" "$scan_cache"
180
280
 
181
- # 5 linger, and capability for the measured mode
182
- linger=$(keyseal_linger)
183
- if [[ $linger == yes ]]; then ok[linger]=true detail[linger]=""; else ok[linger]=false detail[linger]="linger is $linger"; exit2+=(linger_off); fi
184
- upgrade=no
185
- case $mode in
186
- sealed) [[ $outcome == capable ]] || exit2+=("capability_$outcome") ;;
187
- unsealed)
188
- if [[ $outcome == capable ]]; then upgrade=yes exit2+=(upgrade_available)
189
- elif [[ $outcome != incapable ]]; then exit2+=("capability_$outcome")
190
- fi
191
- ;;
192
- esac
281
+ needs_scan=no
282
+ if [[ $KEYSEAL_CACHE_STATE != hit && $scan_cache != only ]]; then needs_scan=yes; fi
283
+ [[ $KEYSEAL_CACHE_STATE != hit && $scan_cache == only ]] && exit2_i+=(scan_not_cached)
193
284
 
194
- pin_sha=null blob_sha=null
195
- [[ -n $pin ]] && pin_sha=$(keyseal_json_str "$(keyseal_sha256_str "$pin")")
196
- [[ -f $cred ]] && blob_sha=$(keyseal_json_str "$(keyseal_sha256_file "$cred")")
197
- machine_id=$(keyseal_machine_id) || machine_id=
285
+ A_MODE[i]=$mode A_PIN[i]=$pin A_EXEMPT[i]=$exempt A_EXEMPT_J[i]=$exempt_j
286
+ A_PINSHA_J[i]=$pin_sha A_BLOBSHA_J[i]=$blob_sha
287
+ A_OK_COMMITTED[i]=$ok_committed_i A_DET_COMMITTED[i]=$detail_committed_i
288
+ A_OK_CHAIN[i]=$ok_chain_i A_DET_CHAIN[i]=$detail_chain_i
289
+ A_CHAIN_LEGACY[i]=$KEYSEAL_CHAIN_LEGACY A_LEGACY[i]=$legacy_i
290
+ A_LOADER_STATE[i]=$KEYSEAL_LOADER_STATE A_LOADER_REC[i]=$KEYSEAL_LOADER_RECORDED A_LOADER_RUN[i]=$KEYSEAL_LOADER_RUNNING
291
+ A_OK_UNITS[i]=$ok_units_i A_DET_UNITS[i]=$detail_units_i
292
+ A_OK_AGENT[i]=$ok_agent_i A_DET_AGENT[i]=$detail_agent_i A_AGENT_EMPTY[i]=$agent_empty
293
+ A_OPTIN_J[i]=$optin_json A_UPGRADE[i]=$upgrade_i
294
+ A_EXIT2[i]="${exit2_i[*]-}"
295
+ A_CACHE_TOKEN[i]=$KEYSEAL_CACHE_TOKEN A_CACHE_STATE[i]=$KEYSEAL_CACHE_STATE A_CACHE_REASON[i]=$KEYSEAL_CACHE_REASON
296
+ A_CACHE_SCAN[i]=$KEYSEAL_CACHE_SCAN A_CACHE_SCANNED_AT[i]=$KEYSEAL_CACHE_SCANNED_AT
297
+ A_CACHE_DURATION[i]=$KEYSEAL_CACHE_DURATION A_CACHE_LAST[i]=$KEYSEAL_CACHE_LAST
298
+ A_NEEDS_SCAN[i]=$needs_scan
299
+ done
198
300
 
199
- # 6 scan: the unsealed slot's own key file is the one exemption. IF-552-2: the inputs a cached
200
- # result is valid for, the cache read (under its lock), then a scan unless a valid cache serves.
201
- exempt=
202
- [[ $mode == unsealed ]] && exempt=$key
203
- stale_temp=$(keyseal_scan_stale_temp)
204
- keyseal_scan_roots
205
- KEYSEAL_INPUT=(
206
- [seam]=$(keyseal_seam_json)
207
- [uid]=$(keyseal_uid)
208
- [machine_id]=$(str_or_null "$machine_id")
209
- [slot]=$(keyseal_json_str "$slot")
210
- [mode]=$(keyseal_json_str "$mode")
211
- [exempt]=$(if [[ -n $exempt ]]; then keyseal_json_str "$(keyseal_sha256_str "$exempt")"; else printf null; fi)
212
- [pin_sha256]=$pin_sha
213
- [blob_sha256]=$blob_sha
214
- [scope_sha256]=$(keyseal_json_str "$(keyseal_sha256_str "$(keyseal_scan_scope_json)")")
215
- [scanner_sha256]=$(str_or_null "$(keyseal_files_sha "$here" lib.sh status.sh || true)")
216
- )
217
- keyseal_cache_read "$slot" "$scan_cache"
218
- cache_read="$KEYSEAL_CACHE_STATE:$KEYSEAL_CACHE_REASON"
219
- cache_last=null
220
- [[ -n $KEYSEAL_CACHE_LAST ]] && cache_last=$(keyseal_json_str "$(keyseal_iso "$KEYSEAL_CACHE_LAST")")
221
- scan_source=none scan_scanned_at=null scan_age=null scan_duration=null scan_core=
222
- scan_judged=yes scan_timed_out=no
223
- if [[ $KEYSEAL_CACHE_STATE == hit ]]; then
224
- scan_source=cached scan_core=$KEYSEAL_CACHE_SCAN
225
- scan_now=$(keyseal_now) || scan_now=$KEYSEAL_CACHE_SCANNED_AT
226
- scan_scanned_at=$(keyseal_json_str "$(keyseal_iso "$KEYSEAL_CACHE_SCANNED_AT")")
227
- scan_age=$((scan_now - KEYSEAL_CACHE_SCANNED_AT)) scan_duration=$KEYSEAL_CACHE_DURATION
228
- elif [[ $scan_cache == only ]]; then
229
- scan_judged=no
230
- detail[scan]="not judged: no valid scan cache ($KEYSEAL_CACHE_STATE:$KEYSEAL_CACHE_REASON)"
231
- exit2+=(scan_not_cached)
232
- else
301
+ # ---- the ONE shared walk, for every slot phase A marked needs_scan=yes --------------------------
302
+
303
+ scan_idx=()
304
+ for i in "${!slots[@]}"; do
305
+ [[ ${A_NEEDS_SCAN[i]} == yes ]] && scan_idx+=("$i")
306
+ done
307
+
308
+ scan_start=0 scan_duration=0
309
+ if ((${#scan_idx[@]})); then
310
+ KEYSEAL_SCAN_PINS=() KEYSEAL_SCAN_EXEMPTS=()
311
+ for k in "${!scan_idx[@]}"; do
312
+ i=${scan_idx[k]}
313
+ KEYSEAL_SCAN_PINS[k]=${A_PIN[i]}
314
+ KEYSEAL_SCAN_EXEMPTS[k]=${A_EXEMPT[i]}
315
+ done
233
316
  # scanned_at is the scan's START: a file created after it may be missed.
234
317
  scan_start=$(keyseal_now) || scan_start=0
235
- printf 'status: scan: starting (%s:%s)\n' "$KEYSEAL_CACHE_STATE" "$KEYSEAL_CACHE_REASON" >&2
236
- keyseal_scan_run "$pin" "$exempt" "$scan_timeout"
318
+ for k in "${!scan_idx[@]}"; do
319
+ i=${scan_idx[k]}
320
+ if ((n > 1)); then
321
+ printf 'status: scan: starting (%s:%s) slot=%s\n' "${A_CACHE_STATE[i]}" "${A_CACHE_REASON[i]}" "${slots[i]}" >&2
322
+ else
323
+ printf 'status: scan: starting (%s:%s)\n' "${A_CACHE_STATE[i]}" "${A_CACHE_REASON[i]}" >&2
324
+ fi
325
+ done
326
+ keyseal_scan_run "$scan_timeout"
237
327
  scan_end=$(keyseal_now) || scan_end=$scan_start
238
328
  scan_duration=$((scan_end - scan_start))
239
329
  ((scan_duration >= 0)) || scan_duration=0
240
- case $KEYSEAL_SCAN_OUTCOME in
241
- ok)
242
- scan_source=fresh scan_core=$(keyseal_scan_core_json)
243
- scan_scanned_at=$(keyseal_json_str "$(keyseal_iso "$scan_start")") scan_age=$scan_duration
244
- ;;
245
- timed_out)
246
- scan_judged=no scan_timed_out=yes
247
- detail[scan]="not judged: the scan gave up after $scan_timeout s (scan_timed_out)"
248
- exit2+=(scan_timed_out)
249
- KEYSEAL_CACHE_STATE=not_written KEYSEAL_CACHE_REASON=timed_out
250
- ;;
251
- *)
252
- detail[scan]="the scan did not run to completion"
253
- KEYSEAL_CACHE_STATE=not_written KEYSEAL_CACHE_REASON=incomplete
254
- ;;
255
- esac
256
330
  fi
257
- if [[ -n $scan_core ]]; then
258
- if [[ $scan_core == *'"plaintext_matches":[]'* ]]; then ok[scan]=true detail[scan]=""
259
- else ok[scan]=false detail[scan]="plaintext copy of the slot key found"
331
+
332
+ # ---- phase B: interpret the scan, commit the cache and emit, per slot ---------------------------
333
+
334
+ saw1=no saw2=no
335
+ for i in "${!slots[@]}"; do
336
+ slot=${slots[i]} harness=${harness_of[i]} mode=${A_MODE[i]}
337
+ read -ra exit2_i <<<"${A_EXIT2[i]:-}"
338
+ cache_read_i="${A_CACHE_STATE[i]}:${A_CACHE_REASON[i]}"
339
+ cache_state=${A_CACHE_STATE[i]} cache_reason=${A_CACHE_REASON[i]}
340
+ base_unit=$KEYSEAL_BASE_UNIT@$slot.service
341
+ load_unit=$KEYSEAL_LOAD_UNIT@$slot.service
342
+
343
+ KEYSEAL_INPUT=(
344
+ [seam]=$seam_j
345
+ [uid]=$uid_j
346
+ [machine_id]=$machine_id_j
347
+ [slot]=$(keyseal_json_str "$slot")
348
+ [mode]=$(keyseal_json_str "$mode")
349
+ [exempt]=${A_EXEMPT_J[i]}
350
+ [pin_sha256]=${A_PINSHA_J[i]}
351
+ [blob_sha256]=${A_BLOBSHA_J[i]}
352
+ [scope_sha256]=$scope_sha_j
353
+ [scanner_sha256]=$scanner_sha_j
354
+ )
355
+
356
+ scan_source=none scan_scanned_at=null scan_age=null scan_dur=null scan_core=
357
+ scan_judged=yes scan_timed_out=no detail_scan=""
358
+ if [[ $cache_state == hit ]]; then
359
+ scan_source=cached scan_core=${A_CACHE_SCAN[i]}
360
+ scan_now=$(keyseal_now) || scan_now=${A_CACHE_SCANNED_AT[i]}
361
+ scan_scanned_at=$(keyseal_json_str "$(keyseal_iso "${A_CACHE_SCANNED_AT[i]}")")
362
+ scan_age=$((scan_now - A_CACHE_SCANNED_AT[i]))
363
+ scan_dur=${A_CACHE_DURATION[i]}
364
+ elif [[ ${A_NEEDS_SCAN[i]} == no ]]; then
365
+ scan_judged=no
366
+ detail_scan="not judged: no valid scan cache ($cache_state:$cache_reason)"
367
+ else
368
+ case $KEYSEAL_SCAN_OUTCOME in
369
+ ok)
370
+ k=-1
371
+ for kk in "${!scan_idx[@]}"; do [[ ${scan_idx[kk]} == "$i" ]] && k=$kk && break; done
372
+ scan_source=fresh scan_core=$(keyseal_scan_core_json "$k")
373
+ scan_scanned_at=$(keyseal_json_str "$(keyseal_iso "$scan_start")") scan_age=$scan_duration
374
+ scan_dur=$scan_duration
375
+ ;;
376
+ timed_out)
377
+ scan_judged=no scan_timed_out=yes
378
+ detail_scan="not judged: the scan gave up after $scan_timeout s (scan_timed_out)"
379
+ exit2_i+=(scan_timed_out)
380
+ cache_state=not_written cache_reason=timed_out
381
+ ;;
382
+ *)
383
+ detail_scan="the scan did not run to completion"
384
+ cache_state=not_written cache_reason=incomplete
385
+ ;;
386
+ esac
260
387
  fi
261
- else
262
- ok[scan]=false
263
- fi
264
- # A match is never cached: it writes a tombstone. Only a clean, completed, fresh scan commits.
265
- if [[ $scan_source == fresh ]]; then
266
- if [[ ${ok[scan]} == true ]]; then keyseal_cache_commit_clean "$slot" "$scan_start" "$scan_duration" "$scan_core"
267
- else keyseal_cache_commit_tombstone "$slot" "$harness"
388
+ if [[ -n $scan_core ]]; then
389
+ if [[ $scan_core == *'"plaintext_matches":[]'* ]]; then ok_scan=true detail_scan=""
390
+ else ok_scan=false detail_scan="plaintext copy of the slot key found"
391
+ fi
392
+ else
393
+ ok_scan=false
394
+ fi
395
+ # A match is never cached: it writes a tombstone. Only a clean, completed, fresh scan commits.
396
+ if [[ $scan_source == fresh ]]; then
397
+ KEYSEAL_CACHE_TOKEN=${A_CACHE_TOKEN[i]}
398
+ if [[ $ok_scan == true ]]; then keyseal_cache_commit_clean "$slot" "$scan_start" "$scan_duration" "$scan_core"
399
+ else keyseal_cache_commit_tombstone "$slot" "$harness"
400
+ fi
401
+ cache_state=$KEYSEAL_CACHE_STATE cache_reason=$KEYSEAL_CACHE_REASON
268
402
  fi
269
- fi
270
- if [[ $scan_source == fresh ]]; then
271
- n_matches=0 n_candidates=0
272
- [[ $scan_core =~ \"candidates\":([0-9]+) ]] && n_candidates=${BASH_REMATCH[1]}
273
- [[ ${ok[scan]} == true ]] || n_matches=$(printf '%s' "$scan_core" | keyseal_node count-matches 2>/dev/null || printf '?')
274
- printf 'status: scan: done in %s s (%s folders, %s candidates, %s matches)\n' "$scan_duration" "$KEYSEAL_SCAN_FOLDERS" "$n_candidates" "$n_matches" >&2
275
- elif [[ $scan_source == none && $scan_judged == yes || $scan_timed_out == yes ]]; then
276
- printf 'status: scan: done in %s s (%s folders): %s\n' "$scan_duration" "$KEYSEAL_SCAN_FOLDERS" "${detail[scan]}" >&2
277
- fi
278
- scan_meta="\"source\":\"$scan_source\",\"scanned_at\":$scan_scanned_at,\"age_s\":$scan_age,\"duration_s\":$scan_duration,\"inputs\":$(keyseal_cache_inputs_json),\"stale_temp\":$stale_temp,\"cache\":{\"mode\":\"$scan_cache\",\"state\":\"$KEYSEAL_CACHE_STATE\",\"reason\":$(str_or_null "$KEYSEAL_CACHE_REASON"),\"read\":$(keyseal_json_str "$cache_read"),\"last_scanned_at\":$cache_last}"
279
- # A scan that was not judged still names the scope it would have scanned.
280
- if [[ -n $scan_core ]]; then scan=$(keyseal_scan_with_headers "${scan_core%\}},$scan_meta}")
281
- else scan="{\"roots\":$(keyseal_json_list ${KEYSEAL_ROOTS[@]+"${KEYSEAL_ROOTS[@]}"}),\"roots_skipped\":$KEYSEAL_ROOTS_SKIPPED_JSON,$scan_meta}"
282
- fi
283
403
 
284
- # Nothing committed is 2 by the provenance table: there is no unit, agent or chain to judge.
285
- hard=(committed chain units agent scan)
286
- [[ ${exit2[*]} == *nothing_committed* ]] && hard=(committed scan)
287
- # A scan that was not judged (no valid cache under `only`, or the deadline) is 2, never 1.
288
- if [[ $scan_judged == no ]]; then
289
- judged=()
290
- for c in "${hard[@]}"; do [[ $c == scan ]] || judged+=("$c"); done
291
- hard=("${judged[@]}")
292
- fi
293
- # committed, chain and scan are always hard. A loader that could not run, or an agent it left
294
- # EMPTY, is the consequence of a sealed slot's capability loss or an undeterminable blob, so it
295
- # is 2 while one is present (review round 1, Opus B2). A wrong or extra key stays 1.
296
- soft=no
297
- # Only causes that EXPLAIN an empty agent (review round 2, Opus N1): a sealed slot whose sealing
298
- # is gone, or a blob that could not be read. Linger off does not (status needs a running
299
- # manager), and an unsealed loader never uses sealing.
300
- for r in "${exit2[@]}"; do
301
- case $r in
302
- undeterminable) soft=yes ;;
303
- # error or incapable: the decrypt itself is gone. incapable-for-keyslot is linger or the ssh
304
- # tools, which alone do not stop a decrypt; but probe.sh reports a lost systemd-creds on a
305
- # no-linger host as incapable-for-keyslot too, so it counts when the round trip failed.
306
- capability_error | capability_incapable) [[ $mode == sealed ]] && soft=yes ;;
307
- capability_incapable-for-keyslot) [[ $mode == sealed && $round_trip != equal ]] && soft=yes ;;
308
- esac
309
- done
310
- if [[ $soft == yes ]]; then
311
- still_hard=()
404
+ if [[ $scan_source == fresh ]]; then
405
+ n_matches=0 n_candidates=0
406
+ [[ $scan_core =~ \"candidates\":([0-9]+) ]] && n_candidates=${BASH_REMATCH[1]}
407
+ [[ $ok_scan == true ]] || n_matches=$(printf '%s' "$scan_core" | keyseal_node count-matches 2>/dev/null || printf '?')
408
+ if ((n > 1)); then
409
+ printf 'status: scan: done in %s s (%s folders, %s candidates, %s matches) slot=%s\n' "$scan_duration" "$KEYSEAL_SCAN_FOLDERS" "$n_candidates" "$n_matches" "$slot" >&2
410
+ else
411
+ printf 'status: scan: done in %s s (%s folders, %s candidates, %s matches)\n' "$scan_duration" "$KEYSEAL_SCAN_FOLDERS" "$n_candidates" "$n_matches" >&2
412
+ fi
413
+ elif [[ $scan_source == none && $scan_judged == yes || $scan_timed_out == yes ]]; then
414
+ if ((n > 1)); then
415
+ printf 'status: scan: done in %s s (%s folders) slot=%s: %s\n' "$scan_duration" "$KEYSEAL_SCAN_FOLDERS" "$slot" "$detail_scan" >&2
416
+ else
417
+ printf 'status: scan: done in %s s (%s folders): %s\n' "$scan_duration" "$KEYSEAL_SCAN_FOLDERS" "$detail_scan" >&2
418
+ fi
419
+ fi
420
+
421
+ cache_last=null
422
+ [[ -n ${A_CACHE_LAST[i]} ]] && cache_last=$(keyseal_json_str "$(keyseal_iso "${A_CACHE_LAST[i]}")")
423
+ scan_meta="\"source\":\"$scan_source\",\"scanned_at\":$scan_scanned_at,\"age_s\":$scan_age,\"duration_s\":$scan_dur,\"inputs\":$(keyseal_cache_inputs_json),\"stale_temp\":$stale_temp,\"cache\":{\"mode\":\"$scan_cache\",\"state\":\"$cache_state\",\"reason\":$(str_or_null "$cache_reason"),\"read\":$(keyseal_json_str "$cache_read_i"),\"last_scanned_at\":$cache_last}"
424
+ if [[ -n $scan_core ]]; then scan_json=$(keyseal_scan_with_headers "${scan_core%\}},$scan_meta}")
425
+ else scan_json="{\"roots\":$(keyseal_json_list ${KEYSEAL_ROOTS[@]+"${KEYSEAL_ROOTS[@]}"}),\"roots_skipped\":$KEYSEAL_ROOTS_SKIPPED_JSON,$scan_meta}"
426
+ fi
427
+
428
+ # Nothing committed is 2 by the provenance table: there is no unit, agent or chain to judge.
429
+ hard=(committed chain units agent scan)
430
+ [[ "${exit2_i[*]-}" == *nothing_committed* ]] && hard=(committed scan)
431
+ # A scan that was not judged (no valid cache under `only`, or the deadline) is 2, never 1.
432
+ if [[ $scan_judged == no ]]; then
433
+ judged=()
434
+ for c in "${hard[@]}"; do [[ $c == scan ]] || judged+=("$c"); done
435
+ hard=("${judged[@]}")
436
+ fi
437
+ soft=no
438
+ for r in "${exit2_i[@]}"; do
439
+ case $r in
440
+ undeterminable) soft=yes ;;
441
+ capability_error | capability_incapable) [[ $mode == sealed ]] && soft=yes ;;
442
+ capability_incapable-for-keyslot) [[ $mode == sealed && $round_trip != equal ]] && soft=yes ;;
443
+ esac
444
+ done
445
+ if [[ $soft == yes ]]; then
446
+ still_hard=()
447
+ for c in "${hard[@]}"; do
448
+ case $c in
449
+ agent) [[ ${A_OK_AGENT[i]} == true || ${A_AGENT_EMPTY[i]} == yes ]] || still_hard+=(agent) ;;
450
+ units)
451
+ [[ ${A_OK_UNITS[i]} == true ]] ||
452
+ { [[ $(state is-enabled "$base_unit") == enabled && $(state is-enabled "$load_unit") == enabled ]] || still_hard+=(units); }
453
+ ;;
454
+ *) still_hard+=("$c") ;;
455
+ esac
456
+ done
457
+ hard=("${still_hard[@]}")
458
+ fi
459
+ rc=0
312
460
  for c in "${hard[@]}"; do
313
461
  case $c in
314
- agent) [[ ${ok[agent]} == true || $agent_empty == yes ]] || still_hard+=(agent) ;;
315
- units)
316
- [[ ${ok[units]} == true ]] ||
317
- { [[ $(state is-enabled "$base_unit") == enabled && $(state is-enabled "$load_unit") == enabled ]] || still_hard+=(units); }
318
- ;;
319
- *) still_hard+=("$c") ;;
462
+ committed) v=${A_OK_COMMITTED[i]} ;;
463
+ chain) v=${A_OK_CHAIN[i]} ;;
464
+ units) v=${A_OK_UNITS[i]} ;;
465
+ agent) v=${A_OK_AGENT[i]} ;;
466
+ scan) v=$ok_scan ;;
320
467
  esac
468
+ [[ $v == true ]] || rc=1
321
469
  done
322
- hard=("${still_hard[@]}")
323
- fi
324
- rc=0
325
- for c in "${hard[@]}"; do [[ ${ok[$c]} == true ]] || rc=1; done
326
- if ((rc == 0 && ${#exit2[@]})); then rc=2; fi
470
+ if ((rc == 0 && ${#exit2_i[@]})); then rc=2; fi
471
+ ((rc == 1)) && saw1=yes
472
+ ((rc == 2)) && saw2=yes
327
473
 
328
- boot_id=$(</proc/sys/kernel/random/boot_id)
329
- bool() { if [[ $1 == yes ]]; then printf true; else printf false; fi; }
330
- reasons=
331
- for r in "${exit2[@]}"; do reasons+="${reasons:+,}$(keyseal_json_str "$r")"; done
332
- outdated=no
333
- [[ ${exit2[*]} == *loader_outdated* ]] && outdated=yes
334
-
335
- if [[ $json == yes ]]; then
336
- checks=
337
- for c in committed chain units agent linger scan; do
338
- checks+="${checks:+,}\"$c\":{\"ok\":${ok[$c]},\"detail\":$(keyseal_json_str "${detail[$c]}")}"
339
- done
340
- printf '{"tool":"status","seam":%s,"host":%s,"uid":%s,"machine_id":%s,"harness":%s,"slot":%s,"mode":%s,"boot_id":%s,"pin_sha256":%s,"blob_sha256":%s,"capability_probe_exit":%s,"probe_outcome":%s,"optin":%s,"loader":{"recorded_sha256":%s,"running_sha256":%s,"state":%s},"upgrade_available":%s,"loader_outdated":%s,"legacy_loader_present":%s,"exit2_reasons":[%s],"checks":{%s},"scan":%s,"exit":%s}\n' \
341
- "$(keyseal_seam_json)" "$(keyseal_json_str "$(uname -n)")" "$(keyseal_uid)" "$(str_or_null "$machine_id")" \
342
- "$(keyseal_json_str "$harness")" "$(keyseal_json_str "$slot")" "$(keyseal_json_str "$mode")" \
343
- "$(keyseal_json_str "$boot_id")" "$pin_sha" "$blob_sha" "$probe_rc" "$(keyseal_json_str "$outcome")" "$optin_json" \
344
- "$(str_or_null "$KEYSEAL_LOADER_RECORDED")" "$(str_or_null "$KEYSEAL_LOADER_RUNNING")" \
345
- "$(keyseal_json_str "$([[ $KEYSEAL_CHAIN_LEGACY == yes ]] && echo legacy || echo "$KEYSEAL_LOADER_STATE")")" \
346
- "$(bool "$upgrade")" "$(bool "$outdated")" "$(bool "$legacy")" "$reasons" "$checks" "$scan" "$rc"
347
- else
348
- for c in committed chain units agent linger scan; do
349
- printf '%-9s %s %s\n' "$c" "$([[ ${ok[$c]} == true ]] && echo ok || echo FAIL)" "${detail[$c]}"
350
- done
351
- printf 'mode %s\n' "$mode"
352
- ((${#exit2[@]})) && printf 'exit 2 %s\n' "${exit2[*]}"
353
- printf 'status: exit %s (seam=%s)\n' "$rc" "$(keyseal_seam_json)"
474
+ outdated_i=no
475
+ [[ "${exit2_i[*]-}" == *loader_outdated* ]] && outdated_i=yes
476
+ reasons=
477
+ for r in "${exit2_i[@]}"; do reasons+="${reasons:+,}$(keyseal_json_str "$r")"; done
478
+
479
+ if [[ $json == yes ]]; then
480
+ checks=
481
+ for c in committed chain units agent linger scan; do
482
+ case $c in
483
+ committed) cok=${A_OK_COMMITTED[i]} cdet=${A_DET_COMMITTED[i]} ;;
484
+ chain) cok=${A_OK_CHAIN[i]} cdet=${A_DET_CHAIN[i]} ;;
485
+ units) cok=${A_OK_UNITS[i]} cdet=${A_DET_UNITS[i]} ;;
486
+ agent) cok=${A_OK_AGENT[i]} cdet=${A_DET_AGENT[i]} ;;
487
+ linger) cok=$ok_linger cdet=$detail_linger ;;
488
+ scan) cok=$ok_scan cdet=$detail_scan ;;
489
+ esac
490
+ checks+="${checks:+,}\"$c\":{\"ok\":$cok,\"detail\":$(keyseal_json_str "$cdet")}"
491
+ done
492
+ printf '{"tool":"status","seam":%s,"host":%s,"uid":%s,"machine_id":%s,"harness":%s,"slot":%s,"mode":%s,"boot_id":%s,"pin_sha256":%s,"blob_sha256":%s,"capability_probe_exit":%s,"probe_outcome":%s,"optin":%s,"loader":{"recorded_sha256":%s,"running_sha256":%s,"state":%s},"upgrade_available":%s,"loader_outdated":%s,"legacy_loader_present":%s,"exit2_reasons":[%s],"checks":{%s},"scan":%s,"exit":%s}\n' \
493
+ "$seam_j" "$host_j" "$uid_j" "$machine_id_j" \
494
+ "$(keyseal_json_str "$harness")" "$(keyseal_json_str "$slot")" "$(keyseal_json_str "$mode")" \
495
+ "$boot_id_j" "${A_PINSHA_J[i]}" "${A_BLOBSHA_J[i]}" "$probe_rc" "$(keyseal_json_str "$outcome")" "${A_OPTIN_J[i]}" \
496
+ "$(str_or_null "${A_LOADER_REC[i]}")" "$(str_or_null "${A_LOADER_RUN[i]}")" \
497
+ "$(keyseal_json_str "$([[ ${A_CHAIN_LEGACY[i]} == yes ]] && echo legacy || echo "${A_LOADER_STATE[i]}")")" \
498
+ "$(bool "${A_UPGRADE[i]}")" "$(bool "$outdated_i")" "$(bool "${A_LEGACY[i]}")" "$reasons" "$checks" "$scan_json" "$rc"
499
+ else
500
+ for c in committed chain units agent linger scan; do
501
+ case $c in
502
+ committed) cok=${A_OK_COMMITTED[i]} cdet=${A_DET_COMMITTED[i]} ;;
503
+ chain) cok=${A_OK_CHAIN[i]} cdet=${A_DET_CHAIN[i]} ;;
504
+ units) cok=${A_OK_UNITS[i]} cdet=${A_DET_UNITS[i]} ;;
505
+ agent) cok=${A_OK_AGENT[i]} cdet=${A_DET_AGENT[i]} ;;
506
+ linger) cok=$ok_linger cdet=$detail_linger ;;
507
+ scan) cok=$ok_scan cdet=$detail_scan ;;
508
+ esac
509
+ printf '%-9s %s %s\n' "$c" "$([[ $cok == true ]] && echo ok || echo FAIL)" "$cdet"
510
+ done
511
+ printf 'mode %s\n' "$mode"
512
+ ((${#exit2_i[@]})) && printf 'exit 2 %s\n' "${exit2_i[*]}"
513
+ printf 'status: exit %s (seam=%s)\n' "$rc" "$seam_j"
514
+ fi
515
+ done
516
+
517
+ if [[ $saw1 == yes ]]; then exit 1
518
+ elif [[ $saw2 == yes ]]; then exit 2
519
+ else exit 0
354
520
  fi
355
- exit "$rc"