@sema-agent/core 2.6.0 → 2.8.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.
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import type { Runner } from "../core/runner/runtask.js";
3
3
  import type { TaskNotificationPayload } from "../core/task-notification.js";
4
+ import { type ToolCtxEnricher } from "../core/tools.js";
4
5
  import { SubagentRetainLedger } from "./retain-ledger.js";
5
6
  import { type SubagentSteerHandle } from "./subagent.js";
6
7
  export declare const SEND_MESSAGE_TOOL_NAME = "SendMessage";
@@ -35,6 +36,7 @@ export interface SendMessageToolOptions {
35
36
  content: string;
36
37
  details?: unknown;
37
38
  }>;
39
+ enrichCtx?: ToolCtxEnricher;
38
40
  }
39
41
  export declare const SEND_MESSAGE_SUMMARY_MAX = 200;
40
42
  export declare function clipSendMessageSummary(raw: string): string;
@@ -554,5 +554,5 @@ export function createSendMessageTool(opts) {
554
554
  }
555
555
  });
556
556
  },
557
- });
557
+ }, opts.enrichCtx !== undefined ? { enrichCtx: opts.enrichCtx } : {});
558
558
  }
@@ -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[];
@@ -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
  }
@@ -1274,6 +1278,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1274
1278
  ...(internals?.parentRetainLedger !== undefined ? { siblingRetain: internals.parentRetainLedger } : {}),
1275
1279
  ...(internals?.parentTaskId !== undefined ? { parentTaskId: internals.parentTaskId } : {}),
1276
1280
  ...(internals?.parentSessionId !== undefined ? { parentSessionId: internals.parentSessionId } : {}),
1281
+ enrichCtx: enrichSpecToolCtx,
1277
1282
  ...(deps.rosterStore !== undefined ? { roster: deps.rosterStore } : {}),
1278
1283
  ...(deps.onBackgroundChildEvent ? { onBackgroundChildEvent: deps.onBackgroundChildEvent } : {}),
1279
1284
  ...(deps.backgroundAgentStore !== undefined ? { agentStore: deps.backgroundAgentStore } : {}),
@@ -1,3 +1,5 @@
1
1
  import type { FileSnapshotStore } from "../file-snapshot-store.js";
2
2
  import { type ContractAssertionRunner } from "./contract-harness.js";
3
- export declare function fileSnapshotStoreContract(make: () => FileSnapshotStore, runAssertion?: ContractAssertionRunner): Promise<void>;
3
+ export declare function fileSnapshotStoreContract(make: () => FileSnapshotStore, runAssertion?: ContractAssertionRunner, options?: {
4
+ blobGc?: "immediate" | "eventual";
5
+ }): Promise<void>;
@@ -4,8 +4,9 @@ import { beginContract } from "./contract-harness.js";
4
4
  const bytes = (s) => new TextEncoder().encode(s);
5
5
  const sha256 = (s) => createHash("sha256").update(bytes(s)).digest("hex");
6
6
  const srcBlob = async (hash) => hash === sha256("alpha") ? bytes("alpha") : hash === sha256("beta") ? bytes("beta") : undefined;
