@geonosis/rails 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # @geonosis/rails
2
+
3
+ Sandbox profiles for an unattended run, as **data rendered to text** — a macOS `sandbox-exec`
4
+ profile or a Linux `bwrap` argv — that **fail open and loud** when they cannot be applied, plus the
5
+ after-the-fact proof that the scored run never wrote the scoreboard.
6
+
7
+ ```bash
8
+ npx geonosis-rails profile --allow-write src # print the seatbelt profile
9
+ npx geonosis-rails profile --format bwrap # print the bubblewrap argv
10
+ npx geonosis-rails contain --allow-write . 'pnpm test' # run it inside the profile
11
+ npx geonosis-rails shift --goal /goal --cycles 8 # the unattended shift
12
+ ```
13
+
14
+ ## The two halves
15
+
16
+ **The profile is enforcement.** The kernel refuses the write.
17
+
18
+ **The hash check is evidence.** Taken before the run and again after, so a run that went
19
+ *uncontained* — because the binary was missing, because the profile could not be written — still
20
+ cannot get away with having rewritten the baseline. The hashes say it did.
21
+
22
+ You need both, because the first one is allowed to fail.
23
+
24
+ ## Fail open, and loud
25
+
26
+ ```
27
+ ############################################################
28
+ # UNSANDBOXED RUN — the profile could not be applied. #
29
+ ############################################################
30
+ ```
31
+
32
+ 2ndm1nd's words for the same decision, in its own runner: *"a missing profile means unsandboxed
33
+ instruments, not a blind cycle. Failing closed here would trade a contained risk for a certain
34
+ outage. It logs loudly instead, because a silent fallback is how rails rot."*
35
+
36
+ **Loudly** is doing the work in that sentence. The shell version this is modelled on logs to a file
37
+ nobody reads at 3 a.m., which is silent in every way that matters. So `runContained` prints the
38
+ banner **and** returns a `marker` — `unsandboxedRuns`, non-zero — that travels with the run into the
39
+ gate report, where the ratchet can refuse to let it grow.
40
+
41
+ Two things get the same treatment, because they are the same event: the binary is not on PATH, and
42
+ the profile cannot be written. A rail that threw on the second would stop the run for a reason that
43
+ has nothing to do with the work.
44
+
45
+ ## What is denied, always
46
+
47
+ ```ts
48
+ RAILS_DENY_WRITE = ['gate-baseline.json']
49
+ ```
50
+
51
+ On every profile whether it was asked for or not. Only the baseline, and that is **narrower** than
52
+ what the Stop hook refuses at the tool layer, on purpose: the kernel cannot tell the agent from the
53
+ hook and the `geonosis-verify` it spawned, and `.geonosis/gate-report.json` and `proofs/` are what
54
+ those write *during* the run — the very files `@geonosis/evals` scores it from. The first version
55
+ denied them too; every contained run then read as a red gate with zero proofs, and the rail read as
56
+ holding. Add a path with `--deny-write` when a repo has a second scoreboard. The scored agent never writes the scoreboard — the
57
+ rail 2ndm1nd added after probing that its sandboxed cycle could `CREATE TABLE` in the very ledger it
58
+ was scored on.
59
+
60
+ Held in three places on purpose: `plugin/hooks/lib/policy.mjs` refuses the agent's own tool call
61
+ inside a session, this profile is the kernel outside it, and `@geonosis/evals` hashes the baseline
62
+ before and after. A run that got past the first two must still fail the third.
63
+
64
+ Stored **relative**, because a repo is at a different path on every machine and a shared list of
65
+ absolute paths could not be shared. They become absolute in `canonicalise`, and nowhere else.
66
+
67
+ ## Three things measured against the real kernel
68
+
69
+ All three on `/usr/bin/sandbox-exec`, 2026-08-30. `seatbelt.live.test.ts` runs them every suite and
70
+ skips loudly where the binary is absent — a suite that quietly passed on Linux would report that the
71
+ rail holds on a platform where nothing checked it.
72
+
73
+ **1. Order is the security property.** SBPL is last-match-wins: deny the tree, allow the work back,
74
+ **then** re-deny the rails. Falsified, not demonstrated — with the re-deny moved *before* the
75
+ allow-back, the same write succeeded and `gate-baseline.json` really did read `tampered`. Reordering
76
+ these lines is not a formatting change.
77
+
78
+ **2. `subpath` denies a file too.** So it is used for every deny and `literal` for none. That
79
+ removes a file-or-directory guess which had `.ssh` down as a file the first time it was written.
80
+
81
+ **3. A symlinked root denies nothing.** The kernel matches the *resolved* path. On macOS
82
+ `os.tmpdir()` is `/var/folders/…`, a symlink to `/private/var/folders/…` — a profile built from the
83
+ symlinked spelling misses every rule, exits 0, and loads, renders and reads perfectly correct. The
84
+ first live run of this package wrote `tampered` straight over a baseline the profile said it was
85
+ protecting.
86
+
87
+ Worse: the falsification test *also* passed, because nothing was denied either way. A symlinked root
88
+ does not merely break the rail, it breaks the test that would have told you. Hence `canonicalise`,
89
+ which `runContained` and the CLI both call, and which resolves children against the **canonical**
90
+ root so a rail naming a path that does not exist yet still lands where the kernel will look.
91
+
92
+ ## Containment, not least-privilege
93
+
94
+ `(allow default)`, then deny. Deliberately, and again in 2ndm1nd's words: *"a deny-list cannot
95
+ silently starve a process of a path it needs"* — and a starved process fails silently, which is the
96
+ failure class the rails exist to end. An over-tight allow-list produces a stale result that nothing
97
+ reports.
98
+
99
+ ## Two renderings, one profile
100
+
101
+ A profile is data. `renderSeatbelt` and `renderBubblewrap` are two functions over the same object,
102
+ so the platforms cannot drift into meaning different things — the failure mode of a `.sb` written by
103
+ hand on one laptop.
104
+
105
+ bubblewrap has no last-match-wins deny: a path is bound or it is not. So the rails are re-bound
106
+ read-only **after** the writable binds, because a later bind shadows an earlier one; without that the
107
+ deny would exist only in the seatbelt rendering. A read-deny becomes `--tmpfs`, which shadows the
108
+ path with an empty directory — the only way to make something unreadable is not to hand it over.
109
+
110
+ **The bubblewrap rendering is not measured.** There is no `bwrap` on the machine this was written
111
+ on. What is tested is the renderer.
112
+
113
+ ## Exit codes for `contain`
114
+
115
+ The command's own exit code, except that a run which broke a rail fails even when its command
116
+ succeeded. A run that failed on its own merits keeps **its** code, because that is the one the
117
+ operator needs.
118
+
119
+ The runner's own rendered profile lands at `.geonosis/rails.sb` and is excused by name, so a repo
120
+ that denies `.geonosis` itself does not catch the rail-writer — the distinction is not *which* path
121
+ was written but *who* wrote it.
122
+
123
+ ## The shift runner
124
+
125
+ ```bash
126
+ geonosis-rails shift --goal /goal --cycles 8 [--json]
127
+ [--claude <bin>] [--gh <bin>] [--ledger <bin>]
128
+ ```
129
+
130
+ **One session, resumed** — the polymorph pattern 2ndm1nd's `brain-loop.sh` runs on, and not an
131
+ optimisation. Scheduling tools die at turn-end inside `claude -p`, so an outer loop that woke
132
+ something up gets a cold session every cycle; resuming keeps the conversation, which is the only
133
+ reason a second cycle is worth more than a first. A cycle that comes back with **no session id**
134
+ parks, because carrying on would silently start fresh every time and look identical in the log.
135
+
136
+ **The handoff is rewritten after every cycle**, not at the end. And every park writes WHY into it
137
+ before returning — a shift that stopped and said nothing is a shift somebody restarts from the
138
+ beginning.
139
+
140
+ ### It parks rather than running blind
141
+
142
+ | Condition | Why |
143
+ |---|---|
144
+ | `main` is red | somebody else's broken build; piling on makes the next person bisect through both |
145
+ | `main` is **unknown** | a `gh` that is missing, logged out or answering about a run in progress. A main nobody could ask about is not a green main |
146
+ | the gate report is red | another cycle on top of a red gate is the blind cycle this exists to refuse |
147
+ | a ⚠AUTH class was reached | `geonosis.json → caps.auth`, D-019 |
148
+ | the Stop hook blocked to its cap | a turn that cannot go green in five tries has a problem the sixth will not solve |
149
+ | the CI-fix attempts are spent | two, then park rather than try the same fix again |
150
+
151
+ Checked **before** the cycle where the condition is about the world (`main`, the blocks) and after
152
+ where it is about the cycle (auth, exit code, gate). Every reason carries its count.
153
+
154
+ Exit **1** on a park. Not a crash and not a pass: it says the shift stopped for a reason a human has
155
+ to read.
156
+
157
+ ### The caps
158
+
159
+ `geonosis.json → caps`, defaulting to Foreman's two-and-two and the Stop hook's five — the numbers
160
+ the kit already enforces elsewhere, so a shift runner does not put a second bound on the same
161
+ behaviour.
162
+
163
+ ```json
164
+ { "caps": { "revisions": 2, "ciFixes": 2, "stopBlocks": 5, "draftPr": true, "auth": [] } }
165
+ ```
166
+
167
+ `auth` defaults to **empty**, and that is law 6 rather than an oversight: a default list of
168
+ dangerous-sounding words would pause every repo that never read the flag, on somebody else's idea of
169
+ dangerous. `AUTH_CLASSES` — `infra`, `deletes`, `deploys`, `money`, `third-party sends` — is the seed
170
+ to copy in, not the default.
171
+
172
+ The match is a stem and it is deliberately generous, because the asymmetry is the design: a false
173
+ pause costs a question, a false pass costs an unattended deploy, and only one of those can be undone
174
+ by answering.
175
+
176
+ A `geonosis.json` that cannot be **parsed** is refused, never defaulted. "I could not read your
177
+ config so I used mine" is how an unattended shift runs for eight hours under bounds nobody wrote.
178
+
179
+ ### Every binary is an option
180
+
181
+ `--claude`, `--gh`, `--ledger`. Law 6, and the practical reason: `claude` is at `~/.local/bin/claude`
182
+ on one machine and elsewhere on the next — and a test has to be able to point them at something that
183
+ is not a model. Every test in this package does.
184
+
185
+ A repeated single-value flag takes the **last**, so an override appended to a base argument list
186
+ wins. First-wins read exactly like the override having no effect, which is how three of these tests
187
+ silently ignored their stub.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // Committed, so `pnpm install` can link the bin on a fresh clone — before `pnpm build` has
3
+ // produced dist/. A bin that only exists after a build is a bin that is missing when you need it.
4
+ import '../dist/rails-cli.js'
@@ -0,0 +1,423 @@
1
+ // src/profile.ts
2
+ import { realpathSync } from "fs";
3
+ import { isAbsolute, resolve } from "path";
4
+ var RAILS_DENY_WRITE = ["gate-baseline.json"];
5
+ var safe = (path) => {
6
+ if (path.includes('"'))
7
+ throw new Error(`path contains a quote, which no profile can render: ${path}`);
8
+ if (path.includes("\n")) throw new Error(`path contains a newline: ${JSON.stringify(path)}`);
9
+ return path;
10
+ };
11
+ var sandboxProfile = ({
12
+ allowNet,
13
+ allowWrite,
14
+ denyRead,
15
+ denyWrite,
16
+ profilePath,
17
+ root
18
+ }) => {
19
+ if (!isAbsolute(root)) {
20
+ throw new Error(
21
+ `sandbox root must be absolute, got "${root}" \u2014 every rule would resolve nowhere`
22
+ );
23
+ }
24
+ return {
25
+ allowNet,
26
+ allowWrite: allowWrite.map(safe),
27
+ denyRead: (denyRead ?? []).map(safe),
28
+ denyWrite: [.../* @__PURE__ */ new Set([...denyWrite ?? [], ...RAILS_DENY_WRITE])].map(safe),
29
+ profilePath: profilePath === void 0 ? void 0 : safe(profilePath),
30
+ root: safe(root)
31
+ };
32
+ };
33
+ var at = (profile, path) => resolve(profile.root, path);
34
+ var renderSeatbelt = (profile) => {
35
+ const lines = [
36
+ // Measured: a `;` comment BEFORE this line is accepted too, but the version form goes first
37
+ // because that is the shape every reader and every other tool expects.
38
+ "(version 1)",
39
+ "; Rendered by @geonosis/rails. Last match wins: deny the tree, allow the work back, re-deny",
40
+ "; the rails. Reordering these lines opens a hole that the profile still reads correct with.",
41
+ "(allow default)",
42
+ `(deny file-write* (subpath "${profile.root}"))`,
43
+ ...profile.allowWrite.map((one) => `(allow file-write* (subpath "${at(profile, one)}"))`),
44
+ ...profile.denyWrite.map((one) => `(deny file-write* (subpath "${at(profile, one)}"))`),
45
+ ...profile.profilePath === void 0 ? [] : (
46
+ // The profile denies writes to ITSELF, dated in the source it is modelled on: the renderer
47
+ // hands back the EXISTING path when a rewrite fails, so a tampered-then-locked profile would
48
+ // be handed straight back and the next run would launch uncaged.
49
+ [`(deny file-write* (subpath "${at(profile, profile.profilePath)}"))`]
50
+ ),
51
+ // Read-denied, not merely unwritable: a run that may explore the disk must find a credential
52
+ // unreadable, because write containment is kernel-enforced and egress is not.
53
+ ...profile.denyRead.map((one) => `(deny file-read* (subpath "${at(profile, one)}"))`),
54
+ ...profile.allowNet ? [] : ["(deny network*)"]
55
+ ];
56
+ return `${lines.join("\n")}
57
+ `;
58
+ };
59
+ var renderBubblewrap = (profile) => [
60
+ "--ro-bind",
61
+ profile.root,
62
+ profile.root,
63
+ ...profile.allowWrite.flatMap((one) => ["--bind", at(profile, one), at(profile, one)]),
64
+ ...profile.denyWrite.flatMap((one) => ["--ro-bind", at(profile, one), at(profile, one)]),
65
+ ...profile.profilePath === void 0 ? [] : ["--ro-bind", at(profile, profile.profilePath), at(profile, profile.profilePath)],
66
+ ...profile.denyRead.flatMap((one) => ["--tmpfs", at(profile, one)]),
67
+ ...profile.allowNet ? [] : ["--unshare-net"]
68
+ ];
69
+ var realOf = (path) => {
70
+ try {
71
+ return realpathSync.native(path);
72
+ } catch {
73
+ return path;
74
+ }
75
+ };
76
+ var canonicalise = (profile, realpath = realOf) => {
77
+ const root = realpath(profile.root);
78
+ const under = (one) => realpath(resolve(root, one));
79
+ return {
80
+ allowNet: profile.allowNet,
81
+ allowWrite: profile.allowWrite.map(under),
82
+ denyRead: profile.denyRead.map(under),
83
+ denyWrite: profile.denyWrite.map(under),
84
+ profilePath: profile.profilePath === void 0 ? void 0 : under(profile.profilePath),
85
+ root
86
+ };
87
+ };
88
+
89
+ // src/contain.ts
90
+ import { createHash } from "crypto";
91
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
92
+ import { dirname, join, relative, resolve as resolve2 } from "path";
93
+ var UNSANDBOXED = "unsandboxedRuns";
94
+ var PROFILE_PATH = ".geonosis/rails.sb";
95
+ var banner = (why) => [
96
+ "",
97
+ " ############################################################",
98
+ " # UNSANDBOXED RUN \u2014 the profile could not be applied. #",
99
+ " ############################################################",
100
+ ` reason: ${why}`,
101
+ " The command RAN, uncontained. This is deliberate: failing closed here would trade a",
102
+ " contained risk for a certain outage, and a blind cycle is worse than a loud one.",
103
+ ` The run carries a non-zero "${UNSANDBOXED}" marker so the gate report says so too.`,
104
+ ""
105
+ ].join("\n");
106
+ var runContained = ({
107
+ command,
108
+ has = () => false,
109
+ platform = process.platform,
110
+ profile,
111
+ root,
112
+ spawn
113
+ }) => {
114
+ const open = (why) => {
115
+ const said = banner(why);
116
+ process.stderr.write(`${said}
117
+ `);
118
+ const done2 = spawn(command, [], root);
119
+ return {
120
+ banner: said,
121
+ exitCode: done2.exitCode,
122
+ marker: { count: 1, id: UNSANDBOXED },
123
+ sandboxed: false,
124
+ stderr: done2.stderr,
125
+ stdout: done2.stdout
126
+ };
127
+ };
128
+ const binary = platform === "darwin" ? "sandbox-exec" : "bwrap";
129
+ if (!has(binary)) return open(`${binary} is not on PATH`);
130
+ const at2 = resolve2(root, PROFILE_PATH);
131
+ let real;
132
+ try {
133
+ mkdirSync(dirname(at2), { recursive: true });
134
+ real = canonicalise({ ...profile, profilePath: at2 });
135
+ writeFileSync(at2, renderSeatbelt(real));
136
+ } catch (error) {
137
+ return open(`the profile could not be written to ${at2}: ${error.message}`);
138
+ }
139
+ const argv = platform === "darwin" ? ["-f", at2, "sh", "-c", command] : [...renderBubblewrap(real), "sh", "-c", command];
140
+ const done = spawn(binary, argv, root);
141
+ return {
142
+ exitCode: done.exitCode,
143
+ profilePath: at2,
144
+ sandboxed: true,
145
+ stderr: done.stderr,
146
+ stdout: done.stdout
147
+ };
148
+ };
149
+ var hashOf = (body) => createHash("sha256").update(body).digest("hex");
150
+ var filesAt = (root, path) => {
151
+ const at2 = resolve2(root, path);
152
+ if (!existsSync(at2)) return [];
153
+ if (statSync(at2).isFile()) return [relative(root, at2)];
154
+ return readdirSync(at2, { withFileTypes: true }).flatMap((entry) => {
155
+ const full = join(at2, entry.name);
156
+ if (entry.isDirectory()) return filesAt(root, relative(root, full));
157
+ return entry.isFile() ? [relative(root, full)] : [];
158
+ });
159
+ };
160
+ var snapshotDenied = (root, denied) => Object.fromEntries(
161
+ denied.flatMap(
162
+ (path) => filesAt(root, path).map((file) => [file, hashOf(readFileSync(resolve2(root, file)))])
163
+ )
164
+ );
165
+ var deniedWrites = (root, denied, before, except = []) => {
166
+ const after = snapshotDenied(root, denied);
167
+ const found = [];
168
+ const ours = new Set(except.map((one) => relative(root, resolve2(root, one))));
169
+ for (const [file, hash] of Object.entries(after)) {
170
+ if (ours.has(file)) continue;
171
+ const was = before[file];
172
+ if (was === void 0) found.push(`${file} was created under a path the run may not write`);
173
+ else if (was !== hash) found.push(`${file} was changed under a path the run may not write`);
174
+ }
175
+ for (const file of Object.keys(before)) {
176
+ if (ours.has(file)) continue;
177
+ if (after[file] === void 0)
178
+ found.push(`${file} was deleted from a path the run may not write`);
179
+ }
180
+ return found;
181
+ };
182
+
183
+ // src/caps.ts
184
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
185
+ import { resolve as resolve3 } from "path";
186
+ var CAP_DEFAULTS = {
187
+ auth: [],
188
+ ciFixes: 2,
189
+ draftPr: true,
190
+ revisions: 2,
191
+ stopBlocks: 5
192
+ };
193
+ var AUTH_CLASSES = ["infra", "deletes", "deploys", "money", "third-party sends"];
194
+ var CONFIG = "geonosis.json";
195
+ var numberAt = (source, key, fallback) => {
196
+ const found = source[key];
197
+ if (found === void 0) return fallback;
198
+ if (typeof found !== "number" || !Number.isFinite(found) || found < 0) {
199
+ throw new Error(
200
+ `${CONFIG}: caps.${key} must be a number of zero or more, got ${JSON.stringify(found)}`
201
+ );
202
+ }
203
+ return found;
204
+ };
205
+ var loadCaps = (root) => {
206
+ const at2 = resolve3(root, CONFIG);
207
+ if (!existsSync2(at2)) return { ...CAP_DEFAULTS };
208
+ let parsed;
209
+ try {
210
+ parsed = JSON.parse(readFileSync2(at2, "utf8"));
211
+ } catch (error) {
212
+ throw new Error(`${CONFIG} is not readable as JSON: ${error.message}`, {
213
+ cause: error
214
+ });
215
+ }
216
+ const caps = parsed?.caps;
217
+ if (caps === void 0) return { ...CAP_DEFAULTS };
218
+ if (typeof caps !== "object" || caps === null || Array.isArray(caps)) {
219
+ throw new Error(`${CONFIG}: "caps" must be an object`);
220
+ }
221
+ const source = caps;
222
+ const auth = source["auth"];
223
+ if (auth !== void 0 && (!Array.isArray(auth) || auth.some((one) => typeof one !== "string"))) {
224
+ throw new Error(`${CONFIG}: caps.auth must be a list of strings`);
225
+ }
226
+ return {
227
+ auth: auth ?? CAP_DEFAULTS.auth,
228
+ ciFixes: numberAt(source, "ciFixes", CAP_DEFAULTS.ciFixes),
229
+ draftPr: source["draftPr"] === void 0 ? CAP_DEFAULTS.draftPr : source["draftPr"] === true,
230
+ revisions: numberAt(source, "revisions", CAP_DEFAULTS.revisions),
231
+ stopBlocks: numberAt(source, "stopBlocks", CAP_DEFAULTS.stopBlocks)
232
+ };
233
+ };
234
+ var stem = (pattern) => pattern.toLowerCase().replace(/(?:es|s)$/, "");
235
+ var needsAuth = (text, patterns) => {
236
+ const lower = text.toLowerCase();
237
+ return patterns.find((pattern) => lower.includes(stem(pattern)));
238
+ };
239
+
240
+ // src/shift.ts
241
+ var runShift = ({ caps, cycles, goal, root, seams }) => {
242
+ if (!(cycles > 0))
243
+ throw new Error(`a shift of ${cycles} cycles would report a clean run over nothing`);
244
+ if (goal.trim() === "") throw new Error("a shift needs a goal command to run");
245
+ const ran = [];
246
+ let session;
247
+ let ciFixes = 0;
248
+ const park = (at2, reason) => {
249
+ seams.handoff(reason);
250
+ return { cycles: ran, parked: true, parkedAt: at2, reason };
251
+ };
252
+ for (let number = 1; number <= cycles; number += 1) {
253
+ const main = seams.mainStatus();
254
+ if (main !== "green") {
255
+ return park(number, `main is ${main} \u2014 parking rather than building on a red or unknown main`);
256
+ }
257
+ const blocks = seams.stopBlocks(root);
258
+ if (blocks >= caps.stopBlocks) {
259
+ return park(
260
+ number,
261
+ `the stop hook blocked ${blocks} times, at the cap of ${caps.stopBlocks} \u2014 a turn that cannot go green in ${caps.stopBlocks} tries has a problem the next one will not solve`
262
+ );
263
+ }
264
+ const outcome = seams.runCycle({
265
+ goal,
266
+ number,
267
+ ...session === void 0 ? {} : { resume: session }
268
+ });
269
+ ran.push({
270
+ exitCode: outcome.exitCode,
271
+ number,
272
+ ...outcome.sessionId === void 0 ? {} : { sessionId: outcome.sessionId }
273
+ });
274
+ if (outcome.sessionId === void 0) {
275
+ return park(
276
+ number,
277
+ "the cycle reported no session id \u2014 the next cycle could not resume it, only start over"
278
+ );
279
+ }
280
+ session = outcome.sessionId;
281
+ const auth = needsAuth(outcome.stdout, caps.auth);
282
+ if (auth !== void 0) {
283
+ return park(
284
+ number,
285
+ `the cycle reached "${auth}", which this repo reserves for a human \u2014 parking for authorisation`
286
+ );
287
+ }
288
+ if (outcome.exitCode === 0) {
289
+ ciFixes = 0;
290
+ } else {
291
+ ciFixes += 1;
292
+ if (ciFixes >= caps.ciFixes) {
293
+ return park(
294
+ number,
295
+ `${ciFixes} CI-fix attempts, at the cap of ${caps.ciFixes} \u2014 parking rather than trying the same fix again`
296
+ );
297
+ }
298
+ }
299
+ const gate = seams.gate(root);
300
+ if (gate !== void 0 && !gate.ok) {
301
+ return park(
302
+ number,
303
+ `the ${gate.tier} gate is red \u2014 parking rather than running another cycle on top of it`
304
+ );
305
+ }
306
+ seams.handoff(`cycle ${number} of ${cycles} complete`);
307
+ }
308
+ return { cycles: ran, parked: false, reason: `${cycles} cycles complete` };
309
+ };
310
+
311
+ // src/shift-seams.ts
312
+ import { spawnSync } from "child_process";
313
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
314
+ import { resolve as resolve4 } from "path";
315
+ var sessionOf = (output) => {
316
+ for (const line of output.split("\n").toReversed()) {
317
+ const trimmed = line.trim();
318
+ if (trimmed === "") continue;
319
+ let parsed;
320
+ try {
321
+ parsed = JSON.parse(trimmed);
322
+ } catch {
323
+ continue;
324
+ }
325
+ const found = parsed;
326
+ if (found?.type !== "result") continue;
327
+ return typeof found.session_id === "string" ? found.session_id : void 0;
328
+ }
329
+ return void 0;
330
+ };
331
+ var readJson = (root, path) => {
332
+ const at2 = resolve4(root, path);
333
+ if (!existsSync3(at2)) return void 0;
334
+ try {
335
+ return JSON.parse(readFileSync3(at2, "utf8"));
336
+ } catch {
337
+ return void 0;
338
+ }
339
+ };
340
+ var realSeams = ({ claude, gh, ledger, root }) => ({
341
+ gate: () => {
342
+ const report = readJson(root, ".geonosis/gate-report.json");
343
+ if (report === void 0) return void 0;
344
+ return { ok: report.ok === true, tier: typeof report.tier === "string" ? report.tier : "\u2014" };
345
+ },
346
+ handoff: (why) => {
347
+ const done = spawnSync(ledger, ["handoff", "--write", "--note", why], {
348
+ cwd: root,
349
+ encoding: "utf8"
350
+ });
351
+ return done.status ?? -1;
352
+ },
353
+ /**
354
+ * `gh run list --branch main --limit 1 --json conclusion`. Anything other than a clear success is
355
+ * NOT green: a `gh` that is not installed, not logged in, or answering about a run still in
356
+ * progress all come back as `unknown`, and the shift parks. A main nobody could ask about is
357
+ * exactly the blind cycle this is here to refuse.
358
+ */
359
+ mainStatus: () => {
360
+ const done = spawnSync(
361
+ gh,
362
+ ["run", "list", "--branch", "main", "--limit", "1", "--json", "conclusion"],
363
+ { cwd: root, encoding: "utf8" }
364
+ );
365
+ if (done.status !== 0) return "unknown";
366
+ try {
367
+ const runs = JSON.parse(done.stdout);
368
+ const latest = runs[0];
369
+ if (latest === void 0) return "unknown";
370
+ return latest.conclusion === "success" ? "green" : "red";
371
+ } catch {
372
+ return "unknown";
373
+ }
374
+ },
375
+ runCycle: ({ goal, resume }) => {
376
+ const done = spawnSync(
377
+ claude,
378
+ [
379
+ "-p",
380
+ goal,
381
+ "--output-format",
382
+ "stream-json",
383
+ ...resume === void 0 ? [] : ["--resume", resume]
384
+ ],
385
+ { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }
386
+ );
387
+ const stdout = done.stdout ?? "";
388
+ const sessionId = sessionOf(stdout);
389
+ return {
390
+ exitCode: done.status ?? -1,
391
+ ...sessionId === void 0 ? {} : { sessionId },
392
+ stdout
393
+ };
394
+ },
395
+ stopBlocks: () => {
396
+ const counts = readJson(root, ".geonosis/stop-blocks.json");
397
+ if (typeof counts !== "object" || counts === null) return 0;
398
+ return Object.values(counts).reduce(
399
+ (most, value) => typeof value === "number" && value > most ? value : most,
400
+ 0
401
+ );
402
+ }
403
+ });
404
+
405
+ export {
406
+ RAILS_DENY_WRITE,
407
+ sandboxProfile,
408
+ renderSeatbelt,
409
+ renderBubblewrap,
410
+ canonicalise,
411
+ UNSANDBOXED,
412
+ PROFILE_PATH,
413
+ runContained,
414
+ snapshotDenied,
415
+ deniedWrites,
416
+ CAP_DEFAULTS,
417
+ AUTH_CLASSES,
418
+ loadCaps,
419
+ needsAuth,
420
+ runShift,
421
+ sessionOf,
422
+ realSeams
423
+ };
@@ -0,0 +1,285 @@
1
+ /**
2
+ * The paths a scored run may never write, on every profile whether it was asked for or not.
3
+ *
4
+ * Relative to a repo root, because the rails are a property of a repo and a repo is at a different
5
+ * path on every machine — a list of absolute paths could not be shared, which is law 6.
6
+ *
7
+ * 2ndm1nd's rail: the scored agent never writes the scoreboard. The commit that introduced it there
8
+ * followed a probe showing the sandboxed cycle could `CREATE TABLE` in the very ledger it was scored
9
+ * on — "harmless while the ledger is only evidence; disqualifying the moment outcome metrics
10
+ * computed FROM it gate promotion of the model's own code".
11
+ *
12
+ * ONLY the baseline, and that is narrower than the list `plugin/hooks/lib/policy.mjs` refuses at the
13
+ * tool layer, on purpose: the kernel cannot tell the agent from the hook and the verify it spawned,
14
+ * and `.geonosis/gate-report.json` and `proofs/` are what THOSE write during the run — the files
15
+ * `@geonosis/evals` scores it from. A profile that denied them denied the scorer its evidence, and
16
+ * every contained run read as a red gate with zero proofs. The hook guards the agent's own writes
17
+ * there; the kernel guards the number the run is scored against.
18
+ */
19
+ declare const RAILS_DENY_WRITE: string[];
20
+ type SandboxProfile = {
21
+ allowNet: boolean;
22
+ allowWrite: string[];
23
+ denyRead: string[];
24
+ denyWrite: string[];
25
+ profilePath: string | undefined;
26
+ root: string;
27
+ };
28
+ type SandboxProfileInput = {
29
+ allowNet: boolean;
30
+ allowWrite: string[];
31
+ denyRead?: string[];
32
+ denyWrite?: string[];
33
+ profilePath?: string;
34
+ root: string;
35
+ };
36
+ /**
37
+ * A profile is DATA, and its paths stay exactly as they were given.
38
+ *
39
+ * Resolution happens at RENDER time, against the root, so one profile object describes the same
40
+ * rails on two machines and the two renderers cannot drift into meaning different things — which is
41
+ * the failure mode of a `.sb` file written by hand on one laptop.
42
+ */
43
+ declare const sandboxProfile: ({ allowNet, allowWrite, denyRead, denyWrite, profilePath, root, }: SandboxProfileInput) => SandboxProfile;
44
+ /**
45
+ * A macOS `sandbox-exec` profile, in SBPL.
46
+ *
47
+ * Three properties, all three MEASURED against `/usr/bin/sandbox-exec` on 2026-08-30 rather than
48
+ * taken from documentation:
49
+ *
50
+ * 1. **`(allow default)` then deny** — containment, not least-privilege, in 2ndm1nd's words and for
51
+ * its reason: "a deny-list cannot silently starve a process of a path it needs", and a starved
52
+ * process fails silently, which is the failure class the rails exist to end.
53
+ * 2. **Last match wins, and the order is the security property.** Measured both ways: with the rail
54
+ * re-denied AFTER the allow-back, a write to `gate-baseline.json` returned "Operation not
55
+ * permitted"; with the same three rules in the other order the write SUCCEEDED and the file read
56
+ * `tampered`. Reordering these lines is not a formatting change. It is a hole, and the profile
57
+ * still reads correct.
58
+ * 3. **`subpath` is used for every deny, files included.** Measured: `(deny file-write* (subpath
59
+ * "…/gate-baseline.json"))` denies that file. `literal` would be equal for a file and strictly
60
+ * weaker for a directory, so one form is used for both and there is no file-or-directory guess to
61
+ * get wrong — a guess that had `.ssh` down as a file the first time it was written.
62
+ */
63
+ declare const renderSeatbelt: (profile: SandboxProfile) => string;
64
+ /**
65
+ * A Linux `bwrap` invocation, as argv.
66
+ *
67
+ * NOT measured — no `bwrap` on the machine this was written on, and the renderer is what this
68
+ * package can honestly test. bubblewrap has no last-match-wins deny: a path is bound or it is not.
69
+ * So the rails are re-bound read-only AFTER the writable binds, because a later bind shadows an
70
+ * earlier one; without that the deny would exist only in the seatbelt rendering and Linux would run
71
+ * without it. A read-deny becomes a `--tmpfs`, which shadows the path with an empty directory — the
72
+ * only way to make something unreadable is not to hand it over.
73
+ */
74
+ declare const renderBubblewrap: (profile: SandboxProfile) => string[];
75
+ /**
76
+ * The same profile with every path canonicalised, and the reason it exists at all.
77
+ *
78
+ * MEASURED, and it is a landmine rather than a nicety. The kernel matches the RESOLVED path. On
79
+ * macOS `os.tmpdir()` is `/var/folders/…`, which is a symlink to `/private/var/folders/…`, so a
80
+ * profile built from the symlinked spelling denies `/var/…` while the write happens against
81
+ * `/private/var/…` — every rule misses, `sandbox-exec` exits 0, and the profile loads, renders and
82
+ * reads perfectly correct. The first live run of this package wrote `tampered` straight over a
83
+ * baseline the profile said it was protecting.
84
+ *
85
+ * Worse: the falsification test — the one that proves the WRONG rule order lets the write through —
86
+ * also passed, because nothing was being denied either way. A symlinked root does not merely break
87
+ * the rail, it breaks the test that would have told you.
88
+ *
89
+ * Kept out of `sandboxProfile` so the profile stays pure data and the renderers stay deterministic
90
+ * off it. `runContained` calls this; anyone rendering a profile to run for real must too.
91
+ */
92
+ declare const canonicalise: (profile: SandboxProfile, realpath?: (path: string) => string) => SandboxProfile;
93
+
94
+ /**
95
+ * The marker a run that could not be contained carries into the gate report.
96
+ *
97
+ * A banner is read by whoever is watching, and by nobody at 3 a.m. The marker travels WITH the run,
98
+ * so the evidence outlives the terminal — this is a counter's name, and a counter that is non-zero
99
+ * is debt the ratchet will not let grow.
100
+ */
101
+ declare const UNSANDBOXED = "unsandboxedRuns";
102
+ /** Where the rendered profile lands. Under the runner-owned directory, which it also denies. */
103
+ declare const PROFILE_PATH = ".geonosis/rails.sb";
104
+ type SpawnResult = {
105
+ exitCode: number;
106
+ stderr: string;
107
+ stdout: string;
108
+ };
109
+ type Spawn = (command: string, argv: string[], cwd: string) => SpawnResult;
110
+ type ContainedRun = {
111
+ banner?: string;
112
+ exitCode: number;
113
+ marker?: {
114
+ count: number;
115
+ id: string;
116
+ };
117
+ profilePath?: string;
118
+ sandboxed: boolean;
119
+ stderr: string;
120
+ stdout: string;
121
+ };
122
+ type RunContainedInput = {
123
+ command: string;
124
+ has?: (binary: string) => boolean;
125
+ platform?: NodeJS.Platform;
126
+ profile: SandboxProfile;
127
+ root: string;
128
+ spawn: Spawn;
129
+ };
130
+ /**
131
+ * Runs a command inside the profile, and runs it ANYWAY when it cannot.
132
+ *
133
+ * Fails open, and loudly. 2ndm1nd's own words for the same decision: "a missing profile means
134
+ * unsandboxed instruments, not a blind cycle. Failing closed here would trade a contained risk for
135
+ * a certain outage. It logs loudly instead, because a silent fallback is how rails rot."
136
+ *
137
+ * Loudly is doing work in that sentence. The shell version this is modelled on logs to a file that
138
+ * nobody reads, which is a fallback that is silent in every way that matters — so this one prints a
139
+ * banner AND returns a marker that goes into the gate report, where the ratchet can refuse to let it
140
+ * grow.
141
+ */
142
+ declare const runContained: ({ command, has, platform, profile, root, spawn, }: RunContainedInput) => ContainedRun;
143
+ type DeniedSnapshot = Record<string, string>;
144
+ /**
145
+ * The digest of everything under every denied path, taken before the run.
146
+ *
147
+ * A hash rather than an mtime, because an mtime moves when a tool merely opens a file for writing,
148
+ * and a write that restores the same bytes is not a write. A directory is walked rather than
149
+ * stat-ed, because a denied path may be a TREE: a file planted inside one is the write that
150
+ * matters, and the directory's own mtime would not always say so.
151
+ */
152
+ declare const snapshotDenied: (root: string, denied: string[]) => DeniedSnapshot;
153
+ /**
154
+ * What the run actually wrote to a path it may not, taken after.
155
+ *
156
+ * This is EVIDENCE, and it is the half that holds when the first half failed open: a run that went
157
+ * uncontained because the binary was missing still cannot get away with having rewritten the
158
+ * scoreboard, because the hashes say it did.
159
+ */
160
+ declare const deniedWrites: (root: string, denied: string[], before: DeniedSnapshot, except?: string[]) => string[];
161
+
162
+ type Caps = {
163
+ auth: string[];
164
+ ciFixes: number;
165
+ draftPr: boolean;
166
+ revisions: number;
167
+ stopBlocks: number;
168
+ };
169
+ /**
170
+ * Foreman's two-and-two, and the Stop hook's five — the numbers the kit already enforces elsewhere,
171
+ * so a shift runner that invented its own would put two different bounds on the same behaviour.
172
+ *
173
+ * `auth` is EMPTY, and that is law 6 rather than an oversight: a default list of dangerous-sounding
174
+ * words would pause every repo that never read the flag, on somebody else's idea of dangerous. A
175
+ * repo opts in by naming what it means, and `AUTH_CLASSES` is the seed to copy from.
176
+ */
177
+ declare const CAP_DEFAULTS: Caps;
178
+ /** D-019's five classes, offered rather than imposed. Copy into `geonosis.json → caps.auth`. */
179
+ declare const AUTH_CLASSES: string[];
180
+ /**
181
+ * The caps a repo runs under, from `geonosis.json → caps`, with the defaults for whatever it did
182
+ * not mention.
183
+ *
184
+ * A config that cannot be PARSED is refused rather than defaulted. "I could not read your config so
185
+ * I used mine" is how an unattended shift ends up running for eight hours under bounds nobody wrote
186
+ * — and the one thing an unattended run must not do is guess about its own limits.
187
+ */
188
+ declare const loadCaps: (root: string) => Caps;
189
+ /**
190
+ * The first configured ⚠AUTH pattern the text mentions, or nothing.
191
+ *
192
+ * Deliberately generous. The asymmetry is the whole design: a false pause costs a question, a false
193
+ * pass costs an unattended deploy — and only one of those can be undone by answering.
194
+ */
195
+ declare const needsAuth: (text: string, patterns: string[]) => string | undefined;
196
+
197
+ type CycleOutcome = {
198
+ exitCode: number;
199
+ sessionId?: string;
200
+ stdout: string;
201
+ };
202
+ type MainStatus = 'green' | 'red' | 'unknown';
203
+ /**
204
+ * Everything the shift does to the outside world, in one object.
205
+ *
206
+ * A seam per side effect, because a shift runner that spawned processes could only be tested by
207
+ * spawning them — and the behaviour worth proving is WHEN it parks, which has nothing to do with
208
+ * how a process is started. Nothing here calls a model; the CLI supplies the real ones.
209
+ */
210
+ type ShiftSeams = {
211
+ gate: (root: string) => {
212
+ ok: boolean;
213
+ tier: string;
214
+ } | undefined;
215
+ handoff: (why: string) => number;
216
+ mainStatus: () => MainStatus;
217
+ runCycle: (input: {
218
+ goal: string;
219
+ number: number;
220
+ resume?: string;
221
+ }) => CycleOutcome;
222
+ stopBlocks: (root: string) => number;
223
+ };
224
+ type ShiftCycle = {
225
+ exitCode: number;
226
+ number: number;
227
+ sessionId?: string;
228
+ };
229
+ type ShiftOutcome = {
230
+ cycles: ShiftCycle[];
231
+ parked: boolean;
232
+ parkedAt?: number;
233
+ reason: string;
234
+ };
235
+ type RunShiftInput = {
236
+ caps: Caps;
237
+ cycles: number;
238
+ goal: string;
239
+ root: string;
240
+ seams: ShiftSeams;
241
+ };
242
+ /**
243
+ * A shift: N cycles of one resumed session, with the handoff rewritten after each and a park
244
+ * instead of a blind cycle.
245
+ *
246
+ * ONE session, resumed — the polymorph pattern 2ndm1nd runs on, and it is not an optimisation.
247
+ * Scheduling tools die at turn-end inside `claude -p`, so an outer loop that woke something up would
248
+ * get a cold session every hour; resuming keeps the conversation, which is the only reason a second
249
+ * cycle is worth more than a first.
250
+ *
251
+ * Every park writes WHY into the handoff before it returns. A shift that stopped and said nothing is
252
+ * a shift nobody can resume, which in practice means a shift somebody restarts from the beginning.
253
+ */
254
+ declare const runShift: ({ caps, cycles, goal, root, seams }: RunShiftInput) => ShiftOutcome;
255
+
256
+ /**
257
+ * The session id out of a headless run's terminal event.
258
+ *
259
+ * This reads the same JSON shape `@geonosis/evals` reads, and it is deliberately NOT shared with it.
260
+ * The two are walled apart (`layer-walls`, planted in `dogfood.test.ts`) because a shift runner that
261
+ * imported the scorer would put a scorer inside the sandbox it is enforcing, and an evals package
262
+ * that imported the rails would make every repo scoring a run also install a sandbox renderer. They
263
+ * also want different fields for different reasons: the scorer needs `is_error` and
264
+ * `permission_denials`; a shift needs one string to resume with. Twelve lines is the honest price of
265
+ * two leaves, and the shape is documented once in
266
+ * `docs/evals-rails-inventory-2026-08-30.md` §3 where it was measured.
267
+ */
268
+ declare const sessionOf: (output: string) => string | undefined;
269
+ type SeamOptions = {
270
+ claude: string;
271
+ gh: string;
272
+ ledger: string;
273
+ root: string;
274
+ };
275
+ /**
276
+ * The real seams: a headless `claude -p`, `gh run list`, `geonosis-ledger handoff`, and two JSON
277
+ * files off disk.
278
+ *
279
+ * Every binary is an OPTION with a default rather than a hardcoded path — law 6, and the practical
280
+ * reason: `claude` is at `~/.local/bin/claude` on one machine and somewhere else on the next, and a
281
+ * test must be able to point them at something that is not a model.
282
+ */
283
+ declare const realSeams: ({ claude, gh, ledger, root }: SeamOptions) => ShiftSeams;
284
+
285
+ export { AUTH_CLASSES, CAP_DEFAULTS, type Caps, type ContainedRun, type CycleOutcome, type DeniedSnapshot, type MainStatus, PROFILE_PATH, RAILS_DENY_WRITE, type RunContainedInput, type RunShiftInput, type SandboxProfile, type SandboxProfileInput, type SeamOptions, type ShiftCycle, type ShiftOutcome, type ShiftSeams, type Spawn, type SpawnResult, UNSANDBOXED, canonicalise, deniedWrites, loadCaps, needsAuth, realSeams, renderBubblewrap, renderSeatbelt, runContained, runShift, sandboxProfile, sessionOf, snapshotDenied };
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ import {
2
+ AUTH_CLASSES,
3
+ CAP_DEFAULTS,
4
+ PROFILE_PATH,
5
+ RAILS_DENY_WRITE,
6
+ UNSANDBOXED,
7
+ canonicalise,
8
+ deniedWrites,
9
+ loadCaps,
10
+ needsAuth,
11
+ realSeams,
12
+ renderBubblewrap,
13
+ renderSeatbelt,
14
+ runContained,
15
+ runShift,
16
+ sandboxProfile,
17
+ sessionOf,
18
+ snapshotDenied
19
+ } from "./chunk-SQQS5NHC.js";
20
+ export {
21
+ AUTH_CLASSES,
22
+ CAP_DEFAULTS,
23
+ PROFILE_PATH,
24
+ RAILS_DENY_WRITE,
25
+ UNSANDBOXED,
26
+ canonicalise,
27
+ deniedWrites,
28
+ loadCaps,
29
+ needsAuth,
30
+ realSeams,
31
+ renderBubblewrap,
32
+ renderSeatbelt,
33
+ runContained,
34
+ runShift,
35
+ sandboxProfile,
36
+ sessionOf,
37
+ snapshotDenied
38
+ };
@@ -0,0 +1,158 @@
1
+ import {
2
+ PROFILE_PATH,
3
+ canonicalise,
4
+ deniedWrites,
5
+ loadCaps,
6
+ realSeams,
7
+ renderBubblewrap,
8
+ renderSeatbelt,
9
+ runContained,
10
+ runShift,
11
+ sandboxProfile,
12
+ snapshotDenied
13
+ } from "./chunk-SQQS5NHC.js";
14
+
15
+ // src/rails-cli.ts
16
+ import { spawnSync } from "child_process";
17
+ import { mkdirSync, writeFileSync } from "fs";
18
+ import { dirname, resolve } from "path";
19
+ var REFUSED = 2;
20
+ var USAGE = `usage: geonosis-rails <command>
21
+
22
+ profile [--root <dir>] [--allow-write <path> ...] [--deny-write <path> ...] [--deny-read <path> ...]
23
+ [--allow-net] [--format seatbelt|bwrap] [--out <file>]
24
+ contain [--root <dir>] [--allow-write <path> ...] [--deny-write <path> ...] [--allow-net] <command>
25
+ shift --goal <command> [--cycles <n>] [--root <dir>] [--json]
26
+ [--claude <bin>] [--gh <bin>] [--ledger <bin>]`;
27
+ var refuse = (message) => {
28
+ process.stderr.write(`geonosis-rails: ${message}
29
+ `);
30
+ process.exit(REFUSED);
31
+ };
32
+ var args = process.argv.slice(2);
33
+ var command = args[0];
34
+ var valueAfter = (flag) => {
35
+ const at = args.lastIndexOf(flag);
36
+ if (at === -1) return void 0;
37
+ const next = args[at + 1];
38
+ if (next === void 0 || next.startsWith("--")) refuse(`${flag} needs a value`);
39
+ return next;
40
+ };
41
+ var valuesOf = (flag) => {
42
+ const found = [];
43
+ for (let at = 0; at < args.length; at += 1) {
44
+ if (args[at] !== flag) continue;
45
+ const next = args[at + 1];
46
+ if (next === void 0 || next.startsWith("--")) refuse(`${flag} needs a value`);
47
+ found.push(next ?? "");
48
+ }
49
+ return found;
50
+ };
51
+ var root = resolve(process.cwd(), valueAfter("--root") ?? ".");
52
+ var profile = () => sandboxProfile({
53
+ allowNet: args.includes("--allow-net"),
54
+ allowWrite: valuesOf("--allow-write"),
55
+ denyRead: valuesOf("--deny-read"),
56
+ denyWrite: valuesOf("--deny-write"),
57
+ root
58
+ });
59
+ if (command === void 0 || command === "--help" || command === "-h") {
60
+ process.stdout.write(`${USAGE}
61
+ `);
62
+ process.exit(command === void 0 ? REFUSED : 0);
63
+ }
64
+ if (command === "profile") {
65
+ const format = valueAfter("--format") ?? "seatbelt";
66
+ if (format !== "seatbelt" && format !== "bwrap") {
67
+ refuse(`no "${format}" renderer \u2014 known formats: seatbelt, bwrap`);
68
+ }
69
+ const real = canonicalise(profile());
70
+ const text = format === "bwrap" ? `${renderBubblewrap(real).join(" ")}
71
+ ` : renderSeatbelt(real);
72
+ const out = valueAfter("--out");
73
+ if (out !== void 0) {
74
+ const at = resolve(root, out);
75
+ mkdirSync(dirname(at), { recursive: true });
76
+ writeFileSync(at, text);
77
+ }
78
+ process.stdout.write(text);
79
+ process.exit(0);
80
+ }
81
+ if (command === "contain") {
82
+ const positional = args.slice(1).filter((one, at) => {
83
+ if (one.startsWith("--")) return false;
84
+ const before2 = args[at];
85
+ return before2 !== "--root" && before2 !== "--allow-write" && before2 !== "--deny-write" && before2 !== "--deny-read" && before2 !== "--format";
86
+ });
87
+ const toRun = positional.at(-1);
88
+ if (toRun === void 0) refuse(`contain needs a command to run \u2014 ${USAGE}`);
89
+ const built = profile();
90
+ const before = snapshotDenied(root, built.denyWrite);
91
+ const done = runContained({
92
+ command: toRun ?? "",
93
+ has: (binary) => spawnSync("command", ["-v", binary], { shell: true }).status === 0,
94
+ profile: built,
95
+ root,
96
+ spawn: (binary, argv, cwd) => {
97
+ const ran = spawnSync(binary, argv, {
98
+ cwd,
99
+ encoding: "utf8",
100
+ // No argv means the command is going through a shell as one string — the fail-open path.
101
+ shell: argv.length === 0,
102
+ stdio: "inherit"
103
+ });
104
+ return { exitCode: ran.status ?? -1, stderr: "", stdout: "" };
105
+ }
106
+ });
107
+ const wrote = deniedWrites(root, built.denyWrite, before, [PROFILE_PATH]);
108
+ for (const one of wrote) process.stderr.write(`geonosis-rails: ${one}
109
+ `);
110
+ if (wrote.length > 0) {
111
+ process.stderr.write(
112
+ "geonosis-rails: the scored process wrote to a path it may not. That is the rail, and it holds whether or not the kernel was there to enforce it.\n"
113
+ );
114
+ }
115
+ process.exit(done.exitCode === 0 && wrote.length > 0 ? 1 : done.exitCode);
116
+ }
117
+ if (command === "shift") {
118
+ const goal = valueAfter("--goal");
119
+ if (goal === void 0) refuse(`shift needs --goal <command> \u2014 ${USAGE}`);
120
+ const raw = valueAfter("--cycles") ?? "1";
121
+ const cycles = Number(raw);
122
+ if (!Number.isInteger(cycles) || cycles < 1) {
123
+ refuse(`--cycles takes a whole number of one or more, got "${raw}"`);
124
+ }
125
+ let caps;
126
+ try {
127
+ caps = loadCaps(root);
128
+ } catch (error) {
129
+ refuse(error.message);
130
+ }
131
+ const outcome = runShift({
132
+ caps: caps ?? { auth: [], ciFixes: 2, draftPr: true, revisions: 2, stopBlocks: 5 },
133
+ cycles,
134
+ goal: goal ?? "",
135
+ root,
136
+ seams: realSeams({
137
+ claude: valueAfter("--claude") ?? "claude",
138
+ gh: valueAfter("--gh") ?? "gh",
139
+ ledger: valueAfter("--ledger") ?? "geonosis-ledger",
140
+ root
141
+ })
142
+ });
143
+ if (args.includes("--json")) {
144
+ process.stdout.write(`${JSON.stringify(outcome, null, 2)}
145
+ `);
146
+ } else {
147
+ for (const cycle of outcome.cycles) {
148
+ process.stdout.write(` cycle ${cycle.number} exit ${cycle.exitCode}
149
+ `);
150
+ }
151
+ process.stdout.write(
152
+ `${outcome.parked ? `shift PARKED at cycle ${outcome.parkedAt}` : "shift complete"} \u2014 ${outcome.reason}
153
+ `
154
+ );
155
+ }
156
+ process.exit(outcome.parked ? 1 : 0);
157
+ }
158
+ refuse(`unknown command "${command}" \u2014 ${USAGE}`);
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@geonosis/rails",
3
+ "version": "1.0.0",
4
+ "description": "Sandbox profiles for an unattended run — data to a macOS seatbelt profile or a Linux bubblewrap argv — that fail OPEN and LOUD when they cannot be applied, plus the after-the-fact proof that the scored run never wrote the scoreboard.",
5
+ "keywords": [
6
+ "sandbox",
7
+ "seatbelt",
8
+ "bubblewrap",
9
+ "agent",
10
+ "rails",
11
+ "unattended",
12
+ "ci"
13
+ ],
14
+ "homepage": "https://github.com/microcompanies/geonosis/tree/main/packages/rails",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/microcompanies/geonosis.git",
18
+ "directory": "packages/rails"
19
+ },
20
+ "license": "Apache-2.0",
21
+ "type": "module",
22
+ "main": "dist/index.js",
23
+ "bin": {
24
+ "geonosis-rails": "bin/geonosis-rails.mjs"
25
+ },
26
+ "exports": {
27
+ ".": "./dist/index.js"
28
+ },
29
+ "files": [
30
+ "bin",
31
+ "dist"
32
+ ],
33
+ "engines": {
34
+ "node": ">=22"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "build": "tsup",
41
+ "typecheck": "tsc --noEmit"
42
+ }
43
+ }