@onlooker-community/ecosystem 0.34.0 → 0.43.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.
Files changed (112) hide show
  1. package/.claude/skills/writing-tests/SKILL.md +27 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.github/workflows/release.yml +14 -14
  4. package/.release-please-manifest.json +7 -7
  5. package/AGENTS.md +116 -0
  6. package/CHANGELOG.md +175 -0
  7. package/CLAUDE.md +5 -0
  8. package/docs/lesson-promotion-pipeline.md +6 -6
  9. package/docs/superpowers/plans/2026-08-10-lesson-confirmation.md +1273 -0
  10. package/docs/superpowers/plans/2026-08-11-lesson-judging.md +1232 -0
  11. package/docs/superpowers/plans/2026-08-11-lesson-unconfirm.md +458 -0
  12. package/docs/superpowers/plans/2026-08-12-author-key.md +567 -0
  13. package/docs/superpowers/plans/2026-08-13-approved-pool.md +747 -0
  14. package/docs/superpowers/plans/2026-08-13-librarian-cluster.md +392 -0
  15. package/docs/superpowers/plans/2026-08-14-criterion-scores-consumers.md +1374 -0
  16. package/docs/superpowers/plans/2026-08-14-criterion-scores-schema.md +277 -0
  17. package/docs/superpowers/plans/2026-08-16-cartographer-undocumented-entity.md +741 -0
  18. package/docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md +213 -0
  19. package/docs/superpowers/specs/2026-08-11-lesson-judging-design.md +267 -0
  20. package/docs/superpowers/specs/2026-08-11-lesson-unconfirm-design.md +152 -0
  21. package/docs/superpowers/specs/2026-08-12-author-key-design.md +317 -0
  22. package/docs/superpowers/specs/2026-08-13-approved-pool-design.md +232 -0
  23. package/docs/superpowers/specs/2026-08-14-criterion-scores-design.md +254 -0
  24. package/docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md +249 -0
  25. package/package.json +2 -2
  26. package/plugins/assayer/.claude-plugin/plugin.json +1 -1
  27. package/plugins/assayer/CHANGELOG.md +7 -0
  28. package/plugins/assayer/scripts/lib/assayer-config.sh +6 -0
  29. package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
  30. package/plugins/cartographer/CHANGELOG.md +54 -0
  31. package/plugins/cartographer/README.md +56 -1
  32. package/plugins/cartographer/config.json +7 -1
  33. package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +4 -4
  34. package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +7 -4
  35. package/plugins/cartographer/scripts/lib/cartographer-collect.sh +8 -1
  36. package/plugins/cartographer/scripts/lib/cartographer-config.sh +26 -0
  37. package/plugins/cartographer/scripts/lib/cartographer-events.sh +90 -0
  38. package/plugins/cartographer/scripts/lib/cartographer-filter.sh +105 -0
  39. package/plugins/cartographer/scripts/lib/cartographer-omission.sh +130 -0
  40. package/plugins/cartographer/scripts/lib/cartographer-resolve.sh +132 -0
  41. package/plugins/cartographer/scripts/run-audit.sh +136 -53
  42. package/plugins/cartographer/skills/cartographer/SKILL.md +33 -9
  43. package/plugins/curator/.claude-plugin/plugin.json +1 -1
  44. package/plugins/curator/CHANGELOG.md +7 -0
  45. package/plugins/curator/scripts/lib/curator-emit.sh +2 -1
  46. package/plugins/historian/.claude-plugin/plugin.json +1 -1
  47. package/plugins/historian/CHANGELOG.md +7 -0
  48. package/plugins/historian/scripts/lib/historian-emit.sh +2 -1
  49. package/plugins/librarian/.claude-plugin/plugin.json +1 -1
  50. package/plugins/librarian/CHANGELOG.md +71 -0
  51. package/plugins/librarian/config.json +30 -1
  52. package/plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md +82 -0
  53. package/plugins/librarian/scripts/hooks/librarian-session-end.sh +28 -0
  54. package/plugins/librarian/scripts/hooks/librarian-session-start.sh +42 -12
  55. package/plugins/librarian/scripts/lib/librarian-author-key.sh +279 -0
  56. package/plugins/librarian/scripts/lib/librarian-cli.sh +346 -2
  57. package/plugins/librarian/scripts/lib/librarian-emit.sh +2 -1
  58. package/plugins/librarian/scripts/lib/librarian-lesson-judge.sh +349 -0
  59. package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +225 -0
  60. package/plugins/librarian/scripts/lib/librarian-lesson-review.sh +312 -0
  61. package/plugins/librarian/scripts/lib/librarian-lesson-rubric.sh +56 -0
  62. package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +82 -13
  63. package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +6 -1
  64. package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +131 -62
  65. package/plugins/librarian/skills/librarian/SKILL.md +98 -1
  66. package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
  67. package/plugins/tribunal/CHANGELOG.md +63 -0
  68. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +34 -0
  69. package/plugins/tribunal/agents/tribunal-judge-security.md +33 -0
  70. package/plugins/tribunal/agents/tribunal-judge-standard.md +12 -0
  71. package/plugins/tribunal/config.json +1 -0
  72. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +154 -9
  73. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +195 -16
  74. package/plugins/tribunal/skills/tribunal/SKILL.md +5 -3
  75. package/scripts/lib/prompt-rules.sh +6 -1
  76. package/test/bats/archivist-inject.bats +1 -1
  77. package/test/bats/assayer-extract.bats +2 -2
  78. package/test/bats/bursar-session-start.bats +3 -3
  79. package/test/bats/cartographer-config.bats +43 -0
  80. package/test/bats/cartographer-events.bats +267 -0
  81. package/test/bats/cartographer-filter.bats +169 -0
  82. package/test/bats/cartographer-lock.bats +3 -3
  83. package/test/bats/cartographer-omission.bats +202 -0
  84. package/test/bats/cartographer-resolve.bats +287 -0
  85. package/test/bats/cartographer-run-audit.bats +325 -0
  86. package/test/bats/compass-sanitizer.bats +11 -11
  87. package/test/bats/compass-transcript.bats +2 -2
  88. package/test/bats/config.bats +15 -15
  89. package/test/bats/curator-session-start.bats +10 -3
  90. package/test/bats/emit-payload-default.bats +52 -0
  91. package/test/bats/governor-ledger.bats +1 -1
  92. package/test/bats/historian-prompt-submit.bats +1 -1
  93. package/test/bats/inspector-post-write-hook.bats +4 -4
  94. package/test/bats/librarian-author-key.bats +477 -0
  95. package/test/bats/librarian-cli.bats +16 -16
  96. package/test/bats/librarian-lesson-judge.bats +884 -0
  97. package/test/bats/librarian-lesson-promote.bats +552 -0
  98. package/test/bats/librarian-lesson-review.bats +900 -0
  99. package/test/bats/librarian-lesson-transform.bats +17 -3
  100. package/test/bats/librarian-session-end.bats +161 -0
  101. package/test/bats/librarian-session-start.bats +36 -2
  102. package/test/bats/lineage-config.bats +1 -1
  103. package/test/bats/lineage-redact.bats +5 -5
  104. package/test/bats/session-tracker.bats +4 -4
  105. package/test/bats/tribunal-aggregate.bats +354 -1
  106. package/test/bats/tribunal-gate.bats +631 -0
  107. package/test/bats/tribunal-judge-agents.bats +119 -0
  108. package/test/bats/tribunal-jury.bats +1 -1
  109. package/test/bats/turn-tracker.bats +1 -1
  110. package/test/bats/warden-sanitizer.bats +3 -3
  111. package/test/bats/worktree-tracker.bats +2 -2
  112. package/test/node/lesson-validate-agreement.test.mjs +35 -6
