@klarkxy/dsh-safe-auto 0.2.3 → 0.3.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/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Safe Auto",
4
- "description": "An independent reviewer vets each sandbox escalation; only a pass auto-approves that one call."
4
+ "description": "A reviewer model answers approval requests; only calls that pass go ahead, one call at a time."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "安全自动",
4
- "description": "沙箱提权请求由独立审核模型把关,通过才自动放行这一次调用。"
4
+ "description": "需要审批的请求交给审核模型判断,通过才放行这一次调用。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@klarkxy/dsh-safe-auto",
3
- "version": "0.2.3",
4
- "description": "Safe Auto: an independent reviewer vets each native sandbox escalation; only a pass auto-approves that one call.",
3
+ "version": "0.3.0",
4
+ "description": "Safe Auto: a reviewer model answers native approval requests, Codex auto-review style; only a pass approves that one call.",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
7
7
  "main": "src/index.js",
@@ -23,7 +23,7 @@
23
23
  "LICENSE"
24
24
  ],
25
25
  "scripts": {
26
- "build": "node --check src/config.js && node --check src/approval-review.js && node --check src/policy.js && node --check src/credential-paths.js && node --check src/credential-text.js && node --check src/credential-reads.js && node --check src/host-runtime.js && node --check src/review-paths.js && node --check src/shell-command.js && node --check src/model-route.js && node --check src/review-context.js && node --check src/execution-evidence.js && node --check src/human-approval.js && node --check src/reviewer.js && node --check src/gate.js && node --check src/index.js && node --check src/control.js && node --check src/ui-host.js && tsdown && node ../../scripts/editor-plugins/wrap-client.mjs @klarkxy/dsh-safe-auto",
26
+ "build": "node --check src/config.js && node --check src/dangerous.js && node --check src/transcript.js && node --check src/model-route.js && node --check src/reviewer.js && node --check src/index.js && node --check src/control.js && node --check src/ui-host.js && tsdown && node ../../scripts/editor-plugins/wrap-client.mjs @klarkxy/dsh-safe-auto",
27
27
  "test": "node --test test/*.test.js"
28
28
  },
29
29
  "engines": {
@@ -58,11 +58,7 @@
58
58
  }
59
59
  },
60
60
  "dependencies": {
61
- "@klarkxy/dsh-plugin-kit": "0.3.3",
62
- "jsonc-parser": "^3.3.1",
63
- "toml-eslint-parser": "^1.0.3",
64
- "unbash": "^5.0.0",
65
- "yaml": "^2.9.1",
61
+ "@klarkxy/dsh-plugin-kit": "0.3.4",
66
62
  "zod": "^4.1.5"
67
63
  },
68
64
  "peerDependencies": {
@@ -78,7 +74,7 @@
78
74
  "@deepseek-ai/cordis": "4.0.4",
79
75
  "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2",
80
76
  "@deepseek-ai/dsh-storage-domain": "0.2.0-rc.2",
81
- "@klarkxy/dsh-model-route": "0.2.3",
77
+ "@klarkxy/dsh-model-route": "0.2.4",
82
78
  "jsdom": "29.1.1"
83
79
  },
84
80
  "publishConfig": {
@@ -86,6 +82,6 @@
86
82
  "registry": "https://registry.npmjs.org/"
87
83
  },
88
84
  "dshRelease": {
89
- "contentHash": "7c85e728f759aaf892513565487c9d552c50697009cd2b2cb7e26d7d3a6a5d53"
85
+ "contentHash": "4479e79ba77c55f0446e01d5ac8a9b2db9079aedde794debb60fea69e85221a0"
90
86
  }
91
87
  }
package/src/config.js CHANGED
@@ -3,24 +3,26 @@ import { validateModelConfig } from './model-route.js';
3
3
  const defaults = Object.freeze({
4
4
  enabled: true,
5
5
  provider: '', model: '', reasoningEffort: '', reviewerPrompt: '',
6
- timeoutMs: 30000, maxInputBytes: 32768, outputTokens: 1024,
7
- humanApprovalTimeoutMs: 60000,
8
- maxReviewsPerTask: 20, consecutiveDenials: 3,
9
- staticReadonly: true,
6
+ timeoutMs: 90000, outputTokens: 4096, maxTranscriptBytes: 48000,
10
7
  });
