@smthrs/kernel 0.0.0-stage → 1.0.0-rc.4

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 (267) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -2
  4. package/dist/cjs/CapabilitySet.d.ts +6 -0
  5. package/dist/cjs/CapabilitySet.d.ts.map +1 -0
  6. package/dist/cjs/CapabilitySet.js +19 -0
  7. package/dist/cjs/CapabilitySet.js.map +7 -0
  8. package/dist/cjs/ChildProcessEnvironment.d.ts +60 -0
  9. package/dist/cjs/ChildProcessEnvironment.d.ts.map +1 -0
  10. package/dist/cjs/ChildProcessEnvironment.js +61 -0
  11. package/dist/cjs/ChildProcessEnvironment.js.map +7 -0
  12. package/dist/cjs/ChildProcessSpawner.d.ts +100 -0
  13. package/dist/cjs/ChildProcessSpawner.d.ts.map +1 -0
  14. package/dist/cjs/ChildProcessSpawner.js +291 -0
  15. package/dist/cjs/ChildProcessSpawner.js.map +7 -0
  16. package/dist/cjs/CommandLine.d.ts +173 -0
  17. package/dist/cjs/CommandLine.d.ts.map +1 -0
  18. package/dist/cjs/CommandLine.js +54 -0
  19. package/dist/cjs/CommandLine.js.map +7 -0
  20. package/dist/cjs/ContainedSpawner.d.ts +135 -0
  21. package/dist/cjs/ContainedSpawner.d.ts.map +1 -0
  22. package/dist/cjs/ContainedSpawner.js +141 -0
  23. package/dist/cjs/ContainedSpawner.js.map +7 -0
  24. package/dist/cjs/FileSystem.d.ts +452 -0
  25. package/dist/cjs/FileSystem.d.ts.map +1 -0
  26. package/dist/cjs/FileSystem.js +890 -0
  27. package/dist/cjs/FileSystem.js.map +7 -0
  28. package/dist/cjs/FileSystemBatch.d.ts +94 -0
  29. package/dist/cjs/FileSystemBatch.d.ts.map +1 -0
  30. package/dist/cjs/FileSystemBatch.js +31 -0
  31. package/dist/cjs/FileSystemBatch.js.map +7 -0
  32. package/dist/cjs/GrantEvent.d.ts +240 -0
  33. package/dist/cjs/GrantEvent.d.ts.map +1 -0
  34. package/dist/cjs/GrantEvent.js +115 -0
  35. package/dist/cjs/GrantEvent.js.map +7 -0
  36. package/dist/cjs/GrantStore.d.ts +270 -0
  37. package/dist/cjs/GrantStore.d.ts.map +1 -0
  38. package/dist/cjs/GrantStore.js +857 -0
  39. package/dist/cjs/GrantStore.js.map +7 -0
  40. package/dist/cjs/HostError.d.ts +36 -0
  41. package/dist/cjs/HostError.d.ts.map +1 -0
  42. package/dist/cjs/HostError.js +17 -0
  43. package/dist/cjs/HostError.js.map +7 -0
  44. package/dist/cjs/HostServices.d.ts +101 -0
  45. package/dist/cjs/HostServices.d.ts.map +1 -0
  46. package/dist/cjs/HostServices.js +66 -0
  47. package/dist/cjs/HostServices.js.map +7 -0
  48. package/dist/cjs/HttpClient.d.ts +177 -0
  49. package/dist/cjs/HttpClient.d.ts.map +1 -0
  50. package/dist/cjs/HttpClient.js +217 -0
  51. package/dist/cjs/HttpClient.js.map +7 -0
  52. package/dist/cjs/Jj.d.ts +67 -0
  53. package/dist/cjs/Jj.d.ts.map +1 -0
  54. package/dist/cjs/Jj.js +118 -0
  55. package/dist/cjs/Jj.js.map +7 -0
  56. package/dist/cjs/JournalGrantStore.d.ts +65 -0
  57. package/dist/cjs/JournalGrantStore.d.ts.map +1 -0
  58. package/dist/cjs/JournalGrantStore.js +354 -0
  59. package/dist/cjs/JournalGrantStore.js.map +7 -0
  60. package/dist/cjs/Path.d.ts +37 -0
  61. package/dist/cjs/Path.d.ts.map +1 -0
  62. package/dist/cjs/Path.js +46 -0
  63. package/dist/cjs/Path.js.map +7 -0
  64. package/dist/cjs/ProcessConfinement.d.ts +55 -0
  65. package/dist/cjs/ProcessConfinement.d.ts.map +1 -0
  66. package/dist/cjs/ProcessConfinement.js +94 -0
  67. package/dist/cjs/ProcessConfinement.js.map +7 -0
  68. package/dist/cjs/ProcessLedger.d.ts +260 -0
  69. package/dist/cjs/ProcessLedger.d.ts.map +1 -0
  70. package/dist/cjs/ProcessLedger.js +190 -0
  71. package/dist/cjs/ProcessLedger.js.map +7 -0
  72. package/dist/cjs/Rooted.d.ts +53 -0
  73. package/dist/cjs/Rooted.d.ts.map +1 -0
  74. package/dist/cjs/Rooted.js +101 -0
  75. package/dist/cjs/Rooted.js.map +7 -0
  76. package/dist/cjs/Unreachable.d.ts +27 -0
  77. package/dist/cjs/Unreachable.d.ts.map +1 -0
  78. package/dist/cjs/Unreachable.js +43 -0
  79. package/dist/cjs/Unreachable.js.map +7 -0
  80. package/dist/cjs/Workspace.d.ts +62 -0
  81. package/dist/cjs/Workspace.d.ts.map +1 -0
  82. package/dist/cjs/Workspace.js +35 -0
  83. package/dist/cjs/Workspace.js.map +7 -0
  84. package/dist/cjs/index.d.ts +161 -0
  85. package/dist/cjs/index.d.ts.map +1 -0
  86. package/dist/cjs/index.js +73 -0
  87. package/dist/cjs/index.js.map +7 -0
  88. package/dist/cjs/internal/Containment.d.ts +24 -0
  89. package/dist/cjs/internal/Containment.d.ts.map +1 -0
  90. package/dist/cjs/internal/Containment.js +30 -0
  91. package/dist/cjs/internal/Containment.js.map +7 -0
  92. package/dist/cjs/internal/Pipeline.d.ts +18 -0
  93. package/dist/cjs/internal/Pipeline.d.ts.map +1 -0
  94. package/dist/cjs/internal/Pipeline.js +90 -0
  95. package/dist/cjs/internal/Pipeline.js.map +7 -0
  96. package/dist/cjs/internal/makeCapability.d.ts +16 -0
  97. package/dist/cjs/internal/makeCapability.d.ts.map +1 -0
  98. package/dist/cjs/internal/makeCapability.js +44 -0
  99. package/dist/cjs/internal/makeCapability.js.map +7 -0
  100. package/dist/cjs/package.json +1 -0
  101. package/dist/cjs/test/FileSystemBatchContract.d.ts +15 -0
  102. package/dist/cjs/test/FileSystemBatchContract.d.ts.map +1 -0
  103. package/dist/cjs/test/FileSystemBatchContract.js +76 -0
  104. package/dist/cjs/test/FileSystemBatchContract.js.map +7 -0
  105. package/dist/cjs/test/HostContract.d.ts +225 -0
  106. package/dist/cjs/test/HostContract.d.ts.map +1 -0
  107. package/dist/cjs/test/HostContract.js +489 -0
  108. package/dist/cjs/test/HostContract.js.map +7 -0
  109. package/dist/cjs/test/TestGrantStore.d.ts +31 -0
  110. package/dist/cjs/test/TestGrantStore.d.ts.map +1 -0
  111. package/dist/cjs/test/TestGrantStore.js +65 -0
  112. package/dist/cjs/test/TestGrantStore.js.map +7 -0
  113. package/dist/esm/CapabilitySet.d.ts +6 -0
  114. package/dist/esm/CapabilitySet.d.ts.map +1 -0
  115. package/dist/esm/CapabilitySet.js +6 -0
  116. package/dist/esm/CapabilitySet.js.map +1 -0
  117. package/dist/esm/ChildProcessEnvironment.d.ts +60 -0
  118. package/dist/esm/ChildProcessEnvironment.d.ts.map +1 -0
  119. package/dist/esm/ChildProcessEnvironment.js +91 -0
  120. package/dist/esm/ChildProcessEnvironment.js.map +1 -0
  121. package/dist/esm/ChildProcessSpawner.d.ts +100 -0
  122. package/dist/esm/ChildProcessSpawner.d.ts.map +1 -0
  123. package/dist/esm/ChildProcessSpawner.js +342 -0
  124. package/dist/esm/ChildProcessSpawner.js.map +1 -0
  125. package/dist/esm/CommandLine.d.ts +173 -0
  126. package/dist/esm/CommandLine.d.ts.map +1 -0
  127. package/dist/esm/CommandLine.js +195 -0
  128. package/dist/esm/CommandLine.js.map +1 -0
  129. package/dist/esm/ContainedSpawner.d.ts +135 -0
  130. package/dist/esm/ContainedSpawner.d.ts.map +1 -0
  131. package/dist/esm/ContainedSpawner.js +180 -0
  132. package/dist/esm/ContainedSpawner.js.map +1 -0
  133. package/dist/esm/FileSystem.d.ts +452 -0
  134. package/dist/esm/FileSystem.d.ts.map +1 -0
  135. package/dist/esm/FileSystem.js +989 -0
  136. package/dist/esm/FileSystem.js.map +1 -0
  137. package/dist/esm/FileSystemBatch.d.ts +94 -0
  138. package/dist/esm/FileSystemBatch.d.ts.map +1 -0
  139. package/dist/esm/FileSystemBatch.js +26 -0
  140. package/dist/esm/FileSystemBatch.js.map +1 -0
  141. package/dist/esm/GrantEvent.d.ts +240 -0
  142. package/dist/esm/GrantEvent.d.ts.map +1 -0
  143. package/dist/esm/GrantEvent.js +155 -0
  144. package/dist/esm/GrantEvent.js.map +1 -0
  145. package/dist/esm/GrantStore.d.ts +270 -0
  146. package/dist/esm/GrantStore.d.ts.map +1 -0
  147. package/dist/esm/GrantStore.js +951 -0
  148. package/dist/esm/GrantStore.js.map +1 -0
  149. package/dist/esm/HostError.d.ts +36 -0
  150. package/dist/esm/HostError.d.ts.map +1 -0
  151. package/dist/esm/HostError.js +20 -0
  152. package/dist/esm/HostError.js.map +1 -0
  153. package/dist/esm/HostServices.d.ts +101 -0
  154. package/dist/esm/HostServices.d.ts.map +1 -0
  155. package/dist/esm/HostServices.js +76 -0
  156. package/dist/esm/HostServices.js.map +1 -0
  157. package/dist/esm/HttpClient.d.ts +177 -0
  158. package/dist/esm/HttpClient.d.ts.map +1 -0
  159. package/dist/esm/HttpClient.js +299 -0
  160. package/dist/esm/HttpClient.js.map +1 -0
  161. package/dist/esm/Jj.d.ts +67 -0
  162. package/dist/esm/Jj.d.ts.map +1 -0
  163. package/dist/esm/Jj.js +111 -0
  164. package/dist/esm/Jj.js.map +1 -0
  165. package/dist/esm/JournalGrantStore.d.ts +65 -0
  166. package/dist/esm/JournalGrantStore.d.ts.map +1 -0
  167. package/dist/esm/JournalGrantStore.js +350 -0
  168. package/dist/esm/JournalGrantStore.js.map +1 -0
  169. package/dist/esm/Path.d.ts +37 -0
  170. package/dist/esm/Path.d.ts.map +1 -0
  171. package/dist/esm/Path.js +34 -0
  172. package/dist/esm/Path.js.map +1 -0
  173. package/dist/esm/ProcessConfinement.d.ts +55 -0
  174. package/dist/esm/ProcessConfinement.d.ts.map +1 -0
  175. package/dist/esm/ProcessConfinement.js +106 -0
  176. package/dist/esm/ProcessConfinement.js.map +1 -0
  177. package/dist/esm/ProcessLedger.d.ts +260 -0
  178. package/dist/esm/ProcessLedger.d.ts.map +1 -0
  179. package/dist/esm/ProcessLedger.js +283 -0
  180. package/dist/esm/ProcessLedger.js.map +1 -0
  181. package/dist/esm/Rooted.d.ts +53 -0
  182. package/dist/esm/Rooted.d.ts.map +1 -0
  183. package/dist/esm/Rooted.js +104 -0
  184. package/dist/esm/Rooted.js.map +1 -0
  185. package/dist/esm/Unreachable.d.ts +27 -0
  186. package/dist/esm/Unreachable.d.ts.map +1 -0
  187. package/dist/esm/Unreachable.js +26 -0
  188. package/dist/esm/Unreachable.js.map +1 -0
  189. package/dist/esm/Workspace.d.ts +62 -0
  190. package/dist/esm/Workspace.d.ts.map +1 -0
  191. package/dist/esm/Workspace.js +51 -0
  192. package/dist/esm/Workspace.js.map +1 -0
  193. package/dist/esm/index.d.ts +161 -0
  194. package/dist/esm/index.d.ts.map +1 -0
  195. package/dist/esm/index.js +151 -0
  196. package/dist/esm/index.js.map +1 -0
  197. package/dist/esm/internal/Containment.d.ts +24 -0
  198. package/dist/esm/internal/Containment.d.ts.map +1 -0
  199. package/dist/esm/internal/Containment.js +27 -0
  200. package/dist/esm/internal/Containment.js.map +1 -0
  201. package/dist/esm/internal/Pipeline.d.ts +18 -0
  202. package/dist/esm/internal/Pipeline.d.ts.map +1 -0
  203. package/dist/esm/internal/Pipeline.js +68 -0
  204. package/dist/esm/internal/Pipeline.js.map +1 -0
  205. package/dist/esm/internal/makeCapability.d.ts +16 -0
  206. package/dist/esm/internal/makeCapability.d.ts.map +1 -0
  207. package/dist/esm/internal/makeCapability.js +22 -0
  208. package/dist/esm/internal/makeCapability.js.map +1 -0
  209. package/dist/esm/test/FileSystemBatchContract.d.ts +15 -0
  210. package/dist/esm/test/FileSystemBatchContract.d.ts.map +1 -0
  211. package/dist/esm/test/FileSystemBatchContract.js +57 -0
  212. package/dist/esm/test/FileSystemBatchContract.js.map +1 -0
  213. package/dist/esm/test/HostContract.d.ts +225 -0
  214. package/dist/esm/test/HostContract.d.ts.map +1 -0
  215. package/dist/esm/test/HostContract.js +460 -0
  216. package/dist/esm/test/HostContract.js.map +1 -0
  217. package/dist/esm/test/TestGrantStore.d.ts +31 -0
  218. package/dist/esm/test/TestGrantStore.d.ts.map +1 -0
  219. package/dist/esm/test/TestGrantStore.js +57 -0
  220. package/dist/esm/test/TestGrantStore.js.map +1 -0
  221. package/docs/README.md +162 -0
  222. package/docs/api.md +1532 -0
  223. package/docs/concepts/decoration-in-place.md +117 -0
  224. package/docs/concepts/filesystem-batches.md +58 -0
  225. package/docs/concepts/filesystem-confinement.md +168 -0
  226. package/docs/concepts/grant-decisions.md +181 -0
  227. package/docs/concepts/process-containment.md +155 -0
  228. package/docs/contract.md +14 -0
  229. package/docs/guides/adapt-a-new-host-platform.md +148 -0
  230. package/docs/guides/answer-permission-requests.md +151 -0
  231. package/docs/guides/authorize-network-and-model-calls.md +118 -0
  232. package/docs/guides/contain-spawned-processes.md +118 -0
  233. package/docs/guides/guard-a-host-bundle.md +109 -0
  234. package/docs/guides/persist-grants-across-restarts.md +135 -0
  235. package/docs/guides/write-a-capability-policy.md +159 -0
  236. package/docs/installation.md +115 -0
  237. package/docs/quickstart.md +182 -0
  238. package/docs/testing.md +108 -0
  239. package/docs/troubleshooting.md +281 -0
  240. package/package.json +344 -3
  241. package/src/CapabilitySet.ts +6 -0
  242. package/src/ChildProcessEnvironment.ts +98 -0
  243. package/src/ChildProcessSpawner.ts +414 -0
  244. package/src/CommandLine.ts +250 -0
  245. package/src/ContainedSpawner.ts +284 -0
  246. package/src/FileSystem.ts +1655 -0
  247. package/src/FileSystemBatch.ts +90 -0
  248. package/src/GrantEvent.ts +174 -0
  249. package/src/GrantStore.ts +1345 -0
  250. package/src/HostError.ts +37 -0
  251. package/src/HostServices.ts +133 -0
  252. package/src/HttpClient.ts +406 -0
  253. package/src/Jj.ts +159 -0
  254. package/src/JournalGrantStore.ts +461 -0
  255. package/src/Path.ts +47 -0
  256. package/src/ProcessConfinement.ts +156 -0
  257. package/src/ProcessLedger.ts +423 -0
  258. package/src/Rooted.ts +128 -0
  259. package/src/Unreachable.ts +30 -0
  260. package/src/Workspace.ts +65 -0
  261. package/src/index.ts +181 -0
  262. package/src/internal/Containment.ts +37 -0
  263. package/src/internal/Pipeline.ts +85 -0
  264. package/src/internal/makeCapability.ts +26 -0
  265. package/src/test/FileSystemBatchContract.ts +59 -0
  266. package/src/test/HostContract.ts +785 -0
  267. package/src/test/TestGrantStore.ts +73 -0
