@consiliency/agent-board-cli 1.27.1 → 1.29.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.
package/keyslot/lib.sh CHANGED
@@ -38,10 +38,33 @@ KEYSEAL_LEGACY_SET_SHA=8e7b771be5a461e4d6b032f518e4c790d91b315011ae2d96eda44bc69
38
38
  # pre-migration layout (legacy_loader_present, 2), not as a chain violation.
39
39
  KEYSEAL_LEGACY_LOAD_EXEC='%h/.local/libexec/agent-board/keyslot-load.sh %i'
40
40
 
41
- # IF-0-KEYSEAL-5: the scan scope, exactly. Roots are resolved at scan time.
41
+ # IF-0-KEYSEAL-5 as amended by IF-552-1 (#552): the scan scope, exactly. Roots are resolved at
42
+ # scan time. The prune entries are ONE source: the find expression, `pruned` and `pruned_dirs`
43
+ # are all derived from these two arrays, in this order (names, then paths).
42
44
  KEYSEAL_SCAN_MAX_BYTES=1048576
43
- KEYSEAL_SCAN_PRUNE_NAMES=(node_modules .git .cache .npm)
44
- KEYSEAL_SCAN_PRUNE_PATHS=(.local/share/pnpm)
45
+ KEYSEAL_SCAN_PRUNE_NAMES=(node_modules .git .cache .npm
46
+ site-packages dist-packages __pycache__ .venv venv env .tox .nox
47
+ pkgs
48
+ .rustup target)
49
+ KEYSEAL_SCAN_PRUNE_PATHS=(.local/share/pnpm .pyenv/versions go/pkg/mod .cargo/registry .cargo/git
50
+ .ollama/models .local/share/containers .local/share/docker)
51
+ # IF-552-2: the scan cache. Wall-clock bounds only; ordering never uses a timestamp.
52
+ KEYSEAL_SCAN_CACHE_FORMAT=agent-board-keyslot-scan/1
53
+ KEYSEAL_SCAN_CACHE_TTL_S=86400
54
+ KEYSEAL_SCAN_CACHE_FUTURE_S=300
55
+ KEYSEAL_SCAN_CACHE_INPUTS=(seam uid machine_id slot mode exempt pin_sha256 blob_sha256 scope_sha256 scanner_sha256)
56
+ # The clean read and write lock wait (a write retries once), and a tombstone's longer wait.
57
+ KEYSEAL_SCAN_LOCK_WAIT_S=10
58
+ KEYSEAL_SCAN_TOMBSTONE_WAIT_S=300
59
+ # The whole scan stage's default bound (status.sh --scan-timeout), and the progress cadence.
60
+ KEYSEAL_SCAN_TIMEOUT_S=1800
61
+ KEYSEAL_SCAN_PROGRESS_S=30
62
+ KEYSEAL_SCAN_LIVENESS_S=5
63
+ KEYSEAL_SCAN_KILL_AFTER_S=10
64
+ KEYSEAL_BIN_TIMEOUT=/usr/bin/timeout
65
+ KEYSEAL_BIN_DATE=/usr/bin/date
66
+ KEYSEAL_BIN_REALPATH=/usr/bin/realpath
67
+ KEYSEAL_BIN_MKFIFO=/usr/bin/mkfifo
45
68
  KEYSEAL_SCAN_HEADERS=('-----BEGIN OPENSSH PRIVATE KEY-----' '-----BEGIN PRIVATE KEY-----')
46
69
  # What the scan does not detect, by stable id (reported in every status JSON).
47
70
  KEYSEAL_SCAN_NOT_DETECTED=(pruned over_1mib not_owned re_encoded_or_encrypted outside_roots)
@@ -163,6 +186,8 @@ keyseal_enabled_slots() {
163
186
  s=${s%.service}
164
187
  [[ $s =~ $KEYSEAL_SLOT_RE && $link == "$dir/$KEYSEAL_BASE_UNIT@$s.service.wants/"* ]] && printf '%s\n' "$s"
165
188
  done
189
+ # Never the last link's test result: a caller under `set -e` must not stop on it (SC-21).
190
+ return 0
166
191
  }
167
192
 
168
193
  # Judge slot $1's loader against the running tooling in $2. Sets KEYSEAL_LOADER_STATE:
