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/src/intent.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recover what the user actually asked for.
|
|
3
|
+
*
|
|
4
|
+
* Only user-authored text is used. Assistant messages and tool output are
|
|
5
|
+
* attacker-influenced in general (they contain file contents and command output),
|
|
6
|
+
* so letting them shape "the user intent" would let repository content argue for
|
|
7
|
+
* its own approval.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface IntentOptions {
|
|
11
|
+
/** How many of the most recent user messages to consider. */
|
|
12
|
+
readonly maxMessages: number;
|
|
13
|
+
readonly maxMessageChars: number;
|
|
14
|
+
readonly maxTotalChars: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_INTENT_OPTIONS: IntentOptions = {
|
|
18
|
+
maxMessages: 8,
|
|
19
|
+
maxMessageChars: 1200,
|
|
20
|
+
maxTotalChars: 4000,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const NO_INTENT_TEXT = "";
|
|
24
|
+
|
|
25
|
+
function truncate(value: string, maxLength: number): string {
|
|
26
|
+
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Flatten a Pi message content value into plain text. */
|
|
30
|
+
export function messageText(content: unknown): string {
|
|
31
|
+
if (typeof content === "string") return content;
|
|
32
|
+
if (!Array.isArray(content)) return "";
|
|
33
|
+
|
|
34
|
+
return content
|
|
35
|
+
.filter((part): part is { type?: unknown; text?: unknown } => Boolean(part) && typeof part === "object")
|
|
36
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
37
|
+
.map((part) => part.text as string)
|
|
38
|
+
.join("\n")
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Recent user turns, oldest first.
|
|
44
|
+
*
|
|
45
|
+
* Messages carrying a `customType` are extension-injected context (plan mode and
|
|
46
|
+
* similar), not user speech, so they are skipped.
|
|
47
|
+
*/
|
|
48
|
+
export function extractRecentIntent(
|
|
49
|
+
branch: readonly unknown[],
|
|
50
|
+
options: IntentOptions = DEFAULT_INTENT_OPTIONS,
|
|
51
|
+
): string {
|
|
52
|
+
const collected: string[] = [];
|
|
53
|
+
|
|
54
|
+
for (let index = branch.length - 1; index >= 0 && collected.length < options.maxMessages; index -= 1) {
|
|
55
|
+
const entry = branch[index];
|
|
56
|
+
if (!entry || typeof entry !== "object") continue;
|
|
57
|
+
if ((entry as { type?: unknown }).type !== "message") continue;
|
|
58
|
+
|
|
59
|
+
const message = (entry as { message?: { role?: unknown; content?: unknown; customType?: unknown } }).message;
|
|
60
|
+
if (!message || message.role !== "user") continue;
|
|
61
|
+
if (typeof message.customType === "string" && message.customType.length > 0) continue;
|
|
62
|
+
|
|
63
|
+
const text = truncate(messageText(message.content), options.maxMessageChars);
|
|
64
|
+
if (text) collected.push(text);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return truncate(collected.reverse().join("\n\n"), options.maxTotalChars);
|
|
68
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the semantic layer usable right now, and where did the key come from?
|
|
3
|
+
*
|
|
4
|
+
* A missing key must not silently degrade into "allow everything": the engine
|
|
5
|
+
* falls back to the ask-only engine, which confirms in a UI and blocks without
|
|
6
|
+
* one. The reason and the key's origin are surfaced in `/jev-auto-mode status` so
|
|
7
|
+
* the degradation and the credential path are visible rather than mysterious.
|
|
8
|
+
*
|
|
9
|
+
* `TYPESAFE_API_KEY` wins over the stored secret, so a one-off or CI override does
|
|
10
|
+
* not require touching the stored credential.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type JevKeySource = "env" | "stored" | "none";
|
|
14
|
+
|
|
15
|
+
export interface JevAvailability {
|
|
16
|
+
readonly available: boolean;
|
|
17
|
+
readonly apiKey?: string;
|
|
18
|
+
readonly model: string;
|
|
19
|
+
readonly source: JevKeySource;
|
|
20
|
+
readonly reason?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function describeJevAvailability(
|
|
24
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
25
|
+
storedApiKey?: string,
|
|
26
|
+
): JevAvailability {
|
|
27
|
+
const model = env.TYPESAFE_DEFAULT_MODEL?.trim() || "jev-latest";
|
|
28
|
+
|
|
29
|
+
const environmentKey = env.TYPESAFE_API_KEY?.trim();
|
|
30
|
+
if (environmentKey) {
|
|
31
|
+
return { available: true, apiKey: environmentKey, model, source: "env" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const stored = storedApiKey?.trim();
|
|
35
|
+
if (stored) {
|
|
36
|
+
return { available: true, apiKey: stored, model, source: "stored" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
available: false,
|
|
41
|
+
model,
|
|
42
|
+
source: "none",
|
|
43
|
+
reason: "no TypeSafe API key is available (run /jev-auto-mode login, or set TYPESAFE_API_KEY)",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function describeKeySource(source: JevKeySource): string {
|
|
48
|
+
if (source === "env") return "TYPESAFE_API_KEY";
|
|
49
|
+
if (source === "stored") return "stored secret (/jev-auto-mode login)";
|
|
50
|
+
return "none";
|
|
51
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared noul criteria.
|
|
3
|
+
*
|
|
4
|
+
* The default criteria matter more than they look. JEV is calibrated, and
|
|
5
|
+
* `noul` has no `confidence` field: the only signal is the probability. If the
|
|
6
|
+
* criteria leave the middle open, an ambiguous condition lands somewhere in the
|
|
7
|
+
* middle and the two-sided thresholds in `decide.ts` can route it to a human.
|
|
8
|
+
* If they force every answer to an extreme, "uncertain" stops existing and the
|
|
9
|
+
* gate has to guess.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { JevEntry } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_CRITERIA: { readonly true: JevEntry; readonly false: JevEntry } = {
|
|
15
|
+
true: "The condition clearly holds for the item under validation.",
|
|
16
|
+
false:
|
|
17
|
+
"The condition clearly does not hold for the item under validation. " +
|
|
18
|
+
"An item the state says nothing about, or that is too ambiguous to decide, is neither clearly true nor clearly false.",
|
|
19
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probability to decision.
|
|
3
|
+
*
|
|
4
|
+
* Two symmetric thresholds per condition:
|
|
5
|
+
*
|
|
6
|
+
* p >= t → satisfied
|
|
7
|
+
* p <= 1 - t → rejected ("the opposite is as certain as a pass would be")
|
|
8
|
+
* in between → the middle band
|
|
9
|
+
*
|
|
10
|
+
* The middle band is not a bug to be squeezed out. Measured JEV answers sit at
|
|
11
|
+
* 0.98/0.02 for clear cases but also at 0.85–0.95 for conditions that are clear
|
|
12
|
+
* to a human and merely not certain to the model, so a single high bar would
|
|
13
|
+
* report almost everything as uncertain. What the middle band *means* depends on
|
|
14
|
+
* the rule:
|
|
15
|
+
*
|
|
16
|
+
* required → the middle band is escalated (ask the user)
|
|
17
|
+
* hazard → the middle band is ignored, because "no hazard is evident" is not
|
|
18
|
+
* the same as "a hazard is present"
|
|
19
|
+
*
|
|
20
|
+
* Composition is done here, in code, so the model never has to weigh concerns
|
|
21
|
+
* against each other. A single un-cleared hazard decides the call.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { JevRule } from "./questions.ts";
|
|
25
|
+
|
|
26
|
+
export type ConditionVerdict = "satisfied" | "rejected" | "uncertain";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Tolerance for the two boundaries.
|
|
30
|
+
*
|
|
31
|
+
* `1 - 0.9` is `0.09999999999999998` in IEEE 754, so an exact comparison would put
|
|
32
|
+
* `p = 0.1, t = 0.9` in the middle band even though the rule says a rejection.
|
|
33
|
+
* The widened side is the reject side, which is the direction a gate should err in.
|
|
34
|
+
*/
|
|
35
|
+
const BOUNDARY_EPSILON = 1e-9;
|
|
36
|
+
|
|
37
|
+
export interface Observation {
|
|
38
|
+
readonly ruleId: string;
|
|
39
|
+
readonly probability: number;
|
|
40
|
+
readonly threshold: number;
|
|
41
|
+
/** The band the probability falls into. */
|
|
42
|
+
readonly verdict: ConditionVerdict;
|
|
43
|
+
/**
|
|
44
|
+
* What the rule's mode makes of that band. A `hazard` rule that lands in the
|
|
45
|
+
* middle band is treated as satisfied, because "no hazard is evident" is not
|
|
46
|
+
* the same as "a hazard is present". Kept separate from `verdict` so the
|
|
47
|
+
* distinction stays visible in records.
|
|
48
|
+
*/
|
|
49
|
+
readonly effective: ConditionVerdict;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function classifyCondition(probability: number, threshold: number): ConditionVerdict {
|
|
53
|
+
if (probability >= threshold - BOUNDARY_EPSILON) return "satisfied";
|
|
54
|
+
if (probability <= 1 - threshold + BOUNDARY_EPSILON) return "rejected";
|
|
55
|
+
return "uncertain";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function observe(rules: readonly JevRule[], answers: Readonly<Record<string, number>>): Observation[] {
|
|
59
|
+
return rules.map((rule) => {
|
|
60
|
+
// A missing answer must not become an approval. The engine rejects a response
|
|
61
|
+
// with missing keys before reaching here; defaulting to 0 means "rejected".
|
|
62
|
+
const probability = answers[rule.id] ?? 0;
|
|
63
|
+
const verdict = classifyCondition(probability, rule.threshold);
|
|
64
|
+
const effective = rule.mode === "hazard" && verdict === "uncertain" ? "satisfied" : verdict;
|
|
65
|
+
return { ruleId: rule.id, probability, threshold: rule.threshold, verdict, effective };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CombinedDecision {
|
|
70
|
+
readonly verdict: "allow" | "deny" | "uncertain";
|
|
71
|
+
readonly rationale: string;
|
|
72
|
+
readonly probabilities: Readonly<Record<string, number>>;
|
|
73
|
+
/** The condition that decided the call, when one did. */
|
|
74
|
+
readonly decidingRule: string | undefined;
|
|
75
|
+
/** Rules whose clear rejection was cleared by the user's own request. */
|
|
76
|
+
readonly clearedByIntent: readonly string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const INTENT_RULE_ID = "intent_coverage";
|
|
80
|
+
|
|
81
|
+
function formatProbability(value: number): string {
|
|
82
|
+
return value.toFixed(2);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format a threshold without losing it to rounding, and keep plain values aligned.
|
|
87
|
+
*
|
|
88
|
+
* `0.995.toFixed(2)` is `"0.99"`, which would display a threshold that is not the one
|
|
89
|
+
* in effect. Tuning reads these numbers, so they have to be the real ones: two decimals
|
|
90
|
+
* when that is exact, more when it is not.
|
|
91
|
+
*/
|
|
92
|
+
export function formatThreshold(value: number): string {
|
|
93
|
+
const twoDecimals = value.toFixed(2);
|
|
94
|
+
return Number(twoDecimals) === Number(value.toFixed(4)) ? twoDecimals : Number(value.toFixed(4)).toString();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function describe(rules: readonly JevRule[], observation: Observation): string {
|
|
98
|
+
const rule = rules.find((candidate) => candidate.id === observation.ruleId);
|
|
99
|
+
return `${rule?.label ?? observation.ruleId} (p=${formatProbability(observation.probability)})`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Combine conditions:
|
|
104
|
+
*
|
|
105
|
+
* 1. A rejected `hazard`-severity rule blocks, whatever the user asked for.
|
|
106
|
+
* 2. A rejected `soft` rule is cleared when the intent condition is satisfied —
|
|
107
|
+
* the user's own words are the authority for actions they are entitled to
|
|
108
|
+
* request, and the deterministic layer already holds the non-negotiable line.
|
|
109
|
+
* 3. An unclear `required` condition escalates to a confirmation.
|
|
110
|
+
* 4. Otherwise the call is approved.
|
|
111
|
+
*/
|
|
112
|
+
export function combine(rules: readonly JevRule[], observations: readonly Observation[]): CombinedDecision {
|
|
113
|
+
const probabilities: Record<string, number> = {};
|
|
114
|
+
for (const observation of observations) probabilities[observation.ruleId] = observation.probability;
|
|
115
|
+
|
|
116
|
+
const severityOf = (observation: Observation): JevRule["severity"] =>
|
|
117
|
+
rules.find((rule) => rule.id === observation.ruleId)?.severity ?? "hazard";
|
|
118
|
+
|
|
119
|
+
const rejected = observations.filter((observation) => observation.effective === "rejected");
|
|
120
|
+
const blocking = rejected.filter((observation) => severityOf(observation) === "hazard");
|
|
121
|
+
if (blocking.length > 0) {
|
|
122
|
+
const first = blocking[0] as Observation;
|
|
123
|
+
const rule = rules.find((candidate) => candidate.id === first.ruleId);
|
|
124
|
+
return {
|
|
125
|
+
verdict: "deny",
|
|
126
|
+
rationale: `${rule?.denyMessage ?? "A safety condition was clearly violated."} ${describe(rules, first)}`,
|
|
127
|
+
probabilities,
|
|
128
|
+
decidingRule: first.ruleId,
|
|
129
|
+
clearedByIntent: [],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const intentSatisfied = observations.some(
|
|
134
|
+
(observation) => observation.ruleId === INTENT_RULE_ID && observation.effective === "satisfied",
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const soft = rejected.filter((observation) => severityOf(observation) === "soft");
|
|
138
|
+
if (soft.length > 0) {
|
|
139
|
+
const first = soft[0] as Observation;
|
|
140
|
+
const rule = rules.find((candidate) => candidate.id === first.ruleId);
|
|
141
|
+
if (!intentSatisfied) {
|
|
142
|
+
return {
|
|
143
|
+
verdict: "deny",
|
|
144
|
+
rationale: `${rule?.denyMessage ?? "A safety condition was clearly violated."} ${describe(rules, first)}`,
|
|
145
|
+
probabilities,
|
|
146
|
+
decidingRule: first.ruleId,
|
|
147
|
+
clearedByIntent: [],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
verdict: "allow",
|
|
152
|
+
rationale: `The user's request covers this call, clearing ${describe(rules, first)}.`,
|
|
153
|
+
probabilities,
|
|
154
|
+
decidingRule: first.ruleId,
|
|
155
|
+
clearedByIntent: soft.map((observation) => observation.ruleId),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const uncertain = observations.filter((observation) => observation.effective === "uncertain");
|
|
160
|
+
const firstUncertain = uncertain[0];
|
|
161
|
+
if (firstUncertain) {
|
|
162
|
+
const rule = rules.find((candidate) => candidate.id === firstUncertain.ruleId);
|
|
163
|
+
return {
|
|
164
|
+
verdict: "uncertain",
|
|
165
|
+
rationale: `${rule?.uncertainMessage ?? "A safety condition could not be decided."} ${describe(rules, firstUncertain)}`,
|
|
166
|
+
probabilities,
|
|
167
|
+
decidingRule: firstUncertain.ruleId,
|
|
168
|
+
clearedByIntent: [],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const lowest = observations.reduce<Observation | undefined>(
|
|
173
|
+
(current, observation) =>
|
|
174
|
+
current === undefined || observation.probability < current.probability ? observation : current,
|
|
175
|
+
undefined,
|
|
176
|
+
);
|
|
177
|
+
return {
|
|
178
|
+
verdict: "allow",
|
|
179
|
+
rationale:
|
|
180
|
+
lowest === undefined
|
|
181
|
+
? "No safety conditions applied."
|
|
182
|
+
: `No hazard was evident across ${observations.length} conditions (lowest p=${formatProbability(lowest.probability)} on ${lowest.ruleId}).`,
|
|
183
|
+
probabilities,
|
|
184
|
+
decidingRule: undefined,
|
|
185
|
+
clearedByIntent: [],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The JEV decision engine.
|
|
3
|
+
*
|
|
4
|
+
* One tool call in, one request out. All conditions for the call travel in the
|
|
5
|
+
* same request because JEV answers them in parallel and independently, so the
|
|
6
|
+
* marginal cost of an extra condition is a few tokens rather than a round trip.
|
|
7
|
+
*
|
|
8
|
+
* Everything that can go wrong resolves to `unavailable`, and the caller turns
|
|
9
|
+
* that into a block. The engine never invents an approval.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { toJevState, NO_POLICY_PLACEHOLDER } from "../call.ts";
|
|
13
|
+
import type { CandidateInput, ConditionReport, DecisionEngine, EngineVerdict, JudgeOptions } from "../decide.ts";
|
|
14
|
+
import { combine, observe, type Observation } from "./decide.ts";
|
|
15
|
+
import { DEFAULT_RULES, applyThresholdOverrides, buildQuestions, rulesForTool, type JevRule } from "./questions.ts";
|
|
16
|
+
import { parseAnswers } from "./response.ts";
|
|
17
|
+
import type { JevTransport } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
export interface ObservationMeta {
|
|
20
|
+
readonly model: string;
|
|
21
|
+
readonly latencyMs: number;
|
|
22
|
+
readonly inputTokens: number;
|
|
23
|
+
readonly outputTokens: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface JevEngineOptions {
|
|
27
|
+
readonly transport: JevTransport;
|
|
28
|
+
readonly rules?: readonly JevRule[];
|
|
29
|
+
readonly model?: string;
|
|
30
|
+
/** Per-rule threshold overrides from settings. */
|
|
31
|
+
readonly thresholds?: Readonly<Record<string, number>>;
|
|
32
|
+
/** Shared state + questions budget, in characters. */
|
|
33
|
+
readonly maxStateCharacters?: number;
|
|
34
|
+
readonly now?: () => number;
|
|
35
|
+
/**
|
|
36
|
+
* Called for every condition of every judgment, including the ones that
|
|
37
|
+
* passed. This is the calibration channel: without the passing probabilities
|
|
38
|
+
* there is no way to choose a threshold that is not a guess.
|
|
39
|
+
*/
|
|
40
|
+
readonly onObservation?: (observations: readonly Observation[], meta: ObservationMeta) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_MAX_STATE_CHARACTERS = 120_000;
|
|
44
|
+
|
|
45
|
+
const UNAVAILABLE_TEXT: Record<string, string> = {
|
|
46
|
+
timeout: "the JEV request timed out",
|
|
47
|
+
network: "the JEV request could not reach the API",
|
|
48
|
+
http: "the JEV API returned an error status",
|
|
49
|
+
malformed_response: "the JEV response did not match the questions that were asked",
|
|
50
|
+
state_too_large: "the call description exceeded the request budget",
|
|
51
|
+
unknown: "the JEV request failed for an unknown reason",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function createJevEngine(options: JevEngineOptions): DecisionEngine {
|
|
55
|
+
const rules = options.rules ?? DEFAULT_RULES;
|
|
56
|
+
const maxStateCharacters = options.maxStateCharacters ?? DEFAULT_MAX_STATE_CHARACTERS;
|
|
57
|
+
const now = options.now ?? (() => Date.now());
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
id: "jev",
|
|
61
|
+
|
|
62
|
+
async judge(input: CandidateInput, judgeOptions: JudgeOptions): Promise<EngineVerdict> {
|
|
63
|
+
// The policy condition is skipped when no policy is configured: asking
|
|
64
|
+
// "does this violate the policy" with an empty policy produced 0.66-0.85 on
|
|
65
|
+
// every fixture, which would have escalated every call.
|
|
66
|
+
const hasPolicy = input.policy.trim().length > 0 && input.policy !== NO_POLICY_PLACEHOLDER;
|
|
67
|
+
const applicable = applyThresholdOverrides(
|
|
68
|
+
rulesForTool(input.call.tool, rules, {
|
|
69
|
+
hasPolicy,
|
|
70
|
+
hasProtectedTarget: input.call.protectedReason !== undefined,
|
|
71
|
+
}),
|
|
72
|
+
options.thresholds,
|
|
73
|
+
);
|
|
74
|
+
const state = toJevState(input);
|
|
75
|
+
const questions = buildQuestions(applicable);
|
|
76
|
+
|
|
77
|
+
// The API budget is shared between state and questions, and a request that
|
|
78
|
+
// is too large is rejected before it is sent: cheaper, and it keeps a huge
|
|
79
|
+
// diff or command from being truncated on the remote side.
|
|
80
|
+
const characters = JSON.stringify(state).length + JSON.stringify(questions).length;
|
|
81
|
+
if (characters > maxStateCharacters) {
|
|
82
|
+
return {
|
|
83
|
+
verdict: "unavailable",
|
|
84
|
+
reason: "state_too_large",
|
|
85
|
+
rationale: `The call description was ${characters} characters, over the ${maxStateCharacters} character budget.`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const startedAt = now();
|
|
90
|
+
const result = await options.transport.systemOne({
|
|
91
|
+
state,
|
|
92
|
+
questions,
|
|
93
|
+
...(options.model === undefined ? {} : { model: options.model }),
|
|
94
|
+
...(judgeOptions.signal === undefined ? {} : { signal: judgeOptions.signal }),
|
|
95
|
+
});
|
|
96
|
+
const latencyMs = now() - startedAt;
|
|
97
|
+
|
|
98
|
+
if (!result.ok) {
|
|
99
|
+
return {
|
|
100
|
+
verdict: "unavailable",
|
|
101
|
+
reason: result.reason,
|
|
102
|
+
rationale: UNAVAILABLE_TEXT[result.reason] ?? UNAVAILABLE_TEXT.unknown ?? "JEV could not decide.",
|
|
103
|
+
latencyMs,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const parsed = parseAnswers(result.response, Object.keys(questions));
|
|
108
|
+
if (!parsed.ok) {
|
|
109
|
+
return {
|
|
110
|
+
verdict: "unavailable",
|
|
111
|
+
reason: parsed.reason,
|
|
112
|
+
rationale: UNAVAILABLE_TEXT[parsed.reason] ?? "The JEV response could not be used.",
|
|
113
|
+
latencyMs,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const observations = observe(applicable, parsed.parsed.answers);
|
|
118
|
+
options.onObservation?.(observations, {
|
|
119
|
+
model: parsed.parsed.model,
|
|
120
|
+
latencyMs,
|
|
121
|
+
inputTokens: parsed.parsed.inputTokens,
|
|
122
|
+
outputTokens: parsed.parsed.outputTokens,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const combined = combine(applicable, observations);
|
|
126
|
+
const thresholds: Record<string, number> = {};
|
|
127
|
+
for (const rule of applicable) thresholds[rule.id] = rule.threshold;
|
|
128
|
+
|
|
129
|
+
const cleared = new Set(combined.clearedByIntent);
|
|
130
|
+
const conditions: ConditionReport[] = applicable.map((rule, index) => {
|
|
131
|
+
const observation = observations[index];
|
|
132
|
+
const verdict = observation?.verdict ?? "uncertain";
|
|
133
|
+
return {
|
|
134
|
+
ruleId: rule.id,
|
|
135
|
+
label: rule.label,
|
|
136
|
+
probability: observation?.probability ?? 0,
|
|
137
|
+
threshold: rule.threshold,
|
|
138
|
+
verdict: verdict === "uncertain" && rule.mode === "hazard" ? "ignored" : verdict,
|
|
139
|
+
clearedByIntent: cleared.has(rule.id),
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const evidence = {
|
|
144
|
+
probabilities: combined.probabilities,
|
|
145
|
+
thresholds,
|
|
146
|
+
conditions,
|
|
147
|
+
model: parsed.parsed.model,
|
|
148
|
+
latencyMs,
|
|
149
|
+
...(combined.decidingRule === undefined ? {} : { decidingRule: combined.decidingRule }),
|
|
150
|
+
...(combined.clearedByIntent.length === 0 ? {} : { clearedByIntent: combined.clearedByIntent }),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
switch (combined.verdict) {
|
|
154
|
+
case "allow":
|
|
155
|
+
return { verdict: "allow", rationale: combined.rationale, ...evidence };
|
|
156
|
+
case "deny":
|
|
157
|
+
return { verdict: "deny", rationale: combined.rationale, ...evidence };
|
|
158
|
+
case "uncertain":
|
|
159
|
+
return { verdict: "uncertain", rationale: combined.rationale, ...evidence };
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
package/src/jev/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JEV layer: transport, question set, response validation, and the engine.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { createJevEngine, DEFAULT_MAX_STATE_CHARACTERS, type JevEngineOptions, type ObservationMeta } from "./engine.ts";
|
|
6
|
+
export { describeJevAvailability, describeKeySource, type JevAvailability, type JevKeySource } from "./availability.ts";
|
|
7
|
+
export { createSdkTransport, isAbortError, verifyApiKey, type ApiKeyVerification, type SdkTransportOptions } from "./transport.ts";
|
|
8
|
+
export { DEFAULT_RULES, applyThresholdOverrides, buildQuestions, ruleById, rulesForTool, type JevRule, type JevRuleMode, type JevSeverity } from "./questions.ts";
|
|
9
|
+
export { DEFAULT_CRITERIA } from "./criteria.ts";
|
|
10
|
+
export { classifyCondition, combine, formatThreshold, observe, type CombinedDecision, type ConditionVerdict, type Observation } from "./decide.ts";
|
|
11
|
+
export { parseAnswers, type ParsedAnswers } from "./response.ts";
|
|
12
|
+
export type {
|
|
13
|
+
JevEntry,
|
|
14
|
+
JevNoulQuestion,
|
|
15
|
+
JevRequest,
|
|
16
|
+
JevTransport,
|
|
17
|
+
JevTransportResult,
|
|
18
|
+
JevUnavailableReason,
|
|
19
|
+
} from "./types.ts";
|
|
20
|
+
export type { JevJson, JevState } from "./state.ts";
|