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/CHANGELOG.ja.md +64 -1
- package/CHANGELOG.ko.md +64 -1
- package/CHANGELOG.md +149 -4
- package/README.ja.md +44 -1
- package/README.ko.md +44 -2
- package/README.md +74 -1
- package/README.zh.md +42 -1
- package/cordis.patch.yml +16 -1
- package/dsh.plugin.json +2 -2
- package/lib/ask-context.d.ts +49 -1
- package/lib/ask-context.js +89 -5
- package/lib/client.js +714 -86
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +152 -1
- package/lib/config.js +20 -1
- package/lib/credential-paths.d.ts +35 -0
- package/lib/credential-paths.js +119 -0
- package/lib/engine.d.ts +4 -6
- package/lib/engine.js +53 -42
- package/lib/evaluate.d.ts +20 -0
- package/lib/evaluate.js +34 -11
- package/lib/feedback.d.ts +42 -0
- package/lib/feedback.js +85 -0
- package/lib/index.d.ts +2 -2
- package/lib/index.js +119 -17
- package/lib/irreversible.js +109 -1
- package/lib/path.d.ts +31 -0
- package/lib/path.js +35 -0
- package/lib/preset.d.ts +8 -5
- package/lib/preset.js +15 -6
- package/lib/risk.d.ts +29 -1
- package/lib/risk.js +36 -2
- package/lib/runtime.d.ts +164 -0
- package/lib/runtime.js +423 -54
- package/lib/scan-text.d.ts +69 -0
- package/lib/scan-text.js +162 -0
- package/lib/script-review.d.ts +50 -0
- package/lib/script-review.js +202 -0
- package/lib/shell.d.ts +18 -0
- package/lib/shell.js +103 -1
- package/lib/unattended.d.ts +61 -0
- package/lib/unattended.js +77 -0
- package/package.json +8 -7
- package/scripts/patch-permission-glyph.mjs +27 -13
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
|
|
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
|
|
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.
|
|
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",
|
package/lib/ask-context.d.ts
CHANGED
|
@@ -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. */
|
package/lib/ask-context.js
CHANGED
|
@@ -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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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;
|