pi-mono-all 1.2.4 → 1.2.6
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/CHANGELOG.md +12 -0
- package/node_modules/pi-mono-linear/CHANGELOG.md +6 -0
- package/node_modules/pi-mono-linear/README.md +17 -0
- package/node_modules/pi-mono-linear/package.json +1 -1
- package/node_modules/pi-mono-linear/skills/linear/SKILL.md +10 -0
- package/node_modules/pi-mono-linear/src/linear-client.ts +146 -0
- package/node_modules/pi-mono-linear/src/linear-markdown-images.ts +91 -0
- package/node_modules/pi-mono-linear/src/linear-schemas.ts +7 -1
- package/node_modules/pi-mono-linear/src/linear-tools.ts +176 -4
- package/node_modules/pi-mono-sentinel/CHANGELOG.md +10 -0
- package/node_modules/pi-mono-sentinel/__tests__/path-access.test.ts +21 -0
- package/node_modules/pi-mono-sentinel/__tests__/whitelist.test.ts +11 -0
- package/node_modules/pi-mono-sentinel/guards/path-access.ts +102 -21
- package/node_modules/pi-mono-sentinel/guards/permission-gate.ts +47 -8
- package/node_modules/pi-mono-sentinel/package.json +1 -1
- package/node_modules/pi-mono-sentinel/path-access.ts +18 -0
- package/node_modules/pi-mono-sentinel/session.ts +15 -3
- package/package.json +9 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# pi-mono-all
|
|
2
2
|
|
|
3
|
+
## 1.2.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Bundle `pi-mono-sentinel@1.13.0` with smarter path-access prompts, session-scoped grants, and exact multi-file grants.
|
|
8
|
+
|
|
9
|
+
## 1.2.5
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Bundle `pi-mono-linear@0.2.4` with automatic Markdown description image reading for `linear_get_issue` on vision-capable models.
|
|
14
|
+
|
|
3
15
|
## 1.2.4
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# pi-mono-linear
|
|
2
2
|
|
|
3
|
+
## 0.2.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Added automatic Markdown description image reading to `linear_get_issue` for vision-capable models. Private `uploads.linear.app` images are streamed in memory with Linear auth and attached as native image blocks; text-only models skip downloads with a clear note.
|
|
8
|
+
|
|
3
9
|
## 0.2.3
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
|
@@ -132,11 +132,28 @@ Linear API keys are sent in the `Authorization` header as the raw key value; do
|
|
|
132
132
|
- `linear_create_issue` accepts either a team UUID or a team key; keys are resolved to UUIDs before the Linear mutation.
|
|
133
133
|
- Use `linear_search_issues` for keyword lookup.
|
|
134
134
|
- Use `linear_get_issue` before updating an issue or creating a comment.
|
|
135
|
+
- `linear_get_issue` automatically parses Markdown images embedded in the issue description. When the active model supports image input, private `uploads.linear.app` images are downloaded in memory with Linear auth and attached to the tool result; images are not written to disk.
|
|
136
|
+
- If the active model does not support image input, `linear_get_issue` does not download images and returns a clear skipped-images note plus image metadata.
|
|
135
137
|
- Use `linear_list_issues` for filtered issue lists by team, assignee, status, and limit.
|
|
136
138
|
- Use `linear_upload_file` to upload a local image, video, or generic file and return a Linear asset URL.
|
|
137
139
|
- Use `linear_upload_file_to_issue_comment` after `linear_get_issue` to upload a local file and post a Markdown comment. Images are rendered with image Markdown; other files use links.
|
|
138
140
|
- File upload tool results return sanitized metadata and the stable Linear asset URL. They do not return local file bytes, signed upload URLs, or upload headers.
|
|
139
141
|
|
|
142
|
+
## Issue description images
|
|
143
|
+
|
|
144
|
+
Linear issue screenshots embedded in descriptions are stored as Markdown image links, for example:
|
|
145
|
+
|
|
146
|
+
```md
|
|
147
|
+

|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
These are part of the issue description Markdown, not GraphQL attachment objects. `linear_get_issue` reads them automatically when possible:
|
|
151
|
+
|
|
152
|
+
- Vision-capable model: images are streamed into memory, base64-encoded, and returned as native image blocks alongside the Markdown description.
|
|
153
|
+
- Text-only model: image downloads are skipped to avoid unnecessary network and payload work; the response includes a note explaining that a vision-capable model is required.
|
|
154
|
+
- No files are saved locally by default.
|
|
155
|
+
- The default safety limits are 10 description images and 10 MiB per image.
|
|
156
|
+
|
|
140
157
|
## Troubleshooting
|
|
141
158
|
|
|
142
159
|
### Missing auth key
|
|
@@ -27,6 +27,8 @@ If auth is missing, invalid, or expired, do **not** ask the user to paste the ke
|
|
|
27
27
|
- Use `linear_workspace_metadata` first when team/project/state/label/user IDs are unknown.
|
|
28
28
|
- Use `linear_search_issues` for keyword lookup.
|
|
29
29
|
- Use `linear_get_issue` before updating or commenting.
|
|
30
|
+
- Use `linear_get_issue` to inspect Linear issues; Markdown images embedded in the issue description are automatically included when the active model supports image input.
|
|
31
|
+
- If `linear_get_issue` reports description images were skipped because the current model does not support images, ask the user to switch to a vision-capable model before interpreting screenshots.
|
|
30
32
|
- Use `linear_list_issues` for filtered issue lists by team, assignee, status, or limit.
|
|
31
33
|
- Use `linear_create_issue` to create issues once the team ID is known.
|
|
32
34
|
- Use `linear_update_issue` to change title, description, priority, state, or assignee.
|
|
@@ -76,6 +78,14 @@ If auth is missing, invalid, or expired, do **not** ask the user to paste the ke
|
|
|
76
78
|
- `linear_upload_file`
|
|
77
79
|
- `linear_upload_file_to_issue_comment`
|
|
78
80
|
|
|
81
|
+
## Issue Description Images
|
|
82
|
+
|
|
83
|
+
- Linear issue screenshots embedded in descriptions are Markdown images, not necessarily GraphQL attachment objects.
|
|
84
|
+
- `linear_get_issue` parses description Markdown image links like ``.
|
|
85
|
+
- When the active model supports image input, `linear_get_issue` downloads private Linear image URLs in memory with Linear auth and attaches native image blocks to the result.
|
|
86
|
+
- When the active model is text-only, image downloads are skipped and the tool returns a note plus image metadata.
|
|
87
|
+
- Description images are not written to disk by default.
|
|
88
|
+
|
|
79
89
|
## File Upload Notes
|
|
80
90
|
|
|
81
91
|
- Upload tools accept local file paths only; they do not read folders recursively.
|
|
@@ -5,6 +5,7 @@ import { createHttpClient, type HttpClient } from "pi-common/http-client";
|
|
|
5
5
|
import { createRateLimiter, type RateLimiter } from "pi-common/rate-limiter";
|
|
6
6
|
import { readFile, stat } from "node:fs/promises";
|
|
7
7
|
import { basename, extname, resolve } from "node:path";
|
|
8
|
+
import type { MarkdownImageReference } from "./linear-markdown-images.js";
|
|
8
9
|
import * as queries from "./linear-queries.js";
|
|
9
10
|
|
|
10
11
|
export interface LinearClientOptions {
|
|
@@ -46,6 +47,20 @@ export interface UploadFileInput {
|
|
|
46
47
|
makePublic?: boolean;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
export interface DownloadIssueImageInput {
|
|
51
|
+
reference: MarkdownImageReference;
|
|
52
|
+
maxBytes?: number;
|
|
53
|
+
signal?: AbortSignal;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DownloadedIssueImageResult {
|
|
57
|
+
reference: MarkdownImageReference;
|
|
58
|
+
filename: string;
|
|
59
|
+
mimeType: string;
|
|
60
|
+
size: number;
|
|
61
|
+
data: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
interface UploadFileHeader {
|
|
50
65
|
key: string;
|
|
51
66
|
value: string;
|
|
@@ -98,6 +113,8 @@ interface GraphQlResponse<T> {
|
|
|
98
113
|
|
|
99
114
|
const cache = createTtlCache<unknown>({ defaultTtlMs: 60_000, maxEntries: 100 });
|
|
100
115
|
const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
116
|
+
const DEFAULT_MAX_DOWNLOAD_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
117
|
+
const SUPPORTED_INLINE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
|
|
101
118
|
|
|
102
119
|
export class LinearClient {
|
|
103
120
|
private readonly http: HttpClient;
|
|
@@ -224,6 +241,10 @@ export class LinearClient {
|
|
|
224
241
|
};
|
|
225
242
|
}
|
|
226
243
|
|
|
244
|
+
async downloadIssueImage(input: DownloadIssueImageInput): Promise<DownloadedIssueImageResult> {
|
|
245
|
+
return downloadIssueImage(input);
|
|
246
|
+
}
|
|
247
|
+
|
|
227
248
|
listCycles(teamId?: string): Promise<unknown> {
|
|
228
249
|
return teamId
|
|
229
250
|
? this.cached(`teamCycles:${teamId}`, () => this.graphql(queries.LIST_TEAM_CYCLES, { id: teamId }))
|
|
@@ -344,6 +365,131 @@ async function safeUploadBody(response: Response): Promise<unknown> {
|
|
|
344
365
|
}
|
|
345
366
|
}
|
|
346
367
|
|
|
368
|
+
async function downloadIssueImage(input: DownloadIssueImageInput): Promise<DownloadedIssueImageResult> {
|
|
369
|
+
const url = validateLinearImageUrl(input.reference.url);
|
|
370
|
+
const maxBytes = input.maxBytes ?? DEFAULT_MAX_DOWNLOAD_IMAGE_BYTES;
|
|
371
|
+
if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
|
|
372
|
+
throw new ApiError("maxImageBytes must be a positive number", 400, { maxBytes }, "Linear");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const token = await readLinearToken();
|
|
376
|
+
const response = await downloadLinearFile(url, token, input.signal);
|
|
377
|
+
if (!response.ok) {
|
|
378
|
+
throw new ApiError(response.statusText || `Image download failed with HTTP ${response.status}`, response.status, await safeUploadBody(response), "Linear");
|
|
379
|
+
}
|
|
380
|
+
if (!response.body) {
|
|
381
|
+
throw new ApiError("Image download response did not include a body", 502, { url }, "Linear");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const contentLength = response.headers.get("content-length");
|
|
385
|
+
if (contentLength && Number(contentLength) > maxBytes) {
|
|
386
|
+
throw new ApiError(`Image is too large (${contentLength} bytes). Limit is ${maxBytes} bytes.`, 400, { size: Number(contentLength), maxBytes }, "Linear");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const bytes = await readResponseBytes(response.body, maxBytes);
|
|
390
|
+
const mimeType = detectSupportedImageMimeType(bytes, response.headers.get("content-type"));
|
|
391
|
+
if (!mimeType) {
|
|
392
|
+
throw new ApiError("Downloaded file is not a supported inline image", 415, { contentType: response.headers.get("content-type"), supportedTypes: [...SUPPORTED_INLINE_IMAGE_TYPES] }, "Linear");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
reference: input.reference,
|
|
397
|
+
filename: filenameForImageUrl(url, mimeType, input.reference.index),
|
|
398
|
+
mimeType,
|
|
399
|
+
size: bytes.byteLength,
|
|
400
|
+
data: Buffer.from(bytes).toString("base64"),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function downloadLinearFile(url: string, token: string, signal?: AbortSignal): Promise<Response> {
|
|
405
|
+
const normalizedToken = token.replace(/^Bearer\s+/i, "").trim();
|
|
406
|
+
const rawResponse = await fetch(url, { method: "GET", headers: { Authorization: normalizedToken }, signal });
|
|
407
|
+
if (rawResponse.status !== 401 && rawResponse.status !== 403) return rawResponse;
|
|
408
|
+
|
|
409
|
+
// Linear personal API keys use `Authorization: <API_KEY>`, while OAuth access
|
|
410
|
+
// tokens use `Authorization: Bearer <ACCESS_TOKEN>`. The extension normally
|
|
411
|
+
// stores personal API keys, but this fallback keeps file reads compatible with
|
|
412
|
+
// OAuth-style tokens without requiring a separate auth configuration.
|
|
413
|
+
await rawResponse.body?.cancel().catch(() => undefined);
|
|
414
|
+
return fetch(url, { method: "GET", headers: { Authorization: `Bearer ${normalizedToken}` }, signal });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function validateLinearImageUrl(value: string): string {
|
|
418
|
+
let url: URL;
|
|
419
|
+
try {
|
|
420
|
+
url = new URL(value);
|
|
421
|
+
} catch {
|
|
422
|
+
throw new ApiError("Invalid image URL in Linear issue description", 400, { url: value }, "Linear");
|
|
423
|
+
}
|
|
424
|
+
if (url.protocol !== "https:") {
|
|
425
|
+
throw new ApiError("Only HTTPS Linear image URLs are supported", 400, { url: value }, "Linear");
|
|
426
|
+
}
|
|
427
|
+
if (url.hostname !== "uploads.linear.app") {
|
|
428
|
+
throw new ApiError("Only uploads.linear.app issue description images are supported", 400, { url: value, hostname: url.hostname }, "Linear");
|
|
429
|
+
}
|
|
430
|
+
return url.toString();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function readResponseBytes(body: ReadableStream<Uint8Array>, maxBytes: number): Promise<Uint8Array> {
|
|
434
|
+
const reader = body.getReader();
|
|
435
|
+
const chunks: Uint8Array[] = [];
|
|
436
|
+
let total = 0;
|
|
437
|
+
try {
|
|
438
|
+
while (true) {
|
|
439
|
+
const { done, value } = await reader.read();
|
|
440
|
+
if (done) break;
|
|
441
|
+
if (!value) continue;
|
|
442
|
+
total += value.byteLength;
|
|
443
|
+
if (total > maxBytes) {
|
|
444
|
+
throw new ApiError(`Image is too large. Limit is ${maxBytes} bytes.`, 400, { size: total, maxBytes }, "Linear");
|
|
445
|
+
}
|
|
446
|
+
chunks.push(value);
|
|
447
|
+
}
|
|
448
|
+
} finally {
|
|
449
|
+
reader.releaseLock();
|
|
450
|
+
}
|
|
451
|
+
return Buffer.concat(chunks, total);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function detectSupportedImageMimeType(bytes: Uint8Array, contentType: string | null): string | undefined {
|
|
455
|
+
const magic = detectImageMimeTypeFromMagicBytes(bytes);
|
|
456
|
+
if (magic && SUPPORTED_INLINE_IMAGE_TYPES.has(magic)) return magic;
|
|
457
|
+
|
|
458
|
+
const normalized = contentType?.split(";")[0]?.trim().toLowerCase();
|
|
459
|
+
if (normalized && SUPPORTED_INLINE_IMAGE_TYPES.has(normalized)) return normalized;
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function detectImageMimeTypeFromMagicBytes(bytes: Uint8Array): string | undefined {
|
|
464
|
+
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) return "image/png";
|
|
465
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
466
|
+
if (bytes.length >= 6) {
|
|
467
|
+
const signature = Buffer.from(bytes.slice(0, 6)).toString("ascii");
|
|
468
|
+
if (signature === "GIF87a" || signature === "GIF89a") return "image/gif";
|
|
469
|
+
}
|
|
470
|
+
if (bytes.length >= 12) {
|
|
471
|
+
const riff = Buffer.from(bytes.slice(0, 4)).toString("ascii");
|
|
472
|
+
const webp = Buffer.from(bytes.slice(8, 12)).toString("ascii");
|
|
473
|
+
if (riff === "RIFF" && webp === "WEBP") return "image/webp";
|
|
474
|
+
}
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function filenameForImageUrl(url: string, mimeType: string, index: number): string {
|
|
479
|
+
const pathname = new URL(url).pathname;
|
|
480
|
+
const leaf = basename(pathname);
|
|
481
|
+
const extension = extensionForMimeType(mimeType);
|
|
482
|
+
if (leaf && leaf.includes(".")) return leaf;
|
|
483
|
+
return `linear-description-image-${index}.${extension}`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function extensionForMimeType(mimeType: string): string {
|
|
487
|
+
if (mimeType === "image/jpeg") return "jpg";
|
|
488
|
+
if (mimeType === "image/webp") return "webp";
|
|
489
|
+
if (mimeType === "image/gif") return "gif";
|
|
490
|
+
return "png";
|
|
491
|
+
}
|
|
492
|
+
|
|
347
493
|
function inferContentType(filePath: string): string {
|
|
348
494
|
const extension = extname(filePath).toLowerCase();
|
|
349
495
|
return CONTENT_TYPES[extension] ?? "application/octet-stream";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export interface MarkdownImageReference {
|
|
2
|
+
index: number;
|
|
3
|
+
source: "description";
|
|
4
|
+
altText: string;
|
|
5
|
+
url: string;
|
|
6
|
+
rawMarkdown: string;
|
|
7
|
+
line: number;
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
contextSnippet: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ExtractMarkdownImagesOptions {
|
|
14
|
+
source?: MarkdownImageReference["source"];
|
|
15
|
+
contextLines?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(\s*(<[^>]+>|[^\s)]+)(?:\s+["'][^"']*["'])?\s*\)/g;
|
|
19
|
+
|
|
20
|
+
export function extractMarkdownImages(markdown: string, options: ExtractMarkdownImagesOptions = {}): MarkdownImageReference[] {
|
|
21
|
+
const source = options.source ?? "description";
|
|
22
|
+
const contextLines = options.contextLines ?? 2;
|
|
23
|
+
const lineStarts = getLineStarts(markdown);
|
|
24
|
+
const lines = markdown.split("\n");
|
|
25
|
+
const references: MarkdownImageReference[] = [];
|
|
26
|
+
|
|
27
|
+
for (const match of markdown.matchAll(MARKDOWN_IMAGE_RE)) {
|
|
28
|
+
const rawMarkdown = match[0];
|
|
29
|
+
const rawUrl = match[2] ?? "";
|
|
30
|
+
const url = unwrapMarkdownUrl(rawUrl);
|
|
31
|
+
if (!url) continue;
|
|
32
|
+
|
|
33
|
+
const start = match.index ?? 0;
|
|
34
|
+
const end = start + rawMarkdown.length;
|
|
35
|
+
const line = lineForOffset(lineStarts, start);
|
|
36
|
+
references.push({
|
|
37
|
+
index: references.length + 1,
|
|
38
|
+
source,
|
|
39
|
+
altText: unescapeMarkdownText(match[1] ?? ""),
|
|
40
|
+
url,
|
|
41
|
+
rawMarkdown,
|
|
42
|
+
line,
|
|
43
|
+
start,
|
|
44
|
+
end,
|
|
45
|
+
contextSnippet: buildContextSnippet(lines, line, contextLines),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return references;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function unwrapMarkdownUrl(rawUrl: string): string {
|
|
53
|
+
const trimmed = rawUrl.trim();
|
|
54
|
+
if (trimmed.startsWith("<") && trimmed.endsWith(">")) return trimmed.slice(1, -1).trim();
|
|
55
|
+
return trimmed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function unescapeMarkdownText(value: string): string {
|
|
59
|
+
return value.replace(/\\([\\\[\]])/g, "$1");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getLineStarts(text: string): number[] {
|
|
63
|
+
const starts = [0];
|
|
64
|
+
for (let index = 0; index < text.length; index++) {
|
|
65
|
+
if (text[index] === "\n") starts.push(index + 1);
|
|
66
|
+
}
|
|
67
|
+
return starts;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function lineForOffset(lineStarts: number[], offset: number): number {
|
|
71
|
+
let low = 0;
|
|
72
|
+
let high = lineStarts.length - 1;
|
|
73
|
+
while (low <= high) {
|
|
74
|
+
const mid = Math.floor((low + high) / 2);
|
|
75
|
+
const start = lineStarts[mid] ?? 0;
|
|
76
|
+
const next = lineStarts[mid + 1] ?? Number.POSITIVE_INFINITY;
|
|
77
|
+
if (offset >= start && offset < next) return mid + 1;
|
|
78
|
+
if (offset < start) high = mid - 1;
|
|
79
|
+
else low = mid + 1;
|
|
80
|
+
}
|
|
81
|
+
return lineStarts.length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildContextSnippet(lines: string[], line: number, contextLines: number): string {
|
|
85
|
+
const start = Math.max(1, line - contextLines);
|
|
86
|
+
const end = Math.min(lines.length, line + contextLines);
|
|
87
|
+
return lines
|
|
88
|
+
.slice(start - 1, end)
|
|
89
|
+
.map((text, index) => `${start + index}: ${text}`)
|
|
90
|
+
.join("\n");
|
|
91
|
+
}
|
|
@@ -15,7 +15,13 @@ export const OptionalTeamParams = Type.Object({ teamId: Type.Optional(TeamIdSche
|
|
|
15
15
|
export const IdParams = Type.Object({ id: Type.String({ description: "Linear object ID" }), maxResponseChars: MaxResponseCharsSchema });
|
|
16
16
|
|
|
17
17
|
export const LinearGetTeamParams = Type.Object({ teamId: TeamIdSchema, maxResponseChars: MaxResponseCharsSchema });
|
|
18
|
-
export const LinearGetIssueParams = Type.Object({
|
|
18
|
+
export const LinearGetIssueParams = Type.Object({
|
|
19
|
+
issueId: IssueIdSchema,
|
|
20
|
+
readDescriptionImages: Type.Optional(Type.Boolean({ description: "Read Markdown images embedded in the issue description when the active model supports image input. Defaults to true." })),
|
|
21
|
+
maxDescriptionImages: Type.Optional(Type.Number({ description: "Maximum number of description images to read. Defaults to 10.", minimum: 1, maximum: 50 })),
|
|
22
|
+
maxImageBytes: Type.Optional(Type.Number({ description: "Maximum bytes per description image before download is stopped. Defaults to 10 MiB.", minimum: 1 })),
|
|
23
|
+
maxResponseChars: MaxResponseCharsSchema,
|
|
24
|
+
});
|
|
19
25
|
export const LinearGetProjectParams = Type.Object({ projectId: ProjectIdSchema, maxResponseChars: MaxResponseCharsSchema });
|
|
20
26
|
export const LinearGetUserParams = Type.Object({ userId: UserIdSchema, maxResponseChars: MaxResponseCharsSchema });
|
|
21
27
|
export const LinearGetDocumentParams = Type.Object({ documentId: Type.String({ description: "Linear document UUID" }), maxResponseChars: MaxResponseCharsSchema });
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { registerAuthConfigurator, runWithAuthRetry, type AuthConfiguratorOptions } from "pi-common/auth-config";
|
|
3
3
|
import { jsonToolResult } from "pi-common/tool-result";
|
|
4
|
-
import { LinearClient, type UploadedFileResult } from "./linear-client.js";
|
|
4
|
+
import { LinearClient, type DownloadedIssueImageResult, type UploadedFileResult } from "./linear-client.js";
|
|
5
|
+
import { extractMarkdownImages, type MarkdownImageReference } from "./linear-markdown-images.js";
|
|
5
6
|
import {
|
|
6
7
|
EmptyParams,
|
|
7
8
|
LinearCommentsParams,
|
|
@@ -23,6 +24,30 @@ import {
|
|
|
23
24
|
OptionalTeamParams,
|
|
24
25
|
} from "./linear-schemas.js";
|
|
25
26
|
|
|
27
|
+
type LinearToolContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string };
|
|
28
|
+
|
|
29
|
+
interface DescriptionImageManifestEntry {
|
|
30
|
+
index: number;
|
|
31
|
+
source: "description";
|
|
32
|
+
altText: string;
|
|
33
|
+
url: string;
|
|
34
|
+
line: number;
|
|
35
|
+
contextSnippet: string;
|
|
36
|
+
status: "downloaded" | "skipped";
|
|
37
|
+
mimeType?: string;
|
|
38
|
+
size?: number;
|
|
39
|
+
filename?: string;
|
|
40
|
+
reason?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface LinearIssueNodeLike {
|
|
44
|
+
id?: string;
|
|
45
|
+
identifier?: string;
|
|
46
|
+
title?: string;
|
|
47
|
+
url?: string;
|
|
48
|
+
description?: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
const LINEAR_AUTH: AuthConfiguratorOptions = {
|
|
27
52
|
service: "linear",
|
|
28
53
|
displayName: "Linear",
|
|
@@ -57,6 +82,139 @@ function escapeMarkdownAltText(value: string): string {
|
|
|
57
82
|
return value.replaceAll("[", "\\[").replaceAll("]", "\\]");
|
|
58
83
|
}
|
|
59
84
|
|
|
85
|
+
function getIssueNode(result: unknown): LinearIssueNodeLike | undefined {
|
|
86
|
+
if (!result || typeof result !== "object") return undefined;
|
|
87
|
+
const issue = (result as { issue?: unknown }).issue;
|
|
88
|
+
if (!issue || typeof issue !== "object") return undefined;
|
|
89
|
+
return issue as LinearIssueNodeLike;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function modelSupportsImages(ctx: ExtensionContext): boolean {
|
|
93
|
+
return ctx.model?.input?.includes("image") ?? false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildSkippedManifestEntry(reference: MarkdownImageReference, reason: string): DescriptionImageManifestEntry {
|
|
97
|
+
return {
|
|
98
|
+
index: reference.index,
|
|
99
|
+
source: reference.source,
|
|
100
|
+
altText: reference.altText,
|
|
101
|
+
url: reference.url,
|
|
102
|
+
line: reference.line,
|
|
103
|
+
contextSnippet: reference.contextSnippet,
|
|
104
|
+
status: "skipped",
|
|
105
|
+
reason,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildDownloadedManifestEntry(image: DownloadedIssueImageResult): DescriptionImageManifestEntry {
|
|
110
|
+
return {
|
|
111
|
+
index: image.reference.index,
|
|
112
|
+
source: image.reference.source,
|
|
113
|
+
altText: image.reference.altText,
|
|
114
|
+
url: image.reference.url,
|
|
115
|
+
line: image.reference.line,
|
|
116
|
+
contextSnippet: image.reference.contextSnippet,
|
|
117
|
+
status: "downloaded",
|
|
118
|
+
mimeType: image.mimeType,
|
|
119
|
+
size: image.size,
|
|
120
|
+
filename: image.filename,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function errorMessage(error: unknown): string {
|
|
125
|
+
return error instanceof Error ? error.message : String(error);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function appendImageReadingMetadata(result: unknown, imageReading: unknown): unknown {
|
|
129
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) return { result, imageReading };
|
|
130
|
+
return { ...(result as Record<string, unknown>), imageReading };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function issueLabel(issue: LinearIssueNodeLike | undefined): string {
|
|
134
|
+
if (!issue) return "Linear issue";
|
|
135
|
+
return [issue.identifier, issue.title].filter(Boolean).join(" — ") || issue.id || "Linear issue";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function imageNote(reference: MarkdownImageReference, image: DownloadedIssueImageResult): string {
|
|
139
|
+
return [
|
|
140
|
+
`[Description image ${reference.index}: alt=${JSON.stringify(reference.altText || "image")}, line=${reference.line}, mimeType=${image.mimeType}, size=${image.size} bytes]`,
|
|
141
|
+
"Markdown context:",
|
|
142
|
+
reference.contextSnippet,
|
|
143
|
+
].join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function buildIssueResultWithDescriptionImages(options: {
|
|
147
|
+
client: LinearClient;
|
|
148
|
+
result: unknown;
|
|
149
|
+
ctx: ExtensionContext;
|
|
150
|
+
readDescriptionImages?: boolean;
|
|
151
|
+
maxDescriptionImages?: number;
|
|
152
|
+
maxImageBytes?: number;
|
|
153
|
+
maxResponseChars?: number;
|
|
154
|
+
signal?: AbortSignal;
|
|
155
|
+
}): Promise<{ content: LinearToolContent[]; details: Record<string, unknown> }> {
|
|
156
|
+
const issue = getIssueNode(options.result);
|
|
157
|
+
const description = issue?.description ?? "";
|
|
158
|
+
const references = typeof description === "string" ? extractMarkdownImages(description, { source: "description" }) : [];
|
|
159
|
+
const shouldReadImages = options.readDescriptionImages ?? true;
|
|
160
|
+
if (!references.length || !shouldReadImages) {
|
|
161
|
+
return jsonToolResult(options.result, { maxChars: options.maxResponseChars });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const maxImages = options.maxDescriptionImages ?? 10;
|
|
165
|
+
const selected = references.slice(0, maxImages);
|
|
166
|
+
const overflow = references.slice(maxImages);
|
|
167
|
+
const supportsImages = modelSupportsImages(options.ctx);
|
|
168
|
+
const manifest: DescriptionImageManifestEntry[] = [];
|
|
169
|
+
const imageContents: LinearToolContent[] = [];
|
|
170
|
+
|
|
171
|
+
if (!supportsImages) {
|
|
172
|
+
manifest.push(...references.map((reference) => buildSkippedManifestEntry(reference, "model_does_not_support_images")));
|
|
173
|
+
const note = `Description images detected but not downloaded because the current model does not support image input. Switch to a vision-capable model to inspect them.`;
|
|
174
|
+
const data = appendImageReadingMetadata(options.result, {
|
|
175
|
+
modelSupportsImages: false,
|
|
176
|
+
descriptionImagesFound: references.length,
|
|
177
|
+
descriptionImages: manifest,
|
|
178
|
+
note,
|
|
179
|
+
});
|
|
180
|
+
const base = jsonToolResult(data, { maxChars: options.maxResponseChars });
|
|
181
|
+
return {
|
|
182
|
+
content: [...base.content, { type: "text", text: `\n[${note}]` }],
|
|
183
|
+
details: { ...base.details, imageReading: { issue: issue ? { id: issue.id, identifier: issue.identifier, title: issue.title, url: issue.url } : undefined, descriptionImages: manifest } },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const reference of selected) {
|
|
188
|
+
try {
|
|
189
|
+
const image = await options.client.downloadIssueImage({ reference, maxBytes: options.maxImageBytes, signal: options.signal });
|
|
190
|
+
manifest.push(buildDownloadedManifestEntry(image));
|
|
191
|
+
imageContents.push({ type: "text", text: imageNote(reference, image) }, { type: "image", mimeType: image.mimeType, data: image.data });
|
|
192
|
+
} catch (error) {
|
|
193
|
+
manifest.push(buildSkippedManifestEntry(reference, errorMessage(error)));
|
|
194
|
+
imageContents.push({ type: "text", text: `[Description image ${reference.index} skipped: ${errorMessage(error)}]` });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const reference of overflow) {
|
|
198
|
+
manifest.push(buildSkippedManifestEntry(reference, `maxDescriptionImages limit (${maxImages}) reached`));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const data = appendImageReadingMetadata(options.result, {
|
|
202
|
+
modelSupportsImages: true,
|
|
203
|
+
descriptionImagesFound: references.length,
|
|
204
|
+
descriptionImagesRead: manifest.filter((entry) => entry.status === "downloaded").length,
|
|
205
|
+
descriptionImages: manifest,
|
|
206
|
+
});
|
|
207
|
+
const base = jsonToolResult(data, { maxChars: options.maxResponseChars });
|
|
208
|
+
const preamble: LinearToolContent = {
|
|
209
|
+
type: "text",
|
|
210
|
+
text: `\nRead ${manifest.filter((entry) => entry.status === "downloaded").length} of ${references.length} Markdown description image(s) for ${issueLabel(issue)}. Images are attached below in description order.`,
|
|
211
|
+
};
|
|
212
|
+
return {
|
|
213
|
+
content: [...base.content, preamble, ...imageContents],
|
|
214
|
+
details: { ...base.details, imageReading: { issue: issue ? { id: issue.id, identifier: issue.identifier, title: issue.title, url: issue.url } : undefined, descriptionImages: manifest } },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
60
218
|
export function registerLinearTools(pi: ExtensionAPI): void {
|
|
61
219
|
const client = new LinearClient();
|
|
62
220
|
registerAuthConfigurator(pi, LINEAR_AUTH);
|
|
@@ -127,10 +285,24 @@ export function registerLinearTools(pi: ExtensionAPI): void {
|
|
|
127
285
|
pi.registerTool({
|
|
128
286
|
name: "linear_get_issue",
|
|
129
287
|
label: "Linear Get Issue",
|
|
130
|
-
description: "Get full Linear issue details by UUID or identifier like ENG-123.",
|
|
288
|
+
description: "Get full Linear issue details by UUID or identifier like ENG-123. Markdown images embedded in the issue description are read and attached when the active model supports image input.",
|
|
289
|
+
promptGuidelines: [
|
|
290
|
+
"Use linear_get_issue to inspect Linear issues; if the issue description contains Markdown images and the active model supports image input, the images are downloaded in memory and attached to the tool result.",
|
|
291
|
+
"When linear_get_issue reports description images were skipped because the model does not support images, ask the user to switch to a vision-capable model before interpreting screenshots.",
|
|
292
|
+
],
|
|
131
293
|
parameters: LinearGetIssueParams,
|
|
132
|
-
async execute(_id, params,
|
|
133
|
-
|
|
294
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
295
|
+
const result = await withLinearAuth(ctx, () => client.getIssue(params.issueId));
|
|
296
|
+
return buildIssueResultWithDescriptionImages({
|
|
297
|
+
client,
|
|
298
|
+
result,
|
|
299
|
+
ctx,
|
|
300
|
+
readDescriptionImages: params.readDescriptionImages,
|
|
301
|
+
maxDescriptionImages: params.maxDescriptionImages,
|
|
302
|
+
maxImageBytes: params.maxImageBytes,
|
|
303
|
+
maxResponseChars: params.maxResponseChars,
|
|
304
|
+
signal,
|
|
305
|
+
});
|
|
134
306
|
},
|
|
135
307
|
});
|
|
136
308
|
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# pi-mono-sentinel
|
|
2
2
|
|
|
3
|
+
## 1.13.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
### Enhanced: path-access grants
|
|
8
|
+
|
|
9
|
+
- Added smarter path-access prompts that distinguish existing files, new files, directories, and multiple same-folder file targets.
|
|
10
|
+
- Added session-only permission grants for `write` / `edit`, including recursive directory grants that reset with the session.
|
|
11
|
+
- Added exact multi-file grants for bash commands that reference several outside-project files in the same directory.
|
|
12
|
+
|
|
3
13
|
## 1.12.0
|
|
4
14
|
|
|
5
15
|
### Changed
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
directoryGrantFor,
|
|
11
11
|
isInsideCwd,
|
|
12
12
|
isPathAllowed,
|
|
13
|
+
pathAccessGrantsForChoice,
|
|
13
14
|
pathAccessGrantForChoice,
|
|
14
15
|
toStoragePath,
|
|
15
16
|
} from "../path-access.ts";
|
|
@@ -78,4 +79,24 @@ describe("path-access helpers", () => {
|
|
|
78
79
|
rmSync(cwd, { recursive: true, force: true });
|
|
79
80
|
}
|
|
80
81
|
});
|
|
82
|
+
|
|
83
|
+
test("derives grants for multiple exact files", () => {
|
|
84
|
+
assert.deepEqual(
|
|
85
|
+
pathAccessGrantsForChoice("allow_files_session", ["/tmp/a/one.txt", "/tmp/a/two.txt"], CWD),
|
|
86
|
+
[
|
|
87
|
+
{
|
|
88
|
+
grant: "/tmp/a/one.txt",
|
|
89
|
+
broadCheckPath: "/tmp/a/one.txt",
|
|
90
|
+
scope: "memory",
|
|
91
|
+
directory: false,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
grant: "/tmp/a/two.txt",
|
|
95
|
+
broadCheckPath: "/tmp/a/two.txt",
|
|
96
|
+
scope: "memory",
|
|
97
|
+
directory: false,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
);
|
|
101
|
+
});
|
|
81
102
|
});
|
|
@@ -50,6 +50,17 @@ describe("SentinelSession whitelist", () => {
|
|
|
50
50
|
assert.equal(session.isWhitelisted("/persisted/path"), true);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
test("session directory grants are recursive and reset-scoped", () => {
|
|
54
|
+
const session = new SentinelSession();
|
|
55
|
+
session.addToSessionWhitelist("/tmp/sentinel-session-dir/");
|
|
56
|
+
assert.equal(session.isWhitelisted("/tmp/sentinel-session-dir/file.md"), true);
|
|
57
|
+
assert.equal(session.isWhitelisted("/tmp/sentinel-session-dir/nested/file.md"), true);
|
|
58
|
+
assert.equal(session.isWhitelisted("/tmp/sentinel-session-dir-sibling/file.md"), false);
|
|
59
|
+
|
|
60
|
+
session.reset();
|
|
61
|
+
assert.equal(session.isWhitelisted("/tmp/sentinel-session-dir/file.md"), false);
|
|
62
|
+
});
|
|
63
|
+
|
|
53
64
|
test("read whitelist is separate from permission whitelist", () => {
|
|
54
65
|
const session = new SentinelSession();
|
|
55
66
|
session.addToReadWhitelist("/safe/example-doc.md");
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { existsSync, statSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
3
5
|
|
|
4
6
|
import { configLoader, type ResolvedSentinelConfig } from "../config.js";
|
|
5
7
|
import { blockToolCall } from "../events.js";
|
|
6
8
|
import {
|
|
7
9
|
checkPathAccess,
|
|
8
10
|
isTooBroadGrant,
|
|
9
|
-
|
|
11
|
+
pathAccessGrantsForChoice,
|
|
10
12
|
} from "../path-access.js";
|
|
11
13
|
import { extractBashPathCandidates } from "../patterns/bash-paths.js";
|
|
12
14
|
import { resolveTargetPath } from "../patterns/permissions.js";
|
|
@@ -14,19 +16,63 @@ import { resolveTargetPath } from "../patterns/permissions.js";
|
|
|
14
16
|
type GrantChoice =
|
|
15
17
|
| "allow_once"
|
|
16
18
|
| "allow_file_session"
|
|
19
|
+
| "allow_files_session"
|
|
17
20
|
| "allow_directory_session"
|
|
18
21
|
| "allow_file_always"
|
|
22
|
+
| "allow_files_always"
|
|
19
23
|
| "allow_directory_always"
|
|
20
24
|
| "deny";
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
type PathPromptKind = "existing_file" | "new_file" | "directory" | "multiple_files";
|
|
27
|
+
|
|
28
|
+
function choicesForPathPrompt(kind: PathPromptKind): Array<{ value: GrantChoice; label: string }> {
|
|
29
|
+
switch (kind) {
|
|
30
|
+
case "new_file":
|
|
31
|
+
return [
|
|
32
|
+
{ value: "allow_once", label: "Allow once" },
|
|
33
|
+
{ value: "allow_directory_session", label: "Allow creating files in this folder for this session" },
|
|
34
|
+
{ value: "allow_directory_always", label: "Always allow creating files in this folder" },
|
|
35
|
+
{ value: "deny", label: "Deny" },
|
|
36
|
+
];
|
|
37
|
+
case "directory":
|
|
38
|
+
return [
|
|
39
|
+
{ value: "allow_once", label: "Allow once" },
|
|
40
|
+
{ value: "allow_directory_session", label: "Allow this folder for this session" },
|
|
41
|
+
{ value: "allow_directory_always", label: "Always allow this folder" },
|
|
42
|
+
{ value: "deny", label: "Deny" },
|
|
43
|
+
];
|
|
44
|
+
case "multiple_files":
|
|
45
|
+
return [
|
|
46
|
+
{ value: "allow_once", label: "Allow once" },
|
|
47
|
+
{ value: "allow_files_session", label: "Allow these files for this session" },
|
|
48
|
+
{ value: "allow_files_always", label: "Always allow these files" },
|
|
49
|
+
{ value: "deny", label: "Deny" },
|
|
50
|
+
];
|
|
51
|
+
case "existing_file":
|
|
52
|
+
default:
|
|
53
|
+
return [
|
|
54
|
+
{ value: "allow_once", label: "Allow once" },
|
|
55
|
+
{ value: "allow_file_session", label: "Allow this file for this session" },
|
|
56
|
+
{ value: "allow_file_always", label: "Always allow this file" },
|
|
57
|
+
{ value: "deny", label: "Deny" },
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function promptKindForPath(absolutePath: string, toolName: string): PathPromptKind {
|
|
63
|
+
try {
|
|
64
|
+
if (existsSync(absolutePath) && statSync(absolutePath).isDirectory()) return "directory";
|
|
65
|
+
} catch {
|
|
66
|
+
// Fall through to operation-based classification.
|
|
67
|
+
}
|
|
68
|
+
return toolName === "write" && !existsSync(absolutePath) ? "new_file" : "existing_file";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function allInSameDirectory(paths: readonly string[]): boolean {
|
|
72
|
+
if (paths.length < 2) return false;
|
|
73
|
+
const first = dirname(paths[0]);
|
|
74
|
+
return paths.every((path) => dirname(path) === first);
|
|
75
|
+
}
|
|
30
76
|
|
|
31
77
|
const MAX_BASH_PATH_CANDIDATES = 50;
|
|
32
78
|
const TOOL_PATH_NORMALIZERS = {
|
|
@@ -42,38 +88,63 @@ export function registerPathAccess(pi: ExtensionAPI): void {
|
|
|
42
88
|
toolName: string,
|
|
43
89
|
input: Record<string, unknown>,
|
|
44
90
|
ctx: { cwd: string; hasUI: boolean; ui: { select?: (title: string, options: string[]) => Promise<string | undefined> } },
|
|
91
|
+
): Promise<{ block: true; reason: string } | undefined> {
|
|
92
|
+
return guardPaths(config, [absolutePath], toolName, input, ctx);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function guardPaths(
|
|
96
|
+
config: ResolvedSentinelConfig,
|
|
97
|
+
absolutePaths: readonly string[],
|
|
98
|
+
toolName: string,
|
|
99
|
+
input: Record<string, unknown>,
|
|
100
|
+
ctx: { cwd: string; hasUI: boolean; ui: { select?: (title: string, options: string[]) => Promise<string | undefined> } },
|
|
45
101
|
): Promise<{ block: true; reason: string } | undefined> {
|
|
46
102
|
if (!config.features.pathAccess || config.pathAccess.mode === "allow") return;
|
|
47
103
|
|
|
48
|
-
const
|
|
49
|
-
|
|
104
|
+
const denied = absolutePaths
|
|
105
|
+
.map((absolutePath) => checkPathAccess(absolutePath, ctx.cwd, config.pathAccess.allowedPaths))
|
|
106
|
+
.filter((check) => !check.allowed) as Array<{ allowed: false; absolutePath: string; reason: string }>;
|
|
107
|
+
if (denied.length === 0) return;
|
|
50
108
|
|
|
51
|
-
const
|
|
109
|
+
const deniedPaths = denied.map((check) => check.absolutePath);
|
|
110
|
+
const reason = denied.length === 1
|
|
111
|
+
? denied[0].reason
|
|
112
|
+
: `Paths are outside the current working directory: ${deniedPaths.join(", ")}`;
|
|
52
113
|
if (config.pathAccess.mode === "block" || !ctx.hasUI) {
|
|
53
114
|
return blockToolCall(pi, { feature: "pathAccess", toolName, input, reason }, `[sentinel] ${reason}`);
|
|
54
115
|
}
|
|
55
116
|
|
|
117
|
+
const promptKind = deniedPaths.length > 1 && allInSameDirectory(deniedPaths)
|
|
118
|
+
? "multiple_files"
|
|
119
|
+
: promptKindForPath(deniedPaths[0], toolName);
|
|
120
|
+
const choices = choicesForPathPrompt(promptKind);
|
|
121
|
+
const pathLines = deniedPaths.length === 1
|
|
122
|
+
? [`Path: ${deniedPaths[0]}`]
|
|
123
|
+
: ["Paths:", ...deniedPaths.map((path) => ` - ${path}`)];
|
|
124
|
+
|
|
56
125
|
const selectedLabel = await ctx.ui.select?.(
|
|
57
126
|
[
|
|
58
127
|
"[sentinel] Path access outside current project",
|
|
59
128
|
`Tool: ${toolName}`,
|
|
60
|
-
|
|
129
|
+
...pathLines,
|
|
61
130
|
`Project: ${ctx.cwd}`,
|
|
62
131
|
"",
|
|
63
132
|
"Allow access?",
|
|
64
133
|
].join("\n"),
|
|
65
|
-
|
|
134
|
+
choices.map((choice) => choice.label),
|
|
66
135
|
);
|
|
67
|
-
const choice =
|
|
136
|
+
const choice = choices.find((item) => item.label === selectedLabel)?.value ?? "deny";
|
|
68
137
|
|
|
69
138
|
if (choice === "allow_once") return;
|
|
70
139
|
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
140
|
+
const grants = pathAccessGrantsForChoice(choice, deniedPaths, ctx.cwd);
|
|
141
|
+
if (grants.length > 0) {
|
|
142
|
+
for (const grant of grants) {
|
|
143
|
+
if (isTooBroadGrant(grant.broadCheckPath)) {
|
|
144
|
+
return { block: true, reason: `[sentinel] Refusing overly broad ${grant.directory ? "directory" : "path"} grant.` };
|
|
145
|
+
}
|
|
146
|
+
configLoader.addAllowedPath(grant.scope, grant.grant);
|
|
75
147
|
}
|
|
76
|
-
configLoader.addAllowedPath(grant.scope, grant.grant);
|
|
77
148
|
return;
|
|
78
149
|
}
|
|
79
150
|
|
|
@@ -94,8 +165,18 @@ export function registerPathAccess(pi: ExtensionAPI): void {
|
|
|
94
165
|
const command = event.input.command ?? "";
|
|
95
166
|
const config = configLoader.getConfig();
|
|
96
167
|
const candidates = extractBashPathCandidates(command, ctx.cwd).slice(0, MAX_BASH_PATH_CANDIDATES);
|
|
168
|
+
const pendingByDirectory = new Map<string, string[]>();
|
|
97
169
|
for (const absolutePath of candidates) {
|
|
98
|
-
const
|
|
170
|
+
const check = checkPathAccess(absolutePath, ctx.cwd, config.pathAccess.allowedPaths);
|
|
171
|
+
if (check.allowed) continue;
|
|
172
|
+
const paths = pendingByDirectory.get(dirname(absolutePath)) ?? [];
|
|
173
|
+
paths.push(absolutePath);
|
|
174
|
+
pendingByDirectory.set(dirname(absolutePath), paths);
|
|
175
|
+
}
|
|
176
|
+
for (const paths of pendingByDirectory.values()) {
|
|
177
|
+
const result = paths.length > 1
|
|
178
|
+
? await guardPaths(config, paths, "bash", event.input, ctx)
|
|
179
|
+
: await guardPath(config, paths[0], "bash", event.input, ctx);
|
|
99
180
|
if (result) return result;
|
|
100
181
|
}
|
|
101
182
|
});
|
|
@@ -18,9 +18,11 @@ import type {
|
|
|
18
18
|
ExtensionContext,
|
|
19
19
|
} from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { existsSync, statSync } from "node:fs";
|
|
21
22
|
|
|
22
23
|
import { configLoader } from "../config.js";
|
|
23
24
|
import { blockToolCall, emitDangerous } from "../events.js";
|
|
25
|
+
import { directoryGrantFor, toStoragePath } from "../path-access.js";
|
|
24
26
|
import type { SentinelSession } from "../session.js";
|
|
25
27
|
import {
|
|
26
28
|
BASH_RISK_DESCRIPTIONS,
|
|
@@ -100,6 +102,43 @@ function registerBashGate(pi: ExtensionAPI): void {
|
|
|
100
102
|
// Write / edit gating
|
|
101
103
|
// ---------------------------------------------------------------------------
|
|
102
104
|
|
|
105
|
+
type PathGateChoice = "allow_once" | "allow_session" | "allow_always" | "deny";
|
|
106
|
+
|
|
107
|
+
function pathGateChoices(absolutePath: string, toolName: "write" | "edit"): Array<{ value: PathGateChoice; label: string; directoryGrant: boolean }> {
|
|
108
|
+
let isDirectory = false;
|
|
109
|
+
try {
|
|
110
|
+
isDirectory = existsSync(absolutePath) && statSync(absolutePath).isDirectory();
|
|
111
|
+
} catch {
|
|
112
|
+
isDirectory = false;
|
|
113
|
+
}
|
|
114
|
+
const isNewFile = toolName === "write" && !existsSync(absolutePath);
|
|
115
|
+
|
|
116
|
+
if (isNewFile) {
|
|
117
|
+
return [
|
|
118
|
+
{ value: "allow_once", label: "Allow once", directoryGrant: false },
|
|
119
|
+
{ value: "allow_session", label: "Allow creating files in this folder for this session", directoryGrant: true },
|
|
120
|
+
{ value: "allow_always", label: "Always allow creating files in this folder", directoryGrant: true },
|
|
121
|
+
{ value: "deny", label: "Deny", directoryGrant: false },
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isDirectory) {
|
|
126
|
+
return [
|
|
127
|
+
{ value: "allow_once", label: "Allow once", directoryGrant: false },
|
|
128
|
+
{ value: "allow_session", label: "Allow this folder for this session", directoryGrant: true },
|
|
129
|
+
{ value: "allow_always", label: "Always allow this folder", directoryGrant: true },
|
|
130
|
+
{ value: "deny", label: "Deny", directoryGrant: false },
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return [
|
|
135
|
+
{ value: "allow_once", label: "Allow once", directoryGrant: false },
|
|
136
|
+
{ value: "allow_session", label: "Allow this file for this session", directoryGrant: false },
|
|
137
|
+
{ value: "allow_always", label: "Always allow this file", directoryGrant: false },
|
|
138
|
+
{ value: "deny", label: "Deny", directoryGrant: false },
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
|
|
103
142
|
function registerPathGate(pi: ExtensionAPI, session: SentinelSession): void {
|
|
104
143
|
const handler = async (
|
|
105
144
|
rawPath: string | undefined,
|
|
@@ -132,18 +171,18 @@ function registerPathGate(pi: ExtensionAPI, session: SentinelSession): void {
|
|
|
132
171
|
].join("\n");
|
|
133
172
|
|
|
134
173
|
if (ctx.hasUI) {
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
"Deny",
|
|
139
|
-
]);
|
|
174
|
+
const choices = pathGateChoices(absolute, toolName);
|
|
175
|
+
const selectedLabel = await ctx.ui.select(title, choices.map((choice) => choice.label));
|
|
176
|
+
const choice = choices.find((item) => item.label === selectedLabel);
|
|
140
177
|
|
|
141
|
-
if (choice === "
|
|
178
|
+
if (choice?.value === "allow_once") {
|
|
142
179
|
return;
|
|
143
180
|
}
|
|
144
181
|
|
|
145
|
-
if (choice === "
|
|
146
|
-
|
|
182
|
+
if (choice?.value === "allow_session" || choice?.value === "allow_always") {
|
|
183
|
+
const grant = choice.directoryGrant ? directoryGrantFor(absolute) : toStoragePath(absolute);
|
|
184
|
+
if (choice.value === "allow_session") session.addToSessionWhitelist(grant);
|
|
185
|
+
else session.addToWhitelist(grant);
|
|
147
186
|
return;
|
|
148
187
|
}
|
|
149
188
|
|
|
@@ -33,6 +33,24 @@ export function pathAccessGrantForChoice(choice: string, absolutePath: string, c
|
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export function pathAccessGrantsForChoice(choice: string, absolutePaths: readonly string[], cwd: string): Array<{ grant: string; broadCheckPath: string; scope: "memory" | "local"; directory: boolean }> {
|
|
37
|
+
if (absolutePaths.length === 0) return [];
|
|
38
|
+
const match = /^allow_(file|directory|files)_(session|always)$/.exec(choice);
|
|
39
|
+
if (!match) return [];
|
|
40
|
+
|
|
41
|
+
if (match[1] === "files") {
|
|
42
|
+
return absolutePaths.map((absolutePath) => ({
|
|
43
|
+
grant: toStoragePath(absolutePath),
|
|
44
|
+
broadCheckPath: absolutePath,
|
|
45
|
+
scope: match[2] === "always" ? "local" : "memory",
|
|
46
|
+
directory: false,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const grant = pathAccessGrantForChoice(choice, absolutePaths[0], cwd);
|
|
51
|
+
return grant ? [grant] : [];
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
export function isTooBroadGrant(absolutePath: string): boolean {
|
|
37
55
|
const normalized = normalize(absolutePath).replace(/[\\/]+$/, "");
|
|
38
56
|
return normalized === "/" || normalized === homedir();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ScanResult, WriteEntry } from "./types.js";
|
|
2
2
|
import { configLoader } from "./config.js";
|
|
3
|
+
import { isPathAllowed } from "./path-access.js";
|
|
3
4
|
import {
|
|
4
5
|
loadReadWhitelist,
|
|
5
6
|
loadWhitelist,
|
|
@@ -27,6 +28,9 @@ export class SentinelSession {
|
|
|
27
28
|
/** Persistent whitelist of paths the user chose to remember. */
|
|
28
29
|
private whitelist = loadWhitelist();
|
|
29
30
|
|
|
31
|
+
/** Session-only whitelist of paths the user allowed until reset. */
|
|
32
|
+
private sessionWhitelist = new Set<string>();
|
|
33
|
+
|
|
30
34
|
/** Persistent whitelist of read paths that are safe despite secret matches. */
|
|
31
35
|
private readWhitelist = loadReadWhitelist();
|
|
32
36
|
|
|
@@ -34,6 +38,7 @@ export class SentinelSession {
|
|
|
34
38
|
reset(): void {
|
|
35
39
|
this.writeRegistry.clear();
|
|
36
40
|
this.scanCache.clear();
|
|
41
|
+
this.sessionWhitelist.clear();
|
|
37
42
|
// whitelist is intentionally NOT cleared here so it persists across sessions
|
|
38
43
|
}
|
|
39
44
|
|
|
@@ -77,11 +82,18 @@ export class SentinelSession {
|
|
|
77
82
|
// -- Whitelist (permission-gate persistence) -------------------------------
|
|
78
83
|
|
|
79
84
|
isWhitelisted(absolutePath: string): boolean {
|
|
80
|
-
return
|
|
85
|
+
return (
|
|
86
|
+
isPathAllowed(absolutePath, [...this.sessionWhitelist], process.cwd()) ||
|
|
87
|
+
isPathAllowed(absolutePath, [...this.whitelist], process.cwd())
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
addToSessionWhitelist(pathGrant: string): void {
|
|
92
|
+
this.sessionWhitelist.add(pathGrant);
|
|
81
93
|
}
|
|
82
94
|
|
|
83
|
-
addToWhitelist(
|
|
84
|
-
this.whitelist.add(
|
|
95
|
+
addToWhitelist(pathGrant: string): void {
|
|
96
|
+
this.whitelist.add(pathGrant);
|
|
85
97
|
saveWhitelist(this.whitelist);
|
|
86
98
|
}
|
|
87
99
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mono-all",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.6",
|
|
4
4
|
"description": "All pi-mono extensions and bundled skills",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -9,24 +9,24 @@
|
|
|
9
9
|
"pi-skill"
|
|
10
10
|
],
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"pi-mono-auto-fix": "0.3.1",
|
|
13
12
|
"pi-mono-ask-user-question": "1.7.4",
|
|
13
|
+
"pi-mono-auto-fix": "0.3.1",
|
|
14
14
|
"pi-mono-btw": "1.7.4",
|
|
15
15
|
"pi-mono-clear": "1.7.3",
|
|
16
16
|
"pi-mono-context": "0.1.1",
|
|
17
|
-
"pi-mono-context-guard": "1.7.3",
|
|
18
|
-
"pi-mono-linear": "0.2.3",
|
|
19
|
-
"pi-common": "0.1.1",
|
|
20
17
|
"pi-mono-figma": "0.2.2",
|
|
18
|
+
"pi-mono-context-guard": "1.7.3",
|
|
19
|
+
"pi-mono-loop": "1.7.3",
|
|
21
20
|
"pi-mono-multi-edit": "1.7.3",
|
|
22
|
-
"pi-mono-
|
|
21
|
+
"pi-mono-linear": "0.2.4",
|
|
23
22
|
"pi-mono-simplify": "1.7.3",
|
|
24
|
-
"pi-mono-
|
|
23
|
+
"pi-mono-sentinel": "1.13.0",
|
|
25
24
|
"pi-mono-team-mode": "2.3.2",
|
|
26
|
-
"pi-mono-status-line": "1.7.3",
|
|
27
25
|
"pi-mono-usage": "0.1.1",
|
|
28
26
|
"pi-mono-web-search": "0.1.0",
|
|
29
|
-
"pi-mono-
|
|
27
|
+
"pi-mono-review": "1.8.2",
|
|
28
|
+
"pi-common": "0.1.1",
|
|
29
|
+
"pi-mono-status-line": "1.7.3"
|
|
30
30
|
},
|
|
31
31
|
"bundledDependencies": [
|
|
32
32
|
"pi-mono-ask-user-question",
|