dsh-perm-gate 5.2.0 → 5.3.1

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.zh.md CHANGED
@@ -56,7 +56,7 @@
56
56
  严格 fail-closed:P0 永不因 grant / 规则 / 分类器 / 人工而放行。
57
57
 
58
58
  <!-- host-compat:begin — generated by scripts/sync-hosts.mjs from scripts/hosts.mjs; DO NOT EDIT -->
59
- - **支持的 DSH 宿主:** `0.1.0-rc.2` `0.1.0-rc.3` `0.1.0-rc.6` `0.1.0-rc.7` `0.1.0-rc.8` `0.1.1-rc.1` `0.1.1-rc.2` `0.1.2-rc.1` `0.1.5-rc.1` `0.1.5-rc.2` `0.1.5-rc.3` `0.1.7-rc.1` `0.1.7-rc.2` `0.2.0-rc.1` `0.2.0-rc.2`(覆盖 0.1.0 / 0.1.1 / 0.1.2 / 0.1.5 / 0.1.7 / 0.2.0 六条线共 15 个 rc)
59
+ - **支持的 DSH 宿主:** `0.1.0-rc.2` `0.1.0-rc.3` `0.1.0-rc.6` `0.1.0-rc.7` `0.1.0-rc.8` `0.1.1-rc.1` `0.1.1-rc.2` `0.1.2-rc.1` `0.1.5-rc.1` `0.1.5-rc.2` `0.1.5-rc.3` `0.1.7-rc.1` `0.1.7-rc.2` `0.2.0-rc.1` `0.2.0-rc.2` `0.2.1-rc.1`(覆盖 0.1.0 / 0.1.1 / 0.1.2 / 0.1.5 / 0.1.7 / 0.2.0 / 0.2.1 七条线共 16 个 rc)
60
60
  - **运行时已验证:** `0.2.0-rc.2`(证据见 `.compat-results/`)
61
61
  <!-- host-compat:end -->
62
62
 
@@ -232,7 +232,32 @@ outside the design set get none*。`permissive` 是内置值,所以「自动
232
232
  alwaysConfirm: false # 一律逐次 ask;允许控件附带重复允许/迁白名单按钮
233
233
  trustEscalation: true # 门禁已放行的调用,其自身的沙箱提权免确认
234
234
  llmAssist: false # 先由 LLM 分类裁决;ask/无分类器时回退到人工
235
+ yolo: false # 无人值守:永不问人,危险才拦、其余放行
236
+ rulesFallback: false # 无可用审查模型 => 只按确定性规则网裁定
237
+ scriptReview: true # 把调用所跑脚本的正文随审查一并附上
238
+ credentialPaths: [] # 长尾凭据路径,按 `.ssh` 同档保护
239
+ denyKeywordsExtra: [] # 追加黑名单词(不替换预置表)
240
+ extraContentArgKeys: [] # 哪些参数是文档而非操作
241
+ extraOperationArgKeys: [] # 哪些参数确实是会被执行的代码(撞名时本表胜)
242
+ safenetPass: true # permissive-mech 档的实现存在(机械接管 ask)
243
+ safenetAsk: false # permissive-snet 档的实现存在(机械过滤,仍然问人)
235
244
  ```
245
+ 三颗开关,每颗只管插件自己贡献给权限下拉的一个预设——所以一颗开关只改它那一档的行为,不会串到
246
+ 别的档;把实现关掉,那一档就退回普通人工流,而不是偷偷变成另一个档。
247
+
248
+ | 开关 | 档 | 谁定 ask | 问人 | 叫模型 |
249
+ | --- | --- | --- | --- | --- |
250
+ | `permissive` | `permissive` / `permissive-full` | 机械先筛 + 模型精化,剩下交给人 | 会 | 由 `llmAssist` 决定 |
251
+ | `safenetAsk` | `permissive-snet` | 交给人,并列出机械疑点 | **会** | **一次都不叫** |
252
+ | `safenetPass` | `permissive-mech` | 机械独判:点名的拦+回喂,其余放行 | 不会 | **一次都不叫** |
253
+ | `yolo` | `permissive-yolo` | 模型替人裁决:risky 拦+回喂,其余放行 | 永不 | 每个残局 |
254
+
255
+ `safenetPass` 就是 `approval: never` 的「有审查版」(那一档无脑直通、没人判定),也是 yolo 拿掉
256
+ 模型后的劣化形态。`yolo` 按定义就是带模型的那一档:模型不在场时它仍然不问人、按机械处置,并把
257
+ 记录写成 `yolo-allow-no-reviewer`,好让审批历史不会被误读成"模型答过"。五档都由插件自带 patch
258
+ 发布,装好就在下拉里,不需要手改宿主配置。不在 `gatePresets` 里的预设走不到这一步:那里整个门禁
259
+ 停用,本来就没有需要回答的确认。
260
+
236
261
  (llmAssist 的真实接收 LLM 在设置页填 `classifierEndpoint` / `classifierModel`,OpenAI 兼容的自定义 API
237
262
  均可。设置页可选择接收来源:**自定义 API**(任何 OpenAI 兼容端点,内置小米 MiMo `https://api.xiaomimimo.com/v1` 等预设)或**宿主模型组**(复用 DSH 会话已配置的 `llm` 服务与当前模型组,可用 `classifierProvider` / `classifierModel` 覆盖);并提供**健康测试**按钮,一键验证接收 LLM 的连通性与延迟。)
