-
Notifications
You must be signed in to change notification settings - Fork 1
fix: stop/cancel/preempt robustness aligned with Python reference + docs #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
00d1d74
f8b0dde
2b4961b
cf93a53
1b997c6
a45c457
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| # Security Policy | ||
|
|
||
| ## What This Project Touches | ||
|
|
||
| This is a stdio ACP bridge — no network listener, no own auth. However, in | ||
| order to bridge the headless ZCode CLI to ACP-compatible editors, it does | ||
| read and write a small number of sensitive files owned by the ZCode app: | ||
|
|
||
| | Path | Read/Write | What it touches | | ||
| |------|-----------|-----------------| | ||
| | `~/.zcode/v2/config.json` | read | Reads the active provider's `baseURL` and `apiKey` (in `backend/credentials.ts`) and the model list (in `config/options.ts`, `config/runtime-model.ts`). The `apiKey` is forwarded to the ZCode subprocess via an environment variable (`ANTHROPIC_API_KEY`) and is never written to logs, stdout, or any other file. | | ||
| | `~/.zcode/v2/tasks-index.sqlite` | **read/write** | Inserts/updates rows in the `tasks` table (`tasks-index.ts`) so that sessions created via ACP appear in the ZCode app's UI. Only the `tasks` table is touched, using `INSERT OR IGNORE` / bounded `UPDATE`. | | ||
|
|
||
| The bridge does **not**: | ||
| - send credentials, tokens, or session data anywhere except the local ZCode subprocess; | ||
| - modify the ZCode CLI binary, the app, or any file outside `tasks-index.sqlite`; | ||
| - expose any network port. | ||
|
|
||
| ## Reporting a Vulnerability | ||
|
|
||
| If you find a way this bridge leaks credentials, corrupts the tasks-index, or | ||
| escapes its stdio boundary, please report it privately rather than opening a | ||
| public issue: | ||
|
|
||
| - Open a private security advisory via GitHub's | ||
| [Report a vulnerability](https://github.com/william0wang/zcode-acp/security/advisories/new), | ||
| or | ||
| - email the maintainer (see the GitHub profile). | ||
|
|
||
| Please include the affected file/line, a description of impact, and a | ||
| reproduction if possible. You should hear back within **72 hours**. | ||
|
|
||
| ## Out of Scope | ||
|
|
||
| Report these to the upstreams, not here: | ||
|
|
||
| - The ZCode CLI itself, the ZCode app, or the `config.json` format — these | ||
| are owned by [Zhipu Z.AI](https://z.ai). | ||
| - The editor (Zed, JetBrains) or the ACP specification. | ||
| - Issues that require already having code execution on the host. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,12 +15,12 @@ | |
| * workers) with `process.kill(-pid)` and leave no orphans. | ||
| */ | ||
|
|
||
| import { spawn, type ChildProcess } from "node:child_process"; | ||
| import { createInterface } from "node:readline"; | ||
| import process from "node:process"; | ||
|
|
||
| import { log } from "../utils.js"; | ||
| import type { | ||
| ZcodeEvent, | ||
| ZcodeInbound, | ||
| ZcodeInteractionPermissionParams, | ||
|
|
@@ -53,6 +53,9 @@ | |
| private readonly serverRequests: ServerRequest[] = []; | ||
| private readonly listeners = new Map<string, EventListener>(); | ||
| private readerDead = false; | ||
| /** Monotonic id for fire-and-forget sends (send()). Uses a high range to | ||
| * avoid collisions with the server's request ids (low range). */ | ||
| private sendIdCounter = 1_000_000_000; | ||
| /** Watchdog process that kills the zcode group if this bridge dies (SIGKILL). */ | ||
| private watchdog: ChildProcess | null = null; | ||
|
|
||
|
|
@@ -213,7 +216,17 @@ | |
| log("backend: sendReply dropped (stdin closed)"); | ||
| return; | ||
| } | ||
| stdin.write(JSON.stringify({ id, result }) + "\n"); | ||
| try { | ||
| stdin.write(JSON.stringify({ id, result }) + "\n"); | ||
| } catch (e) { | ||
| // Broken pipe: backend is gone. Mark reader dead so the rest of the | ||
| // bridge stops trying to talk to it; otherwise zcode reannounces would | ||
| // keep hitting a dead pipe and the dedup cache would spin forever. | ||
| this.readerDead = true; | ||
| log( | ||
| `backend: sendReply write failed (marked dead): ${e instanceof Error ? e.message : String(e)}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** Reply to a zcode server→client request with an error. */ | ||
|
|
@@ -223,7 +236,14 @@ | |
| log("backend: sendError dropped (stdin closed)"); | ||
| return; | ||
| } | ||
| stdin.write(JSON.stringify({ id, error: { code, message } }) + "\n"); | ||
| try { | ||
| stdin.write(JSON.stringify({ id, error: { code, message } }) + "\n"); | ||
| } catch (e) { | ||
| this.readerDead = true; | ||
| log( | ||
| `backend: sendError write failed (marked dead): ${e instanceof Error ? e.message : String(e)}`, | ||
| ); | ||
| } | ||
|
Comment on lines
+239
to
+246
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In Node.js, The synchronous To fix this robustly:
stdin.write(JSON.stringify({ id, error: { code, message } }) + "\n"); |
||
| } | ||
|
|
||
| // ---------- send / request ---------- | ||
|
|
@@ -238,6 +258,23 @@ | |
| stdin.write(JSON.stringify({ method, params }) + "\n"); | ||
| } | ||
|
|
||
| /** | ||
| * Send a message with an id but WITHOUT registering a pending response | ||
| * (fire-and-forget). Mirrors Python's `_backend.send({"id": ..., ...})` for | ||
| * `session/stop`: some backends route by id presence, so carrying an id is | ||
| * more robust than a bare notify. If the backend replies, the reader's | ||
| * `resolvePending` finds no pending entry and safely discards it. | ||
| */ | ||
| send(method: string, params?: Record<string, unknown>): void { | ||
| const stdin = this.proc.stdin; | ||
| if (!stdin || stdin.destroyed) { | ||
| log("backend: send dropped (stdin closed)"); | ||
| return; | ||
| } | ||
| const id = this.sendIdCounter++; | ||
| stdin.write(JSON.stringify({ id, method, params: params ?? {} }) + "\n"); | ||
| } | ||
|
|
||
| /** | ||
| * Synchronous request/response: register a pending promise, send, await. | ||
| * Other notifications arriving during the wait are routed async by the | ||
|
|
@@ -304,9 +341,13 @@ | |
| } | ||
| // Wait up to 3s for a clean exit. | ||
| const exited = await new Promise<boolean>((resolve) => { | ||
| const done = () => resolve(true); | ||
| let timer: ReturnType<typeof setTimeout>; | ||
| const done = () => { | ||
| clearTimeout(timer); // don't let the timeout keep the event loop alive | ||
| resolve(true); | ||
| }; | ||
| proc.once("exit", done); | ||
| setTimeout(() => { | ||
| timer = setTimeout(() => { | ||
| proc.removeListener("exit", done); | ||
| resolve(false); | ||
| }, 3000); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In Node.js,
stream.write()is asynchronous and does not throw synchronous exceptions for write errors likeEPIPE(broken pipe). Instead, these errors are emitted as'error'events on the stream (stdin).The synchronous
try/catchblock here will not catch these errors, and if there is no'error'event listener registered onthis.proc.stdin, the entire Node.js process will crash with an unhandled'error'event.To fix this robustly:
'error'event listener onthis.proc.stdin(e.g., in the constructor ofZcodeBackendor during initialization) to handle write errors and mark the reader dead:try/catchblocks aroundstdin.writecalls insendReplyandsendError.