feat(coding-agent): add project trust gating
This commit is contained in:
@@ -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"]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
|
||||
60
packages/coding-agent/test/trust-manager.test.ts
Normal file
60
packages/coding-agent/test/trust-manager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
48
packages/coding-agent/test/trust-selector.test.ts
Normal file
48
packages/coding-agent/test/trust-selector.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user