@gajae-code/coding-agent 0.11.11 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +131 -4
- package/dist/types/async/job-manager.d.ts +17 -3
- package/dist/types/cli/read-cli.d.ts +1 -0
- package/dist/types/commands/deep-interview.d.ts +9 -0
- package/dist/types/commands/harness.d.ts +22 -0
- package/dist/types/commands/read.d.ts +6 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/provider-auth-health.d.ts +14 -0
- package/dist/types/config/provider-ranking.d.ts +59 -0
- package/dist/types/config/settings-schema.d.ts +83 -1
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/exa/mcp-client.d.ts +10 -1
- package/dist/types/exec/bash-executor.d.ts +45 -12
- package/dist/types/extensibility/extensions/types.d.ts +38 -4
- package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
- package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
- package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
- package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
- package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
- package/dist/types/extensibility/shared-events.d.ts +6 -0
- package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
- package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
- package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +70 -1
- package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
- package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +27 -1
- package/dist/types/gjc-runtime/team-store.d.ts +12 -0
- package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
- package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +17 -0
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
- package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
- package/dist/types/main.d.ts +1 -1
- package/dist/types/memories/index.d.ts +2 -0
- package/dist/types/modes/acp/acp-agent.d.ts +10 -19
- package/dist/types/modes/components/assistant-message.d.ts +3 -1
- package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
- package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
- package/dist/types/modes/components/read-tool-group.d.ts +4 -4
- package/dist/types/modes/components/session-observer-overlay.d.ts +22 -0
- package/dist/types/modes/components/settings-selector.d.ts +11 -0
- package/dist/types/modes/components/status-line/gh.d.ts +5 -0
- package/dist/types/modes/components/tool-execution.d.ts +8 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
- package/dist/types/modes/controllers/event-controller.d.ts +3 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
- package/dist/types/reminders/star-reminder.d.ts +3 -13
- package/dist/types/runtime-mcp/manager.d.ts +1 -0
- package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
- package/dist/types/sdk/acp/adapter.d.ts +3 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +5 -0
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
- package/dist/types/sdk/bus/config.d.ts +6 -2
- package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
- package/dist/types/sdk/bus/index.d.ts +44 -1
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +44 -0
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
- package/dist/types/sdk/bus/reconciliation-store.d.ts +69 -0
- package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
- package/dist/types/sdk/bus/rich-render.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
- package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
- package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
- package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
- package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/sdk/host/query/handlers.d.ts +5 -0
- package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
- package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
- package/dist/types/sdk/prompt-status.d.ts +23 -3
- package/dist/types/sdk/session.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +82 -12
- package/dist/types/session/artifacts.d.ts +8 -0
- package/dist/types/session/blob-store.d.ts +57 -4
- package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
- package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
- package/dist/types/session/session-manager.d.ts +48 -19
- package/dist/types/session/session-storage.d.ts +4 -0
- package/dist/types/session/streaming-output.d.ts +96 -12
- package/dist/types/setup/credential-import.d.ts +12 -0
- package/dist/types/skill-state/active-state.d.ts +12 -4
- package/dist/types/skill-state/workflow-hud.d.ts +9 -0
- package/dist/types/task/executor.d.ts +3 -2
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/task/provider-retry-status.d.ts +8 -0
- package/dist/types/task/receipt.d.ts +7 -1
- package/dist/types/task/types.d.ts +23 -0
- package/dist/types/tools/ask-answer-registry.d.ts +2 -2
- package/dist/types/tools/bash-interactive.d.ts +4 -1
- package/dist/types/tools/bash.d.ts +3 -1
- package/dist/types/tools/browser/launch.d.ts +7 -0
- package/dist/types/tools/fetch.d.ts +17 -1
- package/dist/types/tools/image-gen.d.ts +4 -0
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/output-meta.d.ts +44 -3
- package/dist/types/tools/path-utils.d.ts +1 -0
- package/dist/types/tools/read-internals.d.ts +12 -0
- package/dist/types/tools/read.d.ts +45 -7
- package/dist/types/tools/resource-gc.d.ts +4 -0
- package/dist/types/tools/review.d.ts +5 -7
- package/dist/types/tools/subagent.d.ts +2 -0
- package/dist/types/tools/tool-result.d.ts +7 -1
- package/dist/types/utils/gh.d.ts +13 -0
- package/dist/types/utils/image-resize.d.ts +1 -0
- package/dist/types/web/search/providers/kimi.d.ts +2 -0
- package/dist/types/web/search/providers/searxng.d.ts +7 -0
- package/dist/types/web/search/providers/xai.d.ts +2 -0
- package/package.json +11 -13
- package/scripts/acp-conformance-agent.ts +218 -0
- package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
- package/scripts/capture-gjc-bundle-settings.ts +306 -0
- package/scripts/capture-sticky-viewport-showcase.ts +292 -0
- package/scripts/generate-sdk-operation-inventory.ts +5 -0
- package/scripts/resident-memory-bench.ts +1341 -0
- package/scripts/run-acp-conformance.ts +285 -0
- package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
- package/scripts/verify-sticky-viewport-showcase.ts +611 -0
- package/src/async/job-manager.ts +78 -20
- package/src/capability/index.ts +8 -0
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/plugin-cli.ts +176 -18
- package/src/cli/read-cli.ts +2 -1
- package/src/cli.ts +1 -0
- package/src/commands/deep-interview.ts +29 -9
- package/src/commands/harness.ts +35 -12
- package/src/commands/read.ts +11 -2
- package/src/commands/team.ts +20 -2
- package/src/config/file-lock.ts +11 -2
- package/src/config/model-registry.ts +21 -2
- package/src/config/provider-auth-health.ts +42 -0
- package/src/config/provider-ranking.ts +119 -0
- package/src/config/settings-schema.ts +74 -1
- package/src/config/settings.ts +246 -108
- package/src/coordinator-mcp/question-gate-codec.ts +1 -1
- package/src/coordinator-mcp/server.ts +5 -12
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +230 -82
- package/src/defaults/gjc/skills/ralplan/SKILL.md +77 -41
- package/src/defaults/gjc/skills/team/SKILL.md +3 -1
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +62 -39
- package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
- package/src/defaults/gjc-defaults.ts +4 -4
- package/src/eval/py/executor.ts +7 -2
- package/src/exa/mcp-client.ts +12 -3
- package/src/exec/bash-executor.ts +144 -27
- package/src/extensibility/extensions/runner.ts +15 -0
- package/src/extensibility/extensions/types.ts +46 -5
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
- package/src/extensibility/gjc-plugins/index.ts +19 -2
- package/src/extensibility/gjc-plugins/installer.ts +189 -50
- package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
- package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
- package/src/extensibility/gjc-plugins/observability.ts +3 -3
- package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
- package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
- package/src/extensibility/gjc-plugins/schema.ts +84 -12
- package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
- package/src/extensibility/gjc-plugins/types.ts +141 -0
- package/src/extensibility/gjc-plugins/validation.ts +9 -0
- package/src/extensibility/shared-events.ts +5 -0
- package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -82
- package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
- package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
- package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
- package/src/gjc-runtime/deep-interview-state.ts +12 -943
- package/src/gjc-runtime/gc-render.ts +1 -0
- package/src/gjc-runtime/gc-runtime.ts +11 -1
- package/src/gjc-runtime/launch-tmux.ts +25 -6
- package/src/gjc-runtime/managed-owner-admission.ts +1 -1
- package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
- package/src/gjc-runtime/ralplan-runtime.ts +1149 -98
- package/src/gjc-runtime/state-runtime.ts +2 -1
- package/src/gjc-runtime/state-writer.ts +70 -222
- package/src/gjc-runtime/team-launch.ts +42 -5
- package/src/gjc-runtime/team-runtime.ts +1085 -40
- package/src/gjc-runtime/team-store.ts +32 -0
- package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
- package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
- package/src/gjc-runtime/team-workers.ts +1 -0
- package/src/gjc-runtime/tmux-owner-isolation.ts +28 -0
- package/src/gjc-runtime/tmux-sessions.ts +58 -13
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +414 -618
- package/src/gjc-runtime/workflow-manifest.generated.json +105 -449
- package/src/gjc-runtime/workflow-manifest.ts +38 -247
- package/src/hooks/native-skill-hook.ts +17 -4
- package/src/hooks/skill-state.ts +1 -23
- package/src/internal-urls/docs-index.generated.ts +21 -22
- package/src/internal-urls/local-root-gc.ts +127 -0
- package/src/main.ts +9 -5
- package/src/memories/index.ts +10 -0
- package/src/modes/DESIGN.md +98 -0
- package/src/modes/acp/acp-agent.ts +339 -191
- package/src/modes/acp/acp-event-mapper.ts +14 -7
- package/src/modes/acp/acp-mode.ts +39 -3
- package/src/modes/components/assistant-message.ts +123 -22
- package/src/modes/components/gjc-bundle-settings.ts +508 -0
- package/src/modes/components/irc-sidebar.ts +176 -62
- package/src/modes/components/model-selector.ts +61 -4
- package/src/modes/components/notifications-settings-editor.ts +12 -1
- package/src/modes/components/oauth-selector.ts +96 -28
- package/src/modes/components/read-tool-group.ts +82 -14
- package/src/modes/components/session-observer-overlay.ts +457 -83
- package/src/modes/components/settings-selector.ts +116 -23
- package/src/modes/components/status-line/gh.ts +16 -0
- package/src/modes/components/tool-execution.ts +138 -27
- package/src/modes/components/tool-status-header.ts +3 -14
- package/src/modes/components/transcript-viewer-overlay.ts +99 -24
- package/src/modes/controllers/command-controller.ts +4 -0
- package/src/modes/controllers/event-controller.ts +109 -27
- package/src/modes/controllers/extension-ui-controller.ts +26 -2
- package/src/modes/controllers/input-controller.ts +18 -3
- package/src/modes/controllers/selector-controller.ts +127 -36
- package/src/modes/interactive-mode.ts +62 -3
- package/src/modes/irc-observation-ledger.ts +71 -15
- package/src/modes/runtime-init.ts +22 -2
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +94 -1
- package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
- package/src/prompts/agents/architect.md +9 -1
- package/src/prompts/agents/critic.md +9 -0
- package/src/prompts/system/eager-todo.md +8 -4
- package/src/prompts/system/system-prompt.md +11 -5
- package/src/prompts/tools/bash.md +5 -5
- package/src/prompts/tools/read.md +4 -2
- package/src/prompts/tools/search-tool-bm25.md +5 -0
- package/src/prompts/tools/task.md +1 -1
- package/src/reminders/star-reminder.ts +4 -42
- package/src/runtime-mcp/manager.ts +19 -4
- package/src/runtime-mcp/smithery-auth.ts +17 -2
- package/src/runtime-mcp/smithery-connect.ts +11 -1
- package/src/sdk/acp/adapter.ts +22 -6
- package/src/sdk/broker/lifecycle.ts +25 -6
- package/src/sdk/broker/transport.ts +35 -3
- package/src/sdk/bus/attachment-registry.ts +5 -1
- package/src/sdk/bus/chat-daemon-cli.ts +4 -39
- package/src/sdk/bus/chat-daemon-control.ts +6 -3
- package/src/sdk/bus/config.ts +18 -2
- package/src/sdk/bus/control-drain-lease.ts +104 -0
- package/src/sdk/bus/index.ts +984 -146
- package/src/sdk/bus/kind-aware-reconciliation.ts +317 -0
- package/src/sdk/bus/lifecycle-commands.ts +4 -1
- package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
- package/src/sdk/bus/prompt-reconciliation.ts +2 -1
- package/src/sdk/bus/reconciliation-store.ts +303 -0
- package/src/sdk/bus/rich-draft.ts +1 -1
- package/src/sdk/bus/rich-render.ts +2 -2
- package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
- package/src/sdk/bus/telegram-cli.ts +33 -8
- package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
- package/src/sdk/bus/telegram-daemon.ts +916 -23
- package/src/sdk/bus/telegram-reference.ts +40 -3
- package/src/sdk/bus/threaded-render.ts +48 -3
- package/src/sdk/bus/topic-registry.ts +20 -0
- package/src/sdk/host/control/dispatch.ts +5 -1
- package/src/sdk/host/control/operations.ts +1 -1
- package/src/sdk/host/host.ts +23 -1
- package/src/sdk/host/query/handlers.ts +55 -0
- package/src/sdk/host/query/revision-store.ts +36 -2
- package/src/sdk/host/reverse-leases.ts +44 -7
- package/src/sdk/mcp/server.ts +46 -18
- package/src/sdk/prompt-status.ts +16 -3
- package/src/sdk/protocol/operation-inventory.generated.json +51 -0
- package/src/sdk/protocol/operation-registry.ts +7 -2
- package/src/sdk/session.ts +101 -10
- package/src/session/agent-session.ts +1286 -220
- package/src/session/artifacts.ts +22 -0
- package/src/session/auth-broker-config.ts +10 -3
- package/src/session/blob-store.ts +915 -24
- package/src/session/internal/managed-session-scope.ts +44 -6
- package/src/session/internal/managed-session-storage.ts +48 -6
- package/src/session/memory-guard-checkpoint-participant.ts +131 -0
- package/src/session/session-manager.ts +2364 -438
- package/src/session/session-storage.ts +9 -1
- package/src/session/streaming-output.ts +500 -111
- package/src/setup/credential-import.ts +75 -13
- package/src/setup/provider-onboarding.ts +19 -4
- package/src/skill-state/active-state.ts +32 -10
- package/src/skill-state/workflow-hud.ts +42 -2
- package/src/skill-state/workflow-mutation-guard.ts +560 -8
- package/src/task/executor.ts +122 -39
- package/src/task/index.ts +23 -14
- package/src/task/provider-retry-status.ts +16 -0
- package/src/task/receipt.ts +23 -7
- package/src/task/render.ts +6 -2
- package/src/task/types.ts +108 -0
- package/src/tools/ask-answer-registry.ts +12 -6
- package/src/tools/ask.ts +169 -118
- package/src/tools/ast-edit.ts +1 -0
- package/src/tools/ast-grep.ts +1 -0
- package/src/tools/bash-interactive.ts +8 -3
- package/src/tools/bash.ts +503 -60
- package/src/tools/browser/launch.ts +41 -6
- package/src/tools/fetch.ts +114 -23
- package/src/tools/find.ts +1 -0
- package/src/tools/image-gen.ts +29 -5
- package/src/tools/index.ts +4 -0
- package/src/tools/output-meta.ts +281 -20
- package/src/tools/path-utils.ts +8 -3
- package/src/tools/read-internals.ts +44 -0
- package/src/tools/read.ts +1560 -310
- package/src/tools/resource-gc.ts +182 -18
- package/src/tools/review.ts +5 -7
- package/src/tools/search.ts +1 -0
- package/src/tools/subagent-render.ts +6 -2
- package/src/tools/subagent.ts +6 -0
- package/src/tools/telegram-send.ts +6 -3
- package/src/tools/tool-result.ts +14 -1
- package/src/utils/gh.ts +39 -0
- package/src/utils/image-resize.ts +3 -0
- package/src/web/search/providers/anthropic.ts +4 -4
- package/src/web/search/providers/codex.ts +1 -1
- package/src/web/search/providers/kimi.ts +15 -5
- package/src/web/search/providers/searxng.ts +47 -4
- package/src/web/search/providers/xai.ts +7 -2
- package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
- package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
- package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
- package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
- package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
- package/src/gjc-runtime/deep-interview-draft.ts +0 -843
- package/src/gjc-runtime/deep-interview-payload.ts +0 -262
- package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
- package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
|
@@ -57,6 +57,38 @@ const BASH_OPAQUE_INTERPRETER_WRITE_RE =
|
|
|
57
57
|
const BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE =
|
|
58
58
|
/(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:python3?|node|ruby)\b[^;&|\n]*(?:<<[-]?\s*['"]?\w+['"]?)[\s\S]*(?:open\s*\(|writeFile(?:Sync)?\s*\(|\.write\s*\()/i;
|
|
59
59
|
const BASH_DD_OUTPUT_RE = /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?dd\b([^;&|\n]*)/gi;
|
|
60
|
+
/** Literal `sh|bash|zsh -c '<script>'` payloads whose nested script must also be scanned. */
|
|
61
|
+
const BASH_NESTED_SHELL_RE =
|
|
62
|
+
/(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?(?:ba|z|da)?sh\s+(?:-[A-Za-z]+\s+)*-[A-Za-z]*c\s+(?:(')([^']*)'|(")([^"]*)")/g;
|
|
63
|
+
/** Heredoc opener (`<<`/`<<-` plus an optionally quoted delimiter); `<<<` here-strings excluded. */
|
|
64
|
+
const BASH_HEREDOC_OPEN_RE = /(?<!<)<<(?!<)(-?)\s*(?:'([^'\s]+)'|"([^"\s]+)"|(\\)?([A-Za-z_][\w.-]*))/g;
|
|
65
|
+
/** Consumers whose stdin is inert data: their heredoc bodies are safe to mask. Anything else stays live. */
|
|
66
|
+
const HEREDOC_DATA_CONSUMERS = new Set([
|
|
67
|
+
"cat",
|
|
68
|
+
"tee",
|
|
69
|
+
"head",
|
|
70
|
+
"tail",
|
|
71
|
+
"wc",
|
|
72
|
+
"sort",
|
|
73
|
+
"uniq",
|
|
74
|
+
"tr",
|
|
75
|
+
"cut",
|
|
76
|
+
"grep",
|
|
77
|
+
"column",
|
|
78
|
+
"nl",
|
|
79
|
+
"fold",
|
|
80
|
+
"fmt",
|
|
81
|
+
"base64",
|
|
82
|
+
"md5sum",
|
|
83
|
+
"sha1sum",
|
|
84
|
+
"sha256sum",
|
|
85
|
+
"sha512sum",
|
|
86
|
+
"shasum",
|
|
87
|
+
"jq",
|
|
88
|
+
"yq",
|
|
89
|
+
]);
|
|
90
|
+
/** Stdin appliers whose heredoc body mutates files in ways no body scan can model. */
|
|
91
|
+
const HEREDOC_MUTATING_CONSUMERS = new Set(["patch", "ed", "ex", "sqlite3"]);
|
|
60
92
|
|
|
61
93
|
type ToolWithEditMode = AgentTool & {
|
|
62
94
|
mode?: unknown;
|
|
@@ -77,6 +109,12 @@ export interface WorkflowMutationGuardInput {
|
|
|
77
109
|
interface ExtractedTargets {
|
|
78
110
|
paths: string[];
|
|
79
111
|
unknown: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Set when a bash command was recognized as performing a filesystem write.
|
|
114
|
+
* During a planning phase an unrecognized bash command is allowed (reading
|
|
115
|
+
* and inspection must not be blocked); only a recognized mutation blocks.
|
|
116
|
+
*/
|
|
117
|
+
explicitMutation?: boolean;
|
|
80
118
|
}
|
|
81
119
|
|
|
82
120
|
export interface WorkflowMutationDecision {
|
|
@@ -382,11 +420,485 @@ function cleanShellWord(value: string): string {
|
|
|
382
420
|
return value.replace(/^['"]|['"]$/g, "");
|
|
383
421
|
}
|
|
384
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Blank out single-quoted spans so shell metacharacters inside a literal
|
|
425
|
+
* argument value (e.g. `--value 'uses `x`; a > b'`) are not misread as
|
|
426
|
+
* redirections or separators. Single quotes suppress every expansion in POSIX
|
|
427
|
+
* shells, so their contents are always inert data. Double-quoted spans are left
|
|
428
|
+
* intact because they still expand `$(...)` and backticks.
|
|
429
|
+
*/
|
|
430
|
+
function maskSingleQuotedSpans(command: string): string {
|
|
431
|
+
let masked = "";
|
|
432
|
+
let inSingle = false;
|
|
433
|
+
for (const character of command) {
|
|
434
|
+
if (character === "'") {
|
|
435
|
+
inSingle = !inSingle;
|
|
436
|
+
masked += character;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
masked += inSingle && character !== "\n" ? " " : character;
|
|
440
|
+
}
|
|
441
|
+
// An unbalanced quote means the parse is unreliable; keep the original text
|
|
442
|
+
// so the scanner stays fail-closed rather than blind.
|
|
443
|
+
return inSingle ? command : masked;
|
|
444
|
+
}
|
|
445
|
+
|
|
385
446
|
function isDeviceSinkPath(value: string): boolean {
|
|
386
447
|
return DEVICE_SINK_PATHS.has(cleanShellWord(value));
|
|
387
448
|
}
|
|
388
449
|
|
|
389
|
-
|
|
450
|
+
interface HeredocMaskResult {
|
|
451
|
+
masked: string;
|
|
452
|
+
/** An unquoted-delimiter heredoc body carried `$(…)`/backtick expansion — live code the scanner cannot model. */
|
|
453
|
+
opaqueExpansion: boolean;
|
|
454
|
+
/** The heredoc feeds a stdin applier (patch/ed/ex/sqlite3) whose body mutates files no scan can attribute. */
|
|
455
|
+
mutatingConsumer: boolean;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Quote/substitution state carried ACROSS physical lines so multiline constructs cannot fake heredoc openers. */
|
|
459
|
+
interface ShellQuoteState {
|
|
460
|
+
inSingle: boolean;
|
|
461
|
+
inDouble: boolean;
|
|
462
|
+
/** The open single quote is ANSI-C (`$'…'`): backslash escapes inside it decode at runtime. */
|
|
463
|
+
inAnsiC: boolean;
|
|
464
|
+
/**
|
|
465
|
+
* Paren stack inside substitutions: `true` for a `$(`/`<(`/`>(` opener
|
|
466
|
+
* (its `)` is a word character), `false` for a plain nested paren (its `)`
|
|
467
|
+
* is an operator, so a following `#` starts a comment).
|
|
468
|
+
*/
|
|
469
|
+
parenStack: boolean[];
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Mask one physical line for syntax analysis, carrying `state` across lines:
|
|
474
|
+
* quoted spans and backslash-escaped characters become spaces (they are data,
|
|
475
|
+
* never syntax), so a `<<` inside `"…"`/`'…'` — even when the quote opened on a
|
|
476
|
+
* PREVIOUS line — or an escaped separator like `\|` is never misread. An
|
|
477
|
+
* unquoted `#` at a word boundary starts a comment: the rest of the line is
|
|
478
|
+
* blanked without touching quote state, so a stray quote inside a comment
|
|
479
|
+
* cannot poison later lines. A `)` closing a `$(`/`<(`/`>(` substitution is
|
|
480
|
+
* part of the current word (`x=$(true)#literal`), not a comment boundary.
|
|
481
|
+
* `masked` is the same length as the input so match offsets stay aligned.
|
|
482
|
+
* `dequoted` applies bash-like QUOTE REMOVAL instead — quote/backslash marks
|
|
483
|
+
* drop, quoted word characters stay, quoted metacharacters neutralize to `_` —
|
|
484
|
+
* so obfuscated spellings (`e\val`, `'ev'al`) reassemble into their real
|
|
485
|
+
* command word without quoted data ever forming fake command boundaries. A
|
|
486
|
+
* leading `$` immediately before `'…'` (ANSI-C) or `"…"` (locale-translated)
|
|
487
|
+
* is stripped during quote removal too, so `$'eval'`/`$"eval"` dequote to
|
|
488
|
+
* plain `eval`, matching real bash quote-removal semantics for both forms.
|
|
489
|
+
* `continued` reports an unescaped trailing backslash outside quotes (logical
|
|
490
|
+
* line continuation).
|
|
491
|
+
*/
|
|
492
|
+
function maskLineForSyntax(
|
|
493
|
+
line: string,
|
|
494
|
+
state: ShellQuoteState,
|
|
495
|
+
): { masked: string; dequoted: string; continued: boolean; ansiCEscape: boolean } {
|
|
496
|
+
let masked = "";
|
|
497
|
+
let dequoted = "";
|
|
498
|
+
let escaped = false;
|
|
499
|
+
let previous = "";
|
|
500
|
+
let ansiCEscape = false;
|
|
501
|
+
// Quoted data keeps word characters (so `'ev'al` reassembles to `eval`) but
|
|
502
|
+
// neutralizes everything else to `_` so it can never form a command boundary.
|
|
503
|
+
const dequoteData = (character: string): string => (/[\w./-]/.test(character) ? character : "_");
|
|
504
|
+
for (const character of line) {
|
|
505
|
+
if (state.inSingle) {
|
|
506
|
+
if (character === "'") {
|
|
507
|
+
state.inSingle = false;
|
|
508
|
+
state.inAnsiC = false;
|
|
509
|
+
} else {
|
|
510
|
+
// ANSI-C escapes (`$'\145val'`) decode at runtime into characters the
|
|
511
|
+
// dequoted view cannot reproduce — flag them so the caller fails closed.
|
|
512
|
+
if (state.inAnsiC && character === "\\") ansiCEscape = true;
|
|
513
|
+
masked += " ";
|
|
514
|
+
dequoted += dequoteData(character);
|
|
515
|
+
previous = character;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
masked += "'";
|
|
519
|
+
previous = character;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (escaped) {
|
|
523
|
+
escaped = false;
|
|
524
|
+
masked += " ";
|
|
525
|
+
dequoted += dequoteData(character);
|
|
526
|
+
previous = character;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (character === "\\") {
|
|
530
|
+
escaped = true;
|
|
531
|
+
masked += " ";
|
|
532
|
+
previous = character;
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (state.inDouble) {
|
|
536
|
+
if (character === '"') state.inDouble = false;
|
|
537
|
+
else {
|
|
538
|
+
masked += " ";
|
|
539
|
+
dequoted += dequoteData(character);
|
|
540
|
+
previous = character;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
masked += '"';
|
|
544
|
+
previous = character;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (
|
|
548
|
+
character === "(" &&
|
|
549
|
+
(previous === "$" || previous === "<" || previous === ">" || state.parenStack.length > 0)
|
|
550
|
+
) {
|
|
551
|
+
// Substitution opener, or any paren nested INSIDE one (e.g. `$((1))`,
|
|
552
|
+
// `$(f (x))`): both must be balanced before the substitution closes.
|
|
553
|
+
state.parenStack.push(previous === "$" || previous === "<" || previous === ">");
|
|
554
|
+
masked += character;
|
|
555
|
+
dequoted += character;
|
|
556
|
+
previous = character;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
if (character === ")" && state.parenStack.length > 0) {
|
|
560
|
+
const wasSubstitutionOpener = state.parenStack.pop() === true;
|
|
561
|
+
masked += character;
|
|
562
|
+
dequoted += character;
|
|
563
|
+
// A substitution close binds to the surrounding word (`x=$(true)#lit`);
|
|
564
|
+
// a nested subshell close is an operator, so `#` after it comments.
|
|
565
|
+
previous = wasSubstitutionOpener ? "x" : ")";
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (
|
|
569
|
+
character === "#" &&
|
|
570
|
+
(previous === "" || previous === " " || previous === "\t" || ";|&()".includes(previous))
|
|
571
|
+
) {
|
|
572
|
+
// Comment: blank the remainder without evaluating quotes inside it.
|
|
573
|
+
masked += " ".repeat(line.length - masked.length);
|
|
574
|
+
return { masked, dequoted, continued: false, ansiCEscape };
|
|
575
|
+
}
|
|
576
|
+
if (character === "'") {
|
|
577
|
+
state.inSingle = true;
|
|
578
|
+
masked += "'";
|
|
579
|
+
// ANSI-C quoting `$'…'`: bash strips the `$` during quote removal, and
|
|
580
|
+
// escapes inside decode at runtime.
|
|
581
|
+
if (dequoted.endsWith("$")) {
|
|
582
|
+
dequoted = dequoted.slice(0, -1);
|
|
583
|
+
state.inAnsiC = true;
|
|
584
|
+
}
|
|
585
|
+
previous = character;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if (character === '"') {
|
|
589
|
+
state.inDouble = true;
|
|
590
|
+
masked += '"';
|
|
591
|
+
// `$"…"` locale-translated strings undergo the same quote removal as
|
|
592
|
+
// ANSI-C `$'…'`: bash drops the `$`, so `$"eval"` dequotes to `eval`.
|
|
593
|
+
if (dequoted.endsWith("$")) dequoted = dequoted.slice(0, -1);
|
|
594
|
+
previous = character;
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
masked += character;
|
|
598
|
+
dequoted += character;
|
|
599
|
+
previous = character;
|
|
600
|
+
}
|
|
601
|
+
return { masked, dequoted, continued: escaped, ansiCEscape };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const DATA_CONSUMER_NAMES = new Set(HEREDOC_DATA_CONSUMERS);
|
|
605
|
+
/**
|
|
606
|
+
* Command-position words that force the fail-closed pass. `eval`/`source`/`.`
|
|
607
|
+
* can install a shadow from data the syntax view has blanked (quoted strings,
|
|
608
|
+
* /dev/stdin). `case` introduces pattern `)` terminators that desync the
|
|
609
|
+
* substitution-depth scanner (`$(case x in x) …;; esac)`), making comment
|
|
610
|
+
* detection unreliable — masking is disabled rather than guessing.
|
|
611
|
+
*/
|
|
612
|
+
const SHELL_EVALUATED_COMMANDS = new Set(["eval", "source", ".", "case"]);
|
|
613
|
+
|
|
614
|
+
/** Wrappers whose next word is still the effective command (`builtin eval …`, `command cat …`). */
|
|
615
|
+
const SHELL_COMMAND_WRAPPER_WORDS = new Set(["sudo", "builtin", "command", "exec", "nohup", "nice"]);
|
|
616
|
+
|
|
617
|
+
/** Reserved words that introduce a nested command position (`if eval …`, `while eval …`). */
|
|
618
|
+
const SHELL_RESERVED_PREFIX_WORDS = new Set([
|
|
619
|
+
"if",
|
|
620
|
+
"then",
|
|
621
|
+
"else",
|
|
622
|
+
"elif",
|
|
623
|
+
"while",
|
|
624
|
+
"until",
|
|
625
|
+
"do",
|
|
626
|
+
"time",
|
|
627
|
+
"coproc",
|
|
628
|
+
"{",
|
|
629
|
+
"!",
|
|
630
|
+
]);
|
|
631
|
+
|
|
632
|
+
/** A redirection token (`</dev/null`, `2>err`, `>out`) — not a command word. */
|
|
633
|
+
const SHELL_REDIRECTION_TOKEN_RE = /^\d*[<>]/;
|
|
634
|
+
|
|
635
|
+
/** Command-position words of one command-list segment: the first real word plus words after reserved prefixes. */
|
|
636
|
+
function commandPositionWords(segment: string): string[] {
|
|
637
|
+
const words: string[] = [];
|
|
638
|
+
let expectCommand = true;
|
|
639
|
+
let afterWrapper = false;
|
|
640
|
+
let afterBareRedirection = false;
|
|
641
|
+
for (const word of segment.trim().split(/\s+/)) {
|
|
642
|
+
if (!word) continue;
|
|
643
|
+
// A bare redirection operator (`<`, `2>`) consumes the NEXT word as its target.
|
|
644
|
+
if (afterBareRedirection) {
|
|
645
|
+
afterBareRedirection = false;
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
if (expectCommand) {
|
|
649
|
+
if (/^\w+=/.test(word)) continue;
|
|
650
|
+
// Leading redirections precede the command word (`</dev/null eval …`).
|
|
651
|
+
// A bare operator (`<`, `2>`) additionally consumes its separated target.
|
|
652
|
+
if (SHELL_REDIRECTION_TOKEN_RE.test(word)) {
|
|
653
|
+
if (/^\d*[<>]+$/.test(word)) afterBareRedirection = true;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (SHELL_COMMAND_WRAPPER_WORDS.has(word.toLowerCase())) {
|
|
657
|
+
// The wrapper's effective command follows, possibly after options/`--`.
|
|
658
|
+
afterWrapper = true;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (afterWrapper && (word === "--" || word.startsWith("-"))) {
|
|
662
|
+
// `command -v`/`-V` only DESCRIBES its operand — the rest is data.
|
|
663
|
+
if (/^-[a-zA-Z]*[vV]/.test(word)) return words;
|
|
664
|
+
// Other wrapper options (`command -p`, `builtin --`) precede the real command.
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
afterWrapper = false;
|
|
668
|
+
if (SHELL_RESERVED_PREFIX_WORDS.has(word.toLowerCase())) {
|
|
669
|
+
// Reserved word keeps the NEXT word in command position.
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
words.push(word.split("/").pop()?.toLowerCase() ?? "");
|
|
673
|
+
expectCommand = false;
|
|
674
|
+
}
|
|
675
|
+
// Argument position: only real list operators (handled by the segment
|
|
676
|
+
// split) restore command position — reserved-word TEXT here is data.
|
|
677
|
+
}
|
|
678
|
+
return words;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Command-list segments of a dequoted view (split at `;`, `|`, `&`, `(`, backtick, newline). */
|
|
682
|
+
function commandListSegments(dequotedView: string): string[] {
|
|
683
|
+
return dequotedView.split(/[;|&(`\n]/);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* True when any command-position word of the dequoted syntax view is
|
|
688
|
+
* eval/source/`.` — including nested positions after reserved words
|
|
689
|
+
* (`if eval …`). `echo eval` and `find .` never match because their
|
|
690
|
+
* tokens sit in argument position.
|
|
691
|
+
*/
|
|
692
|
+
function hasShellEvaluatedCommand(dequotedView: string): boolean {
|
|
693
|
+
return commandListSegments(dequotedView).some(segment =>
|
|
694
|
+
commandPositionWords(segment).some(word => SHELL_EVALUATED_COMMANDS.has(word)),
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* True when live syntax actually DECLARES a shadow of an allowlisted
|
|
700
|
+
* data-consumer name: `name()` at a command-list start, `function name`,
|
|
701
|
+
* or `alias name=…` — each in command position, so `echo function cat`
|
|
702
|
+
* (argument position) never matches.
|
|
703
|
+
*/
|
|
704
|
+
function hasDataConsumerShadow(dequotedView: string): boolean {
|
|
705
|
+
for (const segment of commandListSegments(dequotedView)) {
|
|
706
|
+
const words = commandPositionWords(segment);
|
|
707
|
+
for (let index = 0; index < words.length; index++) {
|
|
708
|
+
const word = words[index] ?? "";
|
|
709
|
+
if (word === "function") {
|
|
710
|
+
// commandPositionWords stops after the first non-reserved word, so
|
|
711
|
+
// re-inspect the raw segment for the name following `function`.
|
|
712
|
+
const rawWords = segment.trim().split(/\s+/).filter(Boolean);
|
|
713
|
+
const at = rawWords.findIndex(raw => raw.toLowerCase() === "function");
|
|
714
|
+
const name = rawWords[at + 1]?.split("/").pop()?.toLowerCase() ?? "";
|
|
715
|
+
if (DATA_CONSUMER_NAMES.has(name)) return true;
|
|
716
|
+
}
|
|
717
|
+
if (word === "alias") {
|
|
718
|
+
const rawWords = segment.trim().split(/\s+/).filter(Boolean);
|
|
719
|
+
if (rawWords.some(raw => DATA_CONSUMER_NAMES.has(raw.split("=")[0]?.toLowerCase() ?? ""))) return true;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// `name()` / `name ()` in command position — allow reserved-word prefixes
|
|
724
|
+
// (`then cat() { … }`) before the name (POSIX function definition).
|
|
725
|
+
const nameGroup = [...DATA_CONSUMER_NAMES].join("|");
|
|
726
|
+
const reservedGroup = [...SHELL_RESERVED_PREFIX_WORDS].filter(word => /^\w+$/.test(word)).join("|");
|
|
727
|
+
return new RegExp(
|
|
728
|
+
`(?:^|[;|&{(\`\\n])\\s*(?:(?:${reservedGroup}|\\{|!)\\s+)*(?:${nameGroup})\\s*\\(\\s*\\)`,
|
|
729
|
+
"i",
|
|
730
|
+
).test(dequotedView);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** First command word of a pipeline-segment slice of a quote-masked opener line. */
|
|
734
|
+
function firstCommandWord(segment: string): string {
|
|
735
|
+
return commandPositionWords(segment)[0] ?? "";
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Classify the command consuming the heredoc at offset `at` of the comment-cut,
|
|
740
|
+
* quote-masked opener line — including every DOWNSTREAM pipe stage, because
|
|
741
|
+
* `cat <<'EOF' | bash` hands the body to the interpreter even though `cat` is
|
|
742
|
+
* inert. Only a pipeline whose every stage is an explicitly inert data consumer
|
|
743
|
+
* qualifies for body masking; any stdin applier stage fails closed; anything
|
|
744
|
+
* else (interpreters, awk, unknown binaries) keeps the body live.
|
|
745
|
+
*/
|
|
746
|
+
function heredocConsumerKind(syntaxLine: string, at: number): "data" | "mutating" | "other" {
|
|
747
|
+
let start = 0;
|
|
748
|
+
for (let index = at - 1; index >= 0; index--) {
|
|
749
|
+
const character = syntaxLine[index] ?? "";
|
|
750
|
+
if (";|&(`".includes(character) || character === "\n") {
|
|
751
|
+
start = index + 1;
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
// The heredoc's own simple command plus every downstream `|` stage until the
|
|
756
|
+
// command list ends (`;`, `&&`, `||`, `&`, backtick, or subshell close).
|
|
757
|
+
const segments: string[] = [syntaxLine.slice(start, at)];
|
|
758
|
+
let cursor = at;
|
|
759
|
+
while (cursor < syntaxLine.length) {
|
|
760
|
+
const character = syntaxLine[cursor] ?? "";
|
|
761
|
+
if (character === ";" || character === "&" || character === "`" || character === ")") break;
|
|
762
|
+
if (character === "|") {
|
|
763
|
+
if (syntaxLine[cursor + 1] === "|") break;
|
|
764
|
+
let stageEnd = cursor + 1;
|
|
765
|
+
while (stageEnd < syntaxLine.length && !";|&`)".includes(syntaxLine[stageEnd] ?? "")) stageEnd++;
|
|
766
|
+
segments.push(syntaxLine.slice(cursor + 1, stageEnd));
|
|
767
|
+
cursor = stageEnd;
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
cursor++;
|
|
771
|
+
}
|
|
772
|
+
let kind: "data" | "mutating" | "other" = "data";
|
|
773
|
+
for (const segment of segments) {
|
|
774
|
+
const base = firstCommandWord(segment);
|
|
775
|
+
if (HEREDOC_MUTATING_CONSUMERS.has(base)) return "mutating";
|
|
776
|
+
if (!HEREDOC_DATA_CONSUMERS.has(base)) kind = "other";
|
|
777
|
+
}
|
|
778
|
+
return kind;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Blank out heredoc BODY lines so document payloads (markdown specs, plans,
|
|
783
|
+
* fixtures) piped to an explicitly inert data consumer (`cat <<'EOF' >
|
|
784
|
+
* /tmp/spec.md`) are not misread as shell commands: body text like `a > b` or
|
|
785
|
+
* stray apostrophes must not register redirection targets or unbalance the
|
|
786
|
+
* quote masker. Bodies stay UNMASKED (scanned as today) for every other
|
|
787
|
+
* consumer — interpreters, awk, unknown binaries — because there the body may
|
|
788
|
+
* be live code. Stdin appliers (patch/ed/ex/sqlite3) flag `mutatingConsumer`
|
|
789
|
+
* and the caller fails closed. Unquoted-delimiter bodies still expand
|
|
790
|
+
* `$(…)`/backticks in real shells, so masked ones flag `opaqueExpansion`. A
|
|
791
|
+
* `<<` inside a comment or a quoted span (including a quote opened on a
|
|
792
|
+
* previous line) is not an opener; a line-continued opener, a shadowed
|
|
793
|
+
* data-consumer name (`cat() { … }`), and an unterminated heredoc all keep
|
|
794
|
+
* their bodies live so the scanner stays fail-closed rather than blind.
|
|
795
|
+
*/
|
|
796
|
+
function maskHeredocBodies(command: string): HeredocMaskResult {
|
|
797
|
+
if (!command.includes("<<")) return { masked: command, opaqueExpansion: false, mutatingConsumer: false };
|
|
798
|
+
const first = maskHeredocBodiesPass(command, false);
|
|
799
|
+
// Fail closed on live-syntax constructs that can install a shadow the syntax
|
|
800
|
+
// view cannot see: a function/alias definition of an allowlisted consumer
|
|
801
|
+
// name, or a command-position eval/source/`.` whose argument is blanked
|
|
802
|
+
// quoted data. Both run against the DEQUOTED view so quoting tricks
|
|
803
|
+
// (`e\val`, `'ev'al`) cannot hide the word, while heredoc bodies and
|
|
804
|
+
// comments stay excluded — a spec that merely DOCUMENTS `cat() { … }` or
|
|
805
|
+
// mentions "eval" in prose stays inert.
|
|
806
|
+
if (first.ansiCEscape || hasDataConsumerShadow(first.dequotedView) || hasShellEvaluatedCommand(first.dequotedView)) {
|
|
807
|
+
return maskHeredocBodiesPass(command, true).result;
|
|
808
|
+
}
|
|
809
|
+
return first.result;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function maskHeredocBodiesPass(
|
|
813
|
+
command: string,
|
|
814
|
+
shadowed: boolean,
|
|
815
|
+
): { result: HeredocMaskResult; dequotedView: string; ansiCEscape: boolean } {
|
|
816
|
+
const lines = command.split("\n");
|
|
817
|
+
const out: string[] = [];
|
|
818
|
+
const dequotedLines: string[] = [];
|
|
819
|
+
const state: ShellQuoteState = { inSingle: false, inDouble: false, inAnsiC: false, parenStack: [] };
|
|
820
|
+
let previousContinued = false;
|
|
821
|
+
let opaqueExpansion = false;
|
|
822
|
+
let mutatingConsumer = false;
|
|
823
|
+
let sawAnsiCEscape = false;
|
|
824
|
+
for (let index = 0; index < lines.length; index++) {
|
|
825
|
+
const line = lines[index] ?? "";
|
|
826
|
+
out.push(line);
|
|
827
|
+
// Cross-line quote masking decides whether a `<<` is real syntax or inert
|
|
828
|
+
// data — even when the enclosing quote opened on a previous line.
|
|
829
|
+
const { masked, dequoted, continued, ansiCEscape } = maskLineForSyntax(line, state);
|
|
830
|
+
if (ansiCEscape) sawAnsiCEscape = true;
|
|
831
|
+
const syntax = masked;
|
|
832
|
+
const isContinuationLine = previousContinued;
|
|
833
|
+
// Bash removes a backslash-newline pair entirely: fold a continuation into
|
|
834
|
+
// the previous dequoted line so split command words (`e\` + `val`)
|
|
835
|
+
// reassemble for command/shadow detection.
|
|
836
|
+
if (isContinuationLine && dequotedLines.length > 0) {
|
|
837
|
+
let previousDequoted = dequotedLines[dequotedLines.length - 1] ?? "";
|
|
838
|
+
// A continuation can also split ANSI-C/locale quoting (`$\` + `'eval'` or
|
|
839
|
+
// `$\` + `"eval"`): the rejoined `$'`/`$"` still drops its `$` during
|
|
840
|
+
// quote removal.
|
|
841
|
+
if (previousDequoted.endsWith("$") && (line.startsWith("'") || line.startsWith('"'))) {
|
|
842
|
+
previousDequoted = previousDequoted.slice(0, -1);
|
|
843
|
+
// The masker already opened this quote as ordinary (it ran before the
|
|
844
|
+
// fold), so re-check the rejoined ANSI-C span for runtime-decoding
|
|
845
|
+
// escapes (`$\` + `'\145val'`) it could not have flagged.
|
|
846
|
+
if (line.startsWith("'") && /^'[^']*\\/.test(line)) sawAnsiCEscape = true;
|
|
847
|
+
}
|
|
848
|
+
dequotedLines[dequotedLines.length - 1] = previousDequoted + dequoted;
|
|
849
|
+
} else {
|
|
850
|
+
dequotedLines.push(dequoted);
|
|
851
|
+
}
|
|
852
|
+
previousContinued = continued;
|
|
853
|
+
for (const match of line.matchAll(BASH_HEREDOC_OPEN_RE)) {
|
|
854
|
+
const at = match.index ?? 0;
|
|
855
|
+
if (syntax.slice(at, at + 2) !== "<<") continue;
|
|
856
|
+
const delimiter = match[2] ?? match[3] ?? match[5] ?? "";
|
|
857
|
+
if (!delimiter) continue;
|
|
858
|
+
let kind = heredocConsumerKind(syntax, at);
|
|
859
|
+
// A continued/continuation opener line hides part of the pipeline
|
|
860
|
+
// (`cat <<'EOF' \` / `| bash`); a shadowed consumer name is untrustworthy.
|
|
861
|
+
// Both downgrade masking eligibility, never block eligibility.
|
|
862
|
+
if (kind === "data" && (shadowed || continued || isContinuationLine)) kind = "other";
|
|
863
|
+
if (kind === "mutating") mutatingConsumer = true;
|
|
864
|
+
const quoted = match[2] !== undefined || match[3] !== undefined || match[4] !== undefined;
|
|
865
|
+
const stripTabs = match[1] === "-";
|
|
866
|
+
let end = -1;
|
|
867
|
+
for (let scan = index + 1; scan < lines.length; scan++) {
|
|
868
|
+
const candidate = stripTabs ? (lines[scan] ?? "").replace(/^\t+/, "") : (lines[scan] ?? "");
|
|
869
|
+
if (candidate === delimiter) {
|
|
870
|
+
end = scan;
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (end === -1) {
|
|
875
|
+
return {
|
|
876
|
+
result: { masked: command, opaqueExpansion, mutatingConsumer },
|
|
877
|
+
dequotedView: dequotedLines.join("\n"),
|
|
878
|
+
ansiCEscape: sawAnsiCEscape,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
const maskBody = kind === "data" || kind === "mutating";
|
|
882
|
+
for (let body = index + 1; body < end; body++) {
|
|
883
|
+
const bodyLine = lines[body] ?? "";
|
|
884
|
+
if (maskBody && !quoted && /\$\(|`/.test(bodyLine)) opaqueExpansion = true;
|
|
885
|
+
out.push(maskBody ? "" : bodyLine);
|
|
886
|
+
}
|
|
887
|
+
out.push(lines[end] ?? "");
|
|
888
|
+
index = end;
|
|
889
|
+
// Heredoc bodies are data: they never affect the shell quote state or
|
|
890
|
+
// the syntax view used for shadow detection.
|
|
891
|
+
previousContinued = false;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return {
|
|
895
|
+
result: { masked: out.join("\n"), opaqueExpansion, mutatingConsumer },
|
|
896
|
+
dequotedView: dequotedLines.join("\n"),
|
|
897
|
+
ansiCEscape: sawAnsiCEscape,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
|
|
390
902
|
const record = getRecord(args);
|
|
391
903
|
const command = safeString(record?.command);
|
|
392
904
|
const targets: ExtractedTargets = { paths: [], unknown: false };
|
|
@@ -394,19 +906,51 @@ function extractBashTargets(args: unknown): ExtractedTargets {
|
|
|
394
906
|
targets.unknown = true;
|
|
395
907
|
return targets;
|
|
396
908
|
}
|
|
397
|
-
|
|
909
|
+
// A literal `sh|bash|zsh -c '<script>'` payload is a real command list. Its
|
|
910
|
+
// mutations never match the top-level patterns, so recurse into it; anything
|
|
911
|
+
// deeper than a bounded depth is not statically analyzable and fails closed.
|
|
912
|
+
for (const match of command.matchAll(BASH_NESTED_SHELL_RE)) {
|
|
913
|
+
if (depth >= 2) {
|
|
914
|
+
targets.explicitMutation = true;
|
|
915
|
+
targets.unknown = true;
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
const nested = extractBashTargets({ command: match[2] ?? match[4] ?? "" }, depth + 1);
|
|
919
|
+
for (const nestedPath of nested.paths) addPath(targets, nestedPath);
|
|
920
|
+
if (nested.unknown) targets.unknown = true;
|
|
921
|
+
if (nested.explicitMutation) targets.explicitMutation = true;
|
|
922
|
+
}
|
|
923
|
+
// Heredoc bodies fed to data consumers are inert document payloads; mask them
|
|
924
|
+
// so spec/plan text cannot fake redirections. Script-consumer bodies survive
|
|
925
|
+
// the mask and are still scanned as live code below.
|
|
926
|
+
const heredoc = maskHeredocBodies(command);
|
|
927
|
+
if (heredoc.opaqueExpansion || heredoc.mutatingConsumer) {
|
|
928
|
+
targets.explicitMutation = true;
|
|
929
|
+
targets.unknown = true;
|
|
930
|
+
}
|
|
931
|
+
// Nested scripts were read from the raw text above; every scanner below works
|
|
932
|
+
// on the masked view so quoted argument data cannot look like a redirection.
|
|
933
|
+
const scanned = maskSingleQuotedSpans(heredoc.masked);
|
|
934
|
+
if (
|
|
935
|
+
BASH_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked) ||
|
|
936
|
+
BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked)
|
|
937
|
+
) {
|
|
938
|
+
targets.explicitMutation = true;
|
|
398
939
|
targets.unknown = true;
|
|
399
940
|
}
|
|
400
941
|
// `exec 1<>file` rebinds a descriptor, so a later `>/dev/null` may not reach the device at all.
|
|
401
|
-
if (BASH_EXEC_REDIRECT_RE.test(
|
|
942
|
+
if (BASH_EXEC_REDIRECT_RE.test(scanned)) {
|
|
943
|
+
targets.explicitMutation = true;
|
|
402
944
|
targets.unknown = true;
|
|
403
945
|
}
|
|
404
|
-
for (const match of
|
|
946
|
+
for (const match of scanned.matchAll(BASH_EXTENDED_WRITE_RE)) {
|
|
947
|
+
targets.explicitMutation = true;
|
|
405
948
|
const cleaned = cleanShellWord(match[1] ?? "");
|
|
406
949
|
if (!cleaned) targets.unknown = true;
|
|
407
950
|
else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
|
|
408
951
|
}
|
|
409
|
-
for (const match of
|
|
952
|
+
for (const match of scanned.matchAll(BASH_DD_OUTPUT_RE)) {
|
|
953
|
+
targets.explicitMutation = true;
|
|
410
954
|
const parts = shellWords(match[1] ?? "").map(cleanShellWord);
|
|
411
955
|
// Every `of=` counts: GNU dd honors the last one, so `of=/dev/null of=real.ts` writes real.ts.
|
|
412
956
|
const outputs = parts.filter(part => part.startsWith("of="));
|
|
@@ -417,15 +961,17 @@ function extractBashTargets(args: unknown): ExtractedTargets {
|
|
|
417
961
|
else if (!isDeviceSinkPath(outputPath)) addPath(targets, outputPath);
|
|
418
962
|
}
|
|
419
963
|
}
|
|
420
|
-
for (const match of
|
|
964
|
+
for (const match of scanned.matchAll(BASH_IN_PLACE_MUTATION_COMMAND_RE)) {
|
|
421
965
|
const parts = shellWords(match[1] ?? "").map(cleanShellWord);
|
|
422
966
|
const hasInPlaceFlag = parts.some(part => /^-.*i/.test(part));
|
|
423
967
|
if (!hasInPlaceFlag) continue;
|
|
968
|
+
targets.explicitMutation = true;
|
|
424
969
|
const target = [...parts].reverse().find(part => part && !part.startsWith("-"));
|
|
425
970
|
if (target) addPath(targets, target);
|
|
426
971
|
else targets.unknown = true;
|
|
427
972
|
}
|
|
428
|
-
for (const match of
|
|
973
|
+
for (const match of scanned.matchAll(BASH_MUTATION_COMMAND_RE)) {
|
|
974
|
+
targets.explicitMutation = true;
|
|
429
975
|
const redirected = match[2]?.trim();
|
|
430
976
|
if (redirected) {
|
|
431
977
|
const cleaned = cleanShellWord(redirected);
|
|
@@ -447,6 +993,9 @@ function extractBashTargets(args: unknown): ExtractedTargets {
|
|
|
447
993
|
for (const part of targetParts) {
|
|
448
994
|
const cleaned = cleanShellWord(part);
|
|
449
995
|
if (!cleaned || cleaned.startsWith("-")) continue;
|
|
996
|
+
// Redirection/heredoc operator words (`>/dev/null`, `<<'DOC'`, `2>&1`) are
|
|
997
|
+
// not argument paths; their targets are captured by the redirect scanners.
|
|
998
|
+
if (/^\d*[<>]/.test(cleaned)) continue;
|
|
450
999
|
addPath(targets, cleaned);
|
|
451
1000
|
}
|
|
452
1001
|
}
|
|
@@ -666,7 +1215,10 @@ export async function getWorkflowMutationDecision(
|
|
|
666
1215
|
}
|
|
667
1216
|
if (input.forceOverride) return { blocked: false, targets: [] };
|
|
668
1217
|
const message = planningPhaseBlockMessage(planning.skill);
|
|
669
|
-
|
|
1218
|
+
// Bash during a planning phase blocks only when a filesystem mutation was
|
|
1219
|
+
// actually recognized. Inspection, reads, and CLI invocations the scanner does
|
|
1220
|
+
// not model stay usable; other tools keep failing closed on unknown targets.
|
|
1221
|
+
if (targets.unknown && (input.tool.name !== "bash" || targets.explicitMutation)) {
|
|
670
1222
|
return {
|
|
671
1223
|
blocked: true,
|
|
672
1224
|
message,
|