@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.
- package/dist/agents/send-message-tool.d.ts +2 -0
- package/dist/agents/send-message-tool.js +1 -1
- package/dist/core/background-agent-store.d.ts +3 -0
- package/dist/core/runner/prepare-task.js +7 -2
- package/dist/core/store-contracts/file-snapshot-store-contract.d.ts +3 -1
- package/dist/core/store-contracts/file-snapshot-store-contract.js +11 -3
- package/dist/core/task-registry-agent.js +25 -2
- package/dist/core/tools.d.ts +6 -2
- package/dist/core/tools.js +3 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/tools/fs/bash-readonly-classifier.d.ts +14 -1
- package/dist/tools/fs/bash-readonly-classifier.js +142 -13
- package/dist/tools/fs/fs-bash.d.ts +4 -2
- package/dist/tools/fs/fs-bash.js +22 -7
- package/dist/tools/fs/index.js +1 -0
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -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
|
|
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
|
|
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" }, [
|
|
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/tools.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/core/tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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;
|
package/dist/tools/fs/fs-bash.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
630
|
-
const timeoutCaps = resolveBashTimeoutCaps(
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
package/dist/tools/fs/index.js
CHANGED
|
@@ -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,
|