@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,477 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # `run --separate-stderr` (used below) requires bats >= 1.5.0.
4
+ bats_require_minimum_version 1.5.0
5
+
6
+ setup() {
7
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
8
+ setup_test_env
9
+
10
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
11
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
12
+
13
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-author-key.sh"
14
+ }
15
+
16
+ @test "the secret lives outside any project directory" {
17
+ # Every other librarian artifact is project-keyed. This one must not be:
18
+ # a per-project secret would give one user a different identity in every
19
+ # repo, silently.
20
+ run librarian_author_secret_path
21
+ [ "$status" -eq 0 ]
22
+ [ "$output" = "${ONLOOKER_DIR}/author/user_secret" ]
23
+ [[ "$output" != *"/librarian/"* ]] || return 1
24
+ }
25
+
26
+ @test "first use creates a secret with 0600 permissions" {
27
+ run librarian_author_secret_ensure
28
+ [ "$status" -eq 0 ]
29
+
30
+ local path
31
+ path=$(librarian_author_secret_path)
32
+ [ -f "$path" ]
33
+ # stat's portable-enough form for mode; %A on GNU, %Sp on BSD — use ls.
34
+ local mode
35
+ mode=$(ls -l "$path" | cut -c1-10)
36
+ [ "$mode" = "-rw-------" ]
37
+ }
38
+
39
+ @test "the generated secret is 64 hex characters" {
40
+ # openssl rand -hex 32 requests 32 BYTES and prints 64 characters.
41
+ # Halving this to -hex 16 would halve the entropy and nothing would fail.
42
+ run librarian_author_secret_ensure
43
+ [ "$status" -eq 0 ]
44
+ [ "${#output}" -eq 64 ]
45
+ printf '%s' "$output" | grep -Eq '^[0-9a-f]{64}$' || return 1
46
+ }
47
+
48
+ @test "an existing secret is never regenerated" {
49
+ # Load-bearing: regenerating silently changes the user's identity and
50
+ # orphans every lesson they have written, including retraction.
51
+ librarian_author_secret_ensure >/dev/null
52
+ local path first second
53
+ path=$(librarian_author_secret_path)
54
+ first=$(cat "$path")
55
+
56
+ librarian_author_secret_ensure >/dev/null
57
+ second=$(cat "$path")
58
+ [ "$first" = "$second" ]
59
+ }
60
+
61
+ @test "an empty secret is refused, not used" {
62
+ # HMAC("", scope) is IDENTICAL for every user in this state — a corrupt
63
+ # secret would silently collapse everyone onto one shared identity.
64
+ #
65
+ # --separate-stderr: bats' plain `run` merges stdout and stderr into
66
+ # $output, but the interface contract is "empty stdout, reason on
67
+ # stderr" — two distinct streams. Without separating them here, $output
68
+ # could never be "" once the required stderr reason is written, no
69
+ # matter how correct the implementation is.
70
+ local path
71
+ path=$(librarian_author_secret_path)
72
+ mkdir -p "$(dirname "$path")"
73
+ : > "$path"
74
+
75
+ run --separate-stderr librarian_author_secret_ensure
76
+ [ "$status" -ne 0 ]
77
+ [ "$output" = "" ]
78
+ # Pins the empty-secret guard specifically: an empty secret also trips
79
+ # the short-secret check (0 < 64), so "refused" alone doesn't prove this
80
+ # guard exists — the message must name the case, not just reject it.
81
+ [[ "$stderr" == *"empty"* ]] || return 1
82
+ }
83
+
84
+ @test "a short secret is refused, naming the problem" {
85
+ local path
86
+ path=$(librarian_author_secret_path)
87
+ mkdir -p "$(dirname "$path")"
88
+ printf 'abc123' > "$path"
89
+
90
+ run librarian_author_secret_ensure
91
+ [ "$status" -ne 0 ]
92
+ [[ "$output" == *"too short"* ]] || return 1
93
+ }
94
+
95
+ @test "a 64-char non-hex secret is refused, naming the format problem" {
96
+ # Length alone is not content: 64 characters of the wrong shape (here,
97
+ # 64 'z's — not a hex digit) clears the length check but would still
98
+ # derive a garbage-but-deterministic identity from HMAC. This must be
99
+ # distinguishable from both "empty" and "too short" — a user acts on
100
+ # each differently.
101
+ local path secret
102
+ path=$(librarian_author_secret_path)
103
+ mkdir -p "$(dirname "$path")"
104
+ secret=$(printf 'z%.0s' $(seq 1 64))
105
+ printf '%s' "$secret" > "$path"
106
+ [ "${#secret}" -eq 64 ]
107
+
108
+ run librarian_author_secret_ensure
109
+ [ "$status" -ne 0 ]
110
+ [[ "$output" == *"malformed"* ]] || return 1
111
+ [[ "$output" != *"too short"* ]] || return 1
112
+ }
113
+
114
+ @test "a world-readable secret is tightened and warned about" {
115
+ local path
116
+ path=$(librarian_author_secret_path)
117
+ mkdir -p "$(dirname "$path")"
118
+ openssl rand -hex 32 > "$path"
119
+ chmod 0644 "$path"
120
+
121
+ run librarian_author_secret_ensure
122
+ [ "$status" -eq 0 ]
123
+ [ "$(ls -l "$path" | cut -c1-10)" = "-rw-------" ]
124
+ [[ "$output" == *"permissions"* ]] || return 1
125
+ }
126
+
127
+ @test "an ACL-only grant is warned about even though the mode string looks fine" {
128
+ # `chmod +a` is macOS-specific; on Linux (CI) this scenario can't be
129
+ # constructed the same way, so this test only runs on Darwin.
130
+ [[ "$(uname)" == "Darwin" ]] || skip "chmod +a is macOS-only; CI runs on Linux"
131
+
132
+ local path
133
+ path=$(librarian_author_secret_path)
134
+ mkdir -p "$(dirname "$path")"
135
+ openssl rand -hex 32 > "$path"
136
+ chmod 0600 "$path"
137
+ chmod +a "$(id -un) allow read" "$path"
138
+ # Confirm the fixture actually carries the ACL before asserting on it —
139
+ # otherwise a chmod +a failure would make this test vacuously pass.
140
+ [ "$(ls -l "$path" | cut -c11)" = "+" ]
141
+
142
+ run librarian_author_secret_ensure
143
+ [ "$status" -eq 0 ]
144
+ [[ "$output" == *"ACL"* ]] || return 1
145
+ }
146
+
147
+ @test "the lib never uses RANDOM for the secret" {
148
+ # archivist-ulid.sh uses $RANDOM correctly for a sortable id; it is the
149
+ # nearer example in this repo and the wrong one to copy for a secret.
150
+ #
151
+ # Comment lines are excluded: this pins usage, not mention. The lib's own
152
+ # comment names $RANDOM directly to explain why it is disqualified, and a
153
+ # grep that can't tell code from commentary would force that comment to
154
+ # stop naming the thing it warns against.
155
+ #
156
+ # grep -c returns 0 with exit status 1 when there is no match — assert on
157
+ # $output, not $status.
158
+ run bash -c "grep -v '^[[:space:]]*#' \"${PLUGIN_ROOT}/scripts/lib/librarian-author-key.sh\" | grep -c 'RANDOM'"
159
+ [ "$output" = "0" ]
160
+ }
161
+
162
+ # A fixed secret, so the golden vector below is reproducible.
163
+ _fixed_secret() {
164
+ local path
165
+ path=$(librarian_author_secret_path)
166
+ mkdir -p "$(dirname "$path")"
167
+ printf '%s\n' "0000000000000000000000000000000000000000000000000000000000000000" > "$path"
168
+ chmod 0600 "$path"
169
+ }
170
+
171
+ @test "GOLDEN VECTOR: a fixed secret and visibility produce a fixed key" {
172
+ # THE load-bearing test. Change the domain tag, the truncation width, the
173
+ # hash, or the argument order and this goes red. It is what makes "the
174
+ # derivation is permanent" enforceable rather than aspirational.
175
+ #
176
+ # Regenerate ONLY if the contract's width changes, and say so in the
177
+ # commit — a silent update here defeats the test's whole purpose.
178
+ _fixed_secret
179
+ run librarian_author_key "public"
180
+ [ "$status" -eq 0 ]
181
+ [ "$output" = "11ff8ab7134c834e788ab4a5130f7853" ]
182
+ }
183
+
184
+ @test "GOLDEN VECTOR: org and private are pinned too" {
185
+ # All three, so a change that happens to preserve one scope's output
186
+ # still goes red. Computed independently of the implementation.
187
+ _fixed_secret
188
+ [ "$(librarian_author_key "private")" = "e74674c25190cdf15099604441bb0d4b" ]
189
+ [ "$(librarian_author_key "org")" = "a8cf0203e178702412d37d5f796adbdc" ]
190
+ }
191
+
192
+ @test "the same inputs always produce the same key" {
193
+ # Retraction depends on this: a user must be able to re-derive the key
194
+ # that authored a lesson.
195
+ _fixed_secret
196
+ local a b
197
+ a=$(librarian_author_key "org")
198
+ b=$(librarian_author_key "org")
199
+ [ "$a" = "$b" ]
200
+ }
201
+
202
+ @test "the three visibilities produce three distinct keys" {
203
+ _fixed_secret
204
+ local p o u
205
+ p=$(librarian_author_key "private")
206
+ o=$(librarian_author_key "org")
207
+ u=$(librarian_author_key "public")
208
+ [ "$p" != "$o" ]
209
+ [ "$o" != "$u" ]
210
+ [ "$p" != "$u" ]
211
+ }
212
+
213
+ @test "different secrets produce different keys at the same visibility" {
214
+ # Catches a constant that ignores the secret entirely — which the
215
+ # scope-separation test above would NOT catch.
216
+ _fixed_secret
217
+ local first
218
+ first=$(librarian_author_key "public")
219
+
220
+ local path
221
+ path=$(librarian_author_secret_path)
222
+ printf '%s\n' "1111111111111111111111111111111111111111111111111111111111111111" > "$path"
223
+ local second
224
+ second=$(librarian_author_key "public")
225
+
226
+ [ "$first" != "$second" ]
227
+ }
228
+
229
+ @test "the key is 32 lowercase hex" {
230
+ _fixed_secret
231
+ run librarian_author_key "public"
232
+ [ "$status" -eq 0 ]
233
+ [ "${#output}" -eq 32 ]
234
+ printf '%s' "$output" | grep -Eq '^[0-9a-f]{32}$' || return 1
235
+ }
236
+
237
+ @test "the key is not the secret" {
238
+ # Catches a "derivation" that echoes its input.
239
+ _fixed_secret
240
+ local secret key
241
+ secret=$(librarian_author_secret_ensure)
242
+ key=$(librarian_author_key "public")
243
+ [ "$key" != "$secret" ]
244
+ [[ "$secret" != *"$key"* ]] || return 1
245
+ }
246
+
247
+ @test "an unknown visibility is refused, naming it" {
248
+ # --separate-stderr: plain `run` merges stdout and stderr into $output, so
249
+ # the required stderr reason would make $output non-empty even though
250
+ # stdout itself is clean. See the identical rationale on the Task 1 test
251
+ # "an empty secret is refused, not used" above.
252
+ _fixed_secret
253
+ run --separate-stderr librarian_author_key "everyone"
254
+ [ "$status" -ne 0 ]
255
+ [ "$output" = "" ]
256
+ [[ "$stderr" == *"everyone"* ]] || return 1
257
+ }
258
+
259
+ @test "a derivation on an empty secret refuses rather than sharing an identity" {
260
+ local path
261
+ path=$(librarian_author_secret_path)
262
+ mkdir -p "$(dirname "$path")"
263
+ : > "$path"
264
+
265
+ run --separate-stderr librarian_author_key "public"
266
+ [ "$status" -ne 0 ]
267
+ [ "$output" = "" ]
268
+ [[ "$stderr" == *"empty"* ]] || return 1
269
+ }
270
+
271
+ @test "a 65-character secret is refused, not accepted as a wider key" {
272
+ # The charset check used to be unbounded above (^[0-9a-f]{64,}$), so a
273
+ # longer-but-still-hex first line quietly derived a DIFFERENT identity
274
+ # instead of being refused. HMAC does not ignore extra key width — a
275
+ # longer key is a different key, not a wider version of the same one.
276
+ local path secret
277
+ path=$(librarian_author_secret_path)
278
+ mkdir -p "$(dirname "$path")"
279
+ secret=$(printf '0%.0s' $(seq 1 65))
280
+ [ "${#secret}" -eq 65 ]
281
+ printf '%s\n' "$secret" > "$path"
282
+
283
+ run librarian_author_key "public"
284
+ [ "$status" -ne 0 ]
285
+ [[ "$output" == *"malformed"* ]] || return 1
286
+ }
287
+
288
+ @test "a second line in the secret file is never concatenated into a third identity" {
289
+ # Regression for the embedded-newline bug: the charset check used to run
290
+ # AFTER a `tr -d '\n'` that stripped every newline in the file, so two
291
+ # concatenated 64-char lines (exactly the shape you get from `>>`
292
+ # instead of `>`, or restoring a backup on top of an existing secret)
293
+ # read as one 128-char "valid" secret — a THIRD identity matching
294
+ # neither line. Reading only the first line sidesteps that: content
295
+ # past line one is simply never read, so the key must match the golden
296
+ # vector for the all-zero secret used on its own.
297
+ local path
298
+ path=$(librarian_author_secret_path)
299
+ mkdir -p "$(dirname "$path")"
300
+ printf '%s\n' "0000000000000000000000000000000000000000000000000000000000000000" > "$path"
301
+ printf '%s\n' "1111111111111111111111111111111111111111111111111111111111111111" >> "$path"
302
+ chmod 0600 "$path"
303
+
304
+ run librarian_author_key "public"
305
+ [ "$status" -eq 0 ]
306
+ [ "$output" = "11ff8ab7134c834e788ab4a5130f7853" ]
307
+ }
308
+
309
+ @test "the secret reaches the HMAC subprocess over the environment, never argv" {
310
+ # On Linux /proc/<pid>/cmdline is world-readable, so a secret on argv
311
+ # would be visible to any local user for the life of the call. A spy
312
+ # `node` on PATH records its own argv, then delegates to the real node
313
+ # so the derivation still runs for real — this isn't just checking that
314
+ # SOME node ran, the golden vector still has to come out right.
315
+ _fixed_secret
316
+ local stub_bin argv_capture real_node
317
+ stub_bin="${BATS_TEST_TMPDIR}/bin"
318
+ mkdir -p "$stub_bin"
319
+ real_node=$(command -v node)
320
+ argv_capture="${BATS_TEST_TMPDIR}/node-argv.bin"
321
+ rm -f "$argv_capture"
322
+ cat > "${stub_bin}/node" <<STUB
323
+ #!/usr/bin/env bash
324
+ printf '%s\0' "\$@" >> "$argv_capture"
325
+ exec "$real_node" "\$@"
326
+ STUB
327
+ chmod +x "${stub_bin}/node"
328
+
329
+ local old_path="$PATH"
330
+ export PATH="${stub_bin}:${PATH}"
331
+ run librarian_author_key "public"
332
+ export PATH="$old_path"
333
+
334
+ [ "$status" -eq 0 ]
335
+ [ "$output" = "11ff8ab7134c834e788ab4a5130f7853" ]
336
+ [ -f "$argv_capture" ]
337
+
338
+ # grep -c returns 0 with exit status 1 when there is no match — assert
339
+ # on $output, not $status, per the $RANDOM check above.
340
+ run grep -ac "0000000000000000000000000000000000000000000000000000000000000000" "$argv_capture"
341
+ [ "$output" = "0" ]
342
+ }
343
+
344
+ @test "a directory at the secret path is refused, not silently stranding a secret" {
345
+ # `ln FILE DIR` succeeds by linking basename(FILE) inside DIR rather
346
+ # than failing, so a directory at the secret path used to make creation
347
+ # look like it succeeded while stranding a fresh 0600 secret one level
348
+ # down that nothing ever reads again — one new stray file per call.
349
+ local path
350
+ path=$(librarian_author_secret_path)
351
+ mkdir -p "$path"
352
+
353
+ run --separate-stderr librarian_author_secret_ensure
354
+ [ "$status" -ne 0 ]
355
+ [ "$output" = "" ]
356
+ [[ "$stderr" == *"not a regular file"* ]] || return 1
357
+
358
+ # No stray secret left behind inside the directory.
359
+ [ -z "$(ls -A "$path")" ]
360
+ }
361
+
362
+ @test "a FIFO at the secret path is refused rather than hanging forever" {
363
+ # A plain `cat` on a FIFO with no writer blocks indefinitely, and this
364
+ # repo's constraint is that a plugin must never hang a session. Wrapped
365
+ # in `timeout` as a safety net for the test itself in case of a
366
+ # regression; a correct implementation returns well inside it.
367
+ command -v timeout >/dev/null 2>&1 || skip "timeout not available"
368
+ command -v mkfifo >/dev/null 2>&1 || skip "mkfifo not available"
369
+ local path
370
+ path=$(librarian_author_secret_path)
371
+ mkdir -p "$(dirname "$path")"
372
+ mkfifo "$path"
373
+
374
+ run timeout 5 bash -c "source '${PLUGIN_ROOT}/scripts/lib/librarian-author-key.sh'; librarian_author_secret_ensure"
375
+ [ "$status" -ne 124 ] || return 1
376
+ [ "$status" -ne 0 ]
377
+ }
378
+
379
+ @test "a weak-permission secret from a compromised creation path is not silently repaired" {
380
+ # Pins the `created` guard directly: without it, the tighten step below
381
+ # would run unconditionally and silently repair a permissions
382
+ # regression in the creation path on the very call that introduced it,
383
+ # before anything — test or human — could observe it. The real
384
+ # creation path always produces 0600 under a healthy umask, so a stub
385
+ # `mktemp` on PATH stands in for a umask/creation regression: it
386
+ # creates the temp file for real (so `ln` still has something to link)
387
+ # and then weakens its permissions before this lib ever sees it.
388
+ local stub_bin real_mktemp
389
+ stub_bin="${BATS_TEST_TMPDIR}/bin"
390
+ mkdir -p "$stub_bin"
391
+ real_mktemp=$(command -v mktemp)
392
+ cat > "${stub_bin}/mktemp" <<STUB
393
+ #!/usr/bin/env bash
394
+ tmp=\$("$real_mktemp" "\$@")
395
+ chmod 0644 "\$tmp"
396
+ printf '%s' "\$tmp"
397
+ STUB
398
+ chmod +x "${stub_bin}/mktemp"
399
+
400
+ local old_path="$PATH"
401
+ export PATH="${stub_bin}:${PATH}"
402
+ run librarian_author_secret_ensure
403
+ export PATH="$old_path"
404
+ [ "$status" -eq 0 ]
405
+
406
+ local path
407
+ path=$(librarian_author_secret_path)
408
+ # The guard's job: leave the weak permissions from a bad creation path
409
+ # visible rather than silently fixing them on the same call that
410
+ # created the file.
411
+ [ "$(ls -l "$path" | cut -c1-10)" = "-rw-r--r--" ]
412
+ }
413
+
414
+ @test "the returned key carries no trailing newline" {
415
+ # `run`'s $output and $() both strip trailing newlines, so a stray
416
+ # printf '%s\n' regression in librarian_author_key would stay invisible
417
+ # to every other assertion in this file. Capture with a sentinel
418
+ # appended immediately after the call: only newlines at the very end of
419
+ # the whole captured stream get stripped, so a newline the function
420
+ # itself emits ends up mid-stream, before the sentinel, and survives.
421
+ _fixed_secret
422
+ local captured
423
+ captured=$(librarian_author_key "public"; printf 'END')
424
+ [ "$captured" = "11ff8ab7134c834e788ab4a5130f7853END" ]
425
+ }
426
+
427
+ @test "a 64-character non-hex digest is refused, not truncated and returned" {
428
+ # The width check alone cannot tell a digest from 64 arbitrary bytes.
429
+ # Requires a misbehaving node, which is a serious precondition — but a
430
+ # subprocess can misbehave for reasons other than compromise, and a
431
+ # non-hex key fails the contract's own /^[0-9a-f]{32}$/ at ingest.
432
+ _fixed_secret
433
+ local stub_bin
434
+ stub_bin="${BATS_TEST_TMPDIR}/bin"
435
+ mkdir -p "$stub_bin"
436
+ cat > "${stub_bin}/node" <<'STUB'
437
+ #!/usr/bin/env bash
438
+ printf 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz'
439
+ STUB
440
+ chmod +x "${stub_bin}/node"
441
+
442
+ local old_path="$PATH"
443
+ export PATH="${stub_bin}:${PATH}"
444
+ run --separate-stderr librarian_author_key "public"
445
+ export PATH="$old_path"
446
+
447
+ [ "$status" -ne 0 ]
448
+ [ "$output" = "" ]
449
+ [[ "$stderr" == *"malformed"* ]] || return 1
450
+ }
451
+
452
+ @test "a digest with a garbage line ahead of 64 valid hex chars is refused, not truncated" {
453
+ # grep is LINE-oriented: `grep -Eq '^[0-9a-f]{64}$'` matches as soon as ANY
454
+ # line in its input is 64 hex chars, so a stub that writes a non-hex line
455
+ # before a well-formed 64-hex line reproduces the bug a line-oriented grep
456
+ # would miss here — the single-line stub above cannot catch it. NODE_OPTIONS
457
+ # pointing at a preload that writes to stdout before node's own output is a
458
+ # routine, non-adversarial way to hit this shape in practice.
459
+ _fixed_secret
460
+ local stub_bin
461
+ stub_bin="${BATS_TEST_TMPDIR}/bin"
462
+ mkdir -p "$stub_bin"
463
+ cat > "${stub_bin}/node" <<'STUB'
464
+ #!/usr/bin/env bash
465
+ printf 'zzzzzzzz\n0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
466
+ STUB
467
+ chmod +x "${stub_bin}/node"
468
+
469
+ local old_path="$PATH"
470
+ export PATH="${stub_bin}:${PATH}"
471
+ run --separate-stderr librarian_author_key "public"
472
+ export PATH="$old_path"
473
+
474
+ [ "$status" -ne 0 ]
475
+ [ "$output" = "" ]
476
+ [[ "$stderr" == *"malformed"* ]] || return 1
477
+ }
@@ -124,13 +124,13 @@ _seed_proposal() {
124
124
  run librarian_cli_list "$PROJECT_REPO"
125
125
  [ "$status" -eq 0 ]
126
126
  # Header counts only pending entries (2 of 3).
127
- [[ "$output" == *"2 pending proposal"* ]]
127
+ [[ "$output" == *"2 pending proposal"* ]] || return 1
128
128
  # Both pending titles surface.
129
- [[ "$output" == *"Prefer functional patterns"* ]]
130
- [[ "$output" == *"Auth rewrite is compliance"* ]]
129
+ [[ "$output" == *"Prefer functional patterns"* ]] || return 1
130
+ [[ "$output" == *"Auth rewrite is compliance"* ]] || return 1
131
131
  # Pending rows include full IDs that can be used with show/accept/reject/defer.
132
- [[ "$output" == *"01LISTPENDINGA000000000000"* ]]
133
- [[ "$output" == *"01LISTPENDINGB000000000000"* ]]
132
+ [[ "$output" == *"01LISTPENDINGA000000000000"* ]] || return 1
133
+ [[ "$output" == *"01LISTPENDINGB000000000000"* ]] || return 1
134
134
  # Accepted entry does NOT appear in the list output.
135
135
  [[ "$output" != *"Already accepted"* ]]
136
136
  }