11
8
  const limits = {
12
- timeoutMs: [100, 120000], maxInputBytes: [512, 32768], outputTokens: [64, 2048],
13
- maxReviewsPerTask: [1, 100], consecutiveDenials: [1, 20],
14
- humanApprovalTimeoutMs: [0, 300000],
9
+ timeoutMs: [1000, 300000], outputTokens: [256, 16384], maxTranscriptBytes: [4096, 200000],
15
10
  };
11
+ // Fields from the 0.2 reviewer-only design. Profiles written by older releases
12
+ // still carry them; they are accepted and ignored so an upgrade does not
13
+ // unload the plugin.
14
+ const RETIRED = new Set(['maxInputBytes', 'humanApprovalTimeoutMs', 'maxReviewsPerTask', 'consecutiveDenials', 'staticReadonly']);
16
15
 
17
16
  /** Immutable, load-time policy. Unknown keys fail rather than silently disabling a control. */
18
17
  export function parseConfig(raw = {}) {
19
18
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('config must be an object');
20
- for (const key of Object.keys(raw)) if (!Object.hasOwn(defaults, key)) throw new Error(`unknown config field: ${key}`);
21
- const c = { ...defaults, ...raw };
19
+ const c = { ...defaults };
20
+ for (const [key, value] of Object.entries(raw)) {
21
+ if (RETIRED.has(key)) continue;
22
+ if (!Object.hasOwn(defaults, key)) throw new Error(`unknown config field: ${key}`);
23
+ c[key] = value;
24
+ }
22
25
  if (typeof c.enabled !== 'boolean') throw new Error('enabled must be a boolean');
23
- if (typeof c.staticReadonly !== 'boolean') throw new Error('staticReadonly must be a boolean');
24
26
  for (const key of ['provider', 'model', 'reasoningEffort', 'reviewerPrompt']) {
25
27
  if (typeof c[key] !== 'string' || c[key].length > 4096) throw new Error(`${key} must be a string`);
26
28
  }
@@ -32,7 +34,6 @@ export function parseConfig(raw = {}) {
32
34
  }
33
35
 
34
36
  // Cordis accepts StandardSchemaV1. No runtime schema dependency or provider SDK is needed.
35
- // This schema intentionally has no volatile form metadata; edits require a plugin reload.
36
37
  export const Config = Object.freeze({ '~standard': Object.freeze({
37
38
  version: 1, vendor: 'dsh-safe-auto',
38
39
  validate(value) {
package/src/control.js CHANGED
@@ -5,6 +5,9 @@ export const CHANNEL = '/dsh-safe-auto';
5
5
  export const REVIEW_FIELDS = ['provider', 'model', 'reasoningEffort', 'reviewerPrompt'];
6
6
  export const SETTINGS_FIELDS = REVIEW_FIELDS;
7
7
  const modes = new Set(['read-only', 'workspace-write', 'danger-full-access']);
8
+ /** The only events that can change a session's effective permission state. The optimistic-lock revision tracks these; ordinary turn activity (steps, tool calls, messages) must not invalidate the client's view. */
9
+ const PERMISSION_EVENTS = new Set(['permission/preset', 'sandbox/mode', 'approval/policy']);
10
+ export const isPermissionEvent = event => PERMISSION_EVENTS.has(event?.type);
8
11
 
9
12
  /** UI writes only reviewer preferences. The enable switch and budgets remain operator-owned profile config. */
10
13
  export function createControl(base, { table, presets, sandboxPolicy, sessions, platform = process.platform, onChange = () => {}, onSettingsChanged = () => {} }) {
@@ -12,6 +15,7 @@ export function createControl(base, { table, presets, sandboxPolicy, sessions, p
12
15
  let tail = Promise.resolve();
13
16
  const lifetime = new AbortController();
14
17
  const selections = new WeakMap();
18
+ const permissionMarks = new WeakMap();
15
19
  let generation = 0;
16
20
  const effective = () => parseConfig({ ...base, ...settings.values });
17
21
  function active(session) {
@@ -19,7 +23,7 @@ export function createControl(base, { table, presets, sandboxPolicy, sessions, p
19
23
  const selected = session && selections.get(session);
20
24
  if (!selected || selected.enabled === false) return false;
21
25
  const events = session.snapshotEvents();
22
- const changed = events.slice(selected.fromSeq).some(e => ['permission/preset', 'sandbox/mode', 'approval/policy'].includes(e.type));
26
+ const changed = events.slice(selected.fromSeq).some(e => PERMISSION_EVENTS.has(e.type));
23
27
  let native;
24
28
  try { native = presets.resolve(presets.current(session)); } catch { native = {}; }
25
29
  if (changed || sessions.get(session.id) !== session || isSubagentSession(session) ||
@@ -47,22 +51,39 @@ export function createControl(base, { table, presets, sandboxPolicy, sessions, p
47
51
  const c = effective();
48
52
  return { available: base.enabled && Boolean(workspaceOption()), platform,
49
53
  policyLimits: { readonly: true, sandbox: 'workspace-write', approval: 'ask', rootSessionsOnly: true,
50
- escalationScope: 'this-call-only', timeoutMs: c.timeoutMs, maxInputBytes: c.maxInputBytes,
51
- outputTokens: c.outputTokens, maxReviewsPerTask: c.maxReviewsPerTask, consecutiveDenials: c.consecutiveDenials,
52
- humanApprovalTimeoutMs: c.humanApprovalTimeoutMs } };
54
+ escalationScope: 'this-call-only', timeoutMs: c.timeoutMs, outputTokens: c.outputTokens,
55
+ maxTranscriptBytes: c.maxTranscriptBytes } };
53
56
  }
54
57
  function settingsView() {
55
58
  const c = effective();
56
59
  return { revision: settings.revision, values: Object.fromEntries(REVIEW_FIELDS.map(k => [k, c[k] ?? ''])), ...policyView() };
57
60
  }
61
+ /** Optimistic-lock watermark: one past the last permission event. Mid-turn conversation activity leaves it untouched, so switching while a turn runs applies at once. */
62
+ function permissionMark(session) {
63
+ const seq = session.seq;
64
+ let cached = permissionMarks.get(session);
65
+ if (!cached || cached.scanned > seq) cached = { scanned: 0, mark: 0 };
66
+ if (cached.scanned < seq) {
67
+ const fresh = session.snapshotEvents(cached.scanned);
68
+ const base = seq - fresh.length;
69
+ for (let i = 0; i < fresh.length; i++) {
70
+ const at = base + i;
71
+ if (at < cached.scanned) continue;
72
+ if (PERMISSION_EVENTS.has(fresh[i]?.type)) cached.mark = at + 1;
73
+ }
74
+ cached.scanned = seq;
75
+ permissionMarks.set(session, cached);
76
+ }
77
+ return cached.mark;
78
+ }
58
79
  function sessionView(session) {
59
80
  const catalog = presets.catalog();
60
81
  const policy = policyView();
61
82
  const available = policy.available;
62
83
  const enabled = active(session);
63
84
  const current = enabled ? 'safe-auto' : presets.current(session);
64
- return { revision: `${session.seq}:${selections.get(session)?.generation ?? 0}`, current, safeAuto: enabled, ...policy,
65
- options: [...catalog.options.filter(o => o.value !== 'safe-auto'), ...(available ? [{ value: 'safe-auto', name: 'Safe Auto', description: 'Workspace Write with bounded per-call review' }] : [])] };
85
+ return { revision: `${permissionMark(session)}:${selections.get(session)?.generation ?? 0}`, current, safeAuto: enabled, ...policy,
86
+ options: [...catalog.options.filter(o => o.value !== 'safe-auto'), ...(available ? [{ value: 'safe-auto', name: 'Safe Auto', description: 'Workspace Write; a reviewer model answers approval requests' }] : [])] };
66
87
  }
67
88
  function getSession(id) {
68
89
  if (typeof id !== 'string' || !id || id.length > 200) throw new Error('Invalid session');
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Small dangerous-command list for commands that run inside the sandbox.
3
+ *
4
+ * Modeled on Codex `shell-command/src/command_safety/is_dangerous_command.rs`
5
+ * and `windows_dangerous_commands.rs` (openai/codex, Apache-2.0): instead of
6
+ * proving commands read-only, flag the few patterns whose damage the
7
+ * workspace sandbox cannot contain, and send only those to review. A match
8
+ * never denies by itself; it turns an ordinary sandboxed call into a reviewed
9
+ * one. Parsing is best-effort tokenization, not a shell parser.
10
+ */
11
+
12
+ const MAX_DEPTH = 8;
13
+ const MAX_COMMAND = 32768;
14
+ const SEGMENT_OPERATORS = new Set([';', '&', '&&', '|', '||', '\n']);
15
+ const WRAPPERS = new Set(['sudo', 'doas', 'command', 'builtin', 'nohup', 'time', 'nice', 'xargs', 'exec']);
16
+ const DELETE_CMDLETS = new Set(['remove-item', 'ri', 'rm', 'del', 'erase', 'rd', 'rmdir']);
17
+ const LAUNCH_CMDLETS = new Set(['start-process', 'start', 'saps', 'invoke-item', 'ii']);
18
+ const BROWSERS = new Set(['chrome', 'msedge', 'firefox', 'iexplore', 'brave', 'opera']);
19
+ const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish']);
20
+ const POWERSHELLS = new Set(['pwsh', 'powershell']);
21
+ const SOFT_SEPARATORS = /[{}()[\],]/;
22
+
23
+ /** Program identity without directories, drive letters or Windows extensions. */
24
+ export function programName(token) {
25
+ return String(token ?? '').split(/[\\/]/).pop().replace(/^[A-Za-z]:/, '').toLowerCase()
26
+ .replace(/\.(?:exe|cmd|bat|com|ps1)$/, '');
27
+ }
28
+
29
+ /**
30
+ * Split one command string into segments of words. Quotes group words and are
31
+ * removed; `$(...)` and backtick substitutions are returned separately so
32
+ * their contents are checked as commands too.
33
+ */
34
+ export function tokenize(command) {
35
+ const segments = [[]];
36
+ const nested = [];
37
+ let word = '', inWord = false;
38
+ const push = () => { if (inWord) segments.at(-1).push(word); word = ''; inWord = false; };
39
+ const cut = () => { push(); if (segments.at(-1).length) segments.push([]); };
40
+ for (let i = 0; i < command.length; i++) {
41
+ const ch = command[i];
42
+ if (ch === "'" || ch === '"') {
43
+ const end = command.indexOf(ch, i + 1);
44
+ const body = end < 0 ? command.slice(i + 1) : command.slice(i + 1, end);
45
+ if (ch === '"') for (const match of body.matchAll(/\$\(([^()]*)\)/g)) nested.push(match[1]);
46
+ word += body; inWord = true; i = end < 0 ? command.length : end;
47
+ continue;
48
+ }
49
+ if (ch === '$' && command[i + 1] === '(') {
50
+ let depth = 0, j = i + 1;
51
+ for (; j < command.length; j++) {
52
+ if (command[j] === '(') depth++;
53
+ else if (command[j] === ')' && --depth === 0) break;
54
+ }
55
+ nested.push(command.slice(i + 2, j));
56
+ word += command.slice(i, j + 1); inWord = true; i = j;
57
+ continue;
58
+ }
59
+ if (ch === '`' && command.indexOf('`', i + 1) > i) {
60
+ const end = command.indexOf('`', i + 1);
61
+ nested.push(command.slice(i + 1, end));
62
+ i = end;
63
+ continue;
64
+ }
65
+ if (ch === '\n' || ch === '\r' || ch === ';') { cut(); continue; }
66
+ if (ch === '&' || ch === '|') {
67
+ if (command[i + 1] === ch) i++;
68
+ // `2>&1` and `>|` are redirections, not command separators.
69
+ if (ch === '&' && /[<>]$/.test(word)) { word += ch; inWord = true; continue; }
70
+ cut(); continue;
71
+ }
72
+ if (/\s/.test(ch)) { push(); continue; }
73
+ word += ch; inWord = true;
74
+ }
75
+ push();
76
+ return { segments: segments.filter(segment => segment.length), nested };
77
+ }
78
+
79
+ const hasUrl = words => words.some(word => /https?:\/\//i.test(word));
80
+ const isForcedRm = args => args.some(arg => arg === '--force' || /^-[A-Za-z]*f[A-Za-z]*$/.test(arg) && !arg.startsWith('--'));
81
+
82
+ /** Inner script of `bash -c`, `pwsh -Command` or `cmd /c`, if this is such an invocation. */
83
+ function innerScript(name, args) {
84
+ if (SHELLS.has(name)) {
85
+ const index = args.findIndex(arg => /^-[A-Za-z]*c[A-Za-z]*$/.test(arg));
86
+ return index >= 0 && args[index + 1] !== undefined ? { script: args[index + 1], shell: 'bash' } : undefined;
87
+ }
88
+ if (POWERSHELLS.has(name)) {
89
+ const index = args.findIndex(arg => /^[-/](?:c|command)$/i.test(arg));
90
+ if (index >= 0) return { script: args.slice(index + 1).join(' '), shell: 'pwsh' };
91
+ const inline = args.find(arg => /^[-/]command:/i.test(arg));
92
+ return inline ? { script: inline.slice(inline.indexOf(':') + 1), shell: 'pwsh' } : undefined;
93
+ }
94
+ if (name === 'cmd') {
95
+ const index = args.findIndex(arg => /^[-/][cr]$/i.test(arg));
96
+ return index >= 0 ? { script: args.slice(index + 1).join(' '), shell: 'cmd' } : undefined;
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ function matchSegment(words, shell, depth) {
102
+ let index = 0;
103
+ // Leading assignments (`FOO=1 cmd`) and transparent wrappers do not change the program.
104
+ while (index < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index])) index++;
105
+ let name = programName(words[index]);
106
+ while (WRAPPERS.has(name) || name === 'env') {
107
+ index++;
108
+ while (index < words.length && (words[index].startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index]))) index++;
109
+ name = programName(words[index]);
110
+ }
111
+ const args = words.slice(index + 1);
112
+ if (!name) return undefined;
113
+ const inner = innerScript(name, args);
114
+ if (inner) return scan(inner.script, inner.shell, depth + 1);
115
+ if (name === 'rm' && isForcedRm(args)) return 'forced-rm';
116
+ if ((name === 'del' || name === 'erase') && args.some(arg => /^\/f$/i.test(arg))) return 'force-delete';
117
+ if ((name === 'rd' || name === 'rmdir') && args.some(arg => /^\/s$/i.test(arg)) && args.some(arg => /^\/q$/i.test(arg))) return 'force-delete';
118
+ if (shell === 'pwsh') {
119
+ // PowerShell script blocks and parentheses can hide the cmdlet inside one segment.
120
+ const atoms = words.flatMap(word => word.split(SOFT_SEPARATORS)).map(atom => atom.trim().toLowerCase()).filter(Boolean);
121
+ if (atoms.some(atom => DELETE_CMDLETS.has(atom)) && atoms.some(atom => atom === '-force' || atom.startsWith('-force:'))) return 'force-delete';
122
+ if (hasUrl(words) && atoms.some(atom => LAUNCH_CMDLETS.has(atom) || atom.includes('shellexecute') || atom.includes('shell.application'))) return 'url-launch';
123
+ }
124
+ if (name === 'start' && hasUrl(args)) return 'url-launch';
125
+ if ((name === 'explorer' || name === 'mshta' || BROWSERS.has(name)) && hasUrl(args)) return 'url-launch';
126
+ if (name === 'rundll32' && args.some(arg => /url\.dll,fileprotocolhandler/i.test(arg)) && hasUrl(args)) return 'url-launch';
127
+ return undefined;
128
+ }
129
+
130
+ function scan(command, shell, depth) {
131
+ if (depth > MAX_DEPTH) return 'nesting-too-deep';
132
+ const { segments, nested } = tokenize(command);
133
+ for (const words of segments) {
134
+ const hit = matchSegment(words, shell, depth);
135
+ if (hit) return hit;
136
+ }
137
+ for (const inner of nested) {
138
+ const hit = scan(inner, shell, depth + 1);
139
+ if (hit) return hit;
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ /**
145
+ * Dangerous-command rule matched by one shell tool command, or undefined.
146
+ * @param {string} command - the exact `command` argument.
147
+ * @param {'bash' | 'pwsh'} tool - the shell tool that will run it.
148
+ * @returns {string | undefined} rule id such as `forced-rm`, `force-delete` or `url-launch`.
149
+ */
150
+ export function dangerousCommand(command, tool) {
151
+ if (typeof command !== 'string' || !command.trim()) return undefined;
152
+ if (command.length > MAX_COMMAND) return 'command-too-long';
153
+ return scan(command, tool === 'pwsh' ? 'pwsh' : 'bash', 0);
154
+ }