@klarkxy/dsh-safe-auto 0.3.0 → 0.4.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/README.md CHANGED
@@ -18,7 +18,7 @@ Replace `web` with your profile name and restart the profile.
18
18
 
19
19
  Pick **Safe Auto** from the permission menu next to the chat input. It applies only to that session; choosing any other level turns it off. Nothing happens automatically until you pick it.
20
20
 
21
- Under **Plugins → Safe Auto** you can choose the reviewer model (empty follows the current session model), its reasoning effort, and extra review rules.
21
+ Under **Plugins → Safe Auto** you can choose the reviewer model (empty follows the current session model), its reasoning effort, and extra review rules. Changes made elsewhere update this page on their own; Model Hub is not required.
22
22
 
23
23
  If the model list cannot load, or some providers fail, use **Retry** in the settings panel. Retrying keeps your reviewer settings and unsaved draft.
24
24
 
@@ -32,9 +32,9 @@ Reading files, searching, building, and testing need no approval; they are not i
32
32
 
33
33
  ## How the reviewer decides
34
34
 
35
- - The reviewer sees the conversation, the working directory, and the complete arguments of the action. Only your own messages count as authorization; the agent's explanations, tool output, and file contents are treated as evidence.
35
+ - The reviewer sees the conversation, the current working directory, the sandbox workspace boundary, and the complete arguments of the action. Only your own messages count as authorization; the agent's explanations, tool output, and file contents are treated as evidence.
36
36
  - Low- and medium-risk actions are approved. High-risk actions are approved only when you clearly asked for them and the scope is narrow. Critical actions, such as sending secrets to an external destination or broad irreversible deletion, are always denied.
37
- - If the review times out, fails, or no model is available, the request is not approved, and the agent is told this was a review failure: it may retry once or ask you.
37
+ - If the review times out, returns missing or unknown verdict fields, fails, or no model is available, the request is not approved, and the agent is told this was a review failure: it may retry once or ask you.
38
38
  - After 3 consecutive denials, or 10 denials among the last 50 reviews in one turn, automatic approval pauses until your next message.
39
39
 
40
40
  ## Limits
package/README.zh-CN.md CHANGED
@@ -18,7 +18,7 @@ dsh plugin --profile web add @klarkxy/dsh-safe-auto
18
18
 
19
19
  在输入框旁的权限菜单中选择「安全自动」。它只对当前会话生效,选回其他档位即关闭;选择之前不会有任何自动行为。
20
20
 
21
- 在「插件 → 安全自动」中可以选择审核模型(留空跟随当前会话模型)、思考强度,以及额外的审核要求。
21
+ 在「插件 → 安全自动」中可以选择审核模型(留空跟随当前会话模型)、思考强度,以及额外的审核要求。设置在别处改动时,这个页面会自行更新,不需要安装模型枢纽。
22
22
 
23
23
  模型列表加载失败或部分提供商不可用时,可在设置面板点击「重试」。重试会保留审核配置和未保存的草稿。
24
24
 
@@ -32,9 +32,9 @@ dsh plugin --profile web add @klarkxy/dsh-safe-auto
32
32
 
33
33
  ## 审核怎么判断
34
34
 
35
- - 审核模型会看到对话记录、工作目录和这次操作的完整参数。只有你发的消息算授权,智能体的说明、工具输出和文件内容都只作参考。
35
+ - 审核模型会看到对话记录、当前工作目录、沙箱工作区边界和这次操作的完整参数。只有你发的消息算授权,智能体的说明、工具输出和文件内容都只作参考。
36
36
  - 低风险和中风险的操作放行;高风险只在你明确要求并且范围有限时放行;严重风险(例如把密钥发到外部、大范围不可逆删除)一律拒绝。
37
- - 审核超时、出错或模型不可用时,这次请求不放行,智能体会被告知这是审核失败,可以重试一次或者来问你。
37
+ - 审核超时、返回字段缺失或含未知值、出错或模型不可用时,这次请求不放行,智能体会被告知这是审核失败,可以重试一次或者来问你。
38
38
  - 同一轮对话里连续拒绝 3 次,或最近 50 次拒绝了 10 次,本轮就暂停自动审批,等你发下一条消息。
