@sema-agent/core 7.9.0 → 7.9.1

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 (51) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/core/ask-origin.d.ts +7 -3
  3. package/dist/core/checkpoint-store.d.ts +2 -7
  4. package/dist/core/effective-path-target.d.ts +43 -0
  5. package/dist/core/effective-path-target.js +56 -0
  6. package/dist/core/engine-notice.d.ts +8 -0
  7. package/dist/core/fs-write-gate-policy.js +2 -1
  8. package/dist/core/gate-lanes.d.ts +1 -0
  9. package/dist/core/gate-lanes.js +11 -5
  10. package/dist/core/governance-codes.d.ts +1 -1
  11. package/dist/core/governance-codes.js +2 -0
  12. package/dist/core/hooks.d.ts +10 -1
  13. package/dist/core/mcp-injection-drop.d.ts +74 -0
  14. package/dist/core/mcp-injection-drop.js +27 -0
  15. package/dist/core/permission-rule-model.d.ts +91 -75
  16. package/dist/core/permission-rule-model.js +90 -111
  17. package/dist/core/permission-rule-org.d.ts +12 -6
  18. package/dist/core/permission-rule-org.js +9 -3
  19. package/dist/core/permission-rules.d.ts +3 -2
  20. package/dist/core/permission-rules.js +38 -15
  21. package/dist/core/persisted-rule-arms.d.ts +7 -2
  22. package/dist/core/persisted-rule-arms.js +3 -1
  23. package/dist/core/runner/active-skill-scope.js +2 -1
  24. package/dist/core/runner/permission-rule-lanes.d.ts +12 -4
  25. package/dist/core/runner/permission-rule-lanes.js +13 -15
  26. package/dist/core/runner/session-rule-policy.js +2 -1
  27. package/dist/core/runner/tool-face-overlay.js +22 -3
  28. package/dist/core/sensitive-path-policy.js +5 -3
  29. package/dist/core/shell-lexer.d.ts +47 -0
  30. package/dist/core/shell-lexer.js +478 -0
  31. package/dist/core/shell-scan.d.ts +60 -0
  32. package/dist/core/shell-scan.js +183 -0
  33. package/dist/core/shell-wrapper-table.d.ts +297 -0
  34. package/dist/core/shell-wrapper-table.js +58 -0
  35. package/dist/core/tool-catalog-entries.js +6 -6
  36. package/dist/core/tool-face.d.ts +80 -4
  37. package/dist/core/tool-face.js +10 -0
  38. package/dist/core/tool-policy.d.ts +1 -6
  39. package/dist/core/tool-registry.d.ts +8 -11
  40. package/dist/core/tool-registry.js +5 -2
  41. package/dist/core/tool-roster.d.ts +11 -2
  42. package/dist/core/tool-roster.js +21 -3
  43. package/dist/index.d.ts +6 -3
  44. package/dist/index.js +5 -2
  45. package/dist/tools/fs/fs-search-tools.d.ts +3 -2
  46. package/dist/tools/fs/fs-search-tools.js +17 -9
  47. package/dist/tools/fs/index.js +1 -1
  48. package/dist/tools/fs/search.d.ts +0 -8
  49. package/dist/tools/fs/search.js +0 -23
  50. package/package.json +1 -1
  51. package/test/export-surface.snapshot.json +57 -1
@@ -1,8 +1,9 @@
1
1
  import { MCP_NAMESPACE, protocolOf } from "./protocol-table.js";
2
2
  import { catalogRuleFaceOf, pathTargetOf } from "./tool-registry.js";
3
3
  import { indexOfUnescaped, lastIndexOfUnescaped, parsePermissionRule } from "./permission-rule-syntax.js";
4
- import { isUsablePathBase, lexicalNormalAbsolutePathOf, parseRuleText, pathRuleReaches, ruleReachesProgramRun } from "./permission-rule-model.js";
5
- import { pathTargetValue, protectivePathTargetOf } from "./tool-registry.js";
4
+ import { isUsablePathBase, parseRuleText, pathRuleReaches, programRunReachOf } from "./permission-rule-model.js";
5
+ import { effectivePathTargetOf } from "./effective-path-target.js";
6
+ import { protectivePathTargetOf } from "./tool-registry.js";
6
7
  export { parsePermissionRule };
