Skip to content

feat(permission): confine what we execute, and stop asking what the fence already answers - #39

Merged
benjipeng merged 14 commits into
mainfrom
spike/permission-boundary
Sep 20, 2026
Merged

benjipeng merged 14 commits into
mainfrom
spike/permission-boundary

Conversation

@benjipeng

@benjipeng benjipeng commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Tool permission had one rule the code did not follow, and a policy nobody had written down. This
branch settles the position, builds the fence it calls for, and stops asking the questions that
fence replaces.

The position

.agents/spikes/permission-policy/next-decisions.md records it, because a position agreed in
conversation does not survive a session — the previous attempt spent three sweeps re-deriving what
the spike already held.

What we execute ourselves, we confine. What we hand to a subprocess, we do not pretend to.

Plexmaton has one user: a solo developer, on their own machine, on a project they chose, who picked
the provider and installed every tool connected to it. That decides which mechanisms in comparable
harnesses are load-bearing here and which exist for an audience we do not have. A prompt saying
"this tool needs network access" tells Claude's user something they did not know; told to this user,
about a server they installed for that purpose, it is noise.

Four axes were fixed with the owner before any of the decisions below, and the decisions were judged
against them: rate (approvals per turn), generalisation (how many future questions one
answer prevents), answerability ("Allow Bash?" cannot be answered — Bash does anything), and
interruption (a long autonomous run stopped for something trivial; a delegated child asking
while the user reads the parent). Hands-off inside the blast zone scores full marks on all four.
That is why the remaining work is a fence rather than a dialogue.

What that rejected, each with its reason recorded beside the rule: approval prompts for in-zone
work, checkpointing the worktree, command inspection of any kind, network egress as a category, and
capability warnings.

CMD-7 — the fence

On macOS the shell launches through a Seatbelt profile denying writes outside a resolved root set —
the admitted workspace root, both temporary directories, and the toolchain caches the owner's
environment names — leaving reads, network and process operations untouched. The command itself is
never inspected
: any interpreter defeats a matcher under a different spelling, and matching a
command cannot establish its effects.

Three things the implementation had to learn the hard way, each now a sentence in the spec:

  • Every root enters resolved or not at all. An unresolved subpath compiles to a valid profile,
    exits zero, and grants nothing. macOS hands that case over by default: it reports its temporary
    directory under /var while the kernel matches /private/var. A fence that looks applied and
    denies the roots it was told to allow, with no diagnostic.
  • Stateless character devices are granted by name. Denying /dev/null stops git, python and
    curl from starting, and confines nothing. The executor supplies stdin from the parent, so an
    inherited descriptor satisfies any fixture that only redirects into a file — the test must open
    its own device or the regression passes unseen.
  • Nesting refusal is probed once per process. Undetected it reaches the owner as a command that
    exited 71 having run nothing, which reads as the command's own failure.

The launcher applies the profile to itself and execs the shell, so the spawned process is the
shell: CMD-2's process group, CMD-5's signalling and CMD-3's drains are structurally unchanged.
Measured at ~6 ms per invocation, and 24/24 cancellation arms pass bare and wrapped. Off
macOS the spawn is byte-identical to an unconfined one and the typed result carries which case
applied — a recorded fact, not a platform accident.

PER-11 — a Session starts granting what it already bounds

A coding Session seeds the native file-change preset, and the confined-command preset where PER-3
offers one. A call the system bounds does not need the question the bound replaces: an edit reaches
only what WFS-1 and MUT-2 pin, a command only what CMD-7 fences.

Both are ordinary /permissions rows — revocable, gone with the process — so revoking one restores
its question. Precedence is untouched: Deny and explicit Ask are consulted before any grant.

Rejected: dropping FileWrite and ProcessSpawn from the policy's fallback. It reaches the same
silence and loses what makes it answerable — a fallback has no /permissions row, so it cannot be
seen, revoked, or turned off for one Session.

PER-3 — nothing is carved back out of the zone

The file-change preset excluded .git, .plexmaton, .agents, .codex and agents.md at any
depth. Once PER-11 seeded that preset, the two halves of one Session grant answered the same
question differently: CMD-7's fence grants every path beneath the workspace root to every shell
command
— which is the route that actually writes .git — while the exclusion asked about the
route a model does not need. It also reinstated in-zone approvals on the path this repository writes
most, which is the reflex the position exists to prevent.