39
39
 
40
40
  ## 限制
package/dsh.plugin.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "id": "@klarkxy/dsh-safe-auto",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "main": "src/index.js",
5
5
  "description": "Safe Auto: a reviewer model answers native approval requests, Codex auto-review style; only a pass approves that one call.",
6
6
  "engines": {
7
- "dsh": ">=0.2.0-rc.2"
7
+ "dsh": ">=0.2.1-rc.1"
8
8
  },
9
9
  "contributes": {
10
10
  "tools": [],
package/lib/client.js CHANGED
@@ -1414,25 +1414,18 @@ function createPluginReadGate(now = Date.now) {
1414
1414
  }
1415
1415
  //#endregion
1416
1416
  //#region ../dsh-plugin-kit/lib/contracts.js
1417
- /**
1418
- * Remote contribution for model-settings change notifications.
1419
- * Provided by `@klarkxy/dsh-model-hub` (`ModelSettingsChangesController`);
1420
- * subscribers degrade silently when that package is not installed.
1421
- * The stream carries invalidations only — `ready` is never a settings snapshot.
1422
- */
1423
- const MODEL_SETTINGS_CHANGES_REMOTE = {
1424
- package: "@klarkxy/dsh-model-hub",
1425
- descriptors: [{
1426
- id: "modelSettingsChanges/watch",
1427
- service: "modelSettingsChangesController",
1428
- namespace: "modelSettingsChanges",
1429
- method: "watch",
1430
- mode: "stream",
1431
- invocation: { kind: "direct" },
1432
- parameters: [],
1433
- cancellation: { parameter: "signal" }
1434
- }]
1435
- };
1417
+ /** `@klarkxy/dsh-git-commit` becomes `KlarkxyDshGitCommitModelSettingsChanges`. */
1418
+ function modelSettingsChangesIdentity(packageName) {
1419
+ const scoped = /^@([^/]+)\/(.+)$/.exec(packageName);
1420
+ const parts = (scoped ? `${scoped[1]}/${scoped[2]}` : packageName).split(/[^A-Za-z0-9]+/).filter(Boolean);
1421
+ if (!parts.length) throw new Error("Model settings package name has no RPC segment");
1422
+ const segment = `${parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("")}ModelSettingsChanges`;
1423
+ if (!/^[A-Z][A-Za-z0-9]*$/.test(segment)) throw new Error(`Model settings sync name is not an RPC segment: ${segment}`);
1424
+ return {
1425
+ service: segment,
1426
+ namespace: segment
1427
+ };
1428
+ }
1436
1429
  //#endregion
1437
1430
  //#region ../dsh-plugin-kit/lib/model-settings-client.js
1438
1431
  /** This stream carries invalidations only; ready is never a settings snapshot. */
@@ -1447,17 +1440,44 @@ function parseModelSettingsChangeFrame(value) {
1447
1440
  };
1448
1441
  throw new Error("Invalid model settings change frame");
1449
1442
  }
