@holmes-lab/holmes-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -0
  4. package/bin/holmes-hook-antigravity.js +31 -0
  5. package/bin/holmes-kit.js +23 -0
  6. package/bin/holmes-mcp.js +34 -0
  7. package/bin/holmes-stop-antigravity.js +29 -0
  8. package/dist/.build-id +1 -0
  9. package/dist/holmes/cli/agents.js +168 -0
  10. package/dist/holmes/cli/doctor.js +625 -0
  11. package/dist/holmes/cli/gitignore-merge.js +84 -0
  12. package/dist/holmes/cli/governed-precondition.js +157 -0
  13. package/dist/holmes/cli/index.js +384 -0
  14. package/dist/holmes/cli/init.js +462 -0
  15. package/dist/holmes/cli/playbook-skills.js +711 -0
  16. package/dist/holmes/cli/roles-readme.js +134 -0
  17. package/dist/holmes/cli/settings-merge.js +122 -0
  18. package/dist/holmes/config/config.js +70 -0
  19. package/dist/holmes/context/bundler.js +114 -0
  20. package/dist/holmes/context/render.js +29 -0
  21. package/dist/holmes/context/tiers.js +110 -0
  22. package/dist/holmes/context/tokens.js +8 -0
  23. package/dist/holmes/cpg/cpg-scanner.js +213 -0
  24. package/dist/holmes/cpg/hash-cache.js +86 -0
  25. package/dist/holmes/cpg/language-parser-walk.js +917 -0
  26. package/dist/holmes/cpg/language-parser-worker.js +81 -0
  27. package/dist/holmes/cpg/language-parser.js +234 -0
  28. package/dist/holmes/cpg/scan-cache.js +108 -0
  29. package/dist/holmes/cpg/source-path.js +44 -0
  30. package/dist/holmes/cpg/test-files.js +84 -0
  31. package/dist/holmes/governance/constitution-debt.js +73 -0
  32. package/dist/holmes/governance/constitution-report.js +25 -0
  33. package/dist/holmes/governance/constitution.js +129 -0
  34. package/dist/holmes/governance/identity.js +30 -0
  35. package/dist/holmes/governance/ledger-lock.js +165 -0
  36. package/dist/holmes/governance/ledger-store.conformance.js +90 -0
  37. package/dist/holmes/governance/ledger-store.js +106 -0
  38. package/dist/holmes/governance/progress-ledger.js +83 -0
  39. package/dist/holmes/governance/provenance-chain.js +365 -0
  40. package/dist/holmes/governance/provenance-ledger.js +0 -0
  41. package/dist/holmes/governance/provenance-schema.js +47 -0
  42. package/dist/holmes/governance/replica-id.js +106 -0
  43. package/dist/holmes/governance/role-policy.js +137 -0
  44. package/dist/holmes/governance/trust-score.js +43 -0
  45. package/dist/holmes/guardrail/anchors.js +31 -0
  46. package/dist/holmes/guardrail/blind-spots.js +38 -0
  47. package/dist/holmes/guardrail/decision-ledger.js +107 -0
  48. package/dist/holmes/guardrail/executable-artifact.js +129 -0
  49. package/dist/holmes/guardrail/governance-history.js +101 -0
  50. package/dist/holmes/guardrail/phase.js +169 -0
  51. package/dist/holmes/guardrail/risk-classifier.js +450 -0
  52. package/dist/holmes/guardrail/risk-gate.js +160 -0
  53. package/dist/holmes/guardrail/risk-types.js +6 -0
  54. package/dist/holmes/guardrail/tspec-state.js +392 -0
  55. package/dist/holmes/guardrail/write-target.js +224 -0
  56. package/dist/holmes/hooks/adapters/antigravity.js +194 -0
  57. package/dist/holmes/hooks/pre-tool-use.js +1262 -0
  58. package/dist/holmes/hooks/stop.js +416 -0
  59. package/dist/holmes/mcp/basis.js +162 -0
  60. package/dist/holmes/mcp/handlers.js +1831 -0
  61. package/dist/holmes/mcp/server.js +71 -0
  62. package/dist/holmes/mcp/stdio-client.js +165 -0
  63. package/dist/holmes/mcp/supervisor.js +178 -0
  64. package/dist/holmes/mcp/tool-schemas.js +394 -0
  65. package/dist/holmes/mcp/validate-args.js +281 -0
  66. package/dist/holmes/messages/registry.js +50 -0
  67. package/dist/holmes/project/baseline.js +210 -0
  68. package/dist/holmes/project/change-source.js +233 -0
  69. package/dist/holmes/project/ignore.js +145 -0
  70. package/dist/holmes/project/root.js +113 -0
  71. package/dist/holmes/reverse/anchor.js +162 -0
  72. package/dist/holmes/reverse/cluster.js +187 -0
  73. package/dist/holmes/reverse/draft.js +151 -0
  74. package/dist/holmes/reverse/dynamic-wiring.js +47 -0
  75. package/dist/holmes/reverse/scan.js +194 -0
  76. package/dist/holmes/reverse/surface.js +154 -0
  77. package/dist/holmes/reverse/test-map.js +263 -0
  78. package/dist/holmes/review/coverage.js +33 -0
  79. package/dist/holmes/review/findings.js +123 -0
  80. package/dist/holmes/review/package.js +40 -0
  81. package/dist/holmes/review/review-targets.js +92 -0
  82. package/dist/holmes/review/scope.js +57 -0
  83. package/dist/holmes/review/test-evidence.js +77 -0
  84. package/dist/holmes/review/test-runner.js +572 -0
  85. package/dist/holmes/rtm/dataflow-taint.js +262 -0
  86. package/dist/holmes/rtm/gap-analyzer.js +27 -0
  87. package/dist/holmes/rtm/git-changes.js +72 -0
  88. package/dist/holmes/rtm/incremental.js +45 -0
  89. package/dist/holmes/rtm/localize.js +100 -0
  90. package/dist/holmes/rtm/rtm-builder.js +191 -0
  91. package/dist/holmes/rtm/rtm-check.js +89 -0
  92. package/dist/holmes/rtm/rtm-graph.js +232 -0
  93. package/dist/holmes/rtm/taint.js +92 -0
  94. package/dist/holmes/rtm/test-scope.js +336 -0
  95. package/dist/holmes/spec/approval-blockers.js +204 -0
  96. package/dist/holmes/spec/breaking-change.js +89 -0
  97. package/dist/holmes/spec/legacy-format.js +87 -0
  98. package/dist/holmes/spec/spec-digest.js +71 -0
  99. package/dist/holmes/spec/spec-parser.js +106 -0
  100. package/dist/holmes/spec/spec-store.conformance.js +118 -0
  101. package/dist/holmes/spec/spec-store.js +331 -0
  102. package/dist/holmes/spec/spec-types.js +177 -0
  103. package/dist/holmes/spec/validator.js +280 -0
  104. package/package.json +76 -0
  105. package/playbooks/adopt/PLAYBOOK.md +125 -0
  106. package/playbooks/author-slice/PLAYBOOK.md +119 -0
  107. package/playbooks/promote-slice/PLAYBOOK.md +134 -0
