evolcore 0.0.14 → 0.0.16

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 (36) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -0
  3. package/dist/agents/claude-runner.js +119 -29
  4. package/dist/agents/codex-runner.js +39 -10
  5. package/dist/agents/ecagent-runner.js +4 -1
  6. package/dist/aun/msg/p2p.js +5 -5
  7. package/dist/channels/aun.js +73 -20
  8. package/dist/channels/daemon.js +15 -9
  9. package/dist/channels/feishu.js +6 -1
  10. package/dist/cli/init.js +7 -3
  11. package/dist/cli/task-context.js +8 -4
  12. package/dist/config/config-manager.js +1 -0
  13. package/dist/core/audit/log-integrity.js +149 -0
  14. package/dist/core/auth/authorization-audit.js +73 -3
  15. package/dist/core/command/slash-handler.js +1 -1
  16. package/dist/core/event-catalog.js +1 -0
  17. package/dist/core/message/response-engine.js +35 -21
  18. package/dist/core/permission/approval-gateway.js +99 -16
  19. package/dist/core/permission/tool-error-code.js +47 -0
  20. package/dist/core/permission/tool-policy.js +61 -2
  21. package/dist/index.js +8 -4
  22. package/dist/ipc.js +11 -6
  23. package/dist/paths.js +18 -1
  24. package/dist/trigger/scheduler.js +5 -4
  25. package/dist/utils/cross-platform.js +1 -24
  26. package/dist/utils/instance-registry.js +35 -27
  27. package/dist/utils/logger.js +41 -10
  28. package/dist/utils/windows-autostart.js +50 -9
  29. package/dist/utils/windows-output.js +36 -0
  30. package/kits/docs/evolcore/config.md +1 -0
  31. package/kits/schemas/agent-config.schema.10.json +6 -0
  32. package/kits/schemas/daemon.schema.1.json +1 -1
  33. package/kits/schemas/daemon.schema.2.json +1 -1
  34. package/kits/schemas/daemon.schema.3.json +1 -1
  35. package/kits/schemas/daemon.schema.4.json +1 -1
  36. package/package.json +1 -1
@@ -41,6 +41,41 @@ export const AUN_HANDOFF_MARKER_FIELD = '_evolcore_handoff_id';
41
41
  const AUN_INTERACTION_CARD_TTL_MS = 24 * 60 * 60 * 1000;
42
42
  const AUN_INBOUND_DEDUP_TTL_MS = 7 * 24 * 60 * 60 * 1000;
