@czottmann/pi-automode 1.6.0 → 1.8.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/README.md +43 -9
- package/docs/automode-classifier-flow.md +43 -46
- package/docs/defaults.md +13 -2
- package/docs/observability-logging.md +109 -0
- package/extensions/auto-mode/classifier.ts +305 -59
- package/extensions/auto-mode/config.ts +72 -11
- package/extensions/auto-mode/constants.ts +15 -4
- package/extensions/auto-mode/extension.ts +140 -43
- package/extensions/auto-mode/hard-deny.ts +5 -2
- package/extensions/auto-mode/log.ts +115 -0
- package/extensions/auto-mode/paths.ts +67 -36
- package/extensions/auto-mode/transcript.ts +181 -33
- package/extensions/auto-mode/types.ts +51 -3
- package/extensions/auto-mode.ts +2 -1
- package/package.json +1 -1
|
@@ -19,10 +19,15 @@ import {
|
|
|
19
19
|
writeGlobalClassifierModel,
|
|
20
20
|
} from "./config.ts";
|
|
21
21
|
import { deterministicHardDeny } from "./hard-deny.ts";
|
|
22
|
+
import {
|
|
23
|
+
createLogger,
|
|
24
|
+
newDecisionId,
|
|
25
|
+
resolveLogPath,
|
|
26
|
+
type Logger,
|
|
27
|
+
} from "./log.ts";
|
|
22
28
|
import { formatModelSpec, parseModelSpec } from "./model.ts";
|
|
23
29
|
import { promptForClassifierModel } from "./model-selector.ts";
|
|
24
30
|
import { matchesToolPattern } from "./permissions.ts";
|
|
25
|
-
import { isProtectedPath, resolveInputPath } from "./paths.ts";
|
|
26
31
|
import {
|
|
27
32
|
actionSummary,
|
|
28
33
|
formatDenials,
|
|
@@ -35,7 +40,9 @@ import { loadedContextFromSystemPromptOptions } from "./transcript.ts";
|
|
|
35
40
|
import type {
|
|
36
41
|
AutoModeState,
|
|
37
42
|
ClassifyAction,
|
|
43
|
+
ClassifyResult,
|
|
38
44
|
ConfigLoadResult,
|
|
45
|
+
DecisionKind,
|
|
39
46
|
DenialRecord,
|
|
40
47
|
EffectiveConfig,
|
|
41
48
|
} from "./types.ts";
|
|
@@ -50,6 +57,47 @@ export type PiAutomodeOptions = {
|
|
|
50
57
|
saveClassifierModel?: (classifierModel: string) => void;
|
|
51
58
|
};
|
|
52
59
|
|
|
60
|
+
type LogCtx = {
|
|
61
|
+
logger: Logger;
|
|
62
|
+
decisionId: string;
|
|
63
|
+
classifierModel?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Append ccusage-compatible usage and optional classifier I/O entries. */
|
|
67
|
+
function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
|
|
68
|
+
if (!log.logger.enabled || !decision.io) return;
|
|
69
|
+
|
|
70
|
+
for (const attempt of decision.io.attempts) {
|
|
71
|
+
const response = attempt.response;
|
|
72
|
+
if (!response) continue;
|
|
73
|
+
log.logger.append({
|
|
74
|
+
type: "message",
|
|
75
|
+
timestamp: new Date(response.timestamp).toISOString(),
|
|
76
|
+
message: {
|
|
77
|
+
role: "assistant",
|
|
78
|
+
model: response.model,
|
|
79
|
+
usage: response.usage,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!log.logger.classifierIo) return;
|
|
85
|
+
log.logger.append({
|
|
86
|
+
type: "classifier",
|
|
87
|
+
ts: new Date().toISOString(),
|
|
88
|
+
decisionId: log.decisionId,
|
|
89
|
+
model: decision.io.model,
|
|
90
|
+
prompt: decision.io.prompt,
|
|
91
|
+
attempts: decision.io.attempts,
|
|
92
|
+
durationMs: decision.io.durationMs,
|
|
93
|
+
parsed: {
|
|
94
|
+
decision: decision.decision,
|
|
95
|
+
tier: decision.tier,
|
|
96
|
+
reason: decision.reason,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
53
101
|
/** Create a Pi extension instance. Default export uses production dependencies. */
|
|
54
102
|
export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
55
103
|
const loadConfigWithDiagnostics = options.loadConfig
|
|
@@ -101,6 +149,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
101
149
|
function block(
|
|
102
150
|
ctx: ExtensionContext,
|
|
103
151
|
denial: DenialRecord,
|
|
152
|
+
logCtx: LogCtx,
|
|
104
153
|
): { block: true; reason: string } {
|
|
105
154
|
state.blockedActions += 1;
|
|
106
155
|
state.lastDecision = "block";
|
|
@@ -108,6 +157,21 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
108
157
|
pushDenial(state, denial);
|
|
109
158
|
persist();
|
|
110
159
|
updateUi(ctx);
|
|
160
|
+
if (logCtx.logger.enabled) {
|
|
161
|
+
logCtx.logger.append({
|
|
162
|
+
type: "decision",
|
|
163
|
+
ts: new Date().toISOString(),
|
|
164
|
+
decisionId: logCtx.decisionId,
|
|
165
|
+
sessionId: ctx.sessionManager.getSessionId?.(),
|
|
166
|
+
cwd: ctx.cwd,
|
|
167
|
+
tool: denial.toolName,
|
|
168
|
+
summary: denial.action,
|
|
169
|
+
kind: denial.kind,
|
|
170
|
+
outcome: "block",
|
|
171
|
+
reason: denial.reason,
|
|
172
|
+
classifierModel: logCtx.classifierModel,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
111
175
|
if (ctx.hasUI) {
|
|
112
176
|
ctx.ui.notify(
|
|
113
177
|
`Auto mode blocked ${denial.toolName}: ${denial.reason}`,
|
|
@@ -117,6 +181,36 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
117
181
|
return { block: true, reason: `[pi-automode] ${denial.reason}` };
|
|
118
182
|
}
|
|
119
183
|
|
|
184
|
+
function allow(
|
|
185
|
+
ctx: ExtensionContext,
|
|
186
|
+
kind: DecisionKind,
|
|
187
|
+
reason: string,
|
|
188
|
+
toolName: string,
|
|
189
|
+
summary: string,
|
|
190
|
+
logCtx: LogCtx,
|
|
191
|
+
): undefined {
|
|
192
|
+
state.lastDecision = "allow";
|
|
193
|
+
state.lastReason = reason;
|
|
194
|
+
persist();
|
|
195
|
+
updateUi(ctx);
|
|
196
|
+
if (logCtx.logger.enabled) {
|
|
197
|
+
logCtx.logger.append({
|
|
198
|
+
type: "decision",
|
|
199
|
+
ts: new Date().toISOString(),
|
|
200
|
+
decisionId: logCtx.decisionId,
|
|
201
|
+
sessionId: ctx.sessionManager.getSessionId?.(),
|
|
202
|
+
cwd: ctx.cwd,
|
|
203
|
+
tool: toolName,
|
|
204
|
+
summary,
|
|
205
|
+
kind,
|
|
206
|
+
outcome: "allow",
|
|
207
|
+
reason,
|
|
208
|
+
classifierModel: logCtx.classifierModel,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
120
214
|
pi.on("session_start", (_event, ctx) => {
|
|
121
215
|
loadResult = loadConfigWithDiagnostics(ctx.cwd);
|
|
122
216
|
config = loadResult.config;
|
|
@@ -135,10 +229,10 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
135
229
|
});
|
|
136
230
|
|
|
137
231
|
pi.on("tool_call", async (event, ctx) => {
|
|
138
|
-
// Enforcement order
|
|
232
|
+
// Enforcement order:
|
|
139
233
|
// 1. permission deny/ask rules,
|
|
140
234
|
// 2. deterministic hard-deny checks that never consult the model,
|
|
141
|
-
// 3. read-only fast path,
|
|
235
|
+
// 3. read-only built-in fast path,
|
|
142
236
|
// 4. classifier for every remaining action, fail-closed on setup/parse errors.
|
|
143
237
|
const cfg = effectiveConfig();
|
|
144
238
|
if (!cfg.enabled) return undefined;
|
|
@@ -147,6 +241,17 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
147
241
|
const input = event.input as Record<string, unknown>;
|
|
148
242
|
const summary = actionSummary(event.toolName, input);
|
|
149
243
|
state.checkedActions += 1;
|
|
244
|
+
const logCtx: LogCtx = {
|
|
245
|
+
logger: createLogger({
|
|
246
|
+
enabled: cfg.log.enabled,
|
|
247
|
+
classifierIo: cfg.log.classifierIo,
|
|
248
|
+
sessionFile: ctx.sessionManager.getSessionFile?.(),
|
|
249
|
+
sessionDir: ctx.sessionManager.getSessionDir?.() ?? ctx.cwd,
|
|
250
|
+
sessionId: ctx.sessionManager.getSessionId?.() ?? "unknown",
|
|
251
|
+
}),
|
|
252
|
+
decisionId: newDecisionId(),
|
|
253
|
+
classifierModel: cfg.classifierModel,
|
|
254
|
+
};
|
|
150
255
|
|
|
151
256
|
for (const pattern of cfg.permissionDeny) {
|
|
152
257
|
if (matchesToolPattern(pattern, event.toolName, input, ctx.cwd)) {
|
|
@@ -156,7 +261,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
156
261
|
reason: `Blocked by permissions.deny: ${pattern.raw}`,
|
|
157
262
|
action: summary,
|
|
158
263
|
kind: "permissions.deny",
|
|
159
|
-
});
|
|
264
|
+
}, logCtx);
|
|
160
265
|
}
|
|
161
266
|
}
|
|
162
267
|
|
|
@@ -172,7 +277,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
172
277
|
`Matched permissions.ask (${pattern.raw}) but no UI is available`,
|
|
173
278
|
action: summary,
|
|
174
279
|
kind: "permissions.ask",
|
|
175
|
-
});
|
|
280
|
+
}, logCtx);
|
|
176
281
|
}
|
|
177
282
|
const allowed = await ctx.ui.confirm(
|
|
178
283
|
"Auto mode permission ask",
|
|
@@ -186,7 +291,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
186
291
|
reason: `Declined permissions.ask: ${pattern.raw}`,
|
|
187
292
|
action: summary,
|
|
188
293
|
kind: "permissions.ask",
|
|
189
|
-
});
|
|
294
|
+
}, logCtx);
|
|
190
295
|
}
|
|
191
296
|
}
|
|
192
297
|
|
|
@@ -202,49 +307,32 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
202
307
|
reason: deterministicReason,
|
|
203
308
|
action: summary,
|
|
204
309
|
kind: "deterministic-hard-deny",
|
|
205
|
-
});
|
|
310
|
+
}, logCtx);
|
|
206
311
|
}
|
|
207
312
|
|
|
208
313
|
if (READ_ONLY_TOOLS.has(event.toolName)) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (event.toolName === "write" || event.toolName === "edit") {
|
|
218
|
-
const path = resolveInputPath(ctx.cwd, input.path);
|
|
219
|
-
if (path && isProtectedPath(path, ctx.cwd, cfg.protectedPaths)) {
|
|
220
|
-
const decision = await classify(ctx, cfg, summary, loadedContext);
|
|
221
|
-
if (decision.decision === "allow") {
|
|
222
|
-
state.classifierAllowed += 1;
|
|
223
|
-
state.lastDecision = "allow";
|
|
224
|
-
state.lastReason = decision.reason;
|
|
225
|
-
persist();
|
|
226
|
-
updateUi(ctx);
|
|
227
|
-
return undefined;
|
|
228
|
-
}
|
|
229
|
-
state.classifierDenied += 1;
|
|
230
|
-
return block(ctx, {
|
|
231
|
-
timestamp: Date.now(),
|
|
232
|
-
toolName: event.toolName,
|
|
233
|
-
reason: decision.reason,
|
|
234
|
-
action: summary,
|
|
235
|
-
kind: "classifier",
|
|
236
|
-
});
|
|
237
|
-
}
|
|
314
|
+
return allow(
|
|
315
|
+
ctx,
|
|
316
|
+
"read-only",
|
|
317
|
+
`Read-only built-in tool: ${event.toolName}`,
|
|
318
|
+
event.toolName,
|
|
319
|
+
summary,
|
|
320
|
+
logCtx,
|
|
321
|
+
);
|
|
238
322
|
}
|
|
239
323
|
|
|
240
324
|
const decision = await classify(ctx, cfg, summary, loadedContext);
|
|
325
|
+
logClassifierIo(decision, logCtx);
|
|
241
326
|
if (decision.decision === "allow") {
|
|
242
327
|
state.classifierAllowed += 1;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
328
|
+
return allow(
|
|
329
|
+
ctx,
|
|
330
|
+
"classifier",
|
|
331
|
+
decision.reason,
|
|
332
|
+
event.toolName,
|
|
333
|
+
summary,
|
|
334
|
+
logCtx,
|
|
335
|
+
);
|
|
248
336
|
}
|
|
249
337
|
|
|
250
338
|
state.classifierDenied += 1;
|
|
@@ -254,7 +342,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
254
342
|
reason: decision.reason,
|
|
255
343
|
action: summary,
|
|
256
344
|
kind: "classifier",
|
|
257
|
-
});
|
|
345
|
+
}, logCtx);
|
|
258
346
|
});
|
|
259
347
|
|
|
260
348
|
async function handleAutomodeCommand(
|
|
@@ -328,9 +416,18 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
328
416
|
return;
|
|
329
417
|
}
|
|
330
418
|
if (command === "config") {
|
|
419
|
+
const logFile = resolveLogPath(
|
|
420
|
+
ctx.sessionManager.getSessionFile?.(),
|
|
421
|
+
ctx.sessionManager.getSessionDir?.() ?? ctx.cwd,
|
|
422
|
+
ctx.sessionManager.getSessionId?.() ?? "unknown",
|
|
423
|
+
);
|
|
331
424
|
ctx.ui.notify(
|
|
332
425
|
safeJson(
|
|
333
|
-
{
|
|
426
|
+
{
|
|
427
|
+
config: effectiveConfig(),
|
|
428
|
+
logFile,
|
|
429
|
+
diagnostics: configDiagnostics,
|
|
430
|
+
},
|
|
334
431
|
16000,
|
|
335
432
|
),
|
|
336
433
|
configDiagnostics.length > 0 ? "warning" : "info",
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isProfileOrAuthorizedKeysPath,
|
|
5
5
|
isSafetyControlPath,
|
|
6
6
|
resolveInputPath,
|
|
7
|
+
resolvePathForPolicy,
|
|
7
8
|
shellPathTokenToPath,
|
|
8
9
|
} from "./paths.ts";
|
|
9
10
|
|
|
@@ -331,9 +332,11 @@ export function deterministicHardDeny(
|
|
|
331
332
|
if (toolName === "write" || toolName === "edit") {
|
|
332
333
|
const path = resolveInputPath(cwd, input.path);
|
|
333
334
|
if (!path) return undefined;
|
|
334
|
-
const
|
|
335
|
+
const policyPath = resolvePathForPolicy(path) ?? path;
|
|
336
|
+
const policyCwd = resolvePathForPolicy(cwd) ?? cwd;
|
|
337
|
+
const profileReason = isProfileOrAuthorizedKeysPath(policyPath);
|
|
335
338
|
if (profileReason) return profileReason;
|
|
336
|
-
if (isSafetyControlPath(
|
|
339
|
+
if (isSafetyControlPath(policyPath, policyCwd)) {
|
|
337
340
|
return "auto-mode or permission safety-control modification is hard-denied";
|
|
338
341
|
}
|
|
339
342
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
ClassifierIo,
|
|
6
|
+
ClassifierIoAttempt,
|
|
7
|
+
ClassificationDecision,
|
|
8
|
+
DecisionKind,
|
|
9
|
+
} from "./types.ts";
|
|
10
|
+
|
|
11
|
+
/** A final allow/block decision for a tool call. */
|
|
12
|
+
export type DecisionLogEntry = {
|
|
13
|
+
type: "decision";
|
|
14
|
+
ts: string;
|
|
15
|
+
decisionId: string;
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
cwd: string;
|
|
18
|
+
tool: string;
|
|
19
|
+
summary: string;
|
|
20
|
+
kind: DecisionKind;
|
|
21
|
+
outcome: "allow" | "block";
|
|
22
|
+
reason: string;
|
|
23
|
+
classifierModel?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** The classifier prompt, raw responses, and parsed decision for one action. */
|
|
27
|
+
export type ClassifierLogEntry = {
|
|
28
|
+
type: "classifier";
|
|
29
|
+
ts: string;
|
|
30
|
+
decisionId: string;
|
|
31
|
+
model: string;
|
|
32
|
+
prompt: ClassifierIo["prompt"];
|
|
33
|
+
attempts: ClassifierIoAttempt[];
|
|
34
|
+
durationMs: number;
|
|
35
|
+
parsed: ClassificationDecision;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** A ccusage-compatible record for one classifier model response. */
|
|
39
|
+
export type ClassifierUsageLogEntry = {
|
|
40
|
+
type: "message";
|
|
41
|
+
timestamp: string;
|
|
42
|
+
message: {
|
|
43
|
+
role: "assistant";
|
|
44
|
+
model: string;
|
|
45
|
+
usage: NonNullable<ClassifierIoAttempt["response"]>["usage"];
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type LogEntry =
|
|
50
|
+
| DecisionLogEntry
|
|
51
|
+
| ClassifierLogEntry
|
|
52
|
+
| ClassifierUsageLogEntry;
|
|
53
|
+
|
|
54
|
+
export type Logger = {
|
|
55
|
+
enabled: boolean;
|
|
56
|
+
classifierIo: boolean;
|
|
57
|
+
append(entry: LogEntry): void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type LoggerOptions = {
|
|
61
|
+
enabled: boolean;
|
|
62
|
+
classifierIo: boolean;
|
|
63
|
+
sessionFile?: string;
|
|
64
|
+
sessionDir: string;
|
|
65
|
+
sessionId: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Short id linking a classifier entry to its decision entry in the same file. */
|
|
69
|
+
export function newDecisionId(): string {
|
|
70
|
+
return randomBytes(4).toString("hex");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Derive the log file path from the current session: the session file's
|
|
75
|
+
* directory with `-pi-automode` inserted before the extension. Falls back to
|
|
76
|
+
* `<sessionDir>/<sessionId>-pi-automode.jsonl` when no session file is set.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveLogPath(
|
|
79
|
+
sessionFile: string | undefined,
|
|
80
|
+
sessionDir: string,
|
|
81
|
+
sessionId: string,
|
|
82
|
+
): string {
|
|
83
|
+
if (sessionFile) {
|
|
84
|
+
const ext = extname(sessionFile);
|
|
85
|
+
const stem = ext ? basename(sessionFile, ext) : basename(sessionFile);
|
|
86
|
+
return join(dirname(sessionFile), `${stem}-pi-automode${ext}`);
|
|
87
|
+
}
|
|
88
|
+
return join(sessionDir, `${sessionId}-pi-automode.jsonl`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Append one JSON object as a line. Failures are swallowed: logging must
|
|
92
|
+
* never change a safety decision. */
|
|
93
|
+
function appendJsonl(path: string, entry: unknown): void {
|
|
94
|
+
try {
|
|
95
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
96
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
// Fail open.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Build a logger bound to one session's log path. No-ops when disabled. */
|
|
103
|
+
export function createLogger(opts: LoggerOptions): Logger {
|
|
104
|
+
const { enabled, classifierIo } = opts;
|
|
105
|
+
const path = resolveLogPath(opts.sessionFile, opts.sessionDir, opts.sessionId);
|
|
106
|
+
return {
|
|
107
|
+
enabled,
|
|
108
|
+
classifierIo,
|
|
109
|
+
append(entry) {
|
|
110
|
+
if (!enabled) return;
|
|
111
|
+
if (entry.type === "classifier" && !classifierIo) return;
|
|
112
|
+
appendJsonl(path, entry);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { realpathSync } from "node:fs";
|
|
1
|
+
import { lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
2
2
|
import {
|
|
3
3
|
basename,
|
|
4
4
|
dirname,
|
|
5
5
|
isAbsolute,
|
|
6
|
-
join,
|
|
7
6
|
normalize,
|
|
8
7
|
relative,
|
|
9
8
|
resolve,
|
|
@@ -34,53 +33,85 @@ export function isInside(child: string, parent: string): boolean {
|
|
|
34
33
|
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
|
|
36
|
+
/** Resolve symlinks through the nearest existing ancestor of a path. */
|
|
37
|
+
export function resolvePathForPolicy(path: string): string | undefined {
|
|
38
|
+
return resolvePathForPolicyInner(resolve(path), new Set<string>());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolvePathForPolicyInner(
|
|
38
42
|
path: string,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
resolved = realpathSync(path);
|
|
46
|
-
} catch {
|
|
47
|
-
// File doesn't exist yet — try resolving the parent directory.
|
|
43
|
+
visitedSymlinks: Set<string>,
|
|
44
|
+
): string | undefined {
|
|
45
|
+
let current = path;
|
|
46
|
+
const missingSegments: string[] = [];
|
|
47
|
+
|
|
48
|
+
while (true) {
|
|
48
49
|
try {
|
|
49
|
-
|
|
50
|
-
const base = basename(path);
|
|
51
|
-
resolved = join(realpathSync(dir), base);
|
|
50
|
+
return resolve(realpathSync(current), ...missingSegments);
|
|
52
51
|
} catch {
|
|
53
|
-
|
|
52
|
+
try {
|
|
53
|
+
const stat = lstatSync(current);
|
|
54
|
+
if (!stat.isSymbolicLink() || visitedSymlinks.has(current)) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
visitedSymlinks.add(current);
|
|
58
|
+
const target = resolve(dirname(current), readlinkSync(current));
|
|
59
|
+
return resolvePathForPolicyInner(
|
|
60
|
+
resolve(target, ...missingSegments),
|
|
61
|
+
visitedSymlinks,
|
|
62
|
+
);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const code = error && typeof error === "object" && "code" in error
|
|
65
|
+
? String(error.code)
|
|
66
|
+
: undefined;
|
|
67
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") return undefined;
|
|
68
|
+
const parent = dirname(current);
|
|
69
|
+
if (parent === current) return undefined;
|
|
70
|
+
missingSegments.unshift(basename(current));
|
|
71
|
+
current = parent;
|
|
72
|
+
}
|
|
54
73
|
}
|
|
55
74
|
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function matchesProtectedPath(
|
|
78
|
+
relativePath: string,
|
|
79
|
+
protectedPaths: string[],
|
|
80
|
+
): boolean {
|
|
81
|
+
const normalizedPath = relativePath.replace(/\\/g, "/");
|
|
82
|
+
return protectedPaths.some((pattern) => {
|
|
83
|
+
const normalizedPattern = pattern.replace(/\\/g, "/");
|
|
84
|
+
return normalizedPath === normalizedPattern ||
|
|
85
|
+
normalizedPath.startsWith(`${normalizedPattern}/`);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isProtectedPath(
|
|
90
|
+
path: string,
|
|
91
|
+
cwd: string,
|
|
92
|
+
protectedPaths: string[],
|
|
93
|
+
): boolean {
|
|
94
|
+
// Resolve through the nearest existing ancestor so symlinked directories are
|
|
95
|
+
// respected even when the final write target does not exist yet.
|
|
96
|
+
const resolved = resolvePathForPolicy(path) ?? path;
|
|
97
|
+
const resolvedCwd = resolvePathForPolicy(cwd) ?? cwd;
|
|
56
98
|
|
|
57
99
|
// For paths inside the project: use relative path for matching.
|
|
58
|
-
if (resolved
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
relativePath.startsWith(`${pattern}/`)
|
|
64
|
-
) {
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return false;
|
|
100
|
+
if (isInside(resolved, resolvedCwd)) {
|
|
101
|
+
return matchesProtectedPath(
|
|
102
|
+
relative(resolvedCwd, resolved),
|
|
103
|
+
protectedPaths,
|
|
104
|
+
);
|
|
69
105
|
}
|
|
70
106
|
|
|
71
107
|
// For paths outside the project: check every path component suffix.
|
|
72
108
|
// This catches writes like ../other-project/.git/config even when cwd
|
|
73
109
|
// doesn't contain the target.
|
|
74
|
-
const
|
|
110
|
+
const normalizedResolved = resolved.replace(/\\/g, "/");
|
|
111
|
+
const segments = normalizedResolved.split("/").filter(Boolean);
|
|
75
112
|
for (let i = 0; i < segments.length; i++) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
suffix === pattern ||
|
|
80
|
-
suffix.startsWith(`${pattern}/`)
|
|
81
|
-
) {
|
|
82
|
-
return true;
|
|
83
|
-
}
|
|
113
|
+
if (matchesProtectedPath(segments.slice(i).join("/"), protectedPaths)) {
|
|
114
|
+
return true;
|
|
84
115
|
}
|
|
85
116
|
}
|
|
86
117
|
return false;
|