pi-jev-auto-mode 0.2.0 → 0.3.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 CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ The 0.2.0 default resolved the middle band as a block, but two conditions were still
6
+ `required`, which made the gate strict for structurally wrong reasons rather than measured
7
+ ones. Corrected:
8
+
9
+ - `intent_coverage` 0.80 → **0.60**. Measured answers are 0.77–0.98 when the user asked and
10
+ 0.06–0.15 when they did not, so 0.80 sat on top of the "asked" cluster instead of inside the
11
+ empty band between the two. The middle band is now (0.40, 0.60).
12
+ - `policy_compliance` required → **hazard**. It measured 0.66–0.85 on calls where nothing was
13
+ wrong, so as a requirement it blocked every gated call the moment a policy was configured.
14
+ Now only a clear violation stops a call.
15
+ - `path_not_protected` required → **hazard**. An unclear answer no longer blocks on its own; the
16
+ user's request decides. A target the model clearly identifies as a credential store still
17
+ blocks (`.env` measured p = 0.02, `~/.ssh` p = 0.03).
18
+ - New `no_fetched_code_execution`, required, asked only for commands the deterministic layer
19
+ already recognised as downloaded-script execution. `curl | bash` stays blocked (p = 0.02)
20
+ without making every other call strict.
21
+ - `.env.example`, `.env.sample`, `.env.template`, and `.env.dist` are no longer treated as
22
+ credential stores: templates belong in the repository.
23
+ - The intent window widened from 8 messages / 4000 characters to 12 / 6000, so an ongoing task
24
+ does not lose the request that justifies it and look unrequested as a result.
25
+
3
26
  ## 0.2.0
4
27
 
5
28
  - **The middle band no longer asks the user by default.** An auto mode that stops to ask has
@@ -124,6 +124,26 @@ transcript. The fixture set gained `post a private key, asked for`, which verifi
124
124
  the whole design rests on: with the user explicitly asking for it, `no_secret_egress` still
125
125
  rejects (p = 0.01) and the call is blocked.
126
126
 
127
+ ## Third revision: strictness for the wrong reason
128
+
129
+ With the middle band resolving to a block, the two `required` conditions left in the set turned
130
+ out to be doing the blocking, and not because the calls were risky:
131
+
132
+ - `intent_coverage` at 0.80 was above the entire "asked" cluster. The measured gap is
133
+ **0.15 → 0.77**: nothing the model answered landed between those values, so any threshold in
134
+ that range separates perfectly and 0.80 was simply the strict end of nothing. Now 0.60, with
135
+ the middle band at (0.40, 0.60).
136
+ - `policy_compliance` answered 0.66–0.85 even for calls that were plainly fine. As a requirement
137
+ that blocks, so **configuring a policy disabled the gate entirely**. It is a hazard detector
138
+ now: only a clear violation stops a call.
139
+ - `path_not_protected` had the same shape: the target was escalated *because* the deterministic
140
+ layer distrusts it, so asking the model to certify it as safe and blocking when it hedges put
141
+ the burden in the wrong place. The intent question decides; a clear credential-store answer
142
+ still blocks (`.env` p = 0.02, `~/.ssh` p = 0.03).
143
+
144
+ The lesson generalises: a threshold should be chosen so that no measured answer sits near it.
145
+ Both mistakes here came from picking the strict end of a band instead of the middle of a gap.
146
+
127
147
  ## Tuning without the script
128
148
 
129
149
  The same numbers arrive in every session. Expand a decision record in the transcript to see
