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.
@@ -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
+ }