@consiliency/agent-board-cli 1.27.1 → 1.28.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,731 @@ 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 scan stage: ONE child of `timeout`, run as `bash -c` so the deadline covers find, xargs,
736
+ # grep AND the classifier (IF-552-2 "Self-bound"). Args: rc file, json file, count file, pruned
737
+ # file, list file, uid, exempt path, roots...; the pin is in KEYSEAL_SCAN_PIN. It writes its
738
+ # results to files; status.sh reads them only when the stage completed.
739
+ keyseal_scan_stage() {
740
+ local rcf=$1 jsonf=$2 count=$3 pruned=$4 list=$5 uid=$6 exempt=$7 find grep i=0 h
741
+ shift 7
742
+ local -a expr greps
743
+ find=$(seam_resolve KEYSEAL_TEST_FIND "$KEYSEAL_BIN_FIND") || exit 3
744
+ grep=$(seam_resolve KEYSEAL_TEST_GREP "$KEYSEAL_BIN_GREP") || exit 3
745
+ # One clause per prune entry, in report order; each writes its INDEX, never the path. The
746
+ # first matching clause wins. Every directory that is entered writes one byte to the count.
747
+ expr=(\( -type d \()
748
+ for h in "${KEYSEAL_SCAN_PRUNE_NAMES[@]}"; do expr+=(\( -name "$h" -prune -fprintf "$pruned" "$i\\n" \) -o); ((++i)); done
749
+ for h in "${KEYSEAL_SCAN_PRUNE_PATHS[@]}"; do expr+=(\( -path "*/$h" -prune -fprintf "$pruned" "$i\\n" \) -o); ((++i)); done
750
+ unset 'expr[${#expr[@]}-1]'
751
+ expr+=(\) \) -o \( -type d -fprintf "$count" x \) -o \( -type f -user "$uid" -size -$((KEYSEAL_SCAN_MAX_BYTES + 1))c -print0 \))
577
752
  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]}
753
+ "$find" -H "$@" "${expr[@]}" 2>/dev/null \
754
+ | "$KEYSEAL_BIN_XARGS" -0 -r "$grep" -lsZ -x -F "${greps[@]}" -- >"$list"
755
+ local rc_find=${PIPESTATUS[0]} rc_xargs=${PIPESTATUS[1]} rc_node=-
583
756
  # find: 0, or 1 when some directory was unreadable. xargs: 0, or 123 when some grep found
584
757
  # 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
