Skip to content

Commit 5f0141d

Browse files
committed
feat: enforce workspace guard to prevent operations from project root
- Added `workspaceGuard.ts` to manage project root checks. - Implemented `assertNotProjectRootWorkspace` to throw errors if operations are attempted from the project root. - Updated various modules to include `workspaceRoot` in delegation trace entries. - Modified `defaultTraceDir` to assert that it is not called from the project root. - Added tests to ensure that operations from the project root are correctly blocked.
1 parent 75e709a commit 5f0141d

11 files changed

Lines changed: 89 additions & 638 deletions

ISSUES.md

Lines changed: 4 additions & 622 deletions
Large diffs are not rendered by default.

src/governance/delegationContract.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,13 @@ export async function persistDelegationContract(
7777
}
7878

7979
export function appendDelegationTrace(entry: {
80+
workspaceRoot: string;
8081
runId: string;
8182
node: string | null;
8283
detail: string;
8384
decision: "allow_with_trace" | "require_review" | "hard_stop";
8485
}): void {
86+
const traceDir = path.join(entry.workspaceRoot, ".autolabos", "governance", "traces");
8587
appendGovernanceTrace({
8688
timestamp: new Date().toISOString(),
8789
runId: entry.runId,
@@ -92,7 +94,7 @@ export function appendDelegationTrace(entry: {
9294
decision: entry.decision,
9395
matchedSlotId: null,
9496
detail: entry.detail
95-
});
97+
}, traceDir);
9698
}
9799

