@intentius/chant 0.100.0 → 0.101.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 (276) hide show
  1. package/dist/build.d.ts +6 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/build-options.d.ts +2 -0
  4. package/dist/cli/build-options.d.ts.map +1 -1
  5. package/dist/cli/commands/build.d.ts.map +1 -1
  6. package/dist/cli/commands/import.d.ts.map +1 -1
  7. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/mcp/workspace-tools.d.ts +8 -0
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +22 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/config.d.ts +11 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +24 -1
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  18. package/dist/lifecycle/plan-digest.d.ts +26 -5
  19. package/dist/lifecycle/plan-digest.d.ts.map +1 -1
  20. package/dist/lint/config.d.ts +4 -4
  21. package/dist/op/activities/activity-contracts.d.ts +1 -0
  22. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  23. package/dist/op/activities/propose-upgrade.d.ts +2 -0
  24. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  25. package/dist/op/index.d.ts +1 -1
  26. package/dist/op/index.d.ts.map +1 -1
  27. package/dist/serializer.d.ts +8 -0
  28. package/dist/serializer.d.ts.map +1 -1
  29. package/dist/telemetry-attribution.d.ts +77 -0
  30. package/dist/telemetry-attribution.d.ts.map +1 -0
  31. package/dist/workspace/agent-cli.d.ts +83 -0
  32. package/dist/workspace/agent-cli.d.ts.map +1 -0
  33. package/dist/workspace/changes-cli.d.ts.map +1 -1
  34. package/dist/workspace/changes.d.ts +8 -1
  35. package/dist/workspace/changes.d.ts.map +1 -1
  36. package/dist/workspace/checks/links.d.ts +1 -0
  37. package/dist/workspace/checks/links.d.ts.map +1 -1
  38. package/dist/workspace/checks/live.d.ts +40 -0
  39. package/dist/workspace/checks/live.d.ts.map +1 -0
  40. package/dist/workspace/checks.d.ts +21 -2
  41. package/dist/workspace/checks.d.ts.map +1 -1
  42. package/dist/workspace/compose-graph.d.ts +63 -0
  43. package/dist/workspace/compose-graph.d.ts.map +1 -1
  44. package/dist/workspace/decide.d.ts +1 -1
  45. package/dist/workspace/decide.d.ts.map +1 -1
  46. package/dist/workspace/declaration.d.ts +32 -0
  47. package/dist/workspace/declaration.d.ts.map +1 -1
  48. package/dist/workspace/declaration.schema.json +138 -3
  49. package/dist/workspace/export-cli.d.ts +12 -0
  50. package/dist/workspace/export-cli.d.ts.map +1 -0
  51. package/dist/workspace/export.d.ts +145 -0
  52. package/dist/workspace/export.d.ts.map +1 -0
  53. package/dist/workspace/graph-cli.d.ts.map +1 -1
  54. package/dist/workspace/import.d.ts +73 -0
  55. package/dist/workspace/import.d.ts.map +1 -0
  56. package/dist/workspace/kinds.d.ts +6 -2
  57. package/dist/workspace/kinds.d.ts.map +1 -1
  58. package/dist/workspace/lineage-adopt-cli.d.ts +15 -0
  59. package/dist/workspace/lineage-adopt-cli.d.ts.map +1 -0
  60. package/dist/workspace/lineage-adopt.d.ts +106 -0
  61. package/dist/workspace/lineage-adopt.d.ts.map +1 -0
  62. package/dist/workspace/lineage-check.d.ts +9 -2
  63. package/dist/workspace/lineage-check.d.ts.map +1 -1
  64. package/dist/workspace/lineage-cli.d.ts +6 -1
  65. package/dist/workspace/lineage-cli.d.ts.map +1 -1
  66. package/dist/workspace/lineage-hash-index.d.ts +110 -0
  67. package/dist/workspace/lineage-hash-index.d.ts.map +1 -0
  68. package/dist/workspace/lineage-init.d.ts +10 -0
  69. package/dist/workspace/lineage-init.d.ts.map +1 -1
  70. package/dist/workspace/lineage-lock.d.ts +147 -0
  71. package/dist/workspace/lineage-lock.d.ts.map +1 -1
  72. package/dist/workspace/lineage-migrations.d.ts +15 -3
  73. package/dist/workspace/lineage-migrations.d.ts.map +1 -1
  74. package/dist/workspace/lineage-provenance.d.ts +18 -0
  75. package/dist/workspace/lineage-provenance.d.ts.map +1 -0
  76. package/dist/workspace/lineage-upgrade-cli.d.ts +2 -0
  77. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  78. package/dist/workspace/lineage-upgrade.d.ts +30 -1
  79. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  80. package/dist/workspace/lineage-versions.d.ts +116 -0
  81. package/dist/workspace/lineage-versions.d.ts.map +1 -0
  82. package/dist/workspace/links.d.ts +31 -5
  83. package/dist/workspace/links.d.ts.map +1 -1
  84. package/dist/workspace/ls-generated.d.ts +37 -0
  85. package/dist/workspace/ls-generated.d.ts.map +1 -0
  86. package/dist/workspace/ls.d.ts +4 -0
  87. package/dist/workspace/ls.d.ts.map +1 -1
  88. package/dist/workspace/member-commands.d.ts.map +1 -1
  89. package/dist/workspace/nested-graph.d.ts +56 -0
  90. package/dist/workspace/nested-graph.d.ts.map +1 -0
  91. package/dist/workspace/nesting.d.ts +21 -0
  92. package/dist/workspace/nesting.d.ts.map +1 -0
  93. package/dist/workspace/pin-cli.d.ts +10 -0
  94. package/dist/workspace/pin-cli.d.ts.map +1 -0
  95. package/dist/workspace/pin-integrity.d.ts +51 -0
  96. package/dist/workspace/pin-integrity.d.ts.map +1 -0
  97. package/dist/workspace/reason-codes.d.ts +26 -2
  98. package/dist/workspace/reason-codes.d.ts.map +1 -1
  99. package/dist/workspace/record-sessions.d.ts +7 -11
  100. package/dist/workspace/record-sessions.d.ts.map +1 -1
  101. package/dist/workspace/records-cli.d.ts +30 -1
  102. package/dist/workspace/records-cli.d.ts.map +1 -1
  103. package/dist/workspace/records-close.d.ts +5 -2
  104. package/dist/workspace/records-close.d.ts.map +1 -1
  105. package/dist/workspace/records-write.d.ts +22 -4
  106. package/dist/workspace/records-write.d.ts.map +1 -1
  107. package/dist/workspace/records.d.ts +43 -5
  108. package/dist/workspace/records.d.ts.map +1 -1
  109. package/dist/workspace/returns.d.ts +129 -0
  110. package/dist/workspace/returns.d.ts.map +1 -0
  111. package/dist/workspace/status-gates.d.ts.map +1 -1
  112. package/dist/workspace/template-manifest.d.ts +11 -3
  113. package/dist/workspace/template-manifest.d.ts.map +1 -1
  114. package/dist/workspace/trust/attestor.d.ts +8 -0
  115. package/dist/workspace/trust/attestor.d.ts.map +1 -1
  116. package/dist/workspace/trust/dsse.d.ts +58 -0
  117. package/dist/workspace/trust/dsse.d.ts.map +1 -0
  118. package/dist/workspace/trust/evidence-cli.d.ts +66 -0
  119. package/dist/workspace/trust/evidence-cli.d.ts.map +1 -0
  120. package/dist/workspace/trust/evidence.d.ts +93 -0
  121. package/dist/workspace/trust/evidence.d.ts.map +1 -0
  122. package/dist/workspace/trust/policy.d.ts +54 -1
  123. package/dist/workspace/trust/policy.d.ts.map +1 -1
  124. package/dist/workspace/trust/provenance.d.ts +21 -1
  125. package/dist/workspace/trust/provenance.d.ts.map +1 -1
  126. package/dist/workspace/trust/rotation.d.ts +132 -0
  127. package/dist/workspace/trust/rotation.d.ts.map +1 -0
  128. package/dist/workspace/trust/seal.d.ts.map +1 -1
  129. package/dist/workspace/trust/signers-cli.d.ts +49 -0
  130. package/dist/workspace/trust/signers-cli.d.ts.map +1 -0
  131. package/dist/workspace/trust/ssh-commit.d.ts +12 -0
  132. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  133. package/dist/workspace/trust/test-repo.d.ts +13 -0
  134. package/dist/workspace/trust/test-repo.d.ts.map +1 -1
  135. package/dist/workspace/trust/verify.d.ts +15 -0
  136. package/dist/workspace/trust/verify.d.ts.map +1 -1
  137. package/dist/workspace/work-evidence.d.ts +1 -1
  138. package/dist/workspace/work-evidence.d.ts.map +1 -1
  139. package/dist/workspace/write-scope.d.ts +199 -0
  140. package/dist/workspace/write-scope.d.ts.map +1 -0
  141. package/package.json +1 -1
  142. package/src/build.ts +8 -0
  143. package/src/cli/build-options.ts +4 -1
  144. package/src/cli/commands/build.ts +2 -0
  145. package/src/cli/commands/import-live.test.ts +69 -1
  146. package/src/cli/commands/import.ts +48 -22
  147. package/src/cli/handlers/fan-out.test.ts +6 -6
  148. package/src/cli/handlers/fan-out.ts +2 -1
  149. package/src/cli/handlers/graph.test.ts +42 -0
  150. package/src/cli/handlers/graph.ts +22 -0
  151. package/src/cli/handlers/operator.ts +1 -1
  152. package/src/cli/main.test.ts +32 -0
  153. package/src/cli/main.ts +103 -6
  154. package/src/cli/mcp/workspace-tools.test.ts +1 -1
  155. package/src/cli/mcp/workspace-tools.ts +33 -2
  156. package/src/cli/registry.ts +22 -0
  157. package/src/cli/serve-mcp-workspace.test.ts +1 -1
  158. package/src/codegen/release-wiring.test.ts +5 -1
  159. package/src/components/fan-out-output.test.ts +1 -1
  160. package/src/components/fan-out.test.ts +1 -1
  161. package/src/components/promote.test.ts +1 -1
  162. package/src/config.ts +12 -0
  163. package/src/content-digest.test.ts +2 -2
  164. package/src/lexicon.ts +25 -1
  165. package/src/lifecycle/gate-ledger.test.ts +14 -0
  166. package/src/lifecycle/gate-ledger.ts +2 -1
  167. package/src/lifecycle/plan-digest.test.ts +54 -3
  168. package/src/lifecycle/plan-digest.ts +38 -8
  169. package/src/op/activities/activity-contracts.ts +1 -0
  170. package/src/op/activities/propose-upgrade.ts +8 -5
  171. package/src/op/gate-approval.test.ts +17 -0
  172. package/src/op/gate.ts +3 -3
  173. package/src/op/index.ts +1 -1
  174. package/src/serializer.ts +9 -0
  175. package/src/telemetry-attribution.test.ts +91 -0
  176. package/src/telemetry-attribution.ts +145 -0
  177. package/src/workspace/agent-cli.ts +134 -0
  178. package/src/workspace/agent.schema.json +356 -0
  179. package/src/workspace/behold-kinds.test.ts +1 -1
  180. package/src/workspace/changes-cli.ts +5 -0
  181. package/src/workspace/changes.schema.json +179 -1
  182. package/src/workspace/changes.ts +58 -4
  183. package/src/workspace/check-live.test.ts +192 -0
  184. package/src/workspace/check.schema.json +64 -0
  185. package/src/workspace/checks/links.ts +23 -2
  186. package/src/workspace/checks/live.ts +113 -0
  187. package/src/workspace/checks.test.ts +2 -2
  188. package/src/workspace/checks.ts +20 -3
  189. package/src/workspace/compose-graph.test.ts +47 -0
  190. package/src/workspace/compose-graph.ts +120 -3
  191. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +7 -1
  192. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +5 -0
  193. package/src/workspace/declaration.schema.json +138 -3
  194. package/src/workspace/declaration.ts +106 -1
  195. package/src/workspace/declared-kinds.test.ts +33 -0
  196. package/src/workspace/evidence.schema.json +279 -0
  197. package/src/workspace/export-cli.ts +180 -0
  198. package/src/workspace/export-import.test.ts +282 -0
  199. package/src/workspace/export.ts +486 -0
  200. package/src/workspace/graph-cli.ts +47 -1
  201. package/src/workspace/graph-contract.test.ts +89 -4
  202. package/src/workspace/graph.schema.json +206 -1
  203. package/src/workspace/import.ts +325 -0
  204. package/src/workspace/kinds.test.ts +5 -5
  205. package/src/workspace/kinds.ts +25 -3
  206. package/src/workspace/lineage-adopt-cli.ts +103 -0
  207. package/src/workspace/lineage-adopt.test.ts +552 -0
  208. package/src/workspace/lineage-adopt.ts +452 -0
  209. package/src/workspace/lineage-check.ts +26 -5
  210. package/src/workspace/lineage-cli.ts +12 -2
  211. package/src/workspace/lineage-hash-index.ts +305 -0
  212. package/src/workspace/lineage-init.test.ts +9 -0
  213. package/src/workspace/lineage-init.ts +29 -9
  214. package/src/workspace/lineage-lock.ts +54 -0
  215. package/src/workspace/lineage-migrations.test.ts +27 -0
  216. package/src/workspace/lineage-migrations.ts +44 -14
  217. package/src/workspace/lineage-provenance.ts +40 -0
  218. package/src/workspace/lineage-upgrade-cli.ts +9 -5
  219. package/src/workspace/lineage-upgrade.test.ts +92 -1
  220. package/src/workspace/lineage-upgrade.ts +111 -16
  221. package/src/workspace/lineage-versions.ts +348 -0
  222. package/src/workspace/links.test.ts +121 -2
  223. package/src/workspace/links.ts +97 -6
  224. package/src/workspace/ls-contract.test.ts +84 -1
  225. package/src/workspace/ls-generated.ts +111 -0
  226. package/src/workspace/ls.schema.json +19 -0
  227. package/src/workspace/ls.ts +8 -1
  228. package/src/workspace/member-commands.ts +3 -1
  229. package/src/workspace/nested-graph.test.ts +176 -0
  230. package/src/workspace/nested-graph.ts +169 -0
  231. package/src/workspace/nesting.ts +37 -0
  232. package/src/workspace/pin-cli.test.ts +71 -0
  233. package/src/workspace/pin-cli.ts +57 -0
  234. package/src/workspace/pin-integrity.test.ts +121 -0
  235. package/src/workspace/pin-integrity.ts +104 -0
  236. package/src/workspace/points-write.schema.json +1 -0
  237. package/src/workspace/read-contract.test.ts +24 -0
  238. package/src/workspace/reason-codes.test.ts +10 -0
  239. package/src/workspace/reason-codes.ts +29 -2
  240. package/src/workspace/record-sessions.ts +12 -14
  241. package/src/workspace/records-amend.schema.json +4 -0
  242. package/src/workspace/records-cli.ts +93 -12
  243. package/src/workspace/records-close.schema.json +6 -2
  244. package/src/workspace/records-close.ts +10 -2
  245. package/src/workspace/records-formats.test.ts +13 -5
  246. package/src/workspace/records-new.schema.json +4 -0
  247. package/src/workspace/records-review.schema.json +4 -0
  248. package/src/workspace/records-sessions.test.ts +23 -6
  249. package/src/workspace/records-write.test.ts +65 -8
  250. package/src/workspace/records-write.ts +70 -6
  251. package/src/workspace/records.schema.json +55 -3
  252. package/src/workspace/records.ts +96 -5
  253. package/src/workspace/returns.ts +328 -0
  254. package/src/workspace/signers.schema.json +206 -0
  255. package/src/workspace/status-gates.ts +2 -1
  256. package/src/workspace/template-manifest.ts +22 -4
  257. package/src/workspace/trust/attestor.ts +15 -0
  258. package/src/workspace/trust/dsse.ts +134 -0
  259. package/src/workspace/trust/evidence-cli.ts +195 -0
  260. package/src/workspace/trust/evidence.test.ts +241 -0
  261. package/src/workspace/trust/evidence.ts +207 -0
  262. package/src/workspace/trust/policy.ts +110 -3
  263. package/src/workspace/trust/provenance.ts +41 -4
  264. package/src/workspace/trust/record-seal.test.ts +1 -1
  265. package/src/workspace/trust/rotation.test.ts +258 -0
  266. package/src/workspace/trust/rotation.ts +336 -0
  267. package/src/workspace/trust/seal.ts +11 -0
  268. package/src/workspace/trust/signers-cli.ts +178 -0
  269. package/src/workspace/trust/ssh-commit.ts +53 -4
  270. package/src/workspace/trust/test-repo.ts +18 -0
  271. package/src/workspace/trust/trust.test.ts +8 -2
  272. package/src/workspace/trust/verify-cli.ts +1 -0
  273. package/src/workspace/trust/verify.ts +22 -0
  274. package/src/workspace/work-evidence.schema.json +4 -0
  275. package/src/workspace/write-scope.test.ts +340 -0
  276. package/src/workspace/write-scope.ts +448 -0