758
+ if ((rc_find <= 1)) && ((rc_xargs == 0 || rc_xargs == 123)); then
759
+ local delay=
760
+ seam_active && delay=${KEYSEAL_TEST_CLASSIFY_DELAY_MS-}
761
+ KEYSEAL_SCAN_DELAY_MS=$delay keyseal_node classify "$list" "$uid" "$KEYSEAL_SCAN_MAX_BYTES" "$exempt" "$pruned" \
762
+ "${KEYSEAL_SCAN_PRUNE_NAMES[@]}" "${KEYSEAL_SCAN_PRUNE_PATHS[@]}" >"$jsonf" && rc_node=0 || rc_node=$?
763
+ fi
764
+ printf '%s %s %s\n' "$rc_find" "$rc_xargs" "$rc_node" >"$rcf"
765
+ }
766
+
767
+ # The parent pid of pid $1, from /proc (the field after the state). rc 1 when $1 is gone.
768
+ keyseal_ppid_of() {
769
+ local s
770
+ s=$(cat -- "/proc/$1/stat" 2>/dev/null) || return 1
771
+ s=${s##*) }
772
+ # shellcheck disable=SC2086
773
+ set -- $s
774
+ printf '%s' "$2"
775
+ }
776
+
777
+ # The pgid of pid $1, from /proc (the fields after the command name: state ppid pgrp).
778
+ keyseal_pgid_of() {
779
+ local s
780
+ s=$(cat -- "/proc/$1/stat" 2>/dev/null) || return 1
781
+ s=${s##*) }
782
+ # shellcheck disable=SC2086
783
+ set -- $s
784
+ printf '%s' "$3"
785
+ }
786
+
787
+ # The stage group led by `timeout` (pid $1) is gone within the grace, else KILLed group-wide
788
+ # (president follow-up 4: uutils' --kill-after reaches only timeout's direct child, so the
789
+ # escalation never depends on timeout being alive).
790
+ keyseal_stage_reap() {
791
+ local pg=$1 i
792
+ for ((i = 0; i < KEYSEAL_SCAN_KILL_AFTER_S * 10; i++)); do
793
+ kill -0 -- "-$pg" 2>/dev/null || return 0
794
+ "$SLEEP" 0.1
795
+ done
796
+ kill -KILL -- "-$pg" 2>/dev/null || true
797
+ }
798
+
799
+ # The progress ticker, in the background for the stage's lifetime. Every period it prints the
800
+ # frozen progress line (the cli parses <N>). When status.sh's parent OR status.sh itself is gone
801
+ # (president follow-up 3), in this order: it TERMs timeout (whose group forward ends a stage that
802
+ # honours TERM), REAPS the stage group itself (grace, then a group KILL: no trap may be left to do
803
+ # it, and uutils' --kill-after reaches only its direct child), and only then TERMs status.sh (its
804
+ # whole group when status.sh leads one, as the cli's detached spawn makes it). When only the cli
805
+ # died, status.sh is still alive while the stage is reaped: its `wait` returns and it records a
806
+ # stage that did not complete (never a clean write), unless the TERM that follows ends it first.
807
+ #
808
+ # Residual (#552 review r3, S3): if status.sh is SIGKILLed in the microseconds after it reaped
809
+ # `timeout` and before it stops the ticker, the ticker may signal a pid (or pgid) the kernel has
810
+ # already reused. The window is between two adjacent statements; it is recorded, not closed.
811
+ #
812
+ # The PARENT watch is armed only when the cli spawned status.sh: its helper exports
813
+ # KEYSEAL_SCAN_WATCH_PID=<its own pid>, and the watch applies only while that equals status.sh's
814
+ # parent. A hand run (`nohup status.sh … & disown` over ssh, the fleet's pattern for long jobs) is
815
+ # reparented when its shell exits, and must keep scanning (#552 review r1). The SELF watch is always
816
+ # armed. $4 is the parent to watch, or empty.
817
+ keyseal_scan_ticker() {
818
+ local period=$1 count=$2 tpid=$3 parent=$4 self=$5 pg n sp= me=$BASHPID
819
+ pg=$(keyseal_pgid_of "$self") || pg=
820
+ # Liveness is checked every KEYSEAL_SCAN_LIVENESS_S (a sub-tick), the progress line printed every
821
+ # period: a dead cli or status.sh ends the stage within seconds, not a whole progress period.
822
+ local step=$KEYSEAL_SCAN_LIVENESS_S elapsed=0
823
+ ((period < step)) && step=$period
824
+ trap '[[ -n $sp ]] && kill "$sp" 2>/dev/null; exit 0' TERM
825
+ while :; do
826
+ "$SLEEP" "$step" &
827
+ sp=$!
828
+ wait "$sp" || true
829
+ sp=
830
+ ((elapsed += step))
831
+ # The parent is gone when it no longer exists, OR when status.sh is no longer its child: a dead
832
+ # cli's pid can be a zombie (kill -0 succeeds) or reused, but status.sh has been reparented.
833
+ # status.sh is gone when it is not alive, OR when the ticker is no longer its child: a dead
834
+ # status.sh reparents the ticker at once, zombie or not, and a reused pid is not our parent
835
+ # (#552 review r4). The ticker is status.sh's own subshell, so its parent IS status.sh. Its pid
836
+ # is taken once, above: `$BASHPID` inside `$(…)` would name the substitution's own subshell.
837
+ if { [[ -n $parent ]] && { ! keyseal_alive "$parent" || [[ $(keyseal_ppid_of "$self") != "$parent" ]]; }; } ||
838
+ ! keyseal_alive "$self" || [[ $(keyseal_ppid_of "$me") != "$self" ]]; then
839
+ kill -TERM "$tpid" 2>/dev/null || true
840
+ # The escalation is ours to finish: a SIGKILLed status.sh runs no trap, and uutils timeout's
841
+ # --kill-after reaches only its direct child (#552 review r2, codex). Grace, then group KILL.
842
+ keyseal_stage_reap "$tpid"
843
+ if [[ $pg == "$self" ]]; then kill -TERM -- "-$pg" 2>/dev/null || true
844
+ else kill -TERM "$self" 2>/dev/null || true
845
+ fi
846
+ exit 0
847
+ fi
848
+ ((elapsed >= period)) || continue
849
+ elapsed=0
850
+ n=0
851
+ [[ -f $count ]] && n=$("$KEYSEAL_BIN_STAT" -c %s -- "$count" 2>/dev/null || printf 0)
852
+ printf 'status: scan: still scanning (%s folders so far)\n' "$n" >&2
853
+ done
854
+ }
855
+
856
+ # pid $1 is a live process: the ONE liveness test for the ticker's parent and self watches. A
857
+ # zombie (Z) or dead (X) entry is gone, though `kill -0` still succeeds on it (#552 review r3,
858
+ # codex: an unreaped SIGKILLed status.sh kept its ticker waiting). `kill -0` alone reads EPERM (a
859
+ # process of another uid) as gone; /proc does not. Fallback, stated: where /proc/<pid>/stat cannot
860
+ # be read (a `hidepid=` mount hiding another uid's processes) this falls back to `kill -0`, which
861
+ # counts a zombie as alive. The ticker's self watch does not depend on it (the ticker's own parent).
862
+ # And where /proc cannot be read at all, keyseal_ppid_of fails, so the ticker's own-parent check
863
+ # fires on its first tick: the scan is stopped at once and never yields a clean result, which is
864
+ # loud (every scan fails), never silent.
865
+ keyseal_alive() {
866
+ local s
867
+ s=$(cat -- "/proc/$1/stat" 2>/dev/null) || { kill -0 "$1" 2>/dev/null; return; }
868
+ s=${s##*) }
869
+ [[ ${s:0:1} != [ZX] ]]
870
+ }
871
+
872
+ # status.sh's EXIT trap: the ticker, the stage (TERM forwarded to timeout, then group-wide KILL
873
+ # after the grace), and every temp file this run made. A SIGKILL skips it: the stage is still
874
+ # bounded by timeout, and the next cache write removes a dead run's .tmp.
875
+ keyseal_scan_cleanup() {
876
+ if [[ -n $KEYSEAL_SCAN_TICKER ]]; then kill -TERM "$KEYSEAL_SCAN_TICKER" 2>/dev/null || true; fi
877
+ if [[ -n $KEYSEAL_SCAN_TPID ]]; then
878
+ kill -TERM "$KEYSEAL_SCAN_TPID" 2>/dev/null || true
879
+ keyseal_stage_reap "$KEYSEAL_SCAN_TPID"
880
+ KEYSEAL_SCAN_TPID=
881
+ fi
882
+ rm -f -- ${KEYSEAL_SCAN_TMPS[@]+"${KEYSEAL_SCAN_TMPS[@]}"}
883
+ if [[ -n $KEYSEAL_CACHE_TMP ]]; then rm -f -- "$KEYSEAL_CACHE_TMP"; fi
884
+ }
885
+
886
+ # Run the scan stage under one deadline. $1 pin, $2 exempt path, $3 deadline (s). Sets
887
+ # KEYSEAL_SCAN_OUTCOME (ok | incomplete | timed_out), KEYSEAL_SCAN_CLASSIFIED (the classifier's
888
+ # JSON when ok) and KEYSEAL_SCAN_FOLDERS (directories entered, from the count file's size).
889
+ keyseal_scan_run() {
890
+ local pin=$1 exempt=$2 deadline=$3 list count pruned rcf jsonf trc=0 period rcs
891
+ KEYSEAL_SCAN_OUTCOME=incomplete KEYSEAL_SCAN_CLASSIFIED= KEYSEAL_SCAN_FOLDERS=0
892
+ keyseal_scan_mktemp list && keyseal_scan_mktemp count && keyseal_scan_mktemp pruned &&
893
+ keyseal_scan_mktemp rcf && keyseal_scan_mktemp jsonf || return 0
894
+ if seam_active && [[ ${KEYSEAL_TEST_COUNT_FIFO-} == 1 ]]; then
895
+ # SC-16's seam: a FIFO with no reader, so the REAL find blocks opening it.
896
+ rm -f -- "$count" && "$KEYSEAL_BIN_MKFIFO" -m 0600 -- "$count" || return 0
897
+ fi
898
+ local -a starts=()
899
+ local root
900
+ for root in "${KEYSEAL_ROOTS[@]}"; do starts+=("$(keyseal_scan_start "$root")"); done
901
+ # No --foreground: timeout leads its own process group and signals all of it (IF-552-2). The
902
+ # seam KEYSEAL_TEST_TIMEOUT (test root only) substitutes a timeout with uutils' semantics, so a
903
+ # GNU runner can prove what only uutils would otherwise expose (#552 review r3).
904
+ local timeout_bin
905
+ timeout_bin=$(seam_resolve KEYSEAL_TEST_TIMEOUT "$KEYSEAL_BIN_TIMEOUT") || return 0
906
+ KEYSEAL_SCAN_PIN=$pin "$timeout_bin" --kill-after="$KEYSEAL_SCAN_KILL_AFTER_S" "$deadline" \
907
+ /bin/bash -c '. "$0"; keyseal_scan_stage "$@"' "$keyseal_here/lib.sh" \
908
+ "$rcf" "$jsonf" "$count" "$pruned" "$list" "$(id -u)" "$exempt" "${starts[@]}" >/dev/null &
909
+ KEYSEAL_SCAN_TPID=$!
910
+ period=$(keyseal_seam_seconds KEYSEAL_TEST_SCAN_PROGRESS_S "$KEYSEAL_SCAN_PROGRESS_S")
911
+ local watch=
912
+ [[ ${KEYSEAL_SCAN_WATCH_PID-} =~ ^[0-9]+$ && $KEYSEAL_SCAN_WATCH_PID == "$PPID" ]] && watch=$PPID
913
+ keyseal_scan_ticker "$period" "$count" "$KEYSEAL_SCAN_TPID" "$watch" "$$" &
914
+ KEYSEAL_SCAN_TICKER=$!
915
+ wait "$KEYSEAL_SCAN_TPID" || trc=$?
916
+ # 137: timeout already waited --kill-after and KILLed its child; a survivor there gets no
917
+ # further grace. Otherwise a survivor has the grace, then a group-wide KILL.
918
+ if ((trc == 137)); then kill -KILL -- "-$KEYSEAL_SCAN_TPID" 2>/dev/null || true; fi
919
+ keyseal_stage_reap "$KEYSEAL_SCAN_TPID"
920
+ KEYSEAL_SCAN_TPID=
921
+ kill -TERM "$KEYSEAL_SCAN_TICKER" 2>/dev/null || true
922
+ wait "$KEYSEAL_SCAN_TICKER" 2>/dev/null || true
923
+ KEYSEAL_SCAN_TICKER=
924
+ [[ -f $count ]] && KEYSEAL_SCAN_FOLDERS=$("$KEYSEAL_BIN_STAT" -c %s -- "$count" 2>/dev/null || printf 0)
925
+ # 124: the deadline; 137: timeout's own KILL after --kill-after. Partial output is never read.
926
+ if ((trc == 124 || trc == 137)); then KEYSEAL_SCAN_OUTCOME=timed_out; return 0; fi
927
+ ((trc == 0)) || return 0
928
+ rcs=$(<"$rcf") || return 0
929
+ [[ $rcs =~ ^[01]\ (0|123)\ 0$ ]] || return 0
930
+ KEYSEAL_SCAN_CLASSIFIED=$(<"$jsonf") || return 0
931
+ [[ $KEYSEAL_SCAN_CLASSIFIED == \{* ]] || return 0
932
+ KEYSEAL_SCAN_OUTCOME=ok
933
+ }
934
+
935
+ # The scan object's fixed part: scope, positive prune report and verdicts. This is what a cache
936
+ # stores, WITHOUT `headers`: the cache file lives under $HOME, and the PEM header strings are never
937
+ # written there at all. scope_sha256 covers the headers, so a hit re-adds the current ones
938
+ # (keyseal_scan_with_headers) and a cached report is shape-identical to a fresh one.
939
+ keyseal_scan_core_json() {
940
+ printf '{"roots":%s,"roots_skipped":%s,"pruned":%s,"max_bytes":%s,"not_detected":%s,%s' \
941
+ "$(keyseal_json_list ${KEYSEAL_ROOTS[@]+"${KEYSEAL_ROOTS[@]}"})" "$KEYSEAL_ROOTS_SKIPPED_JSON" \
942
+ "$(keyseal_json_list "${KEYSEAL_SCAN_PRUNE_NAMES[@]}" "${KEYSEAL_SCAN_PRUNE_PATHS[@]}")" \
943
+ "$KEYSEAL_SCAN_MAX_BYTES" \
944
+ "$(keyseal_json_list "${KEYSEAL_SCAN_NOT_DETECTED[@]}")" "${KEYSEAL_SCAN_CLASSIFIED#\{}"
945
+ }
946
+ keyseal_scan_with_headers() { printf '{"headers":%s,%s' "$(keyseal_json_list "${KEYSEAL_SCAN_HEADERS[@]}")" "${1#\{}"; }
947
+
948
+ # ---- the cache: location and directory trust (#536 framed against PR #550) --------------------
949
+
950
+ # The state root: XDG_STATE_HOME if set, else $HOME/.local/state. rc 2 when it is relative.
951
+ keyseal_state_root() {
952
+ local root=${XDG_STATE_HOME-}
953
+ [[ -n ${root//[[:space:]]/} ]] || root=$HOME/.local/state
954
+ [[ $root == /* ]] || return 2
955
+ printf '%s' "$root"
956
+ }
957
+
958
+ # A decimal field as JS Number() reads the forms /etc/group and /etc/passwd hold: optional
959
+ # surrounding blanks, digits only. Prints the value, rc 1 otherwise.
960
+ keyseal_num_field() {
961
+ local v=$1
962
+ v=${v#"${v%%[![:space:]]*}"}
963
+ v=${v%"${v##*[![:space:]]}"}
964
+ [[ $v =~ ^[0-9]+$ ]] || return 1
965
+ printf '%s' "$((10#$v))"
966
+ }
967
+
968
+ # Split a line on ':' keeping empty and trailing fields (as JS split does) into KEYSEAL_FIELDS.
969
+ keyseal_split_colon() {
970
+ local line=$1
971
+ KEYSEAL_FIELDS=()
972
+ while [[ $line == *:* ]]; do
973
+ KEYSEAL_FIELDS+=("${line%%:*}")
974
+ line=${line#*:}
975
+ done
976
+ KEYSEAL_FIELDS+=("$line")
977
+ }
978
+
979
+ # isPrivateGroup (claude_marketplace.ts), ported: gid $1 is the user's PRIVATE group. $2 the
980
+ # process's primary gid, $3 the user name, $4 the uid. Proven only from /etc/group and
981
+ # /etc/passwd (seams KEYSEAL_TEST_GROUP_FILE / KEYSEAL_TEST_PASSWD_FILE, under the test root):
982
+ # exactly one group entry for the gid, named as the user, members a subset of {user}; exactly one
983
+ # account with that primary gid, and it is this user, name and uid. rc 0 private.
984
+ keyseal_private_group() {
985
+ local gid=$1 primary=$2 user=$3 uid=$4 groupf passwdf line n m members gname
986
+ local -a groups_ accounts
987
+ [[ -n $user && $gid =~ ^[0-9]+$ && $primary =~ ^[0-9]+$ ]] || return 1
988
+ ((10#$gid == 10#$primary)) || return 1
989
+ groupf=$(seam_resolve KEYSEAL_TEST_GROUP_FILE /etc/group) || return 1
990
+ passwdf=$(seam_resolve KEYSEAL_TEST_PASSWD_FILE /etc/passwd) || return 1
991
+ [[ -r $groupf && -r $passwdf ]] || return 1
992
+ groups_=()
993
+ while IFS= read -r line || [[ -n $line ]]; do
994
+ keyseal_split_colon "$line"
995
+ ((${#KEYSEAL_FIELDS[@]} >= 4)) && [[ -n ${KEYSEAL_FIELDS[2]} ]] || continue
996
+ n=$(keyseal_num_field "${KEYSEAL_FIELDS[2]}") || continue
997
+ if ((n == 10#$gid)); then groups_+=("${KEYSEAL_FIELDS[0]}:${KEYSEAL_FIELDS[3]}"); fi
998
+ # No `|| return 1` here: a loop's status is its LAST body command's, so a last line that is not
999
+ # the user's group made this "not private" (claw, SL-3.3: /etc/group ends `mbold:x:1002:`).
1000
+ # The files were checked readable above; SC-21 forbids a loop status that leaks.
1001
+ done <"$groupf"
1002
+ ((${#groups_[@]} == 1)) || return 1
1003
+ gname=${groups_[0]%%:*}
1004
+ [[ $gname == "$user" ]] || return 1
1005
+ members=${groups_[0]#*:}
1006
+ IFS=, read -ra accounts <<<"$members"
1007
+ for m in ${accounts[@]+"${accounts[@]}"}; do
1008
+ m=${m#"${m%%[![:space:]]*}"}
1009
+ m=${m%"${m##*[![:space:]]}"}
1010
+ [[ -z $m || $m == "$user" ]] || return 1
1011
+ done
1012
+ accounts=()
1013
+ while IFS= read -r line || [[ -n $line ]]; do
1014
+ keyseal_split_colon "$line"
1015
+ ((${#KEYSEAL_FIELDS[@]} >= 4)) && [[ -n ${KEYSEAL_FIELDS[3]} ]] || continue
1016
+ n=$(keyseal_num_field "${KEYSEAL_FIELDS[3]}") || continue
1017
+ if ((n == 10#$gid)); then accounts+=("${KEYSEAL_FIELDS[0]}:${KEYSEAL_FIELDS[2]}"); fi
1018
+ done <"$passwdf"
1019
+ ((${#accounts[@]} == 1)) || return 1
1020
+ [[ ${accounts[0]%%:*} == "$user" ]] || return 1
1021
+ n=$(keyseal_num_field "${accounts[0]#*:}") || return 1
1022
+ [[ $uid =~ ^[0-9]+$ ]] && ((n == 10#$uid))
1023
+ }
1024
+
1025
+ # Judge one directory the walk ENTERED ($1 path, $2 role: dir | parent | ancestor, $3 uid).
1026
+ # Prints the cause and returns 1 when it is not trusted.
1027
+ keyseal_trust_enter() {
1028
+ local path=$1 role=$2 uid=$3 u g a mode own=no
1029
+ [[ $role != ancestor ]] && own=yes
1030
+ if [[ -L $path || ! -d $path ]]; then printf 'not_directory %s' "$path"; return 1; fi
1031
+ read -r u g a < <("$KEYSEAL_BIN_STAT" -c '%u %g %a' -- "$path") || { printf 'not_directory %s' "$path"; return 1; }
1032
+ if [[ $own == yes ]]; then
1033
+ [[ $u == "$uid" ]] || { printf 'foreign_owner %s' "$path"; return 1; }
1034
+ else
1035
+ [[ $u == "$uid" || $u == 0 ]] || { printf 'foreign_owner %s' "$path"; return 1; }
1036
+ fi
1037
+ mode=$((8#$a))
1038
+ ((mode & 8#022)) || return 0
1039
+ if [[ $own == no && $u == 0 ]] && ((mode & 8#1000)); then return 0; fi
1040
+ if [[ $own == no && $u == "$uid" ]] && ! ((mode & 8#002)) &&
1041
+ keyseal_private_group "$g" "$(id -g)" "$(id -un)" "$uid"; then
1042
+ return 0
1043
+ fi
1044
+ printf 'writable %s' "$path"
1045
+ return 1
1046
+ }
1047
+
1048
+ # resolveTrustChain (claude_marketplace.ts), ported: walk $1 from `/` one component at a time,
1049
+ # as the kernel does. The last two components (keyslot-scan/ and its agent-board/ parent) are
1050
+ # judged strictly and may never be symlinks; every other directory entered by the ancestor rule.
1051
+ # A symlinked ancestor is judged by its own owner (this uid or root) and its target spliced into
1052
+ # the remaining components; more than 40 hops is a loop. Stops at the first missing component.
1053
+ # rc 0 trusted; else prints `<cause> <path>` and returns 1. $2 is the uid.
1054
+ keyseal_cache_chain() {
1055
+ local target=$1 uid=$2 current=/ name path role to u hops=0
1056
+ local -a queue parts
1057
+ [[ $target == /* ]] || { printf 'not_absolute %s' "$target"; return 1; }
1058
+ keyseal_trust_enter / ancestor "$uid" || return 1
1059
+ IFS=/ read -ra parts <<<"$target"
1060
+ queue=()
1061
+ for name in "${parts[@]}"; do [[ -n $name ]] && queue+=("$name"); done
1062
+ while ((${#queue[@]})); do
1063
+ name=${queue[0]}
1064
+ queue=("${queue[@]:1}")
1065
+ if ((${#queue[@]} == 0)); then role=dir; elif ((${#queue[@]} == 1)); then role=parent; else role=ancestor; fi
1066
+ [[ $name == . ]] && continue
1067
+ if [[ $name == .. ]]; then
1068
+ current=${current%/*}
1069
+ [[ -n $current ]] || current=/
1070
+ continue
1071
+ fi
1072
+ if [[ $current == / ]]; then path=/$name; else path=$current/$name; fi
1073
+ [[ -e $path || -L $path ]] || break
1074
+ if [[ -L $path ]]; then
1075
+ [[ $role == ancestor ]] || { printf 'symlink %s' "$path"; return 1; }
1076
+ u=$("$KEYSEAL_BIN_STAT" -c %u -- "$path") || { printf 'not_directory %s' "$path"; return 1; }
1077
+ [[ $u == "$uid" || $u == 0 ]] || { printf 'foreign_owner %s' "$path"; return 1; }
1078
+ ((++hops > 40)) && { printf 'symlink_loop %s' "$path"; return 1; }
1079
+ to=$(readlink -- "$path") || { printf 'not_directory %s' "$path"; return 1; }
1080
+ [[ $to == /* ]] && current=/
1081
+ IFS=/ read -ra parts <<<"$to"
1082
+ local -a spliced=()
1083
+ for name in ${parts[@]+"${parts[@]}"}; do [[ -n $name ]] && spliced+=("$name"); done
1084
+ queue=(${spliced[@]+"${spliced[@]}"} ${queue[@]+"${queue[@]}"})
1085
+ continue
1086
+ fi
1087
+ keyseal_trust_enter "$path" "$role" "$uid" || return 1
1088
+ current=$path
1089
+ done
1090
+ return 0
1091
+ }
1092
+
1093
+ # ---- the cache: tokens, lock, read and judge, commit -------------------------------------------
1094
+
1095
+ # The start token: the file's CONTENT HASH (never its generation), or `absent`. A symlink or a
1096
+ # non-regular file is hashed by what it is, never read.
1097
+ keyseal_cache_token() {
1098
+ local f=$1 h
1099
+ if [[ -L $f ]]; then printf 'symlink:%s' "$(keyseal_sha256_str "$(readlink -- "$f")")"
1100
+ elif [[ ! -e $f ]]; then printf absent
1101
+ elif [[ -f $f ]]; then
1102
+ if h=$(keyseal_sha256_file "$f"); then printf 'sha:%s' "$h"
1103
+ else printf 'unreadable:%s' "$("$KEYSEAL_BIN_STAT" -c '%d:%i:%Y' -- "$f" 2>/dev/null)"
1104
+ fi
1105
+ else printf 'other:%s' "$("$KEYSEAL_BIN_STAT" -c '%d:%i:%F' -- "$f" 2>/dev/null)"
1106
+ fi
1107
+ }
1108
+
1109
+ # Take the slot's cache lock ($1 dir, $2 slot, $3 wait s): a 0600 lock file in the trusted dir.
1110
+ keyseal_cache_lock() {
1111
+ local lock=$1/$2.lock
1112
+ (umask 077 && : >>"$lock") 2>/dev/null || return 1
1113
+ exec {KEYSEAL_CACHE_LFD}>>"$lock" || return 1
1114
+ if "$FLOCK" -w "$3" "$KEYSEAL_CACHE_LFD"; then return 0; fi
1115
+ exec {KEYSEAL_CACHE_LFD}>&-
1116
+ KEYSEAL_CACHE_LFD=
1117
+ return 1
1118
+ }
1119
+ keyseal_cache_unlock() {
1120
+ if [[ -n $KEYSEAL_CACHE_LFD ]]; then exec {KEYSEAL_CACHE_LFD}>&-; fi
1121
+ KEYSEAL_CACHE_LFD=
1122
+ }
1123
+
1124
+ # The cache directory, into KEYSEAL_CACHE_DIR; rc 2 when the state root is relative.
1125
+ keyseal_cache_dir() {
1126
+ local root
1127
+ root=$(keyseal_state_root) || return 2
1128
+ KEYSEAL_CACHE_DIR=$root/agent-board/keyslot-scan
1129
+ }
1130
+
1131
+ # Parse a cache file into KEYSEAL_CP_KIND (format | clean | tombstone), KEYSEAL_CP_GEN,
1132
+ # KEYSEAL_CP_STAMP (scanned_at or cleared_at), KEYSEAL_CP_DURATION, KEYSEAL_CP_SCAN and
1133
+ # KEYSEAL_CP_INPUT[<name>] (compact JSON).
1134
+ keyseal_cache_parse() {
1135
+ local line kind rest
1136
+ KEYSEAL_CP_INPUT=()
1137
+ KEYSEAL_CP_KIND=format KEYSEAL_CP_GEN=0 KEYSEAL_CP_STAMP= KEYSEAL_CP_DURATION= KEYSEAL_CP_SCAN=
1138
+ while IFS= read -r line; do
1139
+ kind=${line%%$'\t'*}
1140
+ rest=${line#*$'\t'}
1141
+ case $kind in
1142
+ clean) KEYSEAL_CP_KIND=clean; IFS=$'\t' read -r KEYSEAL_CP_GEN KEYSEAL_CP_STAMP KEYSEAL_CP_DURATION <<<"$rest" ;;
1143
+ tombstone) KEYSEAL_CP_KIND=tombstone; IFS=$'\t' read -r KEYSEAL_CP_GEN KEYSEAL_CP_STAMP <<<"$rest" ;;
1144
+ input) KEYSEAL_CP_INPUT[${rest%%$'\t'*}]=${rest#*$'\t'} ;;
1145
+ scan) KEYSEAL_CP_SCAN=$rest ;;
1146
+ *) KEYSEAL_CP_KIND=format ;;
1147
+ esac
1148
+ done < <(keyseal_node cacheparse "$1" "$KEYSEAL_SCAN_CACHE_FORMAT" "${KEYSEAL_SCAN_CACHE_INPUTS[@]}" 2>/dev/null || printf 'format\n')
1149
+ return 0
1150
+ }
1151
+
1152
+ # The inputs object for this run, from KEYSEAL_INPUT[<name>] (compact JSON values).
1153
+ keyseal_cache_inputs_json() {
1154
+ local n out=
1155
+ for n in "${KEYSEAL_SCAN_CACHE_INPUTS[@]}"; do out+="${out:+,}\"$n\":${KEYSEAL_INPUT[$n]}"; done
1156
+ printf '{%s}' "$out"
1157
+ }
1158
+
1159
+ # Read and judge the slot's cache under the lock. $1 slot, $2 mode (fresh | use | only). Sets
1160
+ # KEYSEAL_CACHE_STATE / _REASON (IF-552-2's vocabulary), KEYSEAL_CACHE_TOKEN (the start token,
1161
+ # empty when none could be recorded) and, on a hit, KEYSEAL_CACHE_SCAN / _SCANNED_AT / _DURATION.
1162
+ keyseal_cache_read() {
1163
+ keyseal_cache_judge "$@"
1164
+ # `fresh` never reads the cache: whatever the judge found, the reported read is rescan_requested
1165
+ # (the mode table), on the early-return paths too. The start token it recorded, or not, stands.
1166
+ if [[ $2 == fresh ]]; then KEYSEAL_CACHE_STATE=miss KEYSEAL_CACHE_REASON=rescan_requested KEYSEAL_CACHE_SCAN=; fi
1167
+ }
1168
+ keyseal_cache_judge() {
1169
+ local slot=$1 mode=$2 f now uid u a problem n
1170
+ KEYSEAL_CACHE_STATE=miss KEYSEAL_CACHE_REASON= KEYSEAL_CACHE_TOKEN= KEYSEAL_CACHE_SCAN=
1171
+ KEYSEAL_CACHE_SCANNED_AT= KEYSEAL_CACHE_DURATION= KEYSEAL_CACHE_LAST=
1172
+ local judged_state judged_reason
1173
+ if ! keyseal_cache_dir; then KEYSEAL_CACHE_STATE=unusable KEYSEAL_CACHE_REASON=state_root_relative; return 0; fi
1174
+ if [[ ${KEYSEAL_INPUT[machine_id]} == null ]]; then KEYSEAL_CACHE_STATE=unusable KEYSEAL_CACHE_REASON=machine_id_unreadable; return 0; fi
1175
+ f=$KEYSEAL_CACHE_DIR/$slot.json
1176
+ if [[ ! -d $KEYSEAL_CACHE_DIR ]]; then
1177
+ # Nothing to lock yet: a commit from this start needs the file still absent, under the lock.
1178
+ KEYSEAL_CACHE_TOKEN=absent
1179
+ judged_state=miss judged_reason=absent
1180
+ else
1181
+ uid=$(keyseal_uid)
1182
+ if ! problem=$(keyseal_cache_chain "$KEYSEAL_CACHE_DIR" "$uid"); then
1183
+ # An untrusted directory is never locked or read. The file's own lstat, when it names a
1184
+ # problem, is the more specific reason.
1185
+ KEYSEAL_CACHE_STATE=unusable KEYSEAL_CACHE_REASON=untrusted:ancestor
1186
+ if [[ -L $f ]]; then KEYSEAL_CACHE_REASON=untrusted:symlink
1187
+ elif [[ -e $f && ! -f $f ]]; then KEYSEAL_CACHE_REASON=untrusted:not_regular
1188
+ elif [[ -f $f ]]; then
1189
+ read -r u a < <("$KEYSEAL_BIN_STAT" -c '%u %a' -- "$f") || true
1190
+ if [[ $u != "$uid" ]]; then KEYSEAL_CACHE_REASON=untrusted:owner
1191
+ elif [[ $a != 600 ]]; then KEYSEAL_CACHE_REASON=untrusted:mode
1192
+ fi
1193
+ fi
1194
+ return 0
1195
+ fi
1196
+ if ! keyseal_cache_lock "$KEYSEAL_CACHE_DIR" "$slot" "$(keyseal_seam_seconds KEYSEAL_TEST_LOCK_WAIT_S "$KEYSEAL_SCAN_LOCK_WAIT_S")"; then
1197
+ KEYSEAL_CACHE_STATE=unusable KEYSEAL_CACHE_REASON=lock_busy
1198
+ return 0
1199
+ fi
1200
+ KEYSEAL_CACHE_TOKEN=$(keyseal_cache_token "$f")
1201
+ if [[ $KEYSEAL_CACHE_TOKEN == absent ]]; then judged_state=miss judged_reason=absent
1202
+ elif [[ -L $f ]]; then judged_state=unusable judged_reason=untrusted:symlink
1203
+ elif [[ ! -f $f ]]; then judged_state=unusable judged_reason=untrusted:not_regular
1204
+ else
1205
+ read -r u a < <("$KEYSEAL_BIN_STAT" -c '%u %a' -- "$f") || true
1206
+ if [[ $u != "$uid" ]]; then judged_state=unusable judged_reason=untrusted:owner
1207
+ elif [[ $a != 600 ]]; then judged_state=unusable judged_reason=untrusted:mode
1208
+ else
1209
+ keyseal_cache_parse "$f"
1210
+ if [[ $KEYSEAL_CP_KIND == format ]]; then judged_state=miss judged_reason=format
1211
+ elif [[ $KEYSEAL_CP_KIND == tombstone ]]; then judged_state=miss judged_reason=cleared
1212
+ else
1213
+ KEYSEAL_CACHE_LAST=$KEYSEAL_CP_STAMP
1214
+ judged_state=hit judged_reason=
1215
+ for n in "${KEYSEAL_SCAN_CACHE_INPUTS[@]}"; do
1216
+ if [[ ${KEYSEAL_CP_INPUT[$n]-} != "${KEYSEAL_INPUT[$n]}" ]]; then
1217
+ judged_state=miss judged_reason=input_changed:$n
1218
+ break
1219
+ fi
1220
+ done
1221
+ if [[ $judged_state == hit ]]; then
1222
+ now=$(keyseal_now) || now=
1223
+ if [[ -z $now ]]; then judged_state=miss judged_reason=format
1224
+ elif ((KEYSEAL_CP_STAMP > now + KEYSEAL_SCAN_CACHE_FUTURE_S)); then judged_state=miss judged_reason=future
1225
+ elif ((now - KEYSEAL_CP_STAMP >= KEYSEAL_SCAN_CACHE_TTL_S)); then judged_state=miss judged_reason=expired
1226
+ else
1227
+ KEYSEAL_CACHE_SCAN=$KEYSEAL_CP_SCAN KEYSEAL_CACHE_SCANNED_AT=$KEYSEAL_CP_STAMP
1228
+ KEYSEAL_CACHE_DURATION=$KEYSEAL_CP_DURATION
1229
+ fi
1230
+ fi
1231
+ fi
1232
+ fi
1233
+ fi
1234
+ keyseal_cache_unlock
1235
+ fi
1236
+ if [[ $mode == fresh ]]; then
1237
+ KEYSEAL_CACHE_STATE=miss KEYSEAL_CACHE_REASON=rescan_requested KEYSEAL_CACHE_SCAN=
1238
+ else
1239
+ KEYSEAL_CACHE_STATE=$judged_state KEYSEAL_CACHE_REASON=$judged_reason
1240
+ fi
1241
+ }
1242
+
1243
+ # Create the cache directory (0700 each) and judge its chain. rc 0 trusted.
1244
+ keyseal_cache_prepare_dir() {
1245
+ keyseal_cache_dir || return 1
1246
+ (umask 077 && mkdir -p -- "$KEYSEAL_CACHE_DIR") 2>/dev/null || return 1
1247
+ keyseal_cache_chain "$KEYSEAL_CACHE_DIR" "$(keyseal_uid)" >/dev/null
1248
+ }
1249
+
1250
+ # Under the lock: remove this slot's `.<slot>.json.<pid>.tmp` whose pid is not alive (ours by
1251
+ # name, owner and directory; a SIGKILLed run skipped its trap).
1252
+ keyseal_cache_sweep() {
1253
+ local slot=$1 f pid uid
1254
+ uid=$(keyseal_uid)
1255
+ for f in "$KEYSEAL_CACHE_DIR/.$slot.json."*.tmp; do
1256
+ [[ -f $f && ! -L $f ]] || continue
1257
+ pid=${f##*/."$slot".json.}
1258
+ pid=${pid%.tmp}
1259
+ [[ $pid =~ ^[0-9]+$ ]] || continue
1260
+ [[ $("$KEYSEAL_BIN_STAT" -c %u -- "$f" 2>/dev/null) == "$uid" ]] || continue
1261
+ kill -0 "$pid" 2>/dev/null || rm -f -- "$f"
1262
+ done
1263
+ return 0
1264
+ }
1265
+
1266
+ # Write one line atomically (mktemp-named by pid, 0600, then rename). rc 0 written.
1267
+ keyseal_cache_put() {
1268
+ local slot=$1 line=$2
1269
+ # Test seam only (a no-op without KEYSEAL_TEST_ROOT): a write that fails, as a full disk would.
1270
+ if seam_active && [[ ${KEYSEAL_TEST_FAIL_AT-} == cache_put && ${KEYSEAL_TEST_FAIL_MODE-} == fail ]]; then return 1; fi
1271
+ KEYSEAL_CACHE_TMP=$KEYSEAL_CACHE_DIR/.$slot.json.$$.tmp
1272
+ (umask 077 && printf '%s\n' "$line" >"$KEYSEAL_CACHE_TMP") 2>/dev/null || return 1
1273
+ chmod 0600 -- "$KEYSEAL_CACHE_TMP" || return 1
1274
+ mv -f -- "$KEYSEAL_CACHE_TMP" "$KEYSEAL_CACHE_DIR/$slot.json" || return 1
1275
+ KEYSEAL_CACHE_TMP=
1276
+ }
1277
+
1278
+ # The stored valid generation, or 0 (absent, corrupt or not ours).
1279
+ keyseal_cache_generation() {
1280
+ local f=$1
1281
+ [[ -f $f && ! -L $f ]] || { printf 0; return 0; }
1282
+ keyseal_cache_parse "$f"
1283
+ if [[ $KEYSEAL_CP_KIND == format ]]; then printf 0; else printf '%s' "$KEYSEAL_CP_GEN"; fi
1284
+ }
1285
+
1286
+ # A clean, completed scan: commit only when the file's token still equals the start token. $1
1287
+ # slot, $2 scanned_at (the START), $3 duration, $4 the scan core JSON. Sets KEYSEAL_CACHE_STATE /
1288
+ # _REASON to written or not_written:<reason>.
1289
+ keyseal_cache_commit_clean() {
1290
+ local slot=$1 start=$2 duration=$3 core=$4 f cur gen wait
1291
+ KEYSEAL_CACHE_STATE=not_written
1292
+ if [[ ${KEYSEAL_INPUT[machine_id]} == null ]]; then KEYSEAL_CACHE_REASON=write_failed; return 0; fi
1293
+ if ! keyseal_cache_prepare_dir; then KEYSEAL_CACHE_REASON=untrusted_dir; return 0; fi
1294
+ # No start token (the read found the lock busy): nothing proves no commit happened meanwhile.
1295
+ if [[ -z $KEYSEAL_CACHE_TOKEN ]]; then KEYSEAL_CACHE_REASON=lock_busy; return 0; fi
1296
+ wait=$(keyseal_seam_seconds KEYSEAL_TEST_LOCK_WAIT_S "$KEYSEAL_SCAN_LOCK_WAIT_S")
1297
+ # One retry: a completed 30-minute scan is dropped only after two waits of contention.
1298
+ if ! keyseal_cache_lock "$KEYSEAL_CACHE_DIR" "$slot" "$wait" && ! keyseal_cache_lock "$KEYSEAL_CACHE_DIR" "$slot" "$wait"; then
1299
+ KEYSEAL_CACHE_REASON=lock_busy
1300
+ return 0
1301
+ fi
1302
+ f=$KEYSEAL_CACHE_DIR/$slot.json
1303
+ cur=$(keyseal_cache_token "$f")
1304
+ if [[ $cur != "$KEYSEAL_CACHE_TOKEN" ]]; then
1305
+ keyseal_cache_unlock
1306
+ KEYSEAL_CACHE_REASON=superseded
1307
+ return 0
1308
+ fi
1309
+ keyseal_cache_sweep "$slot"
1310
+ gen=$(keyseal_cache_generation "$f")
1311
+ 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
1312
+ KEYSEAL_CACHE_STATE=written KEYSEAL_CACHE_REASON=
1313
+ else
1314
+ KEYSEAL_CACHE_REASON=write_failed
1315
+ fi
1316
+ keyseal_cache_unlock
1317
+ }
1318
+
1319
+ # A match: the tombstone commits UNCONDITIONALLY (whatever the file holds or the token was),
1320
+ # waiting far longer for the lock than a clean write. $1 slot, $2 the hard-error harness name.
1321
+ keyseal_cache_commit_tombstone() {
1322
+ local slot=$1 harness=$2 f gen now wait
1323
+ KEYSEAL_CACHE_STATE=not_written
1324
+ # Unconditional, an unreadable machine id included (#552 review r1): every read treats a
1325
+ # tombstone as miss:cleared before it compares inputs, so it outlives a transient failure.
1326
+ if ! keyseal_cache_prepare_dir; then KEYSEAL_CACHE_REASON=untrusted_dir; return 0; fi
1327
+ wait=$(keyseal_seam_seconds KEYSEAL_TEST_TOMBSTONE_WAIT_S "$KEYSEAL_SCAN_TOMBSTONE_WAIT_S")
1328
+ if ! keyseal_cache_lock "$KEYSEAL_CACHE_DIR" "$slot" 0; then
1329
+ printf 'status: scan: waiting for the scan-cache lock to record a found copy\n' >&2
1330
+ if ! keyseal_cache_lock "$KEYSEAL_CACHE_DIR" "$slot" "$wait"; then
1331
+ KEYSEAL_CACHE_REASON=tombstone_lock_busy
1332
+ 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' \
1333
+ "${0##*/}" "$KEYSEAL_CACHE_DIR/$slot.lock" "$harness" >&2
1334
+ return 0
1335
+ fi
1336
+ fi
1337
+ f=$KEYSEAL_CACHE_DIR/$slot.json
1338
+ keyseal_cache_sweep "$slot"
1339
+ gen=$(keyseal_cache_generation "$f")
1340
+ now=$(keyseal_now) || now=0
1341
+ if keyseal_cache_put "$slot" "{\"format\":\"$KEYSEAL_SCAN_CACHE_FORMAT\",\"generation\":$((gen + 1)),\"cleared_at\":$now,\"inputs\":$(keyseal_cache_inputs_json)}"; then
1342
+ KEYSEAL_CACHE_STATE=cleared KEYSEAL_CACHE_REASON=
1343
+ else
1344
+ # The tombstone could not be written (#552 review r6): the previous clean result must not
1345
+ # survive a found copy, so remove it. The next read is then miss:absent and rescans. Only if
1346
+ # that also fails does a clean result remain, and then we say so, as tombstone_lock_busy does.
1347
+ KEYSEAL_CACHE_REASON=write_failed
1348
+ rm -f -- "$KEYSEAL_CACHE_TMP" 2>/dev/null
1349
+ if ! rm -f -- "$f" 2>/dev/null || [[ -e $f || -L $f ]]; then
1350
+ 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' \
1351
+ "${0##*/}" "$f" "$harness" >&2
1352
+ fi
588
1353
  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#\{}"
1354
+ keyseal_cache_unlock
599
1355
  }