Skip to content

feat(agents): delegation rules, an OS isolation layer and per-CLI modes - #109

Open
BIackFIame wants to merge 30 commits into
howdeploy:mainfrom
BIackFIame:pr/3-agent-isolation
Open

BIackFIame wants to merge 30 commits into
howdeploy:mainfrom
BIackFIame:pr/3-agent-isolation

Conversation

@BIackFIame

@BIackFIame BIackFIame commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Branch: pr/3-agent-isolation → base main (stacked on the previous PR). 8 commits.

Depends on #108 (audit fixes).
Only this PR's commits: BIackFIame/CanvasTTY@pr/2-audit-fixes...pr/3-agent-isolation

Why

  • A subagent could get more rights than its orchestrator (including YOLO), start in /, the home folder or another project, and nest without limit.
  • An agent in auto mode could write anywhere the user can: other projects, git hooks, other CLIs' credentials, CanvasTTY's own tokens.
  • For every CLI except Claude Code, an "ask" from a decision plugin printed nothing and the tool call simply ran.
  • Any OS layer has to coexist with the CLIs' own sandboxes (Codex's seatbelt cannot start inside another macOS sandbox).

What changes for users after merging

Auto becomes the default launch mode and is safe to leave on: agents (every subagent, every plugin-started agent, every agent not in manual) run inside an OS isolation layer that lets them write only in their project, their own temp folder and their CLI's own folders. Subagents never get more than their orchestrator. The person decides the limits in Settings → Agents; turning isolation off is an explicit opt-in.

Case (hidden end-to-end run, stub agents) Before (1.7.0) After
Subagent writes a file in its project written written
Subagent writes / deletes in HOME, writes to /tmp written / deleted EPERM (3/3 attempts, both subagents)
Subagent reads ~/.ssh read EPERM
Orchestrator spawns in / or in HOME started refused with a reason
Orchestrator asks for a YOLO subagent started refused
Decision plugin answers "ask" for Codex / OpenCode / Qwen call runs deny with "the person must decide"
Codex inside the layer no layer runs with its own sandbox off (a sandbox cannot nest) and the same approvals
Nesting depth / live subagents unlimited 2 / 8 by default, set by the person

What's inside

  • Delegation rules in the core for spawn_agent, the orchestrator's control connection, restore and restart: profile order plan < normal < acceptEdits < auto, never YOLO; folder inside the orchestrator's project (both Unicode spellings, real paths); depth and count limits only the person sets; plugin launch options from agents only for plugins that declare launch.delegable; OpenCode/Kimi configs from plugins may not set approval keys.
  • YOLO acknowledged per CLI by the person, checked in main; a plugin with sessions:launch needs the same acknowledgement.
  • Isolation layer: macOS sandbox-exec with a per-launch profile; Linux bubblewrap when installed. Writes only in the project, the launch's TMPDIR and the CLI's folders; git hooks, repository config and CLI permission settings unwritable; keys and other CLIs' credentials unreadable; no signals, Apple events, launchd jobs or foreign sockets. Fails closed. Without a layer (Windows, Linux without bwrap) a subagent runs in normal and the card says why.
  • Modes: manual, accept edits, plan and bypass only where the CLI has them; Codex/Claude adjustments for nested sandboxes; a Claude launch may refresh its login keychain file (documented trade-off).
  • Environments declare what they keep (keeps.launch, isolated, confines).
  • Decisions: ask → deny with reason for CLIs that cannot ask; decision services receive the card's profile and canAsk.
  • Runtime: an oversized Claude HTTP hook body is answered after it has been read.
  • Docs: protection layers, delegation rules and isolation (en; short ru and zh-CN sections).

How to verify

npm run typecheck
node --test tests/agent-isolation.test.mjs tests/agent-delegation-invariants.test.mjs tests/subagent-profile.test.mjs \
  tests/decision-hooks.test.mjs tests/environment-keeps.test.mjs tests/profile-hardening.test.mjs tests/claude-launch-settings.test.mjs
npm run build

Manual (macOS): start an agent in auto, ask it to touch ~/outside.txt — it gets "Operation not permitted"; inside the project the write works. The card shows its isolation state.

Risks / follow-ups

  • Windows has no isolation layer: subagents there run in normal. Linux needs bwrap installed.
  • sandbox-exec is deprecated by Apple but still shipped; if it goes away the layer fails closed (agents fall back to normal).
  • A Claude launch may write its own login keychain file so a refreshed sign-in is saved; other keychain items stay behind their access lists.
  • The end-to-end numbers above use stub agents. The Codex flags inside the layer were checked with codex debug prompt-input under a fake HOME; full sessions of real CLIs inside the layer were not part of this check.
  • Nested repositories created inside the project on Linux are protected only by the post-session git audit (PR 5), not by a mount.

An orchestrator with canvastty_agents did not know which providers it
could spawn, so it searched ~/.local/bin and ~/.config instead of
delegating, and had to poll for results.

- list_providers: every agent provider CanvasTTY can launch as a
  subagent, with the exact spawn_agent.provider id, name, installed and
  available from the provider CLI registry, sign-in state from the last
  usage-limits read (ok, signed_out, expired, unknown; LimitsService.peek
  never starts a read and nothing reads credentials), subagent and
  orchestrator support, and plugin launch options with the plugin tool
  that picks them (such as list_routes). The control CLI answers the
  same list as `providers`.
