feat(coding-agent): improve project trust approvals

This commit is contained in:
Armin Ronacher
2026-06-08 20:36:41 +02:00
Unverified
parent 2edd6b432a
commit ee36576d09
17 changed files with 374 additions and 80 deletions
@@ -102,6 +102,25 @@ describe("package commands", () => {
}
});
it("uses projectTrust always for list", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ projectTrust: "always" }));
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("Project packages:");
expect(stdout).toContain("npm:@project/pkg");
expect(stdout).not.toContain("No packages installed.");
expect(process.exitCode).toBeUndefined();
} 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"] }));
@@ -215,6 +215,29 @@ describe("SettingsManager", () => {
});
describe("project trust", () => {
it("should persist the default project trust setting globally", async () => {
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getProjectTrustSetting()).toBe("ask");
manager.setProjectTrustSetting("always");
await manager.flush();
expect(manager.getProjectTrustSetting()).toBe("always");
expect(JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8"))).toMatchObject({
projectTrust: "always",
});
});
it("should not let project settings control the default project trust setting", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ projectTrust: "always" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ projectTrust: "never" }));
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getProjectTrustSetting()).toBe("always");
});
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" }));
@@ -21,7 +21,10 @@ function createTempDir(): string {
return dir;
}
async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; code: number | null }> {
async function runCli(
args: string[],
globalSettings?: Record<string, unknown>,
): Promise<{ stdout: string; stderr: string; code: number | null }> {
const tempRoot = createTempDir();
const agentDir = join(tempRoot, "agent");
const projectDir = join(tempRoot, "project");
@@ -52,6 +55,9 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string;
),
"utf-8",
);
if (globalSettings) {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify(globalSettings, null, 2), "utf-8");
}
return await new Promise((resolvePromise, reject) => {
const child = spawn(process.execPath, [cliPath, ...args], {
@@ -109,4 +115,14 @@ describe("stdout cleanliness in non-interactive modes", () => {
expect(result.stderr).not.toContain("found 0 vulnerabilities");
expect(result.stderr).toContain("Usage:");
});
it("uses projectTrust always as the default project trust decision", async () => {
const result = await runCli(["-p", "--help"], { projectTrust: "always" });
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:");
});
});
@@ -2,7 +2,12 @@ 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 { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../src/core/trust-manager.ts";
import {
getProjectTrustPath,
hasProjectConfigDir,
hasProjectTrustInputs,
ProjectTrustStore,
} from "../src/core/trust-manager.ts";
describe("ProjectTrustStore", () => {
let tempDir: string;
@@ -25,12 +30,52 @@ describe("ProjectTrustStore", () => {
const store = new ProjectTrustStore(agentDir);
expect(store.get(cwd)).toBeNull();
expect(store.getEntry(cwd)).toBeNull();
store.set(cwd, true);
expect(store.get(cwd)).toBe(true);
expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: true });
store.set(cwd, false);
expect(store.get(cwd)).toBe(false);
expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: false });
store.set(cwd, null);
expect(store.get(cwd)).toBeNull();
expect(store.getEntry(cwd)).toBeNull();
});
it("inherits the closest saved decision from parent directories", () => {
const store = new ProjectTrustStore(agentDir);
const parentDir = join(tempDir, "trusted-parent");
const childDir = join(parentDir, "project");
const grandchildDir = join(childDir, "nested");
mkdirSync(grandchildDir, { recursive: true });
store.set(parentDir, true);
expect(store.get(childDir)).toBe(true);
expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true });
expect(store.get(grandchildDir)).toBe(true);
expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true });
store.set(childDir, false);
expect(store.get(grandchildDir)).toBe(false);
expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false });
});
it("can clear a child override to inherit parent trust", () => {
const store = new ProjectTrustStore(agentDir);
const parentDir = join(tempDir, "trusted-parent");
const childDir = join(parentDir, "project");
mkdirSync(childDir, { recursive: true });
store.set(parentDir, true);
store.set(childDir, false);
expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false });
store.setMany([
{ cwd: parentDir, decision: true },
{ cwd: childDir, decision: null },
]);
expect(store.get(childDir)).toBe(true);
expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true });
});
it("fails loudly without overwriting malformed trust stores", () => {
@@ -17,7 +17,7 @@ describe("TrustSelectorComponent", () => {
it("marks the saved trusted decision", () => {
const selector = new TrustSelectorComponent({
cwd: "/project",
savedDecision: true,
savedDecision: { path: "/project", decision: true },
projectTrusted: true,
onSelect: () => {},
onCancel: () => {},
@@ -25,7 +25,7 @@ describe("TrustSelectorComponent", () => {
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("Saved decision: trusted");
expect(output).toContain("Saved decision: trusted (/project)");
expect(output).toContain("Current session: trusted");
expect(output).toContain("Trust ✓");
expect(output).not.toContain("Do not trust ✓");
@@ -43,6 +43,30 @@ describe("TrustSelectorComponent", () => {
selector.handleInput("\n");
expect(onSelect).toHaveBeenCalledWith(true);
expect(onSelect).toHaveBeenCalledWith({ trusted: true, updates: [{ cwd: "/project", decision: true }] });
});
it("adds a trust parent option", () => {
const onSelect = vi.fn();
const selector = new TrustSelectorComponent({
cwd: "/parent/project",
savedDecision: { path: "/parent", decision: true },
projectTrusted: true,
onSelect,
onCancel: () => {},
});
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("Trust parent folder (/parent) ✓");
selector.handleInput("\n");
expect(onSelect).toHaveBeenCalledWith({
trusted: true,
updates: [
{ cwd: "/parent", decision: true },
{ cwd: "/parent/project", decision: null },
],
});
});
});