@gotgenes/pi-permission-model-judge 1.1.0 → 1.1.2
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 +24 -0
- package/README.md +4 -2
- package/config/config.example.json +1 -1
- package/docs/configuration.md +6 -5
- package/package.json +1 -1
- package/src/model-review.ts +69 -27
- package/src/typo-reviewer.ts +37 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.1.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-model-judge-v1.1.1...pi-permission-model-judge-v1.1.2) (2026-07-22)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **pi-permission-model-judge:** review typo paths embedded in bash commands ([#630](https://github.com/gotgenes/pi-packages/issues/630)) ([782c83d](https://github.com/gotgenes/pi-packages/commit/782c83d36d5d279c4932be315b9e78aae43ccb26))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
* **pi-permission-model-judge:** note bash-command paths are reviewed ([58a82e8](https://github.com/gotgenes/pi-packages/commit/58a82e8ca7a0df5552b767099004afc83e53be55))
|
|
14
|
+
|
|
15
|
+
## [1.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-model-judge-v1.1.0...pi-permission-model-judge-v1.1.1) (2026-07-22)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **pi-permission-model-judge:** force a structured verdict tool call in the model review ([5a89bba](https://github.com/gotgenes/pi-packages/commit/5a89bba94b6493790a664e87cad98514253927fe)), closes [#628](https://github.com/gotgenes/pi-packages/issues/628)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
* **pi-permission-model-judge:** describe the forced verdict tool call ([1a2b940](https://github.com/gotgenes/pi-packages/commit/1a2b9404ad06b083f96b455df0a66e934b3f5eab))
|
|
26
|
+
|
|
3
27
|
## [1.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-model-judge-v1.0.2...pi-permission-model-judge-v1.1.0) (2026-07-21)
|
|
4
28
|
|
|
5
29
|
|
package/README.md
CHANGED
|
@@ -20,6 +20,8 @@ The reviewer runs a short, cheap decision on each ask and defers at the first mi
|
|
|
20
20
|
3. The path matches one of your `typoPatterns` (otherwise defer — no model call).
|
|
21
21
|
4. The model confirms the typo and returns a teaching reason (`deny`), or is unsure (`defer`).
|
|
22
22
|
|
|
23
|
+
The candidate path comes from a file tool's path argument (`read`/`edit`/`write`) or from an external path referenced inside a `bash` command — a typo path in `cat …/pi-permission-system/packages/pi-permission-system/README.md` is reviewed the same way as one passed to `read`.
|
|
24
|
+
|
|
23
25
|
It is fail-safe by construction: a missing model, invalid config, model timeout, unparseable reply, or an unsure verdict all resolve to `defer`.
|
|
24
26
|
Deferring means the ask falls through to the normal permission prompt — this extension only ever *removes* a hand-denial, never grants access (it emits no `allow`).
|
|
25
27
|
|
|
@@ -39,9 +41,9 @@ Once an ask matches a `typoPattern` — the case that *should* reach the model
|
|
|
39
41
|
| `modelId` | `<provider>/<model>`. |
|
|
40
42
|
| `latencyMs` | Model-call wall-clock in ms, or `null` when no call was made. |
|
|
41
43
|
| `verdict` | `"deny"` or `"defer"`. |
|
|
42
|
-
| `deferReason` | `null` on a deny, else one of `model-unresolved` / `auth-failed` / `
|
|
44
|
+
| `deferReason` | `null` on a deny, else one of `model-unresolved` / `auth-failed` / `no-tool-call` / `non-deny-verdict` / `timeout` / `call-failed`. |
|
|
43
45
|
|
|
44
|
-
Cheaper events go to pi-permission-system's **debug** log, and only when its `debugLog` toggle is on: `model_judge.short_circuit` (a `no-path` or `pattern-miss` defer) and `model_judge.model_reply` (the model's
|
|
46
|
+
Cheaper events go to pi-permission-system's **debug** log, and only when its `debugLog` toggle is on: `model_judge.short_circuit` (a `no-path` or `pattern-miss` defer) and `model_judge.model_reply` (the verdict tool-call arguments as JSON, or the model's text when it emitted no tool call).
|
|
45
47
|
A non-`external_directory` ask is not logged — it is not this link's concern.
|
|
46
48
|
|
|
47
49
|
Because every pattern-matched ask leaves a positive record, a misconfiguration that silently defers every path (an auth failure, an unresolved model) shows up as a run of `deferReason` entries rather than an empty log.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-packages/main/packages/pi-permission-model-judge/schemas/model-judge.schema.json",
|
|
3
3
|
"provider": "anthropic",
|
|
4
4
|
"model": "claude-haiku-4-5",
|
|
5
|
-
"instructions": "You review a filesystem path a tool is about to access outside the working directory. Two common typos both point at the wrong location. (1) A doubled package segment — a package name repeated around `packages/`, for example `pi-permission-system/packages/pi-permission-system/…`, where the correct path names the package only once under the repository's `packages/` directory. (2) A dropped repository prefix — a path that jumps straight to a package directory such as `development/pi/pi-permission-system/…`, where the correct path routes through the repository's `pi-packages/packages/` directory (for example `development/pi/pi-packages/packages/pi-permission-system/…`). When the path shows either typo, deny it and give the single corrected absolute path in the reason so the caller can retry against the right location. If the path looks intentional or you are unsure, defer.
|
|
5
|
+
"instructions": "You review a filesystem path a tool is about to access outside the working directory. Two common typos both point at the wrong location. (1) A doubled package segment — a package name repeated around `packages/`, for example `pi-permission-system/packages/pi-permission-system/…`, where the correct path names the package only once under the repository's `packages/` directory. (2) A dropped repository prefix — a path that jumps straight to a package directory such as `development/pi/pi-permission-system/…`, where the correct path routes through the repository's `pi-packages/packages/` directory (for example `development/pi/pi-packages/packages/pi-permission-system/…`). When the path shows either typo, deny it and give the single corrected absolute path in the reason so the caller can retry against the right location. If the path looks intentional or you are unsure, defer.",
|
|
6
6
|
"typoPatterns": [
|
|
7
7
|
"([^/]+)/packages/\\1(/|$)",
|
|
8
8
|
"development/pi/(?!pi-packages/)pi-[^/]+(/|$)"
|
package/docs/configuration.md
CHANGED
|
@@ -49,12 +49,13 @@ If the model does not resolve (wrong id, no credentials), the reviewer records a
|
|
|
49
49
|
### `instructions`
|
|
50
50
|
|
|
51
51
|
This is the model's system prompt.
|
|
52
|
-
Describe the typo shape you care about and instruct the model to
|
|
53
|
-
The reviewer
|
|
52
|
+
Describe the typo shape you care about and instruct the model to give the wrong segment and the correct location, so the invoking agent can self-correct.
|
|
53
|
+
The reviewer forces the model to call a single `report_verdict` tool, so the verdict is read from structured tool-call arguments rather than parsed from free text — a `deny` verdict with a `reason` rejects the path, and anything else (including no tool call) defers.
|
|
54
54
|
|
|
55
55
|
### `typoPatterns`
|
|
56
56
|
|
|
57
57
|
Each string is compiled with `new RegExp(pattern)` (no flags) and tested against the candidate path.
|
|
58
|
+
The candidate path is a file tool's path argument (`read`/`edit`/`write`) or an external path referenced inside a `bash` command — both surfaces are matched the same way.
|
|
58
59
|
Only a path that matches at least one pattern is sent to the model — this is the cost gate that keeps the reviewer from calling the model on every ask.
|
|
59
60
|
An invalid regular expression is skipped and recorded in the debug log (`model_judge.invalid_patterns`).
|
|
60
61
|
An empty list (the default) matches nothing, so the reviewer defers everything.
|
|
@@ -110,9 +111,9 @@ The **review** log (`~/.pi/agent/extensions/pi-permission-system/logs/pi-permiss
|
|
|
110
111
|
| `modelId` | `<provider>/<model>`. |
|
|
111
112
|
| `latencyMs` | Model-call wall-clock in milliseconds, or `null` when no call was made. |
|
|
112
113
|
| `verdict` | `"deny"` or `"defer"`. |
|
|
113
|
-
| `deferReason` | `null` on a deny, else `model-unresolved` / `auth-failed` / `
|
|
114
|
+
| `deferReason` | `null` on a deny, else `model-unresolved` / `auth-failed` / `no-tool-call` / `non-deny-verdict` / `timeout` / `call-failed`. |
|
|
114
115
|
|
|
115
|
-
The **debug** log (same directory, `pi-permission-system-debug.jsonl`, only when pi-permission-system's `debugLog` is on) carries the verbose and cheap-path detail: `model_judge.short_circuit` (a `no-path` or `pattern-miss` defer before the model stage), `model_judge.model_reply` (the model's
|
|
116
|
+
The **debug** log (same directory, `pi-permission-system-debug.jsonl`, only when pi-permission-system's `debugLog` is on) carries the verbose and cheap-path detail: `model_judge.short_circuit` (a `no-path` or `pattern-miss` defer before the model stage), `model_judge.model_reply` (the verdict tool-call arguments as JSON, or the model's text when it emitted no tool call), and `model_judge.invalid_patterns` (skipped `typoPatterns`).
|
|
116
117
|
A non-`external_directory` ask is not logged at all.
|
|
117
118
|
|
|
118
|
-
To diagnose "the judge defers everything," read the review log for `model_judge.decision` entries and inspect `deferReason`: an empty result means no ask ever matched a pattern, `auth-failed` / `model-unresolved` means it is misconfigured,
|
|
119
|
+
To diagnose "the judge defers everything," read the review log for `model_judge.decision` entries and inspect `deferReason`: an empty result means no ask ever matched a pattern, `auth-failed` / `model-unresolved` means it is misconfigured, `non-deny-verdict` means the model saw the path and chose not to deny, and `no-tool-call` means the model replied without calling the verdict tool (rare, since the tool is forced).
|
package/package.json
CHANGED
package/src/model-review.ts
CHANGED
|
@@ -12,6 +12,8 @@ import type {
|
|
|
12
12
|
Context,
|
|
13
13
|
Model,
|
|
14
14
|
TextContent,
|
|
15
|
+
Tool,
|
|
16
|
+
ToolCall,
|
|
15
17
|
} from "@earendil-works/pi-ai";
|
|
16
18
|
import type { AuthorizerVerdict } from "@gotgenes/pi-permission-system";
|
|
17
19
|
|
|
@@ -21,6 +23,39 @@ import type { ModelJudgeConfig } from "./config-schema";
|
|
|
21
23
|
export const GENERIC_TEACHING_REASON =
|
|
22
24
|
"This looks like a mistyped path. Verify the correct location before retrying.";
|
|
23
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The single tool the model is forced to call. Forcing it (`toolChoice: "any"`)
|
|
28
|
+
* removes free-text JSON parsing by construction — the verdict arrives as
|
|
29
|
+
* structured `arguments`, so a Markdown fence or a prose preamble can no longer
|
|
30
|
+
* cost a verdict.
|
|
31
|
+
*
|
|
32
|
+
* The Anthropic provider reads only `parameters.properties` / `parameters.required`,
|
|
33
|
+
* so a plain JSON-Schema object is correct at runtime; the `as unknown as Tool`
|
|
34
|
+
* bridge satisfies the `TSchema`-typed `parameters` field without a `typebox`
|
|
35
|
+
* dependency.
|
|
36
|
+
*/
|
|
37
|
+
const VERDICT_TOOL = {
|
|
38
|
+
name: "report_verdict",
|
|
39
|
+
description:
|
|
40
|
+
"Report whether the path is a mistyped path to reject (deny) or should be deferred to the human (defer).",
|
|
41
|
+
parameters: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
verdict: {
|
|
45
|
+
type: "string",
|
|
46
|
+
enum: ["deny", "defer"],
|
|
47
|
+
description: "deny a mistyped path; defer anything else",
|
|
48
|
+
},
|
|
49
|
+
reason: {
|
|
50
|
+
type: "string",
|
|
51
|
+
description:
|
|
52
|
+
"Why the path is wrong and the correct location (required when denying)",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
required: ["verdict"],
|
|
56
|
+
},
|
|
57
|
+
} as unknown as Tool;
|
|
58
|
+
|
|
24
59
|
/**
|
|
25
60
|
* The injected model-completion seam — structurally the `complete` export of
|
|
26
61
|
* `@earendil-works/pi-ai`. Injected so tests substitute a fake.
|
|
@@ -32,6 +67,7 @@ export type CompleteFn = (
|
|
|
32
67
|
signal?: AbortSignal;
|
|
33
68
|
apiKey?: string;
|
|
34
69
|
headers?: Record<string, string>;
|
|
70
|
+
toolChoice?: string;
|
|
35
71
|
},
|
|
36
72
|
) => Promise<AssistantMessage>;
|
|
37
73
|
|
|
@@ -62,13 +98,14 @@ export interface ReviewPathInputs {
|
|
|
62
98
|
|
|
63
99
|
/**
|
|
64
100
|
* Why a model call defers, distinct enough to diagnose from the decision trail:
|
|
65
|
-
* the reply
|
|
66
|
-
* `deny` (`non-deny-verdict`), the call was aborted at
|
|
67
|
-
* or `complete` threw for any other reason
|
|
68
|
-
* that catches, e.g., a 401 slipping past
|
|
101
|
+
* the reply carried no tool call to read (`no-tool-call`), the tool call named a
|
|
102
|
+
* verdict other than `deny` (`non-deny-verdict`), the call was aborted at
|
|
103
|
+
* `timeoutMs` (`timeout`), or `complete` threw for any other reason
|
|
104
|
+
* (`call-failed` — the honest superset that catches, e.g., a 401 slipping past
|
|
105
|
+
* pre-call auth resolution).
|
|
69
106
|
*/
|
|
70
107
|
export type ModelCallDeferReason =
|
|
71
|
-
| "
|
|
108
|
+
| "no-tool-call"
|
|
72
109
|
| "non-deny-verdict"
|
|
73
110
|
| "timeout"
|
|
74
111
|
| "call-failed";
|
|
@@ -76,8 +113,9 @@ export type ModelCallDeferReason =
|
|
|
76
113
|
/**
|
|
77
114
|
* The full result of a model review: the verdict plus the observability the
|
|
78
115
|
* decision trail records. `deferReason` is set iff the verdict is `defer` and a
|
|
79
|
-
* model call was made; `rawReply` carries the
|
|
80
|
-
* arrived
|
|
116
|
+
* model call was made; `rawReply` carries the tool-call arguments as JSON when a
|
|
117
|
+
* tool call arrived, or the assistant text on a `no-tool-call` defer (absent on
|
|
118
|
+
* a timeout/throw before any reply).
|
|
81
119
|
*/
|
|
82
120
|
export interface ReviewOutcome {
|
|
83
121
|
verdict: AuthorizerVerdict;
|
|
@@ -104,6 +142,7 @@ export async function reviewPath(
|
|
|
104
142
|
try {
|
|
105
143
|
const context: Context = {
|
|
106
144
|
systemPrompt: inputs.config.instructions,
|
|
145
|
+
tools: [VERDICT_TOOL],
|
|
107
146
|
messages: [
|
|
108
147
|
{
|
|
109
148
|
role: "user",
|
|
@@ -116,8 +155,9 @@ export async function reviewPath(
|
|
|
116
155
|
signal: controller.signal,
|
|
117
156
|
apiKey: inputs.apiKey,
|
|
118
157
|
headers: inputs.headers,
|
|
158
|
+
toolChoice: "any",
|
|
119
159
|
});
|
|
120
|
-
return
|
|
160
|
+
return readToolCallOutcome(reply, Date.now() - startedAt);
|
|
121
161
|
} catch {
|
|
122
162
|
return {
|
|
123
163
|
verdict: { kind: "defer" },
|
|
@@ -129,35 +169,41 @@ export async function reviewPath(
|
|
|
129
169
|
}
|
|
130
170
|
}
|
|
131
171
|
|
|
132
|
-
/** The user-turn prompt: hand the model the path and
|
|
172
|
+
/** The user-turn prompt: hand the model the path and point it at the tool. */
|
|
133
173
|
function renderReviewPrompt(path: string): string {
|
|
134
174
|
return [
|
|
135
175
|
"A tool is about to access this path outside the working directory:",
|
|
136
176
|
"",
|
|
137
177
|
path,
|
|
138
178
|
"",
|
|
139
|
-
'
|
|
140
|
-
'Otherwise: {"verdict":"defer"}.',
|
|
179
|
+
'Call report_verdict. Use verdict "deny" with a reason naming the correct location if this is a mistyped path; otherwise use verdict "defer".',
|
|
141
180
|
].join("\n");
|
|
142
181
|
}
|
|
143
182
|
|
|
144
183
|
/**
|
|
145
|
-
* Map the
|
|
146
|
-
*
|
|
184
|
+
* Map the forced tool call to an outcome; anything but a clean `deny` defers
|
|
185
|
+
* with the reason that distinguishes it. The tool call is read by position (the
|
|
186
|
+
* first one), not by name — under OAuth the provider rewrites the registered
|
|
187
|
+
* name, so the reply's tool-call name cannot be relied on.
|
|
147
188
|
*/
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
189
|
+
function readToolCallOutcome(
|
|
190
|
+
reply: AssistantMessage,
|
|
191
|
+
latencyMs: number,
|
|
192
|
+
): ReviewOutcome {
|
|
193
|
+
const call = reply.content.find(
|
|
194
|
+
(part): part is ToolCall => part.type === "toolCall",
|
|
195
|
+
);
|
|
196
|
+
if (!call) {
|
|
153
197
|
return {
|
|
154
198
|
verdict: { kind: "defer" },
|
|
155
|
-
deferReason: "
|
|
199
|
+
deferReason: "no-tool-call",
|
|
156
200
|
latencyMs,
|
|
157
|
-
rawReply,
|
|
201
|
+
rawReply: extractText(reply),
|
|
158
202
|
};
|
|
159
203
|
}
|
|
160
|
-
|
|
204
|
+
const args = call.arguments;
|
|
205
|
+
const rawReply = JSON.stringify(args);
|
|
206
|
+
if (args.verdict !== "deny") {
|
|
161
207
|
return {
|
|
162
208
|
verdict: { kind: "defer" },
|
|
163
209
|
deferReason: "non-deny-verdict",
|
|
@@ -166,8 +212,8 @@ function parseOutcome(rawReply: string, latencyMs: number): ReviewOutcome {
|
|
|
166
212
|
};
|
|
167
213
|
}
|
|
168
214
|
const reason =
|
|
169
|
-
typeof
|
|
170
|
-
?
|
|
215
|
+
typeof args.reason === "string" && args.reason.length > 0
|
|
216
|
+
? args.reason
|
|
171
217
|
: GENERIC_TEACHING_REASON;
|
|
172
218
|
return { verdict: { kind: "deny", reason }, latencyMs, rawReply };
|
|
173
219
|
}
|
|
@@ -179,7 +225,3 @@ function extractText(reply: AssistantMessage): string {
|
|
|
179
225
|
.map((part) => part.text)
|
|
180
226
|
.join("");
|
|
181
227
|
}
|
|
182
|
-
|
|
183
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
184
|
-
return typeof value === "object" && value !== null;
|
|
185
|
-
}
|
package/src/typo-reviewer.ts
CHANGED
|
@@ -91,20 +91,29 @@ export function createTypoReviewer(
|
|
|
91
91
|
return { kind: "defer" };
|
|
92
92
|
}
|
|
93
93
|
const { requestId } = details;
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
94
|
+
const candidates = candidatePathsOf(details);
|
|
95
|
+
if (candidates.length === 0) {
|
|
96
96
|
log.debug(SHORT_CIRCUIT_EVENT, { requestId, reason: "no-path" });
|
|
97
97
|
return { kind: "defer" };
|
|
98
98
|
}
|
|
99
|
-
const
|
|
100
|
-
|
|
99
|
+
const compiled = compiledFor(config, log);
|
|
100
|
+
let matched: { path: string; matchedPattern: string } | undefined;
|
|
101
|
+
for (const candidate of candidates) {
|
|
102
|
+
const pattern = matchTypoPattern(candidate, compiled);
|
|
103
|
+
if (pattern !== undefined) {
|
|
104
|
+
matched = { path: candidate, matchedPattern: pattern };
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (matched === undefined) {
|
|
101
109
|
log.debug(SHORT_CIRCUIT_EVENT, {
|
|
102
110
|
requestId,
|
|
103
|
-
path,
|
|
111
|
+
path: candidates[0],
|
|
104
112
|
reason: "pattern-miss",
|
|
105
113
|
});
|
|
106
114
|
return { kind: "defer" };
|
|
107
115
|
}
|
|
116
|
+
const { path, matchedPattern } = matched;
|
|
108
117
|
|
|
109
118
|
const modelId = `${config.provider}/${config.model}`;
|
|
110
119
|
const base: DecisionBase = { requestId, path, matchedPattern, modelId };
|
|
@@ -177,9 +186,29 @@ function surfaceOf(details: PromptPermissionDetails): string | undefined {
|
|
|
177
186
|
return details.accessIntent?.surface ?? details.surface ?? undefined;
|
|
178
187
|
}
|
|
179
188
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
182
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Candidate paths to test against the typo patterns, most authoritative first.
|
|
191
|
+
*
|
|
192
|
+
* `accessIntent.matchValues` is the raising gate's authoritative path alias set
|
|
193
|
+
* (absolute ∪ cwd-relative ∪ canonical) — present for both file-tool and
|
|
194
|
+
* `bash` external_directory asks, and the only place a `bash`-surfaced path
|
|
195
|
+
* appears (the gate sets `command`/`accessIntent`, never `path`). `path` and
|
|
196
|
+
* `value` are fallbacks for a details bag without `accessIntent` (an older
|
|
197
|
+
* forwarded request or a hand-built local ask). `value` is last because a
|
|
198
|
+
* forwarded `bash` ask's display value is the command string, not a path.
|
|
199
|
+
*/
|
|
200
|
+
function candidatePathsOf(details: PromptPermissionDetails): string[] {
|
|
201
|
+
const seen = new Set<string>();
|
|
202
|
+
for (const value of details.accessIntent?.matchValues ?? []) {
|
|
203
|
+
seen.add(value);
|
|
204
|
+
}
|
|
205
|
+
if (details.path !== undefined) {
|
|
206
|
+
seen.add(details.path);
|
|
207
|
+
}
|
|
208
|
+
if (details.value != null) {
|
|
209
|
+
seen.add(details.value);
|
|
210
|
+
}
|
|
211
|
+
return [...seen];
|
|
183
212
|
}
|
|
184
213
|
|
|
185
214
|
/**
|