@@ -0,0 +1,82 @@
1
+ # ADR-002: Agent definitions are shared assets; hooks are not
2
+
3
+ ## Status
4
+
5
+ Accepted.
6
+
7
+ ## Context
8
+
9
+ CLAUDE.md states: "Plugins communicate by emitting events to the JSONL log —
10
+ they do not call each other directly. All plugins depend on the ecosystem
11
+ substrate; no plugin depends on another plugin directly."
12
+
13
+ Lesson judging (`ecosystem-4z8.3`) needs a jury. Tribunal ships three judge
14
+ agent definitions and the rubric vocabulary. The stage couples librarian and
15
+ tribunal in some direction no matter how it is arranged: either librarian
16
+ reaches for tribunal's judges, or tribunal reaches into librarian's
17
+ project-keyed proposal files and writes verdicts back into them.
18
+
19
+ ## Decision
20
+
21
+ The invariant forbids **runtime** coupling — one plugin's hook or library
22
+ calling another's, which makes one plugin's failure another's. It does not
23
+ forbid reusing a **published agent definition** by name.
24
+
25
+ Librarian therefore owns the `judge` verb, both rubrics, the aggregate, and the
26
+ gate decision, and dispatches `tribunal-judge-standard` and
27
+ `tribunal-judge-adversarial` by name.
28
+
29
+ Librarian does **not** source any file under `plugins/tribunal/`. It implements
30
+ its own aggregate and gate — roughly twenty lines — rather than calling
31
+ `tribunal_aggregate` or `tribunal_gate_decide`.
32
+
33
+ ## Rationale
34
+
35
+ An agent definition is declarative: a markdown file with frontmatter and a
36
+ prompt. It has no runtime surface and cannot fail at call time in a way that
37
+ propagates — the harness resolves it, librarian never invokes tribunal's code
38
+ directly. That is a real difference from sourcing tribunal's bash, where a
39
+ signature change to `tribunal_gate_decide` breaks librarian mid-call and
40
+ neither plugin's tests would catch it.
41
+
42
+ It is a weaker guarantee than depending on a published schema, though.
43
+ `@onlooker-community/schema` is registered and drift-checked in CI (see
44
+ [ADR-005](../../../../docs/adr/005-runtime-emitter-fails-open.md)): a breaking
45
+ change to it fails a build before it ships. A judge agent's prompt carries no
46
+ such contract — nothing in either plugin's test suite stops a maintainer from
47
+ narrowing or repurposing what `tribunal-judge-standard` returns without
48
+ touching the agent's name. Calling that "closer to a published schema than to
49
+ calling another plugin's code" overstated the guarantee; only the *loud*
50
+ failure modes — the judge being renamed or removed — actually behave like a
51
+ schema break. See Consequences for what happens when the drift is quiet
52
+ instead.
53
+
54
+ Keeping the lifecycle in librarian also keeps `ecosystem-4z8.4`'s pool and
55
+ ledger in one plugin instead of splitting them across two.
56
+
57
+ ## Consequences
58
+
59
+ A judge agent renamed or removed in tribunal breaks lesson judging at dispatch
60
+ time. That is a visible, loud failure at the moment a human invokes the verb —
61
+ not a silent one — and the "could not judge" path already handles it: the
62
+ candidate stays `confirmed` and nothing is written.
63
+
64
+ A judge agent silently edited in place is a different, unmitigated risk. If a
65
+ maintainer changes what a judge returns without renaming it — dropping
66
+ `judge_type`, redefining what `feedback_summary` means, rescaling `score` —
67
+ nothing in this pipeline notices at edit time, because the agent definition
68
+ carries no drift check the way `@onlooker-community/schema` does. The
69
+ `usable` panel check in `librarian-lesson-judge.sh` only catches the subset of
70
+ that risk that changes the verdict's *shape*: it requires `judge_type` to be a
71
+ string, `score` a number, and `passed` a boolean, and returns UNJUDGED rather
72
+ than a false rejection if any is missing or mistyped. A *semantic* change —
73
+ same shape, different meaning, such as a score scale moving from 0–1 to
74
+ 0–100 — passes that check and is judged normally, with no signal to librarian
75
+ or its maintainers that the verdict no longer means what the aggregate and
76
+ gate assume it means. This ADR accepts that risk rather than closing it;
77
+ closing it would require either a real schema contract for judge output or
78
+ librarian validating semantics it does not own.
79
+
80
+ Librarian's gate logic can drift from tribunal's. Accepted deliberately: they
81
+ answer different questions. Tribunal gates an Actor's output with retry;
82
+ librarian gates a fixed artifact with none.
@@ -435,11 +435,32 @@ done
435
435
  #
