Skip to content

feat(safety): add base protection, plugin decision hooks and secret redaction - #86

Closed
BIackFIame wants to merge 6 commits into
howdeploy:mainfrom
BIackFIame:core/5-decision-hooks
Closed

BIackFIame wants to merge 6 commits into
howdeploy:mainfrom
BIackFIame:core/5-decision-hooks

Conversation

@BIackFIame

Copy link
Copy Markdown
Contributor

Goal

Give every local agent a deny-only safety floor in core, and let a trusted plugin decide on agents' shell and file tool calls without being able to weaken that floor.

What core gains

  • Base protection (Settings → Agents, on by default, can be turned off). It denies:

    • elevation, pipe-to-shell and download-and-run;
    • disk and format commands, and fork bombs;
    • writes or deletes outside the working folder, /tmp and home included, and deleting the folder itself; the agent's own plan and memory folders are exempt.

    Each denial tells the model what to do instead. It never allows.

  • Decision hooks (decision:provide, decide.events: ["pre-tool"]):

    • a PreToolUse gate for Claude Code, Codex and Qwen Code, and a tool.execute.before guard for OpenCode, YOLO included;
    • services answer canvastty.decide in parallel. Base protection runs first, then any deny wins, then any ask, then an allow counts only with May allow agent actions;
    • decide.timeoutMs (1 to 60 s, 3 s by default) lets a service ask for longer, for example to ask a local model. Each card's hook, helper and gateway deadlines are sized at launch, and the default keeps today's 15 / 12 / 10 s.
  • Redaction registry: vault values, launch secrets, values registered with redaction.register, values read with secrets.get, keys wrapped over lines and common key shapes. They are masked in observe_agent, get_agent_result and the control CLI.

Security posture

  • A timeout, an error, an unreadable answer or cut input never allows: each counts as ask.
  • Allow needs a second confirmation, which is revoked with native-code trust.
  • Codex and Qwen take deny only.
  • A crashed hook lets the CLI run the call, so this is a guard, not a sandbox.
  • Remote and container sessions are not covered.

Docs and examples

Docs (en/ru/zh), the schema, d.ts, the architecture doc, and examples/plugins/deny-rm (denies rm -rf at the top of the folder and declares timeoutMs).

Tests and checks

  • base-protection, secret-redaction, decision-hooks and the budget part of plugin-policy-budget-secrets. They include the real gate through the real gateway and an 11 s answer that only a sized session waits for. Suite 969/969 and typecheck with a fake HOME.
  • Live, with hidden windows and a fake Claude running the real gate:
    • outside writes were denied, ls ran;
    • the plugin denied rm -rf build and allowed rm -rf build/cache;
    • with base protection Off, the plugin still denied.
  • With the assistant plugin and a local model:
    • the hook was sized to 52 s;
    • git status was allowed, sudo was denied by core, and a setup script was asked about after a 5.9 s review.

Dependency

Stacked on #80 and #85. Review only the top commit.

Used by canvastty-plugin-assistant (review ladder, Off/Learning/Suggest/Auto). canvastty-plugin-acp applies the same base-protection rules to ACP requests.

Builds on howdeploy#80 (teo-nex, "restore each Codex card to its own
conversation"): its capture of the conversation id from authenticated
lifecycle hooks, the validated id saved per card, `codex resume <id>`,
the resume picker when no id is known and a plain restart forgetting
the id are kept as they are. This extends the same exact resume to
Claude Code (`claude --resume <id>`) and OpenCode (`opencode --session
<id>`); the field is renamed from codexThreadId to threadId for that,
with one per-provider check (canonical UUID for Codex and Claude, `ses_`
id for OpenCode) shared by the hook client, the gateway, the store and
the launch, and v1 records' codexThreadId still read.

Settings → General now offers Don't save / Reopen windows / Continue
conversations (settings v21; the old opt-in boolean migrates
true→continue, false→off). Session records move to v2 (v1 stays
readable): last state at quit or exit, the thread id, a per-card restore
flag, and two validated opaque plugin slots (launch options and an
environment ref, 4 KB each). No scrollback, prompts or secrets are saved.

Restore puts parents before children, resumes a recorded conversation
by id, and without one uses a "latest in this folder" flag only when
that CLI has one card in the folder (otherwise it starts fresh with a
note on the card; Codex opens its picker). Finished cards come back
stopped with Restart / Continue (Continue resumes the card's own
conversation), and a card whose environment is unavailable is held
stopped with its reason instead of running locally. Cards get an
options menu with "Don't restore this card".

