feat(coding-agent): add project trust gating

This commit is contained in:
Mario Zechner
2026-06-05 10:51:20 +02:00
parent db594d3a59
commit 89a92207f1
28 changed files with 1029 additions and 112 deletions

View File

@@ -293,6 +293,28 @@ describe("parseArgs", () => {
});
});
describe("project approval flags", () => {
test("parses --approve", () => {
const result = parseArgs(["--approve"]);
expect(result.projectTrustOverride).toBe(true);
});
test("parses -a shorthand", () => {
const result = parseArgs(["-a"]);
expect(result.projectTrustOverride).toBe(true);
});
test("parses --no-approve", () => {
const result = parseArgs(["--no-approve"]);
expect(result.projectTrustOverride).toBe(false);
});
test("parses -na shorthand", () => {
const result = parseArgs(["-na"]);
expect(result.projectTrustOverride).toBe(false);
});
});
describe("--verbose flag", () => {
test("parses --verbose flag", () => {
const result = parseArgs(["--verbose"]);

View File

@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts";
import { ProjectTrustStore } from "../src/core/trust-manager.ts";
import { main } from "../src/main.ts";
describe("package commands", () => {
@@ -85,6 +86,103 @@ describe("package commands", () => {
expect(removedSettings.packages ?? []).toHaveLength(0);
});
it("skips untrusted project package settings", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("No packages installed.");
expect(stdout).not.toContain("Project packages:");
} finally {
logSpy.mockRestore();
}
});
it("uses remembered project trust for list", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
new ProjectTrustStore(agentDir).set(projectDir, true);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("Project packages:");
expect(stdout).toContain("npm:@project/pkg");
expect(stdout).not.toContain("No packages installed.");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("overrides remembered trust for list with --no-approve", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
new ProjectTrustStore(agentDir).set(projectDir, true);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list", "--no-approve"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("No packages installed.");
expect(stdout).not.toContain("Project packages:");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("approves project trust for list with --approve", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list", "--approve"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("Project packages:");
expect(stdout).toContain("npm:@project/pkg");
expect(stdout).not.toContain("No packages installed.");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("blocks local package changes when project is untrusted", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await expect(main(["install", "-l", "./local-package"])).resolves.toBeUndefined();
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stderr).toContain("Project is not trusted. Use --approve to modify local package config.");
expect(process.exitCode).toBe(1);
} finally {
errorSpy.mockRestore();
}
});
it("allows local package install to initialize fresh project settings", async () => {
await main(["install", "-l", packageDir]);
const settingsPath = join(projectDir, ".pi", "settings.json");
const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] };
expect(settings.packages?.length).toBe(1);
const stored = settings.packages?.[0] ?? "";
expect(realpathSync(join(projectDir, ".pi", stored))).toBe(realpathSync(packageDir));
expect(process.exitCode).toBeUndefined();
});
it("shows install subcommand help", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -111,7 +209,7 @@ describe("package commands", () => {
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stderr).toContain('Unknown option --unknown for "install".');
expect(stderr).toContain('Use "pi --help" or "pi install <source> [-l]".');
expect(stderr).toContain('Use "pi --help" or "pi install <source> [-l] [--approve|--no-approve]".');
expect(process.exitCode).toBe(1);
} finally {
errorSpy.mockRestore();

View File

@@ -329,6 +329,52 @@ Content`,
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant.");
});
it("should skip project resources when project is not trusted", async () => {
const piDir = join(cwd, ".pi");
const extensionsDir = join(piDir, "extensions");
const skillDir = join(piDir, "skills", "project-skill");
const promptsDir = join(piDir, "prompts");
const themesDir = join(piDir, "themes");
mkdirSync(extensionsDir, { recursive: true });
mkdirSync(skillDir, { recursive: true });
mkdirSync(promptsDir, { recursive: true });
mkdirSync(themesDir, { recursive: true });
writeFileSync(join(piDir, "SYSTEM.md"), "Project system prompt.");
writeFileSync(join(agentDir, "SYSTEM.md"), "Global system prompt.");
writeFileSync(join(agentDir, "AGENTS.md"), "Global instructions");
writeFileSync(join(cwd, "AGENTS.md"), "Project instructions");
writeFileSync(join(extensionsDir, "project.ts"), `throw new Error("should not load");`);
writeFileSync(
join(skillDir, "SKILL.md"),
`---
name: project-skill
description: Project skill
---
Project skill content`,
);
writeFileSync(join(promptsDir, "project.md"), "Project prompt");
const themeData = JSON.parse(
readFileSync(join(process.cwd(), "src", "modes", "interactive", "theme", "dark.json"), "utf-8"),
) as { name: string };
themeData.name = "project-theme";
writeFileSync(join(themesDir, "project.json"), JSON.stringify(themeData, null, 2));
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await loader.reload();
expect(loader.getSystemPrompt()).toBe("Global system prompt.");
expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(agentDir, "AGENTS.md"))).toBe(
true,
);
expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(cwd, "AGENTS.md"))).toBe(false);
expect(loader.getExtensions().extensions).toHaveLength(0);
expect(loader.getExtensions().errors).toEqual([]);
expect(loader.getSkills().skills.some((skill) => skill.name === "project-skill")).toBe(false);
expect(loader.getPrompts().prompts.some((prompt) => prompt.name === "project")).toBe(false);
expect(loader.getThemes().themes.some((theme) => theme.name === "project-theme")).toBe(false);
});
it("should discover APPEND_SYSTEM.md", async () => {
const piDir = join(cwd, ".pi");
mkdirSync(piDir, { recursive: true });

View File

@@ -214,6 +214,44 @@ describe("SettingsManager", () => {
});
});
describe("project trust", () => {
it("should skip project settings when project is not trusted", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" }));
const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false });
expect(manager.isProjectTrusted()).toBe(false);
expect(manager.getTheme()).toBe("global");
expect(manager.getProjectSettings()).toEqual({});
});
it("should reload project settings after trust changes to true", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" }));
const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false });
manager.setProjectTrusted(true);
expect(manager.isProjectTrusted()).toBe(true);
expect(manager.getTheme()).toBe("project");
});
it("should fail project settings writes when project is not trusted", async () => {
const projectSettingsPath = join(projectDir, ".pi", "settings.json");
writeFileSync(projectSettingsPath, JSON.stringify({ packages: ["npm:existing"] }));
const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false });
expect(() => manager.setProjectPackages(["npm:new"])).toThrow(
"Project is not trusted; refusing to write project settings",
);
await manager.flush();
expect(manager.getProjectSettings()).toEqual({});
expect(JSON.parse(readFileSync(projectSettingsPath, "utf-8"))).toEqual({ packages: ["npm:existing"] });
});
});
describe("project settings directory creation", () => {
it("should not create .pi folder when only reading project settings", () => {
// Create agent dir with global settings, but NO .pi folder in project

View File

@@ -80,8 +80,8 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string;
}
describe("stdout cleanliness in non-interactive modes", () => {
it("keeps stdout empty for --mode json --help while routing startup chatter to stderr", async () => {
const result = await runCli(["--mode", "json", "--help"]);
it("keeps stdout empty for --mode json --help while routing trusted startup chatter to stderr", async () => {
const result = await runCli(["--mode", "json", "--help", "--approve"]);
expect(result.code).toBe(0);
expect(result.stdout).toBe("");
@@ -90,13 +90,23 @@ describe("stdout cleanliness in non-interactive modes", () => {
expect(result.stderr).toContain("Usage:");
});
it("keeps stdout empty for -p --help while routing startup chatter to stderr", async () => {
it("keeps stdout empty for -p --help while routing trusted startup chatter to stderr", async () => {
const result = await runCli(["-p", "--help", "--approve"]);
expect(result.code).toBe(0);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("changed 1 package in 471ms");
expect(result.stderr).toContain("found 0 vulnerabilities");
expect(result.stderr).toContain("Usage:");
});
it("ignores untrusted project package installs for help", async () => {
const result = await runCli(["-p", "--help"]);
expect(result.code).toBe(0);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("changed 1 package in 471ms");
expect(result.stderr).toContain("found 0 vulnerabilities");
expect(result.stderr).not.toContain("changed 1 package in 471ms");
expect(result.stderr).not.toContain("found 0 vulnerabilities");
expect(result.stderr).toContain("Usage:");
});
});

View File

@@ -0,0 +1,60 @@
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { hasProjectTrustInputs, ProjectTrustStore } from "../src/core/trust-manager.ts";
describe("ProjectTrustStore", () => {
let tempDir: string;
let agentDir: string;
let cwd: string;
beforeEach(() => {
tempDir = join(tmpdir(), `trust-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
agentDir = join(tempDir, "agent");
cwd = join(tempDir, "project");
mkdirSync(agentDir, { recursive: true });
mkdirSync(cwd, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("stores decisions per cwd", () => {
const store = new ProjectTrustStore(agentDir);
expect(store.get(cwd)).toBeNull();
store.set(cwd, true);
expect(store.get(cwd)).toBe(true);
store.set(cwd, false);
expect(store.get(cwd)).toBe(false);
store.set(cwd, null);
expect(store.get(cwd)).toBeNull();
});
it("fails loudly without overwriting malformed trust stores", () => {
const trustPath = join(agentDir, "trust.json");
writeFileSync(trustPath, "{not json", "utf-8");
const store = new ProjectTrustStore(agentDir);
expect(() => store.get(cwd)).toThrow(/Failed to read trust store/);
expect(() => store.set(cwd, true)).toThrow(/Failed to read trust store/);
expect(readFileSync(trustPath, "utf-8")).toBe("{not json");
});
it("detects project trust inputs", () => {
expect(hasProjectTrustInputs(cwd)).toBe(false);
mkdirSync(join(cwd, ".pi"), { recursive: true });
expect(hasProjectTrustInputs(cwd)).toBe(true);
rmSync(join(cwd, ".pi"), { recursive: true, force: true });
writeFileSync(join(cwd, "AGENTS.md"), "Project instructions");
expect(hasProjectTrustInputs(cwd)).toBe(true);
rmSync(join(cwd, "AGENTS.md"), { force: true });
mkdirSync(join(cwd, ".agents", "skills"), { recursive: true });
expect(hasProjectTrustInputs(cwd)).toBe(true);
});
});

View File

@@ -0,0 +1,48 @@
import { setKeybindings } from "@earendil-works/pi-tui";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { KeybindingsManager } from "../src/core/keybindings.ts";
import { TrustSelectorComponent } from "../src/modes/interactive/components/trust-selector.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
describe("TrustSelectorComponent", () => {
beforeAll(() => {
initTheme("dark");
});
beforeEach(() => {
setKeybindings(new KeybindingsManager());
});
it("marks the saved trusted decision", () => {
const selector = new TrustSelectorComponent({
cwd: "/project",
savedDecision: true,
projectTrusted: true,
onSelect: () => {},
onCancel: () => {},
});
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("Saved decision: trusted");
expect(output).toContain("Current session: trusted");
expect(output).toContain("Trust ✓");
expect(output).not.toContain("Do not trust ✓");
});
it("selects a trust decision", () => {
const onSelect = vi.fn();
const selector = new TrustSelectorComponent({
cwd: "/project",
savedDecision: null,
projectTrusted: false,
onSelect,
onCancel: () => {},
});
selector.handleInput("\n");
expect(onSelect).toHaveBeenCalledWith(true);
});
});