Skip to content

fix(safety): keep agents away from CanvasTTY's own tokens and control socket, and tell them the way in - #99

Closed
BIackFIame wants to merge 2 commits into
howdeploy:mainfrom
BIackFIame:fix/control-access-guidance
Closed

BIackFIame wants to merge 2 commits into
howdeploy:mainfrom
BIackFIame:fix/control-access-guidance

Conversation

@BIackFIame

Copy link
Copy Markdown
Contributor

Based on main (20f6855). Independent of #96 and #97. It merges cleanly with each of them and with both together, and the full suite passes on that merge.

Incident

Someone started an OpenCode agent by hand inside a plain terminal card. CanvasTTY didn't launch it as an orchestrator, so it had no canvastty_agents MCP server and no control environment. It still tried to control CanvasTTY:

  1. It read the agent-control token file in CanvasTTY's user data folder with cat.
  2. It guessed the protocol of the control socket in the temporary folder, using curl --unix-socket and nc -U.

The gateway refused every attempt with INVALID_REQUEST / "Invalid or unauthenticated control request.". That message doesn't tell the agent what to do instead, so it kept guessing.

What changed

1. Base protection treats CanvasTTY's private data as credentials

safety/commandFacts.ts now records a new fact, appPrivate, and baseProtection.ts denies it under a new rule, app-private. That rule is checked before all the others. A shell or file tool call is refused when it names any of the following:

  • Files in the app's userData folder. These are the agent-control token and descriptor (agent-control/), the gateways' connection records and sockets (browser/runtime, lifecycle/runtime, orchestration/runtime), provider-secrets.bin, plugin-secrets/, account-homes/, github-oauth.json and launch-runs/. The app passes these paths in through canvasTtyPrivateData(app.getPath("userData")) → DecisionHooks → checkBaseProtection. Nothing is hard-coded per platform.
  • The gateways' socket folders under the temporary folder (ctty-control-*, ctty-runtime-*, ctty-orch-*, ctty-<uid>-*). These are recognized without the userData path.
  • Variables that carry a descriptor, an address or a capability ($CANVASTTY_CONTROL_CONNECTION, $CANVASTTY_*_ADDRESS, $CANVASTTY_*_CAPABILITY).

The check doesn't depend on the program. It looks at every word and redirection, at paths after = or : (--unix-socket=P, UNIX-CONNECT:P, file://), at paths quoted inside interpreter one-liners and heredocs, and at globs. It also sees cd into those folders, $(…) substitutions and bash -c. Interpreter code that builds the path from pieces is caught when the app folder's name and a private name appear together.

Other rules:

  • A recursive walk of the app folder (grep -r, rg, find, tar, rsync …) is refused. A plain ls or cat settings.json of that folder is still allowed.
  • The project folder is never affected. Neither are the agent's own config folder (an account home it was launched with), other Unix sockets, or the bundled control CLI (node "$CANVASTTY_CONTROL_CLI" …, canvastty-control.mjs --connection …).

2. The control gateway explains the refusal

