@squadrant-ai/auto-gate 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/README.md +2 -0
- package/dist/adapters/claude/blocked-signal.d.ts +16 -0
- package/dist/adapters/claude/blocked-signal.js +21 -0
- package/dist/adapters/claude/install.d.ts +12 -0
- package/dist/adapters/claude/install.js +52 -0
- package/dist/adapters/claude/output.d.ts +5 -0
- package/dist/adapters/claude/output.js +16 -0
- package/dist/adapters/claude/payload.d.ts +14 -0
- package/dist/adapters/claude/payload.js +36 -0
- package/dist/adapters/claude/settings.d.ts +14 -0
- package/dist/adapters/claude/settings.js +48 -0
- package/dist/adapters/opencode/answer.d.ts +24 -0
- package/dist/adapters/opencode/answer.js +25 -0
- package/dist/adapters/opencode/install.d.ts +5 -0
- package/dist/adapters/opencode/install.js +30 -0
- package/dist/adapters/opencode/intent.d.ts +10 -0
- package/dist/adapters/opencode/intent.js +11 -0
- package/dist/adapters/opencode/lock.d.ts +18 -0
- package/dist/adapters/opencode/lock.js +72 -0
- package/dist/adapters/opencode/port.d.ts +17 -0
- package/dist/adapters/opencode/port.js +29 -0
- package/dist/adapters/opencode/run.d.ts +40 -0
- package/dist/adapters/opencode/run.js +69 -0
- package/dist/adapters/opencode/sse.d.ts +18 -0
- package/dist/adapters/opencode/sse.js +29 -0
- package/dist/adapters/opencode/watch.d.ts +43 -0
- package/dist/adapters/opencode/watch.js +138 -0
- package/dist/classifiers/battery.d.ts +26 -0
- package/dist/classifiers/battery.js +27 -0
- package/dist/classifiers/classify.d.ts +15 -0
- package/dist/classifiers/classify.js +25 -0
- package/dist/classifiers/generative.d.ts +16 -0
- package/dist/classifiers/generative.js +50 -0
- package/dist/classifiers/jev-parse.d.ts +6 -0
- package/dist/classifiers/jev-parse.js +35 -0
- package/dist/classifiers/jev-state.d.ts +10 -0
- package/dist/classifiers/jev-state.js +13 -0
- package/dist/classifiers/jev.d.ts +15 -0
- package/dist/classifiers/jev.js +66 -0
- package/dist/classifiers/null.d.ts +6 -0
- package/dist/classifiers/null.js +9 -0
- package/dist/cli/claude.d.ts +21 -0
- package/dist/cli/claude.js +20 -0
- package/dist/cli/decide.d.ts +17 -0
- package/dist/cli/decide.js +16 -0
- package/dist/cli/doctor.d.ts +4 -0
- package/dist/cli/doctor.js +26 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +109 -0
- package/dist/cli/runtime.d.ts +19 -0
- package/dist/cli/runtime.js +86 -0
- package/dist/cli/stats.d.ts +7 -0
- package/dist/cli/stats.js +29 -0
- package/dist/cli/test.d.ts +2 -0
- package/dist/cli/test.js +9 -0
- package/dist/config/keys.d.ts +8 -0
- package/dist/config/keys.js +17 -0
- package/dist/config/resolve.d.ts +17 -0
- package/dist/config/resolve.js +52 -0
- package/dist/config/schema.d.ts +38 -0
- package/dist/config/schema.js +19 -0
- package/dist/core/audit.d.ts +21 -0
- package/dist/core/audit.js +12 -0
- package/dist/core/cache.d.ts +22 -0
- package/dist/core/cache.js +59 -0
- package/dist/core/decide.d.ts +26 -0
- package/dist/core/decide.js +75 -0
- package/dist/core/policy-version.d.ts +2 -0
- package/dist/core/policy-version.js +14 -0
- package/dist/core/policy.d.ts +2 -0
- package/dist/core/policy.js +36 -0
- package/dist/core/presets.d.ts +9 -0
- package/dist/core/presets.js +15 -0
- package/dist/core/redact.d.ts +7 -0
- package/dist/core/redact.js +26 -0
- package/dist/core/scope.d.ts +2 -0
- package/dist/core/scope.js +7 -0
- package/dist/core/tier1.d.ts +3 -0
- package/dist/core/tier1.js +16 -0
- package/dist/core/tools.d.ts +5 -0
- package/dist/core/tools.js +21 -0
- package/dist/core/types.d.ts +68 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +30 -0
- package/dist/integration/host.d.ts +28 -0
- package/dist/integration/host.js +37 -0
- package/dist/integration/project.d.ts +24 -0
- package/dist/integration/project.js +19 -0
- package/package.json +37 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Pure. Unknown / heartbeat / non-JSON frames return null (the caller ignores
|
|
2
|
+
* them). Live-verified shape (opencode 1.15.13): permission.asked properties =
|
|
3
|
+
* { id:"per_…", sessionID:"ses_…", permission:"bash", patterns:[cmd] }. */
|
|
4
|
+
export function parseSseFrame(raw) {
|
|
5
|
+
const line = raw.startsWith("data:") ? raw.slice(5).trim() : raw.trim();
|
|
6
|
+
if (!line || line === "[DONE]")
|
|
7
|
+
return null;
|
|
8
|
+
let j;
|
|
9
|
+
try {
|
|
10
|
+
j = JSON.parse(line);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const p = j.properties ?? {};
|
|
16
|
+
if (j.type === "permission.asked" && typeof p.id === "string" && typeof p.sessionID === "string") {
|
|
17
|
+
return { kind: "permission.asked", id: p.id, sessionID: p.sessionID,
|
|
18
|
+
permission: typeof p.permission === "string" ? p.permission : "a tool",
|
|
19
|
+
patterns: Array.isArray(p.patterns) ? p.patterns.map(String) : [] };
|
|
20
|
+
}
|
|
21
|
+
if (j.type === "permission.replied" && typeof p.id === "string")
|
|
22
|
+
return { kind: "permission.replied", id: p.id };
|
|
23
|
+
const part = p.part;
|
|
24
|
+
if ((j.type === "message.part.updated" || j.type === "message.updated") &&
|
|
25
|
+
p.role === "user" && part?.type === "text" && typeof part.text === "string" && typeof p.sessionID === "string") {
|
|
26
|
+
return { kind: "user-text", sessionID: p.sessionID, text: part.text };
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { GateOutcome, GateRequest } from "../../core/types.js";
|
|
2
|
+
export interface WatcherDeps {
|
|
3
|
+
host: string;
|
|
4
|
+
port: number;
|
|
5
|
+
acquire(): {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
release?(): void;
|
|
8
|
+
};
|
|
9
|
+
subscribe(url: string, onFrame: (line: string) => void | Promise<void>): Promise<() => void>;
|
|
10
|
+
decide(req: GateRequest): Promise<GateOutcome>;
|
|
11
|
+
answer(a: {
|
|
12
|
+
sessionID: string;
|
|
13
|
+
permissionID: string;
|
|
14
|
+
response: "once" | "reject";
|
|
15
|
+
}): Promise<void>;
|
|
16
|
+
log?: (m: string) => void;
|
|
17
|
+
now?: () => number;
|
|
18
|
+
sleep?: (ms: number) => Promise<void>;
|
|
19
|
+
bootDeadlineMs?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface Watcher {
|
|
22
|
+
/** Resolves once the watcher has settled into its steady state (or given up). */
|
|
23
|
+
start(): Promise<"yielded" | "unrecoverable" | "running">;
|
|
24
|
+
/** Ask the watcher to release its lock and tear down its subscription. */
|
|
25
|
+
stop(): void;
|
|
26
|
+
/** Resolves when the watcher has stopped (by `stop()` or a terminal outcome). */
|
|
27
|
+
stopped: Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/** The ONE client allowed to answer permissions for this server (§15 #7). The
|
|
30
|
+
* loser yields permanently — it never loops, never fights the owner. */
|
|
31
|
+
export declare function createWatcher(d: WatcherDeps): Watcher;
|
|
32
|
+
/** Real SSE subscription over fetch — used only by the CLI, never in tests (the
|
|
33
|
+
* watcher takes `subscribe` as a dep so every behaviour is unit-tested with a
|
|
34
|
+
* fake). Resolves to a teardown that aborts the request. */
|
|
35
|
+
export declare function subscribeSse(url: string, onFrame: (line: string) => void | Promise<void>): Promise<() => void>;
|
|
36
|
+
/** Real answer POST over fetch — CLI only, as above. */
|
|
37
|
+
export declare function answerPermission(o: {
|
|
38
|
+
host: string;
|
|
39
|
+
port: number;
|
|
40
|
+
sessionID: string;
|
|
41
|
+
permissionID: string;
|
|
42
|
+
response: "once" | "reject";
|
|
43
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { parseSseFrame } from "./sse.js";
|
|
2
|
+
import { reconstructIntent } from "./intent.js";
|
|
3
|
+
import { answerFor } from "./answer.js";
|
|
4
|
+
const BACKOFF_BASE_MS = 500;
|
|
5
|
+
const BACKOFF_CAP_MS = 30_000;
|
|
6
|
+
/** The ONE client allowed to answer permissions for this server (§15 #7). The
|
|
7
|
+
* loser yields permanently — it never loops, never fights the owner. */
|
|
8
|
+
export function createWatcher(d) {
|
|
9
|
+
const now = d.now ?? Date.now;
|
|
10
|
+
const sleep = d.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
11
|
+
const deadline = d.bootDeadlineMs ?? 120_000;
|
|
12
|
+
let release;
|
|
13
|
+
let unsubscribe;
|
|
14
|
+
let resolveStopped = () => { };
|
|
15
|
+
const stopped = new Promise((r) => { resolveStopped = r; });
|
|
16
|
+
let finished = false;
|
|
17
|
+
function finish() {
|
|
18
|
+
if (finished)
|
|
19
|
+
return;
|
|
20
|
+
finished = true;
|
|
21
|
+
try {
|
|
22
|
+
unsubscribe?.();
|
|
23
|
+
}
|
|
24
|
+
catch { /* already gone */ }
|
|
25
|
+
try {
|
|
26
|
+
release?.();
|
|
27
|
+
}
|
|
28
|
+
catch { /* already gone */ }
|
|
29
|
+
resolveStopped();
|
|
30
|
+
}
|
|
31
|
+
async function onFrame(line, history) {
|
|
32
|
+
const ev = parseSseFrame(line);
|
|
33
|
+
if (!ev)
|
|
34
|
+
return;
|
|
35
|
+
history.push(ev);
|
|
36
|
+
if (history.length > 200)
|
|
37
|
+
history.shift();
|
|
38
|
+
if (ev.kind !== "permission.asked")
|
|
39
|
+
return;
|
|
40
|
+
const intent = reconstructIntent(ev.sessionID, history);
|
|
41
|
+
const req = {
|
|
42
|
+
agent: "opencode",
|
|
43
|
+
toolName: ev.permission,
|
|
44
|
+
toolPayload: ev.patterns.join(" "),
|
|
45
|
+
// D3: `permission.asked` carries NO cwd, so we cannot derive scope here.
|
|
46
|
+
// Keep "" rather than add an endpoint/dep; opencode `scope_escape` fidelity
|
|
47
|
+
// is therefore LOWER than claude's (documented in the PR body).
|
|
48
|
+
cwd: "",
|
|
49
|
+
...(intent.text ? { userIntent: intent.text } : {}),
|
|
50
|
+
intentSupport: intent.support,
|
|
51
|
+
sessionKind: "standalone",
|
|
52
|
+
raw: { sessionID: ev.sessionID, permID: ev.id },
|
|
53
|
+
};
|
|
54
|
+
const outcome = await d.decide(req);
|
|
55
|
+
const response = answerFor(outcome);
|
|
56
|
+
if (response)
|
|
57
|
+
await d.answer({ sessionID: ev.sessionID, permissionID: ev.id, response });
|
|
58
|
+
else
|
|
59
|
+
d.log?.(`auto-gate opencode: leaving ${ev.id} pending (${outcome.decision}) — needs a human`);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
stopped,
|
|
63
|
+
async start() {
|
|
64
|
+
const lock = d.acquire();
|
|
65
|
+
if (!lock.ok) {
|
|
66
|
+
d.log?.(`auto-gate opencode: another client owns ${d.host}:${d.port} — observing only (§15#7)`);
|
|
67
|
+
finish();
|
|
68
|
+
return "yielded";
|
|
69
|
+
}
|
|
70
|
+
release = lock.release;
|
|
71
|
+
const history = [];
|
|
72
|
+
const started = now();
|
|
73
|
+
let backoff = BACKOFF_BASE_MS;
|
|
74
|
+
for (;;) {
|
|
75
|
+
try {
|
|
76
|
+
unsubscribe = await d.subscribe(`http://${d.host}:${d.port}/event`, (line) => onFrame(line, history));
|
|
77
|
+
return "running";
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
if (now() - started >= deadline) {
|
|
81
|
+
d.log?.(`auto-gate opencode: unrecoverable after ${deadline}ms — ${e.message}`);
|
|
82
|
+
finish();
|
|
83
|
+
return "unrecoverable";
|
|
84
|
+
}
|
|
85
|
+
d.log?.(`auto-gate opencode: subscribe failed (${e.message}); retry in ${backoff}ms`);
|
|
86
|
+
await sleep(backoff);
|
|
87
|
+
backoff = Math.min(backoff * 2, BACKOFF_CAP_MS);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
stop() { finish(); },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Real SSE subscription over fetch — used only by the CLI, never in tests (the
|
|
95
|
+
* watcher takes `subscribe` as a dep so every behaviour is unit-tested with a
|
|
96
|
+
* fake). Resolves to a teardown that aborts the request. */
|
|
97
|
+
export async function subscribeSse(url, onFrame) {
|
|
98
|
+
const ctrl = new AbortController();
|
|
99
|
+
const res = await fetch(url, { headers: { accept: "text/event-stream" }, signal: ctrl.signal });
|
|
100
|
+
if (!res.ok || !res.body)
|
|
101
|
+
throw new Error(`SSE connect failed: ${res.status}`);
|
|
102
|
+
const reader = res.body.getReader();
|
|
103
|
+
const decoder = new TextDecoder();
|
|
104
|
+
let buf = "";
|
|
105
|
+
void (async () => {
|
|
106
|
+
try {
|
|
107
|
+
for (;;) {
|
|
108
|
+
const { value, done } = await reader.read();
|
|
109
|
+
if (done)
|
|
110
|
+
break;
|
|
111
|
+
buf += decoder.decode(value, { stream: true });
|
|
112
|
+
let idx;
|
|
113
|
+
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
114
|
+
const chunk = buf.slice(0, idx);
|
|
115
|
+
buf = buf.slice(idx + 2);
|
|
116
|
+
for (const line of chunk.split("\n")) {
|
|
117
|
+
const t = line.trim();
|
|
118
|
+
if (t.startsWith("data:"))
|
|
119
|
+
await onFrame(t);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch { /* stream aborted/closed */ }
|
|
125
|
+
})();
|
|
126
|
+
return () => ctrl.abort();
|
|
127
|
+
}
|
|
128
|
+
/** Real answer POST over fetch — CLI only, as above. */
|
|
129
|
+
export async function answerPermission(o) {
|
|
130
|
+
const url = `http://${o.host}:${o.port}/session/${o.sessionID}/permissions/${o.permissionID}`;
|
|
131
|
+
const res = await fetch(url, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: { "content-type": "application/json" },
|
|
134
|
+
body: JSON.stringify({ response: o.response }),
|
|
135
|
+
});
|
|
136
|
+
if (!res.ok)
|
|
137
|
+
throw new Error(`permission answer failed: ${res.status}`);
|
|
138
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { GateRequest } from "../core/types.js";
|
|
2
|
+
import { redact } from "../core/redact.js";
|
|
3
|
+
export interface ChoiceQuestion {
|
|
4
|
+
type: "choice";
|
|
5
|
+
instructions: string;
|
|
6
|
+
criteria: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export interface NoulQuestion {
|
|
9
|
+
type: "noul";
|
|
10
|
+
instructions: string;
|
|
11
|
+
criteria?: {
|
|
12
|
+
true: string;
|
|
13
|
+
false: string;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export interface ScoreQuestion {
|
|
17
|
+
type: "score";
|
|
18
|
+
instructions: string;
|
|
19
|
+
criteria: string[];
|
|
20
|
+
}
|
|
21
|
+
export type Question = ChoiceQuestion | NoulQuestion | ScoreQuestion;
|
|
22
|
+
/** One request, all questions parallel (§6.3). intent_match is omitted when the
|
|
23
|
+
* adapter declares intent permanently unavailable (§6.2.1). */
|
|
24
|
+
export declare function buildQuestions(req: GateRequest): Record<string, Question>;
|
|
25
|
+
/** Kept beside the battery so both redact identically. Exported for reuse. */
|
|
26
|
+
export { redact };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { redact } from "../core/redact.js";
|
|
2
|
+
const DECISION = {
|
|
3
|
+
type: "choice",
|
|
4
|
+
instructions: "Decide whether the agent may run this action, given the user's stated intent.",
|
|
5
|
+
criteria: {
|
|
6
|
+
allow: "Safe, ordinary software-development work in service of the user's stated intent; reversible; touches no secrets; no system/home-config writes.",
|
|
7
|
+
deny: "Destructive, irreversible, credential-exfiltrating, or system-damaging; or clearly outside the user's stated intent.",
|
|
8
|
+
ask: "Genuinely ambiguous, or the state does not contain enough to decide.",
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
/** One request, all questions parallel (§6.3). intent_match is omitted when the
|
|
12
|
+
* adapter declares intent permanently unavailable (§6.2.1). */
|
|
13
|
+
export function buildQuestions(req) {
|
|
14
|
+
const q = {
|
|
15
|
+
decision: DECISION,
|
|
16
|
+
destructive: { type: "noul", instructions: "Would this action be destructive, irreversible, or hard to undo?" },
|
|
17
|
+
secrets: { type: "noul", instructions: "Does this action read, write, or transmit credentials, keys, or secrets?" },
|
|
18
|
+
scope_escape: { type: "noul", instructions: "Does this action touch paths outside the working directory (system paths, other repositories, home configuration)?" },
|
|
19
|
+
severity: { type: "score", instructions: "How much harm if this runs?", criteria: ["none", "mild", "serious", "severe"] },
|
|
20
|
+
};
|
|
21
|
+
if (req.intentSupport !== "unsupported") {
|
|
22
|
+
q.intent_match = { type: "noul", instructions: "Is this action plausibly in service of the user's stated intent?" };
|
|
23
|
+
}
|
|
24
|
+
return q;
|
|
25
|
+
}
|
|
26
|
+
/** Kept beside the battery so both redact identically. Exported for reuse. */
|
|
27
|
+
export { redact };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { GateAssessment, GateRequest } from "../core/types.js";
|
|
2
|
+
export interface Classifier {
|
|
3
|
+
id: string;
|
|
4
|
+
model?: string;
|
|
5
|
+
classify(req: GateRequest): Promise<GateAssessment>;
|
|
6
|
+
}
|
|
7
|
+
export interface ClassifyConfig {
|
|
8
|
+
kind: "jev" | "generative";
|
|
9
|
+
primary: Classifier;
|
|
10
|
+
fallback?: Classifier;
|
|
11
|
+
}
|
|
12
|
+
/** Jev first (unless kind==='generative'), then the fallback, else ask (§4, §8).
|
|
13
|
+
* The fallback is a safety net: §7 only honours its verdict when
|
|
14
|
+
* fallbackAcceptsVerdict is true, so reaching it never weakens the gate. */
|
|
15
|
+
export declare function classify(req: GateRequest, cfg: ClassifyConfig): Promise<GateAssessment>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
async function safe(c, req) {
|
|
2
|
+
try {
|
|
3
|
+
const a = await c.classify(req);
|
|
4
|
+
if (!a || typeof a.verdict !== "string")
|
|
5
|
+
throw new Error("malformed assessment");
|
|
6
|
+
return a;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return { verdict: "ask", classifier: `${c.id}-error`, tier: 2 };
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Jev first (unless kind==='generative'), then the fallback, else ask (§4, §8).
|
|
13
|
+
* The fallback is a safety net: §7 only honours its verdict when
|
|
14
|
+
* fallbackAcceptsVerdict is true, so reaching it never weakens the gate. */
|
|
15
|
+
export async function classify(req, cfg) {
|
|
16
|
+
const first = cfg.kind === "jev" ? cfg.primary : cfg.fallback;
|
|
17
|
+
if (first) {
|
|
18
|
+
const a = await safe(first, req);
|
|
19
|
+
if (a.verdict !== "ask" || a.probabilities)
|
|
20
|
+
return a; // a usable verdict ends the chain
|
|
21
|
+
}
|
|
22
|
+
if (cfg.kind === "jev" && cfg.fallback)
|
|
23
|
+
return safe(cfg.fallback, req);
|
|
24
|
+
return { verdict: "ask", classifier: "none", tier: 2 };
|
|
25
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { GateAssessment, GateRequest } from "../core/types.js";
|
|
2
|
+
export interface GenerativeOptions {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
model: string;
|
|
5
|
+
apiKeyEnv?: string;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
fetchImpl?: typeof fetch;
|
|
8
|
+
log?: (m: string) => void;
|
|
9
|
+
}
|
|
10
|
+
/** A generative classifier yields a VERDICT ONLY (no confidence, no hazards) — §5.
|
|
11
|
+
* That is deliberate: with fallbackAcceptsVerdict:false the policy engine ignores
|
|
12
|
+
* it and asks (§7). Never throws — any failure is `ask`. */
|
|
13
|
+
export declare function createGenerativeClassifier(o: GenerativeOptions): {
|
|
14
|
+
id: string;
|
|
15
|
+
classify(req: GateRequest): Promise<GateAssessment>;
|
|
16
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { buildState } from "./jev-state.js";
|
|
2
|
+
const PROMPT = [
|
|
3
|
+
"You are a permission gate for a coding agent. Reply with exactly one word: allow, deny, or ask.",
|
|
4
|
+
"allow = safe, ordinary development work in service of the user's intent.",
|
|
5
|
+
"deny = destructive, irreversible, credential-exfiltrating, or system-damaging.",
|
|
6
|
+
"ask = ambiguous or insufficient information.",
|
|
7
|
+
].join(" ");
|
|
8
|
+
/** A generative classifier yields a VERDICT ONLY (no confidence, no hazards) — §5.
|
|
9
|
+
* That is deliberate: with fallbackAcceptsVerdict:false the policy engine ignores
|
|
10
|
+
* it and asks (§7). Never throws — any failure is `ask`. */
|
|
11
|
+
export function createGenerativeClassifier(o) {
|
|
12
|
+
const fetchImpl = o.fetchImpl ?? fetch;
|
|
13
|
+
const timeoutMs = o.timeoutMs ?? 3000;
|
|
14
|
+
const key = o.apiKeyEnv ? process.env[o.apiKeyEnv] : undefined;
|
|
15
|
+
return {
|
|
16
|
+
id: "generative",
|
|
17
|
+
async classify(req) {
|
|
18
|
+
const ac = new AbortController();
|
|
19
|
+
const t = setTimeout(() => ac.abort(), timeoutMs);
|
|
20
|
+
try {
|
|
21
|
+
const r = await fetchImpl(`${o.baseUrl}/chat/completions`, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "content-type": "application/json", ...(key ? { authorization: `Bearer ${key}` } : {}) },
|
|
24
|
+
body: JSON.stringify({
|
|
25
|
+
model: o.model,
|
|
26
|
+
messages: [
|
|
27
|
+
{ role: "system", content: PROMPT },
|
|
28
|
+
{ role: "user", content: JSON.stringify({ intent: req.userIntent ?? null, tool: req.toolName, payload: buildState(req).tool.input }) },
|
|
29
|
+
],
|
|
30
|
+
max_tokens: 4, temperature: 0,
|
|
31
|
+
}),
|
|
32
|
+
signal: ac.signal,
|
|
33
|
+
});
|
|
34
|
+
if (!r.ok)
|
|
35
|
+
return { verdict: "ask", classifier: "generative-unavailable", tier: 2 };
|
|
36
|
+
const body = (await r.json());
|
|
37
|
+
const word = (body.choices?.[0]?.message?.content ?? "").trim().toLowerCase();
|
|
38
|
+
const verdict = word === "allow" ? "allow" : word === "deny" ? "deny" : "ask";
|
|
39
|
+
return { verdict, classifier: `generative:${o.model}`, tier: 2 }; // confidence intentionally ABSENT
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
o.log?.(`generative failed: ${e.message}`);
|
|
43
|
+
return { verdict: "ask", classifier: "generative-unavailable", tier: 2 };
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
clearTimeout(t);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { GateAssessment } from "../core/types.js";
|
|
2
|
+
type RawAnswer = Record<string, unknown>;
|
|
3
|
+
/** Pure. Returns null for anything the policy engine should treat as "no usable
|
|
4
|
+
* assessment" (→ ask): a near-tie, a missing/malformed Choice, or no confidence. */
|
|
5
|
+
export declare function parseJevResponse(raw: Record<string, RawAnswer | undefined>, model: string, askedIds: string[]): GateAssessment | null;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const NEAR_TIE_EPSILON = 0.05; // §8
|
|
2
|
+
const VERDICTS = ["allow", "deny", "ask"];
|
|
3
|
+
/** Pure. Returns null for anything the policy engine should treat as "no usable
|
|
4
|
+
* assessment" (→ ask): a near-tie, a missing/malformed Choice, or no confidence. */
|
|
5
|
+
export function parseJevResponse(raw, model, askedIds) {
|
|
6
|
+
const d = raw.decision;
|
|
7
|
+
if (!d || typeof d.choice !== "string" || !VERDICTS.includes(d.choice))
|
|
8
|
+
return null;
|
|
9
|
+
if (typeof d.confidence !== "number")
|
|
10
|
+
return null;
|
|
11
|
+
if (d.probabilities) {
|
|
12
|
+
const sorted = Object.values(d.probabilities).sort((a, b) => b - a);
|
|
13
|
+
if (sorted.length >= 2 && sorted[0] - sorted[1] < NEAR_TIE_EPSILON)
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const hazards = {};
|
|
17
|
+
for (const id of ["destructive", "secrets", "scope_escape", "intent_match"]) {
|
|
18
|
+
if (!askedIds.includes(id))
|
|
19
|
+
continue;
|
|
20
|
+
const noul = raw[id]?.noul;
|
|
21
|
+
if (typeof noul === "number")
|
|
22
|
+
hazards[id] = noul;
|
|
23
|
+
}
|
|
24
|
+
const sevAnswer = raw.severity;
|
|
25
|
+
const severity = typeof sevAnswer?.score === "number" ? sevAnswer.score : undefined;
|
|
26
|
+
return {
|
|
27
|
+
verdict: d.choice,
|
|
28
|
+
confidence: d.confidence,
|
|
29
|
+
...(d.probabilities ? { probabilities: d.probabilities } : {}),
|
|
30
|
+
...(Object.keys(hazards).length ? { hazards } : {}),
|
|
31
|
+
...(severity !== undefined ? { severity } : {}),
|
|
32
|
+
classifier: `jev-${model}`,
|
|
33
|
+
tier: 2,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GateRequest } from "../core/types.js";
|
|
2
|
+
export interface JevState {
|
|
3
|
+
user_intent?: string;
|
|
4
|
+
tool: {
|
|
5
|
+
name: string;
|
|
6
|
+
input: Record<string, unknown>;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/** Structured, minimal, redacted. State is data, never instructions (§6.2). */
|
|
10
|
+
export declare function buildState(req: GateRequest): JevState;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { redact } from "../core/redact.js";
|
|
2
|
+
const MAX_STATE_CHARS = 8_000; // terse: Jev suffers context-rot on irrelevant detail (§2.2)
|
|
3
|
+
/** Structured, minimal, redacted. State is data, never instructions (§6.2). */
|
|
4
|
+
export function buildState(req) {
|
|
5
|
+
const payload = redact(req.toolPayload).text;
|
|
6
|
+
const state = {
|
|
7
|
+
tool: { name: req.toolName, input: { payload: payload.slice(0, MAX_STATE_CHARS) } },
|
|
8
|
+
};
|
|
9
|
+
if (req.intentSupport === "available" && req.userIntent) {
|
|
10
|
+
state.user_intent = redact(req.userIntent).text.slice(0, MAX_STATE_CHARS);
|
|
11
|
+
}
|
|
12
|
+
return state;
|
|
13
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { GateAssessment, GateRequest } from "../core/types.js";
|
|
2
|
+
export interface JevOptions {
|
|
3
|
+
apiKey: string;
|
|
4
|
+
model: string;
|
|
5
|
+
baseUrl?: string;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
fetchImpl?: typeof fetch;
|
|
8
|
+
sleep?: (ms: number) => Promise<void>;
|
|
9
|
+
log?: (m: string) => void;
|
|
10
|
+
}
|
|
11
|
+
/** One request per decision (§6.1). MUST never throw — every failure is `ask` (§8). */
|
|
12
|
+
export declare function createJevClassifier(o: JevOptions): {
|
|
13
|
+
id: string;
|
|
14
|
+
classify(req: GateRequest): Promise<GateAssessment>;
|
|
15
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { buildQuestions } from "./battery.js";
|
|
2
|
+
import { buildState } from "./jev-state.js";
|
|
3
|
+
import { parseJevResponse } from "./jev-parse.js";
|
|
4
|
+
const ASK_CLASSIFIER = "jev-unavailable";
|
|
5
|
+
/** One request per decision (§6.1). MUST never throw — every failure is `ask` (§8). */
|
|
6
|
+
export function createJevClassifier(o) {
|
|
7
|
+
const base = o.baseUrl ?? "https://api.typesafe.ai";
|
|
8
|
+
const timeoutMs = o.timeoutMs ?? 4000;
|
|
9
|
+
const fetchImpl = o.fetchImpl ?? fetch;
|
|
10
|
+
const sleep = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
11
|
+
/** Every failure resolves here (§8). The reason is logged (audit/redaction in
|
|
12
|
+
* P3), never put on the assessment — policy only needs the verdict. */
|
|
13
|
+
const ask = (reason) => {
|
|
14
|
+
o.log?.(`jev → ask (${reason})`);
|
|
15
|
+
return { verdict: "ask", classifier: ASK_CLASSIFIER, tier: 2 };
|
|
16
|
+
};
|
|
17
|
+
async function postOnce(body) {
|
|
18
|
+
const ac = new AbortController();
|
|
19
|
+
const t = setTimeout(() => ac.abort(), timeoutMs);
|
|
20
|
+
try {
|
|
21
|
+
const r = await fetchImpl(`${base}/v1/systemone`, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${o.apiKey}` },
|
|
24
|
+
body: JSON.stringify(body),
|
|
25
|
+
signal: ac.signal,
|
|
26
|
+
});
|
|
27
|
+
const retryAfter = r.headers?.get?.("retry-after") ?? undefined;
|
|
28
|
+
const json = r.ok ? await r.json() : undefined;
|
|
29
|
+
return { status: r.status, retryAfter: retryAfter ?? undefined, json };
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
clearTimeout(t);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
id: "jev",
|
|
37
|
+
async classify(req) {
|
|
38
|
+
const questions = buildQuestions(req);
|
|
39
|
+
const payload = { state: buildState(req), model: o.model, questions };
|
|
40
|
+
const askedIds = Object.keys(questions);
|
|
41
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
42
|
+
try {
|
|
43
|
+
const r = await postOnce(payload);
|
|
44
|
+
if (r.status === 429 || r.status === 529) {
|
|
45
|
+
if (attempt === 0) {
|
|
46
|
+
const wait = Number(r.retryAfter ?? "0") * 1000;
|
|
47
|
+
await sleep(Number.isFinite(wait) ? Math.min(wait, 2000) : 0);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
return ask("rate-limited");
|
|
51
|
+
}
|
|
52
|
+
if (r.status !== 200 || !r.json)
|
|
53
|
+
return ask(`http ${r.status}`);
|
|
54
|
+
return parseJevResponse(r.json, o.model, askedIds) ?? ask("unusable");
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
if (attempt === 0)
|
|
58
|
+
continue; // one retry covers a transient network blip
|
|
59
|
+
o.log?.(`jev failed: ${e.message}`);
|
|
60
|
+
return ask("network");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return ask("exhausted");
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { GateAssessment, GateRequest } from "../core/types.js";
|
|
2
|
+
/** Always `ask` — used when no classifier is configured (§5: never throw). */
|
|
3
|
+
export declare function createNullClassifier(): {
|
|
4
|
+
id: string;
|
|
5
|
+
classify(_req: GateRequest): Promise<GateAssessment>;
|
|
6
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { GateOutcome } from "../core/types.js";
|
|
2
|
+
import type { BlockedContext } from "../adapters/claude/blocked-signal.js";
|
|
3
|
+
export interface ClaudeDecideIo {
|
|
4
|
+
stdin: {
|
|
5
|
+
read(): Promise<string>;
|
|
6
|
+
};
|
|
7
|
+
stdout: {
|
|
8
|
+
write(s: string): void;
|
|
9
|
+
};
|
|
10
|
+
stderr: {
|
|
11
|
+
write(s: string): void;
|
|
12
|
+
};
|
|
13
|
+
decide(req: unknown): Promise<GateOutcome>;
|
|
14
|
+
readIntent(t: string | undefined): string | undefined;
|
|
15
|
+
blockedSignal?(ctx: BlockedContext): Promise<void>;
|
|
16
|
+
env?: Record<string, string | undefined>;
|
|
17
|
+
log?: (m: string) => void;
|
|
18
|
+
}
|
|
19
|
+
/** The claude `PermissionRequest` hook entry. stdout is machine-parsed: on ask/
|
|
20
|
+
* yield it MUST be empty so the normal dialog appears (§11). */
|
|
21
|
+
export declare function runClaudeDecide(io: ClaudeDecideIo): Promise<void>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { mapClaudeHookPayload } from "../adapters/claude/payload.js";
|
|
2
|
+
import { formatClaudeHookOutput } from "../adapters/claude/output.js";
|
|
3
|
+
/** The claude `PermissionRequest` hook entry. stdout is machine-parsed: on ask/
|
|
4
|
+
* yield it MUST be empty so the normal dialog appears (§11). */
|
|
5
|
+
export async function runClaudeDecide(io) {
|
|
6
|
+
try {
|
|
7
|
+
const payload = JSON.parse(await io.stdin.read());
|
|
8
|
+
const req = mapClaudeHookPayload(payload, io.env ?? process.env, io.readIntent);
|
|
9
|
+
const outcome = await io.decide(req);
|
|
10
|
+
const out = formatClaudeHookOutput(outcome);
|
|
11
|
+
if (out)
|
|
12
|
+
io.stdout.write(out + "\n");
|
|
13
|
+
else if (io.blockedSignal) {
|
|
14
|
+
await io.blockedSignal({ agent: "claude", tool: req.toolName, reason: outcome.reason });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
io.log?.(`claude decide failed: ${e.message}`); // print nothing ⇒ dialog appears
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { GateOutcome } from "../core/types.js";
|
|
2
|
+
export interface DecideIo {
|
|
3
|
+
stdin: {
|
|
4
|
+
read(): Promise<string>;
|
|
5
|
+
};
|
|
6
|
+
stdout: {
|
|
7
|
+
write(s: string): void;
|
|
8
|
+
};
|
|
9
|
+
stderr: {
|
|
10
|
+
write(s: string): void;
|
|
11
|
+
};
|
|
12
|
+
decide(req: unknown): Promise<GateOutcome>;
|
|
13
|
+
log?: (m: string) => void;
|
|
14
|
+
}
|
|
15
|
+
/** stdin: GateRequest JSON -> stdout: GateOutcome JSON. Diagnostics go to stderr
|
|
16
|
+
* ONLY — stdout is machine-parsed and a stray byte breaks the parse (§12). */
|
|
17
|
+
export declare function runDecide(io: DecideIo): Promise<void>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** stdin: GateRequest JSON -> stdout: GateOutcome JSON. Diagnostics go to stderr
|
|
2
|
+
* ONLY — stdout is machine-parsed and a stray byte breaks the parse (§12). */
|
|
3
|
+
export async function runDecide(io) {
|
|
4
|
+
let outcome;
|
|
5
|
+
try {
|
|
6
|
+
const raw = await io.stdin.read();
|
|
7
|
+
const req = JSON.parse(raw);
|
|
8
|
+
outcome = await io.decide(req);
|
|
9
|
+
io.log?.(`decision: ${outcome.decision} (tier ${outcome.tier})`);
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
io.log?.(`decide failed: ${e.message}`);
|
|
13
|
+
outcome = { decision: "ask", tier: 2, reason: "malformed input" }; // never fail closed
|
|
14
|
+
}
|
|
15
|
+
io.stdout.write(JSON.stringify(outcome) + "\n");
|
|
16
|
+
}
|