@gajae-code/coding-agent 0.18.11 → 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 +760 -0
- package/dist/types/async/job-manager.d.ts +7 -3
- package/dist/types/async/result-formatting.d.ts +2 -0
- package/dist/types/autoresearch/git.d.ts +1 -1
- package/dist/types/capability/fs.d.ts +2 -2
- package/dist/types/capability/rule.d.ts +7 -2
- package/dist/types/cli/accounts-cli.d.ts +2 -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/doctor/install-repairs.d.ts +4 -1
- package/dist/types/cli/install-activation.d.ts +10 -4
- package/dist/types/cli/public-command-errors.d.ts +2 -0
- package/dist/types/cli/root-flags.d.ts +0 -12
- package/dist/types/cli/update-cli.d.ts +21 -3
- package/dist/types/cli-main.d.ts +0 -6
- package/dist/types/cli-ordinary.d.ts +2 -2
- package/dist/types/commands/accounts.d.ts +2 -1
- 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 +13 -0
- package/dist/types/commit/changelog/index.d.ts +1 -0
- package/dist/types/commit/map-reduce/index.d.ts +4 -4
- package/dist/types/config/autorouting-tier-map.d.ts +53 -1
- package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
- package/dist/types/config/config-hot-reload.d.ts +44 -0
- package/dist/types/config/file-lock.d.ts +52 -3
- package/dist/types/config/model-discovery-manager.d.ts +2 -0
- package/dist/types/config/model-profile-activation.d.ts +79 -9
- package/dist/types/config/model-registry.d.ts +72 -8
- package/dist/types/config/model-resolver.d.ts +7 -4
- package/dist/types/config/models-config-schema.d.ts +10 -0
- package/dist/types/config/settings-schema.d.ts +57 -4
- package/dist/types/config/settings.d.ts +33 -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/config.d.ts +8 -4
- package/dist/types/dap/session.d.ts +11 -2
- package/dist/types/debug/system-info.d.ts +6 -1
- package/dist/types/discovery/helpers.d.ts +7 -2
- package/dist/types/discovery/mcp-compat.d.ts +3 -1
- package/dist/types/edit/index.d.ts +5 -0
- package/dist/types/edit/modes/patch.d.ts +14 -0
- package/dist/types/edit/modes/replace.d.ts +3 -0
- package/dist/types/eval/py/runtime.d.ts +9 -0
- package/dist/types/exec/bash-executor.d.ts +12 -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/managed-owner-admission.d.ts +3 -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/session-state-lock.d.ts +2 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
- 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 +2 -1
- package/dist/types/migrate/adapters/index.d.ts +42 -1
- package/dist/types/migrate/executor.d.ts +3 -1
- 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 +82 -3
- 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/components/settings-selector.d.ts +6 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
- package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/modes/utils/host-status.d.ts +15 -11
- package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
- package/dist/types/runtime-mcp/manager.d.ts +7 -0
- package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
- package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
- package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
- package/dist/types/sdk/broker/broker-exit.d.ts +3 -3
- package/dist/types/sdk/broker/broker.d.ts +29 -2
- package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
- package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
- package/dist/types/sdk/broker/ensure.d.ts +141 -9
- package/dist/types/sdk/broker/hop.d.ts +45 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
- package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
- package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
- package/dist/types/sdk/broker/session-index.d.ts +6 -1
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
- package/dist/types/sdk/bus/index.d.ts +44 -0
- package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -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/cli/session-cli.d.ts +0 -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 +40 -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-deadline-flush.d.ts +42 -4
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
- package/dist/types/sdk/prompt-status.d.ts +11 -4
- package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
- package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
- package/dist/types/sdk/session-list.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +107 -9
- package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
- package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
- package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
- package/dist/types/session/messages.d.ts +5 -1
- package/dist/types/session/session-manager.d.ts +29 -3
- package/dist/types/session/startup-auth-config.d.ts +24 -2
- package/dist/types/session/streaming-output.d.ts +11 -0
- package/dist/types/session/terminal-abort.d.ts +3 -1
- package/dist/types/session-import/service.d.ts +1 -1
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +20 -4
- package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
- package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
- package/dist/types/ssh/ssh-executor.d.ts +8 -1
- package/dist/types/system-prompt.d.ts +3 -2
- package/dist/types/task/executor.d.ts +19 -2
- package/dist/types/task/role-body.d.ts +11 -0
- package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
- package/dist/types/tools/atomic-file-write.d.ts +9 -7
- package/dist/types/tools/awaiting-input.d.ts +28 -0
- package/dist/types/tools/bash-allowed-prefixes.d.ts +17 -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 +15 -2
- package/dist/types/tools/fetch.d.ts +7 -1
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/irc.d.ts +5 -3
- package/dist/types/tools/python.d.ts +1 -1
- package/dist/types/tools/report-issue.d.ts +41 -0
- package/dist/types/tools/subagent.d.ts +23 -0
- 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/scrapers/types.d.ts +8 -1
- package/dist/types/web/search/providers/codex.d.ts +6 -7
- package/dist/types/web/search/providers/insane.d.ts +7 -1
- package/dist/types/web/search/providers/searxng.d.ts +5 -0
- package/package.json +10 -9
- package/scripts/acp-conformance-agent.ts +12 -2
- package/scripts/build-binary.ts +2 -1
- package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
- package/scripts/generate-docs-index.ts +8 -1
- package/scripts/generate-sdk-operation-inventory.ts +38 -4
- package/src/async/job-manager.ts +356 -56
- package/src/async/result-formatting.ts +40 -0
- package/src/autoresearch/git.ts +9 -2
- package/src/autoresearch/prompts/prompt-setup.md +1 -1
- package/src/capability/fs.ts +58 -22
- package/src/capability/index.ts +2 -2
- package/src/capability/rule.ts +11 -2
- package/src/cli/accounts-cli.ts +176 -8
- package/src/cli/append-system-prompt.ts +12 -0
- package/src/cli/args.ts +11 -7
- package/src/cli/auth-broker-cli.ts +21 -11
- package/src/cli/customize-doctor-precedence.ts +31 -0
- package/src/cli/customize-doctor.ts +49 -39
- package/src/cli/doctor/install-repairs.ts +35 -5
- package/src/cli/fast-help.ts +1 -3
- package/src/cli/install-activation.ts +47 -4
- package/src/cli/migrate-cli.ts +19 -6
- package/src/cli/public-command-errors.ts +6 -0
- package/src/cli/root-flags.ts +2 -4
- package/src/cli/stats-cli.ts +9 -5
- package/src/cli/update-cli.ts +471 -13
- package/src/cli-ordinary.ts +7 -2
- package/src/commands/accounts.ts +10 -5
- package/src/commands/harness.ts +25 -15
- package/src/commands/migrate.ts +3 -3
- package/src/commands/sdk.ts +277 -50
- package/src/commands/ultragoal.ts +16 -8
- package/src/commit/agentic/agent.ts +6 -6
- package/src/commit/analysis/conventional.ts +14 -5
- package/src/commit/changelog/index.ts +39 -1
- package/src/commit/map-reduce/index.ts +7 -7
- package/src/commit/message.ts +10 -3
- package/src/commit/pipeline.ts +12 -12
- package/src/config/autorouting-tier-map.ts +50 -1
- package/src/config/config-file.ts +106 -3
- package/src/config/config-hot-reload-trust.ts +148 -0
- package/src/config/config-hot-reload.ts +742 -0
- package/src/config/file-lock.ts +1615 -328
- package/src/config/keybindings.ts +154 -7
- package/src/config/model-bindings-applier.ts +85 -47
- package/src/config/model-discovery-manager.ts +10 -0
- package/src/config/model-profile-activation.ts +889 -148
- package/src/config/model-registry.ts +1706 -205
- package/src/config/model-resolver.ts +52 -22
- package/src/config/models-config-schema.ts +6 -0
- package/src/config/settings-schema.ts +73 -4
- package/src/config/settings.ts +460 -24
- package/src/coordinator-mcp/policy.ts +26 -1
- package/src/coordinator-mcp/server.ts +37 -2
- package/src/crash/sanitize.ts +7 -1
- package/src/customization/import.ts +279 -8
- package/src/customization/inventory.ts +24 -8
- package/src/customization/types.ts +1 -0
- package/src/dap/client.ts +3 -2
- package/src/dap/config.ts +31 -11
- package/src/dap/defaults.json +1 -1
- package/src/dap/session.ts +161 -36
- package/src/debug/crash-diagnostics.ts +89 -6
- package/src/debug/report-bundle.ts +90 -2
- package/src/debug/system-info.ts +8 -3
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
- package/src/discovery/agents-md.ts +1 -1
- package/src/discovery/builtin.ts +4 -3
- package/src/discovery/helpers.ts +25 -16
- package/src/discovery/mcp-compat.ts +120 -17
- package/src/edit/apply-patch/index.ts +12 -1
- package/src/edit/index.ts +204 -22
- package/src/edit/modes/patch.ts +214 -8
- package/src/edit/modes/replace.ts +136 -0
- package/src/eval/js/context-manager.ts +29 -3
- package/src/eval/js/worker-entry.ts +51 -18
- package/src/eval/py/executor.ts +14 -4
- package/src/eval/py/runner.py +144 -5
- package/src/eval/py/runtime.ts +44 -4
- package/src/exec/bash-executor.ts +81 -4
- package/src/exec/bash-shell-guardian.ts +4 -1
- package/src/exec/bash-shell-supervisor.ts +8 -4
- package/src/exec/isolated-shell.ts +5 -1
- package/src/exec/non-interactive-env.ts +0 -1
- package/src/export/html/index.ts +86 -6
- package/src/export/ttsr.ts +176 -1
- package/src/extensibility/custom-tools/wrapper.ts +3 -1
- package/src/extensibility/extensions/loader.ts +10 -1
- 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/fetcher.ts +2 -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 +37 -7
- package/src/gjc-runtime/launch-tmux.ts +30 -15
- package/src/gjc-runtime/launch-worktree.test.ts +148 -0
- package/src/gjc-runtime/launch-worktree.ts +101 -14
- package/src/gjc-runtime/managed-owner-admission.ts +46 -2
- package/src/gjc-runtime/ralplan-current-final.ts +294 -0
- package/src/gjc-runtime/ralplan-runtime.ts +95 -1
- package/src/gjc-runtime/session-resolution.ts +38 -6
- package/src/gjc-runtime/session-state-lock.ts +59 -14
- package/src/gjc-runtime/state-runtime.ts +23 -19
- 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 +17 -12
- package/src/internal-urls/artifact-protocol.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +23 -22
- package/src/internal-urls/local-protocol.ts +167 -8
- 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/lsp/index.ts +29 -0
- package/src/lsp/workspace-path.ts +23 -0
- package/src/main.ts +330 -92
- package/src/memories/index.ts +25 -2
- package/src/migrate/action-planner.ts +574 -2
- package/src/migrate/adapters/claude-code.ts +162 -5
- package/src/migrate/adapters/codex.ts +77 -7
- package/src/migrate/adapters/index.ts +258 -5
- package/src/migrate/adapters/opencode.ts +10 -1
- package/src/migrate/executor.ts +222 -7
- package/src/migrate/hook-module.ts +193 -0
- package/src/migrate/report.ts +63 -2
- package/src/migrate/types.ts +84 -4
- package/src/modes/DESIGN.md +51 -0
- package/src/modes/acp/acp-agent.ts +10 -1
- package/src/modes/acp/permission-mode.ts +18 -1
- package/src/modes/components/agent-dashboard.ts +20 -4
- package/src/modes/components/assistant-message.ts +55 -1
- 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/settings-selector.ts +103 -37
- package/src/modes/components/welcome.ts +3 -1
- package/src/modes/controllers/command-controller.ts +300 -9
- 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 +66 -11
- package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
- package/src/modes/controllers/selector-controller.ts +76 -7
- package/src/modes/interactive-mode.ts +123 -4
- package/src/modes/prompt-suggestion-controller.ts +1 -1
- package/src/modes/runtime-init.ts +5 -1
- package/src/modes/theme/theme.ts +15 -0
- 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/debug.md +1 -1
- package/src/prompts/tools/irc.md +1 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/report-issue.md +13 -0
- package/src/prompts/tools/task.md +1 -1
- package/src/runtime-credential-selector.ts +2 -2
- package/src/runtime-mcp/content-limits.ts +7 -0
- package/src/runtime-mcp/manager-pool.test.ts +5 -4
- package/src/runtime-mcp/manager.ts +40 -3
- package/src/runtime-mcp/oauth-flow.ts +32 -6
- package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
- package/src/runtime-mcp/project-host-values.ts +12 -8
- package/src/runtime-mcp/render.ts +16 -1
- package/src/runtime-mcp/tool-bridge.ts +194 -21
- package/src/runtime-mcp/transports/stdio.ts +2 -1
- package/src/sdk/broker/broker-exit.ts +10 -5
- package/src/sdk/broker/broker.ts +484 -85
- package/src/sdk/broker/daemon-entry.ts +338 -116
- package/src/sdk/broker/doctor-restart.ts +2 -1
- package/src/sdk/broker/ensure.ts +1214 -268
- package/src/sdk/broker/hop.ts +286 -0
- package/src/sdk/broker/lifecycle-ledger.ts +38 -11
- package/src/sdk/broker/lifecycle.ts +102 -6
- package/src/sdk/broker/lock-host-identity.ts +29 -0
- package/src/sdk/broker/session-index.ts +91 -16
- package/src/sdk/broker/transport.ts +17 -1
- package/src/sdk/bus/chat-command-policy.ts +16 -3
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +460 -35
- package/src/sdk/bus/reconciliation-store.ts +201 -8
- 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 +9 -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 +23 -6
- 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 +1737 -129
- package/src/sdk/host/session-runtime.ts +684 -83
- 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 +15 -1
- package/src/sdk/prompt-deadline-flush.ts +78 -5
- package/src/sdk/prompt-deadline-manager.ts +68 -14
- package/src/sdk/prompt-status.ts +6 -4
- package/src/sdk/protocol/adapter-validation.ts +81 -12
- package/src/sdk/protocol/operation-inventory.generated.json +261 -2
- package/src/sdk/protocol/operation-registry.ts +33 -3
- package/src/sdk/reconciliation-extensions.ts +1 -0
- package/src/sdk/session-list.ts +1 -0
- package/src/sdk/session.ts +237 -97
- package/src/session/agent-session.ts +2726 -479
- package/src/session/history-storage.ts +182 -12
- package/src/session/internal/managed-session-scope.ts +173 -57
- package/src/session/internal/managed-session-storage.ts +85 -12
- package/src/session/internal/native-publish-outcome.ts +5 -1
- package/src/session/messages.ts +47 -6
- package/src/session/session-manager.ts +326 -19
- package/src/session/startup-auth-config.ts +116 -8
- package/src/session/streaming-output.ts +262 -4
- package/src/session/terminal-abort.ts +14 -2
- package/src/session-import/redact.ts +53 -2
- package/src/session-import/service.ts +1 -0
- package/src/skill-state/workflow-mutation-guard.ts +2145 -206
- package/src/slash-commands/builtin-registry.ts +21 -6
- package/src/slash-commands/helpers/credential-switch.ts +10 -5
- package/src/slash-commands/helpers/mcp.ts +11 -3
- package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
- 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 +115 -20
- package/src/task/index.ts +389 -175
- 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-edit-write-path.ts +64 -0
- package/src/tools/ast-edit.ts +18 -1
- package/src/tools/ast-grep.ts +7 -2
- package/src/tools/atomic-file-write.ts +46 -15
- package/src/tools/auto-generated-guard.ts +15 -3
- package/src/tools/awaiting-input.ts +114 -0
- package/src/tools/bash-allowed-prefixes.ts +214 -6
- package/src/tools/bash-interactive.ts +14 -2
- package/src/tools/bash-skill-urls.ts +58 -5
- package/src/tools/bash.ts +113 -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 +16 -8
- package/src/tools/descriptors.test.ts +44 -0
- package/src/tools/descriptors.ts +50 -6
- package/src/tools/eval.ts +15 -0
- package/src/tools/fetch.ts +93 -4
- package/src/tools/gh.ts +27 -12
- package/src/tools/image-gen.ts +20 -6
- package/src/tools/index.ts +2 -0
- package/src/tools/irc.ts +5 -3
- package/src/tools/monitor.ts +9 -1
- package/src/tools/output-meta.ts +74 -5
- package/src/tools/python.ts +1 -1
- package/src/tools/read.ts +26 -37
- package/src/tools/recipe/runner.ts +23 -12
- package/src/tools/render-utils.ts +2 -2
- package/src/tools/report-issue.ts +184 -0
- package/src/tools/search.ts +3 -2
- package/src/tools/ssh.ts +274 -8
- package/src/tools/subagent-render.ts +114 -12
- package/src/tools/subagent.ts +118 -5
- package/src/tools/tmux-self-injection-guard.ts +3301 -122
- package/src/tools/todo-write.ts +41 -3
- package/src/tools/tool-catalog.generated.ts +32 -4
- package/src/tools/ultragoal-ask-guard.ts +13 -1
- package/src/tools/write.ts +42 -3
- package/src/tui/tree-list.ts +15 -1
- package/src/utils/commit-message-generator.ts +12 -4
- package/src/utils/external-editor.ts +21 -7
- package/src/utils/file-mentions.ts +75 -7
- 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 +72 -22
- package/src/web/scrapers/index.ts +3 -1
- package/src/web/scrapers/types.ts +8 -0
- package/src/web/scrapers/youtube.ts +3 -2
- package/src/web/search/index.ts +2 -1
- package/src/web/search/providers/codex.ts +29 -32
- package/src/web/search/providers/insane.ts +91 -28
- package/src/web/search/providers/searxng.ts +62 -10
|
@@ -3,9 +3,14 @@ import * as os from "node:os";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import type { AgentTool } from "@gajae-code/agent-core";
|
|
5
5
|
import { logger } from "@gajae-code/utils";
|
|
6
|
+
import { canonicalEnvKey, type ProjectEnvSnapshot, projectEnvSnapshot } from "@gajae-code/utils/env-file";
|
|
6
7
|
import { isAutoresearchAuthorizedResearchPath } from "../autoresearch/git";
|
|
7
8
|
import { expandApplyPatchToEntries } from "../edit/modes/apply-patch";
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
GJC_SESSION_PREFIX,
|
|
11
|
+
sessionDirName,
|
|
12
|
+
modeStatePath as sessionModeStatePath,
|
|
13
|
+
} from "../gjc-runtime/session-layout";
|
|
9
14
|
import { resolveGjcSessionForRead } from "../gjc-runtime/session-resolution";
|
|
10
15
|
import { ModeStateSchema } from "../gjc-runtime/state-schema";
|
|
11
16
|
import { workflowEnvelopeChecksumStatus } from "../gjc-runtime/state-writer";
|
|
@@ -30,7 +35,7 @@ export const RALPLAN_MUTATION_BLOCK_MESSAGE =
|
|
|
30
35
|
export const ULTRAGOAL_GOAL_PLANNING_MUTATION_BLOCK_MESSAGE =
|
|
31
36
|
"Ultragoal goal-planning phase boundary: finish goal planning and record goals through `gjc ultragoal` before editing code. Product-code mutation tools and patch execution are blocked until goal planning completes and execution begins.";
|
|
32
37
|
export const AUTORESEARCH_MUTATION_BLOCK_MESSAGE =
|
|
33
|
-
"Autoresearch research-only boundary: this workflow produces findings, evidence, and a verdict — never product code. Product-code mutation tools and patch execution are blocked everywhere, on every branch.
|
|
38
|
+
"Autoresearch research-only boundary: this workflow produces findings, evidence, and a verdict — never product code. Product-code mutation tools and patch execution are blocked everywhere, on every branch. Write the root `autoresearch.sh` only with direct file tools; mission state stays writable through the sanctioned `gjc autoresearch` CLI. Run experiments through the `python` tool, or finish the mission and hand off to an approved implementation workflow.";
|
|
34
39
|
|
|
35
40
|
/** Resolve the phase-boundary block message for the active planning skill. */
|
|
36
41
|
function planningPhaseBlockMessage(skill: CanonicalGjcWorkflowSkill): string {
|
|
@@ -40,10 +45,15 @@ function planningPhaseBlockMessage(skill: CanonicalGjcWorkflowSkill): string {
|
|
|
40
45
|
return DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Tools whose execute path can mutate product state. `github` is included so
|
|
50
|
+
* side-effect ops share this guard. `eval` and `python` run code that can write
|
|
51
|
+
* files without going through edit, write, or bash.
|
|
52
|
+
*/
|
|
53
|
+
export const WORKFLOW_MUTATION_TOOL_NAMES = ["edit", "write", "ast_edit", "bash", "github", "eval", "python"] as const;
|
|
45
54
|
|
|
46
55
|
const BLOCKED_TOOL_NAMES = new Set<string>(WORKFLOW_MUTATION_TOOL_NAMES);
|
|
56
|
+
const AUTORESEARCH_HARNESS_FILE_TOOL_NAMES = new Set(["edit", "write"]);
|
|
47
57
|
|
|
48
58
|
export function isWorkflowMutationTool(name: string): boolean {
|
|
49
59
|
return BLOCKED_TOOL_NAMES.has(name);
|
|
@@ -79,22 +89,81 @@ const BASH_COMMAND_WRAPPER_SOURCE =
|
|
|
79
89
|
*/
|
|
80
90
|
const BASH_COMMAND_POSITION_KEYWORD_RE = /(?:^|[;&|(\n{}])\s*(?:then|else|elif|do|in|\{|!)(?=\s)/gi;
|
|
81
91
|
const BASH_MUTATION_COMMAND_RE = new RegExp(
|
|
82
|
-
`(?:^|[;&|(\\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;&|]+)`,
|
|
92
|
+
`(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(?:tee|touch|rm|mkdir|cp|mv|install|truncate|chmod|ln)\\b([^;&|()\\n]*)|(?:^|[^<>])(?:>>?|\\d>>?)\\s*([^\\s;&|]+)`,
|
|
83
93
|
"gi",
|
|
84
94
|
);
|
|
85
95
|
/** Command-word extractor; must accept the same prefixes as `BASH_MUTATION_COMMAND_RE`. */
|
|
86
96
|
const BASH_MUTATION_COMMAND_NAME_RE = new RegExp(
|
|
87
|
-
`(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(tee|touch|rm|mkdir|cp|mv|install|truncate)\\b`,
|
|
97
|
+
`(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(tee|touch|rm|mkdir|cp|mv|install|truncate|chmod|ln)\\b`,
|
|
88
98
|
"i",
|
|
89
99
|
);
|
|
100
|
+
const BASH_CHMOD_MODE_RE = /^(?:[0-7]{1,4}|(?:[ugoa]*[+=-][rwxXstugo]*)(?:,[ugoa]*[+=-][rwxXstugo]*)*)$/;
|
|
90
101
|
const BASH_IN_PLACE_MUTATION_COMMAND_RE = new RegExp(
|
|
91
102
|
`(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(?:sed|perl)\\b([^;&|()\\n]*)`,
|
|
92
103
|
"gi",
|
|
93
104
|
);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
);
|
|
98
167
|
const BASH_DD_OUTPUT_RE = new RegExp(
|
|
99
168
|
`(?:^|[;&|\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|]*\\/)?dd\\b([^;&|\\n]*)`,
|
|
100
169
|
"gi",
|
|
@@ -300,7 +369,7 @@ interface ActivePlanningSkill {
|
|
|
300
369
|
* reliably wins. Ties fall back to the resolved top-level `skill`, then to the
|
|
301
370
|
* first entry, matching how the HUD/chain guard pick `activeSkills[0]`.
|
|
302
371
|
*/
|
|
303
|
-
function resolveCurrentWorkflowEntry(entries: SkillActiveEntry[], topLevelSkill: string): SkillActiveEntry {
|
|
372
|
+
export function resolveCurrentWorkflowEntry(entries: SkillActiveEntry[], topLevelSkill: string): SkillActiveEntry {
|
|
304
373
|
const ts = (entry: SkillActiveEntry): number => {
|
|
305
374
|
const value = Date.parse(safeString(entry.updated_at) || safeString(entry.activated_at));
|
|
306
375
|
return Number.isNaN(value) ? -1 : value;
|
|
@@ -1553,198 +1622,1915 @@ function extractSubstitutionBodies(command: string): SubstitutionBody[] {
|
|
|
1553
1622
|
return bodies;
|
|
1554
1623
|
}
|
|
1555
1624
|
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
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++;
|
|
1577
1646
|
}
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
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++;
|
|
1589
1660
|
}
|
|
1590
|
-
|
|
1591
|
-
for (const nestedPath of nested.paths) addPath(targets, nestedPath);
|
|
1592
|
-
if (nested.unknown) targets.unknown = true;
|
|
1593
|
-
if (nested.explicitMutation) targets.explicitMutation = true;
|
|
1594
|
-
}
|
|
1595
|
-
// Heredoc bodies fed to data consumers are inert document payloads; mask them
|
|
1596
|
-
// so spec/plan text cannot fake redirections. Script-consumer bodies survive
|
|
1597
|
-
// the mask and are still scanned as live code below.
|
|
1598
|
-
if (heredoc.opaqueExpansion || heredoc.mutatingConsumer) {
|
|
1599
|
-
targets.explicitMutation = true;
|
|
1600
|
-
targets.unknown = true;
|
|
1661
|
+
return undefined;
|
|
1601
1662
|
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
// `case` pattern whose `)` is not the substitution close) must never
|
|
1610
|
-
// silently hide a mutation from the scanner.
|
|
1611
|
-
targets.explicitMutation = true;
|
|
1612
|
-
targets.unknown = true;
|
|
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++;
|
|
1613
1670
|
}
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
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++;
|
|
1620
1679
|
}
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
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++;
|
|
1625
1694
|
}
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
|
|
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;
|
|
1631
1699
|
}
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
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;
|
|
1642
1731
|
}
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
}
|
|
1653
|
-
for (const match of scanned.masked.matchAll(BASH_MUTATION_COMMAND_RE)) {
|
|
1654
|
-
const redirected = match[2]?.trim();
|
|
1655
|
-
if (!redirected) continue;
|
|
1656
|
-
targets.explicitMutation = true;
|
|
1657
|
-
const cleaned = cleanShellWord(redirected);
|
|
1658
|
-
// A capture that dequotes to nothing (e.g. `>" src.ts"`) is a truncated parse, not a safe no-op.
|
|
1659
|
-
if (!cleaned) targets.unknown = true;
|
|
1660
|
-
else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
|
|
1661
|
-
}
|
|
1662
|
-
for (const match of scanned.dequoted.matchAll(BASH_MUTATION_COMMAND_RE)) {
|
|
1663
|
-
// Redirections are extracted from the quote-preserving syntax view above.
|
|
1664
|
-
// The dequoted view exists so quote-split command words (`r"m"`) and
|
|
1665
|
-
// quoted operands (`rm "src/product.ts"`) still reach this command branch.
|
|
1666
|
-
if (match[2]) continue;
|
|
1667
|
-
targets.explicitMutation = true;
|
|
1668
|
-
const parts = shellWords(match[1] ?? "");
|
|
1669
|
-
const commandName = match[0]?.match(BASH_MUTATION_COMMAND_NAME_RE)?.[1]?.toLowerCase();
|
|
1670
|
-
const targetParts =
|
|
1671
|
-
commandName === "cp" || commandName === "mv" || commandName === "install" || commandName === "truncate"
|
|
1672
|
-
? parts.slice(-1)
|
|
1673
|
-
: parts;
|
|
1674
|
-
let extractedTarget = false;
|
|
1675
|
-
for (const part of targetParts) {
|
|
1676
|
-
const cleaned = cleanShellWord(part);
|
|
1677
|
-
if (!cleaned || cleaned.startsWith("-")) continue;
|
|
1678
|
-
// Redirection/heredoc operator words (`>/dev/null`, `<<'DOC'`, `2>&1`) are
|
|
1679
|
-
// not argument paths; their targets are captured by the redirect scanners.
|
|
1680
|
-
if (/^\d*[<>]/.test(cleaned)) continue;
|
|
1681
|
-
addPath(targets, cleaned);
|
|
1682
|
-
extractedTarget = true;
|
|
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
|
+
}
|
|
1683
1741
|
}
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
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++;
|
|
1688
1784
|
}
|
|
1689
|
-
return
|
|
1785
|
+
return foundWrite ? foundPath : undefined;
|
|
1690
1786
|
}
|
|
1787
|
+
|
|
1691
1788
|
/**
|
|
1692
|
-
*
|
|
1693
|
-
*
|
|
1694
|
-
*
|
|
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(...)`.
|
|
1695
1792
|
*/
|
|
1696
|
-
function
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
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;
|
|
1714
1831
|
}
|
|
1715
1832
|
|
|
1716
|
-
function
|
|
1717
|
-
const
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
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;
|
|
1727
1890
|
}
|
|
1728
1891
|
}
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
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
|
+
}
|
|
1736
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;
|
|
1737
1942
|
}
|
|
1738
1943
|
|
|
1739
|
-
function
|
|
1740
|
-
const
|
|
1741
|
-
if (
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
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
|
+
|
|
2929
|
+
function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
|
|
2930
|
+
const record = getRecord(args);
|
|
2931
|
+
const command = safeString(record?.command);
|
|
2932
|
+
const targets: ExtractedTargets = { paths: [], unknown: false };
|
|
2933
|
+
if (!command.trim()) {
|
|
2934
|
+
targets.unknown = true;
|
|
2935
|
+
return targets;
|
|
2936
|
+
}
|
|
2937
|
+
// A literal `sh|bash|zsh -c '<script>'` payload is a real command list. Its
|
|
2938
|
+
// mutations never match the top-level patterns, so recurse into it; anything
|
|
2939
|
+
// deeper than a bounded depth is not statically analyzable and fails closed.
|
|
2940
|
+
for (const match of command.matchAll(BASH_NESTED_SHELL_RE)) {
|
|
2941
|
+
if (depth >= 2) {
|
|
2942
|
+
targets.explicitMutation = true;
|
|
2943
|
+
targets.unknown = true;
|
|
2944
|
+
break;
|
|
2945
|
+
}
|
|
2946
|
+
const nested = extractBashTargets({ command: match[2] ?? match[4] ?? "" }, depth + 1);
|
|
2947
|
+
for (const nestedPath of nested.paths) addPath(targets, nestedPath);
|
|
2948
|
+
if (nested.unknown) targets.unknown = true;
|
|
2949
|
+
if (nested.explicitMutation) targets.explicitMutation = true;
|
|
2950
|
+
}
|
|
2951
|
+
// `$(...)` / backticks are a nested command list, exactly like `sh -c`: the
|
|
2952
|
+
// statement anchors below (`^`, `;`, `&`, `|`, newline) never see inside one,
|
|
2953
|
+
// so `echo "$(rm -rf src/x.ts)"` would otherwise scan clean. Heredoc masking
|
|
2954
|
+
// removes literal data-consumer bodies first; the extractor then ignores
|
|
2955
|
+
// inert `#` tails without touching quote/escape bytes needed by extraction.
|
|
2956
|
+
const heredoc = maskHeredocBodies(command);
|
|
2957
|
+
for (const substitution of extractSubstitutionBodies(heredoc.masked)) {
|
|
2958
|
+
if (depth >= 2 || !substitution.balanced) {
|
|
2959
|
+
targets.explicitMutation = true;
|
|
2960
|
+
targets.unknown = true;
|
|
2961
|
+
break;
|
|
2962
|
+
}
|
|
2963
|
+
const nested = extractBashTargets({ command: substitution.body }, depth + 1);
|
|
2964
|
+
for (const nestedPath of nested.paths) addPath(targets, nestedPath);
|
|
2965
|
+
if (nested.unknown) targets.unknown = true;
|
|
2966
|
+
if (nested.explicitMutation) targets.explicitMutation = true;
|
|
2967
|
+
}
|
|
2968
|
+
// Heredoc bodies fed to data consumers are inert document payloads; mask them
|
|
2969
|
+
// so spec/plan text cannot fake redirections. Script-consumer bodies survive
|
|
2970
|
+
// the mask and are still scanned as live code below.
|
|
2971
|
+
if (heredoc.opaqueExpansion || heredoc.mutatingConsumer) {
|
|
2972
|
+
targets.explicitMutation = true;
|
|
2973
|
+
targets.unknown = true;
|
|
2974
|
+
}
|
|
2975
|
+
// Nested scripts were read from the raw text above. Redirection extraction
|
|
2976
|
+
// uses the quote-preserving syntax view; command extraction uses its
|
|
2977
|
+
// dequoted executable-word view so quoted operands and split command words
|
|
2978
|
+
// remain visible without turning prose into syntax.
|
|
2979
|
+
const scanned = maskQuotedSpans(heredoc.masked);
|
|
2980
|
+
if (scanned.unmodeled) {
|
|
2981
|
+
// A live substitution construct we cannot balance (for example a
|
|
2982
|
+
// `case` pattern whose `)` is not the substitution close) must never
|
|
2983
|
+
// silently hide a mutation from the scanner.
|
|
2984
|
+
targets.explicitMutation = true;
|
|
2985
|
+
targets.unknown = true;
|
|
2986
|
+
}
|
|
2987
|
+
if (BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked)) {
|
|
2988
|
+
targets.explicitMutation = true;
|
|
2989
|
+
targets.unknown = true;
|
|
2990
|
+
}
|
|
2991
|
+
// `exec 1<>file` rebinds a descriptor, so a later `>/dev/null` may not reach the device at all.
|
|
2992
|
+
if (BASH_EXEC_REDIRECT_RE.test(scanned.masked)) {
|
|
2993
|
+
targets.explicitMutation = true;
|
|
2994
|
+
targets.unknown = true;
|
|
2995
|
+
}
|
|
2996
|
+
for (const match of scanned.masked.matchAll(BASH_EXTENDED_WRITE_RE)) {
|
|
2997
|
+
targets.explicitMutation = true;
|
|
2998
|
+
const cleaned = cleanShellWord(match[1] ?? "");
|
|
2999
|
+
if (!cleaned) targets.unknown = true;
|
|
3000
|
+
else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
|
|
3001
|
+
}
|
|
3002
|
+
for (const match of scanned.masked.matchAll(BASH_DD_OUTPUT_RE)) {
|
|
3003
|
+
targets.explicitMutation = true;
|
|
3004
|
+
const parts = shellWords(match[1] ?? "").map(cleanShellWord);
|
|
3005
|
+
// Every `of=` counts: GNU dd honors the last one, so `of=/dev/null of=real.ts` writes real.ts.
|
|
3006
|
+
const outputs = parts.filter(part => part.startsWith("of="));
|
|
3007
|
+
if (outputs.length === 0) targets.unknown = true;
|
|
3008
|
+
for (const output of outputs) {
|
|
3009
|
+
const outputPath = cleanShellWord(output.slice(3));
|
|
3010
|
+
if (!outputPath) targets.unknown = true;
|
|
3011
|
+
else if (!isDeviceSinkPath(outputPath)) addPath(targets, outputPath);
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
for (const match of scanned.dequoted.matchAll(BASH_IN_PLACE_MUTATION_COMMAND_RE)) {
|
|
3015
|
+
const parts = shellWords(match[1] ?? "").map(cleanShellWord);
|
|
3016
|
+
const hasInPlaceFlag = parts.some(part => /^-.*i/.test(part));
|
|
3017
|
+
if (!hasInPlaceFlag) continue;
|
|
3018
|
+
targets.explicitMutation = true;
|
|
3019
|
+
const target = [...parts].reverse().find(part => part && !part.startsWith("-"));
|
|
3020
|
+
if (target) addPath(targets, target);
|
|
3021
|
+
else targets.unknown = true;
|
|
3022
|
+
}
|
|
3023
|
+
for (const match of scanned.masked.matchAll(BASH_MUTATION_COMMAND_RE)) {
|
|
3024
|
+
const redirected = match[2]?.trim();
|
|
3025
|
+
if (!redirected) continue;
|
|
3026
|
+
targets.explicitMutation = true;
|
|
3027
|
+
const cleaned = cleanShellWord(redirected);
|
|
3028
|
+
// A capture that dequotes to nothing (e.g. `>" src.ts"`) is a truncated parse, not a safe no-op.
|
|
3029
|
+
if (!cleaned) targets.unknown = true;
|
|
3030
|
+
else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
|
|
3031
|
+
}
|
|
3032
|
+
for (const match of scanned.dequoted.matchAll(BASH_MUTATION_COMMAND_RE)) {
|
|
3033
|
+
// Redirections are extracted from the quote-preserving syntax view above.
|
|
3034
|
+
// The dequoted view exists so quote-split command words (`r"m"`) and
|
|
3035
|
+
// quoted operands (`rm "src/product.ts"`) still reach this command branch.
|
|
3036
|
+
if (match[2]) continue;
|
|
3037
|
+
targets.explicitMutation = true;
|
|
3038
|
+
const parts = shellWords(match[1] ?? "");
|
|
3039
|
+
const commandName = match[0]?.match(BASH_MUTATION_COMMAND_NAME_RE)?.[1]?.toLowerCase();
|
|
3040
|
+
const chmodModeIndex =
|
|
3041
|
+
commandName === "chmod" ? parts.findIndex(part => BASH_CHMOD_MODE_RE.test(cleanShellWord(part))) : -1;
|
|
3042
|
+
let targetParts = parts;
|
|
3043
|
+
if (
|
|
3044
|
+
commandName === "cp" ||
|
|
3045
|
+
commandName === "mv" ||
|
|
3046
|
+
commandName === "install" ||
|
|
3047
|
+
commandName === "truncate" ||
|
|
3048
|
+
commandName === "ln"
|
|
3049
|
+
) {
|
|
3050
|
+
targetParts = parts.slice(-1);
|
|
3051
|
+
} else if (commandName === "chmod" && chmodModeIndex >= 0) {
|
|
3052
|
+
targetParts = parts.filter((_part, index) => index !== chmodModeIndex);
|
|
3053
|
+
}
|
|
3054
|
+
let extractedTarget = false;
|
|
3055
|
+
for (const part of targetParts) {
|
|
3056
|
+
const cleaned = cleanShellWord(part);
|
|
3057
|
+
if (!cleaned || cleaned.startsWith("-")) continue;
|
|
3058
|
+
// Redirection/heredoc operator words (`>/dev/null`, `<<'DOC'`, `2>&1`) are
|
|
3059
|
+
// not argument paths; their targets are captured by the redirect scanners.
|
|
3060
|
+
if (/^\d*[<>]/.test(cleaned)) continue;
|
|
3061
|
+
addPath(targets, cleaned);
|
|
3062
|
+
extractedTarget = true;
|
|
3063
|
+
}
|
|
3064
|
+
// A recognized mutator with no attributable operand is not a safe no-op:
|
|
3065
|
+
// quote masking, unsupported expansions, and option-only invocations all
|
|
3066
|
+
// fail closed rather than allowing a targetless mutation through planning.
|
|
3067
|
+
if (!extractedTarget) targets.unknown = true;
|
|
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);
|
|
3074
|
+
return targets;
|
|
3075
|
+
}
|
|
3076
|
+
/**
|
|
3077
|
+
* Read-only github ops extract no mutation. Every other op fails closed during
|
|
3078
|
+
* planning: remote push, PR creation, and worktree/config writes are not a
|
|
3079
|
+
* filesystem path the planning allowlist can clear.
|
|
3080
|
+
*/
|
|
3081
|
+
function extractGithubTargets(args: unknown): ExtractedTargets {
|
|
3082
|
+
if (isGithubReadOnlyArgs(args)) return { paths: [], unknown: false };
|
|
3083
|
+
return { paths: [], unknown: true, explicitMutation: true };
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
function simpleLiteral(raw: string): string | null {
|
|
3087
|
+
const trimmed = raw.trim();
|
|
3088
|
+
const quote = trimmed[0];
|
|
3089
|
+
if ((quote !== "'" && quote !== '"') || trimmed.length < 2 || trimmed[trimmed.length - 1] !== quote) return null;
|
|
3090
|
+
const body = trimmed.slice(1, -1);
|
|
3091
|
+
return /[\\\n${]/.test(body) || body.includes(quote) ? null : body;
|
|
3092
|
+
}
|
|
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
|
+
|
|
3100
|
+
function skipNoise(code: string, index: number, language: "py" | "js"): number {
|
|
3101
|
+
if (language === "py" && (code.startsWith('"""', index) || code.startsWith("'''", index))) {
|
|
3102
|
+
const quote = code.slice(index, index + 3);
|
|
3103
|
+
const end = code.indexOf(quote, index + 3);
|
|
3104
|
+
return end < 0 ? code.length : end + 3;
|
|
3105
|
+
}
|
|
3106
|
+
const character = code[index] ?? "";
|
|
3107
|
+
if (character === "'" || character === '"') {
|
|
3108
|
+
let cursor = index + 1;
|
|
3109
|
+
while (cursor < code.length && code[cursor] !== character) cursor += code[cursor] === "\\" ? 2 : 1;
|
|
3110
|
+
return Math.min(code.length, cursor + 1);
|
|
3111
|
+
}
|
|
3112
|
+
if (
|
|
3113
|
+
(language === "py" && character === "#") ||
|
|
3114
|
+
(language === "js" && character === "/" && code[index + 1] === "/")
|
|
3115
|
+
) {
|
|
3116
|
+
const end = code.indexOf("\n", index);
|
|
3117
|
+
return end < 0 ? code.length : end;
|
|
3118
|
+
}
|
|
3119
|
+
if (language === "js" && character === "/" && code[index + 1] === "*") {
|
|
3120
|
+
const end = code.indexOf("*/", index + 2);
|
|
3121
|
+
return end < 0 ? code.length : end + 2;
|
|
3122
|
+
}
|
|
3123
|
+
return index;
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
function readArg(code: string, index: number): { raw: string; end: number } | null {
|
|
3127
|
+
let cursor = index;
|
|
3128
|
+
while (/\s/.test(code[cursor] ?? "")) cursor += 1;
|
|
3129
|
+
if (cursor >= code.length || code[cursor] === ")") return null;
|
|
3130
|
+
const start = cursor;
|
|
3131
|
+
let depth = 0;
|
|
3132
|
+
while (cursor < code.length) {
|
|
3133
|
+
const character = code[cursor];
|
|
3134
|
+
if (character === "(") depth += 1;
|
|
3135
|
+
else if ((character === "," || character === ")") && depth === 0) break;
|
|
3136
|
+
else if (character === ")") depth -= 1;
|
|
3137
|
+
else if (character === "'" || character === '"') {
|
|
3138
|
+
const quote = character;
|
|
3139
|
+
cursor += 1;
|
|
3140
|
+
while (cursor < code.length && code[cursor] !== quote) cursor += code[cursor] === "\\" ? 2 : 1;
|
|
3141
|
+
}
|
|
3142
|
+
cursor += 1;
|
|
3143
|
+
}
|
|
3144
|
+
const raw = code.slice(start, cursor).trim();
|
|
3145
|
+
return raw.length > 0 ? { raw, end: cursor } : null;
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
/** Omitted mode is Python's default `"r"`. A non-literal mode cannot be proved read-only. */
|
|
3149
|
+
function modeWrites(raw: string | undefined): boolean | null {
|
|
3150
|
+
if (raw === undefined) return false;
|
|
3151
|
+
const literal = simpleLiteral(raw);
|
|
3152
|
+
return literal === null ? null : /[wax+]/.test(literal);
|
|
3153
|
+
}
|
|
3154
|
+
|
|
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
|
+
);
|
|
3160
|
+
/** Copy and rename write the destination, not only the source. */
|
|
3161
|
+
const TWO_ARG_MUTATORS = new Set(
|
|
3162
|
+
"rename replace move copy copy2 copyfile symlink link renameSync copyFileSync".split(" "),
|
|
3163
|
+
);
|
|
3164
|
+
const JS_FS_MUTATORS = new Set(
|
|
3165
|
+
"writeFileSync writeFile appendFileSync appendFile rmSync unlinkSync renameSync copyFileSync mkdirSync openSync".split(
|
|
3166
|
+
" ",
|
|
3167
|
+
),
|
|
3168
|
+
);
|
|
3169
|
+
|
|
3170
|
+
function nextCallArg(code: string, end: number): { raw: string; end: number } | null {
|
|
3171
|
+
let cursor = end;
|
|
3172
|
+
while (/\s/.test(code[cursor] ?? "")) cursor += 1;
|
|
3173
|
+
if (code[cursor] !== ",") return null;
|
|
3174
|
+
return readArg(code, cursor + 1);
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
function openModeWrites(code: string, first: { raw: string; end: number }): boolean | null {
|
|
3178
|
+
let cursor = first.end;
|
|
3179
|
+
let positionalIndex = 1;
|
|
3180
|
+
let mode: string | undefined;
|
|
3181
|
+
let unknownMode = false;
|
|
3182
|
+
while (cursor < code.length) {
|
|
3183
|
+
while (/\s/.test(code[cursor] ?? "")) cursor += 1;
|
|
3184
|
+
if (code[cursor] !== ",") break;
|
|
3185
|
+
const argument = readArg(code, cursor + 1);
|
|
3186
|
+
if (!argument) break;
|
|
3187
|
+
const raw = argument.raw.trim();
|
|
3188
|
+
if (raw.startsWith("*") || raw.startsWith("**")) {
|
|
3189
|
+
unknownMode = true;
|
|
3190
|
+
} else {
|
|
3191
|
+
const keyword = /^([A-Za-z_]\w*)\s*=\s*([\s\S]*)$/.exec(raw);
|
|
3192
|
+
if (keyword?.[1] === "mode") {
|
|
3193
|
+
if (mode !== undefined) unknownMode = true;
|
|
3194
|
+
else mode = keyword[2];
|
|
3195
|
+
} else if (!keyword && positionalIndex === 1) {
|
|
3196
|
+
if (mode !== undefined) unknownMode = true;
|
|
3197
|
+
else mode = raw;
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
positionalIndex += 1;
|
|
3201
|
+
cursor = argument.end;
|
|
3202
|
+
}
|
|
3203
|
+
if (unknownMode) return null;
|
|
3204
|
+
return modeWrites(mode);
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
/** Whitespace or a backslash-newline may sit before an attribute (`shutil .copy`). */
|
|
3208
|
+
function skipCallGap(code: string, index: number): number {
|
|
3209
|
+
let cursor = index;
|
|
3210
|
+
while (cursor < code.length) {
|
|
3211
|
+
const character = code[cursor] ?? "";
|
|
3212
|
+
if (/\s/.test(character)) {
|
|
3213
|
+
cursor += 1;
|
|
3214
|
+
continue;
|
|
3215
|
+
}
|
|
3216
|
+
if (character === "\\" && code[cursor + 1] === "\n") {
|
|
3217
|
+
cursor += 2;
|
|
3218
|
+
continue;
|
|
3219
|
+
}
|
|
3220
|
+
if (character === "\\" && code[cursor + 1] === "\r" && code[cursor + 2] === "\n") {
|
|
3221
|
+
cursor += 3;
|
|
3222
|
+
continue;
|
|
3223
|
+
}
|
|
3224
|
+
break;
|
|
3225
|
+
}
|
|
3226
|
+
return cursor;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
function literalWrite(targets: ExtractedTargets, raw: string | undefined, write: boolean | null): void {
|
|
3230
|
+
if (write === false) return;
|
|
3231
|
+
targets.explicitMutation = true;
|
|
3232
|
+
const literal = raw === undefined ? null : simpleLiteral(raw);
|
|
3233
|
+
if (write === null || literal === null || literal.length === 0) {
|
|
3234
|
+
targets.unknown = true;
|
|
3235
|
+
return;
|
|
3236
|
+
}
|
|
3237
|
+
addPath(targets, literal);
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
/** Arguments of one Path() call, through its own closing parenthesis. */
|
|
3241
|
+
function readPathConstructor(code: string, argAt: number): { parts: string[]; after: number } | null {
|
|
3242
|
+
let cursor = argAt;
|
|
3243
|
+
const parts: string[] = [];
|
|
3244
|
+
while (cursor < code.length) {
|
|
3245
|
+
while (/\s/.test(code[cursor] ?? "")) cursor += 1;
|
|
3246
|
+
if (code[cursor] === ")") return { parts, after: cursor + 1 };
|
|
3247
|
+
const argument = readArg(code, cursor);
|
|
3248
|
+
if (!argument) return null;
|
|
3249
|
+
parts.push(argument.raw);
|
|
3250
|
+
cursor = argument.end;
|
|
3251
|
+
while (/\s/.test(code[cursor] ?? "")) cursor += 1;
|
|
3252
|
+
if (code[cursor] === ",") {
|
|
3253
|
+
cursor += 1;
|
|
3254
|
+
continue;
|
|
3255
|
+
}
|
|
3256
|
+
if (code[cursor] === ")") return { parts, after: cursor + 1 };
|
|
3257
|
+
return null;
|
|
3258
|
+
}
|
|
3259
|
+
return null;
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
/** pathlib keeps the last absolute segment. A non-literal segment is an unresolved receiver. */
|
|
3263
|
+
function literalPathReceiver(parts: string[]): string | null {
|
|
3264
|
+
if (parts.length === 0) return ".";
|
|
3265
|
+
let joined = "";
|
|
3266
|
+
for (const part of parts) {
|
|
3267
|
+
const literal = simpleLiteral(part);
|
|
3268
|
+
if (literal === null) return null;
|
|
3269
|
+
if (path.isAbsolute(literal)) joined = literal;
|
|
3270
|
+
else joined = joined ? path.join(joined, literal) : literal;
|
|
3271
|
+
}
|
|
3272
|
+
return joined.length === 0 ? "." : joined;
|
|
3273
|
+
}
|
|
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
|
+
|
|
3317
|
+
/** Calls inside strings or comments are skipped. A non-literal write path fails closed. */
|
|
3318
|
+
function scanInterpreterWrites(code: string, language: "py" | "js", targets: ExtractedTargets): void {
|
|
3319
|
+
let index = 0;
|
|
3320
|
+
const recognizedPathMethodIndexes = new Set<number>();
|
|
3321
|
+
while (index < code.length) {
|
|
3322
|
+
const trivia = skipNoise(code, index, language);
|
|
3323
|
+
if (trivia !== index) {
|
|
3324
|
+
if (language === "py" && pythonFStringContainsDynamicExecution(code, index, trivia)) {
|
|
3325
|
+
targets.explicitMutation = true;
|
|
3326
|
+
targets.unknown = true;
|
|
3327
|
+
return;
|
|
3328
|
+
}
|
|
3329
|
+
index = trivia;
|
|
3330
|
+
continue;
|
|
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
|
+
}
|
|
3340
|
+
const previous = index === 0 ? "" : (code[index - 1] ?? "");
|
|
3341
|
+
if ((index > 0 && /[\w]/.test(previous)) || !/[A-Za-z_]/.test(code[index] ?? "")) {
|
|
3342
|
+
index += 1;
|
|
3343
|
+
continue;
|
|
3344
|
+
}
|
|
3345
|
+
const ident = /^[A-Za-z_]\w*/.exec(code.slice(index));
|
|
3346
|
+
if (!ident) {
|
|
3347
|
+
index += 1;
|
|
3348
|
+
continue;
|
|
3349
|
+
}
|
|
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
|
+
}
|
|
3374
|
+
if ((name === "write" || name === "append") && previous === ".") {
|
|
3375
|
+
index += name.length;
|
|
3376
|
+
continue;
|
|
3377
|
+
}
|
|
3378
|
+
let cursor = skipCallGap(code, index + name.length);
|
|
3379
|
+
let dotted = "";
|
|
3380
|
+
if (code[cursor] === ".") {
|
|
3381
|
+
const methodAt = skipCallGap(code, cursor + 1);
|
|
3382
|
+
const dot = /^\w+/.exec(code.slice(methodAt));
|
|
3383
|
+
dotted = dot?.[0] ?? "";
|
|
3384
|
+
cursor = methodAt + dotted.length;
|
|
3385
|
+
}
|
|
3386
|
+
const paren = /^\s*\(/.exec(code.slice(cursor));
|
|
3387
|
+
const helper = (name === "write" || name === "append") && dotted === "";
|
|
3388
|
+
const open = (name === "open" && language === "py") || (name === "openSync" && language === "js");
|
|
3389
|
+
const pathCtor =
|
|
3390
|
+
(name === "Path" && dotted === "") || (language === "py" && name === "pathlib" && dotted === "Path");
|
|
3391
|
+
const osCall =
|
|
3392
|
+
language === "py" &&
|
|
3393
|
+
((name === "os" && PY_OS_MUTATORS.has(dotted)) || (name === "shutil" && PY_OS_MUTATORS.has(dotted)));
|
|
3394
|
+
const jsCall =
|
|
3395
|
+
language === "js" && ((JS_FS_MUTATORS.has(name) && dotted === "") || (name === "Bun" && dotted === "write"));
|
|
3396
|
+
if (!paren || (!helper && !(open && dotted === "") && !pathCtor && !osCall && !jsCall)) {
|
|
3397
|
+
index += name.length;
|
|
3398
|
+
continue;
|
|
3399
|
+
}
|
|
3400
|
+
const argAt = cursor + paren[0].length;
|
|
3401
|
+
const first = readArg(code, argAt);
|
|
3402
|
+
if (pathCtor) {
|
|
3403
|
+
const ctor = readPathConstructor(code, argAt);
|
|
3404
|
+
if (!ctor) {
|
|
3405
|
+
index += 1;
|
|
3406
|
+
continue;
|
|
3407
|
+
}
|
|
3408
|
+
const method = /^\s*\.\s*(write_\w+|open|touch)\s*\(/.exec(code.slice(ctor.after));
|
|
3409
|
+
if (method) {
|
|
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
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
// Extra segments stay in the argument walk so a nested mutator is still recorded.
|
|
3422
|
+
index =
|
|
3423
|
+
ctor.parts.length > 1 ? index + name.length : Math.max(ctor.after + (method?.[0].length ?? 0), index + 1);
|
|
3424
|
+
continue;
|
|
3425
|
+
}
|
|
3426
|
+
if (!first) {
|
|
3427
|
+
literalWrite(targets, undefined, null);
|
|
3428
|
+
index = Math.max(argAt, index + 1);
|
|
3429
|
+
continue;
|
|
3430
|
+
}
|
|
3431
|
+
if (open && dotted === "") {
|
|
3432
|
+
literalWrite(targets, first.raw, openModeWrites(code, first));
|
|
3433
|
+
index = Math.max(first.end, index + 1);
|
|
3434
|
+
continue;
|
|
3435
|
+
}
|
|
3436
|
+
literalWrite(targets, first.raw, true);
|
|
3437
|
+
let end = first.end;
|
|
3438
|
+
if (TWO_ARG_MUTATORS.has(dotted || name)) {
|
|
3439
|
+
const second = nextCallArg(code, first.end);
|
|
3440
|
+
if (!second) literalWrite(targets, undefined, null);
|
|
3441
|
+
else {
|
|
3442
|
+
literalWrite(targets, second.raw, true);
|
|
3443
|
+
end = second.end;
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
index = Math.max(end, index + 1);
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
function extractCodeToolTargets(toolName: string, args: unknown): ExtractedTargets {
|
|
3451
|
+
const targets: ExtractedTargets = { paths: [], unknown: false };
|
|
3452
|
+
const record = getRecord(args);
|
|
3453
|
+
if (!record) {
|
|
3454
|
+
targets.unknown = true;
|
|
3455
|
+
targets.explicitMutation = true;
|
|
3456
|
+
return targets;
|
|
3457
|
+
}
|
|
3458
|
+
if (toolName === "python") {
|
|
3459
|
+
if ((safeString(record.action) || "execute") === "clear") return targets;
|
|
3460
|
+
if (typeof record.code !== "string" || record.code.trim() === "") return targets;
|
|
3461
|
+
scanInterpreterWrites(record.code, "py", targets);
|
|
3462
|
+
return targets;
|
|
3463
|
+
}
|
|
3464
|
+
if (!Array.isArray(record.cells)) {
|
|
3465
|
+
targets.unknown = true;
|
|
3466
|
+
targets.explicitMutation = true;
|
|
3467
|
+
return targets;
|
|
3468
|
+
}
|
|
3469
|
+
for (const cell of record.cells) {
|
|
3470
|
+
const cellRecord = getRecord(cell);
|
|
3471
|
+
if (typeof cellRecord?.code !== "string") {
|
|
3472
|
+
targets.unknown = true;
|
|
3473
|
+
targets.explicitMutation = true;
|
|
3474
|
+
continue;
|
|
3475
|
+
}
|
|
3476
|
+
if (cellRecord.language === "py") scanInterpreterWrites(cellRecord.code, "py", targets);
|
|
3477
|
+
else if (cellRecord.language === "js") scanInterpreterWrites(cellRecord.code, "js", targets);
|
|
3478
|
+
else {
|
|
3479
|
+
targets.unknown = true;
|
|
3480
|
+
targets.explicitMutation = true;
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
return targets;
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
function extractTargets(tool: ToolWithEditMode, args: unknown): ExtractedTargets {
|
|
3487
|
+
if (tool.name === "write") return extractWriteTargets(args);
|
|
3488
|
+
if (tool.name === "ast_edit") return extractAstEditTargets(args);
|
|
3489
|
+
if (tool.name === "edit") return extractEditTargets(args, tool);
|
|
3490
|
+
if (tool.name === "bash") return extractBashTargets(args);
|
|
3491
|
+
if (tool.name === "github") return extractGithubTargets(args);
|
|
3492
|
+
if (tool.name === "python" || tool.name === "eval") return extractCodeToolTargets(tool.name, args);
|
|
3493
|
+
return { paths: [], unknown: true };
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
function stripSelectorBase(rawPath: string): string {
|
|
3497
|
+
const archiveOrSqlite = rawPath.match(ARCHIVE_OR_SQLITE_BASE_RE);
|
|
3498
|
+
if (archiveOrSqlite?.[1]) return archiveOrSqlite[1];
|
|
3499
|
+
return rawPath;
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
function resolveRawPath(cwd: string, rawPath: string): { absolutePath?: string; unknown: boolean } {
|
|
3503
|
+
const normalized = rawPath.trim();
|
|
3504
|
+
if (!normalized) return { unknown: true };
|
|
3505
|
+
if (normalized === ".") return { absolutePath: path.resolve(cwd), unknown: false };
|
|
3506
|
+
if (normalized.startsWith("local://") || normalized.startsWith("local:/")) {
|
|
3507
|
+
const options = LocalProtocolHandler.resolveOptions();
|
|
3508
|
+
if (!options) return { unknown: true };
|
|
3509
|
+
try {
|
|
3510
|
+
return { absolutePath: resolveLocalUrlToPath(normalized, options), unknown: false };
|
|
3511
|
+
} catch {
|
|
3512
|
+
return { unknown: true };
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3515
|
+
if (INTERNAL_SCHEME_RE.test(normalized)) return { unknown: true };
|
|
3516
|
+
|
|
3517
|
+
const basePath = stripSelectorBase(normalized);
|
|
3518
|
+
try {
|
|
3519
|
+
return { absolutePath: resolveToCwd(basePath, cwd), unknown: false };
|
|
3520
|
+
} catch {
|
|
3521
|
+
return { unknown: true };
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
function relativeGjcSegments(cwd: string, rawPath: string): string[] | null {
|
|
3526
|
+
const { absolutePath, unknown } = resolveRawPath(cwd, rawPath);
|
|
3527
|
+
if (unknown || !absolutePath) return null;
|
|
3528
|
+
const relative = path.relative(path.resolve(cwd), path.resolve(absolutePath));
|
|
3529
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null;
|
|
3530
|
+
return normalizePosix(relative).split("/").filter(Boolean);
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
function blockedWorkflowStateSkill(cwd: string, rawPath: string): CanonicalGjcWorkflowSkill | null {
|
|
1748
3534
|
const segments = relativeGjcSegments(cwd, rawPath);
|
|
1749
3535
|
if (segments?.[0] !== ".gjc") return null;
|
|
1750
3536
|
const generatedRoot = segments[1]?.startsWith(GJC_SESSION_PREFIX) ? segments[2] : segments[1];
|
|
@@ -1766,11 +3552,23 @@ function firstBlockedWorkflowStateSkill(cwd: string, targets: ExtractedTargets):
|
|
|
1766
3552
|
return null;
|
|
1767
3553
|
}
|
|
1768
3554
|
|
|
1769
|
-
function
|
|
3555
|
+
function planningSessionDirName(sessionId: string | null | undefined): string | undefined {
|
|
3556
|
+
const trimmed = sessionId?.trim();
|
|
3557
|
+
if (!trimmed) return undefined;
|
|
3558
|
+
return sessionDirName(trimmed);
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
/** Specs and plans are planning-writable. A `_session-` directory must be this session's encoded name. */
|
|
3562
|
+
function isAllowlistedPath(cwd: string, rawPath: string, sessionId?: string | null): boolean {
|
|
1770
3563
|
const segments = relativeGjcSegments(cwd, rawPath);
|
|
1771
3564
|
if (segments?.[0] !== ".gjc") return false;
|
|
1772
|
-
const
|
|
1773
|
-
|
|
3565
|
+
const head = segments[1];
|
|
3566
|
+
if (head?.startsWith(GJC_SESSION_PREFIX)) {
|
|
3567
|
+
const bound = planningSessionDirName(sessionId);
|
|
3568
|
+
if (!bound || head !== bound) return false;
|
|
3569
|
+
return segments[2] === "specs" || segments[2] === "plans";
|
|
3570
|
+
}
|
|
3571
|
+
return head === "specs" || head === "plans";
|
|
1774
3572
|
}
|
|
1775
3573
|
function isBlockedGjcPath(cwd: string, rawPath: string): boolean {
|
|
1776
3574
|
const segments = relativeGjcSegments(cwd, rawPath);
|
|
@@ -1781,21 +3579,119 @@ function hasBlockedGjcTarget(cwd: string, targets: ExtractedTargets): boolean {
|
|
|
1781
3579
|
return targets.paths.some(rawPath => isBlockedGjcPath(cwd, rawPath));
|
|
1782
3580
|
}
|
|
1783
3581
|
|
|
1784
|
-
function allTargetsAllowlisted(cwd: string, targets: ExtractedTargets): boolean {
|
|
3582
|
+
function allTargetsAllowlisted(cwd: string, targets: ExtractedTargets, sessionId?: string | null): boolean {
|
|
1785
3583
|
return (
|
|
1786
|
-
!targets.unknown &&
|
|
3584
|
+
!targets.unknown &&
|
|
3585
|
+
targets.paths.length > 0 &&
|
|
3586
|
+
targets.paths.every(rawPath => isAllowlistedPath(cwd, rawPath, sessionId))
|
|
1787
3587
|
);
|
|
1788
3588
|
}
|
|
1789
3589
|
|
|
1790
|
-
|
|
3590
|
+
/** POSIX directories that are temp roots even when `TMPDIR` is unset or poisoned. */
|
|
3591
|
+
const POSIX_SYSTEM_TEMP_ROOTS = ["/tmp", "/var/tmp", "/private/tmp", "/private/var/tmp"];
|
|
3592
|
+
|
|
3593
|
+
/**
|
|
3594
|
+
* `os.tmpdir()` follows `TMPDIR`, and Bun copies `cwd/.env` into the environment
|
|
3595
|
+
* when that variable was unset. A repository can therefore name an arbitrary
|
|
3596
|
+
* directory. Declarations below are not neutral scratch roots.
|
|
3597
|
+
*/
|
|
3598
|
+
function tempEnvNames(): readonly string[] {
|
|
3599
|
+
return process.platform === "win32" ? ["TEMP", "TMP", "TMPDIR"] : ["TMPDIR"];
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
const PROJECT_TEMP_DOTENV_MAX_FILE_BYTES = 64 * 1024;
|
|
3603
|
+
|
|
3604
|
+
// The snapshot is the cwd at module import, not a directory created later or a chdir before this module is imported.
|
|
3605
|
+
const startupTempSnapshot = projectEnvSnapshot(process.cwd(), {
|
|
3606
|
+
maxFileBytes: PROJECT_TEMP_DOTENV_MAX_FILE_BYTES,
|
|
3607
|
+
});
|
|
3608
|
+
|
|
3609
|
+
function projectTempSnapshots(cwd: string): ProjectEnvSnapshot[] {
|
|
3610
|
+
// Check both the cwd's .env and the startup snapshot (for module-import-time cwd).
|
|
3611
|
+
// The cwd's .env catches project-declared TMPDIR in the current working directory.
|
|
3612
|
+
// The startup snapshot is a fallback for when the cwd's .env is deleted or inaccessible
|
|
3613
|
+
// (e.g., after unlink or chdir), and also handles the case where cwd differs from the
|
|
3614
|
+
// module-import-time cwd. These workspace-controlled files are read as bounded regular files.
|
|
3615
|
+
const snapshots: ProjectEnvSnapshot[] = [
|
|
3616
|
+
projectEnvSnapshot(cwd, { maxFileBytes: PROJECT_TEMP_DOTENV_MAX_FILE_BYTES }),
|
|
3617
|
+
startupTempSnapshot,
|
|
3618
|
+
];
|
|
3619
|
+
return snapshots;
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
/** macOS account temp directory. `getconf` does not read `TMPDIR`. */
|
|
3623
|
+
let darwinUserTempDirCache: string | undefined | null = null;
|
|
3624
|
+
function darwinUserTempDir(): string | undefined {
|
|
3625
|
+
if (process.platform !== "darwin") return undefined;
|
|
3626
|
+
if (darwinUserTempDirCache !== null) return darwinUserTempDirCache;
|
|
3627
|
+
darwinUserTempDirCache = undefined;
|
|
3628
|
+
try {
|
|
3629
|
+
const result = Bun.spawnSync({
|
|
3630
|
+
cmd: ["/usr/bin/getconf", "DARWIN_USER_TEMP_DIR"],
|
|
3631
|
+
env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LC_ALL: "C" },
|
|
3632
|
+
stdout: "pipe",
|
|
3633
|
+
stderr: "ignore",
|
|
3634
|
+
});
|
|
3635
|
+
if (result.exitCode !== 0) return undefined;
|
|
3636
|
+
const value = new TextDecoder().decode(result.stdout).trim();
|
|
3637
|
+
if (value) darwinUserTempDirCache = value;
|
|
3638
|
+
} catch {
|
|
3639
|
+
// Fixed `/tmp` roots still cover the POSIX default.
|
|
3640
|
+
}
|
|
3641
|
+
return darwinUserTempDirCache ?? undefined;
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
function addResolvedTempRoot(roots: Set<string>, value: string | undefined): void {
|
|
3645
|
+
const trimmed = value?.trim();
|
|
3646
|
+
if (trimmed) roots.add(path.resolve(trimmed));
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
function isPlatformTempPath(resolved: string): boolean {
|
|
3650
|
+
for (const root of POSIX_SYSTEM_TEMP_ROOTS) {
|
|
3651
|
+
const resolvedRoot = path.resolve(root);
|
|
3652
|
+
if (resolved === resolvedRoot || isPathWithin(resolvedRoot, resolved)) return true;
|
|
3653
|
+
}
|
|
3654
|
+
const darwin = darwinUserTempDir();
|
|
3655
|
+
if (!darwin) return false;
|
|
3656
|
+
const resolvedDarwin = path.resolve(darwin);
|
|
3657
|
+
return resolved === resolvedDarwin || isPathWithin(resolvedDarwin, resolved);
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
/** True when `live` is declared, or cannot be trusted because a dotenv layer was unsafe to read. */
|
|
3661
|
+
function projectDeclaresTempPath(snapshots: readonly ProjectEnvSnapshot[], live: string): boolean {
|
|
3662
|
+
const trimmedLive = live.trim();
|
|
3663
|
+
if (!trimmedLive) return false;
|
|
3664
|
+
const resolvedLive = path.resolve(trimmedLive);
|
|
3665
|
+
for (const snapshot of snapshots) {
|
|
3666
|
+
if (snapshot.incomplete && !isPlatformTempPath(resolvedLive)) return true;
|
|
3667
|
+
for (const name of tempEnvNames()) {
|
|
3668
|
+
const key = canonicalEnvKey(name);
|
|
3669
|
+
const declared = snapshot.values[key];
|
|
3670
|
+
if (declared === undefined) continue;
|
|
3671
|
+
if (snapshot.dynamic.has(key)) {
|
|
3672
|
+
// Expansion is not comparable. A dynamic value that landed on a
|
|
3673
|
+
// real system temp dir is already allowed through those roots.
|
|
3674
|
+
if (!isPlatformTempPath(resolvedLive)) return true;
|
|
3675
|
+
continue;
|
|
3676
|
+
}
|
|
3677
|
+
const trimmedDeclared = declared.trim();
|
|
3678
|
+
if (trimmedDeclared && path.resolve(trimmedDeclared) === resolvedLive) return true;
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
return false;
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
function neutralTempRoots(cwd: string): string[] {
|
|
1791
3685
|
const roots = new Set<string>();
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
3686
|
+
for (const fixed of POSIX_SYSTEM_TEMP_ROOTS) addResolvedTempRoot(roots, fixed);
|
|
3687
|
+
addResolvedTempRoot(roots, darwinUserTempDir());
|
|
3688
|
+
const candidates = [os.tmpdir(), process.env.TMPDIR];
|
|
3689
|
+
if (process.platform === "win32") candidates.push(process.env.TEMP, process.env.TMP);
|
|
3690
|
+
const snapshots = projectTempSnapshots(cwd);
|
|
3691
|
+
for (const candidate of candidates) {
|
|
3692
|
+
if (!candidate || projectDeclaresTempPath(snapshots, candidate)) continue;
|
|
3693
|
+
addResolvedTempRoot(roots, candidate);
|
|
3694
|
+
}
|
|
1799
3695
|
return [...roots];
|
|
1800
3696
|
}
|
|
1801
3697
|
|
|
@@ -1849,18 +3745,44 @@ async function isNeutralTempPath(cwd: string, rawPath: string): Promise<boolean>
|
|
|
1849
3745
|
if (unknown || !absolutePath) return false;
|
|
1850
3746
|
const resolvedCwd = path.resolve(cwd);
|
|
1851
3747
|
if (isPathWithin(resolvedCwd, absolutePath)) return false;
|
|
1852
|
-
|
|
3748
|
+
const roots = neutralTempRoots(cwd);
|
|
3749
|
+
const realRoots = await Promise.all(roots.map(realpathOrSelf));
|
|
3750
|
+
// Accept both spellings (`/var/folders` and `/private/var/folders`) before the
|
|
3751
|
+
// canonical re-check. The re-check still rejects a temp path whose real
|
|
3752
|
+
// target is inside the project.
|
|
3753
|
+
const lexicalRoots = new Set([...roots, ...realRoots]);
|
|
3754
|
+
if (![...lexicalRoots].some(root => isPathWithin(root, absolutePath))) return false;
|
|
1853
3755
|
const realTarget = await canonicalizeForContainment(absolutePath);
|
|
1854
3756
|
if (isPathWithin(await realpathOrSelf(resolvedCwd), realTarget)) return false;
|
|
1855
|
-
const realRoots = await Promise.all(neutralTempRoots().map(realpathOrSelf));
|
|
1856
3757
|
return realRoots.some(root => isPathWithin(root, realTarget));
|
|
1857
3758
|
}
|
|
1858
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
|
+
|
|
1859
3773
|
/** Targets that remain disallowed during a planning phase (excludes neutral temp scratch). */
|
|
1860
|
-
async function planningBlockedTargets(
|
|
3774
|
+
async function planningBlockedTargets(
|
|
3775
|
+
cwd: string,
|
|
3776
|
+
targets: ExtractedTargets,
|
|
3777
|
+
sessionId?: string | null,
|
|
3778
|
+
): Promise<string[]> {
|
|
1861
3779
|
const blocked: string[] = [];
|
|
1862
3780
|
for (const rawPath of targets.paths) {
|
|
1863
|
-
if (
|
|
3781
|
+
if (!(await hasUnsharedFileTarget(cwd, rawPath))) {
|
|
3782
|
+
blocked.push(rawPath);
|
|
3783
|
+
continue;
|
|
3784
|
+
}
|
|
3785
|
+
if (isAllowlistedPath(cwd, rawPath, sessionId)) continue;
|
|
1864
3786
|
if (!(await isNeutralTempPath(cwd, rawPath))) blocked.push(rawPath);
|
|
1865
3787
|
}
|
|
1866
3788
|
return blocked;
|
|
@@ -1882,12 +3804,15 @@ export async function assertWorkflowMutationRawPathsAllowed(input: {
|
|
|
1882
3804
|
throw new ToolError(`${WORKFLOW_STATE_MUTATION_BLOCK_MESSAGE}\nUse: ${command}`);
|
|
1883
3805
|
}
|
|
1884
3806
|
if (input.forceOverride) return;
|
|
1885
|
-
const
|
|
3807
|
+
const guardContext =
|
|
3808
|
+
input.guardContext ??
|
|
3809
|
+
(await readWorkflowGuardContext(input.cwd, { sessionId: input.sessionId, threadId: input.threadId }));
|
|
3810
|
+
const planning = await getActivePlanningSkill(input.cwd, input.sessionId, input.threadId, guardContext);
|
|
1886
3811
|
|
|
1887
3812
|
if (!planning) return;
|
|
1888
3813
|
const message = planningPhaseBlockMessage(planning.skill);
|
|
1889
3814
|
if (input.rawPaths.length === 0) throw new ToolError(message);
|
|
1890
|
-
const blocked = await planningBlockedTargets(input.cwd, targets);
|
|
3815
|
+
const blocked = await planningBlockedTargets(input.cwd, targets, guardContext.sessionId);
|
|
1891
3816
|
if (blocked.length > 0) throw new ToolError(message);
|
|
1892
3817
|
}
|
|
1893
3818
|
|
|
@@ -1907,7 +3832,10 @@ export async function getWorkflowMutationDecision(
|
|
|
1907
3832
|
command,
|
|
1908
3833
|
};
|
|
1909
3834
|
}
|
|
1910
|
-
const
|
|
3835
|
+
const guardContext =
|
|
3836
|
+
input.guardContext ??
|
|
3837
|
+
(await readWorkflowGuardContext(input.cwd, { sessionId: input.sessionId, threadId: input.threadId }));
|
|
3838
|
+
const planning = await getActivePlanningSkill(input.cwd, input.sessionId, input.threadId, guardContext);
|
|
1911
3839
|
if (!planning) {
|
|
1912
3840
|
return { blocked: false, targets: [] };
|
|
1913
3841
|
}
|
|
@@ -1919,15 +3847,24 @@ export async function getWorkflowMutationDecision(
|
|
|
1919
3847
|
// name is not authorization: an `autoresearch/*` branch isolates keep/discard
|
|
1920
3848
|
// bookkeeping, it does not turn product edits into research.
|
|
1921
3849
|
//
|
|
3850
|
+
// Shell and code-execution tools can run multiple operations before writing,
|
|
3851
|
+
// so checking the harness once cannot protect the later write destination.
|
|
3852
|
+
// Only direct file-mutation tools may use the harness exception.
|
|
1922
3853
|
// The allowance requires at least one named target: `every` is vacuously true
|
|
1923
3854
|
// for an empty list, so an opaque mutation that resolves to NO target (e.g.
|
|
1924
3855
|
// `python3 -c "open('src/product.ts','w').write('x')"`) would otherwise skip
|
|
1925
3856
|
// the fail-closed check below and be waved through on a research mission.
|
|
3857
|
+
// Bash is a compound command list, not a write boundary: an earlier `ln` or
|
|
3858
|
+
// `mv` can retarget the harness before a later redirection. Harness changes
|
|
3859
|
+
// must use a single-target file tool so the final path is checked for symlinks
|
|
3860
|
+
// immediately before that write operation.
|
|
1926
3861
|
if (
|
|
1927
3862
|
planning.skill === "autoresearch" &&
|
|
3863
|
+
AUTORESEARCH_HARNESS_FILE_TOOL_NAMES.has(input.tool.name) &&
|
|
1928
3864
|
targets.paths.length > 0 &&
|
|
1929
3865
|
!targets.unknown &&
|
|
1930
|
-
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)
|
|
1931
3868
|
) {
|
|
1932
3869
|
return { blocked: false, targets: targets.paths };
|
|
1933
3870
|
}
|
|
@@ -1946,7 +3883,7 @@ export async function getWorkflowMutationDecision(
|
|
|
1946
3883
|
// Neutral temp scratch (outside the project tree) stays writable so agents can
|
|
1947
3884
|
// stage artifacts and feed their path to the sanctioned `gjc ... --write` CLIs.
|
|
1948
3885
|
// Read-only / `gjc` bash extract no targets and fall through to allowed here.
|
|
1949
|
-
const blockedTargets = await planningBlockedTargets(input.cwd, targets);
|
|
3886
|
+
const blockedTargets = await planningBlockedTargets(input.cwd, targets, guardContext.sessionId);
|
|
1950
3887
|
if (blockedTargets.length === 0) {
|
|
1951
3888
|
return { blocked: false, targets: targets.paths };
|
|
1952
3889
|
}
|
|
@@ -1954,7 +3891,9 @@ export async function getWorkflowMutationDecision(
|
|
|
1954
3891
|
blocked: true,
|
|
1955
3892
|
message,
|
|
1956
3893
|
targets: targets.paths,
|
|
1957
|
-
reason: allTargetsAllowlisted(input.cwd, targets)
|
|
3894
|
+
reason: allTargetsAllowlisted(input.cwd, targets, guardContext.sessionId)
|
|
3895
|
+
? "handoff-artifact-tool-target"
|
|
3896
|
+
: "phase-boundary",
|
|
1958
3897
|
};
|
|
1959
3898
|
}
|
|
1960
3899
|
|