@sigit / sigit-vscode / commits / 2e06c39

Security hardening: setting scopes, workspace-confined agent fs access, permission matching

- Scope sigit.agents, sigit.agent.default, sigit.registry.url, and sigit.permission.mode as machine settings so a workspace's .vscode/settings.json can no longer inject an arbitrary agent command, flip the permission mode to allow, or point the registry at a malicious catalog. - Declare untrustedWorkspaces/virtualWorkspaces capabilities so the extension stays disabled in Restricted Mode instead of silently spawning agents. - Confine the agent's fs/read_text_file and fs/write_text_file channel to the workspace folders; paths outside the workspace now follow sigit.permission.mode (allow / deny / modal prompt) instead of being granted unconditionally. - Classify permission options by the ACP kind field first; the previous unanchored name regex let agent-chosen labels like "Allow now" match /no/ and be auto-selected as the deny option (and vice versa). The name fallback is now word-bounded. - Reject in-flight JSON-RPC requests when the agent closes the connection, so a crashed agent no longer leaves the chat stuck busy. - Cap the ndjson read buffer at 16 MiB and truncate unparseable lines in error messages. - Honor the ACP line/limit parameters in fs/read_text_file instead of always returning the whole file. - Generate the webview CSP nonce with crypto.randomBytes instead of Math.random. - Run the registry parser test in CI alongside smoke and webview tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDM1YxP1jqcjSGpcXwKtwG

