refactor: enforce P1.1 registry path ownership
This commit is contained in:
@@ -130,34 +130,52 @@ export class GitWorkspaceRepository {
|
||||
}
|
||||
|
||||
async workspacePaths(): Promise<string[]> {
|
||||
const output = await this.git(["ls-tree", "-r", "--name-only", "HEAD", "--", "workspaces"]);
|
||||
const paths = output.trim() === "" ? [] : output.trim().split("\n");
|
||||
for (const path of paths) {
|
||||
if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) {
|
||||
const output = await this.git(["ls-tree", "-d", "--name-only", "HEAD"]);
|
||||
const directories = output.trim() === "" ? [] : output.trim().split("\n");
|
||||
const paths: string[] = [];
|
||||
for (const id of directories) {
|
||||
if (id === "workspace-docs") continue;
|
||||
if (!/^[a-z][a-z0-9-]{2,62}$/.test(id)) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains an invalid path");
|
||||
}
|
||||
const path = `${id}/workspace.yaml`;
|
||||
const type = (await this.git(["cat-file", "-t", `HEAD:${path}`], {},
|
||||
"Workspace descriptor is invalid")).trim();
|
||||
if (type !== "blob") {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace descriptor is invalid");
|
||||
}
|
||||
paths.push(path);
|
||||
}
|
||||
return paths;
|
||||
return paths.sort();
|
||||
}
|
||||
|
||||
async readWorkspace(path: string): Promise<string> {
|
||||
if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid");
|
||||
}
|
||||
return await this.git(["show", `HEAD:${path}`]);
|
||||
async readCatalog(revision = "HEAD"): Promise<string> {
|
||||
return await this.git(["show", `${revision}:thoth-workspaces.yaml`], {}, "Workspace catalog is invalid");
|
||||
}
|
||||
|
||||
async blob(path: string): Promise<string> {
|
||||
if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) {
|
||||
async catalogBlob(revision = "HEAD"): Promise<string> {
|
||||
return (await this.git(["rev-parse", `${revision}:thoth-workspaces.yaml`], {},
|
||||
"Workspace catalog is invalid")).trim();
|
||||
}
|
||||
|
||||
async readWorkspace(path: string, revision = "HEAD"): Promise<string> {
|
||||
if (!/^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid");
|
||||
}
|
||||
return (await this.git(["rev-parse", `HEAD:${path}`])).trim();
|
||||
return await this.git(["show", `${revision}:${path}`]);
|
||||
}
|
||||
|
||||
async blob(path: string, revision = "HEAD"): Promise<string> {
|
||||
if (!/^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid");
|
||||
}
|
||||
return (await this.git(["rev-parse", `${revision}:${path}`])).trim();
|
||||
}
|
||||
|
||||
/** Assert that a canonical Evidence root is a Git tree at an exact commit. */
|
||||
async assertTreeAtRevision(revision: string, repoRelativePath: string): Promise<void> {
|
||||
if (!/^[0-9a-f]{40}$/.test(revision)
|
||||
|| !/^workspace-content\/[a-z][a-z0-9-]{2,62}\/evidence$/.test(repoRelativePath)) {
|
||||
|| !/^[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(
|
||||
@@ -170,7 +188,7 @@ export class GitWorkspaceRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/** Write only a validated registry artifact below the checked-out repository. */
|
||||
/** Write only a validated API-owned artifact below the checked-out repository. */
|
||||
async writeRegistryFile(path: string, source: string): Promise<void> {
|
||||
this.assertRegistryArtifactPath(path);
|
||||
const target = join(this.repoPath, path);
|
||||
@@ -178,6 +196,21 @@ export class GitWorkspaceRepository {
|
||||
await writeFile(target, source, { encoding: "utf8", mode: 0o600 });
|
||||
}
|
||||
|
||||
/** Create a descriptor only when no filesystem entry exists at its exact path. */
|
||||
async createRegistryFile(path: string, source: string): Promise<void> {
|
||||
this.assertRegistryArtifactPath(path);
|
||||
if (!/^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace descriptor path is invalid");
|
||||
}
|
||||
const target = join(this.repoPath, path);
|
||||
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
await writeFile(target, source, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
} catch {
|
||||
throw new WorkspaceRegistryError("workspace_curator_owned", "Workspace descriptor is curator-owned");
|
||||
}
|
||||
}
|
||||
|
||||
async removeRegistryFile(path: string): Promise<void> {
|
||||
this.assertRegistryArtifactPath(path);
|
||||
await rm(join(this.repoPath, path), { force: true });
|
||||
@@ -214,7 +247,7 @@ export class GitWorkspaceRepository {
|
||||
}
|
||||
|
||||
private isRegistryArtifactPath(path: string): boolean {
|
||||
return /^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)
|
||||
return /^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)
|
||||
|| /^workspace-docs\/[a-z][a-z0-9-]{2,62}\/(?:contract\.env\.example|README\.md)$/.test(path);
|
||||
}
|
||||
|
||||
@@ -258,7 +291,7 @@ export class GitWorkspaceRepository {
|
||||
private async restoreFailedPublication(): Promise<void> {
|
||||
try {
|
||||
await this.git(["reset", "--hard", `refs/remotes/origin/${this.config.branch}`]);
|
||||
await this.git(["clean", "-fd", "--", "workspaces", "workspace-docs"]);
|
||||
await this.git(["clean", "-fd", "--", "workspace-docs"]);
|
||||
} catch {
|
||||
// Keep the original sanitized publish failure. A future refresh will surface any recovery
|
||||
// problem without leaking the Git failure details through the API.
|
||||
|
||||
@@ -341,7 +341,7 @@ function workspaceInvariants(workspace: any, context: z.RefinementCtx): void {
|
||||
unique(workspace.llm_policy.allowed, context, ["llm_policy", "allowed"]);
|
||||
|
||||
if (workspace.evidence?.source.type === "filesystem") {
|
||||
const expected = `workspace-content/${workspace.workspace.id}/evidence`;
|
||||
const expected = `${workspace.workspace.id}/evidence`;
|
||||
if (workspace.evidence.source.uri !== expected) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface WorkspaceRegistryConfig {
|
||||
|
||||
export type WorkspaceErrorCode =
|
||||
| "workspace_invalid" | "binding_missing" | "workspace_not_activatable"
|
||||
| "workspace_stale" | "workspace_conflict" | "git_unavailable"
|
||||
| "workspace_stale" | "workspace_conflict" | "workspace_curator_owned" | "git_unavailable"
|
||||
| "git_auth_failed" | "git_non_fast_forward" | "git_push_rejected"
|
||||
| "connector_unavailable" | "semantic_index_incompatible";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user