From 12ace6b0f2c6b964b8a9d90565218636679dfb53 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 22 Oct 2026 20:55:54 +0800 Subject: [PATCH 1/2] emrg: sandbox grants are typed, and the defaults arrive without a command The writable surface was list[str]: an object had a path and nothing else, so its kind could not be stated and each enforcement dialect asked the disk for it (seatbelt_profile_args chose subpath or literal with os.path.isdir at profile-build time, so a name granted as one file widened to the tree beneath it the day that name became a directory), and an object that is not a path could not be named at all. A grant is a (kind, target, source) triple now: emrg/sandbox/grants.py holds the model, roots.py keeps the one derivation (stored_grants for display, named_grants for what a dialect may act on, granted_grants for the enforceable surface and writable_roots for readers that need only paths), and the kind is written once by whoever names the grant instead of being re-decided per dialect. A default manifest supplies the grants a tool needs so a host who types nothing still has them writable; each entry is shown with its source and withdrawn by one command, which is the line between this and the deployment constant host decision D5 deleted. A registry grant is modelled but no platform enforces that kind yet, so the derivation refuses it by name rather than dropping it. --- Agent.md | 2 +- emrg/client/app.py | 27 +- .../src/components/DialogHost.test.tsx | 3 + .../renderer/src/components/DialogHost.tsx | 1 + .../components/SandboxRootsDialog.test.tsx | 30 ++ .../src/components/SandboxRootsDialog.tsx | 55 ++- .../gui/renderer/src/lib/daemonBridge.test.ts | 59 +++ emrg/gui/renderer/src/lib/daemonBridge.ts | 72 ++- emrg/gui/renderer/src/lib/i18n-dicts.ts | 4 + emrg/gui/renderer/src/lib/i18n.test.ts | 6 +- emrg/sandbox/fence.py | 11 +- emrg/sandbox/grants.py | 320 +++++++++++++ emrg/sandbox/policy.py | 117 ++++- emrg/sandbox/providers/darwin.py | 24 +- emrg/sandbox/providers/linux.py | 5 +- emrg/sandbox/providers/win32.py | 25 +- emrg/sandbox/roots.py | 321 +++++++++---- emrg/server/daemon.py | 239 ++++++++-- emrg/session.py | 137 ++++-- emrg/tools/bash_tool_v2.py | 3 +- emrg/tools/edit_tool.py | 4 +- emrg/tools/pwsh_tool_v2.py | 3 +- emrg/tools/write_tool.py | 4 +- tests/conftest.py | 54 +++ tests/test_bash_v2_win32.py | 21 +- tests/test_pwsh_tool_v2.py | 9 +- tests/test_sandbox_extra_roots.py | 155 +++++++ tests/test_sandbox_grants.py | 428 ++++++++++++++++++ tests/test_session_sandbox.py | 3 +- tests/test_shell_tool_mount.py | 15 +- 30 files changed, 1912 insertions(+), 245 deletions(-) create mode 100644 emrg/sandbox/grants.py create mode 100644 tests/test_sandbox_grants.py diff --git a/Agent.md b/Agent.md index f7073d53c..597c14be7 100644 --- a/Agent.md +++ b/Agent.md @@ -55,7 +55,7 @@ One line per command: what it answers. Run the daemon in the foreground with `py - Python: `uv run pytest tests/ -v` — the number is **measured, never stored**: `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (168: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 11 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (729: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 56 daemonBridge + 9 DaemonBridgeProvider + 49 Shell + 24 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog) +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (734: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 59 daemonBridge + 9 DaemonBridgeProvider + 49 Shell + 24 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog) - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each asks what no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (a pair, or a whole order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` - Post-merge audit: `uv run --no-sync python3 scripts/check-merge-landed.py ` (did the merge land a tree its reviews named) diff --git a/emrg/client/app.py b/emrg/client/app.py index 90dd26b18..835d92836 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -2283,11 +2283,28 @@ async def _reconnect(): elif notice: chat.add("system", f"Sandbox roots: {notice}") elif op == "list": - listing = ( - ", ".join(current_roots) if current_roots - else "none — this session writes only inside its workspace" - ) - chat.add("system", f"Extra writable roots: {listing}") + # The whole surface, typed, with where each grant came + # from (issue #2492): the session's own grants and the + # installation's defaults. A default shown without its + # provenance would read as something the host named, and + # one not shown at all could not be withdrawn — which is + # the deployment constant host decision D5 deleted, back + # under a new name. + grants = [ + grant for grant in (data.get("grants") or []) + if isinstance(grant, dict) + ] + if grants: + listing = "\n ".join( + f"\n {grant.get('target')!s}" + f" [{grant.get('kind')!s}, {grant.get('source')!s}]" + for grant in grants + ) + else: + listing = ( + "none — this session writes only inside its workspace" + ) + chat.add("system", f"Writable grants:{listing}") elif op == "remove": chat.add("system", "Sandbox root removed.") else: diff --git a/emrg/gui/renderer/src/components/DialogHost.test.tsx b/emrg/gui/renderer/src/components/DialogHost.test.tsx index 1c27b2690..901a251de 100644 --- a/emrg/gui/renderer/src/components/DialogHost.test.tsx +++ b/emrg/gui/renderer/src/components/DialogHost.test.tsx @@ -40,6 +40,9 @@ function appState(over: Partial = {}): DaemonAppState { // 2026-09-30T09:30:16, GUI half) — absent is "the daemon has not said". sandboxBySid: {}, sandboxRootsBySid: {}, + // And the same surface typed, with provenance (issue #2492): absent is "the + // daemon has not said", which is what a renderer holds until the first frame. + sandboxGrantsBySid: {}, sandboxRootsNoticeBySid: {}, // Nothing has asked this session for its input history yet (issue #2349, // part 2/2) — absent is "not read", which is not the same as an empty list. diff --git a/emrg/gui/renderer/src/components/DialogHost.tsx b/emrg/gui/renderer/src/components/DialogHost.tsx index 7fbd01ca4..a8ba898db 100644 --- a/emrg/gui/renderer/src/components/DialogHost.tsx +++ b/emrg/gui/renderer/src/components/DialogHost.tsx @@ -554,6 +554,7 @@ export const DialogHost = forwardRef(function // 没有可问的主语,界面该显示的是「先开一个会话」而不是一个借来的列表。 // `?? null` 与 `sandboxBySid` 同一个语义:缺席 = daemon 还没说,不是空列表。 roots={sid ? (appState.sandboxRootsBySid[sid] ?? null) : null} + grants={sid ? (appState.sandboxGrantsBySid[sid] ?? null) : null} notice={sid ? (appState.sandboxRootsNoticeBySid[sid] ?? null) : null} onOp={onSandboxRootsOp} onDismiss={() => closeDialog("sandboxRoots")} diff --git a/emrg/gui/renderer/src/components/SandboxRootsDialog.test.tsx b/emrg/gui/renderer/src/components/SandboxRootsDialog.test.tsx index f92ab57d5..743657015 100644 --- a/emrg/gui/renderer/src/components/SandboxRootsDialog.test.tsx +++ b/emrg/gui/renderer/src/components/SandboxRootsDialog.test.tsx @@ -141,4 +141,34 @@ describe("SandboxRootsDialog", () => { await userEvent.click(screen.getByTestId("sandbox-roots-close")); expect(onDismiss).toHaveBeenCalledTimes(1); }); + + it("默认项也画出来,并标明来源;移除按钮发的就是它的 target(issue #2492)", async () => { + // 需求 3 的两半:默认项必须**可见**(否则主人不知道该撤回什么),也必须能**撤回**。 + // 两半是同一次渲染加同一次点击,所以钉在这里。 + const onOp = vi.fn(); + setup({ + onOp, + roots: ["/tmp/named"], + grants: [ + { kind: "directory", target: "/tmp/named", source: "session" }, + { kind: "directory", target: "/tmp/default", source: "default" }, + ], + }); + const rows = screen.getAllByTestId("sandbox-roots-row"); + expect(rows.map((row) => row.getAttribute("data-root"))).toEqual([ + "/tmp/named", + "/tmp/default", + ]); + const sources = screen.getAllByTestId("sandbox-roots-source"); + expect(sources.map((node) => node.textContent)).toEqual(["本会话命名", "安装默认"]); + + await userEvent.click(screen.getAllByTestId("sandbox-roots-remove")[1]); + expect(onOp).toHaveBeenCalledWith("remove", "/tmp/default"); + }); + + it("只有契约表、没有 grants 时仍按路径画(老状态不是空状态)", () => { + setup({ roots: ["/tmp/a"] }); + expect(screen.getAllByTestId("sandbox-roots-row")).toHaveLength(1); + expect(screen.queryAllByTestId("sandbox-roots-source")).toHaveLength(0); + }); }); diff --git a/emrg/gui/renderer/src/components/SandboxRootsDialog.tsx b/emrg/gui/renderer/src/components/SandboxRootsDialog.tsx index c967eae3d..009bfe867 100644 --- a/emrg/gui/renderer/src/components/SandboxRootsDialog.tsx +++ b/emrg/gui/renderer/src/components/SandboxRootsDialog.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useI18n } from "../lib/i18n"; import { Dialog } from "./Dialog"; -import type { SandboxRootsNotice } from "../lib/daemonBridge"; +import type { SandboxGrant, SandboxRootsNotice } from "../lib/daemonBridge"; /** * SandboxRootsDialog — the GUI entry to a session's host-named extra writable @@ -32,6 +32,16 @@ export interface SandboxRootsDialogProps { sid: string | null; /** The daemon's list, or null while nobody has asked. */ roots?: string[] | null; + /** + * The whole surface typed, with provenance (issue #2492), or null while nobody + * has asked. + * + * The rows come from here when it is present, and fall back to `roots` when it + * is not: the manifest's entries are grants the host never typed, and a dialog + * that listed only what they typed could not show them the default that is + * already in force — nor offer the one command that withdraws it. + */ + grants?: SandboxGrant[] | null; /** What the daemon said about the last op for this session, if anything. */ notice?: SandboxRootsNotice | null; onOp?: (op: "add" | "remove" | "list", path: string) => void; @@ -42,6 +52,7 @@ export function SandboxRootsDialog({ open, sid, roots = null, + grants = null, notice = null, onOp, onDismiss, @@ -63,6 +74,14 @@ export function SandboxRootsDialog({ }, [open, sid]); const canWrite = Boolean(sid); + // One row shape for both sources: a typed grant when the daemon sent one, and + // the path alone when this client is reading an older state. The *rows* are the + // display, so the two forms converge here rather than in two lists. + const rows: { target: string; source: string | null }[] = + grants === null + ? (roots ?? []).map((target) => ({ target, source: null })) + : grants.map((grant) => ({ target: grant.target, source: grant.source })); + const asked = grants !== null || roots !== null; const submitAdd = (): void => { const value = path.trim(); // An empty path is not sent: the daemon would refuse it, and a refusal the @@ -122,34 +141,46 @@ export function SandboxRootsDialog({
{t("sandboxRoots.noSession")}
- ) : roots === null ? ( + ) : !asked ? (
{t("dlg.loading")}
- ) : roots.length === 0 ? ( + ) : rows.length === 0 ? (
{t("sandboxRoots.empty")}
) : ( - roots.map((root) => ( + rows.map(({ target, source }) => (
- - {root} + + {target} + {source === null ? null : ( + + {source === "default" + ? t("sandboxRoots.source.default") + : t("sandboxRoots.source.session")} + + )} diff --git a/emrg/gui/renderer/src/lib/daemonBridge.test.ts b/emrg/gui/renderer/src/lib/daemonBridge.test.ts index 700234f8c..6db65caa4 100644 --- a/emrg/gui/renderer/src/lib/daemonBridge.test.ts +++ b/emrg/gui/renderer/src/lib/daemonBridge.test.ts @@ -594,6 +594,65 @@ describe("createDaemonBridge", () => { expect(bridge.store.get().sandboxRootsNoticeBySid["s1"]).toBeUndefined(); }); + it("sandbox_roots 帧里的 grants 带着来源进 store(issue #2492)", () => { + // 契约表(`roots`)与整张可写面(`grants`)是两个问题:前者是主人点名的那几项, + // 后者连安装自带的默认项一起,并且每项都带 `source`。GUI 两者都要——只画 `roots` + // 的话,默认项在 GUI 里既看不见也就无从撤回,正是 D5 删掉的那个部署常量换了名字。 + const { emit, bridge } = setup(); + emit({ + type: "sandbox_roots", + sid: "s1", + data: { + type: "sandbox_roots", session_id: "s1", op: "list", + roots: ["/tmp/named"], + grants: [ + { kind: "directory", target: "/tmp/named", source: "session" }, + { kind: "directory", target: "/tmp/default", source: "default" }, + ], + }, + }); + expect(bridge.store.get().sandboxGrantsBySid["s1"]).toEqual([ + { kind: "directory", target: "/tmp/named", source: "session" }, + { kind: "directory", target: "/tmp/default", source: "default" }, + ]); + expect(bridge.store.get().sandboxRootsBySid["s1"]).toEqual(["/tmp/named"]); + }); + + it("读不懂的 grant 条目被丢掉,而不是被猜成一项授权", () => { + // 容忍只朝一个方向:读不懂的条目消失,可写面变小。猜一个会描述出这次运行 + // 并不具备的边界。 + const { emit, bridge } = setup(); + emit({ + type: "sandbox_roots", + sid: "s1", + data: { + type: "sandbox_roots", session_id: "s1", op: "list", + grants: [{ kind: "directory", target: "/tmp/ok", source: "session" }, {}, "x", null], + }, + }); + expect(bridge.store.get().sandboxGrantsBySid["s1"]).toEqual([ + { kind: "directory", target: "/tmp/ok", source: "session" }, + ]); + }); + + it("resume_result 的 meta.sandbox_grants → 后开的会话也能看到带来源的整张面", () => { + const { emit, bridge } = setup(); + emit({ + type: "command_result", + sid: "s1", + data: { + type: "resume_result", session_id: "s1", + meta: { + sandbox_roots: ["/tmp/named"], + sandbox_grants: [{ kind: "file", target: "/tmp/named", source: "session" }], + }, + }, + }); + expect(bridge.store.get().sandboxGrantsBySid["s1"]).toEqual([ + { kind: "file", target: "/tmp/named", source: "session" }, + ]); + }); + it("resume_result 的 meta.sandbox_roots → 后开的会话也能看到清单([] 是「确实没有」)", () => { const { emit, bridge } = setup(); emit({ diff --git a/emrg/gui/renderer/src/lib/daemonBridge.ts b/emrg/gui/renderer/src/lib/daemonBridge.ts index 7a6ee8079..bed6764a2 100644 --- a/emrg/gui/renderer/src/lib/daemonBridge.ts +++ b/emrg/gui/renderer/src/lib/daemonBridge.ts @@ -166,6 +166,23 @@ export interface DaemonAppState { * different states, and only the second one should show a "loading" surface. */ sandboxRootsBySid: Record; + /** + * The same surface **typed, with provenance** (issue #2492): every grant the + * session may write under — the ones the host named and the ones the + * installation's default manifest supplies — each with its kind and its + * `source`. + * + * It exists beside `sandboxRootsBySid` rather than replacing it because they + * answer different questions: the path map is what this client shows as *the + * host's list* (and what `resume_result.meta.sandbox_roots` has always + * carried), while this one is the whole writable surface. Without it a default + * would be invisible in the GUI, and a grant nobody can see is one nobody can + * withdraw — the deployment constant host decision D5 deleted, back under a new + * name. + * + * An absent key means "the daemon has not said", exactly as above. + */ + sandboxGrantsBySid: Record; /** * The last word the daemon had on a roots op for this session: a refusal (the * rule it named), a "nothing changed" notice, or a plain success. @@ -299,6 +316,47 @@ export function resumeSandboxRoots(meta: unknown): string[] | null { return roots.filter((r): r is string => typeof r === "string"); } +/** + * One typed writable grant, as the daemon reports it (issue #2492). + * + * `kind` is the object's kind (`directory`, `file`, and `registry` for the kind + * the model carries but no dialect enforces yet), and `source` says who named it: + * `session` for a grant the host typed, `default` for an entry of the + * installation's manifest. The GUI shows both, because a default that cannot be + * told from a root the host named is one they cannot decide to withdraw. + */ +export interface SandboxGrant { + kind: string; + target: string; + source: string; +} + +/** + * The grants a `sandbox_roots` frame or a `resume_result` snapshot carries, or + * `null` when it carried none. + * + * Tolerant in one direction only, like the daemon's own reader: an entry that is + * not a grant is **dropped**, because a malformed entry that survived would + * describe a writable surface the run does not have. + */ +export function readSandboxGrants(value: unknown): SandboxGrant[] | null { + if (!Array.isArray(value)) return null; + return value.flatMap((entry): SandboxGrant[] => { + if (!entry || typeof entry !== "object") return []; + const { kind, target, source } = entry as Record; + if (typeof kind !== "string" || typeof target !== "string" || typeof source !== "string") { + return []; + } + return [{ kind, target, source }]; + }); +} + +/** The typed grants a resumed session's snapshot carries, or `null` (see above). */ +export function resumeSandboxGrants(meta: unknown): SandboxGrant[] | null { + const meta_ = meta as { sandbox_grants?: unknown } | null | undefined; + return readSandboxGrants(meta_?.sandbox_grants); +} + export function createDaemonAppStore(): SnapshotStore { return createSnapshotStore({ connected: false, @@ -320,6 +378,7 @@ export function createDaemonAppStore(): SnapshotStore { disconnectedBySid: {}, sandboxBySid: {}, sandboxRootsBySid: {}, + sandboxGrantsBySid: {}, sandboxRootsNoticeBySid: {}, inputHistoryBySid: {}, upgradeBanner: null, @@ -622,6 +681,10 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge { // by the TUI, by another GUI, or by this one before a restart — has no // other way to learn the list. const storedRoots = resumeSandboxRoots(resume.meta); + // And the same surface typed, so a session opened later shows the + // manifest's entries with their provenance rather than only the roots + // someone typed (issue #2492). + const storedGrants = resumeSandboxGrants(resume.meta); store.update((s) => ({ ...s, turnStartBySid: startedMs === null ? rest : { ...s.turnStartBySid, [k]: startedMs }, @@ -632,6 +695,9 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge { ...(storedRoots === null ? {} : { sandboxRootsBySid: { ...s.sandboxRootsBySid, [k]: storedRoots } }), + ...(storedGrants === null + ? {} + : { sandboxGrantsBySid: { ...s.sandboxGrantsBySid, [k]: storedGrants } }), })); break; } @@ -678,13 +744,14 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge { // state the session carries, which is why `roots` is read first and // unconditionally. const d = data as { - session_id?: string; op?: unknown; roots?: unknown; + session_id?: string; op?: unknown; roots?: unknown; grants?: unknown; error?: unknown; notice?: unknown; }; const k = KEY(d.session_id ?? sid); const roots = Array.isArray(d.roots) ? d.roots.filter((r): r is string => typeof r === "string") : null; + const grants = readSandboxGrants(d.grants); const error = typeof d.error === "string" && d.error ? d.error : ""; const notice = typeof d.notice === "string" && d.notice ? d.notice : ""; const op = typeof d.op === "string" ? d.op : ""; @@ -707,6 +774,9 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge { return { ...s, ...(roots === null ? {} : { sandboxRootsBySid: { ...s.sandboxRootsBySid, [k]: roots } }), + ...(grants === null + ? {} + : { sandboxGrantsBySid: { ...s.sandboxGrantsBySid, [k]: grants } }), sandboxRootsNoticeBySid: notices, }; }); diff --git a/emrg/gui/renderer/src/lib/i18n-dicts.ts b/emrg/gui/renderer/src/lib/i18n-dicts.ts index 8d422d211..82abf76a1 100644 --- a/emrg/gui/renderer/src/lib/i18n-dicts.ts +++ b/emrg/gui/renderer/src/lib/i18n-dicts.ts @@ -353,6 +353,8 @@ export const ZH_DICT: Record = { "sandboxRoots.pathPlaceholder": "文件或文件夹的绝对路径", "sandboxRoots.add": "添加", "sandboxRoots.remove": "移除", + "sandboxRoots.source.session": "本会话命名", + "sandboxRoots.source.default": "安装默认", "sandboxRoots.removeTitle": "移除 {path}", "sandboxRoots.empty": "本会话还没有额外可写根。", "sandboxRoots.noSession": "请先创建一个对话。", @@ -802,6 +804,8 @@ export const EN_DICT: Record = { "sandboxRoots.pathPlaceholder": "Absolute path to a file or folder", "sandboxRoots.add": "Add", "sandboxRoots.remove": "Remove", + "sandboxRoots.source.session": "named in this session", + "sandboxRoots.source.default": "installation default", "sandboxRoots.removeTitle": "Remove {path}", "sandboxRoots.empty": "This session has no extra writable roots yet.", "sandboxRoots.noSession": "Start a conversation first.", diff --git a/emrg/gui/renderer/src/lib/i18n.test.ts b/emrg/gui/renderer/src/lib/i18n.test.ts index 66c0c38ae..0e512be63 100644 --- a/emrg/gui/renderer/src/lib/i18n.test.ts +++ b/emrg/gui/renderer/src/lib/i18n.test.ts @@ -87,11 +87,11 @@ describe("词典完整性守卫(防漂移)", () => { // 2026-10-09T09:43:39 Part 2(a)——从 413 改为 429。 // 2026-10-11 issue #2113:device flow 的两个提示串 → 431。 // 2026-10-22 issue #2490:调度窗口 9 个键(含下限插值)→ 440。 - it("zh/en 各 440 个 key 且完全对齐", () => { + it("zh/en 各 442 个 key 且完全对齐", () => { const zhKeys = Object.keys(ZH_DICT); const enKeys = Object.keys(EN_DICT); - expect(zhKeys.length).toBe(440); - expect(enKeys.length).toBe(440); + expect(zhKeys.length).toBe(442); + expect(enKeys.length).toBe(442); expect(zhKeys.sort()).toEqual(enKeys.sort()); // DICTS 聚合结构 expect(Object.keys(DICTS)).toEqual(["zh", "en"]); diff --git a/emrg/sandbox/fence.py b/emrg/sandbox/fence.py index 1688f47b7..4e8a48227 100644 --- a/emrg/sandbox/fence.py +++ b/emrg/sandbox/fence.py @@ -50,6 +50,7 @@ import os +from emrg.sandbox.grants import GrantKindNotEnforced from emrg.sandbox.policy import DANGER_FULL_ACCESS, SandboxPolicy from emrg.sandbox.roots import canonical_path, writable_roots from emrg.tools.file_policy import protected_paths @@ -89,7 +90,15 @@ def file_refusal(file_path: str, policy: SandboxPolicy) -> str | None: # be allowed to make, never the reverse. The list stays in # ``emrg/tools/file_policy.py`` because the legacy scanner reads it too. return f"{policy.mode} sandbox: blocked write to protected daemon file {file_path!r}" - roots = writable_roots(policy) + try: + roots = writable_roots(policy) + except GrantKindNotEnforced as refused: + # A grant this platform cannot enforce is refused rather than dropped + # (issue #2492, requirement 5). The fence answers in text because its + # callers render a refusal to the model; the profile builders let the + # same exception propagate instead, so a confined command fails closed + # rather than running with a boundary nobody applied. + return f"{policy.mode} sandbox: {refused.reason}" if any(_is_within_root(target, root) for root in roots): return None granted = ( diff --git a/emrg/sandbox/grants.py b/emrg/sandbox/grants.py new file mode 100644 index 000000000..1944b62c8 --- /dev/null +++ b/emrg/sandbox/grants.py @@ -0,0 +1,320 @@ +"""The typed writable-grant model every enforcement dialect reads. + +Issue #2492 (rant 2026-10-22T20:12:00). The writable surface used to be a list of +**paths** and nothing else, so two things could not be said at all: + +* an object that is not a path. ``lark-cli`` keeps its renewing token in + ``HKCU\\Software\\LarkCli\\keychain`` on Windows, which no path operation + reaches, so a confined task there can read a message but cannot renew the + identity it read it with; +* a grant a host does not have to type again in every session. Named-by-hand + roots are per session (``policy.extra_roots``), so a tool that keeps state + outside the workspace needs the same ``/sandbox add`` in every session that + runs it. + +A grant is therefore a ``(kind, target, source)`` triple. + +**``kind``** says what kind of object is being made writable, and it is written +down once — by whoever names the grant — instead of being asked of the disk at +every call. That is requirement 1 of the issue, and it fixes a defect as it goes: +the Seatbelt profile distinguishes a directory (``subpath``) from a file +(``literal``), and it used to decide that with ``os.path.isdir`` at +profile-build time, so a name the host granted as one file silently widened to +the whole tree beneath it the day that name became a directory. Recorded here, +the grant keeps meaning what was asked for however the disk changes. + +**``target``** is what the grant names: a path for the two filesystem kinds, a +registry key spelling for ``registry``. The field is not called ``path`` because +a registry key is not one, and no path operation — canonicalization, containment, +``isdir`` — applies to it. + +**``source``** says who named it: ``session`` for a root the host typed with +``/sandbox add``, ``default`` for an entry of :data:`DEFAULT_MANIFEST`. The +distinction is requirement 3: a default that could be neither shown nor withdrawn +would be the deployment constant host decision D5 deleted, back under a new name +(``emrg/sandbox/roots.py`` records that decision and this module is the boundary +between the two mechanisms). + +Which kinds a platform can *enforce* is a property of the platform and not of the +model, and that is what lets the model accept a kind it cannot honour without +lying about it. :data:`ENFORCED_KINDS` names what has an implementation; +:func:`enforcement_refusal` produces the one sentence a caller shows when a kind +is not there, and every dialect refuses such a grant **by name** rather than +dropping it — dropping it would report a boundary the run does not have, and +passing it on as a path would grant something else entirely. Today ``registry`` +is in no platform's set: the model carries the kind, the writer refuses it with +that sentence, and the enforcement itself — a DACL on a registry key, in +``emrg/sandbox/win32/`` — is Stage 2 of issue #2492. +""" + +from __future__ import annotations + +import os +import sys +from typing import NamedTuple + +#: The kinds of object the writable surface can name. ``directory`` and ``file`` +#: are the two a filesystem can enforce; ``registry`` is a key. +GRANT_DIRECTORY = "directory" +GRANT_FILE = "file" +GRANT_REGISTRY = "registry" +GRANT_KINDS: tuple[str, ...] = (GRANT_DIRECTORY, GRANT_FILE, GRANT_REGISTRY) + +#: Who named a grant. ``session`` is a root the host typed; ``default`` is one +#: this module's manifest supplies so a host who types nothing still has the +#: state their tools need writable. +SOURCE_SESSION = "session" +SOURCE_DEFAULT = "default" +GRANT_SOURCES: tuple[str, ...] = (SOURCE_SESSION, SOURCE_DEFAULT) + +#: The kinds **this platform's dialects implement**, per platform family. +#: +#: ``registry`` is deliberately in none of them: the kind is modelled and +#: :func:`enforcement_refusal` names the reason, but the code that applies a DACL +#: to a key does not exist yet (issue #2492 Stage 2). A set rather than a comment +#: because two callers read it — the refusal sentence, and the guard that keeps +#: :data:`DEFAULT_MANIFEST` from claiming a kind its platform cannot honour. +ENFORCED_KINDS: dict[str, tuple[str, ...]] = { + "darwin": (GRANT_DIRECTORY, GRANT_FILE), + "linux": (GRANT_DIRECTORY, GRANT_FILE), + "win32": (GRANT_DIRECTORY, GRANT_FILE), + "other": (GRANT_DIRECTORY, GRANT_FILE), +} + +#: The default manifest: what is writable in every session without a command. +#: +#: Host-requested (rant 2026-10-22T20:12:00), and laid out per platform because +#: the locations genuinely differ. ``lark-cli`` renews its user identity by +#: taking a lock and writing a token; these are the directories it uses. +#: +#: Two properties this table owes, both asserted by tests rather than trusted: +#: +#: * **a row's kind is one its platform can enforce** — :data:`ENFORCED_KINDS` is +#: the reader, so the Windows registry keys the rant also names are absent +#: here until Stage 2 can enforce them. A manifest promising a grant the run +#: does not have would be the lying boundary this module refuses elsewhere; +#: * **the table is data, not a code path** — an entry whose directory does not +#: exist on this host is simply absent from the derivation +#: (:func:`default_grants`), so a deployment that has never run ``lark-cli`` +#: is not an error and not a grant. +#: +#: ``%APPDATA%`` is expanded with :func:`os.path.expandvars`, which understands +#: the ``%NAME%`` form on Windows only; on POSIX the two darwin/linux rows carry +#: ``~`` alone and the Windows row is never read. +DEFAULT_MANIFEST: dict[str, tuple[tuple[str, str], ...]] = { + "darwin": ( + (GRANT_DIRECTORY, "~/Library/Application Support/lark-cli"), + (GRANT_DIRECTORY, "~/.lark-cli"), + ), + "linux": ( + (GRANT_DIRECTORY, "~/.local/share/lark-cli"), + (GRANT_DIRECTORY, "~/.lark-cli"), + ), + "win32": ( + (GRANT_DIRECTORY, "~/.lark-cli"), + (GRANT_DIRECTORY, "%APPDATA%/Microsoft/Protect"), + ), +} + + +def platform_family(platform: str | None = None) -> str: + """The family key :data:`ENFORCED_KINDS` and :data:`DEFAULT_MANIFEST` use. + + Read from ``sys.platform`` unless a caller names one, so a test can measure a + platform it is not running on without inventing an environment. + + :param platform: the platform string to classify; defaults to ``sys.platform``. + :returns: ``darwin``, ``linux``, ``win32``, or ``other``. + """ + name = sys.platform if platform is None else platform + for family in ("darwin", "linux", "win32"): + if name.startswith(family): + return family + return "other" + + +def enforced_kinds(platform: str | None = None) -> tuple[str, ...]: + """The grant kinds this platform's dialects can enforce. + + :param platform: the platform string to classify; defaults to ``sys.platform``. + :returns: the enforced kinds, shortest-first by :data:`GRANT_KINDS` order. + """ + allowed = ENFORCED_KINDS.get(platform_family(platform), ()) + return tuple(kind for kind in GRANT_KINDS if kind in allowed) + + +def enforcement_refusal(kind: str, target: str = "", platform: str | None = None) -> str | None: + """The sentence for a kind this platform cannot enforce, or ``None``. + + One producer for two readers — the writer that refuses a host's + ``/sandbox add --registry`` and the derivation that refuses to hand a dialect + a grant it could only drop — because two spellings of one refusal is how a + host comes to read two different explanations of one rule. + + :param kind: the grant kind in question. + :param target: the grant's target, quoted when there is one. + :param platform: the platform to judge; defaults to ``sys.platform``. + :returns: the refusal text, or ``None`` when the kind is enforceable here. + """ + allowed = enforced_kinds(platform) + if kind in allowed: + return None + # Quoted plainly rather than with `!r`: a registry key is a Windows spelling + # full of backslashes, and `repr` doubles every one of them, so the sentence a + # host reads would name a key that looks nothing like the one they typed. + named = f" '{target}'" if target else "" + return ( + f"a {kind} grant{named} cannot be enforced on {platform_family(platform)} — this " + f"platform enforces {', '.join(allowed)}. Dropping it would report a boundary the " + f"run does not have, and honouring it as a path would grant a different object, so " + f"it is refused instead (issue #2492 Stage 2 adds {kind} enforcement)" + ) + + +class GrantKindNotEnforced(RuntimeError): + """A grant names a kind no dialect on this platform implements. + + Raised by ``emrg/sandbox/roots.py::named_grants`` — the single place every + dialect's derivation passes through — so that a kind this platform cannot + enforce is refused **by name** instead of being dropped by one dialect and + honoured by another. The message is + :func:`enforcement_refusal`'s, the same sentence the writer shows a host who + types ``/sandbox add --registry``: one rule, one wording. + """ + + def __init__(self, grant: "RootGrant") -> None: + self.grant = grant + self.reason = enforcement_refusal(grant.kind, grant.target) or ( + f"a {grant.kind} grant cannot be enforced on {platform_family()}" + ) + super().__init__(self.reason) + + +class RootGrant(NamedTuple): + """One typed writable grant: what kind of object, which one, and who named it. + + A ``NamedTuple`` rather than a dataclass because it crosses the wire (as a + plain dict, :meth:`as_wire`) and is logged in refusal messages; being a tuple + also makes it hashable, so the deduplication in + ``emrg/sandbox/roots.py`` is a set membership test rather than a loop over + attributes. + """ + + #: One of :data:`GRANT_KINDS`. + kind: str + #: A path for ``directory``/``file``, a key spelling for ``registry``. + target: str + #: One of :data:`GRANT_SOURCES`. + source: str = SOURCE_SESSION + + def as_wire(self) -> dict[str, str]: + """The grant as a JSON-ready dict, the shape the daemon injects. + + :returns: a three-key mapping a client or a tool can read back with + :meth:`from_wire`. + """ + return {"kind": self.kind, "target": self.target, "source": self.source} + + @classmethod + def from_wire(cls, value: object) -> "RootGrant | None": + """Read one injected grant, or ``None`` when it is not one. + + Tolerant on purpose, and only in the narrowing direction: the daemon is + the sole writer of this payload (``_inject_tool_arguments`` replaces any + value the model put in the call), and a tool that cannot read an entry + must end up with a **smaller** writable surface, never a larger one. So + a malformed entry is dropped rather than guessed at, and a kind this + module does not know is dropped rather than passed on — the enforcement + refusal is for a grant that was *named*, not for a string that is not a + grant at all. + + :param value: the wire value: a mapping, or a bare path string for the + two filesystem kinds (a caller building a policy by hand). + :returns: the grant, or ``None``. + """ + if isinstance(value, RootGrant): + return value + if isinstance(value, str): + return cls.from_path(value) + if not isinstance(value, dict): + return None + kind = value.get("kind") + target = value.get("target") + if not isinstance(kind, str) or kind not in GRANT_KINDS: + return None + if not isinstance(target, str) or not target.strip(): + return None + source = value.get("source", SOURCE_SESSION) + if source not in GRANT_SOURCES: + source = SOURCE_SESSION + return cls(kind=kind, target=target, source=source) + + @classmethod + def from_path(cls, target: str, source: str = SOURCE_SESSION) -> "RootGrant": + """A filesystem grant whose kind is read from the disk **once**, here. + + The kind of a path is a fact about the disk, not a spelling convention, + so it is measured with ``os.path.isdir`` at the moment the grant is made + — the daemon's ``/sandbox add`` judge has already required the path to + exist, and a caller that skipped that judge still gets the conservative + answer (anything not a directory is granted as the one file it names, + never as the tree above it). + + :param target: the path the grant names. + :param source: who named it. + :returns: the grant. + """ + try: + is_dir = os.path.isdir(target) + except OSError: # pragma: no cover - isdir swallows its own errors + is_dir = False + return cls(kind=GRANT_DIRECTORY if is_dir else GRANT_FILE, target=target, source=source) + + +def default_grants(platform: str | None = None) -> list[RootGrant]: + """The manifest's entries for this platform, as grants that really apply here. + + **Total**, in the sense ``emrg/sandbox/roots.py`` uses for the temp sources: a + manifest entry whose directory this host does not have is *absent* from the + answer, never an error. ``lark-cli`` may never have run here, and a boundary + that refused to be computed over a tool nobody installed would take a working + sandbox down with it. + + The ``source`` is :data:`SOURCE_DEFAULT` for every entry: that is what lets + a host tell a grant they typed from one the installation supplied, which is + the whole difference between this table and the deployment constant host + decision D5 deleted. + + :param platform: the platform to read; defaults to ``sys.platform``. + :returns: the manifest's grants that exist on this host, in table order. + """ + family = platform_family(platform) + out: list[RootGrant] = [] + for kind, spelling in DEFAULT_MANIFEST.get(family, ()): + target = os.path.expandvars(os.path.expanduser(spelling)) + try: + exists = os.path.isdir(target) if kind == GRANT_DIRECTORY else os.path.exists(target) + except OSError: # pragma: no cover - both calls swallow their own errors + exists = False + if not exists: + continue + out.append(RootGrant(kind=kind, target=target, source=SOURCE_DEFAULT)) + return out + + +def manifest_targets(platform: str | None = None) -> list[str]: + """Every manifest target on this platform, whether or not it exists here. + + The withdrawal path reads this rather than :func:`default_grants`: a host who + withdraws a default grants nothing by doing so, and the answer they need — + "that was one of the installation's entries" — does not depend on the + directory being present at the instant they ask. + + :param platform: the platform to read; defaults to ``sys.platform``. + :returns: the expanded targets, in table order. + """ + family = platform_family(platform) + return [ + os.path.expandvars(os.path.expanduser(spelling)) + for _kind, spelling in DEFAULT_MANIFEST.get(family, ()) + ] diff --git a/emrg/sandbox/policy.py b/emrg/sandbox/policy.py index 4dd494ca9..464ce9fa2 100644 --- a/emrg/sandbox/policy.py +++ b/emrg/sandbox/policy.py @@ -11,6 +11,8 @@ import os from dataclasses import dataclass +from emrg.sandbox.grants import GRANT_DIRECTORY, GRANT_FILE, GRANT_REGISTRY, RootGrant + #: The mode vocabulary of the file-effect policy. Unchanged from the old #: tool's, because it is also the blueprint's ``SandboxMode`` — one word list, #: two implementations for the length of the parallel period, pinned equal by @@ -58,22 +60,38 @@ class SandboxPolicy: mode: str workspace_root: str session_id: str | None = None - #: Roots the **host** named for this session, alongside the ones the mode + #: Grants the **host** named for this session, alongside the ones the mode #: derives (rant 2026-10-09T09:43:39). The workspace is not the only place a #: session may legitimately need to write, and the alternative the mode table #: offered was ``danger-full-access`` — the whole filesystem, to reach one - #: file outside it. A host-named root is the middle: this session, these - #: paths, named by a person and withdrawable by one. + #: file outside it. A host-named grant is the middle: this session, these + #: objects, named by a person and withdrawable by one. + #: + #: Typed since issue #2492: a grant carries its **kind** (``directory``, + #: ``file``, ``registry``) so no dialect re-decides it from the disk at + #: profile-build time, and so an object that is not a path can be named at + #: all. ``emrg/sandbox/grants.py`` holds the model. #: #: Two things this field is *not*. It is not a widening the agent can ask #: for: the daemon overwrites the argument it travels in on every tool call #: (``_inject_tool_arguments``), exactly as it does the tier, so a model that - #: invents ``writable_roots`` in a call replaces nothing. And it is not a + #: invents a grant in a call replaces nothing. And it is not a #: per-deployment constant — the root host decision D5 deleted was exactly #: that, unnamed by anyone and unremovable; the test that keeps that name #: dead (``tests/test_file_policy_has_one_home.py``) is the boundary between - #: the two, and it stays. - extra_roots: tuple[str, ...] = () + #: the two, and it stays. The **default manifest** is the third mechanism, + #: and it is neither: its entries are visible in ``/sandbox list`` with their + #: provenance and each is withdrawn by one command. + extra_grants: tuple[RootGrant, ...] = () + #: Default-manifest targets this session's host has **withdrawn**. + #: + #: A default applies without anyone typing anything, so "stop applying it" + #: has to be a piece of state somewhere. It is here rather than in a config + #: file for the reason the tier is: the daemon is the only writer, the value + #: is per session, and a client is a display. Requirements 3 and 4 of issue + #: #2492 — a default is visible, withdrawable, and only ever applies to a + #: tier that grants something — are decided by the derivation reading this. + withheld_roots: tuple[str, ...] = () def __post_init__(self) -> None: # Absolute-path assertion at the POLICY layer, canonicalization at the @@ -86,41 +104,108 @@ def __post_init__(self) -> None: "sandbox policy: workspace_root must be an absolute execution-world path " f"(got {self.workspace_root!r})" ) - # Same assertion, same reason, for the host-named roots: a relative one + # Same assertion, same reason, for the host-named grants: a relative one # would resolve against whatever cwd the provider happened to run in, so # the path granted and the path judged could differ. A caller that # cannot spell it absolutely has not decided what it is granting. - for root in self.extra_roots: - if not os.path.isabs(root): + # + # Only the two filesystem kinds are asked this question, and that is + # requirement 1 of issue #2492 rather than a shortcut: a registry key is + # not a path, `os.path.isabs` has no meaning for it, and a model that + # ran the path assertion over every kind would refuse the one kind this + # change exists to make namable. + for grant in self.extra_grants: + if grant.kind == GRANT_REGISTRY: + if not grant.target.strip(): + raise ValueError("sandbox policy: a registry grant must name a key") + continue + if grant.kind not in (GRANT_DIRECTORY, GRANT_FILE): + raise ValueError(f"sandbox policy: unknown grant kind {grant.kind!r}") + if not os.path.isabs(grant.target): raise ValueError( - "sandbox policy: every extra root must be an absolute execution-world " - f"path (got {root!r})" + "sandbox policy: every extra grant must name an absolute execution-world " + f"path (got {grant.target!r})" ) +def grants_from_arguments(arguments: dict) -> list[RootGrant]: + """The session's writable grants as one tool call carries them. + + The daemon writes the session's grants into a call under **two** keys + (``emrg/server/daemon.py::_inject_tool_arguments``): ``writable_grants``, the + typed list, and ``writable_roots``, its path projection. This is the one + reader of that pair, so four tools cannot disagree about which they prefer — + and the answer is the typed one, because a tool that read only the paths + would have to re-decide each object's kind from the disk, which is the defect + issue #2492 exists to remove. + + ``writable_roots``'s fallback is not a licence to infer: it is for a call that + carries the older key alone (an in-process caller, a test), and its cost is + the one :func:`resolve_policy` documents for ``extra_roots``. + + :param arguments: the call's arguments, as the daemon handed them over. + :returns: the grants; empty when the call carries neither key. + """ + typed = arguments.get("writable_grants") + if typed is not None: + read = (RootGrant.from_wire(value) for value in typed) + return [grant for grant in read if grant is not None] + roots = arguments.get("writable_roots") + if roots is not None: + return [RootGrant.from_path(str(root)) for root in roots] + return [] + + def resolve_policy( *, mode: str | None = None, workspace_root: str, session_id: str | None = None, + extra_grants: "tuple[RootGrant, ...] | list[RootGrant] | list[dict] | None" = None, extra_roots: "tuple[str, ...] | list[str] | None" = None, + withheld_roots: "tuple[str, ...] | list[str] | None" = None, ) -> SandboxPolicy: """Resolve the policy one call runs under. + Two ways to name the host's grants and **one store**, which is why passing + both is an error rather than a merge: ``extra_grants`` is the typed form the + daemon injects, ``extra_roots`` the path spelling an in-process caller + writes. The second is a convenience with a cost the caller should know: + :meth:`~emrg.sandbox.grants.RootGrant.from_path` reads each kind off the disk + at construction, so a path-form grant is decided once here instead of once + per dialect — narrower than the defect issue #2492 fixes, but not the same + thing as naming the kind. + :param mode: the tier the daemon injected for this task, or ``None`` when the call carries none (see :data:`DEFAULT_MODE`). :param workspace_root: the session's cwd — the same identity the command runs in and the boundary it may write under, one value, not two. :param session_id: the calling session, when the caller has one. - :param extra_roots: the session's host-named writable roots, as the daemon - injected them. ``None`` means "this call was told of none", which is - the same list as an empty one: a caller that supplies nothing grants - nothing, and silence never widens a boundary. + :param extra_grants: the session's host-named grants, typed, as the daemon + injected them. Entries it cannot read are dropped + (:meth:`~emrg.sandbox.grants.RootGrant.from_wire`), because the only + safe direction for an unreadable grant is a smaller surface. + :param extra_roots: the same thing spelled as paths; see above. + :param withheld_roots: default-manifest targets this session withdrew. :returns: the fully resolved policy. + :raises ValueError: when both grant forms are given, so a caller cannot + believe two lists were combined when one of them was ignored. """ + if extra_grants is not None and extra_roots is not None: + raise ValueError( + "sandbox policy: name the host's grants once — pass extra_grants (typed) or " + "extra_roots (paths), not both, or one list is silently ignored" + ) + grants: tuple[RootGrant, ...] = () + if extra_grants is not None: + read = (RootGrant.from_wire(value) for value in extra_grants) + grants = tuple(grant for grant in read if grant is not None) + elif extra_roots is not None: + grants = tuple(RootGrant.from_path(str(root)) for root in extra_roots) return SandboxPolicy( mode=DEFAULT_MODE if mode is None else mode, workspace_root=workspace_root, session_id=session_id, - extra_roots=tuple(extra_roots or ()), + extra_grants=grants, + withheld_roots=tuple(withheld_roots or ()), ) diff --git a/emrg/sandbox/providers/darwin.py b/emrg/sandbox/providers/darwin.py index cf7a03898..f00e9be60 100644 --- a/emrg/sandbox/providers/darwin.py +++ b/emrg/sandbox/providers/darwin.py @@ -26,7 +26,8 @@ from emrg.sandbox.contract import Runner, RunnerFailureRule from emrg.sandbox.policy import SandboxPolicy -from emrg.sandbox.roots import writable_roots +from emrg.sandbox.grants import GRANT_DIRECTORY, RootGrant +from emrg.sandbox.roots import granted_grants #: ``sandbox-exec`` ships with every macOS at a fixed path; Apple marks the CLI #: deprecated but still ships and still enforces it (measured on macOS 26.6.2). @@ -109,14 +110,14 @@ def seatbelt_profile_args(policy: SandboxPolicy) -> list[str]: "(deny file-write*)", f"(allow file-write* (literal {sbpl_string('/dev/null')}))", ] - roots = writable_roots(policy) - if roots: - grants = " ".join(_root_grant(root) for root in roots) - forms.append(f"(allow file-write* {grants})") + grants = granted_grants(policy) + if grants: + clauses = " ".join(_root_grant(grant) for grant in grants) + forms.append(f"(allow file-write* {clauses})") return ["-p", " ".join(forms)] -def _root_grant(root: str) -> str: +def _root_grant(grant: RootGrant) -> str: r"""One writable root as an SBPL clause — ``literal`` for a file, ``subpath`` for a directory. Both forms match their own path, so a directory and a file would both work @@ -127,11 +128,16 @@ def _root_grant(root: str) -> str: that name ever became a directory. ``literal`` is exactly the named path, which is what was asked for (rant 2026-10-09T09:43:39 §7). - :param root: one canonical writable root. + The form follows the grant's **recorded kind**, not the disk + (``os.path.isdir`` at profile-build time): asking the disk meant a name the + host granted as one file widened to the whole tree beneath it the day that + name became a directory (issue #2492). + + :param grant: one canonical writable grant. :returns: the SBPL clause granting write access to it. """ - form = "subpath" if os.path.isdir(root) else "literal" - return f"({form} {sbpl_string(root)})" + form = "subpath" if grant.kind == GRANT_DIRECTORY else "literal" + return f"({form} {sbpl_string(grant.target)})" def runner_argv(policy: SandboxPolicy) -> list[str]: diff --git a/emrg/sandbox/providers/linux.py b/emrg/sandbox/providers/linux.py index e5dbaeed2..ec761b7d1 100644 --- a/emrg/sandbox/providers/linux.py +++ b/emrg/sandbox/providers/linux.py @@ -43,6 +43,7 @@ from emrg.sandbox.contract import Runner, RunnerFailureRule from emrg.sandbox.policy import SandboxPolicy +from emrg.sandbox.roots import named_grants #: The program name, resolved through ``PATH``. Bubblewrap is packaged as #: ``bubblewrap``/``bwrap`` on every distribution that ships it; a host without @@ -141,8 +142,8 @@ def bwrap_profile_args(policy: SandboxPolicy) -> list[str]: # this machine; this host cannot run bwrap at all, so that pairing is # **unmeasured** here and is exactly why the acceptance below names it # rather than claiming it. - for extra in policy.extra_roots: - args += ["--bind", extra, extra] + for grant in named_grants(policy): + args += ["--bind", grant.target, grant.target] return args diff --git a/emrg/sandbox/providers/win32.py b/emrg/sandbox/providers/win32.py index 5b20a576d..994715582 100644 --- a/emrg/sandbox/providers/win32.py +++ b/emrg/sandbox/providers/win32.py @@ -45,7 +45,7 @@ from emrg.sandbox.contract import Runner, RunnerFailureRule from emrg.sandbox.policy import SandboxPolicy -from emrg.sandbox.roots import canonical_path +from emrg.sandbox.roots import canonical_path, named_grants from emrg.sandbox.win32.ffi import Win32Bindings, win32 from emrg.sandbox.win32.grants import AclWriteGrant from emrg.sandbox.win32.sid import ( @@ -412,19 +412,24 @@ def runner_argv(policy: SandboxPolicy) -> list[str]: def _extra_root_spellings(policy: SandboxPolicy) -> tuple[str, ...]: - """The policy's host-named roots, canonical, de-duplicated, order preserved. + """The enforceable extra grants' paths — the manifest's and the session's. + + Read from the shared derivation (:func:`~emrg.sandbox.roots.named_grants`) + rather than off ``policy.extra_grants``, because a grant the host never typed + is still one this run must be able to write under: that is what the default + manifest is for (issue #2492). Canonicalization and de-duplication happen in + that derivation, once, for every dialect. + + A **registry** grant never reaches this function: no platform enforces that + kind yet, and the derivation raises + :class:`~emrg.sandbox.grants.GrantKindNotEnforced` rather than handing a key + spelling to a path consumer. On Windows that is the loop Stage 2 closes — the + key needs a DACL of its own, not an ``--extra-root`` flag. :param policy: the resolved per-call policy. :returns: the canonical spellings. """ - out: list[str] = [] - seen: set[str] = set() - for spelling in policy.extra_roots: - canonical = canonical_path(spelling) - if canonical not in seen: - seen.add(canonical) - out.append(canonical) - return tuple(out) + return tuple(grant.target for grant in named_grants(policy)) def release_session(session_id: str) -> None: diff --git a/emrg/sandbox/roots.py b/emrg/sandbox/roots.py index d87b643d6..233e1ade1 100644 --- a/emrg/sandbox/roots.py +++ b/emrg/sandbox/roots.py @@ -16,6 +16,17 @@ import tempfile from typing import TYPE_CHECKING, NamedTuple +from emrg.sandbox.grants import ( + GRANT_DIRECTORY, + GRANT_FILE, + GRANT_KINDS, + GRANT_REGISTRY, + GrantKindNotEnforced, + RootGrant, + default_grants, + enforcement_refusal, +) + if TYPE_CHECKING: # pragma: no cover - typing only from emrg.sandbox.policy import SandboxPolicy @@ -45,77 +56,163 @@ def canonical_path(path: str) -> str: return path -def writable_roots(policy: SandboxPolicy) -> list[str]: - """The roots one confined execution may WRITE under. - - The mode's meaning as a canonical, deduplicated allow-list — ``read-only`` - (and ``danger-full-access``, which never asks) allow nothing; - ``workspace-write`` allows the policy's workspace root, the host ``/tmp``, - and the per-user platform temp dir (``tempfile.gettempdir()`` — the real - temp area for the ``mkstemp`` family; omitting it would deny what the mode - promises). - - The derivation is **total**: a temp source this host cannot report is - *absent* from the list, never fatal (issue #1561, ``_platform_temp_sources``). - The reading this deliberately does not take: treating a policy that cannot be - fully computed as one that cannot be enforced, and failing at the seam. That - reading belongs to ``contract.confine``, whose - :class:`~emrg.sandbox.contract.SandboxUnavailableError` means "no backend on - this platform can enforce the mode" — a missing temp *directory* is not that, - and reporting it there would tell the reader to install bubblewrap while - turning a host that can still confine to its workspace root into one where no - command runs at all. - - Two things this list deliberately does NOT contain: - - * an extra deployer root. The blueprint's allow-list is these three - sources and nothing else (``roots.ts:52-55``); EMRG's former - ``_trusted_write_zones()`` (``~/.emrg/evolution/.emrg/``) is deleted by - host decision D5, not carried over under another name, so the check that - it stays deleted belongs to the tests, not to a spare field here. The - host-named roots this module now *does* carry are a different mechanism - and not that one revived: D5's root was a deployment constant nobody - chose and nothing could withdraw, while these are named per session by - the host, visible in ``/sandbox list`` and removable in one command. The - name stays dead either way — the difference is in who can name a root, - which is why ``policy.extra_roots`` is a session's, not a config's. - * a fourth source on Windows, beyond the ``Temp\\`` normalization - below — which is not a root of its own but the *parent* spelling of the - one ``gettempdir()`` already returned (issue #1093). +def canonical_target(grant: RootGrant) -> str: + """A grant's target in the identity the enforcement layer compares. - :param policy: the file-effect policy to derive the allow-list from. - :returns: the canonical writable roots; empty for every mode but - ``workspace-write`` (``read-only`` allows nothing by definition, and - ``danger-full-access`` never asks). + A path is canonicalized (:func:`canonical_path`) because that is what the + Seatbelt filters, the fence's containment test and the Windows ACL path all + compare. A **registry key is not a path** and is returned exactly as + spelled: running ``realpath`` over ``HKCU\\Software\\LarkCli`` would rewrite a + key name into a filesystem path that means nothing, which is the identity bug + requirement 1 of issue #2492 exists to prevent. + + :param grant: the grant to canonicalize. + :returns: the canonical path, or the key spelling for a registry grant. + """ + if grant.kind == GRANT_REGISTRY: + return grant.target + return canonical_path(grant.target) + + +def _dedupe(grants: "list[RootGrant] | tuple[RootGrant, ...]") -> list[RootGrant]: + """The grants, canonical, with no object granted twice, in the order given. + + Deduplication is by ``(kind, canonical target)`` rather than by target alone: + one path can legitimately be named as a file and as a directory by two + callers, and collapsing those two into one would have to pick a kind — the + silent re-interpretation this model removes. First occurrence wins, so a + caller that lists a session's own grants before the manifest's reports a hard + -typed root as ``session`` rather than as an installation default. + + :param grants: the grants to canonicalize and de-duplicate. + :returns: the deduplicated list. + """ + out: list[RootGrant] = [] + seen: set[tuple[str, str]] = set() + for grant in grants: + canonical = canonical_target(grant) + key = (grant.kind, canonical) + if key in seen: + continue + seen.add(key) + out.append(grant if canonical == grant.target else grant._replace(target=canonical)) + return out + + +def stored_grants(policy: SandboxPolicy) -> list[RootGrant]: + """Every grant this session names — typed, with provenance, mode-independently. + + The **display** reader. It answers ``/sandbox list`` and nothing else, and it + deliberately does not consult the tier or the enforcement table: + + * a grant named at ``read-only`` is stored and takes effect when the tier + rises (the behaviour ``judge_extra_root`` already documents), so a listing + that hid it would tell the host their command did nothing; + * a grant this platform cannot enforce is still *shown*, and only then + refused by whoever tries to enforce it. Hiding it would be the silent + ignore requirement 5 of issue #2492 forbids, one layer earlier. + + The session's own grants come first, then the manifest's, so a root the host + typed that the manifest also supplies is reported as ``session`` — they named + it, and the provenance should say so. + + :param policy: the policy to read, including its withheld targets. + :returns: the grants, canonical and deduplicated, with their sources. + """ + withheld = set(policy.withheld_roots) + named = [ + grant + for grant in (*policy.extra_grants, *default_grants()) + if grant.target not in withheld and canonical_target(grant) not in withheld + ] + return _dedupe(named) + + +def named_grants(policy: SandboxPolicy) -> list[RootGrant]: + """The grants a dialect may actually **act on**: :func:`stored_grants` with the enforcement check. + + This is where a kind no dialect on this platform implements becomes loud. + Raising is the deliberate choice of three: + + * silently dropping the grant would let the run report a boundary it does not + have (the failure requirement 5 names); + * passing a registry key to a path consumer would grant a *different* object + somewhere on the filesystem — a widening nobody asked for; + * so the derivation refuses, and each caller turns that into the loudest + answer it owns: the fence returns the sentence as its refusal, a profile + builder lets it propagate and the command fails closed. + + :param policy: the policy to read. + :returns: the enforceable grants. + :raises GrantKindNotEnforced: when a stored grant's kind is not in + :func:`~emrg.sandbox.grants.enforced_kinds` for this platform. + """ + grants = stored_grants(policy) + for grant in grants: + if enforcement_refusal(grant.kind, grant.target) is not None: + raise GrantKindNotEnforced(grant) + return grants + + +def _mode_grants(policy: SandboxPolicy) -> list[RootGrant]: + """The grants the *mode* derives, before any host-named or default grant. + + The workspace root and the two temp sources, each typed from the disk once + here (:meth:`~emrg.sandbox.grants.RootGrant.from_path`) rather than asked + again by every dialect. + + :param policy: the policy to derive from. + :returns: the mode's grants, uncanonicalized and not yet deduplicated. + """ + spellings = [policy.workspace_root, *_host_temp_spellings(), *_platform_temp_sources()] + return [RootGrant.from_path(spelling) for spelling in spellings] + + +def granted_grants(policy: SandboxPolicy) -> list[RootGrant]: + """Every grant one confined execution may write under, typed. + + The **one** derivation: the mode's own sources, then the session's and the + manifest's (:func:`named_grants`). The Seatbelt profile, the in-process + fence, the bubblewrap mounts and the Windows ACL path all read this, so "the + shell can write where ``write`` cannot" cannot arise between them. + + The derivation is **total** over a temp source this host cannot report: it is + absent from the list, never fatal (issue #1561, + :func:`_platform_temp_sources`). It is *not* total over a grant whose kind + this platform cannot enforce — that raises (:func:`named_grants`). + + ``read-only`` (and ``danger-full-access``, which never asks) grant nothing: + the early return below is what makes requirement 4 of issue #2492 true by + construction rather than by a check — a default manifest cannot widen a tier + that grants none, because this function never consults one for that tier. + + :param policy: the file-effect policy to derive the grant list from. + :returns: the canonical, deduplicated grants; empty for every mode but + ``workspace-write``. """ if policy.mode != "workspace-write": return [] - spellings = [policy.workspace_root, *_host_temp_spellings()] - spellings.extend(_platform_temp_sources()) - # The host-named roots join here rather than in a provider, and that - # placement is the requirement (rant 2026-10-09T09:43:39 §3): the Seatbelt - # profile, the bwrap mounts and the in-process fence all read this one list, - # so an extra root cannot end up writable for a spawned command and refused - # for `write` — the asymmetry this module exists to prevent. A provider that - # carried its own extra allow-list would be a second home for the same fact. - # - # They are appended *after* the mode's own sources and are still subject to - # the same dedup below, so naming an already-granted root (the workspace, or - # a temp area) changes nothing — the caller is told that at the writer, where - # it can be reported, rather than silently repeated here. - # - # The `read-only` return above is what makes a stored extra root harmless at - # a tier that grants nothing: the list is a property of the *mode*, and the - # roots a session happens to carry never widen a mode that grants none. - spellings.extend(policy.extra_roots) - out: list[str] = [] - seen: set[str] = set() - for spelling in spellings: - canonical = canonical_path(spelling) - if canonical not in seen: - seen.add(canonical) - out.append(canonical) - return out + return _dedupe([*_mode_grants(policy), *named_grants(policy)]) + + +def writable_roots(policy: SandboxPolicy) -> list[str]: + """The **paths** among :func:`granted_grants` — the projection path-readers use. + + A derived view, not a second allow-list: it is built from + :func:`granted_grants` and can therefore never disagree with it. It exists + because several readers need nothing but paths — the fence's containment + test, the confined cache relocation, and the providers' own spellings — and + because a registry grant has no path to contribute, so a path-only reader + must not be handed one. + + ``read-only`` still answers ``[]``, which is the property the host's rule + rests on: at a tier that grants nothing, nothing is writable however the + session or the manifest is configured. + + :param policy: the file-effect policy to derive the allow-list from. + :returns: the canonical writable paths. + """ + return [grant.target for grant in granted_grants(policy) if grant.kind != GRANT_REGISTRY] #: The shared POSIX temp root — one spelling, named once, read through @@ -228,6 +325,12 @@ class ExtraRootVerdict(NamedTuple): #: Whether the caller should add ``canonical`` to the session's list. False #: only for a path already covered — a refusal never reaches this field. store: bool = True + #: The **typed** grant to store, when the verdict accepted one (issue #2492). + #: ``None`` for a refusal and for "already covered": in both cases the caller + #: stores nothing, and a grant here would be a second way to decide that. + #: The kind is decided at this boundary — from the disk for a path, from the + #: host's own word for a registry key — and never again by a dialect. + grant: "RootGrant | None" = None def _absolute_root_spelling(path: str) -> tuple[str, str | None]: @@ -264,8 +367,10 @@ def _absolute_root_spelling(path: str) -> tuple[str, str | None]: return canonical_path(expanded), None -def judge_extra_root(path: str, policy: SandboxPolicy) -> ExtraRootVerdict: - """Judge one host-named writable root — refuse it, or report it already covered. +def judge_extra_root( + path: str, policy: SandboxPolicy, kind: str | None = None +) -> ExtraRootVerdict: + """Judge one host-named writable grant — refuse it, or report it already covered. This is the **writer-side** half of the host's ``/sandbox add`` (rant 2026-10-09T09:43:39 §5–§6): the daemon owns the command, and this function is @@ -322,16 +427,42 @@ def judge_extra_root(path: str, policy: SandboxPolicy) -> ExtraRootVerdict: round before this, and the sentence promised what the code did not do. :param path: the path as the host typed it. - :param policy: the session's policy **including its stored extra roots**, so - that re-adding one is recognised as already covered. - :returns: the verdict — the canonical spelling, whether to store it, and any - refusal or notice. + :param policy: the session's policy **including its stored extra grants and + the manifest's defaults**, so that re-adding one is recognised as already + covered. + :param kind: the kind the host asked for, when they named one + (``/sandbox add --registry``); ``None`` means a path, whose kind is read + from the disk once here (:meth:`~emrg.sandbox.grants.RootGrant.from_path`) + rather than re-decided by every dialect at profile-build time. + :returns: the verdict — the canonical spelling, the typed grant to store, and + any refusal or notice. """ # Imported here rather than at module scope: ``emrg.session`` imports this # module for ``canonical_path``, and that happens before the tool package is # on the import path. The list still has one home; this is a reference. from emrg.tools.file_policy import protected_paths + if kind is not None and kind not in (GRANT_DIRECTORY, GRANT_FILE, GRANT_REGISTRY): + return ExtraRootVerdict("", refusal=( + f"unknown grant kind {kind!r} — expected one of {', '.join(GRANT_KINDS)}" + )) + if kind == GRANT_REGISTRY: + # Terminal in this stage, and the sentence says why rather than pretending + # the key was judged: no platform enforces a registry grant yet + # (``grants.ENFORCED_KINDS``), so there is no key spelling to validate + # against. Stage 2 of issue #2492 replaces this branch with the key's own + # rules *and* the DACL that applies it — landing only one of the two would + # be a stored grant that no dialect acts on, which is the silent ignore + # requirement 5 forbids. + refusal = enforcement_refusal(GRANT_REGISTRY, path.strip()) + if refusal is None: # pragma: no cover - no platform reaches this in Stage 1 + refusal = ( + "a registry grant is enforced on this platform but its key spelling is not " + "validated yet — issue #2492 Stage 2 lands the validation and the enforcement " + "together" + ) + return ExtraRootVerdict("", refusal=refusal) + canonical, refusal = _absolute_root_spelling(path) if refusal: return ExtraRootVerdict(canonical, refusal=refusal) @@ -364,17 +495,27 @@ def judge_extra_root(path: str, policy: SandboxPolicy) -> ExtraRootVerdict: f"{path!r} does not exist — a root names what is there, because a " "directory is granted as a tree and a file as itself" )) - for root in writable_roots(policy): + for grant in granted_grants(policy): + if grant.kind == GRANT_REGISTRY: # no containment test exists for a key + continue + root = grant.target if canonical == root or canonical.startswith(root.rstrip(os.sep) + os.sep): return ExtraRootVerdict(canonical, store=False, notice=( - f"{root!r} already grants this path under the session's tier — " - "nothing to add" + f"{root!r} already grants this path under the session's tier " + f"({grant.source}) — nothing to add" )) - # The session's own list is consulted separately, because ``writable_roots`` - # only carries it under ``workspace-write``: at ``read-only`` that derivation - # is empty by definition, so the loop above cannot see the root stored a - # moment ago and a second ``add`` of it would be stored twice. - stored = [canonical_path(extra) for extra in policy.extra_roots] + # The grants the session names itself are consulted separately, because + # ``granted_grants`` only carries them under ``workspace-write``: at + # ``read-only`` that derivation is empty by definition, so the loop above + # cannot see the grant stored a moment ago and a second ``add`` of it would + # be stored twice. The manifest's entries are *not* consulted here: those are + # withdrawn rather than removed, and naming one again at a tier that grants + # nothing should store it as the host's own (the add path drops it from the + # withheld list for exactly that reason). + stored = [ + canonical_target(grant) for grant in policy.extra_grants + if grant.kind != GRANT_REGISTRY + ] if any(canonical == root or canonical.startswith(root.rstrip(os.sep) + os.sep) for root in stored): return ExtraRootVerdict(canonical, store=False, notice=( @@ -386,13 +527,17 @@ def judge_extra_root(path: str, policy: SandboxPolicy) -> ExtraRootVerdict: # so — reading ``notice`` as "do not store" made this branch keep nothing # while telling the host it had, so the tier flip it advised left the # list empty (measured 2026-10-09 on head ``4eb69bab``). Storing here is - # harmless by construction: ``writable_roots`` returns ``[]`` for this - # mode, so a stored root can never widen a tier that grants none. - return ExtraRootVerdict(canonical, notice=( - "stored for this session, but the tier read-only grants no writable " - "root — it takes effect when the tier becomes workspace-write" - )) - return ExtraRootVerdict(canonical) + # harmless by construction: ``granted_grants`` returns ``[]`` for this + # mode, so a stored grant can never widen a tier that grants none. + return ExtraRootVerdict( + canonical, + notice=( + "stored for this session, but the tier read-only grants no writable " + "root — it takes effect when the tier becomes workspace-write" + ), + grant=RootGrant.from_path(canonical), + ) + return ExtraRootVerdict(canonical, grant=RootGrant.from_path(canonical)) def judge_root_removal(path: str, policy: SandboxPolicy) -> ExtraRootVerdict: diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 8ca1f64be..a772d63d1 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -76,7 +76,14 @@ from emrg.sandbox import escalation from emrg.sandbox.policy import DEFAULT_MODE as DEFAULT_SANDBOX_MODE from emrg.sandbox.policy import SANDBOX_MODES, resolve_policy -from emrg.sandbox.roots import judge_extra_root, judge_root_removal +from emrg.sandbox.grants import manifest_targets +from emrg.sandbox.roots import ( + canonical_path, + granted_grants, + judge_extra_root, + judge_root_removal, + stored_grants, +) from emrg.server import content_risk_probe from emrg.server.tool_types import ToolResult from emrg.memory import ( @@ -3441,6 +3448,7 @@ async def _process_message( msg.get("op", ""), msg.get("path", ""), ws, + msg.get("kind") or None, ) elif msg_type == "list_projects": @@ -4322,8 +4330,9 @@ def _inject_tool_arguments( * **the host names the writable roots, and only the host.** The tools that write under a root — the shell tools, ``write`` and ``edit``, the same set the tier reaches — receive ``writable_roots`` from the - session's ``sandbox_roots``, injected **unconditionally** (rant - 2026-10-09T09:43:39 §4). The difference from the tier above is + session's ``sandbox_roots``, and ``writable_grants``, the same grants + **typed** with their kind and provenance, injected **unconditionally** + (rant 2026-10-09T09:43:39 §4; the typed key is issue #2492's). The difference from the tier above is deliberate on both sides: the tier's injection is guarded by ``req.sandbox`` because a call carrying no tier belongs to a host session whose unconfined default is silence, and silence cannot widen @@ -4397,6 +4406,14 @@ def _inject_tool_arguments( # safe form is the unconditional one: whatever the session holds, # including nothing, is what the call runs with. args["writable_roots"] = session.sandbox_roots + # The same fact **typed** (issue #2492), and the one a tool prefers: + # `writable_roots` is the path projection, so a reader that took only + # that list would have to re-decide each object's kind from the disk — + # the defect the typed model exists to remove. Both keys are written + # from the session's one typed list, so they cannot disagree. + args["writable_grants"] = [ + grant.as_wire() for grant in session.sandbox_grants + ] if tc_name in ("subagent", "boss"): # The parent's identity and cwd are facts the model must not choose # (issue #2451, #2467): the slot is derived from both, so a caller that @@ -5463,13 +5480,31 @@ def _writable_boundary_line(session: Session) -> str: :param session: the session whose turn is being built. :returns: the context line, or ``""`` when the session names no root. """ - roots = session.sandbox_roots - if not roots: + policy = resolve_policy( + mode=resolve_client_tier(session), + workspace_root=str(session.cwd), + session_id=session.session_id, + extra_grants=session.sandbox_grants, + withheld_roots=session.sandbox_withheld, + ) + # What actually applies, not what is stored: `granted_grants` is empty at + # `read-only`, so a line built from the stored list would tell the model + # it may write where the fence will refuse it. The installation's + # defaults are included and labelled, because a model that does not know + # a directory is writable wastes the turn proving it either way. + granted = {grant.target for grant in granted_grants(policy)} + grants = [ + grant for grant in stored_grants(policy) + if grant.target in granted + ] + if not grants: return "" - listed = ", ".join(repr(root) for root in roots) + listed = ", ".join( + f"{grant.target!r} ({grant.kind}, {grant.source})" for grant in grants + ) return ( - "[context] This session may also write (host-named, equal to the " - f"workspace for writing): {listed} — you cannot add to or widen these." + "[context] This session may also write (equal to the workspace for " + f"writing): {listed} — you cannot add to or widen these." ) def _inject_context_message( @@ -6867,9 +6902,10 @@ async def _handle_set_sandbox( await self._broadcast_all(frame, exclude=ws) async def _handle_set_sandbox_roots( - self, session_id: str, cwd: str, op: str, path: str, ws + self, session_id: str, cwd: str, op: str, path: str, ws, + kind: str | None = None, ) -> None: - """Add, remove or list a session's host-named writable roots. + """Add, remove or list a session's host-named writable grants. Rant 2026-10-09T09:43:39. The middle tier the mode table was missing: a ``workspace-write`` session could reach a file outside its workspace only @@ -6916,12 +6952,7 @@ async def _handle_set_sandbox_roots( return session = self._get_or_create_session(session_id, Path(cwd)) if op == "list": - await self._send(ws, { - "type": "sandbox_roots", - "session_id": session_id, - "op": "list", - "roots": session.sandbox_roots, - }) + await self._send(ws, self._sandbox_roots_frame(session, op="list")) return # The policy the *session* is under, so "already covered" is judged # against the tier in force and against the roots already stored — naming @@ -6931,7 +6962,8 @@ async def _handle_set_sandbox_roots( mode=resolve_client_tier(session), workspace_root=cwd, session_id=session_id, - extra_roots=session.sandbox_roots, + extra_grants=session.sandbox_grants, + withheld_roots=session.sandbox_withheld, ) # The op's own judge. They are two functions because they answer two # questions: `add` applies the six granting rules (is this a path the @@ -6941,32 +6973,67 @@ async def _handle_set_sandbox_roots( # 2026-10-09 on head `4eb69bab`). verdict = ( judge_root_removal(path, policy) if op == "remove" - else judge_extra_root(path, policy) + else judge_extra_root(path, policy, kind) ) if verdict.refusal: - await self._send(ws, { - "type": "sandbox_roots", - "session_id": session_id, - "op": op, - "roots": session.sandbox_roots, - "error": verdict.refusal, - }) + await self._send( + ws, self._sandbox_roots_frame(session, op=op, error=verdict.refusal) + ) return - roots = session.sandbox_roots if op == "remove": # Removal is idempotent and never a policy refusal: a path that is not # in the list is answered with the list as it stands, which is the # state the host asked for. (A path that cannot be *read* is refused # above, and that is the judge's rule rather than this one's.) The - # comparison is on the canonical spelling, so a path removed with a - # different spelling than it was added with still comes out. - remaining = [r for r in roots if r != verdict.canonical] - notice = None if len(remaining) != len(roots) else ( - f"{verdict.canonical!r} was not one of this session's roots — " - "nothing to remove" - ) - if len(remaining) != len(roots): - session.set_sandbox_roots(remaining) + # comparison is on the canonical target, so a path removed with a + # different spelling than it was added with still comes out, and the + # **kind is carried through** rather than re-read off the disk: a + # `set_sandbox_roots` round trip here would have re-decided every + # remaining grant's kind from the filesystem (issue #2492). + remaining = [ + grant for grant in session.sandbox_grants + if grant.target != verdict.canonical + ] + removed = len(remaining) != len(session.sandbox_grants) + if removed: + session.set_sandbox_grants(remaining) + # A default takes effect without anyone typing anything, so the only + # way to withdraw one is to record the withdrawal: it is state, not + # absence from a list nobody wrote (requirement 3 of issue #2492). A + # session grant that shadows a default is withdrawn for the same + # reason — removing it alone would uncover the default underneath. + withdrew_default = False + if any( + canonical_path(target) == verdict.canonical + for target in manifest_targets() + ): + if verdict.canonical not in session.sandbox_withheld: + session.set_sandbox_withheld( + [*session.sandbox_withheld, verdict.canonical] + ) + withdrew_default = True + if removed and withdrew_default: + notice = ( + f"{verdict.canonical!r} removed, and it was in the installation's " + "default manifest — withdrawn for this session as well" + ) + elif removed: + notice = None + elif withdrew_default: + notice = ( + f"{verdict.canonical!r} is an installation default, not a root this " + "session named — withdrawn for this session" + ) + elif verdict.canonical in session.sandbox_withheld: + notice = ( + f"{verdict.canonical!r} is already withdrawn for this session — " + "nothing to remove" + ) + else: + notice = ( + f"{verdict.canonical!r} was not one of this session's grants and is " + "no installation default — nothing to remove" + ) else: # `store` is the judge's decision, not something inferred from a # notice: a root already covered is not stored, while one named at @@ -6974,17 +7041,81 @@ async def _handle_set_sandbox_roots( # "do not store" kept nothing in that second case while the sentence # told the host it had (measured 2026-10-09 on head `4eb69bab`). notice = verdict.notice - if verdict.store: - session.set_sandbox_roots([*roots, verdict.canonical]) + if verdict.store and verdict.grant is not None: + session.set_sandbox_grants([*session.sandbox_grants, verdict.grant]) + # Naming a withdrawn default again is a re-grant, so the + # withdrawal is lifted; leaving it would store a grant the + # derivation then subtracts, which reads as a command that did + # nothing. + if verdict.grant.target in session.sandbox_withheld: + session.set_sandbox_withheld([ + target for target in session.sandbox_withheld + if target != verdict.grant.target + ]) + frame = self._sandbox_roots_frame(session, op=op, notice=notice) + await self._send(ws, frame) + await self._broadcast_all(frame, exclude=ws) + + def _sandbox_roots_frame( + self, session: Session, op: str, notice: str | None = None, + error: str | None = None, + ) -> dict: + """The one ``sandbox_roots`` frame — list, write and refusal alike. + + Three ops and two failure shapes used to build five dicts in this + handler, which is five places for one of them to drift. There is one + producer now, and the frame carries the surface **twice** on purpose: + + * ``roots`` — the session's own grants as paths. Every existing reader + speaks this (the TUI's confirmation line, ``meta.sandbox_roots`` on a + resume), and it means exactly what it always did: what the host named, + not what the manifest supplied. + * ``grants`` — the whole writable surface typed, with provenance + (requirement 3 of issue #2492): the session's grants first, then the + installation defaults this session has not withdrawn. A default that + no client could display would be the deployment constant host decision + D5 deleted, under a new name; showing it with ``source`` is what makes + «withdraw this» a command a person can type. + * ``withheld`` — the defaults this session withdrew, so a client shows + why a name is missing rather than leaving it unexplained. + + ``stored_grants`` and not ``granted_grants``: this is the display, and a + grant named at ``read-only`` is on the session's list even though the + tier grants nothing — hiding it would tell the host their command did + nothing (the add path says when it takes effect). + + :param session: the session whose surface is being reported. + :param op: the op that produced the frame — ``add``, ``remove`` or ``list``. + :param notice: what to tell the host when the outcome is more than a + plain acceptance. + :param error: the refusal, when the op was refused. + :returns: the frame to send and broadcast. + """ + policy = resolve_policy( + mode=resolve_client_tier(session), + workspace_root=str(session.cwd), + session_id=session.session_id, + extra_grants=session.sandbox_grants, + withheld_roots=session.sandbox_withheld, + ) frame = { "type": "sandbox_roots", - "session_id": session_id, + "session_id": session.session_id, "op": op, "roots": session.sandbox_roots, - "notice": notice, + "grants": [grant.as_wire() for grant in stored_grants(policy)], + "withheld": session.sandbox_withheld, } - await self._send(ws, frame) - await self._broadcast_all(frame, exclude=ws) + # A key is **present** when it has something to say, and absent + # otherwise: both clients read a notice or a refusal by checking whether + # the key is there, so a `None` value would read as "there is a refusal, + # and it is nothing" — the shape `tests/test_sandbox_extra_roots.py` + # caught on the first frame this helper produced. + if notice is not None: + frame["notice"] = notice + if error is not None: + frame["error"] = error + return frame def _apply_model_switch(self, model_name: str) -> dict: """The state change behind `/model`, as one reusable step. @@ -7174,6 +7305,13 @@ async def _handle_resume_session( # never replayed, so this is the only frame a client opening a # session later can learn them from. "sandbox_roots": session.sandbox_roots, + # Typed and with provenance, so a client opening a session later + # shows the same surface the live frame carries — the defaults + # included (issue #2492). One producer, two frames. + "sandbox_grants": [ + grant.as_wire() for grant in session.sandbox_grants + ], + "sandbox_withheld": session.sandbox_withheld, # A client opening a session learns the session's live state # here, because `turn_start`/`turn_end` are broadcasts — they # are addressed to whoever is subscribed at the time and are @@ -7786,12 +7924,18 @@ async def _run_subagent_child( child_tier = getattr(parent, "sandbox", None) or parent_sandbox if child_tier: child.set_sandbox(child_tier) - if parent.sandbox_roots: + if parent.sandbox_grants: try: - child.set_sandbox_roots(parent.sandbox_roots) + # Typed, so a grant the parent holds keeps the kind it was + # named with instead of being re-read off the disk one level + # down (issue #2492), and the withdrawals travel with them: + # a child that inherited a grant without its withdrawal would + # be wider than the parent that spawned it. + child.set_sandbox_grants(parent.sandbox_grants) + child.set_sandbox_withheld(parent.sandbox_withheld) except (ValueError, OSError): logger.warning( - "[sub-%d] could not inherit the parent's writable roots", slot, + "[sub-%d] could not inherit the parent's writable grants", slot, ) req = TaskRequest( @@ -7986,11 +8130,12 @@ async def _run_boss_child( child_tier = getattr(parent, "sandbox", None) or parent_sandbox if child_tier: child.set_sandbox(child_tier) - if parent.sandbox_roots: + if parent.sandbox_grants: try: - child.set_sandbox_roots(parent.sandbox_roots) + child.set_sandbox_grants(parent.sandbox_grants) + child.set_sandbox_withheld(parent.sandbox_withheld) except (ValueError, OSError): - logger.warning("[boss] could not inherit the parent's writable roots") + logger.warning("[boss] could not inherit the parent's writable grants") req = TaskRequest( id=f"boss-{int(started * 1000)}", diff --git a/emrg/session.py b/emrg/session.py index d3570518c..cfd75c397 100644 --- a/emrg/session.py +++ b/emrg/session.py @@ -24,8 +24,9 @@ from typing import Iterable from emrg.memory import NotARegularFile, SessionMemoryStore +from emrg.sandbox.grants import GRANT_REGISTRY, RootGrant from emrg.sandbox.policy import SANDBOX_MODES -from emrg.sandbox.roots import canonical_path +from emrg.sandbox.roots import canonical_path, canonical_target from emrg.sessions_index import remove_session_index, upsert_session_index from emrg.tools.base import non_regular_kind @@ -390,13 +391,19 @@ def __init__(self, session_id: str, cwd: Path) -> None: # session's silence keeps meaning "unconfined" (`policy.DEFAULT_MODE`), # and a property that answered the default would erase that difference. self._sandbox: str | None = None - # The roots the host named for this session on top of the tier, in the - # order they were added (rant 2026-10-09T09:43:39). Empty is the honest - # default: a session nobody has named a root for grants exactly what its - # tier derives, which is what every session did before this existed. - # Canonical spellings only — the writer canonicalizes before storing, so - # a reader never has to wonder which spelling was meant. - self._sandbox_roots: list[str] = [] + # The grants the host named for this session on top of the tier, in the + # order they were added (rant 2026-10-09T09:43:39), typed since issue + # #2492 so a kind no dialect has to re-decide is stored beside the object + # it names. Empty is the honest default: a session nobody has named a + # grant for grants exactly what its tier and the default manifest derive. + # Canonical targets only — the writer canonicalizes before storing, so a + # reader never has to wonder which spelling was meant. + self._sandbox_grants: list[RootGrant] = [] + # The default-manifest targets this session's host has withdrawn (issue + # #2492). Per session, like the grants above and the tier: a default + # applies without anyone typing anything, so "stop applying it" is state, + # and the daemon is its only writer. + self._sandbox_withheld: list[str] = [] # How many delegations deep this session is (issue #2451): 0 for a session # a client or the scheduler started, parent + 1 for one a `subagent` call # started. It lives in `meta.json` rather than in a daemon table because @@ -493,9 +500,25 @@ def load(cls, session_id: str, cwd: Path) -> Session: session._updated_at = meta.get("updated_at", "") session._last_compact_at = meta.get("last_compact_at") session._sandbox = meta.get("sandbox") - stored_roots = meta.get("sandbox_roots") - if isinstance(stored_roots, list): - session._sandbox_roots = [r for r in stored_roots if isinstance(r, str)] + stored_grants = meta.get("sandbox_grants") + if isinstance(stored_grants, list): + session._sandbox_grants = [ + grant for grant in (RootGrant.from_wire(v) for v in stored_grants) + if grant is not None + ] + else: + # A `meta.json` written before grants were typed (issue #2492): + # the path list is the same fact in the older spelling, and a + # session that predates the change must keep its boundary rather + # than silently lose it on the next daemon restart. + stored_roots = meta.get("sandbox_roots") + if isinstance(stored_roots, list): + session._sandbox_grants = [ + RootGrant.from_path(r) for r in stored_roots if isinstance(r, str) + ] + stored_withheld = meta.get("sandbox_withheld") + if isinstance(stored_withheld, list): + session._sandbox_withheld = [r for r in stored_withheld if isinstance(r, str)] stored_depth = meta.get("depth") # `isinstance(..., int)` is not enough on its own: `True` is an int, and # a JSON `true` in this field would then read as depth 1. @@ -597,32 +620,81 @@ def set_sandbox(self, mode: str) -> None: logger.info("session sandbox tier set: %s -> %s", self.session_id, mode) @property - def sandbox_roots(self) -> list[str]: - """The host-named writable roots this session carries, in added order. + def sandbox_grants(self) -> list[RootGrant]: + """The host-named writable grants this session carries, in added order. A copy, so a reader cannot mutate the session's list by accident — the daemon injects this value into every tool call, and an injected list a tool could edit in place would be a second writer of a fact the daemon - owns. + owns. Typed since issue #2492: the **kind** travels with the object it + names, so a dialect never re-decides it from the disk. + """ + return list(self._sandbox_grants) + + @property + def sandbox_roots(self) -> list[str]: + """The **paths** among this session's grants — the projection clients read. + + Clients and `meta.json` consumers have always spoken in paths, and the two + filesystem kinds still are paths; a registry key is not, so it contributes + none. This is a derived view of + :attr:`sandbox_grants` and never a second store: it is computed on every + read, so it cannot disagree with the typed list. + + Kept because the GUI's resume path reads ``meta.sandbox_roots`` (the + ``sandbox_roots`` frame is the live source, this key the reopened one) and + because a removal is still named by a path. + """ + return [ + grant.target for grant in self._sandbox_grants + if grant.kind != GRANT_REGISTRY + ] + + @property + def sandbox_withheld(self) -> list[str]: + """Default-manifest targets this session's host has withdrawn.""" + return list(self._sandbox_withheld) + + def set_sandbox_grants(self, grants: "list[RootGrant] | tuple[RootGrant, ...]") -> None: + """Replace this session's host-named writable grants and persist them. + + The **only** writer of the key, and it does not validate the grants: the + refusals (``/``, the home directory, a grant covering a protected daemon + file, a kind no platform enforces) belong to the daemon's command, which + is where a person is waiting for an answer and where a refusal can name + the reason. This method is storage. Its one precondition is the shape the + reader depends on — a canonical target — and it canonicalizes rather than + trusting the caller, so a symlink handed in here is stored as the path the + enforcement layer will actually compare against. """ - return list(self._sandbox_roots) + self._sandbox_grants = [ + grant._replace(target=canonical_target(grant)) for grant in grants + ] + self._save_meta() + logger.info( + "session sandbox grants set: %s -> %d grant(s)", + self.session_id, len(self._sandbox_grants), + ) def set_sandbox_roots(self, roots: "list[str] | tuple[str, ...]") -> None: - """Replace this session's host-named writable roots and persist them. - - The **only** writer of the key, and it does not validate the paths: the - refusals (``/``, the home directory, a root covering a protected daemon - file) belong to the daemon's command, which is where a person is waiting - for an answer and where a refusal can name the reason. This method is - storage. Its one precondition is the shape the reader depends on — - absolute, canonical spellings — and it canonicalizes rather than - trusting the caller, so a symlink handed in here is stored as the path - the enforcement layer will actually compare against. + """Replace this session's grants from **paths** — the pre-#2492 spelling. + + Kept as a named convenience rather than a second store: it converts to + typed grants and calls :meth:`set_sandbox_grants`, so the one writer stays + one writer. Its cost is stated where callers meet it — + :meth:`~emrg.sandbox.grants.RootGrant.from_path` reads each kind off the + disk — which is why the daemon and the delegation paths use the typed + method and only a path-only caller reaches for this one. """ - self._sandbox_roots = [canonical_path(str(r)) for r in roots] + self.set_sandbox_grants([RootGrant.from_path(str(root)) for root in roots]) + + def set_sandbox_withheld(self, targets: "list[str] | tuple[str, ...]") -> None: + """Replace the default-manifest targets this session withdraws.""" + self._sandbox_withheld = [str(target) for target in targets] self._save_meta() logger.info( - "session sandbox roots set: %s -> %d root(s)", self.session_id, len(self._sandbox_roots) + "session sandbox withheld set: %s -> %d target(s)", + self.session_id, len(self._sandbox_withheld), ) # ── Message persistence ─────────────────────────────────── @@ -1013,8 +1085,15 @@ def _save_meta_with_title(self, title: str | None) -> None: # session's path from a client turn's (rant 2026-09-30T09:30:16). if self._sandbox: meta["sandbox"] = self._sandbox - if self._sandbox_roots: - meta["sandbox_roots"] = list(self._sandbox_roots) + if self._sandbox_grants: + meta["sandbox_grants"] = [grant.as_wire() for grant in self._sandbox_grants] + # The path projection is written beside the typed list because the + # GUI's resume path reads `meta.sandbox_roots` (its live source is the + # `sandbox_roots` frame). One producer — `sandbox_roots` is computed + # from the grants — so the two keys cannot disagree. + meta["sandbox_roots"] = self.sandbox_roots + if self._sandbox_withheld: + meta["sandbox_withheld"] = list(self._sandbox_withheld) # Same rule as `sandbox`: a session nobody delegated to must not gain a # key saying it is zero deep. if self._depth: diff --git a/emrg/tools/bash_tool_v2.py b/emrg/tools/bash_tool_v2.py index 24b02f634..1400c7324 100644 --- a/emrg/tools/bash_tool_v2.py +++ b/emrg/tools/bash_tool_v2.py @@ -53,6 +53,7 @@ from emrg.sandbox.policy import ( DANGER_FULL_ACCESS, SandboxPolicy, + grants_from_arguments, resolve_policy, ) from emrg.sandbox.roots import canonical_path, writable_roots @@ -709,7 +710,7 @@ async def execute(self, arguments: dict) -> ToolResult: mode=arguments.get("sandbox"), workspace_root=workdir, session_id=arguments.get("session_id"), - extra_roots=arguments.get("writable_roots"), + extra_grants=grants_from_arguments(arguments), ) # The command-text rules a checked tier makes (containment escape, and the # host's daemon-lifecycle red line) are read from the text by diff --git a/emrg/tools/edit_tool.py b/emrg/tools/edit_tool.py index 3226eca98..5682b5773 100644 --- a/emrg/tools/edit_tool.py +++ b/emrg/tools/edit_tool.py @@ -7,7 +7,7 @@ from pathlib import Path from emrg.sandbox.fence import file_refusal -from emrg.sandbox.policy import resolve_policy +from emrg.sandbox.policy import grants_from_arguments, resolve_policy from emrg.server.tool_types import ToolDefinition, ToolResult from emrg.tools.base import ( ToolExecutor, @@ -110,7 +110,7 @@ async def execute(self, arguments: dict) -> ToolResult: mode=arguments.get("sandbox"), workspace_root=str(arguments.get("workspace") or os.getcwd()), session_id=arguments.get("session_id"), - extra_roots=arguments.get("writable_roots"), + extra_grants=grants_from_arguments(arguments), ) reason = file_refusal(target, policy) if reason: diff --git a/emrg/tools/pwsh_tool_v2.py b/emrg/tools/pwsh_tool_v2.py index 393f6379c..aea2d106b 100644 --- a/emrg/tools/pwsh_tool_v2.py +++ b/emrg/tools/pwsh_tool_v2.py @@ -64,6 +64,7 @@ from emrg.sandbox.policy import ( DANGER_FULL_ACCESS, SandboxPolicy, + grants_from_arguments, resolve_policy, ) from emrg.sandbox.escalation import with_retry_hint @@ -827,7 +828,7 @@ async def execute(self, arguments: dict) -> ToolResult: mode=arguments.get("sandbox"), workspace_root=workdir, session_id=arguments.get("session_id"), - extra_roots=arguments.get("writable_roots"), + extra_grants=grants_from_arguments(arguments), ) # The command-text rules a checked tier makes (containment escape, and the # host's daemon-lifecycle red line) are read from the text by diff --git a/emrg/tools/write_tool.py b/emrg/tools/write_tool.py index c3cdd38f9..91f2aeda7 100644 --- a/emrg/tools/write_tool.py +++ b/emrg/tools/write_tool.py @@ -7,7 +7,7 @@ from pathlib import Path from emrg.sandbox.fence import file_refusal -from emrg.sandbox.policy import resolve_policy +from emrg.sandbox.policy import grants_from_arguments, resolve_policy from emrg.server.tool_types import ToolDefinition, ToolResult from emrg.tools.base import ToolExecutor, special_file_kind, special_file_refusal from emrg.tools.file_policy import resolve_file_target @@ -86,7 +86,7 @@ async def execute(self, arguments: dict) -> ToolResult: mode=arguments.get("sandbox"), workspace_root=str(arguments.get("workspace") or os.getcwd()), session_id=arguments.get("session_id"), - extra_roots=arguments.get("writable_roots"), + extra_grants=grants_from_arguments(arguments), ) reason = file_refusal(target, policy) if reason: diff --git a/tests/conftest.py b/tests/conftest.py index 764fac183..d90f68312 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1150,3 +1150,57 @@ def _refuse_a_real_probe_on_windows(monkeypatch): from tests.signal_probe_guard import RefusingProbe monkeypatch.setattr(os, "kill", RefusingProbe(os.kill, platform=sys.platform)) + + +@pytest.fixture(autouse=True) +def _empty_default_manifest(monkeypatch): + """Keep the host's ``~`` out of every sandbox reading (issue #2492). + + ``emrg/sandbox/grants.py::DEFAULT_MANIFEST`` is a **real** deployment + manifest: it names the directories ``lark-cli`` renews its token in, and on + this host ``~/.lark-cli`` exists, so ``default_grants()`` returns it and every + ``workspace-write`` derivation grants it. A test asserting an exact profile + would then be measuring the host's home rather than the code — the same + defect ``_redirect_the_config_path`` prevents for ``config.toml``, one layer + over: the host's state is not a test input. + + Pinned to **empty** rather than to a fixed table, because a fixed table is a + second manifest that can drift from the real one. A test whose subject is the + manifest patches :data:`~emrg.sandbox.grants.DEFAULT_MANIFEST` itself, so + "a default applies without a command" is proved against a table the test + wrote, and the test that measures the real table reads it directly, past this + fixture — so "the real table is well formed" is proved on the real one. + """ + from emrg.sandbox import grants + + monkeypatch.setattr(grants, "DEFAULT_MANIFEST", {}) + + +@pytest.fixture +def pin_default_manifest(monkeypatch): + """Give the default manifest a table the **test** wrote (issue #2492). + + The autouse fixture above pins the manifest empty, so no ordinary test reads + the host's home. A test whose subject *is* the manifest needs a table, and + the safe table is one it wrote: pinning to the real deployment rows instead + would make "a default applies without a command" a claim about whether this + machine runs ``lark-cli``. + + Every platform family gets the same rows, so the fixture answers the same on + all three CI legs rather than encoding the leg it was written on. + + :returns: a callable taking the targets to grant as directories, which + returns the rows it installed. + """ + from emrg.sandbox.grants import GRANT_DIRECTORY + from emrg.sandbox import grants + + def _pin(*targets: str, kind: str = GRANT_DIRECTORY) -> tuple: + rows = tuple((kind, str(target)) for target in targets) + monkeypatch.setattr( + grants, "DEFAULT_MANIFEST", + {family: rows for family in ("darwin", "linux", "win32", "other")}, + ) + return rows + + return _pin diff --git a/tests/test_bash_v2_win32.py b/tests/test_bash_v2_win32.py index bbbf673a3..a98ff4d39 100644 --- a/tests/test_bash_v2_win32.py +++ b/tests/test_bash_v2_win32.py @@ -26,6 +26,7 @@ import pytest +from emrg.sandbox.grants import RootGrant from emrg.sandbox.policy import SandboxPolicy from emrg.sandbox.providers import win32 as provider from emrg.sandbox.roots import canonical_path @@ -533,7 +534,15 @@ def test_the_runner_argv_carries_one_extra_root_flag_per_host_named_root(store, mode="workspace-write", workspace_root=str(workspace), session_id="s1", - extra_roots=(str(directory), str(single), str(directory) + os.sep + ".", str(directory)), + # Typed since issue #2492: the kind is recorded with the grant instead of + # being asked of the disk per dialect. `from_path` reads it once here, + # which is what "the host named a path" means now. + extra_grants=tuple( + RootGrant.from_path(spelling) + for spelling in ( + str(directory), str(single), str(directory) + os.sep + ".", str(directory), + ) + ), ) argv = provider.runner_argv(policy) @@ -570,7 +579,7 @@ def test_a_read_only_policy_never_hands_the_runner_an_extra_root(store, monkeypa mode="read-only", workspace_root=str(workspace), session_id="s1", - extra_roots=(str(outside),), + extra_grants=(RootGrant.from_path(str(outside)),), ) ) @@ -585,7 +594,7 @@ def test_an_agentless_run_still_carries_its_extra_roots(store, monkeypatch, tmp_ The two branches differ in who applies the ACE, never in what the policy granted: dropping the roots here would make a root writable for ``bash`` and not for the ``pwsh`` seam's own spawn, which is the asymmetry the single - ``policy.extra_roots`` field exists to prevent. + ``policy.extra_grants`` field exists to prevent. """ built, made = store monkeypatch.setattr(provider, "STORE", built) @@ -595,7 +604,11 @@ def test_an_agentless_run_still_carries_its_extra_roots(store, monkeypatch, tmp_ monkeypatch.setattr(tempfile, "gettempdir", lambda: str(temps)) argv = provider.runner_argv( - SandboxPolicy(mode="workspace-write", workspace_root=str(workspace), extra_roots=(str(outside),)) + SandboxPolicy( + mode="workspace-write", + workspace_root=str(workspace), + extra_grants=(RootGrant.from_path(str(outside)),), + ) ) assert [argv[i + 1] for i, token in enumerate(argv) if token == "--extra-root"] == [ diff --git a/tests/test_pwsh_tool_v2.py b/tests/test_pwsh_tool_v2.py index c348a801b..2e30a78a8 100644 --- a/tests/test_pwsh_tool_v2.py +++ b/tests/test_pwsh_tool_v2.py @@ -467,10 +467,11 @@ def test_every_mounted_dialect_receives_the_session_cwd(tmp_path): dialect added to the roster is covered here without editing this test. """ server = _instantiate() - # `sandbox_roots` is part of what the injection reads now (rant - # 2026-10-09T09:43:39): the session's host-named writable roots ride the same - # call as the tier. Empty here, because this test's subject is the roster. - session = SimpleNamespace(cwd=tmp_path, sandbox_roots=[]) + # `sandbox_grants` and its path projection `sandbox_roots` are part of what + # the injection reads now (rant 2026-10-09T09:43:39; typed since issue + # #2492): the session's host-named writable grants ride the same call as the + # tier. Empty here, because this test's subject is the roster. + session = SimpleNamespace(cwd=tmp_path, sandbox_grants=[], sandbox_roots=[]) for name in sorted(SHELL_TOOL_NAMES): args: dict = {} server._inject_tool_arguments( diff --git a/tests/test_sandbox_extra_roots.py b/tests/test_sandbox_extra_roots.py index 75ee0dc4f..97b0202ec 100644 --- a/tests/test_sandbox_extra_roots.py +++ b/tests/test_sandbox_extra_roots.py @@ -869,6 +869,161 @@ def test_the_resume_snapshot_carries_the_roots(outside): assert meta["sandbox_roots"] == [canonical_path(str(elsewhere))] +def test_list_carries_every_grant_with_its_provenance(outside, pin_default_manifest): + """Requirement 3 of issue #2492: the defaults are shown, and they say what they are. + + Two keys, two questions, and a display needs both: ``roots`` is what the host + named (the list every existing client speaks), ``grants`` is the whole surface + typed, with ``source`` saying who named each one. A default that could not be + told from a host's own grant would be unwithdrawable in practice — nobody + removes something they did not know they had. + """ + ws, elsewhere = outside + installation_default = ws.parent / "installation-state" + installation_default.mkdir() + pin_default_manifest(installation_default) + session = Session.create_with_id("s_provenance", ws) + session.set_sandbox("workspace-write") + session.set_sandbox_roots([str(elsewhere)]) + server = _server() + + frames = _drive(server, { + "type": "set_sandbox_roots", + "session_id": session.session_id, + "cwd": str(ws), + "op": "list", + "path": "", + }) + + reply = [f for f in frames if f.get("type") == "sandbox_roots"][-1] + by_target = {grant["target"]: grant for grant in reply["grants"]} + + assert reply["roots"] == [canonical_path(str(elsewhere))], ( + "`roots` is the host-named projection and must not grow the manifest's entries" + ) + assert by_target[canonical_path(str(elsewhere))] == { + "kind": "directory", + "target": canonical_path(str(elsewhere)), + "source": "session", + } + assert by_target[canonical_path(str(installation_default))] == { + "kind": "directory", + "target": canonical_path(str(installation_default)), + "source": "default", + } + + +def test_removing_a_default_withdraws_it_and_the_write_is_refused(outside, pin_default_manifest): + """Requirement 3's second half: one command withdraws a default. + + A default applies without anyone typing anything, so "stop applying it" has + to be recorded state rather than an absence from a list nobody wrote — and + the acceptance is behavioural: after the withdrawal the same write is refused. + """ + ws, elsewhere = outside + pin_default_manifest(elsewhere) + session = Session.create_with_id("s_withdraw", ws) + session.set_sandbox("workspace-write") + server = _server() + token = elsewhere / "token" + + def current(): + return resolve_policy( + mode="workspace-write", + workspace_root=str(ws), + session_id=session.session_id, + extra_grants=Session.load(session.session_id, ws).sandbox_grants, + withheld_roots=Session.load(session.session_id, ws).sandbox_withheld, + ) + + assert file_refusal(str(token), current()) is None, ( + "the default did not apply — the withdrawal below would prove nothing" + ) + + frames = _drive(server, { + "type": "set_sandbox_roots", + "session_id": session.session_id, + "cwd": str(ws), + "op": "remove", + "path": str(elsewhere), + }) + + reply = [f for f in frames if f.get("type") == "sandbox_roots"][-1] + assert "error" not in reply, reply + assert "withdrawn" in (reply.get("notice") or "") + assert Session.load(session.session_id, ws).sandbox_withheld == [ + canonical_path(str(elsewhere)) + ] + assert file_refusal(str(token), current()) is not None, ( + "the default was still granted after the command that withdrew it" + ) + + +def test_adding_a_withdrawn_default_back_makes_it_the_host_s(outside, pin_default_manifest): + """Withdrawal is not a one-way door, and the re-grant keeps its new provenance. + + Storing the grant while leaving the withdrawal in place would store something + the derivation then subtracts: a command that reports success and changes + nothing. + """ + ws, elsewhere = outside + pin_default_manifest(elsewhere) + session = Session.create_with_id("s_readd", ws) + session.set_sandbox("workspace-write") + server = _server() + for op, path in (("remove", str(elsewhere)), ("add", str(elsewhere))): + _drive(server, { + "type": "set_sandbox_roots", + "session_id": session.session_id, + "cwd": str(ws), + "op": op, + "path": path, + }) + + frames = _drive(server, { + "type": "set_sandbox_roots", + "session_id": session.session_id, + "cwd": str(ws), + "op": "list", + "path": "", + }) + + reply = [f for f in frames if f.get("type") == "sandbox_roots"][-1] + reloaded = Session.load(session.session_id, ws) + + assert reloaded.sandbox_withheld == [], "the withdrawal outlived the re-grant" + assert [grant.source for grant in reloaded.sandbox_grants] == ["session"] + assert { + grant["target"]: grant["source"] for grant in reply["grants"] + }[canonical_path(str(elsewhere))] == "session" + + +def test_a_registry_add_is_refused_with_the_one_sentence(outside): + """Requirement 5 at the command: the kind is named, not ignored. + + Stage 1 stores no registry grant, because no dialect can enforce one yet + (``emrg/sandbox/grants.py``); the refusal is the same sentence + ``named_grants`` raises, so a host and a dialect cannot be told two different + things about one rule. + """ + ws, _elsewhere = outside + session = Session.create_with_id("s_registry", ws) + server = _server() + + frames = _drive(server, { + "type": "set_sandbox_roots", + "session_id": session.session_id, + "cwd": str(ws), + "op": "add", + "path": "HKCU\\Software\\LarkCli", + "kind": "registry", + }) + + reply = [f for f in frames if f.get("type") == "sandbox_roots"][-1] + assert "registry" in reply["error"] and "Stage 2" in reply["error"], reply + assert Session.load(session.session_id, ws).sandbox_grants == [] + + # ── §7 the model's view of the boundary ─────────────────────────────────── diff --git a/tests/test_sandbox_grants.py b/tests/test_sandbox_grants.py new file mode 100644 index 000000000..87780f166 --- /dev/null +++ b/tests/test_sandbox_grants.py @@ -0,0 +1,428 @@ +"""The typed writable-grant model — what a grant is, before any dialect reads it. + +Issue #2492. The writable surface used to be ``list[str]``: a path and nothing +else, so a kind could not be *stated* and had to be *asked of the disk* by each +dialect, and an object that is not a path could not be named at all. A grant is a +``(kind, target, source)`` triple now (``emrg/sandbox/grants.py``), and this file +measures the claims that model makes rather than the dialects that consume it — +those are measured in ``test_sandbox_extra_roots.py`` §4 and +``test_bash_v2_win32.py``. + +The layout follows the issue's requirements: §1 the kind is explicit (§1), §2 the +manifest supplies grants nobody typed (§2), §3 it is visible and withdrawable +(§3), §4 it never widens a tier that grants nothing (§4), §5 a kind no platform +enforces is refused by name rather than dropped (§5). + +Two readings this file is written around: + +* the autouse fixture in ``tests/conftest.py`` pins the deployment manifest + **empty**, so every test here installs its own table (``pin_default_manifest``) + — a test that instead read the real rows would be measuring whether this host + happens to run ``lark-cli``; +* the one test whose subject *is* the real table takes a snapshot of it at + **import**, before any fixture can patch it. That is the "reads it directly, + past the fixture" the conftest docstring promises, and it is the only way to + measure the table that ships. +""" + +from __future__ import annotations + +import json + +import pytest + +from emrg.sandbox import grants as grants_mod +from emrg.sandbox.fence import file_refusal +from emrg.sandbox.grants import ( + DEFAULT_MANIFEST, + ENFORCED_KINDS, + GRANT_DIRECTORY, + GRANT_FILE, + GRANT_KINDS, + GRANT_REGISTRY, + SOURCE_DEFAULT, + SOURCE_SESSION, + GrantKindNotEnforced, + RootGrant, + default_grants, + enforced_kinds, + enforcement_refusal, + manifest_targets, +) +from emrg.sandbox.policy import grants_from_arguments, resolve_policy +from emrg.sandbox.providers.darwin import seatbelt_profile_args +from emrg.sandbox.providers.linux import bwrap_profile_args +from emrg.sandbox.roots import ( + canonical_path, + canonical_target, + granted_grants, + named_grants, + stored_grants, + writable_roots, +) +from emrg.session import Session + +#: The shipped manifest, captured at import — before the autouse fixture can pin +#: it empty for the duration of a test. Only the test that measures the *real* +#: table reads this. +_SHIPPED_MANIFEST = DEFAULT_MANIFEST +_SHIPPED_ENFORCED = ENFORCED_KINDS + + +def _write(name: str, tmp_path) -> str: + """A real file outside the workspace: the path form of a ``file`` grant.""" + target = tmp_path / name + target.write_text("x", encoding="utf-8") + return str(target) + + +# ── §1 the kind is explicit ──────────────────────────────────────────────── + + +def test_a_recorded_kind_wins_over_the_disk(tmp_path): + """A name granted as one file stays one file the day it becomes a directory. + + This is the defect the typed model removes, and it is not hypothetical: the + Seatbelt profile chose ``subpath`` (a whole tree) or ``literal`` (this name) + with ``os.path.isdir`` at profile-build time, so a grant that had become a + directory silently widened to everything beneath it. The kind is recorded + now, so the disk is not asked again. + """ + became_a_directory = tmp_path / "suite" + became_a_directory.mkdir() + grant = RootGrant(kind=GRANT_FILE, target=str(became_a_directory), source=SOURCE_SESSION) + policy = resolve_policy( + mode="workspace-write", + workspace_root=str(tmp_path), + extra_grants=(grant,), + ) + + profile = " ".join(seatbelt_profile_args(policy)) + + assert f'(literal "{canonical_path(str(became_a_directory))}")' in profile + assert f'(subpath "{canonical_path(str(became_a_directory))}")' not in profile, ( + "the recorded kind was overridden by the disk — the widening this change removes" + ) + + +def test_one_path_named_as_two_kinds_is_two_grants(tmp_path): + """De-duplication is by ``(kind, target)``, so a kind is never silently picked. + + Collapsing the two into one would have to choose which was meant, and the + choice would be invisible to the host who named both. + """ + target = str(tmp_path) + policy = resolve_policy( + mode="workspace-write", + workspace_root=str(tmp_path / "ws"), + extra_grants=( + RootGrant(kind=GRANT_DIRECTORY, target=target), + RootGrant(kind=GRANT_FILE, target=target), + ), + ) + + kinds = [ + grant.kind for grant in granted_grants(policy) + if grant.target == canonical_path(target) + ] + + assert kinds == [GRANT_DIRECTORY, GRANT_FILE], kinds + + +def test_the_path_projection_has_no_key_to_a_registry_grant(): + """A registry key is not a path, so the path projection contributes none. + + ``writable_roots`` is a view of ``granted_grants`` for the readers that need + only paths. Handing a key spelling to one of them would grant something else + entirely — the identity bug requirement 1 of issue #2492 names. + """ + grant = RootGrant(kind=GRANT_REGISTRY, target="HKCU\\Software\\LarkCli") + policy = resolve_policy( + mode="workspace-write", + workspace_root="/tmp", + extra_grants=(grant,), + ) + + assert canonical_target(grant) == "HKCU\\Software\\LarkCli", ( + "realpath over a key spelling would rewrite the key into a path" + ) + with pytest.raises(GrantKindNotEnforced): + writable_roots(policy) + + +# ── §2 the manifest supplies grants nobody typed ─────────────────────────── + + +def test_a_default_applies_without_a_command(pin_default_manifest, tmp_path): + """Requirement 2: a host who never types ``/sandbox add`` still gets the state. + + The tool whose identity expires renews by writing outside the workspace, so a + session that grants only its workspace cannot run it. The default manifest is + what closes that, and the grant it produces is labelled ``default`` so a + reader can tell it from one the host named. + """ + state = tmp_path / "state" + state.mkdir() + pin_default_manifest(state) + policy = resolve_policy(mode="workspace-write", workspace_root=str(tmp_path / "ws")) + + grants = granted_grants(policy) + + assert RootGrant( + kind=GRANT_DIRECTORY, target=canonical_path(str(state)), source=SOURCE_DEFAULT + ) in grants + assert canonical_path(str(state)) in writable_roots(policy) + + +def test_a_default_the_host_named_is_reported_as_the_host_s(pin_default_manifest, tmp_path): + """A name both the host and the manifest supply is one grant, with one source. + + The provenance is what lets the display say «you named this» — and what + decides that withdrawing it also withdraws the default underneath. + """ + state = tmp_path / "state" + state.mkdir() + pin_default_manifest(state) + policy = resolve_policy( + mode="workspace-write", + workspace_root=str(tmp_path / "ws"), + extra_grants=(RootGrant(kind=GRANT_DIRECTORY, target=str(state)),), + ) + + matching = [ + grant for grant in granted_grants(policy) + if grant.target == canonical_path(str(state)) + ] + + assert [grant.source for grant in matching] == [SOURCE_SESSION], matching + + +def test_a_directory_the_host_does_not_have_is_absent_not_an_error(pin_default_manifest, tmp_path): + """A manifest row for a tool nobody installed is absent, never fatal. + + ``lark-cli`` may never have run on this host; a boundary that refused to + compute over a name that is not there would take a working sandbox down with + it, which is why the derivation is total over its inputs. + """ + absent = tmp_path / "never-installed" + pin_default_manifest(absent) + + assert default_grants() == [] + assert manifest_targets() == [str(absent)], ( + "the withdrawal path reads the table, not the disk — a name that is not " + "there yet is still a default a host may withdraw" + ) + + +# ── §3 the manifest is visible and withdrawable ──────────────────────────── + + +def test_stored_grants_show_a_default_while_the_tier_grants_nothing(pin_default_manifest, tmp_path): + """Requirement 3 meets requirement 4: shown at every tier, granted at one. + + ``stored_grants`` is the display reader and does not consult the tier — a + host who asks what is on the list sees the defaults even at ``read-only``, + which is what makes one of them withdrawable instead of invisible. + """ + state = tmp_path / "state" + state.mkdir() + pin_default_manifest(state) + policy = resolve_policy(mode="read-only", workspace_root=str(tmp_path / "ws")) + + shown = stored_grants(policy) + granted = granted_grants(policy) + + assert any(grant.source == SOURCE_DEFAULT for grant in shown), shown + assert granted == [] and writable_roots(policy) == [] + + +def test_a_withdrawn_default_is_not_granted(pin_default_manifest, tmp_path): + """Withdrawal is state, and the derivation reads it.""" + state = tmp_path / "state" + state.mkdir() + pin_default_manifest(state) + policy = resolve_policy( + mode="workspace-write", + workspace_root=str(tmp_path / "ws"), + withheld_roots=(canonical_path(str(state)),), + ) + + assert canonical_path(str(state)) not in writable_roots(policy) + assert all( + grant.target != canonical_path(str(state)) for grant in stored_grants(policy) + ) + + +# ── §4 a default never widens a tier that grants nothing ─────────────────── + + +def test_the_manifest_grants_nothing_at_read_only(pin_default_manifest, tmp_path): + """Requirement 4, with a manifest that really names something. + + The property ``writable_roots`` has today is ``[]`` for ``read-only``; a + default that applied regardless would be a widening nobody typed, in the one + tier whose definition is that nothing is writable. + """ + state = tmp_path / "state" + state.mkdir() + pin_default_manifest(state) + + for mode in ("read-only",): + policy = resolve_policy(mode=mode, workspace_root=str(tmp_path / "ws")) + assert granted_grants(policy) == [] + assert writable_roots(policy) == [] + + +# ── §5 a kind no platform enforces is refused by name ───────────────────── + + +def test_no_platform_enforces_a_registry_key_yet(): + """The staging boundary, pinned: the kind exists, the enforcement does not. + + ``emrg/sandbox/win32/`` applies DACLs to paths. A key needs its own + enforcement, and it is Stage 2 of issue #2492 — so this asserts the *absence* + on every platform rather than trusting a comment, and the Stage-2 change is + the one that edits this test on purpose. + """ + for family in ("darwin", "linux", "win32", "other"): + assert GRANT_REGISTRY not in enforced_kinds(family), family + + +def test_a_registry_grant_is_refused_by_name_and_not_dropped(tmp_path): + """Requirement 5: neither silently ignored nor reported as granted. + + Dropping it would let the run claim a boundary it does not have; passing the + key to a path consumer would grant something else. So the derivation raises, + and the sentence names the kind and the platform's set. + """ + policy = resolve_policy( + mode="workspace-write", + workspace_root=str(tmp_path), + extra_grants=(RootGrant(kind=GRANT_REGISTRY, target="HKCU\\Software\\X"),), + ) + + with pytest.raises(GrantKindNotEnforced) as refused: + named_grants(policy) + + assert "registry" in refused.value.reason + assert "HKCU\\Software\\X" in refused.value.reason + assert "Stage 2" in refused.value.reason + + +def test_every_dialect_refuses_a_registry_grant_rather_than_losing_it(tmp_path): + """One rule, both profile builders: the same policy, the same refusal. + + The fence answers in text (its callers render a sentence to the model) while + the profile builders let the exception out, so a confined command fails + closed instead of running under a profile that quietly omitted a grant. + """ + policy = resolve_policy( + mode="workspace-write", + workspace_root=str(tmp_path), + extra_grants=(RootGrant(kind=GRANT_REGISTRY, target="HKCU\\Software\\X"),), + ) + + with pytest.raises(GrantKindNotEnforced): + seatbelt_profile_args(policy) + with pytest.raises(GrantKindNotEnforced): + bwrap_profile_args(policy) + + refusal = file_refusal(str(tmp_path / "file.txt"), policy) + assert refusal is not None and "registry" in refusal + + +# ── the model at the wire and in a tool call ─────────────────────────────── + + +def test_a_grant_round_trips_through_the_wire_shape(tmp_path): + """``as_wire``/``from_wire`` are each other's inverse, and a bad entry narrows. + + Tolerant reading is deliberate and only in one direction: an entry a tool + cannot parse is **dropped**, because a malformed grant that widened a + boundary would be the failure mode the field exists to prevent. + """ + grant = RootGrant(kind=GRANT_FILE, target=_write("f.txt", tmp_path), source=SOURCE_DEFAULT) + + assert RootGrant.from_wire(grant.as_wire()) == grant + assert RootGrant.from_wire(grant) == grant + assert RootGrant.from_wire("relative/but-readable") == RootGrant.from_path( + "relative/but-readable" + ) + for malformed in ( + None, 42, {}, {"kind": "socket", "target": "/tmp"}, {"kind": GRANT_FILE}, + {"kind": GRANT_FILE, "target": " "}, + ): + assert RootGrant.from_wire(malformed) is None, malformed + assert RootGrant.from_wire({"kind": GRANT_FILE, "target": "/tmp/x", "source": "bogus"}) == ( + RootGrant(kind=GRANT_FILE, target="/tmp/x", source=SOURCE_SESSION) + ), "an unknown source falls back to the narrower claim: the host named it" + + +def test_a_tool_call_reads_the_typed_grants_and_falls_back_to_paths(tmp_path): + """One reader for the two injected keys, and the typed one wins. + + A tool that read only ``writable_roots`` would re-decide each kind from the + disk — the defect again, one call further in. The path form stays readable for + a caller that was never told a kind (a hand-built call, a test). + """ + (tmp_path / "suite").mkdir() + target = str(tmp_path / "suite") + typed = RootGrant(kind=GRANT_FILE, target=target, source=SOURCE_SESSION) + + assert grants_from_arguments({"writable_grants": [typed.as_wire()]}) == [typed] + assert grants_from_arguments( + {"writable_grants": [typed.as_wire()], "writable_roots": ["/elsewhere"]} + ) == [typed] + assert grants_from_arguments({"writable_roots": [target]}) == [ + RootGrant(kind=GRANT_DIRECTORY, target=target, source=SOURCE_SESSION) + ], "a path with no kind is read from the disk once, here" + assert grants_from_arguments({}) == [] + + +# ── the shipped table, measured as written ──────────────────────────────── + + +def test_the_shipped_manifest_only_promises_kinds_its_platform_enforces(): + """The real table, read past the fixture that empties it for other tests. + + A manifest row promising a grant the run cannot enforce is the lying boundary + this module refuses everywhere else — so the table is held to + :data:`ENFORCED_KINDS`, which is why the Windows registry keys the issue also + names are absent from it until Stage 2 can enforce them. + """ + assert _SHIPPED_MANIFEST, "the shipped manifest is empty — nothing was measured" + for family, rows in _SHIPPED_MANIFEST.items(): + assert rows, family + for kind, spelling in rows: + assert kind in GRANT_KINDS, (family, kind) + assert kind in _SHIPPED_ENFORCED[family], ( + f"{family} ships a {kind} grant ({spelling!r}) that no dialect on " + "that platform enforces" + ) + assert spelling, (family, kind) + + +# ── what the daemon stores ──────────────────────────────────────────────── + + +def test_the_session_keeps_a_grant_s_typed_kind_across_a_reload(tmp_path): + """The kind is session state, not a derived reading of the disk. + + A restart used to re-decide every root's kind with ``isdir`` — so a grant + could change meaning without anyone asking. The typed list is persisted, and + the path projection is written beside it from the same producer. + """ + session = Session.create_with_id("s_typed", tmp_path) + named = RootGrant( + kind=GRANT_FILE, target=_write("token", tmp_path), source=SOURCE_SESSION + ) + + session.set_sandbox_grants([named]) + + meta = json.loads(session._meta_path.read_text(encoding="utf-8")) + reloaded = Session.load("s_typed", tmp_path) + + assert meta["sandbox_grants"] == [named.as_wire()] + assert meta["sandbox_roots"] == [named.target], "the projection, from the same producer" + assert reloaded.sandbox_grants == [named] + assert reloaded.sandbox_roots == [named.target] diff --git a/tests/test_session_sandbox.py b/tests/test_session_sandbox.py index 5f2852882..17471d493 100644 --- a/tests/test_session_sandbox.py +++ b/tests/test_session_sandbox.py @@ -220,7 +220,8 @@ def test_the_injection_documents_every_argument_it_writes(): "path", # grep's spelling of the same fact "sandbox", # the task's tier "workspace", # the write boundary that rides with the tier - "writable_roots", # the host's roots, injected unconditionally + "writable_roots", # the host's grants as paths, injected unconditionally + "writable_grants", # the same grants typed — the key a tool prefers "_parent_session_id", # a delegation's parent — four facts the model "_parent_cwd", # must not choose, all injected unconditionally "_parent_tools", diff --git a/tests/test_shell_tool_mount.py b/tests/test_shell_tool_mount.py index 9f4b99962..1a0fef098 100644 --- a/tests/test_shell_tool_mount.py +++ b/tests/test_shell_tool_mount.py @@ -154,15 +154,18 @@ def test_a_populated_registry_still_answers_every_other_tool(): def injected(tmp_path): """Call ``_inject_tool_arguments`` with a session cwd and a task tier. - The session is a stub, not a real one: the rule reads two fields (``cwd`` and - ``sandbox_roots`` — the host-named writable roots, rant 2026-10-09T09:43:39), - and a real ``Session`` would create directories on disk to prove nothing - extra. A stub is kept here rather than a real session because the subject is - the injection rule; empty roots is the "nobody named one" case. + The session is a stub, not a real one: the rule reads three fields (``cwd``, + ``sandbox_grants`` — the host-named writable grants, typed since issue #2492 — + and ``sandbox_roots``, that same fact as paths; rant 2026-10-09T09:43:39), and + a real ``Session`` would create directories on disk to prove nothing extra. A + stub is kept here rather than a real session because the subject is the + injection rule; empty grants is the "nobody named one" case. """ def call(tool: str, args: dict, *, sandbox: str | None = None, cwd: Path | None = None): - session = SimpleNamespace(cwd=cwd or tmp_path, sandbox_roots=[]) + session = SimpleNamespace( + cwd=cwd or tmp_path, sandbox_grants=[], sandbox_roots=[], + ) EmrgServer._inject_tool_arguments(tool, args, session, TaskRequest(sandbox=sandbox)) return args From 06cbc46f7462081983f37b6c8ba3e9e0182c7d84 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 22 Oct 2026 21:09:00 +0800 Subject: [PATCH 2/2] emrg: the sandbox grant guards measure on every platform, not just this one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tests in tests/test_sandbox_grants.py were red on the Windows leg, both in the reading rather than in the grant: - test_a_recorded_kind_wins_over_the_disk built its expected Seatbelt clause by interpolating the raw path, so it looked for `(literal "C:\Users\...")` while the profile carries the escaped spelling the provider produces. On a host whose paths have no backslashes the two spellings are equal, which is why the guard was green here and red there. Its negative assertion failed the same way, and for a second reason that matters more: a spelling that can never match answers "absent" whatever the profile holds, so that direction measured nothing at all on that leg. Both now take their clause from the provider's own sbpl_string. - test_the_path_projection_has_no_key_to_a_registry_grant passed workspace_root "/tmp", which is not absolute in the execution world on Windows, so the test died in the policy constructor instead of measuring its claim. It now takes the tmp_path fixture: the claim is about a kind that is not a path, so the root it is made under must not itself be a platform statement. The escaping fix reproduces the Windows shape on a POSIX host: a directory whose name contains a backslash makes the old spelling false with the grant present and the new one true. Both mutation arms redden the guard by node id — restoring os.path.isdir in _root_grant, and deleting the kind branch so every grant is spelled subpath — with the positive and the negative direction each read separately. --- tests/test_sandbox_grants.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/tests/test_sandbox_grants.py b/tests/test_sandbox_grants.py index 87780f166..77c9b02c0 100644 --- a/tests/test_sandbox_grants.py +++ b/tests/test_sandbox_grants.py @@ -50,7 +50,7 @@ manifest_targets, ) from emrg.sandbox.policy import grants_from_arguments, resolve_policy -from emrg.sandbox.providers.darwin import seatbelt_profile_args +from emrg.sandbox.providers.darwin import sbpl_string, seatbelt_profile_args from emrg.sandbox.providers.linux import bwrap_profile_args from emrg.sandbox.roots import ( canonical_path, @@ -99,8 +99,17 @@ def test_a_recorded_kind_wins_over_the_disk(tmp_path): profile = " ".join(seatbelt_profile_args(policy)) - assert f'(literal "{canonical_path(str(became_a_directory))}")' in profile - assert f'(subpath "{canonical_path(str(became_a_directory))}")' not in profile, ( + # The expected clause is spelled by the provider's own ``sbpl_string``, never by + # quoting the path here. The profile escapes every backslash, so a clause written + # from the raw path is found on a host whose paths have none and is *false* on a + # Windows host with the grant visibly present — which is what ``test-windows`` + # reported for this file (`2 failed, 5179 passed, 499 skipped`). Both directions + # take the same spelling, so the negative one is a measurement there too rather + # than a string that could never match. + clause = sbpl_string(canonical_path(str(became_a_directory))) + + assert f"(literal {clause})" in profile + assert f"(subpath {clause})" not in profile, ( "the recorded kind was overridden by the disk — the widening this change removes" ) @@ -129,17 +138,23 @@ def test_one_path_named_as_two_kinds_is_two_grants(tmp_path): assert kinds == [GRANT_DIRECTORY, GRANT_FILE], kinds -def test_the_path_projection_has_no_key_to_a_registry_grant(): +def test_the_path_projection_has_no_key_to_a_registry_grant(tmp_path): """A registry key is not a path, so the path projection contributes none. ``writable_roots`` is a view of ``granted_grants`` for the readers that need only paths. Handing a key spelling to one of them would grant something else entirely — the identity bug requirement 1 of issue #2492 names. + + The workspace root is the fixture's own directory, not a literal ``/tmp``: the + policy refuses a root that is not absolute *in the execution world*, and ``/tmp`` + is not one on a Windows host — where this test then failed on the setup rather + than on the claim it makes. The claim is about a kind that is not a path, so the + root it is made under must not itself be a platform statement. """ grant = RootGrant(kind=GRANT_REGISTRY, target="HKCU\\Software\\LarkCli") policy = resolve_policy( mode="workspace-write", - workspace_root="/tmp", + workspace_root=str(tmp_path), extra_grants=(grant,), )