From 00d1d74a2a401b0cb2e196e6040b18185bdd00c7 Mon Sep 17 00:00:00 2001 From: William Wang Date: Sun, 5 Jul 2026 16:21:42 +0800 Subject: [PATCH 1/6] fix: replace ensureTurnStopped with fire-and-forget stopBackendTurn --- src/handlers/session.ts | 97 ++++++++++++----------------------------- 1 file changed, 28 insertions(+), 69 deletions(-) diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 78b6c21..1b3aee9 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -321,9 +321,9 @@ export async function prompt( 15000, ); if (sendResp.error) { - // send failed/timeout — but backend may have started the turn anyway. - // Stop it and probe to avoid leaking the prompt lock. - await ensureTurnStopped(server, zcodeSid); + // send failed/timeout. Don't fire stop here: a send failure usually + // means the turn never started (no lock to leak). Mirrors Python which + // just returns the error without stopping. throw new Error(`zcode send failed: ${sendResp.error.message ?? ""}`); } const accepted = (sendResp.result ?? {}) as { accepted?: boolean }; @@ -408,66 +408,22 @@ export async function cancel( } /** - * Send `session/stop` and probe until the prompt lock is confirmed released. + * Fire-and-forget `session/stop` to the backend. Mirrors Python's + * `_cancel_backend_turn`: send stop, never wait, never throw. * - * `session/stop` is fire-and-forget, but ZCode has a startup delay: when stop - * arrives before the turn truly holds the lock, the backend ignores it and the - * turn runs on, leaking the lock — the next `session/send` then fails with - * "A prompt is already running". Mirrors the `expectLock:true` strategy from - * `waitForTurnIdle` (extensions.ts) but adapted for the cancel path. - * - * Strategy: - * 1. send `session/stop` - * 2. poll `session/goal show`; first REQUIRE seeing "prompt is running" once - * (proves the turn started), then wait for it to clear - * 3. if the grace window (8s) elapses without ever seeing the lock, the turn - * never started (stop caught it in time) or already ended → treat as released - * 4. hard timeout 30s - * - * Best-effort: never throws (failures only log) so it can't break the cancel - * path. Returns true if released, false on timeout. + * The backend's turn loop will emit turn.completed(cancelled) on its own; + * the ACP turn loop observes that event and exits. No probing needed on the + * prompt path — an earlier ensureTurnStopped probed session/goal show for 30s + * but returned inconsistent values and caused severe stalls. */ -async function ensureTurnStopped(server: ZcodeAcpServer, zcodeSid: string): Promise { - const backend = server.ensureBackend(); - backend.notify("session/stop", { sessionId: zcodeSid }); - const t0 = Date.now(); - const GRACE_MS = 8_000; - const HARD_TIMEOUT_MS = 30_000; - let lockSeen = false; - while (Date.now() - t0 < HARD_TIMEOUT_MS) { - const resp = await backend.request( - server.nextId(), - "session/goal", - { sessionId: zcodeSid, action: "show" }, - 10000, +function stopBackendTurn(server: ZcodeAcpServer, zcodeSid: string): void { + try { + server.ensureBackend().notify("session/stop", { sessionId: zcodeSid }); + } catch (e) { + log( + ` [stop] session/stop send failed (ignored): ${e instanceof Error ? e.message : String(e)}`, ); - const errMsg = resp.error?.message ?? ""; - if (errMsg.includes("prompt is running")) { - lockSeen = true; - await sleep(2000); - continue; - } - if (errMsg.toLowerCase().includes("timeout")) { - await sleep(2000); - continue; - } - // Non-lock error or success. - if (lockSeen) { - log(` [stop] prompt lock released`); - return true; - } - // Haven't seen the lock yet — give the turn a grace window to start. - if (Date.now() - t0 < GRACE_MS) { - await sleep(500); - continue; - } - // Grace window expired without ever seeing the lock: turn never started - // (stop caught it in time) or already ended. Safe to treat as released. - log(` [stop] no lock observed within grace window, treating as released`); - return true; } - log(` [stop] lock wait timed out (30s), lock may still be held`); - return false; } /** @@ -501,13 +457,15 @@ async function preemptInFlightTurn( if (oldRequestId === undefined) return; // no in-flight turn, proceed log(` [preempt] in-flight turn ${oldRequestId} found, stopping it`); - // Stop the backend turn and wait for the prompt lock to release. - await ensureTurnStopped(server, zcodeSid); + // Fire-and-forget stop (mirrors Python's _cancel_backend_turn). The old + // turn loop will receive turn.completed(cancelled) and exit on its own. + stopBackendTurn(server, zcodeSid); // Wait for the old turn's prompt() to fully exit (its finally block deletes // the pendingTurns entry). This is the synchronization point that guarantees - // its listener is unregistered before we register ours. - const PREEMPT_TIMEOUT_MS = 35_000; // slightly longer than ensureTurnStopped's 30s + // both lock release (backend turn ended) and listener unregistration before + // we subscribe/send. More reliable than probing session/goal show. + const PREEMPT_TIMEOUT_MS = 35_000; const t0 = Date.now(); while (server.pendingTurns.has(oldRequestId)) { if (Date.now() - t0 > PREEMPT_TIMEOUT_MS) { @@ -604,7 +562,7 @@ async function runEventTurn( } if (turn.cancelled) { - await ensureTurnStopped(server, turn.zcodeSid); + stopBackendTurn(server, turn.zcodeSid); return { stopReason: "cancelled" }; } @@ -626,7 +584,7 @@ async function runEventTurn( await sendTextChunk(cx, acpSid, reply, chunkMsgId); } else if (!emittedOutput) { // No text and no output → suspected failure. - await ensureTurnStopped(server, turn.zcodeSid); + stopBackendTurn(server, turn.zcodeSid); throw new RequestError(-32603, "turn produced no output"); } } @@ -686,13 +644,14 @@ async function runEventTurn( } if (translator.turnDone) { - // Cancel signalled via turn.completed(resultType:"cancelled"). + // Cancel signalled via turn.completed(resultType:"cancelled"). The + // backend turn has already ended and released the lock — no stop needed. if (translator.turnResultType === "cancelled") { - await ensureTurnStopped(server, turn.zcodeSid); return { stopReason: "cancelled" }; } if (translator.turnFailed) { - await ensureTurnStopped(server, turn.zcodeSid); + // Best-effort stop in case the failed turn left a residual lock. + stopBackendTurn(server, turn.zcodeSid); throw new RequestError(-32603, formatTurnError(translator.turnError)); } // Fallback: if no text streamed, surface the last assistant reply. @@ -728,7 +687,7 @@ async function runEventTurn( } // 120s no progress: abandon. - await ensureTurnStopped(server, turn.zcodeSid); + stopBackendTurn(server, turn.zcodeSid); return { stopReason: "max_turn_requests" }; } From f8b0ddefa826ade48723c1079c2579456392a079 Mon Sep 17 00:00:00 2001 From: William Wang Date: Sun, 5 Jul 2026 16:42:39 +0800 Subject: [PATCH 2/6] fix: serialize prompt preempt under per-session lock and close() timer leak Two fixes for the fire-and-forget stop path: - session.ts + server.ts: wrap 'register self in pendingTurns + preempt others' in a per-zcodeSid promise lock (withPreemptLock). Without it two near-simultaneous prompts could both scan pendingTurns before either registers, miss each other, and run concurrently (listener overwrite, differ corruption). Registering INSIDE the lock guarantees the next prompt entering its section sees this turn and cancels it. - backend/client.ts: close()'s 3s exit-wait timer was never cleared, keeping the event loop alive after a clean shutdown. Capture the timer and clear it on exit. --- src/backend/client.ts | 8 ++++-- src/handlers/session.ts | 64 ++++++++++++++++++++++++++++++++--------- src/server.ts | 6 ++++ 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/src/backend/client.ts b/src/backend/client.ts index 3c7beff..a20ad1c 100644 --- a/src/backend/client.ts +++ b/src/backend/client.ts @@ -304,9 +304,13 @@ export class ZcodeBackend { } // Wait up to 3s for a clean exit. const exited = await new Promise((resolve) => { - const done = () => resolve(true); + let timer: ReturnType; + 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); diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 1b3aee9..ae4fa9e 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -281,19 +281,20 @@ export async function prompt( const intercepted = await handleSlashCommand(server, cx, params.sessionId, zcodeSid, text); if (intercepted) return intercepted; - // Preempt: if another turn is still running for this session (client sent a - // new prompt without cancelling), stop it and wait for it to fully exit - // before we subscribe/send. Without this, session/send hits the backend - // prompt-lock and the error path kills the old turn but loses the new msg. - await preemptInFlightTurn(server, zcodeSid, requestId); - - // Register the pending turn. This same object is mutated by cancel(); the - // turn loop checks `.cancelled` on the SAME reference, so cancel propagates. + // Register self + preempt others under a per-session lock. The lock + // serializes the critical section so that two concurrent prompts (B, C) for + // the same session can't both miss each other and register at once: C waits + // for B's section, by which point B is in pendingTurns, so C's preempt finds + // and cancels B. Registering INSIDE the lock is what makes the new turn + // visible to the next prompt's preempt scan. const turn: PendingTurn = { zcodeSid, cancelled: false, }; - server.pendingTurns.set(requestId, turn); + await withPreemptLock(server, zcodeSid, () => { + server.pendingTurns.set(requestId, turn); + return preemptInFlightTurn(server, zcodeSid, requestId); + }); const listener = new EventStreamListener(backend, zcodeSid); const monitor = new TurnMonitor(backend, zcodeSid, () => server.nextId()); @@ -427,12 +428,47 @@ function stopBackendTurn(server: ZcodeAcpServer, zcodeSid: string): void { } /** - * If another prompt() is already running for this zcodeSid, treat the new - * prompt as an implicit cancel: stop the in-flight turn and wait for it to - * fully exit (lock released + listener unregistered + pendingTurns cleaned) - * before the new prompt subscribes and sends. + * Serialize a per-session critical section. Each section awaits the previous + * one's promise before running, so concurrent prompts for the same session + * execute register+preempt strictly one after another. + * + * Used by prompt() to wrap "register self in pendingTurns + preempt others": + * the registration must land before the section releases, so the next prompt + * entering its section sees this turn in its preempt scan. Without this lock, + * two near-simultaneous prompts could both scan before either registers. + * + * The body may be async and long-running (preempt waits up to 35s for the old + * turn to exit); that is acceptable because the turn loop itself runs OUTSIDE + * this lock — only registration + preempt-in-wait are serialized. + */ +function withPreemptLock( + server: ZcodeAcpServer, + zcodeSid: string, + body: () => Promise, +): Promise { + const prev = server.preemptLocks.get(zcodeSid) ?? Promise.resolve(); + const next = prev.then(body, body); // run body regardless of prior rejection + server.preemptLocks.set(zcodeSid, next); + // Clean up the entry once settled so a later idle session doesn't retain a + // dangling promise. Only delete if still ours (a newer section may have + // chained on top of us). + next.finally(() => { + if (server.preemptLocks.get(zcodeSid) === next) { + server.preemptLocks.delete(zcodeSid); + } + }); + return next; +} + +/** + * Cancel any other in-flight turn for this zcodeSid and wait for it to fully + * exit (listener unregistered + pendingTurns cleaned) before returning. + * + * Must be called from inside a preempt lock section (the caller has already + * registered itself in pendingTurns), so a concurrent prompt entering its own + * section is guaranteed to see this caller's turn and cancel it. * - * Why wait for the map entry to disappear (not just the lock): registering + * Why wait for the map entry to disappear (not just fire stop): registering * a second EventStreamListener overwrites the first (Map.set in client.ts), * so the old turn loop must have run its finally block before we subscribe. * The map cleanup in that finally block is the synchronization point. diff --git a/src/server.ts b/src/server.ts index e0c114e..fa50f28 100644 --- a/src/server.ts +++ b/src/server.ts @@ -39,6 +39,12 @@ export class ZcodeAcpServer { readonly sessionMap = new Map(); /** Currently running turns, keyed by the ACP request id. */ readonly pendingTurns = new Map(); + /** + * Per-session (zcodeSid) preempt lock: a promise chain that serializes the + * "register self + preempt others" critical section in prompt(). Prevents + * concurrent prompts from both missing each other and registering at once. + */ + readonly preemptLocks = new Map>(); /** Capabilities advertised by the connected client (Zed, JetBrains, ...). */ clientCapabilities: ClientCapabilities = {}; /** Session titles already set, to enforce set-once (acp_sid → title). */ From 2b4961b0d96f55816d266e718c1d150a05639d8d Mon Sep 17 00:00:00 2001 From: William Wang Date: Sun, 5 Jul 2026 17:11:18 +0800 Subject: [PATCH 3/6] fix: align stop/reply/usage handling with Python reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three robustness fixes found by diffing against zcode-open-bridge: - backend/client.ts: add send() that carries an id but does not register a pending response (fire-and-forget), mirroring Python's _cancel_backend_turn. Some backends route by id presence, so session/stop via send() is more reliable than a bare notify(). stopBackendTurn now uses send(). - backend/client.ts: sendReply/sendError now mark readerDead on stdin write failure (broken pipe), matching request()'s behavior. Without this, a half-closed stdin would silently drop replies and zcode reannounces would spin the dedup cache forever. - translators/event-translator.ts: translateTurnDone's used fallback chain now uses || instead of ?? to match Python's 'or' semantics — a falsy totalTokens (0/undefined) falls back to tokenCount. Also fixes a stray closing brace. --- src/backend/client.ts | 41 +++++++++++++++++++++++++++-- src/handlers/session.ts | 5 ++-- src/translators/event-translator.ts | 12 ++++----- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/backend/client.ts b/src/backend/client.ts index a20ad1c..1ec3c8b 100644 --- a/src/backend/client.ts +++ b/src/backend/client.ts @@ -53,6 +53,9 @@ export class ZcodeBackend { private readonly serverRequests: ServerRequest[] = []; private readonly listeners = new Map(); 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 @@ export class ZcodeBackend { 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 @@ export class ZcodeBackend { 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)}`, + ); + } } // ---------- send / request ---------- @@ -238,6 +258,23 @@ export class ZcodeBackend { 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): 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 diff --git a/src/handlers/session.ts b/src/handlers/session.ts index ae4fa9e..c9508cf 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -410,7 +410,8 @@ export async function cancel( /** * Fire-and-forget `session/stop` to the backend. Mirrors Python's - * `_cancel_backend_turn`: send stop, never wait, never throw. + * `_cancel_backend_turn`: send stop with an id (some backends route by id + * presence), never wait for a response, never throw. * * The backend's turn loop will emit turn.completed(cancelled) on its own; * the ACP turn loop observes that event and exits. No probing needed on the @@ -419,7 +420,7 @@ export async function cancel( */ function stopBackendTurn(server: ZcodeAcpServer, zcodeSid: string): void { try { - server.ensureBackend().notify("session/stop", { sessionId: zcodeSid }); + server.ensureBackend().send("session/stop", { sessionId: zcodeSid }); } catch (e) { log( ` [stop] session/stop send failed (ignored): ${e instanceof Error ? e.message : String(e)}`, diff --git a/src/translators/event-translator.ts b/src/translators/event-translator.ts index 02ac2ed..36da98b 100644 --- a/src/translators/event-translator.ts +++ b/src/translators/event-translator.ts @@ -203,11 +203,11 @@ export class EventTranslator { private translateTurnDone(payload: Record): InternalEvent[] { const usage = (payload["usage"] as Record) ?? {}; - const used = (usage["totalTokens"] as number) ?? (payload["tokenCount"] as number) ?? undefined; - const size = (usage["contextWindow"] as number) ?? 0; - if (typeof used === "number") { - return [{ kind: "UsageDelta", used, size }]; - } - return []; + // Use || (not ??) to match Python's `or` semantics: a falsy totalTokens + // (0 / undefined) falls back to tokenCount, then to 0. With ?? a 0 would + // be kept as-is and never fall back, diverging from the Python reference. + const used = (usage["totalTokens"] as number) || (payload["tokenCount"] as number) || 0; + const size = (usage["contextWindow"] as number) || 0; + return [{ kind: "UsageDelta", used, size }]; } } From cf93a53985f967aef5f0c183d623476d9aa46992 Mon Sep 17 00:00:00 2001 From: William Wang Date: Sun, 5 Jul 2026 17:15:02 +0800 Subject: [PATCH 4/6] docs: add acknowledgements, related projects, disclaimer, and SECURITY.md - README (en + zh-CN): add a Related Projects section linking to tizerluo/zcode-open-bridge (the Python reference this server borrows from), an Acknowledgements section crediting ACP / ZCode / the reference implementation, and a non-affiliation disclaimer. - SECURITY.md: document the private vulnerability reporting process and the bridge's credential-handling scope, since this server reads ZCode creds from ~/.zcode/v2/config.json. --- README.md | 15 +++++++++++++++ README.zh-CN.md | 14 ++++++++++++++ SECURITY.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 SECURITY.md diff --git a/README.md b/README.md index 53d4ad7..691db9c 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,21 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, code style, commit conventions, and the PR checklist. Notable changes are recorded in [CHANGELOG.md](CHANGELOG.md). +## Related Projects + +- [zcode-open-bridge](https://github.com/tizerluo/zcode-open-bridge) — a community Python implementation that bridges ZCode to the MCP/ACP ecosystem. The design of this server references its bridge architecture and several handling strategies. + +## Acknowledgements + +- [Agent Client Protocol](https://agentclientprotocol.com/) (Apache-2.0) — the ACP specification +- [ZCode](https://zcode.z.ai) / [Zhipu Z.AI](https://z.ai) — the GLM model and ZCode CLI +- [zcode-open-bridge](https://github.com/tizerluo/zcode-open-bridge) — reference implementation that informed this server's design + ## License Apache-2.0. This project follows the same license as the upstream ACP specification. + +## Disclaimer + +This is an independent community project and is not affiliated with, endorsed +by, or sponsored by Zhipu Z.AI. ZCode is a product of Zhipu Z.AI. diff --git a/README.zh-CN.md b/README.zh-CN.md index 9cee31f..39378ff 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -115,6 +115,20 @@ CI 会在每次 push 和 pull request 时运行 `typecheck`、`lint`、`build` 欢迎贡献!请阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 了解环境搭建、代码风格、 commit 约定和 PR 检查清单。重要变更记录在 [CHANGELOG.md](CHANGELOG.md)。 +## 相关项目 + +- [zcode-open-bridge](https://github.com/tizerluo/zcode-open-bridge) —— 一个社区 Python 实现,将 ZCode 接入 MCP/ACP 生态。本项目参考了它的桥接架构和若干处理策略。 + +## 致谢 + +- [Agent Client Protocol](https://agentclientprotocol.com/)(Apache 2.0)—— ACP 协议规范 +- [ZCode](https://zcode.z.ai) / [智谱 Z.AI](https://z.ai) —— GLM 模型与 ZCode CLI +- [zcode-open-bridge](https://github.com/tizerluo/zcode-open-bridge) —— 参考实现,本项目的设计借鉴了它的桥接架构 + ## 许可证 Apache-2.0。本项目沿用上游 ACP 规范的同一许可证。 + +## 免责声明 + +本项目为独立的社区项目,与智谱 Z.AI 官方无任何隶属、认可或赞助关系。ZCode 是智谱 Z.AI 的产品。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a393b0a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,47 @@ +# Security Policy + +## Reporting a Vulnerability + +This project bridges the ZCode CLI to ACP-compatible editors and handles ZCode +credentials (`~/.zcode/v2/config.json`). Security issues are taken seriously. + +**Please do NOT open public GitHub issues for security vulnerabilities.** + +Instead, report vulnerabilities privately: + +1. Open a **private security advisory** via GitHub's + [Report a vulnerability](https://github.com/william0wang/zcode-acp/security/advisories/new) + feature, or +2. Email the maintainer directly (see the GitHub profile for contact info). + +Please include: +- A description of the issue and its potential impact +- Steps to reproduce (proof of concept, logs, etc.) +- Affected versions / commit +- Any suggested fix or mitigation + +You should receive an initial response within **72 hours**. If the issue is +confirmed, a fix and public advisory will be coordinated with you. + +## Scope + +In scope: +- Anything in this repository (the bridge server, its protocol handling, the + process lifecycle / watchdog). +- Mishandling of ZCode credentials, tokens, or session data by the bridge. +- Crashes, hangs, or resource leaks triggered by malformed input. + +Out of scope: +- Vulnerabilities in the upstream ZCode CLI itself — report those to + [Zhipu Z.AI](https://z.ai). +- Vulnerabilities in editors (Zed, JetBrains) or the ACP specification — + report those to the respective upstreams. +- Issues that require already having code execution on the user's machine. + +## Credential Handling + +The bridge reads ZCode credentials from `~/.zcode/v2/config.json` (created by +the ZCode app) and forwards them to the ZCode subprocess via environment +variables. Credentials are **never** written to logs, stdout, or disk by the +bridge. If you find a code path that leaks credentials, please report it as a +security issue (not a regular bug). From 1b997c6d3bc7f126e0228c8bec57744491639f87 Mon Sep 17 00:00:00 2001 From: William Wang Date: Sun, 5 Jul 2026 17:16:14 +0800 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20drop=20SECURITY.md=20=E2=80=94=20pu?= =?UTF-8?q?re=20ACP=20bridge=20has=20no=20own=20attack=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This server is a stdio protocol translator; it doesn't modify ZCode, expose a network surface, or run its own auth. Credential handling is just passing ZCode's own config to its own subprocess, which is ZCode's responsibility. A SECURITY.md would be over-documentation. --- SECURITY.md | 47 ----------------------------------------------- 1 file changed, 47 deletions(-) delete mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index a393b0a..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,47 +0,0 @@ -# Security Policy - -## Reporting a Vulnerability - -This project bridges the ZCode CLI to ACP-compatible editors and handles ZCode -credentials (`~/.zcode/v2/config.json`). Security issues are taken seriously. - -**Please do NOT open public GitHub issues for security vulnerabilities.** - -Instead, report vulnerabilities privately: - -1. Open a **private security advisory** via GitHub's - [Report a vulnerability](https://github.com/william0wang/zcode-acp/security/advisories/new) - feature, or -2. Email the maintainer directly (see the GitHub profile for contact info). - -Please include: -- A description of the issue and its potential impact -- Steps to reproduce (proof of concept, logs, etc.) -- Affected versions / commit -- Any suggested fix or mitigation - -You should receive an initial response within **72 hours**. If the issue is -confirmed, a fix and public advisory will be coordinated with you. - -## Scope - -In scope: -- Anything in this repository (the bridge server, its protocol handling, the - process lifecycle / watchdog). -- Mishandling of ZCode credentials, tokens, or session data by the bridge. -- Crashes, hangs, or resource leaks triggered by malformed input. - -Out of scope: -- Vulnerabilities in the upstream ZCode CLI itself — report those to - [Zhipu Z.AI](https://z.ai). -- Vulnerabilities in editors (Zed, JetBrains) or the ACP specification — - report those to the respective upstreams. -- Issues that require already having code execution on the user's machine. - -## Credential Handling - -The bridge reads ZCode credentials from `~/.zcode/v2/config.json` (created by -the ZCode app) and forwards them to the ZCode subprocess via environment -variables. Credentials are **never** written to logs, stdout, or disk by the -bridge. If you find a code path that leaks credentials, please report it as a -security issue (not a regular bug). From a45c4572122585ea408875f601cd68c077a6be2f Mon Sep 17 00:00:00 2001 From: William Wang Date: Sun, 5 Jul 2026 17:18:00 +0800 Subject: [PATCH 6/6] docs: add SECURITY.md describing actual config.json and tasks-index.sqlite access The bridge reads the ZCode provider's baseURL and apiKey from ~/.zcode/v2/config.json (forwarded to the subprocess as ANTHROPIC_API_KEY, never logged) and writes rows to ~/.zcode/v2/tasks-index.sqlite so that ACP-created sessions appear in the ZCode app UI. SECURITY.md now documents exactly what is read/written, what is explicitly not done, and how to report issues privately. --- SECURITY.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..23b112c --- /dev/null +++ b/SECURITY.md @@ -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.