98100
export async function prepareDelegationContractForRun(options: {
@@ -110,6 +112,7 @@ export async function prepareDelegationContractForRun(options: {
110112
const validation = validateDelegationContract(contract, policy);
111113
if (!validation.valid) {
112114
appendDelegationTrace({
115+
workspaceRoot: options.workspaceRoot,
113116
runId: options.runId,
114117
node: options.node,
115118
decision: "require_review",
@@ -123,6 +126,7 @@ export async function prepareDelegationContractForRun(options: {
123126

124127
const targetPath = await persistDelegationContract(options.workspaceRoot, options.runId, contract);
125128
appendDelegationTrace({
129+
workspaceRoot: options.workspaceRoot,
126130
runId: options.runId,
127131
node: options.node,
128132
decision: "allow_with_trace",

src/governance/governanceTrace.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
22
import path from "node:path";
33

44
import type { EvidenceScreeningResult, GovernanceDecision } from "./policyTypes.js";
5+
import { assertNotProjectRootWorkspace } from "../workspaceGuard.js";
56

67
export interface GovernanceTraceEntry {
78
timestamp: string;
@@ -16,7 +17,9 @@ export interface GovernanceTraceEntry {
1617
}
1718

1819
function defaultTraceDir(): string {
19-
return path.join(process.cwd(), ".autolabos", "governance", "traces");
20+
const cwd = process.cwd();
21+
assertNotProjectRootWorkspace(cwd, "Governance trace logging");
22+
return path.join(cwd, ".autolabos", "governance", "traces");
2023
}
2124

2225
export function appendGovernanceTrace(

src/interaction/InteractionSession.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1774,6 +1774,7 @@ export class InteractionSession {
17741774
const controller = new AutonomousRunController(this.runStore, this.orchestrator, this.eventStream);
17751775
const outcome = await controller.runOvernight(run.id, buildDefaultOvernightPolicy(), { abortSignal });
17761776
appendDelegationTrace({
1777+
workspaceRoot: this.workspaceRoot,
17771778
runId: run.id,
17781779
node: run.currentNode,
17791780
decision: "allow_with_trace",
@@ -1818,6 +1819,7 @@ export class InteractionSession {
18181819
const policy = buildDefaultAutonomousPolicy();
18191820
const outcome = await controller.runAutonomous(run.id, policy, { abortSignal });
18201821
appendDelegationTrace({
1822+
workspaceRoot: this.workspaceRoot,
18211823
runId: run.id,
18221824
node: run.currentNode,
18231825
decision: "allow_with_trace",

src/runtime/createRuntime.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { recoverCollectEnrichmentJobs } from "../core/nodes/collectPapers.js";
3434
import { detectExecutionProfile } from "./executionProfile.js";
3535
import { resolveNodeOptionsForPackage } from "../core/stateGraph/defaults.js";
3636
import { loadExplorationConfig } from "../core/exploration/explorationConfig.js";
37+
import { assertNotProjectRootWorkspace } from "../workspaceGuard.js";
3738

3839
export interface AutoLabOSRuntime {
3940
paths: AppPaths;
@@ -65,7 +66,9 @@ export async function bootstrapAutoLabOSRuntime(opts?: {
6566
allowInteractiveSetup?: boolean;
6667
nodeOptionPackageName?: NodeOptionPackageName;
6768
}): Promise<RuntimeBootstrap> {
68-
const paths = resolveAppPaths(opts?.cwd || process.cwd());
69+
const cwd = opts?.cwd || process.cwd();
70+
assertNotProjectRootWorkspace(cwd);
71+
const paths = resolveAppPaths(cwd);
6972
const executionProfile = await detectExecutionProfile();
7073
const firstRunSetup = !(await configExists(paths));
7174

src/tui/TerminalApp.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3535,6 +3535,7 @@ export class TerminalApp {
35353535
const controller = new AutonomousRunController(this.runStore, this.orchestrator, this.eventStream);
35363536
const outcome = await controller.runOvernight(run.id, buildDefaultOvernightPolicy(), { abortSignal });
35373537
appendDelegationTrace({
3538+
workspaceRoot: process.cwd(),
35383539
runId: run.id,
35393540
node: run.currentNode,
35403541
decision: "allow_with_trace",
@@ -3589,6 +3590,7 @@ export class TerminalApp {
35893590
const policy = buildDefaultAutonomousPolicy();
35903591
const outcome = await controller.runAutonomous(run.id, policy, { abortSignal });
35913592
appendDelegationTrace({
3593+
workspaceRoot: process.cwd(),
35923594
runId: run.id,
35933595
node: run.currentNode,
35943596
decision: "allow_with_trace",

src/workspaceGuard.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import path from "node:path";
2+
import { fileURLToPath } from "node:url";
3+
4+
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
5+
6+
export function getProjectRoot(): string {
7+
return PROJECT_ROOT;
8+
}
9+
10+
export function isProjectRootWorkspace(cwd: string): boolean {
11+
return path.resolve(cwd) === PROJECT_ROOT;
12+
}
13+
14+
export function assertNotProjectRootWorkspace(cwd: string, operation = "AutoLabOS"): void {
15+
if (!isProjectRootWorkspace(cwd)) {
16+
return;
17+
}
18+
throw new Error(
19+
`${operation} must not run from the repository root (${PROJECT_ROOT}). Use test/ or another workspace directory instead.`
20+
);
21+
}

tests/governanceTrace.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@ import { mkdtempSync } from "node:fs";
22
import os from "node:os";
33
import path from "node:path";
44

5-
import { describe, expect, it } from "vitest";
5+
import { afterEach, describe, expect, it } from "vitest";
66

77
import { appendGovernanceTrace, readGovernanceTrace } from "../src/governance/governanceTrace.js";
8+
import { getProjectRoot } from "../src/workspaceGuard.js";
9+
10+
const ORIGINAL_CWD = process.cwd();
11+
12+
afterEach(() => {
13+
process.chdir(ORIGINAL_CWD);
14+
});
815

916
describe("governance trace", () => {
1017
it("appends and reads a trace entry", () => {
@@ -66,4 +73,22 @@ describe("governance trace", () => {
6673
expect(entries[0].inputSummary).toBe("one");
6774
expect(entries[1].inputSummary).toBe("two");
6875
});
76+
77+
it("refuses to use the project root as the default trace directory", () => {
78+
process.chdir(getProjectRoot());
79+
80+
expect(() =>
81+
appendGovernanceTrace({
82+
timestamp: "2026-04-03T00:00:00.000Z",
83+
runId: "run-root",
84+
node: "review",
85+
inputSummary: "root",
86+
screeningResult: null,
87+
triggeredRules: [],
88+
decision: "allow_with_trace",
89+
matchedSlotId: null,
90+
detail: "should not write at project root"
91+
})
92+
).toThrow("must not run from the repository root");
93+
});
6994
});

tests/paperSelection.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ describe("paperSelection", () => {
261261
const selection = await selectPapersForAnalysis({
262262
llm: new SequenceResponseLlm([
263263
new Error(
264-
'2026-03-12T08:56:03.104783Z WARN codex_core::shell_snapshot: Failed to delete shell snapshot at "/Users/hanyonglee/.codex/shell_snapshots/tmp": Os { code: 2, kind: NotFound, message: "No such file or directory" }'
264+
'2026-03-12T08:56:03.104783Z WARN codex_core::shell_snapshot: Failed to delete shell snapshot at "<home>/.codex/shell_snapshots/tmp": Os { code: 2, kind: NotFound, message: "No such file or directory" }'
265265
),
266266
'{"ordered_paper_ids":["p2","p1"]}'
267267
]),
@@ -356,7 +356,7 @@ describe("paperSelection", () => {
356356
const selection = await selectPapersForAnalysis({
357357
llm: new SequenceResponseLlm([
358358
new Error(
359-
'2026-03-12T08:56:03.104783Z WARN codex_core::shell_snapshot: Failed to delete shell snapshot at "/Users/hanyonglee/.codex/shell_snapshots/tmp": Os { code: 2, kind: NotFound, message: "No such file or directory" }\n' +
359+
'2026-03-12T08:56:03.104783Z WARN codex_core::shell_snapshot: Failed to delete shell snapshot at "<home>/.codex/shell_snapshots/tmp": Os { code: 2, kind: NotFound, message: "No such file or directory" }\n' +
360360
"2026-03-12T08:56:03.586264Z WARN codex_core::codex: startup websocket prewarm setup failed: You've hit your usage limit for GPT-5.3-Codex-Spark. Switch to another model now, or try again at 8:24 PM."
361361
)
362362
]),

tests/runtimeBootstrap.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { bootstrapAutoLabOSRuntime } from "../src/runtime/createRuntime.js";
4+
import { getProjectRoot } from "../src/workspaceGuard.js";
5+
6+
describe("runtime bootstrap workspace guard", () => {
7+
it("refuses to bootstrap from the repository root", async () => {
8+
await expect(
9+
bootstrapAutoLabOSRuntime({
10+
cwd: getProjectRoot(),
11+
allowInteractiveSetup: false
12+
})
13+
).rejects.toThrow("must not run from the repository root");
14+
});
15+
});

0 commit comments

Comments
 (0)