@sema-agent/core 7.56.0 → 7.57.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 (173) hide show
  1. package/CHANGELOG.md +541 -0
  2. package/LICENSE +1 -1
  3. package/dist/agents/org-delivery-gate.js +3 -4
  4. package/dist/agents/peer-held-queue.d.ts +69 -49
  5. package/dist/agents/peer-held-queue.js +255 -106
  6. package/dist/agents/peer-session-drain.js +63 -43
  7. package/dist/agents/send-message-tool.js +3 -2
  8. package/dist/agents/subagent.js +8 -8
  9. package/dist/brain/anthropic.js +16 -8
  10. package/dist/brain/open-responses.js +10 -8
  11. package/dist/brain/openai.js +11 -9
  12. package/dist/brain/provider-dialect.d.ts +3 -2
  13. package/dist/brain/provider-dialect.js +2 -2
  14. package/dist/brain/request-params.d.ts +6 -0
  15. package/dist/brain/request-params.js +14 -1
  16. package/dist/brain/stream-engine.d.ts +9 -0
  17. package/dist/brain/stream-engine.js +24 -10
  18. package/dist/brain/stream-shared.d.ts +5 -4
  19. package/dist/brain/stream-shared.js +4 -4
  20. package/dist/core/allow-entry.d.ts +3 -10
  21. package/dist/core/allow-entry.js +17 -25
  22. package/dist/core/approval-object.d.ts +21 -5
  23. package/dist/core/approval-object.js +44 -17
  24. package/dist/core/ask-origin.d.ts +2 -2
  25. package/dist/core/auto-compaction.d.ts +71 -6
  26. package/dist/core/auto-compaction.js +42 -26
  27. package/dist/core/background-agent-store.d.ts +6 -2
  28. package/dist/core/background-agent-store.js +1 -1
  29. package/dist/core/checkpoint-requirements.d.ts +7 -1
  30. package/dist/core/checkpoint-requirements.js +2 -0
  31. package/dist/core/checkpoint-store.d.ts +17 -29
  32. package/dist/core/checkpoint-store.js +19 -11
  33. package/dist/core/cross-boundary-taint.js +4 -2
  34. package/dist/core/engine-notice.d.ts +3 -2
  35. package/dist/core/engine-notice.js +6 -0
  36. package/dist/core/file-history-store.d.ts +14 -8
  37. package/dist/core/file-history-store.js +14 -4
  38. package/dist/core/gate-lanes.js +2 -3
  39. package/dist/core/hooks.d.ts +2 -2
  40. package/dist/core/mailbox-store.d.ts +62 -1
  41. package/dist/core/mailbox-store.js +59 -0
  42. package/dist/core/mcp-name-judge.d.ts +46 -0
  43. package/dist/core/mcp-name-judge.js +61 -0
  44. package/dist/core/mcp-probe.d.ts +1 -1
  45. package/dist/core/mcp-probe.js +2 -5
  46. package/dist/core/mcp.js +10 -30
  47. package/dist/core/memory-engine/consolidation-driver.js +4 -3
  48. package/dist/core/memory-engine/distiller.js +26 -17
  49. package/dist/core/memory-engine/layout.js +5 -2
  50. package/dist/core/park-selfcheck.js +2 -0
  51. package/dist/core/permission-rule-consent.d.ts +5 -3
  52. package/dist/core/permission-rule-consent.js +20 -15
  53. package/dist/core/permission-rule-model.d.ts +43 -2
  54. package/dist/core/permission-rule-model.js +52 -8
  55. package/dist/core/permission-rule-org.js +1 -1
  56. package/dist/core/protocol-http-auth.d.ts +16 -2
  57. package/dist/core/protocol-http-auth.js +42 -11
  58. package/dist/core/protocol-naming.d.ts +20 -0
  59. package/dist/core/protocol-naming.js +20 -3
  60. package/dist/core/read-only-shell.d.ts +20 -5
  61. package/dist/core/read-only-shell.js +29 -14
  62. package/dist/core/remote-env.d.ts +37 -1
  63. package/dist/core/remote-env.js +11 -0
  64. package/dist/core/resume-startup-cause.d.ts +24 -2
  65. package/dist/core/resume-startup-cause.js +20 -1
  66. package/dist/core/roles.js +50 -7
  67. package/dist/core/runner/compact-session.d.ts +10 -0
  68. package/dist/core/runner/compact-session.js +202 -0
  69. package/dist/core/runner/compaction-call-options.d.ts +2 -2
  70. package/dist/core/runner/compaction-seams.d.ts +21 -1
  71. package/dist/core/runner/compaction-seams.js +11 -8
  72. package/dist/core/runner/contracts.d.ts +23 -16
  73. package/dist/core/runner/derived-route-fallback.d.ts +13 -0
  74. package/dist/core/runner/derived-route-fallback.js +6 -0
  75. package/dist/core/runner/env-capability-census.d.ts +3 -2
  76. package/dist/core/runner/env-capability-census.js +9 -3
  77. package/dist/core/runner/permission-rule-lanes.d.ts +7 -7
  78. package/dist/core/runner/permission-rule-lanes.js +17 -11
  79. package/dist/core/runner/prepare-acquire-reconcile.d.ts +3 -0
  80. package/dist/core/runner/prepare-acquire-reconcile.js +2 -1
  81. package/dist/core/runner/prepare-config-doors.d.ts +6 -0
  82. package/dist/core/runner/prepare-config-doors.js +4 -3
  83. package/dist/core/runner/prepare-hands-readface.d.ts +3 -1
  84. package/dist/core/runner/prepare-hands-readface.js +2 -0
  85. package/dist/core/runner/prepare-park-ask.d.ts +2 -0
  86. package/dist/core/runner/prepare-park-ask.js +2 -2
  87. package/dist/core/runner/prepare-policy-chain.js +2 -4
  88. package/dist/core/runner/prepare-task.d.ts +4 -1
  89. package/dist/core/runner/prepare-task.js +23 -27
  90. package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +13 -0
  91. package/dist/core/runner/prepare-tool-disclosure-mount.js +13 -7
  92. package/dist/core/runner/rewind-files.d.ts +2 -5
  93. package/dist/core/runner/rewind-files.js +9 -51
  94. package/dist/core/runner/run-leg.js +10 -21
  95. package/dist/core/runner/run-notification-lane.d.ts +2 -0
  96. package/dist/core/runner/run-notification-lane.js +2 -2
  97. package/dist/core/runner/run-recovery-lanes.js +3 -4
  98. package/dist/core/runner/run-settle-and-teardown.js +5 -11
  99. package/dist/core/runner/run-terminal-adoption.js +3 -4
  100. package/dist/core/runner/run-turn-boundary.js +3 -5
  101. package/dist/core/runner/runtask.d.ts +8 -1
  102. package/dist/core/runner/runtask.js +28 -34
  103. package/dist/core/runner/send-message-continuation.js +1 -1
  104. package/dist/core/runner/session-verb-doors.d.ts +28 -0
  105. package/dist/core/runner/session-verb-doors.js +48 -0
  106. package/dist/core/runner/session-write-ledger.d.ts +30 -0
  107. package/dist/core/runner/session-write-ledger.js +101 -0
  108. package/dist/core/runner/stream-settle-backstop.js +11 -14
  109. package/dist/core/runner/sub-agent-deny-lists.d.ts +25 -3
  110. package/dist/core/runner/sub-agent-deny-lists.js +21 -10
  111. package/dist/core/runner/teardown-bounded.d.ts +1 -0
  112. package/dist/core/runner/teardown-bounded.js +1 -0
  113. package/dist/core/runner-deps.d.ts +2 -2
  114. package/dist/core/secret-env.d.ts +4 -0
  115. package/dist/core/secret-env.js +3 -0
  116. package/dist/core/session-policy-store.d.ts +7 -2
  117. package/dist/core/session-policy-store.js +1 -1
  118. package/dist/core/shell-wrapper-projection.d.ts +1 -1
  119. package/dist/core/store-contracts/background-agent-store-contract.js +1 -0
  120. package/dist/core/store-contracts/checkpoint-store-contract.js +2 -1
  121. package/dist/core/store-contracts/file-history-store-contract.js +9 -0
  122. package/dist/core/store-contracts/mailbox-store-contract.js +49 -1
  123. package/dist/core/task-registry-agent.d.ts +8 -4
  124. package/dist/core/task-registry-agent.js +14 -8
  125. package/dist/core/task-registry-shared.d.ts +35 -6
  126. package/dist/core/task-registry-shared.js +11 -0
  127. package/dist/core/task-registry.d.ts +1 -1
  128. package/dist/core/task-registry.js +1 -1
  129. package/dist/core/tool-catalog-entries.d.ts +6 -0
  130. package/dist/core/tool-catalog-entries.js +1 -0
  131. package/dist/core/tool-name-aliases.d.ts +32 -2
  132. package/dist/core/tool-name-aliases.js +23 -0
  133. package/dist/core/tool-name-readings.d.ts +4 -0
  134. package/dist/core/tool-name-readings.js +1 -0
  135. package/dist/core/tool-policy.d.ts +10 -6
  136. package/dist/core/tool-policy.js +4 -7
  137. package/dist/core/trace.d.ts +4 -1
  138. package/dist/core/trace.js +2 -1
  139. package/dist/core/untrusted-egress.d.ts +20 -0
  140. package/dist/core/untrusted-egress.js +77 -3
  141. package/dist/engine/harness/agent-harness.d.ts +7 -5
  142. package/dist/engine/harness/agent-harness.js +11 -2
  143. package/dist/engine/loop/agent-loop.d.ts +1 -4
  144. package/dist/engine/loop/agent-loop.js +26 -4
  145. package/dist/index.d.ts +12 -7
  146. package/dist/index.js +7 -4
  147. package/dist/internal/harness.d.ts +2 -1
  148. package/dist/internal/harness.js +1 -0
  149. package/dist/primitives/credential-ref.d.ts +89 -0
  150. package/dist/primitives/credential-ref.js +125 -0
  151. package/dist/primitives/index.d.ts +2 -0
  152. package/dist/primitives/index.js +1 -0
  153. package/dist/stores/file/checkpoint-store.d.ts +0 -4
  154. package/dist/stores/file/checkpoint-store.js +0 -1
  155. package/dist/stores/file/file-history-store.js +12 -4
  156. package/dist/stores/file/mailbox-store.d.ts +2 -1
  157. package/dist/stores/file/mailbox-store.js +26 -1
  158. package/dist/tools/fs/bash-readonly-classifier.d.ts +15 -19
  159. package/dist/tools/fs/bash-readonly-classifier.js +5 -10
  160. package/dist/tools/fs/fs-bash.js +10 -3
  161. package/dist/tools/fs/pattern-kill-guard.d.ts +3 -2
  162. package/dist/tools/fs/pattern-kill-guard.js +68 -36
  163. package/dist/tools/fs/shell-read-check.d.ts +10 -0
  164. package/dist/tools/fs/shell-read-check.js +7 -1
  165. package/dist/tools/fs/shell-read-identity.d.ts +12 -9
  166. package/dist/tools/fs/shell-read-identity.js +9 -4
  167. package/dist/tools/web.js +9 -7
  168. package/package.json +1 -1
  169. package/test/export-surface.snapshot.json +79 -3
  170. package/wire-changes/core-stores-sql@7.57.0.json +29 -0
  171. package/wire-changes/core@7.57.0.json +1487 -0
  172. package/wire-changes/mods-kernel@7.57.0.json +29 -0
  173. package/wire-changes/primitives@7.57.0.json +245 -0
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { escapeForDisclosure, hasUnrenderableCharacters, parseRuleText, ruleAdmitsCommand, ruleBreadthWarningsOf, ruleToolGrammarOf, segmentCoverageOf, suggestRuleForHost, suggestRuleForName, suggestRulesForCommand, EDITED_RULE_BREADTH_WARNING_CODES, RULE_OFFER_KINDS, UNCOVERED_SEGMENT_REASONS, RULE_BEHAVIORS, RULE_BEHAVIOR_PRECEDENCE, isRuleBehavior, } from "./permission-rule-model.js";
2
+ import { COMMAND_RULE_TOOL, cardRuleFormOf, escapeForDisclosure, hasUnrenderableCharacters, parseRuleText, ruleAdmitsCommand, ruleBreadthWarningsOf, segmentCoverageOf, suggestRuleForHost, suggestRuleForName, suggestRuleForTool, suggestRulesForCommand, EDITED_RULE_BREADTH_WARNING_CODES, RULE_OFFER_KINDS, UNCOVERED_SEGMENT_REASONS, RULE_BEHAVIORS, RULE_BEHAVIOR_PRECEDENCE, isRuleBehavior, } from "./permission-rule-model.js";
3
3
  import { readRuleOwner, sameRuleOwner } from "./rule-owner.js";
