dsh-perm-gate 5.2.0 → 5.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/cordis.patch.yml CHANGED
@@ -1,7 +1,7 @@
1
1
  # dsh-perm-gate profile patch.
2
2
  #
3
3
  # Two jobs:
4
- # 1) Add the plugin's own "自动审查" session approval tier to the DSH
4
+ # 1) Add the plugin's own "自动审查" session approval tiers to the DSH
5
5
  # permission picker, and restate the built-in tiers it must not drop.
6
6
  # 2) Insert the plugin itself (host half) into the profile.
7
7
  #
@@ -77,6 +77,21 @@
77
77
  approval: ask
78
78
  name: 自动审查(高权限)
79
79
  description: 只由本插件的审批门过滤 —— 文件修改与子进程网络访问不再受沙箱限制,审批仍逐次生效,但没有系统沙箱兜底。
80
+ permissive-snet:
81
+ sandbox: workspace-write
82
+ approval: ask
83
+ name: 自动审查·机械过滤
84
+ description: 不调用审查模型 —— 确定性机械审查器先筛并列出疑点,剩下的仍然交人工确认。适用于无审查 LLM 可用、或不习惯模型替你拿主意的场景(不常用)。
85
+ permissive-mech:
86
+ sandbox: danger-full-access
87
+ approval: ask
88
+ name: 自动审查·机械直通
89
+ description: 无人确认 —— 确定性机械审查器替代人工:命中不可逆 / 不可沉淀 / 读不懂的载荷即拦截,并把理由回喂主模型,其余照常执行。不调用审查模型,零 token。
90
+ permissive-yolo:
91
+ sandbox: danger-full-access
92
+ approval: ask
93
+ name: 自动审查·YOLO
94
+ description: 无人确认 —— 审查模型逐条替代人工裁决,报硬风险即拦截并回喂理由;模型未配置或未能给出裁决时退回机械处置。永不升级人工。
80
95
  danger-full-access:
81
96
  sandbox: danger-full-access
82
97
  approval: never
package/dsh.plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "dsh-perm-gate",
3
3
  "name": "dsh-perm-gate",
