@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,26 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { resolveConfig } from "../config/resolve.js";
|
|
5
|
+
import { resolveApiKey } from "../config/keys.js";
|
|
6
|
+
import { foreignOwnerOf } from "../adapters/claude/settings.js";
|
|
7
|
+
export function doctor() {
|
|
8
|
+
const cfg = resolveConfig();
|
|
9
|
+
const p = join(homedir(), ".claude", "settings.json");
|
|
10
|
+
let owner = null;
|
|
11
|
+
let ours = false;
|
|
12
|
+
try {
|
|
13
|
+
const s = JSON.parse(readFileSync(p, "utf8"));
|
|
14
|
+
owner = foreignOwnerOf(s);
|
|
15
|
+
ours = JSON.stringify(s).includes("auto-gate-managed");
|
|
16
|
+
}
|
|
17
|
+
catch { /* missing/unreadable settings is reported below */ }
|
|
18
|
+
const key = resolveApiKey({ apiKeyEnv: cfg.classifier.jev.apiKeyEnv });
|
|
19
|
+
return { lines: [
|
|
20
|
+
`mode: ${cfg.mode}`,
|
|
21
|
+
`classifier: ${cfg.classifier.kind} (${cfg.classifier.jev.model})`,
|
|
22
|
+
`api key: ${key ? "present" : "MISSING — the gate will ask (fail-open)"}`,
|
|
23
|
+
`claude PermissionRequest: ${owner ? `owned by ${owner} (we defer)` : ours ? "owned by auto-gate" : "not installed"}`,
|
|
24
|
+
`settings file: ${existsSync(p) ? p : "(none)"}`,
|
|
25
|
+
] };
|
|
26
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
const [, , cmd, arg3, arg4] = process.argv;
|
|
4
|
+
if (cmd === "decide" && arg3 === "--agent" && arg4 === "claude") {
|
|
5
|
+
const { runClaudeDecide } = await import("./claude.js");
|
|
6
|
+
const { buildRuntime, readLastHumanMessage } = await import("./runtime.js");
|
|
7
|
+
const { createBlockedSignal } = await import("../adapters/claude/blocked-signal.js");
|
|
8
|
+
const { resolveConfig } = await import("../config/resolve.js");
|
|
9
|
+
const { exec } = await import("node:child_process");
|
|
10
|
+
const { promisify } = await import("node:util");
|
|
11
|
+
const execAsync = promisify(exec);
|
|
12
|
+
await runClaudeDecide({
|
|
13
|
+
stdin: { read: async () => readFileSync(0, "utf8") },
|
|
14
|
+
stdout: process.stdout, stderr: process.stderr,
|
|
15
|
+
decide: (req) => buildRuntime().then((rt) => rt.decide(req)),
|
|
16
|
+
readIntent: readLastHumanMessage,
|
|
17
|
+
blockedSignal: createBlockedSignal({
|
|
18
|
+
command: resolveConfig().ask.onAsk,
|
|
19
|
+
exec: async (command, env) => { await execAsync(command, { env: { ...process.env, ...env } }); },
|
|
20
|
+
log: (m) => process.stderr.write(m + "\n"),
|
|
21
|
+
}),
|
|
22
|
+
log: (m) => process.stderr.write(m + "\n"),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
else if (cmd === "decide") {
|
|
26
|
+
const { runDecide } = await import("./decide.js");
|
|
27
|
+
const { buildRuntime } = await import("./runtime.js");
|
|
28
|
+
await runDecide({
|
|
29
|
+
stdin: { read: async () => readFileSync(0, "utf8") },
|
|
30
|
+
stdout: process.stdout, stderr: process.stderr,
|
|
31
|
+
decide: (req) => buildRuntime().then((rt) => rt.decide(req)),
|
|
32
|
+
log: (m) => process.stderr.write(m + "\n"),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
else if (cmd === "install") {
|
|
36
|
+
const { installClaude } = await import("../adapters/claude/install.js");
|
|
37
|
+
const r = installClaude();
|
|
38
|
+
if (r.installed)
|
|
39
|
+
process.stdout.write("auto-gate: installed claude PermissionRequest hook\n");
|
|
40
|
+
else if (r.deferredTo)
|
|
41
|
+
process.stdout.write(`auto-gate: deferred — ${r.deferredTo} already owns the claude PermissionRequest hook\n`);
|
|
42
|
+
else
|
|
43
|
+
process.stdout.write(`auto-gate: not installed — ${r.reason ?? "unknown reason"}\n`);
|
|
44
|
+
}
|
|
45
|
+
else if (cmd === "uninstall") {
|
|
46
|
+
const { uninstallClaude } = await import("../adapters/claude/install.js");
|
|
47
|
+
const r = uninstallClaude();
|
|
48
|
+
process.stdout.write(r.installed
|
|
49
|
+
? "auto-gate: removed claude PermissionRequest hook\n"
|
|
50
|
+
: `auto-gate: nothing to uninstall — ${r.reason ?? "no settings"}\n`);
|
|
51
|
+
}
|
|
52
|
+
else if (cmd === "doctor") {
|
|
53
|
+
const { doctor } = await import("./doctor.js");
|
|
54
|
+
for (const line of doctor().lines)
|
|
55
|
+
process.stdout.write(line + "\n");
|
|
56
|
+
}
|
|
57
|
+
else if (cmd === "opencode" && arg3 === "run") {
|
|
58
|
+
const { buildRunPlan, supervise } = await import("../adapters/opencode/run.js");
|
|
59
|
+
const plan = await buildRunPlan({ args: process.argv.slice(4) });
|
|
60
|
+
const code = await supervise(plan, { log: (m) => process.stderr.write(m + "\n") });
|
|
61
|
+
process.exit(code);
|
|
62
|
+
}
|
|
63
|
+
else if (cmd === "opencode" && arg3 === "watch") {
|
|
64
|
+
const { resolvePort } = await import("../adapters/opencode/port.js");
|
|
65
|
+
const { acquireLock } = await import("../adapters/opencode/lock.js");
|
|
66
|
+
const { createWatcher, subscribeSse, answerPermission } = await import("../adapters/opencode/watch.js");
|
|
67
|
+
const { buildRuntime } = await import("./runtime.js");
|
|
68
|
+
const at = process.argv.indexOf("--port", 4);
|
|
69
|
+
const flag = at >= 0 ? Number(process.argv[at + 1]) : undefined;
|
|
70
|
+
const resolved = resolvePort({ flag, env: process.env });
|
|
71
|
+
if ("error" in resolved) {
|
|
72
|
+
process.stderr.write(`auto-gate opencode: ${resolved.error}\n`);
|
|
73
|
+
process.exit(4);
|
|
74
|
+
}
|
|
75
|
+
const host = "127.0.0.1";
|
|
76
|
+
const rt = await buildRuntime();
|
|
77
|
+
const watcher = createWatcher({
|
|
78
|
+
host, port: resolved.port,
|
|
79
|
+
acquire: () => acquireLock({ host, port: resolved.port }),
|
|
80
|
+
subscribe: subscribeSse,
|
|
81
|
+
decide: rt.decide,
|
|
82
|
+
answer: (a) => answerPermission({ host, port: resolved.port, ...a }),
|
|
83
|
+
log: (m) => process.stderr.write(m + "\n"),
|
|
84
|
+
});
|
|
85
|
+
const outcome = await watcher.start();
|
|
86
|
+
if (outcome === "yielded")
|
|
87
|
+
process.exit(0);
|
|
88
|
+
if (outcome === "unrecoverable")
|
|
89
|
+
process.exit(1);
|
|
90
|
+
await watcher.stopped;
|
|
91
|
+
}
|
|
92
|
+
else if (cmd === "test") {
|
|
93
|
+
const { runTest } = await import("./test.js");
|
|
94
|
+
process.stdout.write(await runTest(readFileSync(0, "utf8")) + "\n");
|
|
95
|
+
}
|
|
96
|
+
else if (cmd === "stats") {
|
|
97
|
+
const { summarizeAudit } = await import("./stats.js");
|
|
98
|
+
const { resolveConfig } = await import("../config/resolve.js");
|
|
99
|
+
const s = summarizeAudit(resolveConfig().audit.path);
|
|
100
|
+
process.stdout.write(`auto-gate stats — ${s.total} decisions\n`);
|
|
101
|
+
for (const [decision, n] of Object.entries(s.byDecision).sort())
|
|
102
|
+
process.stdout.write(` ${decision}: ${n}\n`);
|
|
103
|
+
process.stdout.write(` cacheHits: ${s.cacheHits}\n`);
|
|
104
|
+
process.stdout.write(` redactionClasses: ${s.redactionClasses.join(", ") || "(none)"}\n`);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
process.stderr.write(`auto-gate: unknown command '${cmd}'\n`);
|
|
108
|
+
process.exit(2);
|
|
109
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { GateCache } from "../core/cache.js";
|
|
2
|
+
import type { GateRequest } from "../core/types.js";
|
|
3
|
+
/** The claude transcript's most recent real human prompt (§11). Best-effort: a
|
|
4
|
+
* missing/unreadable path, malformed lines, or a transcript with no human
|
|
5
|
+
* turns all yield undefined and NEVER throw. Scans JSONL from the last line
|
|
6
|
+
* backwards, skipping assistant turns and tool-result-only user entries. */
|
|
7
|
+
export declare function readLastHumanMessage(transcriptPath: string | undefined): string | undefined;
|
|
8
|
+
/** Assembles config + classifier + cache, and exposes the resolved model id so the
|
|
9
|
+
* decision cache key invalidates when the model changes (§9). */
|
|
10
|
+
export declare function buildRuntime(): Promise<{
|
|
11
|
+
config: import("../index.js").AutoGateConfig;
|
|
12
|
+
classifier: {
|
|
13
|
+
id: "generative" | "jev";
|
|
14
|
+
model: string;
|
|
15
|
+
classify: (r: GateRequest) => Promise<import("../core/types.js").GateAssessment>;
|
|
16
|
+
};
|
|
17
|
+
cache: GateCache;
|
|
18
|
+
decide: (req: GateRequest) => Promise<import("../core/types.js").GateOutcome>;
|
|
19
|
+
}>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { resolveConfig } from "../config/resolve.js";
|
|
2
|
+
import { resolveApiKey } from "../config/keys.js";
|
|
3
|
+
import { createJevClassifier } from "../classifiers/jev.js";
|
|
4
|
+
import { createGenerativeClassifier } from "../classifiers/generative.js";
|
|
5
|
+
import { classify } from "../classifiers/classify.js";
|
|
6
|
+
import { GateCache } from "../core/cache.js";
|
|
7
|
+
import { decideRequest } from "../core/decide.js";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
/** Pull the human-readable text out of a transcript `message.content`: a bare
|
|
11
|
+
* string, or the joined `.text` of blocks whose `type === "text"`. Anything
|
|
12
|
+
* else (notably `tool_result`) contributes nothing, so a tool-result-only turn
|
|
13
|
+
* yields undefined and is treated as not-a-human-message. */
|
|
14
|
+
function textFromContent(content) {
|
|
15
|
+
if (typeof content === "string") {
|
|
16
|
+
const t = content.trim();
|
|
17
|
+
return t || undefined;
|
|
18
|
+
}
|
|
19
|
+
if (Array.isArray(content)) {
|
|
20
|
+
const parts = content
|
|
21
|
+
.filter((b) => !!b && typeof b === "object" && b.type === "text" && typeof b.text === "string")
|
|
22
|
+
.map((b) => b.text);
|
|
23
|
+
const joined = parts.join("\n").trim();
|
|
24
|
+
return joined || undefined;
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
/** The claude transcript's most recent real human prompt (§11). Best-effort: a
|
|
29
|
+
* missing/unreadable path, malformed lines, or a transcript with no human
|
|
30
|
+
* turns all yield undefined and NEVER throw. Scans JSONL from the last line
|
|
31
|
+
* backwards, skipping assistant turns and tool-result-only user entries. */
|
|
32
|
+
export function readLastHumanMessage(transcriptPath) {
|
|
33
|
+
if (!transcriptPath)
|
|
34
|
+
return undefined;
|
|
35
|
+
let raw;
|
|
36
|
+
try {
|
|
37
|
+
raw = readFileSync(transcriptPath, "utf8");
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const lines = raw.split(/\r?\n/);
|
|
43
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
44
|
+
const line = lines[i].trim();
|
|
45
|
+
if (!line)
|
|
46
|
+
continue;
|
|
47
|
+
let entry;
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(line);
|
|
50
|
+
if (!parsed || typeof parsed !== "object")
|
|
51
|
+
continue;
|
|
52
|
+
entry = parsed;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (entry.type !== "user")
|
|
58
|
+
continue;
|
|
59
|
+
const message = entry.message;
|
|
60
|
+
if (message && message.role && message.role !== "user")
|
|
61
|
+
continue;
|
|
62
|
+
const text = textFromContent(message?.content);
|
|
63
|
+
if (text)
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
/** Assembles config + classifier + cache, and exposes the resolved model id so the
|
|
69
|
+
* decision cache key invalidates when the model changes (§9). */
|
|
70
|
+
export async function buildRuntime() {
|
|
71
|
+
const config = resolveConfig();
|
|
72
|
+
const jev = createJevClassifier({
|
|
73
|
+
apiKey: resolveApiKey({ apiKeyEnv: config.classifier.jev.apiKeyEnv }) ?? "",
|
|
74
|
+
model: config.classifier.jev.model, baseUrl: config.classifier.jev.baseUrl,
|
|
75
|
+
timeoutMs: config.classifier.jev.timeoutMs, log: (m) => process.stderr.write(m + "\n"),
|
|
76
|
+
});
|
|
77
|
+
const generative = createGenerativeClassifier({ ...config.classifier.generative });
|
|
78
|
+
const primary = config.classifier.kind === "jev" ? jev : generative;
|
|
79
|
+
const model = config.classifier.kind === "jev" ? config.classifier.jev.model : config.classifier.generative.model;
|
|
80
|
+
const classifier = {
|
|
81
|
+
id: config.classifier.kind, model,
|
|
82
|
+
classify: (r) => classify(r, { kind: config.classifier.kind, primary, fallback: config.classifier.kind === "jev" ? generative : undefined }),
|
|
83
|
+
};
|
|
84
|
+
const cache = new GateCache(`${homedir()}/.auto-gate/gate-cache.json`);
|
|
85
|
+
return { config, classifier, cache, decide: (req) => decideRequest(req, { config, classifier, cache }) };
|
|
86
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
export function summarizeAudit(path) {
|
|
3
|
+
const s = { total: 0, byDecision: {}, cacheHits: 0, redactionClasses: [] };
|
|
4
|
+
let raw = "";
|
|
5
|
+
try {
|
|
6
|
+
raw = readFileSync(path, "utf8");
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return s;
|
|
10
|
+
}
|
|
11
|
+
const redactions = new Set();
|
|
12
|
+
for (const line of raw.split("\n")) {
|
|
13
|
+
if (!line.trim())
|
|
14
|
+
continue;
|
|
15
|
+
try {
|
|
16
|
+
const e = JSON.parse(line);
|
|
17
|
+
s.total++;
|
|
18
|
+
if (e.decision)
|
|
19
|
+
s.byDecision[e.decision] = (s.byDecision[e.decision] ?? 0) + 1;
|
|
20
|
+
if (e.cacheHit)
|
|
21
|
+
s.cacheHits++;
|
|
22
|
+
for (const r of e.redactions ?? [])
|
|
23
|
+
redactions.add(r);
|
|
24
|
+
}
|
|
25
|
+
catch { /* skip a corrupt line rather than failing the whole report */ }
|
|
26
|
+
}
|
|
27
|
+
s.redactionClasses = [...redactions].sort();
|
|
28
|
+
return s;
|
|
29
|
+
}
|
package/dist/cli/test.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { decideRequest } from "../core/decide.js";
|
|
2
|
+
import { buildRuntime } from "./runtime.js";
|
|
3
|
+
/** Dry run: same pipeline, cache bypassed (config.cache=false), human-readable output. */
|
|
4
|
+
export async function runTest(reqJson) {
|
|
5
|
+
const { config, classifier, cache } = await buildRuntime();
|
|
6
|
+
const req = JSON.parse(reqJson);
|
|
7
|
+
const o = await decideRequest(req, { config: { ...config, cache: false }, classifier, cache });
|
|
8
|
+
return `${o.decision} (tier ${o.tier}) ${o.reason}`;
|
|
9
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface KeyInput {
|
|
2
|
+
env?: Record<string, string | undefined>;
|
|
3
|
+
home?: string;
|
|
4
|
+
apiKeyEnv?: string;
|
|
5
|
+
keyFile?: string;
|
|
6
|
+
}
|
|
7
|
+
/** env → key file (§12). Never logged. Absent ⇒ undefined (caller asks). */
|
|
8
|
+
export declare function resolveApiKey(i?: KeyInput): string | undefined;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
/** env → key file (§12). Never logged. Absent ⇒ undefined (caller asks). */
|
|
4
|
+
export function resolveApiKey(i = {}) {
|
|
5
|
+
const env = i.env ?? process.env;
|
|
6
|
+
const fromEnv = env[i.apiKeyEnv ?? "TYPESAFE_API_KEY"];
|
|
7
|
+
if (fromEnv)
|
|
8
|
+
return fromEnv;
|
|
9
|
+
const file = i.keyFile ?? `${i.home ?? homedir()}/.auto-gate-key`;
|
|
10
|
+
try {
|
|
11
|
+
const v = readFileSync(file, "utf8").trim();
|
|
12
|
+
return v || undefined;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type AutoGateConfig } from "./schema.js";
|
|
2
|
+
export interface ResolveInput {
|
|
3
|
+
home?: string;
|
|
4
|
+
cwd?: string;
|
|
5
|
+
env?: Record<string, string | undefined>;
|
|
6
|
+
explicitPath?: string;
|
|
7
|
+
}
|
|
8
|
+
export type DeepPartial<T> = {
|
|
9
|
+
[K in keyof T]?: T[K] extends readonly (infer U)[] ? U[] : T[K] extends object ? DeepPartial<T[K]> : T[K];
|
|
10
|
+
};
|
|
11
|
+
/** Shallow-merge per section, with policy resolved against its named preset so
|
|
12
|
+
* a preset change plus a per-key override compose predictably (§12). Exported
|
|
13
|
+
* so the §3 host API (integration/host.ts) can compose squadrant's projected
|
|
14
|
+
* config with the same tested precedence (D-A2). */
|
|
15
|
+
export declare function mergeConfig(base: AutoGateConfig, over: DeepPartial<AutoGateConfig>): AutoGateConfig;
|
|
16
|
+
/** Precedence (low → high): defaults ← user ← repo ← explicit/--config/env. */
|
|
17
|
+
export declare function resolveConfig(input?: ResolveInput): AutoGateConfig;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { DEFAULT_CONFIG } from "./schema.js";
|
|
5
|
+
import { PRESETS } from "../core/presets.js";
|
|
6
|
+
/** Shallow-merge per section, with policy resolved against its named preset so
|
|
7
|
+
* a preset change plus a per-key override compose predictably (§12). Exported
|
|
8
|
+
* so the §3 host API (integration/host.ts) can compose squadrant's projected
|
|
9
|
+
* config with the same tested precedence (D-A2). */
|
|
10
|
+
export function mergeConfig(base, over) {
|
|
11
|
+
const policyName = (over.policy?.name ?? base.policy.name);
|
|
12
|
+
const preset = PRESETS[policyName] ?? PRESETS.strict;
|
|
13
|
+
return {
|
|
14
|
+
...base,
|
|
15
|
+
...over,
|
|
16
|
+
classifier: { ...base.classifier, ...over.classifier,
|
|
17
|
+
jev: { ...base.classifier.jev, ...over.classifier?.jev },
|
|
18
|
+
generative: { ...base.classifier.generative, ...over.classifier?.generative } },
|
|
19
|
+
// Re-resolve the preset ONLY when this layer touches policy; otherwise keep the
|
|
20
|
+
// already-composed base (re-expanding on every layer would reset per-key overrides).
|
|
21
|
+
policy: (over.policy
|
|
22
|
+
? { ...preset, ...over.policy, name: policyName, hazards: { ...preset.hazards, ...over.policy.hazards } }
|
|
23
|
+
: base.policy),
|
|
24
|
+
agents: { ...base.agents, ...over.agents },
|
|
25
|
+
ask: { ...base.ask, ...over.ask },
|
|
26
|
+
tools: over.tools ?? base.tools,
|
|
27
|
+
toolAliases: (over.toolAliases ?? base.toolAliases),
|
|
28
|
+
audit: { ...base.audit, ...over.audit },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function readJsonLayer(path) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
34
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return {}; // missing or malformed ⇒ this layer contributes nothing (never throws)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Precedence (low → high): defaults ← user ← repo ← explicit/--config/env. */
|
|
41
|
+
export function resolveConfig(input = {}) {
|
|
42
|
+
const home = input.home ?? homedir();
|
|
43
|
+
const cwd = input.cwd ?? process.cwd();
|
|
44
|
+
const env = input.env ?? process.env;
|
|
45
|
+
const explicit = input.explicitPath ?? env.AUTO_GATE_CONFIG;
|
|
46
|
+
let cfg = mergeConfig({ ...DEFAULT_CONFIG, policy: PRESETS.strict }, {});
|
|
47
|
+
cfg = mergeConfig(cfg, readJsonLayer(join(home, ".config", "auto-gate", "config.json")));
|
|
48
|
+
cfg = mergeConfig(cfg, readJsonLayer(join(cwd, ".auto-gate.json")));
|
|
49
|
+
if (explicit)
|
|
50
|
+
cfg = mergeConfig(cfg, readJsonLayer(explicit));
|
|
51
|
+
return cfg;
|
|
52
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PolicyConfig, ToolAliases } from "../core/types.js";
|
|
2
|
+
export interface AutoGateConfig {
|
|
3
|
+
mode: "on" | "off" | "auto";
|
|
4
|
+
agents: Record<string, {
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
}>;
|
|
7
|
+
classifier: {
|
|
8
|
+
kind: "jev" | "generative";
|
|
9
|
+
jev: {
|
|
10
|
+
model: string;
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
apiKeyEnv: string;
|
|
13
|
+
timeoutMs: number;
|
|
14
|
+
};
|
|
15
|
+
generative: {
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
model: string;
|
|
18
|
+
apiKeyEnv?: string;
|
|
19
|
+
timeoutMs: number;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
policy: PolicyConfig;
|
|
23
|
+
tools: string[];
|
|
24
|
+
toolAliasesVersion: number;
|
|
25
|
+
toolAliases: ToolAliases;
|
|
26
|
+
deny?: string[];
|
|
27
|
+
ask: {
|
|
28
|
+
onAsk: string | null;
|
|
29
|
+
timeoutMs: number;
|
|
30
|
+
};
|
|
31
|
+
cache: boolean;
|
|
32
|
+
audit: {
|
|
33
|
+
path: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Standalone default is ON (a user who installs a gate wants it on). This
|
|
37
|
+
* differs from squadrant's `defaults.gate.mode` default of "auto" (§12). */
|
|
38
|
+
export declare const DEFAULT_CONFIG: AutoGateConfig;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Standalone default is ON (a user who installs a gate wants it on). This
|
|
2
|
+
* differs from squadrant's `defaults.gate.mode` default of "auto" (§12). */
|
|
3
|
+
export const DEFAULT_CONFIG = {
|
|
4
|
+
mode: "on",
|
|
5
|
+
agents: { claude: { enabled: true }, opencode: { enabled: true } },
|
|
6
|
+
classifier: {
|
|
7
|
+
kind: "jev",
|
|
8
|
+
jev: { model: "jev-latest", baseUrl: "https://api.typesafe.ai", apiKeyEnv: "TYPESAFE_API_KEY", timeoutMs: 4000 },
|
|
9
|
+
generative: { baseUrl: "", model: "", timeoutMs: 3000 },
|
|
10
|
+
},
|
|
11
|
+
// policy defaults come from presets.ts so there is exactly one source of truth
|
|
12
|
+
policy: {}, // replaced in resolve.ts with PRESETS.strict
|
|
13
|
+
tools: ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit"],
|
|
14
|
+
toolAliasesVersion: 1,
|
|
15
|
+
toolAliases: { opencode: { bash: ["Bash"], edit: ["Edit", "Write", "MultiEdit"] } },
|
|
16
|
+
ask: { onAsk: null, timeoutMs: 120000 },
|
|
17
|
+
cache: true,
|
|
18
|
+
audit: { path: "~/.auto-gate/decisions.jsonl" },
|
|
19
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface AuditEntry {
|
|
2
|
+
ts: string;
|
|
3
|
+
agent: string;
|
|
4
|
+
tool: string;
|
|
5
|
+
cwd: string;
|
|
6
|
+
decision: string;
|
|
7
|
+
verdict: string;
|
|
8
|
+
classifier: string;
|
|
9
|
+
tier: 1 | 2;
|
|
10
|
+
cacheHit: boolean;
|
|
11
|
+
latencyMs: number;
|
|
12
|
+
probabilities?: Record<string, number>;
|
|
13
|
+
hazards?: Record<string, number>;
|
|
14
|
+
severity?: number;
|
|
15
|
+
confidence?: number;
|
|
16
|
+
inputTokens?: number;
|
|
17
|
+
redactions: string[];
|
|
18
|
+
}
|
|
19
|
+
/** Append-only JSONL, 0600. NEVER logs raw payloads/secrets — only the redaction
|
|
20
|
+
* ids that fired (§9/§12). Best-effort: an audit write never breaks a decision. */
|
|
21
|
+
export declare function appendAudit(path: string, entry: AuditEntry): void;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
/** Append-only JSONL, 0600. NEVER logs raw payloads/secrets — only the redaction
|
|
4
|
+
* ids that fired (§9/§12). Best-effort: an audit write never breaks a decision. */
|
|
5
|
+
export function appendAudit(path, entry) {
|
|
6
|
+
try {
|
|
7
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
8
|
+
appendFileSync(path, JSON.stringify(entry) + "\n", { mode: 0o600 });
|
|
9
|
+
chmodSync(path, 0o600);
|
|
10
|
+
}
|
|
11
|
+
catch { /* best-effort */ }
|
|
12
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { GateOutcome } from "./types.js";
|
|
2
|
+
export interface CacheKeyParts {
|
|
3
|
+
agent: string;
|
|
4
|
+
toolName: string;
|
|
5
|
+
cwd: string;
|
|
6
|
+
redactedPayload: string;
|
|
7
|
+
redactedUserIntent: string | null;
|
|
8
|
+
policyVersion: string;
|
|
9
|
+
toolAliasesVersion: number;
|
|
10
|
+
classifierId: string;
|
|
11
|
+
model: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function cacheKey(k: CacheKeyParts): string;
|
|
14
|
+
export declare class GateCache {
|
|
15
|
+
private file;
|
|
16
|
+
private entries;
|
|
17
|
+
constructor(file: string);
|
|
18
|
+
get(key: string): GateOutcome | undefined;
|
|
19
|
+
set(key: string, outcome: GateOutcome): void;
|
|
20
|
+
private load;
|
|
21
|
+
private save;
|
|
22
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, writeFileSync, renameSync, chmodSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
export function cacheKey(k) {
|
|
5
|
+
const intentHash = createHash("sha256").update(k.redactedUserIntent ?? "").digest("hex");
|
|
6
|
+
return createHash("sha256")
|
|
7
|
+
.update([k.agent, k.toolName, k.cwd, k.redactedPayload, intentHash, k.policyVersion,
|
|
8
|
+
String(k.toolAliasesVersion), k.classifierId, k.model].join("\n"))
|
|
9
|
+
.digest("hex");
|
|
10
|
+
}
|
|
11
|
+
const TTL_MS = 10 * 60_000;
|
|
12
|
+
const MAX = 500;
|
|
13
|
+
export class GateCache {
|
|
14
|
+
file;
|
|
15
|
+
entries = new Map();
|
|
16
|
+
constructor(file) {
|
|
17
|
+
this.file = file;
|
|
18
|
+
this.load();
|
|
19
|
+
}
|
|
20
|
+
get(key) {
|
|
21
|
+
const e = this.entries.get(key);
|
|
22
|
+
if (!e)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (Date.now() - e.at > TTL_MS) {
|
|
25
|
+
this.entries.delete(key);
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
return { ...e.outcome, cached: true };
|
|
29
|
+
}
|
|
30
|
+
set(key, outcome) {
|
|
31
|
+
if (outcome.decision !== "allow" && outcome.decision !== "deny")
|
|
32
|
+
return; // never cache ask
|
|
33
|
+
this.entries.set(key, { at: Date.now(), outcome: { ...outcome, cached: undefined } });
|
|
34
|
+
if (this.entries.size > MAX) {
|
|
35
|
+
const oldest = [...this.entries.entries()].sort((a, b) => a[1].at - b[1].at)[0];
|
|
36
|
+
if (oldest)
|
|
37
|
+
this.entries.delete(oldest[0]);
|
|
38
|
+
}
|
|
39
|
+
this.save();
|
|
40
|
+
}
|
|
41
|
+
load() {
|
|
42
|
+
try {
|
|
43
|
+
const raw = JSON.parse(readFileSync(this.file, "utf8"));
|
|
44
|
+
for (const [k, v] of Object.entries(raw))
|
|
45
|
+
this.entries.set(k, v);
|
|
46
|
+
}
|
|
47
|
+
catch { /* missing/corrupt cache is not an error */ }
|
|
48
|
+
}
|
|
49
|
+
save() {
|
|
50
|
+
try {
|
|
51
|
+
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
|
|
52
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
53
|
+
writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.entries)), { mode: 0o600 });
|
|
54
|
+
renameSync(tmp, this.file);
|
|
55
|
+
chmodSync(this.file, 0o600);
|
|
56
|
+
}
|
|
57
|
+
catch { /* cache write failure must never break a decision */ }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { GateAssessment, GateOutcome, GateRequest } from "./types.js";
|
|
2
|
+
import { GateCache } from "./cache.js";
|
|
3
|
+
export interface DecideContext {
|
|
4
|
+
config: {
|
|
5
|
+
mode: "on" | "off" | "auto";
|
|
6
|
+
policy: import("./types.js").PolicyConfig;
|
|
7
|
+
tools: string[];
|
|
8
|
+
toolAliasesVersion: number;
|
|
9
|
+
toolAliases: import("./types.js").ToolAliases;
|
|
10
|
+
deny?: string[];
|
|
11
|
+
cache: boolean;
|
|
12
|
+
audit: {
|
|
13
|
+
path: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
classifier: {
|
|
17
|
+
id: string;
|
|
18
|
+
model?: string;
|
|
19
|
+
classify(req: GateRequest): Promise<GateAssessment>;
|
|
20
|
+
};
|
|
21
|
+
cache: GateCache;
|
|
22
|
+
env?: Record<string, string | undefined>;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
}
|
|
25
|
+
/** The one pipeline (§4): scope → redact → cache → Tier-1 → classify → policy → audit. */
|
|
26
|
+
export declare function decideRequest(req: GateRequest, ctx: DecideContext): Promise<GateOutcome>;
|