@@ -142,10 +142,10 @@ _seed_proposal() {
142
142
 
143
143
  run librarian_cli_show "01SHOWPROPOSAL0000000000000" "$PROJECT_REPO"
144
144
  [ "$status" -eq 0 ]
145
- [[ "$output" == *"01SHOWPROPOSAL0000000000000"* ]]
146
- [[ "$output" == *"type: feedback"* ]]
147
- [[ "$output" == *"filename: feedback_x.md"* ]]
148
- [[ "$output" == *"classifier_confidence: 0.82"* ]]
145
+ [[ "$output" == *"01SHOWPROPOSAL0000000000000"* ]] || return 1
146
+ [[ "$output" == *"type: feedback"* ]] || return 1
147
+ [[ "$output" == *"filename: feedback_x.md"* ]] || return 1
148
+ [[ "$output" == *"classifier_confidence: 0.82"* ]] || return 1
149
149
  [[ "$output" == *"Body of the memory."* ]]
150
150
  }
151
151
 
@@ -165,7 +165,7 @@ _seed_proposal() {
165
165
 
166
166
  run librarian_cli_accept "01ACCEPTPROPOSAL000000000000" "$PROJECT_REPO"
167
167
  [ "$status" -eq 0 ]
168
- [[ "$output" == *"Accepted."* ]]
168
+ [[ "$output" == *"Accepted."* ]] || return 1
169
169
 
170
170
  local out_file="${MEM_DIR}/feedback_functional.md"
171
171
  [ -f "$out_file" ]
@@ -220,7 +220,7 @@ _seed_proposal() {
220
220
 
221
221
  run librarian_cli_accept "01ACCEPTUNSAFE00000000000000" "$PROJECT_REPO"
222
222
  [ "$status" -eq 1 ]
223
- [[ "$output" == *"Failed to write memory file."* ]]
223
+ [[ "$output" == *"Failed to write memory file."* ]] || return 1
224
224
 
225
225
  # No memory file written anywhere under MEM_DIR or its parent.
226
226
  [ ! -f "${MEM_DIR}/../escape.md" ]
@@ -238,8 +238,8 @@ _seed_proposal() {
238
238
 
239
239
  run librarian_cli_reject "01REJECTPROPOSAL00000000000" "stale guidance" "$PROJECT_REPO"
240
240
  [ "$status" -eq 0 ]
241
- [[ "$output" == *"Rejected"* ]]
242
- [[ "$output" == *"stale guidance"* ]]
241
+ [[ "$output" == *"Rejected"* ]] || return 1
242
+ [[ "$output" == *"stale guidance"* ]] || return 1
243
243
 
244
244
  # Proposal status now rejected with the reason captured.
245
245
  local proposal_path="${LIBRARIAN_DIR}/proposals/01REJECTPROPOSAL00000000000.json"
@@ -268,7 +268,7 @@ _seed_proposal() {
268
268
 
269
269
  run librarian_cli_defer "01DEFERPROPOSAL000000000000" "$PROJECT_REPO"
270
270
  [ "$status" -eq 0 ]
271
- [[ "$output" == *"Deferred"* ]]
271
+ [[ "$output" == *"Deferred"* ]] || return 1
272
272
 
273
273
  local proposal_path="${LIBRARIAN_DIR}/proposals/01DEFERPROPOSAL000000000000.json"
274
274
  jq -e '.status == "pending"' "$proposal_path" >/dev/null
@@ -285,8 +285,8 @@ _seed_proposal() {
285
285
 
286
286
  run librarian_cli_status "$PROJECT_REPO"
287
287
  [ "$status" -eq 0 ]
288
- [[ "$output" == *"pending: 1"* ]]
289
- [[ "$output" == *"accepted: 1"* ]]
288
+ [[ "$output" == *"pending: 1"* ]] || return 1
289
+ [[ "$output" == *"accepted: 1"* ]] || return 1
290
290
  [[ "$output" == *"rejected: 1"* ]]
291
291
  }
292
292