436
436
  # Runs over the same durability survivors the classifier saw. Each artifact is
437
437
  # independent: a decline or an outage on one never stops the rest.
438
+ #
439
+ # Budgeted in aggregate, not just per call. Each transform carries a 20s
440
+ # ceiling of its own, but nothing bounded KEPT_COUNT of them end to end, so a
441
+ # backlog could hold SessionEnd open for minutes (ecosystem-qwi). The check is
442
+ # per iteration rather than once before the loop: a pre-loop gate only decides
443
+ # whether to start, and once started the cost is still unbounded — which is the
444
+ # gap the classifier loop above still has.
445
+ #
446
+ # Skipping is the safe direction. Untransformed artifacts are reconsidered on a
447
+ # later session, so the cost of stopping early is a delay; the cost of not
448
+ # stopping is a session that will not close.
438
449
  # ---------------------------------------------------------------------------
439
450
  LESSON_PROPOSED=0
440
451
  LESSON_DECLINED=0
452
+ LESSONS_SKIPPED=0
453
+
454
+ LESSON_BUDGET_MS=$(librarian_config_get '.librarian.lesson_transform.total_budget_ms' 2>/dev/null)
455
+ [[ -z "$LESSON_BUDGET_MS" || "$LESSON_BUDGET_MS" == "null" ]] && LESSON_BUDGET_MS=8000
456
+ LESSON_START_MS=$(librarian_now_ms)
441
457
 
