@haposoft/cafekit 0.18.1 → 0.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +774 -6
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +192 -59
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +1 -1
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
- package/bin/__tests__/omp-bridge.test.js +66 -12
- package/bin/__tests__/omp-hooks.test.js +11 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/phases/claude-runtime.js +91 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +96 -33
- package/bin/phases/codex-runtime.js +45 -52
- package/bin/phases/copy-payload.js +1 -3
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/precompact.test.js +206 -10
- package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +7 -6
- package/src/claude/hooks/inspect-block.cjs +15 -63
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
- package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +67 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +23 -205
- package/src/claude/hooks/rules.cjs +6 -28
- package/src/claude/hooks/session.cjs +34 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +43 -59
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +8 -1
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
- package/src/claude/scripts/provenance.cjs +14 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/codex/AGENTS.md +8 -0
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +11 -7
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +98 -16
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +39 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +9 -215
- package/src/codex/hooks/rules.cjs +4 -30
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +47 -11
- package/src/codex/hooks/spec-gate.cjs +21 -6
- package/src/codex/hooks/spec-state.cjs +32 -6
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -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
|
|
31
|
-
CENTRALIZED_RESOLVER_AVAILABLE = True
|
|
27
|
+
from dotenv import load_dotenv
|
|
32
28
|
except ImportError:
|
|
33
|
-
|
|
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
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
|
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
|
|
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
|
|
package/src/codex/AGENTS.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
|
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');
|