@sema-agent/core 7.33.7 → 7.34.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 (206) hide show
  1. package/CHANGELOG.md +357 -0
  2. package/dist/agents/cross-session-judge.d.ts +16 -7
  3. package/dist/agents/cross-session-judge.js +18 -15
  4. package/dist/agents/peer-session-drain.d.ts +4 -0
  5. package/dist/agents/peer-session-drain.js +5 -2
  6. package/dist/agents/repair-loop.d.ts +1 -1
  7. package/dist/agents/subagent.d.ts +3 -0
  8. package/dist/agents/subagent.js +12 -3
  9. package/dist/brain/anthropic.js +1 -0
  10. package/dist/brain/open-responses.js +1 -0
  11. package/dist/brain/openai.js +1 -0
  12. package/dist/brain/stream-engine.d.ts +3 -0
  13. package/dist/brain/stream-engine.js +2 -1
  14. package/dist/core/a2a.d.ts +3 -1
  15. package/dist/core/a2a.js +1 -1
  16. package/dist/core/ask-origin.d.ts +351 -154
  17. package/dist/core/ask-origin.js +223 -45
  18. package/dist/core/ask-unresolvable-notice.d.ts +5 -2
  19. package/dist/core/ask-unresolvable-notice.js +4 -3
  20. package/dist/core/auto-mode-arming.d.ts +17 -4
  21. package/dist/core/auto-mode-arming.js +31 -6
  22. package/dist/core/auto-mode-defaults.d.ts +16 -7
  23. package/dist/core/auto-mode-defaults.js +2 -1
  24. package/dist/core/auto-mode-directive.d.ts +18 -30
  25. package/dist/core/auto-mode-directive.js +9 -18
  26. package/dist/core/auto-mode-prompt.d.ts +62 -1
  27. package/dist/core/auto-mode-prompt.js +42 -4
  28. package/dist/core/auto-mode-rebuild.d.ts +2 -1
  29. package/dist/core/auto-mode-rebuild.js +1 -0
  30. package/dist/core/auto-mode.d.ts +32 -3
  31. package/dist/core/auto-mode.js +80 -41
  32. package/dist/core/checkpoint-store.d.ts +50 -35
  33. package/dist/core/checkpoint-store.js +58 -3
  34. package/dist/core/child-clamp-seats.js +1 -0
  35. package/dist/core/classifier-unavailable-arm.d.ts +57 -0
  36. package/dist/core/classifier-unavailable-arm.js +17 -0
  37. package/dist/core/effective-path-target.d.ts +12 -0
  38. package/dist/core/effective-path-target.js +18 -7
  39. package/dist/core/engine-notice.d.ts +53 -2
  40. package/dist/core/fs-write-gate-policy.d.ts +5 -1
  41. package/dist/core/fs-write-gate-policy.js +4 -1
  42. package/dist/core/gate-fold.js +17 -1
  43. package/dist/core/gate-lanes.js +111 -46
  44. package/dist/core/gate-outcome.d.ts +40 -7
  45. package/dist/core/gate-outcome.js +26 -4
  46. package/dist/core/governance-codes.d.ts +1 -1
  47. package/dist/core/governance-codes.js +12 -0
  48. package/dist/core/hooks.d.ts +50 -29
  49. package/dist/core/hooks.js +1 -3
  50. package/dist/core/mcp.js +1 -1
  51. package/dist/core/mode-policy.d.ts +182 -0
  52. package/dist/core/mode-policy.js +179 -0
  53. package/dist/core/oracle-isolation.d.ts +3 -3
  54. package/dist/core/permission-rule-model.d.ts +16 -3
  55. package/dist/core/permission-rule-model.js +36 -3
  56. package/dist/core/permission-rule-provider.d.ts +22 -11
  57. package/dist/core/permission-rule-provider.js +17 -7
  58. package/dist/core/permission-rules.d.ts +23 -1
  59. package/dist/core/permission-rules.js +35 -8
  60. package/dist/core/persisted-rule-arms.d.ts +10 -9
  61. package/dist/core/persisted-rule-arms.js +9 -6
  62. package/dist/core/present-plan-tool.d.ts +1 -1
  63. package/dist/core/present-plan-tool.js +3 -3
  64. package/dist/core/read-only-shell.d.ts +19 -19
  65. package/dist/core/read-only-shell.js +38 -12
  66. package/dist/core/retired-keys.d.ts +9 -3
  67. package/dist/core/retired-keys.js +5 -1
  68. package/dist/core/runner/classifier-ladder.d.ts +43 -1
  69. package/dist/core/runner/classifier-ladder.js +29 -0
  70. package/dist/core/runner/contracts.d.ts +37 -35
  71. package/dist/core/runner/denial-limit-arms.d.ts +30 -7
  72. package/dist/core/runner/denial-limit-arms.js +15 -9
  73. package/dist/core/runner/env-capability-census.js +1 -1
  74. package/dist/core/runner/gate-exit.d.ts +30 -4
  75. package/dist/core/runner/gate-exit.js +28 -7
  76. package/dist/core/runner/permission-rule-lanes.d.ts +9 -5
  77. package/dist/core/runner/permission-rule-lanes.js +44 -40
  78. package/dist/core/runner/persisted-path-rows.d.ts +64 -0
  79. package/dist/core/runner/persisted-path-rows.js +89 -0
  80. package/dist/core/runner/prepare-artifact.d.ts +5 -1
  81. package/dist/core/runner/prepare-artifact.js +9 -3
  82. package/dist/core/runner/prepare-ask-lane.d.ts +5 -3
  83. package/dist/core/runner/prepare-ask-lane.js +1 -1
  84. package/dist/core/runner/prepare-caps-and-workflow.d.ts +39 -13
  85. package/dist/core/runner/prepare-caps-and-workflow.js +58 -29
  86. package/dist/core/runner/prepare-gate-stations.d.ts +33 -6
  87. package/dist/core/runner/prepare-gate-stations.js +32 -19
  88. package/dist/core/runner/prepare-hands-readface.d.ts +40 -17
  89. package/dist/core/runner/prepare-hands-readface.js +64 -53
  90. package/dist/core/runner/prepare-inherited-gate.d.ts +22 -22
  91. package/dist/core/runner/prepare-inherited-gate.js +37 -23
  92. package/dist/core/runner/prepare-park-ask.d.ts +2 -2
  93. package/dist/core/runner/prepare-park-ask.js +2 -1
  94. package/dist/core/runner/prepare-policy-chain.d.ts +22 -2
  95. package/dist/core/runner/prepare-policy-chain.js +39 -12
  96. package/dist/core/runner/prepare-protocol-tools.d.ts +28 -0
  97. package/dist/core/runner/prepare-protocol-tools.js +13 -3
  98. package/dist/core/runner/prepare-run-refs.d.ts +6 -6
  99. package/dist/core/runner/prepare-run-refs.js +9 -4
  100. package/dist/core/runner/prepare-safety-scan.d.ts +5 -0
  101. package/dist/core/runner/prepare-safety-scan.js +19 -3
  102. package/dist/core/runner/prepare-suspend-saga.d.ts +5 -5
  103. package/dist/core/runner/prepare-suspend-saga.js +6 -6
  104. package/dist/core/runner/prepare-task.js +51 -22
  105. package/dist/core/runner/prepare-wiring-manifest.d.ts +10 -6
  106. package/dist/core/runner/prepare-wiring-manifest.js +5 -3
  107. package/dist/core/runner/read-root-grant.d.ts +9 -0
  108. package/dist/core/runner/read-root-grant.js +12 -0
  109. package/dist/core/runner/retired-runner-deps.js +1 -0
  110. package/dist/core/runner/run-identity-wiring.js +1 -1
  111. package/dist/core/runner/run-leg.js +24 -15
  112. package/dist/core/runner/run-notification-lane.js +1 -0
  113. package/dist/core/runner/run-telemetry-and-budget-seats.d.ts +2 -2
  114. package/dist/core/runner/run-telemetry-and-budget-seats.js +1 -1
  115. package/dist/core/runner/run-turn-boundary.js +1 -1
  116. package/dist/core/runner/runtask.js +3 -0
  117. package/dist/core/runner/stream-mode-verb.d.ts +27 -0
  118. package/dist/core/runner/stream-mode-verb.js +19 -0
  119. package/dist/core/runner/turn-attachments.d.ts +3 -3
  120. package/dist/core/runner/turn-attachments.js +1 -1
  121. package/dist/core/runner/write-protection-station.d.ts +7 -1
  122. package/dist/core/runner/write-protection-station.js +6 -3
  123. package/dist/core/runner-deps.d.ts +31 -34
  124. package/dist/core/runtime-caps.d.ts +12 -3
  125. package/dist/core/safety-axis-vocab.d.ts +2 -2
  126. package/dist/core/safety-axis-vocab.js +1 -1
  127. package/dist/core/secret-env.d.ts +16 -2
  128. package/dist/core/secret-env.js +25 -1
  129. package/dist/core/sensitive-path-policy.d.ts +4 -2
  130. package/dist/core/sensitive-path-policy.js +39 -30
  131. package/dist/core/spec-contract.d.ts +15 -0
  132. package/dist/core/spec-contract.js +19 -10
  133. package/dist/core/task-event.d.ts +1 -1
  134. package/dist/core/task-result.d.ts +1 -1
  135. package/dist/core/task-spec.d.ts +41 -89
  136. package/dist/core/task-stream.d.ts +16 -0
  137. package/dist/core/terminal-cause.js +1 -0
  138. package/dist/core/tighten-task-spec.d.ts +2 -2
  139. package/dist/core/tighten-task-spec.js +12 -14
  140. package/dist/core/tool-catalog-entries.js +4 -3
  141. package/dist/core/tool-policy.d.ts +54 -25
  142. package/dist/core/tool-policy.js +43 -2
  143. package/dist/core/tool-registry.d.ts +2 -0
  144. package/dist/core/tool-registry.js +3 -0
  145. package/dist/core/tool-spec.d.ts +8 -9
  146. package/dist/core/trace.d.ts +7 -6
  147. package/dist/core/types.d.ts +2 -1
  148. package/dist/core/wiring-manifest.d.ts +31 -5
  149. package/dist/core/wiring-manifest.js +28 -1
  150. package/dist/core/write-protect.d.ts +11 -1
  151. package/dist/core/write-protect.js +26 -15
  152. package/dist/engine/execution-env/kill-tree.js +4 -1
  153. package/dist/engine/execution-env/node-execution-env.d.ts +3 -0
  154. package/dist/engine/execution-env/node-execution-env.js +34 -35
  155. package/dist/engine/harness/types.d.ts +6 -7
  156. package/dist/engine/llm/types.d.ts +2 -4
  157. package/dist/engine/loop/types.d.ts +7 -0
  158. package/dist/index.d.ts +15 -5
  159. package/dist/index.js +12 -3
  160. package/dist/orchestration/run-spec.d.ts +4 -0
  161. package/dist/orchestration/run-spec.js +4 -3
  162. package/dist/orchestration/workflow-governance.js +1 -1
  163. package/dist/orchestration/workflow-primitives.d.ts +1 -1
  164. package/dist/orchestration/workflow-size-guideline.js +2 -2
  165. package/dist/stores/file/fs-atomic.js +2 -1
  166. package/dist/tools/artifact/artifact-tool.d.ts +17 -0
  167. package/dist/tools/artifact/artifact-tool.js +16 -1
  168. package/dist/tools/fs/bash-readonly-classifier.d.ts +28 -7
  169. package/dist/tools/fs/bash-readonly-classifier.js +88 -0
  170. package/dist/tools/fs/containment-face.d.ts +9 -3
  171. package/dist/tools/fs/containment-face.js +2 -0
  172. package/dist/tools/fs/file-read-check.d.ts +49 -0
  173. package/dist/tools/fs/file-read-check.js +71 -0
  174. package/dist/tools/fs/fs-bash.d.ts +3 -3
  175. package/dist/tools/fs/fs-read.d.ts +1 -0
  176. package/dist/tools/fs/fs-search-tools.d.ts +2 -2
  177. package/dist/tools/fs/fs-search-tools.js +4 -4
  178. package/dist/tools/fs/fs-shared.js +20 -6
  179. package/dist/tools/fs/fs-write.d.ts +4 -3
  180. package/dist/tools/fs/fs-write.js +9 -9
  181. package/dist/tools/fs/index.d.ts +39 -3
  182. package/dist/tools/fs/index.js +38 -15
  183. package/dist/tools/fs/read-deny-home.d.ts +93 -0
  184. package/dist/tools/fs/read-deny-home.js +225 -0
  185. package/dist/tools/fs/read-deny.d.ts +176 -34
  186. package/dist/tools/fs/read-deny.js +268 -61
  187. package/dist/tools/fs/safety.d.ts +6 -1
  188. package/dist/tools/fs/safety.js +18 -5
  189. package/dist/tools/fs/search.d.ts +3 -0
  190. package/dist/tools/fs/search.js +47 -9
  191. package/dist/tools/fs/shell-read-check.d.ts +42 -11
  192. package/dist/tools/fs/shell-read-check.js +188 -13
  193. package/dist/tools/fs/shell-read-establish.d.ts +7 -3
  194. package/dist/tools/fs/shell-read-establish.js +134 -21
  195. package/dist/tools/fs/shell-read-identity.d.ts +7 -0
  196. package/dist/tools/fs/shell-read-identity.js +21 -0
  197. package/dist/tools/fs/shell-read-judge.d.ts +92 -32
  198. package/dist/tools/fs/shell-read-judge.js +43 -18
  199. package/dist/tools/fs/shell-read-plan.d.ts +4 -0
  200. package/dist/tools/fs/shell-read-plan.js +26 -4
  201. package/dist/tools/fs/shell-read-write-targets.d.ts +73 -0
  202. package/dist/tools/fs/shell-read-write-targets.js +173 -0
  203. package/package.json +3 -1
  204. package/test/export-surface.snapshot.json +103 -11
  205. package/wire-changes/core@7.33.8.json +165 -0
  206. package/wire-changes/core@7.34.0.json +2621 -0
