@haposoft/cafekit 0.18.0 → 0.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +938 -237
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +222 -149
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +102 -1362
- 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-authority-decoupled.test.js +14 -19
- package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
- package/bin/__tests__/omp-bridge.test.js +66 -12
- package/bin/__tests__/omp-hooks.test.js +11 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/package-inventory.test.js +1 -395
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/spec-narrowing.test.js +30 -134
- package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
- package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/__tests__/validator-grounding.test.js +17 -2929
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/lib/managed-writer.js +2 -3
- package/bin/phases/claude-runtime.js +91 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +96 -33
- package/bin/phases/codex-runtime.js +45 -49
- package/bin/phases/copy-payload.js +11 -28
- package/bin/phases/omp-runtime.js +6 -3
- package/package.json +2 -3
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/agents/code-auditor.md +9 -6
- package/src/claude/agents/debugger.md +3 -9
- package/src/claude/agents/docs-keeper.md +4 -7
- package/src/claude/agents/git-ops.md +2 -4
- package/src/claude/agents/implementer.md +6 -7
- package/src/claude/agents/inspector.md +2 -0
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/spec-maker.md +4 -4
- package/src/claude/agents/test-runner.md +8 -11
- package/src/claude/agents/ui-ux-designer.md +0 -1
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/precompact.test.js +206 -10
- package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +311 -340
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +7 -6
- package/src/claude/hooks/inspect-block.cjs +15 -63
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
- package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +67 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +23 -205
- package/src/claude/hooks/rules.cjs +6 -28
- package/src/claude/hooks/session.cjs +34 -117
- package/src/claude/hooks/spec-gate.cjs +17 -89
- package/src/claude/hooks/spec-state.cjs +89 -96
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +20 -8
- package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
- package/src/claude/output-styles/coding-level-1-junior.md +3 -3
- package/src/claude/output-styles/coding-level-2-mid.md +3 -3
- package/src/claude/output-styles/coding-level-3-senior.md +2 -2
- package/src/claude/output-styles/coding-level-4-lead.md +5 -5
- package/src/claude/output-styles/coding-level-5-god.md +6 -6
- package/src/claude/rules/ai-dev-rules.md +1 -2
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
- package/src/claude/scripts/provenance.cjs +14 -0
- package/src/claude/scripts/spec-receipt.cjs +0 -114
- package/src/claude/scripts/spec-resolver.cjs +41 -457
- package/src/claude/scripts/workflow-policy.cjs +3 -1801
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/agent-browser/SKILL.md +1 -1
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
- package/src/claude/skills/code-review/SKILL.md +25 -108
- package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
- package/src/claude/skills/develop/SKILL.md +22 -70
- package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
- package/src/claude/skills/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/research/SKILL.md +24 -55
- package/src/claude/skills/sync/SKILL.md +12 -34
- package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
- package/src/claude/skills/test/SKILL.md +39 -123
- package/src/claude/skills/test/references/execution-strategy.md +11 -22
- package/src/claude/skills/ui-ux/SKILL.md +1 -1
- package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
- package/src/claude/skills/web-testing/SKILL.md +2 -20
- package/src/codex/AGENTS.md +8 -0
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +11 -7
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +98 -16
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
- 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/spec-utils.cjs +6 -45
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +39 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +9 -215
- package/src/codex/hooks/rules.cjs +4 -30
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +47 -11
- package/src/codex/hooks/spec-gate.cjs +32 -74
- package/src/codex/hooks/spec-state.cjs +68 -46
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/common/AGENTS.md +4 -10
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
- package/src/claude/hooks/lib/context.cjs +0 -527
- package/src/claude/hooks/lib/detect.cjs +0 -474
- package/src/claude/scripts/change-firewall.cjs +0 -1748
- package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
- package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
- package/src/claude/scripts/spec-final-state.cjs +0 -290
- package/src/claude/scripts/spec-ground.cjs +0 -650
- package/src/claude/scripts/spec-readiness.cjs +0 -425
- package/src/claude/scripts/spec-scaffold.cjs +0 -2317
- package/src/claude/scripts/spec-semantic-model.cjs +0 -396
- package/src/claude/scripts/validate-spec-output.cjs +0 -3914
- package/src/claude/skills/code-review/references/verification-gate.md +0 -41
- package/src/claude/skills/specs/templates/design.md +0 -82
- package/src/claude/skills/specs/templates/requirements-init.md +0 -8
- package/src/claude/skills/specs/templates/requirements.md +0 -42
- package/src/claude/skills/specs/templates/spec-state.json +0 -56
- package/src/claude/skills/specs/templates/task.md +0 -47
- 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,10 +44,8 @@ try {
|
|
|
43
44
|
|
|
44
45
|
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
45
46
|
const payload = normalizeHookPayload(JSON.parse(stdin));
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const agentCwd = payloadCwd || process.env.PROJECT_ROOT || process.cwd();
|
|
49
|
-
const runtime = readRuntime(agentCwd);
|
|
47
|
+
const root = projectRoot(payload);
|
|
48
|
+
const runtime = readRuntime(root);
|
|
50
49
|
if (runtime === null) process.exit(0);
|
|
51
50
|
|
|
52
51
|
// Language config from runtime.json
|
|
@@ -56,7 +55,7 @@ try {
|
|
|
56
55
|
const effectThink = thinkLang || (respondLang ? 'en' : '');
|
|
57
56
|
|
|
58
57
|
// Resolve paths from env (set by session.cjs) or runtime defaults
|
|
59
|
-
const baseDir =
|
|
58
|
+
const baseDir = root;
|
|
60
59
|
const plansPath = path.join(baseDir, runtime.paths?.plans || 'plans');
|
|
61
60
|
const docsPath = path.join(baseDir, runtime.paths?.docs || 'docs');
|
|
62
61
|
const specsPath = path.join(baseDir, runtime.paths?.specs || 'specs');
|
|
@@ -74,10 +73,12 @@ try {
|
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
// Python venv (optional — if <skills>/.venv exists)
|
|
77
|
-
const venv = resolveVenv(
|
|
76
|
+
const venv = resolveVenv(root);
|
|
78
77
|
|
|
79
78
|
lines.push('## Rules');
|
|
80
79
|
lines.push(`- Specs → ${specsPath}/ | Plans → ${plansPath}/ | Docs → ${docsPath}/`);
|
|
80
|
+
lines.push('- YAGNI · KISS · DRY');
|
|
81
|
+
lines.push('- Be concise. List unresolved questions at end.');
|
|
81
82
|
if (venv) {
|
|
82
83
|
lines.push(`- Python in ${skillsDir()}/: use \`${venv}\``);
|
|
83
84
|
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,31 @@ 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
|
-
|
|
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);
|
|
102
62
|
|
|
103
63
|
if (runtime.scout?.enabled === false || runtime.inspect?.enabled === false) process.exit(0);
|
|
104
64
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Broad glob check
|
|
112
|
-
if (toolInput.pattern && isBroadGlob(toolInput.pattern)) {
|
|
65
|
+
const result = inspectInput(toolName, toolInput, {
|
|
66
|
+
cwd, projectRoot: root
|
|
67
|
+
});
|
|
68
|
+
if (result.broadGlob) {
|
|
113
69
|
denyWithReason(
|
|
114
70
|
`SCOPE LIMIT EXCEEDED: Glob pattern is excessively broad\n` +
|
|
115
|
-
`Requested Pattern: ${
|
|
71
|
+
`Requested Pattern: ${result.broadGlob}\n\n` +
|
|
116
72
|
`Please narrow your scope (e.g., src/**/*.ts rather than **/*.ts).`
|
|
117
73
|
);
|
|
118
74
|
}
|
|
119
75
|
|
|
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));
|
|
76
|
+
if (result.blockedPath) {
|
|
77
|
+
const { requested, blocked } = result.blockedPath;
|
|
125
78
|
denyWithReason(
|
|
126
79
|
`SCOPE LIMIT EXCEEDED: Directory "${blocked}/" is explicitly forbidden\n` +
|
|
127
|
-
`Requested Path: ${
|
|
128
|
-
`Restricted zones: ${BLOCKED_DIRS.join(', ')}`
|
|
80
|
+
`Requested Path: ${requested}\n` +
|
|
81
|
+
`Restricted zones: ${[...BLOCKED_DIRS].join(', ')}`
|
|
129
82
|
);
|
|
130
|
-
}
|
|
131
83
|
}
|
|
132
84
|
|
|
133
85
|
process.exit(0);
|
|
@@ -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,
|