238
263
 
@@ -243,6 +268,22 @@ outside the design set get none*。`permissive` 是内置值,所以「自动
243
268
  接缝——始终 fail-closed。`trustEscalation`(档位开启时默认开)答复门禁已放行的调用在其工具体内
244
269
  提出的 `sandbox_permissions` 提权;见下文。`permissive` 关闭时,门禁行为与之前完全一致。
245
270
 
271
+ ### 无人值守档位,以及如何保护预置表认不出的凭据
272
+
273
+ 三个开关改变“谁来定”,七个键改变“能看见什么”。它们全部 volatile:设置卡改完下一个工具调用即生效,无需重载。
274
+
275
+ - `yolo`(负责 `permissive-yolo` 那一档)— 永不弹人工确认。危险 ask(不可逆 / 不可沉淀 / 载荷读不懂 / 审查模型报硬风险)被拦截,理由以 `工具审核意见` 消息回喂主模型;其余照常执行。审查模型不在场时它不静默放行:由机械审查器裁定,并把事件记为 `yolo-allow-no-reviewer`——一档既然声称有模型,就不能把没答上的模型报成答过了。
276
+ - `rulesFallback` — 降级审查。仅当**没有审查模型**(未配置,或端点未能给出裁决)时生效:改由确定性规则网裁定,不再把调用挂在没人应答的确认框上。模型正常应答时行为不变。它只作用于普通的自动审查档(`permissive` / `permissive-full`);那三颗各自拥有一档的开关已经写明由谁回答,不会被它降级。
277
+ - `scriptReview`(默认开)— 调用跑脚本时读取它的正文(有界、绝不执行),同时交给审查模型、人工卡片与规则网:只在 `clean-home.sh` 里看到的删除从此按代码判定。正文全读到就不再是“读不懂的载荷”,从而脱离 U 档。关掉可不让文件内容离开本机,代价是工作区外脚本回到“看不清、问人”。
278
+
279
+ “什么算凭据”的长尾由你扩展,但每张表**只往内置表上追加**,不提供替换能力——漏写 `content` 或 `plan` 会静默重现本门禁已经修过的那个 bug:
280
+
281
+ - `credentialPaths` — 路径 glob(双星跨目录、单星与问号限一段;无通配符时按整个路径片段匹配;`~/` 展开为你的 home;相对写法也在任意深度匹配)。列入的路径与 `.ssh` 同档:工作区外读取被拒、被 `write`/`edit` 改写被拒。该档是 P0(不可申诉、审查模型也放行不了),这正是它是配置项而非 `rules.yaml` 规则的原因:只读工具在规则链**之前**就被自动放行了,规则看不见这类调用。
282
+ - `denyKeywordsExtra` — 向当前生效的黑名单(预置或你替换后的表)**追加**词项,为公司 CLI 的吊销子命令或自有令牌前缀加一道确定性拦截,而不必把 50 条预置抄进 profile。
283
+ - `extraContentArgKeys` / `extraOperationArgKeys` — 哪些参数是文档、哪些是操作。前者阻止宿主工具的 `steps`/`notes` 正文被当成操作读(issue #2 就是这么发生的:一份诚实的计划写了 `rm -rf` 反而因“描述”被拒);后者为 `workflowYaml` 这种“正文真的会被执行”的字段反向开口,且与前者撞名时本表获胜。把真正的文档字段写进来,就等于让“计划里提到 `rm -rf`”重新变成拒绝理由,所以默认关。
284
+
285
+ 把 `command` 或 `file_path` 声明为文档并不能削弱门禁:被执行的命令与被改写的路径由各自的判据路径负责,不走这张表(已有断言测试钉住)。
286
+
246
287
  ### 沙箱提权:为何 `safe` 裁决仍会弹窗
247
288
 
248
289
  一个工具调用可能触发**两个独立的审批**。门禁负责第一个——它的 `ask`,在 `tools/pre-execute`
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.1",
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 否决、其余一律放行,规则未知或非法时直接报错,绝不静默失效。"
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "license": "MIT",
15
15
  "engines": {
16
- "dsh": "0.1.0-rc.2 || 0.1.0-rc.3 || 0.1.0-rc.6 || 0.1.0-rc.7 || 0.1.0-rc.8 || 0.1.1-rc.1 || 0.1.1-rc.2 || 0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2"
16
+ "dsh": "0.1.0-rc.2 || 0.1.0-rc.3 || 0.1.0-rc.6 || 0.1.0-rc.7 || 0.1.0-rc.8 || 0.1.1-rc.1 || 0.1.1-rc.2 || 0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-rc.1"
17
17
  },
18
18
  "components": {
19
19
  "host": "lib/index.js",
@@ -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;