{"version":3,"file":"safety.test.d.ts","sourceRoot":"","sources":["../../../src/core/safety/safety.test.ts"],"names":[],"mappings":"","sourcesContent":["import fs from \"node:fs/promises\";\nimport os from \"node:os\";\nimport nodePath from \"node:path\";\nimport type { ToolEffects } from \"@apholdings/jensen-agent-core\";\nimport { describe, expect, it } from \"vitest\";\nimport { codingTools, createAllTools, createCodingTools, createReadOnlyTools, readOnlyTools } from \"../tools/index.js\";\nimport { validatePathInput, WorkspaceBoundary, WorkspaceBoundaryError } from \"./boundary.js\";\nimport { CheckpointStore } from \"./checkpoint.js\";\nimport { PRODUCTION_TOOL_EFFECTS, UndeclaredEffectsError, validateToolEffects } from \"./effects.js\";\nimport { WorkspaceLeaseStore } from \"./lease.js\";\nimport { PolicyDeniedError, WorkspaceSafety } from \"./manager.js\";\nimport { BASELINE_RULES, isSecretPath, PolicyEngine } from \"./policy.js\";\nimport { WorkspaceTransactionManager } from \"./transaction.js\";\nimport type { PolicyInput } from \"./types.js\";\n\nasync function tmpdir(): Promise<string> {\n\treturn fs.mkdtemp(nodePath.join(os.tmpdir(), \"jensen-safety-\"));\n}\n\nlet counter = 0;\nfunction workspace(root: string): string {\n\treturn nodePath.join(root, `workspace-${++counter}`);\n}\n\ndescribe(\"tool effects\", () => {\n\tit(\"every production tool declares effects\", () => {\n\t\tconst undeclared = validateToolEffects([...codingTools, ...readOnlyTools], false);\n\t\texpect(undeclared).toEqual([]);\n\t});\n\n\tit(\"read-only vs mutating tools are classified correctly\", () => {\n\t\tconst read = PRODUCTION_TOOL_EFFECTS.read;\n\t\texpect(read.writesWorkspace).toBe(false);\n\t\texpect(read.parallelSafe).toBe(true);\n\t\tconst write = PRODUCTION_TOOL_EFFECTS.write;\n\t\texpect(write.writesWorkspace).toBe(true);\n\t\texpect(write.requiresExclusiveWorkspaceLease).toBe(true);\n\t\texpect(write.parallelSafe).toBe(false);\n\t});\n\n\tit(\"parallel safety is not inferred from reads\", () => {\n\t\t// read is explicit; write must not be parallelSafe\n\t\texpect(PRODUCTION_TOOL_EFFECTS.write.parallelSafe).toBe(false);\n\t});\n\n\tit(\"unknown tools fail conservative validation\", () => {\n\t\tconst fake = { name: \"mystery\", label: \"m\" } as never;\n\t\tconst undeclared = validateToolEffects([fake], false);\n\t\texpect(undeclared).toContain(\"mystery\");\n\t\texpect(() => validateToolEffects([fake], true)).toThrow(UndeclaredEffectsError);\n\t});\n\n\tit(\"dynamic shell tools are conservative and never parallelSafe\", () => {\n\t\tfor (const name of [\"bash\", \"powershell\"] as const) {\n\t\t\tconst e = PRODUCTION_TOOL_EFFECTS[name];\n\t\t\texpect(e.executesProcesses).toBe(true);\n\t\t\texpect(e.potentiallyDestructive).toBe(true);\n\t\t\texpect(e.parallelSafe).toBe(false);\n\t\t}\n\t});\n\n\tit(\"createAllTools and createReadOnlyTools attach effects\", () => {\n\t\tconst cwd = process.cwd();\n\t\tconst all = createAllTools(cwd);\n\t\tfor (const name of Object.keys(all) as (keyof typeof all)[]) {\n\t\t\texpect(all[name].effects, `tool ${name}`).toBeDefined();\n\t\t}\n\t\tfor (const tool of createCodingTools(cwd)) {\n\t\t\texpect(tool.effects, `tool ${tool.name}`).toBeDefined();\n\t\t}\n\t\tfor (const tool of createReadOnlyTools(cwd)) {\n\t\t\texpect(tool.effects, `tool ${tool.name}`).toBeDefined();\n\t\t}\n\t});\n});\n\ndescribe(\"policy engine\", () => {\n\tconst engine = new PolicyEngine(BASELINE_RULES, {});\n\tconst base: Omit<PolicyInput, \"toolName\" | \"effects\"> = {\n\t\tworkspaceId: \"ws\",\n\t\texecutionMode: \"execute\",\n\t\tcurrentBranch: \"main\",\n\t\tgitClean: false,\n\t};\n\n\tfunction effects(partial: Partial<ToolEffects>): ToolEffects {\n\t\treturn {\n\t\t\treadsWorkspace: false,\n\t\t\twritesWorkspace: false,\n\t\t\tcreatesFiles: false,\n\t\t\tdeletesFiles: false,\n\t\t\texecutesProcesses: false,\n\t\t\tstartsPersistentProcesses: false,\n\t\t\taccessesNetwork: false,\n\t\t\tmutatesGit: false,\n\t\t\tmutatesExternalState: false,\n\t\t\thandlesSecrets: false,\n\t\t\tpotentiallyDestructive: false,\n\t\t\trequiresExclusiveWorkspaceLease: false,\n\t\t\tparallelSafe: true,\n\t\t\t...partial,\n\t\t};\n\t}\n\n\tit(\"deny overrides allow and approval\", () => {\n\t\t// destructive shell deny beats the default allow\n\t\tconst d = engine.evaluate({\n\t\t\t...base,\n\t\t\ttoolName: \"bash\",\n\t\t\teffects: effects({ executesProcesses: true }),\n\t\t\trequestedCommand: \"git reset --hard\",\n\t\t});\n\t\texpect(d.decision.outcome).toBe(\"deny\");\n\t});\n\n\tit(\"force-push to a protected branch is denied without release authorization\", () => {\n\t\tconst d = engine.evaluate({\n\t\t\t...base,\n\t\t\ttoolName: \"bash\",\n\t\t\teffects: effects({ executesProcesses: true, mutatesGit: true }),\n\t\t\trequestedCommand: \"git push --force origin main\",\n\t\t});\n\t\texpect(d.decision.outcome).toBe(\"deny\");\n\t\texpect(d.decision.reasonCode).toBe(\"force_push_protected_branch\");\n\t});\n\n\tit(\"authorized release flow is allowed\", () => {\n\t\tconst d = engine.evaluate({\n\t\t\t...base,\n\t\t\ttoolName: \"bash\",\n\t\t\teffects: effects({ mutatesGit: true, mutatesExternalState: true }),\n\t\t\trequestedCommand: \"npm run release\",\n\t\t\treleaseAuthorized: true,\n\t\t});\n\t\texpect(d.decision.outcome).toBe(\"allow\");\n\t});\n\n\tit(\"plan mode blocks writes\", () => {\n\t\tconst d = engine.evaluate({\n\t\t\t...base,\n\t\t\texecutionMode: \"plan\",\n\t\t\ttoolName: \"write\",\n\t\t\teffects: effects({ writesWorkspace: true, requiresExclusiveWorkspaceLease: true }),\n\t\t\tresolvedPaths: [\"/ws/a.txt\"],\n\t\t});\n\t\texpect(d.decision.outcome).toBe(\"require_approval\");\n\t});\n\n\tit(\"observe mode denies mutations\", () => {\n\t\tconst d = engine.evaluate({\n\t\t\t...base,\n\t\t\texecutionMode: \"observe\",\n\t\t\ttoolName: \"write\",\n\t\t\teffects: effects({ writesWorkspace: true }),\n\t\t});\n\t\texpect(d.decision.outcome).toBe(\"deny\");\n\t});\n\n\tit(\"execute mode still requires authorization for mutations\", () => {\n\t\tconst d = engine.evaluate({\n\t\t\t...base,\n\t\t\texecutionMode: \"execute\",\n\t\t\ttoolName: \"write\",\n\t\t\teffects: effects({ writesWorkspace: true }),\n\t\t});\n\t\texpect(d.decision.outcome).toBe(\"require_approval\");\n\t});\n\n\tit(\"web content cannot authorize a mutation\", () => {\n\t\t// A web_fetch/web_search tool is read-only: its effects can never flip a\n\t\t// write decision. Model/web output never reaches the policy engine.\n\t\tconst web = engine.evaluate({\n\t\t\t...base,\n\t\t\ttoolName: \"web_fetch\",\n\t\t\teffects: effects({ accessesNetwork: true }),\n\t\t});\n\t\texpect(web.decision.outcome).toBe(\"allow\");\n\t\t// And a write decision is independent of any web tool.\n\t\tconst write = engine.evaluate({\n\t\t\t...base,\n\t\t\ttoolName: \"write\",\n\t\t\teffects: effects({ writesWorkspace: true }),\n\t\t});\n\t\texpect(write.decision.outcome).toBe(\"require_approval\");\n\t});\n\n\tit(\"deny.policy-bypass markers\", () => {\n\t\tconst d = engine.evaluate({\n\t\t\t...base,\n\t\t\ttoolName: \"bash\",\n\t\t\teffects: effects({ executesProcesses: true }),\n\t\t\trequestedCommand: \"git commit --no-verify\",\n\t\t});\n\t\texpect(d.decision.outcome).toBe(\"deny\");\n\t\texpect(d.decision.reasonCode).toBe(\"policy_bypass_marker\");\n\t});\n\n\tit(\"secret paths are denied\", () => {\n\t\texpect(isSecretPath(\"/ws/config/keys/id_rsa\")).toBe(true);\n\t\texpect(isSecretPath(\"/ws/.env\")).toBe(true);\n\t\texpect(isSecretPath(\"/ws/src/app.ts\")).toBe(false);\n\t});\n\n\tit(\"unknown effects require approval conservatively\", () => {\n\t\tconst d = engine.evaluate({\n\t\t\t...base,\n\t\t\ttoolName: \"bash\",\n\t\t\teffects: effects({ executesProcesses: true, scopes: [{ kind: \"unknown\" }] }),\n\t\t});\n\t\texpect([\"require_approval\", \"deny\"]).toContain(d.decision.outcome);\n\t});\n});\n\ndescribe(\"workspace boundary\", () => {\n\tit(\"rejects traversal and absolute external paths\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\tconst external = nodePath.join(dir, \"external\");\n\t\tawait fs.mkdir(external, { recursive: true });\n\t\tconst boundary = await WorkspaceBoundary.create(ws);\n\t\tawait expect(boundary.resolveWithin(\"../external/evil.txt\")).rejects.toThrow(WorkspaceBoundaryError);\n\t\tawait expect(boundary.resolveWithin(nodePath.join(external, \"evil.txt\"))).rejects.toThrow(WorkspaceBoundaryError);\n\t});\n\n\tit(\"resolves a normal file inside the workspace\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\tawait fs.writeFile(nodePath.join(ws, \"a.txt\"), \"hello\");\n\t\tconst boundary = await WorkspaceBoundary.create(ws);\n\t\tconst resolved = await boundary.resolveWithin(\"a.txt\");\n\t\texpect(resolved).toBe(nodePath.join(ws, \"a.txt\"));\n\t});\n\n\tit(\"blocks symlink escape\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\tconst external = nodePath.join(dir, \"external\");\n\t\tawait fs.mkdir(external, { recursive: true });\n\t\tconst link = nodePath.join(ws, \"leak\");\n\t\tawait fs.symlink(external, link);\n\t\tconst boundary = await WorkspaceBoundary.create(ws);\n\t\tawait expect(boundary.resolveWithin(\"leak/evil.txt\")).rejects.toThrow(WorkspaceBoundaryError);\n\t});\n\n\tit(\"blocks nested symlink escape\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\tconst external = nodePath.join(dir, \"external\");\n\t\tawait fs.mkdir(external, { recursive: true });\n\t\tconst inner = nodePath.join(ws, \"inner\");\n\t\tawait fs.mkdir(inner, { recursive: true });\n\t\tawait fs.symlink(external, nodePath.join(inner, \"leak\"));\n\t\tconst boundary = await WorkspaceBoundary.create(ws);\n\t\tawait expect(boundary.resolveWithin(\"inner/leak/x.txt\")).rejects.toThrow(WorkspaceBoundaryError);\n\t});\n\n\tit(\"TOCTOU revalidation catches a parent swapped to a symlink\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\tconst external = nodePath.join(dir, \"external\");\n\t\tawait fs.mkdir(external, { recursive: true });\n\t\tconst target = nodePath.join(ws, \"vuln\");\n\t\tawait fs.mkdir(target, { recursive: true });\n\t\tconst boundary = await WorkspaceBoundary.create(ws);\n\t\tconst resolved = await boundary.resolveWithin(\"vuln/new.txt\");\n\t\texpect(resolved.startsWith(ws)).toBe(true);\n\t\t// Swap the parent for a symlink to an external directory.\n\t\tawait fs.rm(target, { recursive: true });\n\t\tawait fs.symlink(external, target);\n\t\tawait expect(boundary.assertParentWithin(resolved)).rejects.toThrow(WorkspaceBoundaryError);\n\t});\n\n\tit(\"rejects NUL input\", () => {\n\t\texpect(() => validatePathInput(\"a\\u0000b\")).toThrow(WorkspaceBoundaryError);\n\t});\n});\n\ndescribe(\"mutation lease\", () => {\n\tit(\"is exclusive and separate workspaces stay independent\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst store = new WorkspaceLeaseStore({ storageDir: dir, now: () => 1000 });\n\t\tconst a = await store.acquire(\"ws-a\", \"run-1\");\n\t\texpect(a.ok).toBe(true);\n\t\tconst second = await store.acquire(\"ws-a\", \"run-2\");\n\t\texpect(second.ok).toBe(false);\n\t\texpect(second.ok === false && \"lease\" in second).toBe(true);\n\t\tconst b = await store.acquire(\"ws-b\", \"run-3\");\n\t\texpect(b.ok).toBe(true);\n\t\tawait store.release(\"ws-b\", \"run-3\");\n\t\t// a still held\n\t\tconst a3 = await store.acquire(\"ws-a\", \"run-4\");\n\t\texpect(a3.ok).toBe(false);\n\t});\n\n\tit(\"stale lease can be recovered after positive liveness check\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tlet time = 10_000;\n\t\tconst store = new WorkspaceLeaseStore({\n\t\t\tstorageDir: dir,\n\t\t\ttimeoutMs: 1000,\n\t\t\tnow: () => time,\n\t\t\tisProcessAlive: () => false,\n\t\t});\n\t\tawait store.acquire(\"ws\", \"run-1\");\n\t\ttime += 5000;\n\t\t// heartbeat does not advance (no heartbeat call), lease is stale+dead\n\t\tconst rec = await store.acquire(\"ws\", \"run-2\");\n\t\texpect(rec.ok).toBe(true);\n\t});\n\n\tit(\"live lease cannot be stolen\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst store = new WorkspaceLeaseStore({\n\t\t\tstorageDir: dir,\n\t\t\ttimeoutMs: 1000,\n\t\t\tnow: () => 100,\n\t\t\tisProcessAlive: () => true,\n\t\t});\n\t\tawait store.acquire(\"ws\", \"run-1\");\n\t\tconst attempt = await store.acquire(\"ws\", \"run-2\");\n\t\texpect(attempt.ok).toBe(false);\n\t\tawait expect(store.recoverIfStale(\"ws\", \"run-2\")).rejects.toThrowError(/alive/);\n\t});\n\n\tit(\"release is idempotent and owner-scoped\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst store = new WorkspaceLeaseStore({ storageDir: dir, now: () => 100 });\n\t\tawait store.acquire(\"ws\", \"run-1\");\n\t\tawait store.release(\"ws\", \"run-1\");\n\t\tawait store.release(\"ws\", \"run-1\");\n\t\texpect(await store.status(\"ws\")).toBeNull();\n\t\t// a new owner can acquire after release\n\t\tconst again = await store.acquire(\"ws\", \"run-2\");\n\t\texpect(again.ok).toBe(true);\n\t});\n});\n\ndescribe(\"checkpoint store\", () => {\n\tasync function setup(): Promise<{ dir: string; cp: CheckpointStore; ws: string }> {\n\t\tconst dir = await tmpdir();\n\t\tconst cp = new CheckpointStore({ storageDir: dir });\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\treturn { dir, cp, ws };\n\t}\n\n\tit(\"restores a modified file, removes a created file, recreates a deleted file\", async () => {\n\t\tconst { cp, ws } = await setup();\n\t\tconst a = nodePath.join(ws, \"a.txt\");\n\t\tconst b = nodePath.join(ws, \"b.txt\");\n\t\tawait fs.writeFile(a, \"before-a\");\n\t\tawait fs.writeFile(b, \"before-b\");\n\t\tconst checkpoint = await cp.create(\"ws\", \"tx1\", [a, b]);\n\t\t// mutate\n\t\tawait fs.writeFile(a, \"after-a\");\n\t\tawait fs.writeFile(nodePath.join(ws, \"new.txt\"), \"new\");\n\t\tawait fs.rm(b);\n\t\t// rollback manually for a and b via a fresh transaction manager to reuse logic\n\t\t// Here we just verify the store content materialize\n\t\tawait cp.verify(checkpoint.checkpointId);\n\t\tconst mat = await cp.materialize(checkpoint.checkpointId, {\n\t\t\tpath: a,\n\t\t\ttype: \"file\",\n\t\t\texisted: true,\n\t\t\tcontentSha256: checkpoint.entries.find((e) => e.path === a)!.contentSha256,\n\t\t});\n\t\texpect(mat!.toString()).toBe(\"before-a\");\n\t\tawait cp.updateStatus(checkpoint.checkpointId, \"confirmed\");\n\t\tconst read = await cp.read(checkpoint.checkpointId);\n\t\texpect(read!.status).toBe(\"confirmed\");\n\t});\n\n\tit(\"detects checkpoint tampering\", async () => {\n\t\tconst { cp, ws } = await setup();\n\t\tconst a = nodePath.join(ws, \"a.txt\");\n\t\tawait fs.writeFile(a, \"hello\");\n\t\tconst checkpoint = await cp.create(\"ws\", \"tx1\", [a]);\n\t\t// Tamper with the manifest\n\t\tconst manifestPath = nodePath.join(cp.storageDir, \"checkpoints\", checkpoint.checkpointId, \"manifest.json\");\n\t\tconst raw = JSON.parse(await fs.readFile(manifestPath, \"utf-8\"));\n\t\traw.entries[0].contentSha256 = \"deadbeef\";\n\t\tawait fs.writeFile(manifestPath, JSON.stringify(raw));\n\t\tawait expect(cp.verify(checkpoint.checkpointId)).rejects.toThrowError(/mismatch/i);\n\t});\n\n\tit(\"blocks oversized file checkpointing\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst cp = new CheckpointStore({ storageDir: dir, maxCheckpointBytes: 4 });\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\tconst a = nodePath.join(ws, \"big.txt\");\n\t\tawait fs.writeFile(a, \"this is way too long\");\n\t\tawait expect(cp.create(\"ws\", \"tx1\", [a])).rejects.toThrowError(/oversized/i);\n\t});\n\n\tit(\"gc removes only confirmed, expired checkpoints\", async () => {\n\t\tconst { cp, ws } = await setup();\n\t\tconst a = nodePath.join(ws, \"a.txt\");\n\t\tawait fs.writeFile(a, \"hi\");\n\t\tconst cp1 = await cp.create(\"ws\", \"tx1\", [a]);\n\t\tconst cp2 = await cp.create(\"ws\", \"tx2\", [a]);\n\t\tawait cp.updateStatus(cp1.checkpointId, \"confirmed\");\n\t\t// cp2 remains created (active/recovery) and must never be collected.\n\t\tconst now = Date.now() + 1000;\n\t\tconst res = await cp.gc({ retainMs: 0, now });\n\t\texpect(res.removed).toContain(cp1.checkpointId);\n\t\texpect(res.removed).not.toContain(cp2.checkpointId);\n\t\texpect(await cp.read(cp1.checkpointId)).toBeNull();\n\t\texpect(await cp.read(cp2.checkpointId)).not.toBeNull();\n\t});\n});\n\ndescribe(\"workspace transaction\", () => {\n\tasync function setup(): Promise<{\n\t\tdir: string;\n\t\tmgr: WorkspaceTransactionManager;\n\t\tws: string;\n\t\tboundary: WorkspaceBoundary;\n\t}> {\n\t\tconst dir = await tmpdir();\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\tconst boundary = await WorkspaceBoundary.create(ws);\n\t\tconst checkpoints = new CheckpointStore({ storageDir: dir });\n\t\tconst mgr = new WorkspaceTransactionManager(dir, boundary, checkpoints);\n\t\treturn { dir, mgr, ws, boundary };\n\t}\n\n\tit(\"successful multi-file transaction confirms\", async () => {\n\t\tconst { mgr, ws } = await setup();\n\t\tconst a = nodePath.join(ws, \"a.txt\");\n\t\tawait fs.writeFile(a, \"old\");\n\t\tconst tx = await mgr.begin(\"ws\", { mode: \"execute\", policy: { outcome: \"allow\", ruleId: \"t\", reasonCode: \"x\" } });\n\t\tconst paths = await mgr.resolvePaths([\n\t\t\t{ kind: \"replace_file\", path: \"a.txt\", content: \"new-a\" },\n\t\t\t{ kind: \"create_file\", path: \"b.txt\", content: \"new-b\" },\n\t\t]);\n\t\tawait mgr.checkpoint(tx, paths);\n\t\tconst applied = await mgr.apply(tx, [\n\t\t\t{ kind: \"replace_file\", path: \"a.txt\", content: \"new-a\" },\n\t\t\t{ kind: \"create_file\", path: \"b.txt\", content: \"new-b\" },\n\t\t]);\n\t\texpect(applied.changed.length).toBe(2);\n\t\texpect(await fs.readFile(a, \"utf-8\")).toBe(\"new-a\");\n\t\texpect(await fs.readFile(nodePath.join(ws, \"b.txt\"), \"utf-8\")).toBe(\"new-b\");\n\t\tawait mgr.validate(tx, { id: \"t\", label: \"t\", run: () => Promise.resolve({ exitCode: 0, outputArtifact: \"\" }) });\n\t\tawait mgr.confirm(tx);\n\t\texpect(await mgr.classify(tx.transactionId)).toBe(\"already_confirmed\");\n\t});\n\n\tit(\"validation failure rolls back\", async () => {\n\t\tconst { mgr, ws } = await setup();\n\t\tconst a = nodePath.join(ws, \"a.txt\");\n\t\tawait fs.writeFile(a, \"original\");\n\t\tconst tx = await mgr.begin(\"ws\", { mode: \"execute\", policy: null });\n\t\tconst paths = await mgr.resolvePaths([{ kind: \"replace_file\", path: \"a.txt\", content: \"changed\" }]);\n\t\tawait mgr.checkpoint(tx, paths);\n\t\tawait mgr.apply(tx, [{ kind: \"replace_file\", path: \"a.txt\", content: \"changed\" }]);\n\t\tawait mgr.validate(tx, {\n\t\t\tid: \"t\",\n\t\t\tlabel: \"t\",\n\t\t\trun: () => Promise.resolve({ exitCode: 1, outputArtifact: \"boom\" }),\n\t\t});\n\t\texpect(tx.validation!.result).toBe(\"failed\");\n\t\tawait expect(mgr.confirm(tx)).rejects.toThrow(/validation/i);\n\t\tconst rollback = await mgr.rollback(tx);\n\t\texpect(rollback.status).toBe(\"rolled_back\");\n\t\texpect(await fs.readFile(a, \"utf-8\")).toBe(\"original\");\n\t\texpect(await mgr.classify(tx.transactionId)).toBe(\"already_rolled_back\");\n\t});\n\n\tit(\"partial write failure rolls back\", async () => {\n\t\tconst { mgr, ws } = await setup();\n\t\tconst a = nodePath.join(ws, \"a.txt\");\n\t\tawait fs.writeFile(a, \"original\");\n\t\tconst tx = await mgr.begin(\"ws\", { mode: \"execute\", policy: null });\n\t\tconst paths = await mgr.resolvePaths([\n\t\t\t{ kind: \"replace_file\", path: \"a.txt\", content: \"ok\" },\n\t\t\t{ kind: \"create_file\", path: \"sub/deep/unwritable.txt\", content: \"x\" },\n\t\t]);\n\t\tawait mgr.checkpoint(tx, paths);\n\t\tawait expect(\n\t\t\tmgr.apply(tx, [\n\t\t\t\t{ kind: \"replace_file\", path: \"a.txt\", content: \"ok\" },\n\t\t\t\t{ kind: \"create_file\", path: \"sub/deep/never.txt\", content: \"x\" },\n\t\t\t]),\n\t\t).rejects.toThrow();\n\t\t// a.txt was already written then rolled back\n\t\texpect(await fs.readFile(a, \"utf-8\")).toBe(\"original\");\n\t});\n\n\tit(\"rollback is idempotent and drift-aware\", async () => {\n\t\tconst { mgr, ws } = await setup();\n\t\tconst a = nodePath.join(ws, \"a.txt\");\n\t\tawait fs.writeFile(a, \"original\");\n\t\tconst tx = await mgr.begin(\"ws\", { mode: \"execute\", policy: null });\n\t\tconst paths = await mgr.resolvePaths([{ kind: \"replace_file\", path: \"a.txt\", content: \"changed\" }]);\n\t\tawait mgr.checkpoint(tx, paths);\n\t\tawait mgr.apply(tx, [{ kind: \"replace_file\", path: \"a.txt\", content: \"changed\" }]);\n\t\t// user edits after transaction\n\t\tawait fs.writeFile(a, \"user-drift\");\n\t\tconst rb = await mgr.rollback(tx);\n\t\texpect(rb.status).toBe(\"conflict\");\n\t\texpect(rb.conflicts.length).toBeGreaterThan(0);\n\t\texpect(await fs.readFile(a, \"utf-8\")).toBe(\"user-drift\");\n\t\t// rollback again is safe (idempotent)\n\t\tconst rb2 = await mgr.rollback(tx);\n\t\texpect(rb2.status).toBe(\"conflict\");\n\t\texpect(await fs.readFile(a, \"utf-8\")).toBe(\"user-drift\");\n\t});\n\n\tit(\"plan-mode preview performs zero physical mutations\", async () => {\n\t\tconst { mgr, ws } = await setup();\n\t\tconst preview = await mgr.preview([\n\t\t\t{ kind: \"create_file\", path: \"new.txt\", content: \"hello\" },\n\t\t\t{ kind: \"replace_file\", path: \"a.txt\", content: \"x\" },\n\t\t\t{ kind: \"delete_file\", path: \"gone.txt\" },\n\t\t]);\n\t\texpect(preview.created).toEqual([\"new.txt\"]);\n\t\texpect(preview.modified).toEqual([\"a.txt\"]);\n\t\texpect(preview.deleted).toEqual([\"gone.txt\"]);\n\t\texpect(preview.bytesChanged).toBeGreaterThan(0);\n\t\texpect(await fs.stat(nodePath.join(ws, \"new.txt\")).catch(() => null)).toBeNull();\n\t});\n\n\tit(\"crash after checkpoint is classified safe_to_resume_apply\", async () => {\n\t\tconst { mgr, ws } = await setup();\n\t\tconst a = nodePath.join(ws, \"a.txt\");\n\t\tawait fs.writeFile(a, \"original\");\n\t\tconst tx = await mgr.begin(\"ws\", { mode: \"execute\", policy: null });\n\t\tconst paths = await mgr.resolvePaths([{ kind: \"replace_file\", path: \"a.txt\", content: \"new\" }]);\n\t\tawait mgr.checkpoint(tx, paths);\n\t\texpect(await mgr.classify(tx.transactionId)).toBe(\"safe_to_resume_apply\");\n\t});\n\n\tit(\"confirmed transaction remains confirmed\", async () => {\n\t\tconst { mgr } = await setup();\n\t\tconst tx = await mgr.begin(\"ws\", { mode: \"execute\", policy: null });\n\t\tconst paths = await mgr.resolvePaths([{ kind: \"create_file\", path: \"c.txt\", content: \"c\" }]);\n\t\tawait mgr.checkpoint(tx, paths);\n\t\tawait mgr.apply(tx, [{ kind: \"create_file\", path: \"c.txt\", content: \"c\" }]);\n\t\tawait mgr.validate(tx, { id: \"t\", label: \"t\", run: () => Promise.resolve({ exitCode: 0, outputArtifact: \"\" }) });\n\t\tawait mgr.confirm(tx);\n\t\tconst reloaded = await mgr.read(tx.transactionId);\n\t\texpect(reloaded!.stage).toBe(\"confirmed\");\n\t\texpect(await mgr.classify(tx.transactionId)).toBe(\"already_confirmed\");\n\t});\n});\n\ndescribe(\"workspace safety manager integration\", () => {\n\tit(\"plan mode performs zero physical mutations\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\tconst safety = await WorkspaceSafety.create(ws, { storageDir: dir }, \"plan\");\n\t\tawait expect(\n\t\t\tsafety.performMutation({ edits: [{ kind: \"create_file\", path: \"x.txt\", content: \"x\" }] }),\n\t\t).rejects.toThrow(/approval|deny/i);\n\t\tconst exists = await fs.stat(nodePath.join(ws, \"x.txt\")).catch(() => null);\n\t\texpect(exists).toBeNull();\n\t});\n\n\tit(\"execute mode confirms an authorized transaction\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\tawait fs.writeFile(nodePath.join(ws, \"a.txt\"), \"old\");\n\t\tconst safety = await WorkspaceSafety.create(ws, { storageDir: dir }, \"execute\");\n\t\tconst out = await safety.performMutation({\n\t\t\tedits: [{ kind: \"replace_file\", path: \"a.txt\", content: \"new\" }],\n\t\t\tpolicy: { outcome: \"allow\", ruleId: \"t\", reasonCode: \"x\" },\n\t\t});\n\t\texpect(out.stage).toBe(\"confirmed\");\n\t\texpect(await fs.readFile(nodePath.join(ws, \"a.txt\"), \"utf-8\")).toBe(\"new\");\n\t\tconst cp = await safety.lastCheckpoint();\n\t\texpect(cp).not.toBeNull();\n\t});\n\n\tit(\"policy deny blocks before mutation and releases lease\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\tconst safety = await WorkspaceSafety.create(ws, { storageDir: dir }, \"execute\");\n\t\tawait expect(\n\t\t\tsafety.performMutation({\n\t\t\t\tedits: [{ kind: \"create_file\", path: \"secret/id_rsa\", content: \"x\" }],\n\t\t\t}),\n\t\t).rejects.toThrow(PolicyDeniedError);\n\t\texpect(await safety.leaseStatus()).toBeNull();\n\t});\n\n\tit(\"guardMutation enforces boundary\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\tconst safety = await WorkspaceSafety.create(ws, { storageDir: dir }, \"execute\");\n\t\tawait expect(\n\t\t\tsafety.guardMutation({\n\t\t\t\ttoolName: \"write\",\n\t\t\t\teffects: PRODUCTION_TOOL_EFFECTS.write,\n\t\t\t\tresolvedPaths: [\"../evil\"],\n\t\t\t}),\n\t\t).rejects.toThrow(WorkspaceBoundaryError);\n\t});\n\n\tit(\"wrapMutationTools rejects a denied call\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\tconst safety = await WorkspaceSafety.create(ws, { storageDir: dir }, \"execute\");\n\t\tconst tools = safety.wrapMutationTools(Object.values(createAllTools(ws)) as never);\n\t\tconst write = tools.find((t) => t.name === \"write\")!;\n\t\tawait expect(\n\t\t\twrite.execute(\"id1\", { path: \"../evil.txt\", content: \"x\" } as never, undefined as never, undefined as never),\n\t\t).rejects.toThrow(WorkspaceBoundaryError);\n\t});\n});\n\ndescribe(\"long-horizon integration\", () => {\n\tit(\"records replayable mutation lifecycle events and gates completion\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\tawait fs.writeFile(nodePath.join(ws, \"a.txt\"), \"old\");\n\t\tconst safety = await WorkspaceSafety.create(ws, { storageDir: dir }, \"execute\");\n\t\t// Gate: before any mutation, incomplete transactions block completion only when mutating.\n\t\texpect((await safety.gateStepCompletion(false)).canComplete).toBe(true);\n\t\tconst out = await safety.performMutation({\n\t\t\tedits: [{ kind: \"replace_file\", path: \"a.txt\", content: \"new\" }],\n\t\t\tpolicy: { outcome: \"allow\", ruleId: \"t\", reasonCode: \"x\" },\n\t\t});\n\t\texpect(out.stage).toBe(\"confirmed\");\n\t\tconst events = await safety.readEvents();\n\t\tconst kinds = events.map((e) => e.event);\n\t\texpect(kinds).toContain(\"MUTATION_POLICY_EVALUATED\");\n\t\texpect(kinds).toContain(\"WORKSPACE_LEASE_ACQUIRED\");\n\t\texpect(kinds).toContain(\"CHECKPOINT_CREATED\");\n\t\texpect(kinds).toContain(\"TRANSACTION_APPLIED\");\n\t\texpect(kinds).toContain(\"TRANSACTION_CONFIRMED\");\n\t\texpect(kinds).toContain(\"WORKSPACE_LEASE_RELEASED\");\n\t\texpect((await safety.gateStepCompletion(true)).canComplete).toBe(true);\n\t});\n\n\tit(\"incomplete mutation blocks step completion\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\tconst safety = await WorkspaceSafety.create(ws, { storageDir: dir }, \"execute\");\n\t\t// Simulate an unresolved transaction by starting one and leaving it applied.\n\t\tconst tx = await safety.transactions.begin(\"ws\", { mode: \"execute\", policy: null });\n\t\tconst paths = await safety.transactions.resolvePaths([{ kind: \"create_file\", path: \"x.txt\", content: \"x\" }]);\n\t\tawait safety.transactions.checkpoint(tx, paths);\n\t\tawait safety.transactions.apply(tx, [{ kind: \"create_file\", path: \"x.txt\", content: \"x\" }]);\n\t\tconst gate = await safety.gateStepCompletion(true);\n\t\texpect(gate.canComplete).toBe(false);\n\t\texpect(gate.blockingReason).toContain(tx.transactionId);\n\t});\n});\n\ndescribe(\"cross-platform path safety\", () => {\n\tit(\"normalizes separators and rejects absolute external paths regardless of platform\", async () => {\n\t\tconst dir = await tmpdir();\n\t\tconst ws = workspace(dir);\n\t\tawait fs.mkdir(ws, { recursive: true });\n\t\tconst boundary = await WorkspaceBoundary.create(ws);\n\t\t// Windows-style backslash traversal resolves and is contained\n\t\tconst inside = await boundary.resolveWithin(\"a\\\\b.txt\");\n\t\texpect(nodePath.isAbsolute(inside)).toBe(true);\n\t\t// UNC-ish path on POSIX is a normal path; ensure it can't point to an\n\t\t// external sibling.\n\t\tawait expect(boundary.resolveWithin(`${nodePath.join(dir, \"..\", \"elsewhere\")}\\\\x`)).rejects.toThrow(\n\t\t\tWorkspaceBoundaryError,\n\t\t);\n\t});\n\n\tit(\"platform-conditional: Windows path safety is only exercised where the primitive exists\", async () => {\n\t\t// Junctions/reparse points only exist on Windows. On POSIX we verify the\n\t\t// symlink-escape path (already covered) and assert the platform gate.\n\t\tif (process.platform !== \"win32\") {\n\t\t\texpect(typeof WorkspaceBoundary).toBe(\"function\");\n\t\t\treturn;\n\t\t}\n\t\texpect(typeof WorkspaceBoundary).toBe(\"function\");\n\t});\n});\n"]}