Skip to content

Commit aead515

Browse files
committed
sandbox: single active policy file, repo .sandboxrc else ~/.config/3code/sandboxrc
1 parent 7654619 commit aead515

12 files changed

Lines changed: 225 additions & 184 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ A working example lives in [`example/webserve.nim`](example/webserve.nim): a web
8080

8181
**unreleased** - patient retry, single-file sandbox policy
8282

83-
- **Single-file sandbox policy.** The sandbox policy now lives in exactly one file, `.3code/sandbox` in the project directory; the old two-level cascade (`~/.config/3code/sandbox` then the repo file) is gone. When the project file is missing at launch it is created from `~/.3code/sandbox`, which itself is created from the built-in default when missing, so the sandbox is still always on and you can now customize what every new project starts with by editing the home file. One implicit rule is appended after every rule in the file: the policy file itself is read-only, so the agent cannot widen its own sandbox by editing the policy (the `is under a writable rule` warning this replaces is gone).
83+
- **Single-file sandbox policy.** The old two-level cascade is gone: exactly one policy file is active at a time, the repo `.sandboxrc` when it exists, else the user file `~/.config/3code/sandboxrc`. The user file is created from the built-in default on first run, so the sandbox stays always on and you can change what every project without its own `.sandboxrc` gets by editing that one file. The first `:sandbox allow|readonly|deny` in a project materializes `.sandboxrc` by copying your user file, so project rules start from your baseline.
8484
- **Patient retry.** Retryable API failures (429 usage limits, 5xx, network errors) now keep retrying on one shared exponential backoff capped at 2048s, for up to ~64 attempts (~36h), instead of surfacing after a short probe. A long-running session rides out a provider's rolling usage window or a network dropout (the train ride) without dropping back to the prompt. The underlying error message and attempt counter keep scrolling by as transcript lines so you can see what is being waited out. `:retry on|off` toggles it (default on; `off` keeps the ~1-minute initial ramp-up so transient blips still self-heal); persisted in `[settings]` as `patient_retry`. Ctrl-C cancels a running hold at any time.
8585

8686
**0.6.0** - filesystem sandbox backed by nimbox, folded into the `box` subcommand

‎config.example‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ current = "baseten.glm5"
2020
; exa-key = "..."
2121
; brave-key = "..."
2222
; notify = off ; turn off the desktop notification that fires when a turn ends (on by default)
23-
; sandbox = off ; disable filesystem sandbox enforcement (on by default: .3code/sandbox policy)
23+
; sandbox = off ; disable filesystem sandbox enforcement (on by default: .sandboxrc in the project, else ~/.config/3code/sandboxrc)
2424
; patient_retry = off ; disable patient retry of 429/5xx/network errors (on by default: ~36h hold so a long-running session rides out a usage limit or network dropout without dropping to the prompt)
2525
; sandbox_wall_warn = off ; silence the Windows "wall not set up" warning (host rules in policy, no `3code wall setup-windows` yet)
2626
; bash_path = "C:\\tools\\msys64\\usr\\bin\\bash.exe" ; Windows only: override bash detection

‎docs/manual.md‎

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -360,26 +360,22 @@ Sub-agents are not supported because both research and user feedback says they a
360360
## Sandbox
361361

