feat(coding-agent): add project trust gating
This commit is contained in:
@@ -27,6 +27,7 @@ export { ThemeSelectorComponent } from "./theme-selector.ts";
|
||||
export { ThinkingSelectorComponent } from "./thinking-selector.ts";
|
||||
export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.ts";
|
||||
export { TreeSelectorComponent } from "./tree-selector.ts";
|
||||
export { TrustSelectorComponent } from "./trust-selector.ts";
|
||||
export { UserMessageComponent } from "./user-message.ts";
|
||||
export { UserMessageSelectorComponent } from "./user-message-selector.ts";
|
||||
export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate.ts";
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui";
|
||||
import type { ProjectTrustDecision } from "../../../core/trust-manager.ts";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
import { DynamicBorder } from "./dynamic-border.ts";
|
||||
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
|
||||
|
||||
interface TrustOption {
|
||||
label: string;
|
||||
trusted: boolean;
|
||||
}
|
||||
|
||||
export interface TrustSelectorOptions {
|
||||
cwd: string;
|
||||
savedDecision: ProjectTrustDecision;
|
||||
projectTrusted: boolean;
|
||||
onSelect: (trusted: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const TRUST_OPTIONS: TrustOption[] = [
|
||||
{ label: "Trust", trusted: true },
|
||||
{ label: "Do not trust", trusted: false },
|
||||
];
|
||||
|
||||
function formatDecision(decision: ProjectTrustDecision): string {
|
||||
if (decision === true) {
|
||||
return "trusted";
|
||||
}
|
||||
if (decision === false) {
|
||||
return "untrusted";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
export class TrustSelectorComponent extends Container {
|
||||
private selectedIndex: number;
|
||||
private readonly listContainer: Container;
|
||||
private readonly savedDecision: ProjectTrustDecision;
|
||||
private readonly onSelectCallback: (trusted: boolean) => void;
|
||||
private readonly onCancelCallback: () => void;
|
||||
|
||||
constructor(options: TrustSelectorOptions) {
|
||||
super();
|
||||
|
||||
this.savedDecision = options.savedDecision;
|
||||
this.selectedIndex = Math.max(
|
||||
0,
|
||||
TRUST_OPTIONS.findIndex((option) => option.trusted === options.savedDecision),
|
||||
);
|
||||
this.onSelectCallback = options.onSelect;
|
||||
this.onCancelCallback = options.onCancel;
|
||||
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(theme.fg("accent", theme.bold("Project trust")), 1, 0));
|
||||
this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.savedDecision)}`), 1, 0));
|
||||
this.addChild(
|
||||
new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0),
|
||||
);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
this.listContainer = new Container();
|
||||
this.addChild(this.listContainer);
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(
|
||||
new Text(
|
||||
rawKeyHint("↑↓", "navigate") +
|
||||
" " +
|
||||
keyHint("tui.select.confirm", "save") +
|
||||
" " +
|
||||
keyHint("tui.select.cancel", "cancel"),
|
||||
1,
|
||||
0,
|
||||
),
|
||||
);
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
this.updateList();
|
||||
}
|
||||
|
||||
private updateList(): void {
|
||||
this.listContainer.clear();
|
||||
for (let i = 0; i < TRUST_OPTIONS.length; i++) {
|
||||
const option = TRUST_OPTIONS[i];
|
||||
if (!option) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isSelected = i === this.selectedIndex;
|
||||
const isCurrent = option.trusted === this.savedDecision;
|
||||
const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
|
||||
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
|
||||
const label = isSelected ? theme.fg("accent", option.label) : theme.fg("text", option.label);
|
||||
this.listContainer.addChild(new Text(`${prefix}${label}${checkmark}`, 1, 0));
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
const kb = getKeybindings();
|
||||
if (kb.matches(keyData, "tui.select.up") || keyData === "k") {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
this.updateList();
|
||||
} else if (kb.matches(keyData, "tui.select.down") || keyData === "j") {
|
||||
this.selectedIndex = Math.min(TRUST_OPTIONS.length - 1, this.selectedIndex + 1);
|
||||
this.updateList();
|
||||
} else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
|
||||
const selected = TRUST_OPTIONS[this.selectedIndex];
|
||||
if (selected) {
|
||||
this.onSelectCallback(selected.trusted);
|
||||
}
|
||||
} else if (kb.matches(keyData, "tui.select.cancel")) {
|
||||
this.onCancelCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
|
||||
import type { SourceInfo } from "../../core/source-info.ts";
|
||||
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
|
||||
import type { TruncationResult } from "../../core/tools/truncate.ts";
|
||||
import { hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.ts";
|
||||
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts";
|
||||
import { copyToClipboard } from "../../utils/clipboard.ts";
|
||||
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
|
||||
@@ -120,6 +121,7 @@ import { SettingsSelectorComponent } from "./components/settings-selector.ts";
|
||||
import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts";
|
||||
import { ToolExecutionComponent } from "./components/tool-execution.ts";
|
||||
import { TreeSelectorComponent } from "./components/tree-selector.ts";
|
||||
import { TrustSelectorComponent } from "./components/trust-selector.ts";
|
||||
import { UserMessageComponent } from "./components/user-message.ts";
|
||||
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
|
||||
import {
|
||||
@@ -2564,6 +2566,11 @@ export class InteractiveMode {
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/trust") {
|
||||
this.showTrustSelector();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/login") {
|
||||
this.showOAuthSelector("login");
|
||||
this.editor.setText("");
|
||||
@@ -3226,6 +3233,7 @@ export class InteractiveMode {
|
||||
updateFooter: true,
|
||||
populateHistory: true,
|
||||
});
|
||||
this.renderProjectTrustWarningIfNeeded();
|
||||
|
||||
// Show compaction info if session was compacted
|
||||
const allEntries = this.sessionManager.getEntries();
|
||||
@@ -3236,6 +3244,26 @@ export class InteractiveMode {
|
||||
}
|
||||
}
|
||||
|
||||
private renderProjectTrustWarningIfNeeded(): void {
|
||||
if (this.settingsManager.isProjectTrusted() || !hasProjectTrustInputs(this.sessionManager.getCwd())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.chatContainer.children.length > 0) {
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
this.chatContainer.addChild(
|
||||
new Text(
|
||||
theme.fg(
|
||||
"warning",
|
||||
"This project is not trusted. Project instructions (AGENTS.md/CLAUDE.md), .pi resources, and project packages are ignored. Use /trust to save a trust decision, then restart pi.",
|
||||
),
|
||||
1,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getUserInput(): Promise<string> {
|
||||
const queuedInput = this.pendingUserInputs.shift();
|
||||
if (queuedInput !== undefined) {
|
||||
@@ -4135,6 +4163,31 @@ export class InteractiveMode {
|
||||
}
|
||||
}
|
||||
|
||||
private showTrustSelector(): void {
|
||||
const cwd = this.sessionManager.getCwd();
|
||||
const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir);
|
||||
const savedDecision = trustStore.get(cwd);
|
||||
this.showSelector((done) => {
|
||||
const selector = new TrustSelectorComponent({
|
||||
cwd,
|
||||
savedDecision,
|
||||
projectTrusted: this.settingsManager.isProjectTrusted(),
|
||||
onSelect: (trusted) => {
|
||||
trustStore.set(cwd, trusted);
|
||||
done();
|
||||
this.showStatus(
|
||||
`Saved trust decision: ${trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`,
|
||||
);
|
||||
},
|
||||
onCancel: () => {
|
||||
done();
|
||||
this.ui.requestRender();
|
||||
},
|
||||
});
|
||||
return { component: selector, focus: selector };
|
||||
});
|
||||
}
|
||||
|
||||
private showModelSelector(initialSearchInput?: string): void {
|
||||
this.showSelector((done) => {
|
||||
const selector = new ModelSelectorComponent(
|
||||
|
||||
Reference in New Issue
Block a user