evolcore 0.0.18 → 0.0.20

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 (51) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +2 -0
  3. package/bin/install-codex-managed-hooks.mjs +61 -0
  4. package/dist/agents/claude-runner.js +4 -3
  5. package/dist/agents/codex-runner.js +25 -20
  6. package/dist/agents/ecagent-runner.js +3 -2
  7. package/dist/aun/aid/agentmd.js +7 -0
  8. package/dist/aun/msg/group.js +14 -3
  9. package/dist/aun/msg/p2p.js +21 -11
  10. package/dist/aun/outbox.js +144 -19
  11. package/dist/channels/aun.js +621 -211
  12. package/dist/cli/daemon-commands.js +41 -8
  13. package/dist/cli/index.js +1 -0
  14. package/dist/cli/init.js +55 -15
  15. package/dist/cli/restart-monitor.js +3 -3
  16. package/dist/config/aun-gateway-config.js +2 -0
  17. package/dist/config/config-manager.js +92 -8
  18. package/dist/config/config-operation-service.js +1 -2
  19. package/dist/config/gateway-config.js +9 -7
  20. package/dist/config/lifecycle.js +16 -5
  21. package/dist/config-store.js +13 -6
  22. package/dist/core/auth/authorization-audit.js +5 -2
  23. package/dist/core/bootstrap-messages.js +2 -2
  24. package/dist/core/bootstrap-service.js +21 -36
  25. package/dist/core/channel-loader.js +0 -2
  26. package/dist/core/data-migration.js +10 -4
  27. package/dist/core/evolagent.js +5 -4
  28. package/dist/core/message/message-bridge.js +6 -11
  29. package/dist/core/message/response-engine.js +62 -6
  30. package/dist/core/permission/ec-command-parser.js +203 -24
  31. package/dist/core/permission/sandbox-runtime.js +46 -12
  32. package/dist/core/permission/tool-policy.js +116 -47
  33. package/dist/core/relation/peer-identity.js +18 -0
  34. package/dist/eck/kit-renderer.js +17 -8
  35. package/dist/index.js +30 -19
  36. package/dist/ipc.js +6 -1
  37. package/dist/paths.js +0 -3
  38. package/dist/utils/stats.js +52 -18
  39. package/dist/utils/welcome.js +2 -2
  40. package/kits/docs/path-registry.md +1 -1
  41. package/kits/rules/01-overview.md +1 -1
  42. package/kits/rules/02-navigation.md +2 -2
  43. package/kits/rules/03-identity.md +1 -1
  44. package/kits/rules/05-venue.md +1 -1
  45. package/kits/schemas/_meta.json +7 -4
  46. package/kits/schemas/agent-config.schema.10.json +2 -1
  47. package/kits/schemas/agent-config.schema.11.json +408 -0
  48. package/kits/schemas/daemon.schema.5.json +136 -0
  49. package/kits/schemas/defaults.schema.5.json +107 -0
  50. package/package.json +2 -1
  51. package/dist/core/message/pause-controller.js +0 -53
@@ -5,7 +5,7 @@ import { kitsTemplatesDir, agentMdPath } from '../paths.js';
5
5
  import { resolveAgentDisplayName } from '../aun/aid/agentmd.js';
6
6
  import { logger } from '../utils/logger.js';
7
7
  import { loadAgent, saveAgent } from '../config-store.js';
8
- import { normalizeAgentLifecycle, withLifecycleForWrite } from '../config/lifecycle.js';
8
+ import { resolveAgentLifecycle, withLifecycleForWrite } from '../config/lifecycle.js';
9
9
  import { renderTemplate } from '../eck/manifest-engine.js';
10
10
  import { activeBaseagent } from './model/config-scope.js';
11
11
  import { buildEnvelope } from './message/message-utils.js';
@@ -123,12 +123,18 @@ export class BootstrapService {
123
123
  this.inFlight.delete(key);
124
124
  return false;
125
125
  }
