diff --git a/backend/src/workspaces/git-repository.ts b/backend/src/workspaces/git-repository.ts index daca086b..c562c8bf 100644 --- a/backend/src/workspaces/git-repository.ts +++ b/backend/src/workspaces/git-repository.ts @@ -154,6 +154,22 @@ export class GitWorkspaceRepository { return (await this.git(["rev-parse", `HEAD:${path}`])).trim(); } + /** Assert that a canonical Evidence root is a Git tree at an exact commit. */ + async assertTreeAtRevision(revision: string, repoRelativePath: string): Promise { + if (!/^[0-9a-f]{40}$/.test(revision) + || !/^workspace-content\/[a-z][a-z0-9-]{2,62}\/evidence$/.test(repoRelativePath)) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence revision is invalid"); + } + const type = (await this.git( + ["cat-file", "-t", `${revision}:${repoRelativePath}`], + {}, + "Workspace Evidence root is invalid", + )).trim(); + if (type !== "tree") { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence root is invalid"); + } + } + /** Write only a validated registry artifact below the checked-out repository. */ async writeRegistryFile(path: string, source: string): Promise { this.assertRegistryArtifactPath(path); @@ -249,7 +265,11 @@ export class GitWorkspaceRepository { } } - private async git(args: string[], env: NodeJS.ProcessEnv = {}): Promise { + private async git( + args: string[], + env: NodeJS.ProcessEnv = {}, + invalidObjectMessage?: string, + ): Promise { try { const { stdout } = await execFileAsync( "git", @@ -258,6 +278,10 @@ export class GitWorkspaceRepository { ); return stdout; } catch (error) { + if (invalidObjectMessage && typeof error === "object" && error !== null + && "code" in error && typeof error.code === "number") { + throw new WorkspaceRegistryError("workspace_invalid", invalidObjectMessage); + } throw this.sanitizeGitError(error); } } diff --git a/backend/src/workspaces/registry.ts b/backend/src/workspaces/registry.ts index 84b45380..a495b1d7 100644 --- a/backend/src/workspaces/registry.ts +++ b/backend/src/workspaces/registry.ts @@ -387,6 +387,9 @@ export class WorkspaceRegistry { } if (request.action === "create" && existing) throw await this.conflictFor(request, status.head!, existing, local); if (request.action !== "create" && !existing) throw await this.conflictFor(request, status.head!, existing, local); + if (request.action !== "delete") { + await this.assertEvidenceContext(request.workspace, status.head!); + } const yamlPath = workspacePath(id); const docPaths = this.documentationPaths(id); @@ -472,6 +475,20 @@ export class WorkspaceRegistry { )); } + private async assertEvidenceContext(workspace: WorkspaceDescriptor, revision: string): Promise { + if (!isCanonicalWorkspace(workspace)) return; + if (workspace.evidence?.source.type !== "filesystem") return; + // P6 owns recursive containment. Here we deliberately validate only the declared root object. + await this.repository.assertTreeAtRevision(revision, workspace.evidence.source.uri); + } + + private async assertSnapshotEvidenceContexts(state: ActiveState): Promise { + for (const revision of state.revisions) { + const workspace = parseWorkspaceYaml(await readFile(revision.snapshotPath, "utf8")); + await this.assertEvidenceContext(workspace, revision.commit); + } + } + private async activate(commit: string): Promise { const safeHead = safeCommit(commit); const files = await this.repository.workspacePaths(); @@ -495,6 +512,7 @@ export class WorkspaceRegistry { if (workspace.workspace.id !== id) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID does not match its repository path"); } + await this.assertEvidenceContext(workspace, safeHead); let snapshotSource = source; const state: WorkspaceRevision["state"] = isCanonicalWorkspace(workspace) ? "operational" @@ -721,6 +739,7 @@ export class WorkspaceRegistry { `${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`, ]); await this.assertManifestFiles(directory, manifest.files, legacyExpected); + await this.assertSnapshotEvidenceContexts(state); const expected = this.expectedSnapshotFiles(state); const files = Object.fromEntries(expected.map((name) => [name, manifest.files[name]])); await this.writeSnapshotManifest(directory, { ...state, files }); @@ -757,6 +776,7 @@ export class WorkspaceRegistry { throw new Error("manifest revisions do not match active state"); } await this.assertManifestFiles(directory, manifest.files, this.expectedSnapshotFiles(state)); + await this.assertSnapshotEvidenceContexts(state); } catch (error) { if (error instanceof WorkspaceRegistryError) throw error; throw new WorkspaceRegistryError("workspace_invalid", "Workspace snapshot integrity check failed"); diff --git a/backend/test/workspace-registry.test.ts b/backend/test/workspace-registry.test.ts index 0111b9bd..7be4cb24 100644 --- a/backend/test/workspace-registry.test.ts +++ b/backend/test/workspace-registry.test.ts @@ -36,6 +36,14 @@ llm_policy: allowed: [zai/glm-5.2] `; +function withFilesystemEvidence(source: string, id = "psd-clinical"): string { + return source.concat(`evidence: + source: + type: filesystem + uri: workspace-content/${id}/evidence +`); +} + function withDwhRestTransport(source: string): string { return source.replace( "supported_transports: [postgres_direct]", @@ -181,7 +189,15 @@ async function fixture(workspaceSource = validYaml): Promise<{ await git(source, ["config", "user.email", "workspace-registry@example.invalid"]); mkdirSync(join(source, "workspaces")); writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), workspaceSource); - await git(source, ["add", "workspaces/psd-clinical.yaml"]); + if (workspaceSource.includes("type: filesystem")) { + mkdirSync(join(source, "workspace-content", "psd-clinical", "evidence"), { recursive: true }); + mkdirSync(join(source, "workspace-content", "research", "evidence"), { recursive: true }); + writeFileSync(join(source, "workspace-content", "psd-clinical", "evidence", "guide.md"), "guide v1\n"); + writeFileSync(join(source, "workspace-content", "research", "evidence", "guide.md"), "research guide\n"); + await git(source, ["add", "workspaces", "workspace-content"]); + } else { + await git(source, ["add", "workspaces/psd-clinical.yaml"]); + } await git(source, ["commit", "-m", "Initial workspace"]); await git(source, ["remote", "add", "origin", remote]); await git(source, ["push", "origin", "main"]); @@ -247,6 +263,16 @@ function workspaceWith( }; } +function filesystemWorkspace(id: string): CanonicalWorkspace { + return parseWorkspaceYaml(withFilesystemEvidence( + validYaml + .replace("id: psd-clinical", `id: ${id}`) + .replace("name: Policlinico San Donato", `name: ${id}`) + .replace("collection: psd-clinical", `collection: ${id}`), + id, + )) as CanonicalWorkspace; +} + async function checkoutStatus(checkout: string): Promise<{ porcelain: string; divergence: string }> { return { porcelain: await gitOutput(checkout, ["status", "--porcelain"]), @@ -321,6 +347,144 @@ test("concurrent first lists lazily bootstrap a clean registry once safely", asy expect(existsSync(join(root, "state", "active.json"))).toBe(true); }); +test("publishes a filesystem descriptor only when its Evidence tree exists in the pulled base", async () => { + const remote = await fixture(withFilesystemEvidence(validYaml)); + const root = join(remote.root, "registry"); + const registry = new WorkspaceRegistry(config(root, remote.remote)); + await registry.bootstrap(); + const evidencePath = "workspace-content/research/evidence"; + const initialTree = await gitOutput(remote.root, [ + "--git-dir", remote.remote, "rev-parse", `${remote.initialCommit}:${evidencePath}`, + ]); + + const created = await registry.publish({ + action: "create", + workspace: filesystemWorkspace("research"), + baseCommit: remote.initialCommit, + }); + + expect(created?.commit).not.toBe(remote.initialCommit); + await expect(runFile("git", [ + "--git-dir", remote.remote, "cat-file", "-e", `${created!.commit}:workspaces/research.yaml`, + ], { cwd: remote.root })).resolves.toBeDefined(); + await expect(runFile("git", [ + "--git-dir", remote.remote, "cat-file", "-e", `${created!.commit}:${evidencePath}/guide.md`, + ], { cwd: remote.root })).resolves.toBeDefined(); + expect(await gitOutput(remote.root, [ + "--git-dir", remote.remote, "rev-parse", `${created!.commit}:${evidencePath}`, + ])).toBe(initialTree); + + const remoteHeadBeforeMissing = await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"]); + await expect(registry.publish({ + action: "create", + workspace: filesystemWorkspace("missing-tree"), + baseCommit: created!.commit, + })).rejects.toMatchObject({ code: "workspace_invalid" }); + expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe( + remoteHeadBeforeMissing, + ); + expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" }); +}); + +test.each(["missing", "blob"])( + "rejects a remote filesystem descriptor with a %s Evidence root and keeps the active snapshot", + async (invalidKind) => { + const remote = await fixture(withFilesystemEvidence(validYaml)); + const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); + await registry.bootstrap(); + const evidenceRoot = join(remote.source, "workspace-content", "psd-clinical", "evidence"); + rmSync(evidenceRoot, { recursive: true, force: true }); + if (invalidKind === "blob") writeFileSync(evidenceRoot, "not a tree\n"); + await git(remote.source, ["add", "-A", "workspace-content/psd-clinical/evidence"]); + await git(remote.source, ["commit", "-m", `Make Evidence root ${invalidKind}`]); + await git(remote.source, ["push", "origin", "main"]); + const invalidCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]); + + await expect(registry.pull()).rejects.toMatchObject({ code: "workspace_invalid" }); + expect(invalidCommit).not.toBe(remote.initialCommit); + await expect(registry.read("psd-clinical")).resolves.toMatchObject({ + revision: { commit: remote.initialCommit }, + }); + }, +); + +test("creates an immutable descriptor revision for a content-only Evidence commit", async () => { + const remote = await fixture(withFilesystemEvidence(validYaml)); + const root = join(remote.root, "registry"); + const registry = new WorkspaceRegistry(config(root, remote.remote)); + await registry.bootstrap(); + const initial = await registry.read("psd-clinical"); + const evidencePath = "workspace-content/psd-clinical/evidence"; + const initialTree = await gitOutput(remote.source, ["rev-parse", `${remote.initialCommit}:${evidencePath}`]); + writeFileSync(join(remote.source, evidencePath, "guide.md"), "guide v2\n"); + await git(remote.source, ["add", `${evidencePath}/guide.md`]); + await git(remote.source, ["commit", "-m", "Update Evidence only"]); + await git(remote.source, ["push", "origin", "main"]); + const contentCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]); + const contentTree = await gitOutput(remote.source, ["rev-parse", `${contentCommit}:${evidencePath}`]); + + await registry.pull(); + const current = await registry.read("psd-clinical"); + + expect(contentTree).not.toBe(initialTree); + expect(current.revision).toMatchObject({ commit: contentCommit, blob: initial.revision.blob }); + expect(current.revision.snapshotPath).not.toBe(initial.revision.snapshotPath); + expect(readFileSync(current.revision.snapshotPath, "utf8")).toBe( + readFileSync(initial.revision.snapshotPath, "utf8"), + ); + await expect(runFile("git", [ + "--git-dir", remote.remote, "cat-file", "-e", `${contentCommit}:${evidencePath}/guide.md`, + ], { cwd: remote.root })).resolves.toBeDefined(); +}); + +test("rejects a stale API update after a content-only Evidence commit", async () => { + const remote = await fixture(withFilesystemEvidence(validYaml)); + const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); + await registry.bootstrap(); + const initial = await registry.read("psd-clinical"); + const guide = join(remote.source, "workspace-content", "psd-clinical", "evidence", "guide.md"); + writeFileSync(guide, "curator content\n"); + await git(remote.source, ["add", "workspace-content/psd-clinical/evidence/guide.md"]); + await git(remote.source, ["commit", "-m", "Curator Evidence update"]); + await git(remote.source, ["push", "origin", "main"]); + const curatorCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]); + + await expect(registry.publish({ + action: "update", + workspace: filesystemWorkspace("psd-clinical"), + baseCommit: initial.revision.commit, + baseBlob: initial.revision.blob, + })).rejects.toMatchObject({ + code: "workspace_conflict", + expected: { commit: initial.revision.commit, blob: initial.revision.blob }, + actual: { commit: curatorCommit, blob: initial.revision.blob }, + }); + expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(curatorCommit); +}); + +test("keeps content-only historical descriptor revisions distinguishable by commit", async () => { + const remote = await fixture(withFilesystemEvidence(validYaml)); + const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); + await registry.bootstrap(); + writeFileSync( + join(remote.source, "workspace-content", "psd-clinical", "evidence", "guide.md"), + "historical content\n", + ); + await git(remote.source, ["add", "workspace-content/psd-clinical/evidence/guide.md"]); + await git(remote.source, ["commit", "-m", "Retained Evidence update"]); + await git(remote.source, ["push", "origin", "main"]); + const contentCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]); + await registry.pull(); + await registry.reconcileSnapshotRetention([remote.initialCommit]); + + const retained = (await registry.listRetainedSnapshots()).filter(({ id }) => id === "psd-clinical"); + expect(retained.map(({ commit }) => commit)).toEqual([contentCommit, remote.initialCommit]); + const oldPinned = await registry.readPinned("psd-clinical", remote.initialCommit); + const newPinned = await registry.readPinned("psd-clinical", contentCommit); + expect(oldPinned.workspaceConfigPath).not.toBe(newPinned.workspaceConfigPath); + expect(oldPinned.workspace).toEqual(newPinned.workspace); +}); + test("publishes create, update, and delete with the configured Git author identity", async () => { const remote = await fixture(); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote, { diff --git a/backend/test/workspaces-git-repository.test.ts b/backend/test/workspaces-git-repository.test.ts index d78e2ab4..9ffbdae2 100644 --- a/backend/test/workspaces-git-repository.test.ts +++ b/backend/test/workspaces-git-repository.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -45,7 +45,7 @@ async function git(cwd: string, args: string[]): Promise { await runFile("git", args, { cwd }); } -async function temporaryRemote(): Promise<{ root: string; remote: string; initialCommit: string }> { +async function temporaryRemote(): Promise<{ root: string; remote: string; source: string; initialCommit: string }> { const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-")); temporaryRoots.push(root); const remote = join(root, "remote.git"); @@ -57,12 +57,23 @@ async function temporaryRemote(): Promise<{ root: string; remote: string; initia await git(source, ["config", "user.email", "workspace-registry@example.invalid"]); mkdirSync(join(source, "workspaces")); writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), validYaml); - await git(source, ["add", "workspaces/psd-clinical.yaml"]); + writeFileSync(join(source, "workspaces", "research.yaml"), validYaml + .replace("id: psd-clinical", "id: research")); + mkdirSync(join(source, "workspace-content", "research", "evidence"), { recursive: true }); + mkdirSync(join(source, "workspace-content", "other", "evidence"), { recursive: true }); + mkdirSync(join(source, "workspace-content", "blob"), { recursive: true }); + mkdirSync(join(source, "workspace-content", "link"), { recursive: true }); + writeFileSync(join(source, "workspace-content", "research", "evidence", "guide.md"), "guide v1\n"); + writeFileSync(join(source, "workspace-content", "other", "evidence", "other.md"), "other\n"); + writeFileSync(join(source, "workspace-content", "blob", "evidence"), "not a tree\n"); + symlinkSync("../research/evidence", join(source, "workspace-content", "link", "evidence")); + symlinkSync("guide.md", join(source, "workspace-content", "research", "evidence", "nested-link")); + await git(source, ["add", "workspaces", "workspace-content"]); await git(source, ["commit", "-m", "Initial workspace"]); await git(source, ["remote", "add", "origin", remote]); await git(source, ["push", "origin", "main"]); const { stdout } = await runFile("git", ["rev-parse", "HEAD"], { cwd: source }); - return { root, remote, initialCommit: stdout.trim() }; + return { root, remote, source, initialCommit: stdout.trim() }; } function config(root: string, remoteUrl: string): WorkspaceRegistryConfig { @@ -94,6 +105,101 @@ test("bootstraps a persistent checkout from a local bare repository", async () = }); }); +test("accepts only a tree at the declared Evidence root for the requested revision", async () => { + const fixture = await temporaryRemote(); + const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); + await repository.bootstrap(); + + await expect(repository.assertTreeAtRevision( + fixture.initialCommit, + "workspace-content/research/evidence", + )).resolves.toBeUndefined(); + await expect(repository.assertTreeAtRevision( + fixture.initialCommit, + "workspace-content/missing/evidence", + )).rejects.toMatchObject({ code: "workspace_invalid" }); + await expect(repository.assertTreeAtRevision( + fixture.initialCommit, + "workspace-content/blob/evidence", + )).rejects.toMatchObject({ code: "workspace_invalid" }); + await expect(repository.assertTreeAtRevision( + fixture.initialCommit, + "workspace-content/link/evidence", + )).rejects.toMatchObject({ code: "workspace_invalid" }); +}); + +test("redacts Git failures while checking an Evidence tree", async () => { + const fixture = await temporaryRemote(); + const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); + await repository.bootstrap(); + rmSync(repository.repoPath, { recursive: true, force: true }); + + const error = await repository.assertTreeAtRevision( + fixture.initialCommit, + "workspace-content/research/evidence", + ).catch((failure: unknown) => failure); + expect(error).toMatchObject({ code: "git_unavailable", message: "Workspace Git operation failed" }); + expect((error as Error).message).not.toContain(fixture.root); +}); + +test("binds Evidence tree validation to old and new content-only commits", async () => { + const fixture = await temporaryRemote(); + const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); + await repository.bootstrap(); + writeFileSync(join(fixture.source, "workspace-content", "research", "evidence", "guide.md"), "guide v2\n"); + await git(fixture.source, ["add", "workspace-content/research/evidence/guide.md"]); + await git(fixture.source, ["commit", "-m", "Update Evidence content"]); + await git(fixture.source, ["push", "origin", "main"]); + const { stdout } = await runFile("git", ["rev-parse", "HEAD"], { cwd: fixture.source }); + const newCommit = stdout.trim(); + await repository.pull(); + const oldTree = (await runFile("git", ["rev-parse", `${fixture.initialCommit}:workspace-content/research/evidence`], { + cwd: fixture.source, + })).stdout.trim(); + const newTree = (await runFile("git", ["rev-parse", `${newCommit}:workspace-content/research/evidence`], { + cwd: fixture.source, + })).stdout.trim(); + + expect(newTree).not.toBe(oldTree); + await expect(repository.assertTreeAtRevision( + fixture.initialCommit, + "workspace-content/research/evidence", + )).resolves.toBeUndefined(); + await expect(repository.assertTreeAtRevision( + newCommit, + "workspace-content/research/evidence", + )).resolves.toBeUndefined(); +}); + +test("defers nested Evidence symlink containment to P6", async () => { + const fixture = await temporaryRemote(); + const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); + await repository.bootstrap(); + + // Task 2 validates only the declared root object. Recursive containment remains a P6 boundary. + await expect(repository.assertTreeAtRevision( + fixture.initialCommit, + "workspace-content/research/evidence", + )).resolves.toBeUndefined(); +}); + +test("rejects malformed revisions and shell-like paths without executing them", async () => { + const fixture = await temporaryRemote(); + const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); + await repository.bootstrap(); + const marker = join(fixture.root, "shell-marker"); + + await expect(repository.assertTreeAtRevision( + "HEAD", + "workspace-content/research/evidence", + )).rejects.toMatchObject({ code: "workspace_invalid" }); + await expect(repository.assertTreeAtRevision( + fixture.initialCommit, + `workspace-content/research/evidence;touch ${marker}`, + )).rejects.toMatchObject({ code: "workspace_invalid" }); + expect(existsSync(marker)).toBe(false); +}); + test("redacts failed Git checkout details behind a stable error code", async () => { const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-missing-")); temporaryRoots.push(root);