@sema-agent/core 7.43.0 → 7.45.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/CHANGELOG.md +245 -0
- package/dist/agents/subagent.d.ts +2 -1
- package/dist/agents/subagent.js +33 -14
- package/dist/agents/tool-filter.d.ts +65 -4
- package/dist/agents/tool-filter.js +127 -4
- package/dist/brain/errors.d.ts +23 -2
- package/dist/brain/errors.js +26 -2
- package/dist/brain/stream-engine.d.ts +2 -1
- package/dist/brain/stream-engine.js +10 -9
- package/dist/core/agent-definition.d.ts +2 -2
- package/dist/core/allow-entry.d.ts +25 -1
- package/dist/core/allow-entry.js +29 -4
- package/dist/core/ask-origin.d.ts +23 -4
- package/dist/core/ask-origin.js +10 -4
- package/dist/core/checkpoint-store.d.ts +28 -15
- package/dist/core/checkpoint-store.js +4 -13
- package/dist/core/decision-receipt.js +1 -1
- package/dist/core/effective-path-target.d.ts +7 -6
- package/dist/core/effective-path-target.js +3 -7
- package/dist/core/engine-notice.d.ts +33 -14
- package/dist/core/engine-notice.js +14 -7
- package/dist/core/gate-lanes.js +2 -0
- package/dist/core/gate-outcome.d.ts +5 -0
- package/dist/core/gate-outcome.js +2 -0
- package/dist/core/governance-codes.d.ts +4 -3
- package/dist/core/governance-codes.js +1 -0
- package/dist/core/hooks.d.ts +2 -0
- package/dist/core/memory-engine/content-origin.d.ts +0 -34
- package/dist/core/memory-engine/content-origin.js +9 -4
- package/dist/core/memory-engine/engine.d.ts +2 -2
- package/dist/core/memory-engine/engine.js +7 -14
- package/dist/core/memory-engine/file-backend.d.ts +2 -2
- package/dist/core/memory-engine/file-backend.js +5 -7
- package/dist/core/memory-engine/layout.d.ts +41 -4
- package/dist/core/memory-engine/layout.js +35 -5
- package/dist/core/permission-rules.d.ts +4 -0
- package/dist/core/permission-rules.js +5 -2
- package/dist/core/real-approval.d.ts +9 -6
- package/dist/core/real-approval.js +5 -5
- package/dist/core/runner/child-tree.d.ts +13 -0
- package/dist/core/runner/child-tree.js +3 -0
- package/dist/core/runner/contracts.d.ts +9 -18
- package/dist/core/runner/prepare-ask-lane.d.ts +2 -0
- package/dist/core/runner/prepare-ask-lane.js +2 -2
- package/dist/core/runner/prepare-config-doors.js +3 -1
- package/dist/core/runner/prepare-execution-env.js +2 -1
- package/dist/core/runner/prepare-gate-stations.d.ts +3 -0
- package/dist/core/runner/prepare-gate-stations.js +2 -1
- package/dist/core/runner/prepare-listings.d.ts +4 -4
- package/dist/core/runner/prepare-listings.js +2 -22
- package/dist/core/runner/prepare-park-ask.d.ts +2 -0
- package/dist/core/runner/prepare-park-ask.js +2 -2
- package/dist/core/runner/prepare-policy-chain.d.ts +2 -0
- package/dist/core/runner/prepare-policy-chain.js +4 -2
- package/dist/core/runner/prepare-task.js +10 -6
- package/dist/core/runner/resume-policy-outcome.js +2 -1
- package/dist/core/runner/run-leg.js +1 -1
- package/dist/core/runner/run-notification-lane.js +2 -2
- package/dist/core/runner/runtask.d.ts +2 -0
- package/dist/core/runner/runtask.js +3 -5
- package/dist/core/runner/steer-admission.d.ts +10 -11
- package/dist/core/runner/steer-admission.js +6 -8
- package/dist/core/runner/stream-steer-verb.d.ts +5 -4
- package/dist/core/runner/stream-steer-verb.js +8 -10
- package/dist/core/runner/sub-agent-deny-lists.d.ts +32 -0
- package/dist/core/runner/sub-agent-deny-lists.js +121 -0
- package/dist/core/runner/synthetic-tools.d.ts +1 -1
- package/dist/core/runner/synthetic-tools.js +3 -2
- package/dist/core/session-policy-store.d.ts +6 -2
- package/dist/core/session-policy-store.js +2 -1
- package/dist/core/skill-grant.d.ts +10 -2
- package/dist/core/skill-grant.js +20 -4
- package/dist/core/steer-replay.d.ts +46 -0
- package/dist/core/steer-replay.js +22 -0
- package/dist/core/task-event.d.ts +3 -2
- package/dist/core/task-notification.d.ts +12 -1
- package/dist/core/task-notification.js +13 -1
- package/dist/core/task-spec.d.ts +5 -3
- package/dist/core/task-stream.d.ts +12 -6
- package/dist/core/tool-errors.d.ts +2 -2
- package/dist/core/tool-policy.d.ts +15 -4
- package/dist/core/tool-policy.js +9 -13
- package/dist/core/tool-spec.d.ts +20 -2
- package/dist/core/types.d.ts +2 -2
- package/dist/core/types.js +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.js +7 -4
- package/dist/orchestration/workflow-types.d.ts +5 -0
- package/dist/orchestration/workflow.d.ts +3 -2
- package/dist/orchestration/workflow.js +16 -7
- package/dist/tools/fs/removal-targets.d.ts +82 -0
- package/dist/tools/fs/removal-targets.js +236 -0
- package/dist/tools/fs/shell-read-write-targets.d.ts +27 -0
- package/dist/tools/fs/shell-read-write-targets.js +1 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +43 -5
- package/wire-changes/core@7.44.0.json +469 -0
- package/wire-changes/core@7.45.0.json +1084 -0
|
@@ -19,6 +19,7 @@ import { boundInputHashOf } from "../core/canonical-json.js";
|
|
|
19
19
|
import { boundedRedactedSummary } from "../core/untrusted-egress.js";
|
|
20
20
|
import { delimitUntrusted } from "../core/untrusted-text.js";
|
|
21
21
|
import { OUTPUT_TOOL_NAME } from "../core/runner/synthetic-tools.js";
|
|
22
|
+
import { deniedToolNamesOf } from "../core/runner/sub-agent-deny-lists.js";
|
|
22
23
|
import { compileOutputSchema } from "../core/runner/strict-output-schema.js";
|
|
23
24
|
import { WorkflowBudgetExceededError, WorkflowNestingError, WorkflowAgentBlockedError, WorkflowAgentParkedError, WorkflowAgentSchemaError, WorkflowAgentStalledError, WorkflowMaxAgentsError, WORKFLOW_SPAWN_BLOCKED_ERROR_CODE } from "./workflow-types.js";
|
|
24
25
|
import { WorkflowScriptError } from "./workflow-meta.js";
|
|
@@ -118,7 +119,7 @@ export function applyWorkflowAgentType(spec, agentType, registry) {
|
|
|
118
119
|
if (out.memoryPersistenceCapable === undefined && def.memoryPersistenceCapable !== undefined)
|
|
119
120
|
out.memoryPersistenceCapable = def.memoryPersistenceCapable;
|
|
120
121
|
if (def.allowTools !== undefined || def.denyTools !== undefined) {
|
|
121
|
-
const perType = createAllowDenyPolicy({ ...(def.allowTools ? { allow: def.allowTools } : {}), ...(def.denyTools ? { deny: def.denyTools } : {}), scope: "narrowing" });
|
|
122
|
+
const perType = createAllowDenyPolicy({ ...(def.allowTools ? { allow: def.allowTools } : {}), ...(def.denyTools ? { deny: deniedToolNamesOf(`workflow agentType "${def.name}"`, def.denyTools) } : {}), scope: "narrowing" });
|
|
122
123
|
out.toolPolicy = out.toolPolicy ? combinePolicies(out.toolPolicy, perType) : perType;
|
|
123
124
|
}
|
|
124
125
|
return out;
|
|
@@ -1056,9 +1057,10 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
|
|
|
1056
1057
|
const parks = run.parks ?? (run.parks = []);
|
|
1057
1058
|
const at = parks.findIndex((e) => e.callKey === rec.callKey);
|
|
1058
1059
|
const held = at === -1 ? undefined : parks[at];
|
|
1060
|
+
const holder = { parkedBy: runId, generation: held?.generation ?? 0 };
|
|
1059
1061
|
const key = held !== undefined && held.token === paused.terminal.token && held.originUnconfirmed !== true
|
|
1060
|
-
? { callKey: rec.callKey, sessionId: paused.sessionId, token: paused.terminal.token, originRunId: held.originRunId }
|
|
1061
|
-
: { callKey: rec.callKey, sessionId: paused.sessionId, token: paused.terminal.token, originRunId: runId, originUnconfirmed: true };
|
|
1062
|
+
? { callKey: rec.callKey, sessionId: paused.sessionId, token: paused.terminal.token, originRunId: held.originRunId, ...holder }
|
|
1063
|
+
: { callKey: rec.callKey, sessionId: paused.sessionId, token: paused.terminal.token, originRunId: runId, originUnconfirmed: true, ...holder };
|
|
1062
1064
|
if (at === -1)
|
|
1063
1065
|
parks.push(key);
|
|
1064
1066
|
else
|
|
@@ -2263,19 +2265,23 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
|
|
|
2263
2265
|
throw parksUnreadable("its journal yielded no entry to derive one from");
|
|
2264
2266
|
const candidates = new Map();
|
|
2265
2267
|
const keyed = (k) => k.token !== "" && k.sessionId !== "";
|
|
2268
|
+
const clash = (a, b) => a !== "" && b !== "" && a !== b;
|
|
2266
2269
|
const offerCandidate = (ord, key) => {
|
|
2267
2270
|
const recorded = candidates.get(ord);
|
|
2268
|
-
if (recorded !== undefined && (recorded.callKey !== key.callKey || (
|
|
2271
|
+
if (recorded !== undefined && (recorded.callKey !== key.callKey || clash(recorded.token, key.token) || clash(recorded.sessionId, key.sessionId)))
|
|
2269
2272
|
throw parkBindingBroken(ord, "the prior run's key caches name different tokens, sessions or calls for it");
|
|
2270
|
-
if (recorded === undefined
|
|
2273
|
+
if (recorded === undefined)
|
|
2271
2274
|
candidates.set(ord, key);
|
|
2275
|
+
else if (!keyed(recorded) && keyed(key))
|
|
2276
|
+
candidates.set(ord, recorded.parkedBy === undefined ? key : { ...key, parkedBy: recorded.parkedBy, generation: recorded.generation });
|
|
2272
2277
|
};
|
|
2273
2278
|
for (const raw of recordParks ?? []) {
|
|
2274
2279
|
const e = raw;
|
|
2275
|
-
|
|
2280
|
+
const holder = e === null || typeof e !== "object" ? undefined : e.parkedBy === undefined && e.generation === undefined ? {} : typeof e.parkedBy === "string" && e.parkedBy !== "" && Number.isSafeInteger(e.generation) && e.generation >= 0 ? { parkedBy: e.parkedBy, generation: e.generation } : undefined;
|
|
2281
|
+
if (e === null || typeof e !== "object" || holder === undefined || typeof e.callKey !== "string" || typeof e.sessionId !== "string" || typeof e.token !== "string" || typeof e.originRunId !== "string" || !/^\d+:/.test(e.callKey)) {
|
|
2276
2282
|
throw parkBindingBroken(-1, "a recorded park entry is malformed or its call key has no ordinal");
|
|
2277
2283
|
}
|
|
2278
|
-
offerCandidate(callKeyOrdinal(e.callKey), { callKey: e.callKey, sessionId: e.sessionId, token: e.token, originRunId: e.originRunId, ...(e.originUnconfirmed === true ? { originUnconfirmed: true } : {}) });
|
|
2284
|
+
offerCandidate(callKeyOrdinal(e.callKey), { callKey: e.callKey, sessionId: e.sessionId, token: e.token, originRunId: e.originRunId, ...(e.originUnconfirmed === true ? { originUnconfirmed: true } : {}), ...holder });
|
|
2279
2285
|
}
|
|
2280
2286
|
for (const [ord, e] of replayByOrdinal.entries()) {
|
|
2281
2287
|
if (e?.parked === undefined)
|
|
@@ -2320,6 +2326,9 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
|
|
|
2320
2326
|
}
|
|
2321
2327
|
}
|
|
2322
2328
|
if (!finalized) {
|
|
2329
|
+
for (const key of inherited)
|
|
2330
|
+
if (key.generation !== undefined)
|
|
2331
|
+
key.generation += 1;
|
|
2323
2332
|
run.parks = inherited;
|
|
2324
2333
|
delete run.resumeAdmissionIncomplete;
|
|
2325
2334
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type EstablishedPlan } from "./shell-read-establish.js";
|
|
2
|
+
import type { ExecutionEnv } from "../../internal/harness-types.js";
|
|
3
|
+
import type { ToolCallRequest } from "../../core/tool-policy.js";
|
|
4
|
+
/** The REMOVAL programs — the reference implementation's dangerous-removal arm reads `rm` and `rmdir`, with or without a
|
|
5
|
+
* recursive option (removing an empty home or the task root is still removing it). */
|
|
6
|
+
export declare const SHELL_REMOVAL_COMMANDS: readonly ["rm", "rmdir"];
|
|
7
|
+
export type ShellRemovalCommand = (typeof SHELL_REMOVAL_COMMANDS)[number];
|
|
8
|
+
/** What makes a removal target dangerous, in the order a card names it. `runtime_script`: a shell's script text is built
|
|
9
|
+
* only when the command runs, and what it may run includes a removal nobody can read before it does. */
|
|
10
|
+
export type DangerousRemovalKind = "filesystem_root" | "home" | "workspace" | "top_level" | "runtime_script";
|
|
11
|
+
/** The protected locations a removal is judged against. `homes`: the environment's declared home and every other spelling
|
|
12
|
+
* of it (its canonical one); `workspace`: the task root, the working directory, the additional working directories. */
|
|
13
|
+
export interface RemovalSeat {
|
|
14
|
+
readonly homes: readonly string[];
|
|
15
|
+
readonly workspace: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
/** One dangerous removal a command makes: the program (the removal program, or the shell a runtime-built script is handed
|
|
18
|
+
* to), the operand as written, the target as established (the script word as written, for `runtime_script`). */
|
|
19
|
+
export interface DangerousRemoval {
|
|
20
|
+
readonly kind: DangerousRemovalKind;
|
|
21
|
+
readonly program: string;
|
|
22
|
+
readonly operand: string;
|
|
23
|
+
readonly target: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The ONE dangerous-removal predicate: is removing this absolute path removing a protected location? The reference
|
|
27
|
+
* implementation's set (cli 2.1.291 `dme` / `xW`) — the filesystem root (a drive root, a UNC share root), a home directory
|
|
28
|
+
* (every spelling the seat holds), a workspace directory or any of its ANCESTORS (the task root, the working directory, an
|
|
29
|
+
* additional working directory — whether the home lies inside the task root or the task root inside the home), a top-level
|
|
30
|
+
* directory (one segment below the root). `undefined` for a relative path or any other target.
|
|
31
|
+
*/
|
|
32
|
+
export declare function dangerousRemovalOf(path: string, seat: RemovalSeat): DangerousRemovalKind | undefined;
|
|
33
|
+
/** What one removal finding asks: a protected location (`dangerous_delete`), or a target the command builds from a value
|
|
34
|
+
* of its own that the guard cannot read (`unverifiable_delete`). */
|
|
35
|
+
export type RemovalFinding = (DangerousRemoval & {
|
|
36
|
+
readonly guard: "dangerous_delete";
|
|
37
|
+
}) | {
|
|
38
|
+
readonly guard: "unverifiable_delete";
|
|
39
|
+
readonly program: string;
|
|
40
|
+
readonly operand: string;
|
|
41
|
+
readonly name: string;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The removal findings of an established plan — ONE walk, nested plans included (a `bash -c` / `sh -c` text, a substitution
|
|
45
|
+
* body), two words. For every `rm` / `rmdir` segment, each of its OPERANDS (every write target that is not one of its
|
|
46
|
+
* option words, read off its argv with {@link REMOVAL_GRAMMAR}) as the plan established it on the WRITE face (the entry the
|
|
47
|
+
* program unlinks, and the deny-floor shadow of a home word the plan did not place) is judged by {@link dangerousRemovalOf};
|
|
48
|
+
* an operand the plan could not place that expands a name the command itself binds is judged in every spelling those
|
|
49
|
+
* bindings give it ({@link boundSpellingsOf}), and one with a binding this reading cannot read is an unverifiable target (a
|
|
50
|
+
* recursive force `rm`). A shell handed a script built only when the command runs is a dangerous removal of its own
|
|
51
|
+
* ({@link runtimeScriptOf}). The read-face copy of an operand is not judged: it follows a link the removal does not follow.
|
|
52
|
+
*/
|
|
53
|
+
export declare function removalFindingsOf(established: EstablishedPlan, seat: RemovalSeat): RemovalFinding[];
|
|
54
|
+
/** Does any segment of this plan (nested plans included) run a removal program, or hand a shell a script built when it
|
|
55
|
+
* runs? The guard establishes nothing for a command that does neither. */
|
|
56
|
+
export declare function planRunsRemoval(plan: EstablishedPlan["plan"]): boolean;
|
|
57
|
+
/** What the removal guard asks for one command: the guard word and its sentence's account — the first protected location
|
|
58
|
+
* (the program, the location, the target), else the first bound value the guard cannot read; `undefined` when neither. */
|
|
59
|
+
export interface RemovalVerdict {
|
|
60
|
+
readonly guard: RemovalFinding["guard"];
|
|
61
|
+
readonly text: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The seat the dangerous-removal arm of the removal guard (`createUnverifiableDeletePolicy`, tool-policy.ts) reads, beside the request's own `root` /
|
|
65
|
+
* `cwd` (the task root and the live working directory): the execution environment (its declared home — the `HOME` the
|
|
66
|
+
* shell child receives, the same seat the read station folds `~` / `$HOME` with — and its resolver, so a target that
|
|
67
|
+
* resolves through a link is judged where it lands), and the leg's additional working directories.
|
|
68
|
+
*/
|
|
69
|
+
export interface RemovalGuardSeat {
|
|
70
|
+
readonly env?: ExecutionEnv;
|
|
71
|
+
readonly additionalRoots?: () => readonly string[];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The dangerous-removal READING of one command — undefined when no segment of it (nested shell text included) runs a
|
|
75
|
+
* removal program, so a command that removes nothing costs the gate no suspension. Otherwise `judge` establishes the plan
|
|
76
|
+
* the read station plans (the same planner, the same write-target reading) lexically against the request's `cwd` and the
|
|
77
|
+
* seat's home, and — where an environment is seated — through its resolver too, and returns what either establishment
|
|
78
|
+
* finds ({@link removalFindingsOf}: the protected-location word first, then the unverifiable one).
|
|
79
|
+
*/
|
|
80
|
+
export declare function removalReadingOf(command: string, req: Pick<ToolCallRequest, "cwd" | "root">, seat: RemovalGuardSeat | undefined): {
|
|
81
|
+
judge(signal?: AbortSignal): Promise<RemovalVerdict | undefined>;
|
|
82
|
+
} | undefined;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { planShellReads } from "./shell-read-plan.js";
|
|
2
|
+
import { createShellReadEstablisher, establishShellReadsLexically } from "./shell-read-establish.js";
|
|
3
|
+
import { isAbsolutePathForm, isWinFormPath, normalizeAbsPathLexically } from "./safety.js";
|
|
4
|
+
import { reentryText } from "./shell-reentry-text.js";
|
|
5
|
+
import { bashClassifyDefaultAllow, SHELL_REENTRY_PROGRAM_RE } from "./bash-readonly-classifier.js";
|
|
6
|
+
import { programArgs, readArgv } from "./shell-read-write-targets.js";
|
|
7
|
+
import { inlineUntrusted } from "../../core/untrusted-text.js";
|
|
8
|
+
export const SHELL_REMOVAL_COMMANDS = Object.freeze(["rm", "rmdir"]);
|
|
9
|
+
const REMOVAL_COMMAND_SET = new Set(SHELL_REMOVAL_COMMANDS);
|
|
10
|
+
const REMOVAL_GRAMMAR = { required: "", optional: "", longRequired: [] };
|
|
11
|
+
function removalKey(path) {
|
|
12
|
+
let k = normalizeAbsPathLexically(path.normalize("NFC")).replace(/\\/g, "/").toLowerCase();
|
|
13
|
+
k = k.replace(/(?:\/+\*+)+\/*$/, "");
|
|
14
|
+
const drive = driveOf(path, k);
|
|
15
|
+
let rest = k.slice(drive.length);
|
|
16
|
+
if (rest === "")
|
|
17
|
+
rest = "/";
|
|
18
|
+
if (rest.length > 1)
|
|
19
|
+
rest = rest.replace(/\/+$/, "");
|
|
20
|
+
return drive + rest.replace(/^\/private\/(etc|var|tmp|home)(?=\/|$)/, "/$1");
|
|
21
|
+
}
|
|
22
|
+
function driveOf(path, key) {
|
|
23
|
+
return isWinFormPath(path) && key.length >= 2 && key[1] === ":" ? key.slice(0, 2) : "";
|
|
24
|
+
}
|
|
25
|
+
export function dangerousRemovalOf(path, seat) {
|
|
26
|
+
if (!isAbsolutePathForm(path))
|
|
27
|
+
return undefined;
|
|
28
|
+
const k = removalKey(path);
|
|
29
|
+
const rest = k.slice(driveOf(path, k).length);
|
|
30
|
+
if (rest === "/" || /^\/\/[^/]+\/[^/]+$/.test(k))
|
|
31
|
+
return "filesystem_root";
|
|
32
|
+
if (seat.homes.some((h) => isAbsolutePathForm(h) && removalKey(h) === k))
|
|
33
|
+
return "home";
|
|
34
|
+
const prefix = k.endsWith("/") ? k : `${k}/`;
|
|
35
|
+
if (seat.workspace.some((w) => isAbsolutePathForm(w) && (removalKey(w) === k || removalKey(w).startsWith(prefix))))
|
|
36
|
+
return "workspace";
|
|
37
|
+
if (/^\/[^/]+$/.test(rest))
|
|
38
|
+
return "top_level";
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
function tildeNamesAHome(spelled) {
|
|
42
|
+
const m = /^~([^/]*)(\/.*)?$/.exec(spelled);
|
|
43
|
+
if (m === null)
|
|
44
|
+
return false;
|
|
45
|
+
const key = removalKey(`/~home${m[2] ?? ""}`);
|
|
46
|
+
return key === "/~home" || !key.startsWith("/~home/");
|
|
47
|
+
}
|
|
48
|
+
function removalKindOf(read, seat) {
|
|
49
|
+
const floors = read.established === "unestablishable" && read.candidate.bases === "unmodelled" ? read.spellings : [];
|
|
50
|
+
for (const view of [read.target, read.origin, ...floors]) {
|
|
51
|
+
const kind = view === undefined ? undefined : dangerousRemovalOf(view, seat);
|
|
52
|
+
if (kind !== undefined)
|
|
53
|
+
return kind;
|
|
54
|
+
}
|
|
55
|
+
return read.established === "unestablishable" && tildeNamesAHome(read.candidate.spelled) ? "home" : undefined;
|
|
56
|
+
}
|
|
57
|
+
function literalOf(pieces) {
|
|
58
|
+
return pieces.every((q) => q.literal !== undefined) ? pieces.map((q) => q.literal).join("") : undefined;
|
|
59
|
+
}
|
|
60
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
61
|
+
const NAME_BINDERS = new Set(["unset", "local", "declare", "typeset", "readonly", "export", "read"]);
|
|
62
|
+
function bindingsOf(plan) {
|
|
63
|
+
const out = new Map();
|
|
64
|
+
const bind = (name, value) => {
|
|
65
|
+
out.set(name, [...(out.get(name) ?? []), value]);
|
|
66
|
+
};
|
|
67
|
+
for (const segment of plan.segments) {
|
|
68
|
+
for (const w of segment.argv)
|
|
69
|
+
if (w.assignment !== undefined)
|
|
70
|
+
bind(w.assignment.name, w.assignment.settlesWholeName ? literalOf(w.assignment.value.pieces) : undefined);
|
|
71
|
+
const [head, name] = segment.argv;
|
|
72
|
+
if ((head?.folded === "for" || head?.folded === "select") && name !== undefined && NAME.test(name.folded)) {
|
|
73
|
+
const at = segment.argv.findIndex((w, i) => i > 1 && w.folded === "in");
|
|
74
|
+
const words = at < 0 ? [undefined] : segment.argv.slice(at + 1).map((w) => literalOf(w.pieces));
|
|
75
|
+
for (const v of words.length > 0 ? words : [undefined])
|
|
76
|
+
bind(name.folded, v);
|
|
77
|
+
}
|
|
78
|
+
if (head !== undefined && NAME_BINDERS.has(head.folded))
|
|
79
|
+
for (const w of segment.argv.slice(1))
|
|
80
|
+
if (w.assignment === undefined && NAME.test(w.folded))
|
|
81
|
+
bind(w.folded, undefined);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
const BOUND_SPELLINGS_MAX = 64;
|
|
86
|
+
function boundSpellingsOf(word, bindings) {
|
|
87
|
+
const named = word.pieces.filter((q) => q.literal === undefined);
|
|
88
|
+
if (named.length === 0 || !named.some((q) => q.expansion?.kind === "parameter" && q.expansion.name !== undefined && bindings.has(q.expansion.name)))
|
|
89
|
+
return undefined;
|
|
90
|
+
let spellings = [""];
|
|
91
|
+
for (const q of word.pieces) {
|
|
92
|
+
if (q.literal !== undefined) {
|
|
93
|
+
spellings = spellings.map((sp) => sp + q.literal);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const name = q.expansion?.kind === "parameter" && (q.expansion.body === undefined || q.expansion.body === q.expansion.name) ? q.expansion.name : undefined;
|
|
97
|
+
const values = name !== undefined ? bindings.get(name) : undefined;
|
|
98
|
+
if (name === undefined || values === undefined)
|
|
99
|
+
return { unreadable: q.expansion?.name ?? word.raw };
|
|
100
|
+
if (values.some((v) => v === undefined || (q.quotedBy === undefined && /\s/.test(v))))
|
|
101
|
+
return { unreadable: name };
|
|
102
|
+
spellings = spellings.flatMap((sp) => values.map((v) => sp + v));
|
|
103
|
+
if (spellings.length > BOUND_SPELLINGS_MAX)
|
|
104
|
+
return { unreadable: name };
|
|
105
|
+
}
|
|
106
|
+
return { spellings };
|
|
107
|
+
}
|
|
108
|
+
function recursiveForce(args) {
|
|
109
|
+
const { options } = readArgv(args, REMOVAL_GRAMMAR);
|
|
110
|
+
const has = (long, short) => options.some((o) => (o.long ? long.startsWith(o.name) : short.test(o.name)));
|
|
111
|
+
return has("recursive", /^[rR]$/) && has("force", /^f$/);
|
|
112
|
+
}
|
|
113
|
+
export function removalFindingsOf(established, seat) {
|
|
114
|
+
const out = [];
|
|
115
|
+
const bindings = bindingsOf(established.plan);
|
|
116
|
+
for (const segment of established.plan.segments) {
|
|
117
|
+
const program = segment.verb?.basename;
|
|
118
|
+
if (program !== undefined && REMOVAL_COMMAND_SET.has(program)) {
|
|
119
|
+
const args = programArgs(segment);
|
|
120
|
+
const operands = new Set(readArgv(args, REMOVAL_GRAMMAR).operands);
|
|
121
|
+
const options = new Set(args.filter((w) => !operands.has(w)).map((w) => w.raw));
|
|
122
|
+
for (const read of established.reads.get(segment) ?? []) {
|
|
123
|
+
if (read.candidate.face !== "write" || options.has(read.candidate.raw))
|
|
124
|
+
continue;
|
|
125
|
+
const kind = removalKindOf(read, seat);
|
|
126
|
+
if (kind !== undefined) {
|
|
127
|
+
out.push({ guard: "dangerous_delete", kind, program, operand: read.candidate.raw, target: read.target ?? read.candidate.floorSpelled ?? read.candidate.spelled });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (read.established !== "unestablishable")
|
|
131
|
+
continue;
|
|
132
|
+
const word = segment.argv.find((w) => w.raw === read.candidate.raw);
|
|
133
|
+
const bound = word === undefined ? undefined : boundSpellingsOf(word, bindings);
|
|
134
|
+
if (bound === undefined)
|
|
135
|
+
continue;
|
|
136
|
+
if ("unreadable" in bound) {
|
|
137
|
+
if (program === "rm" && recursiveForce(args))
|
|
138
|
+
out.push({ guard: "unverifiable_delete", program, operand: read.candidate.raw, name: bound.unreadable });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const bases = segment.bases === "unmodelled" ? (read.candidate.floorBases ?? []) : segment.bases;
|
|
142
|
+
for (const spelled of bound.spellings) {
|
|
143
|
+
const views = isAbsolutePathForm(spelled) ? [spelled] : bases.map((base) => `${base}/${spelled}`);
|
|
144
|
+
for (const view of views) {
|
|
145
|
+
const hit = dangerousRemovalOf(view, seat);
|
|
146
|
+
if (hit === undefined)
|
|
147
|
+
continue;
|
|
148
|
+
out.push({ guard: "dangerous_delete", kind: hit, program, operand: read.candidate.raw, target: normalizeAbsPathLexically(view) });
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const script = runtimeScriptOf(segment);
|
|
155
|
+
if (script !== undefined && program !== undefined)
|
|
156
|
+
out.push({ guard: "dangerous_delete", kind: "runtime_script", program, operand: script, target: script });
|
|
157
|
+
for (const nested of segment.nested) {
|
|
158
|
+
const sub = established.nested.get(nested);
|
|
159
|
+
if (sub !== undefined)
|
|
160
|
+
out.push(...removalFindingsOf(sub, seat));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
function runtimeScriptOf(segment) {
|
|
166
|
+
const program = segment.verb;
|
|
167
|
+
if (segment.reentry !== "shell" || program === undefined || !SHELL_REENTRY_PROGRAM_RE.test(program.basename))
|
|
168
|
+
return undefined;
|
|
169
|
+
const at = segment.argv.findIndex((w) => w.folded === program.name);
|
|
170
|
+
if (at < 0)
|
|
171
|
+
return undefined;
|
|
172
|
+
const MARK = "\u0000";
|
|
173
|
+
const words = segment.argv.slice(at + 1).map((w) => literalOf(w.pieces) ?? `${MARK}${w.raw}`);
|
|
174
|
+
const text = reentryText(program.basename, words);
|
|
175
|
+
return text === undefined || !text.includes(MARK) ? undefined : text.split(MARK).join("");
|
|
176
|
+
}
|
|
177
|
+
export function planRunsRemoval(plan) {
|
|
178
|
+
return plan.segments.some((s) => (s.verb !== undefined && REMOVAL_COMMAND_SET.has(s.verb.basename)) || runtimeScriptOf(s) !== undefined || s.nested.some(planRunsRemoval));
|
|
179
|
+
}
|
|
180
|
+
const DANGEROUS_REMOVAL_SENTENCE = {
|
|
181
|
+
filesystem_root: (p, t) => `\`${p}\` would remove the filesystem root (${t})`,
|
|
182
|
+
home: (p, t) => `\`${p}\` would remove a home directory (${t})`,
|
|
183
|
+
workspace: (p, t) => `\`${p}\` would remove a workspace directory — the task root, the working directory or an additional working directory — or one of its ancestors (${t})`,
|
|
184
|
+
top_level: (p, t) => `\`${p}\` would remove a top-level system directory (${t})`,
|
|
185
|
+
runtime_script: (p, t) => `\`${p}\` runs a script built only when the command runs (${t}), and it may remove the filesystem root, a home directory or the task's directories before anyone can read it`,
|
|
186
|
+
};
|
|
187
|
+
function verdictOf(findings) {
|
|
188
|
+
for (const f of findings)
|
|
189
|
+
if (f.guard === "dangerous_delete")
|
|
190
|
+
return { guard: f.guard, text: DANGEROUS_REMOVAL_SENTENCE[f.kind](f.program, inlineUntrusted(f.target, 200)) };
|
|
191
|
+
for (const f of findings)
|
|
192
|
+
if (f.guard === "unverifiable_delete")
|
|
193
|
+
return { guard: f.guard, text: `\`${f.program}\` recursive+force target ${inlineUntrusted(f.operand, 200)} takes ${f.name}'s value, which this command sets in a form the guard cannot read (an expansion, an unset or bare declaration, a \`read\`, a loop over unread words) — the deleted path cannot be verified from this command` };
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
const REMOVAL_RESOLVE_TIMEOUT_MS = 10_000;
|
|
197
|
+
export function removalReadingOf(command, req, seat) {
|
|
198
|
+
const homeDir = seat?.env?.homeDir;
|
|
199
|
+
const cwd = req.cwd ?? req.root ?? "";
|
|
200
|
+
const roots = req.root !== undefined ? [req.root] : cwd !== "" ? [cwd] : [];
|
|
201
|
+
const boundary = { roots, ...(cwd !== "" ? { cwd } : {}), ...(homeDir !== undefined ? { homeDir } : {}) };
|
|
202
|
+
const plan = planShellReads(command, { readers: new Set(bashClassifyDefaultAllow()), cwd, roots, ...(homeDir !== undefined ? { homeDir } : {}) });
|
|
203
|
+
if (!planRunsRemoval(plan))
|
|
204
|
+
return undefined;
|
|
205
|
+
return {
|
|
206
|
+
async judge(signal) {
|
|
207
|
+
const env = seat?.env;
|
|
208
|
+
const resolved = env !== undefined && homeDir !== undefined ? await resolvedOrUndefined(env.canonicalPath(homeDir, signal), signal) : undefined;
|
|
209
|
+
const removalSeat = {
|
|
210
|
+
homes: [...(homeDir !== undefined ? [homeDir] : []), ...(resolved !== undefined && resolved.ok ? [resolved.value] : [])],
|
|
211
|
+
workspace: [...(req.root !== undefined ? [req.root] : []), ...(req.cwd !== undefined ? [req.cwd] : []), ...(seat?.additionalRoots?.() ?? [])],
|
|
212
|
+
};
|
|
213
|
+
const lexical = removalFindingsOf(establishShellReadsLexically(plan, boundary), removalSeat);
|
|
214
|
+
if (lexical.some((f) => f.guard === "dangerous_delete") || env === undefined || roots.length === 0)
|
|
215
|
+
return verdictOf(lexical);
|
|
216
|
+
const established = await resolvedOrUndefined(createShellReadEstablisher(env, roots, { rootsCanonical: true }).establish(plan, boundary, signal), signal);
|
|
217
|
+
return verdictOf(established === undefined ? lexical : [...removalFindingsOf(established, removalSeat), ...lexical]);
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
async function resolvedOrUndefined(leg, signal) {
|
|
222
|
+
let timer;
|
|
223
|
+
try {
|
|
224
|
+
return await Promise.race([
|
|
225
|
+
leg.catch(() => undefined),
|
|
226
|
+
new Promise((resolve) => {
|
|
227
|
+
timer = setTimeout(() => resolve(undefined), REMOVAL_RESOLVE_TIMEOUT_MS);
|
|
228
|
+
signal?.addEventListener("abort", () => resolve(undefined), { once: true });
|
|
229
|
+
}),
|
|
230
|
+
]);
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
if (timer !== undefined)
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -27,6 +27,32 @@ export interface ArgWord {
|
|
|
27
27
|
}
|
|
28
28
|
/** The words a segment hands its program — the argv after the program word (a launcher's own words are not the program's). */
|
|
29
29
|
export declare function programArgs(segment: ShellReadSegment): ArgWord[];
|
|
30
|
+
/**
|
|
31
|
+
* One program's option GRAMMAR, as its getopt reads it: the short letters taking a REQUIRED value (the rest of the word,
|
|
32
|
+
* or the next word), those taking an OPTIONAL one (the rest of the word only), and the long names taking a required value
|
|
33
|
+
* (any unambiguous prefix; `=` or the next word) — absent for a program with no long options (a `--name` word is then an
|
|
34
|
+
* error the program stops on, and names nothing here). The GNU and BSD programs of one name read the same argv
|
|
35
|
+
* differently (`sed -l` takes a value in GNU and none in BSD; `cp -S` a suffix in GNU and none in BSD), so every question
|
|
36
|
+
* below reads the argv under BOTH grammars and takes the union of what they name.
|
|
37
|
+
*/
|
|
38
|
+
export interface OptionGrammar {
|
|
39
|
+
readonly required: string;
|
|
40
|
+
readonly optional: string;
|
|
41
|
+
readonly longRequired?: readonly string[];
|
|
42
|
+
}
|
|
43
|
+
/** One option read off an argv: a short letter or a long name as written (a prefix), and its value when it has one. */
|
|
44
|
+
interface OptionRead {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly long: boolean;
|
|
47
|
+
readonly value?: ArgWord;
|
|
48
|
+
}
|
|
49
|
+
/** An argv read under one grammar: its options in order, and its operands (every word after `--` included). Options are
|
|
50
|
+
* read anywhere among the operands, as GNU getopt permutes them — for a BSD getopt, which stops at the first operand,
|
|
51
|
+
* that is a superset of its options (the tightening direction). */
|
|
52
|
+
export declare function readArgv(words: readonly ArgWord[], grammar: OptionGrammar): {
|
|
53
|
+
readonly options: readonly OptionRead[];
|
|
54
|
+
readonly operands: readonly ArgWord[];
|
|
55
|
+
};
|
|
30
56
|
/** What a `sed` invocation's option words say about writing, under either grammar ({@link SED_GRAMMARS}): whether it edits
|
|
31
57
|
* in place (`-i` / `-I` / `--in-place`), the backup suffixes it is given (their values; an empty value keeps no backup),
|
|
32
58
|
* and whether GNU `--follow-symlinks` is on. */
|
|
@@ -71,3 +97,4 @@ export interface JudgedCandidate {
|
|
|
71
97
|
* lists it among the reads), plus its WRITE targets for the write face ({@link writeTargetsOf}), plus the deny-floor
|
|
72
98
|
* shadow of every unplaced home word ({@link denyFloorShadowsOf}) on both faces. */
|
|
73
99
|
export declare function judgedCandidatesOf(segment: ShellReadSegment): readonly JudgedCandidate[];
|
|
100
|
+
export {};
|
|
@@ -21,7 +21,7 @@ const COPY_GRAMMARS = [
|
|
|
21
21
|
{ required: "St", optional: "", longRequired: ["target-directory", "suffix", "no-preserve", "sparse"] },
|
|
22
22
|
{ required: "", optional: "" },
|
|
23
23
|
];
|
|
24
|
-
function readArgv(words, grammar) {
|
|
24
|
+
export function readArgv(words, grammar) {
|
|
25
25
|
const options = [];
|
|
26
26
|
const operands = [];
|
|
27
27
|
let endOfOptions = false;
|