- spawn_agent lists the known ids (enum, mirrored from providerCatalog
  and kept equal by a test) and refuses an unknown provider with
  INVALID_REQUEST naming list_providers. The stdio helper answers
  malformed core calls itself, since the bridge drops the connection
  over them.
- wait_for_agent({ sessionId, timeoutSeconds <= 600 }): returns when the
  subagent is idle (after its screen settled), needs_approval, exited
  (done/failed), quiet, closed, or on timeout, with status, exit code,
  waited time and the tail masked before it is cut. Own subagents only,
  never the orchestrator itself; it stops at once on cancel or
  disconnect. It reads only metadata and the output offset while it
  waits.
- The MCP instructions, tool descriptions, orchestrator skill, docs and
  both refusal messages give the workflow list_providers -> spawn_agent
  -> wait_for_agent -> get_agent_result and say not to search the
  filesystem for agent CLIs or their configuration.
OpenCode subagents asked "Access external directory ~/Downloads/<name>"
for their own project when its name had a letter with two Unicode
spellings (Cyrillic "й"). Finder stores names decomposed (NFD); the path
an orchestrator types is composed (NFC). macOS opens either, so the CLI
started in the NFC path, read its working folder back from getcwd in
NFD, and OpenCode's string comparison between its project root and the
paths in its prompt made the project external. Codex trust entries had
the same mismatch.

- TerminalManager.create spells the requested cwd as stored on disk
  (onDiskPath: each non-ASCII part replaced with the parent's entry of
  that name, exact match first, else the one entry equal after
  normalization; symlinks are kept, ambiguous or unreadable parts are
  left as given). This covers spawn_agent, the control CLI, plugin
  sessions.create and the launcher.
- Every launch gets PWD set to its folder (the wrapped one for plugin
  environments) instead of inheriting the app's.
- OpenCode gets permission.external_directory "allow" for exactly its
  project folder in its other Unicode spelling (the folder and its
  contents) in the per-run inline config; a person's own external_directory
  word is kept as "*", YOLO and ASCII paths are unchanged.
…effort

The person asked an orchestrator for subagents on "GLM-5.3 Flash"; they
ran on OpenCode's default GLM-5.3 because spawn_agent had no model.

- CreateSessionRequest/SessionMetadata carry model and effort; the
  launch passes them to the CLI's own flags for that run only (--model:
  OpenCode provider/model, Codex, Claude, Qwen, Kimi alias, Grok, OMP,
  Pi, Cursor; effort: Codex -c model_reasoning_effort, Claude --effort,
  Grok --reasoning-effort, as their --help lists them). Hermes, MiniMax,
  Devin and Antigravity take none. Nothing is written to CLI config.
- Checked per provider (shared/launchModel.ts) with the reason in the
  refusal and a pointer to list_providers / the providers command; kept
  on restart and restore (persisted, and a stored value the CLI would
  not take is dropped instead of failing the restore).
- spawn_agent gets model and effort (effort enum mirrored and tested);
  the control CLI gets --model and --effort.
- list_providers shows each provider's model format and effort levels,
  and for OpenCode the models `opencode models` lists: run in the
  background with a 5 s timeout, cached for 10 minutes, never awaited.
- The skill, docs and tool texts say: if the person names a model,
  pass it as model.
- OpenCode stops with only "Unexpected server error" on a model it does
  not know. A model missing from the cached `opencode models` list is
  refused before launch (spawn_agent, control create, TerminalManager
  for the launcher and plugins) with up to five closest ids; when the
  list cannot be read, the model is passed as given.
- A subagent that exits anyway reports the last lines of its screen as
  plain masked text (exitLines) in wait_for_agent, observe_agent and
  get_agent_result.
OpenCode subagents asked the person for every action. OpenCode 1.18 has
no auto flag, so its auto profile is an inline OPENCODE_CONFIG_CONTENT,
merged with the core's own inline config and never written to
~/.config/opencode.

Checked against the installed opencode 1.18.33: Permission.evaluate is
rules.findLast(permission and pattern match), fromConfig turns
{ tool: action } into a "*" rule and { tool: { pattern: action } } into
one rule per pattern in order, and an agent's permission is appended
after the top-level one. So the rules go under agent.build (OpenCode's
default agent): after the person's own rules, and every tool not named
keeps what the person's configuration says.

- read, glob, grep, list: allow (".env" files still ask, as OpenCode's
  default does); edit (edit, write, apply_patch): allow.