7
8
  export const BASH_GENERIC_PARAMS = new Set(catalogRuleFaceOf("Bash")?.params ?? []);
8
9
  const DEFAULT_CAPS = {
@@ -317,18 +318,34 @@ export function createPermissionRulePolicy(rules, opts) {
317
318
  const coveringHit = (lane, toolName) => lane.length === 0 ? undefined : lane.find((r) => namespacedRuleNameCovers(r.ruleName, toolName));
318
319
  const hasCovering = namespacedCovering.deny.length > 0 || namespacedCovering.ask.length > 0 || namespacedCovering.allow.length > 0;
319
320
  const commandParam = catalogRuleFaceOf("Bash")?.primaryParams[0] ?? "command";
320
- const cwdBase = (pathBases.cwd ?? pathBases.root ?? "").replace(/\/+$/, "");
321
321
  const contentHit = (lane, req) => {
322
322
  if (lane.length === 0)
323
323
  return undefined;
324
- const pt = protectivePathTargetOf(req);
325
- const spelled = pt !== undefined ? pathTargetValue(req.args, pt) : undefined;
326
- const target = spelled === undefined ? undefined : lexicalNormalAbsolutePathOf(spelled.startsWith("/") ? spelled : `${cwdBase}/${spelled}`);
324
+ const target = effectivePathTargetOf(req, protectivePathTargetOf(req), pathBases);
327
325
  const command = isPlainObjectArgs(req.args) ? req.args[commandParam] : undefined;
328
- return lane.find((r) => r.parsed.match === "subpath" || r.parsed.match === "path"
329
- ? target !== undefined && pathRuleReaches(r.parsed, target, pathBases)
330
- : typeof command === "string" && ruleReachesProgramRun(r.parsed, command));
326
+ let unreadable;
327
+ for (const r of lane) {
328
+ if (r.parsed.match === "subpath" || r.parsed.match === "path") {
329
+ if (target !== undefined && pathRuleReaches(r.parsed, target, pathBases))
330
+ return { rule: r };
331
+ continue;
332
+ }
333
+ if (typeof command !== "string")
334
+ continue;
335
+ const outcome = programRunReachOf(r.parsed, command);
336
+ if (outcome.reach === "reached")
337
+ return { rule: r };
338
+ if (outcome.reach === "unreadable")
339
+ unreadable ??= { rule: r, unreadable: outcome.reason };
340
+ }
341
+ return unreadable;
331
342
  };
343
+ const unreadableAsk = (hit, req) => ({
344
+ action: "ask",
345
+ message: `tool "${req.toolName}" needs approval: the command could not be read against the permission rule ${hit.rule.ruleText} (${hit.unreadable}) — a person must decide`,
346
+ matchedAskRule: hit.rule.ruleText,
347
+ requiresRealApproval: true,
348
+ });
332
349
  return {
333
350
  nameSets: [nameSets],
334
351
  check(req) {
@@ -342,15 +359,19 @@ export function createPermissionRulePolicy(rules, opts) {
342
359
  if (coveringDeny)
343
360
  return { action: "deny", message: ruleMessage("denied", coveringDeny.ruleText, coveringDeny.source) };
344
361
  }
362
+ const contentDeny = entry !== undefined ? contentHit(entry.content.deny, req) : undefined;
363
+ if (contentDeny !== undefined && contentDeny.unreadable === undefined) {
364
+ return { action: "deny", message: ruleMessage("denied", contentDeny.rule.ruleText, contentDeny.rule.source) };
365
+ }
345
366
  if (entry) {
346
- const contentDeny = contentHit(entry.content.deny, req);
347
- if (contentDeny) {
348
- return { action: "deny", message: ruleMessage("denied", contentDeny.ruleText, contentDeny.source) };
349
- }
350
367
  const paramDeny = matchParamRules(entry.param.deny, req.args, caps.maxScalarValueChars);
351
368
  if (paramDeny) {
352
369
  return { action: "deny", message: ruleMessage("denied", paramDeny.ruleText, paramDeny.source) };
353
370
  }
371
+ }
372
+ if (contentDeny?.unreadable !== undefined)
373
+ return unreadableAsk({ rule: contentDeny.rule, unreadable: contentDeny.unreadable }, req);
374
+ if (entry) {
354
375
  if (entry.bare.ask) {
355
376
  return { action: "ask", message: ruleMessage("flagged", entry.bare.ask.ruleText, entry.bare.ask.source), matchedAskRule: entry.bare.ask.ruleText };
356
377
  }
@@ -362,8 +383,10 @@ export function createPermissionRulePolicy(rules, opts) {
362
383
  }
363
384
  if (entry) {
364
385
  const contentAsk = contentHit(entry.content.ask, req);
365
- if (contentAsk) {
366
- return { action: "ask", message: ruleMessage("flagged", contentAsk.ruleText, contentAsk.source), matchedAskRule: contentAsk.ruleText };
386
+ if (contentAsk !== undefined) {
387
+ if (contentAsk.unreadable !== undefined)
388
+ return unreadableAsk({ rule: contentAsk.rule, unreadable: contentAsk.unreadable }, req);
389
+ return { action: "ask", message: ruleMessage("flagged", contentAsk.rule.ruleText, contentAsk.rule.source), matchedAskRule: contentAsk.rule.ruleText };
367
390
  }
368
391
  const paramAsk = matchParamRules(entry.param.ask, req.args, caps.maxScalarValueChars);
369
392
  if (paramAsk) {
@@ -18,6 +18,9 @@ import type { PersistedRuleHit, PersistedRuleHitRule } from "./hooks.js";
18
18
  export interface PersistedRuleRead {
19
19
  readonly hit?: PersistedRuleHit;
20
20
  readonly unreadable?: true;
21
+ /** The lexer's reason when the CALL could not be read against the person's deny/ask rows (absent for
22
+ * a store read failure) — the message says which it was; the decision is the same. */
23
+ readonly reason?: string;
21
24
  }
22
25
  /** Display form of a rule set (design/375 §5.1): every rule text is a DISPLAY value on a human trust
23
26
  * boundary, and the set must stay readable however large a lane makes it — each member rides
@@ -42,8 +45,10 @@ export declare function disclosedRuleSet(rules: readonly PersistedRuleHitRule[])
42
45
  * ask, the marker is stamped if absent and the message names the rule — the standing question
43
46
  * outranks a remembered yes across lanes, exactly as the DSL's own order says within one; a deny
44
47
  * stays a deny.
45
- * · UNREADABLE — the wired store could not be read (a read failure, a timeout): the person's deny/ask
46
- * rows cannot be enforced, so the call fails CLOSED the way an unreadable org snapshot does — an
48
+ * · UNREADABLE — the wired store could not be read (a read failure, a timeout), or the CALL could not be
49
+ * read against the person's standing deny/ask rows (`reason` carries the lexer's word: an expansion
50
+ * where a rule reads a word, an unterminated quote, a syntax error): the person's deny/ask rows
51
+ * cannot be enforced for this call, so it fails CLOSED the way an unreadable org snapshot does — an
47
52
  * allow tightens to an ask carrying `requiresRealApproval` (no automatic lane clears it; the gate
48
53
  * derives the origin `rule_store_unavailable` from its own record of the read), an ask acquires the
49
54
  * bit, a deny stays a deny. Reading an unreadable store as "no rule" would erase every deny the
@@ -8,7 +8,9 @@ export function applyPersistedTightening(decision, read) {
8
8
  if (read?.unreadable === true) {
9
9
  if (decision.action === "deny")
10
10
  return { decision };
11
- const message = "the persisted permission-rule store could not be read for this call — the person's own deny/ask rules cannot be enforced, so this call needs a real approval until the store is readable";
11
+ const message = read.reason !== undefined
12
+ ? `this call could not be read against the person's standing deny/ask rules (${read.reason}) — whether one of them forbids it is unknown, so it needs a real approval`
13
+ : "the persisted permission-rule store could not be read for this call — the person's own deny/ask rules cannot be enforced, so this call needs a real approval until the store is readable";
12
14
  return {
13
15
  decision: decision.action === "allow"
14
16
  ? { action: "ask", message, decisionReason: "persisted_rule", requiresRealApproval: true, ...(decision.updatedInput !== undefined ? { updatedInput: decision.updatedInput } : {}) }
@@ -1,5 +1,6 @@
1
1
  import { canonicalizeTarget } from "../../tools/fs/safety.js";
2
2
  import { pathTargetValue } from "../tool-registry.js";
3
+ import { pathTargetBaseOf } from "../effective-path-target.js";
3
4
  import { PATH_WRITE_TOOLS, isWithin } from "./session-rule-policy.js";
4
5
  import { parseSkillToolEntry, skillSpecifierRejection } from "../skill-tool-specifier.js";
5
6
  export class ActiveSkillScope {
@@ -99,7 +100,7 @@ export function createActiveSkillScopePolicy(opts) {
99
100
  decisionReason: "safety",
100
101
  };
101
102
  }
102
- const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
103
+ const canon = await canonicalizeTarget(env, path, signal, pathTargetBaseOf(declaredTarget, { root: rootPath, cwd: req.cwd }));
103
104
  if (!canon.ok) {
104
105
  return {
105
106
  action: "deny",
@@ -26,7 +26,7 @@
26
26
  * silence the ungated-write warning for deployments that wired no policy at all.
27
27
  */
28
28
  import type { AskRuleEvidence, ToolCallRequest } from "../tool-policy.js";
29
- import { persistedRuleMandateOf, type OrgGateVerdict, type PersistedRuleAnswer, type PersistedRuleHit } from "../hooks.js";
29
+ import { persistedRuleMandateOf, type OrgGateVerdict, type PersistedRuleAnswer, type PersistedRuleHit, type PersistedRuleUnreadable } from "../hooks.js";
30
30
  import type { PersistedRule, PersistedRuleVerdict, RuleOffer, SegmentCoverage } from "../permission-rule-model.js";
31
31
  import { type OrgRuleResolution } from "../permission-rule-org.js";
32
32
  import type { PermissionRuleStoreProvider } from "../permission-rule-provider.js";
@@ -79,11 +79,19 @@ export declare function orgRevisionEvidenceOf(resolution: OrgRuleResolution, onD
79
79
  * aliased: the evidence must not change under a store that reuses its row objects.
80
80
  */
81
81
  export declare function persistedRuleHitOf(verdict: PersistedRuleVerdict | undefined): PersistedRuleHit | undefined;
82
+ /** The COMMAND arm's projection of a verdict: a call the lexer could not read against a standing deny/ask
83
+ * row is the lane's UNREADABLE answer — the same fail-closed shape an unreadable store takes (a
84
+ * real-approval ask no automatic lane clears), with the lexer's reason in place of the store's — never a
85
+ * matched ask rule, which would let a blanket `onAsk: "allow"` clear a question nobody can answer. Every
86
+ * other verdict is {@link persistedRuleHitOf}'s hit. */
87
+ export declare function persistedRuleAnswerOf(verdict: PersistedRuleVerdict | undefined): PersistedRuleHit | PersistedRuleUnreadable | undefined;
82
88
  /**
83
89
  * design/382 §2.5-1, widened to every path-targeting tool — the persisted-rule lane's PATH arm: the call's
84
- * target path in the SAME lexical identity the rule family is defined over absolute as spelled, relative
85
- * resolved against the LIVE tracked working directory (the very base the file tools themselves resolve
86
- * against; task root when no tracker moved), then normalized lexically, zero IO.
90
+ * target path in the SAME lexical identity the rule family is defined over, resolved by the ONE reader every
91
+ * fence and every tool goes through (`effectivePathTargetOf`), so the lane judges the path the tool will
92
+ * really open. The tool's own declaration decides which base a relative spelling takes and what an ABSENT
93
+ * slot means — the lane no longer guesses `liveCwd ?? root` for every tool, which was wrong for the two
94
+ * search tools in three separate shapes (see effective-path-target.ts). Still zero IO.
87
95
  *
88
96
  * TWO readings of the call's path slot, one per direction (design/388 B6/B17): the tightening arms judge
89
97
  * the PROTECTIVE target (the object face's declaration, the catalog's for the name as the floor — reading
@@ -1,6 +1,7 @@
1
1
  import { persistedRuleMandateOf } from "../hooks.js";
2
- import { adjudicatePersistedPathRules, adjudicatePersistedRules, lexicalNormalAbsolutePathOf, ruleToolGrammarOf, segmentCoverageOf, suggestRulesForCommand } from "../permission-rule-model.js";
3
- import { declaredPathTargetOf, pathTargetValue, protectivePathTargetOf } from "../tool-registry.js";
2
+ import { adjudicatePersistedPathRules, adjudicatePersistedRules, ruleToolGrammarOf, segmentCoverageOf, suggestRulesForCommand } from "../permission-rule-model.js";
3
+ import { effectivePathTargetOf } from "../effective-path-target.js";
4
+ import { declaredPathTargetOf, protectivePathTargetOf } from "../tool-registry.js";
4
5
  import { orgRuleVerdictFor } from "../permission-rule-org.js";
5
6
  export const PERSISTED_RULE_TOOL = "Bash";
6
7
  const HANDOFF_BOUND = 256;
@@ -24,21 +25,18 @@ export function persistedRuleHitOf(verdict) {
24
25
  ? undefined
25
26
  : { behavior: verdict.behavior, rules: verdict.rules.map((r) => ({ rule: r.rule, dots: r.adds.map((a) => ({ actor: a.dot.actor, counter: a.dot.counter })) })) };
26
27
  }
28
+ export function persistedRuleAnswerOf(verdict) {
29
+ if (verdict?.unreadable !== undefined)
30
+ return { unreadable: true, reason: verdict.unreadable };
31
+ return persistedRuleHitOf(verdict);
32
+ }
27
33
  export function pathRuleLaneAnswer(table, req, ctx) {
28
- const base = (ctx.liveCwd ?? ctx.root ?? "").replace(/\/+$/, "");
29
- const resolve = (pt) => {
30
- if (pt === undefined)
31
- return undefined;
32
- const spelled = pathTargetValue(req.args, pt);
33
- if (spelled === undefined)
34
- return undefined;
35
- return lexicalNormalAbsolutePathOf(spelled.startsWith("/") ? spelled : `${base}/${spelled}`);
36
- };
37
- const tighten = resolve(protectivePathTargetOf(req));
38
- const allow = resolve(declaredPathTargetOf(req));
34
+ const bases = { ...(ctx.root !== undefined ? { root: ctx.root } : {}), ...(ctx.liveCwd !== undefined ? { cwd: ctx.liveCwd } : {}) };
35
+ const tighten = effectivePathTargetOf(req, protectivePathTargetOf(req), bases);
36
+ const allow = effectivePathTargetOf(req, declaredPathTargetOf(req), bases);
39
37
  if (tighten === undefined && allow === undefined)
40
38
  return undefined;
41
- return persistedRuleHitOf(adjudicatePersistedPathRules(table, { tool: req.toolName, cwd: ctx.root, sessionId: ctx.sessionId }, { ...(tighten !== undefined ? { tighten } : {}), ...(allow !== undefined ? { allow } : {}) }, { ...(ctx.root !== undefined ? { root: ctx.root } : {}), ...(ctx.liveCwd !== undefined ? { cwd: ctx.liveCwd } : {}) }));
39
+ return persistedRuleHitOf(adjudicatePersistedPathRules(table, { tool: req.toolName, cwd: ctx.root, sessionId: ctx.sessionId }, { ...(tighten !== undefined ? { tighten } : {}), ...(allow !== undefined ? { allow } : {}) }, bases));
42
40
  }
43
41
  export function createPermissionRuleLanes(cfg) {
44
42
  const provider = cfg.provider;
@@ -137,7 +135,7 @@ export function createPermissionRuleLanes(cfg) {
137
135
  const execCwd = liveCwd !== undefined ? { execCwd: liveCwd } : {};
138
136
  const verdict = adjudicatePersistedRules(table, { tool: req.toolName, command, cwd: cfg.root, sessionId: cfg.sessionId, ...execCwd });
139
137
  if (verdict !== undefined)
140
- return persistedRuleHitOf(verdict);
138
+ return persistedRuleAnswerOf(verdict);
141
139
  const coverage = segmentCoverageOf(command, { persisted: table }, { tool: req.toolName, cwd: cfg.root, sessionId: cfg.sessionId, ...execCwd });
142
140
  return coverage !== undefined ? { segmentCoverage: coverage } : undefined;
143
141
  },
@@ -2,6 +2,7 @@ import { canonicalizeTarget, writeTargetPath } from "../../tools/fs/safety.js";
2
2
  import { isWinFormPath } from "../../tools/fs/safety.js";
3
3
  import { createCoarseCommandNamePolicy, namespacedCoveringEntries, namespacedCoveringHit } from "../tool-policy.js";
4
4
  import { declaredPathTargetOf, isProtectedWrite, pathConfinableWriteToolNames, skillScopeWriteToolNames } from "../tool-registry.js";
5
+ import { pathTargetBaseOf } from "../effective-path-target.js";
5
6
  export const PATH_WRITE_TOOLS = skillScopeWriteToolNames();
6
7
  export const PATH_CONFINABLE_WRITE_TOOLS = pathConfinableWriteToolNames();
7
8
  export function isWithin(root, p) {
@@ -65,7 +66,7 @@ export function createSessionRulePolicy(rules, opts) {
65
66
  if (typeof path !== "string" || path.length === 0) {
66
67
  return deny(`write tool "${req.toolName}" denied: session rule confines writes to allowDirs but the call has no resolvable path`);
67
68
  }
68
- const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
69
+ const canon = await canonicalizeTarget(env, path, signal, pathTargetBaseOf(declared, { root: rootPath, cwd: req.cwd }));
69
70
  if (!canon.ok) {
70
71
  return deny(`write to "${path}" denied: its real target could not be resolved against the session-rule allowDirs`);
71
72
  }
@@ -1,6 +1,6 @@
1
1
  import { MCP_NAMESPACE } from "../protocol-table.js";
2
2
  import { mintNamespacePrefix } from "../protocol-naming.js";
3
- import { TOOL_APPROVAL_CARDS, TOOL_FAMILIES, RENDER_HINT_ACTIVITY_MAX_CHARS, RENDER_HINT_MAX_CHARS, RENDER_HINT_MAX_LIST, TOOL_KEY_MAX_CHARS } from "../tool-face.js";
3
+ import { TOOL_APPROVAL_CARDS, TOOL_FAMILIES, TOOL_PATH_ABSENCES, TOOL_PATH_ACCESSES, TOOL_PATH_BASES, RENDER_HINT_ACTIVITY_MAX_CHARS, RENDER_HINT_MAX_CHARS, RENDER_HINT_MAX_LIST, TOOL_KEY_MAX_CHARS, } from "../tool-face.js";
4
4
  import { inputKeysOf } from "../tool-roster.js";
5
5
  import { deliverEngineNotice } from "../engine-notice.js";
6
6
  export const APPROVAL_CARD_REQUIRED_KEYS = Object.freeze({
@@ -26,9 +26,28 @@ export function toolFaceProblem(face, schema) {
26
26
  if (face.pathTarget !== undefined) {
27
27
  if (typeof face.pathTarget.param !== "string" || !top.has(face.pathTarget.param))
28
28
  return `pathTarget.param ${JSON.stringify(face.pathTarget.param)} is not a top-level property of the tool's schema`;
29
- if (!["read", "create", "edit"].includes(face.pathTarget.access))
29
+ if (!TOOL_PATH_ACCESSES.includes(face.pathTarget.access))
30
30
  return `pathTarget.access ${JSON.stringify(face.pathTarget.access)} is outside the closed set`;
31
- const long = overlong("pathTarget.param", [face.pathTarget.param]) ?? overlong("pathTarget.aliases", face.pathTarget.aliases ?? []);
31
+ const base = face.pathTarget.base;
32
+ if (base !== undefined && !TOOL_PATH_BASES.includes(base))
33
+ return `pathTarget.base ${JSON.stringify(base)} is outside the closed set`;
34
+ if (base === "root" && face.pathTarget.access !== "read")
35
+ return `pathTarget.base "root" needs pathTarget.access "read" — two write guards (the session-transcript directory guard and the frozen-spec deny) resolve a write target against the call's working directory only, so a write face declaring a root base would be judged on two different files (#638)`;
36
+ const absent = face.pathTarget.absent;
37
+ if (absent !== undefined && !TOOL_PATH_ABSENCES.includes(absent))
38
+ return `pathTarget.absent ${JSON.stringify(absent)} is outside the closed set`;
39
+ if (absent === "base" && face.pathTarget.access !== "read")
40
+ return `pathTarget.absent "base" needs pathTarget.access "read" — it says the call is about the whole base directory when the slot is empty, which is a search SCOPE; a write with no path names no file, and the containment fences judge a write on its own target`;
41
+ const patternParam = face.pathTarget.patternParam;
42
+ if (patternParam !== undefined) {
43
+ if (typeof patternParam !== "string" || !top.has(patternParam))
44
+ return `pathTarget.patternParam ${JSON.stringify(patternParam)} is not a top-level property of the tool's schema`;
45
+ if (absent !== "base")
46
+ return `pathTarget.patternParam ${JSON.stringify(patternParam)} is declared but pathTarget.absent is ${JSON.stringify(absent ?? "none")} — the pattern base is only ever read for an absent slot, so this declaration would be inert`;
47
+ }
48
+ const long = overlong("pathTarget.param", [face.pathTarget.param]) ??
49
+ overlong("pathTarget.aliases", face.pathTarget.aliases ?? []) ??
50
+ overlong("pathTarget.patternParam", patternParam === undefined ? [] : [patternParam]);
32
51
  if (long !== undefined)
33
52
  return long;
34
53
  vocabulary.add(face.pathTarget.param);
@@ -2,7 +2,8 @@ import { homedir } from "node:os";
2
2
  import { isAbsolute, join, resolve } from "node:path";
3
3
  import { canonicalizeTarget, expandHomeTilde, isAbsolutePathForm, isWinFormPath, writeTargetPath } from "../tools/fs/safety.js";
4
4
  import { compileSegmentPattern, matchSegmentPatterns } from "../tools/fs/read-deny.js";
5
- import { isProtectedWrite } from "./tool-registry.js";
5
+ import { isProtectedWrite, protectivePathTargetOf } from "./tool-registry.js";
6
+ import { pathTargetBaseOf } from "./effective-path-target.js";
6
7
  export const RECOMMENDED_SENSITIVE_PATTERNS = [
7
8
  ".env",
8
9
  ".env.*",
@@ -97,10 +98,11 @@ export function createSensitivePathPolicy(opts) {
97
98
  return { action: "allow" };
98
99
  if (guardedByName !== undefined ? !guardedByName.has(req.toolName) : !isProtectedWrite(req))
99
100
  return { action: "allow" };
100
- const path = writeTargetPath(req);
101
+ const protective = protectivePathTargetOf(req);
102
+ const path = writeTargetPath(req, protective);
101
103
  if (typeof path !== "string" || path.length === 0)
102
104
  return { action: "allow" };
103
- const canon = await canonicalizeTarget(opts.env, path, signal, req.cwd ?? opts.rootPath);
105
+ const canon = await canonicalizeTarget(opts.env, path, signal, pathTargetBaseOf(protective, { root: opts.rootPath, cwd: req.cwd }));
104
106
  if (!canon.ok) {
105
107
  if (canon.unresolvedSymlink) {
106
108
  return {
@@ -0,0 +1,47 @@
1
+ /** One argv word as the lexer read it. */
2
+ export interface ShellWord {
3
+ /** The word's value with quotes and escapes removed — meaningful only when `expands` is `false`. */
4
+ readonly text: string;
5
+ /** The unquoted source spelling (assignment and wrapper-flag tests read this, never `text`). */
6
+ readonly raw: string;
7
+ /** `false`: a literal word. `"one"`: carries an expansion but yields exactly ONE word (it was double-quoted).
8
+ * `"many"`: an unquoted expansion, glob or brace pattern, or a quoted `"$@"` / array expansion — may
9
+ * yield zero, one or several words. */
10
+ readonly expands: false | "one" | "many";
11
+ }
12
+ /** One program run the lexer found, after keyword stripping. */
13
+ export interface ShellSegment {
14
+ /** The run as SPELLED (a leading keyword removed): the first candidate a rule is compared against. */
15
+ readonly argv: readonly ShellWord[];
16
+ /** The deeper candidates, one per peeled layer (leading assignments, then each wrapper of the table):
17
+ * `sudo -u root rm -r x` ⇒ `[[rm, -r, x]]`. Empty when nothing peeled. */
18
+ readonly peeled: readonly (readonly ShellWord[])[];
19
+ /** Present when this segment's program runs cannot be known from the text at all; `argv` then holds
20
+ * whatever was read before the failure and must not be matched as a run. */
21
+ readonly unreadable?: string;
22
+ /** Present when the segment's program runs are NOT fully known although its candidates could be read:
23
+ * a peel that stopped before the wrapped program could be named (its options carry an expansion, a
24
+ * command string, an option of unknown arity), or a command/process substitution anywhere in the
25
+ * segment (it runs a program the lexer does not read). The candidates stand — a rule they reach is
26
+ * reached — and every other rule reads `unreadable`. */
27
+ readonly peelUnreadable?: string;
28
+ }
29
+ /** A command line as the tightening reader sees it. */
30
+ export interface ShellCommandShape {
31
+ readonly segments: readonly ShellSegment[];
32
+ /** The connector BETWEEN each consecutive pair of segments (`;` `&&` `||` `|` `|&` `&` `\n`). */
33
+ readonly connectors: readonly string[];
34
+ /** A subshell, group, control-structure keyword or dropped empty piece bounded the segments: the
35
+ * connector list is not one flat chain a compound rule could spell. */
36
+ readonly grouped: boolean;
37
+ }
38
+ export { SHELL_WRAPPER_TABLE, type ShellWrapperName } from "./shell-wrapper-table.js";
39
+ /** Longer than this and the command is not read at all (one unreadable segment): every pass is linear,
40
+ * but a substitution nested inside quoting re-enters the quote reader once per nesting level, and the
41
+ * cap is what bounds that on model-supplied text. Upstream's own reader stops at the same size. */
42
+ export declare const MAX_SHELL_READ_CHARS = 10000;
43
+ /** Read `source` as the tightening reader must: every program run the text could perform, or why it cannot be read. */
44
+ export declare function readShellCommand(source: string): ShellCommandShape;
45
+ /** Every program run of `shape` can be NAMED from the text: no segment is unreadable, no peel stopped,
46
+ * and every run candidate's program word is literal. The offer/mint side's "judgeable" question. */
47
+ export declare function isFullyReadable(shape: ShellCommandShape): boolean;