v0.2.0
0.2.0 (2026-08-02)
A minor bump because bento run --json changed shape - see the breaking section
at the end. Pre-1.0 that is what a breaking change earns; see
SECURITY.md.
Boundary Hardening
- Off-Linux is a refusal, not a crash: every command that would enforce
something refuses on a non-Linux host before it does any work.version,
helpand shell completion still answer, since they enforce nothing and a
build identifier is the first thing a bug report needs. Bento's guarantees are kernel
features that only exist on Linux, so a build that ran anywhere else enforced
nothing while looking like it did. The refusal stays inside the--json
envelope, so a machine consumer reads it as a refusal rather than a crash. validatepredicts the grants the run refuses: a write grant naming an
existing file, and a read or write grant whose symlinks loop, aborted the run
at sandbox setup whilebento validatesaid nothing. Validate now reports both
in the same words the run refuses them in, andvalidate --strictfails on
them, so a CI gate and the run agree on what is grantable.superviseno longer prompts for the walk down to the script: the example
supervisor asked about each directory on the path to the script it was told to
run. The boundary moved tighter - a routine "yes" to one of those prompts
granted a recursive read several levels above anything the script named
(~/srcand up).- Grant and shield paths are cleaned before they are compared: a
.or..
segment on either side of a containment test - a grant, a denylist query, a
record awaiting judgement - was compared literally, so a path that resolved
inside a shield could read as outside it. Both sides are cleaned now. - An unexpanded
~is refused at enforcement: a~that reached the
enforcer without being expanded was treated as a literal directory name.
It is refused instead, and a nil policy is answered rather than assumed
expanded. - A grant that lifts a shield is raised before the stamp:
validateand
approvenow resolve grants against the shields the wayrundoes, refuse
the onesrunrefuses, and call out an exact-shield opt-in - including on the
already-approved shortcut, which previously returned early. A manifest stamped
by an earlier bento no longer reads as approved for permissions the run
refuses. - A run whose in-sandbox setup never attested is refused:
enforcerefuses
rather than reporting an outcome for a stage that never said it got there, the
backend refuses aNew()that never dispatched, and an undispatched re-exec
stage panics rather than continuing as the parent. approverefuses a non-terminal stdin: a stamp nobody read is now
something a caller asks for with--yes, not something the absence of a
terminal decides. The example supervisor'srunrefuses the same way.- The shared-write warning proves group membership without NSS: the check
for "somebody else can write this" read routes thatLD_PRELOADcould put
back under the caller's control, and warned on a private group holding only
its owner. It now resolves members through the same pure-Go path the shields
anchor on, and a member passwd cannot resolve is not taken as proof. profileproposes less: unix socket grants are withheld entirely, the
entrypoint's ancestor chain is no longer proposed as a read grant, and a host
that cannot sandbox is refused before anything is observed rather than after.- A shebang's interpreter arguments reach the exec policy: an interpreter
line carrying arguments (#!/usr/bin/env -S python -u) had them dropped, so
the policy attested an exec the run did not make. - The example supervisor's trial is read-only: the trial run no longer
writes, and no longer trims/tmpworkspaces it did not create.
approve is a review step, not a stamp
approve printed four numbered steps whose last command printed one line, which
made typing it the path of least resistance over reading the policy. It now
prints the permissions it is about to stamp, calls out the entries that deserve a
second look, and asks before writing - --yes for scripts and CI.
- It resolves the entrypoint the way
rundoes, so the reviewer sees what will
actually execute. - It says when nobody reviewed and when the stamped policy has drifted, with the
drift notice after the callouts rather than buried above them; an unattested
run is worded as unknown rather than as unrun, and a stale stamp says why it
has no diff to show. - Egress a profiling run reached and the guard refused is recorded on the
manifest asblocked-hostsand called out here. The record is provenance, not
permission: it does not shift the approval fingerprint and it does not widen
anything.
Profiling (bento profile)
--json: the draft, the notes and the refusals come out as one document,
with probed-versus-resolved carried through the envelope so a consumer can tell
a path the target opened from one bento resolved for it.- Manifests are written in the relocatable form: a path under the manifest's
own directory is emitted./-relative and one under your home~/-prefixed,
so the result can be committed and used by someone else. A path under neither
stays absolute and names this machine. /tmpgrants are disclosed as the target's request: a proposed grant under
/tmpreaches the draft because the name exists on this host, which is the
only way a real workspace there can be told from the sandbox's own scratch - so
a target opening guessed names can steer what lands in the proposal. It is now
named as such rather than presented as an observation.- The interpreter comes from the script's shebang, not its extension, with
the interpreter the run actually used merged back into the draft and its
argument cost stated. A whole-workdir grant is called out, a granted write
directory is created the wayruncreates it, and a merge into an existing
manifest says what it changed. - The run is honest about its own shape: the target's stdout passes through
rather than being swallowed, every converge round runs under the base
invocation, and a run that ended before the rounds converged says so instead of
presenting the last draft as settled.
What a Run Tells You
The boundary did not move for any of these; what a user can see about it did.
- Denials name the manifest field that caused them:
bento runprints a
legend mapping a denial's errno to the grant that would have permitted it -
"Read-only file system" towrite:, "Operation not permitted" toexec:.
This is new output on runs that previously said nothing, including runs that
exit 0. - A degraded refusal leads with its remedy: a refusal on a host that cannot
fully enforce a core layer opened with what is broken and the command that
fixes it, then buried it under a tier-consequence enumeration identical on
every degraded host. The run refusal now carries the diagnosis and points at
bento doctorfor the rest;doctorstill prints every fact it printed
before.enforce.LayerStatusgained aConsequencesfield and a
Disclosure()method for embedders that describe a layer in full, plus
Report.AddStatus/SetStatusfor forwarding a status whole. No disclosure was
dropped, only relocated. - A userns refusal in a container names the flags that lift it: the probe's
reason now spells out thedocker run --security-optflags rather than leaving
the reader to find them in the README. - A file-shaped write grant says what it will actually do: a
write:entry
that does not exist yet and is spelled like a file (./out/log.txt) becomes a
directory under that name, so the script's own write to it fails with "is a
directory".validateandrunboth say so before the run rather than leaving
the reader to infer it from the failure. - A death by SIGSYS names the filter that caused it: the filters bento
installs kill only on a foreign-architecture syscall, and a withheld permission
is refused with EPERM instead - so the run says the signal is most likely that
guard rather than a grant the reader can add. - Exit 126 under a blocked exec is explained, with the hint worded for a
manifest that omitted theexecline and gated on the block actually landing.
A non-zero exit points atbento profile. HOMEinside the sandbox is stated up front: it is not passed through, so
~expands somewhere else and a script resolving~itself misses grants
matched against host paths. The note repeats when a run trips it.- A rule covering an egress the guard refused is noted by
validateand
run- the destination resolved to loopback, private space or cloud metadata,
and this run refuses it the same way rather than the rule widening it. - A killed run says it was killed, without guessing who did it, naming the
signal, and blaming limits only for a cgroup kill. - A usage mistake answers in the
--jsonenvelope: a bad flag, an unknown
subcommand and a size-spelling error come back as a refusal with usage and a
hint rather than as bare cobra output, with every spelling of--jsonread
during the scan and unknown refusal shapes rejected at every depth. - A manifest reports every bad field in one pass instead of one per parse.
run --jsoncarries a missing read grant, denied egress and a flag
conflict, and discloses the alias scan a degraded tier skips.- A limits refusal names the way past it: it said the limits could not be
enforced without saying whether to waive the tier or drop thelimits:block.
enforce.Refusalgained aWaivablefield carrying which one applies. - The shared-write warning is quiet where no stamp is at stake:
run,
validateandprofilewarned that somebody else could edit an unapproved
manifest, which is true and beside the point - nothing was attested to drift
from.approvestill refuses. doctorreports its own limits more plainly: it names the platform and
flags one whose enforcement is unverified. A degraded line now leads with the
real cause in plain language rather than a semicolon-joined fragment or raw
bwrap output, oversized detail moves below the table, and the exit code is the
same with--jsonas without.- The sandbox probe is more useful when it fails: it says how to install
bwrap, spells out thedocker run --security-optflags a userns refusal in a
container needs, reads a refused base mount honestly, and says why the shield
probes skipped. bento versionanswers on every install path: ago installor plain
go buildbinary reports the module version Go recorded - a release tag for
@latest, a pseudo-version for a checkout - instead ofdev.make build
stamps the commit and build time it derives from the source, and now derives
the version too rather than carrying a literal that goes stale at every tag.- Paths printed back are quoted by the credential hunt, error text drops its
package prefix, and passing a script where a manifest belongs names the
manifest bento expected.
Platform and Embedding
validateandapproveanswer off Linux; every other command refuses
before doing any work, names the architecture, and keeps the refusal inside the
--jsonenvelope. The Linux-only tree is behind build tags and the no-backend
stub is tagged for the platforms that need it, so a non-Linux build produces a
working binary rather than failing to compile.enforcereports how far in-sandbox setup got and exposes the caller's
deny paths on the enforced run, so an embedder can tell a target that failed
from a sandbox that never reached it - whichexamples/supervisenow reports.internal/observedistinguishes a probed path from a resolved one and
treats only ENOENT as "nothing was there", carryingProbedon the non-Linux
Accesstoo.policyexports the path-coverage predicate that grant and shield
comparison use, so an embedder tests containment the way the enforcer does.
Performance
Shield rules are resolved once per invocation rather than per grant, the
credential hunt indexes the deny rules before walking a home directory, and
policy.CoversResolved no longer allocates.
Breaking: bento run --json is now a stream
The boundary did not move. What changed is the machine contract: run --json
put one indented JSON document on stdout at the end of a run, with the script's
whole output carried in its stdout and stderr fields. It is now a stream of
JSON objects, one per line, written as the run happens.
Every object carries an event field, which is what a consumer switches on:
stdout and stderr for chunks of the script's own output as it arrives, then
exactly one verdict, refusal or failed object last. The verdict is the
old envelope minus the two stream fields; refusal is what refused: true
used to say. Chunk bytes are base64, because a script is untrusted and can
print anything - the old string fields silently replaced invalid UTF-8 with
U+FFFD, so a script emitting binary had its output corrupted with nothing to
say so.
Two things this answers that the document could not. A run no longer costs
memory proportional to what the script printed: measured peak RSS was ~1x the
output volume (12.5 MB at 1 MB of output, 75.2 MB at 64 MB) and is now flat at
~11-12.7 MB across the same range. And the two streams are labelled as they
arrive - the old shape copied both undistinguished onto bento's stderr for
progress, so nothing could tell them apart until the run had ended.
There is no compatibility mode. Two output shapes behind one flag is the thing
to avoid, not the compromise; a consumer of the old envelope pins the previous
release until it reads the stream.
bento profile --json, validate --json and doctor --json are unchanged -
they answer with a single document, and a refusal from profile still carries
refused: true.
Changelog
- d6f8df6: ci: let dependabot bump the pinned actions (@whiskeyjimbo)
- effdee9: feat(approve): make approve a review step, not a stamp (@whiskeyjimbo)
- 64d50c1: feat(cli): name the manifest when a script is passed (@whiskeyjimbo)
- 74fc03c: feat(cli): note a manifest's blocked-hosts in validate and run (@whiskeyjimbo)
- 27771b7: feat(cli): raise a shield-lifting grant before the stamp (@whiskeyjimbo)
- f876b23: feat(cli): say what HOME becomes inside the sandbox (@whiskeyjimbo)
- d6f0898: feat(enforce): expose caller deny paths on the enforced run (@whiskeyjimbo)
- 6e4b564: feat(enforce): report how far in-sandbox setup got (@whiskeyjimbo)
- 32d5810: feat(examples): report a sandbox that never reached the target (@whiskeyjimbo)
- e7f5325: feat(profile): add --json (@whiskeyjimbo)
- b1334df: feat(profile): carry probed-vs-resolved into the envelope (@whiskeyjimbo)
- f1f18de: feat(profile): disclose the /tmp grants a target can steer (@whiskeyjimbo)
- 1771e87: feat(profile): record egress the guard refused, flag it at approve (@whiskeyjimbo)
- 6243a8c: feat(profile): write manifests in the relocatable form (@whiskeyjimbo)
- 2d2b638: feat(run)!: stream the --json output as JSON lines (@whiskeyjimbo)
- 48b4950: feat(run): decode a denial's errno to the manifest field (@whiskeyjimbo)
- 00f3c40: feat(run): hint at profiling when a run exits non-zero (@whiskeyjimbo)
- ae28dd2: feat(run): name the filter behind a SIGSYS death (@whiskeyjimbo)
- a50df4d: feat(run): name the read denial as the ambiguity it is (@whiskeyjimbo)
- 5085b1d: fix(approve): check the shields before the stamp shortcut (@whiskeyjimbo)
- 8f06f55: fix(approve): put the drift notice after the callouts (@whiskeyjimbo)
- 3c37dad: fix(approve): refuse a non-terminal stdin instead of stamping (@whiskeyjimbo)
- 78228d1: fix(approve): resolve the entrypoint the way the run does (@whiskeyjimbo)
- 644d75c: fix(approve): say when nobody reviewed, and when the policy drifted (@whiskeyjimbo)
- 338f2b0: fix(backend): panic on an undispatched re-exec stage (@whiskeyjimbo)
- cf901de: fix(backend): refuse a New() that never dispatched (@whiskeyjimbo)
- 9d67428: fix(build): derive the stamped version from git (@whiskeyjimbo)
- b5ff59a: fix(build): guard the linux-only tree with build tags (@whiskeyjimbo)
- d1f0ee5: fix(build): retag the backend stub test darwin too (@whiskeyjimbo)
- 6f0e18d: fix(build): tag the no-backend stub darwin, not !linux (@whiskeyjimbo)
- 598ae37: fix(ci): pin third-party release actions to commits (@whiskeyjimbo)
- 431bc0c: fix(cli): answer a usage error in the --json envelope (@whiskeyjimbo)
- 23b9316: fix(cli): answer a usage error with usage and a hint (@whiskeyjimbo)
- cb08109: fix(cli): check refusal shapes at every depth (@whiskeyjimbo)
- 1bacc03: fix(cli): clean grant paths and set aside an unjudgeable record (@whiskeyjimbo)
- 2e7c6a1: fix(cli): compare shields and grants as run resolves them (@whiskeyjimbo)
- 99c8bf3: fix(cli): keep cobra's lookup intact and sort size errors (@whiskeyjimbo)
- 400dda2: fix(cli): keep doctor's exit code off the json path (@whiskeyjimbo)
- 2cd6076: fix(cli): keep the platform refusal inside the json envelope (@whiskeyjimbo)
- 58ce17b: fix(cli): keep the usage envelope to usage mistakes (@whiskeyjimbo)
- 9f0eb17: fix(cli): let validate and approve answer off Linux (@whiskeyjimbo)
- 4a992b5: fix(cli): name the way past a limits refusal (@whiskeyjimbo)
- 2145eb6: fix(cli): note a file-ish write grant on run too (@whiskeyjimbo)
- 575b036: fix(cli): point a root flag error at the flags (@whiskeyjimbo)
- d3e8c75: fix(cli): read every spelling of --json in the scan (@whiskeyjimbo)
- 76054a2: fix(cli): refuse a shielded grant before run does (@whiskeyjimbo)
- 3cb0da7: fix(cli): refuse off Linux before any command works (@whiskeyjimbo)
- f528351: fix(cli): reject an unknown json refusal shape (@whiskeyjimbo)
- 1a8a823: fix(cli): say what a file-ish write grant actually does (@whiskeyjimbo)
- 7910b29: fix(cli): say why a stale stamp has no diff (@whiskeyjimbo)
- 128f2e4: fix(cli): word an unattested run as unknown, not as unrun (@whiskeyjimbo)
- f2498b1: fix(credhunt): quote paths read off the walked tree (@whiskeyjimbo)
- 8443572: fix(credhunt): quote the home anchor it prints (@whiskeyjimbo)
- 9099575: fix(denylist): clean the query path in Covers (@whiskeyjimbo)
- 285d2f3: fix(diagnostics): say a run was killed, not who killed it (@whiskeyjimbo)
- e7f0142: fix(docs): a grant inside a shield is refused, not shielded (@whiskeyjimbo)
- 5573ad8: fix(doctor): join a sentence reason without a stray semicolon (@whiskeyjimbo)
- 2134cf0: fix(doctor): keep raw bwrap output out of the tier clause (@whiskeyjimbo)
- c22d606: fix(doctor): lead the degraded detail with the real cause (@whiskeyjimbo)
- 8e21ba8: fix(doctor): lead the proc-mount reason with the docker flag (@whiskeyjimbo)
- cce2a12: fix(doctor): move an oversized detail below the table (@whiskeyjimbo)
- 372aa18: fix(doctor): name the platform and flag unverified ones (@whiskeyjimbo)
- 553321a: fix(enforce): narrow the unattested-run refusal's cause (@whiskeyjimbo)
- c2b83fb: fix(enforce): refuse a run whose stage never attested setup (@whiskeyjimbo)
- 00228a0: fix(errors): drop the package prefix from input errors (@whiskeyjimbo)
- cad5080: fix(examples): anchor the embed demo entrypoint beside its manifest (@whiskeyjimbo)
- 89b7194: fix(examples): supervise run refuses a non-terminal stdin (@whiskeyjimbo)
- 3a3185f: fix(manifest): report every bad field in one pass (@whiskeyjimbo)
- bf56b59: fix(observe): carry Probed on the non-linux Access (@whiskeyjimbo)
- f0fa275: fix(observe): only ENOENT means nothing was there (@whiskeyjimbo)
- 5dd5ffb: fix(platform): name the arch in the no-backend refusal (@whiskeyjimbo)
- 45000c9: fix(policy): answer a nil policy in RequireExpanded (@whiskeyjimbo)
- f269001: fix(policy): carry a shebang's interpreter arguments (@whiskeyjimbo)
- 1e84fbb: fix(policy): clean both paths before testing containment (@whiskeyjimbo)
- 02e9067: fix(policy): name the accepted size spelling (@whiskeyjimbo)
- d3bb8c7: fix(policy): refuse an unexpanded ~ at enforcement (@whiskeyjimbo)
- f5e00a4: fix(probe): hedge the proc remedy, guard flag drift (@whiskeyjimbo)
- bf4a1da: fix(probe): make the canary mount proc like the run (@whiskeyjimbo)
- 323ca1c: fix(probe): name the container flags a userns refusal needs (@whiskeyjimbo)
- 9dd99a3: fix(probe): read any refused base mount honestly (@whiskeyjimbo)
- 32ed908: fix(probe): say why the shield probes skip (@whiskeyjimbo)
- a82ce6a: fix(probe): tell the user how to install bwrap (@whiskeyjimbo)
- 263bf4c: fix(profile): call out a whole-workdir grant (@whiskeyjimbo)
- 81bc53a: fix(profile): collapse a note the rounds disagreed about (@whiskeyjimbo)
- efaacf2: fix(profile): create a granted write dir the way run does (@whiskeyjimbo)
- b002289: fix(profile): do not repeat egress for the write-dir retry (@whiskeyjimbo)
- 279b81a: fix(profile): drop the entrypoint's ancestor chain (@whiskeyjimbo)
- 5f5173c: fix(profile): keep an ancestor the target listed (@whiskeyjimbo)
- c95229f: fix(profile): keep host paths under /tmp in the proposal (@whiskeyjimbo)
- 6532794: fix(profile): keep the drift report's json values unquoted (@whiskeyjimbo)
- b39108d: fix(profile): merge the interpreter the run used (@whiskeyjimbo)
- d439ec7: fix(profile): pass the target's stdout through (@whiskeyjimbo)
- ca3cdf5: fix(profile): prefer a script's shebang over its extension (@whiskeyjimbo)
- 1688405: fix(profile): refuse a host that cannot sandbox up front (@whiskeyjimbo)
- 45bcbab: fix(profile): relocate the interpreter, keep the rewrite valid (@whiskeyjimbo)
- 1914e68: fix(profile): run every converge round under the base invocation (@whiskeyjimbo)
- d97bd7d: fix(profile): say what a merge into an existing manifest changed (@whiskeyjimbo)
- 797155a: fix(profile): say what a shebang's interpreter args cost (@whiskeyjimbo)
- 2834783: fix(profile): tighten the --json envelope and the retry (@whiskeyjimbo)
- d0bbd27: fix(profile): unsplice the retry helper's doc comment (@whiskeyjimbo)
- 20be101: fix(profile): warn about an unfinished run after the rounds (@whiskeyjimbo)
- 05f15b9: fix(profile): withhold socket grants, match blocked hosts by rule (@whiskeyjimbo)
- efb171a: fix(report): keep a layer's consequences in Error() (@whiskeyjimbo)
- 20ff0b1: fix(report): lead a degraded refusal with its remedy (@whiskeyjimbo)
- f6ad865: fix(run): admit a shield can answer EROFS inside a grant (@whiskeyjimbo)
- 97213c0: fix(run): answer a mid-flight failure with a --json envelope (@whiskeyjimbo)
- cae3ddc: fix(run): answer each denial line for its own layer (@whiskeyjimbo)
- 884c0ff: fix(run): blame limits only for a cgroup kill signal (@whiskeyjimbo)
- 4cfc7bc: fix(run): copy the script's streams live under --json (@whiskeyjimbo)
- 2dd4d71: fix(run): disclose the alias scan the degraded tier skips (@whiskeyjimbo)
- ba527d4: fix(run): explain exit 126 under a blocked exec (@whiskeyjimbo)
- cc3631a: fix(run): gate the exec hint on the block landing (@whiskeyjimbo)
- 411039e: fix(run): keep the envelope alive when stderr dies (@whiskeyjimbo)
- c117103: fix(run): name the signal that killed a run (@whiskeyjimbo)
- ecfb12b: fix(run): print the legend where a denial is certain (@whiskeyjimbo)
- 6a519c0: fix(run): refuse every outcome a truncated stream lost (@whiskeyjimbo)
- 3336c5f: fix(run): render a refusal wrapped, hint only on a real run (@whiskeyjimbo)
- e71de24: fix(run): repeat the HOME note when a run trips it (@whiskeyjimbo)
- b32ab4c: fix(run): report a missing read grant in --json (@whiskeyjimbo)
- 9952080: fix(run): report denied egress, missing reads, flag conflict (@whiskeyjimbo)
- 12fc650: fix(run): say the run was killed, not the script (@whiskeyjimbo)
- 52043a5: fix(run): word the exec hint for an omitted exec line (@whiskeyjimbo)
- 7e8dfc2: fix(run): wrap the unreached-target notice (@whiskeyjimbo)
- d5c4a5f: fix(shebang): drop the env option coreutils has no flag for (@whiskeyjimbo)
- 5aaecc2: fix(shebang): read env's attached split-string payload (@whiskeyjimbo)
- 5df8316: fix(supervise): give the demo a host it learns at runtime (@whiskeyjimbo)
- 02a1e5e: fix(supervise): guess the interpreter as the CLI does (@whiskeyjimbo)
- 07a1a5e: fix(supervise): keep the trial read-only and stop trimming /tmp workspaces (@whiskeyjimbo)
- 7ee55c9: fix(supervise): make the demo's learned host resolvable (@whiskeyjimbo)
- d406265: fix(supervise): stop prompting for the walk down to the script (@whiskeyjimbo)
- 2d788ce: fix(trust): ask who the group holds before warning (@whiskeyjimbo)
- 03618bf: fix(trust): close the NSS routes the group proof missed (@whiskeyjimbo)
- 4e473d0: fix(trust): give the shared-project layout its own remedy (@whiskeyjimbo)
- 1ec25e5: fix(validate): answer a write grant that names a file (@whiskeyjimbo)
- 34230ca: fix(validate): drop the double so in the stale-stamp text (@whiskeyjimbo)
- 681350e: fix(validate): fail --strict on a manifest that cannot run (@whiskeyjimbo)
- 15ad8f1: fix(validate): refuse a looping grant the run refuses (@whiskeyjimbo)
- f41c584: fix(version): report the module version when unstamped (@whiskeyjimbo)
- a4def30: observe: tell a probed path from a resolved one (@whiskeyjimbo)
- 57d6ed0: perf(cli): resolve the shield rules once (@whiskeyjimbo)
- b28ed74: perf(credhunt): index the deny rules for the home walk (@whiskeyjimbo)
- 8607323: perf(policy): make CoversResolved allocation-free (@whiskeyjimbo)
- 5fca5bf: refactor(policy): export the path-coverage predicate (@whiskeyjimbo)
- a4b780b: refactor(profile): expose the interpreter guess publicly (@whiskeyjimbo)
- 4ea4e76: refactor(refusal): share the shield refusal wording (@whiskeyjimbo)