The bound is what WFS-1 and MUT-2 pin. Inside it nothing is carved back out. The /permissions row
and its confirmation now say what the grant covers rather than what it excludes; three widths
regenerated and reviewed.

Two defects this found in its own evidence

A 1-in-6 flake. cmd_5_cancellation_gracefully_terms_reaps_and_joins_drains asserted that a
cooperative command is never escalated to SIGKILL, measured against the product's one-second grace
while nine sibling tests spawned processes beside it. Parallel: 1/6 failing. Serial: 6/6 clean.
main: 8/8 clean. Contention, not logic.

The grace now comes through the ProcessOperations seam the supervision fault tests already use.
Production answers with the product constant; that one test answers with sixty seconds, so a SIGKILL
means a real hang rather than a slow scheduler. Widening strengthens the assertion — and the
run time is unchanged at 2.03 s, so the group still becomes quiescent immediately. Verified 12/12
where it was 1/6 failing, and the assertion still fails when the executor's early return is mutated
away. The fixture's readiness marker is also renamed into place; one created by > and filled
afterwards exists before it holds a pid.

Three red smokes. smoke-permissions, smoke-model and smoke-tree each drive a command
approval and were never told PER-11 had seeded it away. All three timed out waiting for
"Approval required". Terminal.restore_command_approvals takes that row back through
/permissions first — the same route the owner has, not a flag a test can set — and where no fence
exists the grant was never seeded, so the journeys read the same on every host. The two journeys
that expected a command to run unasked now prove the grant they name rather than riding the
seeded one.

What two independent reviews found

Eight findings, each verified against the code before it was acted on.

The offer card lied. remember_offer still read "native create/edit; no controls/Git" after
PER-3 dropped the exclusion, so Allow-and-remember described a narrower grant than the one it
installs. That is the reading PER-10 exists to prevent, and nobody had to edit the card for it to
happen — a second copy of the sentence was enough. The card now takes its scope off the matcher it
will apply, with a test pinning them equal.

GOPATH was granted whole. It names a workspace holding src beside pkg/mod, so every other
Go project under it was writable — source outside the admitted workspace, which is precisely what
the fence exists to deny. Caches are now declared with the variables that relocate them and the
tail that reaches the cache inside the value, and a set variable replaces its default instead of
joining it, which the old comment claimed and the old loop did not do. Checked against a real
sandbox-exec launch: GOPATH/src denied, GOPATH/pkg/mod allowed.

/tmp was denied. macOS answers TMPDIR with a per-user directory under /private/var/folders,
so a command hardcoding /tmp — which shell one-liners and build scripts routinely do — reached no
granted root and failed with a denial nothing in the profile explained. Both scratch directories are
granted; dedup drops one where they are the same path.

A green gate proved nothing about the fence. Every fence test steps aside where the host has
none — right for a developer inside an outer sandbox, and wrong for the gate, where a runner
refusing nested profiles would take the whole mechanism with it behind a passing run. Nobody could
see which of the two reasons it passed for, which is the difference the position says must never be
implicit. The macOS gate now sets PLEXMATON_FENCE_REQUIRED, and where that is set an unavailable
fence fails instead of skipping. This PR's run is the first that proves the fence applied on the
runner.
a_binding_carries_… likewise asserts both arms rather than falling off the end unfenced.

PER-3 claimed a proof it did not have. The test it named only checks Seatbelt argv shape. A
matcher test now proves confined-command membership is the catalog's definition and revision, and
refuses a lookalike.

Revoking the confined-command preset is one-way within a process, and PER-11 now says so,
rather than a comment claiming it is not the owner's to un-make while the UI offers Revoke.
Rejected: a typed re-enable twin, which buys a rarely-walked path back for a second control and its
confirmation copy; restarting is the boundary the Session already has.

Both spike Status rows still read "unbuilt" while the same file's body named CMD-7 and PER-11 as
built. That header is the first sentence the next agent is routed to.

Verification

Run outside the sandbox: 17 runtime tests bind loopback fixture servers, which a sandbox refuses.

