@sema-agent/core 5.29.0 → 5.30.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 +63 -0
- package/dist/agents/send-message-tool.js +2 -0
- package/dist/agents/subagent.d.ts +2 -0
- package/dist/agents/subagent.js +6 -0
- package/dist/agents/teacher.js +2 -0
- package/dist/agents/verify.js +2 -0
- package/dist/core/auto-compaction.d.ts +5 -1
- package/dist/core/auto-compaction.js +10 -1
- package/dist/core/checkpoint-store.d.ts +51 -5
- package/dist/core/checkpoint-store.js +2 -1
- package/dist/core/hooks.d.ts +12 -1
- package/dist/core/hooks.js +8 -2
- package/dist/core/permission-rules.js +2 -2
- package/dist/core/runner/prepare-task.d.ts +21 -5
- package/dist/core/runner/prepare-task.js +105 -19
- package/dist/core/runner/runtask.js +29 -3
- package/dist/core/runner/session-rule-policy.d.ts +3 -2
- package/dist/core/runner/tool-output-projection.js +1 -1
- package/dist/core/sensitive-path-policy.js +5 -16
- package/dist/core/store-contracts/tool-result-store-contract.js +23 -0
- package/dist/core/tighten-task-spec.js +18 -0
- package/dist/core/tool-policy.d.ts +20 -1
- package/dist/core/tool-policy.js +31 -4
- package/dist/core/tool-result-store.js +3 -1
- package/dist/core/types.d.ts +55 -0
- package/dist/engine/harness/types.d.ts +10 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/orchestration/run-workflow-tool.d.ts +21 -0
- package/dist/orchestration/run-workflow-tool.js +6 -3
- package/dist/orchestration/workflow-primitives.d.ts +10 -1
- package/dist/orchestration/workflow-primitives.js +12 -1
- package/dist/prompt-assembly/epoch.js +2 -0
- package/dist/prompt-assembly/packs/sema-default.js +2 -2
- package/dist/prompt-assembly/types.d.ts +4 -0
- package/dist/prompts/default.d.ts +14 -9
- package/dist/prompts/default.js +13 -3
- package/dist/tools/fs/bash-readonly-classifier.d.ts +21 -0
- package/dist/tools/fs/bash-readonly-classifier.js +11 -0
- package/dist/tools/fs/fs-bash.d.ts +7 -0
- package/dist/tools/fs/fs-bash.js +8 -3
- package/dist/tools/fs/fs-pdf.d.ts +1 -1
- package/dist/tools/fs/fs-pdf.js +2 -2
- package/dist/tools/fs/fs-read.d.ts +1 -1
- package/dist/tools/fs/fs-read.js +11 -7
- package/dist/tools/fs/fs-search-tools.d.ts +4 -2
- package/dist/tools/fs/fs-search-tools.js +15 -8
- package/dist/tools/fs/fs-shared.d.ts +5 -1
- package/dist/tools/fs/fs-shared.js +8 -3
- package/dist/tools/fs/index.d.ts +18 -0
- package/dist/tools/fs/index.js +13 -2
- package/dist/tools/fs/read-deny.d.ts +105 -0
- package/dist/tools/fs/read-deny.js +151 -0
- package/dist/tools/fs/read-face.d.ts +43 -0
- package/dist/tools/fs/read-face.js +38 -0
- package/dist/tools/fs/repo-map.d.ts +3 -1
- package/dist/tools/fs/repo-map.js +11 -5
- package/dist/tools/fs/safety.d.ts +33 -11
- package/dist/tools/fs/safety.js +88 -7
- package/dist/tools/fs/search.d.ts +54 -5
- package/dist/tools/fs/search.js +103 -21
- package/package.json +1 -1
|
@@ -85,20 +85,23 @@ export type ReadFileState = Map<string, ReadEntry>;
|
|
|
85
85
|
export declare function sha256(content: string): string;
|
|
86
86
|
/** A failed path resolution / invariant check, surfaced to the model as a self-correctable tool error. */
|
|
87
87
|
export interface FsViolation {
|
|
88
|
-
code: "path_not_in_root" | "not_read" | "stale" | "ambiguous_edit" | "invalid";
|
|
88
|
+
code: "path_not_in_root" | "not_read" | "stale" | "ambiguous_edit" | "invalid" | "read_path_denied";
|
|
89
89
|
/** RB-264 N1: set on the `not_read` violation raised by an AUTO-TRUNCATED read
|
|
90
90
|
* ({@link ReadEntry.isPartialView}) rather than by a never-read file. Both carry the same CC-verbatim
|
|
91
91
|
* message; only this one needs {@link PARTIAL_VIEW_READ_ESCAPE_HINT} appended, because for it a plain
|
|
92
92
|
* re-read is a fixpoint. */
|
|
93
93
|
partialView?: true;
|
|
94
|
-
/** RB-371 ④: set
|
|
95
|
-
* (symlinks followed, tracked cwd applied), i.e. the value the fence
|
|
96
|
-
* the tool result's `details` (see {@link violationDetails}) so a
|
|
97
|
-
* boundary refusals from fields instead of regexing the prose. */
|
|
94
|
+
/** RB-371 ④: set on `path_not_in_root` (and design/199 `read_path_denied`) — the CANONICAL key the
|
|
95
|
+
* refused path resolved to (symlinks followed, tracked cwd applied), i.e. the value the fence
|
|
96
|
+
* actually judged. Rides into the tool result's `details` (see {@link violationDetails}) so a
|
|
97
|
+
* consumer can count/aggregate boundary refusals from fields instead of regexing the prose. */
|
|
98
98
|
target?: string;
|
|
99
99
|
/** RB-371 ④: set ONLY on `path_not_in_root` — the containment roots in effect (primary first,
|
|
100
100
|
* then additionalDirectories), canonical form. */
|
|
101
101
|
roots?: readonly string[];
|
|
102
|
+
/** design/199 件B: set ONLY on `read_path_denied` — the deny-list pattern (verbatim entry text)
|
|
103
|
+
* the judged target matched. */
|
|
104
|
+
pattern?: string;
|
|
102
105
|
message: string;
|
|
103
106
|
}
|
|
104
107
|
/**
|
|
@@ -250,6 +253,12 @@ export declare function isBinaryContent(sample: string): boolean;
|
|
|
250
253
|
* This function stays a pure spelling test with no verdict of its own.
|
|
251
254
|
*/
|
|
252
255
|
export declare function isUncPath(path: string): boolean;
|
|
256
|
+
/** design/199 件B — the family-aware LEXICAL view of a possibly-relative spelling against a
|
|
257
|
+
* resolution base (no I/O, `.`/`..` folded via {@link normalizeAbsPathLexically}): the §3.3 second
|
|
258
|
+
* deny-judgment view. It asks "does the NAME land on a guarded path", complementing the canonical
|
|
259
|
+
* view's "does the TARGET" — a symlink AT a guarded spelling resolves away from it canonically,
|
|
260
|
+
* and only this view still names it. */
|
|
261
|
+
export declare function lexicalViewOf(spelled: string, base: string): string;
|
|
253
262
|
/**
|
|
254
263
|
* Resolve a model-supplied path to a canonical key AND enforce rootPath containment (design/44 §4
|
|
255
264
|
* inv 5/6/7). Existing paths use `canonicalPath` (resolves symlinks, so the same file can't get two
|
|
@@ -258,7 +267,11 @@ export declare function isUncPath(path: string): boolean;
|
|
|
258
267
|
* `path_not_in_root` violation. (Defense-in-depth at the tool layer; NOT a substitute for a sandboxed
|
|
259
268
|
* `executionEnv` — see design/44 §5.)
|
|
260
269
|
*/
|
|
261
|
-
export declare function resolveKey(env: ExecutionEnv, rootCanonical: string, path: string, signal?: AbortSignal, baseCwd?: string, additionalRootsCanonical?: readonly string[], exactFileReadExemption?: (canonicalKey: string) => boolean
|
|
270
|
+
export declare function resolveKey(env: ExecutionEnv, rootCanonical: string, path: string, signal?: AbortSignal, baseCwd?: string, additionalRootsCanonical?: readonly string[], exactFileReadExemption?: (canonicalKey: string) => boolean, readDeny?: {
|
|
271
|
+
matchTarget(canonicalKey: string, lexicalView?: string): {
|
|
272
|
+
pattern: string;
|
|
273
|
+
} | null;
|
|
274
|
+
}, readFace?: "open" | "roots"): Promise<{
|
|
262
275
|
ok: true;
|
|
263
276
|
key: string;
|
|
264
277
|
} | {
|
|
@@ -301,6 +314,11 @@ export declare function violationDetails(v: FsViolation): {
|
|
|
301
314
|
code: "path_not_in_root";
|
|
302
315
|
target: string;
|
|
303
316
|
roots: readonly string[];
|
|
317
|
+
} | {
|
|
318
|
+
type: "read_path_denied";
|
|
319
|
+
code: "read_path_denied";
|
|
320
|
+
target: string;
|
|
321
|
+
pattern: string;
|
|
304
322
|
} | undefined;
|
|
305
323
|
/**
|
|
306
324
|
* RB-371 ② — does `p` sit inside ANY of the canonical containment roots? Exposed for the Bash
|
|
@@ -313,12 +331,16 @@ export declare function withinAnyRoot(rootsCanonical: readonly string[], p: stri
|
|
|
313
331
|
/**
|
|
314
332
|
* RB-371 ① — the escape-hatch tail for the `path_not_in_root` refusal: its three sibling hints
|
|
315
333
|
* below each name a sanctioned next step; this refusal named none, leaving the model to oscillate
|
|
316
|
-
* between "the boundary is hard" and discovering by trial that bash is not confined by it.
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
334
|
+
* between "the boundary is hard" and discovering by trial that bash is not confined by it.
|
|
335
|
+
*
|
|
336
|
+
* Ordering is deliberate (ruled 2026-08-12, fence = the deployment's declared containment
|
|
337
|
+
* boundary): the SANCTIONED way out — widening the declaration (`additionalDirectories` /
|
|
338
|
+
* read-only `additionalReadDirectories`, design/119) — leads; the Bash sentence stays as a FACT
|
|
339
|
+
* (the shell genuinely is not confined by this fence — design/44 §5 — and omitting that would
|
|
340
|
+
* just let the model discover it by trial), but it is a statement, not a recommendation, and it
|
|
341
|
+
* comes last. Every shell call still passes the deployment's approval policy.
|
|
320
342
|
*/
|
|
321
|
-
export declare const PATH_NOT_IN_ROOT_ESCAPE_HINT = "(This boundary applies to the structured file tools. If you genuinely need content outside the allowed root(s)
|
|
343
|
+
export declare const PATH_NOT_IN_ROOT_ESCAPE_HINT = "(This boundary applies to the structured file tools. If you genuinely need content outside the allowed root(s), ask for the directory to be added to the deployment's additionalDirectories \u2014 or additionalReadDirectories for read-only access. For completeness: the Bash tool is not confined by this fence, and every Bash call remains subject to the deployment's approval policy.)";
|
|
322
344
|
/** inv 1 (read-before-edit): a file must have been read this task before it can be edited/overwritten.
|
|
323
345
|
* Message is CC 2.1.198 live-verbatim (all-tools-live-probe 2026-07-08 §2.1/§3.1/§5.1 — one message for
|
|
324
346
|
* Edit/Write/NotebookEdit: "before writing to it", not the old sema "before editing").
|
package/dist/tools/fs/safety.js
CHANGED
|
@@ -23,7 +23,10 @@ export function isAbsolutePathForm(p) {
|
|
|
23
23
|
return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith("\\\\");
|
|
24
24
|
}
|
|
25
25
|
function normalizeForCompare(p) {
|
|
26
|
-
let n =
|
|
26
|
+
let n = stripWin32ExtendedPrefix(p);
|
|
27
|
+
if (isUncPath(n) && isWinFormPath(n) && !isWin32DeviceNamespacePath(n))
|
|
28
|
+
n = foldUncLexically(n).key;
|
|
29
|
+
n = isWinFormPath(n) ? n.replace(/\\/g, "/") : n;
|
|
27
30
|
if (/^[A-Za-z]:(\/|$)/.test(n))
|
|
28
31
|
n = n[0].toLowerCase() + n.slice(1);
|
|
29
32
|
return n;
|
|
@@ -67,6 +70,7 @@ export function foldRedundantPathSegments(p) {
|
|
|
67
70
|
return foldSegmentsLexically(p, false);
|
|
68
71
|
}
|
|
69
72
|
function foldSegmentsLexically(p, foldParents) {
|
|
73
|
+
p = stripWin32ExtendedPrefix(p);
|
|
70
74
|
const isUnc = p.startsWith("\\\\");
|
|
71
75
|
const drive = /^[A-Za-z]:/.exec(p)?.[0] ?? "";
|
|
72
76
|
const winForm = isUnc || drive !== "" || (!p.startsWith("/") && p.includes("\\"));
|
|
@@ -101,7 +105,7 @@ function foldSegmentsLexically(p, foldParents) {
|
|
|
101
105
|
outSegs.push(seg);
|
|
102
106
|
}
|
|
103
107
|
if (outSegs.length === 0)
|
|
104
|
-
return abs ? prefix + sep : prefix === "" ? "." : prefix;
|
|
108
|
+
return abs ? (isUnc ? prefix : prefix + sep) : prefix === "" ? "." : prefix;
|
|
105
109
|
return prefix + (abs ? sep : "") + outSegs.join(sep);
|
|
106
110
|
}
|
|
107
111
|
const BINARY_EXTENSIONS = new Set([
|
|
@@ -262,6 +266,46 @@ export function isBinaryContent(sample) {
|
|
|
262
266
|
export function isUncPath(path) {
|
|
263
267
|
return path.startsWith("\\\\") || path.startsWith("//");
|
|
264
268
|
}
|
|
269
|
+
function isWin32DeviceNamespacePath(p) {
|
|
270
|
+
return p.startsWith("\\\\.\\") || p.startsWith("\\\\./");
|
|
271
|
+
}
|
|
272
|
+
function stripWin32ExtendedPrefix(p) {
|
|
273
|
+
if (!p.startsWith("\\\\?\\"))
|
|
274
|
+
return p;
|
|
275
|
+
const rest = p.slice(4);
|
|
276
|
+
const unc = /^UNC[\\/]/i.exec(rest);
|
|
277
|
+
if (unc)
|
|
278
|
+
return "\\\\" + rest.slice(unc[0].length);
|
|
279
|
+
return rest;
|
|
280
|
+
}
|
|
281
|
+
function foldUncLexically(p) {
|
|
282
|
+
const body = p.slice(2);
|
|
283
|
+
const segs = body.split(/[\\/]+/).filter((s) => s !== "");
|
|
284
|
+
const host = segs.shift() ?? "";
|
|
285
|
+
const share = segs.shift();
|
|
286
|
+
const out = [];
|
|
287
|
+
let climbed = false;
|
|
288
|
+
for (const seg of segs) {
|
|
289
|
+
if (seg === ".")
|
|
290
|
+
continue;
|
|
291
|
+
if (seg === "..") {
|
|
292
|
+
if (out.length === 0)
|
|
293
|
+
climbed = true;
|
|
294
|
+
else
|
|
295
|
+
out.pop();
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
out.push(seg);
|
|
299
|
+
}
|
|
300
|
+
const anchor = `\\\\${host}${share !== undefined ? `\\${share}` : ""}`;
|
|
301
|
+
return { key: out.length > 0 ? `${anchor}\\${out.join("\\")}` : anchor, climbedAboveShare: climbed };
|
|
302
|
+
}
|
|
303
|
+
export function lexicalViewOf(spelled, base) {
|
|
304
|
+
const abs = isAbsolutePathForm(spelled)
|
|
305
|
+
? spelled
|
|
306
|
+
: `${base.replace(/[\\/]+$/, "")}${isWinFormPath(base) && base.includes("\\") ? "\\" : "/"}${spelled}`;
|
|
307
|
+
return normalizeAbsPathLexically(abs);
|
|
308
|
+
}
|
|
265
309
|
function within(rootCanonical, p) {
|
|
266
310
|
if (!rootCanonical)
|
|
267
311
|
return false;
|
|
@@ -274,7 +318,7 @@ function within(rootCanonical, p) {
|
|
|
274
318
|
const base = rootN.endsWith(SEP) ? rootN : rootN + SEP;
|
|
275
319
|
return pN.startsWith(base);
|
|
276
320
|
}
|
|
277
|
-
export async function resolveKey(env, rootCanonical, path, signal, baseCwd, additionalRootsCanonical, exactFileReadExemption) {
|
|
321
|
+
export async function resolveKey(env, rootCanonical, path, signal, baseCwd, additionalRootsCanonical, exactFileReadExemption, readDeny, readFace) {
|
|
278
322
|
const canon = await canonicalizeTarget(env, path, signal, baseCwd);
|
|
279
323
|
if (!canon.ok)
|
|
280
324
|
return { ok: false, violation: { code: "invalid", message: canon.message } };
|
|
@@ -282,6 +326,32 @@ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, addi
|
|
|
282
326
|
if (isBlockedDevicePath(key)) {
|
|
283
327
|
return { ok: false, violation: { code: "invalid", message: `path "${path}" is a blocked device/special file (reading it can hang the process); refused.` } };
|
|
284
328
|
}
|
|
329
|
+
if (readDeny !== undefined) {
|
|
330
|
+
const hit = readDeny.matchTarget(key, lexicalViewOf(path, baseCwd ?? rootCanonical));
|
|
331
|
+
if (hit !== null) {
|
|
332
|
+
return {
|
|
333
|
+
ok: false,
|
|
334
|
+
violation: {
|
|
335
|
+
code: "read_path_denied",
|
|
336
|
+
message: `reading "${path}" ${key !== path ? `(canonical target: "${key}") ` : ""}is refused: the target matches the sensitive-path read deny list (pattern "${hit.pattern}"). This list guards credential-class paths and applies regardless of the containment roots.`,
|
|
337
|
+
target: key,
|
|
338
|
+
pattern: hit.pattern,
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (readFace === "open") {
|
|
344
|
+
if (isUncPath(key) && isWinFormPath(key) && !within(rootCanonical, key) && !additionalRootsCanonical?.some((r) => within(r, key))) {
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
violation: {
|
|
348
|
+
code: "invalid",
|
|
349
|
+
message: `path "${path}" names a network (UNC) location outside the declared roots; refused even under the open read face — a read against an undeclared share can hang on the SMB mount timeout and offers credentials to the remote host.`,
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
return { ok: true, key };
|
|
354
|
+
}
|
|
285
355
|
if (!within(rootCanonical, key) && !additionalRootsCanonical?.some((r) => within(r, key))) {
|
|
286
356
|
if (exactFileReadExemption?.(key) === true)
|
|
287
357
|
return { ok: true, key };
|
|
@@ -299,10 +369,18 @@ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, addi
|
|
|
299
369
|
return { ok: true, key };
|
|
300
370
|
}
|
|
301
371
|
export async function canonicalizeTarget(env, path, signal, baseCwd) {
|
|
302
|
-
if (
|
|
303
|
-
return { ok:
|
|
372
|
+
if (isWin32DeviceNamespacePath(path)) {
|
|
373
|
+
return { ok: false, message: `path "${path}" is a Win32 device-namespace path (\\\\.\\ names a raw device object, not a file); refused.` };
|
|
374
|
+
}
|
|
375
|
+
const unprefixed = stripWin32ExtendedPrefix(path);
|
|
376
|
+
if (isUncPath(unprefixed) && isWinFormPath(unprefixed)) {
|
|
377
|
+
const folded = foldUncLexically(unprefixed);
|
|
378
|
+
if (folded.climbedAboveShare) {
|
|
379
|
+
return { ok: false, message: `path "${path}" climbs above its UNC share root via ".." (\\\\host\\share is the root of a network path); refused.` };
|
|
380
|
+
}
|
|
381
|
+
return { ok: true, key: folded.key };
|
|
304
382
|
}
|
|
305
|
-
const spelled =
|
|
383
|
+
const spelled = unprefixed.startsWith("//") ? unprefixed.replace(/^\/+/, "/") : unprefixed;
|
|
306
384
|
const target = baseCwd && !isAbsolutePathForm(spelled) ? `${baseCwd.replace(/[\\/]+$/, "")}/${spelled}` : spelled;
|
|
307
385
|
const absR = await env.absolutePath(target, signal);
|
|
308
386
|
if (!absR.ok)
|
|
@@ -384,6 +462,9 @@ export function violationText(toolName, v) {
|
|
|
384
462
|
return `Error (${toolName}): ${v.message}`;
|
|
385
463
|
}
|
|
386
464
|
export function violationDetails(v) {
|
|
465
|
+
if (v.code === "read_path_denied" && v.target !== undefined && v.pattern !== undefined) {
|
|
466
|
+
return { type: "read_path_denied", code: "read_path_denied", target: v.target, pattern: v.pattern };
|
|
467
|
+
}
|
|
387
468
|
return v.code === "path_not_in_root" && v.target !== undefined
|
|
388
469
|
? { type: "path_not_in_root", code: "path_not_in_root", target: v.target, roots: v.roots ?? [] }
|
|
389
470
|
: undefined;
|
|
@@ -391,7 +472,7 @@ export function violationDetails(v) {
|
|
|
391
472
|
export function withinAnyRoot(rootsCanonical, p) {
|
|
392
473
|
return rootsCanonical.some((r) => within(r, p));
|
|
393
474
|
}
|
|
394
|
-
export const PATH_NOT_IN_ROOT_ESCAPE_HINT = "(This boundary applies to the structured file tools. If you genuinely need content outside the allowed root(s)
|
|
475
|
+
export const PATH_NOT_IN_ROOT_ESCAPE_HINT = "(This boundary applies to the structured file tools. If you genuinely need content outside the allowed root(s), ask for the directory to be added to the deployment's additionalDirectories — or additionalReadDirectories for read-only access. For completeness: the Bash tool is not confined by this fence, and every Bash call remains subject to the deployment's approval policy.)";
|
|
395
476
|
export function requireRead(state, key) {
|
|
396
477
|
const entry = state.get(key);
|
|
397
478
|
if (entry === undefined || entry.isPartialView) {
|
|
@@ -126,12 +126,27 @@ export interface WalkResult {
|
|
|
126
126
|
* without a second stat pass. An env whose listings carry no numeric `mtimeMs` simply leaves
|
|
127
127
|
* entries out of this map (consumers fall back to name order). */
|
|
128
128
|
mtimes: Map<string, number>;
|
|
129
|
+
/** design/199 件B — entries withheld by the sensitive-path read deny list: PRUNE-ROOT count only
|
|
130
|
+
* (a denied directory's descendants are not enumerated, so they cannot be counted — the note says
|
|
131
|
+
* so). 0 when no deny judge was supplied or nothing matched. */
|
|
132
|
+
denyPruned: number;
|
|
133
|
+
/** The deny patterns that pruned something, first-seen order (for the disclosure note). */
|
|
134
|
+
denyPatterns: string[];
|
|
129
135
|
}
|
|
130
136
|
/** F2: any-cause incompleteness — a result built on this walk must not present itself as exhaustive. */
|
|
131
137
|
export declare function walkIsPartial(w: WalkResult): boolean;
|
|
132
138
|
/** Bounded, ignore-aware recursive walk. Returns absolute file paths under `start`. Exported for
|
|
133
139
|
* repo-map (design/72 §1) — same bounded/ignore-aware traversal as grep/glob, one source of truth. */
|
|
134
|
-
export declare function walk(env: ExecutionEnv, root: string, start: string, ignore: (rel: string, isDir: boolean) => boolean, signal?: AbortSignal
|
|
140
|
+
export declare function walk(env: ExecutionEnv, root: string, start: string, ignore: (rel: string, isDir: boolean) => boolean, signal?: AbortSignal, deny?: {
|
|
141
|
+
matchPath(path: string): {
|
|
142
|
+
pattern: string;
|
|
143
|
+
} | null;
|
|
144
|
+
}): Promise<WalkResult>;
|
|
145
|
+
/** design/199 件B — the JS-leg withholding disclosure (§3.1: an empty result must stay
|
|
146
|
+
* distinguishable from searched-and-empty, so this note rides EVERY output shape when something was
|
|
147
|
+
* pruned). Distinct from {@link walkCaveat}: pruning is deliberate withholding, not incompleteness.
|
|
148
|
+
* The `[note:` prefix keeps it out of the tool layer's structured row extraction. */
|
|
149
|
+
export declare function denyWithheldNote(w: Pick<WalkResult, "denyPruned" | "denyPatterns">): string;
|
|
135
150
|
/** Injectable execution boundaries for the JS-fallback scan (tests shrink them; callers use defaults). */
|
|
136
151
|
export interface JsGrepGuards {
|
|
137
152
|
/** Wall-clock budget (ms) for matching a SINGLE file before the scan stops with a diagnosable error. */
|
|
@@ -139,8 +154,37 @@ export interface JsGrepGuards {
|
|
|
139
154
|
/** Longest line/content (chars) a gray-zone pattern may be matched against. */
|
|
140
155
|
longLineLimit?: number;
|
|
141
156
|
}
|
|
142
|
-
/**
|
|
143
|
-
|
|
157
|
+
/** design/199 件B — what a traversal withheld under the sensitive-path read deny list, structured
|
|
158
|
+
* (the prose note is the model-facing twin). Three shapes (§3.1): `pruned_count` = the JS walker
|
|
159
|
+
* counted its prune roots; `existence` = the rg leg's bounded probe CONFIRMED matching entries exist
|
|
160
|
+
* under the scope (rg's own exclusions are silent, so the count is unknowable); `probe_failed` =
|
|
161
|
+
* the probe itself failed — entries MAY have been withheld. */
|
|
162
|
+
export interface ReadDenyWithheld {
|
|
163
|
+
kind: "pruned_count" | "existence" | "probe_failed";
|
|
164
|
+
count?: number;
|
|
165
|
+
patterns?: readonly string[];
|
|
166
|
+
}
|
|
167
|
+
/** The deny judge seam every search leg shares (structural subset of ReadDenyMatcher). */
|
|
168
|
+
export interface ReadDenyJudge {
|
|
169
|
+
matchPath(path: string): {
|
|
170
|
+
pattern: string;
|
|
171
|
+
} | null;
|
|
172
|
+
rgExclusionGlobs: ReadonlyArray<{
|
|
173
|
+
flag: "--glob" | "--iglob";
|
|
174
|
+
glob: string;
|
|
175
|
+
}>;
|
|
176
|
+
rgProbeGlobs: ReadonlyArray<{
|
|
177
|
+
flag: "--glob" | "--iglob";
|
|
178
|
+
glob: string;
|
|
179
|
+
}>;
|
|
180
|
+
}
|
|
181
|
+
/** JS-fallback grep: ignore-aware walk + per-line scan, honoring output_mode / context / head_limit.
|
|
182
|
+
* design/199 件B: `deny` prunes the walk (note appended to every output shape); `denyOut` is the
|
|
183
|
+
* structured twin — a sink because this function's dozen error returns predate the facts (the sink
|
|
184
|
+
* is written the moment the walk lands, whatever the scan then returns). */
|
|
185
|
+
export declare function jsGrep(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal, guards?: JsGrepGuards, deny?: ReadDenyJudge, denyOut?: {
|
|
186
|
+
withheld?: ReadDenyWithheld;
|
|
187
|
+
}): Promise<string>;
|
|
144
188
|
/** Detect ripgrep once per env (cached). */
|
|
145
189
|
export declare function detectRipgrep(env: ExecutionEnv): Promise<boolean>;
|
|
146
190
|
/** How a grep result was produced when the ripgrep leg could not complete normally. Either the
|
|
@@ -160,16 +204,19 @@ export type GrepDegradation = {
|
|
|
160
204
|
export interface GrepRunResult {
|
|
161
205
|
text: string;
|
|
162
206
|
degraded?: GrepDegradation;
|
|
207
|
+
/** design/199 件B — deny-list withholding facts (see {@link ReadDenyWithheld}); absent = nothing
|
|
208
|
+
* withheld / no deny judge in play. */
|
|
209
|
+
withheld?: ReadDenyWithheld;
|
|
163
210
|
}
|
|
164
211
|
/** ripgrep grep: build flags from params, run, normalize to the same output as {@link jsGrep}.
|
|
165
212
|
* Result-fidelity contract: output ripgrep DID produce is never silently replaced by a fallback
|
|
166
213
|
* rescan — partial results ship with a caveat; only a zero-output failure degrades to
|
|
167
214
|
* {@link jsGrep}, and that degradation is declared in both text and structure. An external abort
|
|
168
215
|
* propagates as the interruption it is (no fallback rerun). */
|
|
169
|
-
export declare function rgGrepDetailed(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal): Promise<GrepRunResult>;
|
|
216
|
+
export declare function rgGrepDetailed(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal, deny?: ReadDenyJudge): Promise<GrepRunResult>;
|
|
170
217
|
/** grep dispatch: ripgrep when the env has it, else the hardened JS fallback (design/64 §10.3).
|
|
171
218
|
* Returns the degradation facts alongside the text so the tool layer can surface them structurally. */
|
|
172
|
-
export declare function runGrepDetailed(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal): Promise<GrepRunResult>;
|
|
219
|
+
export declare function runGrepDetailed(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal, deny?: ReadDenyJudge): Promise<GrepRunResult>;
|
|
173
220
|
/** Text-only wrapper of {@link runGrepDetailed}. */
|
|
174
221
|
export declare function runGrep(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal): Promise<string>;
|
|
175
222
|
/** glob: find files by name pattern, with optional sub-path scoping; returns RELATIVE paths (design/64 §10.4). */
|
|
@@ -193,6 +240,7 @@ export declare function runGlob(env: ExecutionEnv, root: string, pattern: string
|
|
|
193
240
|
export declare function runGlobDetailed(env: ExecutionEnv, root: string, pattern: string, opts?: {
|
|
194
241
|
path?: string;
|
|
195
242
|
max?: number;
|
|
243
|
+
deny?: ReadDenyJudge;
|
|
196
244
|
}, signal?: AbortSignal): Promise<{
|
|
197
245
|
text: string;
|
|
198
246
|
filenames: string[];
|
|
@@ -202,4 +250,5 @@ export declare function runGlobDetailed(env: ExecutionEnv, root: string, pattern
|
|
|
202
250
|
totalMatches: number;
|
|
203
251
|
countIsComplete: boolean;
|
|
204
252
|
error?: string;
|
|
253
|
+
withheld?: ReadDenyWithheld;
|
|
205
254
|
}>;
|
package/dist/tools/fs/search.js
CHANGED
|
@@ -218,7 +218,7 @@ export async function buildIgnore(env, root, signal) {
|
|
|
218
218
|
export function walkIsPartial(w) {
|
|
219
219
|
return w.incomplete || w.skippedUnreadable > 0 || w.aborted;
|
|
220
220
|
}
|
|
221
|
-
export async function walk(env, root, start, ignore, signal) {
|
|
221
|
+
export async function walk(env, root, start, ignore, signal, deny) {
|
|
222
222
|
const out = [];
|
|
223
223
|
const nameOnly = [];
|
|
224
224
|
const mtimes = new Map();
|
|
@@ -226,6 +226,17 @@ export async function walk(env, root, start, ignore, signal) {
|
|
|
226
226
|
let skippedLarge = 0;
|
|
227
227
|
let skippedUnreadable = 0;
|
|
228
228
|
let aborted = false;
|
|
229
|
+
let denyPruned = 0;
|
|
230
|
+
const denyPatterns = [];
|
|
231
|
+
const denyHit = (path) => {
|
|
232
|
+
const hit = deny?.matchPath(path);
|
|
233
|
+
if (!hit)
|
|
234
|
+
return false;
|
|
235
|
+
denyPruned++;
|
|
236
|
+
if (!denyPatterns.includes(hit.pattern))
|
|
237
|
+
denyPatterns.push(hit.pattern);
|
|
238
|
+
return true;
|
|
239
|
+
};
|
|
229
240
|
const rootPrefix = root.replace(/[\\/]+$/, "") + (root.includes("\\") ? "\\" : "/");
|
|
230
241
|
const rel = (abs) => (abs.startsWith(rootPrefix) ? abs.slice(rootPrefix.length) : abs);
|
|
231
242
|
const stack = [{ dir: start, depth: 0 }];
|
|
@@ -255,6 +266,8 @@ export async function walk(env, root, start, ignore, signal) {
|
|
|
255
266
|
if (info.kind === "directory") {
|
|
256
267
|
if (ignore(rel(info.path), true) || visited.has(info.path))
|
|
257
268
|
continue;
|
|
269
|
+
if (denyHit(info.path))
|
|
270
|
+
continue;
|
|
258
271
|
if (depth < WALK_MAX_DEPTH)
|
|
259
272
|
stack.push({ dir: info.path, depth: depth + 1 });
|
|
260
273
|
else
|
|
@@ -263,6 +276,8 @@ export async function walk(env, root, start, ignore, signal) {
|
|
|
263
276
|
else if (info.kind === "file") {
|
|
264
277
|
if (ignore(rel(info.path), false))
|
|
265
278
|
continue;
|
|
279
|
+
if (denyHit(info.path))
|
|
280
|
+
continue;
|
|
266
281
|
if (typeof info.mtimeMs === "number")
|
|
267
282
|
mtimes.set(info.path, info.mtimeMs);
|
|
268
283
|
if (hasBinaryExtension(info.path)) {
|
|
@@ -290,7 +305,12 @@ export async function walk(env, root, start, ignore, signal) {
|
|
|
290
305
|
}
|
|
291
306
|
if (stack.length > 0 && out.length >= WALK_MAX_FILES)
|
|
292
307
|
incomplete = true;
|
|
293
|
-
return { files: out, nameOnly, incomplete, skippedLarge, skippedUnreadable, aborted, mtimes };
|
|
308
|
+
return { files: out, nameOnly, incomplete, skippedLarge, skippedUnreadable, aborted, mtimes, denyPruned, denyPatterns };
|
|
309
|
+
}
|
|
310
|
+
export function denyWithheldNote(w) {
|
|
311
|
+
if (w.denyPruned <= 0)
|
|
312
|
+
return "";
|
|
313
|
+
return `\n[note: ${w.denyPruned} entr${w.denyPruned === 1 ? "y" : "ies"} matching the sensitive-path read deny list ${w.denyPruned === 1 ? "was" : "were"} withheld from this traversal (pruned entries' descendants not counted; patterns: ${w.denyPatterns.join(", ")})]`;
|
|
294
314
|
}
|
|
295
315
|
function walkCaveat(w, forContentSearch) {
|
|
296
316
|
const parts = [];
|
|
@@ -737,7 +757,7 @@ function multilineSpans(content, p, captureText = false, deadline = Infinity) {
|
|
|
737
757
|
}
|
|
738
758
|
return spans;
|
|
739
759
|
}
|
|
740
|
-
export async function jsGrep(env, root, p, signal, guards) {
|
|
760
|
+
export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
|
|
741
761
|
const analysis = analyzeRedos(p.pattern, p.ignore_case === true);
|
|
742
762
|
if (analysis.risk)
|
|
743
763
|
return redosRejection(analysis.risk);
|
|
@@ -773,16 +793,21 @@ export async function jsGrep(env, root, p, signal, guards) {
|
|
|
773
793
|
skippedUnreadable: 0,
|
|
774
794
|
aborted: false,
|
|
775
795
|
mtimes: typeof info.value.mtimeMs === "number" ? new Map([[start, info.value.mtimeMs]]) : new Map(),
|
|
796
|
+
denyPruned: 0,
|
|
797
|
+
denyPatterns: [],
|
|
776
798
|
};
|
|
777
799
|
}
|
|
778
800
|
else {
|
|
779
|
-
walked = await walk(env, root, start, ignore, signal);
|
|
801
|
+
walked = await walk(env, root, start, ignore, signal, deny);
|
|
780
802
|
}
|
|
781
803
|
}
|
|
782
804
|
else {
|
|
783
|
-
walked = await walk(env, root, start, ignore, signal);
|
|
805
|
+
walked = await walk(env, root, start, ignore, signal, deny);
|
|
806
|
+
}
|
|
807
|
+
if (denyOut !== undefined && walked.denyPruned > 0) {
|
|
808
|
+
denyOut.withheld = { kind: "pruned_count", count: walked.denyPruned, patterns: walked.denyPatterns };
|
|
784
809
|
}
|
|
785
|
-
let caveat = walkCaveat(walked, true);
|
|
810
|
+
let caveat = walkCaveat(walked, true) + denyWithheldNote(walked);
|
|
786
811
|
let files = walked.files;
|
|
787
812
|
files.sort();
|
|
788
813
|
const cap = p.head_limit === 0 ? Infinity : Math.max(1, Math.floor(p.head_limit ?? GREP_DEFAULT_CAP));
|
|
@@ -1016,16 +1041,18 @@ function formatRgStdout(stdout, p, caveat = "") {
|
|
|
1016
1041
|
(off > 0 ? `\n[offset ${off}]` : "") +
|
|
1017
1042
|
caveat);
|
|
1018
1043
|
}
|
|
1019
|
-
async function jsGrepFallback(env, root, p, signal, reason) {
|
|
1020
|
-
const
|
|
1044
|
+
async function jsGrepFallback(env, root, p, signal, reason, deny) {
|
|
1045
|
+
const denyOut = {};
|
|
1046
|
+
const text = await jsGrep(env, root, p, signal, undefined, deny, denyOut);
|
|
1021
1047
|
return {
|
|
1022
1048
|
text: text.startsWith("Error (grep)")
|
|
1023
1049
|
? text
|
|
1024
1050
|
: `${text}\n[note: ripgrep failed (${reason}); results are from a fallback scanner]`,
|
|
1025
1051
|
degraded: { fallback: "js-scan", reason: `ripgrep failed (${reason})` },
|
|
1052
|
+
...(denyOut.withheld !== undefined ? { withheld: denyOut.withheld } : {}),
|
|
1026
1053
|
};
|
|
1027
1054
|
}
|
|
1028
|
-
export async function rgGrepDetailed(env, root, p, signal) {
|
|
1055
|
+
export async function rgGrepDetailed(env, root, p, signal, deny) {
|
|
1029
1056
|
const mode = p.output_mode ?? "files_with_matches";
|
|
1030
1057
|
const flags = ["--no-messages", "--no-require-git", "--hidden"];
|
|
1031
1058
|
for (const d of VCS_DIRS)
|
|
@@ -1055,6 +1082,9 @@ export async function rgGrepDetailed(env, root, p, signal) {
|
|
|
1055
1082
|
flags.push("--glob", shellQuote(g));
|
|
1056
1083
|
if (p.type)
|
|
1057
1084
|
flags.push("--type", shellQuote(p.type));
|
|
1085
|
+
if (deny !== undefined)
|
|
1086
|
+
for (const g of deny.rgExclusionGlobs)
|
|
1087
|
+
flags.push(g.flag, shellQuote(g.glob));
|
|
1058
1088
|
const target = p.path ? shellQuote(p.path) : ".";
|
|
1059
1089
|
const cmd = `rg ${flags.join(" ")} -e ${shellQuote(p.pattern)} -- ${target}`;
|
|
1060
1090
|
const res = await env.exec(cmd, { cwd: root, timeout: 60, abortSignal: signal });
|
|
@@ -1070,13 +1100,26 @@ export async function rgGrepDetailed(env, root, p, signal) {
|
|
|
1070
1100
|
degraded: { partial: true, reason: "ripgrep timed out" },
|
|
1071
1101
|
};
|
|
1072
1102
|
}
|
|
1073
|
-
return jsGrepFallback(env, root, p, signal, "timed out with no output");
|
|
1103
|
+
return jsGrepFallback(env, root, p, signal, "timed out with no output", deny);
|
|
1074
1104
|
}
|
|
1075
|
-
return jsGrepFallback(env, root, p, signal, `could not run: ${err.code}
|
|
1105
|
+
return jsGrepFallback(env, root, p, signal, `could not run: ${err.code}`, deny);
|
|
1076
1106
|
}
|
|
1077
1107
|
const { exitCode, stdout } = res.value;
|
|
1078
|
-
|
|
1079
|
-
|
|
1108
|
+
const denyDisclosure = async () => {
|
|
1109
|
+
if (deny === undefined || deny.rgExclusionGlobs.length === 0)
|
|
1110
|
+
return { note: "" };
|
|
1111
|
+
const withheld = await rgDenyExistenceProbe(env, root, deny, target, signal);
|
|
1112
|
+
if (withheld === undefined)
|
|
1113
|
+
return { note: "" };
|
|
1114
|
+
const note = withheld.kind === "existence"
|
|
1115
|
+
? "\n[note: entries matching the sensitive-path read deny list exist under this scope and were excluded (readDenyPatterns)]"
|
|
1116
|
+
: "\n[note: entries matching the sensitive-path read deny list may have been excluded (deny-existence probe failed)]";
|
|
1117
|
+
return { note, withheld };
|
|
1118
|
+
};
|
|
1119
|
+
if (exitCode === 1) {
|
|
1120
|
+
const d = await denyDisclosure();
|
|
1121
|
+
return { text: NO_MATCHES + d.note, ...(d.withheld !== undefined ? { withheld: d.withheld } : {}) };
|
|
1122
|
+
}
|
|
1080
1123
|
if (exitCode >= 2) {
|
|
1081
1124
|
if (stdout.trim().length > 0) {
|
|
1082
1125
|
return {
|
|
@@ -1084,15 +1127,45 @@ export async function rgGrepDetailed(env, root, p, signal) {
|
|
|
1084
1127
|
degraded: { partial: true, reason: `ripgrep exited with code ${exitCode}` },
|
|
1085
1128
|
};
|
|
1086
1129
|
}
|
|
1087
|
-
return jsGrepFallback(env, root, p, signal, `exited with code ${exitCode} and produced no output
|
|
1130
|
+
return jsGrepFallback(env, root, p, signal, `exited with code ${exitCode} and produced no output`, deny);
|
|
1088
1131
|
}
|
|
1089
1132
|
const orderedStdout = mode === "files_with_matches" ? await sortRgFilesByMtime(env, root, stdout, signal) : stdout;
|
|
1090
|
-
|
|
1133
|
+
const d = await denyDisclosure();
|
|
1134
|
+
return { text: formatRgStdout(orderedStdout, p) + d.note, ...(d.withheld !== undefined ? { withheld: d.withheld } : {}) };
|
|
1091
1135
|
}
|
|
1092
|
-
|
|
1136
|
+
async function rgDenyExistenceProbe(env, root, deny, target, signal) {
|
|
1137
|
+
const probeFlags = ["--files", "--hidden", "--no-require-git", "--no-messages"];
|
|
1138
|
+
for (const d of VCS_DIRS)
|
|
1139
|
+
probeFlags.push("--glob", `!${d}`);
|
|
1140
|
+
for (const g of deny.rgProbeGlobs)
|
|
1141
|
+
probeFlags.push(g.flag, shellQuote(g.glob));
|
|
1142
|
+
try {
|
|
1143
|
+
const res = await env.exec(`( rg ${probeFlags.join(" ")} -- ${target}; echo "__RG_PROBE_EXIT_$?" 1>&2 ) | head -n 1`, { cwd: root, timeout: 10, abortSignal: signal });
|
|
1144
|
+
if (!res.ok)
|
|
1145
|
+
return { kind: "probe_failed" };
|
|
1146
|
+
const first = res.value.stdout.split("\n").find((l) => l.trim().length > 0);
|
|
1147
|
+
if (first !== undefined) {
|
|
1148
|
+
const hit = deny.matchPath(first.trim());
|
|
1149
|
+
return { kind: "existence", ...(hit !== null ? { patterns: [hit.pattern] } : {}) };
|
|
1150
|
+
}
|
|
1151
|
+
const marker = /__RG_PROBE_EXIT_(\d+)/.exec(res.value.stderr ?? "");
|
|
1152
|
+
const rgExit = marker === null ? undefined : Number(marker[1]);
|
|
1153
|
+
if (rgExit === 1)
|
|
1154
|
+
return undefined;
|
|
1155
|
+
if (rgExit === 0)
|
|
1156
|
+
return undefined;
|
|
1157
|
+
return { kind: "probe_failed" };
|
|
1158
|
+
}
|
|
1159
|
+
catch {
|
|
1160
|
+
return { kind: "probe_failed" };
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
export async function runGrepDetailed(env, root, p, signal, deny) {
|
|
1093
1164
|
if (await detectRipgrep(env))
|
|
1094
|
-
return rgGrepDetailed(env, root, p, signal);
|
|
1095
|
-
|
|
1165
|
+
return rgGrepDetailed(env, root, p, signal, deny);
|
|
1166
|
+
const denyOut = {};
|
|
1167
|
+
const text = await jsGrep(env, root, p, signal, undefined, deny, denyOut);
|
|
1168
|
+
return { text, ...(denyOut.withheld !== undefined ? { withheld: denyOut.withheld } : {}) };
|
|
1096
1169
|
}
|
|
1097
1170
|
export async function runGrep(env, root, p, signal) {
|
|
1098
1171
|
return (await runGrepDetailed(env, root, p, signal)).text;
|
|
@@ -1180,8 +1253,8 @@ export async function runGlobDetailed(env, root, pattern, opts = {}, signal) {
|
|
|
1180
1253
|
}
|
|
1181
1254
|
return ig;
|
|
1182
1255
|
};
|
|
1183
|
-
const walked = await walk(env, root, start, ignore, signal);
|
|
1184
|
-
const caveat = walkCaveat(walked, false);
|
|
1256
|
+
const walked = await walk(env, root, start, ignore, signal, opts.deny);
|
|
1257
|
+
const caveat = walkCaveat(walked, false) + denyWithheldNote(walked);
|
|
1185
1258
|
const files = [...walked.files, ...walked.nameOnly];
|
|
1186
1259
|
const rel = (abs) => (abs.startsWith(rootPrefix) ? abs.slice(rootPrefix.length) : abs);
|
|
1187
1260
|
const cap = Math.max(1, Math.floor(opts.max ?? 500));
|
|
@@ -1212,5 +1285,14 @@ export async function runGlobDetailed(env, root, pattern, opts = {}, signal) {
|
|
|
1212
1285
|
? `\n[note: the file-walk budget (${WALK_MAX_FILES} files / ${WALK_MAX_DEPTH} directory levels) ran out before the whole tree was scanned — this empty result is NOT proof the file is absent; scope the search with \`path\`, or use a root-anchored pattern (e.g. "src/**/*.ts") so the walk is pruned toward it, then retry]`
|
|
1213
1286
|
: "";
|
|
1214
1287
|
const text = matched.length === 0 ? "No files matched." + ignoreNote + ignoredFileNote + budgetNote + caveat : matched.join("\n") + capNote + caveat;
|
|
1215
|
-
return {
|
|
1288
|
+
return {
|
|
1289
|
+
text,
|
|
1290
|
+
filenames: matched,
|
|
1291
|
+
numFiles: matched.length,
|
|
1292
|
+
truncated,
|
|
1293
|
+
durationMs: Date.now() - t0,
|
|
1294
|
+
totalMatches,
|
|
1295
|
+
countIsComplete,
|
|
1296
|
+
...(walked.denyPruned > 0 ? { withheld: { kind: "pruned_count", count: walked.denyPruned, patterns: walked.denyPatterns } } : {}),
|
|
1297
|
+
};
|
|
1216
1298
|
}
|