evolcore 0.0.27 → 0.0.29-test.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +38 -15
  2. package/bin/ec-safe-output.js +270 -54
  3. package/dist/agents/baseagent-availability.js +37 -1
  4. package/dist/agents/baseagent.js +29 -14
  5. package/dist/agents/claude-runner.js +171 -13
  6. package/dist/agents/codex/app-server-client.js +13 -2
  7. package/dist/agents/codex/catalog.js +7 -1
  8. package/dist/agents/codex/diagnostics.js +4 -3
  9. package/dist/agents/codex-runner.js +444 -106
  10. package/dist/agents/gemini-runner.js +35 -17
  11. package/dist/aun/group/history.js +130 -90
  12. package/dist/aun/group/operations.js +1 -3
  13. package/dist/aun/menu/client.js +1 -0
  14. package/dist/aun/menu/transport.js +5 -3
  15. package/dist/aun/msg/managed-operation.js +70 -0
  16. package/dist/aun/msg/p2p.js +1 -3
  17. package/dist/aun/msg/upload.js +2 -2
  18. package/dist/aun/outbox.js +1 -37
  19. package/dist/channels/aun.js +262 -86
  20. package/dist/channels/daemon.js +18 -1
  21. package/dist/channels/feishu.js +1 -2
  22. package/dist/cli/agent-command.js +23 -3
  23. package/dist/cli/agent-create.js +68 -2
  24. package/dist/cli/agent.js +12 -2
  25. package/dist/cli/aun-commands.js +87 -13
  26. package/dist/cli/cli-argv.js +26 -0
  27. package/dist/cli/daemon-commands.js +116 -149
  28. package/dist/cli/data-command.js +4 -4
  29. package/dist/cli/diag.js +1 -1
  30. package/dist/cli/handoff-command.js +2 -0
  31. package/dist/cli/index.js +18 -1
  32. package/dist/cli/managed-command-guard.js +10 -0
  33. package/dist/cli/memory-command.js +441 -0
  34. package/dist/cli/menu-client.js +4 -0
  35. package/dist/cli/restart-monitor.js +127 -23
  36. package/dist/cli/skill-command.js +41 -0
  37. package/dist/cli/task-context.js +2 -23
  38. package/dist/cli/trigger-command.js +9 -1
  39. package/dist/cli/watch-msg-diag.js +209 -0
  40. package/dist/config/config-field-policy.js +52 -3
  41. package/dist/config/config-manager.js +17 -4
  42. package/dist/config/contact-book-store.js +2 -2
  43. package/dist/config/mention-mode.js +7 -2
  44. package/dist/config/merge.js +28 -9
  45. package/dist/config/resolved-config-op.js +1 -1
  46. package/dist/config/runtime-env.js +137 -0
  47. package/dist/config/schema-registry.js +11 -1
  48. package/dist/config-store.js +10 -10
  49. package/dist/core/audit/event-key.js +3 -1
  50. package/dist/core/audit/log-integrity.js +4 -0
  51. package/dist/core/auth/agent-delegation.js +2 -1
  52. package/dist/core/auth/auth-gateway.js +13 -6
  53. package/dist/core/auth/authorization-audit.js +54 -19
  54. package/dist/core/auth/operation-authorizer.js +17 -0
  55. package/dist/core/auth/operation-catalog.js +9 -0
  56. package/dist/core/causation/context.js +5 -1
  57. package/dist/core/command/agent-control.js +1 -2
  58. package/dist/core/command/command-handler.js +80 -24
  59. package/dist/core/command/diag-service.js +292 -27
  60. package/dist/core/command/memory-slash.js +270 -0
  61. package/dist/core/command/menu-catalog.js +1 -0
  62. package/dist/core/command/menu-handler.js +331 -79
  63. package/dist/core/command/menu-protocol.js +45 -4
  64. package/dist/core/command/slash-gate.js +10 -10
  65. package/dist/core/command/slash-handler.js +230 -79
  66. package/dist/core/data-migration.js +282 -505
  67. package/dist/core/event-catalog.js +26 -0
  68. package/dist/core/evolagent-registry.js +6 -0
  69. package/dist/core/evolagent.js +0 -10
  70. package/dist/core/handoff/store.js +1 -20
  71. package/dist/core/inference/text-inference.js +57 -1
  72. package/dist/core/memory/consolidation-context.js +505 -0
  73. package/dist/core/memory/consolidation-ops-records.js +124 -0
  74. package/dist/core/memory/consolidation-trigger.js +394 -0
  75. package/dist/core/memory/index-manager.js +308 -0
  76. package/dist/core/memory/launch-consolidation.js +395 -0
  77. package/dist/core/memory/memory-admin.js +234 -0
  78. package/dist/core/memory/memory-document.js +100 -0
  79. package/dist/core/memory/memory-session-policy.js +41 -0
  80. package/dist/core/memory/memory-signature.js +257 -0
  81. package/dist/core/memory/memory-writer.js +432 -0
  82. package/dist/core/memory/staleness.js +117 -0
  83. package/dist/core/message/auto-mention-gate.js +454 -0
  84. package/dist/core/message/im-renderer.js +90 -20
  85. package/dist/core/message/message-bridge.js +333 -23
  86. package/dist/core/message/message-log.js +1 -0
  87. package/dist/core/message/message-queue.js +114 -27
  88. package/dist/core/message/pending-hints.js +329 -161
  89. package/dist/core/message/response-engine.js +596 -90
  90. package/dist/core/permission/ec-command-parser.js +1044 -87
  91. package/dist/core/permission/protected-paths.js +376 -126
  92. package/dist/core/permission/sandbox-runtime.js +40 -12
  93. package/dist/core/permission/tool-error-code.js +96 -13
  94. package/dist/core/permission/tool-policy.js +277 -24
  95. package/dist/core/role/runtime-policy.js +3 -7
  96. package/dist/core/session/{runner-task-history.js → runner-event-summary.js} +1 -1
  97. package/dist/core/session/session-fs-store.js +0 -76
  98. package/dist/core/session/session-idle-monitor.js +355 -0
  99. package/dist/core/session/session-manager.js +482 -17
  100. package/dist/core/session/session-mapper.js +33 -16
  101. package/dist/core/session/session-renew.js +217 -155
  102. package/dist/core/session/{session-turns.js → session-transcript.js} +35 -6
  103. package/dist/core/skill/global-watcher.js +282 -0
  104. package/dist/core/skill/index-generator.js +84 -0
  105. package/dist/core/skill/startup.js +73 -0
  106. package/dist/core/skill/switch-resolver.js +115 -0
  107. package/dist/core/task/task-activity-store.js +219 -0
  108. package/dist/eck/kit-renderer.js +30 -3
  109. package/dist/eck/manifest-engine.js +26 -4
  110. package/dist/index.js +722 -171
  111. package/dist/ipc.js +564 -2
  112. package/dist/paths.js +30 -9
  113. package/dist/product.js +1 -0
  114. package/dist/response-system/engines/v1/proactive-flow.js +116 -15
  115. package/dist/response-system/modes/single-session/index.js +3 -0
  116. package/dist/trigger/feedback.js +1 -0
  117. package/dist/trigger/manager.js +0 -10
  118. package/dist/trigger/scheduler.js +11 -0
  119. package/dist/trigger/validation.js +22 -0
  120. package/dist/utils/atomic-write.js +98 -0
  121. package/dist/utils/avatar-upload.js +59 -18
  122. package/dist/utils/cross-platform.js +87 -16
  123. package/dist/utils/error-dict.json +6 -3
  124. package/dist/utils/error-utils.js +27 -0
  125. package/dist/utils/logger.js +32 -10
  126. package/dist/utils/media-cache.js +84 -0
  127. package/dist/utils/msg-diag.js +97 -0
  128. package/dist/utils/npm-ops.js +119 -4
  129. package/dist/utils/package-version-info.js +244 -0
  130. package/dist/utils/welcome.js +37 -8
  131. package/dist/utils/windows-shell-trust.js +37 -0
  132. package/kits/docs/AUTHORING.md +314 -0
  133. package/kits/docs/GUIDE.md +2 -0
  134. package/kits/docs/INDEX.md +2 -0
  135. package/kits/docs/aun/CHEATSHEET.md +4 -0
  136. package/kits/docs/aun/SDK.md +60 -0
  137. package/kits/docs/channels/aun.md +8 -0
  138. package/kits/docs/context-assembly.md +10 -6
  139. package/kits/docs/evolcore/INDEX.md +17 -3
  140. package/kits/docs/evolcore/agent-create.md +24 -0
  141. package/kits/docs/evolcore/agent.md +5 -2
  142. package/kits/docs/evolcore/config.md +1 -1
  143. package/kits/docs/evolcore/ec-capabilities.md +364 -0
  144. package/kits/docs/evolcore/group.md +34 -18
  145. package/kits/docs/evolcore/memory-architecture.md +78 -0
  146. package/kits/docs/evolcore/memory-consolidation-batches.md +133 -0
  147. package/kits/docs/evolcore/memory-consolidation.md +263 -0
  148. package/kits/docs/evolcore/memory-tags.md +63 -0
  149. package/kits/docs/evolcore/memory.md +110 -0
  150. package/kits/docs/evolcore/menu-client.md +27 -11
  151. package/kits/{schemas → docs/evolcore}/menu-exec-schema-commands.md +12 -6
  152. package/kits/docs/evolcore/menu-protocol.md +213 -0
  153. package/kits/docs/evolcore/msg.md +2 -0
  154. package/kits/docs/evolcore/trigger.md +7 -5
  155. package/kits/docs/path-registry.md +6 -8
  156. package/kits/docs/prompt-loading-architecture.md +5 -3
  157. package/kits/docs/venues/group.md +5 -4
  158. package/kits/eck_manifest.json +294 -88
  159. package/kits/eck_manifest.memory.json +257 -0
  160. package/kits/rules/03-identity.md +8 -8
  161. package/kits/rules/05-venue.md +4 -2
  162. package/kits/rules/06-channel.md +1 -1
  163. package/kits/schemas/_meta.json +276 -47
  164. package/kits/schemas/agent-config.schema.12.json +38 -2
  165. package/kits/schemas/agent-config.schema.13.json +591 -0
  166. package/kits/schemas/daemon.schema.6.json +9 -9
  167. package/kits/schemas/defaults.schema.5.json +29 -0
  168. package/kits/schemas/relation-config.schema.8.json +14 -1
  169. package/kits/schemas/relation-config.schema.9.json +129 -0
  170. package/kits/schemas/role-config.schema.1.json +3 -3
  171. package/kits/skills/.switches.json +3 -0
  172. package/kits/skills/ec-skill/SKILL.md +217 -0
  173. package/kits/skills/ec-skill/scripts/cli.mjs +128 -0
  174. package/kits/skills/ec-skill/scripts/deps.mjs +107 -0
  175. package/kits/skills/ec-skill/scripts/frontmatter.mjs +104 -0
  176. package/kits/skills/ec-skill/scripts/skill.mjs +629 -0
  177. package/kits/skills/ec-skill/scripts/template/SKILL.md.tmpl +30 -0
  178. package/kits/templates/decision/auto-mention.md +25 -0
  179. package/kits/templates/decision/session-renew.md +22 -0
  180. package/kits/templates/memory-consolidation/forget.md +104 -0
  181. package/kits/templates/memory-consolidation/generalize.md +76 -0
  182. package/kits/templates/memory-consolidation/notes.md +133 -0
  183. package/kits/templates/memory-consolidation/profile.md +58 -0
  184. package/kits/templates/memory-consolidation/promote.md +72 -0
  185. package/kits/templates/memory-consolidation/workflow.md +75 -0
  186. package/kits/templates/system-fragments/bootstrap.md +14 -5
  187. package/kits/templates/system-fragments/commands.md +1 -0
  188. package/kits/templates/system-fragments/group-history.md +6 -0
  189. package/kits/templates/system-fragments/identity.md +0 -3
  190. package/kits/templates/system-fragments/memory.md +79 -0
  191. package/kits/templates/system-fragments/reply.md +1 -0
  192. package/kits/templates/system-fragments/venue.md +1 -1
  193. package/package.json +4 -6
  194. package/dist/trigger/legacy-session-history.js +0 -19
  195. 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,
