@haposoft/cafekit 0.14.0 → 0.14.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 +27 -3
- package/bin/__tests__/installer-safety.test.js +145 -0
- package/bin/install.js +9 -1
- package/bin/lib/backup.js +60 -16
- package/bin/lib/context.js +1 -1
- package/bin/phases/claude-runtime.js +57 -4
- package/bin/phases/post-install.js +39 -26
- package/bin/phases/root-config.js +24 -5
- package/bin/phases/select-platform.js +14 -9
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +1 -1
- package/src/claude/agents/code-auditor.md +1 -1
- package/src/claude/agents/debugger.md +1 -0
- package/src/claude/agents/god-developer.md +10 -1
- package/src/claude/agents/inspector.md +1 -0
- package/src/claude/agents/spec-maker.md +1 -1
- package/src/claude/agents/test-runner.md +2 -2
- package/src/claude/gitignore +11 -4
- package/src/claude/hooks/__tests__/privacy-block.test.js +50 -10
- package/src/claude/hooks/__tests__/spec-gate.test.js +75 -1
- package/src/claude/hooks/__tests__/state.test.js +65 -0
- package/src/claude/hooks/docs-sync.cjs +15 -21
- package/src/claude/hooks/inspect-block.cjs +1 -1
- package/src/claude/hooks/lib/config.cjs +0 -5
- package/src/claude/hooks/privacy-block.cjs +39 -62
- package/src/claude/hooks/spec-gate.cjs +18 -7
- package/src/claude/hooks/spec-state.cjs +4 -0
- package/src/claude/hooks/state.cjs +13 -3
- package/src/claude/hooks/usage.cjs +6 -0
- package/src/claude/migration-manifest.json +1 -0
- package/src/claude/rules/orchestrator.md +2 -2
- package/src/claude/rules/skill-domain-routing.md +27 -86
- package/src/claude/rules/skill-workflow-routing.md +11 -74
- package/src/claude/rules/state-sync.md +1 -1
- package/src/claude/runtime.json +7 -7
- package/src/claude/scripts/validate-spec-output.cjs +6 -0
- package/src/claude/skills/code-review/references/spec-compliance-review.md +1 -1
- package/src/claude/skills/delegate/SKILL.md +124 -0
- package/src/claude/skills/delegate/references/codex-delegation.md +71 -0
- package/src/claude/skills/delegate/references/grok-delegation.md +84 -0
- package/src/claude/skills/develop/SKILL.md +26 -5
- package/src/claude/skills/develop/references/parallel-waves.md +112 -0
- package/src/claude/skills/develop/references/quality-gate.md +10 -2
- package/src/claude/skills/hotfix/references/parallel-patterns.md +1 -1
- package/src/claude/skills/inspect/SKILL.md +42 -113
- package/src/claude/skills/inspect/references/internal-inspection.md +16 -135
- package/src/claude/skills/specs/SKILL.md +134 -386
- package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
- package/src/claude/skills/specs/templates/task.md +3 -1
- package/src/claude/skills/sync/SKILL.md +2 -2
- package/src/claude/skills/sync/references/sync-protocols.md +4 -4
- package/src/claude/skills/test/SKILL.md +1 -1
- package/src/claude/status.cjs +9 -6
- package/src/opencode/plugins/docs-sync.ts +10 -16
- package/src/claude/archive-command/code/SKILL.md +0 -55
- package/src/claude/archive-command/code/references/execution-loop.md +0 -21
- package/src/claude/archive-command/code.md +0 -17
- package/src/claude/archive-command/docs.md +0 -609
- package/src/claude/archive-command/review/codebase/parallel.md +0 -76
- package/src/claude/archive-command/spec-design.md +0 -247
- package/src/claude/archive-command/spec-init.md +0 -120
- package/src/claude/archive-command/spec-requirements.md +0 -138
- package/src/claude/archive-command/spec-status.md +0 -98
- package/src/claude/archive-command/spec-tasks.md +0 -173
- package/src/claude/archive-command/spec-validate.md +0 -118
- package/src/claude/archive-command/test.md +0 -8
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
name: debugger
|
|
3
3
|
description: "Investigates bugs, incidents, CI/log/DB/performance/frontend failures, traces exact root causes with evidence, and hands off a verification-ready fix plan. Edits code only when explicitly requested by a fix workflow."
|
|
4
4
|
model: sonnet
|
|
5
|
+
memory: user
|
|
5
6
|
tools: Glob, Grep, Read, Bash, WebFetch, WebSearch
|
|
6
7
|
---
|
|
7
8
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: god-developer
|
|
3
|
-
description: "Primary code execution agent. Receives specifications (spec) from hapo:specs or task files and transforms them into production-grade source code. Operates on a Single-Track principle (linear
|
|
3
|
+
description: "Primary code execution agent. Receives specifications (spec) from hapo:specs or task files and transforms them into production-grade source code. Operates on a Single-Track principle within its workspace (linear inside one working tree; multiple instances may run concurrently in separate isolated worktrees)."
|
|
4
4
|
model: sonnet
|
|
5
5
|
tools: Glob, Grep, Read, Edit, Write, NotebookEdit, Bash, WebFetch, WebSearch
|
|
6
6
|
---
|
|
@@ -94,3 +94,12 @@ Upon completion, output a concise report in this format:
|
|
|
94
94
|
- Do not add AI attribution to code or commit messages.
|
|
95
95
|
- Prioritize security (validate input, never hardcode secrets).
|
|
96
96
|
- Code should be self-documenting — only add comments for complex logic.
|
|
97
|
+
|
|
98
|
+
## Worktree Conduct (Parallel Wave dispatch)
|
|
99
|
+
|
|
100
|
+
When dispatched into an isolated git worktree by `hapo:develop --parallel`:
|
|
101
|
+
|
|
102
|
+
- Work ONLY inside this worktree; Single-Track discipline applies unchanged within it.
|
|
103
|
+
- Do NOT edit `spec.json` or any `tasks/*.md` — the orchestrator is the single writer of spec state.
|
|
104
|
+
- Do NOT touch files outside this task's `Related Files`.
|
|
105
|
+
- Commit your completed work: `git commit -m "task(<id>): <title>"` — an uncommitted worktree cannot be merged back.
|
|
@@ -3,6 +3,7 @@ name: inspector
|
|
|
3
3
|
tools: Glob, Grep, Read, Bash
|
|
4
4
|
description: "Codebase structure scanner. Use this agent when you need to quickly scout/inspect the codebase architecture, files, and directories. Specializes in finding relevant files for a given work scope before implementation begins."
|
|
5
5
|
model: haiku
|
|
6
|
+
memory: user
|
|
6
7
|
---
|
|
7
8
|
|
|
8
9
|
# Inspect — Codebase Scout
|
|
@@ -127,7 +127,7 @@ Before writing `design.md`, select a discovery mode and record the reason:
|
|
|
127
127
|
- When requirement coverage format: list numeric IDs only, no descriptive suffixes
|
|
128
128
|
- Apply `(P)` parallel markers when applicable (load `.claude/skills/specs/rules/tasks-parallel-analysis.md`)
|
|
129
129
|
- Every task MUST use the compact implementation-ready shape: `Context`, `Steps`, `Requirements`, `Related Files`, `Completion Criteria`, `Evidence`, `Risk Assessment`.
|
|
130
|
-
-
|
|
130
|
+
- `## Evidence` (legacy heading aliases still parse) MUST include exact commands, artifacts/runtime surfaces, runtime reachability proof, and negative-path checks.
|
|
131
131
|
- Completion criteria MUST be objective enough that a downstream quality gate can prove them without guesswork.
|
|
132
132
|
- UI/app/runtime workflows MUST include a final integration/reachability task or final integration section that names the real entrypoint and proves all scoped user-facing surfaces are wired.
|
|
133
133
|
- Do not allow orphan task outputs: components, services, hooks, routes, commands, workers, providers, reducers, data loaders, and generated artifacts must be reachable now or assigned to a named later integration task.
|
|
@@ -11,7 +11,7 @@ You are a battle-hardened QA engineer who has been burned by production incident
|
|
|
11
11
|
|
|
12
12
|
## Task-Aware Inputs
|
|
13
13
|
|
|
14
|
-
If the prompt includes task file paths, Completion Criteria,
|
|
14
|
+
If the prompt includes task file paths, Completion Criteria, or `## Evidence` (legacy heading aliases still parse) instructions, treat them as authoritative.
|
|
15
15
|
Diff-aware test selection does NOT replace task-specific verification.
|
|
16
16
|
If the task/spec names a specific framework, auth system, transport, or shared-state boundary, keep that contract visible while evaluating evidence.
|
|
17
17
|
If the prompt includes a feature name or `specs/<feature>`, load `spec.json`, `requirements.md`, `design.md`, and the active/recent task files. Treat `scope_lock`, Completion Criteria, and Evidence as the test contract.
|
|
@@ -30,7 +30,7 @@ Select tests by the task's touched surface:
|
|
|
30
30
|
## Command Resolution Order
|
|
31
31
|
|
|
32
32
|
When the task file names exact commands, use this order:
|
|
33
|
-
1. Run every exact executable command from
|
|
33
|
+
1. Run every exact executable command from `## Evidence` (legacy heading aliases still parse) in declaration order.
|
|
34
34
|
2. Run repo-default typecheck/test/build commands only to fill gaps not already covered above.
|
|
35
35
|
3. Apply diff-aware test selection only after task-mandated commands are satisfied.
|
|
36
36
|
|
package/src/claude/gitignore
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
|
-
#
|
|
1
|
+
# CafeKit local runtime (.claude/ or .opencode/)
|
|
2
|
+
# Also ignored at project root by the installer. This file protects
|
|
3
|
+
# force-adds (`git add -f`) and partial un-ignores of the runtime folder.
|
|
4
|
+
|
|
5
|
+
# Secrets and local credentials
|
|
2
6
|
.env
|
|
3
7
|
.env.*
|
|
4
8
|
!.env.example
|
|
5
9
|
|
|
6
|
-
#
|
|
10
|
+
# Skill dependency installs (Python venv + npm)
|
|
7
11
|
skills/.venv/
|
|
12
|
+
skills/**/.venv/
|
|
13
|
+
skills/**/node_modules/
|
|
8
14
|
.venv/
|
|
9
15
|
venv/
|
|
10
|
-
skills/**/node_modules/
|
|
11
16
|
__pycache__/
|
|
17
|
+
**/__pycache__/
|
|
12
18
|
*.pyc
|
|
13
19
|
|
|
14
|
-
#
|
|
20
|
+
# Generated session state, caches, and logs
|
|
15
21
|
session-state/
|
|
16
22
|
hooks/.logs/
|
|
23
|
+
plugins/.logs/
|
|
17
24
|
agent-memory/
|
|
18
25
|
settings.bak.json
|
|
19
26
|
.mcp.json
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// Behavioral tests for privacy-block.cjs. The hook is run as a real subprocess:
|
|
4
|
-
// a PreToolUse payload is piped to stdin and
|
|
5
|
-
// (0 = allow, 2 = block). Covers the symlink-bypass hardening.
|
|
4
|
+
// a PreToolUse payload is piped to stdin and native permission output asserted.
|
|
6
5
|
|
|
7
6
|
const { test } = require('node:test');
|
|
8
7
|
const assert = require('node:assert');
|
|
@@ -13,40 +12,47 @@ const path = require('node:path');
|
|
|
13
12
|
|
|
14
13
|
const HOOK = path.join(__dirname, '..', 'privacy-block.cjs');
|
|
15
14
|
|
|
16
|
-
/** Run the hook with a tool payload; return
|
|
15
|
+
/** Run the hook with a tool payload; return process output. */
|
|
17
16
|
function runHook(payload, cwd) {
|
|
18
17
|
const res = spawnSync(process.execPath, [HOOK], {
|
|
19
18
|
input: JSON.stringify({ ...payload, cwd: cwd || payload.cwd }),
|
|
20
19
|
encoding: 'utf8',
|
|
21
20
|
});
|
|
22
|
-
return { code: res.status, stderr: res.stderr || '' };
|
|
21
|
+
return { code: res.status, stdout: res.stdout || '', stderr: res.stderr || '' };
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
function tmpDir() {
|
|
26
25
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'ck-privacy-'));
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
function permissionDecision(stdout) {
|
|
29
|
+
if (!stdout.trim()) return null;
|
|
30
|
+
return JSON.parse(stdout).hookSpecificOutput?.permissionDecision || null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('asks natively for a direct Read of .env', () => {
|
|
30
34
|
const dir = tmpDir();
|
|
31
35
|
try {
|
|
32
36
|
const env = path.join(dir, '.env');
|
|
33
37
|
fs.writeFileSync(env, 'SECRET=1');
|
|
34
|
-
const { code } = runHook({ tool_name: 'Read', tool_input: { file_path: env } }, dir);
|
|
35
|
-
assert.strictEqual(code,
|
|
38
|
+
const { code, stdout } = runHook({ tool_name: 'Read', tool_input: { file_path: env } }, dir);
|
|
39
|
+
assert.strictEqual(code, 0);
|
|
40
|
+
assert.strictEqual(permissionDecision(stdout), 'ask');
|
|
36
41
|
} finally {
|
|
37
42
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
38
43
|
}
|
|
39
44
|
});
|
|
40
45
|
|
|
41
|
-
test('
|
|
46
|
+
test('asks for a symlink whose target is .env (regression: symlink bypass)', () => {
|
|
42
47
|
const dir = tmpDir();
|
|
43
48
|
try {
|
|
44
49
|
const env = path.join(dir, '.env');
|
|
45
50
|
fs.writeFileSync(env, 'SECRET=1');
|
|
46
51
|
const link = path.join(dir, 'notes.txt'); // harmless-looking name
|
|
47
52
|
fs.symlinkSync(env, link);
|
|
48
|
-
const { code } = runHook({ tool_name: 'Read', tool_input: { file_path: link } }, dir);
|
|
49
|
-
assert.strictEqual(code,
|
|
53
|
+
const { code, stdout } = runHook({ tool_name: 'Read', tool_input: { file_path: link } }, dir);
|
|
54
|
+
assert.strictEqual(code, 0);
|
|
55
|
+
assert.strictEqual(permissionDecision(stdout), 'ask');
|
|
50
56
|
} finally {
|
|
51
57
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
52
58
|
}
|
|
@@ -77,3 +83,37 @@ test('allows a symlink to an exempt .env.example (exemption still wins)', () =>
|
|
|
77
83
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
78
84
|
}
|
|
79
85
|
});
|
|
86
|
+
|
|
87
|
+
test('an exempt-looking symlink to .env still asks based on its target', () => {
|
|
88
|
+
const dir = tmpDir();
|
|
89
|
+
try {
|
|
90
|
+
const env = path.join(dir, '.env');
|
|
91
|
+
fs.writeFileSync(env, 'SECRET=1');
|
|
92
|
+
const link = path.join(dir, '.env.example');
|
|
93
|
+
fs.symlinkSync(env, link);
|
|
94
|
+
const { stdout } = runHook({ tool_name: 'Read', tool_input: { file_path: link } }, dir);
|
|
95
|
+
assert.strictEqual(permissionDecision(stdout), 'ask');
|
|
96
|
+
} finally {
|
|
97
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
for (const command of [
|
|
102
|
+
'cat ".env"',
|
|
103
|
+
'cat .env',
|
|
104
|
+
'cat .env*',
|
|
105
|
+
'sed -n 1p .env',
|
|
106
|
+
"bash -c 'sed -n 1p .env'",
|
|
107
|
+
]) {
|
|
108
|
+
test(`asks natively for Bash sensitive reference: ${command}`, () => {
|
|
109
|
+
const dir = tmpDir();
|
|
110
|
+
try {
|
|
111
|
+
fs.writeFileSync(path.join(dir, '.env'), 'SECRET=1');
|
|
112
|
+
const { code, stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
|
|
113
|
+
assert.strictEqual(code, 0);
|
|
114
|
+
assert.strictEqual(permissionDecision(stdout), 'ask');
|
|
115
|
+
} finally {
|
|
116
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -64,7 +64,7 @@ function readCache() {
|
|
|
64
64
|
* @param {string} [opts.taskStatus='done']
|
|
65
65
|
* @param {string|null} [opts.completed_at='2026-07-01T00:00:00Z']
|
|
66
66
|
* @param {string} [opts.mdStatus='done']
|
|
67
|
-
* @param {'valid'|'missing-evidence'|'placeholder'|'none'} [opts.evidence='valid']
|
|
67
|
+
* @param {'valid'|'legacy-valid'|'failed'|'fence-only'|'missing-evidence'|'placeholder'|'none'} [opts.evidence='valid']
|
|
68
68
|
* @param {object|null} [opts.runtime] — if set, write .claude/runtime.json
|
|
69
69
|
*/
|
|
70
70
|
function makeFixture(opts = {}) {
|
|
@@ -97,12 +97,19 @@ function makeFixture(opts = {}) {
|
|
|
97
97
|
evidenceBlock = [
|
|
98
98
|
'## Evidence',
|
|
99
99
|
'',
|
|
100
|
+
'Verification: PASS',
|
|
100
101
|
'```',
|
|
101
102
|
'npm test',
|
|
102
103
|
'PASS: 10 tests',
|
|
103
104
|
'```',
|
|
104
105
|
'',
|
|
105
106
|
].join('\n');
|
|
107
|
+
} else if (evidence === 'legacy-valid') {
|
|
108
|
+
evidenceBlock = '## Evidence\n\nnpm test — passed, exit code 0\n';
|
|
109
|
+
} else if (evidence === 'failed') {
|
|
110
|
+
evidenceBlock = '## Evidence\n\nVerification: PASS\n\nFAIL: tests failed, exit code 1\n';
|
|
111
|
+
} else if (evidence === 'fence-only') {
|
|
112
|
+
evidenceBlock = '## Evidence\n\n```\nnpm test\n```\n';
|
|
106
113
|
} else if (evidence === 'placeholder') {
|
|
107
114
|
evidenceBlock = [
|
|
108
115
|
'## Evidence',
|
|
@@ -268,3 +275,70 @@ test('7. Evidence with {{...}} placeholder → blocked (check c)', () => {
|
|
|
268
275
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
269
276
|
}
|
|
270
277
|
});
|
|
278
|
+
|
|
279
|
+
test('8. explicit failure cannot pass even when PASS text is present', () => {
|
|
280
|
+
const dir = makeFixture({ evidence: 'failed' });
|
|
281
|
+
try {
|
|
282
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
283
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
284
|
+
assert.ok(body);
|
|
285
|
+
assert.match(body.reason, /\bc\b/);
|
|
286
|
+
} finally {
|
|
287
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('9. a code fence alone is not verification proof', () => {
|
|
292
|
+
const dir = makeFixture({ evidence: 'fence-only' });
|
|
293
|
+
try {
|
|
294
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
295
|
+
assert.ok(parseBlock(runHook({}, dir).stdout));
|
|
296
|
+
} finally {
|
|
297
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('10. completed_at must be a valid ISO timestamp', () => {
|
|
302
|
+
const dir = makeFixture({ completed_at: 'yesterday' });
|
|
303
|
+
try {
|
|
304
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
305
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
306
|
+
assert.ok(body);
|
|
307
|
+
assert.match(body.reason, /\bd\b/);
|
|
308
|
+
} finally {
|
|
309
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('11. done → pending cache transition is persisted and can be gated again', () => {
|
|
314
|
+
const dir = makeFixture({ taskStatus: 'pending', mdStatus: 'pending' });
|
|
315
|
+
try {
|
|
316
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'done' } });
|
|
317
|
+
assert.strictEqual(runHook({}, dir).stdout, '');
|
|
318
|
+
assert.strictEqual(readCache()[FEATURE][TASK_REL], 'pending');
|
|
319
|
+
|
|
320
|
+
const specFile = path.join(dir, 'specs', FEATURE, 'spec.json');
|
|
321
|
+
const spec = JSON.parse(fs.readFileSync(specFile, 'utf8'));
|
|
322
|
+
spec.task_registry[TASK_REL] = {
|
|
323
|
+
status: 'done',
|
|
324
|
+
completed_at: '2026-07-01T00:00:00Z',
|
|
325
|
+
};
|
|
326
|
+
fs.writeFileSync(specFile, JSON.stringify(spec));
|
|
327
|
+
const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
328
|
+
fs.writeFileSync(taskFile, '# Task\n\n**Status:** done\n\n## Evidence\n\nFAIL: tests failed\n');
|
|
329
|
+
|
|
330
|
+
assert.ok(parseBlock(runHook({}, dir).stdout), 're-completed failing task must be gated');
|
|
331
|
+
} finally {
|
|
332
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('12. legacy successful receipt remains read-compatible', () => {
|
|
337
|
+
const dir = makeFixture({ evidence: 'legacy-valid' });
|
|
338
|
+
try {
|
|
339
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
340
|
+
assert.strictEqual(runHook({}, dir).stdout, '');
|
|
341
|
+
} finally {
|
|
342
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
343
|
+
}
|
|
344
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert');
|
|
5
|
+
const { spawnSync } = require('node:child_process');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
|
|
10
|
+
const HOOK = path.join(__dirname, '..', 'state.cjs');
|
|
11
|
+
|
|
12
|
+
test('Stop snapshot includes tracked modifications and untracked files', () => {
|
|
13
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-state-'));
|
|
14
|
+
try {
|
|
15
|
+
fs.mkdirSync(path.join(dir, '.claude'));
|
|
16
|
+
spawnSync('git', ['init', '-q'], { cwd: dir });
|
|
17
|
+
spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir });
|
|
18
|
+
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
|
|
19
|
+
fs.writeFileSync(path.join(dir, 'tracked.txt'), 'before\n');
|
|
20
|
+
spawnSync('git', ['add', 'tracked.txt'], { cwd: dir });
|
|
21
|
+
spawnSync('git', ['commit', '-qm', 'initial'], { cwd: dir });
|
|
22
|
+
|
|
23
|
+
fs.writeFileSync(path.join(dir, 'tracked.txt'), 'after\n');
|
|
24
|
+
fs.writeFileSync(path.join(dir, 'new-untracked.json'), '{}\n');
|
|
25
|
+
const result = spawnSync(process.execPath, [HOOK], {
|
|
26
|
+
cwd: dir,
|
|
27
|
+
input: JSON.stringify({ hook_event_name: 'Stop', cwd: dir }),
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
assert.strictEqual(result.status, 0);
|
|
32
|
+
const state = fs.readFileSync(
|
|
33
|
+
path.join(dir, '.claude', 'session-state', 'latest.md'),
|
|
34
|
+
'utf8',
|
|
35
|
+
);
|
|
36
|
+
assert.match(state, /- tracked\.txt/);
|
|
37
|
+
assert.match(state, /- new-untracked\.json/);
|
|
38
|
+
} finally {
|
|
39
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('Stop snapshot includes untracked files before the first commit', () => {
|
|
44
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-state-no-head-'));
|
|
45
|
+
try {
|
|
46
|
+
fs.mkdirSync(path.join(dir, '.claude'));
|
|
47
|
+
spawnSync('git', ['init', '-q'], { cwd: dir });
|
|
48
|
+
fs.writeFileSync(path.join(dir, 'first-untracked.txt'), 'new\n');
|
|
49
|
+
|
|
50
|
+
const result = spawnSync(process.execPath, [HOOK], {
|
|
51
|
+
cwd: dir,
|
|
52
|
+
input: JSON.stringify({ hook_event_name: 'Stop', cwd: dir }),
|
|
53
|
+
encoding: 'utf8',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
assert.strictEqual(result.status, 0);
|
|
57
|
+
const state = fs.readFileSync(
|
|
58
|
+
path.join(dir, '.claude', 'session-state', 'latest.md'),
|
|
59
|
+
'utf8',
|
|
60
|
+
);
|
|
61
|
+
assert.match(state, /- first-untracked\.txt/);
|
|
62
|
+
} finally {
|
|
63
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
@@ -39,24 +39,20 @@ try {
|
|
|
39
39
|
|
|
40
40
|
const lines = [];
|
|
41
41
|
|
|
42
|
-
//
|
|
42
|
+
// Case 1: source present but docs/ missing
|
|
43
43
|
if (!fs.existsSync(docsDir)) {
|
|
44
44
|
lines.push('');
|
|
45
|
-
lines.push('###
|
|
46
|
-
lines.push(
|
|
47
|
-
lines.push(
|
|
48
|
-
lines.push(
|
|
49
|
-
lines.push(
|
|
50
|
-
lines.push(`> 3. Khởi tạo file hidden \`docs/.sync_hash\` để theo dõi cập nhật.`);
|
|
51
|
-
lines.push(`> `);
|
|
52
|
-
lines.push(`> LƯU Ý BẮT BUỘC: Bạn phải chạy lệnh Git này trong Terminal để lấy Hash Code mới nhất: \`git log -1 --format="%H" -- . ":(exclude)docs"\``);
|
|
53
|
-
lines.push(`> Sau đó ghi chuẩn giá trị Hash đó vào file \`docs/.sync_hash\`.`);
|
|
45
|
+
lines.push('### Missing docs/');
|
|
46
|
+
lines.push('> Source exists but `docs/` does not. Create baseline docs first:');
|
|
47
|
+
lines.push('> 1. `docs/system-architecture.md` — system architecture and flows');
|
|
48
|
+
lines.push('> 2. `docs/project-overview-pdr.md` — overview, goals, features');
|
|
49
|
+
lines.push('> 3. Write current hash to `docs/.sync_hash` via `git log -1 --format="%H" -- . ":(exclude)docs"`');
|
|
54
50
|
lines.push('');
|
|
55
|
-
}
|
|
56
|
-
//
|
|
51
|
+
}
|
|
52
|
+
// Case 2: docs exist — check continuous sync vs source-only git hash
|
|
57
53
|
else {
|
|
58
54
|
try {
|
|
59
|
-
//
|
|
55
|
+
// Latest SOURCE-only hash (ignore docs-only commits)
|
|
60
56
|
const currentHash = execSync('git log -1 --format="%H" -- . ":(exclude)docs"', {
|
|
61
57
|
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']
|
|
62
58
|
}).trim();
|
|
@@ -69,18 +65,16 @@ try {
|
|
|
69
65
|
|
|
70
66
|
if (lastSyncHash !== currentHash) {
|
|
71
67
|
lines.push('');
|
|
72
|
-
lines.push('###
|
|
73
|
-
lines.push(`> Source
|
|
74
|
-
lines.push(`>
|
|
75
|
-
lines.push(
|
|
76
|
-
lines.push(`>
|
|
77
|
-
lines.push(`> 3. Cập nhật Changelog (nhật ký thay đổi).`);
|
|
78
|
-
lines.push(`> 4. KHI HOÀN TẤT, BẠN PHẢI GHI ĐÈ GIÁ TRỊ SAU: \`${currentHash}\` VÀO FILE \`docs/.sync_hash\` ĐỂ CHỐT TRẠNG THÁI HIỆN TẠI.`);
|
|
68
|
+
lines.push('### Docs sync needed');
|
|
69
|
+
lines.push(`> Source changed (\`${currentHash}\`) since last docs sync (\`${lastSyncHash || 'none'}\`).`);
|
|
70
|
+
lines.push(`> 1. Review recent changes (\`git diff ${lastSyncHash} ${currentHash}\` or \`git log\`).`);
|
|
71
|
+
lines.push('> 2. Update architecture/code-standards docs and changelog as needed.');
|
|
72
|
+
lines.push(`> 3. Write \`${currentHash}\` to \`docs/.sync_hash\` when done.`);
|
|
79
73
|
lines.push('');
|
|
80
74
|
}
|
|
81
75
|
}
|
|
82
76
|
} catch (e) {
|
|
83
|
-
// Git
|
|
77
|
+
// Git not initialized or no commits — fail-open
|
|
84
78
|
}
|
|
85
79
|
}
|
|
86
80
|
|
|
@@ -21,7 +21,7 @@ try {
|
|
|
21
21
|
// Directories that should never be read (too large / irrelevant to LLM)
|
|
22
22
|
const BLOCKED_DIRS = [
|
|
23
23
|
'node_modules', 'dist', 'build', '.next', '.nuxt', '.output',
|
|
24
|
-
'__pycache__', '.venv', 'venv',
|
|
24
|
+
'__pycache__', '.venv', 'venv',
|
|
25
25
|
'vendor', 'target',
|
|
26
26
|
'.git', 'coverage', '.nyc_output',
|
|
27
27
|
];
|
|
@@ -7,12 +7,11 @@
|
|
|
7
7
|
* Claude Code CLI privacy gate for sensitive files.
|
|
8
8
|
*
|
|
9
9
|
* Runtime contract:
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
13
|
-
* - Bash access is allowed with a warning to enable the approved follow-up path
|
|
10
|
+
* - Sensitive access returns Claude Code's native PreToolUse "ask" decision
|
|
11
|
+
* - The same decision applies to direct tools and Bash commands
|
|
12
|
+
* - Symlinks are classified by their resolved target before their alias
|
|
14
13
|
*
|
|
15
|
-
* Exit: 0
|
|
14
|
+
* Exit: 0 (the JSON hook output carries the permission decision)
|
|
16
15
|
*/
|
|
17
16
|
|
|
18
17
|
try {
|
|
@@ -20,7 +19,7 @@ try {
|
|
|
20
19
|
const path = require('path');
|
|
21
20
|
|
|
22
21
|
const RESTRICTED_PATTERNS = [
|
|
23
|
-
/^\.env(
|
|
22
|
+
/^\.env(?:[.\[*?{]|$)/i,
|
|
24
23
|
/^credentials/i,
|
|
25
24
|
/secrets?\.(ya?ml|json)$/i,
|
|
26
25
|
/\.pem$/i,
|
|
@@ -66,22 +65,28 @@ try {
|
|
|
66
65
|
* when the path cannot be resolved (missing file, broken link) — fail-open to
|
|
67
66
|
* the original-path check in that case.
|
|
68
67
|
*/
|
|
69
|
-
function resolveTarget(filePath) {
|
|
68
|
+
function resolveTarget(filePath, cwd) {
|
|
70
69
|
try {
|
|
71
|
-
|
|
70
|
+
const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
71
|
+
return fs.realpathSync(requested);
|
|
72
72
|
} catch {
|
|
73
73
|
return null;
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
function extractBashPaths(command) {
|
|
78
|
-
const paths =
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
const paths = new Set();
|
|
79
|
+
// Quotes are delimiters rather than grouped tokens so nested shell
|
|
80
|
+
// commands such as `bash -c 'cat .env'` cannot hide the sensitive path.
|
|
81
|
+
const tokens = command.split(/[\s|;&<>"']+/).filter(Boolean);
|
|
82
|
+
for (const token of tokens) {
|
|
83
|
+
const cleaned = token.replace(/^["'()`]+|["'(),]+$/g, '');
|
|
84
|
+
for (const candidate of cleaned.split('=')) {
|
|
85
|
+
const value = candidate.replace(/^["'()`]+|["'(),]+$/g, '');
|
|
86
|
+
if (value && (isSensitive(value) || isSafe(value))) paths.add(value);
|
|
87
|
+
}
|
|
83
88
|
}
|
|
84
|
-
return paths;
|
|
89
|
+
return [...paths];
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
function extractPaths(toolName, input) {
|
|
@@ -107,42 +112,15 @@ try {
|
|
|
107
112
|
return paths.filter(Boolean);
|
|
108
113
|
}
|
|
109
114
|
|
|
110
|
-
function
|
|
115
|
+
function askForPermission(filePath) {
|
|
111
116
|
const basename = path.basename(filePath);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
header: 'File Access',
|
|
118
|
-
text: `I need to read "${basename}" which may contain sensitive data (API keys, passwords, tokens). Do you approve?`,
|
|
119
|
-
options: [
|
|
120
|
-
{
|
|
121
|
-
label: 'Yes, approve access',
|
|
122
|
-
description: `Allow reading ${basename} this time`
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
label: 'No, skip this file',
|
|
126
|
-
description: 'Continue without accessing this file'
|
|
127
|
-
}
|
|
128
|
-
]
|
|
117
|
+
process.stdout.write(JSON.stringify({
|
|
118
|
+
hookSpecificOutput: {
|
|
119
|
+
hookEventName: 'PreToolUse',
|
|
120
|
+
permissionDecision: 'ask',
|
|
121
|
+
permissionDecisionReason: `Sensitive file access requires approval: ${basename}`
|
|
129
122
|
}
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
return [
|
|
133
|
-
'NOTE: This is not an error. This block protects sensitive data.',
|
|
134
|
-
'',
|
|
135
|
-
`PRIVACY BLOCK: Sensitive file access requires user approval`,
|
|
136
|
-
`File: ${filePath}`,
|
|
137
|
-
'',
|
|
138
|
-
'@@PRIVACY_PROMPT_START@@',
|
|
139
|
-
JSON.stringify(promptData, null, 2),
|
|
140
|
-
'@@PRIVACY_PROMPT_END@@',
|
|
141
|
-
'',
|
|
142
|
-
'Claude Code follow-up:',
|
|
143
|
-
`- If approved: use bash to read: cat "${filePath}"`,
|
|
144
|
-
'- If denied: continue without this file'
|
|
145
|
-
].join('\n');
|
|
123
|
+
}) + '\n');
|
|
146
124
|
}
|
|
147
125
|
|
|
148
126
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
@@ -160,22 +138,21 @@ try {
|
|
|
160
138
|
if (!paths.length) process.exit(0);
|
|
161
139
|
|
|
162
140
|
for (const filePath of paths) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
console.error(`WARN: Privacy-sensitive file access via bash allowed for approved follow-up: ${path.basename(filePath)}`);
|
|
174
|
-
process.exit(0);
|
|
141
|
+
const target = resolveTarget(filePath, cwd);
|
|
142
|
+
|
|
143
|
+
// The real target is authoritative: an exempt-looking alias must not hide
|
|
144
|
+
// a sensitive target, while a real .env.example target remains safe.
|
|
145
|
+
if (target) {
|
|
146
|
+
if (isSensitive(target) && !isSafe(target)) {
|
|
147
|
+
askForPermission(filePath);
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
if (isSafe(target)) continue;
|
|
175
151
|
}
|
|
176
152
|
|
|
177
|
-
|
|
178
|
-
|
|
153
|
+
if (isSafe(filePath) || !isSensitive(filePath)) continue;
|
|
154
|
+
askForPermission(filePath);
|
|
155
|
+
process.exit(0);
|
|
179
156
|
}
|
|
180
157
|
|
|
181
158
|
process.exit(0);
|