1450
- MODEL_SETTINGS_CHANGES_REMOTE.package, MODEL_SETTINGS_CHANGES_REMOTE.descriptors.map((descriptor) => ({
1451
- ...descriptor,
1452
- result: {
1453
- mode: "strict",
1454
- typeSymbol: "ModelSettings.ChangeFrame",
1455
- create: () => ({ parse: parseModelSettingsChangeFrame })
1456
- }
1457
- }));
1443
+ /** One owner's contribution. Each package mounts only its own endpoint. */
1444
+ function modelSettingsChangesRemote(packageName, identity = modelSettingsChangesIdentity(packageName)) {
1445
+ return {
1446
+ package: packageName,
1447
+ descriptors: [{
1448
+ id: `${identity.namespace}/watch`,
1449
+ service: identity.service,
1450
+ namespace: identity.namespace,
1451
+ method: "watch",
1452
+ mode: "stream",
1453
+ invocation: { kind: "direct" },
1454
+ parameters: [],
1455
+ cancellation: { parameter: "signal" },
1456
+ result: {
1457
+ mode: "strict",
1458
+ typeSymbol: "ModelSettings.ChangeFrame",
1459
+ create: () => ({ parse: parseModelSettingsChangeFrame })
1460
+ }
1461
+ }]
1462
+ };
1463
+ }
1464
+ /** Mount from a synchronous apply. Disposal waits for an in-flight mount and
1465
+ * then withdraws it, so the contribution cannot outlive the plugin. */
1466
+ function mountModelSettingsChanges(ctx, packageName) {
1467
+ const contribution = modelSettingsChangesRemote(packageName);
1468
+ ctx.inject(["remote"], (injected) => {
1469
+ const scope = injected;
1470
+ const mounting = scope.remote.$mount(contribution);
1471
+ mounting.catch(() => {});
1472
+ scope.effect(() => async () => {
1473
+ await (await mounting.catch(() => void 0))?.();
1474
+ });
1475
+ });
1476
+ }
1458
1477
  /** Native RemoteStream owns carrier reconnects; no timers or browser transport.
1459
1478
  * A factory shares one logical stream while at least one subscriber remains. */
