@haposoft/cafekit 0.18.1 → 0.18.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/README.md +12 -1
- package/bin/__tests__/adv-skill.test.js +371 -0
- package/bin/__tests__/agent-authority.test.js +38 -0
- package/bin/__tests__/agent-instructions.test.js +54 -0
- package/bin/__tests__/claude-plugin-build.test.js +151 -0
- 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 +812 -12
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +198 -58
- package/bin/__tests__/codex-plugin-build.test.js +230 -0
- package/bin/__tests__/codex-plugin-context.test.js +135 -0
- package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
- 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-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -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 +81 -21
- package/bin/__tests__/migrate-apply.test.js +373 -0
- package/bin/__tests__/migrate-cli.test.js +152 -0
- package/bin/__tests__/migrate-merged.test.js +269 -0
- package/bin/__tests__/migrate-plan.test.js +271 -0
- package/bin/__tests__/omp-bridge.test.js +147 -17
- package/bin/__tests__/omp-hooks.test.js +35 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +501 -0
- package/bin/__tests__/usage-hook-retired.test.js +29 -9
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +549 -0
- package/bin/install.js +124 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +141 -12
- package/bin/lib/context.js +6 -1
- package/bin/lib/lock.js +98 -18
- package/bin/lib/managed-writer.js +16 -10
- package/bin/lib/migrate-apply.js +202 -0
- package/bin/lib/migrate-merged.js +342 -0
- package/bin/lib/migrate-plan.js +419 -0
- package/bin/lib/plugin-inventory.js +33 -0
- package/bin/phases/claude-runtime.js +93 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +134 -33
- package/bin/phases/codex-runtime.js +110 -85
- package/bin/phases/copy-payload.js +50 -4
- package/bin/phases/omp-runtime.js +4 -26
- package/bin/phases/root-config.js +5 -2
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +5 -1
- package/src/claude/agents/advisor.md +55 -0
- package/src/claude/agents/debugger.md +2 -3
- package/src/claude/agents/deployer.md +2 -1
- package/src/claude/agents/docs-keeper.md +12 -0
- package/src/claude/agents/inspector.md +1 -1
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/researcher.md +0 -1
- package/src/claude/agents/spec-maker.md +4 -1
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
- package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
- package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
- package/src/claude/hooks/__tests__/precompact.test.js +213 -11
- package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
- 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 +11 -7
- package/src/claude/hooks/inspect-block.cjs +20 -66
- package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/plugin-options.cjs +47 -0
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
- package/src/claude/hooks/lib/privacy-paths.cjs +673 -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 +80 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/plugin-context.cjs +80 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +25 -206
- package/src/claude/hooks/rules.cjs +10 -29
- package/src/claude/hooks/session.cjs +37 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +44 -59
- package/src/claude/hooks/state.cjs +101 -52
- package/src/claude/migration-manifest.json +14 -2
- package/src/claude/plugin/commands/setup-statusline.md +12 -0
- package/src/claude/plugin/core.md +71 -0
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/manage-docs.md +1 -2
- package/src/claude/rules/review-audit-self-decision.md +1 -1
- package/src/claude/rules/skill-workflow-routing.md +1 -1
- package/src/claude/rules/state-sync.md +14 -0
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
- package/src/claude/scripts/provenance.cjs +54 -16
- package/src/claude/scripts/setup-statusline.cjs +88 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/adv/SKILL.md +139 -0
- package/src/claude/skills/adv/references/optional-modes.md +41 -0
- package/src/claude/skills/adv/references/ultra-mode.md +29 -0
- 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/brainstorm/SKILL.md +1 -1
- package/src/claude/skills/code-review/SKILL.md +4 -1
- package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
- package/src/claude/skills/develop/SKILL.md +5 -3
- package/src/claude/skills/develop/references/quality-gate.md +1 -1
- package/src/claude/skills/fix/SKILL.md +2 -2
- package/src/claude/skills/git/SKILL.md +5 -3
- package/src/claude/skills/git/references/merge-pr.md +125 -0
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/claude/skills/specs/references/review.md +1 -1
- package/src/claude/skills/specs/references/templates.md +2 -0
- package/src/claude/skills/vibe/SKILL.md +69 -0
- package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
- package/src/claude/skills/vibe/references/report-templates.md +84 -0
- package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
- package/src/codex/AGENTS.md +9 -1
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +15 -8
- package/src/codex/hooks/lib/hook-context.cjs +118 -4
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/plugin-options.cjs +2 -0
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +116 -17
- 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/state-store.cjs +5 -4
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +40 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +11 -216
- package/src/codex/hooks/rules.cjs +14 -34
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +59 -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/plugin/core.md +76 -0
- package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
- package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/codex/scripts/setup-agents.cjs +124 -0
- package/src/common/AGENTS.md +1 -1
- package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
- package/src/omp/hooks/privacy-block.cjs +26 -207
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { packetAt, namedPackets, explicitFailure } = require('./spec-touch.cjs');
|
|
7
|
+
const { atomicWrite, assertConfiguredSpecsPath, hookStateDir } = require('./hook-context.cjs');
|
|
8
|
+
|
|
9
|
+
function sessionIdentity(payload) {
|
|
10
|
+
return [payload.session_id, payload.sessionId, payload.sessionID, payload.session?.id]
|
|
11
|
+
.find((value) => typeof value === 'string' && value.trim()) || null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function patchPaths(command) {
|
|
15
|
+
if (typeof command !== 'string') return [];
|
|
16
|
+
const lines = command.trim().split(/\r?\n/);
|
|
17
|
+
if (lines.shift() !== '*** Begin Patch' || lines.pop() !== '*** End Patch') return [];
|
|
18
|
+
const paths = [];
|
|
19
|
+
let operation = null;
|
|
20
|
+
let body = false;
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
const header = line.match(/^\*\*\* (Add|Update|Delete) File: (.+)$/);
|
|
23
|
+
if (header) {
|
|
24
|
+
if (operation && operation !== 'Delete' && !body) return [];
|
|
25
|
+
operation = header[1]; body = operation === 'Delete';
|
|
26
|
+
paths.push(header[2]);
|
|
27
|
+
} else if (/^\*\*\* Move to: .+/.test(line) && operation === 'Update' && !body) {
|
|
28
|
+
paths.push(line.slice('*** Move to: '.length));
|
|
29
|
+
} else if (operation === 'Add' && /^\+/.test(line)) {
|
|
30
|
+
body = true;
|
|
31
|
+
} else if (operation === 'Update' && /^(?:@@|[ +\-]|\*\*\* End of File$)/.test(line)) {
|
|
32
|
+
body = true;
|
|
33
|
+
} else {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return operation && body ? paths : [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function responseText(response) {
|
|
41
|
+
if (typeof response === 'string') return response;
|
|
42
|
+
if (!response || typeof response !== 'object') return '';
|
|
43
|
+
const value = response.output ?? response.result ?? response.message ?? response.content;
|
|
44
|
+
if (typeof value === 'string') return value;
|
|
45
|
+
return Array.isArray(value) ? value.map((item) => item?.text || '').join('\n') : '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function successfulMutation(payload) {
|
|
49
|
+
const response = payload.tool_response;
|
|
50
|
+
if (payload.hook_event_name !== 'PostToolUse' || explicitFailure(payload) || response == null) return false;
|
|
51
|
+
const text = responseText(response).trim();
|
|
52
|
+
if (/^(?:error\b|failed\b|failure\b|apply_patch verification failed)/im.test(text)) return false;
|
|
53
|
+
// Contract: github.com/openai/codex/blob/main/codex-rs/core/src/tools/context.rs
|
|
54
|
+
// Native ApplyPatchToolOutput emits a string summary to PostToolUse, whereas its
|
|
55
|
+
// code-mode return is {}. Unknown/empty output is not observed successful mutation.
|
|
56
|
+
const nativeSuccess = /^Success\. Updated the following files:\r?\n[AMD] .+/m.test(text);
|
|
57
|
+
if (payload.tool_name === 'apply_patch') return nativeSuccess;
|
|
58
|
+
return nativeSuccess || response?.ok === true || response?.success === true
|
|
59
|
+
|| /^(?:success\b|.*\bupdated successfully[.!]?\s*$)/i.test(text);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mutationPaths(payload) {
|
|
63
|
+
if (!successfulMutation(payload)) return [];
|
|
64
|
+
const input = payload.tool_input || {};
|
|
65
|
+
if (payload.tool_name === 'apply_patch') return patchPaths(input.command);
|
|
66
|
+
if (!['Edit', 'Write', 'MultiEdit'].includes(payload.tool_name)) return [];
|
|
67
|
+
return [input.file_path, input.path, ...(Array.isArray(input.edits) ? input.edits.map((edit) => edit?.file_path || edit?.path) : [])]
|
|
68
|
+
.filter((value) => typeof value === 'string');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function touchSession(payload, context) {
|
|
72
|
+
const sessionId = sessionIdentity(payload);
|
|
73
|
+
const specsRoot = assertConfiguredSpecsPath(context.projectRoot, context.runtime);
|
|
74
|
+
if (!sessionId) return { sessionId, names: [], touched: [], specsRoot };
|
|
75
|
+
const key = crypto.createHash('sha256').update(`${context.projectRoot}\0${sessionId}`).digest('hex');
|
|
76
|
+
const file = path.join(hookStateDir(context.projectRoot), `spec-touched-${key}.json`);
|
|
77
|
+
let previous = [];
|
|
78
|
+
try { previous = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { /* first touch */ }
|
|
79
|
+
const touched = new Set(Array.isArray(previous) ? previous.filter((name) => typeof name === 'string') : []);
|
|
80
|
+
let names;
|
|
81
|
+
if (payload.hook_event_name === 'PostToolUse') {
|
|
82
|
+
names = mutationPaths(payload).map((value) => packetAt(value, specsRoot, context.sessionCwd)).filter(Boolean);
|
|
83
|
+
} else {
|
|
84
|
+
let packets = [];
|
|
85
|
+
try { packets = fs.readdirSync(specsRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name); } catch { /* absent root */ }
|
|
86
|
+
names = namedPackets(payload.prompt || payload.user_prompt, packets, specsRoot, context.projectRoot);
|
|
87
|
+
const feature = payload.explicitFeature ?? payload.featureName ?? payload.feature;
|
|
88
|
+
if (typeof feature === 'string' && feature.trim() && !/[\\/]/.test(feature) && !['.', '..', '_shared'].includes(feature)) names.push(feature);
|
|
89
|
+
const explicitPath = payload.explicitPath ?? payload.specPath ?? payload.spec_path ?? payload.featurePath;
|
|
90
|
+
const fromPath = packetAt(explicitPath, specsRoot, context.projectRoot);
|
|
91
|
+
if (fromPath) names.push(fromPath);
|
|
92
|
+
}
|
|
93
|
+
for (const name of names) touched.add(name);
|
|
94
|
+
if (names.length) {
|
|
95
|
+
try { atomicWrite(file, `${JSON.stringify([...touched].sort())}\n`); } catch { /* no shared fallback */ }
|
|
96
|
+
}
|
|
97
|
+
return { sessionId, names: [...new Set(names)], touched: [...touched], specsRoot };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { touchSession, sessionIdentity };
|
|
@@ -4,7 +4,7 @@ const crypto = require('crypto');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { execFileSync } = require('child_process');
|
|
7
|
-
const { atomicWrite } = require('./hook-context.cjs');
|
|
7
|
+
const { atomicWrite, pluginRoot, pluginProjectDir } = require('./hook-context.cjs');
|
|
8
8
|
|
|
9
9
|
const MAX_ARCHIVES = 5;
|
|
10
10
|
const MAX_AGENTS = 3;
|
|
@@ -18,9 +18,10 @@ function sessionKey(sessionId) {
|
|
|
18
18
|
|
|
19
19
|
function stateDir(projectRoot, sessionId) {
|
|
20
20
|
const key = sessionKey(sessionId);
|
|
21
|
-
return
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
if (!key) return null;
|
|
22
|
+
return pluginRoot()
|
|
23
|
+
? path.join(pluginProjectDir(projectRoot, 'session-state'), key)
|
|
24
|
+
: path.join(projectRoot, '.codex', 'session-state', key);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
function acquireLock(dir) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('../../../claude/hooks/lib/update-check.cjs');
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { execFileSync } = require('child_process');
|
|
7
|
+
const { atomicWrite, hookStateDir, logCrash, readPayload, resolveProjectRoot } = require('./lib/hook-context.cjs');
|
|
8
|
+
|
|
9
|
+
function gitLine(projectRoot, args) {
|
|
10
|
+
try {
|
|
11
|
+
return execFileSync('git', args, {
|
|
12
|
+
cwd: projectRoot, encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
13
|
+
}).trim() || null;
|
|
14
|
+
} catch { return null; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const payload = readPayload();
|
|
19
|
+
if (!payload) process.exit(0);
|
|
20
|
+
const sessionId = [payload.session_id, payload.sessionId, payload.sessionID, payload.session?.id]
|
|
21
|
+
.find(value => typeof value === 'string' && value.trim());
|
|
22
|
+
if (!sessionId) process.exit(0);
|
|
23
|
+
const projectRoot = resolveProjectRoot(payload);
|
|
24
|
+
const status = gitLine(projectRoot, ['status', '--porcelain']);
|
|
25
|
+
// These anchors are re-derivable facts, never proof or authorization.
|
|
26
|
+
const record = {
|
|
27
|
+
captured_at: new Date().toISOString(),
|
|
28
|
+
session_id: sessionId,
|
|
29
|
+
trigger: typeof payload.trigger === 'string' ? payload.trigger : null,
|
|
30
|
+
project_root: projectRoot,
|
|
31
|
+
branch: gitLine(projectRoot, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
32
|
+
head: gitLine(projectRoot, ['rev-parse', '--short', 'HEAD']),
|
|
33
|
+
dirty_count: status === null ? null : status.split('\n').filter(line => line.trim()).length,
|
|
34
|
+
};
|
|
35
|
+
const key = crypto.createHash('sha256').update(sessionId).digest('hex');
|
|
36
|
+
atomicWrite(path.join(hookStateDir(projectRoot), `compact-recovery-${key}.json`), `${JSON.stringify(record)}\n`);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.message === 'hook payload is empty') process.exit(0);
|
|
39
|
+
logCrash('precompact', error);
|
|
40
|
+
}
|
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const {
|
|
5
|
-
APPROVAL_PREFIX,
|
|
6
|
-
approvePending,
|
|
7
|
-
cleanup
|
|
8
|
-
} = require('./lib/privacy-state.cjs');
|
|
9
|
-
const {
|
|
10
|
-
getHookContext,
|
|
11
|
-
readPayload
|
|
12
|
-
} = require('./lib/hook-context.cjs');
|
|
13
|
-
|
|
14
4
|
function context(message) {
|
|
15
5
|
process.stdout.write(`${JSON.stringify({
|
|
16
6
|
hookSpecificOutput: {
|
|
@@ -20,10 +10,20 @@ function context(message) {
|
|
|
20
10
|
})}\n`);
|
|
21
11
|
}
|
|
22
12
|
|
|
13
|
+
let projectRoot, sessionId, state;
|
|
23
14
|
try {
|
|
15
|
+
const { resolveProjectRoot, readPayload } = require('./lib/hook-context.cjs');
|
|
24
16
|
const data = readPayload();
|
|
25
17
|
if (!data) process.exit(0);
|
|
26
|
-
|
|
18
|
+
projectRoot = resolveProjectRoot(data);
|
|
19
|
+
sessionId = data.session_id;
|
|
20
|
+
let claimed = { ok: false, requestIds: [] };
|
|
21
|
+
try {
|
|
22
|
+
state = require('./lib/privacy-state.cjs');
|
|
23
|
+
claimed = state.claimShortMarkers(projectRoot, sessionId);
|
|
24
|
+
} catch { /* the outer catch retries invalidation, never approval */ }
|
|
25
|
+
if (!claimed.ok) throw new Error('Short approval markers could not be claimed safely');
|
|
26
|
+
const { APPROVAL_PREFIX, approvePending, cleanup } = state;
|
|
27
27
|
// A pasted prompt arrives with whatever whitespace the terminal added around
|
|
28
28
|
// it. Rejecting on a trailing newline made the approval look broken while the
|
|
29
29
|
// id it named was still perfectly valid; the exactness that matters is the
|
|
@@ -31,20 +31,25 @@ try {
|
|
|
31
31
|
const prompt = String(data.prompt || '').trim();
|
|
32
32
|
cleanup(projectRoot);
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
if (!
|
|
34
|
+
const shortApproval = new Set(['y', 'yes', 'có']).has(prompt.normalize('NFC').toLowerCase());
|
|
35
|
+
if (!shortApproval && !prompt.startsWith(APPROVAL_PREFIX)) process.exit(0);
|
|
36
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) {
|
|
36
37
|
context('CafeKit privacy approval rejected: this hook invocation had no Codex session ID.');
|
|
37
38
|
process.exit(0);
|
|
38
39
|
}
|
|
39
|
-
|
|
40
|
-
if (
|
|
40
|
+
let requestId;
|
|
41
|
+
if (shortApproval) {
|
|
42
|
+
if (claimed.requestIds.length !== 1) process.exit(0);
|
|
43
|
+
requestId = claimed.requestIds[0];
|
|
44
|
+
} else requestId = prompt.slice(APPROVAL_PREFIX.length);
|
|
45
|
+
if (!/^[a-f0-9]{24}$/.test(requestId)) {
|
|
41
46
|
context('CafeKit privacy approval rejected: the prompt did not exactly match a pending request.');
|
|
42
47
|
process.exit(0);
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
const result = approvePending({
|
|
46
51
|
projectRoot,
|
|
47
|
-
sessionId
|
|
52
|
+
sessionId,
|
|
48
53
|
requestId
|
|
49
54
|
});
|
|
50
55
|
if (!result.ok) {
|
|
@@ -54,10 +59,16 @@ try {
|
|
|
54
59
|
|
|
55
60
|
context(
|
|
56
61
|
`User approved the pending sensitive access (${result.displayName}). ` +
|
|
57
|
-
`The grant is session-bound, bound to exactly those paths, and expires in one hour
|
|
62
|
+
`The grant is session-bound, bound to exactly those paths, and expires in one hour. ` +
|
|
63
|
+
`Tell the user which paths were just granted (${result.displayName}) before reading them.`
|
|
58
64
|
);
|
|
59
65
|
process.exit(0);
|
|
60
66
|
} catch {
|
|
67
|
+
try {
|
|
68
|
+
if (projectRoot && typeof sessionId === 'string' && sessionId.trim()) {
|
|
69
|
+
(state || require('./lib/privacy-state.cjs')).claimShortMarkers(projectRoot, sessionId);
|
|
70
|
+
}
|
|
71
|
+
} catch { /* no approval follows an invalidation failure */ }
|
|
61
72
|
context('CafeKit privacy approval failed safely; no token was issued.');
|
|
62
73
|
process.exit(0);
|
|
63
74
|
}
|
|
@@ -1,218 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
const {
|
|
7
|
-
APPROVAL_PREFIX,
|
|
8
|
-
createPending,
|
|
9
|
-
hasGrant
|
|
10
|
-
} = require('./lib/privacy-state.cjs');
|
|
11
|
-
const {
|
|
12
|
-
getHookContext,
|
|
13
|
-
readPayload
|
|
14
|
-
} = require('./lib/hook-context.cjs');
|
|
15
|
-
const { commandAccess } = require('./lib/privacy-command-analysis.cjs');
|
|
16
|
-
|
|
17
|
-
const RESTRICTED = [
|
|
18
|
-
/^\.env(?:[.\[*?{]|$)/i,
|
|
19
|
-
/^credentials/i,
|
|
20
|
-
/secrets?\.(ya?ml|json)$/i,
|
|
21
|
-
/\.pem$/i,
|
|
22
|
-
/\.key$/i,
|
|
23
|
-
/\.p12$/i,
|
|
24
|
-
/\.pfx$/i,
|
|
25
|
-
/^id_(rsa|ed25519|ecdsa|dsa)$/i,
|
|
26
|
-
/\.netrc$/i,
|
|
27
|
-
/\.pgpass$/i,
|
|
28
|
-
/kubeconfig/i,
|
|
29
|
-
/\.keystore$/i,
|
|
30
|
-
/\.jks$/i,
|
|
31
|
-
/auth\.json$/i,
|
|
32
|
-
/token(s)?\.json$/i
|
|
33
|
-
];
|
|
34
|
-
|
|
35
|
-
const EXEMPT = [/\.env\.(example|sample|template|test)$/i];
|
|
36
|
-
|
|
37
|
-
function isPathKey(key) {
|
|
38
|
-
return /^(?:file_?paths?|paths?|search_?paths?|director(?:y|ies)|cwd|root|locations?)$/i
|
|
39
|
-
.test(key);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function matchesAny(filePath, rules) {
|
|
43
|
-
const base = path.basename(filePath);
|
|
44
|
-
return rules.some((rule) => rule.test(base) || rule.test(filePath));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function isSafe(filePath) {
|
|
48
|
-
return matchesAny(filePath, EXEMPT);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function isSensitive(filePath) {
|
|
52
|
-
return matchesAny(filePath, RESTRICTED);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function resolveTarget(filePath, cwd) {
|
|
56
|
-
try {
|
|
57
|
-
const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
58
|
-
return fs.realpathSync(requested);
|
|
59
|
-
} catch {
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
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
|
-
}
|
|
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);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return [...paths];
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function extractPatchPaths(patch) {
|
|
152
|
-
return [...String(patch).matchAll(
|
|
153
|
-
/^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:)\s+(.+)$/gm
|
|
154
|
-
)]
|
|
155
|
-
.map((match) => match[1].trim())
|
|
156
|
-
.filter(Boolean);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function shellInput(input) {
|
|
160
|
-
if (typeof input?.command === 'string') return input.command;
|
|
161
|
-
if (typeof input?.cmd === 'string') return input.cmd;
|
|
162
|
-
return '';
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function patchInput(input) {
|
|
166
|
-
if (typeof input === 'string') return input;
|
|
167
|
-
for (const key of ['command', 'patch', 'input']) {
|
|
168
|
-
if (typeof input?.[key] === 'string') return input[key];
|
|
169
|
-
}
|
|
170
|
-
return '';
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function collectPathValues(value, key, out) {
|
|
174
|
-
if (typeof value === 'string' && isPathKey(key)) {
|
|
175
|
-
out.push(value);
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
if (Array.isArray(value)) {
|
|
179
|
-
if (isPathKey(key)) out.push(...value.filter((item) => typeof item === 'string'));
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
if (!value || typeof value !== 'object') return;
|
|
183
|
-
for (const [childKey, childValue] of Object.entries(value)) {
|
|
184
|
-
collectPathValues(childValue, childKey, out);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function extractPaths(toolName, input) {
|
|
189
|
-
const paths = [];
|
|
190
|
-
collectPathValues(input, '', paths);
|
|
191
|
-
const normalizedTool = String(toolName).toLowerCase();
|
|
192
|
-
if (['bash', 'exec_command', 'shell'].includes(normalizedTool)) {
|
|
193
|
-
paths.push(...extractCommandPaths(shellInput(input)));
|
|
194
|
-
} else if (['apply_patch', 'edit', 'write'].includes(normalizedTool)) {
|
|
195
|
-
paths.push(...extractPatchPaths(patchInput(input)));
|
|
196
|
-
}
|
|
197
|
-
return [...new Set(paths.filter(Boolean))];
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function sensitiveRequest(filePath, cwd) {
|
|
201
|
-
const target = resolveTarget(filePath, cwd);
|
|
202
|
-
if (target) {
|
|
203
|
-
if (isSensitive(target) && !isSafe(target)) return true;
|
|
204
|
-
if (isSafe(target)) return false;
|
|
205
|
-
} else {
|
|
206
|
-
if (isSymlinkToSensitive(filePath, cwd)) return true;
|
|
207
|
-
}
|
|
208
|
-
return !isSafe(filePath) && isSensitive(filePath);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function sensitivePaths(toolName, input, cwd) {
|
|
212
|
-
return extractPaths(toolName, input)
|
|
213
|
-
.filter((filePath) => sensitiveRequest(filePath, cwd));
|
|
214
|
-
}
|
|
215
|
-
|
|
216
4
|
function deny(reason) {
|
|
217
5
|
process.stdout.write(`${JSON.stringify({
|
|
218
6
|
hookSpecificOutput: {
|
|
@@ -224,10 +12,16 @@ function deny(reason) {
|
|
|
224
12
|
}
|
|
225
13
|
|
|
226
14
|
try {
|
|
15
|
+
const { APPROVAL_PREFIX, createPending, hasGrant } = require('./lib/privacy-state.cjs');
|
|
16
|
+
const { pluginRoot, resolveProjectRoot, readRuntime, resolveSessionCwd, readPayload } = require('./lib/hook-context.cjs');
|
|
17
|
+
const { sensitivePaths } = require('./lib/privacy-paths.cjs');
|
|
227
18
|
const data = readPayload();
|
|
228
19
|
if (!data) process.exit(0);
|
|
229
|
-
const
|
|
230
|
-
|
|
20
|
+
const projectRoot = resolveProjectRoot(data);
|
|
21
|
+
const runtime = readRuntime(projectRoot);
|
|
22
|
+
const sessionCwd = resolveSessionCwd(data);
|
|
23
|
+
// A plugin is trusted once for every repository, so a repository cannot switch it off.
|
|
24
|
+
if (runtime.privacyBlock === false && !pluginRoot()) process.exit(0);
|
|
231
25
|
|
|
232
26
|
const filePaths = sensitivePaths(data.tool_name || '', data.tool_input || {}, sessionCwd);
|
|
233
27
|
const effectivePaths = filePaths;
|
|
@@ -250,8 +44,9 @@ try {
|
|
|
250
44
|
const pending = createPending(grantInput);
|
|
251
45
|
deny(
|
|
252
46
|
`Sensitive file access denied: ${pending.displayName}. ` +
|
|
253
|
-
`To
|
|
254
|
-
`
|
|
47
|
+
`To allow exactly these paths for one hour of this Codex session, the user replies ` +
|
|
48
|
+
`y (or yes / có) as their very next message; any other reply cancels that shortcut, ` +
|
|
49
|
+
`and it only works while this is the only pending request. Fallback exact prompt: ` +
|
|
255
50
|
`${APPROVAL_PREFIX}${pending.requestId}`
|
|
256
51
|
);
|
|
257
52
|
process.exit(0);
|
|
@@ -1,40 +1,15 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const crypto = require('crypto');
|
|
4
3
|
const fs = require('fs');
|
|
5
4
|
const path = require('path');
|
|
6
5
|
const {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
pluginRoot,
|
|
7
|
+
resolveProjectRoot,
|
|
9
8
|
logCrash,
|
|
10
9
|
readPayload,
|
|
11
10
|
resolveProjectPath
|
|
12
11
|
} = require('./lib/hook-context.cjs');
|
|
13
|
-
|
|
14
|
-
function reservationFile(projectRoot, sessionId) {
|
|
15
|
-
const key = crypto.createHash('sha256')
|
|
16
|
-
.update(String(sessionId || 'unknown'))
|
|
17
|
-
.digest('hex')
|
|
18
|
-
.slice(0, 16);
|
|
19
|
-
return path.join(hookStateDir(projectRoot), `rules-${key}.json`);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Reserve one injection slot per session. Returns null on reservation errors. */
|
|
23
|
-
function reserveSession(projectRoot, sessionId) {
|
|
24
|
-
if (!sessionId) return null;
|
|
25
|
-
try {
|
|
26
|
-
const file = reservationFile(projectRoot, sessionId);
|
|
27
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
28
|
-
fs.writeFileSync(file, JSON.stringify({ sessionId: String(sessionId) }), {
|
|
29
|
-
encoding: 'utf8',
|
|
30
|
-
flag: 'wx',
|
|
31
|
-
mode: 0o600
|
|
32
|
-
});
|
|
33
|
-
return true;
|
|
34
|
-
} catch (error) {
|
|
35
|
-
return error.code === 'EEXIST' ? false : null;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
12
|
+
const { reserveSession } = require('./lib/rules-reservation.cjs');
|
|
38
13
|
|
|
39
14
|
/**
|
|
40
15
|
* Body of the output style for `codingLevel`, or null.
|
|
@@ -46,7 +21,8 @@ function reserveSession(projectRoot, sessionId) {
|
|
|
46
21
|
function readCodingStyle(projectRoot, level) {
|
|
47
22
|
if (!Number.isInteger(level) || level < 0 || level > 5) return null;
|
|
48
23
|
try {
|
|
49
|
-
|
|
24
|
+
// The plugin ships the styles in its own tree; an npx install copies them into .codex.
|
|
25
|
+
const dir = pluginRoot() ? path.join(pluginRoot(), 'output-styles') : path.join(projectRoot, '.codex', 'output-styles');
|
|
50
26
|
const match = fs.readdirSync(dir).find((name) => name.startsWith(`coding-level-${level}-`));
|
|
51
27
|
if (!match) return null;
|
|
52
28
|
const raw = fs.readFileSync(path.join(dir, match), 'utf8');
|
|
@@ -70,13 +46,17 @@ function readRuntime(projectRoot) {
|
|
|
70
46
|
try {
|
|
71
47
|
const payload = readPayload();
|
|
72
48
|
if (!payload) process.exit(0);
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
const runtime = readRuntime(projectRoot);
|
|
49
|
+
const projectRoot = resolveProjectRoot(payload);
|
|
50
|
+
// An npx install always writes runtime.json; under the plugin it is optional.
|
|
51
|
+
const runtime = readRuntime(projectRoot) || (pluginRoot() ? {} : null);
|
|
76
52
|
if (runtime === null) process.exit(0);
|
|
53
|
+
if (reserveSession(projectRoot, payload.session_id) !== true) process.exit(0);
|
|
77
54
|
|
|
78
|
-
|
|
79
|
-
|
|
55
|
+
// Under the plugin a cloned repository's runtime.json reaches model context, so its
|
|
56
|
+
// locale strings are reduced to one short plain line; npx keeps them as written.
|
|
57
|
+
const clean = pluginRoot() ? require('./lib/plugin-options.cjs').sanitizeLine : (value) => value;
|
|
58
|
+
const respondLang = clean(runtime.locale?.responseLanguage || '');
|
|
59
|
+
const thinkLang = clean(runtime.locale?.thinkingLanguage || '') || (respondLang ? 'en' : '');
|
|
80
60
|
const plansPath = resolveProjectPath(projectRoot, runtime.paths?.plans, 'plans');
|
|
81
61
|
const docsPath = resolveProjectPath(projectRoot, runtime.paths?.docs, 'docs');
|
|
82
62
|
const specsPath = resolveProjectPath(projectRoot, runtime.paths?.specs, 'specs');
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
const { logCrash, readPayload } = require('./lib/hook-context.cjs');
|
|
18
|
-
const { REMINDER, containsSecretKeyword } = require('./lib/secret-keywords.cjs');
|
|
19
18
|
|
|
20
19
|
try {
|
|
20
|
+
const { REMINDER, containsSecretKeyword } = require('./lib/secret-keywords.cjs');
|
|
21
21
|
const payload = readPayload();
|
|
22
22
|
if (!payload) process.exit(0);
|
|
23
23
|
|