@@ -6,6 +6,7 @@ import { inlineUntrusted } from "./untrusted-text.js";
6
6
  import { pathTargetOf } from "./tool-registry.js";
7
7
  import { carriesDotSegment, isAbsoluteForFamily, isIdentityDir, isPathIdentity, isShellRootedSpellingUnmapped, isUncIdentity, pathFamilyOf, pathIdentityOf } from "../tools/fs/safety.js";
8
8
  import { readShellCommand } from "./shell-lexer.js";
9
+ import { readOnlyLineObjection, readOnlyShellVerdict } from "./read-only-shell.js";
9
10
  export const RULE_BEHAVIORS = ["deny", "ask", "allow"];
10
11
  const RULE_BEHAVIOR_SET = new Set(RULE_BEHAVIORS);
11
12
  export function isRuleBehavior(v) {
@@ -450,6 +451,29 @@ export function pathRuleReachOf(rule, target, bases) {
450
451
  const segments = target.split("/").filter((sg) => sg !== "");
451
452
  return globSegmentsReach(globs, segments) ? REACHED : NOT_REACHED;
452
453
  }
454
+ export function pathRuleHeadOf(rule, bases) {
455
+ const family = pathFamilyOf(bases);
456
+ const tree = bases.root ?? bases.cwd;
457
+ let head;
458
+ if (rule.match === "subpath") {
459
+ if (carriesDotSegment(rule.command, family))
460
+ return undefined;
461
+ const dir = pathIdentityOf(rule.command, family, tree);
462
+ return dir.ok && isIdentityDir(dir.identity, family) ? dir.identity : undefined;
463
+ }
464
+ else if (rule.match === "path") {
465
+ const r = resolvePathPattern(rule.command, bases, rule.behavior);
466
+ if ("missingBase" in r)
467
+ return undefined;
468
+ const parts = r.resolved.split("/");
469
+ const wild = parts.findIndex((part) => part.includes("*"));
470
+ head = (wild === -1 ? parts : parts.slice(0, wild)).join("/");
471
+ }
472
+ else
473
+ return undefined;
474
+ const id = pathIdentityOf(head === "" ? "/" : head, family, tree);
475
+ return id.ok ? id.identity : undefined;
476
+ }
453
477
  function hasUnescapedStar(s) {
454
478
  for (let i = 0; i < s.length; i++) {
455
479
  if (s[i] !== "*")
@@ -765,15 +789,22 @@ export function findAdmittingRule(rules, call) {
765
789
  return lone !== undefined ? [lone] : undefined;
766
790
  }
767
791
  const covering = [];
792
+ const lineObjection = readOnlyLineObjection(call.command, call.execCwd ?? call.cwd);
768
793
  for (const [i, segment] of shape.segments.entries()) {
769
794
  const dir = cdDirs[i];
770
795
  const first = firstEligibleAdmittingRule(rules, segment, call) ?? (dir !== undefined ? firstEligibleDirectoryRule(rules, dir, call) : undefined);
771
- if (first === undefined)
796
+ if (first === undefined) {
797
+ if (readsOnlyAsSegment(segment, lineObjection))
798
+ continue;
772
799
  return undefined;
800
+ }
773
801
  if (!covering.includes(first))
774
802
  covering.push(first);
775
803
  }
776
- return covering;
804
+ return covering.length > 0 ? covering : undefined;
805
+ }
806
+ function readsOnlyAsSegment(segment, lineObjection) {
807
+ return lineObjection === undefined && !movesShellCursor(segment) && readOnlyShellVerdict(segment).readOnly;
777
808
  }
778
809
  function firstEligibleDirectoryRule(rules, directory, call) {
779
810
  for (const rule of rules) {
@@ -855,11 +886,13 @@ export function segmentCoverageOf(command, rules, call) {
855
886
  return undefined;
856
887
  const proposed = rules.proposed ?? [];
857
888
  const cdDirs = resolveCdSegmentDirectories(shape.segments, call.execCwd ?? call.cwd);
889
+ const lineObjection = readOnlyLineObjection(folded, call.execCwd ?? call.cwd);
858
890
  return shape.segments.map((segment, i) => {
859
891
  const dir = cdDirs[i];
860
892
  return {
861
893
  segment: segment.trim(),
862
- covered: firstEligibleAdmittingRule(rules.persisted, segment, call) !== undefined ||
894
+ covered: readsOnlyAsSegment(segment, lineObjection) ||
895
+ firstEligibleAdmittingRule(rules.persisted, segment, call) !== undefined ||
863
896
  (dir !== undefined && firstEligibleDirectoryRule(rules.persisted, dir, call) !== undefined) ||
864
897
  proposed.some((p) => p.rule.tool === "Read"
865
898
  ? dir !== undefined &&
@@ -72,12 +72,16 @@ export interface EffectivePermissionRules {
72
72
  org: OrgRuleResolution;
73
73
  /** The durable partition's revision — the optimistic-concurrency anchor for its writes. */
74
74
  rev: number;
75
- /** Present iff the DURABLE partition could not be read: `rules`/`removed` are then empty and `rev` is 0,
76
- * while the org partition's resolution beside it still stands (a flaky personal store must not become
77
- * an org governance outage). A consumer that needs the durable rows treats this as a read failure —
78
- * {@link effectiveOrThrow} is that reading; the gate's personal lane reads it as "unreadable"
79
- * (zero rules + a disclosure, fail toward asking). */
75
+ /** Present iff a PERSONAL partition could not be read — the durable one (its user / project rows; `removed` is then
76
+ * empty and `rev` 0) or the session one: the failed partition's rows are absent from `rules`, while the rows of the
77
+ * partition that WAS read stand, and so does the org resolution beside them (a flaky personal store must not become an
78
+ * org governance outage). The line names the failure (both lines, `; `-joined, when both failed). A consumer that needs
79
+ * the durable rows treats this as a read failure — {@link effectiveOrThrow} is that reading; the gate's personal lane
80
+ * and the leg's path-row read skip the failed partition's rows, disclosed, and judge by the rest. */
80
81
  unreadable?: string;
82
+ /** Beside {@link unreadable}: the sources whose partition could not be read (`user` and `project` — the durable
83
+ * partition; `session`), in that order — the rows `rules` does not carry. */
84
+ unreadableSources?: readonly Exclude<RuleSource, "org">[];
81
85
  /** Disclosure lines from the personal partitions (a session partition that could not be read, rows it
82
86
  * served that are not canonical session rows of this session). The org partition's lines ride
83
87
  * `org.disclosures`. */
@@ -85,12 +89,11 @@ export interface EffectivePermissionRules {
85
89
  }
86
90
  /**
87
91
  * The host-visible store, already anchored to one owner. One read verb, which never throws: each
88
- * partition reports its own failure in the view — the DURABLE partition's read failure as `unreadable`
89
- * (zero rows; the caller maps it to "unreadable": a loud disclosure, fail toward asking, exactly as the
90
- * old `list()` throw did), the SESSION partition's read failure as `unreadable` too (it may hold a
91
- * deny/ask row of this session, and zero rows would erase it — served-but-non-canonical rows stay a
92
- * `disclosures` line), the ORG partition's as its `status`. The org verdict stands while a personal
93
- * partition is down, and the personal rows stand while an org source is down.
92
+ * partition reports its own failure in the view — a PERSONAL partition's read failure (the durable one's, the
93
+ * session one's) as `unreadable` / `unreadableSources`, with that partition's rows absent and the other's present
94
+ * (served-but-non-canonical session rows stay a `disclosures` line), the ORG partition's as its `status`. The unit
95
+ * that fails is the partition: the org verdict stands while a personal partition is down, each personal partition's
96
+ * rows stand while the other is down, and the personal rows stand while an org source is down.
94
97
  */
95
98
  export interface PermissionRuleStore {
96
99
  effective(query?: {
@@ -119,6 +122,14 @@ export interface PermissionRuleStoreProvider {
119
122
  session: boolean;
120
123
  }>;
121
124
  }
125
+ /**
126
+ * The store one task reads its person's rules from. The unauthenticated floor is enforced HERE, not merely asked of the
127
+ * provider: an absent principal resolves to zero personal rules, and a provider that forgot it would hand one person's
128
+ * standing approvals to an anonymous task. The ONE exception is an explicitly declared LOCAL-OWNER deployment, where "no
129
+ * principal" is the typed absence of identity — the machine owner's own bucket (a provider with no such face resolves
130
+ * the anonymous store; {@link createPermissionRuleLanes} refuses that declaration loudly).
131
+ */
132
+ export declare function ownerStoreOf(provider: PermissionRuleStoreProvider, principal: string | undefined, localOwnerDeclared: boolean): PermissionRuleStore;
122
133
  /** What {@link createPermissionRuleStoreProvider} composes. */
123
134
  export interface PermissionRuleStoreConfig {
124
135
  /** The durable CRDT partition (user + project sources). Absent ⇒ no personal rules exist on this
@@ -4,6 +4,10 @@ import { UNGOVERNED_ORG_RESOLUTION, compileOrgShadowPredicate, createOrgRulePart
4
4
  export function ruleSourceOf(scope) {
5
5
  return scope.kind === "global" ? "user" : scope.kind;
6
6
  }
7
+ export function ownerStoreOf(provider, principal, localOwnerDeclared) {
8
+ const anonymous = principal === undefined || principal === "";
9
+ return anonymous && localOwnerDeclared && provider.forLocalOwner !== undefined ? provider.forLocalOwner() : provider.forPrincipal(anonymous ? undefined : principal);
10
+ }
7
11
  function usableSessionId(sessionId) {
8
12
  return typeof sessionId === "string" && sessionId !== "" ? sessionId : undefined;
9
13
  }
@@ -61,16 +65,22 @@ class UnifiedPermissionRuleStore {
61
65
  this.readSessionRows(usableSessionId(query?.sessionId), disclosures),
62
66
  ]);
63
67
  const orgResolution = orgSettled;
64
- if ("unreadable" in sessionRows) {
65
- return { rules: [], removed: [], org: orgResolution, rev: 0, disclosures, unreadable: sessionRows.unreadable };
66
- }
68
+ const unreadable = [];
69
+ const unreadableSources = [];
67
70
  if (!durableSettled.ok) {
68
- return { rules: [], removed: [], org: orgResolution, rev: 0, disclosures, unreadable: errText(durableSettled.e) };
71
+ unreadable.push(errText(durableSettled.e));
72
+ unreadableSources.push("user", "project");
73
+ }
74
+ if ("unreadable" in sessionRows) {
75
+ unreadable.push(sessionRows.unreadable);
76
+ unreadableSources.push("session");
69
77
  }
70
- const durableRead = durableSettled.v;
78
+ const failed = unreadable.length > 0 ? { unreadable: unreadable.join("; "), unreadableSources } : {};
79
+ const durableRead = durableSettled.ok ? durableSettled.v : EMPTY_DURABLE;
80
+ const sessionRead = "unreadable" in sessionRows ? [] : sessionRows;
71
81
  const shadowed = compileOrgShadowPredicate(orgResolution.rules);
72
82
  const rules = [];
73
- for (const r of [...sessionRows, ...durableRead.rules]) {
83
+ for (const r of [...sessionRead, ...durableRead.rules]) {
74
84
  rules.push({ ...r, source: ruleSourceOf(r.scope), status: r.behavior === "allow" && shadowed(r) ? "shadowed-by-org" : "live" });
75
85
  }
76
86
  const removed = [];
@@ -83,7 +93,7 @@ class UnifiedPermissionRuleStore {
83
93
  continue;
84
94
  removed.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, source: ruleSourceOf(t.scope), status: "removed" });
85
95
  }
86
- return { rules, removed, org: orgResolution, rev: durableRead.rev, disclosures };
96
+ return { rules, removed, org: orgResolution, rev: durableRead.rev, disclosures, ...failed };
87
97
  }
88
98
  async readSessionRows(sessionId, disclosures) {
89
99
  if (this.session === undefined || sessionId === undefined)
@@ -47,7 +47,8 @@
47
47
  */
48
48
  import type { NamedToolPolicy } from "./tool-policy.js";
49
49
  import { parsePermissionRule, type ParsedPermissionRule } from "./permission-rule-syntax.js";
50
- import { type PathRuleBases } from "./permission-rule-model.js";
50
+ import { type ParsedRule, type PathRuleBases } from "./permission-rule-model.js";
51
+ import type { DenyFace, PathRuleDenyRow } from "../tools/fs/read-deny.js";
51
52
  export { parsePermissionRule, type ParsedPermissionRule };
52
53
  /** [K-PLATFORM-SWEEP] MINOR#11: Bash's REAL non-primary arg names, accepted as generic param rules
53
54
  * (`Bash(timeout:...)`). Exported SOLELY so a lockstep test can assert it against the live Bash tool
@@ -190,6 +191,27 @@ export interface RuleFaceView {
190
191
  export declare function catalogRuleFaceView(toolName: string): RuleFaceView | undefined;
191
192
  /** Exported for the lockstep guard only (the scalar vocabulary of a catalog tool) — not part of the rule DSL. */
192
193
  export declare const fileToolParamVocabulary: (canonicalTool: string) => ReadonlySet<string> | undefined;
194
+ /**
195
+ * The path DENY rules that speak for a FACE rather than for one tool (the reference implementation's reading: a Read
196
+ * deny rule covers every built-in tool that reads a file and the shell's file commands; an Edit deny rule every tool
197
+ * that edits one; a Read deny also refuses writing the same path). Such a rule reaches any call whose declared path
198
+ * target is on one of its faces — the write tools, the read tools, the search roots — and it is exported to the
199
+ * engine's deny tables (`ToolPolicy.pathDenyRules`) so the shell's reads and writes and the
200
+ * traversals meet it too. A path rule on any other tool keeps its tool's name as its reach (`Write(<path>)` is judged on
201
+ * the Write tool, a stricter reading the reference does not make).
202
+ */
203
+ export declare const PATH_DENY_RULE_FACES: Readonly<Record<string, readonly DenyFace[]>>;
204
+ /**
205
+ * The deny-table row of ONE face-keyed path deny rule, resolved against `bases` — the row's reach IS the rule's (the same
206
+ * parsed rule, the same bases). Every source of such rows builds them here: a policy's compiled rules, and the person's
207
+ * persisted store rows. The caller holds the bases the pattern needs (`ruleBasesNeeded`): a row whose base is missing
208
+ * reads `unreadable`, which this row treats as a reach — a deny row that cannot be judged holds.
209
+ */
210
+ export declare function pathRuleDenyRowOf(rule: {
211
+ readonly parsed: ParsedRule;
212
+ readonly ruleText: string;
213
+ readonly faces: readonly DenyFace[];
214
+ }, bases: PathRuleBases): PathRuleDenyRow;
193
215
  /** Dry-run 校验/迁移报告:不 throw,返回全部 issues(含 `unsupported.*` 分类,
194
216
  * 供从 CC settings 迁移的部署逐条对照)。 */
195
217
  export declare function validatePermissionRules(rules: PermissionRule[], opts?: Pick<PermissionRulePolicyOptions, "primaryFieldGeneric" | "caps" | "ruleFaces" | "pathBases">): PermissionRuleIssue[];
@@ -1,8 +1,8 @@
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 { PATH_RULE_BASE_LABEL, isUsablePathBase, parseRuleText, pathRuleReachOf, programRunReachOf, ruleBasesNeeded } from "./permission-rule-model.js";
5
- import { pathFamilyOf } from "../tools/fs/safety.js";
4
+ import { PATH_RULE_BASE_LABEL, READ_RULE_TOOL, isUsablePathBase, parseRuleText, pathRuleHeadOf, pathRuleReachOf, programRunReachOf, ruleBasesNeeded } from "./permission-rule-model.js";
5
+ import { pathFamilyOf, pathIdentityOf, spellingOfIdentity } from "../tools/fs/safety.js";
6
6
  import { effectivePathTargetOf } from "./effective-path-target.js";
7
7
  import { protectivePathTargetOf } from "./tool-registry.js";
8
8
  import { ruleDenialSentence } from "./rule-denial-sentence.js";
@@ -81,6 +81,27 @@ export const fileToolParamVocabulary = (canonicalTool) => {
81
81
  const face = catalogRuleFaceOf(canonicalTool);
82
82
  return face?.params === undefined || pathTargetOf(canonicalTool) === undefined ? undefined : new Set(face.params);
83
83
  };
84
+ export const PATH_DENY_RULE_FACES = Object.freeze({ [READ_RULE_TOOL]: ["read", "write"], Edit: ["write"] });
85
+ export function pathRuleDenyRowOf(rule, bases) {
86
+ const family = pathFamilyOf(bases);
87
+ const tree = bases.root ?? bases.cwd;
88
+ const head = pathRuleHeadOf(rule.parsed, bases);
89
+ return {
90
+ rule: rule.ruleText,
91
+ tool: rule.parsed.tool,
92
+ pattern: parsePermissionRule(rule.ruleText).ruleContent ?? "",
93
+ faces: rule.faces,
94
+ reaches: (absolutePath) => {
95
+ const id = pathIdentityOf(absolutePath, family, tree);
96
+ return id.ok && pathRuleReachOf(rule.parsed, id.identity, bases).reach !== "not_reached";
97
+ },
98
+ ...(head !== undefined ? { head: spellingOfIdentity(head, family) } : {}),
99
+ };
100
+ }
101
+ function callPathFaceOf(req) {
102
+ const target = protectivePathTargetOf(req);
103
+ return target === undefined ? undefined : target.access === "read" ? "read" : "write";
104
+ }
84
105
  function countStars(s) {
85
106
  let n = 0;
86
107
  for (const c of s)
@@ -196,7 +217,8 @@ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
196
217
  }
197
218
  if (issues.length > 0 && issues[issues.length - 1].rule === text && issues[issues.length - 1].code === "unsupported.path_base")
198
219
  continue;
199
- entry.content[r.behavior].push({ parsed: parsedPath.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
220
+ const faces = r.behavior === "deny" ? PATH_DENY_RULE_FACES[toolName] : undefined;
221
+ entry.content[r.behavior].push({ parsed: parsedPath.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}), ...(faces !== undefined ? { faces } : {}) });
200
222
  byTool.set(toolName, entry);
201
223
  continue;
202
224
  }
@@ -317,6 +339,8 @@ export function createPermissionRulePolicy(rules, opts) {
317
339
  }
318
340
  const coveringHit = (lane, toolName) => lane.length === 0 ? undefined : lane.find((r) => namespacedRuleNameCovers(r.ruleName, toolName));
319
341
  const hasCovering = namespacedCovering.deny.length > 0 || namespacedCovering.ask.length > 0 || namespacedCovering.allow.length > 0;
342
+ const faceDenyRules = [...byTool.values()].flatMap((e) => e.content.deny.filter((r) => r.faces !== undefined));
343
+ const pathDenyRules = faceDenyRules.map((r) => pathRuleDenyRowOf({ parsed: r.parsed, ruleText: r.ruleText, faces: r.faces }, pathBases));
320
344
  const commandParam = catalogRuleFaceOf("Bash")?.primaryParams[0] ?? "command";
321
345
  const contentHit = (lane, req) => {
322
346
  if (lane.length === 0)
@@ -349,14 +373,15 @@ export function createPermissionRulePolicy(rules, opts) {
349
373
  }
350
374
  return unreadable;
351
375
  };
352
- const unreadableAsk = (hit, req) => ({
376
+ const unreadableAsk = (hit, lane, req) => ({
353
377
  action: "ask",
354
378
  message: `tool "${req.toolName}" needs approval: the call could not be read against the permission rule ${hit.rule.ruleText} (${hit.unreadable}) — a person must decide`,
355
- matchedAskRule: hit.rule.ruleText,
379
+ ruleUnreadable: lane,
356
380
  requiresRealApproval: true,
357
381
  });
358
382
  return {
359
383
  nameSets: [nameSets],
384
+ ...(pathDenyRules.length > 0 ? { pathDenyRules } : {}),
360
385
  check(req) {
361
386
  const entry = byTool.get(req.toolName);
362
387
  const covering = hasCovering && protocolOf(req.toolName) !== undefined;
@@ -368,7 +393,9 @@ export function createPermissionRulePolicy(rules, opts) {
368
393
  if (coveringDeny)
369
394
  return { action: "deny", message: ruleDenial(req, coveringDeny.ruleText, coveringDeny.source) };
370
395
  }
371
- const contentDeny = entry !== undefined ? contentHit(entry.content.deny, req) : undefined;
396
+ const callFace = faceDenyRules.length > 0 ? callPathFaceOf(req) : undefined;
397
+ const denyLane = [...(entry?.content.deny.filter((r) => r.faces === undefined) ?? []), ...(callFace !== undefined ? faceDenyRules.filter((r) => r.faces.includes(callFace)) : [])];
398
+ const contentDeny = denyLane.length > 0 ? contentHit(denyLane, req) : undefined;
372
399
  if (contentDeny !== undefined && contentDeny.unreadable === undefined) {
373
400
  return { action: "deny", message: ruleDenial(req, contentDeny.rule.ruleText, contentDeny.rule.source) };
374
401
  }
@@ -379,7 +406,7 @@ export function createPermissionRulePolicy(rules, opts) {
379
406
  }
380
407
  }
381
408
  if (contentDeny?.unreadable !== undefined)
382
- return unreadableAsk({ rule: contentDeny.rule, unreadable: contentDeny.unreadable }, req);
409
+ return unreadableAsk({ rule: contentDeny.rule, unreadable: contentDeny.unreadable }, "content_deny", req);
383
410
  if (entry) {
384
411
  if (entry.bare.ask) {
385
412
  return { action: "ask", message: ruleAskMessage(entry.bare.ask.ruleText, entry.bare.ask.source), matchedAskRule: entry.bare.ask.ruleText };
@@ -394,7 +421,7 @@ export function createPermissionRulePolicy(rules, opts) {
394
421
  const contentAsk = contentHit(entry.content.ask, req);
395
422
  if (contentAsk !== undefined) {
396
423
  if (contentAsk.unreadable !== undefined)
397
- return unreadableAsk({ rule: contentAsk.rule, unreadable: contentAsk.unreadable }, req);
424
+ return unreadableAsk({ rule: contentAsk.rule, unreadable: contentAsk.unreadable }, "content_ask", req);
398
425
  return { action: "ask", message: ruleAskMessage(contentAsk.rule.ruleText, contentAsk.rule.source), matchedAskRule: contentAsk.rule.ruleText };
399
426
  }
400
427
  const paramAsk = matchParamRules(entry.param.ask, req.args, caps.maxScalarValueChars);
@@ -17,9 +17,10 @@ import type { PersistedRuleHit, PersistedRuleHitRule } from "./hooks.js";
17
17
  * produces it; the arms below read it). */
18
18
  export interface PersistedRuleRead {
19
19
  readonly hit?: PersistedRuleHit;
20
+ /** The lane could not answer: with `reason`, the CALL could not be read against the person's deny/ask rows;
21
+ * without, the store could not be read — its rows take no part in the decision (the record says so). */
20
22
  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
+ /** The lexer's reason when the CALL could not be read against the person's deny/ask rows. */
23
24
  readonly reason?: string;
24
25
  }
25
26
  /** Display form of a rule set (design/375 §5.1): every rule text is a DISPLAY value on a human trust
@@ -45,14 +46,14 @@ export declare function disclosedRuleSet(rules: readonly PersistedRuleHitRule[])
45
46
  * ask, the marker is stamped if absent and the message names the rule — the standing question
46
47
  * outranks a remembered yes across lanes, exactly as the DSL's own order says within one; a deny
47
48
  * stays a deny.
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
52
- * allow tightens to an ask carrying `requiresRealApproval` (no automatic lane clears it; the gate
49
+ * · UNREADABLE CALL — the store was read, but the CALL could not be read against the person's standing
50
+ * deny/ask rows (`reason` carries the lexer's word: an expansion where a rule reads a word, an
51
+ * unterminated quote, a syntax error): whether a row forbids this call is unknown, so it fails CLOSED —
52
+ * an allow tightens to an ask carrying `requiresRealApproval` (no automatic lane clears it; the gate
53
53
  * derives the origin `rule_store_unavailable` from its own record of the read), an ask acquires the
54
- * bit, a deny stays a deny. Reading an unreadable store as "no rule" would erase every deny the
55
- * person wrote the moment their file is locked or corrupt.
54
+ * bit, a deny stays a deny.
55
+ * A store that could not be READ (`unreadable` without a `reason`) contributes no row: the decision is returned
56
+ * untouched, as for no hit — the lane announces the skipped store, and the rows apply again once a read succeeds.
56
57
  * An allow hit (or no hit) returns the decision untouched: the allow arm is the station's.
57
58
  */
58
59
  export declare function applyPersistedTightening(decision: PermissionResult, read: PersistedRuleRead | undefined): {
@@ -1,3 +1,4 @@
1
+ import { decisionAskClassesOf } from "./ask-origin.js";
1
2
  import { inlineUntrusted } from "./untrusted-text.js";
2
3
  export function disclosedRuleSet(rules) {
3
4
  const shown = rules.slice(0, 5).map((r) => inlineUntrusted(r.rule, 200));
@@ -5,16 +6,18 @@ export function disclosedRuleSet(rules) {
5
6
  return rest > 0 ? `${shown.join(", ")} and ${rest} more` : shown.join(", ");
6
7
  }
7
8
  export function applyPersistedTightening(decision, read) {
8
- if (read?.unreadable === true) {
9
+ if (read?.unreadable === true && read.reason !== undefined) {
9
10
  if (decision.action === "deny")
10
11
  return { decision };
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
+ const message = `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
+ const unreadableCall = { ruleUnreadable: "persisted_call" };
14
+ const standingFence = decision.action === "ask" && decisionAskClassesOf({ ...decision, foldedAskClasses: undefined }).has("policy_rra")
15
+ ? { foldedAskClasses: [...(decision.foldedAskClasses ?? []), "policy_rra"] }
16
+ : {};
14
17
  return {
15
18
  decision: decision.action === "allow"
16
- ? { action: "ask", message, decisionReason: "persisted_rule", requiresRealApproval: true, ...(decision.updatedInput !== undefined ? { updatedInput: decision.updatedInput } : {}) }
17
- : { ...decision, requiresRealApproval: true, message: `${decision.message !== undefined ? `${decision.message} ` : ""}(${message})` },
19
+ ? { action: "ask", message, decisionReason: "persisted_rule", requiresRealApproval: true, ...unreadableCall, ...(decision.updatedInput !== undefined ? { updatedInput: decision.updatedInput } : {}) }
20
+ : { ...decision, requiresRealApproval: true, ...unreadableCall, ...standingFence, message: `${decision.message !== undefined ? `${decision.message} ` : ""}(${message})` },
18
21
  tightened: "unreadable",
19
22
  };
20
23
  }
@@ -13,7 +13,7 @@ export declare const ENTER_PLAN_MODE_TOOL_NAME = "EnterPlanMode";
13
13
  * making enter execute (set the flag) BEFORE the sibling edit is prepared+gated — closing the same-batch window.
14
14
  * Exit is via `present_plan` (→ plan_review → human approve-resume), not by this tool.
15
15
  */
16
- export declare function createEnterPlanModeTool(enterPlanMode: () => void): ToolSpec;
16
+ export declare function createEnterPlanModeTool(enterPlanMode: () => Promise<void> | void): ToolSpec;
17
17
  /**
18
18
  * design/80 D-B — the first-party `present_plan` tool (CC `ExitPlanMode` parity). A THIN composition over the
19
19
  * general `ctx.requestReview()` primitive: the model calls it with a plan; the tool returns the plan as its
@@ -3,7 +3,7 @@ import { delimitUntrusted } from "./untrusted-text.js";
3
3
  import { toolFace } from "./tool-catalog-entries.js";
4
4
  export const PRESENT_PLAN_TOOL_NAME = "ExitPlanMode";
5
5
  export const ENTER_PLAN_MODE_TOOL_NAME = "EnterPlanMode";
6
- const ENTER_PLAN_MODE_DESCRIPTION = `Enter PLAN MODE (read-only): stop making changes and switch to exploration/planning. For the rest of this run, write/edit/mutating tool calls are REJECTED while read-only tools (Read, Grep, Glob, etc.) still work. When your plan is ready, call ${PRESENT_PLAN_TOOL_NAME} with it — the run PAUSES for a human to approve it, then continues (read-only lifted) so you can execute.
6
+ const ENTER_PLAN_MODE_DESCRIPTION = `Enter PLAN MODE (read-only): stop making changes and switch to exploration/planning. For the rest of this run, every write/edit/mutating tool call ASKS the user first ("Cannot write to <path> while in plan mode."; where nobody can answer, it is refused) while read-only tools (Read, Grep, Glob, etc.) still work. When your plan is ready, call ${PRESENT_PLAN_TOOL_NAME} with it — the run PAUSES for a human to approve it, then continues (read-only lifted) so you can execute.
7
7
 
8
8
  Use this tool proactively when you're about to start a non-trivial implementation task. If you would use AskUserQuestion to clarify the approach, use EnterPlanMode instead — getting alignment on the plan itself is the point.
9
9
 
@@ -37,8 +37,8 @@ export function createEnterPlanModeTool(enterPlanMode) {
37
37
  parameters: Type.Object({}),
38
38
  executionMode: "sequential",
39
39
  execute: async () => {
40
- enterPlanMode();
41
- const message = "Entered plan mode (read-only). Write/edit/mutating tools are now rejected for the rest of this run; " +
40
+ await enterPlanMode();
41
+ const message = "Entered plan mode (read-only). Every write/edit/mutating tool call now asks the user first for the rest of this run; " +
42
42
  `research with read-only tools, then call ${PRESENT_PLAN_TOOL_NAME} with your plan to get it approved before acting.`;
43
43
  return {
44
44
  content: message,
@@ -1,19 +1,3 @@
1
- /**
2
- * The READ-ONLY shell reader — "does this command line provably only READ?" — the allow-layer member
3
- * the tool gate consults for the shell tool after every tightening lane and after the person's own allow
4
- * rules, and before the classifier or a person is asked (`gate-lanes.ts`). Upstream (CC 2.1.250) has the
5
- * same station: its shell tool's `isReadOnly` runs the command through a parser, splits it into its
6
- * simple commands, and admits the whole line only when EVERY simple command is read-only by its tables;
7
- * this module is that reading over this engine's own lexer ({@link readShellCommand}, the tightening
8
- * reader's) and the transcribed tables (`read-only-shell-table.ts`).
9
- *
10
- * The reading itself is the contract on {@link readOnlyShellVerdict}.
11
- *
12
- * What the verdict is NOT: a security boundary of its own. The deny/ask lanes run before it and win; the
13
- * mandated asks (an operator's per-call shell confirmation, a tool's own egress/irreversibility marks, an
14
- * explicit ask rule, a hook's ask, governance) are never cleared by it — the gate's allow-layer predicate
15
- * decides where it may speak, exactly as it decides for a person's stored allow rule.
16
- */
17
1
  import { type ShellCommandShape } from "./shell-lexer.js";
18
2
  /** The reader's answer: read-only, or not — with the first reason the reading stopped on (a sentence a
19
3
  * trace or a card can show; never adjudication input). */
@@ -23,6 +7,14 @@ export type ReadOnlyShellVerdict = {
23
7
  readonly readOnly: false;
24
8
  readonly reason: string;
25
9
  };
10
+ /** The reference implementation's question for a compound that changes directory and runs git (2.1.280, `cd-git-compound`). */
11
+ 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.";
12
+ /**
13
+ * Does this line change directory and run git — a `cd` beside a `git` — other than by ONE plain `cd <path>` that lands
14
+ * where the session stands (`cwd`)? The reference's no-op reading, lexical here: the path is `.` / `..`, starts `./` /
15
+ * `../` / `/`, carries no expansion, quote-removed text only, and resolves to `cwd` itself. No `cwd` ⇒ no no-op.
16
+ */
17
+ export declare function changesDirectoryBeforeGit(shape: ShellCommandShape, cwd?: string): boolean;
26
18
  /**
27
19
  * Read `command` as the allow layer must: read-only, or the first reason it is not. A shape already read by
28
20
  * the caller may be handed in (the gate reads every shell command once).
@@ -36,7 +28,15 @@ export type ReadOnlyShellVerdict = {
36
28
  * admitted only under {@link READ_ONLY_GLOB_PROGRAMS}; else the structural reading (the bare-program set,
37
29
  * the exact forms, `echo`/`ls`/`cd`/`find`/`printf`/`history`/`arch`/`ifconfig`); else the flag-vetted
38
30
  * table with its per-row danger predicates, and the `uniq`/`jq` forms. A `cd` beside a `git` in one line
39
- * is never read-only (the git command's repository is then not the session's). Anything the reading does
40
- * not positively admit is NOT read-only — the answer is never a guess, and a `false` costs one question.
31
+ * is never read-only (the git command's repository is then not the session's) unless the `cd` lands where the session
32
+ * stands ({@link changesDirectoryBeforeGit}). Anything the reading does not positively admit is NOT read-only — the
33
+ * answer is never a guess, and a `false` costs one question.
34
+ */
35
+ export declare function readOnlyShellVerdict(command: string, shape?: ShellCommandShape, cwd?: string): ReadOnlyShellVerdict;
36
+ /**
37
+ * The reader's LINE-level objection to `command` — what makes no segment of it read-only in its place, whatever the
38
+ * segment reads on its own: the line's structure, or a `cd` beside a `git` ({@link changesDirectoryBeforeGit}). A caller
39
+ * that judges a compound's segments one by one ({@link readOnlyShellVerdict} over a segment's text) asks this of the
40
+ * line first.
41
41
  */
42
- export declare function readOnlyShellVerdict(command: string, shape?: ShellCommandShape): ReadOnlyShellVerdict;
42
+ export declare function readOnlyLineObjection(command: string, cwd?: string): string | undefined;
@@ -1,6 +1,22 @@
1
+ import { posix } from "node:path";
1
2
  import { readShellCommand } from "./shell-lexer.js";
2
3
  import { FIND_ACTION_PRIMARIES, FIND_NEWER_PRIMARY, FIND_VALUE_PRIMARIES, FLAG_VALUE_ACCEPTS, READ_ONLY_BARE_ONLY, READ_ONLY_BARE_PROGRAMS, READ_ONLY_COMMAND_TABLE, READ_ONLY_ENV_NAMES, READ_ONLY_EXACT_FORMS, READ_ONLY_GLOB_PROGRAMS, XARGS_READ_ONLY_TARGETS, dockerRetargetDanger, } from "./read-only-shell-table.js";
3
4
  const NOT = (reason) => ({ readOnly: false, reason });
5
+ export 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.";
6
+ export function changesDirectoryBeforeGit(shape, cwd) {
7
+ const programs = shape.segments.map((s) => peelBuiltinPrefix(s.argv.slice(leadingAssignmentCount(s.argv)).map((w) => w.text))[0] ?? "");
8
+ if (shape.segments.length < 2 || !programs.includes("cd") || !programs.includes("git"))
9
+ return false;
10
+ const cds = shape.segments.filter((_, i) => programs[i] === "cd");
11
+ const [cd] = cds;
12
+ if (cwd === undefined || cds.length !== 1 || cd === undefined || cd.redirections.length > 0 || cd.argv.length !== 2 || cd.argv[0].raw !== "cd")
13
+ return true;
14
+ const target = cd.argv[1];
15
+ if (target.expands !== false || !/^(?:\.\.?(?:\/|$)|\/)/.test(target.text) || /[$`*?[\\]/.test(target.raw))
16
+ return true;
17
+ const trim = (p) => p.replace(/\/+$/, "") || "/";
18
+ return trim(posix.resolve(cwd, target.text)) !== trim(cwd);
19
+ }
4
20
  const READ_ONLY = { readOnly: true };
5
21
  const BARE_PROGRAMS = new Set(READ_ONLY_BARE_PROGRAMS.filter((p) => !p.includes(" ")));
6
22
  const BARE_MULTIWORD = READ_ONLY_BARE_PROGRAMS.filter((p) => p.includes(" ")).map((p) => p.split(" "));
@@ -23,15 +39,10 @@ const PRINTF_ESCAPE = new RegExp(`%[^%a-zA-Z]*${PRINTF_LENGTH}\\\\[0-7xX]`);
23
39
  const PRINTF_NUMERIC = new RegExp(`%[-+ 0#']*[0-9.*]*${PRINTF_LENGTH}[diouxXeEfFgGaAn]`);
24
40
  const JQ_DANGER_FLAG = /^['"]?-[a-zA-Z]*[fL]/;
25
41
  const JQ_DANGER_TEXT = /--from-file|--rawfile|--slurpfile|--run-tests|--library-path|\benv\b|\$ENV\b|\binclude\b|\bimport\b/;
26
- export function readOnlyShellVerdict(command, shape = readShellCommand(command)) {
27
- if (shape.grouped)
28
- return NOT("a subshell, group or control structure — not a flat list of simple commands");
29
- if (shape.segments.length === 0)
30
- return NOT("no command");
31
- if (shape.backgrounded)
32
- return NOT("a background `&` defers execution past the approval-time reading");
33
- if (shape.strayRedirection)
34
- return NOT("a redirection on a piece that runs no program — the shell still opens its target");
42
+ export function readOnlyShellVerdict(command, shape = readShellCommand(command), cwd) {
43
+ const line = lineStructureObjection(shape);
44
+ if (line !== undefined)
45
+ return NOT(line);
35
46
  for (const seg of shape.segments) {
36
47
  if (seg.unreadable !== undefined)
37
48
  return NOT(seg.unreadable);
@@ -46,9 +57,8 @@ export function readOnlyShellVerdict(command, shape = readShellCommand(command))
46
57
  return NOT("a shell-specific expansion spelling (`=cmd`, `~[…]`)");
47
58
  }
48
59
  }
49
- const programs = shape.segments.map((s) => peelBuiltinPrefix(s.argv.slice(leadingAssignmentCount(s.argv)).map((w) => w.text))[0] ?? "");
50
- if (shape.segments.length > 1 && programs.includes("cd") && programs.includes("git"))
51
- return NOT("a compound that changes directory and runs git");
60
+ if (changesDirectoryBeforeGit(shape, cwd))
61
+ return NOT(CD_GIT_REASON);
52
62
  for (const seg of shape.segments) {
53
63
  const verdict = segmentVerdict(seg);
54
64
  if (!verdict.readOnly)
@@ -56,6 +66,22 @@ export function readOnlyShellVerdict(command, shape = readShellCommand(command))
56
66
  }
57
67
  return READ_ONLY;
58
68
  }
69
+ const CD_GIT_REASON = "a compound that changes directory and runs git";
70
+ function lineStructureObjection(shape) {
71
+ if (shape.grouped)
72
+ return "a subshell, group or control structure — not a flat list of simple commands";
73
+ if (shape.segments.length === 0)
74
+ return "no command";
75
+ if (shape.backgrounded)
76
+ return "a background `&` defers execution past the approval-time reading";
77
+ if (shape.strayRedirection)
78
+ return "a redirection on a piece that runs no program — the shell still opens its target";
79
+ return undefined;
80
+ }
81
+ export function readOnlyLineObjection(command, cwd) {
82
+ const shape = readShellCommand(command);
83
+ return lineStructureObjection(shape) ?? (changesDirectoryBeforeGit(shape, cwd) ? CD_GIT_REASON : undefined);
84
+ }
59
85
  function expansionKind(w) {
60
86
  if (w.expands === false)
61
87
  return "none";
@@ -12,8 +12,14 @@ export declare function retiredKeyOf(bag: object, table: Readonly<Record<string,
12
12
  replacement: string;
13
13
  } | undefined;
14
14
  /**
15
- * The deps-bag door: a retired seat still wired by a deployment is refused BY NAME (`config.deps_retired_key`)
16
- * instead of vanishing silently — a seat that is silently dropped reads to the deployment as wired. `owner` is
17
- * the bag's type name for the sentence (`RunnerDeps`, `RunWorkflowToolDeps`).
15
+ * The retired-key door: a retired seat still wired by a deployment is refused BY NAME (`config.deps_retired_key`
16
+ * on a deps bag, `config.spec_retired_key` on a `TaskSpec`) instead of vanishing silently — a seat that is silently
17
+ * dropped reads to the caller as wired. `owner` is the bag's type name for the sentence (`RunnerDeps`,
18
+ * `RunWorkflowToolDeps`, `TaskSpec`).
18
19
  */
19
20
  export declare function assertNoRetiredKeys(bag: object, table: Readonly<Record<string, string>>, owner: string): void;
21
+ /** The RETIRED `TaskSpec` keys, each with the sentence that names its replacement — the table the spec door reads
22
+ * ({@link assertNoRetiredKeys} with owner `TaskSpec`). A caller still writing one of these is refused by name
23
+ * (`config.spec_retired_key`), never read as absence: a mode a caller believes it declared must not silently run
24
+ * as another. */
25
+ export declare const RETIRED_TASK_SPEC_KEYS: Readonly<Record<string, string>>;
@@ -10,6 +10,10 @@ export function assertNoRetiredKeys(bag, table, owner) {
10
10
  if (hit === undefined)
11
11
  return;
12
12
  const e = new Error(`${owner}.${hit.key} is retired and is not read by this engine — ${hit.replacement}. Refused rather than ignored: a seat that is silently dropped reads to the deployment as wired.`);
13
- e.code = "config.deps_retired_key";
13
+ e.code = owner === "TaskSpec" ? "config.spec_retired_key" : "config.deps_retired_key";
14
14
  throw e;
15
15
  }
16
+ export const RETIRED_TASK_SPEC_KEYS = Object.freeze({
17
+ autoModeRequested: 'the permission mode is one key now — write `permissionMode: "auto"`',
18
+ approverPosture: 'the permission mode is one key now — write `permissionMode: "bypassPermissions"` (the engine renders the posture from the effective mode)',
19
+ });