@@ -0,0 +1,365 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.PROVENANCE_FILE = exports.ProvenanceChain = exports.GENESIS = void 0;
37
+ exports.canonicalize = canonicalize;
38
+ exports.computeHash = computeHash;
39
+ exports.chainNext = chainNext;
40
+ exports.verifyChain = verifyChain;
41
+ exports.authorizationRef = authorizationRef;
42
+ exports.redactTarget = redactTarget;
43
+ exports.blankNonce = blankNonce;
44
+ exports.nonceDeclared = nonceDeclared;
45
+ exports.nonceFingerprint = nonceFingerprint;
46
+ exports.approvalMarkers = approvalMarkers;
47
+ exports.consumeNonceExclusively = consumeNonceExclusively;
48
+ // @implements A-SPEC-125.2
49
+ const crypto = __importStar(require("node:crypto"));
50
+ const fs = __importStar(require("node:fs"));
51
+ const path = __importStar(require("node:path"));
52
+ const ledger_lock_1 = require("./ledger-lock");
53
+ exports.GENESIS = 'GENESIS';
54
+ /** Ordered-key serialization so identical bodies always produce identical bytes (hash stability). */
55
+ function canonicalize(b) {
56
+ return JSON.stringify({
57
+ seq: b.seq, ts: b.ts, actor: b.actor, kind: b.kind, summary: b.summary,
58
+ inputs: b.inputs ?? [], rationale: b.rationale ?? '', authorization: b.authorization ?? '',
59
+ });
60
+ }
61
+ /**
62
+ * Event hash. WITHOUT a key: plain sha256 chain (tamper-EVIDENT — a full rewrite from point k still
63
+ * passes, the documented no-key limit). WITH an out-of-band key (HOLMES_LEDGER_KEY env, same channel
64
+ * discipline as HOLMES_APPROVAL): HMAC-SHA256 — a forger without the key cannot recompute ANY link,
65
+ * so even the full-rewrite attack fails verification. Key presence upgrades the guarantee from
66
+ * tamper-evident to tamper-PROOF-against-keyless-forgers; absence degrades gracefully to the chain.
67
+ */
68
+ function computeHash(prevHash, body, key) {
69
+ const input = `${prevHash}|${canonicalize(body)}`;
70
+ return key
71
+ ? crypto.createHmac('sha256', key).update(input).digest('hex')
72
+ : crypto.createHash('sha256').update(input).digest('hex');
73
+ }
74
+ /** Build the next chained event from the current chain tail (pure). */
75
+ function chainNext(chain, body, key) {
76
+ const prev = chain.length > 0 ? chain[chain.length - 1] : undefined;
77
+ const prevHash = prev ? prev.hash : exports.GENESIS;
78
+ const full = { seq: chain.length, ...body };
79
+ return { ...full, prevHash, hash: computeHash(prevHash, full, key) };
80
+ }
81
+ const KNOWN_FIELDS = new Set(['seq', 'ts', 'actor', 'kind', 'summary', 'inputs', 'rationale', 'authorization', 'prevHash', 'hash']);
82
+ /**
83
+ * Verify the whole chain: recomputed hashes, prevHash linkage, contiguous seq, AND no unknown fields.
84
+ * The unknown-field check closes the canonicalization-gap attack (adversarial review #2): canonicalize
85
+ * hashes only the known fields, so a tamperer could smuggle forged data in an EXTRA field that the
86
+ * hash ignores but a consumer might read — any field outside the schema now fails verification.
87
+ * Returns the FIRST broken seam so remediation is addressable. An empty chain is trivially valid.
88
+ */
89
+ function verifyChain(chain, key) {
90
+ // MIXED-MODE key adoption (review: enabling the key used to brick a pre-existing keyless ledger,
91
+ // and pure per-event fallback would let a keyless forger keep appending). Rules with a key:
92
+ // - a KEYLESS PREFIX (history from before the key existed) verifies with the plain hash;
93
+ // - from the FIRST keyed event onward, every event must be keyed (monotonic upgrade — a keyless
94
+ // event after a keyed one is a forgery attempt);
95
+ // - the chain must contain at least one keyed event (a fully-keyless chain under a configured
96
+ // key is a keyless-rollback rewrite — the exact attack HMAC exists to stop).
97
+ let keyedSeen = false;
98
+ for (let i = 0; i < chain.length; i++) {
99
+ const e = chain[i];
100
+ const unknown = Object.keys(e).filter((k) => !KNOWN_FIELDS.has(k));
101
+ if (unknown.length > 0)
102
+ return { ok: false, brokenAt: i, detail: `unknown field(s) outside the hashed schema: ${unknown.join(', ')} (smuggled data)` };
103
+ if (e.seq !== i)
104
+ return { ok: false, brokenAt: i, detail: `seq gap: expected ${i}, found ${e.seq}` };
105
+ const expectedPrev = i === 0 ? exports.GENESIS : chain[i - 1].hash;
106
+ if (e.prevHash !== expectedPrev)
107
+ return { ok: false, brokenAt: i, detail: 'prevHash does not link to the prior event' };
108
+ const { prevHash: _p, hash: _h, ...body } = e;
109
+ if (key) {
110
+ if (computeHash(e.prevHash, body, key) === e.hash) {
111
+ keyedSeen = true;
112
+ }
113
+ else if (!keyedSeen && computeHash(e.prevHash, body) === e.hash) {
114
+ // legacy keyless prefix — acceptable history from before key adoption
115
+ }
116
+ else {
117
+ return { ok: false, brokenAt: i, detail: keyedSeen ? 'keyless/tampered event AFTER key adoption' : 'event content does not match its hash (tampered)' };
118
+ }
119
+ }
120
+ else if (computeHash(e.prevHash, body) !== e.hash) {
121
+ return { ok: false, brokenAt: i, detail: 'event content does not match its hash (tampered)' };
122
+ }
123
+ }
124
+ if (key && chain.length > 0 && !keyedSeen) {
125
+ return { ok: false, brokenAt: chain.length - 1, detail: 'signing key is configured but the chain has NO keyed events — keyless-rollback rewrite suspected' };
126
+ }
127
+ return { ok: true };
128
+ }
129
+ /**
130
+ * Reference an approval credential WITHOUT persisting it: a short sha256 fingerprint identifies which
131
+ * token authorized an action while the ledger never stores the replayable secret (adversarial review
132
+ * #1 — the raw token in an audit file meant any local reader could harvest live gate-unlock
133
+ * credentials).
134
+ */
135
+ function authorizationRef(actor, token) {
136
+ return `${actor}:${crypto.createHash('sha256').update(token).digest('hex').slice(0, 12)}`;
137
+ }
138
+ /**
139
+ * Redact a tool's target before it enters the audit trail.
140
+ *
141
+ * The chain used to store the raw command text. Audited on this repository's own ledger: 29 records,
142
+ * 15 carrying absolute home paths and 6 matching secret-ish patterns. The hazard is structural, not
143
+ * incidental — the gate DENIES commands like `echo $HOLMES_APPROVAL` and `HOLMES_LEDGER_KEY=…`, and
144
+ * a denial is exactly what gets recorded, so the ledger is the one place guaranteed to collect the
145
+ * secrets the gate exists to protect. Committing it would publish them irreversibly.
146
+ *
147
+ * Same principle `authorizationRef` already applies to tokens: fingerprint, never store.
148
+ *
149
+ * - A FILE PATH is kept, because "which file was touched" is the audit question, but normalised to
150
+ * repo-relative; anything outside the repository becomes a placeholder rather than a host path.
151
+ * - A COMMAND is never kept. Its digest is recorded instead, so identical attempts remain
152
+ * correlatable across records without the text being recoverable. Nothing is lost for audit: the
153
+ * event's `summary` already carries the human-readable classification ("hard-hitl risk: git push
154
+ * --force rewrites remote history"), which is what a reader actually needs.
155
+ */
156
+ function redactTarget(kind, value, repoRoot) {
157
+ if (!value)
158
+ return '';
159
+ if (kind === 'command') {
160
+ return `cmd:${crypto.createHash('sha256').update(value).digest('hex').slice(0, 12)}`;
161
+ }
162
+ const root = repoRoot ?? process.cwd();
163
+ const abs = path.resolve(root, value);
164
+ const rel = path.relative(root, abs);
165
+ // `..` or an absolute result means the path escaped the repository; naming it would leak host
166
+ // layout (usernames, mount points) into an artefact intended to be shareable.
167
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
168
+ return '<outside-repo>';
169
+ return rel.split(path.sep).join('/');
170
+ }
171
+ // @implements A-SPEC-191 §11 — `nonce: ''` is not the absence of a nonce. Truthiness sent a blank
172
+ // token down the no-nonce path, so a grant that ASKED to be single-use became unlimited (the same
173
+ // degenerate fail-open as the blank `expires` and the empty scope pattern before it). A declared but
174
+ // unusable nonce is refused, never ignored.
175
+ function blankNonce(nonce) {
176
+ if (nonce === undefined || nonce === null)
177
+ return false; // 부재는 부재다
178
+ // Round-9: `nonce: 0` and `nonce: false` are DECLARED and unusable, yet truthiness sent them down
179
+ // the no-nonce path — the same degenerate fail-open the blank string closed, two values further.
180
+ // A nonce is a string that names a grant; anything else cannot be spent, and what cannot be spent
181
+ // must not silently become 'unlimited'.
182
+ return typeof nonce !== 'string' || nonce.trim() === '';
183
+ }
184
+ /** Declared at all — the operand for "does this grant claim single use?" (falsy values included). */
185
+ function nonceDeclared(nonce) {
186
+ return nonce !== undefined && nonce !== null;
187
+ }
188
+ // @implements A-SPEC-191 §11 — a spent nonce is recorded by FINGERPRINT, never by value. The old
189
+ // record filed the raw single-use token (and, in the hook, the raw command it authorized) into the
190
+ // append-only chain, so the artefact that proves a token was spent also republished the token and
191
+ // the command forever. `isNonceConsumed` still recognizes both forms, so ledgers written before this
192
+ // change keep denying replays.
193
+ function nonceFingerprint(nonce) {
194
+ return `nonce:${crypto.createHash('sha256').update(nonce).digest('hex').slice(0, 12)}`;
195
+ }
196
+ // @implements A-SPEC-191 §11 — an audit line about an approval must not become the leak the audit
197
+ // line was redacted to prevent. r8 measured it: A-SPEC-133 recommends least-privilege approvals whose
198
+ // scope pattern EQUALS the command being authorized, so writing patterns verbatim put
199
+ // `git push --force origin main` — and a planted `sk-SECRET-…` — into the same record whose target
200
+ // was hashed precisely so the ledger would not accumulate the secrets the gate protects.
201
+ //
202
+ // What an auditor needs is preserved: which KINDS were authorized, whether a wildcard (the master
203
+ // key) was among them, how many entries there were, and whether the grant carried an expiry or was
204
+ // single-use. The patterns themselves travel as fingerprints, comparable across records but not
205
+ // readable. Entries are dropped WHOLE with a count, never cut mid-entry — a truncated summary that
206
+ // silently loses the `shell=*` entry tells the auditor the opposite of what happened.
207
+ function approvalMarkers(approval) {
208
+ const SCOPE_BUDGET = 120;
209
+ let scope;
210
+ if (!approval?.scope)
211
+ scope = 'unscoped';
212
+ else if (approval.scope.length === 0)
213
+ scope = 'empty';
214
+ else {
215
+ // Round-9: a single malformed entry (a null element, a non-string pattern) threw here, and the
216
+ // caller's catch turned that into NO audit line at all for a gate that DID open. The summary is
217
+ // total: anything unprintable is named as such, because 'malformed' is itself audit-worthy.
218
+ const entries = Array.isArray(approval.scope) ? approval.scope : [];
219
+ const parts = entries.map((s) => {
220
+ const kind = s && typeof s.kind === 'string' ? s.kind : '<malformed>';
221
+ const pattern = s ? s.pattern : undefined;
222
+ if (pattern === '*')
223
+ return `${kind}=*`;
224
+ if (typeof pattern !== 'string')
225
+ return `${kind}=<malformed>`;
226
+ return `${kind}=${redactTarget('command', pattern)}`;
227
+ });
228
+ // A wildcard is the MASTER KEY: it is the entry an auditor most needs to see, so it never loses
229
+ // its place to a budget (round-9: `shell=*` disappeared behind '(+N건 생략)' because a longer
230
+ // entry happened to come first).
231
+ const ordered = [...parts.filter((p) => p.endsWith('=*')), ...parts.filter((p) => !p.endsWith('=*'))];
232
+ const kept = [];
233
+ let len = 0;
234
+ for (const p of ordered) {
235
+ if (len + p.length + 1 > SCOPE_BUDGET)
236
+ continue; // 통째로 건너뛴다 — 자르지 않는다
237
+ kept.push(p);
238
+ len += p.length + 1;
239
+ }
240
+ const omitted = ordered.length - kept.length;
241
+ scope = kept.join(',') + (omitted > 0 ? `${kept.length ? ',' : ''}(+${omitted}건 생략)` : '');
242
+ }
243
+ const expiry = approval?.expires ? `until ${String(approval.expires).slice(0, 40)}` : 'no-expiry';
244
+ // nonce 는 값이 아니라 선언 여부만 — 값은 재사용 재료다.
245
+ const nonce = nonceDeclared(approval?.nonce) ? 'single-use' : 'no-nonce';
246
+ return `approval[scope=${scope}; expiry=${expiry}; nonce=${nonce}]`;
247
+ }
248
+ class ProvenanceChain {
249
+ file;
250
+ constructor(file) {
251
+ this.file = file;
252
+ }
253
+ /** Load all events. ENOENT -> []; any other error throws (audit trail must not silently vanish). */
254
+ load() {
255
+ let raw;
256
+ try {
257
+ raw = fs.readFileSync(this.file, 'utf8');
258
+ }
259
+ catch (err) {
260
+ if (err.code === 'ENOENT')
261
+ return [];
262
+ throw err;
263
+ }
264
+ const out = [];
265
+ for (const line of raw.split('\n')) {
266
+ if (!line.trim())
267
+ continue;
268
+ try {
269
+ out.push(JSON.parse(line));
270
+ }
271
+ catch { /* torn tail line tolerated; verify() exposes real tampering */ }
272
+ }
273
+ return out;
274
+ }
275
+ /** Append a new event chained onto the CURRENT on-disk tail. Returns the appended event.
276
+ * The signing key comes from HOLMES_LEDGER_KEY (out-of-band env, never tool-settable in-session).
277
+ *
278
+ * @implements A-SPEC-141
279
+ * The read-and-write span runs under exclusion. Measured without it: two processes read the same
280
+ * tail, both wrote events claiming the same `seq`, and `verify()` then reported `ok:false` —
281
+ * tampering that never happened, 17 times out of 20. */
282
+ append(body) {
283
+ return (0, ledger_lock_1.withLedgerLock)(this.file, () => this.appendUnlocked(body), { onStaleBreak: this.recordStaleBreak });
284
+ }
285
+ /**
286
+ * Append assuming exclusion is ALREADY held. Only for callers running inside `withLedgerLock` on
287
+ * this same file — `consumeNonceExclusively` needs the read and the write in one hold, and calling
288
+ * `append()` from there would deadlock against its own budget.
289
+ */
290
+ appendInsideLock(body) {
291
+ return this.appendUnlocked(body);
292
+ }
293
+ /** The append itself, assuming exclusion is already held. Only callers INSIDE a hold may use it. */
294
+ appendUnlocked(body) {
295
+ const chain = this.load();
296
+ const evt = chainNext(chain, body, process.env.HOLMES_LEDGER_KEY || undefined);
297
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
298
+ fs.appendFileSync(this.file, JSON.stringify(evt) + '\n', 'utf8');
299
+ return evt;
300
+ }
301
+ /**
302
+ * Records a broken stale hold into the chain itself. Bound so it can be handed to the lock layer,
303
+ * which must not import this module (the lock is the lower layer).
304
+ *
305
+ * Written WITHOUT re-entering the lock: this fires from inside the acquire loop, where the hold has
306
+ * just been released and not yet re-taken, so calling `append()` here would deadlock against the
307
+ * budget. The event may therefore race a concurrent writer, which is acceptable for a diagnostic —
308
+ * a missing note is better than a wedged harness, and `verify()` still reports any resulting seam.
309
+ */
310
+ staleBreakRecorder() { return this.recordStaleBreak; }
311
+ recordStaleBreak = (info) => {
312
+ try {
313
+ this.appendUnlocked({
314
+ ts: new Date().toISOString(), actor: 'holmes-kit', kind: 'lock-broken',
315
+ summary: `broke stale ledger lock held by pid ${info.pid ?? 'unknown'} (age ${info.ageMs}ms)`,
316
+ inputs: [], rationale: 'holder exceeded the staleness threshold; recovery must not require an operator',
317
+ });
318
+ }
319
+ catch { /* a diagnostic must never block the recovery it is describing */ }
320
+ };
321
+ verify() {
322
+ return verifyChain(this.load(), process.env.HOLMES_LEDGER_KEY || undefined);
323
+ }
324
+ }
325
+ exports.ProvenanceChain = ProvenanceChain;
326
+ /** Default chain location, beside the other L4 ledgers (protected surface). */
327
+ exports.PROVENANCE_FILE = path.join('.ax', 'ledger', 'provenance.jsonl');
328
+ /**
329
+ * Spend a single-use approval, atomically. Returns `true` iff THIS caller spent it.
330
+ *
331
+ * @implements A-SPEC-141
332
+ * THIS is the operation REQ-141 exists for. The gate used to ask `isNonceConsumed(...)` and only
333
+ * afterwards append the consumption record, so two agents both observed "not consumed" and both
334
+ * proceeded: measured, a single-use approval for a destructive command was honoured TWICE in 20 of
335
+ * 20 barrier-synchronized trials. Locking the append alone would not have helped — the unguarded
336
+ * span is the READ followed by the WRITE, so both must live inside one hold.
337
+ *
338
+ * A loser writes NOTHING. Recording a consumption the caller was refused would put a second
339
+ * `nonce-consumed` event in the audit trail for an action that never happened.
340
+ *
341
+ * Throws `LedgerLockError` when exclusion cannot be obtained. The direction to fail is the CALLER's
342
+ * decision, not this function's: an authorization gate must deny, while a record-keeping append must
343
+ * not flip a decision that was already made.
344
+ */
345
+ function consumeNonceExclusively(nonce, ledgerFile, body, opts) {
346
+ const chain = new ProvenanceChain(ledgerFile);
347
+ return (0, ledger_lock_1.withLedgerLock)(ledgerFile, () => {
348
+ // @implements A-SPEC-148
349
+ // The spent-check spans EVERY replica chain, not just the file being written. Judging from one
350
+ // chain alone would let a single-use approval be spent once per replica, resurrecting REQ-141's
351
+ // double-spend along a new axis. NAMED LIMIT: within one machine the lock makes this exact;
352
+ // across machines there is no shared filesystem to lock, so two machines racing the same nonce
353
+ // can both pass and the duplicate is DETECTED afterwards in the merged ledger rather than
354
+ // prevented. Preventing it needs a shared authority, which is out of this slice's scope.
355
+ const { FileLedgerStore } = require('./ledger-store');
356
+ const spent = new FileLedgerStore(path.dirname(ledgerFile)).isNonceConsumed(nonce);
357
+ if (spent)
358
+ return false;
359
+ chain.appendInsideLock(body);
360
+ return true;
361
+ // Default the stale-break recorder to the chain's own: breaking a lock silently is forbidden
362
+ // here for exactly the same reason it is in `append()`, and leaving it undefined made this
363
+ // path — the SECURITY path — the one place a lock could be stolen without a trace.
364
+ }, { ...opts, onStaleBreak: opts?.onStaleBreak ?? chain.staleBreakRecorder() });
365
+ }
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.provenanceSchemaOf = provenanceSchemaOf;
37
+ // @implements A-SPEC-147
38
+ const path = __importStar(require("node:path"));
39
+ const V2 = /^provenance\.[^.]+\.jsonl$/;
40
+ function provenanceSchemaOf(fileName) {
41
+ // A path may arrive instead of a bare name; the schema is a property of the file, not its location.
42
+ const base = path.basename(fileName || '');
43
+ // An unrecognised name is read as the OLDER schema on purpose. Guessing the newer format for an
44
+ // unknown file would apply replica-local seq rules to a global chain and report false gaps —
45
+ // conservative here means "assume the format that has been around longest".
46
+ return V2.test(base) ? 2 : 1;
47
+ }
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.sanitizeReplicaId = sanitizeReplicaId;
37
+ exports.resolveReplicaId = resolveReplicaId;
38
+ // @implements A-SPEC-148
39
+ const fs = __importStar(require("node:fs"));
40
+ const os = __importStar(require("node:os"));
41
+ const path = __importStar(require("node:path"));
42
+ /**
43
+ * The identity of the machine writing this ledger, used as the chain's filename segment.
44
+ *
45
+ * It is a LOCAL fact: stored under `.ax/state/` (already gitignored) so it stays stable across runs
46
+ * on one machine and never travels with the repository. It must be recognisable to a human reading
47
+ * `provenance.<id>.jsonl`, and must never carry a secret or an absolute path — ADR-012 established
48
+ * that discipline for the ledger's contents and it applies to its name too.
49
+ */
50
+ const MAX = 32;
51
+ const STORE = path.join('.ax', 'state', 'replica-id');
52
+ /**
53
+ * Reduce a raw name to something safe to embed in `provenance.<id>.jsonl`.
54
+ *
55
+ * DOTS ARE THE POINT. macOS hostnames end in `.local`, and the schema rule reads
56
+ * `provenance.<segment>.jsonl` with `<segment>` containing no dot — an unsanitised
57
+ * `SungNamui-MacStudio.local` would produce `provenance.SungNamui-MacStudio.local.jsonl`, which the
58
+ * schema check would not recognise as a replica chain at all. Two rules disagreeing about the same
59
+ * file is worse than either rule being wrong.
60
+ */
61
+ function sanitizeReplicaId(raw) {
62
+ const cleaned = (raw || '').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, MAX);
63
+ // Everything stripped (an all-punctuation name, or an empty one) still needs a usable filename.
64
+ return cleaned === '' ? 'local' : cleaned;
65
+ }
66
+ /**
67
+ * Resolve this machine's replica id: explicit env, then the stored value, then the hostname.
68
+ *
69
+ * The env override exists for CI and for tests, where the hostname is neither stable nor meaningful.
70
+ * Writing the generated value down is what makes the identity stable — an id derived fresh each run
71
+ * would scatter one machine's history across several chains.
72
+ */
73
+ function resolveReplicaId(root, env = process.env, opts = {}) {
74
+ const persist = opts.persist !== false;
75
+ const fromEnv = env.HOLMES_REPLICA_ID;
76
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '')
77
+ return sanitizeReplicaId(fromEnv);
78
+ const file = path.join(root, STORE);
79
+ try {
80
+ const stored = fs.readFileSync(file, 'utf8').trim();
81
+ if (stored !== '')
82
+ return sanitizeReplicaId(stored);
83
+ }
84
+ catch { /* not yet written */ }
85
+ let host = 'local';
86
+ try {
87
+ host = os.hostname();
88
+ }
89
+ catch { /* keep the fallback */ }
90
+ const id = sanitizeReplicaId(host);
91
+ // Best-effort persistence: failing to record the id must not stop the caller from writing to the
92
+ // ledger, it only means the next run re-derives the same value from the same hostname.
93
+ // @implements A-SPEC-150
94
+ // Persisting is a WRITE. Asking "who am I" is a read, and a read must leave no trace — the ledger
95
+ // port's conformance requires it, and doing it here is why merely constructing a store used to
96
+ // create `.ax/state/`. Callers that are about to append pass `persist: true` (the default);
97
+ // read-only callers pass false and get the same value without the side effect.
98
+ if (persist) {
99
+ try {
100
+ fs.mkdirSync(path.dirname(file), { recursive: true });
101
+ fs.writeFileSync(file, `${id}\n`, 'utf8');
102
+ }
103
+ catch { /* ignore */ }
104
+ }
105
+ return id;
106
+ }
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.loadRolePolicy = loadRolePolicy;
37
+ exports.rolePolicyStatus = rolePolicyStatus;
38
+ exports.roleCheck = roleCheck;
39
+ // @implements A-SPEC-152
40
+ const fs = __importStar(require("node:fs"));
41
+ const path = __importStar(require("node:path"));
42
+ const yaml = __importStar(require("js-yaml"));
43
+ const ROLES_DIR = path.join('.ax', 'roles');
44
+ /** `null` when the project has not opted in. A PRESENT-but-broken policy is never null — see below. */
45
+ function loadRolePolicy(root) {
46
+ const dir = path.join(root, ROLES_DIR);
47
+ let files;
48
+ try {
49
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')).sort();
50
+ }
51
+ catch {
52
+ return null; // no directory: not opted in
53
+ }
54
+ if (files.length === 0)
55
+ return null; // empty directory: not opted in either
56
+ const roles = {};
57
+ let dflt;
58
+ for (const f of files) {
59
+ try {
60
+ const doc = yaml.load(fs.readFileSync(path.join(dir, f), 'utf8'));
61
+ // FIRST declaration wins, by filename order. If the last one won, dropping in a new file would
62
+ // silently reverse a decision another file already made — and nothing would report the change.
63
+ if (dflt === undefined && typeof doc?.default === 'string' && doc.default.trim() !== '') {
64
+ dflt = doc.default.trim();
65
+ }
66
+ const declared = doc && typeof doc === 'object' ? doc.roles : undefined;
67
+ if (!declared || typeof declared !== 'object')
68
+ continue;
69
+ for (const [name, def] of Object.entries(declared)) {
70
+ const allow = def?.allow;
71
+ roles[name] = { allow: Array.isArray(allow) ? allow.filter((a) => typeof a === 'string') : [] };
72
+ }
73
+ }
74
+ catch {
75
+ // A corrupt file contributes NO roles — deliberately not `null`. Returning "no policy" would
76
+ // turn a broken governance file into "roles are off", silently downgrading enforcement, which
77
+ // is the failure direction REQ-144 forbids. A policy that grants nothing denies everything.
78
+ continue;
79
+ }
80
+ }
81
+ return dflt === undefined ? { roles } : { roles, default: dflt };
82
+ }
83
+ /**
84
+ * Describes the project's role configuration WITHOUT judging it. Not having roles on is not a
85
+ * defect; reporting it as one would teach operators to ignore doctor, which costs more than it saves.
86
+ */
87
+ function rolePolicyStatus(root) {
88
+ const dir = path.join(root, ROLES_DIR);
89
+ if (!fs.existsSync(dir))
90
+ return { state: 'absent', roles: [] };
91
+ const policy = loadRolePolicy(root);
92
+ if (policy === null)
93
+ return { state: 'inactive', roles: [] };
94
+ return { state: 'active', roles: Object.keys(policy.roles).sort(), default: policy.default };
95
+ }
96
+ /**
97
+ * Whether this identity may perform this action. Returns the refusal reason, or `null` to allow.
98
+ *
99
+ * Fail-closed once a policy exists: an absent role, an unregistered role, and a role without the
100
+ * action are all refusals. Before a policy exists there is no opinion at all.
101
+ */
102
+ function roleCheck(action, identity, policy) {
103
+ if (policy === null)
104
+ return null;
105
+ const known = Object.keys(policy.roles);
106
+ // The default is resolved HERE, not in the identity provider. Identity is observed fact — what the
107
+ // operator actually claimed — while a default is the policy's interpretation of silence. Folding
108
+ // one into the other would leave a server-backed provider unable to tell a role it verified from
109
+ // a role that was merely assumed, which is exactly what `proven` exists to distinguish.
110
+ let effective = identity.role;
111
+ if (!effective && policy.default !== undefined) {
112
+ if (!policy.roles[policy.default]) {
113
+ // A typo'd default must be louder than plain absence: it reads as "roles are configured" while
114
+ // granting nothing to no one, so the operator believes an enforcement exists that does not.
115
+ return `[Holmes-Kit] 역할 정책의 기본 역할 "${policy.default}"이(가) 정의되어 있지 않습니다 — ${action}을(를) 허용할 수 없습니다.`
116
+ + ` .ax/roles/의 default를 고치거나 그 역할을 정의하세요. 등록된 역할: ${known.join(', ') || '(없음)'}`;
117
+ }
118
+ effective = policy.default;
119
+ }
120
+ if (!effective) {
121
+ return `[Holmes-Kit] 이 프로젝트는 역할 정책을 사용합니다 — 역할이 지정되지 않아 ${action}을(를) 허용할 수 없습니다.`
122
+ + ` 에이전트를 기동할 때 HOLMES_ROLE을 설정하세요(대역외 채널), 또는 .ax/roles/에 default를 선언하세요.`
123
+ + ` 등록된 역할: ${known.join(', ') || '(없음)'}`;
124
+ }
125
+ // Exact match on purpose: a silent case-fold would make `Developer` and `developer` the same
126
+ // principal by accident, which is not a decision a policy file ever expressed.
127
+ const def = policy.roles[effective];
128
+ if (!def) {
129
+ return `[Holmes-Kit] 역할 "${effective}"이(가) 역할 정책에 없습니다 — ${action}을(를) 허용할 수 없습니다.`
130
+ + ` 등록된 역할: ${known.join(', ') || '(없음)'}`;
131
+ }
132
+ if (!def.allow.includes(action)) {
133
+ return `[Holmes-Kit] 역할 "${effective}"은(는) ${action}을(를) 수행할 수 없습니다.`
134
+ + ` 허용된 동작: ${def.allow.join(', ') || '(없음)'}`;
135
+ }
136
+ return null;
137
+ }