@sema-agent/core 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/background-agent-store.d.ts +3 -0
- package/dist/core/mcp.d.ts +9 -0
- package/dist/core/mcp.js +56 -5
- package/dist/core/runner/prepare-task.js +25 -3
- package/dist/core/runner/runtask.js +3 -0
- package/dist/core/runner/tool-disclosure.d.ts +8 -2
- package/dist/core/runner/tool-disclosure.js +23 -6
- package/dist/core/skills-directory.d.ts +13 -0
- package/dist/core/skills-directory.js +214 -0
- package/dist/core/task-registry-agent.js +25 -2
- package/dist/core/trace.d.ts +3 -0
- package/dist/core/types.d.ts +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/tools/fs/bash-readonly-classifier.d.ts +13 -1
- package/dist/tools/fs/bash-readonly-classifier.js +126 -11
- package/dist/tools/fs/fs-bash.d.ts +2 -1
- package/dist/tools/fs/fs-bash.js +3 -2
- package/package.json +1 -1
|
@@ -37,6 +37,9 @@ export interface BackgroundAgentRecord {
|
|
|
37
37
|
finalOutput?: string;
|
|
38
38
|
finalOutputFull?: string;
|
|
39
39
|
error?: string;
|
|
40
|
+
errorCode?: string;
|
|
41
|
+
errorRetryable?: boolean;
|
|
42
|
+
errorKind?: string;
|
|
40
43
|
resultIsPartial?: boolean;
|
|
41
44
|
recentSteps?: SubagentStep[];
|
|
42
45
|
editedFiles?: SubagentEditedFile[];
|
package/dist/core/mcp.d.ts
CHANGED
|
@@ -68,6 +68,7 @@ export declare function mcpToolTotalTimeoutMs(perCallMs: number): number;
|
|
|
68
68
|
export declare const MCP_IDLE_TIMEOUT_STDIO_DEFAULT_MS: number;
|
|
69
69
|
export declare const MCP_IDLE_TIMEOUT_HTTP_DEFAULT_MS: number;
|
|
70
70
|
export declare function mcpIdleTimeoutMs(kind: "stdio" | "http"): number;
|
|
71
|
+
export declare function describeMcpSpecErrorCode(code: unknown): string | undefined;
|
|
71
72
|
export declare function normalizeMcpName(name: string): string;
|
|
72
73
|
export declare function clampNameSegment(seg: string, max?: number): string;
|
|
73
74
|
export * from "./image-downsample.js";
|
|
@@ -106,3 +107,11 @@ export type McpSchemaNormalizeResult = {
|
|
|
106
107
|
export declare function normalizeMcpToolSchema(schema: unknown): McpSchemaNormalizeResult;
|
|
107
108
|
export declare function mcpToolSchemaProblem(schema: unknown): string | undefined;
|
|
108
109
|
export declare function materializeMcpTools(specs: McpServerSpec[], principal?: string, onElicit?: OnElicit, imageResizer?: McpImageResizer): Promise<MaterializedMcp>;
|
|
110
|
+
export declare function classifyDirReadInvalidParams(message: string): "not_found" | "not_directory";
|
|
111
|
+
export declare function parseCallToolResultLenient(data: unknown): {
|
|
112
|
+
success: true;
|
|
113
|
+
data: unknown;
|
|
114
|
+
} | {
|
|
115
|
+
success: false;
|
|
116
|
+
error: unknown;
|
|
117
|
+
};
|
package/dist/core/mcp.js
CHANGED
|
@@ -5,7 +5,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|
|
5
5
|
import { lstat, mkdir, writeFile } from "node:fs/promises";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
-
import { ElicitRequestSchema, ErrorCode, ListResourcesResultSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
import { CallToolResultSchema, ElicitRequestSchema, ErrorCode, ListResourcesResultSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
9
9
|
import { MCP_IMAGE_MAX_BASE64, sharpImageResizer } from "./image-downsample.js";
|
|
10
10
|
import { truncateError } from "./tool-errors.js";
|
|
11
11
|
import { delimitUntrusted, inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
|
|
@@ -137,6 +137,14 @@ function armMcpIdleWatchdog(health, idleMs, outerSignal) {
|
|
|
137
137
|
function mcpStartupTimeoutMs() {
|
|
138
138
|
return parseEnvMs("MCP_TIMEOUT");
|
|
139
139
|
}
|
|
140
|
+
const MCP_SPEC_ERROR_CODE_NAMES = new Map([
|
|
141
|
+
[-32020, "header mismatch (the server saw an HTTP header that disagreed with the tool parameter mapped onto it)"],
|
|
142
|
+
[-32021, "missing required client capability (the server requires a capability this client does not declare)"],
|
|
143
|
+
[-32022, "unsupported protocol version (the server implements no protocol revision this client offers)"],
|
|
144
|
+
]);
|
|
145
|
+
export function describeMcpSpecErrorCode(code) {
|
|
146
|
+
return typeof code === "number" ? MCP_SPEC_ERROR_CODE_NAMES.get(code) : undefined;
|
|
147
|
+
}
|
|
140
148
|
function isTransportLost(err) {
|
|
141
149
|
if (err instanceof McpError && err.code === ErrorCode.ConnectionClosed)
|
|
142
150
|
return true;
|
|
@@ -180,6 +188,15 @@ function rethrowHonestMcpError(err, ctx) {
|
|
|
180
188
|
e.details = { transportLost: true, server: ctx.server };
|
|
181
189
|
throw e;
|
|
182
190
|
}
|
|
191
|
+
if (err instanceof McpError) {
|
|
192
|
+
const condition = describeMcpSpecErrorCode(err.code);
|
|
193
|
+
if (condition !== undefined) {
|
|
194
|
+
const e = new Error(`${ctx.what} on MCP server "${serverLabel}" was rejected with MCP protocol error ${err.code} — ${condition}. The server's error text follows as external/untrusted data:\n${delimitUntrusted(`${ctx.server} error`, truncateMcpErrorText(err.message))}`, { cause: err });
|
|
195
|
+
e.errorKind = "protocol_error";
|
|
196
|
+
e.details = { server: ctx.server, specErrorCode: err.code };
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
183
200
|
if (ctx.attributeServer) {
|
|
184
201
|
const msg = err instanceof Error ? err.message : String(err);
|
|
185
202
|
throw new Error(`${ctx.what} on MCP server "${serverLabel}" failed. The server's error text follows as external/untrusted data:\n${delimitUntrusted(`${ctx.server} error`, truncateMcpErrorText(msg))}`, { cause: err });
|
|
@@ -653,6 +670,15 @@ const LEGACY_READ_MCP_RESOURCE = "ReadMcpResource";
|
|
|
653
670
|
const LEGACY_READ_MCP_RESOURCE_DIR = "ReadMcpResourceDir";
|
|
654
671
|
const MCP_SKILLS_EXTENSION = "io.modelcontextprotocol/skills";
|
|
655
672
|
const MAX_DIR_READ_PAGES = 20;
|
|
673
|
+
const DIR_READ_NOT_A_DIRECTORY_RE = /not a directory|isn'?t a directory|not a folder/i;
|
|
674
|
+
const DIR_READ_NOT_FOUND_RE = /not found|no such|does not exist|doesn'?t exist|unknown (?:resource|uri)/i;
|
|
675
|
+
export function classifyDirReadInvalidParams(message) {
|
|
676
|
+
if (DIR_READ_NOT_A_DIRECTORY_RE.test(message))
|
|
677
|
+
return "not_directory";
|
|
678
|
+
if (DIR_READ_NOT_FOUND_RE.test(message))
|
|
679
|
+
return "not_found";
|
|
680
|
+
return "not_directory";
|
|
681
|
+
}
|
|
656
682
|
function resourceLine(r) {
|
|
657
683
|
return `${r.uri}${r.name ? ` — ${r.name}` : ""}${r.mimeType ? ` (${r.mimeType})` : ""}${r.description ? `: ${r.description}` : ""}`;
|
|
658
684
|
}
|
|
@@ -672,7 +698,7 @@ async function readDirViaExtension(rs, uri, signal, timeoutMs, watchdog) {
|
|
|
672
698
|
if (err instanceof McpError && err.code === ErrorCode.InvalidParams) {
|
|
673
699
|
if (pages > 0)
|
|
674
700
|
return { kind: "ok", resources, cursorInvalid: true };
|
|
675
|
-
return { kind:
|
|
701
|
+
return { kind: classifyDirReadInvalidParams(err.message), detail: err.message };
|
|
676
702
|
}
|
|
677
703
|
if (err instanceof McpError && err.code === ErrorCode.MethodNotFound)
|
|
678
704
|
return { kind: "unsupported" };
|
|
@@ -866,9 +892,22 @@ function buildResourceTools(resourceServers) {
|
|
|
866
892
|
const ext = rs.client.getServerCapabilities()?.extensions?.[MCP_SKILLS_EXTENSION];
|
|
867
893
|
if (ext?.directoryRead === true) {
|
|
868
894
|
const r = await readDirViaExtension(rs, uri, signal, timeoutMs, watchdog).catch((err) => rethrowHonestMcpError(err, { server, what, timeoutMs, writeEffect: false, signal, attributeServer: true, idle: { signal: watchdog.idleSignal, idleMs } }));
|
|
895
|
+
const serverSaid = (detail) => `\nThe server's error text follows as external/untrusted data:\n${delimitUntrusted(`${server} error`, truncateMcpErrorText(detail))}`;
|
|
896
|
+
if (r.kind === "not_found") {
|
|
897
|
+
return {
|
|
898
|
+
content: [
|
|
899
|
+
{
|
|
900
|
+
type: "text",
|
|
901
|
+
text: `Resource not found: ${inlineUntrusted(uri)} — the server reports no such resource, so re-reading it will not help either. Use ${LIST_MCP_RESOURCES} to see what this server exposes.${serverSaid(r.detail)}`,
|
|
902
|
+
},
|
|
903
|
+
],
|
|
904
|
+
details: { resources: [], notFound: true },
|
|
905
|
+
terminate: false,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
869
908
|
if (r.kind === "not_directory") {
|
|
870
909
|
return {
|
|
871
|
-
content: [{ type: "text", text: `Not a directory resource: ${inlineUntrusted(uri)}. If it is a file resource, use ${READ_MCP_RESOURCE} instead
|
|
910
|
+
content: [{ type: "text", text: `Not a directory resource: ${inlineUntrusted(uri)}. If it is a file resource, use ${READ_MCP_RESOURCE} instead.${serverSaid(r.detail)}` }],
|
|
872
911
|
details: { resources: [] },
|
|
873
912
|
terminate: false,
|
|
874
913
|
};
|
|
@@ -929,6 +968,17 @@ const LenientListToolsResultSchema = {
|
|
|
929
968
|
return { success: true, data: { tools, ...(typeof nextCursor === "string" ? { nextCursor } : {}) } };
|
|
930
969
|
},
|
|
931
970
|
};
|
|
971
|
+
export function parseCallToolResultLenient(data) {
|
|
972
|
+
if (typeof data !== "object" || data === null || Array.isArray(data) || !("structuredContent" in data)) {
|
|
973
|
+
return CallToolResultSchema.safeParse(data);
|
|
974
|
+
}
|
|
975
|
+
const { structuredContent, ...rest } = data;
|
|
976
|
+
const parsed = CallToolResultSchema.safeParse(rest);
|
|
977
|
+
if (!parsed.success)
|
|
978
|
+
return parsed;
|
|
979
|
+
return { success: true, data: { ...parsed.data, structuredContent } };
|
|
980
|
+
}
|
|
981
|
+
const LENIENT_CALL_TOOL_RESULT_SCHEMA = { safeParse: parseCallToolResultLenient };
|
|
932
982
|
async function listToolsLenient(client, options) {
|
|
933
983
|
return client.request({ method: "tools/list", params: {} }, LenientListToolsResultSchema, options);
|
|
934
984
|
}
|
|
@@ -1059,7 +1109,7 @@ function intakeListedTools(listed, spec, client, health, imageResizer) {
|
|
|
1059
1109
|
let res;
|
|
1060
1110
|
try {
|
|
1061
1111
|
res = await client
|
|
1062
|
-
.callTool({ name: remoteName, arguments: (params ?? {}) },
|
|
1112
|
+
.callTool({ name: remoteName, arguments: (params ?? {}) }, LENIENT_CALL_TOOL_RESULT_SCHEMA, {
|
|
1063
1113
|
signal: watchdog.combinedSignal,
|
|
1064
1114
|
timeout: timeoutMs,
|
|
1065
1115
|
resetTimeoutOnProgress: true,
|
|
@@ -1106,7 +1156,8 @@ function intakeListedTools(listed, spec, client, health, imageResizer) {
|
|
|
1106
1156
|
}
|
|
1107
1157
|
function asServerWarning(spec, err) {
|
|
1108
1158
|
const detail = err instanceof Error ? err.message : String(err);
|
|
1109
|
-
const
|
|
1159
|
+
const condition = err instanceof McpError ? describeMcpSpecErrorCode(err.code) : undefined;
|
|
1160
|
+
const warning = new Error(`mcp: server "${spec.name}" failed to connect — skipped (${condition !== undefined ? `${condition}: ` : ""}${detail})`, { cause: err });
|
|
1110
1161
|
warning.code = "mcp.server_unavailable";
|
|
1111
1162
|
return warning;
|
|
1112
1163
|
}
|
|
@@ -1225,14 +1225,18 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
1225
1225
|
shellGatedBash = !egressTools.has("Bash") && !irreversibilityTier.has("Bash");
|
|
1226
1226
|
irreversibilityTier.set("Bash", shellGate === "always" ? "always" : "maybe");
|
|
1227
1227
|
irreversibleTools.add("Bash");
|
|
1228
|
+
const shellReadBoundary = () => ({
|
|
1229
|
+
roots: [rootCanonical, ...additionalRootsCanonical],
|
|
1230
|
+
...(handsCwdRef?.current !== undefined ? { cwd: handsCwdRef.current } : {}),
|
|
1231
|
+
});
|
|
1228
1232
|
if (shellGate === "classify")
|
|
1229
|
-
reversibilityProbes.set("Bash", bashReversibilityProbe());
|
|
1233
|
+
reversibilityProbes.set("Bash", bashReversibilityProbe(undefined, shellReadBoundary));
|
|
1230
1234
|
if (backgroundTaskToolsActive) {
|
|
1231
1235
|
shellGatedMonitor = !egressTools.has("Monitor") && !irreversibilityTier.has("Monitor");
|
|
1232
1236
|
irreversibilityTier.set("Monitor", shellGate === "always" ? "always" : "maybe");
|
|
1233
1237
|
irreversibleTools.add("Monitor");
|
|
1234
1238
|
if (shellGate === "classify")
|
|
1235
|
-
reversibilityProbes.set("Monitor", bashReversibilityProbe());
|
|
1239
|
+
reversibilityProbes.set("Monitor", bashReversibilityProbe(undefined, shellReadBoundary));
|
|
1236
1240
|
}
|
|
1237
1241
|
}
|
|
1238
1242
|
}
|
|
@@ -1826,7 +1830,25 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
1826
1830
|
throw e;
|
|
1827
1831
|
}
|
|
1828
1832
|
const registry = buildDeferredRegistry(deferred, tools);
|
|
1829
|
-
const
|
|
1833
|
+
const realByName = new Map(tools.map((t) => [t.name, t]));
|
|
1834
|
+
const directCallFor = (name) => {
|
|
1835
|
+
if (spec.deferSelfResolve === false)
|
|
1836
|
+
return undefined;
|
|
1837
|
+
const real = realByName.get(name);
|
|
1838
|
+
if (real === undefined)
|
|
1839
|
+
return undefined;
|
|
1840
|
+
return {
|
|
1841
|
+
parameters: real.parameters,
|
|
1842
|
+
invoke: (toolCallId, params, signal) => real.execute(toolCallId, params, signal),
|
|
1843
|
+
activate: async () => {
|
|
1844
|
+
if (activeTools.has(name))
|
|
1845
|
+
return;
|
|
1846
|
+
activeTools.add(name);
|
|
1847
|
+
await rematerialize(activeTools);
|
|
1848
|
+
},
|
|
1849
|
+
};
|
|
1850
|
+
};
|
|
1851
|
+
const placeholders = new Map([...registry.values()].map((i) => [i.name, createPlaceholderTool(i, directCallFor(i.name))]));
|
|
1830
1852
|
const { messages } = await session.buildContext();
|
|
1831
1853
|
for (const n of extractDiscoveredToolNames(messages, registry)) {
|
|
1832
1854
|
if (deferred.has(n))
|
|
@@ -844,6 +844,7 @@ function makeHarnessHandlers(prepared, stats, rs, deps) {
|
|
|
844
844
|
taskId: rs.telemetry.taskId,
|
|
845
845
|
model: m.model,
|
|
846
846
|
provider: m.provider,
|
|
847
|
+
turn: stats.turns + 1,
|
|
847
848
|
promptTokens: turnInput,
|
|
848
849
|
completionTokens: u.output || 0,
|
|
849
850
|
cacheRead: u.cacheRead || 0,
|
|
@@ -1011,6 +1012,8 @@ function makeHarnessHandlers(prepared, stats, rs, deps) {
|
|
|
1011
1012
|
version: 1,
|
|
1012
1013
|
taskId: rs.telemetry.taskId,
|
|
1013
1014
|
name: event.toolName,
|
|
1015
|
+
toolCallId: event.toolCallId,
|
|
1016
|
+
turn: stats.turns + 1,
|
|
1014
1017
|
durationMs: toolStarted !== undefined ? toolNow - toolStarted : 0,
|
|
1015
1018
|
ok: !event.isError,
|
|
1016
1019
|
ts: toolNow,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { type TSchema } from "typebox";
|
|
2
|
+
import type { AgentMessage, AgentTool, AgentToolResult } from "../../internal/harness-types.js";
|
|
2
3
|
import type { Model } from "../../internal/llm.js";
|
|
3
4
|
import type { ToolSpec } from "../types.js";
|
|
4
5
|
export declare const TOOL_SEARCH_NAME = "ToolSearch";
|
|
@@ -27,7 +28,12 @@ export declare function buildDeferredRegistry(deferred: ReadonlySet<string>, too
|
|
|
27
28
|
name: string;
|
|
28
29
|
description: string;
|
|
29
30
|
}>): Map<string, DeferredToolInfo>;
|
|
30
|
-
export
|
|
31
|
+
export interface PlaceholderDirectCall {
|
|
32
|
+
parameters: TSchema;
|
|
33
|
+
invoke: (toolCallId: string, params: unknown, signal?: AbortSignal) => Promise<AgentToolResult<unknown>>;
|
|
34
|
+
activate: () => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export declare function createPlaceholderTool(info: DeferredToolInfo, direct?: PlaceholderDirectCall): AgentTool;
|
|
31
37
|
export declare function scoreToolMatch(query: string, info: DeferredToolInfo): number;
|
|
32
38
|
export interface ToolSearchArgs {
|
|
33
39
|
query?: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
+
import { Value } from "typebox/value";
|
|
2
3
|
import { defineTool } from "../tools.js";
|
|
3
4
|
export const TOOL_SEARCH_NAME = "ToolSearch";
|
|
4
5
|
const DEFER_AUTO_FRACTION = 0.1;
|
|
@@ -59,17 +60,33 @@ export function buildDeferredRegistry(deferred, tools) {
|
|
|
59
60
|
}
|
|
60
61
|
return reg;
|
|
61
62
|
}
|
|
62
|
-
export function createPlaceholderTool(info) {
|
|
63
|
+
export function createPlaceholderTool(info, direct) {
|
|
63
64
|
const sn = safeName(info.name);
|
|
65
|
+
const teachingRejection = () => {
|
|
66
|
+
throw new Error(`Tool "${sn}" is not active yet. Call ${TOOL_SEARCH_NAME} with {"query":"select:${sn}"} ` +
|
|
67
|
+
`(or a keyword \`query\`) to load its full schema, then call ${sn} with the proper arguments.`);
|
|
68
|
+
};
|
|
69
|
+
if (direct !== undefined) {
|
|
70
|
+
return {
|
|
71
|
+
name: info.name,
|
|
72
|
+
label: info.name,
|
|
73
|
+
description: `${info.hint} — deferred: call ${TOOL_SEARCH_NAME}({"query":"select:${sn}"}) to load its parameters before use.`,
|
|
74
|
+
parameters: EMPTY_PARAMS,
|
|
75
|
+
execute: async (toolCallId, params, signal) => {
|
|
76
|
+
if (Value.Check(direct.parameters, params)) {
|
|
77
|
+
await direct.activate();
|
|
78
|
+
return direct.invoke(toolCallId, params, signal);
|
|
79
|
+
}
|
|
80
|
+
return teachingRejection();
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
64
84
|
return defineTool({
|
|
65
85
|
name: info.name,
|
|
66
86
|
description: `${info.hint} — deferred: call ${TOOL_SEARCH_NAME}({"query":"select:${sn}"}) to load its parameters before use.`,
|
|
67
87
|
parameters: EMPTY_PARAMS,
|
|
68
88
|
effect: "read",
|
|
69
|
-
execute: () =>
|
|
70
|
-
throw new Error(`Tool "${sn}" is not active yet. Call ${TOOL_SEARCH_NAME} with {"query":"select:${sn}"} ` +
|
|
71
|
-
`(or a keyword \`query\`) to load its full schema, then call ${sn} with the proper arguments.`);
|
|
72
|
-
},
|
|
89
|
+
execute: () => teachingRejection(),
|
|
73
90
|
});
|
|
74
91
|
}
|
|
75
92
|
export function scoreToolMatch(query, info) {
|
|
@@ -193,7 +210,7 @@ export function createToolSearchTool(opts) {
|
|
|
193
210
|
: "";
|
|
194
211
|
const missingNote = callableNote +
|
|
195
212
|
(missUnknown.length > 0
|
|
196
|
-
? `\nNot found: ${missUnknown.map((n) => safeName(n)).join(", ")} —
|
|
213
|
+
? `\nNot found: ${missUnknown.map((n) => safeName(n)).join(", ")} — not in the deferred registry under this exact name (lookup is case-sensitive). ` +
|
|
197
214
|
"(Already-active and non-deferred tools are callable directly and don't appear here.)"
|
|
198
215
|
: "");
|
|
199
216
|
if (matched.length === 0) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SkillSpec } from "./types.js";
|
|
2
|
+
export type SkillsDirectoryWarningCode = "no_skill_file" | "no_frontmatter" | "missing_name" | "invalid_name" | "name_mismatch" | "missing_description" | "description_too_long" | "allowed_tool_not_mounted" | "attachment_skipped" | "read_failed";
|
|
3
|
+
export interface SkillsDirectoryWarning {
|
|
4
|
+
code: SkillsDirectoryWarningCode;
|
|
5
|
+
skill: string;
|
|
6
|
+
detail: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SkillsDirectoryOptions {
|
|
9
|
+
deployedTools?: readonly string[];
|
|
10
|
+
onWarning?: (warning: SkillsDirectoryWarning) => void;
|
|
11
|
+
maxAttachmentBytes?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function createSkillsFromDirectory(dir: string, options?: SkillsDirectoryOptions): SkillSpec[];
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const SKILL_FILE = "SKILL.md";
|
|
4
|
+
const RESOURCE_DIRS = ["assets", "references", "scripts"];
|
|
5
|
+
const NAME_MAX_CHARS = 64;
|
|
6
|
+
const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7
|
+
const DESCRIPTION_MAX_CHARS = 1024;
|
|
8
|
+
const DEFAULT_MAX_ATTACHMENT_BYTES = 256 * 1024;
|
|
9
|
+
const FENCE = "---";
|
|
10
|
+
function parseSkillFile(text) {
|
|
11
|
+
const fields = new Map();
|
|
12
|
+
const normalized = text.replace(/\r\n/g, "\n");
|
|
13
|
+
const lines = normalized.split("\n");
|
|
14
|
+
if (lines[0]?.trim() !== FENCE)
|
|
15
|
+
return { fields, body: normalized, hadFrontmatter: false };
|
|
16
|
+
let end = -1;
|
|
17
|
+
for (let i = 1; i < lines.length; i++) {
|
|
18
|
+
if (lines[i].trim() === FENCE) {
|
|
19
|
+
end = i;
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (end === -1)
|
|
24
|
+
return { fields, body: normalized, hadFrontmatter: false };
|
|
25
|
+
for (let i = 1; i < end; i++) {
|
|
26
|
+
const line = lines[i];
|
|
27
|
+
const trimmed = line.trim();
|
|
28
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
29
|
+
continue;
|
|
30
|
+
if (/^\s/.test(line))
|
|
31
|
+
continue;
|
|
32
|
+
const kv = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(trimmed);
|
|
33
|
+
if (!kv)
|
|
34
|
+
continue;
|
|
35
|
+
const [, key, raw] = kv;
|
|
36
|
+
if (fields.has(key))
|
|
37
|
+
continue;
|
|
38
|
+
fields.set(key, unquote(raw.trim()));
|
|
39
|
+
}
|
|
40
|
+
let body = lines.slice(end + 1).join("\n");
|
|
41
|
+
if (body.startsWith("\n"))
|
|
42
|
+
body = body.slice(1);
|
|
43
|
+
return { fields, body, hadFrontmatter: true };
|
|
44
|
+
}
|
|
45
|
+
function unquote(value) {
|
|
46
|
+
if (value.length < 2)
|
|
47
|
+
return value;
|
|
48
|
+
const q = value[0];
|
|
49
|
+
if ((q !== '"' && q !== "'") || value[value.length - 1] !== q)
|
|
50
|
+
return value;
|
|
51
|
+
const inner = value.slice(1, -1);
|
|
52
|
+
return q === '"' ? inner.replace(/\\t/g, "\t").replace(/\\n/g, "\n") : inner;
|
|
53
|
+
}
|
|
54
|
+
function listRelativeFiles(base, dir, prefix, out) {
|
|
55
|
+
let entries;
|
|
56
|
+
try {
|
|
57
|
+
entries = readdirSync(join(base, dir), { withFileTypes: true });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
63
|
+
const rel = `${prefix}${e.name}`;
|
|
64
|
+
if (e.isDirectory())
|
|
65
|
+
listRelativeFiles(base, join(dir, e.name), `${rel}/`, out);
|
|
66
|
+
else if (e.isFile())
|
|
67
|
+
out.push(rel);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function readAttachments(skillDir, skillName, budgetBytes, warn) {
|
|
71
|
+
const relPaths = [];
|
|
72
|
+
for (const d of RESOURCE_DIRS) {
|
|
73
|
+
let st;
|
|
74
|
+
try {
|
|
75
|
+
st = statSync(join(skillDir, d));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (st.isDirectory())
|
|
81
|
+
listRelativeFiles(skillDir, d, `${d}/`, relPaths);
|
|
82
|
+
}
|
|
83
|
+
relPaths.sort();
|
|
84
|
+
const files = [];
|
|
85
|
+
let spent = 0;
|
|
86
|
+
for (const rel of relPaths) {
|
|
87
|
+
let bytes;
|
|
88
|
+
try {
|
|
89
|
+
bytes = readFileSync(join(skillDir, rel));
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
warn({ code: "attachment_skipped", skill: skillName, detail: `${rel}: unreadable (${errText(err)})` });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!isDecodableText(bytes)) {
|
|
96
|
+
warn({ code: "attachment_skipped", skill: skillName, detail: `${rel}: not text (attachment content is a string; binary is not carried)` });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (spent + bytes.byteLength > budgetBytes) {
|
|
100
|
+
warn({ code: "attachment_skipped", skill: skillName, detail: `${rel}: over the ${budgetBytes}-byte attachment budget for this skill` });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
spent += bytes.byteLength;
|
|
104
|
+
files.push({ path: rel, content: bytes.toString("utf8") });
|
|
105
|
+
}
|
|
106
|
+
return files;
|
|
107
|
+
}
|
|
108
|
+
function isDecodableText(bytes) {
|
|
109
|
+
if (bytes.includes(0))
|
|
110
|
+
return false;
|
|
111
|
+
const decoded = bytes.toString("utf8");
|
|
112
|
+
return Buffer.byteLength(decoded, "utf8") === bytes.byteLength;
|
|
113
|
+
}
|
|
114
|
+
function errText(err) {
|
|
115
|
+
return err instanceof Error ? err.message : String(err);
|
|
116
|
+
}
|
|
117
|
+
function manifestFromAllowedTools(declared, skillName, deployedTools, warn) {
|
|
118
|
+
const names = [];
|
|
119
|
+
for (const n of declared.split(/\s+/)) {
|
|
120
|
+
if (n !== "" && !names.includes(n))
|
|
121
|
+
names.push(n);
|
|
122
|
+
}
|
|
123
|
+
if (names.length === 0)
|
|
124
|
+
return undefined;
|
|
125
|
+
let allowTools = names;
|
|
126
|
+
if (deployedTools !== undefined) {
|
|
127
|
+
const mounted = new Set(deployedTools);
|
|
128
|
+
allowTools = names.filter((n) => mounted.has(n));
|
|
129
|
+
for (const n of names) {
|
|
130
|
+
if (!mounted.has(n)) {
|
|
131
|
+
warn({
|
|
132
|
+
code: "allowed_tool_not_mounted",
|
|
133
|
+
skill: skillName,
|
|
134
|
+
detail: `allowed-tools names "${n}", which this deployment does not mount — dropped (a skill declaration may only narrow capability, never add a tool)`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { allowTools, lineageId: `skill:${skillName}` };
|
|
140
|
+
}
|
|
141
|
+
export function createSkillsFromDirectory(dir, options = {}) {
|
|
142
|
+
const warn = (w) => {
|
|
143
|
+
options.onWarning?.(w);
|
|
144
|
+
};
|
|
145
|
+
const budget = options.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES;
|
|
146
|
+
let entries;
|
|
147
|
+
try {
|
|
148
|
+
const st = statSync(dir);
|
|
149
|
+
if (!st.isDirectory())
|
|
150
|
+
throw new Error("not a directory");
|
|
151
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
throw new Error(`skills directory could not be read: ${dir} (${errText(err)})`);
|
|
155
|
+
}
|
|
156
|
+
const dirNames = entries
|
|
157
|
+
.filter((e) => e.isDirectory())
|
|
158
|
+
.map((e) => e.name)
|
|
159
|
+
.sort();
|
|
160
|
+
const skills = [];
|
|
161
|
+
for (const name of dirNames) {
|
|
162
|
+
const skillDir = join(dir, name);
|
|
163
|
+
let text;
|
|
164
|
+
try {
|
|
165
|
+
text = readFileSync(join(skillDir, SKILL_FILE), "utf8");
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
warn({ code: "no_skill_file", skill: name, detail: `no ${SKILL_FILE} in this directory — skipped` });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const parsed = parseSkillFile(text);
|
|
172
|
+
if (!parsed.hadFrontmatter) {
|
|
173
|
+
warn({ code: "no_frontmatter", skill: name, detail: `${SKILL_FILE} has no fenced --- frontmatter block — skipped` });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const declaredName = parsed.fields.get("name");
|
|
177
|
+
if (declaredName === undefined || declaredName === "") {
|
|
178
|
+
warn({ code: "missing_name", skill: name, detail: "frontmatter has no `name` (required) — skipped" });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (declaredName.length > NAME_MAX_CHARS || !NAME_RE.test(declaredName)) {
|
|
182
|
+
warn({
|
|
183
|
+
code: "invalid_name",
|
|
184
|
+
skill: name,
|
|
185
|
+
detail: `\`name\` must be ≤${NAME_MAX_CHARS} chars of lowercase alphanumerics in hyphen-separated runs — skipped`,
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (declaredName !== name) {
|
|
190
|
+
warn({ code: "name_mismatch", skill: name, detail: `frontmatter \`name\` is "${declaredName}" but the directory is "${name}" — skipped` });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const description = parsed.fields.get("description");
|
|
194
|
+
if (description === undefined || description === "") {
|
|
195
|
+
warn({ code: "missing_description", skill: name, detail: "frontmatter has no `description` (required) — skipped" });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (description.length > DESCRIPTION_MAX_CHARS) {
|
|
199
|
+
warn({ code: "description_too_long", skill: name, detail: `\`description\` is ${description.length} chars, over the ${DESCRIPTION_MAX_CHARS} cap — skipped` });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const allowed = parsed.fields.get("allowed-tools");
|
|
203
|
+
const manifest = allowed === undefined ? undefined : manifestFromAllowedTools(allowed, name, options.deployedTools, warn);
|
|
204
|
+
const files = readAttachments(skillDir, name, budget, warn);
|
|
205
|
+
skills.push({
|
|
206
|
+
name: declaredName,
|
|
207
|
+
description,
|
|
208
|
+
content: parsed.body,
|
|
209
|
+
...(manifest !== undefined ? { manifest } : {}),
|
|
210
|
+
...(files.length > 0 ? { files } : {}),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return skills;
|
|
214
|
+
}
|
|
@@ -723,6 +723,9 @@ export function settleBackgroundAgentLane(core, id, outcome) {
|
|
|
723
723
|
...(handle.resultFull !== undefined ? { finalOutputFull: handle.resultFull } : {}),
|
|
724
724
|
...(handle.resultIsPartial ? { resultIsPartial: true } : {}),
|
|
725
725
|
...(handle.error !== undefined ? { error: handle.error } : {}),
|
|
726
|
+
...(handle.errorCode !== undefined ? { errorCode: handle.errorCode } : {}),
|
|
727
|
+
...(handle.errorRetryable !== undefined ? { errorRetryable: handle.errorRetryable } : {}),
|
|
728
|
+
...(handle.errorKind !== undefined ? { errorKind: handle.errorKind } : {}),
|
|
726
729
|
}, ["parkedCheckpointToken", "parkClaimId", "parkedAt"]);
|
|
727
730
|
return outcome.status;
|
|
728
731
|
}
|
|
@@ -856,7 +859,22 @@ export function reviveBackgroundAgentLane(core, id, access, abort) {
|
|
|
856
859
|
handle.reviveCycle = (handle.reviveCycle ?? 0) + 1;
|
|
857
860
|
handle.cycleSeq = (handle.cycleSeq ?? 1) + 1;
|
|
858
861
|
handle.updatedAt = Date.now();
|
|
859
|
-
durableAgentWriteLane(handle, { status: "running" }, [
|
|
862
|
+
durableAgentWriteLane(handle, { status: "running" }, [
|
|
863
|
+
"settledAt",
|
|
864
|
+
"stoppedBy",
|
|
865
|
+
"finalOutput",
|
|
866
|
+
"finalOutputFull",
|
|
867
|
+
"error",
|
|
868
|
+
"errorCode",
|
|
869
|
+
"errorRetryable",
|
|
870
|
+
"errorKind",
|
|
871
|
+
"resultIsPartial",
|
|
872
|
+
"completionId",
|
|
873
|
+
"summary",
|
|
874
|
+
"recentSteps",
|
|
875
|
+
"editedFiles",
|
|
876
|
+
"usage",
|
|
877
|
+
]);
|
|
860
878
|
return { ok: true, cycle: handle.reviveCycle };
|
|
861
879
|
}
|
|
862
880
|
export function settleRevivedAgentLane(core, id, cycle, outcome) {
|
|
@@ -1019,8 +1037,11 @@ The agent is durably suspended, waiting for an approval decision. It resumes whe
|
|
|
1019
1037
|
},
|
|
1020
1038
|
};
|
|
1021
1039
|
}
|
|
1040
|
+
const kindClause = row.status === "failed" && row.errorKind !== undefined && row.errorRetryable !== undefined
|
|
1041
|
+
? ` (error_kind: ${row.errorKind}, retryable: ${row.errorRetryable})`
|
|
1042
|
+
: "";
|
|
1022
1043
|
const body = `status: ${row.status}
|
|
1023
|
-
${row.error ? `error: ${row.error}
|
|
1044
|
+
${row.error ? `error: ${row.error}${kindClause}
|
|
1024
1045
|
` : ""}${row.finalOutput ? `--- result${row.resultIsPartial ? " (partial — produced before the task was stopped)" : ""} ---
|
|
1025
1046
|
${clipTaskOutput(row.finalOutputFull ?? row.finalOutput)}` : "(no result text)"}`;
|
|
1026
1047
|
return {
|
|
@@ -1034,6 +1055,8 @@ ${clipTaskOutput(row.finalOutputFull ?? row.finalOutput)}` : "(no result text)"}
|
|
|
1034
1055
|
...(row.seq !== undefined ? { seq: row.seq } : {}),
|
|
1035
1056
|
...(row.resultIsPartial ? { partial_result: true } : {}),
|
|
1036
1057
|
...(row.completionId !== undefined ? { completionId: row.completionId } : {}),
|
|
1058
|
+
...(row.status === "failed" && row.errorCode !== undefined ? { errorCode: row.errorCode } : {}),
|
|
1059
|
+
...(row.status === "failed" && row.errorRetryable !== undefined ? { retryable: row.errorRetryable } : {}),
|
|
1037
1060
|
},
|
|
1038
1061
|
...(row.status === "failed" ? { isError: true } : {}),
|
|
1039
1062
|
};
|
package/dist/core/trace.d.ts
CHANGED
|
@@ -138,6 +138,7 @@ export type TraceEvent = {
|
|
|
138
138
|
taskId: string;
|
|
139
139
|
model: string;
|
|
140
140
|
provider?: string;
|
|
141
|
+
turn?: number;
|
|
141
142
|
promptTokens: number;
|
|
142
143
|
completionTokens: number;
|
|
143
144
|
cacheRead: number;
|
|
@@ -155,6 +156,8 @@ export type TraceEvent = {
|
|
|
155
156
|
version: 1;
|
|
156
157
|
taskId: string;
|
|
157
158
|
name: string;
|
|
159
|
+
toolCallId?: string;
|
|
160
|
+
turn?: number;
|
|
158
161
|
durationMs: number;
|
|
159
162
|
ok: boolean;
|
|
160
163
|
effect?: ToolEffect;
|
package/dist/core/types.d.ts
CHANGED
|
@@ -258,6 +258,7 @@ export interface TaskSpec {
|
|
|
258
258
|
excludeTools?: string[];
|
|
259
259
|
deferTools?: string[];
|
|
260
260
|
alwaysLoadTools?: string[];
|
|
261
|
+
deferSelfResolve?: boolean;
|
|
261
262
|
promptProfile?: "simple" | "classic";
|
|
262
263
|
agents?: AgentDefinition[];
|
|
263
264
|
toolPolicy?: import("./tool-policy.js").ToolPolicy;
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { SESSION_LOG_DIGEST_SCHEME, sessionEntryDigest, sessionLogDigest, sessio
|
|
|
3
3
|
export type { ResumeTaskConfig } from "./core/runner/runtask.js";
|
|
4
4
|
export { defineTool } from "./core/tools.js";
|
|
5
5
|
export { SKILL_TOOL_NAME } from "./core/runner/synthetic-tools.js";
|
|
6
|
+
export { createSkillsFromDirectory, type SkillsDirectoryOptions, type SkillsDirectoryWarning, type SkillsDirectoryWarningCode, } from "./core/skills-directory.js";
|
|
6
7
|
export { REPORT_FINDINGS_TOOL_NAME, type ReportedFinding } from "./core/runner/synthetic-tools.js";
|
|
7
8
|
export { formatToolError, formatZodValidationError, formatValidationPath, truncateError, errorClassOf } from "./core/tool-errors.js";
|
|
8
9
|
export type { WorkerErrorClass } from "./core/tool-errors.js";
|
|
@@ -66,6 +67,7 @@ export { sanitizeUntrustedText, delimitUntrusted, inlineUntrusted } from "./core
|
|
|
66
67
|
export { deriveInvariants, checkInvariants } from "./core/property-harness.js";
|
|
67
68
|
export type { InvariantKind, FunctionContract, Invariant, InvariantViolation, CheckResult, } from "./core/property-harness.js";
|
|
68
69
|
export { HAND_TOOL_EFFECTS, bashReversibilityProbe, BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName, classifyCompoundReadonly, MAX_EDIT_BYTES } from "./tools/fs/index.js";
|
|
70
|
+
export { classifyCompoundReadonlyDetailed, formatOutOfRootReadApprovalOption, type BashReadonlyRootBoundary, type CompoundReadonlyVerdict, } from "./tools/fs/index.js";
|
|
69
71
|
export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
|
|
70
72
|
export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, type ToolResultStore, type ToolResultSlice, } from "./core/tool-result-store.js";
|
|
71
73
|
export { InMemoryCheckpointStore, CheckpointError, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, WALLTIME_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingWalltimeMs, winnerFromOutcome, validatePendingSteer, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, type RiskDescriptor, type CheckpointStore, type CheckpointSummary, type Checkpoint, type CheckpointToken, type CheckpointGate, type CheckpointState, type SerializedCheckpointState, type CheckpointFaultMode, type PendingAction, type ResumeOutcome, type ResolvedOutcome, type ReopenReason, type ResolveExpectation, type SafetyAxis, type ResourceLedger, type ResourceLimitReason, } from "./core/checkpoint-store.js";
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export { Runner, runTask, DEFAULT_MAX_TURNS } from "./core/runner/runtask.js";
|
|
|
2
2
|
export { SESSION_LOG_DIGEST_SCHEME, sessionEntryDigest, sessionLogDigest, sessionLogDigestsComparable, } from "./engine/session/log-digest.js";
|
|
3
3
|
export { defineTool } from "./core/tools.js";
|
|
4
4
|
export { SKILL_TOOL_NAME } from "./core/runner/synthetic-tools.js";
|
|
5
|
+
export { createSkillsFromDirectory, } from "./core/skills-directory.js";
|
|
5
6
|
export { REPORT_FINDINGS_TOOL_NAME } from "./core/runner/synthetic-tools.js";
|
|
6
7
|
export { formatToolError, formatZodValidationError, formatValidationPath, truncateError, errorClassOf } from "./core/tool-errors.js";
|
|
7
8
|
export { createWebFetchTool, webFetchToolSpec, htmlToText, createWebSearchTool, createWebFetchSummarizer, WEBFETCH_SUMMARY_MAX_CONTENT, WEBFETCH_SUMMARY_GUIDELINES, } from "./tools/web.js";
|
|
@@ -57,6 +58,7 @@ export { runExecGate } from "./core/exec-gate.js";
|
|
|
57
58
|
export { sanitizeUntrustedText, delimitUntrusted, inlineUntrusted } from "./core/untrusted-text.js";
|
|
58
59
|
export { deriveInvariants, checkInvariants } from "./core/property-harness.js";
|
|
59
60
|
export { HAND_TOOL_EFFECTS, bashReversibilityProbe, BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName, classifyCompoundReadonly, MAX_EDIT_BYTES } from "./tools/fs/index.js";
|
|
61
|
+
export { classifyCompoundReadonlyDetailed, formatOutOfRootReadApprovalOption, } from "./tools/fs/index.js";
|
|
60
62
|
export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
|
|
61
63
|
export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, } from "./core/tool-result-store.js";
|
|
62
64
|
export { InMemoryCheckpointStore, CheckpointError, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, WALLTIME_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingWalltimeMs, winnerFromOutcome, validatePendingSteer, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, } from "./core/checkpoint-store.js";
|
|
@@ -5,4 +5,16 @@ export declare function parseLeadingCommandName(command: string): {
|
|
|
5
5
|
reject: string;
|
|
6
6
|
};
|
|
7
7
|
export declare function coarseReadonlyCheck(command: string, allow: ReadonlySet<string>): string | undefined;
|
|
8
|
-
export
|
|
8
|
+
export interface BashReadonlyRootBoundary {
|
|
9
|
+
roots: readonly string[];
|
|
10
|
+
cwd?: string;
|
|
11
|
+
homeDir?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface CompoundReadonlyVerdict {
|
|
14
|
+
reason?: string;
|
|
15
|
+
outOfRootRead?: true;
|
|
16
|
+
outOfRootPaths?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
export declare function formatOutOfRootReadApprovalOption(directory: string): string;
|
|
19
|
+
export declare function classifyCompoundReadonlyDetailed(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): CompoundReadonlyVerdict;
|
|
20
|
+
export declare function classifyCompoundReadonly(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): string | undefined;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isBlockedDevicePath, normalizeAbsPathLexically } from "./safety.js";
|
|
1
|
+
import { isBlockedDevicePath, normalizeAbsPathLexically, withinAnyRoot } from "./safety.js";
|
|
2
2
|
export const BASH_READONLY_DEFAULT_ALLOW = [
|
|
3
3
|
"ls", "cat", "head", "tail", "wc", "pwd", "echo", "whoami", "uname",
|
|
4
4
|
"grep", "cut", "tr", "basename", "dirname", "stat", "du", "df", "which",
|
|
@@ -49,12 +49,102 @@ function foldQuoteRemovalToken(tok) {
|
|
|
49
49
|
}
|
|
50
50
|
return out;
|
|
51
51
|
}
|
|
52
|
-
export function
|
|
52
|
+
export function formatOutOfRootReadApprovalOption(directory) {
|
|
53
|
+
const trimmed = directory.replace(/[/\\]+$/, "");
|
|
54
|
+
const cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
|
|
55
|
+
const leaf = cut >= 0 ? trimmed.slice(cut + 1) : trimmed;
|
|
56
|
+
return `Yes, allow reading from ${leaf || directory}/ from this project`;
|
|
57
|
+
}
|
|
58
|
+
const NO_PATH_OPERAND_COMMANDS = new Set(["pwd", "echo", "whoami", "uname", "which", "tr", "basename", "dirname"]);
|
|
59
|
+
function isAbsolutePathToken(p) {
|
|
60
|
+
return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
|
|
61
|
+
}
|
|
62
|
+
function isPathShapedToken(tok) {
|
|
63
|
+
return tok.includes("/") || tok.startsWith("~") || tok === "." || tok === "..";
|
|
64
|
+
}
|
|
65
|
+
function normalizeAbsPathLexicalEitherFamily(p) {
|
|
66
|
+
const s = p.replace(/\\/g, "/");
|
|
67
|
+
return /^[A-Za-z]:\//.test(s) ? s.slice(0, 2) + normalizeAbsPathLexically(s.slice(2)) : normalizeAbsPathLexically(s);
|
|
68
|
+
}
|
|
69
|
+
function resolveOperandLexically(base, operand, homeDir) {
|
|
70
|
+
let raw = operand;
|
|
71
|
+
if (raw === "~" || raw.startsWith("~/")) {
|
|
72
|
+
if (homeDir === undefined || !isAbsolutePathToken(homeDir))
|
|
73
|
+
return undefined;
|
|
74
|
+
raw = raw === "~" ? homeDir : `${homeDir.replace(/[/\\]+$/, "")}/${raw.slice(2)}`;
|
|
75
|
+
}
|
|
76
|
+
else if (raw.startsWith("~")) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
if (isAbsolutePathToken(raw))
|
|
80
|
+
return normalizeAbsPathLexicalEitherFamily(raw);
|
|
81
|
+
if (base === undefined || !isAbsolutePathToken(base))
|
|
82
|
+
return undefined;
|
|
83
|
+
return normalizeAbsPathLexicalEitherFamily(`${base.replace(/[/\\]+$/, "")}/${raw}`);
|
|
84
|
+
}
|
|
85
|
+
function collectSegmentBoundaryFindings(toks, boundary) {
|
|
86
|
+
const name = toks[0];
|
|
87
|
+
if (NO_PATH_OPERAND_COMMANDS.has(name))
|
|
88
|
+
return [];
|
|
89
|
+
const args = toks.slice(1);
|
|
90
|
+
const findings = [];
|
|
91
|
+
const candidates = [];
|
|
92
|
+
if (name === "cd") {
|
|
93
|
+
const target = args.find((t) => !t.startsWith("-") || t === "-");
|
|
94
|
+
if (target === undefined) {
|
|
95
|
+
return [{ kind: "unresolvable", reason: '`cd` with no argument targets the home directory, which cannot be checked against the allowed directories — not auto-allowed' }];
|
|
96
|
+
}
|
|
97
|
+
if (target === "-") {
|
|
98
|
+
return [{ kind: "unresolvable", reason: '`cd -` targets the previous working directory, which cannot be resolved statically — not auto-allowed' }];
|
|
99
|
+
}
|
|
100
|
+
candidates.push(target);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const patternSuppliedByFlag = name === "grep" && args.some((t) => t === "-e" || t.startsWith("-e") || t === "-f" || t.startsWith("-f") || t.startsWith("--regexp") || t.startsWith("--file"));
|
|
104
|
+
let sawOperand = false;
|
|
105
|
+
for (let k = 0; k < args.length; k++) {
|
|
106
|
+
const t = args[k];
|
|
107
|
+
if (name === "cut" && (t === "-d" || t === "--delimiter" || t === "--output-delimiter")) {
|
|
108
|
+
k++;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (t.startsWith("-") && t !== "-") {
|
|
112
|
+
const eq = t.indexOf("=");
|
|
113
|
+
const value = eq > 0 ? t.slice(eq + 1) : "";
|
|
114
|
+
if (value.length > 0 && isAbsolutePathToken(value))
|
|
115
|
+
candidates.push(value);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (t === "-")
|
|
119
|
+
continue;
|
|
120
|
+
const isGrepPatternSlot = name === "grep" && !patternSuppliedByFlag && !sawOperand;
|
|
121
|
+
sawOperand = true;
|
|
122
|
+
if (isGrepPatternSlot)
|
|
123
|
+
continue;
|
|
124
|
+
if (isPathShapedToken(t))
|
|
125
|
+
candidates.push(t);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const candidate of candidates) {
|
|
129
|
+
const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], candidate, boundary.homeDir);
|
|
130
|
+
if (resolved === undefined) {
|
|
131
|
+
findings.push({
|
|
132
|
+
kind: "unresolvable",
|
|
133
|
+
reason: `"${name}" names the path "${candidate}", which cannot be resolved statically — not auto-allowed`,
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!withinAnyRoot(boundary.roots, resolved))
|
|
138
|
+
findings.push({ kind: "outside", command: name, path: resolved });
|
|
139
|
+
}
|
|
140
|
+
return findings;
|
|
141
|
+
}
|
|
142
|
+
export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
|
|
53
143
|
const trimmed = command.trim();
|
|
54
144
|
if (!trimmed)
|
|
55
|
-
return "empty command";
|
|
145
|
+
return { reason: "empty command" };
|
|
56
146
|
if (SHELL_SEGMENT_HARD_REJECT.test(trimmed)) {
|
|
57
|
-
return "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed";
|
|
147
|
+
return { reason: "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed" };
|
|
58
148
|
}
|
|
59
149
|
const source = trimmed.endsWith(";") ? trimmed.slice(0, -1) : trimmed;
|
|
60
150
|
const segments = [];
|
|
@@ -70,7 +160,7 @@ export function classifyCompoundReadonly(command, allow) {
|
|
|
70
160
|
i++;
|
|
71
161
|
}
|
|
72
162
|
else
|
|
73
|
-
return "`&` backgrounding is not allowed — a backgrounded process outlives the command";
|
|
163
|
+
return { reason: "`&` backgrounding is not allowed — a backgrounded process outlives the command" };
|
|
74
164
|
}
|
|
75
165
|
else if (c === "|") {
|
|
76
166
|
if (source[i + 1] === "|") {
|
|
@@ -97,7 +187,7 @@ export function classifyCompoundReadonly(command, allow) {
|
|
|
97
187
|
for (const segment of segments) {
|
|
98
188
|
const reason = coarseReadonlyCheck(segment, allow);
|
|
99
189
|
if (reason !== undefined)
|
|
100
|
-
return reason;
|
|
190
|
+
return { reason };
|
|
101
191
|
}
|
|
102
192
|
const HEAD_AUTO_ALLOW_MAX = 1_000_000;
|
|
103
193
|
const headBoundIsSmall = (name, toks) => {
|
|
@@ -128,11 +218,13 @@ export function classifyCompoundReadonly(command, allow) {
|
|
|
128
218
|
return byteBound;
|
|
129
219
|
};
|
|
130
220
|
const STDIN_FILE_FLOOR = { cat: 1, grep: 2, head: 1, tail: 1, wc: 1, cut: 1, tr: Infinity };
|
|
221
|
+
const foldedSegments = [];
|
|
131
222
|
for (let si = 0; si < segments.length; si++) {
|
|
132
223
|
const toks = segments[si].trim().split(/\s+/).filter((t) => t.length > 0)
|
|
133
224
|
.map(foldQuoteRemovalToken);
|
|
134
225
|
if (toks.length === 0)
|
|
135
226
|
continue;
|
|
227
|
+
foldedSegments.push(toks);
|
|
136
228
|
const name = toks[0];
|
|
137
229
|
const nonOption = toks.slice(1).filter((t) => !t.startsWith("-"));
|
|
138
230
|
if (!(pipeFed[si] ?? false)) {
|
|
@@ -165,21 +257,44 @@ export function classifyCompoundReadonly(command, allow) {
|
|
|
165
257
|
}
|
|
166
258
|
}
|
|
167
259
|
if (floor !== undefined && hasStdinDash) {
|
|
168
|
-
return `"${name}" reads stdin via an explicit "-" argument and would block until the tool timeout — not auto-allowed
|
|
260
|
+
return { reason: `"${name}" reads stdin via an explicit "-" argument and would block until the tool timeout — not auto-allowed` };
|
|
169
261
|
}
|
|
170
262
|
if (floor !== undefined && nonOption.length < floor) {
|
|
171
|
-
return `"${name}" with no file argument reads stdin and would block until the tool timeout — not auto-allowed
|
|
263
|
+
return { reason: `"${name}" with no file argument reads stdin and would block until the tool timeout — not auto-allowed` };
|
|
172
264
|
}
|
|
173
265
|
}
|
|
174
266
|
if (name === "tail" && toks.slice(1).some((t) => t === "--follow" || t.startsWith("--follow=") || /^[-+][^\s]*[fF]/.test(t))) {
|
|
175
|
-
return "`tail` in follow mode never terminates — not auto-allowed";
|
|
267
|
+
return { reason: "`tail` in follow mode never terminates — not auto-allowed" };
|
|
176
268
|
}
|
|
177
269
|
const GENERATOR_DEVICES = new Set(["/dev/zero", "/dev/random", "/dev/urandom", "/dev/full"]);
|
|
178
270
|
const deviceArgs = toks.slice(1).filter((t) => !t.startsWith("-")).map(normalizeAbsPathLexically).filter(isBlockedDevicePath);
|
|
179
271
|
const rescuedByHead = headBoundIsSmall(name, toks) && deviceArgs.every((d) => GENERATOR_DEVICES.has(d));
|
|
180
272
|
if (!rescuedByHead && deviceArgs.length > 0) {
|
|
181
|
-
return "reads a device/special file that is either unbounded (/dev/zero, /dev/stdin, /proc/<pid>/fd/0, … — blocks the pipeline until the tool timeout) or process-private (/proc/<pid>/environ, /proc/<pid>/mem, …) — not auto-allowed";
|
|
273
|
+
return { reason: "reads a device/special file that is either unbounded (/dev/zero, /dev/stdin, /proc/<pid>/fd/0, … — blocks the pipeline until the tool timeout) or process-private (/proc/<pid>/environ, /proc/<pid>/mem, …) — not auto-allowed" };
|
|
182
274
|
}
|
|
183
275
|
}
|
|
184
|
-
|
|
276
|
+
if (boundary !== undefined) {
|
|
277
|
+
const outside = [];
|
|
278
|
+
for (const toks of foldedSegments) {
|
|
279
|
+
for (const finding of collectSegmentBoundaryFindings(toks, boundary)) {
|
|
280
|
+
if (finding.kind === "unresolvable")
|
|
281
|
+
return { reason: finding.reason };
|
|
282
|
+
if (!outside.some((o) => o.path === finding.path))
|
|
283
|
+
outside.push(finding);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (outside.length > 0) {
|
|
287
|
+
const paths = outside.map((o) => `"${o.path}"`).join(", ");
|
|
288
|
+
const allowed = boundary.roots.length > 0 ? boundary.roots.join(", ") : "(none)";
|
|
289
|
+
return {
|
|
290
|
+
reason: `"${outside[0].command}" reads ${paths}, outside the allowed directories for this session: ${allowed} — not auto-allowed`,
|
|
291
|
+
outOfRootRead: true,
|
|
292
|
+
outOfRootPaths: outside.map((o) => o.path),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return {};
|
|
297
|
+
}
|
|
298
|
+
export function classifyCompoundReadonly(command, allow, boundary) {
|
|
299
|
+
return classifyCompoundReadonlyDetailed(command, allow, boundary).reason;
|
|
185
300
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { AgentTool, ExecutionEnv } from "../../internal/harness-types.js";
|
|
2
2
|
import { type TaskRegistry } from "../../core/task-registry.js";
|
|
3
3
|
import { type CwdRef } from "./fs-shared.js";
|
|
4
|
-
|
|
4
|
+
import { type BashReadonlyRootBoundary } from "./bash-readonly-classifier.js";
|
|
5
|
+
export declare function bashReversibilityProbe(allow?: readonly string[], boundary?: BashReadonlyRootBoundary | (() => BashReadonlyRootBoundary | undefined)): (args: unknown) => {
|
|
5
6
|
reversible: boolean;
|
|
6
7
|
};
|
|
7
8
|
export interface ExecClampOption {
|
package/dist/tools/fs/fs-bash.js
CHANGED
|
@@ -9,13 +9,14 @@ import { imageMagicMatches, withinAnyRoot } from "./safety.js";
|
|
|
9
9
|
import { ghRateLimitHint } from "./gh-rate-limit.js";
|
|
10
10
|
import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, shellRecoveryHint, CWD_SENTINEL, } from "./fs-shared.js";
|
|
11
11
|
import { BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyCompoundReadonly } from "./bash-readonly-classifier.js";
|
|
12
|
-
export function bashReversibilityProbe(allow) {
|
|
12
|
+
export function bashReversibilityProbe(allow, boundary) {
|
|
13
13
|
const allowSet = new Set(allow ?? BASH_READONLY_DEFAULT_ALLOW);
|
|
14
14
|
return (args) => {
|
|
15
15
|
const command = args?.command;
|
|
16
16
|
if (typeof command !== "string")
|
|
17
17
|
return { reversible: false };
|
|
18
|
-
|
|
18
|
+
const resolved = typeof boundary === "function" ? boundary() : boundary;
|
|
19
|
+
return { reversible: classifyCompoundReadonly(command, allowSet, resolved) === undefined };
|
|
19
20
|
};
|
|
20
21
|
}
|
|
21
22
|
const EXIT1_INTERPRETATION = {
|