@sema-agent/core 5.28.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.
Files changed (122) hide show
  1. package/CHANGELOG.md +99 -3
  2. package/dist/agents/cascade.d.ts +1 -1
  3. package/dist/agents/observer.d.ts +3 -3
  4. package/dist/agents/peer-admission.d.ts +1 -1
  5. package/dist/agents/send-message-tool.js +2 -0
  6. package/dist/agents/subagent.d.ts +2 -0
  7. package/dist/agents/subagent.js +6 -0
  8. package/dist/agents/teacher.d.ts +1 -1
  9. package/dist/agents/teacher.js +2 -0
  10. package/dist/agents/verify.d.ts +6 -6
  11. package/dist/agents/verify.js +2 -0
  12. package/dist/bench/metrics.d.ts +1 -1
  13. package/dist/brain/retry.d.ts +1 -1
  14. package/dist/core/arg-summary.d.ts +1 -1
  15. package/dist/core/ask-class.d.ts +2 -2
  16. package/dist/core/ask-question.d.ts +1 -1
  17. package/dist/core/ask-question.js +14 -1
  18. package/dist/core/auto-compaction.d.ts +5 -1
  19. package/dist/core/auto-compaction.js +10 -1
  20. package/dist/core/background-shell.d.ts +5 -5
  21. package/dist/core/checkpoint-store.d.ts +65 -19
  22. package/dist/core/checkpoint-store.js +2 -1
  23. package/dist/core/file-snapshot-store.d.ts +1 -1
  24. package/dist/core/hooks.d.ts +15 -4
  25. package/dist/core/hooks.js +8 -2
  26. package/dist/core/human-input-projection.d.ts +2 -2
  27. package/dist/core/memory-admission.d.ts +2 -2
  28. package/dist/core/memory-engine/engine.d.ts +1 -1
  29. package/dist/core/memory-engine/file-backend.d.ts +2 -2
  30. package/dist/core/memory-engine/header-hints.d.ts +1 -1
  31. package/dist/core/memory-engine/layout.d.ts +2 -2
  32. package/dist/core/memory-recall.d.ts +1 -1
  33. package/dist/core/memory.d.ts +2 -2
  34. package/dist/core/oracle-isolation.d.ts +2 -2
  35. package/dist/core/permission-rule-consent.d.ts +1 -1
  36. package/dist/core/permission-rule-model.d.ts +1 -1
  37. package/dist/core/permission-rule-org.d.ts +1 -1
  38. package/dist/core/permission-rule-store.d.ts +1 -1
  39. package/dist/core/permission-rules.js +2 -2
  40. package/dist/core/runner/active-skill-scope.d.ts +1 -1
  41. package/dist/core/runner/memory-consolidation.d.ts +1 -1
  42. package/dist/core/runner/prepare-task.d.ts +29 -8
  43. package/dist/core/runner/prepare-task.js +118 -22
  44. package/dist/core/runner/runtask.d.ts +1 -1
  45. package/dist/core/runner/runtask.js +34 -8
  46. package/dist/core/runner/session-rule-policy.d.ts +4 -3
  47. package/dist/core/runner/teardown-bounded.d.ts +1 -1
  48. package/dist/core/runner/tool-disclosure.d.ts +1 -1
  49. package/dist/core/runner/tool-output-projection.js +1 -1
  50. package/dist/core/scheduler.d.ts +4 -4
  51. package/dist/core/sensitive-path-policy.js +5 -16
  52. package/dist/core/session-policy-store.d.ts +3 -3
  53. package/dist/core/shared-memory/normalize.d.ts +1 -1
  54. package/dist/core/skill-tool-specifier.d.ts +1 -1
  55. package/dist/core/store-contracts/tool-result-store-contract.js +23 -0
  56. package/dist/core/task-notification.d.ts +2 -2
  57. package/dist/core/task-registry-agent.d.ts +9 -9
  58. package/dist/core/task-registry-shared.d.ts +4 -4
  59. package/dist/core/tighten-task-spec.js +18 -0
  60. package/dist/core/tool-errors.d.ts +2 -2
  61. package/dist/core/tool-policy.d.ts +64 -12
  62. package/dist/core/tool-policy.js +68 -8
  63. package/dist/core/tool-result-store.d.ts +2 -2
  64. package/dist/core/tool-result-store.js +3 -1
  65. package/dist/core/trace.d.ts +1 -1
  66. package/dist/core/types.d.ts +68 -13
  67. package/dist/core/untrusted-text.d.ts +6 -0
  68. package/dist/core/untrusted-text.js +1 -0
  69. package/dist/engine/compaction/compaction.d.ts +1 -1
  70. package/dist/engine/harness/messages.d.ts +1 -1
  71. package/dist/engine/harness/types.d.ts +12 -2
  72. package/dist/engine/llm/types.d.ts +1 -1
  73. package/dist/engine/loop/types.d.ts +2 -2
  74. package/dist/engine/session/import-validate.d.ts +1 -1
  75. package/dist/engine/session/log-digest.d.ts +1 -1
  76. package/dist/fixtures/index.d.ts +18 -2
  77. package/dist/fixtures/index.js +11 -0
  78. package/dist/index.d.ts +3 -1
  79. package/dist/index.js +3 -1
  80. package/dist/orchestration/goal.d.ts +1 -1
  81. package/dist/orchestration/run-workflow-tool.d.ts +21 -0
  82. package/dist/orchestration/run-workflow-tool.js +6 -3
  83. package/dist/orchestration/workflow-primitives.d.ts +10 -1
  84. package/dist/orchestration/workflow-primitives.js +12 -1
  85. package/dist/orchestration/workflow-types.d.ts +5 -4
  86. package/dist/orchestration/workflow.js +11 -3
  87. package/dist/prompt-assembly/artifact.d.ts +1 -1
  88. package/dist/prompt-assembly/epoch.js +2 -0
  89. package/dist/prompt-assembly/packs/sema-default.js +2 -2
  90. package/dist/prompt-assembly/types.d.ts +4 -0
  91. package/dist/prompts/default.d.ts +14 -9
  92. package/dist/prompts/default.js +14 -4
  93. package/dist/scenarios/scenario-registry.d.ts +3 -3
  94. package/dist/stores/file/tool-result-store.d.ts +2 -2
  95. package/dist/tools/fs/bash-readonly-classifier.d.ts +23 -2
  96. package/dist/tools/fs/bash-readonly-classifier.js +11 -0
  97. package/dist/tools/fs/fs-bash.d.ts +8 -1
  98. package/dist/tools/fs/fs-bash.js +8 -3
  99. package/dist/tools/fs/fs-pdf.d.ts +1 -1
  100. package/dist/tools/fs/fs-pdf.js +2 -2
  101. package/dist/tools/fs/fs-read.d.ts +1 -1
  102. package/dist/tools/fs/fs-read.js +11 -7
  103. package/dist/tools/fs/fs-search-tools.d.ts +5 -3
  104. package/dist/tools/fs/fs-search-tools.js +15 -8
  105. package/dist/tools/fs/fs-shared.d.ts +6 -2
  106. package/dist/tools/fs/fs-shared.js +8 -3
  107. package/dist/tools/fs/index.d.ts +18 -0
  108. package/dist/tools/fs/index.js +13 -2
  109. package/dist/tools/fs/read-deny.d.ts +105 -0
  110. package/dist/tools/fs/read-deny.js +151 -0
  111. package/dist/tools/fs/read-face.d.ts +43 -0
  112. package/dist/tools/fs/read-face.js +38 -0
  113. package/dist/tools/fs/repo-map.d.ts +3 -1
  114. package/dist/tools/fs/repo-map.js +11 -5
  115. package/dist/tools/fs/safety.d.ts +34 -12
  116. package/dist/tools/fs/safety.js +88 -7
  117. package/dist/tools/fs/search.d.ts +54 -5
  118. package/dist/tools/fs/search.js +103 -21
  119. package/dist/tools/scheduler-tools.d.ts +1 -1
  120. package/dist/tools/task-list.d.ts +1 -1
  121. package/dist/tools/web.d.ts +3 -3
  122. package/package.json +1 -1