4
- "version": "5.2.0",
4
+ "version": "5.3.0",
5
5
  "description": {
6
6
  "en": "Deterministic-first, fail-closed permission gate for DeepSeek Harness 0.1.2+: a P0 hard-deny -> P1 precise session grant -> P2 static allow/deny rule chain -> P3 optional LLM classifier -> P4 ask pipeline over the tools/pre-execute waterfall, with a standalone CLI dry-run evaluator and an auditable decision log. It only vetoes with deny/ask and always delegates otherwise, and unknown or malformed rules fail loud instead of silently standing the gate down.",
7
7
  "zh": "面向 DeepSeek Harness 0.1.2+ 的确定性优先、失败即拒的权限闸门:在 tools/pre-execute 瀑布上串起 P0 硬拒绝 → P1 精确会话授权 → P2 静态 allow/deny 规则链 → P3 可选 LLM 语义分类 → P4 ask,并附带独立 CLI 干跑评估器与可审计的决策日志。只以 deny/ask 否决、其余一律放行,规则未知或非法时直接报错,绝不静默失效。"
@@ -10,6 +10,17 @@ export type Reversibility =
10
10
  | 'R';
11
11
  /** What the call is aimed at. */
12
12
  export type TargetKind = 'read' | 'write' | 'delete' | 'remote-write' | 'exec-external' | 'unknown';
13
+ /**
14
+ * One script body handed to the reviewers, as the runtime read it. Structural here so
15
+ * this module keeps its dependency-free contract (the fs half lives in `script-review.ts`).
16
+ */
17
+ export interface ScriptContentIn {
18
+ readonly ref: string;
19
+ readonly path: string;
20
+ readonly body: string;
21
+ readonly truncated: boolean;
22
+ readonly unreadable?: string;
23
+ }
13
24
  /** The session's three tier knobs, folded out of the durable session log. */
14
25
  export interface SessionKnobs {
15
26
  readonly permissionPreset?: string;
@@ -28,6 +39,18 @@ export interface AskContextSource {
28
39
  readonly files?: readonly string[];
29
40
  /** The deterministic layer's own conclusion, kept for the human, not the grader. */
30
41
  readonly ruleReason?: string;
42
+ /**
43
+ * The scripts this call runs, with the body the reviewer is given. Read by the node
44
+ * runtime (`script-review.ts`) — this module stays filesystem-free, so the text
45
+ * arrives pre-fetched. Absent means either no script is named or review is off.
46
+ */
47
+ readonly scriptContents?: readonly ScriptContentIn[];
48
+ /**
49
+ * True when *every* script the call names was read, i.e. what will execute is now
50
+ * visible. That is what lifts a script run out of the opaque/`U` tier: the reviewer
51
+ * is no longer judging a filename. One unreadable script keeps the opacity.
52
+ */
53
+ readonly scriptsCovered?: boolean;
31
54
  /** Correlation key, mixed into the event digest. */
32
55
  readonly callId?: string;
33
56
  /** Per-session salt so `inputDigest` cannot be dictionary-reversed or joined across sessions. */
@@ -64,7 +87,24 @@ export interface GraderPayload {
64
87
  readonly reason: string;
65
88
  readonly target_resolvable: boolean;
66
89
  readonly workspace_root?: string;
90
+ /**
91
+ * The bodies of the scripts this call runs, redacted like any other egress text.
92
+ * Without them the grader judges a filename and can say `safe` about code it never
93
+ * saw; with them the answer is about what will execute.
94
+ */
95
+ readonly script_contents?: readonly GraderScript[];
67
96
  }
97
+ /** One attached script, as it leaves the host. */
98
+ export interface GraderScript {
99
+ readonly ref: string;
100
+ readonly path: string;
101
+ /** Absent when `unreadable` names why there is no body. */
102
+ readonly body?: string;
103
+ readonly truncated?: boolean;
104
+ readonly unreadable?: string;
105
+ }
106
+ /** Budget for one script body on the wire: a real script, one round-trip. */
107
+ export declare const GRADER_SCRIPT_BODY_CHARS = 20000;
68
108
  /**
69
109
  * Project for egress to the grader. Tier knobs and the rule layer's own verdict
70
110
  * are stripped by design — see the module comment.
@@ -136,6 +176,14 @@ export declare const DOUBT_COPY: {
136
176
  readonly zh: "会执行工作区之外的代码,本次调用无法静态看清它做什么";
137
177
  readonly en: "it runs code from outside the workspace, which this call cannot inspect";
138
178
  };
179
+ readonly 'script-attached': {
180
+ readonly zh: "它要跑的脚本正文已读出来、随本次审查附上(模型看到的和你看到的是同一份)";
181
+ readonly en: "the script it runs was read and attached to this review — the model and you see the same text";
182
+ };
183
+ readonly 'script-unreadable': {
184
+ readonly zh: "脚本正文没读到(缺失/过大/二进制),没有人看见真正要执行的代码";
185
+ readonly en: "a script body could not be read (missing, over budget, or binary) — nobody has seen the code that will run";
186
+ };
139
187
  readonly 'outside-paths': {
140
188
  readonly zh: "涉及工作区之外的路径";
141
189
  readonly en: "it touches paths outside the workspace";
@@ -171,7 +219,7 @@ export declare function askDoubtKinds(ctx: AskContext, extra?: {
171
219
  * The event still carries the command separately (`humanCommand`) because the
172
220
  * review list has no host-provided command block next to it.
173
221
  */
174
- export declare function humanBrief(ctx: AskContext, lang: 'zh' | 'en', kinds?: ("write" | "delete" | "remote-write" | "exec-external" | "never-learn" | "no-rule" | "opaque" | "irreversible" | "outside-paths" | "model-flagged")[]): string;
222
+ export declare function humanBrief(ctx: AskContext, lang: 'zh' | 'en', kinds?: ("write" | "delete" | "remote-write" | "exec-external" | "never-learn" | "no-rule" | "opaque" | "irreversible" | "script-attached" | "script-unreadable" | "outside-paths" | "model-flagged")[]): string;
175
223
  /** The redacted command alone, for the channels that have nowhere else to show it. */
176
224
  export declare function humanCommand(ctx: AskContext): string;
177
225
  /** What goes on the durable event, for later attribution. */
@@ -124,6 +124,48 @@ function unseenScript(text, root) {
124
124
  }
125
125
  return text.match(/(?:^|[\s"'`(])\.\.[\/][^\s"']*\.(?:py|mjs|js|sh)\b/i) !== null;
126
126
  }
127
+ /**
128
+ * The statements a script body actually contains, for the deterministic nets.
129
+ *
130
+ * Judging a whole file as one string is too coarse: `irreversible.ts` matches a delete
131
+ * verb, a force flag and a home-variable *anywhere* in the text, and a 300-line build
132
+ * script is full of all three in unrelated places (`cd "$HOME"` at the top,
133
+ * `rm -rf ./node_modules` at the bottom). So the body is cut into statements (newline
134
+ * and `;` are the shell separators) and each is judged on its own.
135
+ *
136
+ * But the incident shape is a **block**, and one line of it says nothing:
137
+ * `for f in ~/.*; do` / `rm -rf "$f"` / `done`, or `if (Test-Path $home) {` on its own
138
+ * line. So a line that *ends* in a continuation (`do`, `then`, `{`, `&&`, `;`, a
139
+ * trailing backslash) is joined with the lines it hands off to, up to a small depth.
140
+ * Joining is keyed on the continuation, not on a fixed window: an arbitrary two-line
141
+ * window is exactly the collision the split avoids (`cd "$HOME"` followed by
142
+ * `rm -rf ./build`).
143
+ *
144
+ * The cost of an over-match here is a human ask, never a silent deny: this feeds the
145
+ * `I` tier, which the model cannot clear.
146
+ */
147
+ const LINE_CONTINUATION = /(?:&\s*&\s*$|\|\s*\|\s*$|;\s*(?:do|then|fi|else)\s*$|[{}]\s*$|;\s*$|\\\s*$|\b(?:do|then|else|begin)\s*$)/;
148
+ function bodyStatements(bodies) {
149
+ const out = [];
150
+ for (const body of bodies) {
151
+ const lines = body.split('\n');
152
+ for (let i = 0; i < lines.length; i += 1) {
153
+ let group = lines[i] ?? '';
154
+ // A block opener swallows the lines it governs (max 3 more), so a loop or an `if`
155
+ // wrapping a destructive statement is judged as the one act it is.
156
+ for (let j = 1; j <= 3 && i + j < lines.length && LINE_CONTINUATION.test(group); j += 1) {
157
+ group = `${group}\n${lines[i + j] ?? ''}`;
158
+ }
159
+ for (const stmt of [group, ...group.split(/[;\n]+/)]) {
160
+ if (stmt.trim() !== '')
161
+ out.push(stmt);
162
+ }
163
+ // The individual statements of the swallowed lines are still judged separately on
164
+ // their own turn, so nothing is *only* seen as part of a block.
165
+ }
166
+ }
167
+ return out;
168
+ }
127
169
  /** Classify what the call targets, and whether its payload is inspectable. */
128
170
  function inspect(source) {
129
171
  const text = (source.commandText ?? '').trim();
@@ -132,10 +174,14 @@ function inspect(source) {
132
174
  // hiding `Remove-Item -Recurse -Force C:\` is irreversible even though the
133
175
  // visible text is opaque, and the human must see both facts.
134
176
  const decoded = executableVariants(normalizeCommandText(text));
135
- const irreversible = irreversibleReason(cmd) !== undefined
136
- || decoded.some((variant) => irreversibleReason(variant) !== undefined);
137
- const unlearnable = neverClearReason(cmd) !== undefined
138
- || decoded.some((variant) => neverClearReason(variant) !== undefined);
177
+ // A script run is judged on the file too: `bash ./clean-home.sh` is a filename, the
178
+ // *body* is the operation. This is the clause that closes the incident the whole
179
+ // feature exists for — a script that wipes the profile while the command line says
180
+ // nothing dangerous, judged deterministically so no grader `safe` can clear it.
181
+ const read = (source.scriptContents ?? []).filter((c) => c.unreadable === undefined && c.body !== '');
182
+ const judged = [cmd, ...decoded, ...bodyStatements(read.map((c) => `${c.ref}\n${c.body}`))];
183
+ const irreversible = judged.some((t) => irreversibleReason(t) !== undefined);
184
+ const unlearnable = judged.some((t) => neverClearReason(t) !== undefined);
139
185
  if (cmd.trim() === '{}') {
140
186
  // A tool invoked with no arguments at all: nothing to read, nothing to trust.
141
187
  return { kind: 'unknown', resolvable: false, readOnly: false, irreversible, unlearnable };
@@ -156,7 +202,12 @@ function inspect(source) {
156
202
  })();
157
203
  const first = text.split(/\s/)[0] ?? '';
158
204
  const runner = /\b(?:python|node|pwsh|powershell|bash|sh|zsh|npx|deno|ruby|perl)\b/i.test(cmd);
159
- const execExternal = runner && (outsideWorkspace(cmd, source.workspaceRoot) || unseenScript(cmd, source.workspaceRoot));
205
+ // "Cannot see the script" is what the U tier existed for. Once every script the call
206
+ // names has been read and attached, the reviewer *can* see it — so the call stops
207
+ // being unknowable and becomes an ordinary graded call (whose body the nets have just
208
+ // judged above). One unreadable file keeps the whole call opaque.
209
+ const covered = source.scriptsCovered === true && read.length > 0;
210
+ const execExternal = runner && !covered && (outsideWorkspace(cmd, source.workspaceRoot) || unseenScript(cmd, source.workspaceRoot));
160
211
  const unnamed = text !== '' && unnamedOperation(text);
161
212
  const opaque = OPAQUE.test(cmd) || execExternal;
162
213
  if (irreversible)
@@ -216,18 +267,28 @@ export function buildAskContext(source) {
216
267
  reversibility: grade(seen),
217
268
  };
218
269
  }
270
+ /** Budget for one script body on the wire: a real script, one round-trip. */
271
+ export const GRADER_SCRIPT_BODY_CHARS = 20_000;
219
272
  /**
220
273
  * Project for egress to the grader. Tier knobs and the rule layer's own verdict
221
274
  * are stripped by design — see the module comment.
222
275
  */
223
276
  export function toGraderPayload(ctx) {
224
277
  const root = ctx.workspaceRoot;
278
+ const scripts = (ctx.scriptContents ?? []).map((c) => ({
279
+ ref: c.ref,
280
+ path: c.path,
281
+ ...(c.unreadable === undefined
282
+ ? { body: redactCommandText(c.body, { maxChars: GRADER_SCRIPT_BODY_CHARS }), truncated: c.truncated }
283
+ : { unreadable: c.unreadable }),
284
+ }));
225
285
  return {
226
286
  tool: ctx.tool,
227
287
  args: redactArgs(ctx.args ?? {}),
228
288
  reason: ctx.ruleReason ?? '',
229
289
  target_resolvable: ctx.targetResolvable,
230
290
  ...(root === undefined || root === '' ? {} : { workspace_root: root }),
291
+ ...(scripts.length === 0 ? {} : { script_contents: scripts }),
231
292
  };
232
293
  }
233
294
  /**
@@ -285,6 +346,11 @@ export function humanCardLine(ctx, maxFiles = 3) {
285
346
  bits.push('payload=opaque');
286
347
  if (ctx.unlearnable)
287
348
  bits.push('never-learn');
349
+ const scripts = ctx.scriptContents ?? [];
350
+ if (scripts.length > 0) {
351
+ const unread = scripts.filter((c) => c.unreadable !== undefined).length;
352
+ bits.push(unread === 0 ? `scripts=${scripts.length}:body-attached` : `scripts=${scripts.length}:unread=${unread}`);
353
+ }
288
354
  if (card.fileCount > 0) {
289
355
  const shown = card.files.length;
290
356
  bits.push(shown < card.fileCount ? `files=${shown}/${card.fileCount}` : `files=${card.fileCount}`);
@@ -334,6 +400,14 @@ export const DOUBT_COPY = {
334
400
  zh: '会执行工作区之外的代码,本次调用无法静态看清它做什么',
335
401
  en: 'it runs code from outside the workspace, which this call cannot inspect',
336
402
  },
403
+ 'script-attached': {
404
+ zh: '它要跑的脚本正文已读出来、随本次审查附上(模型看到的和你看到的是同一份)',
405
+ en: 'the script it runs was read and attached to this review — the model and you see the same text',
406
+ },
407
+ 'script-unreadable': {
408
+ zh: '脚本正文没读到(缺失/过大/二进制),没有人看见真正要执行的代码',
409
+ en: 'a script body could not be read (missing, over budget, or binary) — nobody has seen the code that will run',
410
+ },
337
411
  'outside-paths': {
338
412
  zh: '涉及工作区之外的路径',
339
413
  en: 'it touches paths outside the workspace',
@@ -395,6 +469,16 @@ export function askDoubtKinds(ctx, extra = {}) {
395
469
  }
396
470
  if (extra.modelFlagged === true)
397
471
  kinds.push('model-flagged');
472
+ // Whether the code that will run was actually looked at is a fact the human decides
473
+ // with, and it is not implied by any other clause: an attached body means the
474
+ // judgment covered it, an unreadable one means nothing ever will.
475
+ const scripts = ctx.scriptContents ?? [];
476
+ if (scripts.length > 0) {
477
+ if (scripts.some((c) => c.unreadable !== undefined))
478
+ kinds.push('script-unreadable');
479
+ else
480
+ kinds.push('script-attached');
481
+ }
398
482
  if (ctx.ruleReason !== undefined && /no rule matched|default action/i.test(ctx.ruleReason))
399
483
  kinds.push('no-rule');
400
484
  const root = ctx.workspaceRoot;