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