@klarkxy/dsh-safe-auto 0.1.0-rc.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui-host.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { createControl, CHANNEL, REVIEW_FIELDS } from './control.js';
2
+ import { registerHostRpc } from '@klarkxy/dsh-plugin-kit/host-rpc';
3
+ import { registerModelSettings, notifyModelSettingsChanged } from '@klarkxy/dsh-plugin-kit/model-settings';
2
4
 
3
5
  export const name = 'dsh-safe-auto-ui-host';
4
6
  // Register on this fiber. connection.rpc.handle mounts routes on connection's
@@ -9,62 +11,77 @@ const MAX_REQUEST_BYTES = 64 * 1024;
9
11
 
10
12
  /** Connection-compatible POST /dsh-safe-auto/<endpoint>. Missing auth policy fails closed. */
11
13
  export function registerControlRoute(ctx, call) {
12
- return ctx.webServer.register({ kind: 'prefix', path: CHANNEL, handler: async (req, res) => {
13
- if (typeof ctx.connection.requestRejection !== 'function') { res.writeHead(503); res.end(); return; }
14
- const rejection = ctx.connection.requestRejection(req);
15
- if (rejection !== undefined) { res.writeHead(rejection); res.end(rejection === 401 ? 'unauthorized' : 'forbidden'); return; }
16
- const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
17
- const endpoint = pathname.startsWith(`${CHANNEL}/`) ? pathname.slice(CHANNEL.length + 1) : '';
18
- if (req.method !== 'POST' || !ENDPOINT.test(endpoint) || endpoint.includes('/')) { res.writeHead(404); res.end(); return; }
19
- if (String(req.headers['content-type'] ?? '').split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
20
- res.writeHead(415); res.end(); return;
21
- }
22
- const controller = new AbortController();
23
- res.on('close', () => { if (!res.writableEnded) controller.abort(); });
24
- try {
25
- const chunks = [];
26
- let size = 0;
27
- for await (const chunk of req) {
28
- const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
29
- size += bytes.length;
30
- if (size > MAX_REQUEST_BYTES) { res.writeHead(413, { connection: 'close' }); res.end(); req.destroy(); return; }
31
- chunks.push(bytes);
32
- }
33
- let message;
34
- try { message = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
35
- catch { res.writeHead(400); res.end(); return; }
36
- if (!message || typeof message !== 'object' || Array.isArray(message)
37
- || message.type !== 'client-request' || typeof message.rpcId !== 'string'
38
- || message.method !== endpoint || !Object.hasOwn(message, 'payload')) {
39
- res.writeHead(400); res.end(); return;
14
+ return registerHostRpc(ctx, CHANNEL, (endpoint, payload, signal) =>
15
+ call(endpoint, payload, signal, { interaction: endpoint === 'approval.list' || endpoint === 'approval.answer' }), {
16
+ maxBodyBytes: MAX_REQUEST_BYTES,
17
+ isEndpoint: endpoint => ENDPOINT.test(endpoint),
18
+ caseInsensitiveContentType: true,
19
+ requirePayload: true,
20
+ errorBody: status => status === 401 ? 'unauthorized' : status === 403 ? 'forbidden' : undefined,
21
+ beforeBody: (req, endpoint) => {
22
+ // Host admission is authoritative; this metadata gate protects the two
23
+ // browser approval endpoints, including Desktop's forwarded requests.
24
+ const interaction = endpoint === 'approval.list' || endpoint === 'approval.answer';
25
+ const site = req.headers['sec-fetch-site'];
26
+ const desktopRequest = site === undefined && req.headers.origin === undefined;
27
+ const trustedSite = ['same-origin', 'same-site'].includes(site) || desktopRequest;
28
+ const trustedDestination = req.headers['sec-fetch-dest'] === 'empty'
29
+ || (desktopRequest && req.headers['sec-fetch-dest'] === undefined && req.headers['sec-fetch-mode'] === 'cors');
30
+ if (interaction && (!['cors', 'same-origin'].includes(req.headers['sec-fetch-mode']) ||
31
+ !trustedSite || !trustedDestination)) {
32
+ return { status: 403, body: 'browser confirmation required' };
40
33
  }
41
- const result = await call(endpoint, message.payload, controller.signal);
42
- if (!res.destroyed) {
43
- res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
44
- res.end(JSON.stringify({ type: 'server-response', rpcId: message.rpcId, result }));
45
- }
46
- } catch {
47
- if (!res.destroyed) { res.writeHead(500); res.end('rpc request failed'); }
48
- }
49
- } });
34
+ },
35
+ });
50
36
  }
51
37
 
52
38
  export async function apply(ctx) {
53
39
  // Loaded only by the separate UI row; the safety guard keeps its original minimal dependencies.
54
40
  const [{ defineDomain, domainTable }, { z }] = await Promise.all([import('@deepseek-ai/dsh-storage-domain'), import('zod')]);
41
+ // No strict(): rows saved by 0.1.x carry retired keys (fastProvider, approvalReview, …),
42
+ // which strip on load instead of breaking the panel; the reviewer simply falls back to defaults.
55
43
  const domain = await ctx.storageDomain.open(defineDomain({ name: 'dsh_safe_auto', version: 1, tables: {
56
44
  settings: domainTable(z.object({ revision: z.number().int().nonnegative(),
57
- values: z.object({ ...Object.fromEntries(REVIEW_FIELDS.map(k => [k, z.string().max(4096).optional()])), approvalReview: z.boolean().optional() }).strict(),
45
+ values: z.object(Object.fromEntries(REVIEW_FIELDS.map(k => [k, z.string().max(4096).optional()]))),
58
46
  }).strict()),
59
47
  } }));
60
- ctx.effect(() => () => domain.close());
61
- const control = createControl(ctx.safeAutoRuntime.base, { table: domain.table('settings'), presets: ctx.permissionPresets,
62
- sandboxPolicy: ctx.sandboxPolicy, sessions: ctx.sessions });
48
+ let control;
49
+ ctx.effect(() => async () => { await control?.dispose(); await domain.close(); });
50
+ control = createControl(ctx.safeAutoRuntime.base, { table: domain.table('settings'), presets: ctx.permissionPresets,
51
+ sandboxPolicy: ctx.sandboxPolicy, sessions: ctx.sessions, onChange: () => ctx.safeAutoRuntime.invalidateApprovals?.(),
52
+ onSettingsChanged: revision => notifyModelSettingsChanged(ctx, '@klarkxy/dsh-safe-auto', revision) });
63
53
  // Validate stored settings before publishing any UI endpoint.
64
54
  control.settingsView();
55
+ ctx.effect(() => registerModelSettings(ctx, {
56
+ ns: '@klarkxy/dsh-safe-auto', title: { zh: '安全自动', en: 'Safe Auto' },
57
+ read(signal) {
58
+ signal.throwIfAborted(); control.signal.throwIfAborted();
59
+ const { revision, values } = control.settingsView();
60
+ const current = { provider: values.provider, model: values.model,
61
+ ...(values.reasoningEffort ? { reasoningEffort: values.reasoningEffort } : {}) };
62
+ return { revision, fields: [{ path: ['model'], label: { zh: '审查模型', en: 'Reviewer model' }, current,
63
+ followLabel: { zh: '当前会话模型', en: 'Current conversation model' }, allowFollow: true }] };
64
+ },
65
+ async write(path, route, expectedRevision, signal) {
66
+ if (path.length !== 1 || path[0] !== 'model') throw new Error('Unknown Safe Auto model setting');
67
+ return control.call('settings.save', { values: { provider: route.provider, model: route.model,
68
+ reasoningEffort: route.reasoningEffort || '' }, expectedRevision }, signal);
69
+ },
70
+ }));
65
71
  ctx.effect(() => ctx.safeAutoRuntime.attach(control));
66
- ctx.effect(() => registerControlRoute(ctx, async (endpoint, payload, signal) => {
67
- try { return { ok: true, value: await control.call(endpoint, payload, signal) }; }
72
+ ctx.effect(() => registerControlRoute(ctx, async (endpoint, payload, signal, source) => {
73
+ try {
74
+ control.signal.throwIfAborted();
75
+ if (endpoint === 'approval.list' || endpoint === 'approval.answer') {
76
+ if (!source?.interaction) throw new Error('Browser confirmation required');
77
+ signal.throwIfAborted();
78
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('Invalid approval payload');
79
+ const value = endpoint === 'approval.list' ? ctx.safeAutoRuntime.listApprovals(payload.sessionId)
80
+ : ctx.safeAutoRuntime.answerApproval(payload);
81
+ return { ok: true, value };
82
+ }
83
+ return { ok: true, value: await control.call(endpoint, payload, signal) };
84
+ }
68
85
  catch (error) { return { ok: false, error: { code: 'safe-auto/rejected', message: error instanceof Error ? error.message : 'Request rejected', details: {} } }; }
69
86
  }));
70
87
  }
package/src/escalation.js DELETED
@@ -1,118 +0,0 @@
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
- }