@@ -0,0 +1,448 @@
1
+ /**
2
+ * Write scope per member and record kind, for each principal class, and the
3
+ * agent sessions bound to one member (#2524 D5, D20; #2548; ws-067).
4
+ *
5
+ * The declaration's `writeScope` block gives a restricted class the members
6
+ * whose files it may write and the record kinds it may write, with which
7
+ * verbs (`new`, `amend`, `review`, `close`). A class with no entry is not
8
+ * restricted. The declaration's `agents` list names agent sessions, each
9
+ * bound to one member: a session writes that member's files, and the records
10
+ * of kinds that member or the workspace declares, as `writeScope.agent`
11
+ * allows. An agent's members can't be widened.
12
+ *
13
+ * Two places apply it, both reading the scope from the base revision so a
14
+ * change can't widen its own scope (#2524 threat model):
15
+ *
16
+ * - The write paths: `chant workspace records new|amend|review|close` and the
17
+ * MCP record tools refuse a write outside the writer's scope with
18
+ * `write-scope-member` or `write-scope-kind`, and an unknown session with
19
+ * `agent-unknown`. The session comes from `CHANT_AGENT`.
20
+ * - The enforcement boundary: `chant workspace check --changes <range>`
21
+ * judges every commit in the range, by its `Chant-Agent` trailer, its
22
+ * attested principal, or its author, and reports each path written outside
23
+ * that writer's scope. Run in CI with an attestation policy, the principal
24
+ * is the signer the policy at base trusts; on a developer machine it is
25
+ * detection only (ws-002).
26
+ *
27
+ * A principal's class comes from the role grants in the trust policy at base,
28
+ * through {@link principalClass} alone (the agent, runner and service roles),
29
+ * or from naming an agent session. Claiming a session only ever narrows what
30
+ * a writer may do, so an unverified `CHANT_AGENT` or trailer is safe to honour.
31
+ */
32
+
33
+ import { execFileSync } from "node:child_process";
34
+ import { posix, relative, sep } from "node:path";
35
+ import {
36
+ PRINCIPAL_CLASSES,
37
+ readDeclaration,
38
+ WorkspaceReadError,
39
+ type AgentDeclaration,
40
+ type ClassScope,
41
+ type Declaration,
42
+ type PrincipalClass,
43
+ type WriteVerb,
44
+ } from "./declaration";
45
+ import type { ReasonCode } from "./reason-codes";
46
+ import { memberHolding } from "./record-assets";
47
+ import { gitRoot } from "./record-source";
48
+ import { normalisePrincipal, parseFrontMatter } from "./records";
49
+ import { emptyPolicy, type TrustPolicy } from "./trust/policy";
50
+ import { commitProvenance, policyAtBase, resolveBase } from "./trust/provenance";
51
+ import { locateWorkspace } from "./which-chant";
52
+
53
+ /** The environment variable naming the agent session a write is made in. */
54
+ export const AGENT_ENV = "CHANT_AGENT";
55
+
56
+ /** The commit trailer naming the agent session a commit was made in. */
57
+ export const AGENT_TRAILER = "Chant-Agent";
58
+
59
+ /** Why a write is outside its writer's scope. Closed. */
60
+ export const WRITE_SCOPE_CODES = ["write-scope-member", "write-scope-kind", "agent-unknown"] as const satisfies readonly ReasonCode[];
61
+ export type WriteScopeCode = (typeof WRITE_SCOPE_CODES)[number];
62
+
63
+ /** The classes a role grant puts a principal in, in the order they are tried. Human is the rest. */
64
+ const ROLE_CLASSES = ["agent", "runner", "service"] as const satisfies readonly PrincipalClass[];
65
+
66
+ /**
67
+ * The role grants a class is read from. Every read of roles for write scope
68
+ * goes through here, so where the grants live (`.chant/trust.json` today,
69
+ * possibly the declaration, #2547) is decided in one place: the policy.
70
+ */
71
+ export function roleGrants(policy: TrustPolicy): Record<string, string[]> {
72
+ return policy.roles;
73
+ }
74
+
75
+ /** The class a principal is in: the first of agent, runner and service whose role it holds at base, or human. */
76
+ export function principalClass(policy: TrustPolicy, principal: string | null): PrincipalClass {
77
+ if (principal === null) return "human";
78
+ const name = normalisePrincipal(principal);
79
+ const grants = roleGrants(policy);
80
+ return ROLE_CLASSES.find((cls) => (grants[cls] ?? []).some((p) => normalisePrincipal(p) === name)) ?? "human";
81
+ }
82
+
83
+ /** Who is writing, as the scope judges it. */
84
+ export interface Writer {
85
+ /** The principal named, or null when none is. */
86
+ principal: string | null;
87
+ class: PrincipalClass;
88
+ /** The agent session the writer is bound to, or null. */
89
+ agent: AgentDeclaration | null;
90
+ }
91
+
92
+ export class WriteScopeError extends Error {
93
+ constructor(
94
+ readonly code: WriteScopeCode,
95
+ message: string,
96
+ ) {
97
+ super(message);
98
+ this.name = "WriteScopeError";
99
+ }
100
+ }
101
+
102
+ /**
103
+ * The writer: in the session `agent` names (refused with agent-unknown when
104
+ * the declaration declares none by that name), in the session that lists
105
+ * `principal`, or else in the class `principal`'s roles give it.
106
+ */
107
+ export function resolveWriter(declaration: Declaration | null, policy: TrustPolicy, given: { agent?: string | null; principal?: string | null }): Writer {
108
+ const principal = given.principal ?? null;
109
+ const agents = declaration?.agents ?? [];
110
+ if (given.agent !== undefined && given.agent !== null) {
111
+ const agent = agents.find((a) => a.name === given.agent);
112
+ if (!agent) {
113
+ const known = agents.map((a) => a.name).join(", ");
114
+ throw new WriteScopeError(
115
+ "agent-unknown",
116
+ declaration === null
117
+ ? `agent session ${JSON.stringify(given.agent)} is not declared: there is no workspace declaration`
118
+ : `agent session ${JSON.stringify(given.agent)} is not declared: the declaration's agents ${known ? `are ${known}` : "list none"}`,
119
+ );
120
+ }
121
+ return { principal, class: "agent", agent };
122
+ }
123
+ if (principal !== null) {
124
+ const name = normalisePrincipal(principal);
125
+ const agent = agents.find((a) => a.principals.some((p) => normalisePrincipal(p) === name));
126
+ if (agent) return { principal, class: "agent", agent };
127
+ }
128
+ return { principal, class: principalClass(policy, principal), agent: null };
129
+ }
130
+
131
+ /** The scope that applies to `writer`, or null when its class is not restricted. An agent is always restricted to its member. */
132
+ export function scopeOf(declaration: Declaration | null, writer: Writer): ClassScope | null {
133
+ const entry = declaration?.writeScope?.[writer.class];
134
+ if (writer.class === "agent") return { members: null, records: entry?.records ?? null, pointer: entry?.pointer ?? "/agents" };
135
+ return entry ?? null;
136
+ }
137
+
138
+ export type ScopeVerdict = { ok: true } | { ok: false; code: Exclude<WriteScopeCode, "agent-unknown">; message: string };
139
+
140
+ const OK: ScopeVerdict = { ok: true };
141
+
142
+ function who(writer: Writer): string {
143
+ if (writer.agent) return `agent session ${writer.agent.name}, bound to member ${writer.agent.member},`;
144
+ const name = writer.principal !== null ? `${writer.principal} (${writer.class})` : `a ${writer.class} writer`;
145
+ return writer.class === "agent" ? `${name}, which no agent session lists,` : name;
146
+ }
147
+
148
+ /** Whether `writer` may write in `member` (null: a path in no member). */
149
+ function memberAllowed(writer: Writer, scope: ClassScope, member: string | null): boolean {
150
+ if (writer.class === "agent") return member !== null && member === writer.agent?.member;
151
+ return scope.members === null || (member !== null && scope.members.includes(member));
152
+ }
153
+
154
+ /** The record kind a write goes to, as the scope reads it. */
155
+ export interface ScopedKind {
156
+ /** The kind file's `recordKind.name`. */
157
+ name: string;
158
+ /** The name the declaration gives it, or null. */
159
+ declaredName: string | null;
160
+ /** The member that declares the kind, or null for the workspace's own kinds. */
161
+ member: string | null;
162
+ /** True when the declaration names the kind; an undeclared kind belongs to the member holding its records. */
163
+ declared: boolean;
164
+ }
165
+
166
+ /** Whether `writer` may write a record of `kind` with `verb`. A delete is never in a restricted scope. */
167
+ export function judgeRecord(declaration: Declaration | null, writer: Writer, kind: ScopedKind, verb: WriteVerb | "delete"): ScopeVerdict {
168
+ const scope = scopeOf(declaration, writer);
169
+ if (scope === null) return OK;
170
+ // The workspace's own kinds are in every member's reach; a member's kinds only in its own.
171
+ const inReach = kind.declared && kind.member === null ? true : memberAllowed(writer, scope, kind.member);
172
+ if (!inReach) {
173
+ const where = kind.member === null ? "in no member" : `of member ${kind.member}`;
174
+ return {
175
+ ok: false,
176
+ code: "write-scope-member",
177
+ message: `${who(writer)} may not write ${kind.name} records, which are ${where}: ${writer.class === "agent" ? "an agent session writes only its own member" : `writeScope.${writer.class}.members leaves it out`}`,
178
+ };
179
+ }
180
+ if (verb === "delete") {
181
+ return { ok: false, code: "write-scope-kind", message: `${who(writer)} may not delete a ${kind.name} record: a record is never deleted, and a new one supersedes it` };
182
+ }
183
+ if (scope.records === null) return OK;
184
+ const names = [kind.name, ...(kind.declaredName !== null && kind.declaredName !== kind.name ? [kind.declaredName] : [])];
185
+ const verbs = names.flatMap((n) => scope.records![n] ?? []);
186
+ if (verbs.includes(verb)) return OK;
187
+ return {
188
+ ok: false,
189
+ code: "write-scope-kind",
190
+ message:
191
+ verbs.length === 0
192
+ ? `${who(writer)} may not write ${kind.name} records: writeScope.${writer.class}.records does not list the kind`
193
+ : `${who(writer)} may ${[...new Set(verbs)].join(", ")} ${kind.name} records, and not ${verb} them (writeScope.${writer.class}.records)`,
194
+ };
195
+ }
196
+
197
+ /** Whether `writer` may write the file at `path`, from the workspace root. */
198
+ export function judgePath(declaration: Declaration | null, writer: Writer, path: string): ScopeVerdict {
199
+ const scope = scopeOf(declaration, writer);
200
+ if (scope === null) return OK;
201
+ const member = memberHolding(path, declaration?.members ?? []);
202
+ if (memberAllowed(writer, scope, member)) return OK;
203
+ const where = member === null ? "is in no member" : `is in member ${member}`;
204
+ return {
205
+ ok: false,
206
+ code: "write-scope-member",
207
+ message: `${who(writer)} may not write ${path}, which ${where}: ${writer.class === "agent" ? "an agent session writes only its own member" : `writeScope.${writer.class}.members leaves it out`}`,
208
+ };
209
+ }
210
+
211
+ // ── The scope a write is judged by ───────────────────────────────────────────
212
+
213
+ /** The declaration and policy a write on this machine is judged by. */
214
+ export interface ScopeSource {
215
+ declaration: Declaration | null;
216
+ /** Where the declaration was read: the base revision, or the working tree when there is no base or no declaration at it. */
217
+ from: "base" | "working-tree" | null;
218
+ /** The workspace root on disk, or null without a declaration. */
219
+ rootOnDisk: string | null;
220
+ /** The workspace root from the git root, "." for the git root, or null without a declaration. */
221
+ root: string | null;
222
+ policy: TrustPolicy;
223
+ }
224
+
225
+ function readAt(cwd: string, at?: string): { declaration: Declaration; rootOnDisk: string; root: string } | null {
226
+ try {
227
+ const located = locateWorkspace(cwd, at);
228
+ return { declaration: readDeclaration(located.tree), rootOnDisk: located.rootOnDisk, root: located.root };
229
+ } catch (err) {
230
+ if (err instanceof WorkspaceReadError) return null;
231
+ throw err;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * The scope for a write from `cwd`: the declaration and the trust policy at
237
+ * the base revision (`origin/HEAD`, `main` or `master`), so a write can't
238
+ * widen its own scope by editing its copy of the declaration. Without a base,
239
+ * or without a declaration at it, the working tree's declaration.
240
+ */
241
+ export function scopeSource(cwd: string): ScopeSource {
242
+ const top = gitRoot(cwd);
243
+ const base = top ? resolveBase(top) : null;
244
+ const policy = top && base ? policyAtBase(top, base) : emptyPolicy(null);
245
+ if (base?.commit) {
246
+ const atBase = readAt(cwd, base.commit);
247
+ if (atBase) return { ...atBase, from: "base", policy };
248
+ }
249
+ const here = readAt(cwd);
250
+ return here ? { ...here, from: "working-tree", policy } : { declaration: null, from: null, rootOnDisk: null, root: null, policy };
251
+ }
252
+
253
+ const toPosix = (p: string) => (sep === "/" ? p : p.split(sep).join("/"));
254
+
255
+ /**
256
+ * The kind a write goes to, as {@link judgeRecord} reads it: the declared
257
+ * entry whose file is `kindFile`, or, for a kind the declaration does not
258
+ * name, the member holding `recordsDir`.
259
+ */
260
+ export function scopedKind(source: ScopeSource, kindName: string, kindFile: string, recordsDir: string): ScopedKind {
261
+ const decl = source.declaration;
262
+ if (!decl || source.rootOnDisk === null) return { name: kindName, declaredName: null, member: null, declared: false };
263
+ const path = toPosix(relative(source.rootOnDisk, kindFile));
264
+ const declared = [...decl.records, ...decl.members.flatMap((m) => m.records)].find((r) => r.path === path);
265
+ if (declared) return { name: kindName, declaredName: declared.name, member: declared.member, declared: true };
266
+ const dir = toPosix(relative(source.rootOnDisk, recordsDir));
267
+ return { name: kindName, declaredName: null, member: dir.startsWith("..") ? null : memberHolding(dir === "" ? "." : dir, decl.members), declared: false };
268
+ }
269
+
270
+ /**
271
+ * Refuse a record write outside the writer's scope (#2548): throws a
272
+ * {@link WriteScopeError}. `agent` is the session the write names
273
+ * (`CHANT_AGENT`), and `principal` who the write names as its author.
274
+ */
275
+ export function refuseRecordWrite(
276
+ cwd: string,
277
+ write: { kindName: string; kindFile: string; recordsDir: string; verb: WriteVerb; agent?: string | null; principal?: string | null },
278
+ ): void {
279
+ const source = scopeSource(cwd);
280
+ if (source.declaration === null && (write.agent === undefined || write.agent === null)) return;
281
+ const writer = resolveWriter(source.declaration, source.policy, { agent: write.agent, principal: write.principal });
282
+ const verdict = judgeRecord(source.declaration, writer, scopedKind(source, write.kindName, write.kindFile, write.recordsDir), write.verb);
283
+ if (!verdict.ok) throw new WriteScopeError(verdict.code, verdict.message);
284
+ }
285
+
286
+ // ── The check over a range ───────────────────────────────────────────────────
287
+
288
+ /** A commit as the scope check judged it. */
289
+ export interface ScopeCommit {
290
+ commit: string;
291
+ subject: string;
292
+ /** The attested principal, or the author's email when the commit is not attested. */
293
+ principal: string;
294
+ /** Whether the principal is a signer the policy at base attests, or only the author the commit claims. */
295
+ attested: boolean;
296
+ class: PrincipalClass;
297
+ /** The agent session, from the Chant-Agent trailer or the principal, or null. */
298
+ agent: string | null;
299
+ /** Paths in the workspace the commit writes. */
300
+ paths: number;
301
+ }
302
+
303
+ export interface ScopeFinding {
304
+ /** `finding:<code>:<commit>:<path>`, or `finding:agent-unknown:<commit>`. */
305
+ id: string;
306
+ code: WriteScopeCode;
307
+ commit: string;
308
+ /** From the workspace root, or null for a finding on the whole commit. */
309
+ path: string | null;
310
+ principal: string;
311
+ class: PrincipalClass;
312
+ agent: string | null;
313
+ /** For a record file: how it was written, as the records commands name it, or delete. */
314
+ verb: WriteVerb | "delete" | null;
315
+ message: string;
316
+ }
317
+
318
+ export interface ScopeReport {
319
+ /** The commit the declaration and the trust policy were read at. */
320
+ base: string;
321
+ /** The classes with a writeScope entry, and agent when any session is declared. */
322
+ restricted: PrincipalClass[];
323
+ agents: string[];
324
+ commits: ScopeCommit[];
325
+ findings: ScopeFinding[];
326
+ }
327
+
328
+ /** A record kind the check knows, to tell a record file from another path. */
329
+ export interface CheckKind {
330
+ scoped: ScopedKind;
331
+ /** The records directory, from the repository root. */
332
+ dir: string;
333
+ match: RegExp;
334
+ /** The reviews field, when the kind has one; the verdicts field of a session kind. */
335
+ reviewFields: string[];
336
+ /** The state field and closed states of a session kind, for telling a close. */
337
+ session: { stateField: string; closed: string[]; fills: string[] } | null;
338
+ format: string;
339
+ }
340
+
341
+ function git(top: string, args: string[]): string {
342
+ return execFileSync("git", args, { cwd: top, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 512 * 1024 * 1024 });
343
+ }
344
+
345
+ function show(top: string, rev: string, path: string): string | null {
346
+ try {
347
+ return git(top, ["show", `${rev}:${path}`]);
348
+ } catch {
349
+ return null;
350
+ }
351
+ }
352
+
353
+ /** How a commit wrote a record file it modified: review when only the reviews (or a session's verdicts) changed, close when a session moved into a closed state, else amend. */
354
+ function modifiedVerb(top: string, commit: string, path: string, kind: CheckKind): WriteVerb {
355
+ if (kind.format !== "markdown-front-matter") return "amend";
356
+ const before = show(top, `${commit}^`, path);
357
+ const after = show(top, commit, path);
358
+ if (before === null || after === null) return "amend";
359
+ const a = parseFrontMatter(before);
360
+ const b = parseFrontMatter(after);
361
+ if (!a.ok || !b.ok) return "amend";
362
+ const keys = new Set([...Object.keys(a.value), ...Object.keys(b.value)]);
363
+ const changed = [...keys].filter((k) => JSON.stringify(a.value[k]) !== JSON.stringify(b.value[k]));
364
+ if (kind.session) {
365
+ const state = b.value[kind.session.stateField];
366
+ if (typeof state === "string" && kind.session.closed.includes(state) && a.value[kind.session.stateField] !== state) return "close";
367
+ if (changed.every((k) => kind.reviewFields.includes(k) || kind.session!.fills.includes(k))) return "review";
368
+ return "amend";
369
+ }
370
+ return changed.length > 0 && changed.every((k) => kind.reviewFields.includes(k)) ? "review" : "amend";
371
+ }
372
+
373
+ /**
374
+ * Judge every commit in `base..head` against the write scope the declaration
375
+ * at `base` gives (#2548). Returns null when that declaration restricts no
376
+ * one: no writeScope block and no agent session. Merges are skipped; the
377
+ * commits they bring are judged instead.
378
+ */
379
+ export async function checkWriteScope(q: {
380
+ top: string;
381
+ base: string;
382
+ head: string;
383
+ /** The workspace root from the repository root, "" for the top. */
384
+ prefix: string;
385
+ declaration: Declaration;
386
+ kinds: CheckKind[];
387
+ }): Promise<ScopeReport | null> {
388
+ const { top, base, head, prefix, declaration } = q;
389
+ if (declaration.writeScope === null && declaration.agents.length === 0) return null;
390
+ const policy = policyAtBase(top, { commit: base, from: "flag" });
391
+ const { activeAttestors } = await import("./trust/attestor");
392
+ const attestors = await activeAttestors();
393
+ const restricted = PRINCIPAL_CLASSES.filter((c) => declaration.writeScope?.[c] !== undefined || (c === "agent" && declaration.agents.length > 0));
394
+ const report: ScopeReport = { base, restricted, agents: declaration.agents.map((a) => a.name), commits: [], findings: [] };
395
+ const list = git(top, ["rev-list", "--reverse", "--no-merges", `${base}..${head}`]).split("\n").filter(Boolean);
396
+ for (const commit of list) {
397
+ const [subject, email, trailers] = git(top, ["log", "-1", `--format=%s%x00%ae%x00%(trailers:key=${AGENT_TRAILER},valueonly,separator=%x01)`, commit]).split("\0");
398
+ const named = (trailers ?? "").split("\x01").map((s) => s.trim()).filter(Boolean)[0] ?? null;
399
+ const prov = policy.active ? commitProvenance(top, policy, commit, attestors) : null;
400
+ const attested = prov?.level === "attested" && prov.principal !== undefined;
401
+ const principal = attested ? prov!.principal! : email.trim();
402
+ const changed = git(top, ["diff-tree", "--no-commit-id", "-r", "--name-status", "--no-renames", "-z", commit])
403
+ .split("\0")
404
+ .filter(Boolean);
405
+ const paths: { status: string; path: string }[] = [];
406
+ for (let i = 0; i + 1 < changed.length; i += 2) {
407
+ const full = changed[i + 1];
408
+ if (prefix !== "" && !full.startsWith(`${prefix}/`)) continue;
409
+ paths.push({ status: changed[i], path: full });
410
+ }
411
+ let writer: Writer;
412
+ try {
413
+ writer = resolveWriter(declaration, policy, { agent: named, principal });
414
+ } catch (err) {
415
+ if (!(err instanceof WriteScopeError)) throw err;
416
+ report.commits.push({ commit, subject, principal, attested, class: "agent", agent: named, paths: paths.length });
417
+ report.findings.push({ id: `finding:${err.code}:${commit}`, code: err.code, commit, path: null, principal, class: "agent", agent: named, verb: null, message: `${commit.slice(0, 8)} has ${AGENT_TRAILER}: ${named}: ${err.message}` });
418
+ continue;
419
+ }
420
+ report.commits.push({ commit, subject, principal, attested, class: writer.class, agent: writer.agent?.name ?? null, paths: paths.length });
421
+ if (scopeOf(declaration, writer) === null) continue;
422
+ for (const p of paths) {
423
+ const inWorkspace = prefix === "" ? p.path : p.path.slice(prefix.length + 1);
424
+ const kind = q.kinds.find((k) => posix.dirname(p.path) === k.dir && k.match.test(posix.basename(p.path)));
425
+ let verb: WriteVerb | "delete" | null = null;
426
+ let verdict: ScopeVerdict;
427
+ if (kind) {
428
+ verb = p.status === "A" ? "new" : p.status === "D" ? "delete" : modifiedVerb(top, commit, p.path, kind);
429
+ verdict = judgeRecord(declaration, writer, kind.scoped, verb);
430
+ } else {
431
+ verdict = judgePath(declaration, writer, inWorkspace);
432
+ }
433
+ if (verdict.ok) continue;
434
+ report.findings.push({
435
+ id: `finding:${verdict.code}:${commit}:${inWorkspace}`,
436
+ code: verdict.code,
437
+ commit,
438
+ path: inWorkspace,
439
+ principal,
440
+ class: writer.class,
441
+ agent: writer.agent?.name ?? null,
442
+ verb,
443
+ message: `${commit.slice(0, 8)} ${p.status === "D" ? "deletes" : p.status === "A" ? "adds" : "changes"} ${inWorkspace}: ${verdict.message}`,
444
+ });
445
+ }
446
+ }
447
+ return report;
448
+ }