From 6a58827a0d8d9b55325bd2d51c541f561720dc08 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:12:50 +0000 Subject: [PATCH 1/2] fix(safety): refuse agent access to CanvasTTY's own tokens, secret stores 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. --- docs/plugins.md | 2 +- docs/plugins.zh-CN.md | 2 +- src/main/index.ts | 4 +- src/main/services/DecisionHooks.ts | 6 +- src/main/services/safety/baseProtection.ts | 27 ++- src/main/services/safety/commandFacts.ts | 190 +++++++++++++++++++-- tests/base-protection-app-private.test.mjs | 143 ++++++++++++++++ 7 files changed, 357 insertions(+), 17 deletions(-) create mode 100644 tests/base-protection-app-private.test.mjs diff --git a/docs/plugins.md b/docs/plugins.md index bec8181c..a7f69147 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -386,7 +386,7 @@ The full example is [`examples/plugins/collect-demo`](../examples/plugins/collec Two safety parts are built in and need no plugin: -- **Base protection** (Settings → Agents, on by default; the person can turn it off) denies, through the same hook, sudo and other elevation, piping downloaded or generated text into a shell, download-and-run, disk and format commands, fork bombs, and writing or deleting outside the working folder: the home folder, other projects and `/tmp` included, and deleting the working folder itself. An agent's own plan and memory folders (`~/.claude/plans`, `~/.claude/projects//memory`, and the same inside the run's `CLAUDE_CONFIG_DIR`) are not "outside". It only ever denies; each reason tells the model what to do instead (a write to `/tmp` suggests a scratch folder inside the project). +- **Base protection** (Settings → Agents, on by default; the person can turn it off) denies, through the same hook, sudo and other elevation, piping downloaded or generated text into a shell, download-and-run, disk and format commands, fork bombs, and writing or deleting outside the working folder: the home folder, other projects and `/tmp` included, and deleting the working folder itself. An agent's own plan and memory folders (`~/.claude/plans`, `~/.claude/projects//memory`, and the same inside the run's `CLAUDE_CONFIG_DIR`) are not "outside". It also denies any use of CanvasTTY's own private data (from the app's userData folder: the agent-control token and descriptor, the gateways' connection records and sockets, the secret stores, account homes; and the control/runtime socket folders under the temporary folder), by any program, interpreter one-liners and socket clients included; the reason points the model at an **Orchestrator** launch and the `canvastty_agents` tools. The bundled control CLI may name its descriptor. It only ever denies; each reason tells the model what to do instead (a write to `/tmp` suggests a scratch folder inside the project). - **Secret redaction**: every text CanvasTTY hands from one agent to another (`observe_agent`, `get_agent_result`, the control CLI's `screen`, `result` and failure details) is masked: provider keys CanvasTTY holds, launch `secretEnv` values, values a service registered with `redaction.register`, also when the terminal wrapped them over lines, plus common key shapes (`sk-…`, GitHub, Slack, AWS, Google, JWT, `Bearer …`, `"apiKey": "…"`, PEM private keys, long random runs). Plugin tool answers, `screen` in session events, card badges and card action messages are masked the same way. host.onStorageChange(listener) notifies every live contribution of the same plugin — canvases, HOME widgets, and separate windows — of writes made through host.storage.set, avoiding polling when a plugin coordinates several surfaces. diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index dafd0a97..7abb4b1d 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -365,7 +365,7 @@ interface PluginSessionEvent { 两项安全功能内置,无需插件: -- **基础保护**(设置 → Agents → Base protection,默认开启;用户可以关闭)通过同一个 hook 拒绝:sudo 及其他提权、把下载或生成的文本管道给 shell、下载后直接运行、磁盘和格式化命令、fork 炸弹,以及在工作文件夹之外写入或删除(包括主目录、其他项目和 `/tmp`),以及删除工作文件夹本身。agent 自己的计划和记忆文件夹(`~/.claude/plans`、`~/.claude/projects//memory`,以及本次运行 `CLAUDE_CONFIG_DIR` 中的相同位置)不算"外部"。它只会拒绝;每条原因都告诉模型应当改做什么(写入 `/tmp` 时建议在项目内建立临时文件夹)。 +- **基础保护**(设置 → Agents → Base protection,默认开启;用户可以关闭)通过同一个 hook 拒绝:sudo 及其他提权、把下载或生成的文本管道给 shell、下载后直接运行、磁盘和格式化命令、fork 炸弹,以及在工作文件夹之外写入或删除(包括主目录、其他项目和 `/tmp`),以及删除工作文件夹本身。agent 自己的计划和记忆文件夹(`~/.claude/plans`、`~/.claude/projects//memory`,以及本次运行 `CLAUDE_CONFIG_DIR` 中的相同位置)不算"外部"。它还会拒绝任何程序(包括解释器单行命令和套接字客户端)使用 CanvasTTY 自己的私有数据(来自应用的 userData 文件夹:agent-control 令牌与描述文件、各网关的连接记录与套接字、密钥存储、账户主目录;以及临时文件夹下的控制/运行时套接字文件夹);拒绝原因会引导模型改用 **Orchestrator** 启动和 `canvastty_agents` 工具。内置控制 CLI 可以引用它的描述文件。它只会拒绝;每条原因都告诉模型应当改做什么(写入 `/tmp` 时建议在项目内建立临时文件夹)。 - **密钥遮蔽**:CanvasTTY 从一个 agent 交给另一个 agent 的所有文本(`observe_agent`、`get_agent_result`,以及 control CLI 的 `screen`、`result` 和失败详情)都会被遮蔽:CanvasTTY 保存的服务商密钥、启动时的 `secretEnv` 值、服务通过 `redaction.register` 注册的值(包括被终端折行拆开的情况),以及常见密钥形式(`sk-…`、GitHub、Slack、AWS、Google、JWT、`Bearer …`、`"apiKey": "…"`、PEM 私钥、长随机串)。插件工具的回答、会话事件中的 `screen`、卡片标记和卡片动作消息也以同样方式遮蔽。 host.onStorageChange(listener) 会把 host.storage.set 的写入通知给同一插件的所有活动界面——画布卡片、HOME 小组件和独立窗口——从而避免轮询。 diff --git a/src/main/index.ts b/src/main/index.ts index 2a9358ef..c294815d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -31,6 +31,7 @@ import { LaunchPipeline } from "./services/LaunchPipeline"; import { EnvironmentRegistry } from "./services/EnvironmentRegistry"; import { DecisionHooks } from "./services/DecisionHooks"; import { SecretRedactionRegistry } from "./services/safety/SecretRedaction"; +import { canvasTtyPrivateData } from "./services/safety/baseProtection"; import { PluginAgentTools } from "./services/PluginAgentTools"; import { PluginSessions } from "./services/PluginSessions"; import { PluginCards } from "./services/PluginCards"; @@ -328,7 +329,8 @@ async function initializeServices(): Promise { baseProtection: () => settings.get().baseProtectionEnabled, services: () => pluginManager!.decisionServices(), call: (pluginId, serviceId, method, params, timeoutMs) => pluginServices!.hostCall(pluginId, serviceId, method, params, timeoutMs), - session: (sessionId) => terminalManager?.decisionContext(sessionId) ?? null + session: (sessionId) => terminalManager?.decisionContext(sessionId) ?? null, + privateData: canvasTtyPrivateData(userDataPath) }); pluginManager.setServiceObserver(async (specs) => { await pluginServices!.sync(specs); diff --git a/src/main/services/DecisionHooks.ts b/src/main/services/DecisionHooks.ts index 32b3e7e9..1ebdedb1 100644 --- a/src/main/services/DecisionHooks.ts +++ b/src/main/services/DecisionHooks.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import type { AgentProviderId, SessionRole } from "../../shared/contracts.ts"; import type { RuntimePermissionDecision, RuntimePermissionRequest } from "./agent-runtime/RuntimeGateway.ts"; import { actionFromHook, checkBaseProtection } from "./safety/baseProtection.ts"; +import type { PrivateData } from "./safety/commandFacts.ts"; import { DEFAULT_DECIDE_TIMEOUT_MS } from "../../agent-runtime/runtime-protocol.mjs"; /** A trusted plugin service that declared `decide` (PluginManager.decisionServices). */ @@ -34,6 +35,8 @@ export interface DecisionHooksDependencies { call(pluginId: string, serviceId: string, method: "canvastty.decide", params: unknown, timeoutMs: number): Promise; session(sessionId: string): DecisionSession | null; home?: string; + /** CanvasTTY's own tokens, secret stores and sockets (canvasTtyPrivateData of its userData folder). */ + privateData?: PrivateData; timeoutMs?: number; } @@ -101,7 +104,8 @@ export class DecisionHooks { root: session.cwd, commandCwd: request.cwd, home, - agentRoots: [join(home, ".claude"), ...session.configDirs] + agentRoots: [join(home, ".claude"), ...session.configDirs], + ...(this.deps.privateData ? { privateData: this.deps.privateData } : {}) }); if (base) return { behavior: "deny", message: base.message }; } diff --git a/src/main/services/safety/baseProtection.ts b/src/main/services/safety/baseProtection.ts index a6f81ed6..6e6381c5 100644 --- a/src/main/services/safety/baseProtection.ts +++ b/src/main/services/safety/baseProtection.ts @@ -1,6 +1,7 @@ import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { isPathInside } from '../../../agent-runtime/path-inside.mjs'; -import { analyzeAction, commandFromArgv, realish, type HardFacts, type ToolAction } from './commandFacts.ts'; +import { analyzeAction, commandFromArgv, realish, type HardFacts, type PrivateData, type ToolAction } from './commandFacts.ts'; /** * Base protection: a small set of deny-only rules the core applies to every local agent tool call it sees through @@ -9,11 +10,12 @@ import { analyzeAction, commandFromArgv, realish, type HardFacts, type ToolActio * model: local rules only, no git, no network. */ -const BASE_DENY_RULES = ['elevation', 'pipe-to-shell', 'download-exec', 'disk', 'fork-bomb', 'delete-outside', 'write-outside'] as const; +const BASE_DENY_RULES = ['app-private', 'elevation', 'pipe-to-shell', 'download-exec', 'disk', 'fork-bomb', 'delete-outside', 'write-outside'] as const; export type BaseDenyRule = typeof BASE_DENY_RULES[number]; /** What the model reads: why the call was refused and what to do instead. */ const DENY_MESSAGES: Readonly> = { + 'app-private': '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.', elevation: 'CanvasTTY blocked this command: it asks for administrator rights (sudo, doas, runas). Do the work without elevation; if the task truly needs it, stop and ask the person to run that step.', 'pipe-to-shell': 'CanvasTTY blocked this command: it pipes downloaded or generated text straight into a shell or interpreter. Download the file first, show what it contains, and ask the person before running it.', 'download-exec': 'CanvasTTY blocked this command: it downloads code and runs it in one step. Download the file first, show what it contains, and ask the person before running it.', @@ -77,8 +79,24 @@ export function actionFromHook(toolName: string, toolInput: unknown, preview: st return { kind: 'edit', command: null, commandCwd: null, paths: path ? [path] : [] }; } +/** + * CanvasTTY's own private data under its userData folder (the app passes `app.getPath('userData')`; tests pass a + * temporary folder): the control token and descriptor, the gateways' connection records and sockets, the secret + * stores, the per-account homes and the prepared launch runs. Its settings and layouts are not listed. + */ +export function canvasTtyPrivateData(userDataPath: string): PrivateData { + const names = ['agent-control', join('browser', 'runtime'), join('lifecycle', 'runtime'), join('orchestration', 'runtime'), + 'provider-secrets.bin', 'plugin-secrets', 'account-homes', 'github-oauth.json', 'launch-runs']; + return { + appRoots: [userDataPath], + paths: names.map(name => join(userDataPath, name)), + markers: ['agent-control', 'provider-secrets', 'plugin-secrets', 'account-homes', 'github-oauth'] + }; +} + /** The first deny rule a set of facts breaks, in a fixed order. */ export function denyRule(facts: HardFacts): BaseDenyRule | null { + if (facts.appPrivate) return 'app-private'; if (facts.elevation) return 'elevation'; if (facts.pipeToShell) return 'pipe-to-shell'; if (facts.downloadExec) return 'download-exec'; @@ -97,6 +115,8 @@ export function denyRule(facts: HardFacts): BaseDenyRule | null { export function checkBaseProtection(input: { toolName: string; toolInput: unknown; preview?: string | null; root: string; commandCwd?: string | null; home?: string; agentRoots?: readonly string[]; + /** CanvasTTY's own private data (canvasTtyPrivateData); its socket folders are known without it. */ + privateData?: PrivateData; }): BaseVerdict | null { try { const action = actionFromHook(input.toolName, input.toolInput, input.preview ?? null); @@ -105,7 +125,8 @@ export function checkBaseProtection(input: { if (!action.commandCwd && input.commandCwd) action.commandCwd = input.commandCwd; const facts = analyzeAction(action, input.root, { ...(input.home ? { home: input.home } : {}), - ...(input.agentRoots ? { agentRoots: input.agentRoots } : {}) + ...(input.agentRoots ? { agentRoots: input.agentRoots } : {}), + ...(input.privateData ? { privateData: input.privateData } : {}) }); const rule = denyRule(facts); if (!rule) return null; diff --git a/src/main/services/safety/commandFacts.ts b/src/main/services/safety/commandFacts.ts index 58bc9104..aefd9477 100644 --- a/src/main/services/safety/commandFacts.ts +++ b/src/main/services/safety/commandFacts.ts @@ -7,8 +7,9 @@ import { lexShell, shellQuote, type Segment, type Word } from './shellParse.ts'; /** * The hard facts base protection decides on, computed by code from one tool call and the working folder. * Nothing is executed and nothing is read except `realpath` of the paths involved. Only the facts a deny rule - * needs are computed: elevation, a pipe into a shell, download-and-run, disk commands, a fork bomb, and writes or - * deletes outside the working folder (deleting the folder itself included). + * needs are computed: elevation, a pipe into a shell, download-and-run, disk commands, a fork bomb, writes or + * deletes outside the working folder (deleting the folder itself included), and any use of CanvasTTY's own private + * data (its access tokens, secret stores and control sockets). */ /** One tool call, source-neutral: a shell command or the files a file tool writes. */ @@ -43,8 +44,18 @@ export interface HardFacts { deletesOutside: boolean; /** Absolute targets written outside the working folder (for the temporary-folder advice). */ outsideWrites: string[]; + /** Names, reads or connects to CanvasTTY's own private data: tokens, secret stores, control/runtime sockets. */ + appPrivate: boolean; } +/** + * CanvasTTY's own private data, as the running app knows it (its userData folder differs per platform and per + * profile, so it is passed in, never guessed). `appRoots`: the app's data folders; `paths`: the private files and + * folders in them (tokens, connection records, secret stores, account homes); `markers`: names that, together with + * an app root's own name, identify those paths inside interpreter code that builds a path piece by piece. + */ +export interface PrivateData { appRoots: readonly string[]; paths: readonly string[]; markers: readonly string[] } + // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- @@ -70,14 +81,26 @@ const DEVICE = /^(?:\/dev\/(?:r?disk\d|sd[a-z]|hd[a-z]|nvme\d|mmcblk\d|xvd[a-z]| const HARMLESS_DEVICE = /^\/dev\/(?:null|zero|u?random|stdin|stdout|stderr|tty|fd\/\d+)$|^(?:nul|con)$/iu; const WINDOWS_ABSOLUTE = /^(?:[A-Za-z]:[\\/]|[A-Za-z]:$|\\\\)/u; -export interface PathContext { root: string; rootReal: string; home: string; temp: string; agentRoots: string[] } +export interface PathContext { + root: string; rootReal: string; home: string; temp: string; agentRoots: string[]; + /** CanvasTTY's private paths and data folders (as given and resolved), and the temporary folders its sockets live in. */ + privatePaths: string[]; appRoots: string[]; appNames: string[]; markers: string[]; tempRoots: string[]; +} /** * `agentRoots`: the agent's own config folders (Claude's ~/.claude or the run's CLAUDE_CONFIG_DIR). Their plan and * memory folders belong to the agent, so writing there is not a write outside the project. */ -function pathContext(root: string, home = homedir(), agentRoots?: readonly string[]): PathContext { - return { root, rootReal: realish(resolve(root)), home, temp: tmpdir(), agentRoots: (agentRoots ?? [join(home, '.claude')]).map(dir => realish(resolve(dir))) }; +function pathContext(root: string, home = homedir(), agentRoots?: readonly string[], privateData?: PrivateData): PathContext { + const both = (paths: readonly string[]): string[] => [...new Set(paths.filter(path => path && isAbsolute(path)).flatMap(path => [resolve(path), realish(resolve(path))]))]; + const temp = tmpdir(); + return { + root, rootReal: realish(resolve(root)), home, temp, agentRoots: (agentRoots ?? [join(home, '.claude')]).map(dir => realish(resolve(dir))), + privatePaths: both(privateData?.paths ?? []), appRoots: both(privateData?.appRoots ?? []), + appNames: [...new Set((privateData?.appRoots ?? []).map(path => basename(path).toLowerCase()).filter(name => name.length >= 4))], + markers: (privateData?.markers ?? []).map(marker => marker.toLowerCase()).filter(marker => marker.length >= 6), + tempRoots: both([temp, '/tmp', '/private/tmp', '/var/tmp']) + }; } const AGENT_SERVICE_DIR = /^(?:plans|projects[\\/][^\\/]+[\\/]memory)(?:[\\/]|$)/u; @@ -193,9 +216,11 @@ interface Acc { ctx: PathContext; writes: Target[]; deletes: Target[]; - flags: { elevation: boolean; pipeToShell: boolean; downloadExec: boolean; disk: boolean; forkBomb: boolean }; + flags: { elevation: boolean; pipeToShell: boolean; downloadExec: boolean; disk: boolean; forkBomb: boolean; appPrivate: boolean }; depth: number; budget: number; + /** Paths still to be checked against CanvasTTY's private data (each costs a realpath). */ + privateBudget: number; } interface Stdin { pipeIn: boolean; heredoc: string | null } @@ -227,6 +252,7 @@ function analyzeSegment(segment: Segment, cwd: string | null, acc: Acc, download if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(first.text) || LEADING_RESERVED.has(first.text)) words.shift(); else break; } + checkPrivate(segment, words, cwd, acc); for (const word of segment.words) inspectWord(word, acc); for (const redirect of segment.redirects) { if (redirect.fdDup || !redirect.target) continue; @@ -437,6 +463,7 @@ function runInterpreter(program: string, argWords: Word[], cwd: string | null, a if (python && text === '-m') return cwd; if (/^(?:-c|-e|--eval|-p|--print|-r|-E)$/u.test(text) || program === 'deno' && text === 'eval' || program === 'osascript' && text === '-e') { if (innerFetch) acc.flags.downloadExec = true; + if (args[i + 1] !== undefined && codeNamesPrivate(args[i + 1]!, acc.ctx)) acc.flags.appPrivate = true; if (argWords[i + 1] && fetchesIn(args[i + 1]!) && /\b(?:exec|eval|system|spawn|child_process|subprocess|os\.system)\b/u.test(args[i + 1]!)) acc.flags.downloadExec = true; return cwd; } @@ -449,9 +476,148 @@ function runInterpreter(program: string, argWords: Word[], cwd: string | null, a return cwd; } if (stdin.pipeIn) acc.flags.pipeToShell = true; + // A program read from a heredoc (`python3 - < privateHit(text, cwd, acc, false)) || codeNamesPrivate(stdin.heredoc, acc.ctx))) acc.flags.appPrivate = true; return cwd; } +// --------------------------------------------------------------------------- +// CanvasTTY's own private data +// --------------------------------------------------------------------------- + +/** The folders CanvasTTY's gateways put their sockets in, under a temporary folder (`ctty-control-XXXX`, …). */ +const SOCKET_DIR = /^ctty-(?:control|runtime|orch|user|\d+)-/u; +/** Variables that carry a control descriptor, a gateway address or a capability. */ +const PRIVATE_ENV = /^(?:ENV:)?CANVASTTY_(?:CONTROL_CONNECTION|[A-Z_]*_(?:CAPABILITY|ADDRESS))$/u; +/** CanvasTTY's own control CLI reads its descriptor itself: an orchestrator may name it. */ +const CONTROL_CLI = /^canvastty-control(?:\.mjs)?$/u; +/** Programs that walk folders by themselves: naming a folder that holds private data reads it. */ +const RECURSIVE = new Set(['rg', 'ag', 'ack', 'find', 'fd', 'tar', 'bsdtar', 'zip', '7z', 'rsync', 'ditto', 'scp', 'rclone']); +const GLOB_CHAR = /[*?[]/u; +const MAX_PRIVATE_CHECKS = 256; +const MAX_SCANNED_TEXT = 64 * 1024; + +const parts = (path: string): string[] => path.split(/[\\/]+/u).filter(Boolean); + +/** A glob component (`token-*`, `ctty-control-????`) against one real name. */ +function componentMatches(pattern: string, name: string): boolean { + if (!GLOB_CHAR.test(pattern)) return pattern === name; + let source = ''; + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i]!; + if (char === '*') source += '.*'; + else if (char === '?') source += '.'; + else if (char === '[') { + const close = pattern.indexOf(']', i + 2); + if (close < 0) { source += '\\['; continue; } + source += `[${pattern.slice(i + 1, close).replace(/^!/u, '^').replace(/[\\\]]/gu, '\\$&')}]`; + i = close; + } else source += char.replace(/[.+^${}()|\\\]]/gu, '\\$&'); + } + try { return new RegExp(`^${source}$`, 'u').test(name); } catch { return true; } +} + +/** + * Whether one path (absolute, maybe a glob) is CanvasTTY's private data: inside a private path, a folder of the app + * that holds one when the command walks folders, or a gateway's socket folder under a temporary folder. + */ +function privatePath(abs: string, glob: boolean, recursive: boolean, ctx: PathContext): boolean { + if (!glob) { + // The project, and the agent's own config folder (an account home it was launched with), are its own. + if (isPathInside(ctx.rootReal, abs) || ctx.agentRoots.some(dir => isPathInside(dir, abs))) return false; + if (ctx.privatePaths.some(path => isPathInside(path, abs))) return true; + if (recursive && ctx.privatePaths.some(path => isPathInside(abs, path)) && ctx.appRoots.some(root => isPathInside(root, abs))) return true; + return ctx.tempRoots.some(temp => isPathInside(temp, abs, { allowRoot: false }) && SOCKET_DIR.test(parts(relative(temp, abs))[0] ?? '')); + } + // A glob: the folders before its first wildcard resolved, the rest matched name by name. + const all = parts(abs); + const first = all.findIndex(part => GLOB_CHAR.test(part)); + const prefix = realish((abs.startsWith('/') ? '/' : '') + all.slice(0, first).join('/')); + const pattern = [...parts(prefix), ...all.slice(first)]; + const matchesFrom = (target: string[]): number => { + let i = 0; + while (i < pattern.length && i < target.length && componentMatches(pattern[i]!, target[i]!)) i++; + return i; + }; + for (const path of ctx.privatePaths) { + const target = parts(path); + const matched = matchesFrom(target); + if (matched >= target.length) return true; + if (matched === pattern.length && recursive && ctx.appRoots.some(root => parts(root).length <= pattern.length)) return true; + } + return ctx.tempRoots.some(temp => { + const target = parts(temp); + if (matchesFrom(target) < target.length || pattern.length <= target.length) return false; + const next = pattern[target.length]!; + return SOCKET_DIR.test(next) || next.startsWith('ctty') && GLOB_CHAR.test(next); + }); +} + +/** Expands `~`, $HOME and $TMPDIR in a path found inside a word or in code; null when it is not a path. */ +function codePath(text: string, ctx: PathContext): string | null { + let value = text.trim().replace(/^file:\/\//iu, '/'); + value = value.replace(/^(?:\$HOME|\$\{HOME\})(?=[\\/]|$)/u, ctx.home).replace(/^(?:\$TMPDIR|\$\{TMPDIR\})(?=[\\/]|$)/u, ctx.temp); + if (value === '~' || value.startsWith('~/')) value = ctx.home + value.slice(1); + return value.startsWith('/') && value.length > 1 ? value : null; +} + +/** Paths inside a word or a program text: after `=` or `:` (`--unix-socket=P`, `UNIX-CONNECT:P`), quoted strings, bare tokens. */ +function privateCandidates(text: string, ctx: PathContext): string[] { + if (text.length > MAX_SCANNED_TEXT) text = text.slice(0, MAX_SCANNED_TEXT); + const found = new Set(); + const add = (value: string | undefined): void => { const path = value ? codePath(value, ctx) : null; if (path && found.size < 64) found.add(path); }; + for (const match of text.matchAll(/[=:]((?:~|\$\{?(?:HOME|TMPDIR)\}?|\/)[^\s,;'"`()<>|&]*)/gu)) add(match[1]); + for (const match of text.matchAll(/(['"`])([^'"`\n]{1,4096}?)\1/gu)) add(match[2]); + for (const token of text.split(/[\s,;()[\]{}<>|&'"`=]+/u)) if (/^(?:~|\$\{?(?:HOME|TMPDIR)\}?|\/)/u.test(token)) add(token); + return [...found]; +} + +/** Interpreter code that builds a private path from pieces: an app folder's name together with a private name. */ +function codeNamesPrivate(code: string, ctx: PathContext): boolean { + const lower = code.slice(0, MAX_SCANNED_TEXT).toLowerCase(); + if (/ctty-(?:control|runtime|orch)-/u.test(lower)) return true; + return ctx.appNames.some(name => lower.includes(name)) && ctx.markers.some(marker => lower.includes(marker)); +} + +function privateHit(text: string, cwd: string | null, acc: Acc, recursive: boolean, glob = GLOB_CHAR.test(text)): boolean { + if (--acc.privateBudget < 0) return false; + if (!isAbsolute(text) && cwd === null) return false; + return privatePath(glob ? resolve(cwd ?? acc.ctx.rootReal, text) : realish(resolve(cwd ?? acc.ctx.rootReal, text)), glob, recursive, acc.ctx); +} + +/** The command is CanvasTTY's control CLI (`canvastty-control.mjs …`, `node "$CANVASTTY_CONTROL_CLI" …`). */ +function runsControlCli(words: readonly Word[]): boolean { + const cli = (word: Word | undefined): boolean => Boolean(word && (word.vars.length === 1 && word.vars[0] === 'CANVASTTY_CONTROL_CLI' && /^\$\{?CANVASTTY_CONTROL_CLI\}?$/u.test(word.text) || !word.vars.length && !word.substitution && CONTROL_CLI.test(programName(word.text)))); + if (cli(words[0])) return true; + if (!words[0] || !INTERPRETERS.has(programName(words[0].text))) return false; + return cli(words.slice(1).find(word => !word.text.startsWith('-'))); +} + +/** + * One segment against CanvasTTY's private data: every word (and each path inside it), every redirection, and the + * variables that carry a descriptor or a capability. Whatever program reads, copies, encodes or connects to them, + * the command uses them. CanvasTTY's own control CLI is the one program that may name its descriptor. + */ +function checkPrivate(segment: Segment, words: Word[], cwd: string | null, acc: Acc): void { + if (acc.flags.appPrivate || acc.privateBudget <= 0) return; + if (runsControlCli(words)) return; + const recursive = words.some(word => RECURSIVE.has(programName(word.text)) || /^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive|--archive)$/u.test(word.text)) + || programName(words[0]?.text ?? '') === 'cp' && words.some(word => /^-[a-zA-Z]*a/u.test(word.text)); + const targets = [...segment.words, ...segment.redirects.filter(redirect => !redirect.fdDup && redirect.target).map(redirect => redirect.target!)]; + for (const word of targets) { + if (word.vars.some(name => PRIVATE_ENV.test(name))) { acc.flags.appPrivate = true; return; } + if (word.substitution) continue; + const text = expand(word, cwd, acc.ctx); + if (text === null || text === '') continue; + const candidates = new Set([text, ...(text.length > 1 && /[=:'"\s]/u.test(text) ? privateCandidates(text, acc.ctx) : [])]); + for (const candidate of candidates) { + if (!privateHit(candidate, cwd, acc, recursive, candidate === text ? word.glob : GLOB_CHAR.test(candidate))) continue; + acc.flags.appPrivate = true; + return; + } + } +} + function classifyProgram(program: string, argWords: Word[], cwd: string | null, acc: Acc, stdin: Stdin, downloadedHere: Target[]): void { const args = argWords.map(word => word.text); const windows = WINDOWS_BUILTINS.has(program) || args.some(arg => /^\/[sq]$/iu.test(arg)); @@ -843,13 +1009,17 @@ function gitEffect(sub: string, rest: readonly string[]): 'read' | 'write' | 'de /** Converts an argv array (Codex style `["bash","-lc","…"]`) into one command string. */ export function commandFromArgv(argv: readonly string[]): string { return argv.map(shellQuote).join(' '); } -export function analyzeAction(action: ToolAction, root: string, options: { home?: string; agentRoots?: readonly string[] } = {}): HardFacts { - const ctx = pathContext(root, options.home, options.agentRoots); - const acc: Acc = { ctx, writes: [], deletes: [], depth: 0, budget: 64, flags: { elevation: false, pipeToShell: false, downloadExec: false, disk: false, forkBomb: false } }; +export function analyzeAction(action: ToolAction, root: string, options: { home?: string; agentRoots?: readonly string[]; privateData?: PrivateData } = {}): HardFacts { + const ctx = pathContext(root, options.home, options.agentRoots, options.privateData); + const acc: Acc = { ctx, writes: [], deletes: [], depth: 0, budget: 64, privateBudget: MAX_PRIVATE_CHECKS, + flags: { elevation: false, pipeToShell: false, downloadExec: false, disk: false, forkBomb: false, appPrivate: false } }; const commandCwd = action.commandCwd ? resolveTarget(action.commandCwd, ctx.rootReal, ctx) : null; const cwd = commandCwd ? commandCwd.abs : ctx.rootReal; if (action.kind === 'shell' && action.command) analyzeText(action.command, cwd, acc); - else if (action.kind === 'edit') acc.writes.push(...action.paths.map(path => resolveTarget(path, cwd, ctx))); + else if (action.kind === 'edit') { + acc.writes.push(...action.paths.map(path => resolveTarget(path, cwd, ctx))); + if (action.paths.some(path => { const text = codePath(path, ctx) ?? path; return privateHit(text, cwd, acc, false, false); })) acc.flags.appPrivate = true; + } const outside = acc.writes.filter(t => t.where === 'outside' && !t.device && !(t.abs && isAgentServicePath(t.abs, ctx))); return { ...acc.flags, diff --git a/tests/base-protection-app-private.test.mjs b/tests/base-protection-app-private.test.mjs new file mode 100644 index 00000000..e79b330d --- /dev/null +++ b/tests/base-protection-app-private.test.mjs @@ -0,0 +1,143 @@ +/** + * Base protection of CanvasTTY's own private data: its control token and descriptor, the gateways' connection + * records and sockets, the secret stores and account homes. Everything lives in temporary folders: HOME and the + * userData folder are fakes, nothing real is read, and no socket is opened. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { analyzeAction } from "../src/main/services/safety/commandFacts.ts"; +import { canvasTtyPrivateData, checkBaseProtection, denyRule } from "../src/main/services/safety/baseProtection.ts"; +import { DecisionHooks } from "../src/main/services/DecisionHooks.ts"; + +const base = realpathSync(mkdtempSync(join(tmpdir(), "canvastty-app-private-"))); +const home = join(base, "home"); +const project = join(base, "project"); +const userData = join(home, "Library", "Application Support", "canvastty"); +const control = join(userData, "agent-control"); +for (const dir of [project, join(project, "src"), join(project, "agent-control"), control, join(userData, "plugin-secrets"), join(userData, "account-homes", "acct-1", ".claude"), join(userData, "lifecycle", "runtime")]) { + mkdirSync(dir, { recursive: true }); +} +writeFileSync(join(control, "token-0123abcd"), "fake-token\n", { mode: 0o600 }); +writeFileSync(join(control, "connection.json"), "{}\n", { mode: 0o600 }); +writeFileSync(join(userData, "settings.json"), "{}\n"); +writeFileSync(join(project, "agent-control", "token-1"), "fixture\n"); +process.on("exit", () => rmSync(base, { recursive: true, force: true })); + +const privateData = canvasTtyPrivateData(userData); +const token = join(control, "token-0123abcd"); +const shell = (command) => ({ kind: "shell", command, commandCwd: null, paths: [] }); +const rule = (command, extra = {}) => denyRule(analyzeAction(shell(command), project, { home, privateData, ...extra })); +const q = (path) => `'${path}'`; +const sock = "$TMPDIR/ctty-control-Ab12Cd/c.sock"; + +const DENY = [ + // Reading the token and the descriptor, whatever the program. + `cat ${q(token)}`, + `cat "$HOME/Library/Application Support/canvastty/agent-control/token-0123abcd"`, + "cat ~/Library/Application\\ Support/canvastty/agent-control/token-*", + `head -c 64 ${q(join(control, "connection.json"))}`, + `grep -a . ${q(token)}`, + `cp ${q(token)} ./copy.txt`, + `base64 < ${q(token)}`, + `xxd ${q(join(userData, "provider-secrets.bin"))}`, + `strings ${q(join(userData, "provider-secrets.bin"))}`, + `sqlite3 ${q(join(userData, "account-homes", "acct-1", "state.db"))} .dump`, + `cat ${q(join(userData, "github-oauth.json"))}`, + `cat ${q(join(userData, "lifecycle", "runtime", "connection.json"))}`, + `ls ${q(control)}`, + `cd ${q(control)} && cat token-0123abcd`, + `cd ${q(userData)} && cat agent-control/token-0123abcd`, + `echo "$(cat ${q(token)})"`, + `bash -c "cat ${q(token)}"`, + `find ${q(userData)} -name 'token-*'`, + `grep -r token ${q(userData)}`, + `rg secret ${q(userData)}`, + `tar -czf out.tgz ${q(userData)}`, + `cat ${q(userData)}/*/token-*`, + "cat \"$CANVASTTY_CONTROL_CONNECTION\"", + "echo $CANVASTTY_RUNTIME_CAPABILITY", + // Interpreter one-liners and heredocs. + `python3 -c "print(open('${token}').read())"`, + "python3 -c \"import os;p=os.path.join(os.path.expanduser('~'),'Library','Application Support','canvastty','agent-control');print(os.listdir(p))\"", + "node -e \"console.log(require('fs').readFileSync(process.env.HOME + '/Library/Application Support/canvastty/plugin-secrets/x', 'utf8'))\"", + `python3 - <<'EOF'\nprint(open("${token}").read())\nEOF`, + // The control and runtime sockets. + `curl --unix-socket ${sock} http://localhost/`, + `curl -s --unix-socket=${sock} http://x/`, + `nc -U ${sock}`, + "echo '{\"v\":1}' | nc -U /tmp/ctty-orch-501-abcd1234/o.sock", + `socat - UNIX-CONNECT:${sock}`, + "ls $TMPDIR/ctty-control-*", + "cat $TMPDIR/ctty-*/c.sock", + "python3 -c \"import socket,os;s=socket.socket(socket.AF_UNIX);s.connect(os.environ['TMPDIR']+'/ctty-control-x/c.sock')\"", + `nc -U ${q(join(userData, "lifecycle", "runtime", "r-ab12.sock"))}`, + // A controlled-looking word that is not the CLI does not excuse the rest. + `cat ${q(token)} canvastty-control.mjs` +]; + +const ALLOW = [ + "cat src/agent-control.ts", + "cat agent-control/token-1", + "grep -rn \"plugin-secrets\" src", + "git commit -m \"fix agent-control token file mode\"", + `cat ${q(join(userData, "settings.json"))}`, + `ls ${q(userData)}`, + "node \"$CANVASTTY_CONTROL_CLI\" list", + `node /Applications/CanvasTTY.app/Contents/Resources/agent-control/canvastty-control.mjs --connection ${q(join(control, "connection.json"))} list`, + "curl --unix-socket /var/run/docker.sock http://localhost/version", + "nc -U /tmp/other.sock", + "ls $TMPDIR", + "ls /tmp/ctty-notes", + "python3 -c \"print('canvastty')\"", + "cat ~/.config/other/token", + "node -e \"console.log(1)\"", + "curl https://example.com/agent-control/token-1", + "echo hi > out.txt" +]; + +test("CanvasTTY's own tokens, secret stores and sockets are refused for every reader and client", () => { + for (const command of DENY) assert.equal(rule(command), "app-private", command); +}); + +test("look-alikes in the project, the app's other files and other sockets stay allowed", () => { + for (const command of ALLOW) assert.equal(rule(command), null, command); +}); + +test("file tools that name private data are refused; the agent's own account home is its own", () => { + const check = (toolName, toolInput, extra = {}) => checkBaseProtection({ toolName, toolInput, root: project, home, privateData, ...extra }); + assert.equal(check("Write", { file_path: join(control, "token-x"), content: "x" })?.rule, "app-private"); + assert.equal(check("edit", { file_path: "~/Library/Application Support/canvastty/plugin-secrets/p.json" })?.rule, "app-private"); + const accountHome = join(userData, "account-homes", "acct-1", ".claude"); + assert.equal(check("Write", { file_path: join(accountHome, "plans", "p.md") }, { agentRoots: [accountHome] }), null); + assert.equal(check("Bash", { command: `cat ${q(join(accountHome, "projects", "p", "memory", "MEMORY.md"))}` }, { agentRoots: [accountHome] }), null); + assert.equal(check("Bash", { command: `cat ${q(token)}` }, { agentRoots: [accountHome] })?.rule, "app-private"); +}); + +test("without the app's folder the socket folders are still known; the userData paths are not guessed", () => { + assert.equal(denyRule(analyzeAction(shell(`nc -U ${sock}`), project, { home })), "app-private"); + assert.equal(denyRule(analyzeAction(shell(`cat ${q(token)}`), project, { home })), null); +}); + +test("the message tells the model calmly why and what to do instead, without paths or protocol details", () => { + const verdict = checkBaseProtection({ toolName: "Bash", toolInput: { command: `cat ${q(token)}` }, root: project, home, privateData }); + assert.equal(verdict.rule, "app-private"); + assert.match(verdict.message, /^CanvasTTY blocked this: it reads CanvasTTY's own access tokens/u); + assert.match(verdict.message, /Orchestrator role/u); + assert.match(verdict.message, /canvastty_agents tools \(spawn_agent, list_routes, wait_for_agent/u); + assert.doesNotMatch(verdict.message, /token-|\.sock|agent-control|Application Support|ctty-/u); +}); + +test("decision hooks pass the app's private data to base protection", async () => { + const hooks = new DecisionHooks({ + baseProtection: () => true, services: () => [], call: async () => null, home, privateData, + session: () => ({ provider: "opencode", role: "agent", cwd: project, configDirs: [] }) + }); + const decision = await hooks.decide("s1", { toolName: "bash", toolInput: { command: `cat ${q(token)}` }, toolInputPreview: null, cwd: null, truncated: false }, new AbortController().signal); + assert.equal(decision.behavior, "deny"); + assert.match(decision.message, /Orchestrator role/u); + const ordinary = await hooks.decide("s1", { toolName: "bash", toolInput: { command: "cat src/a.ts" }, toolInputPreview: null, cwd: null, truncated: false }, new AbortController().signal); + assert.equal(ordinary.behavior, "none"); +}); From 4948851ddfc396ca26a98e0f50d8f7fb93a06863 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:12:51 +0000 Subject: [PATCH 2/2] fix(agent-control): answer refused and HTTP requests with guidance, then 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. --- CHANGELOG.md | 1 + CHANGELOG.ru.md | 1 + CHANGELOG.zh-CN.md | 1 + .../agent-control/AgentControlGateway.ts | 27 ++++++++- tests/agent-control.test.mjs | 58 ++++++++++++++++++- 5 files changed, 85 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf3c1d5e..eceb3353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Unreleased +- Base protection now also refuses any use of CanvasTTY's own private data by an agent's shell or file tool: reading, copying or encoding the agent-control token and descriptor, the gateways' connection records, the provider and plugin secret stores, account homes, the GitHub sign-in and prepared launch runs (by any program, interpreter one-liners and heredocs included), and connecting to CanvasTTY's control or runtime sockets (`curl --unix-socket`, `nc -U`, `socat`, a Python socket). The model is told calmly that agents cannot control CanvasTTY this way and to ask the person for an **Orchestrator** launch, which brings the `canvastty_agents` tools. The paths come from the app's own userData folder; the project, the app's settings, other sockets and the bundled control CLI are unaffected. The control endpoint now answers an unauthenticated or malformed request, and an HTTP request (a minimal 403), with the same guidance instead of a bare error, and closes the connection. - Added an **Auto** launch profile for agents whose CLI has a native auto mode, next to Normal (still the default) and YOLO: Codex `--approve-for-me` (its own reviewer in its `workspace-write` sandbox), Claude Code `--permission-mode auto` with its sandbox (`sandbox.enabled`, `autoAllowBashIfSandboxed: false`, merged into the one `--settings`), Grok `--permission-mode auto`; also the control CLI's `create --profile auto` and plugin `sessions.create`. A launch contributor may answer `thirdPartyModel: true` (an API or Ollama account): Auto then runs as the CLI's accept-edits mode in the same sandbox, and the card shows **auto · edits**. Codex no longer stops at "Hooks need review" for the hooks CanvasTTY adds itself (per-run `-c hooks.state`, nothing written to `~/.codex`; plugins cannot pass `-c hooks…`), and a Codex subagent in (or below) the folder the person chose for its orchestrator is not asked to trust it again (per-run `-c projects`); plugins get that folder as `trustedFolder`. Claude Code's «✳» title now reads as idle: a hooked Claude card leaves `needs_approval` only through its hooks, or, when the person declined its prompt, a moment after the answer. Example: `examples/plugins/launch-env` (Local model profile). - Added two launch points for account plugins. A launcher `select` may declare `"optionsFrom": "service"`: the launcher asks the service `canvastty.launch.options` (3 s) and lists up to 64 more choices after the declared ones, such as the plugin's own accounts; the saved value is then checked by the service when it prepares. Orchestrators may pass plugin launch options to `spawn_agent` as `launchOptions`, checked exactly like the launcher's. A plugin's inline Claude `--settings` is merged into CanvasTTY's own (Claude Code keeps only the last one, which dropped the lifecycle and decision hooks); approval and hook keys in it are refused. Example: `examples/plugins/launch-env` (Profile). - Added plugin services (manifest apiVersion 2, `services`): bundled single-file JavaScript that runs as a supervised child process only after the separate per-plugin **Extension native code** confirmation in Settings → Agents (off by default, never granted by install, revoked by update, module change, disable, or a changed entry file). Services get a minimal environment without keys or CanvasTTY internals, speak JSON-RPC over stdio with 1 MB messages and 15 s timeouts, restart with backoff, stop on disable, uninstall, update and quit, and log to a bounded per-plugin log. Plugin surfaces call their own plugin's services through `host.service.request` and receive `host.service.onEvent`; services may call back `log`, own-plugin `storage` and `event`, and read their own plugin's secrets with `secrets.get` (needs `secrets`). Example: `examples/plugins/service-echo` (its service also reads a token the page saved). diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index 514e150b..59c5e807 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -4,6 +4,7 @@ ## Unreleased +- Базовая защита теперь также запрещает shell- и файловым инструментам агента любые обращения к собственным закрытым данным CanvasTTY: чтение, копирование или кодирование токена и дескриптора agent-control, файлов подключения шлюзов, хранилищ секретов провайдеров и плагинов, домашних папок аккаунтов, входа GitHub и подготовленных запусков (любой программой, включая однострочники интерпретаторов и heredoc), а также подключение к управляющим и runtime-сокетам CanvasTTY (`curl --unix-socket`, `nc -U`, `socat`, сокет Python). Модель спокойно получает объяснение, что так управлять CanvasTTY нельзя, и совет попросить человека запустить её с ролью **Orchestrator**, которая даёт инструменты `canvastty_agents`. Пути берутся из собственной папки userData приложения; проект, настройки приложения, другие сокеты и встроенный CLI управления не затронуты. Управляющий endpoint теперь отвечает на неаутентифицированный или некорректный запрос, а также на HTTP-запрос (минимальный 403) тем же объяснением вместо голой ошибки и закрывает соединение. - Добавлен профиль запуска **Авто** для агентов, у чьего CLI есть собственный авторежим, рядом с «Обычным» (он остаётся по умолчанию) и YOLO: Codex `--approve-for-me` (его собственная проверка в песочнице `workspace-write`), Claude Code `--permission-mode auto` с его песочницей (`sandbox.enabled`, `autoAllowBashIfSandboxed: false`, в единственном `--settings`), Grok `--permission-mode auto`; также `create --profile auto` в CLI управления и `sessions.create` плагинов. Вклад запуска может ответить `thirdPartyModel: true` (аккаунт API или Ollama): тогда «Авто» работает как режим «только правки» того же CLI в той же песочнице, а окно показывает **авто · правки**. Codex больше не останавливается на «Hooks need review» для хуков, которые добавляет сам CanvasTTY (`-c hooks.state` на этот запуск, в `~/.codex` ничего не пишется; плагины не могут передать `-c hooks…`), а субагента Codex в папке, выбранной человеком для его оркестратора (или внутри неё), не спрашивают о доверии к ней снова (`-c projects` на этот запуск); плагины получают эту папку как `trustedFolder`. Заголовок Claude Code «✳» теперь читается как «ожидает»: окно Claude с хуками выходит из `needs_approval` только по хукам, а если человек отклонил запрос — вскоре после ответа. Пример: `examples/plugins/launch-env` (профиль «Local model»). - Добавлены две точки запуска для плагинов учётных записей. Список (`select`) в параметрах запуска может объявить `"optionsFrom": "service"`: окно запуска спрашивает сервис `canvastty.launch.options` (3 с) и показывает до 64 дополнительных вариантов после объявленных, например учётные записи самого плагина; сохранённое значение проверяет сервис при подготовке запуска. Оркестраторы могут передать параметры запуска плагинов в `spawn_agent` как `launchOptions`; они проверяются так же, как в окне запуска. Встроенный `--settings` плагина для Claude сливается с собственным JSON CanvasTTY (Claude Code применяет только последний, из-за чего пропадали хуки состояния и решений); ключи подтверждений и хуков в нём отклоняются. Пример: `examples/plugins/launch-env` (Profile). - Добавлены сервисы плагинов (манифест apiVersion 2, `services`): собранный одним файлом JavaScript, который запускается отдельным дочерним процессом под надзором хоста только после отдельного подтверждения **Нативный код расширений** для плагина в Настройки → Агенты (по умолчанию выключено, установка его не даёт, обновление, смена модулей, выключение или изменённый файл entry его снимают). Сервис получает минимальное окружение без ключей и внутренних переменных CanvasTTY, общается по JSON-RPC через stdio (сообщения до 1 МБ, таймаут 15 с), перезапускается с паузами, останавливается при выключении, удалении, обновлении и выходе и пишет в ограниченный журнал плагина. Поверхности плагина обращаются к сервисам своего плагина через `host.service.request` и получают `host.service.onEvent`; сервис может вызывать `log`, `storage` своего плагина и `event` и читать секреты своего плагина через `secrets.get` (нужно `secrets`). Пример: `examples/plugins/service-echo` (его сервис ещё и читает токен, сохранённый страницей). diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 1b609785..f9250e02 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -4,6 +4,7 @@ ## Unreleased +- 基础保护现在还会拒绝智能体的 shell 或文件工具使用 CanvasTTY 自己的私有数据:读取、复制或编码 agent-control 令牌与描述文件、各网关的连接记录、提供商与插件的密钥存储、账户主目录、GitHub 登录信息和已准备的启动运行(任何程序,包括解释器单行命令和 heredoc),以及连接 CanvasTTY 的控制或运行时套接字(`curl --unix-socket`、`nc -U`、`socat`、Python 套接字)。模型会平静地得知智能体不能以这种方式控制 CanvasTTY,并被建议请用户以 **Orchestrator** 角色启动它,从而获得 `canvastty_agents` 工具。路径来自应用自己的 userData 文件夹;项目、应用设置、其他套接字和内置控制 CLI 不受影响。控制端点现在对未认证或格式错误的请求,以及 HTTP 请求(最小的 403),都以相同的指引代替简单错误作答,并关闭连接。 - 为 CLI 自带自动模式的智能体新增 **Auto** 启动配置档,与 Normal(仍为默认)和 YOLO 并列:Codex `--approve-for-me`(其自身审查,位于 `workspace-write` 沙箱),Claude Code `--permission-mode auto` 及其沙箱(`sandbox.enabled`、`autoAllowBashIfSandboxed: false`,合并进唯一的 `--settings`),Grok `--permission-mode auto`;控制 CLI 的 `create --profile auto` 和插件的 `sessions.create` 也支持。启动贡献者可回答 `thirdPartyModel: true`(API 或 Ollama 账户):此时 Auto 以同一沙箱中 CLI 的“仅接受编辑”模式运行,卡片显示 **auto · edits**。Codex 不再因 CanvasTTY 自己添加的 hook 停在 “Hooks need review”(本次运行的 `-c hooks.state`,不写入 `~/.codex`;插件不能传递 `-c hooks…`),位于用户为其编排者所选文件夹(或其子目录)中的 Codex 子智能体不再被再次询问是否信任(本次运行的 `-c projects`);插件以 `trustedFolder` 获得该文件夹。Claude Code 的 «✳» 标题现在表示空闲:带 hook 的 Claude 卡片仅通过 hook 离开 `needs_approval`,或在用户拒绝其提示后稍候离开。示例:`examples/plugins/launch-env`(Local model 配置档)。 - 新增两个供账户插件使用的启动扩展点。启动选项中的 `select` 可以声明 `"optionsFrom": "service"`:启动器向服务发送 `canvastty.launch.options`(3 秒),并在声明的选项之后列出最多 64 个额外选项,例如插件自己的账户;保存的值由服务在准备启动时检查。编排器可以把插件启动选项作为 `launchOptions` 传给 `spawn_agent`,校验方式与启动器相同。插件为 Claude 提供的内联 `--settings` 会合并进 CanvasTTY 自己的 JSON(Claude Code 只保留最后一个,此前会丢失生命周期和决策 hook);其中的审批和 hook 键会被拒绝。示例:`examples/plugins/launch-env`(Profile)。 - 新增插件服务(manifest apiVersion 2,`services`):打包为单文件的 JavaScript,仅在 设置 → Agents 中为该插件单独确认 **Extension native code** 后才作为受监管的子进程运行(默认关闭,安装不会授予;更新、更换模块、禁用或 entry 文件被修改都会撤销)。服务获得不含密钥和 CanvasTTY 内部变量的最小环境,通过 stdio 使用 JSON-RPC(消息上限 1 MB,超时 15 秒),退避重启,在禁用、卸载、更新和退出时停止,并写入有界的插件日志。插件界面通过 `host.service.request` 调用自身插件的服务,并通过 `host.service.onEvent` 接收事件;服务可回调 `log`、自身插件的 `storage` 和 `event`,并可用 `secrets.get` 读取自身插件的机密(需要 `secrets`)。示例:`examples/plugins/service-echo`(其服务还会读取页面保存的令牌)。 diff --git a/src/main/services/agent-control/AgentControlGateway.ts b/src/main/services/agent-control/AgentControlGateway.ts index ef124d86..56f0f5ec 100644 --- a/src/main/services/agent-control/AgentControlGateway.ts +++ b/src/main/services/agent-control/AgentControlGateway.ts @@ -27,6 +27,13 @@ const MAX_TRANSPORT_RESTART_ATTEMPTS = 3; const TRANSPORT_RESTART_BASE_DELAY_MS = 500; const MAX_TEXT = 16_000; const ID = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/; +/** + * What a caller that is not CanvasTTY's control CLI reads (an unauthenticated, malformed or HTTP request): why it + * was refused and what to do instead. Stable, and free of protocol details, file names and paths. + */ +export const CONTROL_REFUSAL_MESSAGE = "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)."; +/** An HTTP request line (curl, a browser, an HTTP/2 preface): answered with a minimal 403 instead of NDJSON. */ +const HTTP_REQUEST_LINE = /^[A-Z]{3,10} \S{1,4096} HTTP\/\d(?:\.\d)?\r?$/; const SECRET = /^[a-f0-9]{64}$/; interface TerminalPort { @@ -283,6 +290,24 @@ export class AgentControlGateway { socket.write(data); } catch { socket.destroy(); } }; + // A refusal is answered once and the connection closed, so a caller is not left waiting for the timeout. + const close = (): void => { + // A Unix socket half-closes after the reply is flushed; the Windows relay has no end(), so it is dropped shortly. + const end = (socket as { end?: () => void }).end; + if (typeof end === "function") end.call(socket); + else setTimeout(() => socket.destroy(), 250).unref(); + }; + const refuse = (line: Buffer): void => { + if (socket.destroyed) return; + if (HTTP_REQUEST_LINE.test(line.subarray(0, 4200).toString("latin1"))) { + const body = Buffer.from(`${CONTROL_REFUSAL_MESSAGE}\n`); + socket.write(Buffer.concat([Buffer.from("HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain; charset=utf-8\r\n" + + `Content-Length: ${body.length}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n`), body])); + } else { + reply({ v: 1, ok: false, error: { code: "INVALID_REQUEST", message: CONTROL_REFUSAL_MESSAGE } }); + } + close(); + }; socket.on("data", (chunk) => { if (handled) return; let line: Buffer | undefined; @@ -291,7 +316,7 @@ export class AgentControlGateway { handled = true; let request: ControlRequest; try { request = this.parse(JSON.parse(line.toString("utf8"))); } - catch { reply({ v: 1, ok: false, error: { code: "INVALID_REQUEST", message: "Invalid or unauthenticated control request." } }); return; } + catch { refuse(line); return; } void this.dispatch(request).then( (result) => reply({ v: 1, id: request.id, ok: true, result }), (error: unknown) => reply({ v: 1, id: request.id, ok: false, error: { diff --git a/tests/agent-control.test.mjs b/tests/agent-control.test.mjs index 2aee7ea4..1f53011c 100644 --- a/tests/agent-control.test.mjs +++ b/tests/agent-control.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; -import { mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import { EventEmitter } from "node:events"; import { createConnection } from "node:net"; import { tmpdir } from "node:os"; @@ -10,7 +10,7 @@ import { setTimeout as delay } from "node:timers/promises"; import test from "node:test"; import { controlRequest, parseArguments, runCli } from "../scripts/canvastty-control.mjs"; import xterm from "@xterm/headless"; -import { AgentControlGateway, codexComposerReady } from "../src/main/services/agent-control/AgentControlGateway.ts"; +import { AgentControlGateway, CONTROL_REFUSAL_MESSAGE, codexComposerReady } from "../src/main/services/agent-control/AgentControlGateway.ts"; import { TerminalManager, terminalEnvironment } from "../src/main/services/TerminalManager.ts"; import { TerminalSessionStore } from "../src/main/services/TerminalSessionStore.ts"; import { AgentControlService } from "../src/main/services/AgentControlService.ts"; @@ -284,9 +284,63 @@ test("invalid socket credentials cannot launch a native session", localSocket, a }); assert.equal(reply.ok, false); assert.equal(reply.error.code, "INVALID_REQUEST"); + assert.equal(reply.error.message, CONTROL_REFUSAL_MESSAGE); assert.equal(f.calls.length, 0); }); +/** Sends raw bytes to the control socket and collects everything until the gateway closes the connection. */ +function rawExchange(endpoint, bytes) { + return new Promise((resolveReply, reject) => { + const socket = createConnection(endpoint); + const chunks = []; + socket.on("error", reject); + socket.setTimeout(3000, () => { socket.destroy(); reject(new Error("the gateway did not close the refused connection")); }); + socket.on("data", (chunk) => chunks.push(chunk)); + socket.on("end", () => { socket.destroy(); resolveReply(Buffer.concat(chunks).toString("utf8")); }); + socket.on("connect", () => socket.write(bytes)); + }); +} + +test("unauthenticated, garbage and HTTP requests get the same guidance and a closed connection", localSocket, async (t) => { + const f = await fixture(t); + const descriptor = JSON.parse(await readFile(f.connectionPath, "utf8")); + const guessed = await rawExchange(descriptor.endpoint, JSON.stringify({ method: "list", params: {} }) + "\n"); + const garbage = await rawExchange(descriptor.endpoint, "hello?\n"); + for (const text of [guessed, garbage]) { + const reply = JSON.parse(text.trim()); + assert.deepEqual(reply, { v: 1, ok: false, error: { code: "INVALID_REQUEST", message: CONTROL_REFUSAL_MESSAGE } }); + } + const http = await rawExchange(descriptor.endpoint, "GET / HTTP/1.1\r\nHost: localhost\r\nUser-Agent: curl/8\r\nAccept: */*\r\n\r\n"); + const [head, body] = http.split("\r\n\r\n"); + assert.match(head, /^HTTP\/1\.1 403 Forbidden\r\n/u); + assert.match(head, /\r\nConnection: close/u); + assert.equal(Number(/Content-Length: (\d+)/u.exec(head)[1]), Buffer.byteLength(body)); + assert.equal(body, `${CONTROL_REFUSAL_MESSAGE}\n`); + // The guidance names the way in and nothing about the protocol, the token or where anything lives. + assert.match(CONTROL_REFUSAL_MESSAGE, /Orchestrator role/u); + assert.match(CONTROL_REFUSAL_MESSAGE, /canvastty_agents tools/u); + assert.doesNotMatch(CONTROL_REFUSAL_MESSAGE, /token|instanceId|controller|\.sock|connection\.json|agent-control|ndjson|json/iu); + assert.equal(f.calls.length, 0); +}); + +test("the token file is private (0600) in a private folder (0700), even under a loose umask or a loose folder", localSocket, async (t) => { + const root = await realpath(await mkdtemp(join(tmpdir(), "canvastty-control-hygiene-"))); + await mkdir(join(root, "agent-control"), { mode: 0o777 }); + await chmod(join(root, "agent-control"), 0o777); + const previous = process.umask(0); + const terminals = { create() { throw new Error("unused"); }, listMetadata: () => [], readBuffer() { throw new Error("unused"); }, + inputChecked: () => false, geometry: () => ({ cols: 80, rows: 24 }) }; + const gateway = new AgentControlGateway({ userDataPath: root, terminals, lifecycleEnabled: () => false }); + t.after(async () => { process.umask(previous); await gateway.close(); await rm(root, { recursive: true, force: true }); }); + let connectionPath; + try { connectionPath = await gateway.start(); } finally { process.umask(previous); } + const descriptor = JSON.parse(await readFile(connectionPath, "utf8")); + assert.equal((await stat(join(root, "agent-control"))).mode & 0o777, 0o700); + assert.equal((await stat(descriptor.tokenFile)).mode & 0o777, 0o600); + assert.equal((await stat(connectionPath)).mode & 0o777, 0o600); + assert.equal((await stat(join(descriptor.endpoint, ".."))).mode & 0o777, 0o700); +}); + test("a control write waiting on terminal replay cannot reach a restarted session", localSocket, async (t) => { const held = []; const original = xterm.Terminal.prototype.write;