@haposoft/cafekit 0.18.1 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/bin/__tests__/adv-skill.test.js +371 -0
- package/bin/__tests__/agent-authority.test.js +38 -0
- package/bin/__tests__/agent-instructions.test.js +54 -0
- package/bin/__tests__/claude-plugin-build.test.js +151 -0
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +812 -12
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +198 -58
- package/bin/__tests__/codex-plugin-build.test.js +230 -0
- package/bin/__tests__/codex-plugin-context.test.js +135 -0
- package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +1 -1
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +81 -21
- package/bin/__tests__/migrate-apply.test.js +373 -0
- package/bin/__tests__/migrate-cli.test.js +152 -0
- package/bin/__tests__/migrate-merged.test.js +269 -0
- package/bin/__tests__/migrate-plan.test.js +271 -0
- package/bin/__tests__/omp-bridge.test.js +147 -17
- package/bin/__tests__/omp-hooks.test.js +35 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +501 -0
- package/bin/__tests__/usage-hook-retired.test.js +29 -9
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +549 -0
- package/bin/install.js +124 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +141 -12
- package/bin/lib/context.js +6 -1
- package/bin/lib/lock.js +98 -18
- package/bin/lib/managed-writer.js +16 -10
- package/bin/lib/migrate-apply.js +202 -0
- package/bin/lib/migrate-merged.js +342 -0
- package/bin/lib/migrate-plan.js +419 -0
- package/bin/lib/plugin-inventory.js +33 -0
- package/bin/phases/claude-runtime.js +93 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +134 -33
- package/bin/phases/codex-runtime.js +110 -85
- package/bin/phases/copy-payload.js +50 -4
- package/bin/phases/omp-runtime.js +4 -26
- package/bin/phases/root-config.js +5 -2
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +5 -1
- package/src/claude/agents/advisor.md +55 -0
- package/src/claude/agents/debugger.md +2 -3
- package/src/claude/agents/deployer.md +2 -1
- package/src/claude/agents/docs-keeper.md +12 -0
- package/src/claude/agents/inspector.md +1 -1
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/researcher.md +0 -1
- package/src/claude/agents/spec-maker.md +4 -1
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
- package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
- package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
- package/src/claude/hooks/__tests__/precompact.test.js +213 -11
- package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +11 -7
- package/src/claude/hooks/inspect-block.cjs +20 -66
- package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/plugin-options.cjs +47 -0
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
- package/src/claude/hooks/lib/privacy-paths.cjs +673 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +80 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/plugin-context.cjs +80 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +25 -206
- package/src/claude/hooks/rules.cjs +10 -29
- package/src/claude/hooks/session.cjs +37 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +44 -59
- package/src/claude/hooks/state.cjs +101 -52
- package/src/claude/migration-manifest.json +14 -2
- package/src/claude/plugin/commands/setup-statusline.md +12 -0
- package/src/claude/plugin/core.md +71 -0
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/manage-docs.md +1 -2
- package/src/claude/rules/review-audit-self-decision.md +1 -1
- package/src/claude/rules/skill-workflow-routing.md +1 -1
- package/src/claude/rules/state-sync.md +14 -0
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
- package/src/claude/scripts/provenance.cjs +54 -16
- package/src/claude/scripts/setup-statusline.cjs +88 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/adv/SKILL.md +139 -0
- package/src/claude/skills/adv/references/optional-modes.md +41 -0
- package/src/claude/skills/adv/references/ultra-mode.md +29 -0
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/brainstorm/SKILL.md +1 -1
- package/src/claude/skills/code-review/SKILL.md +4 -1
- package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
- package/src/claude/skills/develop/SKILL.md +5 -3
- package/src/claude/skills/develop/references/quality-gate.md +1 -1
- package/src/claude/skills/fix/SKILL.md +2 -2
- package/src/claude/skills/git/SKILL.md +5 -3
- package/src/claude/skills/git/references/merge-pr.md +125 -0
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/claude/skills/specs/references/review.md +1 -1
- package/src/claude/skills/specs/references/templates.md +2 -0
- package/src/claude/skills/vibe/SKILL.md +69 -0
- package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
- package/src/claude/skills/vibe/references/report-templates.md +84 -0
- package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
- package/src/codex/AGENTS.md +9 -1
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +15 -8
- package/src/codex/hooks/lib/hook-context.cjs +118 -4
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/plugin-options.cjs +2 -0
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +116 -17
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/state-store.cjs +5 -4
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +40 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +11 -216
- package/src/codex/hooks/rules.cjs +14 -34
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +59 -11
- package/src/codex/hooks/spec-gate.cjs +21 -6
- package/src/codex/hooks/spec-state.cjs +32 -6
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/plugin/core.md +76 -0
- package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
- package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/codex/scripts/setup-agents.cjs +124 -0
- package/src/common/AGENTS.md +1 -1
- package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
- package/src/omp/hooks/privacy-block.cjs +26 -207
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -17,6 +17,7 @@ try {
|
|
|
17
17
|
|
|
18
18
|
/** Read <runtime>/runtime.json */
|
|
19
19
|
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
20
|
+
const { projectRoot } = require('./lib/project-root.cjs');
|
|
20
21
|
function readRuntime(cwd) {
|
|
21
22
|
try {
|
|
22
23
|
const p = runtimePath(cwd, 'runtime.json');
|
|
@@ -43,20 +44,21 @@ try {
|
|
|
43
44
|
|
|
44
45
|
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
45
46
|
const payload = normalizeHookPayload(JSON.parse(stdin));
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
47
|
+
const root = projectRoot(payload);
|
|
48
|
+
// An npx install always has runtime.json; under the plugin it is optional.
|
|
49
|
+
// Loaded only under the plugin, so npx installs need no extra hook file.
|
|
50
|
+
const plugin = process.env.CLAUDE_PLUGIN_ROOT ? require('./lib/plugin-options.cjs') : null;
|
|
51
|
+
const runtime = readRuntime(root) || (plugin ? {} : null);
|
|
50
52
|
if (runtime === null) process.exit(0);
|
|
51
53
|
|
|
52
54
|
// Language config from runtime.json
|
|
53
55
|
const thinkLang = runtime.locale?.thinkingLanguage || '';
|
|
54
|
-
const respondLang = runtime.locale?.responseLanguage || '';
|
|
56
|
+
const respondLang = runtime.locale?.responseLanguage || (plugin ? plugin.pluginOption('response_language') : '');
|
|
55
57
|
// Default thinking to 'en' when only response language is set
|
|
56
58
|
const effectThink = thinkLang || (respondLang ? 'en' : '');
|
|
57
59
|
|
|
58
60
|
// Resolve paths from env (set by session.cjs) or runtime defaults
|
|
59
|
-
const baseDir =
|
|
61
|
+
const baseDir = root;
|
|
60
62
|
const plansPath = path.join(baseDir, runtime.paths?.plans || 'plans');
|
|
61
63
|
const docsPath = path.join(baseDir, runtime.paths?.docs || 'docs');
|
|
62
64
|
const specsPath = path.join(baseDir, runtime.paths?.specs || 'specs');
|
|
@@ -74,10 +76,12 @@ try {
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
// Python venv (optional — if <skills>/.venv exists)
|
|
77
|
-
const venv = resolveVenv(
|
|
79
|
+
const venv = resolveVenv(root);
|
|
78
80
|
|
|
79
81
|
lines.push('## Rules');
|
|
80
82
|
lines.push(`- Specs → ${specsPath}/ | Plans → ${plansPath}/ | Docs → ${docsPath}/`);
|
|
83
|
+
lines.push('- YAGNI · KISS · DRY');
|
|
84
|
+
lines.push('- Be concise. List unresolved questions at end.');
|
|
81
85
|
if (venv) {
|
|
82
86
|
lines.push(`- Python in ${skillsDir()}/: use \`${venv}\``);
|
|
83
87
|
lines.push('- Never use global pip install');
|
|
@@ -15,50 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
try {
|
|
17
17
|
const fs = require('fs');
|
|
18
|
-
const
|
|
19
|
-
const {
|
|
20
|
-
|
|
21
|
-
// Directories that should never be read (too large / irrelevant to LLM)
|
|
22
|
-
const BLOCKED_DIRS = [
|
|
23
|
-
'node_modules', 'dist', 'build', '.next', '.nuxt', '.output',
|
|
24
|
-
'__pycache__', '.venv', 'venv',
|
|
25
|
-
'vendor', 'target',
|
|
26
|
-
'.git', 'coverage', '.nyc_output',
|
|
27
|
-
];
|
|
28
|
-
|
|
29
|
-
// Glob patterns too broad to be useful
|
|
30
|
-
const BROAD_GLOB = [/^\*\*\/\*$/, /^\*\*\.\w+$/, /^\*\*\/\*\.\w+$/];
|
|
31
|
-
|
|
32
|
-
// Commands that are always allowed (build tools, package managers, venv)
|
|
33
|
-
const ALLOWED_CMD = /^(npm|pnpm|yarn|bun|npx|pnpx|bunx|tsc|vite|esbuild|webpack|rollup|turbo|nx|jest|vitest|eslint|prettier|go|cargo|make|mvn|gradle|dotnet|docker|kubectl|helm|python3?|pip|uv|deno|bundle|rake|php|composer|ruby|mix)\b/;
|
|
34
|
-
const VENV_EXEC = /(^|[\/\\])\.?venv[\/\\](bin|Scripts)[\/\\]/;
|
|
35
|
-
const VENV_CREATE = /^(python3?|py)\s+.*-m\s+venv\s+|^uv\s+venv(\s|$)/;
|
|
36
|
-
|
|
37
|
-
function isAllowedCommand(cmd) {
|
|
38
|
-
const s = cmd.trim().replace(/^(\w+=\S+\s+)+/, '').replace(/^(sudo|env|time)\s+/, '').trim();
|
|
39
|
-
return ALLOWED_CMD.test(s) || VENV_EXEC.test(s) || VENV_CREATE.test(s);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function isBlockedPath(p) {
|
|
43
|
-
return p.replace(/\\/g, '/').split('/').some(seg => BLOCKED_DIRS.includes(seg));
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function isBroadGlob(p) {
|
|
47
|
-
return BROAD_GLOB.some(r => r.test(p.trim()));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function extractPaths(toolName, input) {
|
|
51
|
-
const out = [];
|
|
52
|
-
if (!input) return out;
|
|
53
|
-
if (input.file_path) out.push(input.file_path);
|
|
54
|
-
if (input.path) out.push(input.path);
|
|
55
|
-
if (input.pattern) out.push(input.pattern);
|
|
56
|
-
if (typeof input.command === 'string') {
|
|
57
|
-
const m = input.command.match(/(?:cat|ls|find|head|tail)\s+(\S+)/g);
|
|
58
|
-
if (m) m.forEach(s => out.push(s.trim().split(/\s+/).pop()));
|
|
59
|
-
}
|
|
60
|
-
return out.filter(Boolean);
|
|
61
|
-
}
|
|
18
|
+
const { BLOCKED_DIRS, inspectInput } = require('./lib/inspect-paths.cjs');
|
|
19
|
+
const { projectRoot } = require('./lib/project-root.cjs');
|
|
62
20
|
|
|
63
21
|
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
64
22
|
function readRuntime(cwd) {
|
|
@@ -97,37 +55,33 @@ function denyWithReason(reason) {
|
|
|
97
55
|
const data = normalizeHookPayload(JSON.parse(stdin));
|
|
98
56
|
const toolName = data.tool_name || '';
|
|
99
57
|
const toolInput = data.tool_input || {};
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
58
|
+
let cwd = data.cwd || process.cwd();
|
|
59
|
+
try { cwd = fs.realpathSync(cwd); } catch { /* retain the payload cwd when unavailable */ }
|
|
60
|
+
const root = projectRoot(data);
|
|
61
|
+
const runtime = readRuntime(root);
|
|
62
|
+
|
|
63
|
+
// A plugin is trusted once for every repository, so a repository cannot switch it off.
|
|
64
|
+
const disabled = runtime.scout?.enabled === false || runtime.inspect?.enabled === false;
|
|
65
|
+
if (disabled && !require('./lib/plugin-options.cjs').isPlugin()) process.exit(0);
|
|
66
|
+
|
|
67
|
+
const result = inspectInput(toolName, toolInput, {
|
|
68
|
+
cwd, projectRoot: root
|
|
69
|
+
});
|
|
70
|
+
if (result.broadGlob) {
|
|
113
71
|
denyWithReason(
|
|
114
72
|
`SCOPE LIMIT EXCEEDED: Glob pattern is excessively broad\n` +
|
|
115
|
-
`Requested Pattern: ${
|
|
73
|
+
`Requested Pattern: ${result.broadGlob}\n\n` +
|
|
116
74
|
`Please narrow your scope (e.g., src/**/*.ts rather than **/*.ts).`
|
|
117
75
|
);
|
|
118
76
|
}
|
|
119
77
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
for (const p of paths) {
|
|
123
|
-
if (isBlockedPath(p)) {
|
|
124
|
-
const blocked = p.replace(/\\/g, '/').split('/').find(s => BLOCKED_DIRS.includes(s));
|
|
78
|
+
if (result.blockedPath) {
|
|
79
|
+
const { requested, blocked } = result.blockedPath;
|
|
125
80
|
denyWithReason(
|
|
126
81
|
`SCOPE LIMIT EXCEEDED: Directory "${blocked}/" is explicitly forbidden\n` +
|
|
127
|
-
`Requested Path: ${
|
|
128
|
-
`Restricted zones: ${BLOCKED_DIRS.join(', ')}`
|
|
82
|
+
`Requested Path: ${requested}\n` +
|
|
83
|
+
`Restricted zones: ${[...BLOCKED_DIRS].join(', ')}`
|
|
129
84
|
);
|
|
130
|
-
}
|
|
131
85
|
}
|
|
132
86
|
|
|
133
87
|
process.exit(0);
|
|
@@ -29,11 +29,29 @@ function isSourceTree(dir) {
|
|
|
29
29
|
return String(dir || '').includes(SOURCE_MARKER);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* State root for hooks running from the Claude Code plugin, or null outside it.
|
|
34
|
+
* The plugin root is replaced on every update and the project must stay clean,
|
|
35
|
+
* so plugin state lives in CLAUDE_PLUGIN_DATA, or the OS temp directory when the
|
|
36
|
+
* host gives no usable data directory.
|
|
37
|
+
*/
|
|
38
|
+
function pluginStateRoot() {
|
|
39
|
+
if (!process.env.CLAUDE_PLUGIN_ROOT) return null;
|
|
40
|
+
const data = process.env.CLAUDE_PLUGIN_DATA;
|
|
41
|
+
if (data && path.isAbsolute(data)) return data;
|
|
42
|
+
// Per-user name so accounts sharing /tmp do not collide; this is a fallback
|
|
43
|
+
// only, since the host sets CLAUDE_PLUGIN_DATA for plugin hooks.
|
|
44
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
|
|
45
|
+
return path.join(os.tmpdir(), `cafekit-plugin-${uid}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
/** Absolute directory for hook-generated state; create it before writing. */
|
|
33
49
|
function hookStateDir() {
|
|
50
|
+
const pluginRoot = pluginStateRoot();
|
|
51
|
+
if (pluginRoot) return path.join(pluginRoot, '.logs');
|
|
34
52
|
return isSourceTree(HOOKS_DIR)
|
|
35
53
|
? path.join(os.tmpdir(), 'cafekit-hook-logs', require('./runtime-dir.cjs').runtimeDirName().slice(1))
|
|
36
54
|
: path.join(HOOKS_DIR, '.logs');
|
|
37
55
|
}
|
|
38
56
|
|
|
39
|
-
module.exports = { hookStateDir, isSourceTree };
|
|
57
|
+
module.exports = { hookStateDir, isSourceTree, pluginStateRoot };
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const BLOCKED_DIRS = new Set([
|
|
6
|
+
'node_modules', 'dist', 'build', '.next', '.nuxt', '.output',
|
|
7
|
+
'__pycache__', '.venv', 'venv', 'vendor', 'target',
|
|
8
|
+
'.git', 'coverage', '.nyc_output'
|
|
9
|
+
]);
|
|
10
|
+
const BROAD_GLOBS = [/^\*\*\/\*$/, /^\*\*\.\w+$/, /^\*\*\/\*\.\w+$/];
|
|
11
|
+
const PATH_KEYS = /^(?:file_?paths?|paths?|search_?paths?|notebook_path|(?:source|target|destination)_?paths?|dirs?|director(?:y|ies)|folders?|cwd|root|locations?|uris?)$/i;
|
|
12
|
+
const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
|
|
13
|
+
const READERS = new Set(['cat', 'head', 'tail', 'nl', 'less', 'more', 'bat', 'ls', 'tree', 'find', 'rg', 'grep', 'sed', 'awk', 'wc', 'du', 'file', 'stat']);
|
|
14
|
+
const clean = (value) => String(value || '').trim();
|
|
15
|
+
const nameOf = (value) => String(value || '').replace(/\\/g, '/').split('/').pop();
|
|
16
|
+
function real(value) { try { return fs.realpathSync(value); } catch { return value; } }
|
|
17
|
+
function relativeInside(root, candidate) {
|
|
18
|
+
const relative = path.relative(root, candidate);
|
|
19
|
+
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ? relative : null;
|
|
20
|
+
}
|
|
21
|
+
function blockedSegment(value, { cwd, projectRoot }) {
|
|
22
|
+
let candidate = clean(value).replace(/\\/g, '/');
|
|
23
|
+
if (candidate === '~' || candidate.startsWith('~/')) candidate = os.homedir() + candidate.slice(1);
|
|
24
|
+
if (path.isAbsolute(candidate)) {
|
|
25
|
+
// Prefer project root over a nested session cwd. Compare lexical and canonical
|
|
26
|
+
// roots so /var and /private/var (or a symlink install) have the same boundary.
|
|
27
|
+
for (const base of [projectRoot, cwd].filter(base => typeof base === 'string' && base)) {
|
|
28
|
+
const root = path.resolve(base);
|
|
29
|
+
const relative = relativeInside(root, candidate) ?? relativeInside(real(root), candidate);
|
|
30
|
+
if (relative !== null) { candidate = relative; break; }
|
|
31
|
+
}
|
|
32
|
+
} else candidate = path.normalize(candidate);
|
|
33
|
+
return candidate.split('/').find((segment) => BLOCKED_DIRS.has(segment));
|
|
34
|
+
}
|
|
35
|
+
function narrowPath(value, context) {
|
|
36
|
+
if (context.cwd !== undefined && typeof context.cwd !== 'string') return false;
|
|
37
|
+
let requested = clean(value).replace(/\\/g, '/');
|
|
38
|
+
if (!requested) return false;
|
|
39
|
+
if (requested === '~' || requested.startsWith('~/')) requested = os.homedir() + requested.slice(1);
|
|
40
|
+
const root = path.resolve(context.projectRoot || context.cwd || process.cwd());
|
|
41
|
+
const candidate = path.resolve(context.cwd || root, requested);
|
|
42
|
+
const relative = relativeInside(root, candidate) ?? relativeInside(real(root), candidate);
|
|
43
|
+
// Both boundaries must hold: a symlink cannot turn an outside read into a
|
|
44
|
+
// narrow scope, nor point a lexical child at the project root or beyond it.
|
|
45
|
+
const canonical = relative === null ? candidate : real(path.resolve(real(root), relative));
|
|
46
|
+
return !!relative && !!relativeInside(real(root), canonical)
|
|
47
|
+
&& !blockedSegment(candidate, context) && !blockedSegment(canonical, context);
|
|
48
|
+
}
|
|
49
|
+
function collectStructured(value, key, toolName, paths, globs) {
|
|
50
|
+
if (typeof value === 'string') {
|
|
51
|
+
if (PATH_KEYS.test(key)) paths.push(value);
|
|
52
|
+
if ((toolName === 'Glob' && key === 'pattern') || /^(?:glob|globs)$/i.test(key)) globs.push(value);
|
|
53
|
+
} else if (Array.isArray(value)) {
|
|
54
|
+
for (const child of value) collectStructured(child, key, toolName, paths, globs);
|
|
55
|
+
} else if (value && typeof value === 'object') {
|
|
56
|
+
for (const [childKey, child] of Object.entries(value)) collectStructured(child, childKey, toolName, paths, globs);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A small shell lexer, not an executor: quoted prose is data, while substitutions
|
|
61
|
+
// contribute their own command groups. Heredoc bodies are data, never commands.
|
|
62
|
+
function commandGroups(command) {
|
|
63
|
+
function scan(start, closing = '') {
|
|
64
|
+
const groups = []; let group = []; let token = ''; let quote = ''; let index = start;
|
|
65
|
+
const heredocs = [];
|
|
66
|
+
const flush = () => { if (token) group.push(token); token = ''; };
|
|
67
|
+
const boundary = () => { flush(); if (group.length) groups.push(group); group = []; };
|
|
68
|
+
for (; index < command.length; index += 1) {
|
|
69
|
+
const char = command[index];
|
|
70
|
+
if (!quote && closing && char === closing) { boundary(); return { groups, index }; }
|
|
71
|
+
if (char === '\\' && quote !== "'") {
|
|
72
|
+
if (command[index + 1] !== '\n') token += command[index + 1] || '';
|
|
73
|
+
index += 1; continue;
|
|
74
|
+
}
|
|
75
|
+
if (quote === "'") { if (char === "'") quote = ''; else token += char; continue; }
|
|
76
|
+
if (char === '$' && command[index + 1] === '(') {
|
|
77
|
+
const sub = scan(index + 2, ')'); groups.push(...sub.groups);
|
|
78
|
+
token += '__substitution__'; index = sub.index; continue;
|
|
79
|
+
}
|
|
80
|
+
if (char === '`') {
|
|
81
|
+
const sub = scan(index + 1, '`'); groups.push(...sub.groups);
|
|
82
|
+
token += '__substitution__'; index = sub.index; continue;
|
|
83
|
+
}
|
|
84
|
+
if (quote) { if (char === quote) quote = ''; else token += char; continue; }
|
|
85
|
+
if (char === '"' || char === "'") { quote = char; continue; }
|
|
86
|
+
if (char === '(') {
|
|
87
|
+
boundary(); const sub = scan(index + 1, ')');
|
|
88
|
+
groups.push(...sub.groups); index = sub.index; continue;
|
|
89
|
+
}
|
|
90
|
+
if (command.startsWith('<<', index) && !command.startsWith('<<<', index)) {
|
|
91
|
+
const match = command.slice(index).match(/^<<(-?)[ \t]*((?:'[^']*'|"[^"]*"|\\[^\n]|[^\s;|&<>'"\\])+)/);
|
|
92
|
+
if (match) {
|
|
93
|
+
const delimiter = match[2].replace(/'([^']*)'|"([^"]*)"|\\(.)/g, (_, single, double, escaped) => single ?? double ?? escaped);
|
|
94
|
+
flush(); heredocs.push({ delimiter, tabs: !!match[1] });
|
|
95
|
+
index += match[0].length - 1; continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (char === '>' || char === '<') {
|
|
99
|
+
// Preserve operator identity (quoted > remains ordinary text). A numeric
|
|
100
|
+
// token touching the operator is its fd, not a positional argument.
|
|
101
|
+
if (/^\d+$/.test(token)) token = '';
|
|
102
|
+
flush();
|
|
103
|
+
const operator = command.slice(index).match(/^(?:>>|>&|>|<)/)[0];
|
|
104
|
+
group.push({ redirect: operator }); index += operator.length - 1; continue;
|
|
105
|
+
}
|
|
106
|
+
if (char === '\n') {
|
|
107
|
+
boundary();
|
|
108
|
+
for (const here of heredocs.splice(0)) {
|
|
109
|
+
while (index < command.length) {
|
|
110
|
+
const end = command.indexOf('\n', index + 1);
|
|
111
|
+
const stop = end < 0 ? command.length : end;
|
|
112
|
+
const line = command.slice(index + 1, stop);
|
|
113
|
+
index = stop;
|
|
114
|
+
if ((here.tabs ? line.replace(/^\t+/, '') : line) === here.delimiter) break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} else if (';&|'.includes(char)) {
|
|
118
|
+
boundary();
|
|
119
|
+
if (command[index + 1] === char) index += 1;
|
|
120
|
+
} else if (/\s/.test(char)) flush();
|
|
121
|
+
else token += char;
|
|
122
|
+
}
|
|
123
|
+
boundary(); return { groups, index };
|
|
124
|
+
}
|
|
125
|
+
return scan(0).groups;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const WRAPPER_OPTIONS = {
|
|
129
|
+
sudo: ['-u', '-g', '-h', '-p', '-C', '-T', '-R', '-D', '--user', '--group', '--host', '--prompt', '--chdir', '--chroot'],
|
|
130
|
+
env: ['-u', '--unset', '-C', '--chdir', '-S', '--split-string'],
|
|
131
|
+
time: ['-f', '--format', '-o', '--output'], command: [], builtin: [],
|
|
132
|
+
nice: ['-n', '--adjustment'], nohup: [],
|
|
133
|
+
xargs: ['-I', '-i', '-n', '-L', '-P', '-s', '-d', '-E', '-a', '--replace', '--max-args', '--max-lines', '--max-procs', '--arg-file', '--delimiter', '--eof'],
|
|
134
|
+
npm: ['-p', '--package', '-c', '--call', '--prefix', '--workspace', '-w'],
|
|
135
|
+
npx: ['-p', '--package', '-c', '--call', '--cache'],
|
|
136
|
+
pnpm: ['--dir', '-C', '--filter'], pnpx: ['-p', '--package'],
|
|
137
|
+
yarn: ['--cwd'], bunx: ['-p', '--package'],
|
|
138
|
+
};
|
|
139
|
+
function unwrap(group, state) {
|
|
140
|
+
let index = 0;
|
|
141
|
+
while (index < group.length) {
|
|
142
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(group[index] || '')) index += 1;
|
|
143
|
+
const name = nameOf(group[index]);
|
|
144
|
+
if (!Object.hasOwn(WRAPPER_OPTIONS, name)) return group.slice(index);
|
|
145
|
+
if (name === 'xargs') state.unknownCwd = true;
|
|
146
|
+
index += 1;
|
|
147
|
+
// npm/pnpm/yarn only wrap a reader in exec mode; run/build remain opaque.
|
|
148
|
+
let exec = !['npm', 'pnpm', 'yarn'].includes(name);
|
|
149
|
+
while (index < group.length) {
|
|
150
|
+
const token = group[index];
|
|
151
|
+
const cwdOptions = { env: ['-C', '--chdir'], sudo: ['-D', '--chdir'],
|
|
152
|
+
npm: ['--prefix'], pnpm: ['-C', '--dir'], yarn: ['--cwd'] };
|
|
153
|
+
if (cwdOptions[name]?.some(option => String(token).split('=')[0] === option
|
|
154
|
+
|| (option.length === 2 && String(token).startsWith(option)))) state.unknownCwd = true;
|
|
155
|
+
if (name === 'env' && /^-[^-]*C/.test(token)) state.unknownCwd = true;
|
|
156
|
+
if (token === 'exec' && !exec) { exec = true; index += 1; continue; }
|
|
157
|
+
if (token === '--') { index += 1; break; }
|
|
158
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { index += 1; continue; }
|
|
159
|
+
const commandOption = name === 'env' ? /^(?:-S|--split-string)(?:=|$)/ : ['npm', 'npx'].includes(name) ? /^(?:-c|--call)(?:=|$)/ : null;
|
|
160
|
+
if (commandOption?.test(token)) {
|
|
161
|
+
const equals = token.indexOf('=');
|
|
162
|
+
return { command: equals < 0 ? group[index + 1] || '' : token.slice(equals + 1),
|
|
163
|
+
rest: name === 'env' ? group.slice(index + (equals < 0 ? 2 : 1)) : [] };
|
|
164
|
+
}
|
|
165
|
+
if (!token.startsWith('-')) break;
|
|
166
|
+
index += WRAPPER_OPTIONS[name].includes(token) ? 2 : 1;
|
|
167
|
+
}
|
|
168
|
+
if (!exec) return [];
|
|
169
|
+
}
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
function positional(tokens, options) {
|
|
173
|
+
const values = []; let afterDash = false;
|
|
174
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
175
|
+
const token = tokens[index];
|
|
176
|
+
if (!afterDash && token === '--') { afterDash = true; continue; }
|
|
177
|
+
if (!afterDash && options.has(token)) { index += 1; continue; }
|
|
178
|
+
if (afterDash || !token.startsWith('-') || token === '-') values.push(token);
|
|
179
|
+
}
|
|
180
|
+
return values;
|
|
181
|
+
}
|
|
182
|
+
function knownSearchOptions(tokens, options, name) {
|
|
183
|
+
const booleans = new Set(['-n', '-i', '-v', '-w', '-x', '-F', '-P', '-l', '-L', '-q', '-s', '-h', '-H', '-o', '-c', '-a', '-u', '-U', '-z',
|
|
184
|
+
'--line-number', '--ignore-case', '--invert-match', '--word-regexp', '--line-regexp', '--fixed-strings', '--pcre2',
|
|
185
|
+
'--files-with-matches', '--files-without-match', '--quiet', '--no-messages', '--no-filename', '--with-filename',
|
|
186
|
+
'--only-matching', '--count', '--text', '--multiline', '--null', '--hidden', '--files', '--stats']);
|
|
187
|
+
if (name === 'grep') for (const flag of ['-r', '-R', '--recursive', '--dereference-recursive']) booleans.add(flag);
|
|
188
|
+
if (name === 'rg') for (const flag of ['--json', '-S', '--smart-case', '-N', '--no-heading', '--heading',
|
|
189
|
+
'--column', '--vimgrep', '--follow', '--no-ignore', '--no-ignore-dot', '--no-ignore-exclude',
|
|
190
|
+
'--no-ignore-files', '--no-ignore-global', '--no-ignore-parent', '--no-ignore-vcs', '--no-ignore-messages',
|
|
191
|
+
'--no-ignore-file-case-insensitive', '--count-matches', '-b', '--trim']) booleans.add(flag);
|
|
192
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
193
|
+
const token = tokens[index];
|
|
194
|
+
if (token === '--') return true;
|
|
195
|
+
if (!token.startsWith('-') || token === '-') continue;
|
|
196
|
+
if (options.has(token)) { index += 1; continue; }
|
|
197
|
+
if (/^-[ABCmMjdt]\S+/.test(token) && options.has(token.slice(0, 2))) continue;
|
|
198
|
+
if (token.startsWith('--') && options.has(token.split('=')[0]) && token.includes('=')) continue;
|
|
199
|
+
if (booleans.has(token)) continue;
|
|
200
|
+
if (!token.startsWith('--') && [...token.slice(1)].every(flag => booleans.has(`-${flag}`))) continue;
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
function commandPaths(group, globs, scopes, state) {
|
|
206
|
+
const inputs = []; const args = [];
|
|
207
|
+
for (let index = 0; index < group.length; index += 1) {
|
|
208
|
+
const token = group[index];
|
|
209
|
+
if (token.redirect) {
|
|
210
|
+
const operand = group[index += 1];
|
|
211
|
+
if (token.redirect === '<' && typeof operand === 'string') inputs.push(operand);
|
|
212
|
+
} else args.push(token);
|
|
213
|
+
}
|
|
214
|
+
return [...inputs, ...readerPaths(args, globs, scopes, state)];
|
|
215
|
+
}
|
|
216
|
+
function readerPaths(group, globs, scopes, state) {
|
|
217
|
+
const tokens = unwrap(group, state);
|
|
218
|
+
if (tokens.command !== undefined) {
|
|
219
|
+
const groups = commandGroups(tokens.command);
|
|
220
|
+
if (groups.length) groups[groups.length - 1].push(...tokens.rest);
|
|
221
|
+
return groups.flatMap((nested) => commandPaths(nested, globs, scopes, state));
|
|
222
|
+
}
|
|
223
|
+
const name = nameOf(tokens[0]);
|
|
224
|
+
if (['cd', 'pushd', 'popd', 'eval', 'source', '.'].includes(name)) state.unknownCwd = true;
|
|
225
|
+
if (!READERS.has(name)) return []; // Executables and non-reader arguments are never paths.
|
|
226
|
+
if (name === 'find') {
|
|
227
|
+
let index = 1;
|
|
228
|
+
while (['-H', '-L', '-P'].includes(tokens[index]) || /^-O\d+$/.test(tokens[index] || '')) index += 1;
|
|
229
|
+
if (tokens[index] === '-D') index += 2;
|
|
230
|
+
if (tokens[index] === '--') index += 1;
|
|
231
|
+
const roots = [];
|
|
232
|
+
for (; index < tokens.length && !/^(?:-|!|\(|\))/.test(tokens[index]); index += 1) roots.push(tokens[index]);
|
|
233
|
+
return roots; // Expressions such as -path ... -prune are not read roots.
|
|
234
|
+
}
|
|
235
|
+
if (['rg', 'grep'].includes(name)) {
|
|
236
|
+
const firstGlob = globs.length;
|
|
237
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
238
|
+
const token = tokens[index];
|
|
239
|
+
if (['-g', '--glob', '--iglob'].includes(token)) globs.push(tokens[index + 1] || '');
|
|
240
|
+
else if (/^--i?glob=/.test(token)) globs.push(token.slice(token.indexOf('=') + 1));
|
|
241
|
+
}
|
|
242
|
+
const options = new Set(['-e', '--regexp', '-f', '--file', '-g', '--glob', '--iglob', '-t', '--type', '-T', '--type-not', '--exclude', '--exclude-dir', '--include', '-m', '--max-count', '-A', '-B', '-C', '--after-context', '--before-context', '--context', '--encoding', '--color',
|
|
243
|
+
'--max-depth', '-d', '-j', '--threads', '-M', '--max-columns', '--sort', '--sortr', '--replace', '--max-filesize', '--pre', '--path-separator', '--dfa-size-limit', '--regex-size-limit', '--directories', '-D', '--devices', '--label']);
|
|
244
|
+
// rg -r takes replacement text; grep -r is a recursive flag.
|
|
245
|
+
if (name === 'rg') options.add('-r');
|
|
246
|
+
const values = positional(tokens, options);
|
|
247
|
+
const explicit = tokens.some((token) => /^(?:-[ef]|--(?:regexp|file)(?:=|$))/.test(token)) || tokens.includes('--files');
|
|
248
|
+
const paths = explicit ? values : values.slice(1);
|
|
249
|
+
scopes.push({ paths: state.unknownCwd || !knownSearchOptions(tokens, options, name) ? [] : paths, globs: globs.slice(firstGlob) });
|
|
250
|
+
return paths;
|
|
251
|
+
}
|
|
252
|
+
if (['sed', 'awk'].includes(name)) {
|
|
253
|
+
const options = new Set(['-e', '--expression', '-f', '--file', '-F', '-v']);
|
|
254
|
+
const values = positional(tokens, options);
|
|
255
|
+
return tokens.some((token) => /^(?:-[ef]|--(?:expression|file)(?:=|$))/.test(token)) ? values : values.slice(1);
|
|
256
|
+
}
|
|
257
|
+
// A flag's meaning belongs to its reader: cat -n and wc -c are booleans,
|
|
258
|
+
// whereas head -n consumes a number. Never swallow the following read path.
|
|
259
|
+
const options = {
|
|
260
|
+
head: ['-n', '--lines', '-c', '--bytes'], tail: ['-n', '--lines', '-c', '--bytes', '--pid', '-s', '--sleep-interval'],
|
|
261
|
+
nl: ['-b', '-d', '-f', '-h', '-i', '-l', '-n', '-s', '-v', '-w'],
|
|
262
|
+
tree: ['-I', '-P', '-L', '-o', '--filelimit', '--sort'],
|
|
263
|
+
ls: ['-I', '--ignore', '--hide', '--format', '--sort', '--time', '--block-size', '--quoting-style', '-w', '--width'],
|
|
264
|
+
du: ['-d', '--max-depth', '--exclude', '-X', '--exclude-from', '-B', '--block-size'],
|
|
265
|
+
stat: ['-c', '--format', '--printf'], file: ['-m', '--magic-file', '-f', '--files-from', '-F', '--separator'],
|
|
266
|
+
bat: ['--color', '--style', '--language', '--paging', '--theme', '--line-range', '-r', '--tabs'],
|
|
267
|
+
less: ['-x', '--tabs', '-j', '-p', '-P', '-z'],
|
|
268
|
+
};
|
|
269
|
+
return positional(tokens, new Set(options[name] || []));
|
|
270
|
+
}
|
|
271
|
+
function broadGlobFor(paths, globs, context) {
|
|
272
|
+
if (paths.length > 0 && paths.every(requested => narrowPath(requested, context))) return undefined;
|
|
273
|
+
return globs.find(glob => !glob.startsWith('!') && BROAD_GLOBS.some(rule => rule.test(clean(glob))));
|
|
274
|
+
}
|
|
275
|
+
function inspectInput(toolName, toolInput, context = {}) {
|
|
276
|
+
if (WRITE_TOOLS.has(toolName)) return {};
|
|
277
|
+
const paths = []; const globs = [];
|
|
278
|
+
collectStructured(toolInput, '', toolName, paths, globs);
|
|
279
|
+
let broadGlob = broadGlobFor(paths, globs, context);
|
|
280
|
+
const scopes = [];
|
|
281
|
+
const commandContext = { ...context };
|
|
282
|
+
let unknownCwd = context.cwd !== undefined && typeof context.cwd !== 'string';
|
|
283
|
+
const overrides = ['cwd', 'workdir'].filter(key => Object.hasOwn(toolInput || {}, key));
|
|
284
|
+
for (const key of overrides) {
|
|
285
|
+
if (typeof toolInput[key] !== 'string' || !toolInput[key].trim()) unknownCwd = true;
|
|
286
|
+
else commandContext.cwd = path.resolve(typeof context.cwd === 'string' ? context.cwd : process.cwd(), toolInput[key]);
|
|
287
|
+
}
|
|
288
|
+
if (overrides.length > 1 && toolInput.cwd !== toolInput.workdir) unknownCwd = true;
|
|
289
|
+
for (const key of ['command', 'cmd']) {
|
|
290
|
+
if (typeof toolInput?.[key] === 'string') {
|
|
291
|
+
const state = { unknownCwd };
|
|
292
|
+
for (const group of commandGroups(toolInput[key])) paths.push(...commandPaths(group, globs, scopes, state));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const included = globs.filter((glob) => !glob.startsWith('!'));
|
|
296
|
+
for (const scope of scopes) broadGlob ||= broadGlobFor(scope.paths, scope.globs, commandContext);
|
|
297
|
+
paths.push(...included);
|
|
298
|
+
return {
|
|
299
|
+
broadGlob,
|
|
300
|
+
blockedPath: paths.map((requested) => ({ requested, blocked: blockedSegment(requested, context) })).find((result) => result.blocked),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
module.exports = { BLOCKED_DIRS, inspectInput };
|
|
@@ -80,7 +80,7 @@ function processEntry(entry, toolMap, agentMap, latestTodos, result) {
|
|
|
80
80
|
for (const block of content) {
|
|
81
81
|
// Handle tool_use blocks
|
|
82
82
|
if (block.type === 'tool_use' && block.id && block.name) {
|
|
83
|
-
if (block.name === 'Task') {
|
|
83
|
+
if (block.name === 'Task' || block.name === 'Agent') {
|
|
84
84
|
// Agent spawn
|
|
85
85
|
agentMap.set(block.id, {
|
|
86
86
|
id: block.id,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code plugin options for the hooks.
|
|
5
|
+
*
|
|
6
|
+
* The plugin's userConfig values reach hooks as CLAUDE_PLUGIN_OPTION_<KEY> and are
|
|
7
|
+
* injected into model context verbatim, so each one is reduced to a single short
|
|
8
|
+
* line: no control, bidi, or zero-width characters or line breaks that could open a
|
|
9
|
+
* new section or disguise the text, and
|
|
10
|
+
* at most 64 characters so the core stays inside the host's context budget.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const MAX_OPTION_LENGTH = 64;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* True when the hook runs from the Claude Code plugin rather than an npx install.
|
|
20
|
+
* The variable alone is not enough: an npx hook can inherit it from a parent process,
|
|
21
|
+
* so this file must really live under that plugin root.
|
|
22
|
+
*/
|
|
23
|
+
function isPlugin() {
|
|
24
|
+
const declared = process.env.CLAUDE_PLUGIN_ROOT;
|
|
25
|
+
if (!declared) return false;
|
|
26
|
+
try {
|
|
27
|
+
return fs.realpathSync(declared) === fs.realpathSync(path.resolve(__dirname, '..', '..'));
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One short plain line: no control, bidi, or zero-width characters; at most 64 characters. */
|
|
34
|
+
function sanitizeLine(raw) {
|
|
35
|
+
if (typeof raw !== 'string') return '';
|
|
36
|
+
return [...raw.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]+/g, ' ').replace(/\s+/g, ' ').trim()]
|
|
37
|
+
.slice(0, MAX_OPTION_LENGTH)
|
|
38
|
+
.join('')
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Sanitized option value, or '' when unset. `key` is the userConfig key, e.g. `response_language`. */
|
|
43
|
+
function pluginOption(key) {
|
|
44
|
+
return sanitizeLine(process.env[`CLAUDE_PLUGIN_OPTION_${key.toUpperCase()}`]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { MAX_OPTION_LENGTH, isPlugin, pluginOption, sanitizeLine };
|