@erichll/pi-auto-review 0.12.0 → 0.14.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 +30 -0
- package/README.md +36 -4
- package/package.json +2 -2
- package/src/index.ts +4 -2
- package/src/path-surfaces.ts +31 -0
- package/src/policy-audit/classifier.ts +118 -136
- package/src/policy-audit/report.ts +173 -26
- package/src/policy-audit/store.ts +74 -14
- package/src/policy.ts +3 -1
- package/src/ui-auto-confirm.ts +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.0 - 2026-08-28
|
|
4
|
+
|
|
5
|
+
- Upgrade `/auto-review-policy-audit` from redacted statistics to an
|
|
6
|
+
evidence-driven permission-configuration optimizer. Reports now separate
|
|
7
|
+
suggested allow rules, keep-ask rules, and insufficient evidence, and emit a
|
|
8
|
+
copyable JSON fragment targeting the selected project or global
|
|
9
|
+
permission-system configuration. Suggestions remain advisory and never read
|
|
10
|
+
or modify permission configuration.
|
|
11
|
+
- Discover candidates from actual audit data without built-in tool, executable,
|
|
12
|
+
Git, RTK, package-manager, or read-only command catalogs. Valid observed
|
|
13
|
+
surfaces and sanitized Bash templates—including unknown tools and write
|
|
14
|
+
operations—can qualify after the configured minimum number of successful
|
|
15
|
+
ask-path approvals. Denials, gate failures, unavailable confirmations,
|
|
16
|
+
forwarded requests, and structurally unsafe Bash variants block a suggestion.
|
|
17
|
+
- Migrate the policy-audit database transactionally to schema v2. Existing
|
|
18
|
+
aggregate counts and `collecting_since` are preserved, while the new
|
|
19
|
+
`recommendations_since` boundary prevents pre-migration data from producing
|
|
20
|
+
allow suggestions. Valid custom tool names and sanitized Bash templates are
|
|
21
|
+
retained for reporting. Templates replace paths, URLs, assignments, quoted
|
|
22
|
+
values, and option values with wildcards; raw commands and non-Bash tool
|
|
23
|
+
arguments or inputs remain excluded.
|
|
24
|
+
|
|
25
|
+
## 0.13.0 - 2026-08-28
|
|
26
|
+
|
|
27
|
+
- Add compatibility with `@gotgenes/pi-permission-system` 27.1 directional
|
|
28
|
+
`path_*` and `external_directory_*` surfaces while retaining the original
|
|
29
|
+
surface in authorization evidence, UI binding, logs, and policy audits.
|
|
30
|
+
- Keep the permission-system peer range at `^27.0.0` so existing 27.x bare
|
|
31
|
+
`path` and `external_directory` integrations remain supported.
|
|
32
|
+
|
|
3
33
|
## 0.12.0 - 2026-08-27
|
|
4
34
|
|
|
5
35
|
- Add the private policy audit
|
package/README.md
CHANGED
|
@@ -334,11 +334,18 @@ only daily, redacted aggregates. Collection is enabled by default and retains
|
|
|
334
334
|
180 days. It starts when this version first initializes successfully; no
|
|
335
335
|
permission-system JSONL or RTK history is read or imported.
|
|
336
336
|
|
|
337
|
-
Before storage, Bash values become
|
|
338
|
-
`
|
|
337
|
+
Before storage, Bash values become observed command templates such as
|
|
338
|
+
`git status --short`, `cat *`, or `npm test * --token *`. URLs, paths,
|
|
339
|
+
assignments, quoted values, and option values are replaced with `*`; safe bare
|
|
340
|
+
command words remain in plaintext so the report can emit a matching rule.
|
|
341
|
+
Paths used for path-surface statistics become only
|
|
339
342
|
`workspace`, `temp`, `home`, `external`, `sensitive`, or `unknown`. Request IDs,
|
|
340
|
-
project locations, and matched-rule patterns are HMACed.
|
|
341
|
-
|
|
343
|
+
project locations, and matched-rule patterns are HMACed. Syntactically valid
|
|
344
|
+
custom tool names are also retained in plaintext; invalid names stay anonymous
|
|
345
|
+
as `<custom-tool>`. The database never stores raw commands, raw paths,
|
|
346
|
+
URLs, credentials, or non-Bash tool arguments and inputs. Because safe bare Bash
|
|
347
|
+
words are retained, command templates can contain non-secret filenames or
|
|
348
|
+
project-specific labels; inspect suggestions before copying them.
|
|
342
349
|
|
|
343
350
|
Data lives at
|
|
344
351
|
`~/.pi/agent/extensions/pi-auto-review/policy-audit.sqlite`, beside an
|
|
@@ -357,6 +364,31 @@ Run `/auto-review-policy-audit` for a durable TUI report that is not sent to the
|
|
|
357
364
|
LLM. Options are `--days 1..retention`, `--top 1..50`, `--min-count >=1`, and
|
|
358
365
|
`--scope current|all`; defaults are `30`, `20`, `5`, and `current`.
|
|
359
366
|
|
|
367
|
+
In addition to the redacted statistics, the report proposes copyable
|
|
368
|
+
permission-system fragments. `--scope current` targets
|
|
369
|
+
`.pi/extensions/pi-permission-system/config.json`; `--scope all` targets
|
|
370
|
+
`~/.pi/agent/extensions/pi-permission-system/config.json`. The extension never
|
|
371
|
+
reads, edits, or rewrites either file. Merge suggested Bash patterns into the
|
|
372
|
+
existing `permission.bash` map and place the narrow allow entries after broader
|
|
373
|
+
matching ask entries, because permission-system uses last-match-wins.
|
|
374
|
+
|
|
375
|
+
Suggestions are discovered entirely from observed audit data; there is no
|
|
376
|
+
built-in tool, executable, Git action, package-manager action, or read-only
|
|
377
|
+
command catalog. Every valid observed permission surface and reliably templated
|
|
378
|
+
Bash command can qualify, including previously unknown tools and write
|
|
379
|
+
operations. Environment prefixes, compound syntax, pipelines, redirection,
|
|
380
|
+
path executables, and unreliable parsing block that Bash template. Forwarded
|
|
381
|
+
requests are statistics-only because the requester's cwd is not available. A
|
|
382
|
+
candidate needs at least `--min-count` successful ask-path approvals and no
|
|
383
|
+
denial, gate error, unavailable confirmation, blocked structural variant, or
|
|
384
|
+
forwarded-only evidence in the selected window. Existing policy/infrastructure
|
|
385
|
+
allows do not count as evidence of user friction. Repeated approval is evidence
|
|
386
|
+
of user preference, not independent proof that a capability is safe.
|
|
387
|
+
|
|
388
|
+
Schema v2 migrates v1 aggregates transactionally. Old totals remain visible,
|
|
389
|
+
`collecting_since` is preserved, and `recommendations_since` records when safe
|
|
390
|
+
recommendation evidence began; pre-migration data cannot produce a suggestion.
|
|
391
|
+
|
|
360
392
|
The report is an extension-owned custom entry. It is deliberately not exposed
|
|
361
393
|
as an Agent tool or packaged skill, so neither a tool schema nor skill metadata
|
|
362
394
|
is added to the model context. Permission changes remain a separate, explicit
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erichll/pi-auto-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Fail-closed, model-backed approval broker for Pi: deterministically hard-denies dangerous operations, reviews dangerous boundaries with a reviewer model, and issues one-shot expiring grants that OS sandbox adapters must consume before retrying.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@earendil-works/pi-ai": "^0.84.1",
|
|
56
56
|
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
57
|
-
"@gotgenes/pi-permission-system": "
|
|
57
|
+
"@gotgenes/pi-permission-system": "27.1.0",
|
|
58
58
|
"@types/node": "^26.2.0",
|
|
59
59
|
"typescript": "^6.0.3"
|
|
60
60
|
}
|
package/src/index.ts
CHANGED
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
type PolicyAuditArguments,
|
|
73
73
|
type PolicyAuditConfig,
|
|
74
74
|
} from "./policy-audit/index.ts";
|
|
75
|
+
import { isPathSurface, pathSurfaceInfo } from "./path-surfaces.ts";
|
|
75
76
|
export { parseHostPort };
|
|
76
77
|
|
|
77
78
|
type ReasoningLevel =
|
|
@@ -697,6 +698,7 @@ function protectedWriteHardDeny(
|
|
|
697
698
|
): { rule: string; reason: string } | undefined {
|
|
698
699
|
const isWrite =
|
|
699
700
|
request.surface === "filesystem-write" ||
|
|
701
|
+
pathSurfaceInfo(request.surface)?.effect === "write" ||
|
|
700
702
|
/\b(?:write|create|delete|rename|chmod|chown)\b/i.test(
|
|
701
703
|
request.operation,
|
|
702
704
|
);
|
|
@@ -773,7 +775,7 @@ function sessionConfig(
|
|
|
773
775
|
}
|
|
774
776
|
|
|
775
777
|
function boundedRequest(surface: string): boolean {
|
|
776
|
-
return
|
|
778
|
+
return isPathSurface(surface);
|
|
777
779
|
}
|
|
778
780
|
|
|
779
781
|
type PermissionsService = {
|
|
@@ -796,7 +798,7 @@ function boundaryRequest(
|
|
|
796
798
|
const surface = evidence.surface;
|
|
797
799
|
const value =
|
|
798
800
|
evidence.resolvedPath ??
|
|
799
|
-
(surface
|
|
801
|
+
(isPathSurface(surface)
|
|
800
802
|
? evidence.path
|
|
801
803
|
: evidence.command ?? evidence.value ?? evidence.destination) ??
|
|
802
804
|
details.skillName ??
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type PathSurfaceFamily = "path" | "external_directory";
|
|
2
|
+
export type PathSurfaceEffect = "read" | "write";
|
|
3
|
+
|
|
4
|
+
export type PathSurfaceInfo = {
|
|
5
|
+
family: PathSurfaceFamily;
|
|
6
|
+
effect?: PathSurfaceEffect;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const PATH_SURFACES: Readonly<Record<string, PathSurfaceInfo>> = Object.freeze({
|
|
10
|
+
path: Object.freeze({ family: "path" }),
|
|
11
|
+
path_read: Object.freeze({ family: "path", effect: "read" }),
|
|
12
|
+
path_write: Object.freeze({ family: "path", effect: "write" }),
|
|
13
|
+
external_directory: Object.freeze({ family: "external_directory" }),
|
|
14
|
+
external_directory_read: Object.freeze({
|
|
15
|
+
family: "external_directory",
|
|
16
|
+
effect: "read",
|
|
17
|
+
}),
|
|
18
|
+
external_directory_write: Object.freeze({
|
|
19
|
+
family: "external_directory",
|
|
20
|
+
effect: "write",
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Return metadata only for permission-system's six explicit path surfaces. */
|
|
25
|
+
export function pathSurfaceInfo(surface: unknown): PathSurfaceInfo | undefined {
|
|
26
|
+
return typeof surface === "string" ? PATH_SURFACES[surface] : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isPathSurface(surface: unknown): boolean {
|
|
30
|
+
return pathSurfaceInfo(surface) !== undefined;
|
|
31
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { homedir, tmpdir } from "node:os";
|
|
2
2
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { pathSurfaceInfo } from "../path-surfaces.ts";
|
|
3
4
|
|
|
4
5
|
export type PolicyAuditRisk =
|
|
5
6
|
| "read_only"
|
|
@@ -22,8 +23,18 @@ export type PolicyAuditCommandFeature =
|
|
|
22
23
|
| "env_prefix"
|
|
23
24
|
| "compound"
|
|
24
25
|
| "pipeline"
|
|
26
|
+
| "redirection"
|
|
25
27
|
| "relative_executable";
|
|
26
28
|
|
|
29
|
+
export type PolicyConfigCandidate = {
|
|
30
|
+
surface: string;
|
|
31
|
+
pattern?: string;
|
|
32
|
+
action: "allow";
|
|
33
|
+
safetyClass: "observed_bash_template" | "observed_surface";
|
|
34
|
+
eligible: boolean;
|
|
35
|
+
blocker?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
27
38
|
export type ClassifiedPermission = {
|
|
28
39
|
surface: string;
|
|
29
40
|
signature: string;
|
|
@@ -31,45 +42,12 @@ export type ClassifiedPermission = {
|
|
|
31
42
|
risk: PolicyAuditRisk;
|
|
32
43
|
pathClass: PolicyAuditPathClass;
|
|
33
44
|
features: readonly PolicyAuditCommandFeature[];
|
|
45
|
+
candidate?: PolicyConfigCandidate;
|
|
34
46
|
};
|
|
35
47
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
"bash_escalated",
|
|
39
|
-
"read",
|
|
40
|
-
"write",
|
|
41
|
-
"edit",
|
|
42
|
-
"grep",
|
|
43
|
-
"find",
|
|
44
|
-
"ls",
|
|
45
|
-
"path",
|
|
46
|
-
"external_directory",
|
|
47
|
-
"skill",
|
|
48
|
-
"mcp",
|
|
49
|
-
"network",
|
|
50
|
-
]);
|
|
51
|
-
|
|
48
|
+
const SAFE_SURFACE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
|
|
49
|
+
const SAFE_COMMAND_WORD = /^[A-Za-z0-9][A-Za-z0-9+._:-]{0,63}$/;
|
|
52
50
|
const SENSITIVE_PATH = /(?:^|[/\\])(?:\.ssh|\.aws|\.gnupg|\.kube|\.azure|\.npmrc|\.netrc|\.env(?:\.[^/\\]+)?|auth\.json|credentials?)(?:[/\\]|$)/i;
|
|
53
|
-
const READ_ONLY_COMMANDS = new Set([
|
|
54
|
-
"cat", "find", "grep", "head", "ls", "pwd", "rg", "sed", "sort",
|
|
55
|
-
"stat", "tail", "wc", "which",
|
|
56
|
-
]);
|
|
57
|
-
const MUTATING_COMMANDS = new Set([
|
|
58
|
-
"chmod", "chown", "cp", "install", "ln", "mkdir", "mv", "rm", "rmdir",
|
|
59
|
-
"touch", "truncate",
|
|
60
|
-
]);
|
|
61
|
-
const NETWORK_COMMANDS = new Set(["curl", "dig", "host", "nc", "ping", "ssh", "wget"]);
|
|
62
|
-
const SHELL_COMMANDS = new Set(["bash", "dash", "fish", "sh", "zsh"]);
|
|
63
|
-
const GIT_ACTIONS = new Set([
|
|
64
|
-
"add", "branch", "checkout", "clean", "clone", "commit", "diff", "fetch",
|
|
65
|
-
"log", "merge", "pull", "push", "rebase", "remote", "reset", "restore",
|
|
66
|
-
"send-pack", "show", "status", "switch", "tag",
|
|
67
|
-
]);
|
|
68
|
-
const RTK_ACTIONS = new Set(["diff", "find", "grep", "ls", "read", "rg", "smart", "wc"]);
|
|
69
|
-
const PACKAGE_ACTIONS = new Set([
|
|
70
|
-
"add", "build", "check", "coverage", "dev", "format", "install", "lint",
|
|
71
|
-
"publish", "start", "test", "typecheck", "uninstall", "update", "upgrade",
|
|
72
|
-
]);
|
|
73
51
|
|
|
74
52
|
function within(path: string, parent: string): boolean {
|
|
75
53
|
const rel = relative(parent, path);
|
|
@@ -85,12 +63,8 @@ export function classifyPath(
|
|
|
85
63
|
if (typeof value !== "string" || !value.trim()) return "unknown";
|
|
86
64
|
const raw = value.trim();
|
|
87
65
|
if (SENSITIVE_PATH.test(raw)) return "sensitive";
|
|
88
|
-
if (!raw.startsWith("/") && !raw.startsWith("~") && !raw.startsWith("."))
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
const expanded = raw === "~" || raw.startsWith("~/")
|
|
92
|
-
? resolve(home, raw.slice(2))
|
|
93
|
-
: resolve(cwd, raw);
|
|
66
|
+
if (!raw.startsWith("/") && !raw.startsWith("~") && !raw.startsWith(".")) return "unknown";
|
|
67
|
+
const expanded = raw === "~" || raw.startsWith("~/") ? resolve(home, raw.slice(2)) : resolve(cwd, raw);
|
|
94
68
|
if (within(expanded, resolve(cwd))) return "workspace";
|
|
95
69
|
if (within(expanded, resolve(temp))) return "temp";
|
|
96
70
|
if (within(expanded, resolve(home))) return "home";
|
|
@@ -98,137 +72,145 @@ export function classifyPath(
|
|
|
98
72
|
}
|
|
99
73
|
|
|
100
74
|
function shellTokens(command: string): string[] {
|
|
101
|
-
return command
|
|
102
|
-
.trim()
|
|
103
|
-
.split(/\s+/u)
|
|
104
|
-
.map((token) => token.replace(/^["']|["']$/g, ""));
|
|
75
|
+
return command.trim().split(/\s+/u);
|
|
105
76
|
}
|
|
106
77
|
|
|
107
78
|
function executableIndex(tokens: readonly string[]): number {
|
|
108
79
|
let index = 0;
|
|
109
80
|
if (tokens[index] === "env") index++;
|
|
110
|
-
while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(tokens[index]))
|
|
111
|
-
index++;
|
|
112
|
-
}
|
|
81
|
+
while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(tokens[index])) index++;
|
|
113
82
|
return index;
|
|
114
83
|
}
|
|
115
84
|
|
|
116
|
-
function
|
|
117
|
-
if (!token)
|
|
118
|
-
const
|
|
119
|
-
return
|
|
85
|
+
function safeExecutable(token: string | undefined): string | undefined {
|
|
86
|
+
if (!token || token.includes("/") || token.startsWith(".")) return undefined;
|
|
87
|
+
const unquoted = token.replace(/^["']|["']$/g, "");
|
|
88
|
+
return SAFE_COMMAND_WORD.test(unquoted) ? unquoted : undefined;
|
|
120
89
|
}
|
|
121
90
|
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
return "<other-command>";
|
|
91
|
+
function reliableShellText(command: string): boolean {
|
|
92
|
+
let quote = "";
|
|
93
|
+
let escaped = false;
|
|
94
|
+
for (const character of command) {
|
|
95
|
+
if (escaped) {
|
|
96
|
+
escaped = false;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (character === "\\" && quote !== "'") {
|
|
100
|
+
escaped = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (quote) {
|
|
104
|
+
if (character === quote) quote = "";
|
|
105
|
+
} else if (character === "'" || character === '"') {
|
|
106
|
+
quote = character;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return !quote && !escaped;
|
|
143
110
|
}
|
|
144
111
|
|
|
145
|
-
function
|
|
146
|
-
if (
|
|
147
|
-
if ([
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
112
|
+
function sanitizedTemplateToken(token: string, followsOption: boolean): string {
|
|
113
|
+
if (!token || /^['"]/.test(token) || /[/\\@]/.test(token) || /:\/\//.test(token)) return "*";
|
|
114
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) return "*";
|
|
115
|
+
if (token.startsWith("--") && token.includes("=")) {
|
|
116
|
+
const name = token.slice(0, token.indexOf("="));
|
|
117
|
+
return /^--[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name) ? `${name}=*` : "*";
|
|
118
|
+
}
|
|
119
|
+
if (/^--?[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(token)) return token;
|
|
120
|
+
if (followsOption) return "*";
|
|
121
|
+
return SAFE_COMMAND_WORD.test(token) ? token : "*";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function observedBashTemplate(tokens: readonly string[], index: number): string | undefined {
|
|
125
|
+
const executable = safeExecutable(tokens[index]);
|
|
126
|
+
if (!executable) return undefined;
|
|
127
|
+
const template = [executable];
|
|
128
|
+
let followsOption = false;
|
|
129
|
+
for (const rawToken of tokens.slice(index + 1)) {
|
|
130
|
+
const token = sanitizedTemplateToken(rawToken, followsOption);
|
|
131
|
+
if (template.at(-1) !== "*" || token !== "*") template.push(token);
|
|
132
|
+
followsOption = /^--?[A-Za-z0-9]/.test(rawToken) && !rawToken.includes("=");
|
|
133
|
+
}
|
|
134
|
+
return template.join(" ");
|
|
167
135
|
}
|
|
168
136
|
|
|
169
137
|
export function classifyBash(command: unknown, cwd: string): ClassifiedPermission {
|
|
170
138
|
if (typeof command !== "string" || !command.trim()) {
|
|
171
|
-
return { surface: "bash", signature: "<
|
|
139
|
+
return { surface: "bash", signature: "<command>", bashCategory: "unknown", risk: "unknown", pathClass: "unknown", features: [] };
|
|
172
140
|
}
|
|
173
141
|
const tokens = shellTokens(command);
|
|
174
142
|
const index = executableIndex(tokens);
|
|
175
|
-
const executable = safeCommandName(tokens[index]);
|
|
176
|
-
const signature = knownSignature(tokens, index);
|
|
177
143
|
const features: PolicyAuditCommandFeature[] = [];
|
|
178
144
|
if (index > 0) features.push("env_prefix");
|
|
179
145
|
if (/&&|\|\||;|\n|`|\$\(/.test(command)) features.push("compound");
|
|
180
146
|
if (/(^|[^|])\|([^|]|$)/.test(command)) features.push("pipeline");
|
|
147
|
+
if (/[<>]/.test(command)) features.push("redirection");
|
|
181
148
|
if (tokens[index]?.includes("/") || tokens[index]?.startsWith(".")) features.push("relative_executable");
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
149
|
+
const uniqueFeatures = [...new Set(features)].sort();
|
|
150
|
+
const structuralBoundary = command.search(/&&|\|\||[;|<>\n]|`|\$\(/);
|
|
151
|
+
const templateTokens = shellTokens(structuralBoundary >= 0 ? command.slice(0, structuralBoundary) : command);
|
|
152
|
+
const template = observedBashTemplate(templateTokens, executableIndex(templateTokens));
|
|
153
|
+
let blocker: string | undefined;
|
|
154
|
+
if (!reliableShellText(command) || !template) blocker = "command could not be templated reliably";
|
|
155
|
+
else if (uniqueFeatures.length > 0) blocker = `unsafe command feature: ${uniqueFeatures.join(", ")}`;
|
|
156
|
+
const candidate: PolicyConfigCandidate | undefined = template ? {
|
|
157
|
+
surface: "bash",
|
|
158
|
+
pattern: template,
|
|
159
|
+
action: "allow",
|
|
160
|
+
safetyClass: "observed_bash_template",
|
|
161
|
+
eligible: blocker === undefined,
|
|
162
|
+
...(blocker ? { blocker } : {}),
|
|
163
|
+
} : undefined;
|
|
191
164
|
const pathToken = tokens.slice(index + 1)
|
|
192
|
-
.map((token) => token.
|
|
165
|
+
.map((token) => token.replace(/^@/, ""))
|
|
193
166
|
.find((token) => token.startsWith("/") || token.startsWith("./") || token.startsWith("../") || token.startsWith("~/"));
|
|
194
167
|
return {
|
|
195
168
|
surface: "bash",
|
|
196
|
-
signature,
|
|
197
|
-
bashCategory,
|
|
198
|
-
risk:
|
|
169
|
+
signature: template ?? "<command>",
|
|
170
|
+
bashCategory: uniqueFeatures.length === 0 ? "simple" : "structured",
|
|
171
|
+
risk: uniqueFeatures.includes("compound") || uniqueFeatures.includes("pipeline")
|
|
172
|
+
? "arbitrary_shell"
|
|
173
|
+
: uniqueFeatures.includes("redirection") ? "workspace_mutation" : "unknown",
|
|
199
174
|
pathClass: classifyPath(pathToken, cwd),
|
|
200
|
-
features:
|
|
175
|
+
features: uniqueFeatures,
|
|
176
|
+
...(candidate ? { candidate } : {}),
|
|
201
177
|
};
|
|
202
178
|
}
|
|
203
179
|
|
|
204
180
|
export function classifyPermission(surfaceValue: unknown, value: unknown, cwd: string): ClassifiedPermission {
|
|
205
|
-
const
|
|
206
|
-
|
|
181
|
+
const suppliedSurface = typeof surfaceValue === "string" ? surfaceValue.trim() : "";
|
|
182
|
+
const normalizedSurface = suppliedSurface.toLowerCase();
|
|
183
|
+
if (normalizedSurface === "bash" || normalizedSurface === "bash_escalated") {
|
|
207
184
|
const classified = classifyBash(value, cwd);
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const risk = surface === "read"
|
|
214
|
-
? "read_only"
|
|
215
|
-
: surface === "write" || surface === "edit"
|
|
216
|
-
? "workspace_mutation"
|
|
217
|
-
: "unknown";
|
|
218
|
-
return { surface, signature: `<${surface}>`, bashCategory: "not_bash", risk, pathClass, features: [] };
|
|
219
|
-
}
|
|
220
|
-
if (["grep", "find", "ls"].includes(surface)) {
|
|
221
|
-
return { surface, signature: `<${surface}>`, bashCategory: "not_bash", risk: "read_only", pathClass: "unknown", features: [] };
|
|
222
|
-
}
|
|
223
|
-
if (surface === "network") {
|
|
224
|
-
return { surface, signature: "<network>", bashCategory: "not_bash", risk: "network", pathClass: "unknown", features: [] };
|
|
185
|
+
if (normalizedSurface === "bash_escalated") {
|
|
186
|
+
const { candidate: _candidate, ...statisticsOnly } = classified;
|
|
187
|
+
return { ...statisticsOnly, surface: normalizedSurface };
|
|
188
|
+
}
|
|
189
|
+
return classified;
|
|
225
190
|
}
|
|
191
|
+
|
|
192
|
+
const validSurface = SAFE_SURFACE.test(suppliedSurface) ? suppliedSurface : undefined;
|
|
193
|
+
const pathSurface = pathSurfaceInfo(normalizedSurface);
|
|
194
|
+
const pathLike = pathSurface !== undefined || normalizedSurface === "read" || normalizedSurface === "write" || normalizedSurface === "edit";
|
|
195
|
+
const pathClass = pathLike ? classifyPath(value, cwd) : "unknown";
|
|
196
|
+
const risk = pathSurface?.effect === "read" || normalizedSurface === "read"
|
|
197
|
+
? "read_only"
|
|
198
|
+
: pathSurface?.effect === "write" || normalizedSurface === "write" || normalizedSurface === "edit"
|
|
199
|
+
? "workspace_mutation"
|
|
200
|
+
: "unknown";
|
|
201
|
+
const candidate: PolicyConfigCandidate | undefined = validSurface ? {
|
|
202
|
+
surface: validSurface,
|
|
203
|
+
action: "allow",
|
|
204
|
+
safetyClass: "observed_surface",
|
|
205
|
+
eligible: true,
|
|
206
|
+
} : undefined;
|
|
226
207
|
return {
|
|
227
|
-
surface,
|
|
228
|
-
signature:
|
|
208
|
+
surface: validSurface ?? "custom_tool",
|
|
209
|
+
signature: validSurface ?? "<custom-tool>",
|
|
229
210
|
bashCategory: "not_bash",
|
|
230
|
-
risk
|
|
231
|
-
pathClass
|
|
211
|
+
risk,
|
|
212
|
+
pathClass,
|
|
232
213
|
features: [],
|
|
214
|
+
...(candidate ? { candidate } : {}),
|
|
233
215
|
};
|
|
234
216
|
}
|
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
import type { PolicyAuditAggregateRow, PolicyAuditQueryResult } from "./store.ts";
|
|
2
2
|
|
|
3
|
-
export const POLICY_AUDIT_REPORT_VERSION =
|
|
3
|
+
export const POLICY_AUDIT_REPORT_VERSION = 2 as const;
|
|
4
4
|
|
|
5
5
|
export type PolicyAuditReportItem = { name: string; count: number; denied: number; denialRate: number };
|
|
6
|
+
export type SuggestedAllowRule = {
|
|
7
|
+
surface: string; pattern?: string; action: "allow"; safetyClass: string;
|
|
8
|
+
successfulEvidence: number; denied: 0; rationale: string;
|
|
9
|
+
};
|
|
10
|
+
export type PolicyRecommendationItem = {
|
|
11
|
+
surface: string; pattern?: string; successfulEvidence: number; denied: number; reason: string;
|
|
12
|
+
};
|
|
6
13
|
|
|
7
14
|
export type PolicyAuditReport = {
|
|
8
15
|
version: typeof POLICY_AUDIT_REPORT_VERSION;
|
|
9
16
|
scope: "current" | "all";
|
|
10
17
|
days: number;
|
|
11
18
|
collectingSince: string;
|
|
19
|
+
recommendationsSince: string;
|
|
12
20
|
fromDay: string;
|
|
13
21
|
throughDay: string;
|
|
14
22
|
total: number;
|
|
15
23
|
allowed: number;
|
|
16
24
|
denied: number;
|
|
17
25
|
denialRate: number;
|
|
26
|
+
configTarget: string;
|
|
27
|
+
configFragment: string;
|
|
28
|
+
suggestedAllowRules: SuggestedAllowRule[];
|
|
29
|
+
keepAskRules: PolicyRecommendationItem[];
|
|
30
|
+
insufficientEvidence: PolicyRecommendationItem[];
|
|
18
31
|
surfaces: PolicyAuditReportItem[];
|
|
19
32
|
tools: PolicyAuditReportItem[];
|
|
20
33
|
bashCategories: PolicyAuditReportItem[];
|
|
@@ -26,21 +39,21 @@ export type PolicyAuditReport = {
|
|
|
26
39
|
};
|
|
27
40
|
|
|
28
41
|
export type PolicyAuditReportOptions = {
|
|
29
|
-
scope: "current" | "all";
|
|
30
|
-
days: number;
|
|
31
|
-
top: number;
|
|
32
|
-
minCount: number;
|
|
42
|
+
scope: "current" | "all"; days: number; top: number; minCount: number;
|
|
33
43
|
};
|
|
34
44
|
|
|
45
|
+
const SUCCESS_RESOLUTIONS = new Set([
|
|
46
|
+
"user_approved", "user_approved_for_session", "session_approved", "auto_approved",
|
|
47
|
+
]);
|
|
48
|
+
const FAILURE_RESOLUTIONS = new Set(["confirmation_unavailable", "gate_error"]);
|
|
49
|
+
|
|
35
50
|
function rate(denied: number, total: number): number {
|
|
36
51
|
return total === 0 ? 0 : Math.round((denied / total) * 10_000) / 100;
|
|
37
52
|
}
|
|
38
53
|
|
|
39
54
|
function rank(
|
|
40
|
-
rows: readonly PolicyAuditAggregateRow[],
|
|
41
|
-
|
|
42
|
-
top: number,
|
|
43
|
-
minCount: number,
|
|
55
|
+
rows: readonly PolicyAuditAggregateRow[], key: (row: PolicyAuditAggregateRow) => string,
|
|
56
|
+
top: number, minCount: number,
|
|
44
57
|
): PolicyAuditReportItem[] {
|
|
45
58
|
const grouped = new Map<string, { count: number; denied: number }>();
|
|
46
59
|
for (const row of rows) {
|
|
@@ -61,16 +74,128 @@ function reviewed(row: PolicyAuditAggregateRow): boolean {
|
|
|
61
74
|
return ["user_approved", "user_approved_for_session", "user_denied", "auto_approved"].includes(row.resolution);
|
|
62
75
|
}
|
|
63
76
|
|
|
77
|
+
type CandidateGroup = {
|
|
78
|
+
surface: string; pattern?: string; safetyClass: string; successfulEvidence: number;
|
|
79
|
+
denied: number; blockers: Set<string>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function recommendationGroups(rows: readonly PolicyAuditAggregateRow[]): CandidateGroup[] {
|
|
83
|
+
const groups = new Map<string, CandidateGroup>();
|
|
84
|
+
for (const row of rows) {
|
|
85
|
+
if (!row.candidateSurface) continue;
|
|
86
|
+
// Forwarded evidence is reported separately: it cannot prove either the
|
|
87
|
+
// current project's cwd or a safe global scope, and must not poison local
|
|
88
|
+
// evidence for the same observed candidate.
|
|
89
|
+
const key = `${row.candidateSurface}\0${row.candidatePattern}\0${row.forwarded ? "forwarded" : "local"}`;
|
|
90
|
+
const group = groups.get(key) ?? {
|
|
91
|
+
surface: row.candidateSurface,
|
|
92
|
+
...(row.candidatePattern ? { pattern: row.candidatePattern } : {}),
|
|
93
|
+
safetyClass: row.candidateSafetyClass,
|
|
94
|
+
successfulEvidence: 0,
|
|
95
|
+
denied: 0,
|
|
96
|
+
blockers: new Set<string>(),
|
|
97
|
+
};
|
|
98
|
+
if (row.result === "allow" && SUCCESS_RESOLUTIONS.has(row.resolution)) group.successfulEvidence += row.count;
|
|
99
|
+
if (row.result === "deny") group.denied += row.count;
|
|
100
|
+
if (row.forwarded) group.blockers.add("forwarded requests lack the requesting cwd");
|
|
101
|
+
if (!row.candidateEligible) group.blockers.add(row.candidateBlocker || "unsafe or unparseable variant observed");
|
|
102
|
+
if (FAILURE_RESOLUTIONS.has(row.resolution)) group.blockers.add(`authorization failure: ${row.resolution}`);
|
|
103
|
+
groups.set(key, group);
|
|
104
|
+
}
|
|
105
|
+
return [...groups.values()].sort((a, b) =>
|
|
106
|
+
b.successfulEvidence - a.successfulEvidence || a.surface.localeCompare(b.surface) ||
|
|
107
|
+
(a.pattern ?? "").localeCompare(b.pattern ?? "")
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function displayRule(item: { surface: string; pattern?: string }): string {
|
|
112
|
+
return item.pattern ? `${item.surface}[${JSON.stringify(item.pattern)}]` : item.surface;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildConfigFragment(rules: readonly SuggestedAllowRule[]): string {
|
|
116
|
+
const bash: Record<string, "allow"> = {};
|
|
117
|
+
const permission: Record<string, "allow" | Record<string, "allow">> = {};
|
|
118
|
+
for (const rule of rules) {
|
|
119
|
+
if (rule.surface === "bash" && rule.pattern) bash[rule.pattern] = "allow";
|
|
120
|
+
}
|
|
121
|
+
if (Object.keys(bash).length > 0) permission.bash = bash;
|
|
122
|
+
for (const rule of rules) {
|
|
123
|
+
if (rule.surface !== "bash") permission[rule.surface] = "allow";
|
|
124
|
+
}
|
|
125
|
+
return JSON.stringify({ permission }, null, 2);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function genericKeepAskRules(
|
|
129
|
+
rows: readonly PolicyAuditAggregateRow[], minCount: number, top: number,
|
|
130
|
+
): PolicyRecommendationItem[] {
|
|
131
|
+
const grouped = new Map<string, { surface: string; risk: string; total: number; successful: number; denied: number }>();
|
|
132
|
+
for (const row of rows) {
|
|
133
|
+
if (row.candidateSurface) continue;
|
|
134
|
+
const surface = row.surface === "custom_tool" ? row.signature : `${row.surface}:${row.signature}`;
|
|
135
|
+
const key = `${surface}\0${row.risk}`;
|
|
136
|
+
const group = grouped.get(key) ?? { surface, risk: row.risk, total: 0, successful: 0, denied: 0 };
|
|
137
|
+
group.total += row.count;
|
|
138
|
+
if (row.result === "allow" && SUCCESS_RESOLUTIONS.has(row.resolution)) group.successful += row.count;
|
|
139
|
+
if (row.result === "deny") group.denied += row.count;
|
|
140
|
+
grouped.set(key, group);
|
|
141
|
+
}
|
|
142
|
+
return [...grouped.values()]
|
|
143
|
+
.filter((group) => group.total >= minCount)
|
|
144
|
+
.sort((a, b) => b.total - a.total || a.surface.localeCompare(b.surface))
|
|
145
|
+
.slice(0, top)
|
|
146
|
+
.map((group) => ({
|
|
147
|
+
surface: group.surface,
|
|
148
|
+
successfulEvidence: group.successful,
|
|
149
|
+
denied: group.denied,
|
|
150
|
+
reason: `${group.total} observed but no valid copyable permission rule could be derived (${group.risk})`,
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
|
|
64
154
|
export function buildPolicyAuditReport(
|
|
65
|
-
result: PolicyAuditQueryResult,
|
|
66
|
-
options: PolicyAuditReportOptions,
|
|
155
|
+
result: PolicyAuditQueryResult, options: PolicyAuditReportOptions,
|
|
67
156
|
): PolicyAuditReport {
|
|
68
157
|
const total = result.rows.reduce((sum, row) => sum + row.count, 0);
|
|
69
158
|
const denied = result.rows.filter((row) => row.result === "deny").reduce((sum, row) => sum + row.count, 0);
|
|
70
159
|
const lowRisk = result.rows.filter((row) => row.risk === "read_only" && reviewed(row));
|
|
71
|
-
const keepAsk = result.rows.filter((row) =>
|
|
72
|
-
|
|
73
|
-
|
|
160
|
+
const keepAsk = result.rows.filter((row) => !row.candidateSurface);
|
|
161
|
+
const groups = recommendationGroups(result.rows);
|
|
162
|
+
const suggestedAllowRules: SuggestedAllowRule[] = groups
|
|
163
|
+
.filter((group) => group.successfulEvidence >= options.minCount && group.denied === 0 && group.blockers.size === 0)
|
|
164
|
+
.slice(0, options.top)
|
|
165
|
+
.map((group) => ({
|
|
166
|
+
surface: group.surface,
|
|
167
|
+
...(group.pattern ? { pattern: group.pattern } : {}),
|
|
168
|
+
action: "allow",
|
|
169
|
+
safetyClass: group.safetyClass,
|
|
170
|
+
successfulEvidence: group.successfulEvidence,
|
|
171
|
+
denied: 0,
|
|
172
|
+
rationale: group.safetyClass === "observed_bash_template"
|
|
173
|
+
? "observed Bash template; zero denials, failures, and structurally unsafe variants"
|
|
174
|
+
: "observed permission surface; zero denials and authorization failures",
|
|
175
|
+
}));
|
|
176
|
+
const candidateKeepAskRules: PolicyRecommendationItem[] = groups
|
|
177
|
+
.filter((group) => group.denied > 0 || group.blockers.size > 0)
|
|
178
|
+
.map((group) => ({
|
|
179
|
+
surface: group.surface,
|
|
180
|
+
...(group.pattern ? { pattern: group.pattern } : {}),
|
|
181
|
+
successfulEvidence: group.successfulEvidence,
|
|
182
|
+
denied: group.denied,
|
|
183
|
+
reason: [group.denied ? `${group.denied} denied` : "", ...group.blockers].filter(Boolean).join("; "),
|
|
184
|
+
}));
|
|
185
|
+
const keepAskRules = [
|
|
186
|
+
...candidateKeepAskRules,
|
|
187
|
+
...genericKeepAskRules(keepAsk, options.minCount, options.top),
|
|
188
|
+
].slice(0, options.top);
|
|
189
|
+
const insufficientEvidence: PolicyRecommendationItem[] = groups
|
|
190
|
+
.filter((group) => group.successfulEvidence > 0 && group.successfulEvidence < options.minCount && group.denied === 0 && group.blockers.size === 0)
|
|
191
|
+
.slice(0, options.top)
|
|
192
|
+
.map((group) => ({
|
|
193
|
+
surface: group.surface,
|
|
194
|
+
...(group.pattern ? { pattern: group.pattern } : {}),
|
|
195
|
+
successfulEvidence: group.successfulEvidence,
|
|
196
|
+
denied: 0,
|
|
197
|
+
reason: `needs ${options.minCount - group.successfulEvidence} more successful ask-path sample(s)`,
|
|
198
|
+
}));
|
|
74
199
|
const fingerprintCounts = new Map<string, number>();
|
|
75
200
|
for (const row of result.rows) {
|
|
76
201
|
if (row.ruleFingerprint === "none") continue;
|
|
@@ -78,8 +203,10 @@ export function buildPolicyAuditReport(
|
|
|
78
203
|
}
|
|
79
204
|
const warnings = [
|
|
80
205
|
"Counts begin at first successful enablement; no historical logs are imported.",
|
|
206
|
+
"Suggestions are evidence-based decision aids and never modify permission configuration.",
|
|
207
|
+
"Migrated pre-v2 counts remain in statistics but cannot contribute recommendation evidence.",
|
|
81
208
|
"Rule fingerprints are anonymous hit indicators, not proof that unlisted rules are unused.",
|
|
82
|
-
"
|
|
209
|
+
"A suggestion reflects repeated approval history, not an independent proof that the capability is safe.",
|
|
83
210
|
];
|
|
84
211
|
if (total === 0) warnings.unshift("No matching permission decisions were collected for this window and scope.");
|
|
85
212
|
return {
|
|
@@ -87,12 +214,20 @@ export function buildPolicyAuditReport(
|
|
|
87
214
|
scope: options.scope,
|
|
88
215
|
days: options.days,
|
|
89
216
|
collectingSince: result.collectingSince,
|
|
217
|
+
recommendationsSince: result.recommendationsSince,
|
|
90
218
|
fromDay: result.fromDay,
|
|
91
219
|
throughDay: result.throughDay,
|
|
92
220
|
total,
|
|
93
221
|
allowed: total - denied,
|
|
94
222
|
denied,
|
|
95
223
|
denialRate: rate(denied, total),
|
|
224
|
+
configTarget: options.scope === "current"
|
|
225
|
+
? ".pi/extensions/pi-permission-system/config.json"
|
|
226
|
+
: "~/.pi/agent/extensions/pi-permission-system/config.json",
|
|
227
|
+
configFragment: buildConfigFragment(suggestedAllowRules),
|
|
228
|
+
suggestedAllowRules,
|
|
229
|
+
keepAskRules,
|
|
230
|
+
insufficientEvidence,
|
|
96
231
|
surfaces: rank(result.rows, (row) => row.surface, options.top, options.minCount),
|
|
97
232
|
tools: rank(result.rows, (row) => row.signature, options.top, options.minCount),
|
|
98
233
|
bashCategories: rank(result.rows.filter((row) => row.bashCategory !== "not_bash"), (row) => row.bashCategory, options.top, options.minCount),
|
|
@@ -114,26 +249,38 @@ function itemLines(items: readonly PolicyAuditReportItem[]): string[] {
|
|
|
114
249
|
: items.map((item) => `- ${item.name}: ${item.count} (${item.denied} denied, ${item.denialRate}% denial)`);
|
|
115
250
|
}
|
|
116
251
|
|
|
252
|
+
function recommendationLines(items: readonly PolicyRecommendationItem[]): string[] {
|
|
253
|
+
return items.length === 0
|
|
254
|
+
? ["- None."]
|
|
255
|
+
: items.map((item) => `- ${displayRule(item)}: ${item.successfulEvidence} successful, ${item.denied} denied — ${item.reason}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
117
258
|
export function renderPolicyAuditMarkdown(report: PolicyAuditReport): string {
|
|
259
|
+
const suggested = report.suggestedAllowRules.length === 0
|
|
260
|
+
? ["- None."]
|
|
261
|
+
: report.suggestedAllowRules.map((item) =>
|
|
262
|
+
`- ${displayRule(item)} = allow: ${item.successfulEvidence} successful ask-path samples, zero denials — ${item.rationale}`
|
|
263
|
+
);
|
|
118
264
|
const fingerprints = report.ruleFingerprints.length === 0
|
|
119
265
|
? ["- None above the minimum count."]
|
|
120
266
|
: report.ruleFingerprints.map((item) => `- ${item.fingerprint}: ${item.count}`);
|
|
121
267
|
return [
|
|
122
|
-
"# Permission policy audit",
|
|
123
|
-
|
|
124
|
-
`
|
|
125
|
-
|
|
126
|
-
"",
|
|
268
|
+
"# Permission policy audit", "",
|
|
269
|
+
`Scope: **${report.scope}** · Window: **${report.fromDay}–${report.throughDay}** · Collecting since: **${report.collectingSince}** · Recommendations since: **${report.recommendationsSince}**`,
|
|
270
|
+
`Decisions: **${report.total}** · Allowed: **${report.allowed}** · Denied: **${report.denied}** · Denial rate: **${report.denialRate}%**`, "",
|
|
271
|
+
"## Suggested allow rules", "", ...suggested, "",
|
|
272
|
+
"## Copyable permission config", "", `Target: \`${report.configTarget}\``, "",
|
|
273
|
+
"```json", report.configFragment, "```", "",
|
|
274
|
+
"Merge Bash patterns into the existing `permission.bash` map at this same scope. Permission-system uses last-match-wins, so place these narrow allow patterns after any broader ask rule that would match them.", "",
|
|
275
|
+
"## Keep ask", "",
|
|
276
|
+
"Denied candidates, authorization failures, structurally unsafe Bash variants, forwarded requests, and values without a valid rule remain ask-by-default.",
|
|
277
|
+
...recommendationLines(report.keepAskRules), ...itemLines(report.keepAskRecommendations), "",
|
|
278
|
+
"## Insufficient evidence", "", ...recommendationLines(report.insufficientEvidence), "",
|
|
127
279
|
"## Surface hotspots", "", ...itemLines(report.surfaces), "",
|
|
128
280
|
"## Tool and command signatures", "", ...itemLines(report.tools), "",
|
|
129
281
|
"## Bash semantic categories", "", ...itemLines(report.bashCategories), "",
|
|
130
282
|
"## Approval sources", "", ...itemLines(report.approvalSources), "",
|
|
131
|
-
"## Low-risk review candidates", "",
|
|
132
|
-
"These read-only requests were still reviewed; inspect them before considering a narrower allow rule.",
|
|
133
|
-
...itemLines(report.lowRiskReviewCandidates), "",
|
|
134
|
-
"## Keep-ask recommendations", "",
|
|
135
|
-
"These mutation, execution, network, shell, or unknown requests should remain ask-by-default.",
|
|
136
|
-
...itemLines(report.keepAskRecommendations), "",
|
|
283
|
+
"## Low-risk review candidates", "", ...itemLines(report.lowRiskReviewCandidates), "",
|
|
137
284
|
"## Anonymous rule-hit fingerprints", "", ...fingerprints, "",
|
|
138
285
|
"## Warnings", "", ...report.warnings.map((warning) => `- ${warning}`),
|
|
139
286
|
].join("\n");
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { dirname, join, resolve } from "node:path";
|
|
13
13
|
import type { ClassifiedPermission } from "./classifier.ts";
|
|
14
14
|
|
|
15
|
-
export const POLICY_AUDIT_SCHEMA_VERSION =
|
|
15
|
+
export const POLICY_AUDIT_SCHEMA_VERSION = 2;
|
|
16
16
|
export const POLICY_AUDIT_DATABASE_NAME = "policy-audit.sqlite";
|
|
17
17
|
export const POLICY_AUDIT_KEY_NAME = "policy-audit.key";
|
|
18
18
|
|
|
@@ -65,11 +65,17 @@ export type PolicyAuditAggregateRow = {
|
|
|
65
65
|
forwarded: boolean;
|
|
66
66
|
features: string;
|
|
67
67
|
ruleFingerprint: string;
|
|
68
|
+
candidateSurface: string;
|
|
69
|
+
candidatePattern: string;
|
|
70
|
+
candidateSafetyClass: string;
|
|
71
|
+
candidateEligible: boolean;
|
|
72
|
+
candidateBlocker: string;
|
|
68
73
|
count: number;
|
|
69
74
|
};
|
|
70
75
|
|
|
71
76
|
export type PolicyAuditQueryResult = {
|
|
72
77
|
collectingSince: string;
|
|
78
|
+
recommendationsSince: string;
|
|
73
79
|
fromDay: string;
|
|
74
80
|
throughDay: string;
|
|
75
81
|
rows: PolicyAuditAggregateRow[];
|
|
@@ -149,6 +155,22 @@ export class PolicyAuditStore {
|
|
|
149
155
|
request_hash TEXT PRIMARY KEY,
|
|
150
156
|
seen_day TEXT NOT NULL
|
|
151
157
|
) STRICT;
|
|
158
|
+
`);
|
|
159
|
+
const version = this.db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value;
|
|
160
|
+
const now = this.now().toISOString();
|
|
161
|
+
if (version !== undefined && version !== "1" && version !== String(POLICY_AUDIT_SCHEMA_VERSION)) {
|
|
162
|
+
throw new Error("unsupported policy audit schema version");
|
|
163
|
+
}
|
|
164
|
+
if (version === "1") this.migrateV1(now);
|
|
165
|
+
else this.createV2Table();
|
|
166
|
+
this.db.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES('schema_version',?)").run(String(POLICY_AUDIT_SCHEMA_VERSION));
|
|
167
|
+
this.db.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES('collecting_since',?)").run(now);
|
|
168
|
+
this.db.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES('recommendations_since',?)").run(now);
|
|
169
|
+
this.secureSidecars();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private createV2Table(): void {
|
|
173
|
+
this.db.exec(`
|
|
152
174
|
CREATE TABLE IF NOT EXISTS daily_permission_stats (
|
|
153
175
|
day TEXT NOT NULL,
|
|
154
176
|
project_hash TEXT NOT NULL,
|
|
@@ -163,19 +185,45 @@ export class PolicyAuditStore {
|
|
|
163
185
|
forwarded INTEGER NOT NULL,
|
|
164
186
|
features TEXT NOT NULL,
|
|
165
187
|
rule_fingerprint TEXT NOT NULL,
|
|
188
|
+
candidate_surface TEXT NOT NULL,
|
|
189
|
+
candidate_pattern TEXT NOT NULL,
|
|
190
|
+
candidate_safety_class TEXT NOT NULL,
|
|
191
|
+
candidate_eligible INTEGER NOT NULL,
|
|
192
|
+
candidate_blocker TEXT NOT NULL,
|
|
166
193
|
count INTEGER NOT NULL,
|
|
167
|
-
PRIMARY KEY (day, project_hash, surface, signature, bash_category, risk, path_class, result, resolution, origin, forwarded, features, rule_fingerprint)
|
|
194
|
+
PRIMARY KEY (day, project_hash, surface, signature, bash_category, risk, path_class, result, resolution, origin, forwarded, features, rule_fingerprint, candidate_surface, candidate_pattern, candidate_safety_class, candidate_eligible, candidate_blocker)
|
|
168
195
|
) STRICT;
|
|
169
196
|
CREATE INDEX IF NOT EXISTS daily_permission_stats_project_day ON daily_permission_stats(project_hash, day);
|
|
170
197
|
`);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private migrateV1(now: string): void {
|
|
201
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
202
|
+
try {
|
|
203
|
+
this.db.exec(`
|
|
204
|
+
CREATE TABLE daily_permission_stats_v2 (
|
|
205
|
+
day TEXT NOT NULL, project_hash TEXT NOT NULL, surface TEXT NOT NULL, signature TEXT NOT NULL,
|
|
206
|
+
bash_category TEXT NOT NULL, risk TEXT NOT NULL, path_class TEXT NOT NULL, result TEXT NOT NULL,
|
|
207
|
+
resolution TEXT NOT NULL, origin TEXT NOT NULL, forwarded INTEGER NOT NULL, features TEXT NOT NULL,
|
|
208
|
+
rule_fingerprint TEXT NOT NULL, candidate_surface TEXT NOT NULL, candidate_pattern TEXT NOT NULL,
|
|
209
|
+
candidate_safety_class TEXT NOT NULL, candidate_eligible INTEGER NOT NULL, candidate_blocker TEXT NOT NULL,
|
|
210
|
+
count INTEGER NOT NULL,
|
|
211
|
+
PRIMARY KEY (day, project_hash, surface, signature, bash_category, risk, path_class, result, resolution, origin, forwarded, features, rule_fingerprint, candidate_surface, candidate_pattern, candidate_safety_class, candidate_eligible, candidate_blocker)
|
|
212
|
+
) STRICT;
|
|
213
|
+
INSERT INTO daily_permission_stats_v2
|
|
214
|
+
SELECT day,project_hash,surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,'','','',0,'',count
|
|
215
|
+
FROM daily_permission_stats;
|
|
216
|
+
DROP TABLE daily_permission_stats;
|
|
217
|
+
ALTER TABLE daily_permission_stats_v2 RENAME TO daily_permission_stats;
|
|
218
|
+
CREATE INDEX daily_permission_stats_project_day ON daily_permission_stats(project_hash, day);
|
|
219
|
+
`);
|
|
220
|
+
this.db.prepare("UPDATE meta SET value=? WHERE key='schema_version'").run(String(POLICY_AUDIT_SCHEMA_VERSION));
|
|
221
|
+
this.db.prepare("INSERT OR REPLACE INTO meta(key,value) VALUES('recommendations_since',?)").run(now);
|
|
222
|
+
this.db.exec("COMMIT");
|
|
223
|
+
} catch (error) {
|
|
224
|
+
try { this.db.exec("ROLLBACK"); } catch { /* preserve migration error */ }
|
|
225
|
+
throw error;
|
|
174
226
|
}
|
|
175
|
-
const now = this.now().toISOString();
|
|
176
|
-
this.db.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES('schema_version',?)").run(String(POLICY_AUDIT_SCHEMA_VERSION));
|
|
177
|
-
this.db.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES('collecting_since',?)").run(now);
|
|
178
|
-
this.secureSidecars();
|
|
179
227
|
}
|
|
180
228
|
|
|
181
229
|
private hash(kind: string, value: string): string {
|
|
@@ -219,8 +267,9 @@ export class PolicyAuditStore {
|
|
|
219
267
|
}
|
|
220
268
|
this.db.prepare(`
|
|
221
269
|
INSERT INTO daily_permission_stats(
|
|
222
|
-
day,project_hash,surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,
|
|
223
|
-
|
|
270
|
+
day,project_hash,surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,
|
|
271
|
+
candidate_surface,candidate_pattern,candidate_safety_class,candidate_eligible,candidate_blocker,count
|
|
272
|
+
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)
|
|
224
273
|
ON CONFLICT DO UPDATE SET count=count+1
|
|
225
274
|
`).run(
|
|
226
275
|
day,
|
|
@@ -236,6 +285,11 @@ export class PolicyAuditStore {
|
|
|
236
285
|
event.forwarded ? 1 : 0,
|
|
237
286
|
event.features.join(","),
|
|
238
287
|
this.ruleFingerprint(event.matchedPattern),
|
|
288
|
+
event.candidate?.surface ?? "",
|
|
289
|
+
event.candidate?.pattern ?? "",
|
|
290
|
+
event.candidate?.safetyClass ?? "",
|
|
291
|
+
event.candidate?.eligible ? 1 : 0,
|
|
292
|
+
event.candidate?.blocker ?? "",
|
|
239
293
|
);
|
|
240
294
|
this.db.exec("COMMIT");
|
|
241
295
|
this.secureSidecars();
|
|
@@ -264,18 +318,24 @@ export class PolicyAuditStore {
|
|
|
264
318
|
const params: unknown[] = [fromDay];
|
|
265
319
|
if (input.scope === "current") params.push(this.projectHash(input.projectPath));
|
|
266
320
|
const rows = this.db.prepare(`
|
|
267
|
-
SELECT surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,
|
|
321
|
+
SELECT surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,
|
|
322
|
+
candidate_surface,candidate_pattern,candidate_safety_class,candidate_eligible,candidate_blocker,SUM(count) AS count
|
|
268
323
|
FROM daily_permission_stats
|
|
269
324
|
WHERE day >= ? ${projectClause}
|
|
270
|
-
GROUP BY surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint
|
|
325
|
+
GROUP BY surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,
|
|
326
|
+
candidate_surface,candidate_pattern,candidate_safety_class,candidate_eligible,candidate_blocker
|
|
271
327
|
`).all(...params).map((row) => ({
|
|
272
328
|
surface: String(row.surface), signature: String(row.signature), bashCategory: String(row.bash_category),
|
|
273
329
|
risk: String(row.risk), pathClass: String(row.path_class), result: String(row.result),
|
|
274
330
|
resolution: String(row.resolution), origin: String(row.origin), forwarded: Number(row.forwarded) === 1,
|
|
275
331
|
features: String(row.features), ruleFingerprint: String(row.rule_fingerprint), count: Number(row.count),
|
|
332
|
+
candidateSurface: String(row.candidate_surface), candidatePattern: String(row.candidate_pattern),
|
|
333
|
+
candidateSafetyClass: String(row.candidate_safety_class), candidateEligible: Number(row.candidate_eligible) === 1,
|
|
334
|
+
candidateBlocker: String(row.candidate_blocker),
|
|
276
335
|
}));
|
|
277
336
|
const collectingSince = String(this.db.prepare("SELECT value FROM meta WHERE key='collecting_since'").get()?.value ?? "unknown");
|
|
278
|
-
|
|
337
|
+
const recommendationsSince = String(this.db.prepare("SELECT value FROM meta WHERE key='recommendations_since'").get()?.value ?? "unknown");
|
|
338
|
+
return { collectingSince, recommendationsSince, fromDay, throughDay, rows };
|
|
279
339
|
});
|
|
280
340
|
}
|
|
281
341
|
|
package/src/policy.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isPathSurface } from "./path-surfaces.ts";
|
|
2
|
+
|
|
1
3
|
export type RiskLevel = "low" | "medium" | "high" | "critical";
|
|
2
4
|
|
|
3
5
|
export type ModelDecision = {
|
|
@@ -327,7 +329,7 @@ export function normalizePermissionEvidence(
|
|
|
327
329
|
"unknown";
|
|
328
330
|
const value = nonEmptyString(details.value);
|
|
329
331
|
const isBash = surface === "bash" || surface === "bash_escalated";
|
|
330
|
-
const isPath = surface
|
|
332
|
+
const isPath = isPathSurface(surface);
|
|
331
333
|
const command =
|
|
332
334
|
nonEmptyString(details.command) ??
|
|
333
335
|
parsedPreviewCommand(details.toolInputPreview) ??
|
package/src/ui-auto-confirm.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ExtensionContext,
|
|
3
3
|
ExtensionUIContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { pathSurfaceInfo } from "./path-surfaces.ts";
|
|
5
6
|
|
|
6
7
|
type UiCustom = ExtensionUIContext["custom"];
|
|
7
8
|
|
|
@@ -61,7 +62,8 @@ export class PermissionUiAutoConfirmer {
|
|
|
61
62
|
|
|
62
63
|
stage(requestId: string, surface: string): boolean {
|
|
63
64
|
this.pruneExpired();
|
|
64
|
-
|
|
65
|
+
const info = pathSurfaceInfo(surface);
|
|
66
|
+
if (!info || !this.enabledSurfaces().includes(info.family)) return false;
|
|
65
67
|
this.pending.set(requestId, {
|
|
66
68
|
surface,
|
|
67
69
|
expiresAt: Date.now() + this.pendingTtlMs,
|
|
@@ -84,10 +86,14 @@ export class PermissionUiAutoConfirmer {
|
|
|
84
86
|
}
|
|
85
87
|
this.pending.delete(event.requestId);
|
|
86
88
|
|
|
87
|
-
|
|
89
|
+
const info = pathSurfaceInfo(pending.surface);
|
|
90
|
+
if (!info || !this.enabledSurfaces().includes(info.family)) return;
|
|
88
91
|
// Top-level event.surface is the display tool name (e.g. bash), not the
|
|
89
92
|
// gate. Only request.surface is comparable to the staged allow.
|
|
90
93
|
if (event.gateSurface && event.gateSurface !== pending.surface) return;
|
|
94
|
+
// Directional approvals cannot safely fall back to a legacy payload that
|
|
95
|
+
// omits request.surface: read and write members share the same family.
|
|
96
|
+
if (!event.gateSurface && info.effect) return;
|
|
91
97
|
this.settleActiveForConflict();
|
|
92
98
|
|
|
93
99
|
const attempt: PromptAttempt = {
|