For plugins: the v2 record's two opaque slots are where later extension
points keep per-card state across restarts. A launch contributor's chosen
options are saved in `options[pluginId]` and an environment's ref in
`environment`, both validated and capped at 4 KB, so a restored card can
be prepared or placed again (or held stopped with a reason) without the
core knowing what the values mean.
Manifest apiVersion 2 adds `services`: bundled single-file JavaScript
entries (integrity-declared like hook entries in modular plugins). A new
PluginServiceSupervisor runs each service of an enabled plugin as its own
process (process.execPath + ELECTRON_RUN_AS_NODE, cwd = plugin folder,
allow-listed environment without keys, NODE_OPTIONS or CANVASTTY_*),
speaks newline-delimited JSON-RPC 2.0 over stdio (1 MB messages, 15 s
request timeouts, 64 pending), restarts with backoff (at most 5 in 10
minutes), stops politely then with SIGTERM/SIGKILL on disable, uninstall,
update, module change, revoke and quit, and keeps a bounded per-plugin log.

Services run only after a separate per-plugin "Extension native code"
confirmation in Settings -> Agents. Install never grants it; it pins each
entry's SHA-256 (checked before every start) and is revoked by update,
module change, disable, or a changed entry file.

How a plugin uses it: its sandboxed surfaces call their own plugin's
services with host.service.request(serviceId, method, params) and receive
host.service.onEvent; the plugin id is bound by the frame host or the
identity-checked plugin window. A service may call back `log`,
own-plugin `storage.*` (storage permission), `event`, and `secrets.get`
(secrets permission) for its own plugin's secret, for example an API key
of a model it calls; anything else is -32601. This is the base the
following extension points (launch, environments, decisions, tools,
sessions, cards) add host requests to.

Docs (en/ru/zh), schema, plugin-api.d.ts, example
examples/plugins/service-echo (a canvas app that calls its service, and a
token the page saves and the service reads), and
tests/plugin-services.test.mjs, tests/plugin-policy-budget-secrets.test.mjs.
A trusted plugin service can now declare a `launch` block (permission
launch:contribute, one service per plugin): up to 8 boolean, select or
text fields, optionally limited to some agents. The agent launcher shows
them under Advanced; the person turns a plugin on for one launch, and the
checked values (at most 4 KB per plugin) are saved in the session
record's plugin options slot and reused on restart and restore.

Before such a card is spawned, LaunchPipeline sends each chosen plugin's
service `canvastty.launch.prepare` (a host-only method surfaces cannot
send) and merges the answers in plugin-id order: env, secretEnv (names of
the plugin's own secrets, resolved in main, never shown to the service or
any UI, masked as <redacted:secret> in observe/result, the control CLI's
screen/result and failure details), args (appended before the resume
selection) and per-run files ({launchFiles}, removed on exit). A refusal,
a 5 s timeout, an error, an invalid answer, a missing secret, two plugins
setting one name, a reserved or core-set variable, or an approval or
conversation argument (coreOwnedLaunchArgument) refuses the launch with
the reason on the card; it is never started without the contribution.
Restore holds a card whose plugin is unavailable stopped with its reason
and keeps its record. Launches without options or policies stay
synchronous and unchanged.

What an account or policy plugin also needs:

- launch.policy: a contributor with `policy: true` is also asked before
  every agent launch where the person did not choose it (`chosen: false`,
  empty options). That answer may only refuse; a contribution, a timeout
  or an error refuses too, so a policy never lets a launch through by
  failing. Policy-only contributors are not shown in the launcher.
