@sema-agent/core 5.29.0 → 5.31.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/dist/agents/send-message-tool.js +2 -0
  3. package/dist/agents/subagent.d.ts +2 -0
  4. package/dist/agents/subagent.js +6 -0
  5. package/dist/agents/teacher.js +2 -0
  6. package/dist/agents/verify.js +2 -0
  7. package/dist/core/auto-compaction.d.ts +5 -1
  8. package/dist/core/auto-compaction.js +10 -1
  9. package/dist/core/checkpoint-store.d.ts +51 -5
  10. package/dist/core/checkpoint-store.js +2 -1
  11. package/dist/core/hooks.d.ts +12 -1
  12. package/dist/core/hooks.js +8 -2
  13. package/dist/core/permission-rules.js +2 -2
  14. package/dist/core/runner/prepare-task.d.ts +21 -5
  15. package/dist/core/runner/prepare-task.js +115 -26
  16. package/dist/core/runner/runtask.js +29 -3
  17. package/dist/core/runner/session-rule-policy.d.ts +3 -2
  18. package/dist/core/runner/tool-output-projection.js +1 -1
  19. package/dist/core/sensitive-path-policy.js +5 -16
  20. package/dist/core/store-contracts/tool-result-store-contract.d.ts +4 -1
  21. package/dist/core/store-contracts/tool-result-store-contract.js +26 -1
  22. package/dist/core/tighten-task-spec.js +18 -0
  23. package/dist/core/tool-policy.d.ts +20 -1
  24. package/dist/core/tool-policy.js +31 -4
  25. package/dist/core/tool-result-store.d.ts +6 -4
  26. package/dist/core/tool-result-store.js +3 -1
  27. package/dist/core/types.d.ts +63 -1
  28. package/dist/engine/harness/types.d.ts +10 -0
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.js +3 -1
  31. package/dist/orchestration/run-workflow-tool.d.ts +26 -0
  32. package/dist/orchestration/run-workflow-tool.js +7 -4
  33. package/dist/orchestration/workflow-governance.d.ts +53 -3
  34. package/dist/orchestration/workflow-governance.js +162 -25
  35. package/dist/orchestration/workflow-primitives.d.ts +15 -1
  36. package/dist/orchestration/workflow-primitives.js +13 -2
  37. package/dist/prompt-assembly/epoch.js +2 -0
  38. package/dist/prompt-assembly/packs/sema-default.js +2 -2
  39. package/dist/prompt-assembly/types.d.ts +4 -0
  40. package/dist/prompts/default.d.ts +14 -9
  41. package/dist/prompts/default.js +13 -3
  42. package/dist/tools/fs/bash-readonly-classifier.d.ts +53 -4
  43. package/dist/tools/fs/bash-readonly-classifier.js +148 -16
  44. package/dist/tools/fs/fs-bash.d.ts +7 -0
  45. package/dist/tools/fs/fs-bash.js +8 -3
  46. package/dist/tools/fs/fs-pdf.d.ts +1 -1
  47. package/dist/tools/fs/fs-pdf.js +2 -2
  48. package/dist/tools/fs/fs-read.d.ts +1 -1
  49. package/dist/tools/fs/fs-read.js +11 -7
  50. package/dist/tools/fs/fs-search-tools.d.ts +4 -2
  51. package/dist/tools/fs/fs-search-tools.js +15 -8
  52. package/dist/tools/fs/fs-shared.d.ts +5 -1
  53. package/dist/tools/fs/fs-shared.js +8 -3
  54. package/dist/tools/fs/index.d.ts +18 -0
  55. package/dist/tools/fs/index.js +13 -2
  56. package/dist/tools/fs/read-deny.d.ts +110 -0
  57. package/dist/tools/fs/read-deny.js +159 -0
  58. package/dist/tools/fs/read-face.d.ts +49 -0
  59. package/dist/tools/fs/read-face.js +38 -0
  60. package/dist/tools/fs/repo-map.d.ts +3 -1
  61. package/dist/tools/fs/repo-map.js +11 -5
  62. package/dist/tools/fs/safety.d.ts +34 -11
  63. package/dist/tools/fs/safety.js +108 -8
  64. package/dist/tools/fs/search.d.ts +54 -5
  65. package/dist/tools/fs/search.js +107 -23
  66. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../../core/tools.js";
