@haposoft/cafekit 0.16.0 → 0.16.1

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 (57) hide show
  1. package/README.md +1 -1
  2. package/bin/__tests__/codex-hooks.test.js +125 -0
  3. package/bin/__tests__/develop-contract.test.js +43 -1
  4. package/bin/__tests__/grok-envelope.test.js +271 -0
  5. package/bin/__tests__/grok-platform.test.js +133 -0
  6. package/bin/__tests__/hook-payload.test.js +194 -0
  7. package/bin/__tests__/omp-bridge.test.js +168 -0
  8. package/bin/__tests__/omp-hooks.test.js +178 -0
  9. package/bin/__tests__/omp-runtime.test.js +150 -0
  10. package/bin/__tests__/runtime-dir.test.js +158 -0
  11. package/bin/__tests__/runtime-schema.test.js +5 -3
  12. package/bin/__tests__/specs-v2-execution-closeout.test.js +28 -1
  13. package/bin/install.js +5 -5
  14. package/bin/lib/context.js +50 -3
  15. package/bin/lib/i18n.js +3 -30
  16. package/bin/phases/copy-payload.js +7 -0
  17. package/bin/phases/omp-runtime.js +140 -0
  18. package/bin/phases/root-config.js +3 -0
  19. package/bin/phases/select-platform.js +22 -1
  20. package/bin/phases/summary.js +0 -4
  21. package/package.json +1 -1
  22. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +2 -0
  23. package/src/claude/hooks/__tests__/spec-gate.test.js +338 -0
  24. package/src/claude/hooks/__tests__/state.test.js +2 -0
  25. package/src/claude/hooks/agent.cjs +12 -7
  26. package/src/claude/hooks/completion-authority.cjs +10 -4
  27. package/src/claude/hooks/docs-sync.cjs +2 -1
  28. package/src/claude/hooks/inspect-block.cjs +27 -7
  29. package/src/claude/hooks/lib/config.cjs +5 -2
  30. package/src/claude/hooks/lib/counter.cjs +7 -5
  31. package/src/claude/hooks/lib/hook-payload.cjs +160 -0
  32. package/src/claude/hooks/lib/hook-state-dir.cjs +2 -2
  33. package/src/claude/hooks/lib/runtime-dir.cjs +49 -0
  34. package/src/claude/hooks/precompact.cjs +2 -1
  35. package/src/claude/hooks/privacy-block.cjs +4 -2
  36. package/src/claude/hooks/rules.cjs +4 -2
  37. package/src/claude/hooks/secret-output-guardrail.cjs +2 -1
  38. package/src/claude/hooks/semantic-review-authority.cjs +6 -4
  39. package/src/claude/hooks/session.cjs +10 -8
  40. package/src/claude/hooks/spec-gate.cjs +18 -8
  41. package/src/claude/hooks/spec-state.cjs +9 -5
  42. package/src/claude/hooks/state.cjs +6 -4
  43. package/src/claude/hooks/task-scaffold-guard.cjs +29 -8
  44. package/src/claude/hooks/usage.cjs +5 -4
  45. package/src/claude/migration-manifest.json +2 -0
  46. package/src/claude/rules/state-sync.md +7 -1
  47. package/src/claude/rules/workflow.md +2 -0
  48. package/src/claude/scripts/provenance.cjs +11 -4
  49. package/src/claude/scripts/spec-receipt.cjs +65 -4
  50. package/src/claude/skills/specs/SKILL.md +10 -11
  51. package/src/codex/hooks/spec-state.cjs +1 -1
  52. package/src/codex/rules/state-sync.md +7 -1
  53. package/src/omp/extensions/cafekit-bridge.mjs +233 -0
  54. package/src/omp/hooks/privacy-block.cjs +250 -0
  55. package/src/omp/runtime.json +33 -0
  56. package/bin/lib/rtk-run.js +0 -28
  57. package/bin/phases/setup-rtk.js +0 -159
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Copyright (c) 2026 Haposoft. MIT License.
3
+ *
4
+ * CafeKit bridge for Oh My Pi.
5
+ *
6
+ * omp has no hooks.json. It loads extensions from `.omp/extensions/` and emits lifecycle
7
+ * events to them. This extension is the only thing that stands between those events and
8
+ * CafeKit's gate scripts in `.omp/hooks/`: it shapes each omp event into the Claude-shaped
9
+ * payload the scripts read, runs the scripts as child processes, and translates their
10
+ * verdicts back into omp's contract.
11
+ *
12
+ * Three facts read from the installed omp binary drive the design:
13
+ * - omp fails CLOSED at `tool_call`: a handler that throws or exceeds
14
+ * `extensionHandlers.toolCallTimeoutMs` (default 30000) is replaced by omp's own
15
+ * `{ block: true, reason: "Extension … timed out" }`. The bridge therefore keeps a
16
+ * shorter budget so a slow gate blocks with its own name instead of a generic reason.
17
+ * - omp's `input` payload is `{ type, text, images, source }` with no session id or cwd.
18
+ * Session-scoped hooks (`rules.cjs`) exit when the id is missing, so the bridge mints one
19
+ * per load and reuses it; omp loads an extension once per session.
20
+ * - `session_stop` carries `stop_hook_active`, the same field `spec-gate.cjs` uses to avoid
21
+ * re-blocking its own continuation. The bridge honours it before dispatching anything.
22
+ *
23
+ * Not mapped, because omp has no such events: SubagentStart (`agent.cjs`) and SubagentStop
24
+ * (`semantic-review-authority.cjs`). Task 04 documents this gap for operators.
25
+ */
26
+
27
+ import { spawn } from 'node:child_process';
28
+ import { randomUUID } from 'node:crypto';
29
+ import { existsSync } from 'node:fs';
30
+ import path from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+
33
+ /** Stay under omp's 30000 ms cut-off so our reason wins over its substitute. */
34
+ export const BRIDGE_TIMEOUT_MS = 8000;
35
+
36
+ /**
37
+ * omp event → the CafeKit hooks registered for the equivalent Claude event.
38
+ * Mirrors src/claude/settings/settings.json; `omp-bridge.test.js` fails if the two drift.
39
+ */
40
+ export const DISPATCH = {
41
+ session_start: { event: 'SessionStart', hooks: ['session.cjs', 'docs-sync.cjs', 'state.cjs'] },
42
+ session_compact: { event: 'SessionStart', hooks: ['session.cjs'], source: 'compact' },
43
+ session_before_compact: { event: 'PreCompact', hooks: ['precompact.cjs'] },
44
+ input: { event: 'UserPromptSubmit', hooks: ['secret-output-guardrail.cjs', 'rules.cjs', 'completion-authority.cjs', 'spec-state.cjs', 'usage.cjs'] },
45
+ tool_call: { event: 'PreToolUse', hooks: ['privacy-block.cjs', 'inspect-block.cjs', 'task-scaffold-guard.cjs'] },
46
+ tool_result: { event: 'PostToolUse', hooks: ['state.cjs', 'usage.cjs'] },
47
+ session_stop: { event: 'Stop', hooks: ['spec-gate.cjs', 'completion-authority.cjs', 'state.cjs'] },
48
+ };
49
+
50
+ /** One id per extension load. omp loads an extension once per session, so this is per-session. */
51
+ const SESSION_ID = `omp-${randomUUID()}`;
52
+
53
+ export function hooksDir() {
54
+ if (process.env.CAFEKIT_OMP_HOOKS_DIR) return process.env.CAFEKIT_OMP_HOOKS_DIR;
55
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'hooks');
56
+ }
57
+
58
+ /** Build the Claude-shaped payload a CafeKit hook reads on stdin. */
59
+ export function shapePayload(kind, event, ctx) {
60
+ const entry = DISPATCH[kind];
61
+ const cwd = ctx?.cwd || process.cwd();
62
+ const payload = {
63
+ hook_event_name: entry.event,
64
+ session_id: event?.session_id || SESSION_ID,
65
+ cwd,
66
+ };
67
+ switch (kind) {
68
+ case 'session_start':
69
+ payload.source = event?.reason === 'resume' ? 'resume' : 'startup';
70
+ break;
71
+ case 'session_compact':
72
+ payload.source = 'compact';
73
+ break;
74
+ case 'session_before_compact':
75
+ payload.trigger = event?.reason === 'manual' ? 'manual' : 'auto';
76
+ break;
77
+ case 'input':
78
+ payload.prompt = typeof event?.text === 'string' ? event.text : '';
79
+ break;
80
+ case 'tool_call':
81
+ payload.tool_name = event?.toolName ?? event?.tool_name ?? '';
82
+ payload.tool_input = event?.input ?? {};
83
+ payload.tool_use_id = event?.toolCallId ?? null;
84
+ break;
85
+ case 'tool_result':
86
+ payload.tool_name = event?.toolName ?? event?.tool_name ?? '';
87
+ payload.tool_input = event?.input ?? {};
88
+ payload.tool_response = { content: event?.content ?? [], isError: event?.isError === true };
89
+ payload.tool_use_id = event?.toolCallId ?? null;
90
+ break;
91
+ case 'session_stop':
92
+ payload.stop_hook_active = event?.stop_hook_active === true;
93
+ payload.last_assistant_message = event?.last_assistant_message ?? null;
94
+ break;
95
+ default:
96
+ break;
97
+ }
98
+ return payload;
99
+ }
100
+
101
+ /**
102
+ * Read a hook's verdict. No CafeKit hook writes a block reason to stderr; the three
103
+ * mechanisms in use are stdout JSON `decision:"block"`, stdout JSON
104
+ * `hookSpecificOutput.permissionDecision` of `deny` (or `ask`, which omp cannot express),
105
+ * and exit 2 with the reason on stdout. Anything else is an allow, and any stdout text
106
+ * that is not a verdict is context to inject.
107
+ */
108
+ export function interpretVerdict({ code, stdout = '', stderr = '' }, hookName) {
109
+ const out = String(stdout).trim();
110
+ const lines = out ? out.split('\n') : [];
111
+ const inject = [];
112
+ let block = null;
113
+
114
+ for (const line of lines) {
115
+ let parsed = null;
116
+ try { parsed = JSON.parse(line); } catch { parsed = null; }
117
+ if (!parsed || typeof parsed !== 'object') { inject.push(line); continue; }
118
+
119
+ if (parsed.decision === 'block') {
120
+ block = block || { reason: parsed.reason || `Blocked by ${hookName}` };
121
+ continue;
122
+ }
123
+ const decision = parsed.hookSpecificOutput?.permissionDecision;
124
+ if (decision === 'deny' || decision === 'ask') {
125
+ block = block || { reason: parsed.hookSpecificOutput.permissionDecisionReason || `Blocked by ${hookName}` };
126
+ continue;
127
+ }
128
+ const extra = parsed.hookSpecificOutput?.additionalContext;
129
+ if (typeof extra === 'string' && extra) inject.push(extra);
130
+ }
131
+
132
+ if (!block && code === 2) {
133
+ block = { reason: out || String(stderr).trim() || `Blocked by ${hookName}` };
134
+ }
135
+ return { block: Boolean(block), reason: block?.reason, inject: inject.join('\n').trim() };
136
+ }
137
+
138
+ /** Run one hook as a child process with a hard budget. Never throws. */
139
+ export function dispatchHook(hookPath, payload, { cwd, timeoutMs = BRIDGE_TIMEOUT_MS } = {}) {
140
+ return new Promise((resolve) => {
141
+ let child;
142
+ try {
143
+ child = spawn(process.execPath, [hookPath], {
144
+ cwd,
145
+ env: { ...process.env, PROJECT_ROOT: cwd },
146
+ stdio: ['pipe', 'pipe', 'pipe'],
147
+ });
148
+ } catch (error) {
149
+ resolve({ code: null, stdout: '', stderr: String(error?.message || error), timedOut: false, failed: true });
150
+ return;
151
+ }
152
+ const out = []; const err = [];
153
+ let settled = false;
154
+ const finish = (result) => { if (!settled) { settled = true; resolve(result); } };
155
+ const timer = setTimeout(() => {
156
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
157
+ finish({ code: null, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8'), timedOut: true, failed: true });
158
+ }, timeoutMs);
159
+ child.stdout.on('data', (c) => out.push(c));
160
+ child.stderr.on('data', (c) => err.push(c));
161
+ child.on('error', (error) => { clearTimeout(timer); finish({ code: null, stdout: '', stderr: String(error?.message || error), timedOut: false, failed: true }); });
162
+ child.on('close', (code) => { clearTimeout(timer); finish({ code, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8'), timedOut: false, failed: false }); });
163
+ try { child.stdin.write(JSON.stringify(payload)); child.stdin.end(); } catch { /* close handler still runs */ }
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Dispatch every hook for an omp event and fold their verdicts. The first block wins and
169
+ * short-circuits; injected context from allowing hooks is concatenated. A hook that fails
170
+ * or times out on a gating event counts as a block naming that hook, matching omp's own
171
+ * fail-closed posture rather than fighting it.
172
+ */
173
+ export async function runEvent(kind, event, ctx, { timeoutMs = BRIDGE_TIMEOUT_MS, dir = hooksDir() } = {}) {
174
+ const entry = DISPATCH[kind];
175
+ if (!entry) return { block: false, inject: '' };
176
+
177
+ // A Stop that omp re-fires after our own block must not block again (infinite loop).
178
+ if (kind === 'session_stop' && event?.stop_hook_active === true) return { block: false, inject: '', skipped: 'stop_hook_active' };
179
+
180
+ const gating = kind === 'tool_call' || kind === 'session_stop';
181
+ const payload = shapePayload(kind, event, ctx);
182
+ const injections = [];
183
+
184
+ for (const hookName of entry.hooks) {
185
+ const hookPath = path.join(dir, hookName);
186
+ if (!existsSync(hookPath)) {
187
+ // A missing gate must be loud on gating events; silently allowing is the worst outcome.
188
+ if (gating) return { block: true, reason: `CafeKit gate ${hookName} is not installed under ${dir}`, hook: hookName };
189
+ continue;
190
+ }
191
+ const result = await dispatchHook(hookPath, payload, { cwd: payload.cwd, timeoutMs });
192
+ if (result.failed) {
193
+ if (gating) {
194
+ const why = result.timedOut ? `exceeded ${timeoutMs} ms` : (result.stderr.trim() || 'failed to run');
195
+ return { block: true, reason: `CafeKit gate ${hookName} ${why}`, hook: hookName };
196
+ }
197
+ continue;
198
+ }
199
+ const verdict = interpretVerdict(result, hookName);
200
+ if (verdict.block) return { block: true, reason: verdict.reason, hook: hookName };
201
+ if (verdict.inject) injections.push(verdict.inject);
202
+ }
203
+ return { block: false, inject: injections.join('\n\n') };
204
+ }
205
+
206
+ /** Extension entry point: omp calls this once with its ExtensionAPI. */
207
+ export default function cafekitBridge(pi) {
208
+ const opts = {};
209
+
210
+ pi.on('session_start', async (event, ctx) => { await runEvent('session_start', event, ctx, opts); });
211
+ pi.on('session_compact', async (event, ctx) => { await runEvent('session_compact', event, ctx, opts); });
212
+ pi.on('session_before_compact', async (event, ctx) => { await runEvent('session_before_compact', event, ctx, opts); });
213
+ pi.on('tool_result', async (event, ctx) => { await runEvent('tool_result', event, ctx, opts); });
214
+
215
+ pi.on('tool_call', async (event, ctx) => {
216
+ const r = await runEvent('tool_call', event, ctx, opts);
217
+ return r.block ? { block: true, reason: r.reason } : undefined;
218
+ });
219
+
220
+ // omp cannot block a prompt, so a hook's block reason on input is surfaced as context.
221
+ pi.on('input', async (event, ctx) => {
222
+ const r = await runEvent('input', event, ctx, opts);
223
+ const extra = [r.block ? r.reason : '', r.inject].filter(Boolean).join('\n\n');
224
+ if (!extra) return { action: 'continue' };
225
+ return { action: 'transform', text: `${event.text}\n\n${extra}`, images: event.images };
226
+ });
227
+
228
+ pi.on('session_stop', async (event, ctx) => {
229
+ const r = await runEvent('session_stop', event, ctx, opts);
230
+ if (r.block) return { decision: 'block', reason: r.reason };
231
+ return r.inject ? { continue: true, additionalContext: r.inject } : undefined;
232
+ });
233
+ }
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Copyright (c) 2026 Haposoft. MIT License.
4
+ *
5
+ * PreToolUse Hook — privacy-block.cjs
6
+ *
7
+ * Claude Code CLI privacy gate for sensitive files.
8
+ *
9
+ * Runtime contract:
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
13
+ *
14
+ * Exit: 0 (the JSON hook output carries the permission decision)
15
+ */
16
+
17
+ try {
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { commandAccess } = require('./lib/privacy-command-analysis.cjs');
21
+
22
+ const RESTRICTED_PATTERNS = [
23
+ /^\.env(?:[.\[*?{]|$)/i,
24
+ /^credentials/i,
25
+ /secrets?\.(ya?ml|json)$/i,
26
+ /\.pem$/i,
27
+ /\.key$/i,
28
+ /\.p12$/i,
29
+ /\.pfx$/i,
30
+ /^id_(rsa|ed25519|ecdsa|dsa)$/i,
31
+ /\.netrc$/i,
32
+ /\.pgpass$/i,
33
+ /kubeconfig/i,
34
+ /\.keystore$/i,
35
+ /\.jks$/i,
36
+ /auth\.json$/i,
37
+ /token(s)?\.json$/i
38
+ ];
39
+
40
+ const ALLOWED_EXEMPTIONS = [
41
+ /\.env\.(example|sample|template|test)$/i
42
+ ];
43
+
44
+ const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
45
+ function readRuntime(cwd) {
46
+ try {
47
+ const file = runtimePath(cwd, 'runtime.json');
48
+ return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ function isSafe(filePath) {
55
+ const base = path.basename(filePath);
56
+ return ALLOWED_EXEMPTIONS.some((rule) => rule.test(base) || rule.test(filePath));
57
+ }
58
+
59
+ function isSensitive(filePath) {
60
+ const base = path.basename(filePath);
61
+ return RESTRICTED_PATTERNS.some((rule) => rule.test(base) || rule.test(filePath));
62
+ }
63
+
64
+ /**
65
+ * Resolve a symlink to its real target so a harmless-looking name that points
66
+ * at a sensitive file cannot slip through the basename check. Returns null
67
+ * when the path cannot be resolved (missing file, broken link) — fail-open to
68
+ * the original-path check in that case. See isSymlinkToSensitive for dangling handling.
69
+ */
70
+ function resolveTarget(filePath, cwd) {
71
+ try {
72
+ const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
73
+ return fs.realpathSync(requested);
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * When realpath fails, check whether the requested path is a symlink (or chain)
81
+ * whose lexical target is sensitive. Dangling link to protected target -> true (fail-closed ask);
82
+ * dangling benign link or link to allowed example/template -> false (allow).
83
+ * Limit hops to avoid loops; malformed/looping -> fail closed.
84
+ * TOCTOU note: check and use are not atomic; target could be swapped between this check and the actual read.
85
+ * This is a best-effort guardrail, not an arbitrary-shell security boundary. For strong isolation use OS sandbox.
86
+ */
87
+ function isSymlinkToSensitive(filePath, cwd) {
88
+ try {
89
+ const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
90
+ let current = requested;
91
+ let hops = 0;
92
+ const MAX_HOPS = 8;
93
+ while (hops < MAX_HOPS) {
94
+ let stat;
95
+ try {
96
+ stat = fs.lstatSync(current);
97
+ } catch {
98
+ return false;
99
+ }
100
+ if (!stat.isSymbolicLink()) return false;
101
+ let linkTarget;
102
+ try {
103
+ linkTarget = fs.readlinkSync(current);
104
+ } catch {
105
+ return true;
106
+ }
107
+ if (isSensitive(linkTarget) && !isSafe(linkTarget)) return true;
108
+ if (isSafe(linkTarget)) return false;
109
+ const next = path.isAbsolute(linkTarget) ? linkTarget : path.resolve(path.dirname(current), linkTarget);
110
+ if (next === current) return true;
111
+ try {
112
+ const real = fs.realpathSync(next);
113
+ if (isSensitive(real) && !isSafe(real)) return true;
114
+ if (isSafe(real)) return false;
115
+ return false;
116
+ } catch {
117
+ current = next;
118
+ hops += 1;
119
+ continue;
120
+ }
121
+ }
122
+ return true;
123
+ } catch {
124
+ return true;
125
+ }
126
+ }
127
+
128
+ function extractBashPaths(command) {
129
+ // The analyzer parses the command and returns the operands of every reading
130
+ // command it recognises, nested shells and `find -exec` included. Treating
131
+ // every unresolved expansion as sensitive, as this did before, asked on
132
+ // `cat $FILE`, `wc -l *.cjs` and `cat ~/.zshrc` alike. Noise is what trains
133
+ // people to click through the prompt that matters.
134
+ // A heredoc whose delimiter is quoted (<<'BODY') is inert: the shell
135
+ // expands nothing inside it, so no word in the body is ever read. The
136
+ // analyzer has no notion of heredocs and parses the body as shell, so prose
137
+ // that quotes a command -- a PR body carrying `cat .env` in backticks --
138
+ // came back as a real command substitution. Blank those bodies out first.
139
+ // An unquoted delimiter (<<BODY) does expand, so it is left to the
140
+ // analyzer.
141
+ const scrubbed = String(command).replace(
142
+ /(<<-?[ \t]*)(['"])([A-Za-z_]\w*)\2[^\n]*\n[\s\S]*?\n[ \t]*\3\b/g,
143
+ '$1$2$3$2'
144
+ );
145
+
146
+ const paths = new Set(commandAccess(scrubbed).paths);
147
+
148
+ // Two shapes the analyzer cannot return. It drops any word carrying an
149
+ // expansion, so `~/.ssh/id_rsa` and `certs/*.pem` still have to be matched
150
+ // by name. And it only walks commands it recognises, so an unknown tool
151
+ // handed a secret through a file-shaped option, such as
152
+ // `docker compose --env-file .env`, is invisible to it.
153
+ const FILE_OPTION = /^--?[\w-]*(?:file|config|key|cert|identity)$/i;
154
+ const tokens = scrubbed.split(/[\s|;&<>"'()`]+/);
155
+ for (let index = 0; index < tokens.length; index += 1) {
156
+ const token = tokens[index];
157
+ const shaped = /^~|[*?[{]/.test(token);
158
+ const optionFed = index > 0 && FILE_OPTION.test(tokens[index - 1]);
159
+ if (!shaped && !optionFed) continue;
160
+ const value = token.replace(/^~/, '').replace(/^["'()`]+|["'(),]+$/g, '');
161
+ if (value && isSensitive(value) && !isSafe(value)) paths.add(value);
162
+ }
163
+
164
+ return [...paths];
165
+ }
166
+
167
+ function extractPaths(toolName, input) {
168
+ const paths = [];
169
+ if (!input) return paths;
170
+
171
+ for (const key of ['file_path', 'path']) {
172
+ if (typeof input[key] === 'string' && input[key].trim()) {
173
+ paths.push(input[key].trim());
174
+ }
175
+ }
176
+
177
+ for (const key of ['paths', 'search_paths']) {
178
+ if (Array.isArray(input[key])) {
179
+ paths.push(...input[key].filter(Boolean));
180
+ }
181
+ }
182
+
183
+ if (toolName === 'Bash' && typeof input.command === 'string') {
184
+ paths.push(...extractBashPaths(input.command));
185
+ }
186
+
187
+ return paths.filter(Boolean);
188
+ }
189
+
190
+ function askForPermission(filePath) {
191
+ const basename = path.basename(filePath);
192
+ process.stdout.write(JSON.stringify({
193
+ hookSpecificOutput: {
194
+ hookEventName: 'PreToolUse',
195
+ // omp has no ask state on tool_call, so an ask under Claude becomes a denial here.
196
+ permissionDecision: 'deny',
197
+ permissionDecisionReason: `Sensitive file access requires approval: ${basename}`
198
+ }
199
+ }) + '\n');
200
+ }
201
+
202
+ const stdin = fs.readFileSync(0, 'utf8').trim();
203
+ if (!stdin) process.exit(0);
204
+
205
+ const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
206
+ const data = normalizeHookPayload(JSON.parse(stdin));
207
+ const toolName = data.tool_name || '';
208
+ const toolInput = data.tool_input || {};
209
+ const cwd = typeof data.cwd === 'string' && data.cwd.trim() ? data.cwd : process.cwd();
210
+ const runtime = readRuntime(cwd);
211
+
212
+ if (runtime.privacyBlock === false) process.exit(0);
213
+
214
+ const paths = extractPaths(toolName, toolInput);
215
+ if (!paths.length) process.exit(0);
216
+
217
+ for (const filePath of paths) {
218
+ const target = resolveTarget(filePath, cwd);
219
+
220
+ // The real target is authoritative: an exempt-looking alias must not hide
221
+ // a sensitive target, while a real .env.example target remains safe.
222
+ if (target) {
223
+ if (isSensitive(target) && !isSafe(target)) {
224
+ askForPermission(filePath);
225
+ process.exit(0);
226
+ }
227
+ if (isSafe(target)) continue;
228
+ } else {
229
+ if (isSymlinkToSensitive(filePath, cwd)) {
230
+ askForPermission(filePath);
231
+ process.exit(0);
232
+ }
233
+ }
234
+
235
+ if (isSafe(filePath) || !isSensitive(filePath)) continue;
236
+ askForPermission(filePath);
237
+ process.exit(0);
238
+ }
239
+
240
+ process.exit(0);
241
+ } catch (_) {
242
+ process.stdout.write(JSON.stringify({
243
+ hookSpecificOutput: {
244
+ hookEventName: 'PreToolUse',
245
+ permissionDecision: 'deny',
246
+ permissionDecisionReason: 'Sensitive access could not be evaluated safely.'
247
+ }
248
+ }) + '\n');
249
+ process.exit(0);
250
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "$schema": "./runtime.schema.json",
3
+ "privacyBlock": true,
4
+ "inspect": {
5
+ "enabled": true
6
+ },
7
+ "docs": {
8
+ "maxLoc": 800
9
+ },
10
+ "paths": {
11
+ "docs": "docs",
12
+ "specs": "specs",
13
+ "plans": "plans"
14
+ },
15
+ "spec": {
16
+ "scaffold_guard": true,
17
+ "completion_gate": true,
18
+ "tollgate": true
19
+ },
20
+ "develop": {
21
+ "parallel": true
22
+ },
23
+ "locale": {
24
+ "thinkingLanguage": null,
25
+ "responseLanguage": null
26
+ },
27
+ "codingLevel": 1,
28
+ "usage": {
29
+ "enabled": false
30
+ },
31
+ "hooks": {},
32
+ "assertions": []
33
+ }
@@ -1,28 +0,0 @@
1
- /**
2
- * Local captured-spawnSync helper for the rtk install phase.
3
- *
4
- * Why local: `lib/skill-deps.js` does not export a `run(cmd, args)` helper
5
- * (it uses `runAsync` internally and never re-exports it). The rtk phase
6
- * needs a synchronous, captured spawnSync (R2.2, R4.2) that returns
7
- * `{ ok, status, stdout, stderr }` — same shape as `skill-deps.runAsync` but
8
- * blocking. KISS: one 20-line module, no dependency on `lib/skill-deps.js`
9
- * (which is skill-domain and not a generic command runner).
10
- *
11
- * @param {string} cmd
12
- * @param {string[]} args
13
- * @param {object} [opts] — passed through to spawnSync (e.g. { timeout })
14
- * @returns {{ ok: boolean, status: number|null, stdout: string, stderr: string }}
15
- */
16
- const { spawnSync } = require('child_process');
17
-
18
- function run(cmd, args, opts = {}) {
19
- const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
20
- return {
21
- ok: !r.error && r.status === 0,
22
- status: r.status,
23
- stdout: r.stdout || '',
24
- stderr: r.stderr || ''
25
- };
26
- }
27
-
28
- module.exports = { run };