pi-sentry 1.0.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 +66 -0
- package/config.default.json +4 -0
- package/index.ts +7 -0
- package/package.json +33 -0
- package/rules.ts +154 -0
- package/src/ai-assessment.ts +154 -0
- package/src/config-loader.ts +111 -0
- package/src/constants.ts +1 -0
- package/src/index.ts +7 -0
- package/src/level-store.ts +84 -0
- package/src/permissions.ts +111 -0
- package/src/tool-assessment.ts +199 -0
- package/src/types.ts +53 -0
- package/src/ui.ts +100 -0
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# pi-sentry
|
|
2
|
+
|
|
3
|
+
A permission/impact gate extension for pi.
|
|
4
|
+
|
|
5
|
+
It classifies every tool call (including `bash`) as **low / medium / high** impact, then allows, prompts, or blocks based on the active permission level.
|
|
6
|
+
|
|
7
|
+
## Permission levels (enforcement behavior)
|
|
8
|
+
|
|
9
|
+
- **low**
|
|
10
|
+
- auto-allows only known **low-impact** operations
|
|
11
|
+
- **medium**
|
|
12
|
+
- auto-allows known **low + medium-impact** operations
|
|
13
|
+
- **YOLO**
|
|
14
|
+
- bypasses classification and authorization checks
|
|
15
|
+
|
|
16
|
+
## Tool classification summary
|
|
17
|
+
|
|
18
|
+
From current implementation:
|
|
19
|
+
|
|
20
|
+
- `read`, `grep`, `find`, `ls` → **low**
|
|
21
|
+
- `edit` → **low**
|
|
22
|
+
- `write` → **medium**
|
|
23
|
+
- other non-bash tools → **medium + unknown** (requires prompt/block path)
|
|
24
|
+
- `bash`:
|
|
25
|
+
- classify via rules in `rules.ts` (including compound command splitting and highest-impact selection)
|
|
26
|
+
- AI fallback is used **only** for unknown bash commands
|
|
27
|
+
- if AI is unavailable, unknown bash defaults to **high**
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
- Keyboard shortcut: cycles levels (`low → medium → YOLO`) and persists the selected level.
|
|
32
|
+
- Set shortcut from pi: `/pi-sentry <key>` (example: `/pi-sentry ctrl+shift+p`)
|
|
33
|
+
- takes effect after `/reload` (or restarting pi)
|
|
34
|
+
- use `/pi-sentry <key> --reload` to apply immediately
|
|
35
|
+
- CLI flag: `--permission-level <low|medium|YOLO>` (applies to current run)
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
`pi-sentry` merges config in this order (later wins):
|
|
40
|
+
|
|
41
|
+
1. Built-in defaults
|
|
42
|
+
2. `config.default.json` (packaged with extension)
|
|
43
|
+
3. Global user config: `~/.pi/agent/pi-sentry/config.json`
|
|
44
|
+
4. Project override: `.pi/pi-sentry/config.json`
|
|
45
|
+
|
|
46
|
+
### Config fields
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"cycle_shortcut": "shift+tab",
|
|
51
|
+
"level": "medium"
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- `cycle_shortcut`: keybinding used for cycling permission levels
|
|
56
|
+
- `level`: initial level (`low`, `medium`, or `YOLO`)
|
|
57
|
+
|
|
58
|
+
> Backward compatibility: legacy key `cycle_shorcut` is still accepted.
|
|
59
|
+
|
|
60
|
+
## Testing
|
|
61
|
+
|
|
62
|
+
From `pi-sentry/`:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm run test:tool-assessment
|
|
66
|
+
```
|
package/index.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-sentry",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Permission/impact gate extension for pi coding agent",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test:tool-assessment": "npx --yes tsx --test test/tool-assessment.test.ts"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"pi-coding-agent",
|
|
11
|
+
"extension",
|
|
12
|
+
"security",
|
|
13
|
+
"permissions"
|
|
14
|
+
],
|
|
15
|
+
"author": "ElwinLiu",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
19
|
+
"@mariozechner/pi-ai": "*"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"index.ts",
|
|
23
|
+
"src",
|
|
24
|
+
"config.default.json",
|
|
25
|
+
"rules.ts",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./index.ts"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
}
|
package/rules.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { Rule } from "./types.js";
|
|
2
|
+
|
|
3
|
+
// Rule coverage is based on common command behavior documented by upstream/manual sources:
|
|
4
|
+
// GNU coreutils (ls/rm), Git docs (push/reset), Docker docs (publish ports),
|
|
5
|
+
// Kubernetes docs (kubectl apply), systemctl man page, npm lifecycle scripts, and Terraform command refs.
|
|
6
|
+
const LOW_IMPACT_RULES: Rule[] = [
|
|
7
|
+
// Display / introspection
|
|
8
|
+
{ pattern: /^\s*(echo|printf)\b/i, reason: "display" },
|
|
9
|
+
{ pattern: /^\s*(pwd|whoami|id|groups|date|uname|hostname|uptime)\b/i, reason: "system info" },
|
|
10
|
+
{ pattern: /^\s*(env|printenv|which|type)\b/i, reason: "environment info" },
|
|
11
|
+
{ pattern: /^\s*(ls|tree)\b/i, reason: "read-only listing" },
|
|
12
|
+
{ pattern: /^\s*(cat|less|more|head|tail|wc|stat|file)\b/i, reason: "read-only file" },
|
|
13
|
+
{ pattern: /^\s*cd\b/i, reason: "directory navigation" },
|
|
14
|
+
{ pattern: /^\s*sed\b(?![^\n]*\s-i\b)/i, reason: "text processing" },
|
|
15
|
+
{ pattern: /^\s*(grep|egrep|fgrep|awk|cut|sort|uniq|tr|column|nl)\b/i, reason: "text processing" },
|
|
16
|
+
{ pattern: /^\s*find\b(?![^\n]*\s-(delete|exec|execdir|ok|okdir)\b)/i, reason: "file discovery" },
|
|
17
|
+
|
|
18
|
+
// Process, network, resource inspection
|
|
19
|
+
{ pattern: /^\s*(ps|top|htop|pgrep|pstree|lsof|ss|netstat|df|du|free|vmstat|iostat|dmesg)\b/i, reason: "runtime inspection" },
|
|
20
|
+
{ pattern: /^\s*(md5sum|sha1sum|sha256sum|sha512sum|cksum|b2sum)\b/i, reason: "checksums" },
|
|
21
|
+
|
|
22
|
+
// Git read-only operations
|
|
23
|
+
{ pattern: /^\s*git\s+(status|log|show|diff|blame|grep|rev-parse|rev-list|ls-files|ls-tree|cat-file)\b/i, reason: "read-only git" },
|
|
24
|
+
{ pattern: /^\s*git\s+branch\b(?!([^\n]*\s-[dDmM]))/i, reason: "read-only git branch view" },
|
|
25
|
+
{ pattern: /^\s*git\s+tag\b(?!([^\n]*\s-d\b))/i, reason: "read-only git tag view" },
|
|
26
|
+
{ pattern: /^\s*git\s+remote\s+-v\b/i, reason: "read-only git remote view" },
|
|
27
|
+
{ pattern: /^\s*git\s+stash\s+list\b/i, reason: "read-only git stash view" },
|
|
28
|
+
|
|
29
|
+
// Package manager read-only queries
|
|
30
|
+
{ pattern: /^\s*(npm|pnpm|yarn)\s+(ls|list|outdated|info|view)\b/i, reason: "package query" },
|
|
31
|
+
{ pattern: /^\s*(pip|pip3)\s+(list|show|freeze)\b/i, reason: "package query" },
|
|
32
|
+
{ pattern: /^\s*(brew)\s+(list|info|search)\b/i, reason: "package query" },
|
|
33
|
+
{ pattern: /^\s*(apt|apt-cache)\s+(list|search|show|policy)\b/i, reason: "package query" },
|
|
34
|
+
|
|
35
|
+
// GitHub CLI read-only queries
|
|
36
|
+
{ pattern: /^\s*gh\s+(--version|version|help|auth\s+status|repo\s+view|issue\s+view|pr\s+view|run\s+view|run\s+list|api\s+\/repos\/[^\s]+\/[^\s]+\/actions\/runs)\b/i, reason: "github query" },
|
|
37
|
+
|
|
38
|
+
// Infra/container read-only queries
|
|
39
|
+
{ pattern: /^\s*docker\s+(ps|images|inspect|logs|stats|top|events|version|info)\b/i, reason: "container query" },
|
|
40
|
+
{ pattern: /^\s*kubectl\s+(get|describe|logs|api-resources|api-versions|version|config\s+view)\b/i, reason: "cluster query" },
|
|
41
|
+
{ pattern: /^\s*helm\s+(list|status|history|get)\b/i, reason: "release query" },
|
|
42
|
+
{ pattern: /^\s*terraform\s+(validate|show|plan)\b/i, reason: "infra plan/query" },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const MEDIUM_IMPACT_RULES: Rule[] = [
|
|
46
|
+
// Local file mutations (usually recoverable)
|
|
47
|
+
{ pattern: /\b(touch|mkdir|rmdir|cp|mv|ln|install)\b/i, reason: "file mutation" },
|
|
48
|
+
{ pattern: /^\s*sed\b[^\n]*\s-i\b/i, reason: "in-place file edit" },
|
|
49
|
+
{ pattern: /(^|[^\u003c])\u003e(?!\u003e)/, reason: "redirect write" },
|
|
50
|
+
{ pattern: /\u003e\u003e/, reason: "redirect append" },
|
|
51
|
+
{ pattern: /\btee\b/i, reason: "write via tee" },
|
|
52
|
+
|
|
53
|
+
// Git local history/index mutations (recoverable with effort)
|
|
54
|
+
{ pattern: /^\s*git\s+(add|restore|checkout|switch|commit|merge|rebase|cherry-pick|revert|pull|fetch|stash(?!\s+list\b))\b/i, reason: "git mutation" },
|
|
55
|
+
|
|
56
|
+
// Language/package ecosystem mutations
|
|
57
|
+
{ pattern: /^\s*(npm|pnpm|yarn)\s+(install|add|update|upgrade|remove|rm|uninstall|ci)\b/i, reason: "package mutation" },
|
|
58
|
+
{ pattern: /^\s*npx\b/i, reason: "one-off package/script execution" },
|
|
59
|
+
{ pattern: /^\s*(pip|pip3)\s+(install|uninstall)\b/i, reason: "package mutation" },
|
|
60
|
+
{ pattern: /^\s*(cargo|go|gem|bundle|poetry|uv)\s+(install|add|get|update|remove|sync)\b/i, reason: "package/toolchain mutation" },
|
|
61
|
+
{ pattern: /^\s*terraform\s+fmt\b/i, reason: "source formatting mutation" },
|
|
62
|
+
|
|
63
|
+
// Build/test execution (side effects typically local)
|
|
64
|
+
{ pattern: /^\s*(make|cmake|ninja|meson|mvn|gradle|\.\/gradlew)\b/i, reason: "build pipeline" },
|
|
65
|
+
{ pattern: /^\s*(pytest|jest|vitest)\b/i, reason: "test run" },
|
|
66
|
+
{ pattern: /^\s*(go\s+test|cargo\s+test)\b/i, reason: "test run" },
|
|
67
|
+
{ pattern: /^\s*(npm|pnpm|yarn)\s+run\s+\S+/i, reason: "script run" },
|
|
68
|
+
|
|
69
|
+
// Service/container operations with local side effects
|
|
70
|
+
{ pattern: /^\s*(systemctl|service|launchctl)\s+(start|stop|restart|reload|enable|disable)\b/i, reason: "service state mutation" },
|
|
71
|
+
{ pattern: /^\s*docker\s+(build|pull|compose\s+(up|down|build|pull)|start|stop|restart|rm|rmi|run)\b/i, reason: "container mutation" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const HIGH_IMPACT_RULES: Rule[] = [
|
|
75
|
+
// Privilege escalation / identity changes
|
|
76
|
+
{ pattern: /\b(sudo|doas|su|pkexec)\b/i, reason: "elevated privileges" },
|
|
77
|
+
{ pattern: /\b(useradd|userdel|usermod|groupadd|groupdel|passwd)\b/i, reason: "identity/security mutation" },
|
|
78
|
+
|
|
79
|
+
// Irreversible/destructive filesystem & disk actions
|
|
80
|
+
{ pattern: /(^\s*rm\b|\bxargs\b[^\n]*\brm\b)/i, reason: "destructive delete" },
|
|
81
|
+
{ pattern: /\b(shred|wipefs|mkfs(?:\.\w+)?|fdisk|parted|sgdisk)\b/i, reason: "disk/filesystem destructive action" },
|
|
82
|
+
{ pattern: /\bdd\b[^\n]*\bof=\/dev\//i, reason: "raw disk write" },
|
|
83
|
+
{ pattern: /\btruncate\b/i, reason: "destructive truncate" },
|
|
84
|
+
{ pattern: /^\s*diskutil\s+erase/i, reason: "disk erase" },
|
|
85
|
+
|
|
86
|
+
// Security-sensitive permission/firewall/system tuning actions
|
|
87
|
+
{ pattern: /\b(chown|chgrp|chmod|setfacl|setcap|visudo|chattr)\b/i, reason: "permission/security mutation" },
|
|
88
|
+
{ pattern: /\b(ufw|iptables|nft|firewall-cmd|pfctl|sysctl)\b/i, reason: "network/system security mutation" },
|
|
89
|
+
{ pattern: /\b(reboot|shutdown|halt|poweroff|init\s+[06])\b/i, reason: "system availability impact" },
|
|
90
|
+
|
|
91
|
+
// Remote code execution patterns
|
|
92
|
+
{ pattern: /\bcurl\b[^|\n]*\|\s*(bash|sh|zsh|fish)\b/i, reason: "remote execution" },
|
|
93
|
+
{ pattern: /\bwget\b[^|\n]*\|\s*(bash|sh|zsh|fish)\b/i, reason: "remote execution" },
|
|
94
|
+
{ pattern: /\biwr\b[^|\n]*\|\s*iex\b/i, reason: "remote execution" },
|
|
95
|
+
{ pattern: /\binvoke-webrequest\b[^|\n]*\|\s*invoke-expression\b/i, reason: "remote execution" },
|
|
96
|
+
{ pattern: /\beval\b/i, reason: "dynamic execution" },
|
|
97
|
+
|
|
98
|
+
// Remote mutation / exposure
|
|
99
|
+
{ pattern: /\bgit\s+push\b/i, reason: "remote mutation" },
|
|
100
|
+
{ pattern: /^\s*gh\s+(issue\s+(create|edit|close|reopen|delete)|pr\s+(create|merge|close|reopen|ready|review)|repo\s+(create|delete|rename|edit)|release\s+create|secret\s+set|variable\s+set|workflow\s+run|run\s+rerun|api\s+.*\b(POST|PUT|PATCH|DELETE)\b)/i, reason: "github remote mutation" },
|
|
101
|
+
{ pattern: /\bgit\s+reset\b[^\n]*--hard\b/i, reason: "destructive history rewrite" },
|
|
102
|
+
{ pattern: /\bgit\s+clean\b[^\n]*\s-f\b/i, reason: "destructive workspace clean" },
|
|
103
|
+
{ pattern: /\bgit\s+branch\b[^\n]*\s-[dD]\b/i, reason: "branch deletion" },
|
|
104
|
+
{ pattern: /\bgit\s+tag\b[^\n]*\s-d\b/i, reason: "tag deletion" },
|
|
105
|
+
{ pattern: /\b(docker\s+run\b[^\n]*(\s-p\s|\s--publish\s|\s--network\s+host\b|\s--privileged\b)|kubectl\s+port-forward\b|ssh\s+-R\b|nc\b[^\n]*\s-l\b|socat\b[^\n]*\bLISTEN\b|ngrok\b|cloudflared\b|localtunnel\b)/i, reason: "port exposure" },
|
|
106
|
+
|
|
107
|
+
// Infra orchestration / destructive remote control
|
|
108
|
+
{ pattern: /\bterraform\s+(apply|destroy|state\s+rm|taint|import)\b/i, reason: "infra mutation" },
|
|
109
|
+
{ pattern: /\bkubectl\s+(apply|create|delete|replace|patch|edit|scale|set|drain|cordon|uncordon|rollout\s+(restart|undo))\b/i, reason: "cluster mutation" },
|
|
110
|
+
{ pattern: /\bhelm\s+(install|upgrade|rollback|uninstall|delete)\b/i, reason: "release mutation" },
|
|
111
|
+
{ pattern: /\bansible-playbook\b/i, reason: "remote orchestration mutation" },
|
|
112
|
+
|
|
113
|
+
// System package manager changes (broad machine impact)
|
|
114
|
+
{ pattern: /\b(apt(?:-get)?|dnf|yum|pacman|zypper)\s+(install|upgrade|dist-upgrade|remove|purge|autoremove)\b/i, reason: "system package mutation" },
|
|
115
|
+
{ pattern: /\bbrew\s+(install|upgrade|uninstall|tap|untap|services\s+(start|stop|restart))\b/i, reason: "system package/service mutation" },
|
|
116
|
+
|
|
117
|
+
// Database destructive intent
|
|
118
|
+
{ pattern: /\b(drop|truncate|delete|destroy|wipe)\b[^\n]*\b(table|database|schema|collection|index|prod|production|db|sensitive)\b/i, reason: "database/data destructive action" },
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const PRIORITIZED_RULESETS = [
|
|
122
|
+
{ level: "high", rules: HIGH_IMPACT_RULES },
|
|
123
|
+
{ level: "medium", rules: MEDIUM_IMPACT_RULES },
|
|
124
|
+
{ level: "low", rules: LOW_IMPACT_RULES },
|
|
125
|
+
] as const;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Classify a single command using the rule-based system.
|
|
129
|
+
* Assumes command is already normalized (trimmed, single spaces).
|
|
130
|
+
* Returns the assessment with unknown: true if no rules match.
|
|
131
|
+
*/
|
|
132
|
+
export function classifyCommandByRules(normalized: string): {
|
|
133
|
+
level: "low" | "medium" | "high";
|
|
134
|
+
reason: string;
|
|
135
|
+
unknown: boolean;
|
|
136
|
+
} {
|
|
137
|
+
if (!normalized) {
|
|
138
|
+
return { level: "low", unknown: false, reason: "empty" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const ruleset of PRIORITIZED_RULESETS) {
|
|
142
|
+
for (const rule of ruleset.rules) {
|
|
143
|
+
if (rule.pattern.test(normalized)) {
|
|
144
|
+
return { level: ruleset.level, unknown: false, reason: rule.reason };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
level: "medium",
|
|
151
|
+
unknown: true,
|
|
152
|
+
reason: "unmapped command",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { complete, type Message } from "@mariozechner/pi-ai";
|
|
2
|
+
import { buildSessionContext, convertToLlm } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import { isImpactLevel } from "./types.js";
|
|
6
|
+
import type { AiImpactClassification, ImpactAssessment } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const BASH_SOURCE = "agent:bash";
|
|
9
|
+
|
|
10
|
+
const AI_IMPACT_ASSESSOR_USER_INSTRUCTIONS = [
|
|
11
|
+
"Permission impact assessment task.",
|
|
12
|
+
"Classify the single bash command described below as low, medium, or high impact.",
|
|
13
|
+
"Use prior conversation messages as context for escalation (prod, secrets, destructive intent, remote impact, privilege/security impact).",
|
|
14
|
+
"Definitions:",
|
|
15
|
+
"- low: read-only inspection/query operations with no meaningful mutation.",
|
|
16
|
+
"- medium: mostly local or recoverable mutations.",
|
|
17
|
+
"- high: security-sensitive, destructive, privileged, remote-mutating, or hard-to-reverse actions.",
|
|
18
|
+
"Rules:",
|
|
19
|
+
"- If uncertain, pick the HIGHER impact.",
|
|
20
|
+
"- Return JSON only: {\"level\":\"low|medium|high\"}",
|
|
21
|
+
].join("\n");
|
|
22
|
+
|
|
23
|
+
function truncateForPrompt(value: string, maxLength = 1200): string {
|
|
24
|
+
if (value.length <= maxLength) return value;
|
|
25
|
+
return `${value.slice(0, maxLength)}...`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function coerceAiImpactClassification(value: unknown): AiImpactClassification | undefined {
|
|
29
|
+
if (!value || typeof value !== "object") return undefined;
|
|
30
|
+
const record = value as { level?: unknown };
|
|
31
|
+
const level = typeof record.level === "string" ? record.level.toLowerCase() : "";
|
|
32
|
+
if (!isImpactLevel(level)) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
return { level };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseAiImpactClassification(raw: string): AiImpactClassification | undefined {
|
|
39
|
+
const trimmed = raw.trim();
|
|
40
|
+
if (!trimmed) return undefined;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(trimmed);
|
|
44
|
+
const normalized = coerceAiImpactClassification(parsed);
|
|
45
|
+
if (normalized) return normalized;
|
|
46
|
+
} catch {
|
|
47
|
+
// fall through
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const jsonBlockMatch = trimmed.match(/\{[\s\S]*\}/);
|
|
51
|
+
if (!jsonBlockMatch) return undefined;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
return coerceAiImpactClassification(JSON.parse(jsonBlockMatch[0]));
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildConversationMessages(ctx: ExtensionContext): Message[] {
|
|
61
|
+
const entries = ctx.sessionManager.getEntries();
|
|
62
|
+
const leafId = ctx.sessionManager.getLeafId();
|
|
63
|
+
const sessionContext = buildSessionContext(entries, leafId);
|
|
64
|
+
return convertToLlm(sessionContext.messages);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildAssessmentMessage(assessment: ImpactAssessment): Message {
|
|
68
|
+
const text = [
|
|
69
|
+
AI_IMPACT_ASSESSOR_USER_INSTRUCTIONS,
|
|
70
|
+
"",
|
|
71
|
+
"<bash_command>",
|
|
72
|
+
`base_level_from_rules: ${assessment.level}`,
|
|
73
|
+
`unknown_from_rules: ${assessment.unknown}`,
|
|
74
|
+
`source: ${assessment.source}`,
|
|
75
|
+
`command: ${truncateForPrompt(assessment.operation)}`,
|
|
76
|
+
`previous_rule_reason: ${truncateForPrompt(assessment.reason, 600)}`,
|
|
77
|
+
"</bash_command>",
|
|
78
|
+
].join("\n");
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
role: "user",
|
|
82
|
+
content: [{ type: "text", text }],
|
|
83
|
+
timestamp: Date.now(),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function getApiAccess(ctx: ExtensionContext): Promise<{ model: NonNullable<ExtensionContext["model"]>; apiKey: string } | undefined> {
|
|
88
|
+
const model = ctx.model;
|
|
89
|
+
if (!model) return undefined;
|
|
90
|
+
|
|
91
|
+
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
|
92
|
+
if (!apiKey) return undefined;
|
|
93
|
+
|
|
94
|
+
return { model, apiKey };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function getClassificationFromModel(
|
|
98
|
+
ctx: ExtensionContext,
|
|
99
|
+
assessment: ImpactAssessment,
|
|
100
|
+
): Promise<AiImpactClassification | undefined> {
|
|
101
|
+
const access = await getApiAccess(ctx);
|
|
102
|
+
if (!access) return undefined;
|
|
103
|
+
|
|
104
|
+
const conversationMessages = buildConversationMessages(ctx);
|
|
105
|
+
const requestMessage = buildAssessmentMessage(assessment);
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const response = await complete(
|
|
109
|
+
access.model,
|
|
110
|
+
{
|
|
111
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
112
|
+
messages: [...conversationMessages, requestMessage],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
apiKey: access.apiKey,
|
|
116
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const output = response.content
|
|
121
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string")
|
|
122
|
+
.map((part) => part.text)
|
|
123
|
+
.join("\n");
|
|
124
|
+
|
|
125
|
+
return parseAiImpactClassification(output);
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class AiAssessor {
|
|
132
|
+
async assessBashImpact(assessment: ImpactAssessment, ctx: ExtensionContext): Promise<ImpactAssessment> {
|
|
133
|
+
if (assessment.source !== BASH_SOURCE || !assessment.unknown) {
|
|
134
|
+
return assessment;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const classified = await getClassificationFromModel(ctx, assessment);
|
|
138
|
+
if (!classified) {
|
|
139
|
+
return {
|
|
140
|
+
...assessment,
|
|
141
|
+
level: "high",
|
|
142
|
+
unknown: false,
|
|
143
|
+
reason: "ai-unavailable-default-high-bash",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
...assessment,
|
|
149
|
+
level: classified.level,
|
|
150
|
+
unknown: false,
|
|
151
|
+
reason: "ai-classified-bash",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_LEVEL, isPermissionLevel } from "./types.js";
|
|
7
|
+
import type { PermissionLevel } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = dirname(__filename);
|
|
11
|
+
|
|
12
|
+
const packageDefaultConfigPath = join(__dirname, "..", "config.default.json");
|
|
13
|
+
const legacyPackageUserConfigPath = join(__dirname, "..", "config.json");
|
|
14
|
+
|
|
15
|
+
const globalConfigPath = join(homedir(), ".pi", "agent", "pi-sentry", "config.json");
|
|
16
|
+
|
|
17
|
+
function getProjectConfigPath(cwd = process.cwd()): string {
|
|
18
|
+
return resolve(cwd, ".pi", "pi-sentry", "config.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Config {
|
|
22
|
+
cycle_shortcut: string;
|
|
23
|
+
level: PermissionLevel;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type RawConfig = {
|
|
27
|
+
cycle_shortcut?: unknown;
|
|
28
|
+
// Backward compatibility for older typo in released config files.
|
|
29
|
+
cycle_shorcut?: unknown;
|
|
30
|
+
level?: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const DEFAULT_CONFIG: Config = {
|
|
34
|
+
cycle_shortcut: "shift+tab",
|
|
35
|
+
level: DEFAULT_LEVEL,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function readJsonFile(path: string): Record<string, unknown> {
|
|
39
|
+
try {
|
|
40
|
+
const content = readFileSync(path, "utf-8");
|
|
41
|
+
const parsed = JSON.parse(content) as unknown;
|
|
42
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
43
|
+
} catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getShortcut(raw: RawConfig): string | undefined {
|
|
49
|
+
if (typeof raw.cycle_shortcut === "string") return raw.cycle_shortcut;
|
|
50
|
+
if (typeof raw.cycle_shorcut === "string") return raw.cycle_shorcut;
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeConfig(raw: RawConfig): Config {
|
|
55
|
+
const level = isPermissionLevel(raw.level) ? raw.level : DEFAULT_LEVEL;
|
|
56
|
+
const cycle_shortcut = getShortcut(raw) ?? DEFAULT_CONFIG.cycle_shortcut;
|
|
57
|
+
return {
|
|
58
|
+
cycle_shortcut,
|
|
59
|
+
level,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function loadUserOverrides(cwd = process.cwd()): RawConfig {
|
|
64
|
+
const legacyOverrides = readJsonFile(legacyPackageUserConfigPath) as RawConfig;
|
|
65
|
+
const globalOverrides = readJsonFile(globalConfigPath) as RawConfig;
|
|
66
|
+
const projectOverrides = readJsonFile(getProjectConfigPath(cwd)) as RawConfig;
|
|
67
|
+
return { ...legacyOverrides, ...globalOverrides, ...projectOverrides };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function loadConfig(options?: { cwd?: string }): Config {
|
|
71
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
72
|
+
const defaults = readJsonFile(packageDefaultConfigPath) as RawConfig;
|
|
73
|
+
const userOverrides = loadUserOverrides(cwd);
|
|
74
|
+
return normalizeConfig({ ...DEFAULT_CONFIG, ...defaults, ...userOverrides });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ensureParentDir(path: string): void {
|
|
78
|
+
const parent = dirname(path);
|
|
79
|
+
if (existsSync(parent)) return;
|
|
80
|
+
mkdirSync(parent, { recursive: true });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writeGlobalOverrides(patch: RawConfig): boolean {
|
|
84
|
+
try {
|
|
85
|
+
const globalOverrides = readJsonFile(globalConfigPath) as RawConfig;
|
|
86
|
+
const next: RawConfig = { ...globalOverrides, ...patch };
|
|
87
|
+
ensureParentDir(globalConfigPath);
|
|
88
|
+
writeFileSync(globalConfigPath, `${JSON.stringify(next, null, "\t")}\n`, "utf-8");
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function savePermissionLevel(level: PermissionLevel): boolean {
|
|
96
|
+
if (!isPermissionLevel(level)) return false;
|
|
97
|
+
return writeGlobalOverrides({ level });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function saveCycleShortcut(shortcut: string): boolean {
|
|
101
|
+
if (typeof shortcut !== "string" || shortcut.trim().length === 0) return false;
|
|
102
|
+
return writeGlobalOverrides({ cycle_shortcut: shortcut.trim() });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function getGlobalConfigPath(): string {
|
|
106
|
+
return globalConfigPath;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function getProjectConfigPathForDisplay(cwd = process.cwd()): string {
|
|
110
|
+
return getProjectConfigPath(cwd);
|
|
111
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const PERMISSION_LEVEL_FLAG = "permission-level" as const;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { PERMISSION_LEVEL_FLAG } from "./constants.js";
|
|
4
|
+
import { getGlobalConfigPath, loadConfig, savePermissionLevel } from "./config-loader.js";
|
|
5
|
+
import { cycleLevel, DEFAULT_LEVEL, isPermissionLevel } from "./types.js";
|
|
6
|
+
import type { PermissionLevel } from "./types.js";
|
|
7
|
+
import { renderPermissionWidget } from "./ui.js";
|
|
8
|
+
|
|
9
|
+
export type SetPermissionLevelOptions = {
|
|
10
|
+
persist?: boolean;
|
|
11
|
+
notify?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function normalizePermissionLevel(value: unknown): PermissionLevel | undefined {
|
|
15
|
+
if (typeof value !== "string") return undefined;
|
|
16
|
+
const normalized = value.trim().toLowerCase();
|
|
17
|
+
return isPermissionLevel(normalized) ? normalized : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parsePermissionLevelFlag(pi: ExtensionAPI): PermissionLevel | undefined {
|
|
21
|
+
return (
|
|
22
|
+
normalizePermissionLevel(pi.getFlag(PERMISSION_LEVEL_FLAG)) ??
|
|
23
|
+
normalizePermissionLevel(pi.getFlag(`--${PERMISSION_LEVEL_FLAG}`))
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class PermissionLevelStore {
|
|
28
|
+
private level: PermissionLevel = DEFAULT_LEVEL;
|
|
29
|
+
private latestContext: ExtensionContext | undefined;
|
|
30
|
+
|
|
31
|
+
constructor(private readonly pi: ExtensionAPI) {}
|
|
32
|
+
|
|
33
|
+
get current(): PermissionLevel {
|
|
34
|
+
return this.level;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
setLatestContext(ctx: ExtensionContext): void {
|
|
38
|
+
this.latestContext = ctx;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Initializes the store from config and CLI flags.
|
|
43
|
+
*
|
|
44
|
+
* Does not persist/notify (session start should be quiet).
|
|
45
|
+
*/
|
|
46
|
+
init(ctx: ExtensionContext): void {
|
|
47
|
+
this.latestContext = ctx;
|
|
48
|
+
|
|
49
|
+
const config = loadConfig({ cwd: ctx.cwd });
|
|
50
|
+
this.level = config.level;
|
|
51
|
+
|
|
52
|
+
const fromFlag = parsePermissionLevelFlag(this.pi);
|
|
53
|
+
if (fromFlag) {
|
|
54
|
+
this.level = fromFlag;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
renderPermissionWidget(ctx, this.level);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
set(nextLevel: PermissionLevel, ctx: ExtensionContext, options?: SetPermissionLevelOptions): void {
|
|
61
|
+
const changed = this.level !== nextLevel;
|
|
62
|
+
|
|
63
|
+
this.level = nextLevel;
|
|
64
|
+
this.latestContext = ctx;
|
|
65
|
+
|
|
66
|
+
if (changed && options?.persist !== false) {
|
|
67
|
+
const persisted = savePermissionLevel(nextLevel);
|
|
68
|
+
if (!persisted) {
|
|
69
|
+
ctx.ui.notify(`Failed to persist permission level to ${getGlobalConfigPath()}`, "error");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
renderPermissionWidget(ctx, nextLevel);
|
|
74
|
+
|
|
75
|
+
if (options?.notify !== false) {
|
|
76
|
+
ctx.ui.notify(`Permission level: ${nextLevel.toUpperCase()}`, "info");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
cycle(options?: SetPermissionLevelOptions): void {
|
|
81
|
+
if (!this.latestContext) return;
|
|
82
|
+
this.set(cycleLevel(this.level), this.latestContext, { notify: false, ...options });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { PERMISSION_LEVEL_FLAG } from "./constants.js";
|
|
4
|
+
import { AiAssessor } from "./ai-assessment.js";
|
|
5
|
+
import { PermissionLevelStore } from "./level-store.js";
|
|
6
|
+
import { authorize, classifyToolCall } from "./tool-assessment.js";
|
|
7
|
+
import { setPermissionBadgeRenderer } from "./ui.js";
|
|
8
|
+
import { getGlobalConfigPath, loadConfig, saveCycleShortcut } from "./config-loader.js";
|
|
9
|
+
|
|
10
|
+
const PERMISSION_BADGE_RENDERER_SET_EVENT = "permission:ui:badge-renderer:set" as const;
|
|
11
|
+
const PERMISSION_BADGE_RENDERER_REQUEST_EVENT = "permission:ui:badge-renderer:request" as const;
|
|
12
|
+
|
|
13
|
+
type PermissionBadgeRendererPayload = {
|
|
14
|
+
renderBadge: (theme: unknown, label: string) => string | undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function isPermissionBadgeRendererPayload(value: unknown): value is PermissionBadgeRendererPayload {
|
|
18
|
+
if (!value || typeof value !== "object") return false;
|
|
19
|
+
const record = value as Partial<PermissionBadgeRendererPayload>;
|
|
20
|
+
return typeof record.renderBadge === "function";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function registerPermissionSystem(pi: ExtensionAPI): void {
|
|
24
|
+
const levelStore = new PermissionLevelStore(pi);
|
|
25
|
+
const aiAssessor = new AiAssessor();
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
|
|
28
|
+
setPermissionBadgeRenderer(undefined);
|
|
29
|
+
|
|
30
|
+
pi.events.on(PERMISSION_BADGE_RENDERER_SET_EVENT, (payload) => {
|
|
31
|
+
if (!isPermissionBadgeRendererPayload(payload)) return;
|
|
32
|
+
setPermissionBadgeRenderer((theme, label) => payload.renderBadge(theme, label));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Ask UI extensions to (re)register renderers, regardless of load order.
|
|
36
|
+
pi.events.emit(PERMISSION_BADGE_RENDERER_REQUEST_EVENT, {});
|
|
37
|
+
|
|
38
|
+
pi.registerFlag(PERMISSION_LEVEL_FLAG, {
|
|
39
|
+
description: "Permission level: low | medium | YOLO",
|
|
40
|
+
type: "string",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
pi.registerShortcut(config.cycle_shortcut, {
|
|
44
|
+
description: "Cycle through permission levels (low → medium → YOLO)",
|
|
45
|
+
handler: () => {
|
|
46
|
+
levelStore.cycle();
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
pi.registerCommand("pi-sentry", {
|
|
51
|
+
description: "Set pi-sentry cycle shortcut (example: /pi-sentry ctrl+shift+p)",
|
|
52
|
+
handler: async (args, ctx) => {
|
|
53
|
+
const rawArgs = (args ?? "").trim();
|
|
54
|
+
if (!rawArgs) {
|
|
55
|
+
const current = loadConfig({ cwd: ctx.cwd }).cycle_shortcut;
|
|
56
|
+
ctx.ui.notify(`Current shortcut: ${current}`, "info");
|
|
57
|
+
ctx.ui.notify("Usage: /pi-sentry <key> [--reload]", "info");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const parts = rawArgs.split(/\s+/).filter(Boolean);
|
|
62
|
+
const reloadNow = parts.includes("--reload");
|
|
63
|
+
const shortcutParts = parts.filter((part) => part !== "--reload");
|
|
64
|
+
const nextShortcut = shortcutParts.join(" ").trim();
|
|
65
|
+
|
|
66
|
+
if (!nextShortcut) {
|
|
67
|
+
ctx.ui.notify("Usage: /pi-sentry <key> [--reload]", "info");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!saveCycleShortcut(nextShortcut)) {
|
|
72
|
+
ctx.ui.notify(`Failed to persist shortcut to ${getGlobalConfigPath()}`, "error");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (reloadNow) {
|
|
77
|
+
ctx.ui.notify(`Saved shortcut: ${nextShortcut}. Reloading...`, "info");
|
|
78
|
+
await ctx.reload();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
ctx.ui.notify(`Saved shortcut: ${nextShortcut}`, "info");
|
|
83
|
+
ctx.ui.notify("It will take effect after /reload (or restart pi).", "info");
|
|
84
|
+
return;
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
89
|
+
levelStore.init(ctx);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
pi.on("before_agent_start", async (event) => {
|
|
93
|
+
return {
|
|
94
|
+
systemPrompt: `${event.systemPrompt}\n\nPermission policy active. Current level: ${levelStore.current.toUpperCase()}. Unknown bash command impacts are AI-classified from operation semantics and conversation intent. Other tools use fixed mappings/rules. YOLO level bypasses all checks.`,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
99
|
+
levelStore.setLatestContext(ctx);
|
|
100
|
+
|
|
101
|
+
// YOLO mode: bypass all classification and authorization
|
|
102
|
+
if (levelStore.current === "YOLO") {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const assessment = await classifyToolCall(event, ctx, aiAssessor);
|
|
107
|
+
const decision = await authorize(assessment, levelStore.current, ctx);
|
|
108
|
+
if (decision.allowed) return undefined;
|
|
109
|
+
return { block: true, reason: decision.reason ?? "Blocked by permission policy" };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { ToolCallEvent, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { AiAssessor } from "./ai-assessment.js";
|
|
4
|
+
import { classifyCommandByRules } from "../rules.js";
|
|
5
|
+
import { askUserPermission } from "./ui.js";
|
|
6
|
+
import { isImpactAtMost, maxImpactLevel } from "./types.js";
|
|
7
|
+
import type { ImpactAssessment, ImpactLevel, PermissionLevel } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
|
10
|
+
const WRITE_TOOLS = new Set(["write"]);
|
|
11
|
+
const EDIT_TOOLS = new Set(["edit"]);
|
|
12
|
+
const BASH_SOURCE = "agent:bash";
|
|
13
|
+
|
|
14
|
+
function truncateForPrompt(value: string, maxLength = 300): string {
|
|
15
|
+
if (value.length <= maxLength) return value;
|
|
16
|
+
return `${value.slice(0, maxLength)}...`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getStringProp(input: unknown, key: string): string | undefined {
|
|
20
|
+
if (!input || typeof input !== "object") return undefined;
|
|
21
|
+
const record = input as Record<string, unknown>;
|
|
22
|
+
const value = record[key];
|
|
23
|
+
return typeof value === "string" ? value : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function serializeForOperation(input: unknown): string {
|
|
27
|
+
try {
|
|
28
|
+
const serialized = JSON.stringify(input);
|
|
29
|
+
if (!serialized) return "";
|
|
30
|
+
return truncateForPrompt(serialized);
|
|
31
|
+
} catch {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeCommand(command: string): string {
|
|
37
|
+
return command.trim().replace(/\s+/g, " ");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Split a compound command into individual commands based on common shell separators.
|
|
42
|
+
* Assumes command is already normalized.
|
|
43
|
+
*/
|
|
44
|
+
function splitCompoundCommands(normalized: string): string[] {
|
|
45
|
+
if (!normalized) return [];
|
|
46
|
+
return normalized
|
|
47
|
+
.split(/\s*(?:&&|\|\||;|\|&|\||&|\n)\s*/)
|
|
48
|
+
.map((part) => part.trim())
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toImpactAssessment(normalized: string, result: ReturnType<typeof classifyCommandByRules>): ImpactAssessment {
|
|
53
|
+
return {
|
|
54
|
+
level: result.level,
|
|
55
|
+
source: BASH_SOURCE,
|
|
56
|
+
operation: normalized,
|
|
57
|
+
unknown: result.unknown,
|
|
58
|
+
reason: result.reason,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pickMoreImpactful(current: ImpactAssessment, candidate: ImpactAssessment): ImpactAssessment {
|
|
63
|
+
const higherLevel = maxImpactLevel(current.level, candidate.level);
|
|
64
|
+
if (higherLevel !== current.level) return candidate;
|
|
65
|
+
if (higherLevel !== candidate.level) return current;
|
|
66
|
+
if (current.unknown && !candidate.unknown) return candidate;
|
|
67
|
+
return current;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function summarizeAssessment(assessment: ImpactAssessment): string {
|
|
71
|
+
const operation = truncateForPrompt(assessment.operation, 30);
|
|
72
|
+
return `${operation}(${assessment.level})`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Rule-only bash classification.
|
|
77
|
+
* Classifies the full command and each sub-command, then returns the highest impact.
|
|
78
|
+
*/
|
|
79
|
+
function classifyBashByRules(command: string): ImpactAssessment {
|
|
80
|
+
const normalized = normalizeCommand(command);
|
|
81
|
+
if (!normalized) {
|
|
82
|
+
return { level: "low", source: BASH_SOURCE, operation: "", unknown: false, reason: "empty" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const overallAssessment = toImpactAssessment(normalized, classifyCommandByRules(normalized));
|
|
86
|
+
const subCommands = splitCompoundCommands(normalized);
|
|
87
|
+
|
|
88
|
+
if (subCommands.length <= 1) {
|
|
89
|
+
return overallAssessment;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let highestAssessment = overallAssessment;
|
|
93
|
+
const reasonParts: string[] = [`whole:${summarizeAssessment(overallAssessment)}`];
|
|
94
|
+
|
|
95
|
+
for (const subCommand of subCommands) {
|
|
96
|
+
const assessment = toImpactAssessment(subCommand, classifyCommandByRules(subCommand));
|
|
97
|
+
highestAssessment = pickMoreImpactful(highestAssessment, assessment);
|
|
98
|
+
reasonParts.push(`part:${summarizeAssessment(assessment)}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
...highestAssessment,
|
|
103
|
+
operation: normalized,
|
|
104
|
+
unknown: highestAssessment.unknown,
|
|
105
|
+
reason: `compound: ${reasonParts.join("; ")}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Classify a bash command with rule-based analysis + AI fallback for unknown commands.
|
|
111
|
+
*/
|
|
112
|
+
async function classifyBash(command: string, ctx: ExtensionContext, aiAssessor: AiAssessor): Promise<ImpactAssessment> {
|
|
113
|
+
const ruleAssessment = classifyBashByRules(command);
|
|
114
|
+
if (!ruleAssessment.unknown) {
|
|
115
|
+
return ruleAssessment;
|
|
116
|
+
}
|
|
117
|
+
return aiAssessor.assessBashImpact(ruleAssessment, ctx);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Unified function to classify any tool call.
|
|
122
|
+
*/
|
|
123
|
+
export async function classifyToolCall(
|
|
124
|
+
event: ToolCallEvent,
|
|
125
|
+
ctx: ExtensionContext,
|
|
126
|
+
aiAssessor: AiAssessor,
|
|
127
|
+
): Promise<ImpactAssessment> {
|
|
128
|
+
let assessment: ImpactAssessment;
|
|
129
|
+
|
|
130
|
+
if (event.toolName === "bash") {
|
|
131
|
+
const command = getStringProp(event.input, "command") ?? "";
|
|
132
|
+
return classifyBash(command, ctx, aiAssessor);
|
|
133
|
+
} else if (READ_ONLY_TOOLS.has(event.toolName)) {
|
|
134
|
+
assessment = {
|
|
135
|
+
level: "low",
|
|
136
|
+
source: `agent:${event.toolName}`,
|
|
137
|
+
operation: event.toolName,
|
|
138
|
+
unknown: false,
|
|
139
|
+
reason: "read-only tool",
|
|
140
|
+
};
|
|
141
|
+
} else if (EDIT_TOOLS.has(event.toolName)) {
|
|
142
|
+
const path = getStringProp(event.input, "path") ?? "(unknown path)";
|
|
143
|
+
assessment = {
|
|
144
|
+
level: "low",
|
|
145
|
+
source: `agent:${event.toolName}`,
|
|
146
|
+
operation: `${event.toolName} ${path}`,
|
|
147
|
+
unknown: false,
|
|
148
|
+
reason: "edit tool",
|
|
149
|
+
};
|
|
150
|
+
} else if (WRITE_TOOLS.has(event.toolName)) {
|
|
151
|
+
const path = getStringProp(event.input, "path") ?? "(unknown path)";
|
|
152
|
+
assessment = {
|
|
153
|
+
level: "medium",
|
|
154
|
+
source: `agent:${event.toolName}`,
|
|
155
|
+
operation: `${event.toolName} ${path}`,
|
|
156
|
+
unknown: false,
|
|
157
|
+
reason: "write tool",
|
|
158
|
+
};
|
|
159
|
+
} else {
|
|
160
|
+
const serializedInput = serializeForOperation(event.input);
|
|
161
|
+
const operation = serializedInput ? `${event.toolName} ${serializedInput}` : event.toolName;
|
|
162
|
+
assessment = {
|
|
163
|
+
level: "medium",
|
|
164
|
+
source: `agent:${event.toolName}`,
|
|
165
|
+
operation,
|
|
166
|
+
unknown: true,
|
|
167
|
+
reason: "unmapped tool",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return assessment;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function authorize(
|
|
175
|
+
assessment: ImpactAssessment,
|
|
176
|
+
level: PermissionLevel,
|
|
177
|
+
ctx: ExtensionContext,
|
|
178
|
+
): Promise<{ allowed: boolean; reason?: string }> {
|
|
179
|
+
// Map permission level to max allowed impact level
|
|
180
|
+
const maxAllowedImpact: ImpactLevel = level === "medium" ? "medium" : "low";
|
|
181
|
+
const thresholdAllows = !assessment.unknown && isImpactAtMost(assessment.level, maxAllowedImpact);
|
|
182
|
+
if (thresholdAllows) {
|
|
183
|
+
return { allowed: true };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const baseReason = assessment.unknown
|
|
187
|
+
? "Blocked unknown-impact operation"
|
|
188
|
+
: `Blocked ${assessment.level}-impact operation`;
|
|
189
|
+
|
|
190
|
+
if (!ctx.hasUI) {
|
|
191
|
+
return {
|
|
192
|
+
allowed: false,
|
|
193
|
+
reason: `${baseReason}: ${assessment.operation}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const ok = await askUserPermission(ctx, assessment);
|
|
198
|
+
return ok ? { allowed: true } : { allowed: false, reason: baseReason };
|
|
199
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const LEVELS = ["low", "medium", "YOLO"] as const;
|
|
2
|
+
export type PermissionLevel = (typeof LEVELS)[number];
|
|
3
|
+
|
|
4
|
+
// Impact levels for command classification (separate from permission levels)
|
|
5
|
+
export const IMPACT_LEVELS = ["low", "medium", "high"] as const;
|
|
6
|
+
export type ImpactLevel = (typeof IMPACT_LEVELS)[number];
|
|
7
|
+
|
|
8
|
+
export type Rule = {
|
|
9
|
+
pattern: RegExp;
|
|
10
|
+
reason: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ImpactAssessment = {
|
|
14
|
+
level: ImpactLevel;
|
|
15
|
+
source: string;
|
|
16
|
+
operation: string;
|
|
17
|
+
unknown: boolean;
|
|
18
|
+
reason: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type AiImpactClassification = {
|
|
22
|
+
level: ImpactLevel;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Impact level ordering for comparison
|
|
26
|
+
export const IMPACT_LEVEL_ORDER = {
|
|
27
|
+
low: 0,
|
|
28
|
+
medium: 1,
|
|
29
|
+
high: 2,
|
|
30
|
+
} satisfies Record<ImpactLevel, number>;
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_LEVEL: PermissionLevel = "medium";
|
|
33
|
+
|
|
34
|
+
export function isImpactAtMost(level: ImpactLevel, threshold: ImpactLevel): boolean {
|
|
35
|
+
return IMPACT_LEVEL_ORDER[level] <= IMPACT_LEVEL_ORDER[threshold];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function maxImpactLevel(base: ImpactLevel, candidate: ImpactLevel): ImpactLevel {
|
|
39
|
+
return IMPACT_LEVEL_ORDER[candidate] > IMPACT_LEVEL_ORDER[base] ? candidate : base;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isImpactLevel(value: unknown): value is ImpactLevel {
|
|
43
|
+
return typeof value === "string" && (IMPACT_LEVELS as readonly string[]).includes(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isPermissionLevel(value: unknown): value is PermissionLevel {
|
|
47
|
+
return typeof value === "string" && (LEVELS as readonly string[]).includes(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function cycleLevel(level: PermissionLevel): PermissionLevel {
|
|
51
|
+
const index = LEVELS.indexOf(level);
|
|
52
|
+
return LEVELS[(index + 1) % LEVELS.length];
|
|
53
|
+
}
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { ImpactAssessment, PermissionLevel } from "./types.js";
|
|
4
|
+
import { loadConfig } from "./config-loader.js";
|
|
5
|
+
|
|
6
|
+
type ThemeLike = {
|
|
7
|
+
inverse: (text: string) => string;
|
|
8
|
+
bold: (text: string) => string;
|
|
9
|
+
fg: (token: string, text: string) => string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type PermissionBadgeRenderer = (theme: ThemeLike, label: string) => string | undefined;
|
|
13
|
+
|
|
14
|
+
let badgeRenderer: PermissionBadgeRenderer | undefined;
|
|
15
|
+
|
|
16
|
+
function truncatePlain(text: string, maxWidth: number): string {
|
|
17
|
+
if (maxWidth <= 0) return "";
|
|
18
|
+
if (text.length <= maxWidth) return text;
|
|
19
|
+
if (maxWidth <= 3) return ".".repeat(maxWidth);
|
|
20
|
+
return `${text.slice(0, maxWidth - 3)}...`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function setPermissionBadgeRenderer(renderer: PermissionBadgeRenderer | undefined): void {
|
|
24
|
+
badgeRenderer = renderer;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const PERMISSION_LABELS = {
|
|
28
|
+
low: "Perm (Low) - allow edits + read-only commands",
|
|
29
|
+
medium: "Perm (Med) - allow reversible commands",
|
|
30
|
+
YOLO: "Perm (YOLO) - bypass all commands",
|
|
31
|
+
} satisfies Record<PermissionLevel, string>;
|
|
32
|
+
|
|
33
|
+
const PERMISSION_TONES = {
|
|
34
|
+
low: "text",
|
|
35
|
+
medium: "warning",
|
|
36
|
+
YOLO: "error",
|
|
37
|
+
} satisfies Record<PermissionLevel, "text" | "warning" | "error">;
|
|
38
|
+
|
|
39
|
+
function defaultBadge(theme: ThemeLike, label: string): string {
|
|
40
|
+
return theme.inverse(theme.bold(` ${label} `));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderBadge(theme: ThemeLike, label: string): string {
|
|
44
|
+
return badgeRenderer?.(theme, label) ?? defaultBadge(theme, label);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function renderPermissionWidget(ctx: ExtensionContext, level: PermissionLevel): void {
|
|
48
|
+
if (!ctx.hasUI) return;
|
|
49
|
+
const config = loadConfig({ cwd: ctx.cwd });
|
|
50
|
+
const text = PERMISSION_LABELS[level];
|
|
51
|
+
const tone = PERMISSION_TONES[level];
|
|
52
|
+
|
|
53
|
+
ctx.ui.setWidget(
|
|
54
|
+
"permission-level",
|
|
55
|
+
() => ({
|
|
56
|
+
render: (width: number) => {
|
|
57
|
+
const prefix = " ";
|
|
58
|
+
const available = Math.max(0, width - prefix.length);
|
|
59
|
+
const hintPlain = ` (${config.cycle_shortcut} to cycle)`;
|
|
60
|
+
|
|
61
|
+
let basePlain = text;
|
|
62
|
+
let hintShown = hintPlain;
|
|
63
|
+
|
|
64
|
+
if (basePlain.length + hintShown.length > available) {
|
|
65
|
+
if (basePlain.length >= available) {
|
|
66
|
+
basePlain = truncatePlain(basePlain, available);
|
|
67
|
+
hintShown = "";
|
|
68
|
+
} else {
|
|
69
|
+
const remaining = available - basePlain.length;
|
|
70
|
+
hintShown = truncatePlain(hintShown, remaining);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rendered = prefix + ctx.ui.theme.fg(tone, basePlain) + ctx.ui.theme.fg("dim", hintShown);
|
|
75
|
+
return [rendered];
|
|
76
|
+
},
|
|
77
|
+
invalidate: () => {},
|
|
78
|
+
}),
|
|
79
|
+
{ placement: "aboveEditor" },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderExecuteLine(ctx: ExtensionContext, assessment: ImpactAssessment): string {
|
|
84
|
+
const impact = assessment.unknown ? "unknown" : assessment.level;
|
|
85
|
+
const executeTag = renderBadge(ctx.ui.theme as ThemeLike, "EXECUTE");
|
|
86
|
+
const detailParts = [
|
|
87
|
+
assessment.operation,
|
|
88
|
+
`impact: ${impact}`,
|
|
89
|
+
assessment.reason ? `reason: ${assessment.reason}` : undefined,
|
|
90
|
+
]
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join(", ");
|
|
93
|
+
const detail = ctx.ui.theme.fg("toolOutput", `(${detailParts})`);
|
|
94
|
+
return `${executeTag} ${detail}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function askUserPermission(ctx: ExtensionContext, assessment: ImpactAssessment): Promise<boolean> {
|
|
98
|
+
const choice = await ctx.ui.select(renderExecuteLine(ctx, assessment), ["Yes, allow", "No, Cancel"]);
|
|
99
|
+
return choice === "Yes, allow";
|
|
100
|
+
}
|