@gajae-code/coding-agent 0.12.0 → 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.
Files changed (223) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/config/settings-schema.d.ts +54 -0
  4. package/dist/types/exa/mcp-client.d.ts +10 -1
  5. package/dist/types/exec/bash-executor.d.ts +45 -12
  6. package/dist/types/extensibility/extensions/types.d.ts +16 -2
  7. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  8. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  9. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  10. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  11. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  12. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  13. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  15. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +18 -0
  16. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  17. package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
  18. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  19. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
  20. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
  21. package/dist/types/modes/acp/acp-agent.d.ts +2 -19
  22. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  23. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  24. package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
  25. package/dist/types/modes/components/settings-selector.d.ts +9 -0
  26. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  27. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  28. package/dist/types/reminders/star-reminder.d.ts +3 -13
  29. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  30. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  31. package/dist/types/sdk/broker/transport.d.ts +2 -0
  32. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  33. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
  34. package/dist/types/sdk/bus/config.d.ts +6 -2
  35. package/dist/types/sdk/bus/index.d.ts +29 -0
  36. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
  37. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  38. package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
  39. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  40. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  41. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  42. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  43. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
  44. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  45. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  46. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  47. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  48. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  49. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  50. package/dist/types/sdk/prompt-status.d.ts +23 -3
  51. package/dist/types/sdk/session.d.ts +6 -0
  52. package/dist/types/session/agent-session.d.ts +38 -2
  53. package/dist/types/session/artifacts.d.ts +8 -0
  54. package/dist/types/session/blob-store.d.ts +57 -4
  55. package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
  56. package/dist/types/session/session-manager.d.ts +23 -6
  57. package/dist/types/session/session-storage.d.ts +4 -0
  58. package/dist/types/session/streaming-output.d.ts +36 -7
  59. package/dist/types/setup/credential-import.d.ts +12 -0
  60. package/dist/types/skill-state/active-state.d.ts +12 -0
  61. package/dist/types/skill-state/workflow-hud.d.ts +6 -0
  62. package/dist/types/task/executor.d.ts +3 -2
  63. package/dist/types/task/index.d.ts +3 -1
  64. package/dist/types/task/provider-retry-status.d.ts +8 -0
  65. package/dist/types/task/receipt.d.ts +7 -1
  66. package/dist/types/task/types.d.ts +23 -0
  67. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  68. package/dist/types/tools/bash-interactive.d.ts +4 -1
  69. package/dist/types/tools/bash.d.ts +3 -1
  70. package/dist/types/tools/image-gen.d.ts +4 -0
  71. package/dist/types/tools/index.d.ts +4 -0
  72. package/dist/types/tools/output-meta.d.ts +23 -2
  73. package/dist/types/tools/path-utils.d.ts +1 -0
  74. package/dist/types/tools/read.d.ts +0 -6
  75. package/dist/types/tools/review.d.ts +5 -7
  76. package/dist/types/tools/subagent.d.ts +2 -0
  77. package/dist/types/utils/gh.d.ts +13 -0
  78. package/dist/types/utils/image-resize.d.ts +1 -0
  79. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  80. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  81. package/dist/types/web/search/providers/xai.d.ts +2 -0
  82. package/package.json +10 -8
  83. package/scripts/acp-conformance-agent.ts +218 -0
  84. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  85. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  86. package/scripts/resident-memory-bench.ts +1341 -0
  87. package/scripts/run-acp-conformance.ts +285 -0
  88. package/src/async/job-manager.ts +78 -20
  89. package/src/cli/fast-help.ts +1 -1
  90. package/src/cli/plugin-cli.ts +176 -18
  91. package/src/commands/team.ts +19 -1
  92. package/src/config/file-lock.ts +11 -2
  93. package/src/config/settings-schema.ts +51 -0
  94. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  95. package/src/coordinator-mcp/server.ts +5 -12
  96. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
  97. package/src/defaults/gjc/skills/ralplan/SKILL.md +19 -16
  98. package/src/defaults/gjc/skills/team/SKILL.md +2 -0
  99. package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -24
  100. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  101. package/src/defaults/gjc-defaults.ts +4 -4
  102. package/src/exa/mcp-client.ts +12 -3
  103. package/src/exec/bash-executor.ts +144 -27
  104. package/src/extensibility/extensions/runner.ts +15 -0
  105. package/src/extensibility/extensions/types.ts +18 -1
  106. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  107. package/src/extensibility/gjc-plugins/index.ts +19 -2
  108. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  109. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  110. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  111. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  112. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  113. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  114. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  115. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  116. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  117. package/src/extensibility/gjc-plugins/types.ts +141 -0
  118. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  119. package/src/gjc-runtime/launch-tmux.ts +25 -6
  120. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  121. package/src/gjc-runtime/ralplan-runtime.ts +411 -17
  122. package/src/gjc-runtime/team-launch.ts +8 -0
  123. package/src/gjc-runtime/team-runtime.ts +140 -32
  124. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  125. package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
  126. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  127. package/src/gjc-runtime/ultragoal-runtime.ts +401 -611
  128. package/src/gjc-runtime/workflow-manifest.generated.json +8 -99
  129. package/src/gjc-runtime/workflow-manifest.ts +4 -68
  130. package/src/internal-urls/docs-index.generated.ts +15 -15
  131. package/src/modes/DESIGN.md +51 -0
  132. package/src/modes/acp/acp-agent.ts +298 -186
  133. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  134. package/src/modes/components/notifications-settings-editor.ts +12 -1
  135. package/src/modes/components/oauth-selector.ts +68 -30
  136. package/src/modes/components/session-observer-overlay.ts +305 -86
  137. package/src/modes/components/settings-selector.ts +56 -5
  138. package/src/modes/components/status-line/gh.ts +16 -0
  139. package/src/modes/components/tool-status-header.ts +3 -14
  140. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  141. package/src/modes/controllers/command-controller.ts +4 -0
  142. package/src/modes/controllers/extension-ui-controller.ts +7 -0
  143. package/src/modes/controllers/selector-controller.ts +45 -0
  144. package/src/modes/runtime-init.ts +3 -0
  145. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  146. package/src/prompts/system/eager-todo.md +8 -4
  147. package/src/prompts/system/system-prompt.md +11 -4
  148. package/src/prompts/tools/bash.md +5 -5
  149. package/src/prompts/tools/search-tool-bm25.md +5 -0
  150. package/src/prompts/tools/task.md +1 -1
  151. package/src/reminders/star-reminder.ts +4 -42
  152. package/src/runtime-mcp/manager.ts +19 -4
  153. package/src/sdk/acp/adapter.ts +22 -6
  154. package/src/sdk/broker/transport.ts +35 -3
  155. package/src/sdk/bus/attachment-registry.ts +5 -1
  156. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  157. package/src/sdk/bus/chat-daemon-control.ts +6 -3
  158. package/src/sdk/bus/config.ts +18 -2
  159. package/src/sdk/bus/index.ts +661 -126
  160. package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
  161. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  162. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  163. package/src/sdk/bus/reconciliation-store.ts +95 -16
  164. package/src/sdk/bus/rich-draft.ts +1 -1
  165. package/src/sdk/bus/rich-render.ts +2 -2
  166. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  167. package/src/sdk/bus/telegram-cli.ts +33 -8
  168. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  169. package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
  170. package/src/sdk/bus/telegram-daemon.ts +916 -23
  171. package/src/sdk/bus/telegram-reference.ts +40 -3
  172. package/src/sdk/bus/threaded-render.ts +48 -3
  173. package/src/sdk/bus/topic-registry.ts +20 -0
  174. package/src/sdk/host/query/revision-store.ts +36 -2
  175. package/src/sdk/host/reverse-leases.ts +44 -7
  176. package/src/sdk/prompt-status.ts +16 -3
  177. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  178. package/src/sdk/session.ts +88 -9
  179. package/src/session/agent-session.ts +822 -172
  180. package/src/session/artifacts.ts +22 -0
  181. package/src/session/auth-broker-config.ts +10 -3
  182. package/src/session/blob-store.ts +915 -24
  183. package/src/session/internal/managed-session-scope.ts +44 -6
  184. package/src/session/internal/managed-session-storage.ts +48 -6
  185. package/src/session/session-manager.ts +1614 -435
  186. package/src/session/session-storage.ts +9 -1
  187. package/src/session/streaming-output.ts +238 -68
  188. package/src/setup/credential-import.ts +75 -13
  189. package/src/skill-state/active-state.ts +32 -0
  190. package/src/skill-state/workflow-hud.ts +17 -0
  191. package/src/skill-state/workflow-mutation-guard.ts +496 -2
  192. package/src/task/executor.ts +122 -39
  193. package/src/task/index.ts +23 -14
  194. package/src/task/provider-retry-status.ts +16 -0
  195. package/src/task/receipt.ts +23 -7
  196. package/src/task/render.ts +6 -2
  197. package/src/task/types.ts +108 -0
  198. package/src/tools/ask-answer-registry.ts +12 -6
  199. package/src/tools/ask.ts +86 -55
  200. package/src/tools/ast-edit.ts +1 -0
  201. package/src/tools/ast-grep.ts +1 -0
  202. package/src/tools/bash-interactive.ts +8 -3
  203. package/src/tools/bash.ts +503 -60
  204. package/src/tools/find.ts +1 -0
  205. package/src/tools/image-gen.ts +29 -5
  206. package/src/tools/index.ts +4 -0
  207. package/src/tools/output-meta.ts +108 -14
  208. package/src/tools/path-utils.ts +3 -2
  209. package/src/tools/read.ts +18 -0
  210. package/src/tools/review.ts +5 -7
  211. package/src/tools/search.ts +1 -0
  212. package/src/tools/subagent-render.ts +6 -2
  213. package/src/tools/subagent.ts +6 -0
  214. package/src/tools/telegram-send.ts +6 -3
  215. package/src/utils/gh.ts +39 -0
  216. package/src/utils/image-resize.ts +3 -0
  217. package/src/web/search/providers/anthropic.ts +4 -4
  218. package/src/web/search/providers/kimi.ts +15 -5
  219. package/src/web/search/providers/searxng.ts +47 -4
  220. package/src/web/search/providers/xai.ts +7 -2
  221. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  222. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  223. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
