From 6f39e16ff170ca1001498a3f329ee89f0ea8beb2 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Mon, 27 Jul 2026 17:48:36 -0400 Subject: [PATCH] feat(amicode): vault browsing fail-closed by kind + public-only deployment mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aaron's review of the #65 gate: armonissima (kind=team, no browse key) browsed by default — orgs/, BUSINESS.md, partner-lab detail. Not hackathon-safe, and per-vault browse=false depends on remembering it. The marker taxonomy now decides: personal and public browse by default; team/project/engagement — and any unknown kind — ship dark until their marker opts in with browse = true. browse = false still darkens any kind. New AMICO_VAULT_BROWSER=public mode for the hackathon boxes: serves ONLY public mounts regardless of markers, and opens the deployment gate for their exposed binds. Co-Authored-By: Claude Fable 5 --- .../docs/adr/0003-context-tree-top-panel.md | 7 ++- .../src/server/amicode/vault-browser.ts | 59 +++++++++++++++---- .../test/server/amicode-vault-browser.test.ts | 41 ++++++++++++- 3 files changed, 90 insertions(+), 17 deletions(-) diff --git a/packages/app/docs/adr/0003-context-tree-top-panel.md b/packages/app/docs/adr/0003-context-tree-top-panel.md index 4259ad79b..faf1fbe95 100644 --- a/packages/app/docs/adr/0003-context-tree-top-panel.md +++ b/packages/app/docs/adr/0003-context-tree-top-panel.md @@ -68,7 +68,10 @@ Entry points: a titlebar vault button (both titlebar variants), the command pale - Vault contents are proprietary knowledge, so browsing is a **local-researcher capability, not a server API**: the routes refuse on any non-loopback bind (same signal as the credential-mutation guard; `AMICO_VAULT_BROWSER=1` opts a shared - deployment in, `=0` forces off), and a mount can go fully dark with - `browse = false` in its `.amico-vault.toml` — the agent's read grants are unaffected. + deployment in, `=0` forces off, `=public` serves only public vaults — the + hackathon-box mode). Per-mount browsability is **fail-closed by kind** (Aaron + 2026-07-27): personal and public browse by default; team/project/engagement — + and unknown kinds — ship dark until their marker says `browse = true`; + `browse = false` darkens any kind. The agent's read grants are unaffected. - Keyboard reachability of individual canvas nodes is an open follow-up; every action the canvas offers also exists via keyboard-reachable surfaces (file tree, Vault panel). diff --git a/packages/opencode/src/server/amicode/vault-browser.ts b/packages/opencode/src/server/amicode/vault-browser.ts index 9f5154f7d..c5e1d71aa 100644 --- a/packages/opencode/src/server/amicode/vault-browser.ts +++ b/packages/opencode/src/server/amicode/vault-browser.ts @@ -33,11 +33,13 @@ const err = (code: string, detail: string) => JSON.stringify({ ok: false, error: * results); browsing is a LOCAL-researcher capability, not a server API. Same * loopback family + bind signal as the credential-mutation guard * (connections.ts) — a 0.0.0.0 / LAN-bound server refuses these routes even - * to authed callers, unless AMICO_VAULT_BROWSER=1 explicitly opts a shared - * deployment in (=0 forces off everywhere). */ + * to authed callers, unless AMICO_VAULT_BROWSER explicitly opts the + * deployment in: =1 opens the deployment gate (per-mount rules still apply), + * =0 forces off everywhere, =public serves ONLY kind="public" mounts + * regardless of markers (the hackathon-box mode — Aaron 2026-07-27). */ export function browseAllowed(env: Record = process.env): boolean { const flag = env.AMICO_VAULT_BROWSER - if (flag === "1" || flag === "true") return true + if (flag === "1" || flag === "true" || flag === "public") return true if (flag === "0" || flag === "false") return false return isLoopbackHostname(getBindHostname()) } @@ -45,18 +47,49 @@ export function browseAllowed(env: Record = process. const browseRefusal = () => err("forbidden", "vault browsing serves loopback servers only (set AMICO_VAULT_BROWSER=1 to override)") -/** A vault opts out of the browser entirely with `browse = false` in its - * .amico-vault.toml marker — the agent's read grants are unaffected, but the - * panel will not list or serve a byte of it. */ -export function browseOptedOut(dir: string): boolean { +/** The mount's marker taxonomy: kind plus the explicit browse override. */ +export function mountMeta(dir: string): { kind: string; browse: boolean | undefined } { try { - return /^\s*browse\s*=\s*false\s*$/m.test(readFileSync(path.join(dir, ".amico-vault.toml"), "utf8")) + const text = readFileSync(path.join(dir, ".amico-vault.toml"), "utf8") + const kind = text.match(/^\s*kind\s*=\s*"([^"]*)"/m)?.[1] ?? "" + const browse = /^\s*browse\s*=\s*false\s*$/m.test(text) + ? false + : /^\s*browse\s*=\s*true\s*$/m.test(text) + ? true + : undefined + return { kind, browse } } catch { - return false + return { kind: "", browse: undefined } } } -const optOutRefusal = (mountId: string) => err("forbidden", `vault "${mountId}" opts out of browsing (browse = false)`) +/** Browsability is FAIL-CLOSED BY KIND (Aaron 2026-07-27): the marker already + * carries the taxonomy, so team/project/engagement/restricted — and anything + * with an unknown kind — ship dark by default; `browse = true` is the + * deliberate opt-in. Personal stays browsable (it is the operator's own + * machine) and public is browsable by definition. `browse = false` darkens + * any kind. Under AMICO_VAULT_BROWSER=public, ONLY public mounts serve, + * markers ignored. Returns a refusal body, or undefined when browsable. + * The agent's read grants are unaffected either way. */ +export function mountBrowseRefusal( + mountId: string, + dir: string, + env: Record = process.env, +): string | undefined { + const meta = mountMeta(dir) + if (env.AMICO_VAULT_BROWSER === "public") { + if (meta.kind !== "public") + return err("forbidden", `vault "${mountId}" is not public — this deployment serves public vaults only`) + return undefined + } + if (meta.browse === false) return err("forbidden", `vault "${mountId}" opts out of browsing (browse = false)`) + if (meta.browse === true) return undefined + if (meta.kind === "personal" || meta.kind === "public") return undefined + return err( + "forbidden", + `vault "${mountId}" is kind "${meta.kind || "unknown"}" — browsing is opt-in for shared vaults (browse = true in its marker)`, + ) +} export function isTextFile(name: string): boolean { const ext = path.extname(name).toLowerCase() @@ -102,7 +135,8 @@ export function vaultFilesBody(mountId: string | undefined, root: string = vault if (!mountId) return err("bad_request", "mount is required") const dir = mountDir(mountId, root) if (!dir) return err("not_found", `no attached vault named "${mountId}"`) - if (browseOptedOut(dir)) return optOutRefusal(mountId) + const refusal = mountBrowseRefusal(mountId, dir) + if (refusal) return refusal let realRoot: string try { realRoot = realpathSync(dir) @@ -164,7 +198,8 @@ export function vaultFileBody(mountId: string | undefined, relPath: string | und if (!relPath) return err("bad_request", "path is required") const dir = mountDir(mountId, root) if (!dir) return err("not_found", `no attached vault named "${mountId}"`) - if (browseOptedOut(dir)) return optOutRefusal(mountId) + const refusal = mountBrowseRefusal(mountId, dir) + if (refusal) return refusal let realRoot: string let realTarget: string try { diff --git a/packages/opencode/test/server/amicode-vault-browser.test.ts b/packages/opencode/test/server/amicode-vault-browser.test.ts index e626cc0e0..2837440de 100644 --- a/packages/opencode/test/server/amicode-vault-browser.test.ts +++ b/packages/opencode/test/server/amicode-vault-browser.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" -import { browseAllowed, browseOptedOut, isTextFile, mountDir, vaultFileBody, vaultFilesBody } from "@/server/amicode/vault-browser" +import { browseAllowed, mountBrowseRefusal, mountMeta, isTextFile, mountDir, vaultFileBody, vaultFilesBody } from "@/server/amicode/vault-browser" import { setBindHostname } from "@/server/amicode/connections" // The suite runs with no listener bound (bindHostname undefined = in-process, @@ -50,13 +50,48 @@ describe("browse gates (proprietary vaults never serve off-box)", () => { }) test("browse = false in the marker darkens the mount for listing AND reads", () => { const { root, mount } = fixtureRoot() - writeFileSync(path.join(mount, ".amico-vault.toml"), 'kind = "team"\nname = "armonia-test"\nbrowse = false\n') - expect(browseOptedOut(mount)).toBe(true) + writeFileSync(path.join(mount, ".amico-vault.toml"), 'kind = "personal"\nname = "armonia-test"\nbrowse = false\n') + expect(mountMeta(mount).browse).toBe(false) expect(JSON.parse(vaultFilesBody("armonia-test", root)).error).toMatch(/^forbidden: vault "armonia-test" opts out/) expect(JSON.parse(vaultFileBody("armonia-test", "STRATEGY.md", root)).error).toMatch(/^forbidden:/) }) }) +describe("fail-closed by kind (Aaron 2026-07-27): shared vaults ship dark", () => { + const marker = (mount: string, body: string) => writeFileSync(path.join(mount, ".amico-vault.toml"), body) + test("team/project/engagement/unknown kinds refuse by default; browse = true is the opt-in", () => { + const { root, mount } = fixtureRoot() + for (const kind of ["team", "project", "engagement", "restricted", ""]) { + marker(mount, `kind = "${kind}"\nname = "armonia-test"\n`) + const out = JSON.parse(vaultFilesBody("armonia-test", root)) + expect(out.ok).toBe(false) + expect(out.error).toMatch(/browsing is opt-in for shared vaults/) + } + marker(mount, 'kind = "team"\nname = "armonia-test"\nbrowse = true\n') + expect(JSON.parse(vaultFilesBody("armonia-test", root)).ok).toBe(true) + }) + test("personal and public stay browsable by default", () => { + const { root, mount } = fixtureRoot() + for (const kind of ["personal", "public"]) { + marker(mount, `kind = "${kind}"\nname = "armonia-test"\n`) + expect(JSON.parse(vaultFilesBody("armonia-test", root)).ok).toBe(true) + } + }) + test("AMICO_VAULT_BROWSER=public serves ONLY public mounts, markers ignored (hackathon boxes)", () => { + const { mount } = fixtureRoot() + const env = { AMICO_VAULT_BROWSER: "public" } + marker(mount, 'kind = "team"\nname = "armonia-test"\nbrowse = true\n') + expect(mountBrowseRefusal("armonia-test", mount, env)).toMatch(/serves public vaults only/) + marker(mount, 'kind = "personal"\nname = "armonia-test"\n') + expect(mountBrowseRefusal("armonia-test", mount, env)).toMatch(/serves public vaults only/) + marker(mount, 'kind = "public"\nname = "armonia-test"\n') + expect(mountBrowseRefusal("armonia-test", mount, env)).toBeUndefined() + // and =public also opens the deployment gate (the boxes are exposed binds) + setBindHostname("0.0.0.0") + expect(browseAllowed(env)).toBe(true) + }) +}) + describe("isTextFile", () => { test("markdown and source are text; binaries are not", () => { expect(isTextFile("note.md")).toBe(true)