evolcore 0.0.28 → 0.0.29

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 (183) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/bin/ec-safe-output.js +270 -54
  3. package/dist/agents/baseagent-availability.js +37 -1
  4. package/dist/agents/baseagent.js +19 -9
  5. package/dist/agents/claude-runner.js +171 -13
  6. package/dist/agents/codex/app-server-client.js +6 -2
  7. package/dist/agents/codex/diagnostics.js +4 -3
  8. package/dist/agents/codex-runner.js +396 -95
  9. package/dist/agents/gemini-runner.js +35 -17
  10. package/dist/aun/group/history.js +130 -90
  11. package/dist/aun/group/operations.js +1 -3
  12. package/dist/aun/menu/transport.js +5 -3
  13. package/dist/aun/msg/managed-operation.js +70 -0
  14. package/dist/aun/msg/p2p.js +1 -3
  15. package/dist/aun/msg/upload.js +2 -2
  16. package/dist/aun/outbox.js +1 -37
  17. package/dist/channels/aun.js +249 -89
  18. package/dist/channels/daemon.js +18 -1
  19. package/dist/channels/feishu.js +1 -2
  20. package/dist/cli/agent-command.js +23 -3
  21. package/dist/cli/agent-create.js +68 -2
  22. package/dist/cli/agent.js +12 -2
  23. package/dist/cli/aun-commands.js +87 -13
  24. package/dist/cli/cli-argv.js +26 -0
  25. package/dist/cli/daemon-commands.js +40 -10
  26. package/dist/cli/data-command.js +4 -4
  27. package/dist/cli/handoff-command.js +2 -0
  28. package/dist/cli/index.js +18 -1
  29. package/dist/cli/managed-command-guard.js +10 -0
  30. package/dist/cli/memory-command.js +441 -0
  31. package/dist/cli/restart-monitor.js +62 -6
  32. package/dist/cli/skill-command.js +41 -0
  33. package/dist/cli/task-context.js +2 -23
  34. package/dist/cli/trigger-command.js +9 -1
  35. package/dist/cli/watch-msg-diag.js +209 -0
  36. package/dist/config/config-field-policy.js +52 -3
  37. package/dist/config/config-manager.js +17 -4
  38. package/dist/config/contact-book-store.js +2 -2
  39. package/dist/config/mention-mode.js +7 -2
  40. package/dist/config/merge.js +28 -9
  41. package/dist/config/resolved-config-op.js +1 -1
  42. package/dist/config/runtime-env.js +137 -0
  43. package/dist/config/schema-registry.js +11 -1
  44. package/dist/config-store.js +10 -10
  45. package/dist/core/audit/event-key.js +3 -1
  46. package/dist/core/audit/log-integrity.js +4 -0
  47. package/dist/core/auth/agent-delegation.js +2 -1
  48. package/dist/core/auth/auth-gateway.js +13 -6
  49. package/dist/core/auth/authorization-audit.js +54 -19
  50. package/dist/core/auth/operation-authorizer.js +17 -0
  51. package/dist/core/auth/operation-catalog.js +9 -0
  52. package/dist/core/causation/context.js +5 -1
  53. package/dist/core/command/agent-control.js +1 -2
  54. package/dist/core/command/command-handler.js +51 -23
  55. package/dist/core/command/memory-slash.js +270 -0
  56. package/dist/core/command/menu-catalog.js +1 -0
  57. package/dist/core/command/menu-handler.js +315 -60
  58. package/dist/core/command/menu-protocol.js +42 -4
  59. package/dist/core/command/slash-gate.js +10 -10
  60. package/dist/core/command/slash-handler.js +206 -64
  61. package/dist/core/data-migration.js +282 -505
  62. package/dist/core/event-catalog.js +26 -0
  63. package/dist/core/evolagent-registry.js +6 -0
  64. package/dist/core/evolagent.js +0 -10
  65. package/dist/core/handoff/store.js +1 -20
  66. package/dist/core/inference/text-inference.js +57 -1
  67. package/dist/core/memory/consolidation-context.js +505 -0
  68. package/dist/core/memory/consolidation-ops-records.js +124 -0
  69. package/dist/core/memory/consolidation-trigger.js +394 -0
  70. package/dist/core/memory/index-manager.js +308 -0
  71. package/dist/core/memory/launch-consolidation.js +395 -0
  72. package/dist/core/memory/memory-admin.js +234 -0
  73. package/dist/core/memory/memory-document.js +100 -0
  74. package/dist/core/memory/memory-session-policy.js +41 -0
  75. package/dist/core/memory/memory-signature.js +257 -0
  76. package/dist/core/memory/memory-writer.js +432 -0
  77. package/dist/core/memory/staleness.js +117 -0
  78. package/dist/core/message/auto-mention-gate.js +454 -0
  79. package/dist/core/message/im-renderer.js +69 -16
  80. package/dist/core/message/message-bridge.js +287 -21
  81. package/dist/core/message/message-log.js +1 -0
  82. package/dist/core/message/message-queue.js +114 -27
  83. package/dist/core/message/pending-hints.js +329 -161
  84. package/dist/core/message/response-engine.js +567 -80
  85. package/dist/core/permission/ec-command-parser.js +1029 -80
  86. package/dist/core/permission/protected-paths.js +376 -126
  87. package/dist/core/permission/sandbox-runtime.js +40 -12
  88. package/dist/core/permission/tool-error-code.js +96 -13
  89. package/dist/core/permission/tool-policy.js +276 -23
  90. package/dist/core/role/runtime-policy.js +3 -7
  91. package/dist/core/session/{runner-task-history.js → runner-event-summary.js} +1 -1
  92. package/dist/core/session/session-fs-store.js +0 -76
  93. package/dist/core/session/session-idle-monitor.js +355 -0
  94. package/dist/core/session/session-manager.js +478 -17
  95. package/dist/core/session/session-mapper.js +33 -16
  96. package/dist/core/session/session-renew.js +217 -155
  97. package/dist/core/session/{session-turns.js → session-transcript.js} +35 -6
  98. package/dist/core/skill/global-watcher.js +282 -0
  99. package/dist/core/skill/index-generator.js +84 -0
  100. package/dist/core/skill/startup.js +73 -0
  101. package/dist/core/skill/switch-resolver.js +115 -0
  102. package/dist/core/task/task-activity-store.js +219 -0
  103. package/dist/eck/kit-renderer.js +30 -3
  104. package/dist/eck/manifest-engine.js +26 -4
  105. package/dist/index.js +708 -18
  106. package/dist/ipc.js +564 -2
  107. package/dist/paths.js +30 -9
  108. package/dist/response-system/engines/v1/proactive-flow.js +116 -15
  109. package/dist/response-system/modes/single-session/index.js +3 -0
  110. package/dist/trigger/feedback.js +1 -0
  111. package/dist/trigger/manager.js +0 -10
  112. package/dist/trigger/scheduler.js +11 -0
  113. package/dist/trigger/validation.js +22 -0
  114. package/dist/utils/avatar-upload.js +59 -18
  115. package/dist/utils/error-dict.json +6 -3
  116. package/dist/utils/error-utils.js +27 -0
  117. package/dist/utils/logger.js +32 -10
  118. package/dist/utils/media-cache.js +84 -0
  119. package/dist/utils/msg-diag.js +97 -0
  120. package/dist/utils/welcome.js +37 -8
  121. package/kits/docs/AUTHORING.md +314 -0
  122. package/kits/docs/GUIDE.md +2 -0
  123. package/kits/docs/INDEX.md +2 -0
  124. package/kits/docs/aun/CHEATSHEET.md +4 -0
  125. package/kits/docs/aun/SDK.md +60 -0
  126. package/kits/docs/context-assembly.md +10 -6
  127. package/kits/docs/evolcore/INDEX.md +17 -3
  128. package/kits/docs/evolcore/agent-create.md +24 -0
  129. package/kits/docs/evolcore/agent.md +5 -2
  130. package/kits/docs/evolcore/config.md +1 -1
  131. package/kits/docs/evolcore/ec-capabilities.md +364 -0
  132. package/kits/docs/evolcore/group.md +34 -18
  133. package/kits/docs/evolcore/memory-architecture.md +78 -0
  134. package/kits/docs/evolcore/memory-consolidation-batches.md +133 -0
  135. package/kits/docs/evolcore/memory-consolidation.md +263 -0
  136. package/kits/docs/evolcore/memory-tags.md +63 -0
  137. package/kits/docs/evolcore/memory.md +110 -0
  138. package/kits/docs/evolcore/menu-client.md +27 -11
  139. package/kits/{schemas → docs/evolcore}/menu-exec-schema-commands.md +12 -6
  140. package/kits/docs/evolcore/menu-protocol.md +213 -0
  141. package/kits/docs/evolcore/msg.md +2 -0
  142. package/kits/docs/evolcore/trigger.md +7 -5
  143. package/kits/docs/path-registry.md +6 -8
  144. package/kits/docs/prompt-loading-architecture.md +5 -3
  145. package/kits/docs/venues/group.md +5 -4
  146. package/kits/eck_manifest.json +294 -88
  147. package/kits/eck_manifest.memory.json +257 -0
  148. package/kits/rules/03-identity.md +8 -8
  149. package/kits/rules/05-venue.md +4 -2
  150. package/kits/rules/06-channel.md +1 -1
  151. package/kits/schemas/_meta.json +276 -47
  152. package/kits/schemas/agent-config.schema.12.json +38 -2
  153. package/kits/schemas/agent-config.schema.13.json +591 -0
  154. package/kits/schemas/daemon.schema.6.json +9 -9
  155. package/kits/schemas/defaults.schema.5.json +29 -0
  156. package/kits/schemas/relation-config.schema.8.json +14 -1
  157. package/kits/schemas/relation-config.schema.9.json +129 -0
  158. package/kits/schemas/role-config.schema.1.json +3 -3
  159. package/kits/skills/.switches.json +3 -0
  160. package/kits/skills/ec-skill/SKILL.md +217 -0
  161. package/kits/skills/ec-skill/scripts/cli.mjs +128 -0
  162. package/kits/skills/ec-skill/scripts/deps.mjs +107 -0
  163. package/kits/skills/ec-skill/scripts/frontmatter.mjs +104 -0
  164. package/kits/skills/ec-skill/scripts/skill.mjs +629 -0
  165. package/kits/skills/ec-skill/scripts/template/SKILL.md.tmpl +30 -0
  166. package/kits/templates/decision/auto-mention.md +25 -0
  167. package/kits/templates/decision/session-renew.md +22 -0
  168. package/kits/templates/memory-consolidation/forget.md +104 -0
  169. package/kits/templates/memory-consolidation/generalize.md +76 -0
  170. package/kits/templates/memory-consolidation/notes.md +133 -0
  171. package/kits/templates/memory-consolidation/profile.md +58 -0
  172. package/kits/templates/memory-consolidation/promote.md +72 -0
  173. package/kits/templates/memory-consolidation/workflow.md +75 -0
  174. package/kits/templates/system-fragments/bootstrap.md +14 -5
  175. package/kits/templates/system-fragments/commands.md +1 -0
  176. package/kits/templates/system-fragments/group-history.md +6 -0
  177. package/kits/templates/system-fragments/identity.md +0 -3
  178. package/kits/templates/system-fragments/memory.md +79 -0
  179. package/kits/templates/system-fragments/reply.md +1 -0
  180. package/kits/templates/system-fragments/venue.md +1 -1
  181. package/package.json +4 -6
  182. package/dist/trigger/legacy-session-history.js +0 -19
  183. package/kits/templates/system-fragments/group-unread-history.md +0 -7
