New
Apps 125Makers 125This week 125
Promote your app−30%
0views in
the last 30 days

SIwithin

Run an agent CLI unattended inside a folder fence and a dollar budget

Built with Claude Code

by metahumansworld · from INIndia · added Oct 29, 2026
no ratings0 comments
Open app Code

SIwithin runs an agent CLI unattended on a brief, inside a fence around the folders you give it and under a dollar budget, and tells you afterwards, in one ledger line, whether the run was done, finished, contract unmet, killed at its budget or timer, stopped by the CLI's own flag, or refused before it began, and what it cost by the proxy's count. done means the CLI finished and the brief's own test, when you give one (--done "<text>" present in the result, --absent "<text>" not), passed; a run that stopped is never written as done, and the exit status is 0 only when it was. It keeps three promises with a tool and not with hope:

  1. The run touches only the folders it was given. Your home folder is refused, for reading and writing, and the run's folders are allowed back.
  2. The run cannot spend more than the dollars it was given. A budget per run, a cap across all runs, a timer on every run, and, for the Claude adapter, a loopback proxy that is the one door to the API: the run holds a per-run token and not your key, and every request it makes, however it makes it, is metered there and refused past the budget. Where a CLI cannot be stopped mid-run, the tool refuses it unless you say you accept that (see "The ledger" and "The Codex adapter").
  3. Every run leaves one line saying what it cost and whether it was done.

Why not the CLI's own budget flag? Claude Code's --max-budget-usd and turn limit are kept by the process you are trying to bound, which holds your key and can reach the API by any path it likes, a script or a second client included, and they say nothing about what the run touched or left running. SIwithin keeps the key outside the fence: the run holds a per-run token, every request goes through a loopback proxy that prices it and refuses past the budget, a cap holds across all runs, the home folder is refused by the kernel, and a walk at the end kills what the run left behind and counts it. The CLI's flag is still passed, as the first of three stops (The ledger).

Works with the Claude Code and OpenAI Codex command-line tools. SIwithin is an independent project; it is not made, endorsed or sponsored by Anthropic or OpenAI. Claude, Claude Code, OpenAI and Codex are the names of their owners, used here only to say which programs SIwithin can drive. You remain bound by each vendor's terms of service and usage policies when you use their CLIs through SIwithin; SIwithin changes none of them.

SIwithin runs on macOS, where the fence is a sandbox-exec profile, and on Linux, where it is bubblewrap with socat for the proxy's bridge (Install, "The fence"). On Windows, and on anything else, it refuses to run, in one line, rather than run without a fence. The Linux fence has run the suite's fake CLIs (Part 4) and the real CLI once, a two-turn Haiku brief on 26 October 2026, both on one Ubuntu 24.04 VM (SECURITY.md, "Not verified in this release").

Install

SIwithin is one file, Python 3.10 or newer, standard library only. It makes no network calls of its own and sends no telemetry; the proxy it starts for a run listens on a loopback port and talks only to the API host you configure.

git clone <this repository> && cd siwithin
python3 siwithin.py --version
# optional: put it on your PATH
install -m 755 siwithin.py ~/.local/bin/siwithin

You also need the CLI you want to drive, installed and on your PATH, and an API key for it (see "Auth").

Linux. The fence there is bubblewrap, and the key proxy's bridge is socat; install both with the distribution's package manager (apt install bubblewrap socat, dnf install bubblewrap socat). SIwithin looks for them at /usr/bin/bwrap and /usr/bin/socat, and before a run, a dry run included, runs bwrap once on /bin/true to see that it can set up its namespaces; if it cannot, the run is refused with bwrap's own last line quoted, and never run unfenced. Socat is needed only under the default network = "proxy"; --network open does not use it. On Ubuntu 23.10 and later, unprivileged user namespaces are restricted by AppArmor (/proc/sys/kernel/apparmor_restrict_unprivileged_userns reads 1) and the bubblewrap package ships no profile for it, so the preflight fails with bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted. There are two remedies, both root's to apply, and SIwithin applies neither. The first is a profile for the binary, in /etc/apparmor.d/bwrap:

abi <abi/4.0>,
include <tunables/global>
profile bwrap /usr/bin/bwrap flags=(unconfined) {
  userns,
  include if exists <local/bwrap>
}

