dsh-perm-gate 5.4.1 → 5.4.2
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.md +5 -0
- package/README.md +3 -1
- package/README.zh.md +1 -1
- package/dsh.plugin.json +1 -1
- package/lib/index.d.ts +46 -0
- package/lib/index.js +89 -18
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- **拦截意见不再冒充用户消息**:被拦后给主模型的 `工具审核意见` 曾以 `mode:'queue'` 投到 next-turn——那是用户的输入队列,意见投进去会在 input-traffic 等待区里像用户自己排的一条,并与待发消息抢队列,且要等整个回合结束才送达(模型多反刍一轮)。现在交由 `buildBlockNoteSender` 投递:首选 `agent.inject()`(next-step 带内、不唤醒 driver,贴着被拒的 tool 结果在下一个 pre-step 送达)并标上自有 `source.kind='perm-gate'`,因为宿主的语义里只有 `source.kind==='user'`(带 `rpcId`)才是“人说的话”,其余一律按注入上下文渲染;没有活 agent 时才回退到 steer 车道(`session.prompt mode:'steer'`),绝不静默降级成 `followup`。yolo / 机械直通 / 降级审查 / 确定性 deny 四条拦截路径统一如此;评审页「撤销此改动」仍走 next-turn(那是人想发的一句话)。
|
|
6
|
+
- 配套:`dsh-input-traffic` 同步把「插话中」限定为真人插话(`source.kind === 'user'`),宿主自有通知(如 `user-approval` 的审批策略变更)与第三方意见不再占位等待区;没带 `source` 的旧行仍照常显示,不会误伤真队列。
|
|
7
|
+
|
|
3
8
|
## 5.4.0 (2026-10-30)
|
|
4
9
|
|
|
5
10
|
- 凭据类型阶梯:七个内置族(ssh / cloud / package / gitcreds / env / cli / db),每类四档 deny / ask(默认)/ readonly / readwrite。档位状态就住在规则文档本身——每类一条 `[deny-rule:cred.<family>]` 行(分区 + enabled)加成对的 volatile `credWriteExemptFamilies` 清单;设置卡经 `GET/POST /api/dsh-perm-gate/cred` 一条 update 成对读写,无本地镜像、无半态。readwrite 需卡内二次确认,且只退让 P0 的一条判据(凭据 store 写);根盘、受保护路径、.dsh、外发在任何档位不让;自定义 credentialPaths 永不参与写豁免。
|
package/README.md
CHANGED
|
@@ -381,7 +381,9 @@ Three switches change **who decides**, seven keys change **what is known**. They
|
|
|
381
381
|
|
|
382
382
|
- `yolo` (the `permissive-yolo` tier) — never raises a confirmation. A dangerous ask
|
|
383
383
|
(irreversible, unlearnable, an unreadable payload, or a hard-risk verdict from the configured
|
|
384
|
-
reviewer) is blocked and the reason comes back to the agent as a `工具审核意见` message
|
|
384
|
+
reviewer) is blocked and the reason comes back to the agent as a `工具审核意见` message on
|
|
385
|
+
the **steering lane** (`next-step`: beside the refused tool result, at the same turn's
|
|
386
|
+
next step) rather than the user's input queue;
|
|
385
387
|
everything else runs. With no reviewer in seat the tier does not quietly allow — the mechanical
|
|
386
388
|
screen settles it and the event is recorded as `yolo-allow-no-reviewer`, because a tier that
|
|
387
389
|
advertises a model must not report one it never got.
|
package/README.zh.md
CHANGED
|
@@ -274,7 +274,7 @@ outside the design set get none*。`permissive` 是内置值,所以「自动
|
|
|
274
274
|
|
|
275
275
|
三个开关改变“谁来定”,七个键改变“能看见什么”。它们全部 volatile:设置卡改完下一个工具调用即生效,无需重载。
|
|
276
276
|
|
|
277
|
-
- `yolo`(负责 `permissive-yolo` 那一档)— 永不弹人工确认。危险 ask(不可逆 / 不可沉淀 / 载荷读不懂 / 审查模型报硬风险)被拦截,理由以 `工具审核意见`
|
|
277
|
+
- `yolo`(负责 `permissive-yolo` 那一档)— 永不弹人工确认。危险 ask(不可逆 / 不可沉淀 / 载荷读不懂 / 审查模型报硬风险)被拦截,理由以 `工具审核意见` 消息经 **steer 车道**(`next-step`,贴着被拒的工具结果、在同一回合的下一步送达)回喂主模型,不占用户输入队列;其余照常执行。审查模型不在场时它不静默放行:由机械审查器裁定,并把事件记为 `yolo-allow-no-reviewer`——一档既然声称有模型,就不能把没答上的模型报成答过了。
|
|
278
278
|
- `rulesFallback` — 降级审查。仅当**没有审查模型**(未配置,或端点未能给出裁决)时生效:改由确定性规则网裁定,不再把调用挂在没人应答的确认框上。模型正常应答时行为不变。它只作用于普通的自动审查档(`permissive` / `permissive-full`);那三颗各自拥有一档的开关已经写明由谁回答,不会被它降级。
|
|
279
279
|
- `scriptReview`(默认开)— 调用跑脚本时读取它的正文(有界、绝不执行),同时交给审查模型、人工卡片与规则网:只在 `clean-home.sh` 里看到的删除从此按代码判定。正文全读到就不再是“读不懂的载荷”,从而脱离 U 档。关掉可不让文件内容离开本机,代价是工作区外脚本回到“看不清、问人”。
|
|
280
280
|
|
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.
|
|
4
|
+
"version": "5.4.2",
|
|
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. Credential handling is a seven-family ladder (ssh/cloud/package/gitcreds/env/cli/db) with deny/ask/readonly/readwrite rungs kept in the rules document plus a paired write-exemption list; a protected-branch push switch keeps the P0 floor by default. 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 干跑评估器与可审计的决策日志。凭据处理为七族阶梯(ssh/cloud/package/gitcreds/env/cli/db)× deny/ask/readonly/readwrite 四档,档位存规则文档 + 成对写豁免清单;保护分支强推开关默认保留 P0 地板。只以 deny/ask 否决、其余一律放行,规则未知或非法时直接报错,绝不静默失效。"
|
package/lib/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { Context } from '@deepseek-ai/cordis';
|
|
9
9
|
import { Config } from './config.js';
|
|
10
|
+
import { type SessionSender } from './events.js';
|
|
10
11
|
import { PermGateRuntime, type PreToolDecisionLike, type ToolExecutionLike } from './runtime.js';
|
|
11
12
|
export declare const name = "dsh-perm-gate";
|
|
12
13
|
/**
|
|
@@ -20,6 +21,51 @@ export declare const inject: string[];
|
|
|
20
21
|
export { Config };
|
|
21
22
|
/** Runtime settings namespace: `permissive` + `permissiveStrategies` (editable in the UI). */
|
|
22
23
|
export declare const PERMISSIVE_NAMESPACE = "dsh-perm-gate";
|
|
24
|
+
/**
|
|
25
|
+
* The `source.kind` the gate stamps on its own block opinions.
|
|
26
|
+
*
|
|
27
|
+
* The host's vocabulary is explicit about this: `source.kind === 'user'` (with an
|
|
28
|
+
* `rpcId`) means *a human typed it*, and `ui-trajectory` / `ui-chat` render any
|
|
29
|
+
* other kind as injected **context**, not as a queued user input. The host's own
|
|
30
|
+
* notices use the same convention (`user-approval`, `policy`, `time-context`…).
|
|
31
|
+
* A gate note is the gate talking, so it must not borrow the human's identity —
|
|
32
|
+
* otherwise the waiting area lists it as a message the person can plan, reorder
|
|
33
|
+
* or resend.
|
|
34
|
+
*/
|
|
35
|
+
export declare const BLOCK_NOTE_SOURCE_KIND = "perm-gate";
|
|
36
|
+
/**
|
|
37
|
+
* Deliver the gate's block opinion into the running turn.
|
|
38
|
+
*
|
|
39
|
+
* The opinion is model-facing context, not user input, so this uses the host's
|
|
40
|
+
* injection lane (`agent.inject`: same `next-step` band as steering, but the
|
|
41
|
+
* note waits for the next pre-step instead of waking a driver) and stamps
|
|
42
|
+
* {@link BLOCK_NOTE_SOURCE_KIND} on it. The role stays `user` — that is the only
|
|
43
|
+
* role a model reads as an instruction — but the source says who really spoke.
|
|
44
|
+
*
|
|
45
|
+
* Falls back to {@link buildSessionSender}'s steering lane when no live agent is
|
|
46
|
+
* reachable (a hosted runtime that only exposes the typert gateway): the note
|
|
47
|
+
* then arrives as a steer, which the UI may show as a pending message — worse,
|
|
48
|
+
* but never a dropped block reason.
|
|
49
|
+
*/
|
|
50
|
+
export declare function buildBlockNoteSender(get: (name: string) => unknown): SessionSender;
|
|
51
|
+
/**
|
|
52
|
+
* Deliver a message into a conversation. A DSH user message content must be a
|
|
53
|
+
* block array — a bare string is rendered per character by the GUI — and two
|
|
54
|
+
* channels are tried in order: the typert gateway, then the live agent's own
|
|
55
|
+
* inbox method.
|
|
56
|
+
*
|
|
57
|
+
* `mode` picks the lane, and the lanes are physically different queues:
|
|
58
|
+
*
|
|
59
|
+
* - `queue` → `agent.followup` → **next-turn**: a new user turn. That is the
|
|
60
|
+
* lane for an instruction the person means to type (the review page's
|
|
61
|
+
* "撤销此改动"), and it shares the queue the user's own input waits in.
|
|
62
|
+
* - `steer` → `agent.steer` → **next-step**: lands beside the tool result that
|
|
63
|
+
* is about to be delivered, inside the *running* turn. That is the lane for
|
|
64
|
+
* the gate's own block opinion — it must not cost the user a turn, must not
|
|
65
|
+
* look like something they typed, and must reach the model at the very next
|
|
66
|
+
* step boundary rather than after the turn ends.
|
|
67
|
+
*/
|
|
68
|
+
export declare function buildSessionSender(get: (name: string) => unknown, mode?: 'queue' | 'steer'): SessionSender;
|
|
23
69
|
/**
|
|
24
70
|
* Build the `approval/request` listener (exported for tests).
|
|
25
71
|
*
|
package/lib/index.js
CHANGED
|
@@ -52,18 +52,79 @@ function asSurface(value) {
|
|
|
52
52
|
: undefined;
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
55
|
+
* The `source.kind` the gate stamps on its own block opinions.
|
|
56
|
+
*
|
|
57
|
+
* The host's vocabulary is explicit about this: `source.kind === 'user'` (with an
|
|
58
|
+
* `rpcId`) means *a human typed it*, and `ui-trajectory` / `ui-chat` render any
|
|
59
|
+
* other kind as injected **context**, not as a queued user input. The host's own
|
|
60
|
+
* notices use the same convention (`user-approval`, `policy`, `time-context`…).
|
|
61
|
+
* A gate note is the gate talking, so it must not borrow the human's identity —
|
|
62
|
+
* otherwise the waiting area lists it as a message the person can plan, reorder
|
|
63
|
+
* or resend.
|
|
64
|
+
*/
|
|
65
|
+
export const BLOCK_NOTE_SOURCE_KIND = 'perm-gate';
|
|
66
|
+
/**
|
|
67
|
+
* Deliver the gate's block opinion into the running turn.
|
|
68
|
+
*
|
|
69
|
+
* The opinion is model-facing context, not user input, so this uses the host's
|
|
70
|
+
* injection lane (`agent.inject`: same `next-step` band as steering, but the
|
|
71
|
+
* note waits for the next pre-step instead of waking a driver) and stamps
|
|
72
|
+
* {@link BLOCK_NOTE_SOURCE_KIND} on it. The role stays `user` — that is the only
|
|
73
|
+
* role a model reads as an instruction — but the source says who really spoke.
|
|
74
|
+
*
|
|
75
|
+
* Falls back to {@link buildSessionSender}'s steering lane when no live agent is
|
|
76
|
+
* reachable (a hosted runtime that only exposes the typert gateway): the note
|
|
77
|
+
* then arrives as a steer, which the UI may show as a pending message — worse,
|
|
78
|
+
* but never a dropped block reason.
|
|
59
79
|
*/
|
|
60
|
-
function
|
|
80
|
+
export function buildBlockNoteSender(get) {
|
|
81
|
+
const steer = buildSessionSender(get, 'steer');
|
|
82
|
+
return async (sessionId, content) => {
|
|
83
|
+
const agents = get('agents');
|
|
84
|
+
if (agents !== undefined && typeof agents.get === 'function') {
|
|
85
|
+
const agent = agents.get(sessionId);
|
|
86
|
+
if (agent !== undefined && typeof agent.inject === 'function') {
|
|
87
|
+
try {
|
|
88
|
+
agent.inject({
|
|
89
|
+
id: 'pg-note-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e9).toString(36),
|
|
90
|
+
role: 'user',
|
|
91
|
+
content: [{ type: 'text', text: content }],
|
|
92
|
+
source: { kind: BLOCK_NOTE_SOURCE_KIND },
|
|
93
|
+
});
|
|
94
|
+
return { ok: true, via: 'inject' };
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// fall through to the steering lane
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return steer(sessionId, content);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Deliver a message into a conversation. A DSH user message content must be a
|
|
106
|
+
* block array — a bare string is rendered per character by the GUI — and two
|
|
107
|
+
* channels are tried in order: the typert gateway, then the live agent's own
|
|
108
|
+
* inbox method.
|
|
109
|
+
*
|
|
110
|
+
* `mode` picks the lane, and the lanes are physically different queues:
|
|
111
|
+
*
|
|
112
|
+
* - `queue` → `agent.followup` → **next-turn**: a new user turn. That is the
|
|
113
|
+
* lane for an instruction the person means to type (the review page's
|
|
114
|
+
* "撤销此改动"), and it shares the queue the user's own input waits in.
|
|
115
|
+
* - `steer` → `agent.steer` → **next-step**: lands beside the tool result that
|
|
116
|
+
* is about to be delivered, inside the *running* turn. That is the lane for
|
|
117
|
+
* the gate's own block opinion — it must not cost the user a turn, must not
|
|
118
|
+
* look like something they typed, and must reach the model at the very next
|
|
119
|
+
* step boundary rather than after the turn ends.
|
|
120
|
+
*/
|
|
121
|
+
export function buildSessionSender(get, mode = 'queue') {
|
|
61
122
|
return async (sessionId, content) => {
|
|
62
123
|
const textBlock = [{ type: 'text', text: content }];
|
|
63
124
|
const gateway = get('typertGateway');
|
|
64
125
|
if (gateway !== undefined && typeof gateway.invoke === 'function') {
|
|
65
126
|
try {
|
|
66
|
-
await gateway.invoke({ namespace: 'session', method: 'prompt', args: { sessionId, mode
|
|
127
|
+
await gateway.invoke({ namespace: 'session', method: 'prompt', args: { sessionId, mode, content: textBlock } });
|
|
67
128
|
return { ok: true, via: 'gateway' };
|
|
68
129
|
}
|
|
69
130
|
catch {
|
|
@@ -73,14 +134,18 @@ function buildSessionSender(get) {
|
|
|
73
134
|
const agents = get('agents');
|
|
74
135
|
if (agents !== undefined && typeof agents.get === 'function') {
|
|
75
136
|
const agent = agents.get(sessionId);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
137
|
+
// The fallback stays on the requested lane: silently degrading a steer to
|
|
138
|
+
// followup would put the block opinion back in the user's turn queue — the
|
|
139
|
+
// exact confusion this lane split exists to remove.
|
|
140
|
+
const push = mode === 'steer' ? agent?.steer : agent?.followup;
|
|
141
|
+
if (agent !== undefined && typeof push === 'function') {
|
|
142
|
+
push.call(agent, {
|
|
143
|
+
id: `pg-${mode}-` + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e9).toString(36),
|
|
79
144
|
role: 'user',
|
|
80
145
|
content: textBlock,
|
|
81
146
|
source: { kind: 'user' },
|
|
82
147
|
});
|
|
83
|
-
return { ok: true, via:
|
|
148
|
+
return { ok: true, via: mode };
|
|
84
149
|
}
|
|
85
150
|
}
|
|
86
151
|
return { ok: false, error: '没有可用的消息投递通道' };
|
|
@@ -853,11 +918,17 @@ export function apply(ctx, config = {}) {
|
|
|
853
918
|
catch {
|
|
854
919
|
// webServer unavailable: the feed remains disk-only, sediment is view-only in files
|
|
855
920
|
}
|
|
856
|
-
//
|
|
857
|
-
// any mode),
|
|
858
|
-
//
|
|
859
|
-
//
|
|
860
|
-
|
|
921
|
+
// Block feedback: when a call is blocked (YOLO / mechanical / degraded-review block
|
|
922
|
+
// **or** a deterministic deny in any mode), the gate hands the main LLM a
|
|
923
|
+
// `工具审核意见` note telling it why, which rule fired and what to do instead.
|
|
924
|
+
//
|
|
925
|
+
// The note is context the gate injects, not something the person typed:
|
|
926
|
+
// `buildBlockNoteSender` uses `agent.inject` (next-step band, no driver wake) and
|
|
927
|
+
// stamps its own `source.kind`, so it lands beside the refused tool result at the
|
|
928
|
+
// next pre-step, never in the next-turn queue the user's own input waits in, and
|
|
929
|
+
// never claims to be a human message in the waiting area.
|
|
930
|
+
// Best-effort; a failed delivery never un-blocks.
|
|
931
|
+
const blockSender = buildBlockNoteSender(fallbackGet);
|
|
861
932
|
const blockedSubject = (exec) => {
|
|
862
933
|
const args = exec.arguments ?? {};
|
|
863
934
|
const cmd = (typeof exec.commandText === 'string' && exec.commandText !== ''
|
|
@@ -868,18 +939,18 @@ export function apply(ctx, config = {}) {
|
|
|
868
939
|
const p = ['file_path', 'path', 'cwd', 'workdir'].map((k) => args[k]).find((v) => typeof v === 'string');
|
|
869
940
|
return `${exec.name}${typeof p === 'string' ? ': ' + p : ''}`;
|
|
870
941
|
};
|
|
871
|
-
const
|
|
942
|
+
const blockFeedback = (exec, decision) => {
|
|
872
943
|
const sessionId = (typeof exec.sessionId === 'string' && exec.sessionId !== ''
|
|
873
944
|
? exec.sessionId
|
|
874
945
|
: (exec.agent?.sessionId ?? exec.agent?.session?.id) ?? '');
|
|
875
946
|
if (sessionId === '')
|
|
876
947
|
return;
|
|
877
948
|
const { text } = composeReviewOpinion({ command: blockedSubject(exec), reason: decision.reason, locale: 'zh' });
|
|
878
|
-
void
|
|
949
|
+
void blockSender(sessionId, `工具审核意见: "${text}"`).catch(() => {
|
|
879
950
|
// no delivery channel — the block still stands, the LLM just gets no note.
|
|
880
951
|
});
|
|
881
952
|
};
|
|
882
|
-
const listener = makePreExecuteListener(runtime,
|
|
953
|
+
const listener = makePreExecuteListener(runtime, blockFeedback);
|
|
883
954
|
// `tools/pre-execute` / `tools/result` / `approval/request` are service events,
|
|
884
955
|
// not part of cordis core's typed `Events`, so they are registered through the
|
|
885
956
|
// string overload.
|