@@ -376,12 +920,115 @@ function parseFailure(issue, input, offset, tokenIndex, dialect) {
376
920
  inputLength: input.length,
377
921
  };
378
922
  }
923
+ // PowerShell uses smart quotes as delimiters, including when the opening
924
+ // quote was ASCII. Treating them as text changes argv and delegation hashes.
925
+ function isPowerShellSingleQuote(char) {
926
+ return char !== undefined && /['\u2018\u2019\u201a\u201b]/u.test(char);
927
+ }
928
+ function isPowerShellDoubleQuote(char) {
929
+ return char !== undefined && /["\u201c\u201d\u201e]/u.test(char);
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
+ }
379
1018
  function parseLiteralPowerShellCommand(command, options = {}) {
380
1019
  const input = trimShellSpacing(command);
381
1020
  if (!input)
382
1021
  return parseFailure('empty-command', input, 0, 0, 'powershell');
383
1022
  if (input.includes('\0'))
384
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);
385
1032
  const argv = [];
386
1033
  let token = '';
387
1034
  let tokenStarted = false;
@@ -399,11 +1046,11 @@ function parseLiteralPowerShellCommand(command, options = {}) {
399
1046
  for (let index = 0; index < input.length; index++) {
400
1047
  const char = input[index];
401
1048
  if (quote === 'single') {
402
- if (char === "'" && input[index + 1] === "'") {
403
- token += "'";
1049
+ if (isPowerShellSingleQuote(char) && isPowerShellSingleQuote(input[index + 1])) {
1050
+ token += input[index + 1];
404
1051
  index++;
405
1052
  }
406
- else if (char === "'") {
1053
+ else if (isPowerShellSingleQuote(char)) {
407
1054
  if (!isLiteralArgBoundary(input[index + 1]))
408
1055
  return parseFailure('invalid-quote', input, index, argv.length, 'powershell');
409
1056
  quote = null;
@@ -420,7 +1067,7 @@ function parseLiteralPowerShellCommand(command, options = {}) {
420
1067
  return parseFailure('unsafe-expansion', input, index, argv.length, 'powershell');
421
1068
  }
422
1069
  if (!options.allowManagedTmpDir || !options.managedTempDir
423
- || (next !== undefined && !/[\\/\s"']/u.test(next))) {
1070
+ || (next !== undefined && !/[\\/\s"']/u.test(next) && !isPowerShellSingleQuote(next) && !isPowerShellDoubleQuote(next))) {
424
1071
  return parseFailure('unsafe-expansion', input, index, argv.length, 'powershell');
425
1072
  }
426
1073
  // Append the registered value directly: spaces remain in this argument,
@@ -431,7 +1078,7 @@ function parseLiteralPowerShellCommand(command, options = {}) {
431
1078
  continue;
432
1079
  }
433
1080
  if (quote === 'double') {
434
- if (char === '"') {
1081
+ if (isPowerShellDoubleQuote(char)) {
435
1082
  if (!isLiteralArgBoundary(input[index + 1]))
436
1083
  return parseFailure('invalid-quote', input, index, argv.length, 'powershell');
437
1084
  quote = null;
@@ -443,6 +1090,8 @@ function parseLiteralPowerShellCommand(command, options = {}) {
443
1090
  return parseFailure('invalid-quote', input, index, argv.length, 'powershell');
444
1091
  if (next === '\r' || next === '\n')
445
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');
446
1095
  token += next;
447
1096
  continue;
448
1097
  }
@@ -458,12 +1107,12 @@ function parseLiteralPowerShellCommand(command, options = {}) {
458
1107
  if (/\s/u.test(char) || char.charCodeAt(0) < 0x20 || char === '\x7f') {
459
1108
  return parseFailure('invalid-control-char', input, index, argv.length, 'powershell');
460
1109
  }
461
- if (char === "'") {
1110
+ if (isPowerShellSingleQuote(char)) {
462
1111
  tokenStarted = true;
463
1112
  quote = 'single';
464
1113
  continue;
465
1114
  }
466
- if (char === '"') {
1115
+ if (isPowerShellDoubleQuote(char)) {
467
1116
  tokenStarted = true;
468
1117
  quote = 'double';
469
1118
  continue;
@@ -474,6 +1123,8 @@ function parseLiteralPowerShellCommand(command, options = {}) {
474
1123
  return parseFailure('invalid-quote', input, index, argv.length, 'powershell');
475
1124
  if (next === '\r' || next === '\n')
476
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');
477
1128
  tokenStarted = true;
478
1129
  token += next;
479
1130
  continue;
@@ -671,7 +1322,7 @@ function resolveCodexPowerShellCarrierString(command) {
671
1322
  /** Resolve a POSIX shell carrier when app-server provides only a string. */
672
1323
  function resolveCodexPosixShellCarrierString(command) {
673
1324
  const input = trimShellSpacing(command);
674
- const match = input.match(/^(?:"([^"]+)"|(\S+))\s+(-lc|-c)\s+(.+)$/);
1325
+ const match = input.match(/^(?:"([^"]+)"|(\S+))\s+(-lc|-c)\s+(.+)$/s);
675
1326
  if (!match)
676
1327
  return undefined;
677
1328
  const executable = match[1] ?? match[2];
@@ -732,7 +1383,7 @@ function looksLikeEcCommandBody(command) {
732
1383
  return false;
733
1384
  const input = command.trim();
734
1385
  const body = (input.startsWith('"') ? input.slice(1) : input).trimStart();
735
- return /^ec(?:\.cmd|\.exe)?(?:\s|$)/i.test(body);
1386
+ return /^ec(?:\.cmd|\.exe|\.ps1)?(?:\s|$)/i.test(body);
736
1387
  }
737
1388
  /** True only when a trusted cmd executable is carrying an EC command body. */
738
1389
  export function hasCodexCmdCarrierEcIntent(input) {
@@ -811,7 +1462,7 @@ export function parseLiteralShellCommand(command, options = {}) {
811
1462
  const char = input[index];
812
1463
  if (quote === 'single') {
813
1464
  if (char === "'") {
814
- if (!isLiteralArgBoundary(input[index + 1]))
1465
+ if (!isPosixQuotedTokenBoundary(input[index + 1]))
815
1466
  return parseFailure('invalid-quote', input, index, argv.length, 'posix');
816
1467
  quote = null;
817
1468
  }
@@ -821,7 +1472,7 @@ export function parseLiteralShellCommand(command, options = {}) {
821
1472
  }
822
1473
  if (quote === 'double') {
823
1474
  if (char === '"') {
824
- if (!isLiteralArgBoundary(input[index + 1]))
1475
+ if (!isPosixQuotedTokenBoundary(input[index + 1]))
825
1476
  return parseFailure('invalid-quote', input, index, argv.length, 'posix');
826
1477
  quote = null;
827
1478
  continue;
@@ -919,6 +1570,14 @@ export function unwrapCodexShellCommandArgv(argv) {
919
1570
  * command still has to pass `parseLiteralShellCommand`.
920
1571
  */
921
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
+ }
922
1581
  const outer = parseLiteralShellArgv(command, options);
923
1582
  if (outer) {
924
1583
  const carrier = resolveCodexShellCarrierArgv(outer);
@@ -951,6 +1610,19 @@ export function parseCodexToolCommand(input, options = {}) {
951
1610
  const nulIndex = outer.findIndex(value => value.includes('\0'));
952
1611
  if (nulIndex >= 0)
953
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
+ }
954
1626
  const carrier = resolveCodexShellCarrierArgv(outer);
955
1627
  if (carrier === undefined) {
956
1628
  return parseCodexCmdCarrierArgvFailure(outer)
@@ -959,6 +1631,17 @@ export function parseCodexToolCommand(input, options = {}) {
959
1631
  return parseCodexCarrierCommand(carrier, { allowManagedTmpDir: true, ...options });
960
1632
  }
961
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
+ }
962
1645
  const outer = parseLiteralShellCommand(command, { allowManagedTmpDir: true });
963
1646
  if (outer.ok) {
964
1647
  const carrier = resolveCodexShellCarrierArgv(outer.argv);
@@ -976,6 +1659,98 @@ export function parseCodexToolCommand(input, options = {}) {
976
1659
  ? parseCodexCarrierCommand(stringCarrier, { allowManagedTmpDir: true, ...options })
977
1660
  : parseCodexCmdCarrierStringFailure(command, { allowManagedTmpDir: true }) ?? outer;
978
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
+ }
979
1754
  /**
980
1755
  * Resolve a Codex command input while preserving the raw `commandArgv` form
981
1756
  * supplied by app-server. Joining that argv with spaces loses the quoting of
@@ -1547,29 +2322,38 @@ function scanBoundedOutputOperators(command) {
1547
2322
  return quote ? null : operators;
1548
2323
  }
1549
2324
  /**
1550
- * Accept `head` as a generic, trusted stdout filter without mirroring its
1551
- * option grammar here. Non-option operands are the security boundary: they
1552
- * 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.
1553
2330
  *
1554
- * GNU/POSIX head only uses a separate value for -n/-c (and their long forms).
1555
- * All other dash-prefixed arguments are safe to leave to head itself; unknown
1556
- * 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.
1557
2333
  */
1558
- function parseStdinOnlyHeadArgs(segment) {
2334
+ export function parseStdinOnlyFilterArgv(segment) {
1559
2335
  const argv = parseLiteralShellArgv(segment);
1560
- if (!argv || !HEAD_EXECUTABLES.has(argv[0]))
2336
+ if (!argv)
2337
+ return null;
2338
+ const executable = normalizeStdoutFilterExecutable(argv[0]);
2339
+ if (!executable)
1561
2340
  return null;
1562
- 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;
1563
2346
  let optionsEnded = false;
1564
2347
  let expectsOptionValue = false;
1565
- for (const arg of args) {
2348
+ for (let index = 1; index < argv.length; index++) {
2349
+ const arg = argv[index];
1566
2350
  if (expectsOptionValue) {
1567
2351
  expectsOptionValue = false;
1568
2352
  continue;
1569
2353
  }
1570
2354
  if (optionsEnded) {
1571
2355
  if (arg !== '-')
1572
- return null;
2356
+ operands++;
1573
2357
  continue;
1574
2358
  }
1575
2359
  if (arg === '--') {
@@ -1578,18 +2362,43 @@ function parseStdinOnlyHeadArgs(segment) {
1578
2362
  }
1579
2363
  if (arg === '-')
1580
2364
  continue;
1581
- if (!arg.startsWith('-'))
1582
- return null;
1583
- if (arg === '-n' || arg === '-c' || arg === '--lines' || arg === '--bytes') {
1584
- 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;
2378
+ continue;
2379
+ }
2380
+ if (spec.valueOptions.has(option)) {
2381
+ if (!arg.includes('=') && arg.length === option.length)
2382
+ expectsOptionValue = true;
1585
2383
  continue;
1586
2384
  }
2385
+ if (spec.flags.has(arg))
2386
+ continue;
2387
+ return null;
1587
2388
  }
1588
- 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)];
1589
2397
  }
1590
2398
  /**
1591
2399
  * Parse the only shell wrapper allowed around a privileged literal process:
1592
- * 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.
1593
2402
  */
1594
2403
  export function parseBoundedOutputShellCommand(command) {
1595
2404
  const input = trimShellSpacing(substituteManagedTmpDirToken(command));
@@ -1598,16 +2407,17 @@ export function parseBoundedOutputShellCommand(command) {
1598
2407
  const operators = scanBoundedOutputOperators(input);
1599
2408
  if (!operators || operators.length === 0)
1600
2409
  return null;
1601
- const pattern = operators.map(operator => operator.kind).join(',');
1602
- if (![
1603
- 'merge-stderr',
1604
- 'pipe',
1605
- 'pipe,redirect',
1606
- 'merge-stderr,pipe',
1607
- 'merge-stderr,pipe,redirect',
1608
- 'redirect',
1609
- ].includes(pattern))
1610
- 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。
1611
2421
  const first = operators[0];
1612
2422
  const sourceText = trimShellSpacing(input.slice(0, first.start));
1613
2423
  const argv = parseLiteralShellArgv(sourceText);
@@ -1617,48 +2427,161 @@ export function parseBoundedOutputShellCommand(command) {
1617
2427
  // expansion for the privileged command itself.
1618
2428
  if (argv.some(value => value.includes(MANAGED_TMPDIR_TOKEN)))
1619
2429
  return null;
1620
- let mergeStderr = false;
1621
- let pipe;
1622
- let redirect;
1623
- if (first.kind === 'merge-stderr') {
1624
- mergeStderr = true;
1625
- if (operators.length === 1)
1626
- return { argv, mergeStderr };
1627
- pipe = operators[1];
1628
- 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)))
1629
2437
  return null;
1630
- 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')
1631
2452
  return null;
1632
- redirect = operators[2];
1633
- }
1634
- else if (first.kind === 'pipe') {
1635
- pipe = first;
1636
- redirect = operators[1];
1637
- }
1638
- else {
1639
- redirect = first;
1640
- }
1641
- let headArgs;
1642
- if (pipe) {
1643
- const headEnd = redirect?.start ?? input.length;
1644
- 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)));
1645
2457
  if (parsedArgs === null)
1646
2458
  return null;
1647
- headArgs = parsedArgs;
2459
+ filterChain.push(parsedArgs);
2460
+ prevEnd = segmentEnd;
2461
+ cursor++;
1648
2462
  }
1649
- let outputPath;
1650
- if (redirect) {
1651
- const outputArgv = parseLiteralShellArgv(trimShellSpacing(input.slice(redirect.end)));
1652
- if (!outputArgv || outputArgv.length !== 1)
1653
- return null;
1654
- // A single-quoted or escaped `$TMPDIR` is literal shell text, not the
1655
- // managed directory reference. Do not reinterpret it as a route to the
1656
- // session temp root.
1657
- 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))
1658
2543
  return null;
1659
- 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
+ }
1660
2562
  }
1661
- 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;
1662
2585
  }
1663
2586
  /**
1664
2587
  * Classify only the shell boundary around an EC process invocation. Runner
@@ -1667,7 +2590,30 @@ export function parseBoundedOutputShellCommand(command) {
1667
2590
  */
1668
2591
  export function classifyEvolcoreShellCommand(command, options = {}) {
1669
2592
  const parseOptions = { allowManagedTmpDir: true, ...options };
1670
- 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);
1671
2617
  if (literal.ok) {
1672
2618
  if (normalizeEvolcoreCommandArgv(literal.argv)[0] === 'ec')
1673
2619
  return { kind: 'literal' };
@@ -1681,7 +2627,7 @@ export function classifyEvolcoreShellCommand(command, options = {}) {
1681
2627
  return { kind: 'composite', issue: nested.issue };
1682
2628
  }
1683
2629
  }
1684
- const stringCarrier = resolveCodexShellCarrierString(command);
2630
+ const stringCarrier = resolveCodexShellCarrierString(stripped);
1685
2631
  if (stringCarrier) {
1686
2632
  const nested = parseCodexCarrierCommand(stringCarrier, parseOptions);
1687
2633
  if (nested.ok && nested.argv[0] === 'ec')
@@ -1691,7 +2637,7 @@ export function classifyEvolcoreShellCommand(command, options = {}) {
1691
2637
  return { kind: 'composite', issue: nested.issue };
1692
2638
  }
1693
2639
  }
1694
- const malformedCmdBody = extractCodexCmdCarrierBody(command);
2640
+ const malformedCmdBody = extractCodexCmdCarrierBody(stripped);
1695
2641
  if (malformedCmdBody && looksLikeEcCommandBody(malformedCmdBody)) {
1696
2642
  const nested = parseLiteralShellCommand(malformedCmdBody, {
1697
2643
  ...parseOptions,
@@ -1703,7 +2649,7 @@ export function classifyEvolcoreShellCommand(command, options = {}) {
1703
2649
  }
1704
2650
  return { kind: 'none' };
1705
2651
  }
1706
- const stringCarrier = resolveCodexShellCarrierString(command);
2652
+ const stringCarrier = resolveCodexShellCarrierString(stripped);
1707
2653
  if (stringCarrier) {
1708
2654
  const nested = parseCodexCarrierCommand(stringCarrier, parseOptions);
1709
2655
  if (nested.ok && nested.argv[0] === 'ec')
@@ -1714,7 +2660,7 @@ export function classifyEvolcoreShellCommand(command, options = {}) {
1714
2660
  }
1715
2661
  return { kind: 'none' };
1716
2662
  }
1717
- const malformedCmdBody = extractCodexCmdCarrierBody(command);
2663
+ const malformedCmdBody = extractCodexCmdCarrierBody(stripped);
1718
2664
  if (malformedCmdBody && looksLikeEcCommandBody(malformedCmdBody)) {
1719
2665
  const nested = parseLiteralShellCommand(malformedCmdBody, {
1720
2666
  ...parseOptions,
@@ -1726,14 +2672,21 @@ export function classifyEvolcoreShellCommand(command, options = {}) {
1726
2672
  }
1727
2673
  const boundedOutput = options.dialect === 'powershell' || options.dialect === 'cmd'
1728
2674
  ? null
1729
- : parseBoundedOutputShellCommand(command);
2675
+ : parseBoundedOutputShellCommand(stripped);
1730
2676
  if (boundedOutput && boundedOutput.argv[0] === 'ec') {
1731
2677
  return { kind: 'bounded-output', command: boundedOutput };
1732
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 };
1733
2686
  // Only a command-boundary prefix is enough to preserve a useful syntax
1734
2687
  // error for a malformed direct EC invocation. Never scan for EC tokens in
1735
2688
  // arbitrary raw text after parsing fails.
1736
- return hasEcCommandPrefix(command)
2689
+ return hasEcCommandPrefix(stripped)
1737
2690
  ? { kind: 'composite', issue: literal.issue }
1738
2691
  : { kind: 'none' };
1739
2692
  }
@@ -1829,6 +2782,10 @@ export function containsLiteralManagedTmpDirOutsideSendContent(argv) {
1829
2782
  return true;
1830
2783
  return literalIndexes.some(index => !textIndexes.has(index));
1831
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
+ }
1832
2789
  function hasExecutableProactiveSendPayload(argv, scope) {
1833
2790
  if (!argv || argv[0] !== 'ec' || argv[1] !== scope || argv[2] !== 'send')
1834
2791
  return false;