@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/README.md +3 -1
- package/dist/index.cjs +1859 -117
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +354 -22
- package/dist/index.d.ts +354 -22
- package/dist/index.js +1841 -117
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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/
|
|
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
|
|
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 =
|
|
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 =
|
|
114
|
-
const relative =
|
|
115
|
-
if (relative.startsWith("..") ||
|
|
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 =
|
|
122
|
-
if (physicalRelative.startsWith("..") ||
|
|
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 =
|
|
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 ?
|
|
1189
|
+
return pendingSegments.length > 0 ? path5.join(real, ...pendingSegments) : real;
|
|
156
1190
|
}
|
|
157
|
-
const parent =
|
|
1191
|
+
const parent = path5.dirname(probe);
|
|
158
1192
|
if (parent === probe) {
|
|
159
1193
|
return resolvedPath;
|
|
160
1194
|
}
|
|
161
|
-
pendingSegments.unshift(
|
|
1195
|
+
pendingSegments.unshift(path5.basename(probe));
|
|
162
1196
|
probe = parent;
|
|
163
1197
|
}
|
|
164
1198
|
}
|
|
165
1199
|
};
|
|
166
1200
|
|
|
167
|
-
// src/
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
237
|
-
import
|
|
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 (!
|
|
1425
|
+
if (!fs6.existsSync(safePath)) {
|
|
276
1426
|
throw new ToolError(`File not found: ${input.path}`);
|
|
277
1427
|
}
|
|
278
|
-
const stat =
|
|
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 =
|
|
1441
|
+
const fileDescriptor = fs6.openSync(safePath, "r");
|
|
292
1442
|
try {
|
|
293
|
-
|
|
1443
|
+
fs6.readSync(fileDescriptor, prefix, 0, prefixSize, 0);
|
|
294
1444
|
} finally {
|
|
295
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
382
|
-
if (!
|
|
383
|
-
|
|
1531
|
+
const dir = path7.dirname(safePath);
|
|
1532
|
+
if (!fs6.existsSync(dir)) {
|
|
1533
|
+
fs6.mkdirSync(dir, { recursive: true });
|
|
384
1534
|
}
|
|
385
|
-
|
|
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 (!
|
|
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 =
|
|
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
|
-
|
|
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 (!
|
|
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 =
|
|
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 =
|
|
477
|
-
const relPath =
|
|
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 (!
|
|
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 =
|
|
530
|
-
const relPath =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
685
|
-
|
|
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
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
|
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
|
|
1082
|
-
import
|
|
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 =
|
|
1087
|
-
const dir =
|
|
1088
|
-
if (!
|
|
1089
|
-
|
|
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
|
-
|
|
2560
|
+
fs7.appendFileSync(this.filePath, `${JSON.stringify(record)}
|
|
1099
2561
|
`, "utf-8");
|
|
1100
2562
|
}
|
|
1101
2563
|
async load() {
|
|
1102
|
-
if (!
|
|
2564
|
+
if (!fs7.existsSync(this.filePath)) {
|
|
1103
2565
|
return [];
|
|
1104
2566
|
}
|
|
1105
|
-
const content =
|
|
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 (
|
|
1128
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
|
1772
|
-
this.logger?.(
|
|
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:
|
|
1813
|
-
|
|
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
|