4
4
  import { principalNameOf, readPrincipal } from "./principal-name.js";
5
5
  import { isPlainRecord } from "./mods/kernel/receive.js";
@@ -236,6 +236,12 @@ function approvalRecordDamageOf(rec) {
236
236
  if (rec.kind === "card" && cand.behavior !== "allow") {
237
237
  return `card offer ${at} references candidate ${member}, a ${cand.behavior} rule — a card mints allow candidates only`;
238
238
  }
239
+ if (rec.kind === "card") {
240
+ const read = parseRuleText(cand.rule, cand.behavior);
241
+ if ("rule" in read && read.rule.match === "tool" && (typeof rec.command === "string" || cardRuleFormOf(read.rule.tool) !== "tool")) {
242
+ return `card offer ${at} references candidate ${member}, the whole-tool rule "${escapeForDisclosure(read.rule.rule)}" — the engine mints one only on the whole-tool card of a tool with no rule grammar`;
243
+ }
244
+ }
239
245
  if (!isValidConsentScope(cand.scope)) {
240
246
  return `offer ${at} references candidate ${member}, whose scope is not a consent-face scope (global, project, or session)`;
241
247
  }
@@ -262,7 +268,7 @@ function approvalRecordDamageOf(rec) {
262
268
  }
263
269
  if (detail.length > 0) {
264
270
  const rows = detail;
265
- const table = typeof rec.command === "string" ? segmentCoverageOf(rec.command, { persisted: [] }, { tool: CARD_RULE_TOOL, cwd: undefined }) : undefined;
271
+ const table = typeof rec.command === "string" ? segmentCoverageOf(rec.command, { persisted: [] }, { tool: COMMAND_RULE_TOOL, cwd: undefined }) : undefined;
266
272
  if (table === undefined) {
267
273
  return `card batch offer ${at} carries reason rows but the record's command cannot back them — a row without a readable source command is a row nobody can audit`;
268
274
  }
@@ -379,10 +385,10 @@ export async function prepareCardApproval(opts) {
379
385
  e.code = "config.invalid_argument";
380
386
  throw e;
381
387
  }
382
- const grammar = ruleToolGrammarOf(opts.toolName);
383
- const oneRuleCard = grammar === "name" || grammar === "domain" ? grammar : undefined;
384
- if (opts.toolName !== CARD_RULE_TOOL && oneRuleCard === undefined)
388
+ const form = cardRuleFormOf(opts.toolName);
389
+ if (form === undefined)
385
390
  return undefined;
391
+ const oneRuleCard = form === "command" ? undefined : form;
386
392
  const scope = (() => {
387
393
  if (opts.scope !== undefined) {
388
394
  if (!isValidConsentScope(opts.scope)) {
@@ -409,14 +415,14 @@ export async function prepareCardApproval(opts) {
409
415
  try {
410
416
  const view = await effectiveOrThrow(storeForOwner(opts.deps.provider, owner), opts.sessionId !== undefined ? { sessionId: opts.sessionId } : undefined);
411
417
  const table = view.rules;
412
- coverage = segmentCoverageOf(opts.command, { persisted: table }, { tool: CARD_RULE_TOOL, cwd: opts.cwd, sessionId: opts.sessionId, ...(opts.execCwd !== undefined ? { execCwd: opts.execCwd } : {}) });
418
+ coverage = segmentCoverageOf(opts.command, { persisted: table }, { tool: COMMAND_RULE_TOOL, cwd: opts.cwd, sessionId: opts.sessionId, ...(opts.execCwd !== undefined ? { execCwd: opts.execCwd } : {}) });
413
419
  }
414
420
  catch {
415
421
  coverage = undefined;
416
422
  }
417
423
  }
418
424
  const suggested = oneRuleCard !== undefined
419
- ? [oneRuleCard === "name" ? suggestRuleForName(opts.toolName, opts.command) : suggestRuleForHost(opts.toolName, opts.command)].filter((o) => o !== undefined)
425
+ ? [oneRuleCard === "name" ? suggestRuleForName(opts.toolName, opts.command) : oneRuleCard === "domain" ? suggestRuleForHost(opts.toolName, opts.command) : opts.command === "" ? suggestRuleForTool(opts.toolName) : undefined].filter((o) => o !== undefined)
420
426
  : suggestRulesForCommand(opts.command, {
421
427
  ...(coverage !== undefined ? { coverage } : {}),
422
428
  scope,
@@ -468,7 +474,6 @@ export async function prepareCardApproval(opts) {
468
474
  candidates,
469
475
  };
470
476
  }
471
- const CARD_RULE_TOOL = "Bash";
472
477
  export async function confirmRuleApproval(opts) {
473
478
  const caller = resolveCallerOwner(opts.principal, opts.owner, "confirmRuleApproval");
474
479
  const no = (reason, detail) => ({
@@ -546,6 +551,9 @@ function checkEditedRuleText(text, command) {
546
551
  const parsed = parseSubmittedSpelling(text);
547
552
  if ("reject" in parsed)
548
553
  return { ok: false, code: parsed.reject.code, message: parsed.reject.message };
554
+ if (parsed.rule.match === "tool") {
555
+ return { ok: false, message: `the edited rule "${parsed.rule.rule}" names the whole tool — a card's edit names a command rule, and a standing yes to every command is not one a card mints` };
556
+ }
549
557
  if (!ruleAdmitsCommand(parsed.rule, command)) {
550
558
  return {
551
559
  ok: false,
@@ -838,18 +846,15 @@ export async function prepareCcImport(opts) {
838
846
  skipped.push({ rule: String(entry), reason: `settings ${behavior} entry is not a string` });
839
847
  continue;
840
848
  }
841
- if (!/^[A-Za-z][A-Za-z0-9_]*\(.*\)$/.test(entry)) {
842
- const reason = /^[A-Za-z][A-Za-z0-9_-]*$/.test(entry)
843
- ? "unsupported.form: a bare tool-name entry is a name-set item, not a command rule — it stays in the settings file, unimported"
844
- : "unsupported.form: not a command rule spelling this version imports — it stays in the settings file, unimported";
845
- skipped.push({ rule: entry, reason });
846
- continue;
847
- }
848
849
  const parsed = parseRuleText(entry, behavior);
849
850
  if ("reject" in parsed) {
850
851
  skipped.push({ rule: entry, reason: `${parsed.reject.code}: ${parsed.reject.message}` });
851
852
  continue;
852
853
  }
854
+ if (parsed.rule.match === "tool") {
855
+ skipped.push({ rule: entry, reason: "unsupported.form: a bare tool-name entry is a name-set item the host carries from the settings layer's own lists — it stays in the settings file, unimported" });
856
+ continue;
857
+ }
853
858
  if (candidates.some((c) => c.behavior === behavior && c.rule === parsed.rule.rule && sameScope(c.scope, scope)))
854
859
  continue;
855
860
  candidates.push({ behavior, rule: parsed.rule.rule, scope });
@@ -77,6 +77,7 @@ import { type DenyFace } from "../tools/fs/read-deny.js";
77
77
  import type { AssertAllKeysHandled, RuleOffersAbsence } from "./ask-origin.js";
78
78
  import { type ToolCallLike } from "./tool-registry.js";
79
79
  import { type HostReading } from "./host-target.js";
80
+ import { type NamedCall } from "./tool-name-readings.js";
80
81
  import { type PathDenialEvidence, type PathFamily, type PathIdentity } from "../tools/fs/safety.js";
81
82
  import { type TargetViews } from "./path-rule-binding.js";
82
83
  /**
@@ -284,6 +285,18 @@ export declare const DOMAIN_RULE_MARKER = "domain:";
284
285
  */
285
286
  export type RuleGrammar = "command" | "path" | "name" | "domain";
286
287
  export declare function ruleToolGrammarOf(toolName: string): RuleGrammar | undefined;
288
+ /**
289
+ * THE rule a tool's approval card mints a "don't ask again" option in — one answer, read by the offer factory and the consent door
290
+ * alike: `command` for the command rule word (the shell card's command rules), `name` / `domain` for a tool of those grammars (the one
291
+ * exact value rule), `tool` for a call no rule word speaks for under any name it answers to ({@link laneToolNamesOf} empty: the
292
+ * whole-tool rule, the reference's fallback card), and `undefined` for the rest — a path tool (no path offer is minted), a shell-face
293
+ * sibling of the command word (its commands are `Bash(…)` rules), and a tool that answers to one of those under an alias it declares
294
+ * (a caller's shell wearing `Bash`: a one-click whole-tool yes on it would be a licence for every program): its card names that it has
295
+ * no rule to offer. `toolName` is the wire name the card mints on; `call` the call's names (absent: the wire name alone — the
296
+ * consent door, which holds no face).
297
+ */
298
+ export type CardRuleForm = "command" | "name" | "domain" | "tool";
299
+ export declare function cardRuleFormOf(toolName: string, call?: NamedCall): CardRuleForm | undefined;
287
300
  /** The parameter a shell-face tool's rule content names as one of the tool's own scalar arguments (`Monitor(description:*)`,
288
301
  * `Bash(timeout:5)`), or undefined — the content is then a command spelling. */
289
302
  export declare function shellRuleParamOf(tool: string, content: string): string | undefined;
@@ -311,11 +324,11 @@ export declare function nameRuleSubjectOf(toolName: string, args: unknown): stri
311
324
  * domain tool's its own name — so a stored `Bash(…)` or `Edit(…)` row reaches a tool that answers to that name under another wire
312
325
  * name, as a policy's rule does. Every lane that judges a call by a rule's word reads it here: the policy compiler, the
313
326
  * person's stored rows, the org partition, the allow lists. Empty ⇒ no grammar speaks for the call. */
314
- export declare function laneToolNamesOf(req: Pick<import("./tool-policy.js").ToolCallRequest, "toolName" | "face">): readonly string[];
327
+ export declare function laneToolNamesOf(req: NamedCall): readonly string[];
315
328
  /** The match words a persisted rule is stored with, spelled once: {@link PersistedRuleMatch} is derived from this
316
329
  * tuple (each form is documented there). A store backend that checks a stored row's `match` reads this value
317
330
  * rather than keeping its own list. */
318
- export declare const PERSISTED_RULE_MATCHES: readonly ["exact", "prefix", "wildcard", "pattern", "subpath", "path", "name", "name_prefix", "host", "subdomain"];
331
+ export declare const PERSISTED_RULE_MATCHES: readonly ["exact", "prefix", "wildcard", "pattern", "subpath", "path", "name", "name_prefix", "host", "subdomain", "tool"];
319
332
  /**
320
333
  * Match forms, one family per grammar (`prefix` and `wildcard` are one predicate):
321
334
  * · `exact` — admits the one string the rule spells.
@@ -347,6 +360,8 @@ export declare const PERSISTED_RULE_MATCHES: readonly ["exact", "prefix", "wildc
347
360
  * · `name` / `name_prefix` — the NAME grammar ({@link parseNameRuleContent}).
348
361
  * · `host` / `subdomain` — the DOMAIN grammar ({@link parseDomainRuleContent}): one exact host, or every
349
362
  * host below one domain.
363
+ * · `tool` — the WHOLE-TOOL form, a rule with no content (`SendUserFile`, `Bash`, `mcp__srv__get_issue`), on any tool:
364
+ * `command` is empty and the rule reaches every call that answers to the name it spells ({@link wholeToolRuleReaches}).
350
365
  */
351
366
  export type PersistedRuleMatch = (typeof PERSISTED_RULE_MATCHES)[number];
352
367
  /**
@@ -1275,6 +1290,32 @@ export declare function adjudicatePersistedDomainRules(rules: readonly Persisted
1275
1290
  sessionId?: string;
1276
1291
  scopeIdentity?: ScopeRootIdentity;
1277
1292
  }): PersistedRuleVerdict | undefined;
1293
+ /**
1294
+ * THE predicate of a whole-tool rule (`match: "tool"`) over one call: the rule reaches the call iff the call answers to the name it
1295
+ * spells, under every name the call answers to (the model's spelling, the wire name, the aliases that resolve to it) — the
1296
+ * reference's tool-wide matcher. Every reader of a rule with no content asks it: an entry list's whole-tool row and a person's stored
1297
+ * whole-tool row alike.
1298
+ */
1299
+ export declare function wholeToolRuleReaches(tool: string, call: NamedCall): boolean;
1300
+ /**
1301
+ * The whole-tool sibling of {@link adjudicatePersistedRules}: the same precedence walk (deny > ask > allow) over the whole-tool rows
1302
+ * (`match: "tool"`) that reach the call ({@link wholeToolRuleReaches}) and are eligible under their own tool ({@link eligiblePersisted},
1303
+ * the one predicate); the first behavior with such a row decides, the first table-order row of it is the answer.
1304
+ */
1305
+ export declare function adjudicatePersistedToolRules(rules: readonly PersistedRule[], call: {
1306
+ request: NamedCall;
1307
+ cwd: string | undefined;
1308
+ sessionId?: string;
1309
+ scopeIdentity?: ScopeRootIdentity;
1310
+ }): PersistedRuleVerdict | undefined;
1311
+ /**
1312
+ * The ONE offer the card of a tool with no rule grammar carries ({@link cardRuleFormOf} `tool`): the whole-tool rule on its name (the
1313
+ * reference's fallback card, "don't ask again for <tool>"), or undefined when the name has no such rule (the round-trip law: the bare
1314
+ * name, parsed back as an allow, must be the whole-tool rule on that very name).
1315
+ */
1316
+ export declare function suggestRuleForTool(tool: string): Extract<RuleOffer, {
1317
+ kind: "single";
1318
+ }> | undefined;
1278
1319
  /**
1279
1320
  * The ONE offer a host tool's approval card carries: the exact rule for the host the call reaches (`WebFetch(domain:<host>)`, the
1280
1321
  * reference's "don't ask again for <host>" row), or undefined when no rule spells exactly that host (the round-trip law: the
@@ -2,13 +2,15 @@ import { homedir } from "node:os";
2
2
  import { shellWordsOf } from "../shell/shell-words.js";
3
3
  import { lexShell } from "../shell/lex.js";
4
4
  import { readProgramPosition } from "../tools/fs/bash-program-position.js";
5
- import { parsePermissionRule, ruleSpellingOf } from "./permission-rule-syntax.js";
5
+ import { isNameableToolName, parsePermissionRule, ruleSpellingOf } from "./permission-rule-syntax.js";
6
+ import { isCoveringRuleName } from "./namespaced-rule-name.js";
7
+ import { toolNameFaultOf } from "./tool-name-aliases.js";
6
8
  import { compileReadDeny } from "../tools/fs/read-deny.js";
7
9
  import { carriesShellRedirection, parseLeadingCommandName, splitShellCompoundSegments } from "../tools/fs/bash-readonly-classifier.js";
8
10
  import { INLINE_TRUNCATION_MARK, inlineUntrusted } from "./untrusted-text.js";
9
- import { catalogRuleFaceOf, hostTargetOf, isShellFaceToolName, pathTargetOf, protectivePathTargetOf } from "./tool-registry.js";
11
+ import { catalogRuleFaceOf, catalogToolNames, hostTargetOf, isShellFaceToolName, pathTargetOf, protectivePathTargetOf } from "./tool-registry.js";
10
12
  import { HOST_UNREADABLE_SENTENCE, hostPatternAdmits, readHostPattern, spelledHostPattern } from "./host-target.js";
11
- import { heldValues } from "./tool-name-readings.js";
13
+ import { heldBy, heldValues } from "./tool-name-readings.js";
12
14
  import { carriesDotSegment, isAbsoluteForFamily, isIdentityDir, isPathIdentity, isShellRootedSpellingUnmapped, isUncIdentity, pathFamilyOf, pathIdentityOf } from "../tools/fs/safety.js";
13
15
  import { bindPathRules, judgeRuleRows, rowsWhereTheyLead } from "./path-rule-binding.js";
14
16
  import { readShellCommand } from "./shell-run-candidates.js";
@@ -135,6 +137,14 @@ export function ruleToolGrammarOf(toolName) {
135
137
  return "domain";
136
138
  return pathTargetOf(toolName) !== undefined ? "path" : undefined;
137
139
  }
140
+ export function cardRuleFormOf(toolName, call = { toolName }) {
141
+ if (toolName === COMMAND_RULE_TOOL)
142
+ return "command";
143
+ const grammar = ruleToolGrammarOf(toolName);
144
+ if (grammar === "name" || grammar === "domain")
145
+ return grammar;
146
+ return grammar === undefined && laneToolNamesOf(call).length === 0 && laneToolNamesOf({ toolName }).length === 0 ? "tool" : undefined;
147
+ }
138
148
  export function shellRuleParamOf(tool, content) {
139
149
  const colon = content.indexOf(":");
140
150
  if (colon <= 0)
@@ -172,7 +182,7 @@ function ruleWordOf(name) {
172
182
  return COMMAND_RULE_TOOL;
173
183
  return ruleToolGrammarOf(name) !== undefined ? name : undefined;
174
184
  }
175
- export const PERSISTED_RULE_MATCHES = Object.freeze(["exact", "prefix", "wildcard", "pattern", "subpath", "path", "name", "name_prefix", "host", "subdomain"]);
185
+ export const PERSISTED_RULE_MATCHES = Object.freeze(["exact", "prefix", "wildcard", "pattern", "subpath", "path", "name", "name_prefix", "host", "subdomain", "tool"]);
176
186
  function isCommandMatch(match) {
177
187
  return match === "exact" || match === "prefix" || match === "wildcard" || match === "pattern";
178
188
  }
@@ -415,6 +425,19 @@ export function respelledRuleTextOf(text, behavior) {
415
425
  const read = parseRuleText(candidate, behavior);
416
426
  return "rule" in read ? read.rule.rule : undefined;
417
427
  }
428
+ function parseWholeToolRule(text, tool, behavior) {
429
+ if (!isNameableToolName(tool) || isCoveringRuleName(tool, behavior)) {
430
+ return reject("invalid.grammar", `"${text}" names a set of tools — a stored rule names one tool, or one tool's content; a set of tools is a list entry's form`);
431
+ }
432
+ const fault = toolNameFaultOf(tool, catalogToolNames(), behavior);
433
+ if (fault === undefined)
434
+ return { rule: { behavior, rule: tool, tool, match: "tool", command: "" } };
435
+ const why = fault.reason === "near_miss"
436
+ ? `is another spelling of "${fault.currentName}", which the engine's tool catalog names — a rule names its tool exactly as spelled, so this one would reach no tool`
437
+ : `names no tool under that name${fault.guidance !== undefined ? ` — ${fault.guidance}` : ""}`;
438
+ const write = fault.currentName !== undefined && !why.includes(`"${fault.currentName}"`) ? `; write "${fault.currentName}"` : "";
439
+ return reject("unsupported.tool", `"${text}" ${why}${write}`);
440
+ }
418
441
  export function parseRuleText(text, behavior) {
419
442
  if (!isRuleBehavior(behavior)) {
420
443
  return reject("invalid.grammar", `rule "${text}" was parsed under behavior ${escapeForDisclosure(behavior)}, which is not one of ${RULE_BEHAVIORS.join("/")}`);
@@ -438,9 +461,8 @@ export function parseRuleText(text, behavior) {
438
461
  };
439
462
  }
440
463
  const ruleContent = ruleContentOf(parsed);
441
- if (ruleContent === undefined) {
442
- return reject("invalid.grammar", `"${text}" is not a Tool(content) rule — a bare tool name claims the whole tool and is not a command rule`);
443
- }
464
+ if (ruleContent === undefined)
465
+ return parseWholeToolRule(text, parsed.toolName, behavior);
444
466
  if (parsed.toolName !== COMMAND_RULE_TOOL) {
445
467
  const grammar = ruleToolGrammarOf(parsed.toolName);
446
468
  if (grammar === undefined) {
@@ -510,6 +532,8 @@ export function parseRuleText(text, behavior) {
510
532
  return { rule: { behavior, rule: formatRuleText(command, match, COMMAND_RULE_TOOL), tool: COMMAND_RULE_TOOL, match, command } };
511
533
  }
512
534
  export function formatRuleText(command, match, tool) {
535
+ if (match === "tool")
536
+ return tool;
513
537
  if (match === "subpath")
514
538
  return `${tool}(//${command.startsWith("/") ? command.slice(1) : command}/**)`;
515
539
  if (match === "path")
@@ -1318,7 +1342,7 @@ export function commandCoverageOf(rules, call) {
1318
1342
  return covering.length > 0 ? { covering } : { uncovered: undefined };
1319
1343
  }
1320
1344
  function readsOnlyAsSegment(segment, lineObjection, whole) {
1321
- return lineObjection === undefined && !movesShellCursor(segment) && readOnlyShellVerdict(segment, undefined, undefined, () => lineDescriptorsOfText(whole)).readOnly;
1345
+ return lineObjection === undefined && !movesShellCursor(segment) && readOnlyShellVerdict(segment, { bound: () => lineDescriptorsOfText(whole) }).readOnly;
1322
1346
  }
1323
1347
  function firstEligibleDirectoryRule(rules, directory, call) {
1324
1348
  for (const rule of rules) {
@@ -1434,6 +1458,26 @@ export function adjudicatePersistedDomainRules(rules, call) {
1434
1458
  }
1435
1459
  return undefined;
1436
1460
  }
1461
+ export function wholeToolRuleReaches(tool, call) {
1462
+ return heldBy("policy.persistedToolRule", { has: (name) => name === tool }, call);
1463
+ }
1464
+ export function adjudicatePersistedToolRules(rules, call) {
1465
+ const at = { cwd: call.cwd, ...(call.sessionId !== undefined ? { sessionId: call.sessionId } : {}), ...(call.scopeIdentity !== undefined ? { scopeIdentity: call.scopeIdentity } : {}) };
1466
+ for (const behavior of RULE_BEHAVIORS_BY_PRECEDENCE) {
1467
+ const row = rules.find((rule) => rule.match === "tool" && wholeToolRuleReaches(rule.tool, call.request) && eligiblePersisted(rule, { ...at, tool: rule.tool }, behavior));
1468
+ if (row !== undefined)
1469
+ return { behavior, rules: [row] };
1470
+ }
1471
+ return undefined;
1472
+ }
1473
+ export function suggestRuleForTool(tool) {
1474
+ if (cardRuleFormOf(tool) !== "tool")
1475
+ return undefined;
1476
+ const parsed = parseRuleText(formatRuleText("", "tool", tool), "allow");
1477
+ if (!("rule" in parsed) || parsed.rule.match !== "tool" || parsed.rule.tool !== tool)
1478
+ return undefined;
1479
+ return { kind: "single", rule: parsed.rule.rule, match: "tool", command: "" };
1480
+ }
1437
1481
  export function suggestRuleForHost(tool, host) {
1438
1482
  if (ruleToolGrammarOf(tool) !== "domain")
1439
1483
  return undefined;
@@ -301,7 +301,7 @@ function readOrgRow(row, index) {
301
301
  const parsed = parseRuleText(text, said);
302
302
  if ("reject" in parsed)
303
303
  return unreadable(parsed.reject.code, undefined, parsed.reject.respelling);
304
- if (parsed.rule.tool !== COMMAND_RULE_TOOL)
304
+ if (parsed.rule.tool !== COMMAND_RULE_TOOL || parsed.rule.match === "tool")
305
305
  return unreadable("unsupported.tool");
306
306
  return { kind: "read", index, rule: text, behavior: said, parsed: parsed.rule };
307
307
  }
@@ -14,6 +14,7 @@
14
14
  * declared headers follows no redirect, {@link carriesDeclaredHeaders}).
15
15
  */
16
16
  import type { ProtocolId } from "./protocol-table.js";
17
+ import { type Egress } from "./untrusted-egress.js";
17
18
  /** The per-peer HTTP auth declaration every protocol spec carries in the same two fields. */
18
19
  export interface ProtocolHttpAuth {
19
20
  headers?: Record<string, string>;
@@ -53,10 +54,23 @@ export declare function declaredHeaderValues(transport: unknown, principal?: str
53
54
  * from outside takes its own range and the member's value goes with it. A declared value that is also an ordinary word or number (`en`, `1`,
54
55
  * `application/json`) goes wherever it occurs in the remote's words, in any case — a legibility cost, never a leak; the failure's closed
55
56
  * fields (code, status, delivery) are not text. A declaration too large for one matcher (tens of thousands of characters, which no header
56
- * carries) makes the whole text the marker: the pass never throws and never shows what it could not match. Run BEFORE the credential
57
- * vocabulary: that pass rewrites parts of a value, after which the value no longer matches whole.
57
+ * carries) makes the whole text the marker: the pass never throws and never shows what it could not match. A hit that lies wholly inside
58
+ * a marker a pass already wrote (the exact literals, `redactionMarkerSpans`) is no hit, and one that reaches into a marker takes the
59
+ * whole marker with it, so the pass over its own output answers the same text.
58
60
  */
59
61
  export declare function scrubRequestEcho(text: string, declared: readonly string[]): string;
62
+ /**
63
+ * THE one form a remote endpoint's words take when the engine carries them in its own words — an MCP server's failure, a model
64
+ * provider's error body, frame or transport failure, a memory distiller seat's failure: what the request was declared with
65
+ * ({@link scrubRequestEcho}: `declared` is required, so no mint reads a remote's words without naming its request), then the credential
66
+ * vocabulary. Words with no declared value in them read exactly as the vocabulary renders them. Where a declared value is there, the
67
+ * values the vocabulary finds in the ORIGINAL words are taken out with it, so neither disarms the other: removing a declared `Bearer`
68
+ * cannot hide the token after it from the vocabulary, and the vocabulary rewriting part of a declared value cannot leave the rest. A
69
+ * caller that bounds the text for display cuts what this returns, never the remote's raw words, so a cut can leave no part of a value.
70
+ * The function is a fixed point (its own output answers the same text). A store, a log or an event lane that receives the result does
71
+ * not redact again: persistence is not an exit, the mint is.
72
+ */
73
+ export declare function remoteWords(text: string, declared: readonly string[]): Egress<"same-principal">;
60
74
  /** Whether a resolved header set carries anything the declaration bound to its endpoints — a request that does follows no redirect (the
61
75
  * next hop is not a declared endpoint; A2A's follow law reads this). */
62
76
  export declare function carriesDeclaredHeaders(headers: Record<string, string> | undefined): boolean;
@@ -1,5 +1,6 @@
1
1
  import { authSchemeCredentials, credentialHeaderValues, holdOwnCredentialValues } from "./secret-env.js";
2
2
  import { readMember } from "./thrown-value.js";
3
+ import { egressText, redactionMarkerSpans } from "./untrusted-egress.js";
3
4
  import { inlineUntrusted } from "./untrusted-text.js";
4
5
  export function headerOwnershipCollision(t) {
5
6
  const owners = new Map();
@@ -106,17 +107,7 @@ function structuredEnd(text, at) {
106
107
  return text.length;
107
108
  }
108
109
  export function scrubRequestEcho(text, declared) {
109
- const forms = new Set();
110
- for (const value of declared)
111
- for (const spelling of requestEchoSpellings(value))
112
- forms.add(spelling);
113
- forms.delete("");
114
- const members = cookieMembers(text);
115
- const removed = members.map(([, from, end]) => [from, end]);
116
- for (const hit of valueHits(text, [...forms])) {
117
- if (!members.some(([name, from]) => hit[0] >= name && hit[1] <= from))
118
- removed.push(hit);
119
- }
110
+ const removed = requestEchoRanges(text, declared);
120
111
  if (removed.length === 0)
121
112
  return text;
122
113
  removed.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
@@ -132,6 +123,46 @@ export function scrubRequestEcho(text, declared) {
132
123
  }
133
124
  return out + text.slice(kept);
134
125
  }
126
+ function requestEchoRanges(text, declared) {
127
+ const forms = new Set();
128
+ for (const value of declared)
129
+ for (const spelling of requestEchoSpellings(value))
130
+ forms.add(spelling);
131
+ forms.delete("");
132
+ const members = cookieMembers(text);
133
+ const removed = members.map(([, from, end]) => [from, end]);
134
+ const markers = redactionMarkerSpans(text);
135
+ for (const hit of valueHits(text, [...forms])) {
136
+ if (members.some(([name, from]) => hit[0] >= name && hit[1] <= from))
137
+ continue;
138
+ if (markers.some(([from, end]) => hit[0] >= from && hit[1] <= end))
139
+ continue;
140
+ const reach = markers.filter(([from, end]) => hit[0] < end && hit[1] > from);
141
+ removed.push([Math.min(hit[0], ...reach.map(([from]) => from)), Math.max(hit[1], ...reach.map(([, end]) => end))]);
142
+ }
143
+ return removed;
144
+ }
145
+ function vocabularyValues(text) {
146
+ const report = { findings: [] };
147
+ egressText("same-principal", text, report);
148
+ return report.findings.map(({ span: [from, end] }) => {
149
+ const found = text.slice(from, end);
150
+ const rendered = egressText("same-principal", found);
151
+ let head = 0;
152
+ while (head < found.length && head < rendered.length && found[head] === rendered[head])
153
+ head++;
154
+ let tail = 0;
155
+ while (tail < found.length - head && tail < rendered.length - head && found[found.length - 1 - tail] === rendered[rendered.length - 1 - tail])
156
+ tail++;
157
+ const value = found.slice(head, found.length - tail);
158
+ return value === "" ? found : value;
159
+ });
160
+ }
161
+ export function remoteWords(text, declared) {
162
+ if (requestEchoRanges(text, declared).length === 0)
163
+ return egressText("same-principal", text);
164
+ return egressText("same-principal", scrubRequestEcho(text, [...declared, ...vocabularyValues(text)]));
165
+ }
135
166
  function cookieMembers(text) {
136
167
  const members = [];
137
168
  const pattern = new RegExp(COOKIE_START, "g");
@@ -60,6 +60,26 @@ export declare function findNamespacePrefixCollision(ns: ProtocolNamespace, peer
60
60
  prefix: string;
61
61
  peers: [string, string];
62
62
  } | undefined;
63
+ /**
64
+ * EVERY peer in `peers` that mints a {@link mintNamespacePrefix} an earlier peer already minted, in list order, each paired with the
65
+ * FIRST peer that holds the prefix — empty when every peer owns its own. {@link findNamespacePrefixCollision} is its first row: one
66
+ * piece of arithmetic answers both the question "is there a collision" and the question "which names, all of them".
67
+ */
68
+ export declare function findNamespacePrefixCollisions(ns: ProtocolNamespace, peers: readonly string[]): Array<{
69
+ prefix: string;
70
+ peers: [string, string];
71
+ }>;
72
+ /**
73
+ * One peer's claim ledger for tool names: the function it returns takes a REMOTE tool name, mints its wire name
74
+ * ({@link mintNamespacedToolName}) and answers `{ minted }` when the name is free, or `{ minted, heldBy }` when an earlier call
75
+ * already minted it — `heldBy` is the RAW name that took it first (it keeps the name; the ledger never re-binds). A listing's
76
+ * intake and a judge over declared names ask the same ledger; `heldBy === tool` (the same raw name again) stays distinguishable
77
+ * from two different raw names that folded together.
78
+ */
79
+ export declare function createToolNameClaims(ns: ProtocolNamespace, peer: string): (tool: string) => {
80
+ minted: string;
81
+ heldBy?: string;
82
+ };
63
83
  /**
64
84
  * The full registered name for `(peer, tool)` in `ns`. Always starts with {@link mintNamespacePrefix}'s
65
85
  * answer for the same peer (the invariant ticket #9's pins hold), and never exceeds
@@ -27,15 +27,32 @@ export function mintNamespacePrefix(ns, peer) {
27
27
  return `${ns.prefix}${settlePeerSegment(clampNameSegment(normalizeNameSegment(peer), peerBudget))}${NAME_SEP}`;
28
28
  }
29
29
  export function findNamespacePrefixCollision(ns, peers) {
30
+ return findNamespacePrefixCollisions(ns, peers)[0];
31
+ }
32
+ export function findNamespacePrefixCollisions(ns, peers) {
30
33
  const seen = new Map();
34
+ const collisions = [];
31
35
  for (const peer of peers) {
32
36
  const prefix = mintNamespacePrefix(ns, peer);
33
37
  const first = seen.get(prefix);
34
- if (first !== undefined)
35
- return { prefix, peers: [first, peer] };
38
+ if (first !== undefined) {
39
+ collisions.push({ prefix, peers: [first, peer] });
40
+ continue;
41
+ }
36
42
  seen.set(prefix, peer);
37
43
  }
38
- return undefined;
44
+ return collisions;
45
+ }
46
+ export function createToolNameClaims(ns, peer) {
47
+ const held = new Map();
48
+ return (tool) => {
49
+ const minted = mintNamespacedToolName(ns, peer, tool);
50
+ const heldBy = held.get(minted);
51
+ if (heldBy !== undefined)
52
+ return { minted, heldBy };
53
+ held.set(minted, tool);
54
+ return { minted };
55
+ };
39
56
  }
40
57
  export function mintNamespacedToolName(ns, peer, tool) {
41
58
  const prefix = mintNamespacePrefix(ns, peer);
@@ -8,6 +8,20 @@ export type ReadOnlyShellVerdict = {
8
8
  readonly readOnly: false;
9
9
  readonly reason: string;
10
10
  };
11
+ /** How a caller asks: a shape it already read, the session's working directory, the whole line's descriptor
12
+ * bindings when it judges one segment of a longer line, and the deployment's programs. */
13
+ export interface ReadOnlyShellOptions {
14
+ /** The command already read with {@link readShellCommand} (the gate reads every shell command once). */
15
+ readonly shape?: ShellCommandShape | undefined;
16
+ /** Where the session stands: a `cd` beside a `git` that lands here is no line objection. */
17
+ readonly cwd?: string | undefined;
18
+ /** The descriptor bindings of the WHOLE line, for a caller that judges one segment of it. */
19
+ readonly bound?: LineDescriptors | (() => LineDescriptors) | undefined;
20
+ /** The programs a deployment lets a face run: every program the line names (each segment's program past a `command` /
21
+ * `builtin` prefix, and the program an `xargs` segment hands its input to) must be one of them, else the
22
+ * line is not read-only for that face. It only ever narrows — a program here whose call writes is still not read-only. */
23
+ readonly readers?: ReadonlySet<string> | undefined;
24
+ }
11
25
  /** The reference implementation's question for a compound that changes directory and runs git (`cd-git-compound`). */
12
26
  export declare const CD_GIT_COMPOUND_SENTENCE = "This command changes directory before running a version-control command, which can pick up untrusted hooks or repository configuration from the target directory. Approve only if you trust it.";
13
27
  /**
@@ -17,8 +31,8 @@ export declare const CD_GIT_COMPOUND_SENTENCE = "This command changes directory
17
31
  */
18
32
  export declare function changesDirectoryBeforeGit(shape: ShellCommandShape, cwd?: string): boolean;
19
33
  /**
20
- * Read `command` as the allow layer must: read-only, or the first reason it is not. A shape already read by
21
- * the caller may be handed in (the gate reads every shell command once).
34
+ * Read `command` as every read-only consumer must: read-only, or the first reason it is not. A shape already read
35
+ * by the caller may be handed in (the gate reads every shell command once).
22
36
  *
23
37
  * @contract read_only_shell.every_segment — a command line is read-only iff the lexer reads it whole (no
24
38
  * unreadable segment, no group/subshell/control structure, no background `&`), no word anywhere carries a
@@ -30,10 +44,11 @@ export declare function changesDirectoryBeforeGit(shape: ShellCommandShape, cwd?
30
44
  * the exact forms, `echo`/`ls`/`cd`/`find`/`printf`/`history`/`arch`/`ifconfig`); else the flag-vetted
31
45
  * table with its per-row danger predicates, and the `uniq`/`jq` forms. A `cd` beside a `git` in one line
32
46
  * is never read-only (the git command's repository is then not the session's) unless the `cd` lands where the session
33
- * stands ({@link changesDirectoryBeforeGit}). Anything the reading does not positively admit is NOT read-only — the
34
- * answer is never a guess, and a `false` costs one question.
47
+ * stands ({@link changesDirectoryBeforeGit}). Given {@link ReadOnlyShellOptions.readers}, a program the line names
48
+ * outside them is an objection of its own, met where the reading meets the program. Anything the reading does not
49
+ * positively admit is NOT read-only — the answer is never a guess, and a `false` costs one question.
35
50
  */
36
- export declare function readOnlyShellVerdict(command: string, shape?: ShellCommandShape, cwd?: string, bound?: LineDescriptors | (() => LineDescriptors)): ReadOnlyShellVerdict;
51
+ export declare function readOnlyShellVerdict(command: string, opts?: ReadOnlyShellOptions): ReadOnlyShellVerdict;
37
52
  /**
38
53
  * The reader's LINE-level objection to `command` — what makes no segment of it read-only in its place, whatever the
39
54
  * segment reads on its own: the line's structure, or a `cd` beside a `git` ({@link changesDirectoryBeforeGit}). A caller