@gajae-code/coding-agent 0.18.12 → 0.19.0
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/CHANGELOG.md +467 -0
- package/dist/types/capability/rule.d.ts +7 -2
- package/dist/types/cli/append-system-prompt.d.ts +6 -0
- package/dist/types/cli/args.d.ts +7 -2
- package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
- package/dist/types/cli/customize-doctor.d.ts +4 -3
- package/dist/types/cli/public-command-errors.d.ts +1 -0
- package/dist/types/cli/root-flags.d.ts +0 -12
- package/dist/types/cli/update-cli.d.ts +7 -2
- package/dist/types/cli-main.d.ts +0 -6
- package/dist/types/commands/launch.d.ts +0 -6
- package/dist/types/commands/migrate.d.ts +1 -1
- package/dist/types/commands/sdk.d.ts +8 -0
- package/dist/types/commit/map-reduce/index.d.ts +4 -4
- package/dist/types/config/autorouting-tier-map.d.ts +23 -1
- package/dist/types/config/config-hot-reload.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +44 -2
- package/dist/types/config/model-profile-activation.d.ts +19 -5
- package/dist/types/config/model-registry.d.ts +16 -3
- package/dist/types/config/model-resolver.d.ts +5 -4
- package/dist/types/config/settings-schema.d.ts +11 -1
- package/dist/types/coordinator-mcp/server.d.ts +4 -0
- package/dist/types/customization/import.d.ts +2 -0
- package/dist/types/customization/types.d.ts +1 -1
- package/dist/types/dap/session.d.ts +11 -2
- package/dist/types/discovery/helpers.d.ts +7 -2
- package/dist/types/edit/index.d.ts +5 -0
- package/dist/types/edit/modes/patch.d.ts +9 -0
- package/dist/types/eval/py/runtime.d.ts +9 -0
- package/dist/types/export/ttsr.d.ts +19 -0
- package/dist/types/extensibility/extensions/types.d.ts +5 -1
- package/dist/types/extensibility/slash-commands.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
- package/dist/types/hindsight/content.d.ts +5 -6
- package/dist/types/internal-urls/types.d.ts +2 -0
- package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
- package/dist/types/main.d.ts +23 -18
- package/dist/types/migrate/action-planner.d.ts +10 -1
- package/dist/types/migrate/adapters/codex.d.ts +1 -1
- package/dist/types/migrate/adapters/index.d.ts +35 -3
- package/dist/types/migrate/hook-module.d.ts +23 -0
- package/dist/types/migrate/report.d.ts +2 -1
- package/dist/types/migrate/types.d.ts +69 -2
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +43 -0
- package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
- package/dist/types/modes/utils/host-status.d.ts +15 -11
- package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
- package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +22 -1
- package/dist/types/sdk/broker/ensure.d.ts +5 -2
- package/dist/types/sdk/broker/hop.d.ts +9 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
- package/dist/types/sdk/broker/session-index.d.ts +2 -0
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/index.d.ts +21 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
- package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
- package/dist/types/sdk/host/control/operations.d.ts +5 -1
- package/dist/types/sdk/host/events.d.ts +4 -0
- package/dist/types/sdk/host/host.d.ts +2 -1
- package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
- package/dist/types/sdk/host/session-runtime.d.ts +8 -8
- package/dist/types/sdk/host/surface-policy.d.ts +2 -1
- package/dist/types/sdk/lifecycle/service.d.ts +12 -0
- package/dist/types/sdk/prompt-status.d.ts +11 -4
- package/dist/types/session/agent-session.d.ts +50 -8
- package/dist/types/session/session-manager.d.ts +16 -0
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
- package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
- package/dist/types/ssh/ssh-executor.d.ts +8 -1
- package/dist/types/system-prompt.d.ts +3 -2
- package/dist/types/task/role-body.d.ts +11 -0
- package/dist/types/tools/awaiting-input.d.ts +28 -0
- package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
- package/dist/types/tools/bash-skill-urls.d.ts +4 -0
- package/dist/types/tools/bash.d.ts +7 -0
- package/dist/types/tools/descriptors.d.ts +13 -2
- package/dist/types/tools/fetch.d.ts +6 -0
- package/dist/types/tools/irc.d.ts +5 -3
- package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/utils/file-mentions.d.ts +5 -0
- package/dist/types/utils/image-limits.d.ts +7 -0
- package/dist/types/utils/image-loading.d.ts +0 -1
- package/dist/types/utils/open.d.ts +0 -1
- package/dist/types/utils/pasted-image-loading.d.ts +1 -5
- package/dist/types/utils/prompt-suggestion.d.ts +2 -2
- package/dist/types/utils/shell-snapshot.d.ts +2 -0
- package/dist/types/utils/title-generator.d.ts +13 -3
- package/dist/types/web/search/providers/codex.d.ts +6 -7
- package/package.json +9 -8
- package/scripts/acp-conformance-agent.ts +12 -2
- package/scripts/generate-sdk-operation-inventory.ts +16 -0
- package/src/capability/fs.ts +31 -3
- package/src/capability/rule.ts +11 -2
- package/src/cli/append-system-prompt.ts +12 -0
- package/src/cli/args.ts +11 -7
- package/src/cli/customize-doctor-precedence.ts +31 -0
- package/src/cli/customize-doctor.ts +49 -39
- package/src/cli/fast-help.ts +0 -2
- package/src/cli/migrate-cli.ts +14 -3
- package/src/cli/public-command-errors.ts +2 -0
- package/src/cli/root-flags.ts +0 -2
- package/src/cli/update-cli.ts +247 -6
- package/src/commands/harness.ts +25 -15
- package/src/commands/migrate.ts +3 -3
- package/src/commands/sdk.ts +81 -11
- package/src/commit/agentic/agent.ts +6 -6
- package/src/commit/analysis/conventional.ts +14 -5
- package/src/commit/map-reduce/index.ts +7 -7
- package/src/commit/pipeline.ts +12 -12
- package/src/config/autorouting-tier-map.ts +20 -1
- package/src/config/config-file.ts +106 -3
- package/src/config/config-hot-reload.ts +8 -0
- package/src/config/file-lock.ts +1431 -304
- package/src/config/keybindings.ts +154 -7
- package/src/config/model-profile-activation.ts +177 -39
- package/src/config/model-registry.ts +380 -55
- package/src/config/model-resolver.ts +49 -21
- package/src/config/settings-schema.ts +13 -1
- package/src/config/settings.ts +2 -0
- package/src/coordinator-mcp/server.ts +37 -2
- package/src/crash/sanitize.ts +3 -1
- package/src/customization/import.ts +276 -7
- package/src/customization/inventory.ts +24 -8
- package/src/customization/types.ts +1 -0
- package/src/dap/client.ts +3 -2
- package/src/dap/session.ts +161 -36
- package/src/debug/crash-diagnostics.ts +87 -5
- package/src/debug/report-bundle.ts +71 -2
- package/src/discovery/builtin.ts +4 -3
- package/src/discovery/helpers.ts +24 -14
- package/src/edit/index.ts +191 -5
- package/src/edit/modes/patch.ts +27 -0
- package/src/eval/py/runtime.ts +44 -4
- package/src/exec/non-interactive-env.ts +0 -1
- package/src/export/html/index.ts +60 -20
- package/src/export/ttsr.ts +176 -1
- package/src/extensibility/extensions/loader.ts +2 -0
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
- package/src/extensibility/extensions/types.ts +5 -1
- package/src/extensibility/hooks/loader.ts +10 -2
- package/src/extensibility/plugins/marketplace/manager.ts +36 -10
- package/src/extensibility/slash-commands.ts +5 -0
- package/src/gjc-runtime/gc-runtime.ts +4 -1
- package/src/gjc-runtime/launch-tmux.ts +23 -13
- package/src/gjc-runtime/ralplan-current-final.ts +294 -0
- package/src/gjc-runtime/ralplan-runtime.ts +95 -1
- package/src/gjc-runtime/state-writer.ts +109 -5
- package/src/gjc-runtime/tmux-sessions.ts +93 -13
- package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
- package/src/gjc-runtime/ultragoal-guard.ts +9 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
- package/src/harness-control-plane/finalize.ts +11 -0
- package/src/harness-control-plane/receipt-spool.ts +105 -28
- package/src/harness-control-plane/storage.ts +124 -6
- package/src/hindsight/content.ts +116 -16
- package/src/hindsight/mental-models.ts +22 -10
- package/src/hindsight/state.ts +7 -4
- package/src/hooks/native-skill-hook.ts +3 -1
- package/src/internal-urls/artifact-protocol.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +14 -14
- package/src/internal-urls/local-protocol.ts +28 -3
- package/src/internal-urls/types.ts +2 -0
- package/src/lsp/client.ts +2 -1
- package/src/lsp/clients/biome-client.ts +2 -0
- package/src/lsp/clients/swiftlint-client.ts +2 -0
- package/src/main.ts +330 -92
- package/src/memories/index.ts +25 -2
- package/src/migrate/action-planner.ts +517 -2
- package/src/migrate/adapters/claude-code.ts +141 -3
- package/src/migrate/adapters/codex.ts +57 -5
- package/src/migrate/adapters/index.ts +240 -5
- package/src/migrate/adapters/opencode.ts +4 -0
- package/src/migrate/executor.ts +141 -3
- package/src/migrate/hook-module.ts +193 -0
- package/src/migrate/report.ts +45 -1
- package/src/migrate/types.ts +73 -3
- package/src/modes/acp/acp-agent.ts +10 -1
- package/src/modes/components/agent-dashboard.ts +20 -4
- package/src/modes/components/customization/customization-dashboard.ts +7 -0
- package/src/modes/components/customization/import-wizard.ts +29 -3
- package/src/modes/components/model-selector.ts +157 -103
- package/src/modes/components/welcome.ts +3 -1
- package/src/modes/controllers/command-controller.ts +271 -16
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/controllers/extension-ui-controller.ts +31 -1
- package/src/modes/controllers/goal-mode-controller.ts +79 -10
- package/src/modes/controllers/input-controller.ts +2 -0
- package/src/modes/controllers/plan-mode-controller.ts +49 -4
- package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
- package/src/modes/controllers/selector-controller.ts +14 -1
- package/src/modes/interactive-mode.ts +55 -4
- package/src/modes/prompt-suggestion-controller.ts +1 -1
- package/src/modes/runtime-init.ts +5 -1
- package/src/modes/utils/host-status.ts +21 -10
- package/src/prompts/agent-fragments/browser-aside.md +2 -2
- package/src/prompts/system/irc-incoming.md +3 -1
- package/src/prompts/tools/irc.md +1 -1
- package/src/runtime-mcp/manager.ts +8 -3
- package/src/runtime-mcp/project-host-values.ts +12 -8
- package/src/runtime-mcp/tool-bridge.ts +68 -4
- package/src/runtime-mcp/transports/stdio.ts +2 -1
- package/src/sdk/broker/broker-exit.ts +2 -0
- package/src/sdk/broker/broker.ts +143 -4
- package/src/sdk/broker/ensure.ts +74 -6
- package/src/sdk/broker/hop.ts +13 -1
- package/src/sdk/broker/lifecycle-ledger.ts +3 -0
- package/src/sdk/broker/lifecycle.ts +102 -6
- package/src/sdk/broker/session-index.ts +29 -4
- package/src/sdk/broker/transport.ts +17 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +408 -14
- package/src/sdk/bus/session-control.ts +2 -0
- package/src/sdk/bus/slack-daemon.ts +105 -9
- package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
- package/src/sdk/bus/telegram-daemon.ts +10 -2
- package/src/sdk/bus/threaded-render.ts +20 -1
- package/src/sdk/cli/session-cli.ts +2 -0
- package/src/sdk/host/control/dispatch.ts +13 -1
- package/src/sdk/host/control/operations.ts +10 -1
- package/src/sdk/host/events.ts +67 -4
- package/src/sdk/host/host.ts +8 -2
- package/src/sdk/host/prompt-image-upload.ts +438 -0
- package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
- package/src/sdk/host/session-runtime.test.ts +1537 -24
- package/src/sdk/host/session-runtime.ts +332 -36
- package/src/sdk/host/surface-policy.ts +4 -0
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
- package/src/sdk/host/websocket-transport.ts +19 -7
- package/src/sdk/lifecycle/service.ts +14 -1
- package/src/sdk/prompt-status.ts +6 -4
- package/src/sdk/protocol/adapter-validation.ts +28 -1
- package/src/sdk/protocol/operation-inventory.generated.json +160 -0
- package/src/sdk/protocol/operation-registry.ts +33 -3
- package/src/sdk/session.ts +123 -36
- package/src/session/agent-session.ts +1614 -329
- package/src/session/history-storage.ts +182 -12
- package/src/session/internal/managed-session-scope.ts +47 -18
- package/src/session/internal/managed-session-storage.ts +71 -1
- package/src/session/session-manager.ts +90 -5
- package/src/session-import/redact.ts +53 -2
- package/src/skill-state/workflow-mutation-guard.ts +1498 -18
- package/src/slash-commands/builtin-registry.ts +6 -3
- package/src/slash-commands/helpers/mcp.ts +11 -3
- package/src/ssh/ssh-executor.ts +9 -2
- package/src/stt/recorder.ts +26 -9
- package/src/system-prompt.ts +61 -16
- package/src/task/executor.ts +12 -8
- package/src/task/role-body.ts +84 -0
- package/src/task/token-log-mode.test.ts +162 -0
- package/src/task/token-log.ts +91 -2
- package/src/tools/archive-reader.ts +650 -37
- package/src/tools/ask.ts +1 -0
- package/src/tools/ast-grep.ts +7 -2
- package/src/tools/auto-generated-guard.ts +12 -3
- package/src/tools/awaiting-input.ts +114 -0
- package/src/tools/bash-allowed-prefixes.ts +61 -1
- package/src/tools/bash-skill-urls.ts +58 -5
- package/src/tools/bash.ts +99 -26
- package/src/tools/browser/tab-worker.ts +39 -13
- package/src/tools/browser.ts +17 -7
- package/src/tools/cron.ts +10 -1
- package/src/tools/debug.ts +9 -2
- package/src/tools/descriptors.test.ts +4 -4
- package/src/tools/descriptors.ts +16 -4
- package/src/tools/eval.ts +15 -0
- package/src/tools/fetch.ts +90 -2
- package/src/tools/gh.ts +27 -12
- package/src/tools/image-gen.ts +20 -6
- package/src/tools/irc.ts +5 -3
- package/src/tools/monitor.ts +9 -1
- package/src/tools/output-meta.ts +51 -3
- package/src/tools/read.ts +9 -2
- package/src/tools/recipe/runner.ts +23 -12
- package/src/tools/render-utils.ts +2 -2
- package/src/tools/search.ts +3 -2
- package/src/tools/ssh.ts +274 -8
- package/src/tools/subagent.ts +10 -4
- package/src/tools/tmux-self-injection-guard.ts +3301 -122
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/tools/ultragoal-ask-guard.ts +13 -1
- package/src/tools/write.ts +7 -0
- package/src/tui/tree-list.ts +15 -1
- package/src/utils/commit-message-generator.ts +3 -2
- package/src/utils/external-editor.ts +21 -7
- package/src/utils/file-mentions.ts +46 -6
- package/src/utils/git.ts +38 -7
- package/src/utils/image-limits.ts +8 -0
- package/src/utils/image-loading.ts +1 -1
- package/src/utils/open.ts +73 -4
- package/src/utils/pasted-image-loading.ts +9 -8
- package/src/utils/prompt-suggestion.ts +5 -4
- package/src/utils/shell-snapshot.ts +9 -1
- package/src/utils/title-generator.ts +65 -20
- package/src/web/search/index.ts +2 -1
- package/src/web/search/providers/codex.ts +29 -32
|
@@ -102,10 +102,68 @@ const BASH_IN_PLACE_MUTATION_COMMAND_RE = new RegExp(
|
|
|
102
102
|
`(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(?:sed|perl)\\b([^;&|()\\n]*)`,
|
|
103
103
|
"gi",
|
|
104
104
|
);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Write APIs a planning one-liner cannot be allowed to call. The destination is
|
|
107
|
+
* not a static path the temp allowlist can trust, so a match fails closed the
|
|
108
|
+
* same way `open(` / `writeFile(` / `.write(` already do. `perl` is included
|
|
109
|
+
* because `perl -e 'open(...)'` is that same call.
|
|
110
|
+
*/
|
|
111
|
+
const BASH_INTERPRETER_WRITE_API_PATTERNS = [
|
|
112
|
+
"open\\s*\\(",
|
|
113
|
+
"writeFile(?:Sync)?\\s*\\(",
|
|
114
|
+
"appendFile(?:Sync)?\\s*\\(",
|
|
115
|
+
"file_put_contents\\s*\\(",
|
|
116
|
+
"fwrite\\s*\\(",
|
|
117
|
+
"fputs\\s*\\(",
|
|
118
|
+
"unlink\\s*\\(",
|
|
119
|
+
"rename\\s*\\(",
|
|
120
|
+
"mkdir\\s*\\(",
|
|
121
|
+
"rmdir\\s*\\(",
|
|
122
|
+
"copy\\s*\\(",
|
|
123
|
+
"symlink\\s*\\(",
|
|
124
|
+
"link\\s*\\(",
|
|
125
|
+
"move_uploaded_file\\s*\\(",
|
|
126
|
+
"truncate\\s*\\(",
|
|
127
|
+
"touch\\s*\\(",
|
|
128
|
+
"\\.write\\s*\\(",
|
|
129
|
+
"shutil\\s*\\.\\s*(?:copy2?|copyfileobj|copyfile)\\s*\\(",
|
|
130
|
+
"os\\s*\\.\\s*symlink\\s*\\(",
|
|
131
|
+
] as const;
|
|
132
|
+
const BASH_INTERPRETER_WRITE_API = `(?:${BASH_INTERPRETER_WRITE_API_PATTERNS.join("|")})`;
|
|
133
|
+
const BASH_INTERPRETER_WRITE_API_EXCLUDING_OPEN = `(?:${BASH_INTERPRETER_WRITE_API_PATTERNS.slice(1).join("|")})`;
|
|
134
|
+
const INTERPRETER_OUTPUT_METHOD_ASSIGNMENT_RE =
|
|
135
|
+
/^\b(?:(?:process|Bun|Deno|sys)(?:\s*\.\s*(?:stdout|stderr)|\s*\[\s*["'`](?:stdout|stderr)["'`]\s*\])|(?:process|Bun|Deno|sys)\s*\[\s*[^\]]+\s*\]\s*(?:\.\s*write|\[\s*["'`]write["'`]\s*\])|(?:(?:globalThis|global)\s*\.\s*)?console)(?:\s*(?:\.\s*[\w$]+|\[\s*[^\]]+\s*\]))?\s*(?:\|\|=|&&=|\?\?=|[+\-*/%&|^]?=(?!=|>))/;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A stream object can also be mutated reflectively, so an exempted
|
|
139
|
+
* `process.stdout.write(...)` is only trustworthy when nothing in the source can
|
|
140
|
+
* have replaced that method (`Object.assign(process.stdout, {write: ...})`).
|
|
141
|
+
*/
|
|
142
|
+
const INTERPRETER_OUTPUT_STREAM_MUTATION_RE =
|
|
143
|
+
/\b(?:Object\s*\.\s*(?:assign|definePropert(?:y|ies))|Reflect\s*\.\s*(?:set|defineProperty))\s*\(\s*(?:process|Bun|Deno|sys)\s*(?:\.\s*(?:stdout|stderr)|\[\s*["'`](?:stdout|stderr)["'`]\s*\])/;
|
|
144
|
+
/**
|
|
145
|
+
* Inline interpreter source is opaque whenever it exposes code evaluation or
|
|
146
|
+
* reflective loading. This deliberately scans the complete source spelling,
|
|
147
|
+
* including quoted spans, because another language may evaluate those spans.
|
|
148
|
+
*/
|
|
149
|
+
const INTERPRETER_DYNAMIC_EXECUTION_RE =
|
|
150
|
+
/\b(?:eval|exec|execfile|Function|instance_eval|instance_exec|class_eval|class_exec|module_eval|module_exec|send|public_send|__send__|method_missing|__import__|exec_module|import_module|run_path|run_module|loadstring|dofile|loadfile|load|source|include|include_once|require_once|create_function|call_user_func(?:_array)?|setTimeout|setInterval|runInThisContext|runInNewContext|runInContext|getattr|setattr|__getattr__|__getattribute__|ReflectionClass|ReflectionFunction|ReflectionMethod|invokeMethod|invokeArgs)\b/;
|
|
151
|
+
const INTERPRETER_FILESYSTEM_REQUIRE_RE =
|
|
152
|
+
/\b(?:require|import|from)\s*(?:\(\s*)?(['"\x60])(?:node:)?(?:fs(?:\/promises)?|fs-extra|graceful-fs|child_process|fileutils|io|lfs)\1(?:\s*\))?/i;
|
|
153
|
+
const INTERPRETER_NONLITERAL_REQUIRE_RE = /\brequire\s*(?:\(\s*(?!['"\x60])|\s+(?!['"\x60]))/i;
|
|
154
|
+
const SHELL_DOT_SOURCE_RE = /(?:^|[;&|\n])\s*(?:(?:if|then|do)\s+)?\.\s+(?:["'$`]|[A-Za-z0-9_./~])/;
|
|
155
|
+
/** Executable Python source evaluation and dynamic imports make payloads opaque; compile() alone is inert. */
|
|
156
|
+
const PYTHON_DYNAMIC_EXECUTION_IDENTIFIER_RE =
|
|
157
|
+
/^(?:exec|eval|execfile|__import__|exec_module|import_module|run_path|run_module|getattr|setattr|__getattr__|__getattribute__)\b/;
|
|
158
|
+
const PYTHON_DYNAMIC_EXECUTION_TOKEN_RE =
|
|
159
|
+
/\b(?:exec|eval|execfile|__import__|exec_module|import_module|run_path|run_module|getattr|setattr|__getattr__|__getattribute__)\b/;
|
|
160
|
+
/** JavaScript eval and Function construct executable code from runtime strings. */
|
|
161
|
+
const JAVASCRIPT_DYNAMIC_EXECUTION_IDENTIFIER_RE = /^(?:eval|Function)\b/;
|
|
162
|
+
const JAVASCRIPT_DYNAMIC_EXECUTION_TOKEN_RE = /\b(?:eval|Function)\b/;
|
|
163
|
+
const BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE = new RegExp(
|
|
164
|
+
`(?:^|[;&|\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?(?:python3?|node|ruby|perl)\\b[^;&|\\n]*(?:<<[-]?\\s*['"]?\\w+['"]?)[\\s\\S]*${BASH_INTERPRETER_WRITE_API}`,
|
|
165
|
+
"i",
|
|
166
|
+
);
|
|
109
167
|
const BASH_DD_OUTPUT_RE = new RegExp(
|
|
110
168
|
`(?:^|[;&|\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|]*\\/)?dd\\b([^;&|\\n]*)`,
|
|
111
169
|
"gi",
|
|
@@ -311,7 +369,7 @@ interface ActivePlanningSkill {
|
|
|
311
369
|
* reliably wins. Ties fall back to the resolved top-level `skill`, then to the
|
|
312
370
|
* first entry, matching how the HUD/chain guard pick `activeSkills[0]`.
|
|
313
371
|
*/
|
|
314
|
-
function resolveCurrentWorkflowEntry(entries: SkillActiveEntry[], topLevelSkill: string): SkillActiveEntry {
|
|
372
|
+
export function resolveCurrentWorkflowEntry(entries: SkillActiveEntry[], topLevelSkill: string): SkillActiveEntry {
|
|
315
373
|
const ts = (entry: SkillActiveEntry): number => {
|
|
316
374
|
const value = Date.parse(safeString(entry.updated_at) || safeString(entry.activated_at));
|
|
317
375
|
return Number.isNaN(value) ? -1 : value;
|
|
@@ -1564,6 +1622,1310 @@ function extractSubstitutionBodies(command: string): SubstitutionBody[] {
|
|
|
1564
1622
|
return bodies;
|
|
1565
1623
|
}
|
|
1566
1624
|
|
|
1625
|
+
const BASH_SED_COMMAND_RE = new RegExp(
|
|
1626
|
+
`(?:^|[;&|(\\n])\\s*(?:(?:if|then|else|elif|do|in|while|until)\\s+)*(?:!\\s+)?(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?sed\\b((?:'[^']*'|"(?:\\\\.|[^"\\\\])*"|\\\\.|[^'";&|()\\n\\\\])*)`,
|
|
1627
|
+
"gi",
|
|
1628
|
+
);
|
|
1629
|
+
const BASH_AWK_COMMAND_RE = new RegExp(
|
|
1630
|
+
`(?:^|[;&|(\\n])\\s*(?:(?:if|then|else|elif|do|in)\\s+)*(?:!\\s+)?(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(?:awk|gawk|mawk|nawk|busybox\\s+awk)\\b((?:'[^']*'|"(?:\\\\.|[^"\\\\])*"|\\\\.|[^'";&|()\\n\\\\])*)`,
|
|
1631
|
+
"gi",
|
|
1632
|
+
);
|
|
1633
|
+
|
|
1634
|
+
/** `true` when index sits at a sed command letter, not inside a pattern. */
|
|
1635
|
+
function sedCommandPosition(script: string, index: number): boolean {
|
|
1636
|
+
if (index === 0) return true;
|
|
1637
|
+
return /[\s;{}\n\d$,]/.test(script[index - 1] ?? "");
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function sedWriteName(script: string, index: number): { path: string | null; next: number } {
|
|
1641
|
+
let cursor = index;
|
|
1642
|
+
while (cursor < script.length && (script[cursor] === " " || script[cursor] === "\t")) cursor++;
|
|
1643
|
+
const start = cursor;
|
|
1644
|
+
while (cursor < script.length && script[cursor] !== ";" && script[cursor] !== "\n" && script[cursor] !== "}") {
|
|
1645
|
+
cursor++;
|
|
1646
|
+
}
|
|
1647
|
+
return { path: cursor > start ? script.slice(start, cursor) : null, next: cursor };
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function sedAddressEnd(script: string, start: number): number | undefined {
|
|
1651
|
+
if (script[start] === "/") {
|
|
1652
|
+
let cursor = start + 1;
|
|
1653
|
+
while (cursor < script.length) {
|
|
1654
|
+
if (script[cursor] === "\\") {
|
|
1655
|
+
cursor += 2;
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
if (script[cursor] === "/") return cursor + 1;
|
|
1659
|
+
cursor++;
|
|
1660
|
+
}
|
|
1661
|
+
return undefined;
|
|
1662
|
+
}
|
|
1663
|
+
if (script[start] === "$") return start + 1;
|
|
1664
|
+
if (!/\d/.test(script[start] ?? "")) return undefined;
|
|
1665
|
+
let cursor = start + 1;
|
|
1666
|
+
while (/\d/.test(script[cursor] ?? "")) cursor++;
|
|
1667
|
+
if (script[cursor] === "~") {
|
|
1668
|
+
cursor++;
|
|
1669
|
+
while (/\d/.test(script[cursor] ?? "")) cursor++;
|
|
1670
|
+
}
|
|
1671
|
+
return cursor;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function sedAddressWithModifiersEnd(script: string, start: number): number | undefined {
|
|
1675
|
+
let cursor = sedAddressEnd(script, start);
|
|
1676
|
+
if (cursor === undefined) return undefined;
|
|
1677
|
+
if (script[start] === "/") {
|
|
1678
|
+
while (script[cursor] === "I" || script[cursor] === "M") cursor++;
|
|
1679
|
+
}
|
|
1680
|
+
return cursor;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
function sedRegexAddressWritePath(script: string, start: number): { path: string | null; next: number } | undefined {
|
|
1684
|
+
let cursor = sedAddressWithModifiersEnd(script, start);
|
|
1685
|
+
if (cursor === undefined) return undefined;
|
|
1686
|
+
while (script[cursor] === " " || script[cursor] === "\t") cursor++;
|
|
1687
|
+
if (script[cursor] === ",") {
|
|
1688
|
+
cursor++;
|
|
1689
|
+
while (script[cursor] === " " || script[cursor] === "\t") cursor++;
|
|
1690
|
+
const rangeEnd = sedAddressWithModifiersEnd(script, cursor);
|
|
1691
|
+
if (rangeEnd === undefined) return undefined;
|
|
1692
|
+
cursor = rangeEnd;
|
|
1693
|
+
while (script[cursor] === " " || script[cursor] === "\t") cursor++;
|
|
1694
|
+
}
|
|
1695
|
+
if (script[cursor] === "!") cursor++;
|
|
1696
|
+
while (script[cursor] === " " || script[cursor] === "\t") cursor++;
|
|
1697
|
+
if ((script[cursor] !== "w" && script[cursor] !== "W") || !" \t;\n}".includes(script[cursor + 1] ?? "\n")) {
|
|
1698
|
+
return undefined;
|
|
1699
|
+
}
|
|
1700
|
+
return sedWriteName(script, cursor + 1);
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/**
|
|
1704
|
+
* Filename a sed script writes with the `w`/`W` command or an `s///w file` flag.
|
|
1705
|
+
* `undefined` means this text does not write. `null` means it writes without a
|
|
1706
|
+
* literal filename.
|
|
1707
|
+
*/
|
|
1708
|
+
/**
|
|
1709
|
+
* GNU sed can execute shell commands: the `e` command replaces the pattern
|
|
1710
|
+
* space with a shell command's output and `s///e` runs the result. Neither form
|
|
1711
|
+
* names a file, so their write targets cannot be derived and the whole program
|
|
1712
|
+
* must be treated as an unclassified mutation.
|
|
1713
|
+
*/
|
|
1714
|
+
function sedScriptRunsShellCommand(script: string): boolean {
|
|
1715
|
+
// GNU sed accepts a numeric, `$`, or regular-expression address (`/re/e cmd`).
|
|
1716
|
+
if (/(?:^|[;{}\n])\s*(?:(?:[0-9$]+(?:,[0-9$]+)?|\/(?:\\[\s\S]|[^\\/\n])*\/)\s*!?\s*)?e(?=[\s;}\n]|$)/.test(script))
|
|
1717
|
+
return true;
|
|
1718
|
+
return /\bs(\W)(?:\\.|(?!\1)[^\n])*?\1(?:\\.|(?!\1)[^\n])*?\1[gpiImM0-9]*e(?![A-Za-z])/.test(script);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function sedScriptWritePath(script: string): string | null | undefined {
|
|
1722
|
+
if (sedScriptRunsShellCommand(script)) return null;
|
|
1723
|
+
let index = 0;
|
|
1724
|
+
let foundWrite = false;
|
|
1725
|
+
let foundPath: string | null | undefined;
|
|
1726
|
+
while (index < script.length) {
|
|
1727
|
+
const character = script[index] ?? "";
|
|
1728
|
+
if (" \t\n;{}".includes(character)) {
|
|
1729
|
+
index++;
|
|
1730
|
+
continue;
|
|
1731
|
+
}
|
|
1732
|
+
if (character === "/" && sedCommandPosition(script, index)) {
|
|
1733
|
+
const written = sedRegexAddressWritePath(script, index);
|
|
1734
|
+
if (written) {
|
|
1735
|
+
if (foundWrite) return null;
|
|
1736
|
+
foundWrite = true;
|
|
1737
|
+
foundPath = written.path;
|
|
1738
|
+
index = Math.max(written.next, index + 1);
|
|
1739
|
+
continue;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
if ((character === "w" || character === "W") && sedCommandPosition(script, index)) {
|
|
1743
|
+
const next = script[index + 1];
|
|
1744
|
+
if (next === undefined || " \t;\n}".includes(next)) {
|
|
1745
|
+
const taken = sedWriteName(script, index + 1);
|
|
1746
|
+
if (foundWrite) return null;
|
|
1747
|
+
foundWrite = true;
|
|
1748
|
+
foundPath = taken.path;
|
|
1749
|
+
index = Math.max(taken.next, index + 1);
|
|
1750
|
+
continue;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
if (character === "s" && index + 1 < script.length) {
|
|
1754
|
+
const delimiter = script[index + 1] ?? "";
|
|
1755
|
+
if (delimiter && !/[\s\\]/.test(delimiter)) {
|
|
1756
|
+
index += 2;
|
|
1757
|
+
let fields = 1;
|
|
1758
|
+
while (index < script.length && fields < 3) {
|
|
1759
|
+
if (script[index] === "\\") {
|
|
1760
|
+
index += 2;
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
if (script[index] === delimiter) fields++;
|
|
1764
|
+
index++;
|
|
1765
|
+
}
|
|
1766
|
+
while (index < script.length && script[index] !== ";" && script[index] !== "\n" && script[index] !== "}") {
|
|
1767
|
+
if (
|
|
1768
|
+
(script[index] === "w" || script[index] === "W") &&
|
|
1769
|
+
(script[index + 1] === " " || script[index + 1] === "\t")
|
|
1770
|
+
) {
|
|
1771
|
+
const taken = sedWriteName(script, index + 1);
|
|
1772
|
+
if (foundWrite) return null;
|
|
1773
|
+
foundWrite = true;
|
|
1774
|
+
foundPath = taken.path;
|
|
1775
|
+
index = Math.max(taken.next, index + 1);
|
|
1776
|
+
break;
|
|
1777
|
+
}
|
|
1778
|
+
index++;
|
|
1779
|
+
}
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
index++;
|
|
1784
|
+
}
|
|
1785
|
+
return foundWrite ? foundPath : undefined;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* `-c`/`-e` bodies are one shell word, so a `;` inside the quotes is not a
|
|
1790
|
+
* statement break. The line-oriented interpreter regex stops at that `;` and
|
|
1791
|
+
* misses `import shutil; shutil.copy(...)`.
|
|
1792
|
+
*/
|
|
1793
|
+
function interpreterStatementEnd(command: string, start: number): number {
|
|
1794
|
+
let index = start;
|
|
1795
|
+
let inSingle = false;
|
|
1796
|
+
let inDouble = false;
|
|
1797
|
+
while (index < command.length) {
|
|
1798
|
+
const character = command[index] ?? "";
|
|
1799
|
+
if (inSingle) {
|
|
1800
|
+
if (character === "'") inSingle = false;
|
|
1801
|
+
index++;
|
|
1802
|
+
continue;
|
|
1803
|
+
}
|
|
1804
|
+
if (inDouble) {
|
|
1805
|
+
if (character === "\\") {
|
|
1806
|
+
index += 2;
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1809
|
+
if (character === '"') inDouble = false;
|
|
1810
|
+
index++;
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1813
|
+
if (character === "'") {
|
|
1814
|
+
inSingle = true;
|
|
1815
|
+
index++;
|
|
1816
|
+
continue;
|
|
1817
|
+
}
|
|
1818
|
+
if (character === '"') {
|
|
1819
|
+
inDouble = true;
|
|
1820
|
+
index++;
|
|
1821
|
+
continue;
|
|
1822
|
+
}
|
|
1823
|
+
if (character === "\\") {
|
|
1824
|
+
index += 2;
|
|
1825
|
+
continue;
|
|
1826
|
+
}
|
|
1827
|
+
if (character === ";" || character === "&" || character === "|" || character === "\n") break;
|
|
1828
|
+
index++;
|
|
1829
|
+
}
|
|
1830
|
+
return index;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
function pythonSystemCallArguments(script: string): { starts: number[]; unclassifiable: boolean } {
|
|
1834
|
+
const starts: number[] = [];
|
|
1835
|
+
let quote: "'" | '"' | null = null;
|
|
1836
|
+
let tripleQuote = false;
|
|
1837
|
+
let inComment = false;
|
|
1838
|
+
let unclassifiable = false;
|
|
1839
|
+
for (let index = 0; index < script.length; index++) {
|
|
1840
|
+
const character = script[index] ?? "";
|
|
1841
|
+
if (inComment) {
|
|
1842
|
+
if (character === "\n") inComment = false;
|
|
1843
|
+
continue;
|
|
1844
|
+
}
|
|
1845
|
+
if (quote) {
|
|
1846
|
+
if (character === "\\") {
|
|
1847
|
+
index++;
|
|
1848
|
+
continue;
|
|
1849
|
+
}
|
|
1850
|
+
if (tripleQuote && script.startsWith(quote.repeat(3), index)) {
|
|
1851
|
+
quote = null;
|
|
1852
|
+
tripleQuote = false;
|
|
1853
|
+
index += 2;
|
|
1854
|
+
continue;
|
|
1855
|
+
}
|
|
1856
|
+
if (!tripleQuote && character === quote) quote = null;
|
|
1857
|
+
continue;
|
|
1858
|
+
}
|
|
1859
|
+
if (character === "#") {
|
|
1860
|
+
inComment = true;
|
|
1861
|
+
continue;
|
|
1862
|
+
}
|
|
1863
|
+
if (character === "'" || character === '"') {
|
|
1864
|
+
const isFString = /(?:^|[^\w])(?:[rR]?[fF]|[fF][rR])$/.test(script.slice(0, index));
|
|
1865
|
+
tripleQuote = script.startsWith(character.repeat(3), index);
|
|
1866
|
+
quote = character;
|
|
1867
|
+
if (tripleQuote) {
|
|
1868
|
+
const close = script.indexOf(character.repeat(3), index + 3);
|
|
1869
|
+
const body = close < 0 ? script.slice(index + 3) : script.slice(index + 3, close);
|
|
1870
|
+
if (isFString && /\bos\s*\.\s*system\s*\(/.test(body)) unclassifiable = true;
|
|
1871
|
+
index += 2;
|
|
1872
|
+
} else if (isFString) {
|
|
1873
|
+
let close = index + 1;
|
|
1874
|
+
while (close < script.length) {
|
|
1875
|
+
if (script[close] === "\\") {
|
|
1876
|
+
close += 2;
|
|
1877
|
+
continue;
|
|
1878
|
+
}
|
|
1879
|
+
if (script[close] === character) break;
|
|
1880
|
+
close++;
|
|
1881
|
+
}
|
|
1882
|
+
if (/\bos\s*\.\s*system\s*\(/.test(script.slice(index + 1, close))) unclassifiable = true;
|
|
1883
|
+
}
|
|
1884
|
+
continue;
|
|
1885
|
+
}
|
|
1886
|
+
const call = script.slice(index).match(/^\bos\s*\.\s*system\s*\(/);
|
|
1887
|
+
if (call) {
|
|
1888
|
+
starts.push(index + call[0].length);
|
|
1889
|
+
index += call[0].length - 1;
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
// `os.system` can reach the shell under another name: a captured callable
|
|
1893
|
+
// (`run = os.system`), a module alias (`import os as o`), a direct import alias
|
|
1894
|
+
// (`from os import system as run`), or any other `<obj>.system(...)`. Neither form
|
|
1895
|
+
// names its command here, so the shell payload is unclassifiable rather than invisible.
|
|
1896
|
+
const systemCalls = (script.match(/\.\s*system\s*\(/g) ?? []).length;
|
|
1897
|
+
const osSystemCalls = (script.match(/\bos\s*\.\s*system\s*\(/g) ?? []).length;
|
|
1898
|
+
if (systemCalls > osSystemCalls) unclassifiable = true;
|
|
1899
|
+
if (/\b[A-Za-z_]\w*\s*=\s*(?:os\s*\.\s*(?:system|popen)|subprocess\s*\.\s*\w+)\b/.test(script))
|
|
1900
|
+
unclassifiable = true;
|
|
1901
|
+
// Detect `from os import system as <name>` and then calls to `<name>(...)`
|
|
1902
|
+
const fromOsImport = /\bfrom\s+os\s+import\s+system\s+as\s+([A-Za-z_]\w*)\b/.exec(script);
|
|
1903
|
+
if (fromOsImport) {
|
|
1904
|
+
const importedName = fromOsImport[1];
|
|
1905
|
+
const importedCalls = new RegExp(`\\b${importedName}\\s*\\(`, "g");
|
|
1906
|
+
if (importedCalls.test(script)) {
|
|
1907
|
+
unclassifiable = true;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
return { starts, unclassifiable };
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
function pythonSystemCommand(script: string, start: number): string | undefined {
|
|
1914
|
+
let index = start;
|
|
1915
|
+
while (/\s/.test(script[index] ?? "")) index++;
|
|
1916
|
+
const quote = script[index];
|
|
1917
|
+
if (quote !== "'" && quote !== '"') return undefined;
|
|
1918
|
+
const tripleQuote = script.startsWith(quote.repeat(3), index);
|
|
1919
|
+
index += tripleQuote ? 3 : 1;
|
|
1920
|
+
let value = "";
|
|
1921
|
+
let hasEscape = false;
|
|
1922
|
+
while (index < script.length) {
|
|
1923
|
+
if (script[index] === "\\") {
|
|
1924
|
+
hasEscape = true;
|
|
1925
|
+
index += 2;
|
|
1926
|
+
continue;
|
|
1927
|
+
}
|
|
1928
|
+
if (tripleQuote && script.startsWith(quote.repeat(3), index)) {
|
|
1929
|
+
index += 3;
|
|
1930
|
+
while (/\s/.test(script[index] ?? "")) index++;
|
|
1931
|
+
return !hasEscape && script[index] === ")" ? value : undefined;
|
|
1932
|
+
}
|
|
1933
|
+
if (!tripleQuote && script[index] === quote) {
|
|
1934
|
+
index++;
|
|
1935
|
+
while (/\s/.test(script[index] ?? "")) index++;
|
|
1936
|
+
return !hasEscape && script[index] === ")" ? value : undefined;
|
|
1937
|
+
}
|
|
1938
|
+
value += script[index];
|
|
1939
|
+
index++;
|
|
1940
|
+
}
|
|
1941
|
+
return undefined;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
function collectPythonSystemWrites(script: string, targets: ExtractedTargets, depth: number): void {
|
|
1945
|
+
const calls = pythonSystemCallArguments(script);
|
|
1946
|
+
if (calls.unclassifiable) {
|
|
1947
|
+
targets.explicitMutation = true;
|
|
1948
|
+
targets.unknown = true;
|
|
1949
|
+
}
|
|
1950
|
+
for (const start of calls.starts) {
|
|
1951
|
+
const payload = pythonSystemCommand(script, start);
|
|
1952
|
+
if (payload === undefined || depth >= 2) {
|
|
1953
|
+
targets.explicitMutation = true;
|
|
1954
|
+
targets.unknown = true;
|
|
1955
|
+
continue;
|
|
1956
|
+
}
|
|
1957
|
+
const nested = extractBashTargets({ command: payload }, depth + 1);
|
|
1958
|
+
for (const nestedPath of nested.paths) addPath(targets, nestedPath);
|
|
1959
|
+
if (nested.unknown) {
|
|
1960
|
+
targets.explicitMutation = true;
|
|
1961
|
+
targets.unknown = true;
|
|
1962
|
+
}
|
|
1963
|
+
if (nested.explicitMutation) targets.explicitMutation = true;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
function cleanInterpreterCodeWord(word: string): string | undefined {
|
|
1968
|
+
if (!word.startsWith("$'")) {
|
|
1969
|
+
const fullyQuoted = (/^'[^']*'$/.test(word) || /^"(?:\\.|[^"\\])*"$/.test(word)) && word.length >= 2;
|
|
1970
|
+
if (word.includes("$'") && !fullyQuoted) return undefined;
|
|
1971
|
+
return cleanShellWord(word);
|
|
1972
|
+
}
|
|
1973
|
+
if (word.length < 3 || !word.endsWith("'")) return undefined;
|
|
1974
|
+
|
|
1975
|
+
let decoded = "";
|
|
1976
|
+
for (let index = 2; index < word.length - 1; index++) {
|
|
1977
|
+
const character = word[index] ?? "";
|
|
1978
|
+
if (character !== "\\") {
|
|
1979
|
+
if (character === "'") return undefined;
|
|
1980
|
+
decoded += character;
|
|
1981
|
+
continue;
|
|
1982
|
+
}
|
|
1983
|
+
const escapeCode = word[++index];
|
|
1984
|
+
if (escapeCode === undefined) return undefined;
|
|
1985
|
+
switch (escapeCode) {
|
|
1986
|
+
case "a":
|
|
1987
|
+
decoded += "\x07";
|
|
1988
|
+
break;
|
|
1989
|
+
case "b":
|
|
1990
|
+
decoded += "\b";
|
|
1991
|
+
break;
|
|
1992
|
+
case "e":
|
|
1993
|
+
case "E":
|
|
1994
|
+
decoded += "\x1b";
|
|
1995
|
+
break;
|
|
1996
|
+
case "f":
|
|
1997
|
+
decoded += "\f";
|
|
1998
|
+
break;
|
|
1999
|
+
case "n":
|
|
2000
|
+
decoded += "\n";
|
|
2001
|
+
break;
|
|
2002
|
+
case "r":
|
|
2003
|
+
decoded += "\r";
|
|
2004
|
+
break;
|
|
2005
|
+
case "t":
|
|
2006
|
+
decoded += "\t";
|
|
2007
|
+
break;
|
|
2008
|
+
case "v":
|
|
2009
|
+
decoded += "\v";
|
|
2010
|
+
break;
|
|
2011
|
+
case "\\":
|
|
2012
|
+
case "'":
|
|
2013
|
+
case '"':
|
|
2014
|
+
case "?":
|
|
2015
|
+
decoded += escapeCode;
|
|
2016
|
+
break;
|
|
2017
|
+
case "c": {
|
|
2018
|
+
const control = word[++index];
|
|
2019
|
+
if (control === undefined || control.charCodeAt(0) > 0x7f) return undefined;
|
|
2020
|
+
const code = control === "?" ? 0x7f : control.toUpperCase().charCodeAt(0) & 0x1f;
|
|
2021
|
+
if (code === 0) return undefined;
|
|
2022
|
+
decoded += String.fromCharCode(code);
|
|
2023
|
+
break;
|
|
2024
|
+
}
|
|
2025
|
+
case "x": {
|
|
2026
|
+
let digits = "";
|
|
2027
|
+
while (digits.length < 2 && /[\da-f]/i.test(word[index + 1] ?? "")) {
|
|
2028
|
+
digits += word[++index];
|
|
2029
|
+
}
|
|
2030
|
+
if (!digits) return undefined;
|
|
2031
|
+
const code = Number.parseInt(digits, 16);
|
|
2032
|
+
if (code === 0) return undefined;
|
|
2033
|
+
decoded += String.fromCharCode(code);
|
|
2034
|
+
break;
|
|
2035
|
+
}
|
|
2036
|
+
case "u":
|
|
2037
|
+
case "U": {
|
|
2038
|
+
const width = escapeCode === "u" ? 4 : 8;
|
|
2039
|
+
const digits = word.slice(index + 1, index + 1 + width);
|
|
2040
|
+
if (digits.length !== width || !new RegExp(`^[\\da-f]{${width}}$`, "i").test(digits)) return undefined;
|
|
2041
|
+
const code = Number.parseInt(digits, 16);
|
|
2042
|
+
if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return undefined;
|
|
2043
|
+
decoded += String.fromCodePoint(code);
|
|
2044
|
+
index += width;
|
|
2045
|
+
break;
|
|
2046
|
+
}
|
|
2047
|
+
default: {
|
|
2048
|
+
if (!/[0-7]/.test(escapeCode)) return undefined;
|
|
2049
|
+
let digits = escapeCode;
|
|
2050
|
+
while (digits.length < 3 && /[0-7]/.test(word[index + 1] ?? "")) {
|
|
2051
|
+
digits += word[++index];
|
|
2052
|
+
}
|
|
2053
|
+
const code = Number.parseInt(digits, 8);
|
|
2054
|
+
if (code === 0 || code > 0xff) return undefined;
|
|
2055
|
+
decoded += String.fromCharCode(code);
|
|
2056
|
+
break;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
return decoded;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
function collectInterpreterOneLinerWrites(command: string, targets: ExtractedTargets, depth: number): void {
|
|
2064
|
+
const api = new RegExp(BASH_INTERPRETER_WRITE_API, "i");
|
|
2065
|
+
const names = new RegExp(
|
|
2066
|
+
`(?:^|[;&|\\n])\\s*(?:(?:if|then|else|elif|do|in|while|until)\\s+)*(?:!\\s+)?(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(?:busybox\\s+)?(python(?:\\d+(?:\\.\\d+)*)?|pypy(?:\\d+(?:\\.\\d+)*)?|node(?:js)?|bun|deno|ruby|jruby|perl|php(?:\\d+(?:\\.\\d+)*)?|lua(?:\\d+(?:\\.\\d+)*)?|bash|ash|sh|zsh|dash|ksh|fish)\\b`,
|
|
2067
|
+
"gi",
|
|
2068
|
+
);
|
|
2069
|
+
for (const match of command.matchAll(names)) {
|
|
2070
|
+
const start = (match.index ?? 0) + match[0].length;
|
|
2071
|
+
const tail = command.slice(start, interpreterStatementEnd(command, start));
|
|
2072
|
+
const words = shellWords(tail);
|
|
2073
|
+
const parts = words.map(cleanShellWord);
|
|
2074
|
+
const binary = (match[1] ?? "").toLowerCase();
|
|
2075
|
+
const options = interpreterCodeOptions(binary);
|
|
2076
|
+
for (let index = 0; index < parts.length; index++) {
|
|
2077
|
+
const part = parts[index] ?? "";
|
|
2078
|
+
let rawScript: string | undefined;
|
|
2079
|
+
const option = options.find(
|
|
2080
|
+
candidate =>
|
|
2081
|
+
part === candidate || (candidate.length === 2 && part.startsWith(candidate) && part.length > 2),
|
|
2082
|
+
);
|
|
2083
|
+
if (option && part === option) {
|
|
2084
|
+
rawScript = words[++index];
|
|
2085
|
+
} else if (option) {
|
|
2086
|
+
const rawWord = words[index] ?? "";
|
|
2087
|
+
rawScript = rawWord.startsWith("-") ? rawWord.slice(option.length) : part.slice(option.length);
|
|
2088
|
+
} else {
|
|
2089
|
+
const longOption = options.find(
|
|
2090
|
+
candidate => candidate.startsWith("--") && part.startsWith(`${candidate}=`),
|
|
2091
|
+
);
|
|
2092
|
+
if (!longOption) continue;
|
|
2093
|
+
rawScript = words[index]?.slice(longOption.length + 1);
|
|
2094
|
+
}
|
|
2095
|
+
if (rawScript === undefined) continue;
|
|
2096
|
+
// Node preload options (-r/--require) load external files that cannot be scanned,
|
|
2097
|
+
// so fail closed when encountered.
|
|
2098
|
+
if ((option === "-r" || option === "--require") && /^(?:node|bun)$/.test(binary)) {
|
|
2099
|
+
targets.explicitMutation = true;
|
|
2100
|
+
targets.unknown = true;
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
const script = cleanInterpreterCodeWord(rawScript);
|
|
2104
|
+
if (
|
|
2105
|
+
script === undefined ||
|
|
2106
|
+
(!rawScript.startsWith("$'") && shellWordHasExpansion(rawScript)) ||
|
|
2107
|
+
containsOpaqueInterpreterSource(script, binary)
|
|
2108
|
+
) {
|
|
2109
|
+
targets.explicitMutation = true;
|
|
2110
|
+
targets.unknown = true;
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
if (/^(?:bash|ash|sh|zsh|dash|ksh|fish)$/.test(binary)) {
|
|
2114
|
+
if (depth >= 2) {
|
|
2115
|
+
targets.explicitMutation = true;
|
|
2116
|
+
targets.unknown = true;
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
const nested = extractBashTargets({ command: script }, depth + 1);
|
|
2120
|
+
for (const nestedPath of nested.paths) addPath(targets, nestedPath);
|
|
2121
|
+
if (nested.unknown) targets.unknown = true;
|
|
2122
|
+
if (nested.explicitMutation) targets.explicitMutation = true;
|
|
2123
|
+
continue;
|
|
2124
|
+
}
|
|
2125
|
+
const language = /^(?:python|pypy)/.test(binary)
|
|
2126
|
+
? "py"
|
|
2127
|
+
: /^(?:node|bun|deno)$/.test(binary)
|
|
2128
|
+
? "js"
|
|
2129
|
+
: /^(?:ruby|jruby)$/.test(binary)
|
|
2130
|
+
? "ruby"
|
|
2131
|
+
: "perl";
|
|
2132
|
+
if (/^(?:python|pypy)/.test(binary)) {
|
|
2133
|
+
scanInterpreterWrites(script, "py", targets);
|
|
2134
|
+
if (
|
|
2135
|
+
interpreterCodeContainsWriteApi(script, "py", new RegExp(BASH_INTERPRETER_WRITE_API_EXCLUDING_OPEN, "i"))
|
|
2136
|
+
) {
|
|
2137
|
+
targets.explicitMutation = true;
|
|
2138
|
+
targets.unknown = true;
|
|
2139
|
+
}
|
|
2140
|
+
collectPythonSystemWrites(script, targets, depth);
|
|
2141
|
+
continue;
|
|
2142
|
+
}
|
|
2143
|
+
if (interpreterCodeContainsWriteApi(script, language, api)) {
|
|
2144
|
+
targets.explicitMutation = true;
|
|
2145
|
+
targets.unknown = true;
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
function interpreterCodeOptions(binary: string): string[] {
|
|
2153
|
+
if (/^(?:python|pypy)/.test(binary)) return ["-c"];
|
|
2154
|
+
if (/^(?:node|bun)$/.test(binary)) return ["-r", "--require", "-e", "--eval", "-p", "--print"];
|
|
2155
|
+
if (binary === "deno") return ["eval", "-e", "--eval"];
|
|
2156
|
+
if (/^(?:ruby|jruby|perl|lua)/.test(binary)) return ["-e"];
|
|
2157
|
+
if (/^php/.test(binary)) return ["-r"];
|
|
2158
|
+
if (/^(?:bash|ash|sh|zsh|dash|ksh|fish)$/.test(binary)) return ["-c"];
|
|
2159
|
+
return [];
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
function containsOpaqueInterpreterSource(source: string, binary: string): boolean {
|
|
2163
|
+
return (
|
|
2164
|
+
(!/^(?:python|pypy)/.test(binary) && INTERPRETER_DYNAMIC_EXECUTION_RE.test(source)) ||
|
|
2165
|
+
INTERPRETER_FILESYSTEM_REQUIRE_RE.test(source) ||
|
|
2166
|
+
INTERPRETER_NONLITERAL_REQUIRE_RE.test(source) ||
|
|
2167
|
+
(/^php/.test(binary) && /\bassert\b/i.test(source)) ||
|
|
2168
|
+
(/^(?:bash|ash|sh|zsh|dash|ksh|fish)$/.test(binary) && SHELL_DOT_SOURCE_RE.test(source))
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
function interpreterCodeContainsWriteApi(
|
|
2173
|
+
script: string,
|
|
2174
|
+
language: "py" | "js" | "ruby" | "perl",
|
|
2175
|
+
api: RegExp,
|
|
2176
|
+
): boolean {
|
|
2177
|
+
const apiAtStart = new RegExp(`^(?:${api.source})`, "i");
|
|
2178
|
+
// Reflective mutation of a stream object defeats the stdout/stderr write
|
|
2179
|
+
// exemption: the method that runs may write to a file. Fail closed instead.
|
|
2180
|
+
const streamMutated =
|
|
2181
|
+
INTERPRETER_OUTPUT_METHOD_ASSIGNMENT_RE.test(script) || INTERPRETER_OUTPUT_STREAM_MUTATION_RE.test(script);
|
|
2182
|
+
let index = 0;
|
|
2183
|
+
while (index < script.length) {
|
|
2184
|
+
if (language === "py" || language === "js") {
|
|
2185
|
+
const next =
|
|
2186
|
+
language === "js" && script[index] === "`"
|
|
2187
|
+
? skipJavaScriptTemplate(script, index)
|
|
2188
|
+
: skipNoise(script, index, language);
|
|
2189
|
+
if (next !== index) {
|
|
2190
|
+
const skipped = script.slice(index, next);
|
|
2191
|
+
const isInterpolated =
|
|
2192
|
+
(language === "js" && script[index] === "`" && skipped.includes("${")) ||
|
|
2193
|
+
(language === "py" &&
|
|
2194
|
+
/(?:^|[^\w])(?:[rR]?[fF]|[fF][rR])$/.test(script.slice(Math.max(0, index - 3), index)) &&
|
|
2195
|
+
skipped.includes("{"));
|
|
2196
|
+
if (
|
|
2197
|
+
isInterpolated &&
|
|
2198
|
+
(api.test(skipped) ||
|
|
2199
|
+
(language === "py" && PYTHON_DYNAMIC_EXECUTION_TOKEN_RE.test(skipped)) ||
|
|
2200
|
+
(language === "js" && JAVASCRIPT_DYNAMIC_EXECUTION_TOKEN_RE.test(skipped)))
|
|
2201
|
+
)
|
|
2202
|
+
return true;
|
|
2203
|
+
index = next;
|
|
2204
|
+
continue;
|
|
2205
|
+
}
|
|
2206
|
+
} else {
|
|
2207
|
+
const next = skipOtherInterpreterNoise(script, index);
|
|
2208
|
+
if (next !== index) {
|
|
2209
|
+
const skipped = script.slice(index, next);
|
|
2210
|
+
if (language === "ruby" && script[index] !== "'" && skipped.includes("#{") && api.test(skipped))
|
|
2211
|
+
return true;
|
|
2212
|
+
index = next;
|
|
2213
|
+
continue;
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
if (INTERPRETER_OUTPUT_METHOD_ASSIGNMENT_RE.test(script.slice(index))) return true;
|
|
2217
|
+
if (language === "py" && PYTHON_DYNAMIC_EXECUTION_IDENTIFIER_RE.test(script.slice(index))) return true;
|
|
2218
|
+
if (language === "js" && JAVASCRIPT_DYNAMIC_EXECUTION_IDENTIFIER_RE.test(script.slice(index))) return true;
|
|
2219
|
+
if (language === "perl" && /^open\b/.test(script.slice(index))) {
|
|
2220
|
+
// Both `open(...)` and the list form `open H, MODE, EXPR` matter; only an
|
|
2221
|
+
// explicit read mode is safe, and an unparsed open fails closed.
|
|
2222
|
+
if (!isReadOnlyPerlOpen(script, index)) return true;
|
|
2223
|
+
index += "open".length;
|
|
2224
|
+
continue;
|
|
2225
|
+
}
|
|
2226
|
+
if (apiAtStart.test(script.slice(index))) {
|
|
2227
|
+
const outputWrite =
|
|
2228
|
+
!streamMutated &&
|
|
2229
|
+
/^\.\s*write\s*\(/.test(script.slice(index)) &&
|
|
2230
|
+
/\b(?:process|Bun)\s*\.\s*(?:stdout|stderr)\s*$/.test(script.slice(0, index));
|
|
2231
|
+
// Aliased mutation: if process.stdout is passed through a function or parameter,
|
|
2232
|
+
// it may be rebound. Fail closed when there are function definitions or calls
|
|
2233
|
+
// that could capture and mutate the stream object.
|
|
2234
|
+
const hasAliasVectors =
|
|
2235
|
+
outputWrite &&
|
|
2236
|
+
/[([]\s*[A-Za-z_]\w*\s*=>|\([^)]*(?:process|Bun)\s*\.\s*(?:stdout|stderr)[^)]*\)/.test(script);
|
|
2237
|
+
if (!outputWrite || hasAliasVectors) return true;
|
|
2238
|
+
}
|
|
2239
|
+
index += 1;
|
|
2240
|
+
}
|
|
2241
|
+
return false;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/**
|
|
2245
|
+
* Perl `open` accepts two spellings — `open(H, MODE, EXPR)` and the list form
|
|
2246
|
+
* `open H, MODE, EXPR` — and only an explicit read mode is safe. Any other shape
|
|
2247
|
+
* (write/pipe modes, a variable mode, or an unparsed statement) fails closed.
|
|
2248
|
+
*/
|
|
2249
|
+
function isReadOnlyPerlOpen(script: string, index: number): boolean {
|
|
2250
|
+
const rest = script.slice(index);
|
|
2251
|
+
if (!/^open\b/.test(rest)) return false;
|
|
2252
|
+
const parenthesized = /^open\s*\(/.exec(rest);
|
|
2253
|
+
const body = parenthesized ? rest.slice(parenthesized[0].length) : rest.slice("open".length);
|
|
2254
|
+
const statementEnd = body.search(/[;\n}]/);
|
|
2255
|
+
const statement = statementEnd < 0 ? body : body.slice(0, statementEnd);
|
|
2256
|
+
|
|
2257
|
+
// Parse arguments properly: count commas to find the MODE position (second argument)
|
|
2258
|
+
// and extract only that argument.
|
|
2259
|
+
let depth = 0;
|
|
2260
|
+
let inString = false;
|
|
2261
|
+
let stringChar = "";
|
|
2262
|
+
let commaCount = 0;
|
|
2263
|
+
let modeStart = -1;
|
|
2264
|
+
let modeEnd = -1;
|
|
2265
|
+
|
|
2266
|
+
for (let i = 0; i < statement.length; i++) {
|
|
2267
|
+
const ch = statement[i];
|
|
2268
|
+
|
|
2269
|
+
if (inString) {
|
|
2270
|
+
if (ch === "\\" && i + 1 < statement.length) {
|
|
2271
|
+
i++; // skip escape sequence
|
|
2272
|
+
continue;
|
|
2273
|
+
}
|
|
2274
|
+
if (ch === stringChar) {
|
|
2275
|
+
inString = false;
|
|
2276
|
+
}
|
|
2277
|
+
continue;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
if (ch === "'" || ch === '"') {
|
|
2281
|
+
inString = true;
|
|
2282
|
+
stringChar = ch;
|
|
2283
|
+
continue;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
if (ch === "(") depth++;
|
|
2287
|
+
else if (ch === ")") depth--;
|
|
2288
|
+
else if (ch === "," && depth === 0) {
|
|
2289
|
+
commaCount++;
|
|
2290
|
+
if (commaCount === 1) {
|
|
2291
|
+
// This comma separates first and second arguments
|
|
2292
|
+
modeStart = i + 1;
|
|
2293
|
+
}
|
|
2294
|
+
if (commaCount === 2) {
|
|
2295
|
+
// This comma ends the mode argument
|
|
2296
|
+
modeEnd = i;
|
|
2297
|
+
break;
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
if (modeStart === -1) return false; // Can't find MODE argument
|
|
2303
|
+
if (modeEnd === -1) modeEnd = statement.length;
|
|
2304
|
+
|
|
2305
|
+
const modeArg = statement.slice(modeStart, modeEnd).trim();
|
|
2306
|
+
|
|
2307
|
+
// Extract the mode value if it's a quoted string; if it's a variable, fail closed
|
|
2308
|
+
const quotedMode = /^(['"])(.*?)\1$/.exec(modeArg);
|
|
2309
|
+
if (!quotedMode) return false; // Variable mode, fail closed
|
|
2310
|
+
|
|
2311
|
+
const mode = quotedMode[2].trim();
|
|
2312
|
+
return mode.startsWith("<") && !mode.startsWith("<+");
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
function skipJavaScriptTemplate(script: string, index: number): number {
|
|
2316
|
+
let cursor = index + 1;
|
|
2317
|
+
while (cursor < script.length) {
|
|
2318
|
+
if (script[cursor] === "\\") {
|
|
2319
|
+
cursor += 2;
|
|
2320
|
+
continue;
|
|
2321
|
+
}
|
|
2322
|
+
if (script[cursor] === "`") return cursor + 1;
|
|
2323
|
+
cursor += 1;
|
|
2324
|
+
}
|
|
2325
|
+
return script.length;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
function skipOtherInterpreterNoise(script: string, index: number): number {
|
|
2329
|
+
const character = script[index] ?? "";
|
|
2330
|
+
if (character === "#") {
|
|
2331
|
+
const end = script.indexOf("\n", index);
|
|
2332
|
+
return end < 0 ? script.length : end;
|
|
2333
|
+
}
|
|
2334
|
+
if (character !== "'" && character !== '"' && character !== "`") return index;
|
|
2335
|
+
let cursor = index + 1;
|
|
2336
|
+
while (cursor < script.length) {
|
|
2337
|
+
if (script[cursor] === "\\") {
|
|
2338
|
+
cursor += 2;
|
|
2339
|
+
continue;
|
|
2340
|
+
}
|
|
2341
|
+
if (script[cursor] === character) return cursor + 1;
|
|
2342
|
+
cursor += 1;
|
|
2343
|
+
}
|
|
2344
|
+
return script.length;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
function collectSedScriptWrites(command: string, targets: ExtractedTargets): void {
|
|
2348
|
+
for (const match of command.matchAll(BASH_SED_COMMAND_RE)) {
|
|
2349
|
+
const matchIndex = match.index ?? 0;
|
|
2350
|
+
if (!shellSyntaxPosition(command, matchIndex)) continue;
|
|
2351
|
+
const words = shellWords(match[1] ?? "").map(cleanShellWord);
|
|
2352
|
+
const parsed = sedScriptOperands(words);
|
|
2353
|
+
if (parsed.unknown) {
|
|
2354
|
+
targets.explicitMutation = true;
|
|
2355
|
+
targets.unknown = true;
|
|
2356
|
+
}
|
|
2357
|
+
for (const script of parsed.scripts) {
|
|
2358
|
+
const writePath = sedScriptWritePath(script);
|
|
2359
|
+
if (writePath === undefined) continue;
|
|
2360
|
+
targets.explicitMutation = true;
|
|
2361
|
+
if (writePath && !isDeviceSinkPath(writePath)) addPath(targets, writePath);
|
|
2362
|
+
else if (!writePath) targets.unknown = true;
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
function sedScriptOperands(words: string[]): { scripts: string[]; unknown: boolean } {
|
|
2368
|
+
const scripts: string[] = [];
|
|
2369
|
+
let hasBareScript = false;
|
|
2370
|
+
let unknown = false;
|
|
2371
|
+
for (let index = 0; index < words.length; index++) {
|
|
2372
|
+
const word = words[index] ?? "";
|
|
2373
|
+
if (!word) continue;
|
|
2374
|
+
if (word === "--") {
|
|
2375
|
+
if (!hasBareScript && scripts.length === 0 && words[index + 1]) scripts.push(words[index + 1] ?? "");
|
|
2376
|
+
break;
|
|
2377
|
+
}
|
|
2378
|
+
if (word === "-e" || word === "--expression") {
|
|
2379
|
+
const script = words[index + 1];
|
|
2380
|
+
if (script) scripts.push(script);
|
|
2381
|
+
index += 1;
|
|
2382
|
+
continue;
|
|
2383
|
+
}
|
|
2384
|
+
if (word.startsWith("--expression=")) {
|
|
2385
|
+
scripts.push(word.slice("--expression=".length));
|
|
2386
|
+
continue;
|
|
2387
|
+
}
|
|
2388
|
+
if (word.startsWith("-e") && word.length > 2) {
|
|
2389
|
+
scripts.push(word.slice(2));
|
|
2390
|
+
continue;
|
|
2391
|
+
}
|
|
2392
|
+
if (word === "-f" || word === "--file") {
|
|
2393
|
+
// The script contents are not present in the command, so their write behavior is opaque.
|
|
2394
|
+
unknown = true;
|
|
2395
|
+
index += 1;
|
|
2396
|
+
continue;
|
|
2397
|
+
}
|
|
2398
|
+
if (word.startsWith("--file=") || (word.startsWith("-f") && word.length > 2)) {
|
|
2399
|
+
unknown = true;
|
|
2400
|
+
continue;
|
|
2401
|
+
}
|
|
2402
|
+
if (word === "-l" || word === "--line-length") {
|
|
2403
|
+
index += 1;
|
|
2404
|
+
continue;
|
|
2405
|
+
}
|
|
2406
|
+
if (word.startsWith("-")) continue;
|
|
2407
|
+
if (!hasBareScript && scripts.length === 0) {
|
|
2408
|
+
scripts.push(word);
|
|
2409
|
+
hasBareScript = true;
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
return { scripts, unknown };
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
function shellSyntaxPosition(command: string, targetIndex: number, allowComment = false): boolean {
|
|
2416
|
+
let quote: "'" | '"' | null = null;
|
|
2417
|
+
let inComment = false;
|
|
2418
|
+
let previous = "";
|
|
2419
|
+
for (let index = 0; index < targetIndex; index++) {
|
|
2420
|
+
const character = command[index] ?? "";
|
|
2421
|
+
if (inComment) {
|
|
2422
|
+
if (character === "\n") {
|
|
2423
|
+
inComment = false;
|
|
2424
|
+
previous = character;
|
|
2425
|
+
}
|
|
2426
|
+
continue;
|
|
2427
|
+
}
|
|
2428
|
+
if (quote === "'") {
|
|
2429
|
+
if (character === "'") quote = null;
|
|
2430
|
+
continue;
|
|
2431
|
+
}
|
|
2432
|
+
if (quote === '"') {
|
|
2433
|
+
if (character === "\\") {
|
|
2434
|
+
index++;
|
|
2435
|
+
continue;
|
|
2436
|
+
}
|
|
2437
|
+
if (character === '"') quote = null;
|
|
2438
|
+
continue;
|
|
2439
|
+
}
|
|
2440
|
+
if (character === "\\") {
|
|
2441
|
+
index++;
|
|
2442
|
+
continue;
|
|
2443
|
+
}
|
|
2444
|
+
if (character === "'" || character === '"') {
|
|
2445
|
+
quote = character;
|
|
2446
|
+
continue;
|
|
2447
|
+
}
|
|
2448
|
+
if (character === "#" && (previous === "" || /\s/.test(previous) || ";|&()".includes(previous))) {
|
|
2449
|
+
inComment = true;
|
|
2450
|
+
continue;
|
|
2451
|
+
}
|
|
2452
|
+
previous = character;
|
|
2453
|
+
}
|
|
2454
|
+
if (command[targetIndex] === "\n" && inComment) return quote === null;
|
|
2455
|
+
return quote === null && (allowComment || !inComment);
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
function awkRegexCanStart(tokens: AwkToken[]): boolean {
|
|
2459
|
+
const previous = tokens.at(-1);
|
|
2460
|
+
if (!previous) return true;
|
|
2461
|
+
if (previous.kind === "separator") return true;
|
|
2462
|
+
return (
|
|
2463
|
+
(previous.kind === "symbol" &&
|
|
2464
|
+
["{", "(", "[", ",", "?", ":", "=", "==", "!=", "!", "&&", "||", "~", "!~"].includes(previous.value)) ||
|
|
2465
|
+
(previous.kind === "word" && ["print", "printf", "return"].includes(previous.value))
|
|
2466
|
+
);
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
function awkRegexEnd(program: string, start: number): number | undefined {
|
|
2470
|
+
let inCharacterClass = false;
|
|
2471
|
+
for (let index = start + 1; index < program.length; index++) {
|
|
2472
|
+
const character = program[index] ?? "";
|
|
2473
|
+
if (character === "\\") {
|
|
2474
|
+
index++;
|
|
2475
|
+
continue;
|
|
2476
|
+
}
|
|
2477
|
+
if (character === "[" && !inCharacterClass) inCharacterClass = true;
|
|
2478
|
+
else if (character === "]" && inCharacterClass) inCharacterClass = false;
|
|
2479
|
+
else if (character === "/" && !inCharacterClass) return index + 1;
|
|
2480
|
+
else if (character === "\n") return undefined;
|
|
2481
|
+
}
|
|
2482
|
+
return undefined;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
interface AwkToken {
|
|
2486
|
+
kind: "word" | "string" | "symbol" | "separator";
|
|
2487
|
+
value: string;
|
|
2488
|
+
unsafeEscapes?: boolean;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
function tokenizeAwkProgram(program: string): { tokens: AwkToken[]; balanced: boolean } {
|
|
2492
|
+
const tokens: AwkToken[] = [];
|
|
2493
|
+
let balanced = true;
|
|
2494
|
+
for (let index = 0; index < program.length; ) {
|
|
2495
|
+
const character = program[index] ?? "";
|
|
2496
|
+
if (character === "\n") {
|
|
2497
|
+
tokens.push({ kind: "separator", value: character });
|
|
2498
|
+
index++;
|
|
2499
|
+
continue;
|
|
2500
|
+
}
|
|
2501
|
+
if (/\s/.test(character)) {
|
|
2502
|
+
index++;
|
|
2503
|
+
continue;
|
|
2504
|
+
}
|
|
2505
|
+
if (character === "#") {
|
|
2506
|
+
while (index < program.length && program[index] !== "\n") index++;
|
|
2507
|
+
continue;
|
|
2508
|
+
}
|
|
2509
|
+
if (character === '"') {
|
|
2510
|
+
let value = "";
|
|
2511
|
+
let unsafeEscapes = false;
|
|
2512
|
+
let closed = false;
|
|
2513
|
+
index++;
|
|
2514
|
+
while (index < program.length) {
|
|
2515
|
+
const next = program[index] ?? "";
|
|
2516
|
+
if (next === "\\") {
|
|
2517
|
+
const escapedChar = program[index + 1] ?? "";
|
|
2518
|
+
if (escapedChar === "\\" || escapedChar === '"') {
|
|
2519
|
+
value += escapedChar;
|
|
2520
|
+
index += 2;
|
|
2521
|
+
continue;
|
|
2522
|
+
}
|
|
2523
|
+
switch (escapedChar) {
|
|
2524
|
+
case "a":
|
|
2525
|
+
value += "\u0007";
|
|
2526
|
+
index += 2;
|
|
2527
|
+
continue;
|
|
2528
|
+
case "b":
|
|
2529
|
+
value += "\b";
|
|
2530
|
+
index += 2;
|
|
2531
|
+
continue;
|
|
2532
|
+
case "f":
|
|
2533
|
+
value += "\f";
|
|
2534
|
+
index += 2;
|
|
2535
|
+
continue;
|
|
2536
|
+
case "n":
|
|
2537
|
+
value += "\n";
|
|
2538
|
+
index += 2;
|
|
2539
|
+
continue;
|
|
2540
|
+
case "r":
|
|
2541
|
+
value += "\r";
|
|
2542
|
+
index += 2;
|
|
2543
|
+
continue;
|
|
2544
|
+
case "t":
|
|
2545
|
+
value += "\t";
|
|
2546
|
+
index += 2;
|
|
2547
|
+
continue;
|
|
2548
|
+
case "v":
|
|
2549
|
+
value += "\u000b";
|
|
2550
|
+
index += 2;
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
if (/[0-7]/.test(escapedChar)) {
|
|
2554
|
+
let end = index + 1;
|
|
2555
|
+
while (end < program.length && end < index + 4 && /[0-7]/.test(program[end] ?? "")) end++;
|
|
2556
|
+
value += String.fromCharCode(Number.parseInt(program.slice(index + 1, end), 8));
|
|
2557
|
+
index = end;
|
|
2558
|
+
continue;
|
|
2559
|
+
}
|
|
2560
|
+
if (escapedChar === "x") {
|
|
2561
|
+
let end = index + 2;
|
|
2562
|
+
while (end < program.length && /[\da-f]/i.test(program[end] ?? "")) end++;
|
|
2563
|
+
const hex = program.slice(index + 2, end);
|
|
2564
|
+
const codePoint = hex ? Number.parseInt(hex, 16) : Number.NaN;
|
|
2565
|
+
if (hex && codePoint <= 0xffff) value += String.fromCharCode(codePoint);
|
|
2566
|
+
else unsafeEscapes = true;
|
|
2567
|
+
index = end;
|
|
2568
|
+
continue;
|
|
2569
|
+
}
|
|
2570
|
+
unsafeEscapes = true;
|
|
2571
|
+
if (escapedChar) value += escapedChar;
|
|
2572
|
+
index += escapedChar ? 2 : 1;
|
|
2573
|
+
continue;
|
|
2574
|
+
}
|
|
2575
|
+
if (next === '"') {
|
|
2576
|
+
closed = true;
|
|
2577
|
+
index++;
|
|
2578
|
+
break;
|
|
2579
|
+
}
|
|
2580
|
+
value += next;
|
|
2581
|
+
index++;
|
|
2582
|
+
}
|
|
2583
|
+
if (!closed) balanced = false;
|
|
2584
|
+
tokens.push({ kind: "string", value, unsafeEscapes });
|
|
2585
|
+
continue;
|
|
2586
|
+
}
|
|
2587
|
+
if (character === "/" && awkRegexCanStart(tokens)) {
|
|
2588
|
+
const end = awkRegexEnd(program, index);
|
|
2589
|
+
if (end === undefined) balanced = false;
|
|
2590
|
+
else {
|
|
2591
|
+
tokens.push({ kind: "symbol", value: "regex" });
|
|
2592
|
+
index = end;
|
|
2593
|
+
continue;
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
if (/[A-Za-z_]/.test(character)) {
|
|
2597
|
+
const start = index++;
|
|
2598
|
+
while (/[A-Za-z0-9_]/.test(program[index] ?? "")) index++;
|
|
2599
|
+
tokens.push({ kind: "word", value: program.slice(start, index) });
|
|
2600
|
+
continue;
|
|
2601
|
+
}
|
|
2602
|
+
if (character === ";") {
|
|
2603
|
+
tokens.push({ kind: "separator", value: character });
|
|
2604
|
+
index++;
|
|
2605
|
+
continue;
|
|
2606
|
+
}
|
|
2607
|
+
const pair = program.slice(index, index + 2);
|
|
2608
|
+
const operator = [">>", ">=", "==", "!=", "<=", "&&", "||", "!~", "++", "--"].includes(pair) ? pair : character;
|
|
2609
|
+
tokens.push({ kind: "symbol", value: operator });
|
|
2610
|
+
index += operator.length;
|
|
2611
|
+
}
|
|
2612
|
+
return { tokens, balanced };
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
interface AwkProgramSource {
|
|
2616
|
+
source: string;
|
|
2617
|
+
expanded: boolean;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
function shellWordHasExpansion(word: string): boolean {
|
|
2621
|
+
let quote: "'" | '"' | null = null;
|
|
2622
|
+
for (let index = 0; index < word.length; index++) {
|
|
2623
|
+
const character = word[index] ?? "";
|
|
2624
|
+
if (quote === "'") {
|
|
2625
|
+
if (character === "'") quote = null;
|
|
2626
|
+
continue;
|
|
2627
|
+
}
|
|
2628
|
+
if (quote === '"') {
|
|
2629
|
+
if (character === "\\") {
|
|
2630
|
+
index++;
|
|
2631
|
+
continue;
|
|
2632
|
+
}
|
|
2633
|
+
if (character === '"') quote = null;
|
|
2634
|
+
else if (character === "$" || character === "`") return true;
|
|
2635
|
+
continue;
|
|
2636
|
+
}
|
|
2637
|
+
if (character === "\\") {
|
|
2638
|
+
index++;
|
|
2639
|
+
continue;
|
|
2640
|
+
}
|
|
2641
|
+
if (character === "'" || character === '"') quote = character;
|
|
2642
|
+
else if (character === "$" || character === "`") return true;
|
|
2643
|
+
}
|
|
2644
|
+
return quote !== null;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
function awkPrograms(words: string[]): { programs: AwkProgramSource[]; unknown: boolean } {
|
|
2648
|
+
const programs: AwkProgramSource[] = [];
|
|
2649
|
+
const noOperandOptions = new Set([
|
|
2650
|
+
"-b",
|
|
2651
|
+
"-c",
|
|
2652
|
+
"-C",
|
|
2653
|
+
"-g",
|
|
2654
|
+
"-h",
|
|
2655
|
+
"-M",
|
|
2656
|
+
"-n",
|
|
2657
|
+
"-N",
|
|
2658
|
+
"-O",
|
|
2659
|
+
"-P",
|
|
2660
|
+
"-r",
|
|
2661
|
+
"-s",
|
|
2662
|
+
"-S",
|
|
2663
|
+
"-t",
|
|
2664
|
+
"-V",
|
|
2665
|
+
"--characters-as-bytes",
|
|
2666
|
+
"--csv",
|
|
2667
|
+
"--help",
|
|
2668
|
+
"--lint",
|
|
2669
|
+
"--lint-no-ext",
|
|
2670
|
+
"--lint-old",
|
|
2671
|
+
"--posix",
|
|
2672
|
+
"--sandbox",
|
|
2673
|
+
"--traditional",
|
|
2674
|
+
"--version",
|
|
2675
|
+
]);
|
|
2676
|
+
let unknown = false;
|
|
2677
|
+
let hasFileProgram = false;
|
|
2678
|
+
let hasSourceOption = false;
|
|
2679
|
+
const programSource = (raw: string): AwkProgramSource => ({
|
|
2680
|
+
source: cleanShellWord(raw),
|
|
2681
|
+
expanded: shellWordHasExpansion(raw),
|
|
2682
|
+
});
|
|
2683
|
+
const operand = (index: number): string | undefined => {
|
|
2684
|
+
const next = words[index + 1];
|
|
2685
|
+
if (!next) {
|
|
2686
|
+
unknown = true;
|
|
2687
|
+
return undefined;
|
|
2688
|
+
}
|
|
2689
|
+
return next;
|
|
2690
|
+
};
|
|
2691
|
+
for (let index = 0; index < words.length; index++) {
|
|
2692
|
+
const word = words[index];
|
|
2693
|
+
if (!word) continue;
|
|
2694
|
+
if (/^(?:\d+)?(?:>>?|<<<?|&>)/.test(word)) {
|
|
2695
|
+
if (/^(?:\d+)?(?:>>?|<<<?)$/.test(word)) index++;
|
|
2696
|
+
continue;
|
|
2697
|
+
}
|
|
2698
|
+
const part = cleanShellWord(word);
|
|
2699
|
+
if (part === "--") {
|
|
2700
|
+
if (hasFileProgram || hasSourceOption) break;
|
|
2701
|
+
const program = words[index + 1];
|
|
2702
|
+
if (!program) unknown = true;
|
|
2703
|
+
else programs.push(programSource(program));
|
|
2704
|
+
break;
|
|
2705
|
+
}
|
|
2706
|
+
if (part === "-e" || part === "--source") {
|
|
2707
|
+
const program = operand(index);
|
|
2708
|
+
if (program) programs.push(programSource(program));
|
|
2709
|
+
hasSourceOption = true;
|
|
2710
|
+
index++;
|
|
2711
|
+
continue;
|
|
2712
|
+
}
|
|
2713
|
+
if (part.startsWith("--source=")) {
|
|
2714
|
+
programs.push(programSource(part.slice("--source=".length)));
|
|
2715
|
+
hasSourceOption = true;
|
|
2716
|
+
continue;
|
|
2717
|
+
}
|
|
2718
|
+
if (part === "-v" || part === "--assign" || part === "-F" || part === "--field-separator") {
|
|
2719
|
+
if (!operand(index)) continue;
|
|
2720
|
+
index++;
|
|
2721
|
+
continue;
|
|
2722
|
+
}
|
|
2723
|
+
if (part === "-W") {
|
|
2724
|
+
const extension = operand(index);
|
|
2725
|
+
if (extension !== "version" && extension !== "lint" && extension !== "posix") {
|
|
2726
|
+
unknown = true;
|
|
2727
|
+
break;
|
|
2728
|
+
}
|
|
2729
|
+
index++;
|
|
2730
|
+
continue;
|
|
2731
|
+
}
|
|
2732
|
+
if (part.startsWith("-W")) {
|
|
2733
|
+
const extension = part.slice(2);
|
|
2734
|
+
if (extension !== "version" && extension !== "lint" && extension !== "posix") {
|
|
2735
|
+
unknown = true;
|
|
2736
|
+
break;
|
|
2737
|
+
}
|
|
2738
|
+
continue;
|
|
2739
|
+
}
|
|
2740
|
+
if (part.startsWith("--assign=") || part.startsWith("--field-separator=")) continue;
|
|
2741
|
+
if (part === "-f" || part === "--file" || part === "-E") {
|
|
2742
|
+
if (!operand(index)) continue;
|
|
2743
|
+
hasFileProgram = true;
|
|
2744
|
+
index++;
|
|
2745
|
+
continue;
|
|
2746
|
+
}
|
|
2747
|
+
if (part.startsWith("--file=") || (part.startsWith("-f") && part.length > 2)) {
|
|
2748
|
+
hasFileProgram = true;
|
|
2749
|
+
continue;
|
|
2750
|
+
}
|
|
2751
|
+
if (part.startsWith("-e") && part.length > 2) {
|
|
2752
|
+
programs.push(programSource(part.slice(2)));
|
|
2753
|
+
hasSourceOption = true;
|
|
2754
|
+
continue;
|
|
2755
|
+
}
|
|
2756
|
+
if (part.startsWith("-v") && part.length > 2) continue;
|
|
2757
|
+
if (part.startsWith("-F") && part.length > 2) continue;
|
|
2758
|
+
if (part.startsWith("-") && noOperandOptions.has(part)) continue;
|
|
2759
|
+
if (part.startsWith("-") && /^-[bcCghMnNPrStV]+$/.test(part)) continue;
|
|
2760
|
+
if (part.startsWith("-")) {
|
|
2761
|
+
unknown = true;
|
|
2762
|
+
break;
|
|
2763
|
+
}
|
|
2764
|
+
if (hasFileProgram || hasSourceOption) break;
|
|
2765
|
+
programs.push(programSource(word));
|
|
2766
|
+
break;
|
|
2767
|
+
}
|
|
2768
|
+
if (hasFileProgram) unknown = true;
|
|
2769
|
+
return { programs, unknown };
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
function analyzeAwkProgram(program: string): { paths: string[]; unknown: boolean } {
|
|
2773
|
+
const { tokens, balanced } = tokenizeAwkProgram(program);
|
|
2774
|
+
const paths: string[] = [];
|
|
2775
|
+
let unknown =
|
|
2776
|
+
!balanced ||
|
|
2777
|
+
tokens.some(
|
|
2778
|
+
(token, index) => token.kind === "word" && token.value === "system" && tokens[index + 1]?.value === "(",
|
|
2779
|
+
);
|
|
2780
|
+
let braceDepth = 0;
|
|
2781
|
+
let parenDepth = 0;
|
|
2782
|
+
let bracketDepth = 0;
|
|
2783
|
+
let statementStart = true;
|
|
2784
|
+
let pendingControlParen = false;
|
|
2785
|
+
const controlParens: boolean[] = [];
|
|
2786
|
+
let printStatement: { braceDepth: number; parenDepth: number; bracketDepth: number } | undefined;
|
|
2787
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
2788
|
+
const token = tokens[index];
|
|
2789
|
+
if (!token) continue;
|
|
2790
|
+
if (token.kind === "separator") {
|
|
2791
|
+
if (parenDepth === 0 && bracketDepth === 0) {
|
|
2792
|
+
printStatement = undefined;
|
|
2793
|
+
statementStart = true;
|
|
2794
|
+
pendingControlParen = false;
|
|
2795
|
+
}
|
|
2796
|
+
continue;
|
|
2797
|
+
}
|
|
2798
|
+
if (token.kind === "string" || token.kind === "word") {
|
|
2799
|
+
if (token.kind === "word" && statementStart && (token.value === "print" || token.value === "printf")) {
|
|
2800
|
+
printStatement = { braceDepth, parenDepth, bracketDepth };
|
|
2801
|
+
statementStart = false;
|
|
2802
|
+
pendingControlParen = false;
|
|
2803
|
+
continue;
|
|
2804
|
+
}
|
|
2805
|
+
if (token.kind === "word" && statementStart && ["for", "if", "while"].includes(token.value)) {
|
|
2806
|
+
pendingControlParen = true;
|
|
2807
|
+
statementStart = false;
|
|
2808
|
+
continue;
|
|
2809
|
+
}
|
|
2810
|
+
if (token.kind === "word" && statementStart && ["do", "else"].includes(token.value)) {
|
|
2811
|
+
statementStart = true;
|
|
2812
|
+
pendingControlParen = false;
|
|
2813
|
+
continue;
|
|
2814
|
+
}
|
|
2815
|
+
statementStart = false;
|
|
2816
|
+
pendingControlParen = false;
|
|
2817
|
+
continue;
|
|
2818
|
+
}
|
|
2819
|
+
if (token.value === "{") {
|
|
2820
|
+
braceDepth++;
|
|
2821
|
+
printStatement = undefined;
|
|
2822
|
+
statementStart = true;
|
|
2823
|
+
pendingControlParen = false;
|
|
2824
|
+
continue;
|
|
2825
|
+
}
|
|
2826
|
+
if (token.value === "}") {
|
|
2827
|
+
braceDepth--;
|
|
2828
|
+
if (braceDepth < 0) unknown = true;
|
|
2829
|
+
printStatement = undefined;
|
|
2830
|
+
statementStart = true;
|
|
2831
|
+
pendingControlParen = false;
|
|
2832
|
+
continue;
|
|
2833
|
+
}
|
|
2834
|
+
if (token.value === "(") {
|
|
2835
|
+
controlParens.push(pendingControlParen);
|
|
2836
|
+
pendingControlParen = false;
|
|
2837
|
+
parenDepth++;
|
|
2838
|
+
statementStart = false;
|
|
2839
|
+
continue;
|
|
2840
|
+
}
|
|
2841
|
+
if (token.value === ")") {
|
|
2842
|
+
parenDepth--;
|
|
2843
|
+
if (parenDepth < 0) unknown = true;
|
|
2844
|
+
statementStart = controlParens.pop() ?? false;
|
|
2845
|
+
continue;
|
|
2846
|
+
}
|
|
2847
|
+
if (token.value === "[") {
|
|
2848
|
+
bracketDepth++;
|
|
2849
|
+
statementStart = false;
|
|
2850
|
+
continue;
|
|
2851
|
+
}
|
|
2852
|
+
if (token.value === "]") {
|
|
2853
|
+
bracketDepth--;
|
|
2854
|
+
if (bracketDepth < 0) unknown = true;
|
|
2855
|
+
statementStart = false;
|
|
2856
|
+
continue;
|
|
2857
|
+
}
|
|
2858
|
+
if (token.value === ">" || token.value === ">>") {
|
|
2859
|
+
if (
|
|
2860
|
+
printStatement &&
|
|
2861
|
+
braceDepth === printStatement.braceDepth &&
|
|
2862
|
+
parenDepth === printStatement.parenDepth &&
|
|
2863
|
+
bracketDepth === printStatement.bracketDepth
|
|
2864
|
+
) {
|
|
2865
|
+
const destination = tokens[index + 1];
|
|
2866
|
+
const trailing = tokens[index + 2];
|
|
2867
|
+
if (
|
|
2868
|
+
destination?.kind !== "string" ||
|
|
2869
|
+
destination.unsafeEscapes ||
|
|
2870
|
+
(destination.value !== "" && trailing && trailing.kind !== "separator" && trailing.value !== "}")
|
|
2871
|
+
) {
|
|
2872
|
+
unknown = true;
|
|
2873
|
+
} else if (destination.value) {
|
|
2874
|
+
paths.push(destination.value);
|
|
2875
|
+
} else {
|
|
2876
|
+
unknown = true;
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
statementStart = false;
|
|
2880
|
+
continue;
|
|
2881
|
+
}
|
|
2882
|
+
if (token.value === "|") {
|
|
2883
|
+
// AWK pipe I/O executes a command; print, printf, and getline effects are opaque.
|
|
2884
|
+
unknown = true;
|
|
2885
|
+
statementStart = false;
|
|
2886
|
+
continue;
|
|
2887
|
+
}
|
|
2888
|
+
if (token.value === ";" && parenDepth === 0 && bracketDepth === 0) {
|
|
2889
|
+
printStatement = undefined;
|
|
2890
|
+
statementStart = true;
|
|
2891
|
+
pendingControlParen = false;
|
|
2892
|
+
continue;
|
|
2893
|
+
}
|
|
2894
|
+
statementStart = false;
|
|
2895
|
+
pendingControlParen = false;
|
|
2896
|
+
}
|
|
2897
|
+
if (braceDepth !== 0 || parenDepth !== 0 || bracketDepth !== 0 || controlParens.length > 0) unknown = true;
|
|
2898
|
+
return { paths, unknown };
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
function collectAwkProgramWrites(command: string, targets: ExtractedTargets): void {
|
|
2902
|
+
for (const match of command.matchAll(BASH_AWK_COMMAND_RE)) {
|
|
2903
|
+
const matchIndex = match.index ?? 0;
|
|
2904
|
+
if (!shellSyntaxPosition(command, matchIndex)) continue;
|
|
2905
|
+
const matchEnd = matchIndex + match[0].length;
|
|
2906
|
+
const { programs, unknown } = awkPrograms(shellWords(match[1] ?? ""));
|
|
2907
|
+
if (!shellSyntaxPosition(command, matchEnd, true) || unknown) {
|
|
2908
|
+
targets.explicitMutation = true;
|
|
2909
|
+
targets.unknown = true;
|
|
2910
|
+
}
|
|
2911
|
+
for (const program of programs) {
|
|
2912
|
+
if (program.expanded) {
|
|
2913
|
+
targets.explicitMutation = true;
|
|
2914
|
+
targets.unknown = true;
|
|
2915
|
+
}
|
|
2916
|
+
const analyzed = analyzeAwkProgram(program.source);
|
|
2917
|
+
if (analyzed.unknown) {
|
|
2918
|
+
targets.explicitMutation = true;
|
|
2919
|
+
targets.unknown = true;
|
|
2920
|
+
}
|
|
2921
|
+
for (const destination of analyzed.paths) {
|
|
2922
|
+
targets.explicitMutation = true;
|
|
2923
|
+
if (!isDeviceSinkPath(destination)) addPath(targets, destination);
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
|
|
1567
2929
|
function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
|
|
1568
2930
|
const record = getRecord(args);
|
|
1569
2931
|
const command = safeString(record?.command);
|
|
@@ -1622,10 +2984,7 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
|
|
|
1622
2984
|
targets.explicitMutation = true;
|
|
1623
2985
|
targets.unknown = true;
|
|
1624
2986
|
}
|
|
1625
|
-
if (
|
|
1626
|
-
BASH_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked) ||
|
|
1627
|
-
BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked)
|
|
1628
|
-
) {
|
|
2987
|
+
if (BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked)) {
|
|
1629
2988
|
targets.explicitMutation = true;
|
|
1630
2989
|
targets.unknown = true;
|
|
1631
2990
|
}
|
|
@@ -1707,6 +3066,11 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
|
|
|
1707
3066
|
// fail closed rather than allowing a targetless mutation through planning.
|
|
1708
3067
|
if (!extractedTarget) targets.unknown = true;
|
|
1709
3068
|
}
|
|
3069
|
+
// `sed w file` and `s///w file` write without `-i`. `awk 'print > "file"'`
|
|
3070
|
+
// writes inside the program, which is not a shell redirection.
|
|
3071
|
+
collectInterpreterOneLinerWrites(heredoc.masked, targets, depth);
|
|
3072
|
+
collectSedScriptWrites(heredoc.masked, targets);
|
|
3073
|
+
collectAwkProgramWrites(heredoc.masked, targets);
|
|
1710
3074
|
return targets;
|
|
1711
3075
|
}
|
|
1712
3076
|
/**
|
|
@@ -1727,6 +3091,12 @@ function simpleLiteral(raw: string): string | null {
|
|
|
1727
3091
|
return /[\\\n${]/.test(body) || body.includes(quote) ? null : body;
|
|
1728
3092
|
}
|
|
1729
3093
|
|
|
3094
|
+
function pythonFStringContainsDynamicExecution(code: string, start: number, end: number): boolean {
|
|
3095
|
+
const source = code.slice(start, end);
|
|
3096
|
+
const isFString = /(?:^|[^\w])(?:[rR]?[fF]|[fF][rR])$/.test(code.slice(Math.max(0, start - 3), start));
|
|
3097
|
+
return isFString && source.includes("{") && PYTHON_DYNAMIC_EXECUTION_TOKEN_RE.test(source);
|
|
3098
|
+
}
|
|
3099
|
+
|
|
1730
3100
|
function skipNoise(code: string, index: number, language: "py" | "js"): number {
|
|
1731
3101
|
if (language === "py" && (code.startsWith('"""', index) || code.startsWith("'''", index))) {
|
|
1732
3102
|
const quote = code.slice(index, index + 3);
|
|
@@ -1782,9 +3152,15 @@ function modeWrites(raw: string | undefined): boolean | null {
|
|
|
1782
3152
|
return literal === null ? null : /[wax+]/.test(literal);
|
|
1783
3153
|
}
|
|
1784
3154
|
|
|
1785
|
-
const PY_OS_MUTATORS = new Set(
|
|
3155
|
+
const PY_OS_MUTATORS = new Set(
|
|
3156
|
+
"remove unlink rename replace rmtree move copy copy2 copyfile symlink link mkdir makedirs rmdir truncate open".split(
|
|
3157
|
+
" ",
|
|
3158
|
+
),
|
|
3159
|
+
);
|
|
1786
3160
|
/** Copy and rename write the destination, not only the source. */
|
|
1787
|
-
const TWO_ARG_MUTATORS = new Set(
|
|
3161
|
+
const TWO_ARG_MUTATORS = new Set(
|
|
3162
|
+
"rename replace move copy copy2 copyfile symlink link renameSync copyFileSync".split(" "),
|
|
3163
|
+
);
|
|
1788
3164
|
const JS_FS_MUTATORS = new Set(
|
|
1789
3165
|
"writeFileSync writeFile appendFileSync appendFile rmSync unlinkSync renameSync copyFileSync mkdirSync openSync".split(
|
|
1790
3166
|
" ",
|
|
@@ -1896,15 +3272,71 @@ function literalPathReceiver(parts: string[]): string | null {
|
|
|
1896
3272
|
return joined.length === 0 ? "." : joined;
|
|
1897
3273
|
}
|
|
1898
3274
|
|
|
3275
|
+
/** `Path.open()` defaults to read mode; a dynamic mode cannot be proven read-only. */
|
|
3276
|
+
function pathlibOpenModeWrites(code: string, methodAt: number): boolean | null {
|
|
3277
|
+
const call = /^\s*\.\s*open\s*\(/.exec(code.slice(methodAt));
|
|
3278
|
+
if (!call) return false;
|
|
3279
|
+
let cursor = methodAt + call[0].length;
|
|
3280
|
+
let mode: string | undefined;
|
|
3281
|
+
let modeSeen = false;
|
|
3282
|
+
let unknownMode = false;
|
|
3283
|
+
let positionalIndex = 0;
|
|
3284
|
+
while (cursor < code.length) {
|
|
3285
|
+
while (/\s/.test(code[cursor] ?? "")) cursor += 1;
|
|
3286
|
+
if (code[cursor] === ")") break;
|
|
3287
|
+
const argument = readArg(code, cursor);
|
|
3288
|
+
if (!argument) return null;
|
|
3289
|
+
const raw = argument.raw.trim();
|
|
3290
|
+
if (raw.startsWith("*") || raw.startsWith("**")) {
|
|
3291
|
+
unknownMode = true;
|
|
3292
|
+
} else {
|
|
3293
|
+
const keyword = /^([A-Za-z_]\w*)\s*=\s*([\s\S]*)$/.exec(raw);
|
|
3294
|
+
if (keyword?.[1] === "mode") {
|
|
3295
|
+
if (modeSeen) unknownMode = true;
|
|
3296
|
+
else {
|
|
3297
|
+
mode = keyword[2];
|
|
3298
|
+
modeSeen = true;
|
|
3299
|
+
}
|
|
3300
|
+
} else if (!keyword && positionalIndex === 0) {
|
|
3301
|
+
if (modeSeen) unknownMode = true;
|
|
3302
|
+
else {
|
|
3303
|
+
mode = raw;
|
|
3304
|
+
modeSeen = true;
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
positionalIndex += 1;
|
|
3309
|
+
cursor = argument.end;
|
|
3310
|
+
while (/\s/.test(code[cursor] ?? "")) cursor += 1;
|
|
3311
|
+
if (code[cursor] === ",") cursor += 1;
|
|
3312
|
+
else if (code[cursor] !== ")") return null;
|
|
3313
|
+
}
|
|
3314
|
+
return unknownMode ? null : modeWrites(mode);
|
|
3315
|
+
}
|
|
3316
|
+
|
|
1899
3317
|
/** Calls inside strings or comments are skipped. A non-literal write path fails closed. */
|
|
1900
3318
|
function scanInterpreterWrites(code: string, language: "py" | "js", targets: ExtractedTargets): void {
|
|
1901
3319
|
let index = 0;
|
|
3320
|
+
const recognizedPathMethodIndexes = new Set<number>();
|
|
1902
3321
|
while (index < code.length) {
|
|
1903
3322
|
const trivia = skipNoise(code, index, language);
|
|
1904
3323
|
if (trivia !== index) {
|
|
3324
|
+
if (language === "py" && pythonFStringContainsDynamicExecution(code, index, trivia)) {
|
|
3325
|
+
targets.explicitMutation = true;
|
|
3326
|
+
targets.unknown = true;
|
|
3327
|
+
return;
|
|
3328
|
+
}
|
|
1905
3329
|
index = trivia;
|
|
1906
3330
|
continue;
|
|
1907
3331
|
}
|
|
3332
|
+
if (
|
|
3333
|
+
INTERPRETER_OUTPUT_METHOD_ASSIGNMENT_RE.test(code.slice(index)) ||
|
|
3334
|
+
INTERPRETER_OUTPUT_STREAM_MUTATION_RE.test(code.slice(index))
|
|
3335
|
+
) {
|
|
3336
|
+
targets.explicitMutation = true;
|
|
3337
|
+
targets.unknown = true;
|
|
3338
|
+
return;
|
|
3339
|
+
}
|
|
1908
3340
|
const previous = index === 0 ? "" : (code[index - 1] ?? "");
|
|
1909
3341
|
if ((index > 0 && /[\w]/.test(previous)) || !/[A-Za-z_]/.test(code[index] ?? "")) {
|
|
1910
3342
|
index += 1;
|
|
@@ -1916,6 +3348,29 @@ function scanInterpreterWrites(code: string, language: "py" | "js", targets: Ext
|
|
|
1916
3348
|
continue;
|
|
1917
3349
|
}
|
|
1918
3350
|
const name = ident[0];
|
|
3351
|
+
if (language === "py" && PYTHON_DYNAMIC_EXECUTION_IDENTIFIER_RE.test(name)) {
|
|
3352
|
+
targets.explicitMutation = true;
|
|
3353
|
+
targets.unknown = true;
|
|
3354
|
+
return;
|
|
3355
|
+
}
|
|
3356
|
+
let receiverDot = index - 1;
|
|
3357
|
+
while (receiverDot >= 0 && /\s/.test(code[receiverDot] ?? "")) receiverDot -= 1;
|
|
3358
|
+
const unresolvedPathMethod =
|
|
3359
|
+
language === "py" &&
|
|
3360
|
+
code[receiverDot] === "." &&
|
|
3361
|
+
(/^write_\w+$/.test(name) || name === "touch" || name === "open");
|
|
3362
|
+
if (unresolvedPathMethod && !recognizedPathMethodIndexes.has(index)) {
|
|
3363
|
+
const callAt = skipCallGap(code, index + name.length);
|
|
3364
|
+
if (code[callAt] === "(") {
|
|
3365
|
+
const writes = name === "open" ? pathlibOpenModeWrites(code, receiverDot) : true;
|
|
3366
|
+
if (writes !== false) {
|
|
3367
|
+
targets.explicitMutation = true;
|
|
3368
|
+
targets.unknown = true;
|
|
3369
|
+
}
|
|
3370
|
+
index = Math.max(callAt + 1, index + name.length);
|
|
3371
|
+
continue;
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
1919
3374
|
if ((name === "write" || name === "append") && previous === ".") {
|
|
1920
3375
|
index += name.length;
|
|
1921
3376
|
continue;
|
|
@@ -1931,7 +3386,8 @@ function scanInterpreterWrites(code: string, language: "py" | "js", targets: Ext
|
|
|
1931
3386
|
const paren = /^\s*\(/.exec(code.slice(cursor));
|
|
1932
3387
|
const helper = (name === "write" || name === "append") && dotted === "";
|
|
1933
3388
|
const open = (name === "open" && language === "py") || (name === "openSync" && language === "js");
|
|
1934
|
-
const pathCtor =
|
|
3389
|
+
const pathCtor =
|
|
3390
|
+
(name === "Path" && dotted === "") || (language === "py" && name === "pathlib" && dotted === "Path");
|
|
1935
3391
|
const osCall =
|
|
1936
3392
|
language === "py" &&
|
|
1937
3393
|
((name === "os" && PY_OS_MUTATORS.has(dotted)) || (name === "shutil" && PY_OS_MUTATORS.has(dotted)));
|
|
@@ -1949,12 +3405,18 @@ function scanInterpreterWrites(code: string, language: "py" | "js", targets: Ext
|
|
|
1949
3405
|
index += 1;
|
|
1950
3406
|
continue;
|
|
1951
3407
|
}
|
|
1952
|
-
const method = /^\s*\.\s*(
|
|
3408
|
+
const method = /^\s*\.\s*(write_\w+|open|touch)\s*\(/.exec(code.slice(ctor.after));
|
|
1953
3409
|
if (method) {
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
3410
|
+
if (ctor.parts.length > 1) {
|
|
3411
|
+
recognizedPathMethodIndexes.add(ctor.after + method[0].indexOf(method[1]));
|
|
3412
|
+
}
|
|
3413
|
+
const writes = method[1] === "open" ? pathlibOpenModeWrites(code, ctor.after) : true;
|
|
3414
|
+
if (writes !== false) {
|
|
3415
|
+
targets.explicitMutation = true;
|
|
3416
|
+
const receiver = literalPathReceiver(ctor.parts);
|
|
3417
|
+
if (writes === null || receiver === null || receiver.length === 0) targets.unknown = true;
|
|
3418
|
+
else addPath(targets, receiver);
|
|
3419
|
+
}
|
|
1958
3420
|
}
|
|
1959
3421
|
// Extra segments stay in the argument walk so a nested mutator is still recorded.
|
|
1960
3422
|
index =
|
|
@@ -2295,6 +3757,19 @@ async function isNeutralTempPath(cwd: string, rawPath: string): Promise<boolean>
|
|
|
2295
3757
|
return realRoots.some(root => isPathWithin(root, realTarget));
|
|
2296
3758
|
}
|
|
2297
3759
|
|
|
3760
|
+
/** realpath cannot expose another name for the same inode outside a writable root. */
|
|
3761
|
+
async function hasUnsharedFileTarget(cwd: string, rawPath: string): Promise<boolean> {
|
|
3762
|
+
const { absolutePath, unknown } = resolveRawPath(cwd, rawPath);
|
|
3763
|
+
if (unknown || !absolutePath) return false;
|
|
3764
|
+
try {
|
|
3765
|
+
const target = await fs.stat(absolutePath);
|
|
3766
|
+
return !target.isFile() || target.nlink === 1;
|
|
3767
|
+
} catch (error) {
|
|
3768
|
+
// New scratch files remain writable; inaccessible or unresolvable targets fail closed.
|
|
3769
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
|
|
2298
3773
|
/** Targets that remain disallowed during a planning phase (excludes neutral temp scratch). */
|
|
2299
3774
|
async function planningBlockedTargets(
|
|
2300
3775
|
cwd: string,
|
|
@@ -2303,6 +3778,10 @@ async function planningBlockedTargets(
|
|
|
2303
3778
|
): Promise<string[]> {
|
|
2304
3779
|
const blocked: string[] = [];
|
|
2305
3780
|
for (const rawPath of targets.paths) {
|
|
3781
|
+
if (!(await hasUnsharedFileTarget(cwd, rawPath))) {
|
|
3782
|
+
blocked.push(rawPath);
|
|
3783
|
+
continue;
|
|
3784
|
+
}
|
|
2306
3785
|
if (isAllowlistedPath(cwd, rawPath, sessionId)) continue;
|
|
2307
3786
|
if (!(await isNeutralTempPath(cwd, rawPath))) blocked.push(rawPath);
|
|
2308
3787
|
}
|
|
@@ -2384,7 +3863,8 @@ export async function getWorkflowMutationDecision(
|
|
|
2384
3863
|
AUTORESEARCH_HARNESS_FILE_TOOL_NAMES.has(input.tool.name) &&
|
|
2385
3864
|
targets.paths.length > 0 &&
|
|
2386
3865
|
!targets.unknown &&
|
|
2387
|
-
targets.paths.every(p => isAutoresearchAuthorizedResearchPath(input.cwd, p))
|
|
3866
|
+
targets.paths.every(p => isAutoresearchAuthorizedResearchPath(input.cwd, p)) &&
|
|
3867
|
+
(await Promise.all(targets.paths.map(p => hasUnsharedFileTarget(input.cwd, p)))).every(Boolean)
|
|
2388
3868
|
) {
|
|
2389
3869
|
return { blocked: false, targets: targets.paths };
|
|
2390
3870
|
}
|