442
458
  for ((li = 0; li < KEPT_COUNT; li++)); do
459
+ if [[ $(( $(librarian_now_ms) - LESSON_START_MS )) -ge "$LESSON_BUDGET_MS" ]]; then
460
+ LESSONS_SKIPPED=$(( KEPT_COUNT - li ))
461
+ break
462
+ fi
463
+
443
464
  LESSON_ARTIFACT=$(printf '%s' "$KEPT" | jq -c ".[$li]")
444
465
  [[ -z "$LESSON_ARTIFACT" || "$LESSON_ARTIFACT" == "null" ]] && continue
445
466
 
@@ -450,6 +471,11 @@ for ((li = 0; li < KEPT_COUNT; li++)); do
450
471
  esac
451
472
  done
452
473
 
474
+ # LESSONS_SKIPPED rides on scan.complete below rather than becoming an event of
475
+ # its own. A truncated stage 5 is not a truncated scan: the scan finishes
476
+ # normally and only this stage stops early, so the count belongs beside a
477
+ # healthy outcome rather than replacing it.
478
+
453
479
  # ----------------------------------------------------------------------------
454
480
  # Watermark advance + scan.complete.
455
481
  # ----------------------------------------------------------------------------
@@ -465,12 +491,14 @@ librarian_emit "librarian.scan.complete" "$SESSION_ID" "$(jq -cn \
465
491
  --arg outcome "$OUTCOME" \
466
492
  --argjson candidates_proposed "$PROPOSED_COUNT" \
467
493
  --argjson candidates_dropped "$TOTAL_DROPPED" \
494
+ --argjson lessons_skipped "$LESSONS_SKIPPED" \
468
495
  --argjson duration_ms "$DURATION_MS" \
469
496
  --argjson artifact_count_in_window "$ARTIFACT_COUNT" \
470
497
  '{
471
498
  outcome: $outcome,
472
499
  candidates_proposed: $candidates_proposed,
473
500
  candidates_dropped: $candidates_dropped,
501
+ lessons_skipped: $lessons_skipped,
474
502
  duration_ms: $duration_ms,
475
503
  artifact_count_in_window: $artifact_count_in_window
476
504
  }')"
@@ -46,6 +46,12 @@ source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
46
46
  source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
47
47
  # shellcheck source=../lib/librarian-storage.sh
48
48
  source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
49
+ # shellcheck source=../lib/librarian-lesson-storage.sh
50
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
51
+ # shellcheck source=../lib/librarian-lesson-validate.sh
52
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-validate.sh"
53
+ # shellcheck source=../lib/librarian-lesson-review.sh
54
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-review.sh"
49
55
 
50
56
  # Emit hookSpecificOutput with the given additionalContext string. An
51
57
  # empty string is fine — the harness sees "nothing to say".
@@ -83,25 +89,49 @@ MAX_PENDING=$(librarian_config_get '.librarian.surfacer.max_pending_for_inject')
83
89
  PENDING=$(librarian_storage_count_pending "$PROJECT_KEY")
84
90
  [[ -z "$PENDING" || "$PENDING" == "null" ]] && PENDING=0
85
91
 
86
- if [[ "$PENDING" -eq 0 && "$SKIP_WHEN_ZERO" == "true" ]]; then
92
+ LESSON_PENDING=$(librarian_lesson_list_pending "$PROJECT_KEY" | jq 'length' 2>/dev/null) || LESSON_PENDING=0
93
+ [[ -z "$LESSON_PENDING" || "$LESSON_PENDING" == "null" ]] && LESSON_PENDING=0
94
+
95
+ if [[ "$PENDING" -eq 0 && "$LESSON_PENDING" -eq 0 && "$SKIP_WHEN_ZERO" == "true" ]]; then
87
96
  _emit ""