Check Result
cargo test --workspace --locked --no-fail-fast 1592 pass, 0 fail, with PLEXMATON_FENCE_REQUIRED=1
cargo test -p plexmaton-command --lib 12/12 consecutive clean, where the flake was 1/6
cargo clippy --workspace --all-targets --locked -- -D warnings clean
cargo fmt --all --check clean
All seven PTY smoke scripts every one passes
python3 -m unittest discover -s scripts/tests 43 pass
Mutation checks every new assertion confirmed by mutating the implementation and watching it fail
citations / frames / file length / crate graph / typos pass
Three-width frames for the changed permission copy regenerated and reviewed

No document crosses a budget it was not already over; next-decisions.md grew 40 bytes.

Still open, and deliberately not in this branch

  1. Where this work belongs. Phase 04 is product polish; a fence is not. It needs a stage under an
    existing phase or a phase of its own — a roadmap decision.
  2. "Show" beyond the transcript. Every tool call already has a row with its invocation
    disclosing beneath it, so removing the question left the record intact. A per-turn status-line
    summary sits on top of that floor; it is contract text, so ui-ux.md owns it and it needs a
    rendered frame the owner has seen.
  3. Deleting prefix. Its tree-sitter parse of every command, the 20 ms budget another branch has
    been fighting, and the capability engine that never runs are all dead once a command on a fenced
    host never reaches a prefix offer. PER-10 has 40 citations across code and documents; a mechanism
    goes with its spec, tests and citations together, so that is its own change.

…t costs

The permission spike asked how routine work can need fewer approvals while
authority stays explicit. It compared five harnesses and modelled the policy.
It never audited where this harness decides, and the answer is not where the
corpus says it is.

## Three findings, each verified in this checkout

A delegated child's floor — whether it may write files or run commands at all
— is a `matches!` on `ToolCall.name` in plexmaton-runtime, gating admission
before any capability is read. CHB-1 states the outcome and never the
mechanism; `NativeToolProfile` appears nowhere in .agents/. The governing
sentence in tools.rs says a name is a label the model chose. At the highest
-stakes boundary in the product, it is the decision.

There is no DenyAndRemember. Deny wins every precedence race and is the only
rule shape the interface cannot produce; it exists solely for someone who
hand-edits configuration, and once they do, no view shows it back to them.

The capability-based Forbidden and Ask tiers are documented, unit-tested and
unreachable: both non-test-file calls to ApprovalPolicy::new fall after
#[cfg(test)], so production runs the default with both sets empty.

## What the comparison adds

Re-read against composition rather than precedence, Claude's "sandbox
auto-allow" is not an exception anywhere — it is the shared rule, named in
code in three independent implementations. The converse is ours: the two
sources without containment are the two where turning the questions off
leaves nothing underneath.

## Cost, measured instead of deferred

sandbox-exec on macOS adds a constant ~6 ms per invocation — 2.6x on `true`,
1.8x on a pipeline, under a percent for anything doing real work. Cost is no
longer a reason to defer containment on this platform. Running it exposed a
constraint reading could not: a subpath parameter must be resolved, because
the unresolved form is accepted as a valid profile and grants nothing — a
write fence that looks applied and denies the roots it was told to allow.

## Two rules this change installs

next-decisions.md carries the four axes a design is judged on, the hypothesis
on the table, and the four open forks — because criteria agreed in
conversation do not survive a session, and the last attempt spent three agent
sweeps re-deriving what the spike and sandbox-boundary already held.

.agents/README.md now says a budget tightens a corpus that code already
documents, so a spike may exceed it while its work is unbuilt. This change
leaves .agents/README.md itself 307 bytes over, which its own escape hatch
(split the rules from the budget table) pays when something else touches it.
The containment question had one unknown left: the command contract requires
group signalling to reach the shell and its descendants, drains to terminate,
and a launch failure to stay distinguishable from a command exit. Wrapping the
spawn in sandbox-exec looked like it put another process in that path.

It does not. The probe records launched == shell: sandbox-exec applies the
profile to itself and execs the target, so the process tree is structurally
unchanged and every existing cancellation invariant holds untouched. 24/24,
each arm run bare and wrapped, because a bare failure means the probe is wrong
rather than the wrapper. The first two runs failed exactly that way.

