pi-jev-auto-mode 0.1.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 +19 -0
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/SECURITY.md +32 -0
- package/docs/calibration.md +134 -0
- package/docs/design.md +119 -0
- package/docs/security.md +122 -0
- package/index.ts +1 -0
- package/package.json +67 -0
- package/src/call.ts +180 -0
- package/src/decide.ts +81 -0
- package/src/extension.ts +705 -0
- package/src/intent.ts +68 -0
- package/src/jev/availability.ts +51 -0
- package/src/jev/criteria.ts +19 -0
- package/src/jev/decide.ts +187 -0
- package/src/jev/engine.ts +163 -0
- package/src/jev/index.ts +20 -0
- package/src/jev/questions.ts +228 -0
- package/src/jev/response.ts +64 -0
- package/src/jev/state.ts +20 -0
- package/src/jev/transport.ts +117 -0
- package/src/jev/types.ts +46 -0
- package/src/policy.ts +464 -0
- package/src/records.ts +118 -0
- package/src/settings.ts +274 -0
- package/src/ui.ts +125 -0
package/docs/security.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Security notes
|
|
2
|
+
|
|
3
|
+
## The shape of the problem
|
|
4
|
+
|
|
5
|
+
A coding agent with shell access can be talked into almost anything by the content it reads:
|
|
6
|
+
a dependency's README, a test fixture, an issue body, a comment in the file it was asked to
|
|
7
|
+
fix. A permission gate therefore cannot be "a second opinion from another chat model". It has
|
|
8
|
+
to be an envelope the agent cannot argue its way out of, plus a narrow judgment for the cases
|
|
9
|
+
the envelope cannot decide on its own.
|
|
10
|
+
|
|
11
|
+
This package splits those two responsibilities and keeps the envelope authoritative.
|
|
12
|
+
|
|
13
|
+
## What the semantic layer may and may not do
|
|
14
|
+
|
|
15
|
+
| Allowed | Not allowed |
|
|
16
|
+
|---|---|
|
|
17
|
+
| Approve a call the deterministic layer escalated | Approve a hard-deny command |
|
|
18
|
+
| Refuse a call that looks required by the task | Override a user deny pattern |
|
|
19
|
+
| Report "uncertain", which becomes a confirmation | Widen the set of protected paths |
|
|
20
|
+
| Clear a `soft` hazard rejection when the user's own request covers the call | Clear a `hazard`-severity rejection (secret egress, credential stores, injection) |
|
|
21
|
+
|
|
22
|
+
The order in `evaluateToolCall` is the enforcement: hard-deny and user rules return before the
|
|
23
|
+
engine is constructed or called at all. There is no code path in which a probabilistic verdict
|
|
24
|
+
is consulted for a hard-deny target.
|
|
25
|
+
|
|
26
|
+
### The soft/hazard split, and why it is the riskiest part of the design
|
|
27
|
+
|
|
28
|
+
A rejection from a condition marked `severity: soft` (`local_scope`, `no_outward_effect`,
|
|
29
|
+
`no_irreversible_damage`) is cleared when `intent_coverage` is satisfied. This is what makes
|
|
30
|
+
`git push --force origin feature/x` after "force push this branch" an approval instead of a
|
|
31
|
+
block, and it is the only place where a probabilistic judgment grants permission for an
|
|
32
|
+
irreversible action.
|
|
33
|
+
|
|
34
|
+
Three things bound the risk:
|
|
35
|
+
|
|
36
|
+
1. The hard-deny patterns for catastrophic targets (forced push to a protected branch, root
|
|
37
|
+
deletion, disk writes) run first and cannot be reached by any semantic verdict.
|
|
38
|
+
2. Content-based authority does not count: `intent_coverage` reads user-authored messages
|
|
39
|
+
only. A README that says "run this installer" is not a user request, and the fixture set
|
|
40
|
+
measures that case as `ask`.
|
|
41
|
+
3. `severity: hazard` covers the conditions where consent should not be sufficient at all:
|
|
42
|
+
sending secret material, writing credential stores, and text that tries to steer the judgment.
|
|
43
|
+
|
|
44
|
+
If a wrong approval ever appears in practice, this is the mechanism to remove first.
|
|
45
|
+
|
|
46
|
+
## Failure modes and what happens
|
|
47
|
+
|
|
48
|
+
Everything below resolves to **block**. Silence is never consent.
|
|
49
|
+
|
|
50
|
+
| Failure | Resolution |
|
|
51
|
+
|---|---|
|
|
52
|
+
| No semantic engine configured (no API key) | confirm in a UI, block without one |
|
|
53
|
+
| API key missing or rejected | block, with the reason surfaced to the model |
|
|
54
|
+
| Login with a key the API refuses | the key is not stored, so a typo cannot become a permanently blocking gate |
|
|
55
|
+
| Login while the API is unreachable | the key is not stored and the command says it could not verify |
|
|
56
|
+
| Timeout / connection error | block (`timeout`, `network`) |
|
|
57
|
+
| 5xx or 429 after retries | block (`http`) |
|
|
58
|
+
| 4xx that retries cannot fix | block (`http`) — not rethrown, so the gate cannot fail open |
|
|
59
|
+
| Response shape wrong, or a condition missing from the answer | block (`malformed_response`) |
|
|
60
|
+
| State + questions over the shared budget | block (`state_too_large`) before the request is sent |
|
|
61
|
+
| Engine throws | block (`engine_error`) |
|
|
62
|
+
| Request cancelled (Esc) | block |
|
|
63
|
+
| No UI available for a confirmation | block (`no-ui`) |
|
|
64
|
+
| A condition answered by fewer than all keys | block (`malformed_response`) — a missing answer is never an approval |
|
|
65
|
+
|
|
66
|
+
A confirmation is not a bypass: it runs only when the semantic layer said `uncertain`, never
|
|
67
|
+
when it said `deny` or when no decision was available.
|
|
68
|
+
|
|
69
|
+
## Injection stance
|
|
70
|
+
|
|
71
|
+
- The user intent sent for judgment is built from **user-authored messages only**. Assistant
|
|
72
|
+
text and tool output are excluded, because they carry repository content and command output
|
|
73
|
+
and would otherwise let a file argue for its own approval.
|
|
74
|
+
- File contents and diffs are never sent. Only paths.
|
|
75
|
+
- `AGENTS.md` / `CLAUDE.md` and the agent configuration directories are treated as protected
|
|
76
|
+
paths: a write there changes what the agent believes it was told.
|
|
77
|
+
- One dedicated condition (`prompt_injection_absent`) asks whether the call is trying to make
|
|
78
|
+
other state act as instructions.
|
|
79
|
+
- The gate never returns a JEV rationale verbatim as a system-level instruction; a block
|
|
80
|
+
reason is a tool-call error string, which is the weakest channel it can use.
|
|
81
|
+
|
|
82
|
+
## What is sent to TypeSafe
|
|
83
|
+
|
|
84
|
+
Judgment requires the content to leave the machine. The API is `api.typesafe.ai`, and the
|
|
85
|
+
payload is deliberately narrow:
|
|
86
|
+
|
|
87
|
+
| Sent | Not sent |
|
|
88
|
+
|---|---|
|
|
89
|
+
| tool name, bash command text (truncated) | file contents, diffs, `write` bodies |
|
|
90
|
+
| write/edit target path, cwd | tool output, assistant messages |
|
|
91
|
+
| matched policy reason names | environment variables |
|
|
92
|
+
| recent user messages (bounded, ≤4k chars) | the API key itself |
|
|
93
|
+
| policy notes | |
|
|
94
|
+
|
|
95
|
+
The API key is stored as a `0600` file under `<agentDir>/secrets/`, the same place Pi keeps its
|
|
96
|
+
own credentials. It is never written to the settings file, and it is never part of the judgment
|
|
97
|
+
state: it travels only in the `Authorization` header to `api.typesafe.ai`, so it cannot come
|
|
98
|
+
back out through a decision record.
|
|
99
|
+
|
|
100
|
+
Redaction runs before the state is built: `*_KEY=` / `*_TOKEN=` / `*_SECRET=` assignments,
|
|
101
|
+
`Bearer …`, JWTs, `sk-` / `rk-` keys, `ghp_` / `gho_` tokens, `AKIA…` access key IDs,
|
|
102
|
+
`apikey_…` keys, and PEM private keys become `<redacted>` markers. Redaction is a safety net,
|
|
103
|
+
not a guarantee — an unusual secret format will pass through. Lower
|
|
104
|
+
`maxStateCharacters`, or keep a command out of the gate by adding a deny/allow rule, if a
|
|
105
|
+
repository must not produce outbound text at all.
|
|
106
|
+
|
|
107
|
+
Records written to the session store the decision, the matched reasons, the rationale, the
|
|
108
|
+
model name, and per-condition probabilities. They are local and do not enter the model's
|
|
109
|
+
context.
|
|
110
|
+
|
|
111
|
+
## Known limits
|
|
112
|
+
|
|
113
|
+
- `classifyWriteTarget` is lexical (no `realpath`), so a symlink inside the working directory
|
|
114
|
+
pointing outside it is not detected by the deterministic layer.
|
|
115
|
+
- Command matching is conservative pattern matching, not a shell parser. `rm -rf build` is
|
|
116
|
+
recognized as scoped; obfuscated equivalents (`xargs`, command substitution, `sh -c`) are
|
|
117
|
+
escalated rather than recognized.
|
|
118
|
+
- A command that `cd`s elsewhere and then deletes is judged by its text and intent, not by a
|
|
119
|
+
simulated shell.
|
|
120
|
+
- The probability thresholds are calibrated on one person's data, one sample per fixture,
|
|
121
|
+
with ±0.05 run-to-run variance. See [`calibration.md`](./calibration.md); treat the
|
|
122
|
+
thresholds as a starting point and tune them from the recorded probabilities.
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/extension.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-jev-auto-mode",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "JEV (TypeSafe System One) backed auto mode for the Pi coding agent: semantically auto-approves bash, write, and edit tool calls and fails closed when a decision cannot be made.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"pi-coding-agent",
|
|
9
|
+
"auto-mode",
|
|
10
|
+
"permission",
|
|
11
|
+
"gate",
|
|
12
|
+
"guardrail",
|
|
13
|
+
"jev",
|
|
14
|
+
"typesafe",
|
|
15
|
+
"semantic-validation"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "jomatsu",
|
|
19
|
+
"homepage": "https://github.com/jomatsu/pi-jev-auto-mode",
|
|
20
|
+
"bugs": "https://github.com/jomatsu/pi-jev-auto-mode/issues",
|
|
21
|
+
"repository": "github:jomatsu/pi-jev-auto-mode",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.19.0"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"index.ts",
|
|
28
|
+
"src",
|
|
29
|
+
"docs",
|
|
30
|
+
"README.md",
|
|
31
|
+
"CHANGELOG.md",
|
|
32
|
+
"SECURITY.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"./index.ts"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "node --test --experimental-strip-types --test-reporter=spec 'test/**/*.test.ts'",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"check": "npm run typecheck && npm test",
|
|
44
|
+
"prepublishOnly": "npm run check"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
+
"@earendil-works/pi-tui": "*"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"@earendil-works/pi-coding-agent": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"@earendil-works/pi-tui": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
60
|
+
"@earendil-works/pi-tui": "^0.84.2",
|
|
61
|
+
"@types/node": "^22.15.0",
|
|
62
|
+
"typescript": "^5.8.0"
|
|
63
|
+
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"@typesafe-ai/sdk": "^0.6.0"
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/call.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a Pi tool call into the shape JEV will judge.
|
|
3
|
+
*
|
|
4
|
+
* What leaves this module is what a third party (TypeSafe) gets to see, so the
|
|
5
|
+
* boundary is explicit: the command text and the target path are sent, file
|
|
6
|
+
* contents, diffs, and tool output are not. Obvious credentials are redacted on
|
|
7
|
+
* the way out.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { classifyWriteTarget } from "./policy.ts";
|
|
11
|
+
import type { JevJson, JevState } from "./jev/state.ts";
|
|
12
|
+
|
|
13
|
+
export type GatedTool = "bash" | "write" | "edit";
|
|
14
|
+
|
|
15
|
+
export const GATED_TOOLS: readonly GatedTool[] = ["bash", "write", "edit"];
|
|
16
|
+
|
|
17
|
+
export interface ToolCallEventLike {
|
|
18
|
+
readonly toolName: string;
|
|
19
|
+
readonly toolCallId?: string;
|
|
20
|
+
readonly input: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GatedCall {
|
|
24
|
+
readonly tool: GatedTool;
|
|
25
|
+
/** One-line description used in records and dialogs. */
|
|
26
|
+
readonly summary: string;
|
|
27
|
+
/** bash: the command, truncated and redacted. Absent for file tools. */
|
|
28
|
+
readonly command?: string;
|
|
29
|
+
/** write/edit: absolute target path. */
|
|
30
|
+
readonly path?: string;
|
|
31
|
+
/** write/edit: target relative to the working directory, when inside it. */
|
|
32
|
+
readonly relativePath?: string;
|
|
33
|
+
readonly outsideCwd: boolean;
|
|
34
|
+
readonly protectedReason?: string;
|
|
35
|
+
/** write/edit: number of edit hunks. */
|
|
36
|
+
readonly editCount?: number;
|
|
37
|
+
/** write: content size in characters. The content itself is never sent. */
|
|
38
|
+
readonly contentLength?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CallOptions {
|
|
42
|
+
readonly cwd: string;
|
|
43
|
+
readonly maxCommandLength?: number;
|
|
44
|
+
/** Additional protected locations from settings. */
|
|
45
|
+
readonly extraProtectedPaths?: readonly string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const DEFAULT_MAX_COMMAND_LENGTH = 4000;
|
|
49
|
+
|
|
50
|
+
const SECRET_PATTERNS: readonly { readonly pattern: RegExp; readonly replacement: string }[] = [
|
|
51
|
+
{
|
|
52
|
+
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
53
|
+
replacement: "<redacted-private-key>",
|
|
54
|
+
},
|
|
55
|
+
{ pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/g, replacement: "<redacted-jwt>" },
|
|
56
|
+
{ pattern: /\b(?:sk|rk)-[A-Za-z0-9_-]{16,}\b/g, replacement: "<redacted-key>" },
|
|
57
|
+
{ pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, replacement: "<redacted-token>" },
|
|
58
|
+
{ pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{12,}\b/g, replacement: "<redacted-aws-key>" },
|
|
59
|
+
{ pattern: /\bapikey_[A-Za-z0-9_-]{8,}\b/gi, replacement: "<redacted-typesafe-key>" },
|
|
60
|
+
{ pattern: /\bBearer\s+[A-Za-z0-9._~+/-]{12,}=*/g, replacement: "Bearer <redacted>" },
|
|
61
|
+
{
|
|
62
|
+
pattern:
|
|
63
|
+
/((?:api[_-]?key|secret|token|password|passwd|access[_-]?key|client[_-]?secret|auth[_-]?token)\s*[:=]\s*)(["']?)([^\s"';|&]{6,})/gi,
|
|
64
|
+
replacement: "$1$2<redacted>",
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** Replace obvious credentials so they are neither sent nor displayed. */
|
|
69
|
+
export function redactSecrets(text: string): string {
|
|
70
|
+
let result = text;
|
|
71
|
+
for (const { pattern, replacement } of SECRET_PATTERNS) {
|
|
72
|
+
result = result.replace(pattern, replacement);
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function truncate(value: string, maxLength: number): string {
|
|
78
|
+
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function asString(value: unknown): string | undefined {
|
|
82
|
+
return typeof value === "string" ? value : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build the gate's view of a tool call.
|
|
87
|
+
*
|
|
88
|
+
* Returns `undefined` for tools this extension does not gate.
|
|
89
|
+
*/
|
|
90
|
+
export function buildGatedCall(event: ToolCallEventLike, options: CallOptions): GatedCall | undefined {
|
|
91
|
+
const maxCommandLength = options.maxCommandLength ?? DEFAULT_MAX_COMMAND_LENGTH;
|
|
92
|
+
|
|
93
|
+
if (event.toolName === "bash") {
|
|
94
|
+
const rawCommand = asString(event.input.command) ?? "";
|
|
95
|
+
const command = truncate(redactSecrets(rawCommand), maxCommandLength);
|
|
96
|
+
const firstLine = rawCommand.split("\n")[0] ?? rawCommand;
|
|
97
|
+
return {
|
|
98
|
+
tool: "bash",
|
|
99
|
+
summary: truncate(redactSecrets(firstLine.trim()), 200) || "(empty command)",
|
|
100
|
+
command,
|
|
101
|
+
outsideCwd: false,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
106
|
+
const inputPath = asString(event.input.path) ?? "";
|
|
107
|
+
const target = classifyWriteTarget(inputPath, options.cwd, options.extraProtectedPaths ?? []);
|
|
108
|
+
const editCount = Array.isArray(event.input.edits) ? event.input.edits.length : undefined;
|
|
109
|
+
const contentLength = asString(event.input.content)?.length;
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
tool: event.toolName,
|
|
113
|
+
summary: `${event.toolName} ${target.relativeToCwd ?? target.absolute}`,
|
|
114
|
+
path: target.absolute,
|
|
115
|
+
relativePath: target.relativeToCwd,
|
|
116
|
+
outsideCwd: target.outsideCwd,
|
|
117
|
+
protectedReason: target.protectedReason,
|
|
118
|
+
editCount,
|
|
119
|
+
contentLength,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface RepoFacts {
|
|
127
|
+
readonly cwd: string;
|
|
128
|
+
readonly isGitRepository: boolean;
|
|
129
|
+
readonly protectedPaths: readonly string[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface JevStateInput {
|
|
133
|
+
readonly call: GatedCall;
|
|
134
|
+
readonly reasons: readonly string[];
|
|
135
|
+
/** Recent user-authored text. Never assistant or tool output. */
|
|
136
|
+
readonly intent: string;
|
|
137
|
+
/** User-authored policy notes. */
|
|
138
|
+
readonly policy: string;
|
|
139
|
+
readonly repo: RepoFacts;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const NO_POLICY_PLACEHOLDER = "(no user policy configured)";
|
|
143
|
+
export const NO_INTENT_PLACEHOLDER = "(no recent user message available)";
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Assemble the JEV request state.
|
|
147
|
+
*
|
|
148
|
+
* The user policy is `context` (session-scoped) so conditions can name it, and
|
|
149
|
+
* the call plus the user intent are `value` (per-call).
|
|
150
|
+
*/
|
|
151
|
+
export function toJevState(input: JevStateInput): JevState {
|
|
152
|
+
const { call } = input;
|
|
153
|
+
|
|
154
|
+
const value: Record<string, JevJson> = {
|
|
155
|
+
tool: call.tool,
|
|
156
|
+
operation: call.summary,
|
|
157
|
+
matched_policy_reasons: [...input.reasons],
|
|
158
|
+
user_intent: input.intent.trim() || NO_INTENT_PLACEHOLDER,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (call.command !== undefined) value.command = call.command;
|
|
162
|
+
if (call.path !== undefined) {
|
|
163
|
+
value.path = call.path;
|
|
164
|
+
value.relative_path = call.relativePath ?? null;
|
|
165
|
+
value.outside_working_directory = call.outsideCwd;
|
|
166
|
+
}
|
|
167
|
+
if (call.editCount !== undefined) value.edit_count = call.editCount;
|
|
168
|
+
if (call.contentLength !== undefined) value.content_length = call.contentLength;
|
|
169
|
+
|
|
170
|
+
const context: Record<string, JevJson> = {
|
|
171
|
+
policy: input.policy.trim() || NO_POLICY_PLACEHOLDER,
|
|
172
|
+
repository: {
|
|
173
|
+
cwd: input.repo.cwd,
|
|
174
|
+
is_git_repository: input.repo.isGitRepository,
|
|
175
|
+
protected_paths: [...input.repo.protectedPaths],
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return { value, context };
|
|
180
|
+
}
|
package/src/decide.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision-engine seam.
|
|
3
|
+
*
|
|
4
|
+
* The gate itself (ordering, blocking, recording) is deterministic. Everything
|
|
5
|
+
* probabilistic sits behind `DecisionEngine`, so the extension can be tested
|
|
6
|
+
* without a network and so the JEV implementation can be swapped or disabled
|
|
7
|
+
* without touching the safety-critical path.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { GatedCall, RepoFacts } from "./call.ts";
|
|
11
|
+
|
|
12
|
+
export type DecisionSource = "hard-deny" | "user-rule" | "engine" | "unavailable" | "no-ui" | "user";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A display-ready report of one condition's judgment.
|
|
16
|
+
*
|
|
17
|
+
* `verdict` is the band the probability fell into; `ignored` means a hazard-mode
|
|
18
|
+
* condition landed in the middle band, which is not evidence of anything. This is
|
|
19
|
+
* what a record shows during threshold tuning.
|
|
20
|
+
*/
|
|
21
|
+
export interface ConditionReport {
|
|
22
|
+
readonly ruleId: string;
|
|
23
|
+
readonly label: string;
|
|
24
|
+
readonly probability: number;
|
|
25
|
+
readonly threshold: number;
|
|
26
|
+
readonly verdict: "satisfied" | "rejected" | "uncertain" | "ignored";
|
|
27
|
+
readonly clearedByIntent: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EngineEvidence {
|
|
31
|
+
/** Per-condition probability, when the engine exposes one. */
|
|
32
|
+
readonly probabilities?: Readonly<Record<string, number>>;
|
|
33
|
+
/** The threshold each probability was compared against. */
|
|
34
|
+
readonly thresholds?: Readonly<Record<string, number>>;
|
|
35
|
+
/** One entry per condition that was asked. */
|
|
36
|
+
readonly conditions?: readonly ConditionReport[];
|
|
37
|
+
/** The condition that decided the call, when one did. */
|
|
38
|
+
readonly decidingRule?: string;
|
|
39
|
+
/** Conditions whose clear rejection the user's own request cleared. */
|
|
40
|
+
readonly clearedByIntent?: readonly string[];
|
|
41
|
+
readonly model?: string;
|
|
42
|
+
readonly latencyMs?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type EngineVerdict =
|
|
46
|
+
| ({ readonly verdict: "allow"; readonly rationale: string } & EngineEvidence)
|
|
47
|
+
| ({ readonly verdict: "deny"; readonly rationale: string } & EngineEvidence)
|
|
48
|
+
| ({ readonly verdict: "uncertain"; readonly rationale: string } & EngineEvidence)
|
|
49
|
+
| ({ readonly verdict: "unavailable"; readonly rationale: string; readonly reason: string } & EngineEvidence);
|
|
50
|
+
|
|
51
|
+
export interface CandidateInput {
|
|
52
|
+
readonly call: GatedCall;
|
|
53
|
+
/** Names of the policy patterns this call matched. */
|
|
54
|
+
readonly reasons: readonly string[];
|
|
55
|
+
readonly intent: string;
|
|
56
|
+
readonly policy: string;
|
|
57
|
+
readonly repo: RepoFacts;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface JudgeOptions {
|
|
61
|
+
readonly signal?: AbortSignal;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DecisionEngine {
|
|
65
|
+
readonly id: string;
|
|
66
|
+
judge(input: CandidateInput, options: JudgeOptions): Promise<EngineVerdict>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Milestone-1 engine: no semantic judgment at all.
|
|
71
|
+
*
|
|
72
|
+
* Every candidate is reported as `uncertain`, which means "ask the user when a UI
|
|
73
|
+
* exists, block otherwise". That keeps the deterministic layer shippable and
|
|
74
|
+
* verifiable on its own, and it fails in the safe direction.
|
|
75
|
+
*/
|
|
76
|
+
export function createManualEngine(): DecisionEngine {
|
|
77
|
+
return {
|
|
78
|
+
id: "manual",
|
|
79
|
+
judge: () => Promise.resolve({ verdict: "uncertain", rationale: "No semantic decision engine is configured." }),
|
|
80
|
+
};
|
|
81
|
+
}
|