evolcore 0.0.2 → 0.0.4

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 (206) hide show
  1. package/CHANGELOG.md +93 -778
  2. package/README.md +45 -10
  3. package/bin/ec-safe-output.js +89 -24
  4. package/dist/agents/baseagent.js +46 -0
  5. package/dist/agents/claude-runner.js +198 -48
  6. package/dist/agents/codex-app-server-client.js +68 -0
  7. package/dist/agents/codex-runner.js +203 -8
  8. package/dist/agents/runner-types.js +2 -2
  9. package/dist/aun/aid/agentmd.js +79 -0
  10. package/dist/aun/aid/encryption-seed-policy.js +29 -0
  11. package/dist/aun/aid/index.js +1 -1
  12. package/dist/aun/aid/store.js +2 -5
  13. package/dist/aun/outbox.js +8 -0
  14. package/dist/channels/aun.js +241 -116
  15. package/dist/channels/daemon.js +18 -4
  16. package/dist/channels/dingtalk.js +1005 -260
  17. package/dist/channels/feishu.js +184 -56
  18. package/dist/channels/qqbot.js +23 -1
  19. package/dist/channels/wechat.js +303 -155
  20. package/dist/channels/wecom.js +1132 -164
  21. package/dist/cli/agent-command.js +2 -1
  22. package/dist/cli/aun-commands.js +98 -43
  23. package/dist/cli/bench.js +2 -2
  24. package/dist/cli/contact.js +71 -0
  25. package/dist/cli/ctl-command.js +2 -2
  26. package/dist/cli/daemon-commands.js +77 -214
  27. package/dist/cli/handoff-command.js +4 -3
  28. package/dist/cli/help.js +9 -5
  29. package/dist/cli/index.js +132 -116
  30. package/dist/cli/init-channel.js +242 -129
  31. package/dist/cli/init.js +70 -31
  32. package/dist/cli/model.js +2 -1
  33. package/dist/cli/net-check.js +2 -2
  34. package/dist/cli/queue-command.js +30 -6
  35. package/dist/cli/raw-key-input.js +25 -0
  36. package/dist/cli/response.js +5 -6
  37. package/dist/cli/restart-monitor.js +25 -1
  38. package/dist/cli/stats.js +6 -4
  39. package/dist/cli/trigger-command.js +146 -25
  40. package/dist/cli/version.js +6 -1
  41. package/dist/config/access-policy-domain.js +38 -0
  42. package/dist/config/access-policy.js +134 -0
  43. package/dist/config/builtin-role-templates.js +42 -17
  44. package/dist/config/builtin-roles.js +31 -6
  45. package/dist/config/config-field-policy.js +17 -5
  46. package/dist/config/config-manager.js +409 -29
  47. package/dist/config/config-operation-service.js +35 -38
  48. package/dist/config/contact-alias.js +68 -0
  49. package/dist/config/contact-bind-code.js +274 -0
  50. package/dist/config/contact-book-store.js +622 -0
  51. package/dist/config/contact-book-v2-startup.js +35 -0
  52. package/dist/config/contact-book.js +185 -300
  53. package/dist/config/contact-operation-service.js +116 -0
  54. package/dist/config/contact-request-service.js +331 -0
  55. package/dist/config/peer-role-resolver.js +136 -55
  56. package/dist/config/role-ranks.js +18 -0
  57. package/dist/config/role-service.js +16 -19
  58. package/dist/config/role-store.js +16 -5
  59. package/dist/config/roles.js +10 -1
  60. package/dist/config/schema-registry.js +49 -24
  61. package/dist/config-store.js +14 -4
  62. package/dist/core/auth/agent-delegation.js +105 -11
  63. package/dist/core/auth/authorization-audit.js +16 -1
  64. package/dist/core/auth/operation-authorizer.js +10 -0
  65. package/dist/core/auth/operation-catalog.js +105 -1
  66. package/dist/core/auth/trigger-authorization.js +15 -0
  67. package/dist/core/bootstrap-service.js +2 -9
  68. package/dist/core/channel-loader.js +6 -2
  69. package/dist/core/command/command-handler.js +113 -21
  70. package/dist/core/command/connect-menu.js +599 -0
  71. package/dist/core/command/evol-menu-version-gate.js +38 -0
  72. package/dist/core/command/group-menu.js +421 -0
  73. package/dist/core/command/menu-handler.js +382 -100
  74. package/dist/core/command/menu-protocol.js +8 -2
  75. package/dist/core/command/menu-token-store.js +102 -0
  76. package/dist/core/command/role-menu.js +128 -29
  77. package/dist/core/command/slash-gate.js +1 -1
  78. package/dist/core/command/slash-handler.js +186 -49
  79. package/dist/core/daemon-file-cache.js +40 -6
  80. package/dist/core/event-catalog.js +99 -0
  81. package/dist/core/evolagent-registry.js +4 -40
  82. package/dist/core/evolagent.js +20 -9
  83. package/dist/core/handoff/runtime.js +162 -37
  84. package/dist/core/handoff/store.js +46 -2
  85. package/dist/core/handoff/types.js +1 -0
  86. package/dist/core/inference/text-inference.js +16 -74
  87. package/dist/core/message/file-markers.js +7 -0
  88. package/dist/core/message/im-renderer.js +20 -14
  89. package/dist/core/message/inbound-admission.js +124 -0
  90. package/dist/core/message/message-bridge.js +670 -57
  91. package/dist/core/message/message-log.js +1 -0
  92. package/dist/core/message/message-queue.js +195 -10
  93. package/dist/core/message/message-utils.js +8 -2
  94. package/dist/core/message/peer-mode.js +7 -8
  95. package/dist/core/message/response-engine.js +642 -96
  96. package/dist/core/message/send-receipt.js +24 -0
  97. package/dist/core/message/stream-debouncer.js +11 -2
  98. package/dist/core/permission/approval-gateway.js +17 -10
  99. package/dist/core/permission/ec-command-parser.js +101 -46
  100. package/dist/core/permission/tool-policy.js +115 -38
  101. package/dist/core/protected-paths.js +38 -11
  102. package/dist/core/session/session-fs-store.js +44 -3
  103. package/dist/core/session/session-manager.js +86 -8
  104. package/dist/core/session/session-mapper.js +2 -0
  105. package/dist/core/session/session-renew.js +125 -69
  106. package/dist/core/session/session-turn-coordinator.js +5 -1
  107. package/dist/eck/kit-renderer.js +12 -1
  108. package/dist/eck/message-renderer.js +79 -1
  109. package/dist/index.js +285 -94
  110. package/dist/ipc.js +114 -8
  111. package/dist/paths.js +3 -0
  112. package/dist/response-system/config-resolver.js +29 -0
  113. package/dist/response-system/coordinator.js +8 -32
  114. package/dist/response-system/engines/v1/proactive-flow.js +1 -1
  115. package/dist/response-system/index.js +1 -0
  116. package/dist/response-system/modes/single-session/index.js +7 -4
  117. package/dist/stats/billing.js +20 -8
  118. package/dist/trigger/manager.js +3 -0
  119. package/dist/trigger/parser.js +126 -11
  120. package/dist/trigger/patch.js +12 -2
  121. package/dist/trigger/scheduler.js +444 -40
  122. package/dist/trigger/validation.js +13 -0
  123. package/dist/utils/aid-bind.js +43 -29
  124. package/dist/utils/error-dict.json +7 -0
  125. package/dist/utils/evolcore-version.js +21 -0
  126. package/dist/utils/instance-registry.js +14 -7
  127. package/dist/utils/log-writer.js +46 -0
  128. package/dist/utils/logger.js +2 -2
  129. package/dist/utils/media-cache.js +4 -1
  130. package/dist/utils/model-prices.jsonl +6 -3
  131. package/dist/utils/restart-safety.js +31 -0
  132. package/dist/utils/stable-semver.js +21 -0
  133. package/dist/utils/stats.js +33 -9
  134. package/dist/utils/system-memory.js +62 -0
  135. package/kits/docs/INDEX.md +2 -1
  136. package/kits/docs/channels/aun.md +4 -13
  137. package/kits/docs/evolcore/INDEX.md +5 -3
  138. package/kits/docs/evolcore/agent.md +9 -1
  139. package/kits/docs/evolcore/aid.md +5 -2
  140. package/kits/docs/evolcore/config.md +47 -2
  141. package/kits/docs/evolcore/contact.md +61 -0
  142. package/kits/docs/evolcore/fs.md +9 -0
  143. package/kits/docs/evolcore/group-rules.md +46 -4
  144. package/kits/docs/evolcore/group.md +15 -6
  145. package/kits/docs/evolcore/model.md +4 -1
  146. package/kits/docs/evolcore/msg.md +12 -6
  147. package/kits/docs/evolcore/response.md +16 -21
  148. package/kits/docs/evolcore/rpc.md +2 -0
  149. package/kits/docs/evolcore/stats.md +15 -2
  150. package/kits/docs/evolcore/storage.md +1 -0
  151. package/kits/docs/evolcore/trigger.md +42 -10
  152. package/kits/docs/path-registry.md +36 -17
  153. package/kits/eck_manifest.json +12 -0
  154. package/kits/eck_message_manifest.json +12 -1
  155. package/kits/migrations/migrate-contact-book-v2.mjs +754 -0
  156. package/kits/rules/01-overview.md +17 -7
  157. package/kits/rules/02-navigation.md +38 -18
  158. package/kits/rules/03-identity.md +28 -24
  159. package/kits/rules/04-relation.md +44 -28
  160. package/kits/rules/05-venue.md +31 -15
  161. package/kits/rules/06-channel.md +11 -7
  162. package/kits/schemas/_meta.json +21 -7
  163. package/kits/schemas/agent-config.schema.6.json +322 -0
  164. package/kits/schemas/agent-config.schema.7.json +303 -0
  165. package/kits/schemas/agent-config.schema.8.json +304 -0
  166. package/kits/schemas/agent-config.schema.9.json +374 -0
  167. package/kits/schemas/contact-book.schema.2.json +43 -0
  168. package/kits/schemas/contact-book.schema.3.json +67 -0
  169. package/kits/schemas/daemon.schema.1.json +3 -2
  170. package/kits/schemas/daemon.schema.2.json +101 -0
  171. package/kits/schemas/daemon.schema.3.json +123 -0
  172. package/kits/schemas/defaults.schema.2.json +85 -0
  173. package/kits/schemas/defaults.schema.3.json +73 -0
  174. package/kits/schemas/relation-config.schema.5.json +47 -0
  175. package/kits/schemas/relation-config.schema.6.json +46 -0
  176. package/kits/schemas/relation-config.schema.7.json +59 -0
  177. package/kits/schemas/role-config.schema.1.json +1 -0
  178. package/kits/schemas/role-registry.schema.1.json +2 -2
  179. package/kits/schemas/single-session.schema.2.json +57 -0
  180. package/kits/templates/message-fragments/handoff-context-to-target.md +9 -0
  181. package/kits/templates/message-fragments/handoff-request-to-target.md +14 -8
  182. package/kits/templates/message-fragments/handoff-response-to-origin.md +5 -6
  183. package/kits/templates/roles/admin.json +47 -0
  184. package/kits/templates/roles/member.json +5 -0
  185. package/kits/templates/roles/owner.json +1 -0
  186. package/kits/templates/roles/visitor.json +5 -0
  187. package/kits/templates/system-fragments/channel.md +12 -2
  188. package/kits/templates/system-fragments/commands.md +3 -1
  189. package/kits/templates/system-fragments/identity.md +3 -1
  190. package/kits/templates/system-fragments/relation.md +1 -1
  191. package/kits/templates/system-fragments/session.md +6 -2
  192. package/package.json +7 -5
  193. package/MIGRATION-0.5.0.md +0 -378
  194. package/ROLE_ACCESS_CONTROL.md +0 -174
  195. package/assets/brand/evolcore/README.md +0 -19
  196. package/assets/brand/evolcore/evolcore-app-icon.png +0 -0
  197. package/assets/brand/evolcore/evolcore-app-icon.svg +0 -13
  198. package/assets/brand/evolcore/evolcore-brand-board.png +0 -0
  199. package/assets/brand/evolcore/evolcore-brand-board.svg +0 -126
  200. package/assets/brand/evolcore/evolcore-logo-kit.zip +0 -0
  201. package/assets/brand/evolcore/evolcore-logo-reverse.png +0 -0
  202. package/assets/brand/evolcore/evolcore-logo-reverse.svg +0 -14
  203. package/assets/brand/evolcore/evolcore-logo.png +0 -0
  204. package/assets/brand/evolcore/evolcore-logo.svg +0 -14
  205. package/assets/brand/evolcore/evolcore-mark.png +0 -0
  206. package/assets/brand/evolcore/evolcore-mark.svg +0 -10