@@ -0,0 +1,156 @@
1
+ /** Operating-system confinement derived conservatively from effective grants.
2
+ * @since 1.0.0-rc.1
3
+ */
4
+
5
+ import {
6
+ type Action,
7
+ Capability,
8
+ CapabilityPattern,
9
+ matches,
10
+ maxResourceLength,
11
+ subsumes,
12
+ withinMatchBudget
13
+ } from "@smthrs/capability/Capability"
14
+ import type { GrantStoreError } from "@smthrs/capability/Permission"
15
+ import { Context, Effect, Layer, type Path, type Scope } from "effect"
16
+ import type { PlatformError } from "effect/PlatformError"
17
+ import type * as ChildProcess from "effect/unstable/process/ChildProcess"
18
+ import type { Policy, PolicyRule, Service as GrantService } from "./GrantStore.ts"
19
+
20
+ /** Workspace-relative trees; `.` names the workspace. No path opens its parent.
21
+ * @category models
22
+ * @since 1.0.0-rc.1
23
+ */
24
+ export interface Profile {
25
+ readonly workspaceRoot: string
26
+ readonly reads: ReadonlyArray<string>
27
+ readonly writes: ReadonlyArray<string>
28
+ readonly readOnly: ReadonlyArray<string>
29
+ readonly network: "none" | "open"
30
+ }
31
+ /** Host wrapper for one process stage. Failure must occur before spawning.
32
+ * @category models
33
+ * @since 1.0.0-rc.1
34
+ */
35
+ export interface Service {
36
+ readonly confine: (
37
+ command: ChildProcess.StandardCommand,
38
+ profile: Profile
39
+ ) => Effect.Effect<ChildProcess.StandardCommand, PlatformError, Scope.Scope>
40
+ }
41
+ /** Host confinement seam; missing service refuses spawning.
42
+ * @category services
43
+ * @since 1.0.0-rc.1
44
+ */
45
+ export class ProcessConfinement
46
+ extends Context.Service<ProcessConfinement, Service>()("@smthrs/kernel/ProcessConfinement")
47
+ {}
48
+ /** Explicit unconfined composition.
49
+ * @category constructors
50
+ * @since 1.0.0-rc.1
51
+ */
52
+ export const makeNoop: Service = ProcessConfinement.of({ confine: (command) => Effect.succeed(command) })
53
+ /** Explicit unconfined layer.
54
+ * @category layers
55
+ * @since 1.0.0-rc.1
56
+ */
57
+ export const layerNoop: Layer.Layer<ProcessConfinement> = Layer.succeed(ProcessConfinement)(makeNoop)
58
+
59
+ const decidable = (pattern: CapabilityPattern, action: Action) =>
60
+ withinMatchBudget(pattern, new Capability({ action, resource: "x".repeat(maxResourceLength) }))
61
+
62
+ // The capability matcher permits a single star to cross separators. Only
63
+ // this universal spelling is equivalent to **; arbitrary globs stay closed.
64
+ const universal = (pattern: CapabilityPattern): CapabilityPattern =>
65
+ pattern.resource === "*" ? new CapabilityPattern({ action: pattern.action, resource: "**" }) : pattern
66
+
67
+ const coversCeiling = (ceiling: PolicyRule["ceiling"], target: CapabilityPattern): boolean =>
68
+ ceiling.groups.every((group) =>
69
+ group.some((pattern) => decidable(pattern, target.action as Action) && subsumes(universal(pattern), target))
70
+ )
71
+ const actionRelevant = (pattern: CapabilityPattern, action: Action): boolean =>
72
+ matches(new CapabilityPattern({ action: pattern.action, resource: "**" }), new Capability({ action, resource: "" }))
73
+
74
+ // A covering rule resolves the complete region. A narrower/conditional refusal
75
+ // makes it unknown until a later covering rule resolves it. Unknown configured
76
+ // denial vetoes all later grant groups, just as evaluate's configured veto does.
77
+ const prove = (policy: Policy, target: CapabilityPattern): boolean => {
78
+ if (!coversCeiling(policy.ceiling, target)) return false
79
+ let decision: "allow" | "deny" | "ask" | "unknown" = "ask"
80
+ let configured: "allow" | "deny" | "ask" | "unknown" = "ask"
81
+ for (const [index, group] of policy.groups.entries()) {
82
+ for (const { rule, ceiling } of group) {
83
+ if (!decidable(rule.pattern, target.action as Action)) return false
84
+ if (!actionRelevant(rule.pattern, target.action as Action)) continue
85
+ if (target.action === "net:private" && rule.effect === "allow" && rule.pattern.action !== "net:private") continue
86
+ if (coversCeiling(ceiling, target) && subsumes(universal(rule.pattern), target)) decision = rule.effect
87
+ else if (rule.effect !== "allow") decision = "unknown"
88
+ if (index === 0) configured = decision
89
+ }
90
+ }
91
+ return configured !== "deny" && configured !== "unknown" && decision === "allow"
92
+ }
93
+
94
+ /** Derives a subset of authority, never a widening. Literal filesystem grants,
95
+ * complex globs and unrepresentable restrictions conservatively open nothing.
96
+ * Writable trees also require read authority and write authority over their
97
+ * exact directory root: native mounts permit reads and directory metadata
98
+ * changes. Only whole-tree grants prove filesystem trees; unrestricted
99
+ * authority for each of get/post/private is required to open network access.
100
+ * @category resolution
101
+ * @since 1.0.0-rc.1
102
+ */
103
+ export const profile = (
104
+ grants: GrantService,
105
+ workspaceRoot: string,
106
+ path: Path.Path
107
+ ): Effect.Effect<Profile, GrantStoreError> =>
108
+ Effect.gen(function*() {
109
+ const policy = yield* grants.policy
110
+ const root = path.resolve(workspaceRoot)
111
+ const resources = [
112
+ ...policy.groups.flatMap((group) =>
113
+ group.flatMap(({ rule, ceiling }) => [rule.pattern, ...ceiling.groups.flat()])
114
+ ),
115
+ ...policy.ceiling.groups.flat()
116
+ ]
117
+ const candidates = new Set<string>()
118
+ for (const { resource } of resources) {
119
+ if (resource === "**" || resource === "*") {
120
+ candidates.add(".")
121
+ continue
122
+ }
123
+ if (!resource.endsWith("/**") || /[*?]/.test(resource.slice(0, -3))) continue
124
+ const base = resource.slice(0, -3)
125
+ // Relative policy resources do not authorize absolute kernel resources.
126
+ if (!path.isAbsolute(base) || path.normalize(base) !== base) continue
127
+ const relative = path.relative(root, base)
128
+ if (path.isAbsolute(relative) || relative.split(path.sep)[0] === "..") continue
129
+ candidates.add(relative === "" ? "." : relative.split(path.sep).join("/"))
130
+ }
131
+ const opened = (action: Action) =>
132
+ [...candidates].filter((relative) => {
133
+ const directory = relative === "." ? root : path.resolve(root, relative)
134
+ const resource = `${directory}/**`
135
+ return prove(policy, new CapabilityPattern({ action, resource })) &&
136
+ (action !== "fs:write" || (
137
+ prove(policy, new CapabilityPattern({ action: "fs:read", resource })) &&
138
+ prove(policy, new CapabilityPattern({ action: "fs:write", resource: directory }))
139
+ ))
140
+ })
141
+ .filter((relative, _, all) =>
142
+ !all.some((parent) => parent !== relative && (parent === "." || relative.startsWith(`${parent}/`)))
143
+ )
144
+ .sort()
145
+ return Object.freeze({
146
+ workspaceRoot: root,
147
+ reads: Object.freeze(opened("fs:read")),
148
+ writes: Object.freeze(opened("fs:write")),
149
+ readOnly: Object.freeze([]),
150
+ network: (["net:get", "net:post", "net:private"] as const).every((action) =>
151
+ prove(policy, new CapabilityPattern({ action, resource: "**" }))
152
+ ) ?
153
+ "open" as const :
154
+ "none" as const
155
+ })
156
+ })
@@ -0,0 +1,423 @@
1
+ /**
2
+ * The host's ledger of the processes it spawned.
3
+ *
4
+ * A cancelled run must not leave a child agent running, and the engine cannot
5
+ * do anything about that on its own: cancellation interrupts fibers, and a
6
+ * fiber closing its scope only reaches the processes THIS incarnation of the
7
+ * host still holds a handle for. A host that crashed left its children with no
8
+ * one holding anything.
9
+ *
10
+ * The ledger is the missing half. Every spawn is recorded here in memory and
11
+ * written to the journal as an ownerless durable record. It is ownerless
12
+ * because it describes the host rather than a run, and first-writer-wins is
13
+ * exactly the semantics a spawn record wants. The next incarnation of the same
14
+ * `hostId` replays those records, subtracts the ones that reported an exit,
15
+ * and is left with {@link Service.orphans}: the process groups a dead owner
16
+ * abandoned, which a platform reaper can then signal.
17
+ *
18
+ * The kernel stays BELOW the engine: this module records pids and process
19
+ * groups and knows nothing about runs, attempts, or ownership fences. It also
20
+ * stays platform-neutral, because signalling a process group is a platform
21
+ * concern and lives in `@smthrs/platform-node`.
22
+ *
23
+ * Governing design:
24
+ * `docs/specs/Concepts/Host Adapters.md` and
25
+ * `docs/specs/Concepts/Journal Queue.md`.
26
+ *
27
+ * @since 1.0.0-rc.0
28
+ */
29
+
30
+ import * as JournalModule from "@smthrs/journal/Journal"
31
+ import type { JournalError } from "@smthrs/journal/Journal"
32
+ import * as JournalEvent from "@smthrs/journal/JournalEvent"
33
+ import * as Context from "effect/Context"
34
+ import * as Effect from "effect/Effect"
35
+ import * as Layer from "effect/Layer"
36
+ import * as Schema from "effect/Schema"
37
+
38
+ /**
39
+ * The event type recording that a host started a process.
40
+ *
41
+ * @category models
42
+ * @since 1.0.0-rc.0
43
+ */
44
+ export const SpawnedEventType = "flows.host.process-spawned.v1"
45
+
46
+ /**
47
+ * The event type recording that a recorded process ended.
48
+ *
49
+ * @category models
50
+ * @since 1.0.0-rc.0
51
+ */
52
+ export const ExitedEventType = "flows.host.process-exited.v1"
53
+
54
+ /**
55
+ * The event type recording that a later incarnation killed an abandoned
56
+ * process group.
57
+ *
58
+ * @category models
59
+ * @since 1.0.0-rc.0
60
+ */
61
+ export const ReapedEventType = "flows.host.process-reaped.v1"
62
+
63
+ /**
64
+ * The event type recording that a later incarnation retired a record WITHOUT
65
+ * signalling anything.
66
+ *
67
+ * A stored pid outlives the process that wrote it, so a reaper that cannot
68
+ * prove the number still names the process the record describes must not
69
+ * signal it. Retiring the record is still correct: nothing will ever be able
70
+ * to prove it again, and leaving it would make every later incarnation
71
+ * re-examine a number the operating system has moved on from.
72
+ *
73
+ * @category models
74
+ * @since 1.0.0-rc.0
75
+ */
76
+ export const SkippedEventType = "flows.host.process-reap-skipped.v1"
77
+
78
+ /**
79
+ * The producer id every ledger record is written under.
80
+ *
81
+ * Replay checks it: a record forged by another producer cannot make a host
82
+ * signal a process group.
83
+ *
84
+ * @category models
85
+ * @since 1.0.0-rc.0
86
+ */
87
+ export const sourceId = "@smthrs/kernel/ProcessLedger"
88
+
89
+ /**
90
+ * The journal run a host's process records live on.
91
+ *
92
+ * Process records describe a host, not a run, so they get a run of their own,
93
+ * named after the host. Two incarnations of one `hostId` therefore share a
94
+ * history, which is what makes an abandoned process discoverable.
95
+ *
96
+ * @category constructors
97
+ * @since 1.0.0-rc.0
98
+ */
99
+ export const hostRunId = (hostId: string): JournalEvent.RunId => `flows.host:${hostId}` as JournalEvent.RunId
100
+
101
+ /**
102
+ * What the spawner knows about a process the moment it starts.
103
+ *
104
+ * `pgid` is `null` when the child shares the host's own process group. Such a
105
+ * process cannot be group-signalled — signalling that group would signal the
106
+ * host itself — so the ledger records the absence rather than a number a
107
+ * reaper could misread.
108
+ *
109
+ * `commandDigest` names the executable and nothing else, as
110
+ * `CommandLine.executable` renders it. Arguments carry credentials
111
+ * (`curl -u user:password`, `mysql -phunter2`) and these records are permanent
112
+ * journal entries, so the ledger never writes an argument down. A reader that
113
+ * has to act on a record matches it by pid and process group; the program name
114
+ * is there to make the row legible.
115
+ *
116
+ * @category models
117
+ * @since 1.0.0-rc.0
118
+ */
119
+ export interface Spawned {
120
+ readonly pid: number
121
+ readonly pgid: number | null
122
+ readonly commandDigest: string
123
+ }
124
+
125
+ /**
126
+ * Schema for a recorded process, as it round-trips through the journal.
127
+ *
128
+ * @category schemas
129
+ * @since 1.0.0-rc.0
130
+ */
131
+ export const ProcessRecord = Schema.Struct({
132
+ pid: Schema.Int,
133
+ pgid: Schema.NullOr(Schema.Int),
134
+ hostId: Schema.String,
135
+ ownerPid: Schema.Int,
136
+ startedAtMs: Schema.Int,
137
+ commandDigest: Schema.String
138
+ })
139
+
140
+ /**
141
+ * One process a host started, with the identity of the incarnation that
142
+ * started it.
143
+ *
144
+ * @category models
145
+ * @since 1.0.0-rc.0
146
+ */
147
+ export type ProcessRecord = typeof ProcessRecord.Type
148
+
149
+ /**
150
+ * Why a ledger history could not be replayed.
151
+ *
152
+ * - `journal_unreadable`: a page of the host's history could not be read.
153
+ * - `cursor_stalled`: the journal reported more history but did not advance.
154
+ *
155
+ * @category models
156
+ * @since 1.0.0-rc.1
157
+ */
158
+ export const ProcessLedgerReplayErrorCode = Schema.Literals(["journal_unreadable", "cursor_stalled"])
159
+
160
+ /**
161
+ * The ledger could not read a previous incarnation's history to its end.
162
+ *
163
+ * An unread history is never reported as an empty one: a caller that treated
164
+ * it as empty would leave every abandoned child running with no owner.
165
+ *
166
+ * @category errors
167
+ * @since 1.0.0-rc.1
168
+ */
169
+ export class ProcessLedgerReplayError extends Schema.TaggedError<ProcessLedgerReplayError>()(
170
+ "@smthrs/kernel/ProcessLedgerReplayError",
171
+ {
172
+ code: ProcessLedgerReplayErrorCode,
173
+ message: Schema.String,
174
+ cause: Schema.optional(Schema.Defect())
175
+ }
176
+ ) {}
177
+
178
+ const decodeRecord = Schema.decodeUnknownResult(ProcessRecord)
179
+ const encodeRecord = Schema.encodeSync(ProcessRecord)
180
+
181
+ /**
182
+ * Ledger operations.
183
+ *
184
+ * Every write that has a durable half REPORTS whether that half committed.
185
+ * The in-memory set is still updated first, so a caller that decides to carry
186
+ * on has an accurate view of this incarnation, but the failure is not
187
+ * swallowed: a spawn whose record did not commit is a child that no later
188
+ * incarnation can discover, which is the exact hole this ledger exists to
189
+ * close. {@link ContainedSpawner} therefore refuses such a spawn, and the
190
+ * reaper leaves a record it could not retire in place so a later incarnation
191
+ * tries again.
192
+ *
193
+ * A ledger built with no journal ({@link makeMemory}) never fails: it promises
194
+ * nothing durable in the first place.
195
+ *
196
+ * @category services
197
+ * @since 1.0.0-rc.0
198
+ */
199
+ export interface Service {
200
+ /** Records a started process and returns the durable record written for it. */
201
+ readonly record: (spawned: Spawned) => Effect.Effect<ProcessRecord, JournalError>
202
+ /** Records that a process ended, and drops it from {@link Service.live}. */
203
+ readonly release: (record: ProcessRecord) => Effect.Effect<void, JournalError>
204
+ /** Records that an abandoned process group was signalled by this host. */
205
+ readonly reaped: (record: ProcessRecord) => Effect.Effect<void, JournalError>
206
+ /**
207
+ * Retires a record this host refused to signal, naming why. The record
208
+ * leaves {@link Service.orphans} without anything having been killed.
209
+ */
210
+ readonly skipped: (record: ProcessRecord, reason: string) => Effect.Effect<void, JournalError>
211
+ /** The processes this incarnation started and has not yet released. */
212
+ readonly live: Effect.Effect<ReadonlyArray<ProcessRecord>>
213
+ /**
214
+ * The processes a previous incarnation of this host left running.
215
+ *
216
+ * Fails when that history cannot be read to its end, so an unreadable
217
+ * history is never mistaken for one with nothing left running.
218
+ */
219
+ readonly orphans: Effect.Effect<ReadonlyArray<ProcessRecord>, ProcessLedgerReplayError>
220
+ }
221
+
222
+ /**
223
+ * The process-ledger service tag.
224
+ *
225
+ * @category services
226
+ * @since 1.0.0-rc.0
227
+ */
228
+ export class ProcessLedger extends Context.Service<ProcessLedger, Service>()("@smthrs/kernel/ProcessLedger") {}
229
+
230
+ /**
231
+ * Ledger configuration.
232
+ *
233
+ * `hostId` is the durable identity two incarnations share; `ownerPid` is the
234
+ * process id of THIS incarnation, which is how a replay tells its own live
235
+ * processes from an abandoned one's.
236
+ *
237
+ * @category models
238
+ * @since 1.0.0-rc.0
239
+ */
240
+ export interface Options {
241
+ readonly hostId: string
242
+ readonly ownerPid: number
243
+ }
244
+
245
+ /** Where records are appended, and where a previous incarnation's are read. */
246
+ interface Sink {
247
+ readonly append: (
248
+ eventType: string,
249
+ record: ProcessRecord,
250
+ extra?: Record<string, unknown>
251
+ ) => Effect.Effect<void, JournalError>
252
+ readonly replay: Effect.Effect<ReadonlyArray<ProcessRecord>, ProcessLedgerReplayError>
253
+ }
254
+
255
+ /** The event types that retire a record from the inherited set. */
256
+ const retires = (eventType: string): boolean =>
257
+ eventType === ExitedEventType || eventType === ReapedEventType || eventType === SkippedEventType
258
+
259
+ /** A pid may be reused before the previous process's scope releases its record. */
260
+ const identity = (record: ProcessRecord): string => `${record.pid}:${record.startedAtMs}`
261
+
262
+ /**
263
+ * One entry folded onto the live set.
264
+ *
265
+ * Returns the entry when it is one of the ledger's own records but its payload
266
+ * does not decode, so replay can report it. Such an entry names no process
267
+ * that could be signalled, so failing the whole replay over it would stop
268
+ * every later incarnation from reaping the well-formed records beside it.
269
+ */
270
+ const apply = (live: Map<string, ProcessRecord>, entry: JournalEvent.Entry): JournalEvent.Entry | undefined => {
271
+ if (entry.sourceId !== sourceId) return undefined
272
+ if (entry.eventType !== SpawnedEventType && !retires(entry.eventType)) return undefined
273
+ const decoded = decodeRecord(entry.payload)
274
+ if (decoded._tag === "Failure") return entry
275
+ if (entry.eventType === SpawnedEventType) {
276
+ live.set(identity(decoded.success), decoded.success)
277
+ return
278
+ }
279
+ live.delete(identity(decoded.success))
280
+ return undefined
281
+ }
282
+
283
+ const PAGE = 256
284
+
285
+ const journalSink = (options: Options, journal: JournalModule.Service): Sink => ({
286
+ append: (eventType, record, extra) =>
287
+ journal.emitDurableUnfenced(
288
+ new JournalEvent.Input({
289
+ runId: hostRunId(options.hostId),
290
+ sourceId: sourceId as JournalEvent.SourceId,
291
+ eventType,
292
+ payload: extra === undefined ? encodeRecord(record) : { ...encodeRecord(record), ...extra }
293
+ })
294
+ ).pipe(
295
+ // The failure is REPORTED, not logged away. A swallowed write leaves a
296
+ // child that no incarnation of this host can discover, which is the one
297
+ // outcome containment exists to prevent; the caller decides what to do
298
+ // about it, and every caller in this repository declines to pretend the
299
+ // process was recorded.
300
+ Effect.tapCause((cause) => Effect.logWarning(`process ledger could not journal ${eventType}`, cause)),
301
+ Effect.asVoid
302
+ ),
303
+ replay: Effect.gen(function*() {
304
+ const live = new Map<string, ProcessRecord>()
305
+ let after: JournalEvent.Seq | undefined
306
+ for (;;) {
307
+ const page = yield* journal.entries({
308
+ runId: hostRunId(options.hostId),
309
+ limit: PAGE,
310
+ ...(after === undefined ? {} : { after })
311
+ }).pipe(
312
+ Effect.mapError((cause) =>
313
+ new ProcessLedgerReplayError({
314
+ code: "journal_unreadable",
315
+ message: `process ledger could not read the history of host ${options.hostId}`,
316
+ cause
317
+ })
318
+ )
319
+ )
320
+ for (const entry of page.entries) {
321
+ const malformed = apply(live, entry)
322
+ if (malformed !== undefined) {
323
+ yield* Effect.logWarning(
324
+ `process ledger skipped an undecodable ${malformed.eventType} record at seq ${malformed.seq}`
325
+ )
326
+ }
327
+ }
328
+ const last = page.entries.at(-1)
329
+ if (last === undefined || !page.hasMore) break
330
+ // A page that ends where the previous one did would be replayed
331
+ // forever, and stopping there would report a partial history as whole.
332
+ if (after !== undefined && last.seq <= after) {
333
+ return yield* Effect.fail(
334
+ new ProcessLedgerReplayError({
335
+ code: "cursor_stalled",
336
+ message: `process ledger history of host ${options.hostId} stopped advancing at seq ${last.seq}`
337
+ })
338
+ )
339
+ }
340
+ after = last.seq
341
+ }
342
+ return [...live.values()]
343
+ })
344
+ })
345
+
346
+ const memorySink: Sink = {
347
+ append: () => Effect.void,
348
+ replay: Effect.succeed([])
349
+ }
350
+
351
+ const makeWith = (options: Options, sink: Sink): Service => {
352
+ const live = new Map<string, ProcessRecord>()
353
+ return ProcessLedger.of({
354
+ record: (spawned) =>
355
+ Effect.gen(function*() {
356
+ const startedAtMs = yield* Effect.clockWith((clock) => clock.currentTimeMillis)
357
+ const record: ProcessRecord = {
358
+ pid: spawned.pid,
359
+ pgid: spawned.pgid,
360
+ hostId: options.hostId,
361
+ ownerPid: options.ownerPid,
362
+ startedAtMs,
363
+ commandDigest: spawned.commandDigest
364
+ }
365
+ live.set(identity(record), record)
366
+ // A failed append tells the caller the spawn was not recorded, and the
367
+ // caller kills the child. Keeping it live would lie to every later
368
+ // reader about a process this incarnation still holds.
369
+ yield* sink.append(SpawnedEventType, record).pipe(
370
+ Effect.tapCause(() => Effect.sync(() => live.delete(identity(record))))
371
+ )
372
+ return record
373
+ }),
374
+ release: (record) =>
375
+ Effect.suspend(() => {
376
+ live.delete(identity(record))
377
+ return sink.append(ExitedEventType, record)
378
+ }),
379
+ reaped: (record) => sink.append(ReapedEventType, record),
380
+ skipped: (record, reason) => sink.append(SkippedEventType, record, { reason }),
381
+ live: Effect.sync(() => [...live.values()]),
382
+ orphans: Effect.map(sink.replay, (records) => records.filter((record) => record.ownerPid !== options.ownerPid))
383
+ })
384
+ }
385
+
386
+ /**
387
+ * Builds a journal-backed ledger.
388
+ *
389
+ * @category constructors
390
+ * @since 1.0.0-rc.0
391
+ */
392
+ export const make = (options: Options): Effect.Effect<Service, never, JournalModule.Journal> =>
393
+ Effect.map(JournalModule.Journal, (journal) => makeWith(options, journalSink(options, journal)))
394
+
395
+ /**
396
+ * Builds a ledger that records nothing durably.
397
+ *
398
+ * A host without a journal still contains the processes it holds handles for —
399
+ * scope closure does that — but it inherits nothing from a previous
400
+ * incarnation, so {@link Service.orphans} is always empty.
401
+ *
402
+ * @category constructors
403
+ * @since 1.0.0-rc.0
404
+ */
405
+ export const makeMemory = (options: Options): Effect.Effect<Service> => Effect.sync(() => makeWith(options, memorySink))
406
+
407
+ /**
408
+ * Provides a journal-backed ledger.
409
+ *
410
+ * @category layers
411
+ * @since 1.0.0-rc.0
412
+ */
413
+ export const layer = (options: Options): Layer.Layer<ProcessLedger, never, JournalModule.Journal> =>
414
+ Layer.effect(ProcessLedger, make(options))
415
+
416
+ /**
417
+ * Provides a ledger that records nothing durably.
418
+ *
419
+ * @category layers
420
+ * @since 1.0.0-rc.0
421
+ */
422
+ export const layerMemory = (options: Options): Layer.Layer<ProcessLedger> =>
423
+ Layer.effect(ProcessLedger, makeMemory(options))