From 8ef17cef2b49b037e47c2d0f0090840810900694 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Wed, 9 Sep 2026 00:24:29 -0400 Subject: [PATCH] Enforce resolved material access boundaries --- .changeset/material-realpath-policy.md | 5 + packages/ghost/README.md | 7 +- packages/ghost/src/embed/inspect.ts | 45 +++- .../src/ghost-core/material-transport.ts | 39 ++- .../src/skill-bundle/references/schema.md | 12 + packages/ghost/test/material-access.test.ts | 244 ++++++++++++++++++ 6 files changed, 340 insertions(+), 12 deletions(-) create mode 100644 .changeset/material-realpath-policy.md create mode 100644 packages/ghost/test/material-access.test.ts diff --git a/.changeset/material-realpath-policy.md b/.changeset/material-realpath-policy.md new file mode 100644 index 00000000..7bb863e0 --- /dev/null +++ b/.changeset/material-realpath-policy.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": patch +--- + +Treat bundled symlinks that resolve outside the materials directory as referenced files, requiring explicit inspection permission and honoring the referenced-file inline limit. diff --git a/packages/ghost/README.md b/packages/ghost/README.md index 54df63f5..723cf620 100644 --- a/packages/ghost/README.md +++ b/packages/ghost/README.md @@ -86,8 +86,11 @@ selected ids, returns misses with suggestions, stable concrete/prose ordering, stripped node bodies, extracted Skeletons, and material transport packets. Use `inspectGhostMaterial` only for materials declared by a pulled node; it is local and bundled-only by default, with explicit host policy required for referenced -files. HTTPS inspection is always rejected. Included and inspected material is -marked `untrusted: true`; hosts must keep it in a data or tool-result channel +files. Symlinks must resolve within the permitted directory. Pull still inlines +small referenced text by default; use `inlineMaterials: false` before inspection +when the host requires explicit read permission. HTTPS inspection is always +rejected. Included and inspected material is marked `untrusted: true`; hosts +must keep it in a data or tool-result channel rather than an instruction channel. Embedded operations do not write `.ghost/.events`; hosts may persist exported observability events in their own telemetry. diff --git a/packages/ghost/src/embed/inspect.ts b/packages/ghost/src/embed/inspect.ts index 53bca8d7..9d6acc42 100644 --- a/packages/ghost/src/embed/inspect.ts +++ b/packages/ghost/src/embed/inspect.ts @@ -13,6 +13,7 @@ import { resolveLocalMaterialLocator, validateMaterialLocator, } from "#ghost-core"; +import { effectiveLocalMaterialTier } from "../ghost-core/material-transport.js"; import { GHOST_MATERIALS_DIR } from "../scan/constants.js"; import type { GhostEmbedSnapshot, @@ -98,6 +99,34 @@ export async function inspectGhostMaterial( ); } + let effectiveTier: Awaited>; + try { + effectiveTier = await effectiveLocalMaterialTier( + resolved.tier, + contained.realPath, + { + repoRoot: request.repoRoot, + packageDir: snapshot.package.dir, + materialsDir: GHOST_MATERIALS_DIR, + }, + ); + } catch { + return rejected( + request, + "matched file could not be read", + resolved.tier, + contained.repoRelativePath, + ); + } + if (effectiveTier === "referenced" && policy.local === "bundled") { + return rejected( + request, + "referenced material inspection is disabled by policy", + effectiveTier, + contained.repoRelativePath, + ); + } + let info: Awaited>; try { info = await stat(contained.realPath); @@ -105,7 +134,7 @@ export async function inspectGhostMaterial( return rejected( request, "matched file could not be read", - resolved.tier, + effectiveTier, contained.repoRelativePath, ); } @@ -113,7 +142,7 @@ export async function inspectGhostMaterial( return rejected( request, "not a file", - resolved.tier, + effectiveTier, contained.repoRelativePath, ); } @@ -121,7 +150,7 @@ export async function inspectGhostMaterial( return rejected( request, `exceeds ${policy.maxBytes} byte inspect limit`, - resolved.tier, + effectiveTier, contained.repoRelativePath, info.size, ); @@ -132,7 +161,7 @@ export async function inspectGhostMaterial( return rejected( request, `MIME type ${mime} is not allowed by policy`, - resolved.tier, + effectiveTier, contained.repoRelativePath, info.size, mime, @@ -146,7 +175,7 @@ export async function inspectGhostMaterial( return rejected( request, "matched file could not be read", - resolved.tier, + effectiveTier, contained.repoRelativePath, ); } @@ -154,7 +183,7 @@ export async function inspectGhostMaterial( return rejected( request, `exceeds ${policy.maxBytes} byte inspect limit`, - resolved.tier, + effectiveTier, contained.repoRelativePath, buffer.byteLength, mime, @@ -165,7 +194,7 @@ export async function inspectGhostMaterial( ok: true as const, nodeId: request.nodeId, locator: request.locator, - tier: resolved.tier, + tier: effectiveTier, path: contained.repoRelativePath, byteLength: buffer.byteLength, mime, @@ -184,7 +213,7 @@ export async function inspectGhostMaterial( return rejected( request, "not valid UTF-8 text", - resolved.tier, + effectiveTier, contained.repoRelativePath, buffer.byteLength, mime, diff --git a/packages/ghost/src/ghost-core/material-transport.ts b/packages/ghost/src/ghost-core/material-transport.ts index a645859a..0d89ac74 100644 --- a/packages/ghost/src/ghost-core/material-transport.ts +++ b/packages/ghost/src/ghost-core/material-transport.ts @@ -208,7 +208,25 @@ async function transportFile( }; } - const base = { locator, tier, path: contained.repoRelativePath }; + let effectiveTier: Exclude; + try { + effectiveTier = await effectiveLocalMaterialTier( + tier, + contained.realPath, + options, + ); + } catch { + return { + ...lexicalBase, + omitted: true, + reason: "matched file could not be read", + }; + } + const base = { + locator, + tier: effectiveTier, + path: contained.repoRelativePath, + }; let s: Awaited>; try { s = await stat(contained.realPath); @@ -226,7 +244,7 @@ async function transportFile( const inlineLimit = options.referencedInlineBytes ?? DEFAULT_REFERENCED_INLINE_BYTES; - if (tier === "referenced" && s.size > inlineLimit) { + if (effectiveTier === "referenced" && s.size > inlineLimit) { return { ...base, omitted: true as const, @@ -319,6 +337,23 @@ export async function resolveContainedRealFile( }; } +/** Tighten lexical bundled access against the real materials root; never promote a reference. */ +export async function effectiveLocalMaterialTier( + lexicalTier: Exclude, + realPath: string, + options: MaterialTransportOptions, +): Promise> { + if (lexicalTier === "referenced") return "referenced"; + + const materialsDir = options.materialsDir ?? DEFAULT_MATERIALS_DIR; + const realPackageMaterialsDir = await realpath( + resolve(options.packageDir, materialsDir), + ); + return isInsideOrEqual(realPath, realPackageMaterialsDir) + ? "bundled" + : "referenced"; +} + export function inferMaterialMime(path: string): MaterialMimeInfo { const lower = path.toLowerCase(); let mime = "application/octet-stream"; diff --git a/packages/ghost/src/skill-bundle/references/schema.md b/packages/ghost/src/skill-bundle/references/schema.md index 36748b4f..5241ac85 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -131,3 +131,15 @@ it does not grade them. - `ghost review` matches touched files to exact local material paths, offers relevant checks, and emits a review packet for the host agent. - `ghost stats` summarizes local gather and pull events. + +### Local material access + +Bundled-only inspection requires the resolved file to stay inside the resolved +materials directory and the repository. A bundled symlink to another in-repo +file is referenced material: inspection requires explicit permission, and pull +applies the referenced-file inline limit. Links within the materials directory +remain usable. Outside-repo targets stay unavailable. + +Pull still inlines eligible referenced text by default. Hosts that need +explicit permission for each read should pull with `inlineMaterials: false`, +then inspect under their chosen policy. diff --git a/packages/ghost/test/material-access.test.ts b/packages/ghost/test/material-access.test.ts new file mode 100644 index 00000000..0dc9df01 --- /dev/null +++ b/packages/ghost/test/material-access.test.ts @@ -0,0 +1,244 @@ +import { mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + inspectGhostMaterial, + loadGhostSnapshot, + pullGhostNodes, +} from "../src/embed/index.js"; +import { resolveGhostPackage } from "../src/package.js"; + +async function writeAccessPackage(dir: string): Promise { + await mkdir(join(dir, ".ghost", "materials"), { recursive: true }); + await mkdir(join(dir, "brand"), { recursive: true }); + await writeFile( + join(dir, ".ghost", "manifest.yml"), + "schema: ghost.package/v1\nid: local\ncover: cover\n", + ); + await writeFile( + join(dir, ".ghost", "glossary.md"), + "---\nkinds:\n - name: asset\n---\n\n# asset\n\nConcrete materials.\n", + ); + await writeFile( + join(dir, ".ghost", "cover.md"), + "---\nfor: Cover.\n---\n\nCover.\n", + ); + await writeFile(join(dir, "brand", "outside-small.txt"), "Outside small.\n"); + await writeFile(join(dir, "brand", "direct-small.txt"), "Direct small.\n"); + await writeFile( + join(dir, "brand", "outside-big.txt"), + "x".repeat(8 * 1024 + 1), + ); + await writeFile(join(dir, ".ghost", "materials", "inside.txt"), "Inside.\n"); + await symlink( + join(dir, "brand", "outside-small.txt"), + join(dir, ".ghost", "materials", "outside-small-link.txt"), + ); + await symlink( + join(dir, "brand", "outside-big.txt"), + join(dir, ".ghost", "materials", "outside-big-link.txt"), + ); + await symlink( + join(dir, ".ghost", "materials", "inside.txt"), + join(dir, ".ghost", "materials", "inside-link.txt"), + ); + await writeFile( + join(dir, ".ghost", "asset.links.md"), + [ + "---", + "for: Links.", + "materials:", + " - materials/outside-small-link.txt", + " - materials/outside-big-link.txt", + " - materials/inside-link.txt", + " - brand/direct-small.txt", + "---", + "", + "Link prose.", + ].join("\n"), + ); +} + +async function writeSymlinkedRootsPackage(dir: string): Promise { + await mkdir(join(dir, ".ghost"), { recursive: true }); + await mkdir(join(dir, "material-root"), { recursive: true }); + await writeFile( + join(dir, "material-root", "root-token.txt"), + "Root token.\n", + ); + await symlink(join(dir, "material-root"), join(dir, ".ghost", "materials")); + await writeFile( + join(dir, ".ghost", "manifest.yml"), + "schema: ghost.package/v1\nid: local\ncover: cover\n", + ); + await writeFile( + join(dir, ".ghost", "glossary.md"), + "---\nkinds:\n - name: asset\n---\n\n# asset\n\nConcrete materials.\n", + ); + await writeFile( + join(dir, ".ghost", "cover.md"), + "---\nfor: Cover.\n---\n\nCover.\n", + ); + await writeFile( + join(dir, ".ghost", "asset.root.md"), + "---\nfor: Root.\nmaterials:\n - materials/root-token.txt\n---\n\nRoot prose.\n", + ); + + const repoLink = `${dir}-link`; + await symlink(dir, repoLink); + return repoLink; +} + +describe("material access realpath policy", () => { + let dir: string; + + beforeEach(async () => { + dir = join( + tmpdir(), + `ghost-material-access-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(dir, { recursive: true }); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + await rm(`${dir}-link`, { recursive: true, force: true }); + }); + + it("downgrades bundled symlinks to in-repo outside-material files for inspection policy", async () => { + await writeAccessPackage(dir); + const snapshot = await loadGhostSnapshot( + resolveGhostPackage(undefined, dir), + ); + + const defaultPolicy = await inspectGhostMaterial(snapshot, { + nodeId: "asset.links", + locator: "materials/outside-small-link.txt", + repoRoot: dir, + }); + expect(defaultPolicy).toMatchObject({ + ok: false, + tier: "referenced", + path: "brand/outside-small.txt", + reason: "referenced material inspection is disabled by policy", + }); + + const explicitPolicy = await inspectGhostMaterial(snapshot, { + nodeId: "asset.links", + locator: "materials/outside-small-link.txt", + repoRoot: dir, + policy: { local: "bundled-and-referenced" }, + }); + expect(explicitPolicy).toMatchObject({ + ok: true, + tier: "referenced", + path: "brand/outside-small.txt", + text: "Outside small.\n", + untrusted: true, + }); + }); + + it("applies the referenced inline limit after bundled symlink downgrade during pull", async () => { + await writeAccessPackage(dir); + const snapshot = await loadGhostSnapshot( + resolveGhostPackage(undefined, dir), + ); + + const result = await pullGhostNodes(snapshot, { + ids: ["asset.links"], + repoRoot: dir, + }); + + expect(result.nodes[0]?.materials).toContainEqual({ + locator: "materials/outside-big-link.txt", + tier: "referenced", + path: "brand/outside-big.txt", + omitted: true, + reason: "exceeds 8 KB inline limit", + }); + expect(result.nodes[0]?.materials).toContainEqual({ + locator: "brand/direct-small.txt", + tier: "referenced", + path: "brand/direct-small.txt", + inlined: "Direct small.\n", + untrusted: true, + }); + }); + + it("keeps internal material symlinks bundled under default inspection policy", async () => { + await writeAccessPackage(dir); + const snapshot = await loadGhostSnapshot( + resolveGhostPackage(undefined, dir), + ); + + const result = await inspectGhostMaterial(snapshot, { + nodeId: "asset.links", + locator: "materials/inside-link.txt", + repoRoot: dir, + }); + + expect(result).toMatchObject({ + ok: true, + tier: "bundled", + path: ".ghost/materials/inside.txt", + text: "Inside.\n", + untrusted: true, + }); + }); + + it("allows symlinked repo and material roots when real targets stay contained", async () => { + const repoLink = await writeSymlinkedRootsPackage(dir); + const snapshot = await loadGhostSnapshot( + resolveGhostPackage(undefined, repoLink), + ); + + const result = await inspectGhostMaterial(snapshot, { + nodeId: "asset.root", + locator: "materials/root-token.txt", + repoRoot: repoLink, + }); + + expect(result).toMatchObject({ + ok: true, + tier: "bundled", + path: "material-root/root-token.txt", + text: "Root token.\n", + untrusted: true, + }); + }); + + it("denies bundled symlinks that resolve outside the repo", async () => { + await writeAccessPackage(dir); + const outsideDir = `${dir}-outside`; + await mkdir(outsideDir, { recursive: true }); + await writeFile(join(outsideDir, "secret.txt"), "Secret.\n"); + await symlink( + join(outsideDir, "secret.txt"), + join(dir, ".ghost", "materials", "outside-repo-link.txt"), + ); + await writeFile( + join(dir, ".ghost", "asset.outside.md"), + "---\nfor: Outside.\nmaterials:\n - materials/outside-repo-link.txt\n---\n\nOutside prose.\n", + ); + const snapshot = await loadGhostSnapshot( + resolveGhostPackage(undefined, dir), + ); + + const result = await inspectGhostMaterial(snapshot, { + nodeId: "asset.outside", + locator: "materials/outside-repo-link.txt", + repoRoot: dir, + policy: { local: "bundled-and-referenced" }, + }); + + expect(result).toMatchObject({ + ok: false, + tier: "bundled", + path: ".ghost/materials/outside-repo-link.txt", + reason: "resolved material path escapes repo", + }); + + await rm(outsideDir, { recursive: true, force: true }); + }); +});