From bb328580af85d0dbe8cd965aa94c1bbb60e8cf4a Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 14:54:08 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20workstreams=20W3=20=E2=80=94=20a?= =?UTF-8?q?=20workstream's=20folders,=20a=20session's=20read=20fence,=20sh?= =?UTF-8?q?are=20verdicts=20for=20references=20and=20moves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/daemon/workstreams/binding.ts | 65 ++++++++++- src/daemon/workstreams/folders.ts | 120 +++++++++++++++++++++ src/daemon/workstreams/service.ts | 120 ++++++++++++++++++++- src/daemon/workstreams/workstreams.test.ts | 80 ++++++++++++++ 4 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 src/daemon/workstreams/folders.ts diff --git a/src/daemon/workstreams/binding.ts b/src/daemon/workstreams/binding.ts index cb55651c..410e4bc5 100644 --- a/src/daemon/workstreams/binding.ts +++ b/src/daemon/workstreams/binding.ts @@ -6,6 +6,20 @@ import type { WorkstreamSource } from "../../protocol/types.js"; +/** + * Another workstream a session may read (§7.2): its folders through file + * tools and the sandbox (read-only), its memory through recall. Never its + * servers, env or accounts, and never a write. + */ +export interface WorkstreamReference { + workstream: string; + /** Folders inside it; absent = all of its folders. */ + paths?: string[]; + /** The subject that granted it (an `ask` workstream: the owner who confirmed). */ + grantedBy: string; + grantedAt: string; +} + /** A session's home, as persisted in its meta (TranscriptMeta.workstream). */ export interface WorkstreamBinding { /** Absent = no home (`source: "none"`). */ @@ -14,6 +28,35 @@ export interface WorkstreamBinding { matchedBy?: string; /** The config revision the binding was made under. */ revision: string; + references?: WorkstreamReference[]; + /** + * Every workstream the session has read from through a reference, for its + * lifetime (§14 q3): a later move or fork follows each one's share policy + * as it is then, even after the reference is gone. + */ + readFrom?: string[]; +} + +const ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; +const MAX_REFERENCES = 32; +const MAX_REFERENCE_PATHS = 64; + +function readReference(raw: unknown): WorkstreamReference | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined; + const r = raw as Record; + if (typeof r.workstream !== "string" || !ID.test(r.workstream)) return undefined; + if (typeof r.grantedBy !== "string" || r.grantedBy.length === 0 || r.grantedBy.length > 512) return undefined; + if (typeof r.grantedAt !== "string" || r.grantedAt.length > 64) return undefined; + if (r.paths !== undefined) { + if (!Array.isArray(r.paths) || r.paths.length > MAX_REFERENCE_PATHS) return undefined; + if (!r.paths.every((p) => typeof p === "string" && p.startsWith("/") && p.length <= 4096 && !p.includes("\0"))) return undefined; + } + return { + workstream: r.workstream, + ...(r.paths !== undefined ? { paths: [...(r.paths as string[])] } : {}), + grantedBy: r.grantedBy, + grantedAt: r.grantedAt, + }; } const SOURCES: ReadonlySet = new Set(["explicit", "matched", "default", "override", "migrated", "reserved", "none"]); @@ -29,13 +72,33 @@ export function readWorkstreamBinding(raw: unknown): WorkstreamBinding | undefin const r = raw as Record; if (typeof r.source !== "string" || !SOURCES.has(r.source)) return undefined; if (typeof r.revision !== "string" || r.revision.length > 128) return undefined; - if (r.id !== undefined && (typeof r.id !== "string" || !/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(r.id))) return undefined; + if (r.id !== undefined && (typeof r.id !== "string" || !ID.test(r.id))) return undefined; if (r.matchedBy !== undefined && (typeof r.matchedBy !== "string" || r.matchedBy.length > 1024)) return undefined; if ((r.source === "none") !== (r.id === undefined)) return undefined; + // References and the read-from record fail the whole binding when malformed: + // a reference dropped silently would be harmless, but a read-from entry + // dropped would let a later move skip a share policy. + let references: WorkstreamReference[] | undefined; + if (r.references !== undefined) { + if (!Array.isArray(r.references) || r.references.length > MAX_REFERENCES) return undefined; + references = []; + for (const raw of r.references) { + const ref = readReference(raw); + if (!ref) return undefined; + references.push(ref); + } + } + let readFrom: string[] | undefined; + if (r.readFrom !== undefined) { + if (!Array.isArray(r.readFrom) || r.readFrom.length > 256 || !r.readFrom.every((x) => typeof x === "string" && ID.test(x))) return undefined; + readFrom = [...new Set(r.readFrom as string[])]; + } return { ...(typeof r.id === "string" ? { id: r.id } : {}), source: r.source as WorkstreamSource, ...(typeof r.matchedBy === "string" ? { matchedBy: r.matchedBy } : {}), revision: r.revision, + ...(references && references.length > 0 ? { references } : {}), + ...(readFrom && readFrom.length > 0 ? { readFrom } : {}), }; } diff --git a/src/daemon/workstreams/folders.ts b/src/daemon/workstreams/folders.ts new file mode 100644 index 00000000..247329a4 --- /dev/null +++ b/src/daemon/workstreams/folders.ts @@ -0,0 +1,120 @@ +/** + * A workstream's folders on this machine (docs/workstreams-design.md §7): the + * concrete directories its path rules name, plus the folders recorded as its + * home (`workstream_folders`: a remote rule names no path). What the read + * fence is built from — the sandbox's roots and hidden paths, the gate's + * check. + * + * Bounded and link-free: a rule is expanded one segment at a time with + * `readdir`, never `**`, at most `FOLDER_LIMIT` folders, and a symlinked + * entry is never followed (a link an agent can plant must not place another + * workstream's folder, nor make one look like the home's). What can't be + * expanded is said in `limits`, never guessed at. + */ + +import { lstatSync, readdirSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** Most folders one workstream's rules expand to. */ +export const FOLDER_LIMIT = 512; + +const GLOB_CHARS = /[*?[\]{}]/; + +export interface ExpandedFolders { + folders: string[]; + /** Rules that couldn't be expanded fully, and why. */ + limits: string[]; +} + +/** A directory reached through no link (an entry a glob matched). */ +function isDir(path: string): boolean { + try { + return lstatSync(path).isDirectory(); + } catch { + return false; + } +} + +/** A directory, links followed: a segment the rule names literally (the user wrote it). */ +function isDirFollowed(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +/** + * The existing directories a path rule (absolute, `~` expanded, normalized) + * names. A literal rule is its own folder when it is a directory. + */ +export function expandPathRule(rule: string, limit = FOLDER_LIMIT): ExpandedFolders { + const limits: string[] = []; + const segments = rule.split("/").filter((s) => s.length > 0); + let level = ["/"]; + for (const seg of segments) { + if (seg === "**") { + // `/**` at the end is normalized away; one in the middle names any depth. + limits.push(`${rule}: a \`**\` in the middle isn't expanded`); + return { folders: [], limits }; + } + const next: string[] = []; + if (!GLOB_CHARS.test(seg)) { + for (const dir of level) if (isDirFollowed(join(dir, seg))) next.push(join(dir, seg)); + } else { + const glob = new Bun.Glob(seg); + for (const dir of level) { + let names: string[]; + try { + names = readdirSync(dir); + } catch { + continue; + } + for (const name of names) { + if (!glob.match(name)) continue; + const path = join(dir, name); + if (isDir(path)) next.push(path); + if (next.length > limit) { + limits.push(`${rule}: more than ${limit} folders`); + return { folders: [], limits }; + } + } + } + } + level = next; + if (level.length === 0) break; + } + return { folders: segments.length === 0 ? ["/"] : level, limits }; +} + +/** + * The repository top of `dir`: the nearest ancestor holding `.git` (a dir or + * a file), or `dir` itself when there is none. A recorded home folder is a + * session's workdir, which may be a package inside a repository: the fence is + * the whole repository. + */ +export function repositoryTopOf(dir: string, stopAt?: string): string { + let at = dir; + for (;;) { + try { + lstatSync(join(at, ".git")); + return at; + } catch {} + const up = dirname(at); + if (up === at || at === stopAt) return dir; + at = up; + } +} + +/** Whether `path` is `folder` or inside it (both absolute, normalized). */ +export function within(path: string, folder: string): boolean { + return folder === "/" || path === folder || path.startsWith(`${folder}/`); +} + +/** The folders with any folder inside another dropped (the outer one kept). */ +export function outermost(folders: Iterable): string[] { + const sorted = [...new Set(folders)].sort((a, b) => a.length - b.length); + const out: string[] = []; + for (const f of sorted) if (!out.some((o) => within(f, o))) out.push(f); + return out; +} diff --git a/src/daemon/workstreams/service.ts b/src/daemon/workstreams/service.ts index 7e2a873e..f6c76e46 100644 --- a/src/daemon/workstreams/service.ts +++ b/src/daemon/workstreams/service.ts @@ -14,6 +14,7 @@ import { createHash } from "node:crypto"; import { realpathSync } from "node:fs"; import type { SessionWorkstreamInfo, WorkstreamResolution, WorkstreamSummary } from "../../protocol/types.js"; import { readWorkstreamBinding, type WorkstreamBinding } from "./binding.js"; +import { expandPathRule, outermost, within } from "./folders.js"; import { effectiveWorkstreams, type EffectiveWorkstream } from "./effective.js"; import { FolderReader } from "./folder.js"; import { expandHome, type MatchableWorkstream, matchFolder, normalizePathRule } from "./match.js"; @@ -22,7 +23,26 @@ import type { SecretStore } from "../credentials/store.js"; import type { CredentialDecl } from "../credentials/schema.js"; import type { GrantCredential, McpGrant } from "./grant.js"; -export { readWorkstreamBinding, type WorkstreamBinding } from "./binding.js"; +export { readWorkstreamBinding, type WorkstreamBinding, type WorkstreamReference } from "./binding.js"; + +/** + * A session's read fence (§7.3): the folders it may read (its home's, and + * its references'), and every other workstream's, which it may not. Built + * from the folders on this machine; `limits` says what couldn't be expanded. + */ +export interface WorkstreamFence { + workstream?: string; + /** The home's folders: readable and, as the sandbox allows, writable. */ + home: string[]; + /** Readable only, never written. */ + references: Array<{ workstream: string; folders: string[] }>; + /** Neither read nor written: every other workstream's folders that hold none of the above. */ + foreign: Array<{ workstream: string; folders: string[] }>; + limits: string[]; +} + +/** What a share policy says to a read or a move: allowed, the owner asked, or refused (with why). */ +export type ShareVerdict = { ok: true; ask: string[] } | { ok: false; error: string }; /** The reserved home of the conductor (§10). */ export const CONDUCTOR_WORKSTREAM = "conductor"; @@ -157,6 +177,104 @@ export class WorkstreamService { return this.#effective.get(id); } + /** + * A workstream's folders on this machine: the directories its path rules + * name (each spelling: as written and, for `~`, the home's real path), plus + * `recorded` (folders recorded as its home, repository tops). + */ + foldersOf(id: string, recorded: readonly string[] = []): { folders: string[]; limits: string[] } { + const m = this.#matchables.find((x) => x.id === id); + if (!m || m.abstract) return { folders: [], limits: [] }; + const limits: string[] = []; + const found: string[] = [...recorded]; + for (const rule of m.paths) { + const e = expandPathRule(rule); + found.push(...e.folders); + limits.push(...e.limits); + } + return { folders: outermost(found), limits }; + } + + /** + * The read fence of a session homed by `binding`, or undefined when + * isolation is off (nothing is fenced then, as before W3). `recorded(id)` + * gives a workstream's recorded home folders. + */ + fenceFor(binding: WorkstreamBinding | undefined, recorded: (id: string) => readonly string[] = () => []): WorkstreamFence | undefined { + if (!this.configured || !this.#cfg.isolateGlobalServers) return undefined; + const limits: string[] = []; + const homeId = binding?.id && binding.source !== "reserved" && this.#effective.has(binding.id) ? binding.id : undefined; + const of = (id: string) => { + const r = this.foldersOf(id, recorded(id)); + limits.push(...r.limits); + return r.folders; + }; + const home = homeId ? of(homeId) : []; + const references: WorkstreamFence["references"] = []; + for (const ref of binding?.references ?? []) { + const ws = this.#effective.get(ref.workstream); + // A reference whose workstream is gone or closed now gives nothing (§8). + if (!ws || ws.abstract || ws.share === "closed" || ref.workstream === homeId) continue; + const all = of(ref.workstream); + const folders = ref.paths ? ref.paths.filter((p) => all.some((f) => within(p, f))) : all; + if (folders.length > 0) references.push({ workstream: ref.workstream, folders }); + } + const readable = [...home, ...references.flatMap((r) => r.folders)]; + const foreign: WorkstreamFence["foreign"] = []; + for (const ws of this.#effective.values()) { + if (ws.abstract || ws.id === homeId) continue; + // A folder holding a readable one can't be fenced off without fencing + // that too: the more specific rule made it the home's or a reference's. + const folders = of(ws.id).filter((f) => !readable.some((r) => within(r, f) || within(f, r))); + if (folders.length > 0) foreign.push({ workstream: ws.id, folders }); + } + return { ...(homeId ? { workstream: homeId } : {}), home, references, foreign, limits: [...new Set(limits)] }; + } + + /** + * Whether a session homed in `home` may read `target` (a reference, + * §7.1): `open` yes, `ask` with its owner's confirm, `closed` never. + */ + referenceVerdict(home: string | undefined, ref: { workstream: string; paths?: readonly string[] }, recorded: readonly string[] = []): ShareVerdict { + const ws = this.#effective.get(ref.workstream); + if (!ws) return { ok: false, error: `Unknown workstream "${ref.workstream}"` }; + if (ws.abstract) return { ok: false, error: `"${ws.id}" is a base workstream: it has no folders of its own` }; + if (ws.id === home) return { ok: false, error: `"${ws.id}" is this session's own workstream` }; + if (ws.share === "closed") return { ok: false, error: `Workstream "${ws.id}" is closed: other workstreams can't read it` }; + if (ref.paths) { + const { folders } = this.foldersOf(ws.id, recorded); + const outside = ref.paths.filter((p) => !folders.some((f) => within(p, f))); + if (outside.length > 0) return { ok: false, error: `Not ${ws.id}'s folders: ${outside.join(", ")}` }; + } + return { ok: true, ask: ws.share === "ask" ? [ws.id] : [] }; + } + + /** + * Whether a session may move to `target` (§5.3): a move carries its whole + * transcript, so it is a share from the old home and from every workstream + * it has read from. From no home or the default workstream it is always + * allowed. + */ + moveVerdict(binding: WorkstreamBinding | undefined, target: string): ShareVerdict { + const problem = this.choiceProblem(target); + if (problem) return { ok: false, error: problem }; + if (binding?.source === "reserved") return { ok: false, error: "The conductor's home can't be changed" }; + const from = new Set(binding?.readFrom ?? []); + if (binding?.id && binding.id !== this.#cfg.default) from.add(binding.id); + from.delete(target); + const ask: string[] = []; + for (const id of [...from].sort()) { + const ws = this.#effective.get(id); + // A workstream since deleted has no policy left to keep. + if (!ws) continue; + if (ws.share === "closed") { + return { ok: false, error: id === binding?.id ? `Workstream "${id}" is closed: its sessions can't move to another` : `This session read from "${id}", which is closed: it can't move to another workstream` }; + } + if (ws.share === "ask") ask.push(id); + } + return { ok: true, ask }; + } + /** Why `id` can't be chosen as a home, or undefined when it can. */ choiceProblem(id: string): string | undefined { const ws = this.#effective.get(id); diff --git a/src/daemon/workstreams/workstreams.test.ts b/src/daemon/workstreams/workstreams.test.ts index 3fb55231..f0175c89 100644 --- a/src/daemon/workstreams/workstreams.test.ts +++ b/src/daemon/workstreams/workstreams.test.ts @@ -9,6 +9,7 @@ import { ancestry, expandHome, matchFolder, normalizePathRule, normalizeRemote, import { workstreamsSchema } from "./schema.js"; import { WorkstreamService } from "./service.js"; import { proposeWorkstreams } from "./import.js"; +import { expandPathRule } from "./folders.js"; import { EnvFileSecretStore } from "../credentials/store.js"; const ws = (id: string, over: Partial = {}): MatchableWorkstream => ({ @@ -552,3 +553,82 @@ describe("proposeWorkstreams (codeoid workstream import)", () => { }); }); + +describe("W3: folders, the fence, share verdicts", () => { + const service = (items: unknown[], extra: Record = {}) => + new WorkstreamService(workstreamsSchema.parse({ isolateGlobalServers: true, items, ...extra }), new FolderReader({ home: tmp, sshConfig: "" })); + + test("a glob rule expands to the folders it names, never its literal head; a link is never followed", () => { + for (const d of ["W/acme-api", "W/acme-web", "W/other", "elsewhere"]) mkdirSync(join(tmp, d), { recursive: true }); + writeFileSync(join(tmp, "W", "acme-file"), ""); + symlinkSync(join(tmp, "elsewhere"), join(tmp, "W", "acme-link")); + expect(expandPathRule(join(tmp, "W", "acme-*")).folders.sort()).toEqual([join(tmp, "W", "acme-api"), join(tmp, "W", "acme-web")]); + expect(expandPathRule(join(tmp, "W", "missing")).folders).toEqual([]); + expect(expandPathRule(join(tmp, "W", "**", "x")).limits[0]).toContain("**"); + expect(expandPathRule(join(tmp, "W", "*"), 1).limits[0]).toContain("more than 1"); + }); + + test("the fence: home's folders, a reference's (narrowed by paths), and every other workstream's as foreign", () => { + for (const d of ["W/acme-api", "W/acme-web", "W/hf", "W/mine/inner"]) mkdirSync(join(tmp, d), { recursive: true }); + const s = service([ + { id: "acme", share: "ask", match: { paths: ["~/W/acme-*"] } }, + { id: "hf", share: "closed", match: { paths: ["~/W/hf"] } }, + { id: "mine", share: "open", match: { paths: ["~/W/mine"] } }, + { id: "broad", match: { paths: ["~/W"] } }, + ]); + const fence = s.fenceFor({ id: "mine", source: "matched", revision: "r", references: [{ workstream: "acme", paths: [join(tmp, "W", "acme-api")], grantedBy: "u", grantedAt: "t" }] })!; + expect(fence.home).toEqual([join(tmp, "W", "mine")]); + expect(fence.references).toEqual([{ workstream: "acme", folders: [join(tmp, "W", "acme-api")] }]); + // acme-web isn't referenced: foreign, as is hf; `broad` holds the home, so it can't be fenced off. + expect(fence.foreign).toEqual([ + { workstream: "acme", folders: [join(tmp, "W", "acme-web")] }, + { workstream: "hf", folders: [join(tmp, "W", "hf")] }, + ]); + // A closed workstream's reference gives nothing, even one granted before. + const closed = s.fenceFor({ id: "mine", source: "matched", revision: "r", references: [{ workstream: "hf", grantedBy: "u", grantedAt: "t" }] })!; + expect(closed.references).toEqual([]); + // Recorded homes (a remote-rule workstream) count as its folders. + expect(s.fenceFor(undefined, (id) => (id === "hf" ? [join(tmp, "W", "hf"), "/srv/hf-clone"] : []))!.foreign.find((f) => f.workstream === "hf")?.folders.sort()).toEqual(["/srv/hf-clone", join(tmp, "W", "hf")].sort()); + // Isolation off: no fence. + expect(new WorkstreamService(workstreamsSchema.parse({ items: [{ id: "a" }] }), new FolderReader({ home: tmp, sshConfig: "" })).fenceFor(undefined)).toBeUndefined(); + }); + + test("reference verdicts follow the share policy of the workstream read", () => { + mkdirSync(join(tmp, "W", "acme-api"), { recursive: true }); + const s = service([ + { id: "acme", share: "ask", match: { paths: ["~/W/acme-*"] } }, + { id: "hf", share: "closed" }, + { id: "pub", share: "open" }, + { id: "base", abstract: true }, + ]); + expect(s.referenceVerdict("pub", { workstream: "acme" })).toEqual({ ok: true, ask: ["acme"] }); + expect(s.referenceVerdict("acme", { workstream: "pub" })).toEqual({ ok: true, ask: [] }); + expect(s.referenceVerdict("pub", { workstream: "hf" })).toMatchObject({ ok: false }); + expect(s.referenceVerdict("pub", { workstream: "base" })).toMatchObject({ ok: false }); + expect(s.referenceVerdict("pub", { workstream: "pub" })).toMatchObject({ ok: false }); + expect(s.referenceVerdict("pub", { workstream: "nope" })).toMatchObject({ ok: false }); + expect(s.referenceVerdict("pub", { workstream: "acme", paths: [join(tmp, "W", "acme-api", "src")] })).toEqual({ ok: true, ask: ["acme"] }); + expect(s.referenceVerdict("pub", { workstream: "acme", paths: ["/etc"] })).toMatchObject({ ok: false }); + }); + + test("move verdicts: the old home's policy and every workstream read from; from the default, always", () => { + const s = service([{ id: "acme", share: "ask" }, { id: "hf", share: "closed" }, { id: "pub", share: "open" }, { id: "scratch", share: "closed" }], { default: "scratch" }); + const b = (id: string, readFrom?: string[]) => ({ id, source: "matched" as const, revision: "r", ...(readFrom ? { readFrom } : {}) }); + expect(s.moveVerdict(b("pub"), "acme")).toEqual({ ok: true, ask: [] }); + expect(s.moveVerdict(b("acme"), "pub")).toEqual({ ok: true, ask: ["acme"] }); + expect(s.moveVerdict(b("hf"), "pub")).toMatchObject({ ok: false }); + expect(s.moveVerdict(b("pub", ["hf"]), "acme")).toMatchObject({ ok: false }); + expect(s.moveVerdict(b("pub", ["hf"]), "hf")).toEqual({ ok: true, ask: [] }); + expect(s.moveVerdict(b("scratch"), "acme")).toEqual({ ok: true, ask: [] }); + expect(s.moveVerdict(undefined, "acme")).toEqual({ ok: true, ask: [] }); + expect(s.moveVerdict({ id: "conductor", source: "reserved", revision: "r" }, "pub")).toMatchObject({ ok: false }); + }); + + test("a binding's references and read-from record round-trip; a malformed one fails the binding", () => { + const good = { id: "a", source: "matched", revision: "r", references: [{ workstream: "b", paths: ["/x"], grantedBy: "u", grantedAt: "t" }], readFrom: ["b", "b"] }; + expect(readWorkstreamBinding(good)).toEqual({ ...good, readFrom: ["b"] }); + expect(readWorkstreamBinding({ ...good, references: [{ workstream: "B!", grantedBy: "u", grantedAt: "t" }] })).toBeUndefined(); + expect(readWorkstreamBinding({ ...good, references: [{ workstream: "b", paths: ["rel"], grantedBy: "u", grantedAt: "t" }] })).toBeUndefined(); + expect(readWorkstreamBinding({ ...good, readFrom: ["ok", 3] })).toBeUndefined(); + }); +}); From 8fe0c9d97edbf89ceec346c54db4ca2e2757ee6c Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 14:58:13 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20workstreams=20W3=20=E2=80=94=20th?= =?UTF-8?q?e=20read=20fence:=20sandbox=20roots=20and=20hidden=20paths=20fr?= =?UTF-8?q?om=20the=20session's=20workstream,=20file=20tools=20refused=20i?= =?UTF-8?q?n=20another=20workstream's=20folders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/daemon/providers/claude/index.ts | 15 ++++ src/daemon/providers/registry.ts | 8 ++ src/daemon/sandbox/policy.ts | 17 +++- src/daemon/sandbox/session-sandbox.ts | 51 ++++++++++- src/daemon/secret-paths.ts | 19 +++++ src/daemon/session-manager.ts | 43 ++++++++-- src/daemon/session.ts | 99 ++++++++++++++++++++-- src/daemon/store.ts | 12 +++ src/daemon/workstreams/gate.ts | 88 +++++++++++++++++++ src/daemon/workstreams/workstreams.test.ts | 45 +++++++++- 10 files changed, 376 insertions(+), 21 deletions(-) create mode 100644 src/daemon/workstreams/gate.ts diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index a70b2954..443cd23c 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -165,6 +165,8 @@ export interface ClaudeProviderInit { onForeignAgentDenied?: (agentId: string, toolName: string) => void; /** The session is in autonomous mode now (ProviderSessionInit.isAutonomous); absent: treated as so. */ isAutonomous?: () => boolean; + /** The workstream fence's refusal of a call (ProviderSessionInit.workstreamFenceRefusal). */ + workstreamFenceRefusal?: (toolName: string, input: unknown, cwd: string | null | undefined) => string | null; /** The session's OS sandbox while on (ProviderSessionInit.sandbox, #448). */ sandbox?: () => SandboxRuntime | null; /** codeoid session id — used as the initial backing id and for audit/logging. */ @@ -1411,6 +1413,19 @@ export class ClaudeProvider implements SessionProvider { // Fail-closed too (#secretPathDecision). const secret = this.#secretPathDecision(input, opts.workdir); if (secret) return secret; + // The workstream fence (workstreams §7.3): a call in another + // workstream's folders is refused here, before any allow rule. + // Fail-closed: a throw refuses. + if (init.workstreamFenceRefusal) { + let fenced: string | null; + try { + fenced = init.workstreamFenceRefusal(input.tool_name, input.tool_input, hookShellCwd(input)); + } catch (err) { + console.error(`[claude-provider ${this.#init.sessionId.slice(0, 8)}] checking a call against the workstream fence failed, refusing it: ${err instanceof Error ? err.message : String(err)}`); + fenced = "codeoid couldn't check this call against the session's workstream, so it was refused."; + } + if (fenced) return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: fenced } }; + } // A shell command is judged by what it runs (#431): a risky one, // one the gate can't read, or one naming a credential file never // runs on an allow rule — it goes to canUseTool, which decides. diff --git a/src/daemon/providers/registry.ts b/src/daemon/providers/registry.ts index caa22ffe..14465baa 100644 --- a/src/daemon/providers/registry.ts +++ b/src/daemon/providers/registry.ts @@ -149,6 +149,13 @@ export interface ProviderSessionInit { * Absent: treated as autonomous (routed). */ isAutonomous?: () => boolean; + /** + * The workstream fence's refusal of a tool call (workstreams/gate.ts), or + * null. Claude only: its PreToolUse hook refuses such a call itself, since + * the CLI runs some calls (reads in the workdir, allow rules) without + * asking the gate. Throws fail closed (the hook refuses). + */ + workstreamFenceRefusal?: (toolName: string, input: unknown, cwd: string | null | undefined) => string | null; /** * The session's OS sandbox while it is on (#448, sandbox/session-sandbox.ts), * read each time the backend is started: null when it is off, unavailable, @@ -474,6 +481,7 @@ export function createDefaultProviderRegistry( isOwnTask: init.isOwnTask, onForeignAgentDenied: init.onForeignAgentDenied, ...(init.isAutonomous ? { isAutonomous: init.isAutonomous } : {}), + ...(init.workstreamFenceRefusal ? { workstreamFenceRefusal: init.workstreamFenceRefusal } : {}), sandbox: init.sandbox, }), }); diff --git a/src/daemon/sandbox/policy.ts b/src/daemon/sandbox/policy.ts index 5682ea5c..3a60d07c 100644 --- a/src/daemon/sandbox/policy.ts +++ b/src/daemon/sandbox/policy.ts @@ -405,6 +405,12 @@ function within(child: string, parent: string): boolean { export interface SpecInputs { sessionId: string; + /** + * More paths hidden for this session alone: other workstreams' folders + * under its read fence (workstreams §7.3). Never the workspace or what + * holds it (SessionSandbox leaves those out). + */ + extraHidden?: readonly string[]; workspace: string; /** * The session's cwd, when it is below `workspace` (a monorepo's package, @@ -1219,7 +1225,15 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { if (record) refuse(pinProblem(r, "project root", { stored: record })); return dirIdentity(r) !== null ? r : realOr(r); }) - .filter((r, i, all) => dirIdentity(r) !== null && all.indexOf(r) === i && !userHidden.some((u) => within(r, u)) && !inHidden(r)); + .filter( + (r, i, all) => + dirIdentity(r) !== null && + all.indexOf(r) === i && + !userHidden.some((u) => within(r, u)) && + !inHidden(r) && + // A root inside another workstream's folder: the session's own setting can't show it past its fence. + !(inputs.extraHidden ?? []).some((h) => within(r, resolve(h)) || within(realOr(r), realOr(resolve(h)))), + ); // The credential files anywhere in a root and in an agent's own added dir (not the session's own); a root or such // a dir too big to look through whole isn't shown at all, nor written (fail closed: resolveSandbox says so). const scanned = [...rootCandidates, ...agentDirs]; @@ -1249,6 +1263,7 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { ...rootSecrets.hidden, ...locationSecrets.hidden, ...(root ? [root] : []), + ...(inputs.extraHidden ?? []).map((p) => resolve(p)), ]; // The session's worktree folders: writable, but never inside something hidden (a root configured in a hidden // path), and, unlike the backend's own state, never taking a hidden path inside them out of the hidden list. diff --git a/src/daemon/sandbox/session-sandbox.ts b/src/daemon/sandbox/session-sandbox.ts index c6e197f6..ba681344 100644 --- a/src/daemon/sandbox/session-sandbox.ts +++ b/src/daemon/sandbox/session-sandbox.ts @@ -167,6 +167,12 @@ export interface EffectiveSandbox { agentDirs: string[]; } +/** A session's workstream fence as the sandbox takes it: the roots shown read-only, the folders hidden. */ +export interface WorkstreamDirs { + roots: string[]; + hidden: string[]; +} + /** The setting a session ends up with: its own fields over the config's. */ export function effectiveSetting(config: SandboxConfig, setting: SessionSandboxSetting | undefined): EffectiveSandbox { const choice = setting?.provider ?? config.provider; @@ -596,6 +602,31 @@ export class SessionSandbox { readonly #onWarning: ((text: string) => void) | undefined; readonly #onWorktreeEntries: ((common: string, findings: WorktreeEntryFinding[]) => void) | undefined; readonly #name: () => string; + readonly #workstreamDirs: (() => WorkstreamDirs | undefined) | undefined; + /** The fence the last resolution was made with, to see when it changed. */ + #workstreamKeyApplied = ""; + + /** The fence now, with what would hide the workspace or an added dir left out (those are the session's own). */ + #workstreamNow(): WorkstreamDirs | undefined { + const wd = this.#workstreamDirs?.(); + if (!wd) return undefined; + const workspace = this.#workspace(); + const added = this.#setting?.addedDirs ?? []; + const holds = (h: string, p: string) => h === "/" || p === h || p.startsWith(`${h}/`); + const hidden = wd.hidden.filter((h) => !holds(h, workspace) && !holds(h, this.#workdir) && !added.some((a) => holds(h, a) || holds(a, h))); + return { roots: wd.roots, hidden }; + } + + #workstreamKey(): string { + const wd = this.#workstreamNow(); + return wd ? JSON.stringify(wd) : ""; + } + + /** The setting with the fence's folders as its project roots, when it names none of its own. */ + #withWorkstream(setting: SessionSandboxSetting | undefined, wd: WorkstreamDirs | undefined = this.#workstreamNow()): SessionSandboxSetting | undefined { + if (!wd || setting?.projectRoots !== undefined) return setting; + return { ...(setting ?? {}), projectRoots: wd.roots }; + } constructor(opts: { sessionId: string; @@ -609,7 +640,15 @@ export class SessionSandbox { onWarning?: (text: string) => void; /** What a look at the repository's worktree entries found (checkWorktreeEntries): said on the session and audited. */ onWorktreeEntries?: (common: string, findings: WorktreeEntryFinding[]) => void; + /** + * The session's workstream fence (workstreams §7.3), read live: its + * readable folders replace the default project roots (never a root the + * session's own setting names), and other workstreams' are hidden. + * Undefined result = no fence. + */ + workstreamDirs?: () => WorkstreamDirs | undefined; }) { + this.#workstreamDirs = opts.workstreamDirs; this.#onWarning = opts.onWarning; this.#onWorktreeEntries = opts.onWorktreeEntries; this.#sessionId = opts.sessionId; @@ -637,7 +676,7 @@ export class SessionSandbox { /** What `setting` means with the config filled in. */ effective(setting: SessionSandboxSetting | undefined = this.#setting): EffectiveSandbox { - return effectiveSetting(this.config, setting); + return effectiveSetting(this.config, this.#withWorkstream(setting)); } /** The last resolution, or null before the first. */ @@ -795,7 +834,7 @@ export class SessionSandbox { * rebuilt mid-work would end it; a process backend is restarted). */ get dirsPending(): boolean { - return this.#dirsStale || this.#pinsBroken; + return this.#dirsStale || this.#pinsBroken || (this.#workstreamDirs !== undefined && this.#workstreamKey() !== this.#workstreamKeyApplied); } /** The added directories the running sandbox has (SandboxSpec.addedDirs as resolved): what host-side file tools are held to. */ @@ -854,13 +893,15 @@ export class SessionSandbox { async #refreshNow(backendId: string): Promise { let r: Awaited>; + let wsKey = ""; for (;;) { const generation = this.#generation; this.#pinWorkspace(); + wsKey = this.#workstreamKey(); r = await resolveSandbox({ registry: this.#registry, config: this.config, - setting: this.#setting, + setting: this.#withWorkstream(this.#setting), backendId, workspace: this.#workdir, top: this.#workspace(), @@ -880,6 +921,7 @@ export class SessionSandbox { this.#resolvedFor = backendId; this.#stale = false; this.#dirsStale = false; + this.#workstreamKeyApplied = wsKey; const dirs = { roots: [...(r.info.projectRoots ?? [])], added: [...(r.info.addedDirs ?? [])], ...(r.rootScan ? { scan: r.rootScan } : {}) }; this.#dirs = dirs; this.#droppedDirs = r.droppedDirs ?? []; @@ -1075,6 +1117,8 @@ export class SessionSandbox { for (const d of dirs.added) this.#checkEntriesOf(d); } const agent = new Set(this.effective().agentDirs); + // Other workstreams' folders, as at the last resolution (a change since is a dirs change, applied between turns). + const hiddenForWorkstream = this.#workstreamKeyApplied ? (JSON.parse(this.#workstreamKeyApplied) as WorkstreamDirs).hidden : []; const build = () => buildSandboxSpec({ backendReadable: readable, sessionId: this.#sessionId, @@ -1088,6 +1132,7 @@ export class SessionSandbox { dockerSocket, hideLocalServices, projectRoots: dirs.roots, + ...(hiddenForWorkstream.length > 0 ? { extraHidden: hiddenForWorkstream } : {}), addedDirs: dirs.added.map((dir) => { const guard = this.#guardFor(dir); return { dir, ...(agent.has(dir) ? { agent: true } : {}), ...(folder && guard ? { ownWorktreeEntries: guard.ownEntries() } : {}) }; diff --git a/src/daemon/secret-paths.ts b/src/daemon/secret-paths.ts index d87c2137..a8831c8d 100644 --- a/src/daemon/secret-paths.ts +++ b/src/daemon/secret-paths.ts @@ -247,6 +247,25 @@ function within(child: string, parent: string): boolean { return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); } +/** + * The paths a tool call reads or searches, as written (relative ones from its + * cwd), backend-neutral: also what the workstream fence judges (workstreams/gate.ts). + */ +export function toolReadPaths(toolName: string, input: unknown): string[] { + return candidatePaths(toolName, input); +} + +/** Whether a tool searches (an absent root: the workdir): one whose root holds a fenced folder reads into it. */ +export function isSearchTool(toolName: string): boolean { + return SEARCH_TOOLS.has(toolName); +} + +/** A tool's path made absolute from `cwd`, as the kernel resolves it, and its real path: the forms a check judges. */ +export function toolPathForms(p: string, cwd: string): string[] { + const literal = expand(p, cwd); + return [...new Set([literal, real(literal)])]; +} + function candidatePaths(toolName: string, input: unknown): string[] { if (!input || typeof input !== "object") return []; const obj = input as Record; diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index e33a511c..800281bc 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -9,7 +9,8 @@ */ import { addedDirProblem, type OtherCheckout, PROJECT_ROOT_PARENT, projectRootProblem } from "./sandbox/project-roots.js"; -import { WorkstreamService, type WorkstreamBinding } from "./workstreams/service.js"; +import { WorkstreamService, type WorkstreamBinding, type WorkstreamFence } from "./workstreams/service.js"; +import { repositoryTopOf } from "./workstreams/folders.js"; import { grantRefusal, type McpGrant } from "./workstreams/grant.js"; import { OverrideTokens } from "./workstreams/override.js"; import { withSlot } from "./mcp/oauth.js"; @@ -2331,8 +2332,9 @@ mcpHub: this.#mcpHub, #workstreamOpts(binding: WorkstreamBinding | undefined): { workstream?: WorkstreamBinding; describeWorkstream: (b: WorkstreamBinding | undefined) => ReturnType; - workstreamEnv?: () => Readonly>; - mcpGrant: () => McpGrant | undefined; + workstreamEnv?: (b: WorkstreamBinding | undefined) => Readonly>; + mcpGrant: (b: WorkstreamBinding | undefined) => McpGrant | undefined; + workstreamFence: (b: WorkstreamBinding | undefined) => WorkstreamFence | undefined; } { const { missing } = this.#workstreams.envFor(binding, this.#secrets); if (missing.length > 0) { @@ -2341,14 +2343,39 @@ mcpHub: this.#mcpHub, return { ...(binding ? { workstream: binding } : {}), describeWorkstream: this.#describeWorkstream, - // Read live: an edit to the workstream or a credential reaches the - // session's next backend start (its next turn), never only a restart. - ...(binding?.id ? { workstreamEnv: () => this.#workstreams.envFor(binding, this.#secrets).env } : {}), - // Read live: the grant follows the config as it is at each listing. - mcpGrant: () => this.#workstreams.grantFor(binding), + // Each read live, for the session's binding as it is then: an edit to + // the workstream or a credential, or a move to another (§5.3), reaches + // the session's next backend start (its next turn), never only a restart. + ...(this.#workstreams.configured || binding ? { workstreamEnv: (b: WorkstreamBinding | undefined) => (b?.id ? this.#workstreams.envFor(b, this.#secrets).env : {}) } : {}), + // The grant follows the config as it is at each listing. + mcpGrant: (b) => this.#workstreams.grantFor(b), + workstreamFence: (b) => this.#workstreamFence(b), }; } + /** Fences by binding and config revision, briefly: the gate reads one on every file tool call. */ + #fenceCache = new Map(); + + /** + * A session's read fence (workstreams §7.3), or undefined when isolation is + * off. Recorded home folders count as their workstream's (a remote rule + * names no path), as repository tops. Cached 5s: folders made meanwhile + * (a new clone) are fenced within that. + */ + #workstreamFence(binding: WorkstreamBinding | undefined): WorkstreamFence | undefined { + if (!this.#workstreams.configured || !this.#workstreams.isolateGlobalServers) return undefined; + const key = `${this.#workstreams.revision}\u0000${JSON.stringify(binding ?? null)}`; + const now = Date.now(); + const hit = this.#fenceCache.get(key); + if (hit && now - hit.at < 5_000) return hit.fence; + const recorded = this.#store.workstreamFoldersByWorkstream(); + const home = homedir(); + const fence = this.#workstreams.fenceFor(binding, (id) => (recorded.get(id) ?? []).map((w) => repositoryTopOf(w, home))); + if (this.#fenceCache.size > 256) this.#fenceCache.clear(); + this.#fenceCache.set(key, { at: now, fence }); + return fence; + } + /** * The home of a session the daemon starts itself in `workdir` (an import, a * loop attempt, a routine run, a dispatch worker): its folder's, else the diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 960736a7..0633e66f 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -103,6 +103,8 @@ import { type PinnedDir, PinnedPathError, pinDir, pinRecord } from "./sandbox/pi import { applyRewinds, REWIND_EVENT, type TurnIndexEntry } from "./transcript.js"; import type { EffortChangeResult, EffortLevel, RewindFiles, RewindIrreversible, SessionWorkstreamInfo, TurnSummary } from "../protocol/types.js"; import type { WorkstreamBinding } from "./workstreams/binding.js"; +import type { WorkstreamFence } from "./workstreams/service.js"; +import { type FenceRefusal, fenceRefusal, fenceRefusalText } from "./workstreams/gate.js"; import type { McpGrant } from "./workstreams/grant.js"; import { LIMITS } from "../protocol/types.js"; import { hasScope, SCOPES } from "../protocol/scopes.js"; @@ -565,13 +567,20 @@ export interface SessionCreateOptions { * and held to the same denylist (the schema already refused anything it * would). Never persisted: recomputed from the config on every resume. */ - workstreamEnv?: () => Readonly>; + workstreamEnv?: (binding: WorkstreamBinding | undefined) => Readonly>; /** - * The home's MCP grant under isolation (workstreams/grant.ts), read live; - * undefined result = no isolation. Applied by the session's MCP view, so - * every backend sees only what the home selects. + * The home's MCP grant under isolation (workstreams/grant.ts), read live + * for the session's binding as it is now; undefined result = no isolation. + * Applied by the session's MCP view, so every backend sees only what the + * home selects. */ - mcpGrant?: () => McpGrant | undefined; + mcpGrant?: (binding: WorkstreamBinding | undefined) => McpGrant | undefined; + /** + * The session's read fence under isolation (workstreams §7.3), read live + * for its binding: its home's and references' folders, and every other + * workstream's. Undefined result = no fence. + */ + workstreamFence?: (binding: WorkstreamBinding | undefined) => WorkstreamFence | undefined; /** * Role-scoped goal-blackboard mount for a collaboration child: the endpoint * URL plus a bearer token that IS the scope (one goal, this role's read/write @@ -1405,11 +1414,27 @@ export class Session { #extraEnvStatic: Readonly> | undefined; /** The home workstream's env, read live (SessionCreateOptions.workstreamEnv). */ #workstreamEnv: (() => Readonly>) | undefined; + /** The session's read fence (SessionCreateOptions.workstreamFence), for its binding as it is now. */ + #workstreamFence: (() => WorkstreamFence | undefined) | undefined; /** What a backend gets as extra env: a live view over both (see #liveExtraEnv). */ #extraEnv: Readonly> | undefined; /** The extra env the current backend was built with, to rebuild it when the workstream's changes. */ #envBuiltWith: string | undefined; + /** + * The read fence as the sandbox takes it (workstreams §7.3): the home's and + * references' folders shown read-only (the project roots), every other + * workstream's hidden. + */ + #sandboxFence(): { roots: string[]; hidden: string[] } | undefined { + const fence = this.#workstreamFence?.(); + if (!fence) return undefined; + return { + roots: [...new Set([...fence.home, ...fence.references.flatMap((r) => r.folders)])], + hidden: [...new Set(fence.foreign.flatMap((f) => f.folders))], + }; + } + /** The workstream's env under the caller's extra env, held to the extra-env denylist. */ #composedEnv(): Record { const out: Record = { ...(this.#workstreamEnv?.() ?? {}), ...(this.#extraEnvStatic ?? {}) }; @@ -1796,7 +1821,10 @@ export class Session { this.#collaborationCost = opts.collaborationCost; this.#maxBudgetUsd = opts.maxBudgetUsd; this.#onBudgetExhausted = opts.onBudgetExhausted ?? "park"; - this.#workstreamEnv = opts.workstreamEnv; + const envOf = opts.workstreamEnv; + this.#workstreamEnv = envOf ? () => envOf(this.#workstream) : undefined; + const fenceOf = opts.workstreamFence; + this.#workstreamFence = fenceOf ? () => fenceOf(this.#workstream) : undefined; const extra = opts.extraEnv; if (extra) { // Before any side effect: a refused key must fail the create, not run a @@ -1820,6 +1848,7 @@ export class Session { ...(opts.sandbox ? { setting: opts.sandbox } : {}), onWarning: (text) => this.#sandboxWarning(text, "sandbox.login_not_saved"), onWorktreeEntries: (common, findings) => this.#worktreeEntriesFound(common, findings), + ...(this.#workstreamFence ? { workstreamDirs: () => this.#sandboxFence() } : {}), }); } this.#requireSandbox = opts.requireSandbox === true; @@ -1871,7 +1900,7 @@ export class Session { workdir: this.workdir, tenant: { accountId: this.accountId, projectId: this.projectId }, approvals: () => this.#store.workdirMcpApprovals(scope), - ...(opts.mcpGrant ? { grant: opts.mcpGrant } : {}), + ...(opts.mcpGrant ? { grant: () => opts.mcpGrant?.(this.#workstream) } : {}), }); } this.#config = opts.config; @@ -2133,6 +2162,14 @@ export class Session { isOwnTask: (taskId) => this.#isOwnTask(taskId), onForeignAgentDenied: (agentId, toolName) => this.#noteForeignAgentRefused(agentId, toolName), isAutonomous: () => this.#mode === "autonomous", + ...(this.#workstreamFence + ? { + workstreamFenceRefusal: (toolName: string, input: unknown, cwd: string | null | undefined) => { + const r = this.#fenceRefusal(toolName, input, cwd); + return r ? fenceRefusalText(r) : null; + }, + } + : {}), // Read each time the backend starts, and only what was resolved for THIS backend. sandbox: () => this.#sandbox?.runtimeFor(factory.id) ?? null, ...(this.#extraEnv ? { extraEnv: this.#extraEnv } : {}), @@ -2470,6 +2507,37 @@ export class Session { } } + /** The workstream fence's verdict on a tool call (workstreams/gate.ts); null without a fence. Fails closed. */ + #fenceRefusal(toolName: string, input: unknown, shellCwd: string | null | undefined): FenceRefusal | null { + let fence: WorkstreamFence | undefined; + try { + fence = this.#workstreamFence?.(); + } catch (e) { + console.error(`[codeoid/session ${this.id}] workstream fence: ${e instanceof Error ? e.message : String(e)}`); + return { path: "(couldn't be judged)", workstream: this.#workstream?.id ?? "?", kind: "read" }; + } + if (!fence) return null; + // paths: judging a tool call's input (fenceRefusal): no use of the path + return fenceRefusal(toolName, input, { workdir: this.workdir, ...(shellCwd !== undefined ? { cwd: shellCwd } : {}), fence }); + } + + /** Refuse a call the workstream fence holds: its card, the audit, and an info line. */ + #refuseFenced(approvalId: string, toolName: string, r: FenceRefusal, sender: AuthContext): { behavior: "deny"; message: string } { + const reason = fenceRefusalText(r); + this.#approvalPatchKeys.delete(approvalId); + this.#resolveToolCallMessage(approvalId, { phase: "cancelled", reason: "denied", message: reason }); + this.#store.audit(sender.sub, "session.workstream_fence_refuse", this.id, `tool=${toolName} kind=${r.kind} workstream=${r.workstream}`); + const infoMsg = this.#makeMessage("info", `⛔ Refused ${toolName}: ${r.path} is workstream "${r.workstream}"'s`, SYSTEM_IDENTITY, undefined, undefined, { + event: "workstream.fenced", + tool: toolName, + workstream: r.workstream, + kind: r.kind, + }); + this.#persistAndBuffer(infoMsg); + this.#broadcastRaw(infoMsg); + return { behavior: "deny", message: reason }; + } + /** A host-side file tool beyond the sandbox, in autonomous mode: refused (#outsideSandbox). Null in every other mode. */ #outsideSandboxRefusal(toolName: string, input: Record, shellCwd: string | null | undefined): HostToolBreach | null { return this.#mode === "autonomous" ? this.#outsideSandbox(toolName, input, shellCwd) : null; @@ -9680,6 +9748,11 @@ export class Session { return { behavior: "deny" as const, message: reason }; } + // The workstream fence (workstreams §7.3), every mode: a file tool in + // another workstream's folders, or writing a reference, is refused. + const fenced = this.#fenceRefusal(toolName, effectiveInput, shellCwd); + if (fenced) return this.#refuseFenced(approvalId, toolName, fenced, sender); + // A file tool running outside the OS sandbox (Claude's, opencode's) is // held to the sandbox's own policy in autonomous mode (#448 rounds 8, 9): // beyond it, refused, never asked. Guarded and interactive mode decide @@ -10385,11 +10458,21 @@ export class Session { const budgetDenied = this.#budgetExhausted || this.#budgetSpentFor(event.name); // Autonomous mode refuses it outright (#autonomousRefusal): no approval card either. const deniedFirst = networkAsk || budgetDenied || secret?.action === "deny" || risk?.secret?.action === "deny"; + // The workstream fence refuses it in every mode (#fenceRefusal): no approval card. + const fenced = deniedFirst ? null : this.#fenceRefusal(event.name, event.input, event.shellCwd); // A pinned directory changed (#448 round 13): the gate refuses every call (#sandboxPinRefusal). const pinned = budgetDenied ? null : this.#sandboxPinRefusal(); // Beyond the sandbox, a host-side file tool is refused in autonomous mode (#outsideSandbox). const outside = deniedFirst || pinned ? null : this.#outsideSandboxRefusal(event.name, event.input, event.shellCwd); - const refused = pinned ? { reason: pinned } : deniedFirst ? null : outside ? { reason: hostFileBreachReason(outside) } : this.#autonomousRefusal(event.name, event.input, risk, secret, held); + const refused = pinned + ? { reason: pinned } + : deniedFirst + ? null + : fenced + ? { reason: fenceRefusalText(fenced) } + : outside + ? { reason: hostFileBreachReason(outside) } + : this.#autonomousRefusal(event.name, event.input, risk, secret, held); const toolMsg = this.#makeMessage( "tool_call", `${event.name}(${Object.keys(event.input).join(", ")})`, diff --git a/src/daemon/store.ts b/src/daemon/store.ts index 7dd61827..e7268aab 100644 --- a/src/daemon/store.ts +++ b/src/daemon/store.ts @@ -1985,6 +1985,18 @@ export class Store { return row?.workstream; } + /** Every recorded folder by workstream, across tenants (the read fence: workstreams are daemon-wide config). */ + workstreamFoldersByWorkstream(): Map { + const rows = this.#db.prepare("SELECT DISTINCT workstream, workdir FROM workstream_folders").all() as { workstream: string; workdir: string }[]; + const out = new Map(); + for (const r of rows) { + const list = out.get(r.workstream) ?? []; + list.push(r.workdir); + out.set(r.workstream, list); + } + return out; + } + setWorkstreamFolder(scope: { accountId: string; projectId: string; workdir: string }, workstream: string): void { this.#db .prepare( diff --git a/src/daemon/workstreams/gate.ts b/src/daemon/workstreams/gate.ts new file mode 100644 index 00000000..e74219e0 --- /dev/null +++ b/src/daemon/workstreams/gate.ts @@ -0,0 +1,88 @@ +/** + * The workstream fence at the tool gate (docs/workstreams-design.md §7.3): + * a file tool reading or searching another workstream's folder, or writing + * one or a reference (read-only), is refused — in every mode, on every + * backend, sandboxed or not. The sandbox fences commands (its hidden paths); + * this fences the file tools that run outside it and every tool of a session + * without one. + * + * Pure: the fence comes from the session (WorkstreamService.fenceFor). + */ + +import { realpathSync } from "node:fs"; +import { writeTargets } from "../held-writes.js"; +import { isSearchTool, toolPathForms, toolReadPaths } from "../secret-paths.js"; +import { within } from "./folders.js"; +import type { WorkstreamFence } from "./service.js"; + +export interface FenceRefusal { + /** The path as the call named it. */ + path: string; + /** The workstream whose folder it is. */ + workstream: string; + kind: "read" | "search" | "write" | "write_reference"; +} + +function realOr(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + +/** Each fenced folder in both spellings: as found and real. */ +function forms(folders: readonly string[]): string[] { + return [...new Set(folders.flatMap((f) => [f, realOr(f)]))]; +} + +/** + * The fence's verdict on one call: null when it stays out of other + * workstreams' folders (and writes no reference), else why not. + */ +export function fenceRefusal(toolName: string, input: unknown, opts: { workdir: string; cwd?: string | null; fence: WorkstreamFence }): FenceRefusal | null { + const base = typeof opts.cwd === "string" ? opts.cwd : opts.workdir; + const foreign = opts.fence.foreign.map((f) => ({ workstream: f.workstream, folders: forms(f.folders) })); + const references = opts.fence.references.map((r) => ({ workstream: r.workstream, folders: forms(r.folders) })); + const home = forms(opts.fence.home); + const inForeign = (p: string) => foreign.find((f) => f.folders.some((d) => within(p, d))); + // Writes first: a write tool's path is also a read key, and the refusal should say it writes. + const writes = input && typeof input === "object" ? writeTargets(toolName, input as Record) : null; + for (const raw of writes ?? []) { + if (!raw) continue; + for (const p of toolPathForms(raw, base)) { + const hit = inForeign(p); + if (hit) return { path: raw, workstream: hit.workstream, kind: "write" }; + // A reference is read-only; a folder of the home's inside one is the home's. + const ref = references.find((r) => r.folders.some((d) => within(p, d))); + if (ref && !home.some((h) => within(p, h))) return { path: raw, workstream: ref.workstream, kind: "write_reference" }; + } + } + const search = isSearchTool(toolName); + for (const raw of toolReadPaths(toolName, input)) { + for (const p of toolPathForms(raw, base)) { + const hit = inForeign(p); + if (hit) return { path: raw, workstream: hit.workstream, kind: "read" }; + // A search whose root holds a fenced folder reads into it. + if (search) { + const holds = foreign.find((f) => f.folders.some((d) => within(d, p))); + if (holds) return { path: raw, workstream: holds.workstream, kind: "search" }; + } + } + } + return null; +} + +/** What the agent is told. */ +export function fenceRefusalText(r: FenceRefusal): string { + switch (r.kind) { + case "read": + return `${r.path} is in workstream "${r.workstream}"'s folders, which this session's workstream may not read. Ask the owner to add a reference to "${r.workstream}" if it should.`; + case "search": + return `Searching ${r.path} would read workstream "${r.workstream}"'s folders, which this session's workstream may not read: search a narrower folder.`; + case "write": + return `${r.path} is in workstream "${r.workstream}"'s folders: this session's workstream may not write there.`; + case "write_reference": + return `${r.path} is in workstream "${r.workstream}", which this session references read-only: write in the session's own workstream instead.`; + } +} diff --git a/src/daemon/workstreams/workstreams.test.ts b/src/daemon/workstreams/workstreams.test.ts index f0175c89..5180c674 100644 --- a/src/daemon/workstreams/workstreams.test.ts +++ b/src/daemon/workstreams/workstreams.test.ts @@ -10,6 +10,7 @@ import { workstreamsSchema } from "./schema.js"; import { WorkstreamService } from "./service.js"; import { proposeWorkstreams } from "./import.js"; import { expandPathRule } from "./folders.js"; +import { fenceRefusal } from "./gate.js"; import { EnvFileSecretStore } from "../credentials/store.js"; const ws = (id: string, over: Partial = {}): MatchableWorkstream => ({ @@ -625,10 +626,52 @@ describe("W3: folders, the fence, share verdicts", () => { }); test("a binding's references and read-from record round-trip; a malformed one fails the binding", () => { - const good = { id: "a", source: "matched", revision: "r", references: [{ workstream: "b", paths: ["/x"], grantedBy: "u", grantedAt: "t" }], readFrom: ["b", "b"] }; + const good = { id: "a", source: "matched" as const, revision: "r", references: [{ workstream: "b", paths: ["/x"], grantedBy: "u", grantedAt: "t" }], readFrom: ["b", "b"] }; expect(readWorkstreamBinding(good)).toEqual({ ...good, readFrom: ["b"] }); expect(readWorkstreamBinding({ ...good, references: [{ workstream: "B!", grantedBy: "u", grantedAt: "t" }] })).toBeUndefined(); expect(readWorkstreamBinding({ ...good, references: [{ workstream: "b", paths: ["rel"], grantedBy: "u", grantedAt: "t" }] })).toBeUndefined(); expect(readWorkstreamBinding({ ...good, readFrom: ["ok", 3] })).toBeUndefined(); }); }); + +describe("W3: the fence at the gate", () => { + const fence = () => ({ + workstream: "mine", + home: [join(tmp, "mine")], + references: [{ workstream: "pub", folders: [join(tmp, "pub")] }], + foreign: [{ workstream: "hf", folders: [join(tmp, "hf")] }, { workstream: "nested", folders: [join(tmp, "mine", "vendor-x")] }], + limits: [], + }); + beforeEach(() => { + for (const d of ["mine/vendor-x", "pub", "hf", "elsewhere"]) mkdirSync(join(tmp, d), { recursive: true }); + }); + const judge = (tool: string, input: Record, cwd?: string) => fenceRefusal(tool, input, { workdir: join(tmp, "mine"), ...(cwd ? { cwd } : {}), fence: fence() }); + + test("reads and writes in another workstream's folders are refused; the home and elsewhere aren't", () => { + expect(judge("Read", { file_path: join(tmp, "hf", "secret.ts") })).toMatchObject({ workstream: "hf", kind: "read" }); + expect(judge("Read", { file_path: join(tmp, "mine", "a.ts") })).toBeNull(); + expect(judge("Read", { file_path: "/etc/hosts" })).toBeNull(); + expect(judge("Read", { file_path: "vendor-x/x.ts" })).toMatchObject({ workstream: "nested" }); + expect(judge("Read", { file_path: "../hf/x" })).toMatchObject({ workstream: "hf" }); + expect(judge("Write", { file_path: join(tmp, "hf", "x") })).toMatchObject({ kind: "write" }); + expect(judge("write_file", { file_path: join(tmp, "hf", "x") })).toMatchObject({ kind: "write" }); + }); + + test("a reference is read, never written", () => { + expect(judge("Read", { file_path: join(tmp, "pub", "x") })).toBeNull(); + expect(judge("Edit", { file_path: join(tmp, "pub", "x") })).toMatchObject({ workstream: "pub", kind: "write_reference" }); + }); + + test("a search whose root holds a fenced folder reads into it", () => { + expect(judge("Grep", { path: tmp, pattern: "x" })).toMatchObject({ workstream: "hf", kind: "search" }); + expect(judge("Grep", { pattern: "x" })).toMatchObject({ workstream: "nested", kind: "search" }); + expect(judge("Grep", { path: join(tmp, "pub"), pattern: "x" })).toBeNull(); + expect(judge("Glob", { pattern: `${join(tmp, "hf")}/**/*.ts` })).toMatchObject({ workstream: "hf" }); + }); + + test("a link into a fenced folder is judged where it leads", () => { + symlinkSync(join(tmp, "hf"), join(tmp, "mine", "link")); + expect(judge("Read", { file_path: join(tmp, "mine", "link", "x") })).toMatchObject({ workstream: "hf" }); + expect(judge("Read", { file_path: "x" }, join(tmp, "hf"))).toMatchObject({ workstream: "hf" }); + }); +}); From f386f071c4d528f3cb0ee16bdf3f7c71cedc9dd8 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 15:00:26 +0800 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20workstreams=20W3=20=E2=80=94=20ep?= =?UTF-8?q?isodes=20record=20their=20workstream;=20recall,=20timeline=20an?= =?UTF-8?q?d=20get=5Fepisode=20keep=20to=20the=20home=20and=20its=20refere?= =?UTF-8?q?nces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/daemon/memory/engine.ts | 38 ++++++++++++++++++++++++------- src/daemon/memory/store.ts | 30 +++++++++++++++++++++--- src/daemon/memory/tools.test.ts | 35 ++++++++++++++++++++++++++++ src/daemon/memory/tools.ts | 20 +++++++++++++--- src/daemon/memory/types.ts | 8 +++++++ src/daemon/session.ts | 17 +++++++++++++- src/daemon/workstreams/service.ts | 3 ++- 7 files changed, 135 insertions(+), 16 deletions(-) diff --git a/src/daemon/memory/engine.ts b/src/daemon/memory/engine.ts index 9853b868..11142070 100644 --- a/src/daemon/memory/engine.ts +++ b/src/daemon/memory/engine.ts @@ -13,7 +13,7 @@ import type { Embedder } from "./embedder.js"; import { normalize } from "./embedder.js"; -import type { SqliteEpisodeStore, WorkspaceTenant } from "./store.js"; +import { type SqliteEpisodeStore, type WorkspaceTenant, workstreamReadable } from "./store.js"; import { DEFAULT_WEIGHTS, rank, type RankerWeights } from "./ranker.js"; import type { Reranker } from "./reranker.js"; import { embedWindows, indexView } from "./lossless.js"; @@ -212,8 +212,8 @@ export class MemoryEngine { } /** The verbatim body of an episode in `workspaceId` — not just its index view. */ - getFullContentScoped(id: string, workspaceId: string): string | null { - if (!this.#store.getEpisodeInWorkspace(id, workspaceId)) return null; + getFullContentScoped(id: string, workspaceId: string, workstreams?: readonly string[]): string | null { + if (!this.getEpisodeScoped(id, workspaceId, workstreams)) return null; return this.#store.getFullContent(id); } @@ -228,6 +228,26 @@ export class MemoryEngine { } } + /** Each session's workstream filter (workstreams §6.4), read at every call by the memory tools. */ + #workstreamScopes = new Map readonly string[] | undefined>(); + + /** + * Scope a session's memory tools to the workstreams it may read (its home + * and references), read live; returns the unregister. Every transport's + * tools carry the session id, so this one seam covers them all. + */ + scopeSession(sessionId: string, workstreams: () => readonly string[] | undefined): () => void { + this.#workstreamScopes.set(sessionId, workstreams); + return () => { + if (this.#workstreamScopes.get(sessionId) === workstreams) this.#workstreamScopes.delete(sessionId); + }; + } + + /** The workstreams a session's memory tools may read; undefined = no filter. */ + workstreamsOf(sessionId: string): readonly string[] | undefined { + return this.#workstreamScopes.get(sessionId)?.(); + } + /** Hybrid retrieval. */ async recall(q: RecallQuery): Promise { const limit = q.limit ?? 8; @@ -348,14 +368,16 @@ export class MemoryEngine { } /** Tenant-scoped fetch by id — the only variant safe to expose to a model. */ - getEpisodeScoped(id: string, workspaceId: string): Episode | null { - return this.#store.getEpisodeInWorkspace(id, workspaceId); + getEpisodeScoped(id: string, workspaceId: string, workstreams?: readonly string[]): Episode | null { + const ep = this.#store.getEpisodeInWorkspace(id, workspaceId); + return ep && workstreamReadable(ep.workstreamId, workstreams) ? ep : null; } /** List recent episodes (for the warm-tier index / timeline UI). `offset` - * enables ordered pagination over the full workspace history. */ - timeline(workspaceId: string, limit = 40, offset = 0): Episode[] { - return this.#store.listRecent(workspaceId, limit, offset); + * enables ordered pagination over the full workspace history; `workstreams` + * keeps it to what they may read (RecallQuery.workstreams). */ + timeline(workspaceId: string, limit = 40, offset = 0, workstreams?: readonly string[]): Episode[] { + return this.#store.listRecent(workspaceId, limit, offset, workstreams); } /** diff --git a/src/daemon/memory/store.ts b/src/daemon/memory/store.ts index e302ae26..7b660cb8 100644 --- a/src/daemon/memory/store.ts +++ b/src/daemon/memory/store.ts @@ -59,6 +59,12 @@ interface EpisodeRow { embedding_model: string | null; created_at: number; created_by: string; + workstream_id?: string | null; +} + +/** Whether an episode's workstream is one `allowed` lets the caller read (RecallQuery.workstreams). */ +export function workstreamReadable(episodeWorkstream: string | undefined, allowed: readonly string[] | undefined): boolean { + return allowed === undefined || episodeWorkstream === undefined || allowed.includes(episodeWorkstream); } /** The tenant a workspace id is scoped to. Every episode query filters by @@ -434,6 +440,11 @@ export class SqliteEpisodeStore { this.#db.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('turn_usage') WHERE name = 'cost_source'").get() as { n: number } | undefined )?.n ?? 0; if (!hasCostSource) this.#db.exec("ALTER TABLE turn_usage ADD COLUMN cost_source TEXT"); + // The writing session's home workstream (workstreams §6.4); NULL on episodes from before it. + const hasWorkstream = ( + this.#db.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('episodes') WHERE name = 'workstream_id'").get() as { n: number } | undefined + )?.n ?? 0; + if (!hasWorkstream) this.#db.exec("ALTER TABLE episodes ADD COLUMN workstream_id TEXT"); } // ── Writes ──────────────────────────────────────────────────────────── @@ -453,8 +464,8 @@ export class SqliteEpisodeStore { .prepare( `INSERT INTO episodes ( id, workspace_id, session_id, kind, tool_name, summary, content, - file_paths, token_estimate, embedding, embedding_model, created_at, created_by - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + file_paths, token_estimate, embedding, embedding_model, created_at, created_by, workstream_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( id, @@ -470,6 +481,7 @@ export class SqliteEpisodeStore { episode.embeddingModel ?? null, episode.createdAt, episode.createdBy, + episode.workstreamId ?? null, ); if (episode.fullContent !== undefined && episode.fullContent !== episode.content) { @@ -1008,7 +1020,17 @@ export class SqliteEpisodeStore { return row ? this.#rowToEpisode(row) : null; } - listRecent(workspaceId: string, limit: number, offset = 0): Episode[] { + listRecent(workspaceId: string, limit: number, offset = 0, workstreams?: readonly string[]): Episode[] { + if (workstreams !== undefined) { + // Only what the caller's workstreams may read (RecallQuery.workstreams), paged over that. + const marks = workstreams.map(() => "?").join(", "); + const rows = this.#db + .prepare( + `SELECT * FROM episodes WHERE workspace_id = ? AND (workstream_id IS NULL${workstreams.length > 0 ? ` OR workstream_id IN (${marks})` : ""}) ORDER BY created_at DESC LIMIT ? OFFSET ?`, + ) + .all(workspaceId, ...workstreams, limit, offset) as EpisodeRow[]; + return rows.map((r) => this.#rowToEpisode(r)); + } const rows = this.#db .prepare( "SELECT * FROM episodes WHERE workspace_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?", @@ -1580,6 +1602,7 @@ export class SqliteEpisodeStore { embeddingModel: row.embedding_model ?? undefined, createdAt: row.created_at, createdBy: row.created_by, + ...(row.workstream_id ? { workstreamId: row.workstream_id } : {}), }; } } @@ -1624,6 +1647,7 @@ function sanitizeFtsQuery(q: string): string { } function matchesQuery(ep: Episode, q: RecallQuery): boolean { + if (!workstreamReadable(ep.workstreamId, q.workstreams)) return false; if (q.sessionId && ep.sessionId !== q.sessionId) return false; if (q.excludeSessionId && ep.sessionId === q.excludeSessionId) return false; if (q.toolName && ep.toolName !== q.toolName) return false; diff --git a/src/daemon/memory/tools.test.ts b/src/daemon/memory/tools.test.ts index f707d77b..da00f650 100644 --- a/src/daemon/memory/tools.test.ts +++ b/src/daemon/memory/tools.test.ts @@ -135,3 +135,38 @@ describe("memory tool registry", () => { expect(typeof r).toBe("string"); }); }); + +describe("workstream scoping (workstreams §6.4)", () => { + async function scoped() { + const store = new SqliteEpisodeStore(":memory:"); + const engine = new MemoryEngine({ store, embedder: new FakeEmbedder() }); + await engine.init(); + const ids = { + acme: engine.ingest(ep("wsA", "s1", "acme unicorn", "unicorn acme deploy", { workstreamId: "acme" })).id, + hf: engine.ingest(ep("wsA", "s2", "hf unicorn", "unicorn hf deploy", { workstreamId: "hf" })).id, + legacy: engine.ingest(ep("wsA", "s3", "old unicorn", "unicorn old deploy")).id, + }; + await engine.drain(); + return { engine, ids }; + } + + test("episodes keep their workstream; a scoped session reads its home's and the untagged, never another's", async () => { + const { engine, ids } = await scoped(); + expect(engine.getEpisodeScoped(ids.acme, "wsA")?.workstreamId).toBe("acme"); + const off = engine.scopeSession("me", () => ["acme"]); + const ctx: MemoryToolContext = { engine, workspaceId: "wsA", sessionId: "me" }; + const recall = await defs().recall!.run({ query: "unicorn deploy", limit: 10 }, ctx); + expect(recall).toContain("acme unicorn"); + expect(recall).toContain("old unicorn"); + expect(recall).not.toContain("hf unicorn"); + const timeline = await defs().timeline!.run({ limit: 10 }, ctx); + expect(timeline).not.toContain("hf unicorn"); + expect(timeline).toContain("acme unicorn"); + expect(await defs().get_episode!.run({ episode_id: ids.hf }, ctx)).toContain("not found"); + // A reference widens it; unscoped (isolation off), everything as before. + off(); + engine.scopeSession("me", () => ["acme", "hf"]); + expect(await defs().recall!.run({ query: "unicorn deploy", limit: 10 }, ctx)).toContain("hf unicorn"); + expect(await defs().recall!.run({ query: "unicorn deploy", limit: 10 }, { ...ctx, sessionId: "other" })).toContain("hf unicorn"); + }); +}); diff --git a/src/daemon/memory/tools.ts b/src/daemon/memory/tools.ts index 1b9c7648..2f014467 100644 --- a/src/daemon/memory/tools.ts +++ b/src/daemon/memory/tools.ts @@ -25,6 +25,11 @@ export interface MemoryToolContext { workspaceId: string; /** Excluded from recall by default so the caller doesn't recall its own turns. */ sessionId: string; + /** + * The workstreams the calling session may read (workstreams §6.4: its home + * and references), read at each call; undefined = no filter (isolation off). + */ + workstreams?: () => readonly string[] | undefined; } export interface MemoryToolDef { @@ -58,6 +63,12 @@ const clampInt = (v: unknown, min: number, max: number, dflt: number): number => return Math.min(max, Math.max(min, Math.floor(n))); }; +/** The caller's workstream filter for a query (MemoryToolContext.workstreams). */ +function readable(ctx: MemoryToolContext): { workstreams?: readonly string[] } { + const w = ctx.workstreams ? ctx.workstreams() : ctx.engine.workstreamsOf(ctx.sessionId); + return w === undefined ? {} : { workstreams: w }; +} + /** The four defs. A function (not a const) so each call gets fresh zod objects. */ export function memoryToolDefs(): MemoryToolDef[] { const recall: MemoryToolDef = { @@ -92,6 +103,7 @@ export function memoryToolDefs(): MemoryToolDef[] { limit: clampInt(args.limit, 1, 20, 6), toolName: str(args.tool_name), excludeSessionId: includeCurrent ? undefined : ctx.sessionId, + ...readable(ctx), }); return formatHits(hits, ctx.sessionId, includeCurrent); }, @@ -117,6 +129,7 @@ export function memoryToolDefs(): MemoryToolDef[] { workspaceId: ctx.workspaceId, filePaths: [path], limit: 3, + ...readable(ctx), }); if (hits.length === 0) return `No prior reads of ${path} in this workspace.`; return `Found ${hits.length} prior read(s) of ${path}:\n\n${formatHits(hits, ctx.sessionId, true)}`; @@ -143,7 +156,7 @@ export function memoryToolDefs(): MemoryToolDef[] { async run(args, ctx) { const limit = clampInt(args.limit, 1, 60, 20); const offset = clampInt(args.offset, 0, Number.MAX_SAFE_INTEGER, 0); - const episodes = ctx.engine.timeline(ctx.workspaceId, limit, offset); + const episodes = ctx.engine.timeline(ctx.workspaceId, limit, offset, readable(ctx).workstreams); return formatTimeline(episodes, ctx.sessionId, offset); }, }; @@ -185,9 +198,10 @@ export function memoryToolDefs(): MemoryToolDef[] { }, async run(args, ctx) { const id = String(args.episode_id ?? ""); - const ep = ctx.engine.getEpisodeScoped(id, ctx.workspaceId); + const { workstreams } = readable(ctx); + const ep = ctx.engine.getEpisodeScoped(id, ctx.workspaceId, workstreams); if (!ep) return `Episode ${id} not found in this workspace.`; - const full = ctx.engine.getFullContentScoped(id, ctx.workspaceId) ?? ep.content; + const full = ctx.engine.getFullContentScoped(id, ctx.workspaceId, workstreams) ?? ep.content; const offset = clampInt(args.offset, 0, Number.MAX_SAFE_INTEGER, 0); const maxChars = clampInt(args.max_chars, 1000, GET_EPISODE_MAX_PAGE, GET_EPISODE_DEFAULT_PAGE); return formatEpisode({ ...ep, content: full }, ctx.sessionId, { offset, maxChars }); diff --git a/src/daemon/memory/types.ts b/src/daemon/memory/types.ts index f2ea65f0..b88b497e 100644 --- a/src/daemon/memory/types.ts +++ b/src/daemon/memory/types.ts @@ -34,6 +34,8 @@ export interface Episode { embeddingModel?: string; createdAt: number; createdBy: string; + /** The home workstream of the session that wrote it (workstreams §6.4); absent before workstreams, or with none. */ + workstreamId?: string; } /** Result of a recall query. */ @@ -67,6 +69,12 @@ export interface RecallQuery { before?: number; /** Exclude episodes older than this (unix ms). */ after?: number; + /** + * The workstreams the caller may read (workstreams §6.4: its home and its + * references). Set: an episode another workstream wrote is left out; + * one with none (from before workstreams) stays. Absent: no filter. + */ + workstreams?: readonly string[]; } /** File read cache entry — enables silent reuse across sessions in a workspace. */ diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 0633e66f..57cc43ab 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -1426,6 +1426,16 @@ export class Session { * references' folders shown read-only (the project roots), every other * workstream's hidden. */ + /** Lets go of the session's recall scope (MemoryEngine.scopeSession), at destroy. */ + #unscopeMemory: (() => void) | undefined; + + /** The workstreams this session's recall may read: its home and references; undefined without a fence. */ + #memoryWorkstreams(): readonly string[] | undefined { + const fence = this.#workstreamFence?.(); + if (!fence) return undefined; + return [...new Set([...(fence.workstream ? [fence.workstream] : []), ...fence.references.map((r) => r.workstream)])]; + } + #sandboxFence(): { roots: string[]; hidden: string[] } | undefined { const fence = this.#workstreamFence?.(); if (!fence) return undefined; @@ -2017,6 +2027,8 @@ export class Session { if (this.#memory) { const memory = this.#memory; + // Recall kept to the home and its references under isolation (workstreams §6.4). + if (this.#workstreamFence) this.#unscopeMemory = memory.scopeSession(this.id, () => this.#memoryWorkstreams()); this.#indexScheduler = new IndexScheduler({ store: memory.store, workspaceId: this.#workspaceId, @@ -2036,7 +2048,9 @@ export class Session { // Retry a registration that failed at construction; until it // succeeds the workspace is invisible to cross-workspace search. if (!this.#workspaceRegistered) this.#registerWorkspace(); - memory.ingest(episode); + // Tagged with the home as it is now (workstreams §6.4): a session moved since writes into its new one. + const home = this.#workstream?.id; + memory.ingest(home ? { ...episode, workstreamId: home } : episode); scheduler.onEpisode(); } catch (err) { console.error( @@ -6522,6 +6536,7 @@ export class Session { // the commit look's git is bounded, and a destroy waits no longer. await Promise.race([this.#checkGitControl(sender.sub), new Promise((r) => setTimeout(r, DESTROY_COMMIT_CHECK_MS).unref?.())]); this.#destroyed = true; + this.#unscopeMemory?.(); // This session's own servers' clients in the hub: let go (the last user closes them). for (const spec of [...this.#mcpRetained.values(), ...this.#mcpToRelease.values()]) this.#mcpHub?.release(spec, this.#mcpTenant()); this.#mcpRetained.clear(); diff --git a/src/daemon/workstreams/service.ts b/src/daemon/workstreams/service.ts index f6c76e46..935c359f 100644 --- a/src/daemon/workstreams/service.ts +++ b/src/daemon/workstreams/service.ts @@ -217,7 +217,8 @@ export class WorkstreamService { if (!ws || ws.abstract || ws.share === "closed" || ref.workstream === homeId) continue; const all = of(ref.workstream); const folders = ref.paths ? ref.paths.filter((p) => all.some((f) => within(p, f))) : all; - if (folders.length > 0) references.push({ workstream: ref.workstream, folders }); + // Kept with no folders here too: it still gives its memory (§7.2). + references.push({ workstream: ref.workstream, folders }); } const readable = [...home, ...references.flatMap((r) => r.folders)]; const foreign: WorkstreamFence["foreign"] = []; From 6bf5d82492e952b6bc9032a96b50c52811112638 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 15:04:10 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20workstreams=20W3=20=E2=80=94=20re?= =?UTF-8?q?ferences=20at=20create=20and=20mid-session=20by=20share=20polic?= =?UTF-8?q?y,=20moving=20a=20session=20to=20another=20workstream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- packages/protocol/src/schemas.test.ts | 3 + packages/protocol/src/schemas.ts | 35 ++++ packages/protocol/src/types.ts | 49 ++++++ packages/protocol/src/workstreams.ts | 4 + src/daemon/session-manager.ts | 179 ++++++++++++++++++++- src/daemon/session.ts | 16 ++ src/daemon/workstreams/service.ts | 7 +- src/tests/sandbox-workstream-fence.test.ts | 54 +++++++ src/tests/workstreams-references.test.ts | 160 ++++++++++++++++++ 9 files changed, 504 insertions(+), 3 deletions(-) create mode 100644 src/tests/sandbox-workstream-fence.test.ts create mode 100644 src/tests/workstreams-references.test.ts diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts index 841efe9b..90d34e59 100644 --- a/packages/protocol/src/schemas.test.ts +++ b/packages/protocol/src/schemas.test.ts @@ -96,6 +96,9 @@ const samples: { [T in ClientTypes]: Extract } = { "session.set_mode": { type: "session.set_mode", id: "r10", sessionId: "s1", mode: "autonomous", maxTurns: 5 }, "session.set_sandbox": { type: "session.set_sandbox", id: "r10s", sessionId: "s1", sandbox: { provider: "bwrap", network: "allowlist", allowedHosts: ["github.com"] } }, "session.sandbox_dir": { type: "session.sandbox_dir", id: "r10d", sessionId: "s1", action: "add", path: "/home/u/Workspace/b" }, + "session.add_reference": { type: "session.add_reference", id: "r10e", sessionId: "s1", workstream: "acme", paths: ["/w/acme-api"] }, + "session.remove_reference": { type: "session.remove_reference", id: "r10f", sessionId: "s1", workstream: "acme" }, + "session.set_workstream": { type: "session.set_workstream", id: "r10g", sessionId: "s1", workstream: "acme" }, "sandbox.providers": { type: "sandbox.providers", id: "r10p" }, "session.pin": { type: "session.pin", id: "r11", sessionId: "s1", path: "SPEC.md" }, "session.unpin": { type: "session.unpin", id: "r12", sessionId: "s1", path: "SPEC.md" }, diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 0469af12..1be987aa 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -32,6 +32,15 @@ const base = { id: idField }; /** A workstream id, bounded only: a malformed one must PARSE so the daemon answers `workstream_unknown`. */ const workstreamIdField = z.string().min(1).max(64); +/** A reference asked for (§7.2): absolute folders only, bounded. */ +export const workstreamReferenceInputSchema = z + .object({ + workstream: workstreamIdField, + paths: z.array(z.string().min(1).max(4096)).max(64).optional(), + }) + .strict(); + + // ── Collaboration ───────────────────────────────────────────────────────────── /** @@ -172,6 +181,7 @@ export const sessionCreateSchema = z.object({ */ workstream: workstreamIdField.optional(), override: z.object({ token: z.string().min(1).max(128) }).strict().optional(), + references: z.array(workstreamReferenceInputSchema).max(32).optional(), }); export const sessionListSchema = z.object({ ...base, type: z.literal("session.list") }); @@ -288,6 +298,28 @@ export const sessionSetSandboxSchema = z.object({ sandbox: sessionSandboxSettingSchema, }); +export const sessionAddReferenceSchema = z.object({ + ...base, + type: z.literal("session.add_reference"), + sessionId: sessionIdField, + workstream: workstreamIdField, + paths: z.array(z.string().min(1).max(4096)).max(64).optional(), +}); + +export const sessionRemoveReferenceSchema = z.object({ + ...base, + type: z.literal("session.remove_reference"), + sessionId: sessionIdField, + workstream: workstreamIdField, +}); + +export const sessionSetWorkstreamSchema = z.object({ + ...base, + type: z.literal("session.set_workstream"), + sessionId: sessionIdField, + workstream: workstreamIdField, +}); + export const sessionSandboxDirSchema = z.object({ ...base, type: z.literal("session.sandbox_dir"), @@ -1324,6 +1356,9 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ sessionSetModeSchema, sessionSetSandboxSchema, sessionSandboxDirSchema, + sessionAddReferenceSchema, + sessionRemoveReferenceSchema, + sessionSetWorkstreamSchema, sandboxProvidersSchema, sessionPinSchema, sessionUnpinSchema, diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 0f08e754..4ee9f92b 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -1330,6 +1330,9 @@ export type ClientMessage = | SessionSetModeMsg | SessionSetSandboxMsg | SessionSandboxDirMsg + | SessionAddReferenceMsg + | SessionRemoveReferenceMsg + | SessionSetWorkstreamMsg | SandboxProvidersMsg | SessionPinMsg | SessionUnpinMsg @@ -1620,6 +1623,13 @@ export interface SessionCreateMsg extends BaseClientMsg { * undelegated token. */ override?: { token: string }; + /** + * Other workstreams the session may read (docs/workstreams-design.md §7.2): + * their folders read-only and their memory. Each follows the share policy + * of the workstream read: `open` is granted, `ask` needs `session:approve` + * on an undelegated token (the creator's confirm), `closed` is refused. + */ + references?: WorkstreamReferenceInput[]; } /** @@ -1938,6 +1948,45 @@ export interface SessionSetSandboxMsg extends BaseClientMsg { * (`data: { pending: true }`), and lands on a yes. It takes effect at the * session's next turn with no background work running. */ +/** A reference asked for (§7.2): a workstream, and optionally folders inside it (absolute; absent = all of them). */ +export interface WorkstreamReferenceInput { + workstream: string; + paths?: string[]; +} + +/** + * Give a session read access to another workstream (§7.1, §7.2). Owner-only. + * `open`: granted; `ask`: granted at once when the owner asks with + * `session:approve` on an undelegated token, else the owner confirms on the + * session (`response.ok` with `pending`); `closed`: refused. + */ +export interface SessionAddReferenceMsg extends BaseClientMsg { + type: "session.add_reference"; + sessionId: string; + workstream: string; + paths?: string[]; +} + +/** Take a reference away (owner-only). The session still counts as having read from it (§14 q3). */ +export interface SessionRemoveReferenceMsg extends BaseClientMsg { + type: "session.remove_reference"; + sessionId: string; + workstream: string; +} + +/** + * Move a session to another home workstream (§5.3): a share of its whole + * transcript, so it follows the share policy of its old home and of every + * workstream it has read from (`ask` as for a reference; `closed` refuses). + * Past another workstream's exclusive claim on the folder it needs + * `session:approve` on an undelegated token. Applied at the next turn. + */ +export interface SessionSetWorkstreamMsg extends BaseClientMsg { + type: "session.set_workstream"; + sessionId: string; + workstream: string; +} + export interface SessionSandboxDirMsg extends BaseClientMsg { type: "session.sandbox_dir"; sessionId: string; diff --git a/packages/protocol/src/workstreams.ts b/packages/protocol/src/workstreams.ts index 3708a26f..3ef50eca 100644 --- a/packages/protocol/src/workstreams.ts +++ b/packages/protocol/src/workstreams.ts @@ -36,6 +36,10 @@ export interface SessionWorkstreamInfo { matchedBy?: string; /** The home was deleted from the config: the session needs a new one. */ orphaned?: boolean; + /** Other workstreams it may read (§7.2); `paths` absent = all their folders. */ + references?: Array<{ workstream: string; paths?: string[] }>; + /** Every workstream it has read from, for its lifetime (a move follows each one's policy). */ + readFrom?: string[]; } export interface WorkstreamServerInfo { diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 800281bc..02d0e8a4 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -9,7 +9,7 @@ */ import { addedDirProblem, type OtherCheckout, PROJECT_ROOT_PARENT, projectRootProblem } from "./sandbox/project-roots.js"; -import { WorkstreamService, type WorkstreamBinding, type WorkstreamFence } from "./workstreams/service.js"; +import { WorkstreamService, type WorkstreamBinding, type WorkstreamFence, type WorkstreamReference } from "./workstreams/service.js"; import { repositoryTopOf } from "./workstreams/folders.js"; import { grantRefusal, type McpGrant } from "./workstreams/grant.js"; import { OverrideTokens } from "./workstreams/override.js"; @@ -1548,6 +1548,10 @@ mcpHub: this.#mcpHub, return this.#loopWorkerGuard(msg, "change the sandbox of") ?? this.#setSandbox(msg, auth); case "session.sandbox_dir": return this.#loopWorkerGuard(msg, "change the sandbox of") ?? this.#sandboxDir(msg, auth); + case "session.add_reference": + case "session.remove_reference": + case "session.set_workstream": + return this.#loopWorkerGuard(msg, "change the workstream of") ?? this.#sessionWorkstreamEdit(msg, auth); case "sandbox.providers": return this.#sandboxProviders(msg, auth); case "session.pin": @@ -4842,7 +4846,33 @@ mcpHub: this.#mcpHub, } } for (const w of homing.warnings) warn(w); - const createWorkstream = homing.binding; + let createWorkstream = homing.binding; + // References asked at create (§7.2): each by the share policy of the + // workstream read. The creator asking with session:approve, undelegated, + // is the confirm an `ask` workstream wants; anyone else gets `open` only. + if (msg.references && msg.references.length > 0) { + if (!this.#workstreams.configured || !createWorkstream) { + return { type: "response.error", requestId: msg.id, error: "No workstreams are configured: a session can't reference one", code: "invalid_request" }; + } + const approves = hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE) && auth.delegationDepth === 0; + const recorded = this.#store.workstreamFoldersByWorkstream(); + const refs: WorkstreamReference[] = []; + for (const asked of msg.references) { + const paths = asked.paths?.map((p) => normalizeWorkdir(p) ?? p); + if (paths?.some((p) => !p.startsWith("/"))) { + return { type: "response.error", requestId: msg.id, error: "A reference's folders are absolute paths", code: "invalid_request" }; + } + const v = this.#workstreams.referenceVerdict(createWorkstream.id, { workstream: asked.workstream, ...(paths ? { paths } : {}) }, (recorded.get(asked.workstream) ?? []).map((w) => repositoryTopOf(w, homedir()))); + if (!v.ok) return { type: "response.error", requestId: msg.id, error: v.error, code: "forbidden" }; + if (v.ask.length > 0 && !approves) { + return { type: "response.error", requestId: msg.id, error: `Workstream "${asked.workstream}" shares only with approval: referencing it needs session:approve on an undelegated token`, code: "forbidden" }; + } + refs.push({ workstream: asked.workstream, ...(paths ? { paths } : {}), grantedBy: auth.sub, grantedAt: new Date().toISOString() }); + } + const unique = [...new Map(refs.map((r) => [r.workstream, r])).values()]; + createWorkstream = { ...createWorkstream, references: unique, readFrom: [...new Set([...(createWorkstream.readFrom ?? []), ...unique.map((r) => r.workstream)])] }; + this.#store.audit(auth.sub, "workstream.reference_added", "", `workdir=${workdir} references=${unique.map((r) => r.workstream).join(",")} at=create`); + } if (createWorkstream?.source === "override") { const c = homing.resolution?.conflict; this.#store.audit(auth.sub, "workstream.override", "", `workdir=${workdir} folder=${c?.folder ?? "?"} chosen=${createWorkstream.id} revision=${createWorkstream.revision}`); @@ -9975,6 +10005,145 @@ mcpHub: this.#mcpHub, return { type: "response.ok", requestId: msg.id, data: { ...(result.pending ? { pending: true } : {}), message: result.message, sandbox: session.sandboxInfo } }; } + /** + * `session.add_reference` / `remove_reference` / `set_workstream` + * (workstreams §5.3, §7): owner-only. A grant or a move follows the share + * policy of each workstream it reads from (WorkstreamService + * `referenceVerdict` / `moveVerdict`): `closed` refuses; `ask` is granted + * at once to the owner asking with session:approve on an undelegated token, + * else asked of the owner on the session. Applied at the next turn. + */ + async #sessionWorkstreamEdit( + msg: Extract, + auth: AuthContext, + ): Promise { + const err = (error: string, code: "forbidden" | "invalid_request" | "not_found" = "invalid_request"): DaemonMessage => ({ type: "response.error", requestId: msg.id, error, code }); + if (!hasScope(auth.scopes as string[], SCOPES.SESSION_SEND)) return err("Missing scope: session:send", "forbidden"); + const session = this.#getOwnedSession(msg.sessionId, auth); + if (!session) return err("Session not found", "not_found"); + if (session.createdBy !== auth.sub) return err("Only the session's owner can change what workstreams it reads", "forbidden"); + const binding = session.workstream; + if (!this.#workstreams.configured || !binding) return err("No workstreams are configured"); + if (binding.source === "reserved") return err("The conductor's workstream can't be changed"); + // The owner asking with approval rights, undelegated, is the confirm an `ask` policy wants. + const approves = hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE) && auth.delegationDepth === 0; + const reply = (data: Record): DaemonMessage => ({ type: "response.ok", requestId: msg.id, data: { ...data, workstream: this.#describeWorkstream(session.workstream) } }); + + if (msg.type === "session.remove_reference") { + const refs = binding.references ?? []; + if (!refs.some((r) => r.workstream === msg.workstream)) return err(`This session has no reference to "${msg.workstream}"`); + const rest = refs.filter((r) => r.workstream !== msg.workstream); + session.setWorkstream({ ...withoutKey(binding, "references"), ...(rest.length > 0 ? { references: rest } : {}) }, `No longer reads workstream "${msg.workstream}" (from the next turn)`); + this.#store.audit(auth.sub, "workstream.reference_removed", session.id, `workstream=${msg.workstream}`); + return reply({ message: `removed the reference to ${msg.workstream}` }); + } + + // What is asked, as it will be granted; null = refused (with why). + let decide: + | { ask: string[]; recheck: () => string | undefined; apply: (by: string) => { next: WorkstreamBinding; said: string; audit: [string, string] } } + | { error: string }; + if (msg.type === "session.add_reference") { + const paths = msg.paths?.map((p) => normalizeWorkdir(p) ?? p); + if (paths?.some((p) => !p.startsWith("/"))) return err("A reference's folders are absolute paths"); + const recorded = this.#store.workstreamFoldersByWorkstream().get(msg.workstream) ?? []; + const v = this.#workstreams.referenceVerdict(binding.id, { workstream: msg.workstream, ...(paths ? { paths } : {}) }, recorded.map((w) => repositoryTopOf(w, homedir()))); + const again = () => { + const w = this.#workstreams.referenceVerdict(session.workstream?.id, { workstream: msg.workstream, ...(paths ? { paths } : {}) }, recorded.map((r) => repositoryTopOf(r, homedir()))); + return w.ok ? undefined : w.error; + }; + decide = !v.ok + ? { error: v.error } + : { + ask: v.ask, + recheck: again, + apply: (by) => { + const cur = session.workstream ?? binding; + const ref = { workstream: msg.workstream, ...(paths ? { paths } : {}), grantedBy: by, grantedAt: new Date().toISOString() }; + const next: WorkstreamBinding = { + ...cur, + references: [...(cur.references ?? []).filter((r) => r.workstream !== msg.workstream), ref], + readFrom: [...new Set([...(cur.readFrom ?? []), msg.workstream])], + }; + return { next, said: `Reads workstream "${msg.workstream}"${paths ? ` (${paths.join(", ")})` : ""} from the next turn: its folders read-only, and its memory`, audit: ["workstream.reference_added", `workstream=${msg.workstream}${paths ? ` paths=${paths.join(",")}` : ""} by=${by}`] }; + }, + }; + } else { + if (msg.workstream === binding.id) return err(`The session is already in "${msg.workstream}"`); + const v = this.#workstreams.moveVerdict(binding, msg.workstream); + // Past another workstream's exclusive claim on the folder: an override (§5.2), never asked on the session. + const resolution = this.#workstreams.resolve(session.workdir, msg.workstream); + const conflict = resolution.conflict; + if (v.ok && conflict) { + if (!conflict.overridable) return err(`This folder belongs to workstream "${conflict.folder}", which allows no override`, "forbidden"); + if (!approves) return err(`This folder belongs to workstream "${conflict.folder}" (exclusive): moving the session past it needs session:approve on an undelegated token`, "forbidden"); + } + decide = !v.ok + ? { error: v.error } + : { + ask: v.ask, + recheck: () => { + const w = this.#workstreams.moveVerdict(session.workstream, msg.workstream); + return w.ok ? undefined : w.error; + }, + apply: (by) => { + const cur = session.workstream ?? binding; + const refs = (cur.references ?? []).filter((r) => r.workstream !== msg.workstream); + const next: WorkstreamBinding = { + id: msg.workstream, + source: conflict ? "override" : "explicit", + revision: this.#workstreams.revision, + ...(refs.length > 0 ? { references: refs } : {}), + ...(cur.readFrom?.length ? { readFrom: cur.readFrom } : {}), + }; + return { next, said: `Moved to workstream "${msg.workstream}"${cur.id ? ` from "${cur.id}"` : ""}: its servers, credentials and sandbox from the next turn`, audit: [conflict ? "workstream.override" : "workstream.moved", `from=${cur.id ?? "none"} to=${msg.workstream} by=${by}`] }; + }, + }; + } + if ("error" in decide) return err(decide.error, "forbidden"); + const applyNow = (by: string) => { + const { next, said, audit } = decide.apply(by); + session.setWorkstream(next, said); + this.#store.audit(by, audit[0], session.id, audit[1]); + }; + if (decide.ask.length === 0 || approves) { + applyNow(auth.sub); + return reply({ message: "done" }); + } + // An `ask` workstream, and the asker can't approve it: the owner confirms on the session. + const what = msg.type === "session.add_reference" ? `read workstream "${msg.workstream}"` : `move to workstream "${msg.workstream}"`; + this.#store.audit(auth.sub, "workstream.share_ask", session.id, `${msg.type} ${msg.workstream} ask=${decide.ask.join(",")}`); + let answeredBy: string | undefined; + void session + .requestUserInput( + { + method: "confirm", + title: `Let "${session.name}" ${what}?`, + message: + msg.type === "session.add_reference" + ? `"${msg.workstream}" shares only with approval. The session could read its folders and recall its memory; it gets none of its servers or credentials, and writes nothing there.` + : `${decide.ask.map((w) => `"${w}"`).join(" and ")} share${decide.ask.length === 1 ? "s" : ""} only with approval, and the session's whole conversation would go with it.`, + }, + { ownerOnly: true, onAnswer: (sub) => void (answeredBy = sub) }, + ) + .then( + (answer) => { + if (answer.cancelled || answer.confirmed !== true) { + this.#store.audit(answeredBy ?? auth.sub, "workstream.share_denied", session.id, `${msg.type} ${msg.workstream} ${answer.reason ?? "declined"}`); + return; + } + // Judged again as it is now: a policy tightened while the owner was asked wins. + const now = decide.recheck(); + if (now) { + this.#store.audit(answeredBy ?? auth.sub, "workstream.share_denied", session.id, `${msg.type} ${msg.workstream} ${now}`); + return; + } + applyNow(answeredBy ?? auth.sub); + }, + () => {}, + ); + return reply({ pending: true, message: `asked on the session: ${what}?` }); + } + /** `sandbox.providers` (#448): what the daemon has and whether each runs here. */ async #sandboxProviders(msg: Extract, auth: AuthContext): Promise { if (!hasScope(auth.scopes as string[], SCOPES.SESSION_CREATE)) { @@ -10433,3 +10602,9 @@ function daemonSandboxRoot(): string[] { const root = sandboxRootIfMade(); return root ? [root] : []; } + +/** `obj` without `key` (an optional field taken away). */ +function withoutKey(obj: T, key: K): Omit { + const { [key]: _, ...rest } = obj; + return rest; +} diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 57cc43ab..8ff29280 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -1094,6 +1094,22 @@ export class Session { return this.#workstream; } + /** + * Change the session's binding (workstreams W3: a reference granted or + * taken away, a move to another home, §5.3, §7). Authorized and judged by + * the manager. Saved at once; what it gives is read live, so the backend + * is rebuilt with it at the next turn (its servers, env, sandbox roots and + * recall), never mid-turn. Said on the session. + */ + setWorkstream(next: WorkstreamBinding, said: string): void { + this.#workstream = next; + this.#saveMetaNow(); + const info = this.#makeMessage("info", `🧭 ${said}`, SYSTEM_IDENTITY, undefined, undefined, { event: "workstream.changed", workstream: next.id ?? null }); + this.#persistAndBuffer(info); + this.#broadcastRaw(info); + this.#broadcastInfoUpdate(); + } + /** * Goal + role→backend bindings when this session was created with the * Collaborative toggle (set from opts / restored from meta). Readonly: the diff --git a/src/daemon/workstreams/service.ts b/src/daemon/workstreams/service.ts index 935c359f..b31bfa8f 100644 --- a/src/daemon/workstreams/service.ts +++ b/src/daemon/workstreams/service.ts @@ -561,7 +561,11 @@ export class WorkstreamService { */ info(binding: WorkstreamBinding | undefined): SessionWorkstreamInfo | undefined { if (!binding || !this.configured) return undefined; - if (binding.id === undefined) return { source: binding.source }; + const reads = { + ...(binding.references?.length ? { references: binding.references.map((r) => ({ workstream: r.workstream, ...(r.paths ? { paths: [...r.paths] } : {}) })) } : {}), + ...(binding.readFrom?.length ? { readFrom: [...binding.readFrom] } : {}), + }; + if (binding.id === undefined) return { source: binding.source, ...reads }; if (binding.id === CONDUCTOR_WORKSTREAM && binding.source === "reserved") { return { id: CONDUCTOR_WORKSTREAM, name: "Conductor", source: "reserved" }; } @@ -571,6 +575,7 @@ export class WorkstreamService { ...(ws ? { name: ws.name, ...(ws.color ? { color: ws.color } : {}) } : { orphaned: true }), source: binding.source, ...(binding.matchedBy ? { matchedBy: binding.matchedBy } : {}), + ...reads, }; } } diff --git a/src/tests/sandbox-workstream-fence.test.ts b/src/tests/sandbox-workstream-fence.test.ts new file mode 100644 index 00000000..6826155e --- /dev/null +++ b/src/tests/sandbox-workstream-fence.test.ts @@ -0,0 +1,54 @@ +/** + * The workstream fence in the sandbox spec (workstreams §7.3): another + * workstream's folder under a shown root is hidden, and a root inside one + * isn't shown, even when the session's own setting names it. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { buildSandboxSpec } from "../daemon/sandbox/policy.js"; + +let base: string; +beforeEach(() => { + const parent = process.env.CODEOID_TEST_SANDBOX_DIR ?? join(homedir(), ".cache"); + mkdirSync(parent, { recursive: true }); + base = realpathSync(mkdtempSync(join(parent, "codeoid-ws-fence-"))); +}); +afterEach(() => rmSync(base, { recursive: true, force: true })); + +describe("extraHidden", () => { + test("another workstream's folder under a shown root is hidden; a root inside one is dropped", () => { + const home = join(base, "home"); + const root = join(home, "Workspace"); + const mine = join(root, "mine"); + const theirs = join(root, "theirs"); + const inside = join(theirs, "pkg"); + for (const d of [mine, inside]) mkdirSync(d, { recursive: true }); + const tmp = join(base, "tmp"); + mkdirSync(tmp); + const spec = buildSandboxSpec({ + sessionId: "s", + workspace: mine, + tempDir: tmp, + network: { mode: "off" }, + hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, + projectRoots: [root, inside], + extraHidden: [theirs], + }); + expect(spec.hidden).toContain(theirs); + expect(spec.reexposed).toContain(root); + expect(spec.reexposed).not.toContain(inside); + // Without the fence, as before. + const open = buildSandboxSpec({ + sessionId: "s", + workspace: mine, + tempDir: tmp, + network: { mode: "off" }, + hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, + projectRoots: [root, inside], + }); + expect(open.hidden).not.toContain(theirs); + expect(open.reexposed).toContain(inside); + }); +}); diff --git a/src/tests/workstreams-references.test.ts b/src/tests/workstreams-references.test.ts new file mode 100644 index 00000000..fe39ddf2 --- /dev/null +++ b/src/tests/workstreams-references.test.ts @@ -0,0 +1,160 @@ +/** + * Workstreams W3 through the manager: references at create and mid-session + * by each workstream's share policy, moves (§5.3), and the read fence + * refusing a file tool in another workstream's folders at the gate (§7.3). + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "../config.js"; +import type { ProviderEvent } from "../daemon/providers/interface.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import { SessionManager } from "../daemon/session-manager.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { FolderReader } from "../daemon/workstreams/folder.js"; +import { ALL_SCOPES, SCOPES } from "../protocol/scopes.js"; +import type { AuthContext, DaemonMessage, SessionInfo } from "../protocol/types.js"; + +const DONE: ProviderEvent = { type: "turn_done", result: { providerId: "mock", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } }; +const OWNER: AuthContext = { sub: "user:ref", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc", projectId: "proj" }; +const SENDER: AuthContext = { ...OWNER, scopes: [SCOPES.SESSION_CREATE, SCOPES.SESSION_SEND, SCOPES.SESSION_LIST] as AuthContext["scopes"] }; +const client = { id: "c-ref", auth: OWNER, send: (_m: DaemonMessage) => {} }; + +let tmp: string; +let home: string; +let store: Store; +let transcript: TranscriptStore; +let savedXdg: string | undefined; +let manager: SessionManager; +let providers: MockSessionProvider[]; +let script: ProviderEvent[][]; + +beforeEach(() => { + tmp = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-ws-ref-"))); + home = join(tmp, "home"); + savedXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = join(tmp, "config"); + mkdirSync(join(tmp, "config", "codeoid"), { recursive: true }); + for (const d of ["mine", "acme-api", "acme-web", "hf", "pub"]) mkdirSync(join(home, d), { recursive: true }); + writeFileSync(join(home, "hf", "secret.ts"), "x"); + writeFileSync( + join(tmp, "config", "codeoid", "config.json"), + JSON.stringify({ + dbPath: join(tmp, "codeoid.db"), + transcriptDir: join(tmp, "transcripts"), + workstreams: { + isolateGlobalServers: true, + items: [ + { id: "mine", share: "open", match: { paths: ["~/mine"] } }, + { id: "acme", share: "ask", match: { paths: ["~/acme-*"] } }, + { id: "hf", share: "closed", claim: "exclusive", match: { paths: ["~/hf"] } }, + { id: "pub", share: "open", match: { paths: ["~/pub"] } }, + ], + }, + }), + ); + store = new Store(join(tmp, "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "transcripts")); + providers = []; + script = [[DONE]]; + manager = new SessionManager(store, transcript, undefined, undefined, undefined, { + config: loadConfig({ env: {}, quiet: true }), + _testProviderFactory: () => { + const p = new MockSessionProvider("mock", script); + providers.push(p); + return p; + }, + _testFolderReader: new FolderReader({ home, sshConfig: "" }), + }); +}); + +afterEach(async () => { + try { + await manager.drain(2_000); + } catch {} + try { + await transcript.flush(); + } catch {} + store.close(); + if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXdg; + rmSync(tmp, { recursive: true, force: true }); +}); + +const h = (msg: Record, auth: AuthContext = OWNER) => manager.handle(msg as never, auth, { ...client, auth }); +const create = async (name: string, dir: string, extra: Record = {}, auth: AuthContext = OWNER) => + (await h({ type: "session.create", id: `c-${name}`, name, workdir: join(home, dir), ...extra }, auth)) as DaemonMessage & { data?: SessionInfo }; + +describe("references at create", () => { + it("open is granted; ask needs the creator's approval rights; closed never", async () => { + const open = await create("a", "mine", { references: [{ workstream: "pub" }] }); + expect(open.type).toBe("response.ok"); + expect(open.data?.workstream).toMatchObject({ id: "mine", references: [{ workstream: "pub" }], readFrom: ["pub"] }); + const ask = await create("b", "mine", { references: [{ workstream: "acme" }] }, SENDER); + expect(ask).toMatchObject({ type: "response.error", code: "forbidden" }); + const asked = await create("c", "mine", { references: [{ workstream: "acme", paths: [join(home, "acme-api")] }] }); + expect(asked.data?.workstream?.references).toEqual([{ workstream: "acme", paths: [join(home, "acme-api")] }]); + expect(await create("d", "mine", { references: [{ workstream: "hf" }] })).toMatchObject({ type: "response.error", code: "forbidden" }); + expect(await create("e", "mine", { references: [{ workstream: "acme", paths: ["/etc"] }] })).toMatchObject({ type: "response.error" }); + }); +}); + +describe("references and moves mid-session", () => { + it("add and remove a reference; an `ask` one from a sender without approval rights is asked on the session", async () => { + const made = await create("s", "mine", {}, SENDER); + const id = made.data!.id; + const added = await h({ type: "session.add_reference", id: "r1", sessionId: id, workstream: "pub" }, SENDER); + expect(added).toMatchObject({ type: "response.ok", data: { workstream: { references: [{ workstream: "pub" }] } } }); + const pending = await h({ type: "session.add_reference", id: "r2", sessionId: id, workstream: "acme" }, SENDER); + expect(pending).toMatchObject({ type: "response.ok", data: { pending: true } }); + expect(manager.findByName("s", OWNER)?.workstream?.references?.map((r) => r.workstream)).toEqual(["pub"]); + expect(await h({ type: "session.add_reference", id: "r3", sessionId: id, workstream: "hf" }, SENDER)).toMatchObject({ code: "forbidden" }); + const removed = await h({ type: "session.remove_reference", id: "r4", sessionId: id, workstream: "pub" }, SENDER); + expect(removed).toMatchObject({ type: "response.ok" }); + // Gone, but still counted as read from (a later move follows its policy). + expect(manager.findByName("s", OWNER)?.workstream).toMatchObject({ id: "mine", readFrom: ["pub"] }); + expect(manager.findByName("s", OWNER)?.workstream?.references).toBeUndefined(); + }); + + it("only the owner", async () => { + const made = await create("o", "mine"); + const other = { ...OWNER, sub: "user:other" }; + expect(await h({ type: "session.add_reference", id: "x", sessionId: made.data!.id, workstream: "pub" }, other)).toMatchObject({ type: "response.error" }); + }); + + it("a move follows the old home's policy and every workstream read from", async () => { + const fromMine = await create("m1", "mine"); + expect(await h({ type: "session.set_workstream", id: "w1", sessionId: fromMine.data!.id, workstream: "pub" })).toMatchObject({ type: "response.ok" }); + expect(manager.findByName("m1", OWNER)?.workstream).toMatchObject({ id: "pub", source: "explicit" }); + // Read from a closed workstream once: that conversation never leaves. + const read = await create("m2", "mine", { references: [{ workstream: "pub" }] }); + const sid = read.data!.id; + const s = manager.findByName("m2", OWNER)!; + s.setWorkstream({ ...s.workstream!, readFrom: ["pub", "hf"] }, "test"); + expect(await h({ type: "session.set_workstream", id: "w2", sessionId: sid, workstream: "acme" })).toMatchObject({ code: "forbidden" }); + // From an `ask` home, a sender without approval rights is asked on the session. + const fromAcme = await create("m3", "acme-api", {}, SENDER); + expect(await h({ type: "session.set_workstream", id: "w3", sessionId: fromAcme.data!.id, workstream: "pub" }, SENDER)).toMatchObject({ data: { pending: true } }); + // Into a folder another workstream holds exclusively: an override, needing approval rights. + const inHf = await create("m4", "hf"); + expect(inHf.data?.workstream?.id).toBe("hf"); + expect(await h({ type: "session.set_workstream", id: "w4", sessionId: inHf.data!.id, workstream: "pub" })).toMatchObject({ code: "forbidden" }); + }); +}); + +describe("the read fence at the gate", () => { + it("a file tool in another workstream's folder is refused, naming it; a reference is readable", async () => { + script = [[{ type: "tool_start", toolId: "t1", sdkToolUseId: "sdk-t1", name: "Read", input: { file_path: join(home, "hf", "secret.ts") }, approvalId: "ap-1" } as ProviderEvent, DONE]]; + const made = await create("g", "mine"); + const session = manager.findByName("g", OWNER)!; + await session.send("read it", OWNER); + for (let i = 0; i < 200 && providers.at(-1)?.canUseToolResults.length === 0; i++) await Bun.sleep(10); + const result = providers.at(-1)!.canUseToolResults[0]; + expect(result).toMatchObject({ behavior: "deny" }); + expect(result?.message).toContain('workstream "hf"'); + expect(made.data?.workstream?.id).toBe("mine"); + }); +}); From 80de89899c3e70b964a4a62701ae293dbcc8a9f3 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 15:04:17 +0800 Subject: [PATCH 05/11] =?UTF-8?q?fix:=20lint=20=E2=80=94=20no=20assignment?= =?UTF-8?q?=20in=20an=20expression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/daemon/session-manager.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 02d0e8a4..b8650847 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -10123,7 +10123,12 @@ mcpHub: this.#mcpHub, ? `"${msg.workstream}" shares only with approval. The session could read its folders and recall its memory; it gets none of its servers or credentials, and writes nothing there.` : `${decide.ask.map((w) => `"${w}"`).join(" and ")} share${decide.ask.length === 1 ? "s" : ""} only with approval, and the session's whole conversation would go with it.`, }, - { ownerOnly: true, onAnswer: (sub) => void (answeredBy = sub) }, + { + ownerOnly: true, + onAnswer: (sub) => { + answeredBy = sub; + }, + }, ) .then( (answer) => { From 9677174966d5ebbe0fb492007c12ec32b6da1439 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 15:11:35 +0800 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20workstreams=20W3=20=E2=80=94=20CL?= =?UTF-8?q?I=20and=20web=20UI=20for=20references=20and=20moves;=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - codeoid new --ref, codeoid workstream ref add|rm, workstream move - the session header's chip opens what the session reads and a move; the new-session dialog offers what it may read (under isolation) - a fresh session's sandbox no longer says its directories changed Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 14 ++ docs/CONFIGURATION.md | 30 +++ docs/workstreams-design.md | 11 + e2e/specs/workstreams-w3.spec.ts | 103 ++++++++++ src/cli.ts | 68 ++++++ src/daemon/sandbox/session-sandbox.ts | 5 +- src/terminal/client.ts | 35 ++++ src/tests/sandbox-workstream-manager.test.ts | 137 +++++++++++++ web/src/components/CenterPane.tsx | 2 +- web/src/components/NewSessionModal.tsx | 39 +++- .../NewSessionModal.workstreams.test.tsx | 26 ++- web/src/components/WorkstreamChip.test.tsx | 37 +++- web/src/components/WorkstreamChip.tsx | 193 +++++++++++++++++- web/src/state/workstreams.ts | 24 ++- 14 files changed, 706 insertions(+), 18 deletions(-) create mode 100644 e2e/specs/workstreams-w3.spec.ts create mode 100644 src/tests/sandbox-workstream-manager.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index e981e412..833c7253 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -254,6 +254,8 @@ codeoid worktree trust [dir] [--off] [--yes] # trust the setup command of a rep codeoid workstream ls | show | match [dir] [--workstream ] | import [--root ] # named lines of work, what a folder resolves to, a proposal from disk (docs/workstreams-design.md) codeoid new --workstream [--override] # a session's home workstream; default: the folder's, else workstreams.default codeoid mcp signin --credential # a named OAuth slot: a second sign-in to one server (a workstream's own) +codeoid new --ref [:] # another workstream it may read (its folders read-only, its memory), by that one's share policy +codeoid workstream ref add|rm [:] | move # references and moves (§5.3, §7) ``` `/mcp/control` (off by default, `mcpControl.*`) lets external agents drive @@ -551,6 +553,18 @@ server's named credential is a REFERENCE on its transport, resolved by call against the live grant. Overrides: `OverrideTokens` (G3); the folder record is `workstream_folders`. OAuth slots: `mcp_oauth_credentials.credential` in the key, `withSlot`/`slotOf` in mcp/oauth.ts. +W3 (under isolation): the read fence `WorkstreamService.fenceFor` (home's and +references' folders, every other workstream's as `foreign`; folders from +`workstreams/folders.ts` and recorded homes), read live by Session through +`workstreamFence(binding)`: the sandbox's project roots and `extraHidden` +(`SessionSandbox` `workstreamDirs`), the gate's `workstreams/gate.ts` +`fenceRefusal` in every mode (and Claude's PreToolUse hook), and recall +(`MemoryEngine.scopeSession`; `episodes.workstream_id`). References +(`WorkstreamBinding.references`, `readFrom`) by the read workstream's `share` +(`referenceVerdict`), moves by `moveVerdict`; `session.add_reference` / +`remove_reference` / `set_workstream` in `#sessionWorkstreamEdit`, +`Session.setWorkstream`; the env, grant and fence closures take the binding, +so a change applies at the next turn. Routines (`src/daemon/routines/`, docs/routines.md) run sessions on a schedule or an API call. `RoutineService` owns the scheduler and runs; it reaches diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 12ab4f45..d6800b8f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -463,6 +463,36 @@ A workstream with `allowOverride: false` can't be overridden at all. A folder's workstream is remembered: if its repository later matches a different one (its `origin` was changed), the next session there must choose. +### Reading another workstream, and moving a session + +With `isolateGlobalServers: true` a session reads only its own workstream's folders and memory: +- its sandbox shows its home's folders read-only as its project roots (never the whole `@parent`), and hides every other workstream's folder under a root it shows; +- a file tool (Read, Grep, Glob, Write, Edit, on every backend, in every mode) in another workstream's folder is refused, naming that workstream; +- `recall`, `timeline` and `get_episode` leave out episodes another workstream wrote (episodes from before workstreams stay visible in their repository). + +A workstream's folders are the directories its path rules name on this machine (a glob such as `~/Workspace/acme-*` expands to the folders it matches, never to `~/Workspace`), plus the folders recorded as its home (a remote rule names no path). + +A **reference** lets a session read another workstream: its folders read-only (or only some), and its memory. Never its servers, credentials or accounts, and never a write. +The workstream being read decides, by its `share`: + +| `share` | A reference to it is… | +| --- | --- | +| `open` | granted. | +| `ask` | granted when its owner asks with `session:approve` on an undelegated token; asked of the owner on the session otherwise. | +| `closed` | refused. A reference granted before it was closed gives nothing. | + +```bash +codeoid new --ref pub --ref acme:~/Workspace/acme-api # at create +codeoid workstream ref add pub # later (the session header's chip too) +codeoid workstream ref rm pub +codeoid workstream move # another home +``` + +Moving a session takes its whole conversation with it, so it follows the `share` of its old home and of every workstream it has read from (for its lifetime, even after the reference is gone). Moving from the default workstream, or from no home, is always allowed; past another workstream's exclusive folder it is an override (needs `session:approve`). +Each change applies at the session's next turn: its backend is rebuilt with the new servers, credentials and sandbox. + +Commands in an unsandboxed session (`--sandbox off`) can still read other workstreams' folders; only its file tools are fenced. + ### Moving an existing setup `codeoid workstream import [--root ]` proposes workstreams from your repositories' remotes and lists Claude's global MCP servers to assign. It writes nothing; isolation stays off until you turn it on. diff --git a/docs/workstreams-design.md b/docs/workstreams-design.md index fb614904..8f2c0cba 100644 --- a/docs/workstreams-design.md +++ b/docs/workstreams-design.md @@ -494,6 +494,17 @@ codeoid new --workstream [--ref [:]]... codeoid workstream move ``` +### 12.1 W3 as built (2026-11-11) + +- **Enforcement switch.** Everything W3 enforces (the fence, recall scoping) is on only with `isolateGlobalServers`, like W1's servers. Without it, episodes are still tagged with `workstream_id` and references are recorded, but nothing is fenced. +- **What is fenced.** Another workstream's folders, not everything outside the home: a blanket deny would refuse toolchains and system headers, and the cross-workstream read is the risk. A workstream's folders are its path rules expanded on disk (`workstreams/folders.ts`: one segment at a time, links followed only where the rule names a segment literally, at most 512) plus its recorded homes' repository tops. A folder that holds the session's home, a reference or the workspace is never fenced (the more specific rule made it theirs). +- **Sandbox.** The fence's readable folders replace `@parent` as the project roots unless the session's own setting names roots; other workstreams' folders go into the spec's hidden paths (`SpecInputs.extraHidden`), and a root inside one is dropped. A change of fence is a change of directories, applied between turns (`SessionSandbox.dirsPending`). +- **Gate.** `workstreams/gate.ts` judges every tool call's read, search and write paths (secret-paths.ts and held-writes.ts extractors), in every mode; Claude's PreToolUse hook refuses the same calls, since the CLI skips `canUseTool` for some. +- **Memory.** `episodes.workstream_id`, tagged with the home at write time. Recall stays the repository's (`workspace_id`), filtered to the home and references; untagged episodes stay visible. Cross-repository recall over a whole workstream is deferred. +- **`ask` grants.** The owner asking with `session:approve`, undelegated, is the approval. Anyone else (a delegated or send-only token) is asked of the owner on the session, and the verdict is judged again when they answer. +- **Read-from record.** `readFrom` is recorded when a reference is granted (it may be read from then). +- **Deferred:** `Bash` commands naming another workstream's path in an unsandboxed session (§14 q4) are not yet asked about; fork *into* another workstream; skillDirs. + ## 13. Phases | Phase | Ships | Enforced by the end | diff --git a/e2e/specs/workstreams-w3.spec.ts b/e2e/specs/workstreams-w3.spec.ts new file mode 100644 index 00000000..709f0ca4 --- /dev/null +++ b/e2e/specs/workstreams-w3.spec.ts @@ -0,0 +1,103 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { expect, openUi, test } from "../harness"; + +const VIEWPORTS = [ + [375, 667], + [390, 844], + [768, 1024], + [1280, 800], + [1470, 830], + [1920, 1080], + [1280, 300], +] as const; +const SHOTS = join(process.cwd(), "test-results", "workstreams-shots"); + +function configureW3(codeoid: { root: string; workdir: string; configure(p: Record): void }): void { + for (const d of ["pub", "hf", "acme"]) mkdirSync(join(codeoid.root, d), { recursive: true }); + codeoid.configure({ + workstreams: { + isolateGlobalServers: true, + items: [ + { id: "mine", name: "Mine", share: "open", match: { paths: [codeoid.workdir] } }, + { id: "pub", name: "Pub", share: "open", match: { paths: [join(codeoid.root, "pub")] } }, + { id: "acme", name: "Acme", share: "ask", match: { paths: [join(codeoid.root, "acme")] } }, + { id: "hf", name: "HF", share: "closed", match: { paths: [join(codeoid.root, "hf")] } }, + ], + }, + }); +} + +// Workstreams W3 (§5.3, §7): references by each workstream's share policy, +// and moving a session, from the CLI. +test("references follow the share policy; a session moves; a closed workstream is never read", async ({ codeoid }) => { + configureW3(codeoid); + await codeoid.restart(); + + expect(await codeoid.cliOk("new", "r1", codeoid.workdir, "--ref", "pub")).toContain("workstream: Mine (mine)"); + const closed = await codeoid.cli("new", "r2", codeoid.workdir, "--ref", "hf"); + expect(closed.code).not.toBe(0); + expect(closed.output).toContain('"hf" is closed'); + // The owner's own token, undelegated: an `ask` workstream is granted at once. + const asked = await codeoid.cliOk("workstream", "ref", "add", "r1", "acme"); + expect(asked).toContain("reads: pub; acme"); + expect(await codeoid.cliOk("workstream", "ref", "rm", "r1", "pub")).toContain("reads: acme"); + // r1 read from acme (ask): the owner's token is the approval. + expect(await codeoid.cliOk("workstream", "move", "r1", "pub")).toContain("workstream: Pub (pub)"); + await codeoid.restart(); + expect(await codeoid.cliOk("ls")).toContain("workstream: Pub (pub)"); +}); + +// The same in the web UI: the dialog offers what the session may read, the +// header chip opens what it reads and where it lives. +test("the web UI: references in the new-session dialog, the chip's panel at every viewport", async ({ page, codeoid }) => { + mkdirSync(SHOTS, { recursive: true }); + configureW3(codeoid); + await codeoid.restart(); + await openUi(page, codeoid); + + await page.getByRole("button", { name: /new session/ }).click(); + await page.getByRole("textbox", { name: "Name", exact: true }).fill("reader"); + await page.getByRole("textbox", { name: /^Workdir/ }).fill(codeoid.workdir); + await expect(page.getByLabel("Workstream")).toContainText("The folder's: mine", { timeout: 10_000 }); + const reads = page.getByRole("group", { name: "Also reads" }); + await expect(reads).toContainText("Pub"); + await expect(reads).toContainText("Acme (asks)"); + await expect(reads).not.toContainText("HF"); + await reads.getByLabel(/^Pub/).check(); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + await reads.scrollIntoViewIfNeeded(); + const box = await reads.boundingBox(); + expect(box && box.x >= 0 && box.x + box.width <= w && box.height > 20).toBe(true); + await page.screenshot({ path: join(SHOTS, `reads-${w}x${h}.png`), fullPage: false }); + } + await page.setViewportSize({ width: 1280, height: 800 }); + await page.getByRole("button", { name: "create", exact: true }).click(); + const header = page.getByRole("heading", { name: "reader", level: 2 }).locator("xpath=.."); + const chip = header.getByTestId("workstream-chip"); + await expect(chip).toContainText("Mine +1"); + // A fresh session's sandbox is no change: nothing said about its directories. + await expect(page.getByText("Sandbox directories applied")).toHaveCount(0); + + await chip.click(); + const panel = page.getByRole("dialog", { name: "Workstream" }); + await expect(panel).toContainText("Pub"); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + // Re-opened at each size: it is placed from the chip's box when it opens. + await page.keyboard.press("Escape"); + await chip.click(); + const box = await panel.boundingBox(); + expect(box && box.x >= 0 && box.x + box.width <= w && box.width > 200 && box.height > 40).toBe(true); + const move = await panel.getByRole("button", { name: "Move", exact: true }).boundingBox(); + expect(move && move.x + move.width <= w).toBe(true); + await page.screenshot({ path: join(SHOTS, `chip-panel-${w}x${h}.png`), fullPage: false }); + } + await page.setViewportSize({ width: 1280, height: 800 }); + await panel.getByLabel("Add a workstream to read").selectOption("acme"); + await panel.getByRole("button", { name: "Add", exact: true }).click(); + await expect(chip).toContainText("Mine +2"); + await panel.getByRole("button", { name: "Remove" }).first().click(); + await expect(chip).toContainText("Mine +1"); +}); diff --git a/src/cli.ts b/src/cli.ts index 970702d7..d7b398cc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1090,6 +1090,12 @@ program "Home workstream (docs/workstreams-design.md): decides the session's servers, credentials and defaults. Default: the folder's (`codeoid workstream match `), else the configured default.", ) .option("--override", "with --workstream: confirm it past the folder's own exclusive workstream without asking (needs session:approve)") + .option( + "--ref ", + "Another workstream the session may read: its folders read-only (or only ) and its memory, repeatable. By its share policy: open, ask (needs session:approve), closed (refused).", + (v: string, p: string[] = []) => [...p, v], + [] as string[], + ) .option( "--pack ", "Activate an installed SDLC pack on the session (inject its constitution, expose its skills/subagents). With --collaborate, the collaboration ADOPTS the pack: role names bind strictly to the pack's declared roles.", @@ -1133,9 +1139,17 @@ program projectRoots?: boolean; workstream?: string; override?: boolean; + ref: string[]; }, ) => { const config = loadConfig(); + let references: Array<{ workstream: string; paths?: string[] }>; + try { + references = referenceFlags(opts.ref); + } catch (e) { + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + } if (opts.override && !opts.workstream) { console.error("--override confirms a --workstream choice; give one (codeoid workstream match shows the folder's)."); process.exit(1); @@ -1210,6 +1224,7 @@ program collaboration, ...(sandbox ? { sandbox } : {}), ...(opts.workstream ? { workstream: opts.workstream } : {}), + ...(references.length > 0 ? { references } : {}), confirmOverride: async (warning: string) => { console.error(warning); return confirmOnTerminal("Start it anyway? [y/N] ", opts.override, "Not started: pass --override to confirm without a terminal."); @@ -1219,6 +1234,22 @@ program }, ); +/** `--ref acme`, `--ref acme:~/Workspace/acme-api` → references, one per workstream (folders gathered). */ +export function referenceFlags(flags: readonly string[]): Array<{ workstream: string; paths?: string[] }> { + const out = new Map(); + for (const f of flags) { + const at = f.indexOf(":"); + const workstream = (at < 0 ? f : f.slice(0, at)).trim(); + const dir = at < 0 ? "" : f.slice(at + 1).trim(); + if (!workstream) throw new Error(`--ref ${f}: name a workstream (--ref [:])`); + const cur = out.get(workstream) ?? { workstream, whole: false }; + if (!dir) cur.whole = true; + else cur.paths = [...(cur.paths ?? []), dirFlag(dir)]; + out.set(workstream, cur); + } + return [...out.values()].map((r) => (r.whole || !r.paths ? { workstream: r.workstream } : { workstream: r.workstream, paths: r.paths })); +} + /** A directory as the daemon keeps it: `~` left to it (its home), a relative one this shell's. */ function dirFlag(d: string): string { return d === "~" || d.startsWith("~/") || isAbsolute(d) ? d : resolve(process.cwd(), d); @@ -1708,6 +1739,43 @@ workstreamCmd }); }); +workstreamCmd + .command("move ") + .description("Move a session to another home workstream: its whole conversation goes with it, so the share policy of its old home (and of each workstream it read) decides; applied at its next turn") + .action(async (session: string, workstream: string) => { + const client = new TerminalClient(loadConfig()); + await client.connect(); + try { + await client.sessionWorkstream(session, { kind: "set_workstream", workstream }); + } finally { + client.disconnect(); + } + }); + +workstreamCmd + .command("ref ") + .description("add | rm: let a session read another workstream (its folders read-only, or only , and its memory), or stop") + .action(async (action: string, session: string, target: string) => { + if (action !== "add" && action !== "rm") { + console.error("codeoid workstream ref add|rm [:]"); + process.exit(1); + } + let ref: { workstream: string; paths?: string[] }; + try { + [ref] = referenceFlags([target]) as [{ workstream: string; paths?: string[] }]; + } catch (e) { + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + const client = new TerminalClient(loadConfig()); + await client.connect(); + try { + await client.sessionWorkstream(session, action === "add" ? { kind: "add_reference", ...ref } : { kind: "remove_reference", workstream: ref.workstream }); + } finally { + client.disconnect(); + } + }); + workstreamCmd .command("match [dir]") .description("Which workstream a folder belongs to, and why — what a new session there would get") diff --git a/src/daemon/sandbox/session-sandbox.ts b/src/daemon/sandbox/session-sandbox.ts index ba681344..8dc9beac 100644 --- a/src/daemon/sandbox/session-sandbox.ts +++ b/src/daemon/sandbox/session-sandbox.ts @@ -834,7 +834,10 @@ export class SessionSandbox { * rebuilt mid-work would end it; a process backend is restarted). */ get dirsPending(): boolean { - return this.#dirsStale || this.#pinsBroken || (this.#workstreamDirs !== undefined && this.#workstreamKey() !== this.#workstreamKeyApplied); + // The fence changed since the last resolution (a reference, a move, a folder made): none before the + // first, which is no change of anything (it resolves with the fence as it is then). + const fenceChanged = this.#workstreamDirs !== undefined && this.#info !== null && this.#workstreamKey() !== this.#workstreamKeyApplied; + return this.#dirsStale || this.#pinsBroken || fenceChanged; } /** The added directories the running sandbox has (SandboxSpec.addedDirs as resolved): what host-side file tools are held to. */ diff --git a/src/terminal/client.ts b/src/terminal/client.ts index b6e39106..a7760a3a 100644 --- a/src/terminal/client.ts +++ b/src/terminal/client.ts @@ -483,6 +483,38 @@ export class TerminalClient { if (line) console.log(` now: ${line}`); } + /** + * Change what workstreams a session reads, or move it to another + * (workstreams §5.3, §7): `session.add_reference` / `remove_reference` / + * `set_workstream`. An `ask` workstream may be asked of the owner on the session. + */ + async sessionWorkstream( + sessionIdOrName: string, + change: { kind: "add_reference"; workstream: string; paths?: string[] } | { kind: "remove_reference"; workstream: string } | { kind: "set_workstream"; workstream: string }, + ): Promise { + const sessionId = await this.#resolveSession(sessionIdOrName); + if (!sessionId) return; + const base = { id: randomUUID(), sessionId, workstream: change.workstream }; + const resp = await this.#request( + change.kind === "add_reference" + ? { type: "session.add_reference", ...base, ...(change.paths?.length ? { paths: change.paths } : {}) } + : change.kind === "remove_reference" + ? { type: "session.remove_reference", ...base } + : { type: "session.set_workstream", ...base }, + ); + if (resp.type !== "response.ok") { + this.#printError(resp); + return; + } + const data = resp.data as { pending?: boolean; message?: string; workstream?: SessionInfo["workstream"] }; + if (data.pending) console.log(`Asked on the session: it applies once its owner says yes (codeoid approve ${S(sessionIdOrName)}, or the web UI), from its next turn.`); + else console.log(change.kind === "set_workstream" ? `Moved: its servers, credentials and sandbox follow from its next turn.` : `Done, from its next turn.`); + const ws = workstreamSummary(data.workstream); + if (ws) console.log(` ${sanitizeTerminalOutput(ws)}`); + const refs = data.workstream?.references ?? []; + if (refs.length > 0) console.log(` reads: ${sanitizeTerminalOutput(refs.map((r) => `${r.workstream}${r.paths ? ` (${r.paths.join(", ")})` : ""}`).join("; "))}`); + } + /** The daemon's sandbox providers and whether each runs here (#448). */ async sandboxProviders(): Promise { const resp = await this.#request({ type: "sandbox.providers", id: randomUUID() }); @@ -521,6 +553,8 @@ export class TerminalClient { sandbox?: SessionSandboxSetting; /** Home workstream (docs/workstreams-design.md); absent = the folder's. */ workstream?: string; + /** Other workstreams it may read (§7.2). */ + references?: Array<{ workstream: string; paths?: string[] }>; /** * Asked when the choice conflicts with the folder's exclusive workstream * and may be overridden (G3): shown what would change; true = confirm. @@ -549,6 +583,7 @@ export class TerminalClient { ...(opts.sandbox ? { sandbox: opts.sandbox } : {}), ...(opts.workstream ? { workstream: opts.workstream } : {}), ...(opts.overrideToken ? { override: { token: opts.overrideToken } } : {}), + ...(opts.references?.length ? { references: opts.references } : {}), }); const conflict = resp.type === "response.error" && resp.detail?.kind === "workstream_conflict" ? resp.detail.resolution.conflict : undefined; diff --git a/src/tests/sandbox-workstream-manager.test.ts b/src/tests/sandbox-workstream-manager.test.ts new file mode 100644 index 00000000..d49e7580 --- /dev/null +++ b/src/tests/sandbox-workstream-manager.test.ts @@ -0,0 +1,137 @@ +/** + * The workstream fence through a real SessionSandbox (workstreams §7.3): + * under isolation a session's project roots are its home's and references' + * folders (never the bare @parent), every other workstream's folder under a + * shown root is hidden, and a reference added later reaches the next build. + */ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { CodeoidConfig } from "../config.js"; +import type { ProviderEvent } from "../daemon/providers/interface.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import { ProviderRegistry } from "../daemon/providers/registry.js"; +import { LocalSandboxHandle } from "../daemon/sandbox/local-handle.js"; +import { SandboxRegistry } from "../daemon/sandbox/registry.js"; +import type { SandboxProvider, SandboxSpec } from "../daemon/sandbox/types.js"; +import { SessionManager } from "../daemon/session-manager.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { FolderReader } from "../daemon/workstreams/folder.js"; +import { workstreamsSchema } from "../daemon/workstreams/schema.js"; +import { ALL_SCOPES } from "../protocol/scopes.js"; +import type { AuthContext, DaemonMessage, SessionInfo, SessionMessage } from "../protocol/types.js"; + +const AUTH: AuthContext = { sub: "user:fence", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc", projectId: "proj" }; +const CLIENT = { id: "cli", auth: AUTH, send: () => {} }; +const DONE: ProviderEvent = { type: "turn_done", result: { providerId: "claude", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } }; + +let specs: SandboxSpec[] = []; +void specs; +function fake(): SandboxProvider { + const open = (spec: SandboxSpec) => { + specs.push(spec); + return new LocalSandboxHandle("bwrap", spec, (argv, o) => ({ argv: [...argv], env: o.env })); + }; + return { + id: "bwrap", + displayName: "fake-bwrap", + capabilities: { isolation: "os", placement: "local", workspace: "mount", stdioProcess: true, commands: true, wrapsArgv: true, network: ["on", "off"], platforms: ["linux"] }, + probe: async () => ({ available: true }), + open: async (s) => open(s), + openSync: open, + }; +} + +let tmp: string; +let home: string; +let ws: string; +let store: Store; +let transcript: TranscriptStore; +const realHome = process.env.HOME; + +beforeEach(() => { + specs = []; + const parent = process.env.CODEOID_TEST_SANDBOX_DIR ?? join(homedir(), ".cache"); + mkdirSync(parent, { recursive: true }); + tmp = realpathSync(mkdtempSync(join(parent, "codeoid-ws-fence-mgr-"))); + home = join(tmp, "home"); + ws = join(home, "Workspace"); + process.env.HOME = home; + for (const d of ["mine", "pub", "hf"]) mkdirSync(join(ws, d), { recursive: true }); + mkdirSync(join(tmp, "data")); + store = new Store(join(tmp, "data", "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "data", "transcripts")); +}); + +afterEach(async () => { + try { + await transcript.flush(); + } catch {} + try { + store.close(); + } catch {} + process.env.HOME = realHome; + rmSync(tmp, { recursive: true, force: true }); +}); + +function newManager(): SessionManager { + const config = { + daemonUrl: "ws://127.0.0.1:7400", + dbPath: join(tmp, "data", "codeoid.db"), + transcriptDir: join(tmp, "data", "transcripts"), + auth: { baseUrl: "http://localhost:8899" }, + zeroidUrl: "http://localhost:8899", + workspaceIndex: { enabled: false, episodeThreshold: 5, timeThresholdMs: 60_000, debounceMs: 15_000 }, + compress: { enabled: false, excludeCommands: [], excludePatterns: [], compressPipes: false, minBytes: 1024 }, + labeling: {}, + telemetry: { osc8: "auto" }, + autoRotate: { enabled: false, warnPct: 0.6, rotatePct: 0.8, hardRotatePct: 0.9, minTurnsBeforeRotate: 3, strategy: "task-anchor" }, + session: {}, + conductor: { enabled: false, name: "conductor", provider: "claude" }, + workstreams: workstreamsSchema.parse({ + isolateGlobalServers: true, + items: [ + { id: "mine", match: { paths: ["~/Workspace/mine"] } }, + { id: "pub", share: "open", match: { paths: ["~/Workspace/pub"] } }, + { id: "hf", share: "closed", match: { paths: ["~/Workspace/hf"] } }, + ], + }), + } as unknown as CodeoidConfig; + const registry = new ProviderRegistry("claude"); + registry.register({ id: "claude", displayName: "claude", create: () => new MockSessionProvider("claude", [[DONE], [DONE], [DONE]]) }); + const sandboxRegistry = new SandboxRegistry("linux", (n) => `/usr/bin/${n}`); + sandboxRegistry.register(fake()); + return new SessionManager(store, transcript, undefined, undefined, undefined, { config, providers: registry, sandboxRegistry, _testFolderReader: new FolderReader({ home, sshConfig: "" }) }); +} + +describe("the fence in the sandbox", () => { + it("roots are the home's and references' folders, another workstream's is hidden, and a fresh session says no change", async () => { + const m = newManager(); + const made = (await m.handle({ type: "session.create", id: "c", name: "s", workdir: join(ws, "mine") } as never, AUTH, CLIENT)) as DaemonMessage & { data?: SessionInfo }; + expect(made.data?.workstream?.id).toBe("mine"); + const session = m.findByName("s", AUTH)!; + const infos: string[] = []; + session.attach({ id: "w", auth: AUTH, send: (msg: { type: string; role?: string; content?: string; metadata?: Record }) => void (msg.type === "session.message" && msg.role === "info" && infos.push(String((msg as unknown as SessionMessage).content))) } as never); + await session.ensureSandbox(); + await session.ensureSandbox(); + expect(session.sandboxInfo?.projectRoots).toEqual([join(ws, "mine")]); + expect(infos.filter((i) => i.includes("Sandbox directories applied"))).toEqual([]); + + await m.handle({ type: "session.add_reference", id: "r", sessionId: session.id, workstream: "pub" } as never, AUTH, CLIENT); + await session.ensureSandbox(); + expect(session.sandboxInfo?.projectRoots?.sort()).toEqual([join(ws, "mine"), join(ws, "pub")].sort()); + expect(infos.filter((i) => i.includes("Sandbox directories applied")).length).toBe(1); + await m.drain(2_000); + }); + + it("a session's own project roots stand (the fence then only hides: sandbox-workstream-fence.test.ts)", async () => { + const m = newManager(); + await m.handle({ type: "session.create", id: "c", name: "t", workdir: join(ws, "mine"), sandbox: { projectRoots: [ws] } } as never, AUTH, CLIENT); + const session = m.findByName("t", AUTH)!; + await session.ensureSandbox(); + expect(session.sandboxInfo?.projectRoots).toEqual([ws]); + await m.drain(2_000); + }); +}); diff --git a/web/src/components/CenterPane.tsx b/web/src/components/CenterPane.tsx index 7eec877b..3d339d8d 100644 --- a/web/src/components/CenterPane.tsx +++ b/web/src/components/CenterPane.tsx @@ -120,7 +120,7 @@ const SessionHeader: Component = () => ( {s().model} - + diff --git a/web/src/components/NewSessionModal.tsx b/web/src/components/NewSessionModal.tsx index 37e316be..7c03204e 100644 --- a/web/src/components/NewSessionModal.tsx +++ b/web/src/components/NewSessionModal.tsx @@ -59,7 +59,7 @@ function sandboxDefaultLabel(d: SandboxProvidersData | null): string { import DirectoryPicker from "./files/DirectoryPicker"; import { providerIds, providerLabel } from "../state/providers"; import { accountDetail, backendAccounts, DEFAULT_ACCOUNT, fetchAccounts } from "../state/accounts"; -import { fetchWorkstreams, homeChoices, resolveWorkstream } from "../state/workstreams"; +import { fetchWorkstreams, homeChoices, resolveWorkstream, workstreamsState } from "../state/workstreams"; import type { WorkstreamResolution } from "../protocol/types"; /** The modal serves three flows from one dialog: a plain session, a governed @@ -167,6 +167,8 @@ const NewSessionModal: Component = () => { // Home workstream (docs/workstreams-design.md): "" = the folder's (what // `workstream.resolve` says), else an explicit choice. const [workstream, setWorkstream] = createSignal(""); + // Other workstreams the session may read (§7.2): their folders read-only, and their memory. + const [references, setReferences] = createSignal([]); const [resolution, setResolution] = createSignal(null); // The folder couldn't be checked (a scope, an unreadable folder): said as such, never as "none matches". const [resolveFailed, setResolveFailed] = createSignal(false); @@ -495,6 +497,7 @@ const NewSessionModal: Component = () => { setProviderId(""); setAccount(""); setWorkstream(""); + setReferences([]); setResolution(null); setOverrideOffer(null); setPackId(""); @@ -507,6 +510,13 @@ const NewSessionModal: Component = () => { setWarnings([]); } + /** The home the session would get: the choice, else what the folder resolves to. */ + const homeNow = () => workstream() || resolution()?.home || ""; + /** Workstreams it could read: not closed, not its home. Only under isolation, where a reference means something. */ + const readable = () => (workstreamsState().isolateGlobalServers ? homeChoices().filter((w) => w.share !== "closed" && w.id !== homeNow()) : []); + /** What is sent: the ticked ones still offered (a home changed since drops its own). */ + const readsSent = () => references().filter((r) => readable().some((w) => w.id === r)); + /** A workstream as the picker names it. */ function nameOf(id: string): string { const w = homeChoices().find((x) => x.id === id); @@ -609,6 +619,7 @@ const NewSessionModal: Component = () => { ...(packId() ? { pack: packId() } : {}), ...(workstream() ? { workstream: workstream() } : {}), ...(overrideToken && workstream() ? { override: { token: overrideToken } } : {}), + ...(readsSent().length > 0 ? { references: readsSent().map((w) => ({ workstream: w })) } : {}), collaboration: { goal: goal().trim(), roles: roles().map((r) => ({ @@ -683,6 +694,7 @@ const NewSessionModal: Component = () => { ...(packRole() ? { packRole: packRole() } : {}), ...(workstream() ? { workstream: workstream() } : {}), ...(overrideToken && workstream() ? { override: { token: overrideToken } } : {}), + ...(readsSent().length > 0 ? { references: readsSent().map((w) => ({ workstream: w })) } : {}), ...(sandboxChoice() || sandboxNetwork() || sandboxDocker() || sandboxRoots().trim() || sandboxAdded().trim() ? { sandbox: { @@ -1284,6 +1296,31 @@ const NewSessionModal: Component = () => { + 0}> +
+ Also reads +
+ + {(w) => ( + + )} + +
+

