pi-auto-permissions 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +304 -0
- package/THIRD_PARTY_NOTICES.md +70 -0
- package/docs/implementation-plan.md +311 -0
- package/docs/invariants.md +555 -0
- package/package.json +59 -0
- package/src/canonical.ts +314 -0
- package/src/commands/index.ts +362 -0
- package/src/domain.ts +198 -0
- package/src/extension.ts +430 -0
- package/src/guardian/circuit-breaker.ts +102 -0
- package/src/guardian/index.ts +74 -0
- package/src/guardian/policy.ts +135 -0
- package/src/guardian/prompt.ts +379 -0
- package/src/guardian/reviewer.ts +599 -0
- package/src/guardian/types.ts +149 -0
- package/src/guardian/verdict.ts +211 -0
- package/src/pi/index.ts +13 -0
- package/src/pi/model-reviewer.ts +235 -0
- package/src/pi/transcript.ts +524 -0
- package/src/policy/dangerous-command.ts +312 -0
- package/src/policy/path-policy.ts +501 -0
- package/src/runtime/index.ts +1 -0
- package/src/runtime/permission-engine.ts +474 -0
- package/src/sandbox/config.ts +188 -0
- package/src/sandbox/controller.ts +354 -0
- package/src/sandbox/index.ts +26 -0
- package/src/sandbox/runtime.ts +28 -0
- package/src/sandbox/types.ts +125 -0
- package/src/state/config-store.ts +580 -0
- package/src/state/index.ts +2 -0
- package/src/tools/bash.ts +101 -0
- package/src/tools/gate.ts +74 -0
- package/src/tools/index.ts +2 -0
- package/vendor/openai-codex/NOTICE +6 -0
- package/vendor/openai-codex/README.md +17 -0
- package/vendor/openai-codex/guardian/policy.md +42 -0
- package/vendor/openai-codex/guardian/policy_template.md +58 -0
package/src/domain.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export const CONFIG_VERSION = 1 as const;
|
|
4
|
+
|
|
5
|
+
export const THINKING_LEVELS = [
|
|
6
|
+
"off",
|
|
7
|
+
"minimal",
|
|
8
|
+
"low",
|
|
9
|
+
"medium",
|
|
10
|
+
"high",
|
|
11
|
+
"xhigh",
|
|
12
|
+
"max",
|
|
13
|
+
] as const satisfies readonly ModelThinkingLevel[];
|
|
14
|
+
|
|
15
|
+
type MissingThinkingLevel = Exclude<ModelThinkingLevel, (typeof THINKING_LEVELS)[number]>;
|
|
16
|
+
const ALL_THINKING_LEVELS_ARE_LISTED: MissingThinkingLevel extends never ? true : never = true;
|
|
17
|
+
void ALL_THINKING_LEVELS_ARE_LISTED;
|
|
18
|
+
|
|
19
|
+
export type PermissionMode = "auto" | "unrestricted";
|
|
20
|
+
export type EnforcementBackend = "sandboxed" | "review-only" | "unavailable";
|
|
21
|
+
export type EffectiveMode = "disabled" | "unrestricted" | "unrestricted-unavailable" | "auto" | "fault";
|
|
22
|
+
export type AdmissionDisposition = "admit" | "review" | "deny";
|
|
23
|
+
|
|
24
|
+
export interface ReviewerSelection {
|
|
25
|
+
provider: string;
|
|
26
|
+
modelId: string;
|
|
27
|
+
thinkingLevel: ModelThinkingLevel;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GlobalConfig {
|
|
31
|
+
version: typeof CONFIG_VERSION;
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
reviewer: ReviewerSelection | null;
|
|
34
|
+
revision: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface HealthyGlobalState {
|
|
38
|
+
health: "missing" | "valid";
|
|
39
|
+
config: GlobalConfig;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FaultedGlobalState {
|
|
43
|
+
health: "fault";
|
|
44
|
+
error: string;
|
|
45
|
+
revisionHint?: number;
|
|
46
|
+
/** A fully validated config whose health is faulted only by revision metadata. */
|
|
47
|
+
recoverableConfig?: GlobalConfig;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type GlobalState = HealthyGlobalState | FaultedGlobalState;
|
|
51
|
+
|
|
52
|
+
export interface SessionState {
|
|
53
|
+
requestedMode: PermissionMode;
|
|
54
|
+
revision: number;
|
|
55
|
+
backend: EnforcementBackend | null;
|
|
56
|
+
alive: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SessionCheckpoint {
|
|
60
|
+
requestedMode: PermissionMode;
|
|
61
|
+
revision: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type SessionInitialization =
|
|
65
|
+
| { kind: "fresh" }
|
|
66
|
+
| { kind: "reload"; checkpoint?: SessionCheckpoint };
|
|
67
|
+
|
|
68
|
+
export interface ReviewBindingState {
|
|
69
|
+
globalRevision: number;
|
|
70
|
+
sessionRevision: number;
|
|
71
|
+
backend: EnforcementBackend;
|
|
72
|
+
sessionId: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const DEFAULT_GLOBAL_CONFIG: Readonly<GlobalConfig> = Object.freeze({
|
|
76
|
+
version: CONFIG_VERSION,
|
|
77
|
+
enabled: true,
|
|
78
|
+
reviewer: null,
|
|
79
|
+
revision: 0,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export function isModelThinkingLevel(value: unknown): value is ModelThinkingLevel {
|
|
83
|
+
return typeof value === "string" && (THINKING_LEVELS as readonly string[]).includes(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isPermissionMode(value: unknown): value is PermissionMode {
|
|
87
|
+
return value === "auto" || value === "unrestricted";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function reviewerSelectionsEqual(
|
|
91
|
+
left: ReviewerSelection | null,
|
|
92
|
+
right: ReviewerSelection | null,
|
|
93
|
+
): boolean {
|
|
94
|
+
if (left === null || right === null) return left === right;
|
|
95
|
+
return (
|
|
96
|
+
left.provider === right.provider &&
|
|
97
|
+
left.modelId === right.modelId &&
|
|
98
|
+
left.thinkingLevel === right.thinkingLevel
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function cloneReviewerSelection(selection: ReviewerSelection | null): ReviewerSelection | null {
|
|
103
|
+
return selection === null ? null : { ...selection };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function cloneGlobalConfig(config: Readonly<GlobalConfig>): GlobalConfig {
|
|
107
|
+
return {
|
|
108
|
+
version: CONFIG_VERSION,
|
|
109
|
+
enabled: config.enabled,
|
|
110
|
+
reviewer: cloneReviewerSelection(config.reviewer),
|
|
111
|
+
revision: config.revision,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createSessionState(initialization: SessionInitialization = { kind: "fresh" }): SessionState {
|
|
116
|
+
if (initialization.kind === "reload" && initialization.checkpoint !== undefined) {
|
|
117
|
+
assertRevision(initialization.checkpoint.revision, "session checkpoint revision");
|
|
118
|
+
if (!isPermissionMode(initialization.checkpoint.requestedMode)) {
|
|
119
|
+
throw new TypeError("session checkpoint requested mode is invalid");
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
requestedMode: initialization.checkpoint.requestedMode,
|
|
123
|
+
revision: nextRevision(initialization.checkpoint.revision),
|
|
124
|
+
backend: null,
|
|
125
|
+
alive: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
requestedMode: "auto",
|
|
131
|
+
revision: 0,
|
|
132
|
+
backend: null,
|
|
133
|
+
alive: true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function checkpointSession(state: Readonly<SessionState>): SessionCheckpoint {
|
|
138
|
+
return { requestedMode: state.requestedMode, revision: state.revision };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function setRequestedMode(state: Readonly<SessionState>, requestedMode: PermissionMode): SessionState {
|
|
142
|
+
if (!isPermissionMode(requestedMode)) throw new TypeError("requested mode is invalid");
|
|
143
|
+
return updateSessionState(state, { requestedMode });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function setSessionBackend(
|
|
147
|
+
state: Readonly<SessionState>,
|
|
148
|
+
backend: EnforcementBackend | null,
|
|
149
|
+
): SessionState {
|
|
150
|
+
if (backend !== null && backend !== "sandboxed" && backend !== "review-only" && backend !== "unavailable") {
|
|
151
|
+
throw new TypeError("session backend is invalid");
|
|
152
|
+
}
|
|
153
|
+
return updateSessionState(state, { backend });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function setSessionAlive(state: Readonly<SessionState>, alive: boolean): SessionState {
|
|
157
|
+
return updateSessionState(state, { alive });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function effectiveMode(global: Readonly<GlobalState>, session: Readonly<SessionState>): EffectiveMode {
|
|
161
|
+
if (global.health !== "fault" && global.config.enabled === false) return "disabled";
|
|
162
|
+
if (session.requestedMode === "unrestricted") return "unrestricted";
|
|
163
|
+
if (global.health === "fault") return "fault";
|
|
164
|
+
if (global.config.reviewer === null) return "unrestricted-unavailable";
|
|
165
|
+
return "auto";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function nextRevision(revision: number): number {
|
|
169
|
+
assertRevision(revision, "revision");
|
|
170
|
+
if (revision === Number.MAX_SAFE_INTEGER) throw new RangeError("revision is exhausted");
|
|
171
|
+
return revision + 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function assertRevision(value: unknown, label = "revision"): asserts value is number {
|
|
175
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
176
|
+
throw new TypeError(`${label} must be a non-negative safe integer`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function updateSessionState(
|
|
181
|
+
state: Readonly<SessionState>,
|
|
182
|
+
patch: Partial<Pick<SessionState, "requestedMode" | "backend" | "alive">>,
|
|
183
|
+
): SessionState {
|
|
184
|
+
const requestedMode = patch.requestedMode ?? state.requestedMode;
|
|
185
|
+
const backend = patch.backend === undefined ? state.backend : patch.backend;
|
|
186
|
+
const alive = patch.alive ?? state.alive;
|
|
187
|
+
|
|
188
|
+
if (requestedMode === state.requestedMode && backend === state.backend && alive === state.alive) {
|
|
189
|
+
return state as SessionState;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
requestedMode,
|
|
194
|
+
backend,
|
|
195
|
+
alive,
|
|
196
|
+
revision: nextRevision(state.revision),
|
|
197
|
+
};
|
|
198
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
createBashToolDefinition,
|
|
4
|
+
getAgentDir,
|
|
5
|
+
type BashOperations,
|
|
6
|
+
type ExtensionAPI,
|
|
7
|
+
type ExtensionCommandContext,
|
|
8
|
+
type ExtensionContext,
|
|
9
|
+
type SessionEntry,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import {
|
|
12
|
+
registerPermissionCommands,
|
|
13
|
+
type PermissionCommandSnapshot,
|
|
14
|
+
type PermissionCommandsHost,
|
|
15
|
+
} from "./commands/index.ts";
|
|
16
|
+
import {
|
|
17
|
+
assertRevision,
|
|
18
|
+
isPermissionMode,
|
|
19
|
+
type EnforcementBackend,
|
|
20
|
+
type SessionCheckpoint,
|
|
21
|
+
type SessionInitialization,
|
|
22
|
+
} from "./domain.ts";
|
|
23
|
+
import { GuardianReviewEngine, type GuardianTranscriptItem } from "./guardian/index.ts";
|
|
24
|
+
import { createPiGuardianModelCall, guardianTranscriptFromSession } from "./pi/index.ts";
|
|
25
|
+
import {
|
|
26
|
+
createDangerousCommandDetector,
|
|
27
|
+
type DangerousCommandDetector,
|
|
28
|
+
} from "./policy/dangerous-command.ts";
|
|
29
|
+
import { StaticPathPolicy } from "./policy/path-policy.ts";
|
|
30
|
+
import { PermissionEngine } from "./runtime/index.ts";
|
|
31
|
+
import {
|
|
32
|
+
createProductionSandboxController,
|
|
33
|
+
type SandboxController,
|
|
34
|
+
type SandboxStatus,
|
|
35
|
+
} from "./sandbox/index.ts";
|
|
36
|
+
import { GlobalConfigStore } from "./state/index.ts";
|
|
37
|
+
import {
|
|
38
|
+
registerGuardedBashTool,
|
|
39
|
+
registerPermissionToolGate,
|
|
40
|
+
type GuardedBashRuntime,
|
|
41
|
+
type PermissionToolGateRuntime,
|
|
42
|
+
} from "./tools/index.ts";
|
|
43
|
+
|
|
44
|
+
export const SESSION_CHECKPOINT_ENTRY = "pi-auto-permissions/session-v1";
|
|
45
|
+
export const PERMISSION_STATUS_KEY = "pi-auto-permissions";
|
|
46
|
+
export const GLOBAL_STATE_DIRECTORY = "pi-auto-permissions";
|
|
47
|
+
export const GLOBAL_STATE_FILE = "state.json";
|
|
48
|
+
|
|
49
|
+
type PathPolicyPort = Pick<StaticPathPolicy, "classify">;
|
|
50
|
+
|
|
51
|
+
export interface PermissionExtensionDependencies {
|
|
52
|
+
readonly getAgentDir: () => string;
|
|
53
|
+
readonly createConfigStore: (configPath: string) => GlobalConfigStore;
|
|
54
|
+
readonly createPathPolicy: (
|
|
55
|
+
cwd: string,
|
|
56
|
+
deniedRoots: readonly string[],
|
|
57
|
+
) => Promise<PathPolicyPort>;
|
|
58
|
+
readonly createDangerousCommandDetector: () => Promise<DangerousCommandDetector>;
|
|
59
|
+
readonly createSandbox: (options: {
|
|
60
|
+
cwd: string;
|
|
61
|
+
additionalDenyWrite: readonly string[];
|
|
62
|
+
}) => SandboxController;
|
|
63
|
+
readonly createGuardian: (ctx: ExtensionContext) => GuardianReviewEngine;
|
|
64
|
+
readonly transcript: (ctx: ExtensionContext) => readonly GuardianTranscriptItem[];
|
|
65
|
+
readonly createBashDefinition: (
|
|
66
|
+
cwd: string,
|
|
67
|
+
operations?: BashOperations,
|
|
68
|
+
) => ReturnType<typeof createBashToolDefinition>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DEFAULT_DEPENDENCIES: PermissionExtensionDependencies = {
|
|
72
|
+
getAgentDir,
|
|
73
|
+
createConfigStore: (configPath) => new GlobalConfigStore({ configPath }),
|
|
74
|
+
createPathPolicy: (cwd, deniedRoots) =>
|
|
75
|
+
StaticPathPolicy.create({ cwd, workspaceRoots: [cwd], deniedRoots }),
|
|
76
|
+
createDangerousCommandDetector,
|
|
77
|
+
createSandbox: (options) => createProductionSandboxController(options),
|
|
78
|
+
createGuardian: (ctx) =>
|
|
79
|
+
new GuardianReviewEngine({ callModel: createPiGuardianModelCall(ctx.modelRegistry) }),
|
|
80
|
+
transcript: (ctx) => guardianTranscriptFromSession(ctx.sessionManager),
|
|
81
|
+
createBashDefinition: (cwd, operations) =>
|
|
82
|
+
createBashToolDefinition(cwd, operations === undefined ? {} : { operations }),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Factory exported so the E2E suite can inject deterministic OS/model ports. */
|
|
86
|
+
export function createPermissionExtension(
|
|
87
|
+
dependencyOverrides: Partial<PermissionExtensionDependencies> = {},
|
|
88
|
+
): (pi: ExtensionAPI) => void {
|
|
89
|
+
const dependencies = { ...DEFAULT_DEPENDENCIES, ...dependencyOverrides };
|
|
90
|
+
|
|
91
|
+
return (pi: ExtensionAPI): void => {
|
|
92
|
+
const stateDirectory = join(dependencies.getAgentDir(), GLOBAL_STATE_DIRECTORY);
|
|
93
|
+
const configPath = join(stateDirectory, GLOBAL_STATE_FILE);
|
|
94
|
+
const configStore = dependencies.createConfigStore(configPath);
|
|
95
|
+
let active: ActivePermissionSession | null = null;
|
|
96
|
+
|
|
97
|
+
const activeRuntime = (): ActivePermissionSession | null => active;
|
|
98
|
+
registerGuardedBashTool(pi, activeRuntime);
|
|
99
|
+
registerPermissionToolGate(pi, activeRuntime);
|
|
100
|
+
registerPermissionCommands(pi, createCommandsHost(pi, () => active));
|
|
101
|
+
|
|
102
|
+
pi.on("session_start", async (event, ctx) => {
|
|
103
|
+
const previous = active;
|
|
104
|
+
active = null;
|
|
105
|
+
if (previous !== null) {
|
|
106
|
+
await previous.close().catch((error) => {
|
|
107
|
+
ctx.ui.notify(`Previous permission session cleanup failed: ${errorMessage(error)}`, "warning");
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const initialization = sessionInitialization(event.reason, ctx.sessionManager.getBranch());
|
|
111
|
+
const sandbox = safeCreateSandbox(dependencies, ctx.cwd, stateDirectory, configPath);
|
|
112
|
+
|
|
113
|
+
const [sandboxResult, pathResult, detectorResult] = await Promise.allSettled([
|
|
114
|
+
sandbox.start(),
|
|
115
|
+
dependencies.createPathPolicy(ctx.cwd, [
|
|
116
|
+
stateDirectory,
|
|
117
|
+
configPath,
|
|
118
|
+
`${stateDirectory}.lock`,
|
|
119
|
+
]),
|
|
120
|
+
dependencies.createDangerousCommandDetector(),
|
|
121
|
+
]);
|
|
122
|
+
const sandboxStatus =
|
|
123
|
+
sandboxResult.status === "fulfilled"
|
|
124
|
+
? sandboxResult.value
|
|
125
|
+
: failedSandboxStatus("initialization", sandboxResult.reason);
|
|
126
|
+
const backend = backendFromStatus(sandboxStatus);
|
|
127
|
+
const pathPolicy: PathPolicyPort =
|
|
128
|
+
pathResult.status === "fulfilled" ? pathResult.value : FAIL_CLOSED_FILE_POLICY;
|
|
129
|
+
const detector = detectorResult.status === "fulfilled" ? detectorResult.value : null;
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const engine = new PermissionEngine({
|
|
133
|
+
configStore,
|
|
134
|
+
pathPolicy,
|
|
135
|
+
guardian: dependencies.createGuardian(ctx),
|
|
136
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
137
|
+
sessionInitialization: initialization,
|
|
138
|
+
dangerousCommandDetector: detector,
|
|
139
|
+
});
|
|
140
|
+
engine.setBackend(backend);
|
|
141
|
+
active = new ActivePermissionSession(
|
|
142
|
+
engine,
|
|
143
|
+
sandbox,
|
|
144
|
+
ctx.cwd,
|
|
145
|
+
dependencies.transcript,
|
|
146
|
+
dependencies.createBashDefinition,
|
|
147
|
+
);
|
|
148
|
+
const permissionStatus = await updateStatus(ctx, engine);
|
|
149
|
+
notifyBackendFallback(ctx, sandboxStatus, permissionStatus);
|
|
150
|
+
if (pathResult.status === "rejected") {
|
|
151
|
+
ctx.ui.notify(
|
|
152
|
+
"Direct-file path policy could not initialize; Auto write/edit actions will be denied.",
|
|
153
|
+
"error",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
try {
|
|
158
|
+
detector?.close();
|
|
159
|
+
} catch {
|
|
160
|
+
// Initialization is already failing; cleanup must not prevent the
|
|
161
|
+
// remaining guardrail from becoming visibly fail-closed.
|
|
162
|
+
}
|
|
163
|
+
await sandbox.shutdown().catch(() => undefined);
|
|
164
|
+
active = null;
|
|
165
|
+
ctx.ui.setStatus(PERMISSION_STATUS_KEY, "Permissions: Auto (unavailable)");
|
|
166
|
+
ctx.ui.notify(`Permission guard initialization failed: ${errorMessage(error)}`, "error");
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
pi.on("turn_start", (event) => {
|
|
171
|
+
active?.startTurn(event.turnIndex, event.timestamp);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
pi.on("turn_end", () => {
|
|
175
|
+
active?.endTurn();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
179
|
+
const closing = active;
|
|
180
|
+
active = null;
|
|
181
|
+
await closing?.close().catch((error) => {
|
|
182
|
+
ctx.ui.notify(`Permission session cleanup failed: ${errorMessage(error)}`, "warning");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export default createPermissionExtension();
|
|
189
|
+
|
|
190
|
+
class ActivePermissionSession implements GuardedBashRuntime, PermissionToolGateRuntime {
|
|
191
|
+
readonly local: ReturnType<typeof createBashToolDefinition>;
|
|
192
|
+
readonly sandboxed: ReturnType<typeof createBashToolDefinition>;
|
|
193
|
+
private currentTurn: string | undefined;
|
|
194
|
+
private reviewGeneration = new AbortController();
|
|
195
|
+
private closed = false;
|
|
196
|
+
|
|
197
|
+
constructor(
|
|
198
|
+
readonly engine: PermissionEngine,
|
|
199
|
+
private readonly sandbox: SandboxController,
|
|
200
|
+
cwd: string,
|
|
201
|
+
private readonly transcriptBuilder: PermissionExtensionDependencies["transcript"],
|
|
202
|
+
createBashDefinition: PermissionExtensionDependencies["createBashDefinition"],
|
|
203
|
+
) {
|
|
204
|
+
this.local = createBashDefinition(cwd);
|
|
205
|
+
this.sandboxed = createBashDefinition(cwd, sandbox.operations);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
startTurn(turnIndex: number, timestamp: number): void {
|
|
209
|
+
this.endTurn();
|
|
210
|
+
this.currentTurn = `${this.engineStatusSessionId()}:${turnIndex}:${timestamp}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
endTurn(): void {
|
|
214
|
+
if (this.currentTurn !== undefined) this.engine.clearTurn(this.currentTurn);
|
|
215
|
+
this.currentTurn = undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
turnId(toolCallId: string): string {
|
|
219
|
+
return this.currentTurn ?? `${this.engineStatusSessionId()}:tool:${toolCallId.slice(0, 256)}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
transcript(ctx: ExtensionContext): readonly GuardianTranscriptItem[] {
|
|
223
|
+
return this.transcriptBuilder(ctx);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
signal(external: AbortSignal | undefined): AbortSignal | undefined {
|
|
227
|
+
if (external === undefined) return this.reviewGeneration.signal;
|
|
228
|
+
return AbortSignal.any([external, this.reviewGeneration.signal]);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
invalidateReviews(): void {
|
|
232
|
+
this.reviewGeneration.abort();
|
|
233
|
+
this.reviewGeneration = new AbortController();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async refreshBackend(ctx: ExtensionContext): Promise<void> {
|
|
237
|
+
const status = this.sandbox.status();
|
|
238
|
+
const current = this.engine.sessionState.backend;
|
|
239
|
+
if (
|
|
240
|
+
current === "sandboxed" &&
|
|
241
|
+
(status.kind === "failed" || status.kind === "closing" || status.kind === "closed")
|
|
242
|
+
) {
|
|
243
|
+
this.engine.setBackend("unavailable");
|
|
244
|
+
this.invalidateReviews();
|
|
245
|
+
}
|
|
246
|
+
await this.refreshStatus(ctx);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async refreshStatus(ctx: ExtensionContext): Promise<void> {
|
|
250
|
+
await updateStatus(ctx, this.engine).catch(() => undefined);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async close(): Promise<void> {
|
|
254
|
+
if (this.closed) return;
|
|
255
|
+
this.closed = true;
|
|
256
|
+
this.reviewGeneration.abort();
|
|
257
|
+
this.endTurn();
|
|
258
|
+
this.engine.shutdown();
|
|
259
|
+
await this.sandbox.shutdown();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private engineStatusSessionId(): string {
|
|
263
|
+
return this.engine.sessionIdentifier.slice(0, 220);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function createCommandsHost(
|
|
268
|
+
pi: ExtensionAPI,
|
|
269
|
+
getActive: () => ActivePermissionSession | null,
|
|
270
|
+
): PermissionCommandsHost {
|
|
271
|
+
const requireActive = (): ActivePermissionSession => {
|
|
272
|
+
const session = getActive();
|
|
273
|
+
if (session === null) throw new Error("permission session is not initialized");
|
|
274
|
+
return session;
|
|
275
|
+
};
|
|
276
|
+
const checkpoint = (session: ActivePermissionSession): void => {
|
|
277
|
+
pi.appendEntry(SESSION_CHECKPOINT_ENTRY, session.engine.checkpoint);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
async readSnapshot(): Promise<PermissionCommandSnapshot> {
|
|
282
|
+
const global = await requireActive().engine.readGlobal();
|
|
283
|
+
return global.health === "fault"
|
|
284
|
+
? { health: "fault", error: global.error }
|
|
285
|
+
: { health: "healthy", reviewer: global.config.reviewer };
|
|
286
|
+
},
|
|
287
|
+
async setRequestedMode(mode, _ctx): Promise<void> {
|
|
288
|
+
const session = requireActive();
|
|
289
|
+
await session.engine.setRequestedMode(mode);
|
|
290
|
+
session.invalidateReviews();
|
|
291
|
+
checkpoint(session);
|
|
292
|
+
},
|
|
293
|
+
async setReviewerAndAuto(selection, _ctx): Promise<void> {
|
|
294
|
+
const session = requireActive();
|
|
295
|
+
await session.engine.setReviewerAndAuto(selection);
|
|
296
|
+
session.invalidateReviews();
|
|
297
|
+
checkpoint(session);
|
|
298
|
+
},
|
|
299
|
+
async setEnabled(enabled, _ctx): Promise<void> {
|
|
300
|
+
const session = requireActive();
|
|
301
|
+
await session.engine.setEnabled(enabled);
|
|
302
|
+
session.invalidateReviews();
|
|
303
|
+
},
|
|
304
|
+
updateStatus: async (ctx: ExtensionCommandContext): Promise<void> => {
|
|
305
|
+
await updateStatus(ctx, requireActive().engine);
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function updateStatus(
|
|
311
|
+
ctx: ExtensionContext,
|
|
312
|
+
engine: PermissionEngine,
|
|
313
|
+
): Promise<Awaited<ReturnType<PermissionEngine["status"]>>> {
|
|
314
|
+
const status = await engine.status();
|
|
315
|
+
ctx.ui.setStatus(PERMISSION_STATUS_KEY, `Permissions: ${status.label}`);
|
|
316
|
+
return status;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function sessionInitialization(
|
|
320
|
+
reason: "startup" | "reload" | "new" | "resume" | "fork",
|
|
321
|
+
branch: readonly SessionEntry[],
|
|
322
|
+
): SessionInitialization {
|
|
323
|
+
if (reason !== "reload") return { kind: "fresh" };
|
|
324
|
+
const checkpoint = findLatestCheckpoint(branch);
|
|
325
|
+
return checkpoint === undefined ? { kind: "fresh" } : { kind: "reload", checkpoint };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function findLatestCheckpoint(
|
|
329
|
+
branch: readonly SessionEntry[],
|
|
330
|
+
): SessionCheckpoint | undefined {
|
|
331
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
332
|
+
const entry = branch[index];
|
|
333
|
+
if (entry?.type !== "custom" || entry.customType !== SESSION_CHECKPOINT_ENTRY) continue;
|
|
334
|
+
const data = entry.data;
|
|
335
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) continue;
|
|
336
|
+
const record = data as Record<string, unknown>;
|
|
337
|
+
if (!isPermissionMode(record.requestedMode)) continue;
|
|
338
|
+
try {
|
|
339
|
+
assertRevision(record.revision, "session checkpoint revision");
|
|
340
|
+
} catch {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
return { requestedMode: record.requestedMode, revision: record.revision };
|
|
344
|
+
}
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function safeCreateSandbox(
|
|
349
|
+
dependencies: PermissionExtensionDependencies,
|
|
350
|
+
cwd: string,
|
|
351
|
+
stateDirectory: string,
|
|
352
|
+
configPath: string,
|
|
353
|
+
): SandboxController {
|
|
354
|
+
try {
|
|
355
|
+
return dependencies.createSandbox({
|
|
356
|
+
cwd,
|
|
357
|
+
additionalDenyWrite: [stateDirectory, configPath, `${stateDirectory}.lock`],
|
|
358
|
+
});
|
|
359
|
+
} catch (error) {
|
|
360
|
+
return new FailedSandboxController(errorMessage(error));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function backendFromStatus(status: SandboxStatus): EnforcementBackend {
|
|
365
|
+
if (status.kind === "sandboxed") return "sandboxed";
|
|
366
|
+
if (status.kind === "review-only") return "review-only";
|
|
367
|
+
return "unavailable";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function failedSandboxStatus(
|
|
371
|
+
phase: "initialization",
|
|
372
|
+
error: unknown,
|
|
373
|
+
): Extract<SandboxStatus, { kind: "failed" }> {
|
|
374
|
+
return { kind: "failed", phase, error: errorMessage(error) };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function notifyBackendFallback(
|
|
378
|
+
ctx: ExtensionContext,
|
|
379
|
+
sandboxStatus: SandboxStatus,
|
|
380
|
+
permissionStatus: Awaited<ReturnType<PermissionEngine["status"]>>,
|
|
381
|
+
): void {
|
|
382
|
+
if (!permissionStatus.label.startsWith("Auto")) return;
|
|
383
|
+
if (sandboxStatus.kind === "review-only") {
|
|
384
|
+
ctx.ui.notify(
|
|
385
|
+
`OS sandboxing is unavailable on ${sandboxStatus.platform}; Auto will model-review every shell command.`,
|
|
386
|
+
"warning",
|
|
387
|
+
);
|
|
388
|
+
} else if (sandboxStatus.kind === "failed") {
|
|
389
|
+
ctx.ui.notify(
|
|
390
|
+
`Auto shell sandbox is unavailable (${sandboxStatus.phase}); Auto shell commands will be denied.`,
|
|
391
|
+
"error",
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const FAIL_CLOSED_FILE_POLICY: PathPolicyPort = Object.freeze({
|
|
397
|
+
classify: async ({ toolName }) =>
|
|
398
|
+
["read", "grep", "find", "ls"].includes(toolName)
|
|
399
|
+
? { disposition: "admit" as const, reason: "known read-only file tool" }
|
|
400
|
+
: { disposition: "deny" as const, reason: "path policy unavailable" },
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
class FailedSandboxController implements SandboxController {
|
|
404
|
+
readonly operations: BashOperations = {
|
|
405
|
+
exec: async () => {
|
|
406
|
+
throw new Error("Auto sandbox is unavailable");
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
private current: SandboxStatus;
|
|
410
|
+
|
|
411
|
+
constructor(error: string) {
|
|
412
|
+
this.current = failedSandboxStatus("initialization", error);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async start(): Promise<SandboxStatus> {
|
|
416
|
+
return this.status();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
status(): SandboxStatus {
|
|
420
|
+
return { ...this.current };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async shutdown(): Promise<void> {
|
|
424
|
+
this.current = { kind: "closed" };
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function errorMessage(error: unknown): string {
|
|
429
|
+
return error instanceof Error ? error.message : String(error);
|
|
430
|
+
}
|