@@ -302,6 +302,32 @@ export function isCanonicalGjcWorkflowSkill(skill: string): skill is CanonicalGj
302
302
  return (CANONICAL_GJC_WORKFLOW_SKILLS as readonly string[]).includes(skill);
303
303
  }
304
304
 
305
+ /**
306
+ * Nonterminal phases that intentionally await workflow-specific integration
307
+ * rather than a generic synthetic compaction continuation.
308
+ */
309
+ const CONTINUATION_INERT_WORKFLOW_PHASES: Readonly<Partial<Record<CanonicalGjcWorkflowSkill, ReadonlySet<string>>>> = {
310
+ team: new Set(["awaiting_integration"]),
311
+ };
312
+
313
+ /**
314
+ * Continuation inertness for compaction auto-continue, derived from each
315
+ * workflow's manifest contract: manifest-terminal and explicitly inert phases
316
+ * are inert; unknown skills or phases unknown to the skill's manifest are
317
+ * conservatively inert so a synthetic continuation never wakes an unrecognized
318
+ * workflow. Generic Ultragoal `blocked` remains continuation-active because its
319
+ * blocker may be autonomously resolvable; verified human pauses are represented
320
+ * by the inline goal's `paused` status instead.
321
+ */
322
+ export function isWorkflowContinuationInert(skill: string, phase: string): boolean {
323
+ const normalizedPhase = phase.trim().toLowerCase();
324
+ if (!isCanonicalGjcWorkflowSkill(skill)) return true;
325
+ const manifest = getSkillManifest(skill);
326
+ if (manifest.terminalStates.some(terminal => terminal.toLowerCase() === normalizedPhase)) return true;
327
+ if (CONTINUATION_INERT_WORKFLOW_PHASES[skill]?.has(normalizedPhase)) return true;
328
+ return !manifest.states.some(state => state.id.toLowerCase() === normalizedPhase);
329
+ }
330
+
305
331
  export function listActiveSkills(raw: unknown): SkillActiveEntry[] {
306
332
  if (!raw || typeof raw !== "object") return [];
307
333
  const state = raw as SkillActiveState;
@@ -615,6 +641,8 @@ async function mergeVisibleEntries(
615
641
  export type VisibleSkillActiveStateCacheTier = "security" | "hud";
616
642
  export interface ReadVisibleSkillActiveStateOptions {
617
643
  tier?: VisibleSkillActiveStateCacheTier;
644
+ /** Bypass all signature/TTL caches for authorization decisions. */
645
+ bypassCache?: boolean;
618
646
  }
619
647
 
620
648
  interface ActiveStateStatSignature {
@@ -747,6 +775,10 @@ export async function readVisibleSkillActiveState(
747
775
  }
748
776
  const resolvedCwd = path.resolve(cwd);
749
777
  const cacheKey = visibleActiveStateCacheKey(resolvedCwd, resolvedSessionId);
778
+ if (opts?.bypassCache) {
779
+ visibleSkillActiveStateCache.delete(cacheKey);
780
+ return await readVisibleSkillActiveStateUncached(resolvedCwd, resolvedSessionId);
781
+ }
750
782
  const tier = opts?.tier ?? "security";
751
783
  const now = Date.now();
752
784
  const cached = visibleSkillActiveStateCache.get(cacheKey);
@@ -29,6 +29,12 @@ interface RalplanHudState extends WorkflowGateHudState {
29
29
  verdict?: string;
30
30
  latestSummary?: string;
31
31
  pendingApproval?: boolean;
32
+ autoHandoff?: {
33
+ configuredTarget: string;
34
+ effectiveTarget: string;
35
+ degradationReason: string | null;
36
+ };
37
+ planningStuck?: boolean;
32
38
  updatedAt?: string;
33
39
  }
34
40
 
@@ -222,6 +228,16 @@ export function buildRalplanHudSummary(state: RalplanHudState): WorkflowHudSumma
222
228
  }
223
229
  return chip(label, `${passes}/${state.reviewPassBudget}`, priority);
224
230
  };
231
+ const handoffValue = state.autoHandoff
232
+ ? `${state.autoHandoff.configuredTarget}→${state.autoHandoff.effectiveTarget}${
233
+ state.autoHandoff.degradationReason ? `:${state.autoHandoff.degradationReason}` : ""
234
+ }`
235
+ : undefined;
236
+ const handoffSeverity = state.planningStuck
237
+ ? "blocked"
238
+ : state.autoHandoff?.degradationReason
239
+ ? "warning"
240
+ : undefined;
225
241
  return {
226
242
  version: 1,
227
243
  summary: state.latestSummary,
@@ -241,6 +257,7 @@ export function buildRalplanHudSummary(state: RalplanHudState): WorkflowHudSumma
241
257
  reviewPassChip("arch", state.architectPasses, 36),
242
258
  reviewPassChip("crit", state.criticPasses, 38),
243
259
  chip("verdict", verdict, 40, verdictSeverity),
260
+ chip("handoff", handoffValue, 45, handoffSeverity),
244
261
  ]),