@@ -1,5 +1,203 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { isTrustedWindowsShellExecutable } from '../../utils/windows-shell-trust.js';
2
- const HEAD_EXECUTABLES = new Set(['head', '/bin/head', '/usr/bin/head']);
4
+ function parseApplyPatchCommand(command) {
5
+ const input = command.trim();
6
+ let commandText = input;
7
+ if (commandText.startsWith('&'))
8
+ commandText = commandText.slice(1).trimStart();
9
+ const executableMatch = commandText.match(/^(?:"([^"]+)"|'([^']+)'|([^\s]+))/u);
10
+ if (!executableMatch)
11
+ return { kind: 'not-apply-patch' };
12
+ const executable = executableMatch[1] ?? executableMatch[2] ?? executableMatch[3] ?? '';
13
+ if (!isApplyPatchExecutable(executable))
14
+ return { kind: 'not-apply-patch' };
15
+ const remainder = commandText.slice(executableMatch[0].length).trimStart();
16
+ if (!remainder)
17
+ return { kind: 'invalid', reason: 'missing patch document' };
18
+ const literal = parseLiteralShellCommand(input, { allowManagedTmpDir: true });
19
+ if (literal.ok && argvUsesShellApplyPatch(literal.argv)) {
20
+ if (literal.argv.length !== 2)
21
+ return { kind: 'invalid', reason: 'apply_patch must carry exactly one patch document' };
22
+ const fileChanges = parseApplyPatchDocument(literal.argv[1]);
23
+ return fileChanges
24
+ ? { kind: 'file-change', fileChanges }
25
+ : { kind: 'invalid', reason: 'patch document is not a complete apply_patch document' };
26
+ }
27
+ const heredoc = remainder.match(/^<<-?\s*(?:'([^']+)'|"([^"]+)"|([^\s]+))\s*\r?\n([\s\S]*)$/u);
28
+ if (!heredoc)
29
+ return { kind: 'invalid', reason: 'apply_patch must use one literal patch document' };
30
+ const delimiter = heredoc[1] ?? heredoc[2] ?? heredoc[3];
31
+ const bodyLines = heredoc[4].replace(/\r\n?/gu, '\n').split('\n');
32
+ const delimiterIndex = bodyLines.findIndex(line => line === delimiter);
33
+ if (delimiterIndex < 0 || bodyLines.slice(delimiterIndex + 1).some(line => line.trim() !== '')) {
34
+ return { kind: 'invalid', reason: 'heredoc delimiter is incomplete or has trailing commands' };
35
+ }
36
+ const fileChanges = parseApplyPatchDocument(bodyLines.slice(0, delimiterIndex).join('\n'));
37
+ return fileChanges
38
+ ? { kind: 'file-change', fileChanges }
39
+ : { kind: 'invalid', reason: 'patch document is not a complete apply_patch document' };
40
+ }
41
+ function composedApplyPatchOnCommandLine(command) {
42
+ const commandLine = command.split(/\r?\n/u, 1)[0] ?? command;
43
+ return commandLine.split(/[|;&]/u).slice(1).some(segment => {
44
+ const executable = leadingShellExecutable(segment);
45
+ return !!executable && isApplyPatchExecutable(executable);
46
+ });
47
+ }
48
+ export function parseShellApplyPatchInvocation(input, options = {}, parsedArgv) {
49
+ const rawCommand = typeof input.command === 'string' ? input.command : '';
50
+ const rawCommandArgv = Array.isArray(input.commandArgv)
51
+ && input.commandArgv.every(value => typeof value === 'string')
52
+ ? input.commandArgv
53
+ : undefined;
54
+ const candidates = [];
55
+ const addCandidate = (value) => {
56
+ if (value && !candidates.includes(value))
57
+ candidates.push(value);
58
+ };
59
+ addCandidate(rawCommand);
60
+ if (rawCommandArgv) {
61
+ if (argvUsesShellApplyPatch(rawCommandArgv)) {
62
+ if (rawCommandArgv.length === 2) {
63
+ const fileChanges = parseApplyPatchDocument(rawCommandArgv[1]);
64
+ return fileChanges
65
+ ? { kind: 'file-change', fileChanges }
66
+ : { kind: 'invalid', reason: 'patch document is not a complete apply_patch document' };
67
+ }
68
+ return { kind: 'invalid', reason: 'apply_patch must carry exactly one patch document' };
69
+ }
70
+ addCandidate(resolveCodexShellCarrierArgv(rawCommandArgv)?.command);
71
+ }
72
+ if (parsedArgv && argvUsesShellApplyPatch(parsedArgv)) {
73
+ return { kind: 'invalid', reason: 'apply_patch payload could not be reconstructed safely' };
74
+ }
75
+ addCandidate(resolveCodexShellCarrierString(rawCommand)?.command);
76
+ let sawApplyPatch = false;
77
+ let invalidReason = 'apply_patch payload could not be reconstructed safely';
78
+ for (const candidate of candidates) {
79
+ const parsed = parseApplyPatchCommand(candidate);
80
+ if (parsed.kind !== 'not-apply-patch') {
81
+ sawApplyPatch = true;
82
+ if (parsed.kind === 'file-change')
83
+ return parsed;
84
+ invalidReason = parsed.reason;
85
+ }
86
+ else if (composedApplyPatchOnCommandLine(candidate)) {
87
+ sawApplyPatch = true;
88
+ invalidReason = 'apply_patch must be the only shell command';
89
+ }
90
+ }
91
+ return sawApplyPatch ? { kind: 'invalid', reason: invalidReason } : { kind: 'not-apply-patch' };
92
+ }
93
+ /**
94
+ * stdout filters allowed after an EC invocation. Each entry reads stdin and
95
+ * writes stdout only, and its option grammar is an allowlist, so a filter can
96
+ * never acquire a file operand, a write target, or a process spawn. Changing
97
+ * this table means changing `bin/ec-safe-output.js`, which repeats the same
98
+ * validation before it spawns the process.
99
+ */
100
+ const STDOUT_FILTER_SPECS = new Map([
101
+ // Every `tail` follow/retry option is absent below, so `tail -f` is rejected
102
+ // instead of being allowed to hold the pipe open.
103
+ ['head', {
104
+ flags: new Set(),
105
+ valueOptions: new Set(['-n', '-c', '--lines', '--bytes']),
106
+ maxOperands: 0,
107
+ numericShorthand: true,
108
+ }],
109
+ ['tail', {
110
+ flags: new Set(),
111
+ valueOptions: new Set(['-n', '-c', '--lines', '--bytes']),
112
+ maxOperands: 0,
113
+ numericShorthand: true,
114
+ }],
115
+ // `grep` needs a pattern: either `-e`/`--regexp` or the single leading
116
+ // operand. Once an explicit pattern option appears, a bare operand would be
117
+ // a file to read, so the operand budget drops to zero. `-r`/`-R`/`-f` are
118
+ // absent from both tables, which keeps recursion and pattern files out.
119
+ ['grep', {
120
+ flags: new Set([
121
+ '-i', '--ignore-case', '-v', '--invert-match', '-c', '--count',
122
+ '-l', '--files-with-matches', '-L', '--files-without-match', '-o', '--only-matching',
123
+ '-n', '--line-number', '-h', '--no-filename', '-H', '--with-filename',
124
+ '-q', '--quiet', '--silent', '-s', '--no-messages', '-w', '--word-regexp',
125
+ '-x', '--line-regexp', '-F', '--fixed-strings', '-E', '--extended-regexp',
126
+ '-G', '--basic-regexp', '-P', '--perl-regexp', '-a', '--text', '-I',
127
+ '-z', '--null-data', '-U', '--binary', '--line-buffered', '--no-group-separator',
128
+ ]),
129
+ valueOptions: new Set([
130
+ '-m', '--max-count', '-A', '--after-context', '-B', '--before-context',
131
+ '-C', '--context', '--color', '--colour', '--binary-files', '--label',
132
+ '--group-separator', '-d', '--directories', '--include', '--exclude', '--exclude-dir',
133
+ ]),
134
+ patternValueOptions: new Set(['-e', '--regexp']),
135
+ maxOperands: 1,
136
+ }],
137
+ // `wc`, `sort`, `cut` and `uniq` take no operands here: a path operand would
138
+ // make them read a file instead of the EC stdout. Options that name an input
139
+ // or output file (`--files0-from`, `sort -o`/`-T`/`--random-source`,
140
+ // `uniq --group`) are absent from both tables and therefore rejected.
141
+ ['wc', {
142
+ flags: new Set([
143
+ '-c', '-m', '-l', '-w', '-L',
144
+ '--bytes', '--chars', '--lines', '--words', '--max-line-length',
145
+ ]),
146
+ valueOptions: new Set(),
147
+ maxOperands: 0,
148
+ }],
149
+ ['sort', {
150
+ flags: new Set([
151
+ '-b', '-d', '-f', '-g', '-h', '-i', '-M', '-n', '-r', '-R', '-s', '-u', '-V', '-z',
152
+ '--ignore-leading-blanks', '--dictionary-order', '--ignore-case', '--general-numeric-sort',
153
+ '--human-numeric-sort', '--ignore-nonprinting', '--month-sort', '--numeric-sort',
154
+ '--random-sort', '--reverse', '--stable', '--unique', '--version-sort', '--zero-terminated',
155
+ '--check', '--merge',
156
+ ]),
157
+ valueOptions: new Set([
158
+ '-k', '-t', '-S', '--key', '--field-separator',
159
+ '--batch-size', '--buffer-size', '--parallel', '--sort',
160
+ ]),
161
+ maxOperands: 0,
162
+ }],
163
+ ['cut', {
164
+ flags: new Set(['-s', '--only-delimited', '-n', '--complement', '-z', '--zero-terminated']),
165
+ valueOptions: new Set([
166
+ '-b', '-c', '-f', '-d', '--bytes', '--characters', '--fields', '--delimiter', '--output-delimiter',
167
+ ]),
168
+ maxOperands: 0,
169
+ }],
170
+ ['uniq', {
171
+ flags: new Set([
172
+ '-c', '-d', '-D', '-u', '-i', '-z',
173
+ '--count', '--repeated', '--all-repeated', '--unique', '--ignore-case', '--zero-terminated',
174
+ ]),
175
+ valueOptions: new Set(['-f', '-s', '--skip-fields', '--skip-chars']),
176
+ maxOperands: 0,
177
+ }],
178
+ // `tr` takes its character sets as operands, which are never files.
179
+ ['tr', {
180
+ flags: new Set([
181
+ '-c', '-C', '-d', '-s', '-t',
182
+ '--complement', '--delete', '--squeeze-repeats', '--truncate-set1',
183
+ ]),
184
+ valueOptions: new Set(),
185
+ maxOperands: 2,
186
+ }],
187
+ ]);
188
+ /** Accept `head`, `/bin/head` and `/usr/bin/head` for every allowed filter. */
189
+ function normalizeStdoutFilterExecutable(value) {
190
+ if (STDOUT_FILTER_SPECS.has(value))
191
+ return value;
192
+ for (const prefix of ['/bin/', '/usr/bin/']) {
193
+ if (value.startsWith(prefix)) {
194
+ const bare = value.slice(prefix.length);
195
+ if (STDOUT_FILTER_SPECS.has(bare))
196
+ return bare;
197
+ }
198
+ }
199
+ return undefined;
200
+ }
3
201
  // Codex app-server sends command executions through a fixed shell carrier.
