@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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/holmes-hook-antigravity.js +31 -0
- package/bin/holmes-kit.js +23 -0
- package/bin/holmes-mcp.js +34 -0
- package/bin/holmes-stop-antigravity.js +29 -0
- package/dist/.build-id +1 -0
- package/dist/holmes/cli/agents.js +168 -0
- package/dist/holmes/cli/doctor.js +625 -0
- package/dist/holmes/cli/gitignore-merge.js +84 -0
- package/dist/holmes/cli/governed-precondition.js +157 -0
- package/dist/holmes/cli/index.js +384 -0
- package/dist/holmes/cli/init.js +462 -0
- package/dist/holmes/cli/playbook-skills.js +711 -0
- package/dist/holmes/cli/roles-readme.js +134 -0
- package/dist/holmes/cli/settings-merge.js +122 -0
- package/dist/holmes/config/config.js +70 -0
- package/dist/holmes/context/bundler.js +114 -0
- package/dist/holmes/context/render.js +29 -0
- package/dist/holmes/context/tiers.js +110 -0
- package/dist/holmes/context/tokens.js +8 -0
- package/dist/holmes/cpg/cpg-scanner.js +213 -0
- package/dist/holmes/cpg/hash-cache.js +86 -0
- package/dist/holmes/cpg/language-parser-walk.js +917 -0
- package/dist/holmes/cpg/language-parser-worker.js +81 -0
- package/dist/holmes/cpg/language-parser.js +234 -0
- package/dist/holmes/cpg/scan-cache.js +108 -0
- package/dist/holmes/cpg/source-path.js +44 -0
- package/dist/holmes/cpg/test-files.js +84 -0
- package/dist/holmes/governance/constitution-debt.js +73 -0
- package/dist/holmes/governance/constitution-report.js +25 -0
- package/dist/holmes/governance/constitution.js +129 -0
- package/dist/holmes/governance/identity.js +30 -0
- package/dist/holmes/governance/ledger-lock.js +165 -0
- package/dist/holmes/governance/ledger-store.conformance.js +90 -0
- package/dist/holmes/governance/ledger-store.js +106 -0
- package/dist/holmes/governance/progress-ledger.js +83 -0
- package/dist/holmes/governance/provenance-chain.js +365 -0
- package/dist/holmes/governance/provenance-ledger.js +0 -0
- package/dist/holmes/governance/provenance-schema.js +47 -0
- package/dist/holmes/governance/replica-id.js +106 -0
- package/dist/holmes/governance/role-policy.js +137 -0
- package/dist/holmes/governance/trust-score.js +43 -0
- package/dist/holmes/guardrail/anchors.js +31 -0
- package/dist/holmes/guardrail/blind-spots.js +38 -0
- package/dist/holmes/guardrail/decision-ledger.js +107 -0
- package/dist/holmes/guardrail/executable-artifact.js +129 -0
- package/dist/holmes/guardrail/governance-history.js +101 -0
- package/dist/holmes/guardrail/phase.js +169 -0
- package/dist/holmes/guardrail/risk-classifier.js +450 -0
- package/dist/holmes/guardrail/risk-gate.js +160 -0
- package/dist/holmes/guardrail/risk-types.js +6 -0
- package/dist/holmes/guardrail/tspec-state.js +392 -0
- package/dist/holmes/guardrail/write-target.js +224 -0
- package/dist/holmes/hooks/adapters/antigravity.js +194 -0
- package/dist/holmes/hooks/pre-tool-use.js +1262 -0
- package/dist/holmes/hooks/stop.js +416 -0
- package/dist/holmes/mcp/basis.js +162 -0
- package/dist/holmes/mcp/handlers.js +1831 -0
- package/dist/holmes/mcp/server.js +71 -0
- package/dist/holmes/mcp/stdio-client.js +165 -0
- package/dist/holmes/mcp/supervisor.js +178 -0
- package/dist/holmes/mcp/tool-schemas.js +394 -0
- package/dist/holmes/mcp/validate-args.js +281 -0
- package/dist/holmes/messages/registry.js +50 -0
- package/dist/holmes/project/baseline.js +210 -0
- package/dist/holmes/project/change-source.js +233 -0
- package/dist/holmes/project/ignore.js +145 -0
- package/dist/holmes/project/root.js +113 -0
- package/dist/holmes/reverse/anchor.js +162 -0
- package/dist/holmes/reverse/cluster.js +187 -0
- package/dist/holmes/reverse/draft.js +151 -0
- package/dist/holmes/reverse/dynamic-wiring.js +47 -0
- package/dist/holmes/reverse/scan.js +194 -0
- package/dist/holmes/reverse/surface.js +154 -0
- package/dist/holmes/reverse/test-map.js +263 -0
- package/dist/holmes/review/coverage.js +33 -0
- package/dist/holmes/review/findings.js +123 -0
- package/dist/holmes/review/package.js +40 -0
- package/dist/holmes/review/review-targets.js +92 -0
- package/dist/holmes/review/scope.js +57 -0
- package/dist/holmes/review/test-evidence.js +77 -0
- package/dist/holmes/review/test-runner.js +572 -0
- package/dist/holmes/rtm/dataflow-taint.js +262 -0
- package/dist/holmes/rtm/gap-analyzer.js +27 -0
- package/dist/holmes/rtm/git-changes.js +72 -0
- package/dist/holmes/rtm/incremental.js +45 -0
- package/dist/holmes/rtm/localize.js +100 -0
- package/dist/holmes/rtm/rtm-builder.js +191 -0
- package/dist/holmes/rtm/rtm-check.js +89 -0
- package/dist/holmes/rtm/rtm-graph.js +232 -0
- package/dist/holmes/rtm/taint.js +92 -0
- package/dist/holmes/rtm/test-scope.js +336 -0
- package/dist/holmes/spec/approval-blockers.js +204 -0
- package/dist/holmes/spec/breaking-change.js +89 -0
- package/dist/holmes/spec/legacy-format.js +87 -0
- package/dist/holmes/spec/spec-digest.js +71 -0
- package/dist/holmes/spec/spec-parser.js +106 -0
- package/dist/holmes/spec/spec-store.conformance.js +118 -0
- package/dist/holmes/spec/spec-store.js +331 -0
- package/dist/holmes/spec/spec-types.js +177 -0
- package/dist/holmes/spec/validator.js +280 -0
- package/package.json +76 -0
- package/playbooks/adopt/PLAYBOOK.md +125 -0
- package/playbooks/author-slice/PLAYBOOK.md +119 -0
- 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
|
+
}
|
|
Binary file
|
|
@@ -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
|
+
}
|