88
97
  exit 0
89
98
  fi
90
99
 
91
- # Cap the surfaced number so a runaway queue doesn't make the pointer
92
- # itself look alarming. Users still see the truthful count in
93
- # /librarian review.
94
- if [[ "$PENDING" -gt "$MAX_PENDING" ]]; then
95
- DISPLAY_COUNT="${MAX_PENDING}+"
96
- else
97
- DISPLAY_COUNT="$PENDING"
100
+ # The two queues fill independently, so a lessons-only session (the common
101
+ # case per the trap this hook guards against) must not resurrect the "0
102
+ # pending" memory noise skip_inject_when_zero exists to suppress — only
103
+ # build the memory line when there's something to say or the config opts
104
+ # out of skipping.
105
+ CONTEXT=""
106
+ if [[ "$PENDING" -gt 0 || "$SKIP_WHEN_ZERO" != "true" ]]; then
107
+ # Cap the surfaced number so a runaway queue doesn't make the pointer
108
+ # itself look alarming. Users still see the truthful count in
109
+ # /librarian review.
110
+ if [[ "$PENDING" -gt "$MAX_PENDING" ]]; then
111
+ DISPLAY_COUNT="${MAX_PENDING}+"
112
+ else
113
+ DISPLAY_COUNT="$PENDING"
114
+ fi
115
+
116
+ NOUN="proposals"
117
+ [[ "$PENDING" -eq 1 ]] && NOUN="proposal"
118
+
119
+ CONTEXT=$(printf 'Librarian has %s pending memory promotion %s. Review with `/librarian review`.' \
120
+ "$DISPLAY_COUNT" "$NOUN")
98
121
  fi
99
122
 
100
- NOUN="proposals"
101
- [[ "$PENDING" -eq 1 ]] && NOUN="proposal"
123
+ LESSON_LINE=""
124
+ if [[ "$LESSON_PENDING" -gt 0 ]]; then
125
+ LESSON_LINE=$(printf '%s lesson candidate(s) awaiting confirmation — run /librarian lessons' "$LESSON_PENDING")
126
+ fi
102
127
 
103
- CONTEXT=$(printf 'Librarian has %s pending memory promotion %s. Review with `/librarian review`.' \
104
- "$DISPLAY_COUNT" "$NOUN")
128
+ if [[ -n "$LESSON_LINE" ]]; then
129
+ if [[ -n "$CONTEXT" ]]; then
130
+ CONTEXT="${CONTEXT}"$'\n'"${LESSON_LINE}"
131
+ else
132
+ CONTEXT="$LESSON_LINE"
133
+ fi
134
+ fi
105
135
 
106
136
  _emit "$CONTEXT"
