@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,1655 @@
1
+ /**
2
+ * Permission-aware filesystem operations.
3
+ *
4
+ * There is no kernel `FileSystem` interface and no kernel `FileSystem` tag.
5
+ * Effect owns both, and its tag fixes the error channel to `PlatformError`, so
6
+ * this module is only the middleware: a `Layer` over Effect's own tag that
7
+ * reads the raw service out of context and returns a guarded one in its place.
8
+ * Permission failures are projected into `PlatformError` by
9
+ * `Permission.toPlatformError`, which keeps the structured kernel failure on
10
+ * the error's `cause`.
11
+ *
12
+ * Governing design:
13
+ * `docs/specs/Concepts/Permission Kernel.md`,
14
+ * `docs/specs/Concepts/Effect Taxonomy.md`, and
15
+ * `docs/specs/Concepts/Host Adapters.md`.
16
+ *
17
+ * @since 1.0.0-rc.0
18
+ */
19
+
20
+ import { permissionDenied, type PermissionError, toPlatformError } from "@smthrs/capability/Permission"
21
+ import {
22
+ Effect,
23
+ Encoding,
24
+ FileSystem as EffectFileSystem,
25
+ Layer,
26
+ Option,
27
+ Path as EffectPath,
28
+ PlatformError,
29
+ Result,
30
+ Schema,
31
+ Sink,
32
+ Stream
33
+ } from "effect"
34
+ import * as Batch from "./FileSystemBatch.ts"
35
+ import { GrantStore } from "./GrantStore.ts"
36
+ import { makeCapability } from "./internal/makeCapability.ts"
37
+ import { Workspace } from "./Workspace.ts"
38
+
39
+ export * from "./FileSystemBatch.ts"
40
+
41
+ /**
42
+ * The directory name that stands in for the host's system temporary directory
43
+ * in a capability resource. `makeTempFile` and friends called without an
44
+ * explicit `directory` are checked against this name resolved one level above
45
+ * the workspace root, so an implicit system-temp write names a resource that
46
+ * is always outside the workspace envelope and is never confusable with a real
47
+ * workspace path.
48
+ *
49
+ * @since 1.0.0-rc.0
50
+ * @category models
51
+ */
52
+ export const systemTemporaryDirectoryName = "<system-temp>"
53
+
54
+ /**
55
+ * Host-private extension used for race-free, descriptor-relative filesystem
56
+ * operations. A plain path-based `FileSystem` cannot provide confinement: an
57
+ * attacker can replace any checked component before the delegate resolves it.
58
+ * Platform adapters opt in only when they can pin a root handle and reject
59
+ * symlinks while traversing from it. Hosts without this extension fail closed.
60
+ *
61
+ * @since 1.0.0-rc.0
62
+ * @category security
63
+ */
64
+ export const AtomicFileSystemTypeId = Symbol.for("@smthrs/kernel/AtomicFileSystem")
65
+
66
+ /** The pinned root every atomic request is resolved against.
67
+ *
68
+ * The guarded layer fills these in at the boundary from the composed
69
+ * workspace, so a caller that builds a request leaves them out.
70
+ *
71
+ * @since 1.0.0-rc.0
72
+ * @category security
73
+ */
74
+ export interface AtomicRoot {
75
+ readonly boundaryRoot?: string | undefined
76
+ readonly logicalRoot?: string | undefined
77
+ readonly rootIdentity?: string | undefined
78
+ }
79
+
80
+ /** A serializable operation executed relative to a pinned filesystem root.
81
+ *
82
+ * One member per operation, discriminated by `operation`, so a request cannot
83
+ * omit an operand its operation needs — a `rename` carries both endpoints or
84
+ * does not compile — and an adapter cannot quietly leave an operation
85
+ * unimplemented. Every member is the same flat JSON object the helper protocol
86
+ * already framed, so the wire shape is unchanged.
87
+ *
88
+ * @since 1.0.0-rc.0
89
+ * @category security
90
+ */
91
+ export type AtomicRequest =
92
+ | (AtomicRoot & { readonly operation: "exists"; readonly path: string })
93
+ | (AtomicRoot & { readonly operation: "chmod"; readonly path: string; readonly options: { readonly mode: number } })
94
+ | (AtomicRoot & {
95
+ readonly operation: "chown"
96
+ readonly path: string
97
+ readonly options: { readonly uid: number; readonly gid: number }
98
+ })
99
+ | (AtomicRoot & {
100
+ readonly operation: "glob"
101
+ readonly pattern: string
102
+ readonly root: string
103
+ readonly options?: { readonly exclude?: ReadonlyArray<string> | undefined } | undefined
104
+ })
105
+ | (AtomicRoot & {
106
+ readonly operation: "makeDirectory"
107
+ readonly path: string
108
+ readonly options?: { readonly recursive?: boolean | undefined; readonly mode?: number | undefined } | undefined
109
+ })
110
+ | (AtomicRoot & {
111
+ readonly operation: "readDirectory"
112
+ readonly path: string
113
+ readonly options?: { readonly recursive?: boolean | undefined } | undefined
114
+ })
115
+ | (AtomicRoot & { readonly operation: "readFile"; readonly path: string })
116
+ | (AtomicRoot & {
117
+ readonly operation: "readFileString"
118
+ readonly path: string
119
+ readonly encoding?: string | undefined
120
+ })
121
+ | (AtomicRoot & { readonly operation: "readLink"; readonly path: string })
122
+ | (AtomicRoot & { readonly operation: "realPath"; readonly path: string })
123
+ | (AtomicRoot & { readonly operation: "resolve"; readonly path: string })
124
+ | (AtomicRoot & {
125
+ readonly operation: "remove"
126
+ readonly path: string
127
+ readonly options?: { readonly recursive?: boolean | undefined; readonly force?: boolean | undefined } | undefined
128
+ })
129
+ | (AtomicRoot & { readonly operation: "rename"; readonly from: string; readonly to: string })
130
+ | (AtomicRoot & { readonly operation: "stat"; readonly path: string })
131
+ | (AtomicRoot & {
132
+ readonly operation: "writeFile"
133
+ readonly path: string
134
+ /** Base64 of the bytes, bounded by the host's `contentLimit`. */
135
+ readonly data: string
136
+ readonly options?:
137
+ | { readonly flag?: EffectFileSystem.OpenFlag | undefined; readonly mode?: number | undefined }
138
+ | undefined
139
+ })
140
+ | (AtomicRoot & {
141
+ readonly operation: "writeFileString"
142
+ readonly path: string
143
+ readonly data: string
144
+ readonly options?:
145
+ | { readonly flag?: EffectFileSystem.OpenFlag | undefined; readonly mode?: number | undefined }
146
+ | undefined
147
+ })
148
+ | (AtomicRoot & { readonly operation: "batch"; readonly requests: ReadonlyArray<AtomicBatchRequest> })
149
+
150
+ /** Where a no-follow executor's `resolve` walk stopped.
151
+ *
152
+ * `path` is the canonical path the walk reached below the pinned root, in
153
+ * on-disk spelling for every existing component. When `target` is `null`,
154
+ * `path` is the whole request, with a missing component and its descendants
155
+ * kept as requested. Otherwise `path` names the first link or reparse point
156
+ * on the way and `target` is its text, which the executor read without
157
+ * opening what it names.
158
+ *
159
+ * @since 1.0.0-rc.1
160
+ * @category security
161
+ */
162
+ export interface Resolution {
163
+ readonly path: string
164
+ readonly target: string | null
165
+ }
166
+
167
+ /** One member of an executor batch: a public read, or authorization's `resolve`.
168
+ *
169
+ * @since 1.0.0-rc.1
170
+ * @category security
171
+ */
172
+ export type AtomicBatchRequest = Batch.BatchRequest | { readonly operation: "resolve"; readonly path: string }
173
+
174
+ /** One member's value in an executor batch.
175
+ *
176
+ * @since 1.0.0-rc.1
177
+ * @category security
178
+ */
179
+ export type AtomicBatchValue = Batch.BatchValue | { readonly operation: "resolve"; readonly resolution: Resolution }
180
+
181
+ /** An executor batch's answer: {@link Batch.BatchResponse} over {@link AtomicBatchValue}.
182
+ *
183
+ * @since 1.0.0-rc.1
184
+ * @category security
185
+ */
186
+ export interface AtomicBatchResponse {
187
+ readonly rootIdentity: string
188
+ readonly entries: ReadonlyArray<{
189
+ readonly index: number
190
+ readonly path: string
191
+ readonly result: Result.Result<AtomicBatchValue, PlatformError.PlatformError>
192
+ }>
193
+ }
194
+
195
+ /** The value each atomic operation resolves to.
196
+ *
197
+ * The executor no longer takes the result type from its caller: an operation
198
+ * names its own result here, so a host that answers a `stat` with a boolean is
199
+ * a compile error rather than a decode that succeeds and reads wrong.
200
+ *
201
+ * @since 1.0.0-rc.0
202
+ * @category security
203
+ */
204
+ export interface AtomicResults {
205
+ readonly exists: boolean
206
+ readonly chmod: void
207
+ readonly chown: void
208
+ readonly glob: Array<string>
209
+ readonly makeDirectory: void
210
+ readonly readDirectory: Array<string>
211
+ readonly readFile: Uint8Array
212
+ readonly readFileString: string
213
+ readonly readLink: string
214
+ readonly realPath: string
215
+ readonly resolve: Resolution
216
+ readonly remove: void
217
+ readonly rename: void
218
+ readonly stat: EffectFileSystem.File.Info
219
+ readonly writeFile: void
220
+ readonly writeFileString: void
221
+ readonly batch: AtomicBatchResponse
222
+ }
223
+
224
+ /** The result of one request, read from the operation it names.
225
+ *
226
+ * @since 1.0.0-rc.0
227
+ * @category security
228
+ */
229
+ export type AtomicResult<R extends AtomicRequest> = AtomicResults[R["operation"]]
230
+
231
+ /** One typed implementation per operation.
232
+ *
233
+ * A record of this type receives each operation's own request and returns that
234
+ * operation's own result, and a record missing an operation does not compile,
235
+ * which is the exhaustiveness a `string` operation could not give an adapter.
236
+ *
237
+ * @since 1.0.0-rc.0
238
+ * @category security
239
+ */
240
+ export type AtomicHandlers = {
241
+ readonly [K in keyof AtomicResults]: (
242
+ request: Extract<AtomicRequest, { readonly operation: K }>
243
+ ) => Effect.Effect<AtomicResults[K], PlatformError.PlatformError>
244
+ }
245
+
246
+ /** Trusted host extension implementing atomic path resolution and operation.
247
+ *
248
+ * @since 1.0.0-rc.0
249
+ * @category security
250
+ */
251
+ export interface AtomicFileSystem {
252
+ /**
253
+ * The executor refuses symlinks and hard links during every handle-relative
254
+ * operation. Authorization still resolves the on-disk path spelling, but
255
+ * does not need a separate hard-link stat before invoking the executor.
256
+ */
257
+ readonly noFollowAuthorization?: true | undefined
258
+ /** Exact composition-time identity when native file IDs exceed numeric precision. */
259
+ readonly identifyRoot?: ((path: string) => Effect.Effect<string, PlatformError.PlatformError>) | undefined
260
+ readonly execute: <R extends AtomicRequest>(
261
+ request: R
262
+ ) => Effect.Effect<AtomicResult<R>, PlatformError.PlatformError>
263
+ /**
264
+ * A filesystem already confined by an enforceable process/filesystem
265
+ * boundary (for example an in-memory browser volume). Methods not expressible
266
+ * as one descriptor-relative request may delegate only through this surface.
267
+ */
268
+ readonly isolated?: EffectFileSystem.FileSystem | undefined
269
+ /** Advertised only by executors implementing the bounded batch protocol. */
270
+ readonly batchLimits?: { readonly size: number; readonly response: number } | undefined
271
+ /**
272
+ * Advertised byte ceiling for one serialized `writeFile` payload. The kernel
273
+ * refuses a larger payload with a typed `BadArgument` before base64-encoding
274
+ * it, so an executor that frames requests across a serialized host boundary
275
+ * never asks its runtime for a string it cannot represent. Hosts delegating
276
+ * through `isolated` receive the bytes directly and advertise nothing.
277
+ */
278
+ readonly contentLimit?: number | undefined
279
+ }
280
+
281
+ /** An Effect filesystem carrying the atomic host extension.
282
+ *
283
+ * @since 1.0.0-rc.0
284
+ * @category security
285
+ */
286
+ export type AtomicHostFileSystem = EffectFileSystem.FileSystem & {
287
+ readonly [AtomicFileSystemTypeId]: AtomicFileSystem
288
+ }
289
+
290
+ /** Attaches a trusted platform's descriptor-relative executor to its service.
291
+ *
292
+ * The executor is attached to the supplied object, so a later attachment over
293
+ * the same service replaces this one. A host attaches exactly once at its
294
+ * boundary; a caller that deliberately layers over an existing executor must
295
+ * read it first and delegate to it, which is what makes replacement its own
296
+ * decision rather than an accident.
297
+ *
298
+ * @since 1.0.0-rc.0
299
+ * @category security
300
+ */
301
+ export const withAtomicFileSystem = (
302
+ fileSystem: EffectFileSystem.FileSystem,
303
+ atomic: AtomicFileSystem
304
+ ): AtomicHostFileSystem => Object.assign(fileSystem, { [AtomicFileSystemTypeId]: atomic })
305
+
306
+ /**
307
+ * Why a filesystem composition or guarded batch refused.
308
+ *
309
+ * @since 1.0.0-rc.0
310
+ * @category models
311
+ */
312
+ export const FileSystemFaultCode = Schema.Literals([
313
+ "executor_already_present",
314
+ "isolated_operation_unsupported",
315
+ "workspace_identity_changed"
316
+ ])
317
+
318
+ /**
319
+ * The stable code of a {@link FileSystemFault}.
320
+ *
321
+ * @since 1.0.0-rc.0
322
+ * @category models
323
+ */
324
+ export type FileSystemFaultCode = typeof FileSystemFaultCode.Type
325
+
326
+ /**
327
+ * A broken filesystem composition or a workspace whose descriptor identity
328
+ * changed. `withIsolatedFileSystem` throws `executor_already_present`; an
329
+ * attested volume reaching an operation it never advertises dies with
330
+ * `isolated_operation_unsupported` (a bug); a batch whose root no longer names
331
+ * the authorized descriptor is the `cause` of a `Busy` `PlatformError` with
332
+ * `workspace_identity_changed`.
333
+ *
334
+ * @since 1.0.0-rc.0
335
+ * @category errors
336
+ */
337
+ export class FileSystemFault extends Schema.TaggedError<FileSystemFault>()("@smthrs/kernel/FileSystemFault", {
338
+ code: FileSystemFaultCode,
339
+ message: Schema.String
340
+ }) {
341
+ override readonly name = "FileSystemFault"
342
+ }
343
+
344
+ /**
345
+ * Attests that a host filesystem is already isolated as a whole. Intended for
346
+ * browser/test volumes whose implementation cannot address the host
347
+ * filesystem at all; native path-based adapters must not use this shortcut.
348
+ *
349
+ * The attestation is refused for a filesystem that already carries a
350
+ * descriptor-relative executor. That executor is the stronger guarantee, and
351
+ * replacing it with a path-delegating one would route `access`, `copy`,
352
+ * `chmod`, `link`, `symlink`, `open`, `watch`, `sink`, `stream`, and every
353
+ * `makeTemp*` back through pathnames after the capability check: the exact
354
+ * symlink-swap window this module exists to close. The refusal is a throw at
355
+ * composition time, so a host cannot be assembled that way.
356
+ *
357
+ * @since 1.0.0-rc.0
358
+ * @category security
359
+ */
360
+ export const withIsolatedFileSystem = (
361
+ fileSystem: EffectFileSystem.FileSystem
362
+ ): AtomicHostFileSystem => {
363
+ if (AtomicFileSystemTypeId in fileSystem) {
364
+ throw new FileSystemFault({
365
+ code: "executor_already_present",
366
+ message:
367
+ "filesystem already carries a descriptor-relative executor; attesting whole-filesystem isolation would replace it"
368
+ })
369
+ }
370
+ return withAtomicFileSystem(fileSystem, {
371
+ isolated: fileSystem,
372
+ execute: dispatch({
373
+ exists: (request) => fileSystem.exists(request.path),
374
+ chmod: (request) => fileSystem.chmod(request.path, request.options.mode),
375
+ chown: (request) => fileSystem.chown(request.path, request.options.uid, request.options.gid),
376
+ glob: (request) => fileSystem.glob(request.pattern, { ...request.options, root: request.root }),
377
+ makeDirectory: (request) => fileSystem.makeDirectory(request.path, request.options),
378
+ readDirectory: (request) => fileSystem.readDirectory(request.path, request.options),
379
+ readFile: (request) => fileSystem.readFile(request.path),
380
+ readFileString: (request) => fileSystem.readFileString(request.path, request.encoding),
381
+ readLink: (request) => fileSystem.readLink(request.path),
382
+ realPath: (request) => fileSystem.realPath(request.path),
383
+ // Authorization asks `resolve` only of an executor that claims
384
+ // `noFollowAuthorization`, which an attested volume never does.
385
+ resolve: (request) => unsupportedIsolated(request.operation),
386
+ remove: (request) => fileSystem.remove(request.path, request.options),
387
+ rename: (request) => fileSystem.rename(request.from, request.to),
388
+ stat: (request) => fileSystem.stat(request.path),
389
+ writeFile: (request) =>
390
+ Encoding.decodeBase64(request.data).pipe(
391
+ Effect.fromResult,
392
+ Effect.orDie,
393
+ Effect.flatMap((data) => fileSystem.writeFile(request.path, data, request.options))
394
+ ),
395
+ writeFileString: (request) => fileSystem.writeFileString(request.path, request.data, request.options),
396
+ // An attested volume advertises no `batchLimits`, so the guarded layer
397
+ // never frames a batch for this executor.
398
+ batch: (request) => unsupportedIsolated(request.operation)
399
+ })
400
+ })
401
+ }
402
+
403
+ const unsupportedIsolated = (operation: string): Effect.Effect<never> =>
404
+ Effect.die(
405
+ new FileSystemFault({
406
+ code: "isolated_operation_unsupported",
407
+ message: `unsupported isolated filesystem operation: ${operation}`
408
+ })
409
+ )
410
+
411
+ /**
412
+ * Turns a typed handler record into the executor the host extension declares.
413
+ *
414
+ * The record is what carries the safety: every operation is implemented, each
415
+ * handler reads its own operands, and each returns its own result. The lookup
416
+ * itself is one assertion because TypeScript cannot correlate an index it
417
+ * narrowed only on the key with the request and result types that key selects.
418
+ * An operation no handler implements can only arrive from a serialized
419
+ * boundary that framed an operation this build does not know, so it dies
420
+ * rather than resolving to `undefined` and being called.
421
+ */
422
+ const dispatch = (handlers: AtomicHandlers): AtomicFileSystem["execute"] => {
423
+ const table = handlers as unknown as Record<
424
+ string,
425
+ ((request: AtomicRequest) => Effect.Effect<unknown, PlatformError.PlatformError>) | undefined
426
+ >
427
+ return <R extends AtomicRequest>(request: R) => {
428
+ const handler = table[request.operation]
429
+ return (
430
+ handler === undefined ? unsupportedIsolated(request.operation) : handler(request)
431
+ ) as Effect.Effect<AtomicResult<R>, PlatformError.PlatformError>
432
+ }
433
+ }
434
+
435
+ /** Prefer the host's exact file ID over the public adapter's optional numeric inode. */
436
+ const identifyRoot = (
437
+ atomic: AtomicFileSystem,
438
+ fileSystem: EffectFileSystem.FileSystem,
439
+ path: string
440
+ ): Effect.Effect<Option.Option<string>, PlatformError.PlatformError> =>
441
+ atomic.identifyRoot === undefined
442
+ ? Effect.map(fileSystem.stat(path), (info) => Option.map(info.ino, (ino) => `${info.dev}:${ino}`))
443
+ : Effect.map(atomic.identifyRoot(path), Option.some)
444
+
445
+ /** The root a descriptor-relative request is resolved against, captured once. */
446
+ interface PinnedRoot {
447
+ readonly boundaryRoot: string
448
+ readonly logicalRoot: string
449
+ readonly rootIdentity: string | undefined
450
+ }
451
+
452
+ /**
453
+ * Stamps a request with its pinned root and runs it through the executor. The
454
+ * one place both the guarded layer and {@link confined} build the wire request,
455
+ * so the two surfaces cannot drift apart.
456
+ */
457
+ const pinned =
458
+ (atomic: AtomicFileSystem, root: PinnedRoot) =>
459
+ <R extends AtomicRequest>(request: R): Effect.Effect<AtomicResult<R>, PlatformError.PlatformError> =>
460
+ atomic.execute<R>({ ...request, ...root })
461
+
462
+ const unconfinedDescription = "host does not provide descriptor-relative, no-follow filesystem isolation"
463
+
464
+ const unconfined = (method: string, pathOrDescriptor: string): PlatformError.PlatformError =>
465
+ PlatformError.systemError({
466
+ _tag: "PermissionDenied",
467
+ module: "FileSystem",
468
+ method,
469
+ pathOrDescriptor,
470
+ description: unconfinedDescription
471
+ })
472
+
473
+ /**
474
+ * Brands a {@link confined} view with the logical root it is pinned to, so a
475
+ * caller handed an already-confined view does not wrap it a second time.
476
+ */
477
+ const ConfinedFileSystemTypeId = Symbol.for("@smthrs/kernel/ConfinedFileSystem")
478
+
479
+ /**
480
+ * Whether {@link confined} can build a view over this filesystem: it carries a
481
+ * descriptor-relative executor, or a whole-filesystem isolation attestation.
482
+ * A plain path-based host answers `false`.
483
+ *
484
+ * @since 1.0.0-rc.1
485
+ * @category security
486
+ */
487
+ export const isConfinable = (fileSystem: EffectFileSystem.FileSystem): boolean =>
488
+ ConfinedFileSystemTypeId in fileSystem || AtomicFileSystemTypeId in fileSystem
489
+
490
+ /**
491
+ * The logical root a filesystem is already confined to: a {@link confined}
492
+ * view's pinned root, or the guarded {@link layer} service's workspace root.
493
+ * `undefined` for any other filesystem, including a host that is merely
494
+ * confinable. Machinery that shares a filesystem with its caller confines to
495
+ * this root, so its paths resolve exactly where the caller's do.
496
+ *
497
+ * @since 1.0.0-rc.1
498
+ * @category security
499
+ */
500
+ export const confinedRoot = (fileSystem: EffectFileSystem.FileSystem): string | undefined =>
501
+ (fileSystem as { readonly [ConfinedFileSystemTypeId]?: string })[ConfinedFileSystemTypeId]
502
+
503
+ /**
504
+ * Fails with the typed `PermissionDenied` refusal unless {@link confined} can
505
+ * build a view over this filesystem. Composition-time check for layers that
506
+ * pin their root later, when it is known to exist.
507
+ *
508
+ * @since 1.0.0-rc.1
509
+ * @category security
510
+ */
511
+ export const requireConfinable = (
512
+ fileSystem: EffectFileSystem.FileSystem,
513
+ root: string
514
+ ): Effect.Effect<void, PlatformError.PlatformError> =>
515
+ isConfinable(fileSystem) ? Effect.void : Effect.fail(unconfined("confined", root))
516
+
517
+ /**
518
+ * A filesystem view confined to `root` without capability checks: engine
519
+ * machinery that must not ask a grant store for its own bookkeeping, but must
520
+ * still never be redirected outside the workspace.
521
+ *
522
+ * Every operation is one descriptor-relative request against the root pinned
523
+ * here (its canonical path and `device:inode` identity), resolved by the host
524
+ * helper with `O_NOFOLLOW` at each component, so there is no window between a
525
+ * check and a write for a symlink to be swapped into. A symlink anywhere on the
526
+ * path, a hard-linked file, `..` traversal, or a root whose identity changed is
527
+ * refused by the host, never followed. The requests are exactly the ones the
528
+ * guarded {@link layer} sends, minus the grant check.
529
+ *
530
+ * A host carrying a whole-filesystem isolation attestation
531
+ * ({@link withIsolatedFileSystem}) is returned as it is: its attestation is
532
+ * the boundary. So is the guarded {@link layer}'s own service when it is
533
+ * rooted at the same workspace, since each of its path operations is already
534
+ * one such request. A plain path-based host is refused with `PermissionDenied`, so
535
+ * there is no path-based fallback that claims confinement. Operations that
536
+ * cannot be expressed as one atomic request (`open`, `stream`, `sink`, `copy`,
537
+ * `link`, `symlink`, `watch`, `truncate`, `utimes`, `access`, and every
538
+ * `makeTemp*`) fail with the same refusal.
539
+ *
540
+ * The root must exist when the view is built.
541
+ *
542
+ * @since 1.0.0-rc.1
543
+ * @category security
544
+ */
545
+ export const confined = (
546
+ fileSystem: EffectFileSystem.FileSystem,
547
+ root: string
548
+ ): Effect.Effect<EffectFileSystem.FileSystem, PlatformError.PlatformError, EffectPath.Path> =>
549
+ Effect.gen(function*() {
550
+ const path = yield* EffectPath.Path
551
+ const logicalRoot = path.resolve(root === "" ? "." : root)
552
+ if (confinedRoot(fileSystem) === logicalRoot) return fileSystem
553
+ const atomic = (fileSystem as Partial<AtomicHostFileSystem>)[AtomicFileSystemTypeId]
554
+ if (atomic === undefined) return yield* Effect.fail(unconfined("confined", logicalRoot))
555
+ if (atomic.isolated !== undefined) return fileSystem
556
+ const boundaryRoot = yield* fileSystem.realPath(logicalRoot)
557
+ const rootIdentity = yield* identifyRoot(atomic, fileSystem, boundaryRoot)
558
+ if (Option.isNone(rootIdentity)) return yield* Effect.fail(unconfined("confined", logicalRoot))
559
+ const run = pinned(atomic, { boundaryRoot, logicalRoot, rootIdentity: rootIdentity.value })
560
+ const normalize = (value: string): string => path.resolve(logicalRoot, value)
561
+ const refuse = (method: string, value: string): Effect.Effect<never, PlatformError.PlatformError> =>
562
+ Effect.fail(unconfined(method, normalize(value)))
563
+ const view: EffectFileSystem.FileSystem = {
564
+ ...EffectFileSystem.make({
565
+ access: (value) => refuse("access", value),
566
+ copy: (from) => refuse("copy", from),
567
+ copyFile: (from) => refuse("copyFile", from),
568
+ chmod: (value, mode) => run({ operation: "chmod", path: normalize(value), options: { mode } }),
569
+ chown: (value, uid, gid) => run({ operation: "chown", path: normalize(value), options: { uid, gid } }),
570
+ glob: (pattern, options) => {
571
+ const base = options?.root === undefined ? logicalRoot : normalize(options.root)
572
+ return run({
573
+ operation: "glob",
574
+ pattern: path.resolve(base, pattern),
575
+ root: base,
576
+ options: options === undefined ? undefined : { exclude: options.exclude ?? [] }
577
+ })
578
+ },
579
+ link: (from) => refuse("link", from),
580
+ makeDirectory: (value, options) =>
581
+ run({
582
+ operation: "makeDirectory",
583
+ path: normalize(value),
584
+ options
585
+ }),
586
+ makeTempDirectory: () => refuse("makeTempDirectory", logicalRoot),
587
+ makeTempDirectoryScoped: () => refuse("makeTempDirectoryScoped", logicalRoot),
588
+ makeTempFile: () => refuse("makeTempFile", logicalRoot),
589
+ makeTempFileScoped: () => refuse("makeTempFileScoped", logicalRoot),
590
+ open: (value) => refuse("open", value),
591
+ readDirectory: (value, options) =>
592
+ run({
593
+ operation: "readDirectory",
594
+ path: normalize(value),
595
+ options
596
+ }),
597
+ readFile: (value) => run({ operation: "readFile", path: normalize(value) }),
598
+ readLink: (value) => run({ operation: "readLink", path: normalize(value) }),
599
+ realPath: (value) => run({ operation: "realPath", path: normalize(value) }),
600
+ remove: (value, options) =>
601
+ run({
602
+ operation: "remove",
603
+ path: normalize(value),
604
+ options
605
+ }),
606
+ rename: (from, to) => run({ operation: "rename", from: normalize(from), to: normalize(to) }),
607
+ stat: (value) => run({ operation: "stat", path: normalize(value) }),
608
+ symlink: (_from, to) => refuse("symlink", to),
609
+ truncate: (value) => refuse("truncate", value),
610
+ utimes: (value) => refuse("utimes", value),
611
+ watch: (value) => Stream.fail(unconfined("watch", normalize(value))),
612
+ writeFile: (value, data, options) => {
613
+ const limit = atomic.contentLimit
614
+ if (limit !== undefined && data.byteLength > limit) {
615
+ return Effect.fail(PlatformError.badArgument({
616
+ module: "FileSystem",
617
+ method: "writeFile",
618
+ description:
619
+ `writeFile payload of ${data.byteLength} bytes exceeds the ${limit} byte limit advertised by the host`
620
+ }))
621
+ }
622
+ return run({
623
+ operation: "writeFile",
624
+ path: normalize(value),
625
+ data: Encoding.encodeBase64(data),
626
+ options
627
+ })
628
+ }
629
+ }),
630
+ exists: (value) => run({ operation: "exists", path: normalize(value) }),
631
+ readFileString: (value, encoding) => run({ operation: "readFileString", path: normalize(value), encoding }),
632
+ writeFileString: (value, data, options) =>
633
+ run({
634
+ operation: "writeFileString",
635
+ path: normalize(value),
636
+ data,
637
+ options
638
+ })
639
+ }
640
+ return Object.assign(view, { [ConfinedFileSystemTypeId]: logicalRoot })
641
+ })
642
+
643
+ const readableOpenFlags: ReadonlySet<EffectFileSystem.OpenFlag> = new Set([
644
+ "r",
645
+ "r+",
646
+ "w+",
647
+ "wx+",
648
+ "a+",
649
+ "ax+"
650
+ ])
651
+
652
+ const writableOpenFlags: ReadonlySet<EffectFileSystem.OpenFlag> = new Set([
653
+ "r+",
654
+ "w",
655
+ "wx",
656
+ "w+",
657
+ "wx+",
658
+ "a",
659
+ "ax",
660
+ "a+",
661
+ "ax+"
662
+ ])
663
+
664
+ const isInside = (path: EffectPath.Path, root: string, candidate: string): boolean => {
665
+ const relative = path.relative(root, candidate)
666
+ return relative === "" ||
667
+ (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
668
+ }
669
+
670
+ /**
671
+ * The `device:inode` identity a descriptor-bound authorization pins, or
672
+ * `Option.none` when the entry is not a regular file or the host reports no
673
+ * inode identity. A host without inode identity is an isolated volume, and its
674
+ * isolation attestation — not inode evidence — is the confinement boundary.
675
+ */
676
+ const identityOf = (info: EffectFileSystem.File.Info): Option.Option<string> =>
677
+ info.type === "File"
678
+ ? Option.map(info.ino, (ino) => `${info.dev}:${ino}`)
679
+ : Option.none()
680
+
681
+ /**
682
+ * Resolves a path through every existing ancestor and maps paths inside the
683
+ * canonical workspace back to the stable logical workspace root. Existing
684
+ * symlinks therefore cannot turn an inside-workspace grant into outside
685
+ * authority, while capability resources remain stable when the root itself is
686
+ * a symlink. The host's `realPath` must return the on-disk spelling of existing
687
+ * components, including on case-insensitive volumes. Native Node hosts use
688
+ * the promise-based `realpath`, which uses the native implementation.
689
+ *
690
+ * @category security
691
+ * @since 1.0.0-rc.0
692
+ */
693
+ export const canonicalResource = (
694
+ fileSystem: EffectFileSystem.FileSystem,
695
+ path: EffectPath.Path,
696
+ workspaceRoot: string,
697
+ value: string
698
+ ): Effect.Effect<string, PlatformError.PlatformError> => {
699
+ const normalizedRoot = path.normalize(workspaceRoot)
700
+ const normalizedValue = path.normalize(
701
+ path.isAbsolute(value) ? value : path.resolve(normalizedRoot, value)
702
+ )
703
+ const resolveExistingAncestor = (
704
+ candidate: string,
705
+ symlinkDepth = 0
706
+ ): Effect.Effect<string, PlatformError.PlatformError> =>
707
+ fileSystem.realPath(candidate).pipe(
708
+ Effect.catch((error) => {
709
+ const resolveParent = () => {
710
+ const parent = path.dirname(candidate)
711
+ if (parent === candidate) {
712
+ return Effect.fail(error)
713
+ }
714
+ return resolveExistingAncestor(parent, symlinkDepth).pipe(
715
+ Effect.map((resolvedParent) => path.join(resolvedParent, path.basename(candidate)))
716
+ )
717
+ }
718
+ return fileSystem.readLink(candidate).pipe(
719
+ Effect.matchEffect({
720
+ onFailure: resolveParent,
721
+ onSuccess: (target) =>
722
+ symlinkDepth >= 40
723
+ ? Effect.fail(error)
724
+ : resolveExistingAncestor(
725
+ path.isAbsolute(target) ? target : path.resolve(path.dirname(candidate), target),
726
+ symlinkDepth + 1
727
+ )
728
+ })
729
+ )
730
+ })
731
+ )
732
+
733
+ return Effect.all([
734
+ fileSystem.realPath(normalizedRoot),
735
+ resolveExistingAncestor(normalizedValue)
736
+ ]).pipe(
737
+ Effect.map(([canonicalRoot, canonicalValue]) =>
738
+ isInside(path, canonicalRoot, canonicalValue)
739
+ ? path.normalize(path.join(normalizedRoot, path.relative(canonicalRoot, canonicalValue)))
740
+ : path.normalize(canonicalValue)
741
+ )
742
+ )
743
+ }
744
+
745
+ type PinnedRun = <R extends AtomicRequest>(
746
+ request: R
747
+ ) => Effect.Effect<AtomicResult<R>, PlatformError.PlatformError>
748
+
749
+ const uninspectable = "path component could not be inspected without following it"
750
+
751
+ /** `value` spelled below the pinned boundary root, or `undefined` outside both roots. */
752
+ const pinnedPath = (
753
+ path: EffectPath.Path,
754
+ root: Pick<PinnedRoot, "boundaryRoot" | "logicalRoot">,
755
+ value: string
756
+ ): string | undefined => {
757
+ const base = isInside(path, root.logicalRoot, value)
758
+ ? root.logicalRoot
759
+ : isInside(path, root.boundaryRoot, value)
760
+ ? root.boundaryRoot
761
+ : undefined
762
+ return base === undefined ? undefined : path.join(root.boundaryRoot, path.relative(base, value))
763
+ }
764
+
765
+ const segmentsBelow = (path: EffectPath.Path, root: string, value: string): Array<string> =>
766
+ path.relative(root, value).split(path.sep).filter((segment) => segment !== "")
767
+
768
+ /** Resolve a glob's literal prefix, keeping pattern syntax out of filename lookups. */
769
+ const globAuthorizationPath = (
770
+ path: EffectPath.Path,
771
+ root: Pick<PinnedRoot, "boundaryRoot" | "logicalRoot">,
772
+ value: string,
773
+ glob: boolean
774
+ ): { readonly prefix: string; readonly suffix: ReadonlyArray<string> } => {
775
+ const requested = glob ? pinnedPath(path, root, value) : undefined
776
+ if (requested === undefined) return { prefix: value, suffix: [] }
777
+ // The workspace's own name is literal even if it contains glob metacharacters.
778
+ const parts = segmentsBelow(path, root.boundaryRoot, requested)
779
+ const wildcard = parts.findIndex((part) => /[*?[{]|[@+!]\(/.test(part))
780
+ return wildcard < 0
781
+ ? { prefix: value, suffix: [] }
782
+ // Keep logical spelling so the executor mapping pins the prefix exactly once.
783
+ : { prefix: path.join(root.logicalRoot, ...parts.slice(0, wildcard)), suffix: parts.slice(wildcard) }
784
+ }
785
+
786
+ /**
787
+ * Resolves the resource a no-follow executor addresses without resolving any
788
+ * pathname on the host. Windows `realpath` opens its argument through every
789
+ * link, so asking the host about a planted link to `\\.\pipe\name` or
790
+ * `\\host\share\x` connects to that pipe or share during authorization,
791
+ * and a component swapped for a link between two host checks is followed
792
+ * even when each check alone was safe (#2882).
793
+ *
794
+ * The executor answers one descriptor-relative `resolve` request from the
795
+ * pinned root instead ({@link Resolution}): existing components in their
796
+ * on-disk spelling, a missing component and its descendants as requested, or
797
+ * the first link on the way with its text. Link text is resolved lexically
798
+ * and asked again, and a result outside both roots is returned untouched for
799
+ * the caller to deny. Any failure is refused.
800
+ */
801
+ const confinedResource = <E>(
802
+ run: PinnedRun,
803
+ path: EffectPath.Path,
804
+ root: Pick<PinnedRoot, "boundaryRoot" | "logicalRoot">,
805
+ value: string,
806
+ refuse: (resource: string, reason: string, cause?: PlatformError.PlatformError) => Effect.Effect<never, E>,
807
+ symlinkDepth = 0
808
+ ): Effect.Effect<string, E> => {
809
+ const requested = pinnedPath(path, root, value)
810
+ if (requested === undefined) {
811
+ return Effect.succeed(value)
812
+ }
813
+ return run({ operation: "resolve", path: requested }).pipe(
814
+ Effect.matchEffect({
815
+ onFailure: (cause) => refuse(logicalPath(path, root, requested), uninspectable, cause),
816
+ onSuccess: (resolution) => followResolution(run, path, root, requested, resolution, refuse, symlinkDepth)
817
+ })
818
+ )
819
+ }
820
+
821
+ const logicalPath = (
822
+ path: EffectPath.Path,
823
+ root: Pick<PinnedRoot, "boundaryRoot" | "logicalRoot">,
824
+ canonical: string
825
+ ): string => path.normalize(path.join(root.logicalRoot, path.relative(root.boundaryRoot, canonical)))
826
+
827
+ /** Turns one executor {@link Resolution} of `requested` into the resource it names. */
828
+ const followResolution = <E>(
829
+ run: PinnedRun,
830
+ path: EffectPath.Path,
831
+ root: Pick<PinnedRoot, "boundaryRoot" | "logicalRoot">,
832
+ requested: string,
833
+ resolution: Resolution,
834
+ refuse: (resource: string, reason: string, cause?: PlatformError.PlatformError) => Effect.Effect<never, E>,
835
+ symlinkDepth: number
836
+ ): Effect.Effect<string, E> => {
837
+ const resolved = path.normalize(resolution.path)
838
+ if (!isInside(path, root.boundaryRoot, resolved)) {
839
+ return refuse(logicalPath(path, root, requested), "path resolves outside the pinned root")
840
+ }
841
+ if (resolution.target === null) {
842
+ return Effect.succeed(logicalPath(path, root, resolved))
843
+ }
844
+ if (symlinkDepth >= 40) {
845
+ return refuse(logicalPath(path, root, resolved), "too many levels of symbolic links")
846
+ }
847
+ const rest = segmentsBelow(path, root.boundaryRoot, requested).slice(
848
+ segmentsBelow(path, root.boundaryRoot, resolved).length
849
+ )
850
+ return confinedResource(
851
+ run,
852
+ path,
853
+ root,
854
+ path.resolve(path.dirname(resolved), resolution.target, ...rest),
855
+ refuse,
856
+ symlinkDepth + 1
857
+ )
858
+ }
859
+
860
+ /** A public read's batch value; a `resolve` answer to a read is a host defect and fails closed. */
861
+ const publicValue = (
862
+ resource: string,
863
+ result: Result.Result<AtomicBatchValue, PlatformError.PlatformError>
864
+ ): Result.Result<Batch.BatchValue, PlatformError.PlatformError> =>
865
+ Result.isFailure(result) || result.success.operation !== "resolve"
866
+ ? result as Result.Result<Batch.BatchValue, PlatformError.PlatformError>
867
+ : Result.fail(PlatformError.systemError({
868
+ _tag: "PermissionDenied",
869
+ module: "FileSystem",
870
+ method: "batch",
871
+ pathOrDescriptor: resource,
872
+ description: "executor answered a batch read with a resolution"
873
+ }))
874
+
875
+ /**
876
+ * Decorates Effect's filesystem service in place with workspace-normalized
877
+ * capability checks. Every host resolves canonical path spelling before the
878
+ * capability check and again after every grant decision. Descriptor-relative
879
+ * executors enforce hard-link refusal themselves; other hosts also run the
880
+ * hard-link guard here. A decision can
881
+ * suspend (an attended request, a journal-backed store), and an operation
882
+ * whose path no longer names the resource that was authorized is refused
883
+ * rather than performed. Open file handles bind their authorization to the
884
+ * `device:inode` identity fstat'd at open time and refuse any operation once
885
+ * the authorized path names a different resource.
886
+ *
887
+ * The layer provides the tag it also requires: compose it over a host
888
+ * filesystem layer with `Layer.provide` and every consumer of
889
+ * `FileSystem.FileSystem` — including one that never heard of the kernel —
890
+ * resolves the guarded implementation. A denied request surfaces as a
891
+ * `PlatformError` whose reason is `PermissionDenied` and whose `cause` is the
892
+ * kernel's own `PermissionRequired`, `PermissionDenied`, or `GrantStoreError`;
893
+ * `Permission.fromPlatformError` reads it back.
894
+ *
895
+ * @category layers
896
+ * @since 1.0.0-rc.0
897
+ */
898
+ export const layer: Layer.Layer<
899
+ EffectFileSystem.FileSystem,
900
+ PlatformError.PlatformError,
901
+ EffectFileSystem.FileSystem | EffectPath.Path | Workspace | GrantStore
902
+ > = Layer.effect(
903
+ EffectFileSystem.FileSystem,
904
+ Effect.gen(function*() {
905
+ const fileSystem = yield* EffectFileSystem.FileSystem
906
+ const path = yield* EffectPath.Path
907
+ const workspace = yield* Workspace
908
+ const grants = yield* GrantStore
909
+ const atomic = (fileSystem as Partial<AtomicHostFileSystem>)[AtomicFileSystemTypeId]
910
+ const normalizeFrom = (base: string, value: string): string =>
911
+ path.normalize(path.isAbsolute(value) ? value : path.resolve(base, value))
912
+ const logicalRoot = path.normalize(path.resolve(workspace.root))
913
+ const normalize = (value: string): string => normalizeFrom(logicalRoot, value)
914
+ const boundaryRoot = yield* fileSystem.realPath(logicalRoot)
915
+ // Descriptor-relative hosts need the composition-time identity even when
916
+ // they expose no batching. Already-isolated volumes need no native inode.
917
+ const rootIdentity = atomic === undefined || (atomic.isolated !== undefined && atomic.batchLimits === undefined)
918
+ ? Option.none<string>()
919
+ : yield* identifyRoot(atomic, fileSystem, boundaryRoot)
920
+ const root: PinnedRoot = { boundaryRoot, logicalRoot, rootIdentity: Option.getOrUndefined(rootIdentity) }
921
+ const refuse = (method: string, resource: string) => (error: PermissionError): PlatformError.PlatformError =>
922
+ toPlatformError({ module: "FileSystem", method, pathOrDescriptor: resource, error })
923
+ const deny = (
924
+ action: "fs:read" | "fs:write",
925
+ method: string,
926
+ resource: string,
927
+ reason: string,
928
+ cause?: PlatformError.PlatformError
929
+ ) =>
930
+ makeCapability(action, resource).pipe(
931
+ Effect.flatMap((capability) => {
932
+ const failure = permissionDenied(capability, reason)
933
+ // Keep the permission identity and the typed inspection failure together.
934
+ if (cause !== undefined) Object.defineProperty(failure, "cause", { value: cause })
935
+ return Effect.fail(failure)
936
+ }),
937
+ Effect.mapError(refuse(method, resource)),
938
+ Effect.mapError((failure) =>
939
+ cause?.reason.description === undefined
940
+ ? failure
941
+ : PlatformError.systemError({
942
+ ...failure.reason,
943
+ _tag: "PermissionDenied",
944
+ description: cause.reason.description,
945
+ cause: failure.reason.cause
946
+ })
947
+ )
948
+ )
949
+ const insideWorkspace = (action: "fs:read" | "fs:write", method: string) => (resource: string) =>
950
+ isInside(path, logicalRoot, resource)
951
+ ? Effect.succeed(resource)
952
+ : deny(action, method, resource, "path is outside the workspace")
953
+ /**
954
+ * Resolves the on-disk resource spelling even for no-follow executors:
955
+ * a differently cased name can address the same file. No-follow executors
956
+ * resolve through {@link confinedResource}, one executor request that
957
+ * reads link text instead of following links. `guard` runs it twice — once
958
+ * before the grant decision and once after — so the resolution must be a
959
+ * pure question about the current filesystem state.
960
+ */
961
+ const resolvedResource = (
962
+ action: "fs:read" | "fs:write",
963
+ method: string,
964
+ value: string,
965
+ glob = false
966
+ ): Effect.Effect<string, PlatformError.PlatformError> => {
967
+ const normalized = normalize(value)
968
+ if (atomic?.noFollowAuthorization === true) {
969
+ const { prefix, suffix } = globAuthorizationPath(path, root, normalized, glob)
970
+ return confinedResource(
971
+ pinned(atomic, root),
972
+ path,
973
+ root,
974
+ prefix,
975
+ (resource, reason, cause) => deny(action, method, resource, reason, cause)
976
+ ).pipe(
977
+ Effect.map((resource) => suffix.length === 0 ? resource : path.join(resource, ...suffix)),
978
+ Effect.flatMap(insideWorkspace(action, method))
979
+ )
980
+ }
981
+ return canonicalResource(fileSystem, path, logicalRoot, normalized).pipe(
982
+ Effect.flatMap((resource) =>
983
+ fileSystem.stat(normalized).pipe(
984
+ Effect.matchEffect({
985
+ onFailure: () => Effect.succeed(resource),
986
+ onSuccess: (info) => {
987
+ const hardLinked = info.type === "File" && Option.isSome(info.nlink) && info.nlink.value > 1
988
+ return hardLinked
989
+ ? deny(action, method, resource, "hard-linked files cannot be confined to the workspace")
990
+ : Effect.succeed(resource)
991
+ }
992
+ })
993
+ )
994
+ )
995
+ )
996
+ }
997
+ /**
998
+ * Resolves every read of one batch. A no-follow executor that batches
999
+ * answers all of them in one `resolve` batch, so authorizing a batch
1000
+ * costs one executor request rather than one per member; only a member
1001
+ * that meets a link asks again, for its target. Other hosts resolve each
1002
+ * member with bounded concurrency.
1003
+ */
1004
+ const resolvedResources = (
1005
+ values: ReadonlyArray<Pick<Batch.BatchRequest, "operation" | "path">>
1006
+ ): Effect.Effect<Array<Result.Result<string, PlatformError.PlatformError>>, PlatformError.PlatformError> => {
1007
+ const refuseRead = (resource: string, reason: string, cause?: PlatformError.PlatformError) =>
1008
+ deny("fs:read", "read", resource, reason, cause)
1009
+ if (atomic?.noFollowAuthorization !== true || atomic.batchLimits === undefined) {
1010
+ return Effect.forEach(
1011
+ values,
1012
+ (value) => Effect.result(resolvedResource("fs:read", "read", value.path, value.operation === "glob")),
1013
+ {
1014
+ concurrency: Batch.fallbackConcurrency
1015
+ }
1016
+ )
1017
+ }
1018
+ const run = pinned(atomic, root)
1019
+ const parts = values.map((value) =>
1020
+ globAuthorizationPath(path, root, normalize(value.path), value.operation === "glob")
1021
+ )
1022
+ const requested = parts.map(({ prefix }) => pinnedPath(path, root, prefix))
1023
+ const members: Array<{ readonly operation: "resolve"; readonly path: string }> = []
1024
+ const memberOf = requested.map((value) =>
1025
+ value === undefined ? undefined : members.push({ operation: "resolve", path: value }) - 1
1026
+ )
1027
+ const answered = members.length === 0
1028
+ ? Effect.succeed<AtomicBatchResponse["entries"]>([])
1029
+ : Effect.map(run({ operation: "batch", requests: members }), (response) => response.entries)
1030
+ return Effect.flatMap(answered, (entries) => {
1031
+ const results = new Map(entries.map((entry) => [entry.index, entry.result] as const))
1032
+ return Effect.forEach(values, (value, index) => {
1033
+ const member = memberOf[index]
1034
+ const resource = member === undefined
1035
+ ? Effect.succeed(normalize(value.path))
1036
+ : Effect.suspend(() => {
1037
+ const pinnedValue = members[member]!.path
1038
+ const result = results.get(member)
1039
+ // The executor's batch framing already requires one entry per
1040
+ // member; an answer of another shape is refused, not guessed.
1041
+ return result === undefined || Result.isFailure(result) || result.success.operation !== "resolve"
1042
+ ? refuseRead(
1043
+ logicalPath(path, root, pinnedValue),
1044
+ uninspectable,
1045
+ result !== undefined && Result.isFailure(result) ? result.failure : undefined
1046
+ )
1047
+ : followResolution(run, path, root, pinnedValue, result.success.resolution, refuseRead, 0)
1048
+ })
1049
+ return Effect.result(resource.pipe(
1050
+ Effect.map((resolved) =>
1051
+ parts[index]!.suffix.length === 0 ? resolved : path.join(resolved, ...parts[index]!.suffix)
1052
+ ),
1053
+ Effect.flatMap(insideWorkspace("fs:read", "read"))
1054
+ ))
1055
+ }, { concurrency: Batch.fallbackConcurrency })
1056
+ })
1057
+ }
1058
+ const guard = (
1059
+ action: "fs:read" | "fs:write",
1060
+ value: string,
1061
+ glob = false
1062
+ ): Effect.Effect<void, PlatformError.PlatformError> => {
1063
+ const method = action === "fs:read" ? "read" : "write"
1064
+ return resolvedResource(action, method, value, glob).pipe(
1065
+ Effect.flatMap((resource) =>
1066
+ makeCapability(action, resource).pipe(
1067
+ Effect.flatMap((capability) => grants.check(capability)),
1068
+ Effect.mapError(refuse(method, resource)),
1069
+ // The grant decision can suspend — an attended request waiting for
1070
+ // a human, a journal-backed store doing IO. What was authorized is
1071
+ // the resource the path named at check time, so the path must
1072
+ // still name it when the decision arrives: a symlink or rename
1073
+ // swapped in during the wait is refused, never followed.
1074
+ Effect.andThen(resolvedResource(action, method, value, glob)),
1075
+ Effect.flatMap((settled) =>
1076
+ settled === resource
1077
+ ? Effect.void
1078
+ : deny(action, method, resource, "path no longer names the resource that was authorized")
1079
+ )
1080
+ )
1081
+ )
1082
+ )
1083
+ }
1084
+ const read = (value: string) => guard("fs:read", value)
1085
+ const write = (value: string) => guard("fs:write", value)
1086
+ const atomicUnavailable = (
1087
+ action: "fs:read" | "fs:write",
1088
+ value: string,
1089
+ method: string
1090
+ ): Effect.Effect<never, PlatformError.PlatformError> =>
1091
+ deny(
1092
+ action,
1093
+ method,
1094
+ normalize(value),
1095
+ unconfinedDescription
1096
+ )
1097
+ const atomicOne = <R extends AtomicRequest>(
1098
+ action: "fs:read" | "fs:write",
1099
+ value: string,
1100
+ method: string,
1101
+ request: R
1102
+ ): Effect.Effect<AtomicResult<R>, PlatformError.PlatformError> =>
1103
+ atomic === undefined
1104
+ ? atomicUnavailable(action, value, method)
1105
+ : guard(action, value, request.operation === "glob").pipe(Effect.andThen(pinned(atomic, root)(request)))
1106
+ const atomicTwo = <R extends AtomicRequest>(
1107
+ first: readonly ["fs:read" | "fs:write", string],
1108
+ second: readonly ["fs:read" | "fs:write", string],
1109
+ method: string,
1110
+ request: R
1111
+ ): Effect.Effect<AtomicResult<R>, PlatformError.PlatformError> =>
1112
+ atomic === undefined
1113
+ ? atomicUnavailable(first[0], first[1], method)
1114
+ : guard(first[0], first[1]).pipe(
1115
+ Effect.andThen(guard(second[0], second[1])),
1116
+ Effect.andThen(pinned(atomic, root)(request))
1117
+ )
1118
+ const isolatedOne = <A>(
1119
+ action: "fs:read" | "fs:write",
1120
+ value: string,
1121
+ method: string,
1122
+ use: (isolated: EffectFileSystem.FileSystem) => Effect.Effect<A, PlatformError.PlatformError>
1123
+ ): Effect.Effect<A, PlatformError.PlatformError> =>
1124
+ atomic?.isolated === undefined
1125
+ ? atomicUnavailable(action, value, method)
1126
+ : guard(action, value).pipe(Effect.andThen(use(atomic.isolated)))
1127
+ const isolatedTwo = <A>(
1128
+ first: readonly ["fs:read" | "fs:write", string],
1129
+ second: readonly ["fs:read" | "fs:write", string],
1130
+ method: string,
1131
+ use: (isolated: EffectFileSystem.FileSystem) => Effect.Effect<A, PlatformError.PlatformError>
1132
+ ): Effect.Effect<A, PlatformError.PlatformError> =>
1133
+ atomic?.isolated === undefined
1134
+ ? atomicUnavailable(first[0], first[1], method)
1135
+ : guard(first[0], first[1]).pipe(
1136
+ Effect.andThen(guard(second[0], second[1])),
1137
+ Effect.andThen(use(atomic.isolated))
1138
+ )
1139
+ const temp = (directory: string | undefined) =>
1140
+ directory === undefined
1141
+ ? write(path.resolve(workspace.root, "..", systemTemporaryDirectoryName))
1142
+ : write(directory)
1143
+ const snapshotOptions = <T>(value: T): T => {
1144
+ if (Array.isArray(value)) return Object.freeze(value.map(snapshotOptions)) as T
1145
+ if (typeof value !== "object" || value === null) return value
1146
+ const snapshot: Record<string, unknown> = {}
1147
+ for (const [name, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
1148
+ if (!descriptor.enumerable) continue
1149
+ if (!("value" in descriptor)) throw new TypeError("filesystem options must contain only data properties")
1150
+ snapshot[name] = snapshotOptions(descriptor.value)
1151
+ }
1152
+ return Object.freeze(snapshot) as T
1153
+ }
1154
+ const normalizeTempOptions = <T extends { readonly directory?: string | undefined }>(options: T | undefined) =>
1155
+ options?.directory === undefined ? options : { ...options, directory: normalize(options.directory) }
1156
+ const openChecks = (value: string, flag: EffectFileSystem.OpenFlag) => {
1157
+ const readable = readableOpenFlags.has(flag)
1158
+ const writable = writableOpenFlags.has(flag)
1159
+ return readable && writable ?
1160
+ read(value).pipe(Effect.andThen(write(value))) :
1161
+ writable ?
1162
+ write(value) :
1163
+ read(value)
1164
+ }
1165
+ const descriptorRefusal = (action: "fs:read" | "fs:write", value: string) =>
1166
+ deny(
1167
+ action,
1168
+ action === "fs:read" ? "read" : "write",
1169
+ normalize(value),
1170
+ "descriptor no longer names the resource at its authorized path"
1171
+ )
1172
+ /**
1173
+ * Confirms an open descriptor still names the resource its authorization
1174
+ * bound: the `device:inode` identity fstat'd at open time must be what the
1175
+ * authorized path names right now. Rechecking the pathname alone would
1176
+ * re-authorize the CURRENT occupant of the path and then delegate to the
1177
+ * OLD descriptor — after a rename that descriptor can name an inode
1178
+ * outside the workspace even though the replacement path is still allowed.
1179
+ * A host that reports no inode identity is an isolated volume whose
1180
+ * attestation is the boundary; there is no descriptor evidence to verify.
1181
+ */
1182
+ const verifyDescriptor = (
1183
+ host: EffectFileSystem.FileSystem,
1184
+ action: "fs:read" | "fs:write",
1185
+ value: string,
1186
+ identity: Option.Option<string>
1187
+ ): Effect.Effect<void, PlatformError.PlatformError> =>
1188
+ Option.match(identity, {
1189
+ onNone: () => Effect.void,
1190
+ onSome: (bound) =>
1191
+ host.stat(normalize(value)).pipe(
1192
+ Effect.matchEffect({
1193
+ onFailure: () => descriptorRefusal(action, value),
1194
+ onSuccess: (info) =>
1195
+ Option.match(identityOf(info), {
1196
+ onNone: () => descriptorRefusal(action, value),
1197
+ onSome: (current) => current === bound ? Effect.void : descriptorRefusal(action, value)
1198
+ })
1199
+ })
1200
+ )
1201
+ })
1202
+ const wrapFile = (
1203
+ host: EffectFileSystem.FileSystem,
1204
+ file: EffectFileSystem.File,
1205
+ value: string,
1206
+ identity: Option.Option<string>
1207
+ ): EffectFileSystem.File => {
1208
+ const readable = Effect.suspend(() =>
1209
+ read(value).pipe(Effect.andThen(verifyDescriptor(host, "fs:read", value, identity)))
1210
+ )
1211
+ const writable = Effect.suspend(() =>
1212
+ write(value).pipe(Effect.andThen(verifyDescriptor(host, "fs:write", value, identity)))
1213
+ )
1214
+ return {
1215
+ [EffectFileSystem.FileTypeId]: EffectFileSystem.FileTypeId,
1216
+ stat: Effect.fn("FileSystem.File.stat")(() => readable.pipe(Effect.andThen(file.stat)))(),
1217
+ seek: Effect.fn("FileSystem.File.seek")(file.seek),
1218
+ sync: Effect.fn("FileSystem.File.sync")(() => writable.pipe(Effect.andThen(file.sync)))(),
1219
+ read: Effect.fn("FileSystem.File.read")((buffer) => readable.pipe(Effect.andThen(file.read(buffer)))),
1220
+ readAlloc: Effect.fn("FileSystem.File.readAlloc")((size) =>
1221
+ readable.pipe(Effect.andThen(file.readAlloc(size)))
1222
+ ),
1223
+ truncate: Effect.fn("FileSystem.File.truncate")((length) =>
1224
+ writable.pipe(Effect.andThen(file.truncate(length)))
1225
+ ),
1226
+ write: Effect.fn("FileSystem.File.write")((buffer) => writable.pipe(Effect.andThen(file.write(buffer)))),
1227
+ writeAll: Effect.fn("FileSystem.File.writeAll")((buffer) =>
1228
+ writable.pipe(Effect.andThen(file.writeAll(buffer)))
1229
+ )
1230
+ }
1231
+ }
1232
+ // `EffectFileSystem.make` brands the implementation with Effect's own
1233
+ // (non-exported) type id. The five operations `make` would derive from
1234
+ // the primitives are overridden below with guarded delegates to the host
1235
+ // implementation, so delegation semantics do not change.
1236
+ const guarded: EffectFileSystem.FileSystem = {
1237
+ ...EffectFileSystem.make({
1238
+ access: Effect.fn("FileSystem.access")((value, options) => {
1239
+ const captured = snapshotOptions(options)
1240
+ return isolatedOne("fs:read", value, "access", (host) => host.access(normalize(value), captured))
1241
+ }),
1242
+ copy: Effect.fn("FileSystem.copy")((from, to, options) => {
1243
+ const captured = snapshotOptions(options)
1244
+ return (
1245
+ isolatedTwo(
1246
+ ["fs:read", from],
1247
+ ["fs:write", to],
1248
+ "copy",
1249
+ (host) => host.copy(normalize(from), normalize(to), captured)
1250
+ )
1251
+ )
1252
+ }),
1253
+ copyFile: Effect.fn("FileSystem.copyFile")((from, to) =>
1254
+ isolatedTwo(
1255
+ ["fs:read", from],
1256
+ ["fs:write", to],
1257
+ "copyFile",
1258
+ (host) => host.copyFile(normalize(from), normalize(to))
1259
+ )
1260
+ ),
1261
+ chmod: Effect.fn("FileSystem.chmod")((value, mode) =>
1262
+ atomicOne("fs:write", value, "chmod", { operation: "chmod", path: normalize(value), options: { mode } })
1263
+ ),
1264
+ chown: Effect.fn("FileSystem.chown")((value, uid, gid) =>
1265
+ atomicOne("fs:write", value, "chown", {
1266
+ operation: "chown",
1267
+ path: normalize(value),
1268
+ options: { uid, gid }
1269
+ })
1270
+ ),
1271
+ glob: Effect.fn("FileSystem.glob")((pattern, options) => {
1272
+ const captured = snapshotOptions(options)
1273
+ const root = captured?.root === undefined ? workspace.root : normalize(captured.root)
1274
+ const normalizedPattern = normalizeFrom(root, pattern)
1275
+ return atomicOne("fs:read", normalizedPattern, "glob", {
1276
+ operation: "glob",
1277
+ pattern: normalizedPattern,
1278
+ root,
1279
+ options: captured === undefined ? undefined : { exclude: captured.exclude ?? [] }
1280
+ })
1281
+ }),
1282
+ link: Effect.fn("FileSystem.link")((from, to) =>
1283
+ isolatedTwo(["fs:read", from], ["fs:write", to], "link", (host) => host.link(normalize(from), normalize(to)))
1284
+ ),
1285
+ makeDirectory: Effect.fn("FileSystem.makeDirectory")((value, options) => {
1286
+ const captured = snapshotOptions(options)
1287
+ return atomicOne("fs:write", value, "makeDirectory", {
1288
+ operation: "makeDirectory",
1289
+ path: normalize(value),
1290
+ options: captured
1291
+ })
1292
+ }),
1293
+ makeTempDirectory: Effect.fn("FileSystem.makeTempDirectory")((options) => {
1294
+ const captured = snapshotOptions(options)
1295
+ return (
1296
+ atomic?.isolated === undefined
1297
+ ? atomicUnavailable(
1298
+ "fs:write",
1299
+ captured?.directory ?? `../${systemTemporaryDirectoryName}`,
1300
+ "makeTempDirectory"
1301
+ )
1302
+ : temp(captured?.directory).pipe(
1303
+ Effect.andThen(atomic.isolated.makeTempDirectory(normalizeTempOptions(captured)))
1304
+ )
1305
+ )
1306
+ }),
1307
+ makeTempDirectoryScoped: Effect.fn("FileSystem.makeTempDirectoryScoped")((options) => {
1308
+ const captured = snapshotOptions(options)
1309
+ return (
1310
+ atomic?.isolated === undefined
1311
+ ? atomicUnavailable(
1312
+ "fs:write",
1313
+ captured?.directory ?? `../${systemTemporaryDirectoryName}`,
1314
+ "makeTempDirectoryScoped"
1315
+ )
1316
+ : temp(captured?.directory).pipe(
1317
+ Effect.andThen(atomic.isolated.makeTempDirectoryScoped(normalizeTempOptions(captured)))
1318
+ )
1319
+ )
1320
+ }),
1321
+ makeTempFile: Effect.fn("FileSystem.makeTempFile")((options) => {
1322
+ const captured = snapshotOptions(options)
1323
+ return (
1324
+ atomic?.isolated === undefined
1325
+ ? atomicUnavailable(
1326
+ "fs:write",
1327
+ captured?.directory ?? `../${systemTemporaryDirectoryName}`,
1328
+ "makeTempFile"
1329
+ )
1330
+ : temp(captured?.directory).pipe(
1331
+ Effect.andThen(atomic.isolated.makeTempFile(normalizeTempOptions(captured)))
1332
+ )
1333
+ )
1334
+ }),
1335
+ makeTempFileScoped: Effect.fn("FileSystem.makeTempFileScoped")((options) => {
1336
+ const captured = snapshotOptions(options)
1337
+ return (
1338
+ atomic?.isolated === undefined
1339
+ ? atomicUnavailable(
1340
+ "fs:write",
1341
+ captured?.directory ?? `../${systemTemporaryDirectoryName}`,
1342
+ "makeTempFileScoped"
1343
+ )
1344
+ : temp(captured?.directory).pipe(
1345
+ Effect.andThen(atomic.isolated.makeTempFileScoped(normalizeTempOptions(captured)))
1346
+ )
1347
+ )
1348
+ }),
1349
+ open: Effect.fn("FileSystem.open")((value, options) => {
1350
+ const captured = snapshotOptions(options)
1351
+ const flag = captured?.flag ?? "r"
1352
+ const isolated = atomic?.isolated
1353
+ return isolated === undefined
1354
+ ? atomicUnavailable("fs:read", value, "open")
1355
+ : openChecks(value, flag).pipe(
1356
+ Effect.andThen(isolated.open(normalize(value), captured)),
1357
+ // fstat the handle the moment it exists: the authorization the
1358
+ // open checks granted binds to THIS resource identity, and every
1359
+ // later handle operation verifies the authorized path still
1360
+ // names it before delegating to the descriptor.
1361
+ Effect.flatMap((file) =>
1362
+ file.stat.pipe(
1363
+ Effect.map((info) => wrapFile(isolated, file, value, identityOf(info)))
1364
+ )
1365
+ )
1366
+ )
1367
+ }),
1368
+ readDirectory: Effect.fn("FileSystem.readDirectory")((value, options) => {
1369
+ const captured = snapshotOptions(options)
1370
+ return atomicOne("fs:read", value, "readDirectory", {
1371
+ operation: "readDirectory",
1372
+ path: normalize(value),
1373
+ options: captured
1374
+ })
1375
+ }),
1376
+ readFile: Effect.fn("FileSystem.readFile")((value) =>
1377
+ atomicOne("fs:read", value, "readFile", {
1378
+ operation: "readFile",
1379
+ path: normalize(value)
1380
+ })
1381
+ ),
1382
+ readLink: Effect.fn("FileSystem.readLink")((value) =>
1383
+ atomicOne("fs:read", value, "readLink", {
1384
+ operation: "readLink",
1385
+ path: normalize(value)
1386
+ })
1387
+ ),
1388
+ realPath: Effect.fn("FileSystem.realPath")((value) =>
1389
+ atomicOne("fs:read", value, "realPath", {
1390
+ operation: "realPath",
1391
+ path: normalize(value)
1392
+ })
1393
+ ),
1394
+ remove: Effect.fn("FileSystem.remove")((value, options) => {
1395
+ const captured = snapshotOptions(options)
1396
+ return atomicOne("fs:write", value, "remove", {
1397
+ operation: "remove",
1398
+ path: normalize(value),
1399
+ options: captured
1400
+ })
1401
+ }),
1402
+ rename: Effect.fn("FileSystem.rename")((from, to) =>
1403
+ atomicTwo(["fs:write", from], ["fs:write", to], "rename", {
1404
+ operation: "rename",
1405
+ from: normalize(from),
1406
+ to: normalize(to)
1407
+ })
1408
+ ),
1409
+ stat: Effect.fn("FileSystem.stat")((value) =>
1410
+ atomicOne("fs:read", value, "stat", {
1411
+ operation: "stat",
1412
+ path: normalize(value)
1413
+ })
1414
+ ),
1415
+ symlink: Effect.fn("FileSystem.symlink")((from, to) =>
1416
+ isolatedOne("fs:write", to, "symlink", (host) => host.symlink(from, normalize(to)))
1417
+ ),
1418
+ truncate: Effect.fn("FileSystem.truncate")((value, length) =>
1419
+ isolatedOne("fs:write", value, "truncate", (host) => host.truncate(normalize(value), length))
1420
+ ),
1421
+ utimes: Effect.fn("FileSystem.utimes")((value, atime, mtime) =>
1422
+ isolatedOne("fs:write", value, "utimes", (host) => host.utimes(normalize(value), atime, mtime))
1423
+ ),
1424
+ watch: (value) =>
1425
+ Stream.unwrap(
1426
+ Effect.fn("FileSystem.watch")(() =>
1427
+ Effect.suspend(() =>
1428
+ atomic?.isolated === undefined
1429
+ ? atomicUnavailable("fs:read", value, "watch")
1430
+ : read(value).pipe(Effect.map(() => atomic.isolated!.watch(normalize(value))))
1431
+ )
1432
+ )()
1433
+ ),
1434
+ writeFile: Effect.fn("FileSystem.writeFile")((value, data, options) => {
1435
+ const captured = snapshotOptions(options)
1436
+ const bytes = data.slice()
1437
+ // An isolated host shares this address space, so the detached
1438
+ // snapshot crosses no serialization boundary: hand it straight to
1439
+ // the delegate rather than base64 round-tripping it through the
1440
+ // serializable request shape.
1441
+ if (atomic?.isolated !== undefined) {
1442
+ return isolatedOne(
1443
+ "fs:write",
1444
+ value,
1445
+ "writeFile",
1446
+ (host) => host.writeFile(normalize(value), bytes, captured)
1447
+ )
1448
+ }
1449
+ // A serialized executor frames the base64 payload as one JS string.
1450
+ // Refuse what its runtime cannot represent before encoding, so an
1451
+ // oversized artifact is a typed BadArgument and not a defect out of
1452
+ // the encoder.
1453
+ const limit = atomic?.contentLimit
1454
+ if (limit !== undefined && bytes.byteLength > limit) {
1455
+ return Effect.fail(PlatformError.badArgument({
1456
+ module: "FileSystem",
1457
+ method: "writeFile",
1458
+ description:
1459
+ `writeFile payload of ${bytes.byteLength} bytes exceeds the ${limit} byte limit advertised by the host`
1460
+ }))
1461
+ }
1462
+ return atomicOne("fs:write", value, "writeFile", {
1463
+ operation: "writeFile",
1464
+ path: normalize(value),
1465
+ data: Encoding.encodeBase64(bytes),
1466
+ options: captured
1467
+ })
1468
+ })
1469
+ }),
1470
+ exists: Effect.fn("FileSystem.exists")((value) =>
1471
+ atomicOne("fs:read", value, "exists", {
1472
+ operation: "exists",
1473
+ path: normalize(value)
1474
+ })
1475
+ ),
1476
+ readFileString: Effect.fn("FileSystem.readFileString")((value, encoding) =>
1477
+ atomicOne("fs:read", value, "readFileString", {
1478
+ operation: "readFileString",
1479
+ path: normalize(value),
1480
+ encoding
1481
+ })
1482
+ ),
1483
+ sink: (value, options) => {
1484
+ const captured = snapshotOptions(options)
1485
+ return (
1486
+ Sink.unwrap(
1487
+ Effect.fn("FileSystem.sink")(
1488
+ () =>
1489
+ Effect.suspend(() =>
1490
+ atomic?.isolated === undefined
1491
+ ? atomicUnavailable("fs:write", value, "sink")
1492
+ : write(value).pipe(Effect.map(() => atomic.isolated!.sink(normalize(value), captured)))
1493
+ )
1494
+ )()
1495
+ )
1496
+ )
1497
+ },
1498
+ stream: (value, options) => {
1499
+ const captured = snapshotOptions(options)
1500
+ return (
1501
+ Stream.unwrap(
1502
+ Effect.fn("FileSystem.stream")(() =>
1503
+ Effect.suspend(() =>
1504
+ atomic?.isolated === undefined
1505
+ ? atomicUnavailable("fs:read", value, "stream")
1506
+ : read(value).pipe(Effect.map(() => atomic.isolated!.stream(normalize(value), captured)))
1507
+ )
1508
+ )()
1509
+ )
1510
+ )
1511
+ },
1512
+ writeFileString: Effect.fn("FileSystem.writeFileString")((value, data, options) => {
1513
+ const captured = snapshotOptions(options)
1514
+ return atomicOne("fs:write", value, "writeFileString", {
1515
+ operation: "writeFileString",
1516
+ path: normalize(value),
1517
+ data,
1518
+ options: captured
1519
+ })
1520
+ })
1521
+ }
1522
+ // Every path operation of the guarded surface is already one
1523
+ // descriptor-relative request against this root (or refuses), so machinery
1524
+ // composed over it may use it as its confined view.
1525
+ Object.assign(guarded, { [ConfinedFileSystemTypeId]: logicalRoot })
1526
+ if (atomic === undefined || atomic.batchLimits === undefined) {
1527
+ return guarded
1528
+ } else {
1529
+ const limits = atomic.batchLimits
1530
+ const executeBatch: Batch.FileSystemBatch["execute"] = Effect.fn("FileSystem.batch")(function*(requests) {
1531
+ if (requests.length === 0 || requests.length > Math.min(limits.size, Batch.maxBatchSize)) {
1532
+ return yield* Effect.fail(
1533
+ PlatformError.badArgument({
1534
+ module: "FileSystem",
1535
+ method: "batch",
1536
+ description: `batch must contain 1 to ${Math.min(limits.size, Batch.maxBatchSize)} operations`
1537
+ })
1538
+ )
1539
+ }
1540
+ if (Option.isNone(rootIdentity)) {
1541
+ return yield* atomicUnavailable("fs:read", logicalRoot, "batch")
1542
+ }
1543
+ // Snapshot before any asynchronous root or permission checks.
1544
+ const captured = snapshotOptions(requests).map((request) => ({
1545
+ ...request,
1546
+ path: request.operation === "glob"
1547
+ ? normalizeFrom(normalize(request.root), request.path)
1548
+ : normalize(request.path),
1549
+ ...(request.operation === "glob" ? { root: normalize(request.root) } : {})
1550
+ }))
1551
+ const rootChanged = (cause: unknown) =>
1552
+ PlatformError.systemError({
1553
+ _tag: "Busy",
1554
+ module: "FileSystem",
1555
+ method: "batch",
1556
+ pathOrDescriptor: logicalRoot,
1557
+ description: "batch root no longer names the authorized descriptor",
1558
+ cause
1559
+ })
1560
+ // A vanished workspace is a lost boundary, not N absent files. Check
1561
+ // before canonical resource resolution could turn it into per-path
1562
+ // NotFound results. Check the logical path as well: a workspace alias
1563
+ // may be retargeted while its old canonical directory still exists.
1564
+ // The helper separately checks the canonical root through no-follow
1565
+ // handles, binding both names to the same captured descriptor.
1566
+ const expectedRoot = rootIdentity.value
1567
+ const verifyRoot = () =>
1568
+ identifyRoot(atomic, fileSystem, logicalRoot).pipe(
1569
+ Effect.mapError(rootChanged),
1570
+ Effect.flatMap((current) =>
1571
+ Option.isSome(current) && current.value === expectedRoot
1572
+ ? Effect.void
1573
+ : Effect.fail(rootChanged(
1574
+ new FileSystemFault({
1575
+ code: "workspace_identity_changed",
1576
+ message: "workspace descriptor identity changed"
1577
+ })
1578
+ ))
1579
+ )
1580
+ )
1581
+ yield* verifyRoot()
1582
+ // Resolve in bounded groups while retaining request-order grant
1583
+ // decisions. Quota/once grants therefore retain their ordering, and
1584
+ // native metadata latency need not serialize every member twice.
1585
+ const resources = yield* resolvedResources(captured)
1586
+ const admitted: Array<Batch.BatchRequest> = []
1587
+ const indexes: Array<number> = []
1588
+ const entries: Array<Batch.BatchEntry> = []
1589
+ const granted: Array<
1590
+ { readonly request: Batch.BatchRequest; readonly index: number; readonly resource: string }
1591
+ > = []
1592
+ for (const [index, request] of captured.entries()) {
1593
+ const resource = resources[index]!
1594
+ const checked = Result.isFailure(resource) ? Result.fail(resource.failure) : yield* Effect.result(
1595
+ makeCapability("fs:read", resource.success).pipe(
1596
+ Effect.flatMap((capability) => grants.check(capability)),
1597
+ Effect.mapError(refuse("read", resource.success))
1598
+ )
1599
+ )
1600
+ if (Result.isFailure(checked)) {
1601
+ entries.push({ index, path: request.path, result: Result.fail(checked.failure) })
1602
+ } else granted.push({ request, index, resource: Result.getOrThrow(resource) })
1603
+ }
1604
+ const current = granted.length === 0
1605
+ ? []
1606
+ : yield* resolvedResources(granted.map(({ request }) => request))
1607
+ const settled = yield* Effect.forEach(granted, ({ resource }, position) => {
1608
+ const now = current[position]!
1609
+ return Effect.result(
1610
+ Result.isFailure(now)
1611
+ ? Effect.fail(now.failure)
1612
+ : now.success === resource
1613
+ ? Effect.void
1614
+ : deny("fs:read", "read", resource, "path no longer names the resource that was authorized")
1615
+ )
1616
+ })
1617
+ for (const [position, member] of granted.entries()) {
1618
+ const checked = settled[position]!
1619
+ if (Result.isFailure(checked)) {
1620
+ entries.push({ index: member.index, path: member.request.path, result: Result.fail(checked.failure) })
1621
+ } else {
1622
+ admitted.push(member.request)
1623
+ indexes.push(member.index)
1624
+ }
1625
+ }
1626
+ yield* verifyRoot()
1627
+ if (admitted.length > 0) {
1628
+ const measured = yield* atomic.execute({
1629
+ operation: "batch",
1630
+ boundaryRoot,
1631
+ logicalRoot,
1632
+ rootIdentity: rootIdentity.value,
1633
+ requests: admitted
1634
+ })
1635
+ for (const entry of measured.entries) {
1636
+ entries.push({
1637
+ index: indexes[entry.index]!,
1638
+ path: entry.path,
1639
+ result: publicValue(entry.path, entry.result)
1640
+ })
1641
+ }
1642
+ }
1643
+ entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : a.index - b.index)
1644
+ return { rootIdentity: rootIdentity.value, entries }
1645
+ })
1646
+ return Object.assign(guarded, {
1647
+ [Batch.FileSystemBatchTypeId]: {
1648
+ maxSize: Math.min(limits.size, Batch.maxBatchSize),
1649
+ maxResponseBytes: limits.response,
1650
+ execute: executeBatch
1651
+ }
1652
+ })
1653
+ }
1654
+ })
1655
+ )