@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
|
@@ -18,6 +18,88 @@ const PROJECT_ROOT = (() => {
|
|
|
18
18
|
}
|
|
19
19
|
})();
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Plugin root when this hook runs from the Codex plugin tree, else null.
|
|
23
|
+
*
|
|
24
|
+
* Codex exports PLUGIN_ROOT (and CLAUDE_PLUGIN_ROOT) to plugin hooks. An npx-installed
|
|
25
|
+
* hook can inherit those variables from a parent process, so the plugin is recognized
|
|
26
|
+
* only when this file really lives under that root.
|
|
27
|
+
*/
|
|
28
|
+
function pluginRoot() {
|
|
29
|
+
const declared = process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT;
|
|
30
|
+
if (!declared) return null;
|
|
31
|
+
try {
|
|
32
|
+
const root = fs.realpathSync(declared);
|
|
33
|
+
const self = fs.realpathSync(path.resolve(__dirname, '..', '..'));
|
|
34
|
+
return self === root ? root : null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Where the plugin keeps state for every repository: PLUGIN_DATA, or a per-user temp
|
|
42
|
+
* directory. The temp directory must be a real 0700 directory this user owns; anything
|
|
43
|
+
* else throws, so privacy decisions fail closed instead of trusting a planted path.
|
|
44
|
+
*/
|
|
45
|
+
function pluginStateRoot() {
|
|
46
|
+
const data = process.env.PLUGIN_DATA || process.env.CLAUDE_PLUGIN_DATA;
|
|
47
|
+
if (data && path.isAbsolute(data)) {
|
|
48
|
+
fs.mkdirSync(data, { recursive: true, mode: 0o700 });
|
|
49
|
+
return fs.realpathSync(data);
|
|
50
|
+
}
|
|
51
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
|
|
52
|
+
const dir = path.join(require('os').tmpdir(), `cafekit-codex-plugin-${uid}`);
|
|
53
|
+
try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
54
|
+
const stat = fs.lstatSync(dir);
|
|
55
|
+
const owned = typeof process.getuid !== 'function' || stat.uid === process.getuid();
|
|
56
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || !owned || (stat.mode & 0o077) !== 0) {
|
|
57
|
+
throw new Error(`unsafe CafeKit plugin state directory: ${dir}`);
|
|
58
|
+
}
|
|
59
|
+
return dir;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Per-repository state directory under the plugin state root. */
|
|
63
|
+
function pluginProjectDir(projectRoot, name) {
|
|
64
|
+
const key = crypto.createHash('sha256').update(String(projectRoot)).digest('hex').slice(0, 16);
|
|
65
|
+
return path.join(pluginStateRoot(), 'projects', key, name);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* True when the repository runs CafeKit's own npx-installed Codex hooks: its
|
|
70
|
+
* .codex/hooks.json parses, and every CafeKit launcher in it names a hook script that
|
|
71
|
+
* exists inside this repository. A committed hooks.json from another machine, or a
|
|
72
|
+
* planted file with no real hooks behind it, must not switch the plugin's gates off.
|
|
73
|
+
*/
|
|
74
|
+
function hasProjectCafekitHooks(projectRoot) {
|
|
75
|
+
try {
|
|
76
|
+
const config = JSON.parse(fs.readFileSync(path.join(projectRoot, '.codex', 'hooks.json'), 'utf8'));
|
|
77
|
+
const hooksDir = fs.realpathSync(path.join(projectRoot, '.codex', 'hooks'));
|
|
78
|
+
const commands = Object.values(config?.hooks || {}).flat()
|
|
79
|
+
.flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : []))
|
|
80
|
+
.map((handler) => String(handler?.command || ''));
|
|
81
|
+
const scripts = commands
|
|
82
|
+
.map((command) => command.match(/'([^']*[/\\]\.codex[/\\]hooks[/\\][a-z0-9-]+\.cjs)'\s*$/)?.[1])
|
|
83
|
+
.filter(Boolean);
|
|
84
|
+
if (scripts.length === 0) return false;
|
|
85
|
+
// The npx launcher names its script by the exact path <realpath(root)>/.codex/hooks.
|
|
86
|
+
// Anything else (a relative path, `..`, a symlinked alias, /proc/self/cwd) can
|
|
87
|
+
// resolve against whatever cwd the hook runs in, so it never counts.
|
|
88
|
+
const expectedDir = path.join(fs.realpathSync(projectRoot), '.codex', 'hooks');
|
|
89
|
+
return scripts.every((script) => {
|
|
90
|
+
if (!path.isAbsolute(script) || path.dirname(script) !== expectedDir) return false;
|
|
91
|
+
try {
|
|
92
|
+
const real = fs.realpathSync(script);
|
|
93
|
+
return path.dirname(real) === hooksDir && fs.statSync(real).isFile();
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
21
103
|
function readPayload() {
|
|
22
104
|
const raw = fs.readFileSync(0, 'utf8').trim();
|
|
23
105
|
if (!raw) throw new Error('hook payload is empty');
|
|
@@ -40,6 +122,33 @@ function resolveSessionCwd(payload) {
|
|
|
40
122
|
}
|
|
41
123
|
}
|
|
42
124
|
|
|
125
|
+
function gitTopLevel(cwd) {
|
|
126
|
+
try {
|
|
127
|
+
const out = require('child_process').execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
128
|
+
cwd, encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore']
|
|
129
|
+
}).trim();
|
|
130
|
+
return out ? fs.realpathSync(out) : cwd;
|
|
131
|
+
} catch {
|
|
132
|
+
return cwd;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The repository a hook acts on. An npx install sits inside it (PROJECT_ROOT); the plugin
|
|
138
|
+
* lives in a shared cache, so it takes the repository from the payload's cwd. Beside an
|
|
139
|
+
* active npx install the plugin stands down, so one set of gates and one privacy store
|
|
140
|
+
* decide; `onStandDown` lets SessionStart say so once.
|
|
141
|
+
*/
|
|
142
|
+
function resolveProjectRoot(payload, { onStandDown } = {}) {
|
|
143
|
+
if (!pluginRoot()) return PROJECT_ROOT;
|
|
144
|
+
const root = gitTopLevel(resolveSessionCwd(payload));
|
|
145
|
+
if (hasProjectCafekitHooks(root)) {
|
|
146
|
+
if (typeof onStandDown === 'function') onStandDown(root);
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
return root;
|
|
150
|
+
}
|
|
151
|
+
|
|
43
152
|
function readRuntime(projectRoot = PROJECT_ROOT) {
|
|
44
153
|
try {
|
|
45
154
|
const file = path.join(projectRoot, '.codex', 'runtime.json');
|
|
@@ -84,10 +193,11 @@ function assertConfiguredSpecsPath(projectRoot, runtime) {
|
|
|
84
193
|
}
|
|
85
194
|
|
|
86
195
|
function getHookContext(payload) {
|
|
87
|
-
const
|
|
88
|
-
|
|
196
|
+
const projectRoot = resolveProjectRoot(payload);
|
|
197
|
+
const runtime = readRuntime(projectRoot);
|
|
198
|
+
assertConfiguredSpecsPath(projectRoot, runtime);
|
|
89
199
|
return {
|
|
90
|
-
projectRoot
|
|
200
|
+
projectRoot,
|
|
91
201
|
sessionCwd: resolveSessionCwd(payload),
|
|
92
202
|
runtime
|
|
93
203
|
};
|
|
@@ -123,6 +233,7 @@ function atomicWrite(file, content, mode = 0o600) {
|
|
|
123
233
|
* `.codex` at any depth. Source-tree runs therefore write to a temp directory.
|
|
124
234
|
*/
|
|
125
235
|
function hookStateDir(projectRoot) {
|
|
236
|
+
if (pluginRoot()) return projectRoot ? pluginProjectDir(projectRoot, 'logs') : path.join(pluginStateRoot(), 'logs');
|
|
126
237
|
const root = projectRoot || PROJECT_ROOT;
|
|
127
238
|
return String(root).includes(`${path.sep}packages${path.sep}spec${path.sep}src`)
|
|
128
239
|
? path.join(require('os').tmpdir(), 'cafekit-hook-logs', 'codex')
|
|
@@ -131,7 +242,7 @@ function hookStateDir(projectRoot) {
|
|
|
131
242
|
|
|
132
243
|
function logCrash(hook, error) {
|
|
133
244
|
try {
|
|
134
|
-
const dir = hookStateDir(PROJECT_ROOT);
|
|
245
|
+
const dir = pluginRoot() ? hookStateDir(null) : hookStateDir(PROJECT_ROOT);
|
|
135
246
|
fs.mkdirSync(dir, { recursive: true });
|
|
136
247
|
fs.appendFileSync(
|
|
137
248
|
path.join(dir, 'hook-log.jsonl'),
|
|
@@ -149,6 +260,9 @@ function logCrash(hook, error) {
|
|
|
149
260
|
|
|
150
261
|
module.exports = {
|
|
151
262
|
PROJECT_ROOT,
|
|
263
|
+
pluginRoot,
|
|
264
|
+
pluginProjectDir,
|
|
265
|
+
resolveProjectRoot,
|
|
152
266
|
atomicWrite,
|
|
153
267
|
getHookContext,
|
|
154
268
|
hookStateDir,
|
|
@@ -1,199 +1 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const BLOCKED_DIRS = new Set([
|
|
4
|
-
'node_modules', 'dist', 'build', '.next', '.nuxt', '.output',
|
|
5
|
-
'__pycache__', '.venv', 'venv', 'vendor', 'target',
|
|
6
|
-
'.git', 'coverage', '.nyc_output'
|
|
7
|
-
]);
|
|
8
|
-
const BROAD_GLOBS = [/^\*\*\/\*$/, /^\*\*\.\w+$/, /^\*\*\/\*\.\w+$/];
|
|
9
|
-
const PATH_KEYS = new RegExp(
|
|
10
|
-
'^(?:file_?paths?|paths?|search_?paths?|'
|
|
11
|
-
+ '(?:source|target|destination)_?paths?|dirs?|director(?:y|ies)|'
|
|
12
|
-
+ 'folders?|cwd|root|locations?|uris?)$',
|
|
13
|
-
'i'
|
|
14
|
-
);
|
|
15
|
-
const GLOB_KEYS = /(?:^|_)(?:glob|globs|pattern|patterns)$/i;
|
|
16
|
-
function clean(value) {
|
|
17
|
-
return String(value || '').trim().replace(/^['"`]+|['"`,:]+$/g, '');
|
|
18
|
-
}
|
|
19
|
-
function blockedSegment(value) {
|
|
20
|
-
return clean(value)
|
|
21
|
-
.replace(/\\/g, '/')
|
|
22
|
-
.split('/')
|
|
23
|
-
.find((segment) => BLOCKED_DIRS.has(segment));
|
|
24
|
-
}
|
|
25
|
-
function isBroadGlob(value) {
|
|
26
|
-
const candidate = clean(value);
|
|
27
|
-
return BROAD_GLOBS.some((rule) => rule.test(candidate));
|
|
28
|
-
}
|
|
29
|
-
function collectStructured(value, key, paths, globs) {
|
|
30
|
-
if (typeof value === 'string') {
|
|
31
|
-
if (PATH_KEYS.test(key)) paths.push(value);
|
|
32
|
-
if (GLOB_KEYS.test(key)) globs.push(value);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
if (Array.isArray(value)) {
|
|
36
|
-
if (PATH_KEYS.test(key)) paths.push(...value.filter((item) => typeof item === 'string'));
|
|
37
|
-
if (GLOB_KEYS.test(key)) globs.push(...value.filter((item) => typeof item === 'string'));
|
|
38
|
-
for (const item of value) collectStructured(item, '', paths, globs);
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
if (!value || typeof value !== 'object') return;
|
|
42
|
-
for (const [childKey, child] of Object.entries(value)) {
|
|
43
|
-
collectStructured(child, childKey, paths, globs);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
function shellTokens(command) {
|
|
47
|
-
const tokens = [];
|
|
48
|
-
let token = '';
|
|
49
|
-
let quote = '';
|
|
50
|
-
for (let index = 0; index < command.length; index += 1) {
|
|
51
|
-
const char = command[index];
|
|
52
|
-
if (quote) {
|
|
53
|
-
if (char === quote) quote = '';
|
|
54
|
-
else if (char === '\\' && quote === '"' && index + 1 < command.length) {
|
|
55
|
-
token += command[index += 1];
|
|
56
|
-
} else token += char;
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
if (char === '"' || char === "'") {
|
|
60
|
-
quote = char;
|
|
61
|
-
} else if (char === '\n') {
|
|
62
|
-
if (token) tokens.push(token);
|
|
63
|
-
token = '';
|
|
64
|
-
tokens.push('|');
|
|
65
|
-
} else if (/\s/.test(char)) {
|
|
66
|
-
if (token) tokens.push(token);
|
|
67
|
-
token = '';
|
|
68
|
-
} else if (';&|'.includes(char)) {
|
|
69
|
-
if (token) tokens.push(token);
|
|
70
|
-
token = '';
|
|
71
|
-
const pair = command.slice(index, index + 2);
|
|
72
|
-
if (pair === '&&' || pair === '||') index += 1;
|
|
73
|
-
tokens.push('|');
|
|
74
|
-
} else {
|
|
75
|
-
token += char;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
if (token) tokens.push(token);
|
|
79
|
-
return tokens;
|
|
80
|
-
}
|
|
81
|
-
function commandGroups(command) {
|
|
82
|
-
const groups = [[]];
|
|
83
|
-
for (const token of shellTokens(command)) {
|
|
84
|
-
if (token === '|') groups.push([]);
|
|
85
|
-
else groups[groups.length - 1].push(clean(token));
|
|
86
|
-
}
|
|
87
|
-
return groups.filter((group) => group.length);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function positional(tokens, optionsWithValue = new Set()) {
|
|
91
|
-
const out = [];
|
|
92
|
-
for (let index = 1; index < tokens.length; index += 1) {
|
|
93
|
-
const token = tokens[index];
|
|
94
|
-
if (optionsWithValue.has(token)) index += 1;
|
|
95
|
-
else if (!token.startsWith('-') || token === '-') out.push(token);
|
|
96
|
-
}
|
|
97
|
-
return out;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function readerOptionPaths(name, tokens) {
|
|
101
|
-
if (!['rg', 'grep', 'sed'].includes(name)) return [];
|
|
102
|
-
const paths = [];
|
|
103
|
-
for (let index = 1; index < tokens.length; index += 1) {
|
|
104
|
-
const token = tokens[index];
|
|
105
|
-
if (token === '-f' || token === '--file') {
|
|
106
|
-
if (tokens[index + 1]) paths.push(tokens[index + 1]);
|
|
107
|
-
index += 1;
|
|
108
|
-
} else if (token.startsWith('--file=')) {
|
|
109
|
-
paths.push(token.slice('--file='.length));
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return paths;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function findRoots(tokens) {
|
|
116
|
-
const roots = [];
|
|
117
|
-
let index = 1;
|
|
118
|
-
while (['-H', '-L', '-P'].includes(tokens[index]) || /^-O\d+$/.test(tokens[index] || '')) {
|
|
119
|
-
index += 1;
|
|
120
|
-
}
|
|
121
|
-
if (tokens[index] === '-D') index += 2;
|
|
122
|
-
if (tokens[index] === '--') index += 1;
|
|
123
|
-
for (; index < tokens.length; index += 1) {
|
|
124
|
-
const token = tokens[index];
|
|
125
|
-
if (token.startsWith('-') || ['!', '(', ')'].includes(token)) break;
|
|
126
|
-
roots.push(token);
|
|
127
|
-
}
|
|
128
|
-
return roots;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function commandPaths(group, globs) {
|
|
132
|
-
const commandIndex = group.findIndex((token) => (
|
|
133
|
-
token && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)
|
|
134
|
-
));
|
|
135
|
-
if (commandIndex < 0) return [];
|
|
136
|
-
const tokens = group.slice(commandIndex);
|
|
137
|
-
while (['sudo', 'env', 'time', 'command'].includes(tokens[0])) tokens.shift();
|
|
138
|
-
if (!tokens.length) return [];
|
|
139
|
-
const name = tokens[0].replace(/\\/g, '/').split('/').pop();
|
|
140
|
-
const optionPaths = readerOptionPaths(name, tokens);
|
|
141
|
-
|
|
142
|
-
for (let index = 1; index < tokens.length; index += 1) {
|
|
143
|
-
const token = tokens[index];
|
|
144
|
-
if (token === '-g' || token === '--glob' || token === '--iglob') {
|
|
145
|
-
if (tokens[index + 1]) globs.push(tokens[index + 1]);
|
|
146
|
-
} else if (/^--(?:i?glob)=/.test(token)) {
|
|
147
|
-
globs.push(token.slice(token.indexOf('=') + 1));
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (name === 'rg' || name === 'grep') {
|
|
152
|
-
const values = positional(tokens, new Set([
|
|
153
|
-
'-e', '--regexp', '-f', '--file', '-g', '--glob', '--iglob',
|
|
154
|
-
'-t', '--type', '-T', '--type-not'
|
|
155
|
-
]));
|
|
156
|
-
const explicitPattern = tokens.some((token) => token === '-e' || token === '--regexp');
|
|
157
|
-
const fileMode = tokens.includes('--files');
|
|
158
|
-
return [...optionPaths, ...(explicitPattern || fileMode ? values : values.slice(1))];
|
|
159
|
-
}
|
|
160
|
-
if (name === 'sed') {
|
|
161
|
-
const values = positional(tokens, new Set(['-e', '--expression', '-f', '--file']));
|
|
162
|
-
const explicitScript = tokens.some((token) => (
|
|
163
|
-
['-e', '--expression', '-f', '--file'].includes(token)
|
|
164
|
-
));
|
|
165
|
-
return [...optionPaths, ...(explicitScript ? values : values.slice(1))];
|
|
166
|
-
}
|
|
167
|
-
if (name === 'find') return findRoots(tokens);
|
|
168
|
-
if (['cat', 'head', 'tail', 'ls', 'wc', 'du', 'tree', 'file', 'stat', 'cd', 'pushd', 'source', '.'].includes(name)) {
|
|
169
|
-
return positional(tokens, new Set(['-n', '--lines', '-c', '--bytes']));
|
|
170
|
-
}
|
|
171
|
-
return tokens.filter((token) => (
|
|
172
|
-
/^(?:\.{0,2}[\\/]|~[\\/])/.test(token)
|
|
173
|
-
|| (token.includes('/') && blockedSegment(token))
|
|
174
|
-
));
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function inspectInput(input) {
|
|
178
|
-
const paths = [];
|
|
179
|
-
const globs = [];
|
|
180
|
-
collectStructured(input, '', paths, globs);
|
|
181
|
-
const command = typeof input?.command === 'string'
|
|
182
|
-
? input.command
|
|
183
|
-
: typeof input?.cmd === 'string' ? input.cmd : '';
|
|
184
|
-
for (const group of commandGroups(command)) {
|
|
185
|
-
paths.push(...commandPaths(group, globs));
|
|
186
|
-
}
|
|
187
|
-
return {
|
|
188
|
-
broadGlob: globs.find(isBroadGlob),
|
|
189
|
-
blockedPath: paths.map((requested) => ({
|
|
190
|
-
requested,
|
|
191
|
-
blocked: blockedSegment(requested)
|
|
192
|
-
})).find((result) => result.blocked)
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
module.exports = {
|
|
197
|
-
BLOCKED_DIRS,
|
|
198
|
-
inspectInput
|
|
199
|
-
};
|
|
1
|
+
module.exports = require('../../../claude/hooks/lib/inspect-paths.cjs');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('../../../claude/hooks/lib/privacy-paths.cjs');
|
|
@@ -4,6 +4,8 @@ const crypto = require('crypto');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const PATH_SAFETY = require('./runtime-path-safety.cjs');
|
|
7
|
+
const { displayNames } = require('./privacy-paths.cjs');
|
|
8
|
+
const { pluginRoot, pluginProjectDir } = require('./hook-context.cjs');
|
|
7
9
|
|
|
8
10
|
// Codex cannot raise an interactive prompt from a hook -- PreToolUse honours no
|
|
9
11
|
// "ask" decision -- so approval costs a full round trip: the agent has to relay
|
|
@@ -15,7 +17,18 @@ const APPROVAL_PREFIX = 'APPROVE CAFEKIT PRIVACY ';
|
|
|
15
17
|
function stateDir(projectRoot) {
|
|
16
18
|
return path.join(projectRoot, '.codex', 'hooks', '.privacy');
|
|
17
19
|
}
|
|
20
|
+
// Under the plugin, approvals live in the plugin state root, one directory per
|
|
21
|
+
// repository; pluginProjectDir throws for an unsafe root so callers fail closed.
|
|
22
|
+
function pluginStateDir(projectRoot, create) {
|
|
23
|
+
const dir = pluginProjectDir(PATH_SAFETY.canonicalDirectory(projectRoot, 'privacy project root'), 'privacy');
|
|
24
|
+
if (create) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
25
|
+
else if (!fs.existsSync(dir)) return null;
|
|
26
|
+
const stat = fs.lstatSync(dir);
|
|
27
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('privacy state must be a regular directory');
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
18
30
|
function secureStateDir(projectRoot, create = false) {
|
|
31
|
+
if (pluginRoot()) return pluginStateDir(projectRoot, create);
|
|
19
32
|
const root = PATH_SAFETY.canonicalDirectory(projectRoot, 'privacy project root');
|
|
20
33
|
const hooks = path.join(root, '.codex', 'hooks');
|
|
21
34
|
try { PATH_SAFETY.assertComponentPath(root, hooks); }
|
|
@@ -24,7 +37,12 @@ function secureStateDir(projectRoot, create = false) {
|
|
|
24
37
|
throw error;
|
|
25
38
|
}
|
|
26
39
|
const dir = path.join(hooks, '.privacy');
|
|
27
|
-
if (create && !fs.existsSync(dir))
|
|
40
|
+
if (create && !fs.existsSync(dir)) {
|
|
41
|
+
// A parallel hook can create the directory between the check and the mkdir;
|
|
42
|
+
// the component and directory checks below still reject anything but a real directory.
|
|
43
|
+
try { fs.mkdirSync(dir, { mode: 0o700 }); }
|
|
44
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
45
|
+
}
|
|
28
46
|
try { PATH_SAFETY.assertComponentPath(root, dir); }
|
|
29
47
|
catch (error) {
|
|
30
48
|
if (!create && error.code === 'ENOENT') return null;
|
|
@@ -61,13 +79,6 @@ function samePathKeys(left, right) {
|
|
|
61
79
|
&& left.every((value, index) => value === right[index]);
|
|
62
80
|
}
|
|
63
81
|
|
|
64
|
-
function displayNames(filePaths) {
|
|
65
|
-
const names = [...new Set(filePaths.map((filePath) => path.basename(filePath)))].sort();
|
|
66
|
-
return names.length <= 3
|
|
67
|
-
? names.join(', ')
|
|
68
|
-
: `${names.slice(0, 3).join(', ')} (+${names.length - 3} more)`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
82
|
function atomicWrite(dir, file, payload) {
|
|
72
83
|
PATH_SAFETY.assertComponentPath(dir, path.dirname(file));
|
|
73
84
|
const temp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
@@ -84,14 +95,81 @@ function readJson(dir, file) {
|
|
|
84
95
|
}
|
|
85
96
|
}
|
|
86
97
|
|
|
98
|
+
function validPending(record, sessionId, requestId, now = Date.now()) {
|
|
99
|
+
return record?.requestId === requestId && record.sessionId === sessionId
|
|
100
|
+
&& Number.isFinite(record.expiresAt) && record.expiresAt > now
|
|
101
|
+
&& typeof record.displayName === 'string' && record.displayName.length > 0
|
|
102
|
+
&& Array.isArray(record.pathKeys) && record.pathKeys.length > 0
|
|
103
|
+
&& record.pathKeys.every(key => typeof key === 'string' && /^[a-f0-9]{64}$/.test(key));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeShortMarker(dir, record) {
|
|
107
|
+
const { requestId, sessionId, expiresAt } = record;
|
|
108
|
+
fs.writeFileSync(path.join(dir, `short-${hash(sessionId)}-${requestId}.json`),
|
|
109
|
+
`${JSON.stringify({ requestId, sessionId, expiresAt })}\n`,
|
|
110
|
+
{ encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Each prompt owns only markers it successfully renamed, never a shared mutable flag. */
|
|
114
|
+
function claimShortMarkers(projectRoot, sessionId) {
|
|
115
|
+
const requestIds = [];
|
|
116
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) return { ok: true, requestIds };
|
|
117
|
+
const dir = secureStateDir(projectRoot);
|
|
118
|
+
if (!dir) return { ok: true, requestIds };
|
|
119
|
+
const prefix = `short-${hash(sessionId)}-`;
|
|
120
|
+
let ok = true;
|
|
121
|
+
for (const name of fs.readdirSync(dir)) {
|
|
122
|
+
if (!name.startsWith(prefix) || !name.endsWith('.json')) continue;
|
|
123
|
+
const file = path.join(dir, name);
|
|
124
|
+
const claimed = path.join(dir, `claim-${crypto.randomBytes(12).toString('hex')}.tmp`);
|
|
125
|
+
const requestId = name.slice(prefix.length, -'.json'.length);
|
|
126
|
+
let moved = false;
|
|
127
|
+
try {
|
|
128
|
+
PATH_SAFETY.assertComponentPath(dir, file);
|
|
129
|
+
fs.renameSync(file, claimed);
|
|
130
|
+
moved = true;
|
|
131
|
+
requestIds.push(requestId);
|
|
132
|
+
PATH_SAFETY.assertComponentPath(dir, claimed);
|
|
133
|
+
const marker = JSON.parse(fs.readFileSync(claimed, 'utf8'));
|
|
134
|
+
if (!/^[a-f0-9]{24}$/.test(requestId) || marker.requestId !== requestId
|
|
135
|
+
|| marker.sessionId !== sessionId || !Number.isFinite(marker.expiresAt)
|
|
136
|
+
|| marker.expiresAt <= Date.now()) throw new Error('Invalid short approval marker');
|
|
137
|
+
} catch {
|
|
138
|
+
ok = false;
|
|
139
|
+
if (!moved) {
|
|
140
|
+
// A failed claim cancels the shortcut; deletion never counts as ownership.
|
|
141
|
+
try { fs.unlinkSync(file); }
|
|
142
|
+
catch {
|
|
143
|
+
let descriptor;
|
|
144
|
+
try {
|
|
145
|
+
PATH_SAFETY.canonicalRegularFile(dir, file, 'short approval marker');
|
|
146
|
+
descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW);
|
|
147
|
+
fs.ftruncateSync(descriptor, 0);
|
|
148
|
+
} catch { /* no permission follows a failed invalidation */ }
|
|
149
|
+
finally {
|
|
150
|
+
if (descriptor !== undefined) {
|
|
151
|
+
try { fs.closeSync(descriptor); } catch { /* already rejected */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
if (moved) {
|
|
158
|
+
try { fs.unlinkSync(claimed); } catch { ok = false; }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { ok, requestIds };
|
|
163
|
+
}
|
|
164
|
+
|
|
87
165
|
function cleanup(projectRoot, now = Date.now()) {
|
|
88
166
|
const dir = secureStateDir(projectRoot);
|
|
89
167
|
if (!dir) return;
|
|
90
168
|
for (const name of fs.readdirSync(dir)) {
|
|
91
|
-
if (!/^(pending|token)-[a-f0-9]
|
|
169
|
+
if (!/^(?:(?:pending|token)-[a-f0-9]+|short-[a-f0-9]{64}-[a-f0-9]{24})\.json$/.test(name)) continue;
|
|
92
170
|
const file = path.join(dir, name);
|
|
93
171
|
const record = readJson(dir, file);
|
|
94
|
-
if (!record || Number(record.expiresAt) <= now) {
|
|
172
|
+
if (!record || !Number.isFinite(record.expiresAt) || record.expiresAt <= now) {
|
|
95
173
|
try {
|
|
96
174
|
fs.unlinkSync(file);
|
|
97
175
|
} catch {
|
|
@@ -101,13 +179,20 @@ function cleanup(projectRoot, now = Date.now()) {
|
|
|
101
179
|
}
|
|
102
180
|
}
|
|
103
181
|
|
|
104
|
-
function clearState(projectRoot) {
|
|
182
|
+
function clearState(projectRoot, sessionId) {
|
|
105
183
|
try {
|
|
184
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) return;
|
|
106
185
|
const dir = secureStateDir(projectRoot);
|
|
107
186
|
if (!dir) return;
|
|
108
187
|
for (const name of fs.readdirSync(dir)) {
|
|
188
|
+
if (name.startsWith(`short-${hash(sessionId)}-`) && name.endsWith('.json')) {
|
|
189
|
+
try { fs.unlinkSync(path.join(dir, name)); } catch { /* concurrently claimed */ }
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
109
192
|
if (/^(pending|token)-[a-f0-9]+\.json$/.test(name)) {
|
|
110
|
-
|
|
193
|
+
const file = path.join(dir, name);
|
|
194
|
+
if (readJson(dir, file)?.sessionId !== sessionId) continue;
|
|
195
|
+
try { fs.unlinkSync(file); } catch { /* concurrently consumed */ }
|
|
111
196
|
}
|
|
112
197
|
}
|
|
113
198
|
} catch {
|
|
@@ -123,26 +208,38 @@ function createPending({
|
|
|
123
208
|
filePath
|
|
124
209
|
}) {
|
|
125
210
|
cleanup(projectRoot);
|
|
126
|
-
if (!sessionId) throw new Error('A Codex session ID is required');
|
|
211
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) throw new Error('A Codex session ID is required');
|
|
127
212
|
const paths = requestPaths(filePaths, filePath);
|
|
128
213
|
if (paths.length === 0) throw new Error('At least one sensitive path is required');
|
|
214
|
+
const pathKeys = requestPathKeys(paths, sessionCwd);
|
|
215
|
+
const dir = secureStateDir(projectRoot, true);
|
|
216
|
+
for (const name of fs.readdirSync(dir)) {
|
|
217
|
+
const match = name.match(/^pending-([a-f0-9]{24})\.json$/);
|
|
218
|
+
if (!match) continue;
|
|
219
|
+
const pending = readJson(dir, path.join(dir, name));
|
|
220
|
+
if (validPending(pending, sessionId, match[1]) && samePathKeys(pending.pathKeys, pathKeys)) {
|
|
221
|
+
try { writeShortMarker(dir, pending); }
|
|
222
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
223
|
+
return pending;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
129
226
|
const requestId = crypto.randomBytes(12).toString('hex');
|
|
130
227
|
const record = {
|
|
131
228
|
version: 2,
|
|
132
229
|
requestId,
|
|
133
230
|
sessionId: String(sessionId || ''),
|
|
134
|
-
pathKeys
|
|
231
|
+
pathKeys,
|
|
135
232
|
displayName: displayNames(paths),
|
|
136
233
|
expiresAt: Date.now() + TTL_MS
|
|
137
234
|
};
|
|
138
|
-
const dir = secureStateDir(projectRoot, true);
|
|
139
235
|
atomicWrite(dir, path.join(dir, `pending-${requestId}.json`), record);
|
|
236
|
+
writeShortMarker(dir, record);
|
|
140
237
|
return record;
|
|
141
238
|
}
|
|
142
239
|
|
|
143
240
|
function approvePending({ projectRoot, sessionId, requestId }) {
|
|
144
241
|
cleanup(projectRoot);
|
|
145
|
-
if (!sessionId) return { ok: false, reason: 'session' };
|
|
242
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) return { ok: false, reason: 'session' };
|
|
146
243
|
if (!/^[a-f0-9]{24}$/.test(requestId)) return { ok: false, reason: 'invalid' };
|
|
147
244
|
|
|
148
245
|
const dir = secureStateDir(projectRoot);
|
|
@@ -152,6 +249,7 @@ function approvePending({ projectRoot, sessionId, requestId }) {
|
|
|
152
249
|
if (!record) return { ok: false, reason: 'missing' };
|
|
153
250
|
if (record.sessionId !== String(sessionId || '')) return { ok: false, reason: 'session' };
|
|
154
251
|
if (Number(record.expiresAt) <= Date.now()) return { ok: false, reason: 'expired' };
|
|
252
|
+
if (!validPending(record, sessionId, requestId)) return { ok: false, reason: 'invalid' };
|
|
155
253
|
|
|
156
254
|
try {
|
|
157
255
|
fs.unlinkSync(pendingFile);
|
|
@@ -218,5 +316,6 @@ module.exports = {
|
|
|
218
316
|
approvePending,
|
|
219
317
|
hasGrant,
|
|
220
318
|
cleanup,
|
|
221
|
-
clearState
|
|
319
|
+
clearState,
|
|
320
|
+
claimShortMarkers
|
|
222
321
|
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { hookStateDir } = require('./hook-context.cjs');
|
|
7
|
+
|
|
8
|
+
function reservationFile(projectRoot, sessionId) {
|
|
9
|
+
const key = crypto.createHash('sha256')
|
|
10
|
+
.update(String(sessionId || 'unknown'))
|
|
11
|
+
.digest('hex')
|
|
12
|
+
.slice(0, 16);
|
|
13
|
+
return path.join(hookStateDir(projectRoot), `rules-${key}.json`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Reserve one injection slot per session. Returns null on reservation errors. */
|
|
17
|
+
function reserveSession(projectRoot, sessionId) {
|
|
18
|
+
if (!sessionId) return null;
|
|
19
|
+
try {
|
|
20
|
+
const file = reservationFile(projectRoot, sessionId);
|
|
21
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
22
|
+
fs.writeFileSync(file, JSON.stringify({ sessionId: String(sessionId) }), {
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
flag: 'wx',
|
|
25
|
+
mode: 0o600
|
|
26
|
+
});
|
|
27
|
+
return true;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return error.code === 'EEXIST' ? false : null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function clearSessionReservation(projectRoot, sessionId) {
|
|
34
|
+
if (!sessionId) return;
|
|
35
|
+
try { fs.rmSync(reservationFile(projectRoot, sessionId), { force: true }); } catch { /* fail-open */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { reserveSession, clearSessionReservation };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('../../../claude/hooks/lib/secret-keywords.cjs');
|