4
202
  // Treat the carrier as syntax only after parsing it as literal argv; never
5
203
  // accept an arbitrary shell command string or unwrap composition here.
@@ -28,8 +226,22 @@ const CODEX_POWERSHELL_INERT_SWITCHES = new Set([
28
226
  // `/e:*`, and `/f:*` can change process lifetime or command semantics and are
29
227
  // intentionally not treated as a trusted Codex carrier.
30
228
  const CODEX_CMD_INERT_SWITCHES = new Set(['/d', '/s', '/q', '/a', '/u']);
31
- const EVOLCORE_EXECUTABLE_ALIASES = new Set(['ec', 'ec.cmd', 'ec.exe']);
229
+ const EVOLCORE_EXECUTABLE_ALIASES = new Set(['ec', 'ec.cmd', 'ec.exe', 'ec.ps1']);
32
230
  const EVOLCORE_NODE_EXECUTABLES = new Set(['node', 'node.exe']);
231
+ const APPLY_PATCH_EXECUTABLE_BASENAMES = new Set([
232
+ 'apply_patch',
233
+ 'apply_patch.bat',
234
+ 'apply_patch.cmd',
235
+ 'apply_patch.exe',
236
+ 'apply_patch.ps1',
237
+ ]);
238
+ const EVOLCORE_SUBCOMMANDS = new Set([
239
+ 'agent', 'aid', 'bench', 'config', 'contact', 'ctl', 'data', 'diag', 'diagnose',
240
+ 'fs', 'gateway', 'group', 'handoff', 'init', 'link-rules', 'logs', 'menu-client',
241
+ 'mode', 'model', 'msg', 'mv', 'net', 'queue', 'response', 'restart',
242
+ 'restart-monitor', 'rpc', 'start', 'stats', 'status', 'stop', 'storage', 'task',
243
+ 'trigger', 'version', 'watch',
244
+ ]);
33
245
  // Development/install entrypoints that are part of this repository's CLI.
34
246
  // Only these exact relative paths are canonicalized; arbitrary Node scripts
35
247
  // must never become an EC command merely because their filename is similar.
@@ -41,6 +253,45 @@ const POWERSHELL_UNQUOTED_META = new Set([
41
253
  ';', '&', '|', '<', '>', '$', '(', ')', '*', '?', '[', ']', '{', '}', '#', '@',
42
254
  ]);
43
255
  const AMBIGUOUS_FILE_PATH_RE = /[\0\r\n$`*?\[\]{}<>|;&]/;
256
+ /**
257
+ * 用空格替换 heredoc 正文,保留命令行本身。
258
+ *
259
+ * heredoc 正文是**数据**(提交信息、脚本片段、文档示例),不是 shell 语法。
260
+ * 若不剥离,正文里出现的 `; ec ...`、`&& ec ...` 会被命令边界扫描当成真的
261
+ * 拼接调用——`git commit -F - <<'MSG'` 的提交信息里写了 `` `cd "x"; ec ...` ``
262
+ * 就会整条被拒,且报的还是"拼接命令",病因完全指错方向。
263
+ *
264
+ * 只处理 heredoc,不动其它多行结构:不支持 heredoc 的场景(比如真正的多行
265
+ * 拼接攻击)仍然走原有的 fail-closed 路径。
266
+ */
267
+ function stripHeredocBodies(command) {
268
+ if (!command.includes('<<'))
269
+ return command;
270
+ const lines = command.split('\n');
271
+ const out = [];
272
+ for (let index = 0; index < lines.length; index++) {
273
+ const line = lines[index];
274
+ // 收集本行声明的所有 heredoc 终止符(`<<WORD` / `<<'WORD'` / `<<"WORD"` / `<<-WORD`)。
275
+ const delimiters = [];
276
+ const pattern = /<<-?[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/g;
277
+ let match;
278
+ while ((match = pattern.exec(line)) !== null)
279
+ delimiters.push(match[2]);
280
+ out.push(line);
281
+ if (delimiters.length === 0)
282
+ continue;
283
+ // 逐行丢弃,直到每个终止符都出现过;未闭合时丢弃剩余全部(保守:正文永远不是命令)。
284
+ let remaining = delimiters.slice();
285
+ while (remaining.length > 0 && index + 1 < lines.length) {
286
+ index++;
287
+ const candidate = lines[index].trim();
288
+ const hit = remaining.indexOf(candidate);
289
+ if (hit >= 0)
290
+ remaining.splice(hit, 1);
291
+ }
292
+ }
293
+ return out.join('\n');
294
+ }
44
295
  // This is intentionally limited to the command boundary. It is not a search
45
296
  // for `ec` anywhere in the input: quoted arguments, regexps and log text must
46
297
  // never establish an EC invocation.
@@ -85,7 +336,7 @@ function hasEcCommandPrefix(command) {
85
336
  }
86
337
  if (boundary && /\s/.test(char))
87
338
  continue;
88
- if (boundary && /^ec(?:\.cmd|\.exe)?(?=$|\s|[;&|`()<>])/i.test(command.slice(index)))
339
+ if (boundary && /^ec(?:\.cmd|\.exe|\.ps1)?(?=$|\s|[;&|`()<>])/i.test(command.slice(index)))
89
340
  return true;
90
341
  boundary = false;
91
342
  }
@@ -100,7 +351,7 @@ function trimShellSpacing(value) {
100
351
  * Relative names remain restricted to the fixed executable allowlists above.
101
352
  */
102
353
  function isAbsoluteCustomShellPath(value, executableNames) {
103
- if (!value || /[\0\r\n"'<>|?*]/.test(value))
354
+ if (!value || /[\0\r\n"'<>|?*`$;&|(){}\[\]]/.test(value))
104
355
  return false;
105
356
  const normalized = value.replaceAll('\\', '/');
106
357
  if (!/^[A-Za-z]:\//.test(normalized) && !normalized.startsWith('//'))
@@ -108,6 +359,114 @@ function isAbsoluteCustomShellPath(value, executableNames) {
108
359
  const basename = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase();
109
360
  return executableNames.has(basename);
110
361
  }
362
+ function isStandardNpmEvolcoreShim(content, basename) {
363
+ const lines = content.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').split('\n')
364
+ .map(line => line.trim())
365
+ .filter(Boolean);
366
+ if (basename === 'ec.cmd') {
367
+ const patterns = [
368
+ /^@echo off$/i,
369
+ /^goto start$/i,
370
+ /^:find_dp0$/i,
371
+ /^set dp0=%~dp0$/i,
372
+ /^exit \/b$/i,
373
+ /^:start$/i,
374
+ /^setlocal$/i,
375
+ /^call :find_dp0$/i,
376
+ /^if exist "%dp0%[\\/]node\.exe" \($/i,
377
+ /^set "_prog=%dp0%[\\/]node\.exe"$/i,
378
+ /^\) else \($/i,
379
+ /^set "_prog=node"$/i,
380
+ /^set pathext=%pathext:;\.js;=;%$/i,
381
+ /^\)$/i,
382
+ /^endlocal\s*&\s*goto #_undefined_#\s+2>nul\s+\|\|\s+title %comspec%\s*&\s+"%_prog%"\s+"%dp0%[\\/]node_modules[\\/]evolcore[\\/]bin[\\/]ec\.js"\s+%\*$/i,
383
+ ];
384
+ return lines.length === patterns.length && lines.every((line, index) => patterns[index].test(line));
385
+ }
386
+ const patterns = [
387
+ /^#!\/usr\/bin\/env\s+pwsh$/i,
388
+ /^\$basedir\s*=\s*split-path\s+\$myinvocation\.mycommand\.definition\s+-parent$/i,
389
+ /^\$exe\s*=\s*""$/i,
390
+ /^if\s*\(\$PSVersionTable\.PSVersion\s+-lt\s+"6\.0"\s+-or\s+\$IsWindows\)\s*\{$/i,
391
+ /^#\s*Fix case when both the Windows and Linux builds of Node$/i,
392
+ /^#\s*are installed in the same directory$/i,
393
+ /^\$exe\s*=\s*"\.exe"$/i,
394
+ /^\}$/,
395
+ /^\$ret\s*=\s*0$/i,
396
+ /^if\s*\(Test-Path\s+"\$basedir\/node\$exe"\)\s*\{$/i,
397
+ /^#\s*Support pipeline input$/i,
398
+ /^if\s*\(\$MyInvocation\.ExpectingInput\)\s*\{$/i,
399
+ /^\$input\s+\|\s+&\s+"\$basedir\/node\$exe"\s+"\$basedir\/node_modules\/evolcore\/bin\/ec\.js"\s+\$args$/i,
400
+ /^\}\s*else\s*\{$/i,
401
+ /^&\s+"\$basedir\/node\$exe"\s+"\$basedir\/node_modules\/evolcore\/bin\/ec\.js"\s+\$args$/i,
402
+ /^\}$/,
403
+ /^\$ret\s*=\s*\$LASTEXITCODE$/i,
404
+ /^\}\s*else\s*\{$/i,
405
+ /^#\s*Support pipeline input$/i,
406
+ /^if\s*\(\$MyInvocation\.ExpectingInput\)\s*\{$/i,
407
+ /^\$input\s+\|\s+&\s+"node\$exe"\s+"\$basedir\/node_modules\/evolcore\/bin\/ec\.js"\s+\$args$/i,
408
+ /^\}\s*else\s*\{$/i,
409
+ /^&\s+"node\$exe"\s+"\$basedir\/node_modules\/evolcore\/bin\/ec\.js"\s+\$args$/i,
410
+ /^\}$/,
411
+ /^\$ret\s*=\s*\$LASTEXITCODE$/i,
412
+ /^\}$/,
413
+ /^exit\s+\$ret$/i,
414
+ ];
415
+ return lines.length === patterns.length && lines.every((line, index) => patterns[index].test(line));
416
+ }
417
+ /**
418
+ * PowerShell's call operator executes the path exactly as supplied. The
419
+ * canonical argv is only safe to arm when that path is the npm shim for this
420
+ * package; accepting an arbitrary same-named `ec.cmd` would let a wrapper run
421
+ * unrelated code while receiving an EC delegation approval.
422
+ */
423
+ function isTrustedEvolcoreCallPath(value) {
424
+ if (!isAbsoluteCustomShellPath(value, EVOLCORE_EXECUTABLE_ALIASES))
425
+ return false;
426
+ if (process.platform !== 'win32')
427
+ return false;
428
+ const normalized = value.replaceAll('\\', '/');
429
+ const basename = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase();
430
+ if (basename !== 'ec.cmd' && basename !== 'ec.ps1')
431
+ return false;
432
+ let shimPath;
433
+ try {
434
+ shimPath = fs.realpathSync.native(value);
435
+ const stat = fs.statSync(shimPath);
436
+ if (!stat.isFile())
437
+ return false;
438
+ }
439
+ catch {
440
+ return false;
441
+ }
442
+ let content;
443
+ try {
444
+ content = fs.readFileSync(shimPath, 'utf8');
445
+ }
446
+ catch {
447
+ return false;
448
+ }
449
+ // npm's Windows shims use either `%dp0%\\node_modules\\evolcore\\...`
450
+ // or `$basedir/node_modules/evolcore/...`. The PowerShell shim references
451
+ // the same target once per input branch, so validate one distinct target
452
+ // rather than counting textual occurrences.
453
+ const nodeModuleRefs = content.match(/node_modules[^"'`\r\n\s]*/gi) ?? [];
454
+ const targetMatches = content.match(/node_modules[\\/]evolcore[\\/]bin[\\/]ec\.js/gi) ?? [];
455
+ const normalizedTargets = new Set(targetMatches.map(value => value.replaceAll('\\', '/').toLowerCase()));
456
+ if (targetMatches.length === 0
457
+ || normalizedTargets.size !== 1
458
+ || nodeModuleRefs.some(value => !/^node_modules[\\/]evolcore[\\/]bin[\\/]ec\.js$/i.test(value))
459
+ || !isStandardNpmEvolcoreShim(content, basename)) {
460
+ return false;
461
+ }
462
+ const targetPath = path.win32.join(path.win32.dirname(shimPath), 'node_modules', 'evolcore', 'bin', 'ec.js');
463
+ try {
464
+ return fs.statSync(targetPath).isFile();
465
+ }
466
+ catch {
467
+ return false;
468
+ }
469
+ }
111
470
  function isNodeExecutable(value) {
112
471
  if (!value)
113
472
  return false;
@@ -203,6 +562,20 @@ function isShellSpacing(value) {
203
562
  function isLiteralArgBoundary(value) {
204
563
  return value === undefined || isShellSpacing(value);
205
564
  }
565
+ /**
566
+ * POSIX 引号闭合后允许直接出现的字符。
567
+ *
568
+ * 与 `isLiteralArgBoundary` 的区别:`cd "x"; ec ...` 在 bash 里完全合法,
569
+ * 引号后紧跟 `;`/`&`/`|` 等操作符是标准写法。若只认空白,这类输入会被
570
+ * 误判成 `invalid-quote`("引号未闭合")——词法上引号其实配对完好,
571
+ * 报错方向完全错误,把人引去排查转义。
572
+ *
573
+ * 这里放宽的只是**引号是否闭合**的判定;操作符本身仍由后续非引号分支
574
+ * 判为 `shell-composition`,拦截力度不变,只让错误归因准确。
575
+ */
576
+ function isPosixQuotedTokenBoundary(value) {
577
+ return value === undefined || isShellSpacing(value) || SHELL_UNQUOTED_META.has(value);
578
+ }
206
579
  function isShellParameterExpansionStart(value) {
207
580
  return value === '(' || value === '{' || !!value && /[A-Za-z0-9_@*#?$!\-]/.test(value);
208
581
  }
@@ -213,7 +586,7 @@ function hasCarrierEcCommandPrefix(command, dialect) {
213
586
  const inner = input.length >= 2 && input.startsWith('"') && input.endsWith('"')
214
587
  ? input.slice(1, -1)
215
588
  : input;
216
- return /^\s*ec(?:\.cmd|\.exe)?(?:\s|$)/i.test(inner);
589
+ return /^\s*ec(?:\.cmd|\.exe|\.ps1)?(?:\s|$)/i.test(inner);
217
590
  }
218
591
  /** Normalize the Windows npm shims to the canonical EvolCore CLI name. */
219
592
  export function normalizeEvolcoreCommandArgv(argv) {
@@ -229,11 +602,151 @@ export function normalizeEvolcoreCommandArgv(argv) {
229
602
  }
230
603
  return [...argv];
231
604
  }
605
+ const INLINE_TEXT_FLAGS = {
606
+ agentNew: new Set(['--description', '--persona-text', '--handoff']),
607
+ agentPersona: new Set(['--text']),
608
+ agentWelcome: new Set(['--text']),
609
+ msgSend: new Set(['--description', '--text', '--transcript', '--handoff-context']),
610
+ groupSend: new Set(['--text', '--transcript']),
611
+ groupCreate: new Set(['--description']),
612
+ groupUpdate: new Set(['--description']),
613
+ groupRules: new Set(['--question']),
614
+ groupJoin: new Set(['--message', '--answer']),
615
+ groupBlock: new Set(['--reason']),
616
+ triggerCreate: new Set(['--prompt']),
617
+ triggerUpdate: new Set(['--prompt', '--description']),
618
+ };
619
+ const MESSAGE_POSITIONAL_FLAGS = new Set([
620
+ '--file', '--payload', '--as', '--content-type', '--text', '--transcript',
621
+ '--thread', '--return', '--title', '--description', '--text-from-file',
622
+ '--handoff-context', '--aun-path', '--app', '--format', '--encrypt', '--no-encrypt',
623
+ ]);
624
+ /** Decode the EC inline text contract exactly once, after shell parsing. */
625
+ export function decodeEvolcoreInlineText(value) {
626
+ return value.replace(/\\([\\nrt])/g, (_match, escaped) => {
627
+ if (escaped === 'n')
628
+ return '\n';
629
+ if (escaped === 'r')
630
+ return '\r';
631
+ if (escaped === 't')
632
+ return '\t';
633
+ return '\\';
634
+ });
635
+ }
636
+ function decodeInlineFlags(argv, flags) {
637
+ for (let index = 0; index < argv.length; index++) {
638
+ const value = argv[index];
639
+ if (value === '--')
640
+ break;
641
+ if (flags.has(value) && argv[index + 1] !== undefined) {
642
+ argv[index + 1] = decodeEvolcoreInlineText(argv[index + 1]);
643
+ index++;
644
+ continue;
645
+ }
646
+ const separator = value.indexOf('=');
647
+ if (separator > 0 && flags.has(value.slice(0, separator))) {
648
+ argv[index] = value.slice(0, separator + 1) + decodeEvolcoreInlineText(value.slice(separator + 1));
649
+ }
650
+ }
651
+ }
652
+ function decodePositionalText(argv, start, optionFlags) {
653
+ for (let index = start; index < argv.length; index++) {
654
+ const value = argv[index];
655
+ if (value === '--') {
656
+ for (let bodyIndex = index + 1; bodyIndex < argv.length; bodyIndex++) {
657
+ argv[bodyIndex] = decodeEvolcoreInlineText(argv[bodyIndex]);
658
+ }
659
+ return;
660
+ }
661
+ if (value.startsWith('--')) {
662
+ const separator = value.indexOf('=');
663
+ if (separator > 0)
664
+ continue;
665
+ if (optionFlags.has(value) && argv[index + 1] !== undefined)
666
+ index++;
667
+ continue;
668
+ }
669
+ argv[index] = decodeEvolcoreInlineText(value);
670
+ }
671
+ }
672
+ function canonicalizeHandoffText(argv) {
673
+ const action = argv[2];
674
+ if (action !== 'return' && action !== 'recover-return')
675
+ return;
676
+ const delimiter = argv.indexOf('--', 2);
677
+ if (delimiter >= 0) {
678
+ for (let index = delimiter + 1; index < argv.length; index++) {
679
+ argv[index] = decodeEvolcoreInlineText(argv[index]);
680
+ }
681
+ return;
682
+ }
683
+ const fileIndex = argv.indexOf('--text-from-file', 2);
684
+ for (let index = 2; index < argv.length; index++) {
685
+ if (index === fileIndex) {
686
+ index++;
687
+ continue;
688
+ }
689
+ if (!argv[index].startsWith('--'))
690
+ argv[index] = decodeEvolcoreInlineText(argv[index]);
691
+ }
692
+ }
693
+ /**
694
+ * Convert an EC invocation to the one argv representation shared by approval,
695
+ * delegation hashing, IPC, and command execution. Shell syntax is already
696
+ * resolved before this function is called; this function only normalizes the
697
+ * EC entrypoint and applies the command-specific inline text contract.
698
+ */
699
+ export function canonicalizeEvolcoreCommandArgv(argv) {
700
+ const canonical = normalizeEvolcoreCommandArgv(argv);
701
+ if (canonical[0] !== 'ec')
702
+ return canonical;
703
+ const result = [...canonical];
704
+ const command = result[1];
705
+ const subcommand = result[2];
706
+ if (command === 'agent') {
707
+ if (subcommand === 'new')
708
+ decodeInlineFlags(result, INLINE_TEXT_FLAGS.agentNew);
709
+ else if (subcommand === 'persona')
710
+ decodeInlineFlags(result, INLINE_TEXT_FLAGS.agentPersona);
711
+ else if (subcommand === 'welcome')
712
+ decodeInlineFlags(result, INLINE_TEXT_FLAGS.agentWelcome);
713
+ }
714
+ else if (command === 'msg' && subcommand === 'send') {
715
+ decodeInlineFlags(result, INLINE_TEXT_FLAGS.msgSend);
716
+ decodePositionalText(result, 5, MESSAGE_POSITIONAL_FLAGS);
717
+ }
718
+ else if (command === 'group') {
719
+ if (subcommand === 'send') {
720
+ decodeInlineFlags(result, INLINE_TEXT_FLAGS.groupSend);
721
+ decodePositionalText(result, 5, MESSAGE_POSITIONAL_FLAGS);
722
+ }
723
+ else if (subcommand === 'create')
724
+ decodeInlineFlags(result, INLINE_TEXT_FLAGS.groupCreate);
725
+ else if (subcommand === 'update')
726
+ decodeInlineFlags(result, INLINE_TEXT_FLAGS.groupUpdate);
727
+ else if (subcommand === 'rules')
728
+ decodeInlineFlags(result, INLINE_TEXT_FLAGS.groupRules);
729
+ else if (subcommand === 'join')
730
+ decodeInlineFlags(result, INLINE_TEXT_FLAGS.groupJoin);
731
+ else if (subcommand === 'block')
732
+ decodeInlineFlags(result, INLINE_TEXT_FLAGS.groupBlock);
733
+ }
734
+ else if (command === 'trigger') {
735
+ if (subcommand === 'create' || subcommand === 'set')
736
+ decodeInlineFlags(result, INLINE_TEXT_FLAGS.triggerCreate);
737
+ else if (subcommand === 'update')
738
+ decodeInlineFlags(result, INLINE_TEXT_FLAGS.triggerUpdate);
739
+ }
740
+ else if (command === 'handoff') {
741
+ canonicalizeHandoffText(result);
742
+ }
743
+ return result;
744
+ }
232
745
  const POWERSHELL_CREDENTIAL_ENV_RE = /^(EVOLCORE_CODEX_SESSION_TOKEN|EVOLCORE_DELEGATION_TOKEN)$/i;
233
746
  /** Detect a PowerShell prefix that materializes an EvolCore session credential. */
234
747
  export function hasPowerShellCredentialAssignment(command) {
235
748
  return /^\s*\$env\s*:\s*(?:EVOLCORE_CODEX_SESSION_TOKEN|EVOLCORE_DELEGATION_TOKEN)\b/i.test(command)
236
- && /\bec(?:\.cmd|\.exe)?(?:\s|$)/i.test(command);
749
+ && /\bec(?:\.cmd|\.exe|\.ps1)?(?:\s|$)/i.test(command);
237
750
  }
238
751
  /**
239
752
  * Windows Codex may materialize one environment variable assignment directly
@@ -354,10 +867,41 @@ export function parseCodexCarrierCommand(carrier, options = {}) {
354
867
  && options.expectedCredentialToken !== unwrapped.token)) {
355
868
  return parseFailure('shell-composition', carrier.command, 0, 0, carrier.dialect);
356
869
  }
357
- const parsed = parseLiteralShellCommand(unwrapped.command, {
870
+ const materialized = parseLiteralShellCommand(unwrapped.command, {
358
871
  ...options,
359
872
  dialect: carrier.dialect,
360
873
  });
874
+ if (materialized.ok && carrier.dialect === 'powershell') {
875
+ const nestedCarrier = resolveCodexCmdCarrierString(unwrapped.command);
876
+ if (nestedCarrier) {
877
+ const nested = parseCodexCarrierCommand(nestedCarrier, options);
878
+ if (!nested.ok)
879
+ return nested;
880
+ const executeIndex = materialized.argv.findIndex((value, index) => index > 0 && value.toLowerCase() === '/c');
881
+ if (executeIndex < 1 || executeIndex >= materialized.argv.length - 1) {
882
+ return parseFailure('shell-composition', unwrapped.command, 0, 0, carrier.dialect);
883
+ }
884
+ const bodyArgv = materialized.argv.slice(executeIndex + 1);
885
+ const materializedBody = bodyArgv.length === 1
886
+ ? parseLiteralShellCommand(bodyArgv[0], { ...options, dialect: 'cmd' })
887
+ : { ok: true, argv: normalizeEvolcoreCommandArgv(bodyArgv) };
888
+ if (!materializedBody.ok
889
+ || materializedBody.argv.length !== nested.argv.length
890
+ || materializedBody.argv.some((value, index) => value !== nested.argv[index])) {
891
+ return parseFailure('shell-composition', unwrapped.command, 0, 0, carrier.dialect);
892
+ }
893
+ return {
894
+ ...nested,
895
+ ...(unwrapped.kind === 'valid' ? { credentialToken: unwrapped.token } : {}),
896
+ };
897
+ }
898
+ }
899
+ const parsed = materialized;
900
+ if (parsed.ok && carrier.dialect === 'powershell'
901
+ && /^\s*ec\.cmd(?:\s|$)/i.test(unwrapped.command)
902
+ && parsed.argv.some(value => /[\r\n]/.test(value))) {
903
+ return parseFailure('lossy-cmd-shim', unwrapped.command, 0, 0, 'powershell');
904
+ }
361
905
  return parsed.ok
362
906
  ? {
363
907
  ...parsed,
@@ -384,12 +928,107 @@ function isPowerShellSingleQuote(char) {
384
928
  function isPowerShellDoubleQuote(char) {
385
929
  return char !== undefined && /["\u201c\u201d\u201e]/u.test(char);
386
930
  }
931
+ function powerShellCallQuoteKind(char) {
932
+ if (isPowerShellSingleQuote(char))
933
+ return 'single';
934
+ if (isPowerShellDoubleQuote(char))
935
+ return 'double';
936
+ return undefined;
937
+ }
938
+ function isPowerShellCallQuote(kind, char) {
939
+ return kind === 'single' ? isPowerShellSingleQuote(char) : isPowerShellDoubleQuote(char);
940
+ }
941
+ function parsePowerShellCallOperator(command) {
942
+ const input = trimShellSpacing(command);
943
+ if (!input.startsWith('&'))
944
+ return undefined;
945
+ let index = 1;
946
+ while (isShellSpacing(input[index]))
947
+ index++;
948
+ const quote = powerShellCallQuoteKind(input[index]);
949
+ if (!quote)
950
+ return undefined;
951
+ const pathStart = ++index;
952
+ while (index < input.length && !isPowerShellCallQuote(quote, input[index]))
953
+ index++;
954
+ if (index >= input.length)
955
+ return undefined;
956
+ const executable = input.slice(pathStart, index);
957
+ if (!isTrustedEvolcoreCallPath(executable))
958
+ return undefined;
959
+ index++;
960
+ if (!isShellSpacing(input[index]))
961
+ return undefined;
962
+ while (isShellSpacing(input[index]))
963
+ index++;
964
+ if (index >= input.length)
965
+ return undefined;
966
+ return `ec ${input.slice(index)}`;
967
+ }
968
+ function looksLikePowerShellEvolcoreCallOperator(command, dialect) {
969
+ const input = trimShellSpacing(command);
970
+ if (!input.startsWith('&'))
971
+ return false;
972
+ let index = 1;
973
+ while (isShellSpacing(input[index]))
974
+ index++;
975
+ const quote = powerShellCallQuoteKind(input[index]);
976
+ let executable;
977
+ if (quote) {
978
+ const start = ++index;
979
+ while (index < input.length && !isPowerShellCallQuote(quote, input[index]))
980
+ index++;
981
+ if (index >= input.length) {
982
+ const lower = input.slice(start).toLowerCase();
983
+ return [...EVOLCORE_EXECUTABLE_ALIASES].some(alias => lower.includes(alias));
984
+ }
985
+ executable = input.slice(start, index);
986
+ index++;
987
+ }
988
+ else {
989
+ const start = index;
990
+ while (index < input.length && !isShellSpacing(input[index]))
991
+ index++;
992
+ executable = input.slice(start, index);
993
+ }
994
+ while (isShellSpacing(input[index]))
995
+ index++;
996
+ if (!executable)
997
+ return false;
998
+ const normalizedExecutable = executable.replaceAll('\\', '/');
999
+ const basename = normalizedExecutable.slice(normalizedExecutable.lastIndexOf('/') + 1).toLowerCase();
1000
+ if (dialect && dialect !== 'powershell')
1001
+ return false;
1002
+ if (EVOLCORE_EXECUTABLE_ALIASES.has(basename))
1003
+ return true;
1004
+ if (index >= input.length)
1005
+ return false;
1006
+ const commandName = input.slice(index).match(/^([^\s;&|<>]+)/)?.[1]?.toLowerCase();
1007
+ if (!commandName || !EVOLCORE_SUBCOMMANDS.has(commandName))
1008
+ return false;
1009
+ if (dialect !== 'powershell') {
1010
+ // A quoted or dynamic call target followed by a known CLI subcommand is
1011
+ // treated as a possible PowerShell EC call until proven otherwise.
1012
+ return !!quote || /^[\$([{]/.test(executable);
1013
+ }
1014
+ // A dynamic or non-EC path is still an EC intent when its first literal
1015
+ // argument is an EC command. This keeps `& $path ctl ...` fail-closed.
1016
+ return true;
1017
+ }
387
1018
  function parseLiteralPowerShellCommand(command, options = {}) {
388
1019
  const input = trimShellSpacing(command);
389
1020
  if (!input)
390
1021
  return parseFailure('empty-command', input, 0, 0, 'powershell');
391
1022
  if (input.includes('\0'))
392
1023
  return parseFailure('invalid-control-char', input, input.indexOf('\0'), 0, 'powershell');
1024
+ const callOperatorCommand = parsePowerShellCallOperator(input);
1025
+ if (callOperatorCommand) {
1026
+ const parsed = parseLiteralPowerShellCommand(callOperatorCommand, options);
1027
+ return parsed.ok
1028
+ ? { ...parsed, argv: normalizeEvolcoreCommandArgv(parsed.argv) }
1029
+ : { ...parsed, inputLength: input.length };
1030
+ }
1031
+ const isEcCommand = hasEcCommandPrefix(input);
393
1032
  const argv = [];
394
1033
  let token = '';
395
1034
  let tokenStarted = false;
@@ -451,6 +1090,8 @@ function parseLiteralPowerShellCommand(command, options = {}) {
451
1090
  return parseFailure('invalid-quote', input, index, argv.length, 'powershell');
452
1091
  if (next === '\r' || next === '\n')
453
1092
  return parseFailure('structural-newline', input, index, argv.length, 'powershell');
1093
+ if (isEcCommand && next === 'n')
1094
+ return parseFailure('unsupported-escape', input, index - 1, argv.length, 'powershell');
454
1095
  token += next;
455
1096
  continue;
456
1097
  }
@@ -482,6 +1123,8 @@ function parseLiteralPowerShellCommand(command, options = {}) {
482
1123
  return parseFailure('invalid-quote', input, index, argv.length, 'powershell');
483
1124
  if (next === '\r' || next === '\n')
484
1125
  return parseFailure('structural-newline', input, index, argv.length, 'powershell');
1126
+ if (isEcCommand && next === 'n')
1127
+ return parseFailure('unsupported-escape', input, index - 1, argv.length, 'powershell');
485
1128
  tokenStarted = true;
486
1129
  token += next;
487
1130
  continue;
@@ -679,7 +1322,7 @@ function resolveCodexPowerShellCarrierString(command) {
679
1322
  /** Resolve a POSIX shell carrier when app-server provides only a string. */
680
1323
  function resolveCodexPosixShellCarrierString(command) {
681
1324
  const input = trimShellSpacing(command);
682
- const match = input.match(/^(?:"([^"]+)"|(\S+))\s+(-lc|-c)\s+(.+)$/);
1325
+ const match = input.match(/^(?:"([^"]+)"|(\S+))\s+(-lc|-c)\s+(.+)$/s);
683
1326
  if (!match)
684
1327
  return undefined;
685
1328
  const executable = match[1] ?? match[2];
@@ -740,7 +1383,7 @@ function looksLikeEcCommandBody(command) {
740
1383
  return false;
741
1384
  const input = command.trim();
742
1385
  const body = (input.startsWith('"') ? input.slice(1) : input).trimStart();
743
- return /^ec(?:\.cmd|\.exe)?(?:\s|$)/i.test(body);
1386
+ return /^ec(?:\.cmd|\.exe|\.ps1)?(?:\s|$)/i.test(body);
744
1387
  }
745
1388
  /** True only when a trusted cmd executable is carrying an EC command body. */
746
1389
  export function hasCodexCmdCarrierEcIntent(input) {
@@ -819,7 +1462,7 @@ export function parseLiteralShellCommand(command, options = {}) {
819
1462
  const char = input[index];
820
1463
  if (quote === 'single') {
821
1464
  if (char === "'") {
822
- if (!isLiteralArgBoundary(input[index + 1]))
1465
+ if (!isPosixQuotedTokenBoundary(input[index + 1]))
823
1466
  return parseFailure('invalid-quote', input, index, argv.length, 'posix');
824
1467
  quote = null;
825
1468
  }
@@ -829,7 +1472,7 @@ export function parseLiteralShellCommand(command, options = {}) {
829
1472
  }
830
1473
  if (quote === 'double') {
831
1474
  if (char === '"') {
832
- if (!isLiteralArgBoundary(input[index + 1]))
1475
+ if (!isPosixQuotedTokenBoundary(input[index + 1]))
833
1476
  return parseFailure('invalid-quote', input, index, argv.length, 'posix');
834
1477
  quote = null;
835
1478
  continue;
@@ -927,6 +1570,14 @@ export function unwrapCodexShellCommandArgv(argv) {
927
1570
  * command still has to pass `parseLiteralShellCommand`.
928
1571
  */
929
1572
  export function parseCodexShellCommandArgv(command, options = {}) {
1573
+ const powerShellCallOperatorCommand = parsePowerShellCallOperator(command);
1574
+ if (powerShellCallOperatorCommand) {
1575
+ const parsed = parseLiteralShellCommand(powerShellCallOperatorCommand, {
1576
+ ...options,
1577
+ dialect: 'powershell',
1578
+ });
1579
+ return parsed.ok ? canonicalizeEvolcoreCommandArgv(parsed.argv) : null;
1580
+ }
930
1581
  const outer = parseLiteralShellArgv(command, options);
931
1582
  if (outer) {
932
1583
  const carrier = resolveCodexShellCarrierArgv(outer);
@@ -959,6 +1610,19 @@ export function parseCodexToolCommand(input, options = {}) {
959
1610
  const nulIndex = outer.findIndex(value => value.includes('\0'));
960
1611
  if (nulIndex >= 0)
961
1612
  return parseFailure('invalid-control-char', '', 0, nulIndex, 'posix');
1613
+ if (outer.length === 1) {
1614
+ const callOperatorCommand = parsePowerShellCallOperator(outer[0]);
1615
+ if (callOperatorCommand) {
1616
+ const parsed = parseLiteralShellCommand(callOperatorCommand, {
1617
+ allowManagedTmpDir: true,
1618
+ ...options,
1619
+ dialect: 'powershell',
1620
+ });
1621
+ return parsed.ok
1622
+ ? { ...parsed, argv: normalizeEvolcoreCommandArgv(parsed.argv) }
1623
+ : parsed;
1624
+ }
1625
+ }
962
1626
  const carrier = resolveCodexShellCarrierArgv(outer);
963
1627
  if (carrier === undefined) {
964
1628
  return parseCodexCmdCarrierArgvFailure(outer)
@@ -967,6 +1631,17 @@ export function parseCodexToolCommand(input, options = {}) {
967
1631
  return parseCodexCarrierCommand(carrier, { allowManagedTmpDir: true, ...options });
968
1632
  }
969
1633
  const command = typeof input.command === 'string' ? input.command : '';
1634
+ const callOperatorCommand = parsePowerShellCallOperator(command);
1635
+ if (callOperatorCommand) {
1636
+ const parsed = parseLiteralShellCommand(callOperatorCommand, {
1637
+ allowManagedTmpDir: true,
1638
+ ...options,
1639
+ dialect: 'powershell',
1640
+ });
1641
+ return parsed.ok
1642
+ ? { ...parsed, argv: normalizeEvolcoreCommandArgv(parsed.argv) }
1643
+ : parsed;
1644
+ }
970
1645
  const outer = parseLiteralShellCommand(command, { allowManagedTmpDir: true });
971
1646
  if (outer.ok) {
972
1647
  const carrier = resolveCodexShellCarrierArgv(outer.argv);
@@ -984,6 +1659,98 @@ export function parseCodexToolCommand(input, options = {}) {
984
1659
  ? parseCodexCarrierCommand(stringCarrier, { allowManagedTmpDir: true, ...options })
985
1660
  : parseCodexCmdCarrierStringFailure(command, { allowManagedTmpDir: true }) ?? outer;
986
1661
  }
1662
+ function isApplyPatchExecutable(value) {
1663
+ const normalized = value.replaceAll('\\', '/').replace(/\/+$/u, '');
1664
+ const basename = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase();
1665
+ return APPLY_PATCH_EXECUTABLE_BASENAMES.has(basename);
1666
+ }
1667
+ function normalizeApplyPatchPath(value) {
1668
+ const normalized = value.trim();
1669
+ if (!normalized || /[\0\r\n]/u.test(normalized))
1670
+ return undefined;
1671
+ return normalized;
1672
+ }
1673
+ function parseApplyPatchDocument(source) {
1674
+ const lines = source.replace(/\r\n?/gu, '\n').split('\n');
1675
+ while (lines.at(-1) === '')
1676
+ lines.pop();
1677
+ if (lines[0] !== '*** Begin Patch' || lines.at(-1) !== '*** End Patch')
1678
+ return undefined;
1679
+ const changes = [];
1680
+ const seen = new Set();
1681
+ let index = 1;
1682
+ while (index < lines.length - 1) {
1683
+ const header = lines[index].match(/^\*\*\* (Add|Delete|Update) File: (.+)$/u);
1684
+ if (!header)
1685
+ return undefined;
1686
+ const kind = header[1].toLowerCase();
1687
+ const filePath = normalizeApplyPatchPath(header[2]);
1688
+ if (!filePath)
1689
+ return undefined;
1690
+ const key = filePath.replaceAll('\\', '/').toLowerCase();
1691
+ if (seen.has(key))
1692
+ return undefined;
1693
+ seen.add(key);
1694
+ index++;
1695
+ let movePath;
1696
+ if (kind === 'update' && lines[index]?.startsWith('*** Move to: ')) {
1697
+ movePath = normalizeApplyPatchPath(lines[index].slice('*** Move to: '.length));
1698
+ if (!movePath)
1699
+ return undefined;
1700
+ index++;
1701
+ }
1702
+ while (index < lines.length - 1 && !/^\*\*\* (?:Add|Delete|Update) File: /u.test(lines[index])) {
1703
+ const line = lines[index];
1704
+ if (kind === 'delete')
1705
+ return undefined;
1706
+ if (line !== '*** End of File') {
1707
+ const validLine = kind === 'add'
1708
+ ? line.startsWith('+')
1709
+ : line.startsWith('@@')
1710
+ || line.startsWith('+')
1711
+ || line.startsWith('-')
1712
+ || line.startsWith(' ')
1713
+ || line === '\';
1714
+ if (!validLine)
1715
+ return undefined;
1716
+ }
1717
+ index++;
1718
+ }
1719
+ changes.push({ path: filePath, kind, ...(movePath ? { movePath } : {}) });
1720
+ }
1721
+ return changes.length > 0 ? changes : undefined;
1722
+ }
1723
+ function argvUsesShellApplyPatch(argv) {
1724
+ if (argv.length === 0)
1725
+ return false;
1726
+ return isApplyPatchExecutable(argv[0]);
1727
+ }
1728
+ function leadingShellExecutable(command) {
1729
+ let input = command.trim();
1730
+ if (input.startsWith('&'))
1731
+ input = input.slice(1).trimStart();
1732
+ if (!input)
1733
+ return undefined;
1734
+ const quote = input[0];
1735
+ if (quote === '"' || quote === "'") {
1736
+ const end = input.indexOf(quote, 1);
1737
+ return end > 1 ? input.slice(1, end) : undefined;
1738
+ }
1739
+ return input.split(/[\s]/u, 1)[0] || undefined;
1740
+ }
1741
+ /**
1742
+ * Detect the shell fallback that tries to perform a file edit outside Codex's
1743
+ * structured FileChange/Edit protocol. The executable check intentionally
1744
+ * looks only at argv[0], so ordinary searches such as `rg apply_patch` remain
1745
+ * valid.
1746
+ */
1747
+ export function isShellApplyPatchInvocation(input, parsedArgv, options = {}) {
1748
+ const parsed = parseShellApplyPatchInvocation(input, {
1749
+ ...options,
1750
+ verifyShellExecutable: false,
1751
+ }, parsedArgv);
1752
+ return parsed.kind !== 'not-apply-patch';
1753
+ }
987
1754
  /**
988
1755
  * Resolve a Codex command input while preserving the raw `commandArgv` form
989
1756
  * supplied by app-server. Joining that argv with spaces loses the quoting of
@@ -1555,29 +2322,38 @@ function scanBoundedOutputOperators(command) {
1555
2322
  return quote ? null : operators;
1556
2323
  }
1557
2324
  /**
1558
- * Accept `head` as a generic, trusted stdout filter without mirroring its
1559
- * option grammar here. Non-option operands are the security boundary: they
1560
- * could make `head` read a file instead of the EC process' stdout.
2325
+ * Accept one trusted stdout filter after an EC invocation. The security
2326
+ * boundary is that the filter may only consume the EC process' stdout: every
2327
+ * non-option operand must be accounted for by the filter's operand budget, and
2328
+ * every option must appear in its allowlist. Unknown or malformed options make
2329
+ * the filter itself fail rather than widening what it can reach.
1561
2330
  *
1562
- * GNU/POSIX head only uses a separate value for -n/-c (and their long forms).
1563
- * All other dash-prefixed arguments are safe to leave to head itself; unknown
1564
- * or malformed options simply make head fail.
2331
+ * Returns the full filter argv (executable included) so the spawn helper can
2332
+ * execute exactly what policy validated.
1565
2333
  */
1566
- function parseStdinOnlyHeadArgs(segment) {
2334
+ export function parseStdinOnlyFilterArgv(segment) {
1567
2335
  const argv = parseLiteralShellArgv(segment);
1568
- if (!argv || !HEAD_EXECUTABLES.has(argv[0]))
2336
+ if (!argv)
2337
+ return null;
2338
+ const executable = normalizeStdoutFilterExecutable(argv[0]);
2339
+ if (!executable)
1569
2340
  return null;
1570
- const args = argv.slice(1);
2341
+ const spec = STDOUT_FILTER_SPECS.get(executable);
2342
+ if (!spec)
2343
+ return null;
2344
+ let operands = 0;
2345
+ let sawPatternValue = false;
1571
2346
  let optionsEnded = false;
1572
2347
  let expectsOptionValue = false;
1573
- for (const arg of args) {
2348
+ for (let index = 1; index < argv.length; index++) {
2349
+ const arg = argv[index];
1574
2350
  if (expectsOptionValue) {
1575
2351
  expectsOptionValue = false;
1576
2352
  continue;
1577
2353
  }
1578
2354
  if (optionsEnded) {
1579
2355
  if (arg !== '-')
1580
- return null;
2356
+ operands++;
1581
2357
  continue;
1582
2358
  }
1583
2359
  if (arg === '--') {
@@ -1586,18 +2362,43 @@ function parseStdinOnlyHeadArgs(segment) {
1586
2362
  }
1587
2363
  if (arg === '-')
1588
2364
  continue;
1589
- if (!arg.startsWith('-'))
1590
- return null;
1591
- if (arg === '-n' || arg === '-c' || arg === '--lines' || arg === '--bytes') {
1592
- expectsOptionValue = true;
2365
+ if (!arg.startsWith('-')) {
2366
+ operands++;
2367
+ continue;
2368
+ }
2369
+ if (spec.numericShorthand && /^-\d+$/.test(arg))
2370
+ continue;
2371
+ const option = arg.split('=', 1)[0];
2372
+ if (spec.patternValueOptions?.has(option)) {
2373
+ sawPatternValue = true;
2374
+ // `-ePATTERN` and `--regexp=PATTERN` carry their own value; only the
2375
+ // bare forms consume the next token.
2376
+ if (!arg.includes('=') && arg.length === option.length)
2377
+ expectsOptionValue = true;
1593
2378
  continue;
1594
2379
  }
2380
+ if (spec.valueOptions.has(option)) {
2381
+ if (!arg.includes('=') && arg.length === option.length)
2382
+ expectsOptionValue = true;
2383
+ continue;
2384
+ }
2385
+ if (spec.flags.has(arg))
2386
+ continue;
2387
+ return null;
1595
2388
  }
1596
- return args;
2389
+ // An explicit pattern option means every remaining operand would be a file
2390
+ // to read, so the operand budget drops to zero.
2391
+ const operandBudget = sawPatternValue ? 0 : spec.maxOperands;
2392
+ if (operands > operandBudget)
2393
+ return null;
2394
+ if (spec.patternValueOptions && !sawPatternValue && operands === 0)
2395
+ return null;
2396
+ return [executable, ...argv.slice(1)];
1597
2397
  }
1598
2398
  /**
1599
2399
  * Parse the only shell wrapper allowed around a privileged literal process:
1600
- * optional stderr merge, one bounded `head`, and one create-only output path.
2400
+ * optional stderr merge, one trusted stdin-only stdout filter, and one
2401
+ * create-only output path.
1601
2402
  */
1602
2403
  export function parseBoundedOutputShellCommand(command) {
1603
2404
  const input = trimShellSpacing(substituteManagedTmpDirToken(command));
@@ -1606,16 +2407,17 @@ export function parseBoundedOutputShellCommand(command) {
1606
2407
  const operators = scanBoundedOutputOperators(input);
1607
2408
  if (!operators || operators.length === 0)
1608
2409
  return null;
1609
- const pattern = operators.map(operator => operator.kind).join(',');
1610
- if (![
1611
- 'merge-stderr',
1612
- 'pipe',
1613
- 'pipe,redirect',
1614
- 'merge-stderr,pipe',
1615
- 'merge-stderr,pipe,redirect',
1616
- 'redirect',
1617
- ].includes(pattern))
1618
- return null;
2410
+ // 允许的形态:可选的 2>&1 合并 + **任意长度**的受控过滤器管道链 + 可选重定向。
2411
+ // 不再枚举具体管道段数(旧实现只列了单段+重定向,模型写 `| grep | head` 就掉出去)。
2412
+ // 放宽的依据:每一级过滤器都要过同一个 stdin-only 白名单,能力面不随段数增长。
2413
+ let cursor = 0;
2414
+ let mergeStderr = false;
2415
+ if (operators[cursor]?.kind === 'merge-stderr') {
2416
+ mergeStderr = true;
2417
+ cursor++;
2418
+ }
2419
+ // 源命令是第一个操作符**之前**的全部文本。合并 stderr 的 `2>&1` 本身也是操作符,
2420
+ // 所以两种情况都切在 operators[0].start,绝不能按 end 切——那会把 `2>&1` 混进 argv。
1619
2421
  const first = operators[0];
1620
2422
  const sourceText = trimShellSpacing(input.slice(0, first.start));
1621
2423
  const argv = parseLiteralShellArgv(sourceText);
@@ -1625,48 +2427,161 @@ export function parseBoundedOutputShellCommand(command) {
1625
2427
  // expansion for the privileged command itself.
1626
2428
  if (argv.some(value => value.includes(MANAGED_TMPDIR_TOKEN)))
1627
2429
  return null;
1628
- let mergeStderr = false;
1629
- let pipe;
1630
- let redirect;
1631
- if (first.kind === 'merge-stderr') {
1632
- mergeStderr = true;
1633
- if (operators.length === 1)
1634
- return { argv, mergeStderr };
1635
- pipe = operators[1];
1636
- if (!pipe || pipe.kind !== 'pipe')
2430
+ const filterChain = [];
2431
+ let outputPath;
2432
+ let prevEnd = mergeStderr ? operators[0].end : first.start;
2433
+ while (cursor < operators.length) {
2434
+ const operator = operators[cursor];
2435
+ // 段与段之间只允许空白(合并 stderr 与首个管道之间同理)。
2436
+ if (trimShellSpacing(input.slice(prevEnd, operator.start)))
1637
2437
  return null;
1638
- if (trimShellSpacing(input.slice(first.end, pipe.start)))
2438
+ if (operator.kind === 'redirect') {
2439
+ // 重定向必须是最后一段;其后再出现操作符即拒绝。
2440
+ if (cursor !== operators.length - 1)
2441
+ return null;
2442
+ const outputArgv = parseLiteralShellArgv(trimShellSpacing(input.slice(operator.end)));
2443
+ if (!outputArgv || outputArgv.length !== 1)
2444
+ return null;
2445
+ if (outputArgv[0].includes('$') && !outputArgv[0].includes(MANAGED_TMPDIR_TOKEN))
2446
+ return null;
2447
+ outputPath = restoreManagedTmpDirToken(outputArgv[0]);
2448
+ cursor++;
2449
+ break;
2450
+ }
2451
+ if (operator.kind !== 'pipe')
1639
2452
  return null;
1640
- redirect = operators[2];
1641
- }
1642
- else if (first.kind === 'pipe') {
1643
- pipe = first;
1644
- redirect = operators[1];
1645
- }
1646
- else {
1647
- redirect = first;
1648
- }
1649
- let headArgs;
1650
- if (pipe) {
1651
- const headEnd = redirect?.start ?? input.length;
1652
- const parsedArgs = parseStdinOnlyHeadArgs(trimShellSpacing(input.slice(pipe.end, headEnd)));
2453
+ // 该管道的下游终点:下一个操作符起点,或输入末尾。
2454
+ const next = operators[cursor + 1];
2455
+ const segmentEnd = next ? next.start : input.length;
2456
+ const parsedArgs = parseStdinOnlyFilterArgv(trimShellSpacing(input.slice(operator.end, segmentEnd)));
1653
2457
  if (parsedArgs === null)
1654
2458
  return null;
1655
- headArgs = parsedArgs;
2459
+ filterChain.push(parsedArgs);
2460
+ prevEnd = segmentEnd;
2461
+ cursor++;
1656
2462
  }
1657
- let outputPath;
1658
- if (redirect) {
1659
- const outputArgv = parseLiteralShellArgv(trimShellSpacing(input.slice(redirect.end)));
1660
- if (!outputArgv || outputArgv.length !== 1)
1661
- return null;
1662
- // A single-quoted or escaped `$TMPDIR` is literal shell text, not the
1663
- // managed directory reference. Do not reinterpret it as a route to the
1664
- // session temp root.
1665
- if (outputArgv[0].includes('$') && !outputArgv[0].includes(MANAGED_TMPDIR_TOKEN))
2463
+ return {
2464
+ argv,
2465
+ mergeStderr,
2466
+ ...(filterChain.length > 0 ? { filterChain } : {}),
2467
+ ...(outputPath ? { outputPath } : {}),
2468
+ };
2469
+ }
2470
+ /**
2471
+ * 每段必须自己就能通过零副作用校验,否则整条拒绝。
2472
+ *
2473
+ * 判据是「这段的字面量 argv 不含任何会被 shell 二次解释的字符」:`$`、反引号、
2474
+ * 通配、括号、重定向、管道、以及 `&&`/`;` 本身。引号包裹的普通文本(如
2475
+ * `echo "extra"`)合法;`echo $?`、`echo \`id\``、`echo *` 一律不合法。
2476
+ */
2477
+ function parseInertSegment(segment) {
2478
+ const text = trimShellSpacing(segment);
2479
+ if (!text)
2480
+ return null;
2481
+ const argv = parseLiteralShellArgv(text);
2482
+ if (!argv || argv.length === 0)
2483
+ return null;
2484
+ // 字面量解析已处理引号;拒绝任何仍可能造成展开或结构变化的字符。
2485
+ // 注意**不含反斜杠**:Windows 路径必然带 `\`,而解析后的 argv 直接交给
2486
+ // spawn、不经 shell,反斜杠不会被再解释一次;`cd` 的目标另有范围校验。
2487
+ if (argv.some(value => /[$`*?\[\]{}()<>|;&]/.test(value)))
2488
+ return null;
2489
+ return argv;
2490
+ }
2491
+ function isInertSegment(argv) {
2492
+ if (argv.length === 0)
2493
+ return false;
2494
+ // cd 的额外约束(唯一参数、必须在前缀位、目录范围)在调用处单独校验。
2495
+ return argv[0] === 'pwd' || argv[0] === 'echo' || argv[0] === 'cd';
2496
+ }
2497
+ /**
2498
+ * 识别「零副作用前后缀 + 恰好一条 ec」的可证拼接。
2499
+ *
2500
+ * 模型最常见的写法是"先切目录再跑命令"或"跑完打印一行提示",而这些会被通用
2501
+ * 拼接规则一律拒绝。这里只放行**结构可证**的窄形态:
2502
+ * - 按顶层 `&&` / `;` 切段(`||`、`&`、`|` 语义不同,直接拒绝不参与切分)
2503
+ * - 恰好一段能解析成 ec 命令(`ec ...`,或 `ec ... | 受控过滤器`)
2504
+ * - 其余每一段都必须是零副作用段:`cd <字面量目录>` / `pwd` / `echo <字面量>`
2505
+ * - `cd` 至多一段且必须是前缀;目录无变量/通配,由 policy 层校验范围
2506
+ *
2507
+ * 返回 ec 段的 argv(调用方按 bounded-output 同样的方式直接 spawn,不经 shell)。
2508
+ * 前后的零副作用段只用来判定结构,不参与执行。
2509
+ */
2510
+ export function parseCdPrefixedEcCommand(input) {
2511
+ const text = trimShellSpacing(input);
2512
+ if (!text)
2513
+ return null;
2514
+ // 只看 `&&` 与 `;` 两种分隔符;以下形态语义不同,一律拒绝:
2515
+ // `||` 短路、`&&&`、以及真正的后台执行 `&`。
2516
+ // 两点必须注意:
2517
+ // 1. 不能写成 `[^|]&[^&]`——它会命中 ` && ` 里的「空格 + & + 空格」,
2518
+ // 把正常拼接一并拒掉,所以用前后断言。
2519
+ // 2. `2>&1` 里的 `&` 是重定向,不是后台符号,必须先剔除再找裸 `&`。
2520
+ const withoutFdRedirect = text.replace(/[0-9]?>>?&[0-9]+/g, ' ');
2521
+ if (/\|\||&&&/.test(text) || /(?<!&)&(?!&)/.test(withoutFdRedirect))
2522
+ return null;
2523
+ const segments = text.split(/(?:\s*&&\s*|\s*;\s*)/).map(trimShellSpacing).filter(Boolean);
2524
+ if (segments.length === 0)
2525
+ return null;
2526
+ let cwd;
2527
+ let ecCommand;
2528
+ let suffix;
2529
+ let ecIndex = -1;
2530
+ for (let index = 0; index < segments.length; index++) {
2531
+ const segment = segments[index];
2532
+ const parsed = parseEcTail(segment);
2533
+ if (parsed) {
2534
+ if (ecCommand)
2535
+ return null; // 两条 ec,拒绝
2536
+ ecCommand = parsed;
2537
+ ecIndex = index;
2538
+ continue;
2539
+ }
2540
+ // 非 ec 段:只允许零副作用形态。
2541
+ const argv = parseInertSegment(segment);
2542
+ if (!argv || !isInertSegment(argv))
1666
2543
  return null;
1667
- outputPath = restoreManagedTmpDirToken(outputArgv[0]);
2544
+ if (argv[0] === 'cd') {
2545
+ // cd 必须以字面量目录为唯一参数,且只能出现在 ec 之前。
2546
+ // ecIndex 为 -1 表示尚未遇到 ec,此时按定义 cd 就在前面。
2547
+ if (argv.length !== 2 || cwd !== undefined || (ecIndex >= 0 && index > ecIndex))
2548
+ return null;
2549
+ const dir = argv[1];
2550
+ if (!dir || dir.startsWith('-'))
2551
+ return null;
2552
+ cwd = dir;
2553
+ continue;
2554
+ }
2555
+ // pwd / echo:ec 之前只是分隔提示,不执行;ec 之后作为尾部命令执行一次。
2556
+ // 实参已在 parseInertSegment 里过滤成纯字面量,不含变量/通配/重定向。
2557
+ if (ecIndex >= 0) {
2558
+ if (suffix)
2559
+ return null; // 至多一段尾部命令
2560
+ suffix = argv;
2561
+ }
1668
2562
  }
1669
- return { argv, mergeStderr, ...(headArgs ? { headArgs } : {}), ...(outputPath ? { outputPath } : {}) };
2563
+ if (!ecCommand)
2564
+ return null;
2565
+ return {
2566
+ ...(cwd ? { cwd } : {}),
2567
+ command: { ...ecCommand, ...(suffix ? { suffixArgv: suffix } : {}) },
2568
+ };
2569
+ }
2570
+ /**
2571
+ * 解析白名单里 ec 那一段,接受两种形态:
2572
+ * 1. 单条字面量 `ec ...`
2573
+ * 2. `ec ... 2>&1 | head` 这类已有受控过滤器(bounded-output)
2574
+ * 两者都直接产出 argv 交给运行器,不再经 shell。其它一律返回 null。
2575
+ */
2576
+ function parseEcTail(segment) {
2577
+ const literal = parseLiteralShellArgv(segment);
2578
+ if (literal && literal.length > 0 && normalizeEvolcoreCommandArgv(literal)[0] === 'ec') {
2579
+ return { argv: literal, mergeStderr: false };
2580
+ }
2581
+ const bounded = parseBoundedOutputShellCommand(segment);
2582
+ if (bounded && bounded.argv[0] === 'ec')
2583
+ return bounded;
2584
+ return null;
1670
2585
  }
1671
2586
  /**
1672
2587
  * Classify only the shell boundary around an EC process invocation. Runner
@@ -1675,7 +2590,30 @@ export function parseBoundedOutputShellCommand(command) {
1675
2590
  */
1676
2591
  export function classifyEvolcoreShellCommand(command, options = {}) {
1677
2592
  const parseOptions = { allowManagedTmpDir: true, ...options };
1678
- const literal = parseLiteralShellCommand(command, parseOptions);
2593
+ // heredoc 正文是数据不是语法,先剥掉再判——否则提交信息里的 `; ec ...`
2594
+ // 会被当成本轮真的拼接调用(详见 stripHeredocBodies)。
2595
+ const stripped = stripHeredocBodies(command);
2596
+ const powerShellCallOperatorIntent = looksLikePowerShellEvolcoreCallOperator(stripped, options.dialect);
2597
+ const powerShellCallOperatorCommand = powerShellCallOperatorIntent
2598
+ ? parsePowerShellCallOperator(stripped)
2599
+ : undefined;
2600
+ if (powerShellCallOperatorCommand) {
2601
+ const parsed = parseLiteralShellCommand(powerShellCallOperatorCommand, {
2602
+ ...parseOptions,
2603
+ dialect: 'powershell',
2604
+ });
2605
+ if (parsed.ok && normalizeEvolcoreCommandArgv(parsed.argv)[0] === 'ec')
2606
+ return { kind: 'literal' };
2607
+ return { kind: 'composite', issue: parsed.ok ? 'shell-composition' : parsed.issue };
2608
+ }
2609
+ if (powerShellCallOperatorIntent) {
2610
+ const parsed = parseLiteralShellCommand(stripped, {
2611
+ ...parseOptions,
2612
+ dialect: 'powershell',
2613
+ });
2614
+ return { kind: 'composite', issue: parsed.ok ? 'shell-composition' : parsed.issue };
2615
+ }
2616
+ const literal = parseLiteralShellCommand(stripped, parseOptions);
1679
2617
  if (literal.ok) {
1680
2618
  if (normalizeEvolcoreCommandArgv(literal.argv)[0] === 'ec')
1681
2619
  return { kind: 'literal' };
@@ -1689,7 +2627,7 @@ export function classifyEvolcoreShellCommand(command, options = {}) {
1689
2627
  return { kind: 'composite', issue: nested.issue };
1690
2628
  }
1691
2629
  }
1692
- const stringCarrier = resolveCodexShellCarrierString(command);
2630
+ const stringCarrier = resolveCodexShellCarrierString(stripped);
1693
2631
  if (stringCarrier) {
1694
2632
  const nested = parseCodexCarrierCommand(stringCarrier, parseOptions);
1695
2633
  if (nested.ok && nested.argv[0] === 'ec')
@@ -1699,7 +2637,7 @@ export function classifyEvolcoreShellCommand(command, options = {}) {
1699
2637
  return { kind: 'composite', issue: nested.issue };
1700
2638
  }
1701
2639
  }
1702
- const malformedCmdBody = extractCodexCmdCarrierBody(command);
2640
+ const malformedCmdBody = extractCodexCmdCarrierBody(stripped);
1703
2641
  if (malformedCmdBody && looksLikeEcCommandBody(malformedCmdBody)) {
1704
2642
  const nested = parseLiteralShellCommand(malformedCmdBody, {
1705
2643
  ...parseOptions,
@@ -1711,7 +2649,7 @@ export function classifyEvolcoreShellCommand(command, options = {}) {
1711
2649
  }
1712
2650
  return { kind: 'none' };
1713
2651
  }
1714
- const stringCarrier = resolveCodexShellCarrierString(command);
2652
+ const stringCarrier = resolveCodexShellCarrierString(stripped);
1715
2653
  if (stringCarrier) {
1716
2654
  const nested = parseCodexCarrierCommand(stringCarrier, parseOptions);
1717
2655
  if (nested.ok && nested.argv[0] === 'ec')
@@ -1722,7 +2660,7 @@ export function classifyEvolcoreShellCommand(command, options = {}) {
1722
2660
  }
1723
2661
  return { kind: 'none' };
1724
2662
  }
1725
- const malformedCmdBody = extractCodexCmdCarrierBody(command);
2663
+ const malformedCmdBody = extractCodexCmdCarrierBody(stripped);
1726
2664
  if (malformedCmdBody && looksLikeEcCommandBody(malformedCmdBody)) {
1727
2665
  const nested = parseLiteralShellCommand(malformedCmdBody, {
1728
2666
  ...parseOptions,
@@ -1734,14 +2672,21 @@ export function classifyEvolcoreShellCommand(command, options = {}) {
1734
2672
  }
1735
2673
  const boundedOutput = options.dialect === 'powershell' || options.dialect === 'cmd'
1736
2674
  ? null
1737
- : parseBoundedOutputShellCommand(command);
2675
+ : parseBoundedOutputShellCommand(stripped);
1738
2676
  if (boundedOutput && boundedOutput.argv[0] === 'ec') {
1739
2677
  return { kind: 'bounded-output', command: boundedOutput };
1740
2678
  }
2679
+ // `cd <字面量目录> && ec ...`:切目录是模型最常见的写法,结构可证时放行。
2680
+ // 只放宽前缀,ec 段仍走完整的字面量解析。
2681
+ const cdPrefixed = options.dialect === 'powershell' || options.dialect === 'cmd'
2682
+ ? null
2683
+ : parseCdPrefixedEcCommand(stripped);
2684
+ if (cdPrefixed)
2685
+ return { kind: 'cd-prefixed', command: cdPrefixed.command, cwd: cdPrefixed.cwd };
1741
2686
  // Only a command-boundary prefix is enough to preserve a useful syntax
1742
2687
  // error for a malformed direct EC invocation. Never scan for EC tokens in
1743
2688
  // arbitrary raw text after parsing fails.
1744
- return hasEcCommandPrefix(command)
2689
+ return hasEcCommandPrefix(stripped)
1745
2690
  ? { kind: 'composite', issue: literal.issue }
1746
2691
  : { kind: 'none' };
1747
2692
  }
@@ -1837,6 +2782,10 @@ export function containsLiteralManagedTmpDirOutsideSendContent(argv) {
1837
2782
  return true;
1838
2783
  return literalIndexes.some(index => !textIndexes.has(index));
1839
2784
  }
2785
+ /** Shell-escaped TMPDIR references are literal text and must not authorize file paths. */
2786
+ export function containsUnresolvedManagedTmpDirReference(argv) {
2787
+ return argv.some(value => /(?:\\\$TMPDIR|\\\$\{TMPDIR\})/i.test(value));
2788
+ }
1840
2789
  function hasExecutableProactiveSendPayload(argv, scope) {
1841
2790
  if (!argv || argv[0] !== 'ec' || argv[1] !== scope || argv[2] !== 'send')
1842
2791
  return false;