From 188b87df4572ba95390032ef62826af433c30abe Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 4 Oct 2026 00:52:46 +0900 Subject: [PATCH 1/2] feat(session): bind readonly task owner access to captured identities Known owner roots must be verified before construction can repair security. Capture immutable owner authority only when actual managed staging and replacement writers are quiescent, and preserve exact native dispositions. Lore-id: 65e28dc3 Constraint: no owner producer activation or synthetic native completion Confidence: high Scope-risk: narrow Reversibility: easy Tested: actual independent managed staging and replacement fd processes Tested: 19 access and GC codec tests 105 assertions plus package types Tested: 139-pass storage codec access union with 8 existing skips Not-tested: full persistent owner and GC SDK consumer activation --- .../internal/managed-session-storage.ts | 108 +++- .../internal/task-artifact-owner-access.ts | 229 +++++++++ .../test/fixtures/task-owner-access-writer.ts | 164 ++++++ .../test/task-artifact-owner-access.test.ts | 482 ++++++++++++++++++ 4 files changed, 982 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/src/session/internal/task-artifact-owner-access.ts create mode 100644 packages/coding-agent/test/fixtures/task-owner-access-writer.ts create mode 100644 packages/coding-agent/test/task-artifact-owner-access.test.ts diff --git a/packages/coding-agent/src/session/internal/managed-session-storage.ts b/packages/coding-agent/src/session/internal/managed-session-storage.ts index cf7b3f06977..fe0ed554bcf 100644 --- a/packages/coding-agent/src/session/internal/managed-session-storage.ts +++ b/packages/coding-agent/src/session/internal/managed-session-storage.ts @@ -1474,6 +1474,7 @@ export class ManagedSessionDescendantStore { retained?: { authority: RecoveryFsRoot; authorityBaseDir: string }, policy?: ManagedSessionSecurityPolicy, profileAgentDir?: string, + expectedSubtreeRoot?: ManagedDirectoryRoot, ) { managedRelativePath(root, baseDir); @@ -1512,13 +1513,46 @@ export class ManagedSessionDescendantStore { }); } this.#authority = retained.authority; + if ( + expectedSubtreeRoot && + (expectedSubtreeRoot.canonicalPath !== this.#baseDir || + expectedSubtreeRoot.dev !== this.#subtreeRoot.dev || + expectedSubtreeRoot.ino !== this.#subtreeRoot.ino) + ) + throw new Error("Managed subtree authority changed during establishment"); this.#assertBound(); return; } assertManagedDirectoryRoot(root); - ensureManagedDirectory(this.#baseDir, root, this.#policy); + if (expectedSubtreeRoot) { + if (expectedSubtreeRoot.canonicalPath !== this.#baseDir) + throw new Error("Managed subtree authority path mismatch"); + assertManagedDirectoryRoot(expectedSubtreeRoot); + const verified = validateNativeSecurityResult( + process.platform === "win32" + ? nativeSessionStorage().verifyOwnerOnlyPathSecurityExpected( + this.#baseDir, + "directory", + expectedSubtreeRoot.dev, + expectedSubtreeRoot.ino, + ) + : nativeSessionStorage().verifyOwnerOnlyPathSecurity(this.#baseDir, "directory"), + "verify", + "directory", + ); + if (!verified.ok) throw securityError(this.#baseDir, verified); + assertManagedDirectoryRoot(expectedSubtreeRoot); + } else { + ensureManagedDirectory(this.#baseDir, root, this.#policy); + } const subtreeStat = fs.lstatSync(this.#baseDir, { bigint: true }); + if ( + expectedSubtreeRoot && + (canonicalFileId(subtreeStat.dev) !== expectedSubtreeRoot.dev || + canonicalFileId(subtreeStat.ino) !== expectedSubtreeRoot.ino) + ) + throw new Error("Managed subtree authority changed during establishment"); this.#subtreeRoot = Object.freeze({ canonicalPath: this.#baseDir, dev: canonicalFileId(subtreeStat.dev), @@ -1634,6 +1668,50 @@ export class ManagedSessionDescendantStore { } this.#assertBound(); } + + /** Capture a directory identity through this store's retained managed root without repairing it. */ + captureDirectoryIdentity(relativePath: string): { dev: string; ino: string } { + this.#assertBound(); + const resolved = this.#resolve(relativePath); + if (!this.#authority) this.#assertPathBackedDirectoryChain(resolved); + const named = fs.lstatSync(resolved, { bigint: true }); + if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("managed_directory_identity_unavailable"); + const dev = canonicalFileId(named.dev); + const ino = canonicalFileId(named.ino); + if (dev !== this.#subtreeRoot.dev) throw new Error("managed_directory_identity_unavailable"); + if (this.#authority) { + const retainedRelative = this.#relative(resolved); + if (retainedRelative === "") { + if (dev !== this.#subtreeRoot.dev || ino !== this.#subtreeRoot.ino) + throw new Error("Managed descendant root binding changed"); + } else { + const retained = this.#authority.retainManagedDirectory(retainedRelative, dev.toString(), ino.toString()); + try { + const identity = retained.identity(); + if ( + !identity.ok || + !identity.identity || + identity.identity.dev !== dev.toString() || + identity.identity.ino !== ino.toString() + ) + throw new Error(identity.code ?? "managed_directory_identity_unavailable"); + } finally { + retained.close(); + } + } + } + const after = fs.lstatSync(resolved, { bigint: true }); + if ( + !after.isDirectory() || + after.isSymbolicLink() || + canonicalFileId(after.dev) !== dev || + canonicalFileId(after.ino) !== ino + ) + throw new Error("managed_directory_identity_changed"); + this.#assertBound(); + return { dev: dev.toString(), ino: ino.toString() }; + } + #assertBound(): void { if (!this.#authority) { const named = fs.statSync(this.#baseDir, { bigint: true }); @@ -3019,6 +3097,34 @@ export class ManagedSessionDescendantStore { if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed"); this.#assertBound(); } + + /** Remove one exact managed tree through the parent-identity-bound native protocol. */ + removeTreeExpectedWithParentIdentity( + relativePath: string, + expected: NativeDirectoryTreeSnapshot, + parentIdentity: { dev: bigint; ino: bigint }, + ): NativeExactUnlinkResult { + this.#beforeMutation(); + this.#assertBound(); + const resolved = this.#resolve(relativePath); + const parentPath = path.dirname(resolved); + const parentRelative = path.relative(this.#baseDir, parentPath); + if (path.isAbsolute(parentRelative) || parentRelative === ".." || parentRelative.startsWith(`..${path.sep}`)) + throw new Error("managed_remove_parent_outside_store"); + const currentParent = this.captureDirectoryIdentity(parentRelative); + if ( + currentParent.dev !== canonicalFileId(parentIdentity.dev).toString() || + currentParent.ino !== canonicalFileId(parentIdentity.ino).toString() + ) + throw new Error("managed_remove_parent_identity_mismatch"); + const removed = nativeSessionStorage().exactRemoveDirectoryTree(resolved, expected, { + dev: BigInt(currentParent.dev), + ino: BigInt(currentParent.ino), + }); + this.#assertBound(); + return removed; + } + fsyncTree(): NativeDirectoryTreeSnapshot { this.#beforeMutation(); this.#assertBound(); diff --git a/packages/coding-agent/src/session/internal/task-artifact-owner-access.ts b/packages/coding-agent/src/session/internal/task-artifact-owner-access.ts new file mode 100644 index 00000000000..67799678e41 --- /dev/null +++ b/packages/coding-agent/src/session/internal/task-artifact-owner-access.ts @@ -0,0 +1,229 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { NativeDirectoryTreeSnapshot } from "@gajae-code/natives"; +import { + assertSafeRelativePath, + assertSessionRoot, + freezeTreeSnapshot, + immutableDeletionEvidence, + isOwnerRetainedRoot, + manifestRelativePath, + OWNER_DIRECTORY, + OWNER_MANIFEST, + OWNER_SCHEMA_VERSION, + ownerIdForSession, + ownerRelativePath, + parseTaskArtifactOwnerLocator, + sameOwnerParentIdentity, + type TaskArtifactOwnerDeletionEvidence, + type TaskArtifactOwnerLocator, + type TaskArtifactOwnerParentIdentity, + type TaskArtifactOwnerStorageContext, +} from "../task-artifact-owner-codec"; +import { ManagedSessionDescendantStore } from "./managed-session-storage"; + +export interface OwnerManifest extends TaskArtifactOwnerLocator { + readonly sessionId: string; +} + +export function newSessionRootStore(context: TaskArtifactOwnerStorageContext): ManagedSessionDescendantStore { + assertSessionRoot(context); + if (path.resolve(context.profileAgentDir) !== context.profileAgentDir) + throw new Error("task_artifact_owner_profile_invalid"); + const profile = fs.lstatSync(context.profileAgentDir, { bigint: true }); + if (!profile.isDirectory() || profile.isSymbolicLink()) throw new Error("task_artifact_owner_profile_invalid"); + const stat = fs.lstatSync(context.sessionsRoot, { bigint: true }); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("task_artifact_owner_root_invalid"); + return new ManagedSessionDescendantStore( + context.rootAuthority, + context.sessionsRoot, + undefined, + context.securityPolicy, + context.profileAgentDir, + { + canonicalPath: context.sessionsRoot, + dev: BigInt.asUintN(64, stat.dev), + ino: BigInt.asUintN(64, stat.ino), + }, + ); +} + +function parseOwnerManifest(bytes: Uint8Array): OwnerManifest { + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(bytes).toString("utf8")); + } catch { + throw new Error("task_artifact_owner_manifest_invalid"); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || Object.keys(parsed).length !== 5) + throw new Error("task_artifact_owner_manifest_invalid"); + const record = parsed as Record; + if (typeof record.sessionId !== "string" || record.sessionId.length === 0) + throw new Error("task_artifact_owner_manifest_invalid"); + let locator: TaskArtifactOwnerLocator | undefined; + try { + locator = parseTaskArtifactOwnerLocator({ + schemaVersion: record.schemaVersion, + ownerId: record.ownerId, + directoryDev: record.directoryDev, + directoryIno: record.directoryIno, + }); + } catch { + throw new Error("task_artifact_owner_manifest_invalid"); + } + if (!locator) throw new Error("task_artifact_owner_manifest_invalid"); + return { ...locator, sessionId: record.sessionId }; +} + +export function readOwnerManifest( + store: ManagedSessionDescendantStore, + locator: TaskArtifactOwnerLocator, + relativePath = manifestRelativePath(locator.ownerId), +): OwnerManifest { + const manifest = store.readExpected(relativePath); + if (!manifest) throw new Error("task_artifact_owner_manifest_missing"); + const parsed = parseOwnerManifest(manifest.bytes); + if ( + parsed.ownerId !== locator.ownerId || + parsed.directoryDev !== locator.directoryDev || + parsed.directoryIno !== locator.directoryIno + ) + throw new Error("task_artifact_owner_manifest_mismatch"); + return parsed; +} + +export function assertOwnerDirectoryExists(context: TaskArtifactOwnerStorageContext, ownerId: string): void { + const parent = path.join(context.sessionsRoot, OWNER_DIRECTORY); + const owner = path.join(parent, ownerId); + for (const candidate of [parent, owner]) { + let stat: fs.BigIntStats; + try { + stat = fs.lstatSync(candidate, { bigint: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error("task_artifact_owner_missing"); + throw error; + } + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("task_artifact_owner_replaced"); + } +} + +export function assertOwnerIdentity(store: ManagedSessionDescendantStore, locator: TaskArtifactOwnerLocator): void { + const identity = store.subtreeRootAuthority; + if (identity.dev.toString() !== locator.directoryDev || identity.ino.toString() !== locator.directoryIno) + throw new Error("task_artifact_owner_identity_mismatch"); +} + +export function openOwnerStore( + context: TaskArtifactOwnerStorageContext, + rootStore: ManagedSessionDescendantStore, + locator: TaskArtifactOwnerLocator, + sessionId: string, +): ManagedSessionDescendantStore { + if (locator.ownerId !== ownerIdForSession(sessionId)) throw new Error("task_artifact_owner_session_mismatch"); + assertOwnerDirectoryExists(context, locator.ownerId); + const manifest = readOwnerManifest(rootStore, locator); + if (manifest.sessionId !== sessionId) throw new Error("task_artifact_owner_session_mismatch"); + const identity = rootStore.captureDirectoryIdentity(ownerRelativePath(locator.ownerId)); + if (identity.dev !== locator.directoryDev || identity.ino !== locator.directoryIno) + throw new Error("task_artifact_owner_identity_mismatch"); + const ownerPath = path.join(context.sessionsRoot, ownerRelativePath(locator.ownerId)); + const ownerStore = new ManagedSessionDescendantStore( + context.rootAuthority, + ownerPath, + undefined, + context.securityPolicy, + context.profileAgentDir, + { + canonicalPath: ownerPath, + dev: BigInt(locator.directoryDev), + ino: BigInt(locator.directoryIno), + }, + ); + try { + assertOwnerIdentity(ownerStore, locator); + readOwnerManifest(ownerStore, locator, OWNER_MANIFEST); + return ownerStore; + } catch (error) { + ownerStore.close(); + throw error; + } +} + +export function locatorFromManifest(manifest: OwnerManifest): TaskArtifactOwnerLocator { + return { + schemaVersion: OWNER_SCHEMA_VERSION, + ownerId: manifest.ownerId, + directoryDev: manifest.directoryDev, + directoryIno: manifest.directoryIno, + }; +} + +export function captureValidatedOwnerTree( + context: TaskArtifactOwnerStorageContext, + rootStore: ManagedSessionDescendantStore, + sessionId: string, + locator: TaskArtifactOwnerLocator, +): NativeDirectoryTreeSnapshot { + if (locator.ownerId !== ownerIdForSession(sessionId)) throw new Error("task_artifact_owner_session_mismatch"); + assertOwnerDirectoryExists(context, locator.ownerId); + const manifest = readOwnerManifest(rootStore, locator); + if (manifest.sessionId !== sessionId) throw new Error("task_artifact_owner_session_mismatch"); + const tree = rootStore.captureTree(ownerRelativePath(locator.ownerId)); + if (tree.rootDev !== locator.directoryDev || tree.rootIno !== locator.directoryIno) + throw new Error("task_artifact_owner_identity_mismatch"); + return tree; +} + +export function captureOwnerTreeIfPresent( + context: TaskArtifactOwnerStorageContext, + rootStore: ManagedSessionDescendantStore, + ownerId: string, + pathname: string, +): NativeDirectoryTreeSnapshot | undefined { + if (!isOwnerRetainedRoot(context, ownerId, pathname)) + throw new Error("task_artifact_owner_retained_root_unrecognized"); + let named: fs.BigIntStats; + try { + named = fs.lstatSync(pathname, { bigint: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("task_artifact_owner_retained_root_replaced"); + const relative = path.relative(context.sessionsRoot, pathname).split(path.sep).join("/"); + assertSafeRelativePath(relative); + return freezeTreeSnapshot(rootStore.captureTree(relative)); +} + +export function ownerTreeHasPendingManagedPublication(snapshot: NativeDirectoryTreeSnapshot): boolean { + return snapshot.entries.some(entry => + /^\..+\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(?:staging|replacement)$/u.test( + path.posix.basename(entry.relativePath), + ), + ); +} + +/** Capture exact owner evidence without repairing its security, timestamps, or namespace. */ +export function captureTaskArtifactOwnerDeletionEvidence( + context: TaskArtifactOwnerStorageContext, + sessionId: string, + locatorValue: unknown, +): TaskArtifactOwnerDeletionEvidence | undefined { + const locator = parseTaskArtifactOwnerLocator(locatorValue); + if (!locator) return undefined; + const rootStore = newSessionRootStore(context); + try { + rootStore.verifyRootSecurity(); + const parentIdentity: TaskArtifactOwnerParentIdentity = rootStore.captureDirectoryIdentity(OWNER_DIRECTORY); + const treeSnapshot = captureValidatedOwnerTree(context, rootStore, sessionId, locator); + // This refuses to capture during publication; it does not revoke independent open descriptors. + if (ownerTreeHasPendingManagedPublication(treeSnapshot)) + throw new Error("task_artifact_owner_writer_not_quiescent"); + const parentAfter = rootStore.captureDirectoryIdentity(OWNER_DIRECTORY); + if (!sameOwnerParentIdentity(parentIdentity, parentAfter)) + throw new Error("task_artifact_owner_parent_changed_during_capture"); + return immutableDeletionEvidence(sessionId, locator, parentIdentity, treeSnapshot); + } finally { + rootStore.close(); + } +} diff --git a/packages/coding-agent/test/fixtures/task-owner-access-writer.ts b/packages/coding-agent/test/fixtures/task-owner-access-writer.ts new file mode 100644 index 00000000000..1d1e0b6b240 --- /dev/null +++ b/packages/coding-agent/test/fixtures/task-owner-access-writer.ts @@ -0,0 +1,164 @@ +import { vi } from "bun:test"; +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import { + type ManagedDirectoryRoot, + ManagedSessionDescendantStore, + prepareManagedDirectoryRoot, +} from "../../src/session/internal/managed-session-storage"; + +interface WriterInput { + readonly profileRoot: string; + readonly ownerRoot: string; + readonly filename: string; + readonly ready: string; + readonly release: string; + readonly phase: "staging" | "replacement"; +} + +function parseInput(value: unknown): WriterInput { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new Error("task_owner_writer_input_invalid"); + const record = value as Record; + if ( + typeof record.profileRoot !== "string" || + typeof record.ownerRoot !== "string" || + typeof record.filename !== "string" || + path.basename(record.filename) !== record.filename || + typeof record.ready !== "string" || + typeof record.release !== "string" || + (record.phase !== "staging" && record.phase !== "replacement") + ) + throw new Error("task_owner_writer_input_invalid"); + return { + profileRoot: record.profileRoot, + ownerRoot: record.ownerRoot, + filename: record.filename, + ready: record.ready, + release: record.release, + phase: record.phase, + }; +} + +const inputValue: unknown = JSON.parse(process.env.GJC_TASK_OWNER_ACCESS_WRITER_INPUT ?? "null"); +const input = parseInput(inputValue); +const securityPolicy = process.platform === "win32" ? "windows-existing-verify-first" : "default"; +const ownerStat = fs.lstatSync(input.ownerRoot, { bigint: true }); +if (!ownerStat.isDirectory() || ownerStat.isSymbolicLink()) throw new Error("task_owner_writer_owner_invalid"); +const expectedOwner: ManagedDirectoryRoot = { + canonicalPath: path.resolve(input.ownerRoot), + dev: BigInt.asUintN(64, ownerStat.dev), + ino: BigInt.asUintN(64, ownerStat.ino), +}; +const store = new ManagedSessionDescendantStore( + prepareManagedDirectoryRoot(input.profileRoot, securityPolicy), + input.ownerRoot, + undefined, + securityPolicy, + input.profileRoot, + expectedOwner, +); + +function publishReady(phase: WriterInput["phase"], markerPath: string, dev: bigint, ino: bigint): void { + const fd = fs.openSync(input.ready, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600); + try { + fs.writeSync( + fd, + Buffer.from( + JSON.stringify({ + pid: process.pid, + phase, + marker: path.basename(markerPath), + dev: dev.toString(), + ino: ino.toString(), + }), + "utf8", + ), + ); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } +} + +function waitForReleaseSync(): void { + const deadline = Date.now() + 10_000; + const wait = new Int32Array(new SharedArrayBuffer(4)); + while (!fs.existsSync(input.release)) { + if (Date.now() >= deadline) throw new Error("task_owner_writer_release_timeout"); + Atomics.wait(wait, 0, 0, 10); + } +} + +async function waitForRelease(): Promise { + const deadline = Date.now() + 10_000; + while (!fs.existsSync(input.release)) { + if (Date.now() >= deadline) throw new Error("task_owner_writer_release_timeout"); + await Bun.sleep(10); + } +} + +try { + if (input.phase === "replacement") { + let held = false; + const write = new Proxy(fs.writeSync, { + apply(target, receiver: unknown, args: unknown[]): unknown { + const fd = args[0]; + if (!held && typeof fd === "number") { + const descriptor = fs.fstatSync(fd, { bigint: true }); + const replacement = fs.readdirSync(input.ownerRoot).find(name => { + if (!name.endsWith(".replacement")) return false; + const named = fs.lstatSync(path.join(input.ownerRoot, name), { bigint: true }); + return named.dev === descriptor.dev && named.ino === descriptor.ino; + }); + if (replacement) { + held = true; + publishReady("replacement", path.join(input.ownerRoot, replacement), descriptor.dev, descriptor.ino); + waitForReleaseSync(); + } + } + return Reflect.apply(target, receiver, args); + }, + }); + const spy = vi.spyOn(fs, "writeSync").mockImplementation(write); + try { + store.replaceSync(input.filename, Buffer.from("independent-managed-replacement", "utf8")); + if (!held) throw new Error("task_owner_writer_replacement_boundary_missing"); + } finally { + spy.mockRestore(); + } + } else { + const originalOpen = fsp.open.bind(fsp); + let held = false; + const open = vi.spyOn(fsp, "open").mockImplementation(async (pathname, flags, mode) => { + const handle = await originalOpen(pathname, flags, mode); + if ( + !held && + typeof pathname === "string" && + path.dirname(pathname) === input.ownerRoot && + path.basename(pathname).endsWith(".staging") + ) { + held = true; + const descriptor = await handle.stat({ bigint: true }); + const delayedWrite = new Proxy(handle.write, { + apply(target, receiver: unknown, args: unknown[]): Promise { + publishReady("staging", pathname, descriptor.dev, descriptor.ino); + return waitForRelease().then(() => Reflect.apply(target, receiver, args)); + }, + }); + vi.spyOn(handle, "write").mockImplementation(delayedWrite); + } + return handle; + }); + try { + await store.publishNoReplace(input.filename, Buffer.from("independent-managed-staging", "utf8")); + if (!held) throw new Error("task_owner_writer_staging_boundary_missing"); + } finally { + open.mockRestore(); + } + } + process.stdout.write(`${JSON.stringify({ pid: process.pid, phase: input.phase, status: "acknowledged" })}\n`); +} finally { + store.close(); +} diff --git a/packages/coding-agent/test/task-artifact-owner-access.test.ts b/packages/coding-agent/test/task-artifact-owner-access.test.ts new file mode 100644 index 00000000000..45acf5e39cd --- /dev/null +++ b/packages/coding-agent/test/task-artifact-owner-access.test.ts @@ -0,0 +1,482 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as native from "@gajae-code/natives"; +import { + type ManagedDirectoryRoot, + ManagedSessionDescendantStore, + prepareManagedDirectoryRoot, +} from "../src/session/internal/managed-session-storage"; +import { + captureTaskArtifactOwnerDeletionEvidence, + newSessionRootStore, + openOwnerStore, +} from "../src/session/internal/task-artifact-owner-access"; +import { + OWNER_DIRECTORY, + OWNER_MANIFEST, + OWNER_SCHEMA_VERSION, + ownerIdForSession, + ownerRelativePath, + type TaskArtifactOwnerLocator, + type TaskArtifactOwnerStorageContext, +} from "../src/session/task-artifact-owner-codec"; + +interface OwnerFixture { + readonly root: string; + readonly sessionId: string; + readonly context: TaskArtifactOwnerStorageContext; + readonly locator: TaskArtifactOwnerLocator; + readonly ownerPath: string; + readonly ownerRoot: ManagedDirectoryRoot; +} + +const fixtureRoots: string[] = []; + +afterEach(() => { + for (const root of fixtureRoots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function expectedDirectoryRoot(pathname: string): ManagedDirectoryRoot { + const stat = fs.lstatSync(pathname, { bigint: true }); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("fixture_directory_invalid"); + return { + canonicalPath: path.resolve(pathname), + dev: BigInt.asUintN(64, stat.dev), + ino: BigInt.asUintN(64, stat.ino), + }; +} + +async function makeOwnerFixture(): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "task-owner-access-")); + fixtureRoots.push(root); + const profileAgentDir = path.resolve(root); + const sessionsRoot = path.join(profileAgentDir, "sessions"); + fs.mkdirSync(sessionsRoot, { mode: 0o700 }); + const securityPolicy = process.platform === "win32" ? "windows-existing-verify-first" : "default"; + const context: TaskArtifactOwnerStorageContext = { + rootAuthority: prepareManagedDirectoryRoot(profileAgentDir, securityPolicy), + sessionsRoot, + securityPolicy, + profileAgentDir, + }; + const sessionId = `access-session-${crypto.randomUUID()}`; + const locator: TaskArtifactOwnerLocator = { + schemaVersion: OWNER_SCHEMA_VERSION, + ownerId: ownerIdForSession(sessionId), + directoryDev: "0", + directoryIno: "0", + }; + const rootStore = new ManagedSessionDescendantStore( + context.rootAuthority, + sessionsRoot, + undefined, + securityPolicy, + profileAgentDir, + expectedDirectoryRoot(sessionsRoot), + ); + let ownerStore: ManagedSessionDescendantStore | undefined; + try { + rootStore.verifyRootSecurity(); + rootStore.ensureDirectory(OWNER_DIRECTORY); + const ownerRoot = rootStore.ensureDirectory(ownerRelativePath(locator.ownerId)); + const boundLocator: TaskArtifactOwnerLocator = { + ...locator, + directoryDev: ownerRoot.dev.toString(), + directoryIno: ownerRoot.ino.toString(), + }; + const ownerPath = path.join(sessionsRoot, ownerRelativePath(locator.ownerId)); + ownerStore = new ManagedSessionDescendantStore( + context.rootAuthority, + ownerPath, + undefined, + securityPolicy, + profileAgentDir, + ownerRoot, + ); + await ownerStore.publishNoReplace( + OWNER_MANIFEST, + Buffer.from(`${JSON.stringify({ ...boundLocator, sessionId })}\n`, "utf8"), + ); + await ownerStore.publishNoReplace("artifact.bin", Buffer.from("owner-payload", "utf8")); + return { root, sessionId, context, locator: boundLocator, ownerPath, ownerRoot }; + } finally { + ownerStore?.close(); + rootStore.close(); + } +} + +function openFixtureOwner(fixture: OwnerFixture): ManagedSessionDescendantStore { + return new ManagedSessionDescendantStore( + fixture.context.rootAuthority, + fixture.ownerPath, + undefined, + fixture.context.securityPolicy, + fixture.context.profileAgentDir, + fixture.ownerRoot, + ); +} + +async function replaceManifest(fixture: OwnerFixture, value: Uint8Array): Promise { + const ownerStore = openFixtureOwner(fixture); + try { + await ownerStore.replace(OWNER_MANIFEST, value); + } finally { + ownerStore.close(); + } +} + +describe("task artifact owner read-only access", () => { + it("captures immutable evidence from a complete managed owner and tolerates only a missing locator", async () => { + const fixture = await makeOwnerFixture(); + const evidence = captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, fixture.locator); + if (!evidence) throw new Error("valid owner locator unexpectedly absent"); + const rootStore = newSessionRootStore(fixture.context); + try { + expect(evidence.locator).toEqual(fixture.locator); + expect(evidence.sessionId).toBe(fixture.sessionId); + expect(evidence.treeSnapshot.rootDev).toBe(fixture.locator.directoryDev); + expect(evidence.treeSnapshot.rootIno).toBe(fixture.locator.directoryIno); + expect(evidence.parentIdentity).toEqual(rootStore.captureDirectoryIdentity(OWNER_DIRECTORY)); + expect(Object.isFrozen(evidence)).toBe(true); + expect(Object.isFrozen(evidence.treeSnapshot.entries)).toBe(true); + } finally { + rootStore.close(); + } + expect(captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, undefined)).toBeUndefined(); + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, { + ...fixture.locator, + ownerId: "bad", + }), + ).toThrow("task_artifact_owner_locator_invalid"); + }); + + it("rejects a noncanonical profile and session or manifest identity substitutions", async () => { + const fixture = await makeOwnerFixture(); + const badProfile = { ...fixture.context, profileAgentDir: "relative-profile" }; + expect(() => captureTaskArtifactOwnerDeletionEvidence(badProfile, fixture.sessionId, fixture.locator)).toThrow( + "task_artifact_owner_profile_invalid", + ); + const fileProfilePath = path.join(fixture.root, "not-a-profile-directory"); + fs.writeFileSync(fileProfilePath, "untouched profile occupant", { mode: 0o600 }); + expect(() => + captureTaskArtifactOwnerDeletionEvidence( + { ...fixture.context, profileAgentDir: fileProfilePath }, + fixture.sessionId, + fixture.locator, + ), + ).toThrow("task_artifact_owner_profile_invalid"); + expect(fs.readFileSync(fileProfilePath, "utf8")).toBe("untouched profile occupant"); + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, `${fixture.sessionId}-other`, fixture.locator), + ).toThrow("task_artifact_owner_session_mismatch"); + const changedLocator = { + ...fixture.locator, + directoryDev: (BigInt(fixture.locator.directoryDev) + 1n).toString(), + }; + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, changedLocator), + ).toThrow("task_artifact_owner_manifest_mismatch"); + }); + + it("rejects malformed and session-mismatched owner manifests", async () => { + const fixture = await makeOwnerFixture(); + await replaceManifest(fixture, Buffer.from("{", "utf8")); + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, fixture.locator), + ).toThrow("task_artifact_owner_manifest_invalid"); + + const second = await makeOwnerFixture(); + await replaceManifest( + second, + Buffer.from(`${JSON.stringify({ ...second.locator, sessionId: "another-owner-session" })}\n`, "utf8"), + ); + expect(() => captureTaskArtifactOwnerDeletionEvidence(second.context, second.sessionId, second.locator)).toThrow( + "task_artifact_owner_session_mismatch", + ); + + const third = await makeOwnerFixture(); + await replaceManifest( + third, + Buffer.from(`${JSON.stringify({ ...third.locator, sessionId: third.sessionId, extra: true })}\n`, "utf8"), + ); + expect(() => captureTaskArtifactOwnerDeletionEvidence(third.context, third.sessionId, third.locator)).toThrow( + "task_artifact_owner_manifest_invalid", + ); + }); + + it("rejects a replaced parent directory without following or repairing it", async () => { + const fixture = await makeOwnerFixture(); + const parent = path.join(fixture.context.sessionsRoot, OWNER_DIRECTORY); + const retainedParent = `${parent}.retained-${crypto.randomUUID()}`; + fs.renameSync(parent, retainedParent); + fs.writeFileSync(parent, "foreign parent", { mode: 0o600 }); + try { + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, fixture.locator), + ).toThrow("managed_directory_identity_unavailable"); + expect(await Bun.file(parent).text()).toBe("foreign parent"); + } finally { + fs.unlinkSync(parent); + fs.renameSync(retainedParent, parent); + } + }); + + it("fails closed when a valid locator names a missing owner directory", async () => { + const fixture = await makeOwnerFixture(); + const displacedOwner = `${fixture.ownerPath}.displaced-${crypto.randomUUID()}`; + fs.renameSync(fixture.ownerPath, displacedOwner); + try { + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, fixture.locator), + ).toThrow("task_artifact_owner_missing"); + expect(fs.existsSync(fixture.ownerPath)).toBe(false); + } finally { + fs.renameSync(displacedOwner, fixture.ownerPath); + } + }); + + it("rejects an owner directory replaced beneath an otherwise valid manifest", async () => { + const fixture = await makeOwnerFixture(); + const displacedOwner = `${fixture.ownerPath}.displaced-${crypto.randomUUID()}`; + const replacementOwner = `${fixture.ownerPath}.replacement-${crypto.randomUUID()}`; + fs.mkdirSync(replacementOwner, { mode: 0o700 }); + fs.writeFileSync( + path.join(replacementOwner, OWNER_MANIFEST), + `${JSON.stringify({ ...fixture.locator, sessionId: fixture.sessionId })}\n`, + { mode: 0o600 }, + ); + fs.writeFileSync(path.join(replacementOwner, "artifact.bin"), "foreign-payload", { mode: 0o600 }); + fs.renameSync(fixture.ownerPath, displacedOwner); + fs.renameSync(replacementOwner, fixture.ownerPath); + try { + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, fixture.locator), + ).toThrow("task_artifact_owner_identity_mismatch"); + expect(await Bun.file(path.join(fixture.ownerPath, "artifact.bin")).text()).toBe("foreign-payload"); + } finally { + fs.renameSync(fixture.ownerPath, replacementOwner); + fs.renameSync(displacedOwner, fixture.ownerPath); + } + }); + + it("rejects both real cross-process managed publication windows and acknowledges release", async () => { + for (const phase of ["staging", "replacement"] as const) { + const fixture = await makeOwnerFixture(); + const readyPath = path.join(fixture.root, `writer-${phase}.ready`); + const releasePath = path.join(fixture.root, `writer-${phase}.release`); + const fixturePath = path.join(import.meta.dir, "fixtures", "task-owner-access-writer.ts"); + const child = Bun.spawn([process.execPath, fixturePath], { + cwd: path.resolve(import.meta.dir, ".."), + env: { + ...process.env, + GJC_TASK_OWNER_ACCESS_WRITER_INPUT: JSON.stringify({ + profileRoot: fixture.context.profileAgentDir, + ownerRoot: fixture.ownerPath, + filename: phase === "replacement" ? "artifact.bin" : `child-${phase}.bin`, + ready: readyPath, + release: releasePath, + phase, + }), + }, + stdout: "pipe", + stderr: "pipe", + }); + const stdoutPromise = new Response(child.stdout).text(); + const stderrPromise = new Response(child.stderr).text(); + try { + const deadline = Date.now() + 10_000; + while (!(await Bun.file(readyPath).exists())) { + if (Date.now() >= deadline) throw new Error(`writer ${phase} readiness timed out`); + const exited = await Promise.race([ + child.exited.then(code => ({ done: true as const, code })), + Bun.sleep(10).then(() => ({ done: false as const })), + ]); + if (exited.done) + throw new Error(`writer ${phase} exited before readiness (${exited.code}): ${await stderrPromise}`); + } + + const readyValue: unknown = JSON.parse(await Bun.file(readyPath).text()); + if (typeof readyValue !== "object" || readyValue === null || Array.isArray(readyValue)) + throw new Error(`writer ${phase} readiness record invalid`); + const record = readyValue as Record; + expect(record.pid).toEqual(expect.any(Number)); + expect(record.pid).not.toBe(process.pid); + expect(record.phase).toBe(phase); + expect(typeof record.marker).toBe("string"); + expect(typeof record.dev).toBe("string"); + expect(typeof record.ino).toBe("string"); + if (typeof record.marker !== "string" || typeof record.dev !== "string" || typeof record.ino !== "string") + throw new Error(`writer ${phase} descriptor identity missing`); + expect(record.marker).toMatch( + new RegExp(`^\\..+\\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.${phase}$`, "u"), + ); + const markerPath = path.join(fixture.ownerPath, record.marker); + const markerStat = fs.lstatSync(markerPath, { bigint: true }); + expect(markerStat.isFile()).toBe(true); + expect(markerStat.dev.toString()).toBe(record.dev); + expect(markerStat.ino.toString()).toBe(record.ino); + + const destinationBefore = await Bun.file(path.join(fixture.ownerPath, "artifact.bin")).text(); + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, fixture.locator), + ).toThrow("task_artifact_owner_writer_not_quiescent"); + expect(await Bun.file(path.join(fixture.ownerPath, "artifact.bin")).text()).toBe(destinationBefore); + expect(fs.lstatSync(markerPath, { bigint: true }).ino.toString()).toBe(record.ino); + } finally { + await Bun.write(releasePath, "release"); + const exitCode = await child.exited; + const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]); + expect(exitCode, `${phase} writer failed: ${stderr}`).toBe(0); + expect(stdout).toContain(`"phase":"${phase}"`); + expect(stdout).toContain('"status":"acknowledged"'); + } + if (phase === "replacement") + expect(await Bun.file(path.join(fixture.ownerPath, "artifact.bin")).text()).toBe( + "independent-managed-replacement", + ); + else + expect(await Bun.file(path.join(fixture.ownerPath, `child-${phase}.bin`)).text()).toBe( + "independent-managed-staging", + ); + } + }); + + it("returns the real native exact-removal result only for the captured parent identity", async () => { + const fixture = await makeOwnerFixture(); + const rootStore = newSessionRootStore(fixture.context); + const directRemove = native.exactRemoveDirectoryTree; + let nativeResult: native.NativeExactUnlinkResult | undefined; + const removalSpy = vi + .spyOn(native, "exactRemoveDirectoryTree") + .mockImplementation((pathname, snapshot, parent) => { + nativeResult = directRemove(pathname, snapshot, parent); + return nativeResult; + }); + try { + const snapshot = rootStore.captureTree(ownerRelativePath(fixture.locator.ownerId)); + const parent = rootStore.captureDirectoryIdentity(OWNER_DIRECTORY); + expect(() => + rootStore.removeTreeExpectedWithParentIdentity(ownerRelativePath(fixture.locator.ownerId), snapshot, { + dev: BigInt(parent.dev), + ino: BigInt(parent.ino) + 1n, + }), + ).toThrow("managed_remove_parent_identity_mismatch"); + const result = rootStore.removeTreeExpectedWithParentIdentity( + ownerRelativePath(fixture.locator.ownerId), + snapshot, + { + dev: BigInt(parent.dev), + ino: BigInt(parent.ino), + }, + ); + expect(removalSpy).toHaveBeenCalledTimes(1); + if (!nativeResult) throw new Error("Expected the real native removal call"); + expect(result).toBe(nativeResult); + // Durable native scrub may retain a namespace; do not demand POSIX physical reclamation. + if (!result.ok) expect(result.code).toBeDefined(); + } finally { + removalSpy.mockRestore(); + rootStore.close(); + } + }); + + it("rejects a substituted directory in the read-only expected-identity security window", async () => { + const fixture = await makeOwnerFixture(); + const candidate = path.join(fixture.root, "substitute-session-root"); + const displaced = path.join(fixture.root, "original-session-root"); + fs.mkdirSync(candidate, { mode: 0o700 }); + fs.writeFileSync(path.join(candidate, "sentinel"), "unmodified foreign bytes", { mode: 0o600 }); + const expected = expectedDirectoryRoot(fixture.context.sessionsRoot); + const nativeBindings = native; + let swapped = false; + let verifiedWithoutMutation = false; + let restoreVerifier: (() => void) | undefined; + const swapAndFingerprint = (): { dev: bigint; ino: bigint; mode: bigint; ctimeNs: bigint; bytes: string } => { + if (!swapped) { + fs.renameSync(fixture.context.sessionsRoot, displaced); + fs.renameSync(candidate, fixture.context.sessionsRoot); + swapped = true; + } + const stat = fs.lstatSync(fixture.context.sessionsRoot, { bigint: true }); + return { + dev: stat.dev, + ino: stat.ino, + mode: stat.mode & 0o777n, + ctimeNs: stat.ctimeNs, + bytes: fs.readFileSync(path.join(fixture.context.sessionsRoot, "sentinel"), "utf8"), + }; + }; + + try { + if (process.platform === "win32") { + const original = nativeBindings.verifyOwnerOnlyPathSecurityExpected.bind(nativeBindings); + const spy = vi + .spyOn(nativeBindings, "verifyOwnerOnlyPathSecurityExpected") + .mockImplementation((pathname, kind, dev, ino) => { + if (pathname !== fixture.context.sessionsRoot) return original(pathname, kind, dev, ino); + const before = swapAndFingerprint(); + expect(before.ino).not.toBe(expected.ino); + const result = original(pathname, kind, dev, ino); + expect(swapAndFingerprint()).toEqual(before); + verifiedWithoutMutation = true; + return result; + }); + restoreVerifier = () => spy.mockRestore(); + } else { + const original = nativeBindings.verifyOwnerOnlyPathSecurity.bind(nativeBindings); + const spy = vi.spyOn(nativeBindings, "verifyOwnerOnlyPathSecurity").mockImplementation((pathname, kind) => { + if (pathname !== fixture.context.sessionsRoot) return original(pathname, kind); + const before = swapAndFingerprint(); + expect(before.ino).not.toBe(expected.ino); + const result = original(pathname, kind); + expect(swapAndFingerprint()).toEqual(before); + verifiedWithoutMutation = true; + return result; + }); + restoreVerifier = () => spy.mockRestore(); + } + expect( + () => + new ManagedSessionDescendantStore( + fixture.context.rootAuthority, + fixture.context.sessionsRoot, + undefined, + fixture.context.securityPolicy, + fixture.context.profileAgentDir, + expected, + ), + ).toThrow(); + expect(verifiedWithoutMutation).toBe(true); + expect(fs.readFileSync(path.join(fixture.context.sessionsRoot, "sentinel"), "utf8")).toBe( + "unmodified foreign bytes", + ); + } finally { + restoreVerifier?.(); + if (swapped) { + fs.renameSync(fixture.context.sessionsRoot, candidate); + fs.renameSync(displaced, fixture.context.sessionsRoot); + } + } + }); + + it("opens a complete owner only after validating its locator and manifest", async () => { + const fixture = await makeOwnerFixture(); + const rootStore = newSessionRootStore(fixture.context); + let ownerStore: ManagedSessionDescendantStore | undefined; + try { + ownerStore = openOwnerStore(fixture.context, rootStore, fixture.locator, fixture.sessionId); + expect(ownerStore.subtreeRootAuthority).toEqual(fixture.ownerRoot); + expect(ownerStore.readExpected(OWNER_MANIFEST)?.bytes.byteLength).toBeGreaterThan(0); + expect(() => + openOwnerStore(fixture.context, rootStore, fixture.locator, `${fixture.sessionId}-wrong`), + ).toThrow("task_artifact_owner_session_mismatch"); + } finally { + ownerStore?.close(); + rootStore.close(); + } + }); +}); From cd7883cf70a08e01fb86b1001b47cb896da846c2 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 4 Oct 2026 05:27:20 +0900 Subject: [PATCH 2/2] test(session): exercise managed path writers across platforms Retained Linux stores publish inside native code, so their store method never opens the JavaScript path publisher's UUID staging window. Use the existing managed path publication and checked replacement APIs directly to hold genuine cross-process descriptors without faking native authority or skipping probes. Lore-id: 54fa128d Constraint: original native result and publication guards remain unchanged Confidence: high Scope-risk: narrow Reversibility: easy Tested: owner access tests on Darwin with genuine independent writer processes Not-tested: fresh Linux and Windows CI at the new head --- .../test/fixtures/task-owner-access-writer.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/test/fixtures/task-owner-access-writer.ts b/packages/coding-agent/test/fixtures/task-owner-access-writer.ts index 1d1e0b6b240..ef8a514c004 100644 --- a/packages/coding-agent/test/fixtures/task-owner-access-writer.ts +++ b/packages/coding-agent/test/fixtures/task-owner-access-writer.ts @@ -3,9 +3,12 @@ import * as fs from "node:fs"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; import { + captureManagedFileNoFollow, type ManagedDirectoryRoot, ManagedSessionDescendantStore, prepareManagedDirectoryRoot, + publishManagedFileNoReplace, + replaceManagedFileSync, } from "../../src/session/internal/managed-session-storage"; interface WriterInput { @@ -123,7 +126,16 @@ try { }); const spy = vi.spyOn(fs, "writeSync").mockImplementation(write); try { - store.replaceSync(input.filename, Buffer.from("independent-managed-replacement", "utf8")); + const destination = path.join(input.ownerRoot, input.filename); + const predecessor = captureManagedFileNoFollow(destination); + replaceManagedFileSync( + destination, + Buffer.from("independent-managed-replacement", "utf8"), + expectedOwner, + securityPolicy, + undefined, + predecessor.identity, + ); if (!held) throw new Error("task_owner_writer_replacement_boundary_missing"); } finally { spy.mockRestore(); @@ -152,7 +164,15 @@ try { return handle; }); try { - await store.publishNoReplace(input.filename, Buffer.from("independent-managed-staging", "utf8")); + // Retained Linux stores publish natively without the path API's .staging window. + // Exercise the actual managed path publisher on every platform; no native authority is mocked. + await publishManagedFileNoReplace( + path.join(input.ownerRoot, input.filename), + Buffer.from("independent-managed-staging", "utf8"), + undefined, + expectedOwner, + securityPolicy, + ); if (!held) throw new Error("task_owner_writer_staging_boundary_missing"); } finally { open.mockRestore();