@haposoft/cafekit 0.16.0-rc.3 → 0.16.0-rc.5
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__/change-firewall.semantic-firewall.test.js +2134 -0
- package/bin/__tests__/codex-hooks.test.js +531 -29
- package/bin/__tests__/codex-native.test.js +228 -7
- package/bin/__tests__/develop-contract.test.js +653 -185
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
- package/bin/__tests__/installer-safety.test.js +39 -0
- package/bin/__tests__/opencode-spec-gate.test.js +24 -16
- package/bin/__tests__/package-inventory.test.js +705 -13
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +578 -0
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
- package/bin/__tests__/specs-v2-execution-closeout.test.js +468 -0
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +665 -0
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +1132 -0
- package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
- package/bin/__tests__/validator-grounding.test.js +2013 -221
- package/bin/lib/managed-writer.js +12 -3
- package/bin/phases/codex-runtime.js +17 -1
- package/package.json +5 -2
- package/src/claude/CLAUDE.md +6 -0
- package/src/claude/agents/code-auditor.md +20 -0
- package/src/claude/agents/spec-maker.md +143 -99
- package/src/claude/hooks/__tests__/completion-authority.test.js +321 -58
- package/src/claude/hooks/__tests__/privacy-block.test.js +254 -0
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
- package/src/claude/hooks/completion-authority-check.cjs +37 -288
- package/src/claude/hooks/completion-authority-state.cjs +10 -8
- package/src/claude/hooks/completion-authority.cjs +13 -7
- package/src/claude/hooks/docs-sync.cjs +5 -2
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
- package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
- package/src/claude/hooks/privacy-block.cjs +99 -28
- package/src/claude/hooks/semantic-review-authority.cjs +152 -0
- package/src/claude/hooks/spec-gate.cjs +47 -117
- package/src/claude/hooks/spec-state.cjs +8 -2
- package/src/claude/migration-manifest.json +9 -1
- package/src/claude/rules/state-sync.md +52 -21
- package/src/claude/rules/workflow.md +17 -9
- package/src/claude/scripts/change-firewall.cjs +1748 -0
- package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
- package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
- package/src/claude/scripts/spec-final-state.cjs +290 -0
- package/src/claude/scripts/spec-ground.cjs +571 -173
- package/src/claude/scripts/spec-readiness.cjs +425 -0
- package/src/claude/scripts/spec-receipt.cjs +162 -0
- package/src/claude/scripts/spec-resolver.cjs +67 -16
- package/src/claude/scripts/spec-scaffold.cjs +1594 -139
- package/src/claude/scripts/spec-semantic-model.cjs +396 -0
- package/src/claude/scripts/validate-spec-output.cjs +3110 -218
- package/src/claude/scripts/workflow-policy.cjs +501 -183
- package/src/claude/settings/settings.json +4 -0
- package/src/claude/skills/code-review/SKILL.md +12 -3
- package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
- package/src/claude/skills/code-review/references/verification-gate.md +13 -0
- package/src/claude/skills/develop/SKILL.md +47 -60
- package/src/claude/skills/develop/references/parallel-waves.md +9 -6
- package/src/claude/skills/develop/references/quality-gate.md +19 -12
- package/src/claude/skills/specs/SKILL.md +183 -177
- package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
- package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
- package/src/claude/skills/specs/references/research-strategy.md +4 -1
- package/src/claude/skills/specs/references/review.md +214 -262
- package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
- package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
- package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
- package/src/claude/skills/specs/rules/design-principles.md +136 -180
- package/src/claude/skills/specs/rules/design-review.md +42 -97
- package/src/claude/skills/specs/rules/ears-format.md +42 -50
- package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
- package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
- package/src/claude/skills/specs/templates/design.md +49 -263
- package/src/claude/skills/specs/templates/requirements.md +26 -36
- package/src/claude/skills/specs/templates/research.md +9 -98
- package/src/claude/skills/specs/templates/spec-state.json +35 -85
- package/src/claude/skills/specs/templates/task.md +30 -102
- package/src/claude/skills/sync/SKILL.md +33 -10
- package/src/claude/skills/sync/references/sync-protocols.md +31 -13
- package/src/claude/skills/test/SKILL.md +17 -7
- package/src/codex/AGENTS.md +6 -0
- package/src/codex/hooks/completion-authority-check.cjs +43 -240
- package/src/codex/hooks/completion-authority-state.cjs +10 -8
- package/src/codex/hooks/completion-authority.cjs +13 -7
- package/src/codex/hooks/lib/hook-context.cjs +16 -2
- package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
- package/src/codex/hooks/lib/privacy-state.cjs +61 -38
- package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
- package/src/codex/hooks/lib/spec-utils.cjs +56 -5
- package/src/codex/hooks/privacy-approval.cjs +6 -2
- package/src/codex/hooks/privacy-block.cjs +94 -15
- package/src/codex/hooks/semantic-review-authority.cjs +130 -0
- package/src/codex/hooks/spec-gate.cjs +49 -34
- package/src/codex/hooks/spec-state.cjs +10 -3
- package/src/codex/hooks/state.cjs +2 -0
- package/src/codex/hooks.json +6 -1
- package/src/codex/rules/state-sync.md +47 -27
- package/src/common/AGENTS.md +1 -1
|
@@ -3,162 +3,100 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
'
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const PASS_MARKER = /^\s*Verification:\s*PASS\s*$/m;
|
|
12
|
-
const LEGACY_SUCCESS = /^\s*(?:PASS(?:ED)?|✓)(?:\s*:|$)|exit\s+code\s*[:=]?\s*0\b/im;
|
|
13
|
-
|
|
14
|
-
// Single authority: delegate to shared workflow-policy validator. If an
|
|
15
|
-
// installed policy exists but is broken, do not fall back to another copy.
|
|
16
|
-
function loadSharedPolicy() {
|
|
17
|
-
const installed = path.join(__dirname, '../../scripts/workflow-policy.cjs');
|
|
18
|
-
const repo = path.join(__dirname, '../../../claude/scripts/workflow-policy.cjs');
|
|
19
|
-
const candidates = fs.existsSync(installed) ? [installed] : [repo];
|
|
20
|
-
let lastError = new Error('shared workflow policy is missing');
|
|
6
|
+
function loadModule(installedName, sourceName, validate, missingMessage) {
|
|
7
|
+
const installed = path.join(__dirname, '../../scripts', installedName);
|
|
8
|
+
const source = path.join(__dirname, '../../../claude/scripts', sourceName);
|
|
9
|
+
const candidates = fs.existsSync(installed) ? [installed] : [source];
|
|
10
|
+
let lastError = new Error(missingMessage);
|
|
21
11
|
for (const candidate of candidates) {
|
|
22
12
|
try {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return { policy, error: null, path: candidate };
|
|
28
|
-
} catch (error) {
|
|
29
|
-
lastError = error;
|
|
30
|
-
}
|
|
13
|
+
const value = require(candidate);
|
|
14
|
+
validate(value);
|
|
15
|
+
return { value, error: null, path: candidate };
|
|
16
|
+
} catch (error) { lastError = error; }
|
|
31
17
|
}
|
|
32
|
-
return {
|
|
18
|
+
return { value: null, error: lastError, path: candidates[0] };
|
|
33
19
|
}
|
|
34
20
|
|
|
35
|
-
function
|
|
36
|
-
|
|
21
|
+
function loadSharedPolicy() {
|
|
22
|
+
const loaded = loadModule(
|
|
23
|
+
'workflow-policy.cjs',
|
|
24
|
+
'workflow-policy.cjs',
|
|
25
|
+
(policy) => {
|
|
26
|
+
if (typeof policy?.validateCanonicalReceipt !== 'function') {
|
|
27
|
+
throw new Error('shared workflow policy has no validateCanonicalReceipt function');
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
'shared workflow policy is missing'
|
|
31
|
+
);
|
|
32
|
+
return { policy: loaded.value, error: loaded.error, path: loaded.path };
|
|
37
33
|
}
|
|
38
34
|
|
|
39
|
-
function
|
|
40
|
-
|
|
35
|
+
function loadSharedReceipt() {
|
|
36
|
+
const loaded = loadModule(
|
|
37
|
+
'spec-receipt.cjs',
|
|
38
|
+
'spec-receipt.cjs',
|
|
39
|
+
(receipt) => {
|
|
40
|
+
if (typeof receipt?.checkTaskReceipt !== 'function') {
|
|
41
|
+
throw new Error('shared spec receipt helper has no checkTaskReceipt function');
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
'shared spec receipt helper is missing'
|
|
45
|
+
);
|
|
46
|
+
return { receipt: loaded.value, error: loaded.error, path: loaded.path };
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
-
return typeof policy?.isTapMetadataHeading === 'function' && policy.isTapMetadataHeading(line);
|
|
46
|
-
}
|
|
49
|
+
function getSharedPolicy() { return loadSharedPolicy().policy; }
|
|
50
|
+
function getSharedValidate() { return getSharedPolicy()?.validateCanonicalReceipt || null; }
|
|
47
51
|
|
|
48
|
-
function
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
53
|
-
const match = lines[index].match(/^(#{2,3})\s+(.+?)\s*$/);
|
|
54
|
-
if (match && EVIDENCE_NAMES.includes(match[2])) {
|
|
55
|
-
start = index + 1;
|
|
56
|
-
level = match[1].length;
|
|
57
|
-
break;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
if (start < 0) return null;
|
|
61
|
-
let end = lines.length;
|
|
62
|
-
for (let index = start; index < lines.length; index += 1) {
|
|
63
|
-
const heading = lines[index].match(/^(#{1,6})\s+/);
|
|
64
|
-
if (heading && heading[1].length <= level && !isTapMetadataHeading(lines[index])) {
|
|
65
|
-
end = index;
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return lines.slice(start, end).join('\n');
|
|
52
|
+
function receiptHelper() {
|
|
53
|
+
const loaded = loadSharedReceipt();
|
|
54
|
+
if (!loaded.receipt) throw loaded.error;
|
|
55
|
+
return loaded.receipt;
|
|
70
56
|
}
|
|
71
57
|
|
|
58
|
+
function evidenceBody(text) { return receiptHelper().evidenceBody(text, getSharedPolicy() || {}); }
|
|
72
59
|
function safeTaskFile(featureDir, taskPath) {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
const relative = path.relative(resolvedFeature, target);
|
|
76
|
-
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
|
77
|
-
if (!fs.existsSync(target)) return null;
|
|
78
|
-
try {
|
|
79
|
-
const canonicalFeature = fs.realpathSync(resolvedFeature);
|
|
80
|
-
const canonicalTarget = fs.realpathSync(target);
|
|
81
|
-
const rel = path.relative(canonicalFeature, canonicalTarget);
|
|
82
|
-
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
|
|
83
|
-
} catch {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
return target;
|
|
60
|
+
const result = receiptHelper().safeRead(featureDir, taskPath);
|
|
61
|
+
return result.status === 'ok' ? result.path : null;
|
|
87
62
|
}
|
|
88
|
-
|
|
89
63
|
function validateCanonicalReceipt(body, options = {}) {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
64
|
+
const validate = getSharedValidate();
|
|
65
|
+
return validate ? validate(body, options) : ['shared_validator'];
|
|
66
|
+
}
|
|
67
|
+
function checkReceiptDetails(featureDir, taskPath, task, runtimeContext) {
|
|
68
|
+
const policy = getSharedPolicy();
|
|
69
|
+
if (!policy) return { failures: ['shared_validator'], status: 'missing' };
|
|
70
|
+
return receiptHelper().checkTaskReceipt(featureDir, taskPath, task, runtimeContext, policy);
|
|
93
71
|
}
|
|
94
|
-
|
|
95
72
|
function checkReceipt(featureDir, taskPath, task, runtimeContext) {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|| !(PASS_MARKER.test(body) || LEGACY_SUCCESS.test(body))
|
|
112
|
-
) {
|
|
113
|
-
failures.push('c');
|
|
114
|
-
} else {
|
|
115
|
-
const policy = loadSharedPolicy().policy;
|
|
116
|
-
const options = typeof policy?.receiptValidatorOptions === 'function'
|
|
117
|
-
? policy.receiptValidatorOptions(task, { runtimeContext, requireProvenanceBinding: true })
|
|
118
|
-
: { requireProvenanceBinding: true };
|
|
119
|
-
const canonical = validateCanonicalReceipt(body, options);
|
|
120
|
-
const map = {
|
|
121
|
-
verification_state: 'c',
|
|
122
|
-
command: 'e',
|
|
123
|
-
exit_result: 'f',
|
|
124
|
-
provenance: 'g',
|
|
125
|
-
artifact_hash: 'h',
|
|
126
|
-
artifact_declaration: 'h',
|
|
127
|
-
placeholder: 'c',
|
|
128
|
-
shared_validator: 'c',
|
|
129
|
-
validator_unavailable: 'c',
|
|
130
|
-
};
|
|
131
|
-
for (const fail of canonical) {
|
|
132
|
-
if (fail === 'verification_state' || fail === 'shared_validator' || fail === 'validator_unavailable') {
|
|
133
|
-
if (!failures.includes('c')) failures.push('c');
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
if (fail === 'placeholder') {
|
|
137
|
-
if (!failures.includes('c')) failures.push('c');
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
const letter = map[fail];
|
|
141
|
-
if (letter && !failures.includes(letter)) failures.push(letter);
|
|
142
|
-
}
|
|
143
|
-
if (!PASS_MARKER.test(body)) {
|
|
144
|
-
if (!failures.includes('c')) failures.push('c');
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const completedAt = task?.completed_at;
|
|
149
|
-
if (
|
|
150
|
-
typeof completedAt !== 'string'
|
|
151
|
-
|| !/^\d{4}-\d{2}-\d{2}T/.test(completedAt)
|
|
152
|
-
|| Number.isNaN(Date.parse(completedAt))
|
|
153
|
-
) failures.push('d');
|
|
154
|
-
return failures;
|
|
73
|
+
const map = {
|
|
74
|
+
unsafe_path: 'a', missing_receipt: 'b', task_status: 'a', completed_at: 'd',
|
|
75
|
+
verification_state: 'c', placeholder: 'c', validator_unavailable: 'c',
|
|
76
|
+
command: 'e', command_identity: 'e', exit_result: 'f', provenance: 'g',
|
|
77
|
+
artifact_hash: 'h', artifact_declaration: 'h',
|
|
78
|
+
};
|
|
79
|
+
return [...new Set(checkReceiptDetails(featureDir, taskPath, task, runtimeContext).failures.map((failure) => map[failure] || failure))];
|
|
80
|
+
}
|
|
81
|
+
function checkFeatureReceipt(featureDir, runtimeContext) {
|
|
82
|
+
const policy = getSharedPolicy();
|
|
83
|
+
if (!policy) return { failures: ['shared_validator'], status: 'missing' };
|
|
84
|
+
return receiptHelper().checkFeatureReceipt(featureDir, runtimeContext, policy);
|
|
85
|
+
}
|
|
86
|
+
function readTaskProof(featureDir, taskPath) {
|
|
87
|
+
return receiptHelper().readTaskProof(featureDir, taskPath, getSharedPolicy() || {});
|
|
155
88
|
}
|
|
156
89
|
|
|
157
90
|
module.exports = {
|
|
91
|
+
checkFeatureReceipt,
|
|
158
92
|
checkReceipt,
|
|
93
|
+
checkReceiptDetails,
|
|
159
94
|
evidenceBody,
|
|
95
|
+
getSharedPolicy,
|
|
96
|
+
getSharedValidate,
|
|
97
|
+
loadSharedPolicy,
|
|
98
|
+
loadSharedReceipt,
|
|
99
|
+
readTaskProof,
|
|
160
100
|
safeTaskFile,
|
|
161
101
|
validateCanonicalReceipt,
|
|
162
|
-
loadSharedPolicy,
|
|
163
|
-
getSharedPolicy,
|
|
164
102
|
};
|
|
@@ -2,26 +2,77 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const PATH_SAFETY = require('./runtime-path-safety.cjs');
|
|
6
|
+
|
|
7
|
+
// Save original fs methods at load time to avoid being affected by test monkey patches
|
|
8
|
+
// that simulate runtime canonicalization failures for spec paths. Resolver discovery
|
|
9
|
+
// must use these originals, while spec resolution uses the (possibly patched) fs.
|
|
10
|
+
const originalLstatSync = fs.lstatSync;
|
|
5
11
|
|
|
6
12
|
// Installed Codex hooks use .codex/scripts; source tests use the Claude-side
|
|
7
13
|
// source path. If an installed resolver exists but is malformed, do not fall
|
|
8
14
|
// back to another copy: the shared authority is unavailable.
|
|
9
15
|
const RESOLVER_CANDIDATES = [
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
{
|
|
17
|
+
file: path.join(__dirname, '../../scripts/spec-resolver.cjs'),
|
|
18
|
+
root: path.resolve(__dirname, '../..'),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
file: path.join(__dirname, '../../../claude/scripts/spec-resolver.cjs'),
|
|
22
|
+
root: path.resolve(__dirname, '../../..'),
|
|
23
|
+
},
|
|
12
24
|
];
|
|
13
25
|
|
|
26
|
+
function canonicalRealpath(candidate) {
|
|
27
|
+
try {
|
|
28
|
+
return PATH_SAFETY.canonicalRegularFile(candidate.root, candidate.file, 'shared spec resolver');
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw new Error(`${error.message} (${candidate.file})`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function findCandidate() {
|
|
35
|
+
// Fail closed if preferred installed resolver exists but is invalid; never silently fall back to source.
|
|
36
|
+
const primary = RESOLVER_CANDIDATES[0];
|
|
37
|
+
let primaryExists = false;
|
|
38
|
+
try {
|
|
39
|
+
originalLstatSync(primary.file);
|
|
40
|
+
primaryExists = true;
|
|
41
|
+
} catch (e) {
|
|
42
|
+
if (e.code !== 'ENOENT') throw new Error(`resolver check failed: ${e.message} (${primary.file})`);
|
|
43
|
+
}
|
|
44
|
+
if (primaryExists) {
|
|
45
|
+
canonicalRealpath(primary);
|
|
46
|
+
return primary;
|
|
47
|
+
}
|
|
48
|
+
const secondary = RESOLVER_CANDIDATES[1];
|
|
49
|
+
let secondaryExists = false;
|
|
50
|
+
try {
|
|
51
|
+
originalLstatSync(secondary.file);
|
|
52
|
+
secondaryExists = true;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (e.code !== 'ENOENT') throw new Error(`resolver check failed: ${e.message} (${secondary.file})`);
|
|
55
|
+
}
|
|
56
|
+
if (secondaryExists) {
|
|
57
|
+
canonicalRealpath(secondary);
|
|
58
|
+
return secondary;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
14
63
|
function sharedResolver() {
|
|
15
|
-
const candidate =
|
|
64
|
+
const candidate = findCandidate() || RESOLVER_CANDIDATES[0];
|
|
16
65
|
try {
|
|
17
|
-
|
|
66
|
+
// Use canonical realpath for require to avoid symlink hijack
|
|
67
|
+
const realCandidate = canonicalRealpath(candidate);
|
|
68
|
+
const resolver = require(realCandidate);
|
|
18
69
|
if (typeof resolver?.resolveActiveSpec !== 'function') {
|
|
19
70
|
throw new Error('shared spec resolver has no resolveActiveSpec function');
|
|
20
71
|
}
|
|
21
72
|
return resolver;
|
|
22
73
|
} catch (error) {
|
|
23
74
|
const message = error instanceof Error ? error.message : String(error);
|
|
24
|
-
throw new Error(`${message} (${candidate})`);
|
|
75
|
+
throw new Error(`${message} (${candidate.file})`);
|
|
25
76
|
}
|
|
26
77
|
}
|
|
27
78
|
|
|
@@ -24,6 +24,10 @@ try {
|
|
|
24
24
|
const data = readPayload();
|
|
25
25
|
if (!data) process.exit(0);
|
|
26
26
|
const { projectRoot } = getHookContext(data);
|
|
27
|
+
// A pasted prompt arrives with whatever whitespace the terminal added around
|
|
28
|
+
// it. Rejecting on a trailing newline made the approval look broken while the
|
|
29
|
+
// id it named was still perfectly valid; the exactness that matters is the
|
|
30
|
+
// request id, checked below.
|
|
27
31
|
const prompt = String(data.prompt || '').trim();
|
|
28
32
|
cleanup(projectRoot);
|
|
29
33
|
|
|
@@ -49,8 +53,8 @@ try {
|
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
context(
|
|
52
|
-
`User approved
|
|
53
|
-
`The
|
|
56
|
+
`User approved the pending sensitive access (${result.displayName}). ` +
|
|
57
|
+
`The grant is session-bound, bound to exactly those paths, and expires in one hour.`
|
|
54
58
|
);
|
|
55
59
|
process.exit(0);
|
|
56
60
|
} catch {
|
|
@@ -6,12 +6,13 @@ const path = require('path');
|
|
|
6
6
|
const {
|
|
7
7
|
APPROVAL_PREFIX,
|
|
8
8
|
createPending,
|
|
9
|
-
|
|
9
|
+
hasGrant
|
|
10
10
|
} = require('./lib/privacy-state.cjs');
|
|
11
11
|
const {
|
|
12
12
|
getHookContext,
|
|
13
13
|
readPayload
|
|
14
14
|
} = require('./lib/hook-context.cjs');
|
|
15
|
+
const { commandAccess } = require('./lib/privacy-command-analysis.cjs');
|
|
15
16
|
|
|
16
17
|
const RESTRICTED = [
|
|
17
18
|
/^\.env(?:[.\[*?{]|$)/i,
|
|
@@ -60,15 +61,90 @@ function resolveTarget(filePath, cwd) {
|
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
/**
|
|
65
|
+
* When realpath fails, check whether the requested path is a symlink (or chain)
|
|
66
|
+
* whose lexical target is sensitive. Dangling link to protected target -> true (fail-closed deny);
|
|
67
|
+
* dangling benign link or link to allowed example/template -> false (allow).
|
|
68
|
+
* Limit hops to avoid loops; malformed/looping -> fail closed.
|
|
69
|
+
* TOCTOU note: check and use are not atomic; target could be swapped between this check and the actual read.
|
|
70
|
+
* This is a best-effort guardrail, not an arbitrary-shell security boundary. For strong isolation use OS sandbox.
|
|
71
|
+
*/
|
|
72
|
+
function isSymlinkToSensitive(filePath, cwd) {
|
|
73
|
+
try {
|
|
74
|
+
const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
75
|
+
let current = requested;
|
|
76
|
+
let hops = 0;
|
|
77
|
+
const MAX_HOPS = 8;
|
|
78
|
+
while (hops < MAX_HOPS) {
|
|
79
|
+
let stat;
|
|
80
|
+
try {
|
|
81
|
+
stat = fs.lstatSync(current);
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if (!stat.isSymbolicLink()) return false;
|
|
86
|
+
let linkTarget;
|
|
87
|
+
try {
|
|
88
|
+
linkTarget = fs.readlinkSync(current);
|
|
89
|
+
} catch {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (isSensitive(linkTarget) && !isSafe(linkTarget)) return true;
|
|
93
|
+
if (isSafe(linkTarget)) return false;
|
|
94
|
+
const next = path.isAbsolute(linkTarget) ? linkTarget : path.resolve(path.dirname(current), linkTarget);
|
|
95
|
+
if (next === current) return true;
|
|
96
|
+
try {
|
|
97
|
+
const real = fs.realpathSync(next);
|
|
98
|
+
if (isSensitive(real) && !isSafe(real)) return true;
|
|
99
|
+
if (isSafe(real)) return false;
|
|
100
|
+
return false;
|
|
101
|
+
} catch {
|
|
102
|
+
current = next;
|
|
103
|
+
hops += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
70
106
|
}
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function extractCommandPaths(command) {
|
|
114
|
+
// The analyzer parses the command and returns the operands of every reading
|
|
115
|
+
// command it recognises, nested shells and `find -exec` included. Treating
|
|
116
|
+
// every unresolved expansion as sensitive, as this did before, denied
|
|
117
|
+
// `cat $FILE`, `wc -l *.cjs` and `cat ~/.zshrc` alike -- and every denial here
|
|
118
|
+
// costs a round trip through a one-shot approval prompt.
|
|
119
|
+
// A heredoc whose delimiter is quoted (<<'BODY') is inert: the shell expands
|
|
120
|
+
// nothing inside it, so no word in the body is ever read. The analyzer has no
|
|
121
|
+
// notion of heredocs and parses the body as shell, so prose that quotes a
|
|
122
|
+
// command -- a PR body carrying `cat .env` in backticks -- came back as a real
|
|
123
|
+
// command substitution. Blank those bodies out first. An unquoted delimiter
|
|
124
|
+
// (<<BODY) does expand, so it is left to the analyzer.
|
|
125
|
+
const scrubbed = String(command).replace(
|
|
126
|
+
/(<<-?[ \t]*)(['"])([A-Za-z_]\w*)\2[^\n]*\n[\s\S]*?\n[ \t]*\3\b/g,
|
|
127
|
+
'$1$2$3$2'
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const paths = new Set(commandAccess(scrubbed).paths);
|
|
131
|
+
|
|
132
|
+
// Two shapes the analyzer cannot return. It drops any word carrying an
|
|
133
|
+
// expansion, so `~/.ssh/id_rsa` and `certs/*.pem` still have to be matched by
|
|
134
|
+
// name. And it only walks commands it recognises, so an unknown tool handed a
|
|
135
|
+
// secret through a file-shaped option, such as
|
|
136
|
+
// `docker compose --env-file .env`, is invisible to it.
|
|
137
|
+
const FILE_OPTION = /^--?[\w-]*(?:file|config|key|cert|identity)$/i;
|
|
138
|
+
const tokens = scrubbed.split(/[\s|;&<>"'()`]+/);
|
|
139
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
140
|
+
const token = tokens[index];
|
|
141
|
+
const shaped = /^~|[*?[{]/.test(token);
|
|
142
|
+
const optionFed = index > 0 && FILE_OPTION.test(tokens[index - 1]);
|
|
143
|
+
if (!shaped && !optionFed) continue;
|
|
144
|
+
const value = token.replace(/^~/, '').replace(/^["'()`]+|["'(),]+$/g, '');
|
|
145
|
+
if (value && isSensitive(value) && !isSafe(value)) paths.add(value);
|
|
71
146
|
}
|
|
147
|
+
|
|
72
148
|
return [...paths];
|
|
73
149
|
}
|
|
74
150
|
|
|
@@ -126,6 +202,8 @@ function sensitiveRequest(filePath, cwd) {
|
|
|
126
202
|
if (target) {
|
|
127
203
|
if (isSensitive(target) && !isSafe(target)) return true;
|
|
128
204
|
if (isSafe(target)) return false;
|
|
205
|
+
} else {
|
|
206
|
+
if (isSymlinkToSensitive(filePath, cwd)) return true;
|
|
129
207
|
}
|
|
130
208
|
return !isSafe(filePath) && isSensitive(filePath);
|
|
131
209
|
}
|
|
@@ -152,7 +230,8 @@ try {
|
|
|
152
230
|
if (runtime.privacyBlock === false) process.exit(0);
|
|
153
231
|
|
|
154
232
|
const filePaths = sensitivePaths(data.tool_name || '', data.tool_input || {}, sessionCwd);
|
|
155
|
-
|
|
233
|
+
const effectivePaths = filePaths;
|
|
234
|
+
if (effectivePaths.length === 0) process.exit(0);
|
|
156
235
|
if (!data.session_id) {
|
|
157
236
|
deny(
|
|
158
237
|
'Sensitive file access denied because this hook invocation had no Codex session ID. ' +
|
|
@@ -160,19 +239,19 @@ try {
|
|
|
160
239
|
);
|
|
161
240
|
process.exit(0);
|
|
162
241
|
}
|
|
163
|
-
const
|
|
242
|
+
const grantInput = {
|
|
164
243
|
projectRoot,
|
|
165
244
|
sessionCwd,
|
|
166
245
|
sessionId: data.session_id,
|
|
167
|
-
filePaths
|
|
168
|
-
toolName: data.tool_name
|
|
246
|
+
filePaths: effectivePaths
|
|
169
247
|
};
|
|
170
|
-
if (
|
|
248
|
+
if (hasGrant(grantInput)) process.exit(0);
|
|
171
249
|
|
|
172
|
-
const pending = createPending(
|
|
250
|
+
const pending = createPending(grantInput);
|
|
173
251
|
deny(
|
|
174
252
|
`Sensitive file access denied: ${pending.displayName}. ` +
|
|
175
|
-
`
|
|
253
|
+
`To approve access to exactly these paths for the next hour of this Codex ` +
|
|
254
|
+
`session, send this exact user prompt: ` +
|
|
176
255
|
`${APPROVAL_PREFIX}${pending.requestId}`
|
|
177
256
|
);
|
|
178
257
|
process.exit(0);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
const STATE = require('./completion-authority-state.cjs');
|
|
8
|
+
const PATH_SAFETY = require('./lib/runtime-path-safety.cjs');
|
|
9
|
+
|
|
10
|
+
const SCHEMA_VERSION = 1;
|
|
11
|
+
const MARKER = 'CAFEKIT_SEMANTIC_REVIEW_ATTESTATION ';
|
|
12
|
+
const SHA256_RE = /^sha256:[a-f0-9]{64}$/;
|
|
13
|
+
const REVIEWER_ROLE_ALLOWLIST = new Set(['code-auditor', 'code_auditor']);
|
|
14
|
+
const RECORD_FIELDS = ['schema_version', 'kind', 'identity', 'semantic_digest', 'event', 'verdict', 'host_session_id', 'reviewer_agent_id', 'reviewer_agent_type', 'issued_at', 'mac'];
|
|
15
|
+
|
|
16
|
+
function plain(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); }
|
|
17
|
+
function exactKeys(value, fields) { return plain(value) && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...fields].sort()); }
|
|
18
|
+
function inside(root, target) { const relative = path.relative(root, target); return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); }
|
|
19
|
+
|
|
20
|
+
function projectRoot(payload = {}) {
|
|
21
|
+
const installedRoot = path.resolve(__dirname, '..', '..');
|
|
22
|
+
if (fs.existsSync(path.join(installedRoot, '.codex', 'hooks', path.basename(__filename)))) {
|
|
23
|
+
try { return STATE.canonicalProjectRoot(installedRoot); } catch {}
|
|
24
|
+
}
|
|
25
|
+
for (const candidate of [process.env.CLAUDE_PROJECT_DIR, process.env.PROJECT_ROOT]) {
|
|
26
|
+
if (typeof candidate === 'string' && candidate.trim()) { try { return STATE.canonicalProjectRoot(candidate); } catch { /* continue */ } }
|
|
27
|
+
}
|
|
28
|
+
const cwd = typeof payload.cwd === 'string' && payload.cwd.trim() ? payload.cwd : process.cwd();
|
|
29
|
+
return STATE.canonicalProjectRoot(cwd);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function identityFor(root, specFile, featureName) {
|
|
33
|
+
if (typeof featureName !== 'string' || !featureName.trim() || featureName.includes('/') || featureName.includes('\\') || featureName === '.' || featureName === '..' || featureName.includes('..')) throw new Error('feature identity is malformed');
|
|
34
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(featureName)) throw new Error('feature identity is malformed');
|
|
35
|
+
const canonicalRoot = STATE.canonicalProjectRoot(root);
|
|
36
|
+
const canonicalSpec = fs.realpathSync(path.resolve(specFile));
|
|
37
|
+
if (!inside(canonicalRoot, canonicalSpec) || path.basename(canonicalSpec) !== 'spec.json') throw new Error('spec identity escapes the project');
|
|
38
|
+
const relative = path.relative(canonicalRoot, canonicalSpec);
|
|
39
|
+
const expected = path.join('specs', featureName, 'spec.json');
|
|
40
|
+
if (relative !== expected) throw new Error(`spec identity must be ${expected}, not ${relative}`);
|
|
41
|
+
const spec = JSON.parse(fs.readFileSync(canonicalSpec, 'utf8'));
|
|
42
|
+
if (spec.feature_name !== featureName) throw new Error('feature identity does not match spec.json');
|
|
43
|
+
return { project_root: canonicalRoot, spec_file: canonicalSpec, feature_name: featureName };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function recordPath(root, identity) {
|
|
47
|
+
const name = STATE.digest({ namespace: 'cafekit-semantic-review-observation-v1', identity });
|
|
48
|
+
return path.join(STATE.stateDir(root), 'semantic-reviews', `observation-${name}.json`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseClaim(message) {
|
|
52
|
+
const lines = String(message || '').split(/\r?\n/).filter((line) => line.startsWith(MARKER));
|
|
53
|
+
if (lines.length === 0) return null;
|
|
54
|
+
if (lines.length !== 1) throw new Error('exactly one semantic review attestation marker is required');
|
|
55
|
+
const claim = JSON.parse(lines[0].slice(MARKER.length));
|
|
56
|
+
if (!exactKeys(claim, ['feature_name', 'spec_file', 'semantic_digest', 'verdict'])) throw new Error('semantic review claim fields are malformed');
|
|
57
|
+
if (claim.verdict !== 'PASS' || !SHA256_RE.test(claim.semantic_digest || '')) throw new Error('semantic review claim must contain literal PASS and a sha256 digest');
|
|
58
|
+
return claim;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validatorFor(root) {
|
|
62
|
+
const installedRoot = path.join(root, '.codex');
|
|
63
|
+
const installedHook = path.join(installedRoot, 'hooks', path.basename(__filename));
|
|
64
|
+
if (fs.existsSync(installedHook)) {
|
|
65
|
+
const canonicalHook = PATH_SAFETY.canonicalRegularFile(root, installedHook, 'installed semantic hook');
|
|
66
|
+
if (canonicalHook !== fs.realpathSync(__filename)) throw new Error('installed semantic hook identity is invalid');
|
|
67
|
+
const validator = path.join(installedRoot, 'scripts', 'validate-spec-output.cjs');
|
|
68
|
+
PATH_SAFETY.verifyCommonJsClosure(installedRoot, validator);
|
|
69
|
+
return PATH_SAFETY.canonicalRegularFile(installedRoot, validator, 'semantic validator');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sourceRuntime = path.resolve(__dirname, '..', '..', 'claude');
|
|
73
|
+
const sourceRoot = path.resolve(sourceRuntime, '..');
|
|
74
|
+
if (path.basename(sourceRuntime) !== 'claude' || path.basename(sourceRoot) !== 'src') {
|
|
75
|
+
throw new Error('semantic validator runtime root is not recognized');
|
|
76
|
+
}
|
|
77
|
+
const validator = path.join(sourceRuntime, 'scripts', 'validate-spec-output.cjs');
|
|
78
|
+
PATH_SAFETY.verifyCommonJsClosure(sourceRoot, validator);
|
|
79
|
+
return PATH_SAFETY.canonicalRegularFile(sourceRoot, validator, 'semantic validator');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function currentDigest(root, specFile) {
|
|
83
|
+
const validator = validatorFor(root);
|
|
84
|
+
const result = spawnSync(process.execPath, [validator, path.dirname(specFile), '--semantic-digest'], { cwd: root, encoding: 'utf8' });
|
|
85
|
+
const digest = result.stdout.trim();
|
|
86
|
+
if (result.status !== 0 || !SHA256_RE.test(digest)) throw new Error(`semantic digest could not be recomputed (${result.stderr.trim() || `exit ${result.status}`})`);
|
|
87
|
+
return digest;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function recordFromSubagentStop(payload) {
|
|
91
|
+
// Honest-agent guardrail: the host hook observes this SubagentStop payload.
|
|
92
|
+
// The worker-authored claim is not authenticated input or a security boundary.
|
|
93
|
+
if (!plain(payload) || payload.hook_event_name !== 'SubagentStop') return { recorded: false, reason: 'event' };
|
|
94
|
+
for (const field of ['session_id', 'agent_id', 'agent_type', 'last_assistant_message']) if (typeof payload[field] !== 'string' || !payload[field].trim()) return { recorded: false, reason: `missing-${field}` };
|
|
95
|
+
if (!REVIEWER_ROLE_ALLOWLIST.has(payload.agent_type)) return { recorded: false, reason: 'reviewer-role' };
|
|
96
|
+
const root = projectRoot(payload);
|
|
97
|
+
const claim = parseClaim(payload.last_assistant_message);
|
|
98
|
+
if (claim === null) return { recorded: false, reason: 'no-claim' };
|
|
99
|
+
const identity = identityFor(root, path.resolve(root, claim.spec_file), claim.feature_name);
|
|
100
|
+
const digest = currentDigest(root, identity.spec_file);
|
|
101
|
+
if (digest !== claim.semantic_digest) throw new Error('claimed semantic digest does not match current artifacts');
|
|
102
|
+
const key = STATE.ensureKey();
|
|
103
|
+
const record = { schema_version: SCHEMA_VERSION, kind: 'semantic-review-observation', identity, semantic_digest: digest, event: 'SubagentStop', verdict: 'PASS', host_session_id: payload.session_id, reviewer_agent_id: payload.agent_id, reviewer_agent_type: payload.agent_type, issued_at: new Date().toISOString() };
|
|
104
|
+
record.mac = STATE.recordMac(record, key);
|
|
105
|
+
STATE.atomicWrite(recordPath(root, identity), record);
|
|
106
|
+
return { recorded: true, record };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function verifyAttestation(root, specFile, featureName, semanticDigest) {
|
|
110
|
+
let identity;
|
|
111
|
+
try { identity = identityFor(root, specFile, featureName); } catch (error) { return { ok: false, reason: error.message }; }
|
|
112
|
+
let record;
|
|
113
|
+
try { record = JSON.parse(fs.readFileSync(recordPath(root, identity), 'utf8')); } catch (error) { return { ok: false, reason: error.code === 'ENOENT' ? 'matching host-hook observation is missing' : 'host-hook observation is unreadable' }; }
|
|
114
|
+
const key = STATE.readKey();
|
|
115
|
+
if (key.missing) return { ok: false, reason: 'completion authority key is missing' };
|
|
116
|
+
if (!exactKeys(record, RECORD_FIELDS) || !STATE.macMatches(record, key.key)) return { ok: false, reason: 'host-hook observation is malformed or its MAC is invalid' };
|
|
117
|
+
if (record.schema_version !== SCHEMA_VERSION || record.kind !== 'semantic-review-observation' || record.event !== 'SubagentStop' || record.verdict !== 'PASS') return { ok: false, reason: 'host-hook observation event or verdict is invalid' };
|
|
118
|
+
if (STATE.stableStringify(record.identity) !== STATE.stableStringify(identity)) return { ok: false, reason: 'host-hook observation project, feature, or spec identity is invalid' };
|
|
119
|
+
if (record.semantic_digest !== semanticDigest) return { ok: false, reason: 'host-hook observation semantic digest is stale' };
|
|
120
|
+
return { ok: true, record };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (require.main === module) {
|
|
124
|
+
try { const raw = fs.readFileSync(0, 'utf8').trim(); if (raw) recordFromSubagentStop(JSON.parse(raw)); }
|
|
125
|
+
catch (error) { process.stderr.write(`CafeKit semantic review SubagentStop claim rejected: ${error.message}\n`); }
|
|
126
|
+
// Codex SubagentStop requires valid JSON on stdout for every exit-0 hook.
|
|
127
|
+
process.stdout.write('{}\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { MARKER, REVIEWER_ROLE_ALLOWLIST, recordFromSubagentStop, verifyAttestation, identityFor, recordPath };
|