It also splits launch failure in two, which the earlier probe recorded as one.
A malformed profile is loud: exit 65 and a located parse error. A well-formed
profile with an unresolved subpath is silent: accepted, exit 0, command runs,
fence grants nothing. Only the second needs defending against, by resolving
every path before it enters a profile and verifying the fence after launch.
The spike asked how routine work can need fewer approvals while authority
stays explicit. Three sessions answered it, and the answer is smaller than the
machinery built for it. None of it was written down, which by this repository's
own rule means it had not happened.

## The position

Plexmaton has one user, on their own machine, on a project they chose, who
picked the provider and installed every connected tool. Comparable harnesses
prompt and warn because they cannot know any of those four things; read their
code for its mechanisms, not for features to match. This harness is best-effort
damage control and says so.

Inside the blast zone everything is allowed and nothing is asked. Operations
mean what they mean: no undo layer, because insurance inside a zone contradicts
calling the zone acceptable, and losing uncommitted work already has an answer.
Command inspection is out, because `python -c` or a build script defeats any
parser. Network egress is out, because the model API call is already the
largest egress channel and blocking `curl` guards an empty room.

The rule that remains is one sentence: what we execute ourselves, we confine;
what we hand to a subprocess, we do not pretend to. The file tools honour it
today. The shell is the half that does not, and closing it is what lets the
inspection that can never be complete stay unwritten.

## What measurement settled

sandbox-exec applies its profile to itself and execs the target, so it adds no
process: `launched == shell`, and every cancellation invariant holds untouched.
24/24, each arm bare and wrapped. Cost was already a constant ~6 ms.

Failure splits in two. A malformed profile is loud. A well-formed profile with
an unresolved subpath is silent — accepted, exit 0, command runs, fence grants
nothing — and macOS hands you that by default through /var. Canonicalise before
a path enters a profile; reject any path not equal to its resolved form.

nono lost on evidence, not preference: it confines the calling process, which a
TUI that must keep writing its journal cannot be, and reaching per-command
through it returns to first-party unsafe in a multi-threaded process. Linux runs
unconfined by decision until there is a host that can test Landlock or bwrap.

## Corrections this makes to its own prior claims

The "sandbox auto-allow is the shared rule" finding was recorded with the
audience folded into the problem. The composition rule survives; the warnings
and tiers around it do not cross the audience boundary.

sandbox-boundary.md is 665 bytes over its budget and README.md 41. Per
.agents/README.md a spike may exceed while its work is unbuilt and pay the debt
once the code documents itself, which is the next change.
The shell was the one place where a policy sentence was not also a capability.
command-tool.md said so honestly — the root is a starting directory, not a
sandbox, and this implementation claims no containment — and that honesty is
what the owner did not want to keep.

The alternative to a fence is reading the command before running it, and that
can never be finished: `python -c`, `node -e`, a written-then-run script or a
build script all reach the same effects under a spelling no parser has seen.
So nothing here inspects a command. CMD-7 bounds writes at the kernel and
leaves the command opaque, which is what lets the inspection stay unwritten.

## Why this is a small change

sandbox-exec applies its profile to itself and execs the target, so the spawned
process *is* the shell. CMD-2's process group, CMD-5's signalling and CMD-3's
drains are untouched: below the spawn site, executor.rs is unchanged, and the
cancellation and reaping tests pass unmodified, which is the guard that says so.
environment.rs is unchanged too — CMD-2 already keeps real HOME and toolchain
configuration, which is exactly what granting the standard cache directories
requires.

## The failure this defends against

A malformed profile is loud. A well-formed profile with an unresolved subpath is
silent: accepted, exit 0, command runs, fence grants nothing — and macOS hands
that over by default, reporting its temp directory under /var while the kernel
matches /private/var. Every root is therefore canonicalised before it enters a
profile or is dropped, and a root that does not exist is never granted.

A second silent case appeared while running the suite: inside an outer sandbox
that forbids nesting, every command exited 71 having run nothing, which reads as
the command's own failure. That refusal belongs to the process tree, so it is
probed once and reported as Unconfined::ApplyRefused rather than arriving as an
exit code from everything the owner runs.