107
137
  exit 0
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env bash
2
+ # Author identity for lesson promotion.
3
+ #
4
+ # author_key is derived PER VISIBILITY SCOPE so a user's org identity and their
5
+ # public identity cannot be linked by anyone who does not hold their secret.
6
+ #
7
+ # Breaking that unlinkability breaks it SILENTLY: nothing throws, and the pool
8
+ # fills with well-formed keys that leak the association they exist to prevent.
9
+ # The golden-vector test in test/bats/librarian-author-key.bats is what makes
10
+ # any change to this derivation go red.
11
+ #
12
+ # Exposes:
13
+ # librarian_author_secret_path
14
+ # librarian_author_secret_ensure
15
+ # librarian_author_key <visibility>
16
+
17
+ # Where the secret lives.
18
+ #
19
+ # NOT project-keyed, deliberately. Every other librarian artifact sits under
20
+ # librarian_project_dir; this one must not, because one user is one author
21
+ # across all their repos. A per-project secret would hand the same person a
22
+ # different identity in every project, and nothing would report it.
23
+ librarian_author_secret_path() {
24
+ printf '%s/author/user_secret' "${ONLOOKER_DIR:-$HOME/.onlooker}"
25
+ }
26
+
27
+ # Echo a valid secret, creating one on first use.
28
+ #
29
+ # Returns non-zero with empty stdout if it cannot produce a valid secret. An
30
+ # invalid secret is never "repaired" by regenerating: an existing file is
31
+ # authoritative, because replacing it silently changes the user's identity.
32
+ librarian_author_secret_ensure() {
33
+ local path dir
34
+ path="$(librarian_author_secret_path)"
35
+ dir="$(dirname "$path")"
36
+
37
+ # Set only when THIS call is the one whose content actually landed at
38
+ # $path (won any creation race, below). Read by the tighten step further
39
+ # down: a file we just created under umask 077 is already 0600, and
40
+ # tightening it unconditionally would silently repair a regression in
41
+ # the creation path itself before anything could observe it.
42
+ local created=0
43
+
44
+ if [[ ! -f "$path" ]]; then
45
+ command -v openssl >/dev/null 2>&1 || {
46
+ printf 'author-key: openssl is required to create a secret.\n' >&2
47
+ return 1
48
+ }
49
+ mkdir -p "$dir" 2>/dev/null || {
50
+ printf 'author-key: cannot create %s\n' "$dir" >&2
51
+ return 1
52
+ }
53
+ # 32 BYTES, printed as 64 hex characters. Not $RANDOM: that is a
54
+ # 15-bit PRNG, correct for archivist's sortable ULIDs and wrong for
55
+ # a secret.
56
+ local generated
57
+ generated=$(openssl rand -hex 32 2>/dev/null) || {
58
+ printf 'author-key: openssl rand failed.\n' >&2
59
+ return 1
60
+ }
61
+
62
+ # Atomic create-if-absent: write to a private temp file in the same
63
+ # directory, then hard-link it into place. `ln` fails if the target
64
+ # already exists — the portable atomic-create idiom. Two concurrent
65
+ # first-use callers race here; only one wins, the loser's own
66
+ # generated value is discarded, and every caller (winner and
67
+ # losers alike) reads back whatever actually landed on disk below.
68
+ # Without this, two concurrent first-use calls can each write
69
+ # $path directly and hand two different callers two different
70
+ # secrets — two identities for one user.
71
+ local tmp
72
+ tmp=$(mktemp "${path}.XXXXXX" 2>/dev/null) || {
73
+ printf 'author-key: cannot create a temp file in %s\n' "$dir" >&2
74
+ return 1
75
+ }
76
+ # Captured now, while it is still known: if $path turns out to be
77
+ # a directory below, the stray secret `ln` leaves behind is named
78
+ # after this.
79
+ local tmp_basename
80
+ tmp_basename="$(basename "$tmp")"
81
+ # Belt-and-suspenders with mktemp's own restrictive creation mode —
82
+ # create restricted, then write, never write-then-chmod, which
83
+ # would leave a window where the secret is world-readable on disk.
84
+ ( umask 077 && printf '%s\n' "$generated" > "$tmp" ) || {
85
+ printf 'author-key: cannot write %s\n' "$tmp" >&2
86
+ rm -f "$tmp" 2>/dev/null
87
+ return 1
88
+ }
89
+ if ln "$tmp" "$path" 2>/dev/null; then
90
+ created=1
91
+ fi
92
+ # Someone else winning the race is not an error — clean up our
93
+ # losing copy either way.
94
+ rm -f "$tmp" 2>/dev/null
95
+
96
+ # `ln FILE DIR` succeeds — POSIX `ln` links basename(FILE) *inside*
97
+ # an existing directory rather than failing — so a directory
98
+ # sitting at $path makes the `ln` above "succeed" (created=1)
99
+ # while $path itself is still that directory, not a secret. Left
100
+ # alone, the stray hard link at $path/$tmp_basename is live secret
101
+ # material nothing ever reads again. Catch it here, while the name
102
+ # is still known, and remove it before refusing.
103
+ if [[ "$created" -eq 1 && ! -f "$path" ]]; then
104
+ rm -f "${path}/${tmp_basename}" 2>/dev/null
105
+ printf 'author-key: %s is not a regular file; refusing to create a secret there.\n' "$path" >&2
106
+ return 1
107
+ fi
108
+ fi
109
+
110
+ # A pre-existing non-regular file at $path (a FIFO, a socket, a device
111
+ # node) also fails the `-f` check above and enters the block above —
112
+ # but `ln` refuses to link onto an existing non-directory path, so
113
+ # `created` stays 0 and the directory case just above never fires.
114
+ # Catch it here instead: before the tighten step below touches its
115
+ # mode, and before the plain `cat` further down would block forever
116
+ # reading a FIFO with no writer. Plugins must never hang a session.
117
+ if [[ ! -f "$path" ]]; then
118
+ printf 'author-key: %s is not a regular file; refusing to read a secret from it.\n' "$path" >&2
119
+ return 1
120
+ fi
121
+
122
+ # Tighten loose permissions and say so, but only on a file that already
123
+ # existed before this call: a file THIS call just created above was
124
+ # written under umask 077 and is already 0600. Running this
125
+ # unconditionally on every call — including the one that just created
126
+ # the file — would silently repair a weakened creation path before any
127
+ # caller, or test, could observe the exposure.
128
+ if [[ "$created" -eq 0 ]]; then
129
+ # Refusing here would block promotion over something the user cannot
130
+ # fix without guidance; staying silent would hide a real exposure.
131
+ # Tightening does not undo an exposure that already happened — it
132
+ # stops the next one, and the warning is the part that counts.
133
+ local mode
134
+ mode=$(ls -l "$path" 2>/dev/null | cut -c1-10)
135
+ if [[ "$mode" != "-rw-------" ]]; then
136
+ chmod 0600 "$path" 2>/dev/null
137
+ printf 'author-key: tightened permissions on %s (was %s)\n' "$path" "$mode" >&2
138
+ fi
139
+ fi
140
+
141
+ # The classic mode string is blind to ACLs: `chmod +a "everyone allow
142
+ # read"` on a 0600 file yields "-rw-------+" on macOS — the extra grant
143
+ # sits at column 11, outside the window the mode check above reads, and
144
+ # chmod cannot clear it. An inherited ACL from MDM policy or a
145
+ # network-mounted home is a plausible non-malicious way to hit this, not
146
+ # just a deliberate change. Warn explicitly rather than claim a fix we
147
+ # cannot make; do not attempt to strip ACLs portably.
148
+ local acl_flag
149
+ acl_flag=$(ls -l "$path" 2>/dev/null | cut -c11)
150
+ if [[ "$acl_flag" == "+" ]]; then
151
+ printf 'author-key: %s carries an ACL granting access beyond its file mode (mode alone will not show this); chmod cannot clear it — review and remove it manually.\n' "$path" >&2
152
+ fi
153
+
154
+ # Read only the FIRST LINE, and validate it before any other processing.
155
+ # `head -n1` never touches bytes past the first newline, which matters
156
+ # for two reasons at once: a secret file is never regenerated (see
157
+ # above), so the documented way to move it to a second machine is a
158
+ # plain copy — but a user who instead appends (`>>` instead of `>`, or
159
+ # restoring a backup on top of an existing file) leaves a *second*
160
+ # 64-hex line sitting after the first. Stripping newlines from the
161
+ # whole file before validating would concatenate the two lines into a
162
+ # 128-character string that reads as one long-but-"valid" secret — a
163
+ # THIRD identity, matching neither machine, accepted silently. Reading
164
+ # only line one sidesteps that: whatever is on later lines is simply
165
+ # never read.
166
+ local secret
167
+ secret=$(head -n1 "$path" 2>/dev/null)
168
+ if [[ -z "$secret" ]]; then
169
+ printf 'author-key: secret at %s is empty; refusing to derive.\n' "$path" >&2
170
+ return 1
171
+ fi
172
+ # A short-but-nonempty secret still derives a plausible key with less
173
+ # entropy than this design claims. 64 is the exact width openssl
174
+ # rand -hex 32 produces.
175
+ if [[ "${#secret}" -lt 64 ]]; then
176
+ printf 'author-key: secret at %s is too short (%d chars, expected 64).\n' \
177
+ "$path" "${#secret}" >&2
178
+ return 1
179
+ fi
180
+ # Anchored and EXACT, not "64 or more": a longer key is not a wider
181
+ # version of the same secret, it is a DIFFERENT secret — HMAC does not
182
+ # ignore the extra width, it derives a different identity from it.
183
+ # Accepting anything past 64 chars here is exactly how a stray longer
184
+ # first line (or the wrong-shape content below) becomes a silently
185
+ # wrong-but-well-formed author_key rather than a refusal.
186
+ #
187
+ # This is also what catches wrong-shape content: 64 characters of the
188
+ # wrong shape (spaces, uppercase, punctuation) passes the length check
189
+ # above and would still derive a garbage-but-deterministic identity
190
+ # from HMAC — accepted-but-wrong is worse than rejected outright,
191
+ # because nothing downstream can tell the difference between that and
192
+ # a real one.
193
+ if ! printf '%s' "$secret" | grep -Eq '^[0-9a-f]{64}$'; then
194
+ printf 'author-key: secret at %s is malformed (expected exactly 64 lowercase hex characters).\n' \
195
+ "$path" >&2
196
+ return 1
197
+ fi
198
+
199
+ printf '%s' "$secret"
200
+ }
201
+
202
+ # Derive this user's author_key for one visibility scope.
203
+ #
204
+ # HMAC-SHA256(secret, "onlooker.author.v1:<visibility>")
205
+ # truncated to 16 bytes, rendered as 32 lowercase hex
206
+ #
207
+ # The domain tag stops this secret's output colliding with any other use of the
208
+ # same secret. The version is what lets a future v2 add an org identity without
209
+ # silently rederiving every existing key: v1 lessons keep validating under v1.
210
+ #
211
+ # No org id in v1 — none exists in this system, and inventing one for an
212
+ # unwritten consumer is the mistake ecosystem-si6 avoided.
213
+ #
214
+ # Usage: librarian_author_key <private|org|public>
215
+ librarian_author_key() {
216
+ local visibility="${1:-}"
217
+ case "$visibility" in
218
+ private|org|public) ;;
219
+ *)
220
+ printf 'author-key: unrecognized visibility: %s\n' "$visibility" >&2
221
+ return 1
222
+ ;;
223
+ esac
224
+
225
+ # node, not openssl: openssl's `dgst -hmac` CLI has no way to take the
226
+ # HMAC key off argv. On Linux /proc/<pid>/cmdline is world-readable, so
227
+ # any local user could read the secret out of the process table for
228
+ # the life of the call. node reads it from the environment instead
229
+ # (AK_SECRET, below) — /proc/<pid>/environ is owner-only. Visibility is
230
+ # not secret and stays on argv. The algorithm is unchanged: verified
231
+ # against the golden vectors byte-for-byte before this switch.
232
+ command -v node >/dev/null 2>&1 || {
233
+ printf 'author-key: node is required to derive a key.\n' >&2
234
+ return 1
235
+ }
236
+
237
+ local secret
238
+ secret=$(librarian_author_secret_ensure) || return 1
239
+
240
+ local digest
241
+ digest=$(AK_SECRET="$secret" node -e '
242
+ const crypto = require("crypto");
243
+ process.stdout.write(
244
+ crypto.createHmac("sha256", process.env.AK_SECRET)
245
+ .update("onlooker.author.v1:" + process.argv[1])
246
+ .digest("hex")
247
+ );
248
+ ' "$visibility" 2>/dev/null) || {
249
+ printf 'author-key: HMAC failed.\n' >&2
250
+ return 1
251
+ }
252
+
253
+ # 64 hex chars in, 32 out. Truncating an HMAC is standard; 128 bits is
254
+ # ample for a collision-resistant pseudonymous identifier. Anchored and
255
+ # on the CHARSET, not just the width: a width-only check lets 64 bytes
256
+ # of anything through a misbehaving node subprocess, and the truncated
257
+ # result would still violate this function's own 32-lowercase-hex
258
+ # contract — this is what actually provides fail-closed behavior if the
259
+ # subprocess misbehaves, since the command substitution above has no
260
+ # `pipefail` to catch a partial write on its own.
261
+ #
262
+ # `[[ =~ ]]`, not `grep`: grep is LINE-oriented, so `^`/`$` anchor to
263
+ # each line, not the whole string. $digest is raw subprocess stdout and
264
+ # can carry an embedded newline (a preload named by NODE_OPTIONS writing
265
+ # a line to stdout before node's own output is a routine, non-adversarial
266
+ # way to hit this) — `grep -Eq '^[0-9a-f]{64}\$'` matches as soon as ANY
267
+ # line is 64 hex chars, garbage lines and all. `[[ =~ ]]` anchors to the
268
+ # entire string, so a leading or trailing line makes it refuse, matching
269
+ # what the old whole-string `${#digest} -eq 64` check refused too. Keep
270
+ # the pattern in a variable and leave it unquoted in the test — quoting
271
+ # it would make bash match it literally instead of as a regex, and that
272
+ # is the bash-3.2-portable way to write this (macOS ships 3.2).
273
+ local hex_re='^[0-9a-f]{64}$'
274
+ if [[ ! "$digest" =~ $hex_re ]]; then
275
+ printf 'author-key: HMAC returned a malformed digest (expected 64 lowercase hex characters).\n' >&2
276
+ return 1
277
+ fi
278
+ printf '%s' "${digest:0:32}"
279
+ }