362362
3code confines every tool call to a filesystem sandbox you define. The
363-
sandbox is a plain text policy built from a cascade of files, each filling
364-
in for the ones below it:
365-
366-
1. **system** - `~/.config/3code/sandbox`, next to your config.
367-
2. **repo** - `.sandboxrc` in your project directory.
368-
369-
A missing level is not empty: it contributes the built-in default
370-
(`deny /`, `allow /tmp`, then `allow` for the cwd), so the
371-
sandbox is always on. The
372-
effective policy is the two texts concatenated (system then repo) and
373-
parsed once, so a repo-level `deny /` cleanly resets anything a
374-
system file opened above it. 3code never writes a sandbox file into your
375-
project on first run; the default lives in memory until you create one
376-
(explicitly, or via the `:sandbox` edit commands, which seed the repo
377-
file on first use). Yolo mode (everything writable) is fine but you have
378-
to ask for it explicitly.
363+
sandbox is a plain text policy living in exactly one file at a time:
364+
365+
1. **repo** - `.sandboxrc` in your project directory, when it exists.
366+
2. **user** - `~/.config/3code/sandboxrc`, next to your config,
367+
otherwise.
368+
369+
There is no cascade and no merging: the repo file wins outright, the
370+
user file is the fallback. The user file is created from the built-in
371+
default on first run, so the sandbox is always on and you can change
372+
what every project without its own `.sandboxrc` gets by editing that
373+
one file. Yolo mode (everything writable) is fine but you have to ask
374+
for it explicitly.
379375

380376
The sandbox is enforced two ways. Bash commands run through `3code box`,
381377
the built-in sandwall sandbox (Landlock on Linux, Seatbelt on macOS), which
382-
3code re-execs itself as; the box process loads the policy files itself,
378+
3code re-execs itself as; the box process loads the policy file itself,
383379
so every command launches on the freshest policy. The in-process
384380
read/write/patch tools check paths against the same policy (reloaded when
385381
the file changes) in the 3code process. Both layers run the same sandwall
@@ -433,7 +429,8 @@ writable project). Host rules (``allow api.example.com``,
433429
``allow 1.2.3.4:8080``, ``allow *`` for no network restrictions) fence
434430
the network egress of sandboxed bash commands through the wall proxy.
435431

436-
On first run in a new directory, 3code uses this default:
432+
On first run, 3code initializes `~/.config/3code/sandboxrc` with this
433+
default:
437434

438435
```
439436
deny /
@@ -490,8 +487,9 @@ REPL commands which append a rule and reload immediately:
490487
```
491488

492489
The first `allow`/`readonly`/`deny` in a project creates the `.sandboxrc`
493-
file (seeded with the built-in default, then your appended rule) so you
494-
have something concrete to version and share. `:sandbox off` disables
490+
file by copying your user file, then appends the rule, so project rules
491+
start from your baseline and you have something concrete to version and
492+
share. `:sandbox off` disables
495493
enforcement entirely for the session (bash runs unconfined, in-process
496494
checks pass through); it persists in `[settings]` as `sandbox = off`. This
497495
is the only way to run without a sandbox short of editing the file.

‎src/threecode.nim‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,14 @@ proc ensureBash() =
100100
quit ExitUsage
101101

102102
proc initSandbox(cwd: string) =
103-
## Load the cascaded sandbox policy into the global state and resolve
104-
## this binary's own path for bash wrapping. The policy is built from the
105-
## system file (next to `~/.config/3code/config`) and the repo file
106-
## (`.sandboxrc`), each falling back to the built-in default when
107-
## absent, so no file is ever written into the project on first run. When
108-
## `sandboxEnabled` is false (the `[settings] sandbox = off` switch), this
109-
## does nothing: bash runs unconfined and the in-process checks pass
110-
## through (`active` stays false).
103+
## Load the sandbox policy into the global state and resolve this
104+
## binary's own path for bash wrapping. Exactly one file is active:
105+
## the repo `.sandboxrc` when it exists, else the user file
106+
## `~/.config/3code/sandboxrc`, which is initialized from the
107+
## built-in default on first run so the sandbox is always on. When
108+
## `sandboxEnabled` is false (the `[settings] sandbox = off` switch),
109+
## this does nothing: bash runs unconfined and the in-process checks
110+
## pass through (`active` stays false).
111111
# The provider stub binary (the tty/visual test harness) skips sandbox
112112
# setup entirely so its behaviour matches the pre-sandbox binary. Those
113113
# tests drive REPL rendering, not enforcement, and `active=true` plus the
@@ -118,7 +118,8 @@ proc initSandbox(cwd: string) =
118118
return
119119
if not sandboxEnabled:
120120
return
121-
sandbox.current = sandbox.loadCascaded(cwd)
121+
discard sandbox.ensureUserPolicy()
122+
sandbox.current = sandbox.loadPolicy(cwd)
122123
sandbox.active = true
123124
# The bash tool re-execs this binary as `3code box restrict ...`, so
124125
# resolve our own path once. The box subcommand is always compiled in, but
@@ -353,10 +354,11 @@ proc main() =
353354
if prompt == "":
354355
restoredDraft = loadPendingDraft(session.cwd)
355356

