@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.
- package/CHANGELOG.md +16 -0
- package/bin/eval.mjs +16 -0
- package/lib/engine.js +37 -23
- package/lib/eval-runner.js +152 -0
- package/lib/forward-proxy.js +218 -0
- package/lib/index.js +8 -0
- package/lib/learning.js +229 -0
- package/lib/network-sandbox.js +147 -0
- package/lib/race-decision.js +180 -0
- package/lib/reviewer.js +134 -0
- package/lib/rules-hmr.js +132 -0
- package/lib/similarity.js +134 -0
- package/package.json +9 -4
package/lib/learning.js
ADDED
|
@@ -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
|
+
}
|
package/lib/reviewer.js
ADDED
|
@@ -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
|
+
}
|