3
3
  import { resolveKey, violationText, violationDetails } from "./safety.js";
4
- import { buildIgnore, walk, walkIsPartial } from "./search.js";
4
+ import { buildIgnore, denyWithheldNote, walk, walkIsPartial } from "./search.js";
5
5
  const DEFAULT_MAX_CHARS = 16_000;
6
6
  const DEFAULT_MAX_FILES = 400;
7
7
  const FILE_SCAN_MAX_BYTES = 256 * 1024;
@@ -79,7 +79,7 @@ function rankEntries(a, b) {
79
79
  return b.symbols.length - a.symbols.length;
80
80
  return a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0;
81
81
  }
82
- export function createRepoMapTool(env, rootCanonical, additionalRoots) {
82
+ export function createRepoMapTool(env, rootCanonical, additionalRoots, readDeny, readFace) {
83
83
  return defineTool({
84
84
  name: "RepoMap",
85
85
  contract: { contractId: "core.repo_map@1", implementationRevision: "1" },
@@ -88,7 +88,7 @@ export function createRepoMapTool(env, rootCanonical, additionalRoots) {
88
88
  "what exists and where, so you can jump to the relevant files. Token-budgeted and honest about " +
89
89
  "truncation. Optionally scope to a sub-directory.",
90
90
  parameters: Type.Object({
91
- path: Type.Optional(Type.String({ description: "Restrict the map to a sub-directory (relative to root)." })),
91
+ path: Type.Optional(Type.String({ description: "Restrict the map to a directory (relative paths resolve against the root)." })),
92
92
  max_chars: Type.Optional(Type.Number({ description: `Token budget for the rendered map in chars (default ${DEFAULT_MAX_CHARS}; a very small value yields only a truncation note).` })),
93
93
  max_files: Type.Optional(Type.Number({ description: `Max files to scan (default ${DEFAULT_MAX_FILES}).` })),
94
94
  }),
@@ -99,7 +99,7 @@ export function createRepoMapTool(env, rootCanonical, additionalRoots) {
99
99
  const rootPrefix = rootCanonical.replace(/[\\/]+$/, "") + (rootCanonical.includes("\\") ? "\\" : "/");
100
100
  let start = rootCanonical;
101
101
  if (a.path !== undefined) {
102
- const r = await resolveKey(env, rootCanonical, a.path, signal, rootCanonical, additionalRoots);
102
+ const r = await resolveKey(env, rootCanonical, a.path, signal, rootCanonical, additionalRoots, undefined, readDeny, readFace);
103
103
  if (!r.ok)
104
104
  return errorResult(violationText("RepoMap", r.violation), violationDetails(r.violation));
105
105
  start = r.key;
@@ -107,7 +107,7 @@ export function createRepoMapTool(env, rootCanonical, additionalRoots) {
107
107
  const maxChars = Math.max(1, Math.floor(a.max_chars ?? DEFAULT_MAX_CHARS));
108
108
  const maxFiles = Math.max(1, Math.floor(a.max_files ?? DEFAULT_MAX_FILES));
109
109
  const ignore = await buildIgnore(env, rootCanonical, signal);
110
- const walked = await walk(env, rootCanonical, start, ignore, signal);
110
+ const walked = await walk(env, rootCanonical, start, ignore, signal, readDeny);
111
111
  const rel = (abs) => (abs.startsWith(rootPrefix) ? abs.slice(rootPrefix.length) : abs);
112
112
  const entries = [];
113
113
  const nonSource = [];
@@ -180,6 +180,11 @@ export function createRepoMapTool(env, rootCanonical, additionalRoots) {
180
180
  notes.push("…[the symbol scan was interrupted before finishing — the map is partial]");
181
181
  if (skippedUnreadableFiles > 0)
182
182
  notes.push(`…[${skippedUnreadableFiles} source file${skippedUnreadableFiles === 1 ? "" : "s"} could not be read — ${skippedUnreadableFiles === 1 ? "it is" : "they are"} missing from the map]`);
183
+ {
184
+ const denyNote = denyWithheldNote(walked).trim();
185
+ if (denyNote.length > 0)
186
+ notes.push(denyNote);
187
+ }
183
188
  if (nonSource.length > 0) {
184
189
  if (!budgetHit) {
185
190
  const NON_SOURCE_CAP = 40;
@@ -198,6 +203,7 @@ export function createRepoMapTool(env, rootCanonical, additionalRoots) {
198
203
  renderedFiles: rendered,
199
204
  nonSourceFiles: nonSource.length,
200
205
  truncated: budgetHit || scanIncomplete || scanAborted || skippedUnreadableFiles > 0 || walkIsPartial(walked) || rendered < entries.length,
206
+ ...(walked.denyPruned > 0 ? { withheld: { kind: "pruned_count", count: walked.denyPruned, patterns: walked.denyPatterns } } : {}),
201
207
  };
202
208
  if (lines.length === 0) {
203
209
  const base = start === rootCanonical ? "Repository is empty or has no readable files." : `No readable files under "${a.path}".`;
@@ -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 ONLY on `path_not_in_root` — the CANONICAL key the refused path resolved to
95
- * (symlinks followed, tracked cwd applied), i.e. the value the fence actually judged. Rides into
96
- * the tool result's `details` (see {@link violationDetails}) so a consumer can count/aggregate
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,12 @@ 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): Promise<{
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
+ matchedView?: string;
274
+ } | null;
275
+ }, readFace?: "open" | "roots"): Promise<{
262
276
  ok: true;
263
277
  key: string;
264
278
  } | {
@@ -301,6 +315,11 @@ export declare function violationDetails(v: FsViolation): {
301
315
  code: "path_not_in_root";
302
316
  target: string;
303
317
  roots: readonly string[];
318
+ } | {
319
+ type: "read_path_denied";
320
+ code: "read_path_denied";
321
+ target: string;
322
+ pattern: string;
304
323
  } | undefined;
305
324
  /**
306
325
  * RB-371 ② — does `p` sit inside ANY of the canonical containment roots? Exposed for the Bash
@@ -313,12 +332,16 @@ export declare function withinAnyRoot(rootsCanonical: readonly string[], p: stri
313
332
  /**
314
333
  * RB-371 ① — the escape-hatch tail for the `path_not_in_root` refusal: its three sibling hints
315
334
  * 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. Both
317
- * stated ways out are consistent with the Bash tool's own card (the shell is deliberately NOT
318
- * sandboxed by this fence design/44 §5 — and every call still passes the deployment's approval
319
- * policy) and with the deployment-side widening knob (`additionalDirectories`, design/119).
335
+ * between "the boundary is hard" and discovering by trial that bash is not confined by it.
336
+ *
337
+ * Ordering is deliberate (ruled 2026-08-12, fence = the deployment's declared containment
338
+ * boundary): the SANCTIONED way out widening the declaration (`additionalDirectories` /
339
+ * read-only `additionalReadDirectories`, design/119) — leads; the Bash sentence stays as a FACT
340
+ * (the shell genuinely is not confined by this fence — design/44 §5 — and omitting that would
341
+ * just let the model discover it by trial), but it is a statement, not a recommendation, and it
342
+ * comes last. Every shell call still passes the deployment's approval policy.
320
343
  */
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) and the deployment permits it, use the Bash tool \u2014 it is not confined by this fence, though every call remains subject to the deployment's approval policy. Or ask for the directory to be added to additionalDirectories.)";
344
+ 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
345
  /** inv 1 (read-before-edit): a file must have been read this task before it can be edited/overwritten.
323
346
  * 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
347
  * Edit/Write/NotebookEdit: "before writing to it", not the old sema "before editing").
@@ -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 = isWinFormPath(p) ? p.replace(/\\/g, "/") : p;
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;
@@ -46,7 +49,7 @@ const PROC_SENSITIVE_SUFFIXES = ["/environ", "/cmdline", "/auxv", "/maps", "/mem
46
49
  function isProcSensitiveFile(key) {
47
50
  return key.startsWith("/proc/") && PROC_SENSITIVE_SUFFIXES.some((suf) => key.endsWith(suf));
48
51
  }
49
- const WIN_RESERVED_RE = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.[^\\/]*)?$/i;
52
+ const WIN_RESERVED_RE = /^(CON|PRN|AUX|NUL|COM[0-9¹²³]|LPT[0-9¹²³]|CONIN\$|CONOUT\$)(\.[^\\/]*)?$/i;
50
53
  export function isWinFormPath(p) {
51
54
  return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith("\\\\") || (!p.startsWith("/") && p.includes("\\"));
52
55
  }
@@ -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,58 @@ 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
+ const body = unc !== null ? rest.slice(unc[0].length) : /^[A-Za-z]:\\/.test(rest) ? rest.slice(3) : undefined;
278
+ if (body === undefined)
279
+ return p;
280
+ if (body !== "") {
281
+ for (const seg of body.split("\\")) {
282
+ if (seg === "" || seg === "." || seg === "..")
283
+ return p;
284
+ if (seg.endsWith(".") || seg.endsWith(" ") || seg.includes("/"))
285
+ return p;
286
+ if (WIN_RESERVED_RE.test(seg))
287
+ return p;
288
+ }
289
+ }
290
+ return unc ? "\\\\" + rest.slice(unc[0].length) : rest;
291
+ }
292
+ function foldUncLexically(p) {
293
+ const body = p.slice(2);
294
+ const segs = body.split(/[\\/]+/).filter((s) => s !== "");
295
+ const host = segs.shift() ?? "";
296
+ const share = segs.shift();
297
+ const out = [];
298
+ let climbed = false;
299
+ for (const seg of segs) {
300
+ if (seg === ".")
301
+ continue;
302
+ if (seg === "..") {
303
+ if (out.length === 0)
304
+ climbed = true;
305
+ else
306
+ out.pop();
307
+ continue;
308
+ }
309
+ const stripped = seg.replace(/[. ]+$/, "");
310
+ out.push(stripped.length > 0 ? stripped : seg);
311
+ }
312
+ const anchor = `\\\\${host}${share !== undefined ? `\\${share}` : ""}`;
313
+ return { key: out.length > 0 ? `${anchor}\\${out.join("\\")}` : anchor, climbedAboveShare: climbed };
314
+ }
315
+ export function lexicalViewOf(spelled, base) {
316
+ const abs = isAbsolutePathForm(spelled)
317
+ ? spelled
318
+ : `${base.replace(/[\\/]+$/, "")}${isWinFormPath(base) && base.includes("\\") ? "\\" : "/"}${spelled}`;
319
+ return normalizeAbsPathLexically(abs);
320
+ }
265
321
  function within(rootCanonical, p) {
266
322
  if (!rootCanonical)
267
323
  return false;
@@ -274,7 +330,7 @@ function within(rootCanonical, p) {
274
330
  const base = rootN.endsWith(SEP) ? rootN : rootN + SEP;
275
331
  return pN.startsWith(base);
276
332
  }
277
- export async function resolveKey(env, rootCanonical, path, signal, baseCwd, additionalRootsCanonical, exactFileReadExemption) {
333
+ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, additionalRootsCanonical, exactFileReadExemption, readDeny, readFace) {
278
334
  const canon = await canonicalizeTarget(env, path, signal, baseCwd);
279
335
  if (!canon.ok)
280
336
  return { ok: false, violation: { code: "invalid", message: canon.message } };
@@ -282,6 +338,36 @@ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, addi
282
338
  if (isBlockedDevicePath(key)) {
283
339
  return { ok: false, violation: { code: "invalid", message: `path "${path}" is a blocked device/special file (reading it can hang the process); refused.` } };
284
340
  }
341
+ if (readDeny !== undefined) {
342
+ const hit = readDeny.matchTarget(key, lexicalViewOf(path, baseCwd ?? rootCanonical));
343
+ if (hit !== null) {
344
+ const lexicalHit = hit.matchedView !== undefined && hit.matchedView !== key;
345
+ const judged = lexicalHit
346
+ ? `the requested path's spelling ("${hit.matchedView}") matches`
347
+ : `the target matches`;
348
+ return {
349
+ ok: false,
350
+ violation: {
351
+ code: "read_path_denied",
352
+ message: `reading "${path}" ${key !== path ? `(canonical target: "${key}") ` : ""}is refused: ${judged} the sensitive-path read deny list (pattern "${hit.pattern}"). This list guards credential-class paths and applies regardless of the containment roots.`,
353
+ target: key,
354
+ pattern: hit.pattern,
355
+ },
356
+ };
357
+ }
358
+ }
359
+ if (readFace === "open") {
360
+ if (isUncPath(key) && isWinFormPath(key) && !within(rootCanonical, key) && !additionalRootsCanonical?.some((r) => within(r, key))) {
361
+ return {
362
+ ok: false,
363
+ violation: {
364
+ code: "invalid",
365
+ 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.`,
366
+ },
367
+ };
368
+ }
369
+ return { ok: true, key };
370
+ }
285
371
  if (!within(rootCanonical, key) && !additionalRootsCanonical?.some((r) => within(r, key))) {
286
372
  if (exactFileReadExemption?.(key) === true)
287
373
  return { ok: true, key };
@@ -299,10 +385,21 @@ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, addi
299
385
  return { ok: true, key };
300
386
  }
301
387
  export async function canonicalizeTarget(env, path, signal, baseCwd) {
302
- if (isUncPath(path) && isWinFormPath(path)) {
303
- return { ok: true, key: path };
388
+ if (isWin32DeviceNamespacePath(path)) {
389
+ return { ok: false, message: `path "${path}" is a Win32 device-namespace path (\\\\.\\ names a raw device object, not a file); refused.` };
390
+ }
391
+ const unprefixed = stripWin32ExtendedPrefix(path);
392
+ if (unprefixed.startsWith("\\\\?\\")) {
393
+ return { ok: false, message: `path "${path}" is a Win32 extended-length namespace path with no DOS-path equivalent (only \\\\?\\<drive>: and \\\\?\\UNC\\ forms name files); refused.` };
304
394
  }
305
- const spelled = path.startsWith("//") ? path.replace(/^\/+/, "/") : path;
395
+ if (isUncPath(unprefixed) && isWinFormPath(unprefixed)) {
396
+ const folded = foldUncLexically(unprefixed);
397
+ if (folded.climbedAboveShare) {
398
+ return { ok: false, message: `path "${path}" climbs above its UNC share root via ".." (\\\\host\\share is the root of a network path); refused.` };
399
+ }
400
+ return { ok: true, key: folded.key };
401
+ }
402
+ const spelled = unprefixed.startsWith("//") ? unprefixed.replace(/^\/+/, "/") : unprefixed;
306
403
  const target = baseCwd && !isAbsolutePathForm(spelled) ? `${baseCwd.replace(/[\\/]+$/, "")}/${spelled}` : spelled;
307
404
  const absR = await env.absolutePath(target, signal);
308
405
  if (!absR.ok)
@@ -384,6 +481,9 @@ export function violationText(toolName, v) {
384
481
  return `Error (${toolName}): ${v.message}`;
385
482
  }
386
483
  export function violationDetails(v) {
484
+ if (v.code === "read_path_denied" && v.target !== undefined && v.pattern !== undefined) {
485
+ return { type: "read_path_denied", code: "read_path_denied", target: v.target, pattern: v.pattern };
486
+ }
387
487
  return v.code === "path_not_in_root" && v.target !== undefined
388
488
  ? { type: "path_not_in_root", code: "path_not_in_root", target: v.target, roots: v.roots ?? [] }
389
489
  : undefined;
@@ -391,7 +491,7 @@ export function violationDetails(v) {
391
491
  export function withinAnyRoot(rootsCanonical, p) {
392
492
  return rootsCanonical.some((r) => within(r, p));
393
493
  }
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) and the deployment permits it, use the Bash tool — it is not confined by this fence, though every call remains subject to the deployment's approval policy. Or ask for the directory to be added to additionalDirectories.)";
494
+ 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
495
  export function requireRead(state, key) {
396
496
  const entry = state.get(key);
397
497
  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): Promise<WalkResult>;
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
- /** JS-fallback grep: ignore-aware walk + per-line scan, honoring output_mode / context / head_limit. */
143
- export declare function jsGrep(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal, guards?: JsGrepGuards): Promise<string>;
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
  }>;