43
43
  const IMAGE_MIME_TYPE = /^image\/[!#$%&'*+.^_`|~0-9A-Za-z-]+$/i;
44
+ const AUN_ATTACHMENT_DOWNLOAD_ATTEMPTS = 3;
45
+ const AUN_ATTACHMENT_RETRY_DELAYS_MS = [250, 750];
46
+ function attachmentDownloadHost(url) {
47
+ try {
48
+ return new URL(url).host || '<unknown>';
49
+ }
50
+ catch {
51
+ return '<invalid-url>';
52
+ }
53
+ }
54
+ function attachmentDownloadErrorDetails(error) {
55
+ const value = error && typeof error === 'object' ? error : {};
56
+ const cause = value.cause && typeof value.cause === 'object'
57
+ ? value.cause
58
+ : {};
59
+ const status = typeof value.status === 'number'
60
+ ? value.status
61
+ : typeof cause.status === 'number' ? cause.status : undefined;
62
+ const code = typeof cause.code === 'string'
63
+ ? cause.code
64
+ : typeof value.code === 'string' ? value.code : undefined;
65
+ const message = typeof value.message === 'string'
66
+ ? value.message
67
+ : String(error);
68
+ const messageStatus = status === undefined
69
+ ? message.match(/\bHTTP\s+(\d{3})\b|\bDownload failed:\s*(\d{3})\b/i)
70
+ : null;
71
+ const parsedStatus = messageStatus
72
+ ? Number(messageStatus[1] ?? messageStatus[2])
73
+ : undefined;
74
+ return { status: status ?? (Number.isFinite(parsedStatus) ? parsedStatus : undefined), code, message };
75
+ }
76
+ function isRetryableAttachmentStatus(status) {
77
+ return status === undefined || status === 408 || status === 429 || status >= 500;
78
+ }
44
79
  // AUN limits the complete encrypted thought envelope to 8192 bytes. The
45
80
  // encrypted group envelope grows with the number of recipient devices, so a
46
81
  // conservative plaintext budget avoids rejecting otherwise valid thoughts.
@@ -1449,6 +1484,31 @@ export class AUNChannel {
1449
1484
  logger.info(`${this.logPrefix()} [attachments] count=${rawAttachments.length} images=${images.length} files=${fileParts.length}`);
1450
1485
  return { finalText, images };
1451
1486
  }
1487
+ async downloadAttachmentWithRetry(url, filename, source, download) {
1488
+ const host = attachmentDownloadHost(url);
1489
+ for (let attempt = 1; attempt <= AUN_ATTACHMENT_DOWNLOAD_ATTEMPTS; attempt++) {
1490
+ try {
1491
+ const buffer = await download();
1492
+ if (attempt > 1) {
1493
+ logger.info(`${this.logPrefix()} ${source} attachment download recovered for ${filename}: attempt=${attempt} host=${host}`);
1494
+ }
1495
+ return buffer;
1496
+ }
1497
+ catch (error) {
1498
+ const details = attachmentDownloadErrorDetails(error);
1499
+ const status = details.status === undefined ? '-' : String(details.status);
1500
+ const code = details.code ?? '-';
1501
+ logger.warn(`${this.logPrefix()} ${source} attachment download failed for ${filename}: `
1502
+ + `attempt=${attempt}/${AUN_ATTACHMENT_DOWNLOAD_ATTEMPTS} host=${host} `
1503
+ + `status=${status} code=${code} error=${details.message}`);
1504
+ if (attempt >= AUN_ATTACHMENT_DOWNLOAD_ATTEMPTS || !isRetryableAttachmentStatus(details.status))
1505
+ break;
1506
+ const delayMs = AUN_ATTACHMENT_RETRY_DELAYS_MS[attempt - 1] ?? AUN_ATTACHMENT_RETRY_DELAYS_MS.at(-1) ?? 0;
1507
+ await new Promise(resolve => setTimeout(resolve, delayMs));
1508
+ }
1509
+ }
1510
+ return null;
1511
+ }
1452
1512
  async downloadAttachment(att, channelId, delivery) {
1453
1513
  const ownerAid = att.owner_aid
1454
1514
  || (delivery?.chatType === 'private' ? channelId : '')
@@ -1491,34 +1551,27 @@ export class AUNChannel {
1491
1551
  logger.warn(`${this.logPrefix()} create_download_ticket failed for ${filename}: ${e}`);
1492
1552
  }
1493
1553
  }
1494
- let buffer;
1554
+ let buffer = null;
1495
1555
  if (downloadUrl) {
1496
- try {
1556
+ buffer = await this.downloadAttachmentWithRetry(downloadUrl, filename, 'ticket', async () => {
1497
1557
  const res = await fetch(downloadUrl);
1498
1558
  if (!res.ok) {
1499
- logger.warn(`${this.logPrefix()} Download failed for ${filename}: HTTP ${res.status}`);
1500
- return null;
1559
+ const error = new Error(`HTTP ${res.status}`);
1560
+ error.status = res.status;
1561
+ throw error;
1501
1562
  }
1502
- buffer = Buffer.from(await res.arrayBuffer());
1503
- }
1504
- catch (e) {
1505
- logger.warn(`${this.logPrefix()} Download error for ${filename}: ${e}`);
1506
- return null;
1507
- }
1563
+ return Buffer.from(await res.arrayBuffer());
1564
+ });
1508
1565
  }
1509
- else {
1510
- if (!fallbackUrl)
1511
- return null;
1512
- try {
1513
- const host = new URL(fallbackUrl).hostname;
1514
- buffer = await safeFetch(fallbackUrl, { allowedHosts: new Set([host]) });
1566
+ if (!buffer && fallbackUrl && fallbackUrl !== downloadUrl) {
1567
+ const host = new URL(fallbackUrl).hostname;
1568
+ buffer = await this.downloadAttachmentWithRetry(fallbackUrl, filename, 'payload', () => safeFetch(fallbackUrl, { allowedHosts: new Set([host]) }));
1569
+ if (buffer) {
1515
1570
  logger.info(`${this.logPrefix()} Downloaded attachment via payload URL fallback: ${filename}`);
1516
1571
  }
1517
- catch (e) {
1518
- logger.warn(`${this.logPrefix()} Payload URL fallback failed for ${filename}: ${e}`);
1519
- return null;
1520
- }
1521
1572
  }
1573
+ if (!buffer)
1574
+ return null;
1522
1575
  if (att.sha256) {
1523
1576
  const { createHash } = await import('node:crypto');
1524
1577
  const actual = createHash('sha256').update(buffer).digest('hex');
@@ -29,14 +29,17 @@ export class DaemonChannel {
29
29
  const pendingId = ctx.attemptId ?? runId;
30
30
  const messageId = pendingId;
31
31
  const startedAt = Date.now();
32
- const remainingExecutionMs = ctx.executionDeadlineAt === undefined
33
- ? this.totalExecutionMs
34
- : Math.max(1, ctx.executionDeadlineAt - startedAt);
32
+ const remainingExecutionMs = typeof ctx.executionDeadlineAt === 'number'
33
+ && Number.isFinite(ctx.executionDeadlineAt)
34
+ ? Math.max(1, ctx.executionDeadlineAt - startedAt)
35
+ : this.totalExecutionMs;
35
36
  const promise = new Promise((resolve) => {
36
37
  const watchdogTimer = setTimeout(() => this.watchdogTimeout(pendingId), this.watchdogMs);
37
38
  watchdogTimer.unref?.();
38
- const totalExecutionTimer = setTimeout(() => this.totalExecutionTimeout(pendingId, remainingExecutionMs), remainingExecutionMs);
39
- totalExecutionTimer.unref?.();
39
+ const totalExecutionTimer = remainingExecutionMs === undefined
40
+ ? undefined
41
+ : setTimeout(() => this.totalExecutionTimeout(pendingId, remainingExecutionMs), remainingExecutionMs);
42
+ totalExecutionTimer?.unref?.();
40
43
  this.pending.set(pendingId, {
41
44
  resolve,
42
45
  startedAt,
@@ -214,7 +217,9 @@ export class DaemonChannel {
214
217
  return await this.sessionManager.getOrCreateSession(this.channelName, channelId, projectPath, threadId, { channelKey: this.channelKey, peerId: `trigger:${trigger.id}`, peerName: trigger.name }, trigger.name, undefined, 'private', baseagent, trigger.agentAid, this.channelName, 'system');
215
218
  }
216
219
  executionDeadlineAt(startedAt = Date.now()) {
217
- return startedAt + this.totalExecutionMs;
220
+ return this.totalExecutionMs === undefined
221
+ ? undefined
222
+ : startedAt + this.totalExecutionMs;
218
223
  }
219
224
  async getResumableConversationSession(sessionId) {
220
225
  const session = await this.sessionManager.getSessionById(sessionId);
@@ -281,7 +286,8 @@ export class DaemonChannel {
281
286
  slot.terminalClaim = { outcome, metadata };
282
287
  if (slot.watchdogTimer)
283
288
  clearTimeout(slot.watchdogTimer);
284
- clearTimeout(slot.totalExecutionTimer);
289
+ if (slot.totalExecutionTimer)
290
+ clearTimeout(slot.totalExecutionTimer);
285
291
  if (slot.abortListener)
286
292
  slot.abortSignal?.removeEventListener('abort', slot.abortListener);
287
293
  return true;
@@ -347,7 +353,7 @@ export class DaemonChannel {
347
353
  slot.watchdogTimer = setTimeout(() => this.watchdogTimeout(runId), this.watchdogMs);
348
354
  slot.watchdogTimer.unref?.();
349
355
  }
350
- totalExecutionTimeout(runId, totalExecutionMs = this.totalExecutionMs) {
356
+ totalExecutionTimeout(runId, totalExecutionMs) {
351
357
  const slot = this.pending.get(runId);
352
358
  if (!slot || slot.terminalClaim)
353
359
  return;
@@ -390,7 +396,7 @@ function normalizeWatchdogMs(value) {
390
396
  function normalizeTotalExecutionMs(value) {
391
397
  return typeof value === 'number' && Number.isFinite(value) && value > 0
392
398
  ? value
393
- : 60 * 60 * 1000;
399
+ : undefined;
394
400
  }
395
401
  function channelTypeFromKey(channelKey) {
396
402
  const idx = channelKey.indexOf('#');
@@ -1467,9 +1467,14 @@ export function buildCardV2(interaction, opts) {
1467
1467
  export function buildResolvedV2(interaction, response) {
1468
1468
  const action = response.action;
1469
1469
  const kind = interaction.kind;
1470
+ const temporaryGrantButton = kind.kind === 'action'
1471
+ ? kind.buttons.find(button => button.key === 'always' || button.key === 'approve_session_30m')
1472
+ : undefined;
1473
+ const temporaryGrantIsFileScoped = temporaryGrantButton?.label.includes('同文件') === true;
1470
1474
  const labelMap = {
1471
1475
  'allow': '✅ 已允许',
1472
- 'always': '⏱ 已授权同操作 30 分钟',
1476
+ 'always': temporaryGrantIsFileScoped ? '⏱ 已授权同文件 30 分钟' : '⏱ 已授权同操作 30 分钟',
1477
+ 'approve_session_30m': temporaryGrantIsFileScoped ? '⏱ 已授权同文件 30 分钟' : '⏱ 已授权本会话 30 分钟',
1473
1478
  'deny': '❌ 已拒绝',
1474
1479
  'cancel': '取消',
1475
1480
  };
package/dist/cli/init.js CHANGED
@@ -444,6 +444,7 @@ export async function initTail(options = {}) {
444
444
  const daemonConfig = loadDaemonConfig();
445
445
  let controlAidReady = !!daemonConfig.aid;
446
446
  let autoStartReady = true;
447
+ const autoStartExplicit = options.autoStart !== undefined;
447
448
  let ownerBindingInterrupted = false;
448
449
  if (daemonConfig.aid) {
449
450
  console.log(`✓ 控制 AID 已存在: ${daemonConfig.aid}`);
@@ -596,13 +597,16 @@ export async function initTail(options = {}) {
596
597
  }
597
598
  // ec start 会接着启动 daemon,并在其 ready 后给出可用的 Agent 创建入口。
598
599
  if (options.invokedByStart)
599
- return controlAidReady && autoStartReady;
600
+ return controlAidReady && (!autoStartExplicit || autoStartReady);
600
601
  if (!controlAidReady) {
601
602
  console.error('\n❌ EvolCore 初始化未完成:控制 AID 未配置。联网后重新运行 ec init。');
602
603
  return false;
603
604
  }
604
- if (!autoStartReady)
605
- return false;
605
+ if (!autoStartReady) {
606
+ if (autoStartExplicit)
607
+ return false;
608
+ console.log(' ⚠️ 登录自启配置未完成,可稍后运行 ec init --auto-start 重试');
609
+ }
606
610
  // 初始化完成总结
607
611
  console.log('\n✓ EvolCore 初始化完成');
608
612
  const finalCfg = loadDaemonConfig();
@@ -47,10 +47,14 @@ function isPrivateDirectory(directory) {
47
47
  try {
48
48
  const resolved = path.resolve(directory);
49
49
  const stat = fs.lstatSync(resolved);
50
+ // Windows does not expose Unix ownership and permission bits through
51
+ // Stats.mode. The ACL is enforced by the platform, so applying the
52
+ // POSIX 0700 check here rejects otherwise valid Windows directories.
53
+ const isWindows = process.platform === 'win32';
50
54
  if (!stat.isDirectory()
51
55
  || stat.isSymbolicLink()
52
- || (typeof process.getuid === 'function' && stat.uid !== process.getuid())
53
- || (stat.mode & 0o077) !== 0)
56
+ || (!isWindows && typeof process.getuid === 'function' && stat.uid !== process.getuid())
57
+ || (!isWindows && (stat.mode & 0o077) !== 0))
54
58
  return false;
55
59
  // A private leaf below a symlinked ancestor can still escape the managed
56
60
  // namespace. Walk the existing ancestor chain and reject such paths while
@@ -138,8 +142,8 @@ function isRunnerOwnedSessionRuntimeDir(directory) {
138
142
  }
139
143
  /** Whether a task-provided runtime directory stays inside the process-managed
140
144
  * TMPDIR and is safe to use for transient writes. */
141
- export function isManagedSessionRuntimeDir(directory) {
142
- const tmpDir = process.env.TMPDIR?.trim();
145
+ export function isManagedSessionRuntimeDir(directory, managedRoot) {
146
+ const tmpDir = (managedRoot ?? process.env.TMPDIR)?.trim();
143
147
  if (!directory || !path.isAbsolute(directory) || !tmpDir || !path.isAbsolute(tmpDir))
144
148
  return false;
145
149
  const root = path.resolve(tmpDir);
@@ -1120,6 +1120,7 @@ export function resolveEffective(sel, opts = {}) {
1120
1120
  models: config.models,
1121
1121
  projects: config.projects,
1122
1122
  capabilities: config.capabilities,
1123
+ readonlySourceDiagnostics: config.readonlySourceDiagnostics,
1123
1124
  observable: config.observable,
1124
1125
  extra_backup: config.extra_backup,
1125
1126
  // Runtime configuration parameters
@@ -0,0 +1,149 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ // Logger rotation is hourly/daily, but the suffix is intentionally generic so
5
+ // this check also covers future rotation formats without changing the audit.
6
+ const STRUCTURED_LOG_RE = /^(?:daemon|events|channel-in|channel-out|messages|command-audit)(?:-[^.]+)?\.log$/;
7
+ const MIXED_TEXT_LOG_RE = /^daemon(?:-[^.]+)?\.log$/;
8
+ /** Validate and de-duplicate structured event streams by lifecycle identity. */
9
+ export function inspectStructuredLogs(logDir, options = {}) {
10
+ const issues = [];
11
+ const records = [];
12
+ let files = [];
13
+ try {
14
+ files = fs.readdirSync(logDir).filter(name => STRUCTURED_LOG_RE.test(name)
15
+ && (options.includeMessages || !name.startsWith('messages')));
16
+ }
17
+ catch {
18
+ return { files: 0, records: 0, uniqueRecords: 0, duplicates: 0, issues };
19
+ }
20
+ for (const file of files) {
21
+ let lines;
22
+ try {
23
+ lines = fs.readFileSync(path.join(logDir, file), 'utf8').split('\n');
24
+ }
25
+ catch {
26
+ continue;
27
+ }
28
+ lines.forEach((line, index) => {
29
+ let raw = line.trim();
30
+ if (!raw)
31
+ return;
32
+ // daemon logs mix human-readable entries with structured audit lines.
33
+ // Ignore the former, but still validate malformed structured payloads.
34
+ if (MIXED_TEXT_LOG_RE.test(file)) {
35
+ if (raw.startsWith('[CommandAuditRecord] '))
36
+ raw = raw.slice('[CommandAuditRecord] '.length);
37
+ else if (!raw.startsWith('{'))
38
+ return;
39
+ }
40
+ let value;
41
+ try {
42
+ const parsed = JSON.parse(raw);
43
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
44
+ throw new Error('not object');
45
+ value = parsed;
46
+ }
47
+ catch {
48
+ issues.push({ file, line: index + 1, reason: 'invalid_json' });
49
+ return;
50
+ }
51
+ const key = structuredRecordKey(value);
52
+ records.push({ file, line: index + 1, value, key });
53
+ if (isLifecycleRecord(value) && !hasContextValue(lifecycleField(value, 'sessionId')))
54
+ issues.push({ file, line: index + 1, reason: 'missing_session', key });
55
+ if (isLifecycleRecord(value) && !hasContextValue(lifecycleField(value, 'agentAid')))
56
+ issues.push({ file, line: index + 1, reason: 'missing_agent', key });
57
+ if (isLifecycleRecord(value) && !hasContextValue(lifecycleField(value, 'permissionMode')))
58
+ issues.push({ file, line: index + 1, reason: 'missing_permission_mode', key });
59
+ if (isCorrelatableRecord(value) && !key)
60
+ issues.push({ file, line: index + 1, reason: 'missing_correlation' });
61
+ });
62
+ }
63
+ const seen = new Set();
64
+ let duplicates = 0;
65
+ for (const record of records) {
66
+ if (!record.key)
67
+ continue;
68
+ const fingerprint = `${record.key}:${stableHash(record.value)}`;
69
+ if (seen.has(fingerprint)) {
70
+ duplicates++;
71
+ issues.push({ file: record.file, line: record.line, reason: 'mirror_duplicate', key: record.key });
72
+ }
73
+ else
74
+ seen.add(fingerprint);
75
+ }
76
+ return { files: files.length, records: records.length, uniqueRecords: records.length - duplicates, duplicates, issues };
77
+ }
78
+ function structuredRecordKey(value) {
79
+ const nested = nestedRecord(value);
80
+ const correlation = firstDefined(value, nested, [
81
+ 'correlationId', 'correlation_id', 'callId', 'call_id', 'toolUseId', 'tool_use_id',
82
+ 'requestId', 'request_id', 'msgId', 'operationId',
83
+ ]);
84
+ if (!correlation)
85
+ return undefined;
86
+ return `${canonicalEventType(value)}:${String(correlation)}`;
87
+ }
88
+ function isLifecycleRecord(value) {
89
+ const type = canonicalEventType(value);
90
+ return type === 'tool_use' || type === 'tool_result';
91
+ }
92
+ function isCorrelatableRecord(value) {
93
+ const nested = nestedRecord(value);
94
+ return isLifecycleRecord(value)
95
+ || firstDefined(value, nested, ['requestId', 'request_id', 'correlationId', 'correlation_id']) !== undefined;
96
+ }
97
+ function stableHash(value) {
98
+ const nested = nestedRecord(value);
99
+ const canonical = {
100
+ type: canonicalEventType(value),
101
+ toolName: firstDefined(value, nested, ['toolName', 'tool', 'name']),
102
+ callId: firstDefined(value, nested, ['callId', 'call_id', 'toolUseId', 'tool_use_id']),
103
+ correlationId: firstDefined(value, nested, ['correlationId', 'correlation_id']),
104
+ requestId: firstDefined(value, nested, ['requestId', 'request_id']),
105
+ sessionId: firstDefined(value, nested, ['sessionId', 'session_id']),
106
+ agentAid: firstDefined(value, nested, ['agentAid', 'agent_aid', 'selfAid']),
107
+ permissionMode: firstDefined(value, nested, ['permissionMode', 'permission_mode']),
108
+ isError: firstDefined(value, nested, ['isError', 'is_error']) ?? (value.ok === false || nested?.ok === false ? true : undefined),
109
+ errorCode: firstDefined(value, nested, ['errorCode', 'error_code']),
110
+ input: firstDefined(value, nested, ['input']),
111
+ result: firstDefined(value, nested, ['result', 'content']),
112
+ error: firstDefined(value, nested, ['error', 'errorMessage']),
113
+ };
114
+ return crypto.createHash('sha256').update(JSON.stringify(canonical)).digest('hex').slice(0, 16);
115
+ }
116
+ function lifecycleField(value, field) {
117
+ const nested = nestedRecord(value);
118
+ if (field === 'sessionId')
119
+ return firstDefined(value, nested, ['sessionId', 'session_id']);
120
+ if (field === 'agentAid')
121
+ return firstDefined(value, nested, ['agentAid', 'agent_aid', 'selfAid']);
122
+ return firstDefined(value, nested, ['permissionMode', 'permission_mode']);
123
+ }
124
+ function nestedRecord(value) {
125
+ return value.event && typeof value.event === 'object' && !Array.isArray(value.event)
126
+ ? value.event
127
+ : undefined;
128
+ }
129
+ function firstDefined(value, nested, fields) {
130
+ for (const field of fields) {
131
+ if (value[field] !== undefined && value[field] !== null)
132
+ return value[field];
133
+ if (nested?.[field] !== undefined && nested[field] !== null)
134
+ return nested[field];
135
+ }
136
+ return undefined;
137
+ }
138
+ function canonicalEventType(value) {
139
+ const nested = nestedRecord(value);
140
+ const raw = value.type ?? nested?.type;
141
+ if (raw === 'tool:use' || raw === 'tool_use')
142
+ return 'tool_use';
143
+ if (raw === 'tool:result' || raw === 'tool_result')
144
+ return 'tool_result';
145
+ return String(raw ?? 'record');
146
+ }
147
+ function hasContextValue(value) {
148
+ return value !== undefined && value !== null && value !== '' && value !== 'unknown';
149
+ }
@@ -7,6 +7,7 @@ export function auditCommandAuthorization(event) {
7
7
  event.decision === 'deny' ||
8
8
  (event.decision === 'allow' && event.dangerous) ||
9
9
  (event.source === 'agent-tool' && event.operation.startsWith('config.')) ||
10
+ event.operation === 'codex.approval' ||
10
11
  event.operation.startsWith('role.') ||
11
12
  event.operation === 'cli.exec.raw';
12
13
  if (!shouldAudit)
@@ -30,11 +31,13 @@ export function auditToolPreflightDenial(input) {
30
31
  decisionSource: 'policy',
31
32
  toolName: input.toolName,
32
33
  policyCode: input.policyCode,
34
+ protectionClass: input.protectionClass ?? protectionClassForPolicy(input.policyCode),
35
+ matchedPath: input.matchedPath ?? extractAuditPath(input.summary),
33
36
  reason: input.reason,
34
37
  argsSummary: input.summary ? { summary: input.summary } : undefined,
35
- sessionId: input.sessionId,
36
- agentAid: input.agentAid,
37
- permissionMode: input.permissionMode,
38
+ sessionId: input.sessionId ?? 'unknown',
39
+ agentAid: input.agentAid ?? 'unknown',
40
+ permissionMode: input.permissionMode ?? 'unknown',
38
41
  channel: input.channel,
39
42
  actorId: input.actorId,
40
43
  role: input.role ?? 'unknown',
@@ -44,6 +47,35 @@ export function auditToolPreflightDenial(input) {
44
47
  taskId: input.taskId,
45
48
  });
46
49
  }
50
+ /**
51
+ * Emit one canonical, structured record for every Codex app-server approval
52
+ * decision. The app-server's request id is the lifecycle key; method and
53
+ * decision source make policy, user, and infrastructure denials distinct.
54
+ */
55
+ export function auditCodexApprovalDecision(input) {
56
+ auditCommandAuthorization({
57
+ ts: Date.now(),
58
+ source: 'agent-tool',
59
+ operation: 'codex.approval',
60
+ scope: input.toolName === 'PermissionGrant' ? 'agent' : 'filesystem',
61
+ dangerous: input.toolName === 'PermissionGrant',
62
+ decision: input.decision,
63
+ decisionSource: input.decisionSource,
64
+ requestId: input.requestId,
65
+ correlationId: input.correlationId ?? input.requestId,
66
+ sessionId: input.sessionId ?? 'unknown',
67
+ agentAid: input.agentAid ?? 'unknown',
68
+ permissionMode: input.permissionMode ?? 'unknown',
69
+ toolName: input.toolName,
70
+ approvalMethod: input.method,
71
+ policyCode: input.policyCode,
72
+ protectionClass: protectionClassForPolicy(input.policyCode),
73
+ reason: input.reason,
74
+ role: input.role ?? 'unknown',
75
+ taskId: input.taskId,
76
+ argsSummary: { method: input.method, executed: false },
77
+ });
78
+ }
47
79
  function buildAuditRecord(event) {
48
80
  return {
49
81
  ts: event.ts,
@@ -53,7 +85,10 @@ function buildAuditRecord(event) {
53
85
  agentAid: redactIdentifier(event.agentAid ?? event.selfAid),
54
86
  permissionMode: event.permissionMode,
55
87
  toolName: event.toolName,
88
+ approvalMethod: event.approvalMethod,
56
89
  policyCode: event.policyCode ?? event.code,
90
+ protectionClass: event.protectionClass,
91
+ matchedPath: event.matchedPath,
57
92
  decisionSource: event.decisionSource,
58
93
  source: event.source,
59
94
  operation: event.operation,
@@ -88,6 +123,9 @@ function redactIdentifier(value) {
88
123
  return crypto.createHash('sha256').update(value).digest('hex').slice(0, 12);
89
124
  }
90
125
  function logAuditEvent(record) {
126
+ // Keep one machine-readable stream independent from daemon text rotation;
127
+ // daily analysis can de-duplicate it against events/channel mirrors.
128
+ writeCommandAuditRecord(record);
91
129
  // A role-management snapshot fans out into several successful read checks.
92
130
  // Keep those available for verbose ECWeb diagnostics without flooding INFO.
93
131
  const routineEcwebRoleRead = record.source === 'ecweb'
@@ -116,6 +154,8 @@ function logAuditEvent(record) {
116
154
  record.permissionMode ? `permissionMode=${record.permissionMode}` : null,
117
155
  record.toolName ? `tool=${record.toolName}` : null,
118
156
  record.policyCode ? `policy=${record.policyCode}` : null,
157
+ record.protectionClass ? `protectionClass=${record.protectionClass}` : null,
158
+ record.matchedPath ? `matchedPath=${JSON.stringify(record.matchedPath)}` : null,
119
159
  record.decisionSource ? `decisionSource=${record.decisionSource}` : null,
120
160
  record.code ? `code=${record.code}` : null,
121
161
  record.matchedRule ? `rule=${record.matchedRule}` : null,
@@ -151,6 +191,21 @@ export function auditRoleMutation(record) {
151
191
  logger.info(`[RoleMutationAudit] ${JSON.stringify(payload)}`);
152
192
  }
153
193
  let roleMutationWriter;
194
+ let commandAuditWriter;
195
+ function writeCommandAuditRecord(record) {
196
+ try {
197
+ const logDir = resolvePaths().logs;
198
+ if (commandAuditWriter?.logDir !== logDir) {
199
+ commandAuditWriter?.writer.close();
200
+ commandAuditWriter = {
201
+ logDir,
202
+ writer: new LogWriter({ baseName: 'command-audit', logDir, rotation: 'daily', retention: { days: 30 } }),
203
+ };
204
+ }
205
+ commandAuditWriter.writer.write(JSON.stringify(record));
206
+ }
207
+ catch { }
208
+ }
154
209
  function writeRoleMutationAudit(payload) {
155
210
  try {
156
211
  const logDir = resolvePaths().logs;
@@ -170,3 +225,18 @@ function writeRoleMutationAudit(payload) {
170
225
  }
171
226
  catch { }
172
227
  }
228
+ function protectionClassForPolicy(policyCode) {
229
+ if (!policyCode)
230
+ return undefined;
231
+ if (/^h_class|^readonly_h_class|h_class_protection/i.test(policyCode))
232
+ return 'H';
233
+ if (/^l_class|^readonly_l_class|l_class_protection/i.test(policyCode))
234
+ return 'L';
235
+ return undefined;
236
+ }
237
+ function extractAuditPath(summary) {
238
+ if (!summary)
239
+ return undefined;
240
+ const match = summary.match(/(?:^|\s)(\/[^\s'"`;]+|[A-Za-z]:[\\/][^\s'"`;]+|(?:src|ecagent|ecweb|scripts|tests?)\/[^\s'"`;]+)/);
241
+ return match?.[1];
242
+ }
@@ -705,7 +705,7 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
705
705
  const args = normalizedContent.slice(5).trim();
706
706
  const permissionDecisionLabels = {
707
707
  allow: '✓ 已授权(本次),继续执行……',
708
- always: '✓ 已授权(同会话同操作 30 分钟),继续执行……',
708
+ always: '✓ 已授权(当前会话临时授权 30 分钟),继续执行……',
709
709
  deny: '✓ 已拒绝',
710
710
  };
711
711
  // Explicit request IDs are globally unique and already bound to an exact
@@ -329,6 +329,7 @@ const CATALOG = [
329
329
  { path: 'sessionId', type: 'string' },
330
330
  { path: 'toolName', type: 'string' },
331
331
  { path: 'isError', type: 'boolean', optional: true },
332
+ { path: 'errorCode', type: 'string', optional: true },
332
333
  { path: 'agentName', type: 'string', optional: true },
333
334
  { path: 'callId', type: 'string', optional: true },
334
335
  { path: 'correlationId', type: 'string', optional: true },