1460
- function createModelSettingsSubscriber(ctx, namespace) {
1479
+ function createModelSettingsSubscriber(ctx, packageName, identity = modelSettingsChangesIdentity(packageName), accept = (ns) => ns === packageName) {
1480
+ const endpoint = identity;
1461
1481
  const listeners = /* @__PURE__ */ new Set();
1462
1482
  let injection;
1463
1483
  let current;
@@ -1483,12 +1503,18 @@ function createModelSettingsSubscriber(ctx, namespace) {
1483
1503
  };
1484
1504
  const start = () => {
1485
1505
  const life = ++lifecycle;
1486
- injection = ctx.inject(["remote", "remote.modelSettingsChanges"], (injected) => {
1506
+ injection = ctx.inject(["remote", `remote.${endpoint.namespace}`], (injected) => {
1487
1507
  if (life !== lifecycle || !listeners.size) return;
1488
1508
  const scope = injected;
1509
+ const service = scope.remote[endpoint.namespace];
1510
+ if (!service || !("watch" in service) || !service.watch) {
1511
+ report("Model settings sync service unavailable");
1512
+ return;
1513
+ }
1514
+ const watch = service.watch;
1489
1515
  const stream = scope.remote.$stream({
1490
1516
  name: "Model settings sync",
1491
- open: (signal) => scope.remote.modelSettingsChanges.watch(signal),
1517
+ open: (signal) => watch(signal),
1492
1518
  ended: (accepted) => /* @__PURE__ */ new Error(`Model settings sync stream ended ${accepted ? "after" : "before"} ready; refresh or reopen the page`)
1493
1519
  });
1494
1520
  const session = {
@@ -1523,7 +1549,7 @@ function createModelSettingsSubscriber(ctx, namespace) {
1523
1549
  invalidate();
1524
1550
  } else {
1525
1551
  if (!ready) throw new Error("Model settings sync change arrived before ready");
1526
- if (namespace === "*" || frame.ns === namespace) invalidate();
1552
+ if (accept(frame.ns)) invalidate();
1527
1553
  }
1528
1554
  }
1529
1555
  } catch (error) {
@@ -2249,6 +2275,7 @@ function SettingsPanel({ view, api, locale, initialSettings = null, initialCatal
2249
2275
  }, t("saved")));
2250
2276
  }
2251
2277
  function apply(ctx) {
2278
+ mountModelSettingsChanges(ctx, BUNDLE);
2252
2279
  const settingsSubscriber = createModelSettingsSubscriber(ctx, BUNDLE);
2253
2280
  const call = (endpoint, payload) => unwrapRpc((...args) => ctx.connection.rpc.call(...args), endpoint, payload);
2254
2281
  const api = Object.freeze({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klarkxy/dsh-safe-auto",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
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",
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "engines": {
30
30
  "node": "^22.19.0 || >=24.0.0",
31
- "dsh": ">=0.2.0-rc.2"
31
+ "dsh": ">=0.2.1-rc.1"
32
32
  },
33
33
  "license": "SEE LICENSE IN LICENSE",
34
34
  "repository": {
@@ -58,30 +58,38 @@
58
58
  }
59
59
  },
60
60
  "dependencies": {
61
- "@klarkxy/dsh-plugin-kit": "0.3.4",
61
+ "@klarkxy/dsh-plugin-kit": "0.4.0",
62
62
  "zod": "^4.1.5"
63
63
  },
64
64
  "peerDependencies": {
65
65
  "@deepseek-ai/cordis": ">=4.0.4",
66
- "@deepseek-ai/dsh-storage-domain": ">=0.2.0-rc.2"
66
+ "@deepseek-ai/dsh-storage-domain": ">=0.2.0-rc.2",
67
+ "@deepseek-ai/dsh-working-directory": ">=0.2.1-rc.1"
67
68
  },
68
69
  "peerDependenciesMeta": {
69
70
  "@deepseek-ai/dsh-storage-domain": {
70
71
  "optional": true
72
+ },
73
+ "@deepseek-ai/cordis": {
74
+ "optional": true
75
+ },
76
+ "@deepseek-ai/dsh-working-directory": {
77
+ "optional": true
71
78
  }
72
79
  },
73
80
  "devDependencies": {
74
- "@deepseek-ai/cordis": "4.0.4",
75
- "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2",
76
- "@deepseek-ai/dsh-storage-domain": "0.2.0-rc.2",
77
- "@klarkxy/dsh-model-route": "0.2.4",
78
- "jsdom": "29.1.1"
81
+ "@deepseek-ai/cordis": "4.0.5",
82
+ "@deepseek-ai/dsh-client-ui-primitives": "0.2.1-rc.1",
83
+ "@deepseek-ai/dsh-storage-domain": "0.2.1-rc.1",
84
+ "@klarkxy/dsh-model-route": "0.2.5",
85
+ "jsdom": "29.1.1",
86
+ "@deepseek-ai/dsh-working-directory": "0.2.1-rc.1"
79
87
  },
80
88
  "publishConfig": {
81
89
  "access": "public",
82
90
  "registry": "https://registry.npmjs.org/"
83
91
  },
84
92
  "dshRelease": {
85
- "contentHash": "4479e79ba77c55f0446e01d5ac8a9b2db9079aedde794debb60fea69e85221a0"
93
+ "contentHash": "fbbaa607cab540cfc58b7d99946f5a0954f3b4c51b9ceb90e452fb3fd0522bed"
86
94
  }
87
95
  }
package/src/index.js CHANGED
@@ -8,7 +8,7 @@ import { resolveReviewRoute } from './model-route.js';
8
8
  export { Config } from './config.js';
9
9
 
10
10
  export const name = 'dsh-safe-auto';
11
- export const inject = ['sandboxPolicy'];
11
+ export const inject = ['sandboxPolicy', 'workingDirectory'];
12
12
 
13
13
  /*
14
14
  * Safe Auto routes native approval requests to a reviewer model, following
@@ -199,7 +199,7 @@ export function apply(ctx, raw = {}) {
199
199
  const agent = req.agent, callId = String(req.callId), token = exec.token;
200
200
  const environment = () => {
201
201
  const sandbox = ctx.sandboxPolicy.resolve({ session });
202
- return JSON.stringify({ platform: process.platform, cwd: session.header?.cwd,
202
+ return JSON.stringify({ platform: process.platform, cwd: ctx.workingDirectory.get(session),
203
203
  workspaceRoot: sandbox?.workspaceRoot, sandbox: sandbox?.mode });
204
204
  };
205
205
  let rawAction, rawEnvironment, revision, input;
@@ -34,8 +34,6 @@ export function resolveReviewRoute(config, owner) {
34
34
  ...(config.reasoningEffort ? { reasoningEffort: config.reasoningEffort } : {}) });
35
35
  }
36
36
 
37
- export const sameRoute = (a, b) => JSON.stringify(a) === JSON.stringify(b);
38
-
39
37
  function usageTotal(usage) {
40
38
  if (!usage || typeof usage !== 'object') return undefined;
41
39
  if (Number.isSafeInteger(usage.totalTokens) && usage.totalTokens >= 0) return usage.totalTokens;
package/src/reviewer.js CHANGED
@@ -65,12 +65,17 @@ export function parseVerdict(text) {
65
65
  if (end <= start) throw new Error('INVALID_REVIEW_RESPONSE');
66
66
  let value;
67
67
  try { value = JSON.parse(text.slice(start, end + 1)); } catch { throw new Error('INVALID_REVIEW_RESPONSE'); }
68
- if (!value || typeof value !== 'object' || !['allow', 'deny'].includes(value.outcome)) throw new Error('INVALID_REVIEW_RESPONSE');
69
- const risk = RISKS.includes(value.risk_level) ? value.risk_level : undefined;
70
- const authorization = AUTHORIZATIONS.includes(value.user_authorization) ? value.user_authorization : undefined;
71
- const rationale = typeof value.rationale === 'string' ? value.rationale.trim().slice(0, 600) : '';
72
- // `critical` never auto-approves, whatever the stated outcome.
73
- const outcome = risk === 'critical' ? 'deny' : value.outcome;
68
+ if (!value || typeof value !== 'object' || Array.isArray(value)
69
+ || !['allow', 'deny'].includes(value.outcome)
70
+ || !RISKS.includes(value.risk_level) || !AUTHORIZATIONS.includes(value.user_authorization)
71
+ || typeof value.rationale !== 'string' || !value.rationale.trim()) throw new Error('INVALID_REVIEW_RESPONSE');
72
+ const risk = value.risk_level;
73
+ const authorization = value.user_authorization;
74
+ const rationale = value.rationale.trim().slice(0, 600);
75
+ // Retain model denials (including malicious injection and broad scope), but
76
+ // enforce the policy's authorization floor even if the model says allow.
77
+ const outcome = risk === 'critical' || (risk === 'high' && !['medium', 'high'].includes(authorization))
78
+ ? 'deny' : value.outcome;
74
79
  return Object.freeze({ outcome, risk, authorization, rationale });
75
80
  }
76
81
 
package/src/ui-host.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { createControl, CHANNEL, REVIEW_FIELDS } from './control.js';
2
2
  import { registerHostRpc } from '@klarkxy/dsh-plugin-kit/host-rpc';
3
+ import { modelSettingsChangesIdentity } from '@klarkxy/dsh-plugin-kit/contracts';
4
+ import { createModelSettingsChanges } from '@klarkxy/dsh-plugin-kit/model-settings-changes';
3
5
  import { registerModelSettings, notifyModelSettingsChanged } from '@klarkxy/dsh-plugin-kit/model-settings';
4
6
 
5
7
  export const name = 'dsh-safe-auto-ui-host';
@@ -21,6 +23,7 @@ export function registerControlRoute(ctx, call) {
21
23
  }
22
24
 
23
25
  export async function apply(ctx) {
26
+ createModelSettingsChanges(ctx, modelSettingsChangesIdentity('@klarkxy/dsh-safe-auto'), '@klarkxy/dsh-safe-auto');
24
27
  // Loaded only by the separate UI row; the review plugin keeps its minimal dependencies.
25
28
  const [{ defineDomain, domainTable }, { z }] = await Promise.all([import('@deepseek-ai/dsh-storage-domain'), import('zod')]);
26
29
  // No strict(): rows saved by 0.1.x carry retired keys (fastProvider, approvalReview, …),