feat(ui): Improved project approval settings
This commit is contained in:
@@ -157,6 +157,70 @@ describe("package commands", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses default project trust for list", async () => {
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "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 project_trust extensions for package commands", 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"], {
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("project_trust", () => ({ trusted: "yes" }));
|
||||
},
|
||||
],
|
||||
}),
|
||||
).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("lets trust.json override default project trust", async () => {
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
|
||||
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
|
||||
new ProjectTrustStore(agentDir).set(projectDir, false);
|
||||
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:");
|
||||
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(() => {});
|
||||
|
||||
@@ -376,7 +376,7 @@ Content`,
|
||||
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant.");
|
||||
});
|
||||
|
||||
it("should skip project resources when project is not trusted", async () => {
|
||||
it("should skip trust-gated 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");
|
||||
@@ -414,7 +414,7 @@ Project skill content`,
|
||||
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.getAgentsFiles().agentsFiles.some((file) => file.path === join(cwd, "AGENTS.md"))).toBe(true);
|
||||
expect(loader.getExtensions().extensions).toHaveLength(0);
|
||||
expect(loader.getExtensions().errors).toEqual([]);
|
||||
expect(loader.getSkills().skills.some((skill) => skill.name === "project-skill")).toBe(false);
|
||||
|
||||
@@ -250,6 +250,23 @@ describe("SettingsManager", () => {
|
||||
expect(manager.getProjectSettings()).toEqual({});
|
||||
expect(JSON.parse(readFileSync(projectSettingsPath, "utf-8"))).toEqual({ packages: ["npm:existing"] });
|
||||
});
|
||||
|
||||
it("should read default project trust from global settings only", () => {
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
|
||||
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ defaultProjectTrust: "never" }));
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
expect(manager.getDefaultProjectTrust()).toBe("always");
|
||||
});
|
||||
|
||||
it("should default invalid project trust settings to ask", () => {
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "sometimes" }));
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
expect(manager.getDefaultProjectTrust()).toBe("ask");
|
||||
});
|
||||
});
|
||||
|
||||
describe("project settings directory creation", () => {
|
||||
|
||||
@@ -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([
|
||||
{ path: parentDir, decision: true },
|
||||
{ path: 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", () => {
|
||||
@@ -53,9 +98,13 @@ describe("ProjectTrustStore", () => {
|
||||
rmSync(join(cwd, ".pi"), { recursive: true, force: true });
|
||||
|
||||
writeFileSync(join(cwd, "AGENTS.md"), "Project instructions");
|
||||
expect(hasProjectTrustInputs(cwd)).toBe(true);
|
||||
expect(hasProjectTrustInputs(cwd)).toBe(false);
|
||||
rmSync(join(cwd, "AGENTS.md"), { force: true });
|
||||
|
||||
writeFileSync(join(cwd, "CLAUDE.md"), "Legacy project instructions");
|
||||
expect(hasProjectTrustInputs(cwd)).toBe(false);
|
||||
rmSync(join(cwd, "CLAUDE.md"), { force: true });
|
||||
|
||||
mkdirSync(join(cwd, ".agents", "skills"), { recursive: true });
|
||||
expect(hasProjectTrustInputs(cwd)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -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,45 @@ describe("TrustSelectorComponent", () => {
|
||||
|
||||
selector.handleInput("\n");
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(true);
|
||||
expect(onSelect).toHaveBeenCalledWith({ trusted: true, updates: [{ path: "/project", decision: true }] });
|
||||
});
|
||||
|
||||
it("labels saved ancestor decisions as inherited", () => {
|
||||
const selector = new TrustSelectorComponent({
|
||||
cwd: "/parent/project/nested",
|
||||
savedDecision: { path: "/parent", decision: true },
|
||||
projectTrusted: true,
|
||||
onSelect: () => {},
|
||||
onCancel: () => {},
|
||||
});
|
||||
|
||||
const output = stripAnsi(selector.render(120).join("\n"));
|
||||
|
||||
expect(output).toContain("Saved decision: trusted (inherited from /parent)");
|
||||
});
|
||||
|
||||
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("Saved decision: trusted (inherited from /parent)");
|
||||
expect(output).toContain("Trust parent folder (/parent) ✓");
|
||||
|
||||
selector.handleInput("\n");
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
trusted: true,
|
||||
updates: [
|
||||
{ path: "/parent", decision: true },
|
||||
{ path: "/parent/project", decision: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user