@gotgenes/pi-permission-system 0.7.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/src/logging.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { appendFileSync } from "node:fs";
2
+
3
+ import {
4
+ DEBUG_LOG_PATH,
5
+ EXTENSION_ID,
6
+ ensurePermissionSystemLogsDirectory,
7
+ LOGS_DIR,
8
+ PERMISSION_REVIEW_LOG_PATH,
9
+ type PermissionSystemExtensionConfig,
10
+ } from "./extension-config.js";
11
+
12
+ export function safeJsonStringify(value: unknown): string | undefined {
13
+ const seen = new WeakSet<object>();
14
+ return JSON.stringify(value, (_key, currentValue) => {
15
+ if (currentValue instanceof Error) {
16
+ return {
17
+ name: currentValue.name,
18
+ message: currentValue.message,
19
+ stack: currentValue.stack,
20
+ };
21
+ }
22
+
23
+ if (typeof currentValue === "bigint") {
24
+ return currentValue.toString();
25
+ }
26
+
27
+ if (typeof currentValue === "object" && currentValue !== null) {
28
+ if (seen.has(currentValue)) {
29
+ return "[Circular]";
30
+ }
31
+ seen.add(currentValue);
32
+ }
33
+
34
+ return currentValue;
35
+ });
36
+ }
37
+
38
+ export interface PermissionSystemLogger {
39
+ debug: (
40
+ event: string,
41
+ details?: Record<string, unknown>,
42
+ ) => string | undefined;
43
+ review: (
44
+ event: string,
45
+ details?: Record<string, unknown>,
46
+ ) => string | undefined;
47
+ }
48
+
49
+ interface PermissionSystemLoggerOptions {
50
+ getConfig: () => PermissionSystemExtensionConfig;
51
+ debugLogPath?: string;
52
+ reviewLogPath?: string;
53
+ ensureLogsDirectory?: () => string | undefined;
54
+ }
55
+
56
+ export function createPermissionSystemLogger(
57
+ options: PermissionSystemLoggerOptions,
58
+ ): PermissionSystemLogger {
59
+ const debugLogPath = options.debugLogPath ?? DEBUG_LOG_PATH;
60
+ const reviewLogPath = options.reviewLogPath ?? PERMISSION_REVIEW_LOG_PATH;
61
+ const ensureLogsDirectory =
62
+ options.ensureLogsDirectory ??
63
+ (() => ensurePermissionSystemLogsDirectory(LOGS_DIR));
64
+
65
+ const writeLine = (
66
+ stream: "debug" | "review",
67
+ path: string,
68
+ event: string,
69
+ details: Record<string, unknown>,
70
+ ): string | undefined => {
71
+ const directoryError = ensureLogsDirectory();
72
+ if (directoryError) {
73
+ return directoryError;
74
+ }
75
+
76
+ try {
77
+ const line = safeJsonStringify({
78
+ timestamp: new Date().toISOString(),
79
+ extension: EXTENSION_ID,
80
+ stream,
81
+ event,
82
+ ...details,
83
+ });
84
+ if (!line) {
85
+ return `Failed to write permission-system ${stream} log '${path}': event could not be serialized.`;
86
+ }
87
+ appendFileSync(path, `${line}\n`, "utf-8");
88
+ return undefined;
89
+ } catch (error) {
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ return `Failed to write permission-system ${stream} log '${path}': ${message}`;
92
+ }
93
+ };
94
+
95
+ const debug = (
96
+ event: string,
97
+ details: Record<string, unknown> = {},
98
+ ): string | undefined => {
99
+ if (!options.getConfig().debugLog) {
100
+ return undefined;
101
+ }
102
+
103
+ return writeLine("debug", debugLogPath, event, details);
104
+ };
105
+
106
+ const review = (
107
+ event: string,
108
+ details: Record<string, unknown> = {},
109
+ ): string | undefined => {
110
+ if (!options.getConfig().permissionReviewLog) {
111
+ return undefined;
112
+ }
113
+
114
+ return writeLine("review", reviewLogPath, event, details);
115
+ };
116
+
117
+ return { debug, review };
118
+ }
@@ -0,0 +1,182 @@
1
+ import {
2
+ type Api,
3
+ type AssistantMessageEventStream,
4
+ getApiProvider,
5
+ type Context as LlmContext,
6
+ type Model,
7
+ type SimpleStreamOptions,
8
+ } from "@mariozechner/pi-ai";
9
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
+
11
+ const GUARDED_TEMPERATURE_APIS = [
12
+ "openai-codex-responses",
13
+ "openai-responses",
14
+ "azure-openai-responses",
15
+ ] as const satisfies readonly Api[];
16
+ const OPENAI_RESPONSES_APIS = new Set<Api>([
17
+ "openai-responses",
18
+ "azure-openai-responses",
19
+ ]);
20
+ const TEMPERATURE_UNSUPPORTED_APIS = new Set<Api>(["openai-codex-responses"]);
21
+ const TEMPERATURE_UNSUPPORTED_PROVIDERS = new Set<string>(["openai-codex"]);
22
+
23
+ export type ApiStreamSimpleDelegate = (
24
+ model: Model<Api>,
25
+ context: LlmContext,
26
+ options?: SimpleStreamOptions,
27
+ ) => AssistantMessageEventStream;
28
+
29
+ type GlobalWithPermissionSystemProviderGuard = typeof globalThis & {
30
+ __piPermissionSystemModelOptionBaseStreams?: Map<
31
+ string,
32
+ ApiStreamSimpleDelegate
33
+ >;
34
+ __piPermissionSystemModelOptionGuardedApis?: Set<string>;
35
+ };
36
+
37
+ function getBaseApiStreams(): Map<string, ApiStreamSimpleDelegate> {
38
+ const globalScope = globalThis as GlobalWithPermissionSystemProviderGuard;
39
+ if (!globalScope.__piPermissionSystemModelOptionBaseStreams) {
40
+ globalScope.__piPermissionSystemModelOptionBaseStreams = new Map<
41
+ string,
42
+ ApiStreamSimpleDelegate
43
+ >();
44
+ }
45
+ return globalScope.__piPermissionSystemModelOptionBaseStreams;
46
+ }
47
+
48
+ function getGuardedApis(): Set<string> {
49
+ const globalScope = globalThis as GlobalWithPermissionSystemProviderGuard;
50
+ if (!globalScope.__piPermissionSystemModelOptionGuardedApis) {
51
+ globalScope.__piPermissionSystemModelOptionGuardedApis = new Set<string>();
52
+ }
53
+ return globalScope.__piPermissionSystemModelOptionGuardedApis;
54
+ }
55
+
56
+ function normalizeIdentifier(value: string | undefined): string {
57
+ return (value ?? "").trim().toLowerCase();
58
+ }
59
+
60
+ function hasModelToken(modelId: string, token: string): boolean {
61
+ return normalizeIdentifier(modelId)
62
+ .split(/[^a-z0-9]+/)
63
+ .includes(token);
64
+ }
65
+
66
+ export function getUnsupportedTemperatureReason(
67
+ model: Pick<Model<Api>, "api" | "id" | "provider" | "reasoning">,
68
+ ): string | undefined {
69
+ if (TEMPERATURE_UNSUPPORTED_APIS.has(model.api)) {
70
+ return `api '${model.api}' does not support temperature`;
71
+ }
72
+
73
+ const provider = normalizeIdentifier(model.provider);
74
+ if (TEMPERATURE_UNSUPPORTED_PROVIDERS.has(provider)) {
75
+ return `provider '${model.provider}' does not support temperature`;
76
+ }
77
+
78
+ if (
79
+ OPENAI_RESPONSES_APIS.has(model.api) &&
80
+ hasModelToken(model.id, "codex")
81
+ ) {
82
+ return `model '${model.id}' does not support temperature`;
83
+ }
84
+
85
+ if (OPENAI_RESPONSES_APIS.has(model.api) && model.reasoning) {
86
+ return `reasoning model '${model.id}' accepts only the provider default temperature`;
87
+ }
88
+
89
+ return undefined;
90
+ }
91
+
92
+ function isRecord(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === "object" && value !== null && !Array.isArray(value);
94
+ }
95
+
96
+ export function stripUnsupportedTemperatureFromPayload(
97
+ payload: unknown,
98
+ ): unknown {
99
+ if (!isRecord(payload) || !("temperature" in payload)) {
100
+ return payload;
101
+ }
102
+
103
+ const { temperature: _temperature, ...rest } = payload;
104
+ return rest;
105
+ }
106
+
107
+ function composeTemperatureSanitizer(
108
+ options: SimpleStreamOptions | undefined,
109
+ model: Model<Api>,
110
+ ): SimpleStreamOptions | undefined {
111
+ const reason = getUnsupportedTemperatureReason(model);
112
+ if (!reason && options?.temperature === undefined) {
113
+ return options;
114
+ }
115
+
116
+ if (!reason) {
117
+ return options;
118
+ }
119
+
120
+ const existingOnPayload = options?.onPayload;
121
+ const nextOptions: SimpleStreamOptions = options
122
+ ? { ...options, temperature: undefined }
123
+ : {};
124
+
125
+ nextOptions.onPayload = async (payload, payloadModel) => {
126
+ const transformedPayload = existingOnPayload
127
+ ? await existingOnPayload(payload, payloadModel)
128
+ : undefined;
129
+ return stripUnsupportedTemperatureFromPayload(
130
+ transformedPayload ?? payload,
131
+ );
132
+ };
133
+
134
+ return nextOptions;
135
+ }
136
+
137
+ function ensureModelOptionGuardForApi(pi: ExtensionAPI, api: Api): boolean {
138
+ const guardedApis = getGuardedApis();
139
+ if (guardedApis.has(api)) {
140
+ return true;
141
+ }
142
+
143
+ const baseStreams = getBaseApiStreams();
144
+ let baseStream = baseStreams.get(api);
145
+ if (!baseStream) {
146
+ const currentProvider = getApiProvider(api);
147
+ if (!currentProvider) {
148
+ return false;
149
+ }
150
+ baseStream = currentProvider.streamSimple as ApiStreamSimpleDelegate;
151
+ baseStreams.set(api, baseStream);
152
+ }
153
+
154
+ const providerName = `pi-permission-system-model-option-compatibility-${api.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
155
+ pi.registerProvider(providerName, {
156
+ api,
157
+ streamSimple: (model, context, options) => {
158
+ const typedModel = model as Model<Api>;
159
+ const delegate = baseStreams.get(typedModel.api);
160
+ if (!delegate) {
161
+ throw new Error(
162
+ `No base stream provider available for api '${typedModel.api}'.`,
163
+ );
164
+ }
165
+
166
+ return delegate(
167
+ typedModel,
168
+ context,
169
+ composeTemperatureSanitizer(options, typedModel),
170
+ );
171
+ },
172
+ });
173
+
174
+ guardedApis.add(api);
175
+ return true;
176
+ }
177
+
178
+ export function registerModelOptionCompatibilityGuard(pi: ExtensionAPI): void {
179
+ for (const api of GUARDED_TEMPERATURE_APIS) {
180
+ ensureModelOptionGuardForApi(pi, api);
181
+ }
182
+ }
@@ -0,0 +1,89 @@
1
+ export type PermissionDecisionState =
2
+ | "approved"
3
+ | "denied"
4
+ | "denied_with_reason";
5
+
6
+ export type PermissionPromptDecision = {
7
+ approved: boolean;
8
+ state: PermissionDecisionState;
9
+ denialReason?: string;
10
+ };
11
+
12
+ export interface PermissionDecisionUi {
13
+ select(title: string, options: string[]): Promise<string | undefined>;
14
+ input(title: string, placeholder?: string): Promise<string | undefined>;
15
+ }
16
+
17
+ const APPROVE_OPTION = "Yes";
18
+ const DENY_OPTION = "No";
19
+ const DENY_WITH_REASON_OPTION = "No, provide reason";
20
+ const PERMISSION_DECISION_OPTIONS = [
21
+ APPROVE_OPTION,
22
+ DENY_OPTION,
23
+ DENY_WITH_REASON_OPTION,
24
+ ] as const;
25
+
26
+ export function normalizePermissionDenialReason(
27
+ value: unknown,
28
+ ): string | undefined {
29
+ if (typeof value !== "string") {
30
+ return undefined;
31
+ }
32
+
33
+ const trimmed = value.trim();
34
+ return trimmed.length > 0 ? trimmed : undefined;
35
+ }
36
+
37
+ export function createDeniedPermissionDecision(
38
+ denialReason?: string,
39
+ ): PermissionPromptDecision {
40
+ const normalizedReason = normalizePermissionDenialReason(denialReason);
41
+ return normalizedReason
42
+ ? {
43
+ approved: false,
44
+ state: "denied_with_reason",
45
+ denialReason: normalizedReason,
46
+ }
47
+ : {
48
+ approved: false,
49
+ state: "denied",
50
+ };
51
+ }
52
+
53
+ export function isPermissionDecisionState(
54
+ value: unknown,
55
+ ): value is PermissionDecisionState {
56
+ return (
57
+ value === "approved" || value === "denied" || value === "denied_with_reason"
58
+ );
59
+ }
60
+
61
+ export async function requestPermissionDecisionFromUi(
62
+ ui: PermissionDecisionUi,
63
+ title: string,
64
+ message: string,
65
+ ): Promise<PermissionPromptDecision> {
66
+ const selected = await ui.select(`${title}\n${message}`, [
67
+ ...PERMISSION_DECISION_OPTIONS,
68
+ ]);
69
+
70
+ if (selected === APPROVE_OPTION) {
71
+ return {
72
+ approved: true,
73
+ state: "approved",
74
+ };
75
+ }
76
+
77
+ if (selected === DENY_WITH_REASON_OPTION) {
78
+ const denialReason = normalizePermissionDenialReason(
79
+ await ui.input(
80
+ `${title}\nShare why this request was denied (optional).`,
81
+ "Reason shown back to the agent",
82
+ ),
83
+ );
84
+
85
+ return createDeniedPermissionDecision(denialReason);
86
+ }
87
+
88
+ return createDeniedPermissionDecision();
89
+ }
@@ -0,0 +1,126 @@
1
+ import { join } from "node:path";
2
+
3
+ import type { PermissionDecisionState } from "./permission-dialog.js";
4
+
5
+ export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
6
+ export const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
7
+ export const SUBAGENT_ENV_HINT_KEYS = [
8
+ "PI_IS_SUBAGENT",
9
+ "PI_SUBAGENT_SESSION_ID",
10
+ "PI_AGENT_ROUTER_SUBAGENT",
11
+ ] as const;
12
+ export const SUBAGENT_PARENT_SESSION_ENV_KEY =
13
+ "PI_AGENT_ROUTER_PARENT_SESSION_ID";
14
+
15
+ const SESSION_FORWARDING_ROOT_DIRECTORY_NAME = "sessions";
16
+ const SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME = "requests";
17
+ const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
18
+
19
+ export type ForwardedPermissionRequest = {
20
+ id: string;
21
+ createdAt: number;
22
+ requesterSessionId: string;
23
+ targetSessionId: string;
24
+ requesterAgentName: string;
25
+ message: string;
26
+ };
27
+
28
+ export type ForwardedPermissionResponse = {
29
+ approved: boolean;
30
+ state: PermissionDecisionState;
31
+ denialReason?: string;
32
+ responderSessionId: string;
33
+ respondedAt: number;
34
+ };
35
+
36
+ export type PermissionForwardingLocation = {
37
+ sessionId: string;
38
+ sessionRootDir: string;
39
+ requestsDir: string;
40
+ responsesDir: string;
41
+ label: "primary";
42
+ };
43
+
44
+ export function normalizePermissionForwardingSessionId(
45
+ value: unknown,
46
+ ): string | null {
47
+ if (typeof value !== "string") {
48
+ return null;
49
+ }
50
+
51
+ const trimmed = value.trim();
52
+ if (!trimmed || trimmed.toLowerCase() === "unknown") {
53
+ return null;
54
+ }
55
+
56
+ return trimmed;
57
+ }
58
+
59
+ function encodeSessionIdForPath(sessionId: string): string {
60
+ return encodeURIComponent(sessionId);
61
+ }
62
+
63
+ export function createPermissionForwardingLocation(
64
+ forwardingRootDir: string,
65
+ sessionId: string,
66
+ ): PermissionForwardingLocation {
67
+ const normalizedSessionId = normalizePermissionForwardingSessionId(sessionId);
68
+ if (!normalizedSessionId) {
69
+ throw new Error(
70
+ "Permission forwarding session id must be a non-empty string.",
71
+ );
72
+ }
73
+
74
+ const sessionRootDir = join(
75
+ forwardingRootDir,
76
+ SESSION_FORWARDING_ROOT_DIRECTORY_NAME,
77
+ encodeSessionIdForPath(normalizedSessionId),
78
+ );
79
+
80
+ return {
81
+ sessionId: normalizedSessionId,
82
+ sessionRootDir,
83
+ requestsDir: join(
84
+ sessionRootDir,
85
+ SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME,
86
+ ),
87
+ responsesDir: join(
88
+ sessionRootDir,
89
+ SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME,
90
+ ),
91
+ label: "primary",
92
+ };
93
+ }
94
+
95
+ export function resolvePermissionForwardingTargetSessionId(options: {
96
+ hasUI: boolean;
97
+ isSubagent: boolean;
98
+ currentSessionId?: string | null;
99
+ env?: NodeJS.ProcessEnv;
100
+ }): string | null {
101
+ if (options.hasUI) {
102
+ return normalizePermissionForwardingSessionId(options.currentSessionId);
103
+ }
104
+
105
+ if (!options.isSubagent) {
106
+ return null;
107
+ }
108
+
109
+ return normalizePermissionForwardingSessionId(
110
+ options.env?.[SUBAGENT_PARENT_SESSION_ENV_KEY],
111
+ );
112
+ }
113
+
114
+ export function isForwardedPermissionRequestForSession(
115
+ request: Pick<ForwardedPermissionRequest, "targetSessionId">,
116
+ sessionId: string | null | undefined,
117
+ ): boolean {
118
+ const normalizedRequestSessionId = normalizePermissionForwardingSessionId(
119
+ request.targetSessionId,
120
+ );
121
+ const normalizedSessionId = normalizePermissionForwardingSessionId(sessionId);
122
+ return (
123
+ normalizedRequestSessionId !== null &&
124
+ normalizedRequestSessionId === normalizedSessionId
125
+ );
126
+ }