In AgentControlGateway, an unauthenticated or malformed NDJSON request still gets INVALID_REQUEST. Its message is now stable guidance (below), and the gateway closes the connection straight after instead of waiting for the 10 s idle timeout. An HTTP request line (curl's GET / HTTP/1.1, a browser, an HTTP/2 preface) gets a minimal HTTP/1.1 403 Forbidden with the same text in text/plain, then the connection closes. The message has no protocol details, token names or paths. The 32-connection cap, the one-request-per-connection rule and the request size limit are unchanged.

3. Token file permissions

Checked, and no change was needed. The token and connection.json are created with mode 0600 in a folder that is chmoded to 0700, and the socket folder is 0700. A new test covers this under umask 0 with a pre-existing 0777 folder.

Messages shown to agents

Base protection deny:

CanvasTTY blocked this: it reads CanvasTTY's own access tokens or secret stores, or talks to its control socket. Agents can't control CanvasTTY this way, and guessing its protocol will not work. If you need other agents, ask the person to start you from CanvasTTY's launcher with the Orchestrator role: you will then get the canvastty_agents tools (spawn_agent, list_routes, wait_for_agent and the rest). Otherwise continue your task without controlling CanvasTTY.

Control endpoint (NDJSON error.message, and the body of the HTTP 403):

CanvasTTY refused this request: this endpoint only accepts requests from sessions CanvasTTY itself launched as orchestrators, and guessing its protocol will not work. If you are an agent and need other agents, ask the person to start you from CanvasTTY's launcher with the Orchestrator role: you will then get the canvastty_agents tools (spawn_agent, list_routes, wait_for_agent and the rest).

Scope and limits

  • Base protection covers what the decision hook covers: Claude Code, Codex, Qwen Code and OpenCode cards that CanvasTTY launched while base protection was on, for shell and file-writing tools. File-read tools (Read, Grep) aren't hooked, the same as before.
  • An agent started by hand in a plain terminal card, as in the incident, has no hook at all. For that agent the gateway reply in part 2 is the guidance it gets.

Tests

  • tests/base-protection-app-private.test.mjs, table-driven, with a fake HOME and a fake userData folder:
    • 38 deny cases. cat, head, grep, cp, base64 <, xxd, strings, sqlite3; globs; cd then a relative read; $(cat …); bash -c; find, grep -r, rg and tar of the app folder; python3 -c, node -e and heredoc readers; a Python path built from pieces; curl --unix-socket (both spellings), nc -U, socat UNIX-CONNECT:, a Python AF_UNIX connect, a runtime socket in userData; the descriptor and capability variables.
    • 17 allow look-alikes. Same-named files inside the project, grep -rn plugin-secrets src, a commit message that mentions them, the app's settings.json, ls of the app folder, the control CLI with its descriptor, the Docker socket and other sockets, ls $TMPDIR, /tmp/ctty-notes, and a URL containing agent-control/token-1.
    • Beyond the tables. File tools are refused and the agent's own account home is allowed. The socket folders are known without userData. The message text has no paths. DecisionHooks forwards the private data.
  • tests/agent-control.test.mjs:
    • A guessed NDJSON request and garbage each get the guidance and a closed connection.
    • An HTTP request gets a 403 with a correct Content-Length and the same text.
    • The token-file permission test from part 3.
  • Full suite 1162/1163 passing, 1 skipped (--test-concurrency=2, fake HOME). Also 1170 passing on this branch merged with fix(startup): load the application surface only after the startup page settled #96 and fix(settings): pasting a provider API key no longer blacks out the window #97.
  • Typecheck, npm run build and audit:secrets pass.

…ores and sockets

Base protection now treats CanvasTTY's private data as credentials. A shell
or file tool call that names the agent-control token or descriptor, the
gateways' connection records, the provider and plugin secret stores,
account homes, the GitHub sign-in or prepared launch runs (paths taken from
the app's own userData folder, passed in by the app), or the control and
runtime socket folders under the temporary folder, is refused whatever the
program: readers, copies, encoders, sqlite3, recursive walks of the app
folder, interpreter one-liners and heredocs, curl --unix-socket, nc -U,
socat and Python sockets. The model is told calmly that agents cannot
control CanvasTTY this way and to ask for an Orchestrator launch.

The project, the app's settings, other sockets, an agent's own account
home and the bundled control CLI are unaffected.
…hen close

An unauthenticated or malformed request to the control endpoint now gets a
stable INVALID_REQUEST whose message says only sessions CanvasTTY launched
as orchestrators may use it and how to get one, with no protocol details,
token names or paths. An HTTP request line (curl, a browser) gets a
minimal 403 with the same text. Either way the connection is closed right
after, instead of waiting for the idle timeout; the connection cap and the
one-request-per-connection rule are unchanged.

Tests cover a guessed NDJSON request, garbage and an HTTP request, and that
the token file is 0600 and its folder 0700 even under umask 0 and a
pre-existing loose folder.
@BIackFIame

Copy link
Copy Markdown
Contributor Author

Combined into #100 together with the other post-merge fixes, so they can be reviewed in one place. The commits are unchanged.

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