From ebe9c3f00b40459cc97f4eadfbf59ed184292a07 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 07:18:07 -0700 Subject: [PATCH 1/2] Plan HITL/Auto modes, tier removal, and project path grants Track CL-5479: drop native fast/standard/clever tiers, two permission modes (HITL ask vs free-reign Auto), and @-path file/dir grants stored in global settings per project. --- CHANGELOG.md | 1 + docs/plans/hitl-auto-path-grants.md | 135 ++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 docs/plans/hitl-auto-path-grants.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0266cb74c..e53b72310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Versions ### Planned +- Drop native model tiers; HITL vs free-reign Auto; project `@`-path grants in global settings (CL-5479) - Local context estimate for compaction when providers omit usage (CL-4345) - Image age → rehydratable attachment URI (CL-4349) - Always-return subagent salvage without a default wall-clock death clock (CL-4401) diff --git a/docs/plans/hitl-auto-path-grants.md b/docs/plans/hitl-auto-path-grants.md new file mode 100644 index 000000000..6ffd1646a --- /dev/null +++ b/docs/plans/hitl-auto-path-grants.md @@ -0,0 +1,135 @@ +# CL-5479 — HITL / Auto, drop native tiers, project path grants + +Planning note for the product simplification tracked in CL-5479. Not a shipped +feature until the implementation PR lands and this plan is either folded into +PRODUCT/ARCHITECTURE or deleted. + +## Goals + +1. **Drop native model tiers** (`fast` / `standard` / `clever`). +2. **Two permission modes only:** HITL (ask) and Auto (free reign). +3. **Project path grants** for `@`-mentioned files and directories, persisted in + global settings keyed by project identity (no repo-local grant file). + +## Model tiers + +### Remove from core + +| Surface | Today | After | +|---|---|---| +| `settings.tiers` | `Partial>` | Gone (migrate: warn + ignore) | +| Slash `/fast` `/standard` `/clever` | Switch active model to tier | Removed | +| `/model` tiers chrome | Assign tier legs | Providers + default/favorite only | +| Agent profile `tier` | Resolve via settings.tiers | Explicit provider/model or profile only | +| `task(tier=…)` | Per-spawn override | Removed (use profile / parent model) | +| Evaluator “prefer fast tier” | Fallback chain | Prefer default or a single configured cheap model if any | + +### Migration + +- Load still accepts `tiers` in JSON for one release. +- Log a one-shot warning: tiers are ignored; pick a default model in `/model`. +- Next major: reject or strip the key on save. + +Related prior tickets: CL-5192, CL-5200, CL-5196. + +## Permission modes + +### HITL (default) + +Current interactive gate behavior: + +- Read-only tools allow inside workspace (+ worktrees + path grants). +- Ask for side-effecting tools, outside-workspace content, secrets, network, etc. +- Hard denials stay (OOM shell patterns, catastrophic recursive rm, secret-guard + path-keyed reads/writes as today). + +### Auto (free reign) + +Operator-enabled unrestricted autonomy for the session (and optionally the run): + +- Gate short-circuits to allow (same family as today’s `skipPermissions`). +- No permission modal for tools. +- UX must make the risk obvious when enabling (status chrome + one-line + confirmation). +- Headless default remains fail-closed unless Auto is explicitly set for that + run. + +This **replaces** the current “soft auto” envelope (partial auto-allow with many +forced asks). Soft-auto rule tables become HITL-only complexity we can simplify +over follow-ups; Auto no longer consults them. + +### Toggle + +- SHIFT+TAB (or successor) switches HITL ↔ Auto. +- Status bar shows `HITL` or `Auto` clearly. + +## Project path grants + +### Trigger + +When the operator sends a message containing `@path` that resolves to a real +file or directory **outside** the primary workspace (+ registered worktrees): + +1. Still block sensitive paths (`.env`, keys, certs, …). +2. Inline content / directory summary into the message as today (when safe). +3. Register a **read-only grant** for the realpath: + - File → that file only. + - Directory → that directory tree. + +### Storage + +- Global settings only (e.g. under `~/.corbits/settings.json` or the project-key + map already used for per-project state), keyed by project identity. +- **Never** write grants into the git worktree — other users and clones must not + inherit them. + +Sketch (names illustrative): + +```json +{ + "projectPathGrants": { + "": [ + { "path": "/abs/benchmark", "mode": "read", "kind": "dir" }, + { "path": "/abs/notes.txt", "mode": "read", "kind": "file" } + ] + } +} +``` + +### Gate integration + +- Path restriction treats granted realpaths as in-bounds for **reads** and pure + listings. +- Writes / edits / shell mutations under a grant still **ask** in HITL + (default). Auto free-reign already allows them. +- Symlink policy: evaluate realpath at use time; only paths under a granted + realpath root (or equal to a granted file) count. + +### UX + +- Transcript line: `Granted read-only access to ../benchmark/ for this project.` +- `/permissions` (or settings) lists and revokes grants for the current project. + +## Suggested implementation order + +1. Path grants (storage + `@` resolution + path-restriction) — high user value, + smaller blast radius. +2. HITL rename + Auto free-reign (gate short-circuit) — product mode clarity. +3. Strip native model tiers (settings, slash, task, profiles, docs) — largest + surface; can land as a follow-up PR on the same branch stack. + +## Out of scope for the first land + +- Plugin-owned mode ladders (future; see platform plugin work). +- Repo-committed shared path grants for teams. +- Write grants from `@` (read-only only). + +## Test plan (acceptance) + +- Settings load with legacy `tiers` does not crash; resolution ignores tiers. +- No `/fast` `/standard` `/clever` in slash menu; `task` schema has no `tier`. +- HITL: outside content still asks; secret dump still asks. +- Auto: outside content and shell mutation do not prompt. +- `@../fixture.txt` and `@../fixture-dir/` grant and auto-allow subsequent reads + under HITL; no file written under the repo root for grants. +- Secret `@.env` remains blocked and does not create a grant. From 04a971c7cd2a128998d529dbfeaa48baaf5f80ce Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 16:36:05 -0700 Subject: [PATCH 2/2] Grant read-only access for @-mentioned outside-workspace paths Operators who @-mention a file or directory outside the workspace get a project-scoped read grant stored in global settings. Subsequent reads under that grant no longer prompt; writes still ask. Sensitive paths stay blocked. --- CHANGELOG.md | 4 + src/config/settings.ts | 15 ++ src/exec/runner.ts | 2 + src/permission/admin.ts | 10 +- src/permission/gate.ts | 22 +++ src/permission/path-grants.test.ts | 141 +++++++++++++++ src/permission/path-grants.ts | 134 ++++++++++++++ src/permission/path-restriction.test.ts | 42 ++++- src/permission/path-restriction.ts | 35 +++- src/tui/app.tsx | 2 + src/tui/hooks/use-gates.ts | 7 + src/tui/hooks/use-message-pipeline.ts | 39 +++- src/tui/mention-resolution.ts | 83 +++++---- src/tui/runner.tsx | 2 + tests/unit/tui/at-mention-resolution.test.ts | 176 ++++++++++++++----- 15 files changed, 623 insertions(+), 91 deletions(-) create mode 100644 src/permission/path-grants.test.ts create mode 100644 src/permission/path-grants.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e53b72310..6a0a30203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Versions ## [Unreleased] +### Added + +- `@` mentions auto-register a read-only project path grant in global settings, keyed by project identity, so subsequent reads (and reads of files under a granted directory) no longer prompt. Sensitive paths remain hard-blocked. (CL-5479) + ### Planned - Drop native model tiers; HITL vs free-reign Auto; project `@`-path grants in global settings (CL-5479) diff --git a/src/config/settings.ts b/src/config/settings.ts index 7f16d6fd2..3e7a687d1 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -156,6 +156,12 @@ export type Settings = { recentModels?: ModelRef[]; // Operator-starred provider+model pairs for the models-first picker. favoriteModels?: ModelRef[]; + // Read-only outside-workspace path grants, keyed by projectKeyFor(cwd). + // Global settings only — never written under the git worktree. + projectPathGrants?: Record< + string, + Array<{ path: string; mode: "read"; kind: "file" | "dir" }> + >; }; function modelRefKey(ref: ModelRef): string { @@ -479,6 +485,13 @@ const SettingsSchema = type({ }), "recentModels?": ModelRefSchema.array(), "favoriteModels?": ModelRefSchema.array(), + "projectPathGrants?": type({ + "[string]": type({ + path: "string", + mode: "'read'", + kind: "'file' | 'dir'", + }).array(), + }), }); // Per-entry MCP shape without the name key. The "exactly one transport" rule is @@ -642,6 +655,7 @@ export const GLOBAL_SETTINGS_OPTIONAL_KEYS = [ "otel", "recentModels", "favoriteModels", + "projectPathGrants", ] as const satisfies readonly (keyof OptionalSettingsFields)[]; /** Optional local settings keys the load path is required to consider. */ @@ -721,6 +735,7 @@ export async function loadSettings(path: string): Promise { otel: s.otel as Settings["otel"] | undefined, recentModels: s.recentModels as Settings["recentModels"] | undefined, favoriteModels: s.favoriteModels as Settings["favoriteModels"] | undefined, + projectPathGrants: s.projectPathGrants as Settings["projectPathGrants"] | undefined, }; return { providers: s.providers as Settings["providers"], diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 35b3554cf..96afd6126 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -46,6 +46,7 @@ import type { InferenceSource, ToolDefinition, InboundMessage } from "@intx/type import { createChatDirector } from "../agent/director.js"; import { loadAgentProfiles } from "../agent/profiles.js"; import { createPermissionGate } from "../permission/gate.js"; +import { getProjectPathGrantsForCwd } from "../permission/path-grants.js"; import { createWorktreeRootsProvider } from "../permission/worktree-roots.js"; import type { ApprovalOutcome, @@ -285,6 +286,7 @@ export async function runExec(config: Config): Promise { approvals: seededApprovals, cwd: config.cwd, rootsProvider: createWorktreeRootsProvider(config.cwd), + getInitialPathGrants: () => getProjectPathGrantsForCwd(config.settings, config.cwd), providerName: config.providerName, model: config.model, requestApproval: (request: PermissionRequest): Promise => diff --git a/src/permission/admin.ts b/src/permission/admin.ts index 1496b6af3..d83ce9200 100644 --- a/src/permission/admin.ts +++ b/src/permission/admin.ts @@ -1,5 +1,6 @@ import type { Approval, GrantScope } from "./types.js"; import type { PermissionGate } from "./gate.js"; +import type { PathGrant } from "./path-grants.js"; import { loadProjectApprovals, loadGlobalApprovals, @@ -14,6 +15,9 @@ export type ScopedApproval = { scope: GrantScope; tool: string; pattern: string; export type PermissionsAdmin = { list: () => Promise; revoke: (entry: ScopedApproval) => Promise; + // Forward mid-session read-only path grants (e.g. minted by an @mention) into + // the live permission gate so reads under them stop prompting immediately. + addPathGrants: (grants: readonly PathGrant[]) => void; }; function toApproval(entry: ScopedApproval): Approval { @@ -64,5 +68,9 @@ export function createPermissionsAdmin(gate: PermissionGate, cwd: string): Permi await reseed(); }; - return { list, revoke }; + const addPathGrants = (grants: readonly PathGrant[]): void => { + gate.addPathGrants(grants); + }; + + return { list, revoke, addPathGrants }; } diff --git a/src/permission/gate.ts b/src/permission/gate.ts index 0b3929f34..5edf378ce 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -18,6 +18,7 @@ import { evaluateApprovals } from "./authz-grants.js"; import { splitChainedCommand, tokenize, isShellCommentOnly, stripCommentLines } from "./command.js"; import { createPathRestriction } from "./path-restriction.js"; import { createWorktreeRootsProvider, type RootsProvider } from "./worktree-roots.js"; +import type { PathGrant } from "./path-grants.js"; import { getSubAgentIdentity } from "../subagent/identity-context.js"; import { createMcpToolPermissionRegistry, @@ -218,6 +219,10 @@ export type PermissionGateOptions = { // already knows about — so a worktree created mid-session is picked up // without a restart. rootsProvider?: RootsProvider; + // Seed live read-only path grants (from global settings projectPathGrants). + // The gate owns a mutable copy; mid-session @mention grants append via + // addPathGrants so the restriction cache is invalidated without restart. + getInitialPathGrants?: () => readonly PathGrant[]; // Tiers learned from connected MCP servers (tools/list annotations). Tests may // inject a shared registry; production gates create one when omitted. mcpTiers?: McpToolPermissionRegistry; @@ -260,6 +265,9 @@ export type PermissionGate = { // glob; a `run_shell` pattern that is not a single real command is dropped // rather than minted. preApprove: (tool: string, pattern: string) => void; + // Append mid-session project path grants (e.g. from an @mention) to the live + // list and invalidate the path-restriction cache so the next read honors them. + addPathGrants: (grants: readonly PathGrant[]) => void; registerMcpClient: (client: MCPClient) => void; unregisterMcpServer: (serverName: string) => void; }; @@ -268,9 +276,12 @@ export function createPermissionGate(options: PermissionGateOptions): Permission const { requestApproval, persist, interactive, skipPermissions, providerName, model, cwd } = options; const mcpTiers = options.mcpTiers ?? createMcpToolPermissionRegistry(); const resolvedCwd = cwd ?? process.cwd(); + const livePathGrants: PathGrant[] = [...(options.getInitialPathGrants?.() ?? [])]; const pathRestriction = createPathRestriction( resolvedCwd, options.rootsProvider ?? createWorktreeRootsProvider(resolvedCwd), + undefined, + () => livePathGrants, ); const isRestricted = pathRestriction.isRestricted; let auto = options.auto; @@ -592,6 +603,16 @@ export function createPermissionGate(options: PermissionGateOptions): Permission mcpTiers.removeToolsForServer(serverName); }; + const addPathGrants = (grants: readonly PathGrant[]): void => { + if (grants.length === 0) return; + for (const g of grants) { + if (!livePathGrants.some((x) => x.path === g.path && x.kind === g.kind && x.mode === g.mode)) { + livePathGrants.push(g); + } + } + pathRestriction.invalidate(); + }; + return { evaluate, getApprovals: () => approvals, @@ -604,6 +625,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission auto = value; }, preApprove, + addPathGrants, registerMcpClient, unregisterMcpServer, }; diff --git a/src/permission/path-grants.test.ts b/src/permission/path-grants.test.ts new file mode 100644 index 000000000..ca1737b3e --- /dev/null +++ b/src/permission/path-grants.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + addProjectPathGrant, + getProjectPathGrants, + getProjectPathGrantsForCwd, + isPathCoveredByReadGrant, + mintPathGrant, + removeProjectPathGrant, + type PathGrant, +} from "./path-grants.js"; +import { projectKeyFor } from "../session/project-key.js"; +import type { Settings } from "../config/settings.js"; + +function emptySettings(): Settings { + return { providers: {} }; +} + +describe("path-grants", () => { + describe("isPathCoveredByReadGrant", () => { + test("file grant covers exact file only", async () => { + const tmp = await mkdtemp(join(tmpdir(), "pg-cover-")); + try { + const file = join(tmp, "a.txt"); + await writeFile(file, "a"); + const sibling = join(tmp, "b.txt"); + await writeFile(sibling, "b"); + const grants: PathGrant[] = [mintPathGrant(file, "file")]; + + expect(isPathCoveredByReadGrant(file, grants)).toBe(true); + expect(isPathCoveredByReadGrant(sibling, grants)).toBe(false); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); + + test("dir grant covers nested paths", async () => { + const tmp = await mkdtemp(join(tmpdir(), "pg-dir-")); + try { + const nested = join(tmp, "sub", "deep", "x.txt"); + await mkdir(join(tmp, "sub", "deep"), { recursive: true }); + await writeFile(nested, "x"); + const grants: PathGrant[] = [mintPathGrant(tmp, "dir")]; + + expect(isPathCoveredByReadGrant(tmp, grants)).toBe(true); + expect(isPathCoveredByReadGrant(nested, grants)).toBe(true); + expect(isPathCoveredByReadGrant(join(tmpdir(), "sibling-not-granted"), grants)).toBe(false); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); + }); + + describe("addProjectPathGrant", () => { + test("adds a file grant and is idempotent", async () => { + const tmp = await mkdtemp(join(tmpdir(), "pg-add-")); + try { + const file = join(tmp, "a.txt"); + await writeFile(file, "a"); + const grant = mintPathGrant(file, "file"); + + const a = addProjectPathGrant(emptySettings(), "pk", grant); + const b = addProjectPathGrant(a, "pk", grant); + + expect(getProjectPathGrants(a, "pk")).toEqual([grant]); + expect(getProjectPathGrants(b, "pk")).toEqual([grant]); + expect(b).toBe(a); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); + + test("dir grant supersedes file grants under it", async () => { + const tmp = await mkdtemp(join(tmpdir(), "pg-supersede-")); + try { + const fileA = join(tmp, "a.txt"); + const fileB = join(tmp, "b.txt"); + await writeFile(fileA, "a"); + await writeFile(fileB, "b"); + + let s = addProjectPathGrant(emptySettings(), "pk", mintPathGrant(fileA, "file")); + s = addProjectPathGrant(s, "pk", mintPathGrant(fileB, "file")); + s = addProjectPathGrant(s, "pk", mintPathGrant(tmp, "dir")); + + const grants = getProjectPathGrants(s, "pk"); + expect(grants).toEqual([mintPathGrant(tmp, "dir")]); + + // Adding a file under the dir grant is a no-op. + const before = s; + const next = addProjectPathGrant(before, "pk", mintPathGrant(join(tmp, "c.txt"), "file")); + expect(next).toBe(before); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); + }); + + describe("removeProjectPathGrant", () => { + test("removes by path and drops empty projectKey", async () => { + const tmp = await mkdtemp(join(tmpdir(), "pg-rm-")); + try { + const file = join(tmp, "a.txt"); + await writeFile(file, "a"); + const grant = mintPathGrant(file, "file"); + + const seeded = addProjectPathGrant(emptySettings(), "pk", grant); + expect(seeded.projectPathGrants?.["pk"]).toHaveLength(1); + + const after = removeProjectPathGrant(seeded, "pk", file); + expect(after.projectPathGrants ?? {}).toEqual({}); + // Removing a non-existent path is a no-op. + expect(removeProjectPathGrant(after, "pk", join(tmp, "missing.txt"))).toBe(after); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); + }); + + describe("getProjectPathGrantsForCwd", () => { + test("reads grants via projectKeyFor(cwd)", async () => { + const tmp = await mkdtemp(join(tmpdir(), "pg-cwd-")); + try { + const file = join(tmp, "outside.txt"); + await writeFile(file, "x"); + const grant = mintPathGrant(file, "file"); + + // Realpath the cwd so the project key matches what projectKeyFor would compute. + const realCwd = realpathSync(tmp); + const seeded = addProjectPathGrant(emptySettings(), projectKeyFor(realCwd), grant); + + expect(getProjectPathGrantsForCwd(seeded, realCwd)).toEqual([grant]); + expect(getProjectPathGrantsForCwd(seeded, join(tmpdir(), "different-project"))).toEqual([]); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/src/permission/path-grants.ts b/src/permission/path-grants.ts new file mode 100644 index 000000000..8a01a4bb5 --- /dev/null +++ b/src/permission/path-grants.ts @@ -0,0 +1,134 @@ +import { realpathSync } from "node:fs"; +import { dirname, join, sep } from "node:path"; +import type { Settings } from "../config/settings.js"; +import { projectKeyFor } from "../session/project-key.js"; + +// Read-only path grants for outside-workspace files and directories, keyed by +// project identity in global settings. Never written under the git worktree so +// teammates do not inherit another operator's grants. + +export type PathGrant = { + path: string; + mode: "read"; + kind: "file" | "dir"; +}; + +// Grant targets may not exist yet (e.g. adding a file grant under a dir grant +// for a path the operator has not created). Realpath the nearest existing +// ancestor and rejoin the missing tail so macOS symlink roots +// (/tmp -> /private/tmp, /var -> /private/var) still compare equal. +function realpathOr(path: string): string { + try { + return realpathSync(path); + } catch { + const parent = dirname(path); + if (parent === path) return path; + const tailStart = parent.endsWith(sep) ? parent.length : parent.length + 1; + return join(realpathOr(parent), path.slice(tailStart)); + } +} + +export function isPathCoveredByReadGrant( + absPath: string, + grants: readonly PathGrant[], +): boolean { + const real = realpathOr(absPath); + for (const grant of grants) { + const root = realpathOr(grant.path); + if (grant.kind === "file") { + if (real === root) return true; + continue; + } + if (real === root || real.startsWith(root + sep)) return true; + } + return false; +} + +export function getProjectPathGrants( + settings: Settings | null | undefined, + projectKey: string, +): PathGrant[] { + const list = settings?.projectPathGrants?.[projectKey]; + return list === undefined ? [] : [...list]; +} + +export function getProjectPathGrantsForCwd( + settings: Settings | null | undefined, + cwd: string, +): PathGrant[] { + return getProjectPathGrants(settings, projectKeyFor(cwd)); +} + +export function addProjectPathGrant( + settings: Settings, + projectKey: string, + grant: PathGrant, +): Settings { + const normalized: PathGrant = { + path: realpathOr(grant.path), + mode: "read", + kind: grant.kind, + }; + const current = getProjectPathGrants(settings, projectKey); + if (current.some((g) => g.path === normalized.path && g.kind === normalized.kind)) { + return settings; + } + // Prefer a dir grant over a redundant file grant under the same root. + const withoutCovered = + normalized.kind === "dir" + ? current.filter( + (g) => + !( + g.kind === "file" && + (g.path === normalized.path || g.path.startsWith(normalized.path + sep)) + ), + ) + : current; + if ( + normalized.kind === "file" && + withoutCovered.some( + (g) => + g.kind === "dir" && + (normalized.path === g.path || normalized.path.startsWith(g.path + sep)), + ) + ) { + return settings; + } + return { + ...settings, + projectPathGrants: { + ...settings.projectPathGrants, + [projectKey]: [...withoutCovered, normalized], + }, + }; +} + +export function removeProjectPathGrant( + settings: Settings, + projectKey: string, + path: string, +): Settings { + const target = realpathOr(path); + const current = getProjectPathGrants(settings, projectKey); + const next = current.filter((g) => g.path !== target); + if (next.length === current.length) return settings; + const projectPathGrants = { ...settings.projectPathGrants }; + if (next.length === 0) { + delete projectPathGrants[projectKey]; + } else { + projectPathGrants[projectKey] = next; + } + const empty = Object.keys(projectPathGrants).length === 0; + if (empty) { + const { projectPathGrants: _drop, ...rest } = settings; + return rest; + } + return { ...settings, projectPathGrants }; +} + +export function mintPathGrant( + absPath: string, + kind: "file" | "dir", +): PathGrant { + return { path: realpathOr(absPath), mode: "read", kind }; +} diff --git a/src/permission/path-restriction.test.ts b/src/permission/path-restriction.test.ts index 6bd0f075f..f6cf4d71f 100644 --- a/src/permission/path-restriction.test.ts +++ b/src/permission/path-restriction.test.ts @@ -1,10 +1,11 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdir, rm } from "node:fs/promises"; +import { mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createPathRestriction } from "./path-restriction.js"; import { projectSessionsRoot } from "../session/project-key.js"; +import { mintPathGrant } from "./path-grants.js"; let cwd = ""; let home = ""; @@ -47,3 +48,42 @@ test("workspace-relative paths are unrestricted", () => { expect(r.isRestricted("src/index.ts", false)).toBe(false); expect(r.isRestricted("src/index.ts", true)).toBe(false); }); + +test("file path grant allows reads only", async () => { + const outside = join(home, "granted-file.txt"); + await writeFile(outside, "x"); + const grants = [mintPathGrant(outside, "file")]; + const r = createPathRestriction(cwd, () => [], home, () => grants); + + expect(r.isRestricted(outside, false)).toBe(false); + expect(r.isRestricted(outside, true)).toBe(true); + expect(r.isRestricted(join(home, "other.txt"), false)).toBe(true); +}); + +test("dir path grant allows nested reads; writes still restricted", async () => { + const outsideDir = join(home, "granted-dir"); + const nested = join(outsideDir, "sub", "x.txt"); + await mkdir(join(outsideDir, "sub"), { recursive: true }); + await writeFile(nested, "x"); + const grants = [mintPathGrant(outsideDir, "dir")]; + const r = createPathRestriction(cwd, () => [], home, () => grants); + + expect(r.isRestricted(outsideDir, false)).toBe(false); + expect(r.isRestricted(nested, false)).toBe(false); + expect(r.isRestricted(nested, true)).toBe(true); + expect(r.isRestricted(join(home, "sibling"), false)).toBe(true); +}); + +test("invalidate refreshes grant coverage after live grant list changes", async () => { + const outside = join(home, "late-grant.txt"); + await writeFile(outside, "x"); + const live: ReturnType[] = []; + const r = createPathRestriction(cwd, () => [], home, () => live); + + expect(r.isRestricted(outside, false)).toBe(true); + live.push(mintPathGrant(outside, "file")); + // Cached restricted result would stick without invalidate. + expect(r.isRestricted(outside, false)).toBe(true); + r.invalidate(); + expect(r.isRestricted(outside, false)).toBe(false); +}); diff --git a/src/permission/path-restriction.ts b/src/permission/path-restriction.ts index 73cf07ebd..71226b649 100644 --- a/src/permission/path-restriction.ts +++ b/src/permission/path-restriction.ts @@ -3,6 +3,10 @@ import { dirname, join, resolve, sep } from "node:path"; import { homedir } from "node:os"; import type { RootsProvider } from "./worktree-roots.js"; import { projectSessionsRoot } from "../session/project-key.js"; +import { + isPathCoveredByReadGrant, + type PathGrant, +} from "./path-grants.js"; // Paths the agent should not touch without explicit operator approval, even // though the read tools are otherwise allow-tier and write/edit auto-allow in @@ -10,7 +14,8 @@ import { projectSessionsRoot } from "../session/project-key.js"; // // - anything outside the session workspace (the primary cwd and its // registered worktrees) — autonomy is scoped to the workspace boundary, not -// the whole filesystem. Restricted for both reads and writes. +// the whole filesystem. Restricted for both reads and writes, unless the +// operator has registered a project path grant (read-only) for that path. // - writes under the session state root (global // ~/.corbits/projects//… and legacy in-repo .agent-state) — // the agent should not rewrite its own session history without operator @@ -24,9 +29,11 @@ import { projectSessionsRoot } from "../session/project-key.js"; // files (.env, keys, certs); shell commands that only mention those paths // require operator approval instead of a hard deny. Results are cached per // resolved path and access mode because the gate consults this on every tool -// call with a path argument. +// call with a path argument. Call invalidate() after path grants change so +// mid-session grants take effect. export type PathRestriction = { isRestricted: (path: string, isWrite: boolean) => boolean; + invalidate: () => void; }; const LEGACY_STATE_DIR = ".agent-state"; @@ -72,6 +79,9 @@ const inKnownRoots = (real: string, roots: readonly string[]): boolean => // allowlist rather than rejected outright: the raw path alone can't tell a // legitimate sibling worktree from a genuinely foreign directory, and both // resolve to `../something` from inside a worktree checkout. +// +// Path grants are not part of this allowlist — grants only relax isRestricted +// for reads; resolveWorkspacePath stays a pure workspace-boundary check. export function resolveWorkspacePath( cwd: string, path: string, @@ -105,10 +115,15 @@ function underRoot(abs: string, root: string): boolean { // // `home` is injectable so tests can pin the global state root without // mutating process env. +// +// `getReadGrants` supplies operator-registered read-only path grants for this +// project. Grants are consulted only for reads (`!isWrite`); writes under a +// granted tree still ask. export function createPathRestriction( cwd: string, rootsProvider: RootsProvider = () => [], home: string = homedir(), + getReadGrants: () => readonly PathGrant[] = () => [], ): PathRestriction { const legacyStateDir = resolve(cwd, LEGACY_STATE_DIR); const globalStateDir = projectSessionsRoot(cwd, home); @@ -118,6 +133,9 @@ export function createPathRestriction( underRoot(abs, legacyStateDir) || underRoot(abs, globalStateDir); return { + invalidate: () => { + cache.clear(); + }, isRestricted: (path: string, isWrite: boolean): boolean => { const abs = resolve(cwd, path); const cacheKey = `${isWrite ? "w" : "r"}:${abs}`; @@ -132,8 +150,17 @@ export function createPathRestriction( } const outsideWorkspace = resolveWorkspacePath(cwd, path, rootsProvider) === undefined; - cache.set(cacheKey, outsideWorkspace); - return outsideWorkspace; + if (!outsideWorkspace) { + cache.set(cacheKey, false); + return false; + } + // Read grants expand the outside-workspace allow surface for reads only. + if (!isWrite && isPathCoveredByReadGrant(abs, getReadGrants())) { + cache.set(cacheKey, false); + return false; + } + cache.set(cacheKey, true); + return true; }, }; } diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 2cb039d51..51fba6555 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -542,6 +542,7 @@ export function App({ eventEmitter, setGatePending: state.setGatePending, activationBlocked: approvalActivationBlocked, + ...(permissionsAdmin !== undefined ? { addPathGrants: permissionsAdmin.addPathGrants } : {}), }); useEffect(() => { @@ -789,6 +790,7 @@ if (workPrimary && !wasWorkPrimary.current) { forceRender, sendMessageRef, requestStopRef, + globalSettingsPath, }); const { getCostSummary, commandContext } = useCommandContext({ diff --git a/src/tui/hooks/use-gates.ts b/src/tui/hooks/use-gates.ts index 23881c133..696091a39 100644 --- a/src/tui/hooks/use-gates.ts +++ b/src/tui/hooks/use-gates.ts @@ -4,6 +4,7 @@ import type { Approval, ApprovalOutcome, PermissionRequest } from "../../permiss import type { OperatorResult } from "../../agent/tools.js"; import type { PlanStep } from "../use-stream.js"; import { goalApprovalTimeoutMessage } from "../../permission/goal-approval-timeout.js"; +import type { PathGrant } from "../../permission/path-grants.js"; export type PlanGateEvent = { @@ -57,6 +58,8 @@ export type GateController = { selectOperator: (id: number, result: OperatorResult) => void; resolvePermission: (id: number, outcome: ApprovalOutcome) => void; resetGates: () => void; + /** Forward mid-session @mention path grants into the live permission gate. */ + addPathGrants: (grants: readonly PathGrant[]) => void; }; // Fired synchronously by the permission gate right after a grant is minted @@ -73,6 +76,8 @@ export type UseGatesArgs = { eventEmitter: EventEmitter; setGatePending: (pending: boolean) => void; activationBlocked?: boolean; + /** Delegates to PermissionGate.addPathGrants when wired by the runner. */ + addPathGrants?: (grants: readonly PathGrant[]) => void; }; type PlanQueueEntry = { @@ -136,6 +141,7 @@ export function useGates({ eventEmitter, setGatePending, activationBlocked = false, + addPathGrants: addPathGrantsArg, }: UseGatesArgs): GateController { const [activeApproval, setActiveApproval] = useState(null); const [queuedApprovals, setQueuedApprovals] = useState([]); @@ -360,5 +366,6 @@ export function useGates({ selectOperator: settleOperator, resolvePermission: settlePermission, resetGates: drainQueue, + addPathGrants: addPathGrantsArg ?? (() => {}), }; } diff --git a/src/tui/hooks/use-message-pipeline.ts b/src/tui/hooks/use-message-pipeline.ts index 4104fcf2b..b0b6ac0a8 100644 --- a/src/tui/hooks/use-message-pipeline.ts +++ b/src/tui/hooks/use-message-pipeline.ts @@ -12,6 +12,15 @@ import { findImagePathMentions, imageAttachmentFromPath, type PendingImageAttach import { isExitCommand } from "../exit-command.js"; import { CodexAuthError } from "../../auth/codex/session.js"; import { XaiAuthError } from "../../auth/xai/session.js"; +import { + loadGlobalSettingsWriteBase, + saveGlobalSettings, +} from "../../config/settings.js"; +import { projectKeyFor } from "../../session/project-key.js"; +import { + addProjectPathGrant, + getProjectPathGrantsForCwd, +} from "../../permission/path-grants.js"; import type { ScrollController } from "./use-scroll.js"; import type { GateController } from "./use-gates.js"; import type { SubAgentSessionStore } from "../../subagent/index.js"; @@ -50,6 +59,10 @@ export type UseMessagePipelineArgs = { forceRender: Dispatch>; sendMessageRef: { current: (message: OutboundUserMessage) => void }; requestStopRef: { current: () => void }; + // Path to the global settings file. Used to read existing project path + // grants before @mention resolution and to persist any new grants the + // resolver mints. Optional only for tests that don't exercise grants. + globalSettingsPath?: string; }; export type MessagePipelineController = { @@ -103,6 +116,7 @@ export function useMessagePipeline({ forceRender, sendMessageRef, requestStopRef, + globalSettingsPath, }: UseMessagePipelineArgs): MessagePipelineController { // One controller per in-flight send so Ctrl+C / double-Esc can abort the // active run. Aborting rejects the send promise; the reactor's current cycle @@ -292,7 +306,30 @@ export function useMessagePipeline({ attachments.push(result.attachment); text = text.replace(mention.raw, `[Attached image: ${result.attachment.name}]`); } - return { text: await resolveAtMentions(text, cwd), attachments }; + // Load existing project grants so the resolver skips re-minting paths the + // operator has already granted. Persist any newly minted grants back to the + // global settings file and forward them to the live gate so subsequent reads + // stop prompting without a restart. Skipped when globalSettingsPath is not + // provided (tests that don't exercise grants). + const baseSettings = + globalSettingsPath !== undefined ? await loadGlobalSettingsWriteBase(globalSettingsPath) : null; + const existing = + baseSettings !== null ? getProjectPathGrantsForCwd(baseSettings, cwd) : []; + const { text: resolvedText, grants } = await resolveAtMentions(text, cwd, { + existingGrants: existing, + }); + if (grants.length > 0) { + gates.addPathGrants(grants); + if (baseSettings !== null && globalSettingsPath !== undefined) { + const projectKey = projectKeyFor(cwd); + const next = grants.reduce( + (s, g) => addProjectPathGrant(s, projectKey, g), + baseSettings, + ); + await saveGlobalSettings(globalSettingsPath, next); + } + } + return { text: resolvedText, attachments }; }; const handleSend = (message: string) => { diff --git a/src/tui/mention-resolution.ts b/src/tui/mention-resolution.ts index 901d0af08..61f524bb2 100644 --- a/src/tui/mention-resolution.ts +++ b/src/tui/mention-resolution.ts @@ -1,8 +1,9 @@ import { readFile, opendir, realpath, stat } from "node:fs/promises"; import { resolve, isAbsolute } from "node:path"; import { isSensitivePath } from "../plugins/secret-guard-plugin.js"; -import { createPathRestriction, type PathRestriction } from "../permission/path-restriction.js"; +import { resolveWorkspacePath } from "../permission/path-restriction.js"; import { createWorktreeRootsProvider } from "../permission/worktree-roots.js"; +import { mintPathGrant, isPathCoveredByReadGrant, type PathGrant } from "../permission/path-grants.js"; const MAX_MENTION_FILE_BYTES = 200_000; const MAX_MENTION_TOTAL_BYTES = 400_000; @@ -10,29 +11,6 @@ const MAX_MENTION_COUNT = 5; const MAX_DIRECTORY_SUMMARY_ENTRIES = 200; const MAX_DIRECTORY_NAMES = 20; -async function resolveMentionPath( - cwd: string, - path: string, - pathRestriction: PathRestriction, -): Promise<{ ok: true; abs: string } | { ok: false; reason: string }> { - if (path === "~" || path.startsWith("~/")) { - return { ok: false, reason: "home-relative paths are not supported" }; - } - - let abs: string; - try { - abs = await realpath(isAbsolute(path) ? path : resolve(cwd, path)); - } catch { - return { ok: false, reason: "not found" }; - } - - if (pathRestriction.isRestricted(abs, false)) { - return { ok: false, reason: "outside workspace" }; - } - - return { ok: true, abs }; -} - async function summarizeDir(abs: string): Promise { let scanned = 0; let files = 0; @@ -58,7 +36,13 @@ async function summarizeDir(abs: string): Promise { return parts.length > 0 ? parts.join(", ") : "empty directory"; } -export async function resolveAtMentions(message: string, cwd: string): Promise { +export type AtMentionResult = { text: string; grants: PathGrant[] }; + +export async function resolveAtMentions( + message: string, + cwd: string, + opts?: { existingGrants?: readonly PathGrant[] }, +): Promise { const pattern = /@("([^"]+)"|(\S+))/g; const mentions: Array<{ full: string; path: string }> = []; let m: RegExpExecArray | null; @@ -66,14 +50,16 @@ export async function resolveAtMentions(message: string, cwd: string): Promise 0) mentions.push({ full: m[0], path }); } - if (mentions.length === 0) return message; + if (mentions.length === 0) return { text: message, grants: [] }; - // Mirrors the permission gate's own containment check (see gate.ts): the - // gate resolves paths against cwd plus every registered git worktree of - // this session, so an @mention into a sibling worktree must resolve the - // same way rather than being wrongly rejected as an escape. - const pathRestriction = createPathRestriction(cwd, createWorktreeRootsProvider(cwd)); + // Mirrors the permission gate's workspace-boundary check: cwd plus every + // registered git worktree of this session. Outside-but-grantable paths are + // handled below via resolveWorkspacePath + grants, NOT pathRestriction.isRestricted + // (which would hard-deny them before a grant could mint). + const rootsProvider = createWorktreeRootsProvider(cwd); + const existingGrants = opts?.existingGrants ?? []; const replacements: Array<{ full: string; replacement: string }> = []; + const newGrants: PathGrant[] = []; let totalBytes = 0; for (const [index, { full, path }] of mentions.entries()) { @@ -81,23 +67,35 @@ export async function resolveAtMentions(message: string, cwd: string): Promise replacement); + text = text.replace(full, () => replacement); } - return result; + return { text, grants: newGrants }; } diff --git a/src/tui/runner.tsx b/src/tui/runner.tsx index 3555fdeca..d2c58ed5f 100644 --- a/src/tui/runner.tsx +++ b/src/tui/runner.tsx @@ -91,6 +91,7 @@ import { resolveAgentPluginProfiles } from "../plugins/agent-plugins.js"; import { createPermissionGate } from "../permission/gate.js"; import { createWorktreeRootsProvider } from "../permission/worktree-roots.js"; import { createPermissionsAdmin } from "../permission/admin.js"; +import { getProjectPathGrantsForCwd } from "../permission/path-grants.js"; import { DEFAULT_GOAL_APPROVAL_TIMEOUT_MS, goalApprovalTimeoutMessage, @@ -349,6 +350,7 @@ export async function runTUI(initialConfig: Config): Promise { approvals: seededApprovals, cwd: config.cwd, rootsProvider: createWorktreeRootsProvider(config.cwd), + getInitialPathGrants: () => getProjectPathGrantsForCwd(config.settings, config.cwd), providerName: config.providerName, model: config.model, requestApproval: createGateRequestApproval({ diff --git a/tests/unit/tui/at-mention-resolution.test.ts b/tests/unit/tui/at-mention-resolution.test.ts index 60579000c..b66bd3dfb 100644 --- a/tests/unit/tui/at-mention-resolution.test.ts +++ b/tests/unit/tui/at-mention-resolution.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "bun:test"; import { execFile } from "node:child_process"; +import { realpathSync } from "node:fs"; import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { resolveAtMentions } from "../../../src/tui/mention-resolution.js"; +import { mintPathGrant } from "../../../src/permission/path-grants.js"; const execFileAsync = promisify(execFile); @@ -21,9 +23,10 @@ describe("resolveAtMentions", () => { test("inlines small relative files", async () => { const dir = await fixture(); try { - const resolved = await resolveAtMentions("read @src/small.ts", dir); - expect(resolved).toContain("`src/small.ts`:"); - expect(resolved).toContain("export const value = 1;"); + const { text, grants } = await resolveAtMentions("read @src/small.ts", dir); + expect(text).toContain("`src/small.ts`:"); + expect(text).toContain("export const value = 1;"); + expect(grants).toEqual([]); } finally { await rm(dir, { recursive: true, force: true }); } @@ -32,9 +35,10 @@ describe("resolveAtMentions", () => { test("does not inline sensitive files", async () => { const dir = await fixture(); try { - const resolved = await resolveAtMentions("read @.env", dir); - expect(resolved).toContain("@.env (blocked: sensitive path)"); - expect(resolved).not.toContain("API_KEY=secret"); + const { text, grants } = await resolveAtMentions("read @.env", dir); + expect(text).toContain("@.env (blocked: sensitive path)"); + expect(text).not.toContain("API_KEY=secret"); + expect(grants).toEqual([]); } finally { await rm(dir, { recursive: true, force: true }); } @@ -43,9 +47,10 @@ describe("resolveAtMentions", () => { test("does not inline oversized files", async () => { const dir = await fixture(); try { - const resolved = await resolveAtMentions("read @large.txt", dir); - expect(resolved).toContain("@large.txt (blocked: file is too large"); - expect(resolved.length).toBeLessThan(500); + const { text, grants } = await resolveAtMentions("read @large.txt", dir); + expect(text).toContain("@large.txt (blocked: file is too large"); + expect(text.length).toBeLessThan(500); + expect(grants).toEqual([]); } finally { await rm(dir, { recursive: true, force: true }); } @@ -58,11 +63,12 @@ describe("resolveAtMentions", () => { await writeFile(join(dir, "two.txt"), "b".repeat(180_000)); await writeFile(join(dir, "three.txt"), "c".repeat(180_000)); - const resolved = await resolveAtMentions("read @one.txt @two.txt @three.txt", dir); - expect(resolved).toContain("`one.txt`:"); - expect(resolved).toContain("`two.txt`:"); - expect(resolved).toContain("@three.txt (blocked: total @mention content is too large"); - expect(resolved.length).toBeLessThan(400_500); + const { text, grants } = await resolveAtMentions("read @one.txt @two.txt @three.txt", dir); + expect(text).toContain("`one.txt`:"); + expect(text).toContain("`two.txt`:"); + expect(text).toContain("@three.txt (blocked: total @mention content is too large"); + expect(text.length).toBeLessThan(400_500); + expect(grants).toEqual([]); } finally { await rm(dir, { recursive: true, force: true }); } @@ -75,13 +81,13 @@ describe("resolveAtMentions", () => { await writeFile(join(dir, `small-${i}.txt`), `file ${i}\n`); } - const resolved = await resolveAtMentions( + const { text } = await resolveAtMentions( "read @small-0.txt @small-1.txt @small-2.txt @small-3.txt @small-4.txt @small-5.txt", dir, ); - expect(resolved).toContain("`small-4.txt`:"); - expect(resolved).toContain("@small-5.txt (blocked: too many @mentions"); - expect(resolved).not.toContain("file 5"); + expect(text).toContain("`small-4.txt`:"); + expect(text).toContain("@small-5.txt (blocked: too many @mentions"); + expect(text).not.toContain("file 5"); } finally { await rm(dir, { recursive: true, force: true }); } @@ -91,9 +97,10 @@ describe("resolveAtMentions", () => { const dir = await fixture(); try { const absolutePath = join(dir, "src", "small.ts"); - const resolved = await resolveAtMentions(`read @${absolutePath}`, dir); - expect(resolved).toContain(`\`${absolutePath}\`:`); - expect(resolved).toContain("export const value = 1;"); + const { text, grants } = await resolveAtMentions(`read @${absolutePath}`, dir); + expect(text).toContain(`\`${absolutePath}\`:`); + expect(text).toContain("export const value = 1;"); + expect(grants).toEqual([]); } finally { await rm(dir, { recursive: true, force: true }); } @@ -102,56 +109,139 @@ describe("resolveAtMentions", () => { test("inlines parent-directory paths", async () => { const dir = await fixture(); try { - const resolved = await resolveAtMentions("read @../src/small.ts", join(dir, "src")); - expect(resolved).toContain("`../src/small.ts`:"); - expect(resolved).toContain("export const value = 1;"); + const { text, grants } = await resolveAtMentions("read @../src/small.ts", join(dir, "src")); + expect(text).toContain("`../src/small.ts`:"); + expect(text).toContain("export const value = 1;"); + expect(grants).toEqual([]); } finally { await rm(dir, { recursive: true, force: true }); } }); - test("blocks symlinks that resolve outside the workspace", async () => { + test("expands symlinked outside-workspace files and mints a read grant", async () => { const dir = await fixture(); const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-")); try { await writeFile(join(outside, "outside.txt"), "outside content\n"); await symlink(outside, join(dir, "escape")); - const resolved = await resolveAtMentions("read @escape/outside.txt", dir); - expect(resolved).toContain("@escape/outside.txt (blocked: outside workspace)"); - expect(resolved).not.toContain("outside content"); + const { text, grants } = await resolveAtMentions("read @escape/outside.txt", dir); + expect(text).toContain("outside content"); + expect(grants.length).toBe(1); + expect(grants[0]?.kind).toBe("file"); + expect(grants[0]?.mode).toBe("read"); + expect(grants[0]?.path).toBe(realpathSync(join(outside, "outside.txt"))); } finally { await rm(dir, { recursive: true, force: true }); await rm(outside, { recursive: true, force: true }); } }); - test("blocks absolute paths outside the workspace", async () => { + test("expands absolute outside-workspace paths and mints a read grant", async () => { const dir = await fixture(); const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-")); try { const outsideFile = join(outside, "outside.txt"); await writeFile(outsideFile, "outside content\n"); - const resolved = await resolveAtMentions(`read @${outsideFile}`, dir); - expect(resolved).toContain(`@${outsideFile} (blocked: outside workspace)`); - expect(resolved).not.toContain("outside content"); + const { text, grants } = await resolveAtMentions(`read @${outsideFile}`, dir); + expect(text).toContain("outside content"); + expect(grants.length).toBe(1); + expect(grants[0]).toEqual({ + path: realpathSync(outsideFile), + mode: "read", + kind: "file", + }); } finally { await rm(dir, { recursive: true, force: true }); await rm(outside, { recursive: true, force: true }); } }); - test("blocks parent-traversal paths that escape the workspace", async () => { + test("expands parent-traversal outside-workspace paths and mints a read grant", async () => { const dir = await fixture(); const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-")); try { await writeFile(join(outside, "outside.txt"), "outside content\n"); const traversal = `../../${outside.split("/").pop() ?? ""}/outside.txt`; - const resolved = await resolveAtMentions(`read @${traversal}`, join(dir, "src")); - expect(resolved).toContain(`@${traversal} (blocked: outside workspace)`); - expect(resolved).not.toContain("outside content"); + const { text, grants } = await resolveAtMentions(`read @${traversal}`, join(dir, "src")); + expect(text).toContain("outside content"); + expect(grants.length).toBe(1); + expect(grants[0]?.kind).toBe("file"); + expect(grants[0]?.path).toBe(realpathSync(join(outside, "outside.txt"))); + } finally { + await rm(dir, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + + test("does not re-mint grants for paths covered by existingGrants", async () => { + const dir = await fixture(); + const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-")); + try { + const outsideFile = join(outside, "outside.txt"); + await writeFile(outsideFile, "outside content\n"); + + const { text, grants } = await resolveAtMentions(`read @${outsideFile}`, dir, { + existingGrants: [mintPathGrant(outsideFile, "file")], + }); + expect(text).toContain("outside content"); + expect(grants).toEqual([]); + } finally { + await rm(dir, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + + test("mints a dir grant for outside-workspace directories", async () => { + const dir = await fixture(); + const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-")); + try { + await writeFile(join(outside, "a.txt"), "a\n"); + await mkdir(join(outside, "sub")); + + const { text, grants } = await resolveAtMentions(`read @${outside}`, dir); + expect(text).toContain("directory - "); + expect(grants.length).toBe(1); + expect(grants[0]?.kind).toBe("dir"); + expect(grants[0]?.path).toBe(realpathSync(outside)); + } finally { + await rm(dir, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + + test("still blocks sensitive outside-workspace paths", async () => { + const dir = await fixture(); + const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-")); + try { + const outsideEnv = join(outside, ".env"); + await writeFile(outsideEnv, "API_KEY=secret\n"); + + const { text, grants } = await resolveAtMentions(`read @${outsideEnv}`, dir); + expect(text).toContain("(blocked: sensitive path)"); + expect(text).not.toContain("API_KEY=secret"); + expect(grants).toEqual([]); + } finally { + await rm(dir, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + + test("does not grant when total content limit blocks the read", async () => { + const dir = await fixture(); + const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-")); + try { + await writeFile(join(dir, "one.txt"), "a".repeat(180_000)); + await writeFile(join(dir, "two.txt"), "b".repeat(180_000)); + const outsideFile = join(outside, "big.txt"); + await writeFile(outsideFile, "c".repeat(180_000)); + + const { grants } = await resolveAtMentions(`read @one.txt @two.txt @${outsideFile}`, dir); + // First two inlined in-workspace (no grant); third blocked by total cap + // before any grant is minted (mint happens just before readFile). + expect(grants).toEqual([]); } finally { await rm(dir, { recursive: true, force: true }); await rm(outside, { recursive: true, force: true }); @@ -172,9 +262,10 @@ describe("resolveAtMentions", () => { await execFileAsync("git", ["worktree", "add", "-b", "sibling", worktree], { cwd: repo }); await writeFile(join(worktree, "shared.ts"), "export const shared = true;\n"); - const resolved = await resolveAtMentions(`read @${join(worktree, "shared.ts")}`, repo); - expect(resolved).toContain(`\`${join(worktree, "shared.ts")}\`:`); - expect(resolved).toContain("export const shared = true;"); + const { text, grants } = await resolveAtMentions(`read @${join(worktree, "shared.ts")}`, repo); + expect(text).toContain(`\`${join(worktree, "shared.ts")}\`:`); + expect(text).toContain("export const shared = true;"); + expect(grants).toEqual([]); } finally { await execFileAsync("git", ["worktree", "remove", "--force", worktree]).catch(() => {}); await rm(repo, { recursive: true, force: true }); @@ -187,9 +278,10 @@ describe("resolveAtMentions", () => { try { await symlink(join(dir, ".env"), join(dir, "looks-safe.txt")); - const resolved = await resolveAtMentions("read @looks-safe.txt", dir); - expect(resolved).toContain("@looks-safe.txt (blocked: sensitive path)"); - expect(resolved).not.toContain("API_KEY=secret"); + const { text, grants } = await resolveAtMentions("read @looks-safe.txt", dir); + expect(text).toContain("@looks-safe.txt (blocked: sensitive path)"); + expect(text).not.toContain("API_KEY=secret"); + expect(grants).toEqual([]); } finally { await rm(dir, { recursive: true, force: true }); }