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
4 changes: 4 additions & 0 deletions docs/releases/UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ release description, then reset it after the release is published.
- Centralized session lifecycle composition in core and routed dead-session
reaping through typed desktop, Android, and browser teardown owners, removing
duplicate CLI/MCP orchestration and core PID/profile stop implementations.
- Added one verified run catalog for deterministic discovery, directory-manifest
identity binding, corrupt-entry handling, and symlink-safe root-file reads
across core retention, CLI artifacts, and MCP tools/resources.
- Centralized CLI provisioning policy for plan classification, consent,
dry-runs, ordered preflight failures/skips, adapter-owned sudo routing,
cancellation, redacted presentation plans, and partial results.
Expand All @@ -56,6 +59,7 @@ release description, then reset it after the release is published.

- `bun install --frozen-lockfile`
- `bun run typecheck`
- Focused run catalog, run, evidence, CLI artifact, and MCP resource/tool suites.
- `bun run test` (79 files, 958 passed / 2 skipped)
- `bun run test:coverage` (79 files, 958 passed / 2 skipped; all thresholds met)
- `bun run build`
Expand Down
58 changes: 29 additions & 29 deletions packages/cli/src/commands/artifacts.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { spawn } from "node:child_process";
import path from "node:path";
import {
EVIDENCE_ACTION_LOG,
isEvidenceRun,
listRuns,
readActions,
openRunCatalog,
parseActionsJournal,
renderRunReport,
runsDir,
type RunManifest,
type RunCatalog,
type RunCatalogEntry,
} from "@pickforge/picklab-core";
import { findOnPath } from "@pickforge/picklab-desktop-linux";
import {
Expand All @@ -18,8 +19,9 @@ import {
export async function runArtifactsList(opts: BaseCliOptions): Promise<number> {
return runReported(opts, async () => {
const projectDir = resolveProjectDir(opts);
const manifests = await listRuns(projectDir);
const runs = manifests.map((manifest) => ({
const catalog = await openRunCatalog(projectDir);
const entries = await catalog.list();
const runs = entries.map(({ manifest }) => ({
runId: manifest.runId,
slug: manifest.slug,
createdAt: manifest.createdAt,
Expand All @@ -39,32 +41,28 @@ export async function runArtifactsList(opts: BaseCliOptions): Promise<number> {
});
}

const RUN_ID_PATTERN = /^[A-Za-z0-9._-]+$/;

function isSafeRunId(runId: string): boolean {
return RUN_ID_PATTERN.test(runId) && runId !== "." && runId !== "..";
}

async function findRun(
projectDir: string,
runId: string | undefined,
): Promise<{ manifest: RunManifest; dir: string }> {
const manifests = (await listRuns(projectDir)).filter((candidate) =>
isSafeRunId(candidate.runId),
);
let manifest: RunManifest | undefined;
if (runId === undefined) {
manifest = manifests[0];
if (manifest === undefined) {
): Promise<{ catalog: RunCatalog; entry: RunCatalogEntry }> {
const catalog = await openRunCatalog(projectDir);
const entry = await catalog.find(runId);
if (entry === undefined) {
if (runId === undefined) {
throw new Error(`No runs found under ${runsDir(projectDir)}`);
}
} else {
manifest = manifests.find((candidate) => candidate.runId === runId);
if (manifest === undefined) {
throw new Error(`Run not found: ${runId} (see: picklab artifacts list)`);
}
throw new Error(`Run not found: ${runId} (see: picklab artifacts list)`);
}
return { manifest, dir: path.join(runsDir(projectDir), manifest.runId) };
return { catalog, entry };
}

async function readCatalogActions(
catalog: RunCatalog,
entry: RunCatalogEntry,
): Promise<ReturnType<typeof parseActionsJournal>> {
if (!isEvidenceRun(entry.manifest)) return [];
const raw = await catalog.readRootTextIfPresent(entry, EVIDENCE_ACTION_LOG);
return raw === undefined ? [] : parseActionsJournal(raw, entry.dir);
}

export async function runArtifactsOpen(
Expand All @@ -73,7 +71,8 @@ export async function runArtifactsOpen(
): Promise<number> {
return runReported(opts, async () => {
const projectDir = resolveProjectDir(opts);
const { manifest, dir } = await findRun(projectDir, runId);
const { entry } = await findRun(projectDir, runId);
const { manifest, dir } = entry;
let opened = false;
const display = process.env.DISPLAY;
if (opts.json !== true && display !== undefined && display !== "") {
Expand Down Expand Up @@ -101,8 +100,9 @@ export async function runArtifactsReport(
): Promise<number> {
return runReported(opts, async () => {
const projectDir = resolveProjectDir(opts);
const { manifest, dir } = await findRun(projectDir, runId);
const records = isEvidenceRun(manifest) ? await readActions(dir) : [];
const { catalog, entry } = await findRun(projectDir, runId);
const { manifest, dir } = entry;
const records = await readCatalogActions(catalog, entry);
return {
data: { runId: manifest.runId, dir, manifest },
lines: renderRunReport(manifest, dir, records),
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1508,6 +1508,30 @@ describe("picklab artifacts", () => {
expect(report.errors[0]).toContain("Corrupt evidence journal");
});

it("fails closed for an unsafe evidence journal", async () => {
const env = makeEnv();
const projectDir = makeProjectDir();
const runId = "20260609-120000-unsafe";
writeSyntheticRun(projectDir, runId, {
evidenceVersion: 1,
actionLog: "actions.jsonl",
});
const runDir = path.join(projectDir, ".picklab", "runs", runId);
fs.symlinkSync(
path.join(runDir, "manifest.json"),
path.join(runDir, "actions.jsonl"),
);

const result = await runCli(
["artifacts", "report", runId, "--project-dir", projectDir, "--json"],
env,
);
expect(result.code).toBe(1);
expect(parseJson(result).errors.join("\n")).toContain(
"Unsafe run catalog file",
);
});

it("fails for unknown run ids", async () => {
const env = makeEnv();
const projectDir = makeProjectDir();
Expand Down
117 changes: 26 additions & 91 deletions packages/core/src/evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { ensureDir, runsDir, writeFileAtomic } from "./paths.js";
import { openRunCatalog } from "./run-catalog.js";
import {
isPidAlive,
processIdentityMatches,
Expand Down Expand Up @@ -1604,69 +1605,6 @@ function isFinalizedStatus(status: RunStatus): boolean {
return status === "completed" || status === "failed";
}

interface EvidenceRunEntry {
/** The actual directory-entry name — authoritative for what gets deleted. */
dirName: string;
manifest: RunManifest;
}

/**
* Enumerate evidence-run directories, binding each manifest to the directory it
* physically lives in. Applies the same runs-root confinement as `listRuns`
* (rejecting a symlinked `.picklab` or `.picklab/runs`) and, critically, only
* yields an entry when the manifest's declared `runId` matches its own
* directory name. A manifest that names a *different* directory is never used to
* decide a deletion, so a spoofed or corrupt `runId` can never redirect a
* removal at another run's directory.
*/
async function listEvidenceRunEntries(
projectDir: string,
): Promise<EvidenceRunEntry[]> {
const parent = runsDir(projectDir);
try {
const realProject = await fs.promises.realpath(projectDir);
const realParent = await fs.promises.realpath(parent);
if (realParent !== path.join(realProject, ".picklab", "runs")) return [];
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}

let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(parent, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}

const out: EvidenceRunEntry[] = [];
for (const entry of entries) {
if (entry.isSymbolicLink() || !entry.isDirectory()) continue;
const dir = path.join(parent, entry.name);
const manifestPath = path.join(dir, "manifest.json");
try {
const manifestStat = await fs.promises.lstat(manifestPath);
if (manifestStat.isSymbolicLink()) continue;
} catch {
continue;
}
const manifest = await readManifest(dir);
if (
manifest === undefined ||
typeof manifest.runId !== "string" ||
typeof manifest.createdAt !== "string" ||
!Array.isArray(manifest.artifacts)
) {
continue;
}
// Bind: the manifest must declare the directory it actually lives in.
if (manifest.runId !== entry.name) continue;
out.push({ dirName: entry.name, manifest });
}
return out;
}

/** Collect run ids currently referenced by any session's active pointer. */
async function collectActiveRunIds(parent: string): Promise<Set<string>> {
const active = new Set<string>();
Expand Down Expand Up @@ -1707,53 +1645,50 @@ export async function pruneFinalizedEvidenceRuns(
opts: PruneEvidenceOptions = {},
): Promise<string[]> {
const keep = opts.keep ?? EVIDENCE_RETENTION_KEEP;
const parent = runsDir(projectDir);
// Enumerate directory entries with their bound manifests (runId === dirName),
// so every deletion decision targets the directory the manifest lives in — a
// spoofed runId can never point the removal at another run.
const entries = await listEvidenceRunEntries(projectDir);
const activeRunIds = await collectActiveRunIds(parent);

const finalized = entries
.filter(
(entry) =>
isEvidenceRun(entry.manifest) &&
isFinalizedStatus(entry.manifest.status) &&
!activeRunIds.has(entry.dirName),
)
.sort((a, b) =>
b.manifest.createdAt.localeCompare(a.manifest.createdAt),
);
const catalog = await openRunCatalog(projectDir);
const entries = await catalog.list();
const activeByRoot = new Map<string, Set<string>>();
for (const entry of entries) {
if (!activeByRoot.has(entry.rootDir)) {
activeByRoot.set(entry.rootDir, await collectActiveRunIds(entry.rootDir));
}
}

const finalized = entries.filter(
(entry) =>
isEvidenceRun(entry.manifest) &&
isFinalizedStatus(entry.manifest.status) &&
!activeByRoot.get(entry.rootDir)?.has(entry.dirName),
);

const removed: string[] = [];
for (const { dirName } of finalized.slice(keep)) {
for (const entry of finalized.slice(keep)) {
const { dirName, dir, rootDir } = entry;
if (!isSafeRunId(dirName)) continue;
const dir = path.join(parent, dirName);
// Confinement: only a real, non-symlink directory directly under the runs
// root is a removal candidate.
// Confinement: only a real, non-symlink directory directly under its
// catalog root is a removal candidate.
let stat: fs.Stats;
try {
stat = await fs.promises.lstat(dir);
} catch {
continue;
}
if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
if (path.dirname(dir) !== parent) continue;
// Re-read and re-verify the *same* manifest immediately before removal, so a
// run that was concurrently re-activated, mutated, or whose manifest now
// disagrees with its directory is never deleted (TOCTOU guard).
const fresh = await readManifest(dir);
if (path.dirname(dir) !== rootDir) continue;
// Re-read through the catalog immediately before removal. A run that was
// swapped, mutated, or whose manifest no longer binds to its directory is
// never deleted.
const fresh = await catalog.refresh(entry);
if (
fresh === undefined ||
fresh.runId !== dirName ||
!isEvidenceRun(fresh) ||
!isFinalizedStatus(fresh.status)
) {
continue;
}
// Re-check the active pointers last: an owner may have re-claimed this run
// between the first scan and now.
const activeNow = await collectActiveRunIds(parent);
const activeNow = await collectActiveRunIds(rootDir);
if (activeNow.has(dirName)) continue;
await fs.promises.rm(dir, { recursive: true, force: true });
forgetRunByteCache(dir);
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export {
createRun,
EVIDENCE_ACTION_LOG,
EVIDENCE_VERSION,
listRuns,
RunHandle,
type ArtifactType,
type CreateRunOptions,
Expand All @@ -39,6 +38,14 @@ export {
type RunStatus,
} from "./run.js";

export {
listRuns,
openRunCatalog,
RunCatalog,
type RunCatalogEntry,
type RunCatalogRoot,
} from "./run-catalog.js";

export {
activePointerPath,
appendAction,
Expand Down
Loading
Loading