Claude committed Jul 5, 2026 at 21:58 UTC 2e06c39c12aa43b4458eaf96228594dc8dc5ae08
4 files changed +100 -16
.github/workflows/ci.yml
+3
@@ -38,3 +38,6 @@ jobs:
38
39 - name: Webview test (tool-call rendering)
40 run: pnpm run test:webview
41 +
42 + - name: Registry test (catalog parsing)
43 + run: pnpm run test:registry
package.json
+14
@@ -39,6 +39,16 @@
39 ],
40 "main": "./dist/extension.js",
41 "activationEvents": [],
42 + "capabilities": {
43 + "untrustedWorkspaces": {
44 + "supported": false,
45 + "description": "siGit spawns agent processes that can read and modify workspace files, so it requires a trusted workspace."
46 + },
47 + "virtualWorkspaces": {
48 + "supported": false,
49 + "description": "siGit spawns local agent processes and needs a local file system."
50 + }
51 + },
52 "contributes": {
53 "commands": [
54 {
@@ -95,16 +105,19 @@
105 "properties": {
106 "sigit.agent.default": {
107 "type": "string",
108 + "scope": "machine",
109 "default": "sigit",
110 "markdownDescription": "Key of the agent to use by default, looked up in `#sigit.agents#`. Defaults to the on-device `sigit` binary."
111 },
112 "sigit.registry.url": {
113 "type": "string",
114 + "scope": "machine",
115 "default": "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json",
116 "markdownDescription": "URL of the [Agent Client Protocol registry](https://github.com/agentclientprotocol/registry) catalog used by **siGit: Browse Agent Registry**. Installing an agent only registers a launch command in `#sigit.agents#` (via `npx`/`uvx`, or a binary you place on your PATH); siGit never downloads or runs agent binaries on your behalf."
117 },
118 "sigit.agents": {
119 "type": "object",
120 + "scope": "machine",
121 "markdownDescription": "Registry of ACP agents that siGit Code can spawn. Each entry maps a key to an agent definition. The on-device `sigit` agent is the local-first default; add other ACP-compatible agents here.",
122 "default": {
123 "sigit": {
@@ -147,6 +160,7 @@
160 },
161 "sigit.permission.mode": {
162 "type": "string",
163 + "scope": "machine",
164 "enum": [
165 "prompt",
166 "allow",
src/acp/connection.ts
+25 -6
@@ -61,12 +61,19 @@ export class Connection extends EventEmitter {
61 private nextId = 1;
62 private disposed = false;
63
64 + /** Cap on buffered bytes awaiting a newline, so a misbehaving agent can't grow memory unboundedly. */
65 + private static readonly MAX_BUFFER_LENGTH = 16 * 1024 * 1024;
66 +
67 constructor(readable: Readable, writable: Writable) {
68 super();
69 this.writable = writable;
70 readable.on("data", (chunk: Buffer | string) => this.onData(chunk.toString()));
71 readable.on("error", (err) => this.emit("error", err));
69 - readable.on("close", () => this.emit("close"));
72 + readable.on("close", () => {
73 + // The agent is gone; unblock every in-flight request instead of hanging.
74 + this.failPending(new Error("Agent closed the connection"));
75 + this.emit("close");
76 + });
77 }
78
79 /** Send a request and resolve with its result (or reject on error). */
@@ -109,14 +116,17 @@ export class Connection extends EventEmitter {
116 return;
117 }
118 this.disposed = true;
112 - const err = new Error("Connection disposed");
119 + this.failPending(new Error("Connection disposed"));
120 + this.requestHandlers.clear();
121 + this.notificationHandlers.clear();
122 + this.removeAllListeners();
123 + }
124 +
125 + private failPending(err: Error): void {
126 for (const { reject } of this.pending.values()) {
127 reject(err);
128 }
129 this.pending.clear();
117 - this.requestHandlers.clear();
118 - this.notificationHandlers.clear();
119 - this.removeAllListeners();
130 }
131
132 private send(message: JsonValue | JsonRpcRequest | JsonRpcNotification | JsonRpcResponse): void {
@@ -129,6 +139,14 @@ export class Connection extends EventEmitter {
139
140 private onData(text: string): void {
141 this.buffer += text;
142 + if (this.buffer.length > Connection.MAX_BUFFER_LENGTH && !this.buffer.includes("\n")) {
143 + this.buffer = "";
144 + this.emit(
145 + "error",
146 + new Error(`Agent sent more than ${Connection.MAX_BUFFER_LENGTH} bytes without a newline; discarding`)
147 + );
148 + return;
149 + }
150 let newlineIndex: number;
151 while ((newlineIndex = this.buffer.indexOf("\n")) !== -1) {
152 const line = this.buffer.slice(0, newlineIndex).trim();
@@ -145,7 +163,8 @@ export class Connection extends EventEmitter {
163 try {
164 message = JSON.parse(line);
165 } catch (err) {
148 - this.emit("error", new Error(`Failed to parse message: ${line}`));
166 + // Truncate: the line is agent output and can be arbitrarily large.
167 + this.emit("error", new Error(`Failed to parse message: ${line.slice(0, 256)}`));
168 return;
169 }
170
src/chatView.ts
+58 -10
@@ -1,3 +1,5 @@
1 +import { randomBytes } from "crypto";
2 +import * as path from "path";
3 import * as vscode from "vscode";
4 import { AcpClient, PermissionRequest, PermissionResponse, SessionUpdate } from "./acp/client";
5 import { defaultAgentKey, listAgents, permissionMode, resolveAgent } from "./agents";
@@ -209,8 +211,13 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
211 private async handlePermission(request: PermissionRequest): Promise<PermissionResponse> {
212 const mode = permissionMode();
213 const options = request.options ?? [];
212 - const allowOption = options.find((o) => /allow|yes|approve/i.test(o.name ?? o.optionId));
213 - const denyOption = options.find((o) => /deny|no|reject|cancel/i.test(o.name ?? o.optionId));
214 + // Classify by the ACP `kind` field; the option *name* is agent-controlled
215 + // free text ("Allow now" would otherwise match /no/), so it is only a
216 + // word-bounded fallback for agents that omit `kind`.
217 + const byKind = (kinds: string[]) => options.find((o) => o.kind !== undefined && kinds.includes(o.kind));
218 + const byName = (re: RegExp) => options.find((o) => re.test(o.name ?? o.optionId));
219 + const allowOption = byKind(["allow_once", "allow_always"]) ?? byName(/\b(allow|yes|approve)\b/i);
220 + const denyOption = byKind(["reject_once", "reject_always"]) ?? byName(/\b(deny|no|reject|cancel)\b/i);
221
222 if (mode === "allow" && allowOption) {
223 return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
@@ -256,17 +263,63 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
263 return `The agent wants to ${what}. Allow?`;
264 }
265
259 - private async readFile(params: { path: string }): Promise<string> {
266 + private async readFile(params: { path: string; line?: number; limit?: number }): Promise<string> {
267 const uri = this.resolveUri(params.path);
268 + await this.ensureFsAccess(uri, "read");
269 const bytes = await vscode.workspace.fs.readFile(uri);
262 - return Buffer.from(bytes).toString("utf8");
270 + let text = Buffer.from(bytes).toString("utf8");
271 + const { line, limit } = params;
272 + if (typeof line === "number" || typeof limit === "number") {
273 + const lines = text.split("\n");
274 + const start = typeof line === "number" ? Math.max(0, line - 1) : 0;
275 + const end = typeof limit === "number" ? start + limit : lines.length;
276 + text = lines.slice(start, end).join("\n");
277 + }
278 + return text;
279 }
280
281 private async writeFile(params: { path: string; content: string }): Promise<void> {
282 const uri = this.resolveUri(params.path);
283 + await this.ensureFsAccess(uri, "write");
284 await vscode.workspace.fs.writeFile(uri, Buffer.from(params.content, "utf8"));
285 }
286
287 + /**
288 + * Confine the agent's fs/* channel to the workspace. These requests carry no
289 + * ACP permission prompt of their own, so paths outside every workspace
290 + * folder follow `sigit.permission.mode`: allow silently, deny outright, or
291 + * ask the user.
292 + */
293 + private async ensureFsAccess(uri: vscode.Uri, action: "read" | "write"): Promise<void> {
294 + if (this.isInWorkspace(uri)) {
295 + return;
296 + }
297 + const mode = permissionMode();
298 + if (mode === "allow") {
299 + return;
300 + }
301 + if (mode === "prompt") {
302 + const allow = "Allow";
303 + const choice = await vscode.window.showWarningMessage(
304 + `The agent wants to ${action} a file outside the workspace: ${uri.fsPath}`,
305 + { modal: true },
306 + allow
307 + );
308 + if (choice === allow) {
309 + return;
310 + }
311 + }
312 + throw new Error(`Access denied: ${action} outside the workspace (${uri.fsPath})`);
313 + }
314 +
315 + private isInWorkspace(uri: vscode.Uri): boolean {
316 + const folders = vscode.workspace.workspaceFolders ?? [];
317 + return folders.some((folder) => {
318 + const rel = path.relative(folder.uri.fsPath, uri.fsPath);
319 + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
320 + });
321 + }
322 +
323 private resolveUri(p: string): vscode.Uri {
324 if (p.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(p)) {
325 return vscode.Uri.file(p);
@@ -392,10 +445,5 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
445 }
446
447 function getNonce(): string {
395 - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
396 - let text = "";
397 - for (let i = 0; i < 32; i++) {
398 - text += chars.charAt(Math.floor(Math.random() * chars.length));
399 - }
400 - return text;
448 + return randomBytes(16).toString("hex");
449 }