@haposoft/cafekit 0.18.1 → 0.18.2
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/README.md +12 -1
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +774 -6
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +192 -59
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +1 -1
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
- package/bin/__tests__/omp-bridge.test.js +66 -12
- package/bin/__tests__/omp-hooks.test.js +11 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/phases/claude-runtime.js +91 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +96 -33
- package/bin/phases/codex-runtime.js +45 -52
- package/bin/phases/copy-payload.js +1 -3
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/precompact.test.js +206 -10
- package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +7 -6
- package/src/claude/hooks/inspect-block.cjs +15 -63
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
- package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +67 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +23 -205
- package/src/claude/hooks/rules.cjs +6 -28
- package/src/claude/hooks/session.cjs +34 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +43 -59
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +8 -1
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
- package/src/claude/scripts/provenance.cjs +14 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/codex/AGENTS.md +8 -0
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +11 -7
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +98 -16
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +39 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +9 -215
- package/src/codex/hooks/rules.cjs +4 -30
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +47 -11
- package/src/codex/hooks/spec-gate.cjs +21 -6
- package/src/codex/hooks/spec-state.cjs +32 -6
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -31,6 +31,7 @@ function sessionIdentity(payload) {
|
|
|
31
31
|
return candidates.find((value) => typeof value === 'string' && value.trim() !== '') || null;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
let hasDoneTasks = false;
|
|
34
35
|
try {
|
|
35
36
|
const payload = readPayload();
|
|
36
37
|
if (payload.stop_hook_active === true) process.exit(0);
|
|
@@ -41,6 +42,8 @@ try {
|
|
|
41
42
|
process.exit(0);
|
|
42
43
|
}
|
|
43
44
|
const POLICY = loaded.policy;
|
|
45
|
+
// Match Claude: one invocation validates receipts against one checkout snapshot.
|
|
46
|
+
require(path.join(path.dirname(loaded.path), 'provenance.cjs')).enableSnapshotMemo();
|
|
44
47
|
const { projectRoot, runtime } = getHookContext(payload);
|
|
45
48
|
if (typeof POLICY.deriveRuntimeContext !== 'function') {
|
|
46
49
|
throw new Error('shared workflow policy lacks completion authority functions');
|
|
@@ -56,6 +59,8 @@ try {
|
|
|
56
59
|
}
|
|
57
60
|
if (!resolved) process.exit(0);
|
|
58
61
|
if (resolved.layoutKind === 'process-v3-completed-set') {
|
|
62
|
+
hasDoneTasks = resolved.candidates.some(candidate =>
|
|
63
|
+
Object.values(candidate.taskRegistry || {}).some(task => task?.status === 'done'));
|
|
59
64
|
const configuredGate = runtime.spec?.completion_gate;
|
|
60
65
|
if (configuredGate !== undefined && configuredGate !== true) {
|
|
61
66
|
emitBlock('Completion gate: runtime.spec.completion_gate is a worker-writable flag, not an authorization; no completion-gate bypass is supported. Remove the flag and satisfy the gate.');
|
|
@@ -98,6 +103,7 @@ try {
|
|
|
98
103
|
}
|
|
99
104
|
if (resolved.error) process.exit(0);
|
|
100
105
|
const active = resolved;
|
|
106
|
+
hasDoneTasks = Object.values(active.taskRegistry || {}).some(task => task?.status === 'done');
|
|
101
107
|
const runtimeContext = POLICY.deriveRuntimeContext({
|
|
102
108
|
projectRoot,
|
|
103
109
|
specsRoot: active.specsDir,
|
|
@@ -143,13 +149,12 @@ try {
|
|
|
143
149
|
currentStatuses[taskPath] === 'done'
|
|
144
150
|
));
|
|
145
151
|
const featureDir = path.join(active.specsDir, active.featureName);
|
|
146
|
-
const receiptBodies = new Map();
|
|
147
152
|
const failures = allDoneTasks
|
|
148
153
|
.map((taskPath) => {
|
|
149
154
|
const proof = checkWorkflowReceiptDetails(featureDir, taskPath, runtimeContext);
|
|
150
|
-
if (proof.body) receiptBodies.set(taskPath, proof.body);
|
|
151
155
|
return {
|
|
152
156
|
taskPath,
|
|
157
|
+
body: proof.body,
|
|
153
158
|
failures: proof.failures
|
|
154
159
|
};
|
|
155
160
|
})
|
|
@@ -160,9 +165,11 @@ try {
|
|
|
160
165
|
if (previous[result.taskPath] === undefined) delete next[result.taskPath];
|
|
161
166
|
else next[result.taskPath] = previous[result.taskPath];
|
|
162
167
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
168
|
+
try {
|
|
169
|
+
atomicWrite(cacheFile, `${JSON.stringify({
|
|
170
|
+
entries: { ...cacheEntries, [runtimeContext.context_id]: { identity: cacheIdentity, tasks: next } },
|
|
171
|
+
})}\n`);
|
|
172
|
+
} catch { /* Cache persistence is not completion authority. */ }
|
|
166
173
|
|
|
167
174
|
if (!failures.length) process.exit(0);
|
|
168
175
|
const lines = [`Completion gate: ${failures.length} done task(s) lack a verification receipt.`];
|
|
@@ -175,6 +182,14 @@ try {
|
|
|
175
182
|
reason: lines.slice(0, 8).join('\n')
|
|
176
183
|
})}\n`);
|
|
177
184
|
} catch (error) {
|
|
185
|
+
if (error.code === 'no_git_history') {
|
|
186
|
+
if (hasDoneTasks) emitBlock('Completion gate: repo has no commits yet; create the first commit, then record Base/Head after it.');
|
|
187
|
+
process.exit(0);
|
|
188
|
+
}
|
|
178
189
|
logCrash('spec-gate', error);
|
|
179
|
-
|
|
190
|
+
if (/^(?:runtime\.paths\.specs|configured specs root)/.test(error.message)) {
|
|
191
|
+
emitBlock(`Completion gate: invalid workflow packet detected (<specs>): ${error.message}. Fix the packet's plan.md or task files before completing tasks.`);
|
|
192
|
+
} else {
|
|
193
|
+
emitBlock(`Completion gate controlled failure: ${error.message}. Completion is blocked until the hook is repaired.`);
|
|
194
|
+
}
|
|
180
195
|
}
|
|
@@ -25,9 +25,16 @@ function cacheFile(projectRoot, sessionId) {
|
|
|
25
25
|
return path.join(hookStateDir(projectRoot), `tollgate-${key}.txt`);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
let mutationObserver = false;
|
|
28
29
|
try {
|
|
29
30
|
const payload = readPayload();
|
|
31
|
+
mutationObserver = payload?.hook_event_name === 'PostToolUse';
|
|
30
32
|
if (!payload) process.exit(0);
|
|
33
|
+
const context = getHookContext(payload);
|
|
34
|
+
const { touchSession } = require('./lib/spec-session-touch.cjs');
|
|
35
|
+
const touch = touchSession(payload, context);
|
|
36
|
+
// Mutation observers only persist paths. They must not inspect packet state or proof.
|
|
37
|
+
if (payload.hook_event_name === 'PostToolUse') process.exit(0);
|
|
31
38
|
const loaded = loadSharedPolicy();
|
|
32
39
|
if (!loaded.policy) {
|
|
33
40
|
logCrash('spec-state', loaded.error);
|
|
@@ -35,13 +42,14 @@ try {
|
|
|
35
42
|
process.exit(0);
|
|
36
43
|
}
|
|
37
44
|
const POLICY = loaded.policy;
|
|
45
|
+
require(path.join(path.dirname(loaded.path), 'provenance.cjs')).enableSnapshotMemo();
|
|
38
46
|
const receiptLoaded = loadSharedReceipt();
|
|
39
47
|
if (!receiptLoaded.receipt) throw receiptLoaded.error;
|
|
40
48
|
const RECEIPT = receiptLoaded.receipt;
|
|
41
|
-
const { projectRoot, runtime } =
|
|
49
|
+
const { projectRoot, runtime } = context;
|
|
42
50
|
if (runtime.spec?.tollgate === false) process.exit(0);
|
|
43
51
|
const { findLegacyPackets, resolveWorkflowCandidate } = require('./lib/spec-utils.cjs');
|
|
44
|
-
const sessionId =
|
|
52
|
+
const sessionId = touch.sessionId;
|
|
45
53
|
// A legacy packet (a specs/<x>/ directory without plan.md that the old flow left
|
|
46
54
|
// behind) is no longer read. Say so once per session instead of on every prompt.
|
|
47
55
|
const legacyPackets = findLegacyPackets(projectRoot, runtime);
|
|
@@ -62,9 +70,22 @@ try {
|
|
|
62
70
|
try { atomicWrite(noticeFile, `${JSON.stringify([...noticed, ...fresh].sort())}\n`); } catch { /* repeats the notice */ }
|
|
63
71
|
}
|
|
64
72
|
}
|
|
65
|
-
const explicitFeature = payload.
|
|
66
|
-
const explicitPath = payload.specPath
|
|
67
|
-
const
|
|
73
|
+
const explicitFeature = payload.explicitFeature ?? payload.featureName ?? payload.feature ?? null;
|
|
74
|
+
const explicitPath = payload.explicitPath ?? payload.specPath ?? payload.spec_path ?? payload.featurePath ?? null;
|
|
75
|
+
const hasExplicit = explicitFeature !== null || explicitPath !== null;
|
|
76
|
+
if (sessionId && !hasExplicit && touch.touched.length === 0) process.exit(0);
|
|
77
|
+
let resolved;
|
|
78
|
+
if (sessionId && !hasExplicit) {
|
|
79
|
+
const names = touch.names.length ? touch.names : touch.touched;
|
|
80
|
+
const candidates = names.map((name) => resolveWorkflowCandidate(projectRoot, runtime, name, null));
|
|
81
|
+
resolved = candidates.find((candidate) => candidate?.error);
|
|
82
|
+
const active = candidates.filter((candidate) => candidate && !candidate.error && !candidate.allTasksDone);
|
|
83
|
+
if (!resolved) resolved = active.length > 1
|
|
84
|
+
? { error: 'multiple_active', candidates: active.map((candidate) => candidate.featureName) }
|
|
85
|
+
: active[0] || null;
|
|
86
|
+
} else {
|
|
87
|
+
resolved = resolveWorkflowCandidate(projectRoot, runtime, explicitFeature, explicitPath);
|
|
88
|
+
}
|
|
68
89
|
if (!resolved) process.exit(0);
|
|
69
90
|
if (resolved.error === 'multiple_active') {
|
|
70
91
|
process.stdout.write(`> ⚠️ Multiple active specs detected: ${resolved.candidates.join(', ')}. Provide explicit feature target or resolve ambiguity. Tollgate paused.\n`);
|
|
@@ -153,6 +174,7 @@ try {
|
|
|
153
174
|
const lines = [
|
|
154
175
|
`### Spec state changed: \`${active.featureName}\``,
|
|
155
176
|
`- Phase: \`${phase}\` | Tasks: ${counts.done || 0} done / ${tasks.length} total`
|
|
177
|
+
+ (tasks.length > 0 ? ` (${counts.in_progress || 0} in_progress, ${counts.pending || 0} pending, ${counts.blocked || 0} blocked)` : '')
|
|
156
178
|
];
|
|
157
179
|
if (next) lines.push(`- Next unblocked: \`${next[0]}\``);
|
|
158
180
|
if (active.queueReady === false) {
|
|
@@ -165,6 +187,10 @@ try {
|
|
|
165
187
|
);
|
|
166
188
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
167
189
|
} catch (error) {
|
|
190
|
+
if (error.code === 'no_git_history') {
|
|
191
|
+
console.log('Repo has no commits yet; create the first commit, then record Base/Head after it.');
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
168
194
|
logCrash('spec-state', error);
|
|
169
|
-
emitControlledFailure(error.message);
|
|
195
|
+
if (!mutationObserver) emitControlledFailure(error.message);
|
|
170
196
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const { containsSecretKeyword } = require('./lib/secret-keywords.cjs');
|
|
6
7
|
const {
|
|
7
8
|
getHookContext,
|
|
8
9
|
logCrash,
|
|
@@ -34,6 +35,11 @@ function summary(value, limit = 600) {
|
|
|
34
35
|
return clean.length > limit ? `${clean.slice(0, limit)}…` : clean;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
function assistantSummary(value) {
|
|
39
|
+
const text = summary(value, Number.MAX_SAFE_INTEGER);
|
|
40
|
+
return containsSecretKeyword(text) ? '[omitted: possible secret]' : summary(text);
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
function normalizeTodos(items) {
|
|
38
44
|
if (!Array.isArray(items)) return [];
|
|
39
45
|
return items.map((item, index) => ({
|
|
@@ -74,11 +80,24 @@ function isTrackedTool(toolName) {
|
|
|
74
80
|
const name = String(toolName || '').toLowerCase();
|
|
75
81
|
return name === 'agent'
|
|
76
82
|
|| name === 'spawn_agent'
|
|
83
|
+
|| name === 'collaborationspawn_agent'
|
|
77
84
|
|| name === 'update_plan'
|
|
78
85
|
|| name.includes('task')
|
|
79
86
|
|| name.includes('todo');
|
|
80
87
|
}
|
|
81
88
|
|
|
89
|
+
function hasPriorContent(text) {
|
|
90
|
+
const filler = [
|
|
91
|
+
/^#{1,6}\s/,
|
|
92
|
+
/^<!--.*-->$/,
|
|
93
|
+
/^- \((?:No completed tasks recorded|No pending tasks recorded|All tasks completed|No file changes detected)\)$/,
|
|
94
|
+
/^- Completed at \d{2}:\d{2}:\d{2}$/,
|
|
95
|
+
/^- Completed without a result message\.$/,
|
|
96
|
+
];
|
|
97
|
+
return text.split('\n').map((line) => line.trim())
|
|
98
|
+
.some((line) => line && !filler.some((pattern) => pattern.test(line)));
|
|
99
|
+
}
|
|
100
|
+
|
|
82
101
|
function loadPrevious(projectRoot, sessionId) {
|
|
83
102
|
try {
|
|
84
103
|
const dir = stateDir(projectRoot, sessionId);
|
|
@@ -87,7 +106,9 @@ function loadPrevious(projectRoot, sessionId) {
|
|
|
87
106
|
if (!fs.existsSync(file)) return '';
|
|
88
107
|
const text = fs.readFileSync(file, 'utf8');
|
|
89
108
|
const generated = text.match(/<!-- Generated: (.+?) -->/)?.[1];
|
|
90
|
-
|
|
109
|
+
const timestamp = Date.parse(generated);
|
|
110
|
+
if (!generated || !Number.isFinite(timestamp) || Date.now() - timestamp > EXPIRY_MS) return '';
|
|
111
|
+
if (!hasPriorContent(text)) return '';
|
|
91
112
|
return text;
|
|
92
113
|
} catch {
|
|
93
114
|
return '';
|
|
@@ -118,7 +139,7 @@ try {
|
|
|
118
139
|
const data = readData(dir);
|
|
119
140
|
const input = payload.tool_input || {};
|
|
120
141
|
const response = payload.tool_response;
|
|
121
|
-
updateTaskState(data, input, response);
|
|
142
|
+
if (!['spawn_agent', 'collaborationspawn_agent'].includes(String(toolName).toLowerCase())) updateTaskState(data, input, response);
|
|
122
143
|
data.lastToolEvent = `${toolName}: ${summary(response) || summary(input)}`.slice(0, 700);
|
|
123
144
|
refreshGit(data, projectRoot);
|
|
124
145
|
writeData(dir, data);
|
|
@@ -135,7 +156,7 @@ try {
|
|
|
135
156
|
data.agentResults.push({
|
|
136
157
|
id,
|
|
137
158
|
type: String(payload.agent_type || 'unknown'),
|
|
138
|
-
message:
|
|
159
|
+
message: assistantSummary(payload.last_assistant_message)
|
|
139
160
|
});
|
|
140
161
|
refreshGit(data, projectRoot);
|
|
141
162
|
writeData(dir, data);
|
|
@@ -147,7 +168,7 @@ try {
|
|
|
147
168
|
if (event === 'Stop') {
|
|
148
169
|
withStateLock(projectRoot, sessionId, (dir) => {
|
|
149
170
|
const data = readData(dir);
|
|
150
|
-
data.lastAssistant =
|
|
171
|
+
data.lastAssistant = assistantSummary(payload.last_assistant_message);
|
|
151
172
|
refreshGit(data, projectRoot);
|
|
152
173
|
writeData(dir, data);
|
|
153
174
|
archive(dir);
|
package/src/codex/hooks.json
CHANGED
|
@@ -10,11 +10,6 @@
|
|
|
10
10
|
"command": "node \".codex/hooks/session.cjs\"",
|
|
11
11
|
"commandWindows": "node \".codex/hooks/session.cjs\""
|
|
12
12
|
},
|
|
13
|
-
{
|
|
14
|
-
"type": "command",
|
|
15
|
-
"command": "node \".codex/hooks/docs-sync.cjs\"",
|
|
16
|
-
"commandWindows": "node \".codex/hooks/docs-sync.cjs\""
|
|
17
|
-
},
|
|
18
13
|
{
|
|
19
14
|
"type": "command",
|
|
20
15
|
"command": "node \".codex/hooks/state.cjs\"",
|
|
@@ -23,6 +18,18 @@
|
|
|
23
18
|
]
|
|
24
19
|
}
|
|
25
20
|
],
|
|
21
|
+
"PreCompact": [
|
|
22
|
+
{
|
|
23
|
+
"matcher": "auto|manual",
|
|
24
|
+
"hooks": [
|
|
25
|
+
{
|
|
26
|
+
"type": "command",
|
|
27
|
+
"command": "node \".codex/hooks/precompact.cjs\"",
|
|
28
|
+
"commandWindows": "node \".codex/hooks/precompact.cjs\""
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
],
|
|
26
33
|
"SubagentStart": [
|
|
27
34
|
{
|
|
28
35
|
"matcher": "*",
|
|
@@ -57,13 +64,18 @@
|
|
|
57
64
|
"type": "command",
|
|
58
65
|
"command": "node \".codex/hooks/spec-state.cjs\"",
|
|
59
66
|
"commandWindows": "node \".codex/hooks/spec-state.cjs\""
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"type": "command",
|
|
70
|
+
"command": "node \".codex/hooks/ask-guard.cjs\"",
|
|
71
|
+
"commandWindows": "node \".codex/hooks/ask-guard.cjs\""
|
|
60
72
|
}
|
|
61
73
|
]
|
|
62
74
|
}
|
|
63
75
|
],
|
|
64
76
|
"PreToolUse": [
|
|
65
77
|
{
|
|
66
|
-
"matcher": "Bash|exec_command|apply_patch|
|
|
78
|
+
"matcher": "Bash|exec_command|shell|apply_patch|mcp__.*|collaborationspawn_agent|collaborationwait_agent",
|
|
67
79
|
"hooks": [
|
|
68
80
|
{
|
|
69
81
|
"type": "command",
|
|
@@ -76,11 +88,21 @@
|
|
|
76
88
|
"commandWindows": "node \".codex/hooks/inspect-block.cjs\""
|
|
77
89
|
}
|
|
78
90
|
]
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"matcher": "apply_patch",
|
|
94
|
+
"hooks": [
|
|
95
|
+
{
|
|
96
|
+
"type": "command",
|
|
97
|
+
"command": "node \".codex/hooks/ask-guard.cjs\"",
|
|
98
|
+
"commandWindows": "node \".codex/hooks/ask-guard.cjs\""
|
|
99
|
+
}
|
|
100
|
+
]
|
|
79
101
|
}
|
|
80
102
|
],
|
|
81
103
|
"PostToolUse": [
|
|
82
104
|
{
|
|
83
|
-
"matcher": "Agent|spawn_agent|update_plan
|
|
105
|
+
"matcher": "collaborationspawn_agent|Agent|spawn_agent|update_plan",
|
|
84
106
|
"hooks": [
|
|
85
107
|
{
|
|
86
108
|
"type": "command",
|
|
@@ -88,6 +110,16 @@
|
|
|
88
110
|
"commandWindows": "node \".codex/hooks/state.cjs\""
|
|
89
111
|
}
|
|
90
112
|
]
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"matcher": "apply_patch",
|
|
116
|
+
"hooks": [
|
|
117
|
+
{
|
|
118
|
+
"type": "command",
|
|
119
|
+
"command": "node \".codex/hooks/spec-state.cjs\"",
|
|
120
|
+
"commandWindows": "node \".codex/hooks/spec-state.cjs\""
|
|
121
|
+
}
|
|
122
|
+
]
|
|
91
123
|
}
|
|
92
124
|
],
|
|
93
125
|
"Stop": [
|
|
@@ -5,16 +5,22 @@ Hooks are instruction boundaries. Do not bypass or work around them.
|
|
|
5
5
|
## Privacy Block Hook
|
|
6
6
|
|
|
7
7
|
Codex cannot pause a blocked tool call for an interactive permission question.
|
|
8
|
-
CafeKit
|
|
9
|
-
phrase
|
|
8
|
+
CafeKit denies sensitive access and offers a short reply and an exact fallback
|
|
9
|
+
phrase. A grant is reusable for one hour in the same session for exactly the
|
|
10
|
+
approved paths; it is not tied to a tool.
|
|
10
11
|
|
|
11
12
|
Required flow:
|
|
12
13
|
|
|
13
14
|
1. Stop the blocked access and show the user the hook's reason.
|
|
14
|
-
2. Ask
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
2. Ask one privacy question as the only question at the end of the turn, naming
|
|
16
|
+
exactly the files shown in the denial. Do not add any other yes/no question.
|
|
17
|
+
3. Explain that the user's very next message may be `y`, `yes`, or `có` only
|
|
18
|
+
while exactly one request is pending; any other reply cancels the shortcut.
|
|
19
|
+
The exact `APPROVE CAFEKIT PRIVACY <id>` prompt remains the fallback.
|
|
20
|
+
4. Never send, repeat as a user message, or simulate either a short approval or
|
|
21
|
+
the exact fallback phrase. Wait for a real user prompt and the approval hook.
|
|
22
|
+
5. After approval, tell the user which paths were just granted before reading
|
|
23
|
+
them. Access only those exact paths during the one-hour session grant.
|
|
18
24
|
|
|
19
25
|
Never use another command, path, encoding, subprocess, or side channel to read a
|
|
20
26
|
privacy-blocked file without that explicit approval.
|
|
@@ -24,6 +30,7 @@ privacy-blocked file without that explicit approval.
|
|
|
24
30
|
- If a hook reports state drift, run the appropriate sync or audit flow before continuing.
|
|
25
31
|
- If a hook rejects task completion, keep the task `in_progress` or `blocked` until proof exists.
|
|
26
32
|
- Do not mark a task `done` unless the matching task file contains a valid verification receipt.
|
|
33
|
+
- Read `state-sync.md` for the receipt contract.
|
|
27
34
|
|
|
28
35
|
## Hook Failure Handling
|
|
29
36
|
|
|
@@ -38,16 +38,16 @@ export const BRIDGE_TIMEOUT_MS = 8000;
|
|
|
38
38
|
* Mirrors src/claude/settings/settings.json; `omp-bridge.test.js` fails if the two drift.
|
|
39
39
|
*/
|
|
40
40
|
export const DISPATCH = {
|
|
41
|
-
session_start: { event: 'SessionStart', hooks: ['session.cjs', '
|
|
41
|
+
session_start: { event: 'SessionStart', hooks: ['session.cjs', 'state.cjs'] },
|
|
42
42
|
session_compact: { event: 'SessionStart', hooks: ['session.cjs'], source: 'compact' },
|
|
43
43
|
session_before_compact: { event: 'PreCompact', hooks: ['precompact.cjs'] },
|
|
44
44
|
input: { event: 'UserPromptSubmit', hooks: ['secret-output-guardrail.cjs', 'rules.cjs', 'spec-state.cjs'] },
|
|
45
45
|
tool_call: { event: 'PreToolUse', hooks: ['privacy-block.cjs', 'inspect-block.cjs'] },
|
|
46
|
-
tool_result: { event: 'PostToolUse', hooks: ['state.cjs'] },
|
|
46
|
+
tool_result: { event: 'PostToolUse', hooks: ['state.cjs', 'spec-state.cjs'] },
|
|
47
47
|
session_stop: { event: 'Stop', hooks: ['spec-gate.cjs', 'state.cjs'] },
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
-
/**
|
|
50
|
+
/** Fallback id per extension load, for an omp whose context has no session manager. */
|
|
51
51
|
const SESSION_ID = `omp-${randomUUID()}`;
|
|
52
52
|
|
|
53
53
|
export function hooksDir() {
|
|
@@ -55,13 +55,21 @@ export function hooksDir() {
|
|
|
55
55
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'hooks');
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
function sessionIdFrom(ctx) {
|
|
59
|
+
try {
|
|
60
|
+
const id = ctx?.sessionManager?.getSessionId?.();
|
|
61
|
+
return typeof id === 'string' && id ? id : null;
|
|
62
|
+
} catch { return null; }
|
|
63
|
+
}
|
|
64
|
+
|
|
58
65
|
/** Build the Claude-shaped payload a CafeKit hook reads on stdin. */
|
|
59
66
|
export function shapePayload(kind, event, ctx) {
|
|
60
67
|
const entry = DISPATCH[kind];
|
|
61
68
|
const cwd = ctx?.cwd || process.cwd();
|
|
62
69
|
const payload = {
|
|
63
70
|
hook_event_name: entry.event,
|
|
64
|
-
|
|
71
|
+
// omp's own id first: session_stop carries it, and touch and state must stay in one place.
|
|
72
|
+
session_id: event?.session_id || sessionIdFrom(ctx) || SESSION_ID,
|
|
65
73
|
cwd,
|
|
66
74
|
};
|
|
67
75
|
switch (kind) {
|
|
@@ -14,32 +14,23 @@
|
|
|
14
14
|
* Exit: 0 (the JSON hook output carries the permission decision)
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
const fs = require('fs');
|
|
19
|
-
const path = require('path');
|
|
20
|
-
const { commandAccess } = require('./lib/privacy-command-analysis.cjs');
|
|
17
|
+
const EVALUATION_FAILURE = 'Sensitive access could not be evaluated safely.';
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
/\.pgpass$/i,
|
|
33
|
-
/kubeconfig/i,
|
|
34
|
-
/\.keystore$/i,
|
|
35
|
-
/\.jks$/i,
|
|
36
|
-
/auth\.json$/i,
|
|
37
|
-
/token(s)?\.json$/i
|
|
38
|
-
];
|
|
19
|
+
function askForPermission(reason) {
|
|
20
|
+
process.stdout.write(JSON.stringify({
|
|
21
|
+
hookSpecificOutput: {
|
|
22
|
+
hookEventName: 'PreToolUse',
|
|
23
|
+
// omp has no ask state on tool_call, so an ask under Claude becomes a denial here.
|
|
24
|
+
permissionDecision: 'deny',
|
|
25
|
+
permissionDecisionReason: reason
|
|
26
|
+
}
|
|
27
|
+
}) + '\n');
|
|
28
|
+
}
|
|
39
29
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
30
|
+
try {
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const { sensitivePaths, displayNames } = require('./lib/privacy-paths.cjs');
|
|
33
|
+
const { projectRoot: resolveProjectRoot } = require('./lib/project-root.cjs');
|
|
43
34
|
|
|
44
35
|
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
45
36
|
function readRuntime(cwd) {
|
|
@@ -51,200 +42,27 @@ try {
|
|
|
51
42
|
}
|
|
52
43
|
}
|
|
53
44
|
|
|
54
|
-
function isSafe(filePath) {
|
|
55
|
-
const base = path.basename(filePath);
|
|
56
|
-
return ALLOWED_EXEMPTIONS.some((rule) => rule.test(base) || rule.test(filePath));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function isSensitive(filePath) {
|
|
60
|
-
const base = path.basename(filePath);
|
|
61
|
-
return RESTRICTED_PATTERNS.some((rule) => rule.test(base) || rule.test(filePath));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Resolve a symlink to its real target so a harmless-looking name that points
|
|
66
|
-
* at a sensitive file cannot slip through the basename check. Returns null
|
|
67
|
-
* when the path cannot be resolved (missing file, broken link) — fail-open to
|
|
68
|
-
* the original-path check in that case. See isSymlinkToSensitive for dangling handling.
|
|
69
|
-
*/
|
|
70
|
-
function resolveTarget(filePath, cwd) {
|
|
71
|
-
try {
|
|
72
|
-
const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
73
|
-
return fs.realpathSync(requested);
|
|
74
|
-
} catch {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* When realpath fails, check whether the requested path is a symlink (or chain)
|
|
81
|
-
* whose lexical target is sensitive. Dangling link to protected target -> true (fail-closed ask);
|
|
82
|
-
* dangling benign link or link to allowed example/template -> false (allow).
|
|
83
|
-
* Limit hops to avoid loops; malformed/looping -> fail closed.
|
|
84
|
-
* TOCTOU note: check and use are not atomic; target could be swapped between this check and the actual read.
|
|
85
|
-
* This is a best-effort guardrail, not an arbitrary-shell security boundary. For strong isolation use OS sandbox.
|
|
86
|
-
*/
|
|
87
|
-
function isSymlinkToSensitive(filePath, cwd) {
|
|
88
|
-
try {
|
|
89
|
-
const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
90
|
-
let current = requested;
|
|
91
|
-
let hops = 0;
|
|
92
|
-
const MAX_HOPS = 8;
|
|
93
|
-
while (hops < MAX_HOPS) {
|
|
94
|
-
let stat;
|
|
95
|
-
try {
|
|
96
|
-
stat = fs.lstatSync(current);
|
|
97
|
-
} catch {
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
if (!stat.isSymbolicLink()) return false;
|
|
101
|
-
let linkTarget;
|
|
102
|
-
try {
|
|
103
|
-
linkTarget = fs.readlinkSync(current);
|
|
104
|
-
} catch {
|
|
105
|
-
return true;
|
|
106
|
-
}
|
|
107
|
-
if (isSensitive(linkTarget) && !isSafe(linkTarget)) return true;
|
|
108
|
-
if (isSafe(linkTarget)) return false;
|
|
109
|
-
const next = path.isAbsolute(linkTarget) ? linkTarget : path.resolve(path.dirname(current), linkTarget);
|
|
110
|
-
if (next === current) return true;
|
|
111
|
-
try {
|
|
112
|
-
const real = fs.realpathSync(next);
|
|
113
|
-
if (isSensitive(real) && !isSafe(real)) return true;
|
|
114
|
-
if (isSafe(real)) return false;
|
|
115
|
-
return false;
|
|
116
|
-
} catch {
|
|
117
|
-
current = next;
|
|
118
|
-
hops += 1;
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return true;
|
|
123
|
-
} catch {
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function extractBashPaths(command) {
|
|
129
|
-
// The analyzer parses the command and returns the operands of every reading
|
|
130
|
-
// command it recognises, nested shells and `find -exec` included. Treating
|
|
131
|
-
// every unresolved expansion as sensitive, as this did before, asked on
|
|
132
|
-
// `cat $FILE`, `wc -l *.cjs` and `cat ~/.zshrc` alike. Noise is what trains
|
|
133
|
-
// people to click through the prompt that matters.
|
|
134
|
-
// A heredoc whose delimiter is quoted (<<'BODY') is inert: the shell
|
|
135
|
-
// expands nothing inside it, so no word in the body is ever read. The
|
|
136
|
-
// analyzer has no notion of heredocs and parses the body as shell, so prose
|
|
137
|
-
// that quotes a command -- a PR body carrying `cat .env` in backticks --
|
|
138
|
-
// came back as a real command substitution. Blank those bodies out first.
|
|
139
|
-
// An unquoted delimiter (<<BODY) does expand, so it is left to the
|
|
140
|
-
// analyzer.
|
|
141
|
-
const scrubbed = String(command).replace(
|
|
142
|
-
/(<<-?[ \t]*)(['"])([A-Za-z_]\w*)\2[^\n]*\n[\s\S]*?\n[ \t]*\3\b/g,
|
|
143
|
-
'$1$2$3$2'
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
const paths = new Set(commandAccess(scrubbed).paths);
|
|
147
|
-
|
|
148
|
-
// Two shapes the analyzer cannot return. It drops any word carrying an
|
|
149
|
-
// expansion, so `~/.ssh/id_rsa` and `certs/*.pem` still have to be matched
|
|
150
|
-
// by name. And it only walks commands it recognises, so an unknown tool
|
|
151
|
-
// handed a secret through a file-shaped option, such as
|
|
152
|
-
// `docker compose --env-file .env`, is invisible to it.
|
|
153
|
-
const FILE_OPTION = /^--?[\w-]*(?:file|config|key|cert|identity)$/i;
|
|
154
|
-
const tokens = scrubbed.split(/[\s|;&<>"'()`]+/);
|
|
155
|
-
for (let index = 0; index < tokens.length; index += 1) {
|
|
156
|
-
const token = tokens[index];
|
|
157
|
-
const shaped = /^~|[*?[{]/.test(token);
|
|
158
|
-
const optionFed = index > 0 && FILE_OPTION.test(tokens[index - 1]);
|
|
159
|
-
if (!shaped && !optionFed) continue;
|
|
160
|
-
const value = token.replace(/^~/, '').replace(/^["'()`]+|["'(),]+$/g, '');
|
|
161
|
-
if (value && isSensitive(value) && !isSafe(value)) paths.add(value);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return [...paths];
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function extractPaths(toolName, input) {
|
|
168
|
-
const paths = [];
|
|
169
|
-
if (!input) return paths;
|
|
170
|
-
|
|
171
|
-
for (const key of ['file_path', 'path']) {
|
|
172
|
-
if (typeof input[key] === 'string' && input[key].trim()) {
|
|
173
|
-
paths.push(input[key].trim());
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
for (const key of ['paths', 'search_paths']) {
|
|
178
|
-
if (Array.isArray(input[key])) {
|
|
179
|
-
paths.push(...input[key].filter(Boolean));
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (toolName === 'Bash' && typeof input.command === 'string') {
|
|
184
|
-
paths.push(...extractBashPaths(input.command));
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return paths.filter(Boolean);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function askForPermission(filePath) {
|
|
191
|
-
const basename = path.basename(filePath);
|
|
192
|
-
process.stdout.write(JSON.stringify({
|
|
193
|
-
hookSpecificOutput: {
|
|
194
|
-
hookEventName: 'PreToolUse',
|
|
195
|
-
// omp has no ask state on tool_call, so an ask under Claude becomes a denial here.
|
|
196
|
-
permissionDecision: 'deny',
|
|
197
|
-
permissionDecisionReason: `Sensitive file access requires approval: ${basename}`
|
|
198
|
-
}
|
|
199
|
-
}) + '\n');
|
|
200
|
-
}
|
|
201
|
-
|
|
202
45
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
203
|
-
if (!stdin)
|
|
46
|
+
if (!stdin) {
|
|
47
|
+
askForPermission(EVALUATION_FAILURE);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
204
50
|
|
|
205
51
|
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
206
52
|
const data = normalizeHookPayload(JSON.parse(stdin));
|
|
207
53
|
const toolName = data.tool_name || '';
|
|
208
54
|
const toolInput = data.tool_input || {};
|
|
209
55
|
const cwd = typeof data.cwd === 'string' && data.cwd.trim() ? data.cwd : process.cwd();
|
|
210
|
-
const
|
|
56
|
+
const projectRoot = resolveProjectRoot(data);
|
|
57
|
+
const runtime = readRuntime(projectRoot);
|
|
211
58
|
|
|
212
59
|
if (runtime.privacyBlock === false) process.exit(0);
|
|
213
60
|
|
|
214
|
-
const paths =
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
for (const filePath of paths) {
|
|
218
|
-
const target = resolveTarget(filePath, cwd);
|
|
219
|
-
|
|
220
|
-
// The real target is authoritative: an exempt-looking alias must not hide
|
|
221
|
-
// a sensitive target, while a real .env.example target remains safe.
|
|
222
|
-
if (target) {
|
|
223
|
-
if (isSensitive(target) && !isSafe(target)) {
|
|
224
|
-
askForPermission(filePath);
|
|
225
|
-
process.exit(0);
|
|
226
|
-
}
|
|
227
|
-
if (isSafe(target)) continue;
|
|
228
|
-
} else {
|
|
229
|
-
if (isSymlinkToSensitive(filePath, cwd)) {
|
|
230
|
-
askForPermission(filePath);
|
|
231
|
-
process.exit(0);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (isSafe(filePath) || !isSensitive(filePath)) continue;
|
|
236
|
-
askForPermission(filePath);
|
|
237
|
-
process.exit(0);
|
|
238
|
-
}
|
|
61
|
+
const paths = sensitivePaths(toolName, toolInput, cwd);
|
|
62
|
+
if (paths.length) askForPermission(`Sensitive file access requires approval: ${displayNames(paths)}`);
|
|
239
63
|
|
|
240
64
|
process.exit(0);
|
|
241
65
|
} catch (_) {
|
|
242
|
-
|
|
243
|
-
hookSpecificOutput: {
|
|
244
|
-
hookEventName: 'PreToolUse',
|
|
245
|
-
permissionDecision: 'deny',
|
|
246
|
-
permissionDecisionReason: 'Sensitive access could not be evaluated safely.'
|
|
247
|
-
}
|
|
248
|
-
}) + '\n');
|
|
66
|
+
askForPermission(EVALUATION_FAILURE);
|
|
249
67
|
process.exit(0);
|
|
250
68
|
}
|