@haposoft/cafekit 0.16.0-rc.1 → 0.16.0-rc.3
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/bin/__tests__/codex-hooks.test.js +355 -12
- package/bin/__tests__/codex-native.test.js +1 -1
- package/bin/__tests__/develop-contract.test.js +1374 -78
- package/bin/__tests__/installer-safety.test.js +43 -29
- package/bin/__tests__/opencode-spec-gate.test.js +791 -0
- package/bin/__tests__/package-inventory.test.js +157 -0
- package/bin/__tests__/validator-grounding.test.js +707 -2
- package/package.json +1 -1
- package/src/claude/agents/code-auditor.md +8 -1
- package/src/claude/agents/spec-maker.md +107 -200
- package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
- package/src/claude/hooks/completion-authority-check.cjs +307 -0
- package/src/claude/hooks/completion-authority-state.cjs +525 -0
- package/src/claude/hooks/completion-authority.cjs +148 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +173 -61
- package/src/claude/hooks/spec-state.cjs +104 -45
- package/src/claude/migration-manifest.json +6 -1
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-resolver.cjs +450 -0
- package/src/claude/scripts/spec-scaffold.cjs +716 -41
- package/src/claude/scripts/validate-spec-output.cjs +355 -53
- package/src/claude/scripts/workflow-policy.cjs +1529 -162
- package/src/claude/settings/settings.json +8 -0
- package/src/claude/skills/backend-development/references/backend-security.md +52 -0
- package/src/claude/skills/code-review/SKILL.md +85 -66
- package/src/claude/skills/develop/SKILL.md +153 -285
- package/src/claude/skills/develop/references/quality-gate.md +112 -124
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +170 -381
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
- package/src/claude/skills/specs/templates/spec-state.json +23 -2
- package/src/claude/skills/specs/templates/task.md +20 -0
- package/src/claude/skills/sync/SKILL.md +15 -8
- package/src/claude/skills/sync/references/sync-protocols.md +4 -4
- package/src/claude/skills/test/SKILL.md +141 -167
- package/src/codex/hooks/completion-authority-check.cjs +260 -0
- package/src/codex/hooks/completion-authority-state.cjs +525 -0
- package/src/codex/hooks/completion-authority.cjs +100 -0
- package/src/codex/hooks/lib/hook-context.cjs +42 -2
- package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
- package/src/codex/hooks/lib/spec-utils.cjs +53 -27
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +132 -20
- package/src/codex/hooks/spec-state.cjs +56 -10
- package/src/codex/hooks.json +10 -0
- package/src/opencode/AGENTS.md +4 -2
- package/src/opencode/plugins/spec-gate.ts +296 -122
- package/src/opencode/plugins/spec-state.ts +183 -116
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const PREFIX = 'APPROVE CAFEKIT COMPLETION ';
|
|
9
|
+
const NONCE_RE = /^[a-f0-9]{24}$/;
|
|
10
|
+
const HASH_RE = /^[a-f0-9]{64}$/;
|
|
11
|
+
const TTL_MS = 5 * 60 * 1000;
|
|
12
|
+
const KEY_BYTES = 32;
|
|
13
|
+
const SCHEMA_VERSION = 2;
|
|
14
|
+
const EPHEMERAL_FILE_RE = /^(pending|grant)-([a-f0-9]{24})\.json$/;
|
|
15
|
+
const BASELINE_FILE_RE = /^baseline-([a-f0-9]{64})\.json$/;
|
|
16
|
+
const FLOOR_FILE = 'policy-floor.json';
|
|
17
|
+
|
|
18
|
+
function plain(value) {
|
|
19
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function canonicalize(value) {
|
|
23
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
24
|
+
if (!value || typeof value !== 'object') return value;
|
|
25
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stableStringify(value) {
|
|
29
|
+
return JSON.stringify(canonicalize(value));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function digest(value) {
|
|
33
|
+
return crypto.createHash('sha256').update(stableStringify(value)).digest('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function secureMkdir(dir) {
|
|
37
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
38
|
+
if (process.platform !== 'win32') fs.chmodSync(dir, 0o700);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function homeDirectory() {
|
|
42
|
+
const requested = process.platform === 'win32'
|
|
43
|
+
? (process.env.USERPROFILE || (process.env.HOMEDRIVE && process.env.HOMEPATH
|
|
44
|
+
? `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
|
|
45
|
+
: null))
|
|
46
|
+
: process.env.HOME;
|
|
47
|
+
const home = requested || os.homedir();
|
|
48
|
+
if (typeof home !== 'string' || !home.trim()) throw new Error('user home directory is unavailable');
|
|
49
|
+
return path.resolve(home);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function userStateRoot() {
|
|
53
|
+
return path.join(homeDirectory(), '.cafekit', 'completion-authority');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function keyPath() {
|
|
57
|
+
return path.join(userStateRoot(), 'hmac.key');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function canonicalProjectRoot(projectRoot) {
|
|
61
|
+
if (typeof projectRoot !== 'string' || !projectRoot.trim()) throw new Error('project root is required');
|
|
62
|
+
return fs.realpathSync(path.resolve(projectRoot));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function projectNamespace(projectRoot) {
|
|
66
|
+
const canonical = canonicalProjectRoot(projectRoot);
|
|
67
|
+
const projectHash = digest({ namespace: 'cafekit-completion-authority-v2', project_root: canonical });
|
|
68
|
+
return { canonical, projectHash };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stateDir(projectRoot) {
|
|
72
|
+
return path.join(userStateRoot(), 'projects', projectNamespace(projectRoot).projectHash);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function inside(root, target) {
|
|
76
|
+
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
77
|
+
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeIdentity(identity, { allowMissingSpec = false } = {}) {
|
|
81
|
+
if (!plain(identity)) throw new Error('baseline identity must be an object');
|
|
82
|
+
const projectRoot = canonicalProjectRoot(identity.project_root);
|
|
83
|
+
const featureName = identity.feature_name;
|
|
84
|
+
if (typeof featureName !== 'string' || !featureName.trim() || featureName.includes('/') || featureName.includes('\\') || featureName === '.' || featureName === '..') {
|
|
85
|
+
throw new Error('baseline feature identity is malformed');
|
|
86
|
+
}
|
|
87
|
+
if (typeof identity.spec_file !== 'string' || !identity.spec_file.trim()) throw new Error('baseline spec identity is malformed');
|
|
88
|
+
const requestedSpec = path.resolve(projectRoot, identity.spec_file);
|
|
89
|
+
let specFile = requestedSpec;
|
|
90
|
+
try {
|
|
91
|
+
specFile = fs.realpathSync(requestedSpec);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (!allowMissingSpec || !['ENOENT', 'ENOTDIR'].includes(error.code)) throw new Error(`baseline spec identity cannot be canonicalized (${error.code || error.message})`);
|
|
94
|
+
}
|
|
95
|
+
if (!inside(projectRoot, specFile) || specFile === projectRoot || path.basename(specFile) !== 'spec.json') {
|
|
96
|
+
throw new Error('baseline spec identity escapes the project');
|
|
97
|
+
}
|
|
98
|
+
if (path.basename(path.dirname(specFile)) !== featureName) throw new Error('baseline feature and spec identities disagree');
|
|
99
|
+
return { project_root: projectRoot, spec_file: specFile, feature_name: featureName };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function identityDigest(identity) {
|
|
103
|
+
return digest({
|
|
104
|
+
namespace: 'cafekit-completion-baseline-v1',
|
|
105
|
+
project_root: identity.project_root,
|
|
106
|
+
spec_file: identity.spec_file,
|
|
107
|
+
feature_name: identity.feature_name,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function baselinePath(projectRoot, identity) {
|
|
112
|
+
const normalized = normalizeIdentity(identity, { allowMissingSpec: true });
|
|
113
|
+
const namespace = projectNamespace(projectRoot);
|
|
114
|
+
if (normalized.project_root !== namespace.canonical) throw new Error('baseline project identity does not match state namespace');
|
|
115
|
+
return path.join(stateDir(namespace.canonical), `baseline-${identityDigest(normalized)}.json`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function atomicWrite(file, value) {
|
|
119
|
+
const dir = path.dirname(file);
|
|
120
|
+
secureMkdir(dir);
|
|
121
|
+
const temp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
122
|
+
fs.writeFileSync(temp, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
123
|
+
if (process.platform !== 'win32') fs.chmodSync(temp, 0o600);
|
|
124
|
+
fs.renameSync(temp, file);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readJson(file) {
|
|
128
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function recordFiles(projectRoot) {
|
|
132
|
+
const dir = stateDir(projectRoot);
|
|
133
|
+
try {
|
|
134
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
135
|
+
.filter((entry) => entry.isFile())
|
|
136
|
+
.map((entry) => entry.name);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error.code === 'ENOENT') return [];
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function anyAuthorityRecords() {
|
|
144
|
+
const projects = path.join(userStateRoot(), 'projects');
|
|
145
|
+
let entries;
|
|
146
|
+
try { entries = fs.readdirSync(projects, { withFileTypes: true }); } catch (error) {
|
|
147
|
+
if (error.code === 'ENOENT') return false;
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
if (entry.isSymbolicLink()) return true;
|
|
152
|
+
if (!entry.isDirectory()) return true;
|
|
153
|
+
if (fs.readdirSync(path.join(projects, entry.name)).length > 0) return true;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readKey() {
|
|
159
|
+
const file = keyPath();
|
|
160
|
+
let stat;
|
|
161
|
+
try { stat = fs.lstatSync(file); } catch (error) {
|
|
162
|
+
if (error.code === 'ENOENT') return { key: null, missing: true };
|
|
163
|
+
throw new Error(`completion authority key cannot be inspected (${error.code || error.message})`);
|
|
164
|
+
}
|
|
165
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('completion authority key is not a regular file');
|
|
166
|
+
if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) throw new Error('completion authority key permissions are too broad');
|
|
167
|
+
let key;
|
|
168
|
+
try { key = fs.readFileSync(file); } catch (error) { throw new Error(`completion authority key cannot be read (${error.message})`); }
|
|
169
|
+
if (key.length !== KEY_BYTES) throw new Error('completion authority key is malformed');
|
|
170
|
+
return { key, missing: false };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function ensureKey() {
|
|
174
|
+
secureMkdir(userStateRoot());
|
|
175
|
+
secureMkdir(path.join(userStateRoot(), 'projects'));
|
|
176
|
+
const current = readKey();
|
|
177
|
+
if (!current.missing) return current.key;
|
|
178
|
+
if (anyAuthorityRecords()) throw new Error('completion authority key is missing while authority records exist');
|
|
179
|
+
const key = crypto.randomBytes(KEY_BYTES);
|
|
180
|
+
try {
|
|
181
|
+
fs.writeFileSync(keyPath(), key, { flag: 'wx', mode: 0o600 });
|
|
182
|
+
if (process.platform !== 'win32') fs.chmodSync(keyPath(), 0o600);
|
|
183
|
+
return key;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (error.code === 'EEXIST') {
|
|
186
|
+
const raced = readKey();
|
|
187
|
+
if (!raced.missing) return raced.key;
|
|
188
|
+
}
|
|
189
|
+
throw new Error(`completion authority key cannot be created (${error.message})`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function unsignedRecord(record) {
|
|
194
|
+
const unsigned = { ...record };
|
|
195
|
+
delete unsigned.mac;
|
|
196
|
+
delete unsigned.file;
|
|
197
|
+
return unsigned;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function recordMac(record, key) {
|
|
201
|
+
return crypto.createHmac('sha256', key).update(stableStringify(unsignedRecord(record))).digest('hex');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function macMatches(record, key) {
|
|
205
|
+
if (typeof record?.mac !== 'string' || !/^[a-f0-9]{64}$/.test(record.mac)) return false;
|
|
206
|
+
const actual = Buffer.from(record.mac, 'hex');
|
|
207
|
+
const expected = Buffer.from(recordMac(record, key), 'hex');
|
|
208
|
+
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function exactKeys(value, expected) {
|
|
212
|
+
return JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...expected].sort());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function validTimestamp(value) {
|
|
216
|
+
return typeof value === 'string' && value.trim() === value && !Number.isNaN(Date.parse(value));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function validateEphemeral(record, kind, nonce, now, key) {
|
|
220
|
+
const expected = kind === 'pending'
|
|
221
|
+
? ['schema_version', 'kind', 'nonce', 'binding', 'issued_at', 'expires_at', 'mac']
|
|
222
|
+
: ['schema_version', 'kind', 'nonce', 'binding', 'issued_at', 'expires_at', 'approved_at', 'mac'];
|
|
223
|
+
if (!plain(record) || !exactKeys(record, expected)) return { ok: false, reason: 'malformed authority record' };
|
|
224
|
+
if (!macMatches(record, key)) return { ok: false, reason: 'authority record MAC is invalid' };
|
|
225
|
+
if (record.schema_version !== SCHEMA_VERSION || record.kind !== kind || record.nonce !== nonce || !NONCE_RE.test(nonce)) return { ok: false, reason: 'authority record identity is invalid' };
|
|
226
|
+
if (!plain(record.binding) || typeof record.binding.session_id !== 'string' || !record.binding.session_id.trim()) return { ok: false, reason: 'authority record binding is invalid' };
|
|
227
|
+
if (!validTimestamp(record.issued_at) || !Number.isFinite(record.expires_at) || record.expires_at <= Date.parse(record.issued_at)) return { ok: false, reason: 'authority record timestamps are invalid' };
|
|
228
|
+
if (kind === 'grant' && !validTimestamp(record.approved_at)) return { ok: false, reason: 'authority grant approval timestamp is invalid' };
|
|
229
|
+
if (record.expires_at <= now) return { ok: false, expired: true, reason: 'authority record expired' };
|
|
230
|
+
return { ok: true };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function validateBaseline(record, filename, projectRoot, key) {
|
|
234
|
+
const expected = ['schema_version', 'kind', 'identity', 'policy', 'issued_at', 'updated_at', 'mac'];
|
|
235
|
+
if (!plain(record) || !exactKeys(record, expected)) return { ok: false, reason: 'malformed policy baseline' };
|
|
236
|
+
if (!macMatches(record, key)) return { ok: false, reason: 'policy baseline MAC is invalid' };
|
|
237
|
+
if (record.schema_version !== SCHEMA_VERSION || record.kind !== 'baseline' || !validTimestamp(record.issued_at) || !validTimestamp(record.updated_at)) return { ok: false, reason: 'policy baseline identity or timestamps are invalid' };
|
|
238
|
+
let identity;
|
|
239
|
+
try { identity = normalizeIdentity(record.identity, { allowMissingSpec: true }); } catch (error) { return { ok: false, reason: error.message }; }
|
|
240
|
+
if (filename !== `baseline-${identityDigest(identity)}.json` || identity.project_root !== canonicalProjectRoot(projectRoot)) return { ok: false, reason: 'policy baseline namespace is invalid' };
|
|
241
|
+
if (!plain(record.policy)) return { ok: false, reason: 'policy baseline policy is malformed' };
|
|
242
|
+
return { ok: true, identity };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function validatePolicyFloor(record, filename, key) {
|
|
246
|
+
const expected = ['schema_version', 'kind', 'policy', 'issued_at', 'updated_at', 'mac'];
|
|
247
|
+
if (!plain(record) || !exactKeys(record, expected)) return { ok: false, reason: 'malformed project policy floor' };
|
|
248
|
+
if (!macMatches(record, key)) return { ok: false, reason: 'project policy floor MAC is invalid' };
|
|
249
|
+
if (filename !== FLOOR_FILE || record.schema_version !== SCHEMA_VERSION || record.kind !== 'policy-floor' || !validTimestamp(record.issued_at) || !validTimestamp(record.updated_at)) {
|
|
250
|
+
return { ok: false, reason: 'project policy floor identity or timestamps are invalid' };
|
|
251
|
+
}
|
|
252
|
+
if (!plain(record.policy)) return { ok: false, reason: 'project policy floor policy is malformed' };
|
|
253
|
+
return { ok: true };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function readState(projectRoot, now = Date.now()) {
|
|
257
|
+
const state = {
|
|
258
|
+
pending: null,
|
|
259
|
+
grant: null,
|
|
260
|
+
baselines: [],
|
|
261
|
+
floor: null,
|
|
262
|
+
floorMissing: false,
|
|
263
|
+
malformed: false,
|
|
264
|
+
reason: null,
|
|
265
|
+
keyMissing: false,
|
|
266
|
+
keyError: null,
|
|
267
|
+
};
|
|
268
|
+
let files;
|
|
269
|
+
try { files = recordFiles(projectRoot); } catch (error) {
|
|
270
|
+
state.malformed = true;
|
|
271
|
+
state.reason = error.message;
|
|
272
|
+
state.keyError = error;
|
|
273
|
+
return state;
|
|
274
|
+
}
|
|
275
|
+
let keyInfo;
|
|
276
|
+
try { keyInfo = readKey(); } catch (error) {
|
|
277
|
+
state.malformed = true;
|
|
278
|
+
state.reason = error.message;
|
|
279
|
+
state.keyError = error;
|
|
280
|
+
return state;
|
|
281
|
+
}
|
|
282
|
+
if (keyInfo.missing) {
|
|
283
|
+
state.keyMissing = true;
|
|
284
|
+
if (files.length > 0) {
|
|
285
|
+
state.malformed = true;
|
|
286
|
+
state.reason = 'completion authority key is missing while authority records exist';
|
|
287
|
+
}
|
|
288
|
+
return state;
|
|
289
|
+
}
|
|
290
|
+
const key = keyInfo.key;
|
|
291
|
+
for (const name of files) {
|
|
292
|
+
const ephemeral = name.match(EPHEMERAL_FILE_RE);
|
|
293
|
+
const baseline = name.match(BASELINE_FILE_RE);
|
|
294
|
+
const floor = name === FLOOR_FILE;
|
|
295
|
+
if (!ephemeral && !baseline && !floor) {
|
|
296
|
+
if (name.endsWith('.json')) {
|
|
297
|
+
state.malformed = true;
|
|
298
|
+
state.reason ||= `unknown completion authority record ${name}`;
|
|
299
|
+
}
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const file = path.join(stateDir(projectRoot), name);
|
|
303
|
+
const record = readJson(file);
|
|
304
|
+
const result = ephemeral
|
|
305
|
+
? validateEphemeral(record, ephemeral[1], ephemeral[2], now, key)
|
|
306
|
+
: baseline
|
|
307
|
+
? validateBaseline(record, name, projectRoot, key)
|
|
308
|
+
: validatePolicyFloor(record, name, key);
|
|
309
|
+
if (!result.ok) {
|
|
310
|
+
if (result.expired) {
|
|
311
|
+
try { fs.unlinkSync(file); } catch { /* expired state is harmless if cleanup races */ }
|
|
312
|
+
} else {
|
|
313
|
+
state.malformed = true;
|
|
314
|
+
state.reason ||= result.reason;
|
|
315
|
+
}
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const accepted = { ...record, file };
|
|
319
|
+
if (ephemeral) {
|
|
320
|
+
if (ephemeral[1] === 'pending') {
|
|
321
|
+
if (state.pending) {
|
|
322
|
+
state.malformed = true;
|
|
323
|
+
state.reason ||= 'multiple pending completion authority records exist';
|
|
324
|
+
} else state.pending = accepted;
|
|
325
|
+
} else if (state.grant) {
|
|
326
|
+
state.malformed = true;
|
|
327
|
+
state.reason ||= 'multiple grant completion authority records exist';
|
|
328
|
+
} else state.grant = accepted;
|
|
329
|
+
} else if (baseline) {
|
|
330
|
+
if (state.baselines.some((entry) => stableStringify(entry.identity) === stableStringify(accepted.identity))) {
|
|
331
|
+
state.malformed = true;
|
|
332
|
+
state.reason ||= 'duplicate policy baselines exist';
|
|
333
|
+
} else state.baselines.push(accepted);
|
|
334
|
+
} else if (state.floor) {
|
|
335
|
+
state.malformed = true;
|
|
336
|
+
state.reason ||= 'multiple project policy floors exist';
|
|
337
|
+
} else {
|
|
338
|
+
state.floor = accepted;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (state.pending && state.grant) {
|
|
342
|
+
state.malformed = true;
|
|
343
|
+
state.reason ||= 'pending and grant completion authority records coexist';
|
|
344
|
+
}
|
|
345
|
+
if (!state.floor) {
|
|
346
|
+
state.floorMissing = true;
|
|
347
|
+
if (state.pending || state.grant || state.baselines.length > 0) {
|
|
348
|
+
state.malformed = true;
|
|
349
|
+
state.reason ||= 'project policy floor is missing';
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return state;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function clearState(projectRoot) {
|
|
356
|
+
let files;
|
|
357
|
+
try { files = recordFiles(projectRoot); } catch { return; }
|
|
358
|
+
for (const name of files) {
|
|
359
|
+
if (/^(?:pending|grant)-[a-f0-9]+\.json$/.test(name)) {
|
|
360
|
+
try { fs.unlinkSync(path.join(stateDir(projectRoot), name)); } catch { /* session cleanup is idempotent */ }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function sameBinding(record, binding) {
|
|
366
|
+
return Boolean(record && binding && stableStringify(record.binding) === stableStringify(binding));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function createPending(projectRoot, binding) {
|
|
370
|
+
if (!plain(binding) || typeof binding.session_id !== 'string' || !binding.session_id.trim()) throw new Error('completion authority binding is malformed');
|
|
371
|
+
const key = ensureKey();
|
|
372
|
+
const state = readState(projectRoot);
|
|
373
|
+
if (state.malformed || state.keyError || state.keyMissing) throw new Error(state.reason || 'completion authority state is unavailable');
|
|
374
|
+
if (state.pending && sameBinding(state.pending, binding)) {
|
|
375
|
+
const { file, ...record } = state.pending;
|
|
376
|
+
return record;
|
|
377
|
+
}
|
|
378
|
+
clearState(projectRoot);
|
|
379
|
+
const nonce = crypto.randomBytes(12).toString('hex');
|
|
380
|
+
const issuedAt = new Date().toISOString();
|
|
381
|
+
const record = {
|
|
382
|
+
schema_version: SCHEMA_VERSION,
|
|
383
|
+
kind: 'pending',
|
|
384
|
+
nonce,
|
|
385
|
+
binding: JSON.parse(JSON.stringify(binding)),
|
|
386
|
+
issued_at: issuedAt,
|
|
387
|
+
expires_at: Date.now() + TTL_MS,
|
|
388
|
+
};
|
|
389
|
+
record.mac = recordMac(record, key);
|
|
390
|
+
atomicWrite(path.join(stateDir(projectRoot), `pending-${nonce}.json`), record);
|
|
391
|
+
return record;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function approvePending(projectRoot, sessionId, nonce) {
|
|
395
|
+
if (typeof sessionId !== 'string' || !sessionId.trim() || !NONCE_RE.test(nonce || '')) return { ok: false, reason: 'shape' };
|
|
396
|
+
let state;
|
|
397
|
+
let key;
|
|
398
|
+
try {
|
|
399
|
+
key = readKey();
|
|
400
|
+
if (key.missing) return { ok: false, reason: 'authority-key-missing' };
|
|
401
|
+
state = readState(projectRoot);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
return { ok: false, reason: `authority-unavailable:${error.message}` };
|
|
404
|
+
}
|
|
405
|
+
if (state.malformed || state.keyError || state.keyMissing) return { ok: false, reason: state.reason || 'authority-state-malformed' };
|
|
406
|
+
const pending = state.pending;
|
|
407
|
+
if (!pending || pending.binding.session_id !== sessionId || pending.nonce !== nonce) return { ok: false, reason: 'missing-or-bound' };
|
|
408
|
+
const { file, mac, ...unsignedPending } = pending;
|
|
409
|
+
const grant = {
|
|
410
|
+
...unsignedPending,
|
|
411
|
+
kind: 'grant',
|
|
412
|
+
approved_at: new Date().toISOString(),
|
|
413
|
+
};
|
|
414
|
+
grant.mac = recordMac(grant, key.key);
|
|
415
|
+
try { fs.unlinkSync(file); } catch { return { ok: false, reason: 'consumed' }; }
|
|
416
|
+
try {
|
|
417
|
+
atomicWrite(path.join(stateDir(projectRoot), `grant-${nonce}.json`), grant);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
return { ok: false, reason: `grant-write:${error.message}` };
|
|
420
|
+
}
|
|
421
|
+
return { ok: true, grant };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function consumeGrant(projectRoot, binding) {
|
|
425
|
+
let state;
|
|
426
|
+
try { state = readState(projectRoot); } catch (error) { return { ok: false, reason: `authority-unavailable:${error.message}` }; }
|
|
427
|
+
if (state.malformed || state.keyError || state.keyMissing) return { ok: false, reason: state.reason || 'authority-state-malformed' };
|
|
428
|
+
if (!state.grant) return { ok: false, reason: 'missing' };
|
|
429
|
+
if (!sameBinding(state.grant, binding)) {
|
|
430
|
+
clearState(projectRoot);
|
|
431
|
+
return { ok: false, reason: 'binding' };
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
fs.unlinkSync(state.grant.file);
|
|
435
|
+
return { ok: true };
|
|
436
|
+
} catch {
|
|
437
|
+
return { ok: false, reason: 'consume' };
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function readBaseline(projectRoot, identity) {
|
|
442
|
+
const normalized = normalizeIdentity(identity, { allowMissingSpec: true });
|
|
443
|
+
const state = readState(projectRoot);
|
|
444
|
+
if (state.malformed || state.keyError || state.keyMissing) throw new Error(state.reason || 'completion authority state is unavailable');
|
|
445
|
+
return state.baselines.find((entry) => stableStringify(entry.identity) === stableStringify(normalized)) || null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function writeBaseline(projectRoot, identity, policy) {
|
|
449
|
+
if (!plain(policy)) throw new Error('policy baseline must contain a policy object');
|
|
450
|
+
const normalized = normalizeIdentity(identity, { allowMissingSpec: true });
|
|
451
|
+
const key = ensureKey();
|
|
452
|
+
const state = readState(projectRoot);
|
|
453
|
+
if (state.malformed || state.keyError || state.keyMissing) throw new Error(state.reason || 'completion authority state is unavailable');
|
|
454
|
+
const existing = state.baselines.find((entry) => stableStringify(entry.identity) === stableStringify(normalized));
|
|
455
|
+
const issuedAt = existing?.issued_at || new Date().toISOString();
|
|
456
|
+
const record = {
|
|
457
|
+
schema_version: SCHEMA_VERSION,
|
|
458
|
+
kind: 'baseline',
|
|
459
|
+
identity: normalized,
|
|
460
|
+
policy: JSON.parse(JSON.stringify(policy)),
|
|
461
|
+
issued_at: issuedAt,
|
|
462
|
+
updated_at: new Date().toISOString(),
|
|
463
|
+
};
|
|
464
|
+
record.mac = recordMac(record, key);
|
|
465
|
+
atomicWrite(baselinePath(projectRoot, normalized), record);
|
|
466
|
+
return record;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function readPolicyFloor(projectRoot) {
|
|
470
|
+
const state = readState(projectRoot);
|
|
471
|
+
if (state.malformed || state.keyError || state.keyMissing) throw new Error(state.reason || 'completion authority state is unavailable');
|
|
472
|
+
return state.floor || null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function writePolicyFloor(projectRoot, policy) {
|
|
476
|
+
if (!plain(policy)) throw new Error('project policy floor must contain a policy object');
|
|
477
|
+
const key = ensureKey();
|
|
478
|
+
const state = readState(projectRoot);
|
|
479
|
+
if (state.malformed || state.keyError || state.keyMissing) throw new Error(state.reason || 'completion authority state is unavailable');
|
|
480
|
+
const issuedAt = state.floor?.issued_at || new Date().toISOString();
|
|
481
|
+
const record = {
|
|
482
|
+
schema_version: SCHEMA_VERSION,
|
|
483
|
+
kind: 'policy-floor',
|
|
484
|
+
policy: JSON.parse(JSON.stringify(policy)),
|
|
485
|
+
issued_at: issuedAt,
|
|
486
|
+
updated_at: new Date().toISOString(),
|
|
487
|
+
};
|
|
488
|
+
record.mac = recordMac(record, key);
|
|
489
|
+
atomicWrite(path.join(stateDir(projectRoot), FLOOR_FILE), record);
|
|
490
|
+
return record;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function hasState(projectRoot) {
|
|
494
|
+
try {
|
|
495
|
+
const state = readState(projectRoot);
|
|
496
|
+
if (state.malformed || state.keyError) return true;
|
|
497
|
+
if (state.pending || state.grant || state.baselines.length > 0 || state.floor) return true;
|
|
498
|
+
return !state.keyMissing && recordFiles(projectRoot).length > 0;
|
|
499
|
+
} catch {
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
module.exports = {
|
|
505
|
+
PREFIX,
|
|
506
|
+
NONCE_RE,
|
|
507
|
+
TTL_MS,
|
|
508
|
+
SCHEMA_VERSION,
|
|
509
|
+
FLOOR_FILE,
|
|
510
|
+
stableStringify,
|
|
511
|
+
digest,
|
|
512
|
+
userStateRoot,
|
|
513
|
+
stateDir,
|
|
514
|
+
clearState,
|
|
515
|
+
readState,
|
|
516
|
+
ensureKey,
|
|
517
|
+
createPending,
|
|
518
|
+
approvePending,
|
|
519
|
+
consumeGrant,
|
|
520
|
+
readBaseline,
|
|
521
|
+
writeBaseline,
|
|
522
|
+
readPolicyFloor,
|
|
523
|
+
writePolicyFloor,
|
|
524
|
+
hasState,
|
|
525
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
let STATE;
|
|
7
|
+
let evaluateCloseout;
|
|
8
|
+
|
|
9
|
+
function emitBlock(reason) {
|
|
10
|
+
process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readPayload() {
|
|
14
|
+
const raw = fs.readFileSync(0, 'utf8').trim();
|
|
15
|
+
if (!raw) throw new Error('hook payload is empty');
|
|
16
|
+
const payload = JSON.parse(raw);
|
|
17
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('hook payload must be a JSON object');
|
|
18
|
+
return payload;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function projectRoot(payload = {}) {
|
|
22
|
+
const configured = typeof process.env.CLAUDE_PROJECT_DIR === 'string'
|
|
23
|
+
? process.env.CLAUDE_PROJECT_DIR.trim()
|
|
24
|
+
: '';
|
|
25
|
+
if (configured) {
|
|
26
|
+
try { return fs.realpathSync(path.resolve(configured)); } catch { /* continue */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const installedRoot = path.resolve(__dirname, '..', '..');
|
|
30
|
+
const installedHook = path.join(installedRoot, '.claude', 'hooks', path.basename(__filename));
|
|
31
|
+
if (fs.existsSync(installedHook)) return installedRoot;
|
|
32
|
+
|
|
33
|
+
const sourceFixture = typeof process.env.PROJECT_ROOT === 'string'
|
|
34
|
+
? process.env.PROJECT_ROOT.trim()
|
|
35
|
+
: '';
|
|
36
|
+
if (sourceFixture) {
|
|
37
|
+
try { return fs.realpathSync(path.resolve(sourceFixture)); } catch { /* continue */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const legacy = typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
|
|
41
|
+
if (legacy) {
|
|
42
|
+
try { return fs.realpathSync(path.resolve(legacy)); } catch { /* continue */ }
|
|
43
|
+
}
|
|
44
|
+
try { return fs.realpathSync(process.cwd()); } catch { return path.resolve(process.cwd()); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sessionId(payload) {
|
|
48
|
+
return typeof payload.session_id === 'string' ? payload.session_id : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readRuntime(root) {
|
|
52
|
+
try {
|
|
53
|
+
const file = path.join(root, '.claude', 'runtime.json');
|
|
54
|
+
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
|
|
55
|
+
} catch { return {}; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rejectApproval(reason) {
|
|
59
|
+
process.stdout.write(`CafeKit completion approval rejected: ${reason}.\n`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadAuthority() {
|
|
63
|
+
try {
|
|
64
|
+
STATE = require('./completion-authority-state.cjs');
|
|
65
|
+
({ evaluateCloseout } = require('./completion-authority-check.cjs'));
|
|
66
|
+
const requiredState = ['createPending', 'approvePending', 'consumeGrant', 'clearState', 'hasState', 'ensureKey', 'readBaseline', 'writeBaseline', 'readPolicyFloor', 'writePolicyFloor'];
|
|
67
|
+
if (requiredState.some((name) => typeof STATE[name] !== 'function') || typeof evaluateCloseout !== 'function') {
|
|
68
|
+
throw new Error('completion authority dependency exports are malformed');
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function approve(payload) {
|
|
77
|
+
if (payload.hook_event_name !== 'UserPromptSubmit') return;
|
|
78
|
+
const root = projectRoot(payload);
|
|
79
|
+
const session = sessionId(payload);
|
|
80
|
+
const prompt = typeof payload.prompt === 'string' ? payload.prompt : null;
|
|
81
|
+
if (!session) return rejectApproval('host session ID is missing');
|
|
82
|
+
if (!prompt || prompt !== prompt.trim() || !prompt.startsWith(STATE.PREFIX)) return;
|
|
83
|
+
const nonce = prompt.slice(STATE.PREFIX.length);
|
|
84
|
+
if (!STATE.NONCE_RE.test(nonce) || `${STATE.PREFIX}${nonce}` !== prompt) return rejectApproval('the prompt did not exactly match a pending request');
|
|
85
|
+
const result = STATE.approvePending(root, session, nonce);
|
|
86
|
+
if (!result.ok) return rejectApproval('request missing, expired, consumed, or bound to another session');
|
|
87
|
+
process.stdout.write('CafeKit completion approval accepted for one bound closeout retry.\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stop(payload) {
|
|
91
|
+
const root = projectRoot(payload);
|
|
92
|
+
const runtime = readRuntime(root);
|
|
93
|
+
const resolverPath = path.join(__dirname, '..', 'scripts', 'spec-resolver.cjs');
|
|
94
|
+
const policyPath = path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs');
|
|
95
|
+
let resolver;
|
|
96
|
+
let policy;
|
|
97
|
+
try {
|
|
98
|
+
resolver = require(resolverPath);
|
|
99
|
+
policy = require(policyPath);
|
|
100
|
+
if (typeof resolver.findAllSpecCandidates !== 'function' || typeof resolver.resolveActiveSpec !== 'function') throw new Error('shared spec resolver lacks candidate scan functions');
|
|
101
|
+
if (typeof policy.completionDecisionForSpec !== 'function' || typeof policy.deriveRuntimeContext !== 'function' || typeof policy.validateWorkflowPolicySnapshot !== 'function') throw new Error('shared workflow policy lacks completion authority functions');
|
|
102
|
+
} catch (error) {
|
|
103
|
+
STATE.clearState(root);
|
|
104
|
+
emitBlock(`Completion authority unavailable: ${error.message}. Repair ${policyPath} and ${resolverPath} before completing tasks`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
result = evaluateCloseout({ resolver, policy, projectRoot: root, runtime, payload });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
STATE.clearState(root);
|
|
113
|
+
emitBlock(`Completion authority controlled failure: ${error.message}. Completion is blocked until the authority is repaired`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (result.candidate === null) {
|
|
117
|
+
if (STATE.hasState(root)) {
|
|
118
|
+
STATE.clearState(root);
|
|
119
|
+
emitBlock('Completion authority: an observed persisted spec disappeared; fresh technical closeout and approval are required');
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!result.ok) {
|
|
124
|
+
STATE.clearState(root);
|
|
125
|
+
emitBlock(`Completion authority: ${result.reason}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const consumed = STATE.consumeGrant(root, result.binding);
|
|
129
|
+
if (consumed.ok) return;
|
|
130
|
+
const pending = STATE.createPending(root, result.binding);
|
|
131
|
+
emitBlock(`Completion authority: technical proofs pass, but user closeout approval is required. Enter exactly "${STATE.PREFIX}${pending.nonce}" as your next user prompt. This approval is one-time and expires in five minutes.`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let mode = process.argv[2] || null;
|
|
135
|
+
try {
|
|
136
|
+
const payload = readPayload();
|
|
137
|
+
mode = mode || (payload.hook_event_name === 'UserPromptSubmit' ? '--approve' : '--stop');
|
|
138
|
+
const dependencyError = loadAuthority();
|
|
139
|
+
if (dependencyError) {
|
|
140
|
+
if (mode === '--approve') rejectApproval(`authority dependency unavailable (${dependencyError.message})`);
|
|
141
|
+
else emitBlock(`Completion authority unavailable: ${dependencyError.message}. Repair the installed authority before completing tasks`);
|
|
142
|
+
} else if (mode === '--approve') approve(payload);
|
|
143
|
+
else if (mode === '--stop') stop(payload);
|
|
144
|
+
else throw new Error('completion authority mode is required');
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (mode === '--approve') rejectApproval(`authority failed safely (${error.message})`);
|
|
147
|
+
else emitBlock(`Completion authority controlled failure: ${error.message}. Completion is blocked until the authority is repaired`);
|
|
148
|
+
}
|