pi-mono-all 1.2.5 → 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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 1.2.5
4
10
 
5
11
  ### Patch Changes
@@ -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
- pathAccessGrantForChoice,
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
- const CHOICES: Array<{ value: GrantChoice; label: string }> = [
23
- { value: "allow_once", label: "Allow once" },
24
- { value: "allow_file_session", label: "Allow file this session" },
25
- { value: "allow_directory_session", label: "Allow directory this session" },
26
- { value: "allow_file_always", label: "Allow file always" },
27
- { value: "allow_directory_always", label: "Allow directory always" },
28
- { value: "deny", label: "Deny" },
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 check = checkPathAccess(absolutePath, ctx.cwd, config.pathAccess.allowedPaths);
49
- if (check.allowed) return;
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 reason = check.reason;
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
- `Path: ${absolutePath}`,
129
+ ...pathLines,
61
130
  `Project: ${ctx.cwd}`,
62
131
  "",
63
132
  "Allow access?",
64
133
  ].join("\n"),
65
- CHOICES.map((choice) => choice.label),
134
+ choices.map((choice) => choice.label),
66
135
  );
67
- const choice = CHOICES.find((item) => item.label === selectedLabel)?.value ?? "deny";
136
+ const choice = choices.find((item) => item.label === selectedLabel)?.value ?? "deny";
68
137
 
69
138
  if (choice === "allow_once") return;
70
139
 
71
- const grant = pathAccessGrantForChoice(choice, absolutePath, ctx.cwd);
72
- if (grant) {
73
- if (isTooBroadGrant(grant.broadCheckPath)) {
74
- return { block: true, reason: `[sentinel] Refusing overly broad ${grant.directory ? "directory" : "path"} grant.` };
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 result = await guardPath(config, absolutePath, "bash", event.input, ctx);
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 choice = await ctx.ui.select(title, [
136
- "Allow once",
137
- "Always allow this path",
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 === "Allow once") {
178
+ if (choice?.value === "allow_once") {
142
179
  return;
143
180
  }
144
181
 
145
- if (choice === "Always allow this path") {
146
- session.addToWhitelist(absolute);
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
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mono-sentinel",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "Pi extension that guards against content-based secret leaks and indirect script execution",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -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 this.whitelist.has(absolutePath);
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(absolutePath: string): void {
84
- this.whitelist.add(absolutePath);
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.5",
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
17
  "pi-mono-figma": "0.2.2",
19
- "pi-common": "0.1.1",
20
- "pi-mono-linear": "0.2.4",
18
+ "pi-mono-context-guard": "1.7.3",
21
19
  "pi-mono-loop": "1.7.3",
20
+ "pi-mono-multi-edit": "1.7.3",
21
+ "pi-mono-linear": "0.2.4",
22
22
  "pi-mono-simplify": "1.7.3",
23
- "pi-mono-status-line": "1.7.3",
24
- "pi-mono-sentinel": "1.12.0",
25
- "pi-mono-review": "1.8.2",
23
+ "pi-mono-sentinel": "1.13.0",
26
24
  "pi-mono-team-mode": "2.3.2",
27
25
  "pi-mono-usage": "0.1.1",
28
26
  "pi-mono-web-search": "0.1.0",
29
- "pi-mono-multi-edit": "1.7.3"
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",