@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,414 @@
1
+ /**
2
+ * Permission-aware process spawning.
3
+ *
4
+ * Smithers has no shell service of its own — process execution is Effect's
5
+ * `effect/unstable/process`. The only thing the kernel adds is the
6
+ * `proc:spawn` capability check and the host confinement wrapper, applied to
7
+ * the one primitive every other helper is derived from.
8
+ *
9
+ * There is no kernel spawner interface and no kernel spawner tag. Effect owns
10
+ * both, and its tag fixes the error channel to `PlatformError`, so this module
11
+ * is only the middleware: a `Layer` over Effect's own tag. A denied command is
12
+ * projected into `PlatformError` by `Permission.toPlatformError`, which keeps
13
+ * the structured kernel failure on the error's `cause`. Because the channel
14
+ * stays `PlatformError`, the full six-method surface is derived from the one
15
+ * guarded `spawn` through Effect's own `ChildProcessSpawner.make` without a
16
+ * cast.
17
+ *
18
+ * Governing design:
19
+ * `docs/specs/Concepts/Permission Kernel.md` and
20
+ * `docs/specs/Concepts/Host Adapters.md`.
21
+ *
22
+ * @since 1.0.0-rc.0
23
+ */
24
+
25
+ import { toPlatformError } from "@smthrs/capability/Permission"
26
+ import { Effect, FileSystem, Layer, Option, Path, type Scope } from "effect"
27
+ import { type PlatformError, systemError } from "effect/PlatformError"
28
+ import * as ChildProcess from "effect/unstable/process/ChildProcess"
29
+ import { ChildProcessSpawner, make as makeSpawner } from "effect/unstable/process/ChildProcessSpawner"
30
+ import * as CommandLine from "./CommandLine.ts"
31
+ import { GrantStore } from "./GrantStore.ts"
32
+ import * as Containment from "./internal/Containment.ts"
33
+ import { makeCapability } from "./internal/makeCapability.ts"
34
+ import * as ProcessConfinement from "./ProcessConfinement.ts"
35
+ import * as Rooted from "./Rooted.ts"
36
+ import { Workspace } from "./Workspace.ts"
37
+
38
+ const data = (value: object, name: PropertyKey): unknown => {
39
+ const descriptor = Object.getOwnPropertyDescriptor(value, name)
40
+ if (descriptor === undefined || !("value" in descriptor)) {
41
+ throw new TypeError(`command ${String(name)} must be a data property`)
42
+ }
43
+ return descriptor.value
44
+ }
45
+
46
+ const optionalData = (value: object, name: PropertyKey): unknown => {
47
+ const descriptor = Object.getOwnPropertyDescriptor(value, name)
48
+ if (descriptor === undefined) return undefined
49
+ if (!("value" in descriptor)) throw new TypeError(`command ${String(name)} must be a data property`)
50
+ return descriptor.value
51
+ }
52
+
53
+ const snapshotRecord = (
54
+ value: Readonly<Record<string, string | undefined>> | undefined
55
+ ): Record<string, string | undefined> | undefined => {
56
+ if (value === undefined) return undefined
57
+ const snapshot: Record<string, string | undefined> = Object.create(null)
58
+ for (const [name, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
59
+ if (!descriptor.enumerable) continue
60
+ if (!("value" in descriptor) || (descriptor.value !== undefined && typeof descriptor.value !== "string")) {
61
+ throw new TypeError("command environment must contain string data properties")
62
+ }
63
+ snapshot[name] = descriptor.value
64
+ }
65
+ return Object.freeze(snapshot)
66
+ }
67
+
68
+ const snapshotStreamConfig = <A>(value: A): A => {
69
+ if (typeof value !== "object" || value === null) return value
70
+ const descriptors = Object.getOwnPropertyDescriptors(value)
71
+ if (descriptors.stream === undefined) return value
72
+ if (!("value" in descriptors.stream)) throw new TypeError("command stream must be a data property")
73
+ const snapshot: Record<string, unknown> = { stream: descriptors.stream.value }
74
+ for (const name of ["endOnDone", "encoding"] as const) {
75
+ const descriptor = descriptors[name]
76
+ if (descriptor !== undefined) {
77
+ if (!("value" in descriptor)) throw new TypeError(`command ${name} must be a data property`)
78
+ snapshot[name] = descriptor.value
79
+ }
80
+ }
81
+ return Object.freeze(snapshot) as A
82
+ }
83
+
84
+ const snapshotAdditionalFds = (
85
+ value: ChildProcess.CommandOptions["additionalFds"]
86
+ ): ChildProcess.CommandOptions["additionalFds"] => {
87
+ if (value === undefined) return undefined
88
+ const snapshot: Record<`fd${number}`, ChildProcess.AdditionalFdConfig> = {}
89
+ for (const [name, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
90
+ if (!descriptor.enumerable) continue
91
+ if (!("value" in descriptor) || typeof descriptor.value !== "object" || descriptor.value === null) {
92
+ throw new TypeError("additional file descriptors must be data records")
93
+ }
94
+ const config = descriptor.value as ChildProcess.AdditionalFdConfig
95
+ const type = data(config, "type")
96
+ if (type === "input") {
97
+ const stream = Object.getOwnPropertyDescriptor(config, "stream")
98
+ snapshot[name as `fd${number}`] = Object.freeze({
99
+ type,
100
+ ...(stream === undefined ? {} : "value" in stream ? { stream: stream.value } : (() => {
101
+ throw new TypeError("additional fd stream must be a data property")
102
+ })())
103
+ })
104
+ } else if (type === "output") {
105
+ const sink = Object.getOwnPropertyDescriptor(config, "sink")
106
+ snapshot[name as `fd${number}`] = Object.freeze({
107
+ type,
108
+ ...(sink === undefined ? {} : "value" in sink ? { sink: sink.value } : (() => {
109
+ throw new TypeError("additional fd sink must be a data property")
110
+ })())
111
+ })
112
+ } else {
113
+ throw new TypeError("additional file descriptor type is invalid")
114
+ }
115
+ }
116
+ return Object.freeze(snapshot)
117
+ }
118
+
119
+ const snapshotOptions = (options: ChildProcess.CommandOptions): ChildProcess.CommandOptions =>
120
+ Object.freeze({
121
+ killSignal: options.killSignal,
122
+ forceKillAfter: options.forceKillAfter,
123
+ cwd: options.cwd,
124
+ env: snapshotRecord(options.env),
125
+ extendEnv: options.extendEnv,
126
+ shell: options.shell,
127
+ detached: options.detached,
128
+ windowsHide: options.windowsHide,
129
+ stdin: snapshotStreamConfig(options.stdin),
130
+ stdout: snapshotStreamConfig(options.stdout),
131
+ stderr: snapshotStreamConfig(options.stderr),
132
+ additionalFds: snapshotAdditionalFds(options.additionalFds)
133
+ })
134
+
135
+ const snapshotCommand = (command: ChildProcess.Command): ChildProcess.Command => {
136
+ const tag = data(command, "_tag")
137
+ if (tag === "StandardCommand") {
138
+ const executable = data(command, "command")
139
+ const args = data(command, "args")
140
+ const options = data(command, "options")
141
+ if (typeof executable !== "string" || !Array.isArray(args) || !args.every((arg) => typeof arg === "string")) {
142
+ throw new TypeError("standard command executable and arguments are invalid")
143
+ }
144
+ if (typeof options !== "object" || options === null) throw new TypeError("command options are invalid")
145
+ return ChildProcess.make(
146
+ executable,
147
+ Object.freeze([...args]),
148
+ snapshotOptions(options)
149
+ )
150
+ }
151
+ if (tag === "PipedCommand") {
152
+ const left = data(command, "left")
153
+ const right = data(command, "right")
154
+ const options = data(command, "options")
155
+ if (typeof left !== "object" || left === null || typeof right !== "object" || right === null) {
156
+ throw new TypeError("pipeline commands are invalid")
157
+ }
158
+ if (typeof options !== "object" || options === null) throw new TypeError("pipeline options are invalid")
159
+ const from = optionalData(options, "from")
160
+ const to = optionalData(options, "to")
161
+ return ChildProcess.pipeTo(
162
+ snapshotCommand(right as ChildProcess.Command),
163
+ Object.freeze({
164
+ ...(from === undefined ? {} : { from: from as ChildProcess.PipeFromOption }),
165
+ ...(to === undefined ? {} : { to: to as ChildProcess.PipeToOption })
166
+ })
167
+ )(snapshotCommand(left as ChildProcess.Command))
168
+ }
169
+ throw new TypeError("unsupported command shape")
170
+ }
171
+
172
+ const maximumEnvironmentNames = 64
173
+
174
+ /**
175
+ * The environment this process holds, which a child built with
176
+ * `ChildProcessEnvironment.make` inherits its bootstrap names from. Empty on a
177
+ * host with no process environment, so every declared bootstrap name counts
178
+ * as an override there.
179
+ */
180
+ const ambientEnvironment = (): Readonly<Record<string, string | undefined>> =>
181
+ (globalThis as { readonly process?: { readonly env?: Record<string, string | undefined> } }).process?.env ?? {}
182
+
183
+ const environmentNames = (command: ChildProcess.StandardCommand): ReadonlyArray<string> | undefined => {
184
+ const environment = command.options.env
185
+ if (environment === undefined) return undefined
186
+ const names = Object.entries(environment)
187
+ .filter(([, value]) => value !== undefined)
188
+ .map(([name]) => name)
189
+ .sort()
190
+ if (names.length === 0) return undefined
191
+ return names.length <= maximumEnvironmentNames
192
+ ? names
193
+ : [...names.slice(0, maximumEnvironmentNames), `+${names.length - maximumEnvironmentNames} more`]
194
+ }
195
+
196
+ /**
197
+ * Effect's own process spawner tag, unchanged. Re-exported so the kernel
198
+ * namespace stays one-stop; it is the *same* tag, never a second one.
199
+ *
200
+ * @category services
201
+ * @since 1.0.0-rc.0
202
+ */
203
+ export { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
204
+
205
+ /**
206
+ * Derives the full six-method surface from one `spawn`, so `exitCode`,
207
+ * `string`, `lines`, and both `stream*` helpers can never bypass whatever
208
+ * `spawn` was given.
209
+ *
210
+ * @category constructors
211
+ * @since 1.0.0-rc.0
212
+ */
213
+ export { make } from "effect/unstable/process/ChildProcessSpawner"
214
+
215
+ /**
216
+ * Constructs an unavailable spawner stub.
217
+ *
218
+ * Every derived helper reports the missing host as a `NotFound`
219
+ * `PlatformError` naming the command line, so an unconfigured capability
220
+ * answers rather than vanishing.
221
+ *
222
+ * @category constructors
223
+ * @since 1.0.0-rc.0
224
+ */
225
+ export const makeNoop = (
226
+ overrides: Partial<ChildProcessSpawner["Service"]> = {}
227
+ ): ChildProcessSpawner["Service"] => ({
228
+ ...makeSpawner((command) =>
229
+ Effect.fail(
230
+ systemError({
231
+ _tag: "NotFound",
232
+ module: "ChildProcessSpawner",
233
+ method: "spawn",
234
+ description: `no process host for \`${CommandLine.render(command)}\``
235
+ })
236
+ )
237
+ ),
238
+ ...overrides
239
+ })
240
+
241
+ /**
242
+ * Provides an unavailable spawner stub.
243
+ *
244
+ * @category layers
245
+ * @since 1.0.0-rc.0
246
+ */
247
+ export const layerNoop = (
248
+ overrides: Partial<ChildProcessSpawner["Service"]> = {}
249
+ ): Layer.Layer<ChildProcessSpawner> => Layer.succeed(ChildProcessSpawner)(makeNoop(overrides))
250
+
251
+ /**
252
+ * Decorates the process spawner in place with a `proc:spawn` capability check.
253
+ *
254
+ * The check is suspended into the spawn itself, so building a `Command` or a
255
+ * stream neither requests permission nor starts a process; only running one
256
+ * does. Every pipeline stage is checked as its own capability, left to right,
257
+ * and no stage starts until all pass. A stage's resource is
258
+ * `CommandLine.resource`: the command line `CommandLine.render` produces, with
259
+ * a `shell: true` line that holds shell control syntax marked as
260
+ * `sh -c '<line>'`, any environment variable the child would not inherit from
261
+ * this process named in an `env <NAME>… -- ` prefix, and a working directory
262
+ * outside `Workspace.root` named in a `cwd <path> -- ` prefix. Custom shell
263
+ * paths are included explicitly in the resource; browser and remote adapters
264
+ * reject them rather than silently substituting a different shell.
265
+ *
266
+ * Every approved stage requires `ProcessConfinement` in the host context.
267
+ * A missing service refuses spawning; its `layerNoop` is the explicit choice
268
+ * for an unconfined host.
269
+ *
270
+ * Pipeline `from`/`to` routing remains outside the grant. The working
271
+ * directory and overridden environment names also reach attended surfaces as
272
+ * display metadata; environment values never leave the command.
273
+ *
274
+ * The layer provides the tag it also requires: compose it over a host spawner
275
+ * layer with `Layer.provide` and a `ChildProcess.Command` run as a plain
276
+ * `Effect` is checked too.
277
+ *
278
+ * A command with no `cwd`, or a relative one, runs in `Workspace.root`, never
279
+ * the process's own directory; the check sees that resolved directory. When
280
+ * the host filesystem is in the layer's context, the working directory and
281
+ * `Workspace.root` are compared by real path, so a symlink inside the
282
+ * workspace that leads out of it is named, as its target, in the `cwd` prefix.
283
+ *
284
+ * @category layers
285
+ * @since 1.0.0-rc.0
286
+ */
287
+ export const layer: Layer.Layer<
288
+ ChildProcessSpawner,
289
+ never,
290
+ ChildProcessSpawner | GrantStore | Path.Path | Workspace
291
+ > = Layer.effect(
292
+ ChildProcessSpawner,
293
+ Effect.gen(function*() {
294
+ const spawner = yield* ChildProcessSpawner
295
+ const grants = yield* GrantStore
296
+ const confinement = yield* Effect.serviceOption(ProcessConfinement.ProcessConfinement)
297
+ const path = yield* Path.Path
298
+ const rooted = Rooted.path(path, path.resolve((yield* Workspace).root))
299
+ const root = rooted.resolve(".")
300
+ // The raw host filesystem this layer is composed over, when there is one.
301
+ // A symlink inside the workspace can lead out of it, so containment is
302
+ // judged on real paths; a directory that cannot be resolved (it does not
303
+ // exist, or the host has no filesystem) is judged lexically.
304
+ const fileSystem = yield* Effect.serviceOption(FileSystem.FileSystem)
305
+ const real = (directory: string) =>
306
+ Option.match(fileSystem, {
307
+ onNone: () => Effect.succeed(directory),
308
+ onSome: (fs) => fs.realPath(directory).pipe(Effect.orElseSucceed(() => directory))
309
+ })
310
+ const within = (base: string) => (directory: string) => {
311
+ const relative = path.relative(base, directory)
312
+ return relative.split(path.sep)[0] !== ".." && !path.isAbsolute(relative)
313
+ }
314
+ const checkStage = (stage: ChildProcess.StandardCommand) =>
315
+ Effect.gen(function*() {
316
+ // Rooted.command gives every stage an absolute cwd.
317
+ const cwd = stage.options.cwd!
318
+ const realRoot = yield* real(root)
319
+ const resolvedCwd = yield* real(cwd)
320
+ const contains = resolvedCwd === cwd
321
+ ? (directory: string) => within(root)(directory) || within(realRoot)(directory)
322
+ : within(realRoot)
323
+ return yield* checkResolved(
324
+ stage,
325
+ CommandLine.resource(stage, {
326
+ ambient: ambientEnvironment(),
327
+ contains,
328
+ resolvedCwd
329
+ })
330
+ )
331
+ })
332
+ const checkResolved = (stage: ChildProcess.StandardCommand, rendered: string) => {
333
+ const environment = environmentNames(stage)
334
+ return makeCapability("proc:spawn", rendered).pipe(
335
+ Effect.flatMap((capability) =>
336
+ grants.check(capability, {
337
+ cwd: stage.options.cwd,
338
+ ...(environment === undefined ? {} : { env: environment })
339
+ })
340
+ ),
341
+ Effect.mapError((error) =>
342
+ toPlatformError({
343
+ module: "ChildProcessSpawner",
344
+ method: "spawn",
345
+ pathOrDescriptor: rendered,
346
+ error
347
+ })
348
+ )
349
+ )
350
+ }
351
+ const check = (command: ChildProcess.Command) =>
352
+ Effect.forEach(CommandLine.stages(command), checkStage, { discard: true })
353
+ const confine = (
354
+ command: ChildProcess.Command
355
+ ): Effect.Effect<
356
+ ChildProcess.Command,
357
+ PlatformError,
358
+ Scope.Scope
359
+ > =>
360
+ Option.match(confinement, {
361
+ onNone: () =>
362
+ Effect.fail(systemError({
363
+ _tag: "NotFound",
364
+ module: "ProcessConfinement",
365
+ method: "confine",
366
+ description:
367
+ "process confinement is required; use ProcessConfinement.layerNoop to explicitly run unconfined"
368
+ })),
369
+ onSome: (service) =>
370
+ Effect.gen(function*() {
371
+ const profile = yield* ProcessConfinement.profile(grants, root, path).pipe(
372
+ Effect.mapError((error) => toPlatformError({ module: "ChildProcessSpawner", method: "spawn", error }))
373
+ )
374
+ const wrap = (
375
+ command: ChildProcess.Command
376
+ ): Effect.Effect<
377
+ ChildProcess.Command,
378
+ PlatformError,
379
+ Scope.Scope
380
+ > =>
381
+ command._tag === "StandardCommand"
382
+ ? service.confine(command, profile)
383
+ : Effect.gen(function*() {
384
+ const left = yield* wrap(command.left)
385
+ const right = yield* wrap(command.right)
386
+ return ChildProcess.pipeTo(right, command.options)(left)
387
+ })
388
+ return yield* wrap(command)
389
+ })
390
+ })
391
+ return Containment.inherit(
392
+ spawner,
393
+ makeSpawner(
394
+ Effect.fn("ChildProcessSpawner.spawn")((command: ChildProcess.Command) =>
395
+ Effect.try({
396
+ try: () => snapshotCommand(command),
397
+ catch: () =>
398
+ systemError({
399
+ _tag: "InvalidData",
400
+ module: "ChildProcessSpawner",
401
+ method: "spawn",
402
+ description: "command must be an immutable supported process description"
403
+ })
404
+ }).pipe(
405
+ Effect.map((snapshot) => Rooted.command(snapshot, rooted)),
406
+ Effect.flatMap((rooted) =>
407
+ check(rooted).pipe(Effect.andThen(confine(rooted)), Effect.flatMap(spawner.spawn))
408
+ )
409
+ )
410
+ )
411
+ )
412
+ )
413
+ })
414
+ )
@@ -0,0 +1,250 @@
1
+ /**
2
+ * @since 1.0.0-rc.0
3
+ *
4
+ * Rendering an `effect/unstable/process` `Command` back to a shell command
5
+ * line.
6
+ *
7
+ * Two callers need the same string and must agree on it exactly:
8
+ *
9
+ * 1. `@smthrs/kernel/ChildProcessSpawner` uses it as the `proc:spawn`
10
+ * capability resource, so a grant reads the way an operator wrote it; and
11
+ * 2. `@smthrs/platform-browser/BrowserChildProcessSpawner` uses it as the line
12
+ * it hands to the in-browser bash interpreter, which has no `argv` to spawn
13
+ * with.
14
+ *
15
+ * A single renderer keeps a granted capability and the command a browser
16
+ * actually runs from drifting apart.
17
+ *
18
+ * The module is pure string handling — no host access — so it stays on the
19
+ * browser-safe side of the package.
20
+ */
21
+
22
+ import type * as ChildProcess from "effect/unstable/process/ChildProcess"
23
+ import { isInheritedName } from "./ChildProcessEnvironment.ts"
24
+
25
+ /** Tokens made only of these characters need no quoting in a POSIX shell. */
26
+ const SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/
27
+
28
+ /**
29
+ * Quotes one token for a POSIX shell, leaving obviously safe tokens alone.
30
+ *
31
+ * @category rendering
32
+ * @since 1.0.0-rc.0
33
+ */
34
+ export const quote = (token: string): string =>
35
+ token !== "" && SAFE.test(token) ? token : `'${token.replaceAll("'", `'\\''`)}'`
36
+
37
+ /**
38
+ * Renders a `Command` as a single shell command line.
39
+ *
40
+ * A standard command with `shell: true` renders its tokens verbatim, matching
41
+ * the line Node and the browser interpreter hand to the default shell. A
42
+ * custom shell renders as an explicit `<shell> -c <line>` invocation so the
43
+ * selected executable is part of the permission resource. Without `shell`,
44
+ * every token is POSIX-quoted to preserve literal argv semantics. These
45
+ * distinctions are security-sensitive: the rendered value is also the
46
+ * `proc:spawn` capability resource, so it must describe what will execute.
47
+ * The rendering is POSIX-only by contract: on Windows, Node invokes the shell
48
+ * with `/d /s /c` rather than `-c`, so the rendered line describes the POSIX
49
+ * invocation, not a cmd.exe one.
50
+ *
51
+ * The `proc:spawn` capability resource is {@link resource}, which starts from
52
+ * this line and adds what the line alone cannot show.
53
+ *
54
+ * A `PipedCommand` renders with `|` between its sides. That is a faithful
55
+ * rendering of what the pipeline does, and it is the only form an interpreter
56
+ * that takes a command line rather than an `argv` can be given. `from`/`to`
57
+ * pipe options are *not* expressible this way; rendering ignores them, so a
58
+ * pipeline that redirects `stderr` renders like one that pipes `stdout`.
59
+ * Capability checks therefore see the commands, never the plumbing.
60
+ *
61
+ * @category rendering
62
+ * @since 1.0.0-rc.0
63
+ */
64
+ export const render = (command: ChildProcess.Command): string =>
65
+ command._tag === "StandardCommand"
66
+ ? command.options.shell === undefined || command.options.shell === false
67
+ ? [command.command, ...command.args].map(quote).join(" ")
68
+ : command.options.shell === true
69
+ ? [command.command, ...command.args].join(" ")
70
+ : `${quote(command.options.shell)} -c ${quote([command.command, ...command.args].join(" "))}`
71
+ : `${render(command.left)} | ${render(command.right)}`
72
+
73
+ /**
74
+ * Shell syntax that chains, substitutes, groups, or redirects: everything that
75
+ * lets one shell line run or write more than a single simple command.
76
+ */
77
+ const shellControl = /[;&|`$<>()\n\r]/
78
+
79
+ /**
80
+ * Redirections that neither run nor write anything a grant should see: an fd
81
+ * duplication such as `2>&1` and a discard to `/dev/null`. They are removed
82
+ * before {@link shellControl} is tested, so `git status 2>&1` keeps its
83
+ * verbatim resource. Only a space or tab may sit between `>` and `/dev/null`:
84
+ * `>` followed by a line break is not a discard, so it stays control syntax.
85
+ */
86
+ const harmlessRedirect = /(^|\s)(?:\d*>&\d+|\d*>[ \t]*\/dev\/null)(?=\s|$)/g
87
+
88
+ /**
89
+ * What {@link resource} compares a stage against, supplied by the spawner that
90
+ * checks it.
91
+ *
92
+ * @category models
93
+ * @since 1.0.0-rc.1
94
+ */
95
+ export interface ResourceContext {
96
+ /**
97
+ * The environment the spawning process itself holds. A bootstrap name
98
+ * (`PATH`, `HOME`, ...) whose declared value equals the ambient value is the
99
+ * inherited default and stays out of the resource; one set to anything else
100
+ * is named. Omitted, every declared bootstrap name is named.
101
+ */
102
+ readonly ambient?: Readonly<Record<string, string | undefined>> | undefined
103
+ /**
104
+ * Whether a stage's working directory is inside the directory the flow
105
+ * already governs. A `cwd` it rejects is named in the resource. Omitted,
106
+ * every `cwd` a stage sets is named.
107
+ */
108
+ readonly contains?: ((directory: string) => boolean) | undefined
109
+ /**
110
+ * The stage `cwd` with every symlink resolved, when the host could resolve
111
+ * it. It replaces the lexical `cwd` both for `contains` and in the
112
+ * `cwd <path> -- ` prefix, so a symlink inside the governed directory that
113
+ * leads out of it is judged, and shown, by where the command really runs.
114
+ */
115
+ readonly resolvedCwd?: string | undefined
116
+ }
117
+
118
+ /**
119
+ * Names of the environment variables a stage sets that the child would not
120
+ * otherwise inherit: every name outside the bootstrap set, and a bootstrap
121
+ * name whose value differs from the ambient one.
122
+ */
123
+ const overriddenNames = (
124
+ command: ChildProcess.StandardCommand,
125
+ ambient: Readonly<Record<string, string | undefined>>
126
+ ): ReadonlyArray<string> =>
127
+ Object.entries(command.options.env ?? {})
128
+ .filter(([name, value]) => value !== undefined && (!isInheritedName(name) || ambient[name] !== value))
129
+ .map(([name]) => name)
130
+ .sort()
131
+
132
+ /**
133
+ * The stages of a command in execution order: the command itself, or every
134
+ * `StandardCommand` of a pipeline from left to right. The spawner checks each
135
+ * stage as its own `proc:spawn` capability.
136
+ *
137
+ * @category rendering
138
+ * @since 1.0.0-rc.1
139
+ */
140
+ export const stages = (command: ChildProcess.Command): ReadonlyArray<ChildProcess.StandardCommand> =>
141
+ command._tag === "StandardCommand" ? [command] : [...stages(command.left), ...stages(command.right)]
142
+
143
+ /**
144
+ * The `proc:spawn` capability resource for one stage: {@link render}, with
145
+ * three additions that keep a prefix grant from authorizing code the stage
146
+ * never named.
147
+ *
148
+ * - A `shell: true` stage whose line holds shell control syntax (`;`, `&`,
149
+ * `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`, or a line break) is marked as the
150
+ * explicit `sh -c '<line>'` it is. A grant such as `git status *` then
151
+ * cannot match `git status; curl x | sh`, while a simple shell line such as
152
+ * `git status --short` keeps its verbatim resource. An fd duplication such
153
+ * as `2>&1` and a discard to `/dev/null` do not count as control syntax.
154
+ * - A stage that sets an environment variable the child would not otherwise
155
+ * inherit is prefixed with `env <NAME>… -- `, sorted names only. That is
156
+ * every name outside the bootstrap set `ChildProcessEnvironment` inherits
157
+ * (`PATH`, `HOME`, `USER`, `LANG`, `TERM`, `TMPDIR`, `SHELL`, `LC_*`), and a
158
+ * bootstrap name whose value differs from `context.ambient`. A grant for
159
+ * `git status` covers neither `env GIT_SSH_COMMAND -- git status` nor
160
+ * `env PATH -- git status`. Values never enter the resource because they
161
+ * often carry credentials.
162
+ * - A stage whose `cwd` `context.contains` rejects is prefixed with
163
+ * `cwd <path> -- `, so a grant does not follow the command into a
164
+ * directory whose configuration the flow does not govern. When
165
+ * `context.resolvedCwd` is given, that real path is judged and named
166
+ * instead of the lexical `cwd`.
167
+ *
168
+ * A pipeline has no single resource: the spawner checks every one of its
169
+ * {@link stages}, so `git status | sh` needs a grant for `sh` too.
170
+ *
171
+ * The `env` and `cwd` prefixes share their spelling with a literal command:
172
+ * `make("env", ["PATH", "--", "git", "status"])` renders as
173
+ * `env PATH -- git status`, the resource of `git status` with `PATH`
174
+ * overridden. The alias grants nothing extra. The literal command runs a
175
+ * program named `PATH` (or `cwd`) from the ambient `PATH`, not `git`. A grant
176
+ * with `*` inside the prefix, such as `env * -- git status`, also matches
177
+ * `env -C /x -- git status`, but it already admits any variable name,
178
+ * including `GIT_SSH_COMMAND` and `LD_PRELOAD`, which run arbitrary code.
179
+ * Grant a prefixed resource exactly as the request shows it.
180
+ *
181
+ * @category rendering
182
+ * @since 1.0.0-rc.1
183
+ */
184
+ export const resource = (command: ChildProcess.StandardCommand, context: ResourceContext = {}): string => {
185
+ const line = render(command)
186
+ const shown = command.options.shell === true && shellControl.test(line.replace(harmlessRedirect, "$1"))
187
+ ? `sh -c ${quote(line)}`
188
+ : line
189
+ const names = overriddenNames(command, context.ambient ?? {})
190
+ const withEnv = names.length === 0 ? shown : `env ${names.map(quote).join(" ")} -- ${shown}`
191
+ const directory = command.options.cwd === undefined ? undefined : context.resolvedCwd ?? command.options.cwd
192
+ return directory === undefined || context.contains?.(directory) === true
193
+ ? withEnv
194
+ : `cwd ${quote(directory)} -- ${withEnv}`
195
+ }
196
+
197
+ /** The leading token of a shell line, which is the program the line runs. */
198
+ const program = (line: string): string => {
199
+ const trimmed = line.trimStart()
200
+ const end = trimmed.search(/\s/)
201
+ return end === -1 ? trimmed : trimmed.slice(0, end)
202
+ }
203
+
204
+ /**
205
+ * The executable a command runs, without its arguments.
206
+ *
207
+ * A durable record of a spawned process names its program with this rather
208
+ * than with {@link render}: arguments carry credentials (`curl -u user:pass`,
209
+ * `mysql -phunter2`), and a journal that keeps them keeps them permanently.
210
+ * Nothing that reads such a record needs more than the program name; a reaper
211
+ * matches processes by pid and process group.
212
+ *
213
+ * Without `shell`, `command` is the executable itself, spaces and all. With a
214
+ * shell, the line is what runs, so the program is its first token: both
215
+ * `make("mysql -phunter2", [], { shell: true })` and
216
+ * `make("mysql", ["-phunter2"])` yield `mysql`. A pipeline names one
217
+ * executable per stage, joined the way {@link render} joins the stages.
218
+ *
219
+ * @category rendering
220
+ * @since 1.0.0-rc.0
221
+ */
222
+ export const executable = (command: ChildProcess.Command): string =>
223
+ command._tag === "StandardCommand"
224
+ ? command.options.shell === undefined || command.options.shell === false
225
+ ? command.command
226
+ : program(command.command)
227
+ : `${executable(command.left)} | ${executable(command.right)}`
228
+
229
+ /**
230
+ * The working directory a command runs in, taking the leftmost stage of a
231
+ * pipeline, which is the stage `setCwd` and the spawners agree to treat as the
232
+ * pipeline's own directory.
233
+ *
234
+ * @category rendering
235
+ * @since 1.0.0-rc.0
236
+ */
237
+ export const cwd = (command: ChildProcess.Command): string | undefined =>
238
+ command._tag === "StandardCommand" ? command.options.cwd : cwd(command.left)
239
+
240
+ /**
241
+ * The environment overrides a command runs with, taking the leftmost stage of
242
+ * a pipeline for the same reason {@link cwd} does.
243
+ *
244
+ * @category rendering
245
+ * @since 1.0.0-rc.0
246
+ */
247
+ export const env = (
248
+ command: ChildProcess.Command
249
+ ): Record<string, string | undefined> | undefined =>
250
+ command._tag === "StandardCommand" ? command.options.env : env(command.left)