Kaynağa Gözat

fix(ci,build): keep evaluations inert, make registry auto-update manual, contain build writes

run-evaluations.yml was moved out of the inert evals/ subfolder by the pnpm
migration commit, silently activating a nightly job against
OPENCODE_API_KEY that the threat-model docs still classify as not loaded.
Move it back.

update-registry.yml ran the append-only auto-detect script on every push to
main touching .opencode/**, which now fights the RegistryEmitter that owns
the agent and profile sections. Dispatch only.

BuildPipeline.write() joined the --stage override and ledger paths without
checking they stay under the build root, so a bad override could write or
rmSync outside the repo. Validate every destination before the first write
and never prune a ledger entry that escapes the root; tests cover both.
darrenhinde 2 hafta önce
ebeveyn
işleme
68a3bdc4ca

+ 4 - 6
.github/workflows/update-registry.yml

@@ -1,12 +1,10 @@
 name: Update Component Registry (Direct Push)
 
+# Manual only. `oac build` (RegistryEmitter) now owns the agent, subagent and profile sections
+# of registry.json and CI gates drift on them, so this append-only auto-detect pass must not run
+# on every push or it fights the emitter. Dispatch it by hand for the hand-authored categories
+# (contexts, commands, tools, skills) until those move into content/ as well.
 on:
-  push:
-    branches:
-      - main
-    paths:
-      - '.opencode/**'
-      - '!registry.json'
   workflow_dispatch:
 
 permissions:

+ 34 - 2
packages/compatibility-layer/src/core/BuildPipeline.ts

@@ -52,7 +52,7 @@ import {
   rmdirSync,
   writeFileSync,
 } from "node:fs";
-import { dirname, join, relative, resolve, sep } from "node:path";
+import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
 import { CanonicalAgentLoader, type CanonicalAgentFile } from "./AgentLoader.js";
 import {
   MANIFEST_FILE,
@@ -195,6 +195,28 @@ function sha256(content: string): string {
   return createHash("sha256").update(content, "utf-8").digest("hex");
 }
 
+/**
+ * True when `absolute` is `root` itself or sits underneath it, after both are resolved.
+ *
+ * Every path the build writes or deletes is derived from a `--stage` override or a committed
+ * ledger, neither of which is trusted to stay inside the repository on its own. `relative()`
+ * is the honest test: a result that starts with `..` (or is absolute, on Windows) has escaped.
+ */
+function contained(root: string, absolute: string): boolean {
+  const rel = relative(resolve(root), resolve(absolute));
+  return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
+}
+
+/** Refuse to touch a path outside the build root. Thrown before any write happens. */
+function assertContained(root: string, absolute: string, what: string): void {
+  if (!contained(root, absolute)) {
+    throw new Error(
+      `refusing to ${what} "${absolute}": it resolves outside the build root "${resolve(root)}". ` +
+        "Check the --stage override and the output roots.",
+    );
+  }
+}
+
 // ============================================================================
 // PLANNING
 // ============================================================================
@@ -457,6 +479,13 @@ export function write(plan: BuildPlan, options: WriteOptions): WriteResult {
   const outputRoots = options.outputRoots ?? {};
   const result: WriteResult = { changed: [], unchanged: [], removed: [], kept: [] };
 
+  // Validate every destination BEFORE the first write, so a bad staging override fails the
+  // build outright instead of leaving a half-written tree behind.
+  for (const file of plan.files) {
+    assertContained(root, join(root, rebase(file.path, file.target, outputRoots)), "write");
+  }
+  assertContained(root, join(root, MANIFEST_FILE), "write");
+
   for (const file of plan.files) {
     const path = rebase(file.path, file.target, outputRoots);
     const absolute = join(root, path);
@@ -482,7 +511,10 @@ export function write(plan: BuildPlan, options: WriteOptions): WriteResult {
       if (path in next.files) continue;
 
       const entry = previous.files[path]!;
-      const reason = prunable(join(root, path), path, entry);
+      // A ledger entry that escapes the root is never deleted, whatever else it claims.
+      const reason = contained(root, join(root, path))
+        ? prunable(join(root, path), path, entry)
+        : "manifest entry resolves outside the build root — refusing to delete it";
 
       if (reason === null) {
         removeAndPruneDirs(root, path, entry.root);

+ 53 - 0
packages/compatibility-layer/tests/unit/build/build-pipeline.test.ts

@@ -373,3 +373,56 @@ describe("failure handling", () => {
     }
   });
 });
+
+// ============================================================================
+// root containment
+// ============================================================================
+
+describe("root containment", () => {
+  it("refuses a staging override that escapes the build root and writes nothing", async () => {
+    const outside = join(root, "..", `oac-escape-${Date.now()}`);
+    const built = await plan({ root });
+
+    expect(() => write(built, { root, outputRoots: { "claude-code": "../../escape" } })).toThrow(
+      /outside the build root/,
+    );
+
+    // Nothing in place was written either: validation runs before the first write.
+    expect(existsSync(join(root, ".opencode/agent/subagents/test/alpha.md"))).toBe(false);
+    expect(existsSync(join(root, ".oac/build-manifest.json"))).toBe(false);
+    expect(existsSync(outside)).toBe(false);
+  });
+
+  it("never deletes a ledger entry whose path resolves outside the build root", async () => {
+    const victimDir = mkdtempSync(join(tmpdir(), "oac-victim-"));
+    const victim = join(victimDir, "precious.md");
+    writeFileSync(victim, "keep me", "utf-8");
+
+    const escaped = `../${victimDir.split("/").pop()}/precious.md`;
+    put(
+      ".oac/build-manifest.json",
+      serializeManifest({
+        version: 1,
+        files: {
+          [escaped]: {
+            sha256: "0000000000000000000000000000000000000000000000000000000000000000",
+            target: "opencode",
+            root: ".opencode/agent",
+          },
+        },
+      }),
+    );
+
+    // Point the tmp root's parent at the victim's parent so `../<dir>` really resolves to it.
+    // Both mkdtemp dirs live directly under tmpdir(), so this holds by construction.
+    const result = write(await plan({ root }), { root });
+
+    expect(existsSync(victim)).toBe(true);
+    expect(readFileSync(victim, "utf-8")).toBe("keep me");
+    expect(result.removed).not.toContain(escaped);
+    expect(result.kept.map((k) => k.path)).toContain(escaped);
+    expect(result.kept.find((k) => k.path === escaped)?.reason).toMatch(/outside the build root/);
+
+    rmSync(victimDir, { recursive: true, force: true });
+  });
+});