loaded with sudo apparmor_parser -r /etc/apparmor.d/bwrap (the Claude Code documentation's sandboxing page, read 26 October 2026, gives the same profile and reloads with sudo systemctl reload apparmor). It applies to bwrap itself and not to what it runs. The second is sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0, for the boot, which lifts the restriction for every program. Measured on Ubuntu 24.04.4 (kernel 6.8, bubblewrap 0.9.0): without either, the preflight's command failed as above; with the profile loaded and the sysctl untouched, it passed. Debian 12, Fedora and Arch have no such restriction, from memory, not measured. Inside a container, bubblewrap needs the container to allow user namespaces; that has not been tried.

The smoke test (python3 tests/smoke.py) passes on the Python it was written on, 3.9.6. 3.10 is the supported floor and the code avoids newer syntax, but it has not been run on 3.10 or newer.

Quick start, Claude Code

mkdir -p ~/.config/siwithin && echo 20 > ~/.config/siwithin/cap       # all runs together may spend $20
export ANTHROPIC_API_KEY=...                                     # see "Auth"; the tool reads it once, outside the fence, and the run gets a per-run token in its place

python3 siwithin.py --dir ~/work/proj --model sonnet --dry-run < brief.md      # print everything, run nothing
python3 siwithin.py --dir ~/work/proj --model sonnet --budget 2 \
    --done "ALL TESTS PASS" --out notes.md < brief.md

--dir is the run's folder: the CLI works in it and may write in it (--readonly makes it read-only). --read <folder> adds folders the run may read. --model takes an id or an alias (haiku, sonnet, opus).

Quick start, Codex

mkdir -p ~/.config/siwithin && echo 20 > ~/.config/siwithin/cap
export CODEX_API_KEY=...

python3 siwithin.py --adapter codex --model gpt-5.3-codex --soft-budget --network open --dir ~/work/proj --dry-run < brief.md
python3 siwithin.py --adapter codex --model gpt-5.3-codex --soft-budget --network open --budget 2 --dir ~/work/proj < brief.md

Two flags are required for this adapter, for reasons in "The Codex adapter": --model (the CLI reports tokens and no cost, so a run is priced by model id) and --soft-budget (the CLI reports usage only when a turn ends). This adapter was built from documentation and has not been run against a live CLI. It runs with auth = "env": the key is in the run's environment (see "Auth"), and since there is no proxy there is no port for the network wall to allow, so it needs --network open, or network = "open" under [fence] in config.toml; under the default the run is refused rather than run with no network (see "The fence").

Auth

SIwithin never parses, stores, prints or logs a key, and writes one into no file, log line or error message. How the key reaches the API depends on the adapter's auth field.

The Claude adapter, auth = "proxy" (the default from 0.3.0). Before launch, outside the fence, the runner reads the key once: from ANTHROPIC_API_KEY in its own environment, or else from the first line printed by [auth] key_command in config.toml, a command of yours that prints the key (a keychain lookup, for example). It keeps the key and starts a small HTTP server on a loopback port of its own, standard library only. The run's environment holds ANTHROPIC_API_KEY=siwithin-<32 characters>, a token new for every run and worth nothing elsewhere, and ANTHROPIC_BASE_URL=http://127.0.0.1:<port>. Every request the run makes, by the CLI, by hand or by a second CLI it starts, passes that door: the proxy takes only the run's token (anything else gets 401 and goes nowhere), forwards only the paths on [auth] paths (/v1/messages and /v1/messages/count_tokens by default; any other path gets 403 and the real key is never attached), adds the real key, relays the response as it streams, and prices each /v1/messages response at the [prices] rates from its usage. Once the total reaches the budget every later /v1/messages request gets 402, the first refusal kills the run's processes as the streamed stop does, and the proxy keeps refusing until the runner exits. When the run ends the proxy is sealed, its log closed and its server shut, so the token is worth nothing. One line per request goes to proxy.jsonl in the run's folder (path, model, status, usage, cost, the running total, the client's port, why it was refused; never a body, a key or the token). A settings.json in the run's config directory that names apiKeyHelper is refused before launch, since the CLI would run the helper inside the fence where the run can read it: put the helper's command in [auth] key_command instead, which the runner runs outside the fence. --dry-run prints auth: proxy 127.0.0.1:<port> and starts nothing.

auth = "env" copies the key's variable into the run by name, as 0.2.1 did. It is the Codex adapter's only mode and an opt-in for the Claude adapter (the checks warn). In this mode the key is in the run's environment and inherited by every command the run starts, and the network wall cannot be raised, since there is no proxy port to allow: the run is refused under the default [fence] network = "proxy" and needs network = "open" (or --network open), so the run can send the key anywhere and spend with it outside the ledger. Use a key made for SIwithin alone, in a workspace with a spend limit set in the provider's console, and treat it as exposed to whatever text the run reads (SECURITY.md, "Known gaps").

Each adapter gets a config directory under SIwithin's home (claude-config/, codex-config/), shared by its runs, with no login in it, so a CLI cannot quietly sign with a login and bill that instead of your key.

  • Claude Code finds a key in the ANTHROPIC_API_KEY environment variable and an API address in ANTHROPIC_BASE_URL; under the proxy those are the run's token and the loopback port, and the CLI ran so in the live red passes of 14 and 15 October 2026 (Tests). To point the CLI at a settings file of your own, add ["--settings", "/path/to/settings.json"] to extra_args in config.toml; one that names apiKeyHelper is refused, as above.
  • Codex CLI reads CODEX_API_KEY for codex exec (its documentation). Its saved ChatGPT login lives in $CODEX_HOME/auth.json; because each run has its own CODEX_HOME, that login is not used, and a dollar budget means nothing for plan billing anyway.

--require-api-key (on by default for the claude adapter, refused for codex, which has no way to check) asks the CLI before launch how it is signed in, and checks the key source in the CLI's first event, which under the proxy must be ANTHROPIC_API_KEY, the token's variable. If either says a login would be billed, the run is refused or killed, and the message says why: a CLI with a login in its config directory can sign with that login while its events name an API key. --no-require-api-key accepts a login.

The fence

SIwithin generates a macOS sandbox-exec profile for each run, from a template, with the home folder derived at run time. --dry-run prints it; each run folder keeps it as fence.sb. The bullets below describe the macOS profile; on Linux the fence is bubblewrap's arguments, kept in the same file, and the "Linux" paragraph after them says what each does and how it differs.

  • The whole home is refused for reads and writes.
  • Allowed back: the run's folders (readable; the first writable unless --readonly), the CLI's binary, its caches and logs under ~/Library (readable and writable), its keychain files (readable only), Python user packages, the shell's rc files, git's config, and the run's own config directory.
  • Inside the CLI's config directory, only its state paths are writable (config_write in the config): transcripts, sessions, file history, todos, caches and the like. Settings, the state file, memory, commands, skills, agents, hooks, plugins, scheduled tasks and a key helper kept there stay as the owner left them, and the memory folder under each transcript folder stays unwritable too, so a run leaves no instruction for the next one.
  • Writes are also refused in /private/tmp, /tmp, /opt, /usr/local, /Applications, /Library and /Volumes. The CLI's temp root is moved into the run's folder, for a CLI that has a variable for it.
  • open, screencapture, osascript, say, afplay, launchctl, crontab, at and batch may not be started (deny_exec; by path, so a copy elsewhere is not stopped).
  • SIwithin's own home (config, cap, ledger, other runs' folders) is refused to the run, except the adapter's config directory, where the CLI keeps the transcripts of its earlier runs: a run can read those, briefs included (SECURITY.md, "Known gaps").
  • --dir and --read refuse the home root and any folder holding it, ~/.ssh, ~/.config, ~/Library, each adapter's own config directory (~/.claude, ~/.codex), SIwithin's home, and any folder listed under [never] in config.toml. The fence enforces only what it is told, so these are refused up front.
  • The run's environment is a short list ([env] pass and the adapter's env_pass). The rest of your shell's environment is not inherited.
  • The network (the wall, 0.4.0). With the proxy adapter the profile refuses every socket, DNS and binds included, except the key proxy's loopback port ([fence] network = "proxy", the default): the CLI reaches the API through the proxy and nothing else leaves the machine from the run, and a local server on another port is refused too. Each refusal is reported by the kernel under the run's own mark (its folder's name); when the run ends the runner reads the report from the unified log by that mark, twice two seconds apart since the kernel lands its coalesced lines late, and counts it in summary.json (network: refused, by target and by process, the CLI's own pid told from the others', and late for what landed between the reads) and on one printed line. network = "report" leaves the network open and counts every socket the run opens instead, a survey of how many sockets an ordinary run opens besides the proxy's, on which ports, and whether it binds any (the kernel names a socket by kind and port, remote:*:443, or a unix socket by path, never by host); "open" is 0.3.0's network, unreported. --network proxy|report|open overrides the config for one run. An adapter with auth = "env" has no proxy and is refused under "proxy", with the way out named. Run live on 15 October 2026 (Status).
  • The run's processes are the runner's to end. While the CLI lives the runner keeps the set of its descendants, and at every stop (budget, timer, interrupt, the CLI's own exit) it reads the process table for any process of yours carrying the run's marker, kills the set, then the group, and looks again until nothing is left, holding the lock until then. summary.json says how many it found alive that the group kill alone would not have reached (outlived, 0 on a clean run). It needs ps; without it the run notes so.

What the fence does not stop: what the run sends to the API itself, inside request bodies the proxy relays unread (the model's context is the one way out the wall leaves), and the whole network under [fence] network = "report" or "open", which an auth = "env" adapter needs; reads outside the home; writes outside the home and the listed roots; whatever the run writes into its own folder and you later run (scripts, git hooks); a key exported in a shell rc file, which the run can read; a process that scrubbed its marker and was re-parented before the walk looked; and anything the CLI does in its own cloud sandbox or on a remote machine, where the fence is not. Read SECURITY.md before relying on it.

Linux (bubblewrap, 0.6.0). The same promises, from bubblewrap's arguments, which the run folder keeps in fence.sb one to a line under a first line that says what they are, and --dry-run prints where macOS prints the profile. Each point is a rule, in the order bubblewrap applies them, a later bind over an earlier mount winning as a later rule does in the profile:

  1. The tree, read-only. / is bound read-only, with a fresh /dev and /proc and a private tmpfs on /tmp that goes with the run. The whole tree is readable, as on macOS, and nothing in it is writable, which is stricter: the macOS profile refuses writes in the home and in the deny_write roots and lets the rest through. deny_write is read on Linux for the dry run's text only, and is redundant there. The CLI's own temp root is moved into the run's folder, on both platforms, by the adapter's tmp_env. Because /tmp is the run's own, a CLI kept under /tmp has its directory bound back in, as one under the home does.
  2. The home, refused whole. The home is an empty tmpfs: nothing in it can be read or written. Bound back into it, each only if it exists: read_home, the rc_files and the adapter's own read paths (the CLI's binary directories) read-only, write_home writable. The folders between the home and a path bound back appear as empty directories, the Linux form of "metadata only". The Linux defaults are .cache, .gitconfig and .config/git to read and .cache to write.
  3. The run's folders. Bound read-only, the first writable unless --readonly.
  4. SIwithin's own home, refused. An empty tmpfs again, after the home's binds, so the config, the cap, the ledger and the other runs stay invisible wherever the home is. Back in, from the run's own folder, only its temp root, last-message.txt where the adapter has one, and proxy.sock.
  5. The CLI's config directory. Bound read-only whole, with the config_write paths bound writable (created first if they are missing) and the memory folders bound read-only again over them: each one that exists, and the run's own project's, whose folder may not exist yet, made first. The CLI's name for a project folder is its path with / written as -, as on every transcript folder in the red home; that is all that was seen, so a run folder with any character but letters, digits, / and - in its path gets no memory folder of its own (SECURITY.md, "Known gaps").
  6. deny_exec. Each listed program that exists is bound over with /dev/null, so that starting it fails with permission denied. The Linux list is crontab, at, batch, systemd-run and xdg-open, under /usr/bin; by path, so a copy elsewhere is not stopped, as on macOS.
  7. Namespaces. User, pid, ipc, uts and (where there is one) cgroup, and --die-with-parent; no --new-session, so the CLI stays in the runner's process group and the kill reaches it as it does on macOS. With its own pid namespace the run's /proc lists only the run's processes, so the runner's environment is not there to be read through its ancestors, which the env-key red brief has the task try; that brief therefore proves less on Linux than on macOS (SECURITY.md). When the command exits the namespace dies with it, so what the run detached is gone; the walk still runs and still counts (outlived). The walk reads ps -A -ww -o pid=,tty=,time=,args= e for the run's mark

Description taken from the repository README.

Ratings

–
0 ratings
5 0
4 0
3 0
2 0
1 0

Rate this app

Sign in to rate

Comments 0

Sign in to comment

No comments yet.

Related apps

#agents · #sandbox · #finance