## What it does not claim

Reads, network and process operations are untouched: confining reads breaks
toolchains one missing sysroot at a time, and the model request is already the
widest path off the machine. The setsid escape CMD-5 admits is unaffected —
neither worsened nor repaired. Off macOS there is no fence, by decision: Landlock
and bubblewrap exist, neither can be exercised on this host, and a fence that is
never run reports confinement it may not deliver. Confinement is carried on the
typed result so that difference is visible per command rather than inferred.

## Verified

53/53 in plexmaton-command and 258 in plexmaton-runtime, run both inside an
outer sandbox (fence unavailable) and outside it (fence applied). A real
`cargo build` compiles fresh dependencies under the generated profile, and the
negative control denies a write one directory outside it.
The fence shipped one commit ago stopped git, python and curl from starting.
Each reported `could not open '/dev/null': Operation not permitted`, which
names the symptom and nothing a reader would connect to a write fence.

A global `deny file-write*` covers /dev like any other path, and /dev/null is
a write. Denying it confines nothing — the device holds no state — while
removing it from every program that opens one for itself, which is most of
them. git needs it to run at all: add, commit and log each failed.

The tests missed it for a reason worth keeping: CMD-2 opens /dev/null for
stdin in the *parent*, so the child inherits a descriptor and never opens one.
Every test that redirected into a file passed, and a real `cargo build` passed,
because neither opens a device itself. cmd_7 now runs a command that does, and
fails with the exact message above when the grant is removed — checked by
reverting the fix and re-running.

Granted by name rather than as a subtree: /dev holds raw disk devices. /dev/fd
is the one subpath, for process substitution.

Verified: 54/54 in plexmaton-command, and a full git init/add/commit/log
workflow under the generated profile, with the out-of-zone write still denied.
The injected-supervision helper gave a command 25 ms to install a SIGTERM trap
and exec into a sleep, then relied on the deadline arriving while it was still
alive. That was always a race against process startup; CMD-7's launcher puts
real milliseconds in front of the shell, and the race started losing — cmd_5's
kill-failure arm reported TimedOut with no SIGKILL sent, because the group was
gone before termination could fail the way the test injects.

The rest of this module already solved this: write a marker, wait for it, then
act. Elapsed time is not readiness. The trigger becomes cancellation, which
reaches the same terminate_and_reap path, fired once the command is provably in
its final state.

Selected rather than joined, because the injections divide: one on the wait
path fails before any marker can be written and needs no trigger at all, and
joining on a marker that never arrives hung that arm for its full bound.

Verified fenced, 20 consecutive runs of the full crate suite: no failure in this
family. The one remaining intermittent failure is
cmd_5_cancellation_gracefully_terms_reaps_and_joins_drains at ~5%, and it is not
this change — an unfenced control, whose spawn is byte-identical to the one
before CMD-7, flakes at the same rate over the same 20 runs.
Unfinished and committed early: this work was overwritten once in the working
tree today and recovered from dangling blobs. It compiles; it does not yet pass.

Both halves of "a call the system bounds does not need the question the bound
replaces" become seeded Session presets rather than a policy default, so each
is visible in /permissions and revocable, and neither depends on the ambient
host in a way a test cannot control:

- files: the existing PER-3 native file-change preset, granted at Session
  start. Its exclusions stand, so .git and agent-control paths keep asking.
- commands: a new ConfinedCommands matcher, granted only where CMD-7 reports it
  can fence one. The check stays inside plexmaton-command so no caller can
  assert a fence that crate did not find.

Outstanding, and why this is WIP: nine runtime permission tests encode the old
default and need deciding one at a time, and
owned_scheduling::user_control::attention_decision_routes_only_to_the_exact_live_child_generation
hangs. Bisecting showed the hang survives removing the confinement probe, so
the cause is not the blocking spawn it looked like; it has not been isolated to
this change or to the rebase onto 1a3f652.
Still unfinished. Sixteen runtime fixtures broke on one root cause and ten
remain; this lands the part that is decided so the next pass starts from a
position rather than a bisect.

