@goodandready/dsh-approval-gate 0.1.23 → 0.1.25

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.
@@ -0,0 +1,229 @@
1
+ // lib/learning.js
2
+ // Confirmation Learning with Hard Critical Category Exclusion (#79, #81).
3
+ // Tracks manual user approvals and auto-promotes routine safe operations after N-1 confirmations.
4
+ // Strictly excludes destructive, secret, privilege-escalation, and system-level operations.
5
+
6
+ import { isCriticalOperation } from "./engine.js";
7
+
8
+ export const DEFAULT_MIN_CONFIRMATIONS = 2;
9
+ export const DEFAULT_MAX_LEARNED_RULES = 100;
10
+
11
+ export const EXCLUSION_REASONS = Object.freeze({
12
+ CRITICAL_OPERATION: "critical-category-excluded",
13
+ SECRETS_EXCLUDED: "secrets-category-excluded",
14
+ DESTRUCTIVE_EXCLUDED: "destructive-category-excluded",
15
+ SYSTEM_PATHS_EXCLUDED: "system-paths-category-excluded",
16
+ PRIVILEGE_EXCLUDED: "privilege-category-excluded",
17
+ });
18
+
19
+ const SECRETS_PATTERNS = [
20
+ /\.env\b/i,
21
+ /[\\/]\.ssh[\\/]/i,
22
+ /[\\/]\.aws[\\/]/i,
23
+ /\/etc\/(?:shadow|gshadow|sudoers)/i,
24
+ /\b(?:id_rsa|id_ed25519|id_ecdsa|private[._-]?key)\b/i,
25
+ ];
26
+
27
+ const DESTRUCTIVE_PATTERNS = [
28
+ /\b(?:rm|shred|wipefs)\b/i,
29
+ /\btruncate\s+-s\s+0\b/i,
30
+ />\s*\/dev\/(?:null|zero|sd[a-z]|nvme)/i,
31
+ ];
32
+
33
+ const SYSTEM_PATH_PATTERNS = [
34
+ /^\/(?:etc|boot|sys|proc|root|dev)\b/i,
35
+ /[\\/](?:Windows[\\/]System32|Program Files)/i,
36
+ ];
37
+
38
+ const PRIVILEGE_PATTERNS = [
39
+ /\b(?:sudo|su|doas)\b/i,
40
+ /\bchmod\s+(-[a-zA-Z]*\s+)?(?:777|666)\b/i,
41
+ /\bchown\s+(-[a-zA-Z]*\s+)?root\b/i,
42
+ ];
43
+
44
+ /**
45
+ * Checks if an operation is strictly eligible for confirmation learning (#81).
46
+ *
47
+ * @param {string} [toolName]
48
+ * @param {string} [command]
49
+ * @param {Record<string, any>} [execution]
50
+ * @returns {{ canLearn: boolean, reason?: string }}
51
+ */
52
+ export function canLearnOperation(toolName, command, execution) {
53
+ const args = execution?.arguments || execution || {};
54
+ const cmd = String(command || args?.command || "").trim();
55
+ const tool = String(toolName || execution?.name || "").trim();
56
+ const target = String(
57
+ args?.TargetFile ||
58
+ args?.target_file ||
59
+ args?.targetPath ||
60
+ args?.path ||
61
+ args?.file ||
62
+ ""
63
+ ).trim();
64
+
65
+ // 1. Critical operations check (#52, #81)
66
+ if (isCriticalOperation(tool, cmd, execution)) {
67
+ return { canLearn: false, reason: EXCLUSION_REASONS.CRITICAL_OPERATION };
68
+ }
69
+
70
+ // 2. Destructive operations exclusion
71
+ if (cmd && DESTRUCTIVE_PATTERNS.some((p) => p.test(cmd))) {
72
+ return { canLearn: false, reason: EXCLUSION_REASONS.DESTRUCTIVE_EXCLUDED };
73
+ }
74
+
75
+ // 3. Secrets and credentials exclusion
76
+ if ((cmd && SECRETS_PATTERNS.some((p) => p.test(cmd))) || (target && SECRETS_PATTERNS.some((p) => p.test(target)))) {
77
+ return { canLearn: false, reason: EXCLUSION_REASONS.SECRETS_EXCLUDED };
78
+ }
79
+
80
+ // 4. System paths exclusion
81
+ if ((cmd && SYSTEM_PATH_PATTERNS.some((p) => p.test(cmd))) || (target && SYSTEM_PATH_PATTERNS.some((p) => p.test(target)))) {
82
+ return { canLearn: false, reason: EXCLUSION_REASONS.SYSTEM_PATHS_EXCLUDED };
83
+ }
84
+
85
+ // 5. Privilege escalation exclusion
86
+ if (cmd && PRIVILEGE_PATTERNS.some((p) => p.test(cmd))) {
87
+ return { canLearn: false, reason: EXCLUSION_REASONS.PRIVILEGE_EXCLUDED };
88
+ }
89
+
90
+ return { canLearn: true };
91
+ }
92
+
93
+ /**
94
+ * Normalizes an execution to a stable cache/lookup key for learning.
95
+ * @param {string} toolName
96
+ * @param {string} command
97
+ * @param {Record<string, any>} [args]
98
+ * @returns {string}
99
+ */
100
+ export function normalizeLearningKey(toolName, command, args = {}) {
101
+ const tool = String(toolName || "").trim().toLowerCase();
102
+ const cmd = String(command || args?.command || "").trim().replace(/\s+/g, " ");
103
+ if (cmd) return `${tool}:${cmd}`;
104
+ const target = String(args?.TargetFile || args?.path || args?.file || "").trim();
105
+ return `${tool}:${target}`;
106
+ }
107
+
108
+ /**
109
+ * Manages confirmation counts and learned rules (#79, #81).
110
+ */
111
+ export class ConfirmationLearner {
112
+ /**
113
+ * @param {{ minConfirmations?: number, maxLearnedRules?: number }} [options]
114
+ */
115
+ constructor(options = {}) {
116
+ this.minConfirmations = options.minConfirmations || DEFAULT_MIN_CONFIRMATIONS;
117
+ this.maxLearnedRules = options.maxLearnedRules || DEFAULT_MAX_LEARNED_RULES;
118
+ /** @type {Map<string, Map<string, { count: number, learned: boolean, command: string, tool: string, timestamp: number }>>} */
119
+ this.sessions = new Map();
120
+ }
121
+
122
+ /**
123
+ * @private
124
+ */
125
+ _getSessionMap(sessionId) {
126
+ const key = sessionId || "default";
127
+ let map = this.sessions.get(key);
128
+ if (!map) {
129
+ map = new Map();
130
+ this.sessions.set(key, map);
131
+ }
132
+ return map;
133
+ }
134
+
135
+ /**
136
+ * Records a manual human confirmation for an execution.
137
+ *
138
+ * @param {string} sessionId
139
+ * @param {{ name: string, arguments?: any }} execution
140
+ * @returns {{ learned: boolean, count: number, threshold: number, excluded?: boolean, reason?: string }}
141
+ */
142
+ recordConfirmation(sessionId, execution) {
143
+ const toolName = execution?.name || "";
144
+ const command = execution?.arguments?.command || "";
145
+
146
+ const check = canLearnOperation(toolName, command, execution);
147
+ if (!check.canLearn) {
148
+ return { learned: false, count: 0, threshold: this.minConfirmations, excluded: true, reason: check.reason };
149
+ }
150
+
151
+ const sessionMap = this._getSessionMap(sessionId);
152
+ const key = normalizeLearningKey(toolName, command, execution?.arguments);
153
+ const existing = sessionMap.get(key) || { count: 0, learned: false, command, tool: toolName, timestamp: Date.now() };
154
+
155
+ existing.count += 1;
156
+ existing.timestamp = Date.now();
157
+
158
+ if (existing.count >= this.minConfirmations) {
159
+ existing.learned = true;
160
+ }
161
+
162
+ sessionMap.set(key, existing);
163
+
164
+ // Enforce maxLearnedRules eviction (FIFO)
165
+ if (sessionMap.size > this.maxLearnedRules) {
166
+ const oldestKey = sessionMap.keys().next().value;
167
+ if (oldestKey) sessionMap.delete(oldestKey);
168
+ }
169
+
170
+ return {
171
+ learned: existing.learned,
172
+ count: existing.count,
173
+ threshold: this.minConfirmations,
174
+ key,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Checks if an execution matches a learned rule in the session.
180
+ *
181
+ * @param {string} sessionId
182
+ * @param {{ name: string, arguments?: any }} execution
183
+ * @returns {{ learned: boolean, rule?: any }}
184
+ */
185
+ isLearned(sessionId, execution) {
186
+ const toolName = execution?.name || "";
187
+ const command = execution?.arguments?.command || "";
188
+
189
+ // Double-check hard exclusion: critical items can NEVER be learned
190
+ if (!canLearnOperation(toolName, command, execution).canLearn) {
191
+ return { learned: false };
192
+ }
193
+
194
+ const sessionMap = this._getSessionMap(sessionId);
195
+ const key = normalizeLearningKey(toolName, command, execution?.arguments);
196
+ const entry = sessionMap.get(key);
197
+
198
+ if (entry && entry.learned) {
199
+ return { learned: true, rule: { ...entry, key } };
200
+ }
201
+
202
+ return { learned: false };
203
+ }
204
+
205
+ /**
206
+ * Returns list of all learned rules for a session.
207
+ * @param {string} sessionId
208
+ * @returns {Array<any>}
209
+ */
210
+ getLearnedRules(sessionId) {
211
+ const sessionMap = this._getSessionMap(sessionId);
212
+ const rules = [];
213
+ for (const [key, entry] of sessionMap.entries()) {
214
+ if (entry.learned) {
215
+ rules.push({ key, ...entry });
216
+ }
217
+ }
218
+ return rules;
219
+ }
220
+
221
+ clearSession(sessionId) {
222
+ const key = sessionId || "default";
223
+ this.sessions.delete(key);
224
+ }
225
+
226
+ reset() {
227
+ this.sessions.clear();
228
+ }
229
+ }
@@ -0,0 +1,147 @@
1
+ // lib/network-sandbox.js
2
+ // Mapping Network Egress Policies to DSH Sandbox Modes (#41).
3
+ // Controls outbound socket and HTTP connections based on shell.sandboxMode.
4
+
5
+ import { isLoopbackHost } from "./loopback.js";
6
+
7
+ export const NETWORK_POLICIES = Object.freeze({
8
+ DENY_ALL: "deny-all",
9
+ WHITELIST_ONLY: "whitelist-only",
10
+ ALLOW_ALL: "allow-all",
11
+ });
12
+
13
+ export const SANDBOX_NETWORK_MAP = Object.freeze({
14
+ "read-only": NETWORK_POLICIES.DENY_ALL,
15
+ readonly: NETWORK_POLICIES.DENY_ALL,
16
+ isolated: NETWORK_POLICIES.DENY_ALL,
17
+ "workspace-write": NETWORK_POLICIES.WHITELIST_ONLY,
18
+ standard: NETWORK_POLICIES.WHITELIST_ONLY,
19
+ hybrid: NETWORK_POLICIES.WHITELIST_ONLY,
20
+ "danger-full-access": NETWORK_POLICIES.ALLOW_ALL,
21
+ full: NETWORK_POLICIES.ALLOW_ALL,
22
+ });
23
+
24
+ /**
25
+ * Resolves network egress policy from DSH sandbox mode (#41).
26
+ *
27
+ * @param {string} [sandboxMode]
28
+ * @param {Record<string, any>} [options]
29
+ * @returns {"deny-all" | "whitelist-only" | "allow-all"}
30
+ */
31
+ export function resolveNetworkPolicy(sandboxMode, options = {}) {
32
+ if (options.policy && Object.values(NETWORK_POLICIES).includes(options.policy)) {
33
+ return options.policy;
34
+ }
35
+ const mode = String(sandboxMode || "").toLowerCase().trim();
36
+ return SANDBOX_NETWORK_MAP[mode] || options.defaultPolicy || NETWORK_POLICIES.WHITELIST_ONLY;
37
+ }
38
+
39
+ /**
40
+ * Normalizes a destination host / URL to hostname and port.
41
+ * @param {string} destination
42
+ * @returns {{ hostname: string, port: number }}
43
+ */
44
+ export function parseDestination(destination) {
45
+ if (!destination || typeof destination !== "string") {
46
+ return { hostname: "", port: 80 };
47
+ }
48
+ const clean = destination.trim();
49
+ try {
50
+ if (clean.includes("://")) {
51
+ const u = new URL(clean);
52
+ return { hostname: u.hostname.toLowerCase(), port: Number(u.port) || (u.protocol === "https:" ? 443 : 80) };
53
+ }
54
+ } catch (_e) {}
55
+
56
+ // host:port format
57
+ if (clean.includes(":")) {
58
+ const [h, p] = clean.split(":");
59
+ return { hostname: h.toLowerCase(), port: Number(p) || 80 };
60
+ }
61
+
62
+ return { hostname: clean.toLowerCase(), port: 80 };
63
+ }
64
+
65
+ /**
66
+ * Checks whether outbound network access to destination is allowed (#41).
67
+ *
68
+ * @param {string} destination - Host, host:port, or full URL
69
+ * @param {string} [sandboxMode] - Active DSH sandbox mode
70
+ * @param {{
71
+ * whitelist?: string[],
72
+ * allowLoopback?: boolean,
73
+ * policy?: string,
74
+ * }} [options]
75
+ * @returns {{
76
+ * allowed: boolean,
77
+ * policy: string,
78
+ * destination: string,
79
+ * hostname: string,
80
+ * reason?: string
81
+ * }}
82
+ */
83
+ export function checkNetworkAccess(destination, sandboxMode, options = {}) {
84
+ const policy = resolveNetworkPolicy(sandboxMode, options);
85
+ const { hostname } = parseDestination(destination);
86
+
87
+ // 1. Loopback check (allowed by default for internal services unless explicitly blocked)
88
+ const isLoopback = isLoopbackHost(hostname);
89
+ if (isLoopback && options.allowLoopback !== false) {
90
+ return {
91
+ allowed: true,
92
+ policy,
93
+ destination,
94
+ hostname,
95
+ reason: "loopback-allowed",
96
+ };
97
+ }
98
+
99
+ // 2. Deny-all policy (read-only sandbox mode)
100
+ if (policy === NETWORK_POLICIES.DENY_ALL) {
101
+ return {
102
+ allowed: false,
103
+ policy,
104
+ destination,
105
+ hostname,
106
+ reason: "sandbox-read-only-network-denied",
107
+ };
108
+ }
109
+
110
+ // 3. Allow-all policy (danger-full-access sandbox mode)
111
+ if (policy === NETWORK_POLICIES.ALLOW_ALL) {
112
+ return {
113
+ allowed: true,
114
+ policy,
115
+ destination,
116
+ hostname,
117
+ reason: "danger-full-access-unrestricted",
118
+ };
119
+ }
120
+
121
+ // 4. Whitelist-only policy (workspace-write / standard sandbox mode)
122
+ const whitelist = Array.isArray(options.whitelist) ? options.whitelist : [];
123
+ const isWhitelisted = whitelist.some((entry) => {
124
+ const entryHost = String(entry).toLowerCase().trim();
125
+ if (entryHost === hostname) return true;
126
+ if (entryHost.startsWith("*.") && hostname.endsWith(entryHost.slice(1))) return true;
127
+ return false;
128
+ });
129
+
130
+ if (isWhitelisted) {
131
+ return {
132
+ allowed: true,
133
+ policy,
134
+ destination,
135
+ hostname,
136
+ reason: "whitelist-allowed",
137
+ };
138
+ }
139
+
140
+ return {
141
+ allowed: false,
142
+ policy,
143
+ destination,
144
+ hostname,
145
+ reason: "not-in-network-whitelist",
146
+ };
147
+ }
@@ -0,0 +1,180 @@
1
+ // lib/race-decision.js
2
+ // Asynchronous Race Decision & Pre-emption Claim (#50, #51).
3
+ // Races human decision against host timeout and enables fast LLM pre-emption claim.
4
+
5
+ export const DEFAULT_HUMAN_TIMEOUT_MS = 60000;
6
+
7
+ export const RACE_WINNERS = Object.freeze({
8
+ HUMAN: "human",
9
+ TIMEOUT: "timeout",
10
+ CLAIM: "claim",
11
+ });
12
+
13
+ /**
14
+ * Handle coordinating human decision, host timeout, and pre-emption claims (#50, #51).
15
+ */
16
+ export class RaceHumanHandle {
17
+ /**
18
+ * @param {{
19
+ * timeoutMs?: number,
20
+ * timeoutAction?: "allow" | "deny" | "ask",
21
+ * lockedAsk?: boolean,
22
+ * onClaim?: (verdict: string, reason: string) => void,
23
+ * onTimeout?: () => void,
24
+ * }} [options]
25
+ */
26
+ constructor(options = {}) {
27
+ this.timeoutMs = typeof options.timeoutMs === "number" ? options.timeoutMs : DEFAULT_HUMAN_TIMEOUT_MS;
28
+ this.timeoutAction = options.timeoutAction || "deny";
29
+ this.lockedAsk = Boolean(options.lockedAsk);
30
+ this.onClaim = options.onClaim || null;
31
+ this.onTimeout = options.onTimeout || null;
32
+
33
+ this.settled = false;
34
+ this.winner = null;
35
+ this.startTime = Date.now();
36
+ this.timer = null;
37
+
38
+ this._resolve = null;
39
+ this._reject = null;
40
+
41
+ this.promise = new Promise((resolve, reject) => {
42
+ this._resolve = resolve;
43
+ this._reject = reject;
44
+ });
45
+
46
+ if (this.timeoutMs > 0 && this.timeoutMs < Infinity) {
47
+ this.timer = setTimeout(() => {
48
+ this._handleTimeout();
49
+ }, this.timeoutMs);
50
+ }
51
+ }
52
+
53
+ isSettled() {
54
+ return this.settled;
55
+ }
56
+
57
+ /**
58
+ * Pre-empts open human confirmation dialog when fast LLM or reviewer arrives at confident verdict (#51).
59
+ *
60
+ * @param {"allow" | "deny" | "ask"} outcome
61
+ * @param {string} [reason="pre-emption-claim"]
62
+ * @returns {{ claimed: boolean, reason?: string, winner?: string }}
63
+ */
64
+ claim(outcome, reason = "pre-emption-claim") {
65
+ if (this.settled) {
66
+ return { claimed: false, reason: "already-settled", winner: this.winner };
67
+ }
68
+
69
+ // Critical lockedAsk operations cannot be auto-claimed as allow
70
+ if (this.lockedAsk && outcome === "allow") {
71
+ return { claimed: false, reason: "locked-ask-cannot-be-claimed-as-allow" };
72
+ }
73
+
74
+ this._settle(RACE_WINNERS.CLAIM, {
75
+ winner: RACE_WINNERS.CLAIM,
76
+ verdict: outcome,
77
+ reason,
78
+ elapsedMs: Date.now() - this.startTime,
79
+ });
80
+
81
+ if (typeof this.onClaim === "function") {
82
+ this.onClaim(outcome, reason);
83
+ }
84
+
85
+ return { claimed: true, verdict: outcome, reason };
86
+ }
87
+
88
+ /**
89
+ * Resolves decision from human approval input.
90
+ * @param {Record<string, any> | string} decision
91
+ */
92
+ resolveHuman(decision) {
93
+ if (this.settled) return false;
94
+ const verdict = typeof decision === "string" ? decision : (decision?.verdict || "allow");
95
+ this._settle(RACE_WINNERS.HUMAN, {
96
+ winner: RACE_WINNERS.HUMAN,
97
+ verdict,
98
+ decision: typeof decision === "object" ? decision : { verdict },
99
+ elapsedMs: Date.now() - this.startTime,
100
+ });
101
+ return true;
102
+ }
103
+
104
+ /**
105
+ * Aborts the race decision handle.
106
+ * @param {string} [reason="aborted"]
107
+ */
108
+ abort(reason = "aborted") {
109
+ if (this.settled) return false;
110
+ this._settle(RACE_WINNERS.TIMEOUT, {
111
+ winner: RACE_WINNERS.TIMEOUT,
112
+ verdict: "deny",
113
+ reason,
114
+ elapsedMs: Date.now() - this.startTime,
115
+ });
116
+ return true;
117
+ }
118
+
119
+ /**
120
+ * @private
121
+ */
122
+ _handleTimeout() {
123
+ if (this.settled) return;
124
+
125
+ // Locked ask operations must fail closed on timeout
126
+ const resolvedVerdict = this.lockedAsk ? "deny" : this.timeoutAction;
127
+
128
+ if (typeof this.onTimeout === "function") {
129
+ this.onTimeout();
130
+ }
131
+
132
+ this._settle(RACE_WINNERS.TIMEOUT, {
133
+ winner: RACE_WINNERS.TIMEOUT,
134
+ verdict: resolvedVerdict,
135
+ reason: "host-timeout-expired",
136
+ elapsedMs: Date.now() - this.startTime,
137
+ });
138
+ }
139
+
140
+ /**
141
+ * @private
142
+ */
143
+ _settle(winner, result) {
144
+ if (this.settled) return;
145
+ this.settled = true;
146
+ this.winner = winner;
147
+ if (this.timer) {
148
+ clearTimeout(this.timer);
149
+ this.timer = null;
150
+ }
151
+ if (this._resolve) {
152
+ this._resolve(result);
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Races human decision against host timeout and optional fast claims (#50, #51).
159
+ *
160
+ * @param {Promise<any>} humanPromise
161
+ * @param {ConstructorParameters<typeof RaceHumanHandle>[0]} [options]
162
+ * @returns {Promise<{
163
+ * winner: "human" | "timeout" | "claim",
164
+ * verdict: string,
165
+ * elapsedMs: number,
166
+ * reason?: string,
167
+ * decision?: any
168
+ * }>}
169
+ */
170
+ export async function raceHumanDecision(humanPromise, options = {}) {
171
+ const handle = new RaceHumanHandle(options);
172
+
173
+ if (humanPromise && typeof humanPromise.then === "function") {
174
+ humanPromise
175
+ .then((res) => handle.resolveHuman(res))
176
+ .catch((err) => handle.abort(err?.message || "human-error"));
177
+ }
178
+
179
+ return handle.promise;
180
+ }
@@ -0,0 +1,134 @@
1
+ // lib/reviewer.js
2
+ // Independent Second-Model AI Reviewer with Separation of Duties (#62).
3
+ // Prevents an agent from approving its own commands by routing safety reviews to an isolated observer model.
4
+
5
+ export const SAFETY_REVIEWER_SYSTEM_PROMPT = `You are an independent security reviewer observing an autonomous AI agent.
6
+ Your sole responsibility is to protect user data, credentials, and system integrity.
7
+ The agent cannot approve its own risky actions. You must review the tool call and evidence dossier.
8
+ Deny or ask for confirmation if:
9
+ 1. Operations mutate files outside declared project boundaries.
10
+ 2. Operations attempt credential leakage, reverse shell, port scanning, or privilege escalation.
11
+ 3. Operations cause destructive mass deletion or service termination.
12
+ Always respond in JSON: {"approved": boolean, "verdict": "allow"|"deny"|"ask", "reason": string}`;
13
+
14
+ export const DEFAULT_REVIEWER_AGENT_ID = "security-reviewer-v1";
15
+
16
+ /**
17
+ * Asserts separation of duties: primary agent cannot act as its own reviewer (#62).
18
+ *
19
+ * @param {string} agentId - The agent requesting or generating the tool execution
20
+ * @param {string} reviewerId - The independent reviewer agent id
21
+ * @returns {{ valid: boolean, error?: string }}
22
+ */
23
+ export function assertSeparationOfDuties(agentId, reviewerId) {
24
+ const agent = String(agentId || "").trim();
25
+ const reviewer = String(reviewerId || "").trim();
26
+
27
+ if (agent && reviewer && agent.toLowerCase() === reviewer.toLowerCase()) {
28
+ return {
29
+ valid: false,
30
+ error: "self-approval-forbidden: primary agent cannot act as its own security reviewer (#62)",
31
+ };
32
+ }
33
+
34
+ return { valid: true };
35
+ }
36
+
37
+ /**
38
+ * Independent Security Reviewer for second-model AI validation (#62).
39
+ */
40
+ export class IndependentReviewer {
41
+ /**
42
+ * @param {{ reviewerId?: string, reviewerClient?: any, fallbackVerdict?: string }} [options]
43
+ */
44
+ constructor(options = {}) {
45
+ this.reviewerId = options.reviewerId || DEFAULT_REVIEWER_AGENT_ID;
46
+ this.reviewerClient = options.reviewerClient || null;
47
+ this.fallbackVerdict = options.fallbackVerdict || "ask";
48
+ }
49
+
50
+ /**
51
+ * Reviews an execution request with separation of duties enforcement (#62).
52
+ *
53
+ * @param {{ name: string, arguments?: any }} execution
54
+ * @param {Record<string, any>} [context]
55
+ * @returns {Promise<{
56
+ * approved: boolean,
57
+ * verdict: "allow" | "deny" | "ask",
58
+ * reason: string,
59
+ * reviewerId: string,
60
+ * separationOfDuties: boolean,
61
+ * timestamp: number
62
+ * }>}
63
+ */
64
+ async review(execution, context = {}) {
65
+ const agentId = context?.agentId || context?.agent?.id || execution?.agentId || "agent-main";
66
+ const reviewerId = context?.reviewerId || this.reviewerId;
67
+
68
+ // Enforce Separation of Duties (#62)
69
+ const sep = assertSeparationOfDuties(agentId, reviewerId);
70
+ if (!sep.valid) {
71
+ return {
72
+ approved: false,
73
+ verdict: "deny",
74
+ reason: sep.error,
75
+ reviewerId,
76
+ separationOfDuties: false,
77
+ timestamp: Date.now(),
78
+ };
79
+ }
80
+
81
+ // If no external model client is configured, return safe fallback
82
+ if (!this.reviewerClient) {
83
+ return {
84
+ approved: false,
85
+ verdict: this.fallbackVerdict,
86
+ reason: "no-independent-reviewer-model-configured",
87
+ reviewerId,
88
+ separationOfDuties: true,
89
+ timestamp: Date.now(),
90
+ };
91
+ }
92
+
93
+ try {
94
+ const payload = {
95
+ tool: execution?.name,
96
+ arguments: execution?.arguments,
97
+ dossier: context?.dossier,
98
+ track: context?.track,
99
+ riskBucket: context?.riskBucket,
100
+ };
101
+
102
+ let response = null;
103
+ if (typeof this.reviewerClient.classify === "function") {
104
+ response = await this.reviewerClient.classify(payload, {
105
+ systemPrompt: SAFETY_REVIEWER_SYSTEM_PROMPT,
106
+ });
107
+ } else if (typeof this.reviewerClient === "function") {
108
+ response = await this.reviewerClient(payload);
109
+ }
110
+
111
+ const verdict = response?.verdict === "allow" ? "allow" : (response?.verdict === "deny" ? "deny" : "ask");
112
+ const approved = verdict === "allow";
113
+
114
+ return {
115
+ approved,
116
+ verdict,
117
+ reason: response?.reason || "independent-reviewer-verdict",
118
+ reviewerId,
119
+ separationOfDuties: true,
120
+ timestamp: Date.now(),
121
+ };
122
+ } catch (err) {
123
+ // Reviewer errors fail closed
124
+ return {
125
+ approved: false,
126
+ verdict: "ask",
127
+ reason: `independent-reviewer-error: ${err?.message || err}`,
128
+ reviewerId,
129
+ separationOfDuties: true,
130
+ timestamp: Date.now(),
131
+ };
132
+ }
133
+ }
134
+ }