@klarkxy/dsh-safe-auto 0.1.0-rc.3
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/LICENSE +40 -0
- package/README.md +147 -0
- package/README.zh-CN.md +129 -0
- package/cordis.patch.yml +3 -0
- package/docs/ADR-0001.md +65 -0
- package/docs/ADR-0002.md +76 -0
- package/docs/ADR-0003.md +39 -0
- package/dsh.plugin.json +8 -0
- package/package.json +51 -0
- package/src/config.js +63 -0
- package/src/escalation.js +118 -0
- package/src/gate.js +64 -0
- package/src/index.js +198 -0
- package/src/model-route.js +110 -0
- package/src/policy.js +97 -0
- package/src/reviewer.js +125 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path';
|
|
2
|
+
import { parseEscalationCandidates } from './escalation.js';
|
|
3
|
+
import { validateModelConfig } from './model-route.js';
|
|
4
|
+
|
|
5
|
+
const defaults = Object.freeze({
|
|
6
|
+
mode: 'shadow', workspaceRoots: [], shellCandidates: [], escalationCandidates: [],
|
|
7
|
+
escalationApprovalTtlMs: 30000, escalationMaxTimeoutMs: 30000,
|
|
8
|
+
endpoint: '', fastProvider: '', fastModel: '', deepProvider: '', deepModel: '', apiKeyEnv: 'DSH_SAFE_AUTO_API_KEY',
|
|
9
|
+
tokenField: 'max_tokens', timeoutMs: 8000, maxInputBytes: 8192,
|
|
10
|
+
fastOutputTokens: 64, deepOutputTokens: 256,
|
|
11
|
+
fastCallsPerTask: 20, deepCallsPerTask: 3, sessionBudgetUnits: 100000,
|
|
12
|
+
consecutiveDenials: 3, totalDenials: 20,
|
|
13
|
+
});
|
|
14
|
+
const limits = {
|
|
15
|
+
escalationApprovalTtlMs: [100, 120000], escalationMaxTimeoutMs: [100, 60000],
|
|
16
|
+
timeoutMs: [100, 60000], maxInputBytes: [512, 32768],
|
|
17
|
+
fastOutputTokens: [32, 512], deepOutputTokens: [64, 2048],
|
|
18
|
+
fastCallsPerTask: [1, 100], deepCallsPerTask: [0, 20],
|
|
19
|
+
sessionBudgetUnits: [1024, 10000000], consecutiveDenials: [1, 20], totalDenials: [1, 100],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Immutable, load-time policy. Unknown keys fail rather than silently disabling a control. */
|
|
23
|
+
export function parseConfig(raw = {}) {
|
|
24
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('config must be an object');
|
|
25
|
+
for (const key of Object.keys(raw)) if (!Object.hasOwn(defaults, key)) throw new Error(`unknown config field: ${key}`);
|
|
26
|
+
const c = { ...defaults, ...raw };
|
|
27
|
+
if (!['off', 'shadow', 'smart', 'unattended'].includes(c.mode)) throw new Error('invalid mode');
|
|
28
|
+
for (const key of ['endpoint', 'fastProvider', 'fastModel', 'deepProvider', 'deepModel', 'apiKeyEnv', 'tokenField']) {
|
|
29
|
+
if (typeof c[key] !== 'string' || c[key].length > 4096) throw new Error(`${key} must be a string`);
|
|
30
|
+
}
|
|
31
|
+
for (const key of ['workspaceRoots', 'shellCandidates']) {
|
|
32
|
+
if (!Array.isArray(c[key]) || c[key].length > 100 || c[key].some(x => typeof x !== 'string' || !x || x.length > 4096)) {
|
|
33
|
+
throw new Error(`${key} must contain at most 100 nonempty strings`);
|
|
34
|
+
}
|
|
35
|
+
c[key] = Object.freeze([...new Set(c[key])]);
|
|
36
|
+
}
|
|
37
|
+
if (c.workspaceRoots.some(p => !isAbsolute(p))) throw new Error('workspaceRoots must be absolute local paths');
|
|
38
|
+
for (const [key, [min, max]] of Object.entries(limits)) {
|
|
39
|
+
if (!Number.isSafeInteger(c[key]) || c[key] < min || c[key] > max) throw new Error(`invalid ${key}`);
|
|
40
|
+
}
|
|
41
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(c.apiKeyEnv)) throw new Error('invalid apiKeyEnv');
|
|
42
|
+
if (!['max_tokens', 'max_completion_tokens'].includes(c.tokenField)) throw new Error('invalid tokenField');
|
|
43
|
+
validateModelConfig(c);
|
|
44
|
+
if (c.endpoint) {
|
|
45
|
+
const u = new URL(c.endpoint);
|
|
46
|
+
if (u.username || u.password || u.hash || u.search) throw new Error('endpoint must not contain credentials, query or fragment');
|
|
47
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
|
|
48
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) throw new Error('endpoint requires HTTPS or loopback HTTP');
|
|
49
|
+
c.endpoint = u.href;
|
|
50
|
+
}
|
|
51
|
+
c.escalationCandidates = parseEscalationCandidates(c.escalationCandidates, c.workspaceRoots);
|
|
52
|
+
return Object.freeze(c);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Cordis accepts StandardSchemaV1. No runtime schema dependency or provider SDK is needed.
|
|
56
|
+
// This schema intentionally has no volatile form metadata; edits require a plugin reload.
|
|
57
|
+
export const Config = Object.freeze({ '~standard': Object.freeze({
|
|
58
|
+
version: 1, vendor: 'dsh-safe-auto',
|
|
59
|
+
validate(value) {
|
|
60
|
+
try { return { value: parseConfig(value) }; }
|
|
61
|
+
catch (error) { return { issues: [{ message: error instanceof Error ? error.message : 'invalid configuration' }] }; }
|
|
62
|
+
},
|
|
63
|
+
}) });
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstatSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, resolve, sep } from 'node:path';
|
|
4
|
+
import { containsSecret, hardRisk, inspectPath, simpleCommand } from './policy.js';
|
|
5
|
+
|
|
6
|
+
const verdict = (kind, code, extra = {}) => ({ kind, code, ...extra });
|
|
7
|
+
const TOOLS = new Set(['bash', 'write', 'edit']);
|
|
8
|
+
const SYSTEM_PATH = /^\/(?:etc|proc|sys|dev|boot|root|usr|bin|sbin|lib|lib64|run)(?:\/|$)/;
|
|
9
|
+
|
|
10
|
+
/** This adapter recognizes only the native, pinned DSH widening vocabulary. */
|
|
11
|
+
export function isNativeEscalation(call) {
|
|
12
|
+
return TOOLS.has(call.tool) && call.args?.sandbox_permissions === 'danger-full-access';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Load-time envelopes are exact, not command prefixes, globs, or directory grants. */
|
|
16
|
+
export function parseEscalationCandidates(value, workspaces) {
|
|
17
|
+
if (!Array.isArray(value) || value.length > 100) throw new Error('invalid escalationCandidates');
|
|
18
|
+
return Object.freeze(value.map(rule => {
|
|
19
|
+
if (!rule || typeof rule !== 'object' || Array.isArray(rule) || !TOOLS.has(rule.tool)) throw new Error('invalid escalation candidate tool');
|
|
20
|
+
const targetKey = rule.tool === 'bash' ? 'command' : 'filePath';
|
|
21
|
+
const keys = ['tool', 'cwd', 'mode', targetKey];
|
|
22
|
+
if (Object.keys(rule).length !== keys.length || Object.keys(rule).some(key => !keys.includes(key))) throw new Error('escalation candidate requires exactly tool, cwd, mode and target');
|
|
23
|
+
if (rule.mode !== 'danger-full-access' || !workspaces.includes(rule.cwd)) throw new Error('escalation candidate requires an enrolled cwd and explicit danger-full-access mode');
|
|
24
|
+
if (rule.tool === 'bash') {
|
|
25
|
+
if (!simpleCommand(rule.command)) throw new Error('escalation command must use the supported simple grammar');
|
|
26
|
+
} else if (typeof rule.filePath !== 'string' || !isAbsolute(rule.filePath) || resolve(rule.filePath) !== rule.filePath || /[\x00-\x1f\x7f\\:*?]/.test(rule.filePath)) {
|
|
27
|
+
throw new Error('escalation filePath must be an exact absolute local path');
|
|
28
|
+
}
|
|
29
|
+
return Object.freeze({ ...rule });
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Include filesystem identity in the pending decision; do not cache authorization across calls. */
|
|
34
|
+
function fileStamp(path) {
|
|
35
|
+
const parent = lstatSync(dirname(path));
|
|
36
|
+
let target;
|
|
37
|
+
try { target = lstatSync(path); }
|
|
38
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
39
|
+
const stamp = s => s ? [s.dev, s.ino, s.mode, s.nlink, s.size, s.mtimeMs, s.ctimeMs] : null;
|
|
40
|
+
return [stamp(parent), stamp(target)];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Does not grant anything: a review result must still pass the native approval seam. */
|
|
44
|
+
export function assessEscalation(call, config) {
|
|
45
|
+
const args = call.args;
|
|
46
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) return verdict('ask', 'INVALID_ARGUMENTS');
|
|
47
|
+
const hard = hardRisk(call);
|
|
48
|
+
if (hard) return hard;
|
|
49
|
+
if (call.sandbox?.mode !== 'workspace-write') return verdict('deny', 'WORKSPACE_SANDBOX_REQUIRED');
|
|
50
|
+
if (!isNativeEscalation(call)) return verdict('ask', 'UNSUPPORTED_ESCALATION');
|
|
51
|
+
if (typeof args.justification !== 'string' || !args.justification.trim() || args.justification.length > 4096) return verdict('ask', 'INVALID_JUSTIFICATION');
|
|
52
|
+
const root = call.sandbox.workspaceRoot;
|
|
53
|
+
if (!config.workspaceRoots.includes(root) || call.cwd !== root) return verdict('ask', 'UNTRUSTED_WORKSPACE');
|
|
54
|
+
if (!call.session || call.subagent || call.nested) return verdict('ask', 'NO_DIRECT_USER_AUTHORITY');
|
|
55
|
+
if (!call.localExecution) return verdict('ask', 'LOCAL_EXECUTION_UNVERIFIED');
|
|
56
|
+
if (sep !== '/') return verdict('ask', 'UNSUPPORTED_PLATFORM');
|
|
57
|
+
try {
|
|
58
|
+
if (realpathSync(root) !== root || !lstatSync(root).isDirectory()) return verdict('ask', 'NONCANONICAL_ROOT');
|
|
59
|
+
} catch { return verdict('ask', 'PATH_UNAVAILABLE'); }
|
|
60
|
+
// Full arguments, including file contents, are reviewed verbatim, or refused; never truncate into another action.
|
|
61
|
+
const serialized = JSON.stringify(args);
|
|
62
|
+
if (Buffer.byteLength(serialized) > config.maxInputBytes) return verdict('ask', 'REVIEW_INPUT_TOO_LARGE');
|
|
63
|
+
if (containsSecret(serialized)) return verdict('deny', 'SENSITIVE_ARGUMENTS');
|
|
64
|
+
let target;
|
|
65
|
+
let stamp;
|
|
66
|
+
if (call.tool === 'bash') {
|
|
67
|
+
if (!simpleCommand(args.command)) return verdict('ask', 'UNSUPPORTED_SHELL');
|
|
68
|
+
const allowed = ['command', 'description', 'timeoutMs', 'workdir', 'run_in_background', 'sandbox_permissions', 'justification'];
|
|
69
|
+
if (Object.keys(args).some(k => !allowed.includes(k))) return verdict('ask', 'UNREVIEWED_ARGUMENTS');
|
|
70
|
+
if (typeof args.description !== 'string' || !args.description.trim()) return verdict('ask', 'INVALID_DESCRIPTION');
|
|
71
|
+
if (args.workdir !== undefined && args.workdir !== root) return verdict('ask', 'UNREVIEWED_WORKDIR');
|
|
72
|
+
if (args.run_in_background !== undefined && args.run_in_background !== false) return verdict('ask', 'BACKGROUND_ESCALATION');
|
|
73
|
+
// The native bash tool can promote even a foreground call when jobs is composed.
|
|
74
|
+
if (call.jobsAvailable !== false || call.shellConfined !== true) return verdict('ask', 'PROCESS_LIFETIME_UNVERIFIED');
|
|
75
|
+
if (!Number.isSafeInteger(args.timeoutMs) || args.timeoutMs <= 0 || args.timeoutMs > config.escalationMaxTimeoutMs) return verdict('ask', 'BOUNDED_TIMEOUT_REQUIRED');
|
|
76
|
+
target = args.command;
|
|
77
|
+
} else {
|
|
78
|
+
const allowed = call.tool === 'write'
|
|
79
|
+
? ['file_path', 'content', 'sandbox_permissions', 'justification']
|
|
80
|
+
: ['file_path', 'old_string', 'new_string', 'replace_all', 'sandbox_permissions', 'justification'];
|
|
81
|
+
if (Object.keys(args).some(k => !allowed.includes(k))) return verdict('ask', 'UNREVIEWED_ARGUMENTS');
|
|
82
|
+
target = args.file_path;
|
|
83
|
+
if (typeof target !== 'string' || !isAbsolute(target) || resolve(target) !== target) return verdict('ask', 'EXACT_ABSOLUTE_PATH_REQUIRED');
|
|
84
|
+
if (SYSTEM_PATH.test(target)) return verdict('deny', 'SYSTEM_TARGET');
|
|
85
|
+
if (call.tool === 'write' ? typeof args.content !== 'string' :
|
|
86
|
+
typeof args.old_string !== 'string' || !args.old_string || typeof args.new_string !== 'string' ||
|
|
87
|
+
(args.replace_all !== undefined && typeof args.replace_all !== 'boolean')) return verdict('ask', 'INVALID_FILE_ARGUMENTS');
|
|
88
|
+
const path = inspectPath(dirname(target), target, call.tool === 'write');
|
|
89
|
+
if (path.kind !== 'allow') return path;
|
|
90
|
+
// edit also changes a file: its existing target must not be a hard link.
|
|
91
|
+
if (call.tool === 'edit' && lstatSync(target).nlink > 1) return verdict('ask', 'NONREGULAR_TARGET');
|
|
92
|
+
try { stamp = fileStamp(target); }
|
|
93
|
+
catch { return verdict('ask', 'PATH_UNAVAILABLE'); }
|
|
94
|
+
}
|
|
95
|
+
const matched = config.escalationCandidates.some(rule => rule.tool === call.tool && rule.cwd === root &&
|
|
96
|
+
rule.mode === args.sandbox_permissions && (call.tool === 'bash' ? rule.command : rule.filePath) === target);
|
|
97
|
+
if (!matched) return verdict('ask', 'OUTSIDE_ESCALATION_ENVELOPE');
|
|
98
|
+
return verdict('review', 'ENROLLED_ESCALATION', {
|
|
99
|
+
stamp,
|
|
100
|
+
action: { tool: call.tool, arguments: args, cwd: root,
|
|
101
|
+
permission: { from: call.sandbox.mode, to: args.sandbox_permissions, scope: 'this-call-only',
|
|
102
|
+
filesystemConfinement: 'removed-for-this-call', networkIsolation: 'not-provided-by-DSH-file-mode' } },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Both grant and request use the native helper's exact audited reason, not a prefix match. */
|
|
107
|
+
export function escalationReason(call) {
|
|
108
|
+
return `escalate sandbox to ${call.args.sandbox_permissions}: ${call.args.justification}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function bindingOf(call, assessment) {
|
|
112
|
+
return createHash('sha256').update(JSON.stringify({
|
|
113
|
+
tool: call.tool, args: call.args, cwd: call.cwd, sandbox: call.sandbox,
|
|
114
|
+
task: call.task, intent: call.intent, subagent: call.subagent, nested: call.nested,
|
|
115
|
+
localExecution: call.localExecution, jobsAvailable: call.jobsAvailable, shellConfined: call.shellConfined,
|
|
116
|
+
code: assessment.code, stamp: assessment.stamp,
|
|
117
|
+
})).digest('hex');
|
|
118
|
+
}
|
package/src/gate.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { assess } from './policy.js';
|
|
2
|
+
import { assessEscalation } from './escalation.js';
|
|
3
|
+
import { review } from './reviewer.js';
|
|
4
|
+
import { resolveReviewRoutes, sameRoutes } from './model-route.js';
|
|
5
|
+
|
|
6
|
+
/** Per-instance, per-session budgets. Model changes never reset the session budget. */
|
|
7
|
+
export function createGate(config, { fetcher = globalThis.fetch, audit = () => {}, getLlm = () => undefined } = {}) {
|
|
8
|
+
const sessions = new WeakMap();
|
|
9
|
+
const abort = new AbortController();
|
|
10
|
+
function state(session, task) {
|
|
11
|
+
let s = sessions.get(session);
|
|
12
|
+
if (!s) {
|
|
13
|
+
s = { task, fastCalls: 0, deepCalls: 0, units: 0, reportedTokens: 0, consecutive: 0, denials: 0 };
|
|
14
|
+
sessions.set(session, s);
|
|
15
|
+
}
|
|
16
|
+
if (s.task !== task) {
|
|
17
|
+
s.task = task; s.fastCalls = 0; s.deepCalls = 0; s.consecutive = 0;
|
|
18
|
+
// Neither the session budget nor the total-denial fuse is reset by a new user message.
|
|
19
|
+
}
|
|
20
|
+
return s;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
dispose() { abort.abort(); },
|
|
24
|
+
async decide(call, phase = 'preflight') {
|
|
25
|
+
const start = Date.now();
|
|
26
|
+
let result = phase === 'escalation' ? assessEscalation(call, config) : assess(call, config);
|
|
27
|
+
let s;
|
|
28
|
+
if (result.kind === 'review') {
|
|
29
|
+
if (!call.session || call.subagent) result = { kind: 'ask', code: 'NO_DIRECT_USER_AUTHORITY' };
|
|
30
|
+
else {
|
|
31
|
+
s = state(call.session, call.task);
|
|
32
|
+
if (s.consecutive >= config.consecutiveDenials || s.denials >= config.totalDenials) {
|
|
33
|
+
result = { kind: 'ask', code: 'CIRCUIT_OPEN' };
|
|
34
|
+
} else {
|
|
35
|
+
const oldTask = s.task;
|
|
36
|
+
try {
|
|
37
|
+
const signal = AbortSignal.any([call.signal, abort.signal]);
|
|
38
|
+
const routes = resolveReviewRoutes(config, call);
|
|
39
|
+
const llm = routes.fast.transport === 'dsh' ? getLlm() : undefined;
|
|
40
|
+
const kind = await review(config, result.action, call.intent, s, signal, fetcher, { routes, llm });
|
|
41
|
+
if (signal.aborted || s.task !== oldTask) result = { kind: 'deny', code: 'STALE_REVIEW' };
|
|
42
|
+
else if (kind === 'deny') result = { kind: 'deny', code: 'MODEL_NOT_ALLOWED' };
|
|
43
|
+
else if (!sameRoutes(routes, resolveReviewRoutes(config, call)) || (llm && llm !== getLlm())) {
|
|
44
|
+
result = { kind: 'ask', code: 'REVIEW_MODEL_CHANGED' };
|
|
45
|
+
} else result = { kind, code: kind === 'allow' ? 'MODEL_ALLOWED' : 'MODEL_NOT_ALLOWED',
|
|
46
|
+
...(kind === 'allow' ? { reviewRoutes: routes, reviewLlm: llm } : {}),
|
|
47
|
+
};
|
|
48
|
+
} catch { result = { kind: 'ask', code: 'REVIEW_UNAVAILABLE' }; }
|
|
49
|
+
if (result.kind === 'allow') s.consecutive = 0;
|
|
50
|
+
else { s.consecutive++; s.denials++; }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (call.signal.aborted || abort.signal.aborted) result = { kind: 'cancel', code: 'CANCELLED' };
|
|
55
|
+
if (config.mode === 'unattended' && result.kind === 'ask') result = { ...result, kind: 'deny' };
|
|
56
|
+
// Audit failures cannot accidentally grant permission. Do not include arguments, intent, endpoint or errors.
|
|
57
|
+
try { audit({ phase: 'assessment', gate: phase, tool: call.tool, callId: call.callId, decision: result.kind, code: result.code,
|
|
58
|
+
mode: config.mode, durationMs: Date.now() - start,
|
|
59
|
+
...(s ? { reservedUnits: s.units, reportedTokens: s.reportedTokens, fastCalls: s.fastCalls, deepCalls: s.deepCalls } : {}),
|
|
60
|
+
}); } catch { result = { kind: 'deny', code: 'AUDIT_UNAVAILABLE' }; }
|
|
61
|
+
return result;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { parseConfig } from './config.js';
|
|
3
|
+
import { assess } from './policy.js';
|
|
4
|
+
import { createGate } from './gate.js';
|
|
5
|
+
import { assessEscalation, bindingOf, escalationReason, isNativeEscalation } from './escalation.js';
|
|
6
|
+
import { resolveReviewRoutes, sameRoutes } from './model-route.js';
|
|
7
|
+
export { Config } from './config.js';
|
|
8
|
+
|
|
9
|
+
export const name = 'dsh-safe-auto';
|
|
10
|
+
export const inject = ['tools', 'sandboxPolicy'];
|
|
11
|
+
|
|
12
|
+
/** Do not turn assistant/tool/subagent text into user authority, even when it resembles an approval. */
|
|
13
|
+
export function authority(session) {
|
|
14
|
+
const events = session?.snapshotEvents?.();
|
|
15
|
+
if (!Array.isArray(events)) return {};
|
|
16
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
17
|
+
const e = events[i];
|
|
18
|
+
if (e?.type !== 'user/message' || e.data?.source?.kind !== 'user') continue;
|
|
19
|
+
const blocks = e.data.content;
|
|
20
|
+
if (!Array.isArray(blocks) || blocks.some(b => b?.type !== 'text' || typeof b.text !== 'string')) return { task: e.seq ?? i };
|
|
21
|
+
return { task: e.seq ?? i, intent: blocks.map(b => b.text).join('\n') };
|
|
22
|
+
}
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Public DSH seams only. Standing sandbox policy is NEVER written or temporarily switched. */
|
|
27
|
+
export function apply(ctx, raw = {}) {
|
|
28
|
+
const config = parseConfig(raw);
|
|
29
|
+
if (config.mode === 'off') return;
|
|
30
|
+
if (typeof ctx.tools?.guard !== 'function' || typeof ctx.sandboxPolicy?.resolve !== 'function') throw new Error('DSH Safe Auto requires tools.guard and sandboxPolicy.resolve');
|
|
31
|
+
const decisions = new Map();
|
|
32
|
+
const execution = new AsyncLocalStorage();
|
|
33
|
+
const lifetime = new AbortController();
|
|
34
|
+
let llm;
|
|
35
|
+
// Optional dependency: losing a model service must NOT unload the safety guards.
|
|
36
|
+
// HTTP-only profiles and deterministic checks remain usable without a native adapter.
|
|
37
|
+
if (!config.endpoint && typeof ctx.inject === 'function') ctx.inject(['llm'], scope => {
|
|
38
|
+
const active = scope.llm;
|
|
39
|
+
llm = active;
|
|
40
|
+
scope.effect(() => () => { if (llm === active) llm = undefined; });
|
|
41
|
+
});
|
|
42
|
+
const gate = createGate(config, { getLlm: () => llm, audit: row => ctx.logger.info('safe-auto %s', JSON.stringify(row)) });
|
|
43
|
+
ctx.effect(() => () => { lifetime.abort(); gate.dispose(); decisions.clear(); execution.disable(); });
|
|
44
|
+
|
|
45
|
+
function callOf(exec, includeAuthority = false) {
|
|
46
|
+
const session = exec.agent?.session;
|
|
47
|
+
const sandbox = ctx.sandboxPolicy.resolve({ session });
|
|
48
|
+
const info = includeAuthority ? authority(session) : {};
|
|
49
|
+
const call = { tool: exec.name, args: exec.arguments, callId: String(exec.callId), session, agent: exec.agent,
|
|
50
|
+
cwd: session?.header?.cwd, sandbox, signal: exec.signal,
|
|
51
|
+
subagent: Boolean(session?.header?.parentSession) || session?.header?.origin === 'subagent',
|
|
52
|
+
nested: exec.parent !== undefined, ...info };
|
|
53
|
+
if (isNativeEscalation(call)) {
|
|
54
|
+
// These are optional capability reads, not model assertions about its environment.
|
|
55
|
+
const fs = ctx.get?.('fs');
|
|
56
|
+
const shell = ctx.get?.('shell');
|
|
57
|
+
const paths = [call.cwd, ...(call.tool === 'bash' ? [] : [call.args.file_path])];
|
|
58
|
+
call.localExecution = paths.every(p => typeof p === 'string' && fs?.processPathFromHostPath?.(p) === p);
|
|
59
|
+
call.jobsAvailable = typeof ctx.get !== 'function' || ctx.get('jobs') !== undefined;
|
|
60
|
+
call.shellConfined = ['read-only', 'workspace-write', 'danger-full-access'].includes(shell?.sandboxMode);
|
|
61
|
+
}
|
|
62
|
+
return call;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function currentEscalation(exec, saved) {
|
|
66
|
+
const call = callOf(exec, true);
|
|
67
|
+
const assessment = assessEscalation(call, config);
|
|
68
|
+
return { call, assessment, unchanged: bindingOf(call, assessment) === saved.binding };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sameReviewer(exec, decision) {
|
|
72
|
+
if (!decision.reviewRoutes) return true;
|
|
73
|
+
return sameRoutes(decision.reviewRoutes, resolveReviewRoutes(config, callOf(exec))) &&
|
|
74
|
+
(decision.reviewRoutes.fast.transport !== 'dsh' || decision.reviewLlm === llm);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
ctx.tools.guard(exec => {
|
|
78
|
+
if (config.mode === 'shadow') return undefined;
|
|
79
|
+
if (exec.signal.aborted || lifetime.signal.aborted) return 'safe-auto: CANCELLED';
|
|
80
|
+
try {
|
|
81
|
+
const hard = assess(callOf(exec), config);
|
|
82
|
+
if (hard.kind === 'deny') return `safe-auto: ${hard.code}`;
|
|
83
|
+
const saved = decisions.get(exec.token);
|
|
84
|
+
if (!saved) return 'safe-auto: PREFLIGHT_NOT_RUN';
|
|
85
|
+
if (!sameReviewer(exec, saved)) return 'safe-auto: REVIEW_MODEL_CHANGED';
|
|
86
|
+
if (saved.kind === 'escalation') {
|
|
87
|
+
const current = currentEscalation(exec, saved);
|
|
88
|
+
if (current.assessment.kind === 'deny') return `safe-auto: ${current.assessment.code}`;
|
|
89
|
+
if (!current.unchanged) return 'safe-auto: ESCALATION_CHANGED';
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
if (saved.kind === 'allow' && hard.kind === 'ask') return 'safe-auto: POLICY_CHANGED';
|
|
93
|
+
if (saved.kind === 'deny' || saved.kind === 'cancel') return `safe-auto: ${saved.code}`;
|
|
94
|
+
} catch { return 'safe-auto: POLICY_UNAVAILABLE'; }
|
|
95
|
+
return undefined;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
99
|
+
let decision;
|
|
100
|
+
try {
|
|
101
|
+
if (decisions.size >= 1024) decision = { kind: 'deny', code: 'TOO_MANY_PENDING_CALLS' };
|
|
102
|
+
else {
|
|
103
|
+
const call = callOf(exec);
|
|
104
|
+
if (isNativeEscalation(call)) {
|
|
105
|
+
Object.assign(call, authority(call.session));
|
|
106
|
+
const assessed = assessEscalation(call, config);
|
|
107
|
+
decision = assessed.kind === 'deny' ? assessed : {
|
|
108
|
+
kind: 'escalation', code: assessed.code, binding: bindingOf(call, assessed), signal: exec.signal,
|
|
109
|
+
};
|
|
110
|
+
} else {
|
|
111
|
+
if (assess(call, config).kind === 'review') Object.assign(call, authority(call.session));
|
|
112
|
+
decision = await gate.decide(call);
|
|
113
|
+
const latest = assess(callOf(exec), config);
|
|
114
|
+
if (latest.kind === 'deny' || (decision.kind === 'allow' && latest.kind === 'ask')) decision = latest;
|
|
115
|
+
if (call.intent !== undefined) {
|
|
116
|
+
const current = authority(call.session);
|
|
117
|
+
if (current.task !== call.task || current.intent !== call.intent) decision = { kind: 'ask', code: 'AUTHORITY_CHANGED' };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch { decision = { kind: 'ask', code: 'POLICY_UNAVAILABLE' }; }
|
|
122
|
+
if (exec.signal.aborted || lifetime.signal.aborted) decision = { kind: 'cancel', code: 'CANCELLED' };
|
|
123
|
+
if (config.mode === 'unattended' && decision.kind === 'ask') decision = { ...decision, kind: 'deny' };
|
|
124
|
+
if (config.mode === 'shadow') return next();
|
|
125
|
+
if (decisions.size < 1024) decisions.set(exec.token, decision);
|
|
126
|
+
if (decision.kind === 'deny') return { kind: 'deny', reason: `safe-auto: ${decision.code}` };
|
|
127
|
+
if (decision.kind === 'cancel') return { kind: 'cancel' };
|
|
128
|
+
const downstream = await next();
|
|
129
|
+
if (downstream.kind !== 'allow' || decision.kind === 'allow' || decision.kind === 'escalation') return downstream;
|
|
130
|
+
return { kind: 'ask', reason: `safe-auto: ${decision.code}` };
|
|
131
|
+
}, { prepend: true });
|
|
132
|
+
|
|
133
|
+
// Bind the approval to the active execution, never a process-wide visible callId cache.
|
|
134
|
+
ctx.on('tools/execute', async (exec, next) => {
|
|
135
|
+
if (config.mode === 'shadow') return next();
|
|
136
|
+
const saved = decisions.get(exec.token);
|
|
137
|
+
if (saved?.kind !== 'escalation') return next();
|
|
138
|
+
const store = { exec, saved, active: true, claimed: false };
|
|
139
|
+
try { return await execution.run(store, next); }
|
|
140
|
+
finally { store.active = false; }
|
|
141
|
+
}, { prepend: true });
|
|
142
|
+
|
|
143
|
+
ctx.on('tools/result', (exec, result) => {
|
|
144
|
+
decisions.delete(exec.token);
|
|
145
|
+
ctx.logger.info('safe-auto %s', JSON.stringify({ phase: 'result', tool: exec.name,
|
|
146
|
+
callId: String(exec.callId), isError: result?.isError, mode: config.mode }));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
ctx.on('approval/request', async (req, next) => {
|
|
150
|
+
if (config.mode === 'shadow') return next();
|
|
151
|
+
const store = execution.getStore();
|
|
152
|
+
if (!store) return config.mode === 'unattended' ? (req.signal?.aborted ? 'cancelled' : 'rejected') : next();
|
|
153
|
+
const { exec, saved } = store;
|
|
154
|
+
if (!store.active || store.claimed) return 'rejected';
|
|
155
|
+
if (typeof req.reason !== 'string' || !req.reason.startsWith('escalate sandbox to ')) {
|
|
156
|
+
return config.mode === 'unattended' ? 'rejected' : next();
|
|
157
|
+
}
|
|
158
|
+
if (req.agent !== exec.agent || req.toolName !== exec.name || req.callId !== exec.callId ||
|
|
159
|
+
req.signal !== exec.signal || req.reason !== escalationReason(callOf(exec))) return 'rejected';
|
|
160
|
+
store.claimed = true;
|
|
161
|
+
const signal = AbortSignal.any([saved.signal, exec.signal, req.signal, lifetime.signal]);
|
|
162
|
+
const started = performance.now();
|
|
163
|
+
const requestReason = req.reason;
|
|
164
|
+
let reviewed;
|
|
165
|
+
function stillBound() {
|
|
166
|
+
if (signal.aborted || !store.active || decisions.get(exec.token) !== saved) return false;
|
|
167
|
+
if (req.agent !== exec.agent || req.toolName !== exec.name || req.callId !== exec.callId ||
|
|
168
|
+
req.signal !== exec.signal || req.reason !== requestReason) return false;
|
|
169
|
+
try { return currentEscalation(exec, saved).unchanged && (!reviewed || sameReviewer(exec, reviewed)); }
|
|
170
|
+
catch { return false; }
|
|
171
|
+
}
|
|
172
|
+
function finish(outcome, source, code) {
|
|
173
|
+
if (signal.aborted) outcome = 'cancelled';
|
|
174
|
+
else if (!stillBound()) outcome = 'rejected';
|
|
175
|
+
try {
|
|
176
|
+
ctx.logger.info('safe-auto %s', JSON.stringify({ phase: 'escalation', tool: exec.name,
|
|
177
|
+
callId: String(exec.callId), decision: outcome, source, code,
|
|
178
|
+
from: 'workspace-write', to: 'danger-full-access', scope: 'this-call-only',
|
|
179
|
+
binding: saved.binding, durationMs: Math.round(performance.now() - started) }));
|
|
180
|
+
} catch { return 'rejected'; }
|
|
181
|
+
return outcome;
|
|
182
|
+
}
|
|
183
|
+
if (!stillBound()) return finish('rejected', 'binding', 'ESCALATION_CHANGED');
|
|
184
|
+
let decision;
|
|
185
|
+
try { decision = await gate.decide({ ...callOf(exec, true), signal }, 'escalation'); }
|
|
186
|
+
catch { decision = { kind: 'ask', code: 'REVIEW_UNAVAILABLE' }; }
|
|
187
|
+
if (decision.kind === 'allow') reviewed = decision;
|
|
188
|
+
if (!stillBound()) return finish('rejected', 'binding', 'ESCALATION_CHANGED');
|
|
189
|
+
if (performance.now() - started > config.escalationApprovalTtlMs) decision = { kind: 'ask', code: 'ESCALATION_LEASE_EXPIRED' };
|
|
190
|
+
if (decision.kind === 'allow') return finish('allowed-once', 'reviewer', decision.code);
|
|
191
|
+
if (decision.kind === 'cancel') return finish('cancelled', 'reviewer', decision.code);
|
|
192
|
+
if (decision.kind === 'deny' || config.mode === 'unattended') return finish('rejected', 'policy', decision.code);
|
|
193
|
+
try {
|
|
194
|
+
const human = await next();
|
|
195
|
+
return finish(['allowed-once', 'rejected', 'cancelled', 'unavailable'].includes(human) ? human : 'unavailable', 'native', decision.code);
|
|
196
|
+
} catch { return finish('unavailable', 'native', 'APPROVAL_UNAVAILABLE'); }
|
|
197
|
+
}, { prepend: true });
|
|
198
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Model selection is independent of the main agent's tools, transcript and sampling config. */
|
|
2
|
+
const id = value => typeof value === 'string' && value.length > 0 && value.length <= 4096 &&
|
|
3
|
+
value === value.trim() && !/[\x00-\x1f\x7f]/.test(value);
|
|
4
|
+
|
|
5
|
+
export function validateModelConfig(config) {
|
|
6
|
+
const { endpoint, fastProvider = '', fastModel, deepProvider = '', deepModel } = config;
|
|
7
|
+
for (const value of [fastProvider, fastModel, deepProvider, deepModel]) {
|
|
8
|
+
if (value !== '' && !id(value)) throw new Error('invalid reviewer provider/model identifier');
|
|
9
|
+
}
|
|
10
|
+
if (endpoint) {
|
|
11
|
+
if (fastProvider || deepProvider) throw new Error('HTTP endpoint cannot be combined with native reviewer providers');
|
|
12
|
+
if (!fastModel) throw new Error('endpoint requires fastModel');
|
|
13
|
+
} else {
|
|
14
|
+
if (Boolean(fastProvider) !== Boolean(fastModel)) throw new Error('fastProvider and fastModel must be configured together');
|
|
15
|
+
if (Boolean(deepProvider) !== Boolean(deepModel)) throw new Error('deepProvider and deepModel must be configured together');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Use the accepted request's route, not a deployment default or another session's model. */
|
|
20
|
+
export function conversationRoute(owner = {}) {
|
|
21
|
+
const agent = owner.agent;
|
|
22
|
+
const session = owner.session ?? agent?.session;
|
|
23
|
+
const header = session?.requestHeader?.();
|
|
24
|
+
// A present but malformed header must not silently fall back to potentially unrelated options.
|
|
25
|
+
const selected = header == null ? agent?.options : header.config;
|
|
26
|
+
if (!id(selected?.provider) || !id(selected?.model)) throw new Error('CONVERSATION_MODEL_UNAVAILABLE');
|
|
27
|
+
return Object.freeze({ provider: selected.provider, model: selected.model });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Snapshot both stages once. No implicit fallback between providers or transports. */
|
|
31
|
+
export function resolveReviewRoutes(config, owner) {
|
|
32
|
+
validateModelConfig(config);
|
|
33
|
+
if (config.endpoint) return Object.freeze({
|
|
34
|
+
fast: Object.freeze({ transport: 'http', endpoint: config.endpoint, model: config.fastModel }),
|
|
35
|
+
deep: config.deepModel ? Object.freeze({ transport: 'http', endpoint: config.endpoint, model: config.deepModel }) : null,
|
|
36
|
+
});
|
|
37
|
+
const selected = config.fastProvider ? { provider: config.fastProvider, model: config.fastModel } : conversationRoute(owner);
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
fast: Object.freeze({ transport: 'dsh', ...selected }),
|
|
40
|
+
deep: config.deepModel ? Object.freeze({ transport: 'dsh', provider: config.deepProvider, model: config.deepModel }) : null,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const sameRoutes = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
45
|
+
|
|
46
|
+
function usageTotal(usage) {
|
|
47
|
+
if (!usage || typeof usage !== 'object') return undefined;
|
|
48
|
+
if (Number.isSafeInteger(usage.totalTokens) && usage.totalTokens >= 0) return usage.totalTokens;
|
|
49
|
+
const fields = [usage.inputTokens, usage.outputTokens, usage.cacheReadTokens ?? 0, usage.cacheWriteTokens ?? 0];
|
|
50
|
+
if (!fields.every(value => Number.isSafeInteger(value) && value >= 0)) return undefined;
|
|
51
|
+
const total = fields.reduce((a, b) => a + b, 0);
|
|
52
|
+
return Number.isSafeInteger(total) ? total : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Bounded native DSH one-shot. It never constructs an Agent or runs returned tools. */
|
|
56
|
+
export async function nativeCompletion(llm, route, system, input, maxTokens, signal) {
|
|
57
|
+
if (typeof llm?.stream !== 'function') throw new Error('NATIVE_REVIEWER_UNAVAILABLE');
|
|
58
|
+
signal.throwIfAborted();
|
|
59
|
+
const options = {
|
|
60
|
+
provider: route.provider, model: route.model,
|
|
61
|
+
system, messages: [{ role: 'user', content: [{ type: 'text', text: input }] }],
|
|
62
|
+
tools: [], maxTokens, signal,
|
|
63
|
+
};
|
|
64
|
+
const blocks = new Map();
|
|
65
|
+
let size = 0;
|
|
66
|
+
let finish;
|
|
67
|
+
let usage;
|
|
68
|
+
let chunks = 0;
|
|
69
|
+
for await (const chunk of llm.stream(options)) {
|
|
70
|
+
signal.throwIfAborted();
|
|
71
|
+
if (++chunks > 16384 || !chunk || typeof chunk !== 'object' || finish !== undefined) throw new Error('INVALID_NATIVE_STREAM');
|
|
72
|
+
if (chunk.type === 'finish') {
|
|
73
|
+
if (chunk.reason?.kind !== 'stop') throw new Error('INVALID_COMPLETION');
|
|
74
|
+
finish = chunk.reason.kind;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (chunk.type === 'usage') { usage = usageTotal(chunk.usage); continue; }
|
|
78
|
+
if (!Number.isSafeInteger(chunk.index) || chunk.index < 0 || chunk.index > 255) throw new Error('INVALID_NATIVE_BLOCK');
|
|
79
|
+
if (chunk.type === 'block-start') {
|
|
80
|
+
if (!['text', 'reasoning'].includes(chunk.blockType)) throw new Error('UNEXPECTED_REVIEWER_TOOL');
|
|
81
|
+
if (blocks.has(chunk.index)) throw new Error('DUPLICATE_NATIVE_BLOCK');
|
|
82
|
+
blocks.set(chunk.index, { type: chunk.blockType, text: '', ended: false, hasDelta: false });
|
|
83
|
+
} else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
|
|
84
|
+
const type = chunk.type === 'text-delta' ? 'text' : 'reasoning';
|
|
85
|
+
if (typeof chunk.text !== 'string') throw new Error('INVALID_NATIVE_DELTA');
|
|
86
|
+
const block = blocks.get(chunk.index) ?? { type, text: '', ended: false, hasDelta: false };
|
|
87
|
+
if (block.type !== type || block.ended) throw new Error('INVALID_NATIVE_BLOCK');
|
|
88
|
+
size += Buffer.byteLength(chunk.text);
|
|
89
|
+
if (size > 32768) throw new Error('REVIEW_RESPONSE_TOO_LARGE');
|
|
90
|
+
block.text += chunk.text;
|
|
91
|
+
block.hasDelta = true;
|
|
92
|
+
blocks.set(chunk.index, block);
|
|
93
|
+
} else if (chunk.type === 'block-end') {
|
|
94
|
+
if (!['text', 'reasoning'].includes(chunk.block?.type) || typeof chunk.block.text !== 'string') throw new Error('UNEXPECTED_REVIEWER_TOOL');
|
|
95
|
+
const prior = blocks.get(chunk.index);
|
|
96
|
+
if (prior && (prior.ended || prior.type !== chunk.block.type || (prior.hasDelta && prior.text !== chunk.block.text))) throw new Error('INCONSISTENT_NATIVE_BLOCK');
|
|
97
|
+
if (!prior?.hasDelta) size += Buffer.byteLength(chunk.block.text);
|
|
98
|
+
blocks.set(chunk.index, { ...chunk.block, ended: true });
|
|
99
|
+
} else {
|
|
100
|
+
throw new Error('UNEXPECTED_REVIEWER_CHUNK');
|
|
101
|
+
}
|
|
102
|
+
if (size > 32768) throw new Error('REVIEW_RESPONSE_TOO_LARGE');
|
|
103
|
+
}
|
|
104
|
+
signal.throwIfAborted();
|
|
105
|
+
if (finish !== 'stop' || [...blocks.values()].some(block => !block.ended)) throw new Error('MISSING_COMPLETION');
|
|
106
|
+
const text = [...blocks.entries()].sort(([a], [b]) => a - b)
|
|
107
|
+
.filter(([, block]) => block.type === 'text').map(([, block]) => block.text).join('');
|
|
108
|
+
if (!text || Buffer.byteLength(text) > 2048) throw new Error('INVALID_COMPLETION');
|
|
109
|
+
return { text, totalTokens: usage };
|
|
110
|
+
}
|