@@ -392,14 +417,26 @@ if (mode === "derive") {
392
417
  // IF-0-KEYSEAL-5: candidates (NUL-separated file on args[0]) -> JSON. args[3], when set,
393
418
  // is the ONE exempt path (A1: an opted-in unsealed slot's <slot>.key, 0600, this uid's). It
394
419
  // is matched by path, never by inode, and never enters `seen`: a hard link elsewhere is a copy.
420
+ // args[4] is the prune-index file (one `<i>` line per pruned directory, IF-552-1) and
421
+ // args[5..] the prune entries in report order: `pruned_dirs` counts each, 0 included.
395
422
  const pin = process.env.KEYSEAL_SCAN_PIN || "";
396
423
  const uid = Number(args[1]);
397
424
  const max = Number(args[2]);
398
425
  const exempt = args[3] || "";
399
- const raw = fs.readFileSync(args[0]).toString("utf8").split("\0").filter(Boolean);
426
+ const entries = args.slice(5);
427
+ const pruned_dirs = Object.fromEntries(entries.map((e) => [e, 0]));
428
+ for (const line of fs.readFileSync(args[4], "utf8").split("\n")) {
429
+ if (/^\d+$/.test(line) && Number(line) < entries.length) pruned_dirs[entries[Number(line)]]++;
430
+ }
431
+ // A test seam (bash sets it only while KEYSEAL_TEST_ROOT is set): wait per candidate.
432
+ const delay = Number(process.env.KEYSEAL_SCAN_DELAY_MS || 0);
433
+ const nap = (ms) => { if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
434
+ // A root named like a prune entry is started at `<root>/.` (keyseal_scan_start): report real paths.
435
+ const raw = fs.readFileSync(args[0]).toString("utf8").split("\0").filter(Boolean).map((p) => p.replace(/\/\.\//, "/"));
400
436
  const seen = new Set();
401
- const res = { candidates: 0, plaintext_matches: [], encrypted_skipped: [], unverifiable: [], exempt: [] };
437
+ const res = { candidates: 0, plaintext_matches: [], encrypted_skipped: [], unverifiable: [], exempt: [], pruned_dirs };
402
438
  for (const p of raw) {
439
+ nap(delay);
403
440
  let st;
404
441
  try { st = fs.lstatSync(p); } catch { continue; }
405
442
  if (!st.isFile() || st.uid !== uid || st.size > max) continue;
@@ -430,6 +467,34 @@ if (mode === "derive") {
430
467
  if (verdicts.has("unverifiable")) res.unverifiable.push(p);
431
468
  }
432
469
  out(JSON.stringify(res) + "\n");
470
+ } else if (mode === "count-matches") {
471
+ // A scan object on stdin -> how many plaintext matches it names (status.sh's done line).
472
+ out(`${JSON.parse(readStdin().toString("utf8")).plaintext_matches.length}\n`);
473
+ } else if (mode === "cacheparse") {
474
+ // IF-552-2, compute only (bash reads, judges and writes). args[0] is a scan-cache file, args[1]
475
+ // the format tag and args[2..] the input names. Prints `format` for anything that is not a
476
+ // well-formed cache file; else `clean <generation> <scanned_at> <duration_s>` or
477
+ // `tombstone <generation> <cleared_at>`, one `input <name> <json>` line per input, and for a
478
+ // clean file `scan <json>`. A valid generation is a safe integer, 1..2^53-1.
479
+ const [file, format, ...names] = args;
480
+ let o;
481
+ try { o = JSON.parse(fs.readFileSync(file, "utf8")); } catch { out("format\n"); process.exit(0); }
482
+ const bad = () => { out("format\n"); process.exit(0); };
483
+ if (!o || typeof o !== "object" || Array.isArray(o) || o.format !== format) bad();
484
+ const g = o.generation;
485
+ if (!(Number.isSafeInteger(g) && g >= 1)) bad();
486
+ const inputs = o.inputs;
487
+ if (!inputs || typeof inputs !== "object" || Array.isArray(inputs) || names.some((n) => !(n in inputs))) bad();
488
+ if ("cleared_at" in o) {
489
+ if (!Number.isSafeInteger(o.cleared_at)) bad();
490
+ out(`tombstone\t${g}\t${o.cleared_at}\n`);
491
+ } else {
492
+ const okScan = o.scan && typeof o.scan === "object" && !Array.isArray(o.scan) && Array.isArray(o.scan.plaintext_matches);
493
+ if (!Number.isSafeInteger(o.scanned_at) || typeof o.duration_s !== "number" || !(o.duration_s >= 0) || !okScan) bad();
494
+ out(`clean\t${g}\t${o.scanned_at}\t${Math.round(o.duration_s)}\n`);
495
+ }
496
+ for (const n of names) out(`input\t${n}\t${JSON.stringify(inputs[n])}\n`);
497
+ if (!("cleared_at" in o)) out(`scan\t${JSON.stringify(o.scan)}\n`);
433
498
  } else {
434
499
  process.stderr.write(`keyseal node helper: unknown mode ${mode}\n`);
435
500
  process.exit(2);
@@ -560,40 +625,772 @@ keyseal_unsealed_check() {
560
625
  keyseal_unsealed_verify "$1" "$(keyseal_uid)" "$mid"
561
626
  }
562
627
 
563
- # IF-0-KEYSEAL-5. Prints the scan JSON (the `scan` object of status); rc 0 when it ran, 1
564
- # when it could not (a failed find/xargs/grep, not "found something"). $2, when set, is the
565
- # one exempt path: an opted-in unsealed slot's <slot>.key (A1).
566
- keyseal_scan() {
567
- local pin=$1 exempt=${2-} uid list rc_find rc_xargs r json roots=() present=() prune=() h
568
- uid=$(id -u)
569
- roots=("$HOME" "$XDG_RUNTIME_DIR" "${TMPDIR:-/tmp}" /tmp /var/tmp)
570
- for r in "${roots[@]}"; do [[ -d $r ]] && present+=("$r"); done
571
- prune=(\()
572
- for h in "${KEYSEAL_SCAN_PRUNE_NAMES[@]}"; do prune+=(-name "$h" -o); done
573
- for h in "${KEYSEAL_SCAN_PRUNE_PATHS[@]}"; do prune+=(-path "*/$h" -o); done
574
- unset 'prune[${#prune[@]}-1]'
575
- prune+=(\))
576
- local greps=()
628
+
629
+ # ---------------------------------------------------------------------------
630
+ # IF-0-KEYSEAL-5 as amended by #552: roots, positive prune reporting, the bounded scan stage,
631
+ # the progress ticker, and the scan cache (IF-552-1, IF-552-2)
632
+ # ---------------------------------------------------------------------------
633
+
634
+ # Temp files and processes this run owns; status.sh's EXIT trap calls keyseal_scan_cleanup.
635
+ declare -A KEYSEAL_CP_INPUT=() KEYSEAL_INPUT=()
636
+ KEYSEAL_SCAN_TMPS=()
637
+ KEYSEAL_SCAN_TPID=
638
+ KEYSEAL_SCAN_TICKER=
639
+ KEYSEAL_CACHE_TMP=
640
+ KEYSEAL_CACHE_LFD=
641
+
642
+ # Wall clock, epoch seconds. KEYSEAL_TEST_NOW (seam only): an integer, or a file inside the test
643
+ # root holding one, so a stub can move the clock during a run.
644
+ keyseal_now() {
645
+ local v
646
+ if seam_active && [[ -n ${KEYSEAL_TEST_NOW-} ]]; then
647
+ v=$KEYSEAL_TEST_NOW
648
+ if [[ $v == /* ]]; then
649
+ v=$(seam_resolve KEYSEAL_TEST_NOW "$v") || return 1
650
+ IFS= read -r v <"$v" || [[ -n $v ]] || return 1
651
+ fi
652
+ [[ $v =~ ^[0-9]+$ ]] || return 1
653
+ printf '%s' "$v"
654
+ return 0
655
+ fi
656
+ "$KEYSEAL_BIN_DATE" +%s
657
+ }
658
+ keyseal_iso() { "$KEYSEAL_BIN_DATE" -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
659
+
660
+ # A seam-overridable wait, in whole seconds.
661
+ keyseal_seam_seconds() {
662
+ local var=$1 default=$2
663
+ if seam_active && [[ ${!var-} =~ ^[0-9]+$ ]]; then printf '%s' "${!var}"; else printf '%s' "$default"; fi
664
+ }
665
+
666
+ # The declared roots, resolved (realpath -e) and de-duplicated, first occurrence scanned. Sets
667
+ # KEYSEAL_ROOTS (what is scanned) and KEYSEAL_ROOTS_SKIPPED_JSON (the others, with a reason).
668
+ keyseal_scan_roots() {
669
+ local r real skipped= seen
670
+ local -a declared
671
+ declared=("$HOME" "$XDG_RUNTIME_DIR" "${TMPDIR:-/tmp}" /tmp /var/tmp)
672
+ KEYSEAL_ROOTS=()
673
+ for r in "${declared[@]}"; do
674
+ if ! real=$("$KEYSEAL_BIN_REALPATH" -e -- "$r" 2>/dev/null) || [[ ! -d $real ]]; then
675
+ skipped+="${skipped:+,}{\"root\":$(keyseal_json_str "$r"),\"reason\":\"absent\"}"
676
+ continue
677
+ fi
678
+ for seen in ${KEYSEAL_ROOTS[@]+"${KEYSEAL_ROOTS[@]}"}; do
679
+ if [[ $seen == "$real" ]]; then
680
+ skipped+="${skipped:+,}{\"root\":$(keyseal_json_str "$r"),\"reason\":$(keyseal_json_str "duplicate_of:$real")}"
681
+ continue 2
682
+ fi
683
+ done
684
+ KEYSEAL_ROOTS+=("$real")
685
+ done
686
+ KEYSEAL_ROOTS_SKIPPED_JSON="[$skipped]"
687
+ }
688
+
689
+ # The starting point find is given for root $1. find applies -name/-path to a START POINT too, so a
690
+ # root named like a prune entry ($HOME=/home/target, a TMPDIR ending in env or go/pkg/mod) would be
691
+ # pruned whole and still read clean (#552 review r1). `<root>/.` has the basename `.`, which no
692
+ # prune clause matches; the classifier strips the `/.` back out of every path it reports.
693
+ keyseal_scan_start() {
694
+ local root=$1 h
695
+ for h in "${KEYSEAL_SCAN_PRUNE_NAMES[@]}"; do [[ ${root##*/} == "$h" ]] && { printf '%s/.' "$root"; return; }; done
696
+ for h in "${KEYSEAL_SCAN_PRUNE_PATHS[@]}"; do [[ $root == */"$h" ]] && { printf '%s/.' "$root"; return; }; done
697
+ printf '%s' "$root"
698
+ }
699
+
700
+ keyseal_json_list() {
701
+ local out= h
702
+ for h in "$@"; do out+="${out:+,}$(keyseal_json_str "$h")"; done
703
+ printf '[%s]' "$out"
704
+ }
705
+
706
+ # The scan's scope, canonical: what IF-552-2's scope_sha256 hashes. Needs keyseal_scan_roots.
707
+ keyseal_scan_scope_json() {
708
+ printf '{"roots":%s,"roots_skipped":%s,"pruned":%s,"max_bytes":%s,"headers":%s}' \
709
+ "$(keyseal_json_list ${KEYSEAL_ROOTS[@]+"${KEYSEAL_ROOTS[@]}"})" "$KEYSEAL_ROOTS_SKIPPED_JSON" \
710
+ "$(keyseal_json_list "${KEYSEAL_SCAN_PRUNE_NAMES[@]}" "${KEYSEAL_SCAN_PRUNE_PATHS[@]}")" \
711
+ "$KEYSEAL_SCAN_MAX_BYTES" "$(keyseal_json_list "${KEYSEAL_SCAN_HEADERS[@]}")"
712
+ }
713
+
714
+ # `keyseal-scan.*` files already in the runtime dir: LISTED, never removed (AGENTS.md: a leftover
715
+ # is not inferred to be ours). Call before this run creates its own.
716
+ keyseal_scan_stale_temp() {
717
+ local f out=
718
+ for f in "${XDG_RUNTIME_DIR:-/tmp}"/keyseal-scan.*; do
719
+ [[ -e $f || -L $f ]] && out+="${out:+,}$(keyseal_json_str "$f")"
720
+ done
721
+ printf '[%s]' "$out"
722
+ }
723
+
724
+ # A temp file into variable $1, registered for the EXIT trap (never a command substitution: the
725
+ # registration must happen in THIS shell).
726
+ keyseal_scan_mktemp() {
727
+ local f dir=${XDG_RUNTIME_DIR:-/tmp}
728
+ # An absent runtime dir is a root reported `absent`, never a reason the scan cannot run.
729
+ [[ -d $dir ]] || dir=/tmp
730
+ f=$(mktemp "$dir/keyseal-scan.XXXXXX") || return 1
731
+ KEYSEAL_SCAN_TMPS+=("$f")
732
+ printf -v "$1" '%s' "$f"
733
+ }
734
+
735
+ # The parent pid of pid $1, from /proc (the field after the state). rc 1 when $1 is gone.
736
+ keyseal_ppid_of() {
737
+ local s
738
+ s=$(cat -- "/proc/$1/stat" 2>/dev/null) || return 1
739
+ s=${s##*) }
740
+ # shellcheck disable=SC2086
741
+ set -- $s
742
+ printf '%s' "$2"
743
+ }
744
+
745
+ # The pgid of pid $1, from /proc (the fields after the command name: state ppid pgrp).
746
+ keyseal_pgid_of() {
747
+ local s
748
+ s=$(cat -- "/proc/$1/stat" 2>/dev/null) || return 1
749
+ s=${s##*) }
750
+ # shellcheck disable=SC2086
751
+ set -- $s
752
+ printf '%s' "$3"
753
+ }
754
+
755
+ # The scan stage: ONE child of `timeout`, run as `bash -c` so the deadline covers find, xargs,
756
+ # grep AND every slot's classifier (IF-552-2 "Self-bound"; extended by #552-ONBOARD-SL3 so N
757
+ # slots share this ONE walk — the walk itself never depends on a pin or an exempt path). Args: rc
758
+ # file, count file, pruned file, list file, uid, slot count n, then n groups of (json file, pin,
759
+ # exempt path), then `--`, then the roots. It writes its results to files; status.sh reads them
760
+ # only when the stage completed.
761
+ keyseal_scan_stage() {
762
+ local rcf=$1 count=$2 pruned=$3 list=$4 uid=$5 n=$6 find grep i=0 h j
763
+ shift 6
764
+ local -a jsonfs=() pins=() exempts=() expr greps
765
+ for ((j = 0; j < n; j++)); do
766
+ jsonfs+=("$1"); pins+=("$2"); exempts+=("$3")
767
+ shift 3
768
+ done
769
+ [[ ${1-} == -- ]] || exit 3
770
+ shift
771
+ find=$(seam_resolve KEYSEAL_TEST_FIND "$KEYSEAL_BIN_FIND") || exit 3
772
+ grep=$(seam_resolve KEYSEAL_TEST_GREP "$KEYSEAL_BIN_GREP") || exit 3
773
+ # One clause per prune entry, in report order; each writes its INDEX, never the path. The
774
+ # first matching clause wins. Every directory that is entered writes one byte to the count.
775
+ expr=(\( -type d \()
776
+ for h in "${KEYSEAL_SCAN_PRUNE_NAMES[@]}"; do expr+=(\( -name "$h" -prune -fprintf "$pruned" "$i\\n" \) -o); ((++i)); done
777
+ for h in "${KEYSEAL_SCAN_PRUNE_PATHS[@]}"; do expr+=(\( -path "*/$h" -prune -fprintf "$pruned" "$i\\n" \) -o); ((++i)); done
778
+ unset 'expr[${#expr[@]}-1]'
779
+ expr+=(\) \) -o \( -type d -fprintf "$count" x \) -o \( -type f -user "$uid" -size -$((KEYSEAL_SCAN_MAX_BYTES + 1))c -print0 \))
577
780
  for h in "${KEYSEAL_SCAN_HEADERS[@]}"; do greps+=(-e "$h" -e "$h"$'\r'); done
578
- list=$(mktemp "${XDG_RUNTIME_DIR:-/tmp}/keyseal-scan.XXXXXX") || return 1
579
- "$KEYSEAL_BIN_FIND" -H "${present[@]}" -type d "${prune[@]}" -prune -o \
580
- -type f -user "$uid" -size -$((KEYSEAL_SCAN_MAX_BYTES + 1))c -print0 2>/dev/null \
581
- | "$KEYSEAL_BIN_XARGS" -0 -r "$KEYSEAL_BIN_GREP" -lsZ -x -F "${greps[@]}" -- >"$list"
582
- rc_find=${PIPESTATUS[0]} rc_xargs=${PIPESTATUS[1]}
781
+ "$find" -H "$@" "${expr[@]}" 2>/dev/null \
782
+ | "$KEYSEAL_BIN_XARGS" -0 -r "$grep" -lsZ -x -F "${greps[@]}" -- >"$list"
783
+ local rc_find=${PIPESTATUS[0]} rc_xargs=${PIPESTATUS[1]}
784
+ local -a rc_nodes=()
583
785
  # find: 0, or 1 when some directory was unreadable. xargs: 0, or 123 when some grep found
584
- # nothing. Anything else means the scan did not run to completion.
585
- if ! { ((rc_find <= 1)) && ((rc_xargs == 0 || rc_xargs == 123)); }; then
586
- rm -f -- "$list"
587
- return 1
786
+ # nothing. Anything else means the scan did not run to completion, for every slot alike.
787
+ if ((rc_find <= 1)) && ((rc_xargs == 0 || rc_xargs == 123)); then
788
+ local delay=
789
+ seam_active && delay=${KEYSEAL_TEST_CLASSIFY_DELAY_MS-}
790
+ for ((j = 0; j < n; j++)); do
791
+ local rc_node
792
+ KEYSEAL_SCAN_DELAY_MS=$delay KEYSEAL_SCAN_PIN=${pins[j]} keyseal_node classify "$list" "$uid" "$KEYSEAL_SCAN_MAX_BYTES" "${exempts[j]}" "$pruned" \
793
+ "${KEYSEAL_SCAN_PRUNE_NAMES[@]}" "${KEYSEAL_SCAN_PRUNE_PATHS[@]}" >"${jsonfs[j]}" && rc_node=0 || rc_node=$?
794
+ rc_nodes+=("$rc_node")
795
+ done
796
+ else
797
+ for ((j = 0; j < n; j++)); do rc_nodes+=(-); done
798
+ fi
799
+ {
800
+ printf '%s %s' "$rc_find" "$rc_xargs"
801
+ for j in "${rc_nodes[@]}"; do printf ' %s' "$j"; done
802
+ printf '\n'
803
+ } >"$rcf"
804
+ }
805
+
806
+ # The stage group led by `timeout` (pid $1) is gone within the grace, else KILLed group-wide
807
+ # (president follow-up 4: uutils' --kill-after reaches only timeout's direct child, so the
808
+ # escalation never depends on timeout being alive).
809
+ keyseal_stage_reap() {
810
+ local pg=$1 i
811
+ for ((i = 0; i < KEYSEAL_SCAN_KILL_AFTER_S * 10; i++)); do
812
+ kill -0 -- "-$pg" 2>/dev/null || return 0
813
+ "$SLEEP" 0.1
814
+ done
815
+ kill -KILL -- "-$pg" 2>/dev/null || true
816
+ }
817
+
818
+ # The progress ticker, in the background for the stage's lifetime. Every period it prints the
819
+ # frozen progress line (the cli parses <N>). When status.sh's parent OR status.sh itself is gone
820
+ # (president follow-up 3), in this order: it TERMs timeout (whose group forward ends a stage that
821
+ # honours TERM), REAPS the stage group itself (grace, then a group KILL: no trap may be left to do
822
+ # it, and uutils' --kill-after reaches only its direct child), and only then TERMs status.sh (its
823
+ # whole group when status.sh leads one, as the cli's detached spawn makes it). When only the cli
824
+ # died, status.sh is still alive while the stage is reaped: its `wait` returns and it records a
825
+ # stage that did not complete (never a clean write), unless the TERM that follows ends it first.
826
+ #
827
+ # Residual (#552 review r3, S3): if status.sh is SIGKILLed in the microseconds after it reaped
828
+ # `timeout` and before it stops the ticker, the ticker may signal a pid (or pgid) the kernel has
829
+ # already reused. The window is between two adjacent statements; it is recorded, not closed.
830
+ #
831
+ # The PARENT watch is armed only when the cli spawned status.sh: its helper exports
832
+ # KEYSEAL_SCAN_WATCH_PID=<its own pid>, and the watch applies only while that equals status.sh's
833
+ # parent. A hand run (`nohup status.sh … & disown` over ssh, the fleet's pattern for long jobs) is
834
+ # reparented when its shell exits, and must keep scanning (#552 review r1). The SELF watch is always
835
+ # armed. $4 is the parent to watch, or empty.
836
+ keyseal_scan_ticker() {
837
+ local period=$1 count=$2 tpid=$3 parent=$4 self=$5 pg n sp= me=$BASHPID
838
+ pg=$(keyseal_pgid_of "$self") || pg=
839
+ # Liveness is checked every KEYSEAL_SCAN_LIVENESS_S (a sub-tick), the progress line printed every
840
+ # period: a dead cli or status.sh ends the stage within seconds, not a whole progress period.
841
+ local step=$KEYSEAL_SCAN_LIVENESS_S elapsed=0
842
+ ((period < step)) && step=$period
843
+ trap '[[ -n $sp ]] && kill "$sp" 2>/dev/null; exit 0' TERM
844
+ while :; do
845
+ "$SLEEP" "$step" &
846
+ sp=$!
847
+ wait "$sp" || true
848
+ sp=
849
+ ((elapsed += step))
850
+ # The parent is gone when it no longer exists, OR when status.sh is no longer its child: a dead
851
+ # cli's pid can be a zombie (kill -0 succeeds) or reused, but status.sh has been reparented.
852
+ # status.sh is gone when it is not alive, OR when the ticker is no longer its child: a dead
853
+ # status.sh reparents the ticker at once, zombie or not, and a reused pid is not our parent
854
+ # (#552 review r4). The ticker is status.sh's own subshell, so its parent IS status.sh. Its pid
855
+ # is taken once, above: `$BASHPID` inside `$(…)` would name the substitution's own subshell.
856
+ if { [[ -n $parent ]] && { ! keyseal_alive "$parent" || [[ $(keyseal_ppid_of "$self") != "$parent" ]]; }; } ||
857
+ ! keyseal_alive "$self" || [[ $(keyseal_ppid_of "$me") != "$self" ]]; then
858
+ kill -TERM "$tpid" 2>/dev/null || true
859
+ # The escalation is ours to finish: a SIGKILLed status.sh runs no trap, and uutils timeout's
860
+ # --kill-after reaches only its direct child (#552 review r2, codex). Grace, then group KILL.
861
+ keyseal_stage_reap "$tpid"
862
+ if [[ $pg == "$self" ]]; then kill -TERM -- "-$pg" 2>/dev/null || true
863
+ else kill -TERM "$self" 2>/dev/null || true
864
+ fi
865
+ exit 0
866
+ fi
867
+ ((elapsed >= period)) || continue
868
+ elapsed=0
869
+ n=0
870
+ [[ -f $count ]] && n=$("$KEYSEAL_BIN_STAT" -c %s -- "$count" 2>/dev/null || printf 0)
871
+ printf 'status: scan: still scanning (%s folders so far)\n' "$n" >&2
872
+ done
873
+ }
874
+
875
+ # pid $1 is a live process: the ONE liveness test for the ticker's parent and self watches. A
876
+ # zombie (Z) or dead (X) entry is gone, though `kill -0` still succeeds on it (#552 review r3,
877
+ # codex: an unreaped SIGKILLed status.sh kept its ticker waiting). `kill -0` alone reads EPERM (a
878
+ # process of another uid) as gone; /proc does not. Fallback, stated: where /proc/<pid>/stat cannot
879
+ # be read (a `hidepid=` mount hiding another uid's processes) this falls back to `kill -0`, which
880
+ # counts a zombie as alive. The ticker's self watch does not depend on it (the ticker's own parent).
881
+ # And where /proc cannot be read at all, keyseal_ppid_of fails, so the ticker's own-parent check
882
+ # fires on its first tick: the scan is stopped at once and never yields a clean result, which is
883
+ # loud (every scan fails), never silent.
884
+ keyseal_alive() {
885
+ local s
886
+ s=$(cat -- "/proc/$1/stat" 2>/dev/null) || { kill -0 "$1" 2>/dev/null; return; }
887
+ s=${s##*) }
888
+ [[ ${s:0:1} != [ZX] ]]
889
+ }
890
+
891
+ # status.sh's EXIT trap: the ticker, the stage (TERM forwarded to timeout, then group-wide KILL
892
+ # after the grace), and every temp file this run made. A SIGKILL skips it: the stage is still
893
+ # bounded by timeout, and the next cache write removes a dead run's .tmp.
894
+ keyseal_scan_cleanup() {
895
+ if [[ -n $KEYSEAL_SCAN_TICKER ]]; then kill -TERM "$KEYSEAL_SCAN_TICKER" 2>/dev/null || true; fi
896
+ if [[ -n $KEYSEAL_SCAN_TPID ]]; then
897
+ kill -TERM "$KEYSEAL_SCAN_TPID" 2>/dev/null || true
898
+ keyseal_stage_reap "$KEYSEAL_SCAN_TPID"
899
+ KEYSEAL_SCAN_TPID=
900
+ fi
901
+ rm -f -- ${KEYSEAL_SCAN_TMPS[@]+"${KEYSEAL_SCAN_TMPS[@]}"}
902
+ if [[ -n $KEYSEAL_CACHE_TMP ]]; then rm -f -- "$KEYSEAL_CACHE_TMP"; fi
903
+ }
904
+
905
+ # Run the scan stage under one deadline, for every slot named in the caller's parallel arrays
906
+ # KEYSEAL_SCAN_PINS[] / KEYSEAL_SCAN_EXEMPTS[] (at least one entry; #552-ONBOARD-SL3: N slots
907
+ # share this ONE walk, never one walk per slot). $1 deadline (s). Sets KEYSEAL_SCAN_OUTCOME (ok |
908
+ # incomplete | timed_out, for the whole stage: one deadline covers every slot alike),
909
+ # KEYSEAL_SCAN_CLASSIFIED[] (index-parallel to the inputs: each slot's classifier JSON, only when
910
+ # ok) and KEYSEAL_SCAN_FOLDERS (directories entered, from the count file's size).
911
+ keyseal_scan_run() {
912
+ local deadline=$1 list count pruned rcf trc=0 period rcs n i pat
913
+ n=${#KEYSEAL_SCAN_PINS[@]}
914
+ KEYSEAL_SCAN_OUTCOME=incomplete KEYSEAL_SCAN_CLASSIFIED=() KEYSEAL_SCAN_FOLDERS=0
915
+ ((n >= 1)) || return 0
916
+ keyseal_scan_mktemp list && keyseal_scan_mktemp count && keyseal_scan_mktemp pruned &&
917
+ keyseal_scan_mktemp rcf || return 0
918
+ local -a jsonfs=()
919
+ for ((i = 0; i < n; i++)); do
920
+ local jf
921
+ keyseal_scan_mktemp jf || return 0
922
+ jsonfs+=("$jf")
923
+ done
924
+ if seam_active && [[ ${KEYSEAL_TEST_COUNT_FIFO-} == 1 ]]; then
925
+ # SC-16's seam: a FIFO with no reader, so the REAL find blocks opening it.
926
+ rm -f -- "$count" && "$KEYSEAL_BIN_MKFIFO" -m 0600 -- "$count" || return 0
927
+ fi
928
+ local -a starts=()
929
+ local root
930
+ for root in "${KEYSEAL_ROOTS[@]}"; do starts+=("$(keyseal_scan_start "$root")"); done
931
+ # No --foreground: timeout leads its own process group and signals all of it (IF-552-2). The
932
+ # seam KEYSEAL_TEST_TIMEOUT (test root only) substitutes a timeout with uutils' semantics, so a
933
+ # GNU runner can prove what only uutils would otherwise expose (#552 review r3).
934
+ local timeout_bin
935
+ timeout_bin=$(seam_resolve KEYSEAL_TEST_TIMEOUT "$KEYSEAL_BIN_TIMEOUT") || return 0
936
+ local -a stage_args=("$rcf" "$count" "$pruned" "$list" "$(id -u)" "$n")
937
+ for ((i = 0; i < n; i++)); do stage_args+=("${jsonfs[i]}" "${KEYSEAL_SCAN_PINS[i]}" "${KEYSEAL_SCAN_EXEMPTS[i]}"); done
938
+ stage_args+=(--)
939
+ "$timeout_bin" --kill-after="$KEYSEAL_SCAN_KILL_AFTER_S" "$deadline" \
940
+ /bin/bash -c '. "$0"; keyseal_scan_stage "$@"' "$keyseal_here/lib.sh" \
941
+ "${stage_args[@]}" "${starts[@]}" >/dev/null &
942
+ KEYSEAL_SCAN_TPID=$!
943
+ period=$(keyseal_seam_seconds KEYSEAL_TEST_SCAN_PROGRESS_S "$KEYSEAL_SCAN_PROGRESS_S")
944
+ local watch=
945
+ [[ ${KEYSEAL_SCAN_WATCH_PID-} =~ ^[0-9]+$ && $KEYSEAL_SCAN_WATCH_PID == "$PPID" ]] && watch=$PPID
946
+ keyseal_scan_ticker "$period" "$count" "$KEYSEAL_SCAN_TPID" "$watch" "$$" &
947
+ KEYSEAL_SCAN_TICKER=$!
948
+ wait "$KEYSEAL_SCAN_TPID" || trc=$?
949
+ # 137: timeout already waited --kill-after and KILLed its child; a survivor there gets no
950
+ # further grace. Otherwise a survivor has the grace, then a group-wide KILL.
951
+ if ((trc == 137)); then kill -KILL -- "-$KEYSEAL_SCAN_TPID" 2>/dev/null || true; fi
952
+ keyseal_stage_reap "$KEYSEAL_SCAN_TPID"
953
+ KEYSEAL_SCAN_TPID=
954
+ kill -TERM "$KEYSEAL_SCAN_TICKER" 2>/dev/null || true
955
+ wait "$KEYSEAL_SCAN_TICKER" 2>/dev/null || true
956
+ KEYSEAL_SCAN_TICKER=
957
+ [[ -f $count ]] && KEYSEAL_SCAN_FOLDERS=$("$KEYSEAL_BIN_STAT" -c %s -- "$count" 2>/dev/null || printf 0)
958
+ # 124: the deadline; 137: timeout's own KILL after --kill-after. Partial output is never read.
959
+ if ((trc == 124 || trc == 137)); then KEYSEAL_SCAN_OUTCOME=timed_out; return 0; fi
960
+ ((trc == 0)) || return 0
961
+ rcs=$(<"$rcf") || return 0
962
+ pat="^[01] (0|123)"
963
+ for ((i = 0; i < n; i++)); do pat+=" 0"; done
964
+ pat+='$'
965
+ [[ $rcs =~ $pat ]] || return 0
966
+ for ((i = 0; i < n; i++)); do
967
+ local c
968
+ c=$(<"${jsonfs[i]}") || return 0
969
+ [[ $c == \{* ]] || return 0
970
+ KEYSEAL_SCAN_CLASSIFIED[i]=$c
971
+ done
972
+ KEYSEAL_SCAN_OUTCOME=ok
973
+ }
974
+
975
+ # The scan object's fixed part for slot index $1: scope, positive prune report and verdicts. This
976
+ # is what a cache stores, WITHOUT `headers`: the cache file lives under $HOME, and the PEM header
977
+ # strings are never written there at all. scope_sha256 covers the headers, so a hit re-adds the
978
+ # current ones (keyseal_scan_with_headers) and a cached report is shape-identical to a fresh one.
979
+ keyseal_scan_core_json() {
980
+ local i=$1
981
+ printf '{"roots":%s,"roots_skipped":%s,"pruned":%s,"max_bytes":%s,"not_detected":%s,%s' \
982
+ "$(keyseal_json_list ${KEYSEAL_ROOTS[@]+"${KEYSEAL_ROOTS[@]}"})" "$KEYSEAL_ROOTS_SKIPPED_JSON" \
983
+ "$(keyseal_json_list "${KEYSEAL_SCAN_PRUNE_NAMES[@]}" "${KEYSEAL_SCAN_PRUNE_PATHS[@]}")" \
984
+ "$KEYSEAL_SCAN_MAX_BYTES" \
985
+ "$(keyseal_json_list "${KEYSEAL_SCAN_NOT_DETECTED[@]}")" "${KEYSEAL_SCAN_CLASSIFIED[i]#\{}"
986
+ }
987
+ keyseal_scan_with_headers() { printf '{"headers":%s,%s' "$(keyseal_json_list "${KEYSEAL_SCAN_HEADERS[@]}")" "${1#\{}"; }
988
+
989
+ # ---- the cache: location and directory trust (#536 framed against PR #550) --------------------
990
+
991
+ # The state root: XDG_STATE_HOME if set, else $HOME/.local/state. rc 2 when it is relative.
992
+ keyseal_state_root() {
993
+ local root=${XDG_STATE_HOME-}
994
+ [[ -n ${root//[[:space:]]/} ]] || root=$HOME/.local/state
995
+ [[ $root == /* ]] || return 2
996
+ printf '%s' "$root"
997
+ }
998
+
999
+ # A decimal field as JS Number() reads the forms /etc/group and /etc/passwd hold: optional
1000
+ # surrounding blanks, digits only. Prints the value, rc 1 otherwise.
1001
+ keyseal_num_field() {
1002
+ local v=$1
1003
+ v=${v#"${v%%[![:space:]]*}"}
1004
+ v=${v%"${v##*[![:space:]]}"}
1005
+ [[ $v =~ ^[0-9]+$ ]] || return 1
1006
+ printf '%s' "$((10#$v))"
1007
+ }
1008
+
1009
+ # Split a line on ':' keeping empty and trailing fields (as JS split does) into KEYSEAL_FIELDS.
1010
+ keyseal_split_colon() {
1011
+ local line=$1
1012
+ KEYSEAL_FIELDS=()
1013
+ while [[ $line == *:* ]]; do
1014
+ KEYSEAL_FIELDS+=("${line%%:*}")
1015
+ line=${line#*:}
1016
+ done
1017
+ KEYSEAL_FIELDS+=("$line")
1018
+ }
1019
+
1020
+ # isPrivateGroup (claude_marketplace.ts), ported: gid $1 is the user's PRIVATE group. $2 the
1021
+ # process's primary gid, $3 the user name, $4 the uid. Proven only from /etc/group and
1022
+ # /etc/passwd (seams KEYSEAL_TEST_GROUP_FILE / KEYSEAL_TEST_PASSWD_FILE, under the test root):
1023
+ # exactly one group entry for the gid, named as the user, members a subset of {user}; exactly one
1024
+ # account with that primary gid, and it is this user, name and uid. rc 0 private.
1025
+ keyseal_private_group() {
1026
+ local gid=$1 primary=$2 user=$3 uid=$4 groupf passwdf line n m members gname
1027
+ local -a groups_ accounts
1028
+ [[ -n $user && $gid =~ ^[0-9]+$ && $primary =~ ^[0-9]+$ ]] || return 1
1029
+ ((10#$gid == 10#$primary)) || return 1
1030
+ groupf=$(seam_resolve KEYSEAL_TEST_GROUP_FILE /etc/group) || return 1
1031
+ passwdf=$(seam_resolve KEYSEAL_TEST_PASSWD_FILE /etc/passwd) || return 1
1032
+ [[ -r $groupf && -r $passwdf ]] || return 1
1033
+ groups_=()
1034
+ while IFS= read -r line || [[ -n $line ]]; do
1035
+ keyseal_split_colon "$line"
1036
+ ((${#KEYSEAL_FIELDS[@]} >= 4)) && [[ -n ${KEYSEAL_FIELDS[2]} ]] || continue
1037
+ n=$(keyseal_num_field "${KEYSEAL_FIELDS[2]}") || continue
1038
+ if ((n == 10#$gid)); then groups_+=("${KEYSEAL_FIELDS[0]}:${KEYSEAL_FIELDS[3]}"); fi
1039
+ # No `|| return 1` here: a loop's status is its LAST body command's, so a last line that is not
1040
+ # the user's group made this "not private" (claw, SL-3.3: /etc/group ends `mbold:x:1002:`).
1041
+ # The files were checked readable above; SC-21 forbids a loop status that leaks.
1042
+ done <"$groupf"
1043
+ ((${#groups_[@]} == 1)) || return 1
1044
+ gname=${groups_[0]%%:*}
1045
+ [[ $gname == "$user" ]] || return 1
1046
+ members=${groups_[0]#*:}
1047
+ IFS=, read -ra accounts <<<"$members"
1048
+ for m in ${accounts[@]+"${accounts[@]}"}; do
1049
+ m=${m#"${m%%[![:space:]]*}"}
1050
+ m=${m%"${m##*[![:space:]]}"}
1051
+ [[ -z $m || $m == "$user" ]] || return 1
1052
+ done
1053
+ accounts=()
1054
+ while IFS= read -r line || [[ -n $line ]]; do
1055
+ keyseal_split_colon "$line"
1056
+ ((${#KEYSEAL_FIELDS[@]} >= 4)) && [[ -n ${KEYSEAL_FIELDS[3]} ]] || continue
1057
+ n=$(keyseal_num_field "${KEYSEAL_FIELDS[3]}") || continue
1058
+ if ((n == 10#$gid)); then accounts+=("${KEYSEAL_FIELDS[0]}:${KEYSEAL_FIELDS[2]}"); fi
1059
+ done <"$passwdf"
1060
+ ((${#accounts[@]} == 1)) || return 1
1061
+ [[ ${accounts[0]%%:*} == "$user" ]] || return 1
1062
+ n=$(keyseal_num_field "${accounts[0]#*:}") || return 1
1063
+ [[ $uid =~ ^[0-9]+$ ]] && ((n == 10#$uid))
1064
+ }
1065
+
1066
+ # Judge one directory the walk ENTERED ($1 path, $2 role: dir | parent | ancestor, $3 uid).
1067
+ # Prints the cause and returns 1 when it is not trusted.
1068
+ keyseal_trust_enter() {
1069
+ local path=$1 role=$2 uid=$3 u g a mode own=no
1070
+ [[ $role != ancestor ]] && own=yes
1071
+ if [[ -L $path || ! -d $path ]]; then printf 'not_directory %s' "$path"; return 1; fi
1072
+ read -r u g a < <("$KEYSEAL_BIN_STAT" -c '%u %g %a' -- "$path") || { printf 'not_directory %s' "$path"; return 1; }
1073
+ if [[ $own == yes ]]; then
1074
+ [[ $u == "$uid" ]] || { printf 'foreign_owner %s' "$path"; return 1; }
1075
+ else
1076
+ [[ $u == "$uid" || $u == 0 ]] || { printf 'foreign_owner %s' "$path"; return 1; }
1077
+ fi
1078
+ mode=$((8#$a))
1079
+ ((mode & 8#022)) || return 0
1080
+ if [[ $own == no && $u == 0 ]] && ((mode & 8#1000)); then return 0; fi
1081
+ if [[ $own == no && $u == "$uid" ]] && ! ((mode & 8#002)) &&
1082
+ keyseal_private_group "$g" "$(id -g)" "$(id -un)" "$uid"; then
1083
+ return 0
1084
+ fi
1085
+ printf 'writable %s' "$path"
1086
+ return 1
1087
+ }
1088
+
1089
+ # resolveTrustChain (claude_marketplace.ts), ported: walk $1 from `/` one component at a time,
1090
+ # as the kernel does. The last two components (keyslot-scan/ and its agent-board/ parent) are
1091
+ # judged strictly and may never be symlinks; every other directory entered by the ancestor rule.
1092
+ # A symlinked ancestor is judged by its own owner (this uid or root) and its target spliced into
1093
+ # the remaining components; more than 40 hops is a loop. Stops at the first missing component.
1094
+ # rc 0 trusted; else prints `<cause> <path>` and returns 1. $2 is the uid.
1095
+ keyseal_cache_chain() {
1096
+ local target=$1 uid=$2 current=/ name path role to u hops=0
1097
+ local -a queue parts
1098
+ [[ $target == /* ]] || { printf 'not_absolute %s' "$target"; return 1; }
1099
+ keyseal_trust_enter / ancestor "$uid" || return 1
1100
+ IFS=/ read -ra parts <<<"$target"
1101
+ queue=()
1102
+ for name in "${parts[@]}"; do [[ -n $name ]] && queue+=("$name"); done
1103
+ while ((${#queue[@]})); do
1104
+ name=${queue[0]}
1105
+ queue=("${queue[@]:1}")
1106
+ if ((${#queue[@]} == 0)); then role=dir; elif ((${#queue[@]} == 1)); then role=parent; else role=ancestor; fi
1107
+ [[ $name == . ]] && continue
1108
+ if [[ $name == .. ]]; then
1109
+ current=${current%/*}
1110
+ [[ -n $current ]] || current=/
1111
+ continue
1112
+ fi
1113
+ if [[ $current == / ]]; then path=/$name; else path=$current/$name; fi
1114
+ [[ -e $path || -L $path ]] || break
1115
+ if [[ -L $path ]]; then
1116
+ [[ $role == ancestor ]] || { printf 'symlink %s' "$path"; return 1; }
1117
+ u=$("$KEYSEAL_BIN_STAT" -c %u -- "$path") || { printf 'not_directory %s' "$path"; return 1; }
1118
+ [[ $u == "$uid" || $u == 0 ]] || { printf 'foreign_owner %s' "$path"; return 1; }
1119
+ ((++hops > 40)) && { printf 'symlink_loop %s' "$path"; return 1; }
1120
+ to=$(readlink -- "$path") || { printf 'not_directory %s' "$path"; return 1; }
1121
+ [[ $to == /* ]] && current=/
1122
+ IFS=/ read -ra parts <<<"$to"
1123
+ local -a spliced=()
1124
+ for name in ${parts[@]+"${parts[@]}"}; do [[ -n $name ]] && spliced+=("$name"); done
1125
+ queue=(${spliced[@]+"${spliced[@]}"} ${queue[@]+"${queue[@]}"})
1126
+ continue
1127
+ fi
1128
+ keyseal_trust_enter "$path" "$role" "$uid" || return 1
1129
+ current=$path
1130
+ done
1131
+ return 0
1132
+ }
1133
+
1134
+ # ---- the cache: tokens, lock, read and judge, commit -------------------------------------------
1135
+
1136
+ # The start token: the file's CONTENT HASH (never its generation), or `absent`. A symlink or a
1137
+ # non-regular file is hashed by what it is, never read.
1138
+ keyseal_cache_token() {
1139
+ local f=$1 h
1140
+ if [[ -L $f ]]; then printf 'symlink:%s' "$(keyseal_sha256_str "$(readlink -- "$f")")"
1141
+ elif [[ ! -e $f ]]; then printf absent
1142
+ elif [[ -f $f ]]; then
1143
+ if h=$(keyseal_sha256_file "$f"); then printf 'sha:%s' "$h"
1144
+ else printf 'unreadable:%s' "$("$KEYSEAL_BIN_STAT" -c '%d:%i:%Y' -- "$f" 2>/dev/null)"
1145
+ fi
1146
+ else printf 'other:%s' "$("$KEYSEAL_BIN_STAT" -c '%d:%i:%F' -- "$f" 2>/dev/null)"
1147
+ fi
1148
+ }
1149
+
1150
+ # Take the slot's cache lock ($1 dir, $2 slot, $3 wait s): a 0600 lock file in the trusted dir.
1151
+ keyseal_cache_lock() {
1152
+ local lock=$1/$2.lock
1153
+ (umask 077 && : >>"$lock") 2>/dev/null || return 1
1154
+ exec {KEYSEAL_CACHE_LFD}>>"$lock" || return 1
1155
+ if "$FLOCK" -w "$3" "$KEYSEAL_CACHE_LFD"; then return 0; fi
1156
+ exec {KEYSEAL_CACHE_LFD}>&-
1157
+ KEYSEAL_CACHE_LFD=
1158
+ return 1
1159
+ }
1160
+ keyseal_cache_unlock() {
1161
+ if [[ -n $KEYSEAL_CACHE_LFD ]]; then exec {KEYSEAL_CACHE_LFD}>&-; fi
1162
+ KEYSEAL_CACHE_LFD=
1163
+ }
1164
+
1165
+ # The cache directory, into KEYSEAL_CACHE_DIR; rc 2 when the state root is relative.
1166
+ keyseal_cache_dir() {
1167
+ local root
1168
+ root=$(keyseal_state_root) || return 2
1169
+ KEYSEAL_CACHE_DIR=$root/agent-board/keyslot-scan
1170
+ }
1171
+
1172
+ # Parse a cache file into KEYSEAL_CP_KIND (format | clean | tombstone), KEYSEAL_CP_GEN,
1173
+ # KEYSEAL_CP_STAMP (scanned_at or cleared_at), KEYSEAL_CP_DURATION, KEYSEAL_CP_SCAN and
1174
+ # KEYSEAL_CP_INPUT[<name>] (compact JSON).
1175
+ keyseal_cache_parse() {
1176
+ local line kind rest
1177
+ KEYSEAL_CP_INPUT=()
1178
+ KEYSEAL_CP_KIND=format KEYSEAL_CP_GEN=0 KEYSEAL_CP_STAMP= KEYSEAL_CP_DURATION= KEYSEAL_CP_SCAN=
1179
+ while IFS= read -r line; do
1180
+ kind=${line%%$'\t'*}
1181
+ rest=${line#*$'\t'}
1182
+ case $kind in
1183
+ clean) KEYSEAL_CP_KIND=clean; IFS=$'\t' read -r KEYSEAL_CP_GEN KEYSEAL_CP_STAMP KEYSEAL_CP_DURATION <<<"$rest" ;;
1184
+ tombstone) KEYSEAL_CP_KIND=tombstone; IFS=$'\t' read -r KEYSEAL_CP_GEN KEYSEAL_CP_STAMP <<<"$rest" ;;
1185
+ input) KEYSEAL_CP_INPUT[${rest%%$'\t'*}]=${rest#*$'\t'} ;;
1186
+ scan) KEYSEAL_CP_SCAN=$rest ;;
1187
+ *) KEYSEAL_CP_KIND=format ;;
1188
+ esac
1189
+ done < <(keyseal_node cacheparse "$1" "$KEYSEAL_SCAN_CACHE_FORMAT" "${KEYSEAL_SCAN_CACHE_INPUTS[@]}" 2>/dev/null || printf 'format\n')
1190
+ return 0
1191
+ }
1192
+
1193
+ # The inputs object for this run, from KEYSEAL_INPUT[<name>] (compact JSON values).
1194
+ keyseal_cache_inputs_json() {
1195
+ local n out=
1196
+ for n in "${KEYSEAL_SCAN_CACHE_INPUTS[@]}"; do out+="${out:+,}\"$n\":${KEYSEAL_INPUT[$n]}"; done
1197
+ printf '{%s}' "$out"
1198
+ }
1199
+
1200
+ # Read and judge the slot's cache under the lock. $1 slot, $2 mode (fresh | use | only). Sets
1201
+ # KEYSEAL_CACHE_STATE / _REASON (IF-552-2's vocabulary), KEYSEAL_CACHE_TOKEN (the start token,
1202
+ # empty when none could be recorded) and, on a hit, KEYSEAL_CACHE_SCAN / _SCANNED_AT / _DURATION.
1203
+ keyseal_cache_read() {
1204
+ keyseal_cache_judge "$@"
1205
+ # `fresh` never reads the cache: whatever the judge found, the reported read is rescan_requested
1206
+ # (the mode table), on the early-return paths too. The start token it recorded, or not, stands.
1207
+ if [[ $2 == fresh ]]; then KEYSEAL_CACHE_STATE=miss KEYSEAL_CACHE_REASON=rescan_requested KEYSEAL_CACHE_SCAN=; fi
1208
+ }
1209
+ keyseal_cache_judge() {
1210
+ local slot=$1 mode=$2 f now uid u a problem n
1211
+ KEYSEAL_CACHE_STATE=miss KEYSEAL_CACHE_REASON= KEYSEAL_CACHE_TOKEN= KEYSEAL_CACHE_SCAN=
1212
+ KEYSEAL_CACHE_SCANNED_AT= KEYSEAL_CACHE_DURATION= KEYSEAL_CACHE_LAST=
1213
+ local judged_state judged_reason
1214
+ if ! keyseal_cache_dir; then KEYSEAL_CACHE_STATE=unusable KEYSEAL_CACHE_REASON=state_root_relative; return 0; fi
1215
+ if [[ ${KEYSEAL_INPUT[machine_id]} == null ]]; then KEYSEAL_CACHE_STATE=unusable KEYSEAL_CACHE_REASON=machine_id_unreadable; return 0; fi
1216
+ f=$KEYSEAL_CACHE_DIR/$slot.json
1217
+ if [[ ! -d $KEYSEAL_CACHE_DIR ]]; then
1218
+ # Nothing to lock yet: a commit from this start needs the file still absent, under the lock.
1219
+ KEYSEAL_CACHE_TOKEN=absent
1220
+ judged_state=miss judged_reason=absent
1221
+ else
1222
+ uid=$(keyseal_uid)
1223
+ if ! problem=$(keyseal_cache_chain "$KEYSEAL_CACHE_DIR" "$uid"); then
1224
+ # An untrusted directory is never locked or read. The file's own lstat, when it names a
1225
+ # problem, is the more specific reason.
1226
+ KEYSEAL_CACHE_STATE=unusable KEYSEAL_CACHE_REASON=untrusted:ancestor
1227
+ if [[ -L $f ]]; then KEYSEAL_CACHE_REASON=untrusted:symlink
1228
+ elif [[ -e $f && ! -f $f ]]; then KEYSEAL_CACHE_REASON=untrusted:not_regular
1229
+ elif [[ -f $f ]]; then
1230
+ read -r u a < <("$KEYSEAL_BIN_STAT" -c '%u %a' -- "$f") || true
1231
+ if [[ $u != "$uid" ]]; then KEYSEAL_CACHE_REASON=untrusted:owner
1232
+ elif [[ $a != 600 ]]; then KEYSEAL_CACHE_REASON=untrusted:mode
1233
+ fi
1234
+ fi
1235
+ return 0
1236
+ fi
1237
+ if ! keyseal_cache_lock "$KEYSEAL_CACHE_DIR" "$slot" "$(keyseal_seam_seconds KEYSEAL_TEST_LOCK_WAIT_S "$KEYSEAL_SCAN_LOCK_WAIT_S")"; then
1238
+ KEYSEAL_CACHE_STATE=unusable KEYSEAL_CACHE_REASON=lock_busy
1239
+ return 0
1240
+ fi
1241
+ KEYSEAL_CACHE_TOKEN=$(keyseal_cache_token "$f")
1242
+ if [[ $KEYSEAL_CACHE_TOKEN == absent ]]; then judged_state=miss judged_reason=absent
1243
+ elif [[ -L $f ]]; then judged_state=unusable judged_reason=untrusted:symlink
1244
+ elif [[ ! -f $f ]]; then judged_state=unusable judged_reason=untrusted:not_regular
1245
+ else
1246
+ read -r u a < <("$KEYSEAL_BIN_STAT" -c '%u %a' -- "$f") || true
1247
+ if [[ $u != "$uid" ]]; then judged_state=unusable judged_reason=untrusted:owner
1248
+ elif [[ $a != 600 ]]; then judged_state=unusable judged_reason=untrusted:mode
1249
+ else
1250
+ keyseal_cache_parse "$f"
1251
+ if [[ $KEYSEAL_CP_KIND == format ]]; then judged_state=miss judged_reason=format
1252
+ elif [[ $KEYSEAL_CP_KIND == tombstone ]]; then judged_state=miss judged_reason=cleared
1253
+ else
1254
+ KEYSEAL_CACHE_LAST=$KEYSEAL_CP_STAMP
1255
+ judged_state=hit judged_reason=
1256
+ for n in "${KEYSEAL_SCAN_CACHE_INPUTS[@]}"; do
1257
+ if [[ ${KEYSEAL_CP_INPUT[$n]-} != "${KEYSEAL_INPUT[$n]}" ]]; then
1258
+ judged_state=miss judged_reason=input_changed:$n
1259
+ break
1260
+ fi
1261
+ done
1262
+ if [[ $judged_state == hit ]]; then
1263
+ now=$(keyseal_now) || now=
1264
+ if [[ -z $now ]]; then judged_state=miss judged_reason=format
1265
+ elif ((KEYSEAL_CP_STAMP > now + KEYSEAL_SCAN_CACHE_FUTURE_S)); then judged_state=miss judged_reason=future
1266
+ elif ((now - KEYSEAL_CP_STAMP >= KEYSEAL_SCAN_CACHE_TTL_S)); then judged_state=miss judged_reason=expired
1267
+ else
1268
+ KEYSEAL_CACHE_SCAN=$KEYSEAL_CP_SCAN KEYSEAL_CACHE_SCANNED_AT=$KEYSEAL_CP_STAMP
1269
+ KEYSEAL_CACHE_DURATION=$KEYSEAL_CP_DURATION
1270
+ fi
1271
+ fi
1272
+ fi
1273
+ fi
1274
+ fi
1275
+ keyseal_cache_unlock
1276
+ fi
1277
+ if [[ $mode == fresh ]]; then
1278
+ KEYSEAL_CACHE_STATE=miss KEYSEAL_CACHE_REASON=rescan_requested KEYSEAL_CACHE_SCAN=
1279
+ else
1280
+ KEYSEAL_CACHE_STATE=$judged_state KEYSEAL_CACHE_REASON=$judged_reason
1281
+ fi
1282
+ }
1283
+
1284
+ # Create the cache directory (0700 each) and judge its chain. rc 0 trusted.
1285
+ keyseal_cache_prepare_dir() {
1286
+ keyseal_cache_dir || return 1
1287
+ (umask 077 && mkdir -p -- "$KEYSEAL_CACHE_DIR") 2>/dev/null || return 1
1288
+ keyseal_cache_chain "$KEYSEAL_CACHE_DIR" "$(keyseal_uid)" >/dev/null
1289
+ }
1290
+
1291
+ # Under the lock: remove this slot's `.<slot>.json.<pid>.tmp` whose pid is not alive (ours by
1292
+ # name, owner and directory; a SIGKILLed run skipped its trap).
1293
+ keyseal_cache_sweep() {
1294
+ local slot=$1 f pid uid
1295
+ uid=$(keyseal_uid)
1296
+ for f in "$KEYSEAL_CACHE_DIR/.$slot.json."*.tmp; do
1297
+ [[ -f $f && ! -L $f ]] || continue
1298
+ pid=${f##*/."$slot".json.}
1299
+ pid=${pid%.tmp}
1300
+ [[ $pid =~ ^[0-9]+$ ]] || continue
1301
+ [[ $("$KEYSEAL_BIN_STAT" -c %u -- "$f" 2>/dev/null) == "$uid" ]] || continue
1302
+ kill -0 "$pid" 2>/dev/null || rm -f -- "$f"
1303
+ done
1304
+ return 0
1305
+ }
1306
+
1307
+ # Write one line atomically (mktemp-named by pid, 0600, then rename). rc 0 written.
1308
+ keyseal_cache_put() {
1309
+ local slot=$1 line=$2
1310
+ # Test seam only (a no-op without KEYSEAL_TEST_ROOT): a write that fails, as a full disk would.
1311
+ if seam_active && [[ ${KEYSEAL_TEST_FAIL_AT-} == cache_put && ${KEYSEAL_TEST_FAIL_MODE-} == fail ]]; then return 1; fi
1312
+ KEYSEAL_CACHE_TMP=$KEYSEAL_CACHE_DIR/.$slot.json.$$.tmp
1313
+ (umask 077 && printf '%s\n' "$line" >"$KEYSEAL_CACHE_TMP") 2>/dev/null || return 1
1314
+ chmod 0600 -- "$KEYSEAL_CACHE_TMP" || return 1
1315
+ mv -f -- "$KEYSEAL_CACHE_TMP" "$KEYSEAL_CACHE_DIR/$slot.json" || return 1
1316
+ KEYSEAL_CACHE_TMP=
1317
+ }
1318
+
1319
+ # The stored valid generation, or 0 (absent, corrupt or not ours).
1320
+ keyseal_cache_generation() {
1321
+ local f=$1
1322
+ [[ -f $f && ! -L $f ]] || { printf 0; return 0; }
1323
+ keyseal_cache_parse "$f"
1324
+ if [[ $KEYSEAL_CP_KIND == format ]]; then printf 0; else printf '%s' "$KEYSEAL_CP_GEN"; fi
1325
+ }
1326
+
1327
+ # A clean, completed scan: commit only when the file's token still equals the start token. $1
1328
+ # slot, $2 scanned_at (the START), $3 duration, $4 the scan core JSON. Sets KEYSEAL_CACHE_STATE /
1329
+ # _REASON to written or not_written:<reason>.
1330
+ keyseal_cache_commit_clean() {
1331
+ local slot=$1 start=$2 duration=$3 core=$4 f cur gen wait
1332
+ KEYSEAL_CACHE_STATE=not_written
1333
+ if [[ ${KEYSEAL_INPUT[machine_id]} == null ]]; then KEYSEAL_CACHE_REASON=write_failed; return 0; fi
1334
+ if ! keyseal_cache_prepare_dir; then KEYSEAL_CACHE_REASON=untrusted_dir; return 0; fi
1335
+ # No start token (the read found the lock busy): nothing proves no commit happened meanwhile.
1336
+ if [[ -z $KEYSEAL_CACHE_TOKEN ]]; then KEYSEAL_CACHE_REASON=lock_busy; return 0; fi
1337
+ wait=$(keyseal_seam_seconds KEYSEAL_TEST_LOCK_WAIT_S "$KEYSEAL_SCAN_LOCK_WAIT_S")
1338
+ # One retry: a completed 30-minute scan is dropped only after two waits of contention.
1339
+ if ! keyseal_cache_lock "$KEYSEAL_CACHE_DIR" "$slot" "$wait" && ! keyseal_cache_lock "$KEYSEAL_CACHE_DIR" "$slot" "$wait"; then
1340
+ KEYSEAL_CACHE_REASON=lock_busy
1341
+ return 0
1342
+ fi
1343
+ f=$KEYSEAL_CACHE_DIR/$slot.json
1344
+ cur=$(keyseal_cache_token "$f")
1345
+ if [[ $cur != "$KEYSEAL_CACHE_TOKEN" ]]; then
1346
+ keyseal_cache_unlock
1347
+ KEYSEAL_CACHE_REASON=superseded
1348
+ return 0
1349
+ fi
1350
+ keyseal_cache_sweep "$slot"
1351
+ gen=$(keyseal_cache_generation "$f")
1352
+ if keyseal_cache_put "$slot" "{\"format\":\"$KEYSEAL_SCAN_CACHE_FORMAT\",\"generation\":$((gen + 1)),\"inputs\":$(keyseal_cache_inputs_json),\"scanned_at\":$start,\"duration_s\":$duration,\"scan\":$core}"; then
1353
+ KEYSEAL_CACHE_STATE=written KEYSEAL_CACHE_REASON=
1354
+ else
1355
+ KEYSEAL_CACHE_REASON=write_failed
1356
+ fi
1357
+ keyseal_cache_unlock
1358
+ }
1359
+
1360
+ # A match: the tombstone commits UNCONDITIONALLY (whatever the file holds or the token was),
1361
+ # waiting far longer for the lock than a clean write. $1 slot, $2 the hard-error harness name.
1362
+ keyseal_cache_commit_tombstone() {
1363
+ local slot=$1 harness=$2 f gen now wait
1364
+ KEYSEAL_CACHE_STATE=not_written
1365
+ # Unconditional, an unreadable machine id included (#552 review r1): every read treats a
1366
+ # tombstone as miss:cleared before it compares inputs, so it outlives a transient failure.
1367
+ if ! keyseal_cache_prepare_dir; then KEYSEAL_CACHE_REASON=untrusted_dir; return 0; fi
1368
+ wait=$(keyseal_seam_seconds KEYSEAL_TEST_TOMBSTONE_WAIT_S "$KEYSEAL_SCAN_TOMBSTONE_WAIT_S")
1369
+ if ! keyseal_cache_lock "$KEYSEAL_CACHE_DIR" "$slot" 0; then
1370
+ printf 'status: scan: waiting for the scan-cache lock to record a found copy\n' >&2
1371
+ if ! keyseal_cache_lock "$KEYSEAL_CACHE_DIR" "$slot" "$wait"; then
1372
+ KEYSEAL_CACHE_REASON=tombstone_lock_busy
1373
+ printf '%s: tombstone_lock_busy: a plaintext copy was found but could not be recorded in %s; until `agent-board doctor --harness %s --rescan` succeeds, a cached clean result for this slot must not be trusted\n' \
1374
+ "${0##*/}" "$KEYSEAL_CACHE_DIR/$slot.lock" "$harness" >&2
1375
+ return 0
1376
+ fi
1377
+ fi
1378
+ f=$KEYSEAL_CACHE_DIR/$slot.json
1379
+ keyseal_cache_sweep "$slot"
1380
+ gen=$(keyseal_cache_generation "$f")
1381
+ now=$(keyseal_now) || now=0
1382
+ if keyseal_cache_put "$slot" "{\"format\":\"$KEYSEAL_SCAN_CACHE_FORMAT\",\"generation\":$((gen + 1)),\"cleared_at\":$now,\"inputs\":$(keyseal_cache_inputs_json)}"; then
1383
+ KEYSEAL_CACHE_STATE=cleared KEYSEAL_CACHE_REASON=
1384
+ else
1385
+ # The tombstone could not be written (#552 review r6): the previous clean result must not
1386
+ # survive a found copy, so remove it. The next read is then miss:absent and rescans. Only if
1387
+ # that also fails does a clean result remain, and then we say so, as tombstone_lock_busy does.
1388
+ KEYSEAL_CACHE_REASON=write_failed
1389
+ rm -f -- "$KEYSEAL_CACHE_TMP" 2>/dev/null
1390
+ if ! rm -f -- "$f" 2>/dev/null || [[ -e $f || -L $f ]]; then
1391
+ printf '%s: tombstone_write_failed: a plaintext copy was found but neither recorded nor was the old result removed (%s); until `agent-board doctor --harness %s --rescan` succeeds, a cached clean result for this slot must not be trusted\n' \
1392
+ "${0##*/}" "$f" "$harness" >&2
1393
+ fi
588
1394
  fi
589
- json=$(KEYSEAL_SCAN_PIN=$pin keyseal_node classify "$list" "$uid" "$KEYSEAL_SCAN_MAX_BYTES" "$exempt") || { rm -f -- "$list"; return 1; }
590
- rm -f -- "$list"
591
- local jr=() jp=() jn=() jh=()
592
- for r in "${roots[@]}"; do jr+=("$(keyseal_json_str "$r")"); done
593
- for h in "${KEYSEAL_SCAN_PRUNE_NAMES[@]}" "${KEYSEAL_SCAN_PRUNE_PATHS[@]}"; do jp+=("$(keyseal_json_str "$h")"); done
594
- for h in "${KEYSEAL_SCAN_NOT_DETECTED[@]}"; do jn+=("$(keyseal_json_str "$h")"); done
595
- for h in "${KEYSEAL_SCAN_HEADERS[@]}"; do jh+=("$(keyseal_json_str "$h")"); done
596
- local IFS=,
597
- printf '{"roots":[%s],"pruned":[%s],"max_bytes":%s,"headers":[%s],"not_detected":[%s],%s\n' \
598
- "${jr[*]}" "${jp[*]}" "$KEYSEAL_SCAN_MAX_BYTES" "${jh[*]}" "${jn[*]}" "${json#\{}"
1395
+ keyseal_cache_unlock
599
1396
  }