@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,989 @@
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
+ import { permissionDenied, toPlatformError } from "@smthrs/capability/Permission";
20
+ import { Effect, Encoding, FileSystem as EffectFileSystem, Layer, Option, Path as EffectPath, PlatformError, Result, Schema, Sink, Stream } from "effect";
21
+ import * as Batch from "./FileSystemBatch.js";
22
+ import { GrantStore } from "./GrantStore.js";
23
+ import { makeCapability } from "./internal/makeCapability.js";
24
+ import { Workspace } from "./Workspace.js";
25
+ export * from "./FileSystemBatch.js";
26
+ /**
27
+ * The directory name that stands in for the host's system temporary directory
28
+ * in a capability resource. `makeTempFile` and friends called without an
29
+ * explicit `directory` are checked against this name resolved one level above
30
+ * the workspace root, so an implicit system-temp write names a resource that
31
+ * is always outside the workspace envelope and is never confusable with a real
32
+ * workspace path.
33
+ *
34
+ * @since 1.0.0-rc.0
35
+ * @category models
36
+ */
37
+ export const systemTemporaryDirectoryName = "<system-temp>";
38
+ /**
39
+ * Host-private extension used for race-free, descriptor-relative filesystem
40
+ * operations. A plain path-based `FileSystem` cannot provide confinement: an
41
+ * attacker can replace any checked component before the delegate resolves it.
42
+ * Platform adapters opt in only when they can pin a root handle and reject
43
+ * symlinks while traversing from it. Hosts without this extension fail closed.
44
+ *
45
+ * @since 1.0.0-rc.0
46
+ * @category security
47
+ */
48
+ export const AtomicFileSystemTypeId = Symbol.for("@smthrs/kernel/AtomicFileSystem");
49
+ /** Attaches a trusted platform's descriptor-relative executor to its service.
50
+ *
51
+ * The executor is attached to the supplied object, so a later attachment over
52
+ * the same service replaces this one. A host attaches exactly once at its
53
+ * boundary; a caller that deliberately layers over an existing executor must
54
+ * read it first and delegate to it, which is what makes replacement its own
55
+ * decision rather than an accident.
56
+ *
57
+ * @since 1.0.0-rc.0
58
+ * @category security
59
+ */
60
+ export const withAtomicFileSystem = (fileSystem, atomic) => Object.assign(fileSystem, { [AtomicFileSystemTypeId]: atomic });
61
+ /**
62
+ * Why a filesystem composition or guarded batch refused.
63
+ *
64
+ * @since 1.0.0-rc.0
65
+ * @category models
66
+ */
67
+ export const FileSystemFaultCode = Schema.Literals([
68
+ "executor_already_present",
69
+ "isolated_operation_unsupported",
70
+ "workspace_identity_changed"
71
+ ]);
72
+ /**
73
+ * A broken filesystem composition or a workspace whose descriptor identity
74
+ * changed. `withIsolatedFileSystem` throws `executor_already_present`; an
75
+ * attested volume reaching an operation it never advertises dies with
76
+ * `isolated_operation_unsupported` (a bug); a batch whose root no longer names
77
+ * the authorized descriptor is the `cause` of a `Busy` `PlatformError` with
78
+ * `workspace_identity_changed`.
79
+ *
80
+ * @since 1.0.0-rc.0
81
+ * @category errors
82
+ */
83
+ export class FileSystemFault extends Schema.TaggedError()("@smthrs/kernel/FileSystemFault", {
84
+ code: FileSystemFaultCode,
85
+ message: Schema.String
86
+ }) {
87
+ name = "FileSystemFault";
88
+ }
89
+ /**
90
+ * Attests that a host filesystem is already isolated as a whole. Intended for
91
+ * browser/test volumes whose implementation cannot address the host
92
+ * filesystem at all; native path-based adapters must not use this shortcut.
93
+ *
94
+ * The attestation is refused for a filesystem that already carries a
95
+ * descriptor-relative executor. That executor is the stronger guarantee, and
96
+ * replacing it with a path-delegating one would route `access`, `copy`,
97
+ * `chmod`, `link`, `symlink`, `open`, `watch`, `sink`, `stream`, and every
98
+ * `makeTemp*` back through pathnames after the capability check: the exact
99
+ * symlink-swap window this module exists to close. The refusal is a throw at
100
+ * composition time, so a host cannot be assembled that way.
101
+ *
102
+ * @since 1.0.0-rc.0
103
+ * @category security
104
+ */
105
+ export const withIsolatedFileSystem = (fileSystem) => {
106
+ if (AtomicFileSystemTypeId in fileSystem) {
107
+ throw new FileSystemFault({
108
+ code: "executor_already_present",
109
+ message: "filesystem already carries a descriptor-relative executor; attesting whole-filesystem isolation would replace it"
110
+ });
111
+ }
112
+ return withAtomicFileSystem(fileSystem, {
113
+ isolated: fileSystem,
114
+ execute: dispatch({
115
+ exists: (request) => fileSystem.exists(request.path),
116
+ chmod: (request) => fileSystem.chmod(request.path, request.options.mode),
117
+ chown: (request) => fileSystem.chown(request.path, request.options.uid, request.options.gid),
118
+ glob: (request) => fileSystem.glob(request.pattern, { ...request.options, root: request.root }),
119
+ makeDirectory: (request) => fileSystem.makeDirectory(request.path, request.options),
120
+ readDirectory: (request) => fileSystem.readDirectory(request.path, request.options),
121
+ readFile: (request) => fileSystem.readFile(request.path),
122
+ readFileString: (request) => fileSystem.readFileString(request.path, request.encoding),
123
+ readLink: (request) => fileSystem.readLink(request.path),
124
+ realPath: (request) => fileSystem.realPath(request.path),
125
+ // Authorization asks `resolve` only of an executor that claims
126
+ // `noFollowAuthorization`, which an attested volume never does.
127
+ resolve: (request) => unsupportedIsolated(request.operation),
128
+ remove: (request) => fileSystem.remove(request.path, request.options),
129
+ rename: (request) => fileSystem.rename(request.from, request.to),
130
+ stat: (request) => fileSystem.stat(request.path),
131
+ writeFile: (request) => Encoding.decodeBase64(request.data).pipe(Effect.fromResult, Effect.orDie, Effect.flatMap((data) => fileSystem.writeFile(request.path, data, request.options))),
132
+ writeFileString: (request) => fileSystem.writeFileString(request.path, request.data, request.options),
133
+ // An attested volume advertises no `batchLimits`, so the guarded layer
134
+ // never frames a batch for this executor.
135
+ batch: (request) => unsupportedIsolated(request.operation)
136
+ })
137
+ });
138
+ };
139
+ const unsupportedIsolated = (operation) => Effect.die(new FileSystemFault({
140
+ code: "isolated_operation_unsupported",
141
+ message: `unsupported isolated filesystem operation: ${operation}`
142
+ }));
143
+ /**
144
+ * Turns a typed handler record into the executor the host extension declares.
145
+ *
146
+ * The record is what carries the safety: every operation is implemented, each
147
+ * handler reads its own operands, and each returns its own result. The lookup
148
+ * itself is one assertion because TypeScript cannot correlate an index it
149
+ * narrowed only on the key with the request and result types that key selects.
150
+ * An operation no handler implements can only arrive from a serialized
151
+ * boundary that framed an operation this build does not know, so it dies
152
+ * rather than resolving to `undefined` and being called.
153
+ */
154
+ const dispatch = (handlers) => {
155
+ const table = handlers;
156
+ return (request) => {
157
+ const handler = table[request.operation];
158
+ return (handler === undefined ? unsupportedIsolated(request.operation) : handler(request));
159
+ };
160
+ };
161
+ /** Prefer the host's exact file ID over the public adapter's optional numeric inode. */
162
+ const identifyRoot = (atomic, fileSystem, path) => atomic.identifyRoot === undefined
163
+ ? Effect.map(fileSystem.stat(path), (info) => Option.map(info.ino, (ino) => `${info.dev}:${ino}`))
164
+ : Effect.map(atomic.identifyRoot(path), Option.some);
165
+ /**
166
+ * Stamps a request with its pinned root and runs it through the executor. The
167
+ * one place both the guarded layer and {@link confined} build the wire request,
168
+ * so the two surfaces cannot drift apart.
169
+ */
170
+ const pinned = (atomic, root) => (request) => atomic.execute({ ...request, ...root });
171
+ const unconfinedDescription = "host does not provide descriptor-relative, no-follow filesystem isolation";
172
+ const unconfined = (method, pathOrDescriptor) => PlatformError.systemError({
173
+ _tag: "PermissionDenied",
174
+ module: "FileSystem",
175
+ method,
176
+ pathOrDescriptor,
177
+ description: unconfinedDescription
178
+ });
179
+ /**
180
+ * Brands a {@link confined} view with the logical root it is pinned to, so a
181
+ * caller handed an already-confined view does not wrap it a second time.
182
+ */
183
+ const ConfinedFileSystemTypeId = Symbol.for("@smthrs/kernel/ConfinedFileSystem");
184
+ /**
185
+ * Whether {@link confined} can build a view over this filesystem: it carries a
186
+ * descriptor-relative executor, or a whole-filesystem isolation attestation.
187
+ * A plain path-based host answers `false`.
188
+ *
189
+ * @since 1.0.0-rc.1
190
+ * @category security
191
+ */
192
+ export const isConfinable = (fileSystem) => ConfinedFileSystemTypeId in fileSystem || AtomicFileSystemTypeId in fileSystem;
193
+ /**
194
+ * The logical root a filesystem is already confined to: a {@link confined}
195
+ * view's pinned root, or the guarded {@link layer} service's workspace root.
196
+ * `undefined` for any other filesystem, including a host that is merely
197
+ * confinable. Machinery that shares a filesystem with its caller confines to
198
+ * this root, so its paths resolve exactly where the caller's do.
199
+ *
200
+ * @since 1.0.0-rc.1
201
+ * @category security
202
+ */
203
+ export const confinedRoot = (fileSystem) => fileSystem[ConfinedFileSystemTypeId];
204
+ /**
205
+ * Fails with the typed `PermissionDenied` refusal unless {@link confined} can
206
+ * build a view over this filesystem. Composition-time check for layers that
207
+ * pin their root later, when it is known to exist.
208
+ *
209
+ * @since 1.0.0-rc.1
210
+ * @category security
211
+ */
212
+ export const requireConfinable = (fileSystem, root) => isConfinable(fileSystem) ? Effect.void : Effect.fail(unconfined("confined", root));
213
+ /**
214
+ * A filesystem view confined to `root` without capability checks: engine
215
+ * machinery that must not ask a grant store for its own bookkeeping, but must
216
+ * still never be redirected outside the workspace.
217
+ *
218
+ * Every operation is one descriptor-relative request against the root pinned
219
+ * here (its canonical path and `device:inode` identity), resolved by the host
220
+ * helper with `O_NOFOLLOW` at each component, so there is no window between a
221
+ * check and a write for a symlink to be swapped into. A symlink anywhere on the
222
+ * path, a hard-linked file, `..` traversal, or a root whose identity changed is
223
+ * refused by the host, never followed. The requests are exactly the ones the
224
+ * guarded {@link layer} sends, minus the grant check.
225
+ *
226
+ * A host carrying a whole-filesystem isolation attestation
227
+ * ({@link withIsolatedFileSystem}) is returned as it is: its attestation is
228
+ * the boundary. So is the guarded {@link layer}'s own service when it is
229
+ * rooted at the same workspace, since each of its path operations is already
230
+ * one such request. A plain path-based host is refused with `PermissionDenied`, so
231
+ * there is no path-based fallback that claims confinement. Operations that
232
+ * cannot be expressed as one atomic request (`open`, `stream`, `sink`, `copy`,
233
+ * `link`, `symlink`, `watch`, `truncate`, `utimes`, `access`, and every
234
+ * `makeTemp*`) fail with the same refusal.
235
+ *
236
+ * The root must exist when the view is built.
237
+ *
238
+ * @since 1.0.0-rc.1
239
+ * @category security
240
+ */
241
+ export const confined = (fileSystem, root) => Effect.gen(function* () {
242
+ const path = yield* EffectPath.Path;
243
+ const logicalRoot = path.resolve(root === "" ? "." : root);
244
+ if (confinedRoot(fileSystem) === logicalRoot)
245
+ return fileSystem;
246
+ const atomic = fileSystem[AtomicFileSystemTypeId];
247
+ if (atomic === undefined)
248
+ return yield* Effect.fail(unconfined("confined", logicalRoot));
249
+ if (atomic.isolated !== undefined)
250
+ return fileSystem;
251
+ const boundaryRoot = yield* fileSystem.realPath(logicalRoot);
252
+ const rootIdentity = yield* identifyRoot(atomic, fileSystem, boundaryRoot);
253
+ if (Option.isNone(rootIdentity))
254
+ return yield* Effect.fail(unconfined("confined", logicalRoot));
255
+ const run = pinned(atomic, { boundaryRoot, logicalRoot, rootIdentity: rootIdentity.value });
256
+ const normalize = (value) => path.resolve(logicalRoot, value);
257
+ const refuse = (method, value) => Effect.fail(unconfined(method, normalize(value)));
258
+ const view = {
259
+ ...EffectFileSystem.make({
260
+ access: (value) => refuse("access", value),
261
+ copy: (from) => refuse("copy", from),
262
+ copyFile: (from) => refuse("copyFile", from),
263
+ chmod: (value, mode) => run({ operation: "chmod", path: normalize(value), options: { mode } }),
264
+ chown: (value, uid, gid) => run({ operation: "chown", path: normalize(value), options: { uid, gid } }),
265
+ glob: (pattern, options) => {
266
+ const base = options?.root === undefined ? logicalRoot : normalize(options.root);
267
+ return run({
268
+ operation: "glob",
269
+ pattern: path.resolve(base, pattern),
270
+ root: base,
271
+ options: options === undefined ? undefined : { exclude: options.exclude ?? [] }
272
+ });
273
+ },
274
+ link: (from) => refuse("link", from),
275
+ makeDirectory: (value, options) => run({
276
+ operation: "makeDirectory",
277
+ path: normalize(value),
278
+ options
279
+ }),
280
+ makeTempDirectory: () => refuse("makeTempDirectory", logicalRoot),
281
+ makeTempDirectoryScoped: () => refuse("makeTempDirectoryScoped", logicalRoot),
282
+ makeTempFile: () => refuse("makeTempFile", logicalRoot),
283
+ makeTempFileScoped: () => refuse("makeTempFileScoped", logicalRoot),
284
+ open: (value) => refuse("open", value),
285
+ readDirectory: (value, options) => run({
286
+ operation: "readDirectory",
287
+ path: normalize(value),
288
+ options
289
+ }),
290
+ readFile: (value) => run({ operation: "readFile", path: normalize(value) }),
291
+ readLink: (value) => run({ operation: "readLink", path: normalize(value) }),
292
+ realPath: (value) => run({ operation: "realPath", path: normalize(value) }),
293
+ remove: (value, options) => run({
294
+ operation: "remove",
295
+ path: normalize(value),
296
+ options
297
+ }),
298
+ rename: (from, to) => run({ operation: "rename", from: normalize(from), to: normalize(to) }),
299
+ stat: (value) => run({ operation: "stat", path: normalize(value) }),
300
+ symlink: (_from, to) => refuse("symlink", to),
301
+ truncate: (value) => refuse("truncate", value),
302
+ utimes: (value) => refuse("utimes", value),
303
+ watch: (value) => Stream.fail(unconfined("watch", normalize(value))),
304
+ writeFile: (value, data, options) => {
305
+ const limit = atomic.contentLimit;
306
+ if (limit !== undefined && data.byteLength > limit) {
307
+ return Effect.fail(PlatformError.badArgument({
308
+ module: "FileSystem",
309
+ method: "writeFile",
310
+ description: `writeFile payload of ${data.byteLength} bytes exceeds the ${limit} byte limit advertised by the host`
311
+ }));
312
+ }
313
+ return run({
314
+ operation: "writeFile",
315
+ path: normalize(value),
316
+ data: Encoding.encodeBase64(data),
317
+ options
318
+ });
319
+ }
320
+ }),
321
+ exists: (value) => run({ operation: "exists", path: normalize(value) }),
322
+ readFileString: (value, encoding) => run({ operation: "readFileString", path: normalize(value), encoding }),
323
+ writeFileString: (value, data, options) => run({
324
+ operation: "writeFileString",
325
+ path: normalize(value),
326
+ data,
327
+ options
328
+ })
329
+ };
330
+ return Object.assign(view, { [ConfinedFileSystemTypeId]: logicalRoot });
331
+ });
332
+ const readableOpenFlags = new Set([
333
+ "r",
334
+ "r+",
335
+ "w+",
336
+ "wx+",
337
+ "a+",
338
+ "ax+"
339
+ ]);
340
+ const writableOpenFlags = new Set([
341
+ "r+",
342
+ "w",
343
+ "wx",
344
+ "w+",
345
+ "wx+",
346
+ "a",
347
+ "ax",
348
+ "a+",
349
+ "ax+"
350
+ ]);
351
+ const isInside = (path, root, candidate) => {
352
+ const relative = path.relative(root, candidate);
353
+ return relative === "" ||
354
+ (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
355
+ };
356
+ /**
357
+ * The `device:inode` identity a descriptor-bound authorization pins, or
358
+ * `Option.none` when the entry is not a regular file or the host reports no
359
+ * inode identity. A host without inode identity is an isolated volume, and its
360
+ * isolation attestation — not inode evidence — is the confinement boundary.
361
+ */
362
+ const identityOf = (info) => info.type === "File"
363
+ ? Option.map(info.ino, (ino) => `${info.dev}:${ino}`)
364
+ : Option.none();
365
+ /**
366
+ * Resolves a path through every existing ancestor and maps paths inside the
367
+ * canonical workspace back to the stable logical workspace root. Existing
368
+ * symlinks therefore cannot turn an inside-workspace grant into outside
369
+ * authority, while capability resources remain stable when the root itself is
370
+ * a symlink. The host's `realPath` must return the on-disk spelling of existing
371
+ * components, including on case-insensitive volumes. Native Node hosts use
372
+ * the promise-based `realpath`, which uses the native implementation.
373
+ *
374
+ * @category security
375
+ * @since 1.0.0-rc.0
376
+ */
377
+ export const canonicalResource = (fileSystem, path, workspaceRoot, value) => {
378
+ const normalizedRoot = path.normalize(workspaceRoot);
379
+ const normalizedValue = path.normalize(path.isAbsolute(value) ? value : path.resolve(normalizedRoot, value));
380
+ const resolveExistingAncestor = (candidate, symlinkDepth = 0) => fileSystem.realPath(candidate).pipe(Effect.catch((error) => {
381
+ const resolveParent = () => {
382
+ const parent = path.dirname(candidate);
383
+ if (parent === candidate) {
384
+ return Effect.fail(error);
385
+ }
386
+ return resolveExistingAncestor(parent, symlinkDepth).pipe(Effect.map((resolvedParent) => path.join(resolvedParent, path.basename(candidate))));
387
+ };
388
+ return fileSystem.readLink(candidate).pipe(Effect.matchEffect({
389
+ onFailure: resolveParent,
390
+ onSuccess: (target) => symlinkDepth >= 40
391
+ ? Effect.fail(error)
392
+ : resolveExistingAncestor(path.isAbsolute(target) ? target : path.resolve(path.dirname(candidate), target), symlinkDepth + 1)
393
+ }));
394
+ }));
395
+ return Effect.all([
396
+ fileSystem.realPath(normalizedRoot),
397
+ resolveExistingAncestor(normalizedValue)
398
+ ]).pipe(Effect.map(([canonicalRoot, canonicalValue]) => isInside(path, canonicalRoot, canonicalValue)
399
+ ? path.normalize(path.join(normalizedRoot, path.relative(canonicalRoot, canonicalValue)))
400
+ : path.normalize(canonicalValue)));
401
+ };
402
+ const uninspectable = "path component could not be inspected without following it";
403
+ /** `value` spelled below the pinned boundary root, or `undefined` outside both roots. */
404
+ const pinnedPath = (path, root, value) => {
405
+ const base = isInside(path, root.logicalRoot, value)
406
+ ? root.logicalRoot
407
+ : isInside(path, root.boundaryRoot, value)
408
+ ? root.boundaryRoot
409
+ : undefined;
410
+ return base === undefined ? undefined : path.join(root.boundaryRoot, path.relative(base, value));
411
+ };
412
+ const segmentsBelow = (path, root, value) => path.relative(root, value).split(path.sep).filter((segment) => segment !== "");
413
+ /** Resolve a glob's literal prefix, keeping pattern syntax out of filename lookups. */
414
+ const globAuthorizationPath = (path, root, value, glob) => {
415
+ const requested = glob ? pinnedPath(path, root, value) : undefined;
416
+ if (requested === undefined)
417
+ return { prefix: value, suffix: [] };
418
+ // The workspace's own name is literal even if it contains glob metacharacters.
419
+ const parts = segmentsBelow(path, root.boundaryRoot, requested);
420
+ const wildcard = parts.findIndex((part) => /[*?[{]|[@+!]\(/.test(part));
421
+ return wildcard < 0
422
+ ? { prefix: value, suffix: [] }
423
+ // Keep logical spelling so the executor mapping pins the prefix exactly once.
424
+ : { prefix: path.join(root.logicalRoot, ...parts.slice(0, wildcard)), suffix: parts.slice(wildcard) };
425
+ };
426
+ /**
427
+ * Resolves the resource a no-follow executor addresses without resolving any
428
+ * pathname on the host. Windows `realpath` opens its argument through every
429
+ * link, so asking the host about a planted link to `\\.\pipe\name` or
430
+ * `\\host\share\x` connects to that pipe or share during authorization,
431
+ * and a component swapped for a link between two host checks is followed
432
+ * even when each check alone was safe (#2882).
433
+ *
434
+ * The executor answers one descriptor-relative `resolve` request from the
435
+ * pinned root instead ({@link Resolution}): existing components in their
436
+ * on-disk spelling, a missing component and its descendants as requested, or
437
+ * the first link on the way with its text. Link text is resolved lexically
438
+ * and asked again, and a result outside both roots is returned untouched for
439
+ * the caller to deny. Any failure is refused.
440
+ */
441
+ const confinedResource = (run, path, root, value, refuse, symlinkDepth = 0) => {
442
+ const requested = pinnedPath(path, root, value);
443
+ if (requested === undefined) {
444
+ return Effect.succeed(value);
445
+ }
446
+ return run({ operation: "resolve", path: requested }).pipe(Effect.matchEffect({
447
+ onFailure: (cause) => refuse(logicalPath(path, root, requested), uninspectable, cause),
448
+ onSuccess: (resolution) => followResolution(run, path, root, requested, resolution, refuse, symlinkDepth)
449
+ }));
450
+ };
451
+ const logicalPath = (path, root, canonical) => path.normalize(path.join(root.logicalRoot, path.relative(root.boundaryRoot, canonical)));
452
+ /** Turns one executor {@link Resolution} of `requested` into the resource it names. */
453
+ const followResolution = (run, path, root, requested, resolution, refuse, symlinkDepth) => {
454
+ const resolved = path.normalize(resolution.path);
455
+ if (!isInside(path, root.boundaryRoot, resolved)) {
456
+ return refuse(logicalPath(path, root, requested), "path resolves outside the pinned root");
457
+ }
458
+ if (resolution.target === null) {
459
+ return Effect.succeed(logicalPath(path, root, resolved));
460
+ }
461
+ if (symlinkDepth >= 40) {
462
+ return refuse(logicalPath(path, root, resolved), "too many levels of symbolic links");
463
+ }
464
+ const rest = segmentsBelow(path, root.boundaryRoot, requested).slice(segmentsBelow(path, root.boundaryRoot, resolved).length);
465
+ return confinedResource(run, path, root, path.resolve(path.dirname(resolved), resolution.target, ...rest), refuse, symlinkDepth + 1);
466
+ };
467
+ /** A public read's batch value; a `resolve` answer to a read is a host defect and fails closed. */
468
+ const publicValue = (resource, result) => Result.isFailure(result) || result.success.operation !== "resolve"
469
+ ? result
470
+ : Result.fail(PlatformError.systemError({
471
+ _tag: "PermissionDenied",
472
+ module: "FileSystem",
473
+ method: "batch",
474
+ pathOrDescriptor: resource,
475
+ description: "executor answered a batch read with a resolution"
476
+ }));
477
+ /**
478
+ * Decorates Effect's filesystem service in place with workspace-normalized
479
+ * capability checks. Every host resolves canonical path spelling before the
480
+ * capability check and again after every grant decision. Descriptor-relative
481
+ * executors enforce hard-link refusal themselves; other hosts also run the
482
+ * hard-link guard here. A decision can
483
+ * suspend (an attended request, a journal-backed store), and an operation
484
+ * whose path no longer names the resource that was authorized is refused
485
+ * rather than performed. Open file handles bind their authorization to the
486
+ * `device:inode` identity fstat'd at open time and refuse any operation once
487
+ * the authorized path names a different resource.
488
+ *
489
+ * The layer provides the tag it also requires: compose it over a host
490
+ * filesystem layer with `Layer.provide` and every consumer of
491
+ * `FileSystem.FileSystem` — including one that never heard of the kernel —
492
+ * resolves the guarded implementation. A denied request surfaces as a
493
+ * `PlatformError` whose reason is `PermissionDenied` and whose `cause` is the
494
+ * kernel's own `PermissionRequired`, `PermissionDenied`, or `GrantStoreError`;
495
+ * `Permission.fromPlatformError` reads it back.
496
+ *
497
+ * @category layers
498
+ * @since 1.0.0-rc.0
499
+ */
500
+ export const layer = Layer.effect(EffectFileSystem.FileSystem, Effect.gen(function* () {
501
+ const fileSystem = yield* EffectFileSystem.FileSystem;
502
+ const path = yield* EffectPath.Path;
503
+ const workspace = yield* Workspace;
504
+ const grants = yield* GrantStore;
505
+ const atomic = fileSystem[AtomicFileSystemTypeId];
506
+ const normalizeFrom = (base, value) => path.normalize(path.isAbsolute(value) ? value : path.resolve(base, value));
507
+ const logicalRoot = path.normalize(path.resolve(workspace.root));
508
+ const normalize = (value) => normalizeFrom(logicalRoot, value);
509
+ const boundaryRoot = yield* fileSystem.realPath(logicalRoot);
510
+ // Descriptor-relative hosts need the composition-time identity even when
511
+ // they expose no batching. Already-isolated volumes need no native inode.
512
+ const rootIdentity = atomic === undefined || (atomic.isolated !== undefined && atomic.batchLimits === undefined)
513
+ ? Option.none()
514
+ : yield* identifyRoot(atomic, fileSystem, boundaryRoot);
515
+ const root = { boundaryRoot, logicalRoot, rootIdentity: Option.getOrUndefined(rootIdentity) };
516
+ const refuse = (method, resource) => (error) => toPlatformError({ module: "FileSystem", method, pathOrDescriptor: resource, error });
517
+ const deny = (action, method, resource, reason, cause) => makeCapability(action, resource).pipe(Effect.flatMap((capability) => {
518
+ const failure = permissionDenied(capability, reason);
519
+ // Keep the permission identity and the typed inspection failure together.
520
+ if (cause !== undefined)
521
+ Object.defineProperty(failure, "cause", { value: cause });
522
+ return Effect.fail(failure);
523
+ }), Effect.mapError(refuse(method, resource)), Effect.mapError((failure) => cause?.reason.description === undefined
524
+ ? failure
525
+ : PlatformError.systemError({
526
+ ...failure.reason,
527
+ _tag: "PermissionDenied",
528
+ description: cause.reason.description,
529
+ cause: failure.reason.cause
530
+ })));
531
+ const insideWorkspace = (action, method) => (resource) => isInside(path, logicalRoot, resource)
532
+ ? Effect.succeed(resource)
533
+ : deny(action, method, resource, "path is outside the workspace");
534
+ /**
535
+ * Resolves the on-disk resource spelling even for no-follow executors:
536
+ * a differently cased name can address the same file. No-follow executors
537
+ * resolve through {@link confinedResource}, one executor request that
538
+ * reads link text instead of following links. `guard` runs it twice — once
539
+ * before the grant decision and once after — so the resolution must be a
540
+ * pure question about the current filesystem state.
541
+ */
542
+ const resolvedResource = (action, method, value, glob = false) => {
543
+ const normalized = normalize(value);
544
+ if (atomic?.noFollowAuthorization === true) {
545
+ const { prefix, suffix } = globAuthorizationPath(path, root, normalized, glob);
546
+ return confinedResource(pinned(atomic, root), path, root, prefix, (resource, reason, cause) => deny(action, method, resource, reason, cause)).pipe(Effect.map((resource) => suffix.length === 0 ? resource : path.join(resource, ...suffix)), Effect.flatMap(insideWorkspace(action, method)));
547
+ }
548
+ return canonicalResource(fileSystem, path, logicalRoot, normalized).pipe(Effect.flatMap((resource) => fileSystem.stat(normalized).pipe(Effect.matchEffect({
549
+ onFailure: () => Effect.succeed(resource),
550
+ onSuccess: (info) => {
551
+ const hardLinked = info.type === "File" && Option.isSome(info.nlink) && info.nlink.value > 1;
552
+ return hardLinked
553
+ ? deny(action, method, resource, "hard-linked files cannot be confined to the workspace")
554
+ : Effect.succeed(resource);
555
+ }
556
+ }))));
557
+ };
558
+ /**
559
+ * Resolves every read of one batch. A no-follow executor that batches
560
+ * answers all of them in one `resolve` batch, so authorizing a batch
561
+ * costs one executor request rather than one per member; only a member
562
+ * that meets a link asks again, for its target. Other hosts resolve each
563
+ * member with bounded concurrency.
564
+ */
565
+ const resolvedResources = (values) => {
566
+ const refuseRead = (resource, reason, cause) => deny("fs:read", "read", resource, reason, cause);
567
+ if (atomic?.noFollowAuthorization !== true || atomic.batchLimits === undefined) {
568
+ return Effect.forEach(values, (value) => Effect.result(resolvedResource("fs:read", "read", value.path, value.operation === "glob")), {
569
+ concurrency: Batch.fallbackConcurrency
570
+ });
571
+ }
572
+ const run = pinned(atomic, root);
573
+ const parts = values.map((value) => globAuthorizationPath(path, root, normalize(value.path), value.operation === "glob"));
574
+ const requested = parts.map(({ prefix }) => pinnedPath(path, root, prefix));
575
+ const members = [];
576
+ const memberOf = requested.map((value) => value === undefined ? undefined : members.push({ operation: "resolve", path: value }) - 1);
577
+ const answered = members.length === 0
578
+ ? Effect.succeed([])
579
+ : Effect.map(run({ operation: "batch", requests: members }), (response) => response.entries);
580
+ return Effect.flatMap(answered, (entries) => {
581
+ const results = new Map(entries.map((entry) => [entry.index, entry.result]));
582
+ return Effect.forEach(values, (value, index) => {
583
+ const member = memberOf[index];
584
+ const resource = member === undefined
585
+ ? Effect.succeed(normalize(value.path))
586
+ : Effect.suspend(() => {
587
+ const pinnedValue = members[member].path;
588
+ const result = results.get(member);
589
+ // The executor's batch framing already requires one entry per
590
+ // member; an answer of another shape is refused, not guessed.
591
+ return result === undefined || Result.isFailure(result) || result.success.operation !== "resolve"
592
+ ? refuseRead(logicalPath(path, root, pinnedValue), uninspectable, result !== undefined && Result.isFailure(result) ? result.failure : undefined)
593
+ : followResolution(run, path, root, pinnedValue, result.success.resolution, refuseRead, 0);
594
+ });
595
+ return Effect.result(resource.pipe(Effect.map((resolved) => parts[index].suffix.length === 0 ? resolved : path.join(resolved, ...parts[index].suffix)), Effect.flatMap(insideWorkspace("fs:read", "read"))));
596
+ }, { concurrency: Batch.fallbackConcurrency });
597
+ });
598
+ };
599
+ const guard = (action, value, glob = false) => {
600
+ const method = action === "fs:read" ? "read" : "write";
601
+ return resolvedResource(action, method, value, glob).pipe(Effect.flatMap((resource) => makeCapability(action, resource).pipe(Effect.flatMap((capability) => grants.check(capability)), Effect.mapError(refuse(method, resource)),
602
+ // The grant decision can suspend — an attended request waiting for
603
+ // a human, a journal-backed store doing IO. What was authorized is
604
+ // the resource the path named at check time, so the path must
605
+ // still name it when the decision arrives: a symlink or rename
606
+ // swapped in during the wait is refused, never followed.
607
+ Effect.andThen(resolvedResource(action, method, value, glob)), Effect.flatMap((settled) => settled === resource
608
+ ? Effect.void
609
+ : deny(action, method, resource, "path no longer names the resource that was authorized")))));
610
+ };
611
+ const read = (value) => guard("fs:read", value);
612
+ const write = (value) => guard("fs:write", value);
613
+ const atomicUnavailable = (action, value, method) => deny(action, method, normalize(value), unconfinedDescription);
614
+ const atomicOne = (action, value, method, request) => atomic === undefined
615
+ ? atomicUnavailable(action, value, method)
616
+ : guard(action, value, request.operation === "glob").pipe(Effect.andThen(pinned(atomic, root)(request)));
617
+ const atomicTwo = (first, second, method, request) => atomic === undefined
618
+ ? atomicUnavailable(first[0], first[1], method)
619
+ : guard(first[0], first[1]).pipe(Effect.andThen(guard(second[0], second[1])), Effect.andThen(pinned(atomic, root)(request)));
620
+ const isolatedOne = (action, value, method, use) => atomic?.isolated === undefined
621
+ ? atomicUnavailable(action, value, method)
622
+ : guard(action, value).pipe(Effect.andThen(use(atomic.isolated)));
623
+ const isolatedTwo = (first, second, method, use) => atomic?.isolated === undefined
624
+ ? atomicUnavailable(first[0], first[1], method)
625
+ : guard(first[0], first[1]).pipe(Effect.andThen(guard(second[0], second[1])), Effect.andThen(use(atomic.isolated)));
626
+ const temp = (directory) => directory === undefined
627
+ ? write(path.resolve(workspace.root, "..", systemTemporaryDirectoryName))
628
+ : write(directory);
629
+ const snapshotOptions = (value) => {
630
+ if (Array.isArray(value))
631
+ return Object.freeze(value.map(snapshotOptions));
632
+ if (typeof value !== "object" || value === null)
633
+ return value;
634
+ const snapshot = {};
635
+ for (const [name, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
636
+ if (!descriptor.enumerable)
637
+ continue;
638
+ if (!("value" in descriptor))
639
+ throw new TypeError("filesystem options must contain only data properties");
640
+ snapshot[name] = snapshotOptions(descriptor.value);
641
+ }
642
+ return Object.freeze(snapshot);
643
+ };
644
+ const normalizeTempOptions = (options) => options?.directory === undefined ? options : { ...options, directory: normalize(options.directory) };
645
+ const openChecks = (value, flag) => {
646
+ const readable = readableOpenFlags.has(flag);
647
+ const writable = writableOpenFlags.has(flag);
648
+ return readable && writable ?
649
+ read(value).pipe(Effect.andThen(write(value))) :
650
+ writable ?
651
+ write(value) :
652
+ read(value);
653
+ };
654
+ const descriptorRefusal = (action, value) => deny(action, action === "fs:read" ? "read" : "write", normalize(value), "descriptor no longer names the resource at its authorized path");
655
+ /**
656
+ * Confirms an open descriptor still names the resource its authorization
657
+ * bound: the `device:inode` identity fstat'd at open time must be what the
658
+ * authorized path names right now. Rechecking the pathname alone would
659
+ * re-authorize the CURRENT occupant of the path and then delegate to the
660
+ * OLD descriptor — after a rename that descriptor can name an inode
661
+ * outside the workspace even though the replacement path is still allowed.
662
+ * A host that reports no inode identity is an isolated volume whose
663
+ * attestation is the boundary; there is no descriptor evidence to verify.
664
+ */
665
+ const verifyDescriptor = (host, action, value, identity) => Option.match(identity, {
666
+ onNone: () => Effect.void,
667
+ onSome: (bound) => host.stat(normalize(value)).pipe(Effect.matchEffect({
668
+ onFailure: () => descriptorRefusal(action, value),
669
+ onSuccess: (info) => Option.match(identityOf(info), {
670
+ onNone: () => descriptorRefusal(action, value),
671
+ onSome: (current) => current === bound ? Effect.void : descriptorRefusal(action, value)
672
+ })
673
+ }))
674
+ });
675
+ const wrapFile = (host, file, value, identity) => {
676
+ const readable = Effect.suspend(() => read(value).pipe(Effect.andThen(verifyDescriptor(host, "fs:read", value, identity))));
677
+ const writable = Effect.suspend(() => write(value).pipe(Effect.andThen(verifyDescriptor(host, "fs:write", value, identity))));
678
+ return {
679
+ [EffectFileSystem.FileTypeId]: EffectFileSystem.FileTypeId,
680
+ stat: Effect.fn("FileSystem.File.stat")(() => readable.pipe(Effect.andThen(file.stat)))(),
681
+ seek: Effect.fn("FileSystem.File.seek")(file.seek),
682
+ sync: Effect.fn("FileSystem.File.sync")(() => writable.pipe(Effect.andThen(file.sync)))(),
683
+ read: Effect.fn("FileSystem.File.read")((buffer) => readable.pipe(Effect.andThen(file.read(buffer)))),
684
+ readAlloc: Effect.fn("FileSystem.File.readAlloc")((size) => readable.pipe(Effect.andThen(file.readAlloc(size)))),
685
+ truncate: Effect.fn("FileSystem.File.truncate")((length) => writable.pipe(Effect.andThen(file.truncate(length)))),
686
+ write: Effect.fn("FileSystem.File.write")((buffer) => writable.pipe(Effect.andThen(file.write(buffer)))),
687
+ writeAll: Effect.fn("FileSystem.File.writeAll")((buffer) => writable.pipe(Effect.andThen(file.writeAll(buffer))))
688
+ };
689
+ };
690
+ // `EffectFileSystem.make` brands the implementation with Effect's own
691
+ // (non-exported) type id. The five operations `make` would derive from
692
+ // the primitives are overridden below with guarded delegates to the host
693
+ // implementation, so delegation semantics do not change.
694
+ const guarded = {
695
+ ...EffectFileSystem.make({
696
+ access: Effect.fn("FileSystem.access")((value, options) => {
697
+ const captured = snapshotOptions(options);
698
+ return isolatedOne("fs:read", value, "access", (host) => host.access(normalize(value), captured));
699
+ }),
700
+ copy: Effect.fn("FileSystem.copy")((from, to, options) => {
701
+ const captured = snapshotOptions(options);
702
+ return (isolatedTwo(["fs:read", from], ["fs:write", to], "copy", (host) => host.copy(normalize(from), normalize(to), captured)));
703
+ }),
704
+ copyFile: Effect.fn("FileSystem.copyFile")((from, to) => isolatedTwo(["fs:read", from], ["fs:write", to], "copyFile", (host) => host.copyFile(normalize(from), normalize(to)))),
705
+ chmod: Effect.fn("FileSystem.chmod")((value, mode) => atomicOne("fs:write", value, "chmod", { operation: "chmod", path: normalize(value), options: { mode } })),
706
+ chown: Effect.fn("FileSystem.chown")((value, uid, gid) => atomicOne("fs:write", value, "chown", {
707
+ operation: "chown",
708
+ path: normalize(value),
709
+ options: { uid, gid }
710
+ })),
711
+ glob: Effect.fn("FileSystem.glob")((pattern, options) => {
712
+ const captured = snapshotOptions(options);
713
+ const root = captured?.root === undefined ? workspace.root : normalize(captured.root);
714
+ const normalizedPattern = normalizeFrom(root, pattern);
715
+ return atomicOne("fs:read", normalizedPattern, "glob", {
716
+ operation: "glob",
717
+ pattern: normalizedPattern,
718
+ root,
719
+ options: captured === undefined ? undefined : { exclude: captured.exclude ?? [] }
720
+ });
721
+ }),
722
+ link: Effect.fn("FileSystem.link")((from, to) => isolatedTwo(["fs:read", from], ["fs:write", to], "link", (host) => host.link(normalize(from), normalize(to)))),
723
+ makeDirectory: Effect.fn("FileSystem.makeDirectory")((value, options) => {
724
+ const captured = snapshotOptions(options);
725
+ return atomicOne("fs:write", value, "makeDirectory", {
726
+ operation: "makeDirectory",
727
+ path: normalize(value),
728
+ options: captured
729
+ });
730
+ }),
731
+ makeTempDirectory: Effect.fn("FileSystem.makeTempDirectory")((options) => {
732
+ const captured = snapshotOptions(options);
733
+ return (atomic?.isolated === undefined
734
+ ? atomicUnavailable("fs:write", captured?.directory ?? `../${systemTemporaryDirectoryName}`, "makeTempDirectory")
735
+ : temp(captured?.directory).pipe(Effect.andThen(atomic.isolated.makeTempDirectory(normalizeTempOptions(captured)))));
736
+ }),
737
+ makeTempDirectoryScoped: Effect.fn("FileSystem.makeTempDirectoryScoped")((options) => {
738
+ const captured = snapshotOptions(options);
739
+ return (atomic?.isolated === undefined
740
+ ? atomicUnavailable("fs:write", captured?.directory ?? `../${systemTemporaryDirectoryName}`, "makeTempDirectoryScoped")
741
+ : temp(captured?.directory).pipe(Effect.andThen(atomic.isolated.makeTempDirectoryScoped(normalizeTempOptions(captured)))));
742
+ }),
743
+ makeTempFile: Effect.fn("FileSystem.makeTempFile")((options) => {
744
+ const captured = snapshotOptions(options);
745
+ return (atomic?.isolated === undefined
746
+ ? atomicUnavailable("fs:write", captured?.directory ?? `../${systemTemporaryDirectoryName}`, "makeTempFile")
747
+ : temp(captured?.directory).pipe(Effect.andThen(atomic.isolated.makeTempFile(normalizeTempOptions(captured)))));
748
+ }),
749
+ makeTempFileScoped: Effect.fn("FileSystem.makeTempFileScoped")((options) => {
750
+ const captured = snapshotOptions(options);
751
+ return (atomic?.isolated === undefined
752
+ ? atomicUnavailable("fs:write", captured?.directory ?? `../${systemTemporaryDirectoryName}`, "makeTempFileScoped")
753
+ : temp(captured?.directory).pipe(Effect.andThen(atomic.isolated.makeTempFileScoped(normalizeTempOptions(captured)))));
754
+ }),
755
+ open: Effect.fn("FileSystem.open")((value, options) => {
756
+ const captured = snapshotOptions(options);
757
+ const flag = captured?.flag ?? "r";
758
+ const isolated = atomic?.isolated;
759
+ return isolated === undefined
760
+ ? atomicUnavailable("fs:read", value, "open")
761
+ : openChecks(value, flag).pipe(Effect.andThen(isolated.open(normalize(value), captured)),
762
+ // fstat the handle the moment it exists: the authorization the
763
+ // open checks granted binds to THIS resource identity, and every
764
+ // later handle operation verifies the authorized path still
765
+ // names it before delegating to the descriptor.
766
+ Effect.flatMap((file) => file.stat.pipe(Effect.map((info) => wrapFile(isolated, file, value, identityOf(info))))));
767
+ }),
768
+ readDirectory: Effect.fn("FileSystem.readDirectory")((value, options) => {
769
+ const captured = snapshotOptions(options);
770
+ return atomicOne("fs:read", value, "readDirectory", {
771
+ operation: "readDirectory",
772
+ path: normalize(value),
773
+ options: captured
774
+ });
775
+ }),
776
+ readFile: Effect.fn("FileSystem.readFile")((value) => atomicOne("fs:read", value, "readFile", {
777
+ operation: "readFile",
778
+ path: normalize(value)
779
+ })),
780
+ readLink: Effect.fn("FileSystem.readLink")((value) => atomicOne("fs:read", value, "readLink", {
781
+ operation: "readLink",
782
+ path: normalize(value)
783
+ })),
784
+ realPath: Effect.fn("FileSystem.realPath")((value) => atomicOne("fs:read", value, "realPath", {
785
+ operation: "realPath",
786
+ path: normalize(value)
787
+ })),
788
+ remove: Effect.fn("FileSystem.remove")((value, options) => {
789
+ const captured = snapshotOptions(options);
790
+ return atomicOne("fs:write", value, "remove", {
791
+ operation: "remove",
792
+ path: normalize(value),
793
+ options: captured
794
+ });
795
+ }),
796
+ rename: Effect.fn("FileSystem.rename")((from, to) => atomicTwo(["fs:write", from], ["fs:write", to], "rename", {
797
+ operation: "rename",
798
+ from: normalize(from),
799
+ to: normalize(to)
800
+ })),
801
+ stat: Effect.fn("FileSystem.stat")((value) => atomicOne("fs:read", value, "stat", {
802
+ operation: "stat",
803
+ path: normalize(value)
804
+ })),
805
+ symlink: Effect.fn("FileSystem.symlink")((from, to) => isolatedOne("fs:write", to, "symlink", (host) => host.symlink(from, normalize(to)))),
806
+ truncate: Effect.fn("FileSystem.truncate")((value, length) => isolatedOne("fs:write", value, "truncate", (host) => host.truncate(normalize(value), length))),
807
+ utimes: Effect.fn("FileSystem.utimes")((value, atime, mtime) => isolatedOne("fs:write", value, "utimes", (host) => host.utimes(normalize(value), atime, mtime))),
808
+ watch: (value) => Stream.unwrap(Effect.fn("FileSystem.watch")(() => Effect.suspend(() => atomic?.isolated === undefined
809
+ ? atomicUnavailable("fs:read", value, "watch")
810
+ : read(value).pipe(Effect.map(() => atomic.isolated.watch(normalize(value))))))()),
811
+ writeFile: Effect.fn("FileSystem.writeFile")((value, data, options) => {
812
+ const captured = snapshotOptions(options);
813
+ const bytes = data.slice();
814
+ // An isolated host shares this address space, so the detached
815
+ // snapshot crosses no serialization boundary: hand it straight to
816
+ // the delegate rather than base64 round-tripping it through the
817
+ // serializable request shape.
818
+ if (atomic?.isolated !== undefined) {
819
+ return isolatedOne("fs:write", value, "writeFile", (host) => host.writeFile(normalize(value), bytes, captured));
820
+ }
821
+ // A serialized executor frames the base64 payload as one JS string.
822
+ // Refuse what its runtime cannot represent before encoding, so an
823
+ // oversized artifact is a typed BadArgument and not a defect out of
824
+ // the encoder.
825
+ const limit = atomic?.contentLimit;
826
+ if (limit !== undefined && bytes.byteLength > limit) {
827
+ return Effect.fail(PlatformError.badArgument({
828
+ module: "FileSystem",
829
+ method: "writeFile",
830
+ description: `writeFile payload of ${bytes.byteLength} bytes exceeds the ${limit} byte limit advertised by the host`
831
+ }));
832
+ }
833
+ return atomicOne("fs:write", value, "writeFile", {
834
+ operation: "writeFile",
835
+ path: normalize(value),
836
+ data: Encoding.encodeBase64(bytes),
837
+ options: captured
838
+ });
839
+ })
840
+ }),
841
+ exists: Effect.fn("FileSystem.exists")((value) => atomicOne("fs:read", value, "exists", {
842
+ operation: "exists",
843
+ path: normalize(value)
844
+ })),
845
+ readFileString: Effect.fn("FileSystem.readFileString")((value, encoding) => atomicOne("fs:read", value, "readFileString", {
846
+ operation: "readFileString",
847
+ path: normalize(value),
848
+ encoding
849
+ })),
850
+ sink: (value, options) => {
851
+ const captured = snapshotOptions(options);
852
+ return (Sink.unwrap(Effect.fn("FileSystem.sink")(() => Effect.suspend(() => atomic?.isolated === undefined
853
+ ? atomicUnavailable("fs:write", value, "sink")
854
+ : write(value).pipe(Effect.map(() => atomic.isolated.sink(normalize(value), captured)))))()));
855
+ },
856
+ stream: (value, options) => {
857
+ const captured = snapshotOptions(options);
858
+ return (Stream.unwrap(Effect.fn("FileSystem.stream")(() => Effect.suspend(() => atomic?.isolated === undefined
859
+ ? atomicUnavailable("fs:read", value, "stream")
860
+ : read(value).pipe(Effect.map(() => atomic.isolated.stream(normalize(value), captured)))))()));
861
+ },
862
+ writeFileString: Effect.fn("FileSystem.writeFileString")((value, data, options) => {
863
+ const captured = snapshotOptions(options);
864
+ return atomicOne("fs:write", value, "writeFileString", {
865
+ operation: "writeFileString",
866
+ path: normalize(value),
867
+ data,
868
+ options: captured
869
+ });
870
+ })
871
+ };
872
+ // Every path operation of the guarded surface is already one
873
+ // descriptor-relative request against this root (or refuses), so machinery
874
+ // composed over it may use it as its confined view.
875
+ Object.assign(guarded, { [ConfinedFileSystemTypeId]: logicalRoot });
876
+ if (atomic === undefined || atomic.batchLimits === undefined) {
877
+ return guarded;
878
+ }
879
+ else {
880
+ const limits = atomic.batchLimits;
881
+ const executeBatch = Effect.fn("FileSystem.batch")(function* (requests) {
882
+ if (requests.length === 0 || requests.length > Math.min(limits.size, Batch.maxBatchSize)) {
883
+ return yield* Effect.fail(PlatformError.badArgument({
884
+ module: "FileSystem",
885
+ method: "batch",
886
+ description: `batch must contain 1 to ${Math.min(limits.size, Batch.maxBatchSize)} operations`
887
+ }));
888
+ }
889
+ if (Option.isNone(rootIdentity)) {
890
+ return yield* atomicUnavailable("fs:read", logicalRoot, "batch");
891
+ }
892
+ // Snapshot before any asynchronous root or permission checks.
893
+ const captured = snapshotOptions(requests).map((request) => ({
894
+ ...request,
895
+ path: request.operation === "glob"
896
+ ? normalizeFrom(normalize(request.root), request.path)
897
+ : normalize(request.path),
898
+ ...(request.operation === "glob" ? { root: normalize(request.root) } : {})
899
+ }));
900
+ const rootChanged = (cause) => PlatformError.systemError({
901
+ _tag: "Busy",
902
+ module: "FileSystem",
903
+ method: "batch",
904
+ pathOrDescriptor: logicalRoot,
905
+ description: "batch root no longer names the authorized descriptor",
906
+ cause
907
+ });
908
+ // A vanished workspace is a lost boundary, not N absent files. Check
909
+ // before canonical resource resolution could turn it into per-path
910
+ // NotFound results. Check the logical path as well: a workspace alias
911
+ // may be retargeted while its old canonical directory still exists.
912
+ // The helper separately checks the canonical root through no-follow
913
+ // handles, binding both names to the same captured descriptor.
914
+ const expectedRoot = rootIdentity.value;
915
+ const verifyRoot = () => identifyRoot(atomic, fileSystem, logicalRoot).pipe(Effect.mapError(rootChanged), Effect.flatMap((current) => Option.isSome(current) && current.value === expectedRoot
916
+ ? Effect.void
917
+ : Effect.fail(rootChanged(new FileSystemFault({
918
+ code: "workspace_identity_changed",
919
+ message: "workspace descriptor identity changed"
920
+ })))));
921
+ yield* verifyRoot();
922
+ // Resolve in bounded groups while retaining request-order grant
923
+ // decisions. Quota/once grants therefore retain their ordering, and
924
+ // native metadata latency need not serialize every member twice.
925
+ const resources = yield* resolvedResources(captured);
926
+ const admitted = [];
927
+ const indexes = [];
928
+ const entries = [];
929
+ const granted = [];
930
+ for (const [index, request] of captured.entries()) {
931
+ const resource = resources[index];
932
+ const checked = Result.isFailure(resource) ? Result.fail(resource.failure) : yield* Effect.result(makeCapability("fs:read", resource.success).pipe(Effect.flatMap((capability) => grants.check(capability)), Effect.mapError(refuse("read", resource.success))));
933
+ if (Result.isFailure(checked)) {
934
+ entries.push({ index, path: request.path, result: Result.fail(checked.failure) });
935
+ }
936
+ else
937
+ granted.push({ request, index, resource: Result.getOrThrow(resource) });
938
+ }
939
+ const current = granted.length === 0
940
+ ? []
941
+ : yield* resolvedResources(granted.map(({ request }) => request));
942
+ const settled = yield* Effect.forEach(granted, ({ resource }, position) => {
943
+ const now = current[position];
944
+ return Effect.result(Result.isFailure(now)
945
+ ? Effect.fail(now.failure)
946
+ : now.success === resource
947
+ ? Effect.void
948
+ : deny("fs:read", "read", resource, "path no longer names the resource that was authorized"));
949
+ });
950
+ for (const [position, member] of granted.entries()) {
951
+ const checked = settled[position];
952
+ if (Result.isFailure(checked)) {
953
+ entries.push({ index: member.index, path: member.request.path, result: Result.fail(checked.failure) });
954
+ }
955
+ else {
956
+ admitted.push(member.request);
957
+ indexes.push(member.index);
958
+ }
959
+ }
960
+ yield* verifyRoot();
961
+ if (admitted.length > 0) {
962
+ const measured = yield* atomic.execute({
963
+ operation: "batch",
964
+ boundaryRoot,
965
+ logicalRoot,
966
+ rootIdentity: rootIdentity.value,
967
+ requests: admitted
968
+ });
969
+ for (const entry of measured.entries) {
970
+ entries.push({
971
+ index: indexes[entry.index],
972
+ path: entry.path,
973
+ result: publicValue(entry.path, entry.result)
974
+ });
975
+ }
976
+ }
977
+ entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : a.index - b.index);
978
+ return { rootIdentity: rootIdentity.value, entries };
979
+ });
980
+ return Object.assign(guarded, {
981
+ [Batch.FileSystemBatchTypeId]: {
982
+ maxSize: Math.min(limits.size, Batch.maxBatchSize),
983
+ maxResponseBytes: limits.response,
984
+ execute: executeBatch
985
+ }
986
+ });
987
+ }
988
+ }));
989
+ //# sourceMappingURL=FileSystem.js.map