@kiturone/kapibala 0.0.1 → 0.0.2

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.
package/dist/index.js CHANGED
@@ -14,9 +14,15 @@ function isThinkingBlock(block) {
14
14
 
15
15
  // src/errors/index.ts
16
16
  var KapibalaError = class extends Error {
17
- constructor(message) {
17
+ code;
18
+ retryPolicy;
19
+ safeMessage;
20
+ constructor(message, options) {
18
21
  super(message);
19
22
  this.name = "KapibalaError";
23
+ this.code = options?.code ?? "INTERNAL_ERROR";
24
+ this.retryPolicy = options?.retryPolicy ?? "never";
25
+ this.safeMessage = options?.safeMessage ?? "Operation failed";
20
26
  }
21
27
  };
22
28
  var ModelError = class extends KapibalaError {
@@ -39,7 +45,11 @@ var TransportError = class extends KapibalaError {
39
45
  };
40
46
  var ToolError = class extends KapibalaError {
41
47
  constructor(message) {
42
- super(message);
48
+ super(message, {
49
+ code: "TOOL_ERROR",
50
+ retryPolicy: "never",
51
+ safeMessage: "Tool execution failed"
52
+ });
43
53
  this.name = "ToolError";
44
54
  }
45
55
  };
@@ -47,7 +57,11 @@ var ToolNotFound = class extends KapibalaError {
47
57
  toolName;
48
58
  availableTools;
49
59
  constructor(toolName, availableTools = []) {
50
- super(`Tool '${toolName}' not found. Available tools: ${availableTools.join(", ") || "none"}`);
60
+ super(`Tool '${toolName}' not found. Available tools: ${availableTools.join(", ") || "none"}`, {
61
+ code: "TOOL_NOT_FOUND",
62
+ retryPolicy: "never",
63
+ safeMessage: "Requested tool is unavailable"
64
+ });
51
65
  this.name = "ToolNotFound";
52
66
  this.toolName = toolName;
53
67
  this.availableTools = availableTools;
@@ -55,7 +69,11 @@ var ToolNotFound = class extends KapibalaError {
55
69
  };
56
70
  var AbortError = class extends KapibalaError {
57
71
  constructor(message = "Execution aborted by user") {
58
- super(message);
72
+ super(message, {
73
+ code: "ABORTED",
74
+ retryPolicy: "after_user_action",
75
+ safeMessage: "Operation cancelled"
76
+ });
59
77
  this.name = "AbortError";
60
78
  }
61
79
  };
@@ -72,12 +90,1028 @@ var SessionBusyError = class extends KapibalaError {
72
90
  }
73
91
  };
74
92
 
75
- // src/security/sandbox.ts
93
+ // src/logging/index.ts
94
+ import { createHash, randomUUID } from "crypto";
95
+ var SAFE_FIELDS = /* @__PURE__ */ new Set([
96
+ "mode",
97
+ "toolName",
98
+ "durationMs",
99
+ "decision",
100
+ "decisionSource",
101
+ "ruleSource",
102
+ "approvalChoice",
103
+ "status",
104
+ "phase",
105
+ "errorCode",
106
+ "retryPolicy",
107
+ "reason",
108
+ "shell",
109
+ "executableDigest",
110
+ "cwdDigest",
111
+ "commandDigest",
112
+ "commandPreview",
113
+ "commandLength",
114
+ "argumentCount",
115
+ "executionFingerprint",
116
+ "exitCode",
117
+ "outputBytes",
118
+ "outputTruncated",
119
+ "outputFileTruncated",
120
+ "reclaimed",
121
+ "modelId",
122
+ "provider",
123
+ "promptTokens",
124
+ "completionTokens",
125
+ "totalTokens",
126
+ "attempt",
127
+ "count",
128
+ "pathDigest",
129
+ "source",
130
+ "sessionMode",
131
+ "processId"
132
+ ]);
133
+ var SENSITIVE_VALUE = /(?:\bBearer\s+\S+|\b(?:sk|api|key)-[A-Za-z0-9_-]{5,}|(?:api[_-]?key|token|password|secret)\s*[:=]\s*\S+)/i;
134
+ function projectSafeFields(fields) {
135
+ const safe = {};
136
+ for (const [name, value] of Object.entries(fields)) {
137
+ if (!SAFE_FIELDS.has(name)) continue;
138
+ if (value === null || typeof value === "number" || typeof value === "boolean") {
139
+ safe[name] = value;
140
+ } else if (typeof value === "string") {
141
+ const validPreview = name !== "commandPreview" || /^(?:[a-z][a-z0-9-]* \(\d+ args\)|\[redacted\])$/.test(value);
142
+ const validReason = name !== "reason" || /^[a-z][a-z0-9_.-]{0,80}$/i.test(value);
143
+ safe[name] = SENSITIVE_VALUE.test(value) || !validPreview || !validReason || value.includes("\n") || value.includes("\r") ? "[redacted]" : value.slice(0, 512);
144
+ }
145
+ }
146
+ return safe;
147
+ }
148
+ function createLogId() {
149
+ return randomUUID();
150
+ }
151
+ function commandAuditSummary(command) {
152
+ const parts = command.trim().split(/\s+/).filter(Boolean);
153
+ const first = parts[0]?.toLowerCase();
154
+ const category = first && (/* @__PURE__ */ new Set([
155
+ "git",
156
+ "pnpm",
157
+ "npm",
158
+ "node",
159
+ "rg",
160
+ "ls",
161
+ "pwd",
162
+ "cat",
163
+ "get-childitem",
164
+ "get-content"
165
+ ])).has(first) ? first : void 0;
166
+ return {
167
+ commandDigest: createHash("sha256").update(command, "utf8").digest("hex"),
168
+ commandPreview: category ? `${category} (${Math.max(0, parts.length - 1)} args)` : "[redacted]",
169
+ commandLength: Buffer.byteLength(command, "utf8"),
170
+ argumentCount: Math.max(0, parts.length - 1)
171
+ };
172
+ }
173
+ function executionFingerprint(input) {
174
+ return createHash("sha256").update(
175
+ JSON.stringify([
176
+ input.command,
177
+ input.shell,
178
+ input.executable,
179
+ input.cwd,
180
+ input.environmentDigest,
181
+ input.timeoutMs,
182
+ input.outputLimitBytes,
183
+ input.targetDigest ?? null
184
+ ])
185
+ ).digest("hex");
186
+ }
187
+ var StructuredLogger = class {
188
+ operationSink;
189
+ auditSink;
190
+ debug;
191
+ onDiagnostic;
192
+ constructor(options) {
193
+ this.operationSink = options.operationSink;
194
+ this.auditSink = options.auditSink;
195
+ this.debug = options.debug ?? false;
196
+ this.onDiagnostic = options.onDiagnostic;
197
+ }
198
+ /** 普通日志写入失败不阻断业务,也不声称持久化成功;诊断不携带原始异常内容。 */
199
+ async record(event) {
200
+ if (!this.debug && (event.level === "debug" || event.level === "trace")) return;
201
+ try {
202
+ await this.operationSink.write(this.format(event, "operation"));
203
+ } catch {
204
+ try {
205
+ this.onDiagnostic?.("Operation log write failed; the event was not persisted");
206
+ } catch {
207
+ }
208
+ }
209
+ }
210
+ /** 审计必须成功持久化;异常交由执行器拒绝启动工具或报告已执行操作的结果未知。 */
211
+ async recordAudit(event) {
212
+ await this.auditSink.write(this.format(event, "audit"));
213
+ }
214
+ format(input, channel) {
215
+ return {
216
+ schemaVersion: 1,
217
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
218
+ level: input.level,
219
+ channel,
220
+ event: input.event,
221
+ sessionId: input.sessionId,
222
+ ...input.runId === void 0 ? {} : { runId: input.runId },
223
+ ...input.operationId === void 0 ? {} : { operationId: input.operationId },
224
+ ...input.toolUseId === void 0 ? {} : { toolUseId: input.toolUseId },
225
+ fields: projectSafeFields(input.fields ?? {})
226
+ };
227
+ }
228
+ };
229
+
230
+ // src/logging/file-sink.ts
76
231
  import fs from "fs";
232
+ import os from "os";
77
233
  import path from "path";
234
+ import readline from "readline";
235
+ var FileLogSink = class {
236
+ directory;
237
+ prefix;
238
+ maxBytes;
239
+ retentionDays;
240
+ sync;
241
+ constructor(options) {
242
+ this.directory = path.resolve(options.directory);
243
+ this.prefix = options.prefix;
244
+ this.maxBytes = options.maxBytes ?? 10 * 1024 * 1024;
245
+ this.retentionDays = options.prefix === "audit" ? void 0 : options.retentionDays;
246
+ this.sync = options.prefix === "audit" || (options.sync ?? false);
247
+ }
248
+ async write(event) {
249
+ const line = `${JSON.stringify(event)}
250
+ `;
251
+ const bytes = Buffer.byteLength(line, "utf8");
252
+ const date = event.timestamp.slice(0, 10);
253
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error("Invalid log timestamp");
254
+ fs.mkdirSync(this.directory, { recursive: true, mode: 448 });
255
+ const filePath = this.chooseFile(date, bytes);
256
+ const descriptor = fs.openSync(filePath, "a", 384);
257
+ try {
258
+ fs.writeSync(descriptor, line, void 0, "utf8");
259
+ if (this.sync) fs.fsyncSync(descriptor);
260
+ } finally {
261
+ fs.closeSync(descriptor);
262
+ }
263
+ if (this.retentionDays !== void 0) this.cleanupExpired(date);
264
+ }
265
+ /** 返回该通道已占用的字节数,供宿主提示长期保留的审计用量。 */
266
+ getUsageBytes() {
267
+ if (!fs.existsSync(this.directory)) return 0;
268
+ return fs.readdirSync(this.directory).filter((name) => this.isOwnedFile(name)).reduce((total, name) => total + fs.statSync(path.join(this.directory, name)).size, 0);
269
+ }
270
+ chooseFile(date, bytes) {
271
+ const names = fs.readdirSync(this.directory).filter((name) => name.startsWith(`${this.prefix}-${date}-`) && this.isOwnedFile(name)).sort();
272
+ const lastName = names.at(-1);
273
+ if (lastName) {
274
+ const lastPath = path.join(this.directory, lastName);
275
+ if (fs.statSync(lastPath).size + bytes <= this.maxBytes) return lastPath;
276
+ }
277
+ const next = lastName ? Number(lastName.slice(-9, -6)) + 1 : 1;
278
+ return path.join(
279
+ this.directory,
280
+ `${this.prefix}-${date}-${String(next).padStart(3, "0")}.jsonl`
281
+ );
282
+ }
283
+ cleanupExpired(currentDate) {
284
+ const current = Date.parse(`${currentDate}T00:00:00.000Z`);
285
+ const cutoff = current - (this.retentionDays ?? 0) * 864e5;
286
+ for (const name of fs.readdirSync(this.directory)) {
287
+ if (!this.isOwnedFile(name)) continue;
288
+ const fileDate = name.slice(this.prefix.length + 1, this.prefix.length + 11);
289
+ if (Date.parse(`${fileDate}T00:00:00.000Z`) < cutoff) {
290
+ fs.unlinkSync(path.join(this.directory, name));
291
+ }
292
+ }
293
+ }
294
+ isOwnedFile(name) {
295
+ return new RegExp(`^${this.prefix}-\\d{4}-\\d{2}-\\d{2}-\\d{3}\\.jsonl$`).test(name);
296
+ }
297
+ };
298
+ function createDefaultLogSinks(baseDirectory = path.join(os.homedir(), ".kapibala")) {
299
+ return {
300
+ operationSink: new FileLogSink({
301
+ directory: path.join(baseDirectory, "logs"),
302
+ prefix: "operation",
303
+ retentionDays: 30
304
+ }),
305
+ auditSink: new FileLogSink({
306
+ directory: path.join(baseDirectory, "audit"),
307
+ prefix: "audit",
308
+ sync: true
309
+ })
310
+ };
311
+ }
312
+ async function recoverIncompleteAudit(directory) {
313
+ if (!fs.existsSync(directory)) return 0;
314
+ const files = fs.readdirSync(directory).filter((name) => /^audit-\d{4}-\d{2}-\d{2}-\d{3}\.jsonl$/.test(name)).sort();
315
+ const open = /* @__PURE__ */ new Map();
316
+ for (const name of files) {
317
+ const lines = readline.createInterface({
318
+ input: fs.createReadStream(path.join(directory, name), { encoding: "utf8" }),
319
+ crlfDelay: Number.POSITIVE_INFINITY
320
+ });
321
+ for await (const line of lines) {
322
+ if (!line) continue;
323
+ let event;
324
+ try {
325
+ event = JSON.parse(line);
326
+ } catch {
327
+ continue;
328
+ }
329
+ if (!event.operationId || typeof event.operationId !== "string") continue;
330
+ if (event.event === "tool.requested") open.set(event.operationId, event);
331
+ if (event.event === "tool.finished" || event.event === "audit.outcome_unknown") {
332
+ open.delete(event.operationId);
333
+ }
334
+ }
335
+ }
336
+ const sink = new FileLogSink({ directory, prefix: "audit" });
337
+ let recovered = 0;
338
+ for (const event of open.values()) {
339
+ const processId = event.fields?.processId;
340
+ if (typeof processId === "number" && Number.isSafeInteger(processId) && processId > 0) {
341
+ try {
342
+ process.kill(processId, 0);
343
+ continue;
344
+ } catch (error) {
345
+ if (error.code === "EPERM") continue;
346
+ }
347
+ }
348
+ await sink.write({
349
+ schemaVersion: 1,
350
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
351
+ channel: "audit",
352
+ level: "warn",
353
+ event: "audit.outcome_unknown",
354
+ sessionId: event.sessionId,
355
+ runId: event.runId,
356
+ operationId: event.operationId,
357
+ toolUseId: event.toolUseId,
358
+ fields: { status: "unknown_after_restart" }
359
+ });
360
+ recovered++;
361
+ }
362
+ return recovered;
363
+ }
364
+
365
+ // src/instructions/index.ts
366
+ import fs2 from "fs";
367
+ import os2 from "os";
368
+ import path2 from "path";
369
+ var MAX_FILE_BYTES = 32 * 1024;
370
+ var MAX_TOTAL_BYTES = 128 * 1024;
371
+ var MAX_LEVELS = 16;
372
+ function loadInstructions(options) {
373
+ const root = fs2.realpathSync(options.projectRoot);
374
+ const cwd = fs2.realpathSync(options.cwd);
375
+ const relative = path2.relative(root, cwd);
376
+ if (relative === ".." || relative.startsWith(`..${path2.sep}`) || path2.isAbsolute(relative)) {
377
+ throw new Error("Instruction cwd is outside project root");
378
+ }
379
+ const segments = relative ? relative.split(path2.sep) : [];
380
+ if (segments.length + 1 > MAX_LEVELS)
381
+ throw new Error(`Instruction directory chain exceeds ${MAX_LEVELS} levels`);
382
+ const userFile = options.userFile ?? path2.join(os2.homedir(), ".kapibala", "AGENTS.md");
383
+ const candidates = [userFile];
384
+ let directory = root;
385
+ candidates.push(path2.join(directory, "AGENTS.md"));
386
+ for (const segment of segments) {
387
+ directory = path2.join(directory, segment);
388
+ candidates.push(path2.join(directory, "AGENTS.md"));
389
+ }
390
+ const seenPaths = /* @__PURE__ */ new Set();
391
+ const uniqueCandidates = [];
392
+ for (const file of candidates) {
393
+ const resolved = path2.resolve(file);
394
+ const key = process.platform === "win32" ? resolved.toLowerCase() : resolved;
395
+ if (!seenPaths.has(key)) {
396
+ seenPaths.add(key);
397
+ uniqueCandidates.push(file);
398
+ }
399
+ }
400
+ const sources = [];
401
+ let totalBytes = 0;
402
+ for (const file of uniqueCandidates) {
403
+ let stat;
404
+ try {
405
+ stat = fs2.lstatSync(file);
406
+ } catch (error) {
407
+ if (error.code === "ENOENT") continue;
408
+ throw new Error(`Cannot inspect instructions at ${file}`, { cause: error });
409
+ }
410
+ if (stat.isSymbolicLink())
411
+ throw new Error(`Instruction symbolic link is not accepted: ${file}`);
412
+ if (!stat.isFile()) throw new Error(`Instruction source is not a regular file: ${file}`);
413
+ if (stat.size > MAX_FILE_BYTES) throw new Error(`Instruction ${file} exceeds 32 KiB`);
414
+ totalBytes += stat.size;
415
+ if (totalBytes > MAX_TOTAL_BYTES) throw new Error("Instructions exceed 128 KiB total");
416
+ let bytes;
417
+ try {
418
+ bytes = fs2.readFileSync(file);
419
+ } catch (error) {
420
+ throw new Error(`Cannot read instructions at ${file}`, { cause: error });
421
+ }
422
+ if (bytes.byteLength > MAX_FILE_BYTES) throw new Error(`Instruction ${file} exceeds 32 KiB`);
423
+ totalBytes += bytes.byteLength - stat.size;
424
+ if (totalBytes > MAX_TOTAL_BYTES) throw new Error("Instructions exceed 128 KiB total");
425
+ let content;
426
+ try {
427
+ content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
428
+ } catch (error) {
429
+ throw new Error(`Instruction ${file} is not valid UTF-8`, { cause: error });
430
+ }
431
+ sources.push({ path: file, content });
432
+ }
433
+ return { sources, totalBytes };
434
+ }
435
+
436
+ // src/shell/detect.ts
437
+ import { spawnSync } from "child_process";
438
+ import { createHash as createHash2 } from "crypto";
439
+ import fs3 from "fs";
440
+ import os3 from "os";
441
+ import path3 from "path";
442
+ function classifyBashOutput(unameOutput, platform) {
443
+ const uname = unameOutput.trim().split(/\r?\n/)[0]?.toLowerCase() ?? "";
444
+ if (platform === "win32") {
445
+ if (uname.startsWith("mingw") || uname.startsWith("msys") || uname.startsWith("cygwin")) {
446
+ return "bash";
447
+ }
448
+ return uname === "linux" ? "wsl" : void 0;
449
+ }
450
+ return uname ? "bash" : void 0;
451
+ }
452
+ function findExecutables(name, pathEnv, platform) {
453
+ const extension = platform === "win32" && !name.toLowerCase().endsWith(".exe") ? ".exe" : "";
454
+ const found = [];
455
+ const seen = /* @__PURE__ */ new Set();
456
+ for (const directory of pathEnv.split(path3.delimiter)) {
457
+ if (!directory) continue;
458
+ const candidate = path3.join(directory, name + extension);
459
+ try {
460
+ if (!fs3.statSync(candidate).isFile()) continue;
461
+ const real = fs3.realpathSync(candidate);
462
+ const key = platform === "win32" ? real.toLowerCase() : real;
463
+ if (seen.has(key)) continue;
464
+ seen.add(key);
465
+ found.push(real);
466
+ } catch {
467
+ }
468
+ }
469
+ return found;
470
+ }
471
+ function probeBash(executable, cwd, platform) {
472
+ const result = spawnSync(executable, ["--noprofile", "--norc", "-c", "uname -s; pwd"], {
473
+ cwd,
474
+ encoding: "utf8",
475
+ timeout: 15e3,
476
+ windowsHide: true
477
+ });
478
+ if (result.status !== 0 || !result.stdout?.trim()) return { ok: false };
479
+ return { ok: true, kind: classifyBashOutput(result.stdout, platform) };
480
+ }
481
+ function probePowerShell(executable, cwd) {
482
+ const result = spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", "$PWD.Path"], {
483
+ cwd,
484
+ encoding: "utf8",
485
+ timeout: 15e3,
486
+ windowsHide: true
487
+ });
488
+ return result.status === 0 && Boolean(result.stdout?.trim());
489
+ }
490
+ function buildEnvironment(executable, kind, cwd) {
491
+ const stat = fs3.statSync(executable);
492
+ return {
493
+ kind,
494
+ executable,
495
+ cwd,
496
+ executableDigest: createHash2("sha256").update(JSON.stringify([executable, stat.size, stat.mtimeMs])).digest("hex")
497
+ };
498
+ }
499
+ function candidatesFor(kind, platform, pathEnv) {
500
+ if (kind === "bash" || kind === "wsl") {
501
+ return findExecutables("bash", pathEnv, platform);
502
+ }
503
+ if (platform !== "win32" && kind === "powershell") return [];
504
+ return findExecutables(kind, pathEnv, platform);
505
+ }
506
+ function tryKind(kind, platform, pathEnv, cwd) {
507
+ for (const executable of candidatesFor(kind, platform, pathEnv)) {
508
+ try {
509
+ if (kind === "bash" || kind === "wsl") {
510
+ const probe = probeBash(executable, cwd, platform);
511
+ if (probe.ok && probe.kind === kind) return buildEnvironment(executable, kind, cwd);
512
+ } else if (probePowerShell(executable, cwd)) {
513
+ return buildEnvironment(executable, kind, cwd);
514
+ }
515
+ } catch {
516
+ }
517
+ }
518
+ return void 0;
519
+ }
520
+ function detectExplicitPath(preference, cwd, platform) {
521
+ const executable = fs3.realpathSync(preference);
522
+ if (!fs3.statSync(executable).isFile()) {
523
+ throw new Error(`Requested interpreter path is not a file: ${preference}`);
524
+ }
525
+ const base = path3.basename(executable).toLowerCase();
526
+ if (/^pwsh(\.exe)?$/.test(base) || /^powershell(_ise)?(\.exe)?$/.test(base)) {
527
+ const kind = base.startsWith("pwsh") ? "pwsh" : "powershell";
528
+ if (platform !== "win32" && kind === "powershell") {
529
+ throw new Error(`Requested ${kind} interpreter is unavailable on this platform`);
530
+ }
531
+ if (!probePowerShell(executable, cwd)) {
532
+ throw new Error(
533
+ `Requested ${kind} interpreter is unavailable or incompatible with this working directory`
534
+ );
535
+ }
536
+ return buildEnvironment(executable, kind, cwd);
537
+ }
538
+ const probe = probeBash(executable, cwd, platform);
539
+ if (!probe.ok || !probe.kind) {
540
+ throw new Error(
541
+ `Requested interpreter is unavailable or incompatible with this working directory: ${preference}`
542
+ );
543
+ }
544
+ return buildEnvironment(executable, probe.kind, cwd);
545
+ }
546
+ function detectShell(options) {
547
+ const platform = options.platform ?? os3.platform();
548
+ const cwd = fs3.realpathSync(options.cwd);
549
+ const pathEnv = options.pathEnv ?? process.env.PATH ?? "";
550
+ const preference = options.preference ?? "auto";
551
+ if (/[\\/]/.test(preference) || preference.toLowerCase().endsWith(".exe")) {
552
+ return detectExplicitPath(preference, cwd, platform);
553
+ }
554
+ const kinds = preference === "auto" ? platform === "win32" ? ["bash", "wsl", "pwsh", "powershell"] : ["bash"] : [preference];
555
+ for (const kind of kinds) {
556
+ const environment = tryKind(kind, platform, pathEnv, cwd);
557
+ if (environment) return environment;
558
+ }
559
+ throw new Error(
560
+ preference === "auto" ? "No usable Bash or PowerShell interpreter found" : `Requested ${preference} interpreter is unavailable or incompatible with this working directory`
561
+ );
562
+ }
563
+
564
+ // src/shell/tool.ts
565
+ import { spawn, spawnSync as spawnSync2 } from "child_process";
566
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
567
+ import fs4 from "fs";
568
+ import path4 from "path";
569
+ import { StringDecoder } from "string_decoder";
570
+
571
+ // src/tools/index.ts
572
+ function defineTool(tool) {
573
+ return tool;
574
+ }
575
+ function toToolDefinition(tool) {
576
+ return {
577
+ name: tool.name,
578
+ description: tool.description,
579
+ parameters: tool.parameters
580
+ };
581
+ }
582
+
583
+ // src/shell/tool.ts
584
+ var INLINE_LIMIT = 64 * 1024;
585
+ var FILE_LIMIT = 16 * 1024 * 1024;
586
+ var DEFAULT_TIMEOUT = 12e4;
587
+ var MAX_TIMEOUT = 6e5;
588
+ var MAX_COMMAND_BYTES = 8 * 1024;
589
+ var OWNED_RESULT = /^run-[a-f0-9-]{36}\.txt$/;
590
+ var RESULT_MANIFEST = "managed-results.jsonl";
591
+ function limitedEnvironment(kind) {
592
+ const allowed = [
593
+ "PATH",
594
+ "Path",
595
+ "SystemRoot",
596
+ "ComSpec",
597
+ "TEMP",
598
+ "TMP",
599
+ "HOME",
600
+ "USERPROFILE",
601
+ "APPDATA",
602
+ "LOCALAPPDATA",
603
+ "LANG",
604
+ "LC_ALL",
605
+ "TERM"
606
+ ];
607
+ const environment = Object.fromEntries(
608
+ allowed.filter((key) => process.env[key] !== void 0).map((key) => [key, process.env[key]])
609
+ );
610
+ if (kind === "wsl") environment.WSL_UTF8 = "1";
611
+ return environment;
612
+ }
613
+ function utf8Prefix(bytes, limit) {
614
+ let end = Math.min(bytes.length, limit);
615
+ let start = end - 1;
616
+ while (start >= 0 && (bytes[start] & 192) === 128) start--;
617
+ if (start >= 0) {
618
+ const lead = bytes[start];
619
+ const length = lead >= 240 ? 4 : lead >= 224 ? 3 : lead >= 192 ? 2 : 1;
620
+ if (start + length > end) end = start;
621
+ }
622
+ return bytes.subarray(0, end);
623
+ }
624
+ function effectiveCwd(input, rootDir) {
625
+ const requested = input.cwd ? path4.resolve(rootDir, input.cwd) : rootDir;
626
+ const real = fs4.realpathSync(requested);
627
+ if (!fs4.statSync(real).isDirectory())
628
+ throw new Error("Command working directory is not a directory");
629
+ return real;
630
+ }
631
+ function effectiveTimeout(input) {
632
+ const timeout = input.timeout_ms ?? DEFAULT_TIMEOUT;
633
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > MAX_TIMEOUT) {
634
+ throw new Error(`Command timeout must be 1-${MAX_TIMEOUT} ms`);
635
+ }
636
+ return timeout;
637
+ }
638
+ function validateCommand(input) {
639
+ if (typeof input.command !== "string" || input.command.trim().length === 0 || Buffer.byteLength(input.command, "utf8") > MAX_COMMAND_BYTES) {
640
+ throw new Error(`Command must be nonempty and at most ${MAX_COMMAND_BYTES} bytes`);
641
+ }
642
+ }
643
+ function directScriptDigest(command, kind, cwd) {
644
+ const bashLike = kind === "bash" || kind === "wsl";
645
+ if (bashLike && command.includes("\\")) return void 0;
646
+ const normalized = command.trim().replaceAll("\\", "/");
647
+ const match = bashLike ? /^(?:bash )?\.\/([A-Za-z0-9_./-]+\.sh)$/.exec(normalized) : /^\.\/([A-Za-z0-9_./-]+\.ps1)$/i.exec(normalized);
648
+ if (!match) return void 0;
649
+ try {
650
+ const script = fs4.realpathSync(path4.resolve(cwd, match[1]));
651
+ const relative = path4.relative(cwd, script);
652
+ if (relative.startsWith("..") || path4.isAbsolute(relative)) return void 0;
653
+ const stat = fs4.statSync(script);
654
+ if (!stat.isFile() || stat.size > 1024 * 1024) return void 0;
655
+ return createHash3("sha256").update(script).update("\0").update(fs4.readFileSync(script)).digest("hex");
656
+ } catch {
657
+ return void 0;
658
+ }
659
+ }
660
+ function interpreterArgs(environment, command) {
661
+ if (environment.kind === "bash" || environment.kind === "wsl") {
662
+ return ["--noprofile", "--norc", "-c", command];
663
+ }
664
+ const utf8 = "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [Console]::OutputEncoding; ";
665
+ return ["-NoProfile", "-NonInteractive", "-Command", utf8 + command];
666
+ }
667
+ function getTaskkillPath() {
668
+ if (process.platform !== "win32") return "taskkill";
669
+ const systemRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
670
+ const candidate = path4.join(systemRoot, "System32", "taskkill.exe");
671
+ try {
672
+ if (fs4.existsSync(candidate)) return candidate;
673
+ } catch {
674
+ }
675
+ return "taskkill.exe";
676
+ }
677
+ function createNativeBashGroup(environment) {
678
+ if (process.platform !== "win32" || environment.kind !== "bash") return void 0;
679
+ let resolveReady = () => {
680
+ };
681
+ let pending = "";
682
+ let accepted = false;
683
+ const group = {
684
+ environment,
685
+ token: randomUUID2(),
686
+ ready: new Promise((resolve) => {
687
+ resolveReady = resolve;
688
+ }),
689
+ accept(text) {
690
+ if (accepted) return text;
691
+ pending += text;
692
+ const newline = pending.indexOf("\n");
693
+ if (newline < 0) return "";
694
+ accepted = true;
695
+ const prefix = `${group.token}:`;
696
+ const line = pending.slice(0, newline).trimEnd();
697
+ const pid = line.startsWith(prefix) ? Number(line.slice(prefix.length)) : Number.NaN;
698
+ if (Number.isSafeInteger(pid) && pid > 0) group.pid = pid;
699
+ resolveReady();
700
+ return group.pid ? pending.slice(newline + 1) : pending;
701
+ }
702
+ };
703
+ return group;
704
+ }
705
+ function signalNativeBashGroup(group, signal) {
706
+ if (!group.pid) return false;
707
+ const result = spawnSync2(
708
+ group.environment.executable,
709
+ ["--noprofile", "--norc", "-c", `kill -${signal} -- -${group.pid}`],
710
+ { windowsHide: true, timeout: 3e3, env: limitedEnvironment("bash") }
711
+ );
712
+ return result.status === 0;
713
+ }
714
+ function nativeBashGroupStopped(group) {
715
+ if (!group.pid) return false;
716
+ const result = spawnSync2(group.environment.executable, ["--noprofile", "--norc", "-c", "ps -l"], {
717
+ windowsHide: true,
718
+ timeout: 3e3,
719
+ encoding: "utf8",
720
+ env: limitedEnvironment("bash")
721
+ });
722
+ if (result.status !== 0 || !result.stdout.includes("PGID")) return false;
723
+ return !result.stdout.split("\n").some((line) => {
724
+ const columns = line.trim().split(/\s+/);
725
+ return /^\d+$/.test(columns[0] ?? "") && Number(columns[2]) === group.pid;
726
+ });
727
+ }
728
+ function stopProcessTree(child, nativeGroup) {
729
+ if (!child.pid) return false;
730
+ if (process.platform === "win32") {
731
+ const groupStopped = !nativeGroup || signalNativeBashGroup(nativeGroup, "TERM");
732
+ const result = spawnSync2(getTaskkillPath(), ["/PID", String(child.pid), "/T", "/F"], {
733
+ windowsHide: true,
734
+ timeout: 3e3
735
+ });
736
+ return groupStopped && (result.status === 0 || Boolean(nativeGroup));
737
+ }
738
+ try {
739
+ process.kill(-child.pid, "SIGTERM");
740
+ return true;
741
+ } catch {
742
+ try {
743
+ child.kill("SIGTERM");
744
+ } catch {
745
+ }
746
+ return false;
747
+ }
748
+ }
749
+ function forceProcessTree(child, nativeGroup) {
750
+ if (!child.pid) return false;
751
+ if (process.platform === "win32") {
752
+ const groupStopped = !nativeGroup || signalNativeBashGroup(nativeGroup, "KILL");
753
+ const result = spawnSync2(getTaskkillPath(), ["/PID", String(child.pid), "/T", "/F"], {
754
+ windowsHide: true,
755
+ timeout: 3e3
756
+ });
757
+ return groupStopped && (result.status === 0 || Boolean(nativeGroup));
758
+ }
759
+ try {
760
+ process.kill(-child.pid, "SIGKILL");
761
+ return true;
762
+ } catch {
763
+ try {
764
+ child.kill("SIGKILL");
765
+ } catch {
766
+ }
767
+ return false;
768
+ }
769
+ }
770
+ async function confirmProcessTreeStopped(child, terminationSucceeded, nativeGroup) {
771
+ if (!terminationSucceeded || !child.pid) return false;
772
+ if (nativeGroup) {
773
+ const deadline2 = Date.now() + 1e3;
774
+ while (Date.now() < deadline2) {
775
+ if (nativeBashGroupStopped(nativeGroup)) return true;
776
+ await new Promise((resolve) => setTimeout(resolve, 50));
777
+ }
778
+ return false;
779
+ }
780
+ if (process.platform === "win32") return true;
781
+ const deadline = Date.now() + 1e3;
782
+ while (Date.now() < deadline) {
783
+ try {
784
+ process.kill(-child.pid, 0);
785
+ } catch (error) {
786
+ if (error.code === "ESRCH") return true;
787
+ return false;
788
+ }
789
+ await new Promise((resolve) => setTimeout(resolve, 50));
790
+ }
791
+ return false;
792
+ }
793
+ function waitForClose(promise, milliseconds) {
794
+ return new Promise((resolve) => {
795
+ const timer = setTimeout(() => resolve(false), milliseconds);
796
+ void promise.then(
797
+ () => {
798
+ clearTimeout(timer);
799
+ resolve(true);
800
+ },
801
+ () => {
802
+ clearTimeout(timer);
803
+ resolve(true);
804
+ }
805
+ );
806
+ });
807
+ }
808
+ function resultDirectory(rootDir) {
809
+ return path4.join(rootDir, ".kapibala", "tool-results");
810
+ }
811
+ function cleanupCommandResults(rootDir, now = Date.now()) {
812
+ const directory = resultDirectory(rootDir);
813
+ if (!fs4.existsSync(directory)) return;
814
+ const manifest = path4.join(directory, RESULT_MANIFEST);
815
+ if (!fs4.existsSync(manifest) || !fs4.lstatSync(manifest).isFile()) return;
816
+ const cutoff = now - 7 * 864e5;
817
+ const retained = [];
818
+ for (const line of fs4.readFileSync(manifest, "utf8").split("\n")) {
819
+ if (!line) continue;
820
+ let record;
821
+ try {
822
+ record = JSON.parse(line);
823
+ } catch {
824
+ continue;
825
+ }
826
+ if (!OWNED_RESULT.test(record.name)) continue;
827
+ const file = path4.join(directory, record.name);
828
+ if (!fs4.existsSync(file)) continue;
829
+ const stat = fs4.lstatSync(file);
830
+ if (!stat.isFile() || stat.dev !== record.dev || stat.ino !== record.ino) continue;
831
+ if (stat.mtimeMs < cutoff) fs4.unlinkSync(file);
832
+ else retained.push(line);
833
+ }
834
+ const replacement = path4.join(directory, `.managed-results-${randomUUID2()}.tmp`);
835
+ fs4.writeFileSync(replacement, retained.length ? `${retained.join("\n")}
836
+ ` : "", { mode: 384 });
837
+ fs4.renameSync(replacement, manifest);
838
+ }
839
+ function recordManagedResult(directory, file, descriptor) {
840
+ const stat = fs4.fstatSync(descriptor);
841
+ fs4.appendFileSync(
842
+ path4.join(directory, RESULT_MANIFEST),
843
+ `${JSON.stringify({ name: path4.basename(file), dev: stat.dev, ino: stat.ino })}
844
+ `,
845
+ { mode: 384 }
846
+ );
847
+ }
848
+ function createRunCommandTool(environment) {
849
+ return defineTool({
850
+ name: "run_command",
851
+ description: `Run a ${environment.kind === "wsl" ? "bash (WSL)" : environment.kind} command as the current OS user. The file tool PathSandbox does not isolate this process or its network access.`,
852
+ metadata: {
853
+ source: "builtin",
854
+ permissions: ["fs:read", "fs:write", "exec", "net:outbound", "env:read"],
855
+ dangerous: true,
856
+ managesTimeout: true
857
+ },
858
+ parameters: {
859
+ type: "object",
860
+ properties: {
861
+ command: {
862
+ type: "string",
863
+ description: `Command using ${environment.kind === "wsl" ? "bash" : environment.kind} syntax.`
864
+ },
865
+ cwd: { type: "string", description: "Working directory; defaults to workspace root." },
866
+ timeout_ms: { type: "integer", description: "Timeout in milliseconds, maximum 600000." }
867
+ },
868
+ required: ["command"]
869
+ },
870
+ approvalScope(input, rootDir) {
871
+ validateCommand(input);
872
+ const cwd = effectiveCwd(input, rootDir);
873
+ const timeoutMs = effectiveTimeout(input);
874
+ const scriptDigest = directScriptDigest(input.command, environment.kind, cwd);
875
+ const stat = fs4.statSync(environment.executable);
876
+ const executableDigest = createHash3("sha256").update(JSON.stringify([environment.executable, stat.size, stat.mtimeMs])).digest("hex");
877
+ const environmentDigest = createHash3("sha256").update(JSON.stringify(limitedEnvironment(environment.kind))).digest("hex");
878
+ return {
879
+ command: input.command,
880
+ interpreter: environment.kind,
881
+ executablePath: environment.executable,
882
+ cwd,
883
+ executableDigest,
884
+ ...scriptDigest ? { scriptDigest } : {},
885
+ executionFingerprint: executionFingerprint({
886
+ command: input.command,
887
+ shell: environment.kind,
888
+ executable: executableDigest,
889
+ cwd,
890
+ environmentDigest,
891
+ timeoutMs,
892
+ outputLimitBytes: FILE_LIMIT,
893
+ targetDigest: scriptDigest
894
+ })
895
+ };
896
+ },
897
+ async execute(input, ctx) {
898
+ const startedAt = Date.now();
899
+ validateCommand(input);
900
+ if (ctx.signal?.aborted) {
901
+ throw new KapibalaError("Command aborted before start", {
902
+ code: "ABORTED",
903
+ retryPolicy: "after_user_action",
904
+ safeMessage: "Command was not started because the run was aborted"
905
+ });
906
+ }
907
+ const cwd = effectiveCwd(input, ctx.rootDir);
908
+ const timeoutMs = effectiveTimeout(input);
909
+ cleanupCommandResults(ctx.rootDir);
910
+ const nativeGroup = createNativeBashGroup(environment);
911
+ const command = nativeGroup ? `printf '${nativeGroup.token}:%s\\n' "$$" >&2
912
+ ${input.command}` : input.command;
913
+ const child = spawn(environment.executable, interpreterArgs(environment, command), {
914
+ cwd,
915
+ env: limitedEnvironment(environment.kind),
916
+ windowsHide: true,
917
+ detached: process.platform !== "win32",
918
+ stdio: ["ignore", "pipe", "pipe"]
919
+ });
920
+ const inline = [];
921
+ let inlineBytes = 0;
922
+ let outputBytes = 0;
923
+ let fileBytes = 0;
924
+ let outputFile;
925
+ let outputFileTruncated = false;
926
+ let outputFailure = false;
927
+ let lastProgressAt = startedAt;
928
+ let descriptor;
929
+ const collect = (chunk) => {
930
+ outputBytes += chunk.length;
931
+ if (outputFailure) return;
932
+ if (inlineBytes < INLINE_LIMIT) {
933
+ const part = chunk.subarray(0, INLINE_LIMIT - inlineBytes);
934
+ inline.push(part);
935
+ inlineBytes += part.length;
936
+ }
937
+ if (outputBytes > INLINE_LIMIT && descriptor === void 0) {
938
+ const directory = resultDirectory(ctx.rootDir);
939
+ fs4.mkdirSync(directory, { recursive: true, mode: 448 });
940
+ outputFile = path4.join(directory, `run-${randomUUID2()}.txt`);
941
+ descriptor = fs4.openSync(outputFile, "wx", 384);
942
+ recordManagedResult(directory, outputFile, descriptor);
943
+ const prefix = Buffer.concat(inline);
944
+ fs4.writeSync(descriptor, prefix);
945
+ fileBytes = prefix.length;
946
+ const remaining = chunk.subarray(
947
+ Math.max(0, prefix.length - (outputBytes - chunk.length))
948
+ );
949
+ const writable = utf8Prefix(remaining, FILE_LIMIT - fileBytes);
950
+ if (writable.length > 0) fs4.writeSync(descriptor, writable);
951
+ fileBytes += writable.length;
952
+ if (writable.length < remaining.length) outputFileTruncated = true;
953
+ } else if (descriptor !== void 0 && !outputFileTruncated) {
954
+ const writable = utf8Prefix(chunk, FILE_LIMIT - fileBytes);
955
+ if (writable.length > 0) fs4.writeSync(descriptor, writable);
956
+ fileBytes += writable.length;
957
+ if (writable.length < chunk.length) outputFileTruncated = true;
958
+ } else if (descriptor !== void 0) {
959
+ outputFileTruncated = true;
960
+ }
961
+ };
962
+ const onData = (chunk) => {
963
+ try {
964
+ collect(chunk);
965
+ } catch {
966
+ outputFailure = true;
967
+ if (descriptor !== void 0) {
968
+ try {
969
+ fs4.closeSync(descriptor);
970
+ } catch {
971
+ }
972
+ descriptor = void 0;
973
+ }
974
+ }
975
+ const now = Date.now();
976
+ if (now - lastProgressAt >= 250) {
977
+ lastProgressAt = now;
978
+ ctx.onProgress?.({ elapsedMs: now - startedAt, outputBytes });
979
+ }
980
+ };
981
+ for (const stream of [child.stdout, child.stderr]) {
982
+ const decoder = new StringDecoder("utf8");
983
+ stream?.on("data", (chunk) => {
984
+ let text = decoder.write(chunk);
985
+ if (stream === child.stderr && nativeGroup) text = nativeGroup.accept(text);
986
+ if (text) onData(Buffer.from(text, "utf8"));
987
+ });
988
+ stream?.once("end", () => {
989
+ const text = decoder.end();
990
+ if (text) onData(Buffer.from(text, "utf8"));
991
+ });
992
+ }
993
+ const close = new Promise((resolve, reject) => {
994
+ child.once("error", reject);
995
+ child.once("close", (code) => resolve({ code }));
996
+ });
997
+ let closed = false;
998
+ void close.finally(() => {
999
+ closed = true;
1000
+ }).catch(() => {
1001
+ });
1002
+ try {
1003
+ await ctx.onProcessSpawned?.();
1004
+ } catch {
1005
+ if (nativeGroup) await waitForClose(nativeGroup.ready, 1e3);
1006
+ stopProcessTree(child, nativeGroup);
1007
+ if (!await waitForClose(close, 2e3)) {
1008
+ forceProcessTree(child, nativeGroup);
1009
+ await waitForClose(close, 3e3);
1010
+ }
1011
+ if (descriptor !== void 0) fs4.closeSync(descriptor);
1012
+ throw new KapibalaError("Process started but spawn audit failed", {
1013
+ code: "OUTCOME_UNKNOWN",
1014
+ retryPolicy: "after_user_action",
1015
+ safeMessage: "Command started but audit failed; outcome is unknown"
1016
+ });
1017
+ }
1018
+ let stopReason;
1019
+ let stopPromise;
1020
+ const stop = (reason) => {
1021
+ if (stopPromise) return;
1022
+ stopReason = reason;
1023
+ stopPromise = (async () => {
1024
+ if (nativeGroup) await waitForClose(nativeGroup.ready, 1e3);
1025
+ const stopped = stopProcessTree(child, nativeGroup);
1026
+ if (await waitForClose(close, 2e3)) {
1027
+ if (await confirmProcessTreeStopped(child, stopped, nativeGroup)) return true;
1028
+ }
1029
+ const forced = forceProcessTree(child, nativeGroup);
1030
+ if (!await waitForClose(close, 3e3)) return false;
1031
+ return confirmProcessTreeStopped(child, forced, nativeGroup);
1032
+ })();
1033
+ };
1034
+ const timer = setTimeout(() => stop("timeout"), timeoutMs);
1035
+ const progressTimer = setInterval(() => {
1036
+ const now = Date.now();
1037
+ if (now - lastProgressAt >= 500) {
1038
+ lastProgressAt = now;
1039
+ ctx.onProgress?.({ elapsedMs: now - startedAt, outputBytes });
1040
+ }
1041
+ }, 500);
1042
+ const onAbort = () => stop("aborted");
1043
+ if (ctx.signal?.aborted) onAbort();
1044
+ else ctx.signal?.addEventListener("abort", onAbort, { once: true });
1045
+ try {
1046
+ const outcome = await Promise.race([
1047
+ close.then((value) => ({ kind: "closed", value })),
1048
+ new Promise((resolve) => {
1049
+ const check = () => {
1050
+ if (closed) return;
1051
+ if (stopPromise)
1052
+ void stopPromise.then((confirmed) => {
1053
+ if (!confirmed) resolve({ kind: "unknown" });
1054
+ });
1055
+ else setTimeout(check, 10);
1056
+ };
1057
+ check();
1058
+ })
1059
+ ]);
1060
+ if (outcome.kind === "unknown") {
1061
+ throw new KapibalaError("Command process tree could not be confirmed stopped", {
1062
+ code: "OUTCOME_UNKNOWN",
1063
+ retryPolicy: "after_user_action",
1064
+ safeMessage: "Command outcome is unknown; inspect the system before retrying"
1065
+ });
1066
+ }
1067
+ if (stopReason) {
1068
+ if (!await stopPromise) {
1069
+ throw new KapibalaError("Command process tree could not be confirmed stopped", {
1070
+ code: "OUTCOME_UNKNOWN",
1071
+ retryPolicy: "after_user_action",
1072
+ safeMessage: "Command outcome is unknown; inspect the system before retrying"
1073
+ });
1074
+ }
1075
+ throw new KapibalaError(`Command ${stopReason}`, {
1076
+ code: stopReason === "timeout" ? "COMMAND_TIMEOUT" : "ABORTED",
1077
+ retryPolicy: "after_user_action",
1078
+ safeMessage: `Command ${stopReason}; process exit was observed`
1079
+ });
1080
+ }
1081
+ if (outputFailure) {
1082
+ throw new KapibalaError("Command output storage failed", {
1083
+ code: "OUTPUT_STORAGE_FAILED",
1084
+ retryPolicy: "after_user_action",
1085
+ safeMessage: "Command finished but its output could not be stored"
1086
+ });
1087
+ }
1088
+ const inlineBuffer = Buffer.concat(inline);
1089
+ const output = utf8Prefix(inlineBuffer, INLINE_LIMIT).toString("utf8");
1090
+ return {
1091
+ exitCode: outcome.value.code,
1092
+ output,
1093
+ outputTruncated: outputBytes > INLINE_LIMIT,
1094
+ ...outputFile ? { outputFile } : {},
1095
+ outputFileTruncated,
1096
+ outputBytes,
1097
+ shell: environment.kind
1098
+ };
1099
+ } finally {
1100
+ clearTimeout(timer);
1101
+ clearInterval(progressTimer);
1102
+ ctx.signal?.removeEventListener("abort", onAbort);
1103
+ if (descriptor !== void 0) fs4.closeSync(descriptor);
1104
+ }
1105
+ }
1106
+ });
1107
+ }
1108
+
1109
+ // src/security/sandbox.ts
1110
+ import fs5 from "fs";
1111
+ import path5 from "path";
78
1112
  function tryRealpath(target) {
79
1113
  try {
80
- return fs.realpathSync(target);
1114
+ return fs5.realpathSync(target);
81
1115
  } catch {
82
1116
  return void 0;
83
1117
  }
@@ -87,7 +1121,7 @@ var PathSandbox = class {
87
1121
  allowSymlinks;
88
1122
  cachedRealRootDir;
89
1123
  constructor(config) {
90
- this.rootDir = path.resolve(config.rootDir);
1124
+ this.rootDir = path5.resolve(config.rootDir);
91
1125
  this.allowSymlinks = config.allowSymlinks ?? false;
92
1126
  }
93
1127
  /**
@@ -110,16 +1144,16 @@ var PathSandbox = class {
110
1144
  if (!targetPath || typeof targetPath !== "string") {
111
1145
  throw new ToolError("Invalid path: path must be a non-empty string");
112
1146
  }
113
- const resolvedPath = path.isAbsolute(targetPath) ? path.resolve(targetPath) : path.resolve(this.rootDir, targetPath);
114
- const relative = path.relative(this.rootDir, resolvedPath);
115
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
1147
+ const resolvedPath = path5.isAbsolute(targetPath) ? path5.resolve(targetPath) : path5.resolve(this.rootDir, targetPath);
1148
+ const relative = path5.relative(this.rootDir, resolvedPath);
1149
+ if (relative.startsWith("..") || path5.isAbsolute(relative)) {
116
1150
  throw new ToolError(
117
1151
  `Access denied: path '${targetPath}' escapes root directory '${this.rootDir}'`
118
1152
  );
119
1153
  }
120
1154
  const physicalPath = this.toPhysicalPath(resolvedPath);
121
- const physicalRelative = path.relative(this.realRootDir, physicalPath);
122
- if (physicalRelative.startsWith("..") || path.isAbsolute(physicalRelative)) {
1155
+ const physicalRelative = path5.relative(this.realRootDir, physicalPath);
1156
+ if (physicalRelative.startsWith("..") || path5.isAbsolute(physicalRelative)) {
123
1157
  throw new ToolError(
124
1158
  `Access denied: path '${targetPath}' resolves outside root directory '${this.rootDir}' (via symlink)`
125
1159
  );
@@ -141,7 +1175,7 @@ var PathSandbox = class {
141
1175
  while (true) {
142
1176
  let stat;
143
1177
  try {
144
- stat = fs.lstatSync(probe);
1178
+ stat = fs5.lstatSync(probe);
145
1179
  } catch {
146
1180
  stat = void 0;
147
1181
  }
@@ -152,30 +1186,146 @@ var PathSandbox = class {
152
1186
  `Access denied: cannot resolve real path for '${resolvedPath}' (broken or unresolvable symlink)`
153
1187
  );
154
1188
  }
155
- return pendingSegments.length > 0 ? path.join(real, ...pendingSegments) : real;
1189
+ return pendingSegments.length > 0 ? path5.join(real, ...pendingSegments) : real;
156
1190
  }
157
- const parent = path.dirname(probe);
1191
+ const parent = path5.dirname(probe);
158
1192
  if (parent === probe) {
159
1193
  return resolvedPath;
160
1194
  }
161
- pendingSegments.unshift(path.basename(probe));
1195
+ pendingSegments.unshift(path5.basename(probe));
162
1196
  probe = parent;
163
1197
  }
164
1198
  }
165
1199
  };
166
1200
 
167
- // src/tools/index.ts
168
- function defineTool(tool) {
169
- return tool;
1201
+ // src/security/permissions.ts
1202
+ import path6 from "path";
1203
+ var CAPABILITIES = /* @__PURE__ */ new Set([
1204
+ "fs:read",
1205
+ "fs:write",
1206
+ "exec",
1207
+ "net:outbound",
1208
+ "env:read",
1209
+ "agent:spawn"
1210
+ ]);
1211
+ function validatePermissionRules(rules) {
1212
+ for (const [index, rule] of rules.entries()) {
1213
+ const ruleNumber = index + 1;
1214
+ if (!["allow", "ask", "deny"].includes(rule.action)) {
1215
+ throw new Error(`Permission rule ${ruleNumber}: invalid action`);
1216
+ }
1217
+ const scopes = Number(rule.tool !== void 0) + Number(rule.capability !== void 0) + Number(rule.shell !== void 0);
1218
+ if (scopes !== 1)
1219
+ throw new Error(`Permission rule ${ruleNumber}: exactly one scope is required`);
1220
+ if (rule.capability && !CAPABILITIES.has(rule.capability)) {
1221
+ throw new Error(`Permission rule ${ruleNumber}: unknown capability`);
1222
+ }
1223
+ if (rule.tool !== void 0 && (!rule.tool.trim() || /[*?]/.test(rule.tool))) {
1224
+ throw new Error(`Permission rule ${ruleNumber}: tool scope must be an exact name`);
1225
+ }
1226
+ if (rule.shell) {
1227
+ const { command, interpreter, cwd } = rule.shell;
1228
+ if (!command?.trim() || !interpreter?.trim() || !cwd?.trim() || /[*?]/.test(command) || /[*?]/.test(interpreter) || /[*?]/.test(cwd)) {
1229
+ throw new Error(
1230
+ `Permission rule ${ruleNumber}: shell rules require an exact command, interpreter and cwd`
1231
+ );
1232
+ }
1233
+ }
1234
+ }
170
1235
  }
171
- function toToolDefinition(tool) {
172
- return {
173
- name: tool.name,
174
- description: tool.description,
175
- parameters: tool.parameters
176
- };
1236
+ function samePath(a, b) {
1237
+ const na = path6.resolve(a);
1238
+ const nb = path6.resolve(b);
1239
+ return process.platform === "win32" ? na.toLowerCase() === nb.toLowerCase() : na === nb;
1240
+ }
1241
+ var PermissionPolicy = class {
1242
+ decide(request) {
1243
+ if (request.hardDenied) return { decision: "deny", source: "hard_limit" };
1244
+ if (request.mode === "Plan" && (request.dangerous || request.capabilities.length === 0 || request.capabilities.some((capability) => capability !== "fs:read"))) {
1245
+ return { decision: "deny", source: "plan" };
1246
+ }
1247
+ const matches = (rule) => {
1248
+ if (rule.tool !== void 0) return rule.tool === request.toolName;
1249
+ if (rule.capability !== void 0) return request.capabilities.includes(rule.capability);
1250
+ if (!rule.shell || !request.shell) return false;
1251
+ return rule.shell.command === request.shell.command && rule.shell.interpreter === request.shell.interpreter && samePath(rule.shell.cwd, request.shell.cwd);
1252
+ };
1253
+ const rules = request.rules ?? [];
1254
+ for (const action of ["deny", "ask"]) {
1255
+ const ruleIndex = rules.findIndex((rule) => rule.action === action && matches(rule));
1256
+ if (ruleIndex >= 0) return { decision: action, source: "explicit_rule", ruleIndex };
1257
+ if (action === "deny" && request.cachedDenial)
1258
+ return { decision: "deny", source: "session_cache" };
1259
+ }
1260
+ if (request.cachedApproval) return { decision: "allow", source: "session_cache" };
1261
+ if (request.capabilities.length === 0) return { decision: "ask", source: "mode_default" };
1262
+ const allowIndex = rules.findIndex(
1263
+ (rule) => rule.action === "allow" && matches(rule) && (request.toolName !== "run_command" || rule.shell !== void 0)
1264
+ );
1265
+ if (allowIndex >= 0)
1266
+ return { decision: "allow", source: "explicit_rule", ruleIndex: allowIndex };
1267
+ if (request.capabilities.length === 0 || request.dangerous && request.mode !== "FullAccess") {
1268
+ return { decision: "ask", source: "mode_default" };
1269
+ }
1270
+ if (request.mode === "FullAccess") return { decision: "allow", source: "mode_default" };
1271
+ if (request.capabilities.every((capability) => capability === "fs:read")) {
1272
+ return { decision: "allow", source: "mode_default" };
1273
+ }
1274
+ if (request.mode === "Auto" && request.capabilities.every(
1275
+ (capability) => capability === "fs:read" || capability === "fs:write"
1276
+ )) {
1277
+ return { decision: "allow", source: "mode_default" };
1278
+ }
1279
+ return { decision: "ask", source: "mode_default" };
1280
+ }
1281
+ };
1282
+ function visibleTools(tools, mode, rules = []) {
1283
+ const policy = new PermissionPolicy();
1284
+ return tools.filter(
1285
+ (tool) => policy.decide({
1286
+ mode,
1287
+ toolName: tool.name,
1288
+ capabilities: tool.metadata?.permissions ?? [],
1289
+ dangerous: tool.metadata?.dangerous,
1290
+ rules
1291
+ }).decision !== "deny"
1292
+ );
177
1293
  }
178
1294
 
1295
+ // src/security/approval.ts
1296
+ import { createHash as createHash4 } from "crypto";
1297
+ function stableJsonStringify(value) {
1298
+ if (value === null || typeof value !== "object") {
1299
+ return JSON.stringify(value);
1300
+ }
1301
+ if (Array.isArray(value)) {
1302
+ return `[${value.map(stableJsonStringify).join(",")}]`;
1303
+ }
1304
+ const obj = value;
1305
+ const keys = Object.keys(obj).sort();
1306
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableJsonStringify(obj[k])}`).join(",")}}`;
1307
+ }
1308
+ var SessionApprovalCache = class {
1309
+ choices = /* @__PURE__ */ new Map();
1310
+ key(request) {
1311
+ return createHash4("sha256").update(
1312
+ stableJsonStringify([
1313
+ request.toolName,
1314
+ [...request.capabilities].sort(),
1315
+ request.input,
1316
+ request.rootDir,
1317
+ request.shell ?? null
1318
+ ])
1319
+ ).digest("hex");
1320
+ }
1321
+ get(request) {
1322
+ return this.choices.get(this.key(request));
1323
+ }
1324
+ set(request, choice) {
1325
+ this.choices.set(this.key(request), choice);
1326
+ }
1327
+ };
1328
+
179
1329
  // src/tools/registry.ts
180
1330
  var ToolRegistry = class {
181
1331
  tools = /* @__PURE__ */ new Map();
@@ -233,8 +1383,8 @@ var ToolRegistry = class {
233
1383
  };
234
1384
 
235
1385
  // src/tools/builtin/fs.ts
236
- import fs2 from "fs";
237
- import path2 from "path";
1386
+ import fs6 from "fs";
1387
+ import path7 from "path";
238
1388
  var sandboxCache = /* @__PURE__ */ new Map();
239
1389
  function getSandbox(rootDir) {
240
1390
  let sandbox = sandboxCache.get(rootDir);
@@ -272,10 +1422,10 @@ var readFileTool = defineTool({
272
1422
  async execute(input, ctx) {
273
1423
  const sandbox = getSandbox(ctx.rootDir);
274
1424
  const safePath = sandbox.resolveSafePath(input.path);
275
- if (!fs2.existsSync(safePath)) {
1425
+ if (!fs6.existsSync(safePath)) {
276
1426
  throw new ToolError(`File not found: ${input.path}`);
277
1427
  }
278
- const stat = fs2.statSync(safePath);
1428
+ const stat = fs6.statSync(safePath);
279
1429
  if (stat.isDirectory()) {
280
1430
  throw new ToolError(`Target path is a directory, not a file: ${input.path}`);
281
1431
  }
@@ -288,11 +1438,11 @@ var readFileTool = defineTool({
288
1438
  }
289
1439
  const prefixSize = Math.min(stat.size, 8192);
290
1440
  const prefix = Buffer.alloc(prefixSize);
291
- const fileDescriptor = fs2.openSync(safePath, "r");
1441
+ const fileDescriptor = fs6.openSync(safePath, "r");
292
1442
  try {
293
- fs2.readSync(fileDescriptor, prefix, 0, prefixSize, 0);
1443
+ fs6.readSync(fileDescriptor, prefix, 0, prefixSize, 0);
294
1444
  } finally {
295
- fs2.closeSync(fileDescriptor);
1445
+ fs6.closeSync(fileDescriptor);
296
1446
  }
297
1447
  if (prefix.includes(0)) {
298
1448
  throw new ToolError(`Cannot read binary file: ${input.path}`);
@@ -305,7 +1455,7 @@ var readFileTool = defineTool({
305
1455
  maxReadBytes
306
1456
  );
307
1457
  }
308
- return fs2.readFileSync(safePath, "utf-8");
1458
+ return fs6.readFileSync(safePath, "utf-8");
309
1459
  }
310
1460
  });
311
1461
  async function readFileLineRange(filePath, startLine, endLine, maxReadBytes) {
@@ -315,7 +1465,7 @@ async function readFileLineRange(filePath, startLine, endLine, maxReadBytes) {
315
1465
  let currentLine = 1;
316
1466
  let pending = "";
317
1467
  let reachedEnd = false;
318
- const stream = fs2.createReadStream(filePath, { encoding: "utf-8" });
1468
+ const stream = fs6.createReadStream(filePath, { encoding: "utf-8" });
319
1469
  const selectLine = (line) => {
320
1470
  if (currentLine >= startLine && (endLine === void 0 || currentLine <= endLine)) {
321
1471
  const addedBytes = Buffer.byteLength(line, "utf-8") + (selectedLines.length > 0 ? 1 : 0);
@@ -378,11 +1528,11 @@ var writeFileTool = defineTool({
378
1528
  async execute(input, ctx) {
379
1529
  const sandbox = getSandbox(ctx.rootDir);
380
1530
  const safePath = sandbox.resolveSafePath(input.path);
381
- const dir = path2.dirname(safePath);
382
- if (!fs2.existsSync(dir)) {
383
- fs2.mkdirSync(dir, { recursive: true });
1531
+ const dir = path7.dirname(safePath);
1532
+ if (!fs6.existsSync(dir)) {
1533
+ fs6.mkdirSync(dir, { recursive: true });
384
1534
  }
385
- fs2.writeFileSync(safePath, input.content, "utf-8");
1535
+ fs6.writeFileSync(safePath, input.content, "utf-8");
386
1536
  return `Successfully wrote ${Buffer.byteLength(input.content, "utf-8")} bytes to ${input.path}`;
387
1537
  }
388
1538
  });
@@ -414,13 +1564,13 @@ var editFileTool = defineTool({
414
1564
  async execute(input, ctx) {
415
1565
  const sandbox = getSandbox(ctx.rootDir);
416
1566
  const safePath = sandbox.resolveSafePath(input.path);
417
- if (!fs2.existsSync(safePath)) {
1567
+ if (!fs6.existsSync(safePath)) {
418
1568
  throw new ToolError(`File not found: ${input.path}`);
419
1569
  }
420
1570
  if (!input.targetContent) {
421
1571
  throw new ToolError("Target content must not be empty");
422
1572
  }
423
- const content = fs2.readFileSync(safePath, "utf-8");
1573
+ const content = fs6.readFileSync(safePath, "utf-8");
424
1574
  if (!content.includes(input.targetContent)) {
425
1575
  throw new ToolError(
426
1576
  `Target content not found in ${input.path}. Ensure exact indentation and characters.`
@@ -433,7 +1583,7 @@ var editFileTool = defineTool({
433
1583
  );
434
1584
  }
435
1585
  const newContent = content.replace(input.targetContent, () => input.replacementContent);
436
- fs2.writeFileSync(safePath, newContent, "utf-8");
1586
+ fs6.writeFileSync(safePath, newContent, "utf-8");
437
1587
  return `Successfully replaced target content in ${input.path}`;
438
1588
  }
439
1589
  });
@@ -461,20 +1611,20 @@ var globTool = defineTool({
461
1611
  async execute(input, ctx) {
462
1612
  const sandbox = getSandbox(ctx.rootDir);
463
1613
  const searchRoot = input.subDirectory ? sandbox.resolveSafePath(input.subDirectory) : ctx.rootDir;
464
- if (!fs2.existsSync(searchRoot)) {
1614
+ if (!fs6.existsSync(searchRoot)) {
465
1615
  throw new ToolError(`Directory not found: ${input.subDirectory ?? "."}`);
466
1616
  }
467
1617
  const results = [];
468
1618
  const maxResults = 100;
469
1619
  function walk(currentDir) {
470
1620
  if (results.length >= maxResults) return;
471
- const entries = fs2.readdirSync(currentDir, { withFileTypes: true });
1621
+ const entries = fs6.readdirSync(currentDir, { withFileTypes: true });
472
1622
  for (const entry of entries) {
473
1623
  if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") {
474
1624
  continue;
475
1625
  }
476
- const fullPath = path2.join(currentDir, entry.name);
477
- const relPath = path2.relative(ctx.rootDir, fullPath).replace(/\\/g, "/");
1626
+ const fullPath = path7.join(currentDir, entry.name);
1627
+ const relPath = path7.relative(ctx.rootDir, fullPath).replace(/\\/g, "/");
478
1628
  if (entry.isDirectory()) {
479
1629
  walk(fullPath);
480
1630
  } else {
@@ -514,7 +1664,7 @@ var grepTool = defineTool({
514
1664
  if (ctx.signal?.aborted) throw new AbortError();
515
1665
  const sandbox = getSandbox(ctx.rootDir);
516
1666
  const target = input.path ? sandbox.resolveSafePath(input.path) : ctx.rootDir;
517
- if (!fs2.existsSync(target)) {
1667
+ if (!fs6.existsSync(target)) {
518
1668
  throw new ToolError(`Path not found: ${input.path ?? "."}`);
519
1669
  }
520
1670
  if (!input.pattern.trim()) throw new ToolError("Search pattern must not be empty");
@@ -526,8 +1676,8 @@ var grepTool = defineTool({
526
1676
  if (matches.length >= maxMatches) return;
527
1677
  const maxLineBytes = 512 * 1024;
528
1678
  const maxOutputBytes = 512 * 1024;
529
- const stream = fs2.createReadStream(filePath, { encoding: "utf-8" });
530
- const relPath = path2.relative(ctx.rootDir, filePath).replace(/\\/g, "/");
1679
+ const stream = fs6.createReadStream(filePath, { encoding: "utf-8" });
1680
+ const relPath = path7.relative(ctx.rootDir, filePath).replace(/\\/g, "/");
531
1681
  let lineNumber = 0;
532
1682
  let pending = "";
533
1683
  let skippingLongLine = false;
@@ -577,14 +1727,14 @@ var grepTool = defineTool({
577
1727
  }
578
1728
  async function walk(dirPath) {
579
1729
  if (matches.length >= maxMatches) return;
580
- const entries = fs2.readdirSync(dirPath, { withFileTypes: true });
1730
+ const entries = fs6.readdirSync(dirPath, { withFileTypes: true });
581
1731
  for (const entry of entries) {
582
1732
  if (ctx.signal?.aborted) throw new AbortError();
583
1733
  if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") {
584
1734
  continue;
585
1735
  }
586
1736
  if (entry.isSymbolicLink()) continue;
587
- const fullPath = path2.join(dirPath, entry.name);
1737
+ const fullPath = path7.join(dirPath, entry.name);
588
1738
  if (entry.isDirectory()) {
589
1739
  await walk(fullPath);
590
1740
  } else {
@@ -593,7 +1743,7 @@ var grepTool = defineTool({
593
1743
  if (matches.length >= maxMatches) return;
594
1744
  }
595
1745
  }
596
- const stat = fs2.statSync(target);
1746
+ const stat = fs6.statSync(target);
597
1747
  if (stat.isFile()) {
598
1748
  await searchFile(target);
599
1749
  } else {
@@ -633,6 +1783,7 @@ var HookRegistry = class {
633
1783
  };
634
1784
 
635
1785
  // src/executor/index.ts
1786
+ import { createHash as createHash5 } from "crypto";
636
1787
  var DEFAULT_TOOL_TIMEOUT_MS = 12e4;
637
1788
  var ToolExecutor = class {
638
1789
  tools;
@@ -641,54 +1792,310 @@ var ToolExecutor = class {
641
1792
  signal;
642
1793
  logger;
643
1794
  toolTimeoutMs;
1795
+ eventLogger;
1796
+ sessionId;
1797
+ runId;
1798
+ mode;
1799
+ permissionRules;
1800
+ approvalChannel;
1801
+ approvalCache;
1802
+ policy = new PermissionPolicy();
1803
+ neverFailures = /* @__PURE__ */ new Set();
1804
+ cancellation = new AbortController();
644
1805
  constructor(options) {
645
1806
  this.tools = options.tools;
646
1807
  this.hooks = options.hooks;
647
1808
  this.rootDir = options.rootDir;
648
- this.signal = options.signal;
1809
+ this.signal = options.signal ? AbortSignal.any([options.signal, this.cancellation.signal]) : this.cancellation.signal;
649
1810
  this.logger = options.logger;
650
1811
  this.toolTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
651
- }
652
- async runAll(calls) {
1812
+ this.eventLogger = options.eventLogger;
1813
+ this.sessionId = options.sessionId;
1814
+ this.runId = options.runId;
1815
+ this.mode = options.mode ?? "Approval";
1816
+ this.permissionRules = options.permissionRules ?? [];
1817
+ this.approvalChannel = options.approvalChannel;
1818
+ this.approvalCache = options.approvalCache ?? new SessionApprovalCache();
1819
+ }
1820
+ /** 中止本轮正在等待或执行的工具;调用方仍须等待 runAll 完成资源回收及结果审计。 */
1821
+ cancel() {
1822
+ this.cancellation.abort();
1823
+ }
1824
+ async runAll(calls, onProgress) {
653
1825
  const results = [];
1826
+ let requiresUserAction = false;
654
1827
  for (const call of calls) {
655
- if (this.signal?.aborted) {
656
- results.push({
657
- type: "tool_result",
658
- toolUseId: call.id,
659
- content: "Tool execution aborted",
660
- isError: true
661
- });
662
- continue;
663
- }
664
- results.push(await this.executeOne(call));
1828
+ const result = await this.executeOne(call, onProgress, requiresUserAction);
1829
+ results.push(result);
1830
+ if (result.retryPolicy === "after_user_action") requiresUserAction = true;
665
1831
  }
666
1832
  return results;
667
1833
  }
668
- async executeOne(call) {
1834
+ /** 在最终参数上裁决并审计一次调用,所有拒绝和异常均闭合为工具结果。 */
1835
+ async executeOne(call, onProgress, forcedDeny = false) {
669
1836
  const startedAt = Date.now();
1837
+ const operationId = createLogId();
1838
+ const failure = (message, errorCode, retryPolicy = "never") => ({
1839
+ type: "tool_result",
1840
+ toolUseId: call.id,
1841
+ content: JSON.stringify({ code: errorCode, retryPolicy, message }),
1842
+ isError: true,
1843
+ errorCode,
1844
+ retryPolicy,
1845
+ durationMs: Math.max(0, Date.now() - startedAt)
1846
+ });
1847
+ const audit = async (event, fields = {}) => {
1848
+ if (!this.eventLogger || !this.sessionId) return;
1849
+ await this.eventLogger.recordAudit({
1850
+ level: "info",
1851
+ event,
1852
+ sessionId: this.sessionId,
1853
+ runId: this.runId,
1854
+ operationId,
1855
+ toolUseId: call.id,
1856
+ fields: { toolName: call.name, ...fields }
1857
+ });
1858
+ };
1859
+ try {
1860
+ await audit("tool.requested", { status: "requested", processId: process.pid });
1861
+ } catch {
1862
+ return failure(
1863
+ "Audit unavailable; tool was not started",
1864
+ "AUDIT_UNAVAILABLE",
1865
+ "after_user_action"
1866
+ );
1867
+ }
1868
+ if (forcedDeny) {
1869
+ try {
1870
+ await audit("tool.decided", { decision: "deny", decisionSource: "prior_user_action" });
1871
+ await audit("tool.finished", { status: "skipped", errorCode: "SKIPPED_AFTER_USER_ACTION" });
1872
+ } catch {
1873
+ return failure(
1874
+ "Audit unavailable; tool was not started",
1875
+ "AUDIT_UNAVAILABLE",
1876
+ "after_user_action"
1877
+ );
1878
+ }
1879
+ return failure(
1880
+ "Earlier tool call requires user action",
1881
+ "SKIPPED_AFTER_USER_ACTION",
1882
+ "after_user_action"
1883
+ );
1884
+ }
670
1885
  const hookCtx = { signal: this.signal, logger: this.logger };
671
1886
  let currentInput = call.input;
672
- const beforeHooks = this.hooks.get("tool:before");
673
- for (const hook of beforeHooks) {
674
- const decision = await hook(hookCtx, { id: call.id, name: call.name, input: currentInput });
675
- if (decision.action === "skip") {
676
- return {
677
- type: "tool_result",
678
- toolUseId: call.id,
679
- content: decision.result,
680
- isError: decision.isError ?? false,
681
- durationMs: Math.max(0, Date.now() - startedAt)
682
- };
1887
+ try {
1888
+ for (const hook of this.hooks.get("tool:before")) {
1889
+ const decision = await hook(hookCtx, { id: call.id, name: call.name, input: currentInput });
1890
+ if (decision.action === "modify") currentInput = decision.input;
1891
+ if (decision.action === "skip") {
1892
+ await audit("tool.decided", { decision: "deny", decisionSource: "hook" });
1893
+ await audit("tool.finished", { status: "skipped" });
1894
+ return {
1895
+ type: "tool_result",
1896
+ toolUseId: call.id,
1897
+ content: decision.result,
1898
+ isError: decision.isError ?? false,
1899
+ durationMs: Math.max(0, Date.now() - startedAt)
1900
+ };
1901
+ }
1902
+ }
1903
+ } catch {
1904
+ try {
1905
+ await audit("tool.decided", { decision: "deny", decisionSource: "hook" });
1906
+ await audit("tool.finished", { status: "failed" });
1907
+ } catch {
1908
+ }
1909
+ return failure("Tool preparation failed", "HOOK_ERROR");
1910
+ }
1911
+ let tool;
1912
+ try {
1913
+ tool = this.tools.resolve(call.name);
1914
+ } catch (error) {
1915
+ const known = error instanceof KapibalaError ? error : void 0;
1916
+ try {
1917
+ await audit("tool.decided", { decision: "deny", decisionSource: "unavailable" });
1918
+ await audit("tool.finished", { status: "failed" });
1919
+ } catch {
1920
+ }
1921
+ return failure(
1922
+ known?.safeMessage ?? "Requested tool is unavailable",
1923
+ known?.code ?? "TOOL_NOT_FOUND"
1924
+ );
1925
+ }
1926
+ try {
1927
+ return await this.executeResolved(
1928
+ call,
1929
+ currentInput,
1930
+ tool,
1931
+ startedAt,
1932
+ audit,
1933
+ failure,
1934
+ hookCtx,
1935
+ onProgress
1936
+ );
1937
+ } catch {
1938
+ try {
1939
+ await audit("tool.finished", { status: "unknown", errorCode: "OUTCOME_UNKNOWN" });
1940
+ } catch {
683
1941
  }
684
- if (decision.action === "modify") {
685
- currentInput = decision.input;
1942
+ return failure(
1943
+ "Tool outcome is unknown after an executor failure",
1944
+ "OUTCOME_UNKNOWN",
1945
+ "after_user_action"
1946
+ );
1947
+ }
1948
+ }
1949
+ async executeResolved(call, currentInput, tool, startedAt, audit, failure, hookCtx, onProgress) {
1950
+ let shell;
1951
+ try {
1952
+ shell = tool.approvalScope?.(currentInput, this.rootDir);
1953
+ } catch {
1954
+ try {
1955
+ await audit("tool.decided", { decision: "deny", decisionSource: "invalid_target" });
1956
+ await audit("tool.finished", { status: "failed", errorCode: "INVALID_TARGET" });
1957
+ } catch {
686
1958
  }
1959
+ return failure("Invalid tool execution target", "INVALID_TARGET");
1960
+ }
1961
+ let auditFields = {};
1962
+ for (let attempt = 0; attempt < 2; attempt++) {
1963
+ const request = {
1964
+ toolName: call.name,
1965
+ capabilities: tool.metadata?.permissions ?? [],
1966
+ input: currentInput,
1967
+ rootDir: this.rootDir,
1968
+ ...shell ? { shell } : {},
1969
+ // 只有无参数的解释器内建查询能确认不依赖外部脚本;其余命令仅允许单次批准。
1970
+ sessionAllowed: !shell || /^(?:pwd|get-location)$/i.test(shell.command.trim()) || shell.scriptDigest !== void 0
1971
+ };
1972
+ const repeatKey = createHash5("sha256").update(stableJsonStringify([call.name, currentInput])).digest("hex");
1973
+ if (this.neverFailures.has(repeatKey)) {
1974
+ try {
1975
+ await audit("tool.decided", { decision: "deny", decisionSource: "repeat_block" });
1976
+ await audit("tool.finished", { status: "blocked", errorCode: "REPEAT_BLOCKED" });
1977
+ } catch {
1978
+ return failure(
1979
+ "Audit unavailable; tool was not started",
1980
+ "AUDIT_UNAVAILABLE",
1981
+ "after_user_action"
1982
+ );
1983
+ }
1984
+ return failure(
1985
+ "Identical failed tool call cannot be repeated in this run",
1986
+ "REPEAT_BLOCKED"
1987
+ );
1988
+ }
1989
+ const cached = this.approvalCache.get(request);
1990
+ const decision = this.policy.decide({
1991
+ mode: typeof this.mode === "function" ? this.mode() : this.mode,
1992
+ toolName: call.name,
1993
+ capabilities: request.capabilities,
1994
+ dangerous: tool.metadata?.dangerous,
1995
+ cachedApproval: cached === "allow",
1996
+ cachedDenial: cached === "deny",
1997
+ rules: this.permissionRules,
1998
+ ...shell ? { shell } : {}
1999
+ });
2000
+ let permitted = decision.decision === "allow";
2001
+ let source = decision.source;
2002
+ let approvalChoice;
2003
+ if (decision.decision === "ask") {
2004
+ let choice = "deny_once";
2005
+ try {
2006
+ choice = await this.approvalChannel?.requestApproval(request, this.signal) ?? "deny_once";
2007
+ } catch {
2008
+ choice = "cancel";
2009
+ }
2010
+ approvalChoice = choice;
2011
+ if (choice === "allow_session" && request.sessionAllowed)
2012
+ this.approvalCache.set(request, "allow");
2013
+ if (choice === "deny_session") this.approvalCache.set(request, "deny");
2014
+ permitted = choice === "allow_once" || choice === "allow_session" && request.sessionAllowed;
2015
+ source = this.approvalChannel ? "human" : "non_interactive";
2016
+ }
2017
+ auditFields = shell ? {
2018
+ shell: shell.interpreter,
2019
+ cwdDigest: createHash5("sha256").update(shell.cwd).digest("hex"),
2020
+ executableDigest: shell.executableDigest,
2021
+ executionFingerprint: shell.executionFingerprint,
2022
+ ...commandAuditSummary(shell.command)
2023
+ } : {};
2024
+ try {
2025
+ await audit("tool.decided", {
2026
+ decision: permitted ? "allow" : "deny",
2027
+ decisionSource: source,
2028
+ ...approvalChoice ? { approvalChoice } : {},
2029
+ ...auditFields
2030
+ });
2031
+ } catch {
2032
+ return failure(
2033
+ "Audit unavailable; tool was not started",
2034
+ "AUDIT_UNAVAILABLE",
2035
+ "after_user_action"
2036
+ );
2037
+ }
2038
+ if (!permitted || this.signal?.aborted) {
2039
+ const code = this.signal?.aborted ? "ABORTED" : "PERMISSION_DENIED";
2040
+ try {
2041
+ await audit("tool.finished", { status: "denied", errorCode: code });
2042
+ } catch {
2043
+ return failure("Audit result unavailable", "AUDIT_UNAVAILABLE", "after_user_action");
2044
+ }
2045
+ return failure(
2046
+ this.signal?.aborted ? "Operation cancelled" : "Tool permission denied",
2047
+ code,
2048
+ "after_user_action"
2049
+ );
2050
+ }
2051
+ if (shell) {
2052
+ try {
2053
+ const next = tool.approvalScope?.(currentInput, this.rootDir);
2054
+ if (next?.executionFingerprint !== shell.executionFingerprint) {
2055
+ if (attempt === 0 && next) {
2056
+ try {
2057
+ await audit("tool.target_changed", { status: "redeciding" });
2058
+ } catch {
2059
+ return failure(
2060
+ "Audit unavailable; tool was not started",
2061
+ "AUDIT_UNAVAILABLE",
2062
+ "after_user_action"
2063
+ );
2064
+ }
2065
+ shell = next;
2066
+ continue;
2067
+ }
2068
+ await audit("tool.finished", { status: "target_changed", errorCode: "TARGET_CHANGED" });
2069
+ return failure(
2070
+ "Execution target changed after approval",
2071
+ "TARGET_CHANGED",
2072
+ "after_user_action"
2073
+ );
2074
+ }
2075
+ } catch {
2076
+ return failure(
2077
+ "Execution target changed after approval",
2078
+ "TARGET_CHANGED",
2079
+ "after_user_action"
2080
+ );
2081
+ }
2082
+ }
2083
+ break;
2084
+ }
2085
+ try {
2086
+ await audit("tool.started", { status: "started", ...auditFields });
2087
+ } catch {
2088
+ return failure(
2089
+ "Audit unavailable; tool was not started",
2090
+ "AUDIT_UNAVAILABLE",
2091
+ "after_user_action"
2092
+ );
687
2093
  }
688
2094
  let outputStr;
689
2095
  let isError = false;
2096
+ let errorCode;
2097
+ let retryPolicy;
690
2098
  try {
691
- const tool = this.tools.resolve(call.name);
692
2099
  const executionController = new AbortController();
693
2100
  const abortFromSession = () => executionController.abort();
694
2101
  if (this.signal?.aborted) {
@@ -699,37 +2106,85 @@ var ToolExecutor = class {
699
2106
  const toolCtx = {
700
2107
  rootDir: this.rootDir,
701
2108
  signal: executionController.signal,
702
- logger: this.logger
2109
+ logger: this.logger,
2110
+ onProgress: (progress) => onProgress?.({ id: call.id, name: call.name, ...progress }),
2111
+ ...shell ? {
2112
+ onProcessSpawned: () => audit("process.spawned", { status: "spawned", ...auditFields })
2113
+ } : {}
703
2114
  };
704
2115
  try {
705
- const rawResult = await this.withTimeout(
2116
+ const rawResult = tool.metadata?.managesTimeout ? await tool.execute(currentInput, toolCtx) : await this.withTimeout(
706
2117
  tool.execute(currentInput, toolCtx),
707
2118
  call.name,
708
2119
  executionController
709
2120
  );
710
2121
  outputStr = typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult, null, 2);
2122
+ if (call.name === "run_command" && typeof rawResult === "object" && rawResult !== null && "exitCode" in rawResult && rawResult.exitCode !== 0) {
2123
+ isError = true;
2124
+ errorCode = "COMMAND_EXIT_NONZERO";
2125
+ retryPolicy = "never";
2126
+ }
711
2127
  } finally {
712
2128
  this.signal?.removeEventListener("abort", abortFromSession);
713
2129
  }
714
2130
  } catch (err) {
715
2131
  isError = true;
716
- outputStr = err instanceof Error ? err.message : String(err);
2132
+ const known = err instanceof KapibalaError ? err : void 0;
2133
+ outputStr = known?.safeMessage ?? "Tool execution failed";
2134
+ errorCode = known?.code ?? "TOOL_FAILED";
2135
+ retryPolicy = known?.retryPolicy ?? "never";
717
2136
  }
718
2137
  const resultBlock = {
719
2138
  type: "tool_result",
720
2139
  toolUseId: call.id,
721
2140
  content: outputStr,
722
- isError
2141
+ isError,
2142
+ ...errorCode ? { errorCode, retryPolicy } : {}
723
2143
  };
724
- const afterHooks = this.hooks.get("tool:after");
725
- for (const hook of afterHooks) {
726
- await hook(
727
- hookCtx,
728
- { id: call.id, name: call.name, input: currentInput },
729
- { output: outputStr, isError }
730
- );
2144
+ if (resultBlock.isError && resultBlock.errorCode) {
2145
+ resultBlock.content = JSON.stringify({
2146
+ code: resultBlock.errorCode,
2147
+ retryPolicy: resultBlock.retryPolicy,
2148
+ message: outputStr
2149
+ });
2150
+ }
2151
+ try {
2152
+ for (const hook of this.hooks.get("tool:after")) {
2153
+ await hook(
2154
+ hookCtx,
2155
+ { id: call.id, name: call.name, input: currentInput },
2156
+ { output: outputStr, isError }
2157
+ );
2158
+ }
2159
+ } catch {
2160
+ resultBlock.content = JSON.stringify({
2161
+ code: "HOOK_ERROR",
2162
+ retryPolicy: "after_user_action",
2163
+ message: "Tool finished but result processing failed"
2164
+ });
2165
+ resultBlock.isError = true;
2166
+ resultBlock.errorCode = "HOOK_ERROR";
2167
+ resultBlock.retryPolicy = "after_user_action";
731
2168
  }
732
2169
  resultBlock.durationMs = Math.max(0, Date.now() - startedAt);
2170
+ if (resultBlock.isError && resultBlock.retryPolicy === "never") {
2171
+ this.neverFailures.add(
2172
+ createHash5("sha256").update(stableJsonStringify([call.name, currentInput])).digest("hex")
2173
+ );
2174
+ }
2175
+ try {
2176
+ await audit("tool.finished", {
2177
+ status: resultBlock.isError ? "failed" : "completed",
2178
+ ...resultBlock.errorCode ? { errorCode: resultBlock.errorCode } : {},
2179
+ durationMs: resultBlock.durationMs
2180
+ });
2181
+ } catch {
2182
+ return failure(
2183
+ "Tool outcome is unknown because the audit result could not be written",
2184
+ "OUTCOME_UNKNOWN",
2185
+ "after_user_action"
2186
+ );
2187
+ }
733
2188
  return resultBlock;
734
2189
  }
735
2190
  /**
@@ -743,7 +2198,14 @@ var ToolExecutor = class {
743
2198
  let timer;
744
2199
  const timeout = new Promise((_, reject) => {
745
2200
  timer = setTimeout(() => {
746
- const error = new ToolError(`Tool '${toolName}' timed out after ${this.toolTimeoutMs}ms`);
2201
+ const error = new KapibalaError(
2202
+ `Tool '${toolName}' timed out after ${this.toolTimeoutMs}ms`,
2203
+ {
2204
+ code: "OUTCOME_UNKNOWN",
2205
+ retryPolicy: "after_user_action",
2206
+ safeMessage: "Tool timed out; completion of side effects is unknown"
2207
+ }
2208
+ );
747
2209
  reject(error);
748
2210
  executionController.abort(error);
749
2211
  }, this.toolTimeoutMs);
@@ -1078,15 +2540,15 @@ var OpenAICompatibleProvider = class {
1078
2540
  };
1079
2541
 
1080
2542
  // src/store/jsonl.ts
1081
- import fs3 from "fs";
1082
- import path3 from "path";
2543
+ import fs7 from "fs";
2544
+ import path8 from "path";
1083
2545
  var JSONLMessageStore = class {
1084
2546
  filePath;
1085
2547
  constructor(filePath) {
1086
- this.filePath = path3.resolve(filePath);
1087
- const dir = path3.dirname(this.filePath);
1088
- if (!fs3.existsSync(dir)) {
1089
- fs3.mkdirSync(dir, { recursive: true });
2548
+ this.filePath = path8.resolve(filePath);
2549
+ const dir = path8.dirname(this.filePath);
2550
+ if (!fs7.existsSync(dir)) {
2551
+ fs7.mkdirSync(dir, { recursive: true });
1090
2552
  }
1091
2553
  }
1092
2554
  async append(message) {
@@ -1095,14 +2557,14 @@ var JSONLMessageStore = class {
1095
2557
  role: message.role,
1096
2558
  content: message.content
1097
2559
  };
1098
- fs3.appendFileSync(this.filePath, `${JSON.stringify(record)}
2560
+ fs7.appendFileSync(this.filePath, `${JSON.stringify(record)}
1099
2561
  `, "utf-8");
1100
2562
  }
1101
2563
  async load() {
1102
- if (!fs3.existsSync(this.filePath)) {
2564
+ if (!fs7.existsSync(this.filePath)) {
1103
2565
  return [];
1104
2566
  }
1105
- const content = fs3.readFileSync(this.filePath, "utf-8");
2567
+ const content = fs7.readFileSync(this.filePath, "utf-8");
1106
2568
  const lines = content.split("\n");
1107
2569
  const messages = [];
1108
2570
  for (const line of lines) {
@@ -1124,8 +2586,8 @@ var JSONLMessageStore = class {
1124
2586
  return messages;
1125
2587
  }
1126
2588
  async clear() {
1127
- if (fs3.existsSync(this.filePath)) {
1128
- fs3.unlinkSync(this.filePath);
2589
+ if (fs7.existsSync(this.filePath)) {
2590
+ fs7.unlinkSync(this.filePath);
1129
2591
  }
1130
2592
  }
1131
2593
  /**
@@ -1148,7 +2610,9 @@ var JSONLMessageStore = class {
1148
2610
  type: "tool_result",
1149
2611
  toolUseId: toolUse.id,
1150
2612
  content: "Tool execution was interrupted or crashed in previous session",
1151
- isError: true
2613
+ isError: true,
2614
+ errorCode: "OUTCOME_UNKNOWN",
2615
+ retryPolicy: "after_user_action"
1152
2616
  })
1153
2617
  ),
1154
2618
  timestamp: Date.now()
@@ -1180,17 +2644,21 @@ var JSONLMessageStore = class {
1180
2644
  };
1181
2645
 
1182
2646
  // src/prompt/index.ts
1183
- import os from "os";
2647
+ import os4 from "os";
1184
2648
  var PromptAssembler = class {
1185
2649
  tools;
1186
2650
  rootDir;
1187
2651
  agentName;
1188
2652
  customInstructions;
2653
+ instructionSources;
2654
+ visibleTools;
1189
2655
  constructor(options) {
1190
2656
  this.tools = options.tools;
1191
2657
  this.rootDir = options.rootDir;
1192
2658
  this.agentName = options.agentName ?? "Kapibala";
1193
2659
  this.customInstructions = options.customInstructions;
2660
+ this.instructionSources = options.instructionSources ?? [];
2661
+ this.visibleTools = options.visibleTools;
1194
2662
  }
1195
2663
  assemble() {
1196
2664
  const sections = [];
@@ -1202,6 +2670,13 @@ var PromptAssembler = class {
1202
2670
  if (this.customInstructions?.trim()) {
1203
2671
  sections.push(this.customInstructions.trim());
1204
2672
  }
2673
+ if (this.instructionSources.length > 0) {
2674
+ sections.push(
2675
+ `# L4 Project Instructions
2676
+ ${this.instructionSources.map((source, index) => `## ${index + 1}. ${source.path}
2677
+ ${source.content}`).join("\n\n")}`
2678
+ );
2679
+ }
1205
2680
  return sections.filter(Boolean).join("\n\n---\n\n");
1206
2681
  }
1207
2682
  getL1Behavior() {
@@ -1218,7 +2693,7 @@ Follow these key behavioral rules:
1218
2693
  }
1219
2694
  getL2Tools() {
1220
2695
  if (!this.tools) return "";
1221
- const tools = this.tools.list();
2696
+ const tools = this.visibleTools ?? this.tools.list();
1222
2697
  if (tools.length === 0) return "";
1223
2698
  const lines = ["# Available Tools:"];
1224
2699
  for (const tool of tools) {
@@ -1228,7 +2703,7 @@ Follow these key behavioral rules:
1228
2703
  }
1229
2704
  getL3Environment() {
1230
2705
  const now = (/* @__PURE__ */ new Date()).toISOString();
1231
- const platform = os.platform();
2706
+ const platform = os4.platform();
1232
2707
  return `# Environment Context:
1233
2708
  - Current Working Directory (Workspace Root): ${this.rootDir}
1234
2709
  - Operating System: ${platform}
@@ -1246,6 +2721,11 @@ var AgentLoop = class {
1246
2721
  maxSteps;
1247
2722
  maxConsecutiveErrors;
1248
2723
  signal;
2724
+ eventLogger;
2725
+ sessionId;
2726
+ runId;
2727
+ getMode;
2728
+ permissionRules;
1249
2729
  constructor(options) {
1250
2730
  this.provider = options.provider;
1251
2731
  this.executor = options.executor;
@@ -1255,6 +2735,11 @@ var AgentLoop = class {
1255
2735
  this.maxSteps = options.maxSteps ?? 20;
1256
2736
  this.maxConsecutiveErrors = options.maxConsecutiveErrors ?? 3;
1257
2737
  this.signal = options.signal;
2738
+ this.eventLogger = options.eventLogger;
2739
+ this.sessionId = options.sessionId;
2740
+ this.runId = options.runId;
2741
+ this.getMode = options.getMode ?? (() => "Approval");
2742
+ this.permissionRules = options.permissionRules ?? [];
1258
2743
  }
1259
2744
  async *run(history) {
1260
2745
  let step = 0;
@@ -1270,7 +2755,9 @@ var AgentLoop = class {
1270
2755
  let request = {
1271
2756
  systemPrompt: this.systemPrompt,
1272
2757
  messages: history,
1273
- tools: this.tools.definitions(),
2758
+ tools: visibleTools(this.tools.list(), this.getMode(), this.permissionRules).map(
2759
+ toToolDefinition
2760
+ ),
1274
2761
  signal: this.signal
1275
2762
  };
1276
2763
  const beforeModelHooks = this.hooks.get("model:before");
@@ -1293,6 +2780,15 @@ var AgentLoop = class {
1293
2780
  let turnUsage;
1294
2781
  let turnTtftMs;
1295
2782
  const modelStartTime = Date.now();
2783
+ if (this.eventLogger && this.sessionId) {
2784
+ await this.eventLogger.record({
2785
+ level: "info",
2786
+ event: "model.requested",
2787
+ sessionId: this.sessionId,
2788
+ runId: this.runId,
2789
+ fields: { attempt: step, count: request.messages.length }
2790
+ });
2791
+ }
1296
2792
  let firstTokenReceived = false;
1297
2793
  const buildMetrics = (modelDurationMs2, toolDurationMs2, toolCallsCount) => {
1298
2794
  const endTime = Date.now();
@@ -1353,6 +2849,15 @@ var AgentLoop = class {
1353
2849
  throw new AbortError();
1354
2850
  }
1355
2851
  const modelError = err instanceof Error ? err : new Error(String(err));
2852
+ if (this.eventLogger && this.sessionId) {
2853
+ await this.eventLogger.record({
2854
+ level: "error",
2855
+ event: "model.failed",
2856
+ sessionId: this.sessionId,
2857
+ runId: this.runId,
2858
+ fields: { attempt: step, durationMs: Date.now() - modelStartTime }
2859
+ });
2860
+ }
1356
2861
  await this.hooks.emit("error", hookCtx, modelError);
1357
2862
  yield { type: "error", error: modelError };
1358
2863
  yield {
@@ -1364,6 +2869,20 @@ var AgentLoop = class {
1364
2869
  break;
1365
2870
  }
1366
2871
  const modelDurationMs = Date.now() - modelStartTime;
2872
+ if (this.eventLogger && this.sessionId) {
2873
+ await this.eventLogger.record({
2874
+ level: "info",
2875
+ event: "model.finished",
2876
+ sessionId: this.sessionId,
2877
+ runId: this.runId,
2878
+ fields: {
2879
+ attempt: step,
2880
+ durationMs: modelDurationMs,
2881
+ promptTokens: turnUsage?.promptTokens ?? 0,
2882
+ completionTokens: turnUsage?.completionTokens ?? 0
2883
+ }
2884
+ });
2885
+ }
1367
2886
  yield {
1368
2887
  type: "step_log",
1369
2888
  log: {
@@ -1429,12 +2948,44 @@ var AgentLoop = class {
1429
2948
  yield { type: "tool_start", id: call.id, name: call.name, input: call.input };
1430
2949
  }
1431
2950
  const toolStartTime = Date.now();
1432
- const toolResults = await this.executor.runAll(toolCalls);
2951
+ const progressQueue = [];
2952
+ let progressWake;
2953
+ let toolsFinished = false;
2954
+ const toolRun = this.executor.runAll(toolCalls, (progress) => {
2955
+ progressQueue.push({ type: "tool_progress", ...progress });
2956
+ progressWake?.();
2957
+ });
2958
+ void toolRun.then(
2959
+ () => {
2960
+ toolsFinished = true;
2961
+ progressWake?.();
2962
+ },
2963
+ () => {
2964
+ toolsFinished = true;
2965
+ progressWake?.();
2966
+ }
2967
+ );
2968
+ let toolResults;
2969
+ let toolMessages;
2970
+ try {
2971
+ while (!toolsFinished || progressQueue.length > 0) {
2972
+ const next = progressQueue.shift();
2973
+ if (next) yield next;
2974
+ else
2975
+ await new Promise((resolve) => {
2976
+ progressWake = resolve;
2977
+ });
2978
+ progressWake = void 0;
2979
+ }
2980
+ } finally {
2981
+ if (!toolsFinished) this.executor.cancel();
2982
+ toolResults = await toolRun;
2983
+ toolMessages = this.provider.assembleToolResults(toolResults);
2984
+ history.push(assistantMessage, ...toolMessages);
2985
+ }
1433
2986
  const toolDurationMs = Date.now() - toolStartTime;
1434
2987
  const hasError = toolResults.some((result) => result.isError);
1435
2988
  const metrics = buildMetrics(modelDurationMs, toolDurationMs, toolCalls.length);
1436
- const toolMessages = this.provider.assembleToolResults(toolResults);
1437
- history.push(assistantMessage, ...toolMessages);
1438
2989
  yield {
1439
2990
  type: "message_stop",
1440
2991
  message: assistantMessage,
@@ -1464,6 +3015,19 @@ var AgentLoop = class {
1464
3015
  }
1465
3016
  };
1466
3017
  yield { type: "tool_messages", messages: toolMessages };
3018
+ if (toolResults.some((result) => result.retryPolicy === "after_user_action")) {
3019
+ if (this.signal?.aborted) {
3020
+ yield { type: "turn_finish", turn: step, usage: turnUsage, metrics };
3021
+ throw new AbortError();
3022
+ }
3023
+ yield {
3024
+ type: "error",
3025
+ error: new Error("Tool execution requires user action before continuing")
3026
+ };
3027
+ yield { type: "turn_finish", turn: step, usage: turnUsage, metrics };
3028
+ completed = true;
3029
+ break;
3030
+ }
1467
3031
  if (hasError) {
1468
3032
  consecutiveErrors++;
1469
3033
  if (consecutiveErrors >= this.maxConsecutiveErrors) {
@@ -1513,6 +3077,17 @@ var AgentSession = class {
1513
3077
  customSystemPrompt;
1514
3078
  maxSteps;
1515
3079
  logger;
3080
+ eventLogger;
3081
+ sessionId = createLogId();
3082
+ mode;
3083
+ permissionRules;
3084
+ approvalChannel;
3085
+ approvalCache = new SessionApprovalCache();
3086
+ projectRoot;
3087
+ cwd;
3088
+ userInstructionsPath;
3089
+ instructionSnapshot;
3090
+ auditRecoveryDirectory;
1516
3091
  history = [];
1517
3092
  totalTurns = 0;
1518
3093
  usageStats = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
@@ -1532,13 +3107,49 @@ var AgentSession = class {
1532
3107
  this.customSystemPrompt = config.systemPrompt;
1533
3108
  this.maxSteps = config.maxSteps ?? 20;
1534
3109
  this.logger = config.logger;
3110
+ validatePermissionRules(config.permissionRules ?? []);
3111
+ this.mode = config.mode ?? "Approval";
3112
+ this.permissionRules = config.permissionRules ?? [];
3113
+ this.approvalChannel = config.approvalChannel;
3114
+ this.projectRoot = config.projectRoot ?? this.rootDir;
3115
+ this.cwd = config.cwd ?? this.rootDir;
3116
+ this.userInstructionsPath = config.userInstructionsPath;
3117
+ const sinks = config.eventLogger ? void 0 : createDefaultLogSinks(config.loggingDirectory);
3118
+ this.eventLogger = config.eventLogger ?? new StructuredLogger({
3119
+ operationSink: sinks.operationSink,
3120
+ auditSink: sinks.auditSink,
3121
+ debug: config.logger !== void 0,
3122
+ onDiagnostic: config.onDiagnostic ?? config.logger
3123
+ });
3124
+ this.auditRecoveryDirectory = config.auditRecoveryDirectory ?? sinks?.auditSink.directory;
1535
3125
  }
1536
3126
  async init() {
1537
3127
  if (this.initialized) return;
3128
+ if (this.auditRecoveryDirectory) {
3129
+ const count = await recoverIncompleteAudit(this.auditRecoveryDirectory);
3130
+ if (count > 0)
3131
+ await this.eventLogger.record({
3132
+ level: "warn",
3133
+ event: "audit.recovered_unknown",
3134
+ sessionId: this.sessionId,
3135
+ fields: { count }
3136
+ });
3137
+ }
3138
+ this.instructionSnapshot = loadInstructions({
3139
+ projectRoot: this.projectRoot,
3140
+ cwd: this.cwd,
3141
+ userFile: this.userInstructionsPath
3142
+ });
1538
3143
  if (this.store) {
1539
3144
  this.history = await this.store.load();
1540
3145
  }
1541
3146
  await this.hooks.emit("session:start", { logger: this.logger });
3147
+ await this.eventLogger.record({
3148
+ level: "info",
3149
+ event: "session.started",
3150
+ sessionId: this.sessionId,
3151
+ fields: { modelId: this.router.getProfile("default").id }
3152
+ });
1542
3153
  this.initialized = true;
1543
3154
  }
1544
3155
  async use(plugin) {
@@ -1566,6 +3177,18 @@ var AgentSession = class {
1566
3177
  getActiveProfile(role = "default") {
1567
3178
  return this.router.getProfile(role);
1568
3179
  }
3180
+ /** 只在空闲时切换本次会话模式;宿主负责 FullAccess 的显式选择交互。 */
3181
+ switchMode(mode) {
3182
+ this.assertIdle("switch permission mode");
3183
+ this.mode = mode;
3184
+ }
3185
+ getMode() {
3186
+ return this.mode;
3187
+ }
3188
+ /** 返回最近一次成功装载的来源路径,不暴露指令正文。 */
3189
+ getInstructionSources() {
3190
+ return this.instructionSnapshot?.sources.map((source) => source.path) ?? [];
3191
+ }
1569
3192
  getHistory() {
1570
3193
  return [...this.history];
1571
3194
  }
@@ -1613,6 +3236,7 @@ var AgentSession = class {
1613
3236
  this.assertIdle("start another run");
1614
3237
  resolveContextWindow(this.router.getProfile("default").contextWindow);
1615
3238
  this.activeRun = true;
3239
+ const runId = createLogId();
1616
3240
  const runStartTime = Date.now();
1617
3241
  const aggregate = {
1618
3242
  modelDurationMs: 0,
@@ -1642,6 +3266,28 @@ var AgentSession = class {
1642
3266
  if (!this.initialized) {
1643
3267
  await this.init();
1644
3268
  }
3269
+ await this.eventLogger.record({
3270
+ level: "info",
3271
+ event: "run.started",
3272
+ sessionId: this.sessionId,
3273
+ runId
3274
+ });
3275
+ try {
3276
+ this.instructionSnapshot = loadInstructions({
3277
+ projectRoot: this.projectRoot,
3278
+ cwd: this.cwd,
3279
+ userFile: this.userInstructionsPath
3280
+ });
3281
+ } catch (error) {
3282
+ await this.eventLogger.record({
3283
+ level: "warn",
3284
+ event: "instructions.refresh_failed",
3285
+ sessionId: this.sessionId,
3286
+ runId,
3287
+ fields: { status: "retained_previous_snapshot" }
3288
+ });
3289
+ if (!this.instructionSnapshot) throw error;
3290
+ }
1645
3291
  this.totalTurns++;
1646
3292
  const userMessage = {
1647
3293
  role: "user",
@@ -1655,7 +3301,9 @@ var AgentSession = class {
1655
3301
  const promptAssembler = new PromptAssembler({
1656
3302
  tools: this.tools,
1657
3303
  rootDir: this.rootDir,
1658
- customInstructions: this.customSystemPrompt
3304
+ customInstructions: this.customSystemPrompt,
3305
+ instructionSources: this.instructionSnapshot.sources,
3306
+ visibleTools: visibleTools(this.tools.list(), this.mode, this.permissionRules)
1659
3307
  });
1660
3308
  const assembledSystemPrompt = promptAssembler.assemble();
1661
3309
  const executor = new ToolExecutor({
@@ -1663,7 +3311,14 @@ var AgentSession = class {
1663
3311
  hooks: this.hooks,
1664
3312
  rootDir: this.rootDir,
1665
3313
  signal: options?.signal,
1666
- logger: this.logger
3314
+ logger: this.logger,
3315
+ eventLogger: this.eventLogger,
3316
+ sessionId: this.sessionId,
3317
+ runId,
3318
+ mode: () => this.mode,
3319
+ permissionRules: this.permissionRules,
3320
+ approvalChannel: this.approvalChannel,
3321
+ approvalCache: this.approvalCache
1667
3322
  });
1668
3323
  const loop = new AgentLoop({
1669
3324
  provider: this.router.resolve("default"),
@@ -1672,7 +3327,12 @@ var AgentSession = class {
1672
3327
  hooks: this.hooks,
1673
3328
  systemPrompt: assembledSystemPrompt,
1674
3329
  maxSteps: this.maxSteps,
1675
- signal: options?.signal
3330
+ signal: options?.signal,
3331
+ eventLogger: this.eventLogger,
3332
+ sessionId: this.sessionId,
3333
+ runId,
3334
+ getMode: () => this.mode,
3335
+ permissionRules: this.permissionRules
1676
3336
  });
1677
3337
  let pendingAssistant;
1678
3338
  let pendingEvents = [];
@@ -1680,10 +3340,24 @@ var AgentSession = class {
1680
3340
  let pendingAssistantPersisted = false;
1681
3341
  let pendingPersistenceStarted = false;
1682
3342
  let pendingToolMessages = [];
3343
+ let persistedHistoryLength = this.history.length;
3344
+ let loopCompleted = false;
3345
+ let loopFailed = false;
1683
3346
  try {
1684
3347
  for await (const event of loop.run(this.history)) {
1685
3348
  if (event.type === "step_log") {
1686
3349
  this.logger?.(`[Turn ${event.log.turn} | ${event.log.stage}] ${event.log.message}`);
3350
+ await this.eventLogger.record({
3351
+ level: "debug",
3352
+ event: "diagnostic.step",
3353
+ sessionId: this.sessionId,
3354
+ runId,
3355
+ fields: {
3356
+ phase: event.log.stage,
3357
+ durationMs: event.log.durationMs ?? 0,
3358
+ attempt: event.log.turn
3359
+ }
3360
+ });
1687
3361
  }
1688
3362
  if (event.type === "turn_finish") {
1689
3363
  this.lastMetrics = event.metrics;
@@ -1718,6 +3392,7 @@ var AgentSession = class {
1718
3392
  await this.persistAssistant(pendingAssistant);
1719
3393
  pendingAssistantPersisted = true;
1720
3394
  await this.persistToolMessages(event.messages);
3395
+ persistedHistoryLength = this.history.length;
1721
3396
  const releasable = pendingEvents;
1722
3397
  pendingAssistant = void 0;
1723
3398
  pendingEvents = [];
@@ -1729,9 +3404,14 @@ var AgentSession = class {
1729
3404
  } else if (event.type === "tool_messages") {
1730
3405
  await this.persistToolMessages(event.messages);
1731
3406
  }
3407
+ if (event.type === "message_stop" || event.type === "tool_messages") {
3408
+ persistedHistoryLength = this.history.length;
3409
+ }
1732
3410
  yield event;
1733
3411
  }
3412
+ loopCompleted = true;
1734
3413
  } catch (error) {
3414
+ loopFailed = true;
1735
3415
  if (pendingAssistant && !pendingPersistenceStarted) {
1736
3416
  const interruptedMessages = pendingClosedInMemory ? pendingToolMessages : this.closeInterruptedToolCalls(pendingAssistant, error);
1737
3417
  if (!pendingAssistantPersisted) {
@@ -1742,16 +3422,36 @@ var AgentSession = class {
1742
3422
  yield { type: "tool_messages", messages: interruptedMessages };
1743
3423
  }
1744
3424
  throw error;
3425
+ } finally {
3426
+ if (!loopCompleted && !loopFailed && this.store) {
3427
+ for (const message of this.history.slice(persistedHistoryLength)) {
3428
+ await this.store.append(message);
3429
+ }
3430
+ }
1745
3431
  }
1746
3432
  const metrics = finalizeRun(runStatus);
1747
3433
  this.lastRunMetrics = metrics;
1748
3434
  runFinalized = true;
3435
+ await this.eventLogger.record({
3436
+ level: "info",
3437
+ event: "run.finished",
3438
+ sessionId: this.sessionId,
3439
+ runId,
3440
+ fields: { status: metrics.status, durationMs: metrics.totalDurationMs }
3441
+ });
1749
3442
  yield { type: "run_finish", metrics };
1750
3443
  } catch (error) {
1751
3444
  const status = error instanceof AbortError || options?.signal?.aborted ? "aborted" : "failed";
1752
3445
  const metrics = finalizeRun(status);
1753
3446
  this.lastRunMetrics = metrics;
1754
3447
  runFinalized = true;
3448
+ await this.eventLogger.record({
3449
+ level: "error",
3450
+ event: "run.finished",
3451
+ sessionId: this.sessionId,
3452
+ runId,
3453
+ fields: { status: metrics.status, durationMs: metrics.totalDurationMs }
3454
+ });
1755
3455
  yield { type: "run_finish", metrics };
1756
3456
  throw error;
1757
3457
  } finally {
@@ -1768,8 +3468,8 @@ var AgentSession = class {
1768
3468
  for (const plugin of this.plugins) {
1769
3469
  try {
1770
3470
  await plugin.teardown?.();
1771
- } catch (err) {
1772
- this.logger?.(`Plugin '${plugin.name}' teardown failed: ${err.message}`);
3471
+ } catch {
3472
+ this.logger?.("Plugin teardown failed");
1773
3473
  }
1774
3474
  }
1775
3475
  this.plugins.length = 0;
@@ -1809,8 +3509,14 @@ var AgentSession = class {
1809
3509
  content: assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => ({
1810
3510
  type: "tool_result",
1811
3511
  toolUseId: toolUse.id,
1812
- content: `Tool execution interrupted before completion: ${error instanceof Error ? error.message : String(error)}`,
1813
- isError: true
3512
+ content: JSON.stringify({
3513
+ code: "OUTCOME_UNKNOWN",
3514
+ retryPolicy: "after_user_action",
3515
+ message: "Tool execution was interrupted; outcome is unknown"
3516
+ }),
3517
+ isError: true,
3518
+ errorCode: "OUTCOME_UNKNOWN",
3519
+ retryPolicy: "after_user_action"
1814
3520
  })),
1815
3521
  timestamp: Date.now()
1816
3522
  };
@@ -1841,32 +3547,50 @@ export {
1841
3547
  AgentSession,
1842
3548
  DEFAULT_CONTEXT_WINDOW_TOKENS,
1843
3549
  FatalError,
3550
+ FileLogSink,
1844
3551
  HookRegistry,
1845
3552
  JSONLMessageStore,
1846
3553
  KapibalaError,
1847
3554
  ModelError,
1848
3555
  OpenAICompatibleProvider,
1849
3556
  PathSandbox,
3557
+ PermissionPolicy,
1850
3558
  PromptAssembler,
3559
+ SessionApprovalCache,
1851
3560
  SessionBusyError,
1852
3561
  SimpleModelRouter,
3562
+ StructuredLogger,
1853
3563
  ToolError,
1854
3564
  ToolExecutor,
1855
3565
  ToolNotFound,
1856
3566
  ToolRegistry,
1857
3567
  TransportError,
1858
3568
  builtinTools,
3569
+ classifyBashOutput,
3570
+ cleanupCommandResults,
3571
+ commandAuditSummary,
3572
+ createDefaultLogSinks,
3573
+ createLogId,
3574
+ createRunCommandTool,
1859
3575
  defineTool,
3576
+ detectShell,
1860
3577
  editFileTool,
3578
+ executionFingerprint,
1861
3579
  globTool,
1862
3580
  grepTool,
1863
3581
  isTextBlock,
1864
3582
  isThinkingBlock,
1865
3583
  isToolResultBlock,
1866
3584
  isToolUseBlock,
3585
+ loadInstructions,
3586
+ projectSafeFields,
1867
3587
  readFileTool,
3588
+ recoverIncompleteAudit,
1868
3589
  resolveContextWindow,
3590
+ stableJsonStringify,
1869
3591
  toToolDefinition,
3592
+ validatePermissionRules,
3593
+ visibleTools,
1870
3594
  writeFileTool
1871
3595
  };
1872
3596
  //# sourceMappingURL=index.js.map