356-
# Sandbox is mandatory: the cascaded policy is loaded, each level
357-
# falling back to the built-in default when absent. Paths resolve
358-
# relative to the session cwd so the policy follows the project, not
359-
# the binary.
357+
# Sandbox is mandatory: the user policy file is initialized from
358+
# the built-in default when absent, then the single active file
359+
# (repo `.sandboxrc` or the user file) is loaded. Paths resolve
360+
# relative to the session cwd so the policy follows the project,
361+
# not the binary.
360362
initSandbox(session.cwd)
361363

362364
try:

‎src/threecode/box.nim‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,21 +40,19 @@ Usage:
4040
children are confined: writes outside the writable paths fail with
4141
EACCES.
4242
43-
With --policy, the writable/read-only sets come from the given policy
44-
files (cascaded in order, system first, relative targets resolved
45-
against the project dir: the parent of a `.sandboxrc` file, else cwd).
46-
Explicit
47-
RWPATH/ROPATH args union with the policy sets. Policy files themselves
48-
are forced read-only inside the sandbox.
43+
With --policy, the writable/read-only sets come from the given
44+
policy file (relative targets resolve against the project dir: the
45+
parent of a `.sandboxrc` file, else cwd). Explicit RWPATH/ROPATH
46+
args union with the policy sets. The policy file itself is forced
47+
read-only inside the sandbox.
4948
5049
System dirs (/usr, /bin, /lib, /dev/*, etc.) are always read-only so the
5150
command's binaries, libs, and device nodes stay runnable; --ro adds to
5251
that set, it does not replace it.
5352
5453
Examples:
5554
3code box restrict /tmp /home/me/work -- ls -la
56-
3code box --policy ~/.config/3code/sandbox --policy .sandboxrc \
57-
restrict -- make test
55+
3code box --policy .sandboxrc restrict -- make test
5856
3code box restrict . -- make test
5957
6058
Landlock is monotonic: the restriction is permanent for this process and all
@@ -111,6 +109,9 @@ proc resolvePolicy(a: BoxArgs): tuple[writable, readonly, denied: seq[string];
111109
var denied: seq[string]
112110
var hostRules = 0
113111
if a.policies.len > 0:
112+
# Exactly one active policy file: 3code passes the repo `.sandboxrc`
113+
# when it exists, else the user file. Multiple --policy args are
114+
# still accepted (concatenated) for standalone box use.
114115
var texts: seq[string]
115116
for f in a.policies:
116117
texts.add(if fileExists(f): readFile(f) else: "")

‎src/threecode/library.nim‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,13 @@ proc initAgentSession*(opts: AgentOptions): AgentSession =
147147
materializeBuiltinSkills()
148148

149149
# Config first (it reads `[settings] sandbox = off`), then the
150-
# sandbox: same cascaded policy and order as the CLI. Paths resolve
150+
# sandbox: same single-file policy as the CLI. Paths resolve
151151
# against the session cwd so the policy follows the project.
152152
var colorKeys: Table[string, string]
153153
(activeCurrent, activeProviders, colorKeys) = loadStateOrEmpty(configPath())
154154
if sandboxEnabled:
155-
sandbox.current = sandbox.loadCascaded(cwd)
155+
discard sandbox.ensureUserPolicy()
156+
sandbox.current = sandbox.loadPolicy(cwd)
156157
sandbox.active = true
157158
sandbox.procboxExe = sandbox.findProcbox()
158159
if not sandbox.backendWorks(sandbox.procboxExe):

0 commit comments

Comments
 (0)