The cause is single: these fixtures use a command as the call that waits, and
the confined-command preset removes the wait. One of them —
owned_scheduling::user_control::attention_decision_routes_only_to_the_exact_live_child_generation
— has no timeout around that wait, so it hung rather than failed, which is what
sent an earlier bisect after the confinement probe. The probe was innocent:
removing it left the hang in place, and the test passes untouched at 8a84d7c.

ask_about_commands revokes the preset the way an owner does from /permissions,
so each fixture keeps its own subject — the commit boundary, attention routing,
what a remembered scope covers — instead of testing a default it never meant to
assert. On a host with no fence there is nothing to revoke and commands already
ask, so the fixture reads the same either way. Applied at the shared builders
rather than at sixteen call sites, and after any use_coding_session, because a
Session replacement carries its own preset.

The ten that remain are a second class: they assert grant counts and revisions
that two seeded presets shift. Those want relative assertions, not new numbers.
Still owed beyond them: tests for the new default itself, so the relaxed path
is not the untested one, and the documents.
Sixteen fixtures broke on one cause and they divided in two.

Most use a command, or an edit, as the call that waits, and the seeded presets
remove the wait. ask_about_commands and ask_about_file_changes revoke a preset
the way an owner does from /permissions, so each fixture keeps its own subject
— the commit boundary, attention routing, what a remembered scope covers — and
reads the same on a host with no fence, where there is nothing to revoke and
the call already waits. Applied at the shared builders and the two `owner`
helpers rather than at every call site, and after any use_coding_session,
because a Session replacement carries its own presets.

The rest asserted totals. `grants().len() == 1` was a proxy for "the approval
applied one grant" and stopped meaning that the moment a Session starts with
presets; `grants()[0]` reached for that grant by position and found a preset
instead. Both now name the origin, which says what was meant and keeps saying
it whatever else the Session carries.

PER-7 and the session picker's PER-7 are the two that needed the opposite:
they prove that *enabling* the setting releases what waited, so they clear the
seeds first and watch the flow put one back.

1282 tests pass across the workspace; clippy is clean. Still owed: tests for
the new default itself, so the relaxed path is not the untested one, and the
documents.
PER-11. A call the system bounds does not need the question the bound replaces.
An edit reaches only what WFS-1 and MUT-2 pin; a command reaches only what
CMD-7 fences. So a coding Session seeds the native file-change preset, and the
confined-command preset where a fence exists to hold it.

Presets rather than a policy default, and that is the whole design. PER-3's
exclusions live on the preset, so relaxing the fallback would have granted the
control plane along with everything else — `.git`, `.plexmaton`, `.agents`,
`.codex` and `agents.md` at any depth keep asking precisely because the preset
is what carries them. And a grant is a /permissions row: revocable, visible,
gone with the process. An invisible default is none of those, and could not
have given the fixtures a host-independent way to get a call that waits.

Nothing is granted that precedence cannot refuse: PER-2 consults a Deny rule,
an explicit Ask rule and then grants, so an owner who asked to be asked still
is, and a command on a host with no fence still waits because no preset was
seeded there.

Proven on the production path rather than through the test helper that revokes:
a fenced command runs with no pending approval and no decision-created grant;
revoking the preset brings the question back, which is what makes the silence
the preset's; and a seeded Session writes note.txt while .agents/note.md waits.

1285 tests pass across the workspace, clippy is clean, six gates pass.
…ready bounds

PER-3 excluded `.git`, `.plexmaton`, `.agents`, `.codex` and `agents.md` at any
depth from the native file-change preset. With PER-11 seeding that preset, the
two halves of one Session grant answered the same question differently: CMD-7's
fence grants every path beneath the workspace root to every shell command, which
is the route that actually writes `.git`, while the exclusion asked about the
route a model does not need. It also reinstated in-zone approvals on the path
this repository writes most.

The bound is what WFS-1 and MUT-2 pin. Inside it nothing is carved back out.

The `/permissions` row and its confirmation now say what the grant covers rather
than what it excludes; three widths regenerated.
`cmd_5_cancellation_gracefully_terms_reaps_and_joins_drains` asserted that a
cooperative command is never escalated to SIGKILL, and measured it against the
product's one-second termination grace while nine sibling tests spawned
processes beside it. It lost about one run in six on this branch, and passed
6/6 serially — contention, not logic.