- bash: allow only when base protection is on and CanvasTTY's guard is
  installed in that OpenCode (tool.execute.before, hard denies still
  deny before OpenCode's own check); otherwise it asks, so auto never
  grants more than the person chose. A launch contributor's other model
  keeps bash asking, like accept-edits elsewhere.
- external_directory is untouched: outside the project asks as before.
- hasAutoMode includes OpenCode (launcher, card label, control CLI);
  TerminalManager learns base protection through configureBaseProtection.
…ofile

Subagents were always launched in the normal profile, so a person who
ran the orchestrator in auto was still asked about every step of every
subagent.

- spawn_agent takes an optional profile, "normal" or "auto" (auto only
  where the subagent's CLI has one). Without it the subagent gets its
  orchestrator's profile; an auto its CLI lacks becomes normal.
- YOLO is never handed to a subagent: core allows it only in an
  isolated environment the person chose, which spawn_agent cannot pick.
  An explicit "yolo" is refused with the reason, and a YOLO
  orchestrator's subagents run in auto (or normal).
- The answer reports profile and profileInherited, list_agents shows
  each subagent's profile (and model), and the card shows the profile
  it runs in. The control CLI's create --profile stays its equivalent.
- The skill and docs describe it.
…screen tail

How core decided an OpenCode subagent finished: CanvasTTY's OpenCode
plugin reports working on session.status busy and idle on session.idle
through the runtime gateway, so wait_for_agent returned at the right
time. But get_agent_result only returned the raw PTY tail (the last
8,192 characters of a full-screen TUI's redraw sequences), and state
stayed "running" while the CLI was open, so the orchestrator never got
the answer text.

- The OpenCode plugin, when result capture is on for its session, reads
  the session's last assistant message (text parts, not tool, reasoning
  or synthetic parts; v2 and v1 SDK shapes; 2 s timeout) at
  session.idle and reports it with the turn's end, at most 4,096
  characters with the end kept. The runtime gateway accepts a result on
  OpenCode's session.idle as it does on a Stop hook, still only for a
  lease that captures results.
- spawn_agent turns result capture on for Codex and OpenCode subagents
  only (TerminalManager accepts it for both); ordinary cards never keep
  an answer.
- TerminalManager keeps the last answer in memory, masks it when read,
  and clears it when the next turn starts. get_agent_result returns it
  as answer, plus status; wait_for_agent includes it when the turn ended.
- Tests drive the plugin in a separate process with a fake SDK client
  against a real runtime gateway, and cover masking, truncation, the
  v1/v2 client shapes and the no-capture path.
…riables

Base protection let three kinds of calls through unchecked:

- A shell call whose input was over the 40 KB bound reached main as a
  preview only; no command was read, so nothing was denied and, with no
  decision plugin, the call ran. With base protection on, a cut shell
  call, or a cut file write whose target is not at the start of the
  preview, is now denied with advice to split the work.
- A folder named in a variable of the same command (`OUT=/x; rm -rf
  "$OUT"`, `export OUT=/x`) was unresolved. Standalone assignments and
  export/declare/local/readonly/typeset now set the value for the rest of
  the command (a prefix assignment does not, as in the shell), and a value
  that cannot be known clears it.
- `tar -C<dir>` with the folder attached, and `git -c alias.x='!cmd'` or a
  `-c` key whose value git runs (fsmonitor, pager, editor, filters,
  helpers) were not read. Their commands are now analyzed like any other.

A decision-plugin list that cannot be read is no longer an empty list: the
handler fails, so the gateway answers as unavailable (a CLI that can ask asks the
person, a fail-closed gate denies), and a launch still installs the hook.
…s restore, close and cancel

Saved cards:
- A card list written by a newer version is left as it is and never
  saved over for the rest of the run; one that cannot be read at all is
  left alone too. A file that is not a card list is kept beside a fresh
  one (terminal-sessions.json.corrupt-<time>) instead of being replaced
  by an empty state when the manager saves.
- Restore resumes environments only for cards that come back. Cards that
  do not (not restored, or a subagent whose parent is gone) release their
  environment with its data kept, instead of leaking it.
- Subagents whose parents loop back on each other have no owner and do
  not come back.

Owners:
- Closing a card closes its subagents first, so none keeps running
  without an owner.
- An environment a plugin prepared after the launch timed out is still
  heard of (the plugin call keeps the host-call budget) and released.
- A controlled create whose setup failed closes the card it started.

Gateways and cancel:
- The orchestration gateway starts once for concurrent callers, and a
  stop during start leaves nothing listening; the browser agent gateway
  closed while its Unix socket opens closes that socket.
- A Windows pipe host that fails and exits late no longer clears the host
  started after it.
- A late start hook of an earlier turn no longer takes over from the
  newer turn, whose Stop was then dropped as stale.
- A cancelled send_to_agent passes its signal down to the delivery: text
  still waiting for the launch is dropped and the answer is CANCELED.
… keep host replies with their process

- Two installs of the same plugin both passed the "already installed"
  check; the loser's failed move then deleted the winner's directory and
  registry entry. Install, module changes, update and uninstall of one
  plugin now run one after another, the check runs inside that order, and
  a failed install removes only what it created.
- A host call a service made before it crashed was answered into the
  stdin of the restarted process. The reply now goes only to the process
  that asked.
- Uninstall revoked secrets before stopping the plugin, so a secret write
  in flight could recreate the file, and a failed uninstall left the
  plugin without its secrets. Uninstall now stops and removes the plugin
  first, then revokes; secret writes are refused while a revoke is pending
  and re-check permission when they run.
- The companion (glasses) read the terminal screen and captured answers
  without the secret registry. The screen text is now masked as a whole
  before it is cut, so a key the terminal wrapped over two lines is found,
  and captured answers are masked when they arrive.
- An owner holding 64 values dropped the oldest one even when it was still
  in use. Adding a held value again now makes it the newest (the vault adds
  each key it reads), and a drop or a refused owner is logged without the
  value instead of happening silently.
… went in

When focusing or selecting the target opened a JavaScript dialog (a focus
handler's alert), browser_type inserted nothing but answered typed: true.
It now answers DIALOG_OPEN with the dialog type, so the agent handles the
dialog and types again. A dialog raised by the page's input handler comes
after the text went in and still counts as typed. browser_select sets the
selection before it fires its events, so a dialog from those events
leaves it selected and its answer is unchanged.
…v names as the OS does

- Clipboard, settings reads, file pickers, limits, plugin canvas and window
  opening, plugin storage and media, the Hermes HUD, and terminal list,
  resize, bounds, rename, restore and visibility handlers now check that
  the call comes from the main window's own frame, like the other
  privileged channels; the terminal ids they take are checked as text.
- Plugin environment names: __proto__ is refused as a name (assigning it
  was silently dropped), a name such as constructor no longer looks like
  one another plugin already set, and on Windows two spellings of one
  name (Path, PATH) collide with each other and with what CanvasTTY sets.
…plies with what they belong to

- A card whose history snapshot failed also lost its live output: the
  error disposed the only subscription. The failure is still reported, and
  the card now goes on with live output from the oldest event queued.
- Provider keys and API profiles could be saved twice (Enter bypassed the
  busy state), and a save that finished late cleared text typed after it
  was submitted. A second save of the same entry is ignored while the
  first runs, and a draft is cleared only while it still holds what was
  saved. Profile edits no longer merge into a draft from an earlier
  render.
- After the launcher's agent changed, the previous agent's launch plugins
  and environments stayed offered until the new list arrived, so their
  options could be sent with the new agent. Lists are shown only for the
  agent they were loaded for.
- A plugin frame request still running when the frame reloaded or
  navigated was answered into the next document (a secrets.get reply
  included). A reply is now dropped once the frame shows a new document or
  its window is no longer the one that asked.
…cement candidates

- The browser card reports its rectangle on every camera step, resize and
  layout pass. A report that rounds to the placement already applied now
  returns before the native view is re-laid out (hidden reports still run:
  they end gestures). In a simulated slow pan with a duplicate report per
  frame, 1200 reports caused 1200 view syncs before and 300 after; idle
  re-renders go from 1200 to 1. A fast pan is unchanged (every report
  moves the view).
- Placing a new card near Home built and sorted a lattice of about 3000
  candidate positions every time. The sorted list is kept for the same
  Home, card size and options, and the search still stops at the first
  free position: 500 placements beside 20 cards took about 240 ms before
  and under 3 ms after.
…ants and plugin frame replies

- Base protection: rm, rmdir and the other deleters, find -delete, and mv
  operands whose path is a variable or command output that cannot be
  resolved are now denied as an unknown target, with advice to write the
  path out. A variable set in the same command, or a for-loop over plain
  words, still resolves (a loop over a folder outside is the delete
  outside it).
- Uninstall marks the plugin as being removed: a music folder pick that
  finishes while it runs, or after it, stores no grant (the grant is
  checked again right before it is stored).
- A plugin frame reply is tied to the window that asked, the plugin the
  frame served and the frame's document generation, all captured when the
  request arrived. Pointing the frame at another plugin or entry starts a
  new generation at once, so a reply that finishes before the new document
  loads or asks anything is dropped.
…not words in values

A contributed argument was refused when its text anywhere contained words
such as "dangerously" or "approval_policy", so a context plugin whose rule
merely mentioned them blocked the Codex or Grok launch. Arguments are now
read by structure: a flag whose name is core-owned or asks to bypass
approvals, a config override (`-c key=value`, `--config=key=value`,
`-ckey=value`) whose key decides approvals, the sandbox or the hooks, and
the core-owned subcommands are refused as before. Text inside a value is
the plugin's own.
…s reason

Only Claude Code takes "ask" from a hook. For Codex, Qwen, OpenCode and the
rest, an ask from a decision plugin (or a plugin that did not answer in
time) printed nothing, so the tool call simply ran. It is now a deny that
says the person must decide, and the gate turns a gateway-level ask into
the same deny for those CLIs.

Decision services now receive the card's profile and canAsk.
Delegation rules, in the core, for every way an agent starts another one
(spawn_agent, an orchestrator's control connection, restore, restart):
- a subagent never gets more than its orchestrator (plan < normal <
  acceptEdits < auto), never YOLO; a restored record cannot raise it;
- its folder is the orchestrator's project or inside it, compared as real
  paths in both Unicode spellings; /, the home folder and other projects are
  refused with a reason the agent can act on;
- nesting depth (2) and live subagents per orchestration (8) are limits only
  the person sets (Settings -> Agents);
- plugin launch options from an agent reach only plugins that declared
  launch.delegable; OpenCode/Kimi configuration a plugin hands the CLI may not
  set approval keys; cursor's --force/-f and other bypass flags are core-owned;
- YOLO is checked in the main process: acknowledged by the person for that
  CLI, never for a subagent or an orchestrator's connection, and a plugin with
  sessions:launch needs the same acknowledgement;
- an orchestrator gets a control connection of its own (never the app-wide
  descriptor): create makes its subagents under these rules, and theme or
  settings commands are refused.

Agent isolation (Settings -> Agents, on by default) wraps the agent's whole
process tree for every subagent, every plugin-started agent and every agent
not in manual: macOS sandbox-exec with a profile generated per launch, Linux
bubblewrap when installed. Writes only in the project (both spellings), the
launch's own TMPDIR and the CLI's own folders; git hooks, an existing
repository's config and the CLI's permission settings stay unwritable; keys,
other CLIs' credentials and CanvasTTY's tokens are unreadable except what the
launch was handed; no signals to other processes, Launch Services, Apple
events, cfprefsd writes, launchd jobs or foreign Unix sockets. It fails
closed. Without a layer (Windows, Linux without bwrap) a subagent runs in
normal and the card says why; turning it off is the person's opt-in. Inside
the layer Claude Code's own sandbox block is left out (macOS refuses a
sandbox in a sandbox); Codex keeps its flags and escalates through its
reviewer. Plugin environments declare what they keep (keeps.launch,
isolated, confines); an isolated one is not wrapped again, and without
launch only normal runs there. Launches never count as shell-guarded inside
an environment.

Modes: auto is the default launch mode; manual, accept edits, plan and
bypass are offered only where the CLI has them (Codex plan is its read-only
sandbox, OpenCode plan its plan agent, cursor --mode plan). A CLI without an
auto of its own gets its bypass as auto only inside isolation. OpenCode auto
keeps the person's own deny and ask rules after its allow rules. Claude's
sandbox, where it runs, no longer lets commands leave it. A manual card
shows when the CLI's own configuration skips approvals. The card shows the
isolation state; list_providers shows each provider's subagent profiles.
What each layer guarantees and what it does not: the agent's own mode,
base protection, the delegation rules and the OS isolation layer (including
nested sandboxes, network, the macOS login keychain and Windows). The
orchestration guide covers subagent profiles, folders, limits, an
orchestrator's own control connection and environments' keeps; the plugin
guide covers launch.delegable and keeps. The ru and zh-CN security pages get
a short version of the new section.
macOS refuses a sandbox inside another one, so inside CanvasTTY's isolation
layer Codex's seatbelt could not start and every command failed once before
being re-requested. Inside the layer Codex now runs with its own sandbox off
(--sandbox danger-full-access, never the bypass flag) and the same approvals:
on-request, and in auto the reviewer --approve-for-me uses
(approvals_reviewer="auto_review"). Verified with codex debug prompt-input
under a fake HOME, run inside the layer by the tests. Outside the layer Codex
keeps its own sandbox. In plan the layer keeps the project read-only.

Claude Code keeps its sign-in in the macOS login keychain and rewrites that
file from inside the process when it refreshes it. A Claude launch inside
the layer may now write that one file and its temporary siblings (tested
with the security CLI on a temporary keychain in a fake HOME), so a
refreshed sign-in is saved. Other keychain items stay behind their access
lists; the tradeoff is documented.
@howdeploy

Copy link
Copy Markdown
Owner

There are implementation issues in the isolation/Auto changes that remain present at the current #112 head (cb7835a). Please address these in the owning PR and propagate the fixes through the stack.

  1. P1: OpenCode Auto overrides a user's wildcard deny. openCodePersonRules, lines 151–162 only visits the named AUTO_TOOLS; it does not interpret permission["*"]. Calling the actual openCodeAutoEnvironment with OPENCODE_CONFIG_CONTENT={"permission":{"*":"deny"}} produces agent.build.permission.edit="allow" and bash="allow". OpenCode gives agent permissions precedence over global permissions, so the wildcard deny is defeated. Please preserve effective wildcard and specific user rules when constructing Auto permissions. See the official permission rules.

  2. P1: Linux only protects configuration files that already exist. bubblewrapArguments, lines 35–37 skips a missing protectedWrites path. With an existing writable ~/.codex and no config.toml, the generated arguments bind .codex writable but provide no protection for the config path. An isolated agent can create persistent CLI settings for later launches. The same issue applies to missing Claude settings. Please enforce the protection for creation as well as modifications to existing files, without opening the parent home directory for general writes.

  3. P2: missing CLI state/cache directories cannot be created on Linux. bubblewrapArguments, lines 27–30 only binds existing directories. The root filesystem is read-only, and Linux never consumes creatableFolders. A fresh CLI state/cache directory therefore stays unwritable. Please support first-run directory creation and verify the behavior with a fresh HOME.

I confirmed the first issue by invoking the actual config builder and the latter two by generating the actual isolation paths/arguments with temporary fixtures. I did not run a real CLI inside bubblewrap. The existing Windows job also has 12 failing tests; several isolation fixtures hardcode /tmp, but the implementation issues above require separate fixes. Please get the rebased stack green on all supported platforms.

…e open

On Windows, replacing terminal-sessions.json fails with EPERM, EACCES or
EBUSY while any other handle has the file open (a reader, an indexer,
antivirus). The failed save was dropped, so the saved cards stayed behind
the live ones until the next change; the Windows CI run of
session-environments showed exactly that (the saved environment label
never arrived while the test was reading the file).

The store now retries such a rename for about a second on Windows before
giving up; on other platforms the error still fails at once. The rename is
injectable so the Windows behaviour is tested on every platform.
…opped, not failed

On Windows the gateway listens through the current-user pipe host. When
stop() ran while the host was starting, start() rejected with "shutting
down" instead of returning with the gateway stopped, as it does on macOS
and Linux. The lifecycle test also constructed the gateway without the
pipe host, so on Windows it could only fail with the production
requirement; it now gets the same built host as the other gateway tests.
…eny or ask

OpenCode 1.18 evaluates the build agent's rules as its defaults, then the
merged top-level permission, then the merged agent.build.permission, and
the last rule whose key and pattern match wins; the key is a wildcard too.
Auto only looked at the six tool names it opens, so a person's
"*": "deny" (or "ask", or "ed*") was overridden by auto's agent-level
allow rules.

Auto now reads the person's configuration the way OpenCode merges it
(global files, OPENCODE_CONFIG, project files up the tree, .opencode
folders and OPENCODE_CONFIG_DIR including agent/build.md, the inline
config) and, per tool:
- leaves the tool alone when any deny or ask rule reaches it through
  another key, or when the person's files already name it under
  agent.build.permission (mergeDeep would keep their rules before ours);
- otherwise puts auto's rules first and the person's own rules for that
  tool after them, so theirs still win.
A configuration file that exists but cannot be parsed makes auto open
nothing. The tests decide each case with a model of OpenCode's own
evaluation (defaults, merge, last match).
…h CLI create its folders

bubblewrap binds only paths that exist, and nothing can be mounted once
the agent runs. Two gaps followed:
- a protected file that did not exist yet (~/.codex/config.toml in a
  writable ~/.codex, Claude's settings.json) was not mounted at all, so
  an isolated agent could create persistent CLI settings for later runs;
- the CLI's own state and cache folders that did not exist yet stayed
  unwritable under the read-only root: Linux never used creatableFolders.

Before a Linux launch, LinuxHostPaths now creates the CLI's missing own
folders (with the missing folders on the way that creatableFolders
allows, the same ones macOS lets the agent create) and puts a placeholder
that means "no settings" ({} for JSON, empty otherwise) at each missing
protected file; bubblewrap then mounts it read-only. The parent folder
stays writable for everything else and HOME is not opened. Both are
shared by concurrent launches and undone when the last one ends:
folders only while still empty, placeholders only while they still hold
what CanvasTTY wrote. bubblewrapArguments refuses the launch if a
protected file is still missing where the agent could create it.

The tests build a fresh HOME, generate the real arguments and check them
with a model of bubblewrap's mount stacking; a real-bubblewrap test runs
where bwrap works.
…te their platform

The Windows job failed on fixtures, not on the behaviour they check:
- agent-isolation made its short temporary folder under /tmp, which does
  not exist on Windows; it uses the temporary folder there.
- the delegation test used "/" as the file system root, which on Windows
  is the current drive (D: on CI), not the temporary folder's (C:).
- the auto-profile launches did not name the platform although Claude's
  own sandbox exists only on macOS and Linux; they are built for Linux,
  and the Windows launch (auto mode without a sandbox) is asserted too.
  The TerminalManager-level settings test expects the sandbox only where
  the host has one.
- configuredMode builds the paths it reads with the host's rules; the
  fixture now does as well.
- the environment fixture wrapped the agent in "/bin/sh", which is not
  an absolute program path on Windows, so the registry refused it and
  the normal launch never started; it uses node's own path.
… shut down

node:test runs after-hooks in the order they were registered. Four
environment tests registered the removal of their session-store folder
before managerFixture registered the manager's shutdown, so the folder
was removed while the store could still write into it; the Linux job
failed once with ENOTEMPTY. The removal is now registered after the
fixture.
…close

The gateways answer a protocol failure (an unauthenticated command, a
replayed token) with an error message and close the connection. On
Windows both go through the current-user pipe host as a write frame and
a destroy frame; the destroy marked the connection closing at once and
its writer thread stopped without writing what was still queued, so the
client sometimes saw the pipe close without the error. The Windows job
failed orchestration-gateway's "unauthenticated commands and replayed
bootstrap tokens are rejected" this way (a timeout waiting for the
error), intermittently.

A destroy now lets the writer drain what was queued before it, bounded
by two seconds for a client that stops reading, then closes. The relay
self-test (npm run test:windows-pipe-host) sends a last message and the
close in one write and requires the message to arrive.
A Claude Code HTTP lifecycle hook whose body is over the 512 KB bound was
answered (and its connection closed) while Claude was still sending it, so
the sender could see a reset instead of the answer. The state is still
reported at once; the answer now waits until the rest of the body has been
read and dropped.
@BIackFIame

Copy link
Copy Markdown
Contributor Author

Thanks, all three were real. Fixed in this PR (rebased onto main a0f23b4), carried through #110–#112:

  1. P1 OpenCode Auto vs. the person's rules. Auto now reads the configuration the way OpenCode 1.18.33 merges it (global files, OPENCODE_CONFIG, project files up the tree, .opencode folders / OPENCODE_CONFIG_DIR including agent/build.md, the inline config; mergeDeep key order). Per tool: if any deny/ask rule reaches it through another key ("*", "ed*", a bare "deny"), or the person's files already name it under agent.build.permission, Auto leaves that tool alone; otherwise Auto's rules come first and the person's rules for that tool after them. An unparseable config file makes Auto open nothing. Tests (profile-hardening.test.mjs) decide each case with a model of OpenCode's evaluation (defaults, top-level, agent, last match wins): OpenCode auto never overrides the person's wildcard deny or ask (the reviewer's case), …keeps the person's specific rules for a tool after its own…, …leaves a tool alone when the person's files name it for the build agent, or cannot be read.
  2. P1 missing protected files on Linux. Before a bubblewrap launch, a missing protected file inside a writable CLI folder gets a placeholder meaning "no settings" ({} for JSON, empty for TOML) and is mounted read-only; the folder stays writable, HOME is not opened. Placeholders are refcounted across concurrent launches and removed afterwards only if unchanged. bubblewrapArguments refuses the launch if such a file is still missing (fail closed).
  3. P2 first-run folders. creatableFolders is now used on Linux: the CLI's missing own folders are created (only through the allowed intermediate folders) and removed again if still empty.

Tests for 2 and 3 (isolation-linux-host.test.mjs) use a fresh HOME, generate the real arguments and check them with a model of bubblewrap's mount stacking. A real-bubblewrap test in the same file (create ~/.codex/config.toml, write own state, write HOME) passed on an ubuntu-latest runner with bubblewrap installed — note that it needed kernel.apparmor_restrict_unprivileged_userns=0 there; Ubuntu 24.04's default blocks bwrap's user namespace. It is skipped where bwrap is absent (the regular jobs).
Windows: the /tmp fixtures, the drive root in the delegation test, Claude's sandbox platform (auto-profile/claude-launch-settings), host-path fixtures for configuredMode, and /bin/sh as an environment wrapper (not an absolute program on Windows, so the normal launch was refused) are fixed.

Verified: fork CI on tip 863e2b1, all three jobs green — https://github.com/BIackFIame/CanvasTTY/actions/runs/36781738690. Real bwrap probe: https://github.com/BIackFIame/CanvasTTY/actions/runs/36775943081.
Not verified: a real agent CLI inside bubblewrap; OpenCode's remote (well-known) org configs are not read.

@howdeploy

Copy link
Copy Markdown
Owner

Thanks for the detailed fixes. I inspected the updated implementation: the original wildcard case, missing Linux configuration protection, and first-run directory handling have been addressed. The .git file and hidden-terminal state fixes in the later PRs also address the reported cases.

P1: Auto still overrides the person's OPENCODE_PERMISSION restrictions. At the current #109 head (863e2b1), openCodePersonRules reads the inline configuration but never merges this environment permission source before generating agent.build.permission.

Concrete case, also confirmed by invoking the current builder at the cumulative #112 head:

const env = { OPENCODE_PERMISSION: '{"*":"deny"}' };
const result = openCodeAutoEnvironment(env, {
  shellGuarded: true,
  readFile: () => null,
});
// openCodePersonRules(...).top === {}
// JSON.parse(result.OPENCODE_CONFIG_CONTENT).agent.build.permission.edit === "allow"

OpenCode 1.18.33's config loader merges OPENCODE_PERMISSION into the top-level permission after the inline config; its agent builder then appends the build-agent permission. Consequently CanvasTTY's generated edit: allow overrides the person's wildcard deny.

Please include this source at its actual precedence when computing the person's rules and add regression coverage for wildcard deny/ask and tool-specific restrictions. Keep the user's environment setting intact. The issue is inherited by #110–#112.

I verified the cited green fork CI snapshots against the PR heads: their only tree difference is enabling push CI for ci/** branches. Those results are useful evidence, but the missing permission source is not covered by the current tests. Please also let the upstream required checks finish before merging.

@howdeploy

Copy link
Copy Markdown
Owner

P1 — remaining merge blocker: OpenCode Auto can still override restrictions supplied through OPENCODE_PERMISSION. This is present at #109 head 863e2b1 and inherited by #110–#112, including #112 head 86e4c33. The following expands my previous follow-up with the expected fix and verification criteria.

Why the current fix is incomplete

openCodePersonRules now accounts for configuration files and OPENCODE_CONFIG_CONTENT, but it does not account for OPENCODE_PERMISSION. Consequently autoWithPersonRules does not see those restrictions when deciding which build-agent rules Auto may add.

I invoked the actual CanvasTTY builder at 86e4c33 with this input:

const env = { OPENCODE_PERMISSION: '{"*":"deny"}' };
const options = { shellGuarded: true, readFile: () => null };
const person = openCodePersonRules(env, undefined, options.readFile);
const inline = JSON.parse(
  openCodeAutoEnvironment(env, options).OPENCODE_CONFIG_CONTENT,
);
// Actual: person.top === {}
// Actual: inline.agent.build.permission.edit === "allow"

The empty file reader makes the example independent of local configuration; the user's restriction is entirely in the environment. It is not a plugin injecting approval settings.

The consequence follows from OpenCode's actual precedence, not merely from the generated JSON. In the upstream version used by this implementation:

  1. The config loader merges OPENCODE_PERMISSION into top-level permission after loading the inline configuration.
  2. The build agent starts with defaults and those top-level user rules.
  3. The agent configuration appends agent.build.permission. Auto's generated edit: allow therefore takes precedence over the user's wildcard deny.

I verified the builder output and inspected this upstream ordering. I did not run a real OpenCode agent session for this reproduction.

Required behavior

  • Include OPENCODE_PERMISSION when computing the person's effective top-level permission rules, at the same precedence as OpenCode: after file and inline top-level permissions. Reuse the existing permission normalization/merge logic rather than introducing a second evaluator.
  • Let the existing conservative Auto logic see wildcard, wildcard-key, whole-tool and command-pattern restrictions from this source. Auto must not introduce a new grant over the person's effective deny/ask. Preserve intentionally supplied build-agent rules as well; this is not a request to change OpenCode's precedence between the person's own rules.
  • Preserve the original environment variable passed to the CLI. Removing it, rewriting the user's restrictions, or disabling Auto for every configuration would not address the behavior we need.
  • Handle malformed JSON consistently with the upstream behavior, which logs and skips an invalid OPENCODE_PERMISSION value. State any deliberate stricter behavior explicitly rather than silently changing it.

The upstream version above is a reference for the observed behavior, not a request to require that exact installed OpenCode version.

Regression coverage

Please extend the existing profile-hardening.test.mjs coverage. Its current decide() helper merges files and inline configuration but never applies OPENCODE_PERMISSION; adding only a builder assertion would miss the effective decision again. Include the environment merge at the actual upstream stage before evaluating the build-agent rules.

Input or scenario Expected result
OPENCODE_PERMISSION={"*":"deny"}, no other user rules All six Auto-managed tools remain denied.
OPENCODE_PERMISSION={"*":"ask"}, no other user rules Auto does not silently turn those prompts into grants.
OPENCODE_PERMISSION={"edit":"deny","bash":"ask"} Editing remains denied and shell execution still asks.
OPENCODE_PERMISSION={"bash":{"git push *":"ask"}} git push origin main still asks; check that unrelated guarded commands retain the intended Auto behavior.
Inline top-level edit: allow, environment edit: deny The later environment restriction survives Auto.
Existing restrictive agent.build.permission together with environment rules Auto preserves the person's effective agent-level restrictions and ordering.

Keep a control case without environment restrictions so normal Auto behavior still works, including shell prompts when shellGuarded is false. Propagate the fix and its tests through #110–#112 and rerun CI on the updated heads.

What is already addressed and what remains unverified

The earlier reported cases are addressed in the inspected code: wildcard restrictions from the already-read config sources, protection of missing Linux settings files, creation of first-run CLI folders, Git audit discovery through .git files/common directories, and preserving hidden-terminal parser state before output falls out of the ring. I am not asking you to redo those fixes.

I also compared the green fork CI snapshots with the submitted PR trees: the only difference is allowing workflow runs on ci/** push branches. #107 and #108 now have all three upstream checks green. #109–#112 currently have no upstream checks listed; please obtain the required checks for their updated heads too.

Your stated limits around real isolated CLI sessions and macOS-only performance measurements remain acknowledged limitations, not additional reproduced defects. This follow-up identifies one remaining confirmed code blocker in the reviewed paths: the missing environment permission source. It is not a blanket approval of every change in the cumulative stack.

…owdeploy#109)

Merge OPENCODE_PERMISSION after file and inline rules and verify effective build-agent decisions.

Note: pre-existing failure in the local focused-wheel browser smoke is not addressed by this change.
…#111)

Flush final replies through the Windows pipe buffer, cancel draining on the original deadline or shutdown, and exercise delayed and stalled readers.

Note: pre-existing failure in the local focused-wheel browser smoke is not addressed by this change.
BIackFIame added a commit to BIackFIame/CanvasTTY that referenced this pull request Sep 30, 2026
…owdeploy#109)

Merge OPENCODE_PERMISSION after file and inline rules and verify effective build-agent decisions.

Note: pre-existing failure in the local focused-wheel browser smoke is not addressed by this change.
BIackFIame added a commit to BIackFIame/CanvasTTY that referenced this pull request Sep 30, 2026
…owdeploy#109)

Merge OPENCODE_PERMISSION after file and inline rules and verify effective build-agent decisions.

Note: pre-existing failure in the local focused-wheel browser smoke is not addressed by this change.
@BIackFIame

Copy link
Copy Markdown
Contributor Author

Auto still overrides the person's OPENCODE_PERMISSION restrictions.

Addressed both follow-ups: openCodePersonRules now merges OPENCODE_PERMISSION after file and inline top-level permissions, using the existing normalization and merge logic. The original environment value reaches OpenCode unchanged, and the person's build-agent rules keep their existing precedence. Malformed JSON is skipped like upstream. Deliberately stricter behavior: valid JSON with an unsupported permission shape disables Auto additions rather than guessing its meaning.

The independent decide() test model now applies the environment merge at OpenCode's actual stage. Regressions cover all six tools with wildcard deny/ask, wildcard tool keys, whole-tool and command-pattern restrictions, file/inline/environment precedence, restrictive build-agent rules, preserved environment bytes, malformed JSON, and the normal unguarded-shell control. All 18 targeted tests on this tip passed.

Also carried the Windows pipe-buffer close fix from #108. Both fixes and their tests are propagated through #110–#112 without rewriting published history.

All three upstream checks on fcfdafa9 passed: verify, macos-cli-resolution and windows-pipe-host — https://github.com/howdeploy/CanvasTTY/actions/runs/36787425094.

On the cumulative #112 tip, local macOS validation passed: 1581 tests, 3 skips, no failures; 47 Even G2 tests; typecheck, production build, source/bundle secret audit, and MCP-helper smoke. A real OpenCode session was not run; permission decisions were checked against the upstream source ordering. The local browser smoke still fails at the focused-wheel check, also previously observed on clean main; the Linux CI browser smoke passed.

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.

2 participants