@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.
@@ -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[];
@@ -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: "not_directory" };
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 ?? {}) }, undefined, {
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 warning = new Error(`mcp: server "${spec.name}" failed to connect — skipped (${detail})`, { cause: err });
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 placeholders = new Map([...registry.values()].map((i) => [i.name, createPlaceholderTool(i)]));
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 type { AgentMessage, AgentTool } from "../../internal/harness-types.js";
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 declare function createPlaceholderTool(info: DeferredToolInfo): AgentTool;
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(", ")} — no deferred tool has this exact name. ` +
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" }, ["settledAt", "stoppedBy", "finalOutput", "finalOutputFull", "error", "resultIsPartial", "completionId", "summary", "recentSteps", "editedFiles", "usage"]);
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
  };
@@ -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;
@@ -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 declare function classifyCompoundReadonly(command: string, allow: ReadonlySet<string>): string | undefined;
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 classifyCompoundReadonly(command, allow) {
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
- return undefined;
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
- export declare function bashReversibilityProbe(allow?: readonly string[]): (args: unknown) => {
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 {
@@ -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
- return { reversible: classifyCompoundReadonly(command, allowSet) === undefined };
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 = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",