The grace now comes through the `ProcessOperations` seam the supervision fault
tests already use. Production answers with the product constant; this one test
answers with sixty seconds, so a SIGKILL means a real hang rather than a slow
scheduler. Widening strengthens the assertion: the run time is unchanged at
2.03s, so the group still becomes quiescent immediately.

The fixture's readiness marker is renamed into place. One created by `>` and
filled afterwards exists before it holds a pid, and hands back a truncated one.

Verified: 12/12 clean where it was 1/6 failing, and the assertion still fails
when the executor's early return is mutated away.
… back

PER-11 seeds the confined-command preset, so a command runs without asking.
Three journeys drive a command approval and were never told: `smoke-permissions`,
`smoke-model` and `smoke-tree` all timed out waiting for "Approval required" on
this branch, which CI would have reported and no local gate did.

`Terminal.restore_command_approvals` takes that row back through `/permissions`
before the part of a journey whose subject is the admission itself. Where no
fence exists the grant was never seeded and the question is already there, so
the helper changes nothing and the journeys read the same on every host — the
seeded row is what the `/permissions` list shows, not a flag a test can set.

The two journeys that expected a command to run unasked now prove the grant they
name: `smoke-permissions` revokes in both processes, so its configured Allow rule
and its Project prefix grant each carry their own turn rather than riding the
seeded one.

The row marker is short on purpose: the full scope wraps at 60 columns, and a
wrapped marker silently matched nothing, which skipped the revoke and left
`smoke-model` failing exactly as before.

Verified: all seven PTY smokes pass on this branch, and `scripts/tests` 43 pass.
…t the card applies

Two reviews, eight findings, all verified against the code.

The offer card lied. `remember_offer` still read "native create/edit; no
controls/Git" after PER-3 dropped the exclusion, so Allow-and-remember described
a narrower grant than the one it installs — the reading PER-10 exists to
prevent, and nobody had to edit the card for it to happen. The card now takes
its scope off the matcher it will apply, and a test pins them equal so the two
cannot drift again.

GOPATH was granted whole. It names a workspace holding `src` beside `pkg/mod`,
so every other Go project under it became writable — source outside the admitted
workspace, which is what the fence exists to deny. Caches are now declared with
the variables that relocate them and the tail that reaches the cache inside the
value; a set variable replaces its default rather than joining it, which is what
the old comment claimed and the old loop did not do.

`/tmp` was denied. macOS answers `TMPDIR` with a per-user directory under
`/private/var/folders`, so a command hardcoding `/tmp` — which shell one-liners
and build scripts routinely do — reached no granted root. Both are granted; one
is dropped by dedup where they are the same path.

A green gate proved nothing about the fence. Every fence test steps aside where
the host has none, which is right for a developer inside an outer sandbox and
would let a runner refusing nested profiles take the whole mechanism with it
behind a passing run. The macOS gate sets `PLEXMATON_FENCE_REQUIRED`; where that
is set, an unavailable fence fails instead of skipping. `a_binding_carries_…`
also asserts both arms rather than falling off the end unfenced.

PER-3 claimed a confined-command proof it did not have: the test it named only
checks Seatbelt argv shape. A matcher test now proves membership is the
catalog's definition and revision, and refuses a lookalike.

Revoking the confined-command preset holds for the life of the process, and
PER-11 says so rather than a comment claiming the owner cannot un-make it while
the UI offers Revoke. Rejected: a typed re-enable twin, which buys a
rarely-walked path back for a second control and its confirmation copy.

Both spike Status rows still read "unbuilt" while the same file's body named
CMD-7 and PER-11. That header is the first sentence the next agent is routed to.

Verified: 1592 pass with the fence gate on, clippy and fmt clean, all seven PTY
smokes pass, 43 script tests, citations/frames/file-length clean. The two new
assertions were each confirmed by mutating the implementation and watching them
fail. `GOPATH/src` is denied and `GOPATH/pkg/mod` and `/tmp` allowed, checked
against a real `sandbox-exec` launch.
@benjipeng
benjipeng merged commit 51bb7d3 into main Sep 20, 2026
3 checks passed
@benjipeng
benjipeng deleted the spike/permission-boundary branch September 20, 2026 04:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant