Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/material-realpath-policy.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions packages/ghost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 37 additions & 8 deletions packages/ghost/src/embed/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -98,30 +99,58 @@ export async function inspectGhostMaterial(
);
}

let effectiveTier: Awaited<ReturnType<typeof effectiveLocalMaterialTier>>;
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<ReturnType<typeof stat>>;
try {
info = await stat(contained.realPath);
} catch {
return rejected(
request,
"matched file could not be read",
resolved.tier,
effectiveTier,
contained.repoRelativePath,
);
}
if (!info.isFile()) {
return rejected(
request,
"not a file",
resolved.tier,
effectiveTier,
contained.repoRelativePath,
);
}
if (info.size > policy.maxBytes) {
return rejected(
request,
`exceeds ${policy.maxBytes} byte inspect limit`,
resolved.tier,
effectiveTier,
contained.repoRelativePath,
info.size,
);
Expand All @@ -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,
Expand All @@ -146,15 +175,15 @@ export async function inspectGhostMaterial(
return rejected(
request,
"matched file could not be read",
resolved.tier,
effectiveTier,
contained.repoRelativePath,
);
}
if (buffer.byteLength > policy.maxBytes) {
return rejected(
request,
`exceeds ${policy.maxBytes} byte inspect limit`,
resolved.tier,
effectiveTier,
contained.repoRelativePath,
buffer.byteLength,
mime,
Expand All @@ -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,
Expand All @@ -184,7 +213,7 @@ export async function inspectGhostMaterial(
return rejected(
request,
"not valid UTF-8 text",
resolved.tier,
effectiveTier,
contained.repoRelativePath,
buffer.byteLength,
mime,
Expand Down
39 changes: 37 additions & 2 deletions packages/ghost/src/ghost-core/material-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,25 @@ async function transportFile(
};
}

const base = { locator, tier, path: contained.repoRelativePath };
let effectiveTier: Exclude<TransportedMaterialTier, "url">;
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<ReturnType<typeof stat>>;
try {
s = await stat(contained.realPath);
Expand All @@ -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,
Expand Down Expand Up @@ -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<TransportedMaterialTier, "url">,
realPath: string,
options: MaterialTransportOptions,
): Promise<Exclude<TransportedMaterialTier, "url">> {
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";
Expand Down
12 changes: 12 additions & 0 deletions packages/ghost/src/skill-bundle/references/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading