@onlooker-community/ecosystem 0.34.1 → 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-plugin/plugin.json +1 -1
- package/.github/workflows/release.yml +14 -14
- package/.release-please-manifest.json +4 -4
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +168 -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/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/librarian/.claude-plugin/plugin.json +1 -1
- package/plugins/librarian/CHANGELOG.md +64 -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 +344 -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 +56 -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 -11
- package/plugins/tribunal/scripts/lib/tribunal-gate.sh +193 -15
- package/plugins/tribunal/skills/tribunal/SKILL.md +5 -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-omission.bats +202 -0
- package/test/bats/cartographer-resolve.bats +287 -0
- package/test/bats/cartographer-run-audit.bats +325 -0
- package/test/bats/librarian-author-key.bats +477 -0
- 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 +34 -0
- 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/node/lesson-validate-agreement.test.mjs +35 -6
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
# Author Key Derivation Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Derive a per-visibility-scope `author_key` from a machine-local secret, so a lesson can be attributed without linking a user's org and public identities.
|
|
6
|
+
|
|
7
|
+
**Architecture:** One new librarian lib. A `0600` secret file outside any project key, created once and never regenerated, plus an HMAC derivation with a versioned domain tag. Nothing consumes it yet — `ecosystem-4z8.4` will.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** bash (macOS bash 3.2 compatible), `openssl` (LibreSSL 3.3.6 on macOS, OpenSSL 3.x on Linux CI), bats.
|
|
10
|
+
|
|
11
|
+
## Pre-flight — RESOLVED
|
|
12
|
+
|
|
13
|
+
The `author_key` width was recorded as an assumption. **It is confirmed.**
|
|
14
|
+
`ZAuthorKey` in `onlooker/packages/lesson-contract/src/primitives.ts:35-41`
|
|
15
|
+
is `z.string().regex(/^[0-9a-f]{32}$/)` — 32 lowercase hex characters. The
|
|
16
|
+
derivation truncates HMAC-SHA256 to its first 16 bytes, and the golden vectors
|
|
17
|
+
below are correct as written.
|
|
18
|
+
|
|
19
|
+
The contract does not pin the HMAC's `scope` input, so the
|
|
20
|
+
`onlooker.author.v1:` domain tag is ours to choose and is compatible.
|
|
21
|
+
|
|
22
|
+
## Global Constraints
|
|
23
|
+
|
|
24
|
+
- **The secret is NOT project-keyed.** It lives at `${ONLOOKER_DIR:-$HOME/.onlooker}/author/user_secret`, outside any project directory. Every *other* librarian artifact is project-keyed via `librarian_project_dir`, so the local pattern is the wrong one to copy here — a per-project secret would silently give one user a different identity in every repo.
|
|
25
|
+
- **Never use `$RANDOM` for the secret.** `plugins/archivist/scripts/lib/archivist-ulid.sh:41-44` uses it for ULIDs, which is correct there and disqualifying here. Copy `plugins/assayer/scripts/lib/assayer-ulid.sh:29`'s `openssl rand -hex` instead.
|
|
26
|
+
- **`openssl rand -hex 32` means 32 BYTES, printed as 64 hex characters.** Do not "correct" it to `-hex 16`; that halves the entropy and nothing fails.
|
|
27
|
+
- **Never regenerate an existing secret.** A missing file means first use; an existing file is authoritative. Regenerating orphans every lesson the user has written, including their ability to retract them.
|
|
28
|
+
- Use `${ONLOOKER_DIR:-$HOME/.onlooker}` — the idiom at `plugins/librarian/scripts/lib/librarian-storage.sh:19`. Never a bare hardcoded `~/.onlooker`.
|
|
29
|
+
- On failure, return non-zero and write **nothing to stdout**, with a reason on stderr. Silence is the actual danger in this file.
|
|
30
|
+
- Bash 3.2 compatible: no associative arrays, no `${var^^}`, no `mapfile`.
|
|
31
|
+
- **bats runs under macOS system bash 3.2, where a failing NON-FINAL `[[ ]]` does NOT fail the test.** Every non-final `[[ ]]` needs `|| return 1`; single-bracket `[ ]` gates on its own.
|
|
32
|
+
- Assert on messages, not just exit codes, wherever a test asserts a refusal. This codebase has shipped six vacuous tests across three branches.
|
|
33
|
+
- No event emission. American English.
|
|
34
|
+
- Commit via the `/commit` contract: `<type>(<scope>): <subject> :emoji:`, subject ≤72 chars including the emoji, why-focused body.
|
|
35
|
+
|
|
36
|
+
## File Structure
|
|
37
|
+
|
|
38
|
+
| File | Responsibility |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `plugins/librarian/scripts/lib/librarian-author-key.sh` | **Create.** Secret path, secret creation/validation, and the HMAC derivation. |
|
|
41
|
+
| `test/bats/librarian-author-key.bats` | **Create.** Secret-handling tests (Task 1), derivation tests including the golden vector (Task 2). |
|
|
42
|
+
| `docs/lesson-promotion-pipeline.md` | **Modify** (Task 2). Its "Open questions" section still lists `author_key` derivation as unanswered. |
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
### Task 1: The secret
|
|
47
|
+
|
|
48
|
+
**Files:**
|
|
49
|
+
- Create: `plugins/librarian/scripts/lib/librarian-author-key.sh`
|
|
50
|
+
- Test: `test/bats/librarian-author-key.bats`
|
|
51
|
+
|
|
52
|
+
**Interfaces:**
|
|
53
|
+
- Consumes: nothing. This lib is self-contained by design — it must not depend on `librarian_project_dir`, because the secret is not project-scoped.
|
|
54
|
+
- Produces:
|
|
55
|
+
- `librarian_author_secret_path` → echoes the absolute path. Never fails.
|
|
56
|
+
- `librarian_author_secret_ensure` → creates the secret if absent, validates it if present. Echoes the secret on stdout, returns 0. Returns non-zero with empty stdout and a stderr reason if it cannot produce a valid secret.
|
|
57
|
+
|
|
58
|
+
- [ ] **Step 1: Write the failing tests**
|
|
59
|
+
|
|
60
|
+
Create `test/bats/librarian-author-key.bats`:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
#!/usr/bin/env bats
|
|
64
|
+
|
|
65
|
+
setup() {
|
|
66
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
67
|
+
setup_test_env
|
|
68
|
+
|
|
69
|
+
PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
|
|
70
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
71
|
+
|
|
72
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-author-key.sh"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@test "the secret lives outside any project directory" {
|
|
76
|
+
# Every other librarian artifact is project-keyed. This one must not be:
|
|
77
|
+
# a per-project secret would give one user a different identity in every
|
|
78
|
+
# repo, silently.
|
|
79
|
+
run librarian_author_secret_path
|
|
80
|
+
[ "$status" -eq 0 ]
|
|
81
|
+
[ "$output" = "${ONLOOKER_DIR}/author/user_secret" ]
|
|
82
|
+
[[ "$output" != *"/librarian/"* ]] || return 1
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@test "first use creates a secret with 0600 permissions" {
|
|
86
|
+
run librarian_author_secret_ensure
|
|
87
|
+
[ "$status" -eq 0 ]
|
|
88
|
+
|
|
89
|
+
local path
|
|
90
|
+
path=$(librarian_author_secret_path)
|
|
91
|
+
[ -f "$path" ]
|
|
92
|
+
# stat's portable-enough form for mode; %A on GNU, %Sp on BSD — use ls.
|
|
93
|
+
local mode
|
|
94
|
+
mode=$(ls -l "$path" | cut -c1-10)
|
|
95
|
+
[ "$mode" = "-rw-------" ]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@test "the generated secret is 64 hex characters" {
|
|
99
|
+
# openssl rand -hex 32 requests 32 BYTES and prints 64 characters.
|
|
100
|
+
# Halving this to -hex 16 would halve the entropy and nothing would fail.
|
|
101
|
+
run librarian_author_secret_ensure
|
|
102
|
+
[ "$status" -eq 0 ]
|
|
103
|
+
[ "${#output}" -eq 64 ]
|
|
104
|
+
printf '%s' "$output" | grep -Eq '^[0-9a-f]{64}$' || return 1
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@test "an existing secret is never regenerated" {
|
|
108
|
+
# Load-bearing: regenerating silently changes the user's identity and
|
|
109
|
+
# orphans every lesson they have written, including retraction.
|
|
110
|
+
librarian_author_secret_ensure >/dev/null
|
|
111
|
+
local path first second
|
|
112
|
+
path=$(librarian_author_secret_path)
|
|
113
|
+
first=$(cat "$path")
|
|
114
|
+
|
|
115
|
+
librarian_author_secret_ensure >/dev/null
|
|
116
|
+
second=$(cat "$path")
|
|
117
|
+
[ "$first" = "$second" ]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@test "an empty secret is refused, not used" {
|
|
121
|
+
# HMAC("", scope) is IDENTICAL for every user in this state — a corrupt
|
|
122
|
+
# secret would silently collapse everyone onto one shared identity.
|
|
123
|
+
local path
|
|
124
|
+
path=$(librarian_author_secret_path)
|
|
125
|
+
mkdir -p "$(dirname "$path")"
|
|
126
|
+
: > "$path"
|
|
127
|
+
|
|
128
|
+
run librarian_author_secret_ensure
|
|
129
|
+
[ "$status" -ne 0 ]
|
|
130
|
+
[ "$output" = "" ]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@test "a short secret is refused, naming the problem" {
|
|
134
|
+
local path
|
|
135
|
+
path=$(librarian_author_secret_path)
|
|
136
|
+
mkdir -p "$(dirname "$path")"
|
|
137
|
+
printf 'abc123' > "$path"
|
|
138
|
+
|
|
139
|
+
run librarian_author_secret_ensure
|
|
140
|
+
[ "$status" -ne 0 ]
|
|
141
|
+
[[ "$output" == *"too short"* ]] || return 1
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@test "a world-readable secret is tightened and warned about" {
|
|
145
|
+
local path
|
|
146
|
+
path=$(librarian_author_secret_path)
|
|
147
|
+
mkdir -p "$(dirname "$path")"
|
|
148
|
+
openssl rand -hex 32 > "$path"
|
|
149
|
+
chmod 0644 "$path"
|
|
150
|
+
|
|
151
|
+
run librarian_author_secret_ensure
|
|
152
|
+
[ "$status" -eq 0 ]
|
|
153
|
+
[ "$(ls -l "$path" | cut -c1-10)" = "-rw-------" ]
|
|
154
|
+
[[ "$output" == *"permissions"* ]] || return 1
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@test "the lib never uses RANDOM for the secret" {
|
|
158
|
+
# archivist-ulid.sh uses $RANDOM correctly for a sortable id; it is the
|
|
159
|
+
# nearer example in this repo and the wrong one to copy for a secret.
|
|
160
|
+
run grep -c 'RANDOM' "${PLUGIN_ROOT}/scripts/lib/librarian-author-key.sh"
|
|
161
|
+
[ "$output" = "0" ]
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- [ ] **Step 2: Run the tests to verify they fail**
|
|
166
|
+
|
|
167
|
+
Run: `bats test/bats/librarian-author-key.bats`
|
|
168
|
+
Expected: every test FAILS — the lib does not exist, so `source` in `setup()` errors.
|
|
169
|
+
|
|
170
|
+
- [ ] **Step 3: Write the secret half of the lib**
|
|
171
|
+
|
|
172
|
+
Create `plugins/librarian/scripts/lib/librarian-author-key.sh`:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
#!/usr/bin/env bash
|
|
176
|
+
# Author identity for lesson promotion.
|
|
177
|
+
#
|
|
178
|
+
# author_key is derived PER VISIBILITY SCOPE so a user's org identity and their
|
|
179
|
+
# public identity cannot be linked by anyone who does not hold their secret.
|
|
180
|
+
#
|
|
181
|
+
# Breaking that unlinkability breaks it SILENTLY: nothing throws, and the pool
|
|
182
|
+
# fills with well-formed keys that leak the association they exist to prevent.
|
|
183
|
+
# The golden-vector test in test/bats/librarian-author-key.bats is what makes
|
|
184
|
+
# any change to this derivation go red.
|
|
185
|
+
#
|
|
186
|
+
# Exposes:
|
|
187
|
+
# librarian_author_secret_path
|
|
188
|
+
# librarian_author_secret_ensure
|
|
189
|
+
# librarian_author_key <visibility>
|
|
190
|
+
|
|
191
|
+
# Where the secret lives.
|
|
192
|
+
#
|
|
193
|
+
# NOT project-keyed, deliberately. Every other librarian artifact sits under
|
|
194
|
+
# librarian_project_dir; this one must not, because one user is one author
|
|
195
|
+
# across all their repos. A per-project secret would hand the same person a
|
|
196
|
+
# different identity in every project, and nothing would report it.
|
|
197
|
+
librarian_author_secret_path() {
|
|
198
|
+
printf '%s/author/user_secret' "${ONLOOKER_DIR:-$HOME/.onlooker}"
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# Echo a valid secret, creating one on first use.
|
|
202
|
+
#
|
|
203
|
+
# Returns non-zero with empty stdout if it cannot produce a valid secret. An
|
|
204
|
+
# invalid secret is never "repaired" by regenerating: an existing file is
|
|
205
|
+
# authoritative, because replacing it silently changes the user's identity.
|
|
206
|
+
librarian_author_secret_ensure() {
|
|
207
|
+
local path dir
|
|
208
|
+
path="$(librarian_author_secret_path)"
|
|
209
|
+
dir="$(dirname "$path")"
|
|
210
|
+
|
|
211
|
+
if [[ ! -f "$path" ]]; then
|
|
212
|
+
command -v openssl >/dev/null 2>&1 || {
|
|
213
|
+
printf 'author-key: openssl is required to create a secret.\n' >&2
|
|
214
|
+
return 1
|
|
215
|
+
}
|
|
216
|
+
mkdir -p "$dir" 2>/dev/null || {
|
|
217
|
+
printf 'author-key: cannot create %s\n' "$dir" >&2
|
|
218
|
+
return 1
|
|
219
|
+
}
|
|
220
|
+
# 32 BYTES, printed as 64 hex characters. Not $RANDOM: that is a
|
|
221
|
+
# 15-bit PRNG, fine for a sortable id and disqualifying for a secret.
|
|
222
|
+
local generated
|
|
223
|
+
generated=$(openssl rand -hex 32 2>/dev/null) || {
|
|
224
|
+
printf 'author-key: openssl rand failed.\n' >&2
|
|
225
|
+
return 1
|
|
226
|
+
}
|
|
227
|
+
# Create restricted, then write — never write then chmod, which leaves
|
|
228
|
+
# a window where the secret is world-readable on disk.
|
|
229
|
+
( umask 077 && printf '%s\n' "$generated" > "$path" ) || {
|
|
230
|
+
printf 'author-key: cannot write %s\n' "$path" >&2
|
|
231
|
+
return 1
|
|
232
|
+
}
|
|
233
|
+
fi
|
|
234
|
+
|
|
235
|
+
# Tighten loose permissions and say so. Refusing would block promotion over
|
|
236
|
+
# something the user cannot fix without guidance; staying silent would hide
|
|
237
|
+
# a real exposure. Tightening does not undo an exposure that already
|
|
238
|
+
# happened — it stops the next one, and the warning is the part that counts.
|
|
239
|
+
local mode
|
|
240
|
+
mode=$(ls -l "$path" 2>/dev/null | cut -c1-10)
|
|
241
|
+
if [[ "$mode" != "-rw-------" ]]; then
|
|
242
|
+
chmod 0600 "$path" 2>/dev/null
|
|
243
|
+
printf 'author-key: tightened permissions on %s (was %s)\n' "$path" "$mode" >&2
|
|
244
|
+
fi
|
|
245
|
+
|
|
246
|
+
local secret
|
|
247
|
+
secret=$(cat "$path" 2>/dev/null | tr -d '\n')
|
|
248
|
+
if [[ -z "$secret" ]]; then
|
|
249
|
+
printf 'author-key: secret at %s is empty; refusing to derive.\n' "$path" >&2
|
|
250
|
+
return 1
|
|
251
|
+
fi
|
|
252
|
+
# A short-but-nonempty secret still derives a plausible key with less
|
|
253
|
+
# entropy than this design claims. 64 is the width openssl rand -hex 32
|
|
254
|
+
# produces.
|
|
255
|
+
if [[ "${#secret}" -lt 64 ]]; then
|
|
256
|
+
printf 'author-key: secret at %s is too short (%d chars, expected 64).\n' \
|
|
257
|
+
"$path" "${#secret}" >&2
|
|
258
|
+
return 1
|
|
259
|
+
fi
|
|
260
|
+
|
|
261
|
+
printf '%s' "$secret"
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
- [ ] **Step 4: Run the tests to verify they pass**
|
|
266
|
+
|
|
267
|
+
Run: `bats test/bats/librarian-author-key.bats`
|
|
268
|
+
Expected: PASS, 8/8.
|
|
269
|
+
|
|
270
|
+
- [ ] **Step 5: Prove the two security-critical tests discriminate**
|
|
271
|
+
|
|
272
|
+
In a throwaway `git worktree` only — never the shared working tree:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
git worktree add /tmp/ak-verify HEAD
|
|
276
|
+
# In /tmp/ak-verify, two separate injections, run the suite after each:
|
|
277
|
+
# (a) make librarian_author_secret_ensure regenerate unconditionally
|
|
278
|
+
# (move the generation outside the `if [[ ! -f ... ]]`)
|
|
279
|
+
# Expected: "an existing secret is never regenerated" FAILS
|
|
280
|
+
# (b) delete the empty-secret guard
|
|
281
|
+
# Expected: "an empty secret is refused, not used" FAILS
|
|
282
|
+
git worktree remove --force /tmp/ak-verify
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Report each injection's result separately. If either leaves the suite green, that test is not pinning its guard — say so plainly rather than reporting it as proven.
|
|
286
|
+
|
|
287
|
+
- [ ] **Step 6: Lint and commit**
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
npm run lint:check
|
|
291
|
+
shellcheck -S error -x plugins/librarian/scripts/lib/librarian-author-key.sh
|
|
292
|
+
git add plugins/librarian/scripts/lib/librarian-author-key.sh test/bats/librarian-author-key.bats
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Commit subject: `feat(librarian): keep one author secret per user :closed_lock_with_key:`
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
### Task 2: The derivation
|
|
300
|
+
|
|
301
|
+
**Files:**
|
|
302
|
+
- Modify: `plugins/librarian/scripts/lib/librarian-author-key.sh`
|
|
303
|
+
- Modify: `docs/lesson-promotion-pipeline.md`
|
|
304
|
+
- Test: `test/bats/librarian-author-key.bats` (append)
|
|
305
|
+
|
|
306
|
+
**Interfaces:**
|
|
307
|
+
- Consumes: `librarian_author_secret_ensure` from Task 1.
|
|
308
|
+
- Produces: `librarian_author_key <visibility>` → echoes 32 lowercase hex, returns 0. Non-zero with empty stdout on any failure.
|
|
309
|
+
|
|
310
|
+
**Confirm the BLOCKING PRE-FLIGHT above before starting.** If the contract says 64 hex rather than 32, change the truncation and regenerate the golden vector; nothing else moves.
|
|
311
|
+
|
|
312
|
+
- [ ] **Step 1: Write the failing tests**
|
|
313
|
+
|
|
314
|
+
Append to `test/bats/librarian-author-key.bats`:
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
# A fixed secret, so the golden vector below is reproducible.
|
|
318
|
+
_fixed_secret() {
|
|
319
|
+
local path
|
|
320
|
+
path=$(librarian_author_secret_path)
|
|
321
|
+
mkdir -p "$(dirname "$path")"
|
|
322
|
+
printf '%s\n' "0000000000000000000000000000000000000000000000000000000000000000" > "$path"
|
|
323
|
+
chmod 0600 "$path"
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
@test "GOLDEN VECTOR: a fixed secret and visibility produce a fixed key" {
|
|
327
|
+
# THE load-bearing test. Change the domain tag, the truncation width, the
|
|
328
|
+
# hash, or the argument order and this goes red. It is what makes "the
|
|
329
|
+
# derivation is permanent" enforceable rather than aspirational.
|
|
330
|
+
#
|
|
331
|
+
# Regenerate ONLY if the contract's width changes, and say so in the
|
|
332
|
+
# commit — a silent update here defeats the test's whole purpose.
|
|
333
|
+
_fixed_secret
|
|
334
|
+
run librarian_author_key "public"
|
|
335
|
+
[ "$status" -eq 0 ]
|
|
336
|
+
[ "$output" = "11ff8ab7134c834e788ab4a5130f7853" ]
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
@test "GOLDEN VECTOR: org and private are pinned too" {
|
|
340
|
+
# All three, so a change that happens to preserve one scope's output
|
|
341
|
+
# still goes red. Computed independently of the implementation.
|
|
342
|
+
_fixed_secret
|
|
343
|
+
[ "$(librarian_author_key "private")" = "e74674c25190cdf15099604441bb0d4b" ]
|
|
344
|
+
[ "$(librarian_author_key "org")" = "a8cf0203e178702412d37d5f796adbdc" ]
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
@test "the same inputs always produce the same key" {
|
|
348
|
+
# Retraction depends on this: a user must be able to re-derive the key
|
|
349
|
+
# that authored a lesson.
|
|
350
|
+
_fixed_secret
|
|
351
|
+
local a b
|
|
352
|
+
a=$(librarian_author_key "org")
|
|
353
|
+
b=$(librarian_author_key "org")
|
|
354
|
+
[ "$a" = "$b" ]
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
@test "the three visibilities produce three distinct keys" {
|
|
358
|
+
_fixed_secret
|
|
359
|
+
local p o u
|
|
360
|
+
p=$(librarian_author_key "private")
|
|
361
|
+
o=$(librarian_author_key "org")
|
|
362
|
+
u=$(librarian_author_key "public")
|
|
363
|
+
[ "$p" != "$o" ]
|
|
364
|
+
[ "$o" != "$u" ]
|
|
365
|
+
[ "$p" != "$u" ]
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
@test "different secrets produce different keys at the same visibility" {
|
|
369
|
+
# Catches a constant that ignores the secret entirely — which the
|
|
370
|
+
# scope-separation test above would NOT catch.
|
|
371
|
+
_fixed_secret
|
|
372
|
+
local first
|
|
373
|
+
first=$(librarian_author_key "public")
|
|
374
|
+
|
|
375
|
+
local path
|
|
376
|
+
path=$(librarian_author_secret_path)
|
|
377
|
+
printf '%s\n' "1111111111111111111111111111111111111111111111111111111111111111" > "$path"
|
|
378
|
+
local second
|
|
379
|
+
second=$(librarian_author_key "public")
|
|
380
|
+
|
|
381
|
+
[ "$first" != "$second" ]
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
@test "the key is 32 lowercase hex" {
|
|
385
|
+
_fixed_secret
|
|
386
|
+
run librarian_author_key "public"
|
|
387
|
+
[ "$status" -eq 0 ]
|
|
388
|
+
[ "${#output}" -eq 32 ]
|
|
389
|
+
printf '%s' "$output" | grep -Eq '^[0-9a-f]{32}$' || return 1
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
@test "the key is not the secret" {
|
|
393
|
+
# Catches a "derivation" that echoes its input.
|
|
394
|
+
_fixed_secret
|
|
395
|
+
local secret key
|
|
396
|
+
secret=$(librarian_author_secret_ensure)
|
|
397
|
+
key=$(librarian_author_key "public")
|
|
398
|
+
[ "$key" != "$secret" ]
|
|
399
|
+
[[ "$secret" != *"$key"* ]] || return 1
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
@test "an unknown visibility is refused, naming it" {
|
|
403
|
+
_fixed_secret
|
|
404
|
+
run librarian_author_key "everyone"
|
|
405
|
+
[ "$status" -ne 0 ]
|
|
406
|
+
[ "$output" = "" ]
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
@test "a derivation on an empty secret refuses rather than sharing an identity" {
|
|
410
|
+
local path
|
|
411
|
+
path=$(librarian_author_secret_path)
|
|
412
|
+
mkdir -p "$(dirname "$path")"
|
|
413
|
+
: > "$path"
|
|
414
|
+
|
|
415
|
+
run librarian_author_key "public"
|
|
416
|
+
[ "$status" -ne 0 ]
|
|
417
|
+
[ "$output" = "" ]
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
- [ ] **Step 2: Verify the golden vectors independently**
|
|
422
|
+
|
|
423
|
+
The three expected values above were computed **before any implementation
|
|
424
|
+
existed**, directly from `openssl`, so they are not circular. Confirm them
|
|
425
|
+
yourself before trusting them — a golden vector copied from a buggy
|
|
426
|
+
implementation pins the bug:
|
|
427
|
+
|
|
428
|
+
```bash
|
|
429
|
+
Z=0000000000000000000000000000000000000000000000000000000000000000
|
|
430
|
+
for v in private org public; do
|
|
431
|
+
printf '%s' "onlooker.author.v1:${v}" \
|
|
432
|
+
| openssl dgst -sha256 -hmac "$Z" -r | cut -d' ' -f1 | cut -c1-32
|
|
433
|
+
done
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Expected, in order: `e74674c25190cdf15099604441bb0d4b`,
|
|
437
|
+
`a8cf0203e178702412d37d5f796adbdc`, `11ff8ab7134c834e788ab4a5130f7853`.
|
|
438
|
+
|
|
439
|
+
If your platform's `openssl` disagrees with these, **stop and report it** —
|
|
440
|
+
that is a portability problem in the derivation itself, not a bad test, and it
|
|
441
|
+
means keys would differ between a contributor's machine and CI.
|
|
442
|
+
|
|
443
|
+
- [ ] **Step 3: Run the tests to verify they fail**
|
|
444
|
+
|
|
445
|
+
Run: `bats test/bats/librarian-author-key.bats`
|
|
446
|
+
Expected: the nine new tests FAIL — `librarian_author_key` is not defined.
|
|
447
|
+
|
|
448
|
+
- [ ] **Step 4: Write the derivation**
|
|
449
|
+
|
|
450
|
+
Append to `plugins/librarian/scripts/lib/librarian-author-key.sh`:
|
|
451
|
+
|
|
452
|
+
```bash
|
|
453
|
+
# Derive this user's author_key for one visibility scope.
|
|
454
|
+
#
|
|
455
|
+
# HMAC-SHA256(secret, "onlooker.author.v1:<visibility>")
|
|
456
|
+
# truncated to 16 bytes, rendered as 32 lowercase hex
|
|
457
|
+
#
|
|
458
|
+
# The domain tag stops this secret's output colliding with any other use of the
|
|
459
|
+
# same secret. The version is what lets a future v2 add an org identity without
|
|
460
|
+
# silently rederiving every existing key: v1 lessons keep validating under v1.
|
|
461
|
+
#
|
|
462
|
+
# No org id in v1 — none exists in this system, and inventing one for an
|
|
463
|
+
# unwritten consumer is the mistake ecosystem-si6 avoided.
|
|
464
|
+
#
|
|
465
|
+
# Usage: librarian_author_key <private|org|public>
|
|
466
|
+
librarian_author_key() {
|
|
467
|
+
local visibility="${1:-}"
|
|
468
|
+
case "$visibility" in
|
|
469
|
+
private|org|public) ;;
|
|
470
|
+
*)
|
|
471
|
+
printf 'author-key: unrecognized visibility: %s\n' "$visibility" >&2
|
|
472
|
+
return 1
|
|
473
|
+
;;
|
|
474
|
+
esac
|
|
475
|
+
|
|
476
|
+
command -v openssl >/dev/null 2>&1 || {
|
|
477
|
+
printf 'author-key: openssl is required to derive a key.\n' >&2
|
|
478
|
+
return 1
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
local secret
|
|
482
|
+
secret=$(librarian_author_secret_ensure) || return 1
|
|
483
|
+
|
|
484
|
+
local digest
|
|
485
|
+
digest=$(printf '%s' "onlooker.author.v1:${visibility}" \
|
|
486
|
+
| openssl dgst -sha256 -hmac "$secret" -r 2>/dev/null \
|
|
487
|
+
| cut -d' ' -f1) || {
|
|
488
|
+
printf 'author-key: HMAC failed.\n' >&2
|
|
489
|
+
return 1
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
# 64 hex chars in, 32 out. Truncating an HMAC is standard; 128 bits is
|
|
493
|
+
# ample for a collision-resistant pseudonymous identifier.
|
|
494
|
+
[[ "${#digest}" -eq 64 ]] || {
|
|
495
|
+
printf 'author-key: unexpected digest width %d; refusing.\n' "${#digest}" >&2
|
|
496
|
+
return 1
|
|
497
|
+
}
|
|
498
|
+
printf '%s' "${digest:0:32}"
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
- [ ] **Step 5: Run the tests to verify they pass**
|
|
503
|
+
|
|
504
|
+
Run: `bats test/bats/librarian-author-key.bats`
|
|
505
|
+
Expected: PASS, 17/17 (8 from Task 1, 9 new).
|
|
506
|
+
|
|
507
|
+
- [ ] **Step 6: Prove the golden vector discriminates**
|
|
508
|
+
|
|
509
|
+
In a throwaway `git worktree` only. Three separate injections, running the
|
|
510
|
+
suite after each and reverting between:
|
|
511
|
+
|
|
512
|
+
```bash
|
|
513
|
+
git worktree add /tmp/ak-verify2 HEAD
|
|
514
|
+
# (a) change the domain tag to "onlooker.author.v2:"
|
|
515
|
+
# (b) change the truncation from :0:32 to :0:64
|
|
516
|
+
# (c) swap the HMAC arguments (hash the secret keyed by the message)
|
|
517
|
+
git worktree remove --force /tmp/ak-verify2
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Expected: the golden-vector test FAILS under each. Report all three results
|
|
521
|
+
separately. If any injection leaves it green, the vector is not pinning the
|
|
522
|
+
derivation — say so rather than reporting it as proven.
|
|
523
|
+
|
|
524
|
+
- [ ] **Step 7: Close the doc's open question**
|
|
525
|
+
|
|
526
|
+
`docs/lesson-promotion-pipeline.md` has an "Open questions" section whose
|
|
527
|
+
second bullet still reads that `author_key` derivation is unsettled ("Where
|
|
528
|
+
does `user_secret` live, and how is it created on first use?"). Replace that
|
|
529
|
+
bullet with a one-line statement of the answer and a pointer to
|
|
530
|
+
`docs/superpowers/specs/2026-08-12-author-key-design.md`.
|
|
531
|
+
|
|
532
|
+
Leave the other open questions alone.
|
|
533
|
+
|
|
534
|
+
- [ ] **Step 8: Lint, full suite, and commit**
|
|
535
|
+
|
|
536
|
+
```bash
|
|
537
|
+
npm run lint:check
|
|
538
|
+
shellcheck -S error -x plugins/librarian/scripts/lib/librarian-author-key.sh
|
|
539
|
+
npm run test:ci
|
|
540
|
+
git add plugins/librarian/scripts/lib/librarian-author-key.sh \
|
|
541
|
+
test/bats/librarian-author-key.bats \
|
|
542
|
+
docs/lesson-promotion-pipeline.md
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Read each exit code from `$?` directly — never through a pipe, which reports
|
|
546
|
+
the pipe's last command.
|
|
547
|
+
|
|
548
|
+
Commit subject: `feat(librarian): derive an author key per visibility scope :closed_lock_with_key:`
|
|
549
|
+
|
|
550
|
+
---
|
|
551
|
+
|
|
552
|
+
## Spec coverage
|
|
553
|
+
|
|
554
|
+
| Spec requirement | Task |
|
|
555
|
+
|---|---|
|
|
556
|
+
| Secret at `$ONLOOKER_DIR/author/user_secret`, not project-keyed | 1 |
|
|
557
|
+
| `openssl rand -hex 32`, never `$RANDOM` | 1 |
|
|
558
|
+
| Created `0600`; loose permissions tightened with a warning | 1 |
|
|
559
|
+
| Never regenerated when present | 1 |
|
|
560
|
+
| Empty or short secret refused | 1, 2 |
|
|
561
|
+
| `HMAC(secret, "onlooker.author.v1:<visibility>")`, truncated to 32 hex | 2 |
|
|
562
|
+
| All three visibilities derive a key | 2 |
|
|
563
|
+
| Failure returns non-zero with empty stdout and a stderr reason | 1, 2 |
|
|
564
|
+
| Golden vector, determinism, scope separation, secret separation | 2 |
|
|
565
|
+
| Format is exactly 32 lowercase hex | 2 |
|
|
566
|
+
| Key is not the secret | 2 |
|
|
567
|
+
| No `$RANDOM` in the lib | 1 |
|