@@ -0,0 +1,24 @@
1
+ export function sendOperationId(envelope) {
2
+ return envelope.operationId || envelope.taskId;
3
+ }
4
+ export function sentReceipt(envelope, messageIds, threadId) {
5
+ const messages = messageIds
6
+ .filter((messageId) => typeof messageId === 'string' && messageId.length > 0)
7
+ .map((messageId, partIndex) => ({ messageId, partIndex, threadId }));
8
+ if (messages.length === 0) {
9
+ throw new Error(`MISSING_MESSAGE_ID: operation=${sendOperationId(envelope)}`);
10
+ }
11
+ return {
12
+ status: 'sent',
13
+ operationId: sendOperationId(envelope),
14
+ messages: messages,
15
+ };
16
+ }
17
+ export function suppressedReceipt(envelope, reason) {
18
+ return {
19
+ status: 'suppressed',
20
+ operationId: sendOperationId(envelope),
21
+ messages: [],
22
+ reason,
23
+ };
24
+ }
@@ -27,9 +27,9 @@ export class StreamDebouncer {
27
27
  * 否则新建窗口。timer 到期后自动 flush。
28
28
  */
29
29
  submit(key, message, enqueue) {
30
- const { content, images, mentions, messageId, replyContext, ...rest } = message;
30
+ const { content, images, mentions, messageId, sourceMessageIds, replyContext, ...rest } = message;
31
31
  return new Promise((resolve, reject) => {
32
- const entry = { messageId, content, images, mentions, replyContext, rest, resolve, reject };
32
+ const entry = { messageId, sourceMessageIds, content, images, mentions, replyContext, rest, resolve, reject };
33
33
  const win = this.pending.get(key);
34
34
  if (win) {
35
35
  clearTimeout(win.timer);
@@ -87,12 +87,20 @@ export class StreamDebouncer {
87
87
  const allImages = [];
88
88
  const allMentions = [];
89
89
  const contents = [];
90
+ const sourceMessageIds = [];
91
+ const seenSourceMessageIds = new Set();
90
92
  for (const e of entries) {
91
93
  contents.push(e.content);
92
94
  if (e.images)
93
95
  allImages.push(...e.images);
94
96
  if (e.mentions)
95
97
  allMentions.push(...e.mentions);
98
+ for (const id of [...(e.sourceMessageIds ?? []), ...(e.messageId ? [e.messageId] : [])]) {
99
+ if (!seenSourceMessageIds.has(id)) {
100
+ seenSourceMessageIds.add(id);
101
+ sourceMessageIds.push(id);
102
+ }
103
+ }
96
104
  }
97
105
  const last = entries[entries.length - 1];
98
106
  // 合并后保留最新一条的 messageId(用于 thought 锚定与中断追踪)
@@ -110,6 +118,7 @@ export class StreamDebouncer {
110
118
  mentions: allMentions.length > 0 ? allMentions : undefined,
111
119
  replyContext: last.replyContext,
112
120
  messageId: latestMessageId,
121
+ sourceMessageIds: sourceMessageIds.length > 0 ? sourceMessageIds : undefined,
113
122
  };
114
123
  const resolves = entries.map(e => e.resolve);
115
124
  const rejects = entries.map(e => e.reject);
@@ -55,10 +55,10 @@ function structuredApprovalAuditSummary(input) {
55
55
  .slice(0, 16);
56
56
  return fields.length > 0 ? `input_fields=${fields.join(',')}` : 'input_withheld';
57
57
  }
58
- function requiredApproverRole(policy, routeKind) {
58
+ function requiredApproverRole(policy) {
59
59
  if (policy === 'requester')
60
60
  return 'requester';
61
- if (policy === 'agent_owner' || routeKind === 'handoff')
61
+ if (policy === 'agent_owner')
62
62
  return 'owner';
63
63
  return 'manager';
64
64
  }
@@ -148,13 +148,20 @@ export function planApprovalRoute(challenge, context) {
148
148
  approverPrincipalId: actor.principalId,
149
149
  };
150
150
  }
151
- if (owners.length === 0) {
152
- return { kind: 'unavailable', reason: 'no_agent_owner_configured' };
151
+ const handoffCandidates = challenge.approverPolicy === 'agent_manager'
152
+ ? Array.from(new Set([...admins, ...owners]))
153
+ : owners;
154
+ if (handoffCandidates.length === 0) {
155
+ return {
156
+ kind: 'unavailable',
157
+ reason: challenge.approverPolicy === 'agent_manager'
158
+ ? 'no_agent_manager_configured'
159
+ : 'no_agent_owner_configured',
160
+ };
153
161
  }
154
- // Both management policies hand off to the first configured AUN owner.
155
- // Admin handoff approval and implicit approver selection are intentionally
156
- // out of scope; a future caller may explicitly select from the owner list.
157
- const approverId = owners[0];
162
+ // Manager handoffs prefer the configured admin pool for routine approvals;
163
+ // owner-only policies remain restricted to owners. Only one card is sent.
164
+ const approverId = handoffCandidates[0];
158
165
  if (!approverId) {
159
166
  return { kind: 'unavailable', reason: 'no_aun_owner_available' };
160
167
  }
@@ -505,7 +512,7 @@ export class PermissionGateway {
505
512
  grantScope,
506
513
  approverPolicy: challenge.approverPolicy,
507
514
  approvalRouteKind: route.kind,
508
- requiredApproverRole: requiredApproverRole(challenge.approverPolicy, route.kind),
515
+ requiredApproverRole: requiredApproverRole(challenge.approverPolicy),
509
516
  approverOperatorId: route.approverOperatorId,
510
517
  approverChannelKey: route.approverChannelKey,
511
518
  approverPrincipalId: route.approverPrincipalId,
@@ -763,7 +770,7 @@ export class PermissionGateway {
763
770
  grantScope,
764
771
  approverPolicy: challenge.approverPolicy,
765
772
  approvalRouteKind: route.kind,
766
- requiredApproverRole: requiredApproverRole(challenge.approverPolicy, route.kind),
773
+ requiredApproverRole: requiredApproverRole(challenge.approverPolicy),
767
774
  approverOperatorId: route.approverOperatorId,
768
775
  approverChannelKey: route.approverChannelKey,
769
776
  approverPrincipalId: route.approverPrincipalId,
@@ -1,5 +1,4 @@
1
- const DEFAULT_HEAD_LINES = 10;
2
- const MAX_HEAD_LINES = 10_000;
1
+ const HEAD_EXECUTABLES = new Set(['head', '/bin/head', '/usr/bin/head']);
3
2
  const SHELL_UNQUOTED_META = new Set([
4
3
  ';', '&', '|', '`', '<', '>', '$', '(', ')', '*', '?', '[', ']', '{', '}', '#',
5
4
  ]);
@@ -11,16 +10,36 @@ function trimShellSpacing(value) {
11
10
  function isShellSpacing(value) {
12
11
  return value === ' ' || value === '\t';
13
12
  }
13
+ function isLiteralArgBoundary(value) {
14
+ return value === undefined || isShellSpacing(value);
15
+ }
16
+ function isShellParameterExpansionStart(value) {
17
+ return value === '(' || value === '{' || !!value && /[A-Za-z0-9_@*#?$!\-]/.test(value);
18
+ }
19
+ function doubleQuotedIssueAt(input, index) {
20
+ const char = input[index];
21
+ if (char === '`' || (char === '$' && isShellParameterExpansionStart(input[index + 1]))) {
22
+ return 'unsafe-expansion';
23
+ }
24
+ if (char !== '\\')
25
+ return null;
26
+ const next = input[index + 1];
27
+ if (next === undefined)
28
+ return 'invalid-quote';
29
+ return next === '\r' || next === '\n' ? 'structural-newline' : null;
30
+ }
14
31
  /**
15
32
  * Parse the deliberately small shell subset accepted by the privileged EC
16
33
  * command path. The returned argv must describe one literal invocation: no
17
34
  * expansion, glob, pipeline, command substitution, redirection, or comment.
18
35
  * Anything outside this subset is never granted the privileged EC path.
19
36
  */
20
- export function parseLiteralShellArgv(command) {
37
+ export function parseLiteralShellCommand(command) {
21
38
  const input = trimShellSpacing(command);
22
- if (!input || input.includes('\0'))
23
- return null;
39
+ if (!input)
40
+ return { ok: false, issue: 'empty-command' };
41
+ if (input.includes('\0'))
42
+ return { ok: false, issue: 'invalid-control-char' };
24
43
  const argv = [];
25
44
  let token = '';
26
45
  let tokenStarted = false;
@@ -35,26 +54,28 @@ export function parseLiteralShellArgv(command) {
35
54
  for (let index = 0; index < input.length; index++) {
36
55
  const char = input[index];
37
56
  if (quote === 'single') {
38
- if (char === "'")
57
+ if (char === "'") {
58
+ if (!isLiteralArgBoundary(input[index + 1]))
59
+ return { ok: false, issue: 'invalid-quote' };
39
60
  quote = null;
61
+ }
40
62
  else
41
63
  token += char;
42
64
  continue;
43
65
  }
44
66
  if (quote === 'double') {
45
67
  if (char === '"') {
68
+ if (!isLiteralArgBoundary(input[index + 1]))
69
+ return { ok: false, issue: 'invalid-quote' };
46
70
  quote = null;
47
71
  continue;
48
72
  }
49
- if (char === '$' || char === '`')
50
- return null;
73
+ const issue = doubleQuotedIssueAt(input, index);
74
+ if (issue)
75
+ return { ok: false, issue };
51
76
  if (char === '\\') {
52
77
  const next = input[++index];
53
- if (next === undefined || next === '\r' || next === '\n')
54
- return null;
55
- if (next === '$' || next === '`')
56
- return null;
57
- token += ['"', '\\'].includes(next) ? next : `\\${next}`;
78
+ token += ['"', '\\', '$', '`'].includes(next) ? next : `\\${next}`;
58
79
  continue;
59
80
  }
60
81
  token += char;
@@ -70,8 +91,11 @@ export function parseLiteralShellArgv(command) {
70
91
  // includes NBSP, line/paragraph separators, VT and FF, which the shell
71
92
  // keeps inside the executable name; accepting those would misclassify a
72
93
  // different executable as `ec`.
73
- if (char === '\r' || char === '\n' || /\s/u.test(char) || char.charCodeAt(0) < 0x20 || char === '\x7f')
74
- return null;
94
+ if (char === '\r' || char === '\n')
95
+ return { ok: false, issue: 'structural-newline' };
96
+ if (/\s/u.test(char) || char.charCodeAt(0) < 0x20 || char === '\x7f') {
97
+ return { ok: false, issue: 'invalid-control-char' };
98
+ }
75
99
  if (char === "'") {
76
100
  tokenStarted = true;
77
101
  quote = 'single';
@@ -84,8 +108,10 @@ export function parseLiteralShellArgv(command) {
84
108
  }
85
109
  if (char === '\\') {
86
110
  const next = input[++index];
87
- if (next === undefined || next === '\r' || next === '\n')
88
- return null;
111
+ if (next === undefined)
112
+ return { ok: false, issue: 'invalid-quote' };
113
+ if (next === '\r' || next === '\n')
114
+ return { ok: false, issue: 'structural-newline' };
89
115
  tokenStarted = true;
90
116
  token += next;
91
117
  continue;
@@ -94,14 +120,20 @@ export function parseLiteralShellArgv(command) {
94
120
  // peer keys such as `aun#alice` are ordinary literal arguments.
95
121
  if ((SHELL_UNQUOTED_META.has(char) && (char !== '#' || !tokenStarted))
96
122
  || (char === '~' && !tokenStarted))
97
- return null;
123
+ return { ok: false, issue: 'shell-composition' };
98
124
  tokenStarted = true;
99
125
  token += char;
100
126
  }
101
127
  if (quote)
102
- return null;
128
+ return { ok: false, issue: 'invalid-quote' };
103
129
  finishToken();
104
- return argv.length > 0 ? argv : null;
130
+ return argv.length > 0
131
+ ? { ok: true, argv }
132
+ : { ok: false, issue: 'empty-command' };
133
+ }
134
+ export function parseLiteralShellArgv(command) {
135
+ const result = parseLiteralShellCommand(command);
136
+ return result.ok ? result.argv : null;
105
137
  }
106
138
  function scanBoundedOutputOperators(command) {
107
139
  const operators = [];
@@ -118,12 +150,10 @@ function scanBoundedOutputOperators(command) {
118
150
  quote = null;
119
151
  continue;
120
152
  }
121
- if (char === '$' || char === '`')
153
+ if (doubleQuotedIssueAt(command, index))
122
154
  return null;
123
155
  if (char === '\\') {
124
- const next = command[++index];
125
- if (next === undefined || next === '\r' || next === '\n' || next === '$' || next === '`')
126
- return null;
156
+ index++;
127
157
  }
128
158
  continue;
129
159
  }
@@ -171,21 +201,46 @@ function scanBoundedOutputOperators(command) {
171
201
  }
172
202
  return quote ? null : operators;
173
203
  }
174
- function parseHeadLines(segment) {
204
+ /**
205
+ * Accept `head` as a generic, trusted stdout filter without mirroring its
206
+ * option grammar here. Non-option operands are the security boundary: they
207
+ * could make `head` read a file instead of the EC process' stdout.
208
+ *
209
+ * GNU/POSIX head only uses a separate value for -n/-c (and their long forms).
210
+ * All other dash-prefixed arguments are safe to leave to head itself; unknown
211
+ * or malformed options simply make head fail.
212
+ */
213
+ function parseStdinOnlyHeadArgs(segment) {
175
214
  const argv = parseLiteralShellArgv(segment);
176
- if (!argv || (argv[0] !== 'head' && argv[0] !== '/usr/bin/head'))
177
- return null;
178
- let value;
179
- if (argv.length === 1)
180
- value = String(DEFAULT_HEAD_LINES);
181
- else if (argv.length === 2 && /^-\d+$/.test(argv[1]))
182
- value = argv[1].slice(1);
183
- else if (argv.length === 3 && argv[1] === '-n' && /^\d+$/.test(argv[2]))
184
- value = argv[2];
185
- else
215
+ if (!argv || !HEAD_EXECUTABLES.has(argv[0]))
186
216
  return null;
187
- const lines = Number(value);
188
- return Number.isSafeInteger(lines) && lines >= 1 && lines <= MAX_HEAD_LINES ? lines : null;
217
+ const args = argv.slice(1);
218
+ let optionsEnded = false;
219
+ let expectsOptionValue = false;
220
+ for (const arg of args) {
221
+ if (expectsOptionValue) {
222
+ expectsOptionValue = false;
223
+ continue;
224
+ }
225
+ if (optionsEnded) {
226
+ if (arg !== '-')
227
+ return null;
228
+ continue;
229
+ }
230
+ if (arg === '--') {
231
+ optionsEnded = true;
232
+ continue;
233
+ }
234
+ if (arg === '-')
235
+ continue;
236
+ if (!arg.startsWith('-'))
237
+ return null;
238
+ if (arg === '-n' || arg === '-c' || arg === '--lines' || arg === '--bytes') {
239
+ expectsOptionValue = true;
240
+ continue;
241
+ }
242
+ }
243
+ return args;
189
244
  }
190
245
  /**
191
246
  * Parse the only shell wrapper allowed around a privileged literal process:
@@ -234,13 +289,13 @@ export function parseBoundedOutputShellCommand(command) {
234
289
  else {
235
290
  redirect = first;
236
291
  }
237
- let headLines;
292
+ let headArgs;
238
293
  if (pipe) {
239
294
  const headEnd = redirect?.start ?? input.length;
240
- const parsedLines = parseHeadLines(trimShellSpacing(input.slice(pipe.end, headEnd)));
241
- if (parsedLines === null)
295
+ const parsedArgs = parseStdinOnlyHeadArgs(trimShellSpacing(input.slice(pipe.end, headEnd)));
296
+ if (parsedArgs === null)
242
297
  return null;
243
- headLines = parsedLines;
298
+ headArgs = parsedArgs;
244
299
  }
245
300
  let outputPath;
246
301
  if (redirect) {
@@ -249,7 +304,7 @@ export function parseBoundedOutputShellCommand(command) {
249
304
  return null;
250
305
  outputPath = outputArgv[0];
251
306
  }
252
- return { argv, mergeStderr, ...(headLines ? { headLines } : {}), ...(outputPath ? { outputPath } : {}) };
307
+ return { argv, mergeStderr, ...(headArgs ? { headArgs } : {}), ...(outputPath ? { outputPath } : {}) };
253
308
  }
254
309
  /**
255
310
  * Classify only the shell boundary around an EC process invocation. Runner
@@ -257,9 +312,9 @@ export function parseBoundedOutputShellCommand(command) {
257
312
  * invocation may leave the sandbox, while shell composition may not.
258
313
  */
259
314
  export function classifyEvolcoreShellCommand(command) {
260
- const literalArgv = parseLiteralShellArgv(command);
261
- if (literalArgv) {
262
- return literalArgv[0] === 'ec'
315
+ const literal = parseLiteralShellCommand(command);
316
+ if (literal.ok) {
317
+ return literal.argv[0] === 'ec'
263
318
  ? { kind: 'literal' }
264
319
  : { kind: 'none' };
265
320
  }
@@ -268,7 +323,7 @@ export function classifyEvolcoreShellCommand(command) {
268
323
  return { kind: 'bounded-output', command: boundedOutput };
269
324
  }
270
325
  return EVOLCORE_COMMAND_POSITION_RE.test(command)
271
- ? { kind: 'composite' }
326
+ ? { kind: 'composite', issue: literal.issue }
272
327
  : { kind: 'none' };
273
328
  }
274
329
  function isDeterministicFilePath(value) {
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import { randomUUID } from 'crypto';
4
4
  import { logger } from '../../utils/logger.js';
5
5
  import { resolveRoot } from '../../paths.js';
6
- import { containsHClassReference, hClassGrantIncludesProtectedPath, isHClassPath, isSameOrDescendant, isLClassPath, lClassGrantIncludesProtectedPath, resolveProtectedCandidate, } from '../protected-paths.js';
6
+ import { containsHClassReference, containsLClassReference, hClassGrantIncludesProtectedPath, isHClassPath, isSameOrDescendant, isLClassPath, lClassGrantIncludesProtectedPath, resolveProtectedCandidate, } from '../protected-paths.js';
7
7
  import { classifyEvolcoreShellCommand, parseBoundedOutputShellCommand, parseLiteralShellArgv, } from './ec-command-parser.js';
8
8
  // 绝对禁止命令(所有角色、所有权限模式下都禁止,不可授权)
9
9
  // 这些是系统级破坏操作,任何情况下都不应该允许
@@ -18,6 +18,15 @@ const READONLY_SHELL_EXECUTABLES = new Set([
18
18
  'ls', '/bin/ls', '/usr/bin/ls',
19
19
  'cat', '/bin/cat', '/usr/bin/cat',
20
20
  ]);
21
+ const L_CLASS_READONLY_SHELL_EXECUTABLES = new Set([
22
+ ...READONLY_SHELL_EXECUTABLES,
23
+ 'grep', '/bin/grep', '/usr/bin/grep',
24
+ 'rg', '/bin/rg', '/usr/bin/rg',
25
+ 'find', '/bin/find', '/usr/bin/find',
26
+ 'sed', '/bin/sed', '/usr/bin/sed',
27
+ ]);
28
+ const FIND_MUTATING_PRIMARY_RE = /^-(?:delete|exec|execdir|ok|okdir|fprint|fprint0|fprintf|fls)$/;
29
+ const RG_PROCESS_SPAWNING_OPTION_RE = /^--pre(?:=|$)|^--pre-glob(?:=|$)/;
21
30
  // 危险操作(需要用户审批才能执行)
22
31
  // 这些操作有合理使用场景,但需要用户明确授权
23
32
  const DANGEROUS_PATTERNS = [
@@ -347,25 +356,45 @@ export function checkReadonly(toolName, input, projectPath, context) {
347
356
  logger.warn(`[ReadonlyCheck] 🔒 Unknown tool blocked: tool=${toolName} session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
348
357
  return { behavior: 'deny', message: `🔒 只读模式:未声明为只读的工具 ${toolName} 已拒绝执行` };
349
358
  }
350
- /**
351
- * H 类文件保护(用于 PreToolUse hook)
352
- * H 类文件只能由受控的人类/daemon 路径处理,agent 不可直接访问
353
- */
354
- function collectStringValues(value, output) {
359
+ const NON_FILESYSTEM_METADATA_TOOLS = new Set([
360
+ 'Agent',
361
+ 'AskUserQuestion',
362
+ 'CronCreate',
363
+ 'CronDelete',
364
+ 'CronList',
365
+ 'EnterPlanMode',
366
+ 'ExitPlanMode',
367
+ 'SendMessage',
368
+ 'Skill',
369
+ 'Task',
370
+ 'TaskCreate',
371
+ 'TaskGet',
372
+ 'TaskList',
373
+ 'TaskOutput',
374
+ 'TaskStop',
375
+ 'TaskUpdate',
376
+ 'TeamCreate',
377
+ 'TeamDelete',
378
+ 'TodoWrite',
379
+ 'ToolSearch',
380
+ 'WebFetch',
381
+ 'WebSearch',
382
+ ]);
383
+ function collectOpaqueToolStrings(value, output) {
355
384
  if (typeof value === 'string') {
356
- output.push(value);
385
+ if (value.trim())
386
+ output.push(value);
357
387
  return;
358
388
  }
359
389
  if (Array.isArray(value)) {
360
390
  for (const entry of value)
361
- collectStringValues(entry, output);
391
+ collectOpaqueToolStrings(entry, output);
362
392
  return;
363
393
  }
364
394
  if (!value || typeof value !== 'object')
365
395
  return;
366
- for (const [key, entry] of Object.entries(value)) {
367
- output.push(key);
368
- collectStringValues(entry, output);
396
+ for (const entry of Object.values(value)) {
397
+ collectOpaqueToolStrings(entry, output);
369
398
  }
370
399
  }
371
400
  function collectPermissionPathSpec(value, output, projectRootSubpaths) {
@@ -532,28 +561,50 @@ function collectFileChangePaths(value, output) {
532
561
  }
533
562
  function shellCommandReferencesLClass(command, options) {
534
563
  const normalized = command.replace(/\\/g, '/');
535
- const root = path.resolve(options.root ?? resolveRoot()).replace(/\\/g, '/');
536
- const protectedRoot = `${root.replace(/\/+$/, '')}/data/triggers`;
537
- if (normalized.includes(protectedRoot))
564
+ if (containsLClassReference(normalized))
538
565
  return true;
539
- const references = normalized.match(/(?:^|[\s"'=<>;&|()])((?:\.\.\/|\.\/)*data\/triggers(?:\/[^\s"';&|<>)]*)?)/g) ?? [];
540
- return references.some(reference => {
541
- const candidate = reference.replace(/^[\s"'=<>;&|()]+/, '');
542
- return isLClassPath(candidate, options);
543
- });
566
+ const argv = parseLiteralShellArgv(command);
567
+ return argv?.slice(1).some(argument => {
568
+ if (argument === '-' || argument === '--' || argument.startsWith('-'))
569
+ return false;
570
+ return isLClassPath(argument, options);
571
+ }) ?? false;
544
572
  }
545
- function isLClassMutatingShellCommand(command, options) {
573
+ function isVerifiedLClassReadOnlyShellCommand(command) {
574
+ const argv = parseLiteralShellArgv(command);
575
+ if (!argv || !L_CLASS_READONLY_SHELL_EXECUTABLES.has(argv[0]))
576
+ return false;
577
+ switch (argv[0].replace(/^.*\//, '')) {
578
+ case 'grep':
579
+ // grep never mutates its operands. Shell composition is rejected by the
580
+ // literal parser before this point.
581
+ return true;
582
+ case 'rg':
583
+ // ripgrep's --pre hooks may execute an arbitrary process.
584
+ return !argv.slice(1).some(argument => RG_PROCESS_SPAWNING_OPTION_RE.test(argument));
585
+ case 'find':
586
+ // Keep only the predicates/actions which cannot create, mutate, or
587
+ // execute files. Other find options remain fail-closed.
588
+ return !argv.slice(1).some(argument => FIND_MUTATING_PRIMARY_RE.test(argument));
589
+ case 'sed': {
590
+ // sed can write via -i or a `w` command. Permit only the bounded
591
+ // inspection form used for line-range reads: sed -n 'START[,END]p'.
592
+ const quiet = argv[1] === '-n' || argv[1] === '--quiet' || argv[1] === '--silent';
593
+ const program = quiet ? argv[2] : undefined;
594
+ return !!program
595
+ && /^(?:\d+(?:,\d+)?|\$)?p$/.test(program)
596
+ // GNU sed accepts options after the script as well. Reject every
597
+ // later option so -i/-e/-f cannot be smuggled into this read form.
598
+ && !argv.slice(3).some(argument => argument.startsWith('-'));
599
+ }
600
+ default:
601
+ return true;
602
+ }
603
+ }
604
+ function isUnverifiedLClassShellCommand(command, options) {
546
605
  if (!shellCommandReferencesLClass(command, options))
547
606
  return false;
548
- const mutationPatterns = [
549
- /(?:^|[;&|]\s*)\s*(?:rm|unlink|rmdir|mv|rename|truncate|touch|chmod|chown|chgrp|mkdir)\b/i,
550
- /(?:^|[;&|]\s*)\s*(?:sed\s+[^;&|]*\s-i\b|perl\s+[^;&|]*\s-pi\b)/i,
551
- /(?:^|[;&|]\s*)\s*tee\b/i,
552
- /\bfind\b[^;&|]*(?:-delete|-exec\s+(?:rm|mv|chmod|chown)\b)/i,
553
- /\bgit\s+(?:checkout|restore|clean|reset)\b/i,
554
- /(?:^|[^<])>{1,2}\s*[^;&|\r\n]*data[\\/]triggers(?:[\\/]|$)/i,
555
- ];
556
- return mutationPatterns.some(pattern => pattern.test(command));
607
+ return !isVerifiedLClassReadOnlyShellCommand(command);
557
608
  }
558
609
  function requestsFilesystemRoot(value) {
559
610
  if (Array.isArray(value))
@@ -604,9 +655,18 @@ export function checkHClassWrite(toolName, input, context) {
604
655
  collectFilesystemGrantPaths(input.additionalPermissions, grantPaths, projectRootGrantSubpaths);
605
656
  collectFilesystemGrantPaths(input.permissions, grantPaths, projectRootGrantSubpaths);
606
657
  }
607
- else {
608
- collectStringValues(input, paths);
658
+ else if (!NON_FILESYSTEM_METADATA_TOOLS.has(toolName)) {
659
+ // Opaque external tools may run outside the Claude/Codex filesystem
660
+ // sandbox. Retain conservative string inspection for those tools, while
661
+ // excluding known orchestration tools whose payload is only metadata.
662
+ collectOpaqueToolStrings(input, paths);
609
663
  }
664
+ // Agent/Skill/Task and other orchestration tools carry natural-language
665
+ // metadata, not direct filesystem operands. Treating every string in their
666
+ // payload as a path makes a prompt that merely mentions daemon.json or a
667
+ // *.json_ backup indistinguishable from an actual file access. Any nested
668
+ // filesystem operation is checked again when it reaches Read/Write/Bash,
669
+ // FileChange, or PermissionGrant above.
610
670
  const pathOptions = { cwd: context?.projectPath, root: context?.root };
611
671
  if (requestsFilesystemRoot(input.permissions) || requestsFilesystemRoot(input.additionalPermissions)) {
612
672
  logger.warn(`[H-Class Protection] 🔒 Permission grant covers filesystem root: tool=${toolName} ` +
@@ -718,20 +778,23 @@ export function checkLClassWrite(toolName, input, context) {
718
778
  else if (toolName === 'Bash') {
719
779
  const command = typeof input.command === 'string' ? input.command.replace(/\\/g, '/') : '';
720
780
  const pathOptions = { cwd: context?.projectPath, root: context?.root };
721
- if (isLClassMutatingShellCommand(command, pathOptions)) {
722
- logger.warn(`[L-Class Protection] Protected path mutation requested by shell command: tool=${toolName} ` +
781
+ if (isUnverifiedLClassShellCommand(command, pathOptions)) {
782
+ logger.warn(`[L-Class Protection] Unverified L-class shell command denied: tool=${toolName} ` +
723
783
  `session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
724
784
  return {
725
785
  behavior: 'deny',
726
- message: '🔒 Shell 命令试图修改 L-class 只读路径;读取允许,修改请使用 ec trigger',
786
+ message: '🔒 L-class 路径仅允许单个可验证的只读命令。请拆分为单条 ls/cat/grep/rg/find 或 sed -n,或使用 Read;禁止管道、重定向、变量展开和命令拼接。修改必须通过受控的 EvolCore 命令',
727
787
  };
728
788
  }
729
789
  collectFilesystemWriteGrantPaths(input.additionalPermissions, writeGrantPaths, projectRootWriteSubpaths);
730
790
  collectFilesystemWriteGrantPaths(input.permissions, writeGrantPaths, projectRootWriteSubpaths);
731
791
  }
732
- else {
733
- collectStringValues(input, paths);
792
+ else if (!NON_FILESYSTEM_METADATA_TOOLS.has(toolName)) {
793
+ collectOpaqueToolStrings(input, paths);
734
794
  }
795
+ // Do not infer mutation targets from arbitrary orchestration-tool strings.
796
+ // Skills commonly pass args: "" and delegation prompts may discuss
797
+ // protected paths; neither is itself a filesystem mutation.
735
798
  const pathOptions = { cwd: context?.projectPath, root: context?.root };
736
799
  for (const grantPath of writeGrantPaths) {
737
800
  if (grantPath && lClassGrantIncludesProtectedPath(grantPath, pathOptions)) {
@@ -759,7 +822,7 @@ export function checkLClassWrite(toolName, input, context) {
759
822
  `session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
760
823
  return {
761
824
  behavior: 'deny',
762
- message: `🔒 L-class 路径允许读取但禁止 Agent 直接修改、删除或移动\n\n文件:${filePath}\n\n💡 请使用 ec trigger 命令修改`,
825
+ message: `🔒 L-class 路径允许读取但禁止 Agent 直接修改、删除或移动\n\n文件:${filePath}\n\n💡 请使用对应的 EvolCore 受控命令修改`,
763
826
  };
764
827
  }
765
828
  return { behavior: 'allow' };
@@ -862,7 +925,7 @@ function prepareBoundedOutputInput(input, command, kind, context) {
862
925
  cwd: path.resolve(context.projectPath),
863
926
  argv: command.argv,
864
927
  mergeStderr: command.mergeStderr,
865
- ...(command.headLines ? { headLines: command.headLines } : {}),
928
+ ...(command.headArgs ? { headArgs: command.headArgs } : {}),
866
929
  ...(command.outputPath ? { outputPath: command.outputPath } : {}),
867
930
  }), 'utf8').toString('base64url');
868
931
  const rewrittenCommand = `${quotePosixShellArg(process.execPath)} ${quotePosixShellArg(helperPath)} ${quotePosixShellArg(payload)}`;
@@ -884,6 +947,20 @@ export function evaluateToolPreflight(toolName, input, context) {
884
947
  return prepareBoundedOutputInput(input, ecCommand.command, 'ec', context);
885
948
  }
886
949
  if (ecCommand.kind === 'composite') {
950
+ if (ecCommand.issue === 'unsafe-expansion') {
951
+ return {
952
+ behavior: 'deny',
953
+ input,
954
+ message: '🔒 EC 双引号正文包含未转义的 Shell 展开;请将反引号写成 \\`,将 $()、${}、$变量中的 $ 写成 \\$。Shell 传给 ec 时会还原为原文字面量',
955
+ };
956
+ }
957
+ if (ecCommand.issue === 'invalid-quote') {
958
+ return {
959
+ behavior: 'deny',
960
+ input,
961
+ message: '🔒 EC 命令引号未闭合或存在错误嵌套;正文中的双引号请写成 \\"',
962
+ };
963
+ }
887
964
  return {
888
965
  behavior: 'deny',
889
966
  input,