From 448e7febeb67e986d83e9d2fe495c00a50d36f62 Mon Sep 17 00:00:00 2001 From: Filippo Costa <209793088+filocosta46@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:35:39 +0200 Subject: [PATCH] fix(context): read only the selected project body in project briefs --- docs/architecture.md | 9 ++ packages/core/src/projects.mjs | 134 ++++++++++++++++++++- packages/core/src/working-context.mjs | 46 +------ tests/cli/brief.test.mjs | 35 ++++++ tests/core/working-context.test.mjs | 165 ++++++++++++++++++++++++++ 5 files changed, 346 insertions(+), 43 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index abf498da..1c356d2c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,6 +100,15 @@ stable id may stand alone; an alias shared by multiple catalog records requires the matching unique `project_id`. Malformed, conflicting, or differently attributed rows are excluded rather than widened into global context. +For This project, catalog discovery reads bounded README frontmatter before +opening the selected project's body. Sibling bodies stay unread; their size +does not spend the selected-file limit or prevent a valid brief. Catalog +identity checks still include slug, project alias, and stable-ID collisions. +Frontmatter is capped at 16 KiB per record, and an opening delimiter without a +closing delimiter inside that bound fails closed. Catalog files and directories +are revalidated around the selected body read so identity changes cannot reuse +the earlier selection. Shared projection selection is unchanged. + Projection work is bounded separately from visible output. One projection may open at most 512 source files and reserve at most 16 MiB of raw source bytes. Ordinary context, daily, project README, and signal files are capped at 1 MiB diff --git a/packages/core/src/projects.mjs b/packages/core/src/projects.mjs index 4d13f34e..ef399e29 100644 --- a/packages/core/src/projects.mjs +++ b/packages/core/src/projects.mjs @@ -6,8 +6,13 @@ import { createHash, randomUUID } from "node:crypto"; import { promisify } from "node:util"; import { parseDocument } from "yaml"; import { + ContainedReadError, + assertContainedDirectorySnapshotsUnchanged, + inspectContainedDirectory, + inspectContainedFile, readContainedDirectory, - readContainedFile + readContainedFile, + sameContainedFileSnapshot } from "./contained-read.mjs"; import { isPathWithin } from "./paths.mjs"; import { stableJson } from "./json.mjs"; @@ -41,6 +46,9 @@ const MAX_PROJECT_ROUTE_METADATA_BYTES = 64 * 1024; const MAX_PROJECT_ROUTE_FRONTMATTER_BYTES = 16 * 1024; const MAX_PROJECT_ROUTE_README_BYTES = 1024 * 1024; const MAX_PROJECT_ROUTE_STATE_BYTES = 256 * 1024; +const MAX_PROJECT_CONTEXT_FRONTMATTER_BYTES = 16 * 1024; +const MAX_PROJECT_CONTEXT_README_BYTES = 1024 * 1024; +const MAX_PROJECT_CONTEXT_CATALOG_ENTRIES = 256; const UNSELECTABLE_PROJECT_IDENTITY_CONTENT_ERRORS = new Set([ "DOTAIOS_EVIDENCE_FILE_TOO_LARGE", "DOTAIOS_EVIDENCE_FRONTMATTER_INVALID", @@ -510,10 +518,134 @@ async function managedRepositoryReceipt(context, projectPath) { /** Read the portable project catalog without consulting machine-local paths. */ export async function readProjectCatalog(options = {}) { const context = createContext(options); + if (options.projectSelector) return readScopedProjectCatalog(context, options); const records = await readProjectRecords(context); return records.map(toProjectCatalogRecord); } +/** Keep sibling identities for attribution checks without reading their bodies. */ +async function readScopedProjectCatalog(context, { projectSelector, budget }) { + const filesystem = context.fs; + const root = context.aiosPath; + const projectsPath = path.join(root, "projects"); + const listing = await readContainedDirectory(root, projectsPath, { + filesystem, + budget, + maxEntries: MAX_PROJECT_CONTEXT_CATALOG_ENTRIES, + tooManyCode: "DOTAIOS_PROJECT_DIRECTORY_LIMIT_EXCEEDED", + readdirOptions: { withFileTypes: true }, + returnSnapshot: true + }); + const directories = listing ? [{ path: projectsPath, snapshot: listing.snapshot }] : []; + const observations = []; + const records = []; + for (const entry of (listing?.entries || []).sort((left, right) => left.name.localeCompare(right.name))) { + if (entry.isSymbolicLink()) throw new ContainedReadError(); + if (!entry.isDirectory()) continue; + validateSlug(entry.name); + const directoryPath = path.join(projectsPath, entry.name); + const directorySnapshot = await inspectContainedDirectory(root, directoryPath, { filesystem, returnSnapshot: true }); + if (!directorySnapshot) throw new ContainedReadError("DOTAIOS_CONTEXT_SOURCE_CHANGED"); + directories.push({ path: directoryPath, snapshot: directorySnapshot }); + const readmePath = path.join(directoryPath, "README.md"); + const snapshot = await inspectContainedFile(root, readmePath, { filesystem }); + observations.push({ path: readmePath, snapshot }); + if (!snapshot) continue; + const bytes = await readContainedFile(root, readmePath, { + filesystem, + budget, + expectedSnapshot: snapshot, + prefixBytes: MAX_PROJECT_CONTEXT_FRONTMATTER_BYTES, + frontmatterOnly: true, + stopOnMissingFrontmatter: true + }); + // A missing opening delimiter is a legacy record, not permission to read + // its body. An opening without a bounded closing delimiter is invalid: + // treating truncated metadata as empty could hide an identity collision. + const hasFrontmatter = bytes.subarray(0, 5).toString("ascii").startsWith("---\n") + || bytes.subarray(0, 5).toString("ascii") === "---\r\n"; + const frontmatter = hasFrontmatter ? bytes.toString("utf8") : ""; + const match = hasFrontmatter ? FRONTMATTER_RE.exec(frontmatter) : null; + if (hasFrontmatter && ( + !Buffer.from(frontmatter, "utf8").equals(bytes) + || !match + || (!match[0].endsWith("\n") && bytes.length !== snapshot.stats.size) + )) { + throw new ContainedReadError("DOTAIOS_PROJECT_FRONTMATTER_INVALID"); + } + records.push(projectRecord(entry.name, readmePath, parseMarkdownSource(frontmatter, readmePath))); + } + await revalidateCatalog(); + assertUniqueProjectIds(records); + const catalog = records.map(toProjectCatalogRecord); + const scope = resolveProjectCatalogScope(projectSelector, catalog); + const selected = records.find((record) => record.slug === scope.filter); + const selectedSnapshot = observations.find((observation) => observation.path === selected.readmePath).snapshot; + const content = await readContainedFile(root, selected.readmePath, { + filesystem, + budget, + expectedSnapshot: selectedSnapshot, + maxBytes: MAX_PROJECT_CONTEXT_README_BYTES, + tooLargeCode: "DOTAIOS_CONTEXT_SOURCE_TOO_LARGE", + encoding: "utf8" + }); + await revalidateCatalog(); + const selectedProject = toProjectCatalogRecord(projectRecord( + selected.slug, selected.readmePath, parseMarkdownSource(content, selected.readmePath) + )); + return catalog.map((project) => project.slug === selected.slug ? selectedProject : project); + + async function revalidateCatalog() { + for (const observation of observations) { + const current = await inspectContainedFile(root, observation.path, { filesystem }); + if (observation.snapshot === null && current === null) continue; + if (!sameContainedFileSnapshot(observation.snapshot, current)) { + throw new ContainedReadError("DOTAIOS_CONTEXT_SOURCE_CHANGED"); + } + } + await assertContainedDirectorySnapshotsUnchanged(root, directories, { filesystem }); + } +} + +export function resolveProjectCatalogScope(reference, projects) { + if (!reference) return null; + const matches = projects.filter((project) => + project.id === reference || project.slug === reference || project.project === reference); + if (matches.length > 1) { + const error = new TypeError(`Project reference "${reference}" is ambiguous. Use its stable id.`); + error.code = "DOTAIOS_AMBIGUOUS_PROJECT"; + throw error; + } + if (matches.length === 0) { + const error = new TypeError("Project selector is unknown."); + error.code = "DOTAIOS_PROJECT_SELECTOR_UNKNOWN"; + throw error; + } + const selected = matches[0]; + const filter = selected.slug; + const aliases = projectAliases(selected); + const uniqueAliases = new Set( + [...aliases].filter((alias) => projects.filter((project) => projectAliases(project).has(alias)).length === 1) + ); + const selectedId = typeof selected?.id === "string" && selected.id.length > 0 ? selected.id : null; + const id = selectedId && projects.filter((project) => project.id === selectedId).length === 1 + ? selectedId + : null; + return { + aliases, + filter, + id, + uniqueAliases, + }; +} + +function projectAliases(project) { + return new Set( + [project?.slug, project?.project, project?.id] + .filter((value) => typeof value === "string" && value.length > 0) + ); +} + /** * Read the minimal bounded registration projection used by project-native * routing. This is the sole owner of portable metadata and machine-local diff --git a/packages/core/src/working-context.mjs b/packages/core/src/working-context.mjs index cfe29919..406027bc 100644 --- a/packages/core/src/working-context.mjs +++ b/packages/core/src/working-context.mjs @@ -11,7 +11,7 @@ import { sameContainedFileSnapshot } from "./contained-read.mjs"; import { resolveMemoryPolicy } from "./memory-policy.mjs"; -import { readProjectCatalog } from "./projects.mjs"; +import { readProjectCatalog, resolveProjectCatalogScope } from "./projects.mjs"; import { readSection, readSubsection } from "./sections.mjs"; export const DEFAULT_VISIBLE_CHARACTER_BUDGET = 6000; @@ -156,20 +156,21 @@ export async function selectWorkingContext(aiosPath, options = {}, dependencies maxBytes: MAX_TIMELINE_SOURCE_BYTES, sourcePath: "memory/events.jsonl", }), - readProjectCatalog({ aiosPath, fs: projectFilesystem }), + readProjectCatalog({ aiosPath, fs: projectFilesystem, projectSelector: requestedProject, budget: readBudget }), ]); const authorityAfter = await inspectContainedFile(aiosPath, authorityPath, { filesystem }); if (!sameContainedFileSnapshot(authorityBefore, authorityAfter)) { throw new ContainedReadError("DOTAIOS_CONTEXT_SOURCE_CHANGED"); } } catch (cause) { + if (["DOTAIOS_AMBIGUOUS_PROJECT", "DOTAIOS_PROJECT_SELECTOR_UNKNOWN"].includes(cause?.code)) throw cause; const error = new Error("DotAIOS could not read working context safely.", { cause }); error.code = "DOTAIOS_WORKING_CONTEXT_READ_FAILED"; throw error; } const [identity, priorities, workNote, decisionsLog, todayNote, yesterdayNote, sessionEntries, signalEntries, eventEntries, projects] = sources; - const projectScope = resolveProjectScope(requestedProject, projects); + const projectScope = resolveProjectCatalogScope(requestedProject, projects); const projectFilter = projectScope?.filter || null; const sessions = stableSessionOrder(sessionEntries) .filter((session) => matchesProject(session, projectScope, memoryPolicy.mode)) @@ -607,45 +608,6 @@ function timelineKey(entry) { return [entry.type, entry.source, entry.project || "", entry.project_id || "", timelineSummary(entry)].join("\n"); } -function resolveProjectScope(reference, projects) { - if (!reference) return null; - const matches = projects.filter((project) => - project.id === reference || project.slug === reference || project.project === reference); - if (matches.length > 1) { - const error = new TypeError(`Project reference "${reference}" is ambiguous. Use its stable id.`); - error.code = "DOTAIOS_AMBIGUOUS_PROJECT"; - throw error; - } - if (matches.length === 0) { - const error = new TypeError("Project selector is unknown."); - error.code = "DOTAIOS_PROJECT_SELECTOR_UNKNOWN"; - throw error; - } - const selected = matches[0]; - const filter = selected.slug; - const aliases = projectAliases(selected); - const uniqueAliases = new Set( - [...aliases].filter((alias) => projects.filter((project) => projectAliases(project).has(alias)).length === 1) - ); - const selectedId = typeof selected?.id === "string" && selected.id.length > 0 ? selected.id : null; - const id = selectedId && projects.filter((project) => project.id === selectedId).length === 1 - ? selectedId - : null; - return { - aliases, - filter, - id, - uniqueAliases, - }; -} - -function projectAliases(project) { - return new Set( - [project?.slug, project?.project, project?.id] - .filter((value) => typeof value === "string" && value.length > 0) - ); -} - function isOperationalDate(date, today, yesterday) { return date === today || date === yesterday; } diff --git a/tests/cli/brief.test.mjs b/tests/cli/brief.test.mjs index 4f326d12..3c0846ba 100644 --- a/tests/cli/brief.test.mjs +++ b/tests/cli/brief.test.mjs @@ -7,6 +7,7 @@ import assert from "node:assert/strict"; import { buildDailyBrief } from "../../packages/cli/src/commands/brief.mjs"; import { isoDate } from "../../packages/core/src/memory.mjs"; +import { buildWorkingContext } from "../../packages/core/src/working-context.mjs"; const repoRoot = path.resolve(new URL("../..", import.meta.url).pathname); const cli = path.join(repoRoot, "packages", "cli", "src", "index.mjs"); @@ -41,6 +42,40 @@ function yesterday() { return isoDate(date); } +test("project compact CLI, hook and MCP match core context despite an oversized sibling body", async (t) => { + const { aiosPath, tempRoot } = setupAios(); + t.after(() => fs.rmSync(tempRoot, { recursive: true, force: true })); + for (const slug of ["selected", "sibling"]) { + const directory = path.join(aiosPath, "projects", slug); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(path.join(directory, "README.md"), + `---\nid: ${slug}-id\nproject: ${slug}\nstatus: active\n---\n# ${slug}\n\n${slug === "selected" ? "SELECTED_CONTEXT_BODY" : "PRIVATE_SIBLING_BODY\n" + "x".repeat(1024 * 1024)}\n`); + } + const before = snapshotTree(aiosPath); + const args = ["brief", "--compact", "--memory", "project", "--project", "selected-id", "--path", aiosPath]; + const plain = run(args).stdout.trimEnd(); + const hook = JSON.parse(run([...args, "--json"]).stdout); + const mcp = spawnSync(process.execPath, [path.join(repoRoot, "packages/mcp/src/server.mjs"), "--path", aiosPath], { + cwd: repoRoot, + encoding: "utf8", + input: `${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { + name: "read_working_context", arguments: { memory: "project", project: "selected-id" } + } })}\n` + }); + assert.equal(mcp.status, 0, mcp.stderr); + const response = JSON.parse(mcp.stdout.trim()); + assert.equal(response.error, undefined); + const mcpContext = JSON.parse(response.result.content[0].text); + const { rendered } = await buildWorkingContext(aiosPath, { memory: "project", project: "selected-id" }); + + assert.match(rendered, /SELECTED_CONTEXT_BODY/); + assert.doesNotMatch(rendered, /PRIVATE_SIBLING_BODY/); + assert.equal(plain, rendered); + assert.equal(hook.hookSpecificOutput.additionalContext, rendered); + assert.equal(mcpContext.markdown, rendered); + assert.deepEqual(snapshotTree(aiosPath), before); +}); + test("compact brief never injects owned skill bodies", () => { const { aiosPath } = setupAios(); const canary = "FULL_SKILL_BODY_MUST_STAY_LAZY_94A7"; diff --git a/tests/core/working-context.test.mjs b/tests/core/working-context.test.mjs index 6a1861f5..93781cea 100644 --- a/tests/core/working-context.test.mjs +++ b/tests/core/working-context.test.mjs @@ -1,4 +1,5 @@ import fs from "node:fs"; +import filesystem from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -41,6 +42,170 @@ function fixedClock() { return new Date(FIXED_NOW.getTime()); } +test("This project reads only sibling identity metadata, even when a sibling body exceeds the source limit", async (t) => { + const aiosPath = tmpAios(); + t.after(() => fs.rmSync(aiosPath, { recursive: true, force: true })); + registerProject(aiosPath, "selected"); + registerProject(aiosPath, "sibling"); + const selectedReadme = path.join(aiosPath, "projects", "selected", "README.md"); + const siblingReadme = path.join(aiosPath, "projects", "sibling", "README.md"); + const siblingMetadata = "---\nid: sibling-id\nproject: sibling\nstatus: active\n---\n"; + fs.appendFileSync(selectedReadme, "\nSELECTED_PROJECT_BODY\n"); + fs.writeFileSync(siblingReadme, `${siblingMetadata}PRIVATE_SIBLING_BODY\n${"x".repeat(1024 * 1024)}`); + let siblingBytesRead = 0; + const guardedFilesystem = { + ...filesystem, + async open(file, ...args) { + const handle = await filesystem.open(file, ...args); + if (file !== siblingReadme) return handle; + return { + stat: (...args) => handle.stat(...args), + close: () => handle.close(), + async read(...args) { + const result = await handle.read(...args); + siblingBytesRead += result.bytesRead; + assert.ok(siblingBytesRead <= Buffer.byteLength(siblingMetadata), "sibling body bytes must never be read"); + return result; + }, + async readFile() { + assert.fail("sibling body must never be read without a metadata bound"); + } + }; + } + }; + + const { rendered } = await buildWorkingContext(aiosPath, { + memory: "project", project: "selected-id" + }, { filesystem: guardedFilesystem, clock: fixedClock }); + + assert.match(rendered, /SELECTED_PROJECT_BODY/); + assert.doesNotMatch(rendered, /PRIVATE_SIBLING_BODY/); + assert.ok(siblingBytesRead > 0, "catalog identity still participates in collision checks"); +}); + +test("This project preserves selected-body limits and rejects incomplete identity metadata", async (t) => { + const aiosPath = tmpAios(); + t.after(() => fs.rmSync(aiosPath, { recursive: true, force: true })); + registerProject(aiosPath, "selected"); + const selectedReadme = path.join(aiosPath, "projects", "selected", "README.md"); + const original = fs.readFileSync(selectedReadme, "utf8"); + fs.appendFileSync(selectedReadme, "x".repeat(1024 * 1024)); + await assert.rejects( + () => buildWorkingContext(aiosPath, { project: "selected-id" }, { clock: fixedClock }), + (error) => error.code === "DOTAIOS_WORKING_CONTEXT_READ_FAILED" + && error.cause?.code === "DOTAIOS_CONTEXT_SOURCE_TOO_LARGE" + ); + fs.writeFileSync(selectedReadme, original); + registerProject(aiosPath, "sibling"); + fs.writeFileSync(path.join(aiosPath, "projects", "sibling", "README.md"), + `---\n${"# metadata padding\n".repeat(1000)}id: selected-id\n---\n`); + await assert.rejects( + () => buildWorkingContext(aiosPath, { project: "selected-id" }, { clock: fixedClock }), + (error) => error.code === "DOTAIOS_WORKING_CONTEXT_READ_FAILED" + && error.cause?.code === "DOTAIOS_PROJECT_FRONTMATTER_INVALID" + ); +}); + +test("This project distinguishes actual EOF from a truncated frontmatter delimiter", async (t) => { + const aiosPath = tmpAios(); + t.after(() => fs.rmSync(aiosPath, { recursive: true, force: true })); + registerProject(aiosPath, "selected"); + registerProject(aiosPath, "sibling"); + const siblingReadme = path.join(aiosPath, "projects", "sibling", "README.md"); + const opening = "---\nproject: sibling\n#"; + const prefix = `${opening}${"x".repeat(16 * 1024 - Buffer.byteLength(`${opening}\n---`))}\n---`; + assert.equal(Buffer.byteLength(prefix), 16 * 1024); + fs.writeFileSync(siblingReadme, `${prefix}: ignored\nid: selected-id\n---\n`); + await assert.rejects( + () => buildWorkingContext(aiosPath, { project: "selected-id" }, { clock: fixedClock }), + (error) => error.code === "DOTAIOS_WORKING_CONTEXT_READ_FAILED" + && error.cause?.code === "DOTAIOS_PROJECT_FRONTMATTER_INVALID" + ); + + // A closing delimiter at the same byte bound is valid at the actual EOF. + fs.writeFileSync(siblingReadme, prefix); + const { context } = await buildWorkingContext(aiosPath, { project: "selected-id" }, { clock: fixedClock }); + assert.equal(context.projectFilter, "selected"); +}); + +test("This project resolves aliases and rejects cross-namespace collisions before reading bodies", async (t) => { + const aiosPath = tmpAios(); + t.after(() => fs.rmSync(aiosPath, { recursive: true, force: true })); + registerProject(aiosPath, "selected"); + registerProject(aiosPath, "sibling"); + const selectedReadme = path.join(aiosPath, "projects", "selected", "README.md"); + fs.writeFileSync(selectedReadme, "---\nproject_id: selected-id\nproject: work-alias\n---\n# Work\n\nSELECTED_ALIAS_BODY\n"); + const aliased = await buildWorkingContext(aiosPath, { project: "work-alias" }, { clock: fixedClock }); + assert.equal(aliased.context.projectFilter, "selected"); + assert.match(aliased.rendered, /SELECTED_ALIAS_BODY/); + + // Oversized bodies would cause a read error if selection did not stop first. + fs.appendFileSync(selectedReadme, "x".repeat(1024 * 1024)); + fs.writeFileSync(path.join(aiosPath, "projects", "sibling", "README.md"), + `---\nid: work-alias\nproject: selected-id\n---\n${"x".repeat(1024 * 1024)}`); + for (const selector of ["work-alias", "selected-id"]) { + await assert.rejects( + () => buildWorkingContext(aiosPath, { project: selector }, { clock: fixedClock }), + (error) => error.code === "DOTAIOS_AMBIGUOUS_PROJECT" + ); + } + await assert.rejects( + () => buildWorkingContext(aiosPath, { project: "unknown" }, { clock: fixedClock }), + (error) => error.code === "DOTAIOS_PROJECT_SELECTOR_UNKNOWN" + ); +}); + +test("This project refuses catalog identity changes across metadata and body reads", async (t) => { + for (const change of ["selected-identity", "sibling-alias", "new-collision"]) { + await t.test(change, async (t) => { + const aiosPath = tmpAios(); + t.after(() => fs.rmSync(aiosPath, { recursive: true, force: true })); + registerProject(aiosPath, "selected"); + registerProject(aiosPath, "sibling"); + const selectedReadme = path.join(aiosPath, "projects", "selected", "README.md"); + const original = fs.readFileSync(selectedReadme, "utf8"); + let changed = false; + const changingFilesystem = { + ...filesystem, + async open(file, ...args) { + const handle = await filesystem.open(file, ...args); + if (file !== selectedReadme) return handle; + let bytesRead = 0; + return { + stat: (...args) => handle.stat(...args), + async read(...args) { + const result = await handle.read(...args); + bytesRead += result.bytesRead; + return result; + }, + async close() { + await handle.close(); + const shouldChange = change === "selected-identity" ? bytesRead > 0 : bytesRead === Buffer.byteLength(original); + if (changed || !shouldChange) return; + changed = true; + if (change === "selected-identity") { + fs.writeFileSync(selectedReadme, original.replace("id: selected-id", "id: different-id")); + } else if (change === "sibling-alias") { + fs.appendFileSync(path.join(aiosPath, "projects", "sibling", "README.md"), "\nChanged after selection\n"); + const sibling = path.join(aiosPath, "projects", "sibling", "README.md"); + fs.writeFileSync(sibling, fs.readFileSync(sibling, "utf8").replace("project: sibling", "project: selected-id")); + } else { + registerProject(aiosPath, "new-collision", "selected-id"); + } + } + }; + } + }; + await assert.rejects( + () => buildWorkingContext(aiosPath, { project: "selected-id" }, { filesystem: changingFilesystem, clock: fixedClock }), + (error) => error.code === "DOTAIOS_WORKING_CONTEXT_READ_FAILED" + && error.cause?.code === "DOTAIOS_CONTEXT_SOURCE_CHANGED" + ); + assert.equal(changed, true, "the fixture must reach the source-change boundary"); + }); + } +}); + test("project filter scopes sessions, namespaced signals, and events with stable ordering", async () => { const aiosPath = tmpAios(); registerProject(aiosPath, "project-a");