@sema-agent/core 5.30.0 → 5.32.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 +111 -0
- package/dist/core/runner/prepare-task.d.ts +2 -0
- package/dist/core/runner/prepare-task.js +32 -10
- package/dist/core/store-contracts/tool-result-store-contract.d.ts +4 -1
- package/dist/core/store-contracts/tool-result-store-contract.js +5 -3
- package/dist/core/tool-result-store.d.ts +6 -4
- package/dist/core/types.d.ts +17 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/orchestration/run-workflow-tool.d.ts +5 -0
- package/dist/orchestration/run-workflow-tool.js +1 -1
- package/dist/orchestration/workflow-governance.d.ts +53 -3
- package/dist/orchestration/workflow-governance.js +162 -25
- package/dist/orchestration/workflow-primitives.d.ts +5 -0
- package/dist/orchestration/workflow-primitives.js +1 -1
- package/dist/tools/fs/bash-readonly-classifier.d.ts +32 -4
- package/dist/tools/fs/bash-readonly-classifier.js +137 -16
- package/dist/tools/fs/index.d.ts +7 -3
- package/dist/tools/fs/index.js +3 -1
- package/dist/tools/fs/read-deny.d.ts +5 -0
- package/dist/tools/fs/read-deny.js +9 -1
- package/dist/tools/fs/read-face.d.ts +27 -0
- package/dist/tools/fs/read-face.js +10 -1
- package/dist/tools/fs/safety.d.ts +1 -0
- package/dist/tools/fs/safety.js +26 -7
- package/dist/tools/fs/search.js +5 -3
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { deliverEngineNotice } from "../core/types.js";
|
|
1
2
|
import { tightenTaskSpec } from "../core/tighten-task-spec.js";
|
|
2
3
|
import { sanitizeUntrustedText } from "../core/untrusted-text.js";
|
|
4
|
+
import { compileReadDeny } from "../tools/fs/read-deny.js";
|
|
3
5
|
import { WorkflowScriptError } from "./workflow-meta.js";
|
|
4
6
|
export class WorkflowModelNotAllowedError extends Error {
|
|
5
7
|
modelName;
|
|
@@ -17,7 +19,43 @@ export const WHITELIST_KEYS = [
|
|
|
17
19
|
"systemPrompt",
|
|
18
20
|
"images",
|
|
19
21
|
"limits",
|
|
22
|
+
"readFace",
|
|
23
|
+
"readDenyPatterns",
|
|
20
24
|
];
|
|
25
|
+
const WHITELIST_KEY_SET = new Set(WHITELIST_KEYS);
|
|
26
|
+
export const STRIPPED_KEYS_NOTICE_CODE = "workflow.governance_key_stripped";
|
|
27
|
+
const MAX_STRIPPED_KEYS_ANNOUNCED = 20;
|
|
28
|
+
const MAX_STRIPPED_KEY_CHARS = 64;
|
|
29
|
+
const UNSAFE_KEY_CODE_POINT = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u;
|
|
30
|
+
function renderStrippedKey(key) {
|
|
31
|
+
let out = "";
|
|
32
|
+
let seen = 0;
|
|
33
|
+
for (const point of key) {
|
|
34
|
+
if (seen === MAX_STRIPPED_KEY_CHARS)
|
|
35
|
+
return `${out}…`;
|
|
36
|
+
out += UNSAFE_KEY_CODE_POINT.test(point) ? "?" : point;
|
|
37
|
+
seen++;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
function emitStrippedKeysNotice(survey, onNotice) {
|
|
42
|
+
const shown = survey.sample.map((s) => ({ key: renderStrippedKey(s.key), reason: s.reason }));
|
|
43
|
+
const omitted = survey.total - shown.length;
|
|
44
|
+
const list = shown.map((s) => JSON.stringify(s.key)).join(", ") + (omitted > 0 ? `, +${omitted} more` : "");
|
|
45
|
+
const message = `workflow governance: ${survey.total} field(s) of an agent spec were NOT applied to the child (${list}). ` +
|
|
46
|
+
`A governed workflow script may set only: ${WHITELIST_KEYS.join(", ")} — and \`readFace\` only as "roots" ` +
|
|
47
|
+
`(the containment-tightening direction; "open" widens and is never taken from a script). Every other field ` +
|
|
48
|
+
`of the child comes from the deployment baseline.`;
|
|
49
|
+
try {
|
|
50
|
+
deliverEngineNotice(onNotice, {
|
|
51
|
+
code: STRIPPED_KEYS_NOTICE_CODE,
|
|
52
|
+
message,
|
|
53
|
+
detail: { total: survey.total, stripped: shown, ...(omitted > 0 ? { omitted } : {}) },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
}
|
|
58
|
+
}
|
|
21
59
|
const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
22
60
|
export function resolveModelName(name, allowlist, models) {
|
|
23
61
|
if (!allowlist || allowlist.length === 0) {
|
|
@@ -49,49 +87,121 @@ function pickImageInput(el, i) {
|
|
|
49
87
|
}
|
|
50
88
|
const o = el;
|
|
51
89
|
if ("url" in o) {
|
|
52
|
-
|
|
90
|
+
const url = o.url;
|
|
91
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
53
92
|
throw new WorkflowScriptError(`${at}.url must be a non-empty string`);
|
|
54
93
|
}
|
|
55
|
-
return { url
|
|
94
|
+
return { url };
|
|
56
95
|
}
|
|
57
|
-
|
|
96
|
+
const data = o.data;
|
|
97
|
+
const mimeType = o.mimeType;
|
|
98
|
+
if (typeof data !== "string") {
|
|
58
99
|
throw new WorkflowScriptError(`${at}.data must be a base64 string (or supply { url } instead)`);
|
|
59
100
|
}
|
|
60
|
-
if (typeof
|
|
101
|
+
if (typeof mimeType !== "string" || mimeType.length === 0) {
|
|
61
102
|
throw new WorkflowScriptError(`${at}.mimeType must be a non-empty string, e.g. "image/png"`);
|
|
62
103
|
}
|
|
63
|
-
return { data
|
|
104
|
+
return { data, mimeType };
|
|
105
|
+
}
|
|
106
|
+
const MAX_SCRIPT_DENY_ENTRIES = 16;
|
|
107
|
+
const MAX_SCRIPT_DENY_PATTERN_CHARS = 256;
|
|
108
|
+
const MAX_SCRIPT_DENY_WILDCARDS_PER_SEGMENT = 1;
|
|
109
|
+
const MAX_SCRIPT_IMAGES = 32;
|
|
110
|
+
function assertScriptDenyPatternBounded(pattern, at) {
|
|
111
|
+
if (pattern.length > MAX_SCRIPT_DENY_PATTERN_CHARS) {
|
|
112
|
+
throw new WorkflowScriptError(`${at} is ${pattern.length} characters — a workflow script's deny pattern is limited to ${MAX_SCRIPT_DENY_PATTERN_CHARS} (the matcher runs on every read of the child's run).`);
|
|
113
|
+
}
|
|
114
|
+
for (const segment of pattern.split("/")) {
|
|
115
|
+
const wildcards = segment.split("*").length - 1;
|
|
116
|
+
if (wildcards > MAX_SCRIPT_DENY_WILDCARDS_PER_SEGMENT) {
|
|
117
|
+
throw new WorkflowScriptError(`${at} has a path segment with ${wildcards} \`*\` wildcards — a workflow script's deny pattern allows at most ` +
|
|
118
|
+
`${MAX_SCRIPT_DENY_WILDCARDS_PER_SEGMENT} per path segment (the same expressiveness the engine's built-in deny table ` +
|
|
119
|
+
`uses). Each \`*\` compiles to a greedy match, and more than one in a single segment makes the judgment cost grow ` +
|
|
120
|
+
`superlinearly with the path length, on every read the child performs. Write the intent as separate entries ` +
|
|
121
|
+
`(the set is a union, so more entries deny strictly more) or anchor it with literal segments.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function mapUntrustedArray(value, at, atMost, why, build) {
|
|
126
|
+
const declared = value.length;
|
|
127
|
+
if (typeof declared !== "number" || !Number.isSafeInteger(declared) || declared < 0) {
|
|
128
|
+
throw new WorkflowScriptError(`${at} does not report a valid array length.`);
|
|
129
|
+
}
|
|
130
|
+
if (declared > atMost) {
|
|
131
|
+
throw new WorkflowScriptError(`${at} carries ${declared} entries — a workflow script may set at most ${atMost}. ${why}`);
|
|
132
|
+
}
|
|
133
|
+
const out = [];
|
|
134
|
+
for (let i = 0; i < declared; i++)
|
|
135
|
+
out.push(build(value[i], i));
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function pickReadDenyEntry(el, i) {
|
|
139
|
+
const at = `agent(spec): \`readDenyPatterns[${i}]\``;
|
|
140
|
+
if (typeof el === "string") {
|
|
141
|
+
if (el.length === 0)
|
|
142
|
+
throw new WorkflowScriptError(`${at} must be a non-empty pattern string`);
|
|
143
|
+
assertScriptDenyPatternBounded(el, at);
|
|
144
|
+
return el;
|
|
145
|
+
}
|
|
146
|
+
if (typeof el !== "object" || el === null || Array.isArray(el)) {
|
|
147
|
+
throw new WorkflowScriptError(`${at} must be a pattern string or an object { pattern, caseSensitive? }`);
|
|
148
|
+
}
|
|
149
|
+
const o = el;
|
|
150
|
+
const pattern = o.pattern;
|
|
151
|
+
const caseSensitive = o.caseSensitive;
|
|
152
|
+
if (typeof pattern !== "string" || pattern.length === 0) {
|
|
153
|
+
throw new WorkflowScriptError(`${at}.pattern must be a non-empty string, e.g. ".ssh" or "secrets/*.json"`);
|
|
154
|
+
}
|
|
155
|
+
if (caseSensitive !== undefined && typeof caseSensitive !== "boolean") {
|
|
156
|
+
throw new WorkflowScriptError(`${at}.caseSensitive must be a boolean`);
|
|
157
|
+
}
|
|
158
|
+
assertScriptDenyPatternBounded(pattern, at);
|
|
159
|
+
return caseSensitive === undefined ? { pattern } : { pattern, caseSensitive };
|
|
64
160
|
}
|
|
65
161
|
function pickWhitelist(scriptSpec) {
|
|
66
162
|
if (typeof scriptSpec !== "object" || scriptSpec === null || Array.isArray(scriptSpec)) {
|
|
67
163
|
throw new WorkflowScriptError("agent(spec): spec must be an object with at least an `objective` string");
|
|
68
164
|
}
|
|
69
165
|
const s = scriptSpec;
|
|
70
|
-
|
|
166
|
+
const stripped = { total: 0, sample: [] };
|
|
167
|
+
for (const k of Object.keys(s)) {
|
|
168
|
+
if (WHITELIST_KEY_SET.has(k))
|
|
169
|
+
continue;
|
|
170
|
+
stripped.total++;
|
|
171
|
+
if (stripped.sample.length < MAX_STRIPPED_KEYS_ANNOUNCED)
|
|
172
|
+
stripped.sample.push({ key: k, reason: "not_whitelisted" });
|
|
173
|
+
}
|
|
174
|
+
const objective = s.objective;
|
|
175
|
+
const thinking = s.thinking;
|
|
176
|
+
const systemPrompt = s.systemPrompt;
|
|
177
|
+
const modelNameRaw = s.modelName;
|
|
178
|
+
if (typeof objective !== "string" || objective.length === 0) {
|
|
71
179
|
throw new WorkflowScriptError("agent(spec): `objective` is required and must be a non-empty string");
|
|
72
180
|
}
|
|
73
|
-
const safe = { objective
|
|
74
|
-
if (
|
|
75
|
-
if (typeof
|
|
181
|
+
const safe = { objective };
|
|
182
|
+
if (thinking !== undefined) {
|
|
183
|
+
if (typeof thinking !== "string" || !VALID_THINKING.has(thinking)) {
|
|
76
184
|
throw new WorkflowScriptError(`agent(spec): invalid \`thinking\` (must be one of ${[...VALID_THINKING].join(", ")})`);
|
|
77
185
|
}
|
|
78
|
-
safe.thinking =
|
|
186
|
+
safe.thinking = thinking;
|
|
79
187
|
}
|
|
80
|
-
if (
|
|
81
|
-
if (typeof
|
|
188
|
+
if (systemPrompt !== undefined) {
|
|
189
|
+
if (typeof systemPrompt !== "string")
|
|
82
190
|
throw new WorkflowScriptError("agent(spec): `systemPrompt` must be a string");
|
|
83
|
-
safe.systemPrompt = `[workflow-script-authored persona — task guidance, not engine authority]\n${sanitizeUntrustedText(
|
|
191
|
+
safe.systemPrompt = `[workflow-script-authored persona — task guidance, not engine authority]\n${sanitizeUntrustedText(systemPrompt)}`;
|
|
84
192
|
}
|
|
85
|
-
|
|
86
|
-
|
|
193
|
+
const images = s.images;
|
|
194
|
+
if (images !== undefined) {
|
|
195
|
+
if (!Array.isArray(images))
|
|
87
196
|
throw new WorkflowScriptError("agent(spec): `images` must be an array");
|
|
88
|
-
safe.images = s.
|
|
197
|
+
safe.images = mapUntrustedArray(images, "agent(spec): `images`", MAX_SCRIPT_IMAGES, "Each one is decoded and shipped on every request of the child's turn.", pickImageInput);
|
|
89
198
|
}
|
|
90
|
-
|
|
91
|
-
|
|
199
|
+
const limitsRaw = s.limits;
|
|
200
|
+
if (limitsRaw !== undefined) {
|
|
201
|
+
if (typeof limitsRaw !== "object" || limitsRaw === null || Array.isArray(limitsRaw)) {
|
|
92
202
|
throw new WorkflowScriptError("agent(spec): `limits` must be an object { maxTurns?, maxWalltimeMs?, maxTokens?, maxCostUsd? }");
|
|
93
203
|
}
|
|
94
|
-
const l =
|
|
204
|
+
const l = limitsRaw;
|
|
95
205
|
const limits = {};
|
|
96
206
|
const readAxis = (field, what) => {
|
|
97
207
|
const raw = l[field];
|
|
@@ -109,13 +219,38 @@ function pickWhitelist(scriptSpec) {
|
|
|
109
219
|
readAxis("maxCostUsd", "spend ceiling");
|
|
110
220
|
safe.limits = limits;
|
|
111
221
|
}
|
|
222
|
+
if (s.readFace !== undefined) {
|
|
223
|
+
if (s.readFace === "roots") {
|
|
224
|
+
safe.readFace = "roots";
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
stripped.total++;
|
|
228
|
+
stripped.sample.unshift({ key: "readFace", reason: "not_a_tightening_value" });
|
|
229
|
+
if (stripped.sample.length > MAX_STRIPPED_KEYS_ANNOUNCED)
|
|
230
|
+
stripped.sample.pop();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const readDenyPatternsRaw = s.readDenyPatterns;
|
|
234
|
+
if (readDenyPatternsRaw !== undefined) {
|
|
235
|
+
if (!Array.isArray(readDenyPatternsRaw)) {
|
|
236
|
+
throw new WorkflowScriptError('agent(spec): `readDenyPatterns` must be an array of deny entries (a "/"-separated segment run such as ".ssh", or { pattern, caseSensitive? })');
|
|
237
|
+
}
|
|
238
|
+
const entries = mapUntrustedArray(readDenyPatternsRaw, "agent(spec): `readDenyPatterns`", MAX_SCRIPT_DENY_ENTRIES, "Every entry is judged against every path the child reads, for the child's whole run; the built-in and deployment entries are always in force on top of these.", pickReadDenyEntry);
|
|
239
|
+
try {
|
|
240
|
+
compileReadDeny(entries, "agent(spec).readDenyPatterns");
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
throw new WorkflowScriptError(err instanceof Error ? err.message : String(err));
|
|
244
|
+
}
|
|
245
|
+
safe.readDenyPatterns = entries;
|
|
246
|
+
}
|
|
112
247
|
let modelName;
|
|
113
|
-
if (
|
|
114
|
-
if (typeof
|
|
248
|
+
if (modelNameRaw !== undefined) {
|
|
249
|
+
if (typeof modelNameRaw !== "string")
|
|
115
250
|
throw new WorkflowScriptError("agent(spec): `modelName` must be a string (a model NAME, never a Model object)");
|
|
116
|
-
modelName =
|
|
251
|
+
modelName = modelNameRaw;
|
|
117
252
|
}
|
|
118
|
-
return { safe, modelName };
|
|
253
|
+
return { safe, modelName, stripped };
|
|
119
254
|
}
|
|
120
255
|
function clampResourceLimits(safe, base, caps) {
|
|
121
256
|
const trustedCandidates = [
|
|
@@ -172,8 +307,10 @@ function clampResourceLimits(safe, base, caps) {
|
|
|
172
307
|
safe.limits = rebuilt;
|
|
173
308
|
return notes;
|
|
174
309
|
}
|
|
175
|
-
export function buildGovernedChildSpec(scriptSpec, baseline, models, caps, onResourceClamp) {
|
|
176
|
-
const { safe, modelName } = pickWhitelist(scriptSpec);
|
|
310
|
+
export function buildGovernedChildSpec(scriptSpec, baseline, models, caps, onResourceClamp, onNotice) {
|
|
311
|
+
const { safe, modelName, stripped } = pickWhitelist(scriptSpec);
|
|
312
|
+
if (stripped.total > 0)
|
|
313
|
+
emitStrippedKeysNotice(stripped, onNotice);
|
|
177
314
|
if (modelName !== undefined) {
|
|
178
315
|
safe.model = resolveModelName(modelName, baseline.workflowModelAllowlist, models);
|
|
179
316
|
}
|
|
@@ -22,6 +22,11 @@ export interface WorkflowGovernance {
|
|
|
22
22
|
baseline: WorkflowGovernanceBaseline;
|
|
23
23
|
models?: Record<string, Model>;
|
|
24
24
|
caps?: WorkflowChildCaps;
|
|
25
|
+
/** The deployment's structured notice sink (`RunnerDeps.onNotice`), threaded here because the governed
|
|
26
|
+
* build is the only place that can report which spec fields did NOT reach the child (see
|
|
27
|
+
* `buildGovernedChildSpec`'s `onNotice` param). Only meaningful in governed mode — the trusted-dev lane
|
|
28
|
+
* strips nothing. Absent ⇒ the notice falls back to `console.warn`. */
|
|
29
|
+
onNotice?: (n: import("../core/types.js").EngineNotice) => void;
|
|
25
30
|
}
|
|
26
31
|
/**
|
|
27
32
|
* Build the flat {@link WorkflowPrimitives} a {@link WorkflowScriptRunner} runs the script against. The
|
|
@@ -29,7 +29,7 @@ export function buildWorkflowPrimitives(ctx, governance, onAgentSpawn, parentThi
|
|
|
29
29
|
const agentOpts = safeAgentOptions(opts);
|
|
30
30
|
const effectiveBaseline = (b) => agentOpts.isolation === "worktree" && b.worktreeBase !== undefined ? { ...b, base: { ...b.base, ...b.worktreeBase } } : b;
|
|
31
31
|
const childSpec = governance
|
|
32
|
-
? buildGovernedChildSpec(spec, effectiveBaseline(governance.baseline), governance.models, governance.caps, (notes) => ctx.log(formatResourceClampNote(notes)))
|
|
32
|
+
? buildGovernedChildSpec(spec, effectiveBaseline(governance.baseline), governance.models, governance.caps, (notes) => ctx.log(formatResourceClampNote(notes)), governance.onNotice)
|
|
33
33
|
: { ...spec };
|
|
34
34
|
if (childSpec.thinking === undefined && parentThinking) {
|
|
35
35
|
const inherited = parentThinking();
|
|
@@ -172,8 +172,32 @@ export interface CompoundReadonlyVerdict {
|
|
|
172
172
|
*
|
|
173
173
|
* `cd` is the one exception, and it fails closed: a glob there cannot be expanded into the single
|
|
174
174
|
* directory the compound face must track as the new working directory, so it demotes (see {@link reason}).
|
|
175
|
+
*
|
|
176
|
+
* ALSO carries the {@link recursiveReadPaths} entries (a subset): both families are operands whose
|
|
177
|
+
* real read set is not in the command text, and the consumer contract above ("non-empty ⇒ ask, or
|
|
178
|
+
* resolve yourself") is stated over this one field so an auto-allow gate cannot honour one family
|
|
179
|
+
* and miss the other.
|
|
175
180
|
*/
|
|
176
181
|
undecidedPaths?: readonly string[];
|
|
182
|
+
/**
|
|
183
|
+
* Operands of a RECURSIVE/EXPANDING read form (`grep -r`, `ls -R`, `du`, … — see
|
|
184
|
+
* {@link RECURSIVE_READ_FORMS}) judged with a {@link BashReadonlyRootBoundary.denyMatch} seat wired.
|
|
185
|
+
* The deny judge sees only the operand's own resolved spelling, but a recursive verb reads the
|
|
186
|
+
* operand's whole SUBTREE — `grep -r x /home/user` touches `/home/user/.ssh/*` while the judged
|
|
187
|
+
* spelling `/home/user` matches no deny pattern. The traversal's reach set is therefore not covered
|
|
188
|
+
* by the lexical check at all, and with zero I/O "provably not a directory" does not exist — so
|
|
189
|
+
* every such operand is UNDECIDED (no directory guessing, no bounded pre-check: both would be a
|
|
190
|
+
* false "resolved, inside" of exactly the kind {@link undecidedPaths} exists to prevent).
|
|
191
|
+
*
|
|
192
|
+
* Subset of {@link undecidedPaths} (same consumer contract: ask, never auto-allow); carried
|
|
193
|
+
* separately so a consumer minting prose can name the recursive-reach cause rather than the glob
|
|
194
|
+
* one. Minted ONLY when `denyMatch` is wired: without a deny judge there is nothing the traversal
|
|
195
|
+
* bypasses — the containment half already judges the operand itself, and its lexical residuals are
|
|
196
|
+
* recorded on {@link checkedPaths}. The `bash_readonly` face never wires `denyMatch` (v1 ruling,
|
|
197
|
+
* see that seat's note), so this field never appears there and its expand-and-verify execution
|
|
198
|
+
* path is unchanged.
|
|
199
|
+
*/
|
|
200
|
+
recursiveReadPaths?: readonly string[];
|
|
177
201
|
}
|
|
178
202
|
/**
|
|
179
203
|
* RB-412 — the single minting point for the out-of-root-read approval option text, so a gate rendering
|
|
@@ -224,10 +248,14 @@ export declare function classifyCompoundReadonlyDetailed(command: string, allow:
|
|
|
224
248
|
export declare function classifySimpleCommandReadBoundary(command: string, boundary: BashReadonlyRootBoundary): CompoundReadonlyVerdict;
|
|
225
249
|
/**
|
|
226
250
|
* design/154 — compound read-only classification, reason-only face. Returns the demotion reason, or
|
|
227
|
-
* undefined when the command classifies read-only.
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
251
|
+
* undefined when the command classifies read-only. ⚠️ `undefined` is NOT "safe to auto-execute":
|
|
252
|
+
* the detailed verdict may still carry `undecidedPaths` (operands whose unexpanded spelling — a
|
|
253
|
+
* glob — is what got checked), and this face discards that field. An auto-allow decision must read
|
|
254
|
+
* {@link classifyCompoundReadonlyDetailed} and treat a non-empty `undecidedPaths` as ask — the
|
|
255
|
+
* engine's own probe does exactly that (fs-bash.ts). RB-412 added the optional `boundary`: with it,
|
|
256
|
+
* an allowlisted reader whose path arguments leave the allowed directories is demoted too (use the
|
|
257
|
+
* detailed face when the caller wants to know WHY, e.g. to offer the narrow "allow reading from
|
|
258
|
+
* <dir>" approval); without it the verdict is exactly what it always was.
|
|
231
259
|
*/
|
|
232
260
|
export declare function classifyCompoundReadonly(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): string | undefined;
|
|
233
261
|
/**
|
|
@@ -284,6 +284,90 @@ function takesSeparatedValue(name, tok) {
|
|
|
284
284
|
}
|
|
285
285
|
return false;
|
|
286
286
|
}
|
|
287
|
+
const RECURSIVE_READ_FORMS = {
|
|
288
|
+
grep: {
|
|
289
|
+
shortLetters: "rR",
|
|
290
|
+
valueOwners: "efmABCD",
|
|
291
|
+
longNames: ["recursive", "dereference-recursive"],
|
|
292
|
+
enumOptions: [{ shortLetter: "d", longName: "directories", recursiveValue: "recurse" }],
|
|
293
|
+
dashIsStdin: true,
|
|
294
|
+
},
|
|
295
|
+
ls: { shortLetters: "R", longNames: ["recursive"] },
|
|
296
|
+
du: { always: true },
|
|
297
|
+
find: { always: true },
|
|
298
|
+
rg: { always: true, dashIsStdin: true },
|
|
299
|
+
tree: { always: true },
|
|
300
|
+
ag: { always: true, dashIsStdin: true },
|
|
301
|
+
ack: { always: true, dashIsStdin: true },
|
|
302
|
+
tar: { shortLetters: "cru", valueOwners: "fCTXbg", longNames: ["create", "append", "update"], bundledModeLetters: "cru", dashIsStdin: true },
|
|
303
|
+
diff: { shortLetters: "r", valueOwners: "UCWISFXx", longNames: ["recursive"], dashIsStdin: true },
|
|
304
|
+
};
|
|
305
|
+
function segmentSelectsRecursiveRead(name, args) {
|
|
306
|
+
const model = RECURSIVE_READ_FORMS[name];
|
|
307
|
+
if (model === undefined)
|
|
308
|
+
return false;
|
|
309
|
+
if (model.always === true)
|
|
310
|
+
return true;
|
|
311
|
+
if (model.bundledModeLetters !== undefined) {
|
|
312
|
+
const first = args.find((t) => t.length > 0);
|
|
313
|
+
if (first !== undefined && !first.startsWith("-") && /^[A-Za-z]+$/.test(first) && [...first].some((ch) => model.bundledModeLetters.includes(ch))) {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
let endOfOptions = false;
|
|
318
|
+
for (let k = 0; k < args.length; k++) {
|
|
319
|
+
const t = args[k];
|
|
320
|
+
if (endOfOptions)
|
|
321
|
+
continue;
|
|
322
|
+
if (t === "--") {
|
|
323
|
+
const prev = k > 0 ? args[k - 1] : undefined;
|
|
324
|
+
const prevMayOwnValue = prev !== undefined && prev.startsWith("--") && prev.length > 2 && !prev.includes("=");
|
|
325
|
+
if (!prevMayOwnValue)
|
|
326
|
+
endOfOptions = true;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (t.startsWith("--")) {
|
|
330
|
+
const long = longOptionNameOf(t);
|
|
331
|
+
if (long === undefined)
|
|
332
|
+
continue;
|
|
333
|
+
if (model.longNames?.some((full) => isLongOptionAbbrevOf(long, full)) === true)
|
|
334
|
+
return true;
|
|
335
|
+
for (const en of model.enumOptions ?? []) {
|
|
336
|
+
if (en.longName === undefined || !isLongOptionAbbrevOf(long, en.longName))
|
|
337
|
+
continue;
|
|
338
|
+
const eq = t.indexOf("=");
|
|
339
|
+
const v = eq >= 0 ? t.slice(eq + 1) : args[k + 1];
|
|
340
|
+
if (v !== undefined && v.length > 0 && en.recursiveValue.startsWith(v.toLowerCase()))
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (!t.startsWith("-") || t === "-")
|
|
346
|
+
continue;
|
|
347
|
+
for (let i = 1; i < t.length; i++) {
|
|
348
|
+
const ch = t[i];
|
|
349
|
+
if (model.shortLetters?.includes(ch) === true)
|
|
350
|
+
return true;
|
|
351
|
+
const en = (model.enumOptions ?? []).find((e) => e.shortLetter === ch);
|
|
352
|
+
if (en !== undefined) {
|
|
353
|
+
const v = i === t.length - 1 ? args[k + 1] : t.slice(i + 1);
|
|
354
|
+
if (v !== undefined && v.length > 0 && en.recursiveValue.startsWith(v.toLowerCase()))
|
|
355
|
+
return true;
|
|
356
|
+
if (i === t.length - 1)
|
|
357
|
+
k++;
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
if (model.valueOwners?.includes(ch) === true) {
|
|
361
|
+
if (i === t.length - 1)
|
|
362
|
+
k++;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
if (!/[A-Za-z0-9]/.test(ch))
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
287
371
|
function isGrepFileStdinLongOption(tok) {
|
|
288
372
|
const eq = tok.indexOf("=");
|
|
289
373
|
if (eq < 0 || tok.slice(eq + 1) !== "-")
|
|
@@ -327,6 +411,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
327
411
|
const findings = [];
|
|
328
412
|
const candidates = [];
|
|
329
413
|
const bareWordOperands = [];
|
|
414
|
+
const positionalOperands = [];
|
|
330
415
|
const argGlobs = (k) => {
|
|
331
416
|
const raw = tokens.raw[k + 1];
|
|
332
417
|
return raw !== undefined && hasUnquotedGlobMetachar(raw);
|
|
@@ -356,31 +441,47 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
356
441
|
candidates.push({ text: target, globbed: false });
|
|
357
442
|
}
|
|
358
443
|
else {
|
|
359
|
-
const
|
|
444
|
+
const eoo = args.indexOf("--");
|
|
445
|
+
const patternSuppliedByFlag = name === "grep" && (eoo === -1 ? args : args.slice(0, eoo)).some(isGrepPatternFlagToken);
|
|
360
446
|
let sawOperand = false;
|
|
447
|
+
let endOfOptions = false;
|
|
361
448
|
for (let k = 0; k < args.length; k++) {
|
|
362
449
|
const t = args[k];
|
|
363
|
-
if (
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
450
|
+
if (!endOfOptions) {
|
|
451
|
+
if (t === "--") {
|
|
452
|
+
endOfOptions = true;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (name === "cut" && (t === "-d" || t === "--delimiter" || t === "--output-delimiter")) {
|
|
456
|
+
k++;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (name === "cut" && (/^-d./.test(t) || isCutDelimiterPayloadLongOption(t)))
|
|
460
|
+
continue;
|
|
461
|
+
if (name === "grep" && (grepClusterValueOwner(t) === "e" || isGrepPatternPayloadLongOption(t)))
|
|
462
|
+
continue;
|
|
463
|
+
if (t.startsWith("-") && t !== "-") {
|
|
464
|
+
for (const payload of attachedOptionPayloads(t)) {
|
|
465
|
+
if (isAbsolutePathForm(payload) || isPathShapedToken(payload))
|
|
466
|
+
candidates.push({ text: payload, globbed: argGlobs(k) });
|
|
467
|
+
}
|
|
468
|
+
continue;
|
|
375
469
|
}
|
|
376
|
-
continue;
|
|
377
470
|
}
|
|
378
471
|
const isGrepPatternSlot = name === "grep" && !patternSuppliedByFlag && !sawOperand;
|
|
379
472
|
sawOperand = true;
|
|
380
|
-
if (t === "-")
|
|
473
|
+
if (t === "-") {
|
|
474
|
+
if (isGrepPatternSlot)
|
|
475
|
+
continue;
|
|
476
|
+
const m = RECURSIVE_READ_FORMS[name];
|
|
477
|
+
if (m === undefined || m.dashIsStdin === true)
|
|
478
|
+
continue;
|
|
479
|
+
positionalOperands.push(t);
|
|
381
480
|
continue;
|
|
481
|
+
}
|
|
382
482
|
if (isGrepPatternSlot)
|
|
383
483
|
continue;
|
|
484
|
+
positionalOperands.push(t);
|
|
384
485
|
if (isPathShapedToken(t))
|
|
385
486
|
candidates.push({ text: t, globbed: argGlobs(k) });
|
|
386
487
|
else
|
|
@@ -416,6 +517,13 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
416
517
|
if (!globbed && withinAnyRoot(boundary.roots, resolved))
|
|
417
518
|
findings.push({ kind: "inside", path: resolved });
|
|
418
519
|
}
|
|
520
|
+
if (boundary.denyMatch !== undefined && segmentSelectsRecursiveRead(name, args)) {
|
|
521
|
+
const roots = positionalOperands.length > 0 ? positionalOperands : ["."];
|
|
522
|
+
for (const operand of roots) {
|
|
523
|
+
const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], operand, boundary.homeDir);
|
|
524
|
+
findings.push({ kind: "recursive", path: resolved ?? operand });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
419
527
|
return findings;
|
|
420
528
|
}
|
|
421
529
|
export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
|
|
@@ -584,6 +692,7 @@ function evaluateReadBoundary(foldedSegments, boundary) {
|
|
|
584
692
|
const outside = [];
|
|
585
693
|
const inside = [];
|
|
586
694
|
const undecided = [];
|
|
695
|
+
const recursive = [];
|
|
587
696
|
for (const toks of foldedSegments) {
|
|
588
697
|
for (const finding of collectSegmentBoundaryFindings(toks, boundary)) {
|
|
589
698
|
if (finding.kind === "unresolvable")
|
|
@@ -604,6 +713,11 @@ function evaluateReadBoundary(foldedSegments, boundary) {
|
|
|
604
713
|
undecided.push(finding.path);
|
|
605
714
|
continue;
|
|
606
715
|
}
|
|
716
|
+
if (finding.kind === "recursive") {
|
|
717
|
+
if (!recursive.includes(finding.path))
|
|
718
|
+
recursive.push(finding.path);
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
607
721
|
if (boundary.face === "open") {
|
|
608
722
|
if (!inside.includes(finding.path))
|
|
609
723
|
inside.push(finding.path);
|
|
@@ -613,7 +727,11 @@ function evaluateReadBoundary(foldedSegments, boundary) {
|
|
|
613
727
|
outside.push(finding);
|
|
614
728
|
}
|
|
615
729
|
}
|
|
616
|
-
const
|
|
730
|
+
const undecidedAll = [...undecided, ...recursive.filter((p) => !undecided.includes(p))];
|
|
731
|
+
const undecidedField = {
|
|
732
|
+
...(undecidedAll.length > 0 ? { undecidedPaths: undecidedAll } : {}),
|
|
733
|
+
...(recursive.length > 0 ? { recursiveReadPaths: recursive } : {}),
|
|
734
|
+
};
|
|
617
735
|
if (outside.length === 0)
|
|
618
736
|
return inside.length > 0 ? { checkedPaths: inside, ...undecidedField } : { ...undecidedField };
|
|
619
737
|
const paths = outside.map((o) => `"${o.path}"`).join(", ");
|
|
@@ -724,6 +842,9 @@ export function classifyBoundedReadonlyPollLoop(command, allow, boundary) {
|
|
|
724
842
|
const verdict = classifyCompoundReadonlyDetailed(readSegments.join("; "), allow, boundary);
|
|
725
843
|
if (verdict.reason !== undefined)
|
|
726
844
|
return verdict.reason;
|
|
845
|
+
if (verdict.recursiveReadPaths !== undefined) {
|
|
846
|
+
return `the loop body reads recursively from ${verdict.recursiveReadPaths.join(", ")} — the traversal's reach is not covered by this lexical check, so it is not auto-allowed`;
|
|
847
|
+
}
|
|
727
848
|
if (verdict.undecidedPaths !== undefined) {
|
|
728
849
|
return `the loop body carries an unexpanded glob (${verdict.undecidedPaths.join(", ")}) — what a REPEATED read touches is decided at run time, so it is not auto-allowed`;
|
|
729
850
|
}
|
package/dist/tools/fs/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AgentTool, ExecutionEnv } from "../../internal/harness-types.js";
|
|
2
|
-
import type
|
|
2
|
+
import { type BeforeWriteHook } from "../../core/types.js";
|
|
3
3
|
import { type TaskRegistry } from "../../core/task-registry.js";
|
|
4
4
|
import { type ReadFileState } from "./safety.js";
|
|
5
5
|
import type { PdfModelCapabilities } from "./pdf.js";
|
|
@@ -130,8 +130,12 @@ export interface HandsToolkitOptions {
|
|
|
130
130
|
* {@link import("./read-face.js").resolveReadFace} order prepare-task uses). Absent ⇒ the
|
|
131
131
|
* resolution order's default = "roots" (D-1b: the engine never opens implicitly). "open" skips
|
|
132
132
|
* ONLY the roots containment judgment — the deny set, the UNC out-of-set refusal and the
|
|
133
|
-
* special-file type gates run in both faces (§2.0).
|
|
134
|
-
* (
|
|
133
|
+
* special-file type gates run in both faces (§2.0). Beside `readOnly: true` the mount wins:
|
|
134
|
+
* this seat is deps-shaped (a deployment default, not a per-call assertion), so its "open"
|
|
135
|
+
* CLAMPS to "roots" and the clamp is ANNOUNCED (`config.read_face_deployment_clamped`, through
|
|
136
|
+
* `onNotice` / `console.warn`) — the loud refusal belongs to the TASK seat (`TaskSpec.readFace`),
|
|
137
|
+
* which is the one genuine per-call contradiction (#237; stale "refused loudly" wording here
|
|
138
|
+
* predated the 96ef89d seat distinction). Never affects the write faces. */
|
|
135
139
|
readFace?: "open" | "roots";
|
|
136
140
|
}
|
|
137
141
|
/**
|
package/dist/tools/fs/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deliverEngineNotice } from "../../core/types.js";
|
|
1
2
|
import { createTaskOutputTool, createTaskStopTool } from "../../core/task-registry.js";
|
|
2
3
|
import { hasBackgroundShell } from "../../core/background-shell.js";
|
|
3
4
|
import {} from "./safety.js";
|
|
@@ -12,7 +13,7 @@ export * from "./fs-bash.js";
|
|
|
12
13
|
export * from "./read-deny.js";
|
|
13
14
|
import { BASH_READONLY_DEFAULT_ALLOW, } from "./bash-readonly-classifier.js";
|
|
14
15
|
import { compileReadDeny } from "./read-deny.js";
|
|
15
|
-
import { resolveReadFace } from "./read-face.js";
|
|
16
|
+
import { deploymentReadFaceClampNotice, resolveReadFace } from "./read-face.js";
|
|
16
17
|
export * from "./read-face.js";
|
|
17
18
|
import {} from "./fs-shared.js";
|
|
18
19
|
import { createReadFileTool } from "./fs-read.js";
|
|
@@ -39,6 +40,7 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
|
|
|
39
40
|
readOnlyMount: readOnly,
|
|
40
41
|
orgGoverned: false,
|
|
41
42
|
fullShellReachable: includeShell && !readOnly,
|
|
43
|
+
onDeploymentClamp: () => deliverEngineNotice(opts.onNotice, deploymentReadFaceClampNotice()),
|
|
42
44
|
});
|
|
43
45
|
const tools = [
|
|
44
46
|
createReadFileTool(env, readFileState, rootCanonical, readOnly ? undefined : cwdRef, readFaceRoots, opts.readImageDownsampler, opts.pdfModelCapabilities, bgOutputReadExemption, opts.readCyberReminder, readDeny, readFace),
|
|
@@ -20,6 +20,11 @@ export interface NormalizedReadDenyEntry {
|
|
|
20
20
|
/** A deny verdict: which entry's pattern matched. */
|
|
21
21
|
export interface ReadDenyHit {
|
|
22
22
|
pattern: string;
|
|
23
|
+
/** The view string the pattern actually matched (set by {@link CompiledReadDeny.matchTarget}: the
|
|
24
|
+
* canonical key, or the lexical view when only the SPELLING matched). Disclosure must cite the
|
|
25
|
+
* matched view — under a symlink/case alias the two strings differ, and naming the other one
|
|
26
|
+
* sends the reader chasing a path the pattern does not match. */
|
|
27
|
+
matchedView?: string;
|
|
23
28
|
}
|
|
24
29
|
/** One compiled ripgrep glob flag for the traversal legs (`--iglob` = case-insensitive entry). */
|
|
25
30
|
export interface ReadDenyRgGlob {
|
|
@@ -143,7 +143,15 @@ export function compileReadDeny(additions = [], layer = "additions") {
|
|
|
143
143
|
entries: normalized,
|
|
144
144
|
matchPath,
|
|
145
145
|
matchTarget(canonicalKey, lexicalView) {
|
|
146
|
-
|
|
146
|
+
const canonical = matchPath(canonicalKey);
|
|
147
|
+
if (canonical !== null)
|
|
148
|
+
return { ...canonical, matchedView: canonicalKey };
|
|
149
|
+
if (lexicalView !== undefined) {
|
|
150
|
+
const lexical = matchPath(lexicalView);
|
|
151
|
+
if (lexical !== null)
|
|
152
|
+
return { ...lexical, matchedView: lexicalView };
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
147
155
|
},
|
|
148
156
|
rgExclusionGlobs,
|
|
149
157
|
rgProbeGlobs,
|