- A select may declare `optionsFrom: "service"`: the launcher asks
  `canvastty.launch.options` (3 s) and lists up to 64 more choices (the
  plugin's accounts, say) after the declared ones; such a value is any
  short text the service re-checks when it prepares.
- spawn_agent takes `launchOptions` ({pluginId: {field: value}}), checked
  exactly like the launcher's, so an orchestrator can start a subagent
  with the account a plugin tool picked.
- Claude Code applies only its last inline --settings, so a plugin's
  inline --settings is merged into CanvasTTY's own (hooks kept); one
  that sets permissions, hooks, sandbox, defaultMode or apiKeyHelper is
  refused.

Docs (en/ru/zh), schema, plugin-api.d.ts, examples
examples/plugins/launch-env (options, a service-filled Profile) and
examples/plugins/yolo-guard (a policy that refuses YOLO launches), and
tests/launch-contributors.test.mjs, tests/plugin-launch-choices.test.mjs,
tests/plugin-policy-budget-secrets.test.mjs.
A trusted plugin service can now list `environments` kinds (permission
environment:provide, up to 8 kinds per plugin with optional launcher
fields and appliesTo, "terminal" included). Kinds are unique within the
plugin and may be split over its services (one per module); each is
answered by the service that lists it. The launcher's Advanced section
shows "Where" (default "This computer"); while a kind applies to
terminals, Open terminal opens the same launcher (folder and Where)
instead of opening at once.

EnvironmentRegistry sends host-only requests: prepare (once, 15 s:
opaque ref <= 4 KB, badge label, optional cwd that becomes the card's
folder), wrap (before every start, 5 s), resume (10 s), release (10 s)
and describe (3 s, badge text). TerminalManager still spawns the PTY:
the launch is planned (planSpawn), wrapped, then spawned. Wrap output is
validated: an absolute executable or a bare name resolved on PATH, never
a shell string; args array without NUL; env/secretEnv under the launch
contributor rules (reserved names and names this launch already sets are
refused), secretEnv resolved from the plugin's own secrets and masked.
The environment sees the launch's own variables without CANVASTTY_*
names or secret values (secretEnvNames lists the latter).

The ref is saved in the v2 record. Restore resumes every saved
environment first, then plans parents before children; a missing,
disabled or untrusted plugin, a `stopped` answer, an error or a timeout
brings the card back stopped with the reason, keeps its record, and never
runs it locally. Closing a card asks once "Keep environment data?" and
releases with the answer; quitting releases nothing unless saving is off
(keepData: true, reason "quit"); a card closed while preparing releases
the new environment unkept.

Launch contributors and policies now receive the card's `environment`
({ pluginId, kind } or null) in canvastty.launch.prepare, so a policy can
allow risky launches only inside an isolated place; the yolo-guard
example now refuses YOLO only outside an environment.

How a plugin uses it: a worktree, container or remote-host plugin
declares its kinds, answers prepare/wrap/resume/release/describe, and
keeps whatever it needs in the opaque ref. Example
examples/plugins/env-worktree: git worktree add in the plugin's data
folder, wrap sets the folder, resume checks it, describe shows the
branch, release removes the worktree and the branch it created unless
kept. Docs (en/ru/zh), schema, plugin-api.d.ts, architecture,
changelogs, and tests/session-environments.test.mjs.
…edaction

Base protection (core, Settings -> Agents, on by default, switchable):
deny-only local rules (shellParse, commandFacts, the deny rules and their
"what to do instead" messages): elevation, pipe to a shell,
download-and-run, disk and format commands, fork bombs, writes or
deletes outside the working folder (/tmp and home included; deleting the
folder itself), with the agent's own plan and memory folders exempt. It
never allows.

Decision hooks (EP-5): permission-gate.mjs is installed as a PreToolUse
hook (Claude Code, Codex, Qwen Code; shells and file writes, YOLO
included) and opencode-decisions.mjs guards OpenCode's
tool.execute.before, both only for launches that need them (base
protection on or a decision plugin applies). They reach RuntimeGateway
over the session's runtime capability, also with status hooks off.
DecisionHooks runs base protection first, then every trusted service
that declares `decide` (permission decision:provide) answers
canvastty.decide in parallel: any deny wins; else any ask (a timeout,
error or unreadable answer is ask); else an allow counts only after the
person's separate "May allow agent actions" confirmation, revoked with
native code trust. Claude takes deny, ask and allow; Codex and Qwen deny
only; OpenCode deny (throw) and allow (permission reply "once"). Cut
input is never allowed.

A decision service may declare `decide.timeoutMs` (1-60 s, 3 s by
default) when it needs longer, for example to ask a local model: the
host waits that long, sends `budgetMs`, and sizes each card's hook,
helper (CANVASTTY_RUNTIME_DECISION_MS) and gateway deadlines at launch
for the longest budget that applies; the default keeps the old
15 s / 12 s / 10 s.

Redaction registry (EP-8): vault values the process reads or writes,
launch secretEnv values per card, values a service registers
(redaction.register) or reads with secrets.get, also wrapped over lines
or JSON-escaped, plus generic key shapes (incl. keys wrapped across
lines). Applied to observe/result and the control CLI's screen, result
and failure details.

How a plugin uses it: a guard or reviewer plugin declares
`decide: { events: ["pre-tool"], appliesTo?, timeoutMs? }` and answers
{ verdict, reason } or null; base protection still runs first and a
failure never allows. Example examples/plugins/deny-rm; docs
(en/ru/zh), schema, d.ts, architecture, changelogs; tests for the deny
table, redaction, precedence, timeout -> ask, allow gating, budgets, and
the real gate through the real gateway and supervisor.
@howdeploy

Copy link
Copy Markdown
Owner

Consolidated into #88 at the maintainer's request. Its branch already includes this implementation (the #81 authentication fix is incorporated through #87). Please continue all follow-up fixes and discussion in #88. Detailed changes-requested review: #88 (review) . Closing this superseded PR preserves its branch, commits and authorship; no code is being merged into main.

@howdeploy howdeploy closed this Sep 27, 2026
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.

3 participants