headlesscode 1.0.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/ATTRIBUTION.md +53 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +107 -0
- package/LICENSE +202 -0
- package/README.md +486 -0
- package/SECURITY.md +211 -0
- package/bin/headlesscode.mjs +83 -0
- package/package.json +63 -0
- package/shared/prompts/review-mode-prompt-short.md +93 -0
- package/shared/prompts/review-mode-prompt.md +281 -0
- package/shared/rules-code/rules.md +22 -0
- package/shared/stacks/cpp/rules.md +30 -0
- package/shared/stacks/fastapi/rules.md +30 -0
- package/shared/stacks/javascript/rules.md +37 -0
- package/shared/stacks/postgresql/rules.md +31 -0
- package/shared/stacks/python/rules.md +35 -0
- package/shared/stacks/react/rules.md +11 -0
- package/shared/stacks/typescript/rules.md +10 -0
- package/src/budget/budget.ts +221 -0
- package/src/budget/concurrency.ts +126 -0
- package/src/budget/cost.ts +309 -0
- package/src/budget/index.ts +8 -0
- package/src/checkpoints/cli.ts +256 -0
- package/src/checkpoints/service.ts +227 -0
- package/src/cli.ts +1535 -0
- package/src/cloud/docker-provider.ts +334 -0
- package/src/cloud/provider.ts +300 -0
- package/src/codeintel/call-graph.ts +78 -0
- package/src/codeintel/find-references.ts +123 -0
- package/src/codeintel/go-to-definition.ts +193 -0
- package/src/codeintel/handlers.ts +190 -0
- package/src/codeintel/import-graph.ts +173 -0
- package/src/codeintel/outline.ts +180 -0
- package/src/codeintel/position.ts +77 -0
- package/src/codeintel/program.ts +350 -0
- package/src/codeintel/rename-symbol.ts +213 -0
- package/src/codeintel/tools.ts +280 -0
- package/src/codemap/build.ts +135 -0
- package/src/codemap/cli.ts +190 -0
- package/src/codemap/extract.ts +339 -0
- package/src/codemap/files.ts +236 -0
- package/src/codemap/fingerprint.ts +65 -0
- package/src/codemap/flows.ts +62 -0
- package/src/codemap/html.ts +451 -0
- package/src/codemap/lock.ts +80 -0
- package/src/codemap/types.ts +101 -0
- package/src/codesearch/airunner-embedder.ts +185 -0
- package/src/codesearch/chunk.ts +339 -0
- package/src/codesearch/cli.ts +223 -0
- package/src/codesearch/embedder.ts +332 -0
- package/src/codesearch/files.ts +280 -0
- package/src/codesearch/index.ts +469 -0
- package/src/codesearch/ollama-embedder.ts +205 -0
- package/src/codesearch/search.ts +141 -0
- package/src/codesearch/types.ts +100 -0
- package/src/config/mode-models.ts +218 -0
- package/src/dashboard/aggregate.ts +364 -0
- package/src/dashboard/chat-thread.ts +141 -0
- package/src/dashboard/checkpoints.ts +124 -0
- package/src/dashboard/cli.ts +193 -0
- package/src/dashboard/codemap.ts +44 -0
- package/src/dashboard/files.ts +121 -0
- package/src/dashboard/page.ts +2803 -0
- package/src/dashboard/self-improvement-metrics.ts +282 -0
- package/src/dashboard/server.ts +1103 -0
- package/src/dashboard/session-launch.ts +310 -0
- package/src/dashboard/timeline.ts +273 -0
- package/src/dashboard/tool-exec.ts +107 -0
- package/src/dashboard/trend-cli.ts +141 -0
- package/src/dashboard/trend.ts +413 -0
- package/src/decision-proxy/cli.ts +261 -0
- package/src/decision-proxy/proxy.ts +569 -0
- package/src/deploy/gate-cli.ts +147 -0
- package/src/deploy/gate.ts +254 -0
- package/src/engine/condense.ts +512 -0
- package/src/engine/events.ts +428 -0
- package/src/engine/handoff.ts +71 -0
- package/src/engine/lazy-tools.ts +160 -0
- package/src/engine/local-explore.ts +653 -0
- package/src/engine/logger.ts +96 -0
- package/src/engine/loop.ts +5517 -0
- package/src/engine/parser.ts +347 -0
- package/src/engine/prompt.ts +860 -0
- package/src/engine/reports.ts +47 -0
- package/src/engine/stacks.ts +448 -0
- package/src/engine/types.ts +291 -0
- package/src/engine/usage.ts +186 -0
- package/src/github/app-auth.ts +161 -0
- package/src/github/cli.ts +448 -0
- package/src/github/installations.ts +133 -0
- package/src/github/pr.ts +321 -0
- package/src/github/provision.ts +118 -0
- package/src/github/push.ts +122 -0
- package/src/index-util.ts +50 -0
- package/src/index.ts +81 -0
- package/src/init/cli.ts +248 -0
- package/src/init/gitignore.ts +74 -0
- package/src/llm/ollama.ts +308 -0
- package/src/llm/openrouter.ts +868 -0
- package/src/llm/preflight.ts +367 -0
- package/src/llm/transcript-capture.ts +84 -0
- package/src/memory/embed.ts +110 -0
- package/src/memory/index.ts +22 -0
- package/src/memory/local.ts +259 -0
- package/src/memory/summarizer.ts +283 -0
- package/src/memory/types.ts +153 -0
- package/src/memory/uwuchat.ts +157 -0
- package/src/migrate/cli.ts +115 -0
- package/src/orchestrator/analyze-cli.ts +104 -0
- package/src/orchestrator/auto-split.ts +206 -0
- package/src/orchestrator/cleanup.ts +1003 -0
- package/src/orchestrator/cli.ts +3571 -0
- package/src/orchestrator/cost-estimate.ts +564 -0
- package/src/orchestrator/cost-history-cli.ts +242 -0
- package/src/orchestrator/cost-history.ts +397 -0
- package/src/orchestrator/git-sync.ts +250 -0
- package/src/orchestrator/index.ts +153 -0
- package/src/orchestrator/log-analysis.ts +0 -0
- package/src/orchestrator/merge-check.ts +108 -0
- package/src/orchestrator/pipeline.ts +411 -0
- package/src/orchestrator/resume.ts +1940 -0
- package/src/orchestrator/reviewer.ts +503 -0
- package/src/orchestrator/split.ts +296 -0
- package/src/orchestrator/state.ts +542 -0
- package/src/orchestrator/status.ts +697 -0
- package/src/orchestrator/verification-gate.ts +134 -0
- package/src/orchestrator/watch.ts +898 -0
- package/src/permissions/commands.ts +1083 -0
- package/src/permissions/config.ts +241 -0
- package/src/permissions/index.ts +12 -0
- package/src/permissions/protected-files.ts +96 -0
- package/src/permissions/store-protection.ts +272 -0
- package/src/project-store.ts +648 -0
- package/src/projects/cli.ts +382 -0
- package/src/qa/qa.ts +487 -0
- package/src/tools/browser/handler.ts +346 -0
- package/src/tools/browser/service.ts +406 -0
- package/src/tools/browser/smoke.ts +78 -0
- package/src/tools/browser/tool.ts +99 -0
- package/src/tools/executor.ts +2575 -0
- package/src/tools/language-detect.ts +183 -0
- package/src/tools/output-summarizer.ts +369 -0
- package/src/tools/run-tests.ts +302 -0
- package/src/tools/set-indentation-tool.ts +49 -0
- package/src/tools/test-selection.ts +160 -0
- package/src/vendor/tests/smoke.ts +103 -0
- package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
- package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
- package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
- package/src/vendor/zoo-code/shim/os-name.ts +18 -0
- package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
- package/src/vendor/zoo-code/shim/vscode.ts +76 -0
- package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
- package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
- package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
- package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
- package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
- package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
- package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
- package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
- package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
- package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
- package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
- package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
- package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
- package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
- package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
- package/src/vendor/zoo-code/src/shared/language.ts +43 -0
- package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
- package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
- package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
- package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
- package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
- package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
- package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
- package/src/vendor/zoo-code/src/utils/object.ts +18 -0
- package/src/vendor/zoo-code/src/utils/path.ts +94 -0
- package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
- package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
- package/src/vendor/zoo-code/types/global-settings.ts +19 -0
- package/src/vendor/zoo-code/types/index.ts +22 -0
- package/src/vendor/zoo-code/types/message.ts +375 -0
- package/src/vendor/zoo-code/types/mode.ts +241 -0
- package/src/vendor/zoo-code/types/todo.ts +19 -0
- package/src/vendor/zoo-code/types/tool-params.ts +116 -0
- package/src/vendor/zoo-code/types/tool.ts +67 -0
- package/src/vendor/zoo-code/types/vscode.ts +84 -0
- package/src/vision/describe.ts +242 -0
- package/src/vision/tool.ts +91 -0
- package/src/watcher/cli.ts +369 -0
- package/src/watcher/github.ts +304 -0
- package/src/watcher/index.ts +59 -0
- package/src/watcher/state.ts +254 -0
- package/src/watcher/watch.ts +562 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command allow/deny decision logic for the headless harness — a faithful port
|
|
3
|
+
* of Zoo Code's auto-approval command gating.
|
|
4
|
+
*
|
|
5
|
+
* Ported (with attribution) from the reference tree (read-only):
|
|
6
|
+
* zoo-code/src/shared/parse-command.ts — `parseCommand` (compound
|
|
7
|
+
* command splitting on &&/||/;/|/& + unterminated-quote detection)
|
|
8
|
+
* zoo-code/src/core/auto-approval/commands.ts — `containsDangerousSubstitution`,
|
|
9
|
+
* `findLongestPrefixMatch`, `getSingleCommandDecision`, `getCommandDecision`
|
|
10
|
+
*
|
|
11
|
+
* The vendored copy under src/vendor/zoo-code/ does NOT include these files;
|
|
12
|
+
* the reference lives at the repo-root `zoo-code/` tree.
|
|
13
|
+
*
|
|
14
|
+
* Verified precedence in the reference logic (do not assume; this is what the
|
|
15
|
+
* vendored code actually does):
|
|
16
|
+
*
|
|
17
|
+
* 1. `getCommandDecision` splits the command with `parseCommand`, then checks
|
|
18
|
+
* each sub-command with `getSingleCommandDecision`. **If ANY sub-command is
|
|
19
|
+
* denied, the whole command is denied** (`decisions.includes("auto_deny")`
|
|
20
|
+
* → `"auto_deny"`) — deny wins over allow at the compound-command level.
|
|
21
|
+
* 2. Dangerous substitutions (`containsDangerousSubstitution`) are NEVER
|
|
22
|
+
* auto-approved — upstream maps them to `"ask_user"` (a human must decide).
|
|
23
|
+
* 3. For a single command, allow-vs-deny conflicts use LONGEST-PREFIX-MATCH:
|
|
24
|
+
* the longer (more specific) pattern wins; a TIE goes to deny
|
|
25
|
+
* (`longestAllowedMatch.length > longestDeniedMatch.length ? approve : deny`).
|
|
26
|
+
* 4. No match at all → `"ask_user"`.
|
|
27
|
+
*
|
|
28
|
+
* Headless translation (our layer, on top of the port):
|
|
29
|
+
* - A headless harness has no human to prompt for `"ask_user"`, so it is
|
|
30
|
+
* refused (deny). Dangerous substitution ⇒ deny unconditionally, regardless
|
|
31
|
+
* of config.
|
|
32
|
+
* - DEFAULT-ALLOW decision (deliberate, documented in plans/permissions-parity.md
|
|
33
|
+
* and the completion report): an EMPTY `allowedCommands` list means "allow
|
|
34
|
+
* everything except the deny-list" — backward compatible with the
|
|
35
|
+
* pre-permissions harness. `deniedCommands` always applies. Only when an
|
|
36
|
+
* allow-list is explicitly configured does "not on the allow-list" mean deny.
|
|
37
|
+
* - CENTRAL-STORE PROTECTION (always-applied, NOT configurable): a recursive
|
|
38
|
+
* `rm` whose resolved target is the shared central store root
|
|
39
|
+
* (~/.local/share/headlesscode) or a parent of it is refused EVEN with an
|
|
40
|
+
* empty allow/deny config — the default-allow branch must never bypass it.
|
|
41
|
+
* This is a pattern-based speed bump against the exact incident documented
|
|
42
|
+
* in plans/protect-shared-store-from-destructive-commands.md, not a
|
|
43
|
+
* sandbox; see src/permissions/store-protection.ts for the honest scope.
|
|
44
|
+
* Per-workspace permissions.json cannot override it (the whole point is
|
|
45
|
+
* protecting the SHARED resource from any single workspace).
|
|
46
|
+
* - REDIRECT-ESCAPE PROTECTION (always-applied, NOT configurable, issue #122):
|
|
47
|
+
* an output redirect (`>`, `>>`, `2>`, `2>>`, `&>`, `&>>`) whose resolved
|
|
48
|
+
* target escapes the workspace root is refused EVEN with an empty allow/deny
|
|
49
|
+
* config. This closes the same-class hole the rules doc calls out in
|
|
50
|
+
* .roo/rules/rules.md: shell redirects to `/tmp` previously slipped through
|
|
51
|
+
* the command-string allow/deny gate even though every file tool rejects
|
|
52
|
+
* outside-workspace paths. Pattern-based like the store check — a script
|
|
53
|
+
* that writes outside the workspace via a non-redirect mechanism (e.g.
|
|
54
|
+
* `python -c "open('/tmp/x','w')"`) is out of scope; see
|
|
55
|
+
* redirectTargets/checkRedirectEscape below.
|
|
56
|
+
*
|
|
57
|
+
* `parseCommand` is ported WITHOUT the `shell-quote` dependency (not present in
|
|
58
|
+
* this repo's node_modules): quoted strings, arithmetic, parameter expansions,
|
|
59
|
+
* process substitutions, redirections and variables are all masked into
|
|
60
|
+
* placeholder tokens BEFORE splitting (identical masking order to the
|
|
61
|
+
* reference), so splitting the masked string on the chain operators and on
|
|
62
|
+
* top-level subshell placeholders is behaviorally equivalent to the
|
|
63
|
+
* reference's shell-quote token walk (which splits on exactly those operator
|
|
64
|
+
* tokens and promotes subshell contents to their own sub-command). The quote
|
|
65
|
+
* state machine (single/double/ANSI-C/locale/heredoc + comments) is ported
|
|
66
|
+
* verbatim.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
import * as os from "node:os"
|
|
70
|
+
import * as path from "node:path"
|
|
71
|
+
|
|
72
|
+
import { checkCentralStoreDestruction, expandEnv, expandHome, splitCommandWords } from "./store-protection.js"
|
|
73
|
+
|
|
74
|
+
// ─── parseCommand port (zoo-code/src/shared/parse-command.ts) ───────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The style of quoting that opened a region (see the reference file for the
|
|
78
|
+
* full rationale; kept identical so masking behaves the same).
|
|
79
|
+
*/
|
|
80
|
+
export type QuoteType = "posix-single" | "ansi-c" | "double" | "locale" | "heredoc"
|
|
81
|
+
|
|
82
|
+
/** Describes the opening of a quoted region that is never closed. */
|
|
83
|
+
export interface UnterminatedQuote {
|
|
84
|
+
quoteType: QuoteType
|
|
85
|
+
/** Index in the original command string of the character that opened the region. */
|
|
86
|
+
openIndex: number
|
|
87
|
+
/** Human-readable description suitable for surfacing to an agent as a tool error. */
|
|
88
|
+
message: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The result of parsing a command string. `commands` is the list of individual
|
|
93
|
+
* sub-commands produced by splitting on unquoted newlines and chain operators.
|
|
94
|
+
* When `parseError` is non-null the command string is syntactically malformed
|
|
95
|
+
* (e.g. an unterminated quote) and `commands` contains the raw input as a
|
|
96
|
+
* single opaque token so callers can surface the error without splitting
|
|
97
|
+
* unsafe fragments.
|
|
98
|
+
*/
|
|
99
|
+
export interface ParseResult {
|
|
100
|
+
commands: string[]
|
|
101
|
+
parseError: UnterminatedQuote | null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function unterminatedQuoteMessage(quoteType: QuoteType, openIndex: number, command: string): string {
|
|
105
|
+
const labels: Record<QuoteType, string> = {
|
|
106
|
+
"posix-single": "single quote (')",
|
|
107
|
+
"ansi-c": "ANSI-C quote ($')",
|
|
108
|
+
double: 'double quote (")',
|
|
109
|
+
locale: 'locale quote ($")',
|
|
110
|
+
heredoc: "heredoc (<<)",
|
|
111
|
+
}
|
|
112
|
+
const snippetStart = Math.max(0, openIndex - 10)
|
|
113
|
+
const snippetEnd = Math.min(command.length, openIndex + 20)
|
|
114
|
+
const prefix = snippetStart > 0 ? "..." : ""
|
|
115
|
+
const suffix = snippetEnd < command.length ? "..." : ""
|
|
116
|
+
const excerpt = prefix + command.slice(snippetStart, snippetEnd).replace(/\r?\n/g, "\\n") + suffix
|
|
117
|
+
return `Malformed command: unterminated ${labels[quoteType]} at position ${openIndex} -- near: \`${excerpt}\`. `
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A contiguous quoted region found at the top level of a command string. */
|
|
121
|
+
interface QuoteSpan {
|
|
122
|
+
/** Index of the first character of the opening delimiter (e.g. `$` for `$'...'`). */
|
|
123
|
+
start: number
|
|
124
|
+
/** Index one past the last character of the closing delimiter. */
|
|
125
|
+
end: number
|
|
126
|
+
/** The style of quoting. */
|
|
127
|
+
quoteType: QuoteType
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Result returned by the single shared state-machine walk. */
|
|
131
|
+
interface ScanResult {
|
|
132
|
+
spans: QuoteSpan[]
|
|
133
|
+
unterminatedQuote: UnterminatedQuote | null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Parse a heredoc delimiter word starting at position `start` in `command`
|
|
138
|
+
* (unquoted / 'EOF' / "EOF" / \EOF — see the reference for the rules).
|
|
139
|
+
*/
|
|
140
|
+
function parseHeredocDelimiter(command: string, start: number): { delimiter: string; endIndex: number } {
|
|
141
|
+
let i = start
|
|
142
|
+
let delimiter = ""
|
|
143
|
+
|
|
144
|
+
if (command[i] === "'") {
|
|
145
|
+
i++
|
|
146
|
+
while (i < command.length && command[i] !== "'" && command[i] !== "\n") {
|
|
147
|
+
delimiter += command[i++]
|
|
148
|
+
}
|
|
149
|
+
if (command[i] === "'") i++
|
|
150
|
+
} else if (command[i] === '"') {
|
|
151
|
+
i++
|
|
152
|
+
while (i < command.length && command[i] !== '"' && command[i] !== "\n") {
|
|
153
|
+
delimiter += command[i++]
|
|
154
|
+
}
|
|
155
|
+
if (command[i] === '"') i++
|
|
156
|
+
} else if (command[i] === "\\") {
|
|
157
|
+
i++
|
|
158
|
+
while (i < command.length && command[i] !== "\n" && command[i] !== " " && command[i] !== "\t") {
|
|
159
|
+
delimiter += command[i++]
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
while (i < command.length && command[i] !== "\n" && command[i] !== " " && command[i] !== "\t") {
|
|
163
|
+
delimiter += command[i++]
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { delimiter, endIndex: i }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Single shared state-machine walk used by `parseCommand`: identifies every
|
|
172
|
+
* top-level quoted region (outside other quotes and `#` comments) and reports
|
|
173
|
+
* the first unterminated one. Ported verbatim from the reference.
|
|
174
|
+
*/
|
|
175
|
+
function scanTopLevelQuotes(command: string): ScanResult {
|
|
176
|
+
const spans: QuoteSpan[] = []
|
|
177
|
+
let i = 0
|
|
178
|
+
|
|
179
|
+
while (i < command.length) {
|
|
180
|
+
const char = command[i]
|
|
181
|
+
|
|
182
|
+
if (char === "\\") {
|
|
183
|
+
i += 2
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (char === "#" && (i === 0 || /\s/.test(command[i - 1]))) {
|
|
188
|
+
while (i < command.length && command[i] !== "\n" && command[i] !== "\r") {
|
|
189
|
+
i++
|
|
190
|
+
}
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Herestring (<<<): single-line stdin redirect — no body or terminator.
|
|
195
|
+
if (char === "<" && command[i + 1] === "<" && command[i + 2] === "<") {
|
|
196
|
+
i += 3
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Heredoc opener: <<[-]? followed by an optional-quoted delimiter word.
|
|
201
|
+
if (char === "<" && command[i + 1] === "<") {
|
|
202
|
+
const start = i
|
|
203
|
+
i += 2
|
|
204
|
+
const stripTabs = command[i] === "-"
|
|
205
|
+
if (stripTabs) i++
|
|
206
|
+
while (i < command.length && (command[i] === " " || command[i] === "\t")) {
|
|
207
|
+
i++
|
|
208
|
+
}
|
|
209
|
+
const { delimiter, endIndex } = parseHeredocDelimiter(command, i)
|
|
210
|
+
i = endIndex
|
|
211
|
+
while (i < command.length && command[i] !== "\n") i++
|
|
212
|
+
if (i < command.length) i++
|
|
213
|
+
if (delimiter.length > 0) {
|
|
214
|
+
let found = false
|
|
215
|
+
while (i < command.length) {
|
|
216
|
+
const lineStart = i
|
|
217
|
+
while (i < command.length && command[i] !== "\n" && command[i] !== "\r") {
|
|
218
|
+
i++
|
|
219
|
+
}
|
|
220
|
+
const rawLine = command.slice(lineStart, i)
|
|
221
|
+
const line = stripTabs ? rawLine.replace(/^\t*/, "") : rawLine
|
|
222
|
+
if (line === delimiter) {
|
|
223
|
+
found = true
|
|
224
|
+
break
|
|
225
|
+
}
|
|
226
|
+
if (i < command.length) i++
|
|
227
|
+
}
|
|
228
|
+
if (!found) {
|
|
229
|
+
return {
|
|
230
|
+
spans,
|
|
231
|
+
unterminatedQuote: {
|
|
232
|
+
quoteType: "heredoc",
|
|
233
|
+
openIndex: start,
|
|
234
|
+
message: unterminatedQuoteMessage("heredoc", start, command),
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
spans.push({ start, end: i, quoteType: "heredoc" })
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ANSI-C quoting: $'...', escape-aware.
|
|
244
|
+
if (char === "$" && command[i + 1] === "'") {
|
|
245
|
+
const start = i
|
|
246
|
+
i += 2
|
|
247
|
+
let closed = false
|
|
248
|
+
while (i < command.length) {
|
|
249
|
+
if (command[i] === "\\") {
|
|
250
|
+
i += 2
|
|
251
|
+
} else if (command[i] === "'") {
|
|
252
|
+
i++
|
|
253
|
+
closed = true
|
|
254
|
+
break
|
|
255
|
+
} else {
|
|
256
|
+
i++
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (!closed) {
|
|
260
|
+
return {
|
|
261
|
+
spans,
|
|
262
|
+
unterminatedQuote: {
|
|
263
|
+
quoteType: "ansi-c",
|
|
264
|
+
openIndex: start,
|
|
265
|
+
message: unterminatedQuoteMessage("ansi-c", start, command),
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
spans.push({ start, end: i, quoteType: "ansi-c" })
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Locale quoting: $"...", escape-aware like double quotes.
|
|
274
|
+
if (char === "$" && command[i + 1] === '"') {
|
|
275
|
+
const start = i
|
|
276
|
+
i += 2
|
|
277
|
+
let closed = false
|
|
278
|
+
while (i < command.length) {
|
|
279
|
+
if (command[i] === "\\") {
|
|
280
|
+
i += 2
|
|
281
|
+
} else if (command[i] === '"') {
|
|
282
|
+
i++
|
|
283
|
+
closed = true
|
|
284
|
+
break
|
|
285
|
+
} else {
|
|
286
|
+
i++
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (!closed) {
|
|
290
|
+
return {
|
|
291
|
+
spans,
|
|
292
|
+
unterminatedQuote: {
|
|
293
|
+
quoteType: "locale",
|
|
294
|
+
openIndex: start,
|
|
295
|
+
message: unterminatedQuoteMessage("locale", start, command),
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
spans.push({ start, end: i, quoteType: "locale" })
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// POSIX single quote: fully opaque, ends at the next literal '.
|
|
304
|
+
if (char === "'") {
|
|
305
|
+
const start = i
|
|
306
|
+
i++
|
|
307
|
+
while (i < command.length && command[i] !== "'") {
|
|
308
|
+
i++
|
|
309
|
+
}
|
|
310
|
+
if (i >= command.length) {
|
|
311
|
+
return {
|
|
312
|
+
spans,
|
|
313
|
+
unterminatedQuote: {
|
|
314
|
+
quoteType: "posix-single",
|
|
315
|
+
openIndex: start,
|
|
316
|
+
message: unterminatedQuoteMessage("posix-single", start, command),
|
|
317
|
+
},
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
i++
|
|
321
|
+
spans.push({ start, end: i, quoteType: "posix-single" })
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Double quote: escape-aware, ends at the next unescaped ".
|
|
326
|
+
if (char === '"') {
|
|
327
|
+
const start = i
|
|
328
|
+
i++
|
|
329
|
+
let closed = false
|
|
330
|
+
while (i < command.length) {
|
|
331
|
+
if (command[i] === "\\") {
|
|
332
|
+
i += 2
|
|
333
|
+
} else if (command[i] === '"') {
|
|
334
|
+
i++
|
|
335
|
+
closed = true
|
|
336
|
+
break
|
|
337
|
+
} else {
|
|
338
|
+
i++
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (!closed) {
|
|
342
|
+
return {
|
|
343
|
+
spans,
|
|
344
|
+
unterminatedQuote: {
|
|
345
|
+
quoteType: "double",
|
|
346
|
+
openIndex: start,
|
|
347
|
+
message: unterminatedQuoteMessage("double", start, command),
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
spans.push({ start, end: i, quoteType: "double" })
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
i++
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return { spans, unterminatedQuote: null }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Walk `command` and replace every top-level quoted region with a placeholder
|
|
363
|
+
* token. Returns the masked string and the array of original quoted substrings
|
|
364
|
+
* so callers can restore them later. Ported verbatim from the reference.
|
|
365
|
+
*/
|
|
366
|
+
function maskTopLevelQuotes(command: string): { masked: string; quotes: string[] } {
|
|
367
|
+
const { spans, unterminatedQuote } = scanTopLevelQuotes(command)
|
|
368
|
+
|
|
369
|
+
const effectiveSpans: QuoteSpan[] =
|
|
370
|
+
unterminatedQuote !== null
|
|
371
|
+
? [
|
|
372
|
+
...spans,
|
|
373
|
+
{ start: unterminatedQuote.openIndex, end: command.length, quoteType: unterminatedQuote.quoteType },
|
|
374
|
+
]
|
|
375
|
+
: spans
|
|
376
|
+
|
|
377
|
+
const quotes: string[] = []
|
|
378
|
+
let result = ""
|
|
379
|
+
let pos = 0
|
|
380
|
+
|
|
381
|
+
for (const span of effectiveSpans) {
|
|
382
|
+
result += command.slice(pos, span.start)
|
|
383
|
+
quotes.push(command.slice(span.start, span.end))
|
|
384
|
+
result += `__TOPLEVEL_QUOTE_${quotes.length - 1}__`
|
|
385
|
+
pos = span.end
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
result += command.slice(pos)
|
|
389
|
+
|
|
390
|
+
return { masked: result, quotes }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Split a command string into individual sub-commands by chaining operators
|
|
395
|
+
* (&&, ||, ;, |, &) and unquoted newlines, preserving quoted strings (including
|
|
396
|
+
* multi-line quoted strings) as atomic units. Returns the sub-command list and
|
|
397
|
+
* an optional parse error for unterminated quotes/heredocs. Ported from the
|
|
398
|
+
* reference; the shell-quote tokenization step is replaced by an equivalent
|
|
399
|
+
* operator split (see the module header).
|
|
400
|
+
*/
|
|
401
|
+
export function parseCommand(command: string): ParseResult {
|
|
402
|
+
if (!command?.trim()) {
|
|
403
|
+
return { commands: [], parseError: null }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const { unterminatedQuote } = scanTopLevelQuotes(command)
|
|
407
|
+
|
|
408
|
+
if (unterminatedQuote !== null) {
|
|
409
|
+
return { commands: [command], parseError: unterminatedQuote }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Pre-escape literal __ sequences so they cannot collide with the internal
|
|
413
|
+
// placeholder tokens. \x00 (the null byte) cannot appear in a real shell
|
|
414
|
+
// command, so it is a safe sentinel; the post-unescape step reverses it.
|
|
415
|
+
const escapedCommand = command.replace(/__/g, "\x00")
|
|
416
|
+
|
|
417
|
+
const { masked, quotes: topLevelQuotes } = maskTopLevelQuotes(escapedCommand)
|
|
418
|
+
|
|
419
|
+
const lines = masked.split(/\r\n|\r|\n/)
|
|
420
|
+
const allCommands: string[] = []
|
|
421
|
+
|
|
422
|
+
for (const line of lines) {
|
|
423
|
+
if (!line.trim()) {
|
|
424
|
+
continue
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const restoredLine = line.replace(/__TOPLEVEL_QUOTE_(\d+)__/g, (_, i) => topLevelQuotes[parseInt(i)])
|
|
428
|
+
|
|
429
|
+
// A restored line with embedded newlines means a top-level quote (e.g. a
|
|
430
|
+
// heredoc) spanned multiple lines — the whole string is one atomic
|
|
431
|
+
// command; re-splitting would break on the embedded newlines/<<.
|
|
432
|
+
if (restoredLine.includes("\n")) {
|
|
433
|
+
allCommands.push(restoredLine)
|
|
434
|
+
continue
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
allCommands.push(...parseCommandLine(restoredLine))
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return { commands: allCommands.map((cmd) => cmd.split("\x00").join("__")), parseError: null }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Parse a single line of commands into sub-commands. The masking pipeline is
|
|
445
|
+
* identical to the reference; the final shell-quote `parse()` call is replaced
|
|
446
|
+
* by a split on chain operators + top-level subshell placeholders (see the
|
|
447
|
+
* module header for why this is equivalent).
|
|
448
|
+
*/
|
|
449
|
+
function parseCommandLine(command: string): string[] {
|
|
450
|
+
if (!command?.trim()) return []
|
|
451
|
+
|
|
452
|
+
const redirections: string[] = []
|
|
453
|
+
const subshells: string[] = []
|
|
454
|
+
const quotes: string[] = []
|
|
455
|
+
const singleQuotes: string[] = []
|
|
456
|
+
const arithmeticExpressions: string[] = []
|
|
457
|
+
const variables: string[] = []
|
|
458
|
+
const parameterExpansions: string[] = []
|
|
459
|
+
|
|
460
|
+
let processedCommand = command.replace(/\d*>&\d*/g, (match) => {
|
|
461
|
+
redirections.push(match)
|
|
462
|
+
return `__REDIR_${redirections.length - 1}__`
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
processedCommand = processedCommand.replace(/\$\(\([^)]*(?:\)[^)]*)*\)\)/g, (match) => {
|
|
466
|
+
arithmeticExpressions.push(match)
|
|
467
|
+
return `__ARITH_${arithmeticExpressions.length - 1}__`
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
processedCommand = processedCommand.replace(/\$\[[^\]]*\]/g, (match) => {
|
|
471
|
+
arithmeticExpressions.push(match)
|
|
472
|
+
return `__ARITH_${arithmeticExpressions.length - 1}__`
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
processedCommand = processedCommand.replace(/\$\{[^}]+\}/g, (match) => {
|
|
476
|
+
parameterExpansions.push(match)
|
|
477
|
+
return `__PARAM_${parameterExpansions.length - 1}__`
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
processedCommand = processedCommand.replace(/[<>]\(([^)]+)\)/g, (_, inner) => {
|
|
481
|
+
subshells.push(inner.trim())
|
|
482
|
+
return `__SUBSH_${subshells.length - 1}__`
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
// Locale quoting: $"...". Must run before variable masking so the leading $
|
|
486
|
+
// is captured as part of the quoted unit (see reference).
|
|
487
|
+
processedCommand = processedCommand.replace(/\$"(?:[^"\\]|\\.)*"/g, (match) => {
|
|
488
|
+
quotes.push(match)
|
|
489
|
+
return `__QUOTE_${quotes.length - 1}__`
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
// ANSI-C quoting: $'...'. Same ordering rationale as locale quoting.
|
|
493
|
+
processedCommand = processedCommand.replace(/\$'(?:[^'\\]|\\.)*'/g, (match) => {
|
|
494
|
+
singleQuotes.push(match)
|
|
495
|
+
return `__SQUOTE_${singleQuotes.length - 1}__`
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
// Simple variable references: $varname.
|
|
499
|
+
processedCommand = processedCommand.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => {
|
|
500
|
+
variables.push(match)
|
|
501
|
+
return `__VAR_${variables.length - 1}__`
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
// Special bash variables: $?, $!, $#, $$, $@, $*, $-, $0-$9.
|
|
505
|
+
processedCommand = processedCommand.replace(/\$[?!#$@*\-0-9]/g, (match) => {
|
|
506
|
+
variables.push(match)
|
|
507
|
+
return `__VAR_${variables.length - 1}__`
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
// Subshell commands $() and back-ticks.
|
|
511
|
+
processedCommand = processedCommand
|
|
512
|
+
.replace(/\$\((.*?)\)/g, (_, inner) => {
|
|
513
|
+
subshells.push(inner.trim())
|
|
514
|
+
return `__SUBSH_${subshells.length - 1}__`
|
|
515
|
+
})
|
|
516
|
+
.replace(/`(.*?)`/g, (_, inner) => {
|
|
517
|
+
subshells.push(inner.trim())
|
|
518
|
+
return `__SUBSH_${subshells.length - 1}__`
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
// Mask quoted strings (single + double) so their contents — including
|
|
522
|
+
// operators like &&, |, ; and embedded newlines — are not treated as
|
|
523
|
+
// command separators. Single quotes are fully opaque; double quotes are
|
|
524
|
+
// escape-aware. Matches the reference's alternation exactly.
|
|
525
|
+
processedCommand = processedCommand.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, (match) => {
|
|
526
|
+
if (match.startsWith("'")) {
|
|
527
|
+
singleQuotes.push(match)
|
|
528
|
+
return `__SQUOTE_${singleQuotes.length - 1}__`
|
|
529
|
+
}
|
|
530
|
+
quotes.push(match)
|
|
531
|
+
return `__QUOTE_${quotes.length - 1}__`
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
// ── Replace the reference's `parse(processedCommand)` (shell-quote) with an
|
|
535
|
+
// equivalent self-contained token walk. Everything that could contain an
|
|
536
|
+
// operator is already masked above, so the only operator-like tokens left
|
|
537
|
+
// are the chain operators and __SUBSH_ placeholders. Tokenizing on
|
|
538
|
+
// whitespace collapses runs exactly like shell-quote's word tokens; chain
|
|
539
|
+
// operators split commands; and a subshell placeholder promotes its content
|
|
540
|
+
// to its own sub-command (mirroring the reference's token walk, including
|
|
541
|
+
// its unanchored __SUBSH_ match). Operators are split out even when attached
|
|
542
|
+
// to a word (`hi;`, `a||b`) — exactly like shell-quote's tokenizer — and
|
|
543
|
+
// whitespace runs collapse to single separators.
|
|
544
|
+
const tokens = processedCommand
|
|
545
|
+
.split(/(\s+|&&|\|\||;|\||&)/)
|
|
546
|
+
.filter((t) => t.length > 0 && !/^\s+$/.test(t))
|
|
547
|
+
const commands: string[] = []
|
|
548
|
+
let current: string[] = []
|
|
549
|
+
|
|
550
|
+
for (const token of tokens) {
|
|
551
|
+
if (token === "&&" || token === "||" || token === ";" || token === "|" || token === "&") {
|
|
552
|
+
if (current.length > 0) {
|
|
553
|
+
commands.push(current.join(" "))
|
|
554
|
+
current = []
|
|
555
|
+
}
|
|
556
|
+
} else {
|
|
557
|
+
const subshellMatch = token.match(/__SUBSH_(\d+)__/)
|
|
558
|
+
if (subshellMatch) {
|
|
559
|
+
if (current.length > 0) {
|
|
560
|
+
commands.push(current.join(" "))
|
|
561
|
+
current = []
|
|
562
|
+
}
|
|
563
|
+
commands.push(subshells[parseInt(subshellMatch[1])])
|
|
564
|
+
} else {
|
|
565
|
+
current.push(token)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (current.length > 0) {
|
|
570
|
+
commands.push(current.join(" "))
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return commands.map((cmd) =>
|
|
574
|
+
restorePlaceholders(
|
|
575
|
+
cmd,
|
|
576
|
+
quotes,
|
|
577
|
+
singleQuotes,
|
|
578
|
+
redirections,
|
|
579
|
+
arithmeticExpressions,
|
|
580
|
+
parameterExpansions,
|
|
581
|
+
variables,
|
|
582
|
+
subshells,
|
|
583
|
+
),
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Helper function to restore placeholders in a command string (reference). */
|
|
588
|
+
function restorePlaceholders(
|
|
589
|
+
command: string,
|
|
590
|
+
quotes: string[],
|
|
591
|
+
singleQuotes: string[],
|
|
592
|
+
redirections: string[],
|
|
593
|
+
arithmeticExpressions: string[],
|
|
594
|
+
parameterExpansions: string[],
|
|
595
|
+
variables: string[],
|
|
596
|
+
subshells: string[],
|
|
597
|
+
): string {
|
|
598
|
+
let result = command
|
|
599
|
+
result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)])
|
|
600
|
+
result = result.replace(/__SQUOTE_(\d+)__/g, (_, i) => singleQuotes[parseInt(i)])
|
|
601
|
+
result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)])
|
|
602
|
+
result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)])
|
|
603
|
+
result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)])
|
|
604
|
+
result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)])
|
|
605
|
+
result = result.replace(/__SUBSH_(\d+)__/g, (_, i) => subshells[parseInt(i)])
|
|
606
|
+
return result
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ─── redirect-target extraction (shell redirects to outside the workspace) ──
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* A redirection found in a command string: the raw `>`-style operator
|
|
613
|
+
* (including an optional numeric fd prefix and `&` for `&>`), the index it
|
|
614
|
+
* starts at, and the word that follows it (the redirect target). Quoted words
|
|
615
|
+
* (e.g. `> "out file.txt"`) arrive with their quotes intact; callers strip
|
|
616
|
+
* them via splitCommandWords, which also removes backslash escapes.
|
|
617
|
+
*/
|
|
618
|
+
export interface RedirectTarget {
|
|
619
|
+
operator: string
|
|
620
|
+
/** Index in the ORIGINAL command string where the operator starts. */
|
|
621
|
+
index: number
|
|
622
|
+
/** The raw word following the operator (quote-stripped by splitCommandWords). */
|
|
623
|
+
word: string
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Extract the target words of every output redirection in a command string.
|
|
628
|
+
*
|
|
629
|
+
* Recognized operators: `>`, `>>`, `>|` (noclobber), `2>`, `2>>`, `2>|`,
|
|
630
|
+
* `&>`, `&>>`, `&>|` — the alternation is ordered longest-first so `>>` wins
|
|
631
|
+
* over `>`. `<`/`<<`/`<<<` input redirects are deliberately NOT matched —
|
|
632
|
+
* they read from a path instead of writing to it, so they cannot smuggle an
|
|
633
|
+
* outside-workspace WRITE. `2>&1`/`3>&2` fd duplication is not a path
|
|
634
|
+
* redirect: the `&` immediately after the `>` terminates the word, leaving
|
|
635
|
+
* no target, so it is skipped (see parseCommandLine, which masks the same
|
|
636
|
+
* shape).
|
|
637
|
+
*
|
|
638
|
+
* A target word is a shell WORD: unquoted characters up to the next
|
|
639
|
+
* metacharacter (`;`, `|`, `&`, `<`, `>`, whitespace) or a quote/escape that
|
|
640
|
+
* stays part of the word. When the redirect is immediately followed by a
|
|
641
|
+
* metacharacter (`echo hi >;ls`, `echo hi >`), no word exists — the shell
|
|
642
|
+
* errors on the missing operand and no file is created, so nothing is
|
|
643
|
+
* emitted. A quoted target (`> "out file.txt"`, `> '/tmp/x y'`) is emitted
|
|
644
|
+
* with its quotes intact; callers strip them via splitCommandWords.
|
|
645
|
+
*/
|
|
646
|
+
export function redirectTargets(command: string): RedirectTarget[] {
|
|
647
|
+
const targets: RedirectTarget[] = []
|
|
648
|
+
// Word chars: anything but unquoted shell metacharacters/quotes, plus
|
|
649
|
+
// quoted regions (which may contain metacharacters) and backslash escapes.
|
|
650
|
+
const re =
|
|
651
|
+
/(?:\d*&>>|\d*&>\||\d*&>|\d*>>\||\d*>\||\d*>>|\d*>)(?:\s*)(?:[^\s;|&<>()'"]|"[^"]*"|'[^']*'|\\.)*/g
|
|
652
|
+
let match: RegExpExecArray | null
|
|
653
|
+
while ((match = re.exec(command)) !== null) {
|
|
654
|
+
const raw = match[0]
|
|
655
|
+
if (raw === undefined) {
|
|
656
|
+
continue
|
|
657
|
+
}
|
|
658
|
+
const operatorMatch = raw.match(/^(\d*&>>|\d*&>\||\d*&>|\d*>>\||\d*>\||\d*>>|\d*>)/)
|
|
659
|
+
if (operatorMatch === null) {
|
|
660
|
+
continue
|
|
661
|
+
}
|
|
662
|
+
const operator = operatorMatch[1] ?? raw
|
|
663
|
+
const wordText = raw.slice(operator.length).trimStart()
|
|
664
|
+
if (wordText === "") {
|
|
665
|
+
continue
|
|
666
|
+
}
|
|
667
|
+
const words = splitCommandWords(wordText)
|
|
668
|
+
if (words.length === 0) {
|
|
669
|
+
continue
|
|
670
|
+
}
|
|
671
|
+
targets.push({ operator, index: match.index, word: words[0] })
|
|
672
|
+
}
|
|
673
|
+
return targets
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Resolve a redirect target word to an absolute path, anchored on the command's
|
|
678
|
+
* working directory. Applies the same `~`/`$VAR` expansion and `/*`-glob
|
|
679
|
+
* truncation as the central-store check (store-protection.ts's
|
|
680
|
+
* resolveCommandTarget), so `/tmp` and `/tmp/x` behave identically to `~` and
|
|
681
|
+
* `$TMPDIR` — a relative target like `> out.txt` anchors inside the workspace.
|
|
682
|
+
*
|
|
683
|
+
* A word the shell could expand (e.g. `$TMPDIR/x.txt`) resolves through the
|
|
684
|
+
* SAME expansions the central-store check applies (expandHome/expandEnv), so
|
|
685
|
+
* `> $TMPDIR/x` with TMPDIR set to /tmp refuses like the literal `/tmp/x` it
|
|
686
|
+
* expands to. An UNKNOWN variable stays literal — the shell would expand it
|
|
687
|
+
* to an empty word (the redirect would then hit a missing-operand error),
|
|
688
|
+
* which cannot write outside the workspace, so resolving it literally is the
|
|
689
|
+
* safe direction.
|
|
690
|
+
*/
|
|
691
|
+
export function resolveRedirectTarget(target: string, workspaceRoot: string): string {
|
|
692
|
+
const expanded = expandEnv(expandHome(target))
|
|
693
|
+
return path.resolve(workspaceRoot, expanded)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* True when the resolved redirect target escapes the workspace root — the
|
|
698
|
+
* same lexical containment rule `resolveWithinWorkspace` uses for the file
|
|
699
|
+
* tools (path.resolve prefix check; no symlink following, which matches the
|
|
700
|
+
* store-protection check's documented boundary).
|
|
701
|
+
*
|
|
702
|
+
* `/dev/null` and `/dev/fd/N` are deliberately NOT escapes: they are
|
|
703
|
+
* device/fd-backed targets with no persistent state and cannot leak data
|
|
704
|
+
* outside the workspace, unlike a file redirect. The agent routinely uses
|
|
705
|
+
* `2>/dev/null` to silence stderr; refusing it makes every otherwise-fine
|
|
706
|
+
* command fail with a redirect-escape error.
|
|
707
|
+
*/
|
|
708
|
+
export function isOutsideWorkspace(root: string, target: string): boolean {
|
|
709
|
+
if (target === "/dev/null" || /^\/dev\/fd\/\d+$/.test(target)) {
|
|
710
|
+
return false
|
|
711
|
+
}
|
|
712
|
+
const rootAbs = path.resolve(root)
|
|
713
|
+
const t = path.resolve(target)
|
|
714
|
+
if (t === rootAbs) {
|
|
715
|
+
return false
|
|
716
|
+
}
|
|
717
|
+
return !t.startsWith(rootAbs.endsWith(path.sep) ? rootAbs : rootAbs + path.sep)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Check ONE sub-command (already split by parseCommand) for an output
|
|
722
|
+
* redirect whose resolved target escapes the workspace root. Returns the
|
|
723
|
+
* first offending redirect, or `null` when every redirect target stays inside
|
|
724
|
+
* the workspace. `workspaceRoot` anchors relative targets (defaults to
|
|
725
|
+
* process.cwd()).
|
|
726
|
+
*/
|
|
727
|
+
export function checkRedirectEscape(subCommand: string, workspaceRoot?: string): RedirectTarget | null {
|
|
728
|
+
const root = path.resolve(workspaceRoot ?? process.cwd())
|
|
729
|
+
for (const target of redirectTargets(subCommand)) {
|
|
730
|
+
if (isOutsideWorkspace(root, resolveRedirectTarget(target.word, root))) {
|
|
731
|
+
return target
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return null
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Human-readable operator for the model-facing refusal message. */
|
|
738
|
+
export function describeRedirect(target: RedirectTarget): string {
|
|
739
|
+
const compact =
|
|
740
|
+
target.operator.includes("&") || target.operator.includes("|") || /^\d*>>/.test(target.operator)
|
|
741
|
+
return compact ? `${target.operator}${target.word}` : `${target.operator} ${target.word}`
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ─── allow/deny logic port (zoo-code/src/core/auto-approval/commands.ts) ────
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Detect dangerous parameter substitutions that could lead to command
|
|
748
|
+
* execution. These patterns are never auto-approved (upstream) and are always
|
|
749
|
+
* refused in the headless harness. Ported verbatim from the reference.
|
|
750
|
+
*/
|
|
751
|
+
export function containsDangerousSubstitution(source: string): boolean {
|
|
752
|
+
// ${var@P} prompt-string expansion, ${var@Q} quote removal, ${var@E} escape
|
|
753
|
+
// expansion, ${var@A} assignment statement, ${var@a} attribute flags.
|
|
754
|
+
const dangerousParameterExpansion = /\$\{[^}]*@[PQEAa][^}]*\}/.test(source)
|
|
755
|
+
|
|
756
|
+
// ${var=value} / ${var:=value} / ${var+value} / ${var:-value} / ${var:+value}
|
|
757
|
+
// / ${var:?value} with octal / hex / unicode escapes that can embed commands.
|
|
758
|
+
const parameterAssignmentWithEscapes =
|
|
759
|
+
/\$\{[^}]*[=+\-?][^}]*\\[0-7]{3}[^}]*\}/.test(source) ||
|
|
760
|
+
/\$\{[^}]*[=+\-?][^}]*\\x[0-9a-fA-F]{2}[^}]*\}/.test(source) ||
|
|
761
|
+
/\$\{[^}]*[=+\-?][^}]*\\u[0-9a-fA-F]{4}[^}]*\}/.test(source)
|
|
762
|
+
|
|
763
|
+
// ${!var} indirect expansion.
|
|
764
|
+
const indirectExpansion = /\$\{![^}]+\}/.test(source)
|
|
765
|
+
|
|
766
|
+
// <<<$(...) or <<<`...` here-strings with command substitution.
|
|
767
|
+
const hereStringWithSubstitution = /<<<\s*(\$\(|`)/.test(source)
|
|
768
|
+
|
|
769
|
+
// =(...) zsh process substitution that executes commands.
|
|
770
|
+
const zshProcessSubstitution = /(?:(?<=^)|(?<=[\s;|&(<]))=\([^)]+\)/.test(source)
|
|
771
|
+
|
|
772
|
+
// zsh glob qualifiers with code execution, e.g. *(e:whoami:), ?(e:rm -rf /:).
|
|
773
|
+
const zshGlobQualifier = /[*?+@!]\(e:[^:]+:\)/.test(source)
|
|
774
|
+
|
|
775
|
+
return (
|
|
776
|
+
dangerousParameterExpansion ||
|
|
777
|
+
parameterAssignmentWithEscapes ||
|
|
778
|
+
indirectExpansion ||
|
|
779
|
+
hereStringWithSubstitution ||
|
|
780
|
+
zshProcessSubstitution ||
|
|
781
|
+
zshGlobQualifier
|
|
782
|
+
)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Find the longest matching prefix from a list of prefixes for a given
|
|
787
|
+
* command (case-insensitive, startsWith-based). Wildcard "*" matches any
|
|
788
|
+
* command but is treated as length 1 for comparison. Ported verbatim.
|
|
789
|
+
*/
|
|
790
|
+
export function findLongestPrefixMatch(command: string, prefixes: string[]): string | null {
|
|
791
|
+
if (!command || !prefixes?.length) {
|
|
792
|
+
return null
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const trimmedCommand = command.trim().toLowerCase()
|
|
796
|
+
let longestMatch: string | null = null
|
|
797
|
+
|
|
798
|
+
for (const prefix of prefixes) {
|
|
799
|
+
const lowerPrefix = prefix.toLowerCase()
|
|
800
|
+
if (lowerPrefix === "*" || trimmedCommand.startsWith(lowerPrefix)) {
|
|
801
|
+
if (!longestMatch || lowerPrefix.length > longestMatch.length) {
|
|
802
|
+
longestMatch = lowerPrefix
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return longestMatch
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** Command approval decision types (upstream). */
|
|
811
|
+
export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user" | "malformed_command"
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Decision for a single command using the longest-prefix-match rule (ported
|
|
815
|
+
* verbatim). Both-list conflict: longer (more specific) match wins; a TIE goes
|
|
816
|
+
* to deny. No match at all → "ask_user".
|
|
817
|
+
*/
|
|
818
|
+
export function getSingleCommandDecision(
|
|
819
|
+
command: string,
|
|
820
|
+
allowedCommands: string[],
|
|
821
|
+
deniedCommands?: string[],
|
|
822
|
+
): CommandDecision {
|
|
823
|
+
if (!command) return "auto_approve"
|
|
824
|
+
|
|
825
|
+
const longestAllowedMatch = findLongestPrefixMatch(command, allowedCommands || [])
|
|
826
|
+
const longestDeniedMatch = findLongestPrefixMatch(command, deniedCommands || [])
|
|
827
|
+
|
|
828
|
+
if (longestAllowedMatch && !longestDeniedMatch) {
|
|
829
|
+
return "auto_approve"
|
|
830
|
+
}
|
|
831
|
+
if (!longestAllowedMatch && longestDeniedMatch) {
|
|
832
|
+
return "auto_deny"
|
|
833
|
+
}
|
|
834
|
+
if (longestAllowedMatch && longestDeniedMatch) {
|
|
835
|
+
return longestAllowedMatch.length > longestDeniedMatch.length ? "auto_approve" : "auto_deny"
|
|
836
|
+
}
|
|
837
|
+
return "ask_user"
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Unified command validation implementing the upstream decision flow (ported
|
|
842
|
+
* verbatim): any denied sub-command denies the whole command; dangerous
|
|
843
|
+
* substitutions are never auto-approved (→ "ask_user"); malformed commands
|
|
844
|
+
* (unterminated quotes) are rejected. See the module header for the verified
|
|
845
|
+
* precedence.
|
|
846
|
+
*/
|
|
847
|
+
export function getCommandDecision(
|
|
848
|
+
command: string,
|
|
849
|
+
allowedCommands: string[],
|
|
850
|
+
deniedCommands?: string[],
|
|
851
|
+
): CommandDecision {
|
|
852
|
+
if (!command?.trim()) {
|
|
853
|
+
return "auto_approve"
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const { commands: subCommands, parseError } = parseCommand(command)
|
|
857
|
+
|
|
858
|
+
if (parseError !== null) {
|
|
859
|
+
return "malformed_command"
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const decisions: CommandDecision[] = subCommands.map((cmd) => {
|
|
863
|
+
const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim()
|
|
864
|
+
return getSingleCommandDecision(cmdWithoutRedirection, allowedCommands, deniedCommands)
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
// Any denied sub-command denies the whole compound command (deny wins).
|
|
868
|
+
if (decisions.includes("auto_deny")) {
|
|
869
|
+
return "auto_deny"
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (containsDangerousSubstitution(command)) {
|
|
873
|
+
return "ask_user"
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (decisions.every((decision) => decision === "auto_approve")) {
|
|
877
|
+
return "auto_approve"
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return "ask_user"
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ─── headless decision layer (our mapping, on top of the port) ──────────────
|
|
884
|
+
|
|
885
|
+
export type PermissionDecision = "allow" | "deny"
|
|
886
|
+
|
|
887
|
+
/** Details of a command refusal, for a clear model-facing error message. */
|
|
888
|
+
export interface CommandRefusal {
|
|
889
|
+
/**
|
|
890
|
+
* - "dangerous": contains a dangerous shell substitution — always blocked,
|
|
891
|
+
* not configurable (upstream never auto-approves these).
|
|
892
|
+
* - "malformed": shell syntax error (unterminated quote/heredoc).
|
|
893
|
+
* - "redirect_escape": an output redirect (`>`, `>>`, `2>`, `&>`) whose
|
|
894
|
+
* resolved target escapes the workspace root — always blocked, NOT
|
|
895
|
+
* configurable, closing the execute_command hole where shell redirects
|
|
896
|
+
* could write outside the workspace (e.g. `/tmp`) even though every
|
|
897
|
+
* file tool rejects those paths (issue #122).
|
|
898
|
+
* - "denied": matched the deny-list (deny wins over allow).
|
|
899
|
+
* - "not_allowed": allow-list configured but this sub-command matches
|
|
900
|
+
* neither list; a headless harness has no human to ask, so it is denied.
|
|
901
|
+
* - "protected_store": recursive delete targeting the shared central store
|
|
902
|
+
* or a parent of it — always blocked, NOT configurable (see
|
|
903
|
+
* src/permissions/store-protection.ts).
|
|
904
|
+
*/
|
|
905
|
+
kind: "dangerous" | "malformed" | "redirect_escape" | "denied" | "not_allowed" | "protected_store"
|
|
906
|
+
/** The sub-command that triggered the refusal (denied/not_allowed/protected_store/redirect_escape). */
|
|
907
|
+
subCommand?: string
|
|
908
|
+
/** The deny-list pattern that matched (denied). */
|
|
909
|
+
pattern?: string
|
|
910
|
+
/** The parse error (malformed). */
|
|
911
|
+
parseError?: UnterminatedQuote
|
|
912
|
+
/** The resolved target that matched the store (protected_store). */
|
|
913
|
+
target?: string
|
|
914
|
+
/** The protected central store root (protected_store). */
|
|
915
|
+
storeRoot?: string
|
|
916
|
+
/** The redirect that escaped the workspace (redirect_escape). */
|
|
917
|
+
redirect?: RedirectTarget
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Check a command against the resolved permissions. Returns a refusal
|
|
922
|
+
* descriptor when the command must not run, or `null` when it is allowed.
|
|
923
|
+
*
|
|
924
|
+
* Order (per the spec + upstream behavior):
|
|
925
|
+
* 1. Dangerous substitution → unconditional refuse (not configurable).
|
|
926
|
+
* 2. Malformed command (unterminated quote/heredoc) → refuse.
|
|
927
|
+
* 3. Always-applied redirect-escape guard: an output redirect (`>`, `>>`,
|
|
928
|
+
* `2>`, `&>`) whose target escapes the workspace root — e.g. `> /tmp/x`,
|
|
929
|
+
* `> $HOME/out`, `2> ../outside.log` — is refused even with an empty
|
|
930
|
+
* allow/deny config, and is NOT overridable. This closes the documented
|
|
931
|
+
* execute_command hole (issue #122): every file tool hard-rejects
|
|
932
|
+
* outside-workspace paths via resolveWithinWorkspace, but a shell redirect
|
|
933
|
+
* previously slipped through the command-string allow/deny gate and wrote
|
|
934
|
+
* to `/tmp`. Runs before parsing so it also catches redirects embedded in
|
|
935
|
+
* unparseable fragments (e.g. a heredoc body's own `> /tmp` line).
|
|
936
|
+
* 4. Each sub-command from `parseCommand` (so `echo hi && rm -rf /` is checked
|
|
937
|
+
* per sub-command, not as one opaque string): FIRST the central-store
|
|
938
|
+
* protection (recursive delete targeting the shared store or a parent of it
|
|
939
|
+
* — always refused, even with empty allow/deny config, and NOT overridable
|
|
940
|
+
* by any permissions config), THEN the redirect-escape guard per
|
|
941
|
+
* sub-command, then the deny-list, then the allow-list, with upstream's
|
|
942
|
+
* longest-prefix-match precedence.
|
|
943
|
+
*
|
|
944
|
+
* `options.workspaceRoot` anchors relative command targets for the
|
|
945
|
+
* central-store and redirect-escape checks (the executor passes the command's
|
|
946
|
+
* resolved `cwd`; default: process.cwd()). It has no effect on the
|
|
947
|
+
* allow/deny lists.
|
|
948
|
+
*/
|
|
949
|
+
export function checkCommand(
|
|
950
|
+
command: string,
|
|
951
|
+
allowedCommands: string[],
|
|
952
|
+
deniedCommands: string[],
|
|
953
|
+
options?: { workspaceRoot?: string },
|
|
954
|
+
): CommandRefusal | null {
|
|
955
|
+
if (!command?.trim()) {
|
|
956
|
+
return null
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
if (containsDangerousSubstitution(command)) {
|
|
960
|
+
return { kind: "dangerous" }
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Pre-parse redirect-escape scan: redirectTargets walks the raw command
|
|
964
|
+
// (heredoc bodies included), so it still catches redirects that parseCommand
|
|
965
|
+
// would classify as malformed (unterminated quote/heredoc).
|
|
966
|
+
const redirectEscape = checkRedirectEscape(command, options?.workspaceRoot)
|
|
967
|
+
if (redirectEscape !== null) {
|
|
968
|
+
return { kind: "redirect_escape", subCommand: command, redirect: redirectEscape }
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const { commands: subCommands, parseError } = parseCommand(command)
|
|
972
|
+
if (parseError !== null) {
|
|
973
|
+
return { kind: "malformed", parseError, subCommand: command }
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Track a leading `cd <dir> && ...` chain's effective cwd across
|
|
977
|
+
// sub-commands (SEC-6): `cd /tmp && rm -rf x` must resolve `x` against
|
|
978
|
+
// /tmp, not the pre-cd workspace root, or a relative-path rm evades the
|
|
979
|
+
// central-store check. Only a plain `cd <path>` sub-command updates the
|
|
980
|
+
// tracked cwd (best-effort — `cd` inside a subshell, via a variable, or
|
|
981
|
+
// via `pushd`/`popd` is not tracked; see store-protection.ts header).
|
|
982
|
+
let effectiveCwd = path.resolve(options?.workspaceRoot ?? process.cwd())
|
|
983
|
+
|
|
984
|
+
for (const sub of subCommands) {
|
|
985
|
+
const cmd = sub.replace(/\d*>&\d*/, "").trim()
|
|
986
|
+
if (!cmd) {
|
|
987
|
+
continue
|
|
988
|
+
}
|
|
989
|
+
// Always-applied central-store protection: runs before (and
|
|
990
|
+
// independently of) the allow/deny lists, so the empty-allow-list
|
|
991
|
+
// default-ALLOW branch can never bypass it and no permissions.json
|
|
992
|
+
// entry can override it.
|
|
993
|
+
const storeRefusal = checkCentralStoreDestruction(cmd, effectiveCwd)
|
|
994
|
+
if (storeRefusal !== null) {
|
|
995
|
+
return {
|
|
996
|
+
kind: "protected_store",
|
|
997
|
+
subCommand: cmd,
|
|
998
|
+
target: storeRefusal.target,
|
|
999
|
+
storeRoot: storeRefusal.storeRoot,
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
// Per-sub-command redirect-escape guard (the pre-parse scan above is
|
|
1003
|
+
// the same check on the whole command; this names the offending
|
|
1004
|
+
// sub-command for the refusal message). Anchored to the tracked
|
|
1005
|
+
// cd-chain cwd for the same reason as the store-destruction check
|
|
1006
|
+
// above — a relative redirect after `cd /tmp && ...` must resolve
|
|
1007
|
+
// against /tmp, not the pre-cd workspace root.
|
|
1008
|
+
const subRedirect = checkRedirectEscape(cmd, effectiveCwd)
|
|
1009
|
+
if (subRedirect !== null) {
|
|
1010
|
+
return { kind: "redirect_escape", subCommand: cmd, redirect: subRedirect }
|
|
1011
|
+
}
|
|
1012
|
+
const refusal = checkSingleCommand(cmd, allowedCommands, deniedCommands)
|
|
1013
|
+
if (refusal !== null) {
|
|
1014
|
+
return refusal
|
|
1015
|
+
}
|
|
1016
|
+
const cdTarget = matchLeadingCd(cmd)
|
|
1017
|
+
if (cdTarget !== null) {
|
|
1018
|
+
effectiveCwd = path.resolve(effectiveCwd, expandEnv(expandHome(cdTarget)))
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
return null
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* When `cmd` is a plain `cd <path>` (optionally quoted, no other words), return
|
|
1027
|
+
* the raw path argument; otherwise null. Deliberately narrow — `cd` combined
|
|
1028
|
+
* with anything else on the same sub-command word (e.g. `cd /tmp; ls`, already
|
|
1029
|
+
* split by parseCommand into separate sub-commands) or with no argument
|
|
1030
|
+
* (`cd` alone, which goes to $HOME) is not tracked.
|
|
1031
|
+
*/
|
|
1032
|
+
function matchLeadingCd(cmd: string): string | null {
|
|
1033
|
+
const m = cmd.match(/^cd\s+(.+)$/)
|
|
1034
|
+
if (!m) {
|
|
1035
|
+
return null
|
|
1036
|
+
}
|
|
1037
|
+
let arg = m[1].trim()
|
|
1038
|
+
if ((arg.startsWith('"') && arg.endsWith('"')) || (arg.startsWith("'") && arg.endsWith("'"))) {
|
|
1039
|
+
arg = arg.slice(1, -1)
|
|
1040
|
+
}
|
|
1041
|
+
return arg || null
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function checkSingleCommand(
|
|
1045
|
+
command: string,
|
|
1046
|
+
allowedCommands: string[],
|
|
1047
|
+
deniedCommands: string[],
|
|
1048
|
+
): CommandRefusal | null {
|
|
1049
|
+
// EMPTY allow-list = default-ALLOW (deliberate, documented decision): the
|
|
1050
|
+
// operator has not opted into allow-list gating, so only the deny-list
|
|
1051
|
+
// applies. This preserves today's behavior for sessions that configure
|
|
1052
|
+
// nothing while still making deniedCommands always apply.
|
|
1053
|
+
if (allowedCommands.length === 0) {
|
|
1054
|
+
const deniedPattern = findLongestPrefixMatch(command, deniedCommands)
|
|
1055
|
+
if (deniedPattern !== null) {
|
|
1056
|
+
return { kind: "denied", subCommand: command, pattern: deniedPattern }
|
|
1057
|
+
}
|
|
1058
|
+
return null
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Allow-list configured: mirror the upstream longest-prefix-match decision.
|
|
1062
|
+
const decision = getSingleCommandDecision(command, allowedCommands, deniedCommands)
|
|
1063
|
+
if (decision === "auto_deny") {
|
|
1064
|
+
const pattern = findLongestPrefixMatch(command, deniedCommands) ?? findLongestPrefixMatch(command, allowedCommands)
|
|
1065
|
+
return { kind: "denied", subCommand: command, pattern: pattern ?? "(deny-list)" }
|
|
1066
|
+
}
|
|
1067
|
+
if (decision === "ask_user") {
|
|
1068
|
+
// Upstream would prompt a human here; a headless harness has none, so
|
|
1069
|
+
// anything not explicitly allowed is refused.
|
|
1070
|
+
return { kind: "not_allowed", subCommand: command }
|
|
1071
|
+
}
|
|
1072
|
+
return null // auto_approve
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Allow/deny for a command against the resolved lists (headless semantics). */
|
|
1076
|
+
export function decideCommand(
|
|
1077
|
+
command: string,
|
|
1078
|
+
allowedCommands: string[],
|
|
1079
|
+
deniedCommands: string[],
|
|
1080
|
+
options?: { workspaceRoot?: string },
|
|
1081
|
+
): PermissionDecision {
|
|
1082
|
+
return checkCommand(command, allowedCommands, deniedCommands, options) === null ? "allow" : "deny"
|
|
1083
|
+
}
|