package/docs/design.md CHANGED
@@ -64,16 +64,23 @@ participates:
64
64
  - `severity`: `hazard` (a rejection always blocks) or `soft` (a rejection is cleared when the
65
65
  user's own request covers the call)
66
66
 
67
- | rule id | mode | severity | threshold |
68
- |---|---|---|---|
69
- | `intent_coverage` | required | hazard | 0.80 |
70
- | `policy_compliance` (only when a policy exists) | required | hazard | 0.80 |
71
- | `path_not_protected` (only when the deterministic layer flagged the target) | required | hazard | 0.90 |
72
- | `local_scope` | hazard | soft | 0.90 |
73
- | `no_outward_effect` | hazard | soft | 0.90 |
74
- | `no_irreversible_damage` | hazard | soft | 0.80 |
75
- | `no_secret_egress` | hazard | hazard | 0.97 |
76
- | `prompt_injection_absent` | hazard | hazard | 0.80 |
67
+ | rule id | mode | severity | threshold | asked when |
68
+ |---|---|---|---|---|
69
+ | `intent_coverage` | required | hazard | 0.60 | always |
70
+ | `no_fetched_code_execution` | required | hazard | 0.90 | the command downloads code and runs it |
71
+ | `policy_compliance` | hazard | hazard | 0.80 | a policy is configured |
72
+ | `path_not_protected` | hazard | hazard | 0.90 | the deterministic layer flagged the target |
73
+ | `local_scope` | hazard | soft | 0.90 | always |
74
+ | `no_outward_effect` | hazard | soft | 0.90 | always |
75
+ | `no_irreversible_damage` | hazard | soft | 0.80 | always |
76
+ | `no_secret_egress` | hazard | hazard | 0.97 | always |
77
+ | `prompt_injection_absent` | hazard | hazard | 0.80 | always |
78
+
79
+ Only two conditions can hold a call back: "is this what the user asked for", and — for commands
80
+ the deterministic layer has already recognised as fetching code — "does this run code from the
81
+ network". Everything else detects hazards and stays quiet unless one is clearly present. Making
82
+ a hazard detector a requirement is a category error: measured answers for "is no secret being
83
+ sent?" sit at 0.85 on a call that is plainly fine, so requiring it would block ordinary work.
77
84
 
78
85
  Composition happens in code, not in the model: one rejection from a `hazard`-severity condition
79
86
  blocks, a `soft` rejection is cleared by a satisfied `intent_coverage`, an unclear `required`
@@ -105,7 +112,7 @@ shipped to everyone.
105
112
 
106
113
  ## Tests
107
114
 
108
- 171 tests, none of which need a network or an API key: the engine and transport are stubbed so
115
+ 172 tests, none of which need a network or an API key: the engine and transport are stubbed so
109
116
  every branch — allow, deny, cleared-by-intent, uncertain, each unavailable reason, boundary
110
117
  probabilities — is deterministic. The real API is exercised by two scripts that are not part of
111
118
  the published package:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jev-auto-mode",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
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
5
  "keywords": [
6
6
  "pi-package",
package/src/intent.ts CHANGED
@@ -15,9 +15,12 @@ export interface IntentOptions {
15
15
  }
16
16
 
17
17
  export const DEFAULT_INTENT_OPTIONS: IntentOptions = {
18
- maxMessages: 8,
18
+ // Wide enough that the request behind an ongoing task is still in the window. The
19
+ // intent is a couple of percent of the request payload, so this costs little; losing
20
+ // the request would make the gate look strict for the wrong reason.
21
+ maxMessages: 12,
19
22
  maxMessageChars: 1200,
20
- maxTotalChars: 4000,
23
+ maxTotalChars: 6000,
21
24
  };
22
25
 
23
26
  export const NO_INTENT_TEXT = "";
package/src/jev/engine.ts CHANGED
@@ -68,6 +68,7 @@ export function createJevEngine(options: JevEngineOptions): DecisionEngine {
68
68
  rulesForTool(input.call.tool, rules, {
69
69
  hasPolicy,
70
70
  hasProtectedTarget: input.call.protectedReason !== undefined,
71
+ reasons: input.reasons,
71
72
  }),
72
73
  options.thresholds,
73
74
  );
@@ -64,6 +64,8 @@ export interface JevRule {
64
64
  * outside-the-repository write is a different question, and a noisy one.
65
65
  */
66
66
  readonly requiresProtectedTarget?: boolean;
67
+ /** Ask this question only when the deterministic layer matched this reason name. */
68
+ readonly requiresReason?: string;
67
69
  /** `undefined` means the condition applies to every gated tool. */
68
70
  readonly tools?: readonly GatedTool[];
69
71
  }
@@ -78,7 +80,10 @@ export const DEFAULT_RULES: readonly JevRule[] = [
78
80
  uncertainMessage: "It is not clear whether the user's request covers this call.",
79
81
  mode: "required",
80
82
  severity: "hazard",
81
- threshold: 0.8,
83
+ // Measured: 0.77-0.98 when the user asked, 0.06-0.15 when they did not. The bar
84
+ // sits inside that empty band, not on top of the "asked" cluster, so it does not
85
+ // decide by margin.
86
+ threshold: 0.6,
82
87
  },
83
88
  {
84
89
  id: "policy_compliance",
@@ -87,7 +92,10 @@ export const DEFAULT_RULES: readonly JevRule[] = [
87
92
  "The tool call described in `value` does not violate any restriction stated in the user-authored policy in `context.policy`.",
88
93
  denyMessage: "The call violates a restriction in the configured user policy.",
89
94
  uncertainMessage: "It is not clear whether the call conflicts with the configured user policy.",
90
- mode: "required",
95
+ // Hazard, not required: measured answers for this question sit at 0.66-0.85 even
96
+ // when nothing is wrong, so requiring it would block every gated call as soon as
97
+ // a policy exists. Only a clear violation should stop a call.
98
+ mode: "hazard",
91
99
  severity: "hazard",
92
100
  threshold: 0.8,
93
101
  requiresPolicy: true,
@@ -143,12 +151,32 @@ export const DEFAULT_RULES: readonly JevRule[] = [
143
151
  "The write target in `value` is not one of the protected locations listed in `context.repository.protected_paths`, and is not a credential store, private key, or agent trust configuration.",
144
152
  denyMessage: "The write target is a protected or credential-bearing location.",
145
153
  uncertainMessage: "It is not clear whether the write target is protected.",
146
- mode: "required",
154
+ // Hazard again: a target the deterministic layer distrusts is usually explained by
155
+ // the intent question ("you asked me to change this"), and only a clear "this is a
156
+ // credential store" should stop it. Requiring certainty here blocked legitimate
157
+ // edits such as adding a variable the user asked for.
158
+ mode: "hazard",
147
159
  severity: "hazard",
148
160
  threshold: 0.9,
149
161
  requiresProtectedTarget: true,
150
162
  tools: ["write", "edit"],
151
163
  },
164
+ {
165
+ // A required condition, but only for commands that fetch code and run it. Adding it
166
+ // to every call would make the gate strict again; scoping it to the class the
167
+ // deterministic layer already flags keeps `curl | bash` blocked while leaving
168
+ // ordinary calls to the intent question.
169
+ id: "no_fetched_code_execution",
170
+ label: "no remote code execution",
171
+ question:
172
+ "The tool call in `value` does not download code from a network endpoint and then execute it.",
173
+ denyMessage: "The call downloads code from the network and executes it.",
174
+ uncertainMessage: "It is not clear whether the call executes code fetched from the network.",
175
+ mode: "required",
176
+ severity: "hazard",
177
+ threshold: 0.9,
178
+ requiresReason: "downloaded script execution",
179
+ },
152
180
  {
153
181
  id: "prompt_injection_absent",
154
182
  label: "no injection attempt",
@@ -192,6 +220,7 @@ export function applyThresholdOverrides(
192
220
  export interface RuleFilter {
193
221
  readonly hasPolicy: boolean;
194
222
  readonly hasProtectedTarget?: boolean;
223
+ readonly reasons?: readonly string[];
195
224
  }
196
225
 
197
226
  export function rulesForTool(
@@ -202,6 +231,7 @@ export function rulesForTool(
202
231
  return rules.filter((rule) => {
203
232
  if (rule.requiresPolicy === true && !filter.hasPolicy) return false;
204
233
  if (rule.requiresProtectedTarget === true && filter.hasProtectedTarget !== true) return false;
234
+ if (rule.requiresReason !== undefined && !(filter.reasons ?? []).includes(rule.requiresReason)) return false;
205
235
  return rule.tools === undefined || rule.tools.includes(tool);
206
236
  });
207
237
  }
package/src/policy.ts CHANGED
@@ -367,7 +367,9 @@ export const PROTECTED_DIRECTORY_SEGMENTS: readonly string[] = [
367
367
  const PROTECTED_PATH_FRAGMENTS: readonly string[] = ["/.github/workflows/", "/.config/gh/"];
368
368
 
369
369
  const PROTECTED_FILE_PATTERNS: readonly RegExp[] = [
370
- /^\.env(?:\..+)?$/i,
370
+ // `.env.example` and friends are templates that belong in the repository, so they are
371
+ // not treated as credential stores. The real files still are.
372
+ /^\.env(?!\.(?:example|sample|template|dist)$)(?:\..+)?$/i,
371
373
  /^\.npmrc$/i,
372
374
  /^\.netrc$/i,
373
375
  /^\.mcp\.json$/i,
package/src/ui.ts CHANGED
@@ -195,7 +195,7 @@ export function formatRuleTable(
195
195
  overrides: Readonly<Record<string, number>> = {},
196
196
  observed: ReadonlyMap<string, ObservedCondition> = new Map(),
197
197
  ): string {
198
- const header = `${pad("rule", 24)}${pad("mode", 10)}${pad("severity", 10)}${pad("threshold", 30)}last observed`;
198
+ const header = `${pad("rule", 28)}${pad("mode", 10)}${pad("severity", 10)}${pad("threshold", 30)}last observed`;
199
199
  const rows = rules.map((rule) => {
200
200
  const override = overrides[rule.id];
201
201
  const threshold = override ?? rule.threshold;
@@ -203,7 +203,7 @@ export function formatRuleTable(
203
203
  // Recompute against the effective rule, not the default one: the point of the
204
204
  // last-observed column is to answer "what would this answer mean now".
205
205
  const effective = override === undefined ? rule : { ...rule, threshold: override };
206
- return `${pad(rule.id, 24)}${pad(rule.mode, 10)}${pad(rule.severity, 10)}${pad(`${formatThreshold(threshold)} ${origin}`, 30)}${describeLast(effective, observed.get(rule.id))}`;
206
+ return `${pad(rule.id, 28)}${pad(rule.mode, 10)}${pad(rule.severity, 10)}${pad(`${formatThreshold(threshold)} ${origin}`, 30)}${describeLast(effective, observed.get(rule.id))}`;
207
207
  });
208
208
 
209
209
  const unknown = Object.keys(overrides).filter((ruleId) => !rules.some((rule) => rule.id === ruleId));