@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.
Files changed (93) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/claude-settings.test.js +123 -0
  3. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  4. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  5. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  6. package/bin/__tests__/codex-hooks.test.js +774 -6
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +192 -59
  9. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  10. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  11. package/bin/__tests__/codex-research-projection.test.js +70 -0
  12. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  13. package/bin/__tests__/develop-contract.test.js +1 -1
  14. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  15. package/bin/__tests__/inspect-paths.test.js +183 -0
  16. package/bin/__tests__/installer-safety.test.js +335 -0
  17. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  18. package/bin/__tests__/omp-bridge.test.js +66 -12
  19. package/bin/__tests__/omp-hooks.test.js +11 -5
  20. package/bin/__tests__/orca-session.test.js +7 -0
  21. package/bin/__tests__/privacy-paths.test.js +317 -0
  22. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  23. package/bin/install.js +25 -0
  24. package/bin/lib/codex-install.js +134 -12
  25. package/bin/lib/context.js +1 -1
  26. package/bin/phases/claude-runtime.js +91 -25
  27. package/bin/phases/claude-settings.js +22 -1
  28. package/bin/phases/codex-hooks.js +96 -33
  29. package/bin/phases/codex-runtime.js +45 -52
  30. package/bin/phases/copy-payload.js +1 -3
  31. package/package.json +1 -1
  32. package/src/claude/CLAUDE.md +4 -0
  33. package/src/claude/gitignore +3 -0
  34. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  35. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  36. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  37. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  38. package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
  39. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  40. package/src/claude/hooks/__tests__/state.test.js +207 -7
  41. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  42. package/src/claude/hooks/agent.cjs +7 -6
  43. package/src/claude/hooks/inspect-block.cjs +15 -63
  44. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  45. package/src/claude/hooks/lib/parser.cjs +1 -1
  46. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  47. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  48. package/src/claude/hooks/lib/project-root.cjs +15 -0
  49. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  50. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  51. package/src/claude/hooks/lib/update-check.cjs +77 -0
  52. package/src/claude/hooks/precompact.cjs +8 -15
  53. package/src/claude/hooks/privacy-block.cjs +23 -205
  54. package/src/claude/hooks/rules.cjs +6 -28
  55. package/src/claude/hooks/session.cjs +34 -117
  56. package/src/claude/hooks/spec-gate.cjs +9 -26
  57. package/src/claude/hooks/spec-state.cjs +43 -59
  58. package/src/claude/hooks/state.cjs +96 -51
  59. package/src/claude/migration-manifest.json +8 -1
  60. package/src/claude/rules/hook-protocols.md +1 -1
  61. package/src/claude/runtime.json +0 -3
  62. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  63. package/src/claude/scripts/provenance.cjs +14 -0
  64. package/src/claude/settings/settings.json +1 -1
  65. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  66. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  67. package/src/claude/skills/git/SKILL.md +1 -1
  68. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  69. package/src/codex/AGENTS.md +8 -0
  70. package/src/codex/hooks/ask-guard.cjs +54 -0
  71. package/src/codex/hooks/inspect-block.cjs +11 -7
  72. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  73. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  74. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  75. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  76. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  77. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  78. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  79. package/src/codex/hooks/lib/update-check.cjs +1 -0
  80. package/src/codex/hooks/precompact.cjs +39 -0
  81. package/src/codex/hooks/privacy-approval.cjs +28 -17
  82. package/src/codex/hooks/privacy-block.cjs +9 -215
  83. package/src/codex/hooks/rules.cjs +4 -30
  84. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  85. package/src/codex/hooks/session.cjs +47 -11
  86. package/src/codex/hooks/spec-gate.cjs +21 -6
  87. package/src/codex/hooks/spec-state.cjs +32 -6
  88. package/src/codex/hooks/state.cjs +25 -4
  89. package/src/codex/hooks.json +39 -7
  90. package/src/codex/rules/hook-protocols.md +13 -6
  91. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  92. package/src/omp/hooks/privacy-block.cjs +24 -206
  93. package/src/codex/rules/state-sync.md +0 -60
@@ -23,19 +23,10 @@ from typing import List, Dict, Any, Optional
23
23
  import csv
24
24
  import shutil
25
25
 
26
- # Import centralized environment resolver (works for both local and global installs)
27
- CLAUDE_ROOT = Path(__file__).parent.parent.parent.parent
28
- sys.path.insert(0, str(CLAUDE_ROOT / 'scripts'))
29
26
  try:
30
- from resolve_env import resolve_env
31
- CENTRALIZED_RESOLVER_AVAILABLE = True
27
+ from dotenv import load_dotenv
32
28
  except ImportError:
33
- # Fallback if centralized resolver not available
34
- CENTRALIZED_RESOLVER_AVAILABLE = False
35
- try:
36
- from dotenv import load_dotenv
37
- except ImportError:
38
- load_dotenv = None
29
+ load_dotenv = None
39
30
 
40
31
  # Import key rotation support
41
32
  sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'common'))
@@ -74,10 +65,7 @@ IMAGEN_MODELS = {
74
65
 
75
66
 
76
67
  def find_api_key() -> Optional[str]:
77
- """Find Gemini API key using centralized resolver or fallback.
78
-
79
- Uses ~/packages/spec/src/claude/scripts/resolve_env.py for consistent resolution across all skills.
80
- Falls back to local resolution if centralized resolver not available.
68
+ """Find Gemini API key using local environment resolution.
81
69
 
82
70
  Priority order (highest to lowest):
83
71
  1. process.env (runtime environment variables)
@@ -88,11 +76,6 @@ def find_api_key() -> Optional[str]:
88
76
  6. ~/packages/spec/src/claude/skills/.env (user shared)
89
77
  7. ~/packages/spec/src/claude/.env (user global)
90
78
  """
91
- if CENTRALIZED_RESOLVER_AVAILABLE:
92
- # Use centralized resolver (recommended)
93
- return resolve_env('GEMINI_API_KEY', skill='ai-multimodal')
94
-
95
- # Fallback: Local resolution (legacy)
96
79
  api_key = os.getenv('GEMINI_API_KEY')
97
80
  if api_key:
98
81
  return api_key
@@ -751,18 +734,13 @@ def batch_process(
751
734
  print("Error: GEMINI_API_KEY not found in any location")
752
735
  print("\nSearched locations (highest to lowest priority):")
753
736
  print(" 1. OS environment (process.env)")
754
- if CENTRALIZED_RESOLVER_AVAILABLE:
755
- from resolve_env import get_env_file_paths
756
- for i, (desc, path) in enumerate(get_env_file_paths('ai-multimodal'), 2):
757
- exists = "[OK]" if path.exists() else "[ ]"
758
- print(f" {i}. {exists} {path}")
759
- else:
760
- print(" 2-7. .env files (centralized resolver unavailable)")
737
+ print(" 2. Runtime .env file")
738
+ print(" 3. Shared skills .env file")
739
+ print(" 4. Skill-specific .env file")
761
740
  print("\nQuick fix — add your key to any .env file above:")
762
741
  print(" echo 'GEMINI_API_KEY=your-key' >> ~/packages/spec/src/claude/.env")
763
742
  print("\nOther options:")
764
743
  print(" - Run setup checker: python scripts/check_setup.py")
765
- print(" - Show full hierarchy: python ~/packages/spec/src/claude/scripts/resolve_env.py --show-hierarchy --skill ai-multimodal -v")
766
744
  print("\nFor key rotation, add multiple keys to any .env:")
767
745
  print(" GEMINI_API_KEY=key1")
768
746
  print(" GEMINI_API_KEY_2=key2")
@@ -24,7 +24,7 @@ Present options via AskUserQuestion — header "Git Operation": commit / push /
24
24
  `gh repo view --json defaultBranchRef` or `origin/HEAD`; never assume `main`/`develop`.
25
25
  - `finish`: fresh `git status` + verification, then present exactly 4 options:
26
26
  merge locally / push + PR / keep branch-worktree / discard (typed confirmation, as in the worktree lifecycle: `remove <dir-name>` / `delete <branch>`).
27
- - `worktree <desc>`: sibling dir `../<project>-<branch>` for isolated setup, branched from the current branch unless the user names a base, hydrating the installer-ignored `.claude/`, `.codex/`, `.agents/` it lacks. Git alone is enough (plus `rsync`); Orca, Herdr and Claude Code's own worktree are optional shortcuts, never required.
27
+ - `worktree <desc>`: sibling dir `../<project>-<branch>` for isolated setup, branched from the current branch unless the user names a base, hydrating the installer-ignored `.claude/`, `.codex/`, `.agents/` it lacks. Git alone is enough (plus `rsync`); Orca, Herdr and the host's own worktree are optional shortcuts, never required.
28
28
  - `worktree list | remove <dir> | prune`: lifecycle in `references/worktree-blueprint.md` — never lose uncommitted, ignored or unmerged work: before `git worktree remove`, check `git -C <dir> status --porcelain --ignored` and `git -C <dir> log --oneline <base>..HEAD`, never `--force`, and anything they show needs the typed `remove <dir-name>`; use `git worktree prune` for a missing directory, never `rm -rf`.
29
29
 
30
30
  ## Hard rules for `commit`
@@ -19,7 +19,7 @@ Tuỳ theo `<feature-description>`, hãy sinh ra một cái tên nhánh dạng K
19
19
  *Lưu ý: Nếu User chủ động gõ tên có sẵn mã Jira (TD-1234) hoặc tên tuyệt đối thì bỏ qua prefix Type.*
20
20
 
21
21
  ### Bước 3: Khởi Sinh Worktree (Sibling Pattern)
22
- **Mặc định luôn là cấp Sibling (`../`)**: skill không bao giờ tự tạo worktree lồng bên trong repo. Một đường dẫn lồng (dưới `.claude/worktrees/` đã bị ignore) chỉ chấp nhận khi chính người dùng hoặc công cụ host đã đặt tên và tạo ra nó; khi còn worktree lồng, không chạy `git clean -fdx` ở root repo vì nó xoá cả thư mục bị ignore.
22
+ **Mặc định luôn là cấp Sibling (`../`)**: skill không bao giờ tự tạo worktree lồng bên trong repo. Một đường dẫn lồng dưới runtime đã bị ignore chỉ chấp nhận khi chính người dùng hoặc công cụ host đã đặt tên và tạo ra nó; khi còn worktree lồng, không chạy `git clean -fdx` ở root repo vì nó xoá cả thư mục bị ignore.
23
23
 
24
24
  Lối tắt tùy chọn (không bắt buộc, không phải điều kiện): `claude --worktree`, Orca, Herdr. Chỉ cần `git` (thêm `rsync` cho Bước 4); máy không có công cụ nào khác vẫn làm đủ mọi bước.
25
25
 
@@ -1,5 +1,9 @@
1
1
  ## Codex runtime
2
2
 
3
+ Project notes (commands, files to leave unchanged, slow or expensive operations)
4
+ belong in the root AGENTS.md after the CAFEKIT CORE END marker; Codex reads
5
+ AGENTS.md directly.
6
+
3
7
  - Repository instructions live in `AGENTS.md`.
4
8
  - CafeKit skills live in `.agents/skills/`; invoke them as `$cf-<name>` or browse with `/skills`. Edit skills in the project, not in a global skills directory.
5
9
  - Run Python skill scripts with the project venv:
@@ -7,6 +11,10 @@
7
11
  - Windows: `.agents\\skills\\.venv\\Scripts\\python.exe scripts\\<script>.py`
8
12
  - CafeKit agents live in `.codex/agents/*.toml` and are auto-discovered after the repository is trusted.
9
13
  - Runtime support lives in `.codex/rules/`, `.codex/scripts/`, `.codex/references/`, and `.codex/runtime.json`.
14
+ - Read `.codex/rules/workflow.md` before planning or implementing changes.
15
+ - Follow `.codex/rules/ai-dev-rules.md` while writing or reviewing code.
16
+ - Consult `.codex/rules/orchestrator.md` when delegating work or seeking specialist counsel.
17
+ - Read `.codex/rules/manage-docs.md` when updating project documentation.
10
18
  - For skill selection, read `.codex/rules/skill-workflow-routing.md` and
11
19
  `.codex/rules/skill-domain-routing.md`. When capability presence is uncertain,
12
20
  run `node .codex/scripts/generate-skill-catalog.cjs --skills`; its Codex-bound
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const crypto = require('node:crypto');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const { atomicWrite, getHookContext, logCrash, readPayload } = require('./lib/hook-context.cjs');
9
+
10
+ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
11
+
12
+ try {
13
+ const data = readPayload();
14
+ if (!['UserPromptSubmit', 'PreToolUse'].includes(data.hook_event_name)) process.exit(0);
15
+ if (typeof data.session_id !== 'string' || !data.session_id.trim()) {
16
+ throw new Error('missing session ID');
17
+ }
18
+ const { projectRoot } = getHookContext(data);
19
+ const sessionHash = sha256(data.session_id);
20
+ const root = process.env.CAFEKIT_ASK_GUARD_DIR
21
+ || path.join(os.tmpdir(), 'cafekit-ask-guard', sha256(projectRoot));
22
+ const directory = path.join(path.resolve(root), sessionHash);
23
+ const flag = path.join(directory, 'state.json');
24
+
25
+ if (data.hook_event_name === 'UserPromptSubmit') {
26
+ const active = /^(\$cf-ask|\/cf:ask)(?=\s|$)/.test(String(data.prompt || '').trim());
27
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
28
+ atomicWrite(flag, `${JSON.stringify({ active })}\n`);
29
+ process.exit(0);
30
+ }
31
+ // The live probe exposed this native edit separately from its enclosing exec.
32
+ // Shell commands and child sessions are outside this guard's scope.
33
+ if (data.tool_name !== 'apply_patch') process.exit(0);
34
+ let state;
35
+ try {
36
+ state = JSON.parse(fs.readFileSync(flag, 'utf8'));
37
+ } catch (error) {
38
+ if (error.code === 'ENOENT') process.exit(0);
39
+ throw error;
40
+ }
41
+ if (typeof state?.active !== 'boolean') throw new Error('invalid session flag');
42
+ if (!state.active) process.exit(0);
43
+
44
+ fs.appendFileSync(path.join(directory, 'denials.jsonl'), `${JSON.stringify({
45
+ timestamp: new Date().toISOString(),
46
+ session_id_sha256: sessionHash,
47
+ tool_name: 'apply_patch'
48
+ })}\n`, { mode: 0o600 });
49
+ process.stderr.write('This ask session answers only. Run $cf-fix to change files.\n');
50
+ process.exit(2);
51
+ } catch {
52
+ // Do not log exception text: malformed payloads can contain private input.
53
+ logCrash('ask-guard', new Error('Ask guard could not read or persist session state; allowing the call.'));
54
+ }
@@ -2,13 +2,14 @@
2
2
  'use strict';
3
3
 
4
4
  const {
5
- getHookContext,
5
+ PROJECT_ROOT,
6
6
  logCrash,
7
- readPayload
7
+ readPayload,
8
+ readRuntime,
9
+ resolveSessionCwd
8
10
  } = require('./lib/hook-context.cjs');
9
- const { BLOCKED_DIRS, inspectInput } = require('./lib/inspect-paths.cjs');
10
11
 
11
- function denyPath(requested, blocked) {
12
+ function denyPath(requested, blocked, BLOCKED_DIRS) {
12
13
  process.stderr.write(
13
14
  `SCOPE LIMIT EXCEEDED: Directory "${blocked}/" is explicitly forbidden\n`
14
15
  + `Requested Path: ${requested}\n`
@@ -18,12 +19,15 @@ function denyPath(requested, blocked) {
18
19
  }
19
20
 
20
21
  try {
22
+ const { BLOCKED_DIRS, inspectInput } = require('./lib/inspect-paths.cjs');
21
23
  const payload = readPayload();
22
24
  if (!payload) process.exit(0);
23
- const { runtime } = getHookContext(payload);
25
+ const projectRoot = PROJECT_ROOT;
26
+ const runtime = readRuntime(PROJECT_ROOT);
27
+ const sessionCwd = resolveSessionCwd(payload);
24
28
  if (runtime.scout?.enabled === false || runtime.inspect?.enabled === false) process.exit(0);
25
29
 
26
- const result = inspectInput(payload.tool_input || {});
30
+ const result = inspectInput(payload.tool_name || '', payload.tool_input || {}, { cwd: sessionCwd, projectRoot });
27
31
  if (result.broadGlob) {
28
32
  process.stderr.write(
29
33
  `SCOPE LIMIT EXCEEDED: Glob pattern is excessively broad\n`
@@ -33,7 +37,7 @@ try {
33
37
  process.exit(2);
34
38
  }
35
39
  if (result.blockedPath) {
36
- denyPath(result.blockedPath.requested, result.blockedPath.blocked);
40
+ denyPath(result.blockedPath.requested, result.blockedPath.blocked, BLOCKED_DIRS);
37
41
  }
38
42
  } catch (error) {
39
43
  logCrash('inspect-block', error);
@@ -1,199 +1 @@
1
- 'use strict';
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,7 @@ 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');
7
8
 
8
9
  // Codex cannot raise an interactive prompt from a hook -- PreToolUse honours no
9
10
  // "ask" decision -- so approval costs a full round trip: the agent has to relay
@@ -61,13 +62,6 @@ function samePathKeys(left, right) {
61
62
  && left.every((value, index) => value === right[index]);
62
63
  }
63
64
 
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
65
  function atomicWrite(dir, file, payload) {
72
66
  PATH_SAFETY.assertComponentPath(dir, path.dirname(file));
73
67
  const temp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
@@ -84,14 +78,81 @@ function readJson(dir, file) {
84
78
  }
85
79
  }
86
80
 
81
+ function validPending(record, sessionId, requestId, now = Date.now()) {
82
+ return record?.requestId === requestId && record.sessionId === sessionId
83
+ && Number.isFinite(record.expiresAt) && record.expiresAt > now
84
+ && typeof record.displayName === 'string' && record.displayName.length > 0
85
+ && Array.isArray(record.pathKeys) && record.pathKeys.length > 0
86
+ && record.pathKeys.every(key => typeof key === 'string' && /^[a-f0-9]{64}$/.test(key));
87
+ }
88
+
89
+ function writeShortMarker(dir, record) {
90
+ const { requestId, sessionId, expiresAt } = record;
91
+ fs.writeFileSync(path.join(dir, `short-${hash(sessionId)}-${requestId}.json`),
92
+ `${JSON.stringify({ requestId, sessionId, expiresAt })}\n`,
93
+ { encoding: 'utf8', mode: 0o600, flag: 'wx' });
94
+ }
95
+
96
+ /** Each prompt owns only markers it successfully renamed, never a shared mutable flag. */
97
+ function claimShortMarkers(projectRoot, sessionId) {
98
+ const requestIds = [];
99
+ if (typeof sessionId !== 'string' || !sessionId.trim()) return { ok: true, requestIds };
100
+ const dir = secureStateDir(projectRoot);
101
+ if (!dir) return { ok: true, requestIds };
102
+ const prefix = `short-${hash(sessionId)}-`;
103
+ let ok = true;
104
+ for (const name of fs.readdirSync(dir)) {
105
+ if (!name.startsWith(prefix) || !name.endsWith('.json')) continue;
106
+ const file = path.join(dir, name);
107
+ const claimed = path.join(dir, `claim-${crypto.randomBytes(12).toString('hex')}.tmp`);
108
+ const requestId = name.slice(prefix.length, -'.json'.length);
109
+ let moved = false;
110
+ try {
111
+ PATH_SAFETY.assertComponentPath(dir, file);
112
+ fs.renameSync(file, claimed);
113
+ moved = true;
114
+ requestIds.push(requestId);
115
+ PATH_SAFETY.assertComponentPath(dir, claimed);
116
+ const marker = JSON.parse(fs.readFileSync(claimed, 'utf8'));
117
+ if (!/^[a-f0-9]{24}$/.test(requestId) || marker.requestId !== requestId
118
+ || marker.sessionId !== sessionId || !Number.isFinite(marker.expiresAt)
119
+ || marker.expiresAt <= Date.now()) throw new Error('Invalid short approval marker');
120
+ } catch {
121
+ ok = false;
122
+ if (!moved) {
123
+ // A failed claim cancels the shortcut; deletion never counts as ownership.
124
+ try { fs.unlinkSync(file); }
125
+ catch {
126
+ let descriptor;
127
+ try {
128
+ PATH_SAFETY.canonicalRegularFile(dir, file, 'short approval marker');
129
+ descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW);
130
+ fs.ftruncateSync(descriptor, 0);
131
+ } catch { /* no permission follows a failed invalidation */ }
132
+ finally {
133
+ if (descriptor !== undefined) {
134
+ try { fs.closeSync(descriptor); } catch { /* already rejected */ }
135
+ }
136
+ }
137
+ }
138
+ }
139
+ } finally {
140
+ if (moved) {
141
+ try { fs.unlinkSync(claimed); } catch { ok = false; }
142
+ }
143
+ }
144
+ }
145
+ return { ok, requestIds };
146
+ }
147
+
87
148
  function cleanup(projectRoot, now = Date.now()) {
88
149
  const dir = secureStateDir(projectRoot);
89
150
  if (!dir) return;
90
151
  for (const name of fs.readdirSync(dir)) {
91
- if (!/^(pending|token)-[a-f0-9]+\.json$/.test(name)) continue;
152
+ if (!/^(?:(?:pending|token)-[a-f0-9]+|short-[a-f0-9]{64}-[a-f0-9]{24})\.json$/.test(name)) continue;
92
153
  const file = path.join(dir, name);
93
154
  const record = readJson(dir, file);
94
- if (!record || Number(record.expiresAt) <= now) {
155
+ if (!record || !Number.isFinite(record.expiresAt) || record.expiresAt <= now) {
95
156
  try {
96
157
  fs.unlinkSync(file);
97
158
  } catch {
@@ -101,13 +162,20 @@ function cleanup(projectRoot, now = Date.now()) {
101
162
  }
102
163
  }
103
164
 
104
- function clearState(projectRoot) {
165
+ function clearState(projectRoot, sessionId) {
105
166
  try {
167
+ if (typeof sessionId !== 'string' || !sessionId.trim()) return;
106
168
  const dir = secureStateDir(projectRoot);
107
169
  if (!dir) return;
108
170
  for (const name of fs.readdirSync(dir)) {
171
+ if (name.startsWith(`short-${hash(sessionId)}-`) && name.endsWith('.json')) {
172
+ try { fs.unlinkSync(path.join(dir, name)); } catch { /* concurrently claimed */ }
173
+ continue;
174
+ }
109
175
  if (/^(pending|token)-[a-f0-9]+\.json$/.test(name)) {
110
- try { fs.unlinkSync(path.join(dir, name)); } catch { /* concurrently consumed */ }
176
+ const file = path.join(dir, name);
177
+ if (readJson(dir, file)?.sessionId !== sessionId) continue;
178
+ try { fs.unlinkSync(file); } catch { /* concurrently consumed */ }
111
179
  }
112
180
  }
113
181
  } catch {
@@ -123,26 +191,38 @@ function createPending({
123
191
  filePath
124
192
  }) {
125
193
  cleanup(projectRoot);
126
- if (!sessionId) throw new Error('A Codex session ID is required');
194
+ if (typeof sessionId !== 'string' || !sessionId.trim()) throw new Error('A Codex session ID is required');
127
195
  const paths = requestPaths(filePaths, filePath);
128
196
  if (paths.length === 0) throw new Error('At least one sensitive path is required');
197
+ const pathKeys = requestPathKeys(paths, sessionCwd);
198
+ const dir = secureStateDir(projectRoot, true);
199
+ for (const name of fs.readdirSync(dir)) {
200
+ const match = name.match(/^pending-([a-f0-9]{24})\.json$/);
201
+ if (!match) continue;
202
+ const pending = readJson(dir, path.join(dir, name));
203
+ if (validPending(pending, sessionId, match[1]) && samePathKeys(pending.pathKeys, pathKeys)) {
204
+ try { writeShortMarker(dir, pending); }
205
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
206
+ return pending;
207
+ }
208
+ }
129
209
  const requestId = crypto.randomBytes(12).toString('hex');
130
210
  const record = {
131
211
  version: 2,
132
212
  requestId,
133
213
  sessionId: String(sessionId || ''),
134
- pathKeys: requestPathKeys(paths, sessionCwd),
214
+ pathKeys,
135
215
  displayName: displayNames(paths),
136
216
  expiresAt: Date.now() + TTL_MS
137
217
  };
138
- const dir = secureStateDir(projectRoot, true);
139
218
  atomicWrite(dir, path.join(dir, `pending-${requestId}.json`), record);
219
+ writeShortMarker(dir, record);
140
220
  return record;
141
221
  }
142
222
 
143
223
  function approvePending({ projectRoot, sessionId, requestId }) {
144
224
  cleanup(projectRoot);
145
- if (!sessionId) return { ok: false, reason: 'session' };
225
+ if (typeof sessionId !== 'string' || !sessionId.trim()) return { ok: false, reason: 'session' };
146
226
  if (!/^[a-f0-9]{24}$/.test(requestId)) return { ok: false, reason: 'invalid' };
147
227
 
148
228
  const dir = secureStateDir(projectRoot);
@@ -152,6 +232,7 @@ function approvePending({ projectRoot, sessionId, requestId }) {
152
232
  if (!record) return { ok: false, reason: 'missing' };
153
233
  if (record.sessionId !== String(sessionId || '')) return { ok: false, reason: 'session' };
154
234
  if (Number(record.expiresAt) <= Date.now()) return { ok: false, reason: 'expired' };
235
+ if (!validPending(record, sessionId, requestId)) return { ok: false, reason: 'invalid' };
155
236
 
156
237
  try {
157
238
  fs.unlinkSync(pendingFile);
@@ -218,5 +299,6 @@ module.exports = {
218
299
  approvePending,
219
300
  hasGrant,
220
301
  cleanup,
221
- clearState
302
+ clearState,
303
+ claimShortMarkers
222
304
  };
@@ -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');