@@ -10,6 +10,9 @@ export * from "./fs-write.js";
10
10
  export * from "./fs-search-tools.js";
11
11
  export * from "./bash-readonly-classifier.js";
12
12
  export * from "./fs-bash.js";
13
+ export * from "./read-deny.js";
14
+ import { type ReadDenyEntry } from "./read-deny.js";
15
+ export * from "./read-face.js";
13
16
  import { type CwdRef, type ReadImageDownsamplerOption } from "./fs-shared.js";
14
17
  /** Options for {@link createHandsToolkit}. */
15
18
  export interface HandsToolkitOptions {
@@ -115,6 +118,21 @@ export interface HandsToolkitOptions {
115
118
  * this run's roster (the Runner mounts Monitor, this band never does). `false` drops the gh
116
119
  * rate-limit hint's Monitor clause; absent ⇒ historic full wording (byte-compat). */
117
120
  monitorToolActive?: boolean;
121
+ /** design/199 件B — ADDITIONS to the built-in sensitive-path READ deny set
122
+ * ({@link import("./read-deny.js").READ_FACE_DEFAULT_DENY_ENTRIES}), judged by the structured read
123
+ * faces (Read/Grep/Glob/RepoMap and their traversals) in BOTH containment modes. Add-only at every
124
+ * layer (D-4 zero-shrink ruling): the built-ins are always in force, `[]` ≡ absent (union
125
+ * identity), and there is no replacement escape hatch. Bad entry shapes throw at wiring time
126
+ * (#123). The write faces are untouched (their guard is createSensitivePathPolicy). */
127
+ readDenyPatterns?: readonly ReadDenyEntry[];
128
+ /** design/199 件A — the READ-face containment state for the structured read faces
129
+ * ({@link import("./read-face.js").ReadFace}; resolved through the SAME
130
+ * {@link import("./read-face.js").resolveReadFace} order prepare-task uses). Absent ⇒ the
131
+ * resolution order's default = "roots" (D-1b: the engine never opens implicitly). "open" skips
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). Refused loudly beside `readOnly: true`
134
+ * (the verifier mount's containment is load-bearing). Never affects the write faces. */
135
+ readFace?: "open" | "roots";
118
136
  }
119
137
  /**
120
138
  * Build the per-task hand tool band over an injected env + fresh per-task read state (design/44 §11 A).
@@ -9,7 +9,11 @@ export * from "./fs-write.js";
9
9
  export * from "./fs-search-tools.js";
10
10
  export * from "./bash-readonly-classifier.js";
11
11
  export * from "./fs-bash.js";
12
+ export * from "./read-deny.js";
12
13
  import { BASH_READONLY_DEFAULT_ALLOW, } from "./bash-readonly-classifier.js";
14
+ import { compileReadDeny } from "./read-deny.js";
15
+ import { resolveReadFace } from "./read-face.js";
16
+ export * from "./read-face.js";
13
17
  import {} from "./fs-shared.js";
14
18
  import { createReadFileTool } from "./fs-read.js";
15
19
  import { createEditFileTool, createWriteFileTool, createNotebookEditTool } from "./fs-write.js";
@@ -29,13 +33,20 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
29
33
  scope: c.principal ?? opts.taskScope,
30
34
  ...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
31
35
  }, env);
36
+ const readDeny = compileReadDeny(opts.readDenyPatterns ?? [], "HandsToolkitOptions.readDenyPatterns");
37
+ const readFace = resolveReadFace({
38
+ depsReadFace: opts.readFace,
39
+ readOnlyMount: readOnly,
40
+ orgGoverned: false,
41
+ fullShellReachable: includeShell && !readOnly,
42
+ });
32
43
  const tools = [
33
- createReadFileTool(env, readFileState, rootCanonical, readOnly ? undefined : cwdRef, readFaceRoots, opts.readImageDownsampler, opts.pdfModelCapabilities, bgOutputReadExemption, opts.readCyberReminder),
44
+ createReadFileTool(env, readFileState, rootCanonical, readOnly ? undefined : cwdRef, readFaceRoots, opts.readImageDownsampler, opts.pdfModelCapabilities, bgOutputReadExemption, opts.readCyberReminder, readDeny, readFace),
34
45
  ];
35
46
  if (!readOnly) {
36
47
  tools.push(createEditFileTool(env, readFileState, rootCanonical, cwdRef, additionalRoots, opts.beforeWrite), createWriteFileTool(env, readFileState, rootCanonical, cwdRef, additionalRoots, opts.beforeWrite), createNotebookEditTool(env, readFileState, rootCanonical, cwdRef, additionalRoots, opts.beforeWrite));
37
48
  }
38
- tools.push(createGrepTool(env, rootCanonical, readFaceRoots), createGlobTool(env, rootCanonical, readFaceRoots), createRepoMapTool(env, rootCanonical, readFaceRoots));
49
+ tools.push(createGrepTool(env, rootCanonical, readFaceRoots, readDeny, readFace), createGlobTool(env, rootCanonical, readFaceRoots, readDeny, readFace), createRepoMapTool(env, rootCanonical, readFaceRoots, readDeny, readFace));
39
50
  if (includeShell) {
40
51
  tools.push(readOnly
41
52
  ? createBashReadonlyTool(env, rootCanonical, new Set(bashReadonlyAllow ?? BASH_READONLY_DEFAULT_ALLOW), {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * One deny entry: a `/`-separated run of path SEGMENTS. `*` matches any run of non-separator
3
+ * characters within one segment and is the ONLY metacharacter (everything else is literal). The run
4
+ * may match ANYWHERE in the judged path (`.ssh` covers `~/.ssh/…` and any other `.ssh` directory).
5
+ * String shorthand ≡ `{ pattern }`. Matching is ASCII-case-insensitive by default (a case-insensitive
6
+ * filesystem serves `.SSH` and `.ssh` as the same directory); `caseSensitive: true` opts a single
7
+ * entry out. Unicode normalization (NFC/NFD) and volume-level case semantics are NOT modeled (v1
8
+ * residual, shared with the write guard; the canonical view — realpath returns the on-disk spelling —
9
+ * covers half of it).
10
+ */
11
+ export type ReadDenyEntry = string | {
12
+ pattern: string;
13
+ caseSensitive?: boolean;
14
+ };
15
+ /** The normalized (validated, defaulted) form of one entry. */
16
+ export interface NormalizedReadDenyEntry {
17
+ pattern: string;
18
+ caseSensitive: boolean;
19
+ }
20
+ /** A deny verdict: which entry's pattern matched. */
21
+ export interface ReadDenyHit {
22
+ pattern: string;
23
+ }
24
+ /** One compiled ripgrep glob flag for the traversal legs (`--iglob` = case-insensitive entry). */
25
+ export interface ReadDenyRgGlob {
26
+ flag: "--glob" | "--iglob";
27
+ glob: string;
28
+ }
29
+ /**
30
+ * The compiled deny judge — ONE compilation, consumed by every judgment point (target judgment in
31
+ * resolveKey, the JS walker, the rg exclusion/probe legs, the classify operand probe, the ENOENT
32
+ * sibling filter, the attachment reader), so the legs cannot drift apart.
33
+ */
34
+ export interface ReadDenyMatcher {
35
+ /** Normalized entries (built-ins first, then additions; exact duplicates folded). The disclosure /
36
+ * persistence face. */
37
+ readonly entries: readonly NormalizedReadDenyEntry[];
38
+ /** Judge ONE path view (segment-window semantics). Returns the first matching entry, or null. */
39
+ matchPath(path: string): ReadDenyHit | null;
40
+ /** §3.3 target judgment: canonical view + lexical view; EITHER hit decides. */
41
+ matchTarget(canonicalKey: string, lexicalView?: string): ReadDenyHit | null;
42
+ /** Traversal exclusions for the rg legs: `!`-polarity glob pairs — `!**\/<p>` plus `!**\/<p>/…`
43
+ * (the entry itself and its descendants). */
44
+ readonly rgExclusionGlobs: readonly ReadDenyRgGlob[];
45
+ /** Positive-polarity twins of the exclusions, for the bounded existence probe (first hit = entries
46
+ * matching the deny list exist under the scope and were excluded). */
47
+ readonly rgProbeGlobs: readonly ReadDenyRgGlob[];
48
+ }
49
+ /**
50
+ * READ_FACE_DEFAULT_DENY_ENTRIES — the built-in table (D-4 ruling: ONE tier, credential-class path
51
+ * families; the design's `outside_workspace` tier was ruled OUT, and the dotenv family is deliberately
52
+ * NOT here — workspace `.env` files are working material for the tasks this engine runs).
53
+ *
54
+ * Each row states its tradeoff. Rows are matched as segment runs anywhere in the path, so a repo
55
+ * fixture spelled `fixtures/.ssh/id_rsa` is refused too — deliberate: the refusal names the pattern,
56
+ * and a false refusal on a fixture is the cheap direction (deny errs strict).
57
+ *
58
+ * NOT listed, deliberately: `.env`/`.env.*` (ruled out — workspace material; the WRITE guard still
59
+ * covers them); `*.pem`/`*.key`/`*.p12`/`*.pfx` (repo certificates/test keys are routinely READ —
60
+ * refusing them breaks ordinary work, the write guard covers the mutation direction); `.git/hooks`/
61
+ * `.git/config` (reads are harmless; writes are the escalation and stay guarded); `.claude*`/
62
+ * `.mcp.json` (agent config is routinely read for debugging); the engine's own data root
63
+ * (transcript integrity policy owns it with an ask on the write face).
64
+ */
65
+ export declare const READ_FACE_DEFAULT_DENY_ENTRIES: readonly ReadDenyEntry[];
66
+ /** A compiled segment-run pattern (shared engine — the write guard delegates here too). */
67
+ export interface CompiledSegmentPattern {
68
+ raw: string;
69
+ segments: RegExp[];
70
+ /** Fold BOTH sides through {@link asciiLower} before testing (compile lowered the pattern side). */
71
+ foldAscii: boolean;
72
+ }
73
+ /**
74
+ * Compile one segment-glob pattern (shared engine). `fold`:
75
+ * · "none" — byte-exact segments;
76
+ * · "ascii" — ASCII-case-insensitive (deny-set contract);
77
+ * · "unicode" — RegExp `i` flag (the write guard's historical host-keyed behavior — kept for it
78
+ * byte-identically; NOT used by the deny set).
79
+ * Returns null when the pattern reduces to zero segments — the CALLER owns its loud path (#123: the
80
+ * two consumers refuse with their own surface-specific texts).
81
+ */
82
+ export declare function compileSegmentPattern(raw: string, fold: "none" | "ascii" | "unicode"): CompiledSegmentPattern | null;
83
+ /**
84
+ * Does the path contain a compiled pattern as a CONTIGUOUS run of full path segments? (Shared engine:
85
+ * `.git/hooks` matches `/repo/.git/hooks/pre-commit`; `.ssh` matches any `.ssh` segment.) Splits on
86
+ * BOTH separator families — win32 canonical keys are backslash-form. Returns the matched raw pattern
87
+ * or null.
88
+ */
89
+ export declare function matchSegmentPatterns(path: string, compiled: readonly CompiledSegmentPattern[]): string | null;
90
+ /**
91
+ * codex r3 — the PURE validation/canonicality predicate over a PERSISTED deny entry (checkpoint v9
92
+ * face section), for the resume pre-CAS ladder: it must refuse BEFORE the approval CAS everything
93
+ * {@link compileReadDeny} would throw on AFTER it (a post-CAS compile throw consumes the human's
94
+ * approval and strands the row), plus non-canonical spellings (normalizeEntry canonicalizes at every
95
+ * mint, so a persisted non-canonical pattern was not minted by any release = damaged). Returns the
96
+ * refusal reason, or null when the entry is valid.
97
+ */
98
+ export declare function persistedReadDenyEntryProblem(e: unknown): string | null;
99
+ /**
100
+ * Compile the read-face deny judge: built-in table ∪ additions (add-only at every layer — D-4
101
+ * zero-shrink; `[]` additions ≡ absent, deliberately a no-op rather than a refusal: an array API
102
+ * cannot observe a "replace" intent, and union-with-empty is the identity, not a silent fallback).
103
+ * Bad entry shapes / zero-segment patterns throw loudly, naming the layer (#123).
104
+ */
105
+ export declare function compileReadDeny(additions?: readonly ReadDenyEntry[], layer?: string): ReadDenyMatcher;
@@ -0,0 +1,151 @@
1
+ export const READ_FACE_DEFAULT_DENY_ENTRIES = [
2
+ ".ssh",
3
+ "id_rsa*",
4
+ "id_ed25519*",
5
+ "id_ecdsa*",
6
+ ".gnupg",
7
+ ".aws",
8
+ ".config/gcloud",
9
+ ".azure",
10
+ ".kube",
11
+ ".netrc",
12
+ "_netrc",
13
+ ".git-credentials",
14
+ ".docker/config.json",
15
+ ".config/gh",
16
+ ".npmrc",
17
+ ".pypirc",
18
+ ".local/share/keyrings",
19
+ "Library/Keychains",
20
+ ".bash_history",
21
+ ".zsh_history",
22
+ "Library/Application Support/Google/Chrome",
23
+ "Library/Application Support/Firefox",
24
+ "Library/Safari",
25
+ ".config/google-chrome",
26
+ ".config/chromium",
27
+ ".mozilla/firefox",
28
+ "AppData/Local/Google/Chrome/User Data",
29
+ "AppData/Local/Microsoft/Edge/User Data",
30
+ "AppData/Roaming/Mozilla/Firefox",
31
+ ".bitcoin",
32
+ ".ethereum",
33
+ ".electrum",
34
+ "Library/Application Support/Exodus",
35
+ "Library/Application Support/Ledger Live",
36
+ "wallet.dat",
37
+ ];
38
+ function asciiLower(s) {
39
+ let out = "";
40
+ for (let i = 0; i < s.length; i++) {
41
+ const c = s.charCodeAt(i);
42
+ out += c >= 0x41 && c <= 0x5a ? String.fromCharCode(c + 32) : s[i];
43
+ }
44
+ return out;
45
+ }
46
+ export function compileSegmentPattern(raw, fold) {
47
+ const source = fold === "ascii" ? asciiLower(raw) : raw;
48
+ const parts = source.split("/").filter(Boolean);
49
+ if (parts.length === 0)
50
+ return null;
51
+ const flags = fold === "unicode" ? "i" : "";
52
+ const segments = parts.map((segment) => {
53
+ const escaped = segment.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, "[^/]*");
54
+ return new RegExp(`^${escaped}$`, flags);
55
+ });
56
+ return { raw, segments, foldAscii: fold === "ascii" };
57
+ }
58
+ export function matchSegmentPatterns(path, compiled) {
59
+ const rawSegs = path.split(/[\\/]/).filter(Boolean);
60
+ let loweredSegs;
61
+ for (const pat of compiled) {
62
+ const segs = pat.foldAscii ? (loweredSegs ??= rawSegs.map(asciiLower)) : rawSegs;
63
+ const n = pat.segments.length;
64
+ for (let i = 0; i + n <= segs.length; i++) {
65
+ if (pat.segments.every((re, j) => re.test(segs[i + j] ?? "")))
66
+ return pat.raw;
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+ function rgGlobEscape(pattern) {
72
+ return pattern.replace(/[?[\]{}]/g, (c) => `[${c}]`);
73
+ }
74
+ function normalizeEntry(entry, layer) {
75
+ const shape = typeof entry === "string" ? { pattern: entry, caseSensitive: false } : entry;
76
+ if (typeof shape !== "object" || shape === null || typeof shape.pattern !== "string") {
77
+ throw new Error(`readDenyPatterns (${layer}): entry ${JSON.stringify(entry)} is not a pattern string or { pattern, caseSensitive? } object.`);
78
+ }
79
+ if (shape.caseSensitive !== undefined && typeof shape.caseSensitive !== "boolean") {
80
+ throw new Error(`readDenyPatterns (${layer}): entry ${JSON.stringify(entry)} has a non-boolean caseSensitive.`);
81
+ }
82
+ if (shape.pattern.includes("\\")) {
83
+ throw new Error(`readDenyPatterns (${layer}): pattern ${JSON.stringify(shape.pattern)} contains a backslash — patterns are "/"-separated segment runs (both path families are matched); spell the segments with "/".`);
84
+ }
85
+ const segments = shape.pattern.split("/").filter(Boolean);
86
+ if (segments.length === 0) {
87
+ throw new Error(`readDenyPatterns (${layer}): pattern ${JSON.stringify(shape.pattern)} contains no path segments and would deny nothing. ` +
88
+ `Patterns are "/"-separated runs of path SEGMENTS (e.g. ".ssh", ".config/gcloud", "id_rsa*"); remove the entry or spell the segments.`);
89
+ }
90
+ return { pattern: segments.join("/"), caseSensitive: shape.caseSensitive === true };
91
+ }
92
+ export function persistedReadDenyEntryProblem(e) {
93
+ if (typeof e !== "object" || e === null)
94
+ return "entry is not an object";
95
+ const pattern = e.pattern;
96
+ const caseSensitive = e.caseSensitive;
97
+ if (typeof pattern !== "string")
98
+ return "pattern is not a string";
99
+ if (typeof caseSensitive !== "boolean")
100
+ return "caseSensitive is not a boolean";
101
+ if (pattern.includes("\\"))
102
+ return "pattern contains a backslash";
103
+ const segments = pattern.split("/").filter(Boolean);
104
+ if (segments.length === 0)
105
+ return "pattern has no path segments";
106
+ if (segments.join("/") !== pattern)
107
+ return "pattern is not in canonical a/b form";
108
+ return null;
109
+ }
110
+ export function compileReadDeny(additions = [], layer = "additions") {
111
+ const normalized = [];
112
+ const seen = new Set();
113
+ const push = (e) => {
114
+ const key = `${e.caseSensitive ? "s" : "i"}:${e.pattern}`;
115
+ if (seen.has(key))
116
+ return;
117
+ seen.add(key);
118
+ normalized.push(e);
119
+ };
120
+ for (const entry of READ_FACE_DEFAULT_DENY_ENTRIES)
121
+ push(normalizeEntry(entry, "built-in"));
122
+ for (const entry of additions)
123
+ push(normalizeEntry(entry, layer));
124
+ const compiled = normalized.map((e) => {
125
+ const c = compileSegmentPattern(e.pattern, e.caseSensitive ? "none" : "ascii");
126
+ if (c === null)
127
+ throw new Error(`readDenyPatterns: pattern ${JSON.stringify(e.pattern)} compiled to zero segments.`);
128
+ return c;
129
+ });
130
+ const rgExclusionGlobs = [];
131
+ const rgProbeGlobs = [];
132
+ for (const e of normalized) {
133
+ const flag = e.caseSensitive ? "--glob" : "--iglob";
134
+ const g = rgGlobEscape(e.pattern);
135
+ rgExclusionGlobs.push({ flag, glob: `!**/${g}` }, { flag, glob: `!**/${g}/**` });
136
+ rgProbeGlobs.push({ flag, glob: `**/${g}` }, { flag, glob: `**/${g}/**` });
137
+ }
138
+ const matchPath = (path) => {
139
+ const hit = matchSegmentPatterns(path, compiled);
140
+ return hit === null ? null : { pattern: hit };
141
+ };
142
+ return {
143
+ entries: normalized,
144
+ matchPath,
145
+ matchTarget(canonicalKey, lexicalView) {
146
+ return matchPath(canonicalKey) ?? (lexicalView !== undefined ? matchPath(lexicalView) : null);
147
+ },
148
+ rgExclusionGlobs,
149
+ rgProbeGlobs,
150
+ };
151
+ }
@@ -0,0 +1,43 @@
1
+ /** The read-face containment state. `"roots"` = the historical containment judgment, unchanged.
2
+ * `"open"` = the containment step is skipped (canonicalization, deny set, UNC out-of-set refusal
3
+ * and the type gates all still run). */
4
+ export type ReadFace = "open" | "roots";
5
+ /** Inputs to {@link resolveReadFace} — all structural/declaration facts, never permission modes
6
+ * (a deliberate axis separation: this shape carries facts, not verdicts). */
7
+ export interface ReadFaceInputs {
8
+ /** TaskSpec.readFace (raw — validated here). The TASK layer: may only tighten under governance. */
9
+ specReadFace?: unknown;
10
+ /** RunnerDeps.readFace / HandsToolkitOptions.readFace (raw) — the DEPLOYMENT's own declaration. */
11
+ depsReadFace?: unknown;
12
+ /** The verifier read-only mount (`handsReadOnly` / toolkit `readOnly`): its containment is a
13
+ * LOAD-BEARING wall (bash_readonly is genuinely confined by it) — never openable. */
14
+ readOnlyMount: boolean;
15
+ /** Org governance declared (RunnerDeps.permissionRuleOrg in place): default roots; the task layer
16
+ * may not open (D-2 — tighten-only under governance; the deps layer, being the deployment's own
17
+ * declaration, still may). */
18
+ orgGoverned: boolean;
19
+ /** D-6: is the FULL shell (`core.bash@1`) structurally reachable on this mount? Computed BEFORE
20
+ * tool-band assembly from spec-time facts (env kind / handsReadOnly / Bash exclusion — recon §3:
21
+ * every exclusion source is spec-time-frozen, so this is always computable). When false, the
22
+ * roots fence is the ONLY read boundary and the default stays roots even under a deps-layer
23
+ * "open"… no — see the resolution order: an EXPLICIT open still wins (row 4 is a default, not a
24
+ * clamp); what it changes is that NOTHING implicit opens a bash-less mount. */
25
+ fullShellReachable: boolean;
26
+ }
27
+ /**
28
+ * The ONE resolution order (§2.2), first hit wins. prepare-task AND createHandsToolkit both call
29
+ * this — a library-direct mount gets identical validation and identical defaults.
30
+ *
31
+ * 1. read-only (verifier) mount → ROOTS; an explicit "open" on the TASK seat is a genuine
32
+ * per-call CONTRADICTION and refuses loudly (#123 — never silently pick a side). An "open"
33
+ * on the DEPLOYMENT seat alone is a deployment-wide default, not a per-task assertion — it
34
+ * silently CLAMPS to roots (stricter-wins), same seat distinction row 2 already draws for
35
+ * org governance below.
36
+ * 2. org-governed: TaskSpec "open" refuses (task layer only tightens, D-2); deps "open" wins;
37
+ * otherwise ROOTS.
38
+ * 3. explicit seat: spec ?? deps.
39
+ * 4. bash-less mount (D-6): ROOTS (the fence is the only read boundary there — "the fence guards
40
+ * nothing bash reaches anyway" does not hold, so no implicit default may open it).
41
+ * 5. default: ROOTS (D-1b — the engine never opens implicitly).
42
+ */
43
+ export declare function resolveReadFace(i: ReadFaceInputs): ReadFace;
@@ -0,0 +1,38 @@
1
+ function assertReadFaceValue(v, seat) {
2
+ if (v === undefined)
3
+ return undefined;
4
+ if (v === "open" || v === "roots")
5
+ return v;
6
+ const e = new Error(`${seat}: invalid readFace value ${JSON.stringify(v)} — expected "open" or "roots" (bad values refuse loudly; nothing falls back to a default).`);
7
+ e.code = "config.read_face_invalid";
8
+ throw e;
9
+ }
10
+ export function resolveReadFace(i) {
11
+ const spec = assertReadFaceValue(i.specReadFace, "TaskSpec.readFace");
12
+ const deps = assertReadFaceValue(i.depsReadFace, "readFace (deployment seat)");
13
+ if (i.readOnlyMount) {
14
+ if (spec === "open") {
15
+ const e = new Error(`readFace: "open" contradicts the read-only (verifier) mount — that mount's containment is load-bearing (its read-only shell face is confined by it) and is never openable. Drop the readFace declaration or the readOnly mount.`);
16
+ e.code = "config.read_face_readonly_conflict";
17
+ throw e;
18
+ }
19
+ return "roots";
20
+ }
21
+ if (i.orgGoverned) {
22
+ if (spec === "open") {
23
+ const e = new Error(`TaskSpec.readFace: "open" is refused under organization governance — the task layer may only tighten (set RunnerDeps.readFace: "open" if the DEPLOYMENT declares the open read face).`);
24
+ e.code = "config.read_face_org_task_escalation";
25
+ throw e;
26
+ }
27
+ if (spec === "roots")
28
+ return "roots";
29
+ return deps ?? "roots";
30
+ }
31
+ if (spec !== undefined)
32
+ return spec;
33
+ if (deps !== undefined)
34
+ return deps;
35
+ if (!i.fullShellReachable)
36
+ return "roots";
37
+ return "roots";
38
+ }
@@ -1,5 +1,7 @@
1
1
  import type { AgentTool } from "../../internal/harness-types.js";
2
2
  import type { ExecutionEnv } from "../../internal/harness-types.js";
3
+ import type { ReadDenyMatcher } from "./read-deny.js";
4
+ import type { ReadFace } from "./read-face.js";
3
5
  /** Extract top-level symbol names (deduped, in first-seen order) from source text. A lightweight
4
6
  * comment/docstring state machine (council #4) skips column-0 declaration-like lines that are really
5
7
  * inside a `/* … *​/` block comment or a Python `"""`/`'''` docstring — otherwise e.g. a module
@@ -11,4 +13,4 @@ import type { ExecutionEnv } from "../../internal/harness-types.js";
11
13
  * auto-promote tripwire (src/core/auto-promote.ts) consumes it in ESCALATE-ONLY mode — a clean scan
12
14
  * contributes nothing; it can only ever raise an escalation, never clear one. */
13
15
  export declare function extractSymbols(text: string): string[];
14
- export declare function createRepoMapTool(env: ExecutionEnv, rootCanonical: string, additionalRoots?: readonly string[]): AgentTool;
16
+ export declare function createRepoMapTool(env: ExecutionEnv, rootCanonical: string, additionalRoots?: readonly string[], readDeny?: ReadDenyMatcher, readFace?: ReadFace): AgentTool;
@@ -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}".`;
@@ -3,7 +3,7 @@ import type { ExecutionEnv } from "../../internal/harness-types.js";
3
3
  * batch-B (CC 2.1.187 parity): Read/Edit/Write take the file path as `file_path`. Read the target the same way
4
4
  * EVERYWHERE the path is consumed from a tool-call's args — not just the tool body but ALSO every gate that
5
5
  * extracts the write target to confine it (sensitive-path, skill allowPaths, session allowDirs). A gate that only
6
- * read `args.path` would see `undefined` for a `file_path` call and either fail-OPEN (bypass the guard — a real
6
+ * read `args.path` would see `undefined` for a `file_path` call and either fail-OPEN (circumvent the guard — a real
7
7
  * hole) or fail-closed (deny a legitimate write). Single source.
8
8
  *
9
9
  * The `path` arm is deliberately BROADER than what Read/Edit/Write accept (ruled 2026-08-03): their schema requires
@@ -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,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): 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
+ } | 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. 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).
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) 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.)";
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").