@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/dist/assets.json +1 -1
- package/dist/cli.js +2 -1
- package/dist/doctor.d.ts +61 -0
- package/dist/doctor.js +177 -36
- package/dist/keyslot_status.d.ts +117 -0
- package/dist/keyslot_status.js +242 -0
- package/dist/setup.js +59 -13
- package/dist/setup_storage.d.ts +35 -10
- package/dist/setup_storage.js +97 -16
- package/keyslot/lib.sh +793 -37
- package/keyslot/status.sh +115 -14
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
564
|
-
#
|
|
565
|
-
#
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
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
|
}
|