126
- const config = normalizeAgentLifecycle(loadedConfig);
127
- if (config.lifecycle === 'active') {
126
+ const lifecycle = resolveAgentLifecycle(loadedConfig);
127
+ if (lifecycle === 'active') {
128
128
  this.inFlight.delete(key);
129
129
  return false;
130
130
  }
131
- const starting = config.lifecycle === 'created';
131
+ if (lifecycle !== 'created' && lifecycle !== 'bootstrapping') {
132
+ this.inFlight.delete(key);
133
+ logger.warn(`[Bootstrap] Refusing invalid lifecycle for ${aid}: ${String(loadedConfig.lifecycle)}`);
134
+ return false;
135
+ }
136
+ const config = { ...loadedConfig, lifecycle };
137
+ const starting = lifecycle === 'created';
132
138
  const channelType = ctx.channelType || this.channelTypeFromKey(ctx.channelKey);
133
139
  const configuredRecipient = this.resolveConfiguredRecipient(config, ctx.channelKey, channelType);
134
140
  const recipientId = ctx.recipientId || configuredRecipient;
@@ -140,35 +146,14 @@ export class BootstrapService {
140
146
  this.inFlight.delete(key);
141
147
  return false;
142
148
  }
143
- // The bootstrap prompt is deliberately a private message to the configured
144
- // owner. An inbound owner message may have arrived from a group, in which
145
- // case ctx.channelId is the group AID and must not be paired with the
146
- // private delivery route below (that would call message.send(to=group)).
147
- // A configured AUN recipient may itself be a group AID. Ask the channel's
148
- // authoritative group endpoint before choosing the transport; never infer
149
- // group/private semantics from the AID string or from the inbound venue.
150
- let isGroupRecipient = false;
151
- try {
152
- isGroupRecipient = channelType === 'aun'
153
- ? (await ctx.adapter.isGroup?.(recipientId)) === true
154
- : false;
155
- }
156
- catch (error) {
157
- // Group/private routing is security-sensitive. If the authoritative
158
- // probe fails, do not guess a route, and release the single-flight key
159
- // so a later connection or retry can recover.
160
- this.inFlight.delete(key);
161
- logger.warn(`[Bootstrap] Failed to resolve recipient type for ${aid}: ${error instanceof Error ? error.message : String(error)}`);
162
- return false;
163
- }
164
- const delivery = isGroupRecipient === true
165
- ? { chatType: 'group', groupId: recipientId }
166
- : { chatType: 'private' };
167
- const channelId = delivery.chatType === 'group'
168
- ? delivery.groupId
169
- : channelType === 'aun'
170
- ? recipientId
171
- : ctx.channelId || this.defaultChannelIdForConnection(channelType, recipientId);
149
+ // Bootstrap is initiated by connection/owner binding, not by a reply to
150
+ // an existing group message. `owners[]` contains personal Owner AIDs, so
151
+ // the first prompt must always use the private message route. Inbound
152
+ // group context must never change this initial delivery target.
153
+ const delivery = { chatType: 'private' };
154
+ const channelId = channelType === 'aun'
155
+ ? recipientId
156
+ : ctx.channelId || this.defaultChannelIdForConnection(channelType, recipientId);
172
157
  if (!channelId) {
173
158
  this.inFlight.delete(key);
174
159
  return false;
@@ -234,14 +219,14 @@ export class BootstrapService {
234
219
  const loadedConfig = loadAgent(aid) || agent?.config;
235
220
  if (!loadedConfig)
236
221
  return { ok: false, error: `Agent "${aid}" not found` };
237
- const lifecycle = normalizeAgentLifecycle(loadedConfig).lifecycle;
222
+ const lifecycle = resolveAgentLifecycle(loadedConfig);
238
223
  if (lifecycle === 'active')
239
224
  return { ok: true, aid, transitioned: false };
240
225
  if (lifecycle !== 'bootstrapping') {
241
226
  return {
242
227
  ok: false,
243
228
  code: 'INVALID_LIFECYCLE',
244
- error: `Agent "${aid}" is ${lifecycle}; only a bootstrapping agent can become ready`,
229
+ error: `Agent "${aid}" is ${lifecycle ?? `invalid (${String(loadedConfig.lifecycle)})`}; only a bootstrapping agent can become ready`,
245
230
  };
246
231
  }
247
232
  this.setLifecycle(agent, aid, 'active');
@@ -253,7 +238,7 @@ export class BootstrapService {
253
238
  const agent = this.agentRegistry.get(aid);
254
239
  const loadedConfig = loadAgent(aid) || agent?.config;
255
240
  return loadedConfig
256
- ? normalizeAgentLifecycle(loadedConfig).lifecycle
241
+ ? resolveAgentLifecycle(loadedConfig)
257
242
  : null;
258
243
  }
259
244
  setLifecycle(agent, aid, lifecycle) {
@@ -65,7 +65,6 @@ export class ChannelLoader {
65
65
  owner: agent.getOwner(aunEffName),
66
66
  enabled: true,
67
67
  keystorePath: agent.config.aun?.keystorePath,
68
- gatewayUrl: agent.config.aun?.gatewayUrl,
69
68
  };
70
69
  const configInsts = [aunInst];
71
70
  for (const inst of agent.config.channels) {
@@ -255,7 +254,6 @@ export function buildReloadHooks(deps) {
255
254
  aid,
256
255
  enabled: true,
257
256
  keystorePath: agent.config?.aun?.keystorePath,
258
- gatewayUrl: agent.config?.aun?.gatewayUrl,
259
257
  }
260
258
  : (() => {
261
259
  const agentChannels = agent.config?.channels ?? [];
@@ -1295,8 +1295,14 @@ function finalizationSourcePaths(root, manifest) {
1295
1295
  function archiveHash(filePath) {
1296
1296
  return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
1297
1297
  }
1298
- function verifyArchiveContains(archivePath, sourcePaths) {
1299
- const tar = process.platform === 'win32' ? 'tar.exe' : 'tar';
1298
+ function systemTar() {
1299
+ if (process.platform !== 'win32')
1300
+ return 'tar';
1301
+ // Git Bash puts GNU tar ahead of Windows bsdtar on PATH. GNU tar treats the
1302
+ // drive colon in an absolute archive path as host:path remote syntax.
1303
+ return path.win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe');
1304
+ }
1305
+ function verifyArchiveContains(archivePath, sourcePaths, tar) {
1300
1306
  const normalizeArchivePath = (value) => value
1301
1307
  .trim()
1302
1308
  .replace(/\\/g, '/')
@@ -1402,14 +1408,14 @@ export function finalizeDataMigration(root, manifest) {
1402
1408
  }
1403
1409
  fs.mkdirSync(path.dirname(archivePath), { recursive: true });
1404
1410
  const temporary = `${archivePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
1405
- const tar = process.platform === 'win32' ? 'tar.exe' : 'tar';
1411
+ const tar = systemTar();
1406
1412
  try {
1407
1413
  execFileSync(tar, ['-czf', temporary, ...sourcePaths], {
1408
1414
  cwd: root,
1409
1415
  stdio: 'pipe',
1410
1416
  windowsHide: process.platform === 'win32',
1411
1417
  });
1412
- verifyArchiveContains(temporary, sourcePaths);
1418
+ verifyArchiveContains(temporary, sourcePaths, tar);
1413
1419
  fs.renameSync(temporary, archivePath);
1414
1420
  }
1415
1421
  catch (error) {
@@ -243,16 +243,17 @@ export class EvolAgent {
243
243
  b.mentionMode = value;
244
244
  });
245
245
  }
246
- /** 读取观察者模式开关(默认 false)。 */
246
+ /** 读取观察者模式开关(默认 true;显式 false 可关闭)。 */
247
247
  getObservable() {
248
- return this.merged.observable === true;
248
+ return this.merged.observable ?? true;
249
249
  }
250
250
  /** 设置观察者模式开关:开启后入站/出站消息各转发一份给 owners[]。 */
251
251
  setObservable(value) {
252
+ // The default is enabled, so false must remain an explicit persisted override.
252
253
  if (value)
253
- this.rawAgent.observable = true;
254
- else
255
254
  delete this.rawAgent.observable;
255
+ else
256
+ this.rawAgent.observable = false;
256
257
  this.merged.observable = value;
257
258
  this.persist();
258
259
  }
@@ -695,12 +695,6 @@ export class MessageBridge {
695
695
  const channelKey = adapter?.channelKey || channelName;
696
696
  const owningAgent = this.agentRegistry?.resolveByChannel(channelKey)
697
697
  ?? this.agentRegistry?.resolveByChannel(channelName);
698
- // Agent-owned business controls bypass ResponseEngine entirely, so
699
- // expose them only after bootstrap has explicitly reached active.
700
- // Missing lifecycle fails closed; control channels without an owning
701
- // EvolAgent keep their existing command behavior.
702
- const allowsNormalCommandRouting = !owningAgent
703
- || owningAgent.config?.lifecycle === 'active';
704
698
  const parsedChannelKey = tryParseChannelKey(channelKey);
705
699
  const chatType = msg.chatType || 'private';
706
700
  const delivery = chatType === 'group'
@@ -779,7 +773,10 @@ export class MessageBridge {
779
773
  logger.info(`[MessageBridge] Inbound admission denied before business routing: self=${selfAid} actor=${actorId ?? '<none>'} channel=${channelKey} reason=${admissionDecision.reason}`);
780
774
  return;
781
775
  }
782
- const menuControl = allowsNormalCommandRouting && resolvedChannelType === 'aun'
776
+ // Menu is a protocol control plane. Its parsing and response path must
777
+ // remain available throughout the agent lifecycle; lifecycle gates
778
+ // ordinary model execution, not protocol handling.
779
+ const menuControl = resolvedChannelType === 'aun'
783
780
  ? parseMenuControl(content)
784
781
  : { isMenu: false };
785
782
  let menuResponseWarning;
@@ -930,8 +927,7 @@ export class MessageBridge {
930
927
  // 2. 命令快速路径(去除引用前缀后检查,兼容话题中引用上文的情况)
931
928
  const contentForCmd = content.replace(/^(>[^\n]*\n)+\n?/, '').trim();
932
929
  const cmdContent = contentForCmd || content;
933
- const isCmd = allowsNormalCommandRouting
934
- && msg.source !== 'handoff'
930
+ const isCmd = msg.source !== 'handoff'
935
931
  && this.cmdHandler.isCommand(cmdContent);
936
932
  if (isCmd) {
937
933
  logger.debug(`[MessageBridge] Command detected: "${cmdContent}", routing to handler`);
@@ -969,8 +965,7 @@ export class MessageBridge {
969
965
  logger.debug(`[MessageBridge] Failed to log inbound command: ${e}`);
970
966
  }
971
967
  }
972
- if (allowsNormalCommandRouting
973
- && msg.source !== 'handoff'
968
+ if (msg.source !== 'handoff'
974
969
  && await this.handleCommand(cmdContent, channelName, msg.channelId, (text) => {
975
970
  const taskId = `cmd-${msg.messageId || Date.now()}`;
976
971
  logger.channelOut({ channel: channelName, channelId: msg.channelId, taskId, correlationId: taskId, sessionId: msg.replyContext?.sessionId, agentAid: msg.selfAID, payload: { kind: 'command.result', text } });
@@ -33,6 +33,7 @@ import { eligibleFallbackModels } from '../model/model-fallback.js';
33
33
  import { RetryScheduler } from './retry-scheduler.js';
34
34
  import { constrainRuntimePermissionMode, resolveRuntimeStringField } from '../role/runtime-policy.js';
35
35
  import { resolveEffective, resolveEffectiveFieldWithSource } from '../../config/config-manager.js';
36
+ import { resolveAgentLifecycle } from '../../config/lifecycle.js';
36
37
  import { authorizationConfigRevision, checkRoleAccess, getFirstStaticAgentOwner, listStaticAgentAdmins, listStaticAgentOwners, resolvePeerRoleDetail, roleToSessionIdentity, } from '../../config/peer-role-resolver.js';
37
38
  import { insertUsageEvent, insertContextBreakdown, insertModelCalls } from '../../stats/writer.js';
38
39
  import { normalizeUsage } from '../../stats/normalizer.js';
@@ -48,7 +49,58 @@ import { SessionTurnCoordinator } from '../session/session-turn-coordinator.js';
48
49
  import { recordTriggerExecutionAnomaly } from '../../trigger/anomaly-store.js';
49
50
  import { classifyToolErrorCode } from '../permission/tool-error-code.js';
50
51
  import { buildToolLifecycleEventKey } from '../audit/event-key.js';
51
- import { PauseController } from './pause-controller.js';
52
+ export class PauseController {
53
+ state = 'running';
54
+ waiters = new Set();
55
+ requestPause() {
56
+ if (this.state !== 'running')
57
+ return false;
58
+ this.state = 'pause_requested';
59
+ return true;
60
+ }
61
+ isPauseRequested() { return this.state === 'pause_requested'; }
62
+ isPaused() { return this.state === 'paused'; }
63
+ waitAtToolBoundary(signal) {
64
+ if (signal?.aborted)
65
+ return Promise.resolve('cancelled');
66
+ if (this.state === 'running')
67
+ return Promise.resolve('none');
68
+ this.state = 'paused';
69
+ return new Promise(resolve => {
70
+ const waiter = { resolve: () => { }, abort: undefined };
71
+ const finish = (result) => {
72
+ this.waiters.delete(waiter);
73
+ if (waiter.abort)
74
+ signal?.removeEventListener('abort', waiter.abort);
75
+ resolve(result);
76
+ };
77
+ waiter.resolve = finish;
78
+ if (signal) {
79
+ waiter.abort = () => finish('cancelled');
80
+ signal.addEventListener('abort', waiter.abort, { once: true });
81
+ }
82
+ this.waiters.add(waiter);
83
+ if (this.state === 'running')
84
+ finish('released');
85
+ else if (signal?.aborted)
86
+ finish('cancelled');
87
+ });
88
+ }
89
+ resume() {
90
+ if (this.state === 'running')
91
+ return false;
92
+ this.state = 'running';
93
+ for (const waiter of [...this.waiters])
94
+ waiter.resolve('released');
95
+ return true;
96
+ }
97
+ cancel() {
98
+ this.state = 'running';
99
+ for (const waiter of [...this.waiters])
100
+ waiter.resolve('cancelled');
101
+ }
102
+ clear() { this.cancel(); }
103
+ }
52
104
  function isShowActivitiesMode(value) {
53
105
  return value === 'all' || value === 'text' || value === 'none';
54
106
  }
@@ -1555,7 +1607,9 @@ export class ResponseEngine {
1555
1607
  const owningAgentForTask = this.agentRegistry?.resolveByChannel(channelKey)
1556
1608
  ?? (taskAgentAid ? this.agentRegistry?.get(taskAgentAid) : null);
1557
1609
  const runnerSelfAid = owningAgentForTask?.aid || session.selfAID || message.selfAID;
1558
- const lifecycle = owningAgentForTask?.config?.lifecycle;
1610
+ const lifecycle = owningAgentForTask
1611
+ ? resolveAgentLifecycle(owningAgentForTask.config)
1612
+ : undefined;
1559
1613
  const isActive = lifecycle === 'active';
1560
1614
  const isBootstrapping = lifecycle === 'bootstrapping';
1561
1615
  // Per-method agent name for stats bucketing (agent.name or '<unknown>')
@@ -1566,10 +1620,12 @@ export class ResponseEngine {
1566
1620
  return;
1567
1621
  }
1568
1622
  // Agent-owned turns are runnable only in the two explicit execution
1569
- // states. An untransitioned `created` agent or a malformed/missing
1570
- // lifecycle must not fall through to a base runner's default prompt.
1623
+ // states. An untransitioned `created` agent or malformed lifecycle must
1624
+ // not fall through to a base runner's default prompt. Missing lifecycle
1625
+ // is normalized to active for legacy configurations.
1571
1626
  if (owningAgentForTask && !isActive && !isBootstrapping) {
1572
- const blockedLifecycle = lifecycle ?? 'missing';
1627
+ const blockedLifecycle = lifecycle
1628
+ ?? `invalid (${String(owningAgentForTask.config.lifecycle)})`;
1573
1629
  logger.error(`[ResponseEngine] Agent lifecycle is not runnable: agent=${owningAgentForTask.aid} lifecycle=${blockedLifecycle}`);
1574
1630
  this.publishTriggerExecutionFailure(message, `agent_lifecycle_not_runnable:${blockedLifecycle}`);
1575
1631
  return;
@@ -1577,7 +1633,7 @@ export class ResponseEngine {
1577
1633
  // 二次拦截:如果命令消息绕过 MessageBridge 的 handleCommand 泄漏到这里,
1578
1634
  // 静默丢弃而不是发送给 Agent(命令已在 MessageBridge 层处理过)
1579
1635
  const rawContent = message.content.replace(/^(>[^\n]*\n)+\n?/, '').trim();
1580
- if (!isBootstrapping && rawContent.startsWith('/') && this.isKnownCommand(rawContent)) {
1636
+ if (rawContent.startsWith('/') && this.isKnownCommand(rawContent)) {
1581
1637
  logger.warn(`[ResponseEngine] Command leaked past MessageBridge, dropped: "${rawContent.substring(0, 40)}"`);
1582
1638
  this.publishTriggerExecutionFailure(message, 'trigger_command_not_supported');
1583
1639
  return;
@@ -1,16 +1,73 @@
1
1
  const HEAD_EXECUTABLES = new Set(['head', '/bin/head', '/usr/bin/head']);
2
2
  // Codex app-server sends command executions through a fixed shell carrier.
3
3
  // Treat the carrier as syntax only after parsing it as literal argv; never
4
- // accept an arbitrary `sh -c` string or unwrap shell composition here.
5
- const CODEX_SHELL_EXECUTABLES = new Set([
4
+ // accept an arbitrary shell command string or unwrap composition here.
5
+ const CODEX_POSIX_SHELL_EXECUTABLES = new Set([
6
6
  'bash', '/bin/bash', '/usr/bin/bash',
7
7
  'sh', '/bin/sh', '/usr/bin/sh',
8
8
  ]);
9
+ const CODEX_POWERSHELL_EXECUTABLES = new Set([
10
+ 'pwsh', 'pwsh.exe', 'powershell', 'powershell.exe',
11
+ ]);
12
+ const CODEX_POWERSHELL_INSTALL_PATH_RE = /^[A-Za-z]:[\\/](?:Program Files[\\/]PowerShell[\\/][0-9]+(?:\.[0-9]+)*(?:-[A-Za-z0-9.-]+)?[\\/]pwsh\.exe|Windows[\\/]System32[\\/]WindowsPowerShell[\\/]v1\.0[\\/]powershell\.exe)$/i;
9
13
  const SHELL_UNQUOTED_META = new Set([
10
14
  ';', '&', '|', '`', '<', '>', '$', '(', ')', '*', '?', '[', ']', '{', '}', '#',
11
15
  ]);
16
+ const POWERSHELL_UNQUOTED_META = new Set([
17
+ ';', '&', '|', '<', '>', '$', '(', ')', '*', '?', '[', ']', '{', '}', '#', '@',
18
+ ]);
12
19
  const AMBIGUOUS_FILE_PATH_RE = /[\0\r\n$`*?\[\]{}<>|;&]/;
13
- const EVOLCORE_COMMAND_POSITION_RE = /(?:^|[;&|()`])\s*ec(?=\s|[;&|()<>`]|$)/i;
20
+ // This is intentionally limited to the command boundary. It is not a search
21
+ // for `ec` anywhere in the input: quoted arguments, regexps and log text must
22
+ // never establish an EC invocation.
23
+ function hasEcCommandPrefix(command) {
24
+ let quote = null;
25
+ let escaped = false;
26
+ let boundary = true;
27
+ for (let index = 0; index < command.length; index++) {
28
+ const char = command[index];
29
+ if (escaped) {
30
+ escaped = false;
31
+ boundary = false;
32
+ continue;
33
+ }
34
+ if (quote === 'single') {
35
+ if (char === "'")
36
+ quote = null;
37
+ continue;
38
+ }
39
+ if (quote === 'double') {
40
+ if (char === '\\') {
41
+ escaped = true;
42
+ continue;
43
+ }
44
+ if (char === '"')
45
+ quote = null;
46
+ continue;
47
+ }
48
+ if (char === "'") {
49
+ quote = 'single';
50
+ boundary = false;
51
+ continue;
52
+ }
53
+ if (char === '"') {
54
+ quote = 'double';
55
+ boundary = false;
56
+ continue;
57
+ }
58
+ if (/[;&|`()<>]/.test(char)) {
59
+ boundary = true;
60
+ continue;
61
+ }
62
+ if (boundary && /\s/.test(char))
63
+ continue;
64
+ if (boundary && command.slice(index, index + 2).toLowerCase() === 'ec'
65
+ && (index + 2 === command.length || /\s|[;&|`()<>]/.test(command[index + 2])))
66
+ return true;
67
+ boundary = false;
68
+ }
69
+ return false;
70
+ }
14
71
  function trimShellSpacing(value) {
15
72
  return value.replace(/^[ \t]+|[ \t]+$/g, '');
16
73
  }
@@ -83,6 +140,106 @@ function isLiteralArgBoundary(value) {
83
140
  function isShellParameterExpansionStart(value) {
84
141
  return value === '(' || value === '{' || !!value && /[A-Za-z0-9_@*#?$!\-]/.test(value);
85
142
  }
143
+ function parseLiteralPowerShellCommand(command) {
144
+ const input = trimShellSpacing(command);
145
+ if (!input)
146
+ return { ok: false, issue: 'empty-command' };
147
+ if (input.includes('\0'))
148
+ return { ok: false, issue: 'invalid-control-char' };
149
+ const argv = [];
150
+ let token = '';
151
+ let tokenStarted = false;
152
+ let quote = null;
153
+ const finishToken = () => {
154
+ if (!tokenStarted)
155
+ return;
156
+ argv.push(token);
157
+ token = '';
158
+ tokenStarted = false;
159
+ };
160
+ for (let index = 0; index < input.length; index++) {
161
+ const char = input[index];
162
+ if (quote === 'single') {
163
+ if (char === "'" && input[index + 1] === "'") {
164
+ token += "'";
165
+ index++;
166
+ }
167
+ else if (char === "'") {
168
+ if (!isLiteralArgBoundary(input[index + 1]))
169
+ return { ok: false, issue: 'invalid-quote' };
170
+ quote = null;
171
+ }
172
+ else {
173
+ token += char;
174
+ }
175
+ continue;
176
+ }
177
+ if (quote === 'double') {
178
+ if (char === '"') {
179
+ if (!isLiteralArgBoundary(input[index + 1]))
180
+ return { ok: false, issue: 'invalid-quote' };
181
+ quote = null;
182
+ continue;
183
+ }
184
+ if (char === '`') {
185
+ const next = input[++index];
186
+ if (next === undefined)
187
+ return { ok: false, issue: 'invalid-quote' };
188
+ if (next === '\r' || next === '\n')
189
+ return { ok: false, issue: 'structural-newline' };
190
+ token += next;
191
+ continue;
192
+ }
193
+ if (char === '$' && isShellParameterExpansionStart(input[index + 1])) {
194
+ return { ok: false, issue: 'unsafe-expansion' };
195
+ }
196
+ token += char;
197
+ continue;
198
+ }
199
+ if (char === ' ' || char === '\t') {
200
+ finishToken();
201
+ continue;
202
+ }
203
+ if (char === '\r' || char === '\n')
204
+ return { ok: false, issue: 'structural-newline' };
205
+ if (/\s/u.test(char) || char.charCodeAt(0) < 0x20 || char === '\x7f') {
206
+ return { ok: false, issue: 'invalid-control-char' };
207
+ }
208
+ if (char === "'") {
209
+ tokenStarted = true;
210
+ quote = 'single';
211
+ continue;
212
+ }
213
+ if (char === '"') {
214
+ tokenStarted = true;
215
+ quote = 'double';
216
+ continue;
217
+ }
218
+ if (char === '`') {
219
+ const next = input[++index];
220
+ if (next === undefined)
221
+ return { ok: false, issue: 'invalid-quote' };
222
+ if (next === '\r' || next === '\n')
223
+ return { ok: false, issue: 'structural-newline' };
224
+ tokenStarted = true;
225
+ token += next;
226
+ continue;
227
+ }
228
+ const isWordInitialMeta = (char === '#' || char === '@') && !tokenStarted;
229
+ const isAlwaysMeta = POWERSHELL_UNQUOTED_META.has(char) && char !== '#' && char !== '@';
230
+ if (isAlwaysMeta || isWordInitialMeta || (char === '~' && !tokenStarted)) {
231
+ return { ok: false, issue: 'shell-composition' };
232
+ }
233
+ tokenStarted = true;
234
+ token += char;
235
+ }
236
+ if (quote)
237
+ return { ok: false, issue: 'invalid-quote' };
238
+ finishToken();
239
+ return argv.length > 0
240
+ ? { ok: true, argv }
241
+ : { ok: false, issue: 'empty-command' };
242
+ }
86
243
  function doubleQuotedIssueAt(input, index) {
87
244
  const char = input[index];
88
245
  if (char === '`' || (char === '$' && isShellParameterExpansionStart(input[index + 1]))) {
@@ -102,6 +259,9 @@ function doubleQuotedIssueAt(input, index) {
102
259
  * Anything outside this subset is never granted the privileged EC path.
103
260
  */
104
261
  export function parseLiteralShellCommand(command, options = {}) {
262
+ if (options.dialect === 'powershell') {
263
+ return parseLiteralPowerShellCommand(command);
264
+ }
105
265
  const input = trimShellSpacing(options.allowManagedTmpDir
106
266
  ? substituteManagedTmpDirToken(command)
107
267
  : command);
@@ -209,15 +369,21 @@ export function parseLiteralShellArgv(command, options = {}) {
209
369
  const result = parseLiteralShellCommand(command, options);
210
370
  return result.ok ? result.argv : null;
211
371
  }
212
- /**
213
- * Return the command string carried by Codex's literal `/bin/bash -lc ...`
214
- * wrapper. The wrapper is accepted only in its exact three-argv form and the
215
- * caller must parse the returned string with the literal shell parser again.
216
- */
217
- export function unwrapCodexShellCommandArgv(argv) {
218
- if (argv.length !== 3 || argv[1] !== '-lc')
372
+ export function resolveCodexShellCarrierArgv(argv) {
373
+ if (argv.length !== 3)
219
374
  return undefined;
220
- return CODEX_SHELL_EXECUTABLES.has(argv[0]) ? argv[2] : undefined;
375
+ if (argv[1] === '-lc' && CODEX_POSIX_SHELL_EXECUTABLES.has(argv[0])) {
376
+ return { command: argv[2], dialect: 'posix' };
377
+ }
378
+ const powershellExecutable = argv[0].toLowerCase();
379
+ const isPowerShell = CODEX_POWERSHELL_EXECUTABLES.has(powershellExecutable)
380
+ || CODEX_POWERSHELL_INSTALL_PATH_RE.test(argv[0]);
381
+ return isPowerShell && argv[1].toLowerCase() === '-command'
382
+ ? { command: argv[2], dialect: 'powershell' }
383
+ : undefined;
384
+ }
385
+ export function unwrapCodexShellCommandArgv(argv) {
386
+ return resolveCodexShellCarrierArgv(argv)?.command;
221
387
  }
222
388
  /**
223
389
  * Parse either a bare literal command or the exact shell carrier emitted by
@@ -228,8 +394,10 @@ export function parseCodexShellCommandArgv(command, options = {}) {
228
394
  const outer = parseLiteralShellArgv(command, options);
229
395
  if (!outer)
230
396
  return null;
231
- const wrapped = unwrapCodexShellCommandArgv(outer);
232
- return wrapped === undefined ? outer : parseLiteralShellArgv(wrapped, options);
397
+ const carrier = resolveCodexShellCarrierArgv(outer);
398
+ return carrier === undefined
399
+ ? outer
400
+ : parseLiteralShellArgv(carrier.command, { ...options, dialect: carrier.dialect });
233
401
  }
234
402
  /**
235
403
  * Resolve a Codex command input while preserving the raw `commandArgv` form
@@ -239,10 +407,13 @@ export function parseCodexShellCommandArgv(command, options = {}) {
239
407
  export function parseCodexToolCommandArgv(input) {
240
408
  if (Array.isArray(input.commandArgv) && input.commandArgv.every(value => typeof value === 'string')) {
241
409
  const outer = input.commandArgv;
242
- const wrapped = unwrapCodexShellCommandArgv(outer);
243
- return wrapped === undefined
410
+ const carrier = resolveCodexShellCarrierArgv(outer);
411
+ return carrier === undefined
244
412
  ? outer
245
- : parseLiteralShellArgv(wrapped, { allowManagedTmpDir: true });
413
+ : parseLiteralShellArgv(carrier.command, {
414
+ allowManagedTmpDir: true,
415
+ dialect: carrier.dialect,
416
+ });
246
417
  }
247
418
  const command = typeof input.command === 'string' ? input.command : '';
248
419
  return parseCodexShellCommandArgv(command, { allowManagedTmpDir: true });
@@ -924,28 +1095,36 @@ export function parseBoundedOutputShellCommand(command) {
924
1095
  * policy must not interpret EC subcommands or arguments: a single literal
925
1096
  * invocation may leave the sandbox, while shell composition may not.
926
1097
  */
927
- export function classifyEvolcoreShellCommand(command) {
928
- const parseOptions = { allowManagedTmpDir: true };
1098
+ export function classifyEvolcoreShellCommand(command, options = {}) {
1099
+ const parseOptions = { allowManagedTmpDir: true, ...options };
929
1100
  const literal = parseLiteralShellCommand(command, parseOptions);
930
1101
  if (literal.ok) {
931
1102
  if (literal.argv[0] === 'ec')
932
1103
  return { kind: 'literal' };
933
- const wrapped = unwrapCodexShellCommandArgv(literal.argv);
934
- if (wrapped !== undefined) {
935
- const nested = parseLiteralShellCommand(wrapped, parseOptions);
1104
+ const carrier = resolveCodexShellCarrierArgv(literal.argv);
1105
+ if (carrier !== undefined) {
1106
+ const nested = parseLiteralShellCommand(carrier.command, {
1107
+ ...parseOptions,
1108
+ dialect: carrier.dialect,
1109
+ });
936
1110
  if (nested.ok && nested.argv[0] === 'ec')
937
1111
  return { kind: 'literal' };
938
- if (!nested.ok && EVOLCORE_COMMAND_POSITION_RE.test(wrapped)) {
1112
+ if (!nested.ok && hasEcCommandPrefix(carrier.command)) {
939
1113
  return { kind: 'composite', issue: nested.issue };
940
1114
  }
941
1115
  }
942
1116
  return { kind: 'none' };
943
1117
  }
944
- const boundedOutput = parseBoundedOutputShellCommand(command);
1118
+ const boundedOutput = options.dialect === 'powershell'
1119
+ ? null
1120
+ : parseBoundedOutputShellCommand(command);
945
1121
  if (boundedOutput && boundedOutput.argv[0] === 'ec') {
946
1122
  return { kind: 'bounded-output', command: boundedOutput };
947
1123
  }
948
- return EVOLCORE_COMMAND_POSITION_RE.test(command)
1124
+ // Only a command-boundary prefix is enough to preserve a useful syntax
1125
+ // error for a malformed direct EC invocation. Never scan for EC tokens in
1126
+ // arbitrary raw text after parsing fails.
1127
+ return hasEcCommandPrefix(command)
949
1128
  ? { kind: 'composite', issue: literal.issue }
950
1129
  : { kind: 'none' };
951
1130
  }