diff --git a/Agent.md b/Agent.md index f7073d53..597c14be 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 90dd26b1..835d9283 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 1c27b269..901a251d 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 7fbd01ca..a8ba898d 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 f92ab57d..74365701 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 c967eae3..009bfe86 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 700234f8..6db65caa 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 7a6ee807..bed6764a 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 8d422d21..82abf76a 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 66c0c38a..0e512be6 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 1688f47b..4e8a4822 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 00000000..1944b62c --- /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 4dd494ca..464ce9fa 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 cf7a0389..f00e9be6 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 e5dbaeed..ec761b7d 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 5b20a576..99471558 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 d87b643d..233e1ade 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 8ca1f64b..a772d63d 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 d3570518..cfd75c39 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 24b02f63..1400c732 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 3226eca9..5682b577 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 393f6379..aea2d106 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 c3cdd38f..91f2aeda 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 764fac18..d90f6831 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 bbbf673a..a98ff4d3 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 c348a801..2e30a78a 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 75ee0dc4..97b0202e 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 00000000..77c9b02c --- /dev/null +++ b/tests/test_sandbox_grants.py @@ -0,0 +1,443 @@ +"""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 sbpl_string, 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)) + + # 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" + ) + + +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(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=str(tmp_path), + 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 5f285288..17471d49 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 9f4b9996..1a0fef09 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