____ __
/ __ )____ ____ ____/ /___ _____ ____
/ __ / __ \/ __ \/ __ / __ `/ __ `/ _ \
/ /_/ / /_/ / / / / /_/ / /_/ / /_/ / __/
/_____/\____/_/ /_/\__,_/\__,_/\__, /\___/
/____/
bondage-core is the local C launcher/policy engine.
Runtime shape:
alias -> bondage -> envchain-xtra -> nono -> exact target
Why bother:
- you cannot let coding agents run loose with live keys
- you cannot assume a familiar command implies a trustworthy dependency tree
- you should not grant broad ambient environment access by default
bondage exists to narrow that trust boundary at launch time. It does not make
bad dependencies good. It makes the launch decision explicit: exact paths,
exact hashes, explicit secret release, explicit sandbox profile, then exact
target.
It also assumes the OS should do the parts the OS is good at. On macOS that means leveraging code-signing identity where available for approvals, and letting Keychain remain the underlying secret store instead of trying to replace it inside the launcher.
That matters in practice for tools like Claude Code on macOS: if you use the normal Claude.ai/OAuth login flow, your default Claude profile should keep Keychain access or the login may fail to persist across sessions.
This repository is intentionally small and local-first. The design goal is a FreeBSD/Capsicum-style trusted launcher:
- small C codebase
- explicit path and fd handling
- fail-closed behavior
- exact absolute execution paths
- no shell logic in the security boundary
For the trust model and deployment shape, see
TRUST_MODEL.md.
Start with GETTING_STARTED.md if you want to install
and use bondage on a new machine.
The launcher architecture is only half the story. The operational glue matters too. A stack like this gets brittle when shell startup, upgrade paths, or helper scripts quietly become part of the trust boundary.
Public lessons worth keeping:
- keep home-shell startup files tiny and stable; let them source the real repo config when readable, instead of making the login shell depend directly on a fragile repo symlink
- keep shell wrappers thin; if real policy lives in shell again, the design has drifted
- treat package-manager upgrades as launch-policy events: repin, verify, then promote
- keep a named repair tier for fixing the launcher stack itself
- treat helper scripts as testable code, not disposable glue
- treat agent-visible hook output as prompt surface: keep it factual and short, and do not inject profile-repair workflows into the transcript
In practice that means:
stable home bootstrap stub
-> readable wrapper file
-> bondage
-> [envchain-xtra]
-> [nono]
-> exact pinned tool
The point is not ceremony. It is to avoid a situation where one unreadable startup file or one stale package-manager path silently drops you onto the raw binary you were trying not to trust.
Sandbox denials should be fixed in managed profiles and launcher config. Hooks may be useful as deterministic guards, but they should not tell an agent to offer choices, wait, stop, or create new profile policy from inside the model transcript.
makeHomebrew tap install:
brew tap nvk/tap
brew install nvk/tap/agent-bondageThe formula name is agent-bondage, but it installs the bondage executable.
If you use profiles with touch_policy = prompt, install the optional macOS
approval helper too:
brew install nvk/tap/touchid-checkSource build for development:
git clone https://github.com/nvk/bondage.git
cd bondage
makeThe Homebrew formula installs only the binary. Your local config, pinned tool artifacts, and thin shell wrappers remain your responsibility.
touchid-check is a small macOS LocalAuthentication helper used by profiles
that set touch_policy = prompt. It prompts for device-owner authentication
using Touch ID when available, with the normal macOS password fallback. Bondage
verifies the helper path and fingerprint before running it, so configs should
pin both touchid and touchid_fp in [global].
The helper can be installed from the same tap:
brew install nvk/tap/touchid-check
touchid-check --helpAfter installing or upgrading it, run bondage repin-globals for configs that
use touch_policy = prompt; the repin step records the concrete Homebrew
Cellar path and fingerprint rather than the /opt/homebrew/bin symlink.
./bondage [--config <path>] status [config]
./bondage [--config <path>] doctor [config]
./bondage [--config <path>] verify <profile> [config]
./bondage [--config <path>] repin <profile> [config]
./bondage [--config <path>] repin-globals [config]
./bondage [--config <path>] chain <profile> [config] [-- args...]
./bondage [--config <path>] exec <profile> [config] [-- args...]
./bondage [--config <path>] argv <profile> [config] [-- args...] # compatibility alias for chain
./bondage hash-file <absolute-path>
./bondage hash-tree <absolute-path>If config is omitted, bondage resolves it in this order:
--config <path>/-c <path>- legacy positional config argument
BONDAGE_CONF~/.config/bondage/bondage.conf, when it exists./.bondage.conf, when it exists./bondage.conf
Do not pass both --config and a positional config path in the same command.
For chain, argv, and exec, everything after -- is preserved as
passthrough tool arguments and is never parsed as bondage options.
Legacy positional config forms remain supported:
bondage verify codex ~/.config/bondage/bondage.conf
bondage chain codex ~/.config/bondage/bondage.conf -- --helpThe less ambiguous form is preferred for new docs and wrappers:
bondage --config ~/.config/bondage/bondage.conf verify codex
bondage --config ~/.config/bondage/bondage.conf chain codex -- --helpAn example config lives at bondage.conf.example.
It is intentionally a small schema/sample file, not the full local profile matrix.
The local ./bondage.conf in this checkout is gitignored and can pin directly
to the live agent artifacts on this machine. A committed ./.bondage.conf is
also supported for repo-local setups, but keep the safety model straight:
host-specific paths, fingerprints, and secret namespaces still belong in local
policy unless they are genuinely valid for every checkout.
Minimal starter nono profiles live in examples/nono/.
Those are the sandbox-side companions to the sample launcher config.
Important:
- paths in the sample config are literal absolute paths
bondagedoes not expand shell variables inside the config- named
[defaults "..."]blocks are opt-in; a profile only consumes them when it declaresinherits = ... bondageitself now prefers the standard config location~/.config/bondage/bondage.confwhen no explicit config is provided- a repo-local
./.bondage.confis a fallback, not a silent override of your user config - the sample config is a pattern to adapt, not a file to use unchanged
- the
examples/nono/profiles are starter patterns, not a complete local tier matrix
Use named defaults to remove repeated launch-policy fragments without moving policy back into shell wrappers:
[defaults "agent-nono"]
nono_allow_cwd = true
nono_allow_dir = /Users/you/Library/Mobile Documents/com~apple~CloudDocs/claude-sandbox
nono_allow_file = /dev/tty
nono_read_file = /dev/urandom
[defaults "codex-target"]
target_kind = native
target = /Users/you/.bondage/tools/codex/0.128.0/codex-aarch64-apple-darwin
target_fp = sha256:replace-me
[defaults "codex-external-sandbox"]
target_arg = --dangerously-bypass-approvals-and-sandbox
[profile "codex"]
inherits = agent-nono,codex-target,codex-external-sandbox
use_envchain = false
use_nono = true
nono_profile = codex
touch_policy = noneRules:
- inheritance is explicit and profile-local
- defaults are applied in order, then profile-local keys override them
nono_allow_cwd = truepasses both--workdir <current directory>and--allow-cwdtononoso$WORKDIRprofile expansion is deterministic- list keys append in order, so inherited
nono_allow_dir,nono_read_dir,nono_allow_file, andnono_read_fileentries come before profile-local entries - repeatable
target_argentries are appended after the verified target and before user passthrough args; this is where tool policy flags belong - old configs without defaults still work
- invalid combinations fail closed, for example inheriting
nono_*settings while settinguse_nono = false
For agent permission modes, prefer target args in config over shell-wrapper flags:
[defaults "claude-auto"]
target_arg = --permission-mode
target_arg = auto
[defaults "codex-external-sandbox"]
target_arg = --dangerously-bypass-approvals-and-sandboxOnly inherit those defaults into profiles that are still protected by the
outer sandbox layer. Do not inherit dangerous target-permission flags into a
rawdog/no-nono profile unless that is the explicit purpose of the profile.
status, verify, doctor, and repin report where inherited pin fields
come from. If repin codex refreshes defaults "codex-target", every profile
that inherits that defaults block gets the new pin. The shared ownership is the
point, but it should be visible in the command output.
Treat upgrades to bondage, nono, agent binaries, interpreters, or package
trees as explicit change events.
Current public baseline:
agent-bondage/bondage0.2.7touchid-check0.2.7- Homebrew-managed
nonoshould be cleaned up before repinning if old kegs remain installed
Minimum troubleshooting loop:
export BONDAGE_CONF="${BONDAGE_CONF:-$HOME/.config/bondage/bondage.conf}"
bondage --config "$BONDAGE_CONF" doctor
bondage --config "$BONDAGE_CONF" repin-globals
bondage --config "$BONDAGE_CONF" doctor
bondage --config "$BONDAGE_CONF" repin codex # only if doctor suggests it
bondage --config "$BONDAGE_CONF" repin claude # only if doctor suggests it
bondage --config "$BONDAGE_CONF" verify codex
bondage --config "$BONDAGE_CONF" verify claude
bondage --config "$BONDAGE_CONF" chain codex -- --helpUse bondage doctor --help to print this loop at the terminal.
repin is the command that removes the dumb manual step. It rewrites the
selected profile family in place, refreshes fingerprints, canonicalizes live
symlinked tool paths, and follows Homebrew version moves under Cellar/ and
Caskroom/ before re-verifying the result.
In practice:
bondage repin codex ...updates every Codex-tier profile sharing the same pinned target, or the shared defaults block if the target is inheritedbondage repin opencode ...can also refresh the pinned interpreter and package tree for script-based tools, including inherited script defaults- global helpers like
nono,envchain, andtouchid-checkare repinned too when that profile type depends on them
doctor is the non-mutating pass. It checks the whole config, exits nonzero on
stale or broken pins, and tells you which repair command to run next.
repin-globals is the narrow maintenance command for shared helpers only. Use
it when the drift is in nono, envchain, or touchid-check, not in a tool
family itself.
verify still fails closed, but it now tries to explain common Homebrew drift
in human terms. If a pinned path moved from one installed version to another,
the error tells you what changed and which repin command to run next.
For Homebrew-managed nono, run cleanup before repinning if your config pins a
versioned Cellar path. Otherwise an old keg may still exist and repin-globals
can correctly report "clean" while your launcher remains pinned to the old
version.
brew upgrade nono
brew cleanup nono
bondage --config "$BONDAGE_CONF" repin-globals
bondage --config "$BONDAGE_CONF" status | grep 'nono: 'If you use registry-managed nono packs for agent profiles, update them after
upgrading the nono binary. Newer packs may use schema fields that older
nono versions cannot parse.
nono list --installed
nono profile show "${NONO_PROFILE:-codex}" >/dev/nullPortable sandbox checks should avoid machine-specific paths. Pick the profile you actually launch and confirm both the secret denies and the profile-draft split:
export NONO_PROFILE="${NONO_PROFILE:-codex}"
for path in \
"$HOME/.ssh" \
"$HOME/.npmrc" \
"$HOME/.aws" \
"$HOME/Library/Keychains"
do
nono why --profile "$NONO_PROFILE" --path "$path" --op read
done
nono why --profile "$NONO_PROFILE" --path "$HOME/.config/nono/profiles" --op write
nono why --profile "$NONO_PROFILE" --path "$HOME/.config/nono/profile-drafts" --op writeExpected shape: credential paths are denied, active profile writes are denied,
and profile-drafts is writable only when the selected profile intentionally
supports draft-and-promote profile edits.
Then open a fresh shell and confirm the wrapper names still resolve to shell functions rather than silently falling through to raw binaries.
Implemented now:
- hand-written INI-ish config parser
statusdoctorverifyrepinrepin-globalschainargvcompatibility aliasexechash-filehash-tree- exact-path checks via
realpath() - fd-based SHA-256 hashing for direct artifacts
- deterministic package-tree hashing for script profiles
- named defaults and explicit profile inheritance for repeated launch policy
- optional
envchainper profile - optional
nonoper profile, including rawdog/no-nonolaunches - profile-driven
nonoflags like--workdir,--allow-cwd,--allow,--read,--allow-file, and--read-file - profile-driven target args for stable tool policy flags
- global
nono_profile_rootinjection so short profile names expand to explicit JSON paths - profile-driven static env injection and command-derived env vars
- Touch ID launch policy via pinned
touchid-check - prelaunch directory creation for profiles that need state dirs before sandbox start
- exact argv construction for:
envchain -> nono wrap --profile <name> -- targetenvchain -> targetnono wrap --profile <name> -- target- direct target exec
- compact launch-chain summary before
exec; setBONDAGE_QUIET=1orBONDAGE_LAUNCH_SUMMARY=0to suppress it for scripts - end-to-end fake execution for:
- native target
- script target + pinned interpreter + package tree
- explicit
nono_profile_rootsupport so short profile names expand to real JSON profile paths - real thin-wrapper cutover from the shell functions for the local agent stack
Notes:
- local pinned configs should use the real installed
nonoprofile names from this setup, likecustom-claude - the local config now carries the real tier matrix:
base,-mid,-plugin,-unsafe,-dotfiles, and-rawdog - the intended shell shape is now thin convenience only: names, tab colors, and small prompt-shaping helpers
For release/tag and tap update steps, see
RELEASING.md.