@egoistmachines/opencode-switchboard 0.1.1
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 +125 -0
- package/config.schema.json +81 -0
- package/package.json +38 -0
- package/src/cli.js +41 -0
- package/src/config.js +82 -0
- package/src/context.js +203 -0
- package/src/host.js +11 -0
- package/src/hostedTransport.js +271 -0
- package/src/index.js +12 -0
- package/src/localTransport.js +213 -0
- package/src/outcomes.js +104 -0
- package/src/plugin.js +223 -0
- package/src/shared/atomicFile.js +32 -0
- package/src/shared/cache.js +34 -0
- package/src/shared/cli.js +103 -0
- package/src/shared/client.js +195 -0
- package/src/shared/config.js +105 -0
- package/src/shared/credentials.js +326 -0
- package/src/shared/format.js +88 -0
- package/src/shared/policy.js +599 -0
- package/src/shared/transport.js +256 -0
- package/src/status.js +186 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
import { writeFileAtomically } from "./atomicFile.js";
|
|
5
|
+
import { clampSessionKey } from "./client.js";
|
|
6
|
+
import { createPlaneTransport } from "./transport.js";
|
|
7
|
+
|
|
8
|
+
// The host-neutral tool-policy reporter (issue #425 phase 4, audit-only).
|
|
9
|
+
//
|
|
10
|
+
// A `before_tool_call` hook posts every tool call's NAME and an argument
|
|
11
|
+
// DIGEST to POST /agent/policy/check, which is what puts the owner's activity
|
|
12
|
+
// feed in front of calls MCP alone could never see (exec, browse, message
|
|
13
|
+
// sends). Phase 4 is audit-only end to end: the backend answers allow for
|
|
14
|
+
// everything, and the hook below never blocks and never even awaits the
|
|
15
|
+
// answer, so the report costs the tool call nothing.
|
|
16
|
+
//
|
|
17
|
+
// Contract, same as src/client.js: the reporter NEVER throws, never loops,
|
|
18
|
+
// and keeps itself inside the backend's per-route throttle with a dedupe
|
|
19
|
+
// cache plus the shared transport's backoff and budget (src/transport.js
|
|
20
|
+
// owns all of the request mechanics for both surfaces).
|
|
21
|
+
//
|
|
22
|
+
// Content boundary: the tool's arguments never leave the machine. What is
|
|
23
|
+
// sent is sha256 over a canonical JSON form, which the backend shape-checks
|
|
24
|
+
// (h1_ + 64 hex) exactly so argument TEXT cannot end up in the owner's feed.
|
|
25
|
+
|
|
26
|
+
// Mirrors the backend's TOOL_SHAPE (lib/agentPolicy.js). Checked CLIENT-side
|
|
27
|
+
// because the backend answers 400 for a name outside it, and a 400 latches
|
|
28
|
+
// the version-skew backoff: one oddly named tool must cost its own report,
|
|
29
|
+
// not five minutes of everyone else's.
|
|
30
|
+
const TOOL_SHAPE = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/;
|
|
31
|
+
|
|
32
|
+
// Fallback when a response carries no usable cache_ttl; the backend's
|
|
33
|
+
// documented value is 60s.
|
|
34
|
+
const DEFAULT_CACHE_TTL_S = 60;
|
|
35
|
+
const MAX_CACHE_TTL_S = 600;
|
|
36
|
+
// Identical (tool, digest) answers within the TTL are served locally, so the
|
|
37
|
+
// cache is also the dedupe that keeps a tool-calling loop from hammering the
|
|
38
|
+
// plane. Bounded: a runaway generator of novel arguments must not grow it
|
|
39
|
+
// without limit.
|
|
40
|
+
const MAX_CACHED_ANSWERS = 512;
|
|
41
|
+
|
|
42
|
+
// Client-side share of the backend's check budget (600/min per client). All
|
|
43
|
+
// concurrent sessions of one install share the client_id, so an uncapped
|
|
44
|
+
// process could trip the backend throttle and starve the snapshot and
|
|
45
|
+
// decisions legs that share the plane.
|
|
46
|
+
const REQUEST_BUDGET_MAX = 300;
|
|
47
|
+
|
|
48
|
+
// How often the cached snapshot is refreshed while calls keep coming, and how
|
|
49
|
+
// often a pending approval is re-polled. Both sit far inside the backend's
|
|
50
|
+
// per-route budgets (snapshot 120/min, decisions 300/min).
|
|
51
|
+
const SNAPSHOT_TTL_MS = 60_000;
|
|
52
|
+
const APPROVAL_POLL_INTERVAL_MS = 3000;
|
|
53
|
+
|
|
54
|
+
// The matcher twin for LOCAL evaluation during an outage (enforce mode only).
|
|
55
|
+
// Same tiny grammar and the same total order as lib/agentPolicy.js on the
|
|
56
|
+
// backend: exact beats prefix, longer prefix beats shorter, bare `*` is the
|
|
57
|
+
// floor, default allow. SNAPSHOT_VERSION 1 is the contract that keeps the two
|
|
58
|
+
// in step; a snapshot with a higher version is refused rather than
|
|
59
|
+
// half-understood.
|
|
60
|
+
const SNAPSHOT_VERSION = 1;
|
|
61
|
+
const PATTERN_SHAPE = /^([A-Za-z0-9_][A-Za-z0-9_.:-]*\*?|\*)$/;
|
|
62
|
+
const POLICY_ACTIONS = ["allow", "deny", "require_approval"];
|
|
63
|
+
|
|
64
|
+
export function resolveLocalPolicy(tool, rules) {
|
|
65
|
+
const fallback = { action: "allow", matchedPattern: null };
|
|
66
|
+
if (typeof tool !== "string" || !TOOL_SHAPE.test(tool) || !Array.isArray(rules)) return fallback;
|
|
67
|
+
let best = fallback;
|
|
68
|
+
let bestScore = -1;
|
|
69
|
+
for (const rule of rules) {
|
|
70
|
+
const pattern = rule?.tool_pattern;
|
|
71
|
+
const action = rule?.action;
|
|
72
|
+
if (typeof pattern !== "string" || !PATTERN_SHAPE.test(pattern) || !POLICY_ACTIONS.includes(action)) continue;
|
|
73
|
+
const matches = pattern === "*" || (pattern.endsWith("*") ? tool.startsWith(pattern.slice(0, -1)) : pattern === tool);
|
|
74
|
+
if (!matches) continue;
|
|
75
|
+
const score = pattern === "*" ? 0 : pattern.endsWith("*") ? pattern.length : 1000 + pattern.length;
|
|
76
|
+
if (score > bestScore) {
|
|
77
|
+
bestScore = score;
|
|
78
|
+
best = { action, matchedPattern: pattern };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return best;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function canonicalJson(value) {
|
|
85
|
+
if (typeof value === "number") {
|
|
86
|
+
// Refuse non-finite numbers instead of letting JSON.stringify quietly
|
|
87
|
+
// spell them "null": the Python twin (hermes-passport policy_hook.py)
|
|
88
|
+
// digests with allow_nan=False and answers "no digest", and the two
|
|
89
|
+
// clients must agree on which inputs are canonicalizable at all.
|
|
90
|
+
if (!Number.isFinite(value)) throw new RangeError("non-finite number");
|
|
91
|
+
return JSON.stringify(value);
|
|
92
|
+
}
|
|
93
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
94
|
+
return JSON.stringify(value) ?? "null";
|
|
95
|
+
}
|
|
96
|
+
if (Array.isArray(value)) {
|
|
97
|
+
return `[${value.map((entry) => canonicalJson(entry === undefined ? null : entry)).join(",")}]`;
|
|
98
|
+
}
|
|
99
|
+
if (typeof value === "object") {
|
|
100
|
+
const keys = Object.keys(value)
|
|
101
|
+
.filter((key) => value[key] !== undefined && typeof value[key] !== "function")
|
|
102
|
+
.sort();
|
|
103
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
104
|
+
}
|
|
105
|
+
// undefined, functions, symbols: nothing canonical to say.
|
|
106
|
+
return "null";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `h1_` + sha256 over a key-sorted JSON form of the params, or null when the
|
|
111
|
+
* params cannot be canonicalized (circular structures, exotic values). Null
|
|
112
|
+
* is a valid check-route value meaning "no digest", so failure here degrades
|
|
113
|
+
* to a slightly coarser audit row rather than a dropped one.
|
|
114
|
+
*/
|
|
115
|
+
export function argsDigest(params) {
|
|
116
|
+
if (params === undefined || params === null) return null;
|
|
117
|
+
try {
|
|
118
|
+
return `h1_${createHash("sha256").update(canonicalJson(params), "utf8").digest("hex")}`;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createPolicyReporter({
|
|
125
|
+
config,
|
|
126
|
+
credentials,
|
|
127
|
+
fetchImpl = globalThis.fetch,
|
|
128
|
+
logger = null,
|
|
129
|
+
now = () => Date.now(),
|
|
130
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
131
|
+
// Where the last real snapshot persists across processes (the plugin wires
|
|
132
|
+
// config.policySnapshotPath). Null disables persistence, which is what the
|
|
133
|
+
// suite wants unless a test is about persistence itself.
|
|
134
|
+
snapshotPath = null,
|
|
135
|
+
}) {
|
|
136
|
+
const transport = createPlaneTransport({
|
|
137
|
+
config,
|
|
138
|
+
credentials,
|
|
139
|
+
fetchImpl,
|
|
140
|
+
logger,
|
|
141
|
+
now,
|
|
142
|
+
label: "policy check",
|
|
143
|
+
budgetMax: REQUEST_BUDGET_MAX,
|
|
144
|
+
});
|
|
145
|
+
const answers = new Map(); // key -> {value, expiresAt}
|
|
146
|
+
const pendingByKey = new Map();
|
|
147
|
+
|
|
148
|
+
// The last snapshot the plane served: {mode, rules, fetchedAt}. This is the
|
|
149
|
+
// outage posture in enforce mode (evaluate the last-known rules locally)
|
|
150
|
+
// and the mode detector that decides whether a tool call awaits its
|
|
151
|
+
// verdict. PERSISTED beside the credentials file (same trust boundary):
|
|
152
|
+
// rules are the owner's standing policy, and a gateway restart during a
|
|
153
|
+
// Passport outage must not silently drop their Block rules to the audit
|
|
154
|
+
// posture until the plane answers again.
|
|
155
|
+
const loadPersistedSnapshot = () => {
|
|
156
|
+
if (!snapshotPath) return null;
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(readFileSync(snapshotPath, "utf8"));
|
|
159
|
+
if (
|
|
160
|
+
parsed &&
|
|
161
|
+
parsed.version === SNAPSHOT_VERSION &&
|
|
162
|
+
(parsed.mode === "enforce" || parsed.mode === "audit") &&
|
|
163
|
+
Array.isArray(parsed.rules) &&
|
|
164
|
+
typeof parsed.fetchedAt === "number" &&
|
|
165
|
+
Number.isFinite(parsed.fetchedAt) &&
|
|
166
|
+
parsed.fetchedAt > 0
|
|
167
|
+
) {
|
|
168
|
+
return { mode: parsed.mode, rules: parsed.rules, fetchedAt: parsed.fetchedAt };
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// Missing or unreadable is the same as never fetched; a malformed file
|
|
172
|
+
// (torn write, foreign version) is refused whole like a bad snapshot.
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
};
|
|
176
|
+
let lastSnapshot = loadPersistedSnapshot();
|
|
177
|
+
let snapshotPending = null;
|
|
178
|
+
let persistWarned = false;
|
|
179
|
+
// Fire-and-forget: persistence is what makes the outage posture survive a
|
|
180
|
+
// restart, but it must never cost a tool call or a report. Mode-learned
|
|
181
|
+
// stubs (fetchedAt 0) are not worth a write; they carry no rules. Writes
|
|
182
|
+
// are chained so two never interleave on the same file (a torn write would
|
|
183
|
+
// be refused whole at the next load, but a clean file is strictly better),
|
|
184
|
+
// and the chain doubles as the awaitable the test seam below exposes.
|
|
185
|
+
let persistPending = Promise.resolve();
|
|
186
|
+
const persistSnapshot = () => {
|
|
187
|
+
if (!snapshotPath || !lastSnapshot || lastSnapshot.fetchedAt === 0) return;
|
|
188
|
+
// Captured at queue time: the chained job below must persist THIS
|
|
189
|
+
// snapshot even if a later answer mutates lastSnapshot first.
|
|
190
|
+
const snapshot = { version: SNAPSHOT_VERSION, ...lastSnapshot };
|
|
191
|
+
persistPending = persistPending
|
|
192
|
+
.then(async () => {
|
|
193
|
+
// Written UNCONDITIONALLY, one tiny file per snapshot refresh: the
|
|
194
|
+
// path is shared (the gateway plus every CLI invocation), so any
|
|
195
|
+
// skip heuristic (a process-local latch, a read-and-compare against
|
|
196
|
+
// the file) leaves some external rewrite unhealed; a content compare
|
|
197
|
+
// in particular would never re-assert mode 600 on a matching file an
|
|
198
|
+
// external writer left world-readable. Always writing heals content
|
|
199
|
+
// AND permissions, retries a failed persist at the next refresh for
|
|
200
|
+
// free, and keeps loadPersistedSnapshot the only reader.
|
|
201
|
+
//
|
|
202
|
+
// The shared crash-safe write (src/atomicFile.js): a SIGTERM
|
|
203
|
+
// mid-write (a restart is the exact event this file exists for) must
|
|
204
|
+
// not tear the file into a silent audit-posture start, the mkdtemp
|
|
205
|
+
// stage keeps concurrent writers off each other's temp, mode 600 is
|
|
206
|
+
// the same trust boundary as the credentials file, and the directory
|
|
207
|
+
// is created on demand so an owner-configured policySnapshotPath
|
|
208
|
+
// works without a manual mkdir.
|
|
209
|
+
await writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot)}\n`);
|
|
210
|
+
persistWarned = false;
|
|
211
|
+
})
|
|
212
|
+
.catch((err) => {
|
|
213
|
+
if (persistWarned) return;
|
|
214
|
+
persistWarned = true;
|
|
215
|
+
logger?.warn?.(
|
|
216
|
+
`ai-passport: could not persist the policy snapshot (${err?.code ?? err?.name ?? "error"}); the enforce outage posture will not survive a restart`
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
const refreshSnapshot = () => {
|
|
221
|
+
if (snapshotPending) return snapshotPending;
|
|
222
|
+
snapshotPending = (async () => {
|
|
223
|
+
const payload = await transport.request({
|
|
224
|
+
path: "/agent/policy/snapshot",
|
|
225
|
+
method: "GET",
|
|
226
|
+
timeoutMs: config.policy.timeoutMs,
|
|
227
|
+
});
|
|
228
|
+
// A version this plugin does not understand, or a body that does not
|
|
229
|
+
// even carry one (a middlebox 200, an error-shaped reply), is refused
|
|
230
|
+
// whole: acting on half-understood rules is worse than the documented
|
|
231
|
+
// unknown posture, and latching a versionless body as authoritative
|
|
232
|
+
// once silently dropped an enforce install to the audit posture.
|
|
233
|
+
if (payload && typeof payload.version === "number" && payload.version <= SNAPSHOT_VERSION) {
|
|
234
|
+
lastSnapshot = {
|
|
235
|
+
mode: payload.mode === "enforce" ? "enforce" : "audit",
|
|
236
|
+
rules: Array.isArray(payload.rules) ? payload.rules : [],
|
|
237
|
+
fetchedAt: now(),
|
|
238
|
+
};
|
|
239
|
+
persistSnapshot();
|
|
240
|
+
}
|
|
241
|
+
return lastSnapshot;
|
|
242
|
+
})().finally(() => {
|
|
243
|
+
snapshotPending = null;
|
|
244
|
+
});
|
|
245
|
+
return snapshotPending;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const cacheGet = (key) => {
|
|
249
|
+
const entry = answers.get(key);
|
|
250
|
+
if (!entry) return null;
|
|
251
|
+
if (entry.expiresAt <= now()) {
|
|
252
|
+
answers.delete(key);
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return entry.value;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const cacheSet = (key, value, ttlSeconds) => {
|
|
259
|
+
const ttl = typeof ttlSeconds === "number" && Number.isFinite(ttlSeconds) && ttlSeconds > 0
|
|
260
|
+
? Math.min(MAX_CACHE_TTL_S, ttlSeconds)
|
|
261
|
+
: DEFAULT_CACHE_TTL_S;
|
|
262
|
+
answers.delete(key);
|
|
263
|
+
answers.set(key, { value, expiresAt: now() + ttl * 1000 });
|
|
264
|
+
while (answers.size > MAX_CACHED_ANSWERS) {
|
|
265
|
+
const oldest = answers.keys().next();
|
|
266
|
+
if (oldest.done) break;
|
|
267
|
+
answers.delete(oldest.value);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const normalizeAnswer = (payload) => ({
|
|
272
|
+
decision: typeof payload.decision === "string" ? payload.decision : "allow",
|
|
273
|
+
effective: typeof payload.effective === "string" ? payload.effective : "allow",
|
|
274
|
+
// Explicit or nothing: a body that does not NAME its mode teaches
|
|
275
|
+
// nothing. Coercing an absent mode to "audit" once meant a single
|
|
276
|
+
// error-shaped or field-renamed JSON 200 could durably downgrade an
|
|
277
|
+
// enforce install (learnMode persists flips); the Hermes twin refuses
|
|
278
|
+
// the same input.
|
|
279
|
+
mode: payload.mode === "enforce" || payload.mode === "audit" ? payload.mode : null,
|
|
280
|
+
matchedPattern: typeof payload.matched_pattern === "string" ? payload.matched_pattern : null,
|
|
281
|
+
eventId: typeof payload.event_id === "string" ? payload.event_id : null,
|
|
282
|
+
pollUrl: typeof payload.poll_url === "string" ? payload.poll_url : null,
|
|
283
|
+
approvalUrl: typeof payload.approval_url === "string" ? payload.approval_url : null,
|
|
284
|
+
degraded: payload.degraded === true,
|
|
285
|
+
degradedReason: typeof payload.degraded_reason === "string" ? payload.degraded_reason : null,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// The rules-unreachable degraded flavor: the plane had NO OPINION (nothing
|
|
289
|
+
// was readable), as opposed to a real verdict whose event RECORDING failed.
|
|
290
|
+
// Named on the wire by degraded_reason (phase 6). Only the KNOWN verdict
|
|
291
|
+
// reason is trusted as a verdict; everything else, including reason strings
|
|
292
|
+
// this plugin has never heard of, falls back to the mode heuristic (the
|
|
293
|
+
// no-opinion body hardcodes mode audit). Plugins live on owner machines for
|
|
294
|
+
// years while the backend deploys continuously, so a future no-opinion
|
|
295
|
+
// flavor must not be mistaken for a verdict that teaches and persists its
|
|
296
|
+
// hardcoded audit mode.
|
|
297
|
+
const isNoOpinion = (answer) => {
|
|
298
|
+
if (!answer.degraded) return false;
|
|
299
|
+
if (answer.degradedReason === "rules_unavailable") return true;
|
|
300
|
+
if (answer.degradedReason === "event_write_failed") return false;
|
|
301
|
+
return answer.mode !== "enforce";
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// Every check answer names the live mode, so a mode flip reaches the next
|
|
305
|
+
// tool call without waiting for the snapshot poll. When no snapshot exists
|
|
306
|
+
// yet, the learned mode arrives with an empty rule set and a stale stamp,
|
|
307
|
+
// which forces a real snapshot fetch before any local evaluation.
|
|
308
|
+
const learnMode = (mode) => {
|
|
309
|
+
if (lastSnapshot) {
|
|
310
|
+
if (lastSnapshot.mode !== mode) {
|
|
311
|
+
lastSnapshot.mode = mode;
|
|
312
|
+
// A real snapshot whose mode just flipped is worth re-persisting, or
|
|
313
|
+
// a restart would resurrect the old mode until the plane answers.
|
|
314
|
+
persistSnapshot();
|
|
315
|
+
}
|
|
316
|
+
} else lastSnapshot = { mode, rules: [], fetchedAt: 0 };
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const reporter = {
|
|
320
|
+
/**
|
|
321
|
+
* Report one tool call. Resolves to the (normalized) check answer, or
|
|
322
|
+
* null when nothing was knowable (disabled, invalid name, backoff,
|
|
323
|
+
* budget, outage). Phase 4 callers ignore the value; it exists so the
|
|
324
|
+
* phase 5 enforce path is a caller change, not a client change.
|
|
325
|
+
*/
|
|
326
|
+
async report({ toolName, params = undefined, sessionKey = null, coalesce = true }) {
|
|
327
|
+
if (!config.policy.enabled) return null;
|
|
328
|
+
if (typeof toolName !== "string" || !TOOL_SHAPE.test(toolName)) {
|
|
329
|
+
// Not an error: the host owns its tool names and this plane's grammar
|
|
330
|
+
// is deliberately narrower. Skipping here keeps one exotic name from
|
|
331
|
+
// latching the invalid_request backoff against every other tool.
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
const digest = argsDigest(params);
|
|
335
|
+
// \u0000 as the separator: it cannot appear in a TOOL_SHAPE name or a
|
|
336
|
+
// hex digest, so the key cannot collide. Escaped, never a raw byte: a
|
|
337
|
+
// literal NUL in the source made file(1) and grep classify this module
|
|
338
|
+
// as binary data and silently no-match it.
|
|
339
|
+
const key = `${toolName}\u0000${digest ?? ""}`;
|
|
340
|
+
const cached = cacheGet(key);
|
|
341
|
+
if (cached) return cached;
|
|
342
|
+
// In-flight coalescing is for the audit path only. Enforce-mode calls
|
|
343
|
+
// pass coalesce=false: N concurrent identical calls sharing one check
|
|
344
|
+
// would share ONE approval event, and the owner's allow-once would run
|
|
345
|
+
// the tool N times. Each enforce call gets its own event and its own
|
|
346
|
+
// wait; the backend's single-use grant is what keeps that honest.
|
|
347
|
+
if (coalesce) {
|
|
348
|
+
const pending = pendingByKey.get(key);
|
|
349
|
+
if (pending) return pending;
|
|
350
|
+
}
|
|
351
|
+
const attempt = (async () => {
|
|
352
|
+
const boundedSessionKey = clampSessionKey(sessionKey);
|
|
353
|
+
const payload = await transport.request({
|
|
354
|
+
path: "/agent/policy/check",
|
|
355
|
+
method: "POST",
|
|
356
|
+
body: {
|
|
357
|
+
tool: toolName,
|
|
358
|
+
...(digest ? { args_digest: digest } : {}),
|
|
359
|
+
...(boundedSessionKey ? { session_key: boundedSessionKey } : {}),
|
|
360
|
+
},
|
|
361
|
+
timeoutMs: config.policy.timeoutMs,
|
|
362
|
+
});
|
|
363
|
+
if (!payload) return null;
|
|
364
|
+
const answer = normalizeAnswer(payload);
|
|
365
|
+
// Every answer that NAMES its mode teaches it, including a
|
|
366
|
+
// recording-degraded verdict: while the event table is down those
|
|
367
|
+
// answers are the only flip detector, in BOTH directions (an owner's
|
|
368
|
+
// enforce flip must start blocking, and their audit flip must stop
|
|
369
|
+
// enforcing stale rules). Only the no-opinion flavor is mute: its
|
|
370
|
+
// mode is hardcoded audit and must never overwrite the learned one.
|
|
371
|
+
if (answer.mode !== null && !isNoOpinion(answer)) learnMode(answer.mode);
|
|
372
|
+
// require_approval answers are never cached (each is ONE wait on ONE
|
|
373
|
+
// event), and neither is anything the server marks single-use with
|
|
374
|
+
// cache_ttl <= 0 (a grant-consumed allow, or the degraded approval
|
|
375
|
+
// fall-open whose allow admits exactly the one call whose wait could
|
|
376
|
+
// not be recorded): an owner's allow_once must mean once, not sixty
|
|
377
|
+
// seconds of identical calls riding it. The mode-gated decision
|
|
378
|
+
// clause is the belt for a backend that predates the fall-open's
|
|
379
|
+
// cache_ttl 0; the mode gate matters because an AUDIT answer for an
|
|
380
|
+
// approval-ruled tool during the same outage enforced nothing and
|
|
381
|
+
// must keep its ttl. Degraded answers otherwise ARE cached (the
|
|
382
|
+
// backend sends them a ttl on purpose): the cache is what keeps
|
|
383
|
+
// per-call checks from hammering an already-degraded plane, and a
|
|
384
|
+
// replayed no-opinion body still gets its local-rules evaluation in
|
|
385
|
+
// decide() on every call.
|
|
386
|
+
const singleUse =
|
|
387
|
+
(typeof payload.cache_ttl === "number" && payload.cache_ttl <= 0) ||
|
|
388
|
+
(answer.degraded && answer.mode === "enforce" && answer.decision === "require_approval");
|
|
389
|
+
if (answer.effective !== "require_approval" && !singleUse) {
|
|
390
|
+
cacheSet(key, answer, payload.cache_ttl);
|
|
391
|
+
}
|
|
392
|
+
return answer;
|
|
393
|
+
})();
|
|
394
|
+
if (!coalesce) return attempt;
|
|
395
|
+
pendingByKey.set(key, attempt);
|
|
396
|
+
try {
|
|
397
|
+
return await attempt;
|
|
398
|
+
} finally {
|
|
399
|
+
pendingByKey.delete(key);
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The verdict for one tool call (issue #425 phase 5). Never throws.
|
|
405
|
+
*
|
|
406
|
+
* Audit mode (or an unknown mode: fresh process, plane never reached)
|
|
407
|
+
* keeps the phase-4 posture: the report is fired without being awaited
|
|
408
|
+
* and the answer is `allow` immediately. Enforce mode awaits the plane,
|
|
409
|
+
* blocks on deny, waits out a pending approval by polling the decisions
|
|
410
|
+
* leg, and falls back to evaluating the LAST KNOWN rules locally when the
|
|
411
|
+
* plane does not answer: fail closed exactly for the tools the owner's
|
|
412
|
+
* rules constrain, fail open for everything else (a Passport outage must
|
|
413
|
+
* not wedge tools the owner never restricted).
|
|
414
|
+
*/
|
|
415
|
+
async decide({ toolName, params = undefined, sessionKey = null }) {
|
|
416
|
+
const allow = { effective: "allow", reason: null };
|
|
417
|
+
try {
|
|
418
|
+
if (!config.policy.enabled) return allow;
|
|
419
|
+
if (typeof toolName !== "string" || !TOOL_SHAPE.test(toolName)) return allow;
|
|
420
|
+
|
|
421
|
+
// Mode discovery never blocks a tool call. The snapshot warms up in
|
|
422
|
+
// the background (registration kicks it; a fresh process's first
|
|
423
|
+
// calls run in audit posture until it lands), every check answer
|
|
424
|
+
// teaches the live mode for free, and only ENFORCE mode has any use
|
|
425
|
+
// for the periodic rules refresh; audit installs make zero snapshot
|
|
426
|
+
// requests. The phase-4 test suite pins this: audit decide() awaits
|
|
427
|
+
// nothing.
|
|
428
|
+
if (!lastSnapshot) refreshSnapshot().catch(() => {});
|
|
429
|
+
else if (lastSnapshot.mode === "enforce" && now() - lastSnapshot.fetchedAt > SNAPSHOT_TTL_MS) {
|
|
430
|
+
refreshSnapshot().catch(() => {});
|
|
431
|
+
}
|
|
432
|
+
const mode = lastSnapshot?.mode ?? "audit";
|
|
433
|
+
|
|
434
|
+
if (mode !== "enforce") {
|
|
435
|
+
// The phase-4 contract: the report costs the tool call nothing.
|
|
436
|
+
this.report({ toolName, params, sessionKey })?.catch?.(() => {});
|
|
437
|
+
return allow;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const answer = await this.report({ toolName, params, sessionKey, coalesce: false });
|
|
441
|
+
// Two degraded flavors, named by degraded_reason. An
|
|
442
|
+
// event_write_failed body is a real verdict whose RECORDING failed:
|
|
443
|
+
// it falls through to the ordinary handling below, so a wire deny
|
|
444
|
+
// stays a deny even when the local snapshot is stale, and a
|
|
445
|
+
// grant-consumed allow (the owner's explicit yes, cache_ttl 0) runs
|
|
446
|
+
// the tool instead of paging the owner for a second approval of the
|
|
447
|
+
// call they just approved. Only the NO-OPINION flavor
|
|
448
|
+
// (rules_unavailable: nothing was readable) defers to the last-known
|
|
449
|
+
// rules, which is what keeps the public promise that an outage
|
|
450
|
+
// blocks exactly the tools the owner's rules constrain.
|
|
451
|
+
const noOpinion = answer !== null && isNoOpinion(answer);
|
|
452
|
+
if (!answer || noOpinion) {
|
|
453
|
+
// The plane did not answer, or shrugged; the last-known rules
|
|
454
|
+
// decide. A mode
|
|
455
|
+
// learned from a check answer arrives with NO rules (fetchedAt 0),
|
|
456
|
+
// and evaluating an empty list would fail open for the very tools
|
|
457
|
+
// the owner constrained, so an unknown rule set gets one awaited
|
|
458
|
+
// fetch attempt first; if that also fails, fail open and say so
|
|
459
|
+
// rather than silently wedging every tool.
|
|
460
|
+
if (!lastSnapshot || lastSnapshot.fetchedAt === 0) await refreshSnapshot().catch(() => {});
|
|
461
|
+
if (!lastSnapshot || lastSnapshot.fetchedAt === 0) {
|
|
462
|
+
logger?.warn?.(
|
|
463
|
+
"ai-passport: enforce mode with no reachable rule snapshot; failing open until the plane answers"
|
|
464
|
+
);
|
|
465
|
+
return allow;
|
|
466
|
+
}
|
|
467
|
+
const local = resolveLocalPolicy(toolName, lastSnapshot.rules);
|
|
468
|
+
if (local.action === "deny") {
|
|
469
|
+
// Honest copy: a no-opinion 200 means the Passport ANSWERED and
|
|
470
|
+
// only its policy store is down; calling it "unreachable" would
|
|
471
|
+
// send the owner debugging connectivity while the service is up.
|
|
472
|
+
const why = noOpinion
|
|
473
|
+
? "their Passport's policy service is temporarily degraded"
|
|
474
|
+
: "their Passport is currently unreachable";
|
|
475
|
+
return {
|
|
476
|
+
effective: "deny",
|
|
477
|
+
reason: `Blocked by the owner's AI Passport tool policy (rule ${local.matchedPattern}); ${why}.`,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
if (local.action === "require_approval") {
|
|
481
|
+
const why = noOpinion
|
|
482
|
+
? "their AI Passport's policy service is temporarily degraded"
|
|
483
|
+
: "their AI Passport is unreachable";
|
|
484
|
+
return {
|
|
485
|
+
effective: "deny",
|
|
486
|
+
reason: `This tool needs the owner's approval (rule ${local.matchedPattern}) and ${why}, so no approval can be requested right now. Retry later.`,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
return allow;
|
|
490
|
+
}
|
|
491
|
+
if (answer.effective === "deny") {
|
|
492
|
+
return {
|
|
493
|
+
effective: "deny",
|
|
494
|
+
reason: `Blocked by the owner's AI Passport tool policy${answer.matchedPattern ? ` (rule ${answer.matchedPattern})` : ""}.`,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
if (answer.effective === "require_approval") return await this._awaitApproval(answer);
|
|
498
|
+
return allow;
|
|
499
|
+
} catch (err) {
|
|
500
|
+
// decide() sits in front of every tool call; a plugin bug here must
|
|
501
|
+
// cost a report, never the tool.
|
|
502
|
+
logger?.warn?.(`ai-passport: policy decision failed open (${err?.name ?? "error"})`);
|
|
503
|
+
return allow;
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
// Wait for the owner to answer a pending approval, by polling the
|
|
508
|
+
// decisions leg the check answer named. A transient poll failure keeps
|
|
509
|
+
// waiting (the wait's own deadline bounds the loop); running out of
|
|
510
|
+
// patience blocks with the approval link so the owner can still answer
|
|
511
|
+
// and the agent can retry.
|
|
512
|
+
async _awaitApproval(answer) {
|
|
513
|
+
const approvalHint = answer.approvalUrl ? ` The owner can approve it at ${answer.approvalUrl}.` : "";
|
|
514
|
+
if (!answer.eventId || !answer.pollUrl) {
|
|
515
|
+
// The server fell open recording the wait (degraded), so there is
|
|
516
|
+
// nothing to poll and nothing the owner could resolve.
|
|
517
|
+
return answer.degraded
|
|
518
|
+
? { effective: "allow", reason: null }
|
|
519
|
+
: { effective: "deny", reason: `This tool needs the owner's approval.${approvalHint}` };
|
|
520
|
+
}
|
|
521
|
+
const deadline = now() + config.policy.approvalWaitMs;
|
|
522
|
+
while (now() < deadline) {
|
|
523
|
+
await sleep(Math.min(APPROVAL_POLL_INTERVAL_MS, Math.max(1, deadline - now())));
|
|
524
|
+
const payload = await transport.request({
|
|
525
|
+
path: answer.pollUrl,
|
|
526
|
+
method: "GET",
|
|
527
|
+
timeoutMs: config.policy.timeoutMs,
|
|
528
|
+
});
|
|
529
|
+
if (!payload || !payload.resolution) continue;
|
|
530
|
+
// The server's `effective` is the verdict, not the resolution alone:
|
|
531
|
+
// an allow_once whose grant was already consumed elsewhere answers
|
|
532
|
+
// effective=deny, because the one permitted execution happened.
|
|
533
|
+
if (payload.effective === "allow") return { effective: "allow", reason: null };
|
|
534
|
+
if (payload.resolution === "expired") {
|
|
535
|
+
return {
|
|
536
|
+
effective: "deny",
|
|
537
|
+
reason: `The owner's approval window for this tool expired before they answered.${approvalHint} Retry to ask again.`,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (payload.resolution === "denied") {
|
|
541
|
+
return { effective: "deny", reason: "The owner denied this tool call in their AI Passport." };
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
effective: "deny",
|
|
545
|
+
reason: "That approval was already used by another call. Retry to ask the owner again.",
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
effective: "deny",
|
|
550
|
+
reason: `Still waiting for the owner's approval to use this tool.${approvalHint} Retry after they approve.`,
|
|
551
|
+
};
|
|
552
|
+
},
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Fire-and-forget snapshot warmup, called at plugin registration so an
|
|
556
|
+
* enforce-mode owner's rules are in memory before the model produces its
|
|
557
|
+
* first tool call, without any tool call ever awaiting the fetch.
|
|
558
|
+
*/
|
|
559
|
+
warmup() {
|
|
560
|
+
if (!config.policy.enabled) return;
|
|
561
|
+
refreshSnapshot().catch(() => {});
|
|
562
|
+
},
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* One GET /agent/policy/snapshot, for the status CLI (and, in phase 5,
|
|
566
|
+
* the local evaluation fallback). Returns {mode, ruleCount, version} or
|
|
567
|
+
* null; never throws.
|
|
568
|
+
*/
|
|
569
|
+
async snapshot() {
|
|
570
|
+
const payload = await transport.request({
|
|
571
|
+
path: "/agent/policy/snapshot",
|
|
572
|
+
method: "GET",
|
|
573
|
+
timeoutMs: config.policy.timeoutMs,
|
|
574
|
+
});
|
|
575
|
+
if (!payload) return null;
|
|
576
|
+
return {
|
|
577
|
+
mode: payload.mode === "enforce" ? "enforce" : "audit",
|
|
578
|
+
ruleCount: Array.isArray(payload.rules) ? payload.rules.length : 0,
|
|
579
|
+
version: typeof payload.version === "number" ? payload.version : null,
|
|
580
|
+
};
|
|
581
|
+
},
|
|
582
|
+
|
|
583
|
+
__state() {
|
|
584
|
+
return {
|
|
585
|
+
...transport.state(),
|
|
586
|
+
cacheSize: answers.size,
|
|
587
|
+
snapshotMode: lastSnapshot?.mode ?? null,
|
|
588
|
+
snapshotRules: lastSnapshot?.rules?.length ?? null,
|
|
589
|
+
};
|
|
590
|
+
},
|
|
591
|
+
|
|
592
|
+
// Test seam: resolves once every persist queued so far has hit the disk
|
|
593
|
+
// (or failed). Production code never awaits a persist by design.
|
|
594
|
+
__persistSettled() {
|
|
595
|
+
return persistPending;
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
return reporter;
|
|
599
|
+
}
|