@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.
- package/.claude/skills/writing-tests/SKILL.md +27 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/release.yml +14 -14
- package/.release-please-manifest.json +7 -7
- package/AGENTS.md +116 -0
- package/CHANGELOG.md +175 -0
- package/CLAUDE.md +5 -0
- package/docs/lesson-promotion-pipeline.md +6 -6
- package/docs/superpowers/plans/2026-08-10-lesson-confirmation.md +1273 -0
- package/docs/superpowers/plans/2026-08-11-lesson-judging.md +1232 -0
- package/docs/superpowers/plans/2026-08-11-lesson-unconfirm.md +458 -0
- package/docs/superpowers/plans/2026-08-12-author-key.md +567 -0
- package/docs/superpowers/plans/2026-08-13-approved-pool.md +747 -0
- package/docs/superpowers/plans/2026-08-13-librarian-cluster.md +392 -0
- package/docs/superpowers/plans/2026-08-14-criterion-scores-consumers.md +1374 -0
- package/docs/superpowers/plans/2026-08-14-criterion-scores-schema.md +277 -0
- package/docs/superpowers/plans/2026-08-16-cartographer-undocumented-entity.md +741 -0
- package/docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md +213 -0
- package/docs/superpowers/specs/2026-08-11-lesson-judging-design.md +267 -0
- package/docs/superpowers/specs/2026-08-11-lesson-unconfirm-design.md +152 -0
- package/docs/superpowers/specs/2026-08-12-author-key-design.md +317 -0
- package/docs/superpowers/specs/2026-08-13-approved-pool-design.md +232 -0
- package/docs/superpowers/specs/2026-08-14-criterion-scores-design.md +254 -0
- package/docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md +249 -0
- package/package.json +2 -2
- package/plugins/assayer/.claude-plugin/plugin.json +1 -1
- package/plugins/assayer/CHANGELOG.md +7 -0
- package/plugins/assayer/scripts/lib/assayer-config.sh +6 -0
- package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
- package/plugins/cartographer/CHANGELOG.md +54 -0
- package/plugins/cartographer/README.md +56 -1
- package/plugins/cartographer/config.json +7 -1
- package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +4 -4
- package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +7 -4
- package/plugins/cartographer/scripts/lib/cartographer-collect.sh +8 -1
- package/plugins/cartographer/scripts/lib/cartographer-config.sh +26 -0
- package/plugins/cartographer/scripts/lib/cartographer-events.sh +90 -0
- package/plugins/cartographer/scripts/lib/cartographer-filter.sh +105 -0
- package/plugins/cartographer/scripts/lib/cartographer-omission.sh +130 -0
- package/plugins/cartographer/scripts/lib/cartographer-resolve.sh +132 -0
- package/plugins/cartographer/scripts/run-audit.sh +136 -53
- package/plugins/cartographer/skills/cartographer/SKILL.md +33 -9
- package/plugins/curator/.claude-plugin/plugin.json +1 -1
- package/plugins/curator/CHANGELOG.md +7 -0
- package/plugins/curator/scripts/lib/curator-emit.sh +2 -1
- package/plugins/historian/.claude-plugin/plugin.json +1 -1
- package/plugins/historian/CHANGELOG.md +7 -0
- package/plugins/historian/scripts/lib/historian-emit.sh +2 -1
- package/plugins/librarian/.claude-plugin/plugin.json +1 -1
- package/plugins/librarian/CHANGELOG.md +71 -0
- package/plugins/librarian/config.json +30 -1
- package/plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md +82 -0
- package/plugins/librarian/scripts/hooks/librarian-session-end.sh +28 -0
- package/plugins/librarian/scripts/hooks/librarian-session-start.sh +42 -12
- package/plugins/librarian/scripts/lib/librarian-author-key.sh +279 -0
- package/plugins/librarian/scripts/lib/librarian-cli.sh +346 -2
- package/plugins/librarian/scripts/lib/librarian-emit.sh +2 -1
- package/plugins/librarian/scripts/lib/librarian-lesson-judge.sh +349 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +225 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-review.sh +312 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-rubric.sh +56 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +82 -13
- package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +6 -1
- package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +131 -62
- package/plugins/librarian/skills/librarian/SKILL.md +98 -1
- package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
- package/plugins/tribunal/CHANGELOG.md +63 -0
- package/plugins/tribunal/agents/tribunal-judge-adversarial.md +34 -0
- package/plugins/tribunal/agents/tribunal-judge-security.md +33 -0
- package/plugins/tribunal/agents/tribunal-judge-standard.md +12 -0
- package/plugins/tribunal/config.json +1 -0
- package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +154 -9
- package/plugins/tribunal/scripts/lib/tribunal-gate.sh +195 -16
- package/plugins/tribunal/skills/tribunal/SKILL.md +5 -3
- package/scripts/lib/prompt-rules.sh +6 -1
- package/test/bats/archivist-inject.bats +1 -1
- package/test/bats/assayer-extract.bats +2 -2
- package/test/bats/bursar-session-start.bats +3 -3
- package/test/bats/cartographer-config.bats +43 -0
- package/test/bats/cartographer-events.bats +267 -0
- package/test/bats/cartographer-filter.bats +169 -0
- package/test/bats/cartographer-lock.bats +3 -3
- package/test/bats/cartographer-omission.bats +202 -0
- package/test/bats/cartographer-resolve.bats +287 -0
- package/test/bats/cartographer-run-audit.bats +325 -0
- package/test/bats/compass-sanitizer.bats +11 -11
- package/test/bats/compass-transcript.bats +2 -2
- package/test/bats/config.bats +15 -15
- package/test/bats/curator-session-start.bats +10 -3
- package/test/bats/emit-payload-default.bats +52 -0
- package/test/bats/governor-ledger.bats +1 -1
- package/test/bats/historian-prompt-submit.bats +1 -1
- package/test/bats/inspector-post-write-hook.bats +4 -4
- package/test/bats/librarian-author-key.bats +477 -0
- package/test/bats/librarian-cli.bats +16 -16
- package/test/bats/librarian-lesson-judge.bats +884 -0
- package/test/bats/librarian-lesson-promote.bats +552 -0
- package/test/bats/librarian-lesson-review.bats +900 -0
- package/test/bats/librarian-lesson-transform.bats +17 -3
- package/test/bats/librarian-session-end.bats +161 -0
- package/test/bats/librarian-session-start.bats +36 -2
- package/test/bats/lineage-config.bats +1 -1
- package/test/bats/lineage-redact.bats +5 -5
- package/test/bats/session-tracker.bats +4 -4
- package/test/bats/tribunal-aggregate.bats +354 -1
- package/test/bats/tribunal-gate.bats +631 -0
- package/test/bats/tribunal-judge-agents.bats +119 -0
- package/test/bats/tribunal-jury.bats +1 -1
- package/test/bats/turn-tracker.bats +1 -1
- package/test/bats/warden-sanitizer.bats +3 -3
- package/test/bats/worktree-tracker.bats +2 -2
- 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
|
|