@gajae-code/coding-agent 0.16.7 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +180 -1
- package/dist/types/capability/fs.d.ts +37 -4
- package/dist/types/capability/index.d.ts +19 -0
- package/dist/types/capability/types.d.ts +9 -0
- package/dist/types/cli/doctor/args.d.ts +36 -0
- package/dist/types/cli/doctor/checks.d.ts +18 -0
- package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
- package/dist/types/cli/doctor/context.d.ts +89 -0
- package/dist/types/cli/doctor/files.d.ts +27 -0
- package/dist/types/cli/doctor/ids.d.ts +20 -0
- package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
- package/dist/types/cli/doctor/journal.d.ts +43 -0
- package/dist/types/cli/doctor/managed-link.d.ts +39 -0
- package/dist/types/cli/doctor/native-probe.d.ts +2 -0
- package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
- package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
- package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
- package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
- package/dist/types/cli/doctor/probe-client.d.ts +9 -0
- package/dist/types/cli/doctor/probe-types.d.ts +18 -0
- package/dist/types/cli/doctor/probe.d.ts +3 -0
- package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
- package/dist/types/cli/doctor/repairs.d.ts +28 -0
- package/dist/types/cli/doctor/report.d.ts +27 -0
- package/dist/types/cli/doctor/runner.d.ts +4 -0
- package/dist/types/cli/doctor/safety.d.ts +10 -0
- package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
- package/dist/types/cli/doctor/service-targets.d.ts +41 -0
- package/dist/types/cli/doctor/types.d.ts +145 -0
- package/dist/types/cli/doctor-cli.d.ts +7 -0
- package/dist/types/cli/doctor-supervisor.d.ts +121 -0
- package/dist/types/cli/doctor-worker.d.ts +29 -0
- package/dist/types/cli/install-activation.d.ts +158 -0
- package/dist/types/cli/macos-community-app.d.ts +41 -0
- package/dist/types/cli/mcp-cli.d.ts +0 -11
- package/dist/types/cli/update-cli.d.ts +41 -4
- package/dist/types/cli-main.d.ts +174 -0
- package/dist/types/cli.d.ts +1 -171
- package/dist/types/commands/doctor.d.ts +7 -0
- package/dist/types/commands/sdk.d.ts +17 -8
- package/dist/types/config/autorouting-tier-map.d.ts +19 -0
- package/dist/types/config/file-lock.d.ts +27 -3
- package/dist/types/config/model-preset-registry.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +12 -5
- package/dist/types/config/model-resolver.d.ts +16 -3
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
- package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
- package/dist/types/cursor.d.ts +9 -9
- package/dist/types/discovery/agents-md.d.ts +6 -1
- package/dist/types/discovery/helpers.d.ts +36 -7
- package/dist/types/discovery/index.d.ts +1 -1
- package/dist/types/discovery/mcp-json.d.ts +2 -0
- package/dist/types/extensibility/extensions/loader.d.ts +22 -1
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
- package/dist/types/extensibility/plugins/manager.d.ts +33 -4
- package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
- package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
- package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
- package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
- package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
- package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
- package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
- package/dist/types/main.d.ts +19 -1
- package/dist/types/modes/acp/acp-agent.d.ts +40 -0
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
- package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
- package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -2
- package/dist/types/sdk/acp/adapter.d.ts +5 -1
- package/dist/types/sdk/broker/broker.d.ts +51 -0
- package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
- package/dist/types/sdk/broker/discovery.d.ts +38 -0
- package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
- package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
- package/dist/types/sdk/broker/ensure.d.ts +14 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
- package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
- package/dist/types/sdk/broker/runtime.d.ts +26 -0
- package/dist/types/sdk/broker/session-index.d.ts +13 -4
- package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
- package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
- package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
- package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
- package/dist/types/sdk/bus/index.d.ts +2 -0
- package/dist/types/sdk/bus/notification-service.d.ts +3 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
- package/dist/types/sdk/cli/session-cli.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/host/surface-policy.d.ts +3 -1
- package/dist/types/sdk/lifecycle/service.d.ts +44 -10
- package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
- package/dist/types/sdk/prompt-failure.d.ts +47 -0
- package/dist/types/sdk/prompt-status.d.ts +19 -0
- package/dist/types/sdk/router/session-router.d.ts +3 -4
- package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
- package/dist/types/sdk/session-authority.d.ts +9 -0
- package/dist/types/sdk/session.d.ts +3 -1
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/blob-store.d.ts +1 -1
- package/dist/types/session/fallback-chain-controller.d.ts +3 -6
- package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
- package/dist/types/session/primary-control-surface.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +6 -0
- package/dist/types/tools/ask.d.ts +25 -0
- package/dist/types/tools/image-gen.d.ts +0 -4
- package/dist/types/tools/skill-discovery.d.ts +7 -5
- package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
- package/dist/types/utils/changelog.d.ts +0 -9
- package/dist/types/utils/mupdf-wasm.d.ts +1 -0
- package/package.json +9 -8
- package/scripts/compile-args.ts +8 -4
- package/src/capability/fs.ts +395 -17
- package/src/capability/index.ts +273 -10
- package/src/capability/types.ts +9 -0
- package/src/cli/customize-doctor.ts +18 -12
- package/src/cli/doctor/args.ts +236 -0
- package/src/cli/doctor/checks.ts +431 -0
- package/src/cli/doctor/config-repairs.ts +517 -0
- package/src/cli/doctor/context.ts +149 -0
- package/src/cli/doctor/files.ts +149 -0
- package/src/cli/doctor/ids.ts +115 -0
- package/src/cli/doctor/install-repairs.ts +335 -0
- package/src/cli/doctor/journal.ts +423 -0
- package/src/cli/doctor/managed-link.ts +514 -0
- package/src/cli/doctor/native-probe.ts +13 -0
- package/src/cli/doctor/permission-repairs.ts +242 -0
- package/src/cli/doctor/plugin-quarantine.ts +377 -0
- package/src/cli/doctor/plugin-restore.ts +59 -0
- package/src/cli/doctor/plugin-targets.ts +373 -0
- package/src/cli/doctor/probe-client.ts +195 -0
- package/src/cli/doctor/probe-types.ts +21 -0
- package/src/cli/doctor/probe.ts +42 -0
- package/src/cli/doctor/projection-probe.ts +249 -0
- package/src/cli/doctor/repairs.ts +1157 -0
- package/src/cli/doctor/report.ts +176 -0
- package/src/cli/doctor/runner.ts +342 -0
- package/src/cli/doctor/safety.ts +40 -0
- package/src/cli/doctor/service-repairs.ts +452 -0
- package/src/cli/doctor/service-targets.ts +274 -0
- package/src/cli/doctor/types.ts +164 -0
- package/src/cli/doctor-cli.ts +62 -0
- package/src/cli/doctor-supervisor.ts +571 -0
- package/src/cli/doctor-worker.ts +181 -0
- package/src/cli/install-activation.ts +666 -0
- package/src/cli/macos-community-app.ts +1449 -0
- package/src/cli/mcp-cli.ts +11 -21
- package/src/cli/nofile-limit.ts +5 -1
- package/src/cli/plugin-cli.ts +6 -1
- package/src/cli/update-cli.ts +362 -264
- package/src/cli-main.ts +517 -0
- package/src/cli.ts +64 -433
- package/src/commands/autoresearch.ts +11 -5
- package/src/commands/completion.ts +1 -1
- package/src/commands/doctor.ts +13 -0
- package/src/commands/sdk.ts +58 -10
- package/src/commands/ultragoal.ts +11 -5
- package/src/config/autorouting-tier-map.ts +7 -0
- package/src/config/file-lock.ts +736 -71
- package/src/config/mcp-schema.json +3 -3
- package/src/config/model-preset-registry.ts +1 -0
- package/src/config/model-registry.ts +80 -27
- package/src/config/model-resolver.ts +33 -4
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/settings-schema.ts +6 -2
- package/src/coordinator-mcp/question-gate-codec.ts +1 -0
- package/src/coordinator-mcp/server.ts +534 -51
- package/src/coordinator-mcp/session-reaper.ts +64 -8
- package/src/cursor.ts +223 -53
- package/src/discovery/agents-md.ts +74 -8
- package/src/discovery/agents.ts +32 -7
- package/src/discovery/builtin.ts +184 -75
- package/src/discovery/claude-plugins.ts +85 -17
- package/src/discovery/cline.ts +38 -10
- package/src/discovery/cursor.ts +19 -6
- package/src/discovery/gemini.ts +62 -22
- package/src/discovery/github.ts +13 -3
- package/src/discovery/helpers.ts +483 -89
- package/src/discovery/index.ts +1 -0
- package/src/discovery/mcp-json.ts +36 -10
- package/src/discovery/opencode.ts +36 -19
- package/src/discovery/ssh.ts +11 -8
- package/src/discovery/windsurf.ts +12 -3
- package/src/exec/bash-executor.ts +10 -3
- package/src/exec/isolated-shell.ts +15 -2
- package/src/extensibility/extensions/loader.ts +88 -26
- package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
- package/src/extensibility/gjc-plugins/paths.ts +4 -1
- package/src/extensibility/gjc-plugins/types.ts +27 -0
- package/src/extensibility/plugins/manager.ts +200 -22
- package/src/extensibility/plugins/marketplace/cache.ts +55 -2
- package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
- package/src/extensibility/plugins/marketplace/registry.ts +153 -0
- package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
- package/src/extensibility/runtime-skill-discovery.ts +61 -9
- package/src/extensibility/skill-management.ts +7 -1
- package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
- package/src/gjc-runtime/launch-worktree.test.ts +248 -0
- package/src/gjc-runtime/launch-worktree.ts +275 -38
- package/src/gjc-runtime/repository-binding.ts +26 -1
- package/src/gjc-runtime/session-layout.ts +10 -0
- package/src/gjc-runtime/session-state-lock.ts +330 -23
- package/src/gjc-runtime/session-state-sidecar.ts +328 -80
- package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
- package/src/harness-control-plane/session-lease.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +28 -26
- package/src/main.ts +150 -29
- package/src/memories/index.ts +24 -2
- package/src/modes/acp/acp-agent.ts +522 -68
- package/src/modes/components/agent-dashboard.ts +3 -1
- package/src/modes/components/model-selector.ts +28 -19
- package/src/modes/components/plugin-settings.ts +2 -1
- package/src/modes/controllers/selector-controller.ts +15 -9
- package/src/modes/interactive-mode.ts +21 -3
- package/src/prompts/system/system-prompt.md +5 -1
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/move-session.md +4 -0
- package/src/prompts/tools/read.md +2 -1
- package/src/prompts/tools/skill-discovery.md +6 -5
- package/src/prompts/tools/task.md +1 -0
- package/src/runtime-mcp/config-writer.ts +228 -0
- package/src/runtime-mcp/oauth-discovery.ts +114 -14
- package/src/runtime-mcp/policy-json-edit.ts +129 -0
- package/src/runtime-mcp/startup-policy.ts +14 -0
- package/src/runtime-mcp/types.ts +2 -2
- package/src/sdk/acp/adapter.ts +27 -12
- package/src/sdk/broker/broker.ts +273 -44
- package/src/sdk/broker/daemon-entry.ts +119 -0
- package/src/sdk/broker/discovery.ts +173 -20
- package/src/sdk/broker/doctor-restart.ts +206 -0
- package/src/sdk/broker/endpoint-authority.ts +36 -0
- package/src/sdk/broker/ensure.ts +49 -14
- package/src/sdk/broker/lifecycle.ts +174 -42
- package/src/sdk/broker/process-incarnation.ts +86 -2
- package/src/sdk/broker/runtime.ts +145 -21
- package/src/sdk/broker/session-index.ts +136 -22
- package/src/sdk/broker/spawn-substrate.ts +4 -1
- package/src/sdk/broker/transport.ts +5 -0
- package/src/sdk/broker/worktree-occupancy.ts +21 -1
- package/src/sdk/bus/chat-daemon-cli.ts +63 -0
- package/src/sdk/bus/chat-daemon-control.ts +260 -64
- package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
- package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
- package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
- package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
- package/src/sdk/bus/index.ts +252 -51
- package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
- package/src/sdk/bus/notification-service.ts +21 -3
- package/src/sdk/bus/reconciliation-store.ts +17 -11
- package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon-control.ts +47 -0
- package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
- package/src/sdk/bus/telegram-daemon.ts +178 -13
- package/src/sdk/cli/session-cli.ts +100 -12
- package/src/sdk/host/host.ts +2 -7
- package/src/sdk/host/session-runtime.test.ts +329 -8
- package/src/sdk/host/session-runtime.ts +202 -50
- package/src/sdk/host/surface-policy.ts +8 -1
- package/src/sdk/lifecycle/service.ts +199 -22
- package/src/sdk/mcp/server.ts +1 -1
- package/src/sdk/prompt-deadline-lease.ts +12 -4
- package/src/sdk/prompt-deadline-manager.ts +5 -7
- package/src/sdk/prompt-failure.ts +148 -0
- package/src/sdk/prompt-status.ts +30 -1
- package/src/sdk/prompt-watchdog.ts +11 -3
- package/src/sdk/protocol/operation-inventory.generated.json +18 -0
- package/src/sdk/protocol/operation-registry.ts +20 -8
- package/src/sdk/router/session-router.ts +8 -3
- package/src/sdk/service-artifact-paths.ts +26 -0
- package/src/sdk/session-authority.ts +10 -0
- package/src/sdk/session.ts +361 -194
- package/src/session/agent-session.ts +398 -93
- package/src/session/blob-store.ts +9 -9
- package/src/session/fallback-chain-controller.ts +3 -7
- package/src/session/internal/managed-session-scope.ts +445 -29
- package/src/session/internal/managed-session-storage.ts +38 -6
- package/src/session/primary-control-surface.ts +12 -0
- package/src/session/session-manager.ts +11 -3
- package/src/session/session-storage.ts +1 -1
- package/src/session-import/codex.ts +6 -1
- package/src/session-import/redact.ts +7 -0
- package/src/skill-state/workflow-mutation-guard.ts +645 -39
- package/src/slash-commands/builtin-registry.ts +7 -3
- package/src/system-prompt.ts +8 -0
- package/src/task/executor.ts +36 -3
- package/src/task/types.ts +27 -5
- package/src/tools/ask.ts +114 -34
- package/src/tools/browser/actions.ts +2 -2
- package/src/tools/browser/tab-worker.ts +11 -8
- package/src/tools/descriptors.ts +6 -1
- package/src/tools/fetch.ts +7 -2
- package/src/tools/image-gen.ts +33 -34
- package/src/tools/read.ts +78 -32
- package/src/tools/skill-discovery.ts +17 -7
- package/src/tools/tool-catalog.generated.ts +8 -8
- package/src/tools/ultragoal-ask-guard.ts +16 -26
- package/src/utils/changelog.ts +7 -1
- package/src/utils/git.ts +3 -1
- package/src/utils/markit.ts +24 -2
- package/src/utils/mupdf-wasm.ts +26 -0
- package/src/web/search/providers/openai-compatible.ts +191 -14
|
@@ -53,14 +53,43 @@ const BASH_EXEC_REDIRECT_RE = /(?:^|[;&|\n])\s*(?:sudo\s+)?exec\b[^;&|\n]*[<>]/i
|
|
|
53
53
|
const ARCHIVE_OR_SQLITE_BASE_RE = /^(.+?\.(?:tar\.gz|sqlite3|sqlite|db3|zip|tgz|tar|db))(?:$|:)/i;
|
|
54
54
|
const INTERNAL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
55
55
|
const VIM_FILE_SWITCH_RE = /^\s*:(?:e|e!|edit|edit!)(?:\s+([^<\r\n]+))?(?:<CR>|\r|\n|$)/i;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Executable wrappers that run their operand as a command (`command rm x`,
|
|
58
|
+
* `env rm x`, `exec rm x`). Bash resolves through them, so the mutator that
|
|
59
|
+
* follows is a real write. Left unmodeled they produced no target and no
|
|
60
|
+
* unknown flag, letting a mutation cross the planning boundary. `env` may
|
|
61
|
+
* carry `NAME=value` assignments before the wrapped command.
|
|
62
|
+
*/
|
|
63
|
+
const BASH_COMMAND_WRAPPER_SOURCE =
|
|
64
|
+
"(?:(?:command|builtin|exec|nohup|nice|ionice|stdbuf|setsid|time|env)\\s+(?:-[^\\s]+\\s+(?:\\d+\\s+)?|\\w+=[^\\s]*\\s+)*)*";
|
|
65
|
+
/**
|
|
66
|
+
* Shell keywords that open a NEW command position without a `;`/`|`/`&`
|
|
67
|
+
* separator in front of the command word (`then rm x`, `do rm x`, `else rm x`).
|
|
68
|
+
* Normalizing them to separators lets the existing mutation regexes see the
|
|
69
|
+
* command position instead of treating `then rm` as one opaque word.
|
|
70
|
+
*/
|
|
71
|
+
const BASH_COMMAND_POSITION_KEYWORD_RE = /(?:^|[;&|(\n{}])\s*(?:then|else|elif|do|in|\{|!)(?=\s)/gi;
|
|
72
|
+
const BASH_MUTATION_COMMAND_RE = new RegExp(
|
|
73
|
+
`(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(?:tee|touch|rm|mkdir|cp|mv|install|truncate)\\b([^;&|()\\n]*)|(?:^|[^<>])(?:>>?|\\d>>?)\\s*([^\\s;&|]+)`,
|
|
74
|
+
"gi",
|
|
75
|
+
);
|
|
76
|
+
/** Command-word extractor; must accept the same prefixes as `BASH_MUTATION_COMMAND_RE`. */
|
|
77
|
+
const BASH_MUTATION_COMMAND_NAME_RE = new RegExp(
|
|
78
|
+
`(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(tee|touch|rm|mkdir|cp|mv|install|truncate)\\b`,
|
|
79
|
+
"i",
|
|
80
|
+
);
|
|
81
|
+
const BASH_IN_PLACE_MUTATION_COMMAND_RE = new RegExp(
|
|
82
|
+
`(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(?:sed|perl)\\b([^;&|()\\n]*)`,
|
|
83
|
+
"gi",
|
|
84
|
+
);
|
|
59
85
|
const BASH_OPAQUE_INTERPRETER_WRITE_RE =
|
|
60
86
|
/(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:python3?|node|ruby)\b[^;&|\n]*(?:-c|-e)\b[^;&|\n]*(?:open\s*\(|writeFile(?:Sync)?\s*\(|\.write\s*\()/i;
|
|
61
87
|
const BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE =
|
|
62
88
|
/(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:python3?|node|ruby)\b[^;&|\n]*(?:<<[-]?\s*['"]?\w+['"]?)[\s\S]*(?:open\s*\(|writeFile(?:Sync)?\s*\(|\.write\s*\()/i;
|
|
63
|
-
const BASH_DD_OUTPUT_RE =
|
|
89
|
+
const BASH_DD_OUTPUT_RE = new RegExp(
|
|
90
|
+
`(?:^|[;&|\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|]*\\/)?dd\\b([^;&|\\n]*)`,
|
|
91
|
+
"gi",
|
|
92
|
+
);
|
|
64
93
|
/** Literal `sh|bash|zsh -c '<script>'` payloads whose nested script must also be scanned. */
|
|
65
94
|
const BASH_NESTED_SHELL_RE =
|
|
66
95
|
/(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?(?:ba|z|da)?sh\s+(?:-[A-Za-z]+\s+)*-[A-Za-z]*c\s+(?:(')([^']*)'|(")([^"]*)")/g;
|
|
@@ -428,26 +457,344 @@ function cleanShellWord(value: string): string {
|
|
|
428
457
|
}
|
|
429
458
|
|
|
430
459
|
/**
|
|
431
|
-
* Blank out
|
|
432
|
-
*
|
|
433
|
-
* redirections or separators.
|
|
434
|
-
*
|
|
435
|
-
*
|
|
460
|
+
* Blank out quoted spans so shell metacharacters inside a literal argument
|
|
461
|
+
* value (e.g. `--evidence "p99 500ms -> 120ms"`) are not misread as
|
|
462
|
+
* redirections or separators.
|
|
463
|
+
*
|
|
464
|
+
* Both quote styles are masked because in POSIX shells neither one can contain
|
|
465
|
+
* a redirection or a command separator: `>`, `|`, `;`, and `&` are always
|
|
466
|
+
* literal data inside quotes. Masking only single quotes made every `>` in
|
|
467
|
+
* double-quoted PROSE look like a real redirect, so ordinary evidence text
|
|
468
|
+
* (`"throughput > baseline"`, `"500ms -> 120ms"`) was scanned as a file write
|
|
469
|
+
* and blocked.
|
|
470
|
+
*
|
|
471
|
+
* Double quotes differ from single quotes in one way that matters here: they
|
|
472
|
+
* still expand `$(...)` and backticks, and that substitution IS live code. So
|
|
473
|
+
* the literal text of a double-quoted span is masked while any substitution
|
|
474
|
+
* inside it is preserved verbatim for the scanners that follow.
|
|
475
|
+
*
|
|
476
|
+
* One span is never prose: a quoted word that is the OPERAND of a redirection
|
|
477
|
+
* or of `of=` (`> "/dev/null"`, `>" src.ts"`, `of=" /dev/null"`) names a file.
|
|
478
|
+
* Masking it would erase the very path the sink and bypass checks must read, so
|
|
479
|
+
* operand spans are preserved verbatim. The returned dequoted view performs
|
|
480
|
+
* shell quote removal for executable-word/argument extraction while replacing
|
|
481
|
+
* quoted separators with inert placeholders.
|
|
436
482
|
*/
|
|
437
|
-
|
|
483
|
+
/**
|
|
484
|
+
* Does the double quote at `quoteIndex` open a redirection / `of=` operand
|
|
485
|
+
* rather than a prose argument? Only whitespace may sit between the operator
|
|
486
|
+
* and the quote, so `> "x"`, `>"x"`, `2>>"x"`, `>|"x"`, and `of="x"` all
|
|
487
|
+
* qualify, while `--evidence "a > b"` does not.
|
|
488
|
+
*/
|
|
489
|
+
function opensRedirectionOperand(command: string, quoteIndex: number): boolean {
|
|
490
|
+
let cursor = quoteIndex - 1;
|
|
491
|
+
while (cursor >= 0 && (command[cursor] === " " || command[cursor] === "\t")) cursor -= 1;
|
|
492
|
+
if (cursor < 0) return false;
|
|
493
|
+
const previous = command[cursor];
|
|
494
|
+
if (previous === ">" || previous === "<" || previous === "|" || previous === "&") {
|
|
495
|
+
// `|`/`&` qualify only as part of a redirection operator (`>|`, `>&`).
|
|
496
|
+
if (previous === "|" || previous === "&") {
|
|
497
|
+
const before = command[cursor - 1];
|
|
498
|
+
return before === ">" || before === "<";
|
|
499
|
+
}
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
// `of="..."` (dd) names an output file the same way a redirection does.
|
|
503
|
+
return previous === "=" && /(?:^|[\s;&|])of=$/i.test(command.slice(0, cursor + 1));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
interface MaskedQuotedSpans {
|
|
507
|
+
masked: string;
|
|
508
|
+
/**
|
|
509
|
+
* Quote removal preserves executable word characters while neutralizing
|
|
510
|
+
* quoted separators/metacharacters. This lets mutation extraction recognize
|
|
511
|
+
* `r"m"` and `rm "src/product.ts"` without re-exposing prose as syntax.
|
|
512
|
+
*/
|
|
513
|
+
dequoted: string;
|
|
514
|
+
/** True when live syntax cannot be balanced by this lightweight parser. */
|
|
515
|
+
unmodeled: boolean;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
type QuotedSpanFrame = {
|
|
519
|
+
kind: "root" | "paren" | "backtick";
|
|
520
|
+
quote: "none" | "single" | "double";
|
|
521
|
+
parenDepth: number;
|
|
522
|
+
quotedOperand: boolean;
|
|
523
|
+
/** An unquoted `#` comment consumes the rest of this live frame line. */
|
|
524
|
+
comment: boolean;
|
|
525
|
+
/** Last live byte in this frame, used for comment-boundary recognition. */
|
|
526
|
+
previous: string;
|
|
527
|
+
/** True while the frame is at a command-list boundary before its first word. */
|
|
528
|
+
expectCommand: boolean;
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
function maskQuotedSpans(command: string): MaskedQuotedSpans {
|
|
438
532
|
let masked = "";
|
|
439
|
-
let
|
|
440
|
-
|
|
533
|
+
let dequoted = "";
|
|
534
|
+
let unmodeledSyntax = false;
|
|
535
|
+
const frames: QuotedSpanFrame[] = [
|
|
536
|
+
{
|
|
537
|
+
kind: "root",
|
|
538
|
+
quote: "none",
|
|
539
|
+
parenDepth: 0,
|
|
540
|
+
quotedOperand: false,
|
|
541
|
+
comment: false,
|
|
542
|
+
previous: "",
|
|
543
|
+
expectCommand: true,
|
|
544
|
+
},
|
|
545
|
+
];
|
|
546
|
+
const dequoteData = (character: string): string => (/[\w./-]/.test(character) ? character : "_");
|
|
547
|
+
const appendLive = (frame: QuotedSpanFrame, character: string): void => {
|
|
548
|
+
masked += character;
|
|
549
|
+
dequoted += character;
|
|
550
|
+
frame.previous = character;
|
|
551
|
+
if (character === "\n" || ";|&".includes(character)) frame.expectCommand = true;
|
|
552
|
+
else if (character === "(" && frame.kind === "paren") frame.expectCommand = true;
|
|
553
|
+
else if (!/\s/.test(character)) frame.expectCommand = false;
|
|
554
|
+
};
|
|
555
|
+
const appendQuoted = (character: string, operand: boolean): void => {
|
|
556
|
+
if (operand) masked += character;
|
|
557
|
+
else masked += character === "\n" ? "\n" : " ";
|
|
558
|
+
dequoted += dequoteData(character);
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
562
|
+
const frame = frames.at(-1) as QuotedSpanFrame;
|
|
563
|
+
const character = command[index] as string;
|
|
564
|
+
const next = command[index + 1];
|
|
565
|
+
|
|
566
|
+
// A comment inside a live substitution must not let a literal `)` close
|
|
567
|
+
// that substitution. Preserve the newline as syntax, but mask all bytes
|
|
568
|
+
// before it as inert comment data.
|
|
569
|
+
if (frame.comment) {
|
|
570
|
+
if (character === "\n") {
|
|
571
|
+
frame.comment = false;
|
|
572
|
+
appendLive(frame, character);
|
|
573
|
+
} else {
|
|
574
|
+
appendQuoted(character, false);
|
|
575
|
+
}
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Backslash escapes are quote syntax, not command boundaries. In the
|
|
580
|
+
// dequoted view the escaped byte survives only as data; in the masked
|
|
581
|
+
// view preserve the historical bytes so redirection extraction remains
|
|
582
|
+
// conservative.
|
|
583
|
+
if (character === "\\" && frame.quote !== "single") {
|
|
584
|
+
if (next === undefined) {
|
|
585
|
+
appendQuoted(character, frame.quote !== "none" && frame.quotedOperand);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if (next === "\n") {
|
|
589
|
+
index += 1;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (frame.quote === "none" || frame.quotedOperand) masked += character + next;
|
|
593
|
+
else masked += " ";
|
|
594
|
+
dequoted += dequoteData(next);
|
|
595
|
+
frame.previous = "_";
|
|
596
|
+
if (frame.quote === "none") frame.expectCommand = false;
|
|
597
|
+
index += 1;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (frame.quote === "single") {
|
|
602
|
+
if (character === "'") {
|
|
603
|
+
masked += character;
|
|
604
|
+
frame.quote = "none";
|
|
605
|
+
frame.quotedOperand = false;
|
|
606
|
+
frame.previous = character;
|
|
607
|
+
} else {
|
|
608
|
+
appendQuoted(character, frame.quotedOperand);
|
|
609
|
+
}
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (frame.quote === "double") {
|
|
614
|
+
if (character === '"') {
|
|
615
|
+
masked += character;
|
|
616
|
+
frame.quote = "none";
|
|
617
|
+
frame.quotedOperand = false;
|
|
618
|
+
frame.previous = character;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
// A substitution is live even when it appears inside a double-quoted
|
|
622
|
+
// argument. Push a separate parser frame so quotes/parentheses inside
|
|
623
|
+
// the substitution cannot close the surrounding `"` prematurely.
|
|
624
|
+
if (character === "$" && next === "(") {
|
|
625
|
+
masked += "$(";
|
|
626
|
+
dequoted += "$(";
|
|
627
|
+
frame.previous = "(";
|
|
628
|
+
frame.expectCommand = false;
|
|
629
|
+
frames.push({
|
|
630
|
+
kind: "paren",
|
|
631
|
+
quote: "none",
|
|
632
|
+
parenDepth: 0,
|
|
633
|
+
quotedOperand: false,
|
|
634
|
+
comment: false,
|
|
635
|
+
previous: "",
|
|
636
|
+
expectCommand: true,
|
|
637
|
+
});
|
|
638
|
+
index += 1;
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
if (character === "`") {
|
|
642
|
+
masked += character;
|
|
643
|
+
dequoted += character;
|
|
644
|
+
frame.previous = character;
|
|
645
|
+
frame.expectCommand = false;
|
|
646
|
+
frames.push({
|
|
647
|
+
kind: "backtick",
|
|
648
|
+
quote: "none",
|
|
649
|
+
parenDepth: 0,
|
|
650
|
+
quotedOperand: false,
|
|
651
|
+
comment: false,
|
|
652
|
+
previous: "",
|
|
653
|
+
expectCommand: true,
|
|
654
|
+
});
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
appendQuoted(character, frame.quotedOperand);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// A command-substitution frame closes only at its own unquoted `)`.
|
|
662
|
+
// Parentheses nested inside `$((…))`/`$( (… ) …)` are tracked separately.
|
|
663
|
+
if (frame.kind === "paren" && character === ")") {
|
|
664
|
+
if (frame.parenDepth > 0) {
|
|
665
|
+
frame.parenDepth -= 1;
|
|
666
|
+
appendLive(frame, character);
|
|
667
|
+
} else {
|
|
668
|
+
appendLive(frame, character);
|
|
669
|
+
frames.pop();
|
|
670
|
+
const parent = frames.at(-1);
|
|
671
|
+
if (parent) {
|
|
672
|
+
// A substitution close belongs to the current shell word.
|
|
673
|
+
parent.previous = "_";
|
|
674
|
+
parent.expectCommand = false;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (frame.kind === "backtick" && character === "`") {
|
|
680
|
+
appendLive(frame, character);
|
|
681
|
+
frames.pop();
|
|
682
|
+
const parent = frames.at(-1);
|
|
683
|
+
if (parent) {
|
|
684
|
+
parent.previous = character;
|
|
685
|
+
parent.expectCommand = false;
|
|
686
|
+
}
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (frame.quote === "none" && character === "#" && (frame.previous === "" || /[\s;|&()]/.test(frame.previous))) {
|
|
691
|
+
frame.comment = true;
|
|
692
|
+
appendQuoted(character, false);
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
if (frame.quote === "none" && frame.expectCommand) {
|
|
696
|
+
const ahead = command.slice(index, index + 9);
|
|
697
|
+
if (
|
|
698
|
+
frame.kind !== "root" &&
|
|
699
|
+
/^(?:case|if|for|while|until|select|function|time|!|\{)(?:[\s;|&()]|$)/.test(ahead)
|
|
700
|
+
) {
|
|
701
|
+
// Compound commands have command positions the mutation regexes do
|
|
702
|
+
// not model; `case` also uses `)` as a pattern terminator. Balanced
|
|
703
|
+
// parentheses alone cannot authorize these live substitution bodies.
|
|
704
|
+
unmodeledSyntax = true;
|
|
705
|
+
} else if (frame.kind === "root" && /^(?:case|select|function)(?:[\s;|&()]|$)/.test(ahead)) {
|
|
706
|
+
// At the root the other compounds are recovered by command-position
|
|
707
|
+
// keyword normalization below, but `case` overloads `)` as a pattern
|
|
708
|
+
// terminator and `select`/`function` introduce command positions the
|
|
709
|
+
// regexes cannot attribute. Fail closed instead of scanning blind.
|
|
710
|
+
unmodeledSyntax = true;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
441
713
|
if (character === "'") {
|
|
442
|
-
inSingle = !inSingle;
|
|
443
714
|
masked += character;
|
|
715
|
+
frame.quote = "single";
|
|
716
|
+
// A single-quoted redirection/`of=` operand names a file exactly like a
|
|
717
|
+
// double-quoted one (`> '/dev/null'`, `of='/dev/null'`). Masking it would
|
|
718
|
+
// erase the path the sink and bypass checks must read.
|
|
719
|
+
frame.quotedOperand = opensRedirectionOperand(command, index);
|
|
720
|
+
frame.previous = character;
|
|
721
|
+
frame.expectCommand = false;
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
if (character === '"') {
|
|
725
|
+
masked += character;
|
|
726
|
+
frame.quote = "double";
|
|
727
|
+
frame.quotedOperand = opensRedirectionOperand(command, index);
|
|
728
|
+
frame.previous = character;
|
|
729
|
+
frame.expectCommand = false;
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (character === "$" && next === "(") {
|
|
733
|
+
masked += "$(";
|
|
734
|
+
dequoted += "$(";
|
|
735
|
+
frame.previous = "(";
|
|
736
|
+
frame.expectCommand = false;
|
|
737
|
+
frames.push({
|
|
738
|
+
kind: "paren",
|
|
739
|
+
quote: "none",
|
|
740
|
+
parenDepth: 0,
|
|
741
|
+
quotedOperand: false,
|
|
742
|
+
comment: false,
|
|
743
|
+
previous: "",
|
|
744
|
+
expectCommand: true,
|
|
745
|
+
});
|
|
746
|
+
index += 1;
|
|
444
747
|
continue;
|
|
445
748
|
}
|
|
446
|
-
|
|
749
|
+
// ANSI-C (`$'…'`) and locale-translated (`$"…"`) quoting: bash strips the
|
|
750
|
+
// `$` during quote removal, so `$'rm' src/product.ts` really executes `rm`.
|
|
751
|
+
// The dequoted view must drop the `$` too, otherwise the executable word
|
|
752
|
+
// keeps a leading `$` and misses the mutation regexes entirely. The masked
|
|
753
|
+
// (syntax) view keeps the byte so redirection offsets stay conservative.
|
|
754
|
+
if (character === "$" && (next === "'" || next === '"')) {
|
|
755
|
+
masked += character;
|
|
756
|
+
frame.quote = next === "'" ? "single" : "double";
|
|
757
|
+
masked += next;
|
|
758
|
+
frame.quotedOperand = opensRedirectionOperand(command, index);
|
|
759
|
+
frame.previous = next;
|
|
760
|
+
frame.expectCommand = false;
|
|
761
|
+
index += 1;
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
if (character === "`") {
|
|
765
|
+
appendLive(frame, character);
|
|
766
|
+
frames.push({
|
|
767
|
+
kind: "backtick",
|
|
768
|
+
quote: "none",
|
|
769
|
+
parenDepth: 0,
|
|
770
|
+
quotedOperand: false,
|
|
771
|
+
comment: false,
|
|
772
|
+
previous: "",
|
|
773
|
+
expectCommand: true,
|
|
774
|
+
});
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (frame.kind === "paren" && character === "(") frame.parenDepth += 1;
|
|
778
|
+
appendLive(frame, character);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// An unbalanced quote/substitution means the parse is unreliable; keep the
|
|
782
|
+
// original text so both passes remain fail-closed rather than going blind.
|
|
783
|
+
const root = frames[0];
|
|
784
|
+
const unbalanced = frames.length !== 1 || root?.quote !== "none";
|
|
785
|
+
if (unbalanced || unmodeledSyntax) {
|
|
786
|
+
return { masked: command, dequoted: command, unmodeled: true };
|
|
447
787
|
}
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
788
|
+
// `then rm x` / `do rm x` / `{ rm x` put a mutator in a command position that
|
|
789
|
+
// is not preceded by a `;`/`|`/`&` separator, so the mutation regexes would
|
|
790
|
+
// read `then rm` as a single opaque word. Replacing the keyword with a `;`
|
|
791
|
+
// keeps byte length (match offsets stay aligned) while exposing the position.
|
|
792
|
+
const exposeCommandPositions = (value: string): string =>
|
|
793
|
+
value.replace(BASH_COMMAND_POSITION_KEYWORD_RE, segment => {
|
|
794
|
+
const keyword = segment.trimEnd();
|
|
795
|
+
return `${keyword.slice(0, keyword.length - 1)};`.padEnd(segment.length, " ");
|
|
796
|
+
});
|
|
797
|
+
return { masked: exposeCommandPositions(masked), dequoted: exposeCommandPositions(dequoted), unmodeled: false };
|
|
451
798
|
}
|
|
452
799
|
|
|
453
800
|
function isDeviceSinkPath(value: string): boolean {
|
|
@@ -905,6 +1252,226 @@ function maskHeredocBodiesPass(
|
|
|
905
1252
|
};
|
|
906
1253
|
}
|
|
907
1254
|
|
|
1255
|
+
/**
|
|
1256
|
+
* A command-substitution body and whether its delimiters were balanced. An
|
|
1257
|
+
* unbalanced body is an unreliable parse and the caller fails closed on it.
|
|
1258
|
+
*/
|
|
1259
|
+
interface SubstitutionBody {
|
|
1260
|
+
body: string;
|
|
1261
|
+
balanced: boolean;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** Keep parser-frame recursion bounded before extractBashTargets' semantic depth guard. */
|
|
1265
|
+
const SUBSTITUTION_PARSER_MAX_DEPTH = 64;
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* Scan a `$(` body starting just after the opening paren. Quotes inside the
|
|
1269
|
+
* body are honoured: a `)` inside `'...'` or `"..."` is data, not the closer.
|
|
1270
|
+
* Nested substitutions are scanned with their own quote/comment frame, so a
|
|
1271
|
+
* `#` or quote inside `$(...)` nested in a double-quoted outer substitution
|
|
1272
|
+
* cannot desynchronise this frame. Returns the body and the index just past
|
|
1273
|
+
* its closing paren, or `end === command.length` with `balanced: false`.
|
|
1274
|
+
*/
|
|
1275
|
+
function scanDollarParenBody(command: string, start: number, parserDepth = 0): SubstitutionBody & { end: number } {
|
|
1276
|
+
let depth = 1;
|
|
1277
|
+
let inSingle = false;
|
|
1278
|
+
let inDouble = false;
|
|
1279
|
+
let inComment = false;
|
|
1280
|
+
let previous = "";
|
|
1281
|
+
for (let cursor = start; cursor < command.length; cursor += 1) {
|
|
1282
|
+
const character = command[cursor];
|
|
1283
|
+
if (inComment) {
|
|
1284
|
+
if (character === "\n") {
|
|
1285
|
+
inComment = false;
|
|
1286
|
+
previous = character;
|
|
1287
|
+
}
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
if (character === "\\" && !inSingle) {
|
|
1291
|
+
cursor += 1;
|
|
1292
|
+
previous = "x";
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
if (character === "'" && !inDouble) {
|
|
1296
|
+
inSingle = !inSingle;
|
|
1297
|
+
previous = character;
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
if (character === '"' && !inSingle) {
|
|
1301
|
+
inDouble = !inDouble;
|
|
1302
|
+
previous = character;
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
if (inSingle) continue;
|
|
1306
|
+
if (character === "$" && command[cursor + 1] === "(") {
|
|
1307
|
+
if (parserDepth >= SUBSTITUTION_PARSER_MAX_DEPTH) {
|
|
1308
|
+
return { body: command.slice(start), balanced: false, end: command.length };
|
|
1309
|
+
}
|
|
1310
|
+
const nested = scanDollarParenBody(command, cursor + 2, parserDepth + 1);
|
|
1311
|
+
if (!nested.balanced) {
|
|
1312
|
+
return { body: command.slice(start), balanced: false, end: command.length };
|
|
1313
|
+
}
|
|
1314
|
+
cursor = nested.end - 1;
|
|
1315
|
+
previous = "x";
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
if (character === "`") {
|
|
1319
|
+
if (parserDepth >= SUBSTITUTION_PARSER_MAX_DEPTH) {
|
|
1320
|
+
return { body: command.slice(start), balanced: false, end: command.length };
|
|
1321
|
+
}
|
|
1322
|
+
const nested = scanBacktickBody(command, cursor + 1, parserDepth + 1);
|
|
1323
|
+
if (!nested.balanced) {
|
|
1324
|
+
return { body: command.slice(start), balanced: false, end: command.length };
|
|
1325
|
+
}
|
|
1326
|
+
cursor = nested.end - 1;
|
|
1327
|
+
previous = "x";
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
if (!inDouble && character === "#" && (previous === "" || /\s/.test(previous) || ";|&()".includes(previous))) {
|
|
1331
|
+
inComment = true;
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
if (!inDouble && character === "(") depth += 1;
|
|
1335
|
+
else if (!inDouble && character === ")") {
|
|
1336
|
+
depth -= 1;
|
|
1337
|
+
if (depth === 0) {
|
|
1338
|
+
return { body: command.slice(start, cursor), balanced: true, end: cursor + 1 };
|
|
1339
|
+
}
|
|
1340
|
+
previous = ")";
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
previous = character;
|
|
1344
|
+
}
|
|
1345
|
+
return { body: command.slice(start), balanced: false, end: command.length };
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Scan a backtick command substitution with the same quote/comment rules as
|
|
1350
|
+
* `scanDollarParenBody`. Nested `$()` frames are skipped with their own
|
|
1351
|
+
* parser so backticks in those frames cannot close this one.
|
|
1352
|
+
*/
|
|
1353
|
+
function scanBacktickBody(command: string, start: number, parserDepth = 0): SubstitutionBody & { end: number } {
|
|
1354
|
+
let inSingle = false;
|
|
1355
|
+
let inDouble = false;
|
|
1356
|
+
let inComment = false;
|
|
1357
|
+
let previous = "";
|
|
1358
|
+
for (let cursor = start; cursor < command.length; cursor += 1) {
|
|
1359
|
+
const character = command[cursor];
|
|
1360
|
+
if (inComment) {
|
|
1361
|
+
if (character === "\n") {
|
|
1362
|
+
inComment = false;
|
|
1363
|
+
previous = character;
|
|
1364
|
+
}
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
if (character === "\\" && !inSingle) {
|
|
1368
|
+
cursor += 1;
|
|
1369
|
+
previous = "x";
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
if (character === "'" && !inDouble) {
|
|
1373
|
+
inSingle = !inSingle;
|
|
1374
|
+
previous = character;
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1377
|
+
if (character === '"' && !inSingle) {
|
|
1378
|
+
inDouble = !inDouble;
|
|
1379
|
+
previous = character;
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
if (inSingle) continue;
|
|
1383
|
+
if (character === "$" && command[cursor + 1] === "(") {
|
|
1384
|
+
if (parserDepth >= SUBSTITUTION_PARSER_MAX_DEPTH) {
|
|
1385
|
+
return { body: command.slice(start), balanced: false, end: command.length };
|
|
1386
|
+
}
|
|
1387
|
+
const nested = scanDollarParenBody(command, cursor + 2, parserDepth + 1);
|
|
1388
|
+
if (!nested.balanced) {
|
|
1389
|
+
return { body: command.slice(start), balanced: false, end: command.length };
|
|
1390
|
+
}
|
|
1391
|
+
cursor = nested.end - 1;
|
|
1392
|
+
previous = "x";
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
if (character === "`") {
|
|
1396
|
+
return { body: command.slice(start, cursor), balanced: true, end: cursor + 1 };
|
|
1397
|
+
}
|
|
1398
|
+
if (!inDouble && character === "#" && (previous === "" || /\s/.test(previous) || ";|&()".includes(previous))) {
|
|
1399
|
+
inComment = true;
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
previous = character;
|
|
1403
|
+
}
|
|
1404
|
+
return { body: command.slice(start), balanced: false, end: command.length };
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Collect the bodies of `$(...)` and backtick command substitutions. Their
|
|
1409
|
+
* contents are a live command list wherever they appear -- including inside a
|
|
1410
|
+
* double-quoted span -- so the caller rescans each one as its own script.
|
|
1411
|
+
*
|
|
1412
|
+
* Quote state is tracked the way the shell does it: an apostrophe inside a
|
|
1413
|
+
* double-quoted word is data, not a single-quote opener, so `"it's $(rm x)"`
|
|
1414
|
+
* still yields the substitution; a `)` inside a quoted argument does not close
|
|
1415
|
+
* the body, so `$(printf ')'; rm x)` yields the whole body. Single-quoted spans
|
|
1416
|
+
* are skipped because they suppress substitution entirely. Shell comments are
|
|
1417
|
+
* skipped only at word boundaries, and their quote-like text cannot poison the
|
|
1418
|
+
* surrounding frame. A body whose delimiters never balance is reported as such
|
|
1419
|
+
* so the caller fails closed.
|
|
1420
|
+
*/
|
|
1421
|
+
function extractSubstitutionBodies(command: string): SubstitutionBody[] {
|
|
1422
|
+
const bodies: SubstitutionBody[] = [];
|
|
1423
|
+
let inSingle = false;
|
|
1424
|
+
let inDouble = false;
|
|
1425
|
+
let inComment = false;
|
|
1426
|
+
let previous = "";
|
|
1427
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
1428
|
+
const character = command[index];
|
|
1429
|
+
if (inComment) {
|
|
1430
|
+
if (character === "\n") {
|
|
1431
|
+
inComment = false;
|
|
1432
|
+
previous = character;
|
|
1433
|
+
}
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
if (character === "\\" && !inSingle) {
|
|
1437
|
+
index += 1;
|
|
1438
|
+
previous = "x";
|
|
1439
|
+
continue;
|
|
1440
|
+
}
|
|
1441
|
+
if (character === "'" && !inDouble) {
|
|
1442
|
+
inSingle = !inSingle;
|
|
1443
|
+
previous = character;
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
if (character === '"' && !inSingle) {
|
|
1447
|
+
inDouble = !inDouble;
|
|
1448
|
+
previous = character;
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1451
|
+
if (inSingle) continue;
|
|
1452
|
+
if (character === "$" && command[index + 1] === "(") {
|
|
1453
|
+
const scanned = scanDollarParenBody(command, index + 2);
|
|
1454
|
+
bodies.push({ body: scanned.body, balanced: scanned.balanced });
|
|
1455
|
+
index = scanned.end - 1;
|
|
1456
|
+
previous = "x";
|
|
1457
|
+
continue;
|
|
1458
|
+
}
|
|
1459
|
+
if (character === "`") {
|
|
1460
|
+
const scanned = scanBacktickBody(command, index + 1);
|
|
1461
|
+
bodies.push({ body: scanned.body, balanced: scanned.balanced });
|
|
1462
|
+
index = scanned.end - 1;
|
|
1463
|
+
previous = "x";
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
if (!inDouble && character === "#" && (previous === "" || /\s/.test(previous) || ";|&()".includes(previous))) {
|
|
1467
|
+
inComment = true;
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
previous = character;
|
|
1471
|
+
}
|
|
1472
|
+
return bodies;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
908
1475
|
function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
|
|
909
1476
|
const record = getRecord(args);
|
|
910
1477
|
const command = safeString(record?.command);
|
|
@@ -927,17 +1494,42 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
|
|
|
927
1494
|
if (nested.unknown) targets.unknown = true;
|
|
928
1495
|
if (nested.explicitMutation) targets.explicitMutation = true;
|
|
929
1496
|
}
|
|
1497
|
+
// `$(...)` / backticks are a nested command list, exactly like `sh -c`: the
|
|
1498
|
+
// statement anchors below (`^`, `;`, `&`, `|`, newline) never see inside one,
|
|
1499
|
+
// so `echo "$(rm -rf src/x.ts)"` would otherwise scan clean. Heredoc masking
|
|
1500
|
+
// removes literal data-consumer bodies first; the extractor then ignores
|
|
1501
|
+
// inert `#` tails without touching quote/escape bytes needed by extraction.
|
|
1502
|
+
const heredoc = maskHeredocBodies(command);
|
|
1503
|
+
for (const substitution of extractSubstitutionBodies(heredoc.masked)) {
|
|
1504
|
+
if (depth >= 2 || !substitution.balanced) {
|
|
1505
|
+
targets.explicitMutation = true;
|
|
1506
|
+
targets.unknown = true;
|
|
1507
|
+
break;
|
|
1508
|
+
}
|
|
1509
|
+
const nested = extractBashTargets({ command: substitution.body }, depth + 1);
|
|
1510
|
+
for (const nestedPath of nested.paths) addPath(targets, nestedPath);
|
|
1511
|
+
if (nested.unknown) targets.unknown = true;
|
|
1512
|
+
if (nested.explicitMutation) targets.explicitMutation = true;
|
|
1513
|
+
}
|
|
930
1514
|
// Heredoc bodies fed to data consumers are inert document payloads; mask them
|
|
931
1515
|
// so spec/plan text cannot fake redirections. Script-consumer bodies survive
|
|
932
1516
|
// the mask and are still scanned as live code below.
|
|
933
|
-
const heredoc = maskHeredocBodies(command);
|
|
934
1517
|
if (heredoc.opaqueExpansion || heredoc.mutatingConsumer) {
|
|
935
1518
|
targets.explicitMutation = true;
|
|
936
1519
|
targets.unknown = true;
|
|
937
1520
|
}
|
|
938
|
-
// Nested scripts were read from the raw text above
|
|
939
|
-
//
|
|
940
|
-
|
|
1521
|
+
// Nested scripts were read from the raw text above. Redirection extraction
|
|
1522
|
+
// uses the quote-preserving syntax view; command extraction uses its
|
|
1523
|
+
// dequoted executable-word view so quoted operands and split command words
|
|
1524
|
+
// remain visible without turning prose into syntax.
|
|
1525
|
+
const scanned = maskQuotedSpans(heredoc.masked);
|
|
1526
|
+
if (scanned.unmodeled) {
|
|
1527
|
+
// A live substitution construct we cannot balance (for example a
|
|
1528
|
+
// `case` pattern whose `)` is not the substitution close) must never
|
|
1529
|
+
// silently hide a mutation from the scanner.
|
|
1530
|
+
targets.explicitMutation = true;
|
|
1531
|
+
targets.unknown = true;
|
|
1532
|
+
}
|
|
941
1533
|
if (
|
|
942
1534
|
BASH_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked) ||
|
|
943
1535
|
BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked)
|
|
@@ -946,17 +1538,17 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
|
|
|
946
1538
|
targets.unknown = true;
|
|
947
1539
|
}
|
|
948
1540
|
// `exec 1<>file` rebinds a descriptor, so a later `>/dev/null` may not reach the device at all.
|
|
949
|
-
if (BASH_EXEC_REDIRECT_RE.test(scanned)) {
|
|
1541
|
+
if (BASH_EXEC_REDIRECT_RE.test(scanned.masked)) {
|
|
950
1542
|
targets.explicitMutation = true;
|
|
951
1543
|
targets.unknown = true;
|
|
952
1544
|
}
|
|
953
|
-
for (const match of scanned.matchAll(BASH_EXTENDED_WRITE_RE)) {
|
|
1545
|
+
for (const match of scanned.masked.matchAll(BASH_EXTENDED_WRITE_RE)) {
|
|
954
1546
|
targets.explicitMutation = true;
|
|
955
1547
|
const cleaned = cleanShellWord(match[1] ?? "");
|
|
956
1548
|
if (!cleaned) targets.unknown = true;
|
|
957
1549
|
else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
|
|
958
1550
|
}
|
|
959
|
-
for (const match of scanned.matchAll(BASH_DD_OUTPUT_RE)) {
|
|
1551
|
+
for (const match of scanned.masked.matchAll(BASH_DD_OUTPUT_RE)) {
|
|
960
1552
|
targets.explicitMutation = true;
|
|
961
1553
|
const parts = shellWords(match[1] ?? "").map(cleanShellWord);
|
|
962
1554
|
// Every `of=` counts: GNU dd honors the last one, so `of=/dev/null of=real.ts` writes real.ts.
|
|
@@ -968,7 +1560,7 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
|
|
|
968
1560
|
else if (!isDeviceSinkPath(outputPath)) addPath(targets, outputPath);
|
|
969
1561
|
}
|
|
970
1562
|
}
|
|
971
|
-
for (const match of scanned.matchAll(BASH_IN_PLACE_MUTATION_COMMAND_RE)) {
|
|
1563
|
+
for (const match of scanned.dequoted.matchAll(BASH_IN_PLACE_MUTATION_COMMAND_RE)) {
|
|
972
1564
|
const parts = shellWords(match[1] ?? "").map(cleanShellWord);
|
|
973
1565
|
const hasInPlaceFlag = parts.some(part => /^-.*i/.test(part));
|
|
974
1566
|
if (!hasInPlaceFlag) continue;
|
|
@@ -977,26 +1569,28 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
|
|
|
977
1569
|
if (target) addPath(targets, target);
|
|
978
1570
|
else targets.unknown = true;
|
|
979
1571
|
}
|
|
980
|
-
for (const match of scanned.matchAll(BASH_MUTATION_COMMAND_RE)) {
|
|
981
|
-
targets.explicitMutation = true;
|
|
1572
|
+
for (const match of scanned.masked.matchAll(BASH_MUTATION_COMMAND_RE)) {
|
|
982
1573
|
const redirected = match[2]?.trim();
|
|
983
|
-
if (redirected)
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1574
|
+
if (!redirected) continue;
|
|
1575
|
+
targets.explicitMutation = true;
|
|
1576
|
+
const cleaned = cleanShellWord(redirected);
|
|
1577
|
+
// A capture that dequotes to nothing (e.g. `>" src.ts"`) is a truncated parse, not a safe no-op.
|
|
1578
|
+
if (!cleaned) targets.unknown = true;
|
|
1579
|
+
else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
|
|
1580
|
+
}
|
|
1581
|
+
for (const match of scanned.dequoted.matchAll(BASH_MUTATION_COMMAND_RE)) {
|
|
1582
|
+
// Redirections are extracted from the quote-preserving syntax view above.
|
|
1583
|
+
// The dequoted view exists so quote-split command words (`r"m"`) and
|
|
1584
|
+
// quoted operands (`rm "src/product.ts"`) still reach this command branch.
|
|
1585
|
+
if (match[2]) continue;
|
|
1586
|
+
targets.explicitMutation = true;
|
|
990
1587
|
const parts = shellWords(match[1] ?? "");
|
|
991
|
-
const commandName = match[0]
|
|
992
|
-
?.match(
|
|
993
|
-
/(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?(tee|touch|rm|mkdir|cp|mv|install|truncate)\b/i,
|
|
994
|
-
)?.[1]
|
|
995
|
-
?.toLowerCase();
|
|
1588
|
+
const commandName = match[0]?.match(BASH_MUTATION_COMMAND_NAME_RE)?.[1]?.toLowerCase();
|
|
996
1589
|
const targetParts =
|
|
997
1590
|
commandName === "cp" || commandName === "mv" || commandName === "install" || commandName === "truncate"
|
|
998
1591
|
? parts.slice(-1)
|
|
999
1592
|
: parts;
|
|
1593
|
+
let extractedTarget = false;
|
|
1000
1594
|
for (const part of targetParts) {
|
|
1001
1595
|
const cleaned = cleanShellWord(part);
|
|
1002
1596
|
if (!cleaned || cleaned.startsWith("-")) continue;
|
|
@@ -1004,7 +1598,12 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
|
|
|
1004
1598
|
// not argument paths; their targets are captured by the redirect scanners.
|
|
1005
1599
|
if (/^\d*[<>]/.test(cleaned)) continue;
|
|
1006
1600
|
addPath(targets, cleaned);
|
|
1601
|
+
extractedTarget = true;
|
|
1007
1602
|
}
|
|
1603
|
+
// A recognized mutator with no attributable operand is not a safe no-op:
|
|
1604
|
+
// quote masking, unsupported expansions, and option-only invocations all
|
|
1605
|
+
// fail closed rather than allowing a targetless mutation through planning.
|
|
1606
|
+
if (!extractedTarget) targets.unknown = true;
|
|
1008
1607
|
}
|
|
1009
1608
|
return targets;
|
|
1010
1609
|
}
|
|
@@ -1227,8 +1826,15 @@ export async function getWorkflowMutationDecision(
|
|
|
1227
1826
|
// `.gjc/**` is runtime-owned and only the sanctioned CLI writes it). A branch
|
|
1228
1827
|
// name is not authorization: an `autoresearch/*` branch isolates keep/discard
|
|
1229
1828
|
// bookkeeping, it does not turn product edits into research.
|
|
1829
|
+
//
|
|
1830
|
+
// The allowance requires at least one named target: `every` is vacuously true
|
|
1831
|
+
// for an empty list, so an opaque mutation that resolves to NO target (e.g.
|
|
1832
|
+
// `python3 -c "open('src/product.ts','w').write('x')"`) would otherwise skip
|
|
1833
|
+
// the fail-closed check below and be waved through on a research mission.
|
|
1230
1834
|
if (
|
|
1231
1835
|
planning.skill === "autoresearch" &&
|
|
1836
|
+
targets.paths.length > 0 &&
|
|
1837
|
+
!targets.unknown &&
|
|
1232
1838
|
targets.paths.every(p => isAutoresearchAuthorizedResearchPath(input.cwd, p))
|
|
1233
1839
|
) {
|
|
1234
1840
|
return { blocked: false, targets: targets.paths };
|