7
- export async function fileSnapshotStoreContract(make, runAssertion) {
7
+ export async function fileSnapshotStoreContract(make, runAssertion, options) {
8
8
  const { run, settle } = beginContract(runAssertion);
9
+ const blobGc = options?.blobGc ?? "immediate";
9
10
  run("kit prerequisites: exportManifest/getBlob/putBlob/importManifest are implemented (REQUIRED by this kit)", async () => {
10
11
  const probe = make();
11
12
  const missing = ["exportManifest", "getBlob", "putBlob", "importManifest"].filter((m) => typeof probe[m] !== "function");
@@ -115,12 +116,19 @@ export async function fileSnapshotStoreContract(make, runAssertion) {
115
116
  assert.deepEqual(await store.listKeys("sc"), ["k1"]);
116
117
  assert.deepEqual([...(await store.getBlob(sha256("alpha")))], [...bytes("alpha")]);
117
118
  });
118
- run("reap keep-nothing drops the key and GCs the now-unreferenced blobs", async () => {
119
+ run("reap keep-nothing drops the key IMMEDIATELY (every backend)", async () => {
119
120
  const store = make();
120
121
  await store.importManifest("sc", "k1", new Map([["a.txt", sha256("alpha")]]), srcBlob);
121
122
  assert.equal(await store.reap("sc", []), 1);
122
123
  assert.equal(await store.has("sc", "k1"), false);
123
- assert.equal(await store.getBlob(sha256("alpha")), undefined);
124
124
  });
125
+ if (blobGc === "immediate") {
126
+ run("reap keep-nothing GCs the now-unreferenced blob bytes immediately (blobGc: immediate)", async () => {
127
+ const store = make();
128
+ await store.importManifest("sc", "k1", new Map([["a.txt", sha256("alpha")]]), srcBlob);
129
+ assert.equal(await store.reap("sc", []), 1);
130
+ assert.equal(await store.getBlob(sha256("alpha")), undefined);
131
+ });
132
+ }
125
133
  await settle();
126
134
  }
@@ -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
  };
@@ -1,6 +1,6 @@
1
1
  import type { TSchema } from "typebox";
2
2
  import type { AgentTool } from "../internal/harness.js";
3
- import type { ToolSpec } from "./types.js";
3
+ import type { ToolExecuteContext, ToolSpec } from "./types.js";
4
4
  export declare function errorResult(text: string, details?: unknown): {
5
5
  content: string;
6
6
  isError: true;
@@ -8,4 +8,8 @@ export declare function errorResult(text: string, details?: unknown): {
8
8
  };
9
9
  export declare function isDefineToolProduct(x: unknown): x is AgentTool;
10
10
  export declare function stampDefineToolBrand<T extends object>(tool: T): T;
11
- export declare function defineTool<TParams extends TSchema = TSchema>(spec: ToolSpec<TParams>): AgentTool<TParams>;
11
+ export type ToolCtxEnricher = (base: ToolExecuteContext) => ToolExecuteContext;
12
+ export interface DefineToolOptions {
13
+ enrichCtx?: ToolCtxEnricher;
14
+ }
15
+ export declare function defineTool<TParams extends TSchema = TSchema>(spec: ToolSpec<TParams>, options?: DefineToolOptions): AgentTool<TParams>;
@@ -33,7 +33,7 @@ export function stampDefineToolBrand(tool) {
33
33
  Object.defineProperty(tool, DEFINE_TOOL_BRAND, { value: true, enumerable: false });
34
34
  return tool;
35
35
  }
36
- export function defineTool(spec) {
36
+ export function defineTool(spec, options) {
37
37
  const executionMode = spec.executionMode ?? (spec.effect === "read" ? "parallel" : "sequential");
38
38
  const tool = {
39
39
  name: spec.name,
@@ -60,7 +60,8 @@ export function defineTool(spec) {
60
60
  }
61
61
  let ret;
62
62
  try {
63
- ret = await spec.execute(params, { toolCallId, signal });
63
+ const baseCtx = { toolCallId, signal };
64
+ ret = await spec.execute(params, options?.enrichCtx ? { ...options.enrichCtx(baseCtx), toolCallId, signal } : baseCtx);
64
65
  }
65
66
  catch (err) {
66
67
  throw new Error(formatToolError(err));
package/dist/index.d.ts CHANGED
@@ -67,8 +67,9 @@ export { sanitizeUntrustedText, delimitUntrusted, inlineUntrusted } from "./core
67
67
  export { deriveInvariants, checkInvariants } from "./core/property-harness.js";
68
68
  export type { InvariantKind, FunctionContract, Invariant, InvariantViolation, CheckResult, } from "./core/property-harness.js";
69
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";
70
71
  export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
71
- export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, type ToolResultStore, type ToolResultSlice, } from "./core/tool-result-store.js";
72
+ export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, type ToolResultStore, type ToolResultSlice, } from "./core/tool-result-store.js";
72
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";
73
74
  export { InMemoryFileSnapshotStore, DEFAULT_SNAPSHOT_BOUNDS } from "./core/file-snapshot-store.js";
74
75
  export { captureManifest, applyManifest } from "./core/file-snapshot-store.js";
package/dist/index.js CHANGED
@@ -58,8 +58,9 @@ export { runExecGate } from "./core/exec-gate.js";
58
58
  export { sanitizeUntrustedText, delimitUntrusted, inlineUntrusted } from "./core/untrusted-text.js";
59
59
  export { deriveInvariants, checkInvariants } from "./core/property-harness.js";
60
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";
61
62
  export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
62
- export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, } from "./core/tool-result-store.js";
63
+ export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, } from "./core/tool-result-store.js";
63
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";
64
65
  export { InMemoryFileSnapshotStore, DEFAULT_SNAPSHOT_BOUNDS } from "./core/file-snapshot-store.js";
65
66
  export { captureManifest, applyManifest } from "./core/file-snapshot-store.js";
@@ -5,4 +5,17 @@ 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 classifySimpleCommandReadBoundary(command: string, boundary: BashReadonlyRootBoundary): CompoundReadonlyVerdict;
21
+ 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,109 @@ 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 tokenizeSegment(segment) {
86
+ return segment
87
+ .trim()
88
+ .split(/\s+/)
89
+ .filter((t) => t.length > 0)
90
+ .map(foldQuoteRemovalToken);
91
+ }
92
+ function collectSegmentBoundaryFindings(toks, boundary) {
93
+ const name = toks[0];
94
+ if (NO_PATH_OPERAND_COMMANDS.has(name))
95
+ return [];
96
+ const args = toks.slice(1);
97
+ const findings = [];
98
+ const candidates = [];
99
+ if (name === "cd") {
100
+ const target = args.find((t) => !t.startsWith("-") || t === "-");
101
+ if (target === undefined) {
102
+ return [{ kind: "unresolvable", reason: '`cd` with no argument targets the home directory, which cannot be checked against the allowed directories — not auto-allowed' }];
103
+ }
104
+ if (target === "-") {
105
+ return [{ kind: "unresolvable", reason: '`cd -` targets the previous working directory, which cannot be resolved statically — not auto-allowed' }];
106
+ }
107
+ candidates.push(target);
108
+ }
109
+ else {
110
+ const patternSuppliedByFlag = name === "grep" && args.some((t) => t === "-e" || t.startsWith("-e") || t === "-f" || t.startsWith("-f") || t.startsWith("--regexp") || t.startsWith("--file"));
111
+ let sawOperand = false;
112
+ for (let k = 0; k < args.length; k++) {
113
+ const t = args[k];
114
+ if (name === "cut" && (t === "-d" || t === "--delimiter" || t === "--output-delimiter")) {
115
+ k++;
116
+ continue;
117
+ }
118
+ if (t.startsWith("-") && t !== "-") {
119
+ const eq = t.indexOf("=");
120
+ const value = eq > 0 ? t.slice(eq + 1) : "";
121
+ if (value.length > 0 && isAbsolutePathToken(value))
122
+ candidates.push(value);
123
+ continue;
124
+ }
125
+ if (t === "-")
126
+ continue;
127
+ const isGrepPatternSlot = name === "grep" && !patternSuppliedByFlag && !sawOperand;
128
+ sawOperand = true;
129
+ if (isGrepPatternSlot)
130
+ continue;
131
+ if (isPathShapedToken(t))
132
+ candidates.push(t);
133
+ }
134
+ }
135
+ for (const candidate of candidates) {
136
+ const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], candidate, boundary.homeDir);
137
+ if (resolved === undefined) {
138
+ findings.push({
139
+ kind: "unresolvable",
140
+ reason: `"${name}" names the path "${candidate}", which cannot be resolved statically — not auto-allowed`,
141
+ });
142
+ continue;
143
+ }
144
+ if (!withinAnyRoot(boundary.roots, resolved))
145
+ findings.push({ kind: "outside", command: name, path: resolved });
146
+ }
147
+ return findings;
148
+ }
149
+ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
53
150
  const trimmed = command.trim();
54
151
  if (!trimmed)
55
- return "empty command";
152
+ return { reason: "empty command" };
56
153
  if (SHELL_SEGMENT_HARD_REJECT.test(trimmed)) {
57
- return "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed";
154
+ return { reason: "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed" };
58
155
  }
59
156
  const source = trimmed.endsWith(";") ? trimmed.slice(0, -1) : trimmed;
60
157
  const segments = [];
@@ -70,7 +167,7 @@ export function classifyCompoundReadonly(command, allow) {
70
167
  i++;
71
168
  }
72
169
  else
73
- return "`&` backgrounding is not allowed — a backgrounded process outlives the command";
170
+ return { reason: "`&` backgrounding is not allowed — a backgrounded process outlives the command" };
74
171
  }
75
172
  else if (c === "|") {
76
173
  if (source[i + 1] === "|") {
@@ -97,7 +194,7 @@ export function classifyCompoundReadonly(command, allow) {
97
194
  for (const segment of segments) {
98
195
  const reason = coarseReadonlyCheck(segment, allow);
99
196
  if (reason !== undefined)
100
- return reason;
197
+ return { reason };
101
198
  }
102
199
  const HEAD_AUTO_ALLOW_MAX = 1_000_000;
103
200
  const headBoundIsSmall = (name, toks) => {
@@ -128,11 +225,12 @@ export function classifyCompoundReadonly(command, allow) {
128
225
  return byteBound;
129
226
  };
130
227
  const STDIN_FILE_FLOOR = { cat: 1, grep: 2, head: 1, tail: 1, wc: 1, cut: 1, tr: Infinity };
228
+ const foldedSegments = [];
131
229
  for (let si = 0; si < segments.length; si++) {
132
- const toks = segments[si].trim().split(/\s+/).filter((t) => t.length > 0)
133
- .map(foldQuoteRemovalToken);
230
+ const toks = tokenizeSegment(segments[si]);
134
231
  if (toks.length === 0)
135
232
  continue;
233
+ foldedSegments.push(toks);
136
234
  const name = toks[0];
137
235
  const nonOption = toks.slice(1).filter((t) => !t.startsWith("-"));
138
236
  if (!(pipeFed[si] ?? false)) {
@@ -165,21 +263,52 @@ export function classifyCompoundReadonly(command, allow) {
165
263
  }
166
264
  }
167
265
  if (floor !== undefined && hasStdinDash) {
168
- return `"${name}" reads stdin via an explicit "-" argument and would block until the tool timeout — not auto-allowed`;
266
+ return { reason: `"${name}" reads stdin via an explicit "-" argument and would block until the tool timeout — not auto-allowed` };
169
267
  }
170
268
  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`;
269
+ return { reason: `"${name}" with no file argument reads stdin and would block until the tool timeout — not auto-allowed` };
172
270
  }
173
271
  }
174
272
  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";
273
+ return { reason: "`tail` in follow mode never terminates — not auto-allowed" };
176
274
  }
177
275
  const GENERATOR_DEVICES = new Set(["/dev/zero", "/dev/random", "/dev/urandom", "/dev/full"]);
178
276
  const deviceArgs = toks.slice(1).filter((t) => !t.startsWith("-")).map(normalizeAbsPathLexically).filter(isBlockedDevicePath);
179
277
  const rescuedByHead = headBoundIsSmall(name, toks) && deviceArgs.every((d) => GENERATOR_DEVICES.has(d));
180
278
  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";
279
+ 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
280
  }
183
281
  }
184
- return undefined;
282
+ if (boundary !== undefined)
283
+ return evaluateReadBoundary(foldedSegments, boundary);
284
+ return {};
285
+ }
286
+ function evaluateReadBoundary(foldedSegments, boundary) {
287
+ const outside = [];
288
+ for (const toks of foldedSegments) {
289
+ for (const finding of collectSegmentBoundaryFindings(toks, boundary)) {
290
+ if (finding.kind === "unresolvable")
291
+ return { reason: finding.reason };
292
+ if (!outside.some((o) => o.path === finding.path))
293
+ outside.push(finding);
294
+ }
295
+ }
296
+ if (outside.length === 0)
297
+ return {};
298
+ const paths = outside.map((o) => `"${o.path}"`).join(", ");
299
+ const allowed = boundary.roots.length > 0 ? boundary.roots.join(", ") : "(none)";
300
+ return {
301
+ reason: `"${outside[0].command}" reads ${paths}, outside the allowed directories for this session: ${allowed} — not auto-allowed`,
302
+ outOfRootRead: true,
303
+ outOfRootPaths: outside.map((o) => o.path),
304
+ };
305
+ }
306
+ export function classifySimpleCommandReadBoundary(command, boundary) {
307
+ const toks = tokenizeSegment(command);
308
+ if (toks.length === 0)
309
+ return {};
310
+ return evaluateReadBoundary([toks], boundary);
311
+ }
312
+ export function classifyCompoundReadonly(command, allow, boundary) {
313
+ return classifyCompoundReadonlyDetailed(command, allow, boundary).reason;
185
314
  }
@@ -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 {
@@ -30,9 +31,10 @@ export declare function createBashTool(env: ExecutionEnv, rootCanonical: string,
30
31
  bashDefaultTimeoutMs?: number;
31
32
  bashMaxTimeoutMs?: number;
32
33
  }): AgentTool;
33
- export declare function createBashReadonlyTool(env: ExecutionEnv, rootCanonical: string, allow: ReadonlySet<string>, execClamp?: ExecClampOption, timeoutOpts?: {
34
+ export declare function createBashReadonlyTool(env: ExecutionEnv, rootCanonical: string, allow: ReadonlySet<string>, execClamp?: ExecClampOption, opts?: {
34
35
  bashDefaultTimeoutMs?: number;
35
36
  bashMaxTimeoutMs?: number;
37
+ additionalRoots?: readonly string[];
36
38
  }): AgentTool;
37
39
  export declare function createEnvTaskOutputTool(env: ExecutionEnv): AgentTool;
38
40
  export declare function createEnvTaskStopTool(env: ExecutionEnv, registry?: TaskRegistry): AgentTool;
@@ -8,14 +8,15 @@ import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js";
8
8
  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
- import { BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyCompoundReadonly } from "./bash-readonly-classifier.js";
12
- export function bashReversibilityProbe(allow) {
11
+ import { BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyCompoundReadonly, classifySimpleCommandReadBoundary, } from "./bash-readonly-classifier.js";
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 = {
@@ -626,16 +627,20 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
626
627
  },
627
628
  });
628
629
  }
629
- export function createBashReadonlyTool(env, rootCanonical, allow, execClamp, timeoutOpts) {
630
- const timeoutCaps = resolveBashTimeoutCaps(timeoutOpts);
630
+ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp, opts) {
631
+ const timeoutCaps = resolveBashTimeoutCaps(opts);
631
632
  const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
632
633
  const sample = [...allow].slice(0, 6).join(", ");
634
+ const readRoots = [rootCanonical, ...(opts?.additionalRoots ?? [])];
635
+ const mintedOutputFiles = new Set();
633
636
  return defineTool({
634
637
  name: "Bash",
635
638
  contract: { contractId: "core.bash_readonly@1", implementationRevision: "1" },
636
639
  description: `Run a SINGLE read-only inspection command (e.g. ${sample}, …) and return its output. Shell ` +
637
640
  "operators (pipes, redirects, ;, &&, command substitution, subshells) are rejected and only " +
638
- "allowlisted commands run. Still subject to the deployment's approval policy.",
641
+ "allowlisted commands run. Reads are confined to the workspace root(s): a path outside them is " +
642
+ "refused, as is one that cannot be resolved statically (e.g. a `~` path). Still subject to the " +
643
+ "deployment's approval policy.",
639
644
  parameters: Type.Object({
640
645
  command: Type.String({ description: "A single allowlisted read-only command (no shell operators)." }),
641
646
  timeout: Type.Optional(Type.Number({ description: `Timeout in milliseconds (default ${timeoutCaps.defaultMs}, max ${timeoutCaps.maxMs}; requests above the max are capped to it).` })),
@@ -646,7 +651,17 @@ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp, tim
646
651
  const reason = coarseReadonlyCheck(command, allow);
647
652
  if (reason)
648
653
  return errorResult(`Error (Bash): ${reason}`);
649
- return runShell(env, rootCanonical, "Bash", command, msTimeoutToRequestedSec(timeout), timeoutCapsSecView, ctx.signal, undefined, undefined, execClamp, ctx.toolCallId, true);
654
+ const boundary = classifySimpleCommandReadBoundary(command, { roots: [...readRoots, ...mintedOutputFiles], cwd: rootCanonical });
655
+ if (boundary.reason !== undefined) {
656
+ return errorResult(`Error (Bash): ${boundary.reason}. bash_readonly is confined to the workspace roots; it has no approval path, so the call is refused rather than escalated.`, { code: "readonly_out_of_root", paths: boundary.outOfRootPaths ?? [] });
657
+ }
658
+ const result = await runShell(env, rootCanonical, "Bash", command, msTimeoutToRequestedSec(timeout), timeoutCapsSecView, ctx.signal, undefined, undefined, execClamp, ctx.toolCallId, true);
659
+ if (typeof result !== "string") {
660
+ const outputFile = result.details.output_file;
661
+ if (typeof outputFile === "string")
662
+ mintedOutputFiles.add(outputFile);
663
+ }
664
+ return result;
650
665
  },
651
666
  });
652
667
  }
@@ -38,6 +38,7 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
38
38
  ? createBashReadonlyTool(env, rootCanonical, new Set(bashReadonlyAllow ?? BASH_READONLY_DEFAULT_ALLOW), opts.execClamp, {
39
39
  ...(opts.bashDefaultTimeoutMs !== undefined ? { bashDefaultTimeoutMs: opts.bashDefaultTimeoutMs } : {}),
40
40
  ...(opts.bashMaxTimeoutMs !== undefined ? { bashMaxTimeoutMs: opts.bashMaxTimeoutMs } : {}),
41
+ ...(additionalRoots !== undefined ? { additionalRoots } : {}),
41
42
  })
42
43
  : createBashTool(env, rootCanonical, commitCoAuthor, cwdRef, {
43
44
  taskRegistry: opts.taskRegistry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",