@gotgenes/pi-permission-system 4.6.0 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [4.7.0](https://github.com/gotgenes/pi-permission-system/compare/v4.6.0...v4.7.0) (2026-05-05)
9
+
10
+
11
+ ### Features
12
+
13
+ * add bash arity table with prefix lookup ([#52](https://github.com/gotgenes/pi-permission-system/issues/52)) ([56a8e81](https://github.com/gotgenes/pi-permission-system/commit/56a8e81911a5869bceabf9076bca6e1bb709814b))
14
+ * integrate arity table into suggestBashPattern ([#52](https://github.com/gotgenes/pi-permission-system/issues/52)) ([5a3c809](https://github.com/gotgenes/pi-permission-system/commit/5a3c8094319166a8f3bd7c97a68af6b8cd0d0205))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * document bash arity table ([#52](https://github.com/gotgenes/pi-permission-system/issues/52)) ([376ae5c](https://github.com/gotgenes/pi-permission-system/commit/376ae5cdd9d3a9d28f0e26ec26455f44b45b56d7))
20
+ * plan bash arity table for smart approval patterns ([#52](https://github.com/gotgenes/pi-permission-system/issues/52)) ([6a78244](https://github.com/gotgenes/pi-permission-system/commit/6a78244b7552563e331bb2d426aa0abd35ef9065))
21
+ * **retro:** add retro notes for issue [#60](https://github.com/gotgenes/pi-permission-system/issues/60) ([54ed04a](https://github.com/gotgenes/pi-permission-system/commit/54ed04a85e47a5b1ceb9d496851474856fb0fa17))
22
+
8
23
  ## [4.6.0](https://github.com/gotgenes/pi-permission-system/compare/v4.5.0...v4.6.0) (2026-05-05)
9
24
 
10
25
 
package/README.md CHANGED
@@ -450,13 +450,32 @@ The suggested pattern is surface-specific:
450
450
 
451
451
  |Surface|Example request|Suggested session pattern|
452
452
  |---|---|---|
453
- |bash|`git status --short`|`git *`|
453
+ |bash|`git status --short`|`git status *`|
454
454
  |mcp (qualified)|`exa:search`|`exa:*`|
455
455
  |mcp (munged)|`exa_search`|`exa_*`|
456
456
  |skill|`librarian`|`librarian`|
457
457
  |tool (read, write, …)|`read`|`*`|
458
458
  |external_directory|`/other/project/src/foo.ts`|`/other/project/src/*`|
459
459
 
460
+ #### Bash arity table
461
+
462
+ Bash pattern suggestions use a curated arity dictionary (`src/bash-arity.ts`) to determine how many tokens define the "human-understandable subcommand."
463
+ Longest matching prefix wins, so `npm run` (arity 3) takes precedence over `npm` (arity 2).
464
+ Unknown commands default to arity 1 (first word only).
465
+
466
+ |Example command|Arity entry matched|Suggested pattern|
467
+ |---|---|---|
468
+ |`git checkout main`|`git` → 2|`git checkout *`|
469
+ |`npm run dev`|`npm run` → 3|`npm run dev*`|
470
+ |`npm install lodash`|`npm` → 2|`npm install *`|
471
+ |`docker compose up`|`docker compose` → 3|`docker compose up *`|
472
+ |`rm -rf node_modules`|`rm` → 1|`rm *`|
473
+ |`mytool --verbose`|(unknown) → 1|`mytool *`|
474
+
475
+ The arity table covers common CLI tools including git, npm/pnpm/yarn/bun, docker, cargo, go, kubectl, gh, and others.
476
+ To add an entry, open `src/bash-arity.ts` and add a key/arity pair to the `ARITY` object.
477
+ Put the most specific multi-word prefix first (e.g. `"npm run": 3`) before the shorter fallback (`"npm": 2`).
478
+
460
479
  Session approvals are ephemeral — they are never persisted to disk and are cleared on `session_shutdown`.
461
480
  The review log records these decisions: `resolution: "approved_for_session"` when the user approves, and `resolution: "session_approved"` when a later request is matched by an existing session rule.
462
481
 
@@ -505,6 +524,7 @@ index.ts → Root Pi entrypoint shim
505
524
  src/
506
525
  ├── index.ts → Extension bootstrap, permission checks, readable prompts, review logging, reload handling, and subagent forwarding
507
526
  ├── pattern-suggest.ts → Per-surface session approval pattern suggestions
527
+ ├── bash-arity.ts → Curated arity dictionary for smarter bash session-approval patterns
508
528
  ├── session-rules.ts → Ephemeral session-scoped approval rules (Ruleset-based, wildcard patterns across all surfaces)
509
529
  ├── config-loader.ts → Unified config loader, merger, and legacy-path detection
510
530
  ├── config-paths.ts → Path derivation for global, project, and legacy config locations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "4.6.0",
3
+ "version": "4.7.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Curated arity dictionary for common CLI commands.
3
+ *
4
+ * Keys are lowercase, space-joined command prefixes.
5
+ * Values are the total token count that defines the "human-understandable
6
+ * subcommand" — i.e. how many tokens to include in a session-approval pattern.
7
+ *
8
+ * Multi-level entries (e.g. "npm run": 3) take precedence over shorter entries
9
+ * ("npm": 2) because `prefix()` uses longest-match-wins.
10
+ *
11
+ * Exported for testability.
12
+ */
13
+ export const ARITY: Record<string, number> = {
14
+ // Version control
15
+ git: 2,
16
+ hg: 2,
17
+ svn: 2,
18
+
19
+ // Node.js package managers
20
+ npm: 2,
21
+ "npm run": 3,
22
+ "npm exec": 3,
23
+ npx: 2,
24
+ pnpm: 2,
25
+ "pnpm run": 3,
26
+ "pnpm exec": 3,
27
+ "pnpm dlx": 3,
28
+ yarn: 2,
29
+ "yarn run": 3,
30
+ bun: 2,
31
+ "bun run": 3,
32
+ "bun add": 2,
33
+ "bun x": 3,
34
+
35
+ // Runtimes
36
+ deno: 2,
37
+ "deno run": 3,
38
+ "deno task": 3,
39
+ "deno compile": 3,
40
+
41
+ // Python
42
+ pip: 2,
43
+ pip3: 2,
44
+ uv: 2,
45
+ "uv run": 3,
46
+ "uv pip": 3,
47
+
48
+ // Rust
49
+ cargo: 2,
50
+
51
+ // Go
52
+ go: 2,
53
+ "go run": 3,
54
+
55
+ // Ruby
56
+ bundle: 2,
57
+ "bundle exec": 3,
58
+
59
+ // Docker / container
60
+ docker: 2,
61
+ "docker compose": 3,
62
+ "docker container": 3,
63
+ "docker image": 3,
64
+ "docker network": 3,
65
+ "docker volume": 3,
66
+ podman: 2,
67
+ "podman compose": 3,
68
+
69
+ // Kubernetes
70
+ kubectl: 2,
71
+ helm: 2,
72
+
73
+ // Cloud CLIs
74
+ aws: 3,
75
+ az: 3,
76
+ gcloud: 3,
77
+ gh: 2,
78
+ "gh pr": 3,
79
+ "gh issue": 3,
80
+ "gh repo": 3,
81
+ fly: 2,
82
+ vercel: 2,
83
+ wrangler: 2,
84
+
85
+ // Build tools
86
+ make: 1,
87
+ bazel: 2,
88
+
89
+ // Infrastructure
90
+ terraform: 2,
91
+ tofu: 2,
92
+ pulumi: 2,
93
+
94
+ // System service management
95
+ systemctl: 2,
96
+ service: 2,
97
+
98
+ // Shell file-ops — args are paths/targets, not subcommands
99
+ ls: 1,
100
+ ll: 1,
101
+ la: 1,
102
+ cat: 1,
103
+ less: 1,
104
+ more: 1,
105
+ head: 1,
106
+ tail: 1,
107
+ grep: 1,
108
+ rg: 1,
109
+ ag: 1,
110
+ find: 1,
111
+ touch: 1,
112
+ mkdir: 1,
113
+ rm: 1,
114
+ cp: 1,
115
+ mv: 1,
116
+ ln: 1,
117
+ chmod: 1,
118
+ chown: 1,
119
+ du: 1,
120
+ df: 1,
121
+ echo: 1,
122
+ printf: 1,
123
+ diff: 1,
124
+ patch: 1,
125
+ wc: 1,
126
+ sort: 1,
127
+ uniq: 1,
128
+ awk: 1,
129
+ sed: 1,
130
+ tar: 1,
131
+ zip: 1,
132
+ unzip: 1,
133
+
134
+ // Network
135
+ curl: 1,
136
+ wget: 1,
137
+ ssh: 1,
138
+ scp: 1,
139
+ rsync: 1,
140
+ ping: 1,
141
+
142
+ // Process management
143
+ kill: 1,
144
+ killall: 1,
145
+ pkill: 1,
146
+
147
+ // Package managers (system)
148
+ brew: 2,
149
+ apt: 2,
150
+ "apt-get": 2,
151
+ yum: 2,
152
+ dnf: 2,
153
+ };
154
+
155
+ /**
156
+ * Return the semantically meaningful prefix tokens for a tokenized command.
157
+ *
158
+ * Performs a longest-match-wins lookup against the `ARITY` dictionary:
159
+ * iterates from the longest possible prefix down to a single token, returning
160
+ * the first (longest) match. Lookup is case-insensitive; the returned tokens
161
+ * preserve their original casing.
162
+ *
163
+ * When no entry matches, defaults to arity 1 (first token only).
164
+ * When the resolved arity exceeds the available tokens, it is clamped.
165
+ *
166
+ * @param tokens - The command split by whitespace (e.g. `["git", "checkout", "main"]`).
167
+ * @returns The prefix tokens defining the meaningful subcommand.
168
+ */
169
+ export function prefix(tokens: string[]): string[] {
170
+ if (tokens.length === 0) return [];
171
+
172
+ for (let n = tokens.length; n >= 1; n--) {
173
+ const key = tokens
174
+ .slice(0, n)
175
+ .map((t) => t.toLowerCase())
176
+ .join(" ");
177
+ const arity = ARITY[key];
178
+ if (arity !== undefined) {
179
+ return tokens.slice(0, Math.min(arity, tokens.length));
180
+ }
181
+ }
182
+
183
+ // Unknown command — default arity 1.
184
+ return [tokens[0]];
185
+ }
@@ -1,3 +1,4 @@
1
+ import { prefix } from "./bash-arity";
1
2
  import { deriveApprovalPattern } from "./session-rules";
2
3
 
3
4
  /** The suggestion returned for a "Yes, for this session" dialog option. */
@@ -13,20 +14,24 @@ export interface SessionApprovalSuggestion {
13
14
  /**
14
15
  * Suggest a bash session-approval pattern from a command string.
15
16
  *
16
- * Heuristic: split on the first space to get the base command.
17
- * Multi-word commands `<command> *`.
18
- * Single-word commands → exact command (no wildcard).
17
+ * Uses the arity table (`src/bash-arity.ts`) to identify the semantically
18
+ * meaningful prefix tokens for the command, then produces a wildcard pattern:
19
19
  *
20
- * This is intentionally conservative. The arity table (#52) will refine
21
- * suggestions later (e.g. `git checkout *` instead of `git *`).
20
+ * - Single bare token (no args): exact command (`ls`).
21
+ * - Arity prefix covers all tokens: trailing wildcard (`npm run build*`).
22
+ * - Arity prefix shorter than token list: space + wildcard (`git checkout *`).
23
+ * - Unknown command: first token + space wildcard (`mytool *`).
22
24
  */
23
25
  export function suggestBashPattern(command: string): string {
24
26
  const trimmed = command.trim();
25
- const spaceIndex = trimmed.indexOf(" ");
26
- if (spaceIndex === -1) {
27
- return trimmed;
27
+ if (!trimmed) return "";
28
+ const tokens = trimmed.split(/\s+/);
29
+ if (tokens.length === 1) return trimmed;
30
+ const meaningful = prefix(tokens);
31
+ if (meaningful.length >= tokens.length) {
32
+ return `${trimmed}*`;
28
33
  }
29
- return `${trimmed.slice(0, spaceIndex)} *`;
34
+ return `${meaningful.join(" ")} *`;
30
35
  }
31
36
 
32
37
  /**
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ARITY, prefix } from "../src/bash-arity";
3
+
4
+ describe("ARITY dictionary", () => {
5
+ it("is exported as a plain object", () => {
6
+ expect(typeof ARITY).toBe("object");
7
+ });
8
+
9
+ it("maps 'git' to arity 2", () => {
10
+ expect(ARITY["git"]).toBe(2);
11
+ });
12
+
13
+ it("maps 'npm run' to arity 3", () => {
14
+ expect(ARITY["npm run"]).toBe(3);
15
+ });
16
+
17
+ it("maps 'npm' to arity 2 (fallback when 'npm run' does not match)", () => {
18
+ expect(ARITY["npm"]).toBe(2);
19
+ });
20
+
21
+ it("maps 'docker compose' to arity 3", () => {
22
+ expect(ARITY["docker compose"]).toBe(3);
23
+ });
24
+
25
+ it("maps 'docker' to arity 2 (fallback)", () => {
26
+ expect(ARITY["docker"]).toBe(2);
27
+ });
28
+ });
29
+
30
+ describe("prefix", () => {
31
+ it("returns empty array for empty input", () => {
32
+ expect(prefix([])).toEqual([]);
33
+ });
34
+
35
+ it("returns single-element array for a bare known command", () => {
36
+ // 'git' alone has arity 2 but only 1 token is available — clamp.
37
+ expect(prefix(["git"])).toEqual(["git"]);
38
+ });
39
+
40
+ it("returns arity-2 prefix for git subcommands", () => {
41
+ expect(prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"]);
42
+ });
43
+
44
+ it("returns arity-2 prefix for git status with flags", () => {
45
+ expect(prefix(["git", "status", "--short"])).toEqual(["git", "status"]);
46
+ });
47
+
48
+ it("returns arity-3 prefix for npm run (longest match wins over npm arity-2)", () => {
49
+ expect(prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"]);
50
+ });
51
+
52
+ it("returns arity-2 prefix for npm install (npm fallback, npm run does not match)", () => {
53
+ expect(prefix(["npm", "install", "lodash"])).toEqual(["npm", "install"]);
54
+ });
55
+
56
+ it("returns arity-3 prefix for docker compose subcommands", () => {
57
+ expect(prefix(["docker", "compose", "up", "--build"])).toEqual([
58
+ "docker",
59
+ "compose",
60
+ "up",
61
+ ]);
62
+ });
63
+
64
+ it("returns arity-2 prefix for docker pull (docker fallback)", () => {
65
+ expect(prefix(["docker", "pull", "ubuntu"])).toEqual(["docker", "pull"]);
66
+ });
67
+
68
+ it("returns arity-1 prefix for unknown commands", () => {
69
+ expect(prefix(["unknown-tool", "--flag"])).toEqual(["unknown-tool"]);
70
+ });
71
+
72
+ it("returns arity-1 prefix for rm (args are targets, not subcommands)", () => {
73
+ expect(prefix(["rm", "-rf", "node_modules"])).toEqual(["rm"]);
74
+ });
75
+
76
+ it("returns arity-1 prefix for cat", () => {
77
+ expect(prefix(["cat", "file.txt"])).toEqual(["cat"]);
78
+ });
79
+
80
+ it("is case-insensitive: 'Git' looks up as 'git'", () => {
81
+ // Tokens are preserved as-is; only the lookup key is lowercased.
82
+ expect(prefix(["Git", "checkout", "main"])).toEqual(["Git", "checkout"]);
83
+ });
84
+
85
+ it("clamps arity to available token count when command is shorter than arity", () => {
86
+ // npm run has arity 3; only ["npm", "run"] provided → return both.
87
+ expect(prefix(["npm", "run"])).toEqual(["npm", "run"]);
88
+ });
89
+
90
+ it("returns arity-2 prefix for pnpm run (longest match wins over pnpm)", () => {
91
+ // pnpm run <script> — arity 3 means include the script name.
92
+ expect(prefix(["pnpm", "run", "build"])).toEqual(["pnpm", "run", "build"]);
93
+ });
94
+
95
+ it("returns arity-2 prefix for cargo subcommands", () => {
96
+ expect(prefix(["cargo", "build", "--release"])).toEqual(["cargo", "build"]);
97
+ });
98
+
99
+ it("returns arity-2 prefix for kubectl subcommands", () => {
100
+ expect(prefix(["kubectl", "get", "pods"])).toEqual(["kubectl", "get"]);
101
+ });
102
+
103
+ it("returns arity-1 for bare 'ls' (args are paths)", () => {
104
+ expect(prefix(["ls", "-la", "/tmp"])).toEqual(["ls"]);
105
+ });
106
+ });
@@ -568,7 +568,8 @@ describe("handleToolCall — session recording on approved_for_session", () => {
568
568
  input: { command: "git status" },
569
569
  });
570
570
  await handleToolCall(deps, event, makeCtx());
571
- expect(sessionRules.approve).toHaveBeenCalledWith("bash", "git *");
571
+ // git arity=2: "git status" prefix covers all 2 tokens → trailing wildcard.
572
+ expect(sessionRules.approve).toHaveBeenCalledWith("bash", "git status*");
572
573
  });
573
574
 
574
575
  it("records mcp session approval with suggestMcpPattern result", async () => {
@@ -6,25 +6,42 @@ import {
6
6
  } from "../src/pattern-suggest";
7
7
 
8
8
  describe("suggestBashPattern", () => {
9
- it("returns <command> * for a multi-word command", () => {
10
- expect(suggestBashPattern("git status --short")).toBe("git *");
9
+ it("returns <command> <subcommand> * using the arity table", () => {
10
+ // git arity=2: include the subcommand in the prefix.
11
+ expect(suggestBashPattern("git status --short")).toBe("git status *");
11
12
  });
12
13
 
13
- it("uses only the first word as the base", () => {
14
- expect(suggestBashPattern("npm run build")).toBe("npm *");
14
+ it("appends trailing * when arity covers all tokens (multi-word script name)", () => {
15
+ // npm run arity=3: prefix covers all three tokens → trailing wildcard.
16
+ expect(suggestBashPattern("npm run build")).toBe("npm run build*");
15
17
  });
16
18
 
17
19
  it("returns the exact command when there are no arguments", () => {
18
20
  expect(suggestBashPattern("ls")).toBe("ls");
19
21
  });
20
22
 
21
- it("trims leading and trailing whitespace", () => {
22
- expect(suggestBashPattern(" git log ")).toBe("git *");
23
+ it("trims leading and trailing whitespace before lookup", () => {
24
+ // git arity=2, tokens=["git","log"], prefix covers all → trailing wildcard.
25
+ expect(suggestBashPattern(" git log ")).toBe("git log*");
23
26
  });
24
27
 
25
28
  it("handles empty string gracefully", () => {
26
29
  expect(suggestBashPattern("")).toBe("");
27
30
  });
31
+
32
+ it("falls back to first-word prefix for unknown commands", () => {
33
+ expect(suggestBashPattern("mytool --verbose run")).toBe("mytool *");
34
+ });
35
+
36
+ it("returns first-word * for known arity-1 commands with args", () => {
37
+ expect(suggestBashPattern("rm -rf node_modules")).toBe("rm *");
38
+ });
39
+
40
+ it("produces tighter pattern for docker compose than plain docker", () => {
41
+ expect(suggestBashPattern("docker compose up --build")).toBe(
42
+ "docker compose up *",
43
+ );
44
+ });
28
45
  });
29
46
 
30
47
  describe("suggestMcpPattern", () => {
@@ -52,11 +69,12 @@ describe("suggestMcpPattern", () => {
52
69
 
53
70
  describe("suggestSessionPattern", () => {
54
71
  describe("bash surface", () => {
55
- it("returns bash surface with <command> * pattern for multi-word command", () => {
72
+ it("returns arity-aware subcommand pattern for multi-word command", () => {
73
+ // git arity=2: include the subcommand token in the prefix.
56
74
  const result = suggestSessionPattern("bash", "git status --short");
57
75
  expect(result).toMatchObject({
58
76
  surface: "bash",
59
- pattern: "git *",
77
+ pattern: "git status *",
60
78
  });
61
79
  });
62
80
 
@@ -122,8 +140,9 @@ describe("suggestSessionPattern", () => {
122
140
 
123
141
  describe("label field", () => {
124
142
  it("includes the suggested pattern in the label", () => {
143
+ // git arity=2, "git status" has 2 tokens → trailing wildcard.
125
144
  const result = suggestSessionPattern("bash", "git status");
126
- expect(result.label).toContain("git *");
145
+ expect(result.label).toContain("git status*");
127
146
  });
128
147
 
129
148
  it("wraps the pattern in quotes in the label", () => {