245
262
  ...(state.updatedAt ? { updated_at: state.updatedAt } : {}),
246
263
  };
@@ -60,6 +60,35 @@ const BASH_DD_OUTPUT_RE = /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s
60
60
  /** Literal `sh|bash|zsh -c '<script>'` payloads whose nested script must also be scanned. */
61
61
  const BASH_NESTED_SHELL_RE =
62
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"]);
63
92
 
64
93
  type ToolWithEditMode = AgentTool & {
65
94
  mode?: unknown;
@@ -418,6 +447,457 @@ function isDeviceSinkPath(value: string): boolean {
418
447
  return DEVICE_SINK_PATHS.has(cleanShellWord(value));
419
448
  }
420
449
 
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
+
421
901
  function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
422
902
  const record = getRecord(args);
423
903
  const command = safeString(record?.command);
@@ -440,10 +920,21 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
440
920
  if (nested.unknown) targets.unknown = true;
441
921
  if (nested.explicitMutation) targets.explicitMutation = true;
442
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
+ }
443
931
  // Nested scripts were read from the raw text above; every scanner below works
444
932
  // on the masked view so quoted argument data cannot look like a redirection.
445
- const scanned = maskSingleQuotedSpans(command);
446
- if (BASH_OPAQUE_INTERPRETER_WRITE_RE.test(command) || BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(command)) {
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
+ ) {
447
938
  targets.explicitMutation = true;
448
939
  targets.unknown = true;
449
940
  }
@@ -502,6 +993,9 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
502
993
  for (const part of targetParts) {
503
994
  const cleaned = cleanShellWord(part);
504
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;
505
999
  addPath(targets, cleaned);
506
1000
  }
507
1001
  }