@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,117 @@
1
+ ---
2
+ title: "Decoration in place"
3
+ description: "Why the kernel guards the platform's own service tags instead of publishing protected copies: the closed port list, what each slot's error channel becomes, and what is deliberately not on the list."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ A capability kernel has to answer one question before anything else: how does
9
+ a caller get the guarded service rather than the raw one? The usual answer is
10
+ a second tag. The platform provides `FileSystem`, the kernel provides
11
+ `ProtectedFileSystem`, and every consumer is told to ask for the second. That
12
+ answer fails the moment a dependency does not know about the kernel, and it
13
+ leaves two tag lists to keep in sync.
14
+
15
+ The kernel does the opposite. Each guarded implementation is a middleware
16
+ `Layer` over the very tag the platform adapter provides: it requires
17
+ `FileSystem` and it provides `FileSystem`. Composed over a host bundle, the
18
+ guarded implementation shadows the raw one for everything downstream. A
19
+ library that has never heard of Smithers is guarded because there is nothing
20
+ else for it to resolve.
21
+
22
+ ```text
23
+ raw platform service
24
+ |
25
+ kernel decorator -> GrantStore
26
+ |
27
+ flow-visible service
28
+ ```
29
+
30
+ `HostServices.layer` is those five middleware layers merged, so one
31
+ composition guards the whole surface at once.
32
+
33
+ ## The closed list
34
+
35
+ Five tags, and everything that touches the outside world enters through one of
36
+ them:
37
+
38
+ | Slot | Tag | Owner | Actions checked |
39
+ | ---------- | --------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
40
+ | Filesystem | `FileSystem` | Effect | `fs:read`, `fs:write` |
41
+ | Paths | `Path` | Effect | none, by decision |
42
+ | Processes | `ChildProcessSpawner` | Effect | `proc:spawn` |
43
+ | Repository | `Jj` | [`@smthrs/jj`](/api/jj) | `jj:status`, `jj:diff`, `jj:snapshot`, `jj:restore`, `jj:workspace-add`, `jj:workspace-forget`, `jj:root`, `jj:revert`, `jj:op-restore` |
44
+ | Network | `HttpClient` | Effect | `net:get`, `net:post`, `model:call` |
45
+
46
+ `HostServices.HostServiceTags` is that list as values and
47
+ `HostServices.HostServiceIds` is the matching stable id per slot
48
+ (`"effect/FileSystem"`, `"effect/Path"`,
49
+ `"effect/process/ChildProcessSpawner"`, `"@smthrs/jj/Jj"`,
50
+ `"effect/HttpClient"`).
51
+
52
+ Two things depend on the list being closed. A service that is not on it cannot
53
+ be attenuated, denied, or audited, so it must not exist. And the list is part
54
+ of what a durable run records about itself: [`@smthrs/engine`](/api/engine)
55
+ folds the implementations that were in scope into the identity of every step it
56
+ caches, so a replay under a different set of them is a different step rather
57
+ than a silent substitution. The slot ids are durable for that reason too, and
58
+ change only when the service behind a slot changes.
59
+
60
+ ## Four of the five tags are Effect's
61
+
62
+ Smithers supplies implementations of Effect's `FileSystem`, `Path`,
63
+ `ChildProcessSpawner`, and `HttpClient` rather than wrappers around them. That
64
+ choice decides where a refusal surfaces.
65
+
66
+ Where Effect owns the tag, the error channel stays Effect's own, and the
67
+ kernel projects its failure into it:
68
+
69
+ - `FileSystem` and `ChildProcessSpawner` refusals become a `PlatformError`
70
+ whose reason is `PermissionDenied`, carrying the structured kernel failure
71
+ on `cause`. `Permission.fromPlatformError` reads it back.
72
+ - `HttpClient` refusals become an `HttpClientError` whose reason is a
73
+ `TransportError`, again carrying the kernel failure.
74
+ `HttpClient.fromHttpClientError` reads it back.
75
+
76
+ Where Smithers owns the service, the interface names the failure directly:
77
+ `Jj` fails with `JjError | PermissionError`, and nothing has to be projected
78
+ or read back.
79
+
80
+ The asymmetry is not an accident. Widening Effect's error channels would mean
81
+ forking Effect's service interfaces; hiding the kernel failure inside an
82
+ opaque platform error would mean losing the capability, the reason, and the
83
+ stable code. Projection with the original on the cause keeps both.
84
+
85
+ ## What is not on the list
86
+
87
+ **`Path` is on the list and checks nothing.** It is pure string manipulation:
88
+ `path.resolve` reads no directory and creates no file, so there is no
89
+ authority to guard. The slot exists as an explicit decision rather than an
90
+ omission. `Path.layer` resolves relative paths against `Workspace.root`, the
91
+ directory `ChildProcessSpawner.layer` runs a command in when it names no
92
+ `cwd`, so neither depends on the process's own directory.
93
+
94
+ **`Clock` and `Random` are Effect core built-ins.** They are already
95
+ port-shaped and already swappable with `Effect.provideService`, and neither
96
+ carries host authority, so they are not the kernel's to define or decorate.
97
+
98
+ **There is no Smithers transport port.** Network access is Effect's
99
+ `HttpClient` and the kernel decorates it. A raw Smithers transport beneath it
100
+ would be a second way to reach the network whose contract never mentions
101
+ permission, which is exactly the hole this design closes.
102
+
103
+ ## What decoration cannot see
104
+
105
+ The kernel checks capabilities at adapter call sites. It does not sandbox the
106
+ operating system. Code that imports `node:fs` directly, or a native addon that
107
+ opens a socket, never passes through a decorated service and is not observed.
108
+ That is a real boundary, not a caveat to bury: confinement against untrusted
109
+ code requires an operating-system sandbox as well, and hermetic execution
110
+ requires a `StepBoundary` from [`@smthrs/engine`](/api/engine).
111
+
112
+ ## Related
113
+
114
+ - [How a grant decision is made](./grant-decisions.md): what happens inside
115
+ the check each decorator performs.
116
+ - [Guard a host bundle](../guides/guard-a-host-bundle.md): the composition,
117
+ in order, with the layers a real host adds.
@@ -0,0 +1,58 @@
1
+ ---
2
+ title: "Bounded filesystem batches"
3
+ description: "Optional read batches on the guarded filesystem, with per-path grants, root identity, explicit failures, and portable fallback."
4
+ ---
5
+
6
+ `FileSystem.batch(fs)` from `@smthrs/kernel/FileSystem` discovers an optional
7
+ capability on the existing Effect filesystem service. It adds no host slot and
8
+ changes no persisted host identity. A native adapter advertises `batchLimits`
9
+ on its descriptor-relative executor. The kernel exposes the guarded capability
10
+ after binding the workspace root; callers never receive the raw executor.
11
+
12
+ ```ts
13
+ const batch = FileSystem.batch(fs)
14
+ if (batch !== undefined) {
15
+ const response = yield * batch.execute([
16
+ { operation: "stat", path: "src/a.ts" },
17
+ { operation: "digest", path: "src/b.ts" },
18
+ { operation: "readDirectory", path: "src" }
19
+ ])
20
+ }
21
+ ```
22
+
23
+ A batch accepts 1 through `maxSize` requests, with a hard maximum of 128.
24
+ `maxResponseBytes` bounds the aggregate encoded answer. Inputs are copied
25
+ before asynchronous root or permission checks. Every member gets the same
26
+ canonical `fs:read` check and post-grant re-resolution as an individual call.
27
+ Each grant or quota refusal remains an explicit per-path `Result` carrying its
28
+ original `PlatformError`. A batch with no admitted members starts no helper.
29
+
30
+ Each response carries `rootIdentity`, the root device/inode checked by the
31
+ executor. Members retain their original request `index` and normalized `path`,
32
+ and sort by path and then index. Successful values discriminate `stat`,
33
+ `readDirectory`, `glob`, and `digest`. A digest contains 64 lowercase SHA-256
34
+ hex digits, its byte count, and bytes only when requested with `content: true`.
35
+ Missing paths report `NotFound`; they are not silently dropped. The engine
36
+ decides where absence means an empty expansion or an absent digest.
37
+
38
+ Root loss or replacement refuses the entire operation. Helper failure,
39
+ cancellation, deadline, malformed framing, and aggregate quota exhaustion also
40
+ produce no partial batch. Ordinary path failures appear individually so an
41
+ unrelated authorized path can still be measured. A batch is not a transaction
42
+ or an assertion that the workspace was frozen.
43
+
44
+ When the workspace root is a symlink, its logical path must still resolve to
45
+ the captured root inode before and after grants. Retargeting the alias refuses
46
+ the batch even if the original canonical directory still exists. The helper
47
+ independently verifies that canonical directory against the same inode.
48
+
49
+ Hosts may omit the capability. The engine then uses the existing guarded
50
+ operations with at most four operations in flight. Isolated test/browser
51
+ volumes keep their existing attestation; optional batching does not weaken it.
52
+ The shared HostContract exercises each adapter that advertises batching, while
53
+ the Node conformance tests additionally exercise the confined helper's fault
54
+ and resource boundaries.
55
+
56
+ Host glob grammar remains host-specific. Declared engine globs continue to use
57
+ the plan's matcher, including dotfiles and its metadata-directory pruning, over
58
+ batched directory/stat operations.
@@ -0,0 +1,168 @@
1
+ ---
2
+ title: "Filesystem confinement"
3
+ description: "Why authorizing a pathname is not enough: canonical resources, the re-resolution after a suspended grant, descriptor identity, and the two host extensions that make confinement enforceable."
4
+ sidebar:
5
+ order: 3
6
+ ---
7
+
8
+ Guarding a filesystem is harder than guarding a process, because a path is not
9
+ a thing. It is a name that something else can rebind between the moment you
10
+ authorize it and the moment the host acts on it. The naive decorator, check
11
+ the pathname and then hand the same pathname to the host, is vulnerable to a
12
+ symlink swap: authorize `/workspace/notes.txt`, and by the time the host
13
+ resolves it the name points at `/etc/shadow`.
14
+
15
+ Everything in this page exists to close that window.
16
+
17
+ ## The capability resource is canonical, not literal
18
+
19
+ `FileSystem.canonicalResource(fileSystem, path, workspaceRoot, value)` is what
20
+ turns a caller's string into the resource a capability names. It resolves the
21
+ path through every existing ancestor, so an existing symlink cannot turn an
22
+ inside-workspace grant into outside authority. Then it maps a canonical path
23
+ that lands inside the workspace back to the stable logical workspace root, so
24
+ capability resources stay readable and stable even when the root itself is a
25
+ symlink. A relative path is resolved against the workspace root first.
26
+
27
+ Every host, including a descriptor-relative executor, resolves existing
28
+ components to their on-disk spelling before and after a grant decision.
29
+ On a case-insensitive volume, `.ENV` therefore names `.env` for permission
30
+ checks, and `REPORTDIR/new.txt` names `ReportDir/new.txt` when `ReportDir`
31
+ already exists. Missing components retain their requested spelling. A
32
+ path-based host must implement `realPath` with canonical on-disk spelling. The
33
+ capability matcher remains case-sensitive so distinct files on case-sensitive
34
+ volumes keep distinct permissions.
35
+
36
+ Native guarded operations on outside-pointing symlinks fail with
37
+ `PermissionDenied`, including `readLink`, `stat`, `exists`, and `remove`.
38
+ Authorization for a descriptor-relative executor never resolves a pathname on
39
+ the host. This matters on Windows, where `realpath` opens its argument through
40
+ links: one call on a link to `\\.\pipe\name` or `\\host\share` connects to
41
+ that pipe or share. Instead the executor answers one `resolve` request from the
42
+ pinned root. It walks without following any link, junction, or mount point,
43
+ and answers the on-disk spelling of existing components, or the first link on
44
+ the way with its text. Link text is resolved lexically and asked again, so a
45
+ component swapped for a link between two checks is seen as that link. A
46
+ reparse point the executor cannot translate, such as a volume mount point, is
47
+ refused without being traversed.
48
+
49
+ Each guarded operation asks the executor to resolve its path before and after
50
+ the grant decision, and then runs the operation itself. A guarded batch asks
51
+ once for all of its members before the grants, once after them, and then runs
52
+ the batch.
53
+
54
+ Capability resources are native paths, and patterns match them as text. On
55
+ Windows, write filesystem patterns with the native separator, such as
56
+ `fs:read:C:\work\**`. The pattern `fs:read:C:\work/**` selects nothing.
57
+
58
+ Resolution also applies one always-on refusal: a regular file with more than
59
+ one link fails with `"hard-linked files cannot be confined to the workspace"`.
60
+ A hard link is a second name for the same inode with no ancestry to check, so
61
+ there is no canonical answer to give.
62
+
63
+ ## The path is resolved twice
64
+
65
+ A grant decision can suspend. An attended request waits for a person; a
66
+ journal-backed store waits for a write. What was authorized is the resource
67
+ the path named at check time, so the path must still name it when the decision
68
+ comes back.
69
+
70
+ The guard therefore resolves the resource, checks the capability, and resolves
71
+ it **again**. If the second answer differs, the operation fails with
72
+ `"path no longer names the resource that was authorized"`. A symlink or rename
73
+ swapped in during the wait is refused, never followed.
74
+
75
+ ## An open handle is bound to an inode
76
+
77
+ Rechecking a pathname is not enough once a file is open. The pathname could be
78
+ re-authorized while the descriptor you hold names something else: after a
79
+ rename that descriptor can address an inode outside the workspace even though
80
+ the replacement path is still allowed.
81
+
82
+ So an open handle binds authorization to the descriptor's `device:inode`
83
+ identity, captured at open time, and every guarded handle operation
84
+ (`read`, `write`, `stat`, `truncate`, `sync`, and the `*All` and `*Alloc`
85
+ forms) rechecks that identity as well as the capability. A mismatch fails with
86
+ `"descriptor no longer names the resource at its authorized path"`.
87
+
88
+ Open flags decide which capabilities the open itself requires. A read-only
89
+ flag needs `fs:read`, a write flag needs `fs:write`, and a read-write flag
90
+ needs both.
91
+
92
+ A host that reports no inode identity is an isolated volume. Its attestation,
93
+ not inode evidence, is the boundary, so there is nothing to verify and the
94
+ recheck passes.
95
+
96
+ ## Two ways a host earns the filesystem
97
+
98
+ The kernel refuses to delegate a checked pathname to a path-based host. A
99
+ platform adapter has to provide one of two extensions, and a host that
100
+ provides neither fails **every** relevant path, directory, stream, glob, and
101
+ handle operation closed with
102
+ `"host does not provide descriptor-relative, no-follow filesystem isolation"`.
103
+
104
+ **`withAtomicFileSystem(fileSystem, atomic)`** attaches a descriptor-relative,
105
+ no-follow executor. Operations are expressed as an `AtomicRequest` and run
106
+ relative to a pinned root handle, so there is no pathname for anything to
107
+ rebind between the check and the act. This is what a native host uses;
108
+ [`@smthrs/platform-node`](/api/platform-node) attaches it.
109
+
110
+ **`withIsolatedFileSystem(fileSystem)`** attests that the whole volume is
111
+ already confined by an enforceable boundary, which is true of an in-memory
112
+ browser volume or a test double that cannot address the host filesystem at
113
+ all. Path delegation is safe there because there is no host filesystem to
114
+ reach.
115
+
116
+ Both decorate the supplied service object in place and return that same
117
+ identity, so a host attaches exactly once at its boundary and keeps no
118
+ undecorated alias.
119
+
120
+ `withIsolatedFileSystem` **throws** on a filesystem that already carries a
121
+ descriptor-relative executor. The executor is the stronger guarantee, and
122
+ replacing it with a path-delegating attestation would route `access`, `copy`,
123
+ `chmod`, `link`, `symlink`, `open`, `watch`, `sink`, `stream`, and every
124
+ `makeTemp*` call back through pathnames after the capability check: the exact
125
+ window the extension closes. The refusal is a throw at composition time, so a
126
+ host cannot be assembled that way. Layering over an executor a caller has read
127
+ and delegates to is still allowed, because that replacement is the caller's
128
+ own decision.
129
+
130
+ ## Engine machinery uses a confined view
131
+
132
+ The engine's own copy-back writes files a body produced; asking a grant store
133
+ for them would make the engine ask permission for its own bookkeeping. It still
134
+ must not follow a symlink a concurrent process swapped into the workspace.
135
+ `confined(fileSystem, root)` gives it the same descriptor-relative requests the
136
+ guarded layer sends, pinned to `root`, with no grant check. A path-based host
137
+ gets no such view: `confined` refuses it rather than fall back to a
138
+ check-then-write that a swap could beat.
139
+
140
+ ## Temporary directories are outside the workspace
141
+
142
+ `makeTempDirectory`, `makeTempDirectoryScoped`, `makeTempFile`, and
143
+ `makeTempFileScoped` name no directory of their own. An implicit one is
144
+ authorized as `fs:write` on
145
+ `path.resolve(workspace.root, "..", FileSystem.systemTemporaryDirectoryName)`,
146
+ where the sentinel is the literal string `"<system-temp>"`.
147
+
148
+ The sentinel is outside the workspace root by construction and is never
149
+ confusable with a real path, so granting an ordinary workspace write does not
150
+ grant system temporary-directory access. A host missing the required extension
151
+ reports the refusal against the logical input `../<system-temp>`.
152
+
153
+ ## Mutable inputs are snapshotted
154
+
155
+ An options record a caller still holds is a second way to change an operation
156
+ after it was authorized. The guarded filesystem snapshots option records,
157
+ nested arrays, and maps before any permission suspension, freezing what it
158
+ copies, so the operation executed after a grant arrives is the operation that
159
+ was approved. An options object carrying a getter rather than plain data
160
+ fails with `"filesystem options must contain only data properties"`, because a
161
+ getter is not something a snapshot can capture.
162
+
163
+ ## Related
164
+
165
+ - [Adapt a new host platform](../guides/adapt-a-new-host-platform.md):
166
+ attaching an extension and proving it with the contract suite.
167
+ - [How a grant decision is made](./grant-decisions.md): what happens between
168
+ the two resolutions.
@@ -0,0 +1,181 @@
1
+ ---
2
+ title: "How a grant decision is made"
3
+ description: "The order GrantStore.check consults authority in: the fiber ceiling, four rulesets under last-match-wins, the ask default, and what an attended and an unattended store each do with ask."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ Every decorated host operation ends in one call: `grants.check(capability)`.
9
+ That call either returns void, in which case the operation proceeds, or fails
10
+ with a typed permission error. Everything the kernel decides happens inside
11
+ it, in a fixed order.
12
+
13
+ ## The order of the check
14
+
15
+ 1. **Is the store open?** A closed store fails every check with
16
+ `store_closed`. Closing a store also rejects every request already parked
17
+ on it, so no waiter is stranded.
18
+ 2. **Is the capability inside the fiber's ceiling?** `CapabilitySet` is the
19
+ ambient authority of the current fiber. A capability outside it fails
20
+ immediately with `permission_denied` and the reason
21
+ `"outside capability ceiling"`. No rule and no operator can override the
22
+ ceiling.
23
+ 3. **What do the rules say?** `Permission.evaluate` reduces four rulesets into
24
+ `allow`, `deny`, or `ask`.
25
+ 4. **`allow` proceeds. `deny` fails** with `permission_denied` and the reason
26
+ `"denied by permission policy"`.
27
+ 5. **`ask` depends on who is there.** An unattended store fails with
28
+ `permission_required`, carrying the request id, the capability, its effect
29
+ tier, and the display metadata. An attended store parks the fiber on a
30
+ pending request and waits for a reply.
31
+
32
+ The default decision is `ask`, so a capability no rule mentions is never
33
+ allowed. What differs between a headless host and an operator-driven one is
34
+ only what `ask` becomes.
35
+
36
+ ## The ceiling is monotone
37
+
38
+ `CapabilitySet` is a normalized conjunction of any-of pattern groups: a
39
+ capability is allowed only when every group contains a pattern matching it.
40
+ `CapabilitySet.attenuate(patterns)` runs an effect with the parent authority
41
+ **intersected** with one more group, which is the only way authority ever
42
+ moves. There is no public widening constructor and no unrestricted value, so a
43
+ scoped fiber can hand a narrower ceiling to work it delegates and can never
44
+ recover what it gave up.
45
+
46
+ A fiber that never passed through `attenuate` allows every capability, because
47
+ unrestricted authority is the identity element of intersection and a
48
+ permanently closed default would leave no operation able to widen a fiber
49
+ again. The ceiling is not what makes the kernel fail closed. The ruleset is:
50
+ its default verdict is `ask`, and an unattended store turns `ask` into a
51
+ refusal. The ceiling's job is to bound what a _scoped_ fiber may ask for.
52
+
53
+ ## Four rulesets, last match wins
54
+
55
+ `GrantStore` evaluates these in order:
56
+
57
+ | Ruleset | Where it comes from |
58
+ | ---------- | -------------------------------------------------------------------------------------------------------------- |
59
+ | Configured | `MakeOptions.rules`, or the first ruleset of a nested `rules` value. The operator's written policy. |
60
+ | Envelope | Patterns activated by `grantEnvelope`, or the construction `envelope`, with run scope. |
61
+ | Run | `MakeOptions.runRules` and every `"run"` reply, each remembered alongside the ceiling of the fiber that asked. |
62
+ | Remembered | Replayed remembered grants, and every `"remembered"` reply. |
63
+
64
+ Matching rules are last-match-wins across all four. The configured ruleset gets
65
+ one extra power: after it is reduced by the same last-match rule, an effective
66
+ denial is a hard veto that no later ruleset can lift. A configured deny that a
67
+ later configured allow or ask supersedes _within that ruleset_ is not a veto,
68
+ so an operator can still write an exception after a broad denial.
69
+
70
+ Run rules carry the ceiling of the fiber that requested them and are filtered
71
+ out for any capability that ceiling would not allow. A grant handed to an
72
+ attenuated fiber therefore cannot leak back up to a broader one, including after
73
+ restart. Each `flows.kernel.grant.run.v2` event persists the normalized captured
74
+ ceiling as a conjunction of any-of pattern groups. Replay intersects it with
75
+ the constructor's ceiling; reopening under a broader parent cannot widen it.
76
+
77
+ Trusted legacy `flows.kernel.grant.run.v1` events have no captured ceiling.
78
+ The journal store refuses construction with `invalid_resolution` when it finds
79
+ one. Their original boundary cannot be recovered, so obtain fresh approval in
80
+ a new run instead of replaying legacy authority.
81
+
82
+ A bare rule in `MakeOptions.runRules` uses the constructor's ceiling. A
83
+ `{ rule, ceiling }` entry carries captured pattern groups and intersects them
84
+ with that ceiling, as journal replay does.
85
+
86
+ ## The four resolutions
87
+
88
+ `store.reply(requestId, resolution, pattern?)` takes one of four answers:
89
+
90
+ | Resolution | Effect |
91
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------- |
92
+ | `once` | Authorizes this request alone. Nothing is added to the rules. |
93
+ | `run` | Adds an allow rule for the rest of the run. Requires a plan digest; without one the reply fails with `invalid_resolution`. |
94
+ | `remembered` | Adds an allow rule that a journal-backed store replays into later processes. |
95
+ | `deny` | Fails the parked request with `permission_denied`. |
96
+
97
+ For `run` and `remembered`, the pattern is the caller's if one is supplied and
98
+ otherwise derived from the exact capability. A resource containing glob
99
+ metacharacters has no unambiguous derived pattern, so the reply fails and says
100
+ to supply a pattern explicitly or resolve `once`.
101
+
102
+ A supplied pattern is checked before it is stored. `isValidGrantPattern`
103
+ refuses a pattern that names a different action, reaches a more dangerous
104
+ effect tier than the request displayed, or is a wildcard-bearing pattern
105
+ identical to the resource: the grammar has no escape, so that pattern's
106
+ wildcard reading would silently widen access. The refusal is
107
+ `invalid_resolution` with `"grant pattern exceeds the requested authority"`.
108
+
109
+ Adding a rule wakes every other parked request the new rule now allows, so one
110
+ `run` grant for `/workspace/**` resolves the queue behind it rather than
111
+ asking again per file.
112
+
113
+ A reply records its decision in the journal before anything activates, but the
114
+ write never holds the store's mutation permit. A slow or dead journal stalls
115
+ only the reply that issued it: checks, `list`, waiter cancellation, and store
116
+ closure all proceed while a write is stuck, and a write that exceeds
117
+ `maximumPersistMillis` (30 seconds) fails the reply with `journal_failed`,
118
+ leaving the request parked for a retry. A second reply to the same request
119
+ while its decision is mid-write fails fast with `request_not_found` rather
120
+ than queueing behind the journal. Envelope admissions follow the same rule,
121
+ and a concurrent identical envelope adopts the in-flight outcome instead of
122
+ writing a duplicate record.
123
+
124
+ ## Envelope approvals
125
+
126
+ `grantEnvelope` approves a whole set of patterns at once, which is what a plan
127
+ approval is: the operator says yes to everything the plan declared, before it
128
+ starts. An envelope is a **set**, so `canonicalEnvelopePatterns` deduplicates
129
+ its predicates and sorts them by formatted identity, and `envelopeSignature`
130
+ computes `sha256:` followed by the 64 lowercase hexadecimal digits of the
131
+ SHA-256 digest of that canonical JSON encoding (plan digest, scope, sorted
132
+ formatted predicates). Existing v1 journal events keep their format: replay
133
+ recomputes the digest from their fields. Legacy full-JSON signature seeds are
134
+ normalized to the same digest before comparison.
135
+ Two approvals listing the same predicates in a different order are the same
136
+ approval, and the second one is a no-op rather than a second durable record.
137
+
138
+ Envelope patterns are checked by `isValidEnvelopePattern`, which preserves
139
+ exact action and filesystem effect-tier boundaries, so an envelope cannot turn
140
+ a read approval into a write one.
141
+
142
+ ## Bounds are part of the contract
143
+
144
+ One store retains at most 1,024 policy rules across all four rulesets, 1,024
145
+ activated envelope signatures, 256 patterns per envelope, and 1,024 parked
146
+ requests. Metadata is limited to 16 levels of nesting, 1,024 members, and
147
+ 64 KiB of canonical JSON, and one encoded event to 256 KiB. Run, plan,
148
+ and request identities are limited to 4,096 UTF-16 code units. Generated
149
+ envelope signatures occupy 71 ASCII characters; legacy signature seeds must
150
+ fit the 256 KiB event bound before normalization. Other supplied signature
151
+ identities retain the 4,096-unit limit. Capability resources share the
152
+ capability package's own 4,096-unit limit.
153
+
154
+ Construction and runtime envelopes use the same admission check. Incoming
155
+ canonical patterns count toward the combined rule ceiling; rules already
156
+ replayed for the same envelope are counted once. Journal reconstruction uses
157
+ this admission check before appending a construction envelope.
158
+
159
+ Every bound failure is `invalid_resolution` and happens **before** any state
160
+ or journal authority changes, so a refused write never half-applies. The
161
+ constants are exported (`GrantStore.maximumRules` and its siblings) so a host
162
+ can check against the same numbers rather than guessing them.
163
+
164
+ ## Identity is exact text
165
+
166
+ Capability actions and resources, patterns, run ids, plan digests, request
167
+ ids, and grant metadata are identity-bearing values. Smithers validates
168
+ well-formed text but does not apply Unicode normalization: matching,
169
+ signatures, and journal replay all use the exact JavaScript string and its
170
+ UTF-16 code-unit sequence. A caller that wants NFC applies it before
171
+ constructing the value. Identity fields whose contract forbids it reject lone
172
+ surrogates and NUL.
173
+
174
+ ## Related
175
+
176
+ - [Write a capability policy](../guides/write-a-capability-policy.md): rules
177
+ and ceilings in practice.
178
+ - [Answer permission requests](../guides/answer-permission-requests.md): the
179
+ attended surface.
180
+ - [The authorization model](/pkg/capability/concepts/authorization-model): the
181
+ vocabulary and the evaluation function this page drives.