Their folders read-only, and their memory — never their servers or credentials.

+
+
+ {(c) => (
({ })); vi.mock("../state/pipelines", () => ({ runPipeline: vi.fn() })); const resolveMock = vi.hoisted(() => vi.fn()); +const isolation = vi.hoisted(() => ({ on: false })); vi.mock("../state/workstreams", () => ({ fetchWorkstreams: vi.fn(() => Promise.resolve()), homeChoices: () => [ - { id: "acme", name: "Acme", abstract: false }, - { id: "mine", name: "mine", abstract: false }, + { id: "acme", name: "Acme", abstract: false, share: "ask" }, + { id: "mine", name: "mine", abstract: false, share: "open" }, + { id: "hf", name: "HF", abstract: false, share: "closed" }, ], + workstreamsState: () => ({ isolateGlobalServers: isolation.on, workstreams: [] }), resolveWorkstream: resolveMock, })); @@ -108,6 +111,25 @@ describe("the new-session dialog's workstream", () => { openNewSessionModal(); }); + it("under isolation, other workstreams it may read are offered (never a closed one, nor its home) and sent", async () => { + isolation.on = true; + try { + const r = setup(); + fireEvent.input(r.getByLabelText(/^Name/), { target: { value: "s5" } }); + fireEvent.input(r.getByLabelText(/^Workdir/), { target: { value: "/w/acme" } }); + await waitFor(() => expect(r.getByLabelText("Workstream").textContent).toContain("The folder's: acme"), { timeout: 2000 }); + expect(r.queryByLabelText(/HF/)).toBeNull(); + expect(r.queryByLabelText(/^Acme/)).toBeNull(); + fireEvent.click(r.getByLabelText(/^mine/)); + requestMock.mockResolvedValueOnce({ info: { id: "s5", name: "s5" } }); + fireEvent.click(r.getByRole("button", { name: /^create/i })); + await waitFor(() => expect(requestMock).toHaveBeenCalled()); + expect(requestMock.mock.calls[0]![0]).toMatchObject({ references: [{ workstream: "mine" }] }); + } finally { + isolation.on = false; + } + }); + it("a failed check says so, and a changed folder never shows the last folder's answer", async () => { const r = setup(); resolveMock.mockReset(); diff --git a/web/src/components/WorkstreamChip.test.tsx b/web/src/components/WorkstreamChip.test.tsx index 7d248109..e8f3e25b 100644 --- a/web/src/components/WorkstreamChip.test.tsx +++ b/web/src/components/WorkstreamChip.test.tsx @@ -1,6 +1,18 @@ // @vitest-environment jsdom -import { cleanup, render } from "@solidjs/testing-library"; -import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, waitFor } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const change = vi.hoisted(() => vi.fn()); +vi.mock("../state/workstreams", () => ({ + changeSessionWorkstream: change, + fetchWorkstreams: vi.fn(() => Promise.resolve()), + homeChoices: () => [ + { id: "acme", name: "Acme", share: "ask" }, + { id: "pub", name: "Pub", share: "open" }, + { id: "hf", name: "HF", share: "closed" }, + ], + workstreamsState: () => ({ workstreams: [{ id: "acme", name: "Acme", share: "ask" }, { id: "pub", name: "Pub", share: "open" }, { id: "hf", name: "HF", share: "closed" }] }), +})); import type { SessionInfo } from "../protocol/types"; import { chipColor, WorkstreamChip } from "./WorkstreamChip"; @@ -36,4 +48,25 @@ describe("WorkstreamChip", () => { expect(chipColor("red; background:url(x)")).toBeUndefined(); expect(chipColor(undefined)).toBeUndefined(); }); + + it("editable: a panel with what it reads (remove), what it could read (never a closed one), and a move; a pending ask is said", async () => { + change.mockResolvedValue({ pending: false }); + const r = render(() => ); + expect(r.getByTestId("workstream-chip").textContent).toContain("+1"); + fireEvent.click(r.getByTestId("workstream-chip")); + const panel = r.getByRole("dialog", { name: "Workstream" }); + expect(panel.textContent).toContain("Pub"); + // Nothing more to read: pub is read already, hf is closed, acme is the home. + expect(r.queryByLabelText("Add a workstream to read")).toBeNull(); + fireEvent.click(r.getByRole("button", { name: "Remove" })); + await waitFor(() => expect(change).toHaveBeenCalledWith("s", { kind: "remove_reference", workstream: "pub" })); + change.mockResolvedValueOnce({ pending: true }); + fireEvent.change(r.getByLabelText("Move to workstream"), { target: { value: "pub" } }); + fireEvent.click(r.getByRole("button", { name: "Move" })); + await waitFor(() => expect(r.getByRole("status").textContent).toContain("Asked on the session")); + change.mockRejectedValueOnce(new Error("closed: no")); + fireEvent.change(r.getByLabelText("Move to workstream"), { target: { value: "hf" } }); + fireEvent.click(r.getByRole("button", { name: "Move" })); + await waitFor(() => expect(r.getByRole("alert").textContent).toContain("closed: no")); + }); }); diff --git a/web/src/components/WorkstreamChip.tsx b/web/src/components/WorkstreamChip.tsx index 72f8191c..caf1fdd7 100644 --- a/web/src/components/WorkstreamChip.tsx +++ b/web/src/components/WorkstreamChip.tsx @@ -3,11 +3,17 @@ * name in its color, amber when homed past another workstream's claim * (override), red when its workstream was removed (orphaned), and "no * workstream" when nothing matched. Absent on a daemon without workstreams. + * + * `editable` (the session header): the chip opens a panel with the + * workstreams the session reads (§7.2), to add or take one away, and a move + * to another home (§5.3). The daemon judges each by the share policy; an + * `ask` one may be asked of the owner on the session. */ -import { type Component, Show } from "solid-js"; +import { type Component, For, Show, createSignal, onCleanup } from "solid-js"; import type { SessionInfo } from "../protocol/types"; +import { changeSessionWorkstream, fetchWorkstreams, homeChoices, workstreamsState } from "../state/workstreams"; /** A config color as a CSS color, or none: a name or a #hex only — never arbitrary CSS. */ export function chipColor(color: string | undefined): string | undefined { @@ -15,7 +21,11 @@ export function chipColor(color: string | undefined): string | undefined { return /^#[0-9a-fA-F]{3,8}$/.test(color) || /^[a-zA-Z]{3,20}$/.test(color) ? color : undefined; } -export const WorkstreamChip: Component<{ session: SessionInfo }> = (props) => { +const button = + "rounded border border-border bg-bg px-2 py-1 text-[11px] text-fg-muted transition hover:border-accent/40 hover:text-fg disabled:opacity-50"; +const select = "min-w-0 flex-1 rounded border border-border bg-bg px-2 py-1 font-mono text-[11px] text-fg focus:border-accent focus:outline-none"; + +export const WorkstreamChip: Component<{ session: SessionInfo; editable?: boolean }> = (props) => { const ws = () => props.session.workstream; const tone = () => { const w = ws(); @@ -28,24 +38,187 @@ export const WorkstreamChip: Component<{ session: SessionInfo }> = (props) => { const w = ws(); if (!w) return ""; if (w.orphaned) return `Workstream "${w.id}" is no longer configured: move this session to another`; - if (!w.id) return "No workstream: no rule matched this folder and no default is set"; + const reads = w.references?.length ? `\nReads: ${w.references.map((r) => r.workstream).join(", ")}` : ""; + if (!w.id) return `No workstream: no rule matched this folder and no default is set${reads}`; const how = w.source === "matched" ? `matched by ${w.matchedBy ?? "a rule"}` : w.source === "default" ? "the default workstream" - : w.source === "explicit" ? "chosen at create" + : w.source === "explicit" ? "chosen" : w.source === "override" ? "chosen past the folder's own workstream" : w.source === "migrated" ? "from its folder, when workstreams were set up" : w.source; - return `Workstream ${w.name ?? w.id} — ${how}`; + return `Workstream ${w.name ?? w.id} — ${how}${reads}`; + }; + const label = () => { + const w = ws(); + const n = w?.references?.length ?? 0; + return `${w?.id ? (w.name ?? w.id) : "no workstream"}${n > 0 ? ` +${n}` : ""}`; }; + const [open, setOpen] = createSignal(false); + let chip: HTMLButtonElement | undefined; + const dot = () => ( + + {(c) => + ); + const cls = () => `inline-flex items-center gap-1 whitespace-nowrap rounded border px-1.5 py-0.5 font-mono text-[10px] tracking-wider ${tone()}`; return ( - - - {(c) => + } + > + + + setOpen(false)} /> - {ws()?.id ? (ws()?.name ?? ws()?.id) : "no workstream"} - + ); }; + +/** The chip's panel: what the session reads, and where it lives. Fixed, so a header's overflow never clips it. */ +const WorkstreamPanel: Component<{ session: SessionInfo; anchor: HTMLElement | undefined; onClose: () => void }> = (props) => { + const ws = () => props.session.workstream; + const [busy, setBusy] = createSignal(false); + const [note, setNote] = createSignal<{ text: string; error: boolean } | null>(null); + const [addId, setAddId] = createSignal(""); + const [moveId, setMoveId] = createSignal(""); + const nameOf = (id: string) => workstreamsState().workstreams.find((w) => w.id === id)?.name ?? id; + const shareOf = (id: string) => workstreamsState().workstreams.find((w) => w.id === id)?.share; + const referenced = () => new Set((ws()?.references ?? []).map((r) => r.workstream)); + const addable = () => homeChoices().filter((w) => w.share !== "closed" && w.id !== ws()?.id && !referenced().has(w.id)); + const movable = () => homeChoices().filter((w) => w.id !== ws()?.id); + const run = async (fn: () => Promise<{ pending: boolean }>, done: string) => { + setBusy(true); + setNote(null); + try { + const r = await fn(); + setNote({ text: r.pending ? "Asked on the session: it applies once you confirm there." : done, error: false }); + } catch (e) { + setNote({ text: e instanceof Error ? e.message : String(e), error: true }); + } finally { + setBusy(false); + } + }; + // Below the chip, kept inside the window. + const rect = props.anchor?.getBoundingClientRect(); + const width = Math.min(320, window.innerWidth - 16); + const left = Math.max(8, Math.min((rect?.left ?? 8), window.innerWidth - width - 8)); + const top = (rect?.bottom ?? 40) + 4; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") props.onClose(); + }; + window.addEventListener("keydown", onKey); + onCleanup(() => window.removeEventListener("keydown", onKey)); + return ( + <> +
props.onClose()} aria-hidden="true" /> + + + ); +}; diff --git a/web/src/state/workstreams.ts b/web/src/state/workstreams.ts index 750110f1..3ea25d6a 100644 --- a/web/src/state/workstreams.ts +++ b/web/src/state/workstreams.ts @@ -10,7 +10,7 @@ import { createSignal } from "solid-js"; -import { getClient, newRequestId } from "./connection"; +import { getClient, newRequestId, request } from "./connection"; import type { ClientMessage, CredentialInfo, @@ -175,3 +175,25 @@ export async function resolveWorkstream(workdir: string, workstream?: string): P export function homeChoices(): WorkstreamSummary[] { return state().workstreams.filter((w) => !w.abstract); } + +/** + * Change what workstreams a session reads, or move it (workstreams §5.3, + * §7). Resolves `pending` when an `ask` workstream was asked of the owner on + * the session; throws with the daemon's message when refused. The session's + * info update follows on its own. + */ +export async function changeSessionWorkstream( + sessionId: string, + change: { kind: "add_reference"; workstream: string; paths?: string[] } | { kind: "remove_reference"; workstream: string } | { kind: "set_workstream"; workstream: string }, +): Promise<{ pending: boolean }> { + const base = { id: newRequestId(), sessionId, workstream: change.workstream }; + const data = (await request( + change.kind === "add_reference" + ? { type: "session.add_reference", ...base, ...(change.paths?.length ? { paths: change.paths } : {}) } + : change.kind === "remove_reference" + ? { type: "session.remove_reference", ...base } + : { type: "session.set_workstream", ...base }, + )) as { data?: { pending?: boolean } } | { pending?: boolean } | undefined; + const d = data && "data" in data ? data.data : (data as { pending?: boolean } | undefined); + return { pending: d?.pending === true }; +} From 2bd3dd2d8664596abd2b364f5fe7633d75a40428 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 15:11:44 +0800 Subject: [PATCH 07/11] =?UTF-8?q?fix:=20lint=20=E2=80=94=20plain=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/terminal/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/client.ts b/src/terminal/client.ts index a7760a3a..96c4ca2f 100644 --- a/src/terminal/client.ts +++ b/src/terminal/client.ts @@ -508,7 +508,7 @@ export class TerminalClient { } const data = resp.data as { pending?: boolean; message?: string; workstream?: SessionInfo["workstream"] }; if (data.pending) console.log(`Asked on the session: it applies once its owner says yes (codeoid approve ${S(sessionIdOrName)}, or the web UI), from its next turn.`); - else console.log(change.kind === "set_workstream" ? `Moved: its servers, credentials and sandbox follow from its next turn.` : `Done, from its next turn.`); + else console.log(change.kind === "set_workstream" ? "Moved: its servers, credentials and sandbox follow from its next turn." : "Done, from its next turn."); const ws = workstreamSummary(data.workstream); if (ws) console.log(` ${sanitizeTerminalOutput(ws)}`); const refs = data.workstream?.references ?? []; From 7fd232c12c913c1b352f2a8b1b23491714b21e5d Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 15:20:40 +0800 Subject: [PATCH 08/11] fix: a test's memory stub has the session scope W3 registers Co-Authored-By: Claude Opus 5.5 --- src/tests/collaboration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/collaboration.test.ts b/src/tests/collaboration.test.ts index ba9e0471..7dd8a22f 100644 --- a/src/tests/collaboration.test.ts +++ b/src/tests/collaboration.test.ts @@ -2711,7 +2711,7 @@ describe("the orchestrator's fleet surface is scoped to its own children", () => // because the shared harness injects no engine at all — a mutation that // handed `this.#memory` straight to the orchestrator went undetected until // this test was written this way. - const stub = { marker: "memory-engine" } as unknown as MemoryEngine; + const stub = { marker: "memory-engine", scopeSession: () => () => {} } as unknown as MemoryEngine; manager.setMemory(stub); const mine = await createGoal("sf5"); From 4436231fb2964d24c198a8625918dfe7a41a4ebb Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 15:29:34 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20workstreams=20W3=20audit=20round?= =?UTF-8?q?=201=20=E2=80=94=20every=20other=20workstream's=20folder=20is?= =?UTF-8?q?=20fenced,=20with=20what=20the=20session=20reads=20carved=20out?= =?UTF-8?q?;=20its=20own=20workdir=20always=20readable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fenceFor: no foreign folder is dropped for being nested in the home's or holding it; WorkstreamFence.readable (workdir, home, exactly what a reference grants) is carved out — the gate checks it, the sandbox hides every other entry on the way (carveOut), else the folder whole - the home is never a workstream's folder, nor a repository top above it - a session's own workdir stays readable after a move or an override - extraction: a glob's partial last segment, ACP locations, pi's @ and find/ls; a relative path with an unknown cwd is refused - an added sandbox dir inside a fenced folder is refused and never kept - reference inputs held to the binding's own bounds; an unreadable stored binding seals the session (no home, never moved) - the session map and the frozen memory index keep to the workstream - the chip's panel takes focus and follows the window Co-Authored-By: Claude Opus 5.5 --- docs/CONFIGURATION.md | 11 +-- docs/workstreams-design.md | 4 +- src/daemon/memory/store.ts | 9 +++ src/daemon/sandbox/policy.ts | 6 +- src/daemon/sandbox/session-sandbox.ts | 4 +- src/daemon/secret-paths.ts | 18 ++++- src/daemon/session-manager.ts | 22 +++--- src/daemon/session.ts | 27 ++++++-- src/daemon/workstreams/binding.ts | 19 ++++++ src/daemon/workstreams/folders.ts | 46 ++++++++++++- src/daemon/workstreams/gate.ts | 23 +++++-- src/daemon/workstreams/service.ts | 69 ++++++++++++++++--- src/daemon/workstreams/workstreams.test.ts | 78 +++++++++++++++++++--- src/tests/sandbox-workstream-fence.test.ts | 20 ++++++ src/tests/workstreams-references.test.ts | 24 +++++++ web/src/components/WorkstreamChip.tsx | 30 ++++++--- 16 files changed, 348 insertions(+), 62 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d6800b8f..91aa0695 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -465,12 +465,16 @@ A folder's workstream is remembered: if its repository later matches a different ### Reading another workstream, and moving a session -With `isolateGlobalServers: true` a session reads only its own workstream's folders and memory: -- its sandbox shows its home's folders read-only as its project roots (never the whole `@parent`), and hides every other workstream's folder under a root it shows; +With `isolateGlobalServers: true` a session is fenced off from every other workstream's folders and memory: +- its sandbox shows its home's folders read-only as its project roots (never the whole `@parent`), and hides other workstreams' folders; - a file tool (Read, Grep, Glob, Write, Edit, on every backend, in every mode) in another workstream's folder is refused, naming that workstream; - `recall`, `timeline` and `get_episode` leave out episodes another workstream wrote (episodes from before workstreams stay visible in their repository). -A workstream's folders are the directories its path rules name on this machine (a glob such as `~/Workspace/acme-*` expands to the folders it matches, never to `~/Workspace`), plus the folders recorded as its home (a remote rule names no path). +A workstream's folders are the directories its path rules name on this machine (a glob such as `~/Workspace/acme-*` expands to the folders it matches, never to `~/Workspace`), plus the folders where its sessions were started by their folder's own match. +The more specific rule decides: a catch-all workstream over `~/Workspace` is fenced off from `~/Workspace/acme-api` when that is another's, and inside a folder of another workstream's, only what the session may read (its own workdir, its home's folders, what a reference grants) stays readable. +The home itself is never a workstream's folder. + +Limits: a repository matched only by a remote rule is fenced once a session has been started there (its folder is recorded then); one never opened is not. A session's commands in an unsandboxed session (`--sandbox off`) can still read other workstreams' folders; only its file tools are fenced. A **reference** lets a session read another workstream: its folders read-only (or only some), and its memory. Never its servers, credentials or accounts, and never a write. The workstream being read decides, by its `share`: @@ -491,7 +495,6 @@ codeoid workstream move # another Moving a session takes its whole conversation with it, so it follows the `share` of its old home and of every workstream it has read from (for its lifetime, even after the reference is gone). Moving from the default workstream, or from no home, is always allowed; past another workstream's exclusive folder it is an override (needs `session:approve`). Each change applies at the session's next turn: its backend is rebuilt with the new servers, credentials and sandbox. -Commands in an unsandboxed session (`--sandbox off`) can still read other workstreams' folders; only its file tools are fenced. ### Moving an existing setup diff --git a/docs/workstreams-design.md b/docs/workstreams-design.md index 8f2c0cba..71da5a32 100644 --- a/docs/workstreams-design.md +++ b/docs/workstreams-design.md @@ -497,12 +497,14 @@ codeoid workstream move ### 12.1 W3 as built (2026-11-11) - **Enforcement switch.** Everything W3 enforces (the fence, recall scoping) is on only with `isolateGlobalServers`, like W1's servers. Without it, episodes are still tagged with `workstream_id` and references are recorded, but nothing is fenced. -- **What is fenced.** Another workstream's folders, not everything outside the home: a blanket deny would refuse toolchains and system headers, and the cross-workstream read is the risk. A workstream's folders are its path rules expanded on disk (`workstreams/folders.ts`: one segment at a time, links followed only where the rule names a segment literally, at most 512) plus its recorded homes' repository tops. A folder that holds the session's home, a reference or the workspace is never fenced (the more specific rule made it theirs). +- **What is fenced.** Another workstream's folders, not everything outside the home: a blanket deny would refuse toolchains and system headers, and the cross-workstream read is the risk. A workstream's folders are its path rules expanded on disk (`workstreams/folders.ts`: one segment at a time, links followed only where the rule names a segment literally, at most 512) plus its recorded homes' repository tops (never the home, nor a top at or above it). Every one is fenced, nested in the home's or holding it: what the session may read inside one (`WorkstreamFence.readable`: its workdir and repository, its home's folders, exactly what its references grant) is carved out of it (the gate: a path in a foreign folder and in none of those; the sandbox: every other entry on the way to them, `carveOut`, else the folder whole). - **Sandbox.** The fence's readable folders replace `@parent` as the project roots unless the session's own setting names roots; other workstreams' folders go into the spec's hidden paths (`SpecInputs.extraHidden`), and a root inside one is dropped. A change of fence is a change of directories, applied between turns (`SessionSandbox.dirsPending`). - **Gate.** `workstreams/gate.ts` judges every tool call's read, search and write paths (secret-paths.ts and held-writes.ts extractors), in every mode; Claude's PreToolUse hook refuses the same calls, since the CLI skips `canUseTool` for some. - **Memory.** `episodes.workstream_id`, tagged with the home at write time. Recall stays the repository's (`workspace_id`), filtered to the home and references; untagged episodes stay visible. Cross-repository recall over a whole workstream is deferred. - **`ask` grants.** The owner asking with `session:approve`, undelegated, is the approval. Anyone else (a delegated or send-only token) is asked of the owner on the session, and the verdict is judged again when they answer. - **Read-from record.** `readFrom` is recorded when a reference is granted (it may be read from then). +- **Sealed.** A stored binding that can't be read back gives no home and `sealed: true`: what it read from is unknown, so it never moves. +- **Known limits:** a reference with `paths` gives the workstream's whole memory (recall isn't filtered by folder yet); an episode is tagged with the home that wrote it, so what a session read through a reference stays in its home's recall after that workstream closes; the conductor's fleet reads (`fleet_summary`, the dispatch digest) are W4's. - **Deferred:** `Bash` commands naming another workstream's path in an unsandboxed session (§14 q4) are not yet asked about; fork *into* another workstream; skillDirs. ## 13. Phases diff --git a/src/daemon/memory/store.ts b/src/daemon/memory/store.ts index 7b660cb8..71b70870 100644 --- a/src/daemon/memory/store.ts +++ b/src/daemon/memory/store.ts @@ -1020,6 +1020,15 @@ export class SqliteEpisodeStore { return row ? this.#rowToEpisode(row) : null; } + /** Whether `workspaceId` holds an episode a workstream outside `allowed` wrote (its index would show it). */ + hasOtherWorkstreams(workspaceId: string, allowed: readonly string[]): boolean { + const marks = allowed.map(() => "?").join(", "); + const row = this.#db + .prepare(`SELECT 1 FROM episodes WHERE workspace_id = ? AND workstream_id IS NOT NULL${allowed.length > 0 ? ` AND workstream_id NOT IN (${marks})` : ""} LIMIT 1`) + .get(workspaceId, ...allowed); + return row !== null && row !== undefined; + } + listRecent(workspaceId: string, limit: number, offset = 0, workstreams?: readonly string[]): Episode[] { if (workstreams !== undefined) { // Only what the caller's workstreams may read (RecallQuery.workstreams), paged over that. diff --git a/src/daemon/sandbox/policy.ts b/src/daemon/sandbox/policy.ts index 3a60d07c..746820b3 100644 --- a/src/daemon/sandbox/policy.ts +++ b/src/daemon/sandbox/policy.ts @@ -1210,7 +1210,11 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { const baseHidden = hiddenPathsFor(inputs.config, inputs.hidden); // What the hidden list hides, the home aside: no root or added dir is inside it (validated; again here). const inHidden = (p: string) => [...baseHidden, ...(root ? [root] : [])].some((h) => within(p, h) || within(realOr(p), realOr(h))); - const addedKept = added.filter((a) => !inHidden(a.dir) && !userHidden.some((u) => within(a.dir, u))); + // An added dir inside another workstream's folder isn't kept either: adding it can't open the fence. + const extraHidden = (inputs.extraHidden ?? []).map((h) => resolve(h)); + const addedKept = added.filter( + (a) => !inHidden(a.dir) && !userHidden.some((u) => within(a.dir, u)) && !extraHidden.some((h) => within(a.dir, h) || within(realOr(a.dir), realOr(h))), + ); // The session's own: its workspace and the dirs its owner added (their credentials are its to use, as the // workspace's are). A dir the agent added itself (autonomous `request_directory`) keeps the hiding a root has. // Its repository's git dir too (a fork's worktree uses its main checkout's): git there reads its config. diff --git a/src/daemon/sandbox/session-sandbox.ts b/src/daemon/sandbox/session-sandbox.ts index 8dc9beac..0f7109d5 100644 --- a/src/daemon/sandbox/session-sandbox.ts +++ b/src/daemon/sandbox/session-sandbox.ts @@ -611,9 +611,9 @@ export class SessionSandbox { const wd = this.#workstreamDirs?.(); if (!wd) return undefined; const workspace = this.#workspace(); - const added = this.#setting?.addedDirs ?? []; const holds = (h: string, p: string) => h === "/" || p === h || p.startsWith(`${h}/`); - const hidden = wd.hidden.filter((h) => !holds(h, workspace) && !holds(h, this.#workdir) && !added.some((a) => holds(h, a) || holds(a, h))); + // An added dir doesn't un-hide one: one inside a hidden folder is dropped (policy.ts), one holding it keeps it hidden. + const hidden = wd.hidden.filter((h) => !holds(h, workspace) && !holds(h, this.#workdir)); return { roots: wd.roots, hidden }; } diff --git a/src/daemon/secret-paths.ts b/src/daemon/secret-paths.ts index a8831c8d..13050d52 100644 --- a/src/daemon/secret-paths.ts +++ b/src/daemon/secret-paths.ts @@ -68,7 +68,9 @@ const PATH_LIST_KEYS = ["paths", "file_paths", "absolute_paths", "include", "dir /** Tools whose `pattern` is a path glob (not a regex) and whose absent root means the workdir. */ const GLOB_TOOLS = new Set(["Glob", "glob"]); /** Search tools: an absent root means the workdir. */ -const SEARCH_TOOLS = new Set(["Glob", "glob", "Grep", "grep", "grep_search", "search_file_content", "LS", "list_directory"]); +const SEARCH_TOOLS = new Set(["Glob", "glob", "Grep", "grep", "grep_search", "search_file_content", "LS", "list_directory", "find", "ls", "acp_search"]); +/** pi's file tools: a path's leading `@` is dropped, as pi drops it. */ +const PI_PATH_TOOLS = new Set(["read", "find", "ls", "grep", "write", "edit"]); /** Files in codeoid's config dir that hold credentials. */ function isSecretConfigFile(name: string): boolean { @@ -275,12 +277,24 @@ function candidatePaths(toolName: string, input: unknown): string[] { const v = obj[k]; if (Array.isArray(v)) for (const p of v) if (typeof p === "string" && p) out.push(p); } + // ACP's `locations` (acp/index.ts acpToolInput): paths, or `{path}` as the agent sent them. + if (Array.isArray(obj.locations)) { + for (const l of obj.locations) { + const p = typeof l === "string" ? l : l && typeof l === "object" ? (l as { path?: unknown }).path : undefined; + if (typeof p === "string" && p) out.push(p); + } + } + if (PI_PATH_TOOLS.has(toolName)) { + for (const [i, p] of out.entries()) if (p.startsWith("@") && p.length > 1) out[i] = p.slice(1); + } // A glob's pattern can be absolute (`/home/u/.codeoid/*.json`): judge its // literal prefix, up to the first wildcard. Only for glob tools — Grep's // `pattern` is a regex, and searching code for "/proc/self/environ" is fine. if (GLOB_TOOLS.has(toolName) && typeof obj.pattern === "string" && (isAbsolute(obj.pattern) || obj.pattern.startsWith("~"))) { const literal = obj.pattern.split(/[*?[{]/, 1)[0] ?? ""; - if (literal) out.push(literal.endsWith("/") ? literal.slice(0, -1) || "/" : literal); + // Up to the last whole segment: `/w/h?/*` searches `/w` (any `h?` in it), not a folder named `/w/h`. + const root = literal.endsWith("/") ? literal.slice(0, -1) || "/" : obj.pattern === literal ? literal : dirname(literal); + if (root) out.push(root); } // A search with no root searches the workdir. if (out.length === 0 && SEARCH_TOOLS.has(toolName)) out.push("."); diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index b8650847..cb6d2f4d 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -11,6 +11,7 @@ import { addedDirProblem, type OtherCheckout, PROJECT_ROOT_PARENT, projectRootProblem } from "./sandbox/project-roots.js"; import { WorkstreamService, type WorkstreamBinding, type WorkstreamFence, type WorkstreamReference } from "./workstreams/service.js"; import { repositoryTopOf } from "./workstreams/folders.js"; +import { REFERENCE_LIMIT, referencePathsProblem } from "./workstreams/binding.js"; import { grantRefusal, type McpGrant } from "./workstreams/grant.js"; import { OverrideTokens } from "./workstreams/override.js"; import { withSlot } from "./mcp/oauth.js"; @@ -2338,7 +2339,7 @@ mcpHub: this.#mcpHub, describeWorkstream: (b: WorkstreamBinding | undefined) => ReturnType; workstreamEnv?: (b: WorkstreamBinding | undefined) => Readonly>; mcpGrant: (b: WorkstreamBinding | undefined) => McpGrant | undefined; - workstreamFence: (b: WorkstreamBinding | undefined) => WorkstreamFence | undefined; + workstreamFence: (b: WorkstreamBinding | undefined, own: readonly string[]) => WorkstreamFence | undefined; } { const { missing } = this.#workstreams.envFor(binding, this.#secrets); if (missing.length > 0) { @@ -2353,7 +2354,7 @@ mcpHub: this.#mcpHub, ...(this.#workstreams.configured || binding ? { workstreamEnv: (b: WorkstreamBinding | undefined) => (b?.id ? this.#workstreams.envFor(b, this.#secrets).env : {}) } : {}), // The grant follows the config as it is at each listing. mcpGrant: (b) => this.#workstreams.grantFor(b), - workstreamFence: (b) => this.#workstreamFence(b), + workstreamFence: (b, own) => this.#workstreamFence(b, own), }; } @@ -2366,15 +2367,15 @@ mcpHub: this.#mcpHub, * names no path), as repository tops. Cached 5s: folders made meanwhile * (a new clone) are fenced within that. */ - #workstreamFence(binding: WorkstreamBinding | undefined): WorkstreamFence | undefined { + #workstreamFence(binding: WorkstreamBinding | undefined, own: readonly string[] = []): WorkstreamFence | undefined { if (!this.#workstreams.configured || !this.#workstreams.isolateGlobalServers) return undefined; - const key = `${this.#workstreams.revision}\u0000${JSON.stringify(binding ?? null)}`; + const key = `${this.#workstreams.revision}\u0000${JSON.stringify(binding ?? null)}\u0000${own.join("\u0000")}`; const now = Date.now(); const hit = this.#fenceCache.get(key); if (hit && now - hit.at < 5_000) return hit.fence; const recorded = this.#store.workstreamFoldersByWorkstream(); const home = homedir(); - const fence = this.#workstreams.fenceFor(binding, (id) => (recorded.get(id) ?? []).map((w) => repositoryTopOf(w, home))); + const fence = this.#workstreams.fenceFor(binding, (id) => (recorded.get(id) ?? []).map((w) => repositoryTopOf(w, home)), own); if (this.#fenceCache.size > 256) this.#fenceCache.clear(); this.#fenceCache.set(key, { at: now, fence }); return fence; @@ -4859,9 +4860,8 @@ mcpHub: this.#mcpHub, const refs: WorkstreamReference[] = []; for (const asked of msg.references) { const paths = asked.paths?.map((p) => normalizeWorkdir(p) ?? p); - if (paths?.some((p) => !p.startsWith("/"))) { - return { type: "response.error", requestId: msg.id, error: "A reference's folders are absolute paths", code: "invalid_request" }; - } + const pathProblem = referencePathsProblem(paths); + if (pathProblem) return { type: "response.error", requestId: msg.id, error: pathProblem, code: "invalid_request" }; const v = this.#workstreams.referenceVerdict(createWorkstream.id, { workstream: asked.workstream, ...(paths ? { paths } : {}) }, (recorded.get(asked.workstream) ?? []).map((w) => repositoryTopOf(w, homedir()))); if (!v.ok) return { type: "response.error", requestId: msg.id, error: v.error, code: "forbidden" }; if (v.ask.length > 0 && !approves) { @@ -10044,7 +10044,11 @@ mcpHub: this.#mcpHub, | { error: string }; if (msg.type === "session.add_reference") { const paths = msg.paths?.map((p) => normalizeWorkdir(p) ?? p); - if (paths?.some((p) => !p.startsWith("/"))) return err("A reference's folders are absolute paths"); + // Held to what a binding is read back with: one stored that can't be read would seal the session. + const pathProblem = referencePathsProblem(paths); + if (pathProblem) return err(pathProblem); + const others = (binding.references ?? []).filter((r) => r.workstream !== msg.workstream); + if (others.length >= REFERENCE_LIMIT) return err(`A session reads at most ${REFERENCE_LIMIT} other workstreams`); const recorded = this.#store.workstreamFoldersByWorkstream().get(msg.workstream) ?? []; const v = this.#workstreams.referenceVerdict(binding.id, { workstream: msg.workstream, ...(paths ? { paths } : {}) }, recorded.map((w) => repositoryTopOf(w, homedir()))); const again = () => { diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 8ff29280..a4132a42 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -105,6 +105,7 @@ import type { EffortChangeResult, EffortLevel, RewindFiles, RewindIrreversible, import type { WorkstreamBinding } from "./workstreams/binding.js"; import type { WorkstreamFence } from "./workstreams/service.js"; import { type FenceRefusal, fenceRefusal, fenceRefusalText } from "./workstreams/gate.js"; +import { repositoryTopOf } from "./workstreams/folders.js"; import type { McpGrant } from "./workstreams/grant.js"; import { LIMITS } from "../protocol/types.js"; import { hasScope, SCOPES } from "../protocol/scopes.js"; @@ -580,7 +581,7 @@ export interface SessionCreateOptions { * for its binding: its home's and references' folders, and every other * workstream's. Undefined result = no fence. */ - workstreamFence?: (binding: WorkstreamBinding | undefined) => WorkstreamFence | undefined; + workstreamFence?: (binding: WorkstreamBinding | undefined, own: readonly string[]) => WorkstreamFence | undefined; /** * Role-scoped goal-blackboard mount for a collaboration child: the endpoint * URL plus a bearer token that IS the scope (one goal, this role's read/write @@ -1457,7 +1458,7 @@ export class Session { if (!fence) return undefined; return { roots: [...new Set([...fence.home, ...fence.references.flatMap((r) => r.folders)])], - hidden: [...new Set(fence.foreign.flatMap((f) => f.folders))], + hidden: fence.hidden, }; } @@ -1850,7 +1851,10 @@ export class Session { const envOf = opts.workstreamEnv; this.#workstreamEnv = envOf ? () => envOf(this.#workstream) : undefined; const fenceOf = opts.workstreamFence; - this.#workstreamFence = fenceOf ? () => fenceOf(this.#workstream) : undefined; + // The session's own folder stays readable whatever its home (one moved, or homed past a claim): its + // workdir and the repository holding it (the sandbox's workspace in a monorepo). + const own = [...new Set([opts.workdir, repositoryTopOf(opts.workdir, homedir())])]; + this.#workstreamFence = fenceOf ? () => fenceOf(this.#workstream, own) : undefined; const extra = opts.extraEnv; if (extra) { // Before any side effect: a refused key must fail the create, not run a @@ -2557,7 +2561,8 @@ export class Session { this.#approvalPatchKeys.delete(approvalId); this.#resolveToolCallMessage(approvalId, { phase: "cancelled", reason: "denied", message: reason }); this.#store.audit(sender.sub, "session.workstream_fence_refuse", this.id, `tool=${toolName} kind=${r.kind} workstream=${r.workstream}`); - const infoMsg = this.#makeMessage("info", `⛔ Refused ${toolName}: ${r.path} is workstream "${r.workstream}"'s`, SYSTEM_IDENTITY, undefined, undefined, { + const said = r.kind === "unresolved" ? `couldn't tell where ${r.path} is` : `${r.path} is workstream "${r.workstream}"'s`; + const infoMsg = this.#makeMessage("info", `⛔ Refused ${toolName}: ${said}`, SYSTEM_IDENTITY, undefined, undefined, { event: "workstream.fenced", tool: toolName, workstream: r.workstream, @@ -3754,7 +3759,11 @@ export class Session { const root = sandboxRootIfMade(); const hidden = [...hiddenPathsFor(this.#config), ...(root ? [root] : [])]; const problem = addedDirProblem(written, { home, configDir: getConfigDir(), hidden }); - return problem ? { error: problem } : { dir, written }; + if (problem) return { error: problem }; + // Another workstream's folder (workstreams §7.3): writable would open the fence; reading it is a reference. + const fenced = this.#fenceRefusal("Write", { file_path: join(dir, ".codeoid-probe") }, null); + if (fenced) return { error: `${dir} is in workstream "${fenced.workstream}"'s folders: add a reference to read it (codeoid workstream ref add), never a writable directory` }; + return { dir, written }; } /** @@ -8973,7 +8982,7 @@ export class Session { let timelineEpisodes: ReturnType = []; if (this.#memory) { try { - timelineEpisodes = this.#memory.timeline(this.#workspaceId, 30); + timelineEpisodes = this.#memory.timeline(this.#workspaceId, 30, 0, this.#memoryWorkstreams()); } catch { /* graceful — the map still works without the page table */ } @@ -9112,7 +9121,11 @@ export class Session { // live scheduler every turn churns the append by a few bytes (relative times, // counts) and forces a per-turn query-loop rebuild that aborts the turn. if (this.#frozenMemoryIndex === null) { - this.#frozenMemoryIndex = this.#indexScheduler?.get() ?? ""; + // The index is the repository's whole memory: left out where another workstream wrote some of it + // (a repository two workstreams share); recall, filtered, still reaches the session's own. + const scope = this.#memoryWorkstreams(); + const mixed = scope !== undefined && (this.#memory?.store.hasOtherWorkstreams(this.#workspaceId, scope) ?? false); + this.#frozenMemoryIndex = mixed ? "" : (this.#indexScheduler?.get() ?? ""); } const index = this.#frozenMemoryIndex; if (!index) return MEMORY_SYSTEM_PROMPT_APPEND; diff --git a/src/daemon/workstreams/binding.ts b/src/daemon/workstreams/binding.ts index 410e4bc5..64390fd7 100644 --- a/src/daemon/workstreams/binding.ts +++ b/src/daemon/workstreams/binding.ts @@ -35,6 +35,12 @@ export interface WorkstreamBinding { * as it is then, even after the reference is gone. */ readFrom?: string[]; + /** + * The stored binding couldn't be read back, so what the session read from + * is unknown: it gets no home, and is never moved to another workstream + * (that would carry its conversation past a policy nobody can check). + */ + sealed?: true; } const ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; @@ -59,6 +65,18 @@ function readReference(raw: unknown): WorkstreamReference | undefined { }; } +/** Why a reference's folders can't be stored (the same bounds a binding is read back with), or undefined. */ +export function referencePathsProblem(paths: readonly string[] | undefined): string | undefined { + if (paths === undefined) return undefined; + if (paths.length === 0) return "A reference names its folders, or none for all of them"; + if (paths.length > MAX_REFERENCE_PATHS) return `A reference names at most ${MAX_REFERENCE_PATHS} folders`; + const bad = paths.find((p) => !p.startsWith("/") || p.length > 4096 || p.includes("\0")); + return bad === undefined ? undefined : `Not an absolute folder: ${JSON.stringify(bad)}`; +} + +/** Most references one session holds. */ +export const REFERENCE_LIMIT = MAX_REFERENCES; + const SOURCES: ReadonlySet = new Set(["explicit", "matched", "default", "override", "migrated", "reserved", "none"]); /** @@ -100,5 +118,6 @@ export function readWorkstreamBinding(raw: unknown): WorkstreamBinding | undefin revision: r.revision, ...(references && references.length > 0 ? { references } : {}), ...(readFrom && readFrom.length > 0 ? { readFrom } : {}), + ...(r.sealed === true ? { sealed: true as const } : {}), }; } diff --git a/src/daemon/workstreams/folders.ts b/src/daemon/workstreams/folders.ts index 247329a4..8978e85e 100644 --- a/src/daemon/workstreams/folders.ts +++ b/src/daemon/workstreams/folders.ts @@ -91,17 +91,20 @@ export function expandPathRule(rule: string, limit = FOLDER_LIMIT): ExpandedFold * The repository top of `dir`: the nearest ancestor holding `.git` (a dir or * a file), or `dir` itself when there is none. A recorded home folder is a * session's workdir, which may be a package inside a repository: the fence is - * the whole repository. + * the whole repository. Never `stopAt` (the home) or anything holding it: a + * home that is itself a repository (dotfiles) must not make every folder in + * it one workstream's. */ export function repositoryTopOf(dir: string, stopAt?: string): string { let at = dir; for (;;) { + if (at === "/" || (stopAt !== undefined && within(stopAt, at))) return dir; try { lstatSync(join(at, ".git")); return at; } catch {} const up = dirname(at); - if (up === at || at === stopAt) return dir; + if (up === at) return dir; at = up; } } @@ -118,3 +121,42 @@ export function outermost(folders: Iterable): string[] { for (const f of sorted) if (!out.some((o) => within(f, o))) out.push(f); return out; } + +/** Most entries {@link carveOut} hides before it gives up (and the folder is hidden whole). */ +export const CARVE_LIMIT = 2000; + +/** + * What to hide of `folder` so that only `keep` (paths inside it) stays + * visible: every entry on the way to a kept path that doesn't lead to one, + * at each level. Null when that takes more than `limit` entries or a level + * can't be listed: then the folder is hidden whole (the kept paths with it — + * failing closed). + */ +export function carveOut(folder: string, keep: readonly string[], limit = CARVE_LIMIT): string[] | null { + const out: string[] = []; + const walk = (dir: string): boolean => { + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return false; + } + for (const name of names) { + const path = join(dir, name); + if (keep.some((k) => within(path, k))) continue; // kept (or inside one) + if (keep.some((k) => within(k, path))) { + // Leads to a kept path: hide its other entries, never follow a link. + let real = false; + try { + real = lstatSync(path).isDirectory(); + } catch {} + if (!real || !walk(path)) return false; + continue; + } + out.push(path); + if (out.length > limit) return false; + } + return true; + }; + return walk(folder) ? out : null; +} diff --git a/src/daemon/workstreams/gate.ts b/src/daemon/workstreams/gate.ts index e74219e0..b6594160 100644 --- a/src/daemon/workstreams/gate.ts +++ b/src/daemon/workstreams/gate.ts @@ -20,7 +20,7 @@ export interface FenceRefusal { path: string; /** The workstream whose folder it is. */ workstream: string; - kind: "read" | "search" | "write" | "write_reference"; + kind: "read" | "search" | "write" | "write_reference" | "unresolved"; } function realOr(p: string): string { @@ -42,10 +42,22 @@ function forms(folders: readonly string[]): string[] { */ export function fenceRefusal(toolName: string, input: unknown, opts: { workdir: string; cwd?: string | null; fence: WorkstreamFence }): FenceRefusal | null { const base = typeof opts.cwd === "string" ? opts.cwd : opts.workdir; - const foreign = opts.fence.foreign.map((f) => ({ workstream: f.workstream, folders: forms(f.folders) })); + // A relative path where the backend didn't say where it runs (null: it may have moved) can't be + // judged: refused while there is something to fence, never judged from a guess. + if (opts.cwd === null && opts.fence.foreign.length > 0) { + const writesRaw = input && typeof input === "object" ? (writeTargets(toolName, input as Record) ?? []) : []; + const relative = [...toolReadPaths(toolName, input), ...writesRaw.filter((p): p is string => typeof p === "string")].find((p) => !p.startsWith("/") && p !== "~" && !p.startsWith("~/")); + if (relative !== undefined) return { path: relative, workstream: opts.fence.workstream ?? "", kind: "unresolved" }; + } + const readable = forms(opts.fence.readable); + // Each foreign folder with what is carved out of it: the readable paths inside it (never one holding it). + const foreign = opts.fence.foreign.map((f) => ({ + workstream: f.workstream, + folders: forms(f.folders).map((d) => ({ d, keep: readable.filter((r) => within(r, d)) })), + })); const references = opts.fence.references.map((r) => ({ workstream: r.workstream, folders: forms(r.folders) })); const home = forms(opts.fence.home); - const inForeign = (p: string) => foreign.find((f) => f.folders.some((d) => within(p, d))); + const inForeign = (p: string) => foreign.find((f) => f.folders.some(({ d, keep }) => within(p, d) && !keep.some((k) => within(p, k)))); // Writes first: a write tool's path is also a read key, and the refusal should say it writes. const writes = input && typeof input === "object" ? writeTargets(toolName, input as Record) : null; for (const raw of writes ?? []) { @@ -65,7 +77,8 @@ export function fenceRefusal(toolName: string, input: unknown, opts: { workdir: if (hit) return { path: raw, workstream: hit.workstream, kind: "read" }; // A search whose root holds a fenced folder reads into it. if (search) { - const holds = foreign.find((f) => f.folders.some((d) => within(d, p))); + // (A folder kept whole, a reference's, is no more fenced than any other readable one.) + const holds = foreign.find((f) => f.folders.some(({ d, keep }) => within(d, p) && !keep.some((k) => within(d, k)))); if (holds) return { path: raw, workstream: holds.workstream, kind: "search" }; } } @@ -82,6 +95,8 @@ export function fenceRefusalText(r: FenceRefusal): string { return `Searching ${r.path} would read workstream "${r.workstream}"'s folders, which this session's workstream may not read: search a narrower folder.`; case "write": return `${r.path} is in workstream "${r.workstream}"'s folders: this session's workstream may not write there.`; + case "unresolved": + return `codeoid can't tell where ${r.path} is (a relative path, and where the call runs isn't known), so it can't be checked against other workstreams' folders: use an absolute path.`; case "write_reference": return `${r.path} is in workstream "${r.workstream}", which this session references read-only: write in the session's own workstream instead.`; } diff --git a/src/daemon/workstreams/service.ts b/src/daemon/workstreams/service.ts index b31bfa8f..ebad878c 100644 --- a/src/daemon/workstreams/service.ts +++ b/src/daemon/workstreams/service.ts @@ -14,7 +14,7 @@ import { createHash } from "node:crypto"; import { realpathSync } from "node:fs"; import type { SessionWorkstreamInfo, WorkstreamResolution, WorkstreamSummary } from "../../protocol/types.js"; import { readWorkstreamBinding, type WorkstreamBinding } from "./binding.js"; -import { expandPathRule, outermost, within } from "./folders.js"; +import { carveOut, expandPathRule, outermost, within } from "./folders.js"; import { effectiveWorkstreams, type EffectiveWorkstream } from "./effective.js"; import { FolderReader } from "./folder.js"; import { expandHome, type MatchableWorkstream, matchFolder, normalizePathRule } from "./match.js"; @@ -36,8 +36,25 @@ export interface WorkstreamFence { home: string[]; /** Readable only, never written. */ references: Array<{ workstream: string; folders: string[] }>; - /** Neither read nor written: every other workstream's folders that hold none of the above. */ + /** + * Every other workstream's folders: neither read nor written, but for what + * `readable` keeps inside them (a folder of another's holding the home, a + * reference's own folder, the session's workdir). + */ foreign: Array<{ workstream: string; folders: string[] }>; + /** + * What the session may read wherever it is: its workdir and workspace, its + * home's folders, and its references' (as granted: a reference's paths, + * else its folders). Inside a foreign folder these stay readable; the + * rest of that folder doesn't. + */ + readable: string[]; + /** + * What a sandbox hides for it: each foreign folder whole, or, where + * something readable is inside one, every other entry on the way to it + * (folders.ts `carveOut`; hidden whole when that can't be done). + */ + hidden: string[]; limits: string[]; } @@ -192,7 +209,14 @@ export class WorkstreamService { found.push(...e.folders); limits.push(...e.limits); } - return { folders: outermost(found), limits }; + // The home (or what holds it) is never one workstream's folder: fencing it would fence everything. + const homes = [this.#reader.home, this.#realHome()]; + const kept = found.filter((f) => { + const holds = homes.some((h) => within(h, f)); + if (holds) limits.push(`${id}: ${f} is the home or holds it, so it isn't fenced`); + return !holds; + }); + return { folders: outermost(kept), limits }; } /** @@ -200,7 +224,11 @@ export class WorkstreamService { * isolation is off (nothing is fenced then, as before W3). `recorded(id)` * gives a workstream's recorded home folders. */ - fenceFor(binding: WorkstreamBinding | undefined, recorded: (id: string) => readonly string[] = () => []): WorkstreamFence | undefined { + fenceFor( + binding: WorkstreamBinding | undefined, + recorded: (id: string) => readonly string[] = () => [], + own: readonly string[] = [], + ): WorkstreamFence | undefined { if (!this.configured || !this.#cfg.isolateGlobalServers) return undefined; const limits: string[] = []; const homeId = binding?.id && binding.source !== "reserved" && this.#effective.has(binding.id) ? binding.id : undefined; @@ -220,16 +248,35 @@ export class WorkstreamService { // Kept with no folders here too: it still gives its memory (§7.2). references.push({ workstream: ref.workstream, folders }); } - const readable = [...home, ...references.flatMap((r) => r.folders)]; + // What stays readable even inside another workstream's folder: the + // session's own workdir (a session moved or homed past a claim keeps its + // folder), its home's folders, and what its references grant — exactly + // that, never the folder holding a reference's path. + const readable = [...new Set([...own, ...home, ...references.flatMap((r) => r.folders)])]; const foreign: WorkstreamFence["foreign"] = []; for (const ws of this.#effective.values()) { if (ws.abstract || ws.id === homeId) continue; - // A folder holding a readable one can't be fenced off without fencing - // that too: the more specific rule made it the home's or a reference's. - const folders = of(ws.id).filter((f) => !readable.some((r) => within(r, f) || within(f, r))); + // Every one of its folders, nested in the home's or holding it (the + // more specific rule decides): what is readable inside one is carved + // out of it (gate.ts, the sandbox). A reference's workstream is here + // too: only what the reference grants is carved out. + const folders = of(ws.id); if (folders.length > 0) foreign.push({ workstream: ws.id, folders }); } - return { ...(homeId ? { workstream: homeId } : {}), home, references, foreign, limits: [...new Set(limits)] }; + const hidden: string[] = []; + for (const d of foreign.flatMap((f) => f.folders)) { + const keep = readable.filter((r) => within(r, d)); + if (keep.length === 0) { + hidden.push(d); + continue; + } + const carved = keep.some((k) => k === d) ? [] : carveOut(d, keep); + if (carved === null) { + limits.push(`${d}: too much to carve around what this session reads there, so it is hidden whole`); + hidden.push(d); + } else hidden.push(...carved); + } + return { ...(homeId ? { workstream: homeId } : {}), home, references, foreign, readable, hidden: [...new Set(hidden)], limits: [...new Set(limits)] }; } /** @@ -260,6 +307,7 @@ export class WorkstreamService { const problem = this.choiceProblem(target); if (problem) return { ok: false, error: problem }; if (binding?.source === "reserved") return { ok: false, error: "The conductor's home can't be changed" }; + if (binding?.sealed) return { ok: false, error: "This session's workstream record couldn't be read back, so what it read from is unknown: it can't move to another workstream" }; const from = new Set(binding?.readFrom ?? []); if (binding?.id && binding.id !== this.#cfg.default) from.add(binding.id); from.delete(target); @@ -494,7 +542,8 @@ export class WorkstreamService { parsed.source === "reserved" || parsed.id === CONDUCTOR_WORKSTREAM || (parsed.id !== undefined && this.#effective.get(parsed.id)?.abstract === true); - result = bad ? { binding: none(), persist: true } : { binding: parsed, persist: false }; + // Unreadable: no home, and sealed (what it read from is unknown, so it never moves). + result = bad ? { binding: { ...none(), sealed: true }, persist: true } : { binding: parsed, persist: false }; } else { const parent = m.parentId && depth < 32 ? byId.get(m.parentId) : undefined; const inherited = parent ? decide(parent, depth + 1).binding : undefined; diff --git a/src/daemon/workstreams/workstreams.test.ts b/src/daemon/workstreams/workstreams.test.ts index 5180c674..267ebfb9 100644 --- a/src/daemon/workstreams/workstreams.test.ts +++ b/src/daemon/workstreams/workstreams.test.ts @@ -9,7 +9,7 @@ import { ancestry, expandHome, matchFolder, normalizePathRule, normalizeRemote, import { workstreamsSchema } from "./schema.js"; import { WorkstreamService } from "./service.js"; import { proposeWorkstreams } from "./import.js"; -import { expandPathRule } from "./folders.js"; +import { expandPathRule, repositoryTopOf } from "./folders.js"; import { fenceRefusal } from "./gate.js"; import { EnvFileSecretStore } from "../credentials/store.js"; @@ -569,22 +569,33 @@ describe("W3: folders, the fence, share verdicts", () => { expect(expandPathRule(join(tmp, "W", "*"), 1).limits[0]).toContain("more than 1"); }); - test("the fence: home's folders, a reference's (narrowed by paths), and every other workstream's as foreign", () => { - for (const d of ["W/acme-api", "W/acme-web", "W/hf", "W/mine/inner"]) mkdirSync(join(tmp, d), { recursive: true }); + test("the fence: every other workstream's folders are foreign; what is readable inside one is carved out", () => { + for (const d of ["W/acme-api/src", "W/acme-web", "W/hf", "W/mine/inner", "W/stray"]) mkdirSync(join(tmp, d), { recursive: true }); + writeFileSync(join(tmp, "W", "acme-api", ".env"), "x"); const s = service([ { id: "acme", share: "ask", match: { paths: ["~/W/acme-*"] } }, { id: "hf", share: "closed", match: { paths: ["~/W/hf"] } }, { id: "mine", share: "open", match: { paths: ["~/W/mine"] } }, { id: "broad", match: { paths: ["~/W"] } }, ]); - const fence = s.fenceFor({ id: "mine", source: "matched", revision: "r", references: [{ workstream: "acme", paths: [join(tmp, "W", "acme-api")], grantedBy: "u", grantedAt: "t" }] })!; + const src = join(tmp, "W", "acme-api", "src"); + const fence = s.fenceFor({ id: "mine", source: "matched", revision: "r", references: [{ workstream: "acme", paths: [src], grantedBy: "u", grantedAt: "t" }] }, () => [], [join(tmp, "W", "mine")])!; expect(fence.home).toEqual([join(tmp, "W", "mine")]); - expect(fence.references).toEqual([{ workstream: "acme", folders: [join(tmp, "W", "acme-api")] }]); - // acme-web isn't referenced: foreign, as is hf; `broad` holds the home, so it can't be fenced off. - expect(fence.foreign).toEqual([ - { workstream: "acme", folders: [join(tmp, "W", "acme-web")] }, - { workstream: "hf", folders: [join(tmp, "W", "hf")] }, - ]); + expect(fence.references).toEqual([{ workstream: "acme", folders: [src] }]); + expect(fence.readable.sort()).toEqual([join(tmp, "W", "mine"), src].sort()); + // acme's folder holding the granted path is foreign still (only `src` is carved out), and `broad`, + // which holds the home, is foreign with the home carved out of it. + expect(fence.foreign.map((f) => f.workstream).sort()).toEqual(["acme", "broad", "hf"]); + expect(fence.hidden).toContain(join(tmp, "W", "acme-web")); + expect(fence.hidden).toContain(join(tmp, "W", "hf")); + expect(fence.hidden).toContain(join(tmp, "W", "acme-api", ".env")); + expect(fence.hidden).toContain(join(tmp, "W", "stray")); + expect(fence.hidden).not.toContain(src); + expect(fence.hidden).not.toContain(join(tmp, "W", "mine")); + // A catch-all home: every narrower workstream inside it is fenced (the more specific rule decides). + const wide = s.fenceFor({ id: "broad", source: "matched", revision: "r" }, () => [], [join(tmp, "W")])!; + expect(wide.foreign.find((f) => f.workstream === "hf")?.folders).toEqual([join(tmp, "W", "hf")]); + expect(fenceRefusal("Read", { file_path: join(tmp, "W", "hf", "x") }, { workdir: join(tmp, "W"), fence: wide })).toMatchObject({ workstream: "hf" }); // A closed workstream's reference gives nothing, even one granted before. const closed = s.fenceFor({ id: "mine", source: "matched", revision: "r", references: [{ workstream: "hf", grantedBy: "u", grantedAt: "t" }] })!; expect(closed.references).toEqual([]); @@ -594,6 +605,31 @@ describe("W3: folders, the fence, share verdicts", () => { expect(new WorkstreamService(workstreamsSchema.parse({ items: [{ id: "a" }] }), new FolderReader({ home: tmp, sshConfig: "" })).fenceFor(undefined)).toBeUndefined(); }); + test("a session's own workdir stays readable whatever its home (moved, or homed past a claim)", () => { + mkdirSync(join(tmp, "W", "mine"), { recursive: true }); + const s = service([{ id: "mine", match: { paths: ["~/W/mine"] } }, { id: "pub" }]); + const fence = s.fenceFor({ id: "pub", source: "explicit", revision: "r" }, () => [], [join(tmp, "W", "mine")])!; + expect(fenceRefusal("Read", { file_path: join(tmp, "W", "mine", "own.ts") }, { workdir: join(tmp, "W", "mine"), fence })).toBeNull(); + expect(fenceRefusal("Grep", { pattern: "x" }, { workdir: join(tmp, "W", "mine"), fence })).toBeNull(); + }); + + test("the home is never a workstream's folder, nor a repository top for one (a dotfiles home)", () => { + mkdirSync(join(tmp, ".git"), { recursive: true }); + mkdirSync(join(tmp, "plain", "pkg"), { recursive: true }); + expect(repositoryTopOf(join(tmp, "plain", "pkg"), tmp)).toBe(join(tmp, "plain", "pkg")); + const s = service([{ id: "all", match: { paths: ["~"] } }, { id: "mine" }]); + expect(s.foldersOf("all").folders).toEqual([]); + expect(s.foldersOf("all").limits[0]).toContain("home"); + }); + + test("a malformed stored binding seals the session: no home, and never moved", () => { + const s = service([{ id: "a" }, { id: "b" }]); + const r = s.forResume([{ sessionId: "x", workdir: tmp, stored: { id: "a", source: "matched", revision: "r", references: "bad" } }])("x"); + expect(r.binding).toMatchObject({ source: "none", sealed: true }); + expect(s.moveVerdict(r.binding, "b")).toMatchObject({ ok: false }); + expect(readWorkstreamBinding(r.binding)).toMatchObject({ sealed: true }); + }); + test("reference verdicts follow the share policy of the workstream read", () => { mkdirSync(join(tmp, "W", "acme-api"), { recursive: true }); const s = service([ @@ -639,7 +675,13 @@ describe("W3: the fence at the gate", () => { workstream: "mine", home: [join(tmp, "mine")], references: [{ workstream: "pub", folders: [join(tmp, "pub")] }], - foreign: [{ workstream: "hf", folders: [join(tmp, "hf")] }, { workstream: "nested", folders: [join(tmp, "mine", "vendor-x")] }], + foreign: [ + { workstream: "hf", folders: [join(tmp, "hf")] }, + { workstream: "nested", folders: [join(tmp, "mine", "vendor-x")] }, + { workstream: "pub", folders: [join(tmp, "pub")] }, + ], + readable: [join(tmp, "mine"), join(tmp, "pub")], + hidden: [], limits: [], }); beforeEach(() => { @@ -669,6 +711,20 @@ describe("W3: the fence at the gate", () => { expect(judge("Glob", { pattern: `${join(tmp, "hf")}/**/*.ts` })).toMatchObject({ workstream: "hf" }); }); + test("what other backends name is judged too: a glob's partial segment, ACP locations, pi's @ and find/ls", () => { + expect(judge("Glob", { pattern: `${join(tmp, "h")}?/*` })).toMatchObject({ workstream: "hf" }); + expect(judge("Read", { title: "read", locations: [join(tmp, "hf", "secret.ts")] })).toMatchObject({ workstream: "hf" }); + expect(judge("Read", { locations: [{ path: join(tmp, "hf", "secret.ts") }] })).toMatchObject({ workstream: "hf" }); + expect(judge("read", { path: `@${join(tmp, "hf", "secret.ts")}` })).toMatchObject({ workstream: "hf" }); + expect(judge("find", { path: tmp })).toMatchObject({ kind: "search" }); + expect(judge("ls", { path: tmp })).toMatchObject({ kind: "search" }); + }); + + test("a relative path where the cwd isn't known is refused, never guessed", () => { + expect(fenceRefusal("Read", { file_path: "x.ts" }, { workdir: join(tmp, "mine"), cwd: null, fence: fence() })).toMatchObject({ kind: "unresolved" }); + expect(fenceRefusal("Read", { file_path: join(tmp, "mine", "x.ts") }, { workdir: join(tmp, "mine"), cwd: null, fence: fence() })).toBeNull(); + }); + test("a link into a fenced folder is judged where it leads", () => { symlinkSync(join(tmp, "hf"), join(tmp, "mine", "link")); expect(judge("Read", { file_path: join(tmp, "mine", "link", "x") })).toMatchObject({ workstream: "hf" }); diff --git a/src/tests/sandbox-workstream-fence.test.ts b/src/tests/sandbox-workstream-fence.test.ts index 6826155e..2bbc8165 100644 --- a/src/tests/sandbox-workstream-fence.test.ts +++ b/src/tests/sandbox-workstream-fence.test.ts @@ -51,4 +51,24 @@ describe("extraHidden", () => { expect(open.hidden).not.toContain(theirs); expect(open.reexposed).toContain(inside); }); + + test("an added dir inside another workstream's folder isn't kept: adding it can't open the fence", () => { + const home = join(base, "home"); + const mine = join(home, "mine"); + const theirs = join(home, "theirs"); + for (const d of [mine, join(theirs, "sub")]) mkdirSync(d, { recursive: true }); + const tmp = join(base, "tmp"); + mkdirSync(tmp); + const spec = buildSandboxSpec({ + sessionId: "s", + workspace: mine, + tempDir: tmp, + network: { mode: "off" }, + hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, + addedDirs: [{ dir: join(theirs, "sub") }], + extraHidden: [theirs], + }); + expect(spec.addedDirs ?? []).not.toContain(join(theirs, "sub")); + expect(spec.writable).not.toContain(join(theirs, "sub")); + }); }); diff --git a/src/tests/workstreams-references.test.ts b/src/tests/workstreams-references.test.ts index fe39ddf2..73e50d49 100644 --- a/src/tests/workstreams-references.test.ts +++ b/src/tests/workstreams-references.test.ts @@ -157,4 +157,28 @@ describe("the read fence at the gate", () => { expect(result?.message).toContain('workstream "hf"'); expect(made.data?.workstream?.id).toBe("mine"); }); + + it("a session moved to another workstream still reads its own folder", async () => { + script = [[{ type: "tool_start", toolId: "t2", sdkToolUseId: "sdk-t2", name: "Read", input: { file_path: join(home, "mine", "own.ts") }, approvalId: "ap-2" } as ProviderEvent, DONE]]; + writeFileSync(join(home, "mine", "own.ts"), "x"); + const made = await create("mv", "mine"); + await h({ type: "session.set_workstream", id: "w", sessionId: made.data!.id, workstream: "pub" }); + const session = manager.findByName("mv", OWNER)!; + expect(session.workstream?.id).toBe("pub"); + await session.send("read", OWNER); + for (let i = 0; i < 100 && providers.at(-1)?.canUseToolResults.length === 0; i++) await Bun.sleep(10); + const r = providers.at(-1)?.canUseToolResults[0]; + // Not refused by the fence (a guarded Read may still wait on its card: either way, never fenced). + expect(r?.message ?? "").not.toContain("workstream"); + }); +}); + +describe("reference inputs are held to what a binding is read back with", () => { + it("a NUL in a folder, or one reference too many, is refused before it is stored", async () => { + const made = await create("v", "mine"); + const id = made.data!.id; + expect(await h({ type: "session.add_reference", id: "n", sessionId: id, workstream: "pub", paths: [`${join(home, "pub")}/\u0000x`] })).toMatchObject({ type: "response.error" }); + expect(await create("v2", "mine", { references: [{ workstream: "pub", paths: ["relative"] }] })).toMatchObject({ type: "response.error" }); + expect(manager.findByName("v", OWNER)?.workstream?.references).toBeUndefined(); + }); }); diff --git a/web/src/components/WorkstreamChip.tsx b/web/src/components/WorkstreamChip.tsx index caf1fdd7..bc1475af 100644 --- a/web/src/components/WorkstreamChip.tsx +++ b/web/src/components/WorkstreamChip.tsx @@ -10,7 +10,7 @@ * `ask` one may be asked of the owner on the session. */ -import { type Component, For, Show, createSignal, onCleanup } from "solid-js"; +import { type Component, For, Show, createSignal, onCleanup, onMount } from "solid-js"; import type { SessionInfo } from "../protocol/types"; import { changeSessionWorkstream, fetchWorkstreams, homeChoices, workstreamsState } from "../state/workstreams"; @@ -121,24 +121,36 @@ const WorkstreamPanel: Component<{ session: SessionInfo; anchor: HTMLElement | u setBusy(false); } }; - // Below the chip, kept inside the window. - const rect = props.anchor?.getBoundingClientRect(); - const width = Math.min(320, window.innerWidth - 16); - const left = Math.max(8, Math.min((rect?.left ?? 8), window.innerWidth - width - 8)); - const top = (rect?.bottom ?? 40) + 4; + // Below the chip, kept inside the window; placed again when the window changes. + const place = () => { + const rect = props.anchor?.getBoundingClientRect(); + const width = Math.min(320, window.innerWidth - 16); + const left = Math.max(8, Math.min(rect?.left ?? 8, window.innerWidth - width - 8)); + return { width, left, top: (rect?.bottom ?? 40) + 4 }; + }; + const [pos, setPos] = createSignal(place()); + const onResize = () => setPos(place()); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") props.onClose(); }; window.addEventListener("keydown", onKey); - onCleanup(() => window.removeEventListener("keydown", onKey)); + window.addEventListener("resize", onResize); + onCleanup(() => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("resize", onResize); + }); + let dialog: HTMLDivElement | undefined; + onMount(() => dialog?.focus()); return ( <>
props.onClose()} aria-hidden="true" />