@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 +202 -0
- package/README.md +187 -0
- package/bin/geonosis-rails.mjs +4 -0
- package/dist/chunk-SQQS5NHC.js +423 -0
- package/dist/index.d.ts +285 -0
- package/dist/index.js +38 -0
- package/dist/rails-cli.js +158 -0
- package/package.json +43 -0
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,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
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|