@twinsearth/w2m-dsh-plugin 1.9.1 → 1.9.5
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +24 -2
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.9.3/RELEASE-193.json +21 -0
- package/docs/acceptance/1.9.4/RELEASE-194.json +23 -0
- package/docs/acceptance/1.9.5/RELEASE-195.json +23 -0
- package/lib/client.js +2021 -1764
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/sinks/dir-sink.mjs +33 -0
- package/src/plugin/folder-policy.mjs +10 -18
- package/src/plugin/gpw-policy-adapter.mjs +30 -0
- package/src/plugin/joint-dev/audit-file.mjs +83 -0
- package/src/plugin/joint-dev/dispatch.mjs +154 -0
- package/src/plugin/joint-dev/loop.mjs +73 -0
- package/src/plugin/joint-dev/plan-store.mjs +66 -0
- package/src/plugin/joint-dev/redline.mjs +16 -0
- package/src/plugin/npm-update.mjs +18 -12
- package/src/plugin/path-policy.mjs +214 -0
- package/src/plugin/target-policy.mjs +90 -0
- package/src/plugin/tools.mjs +8231 -8225
- package/src/relay/guarded-dispatch.mjs +43 -0
- package/src/relay/p0-projection.mjs +94 -0
- package/src/relay/redline-guard.mjs +72 -0
- package/src/relay/scrub.mjs +91 -0
- package/src/relay/server.mjs +21 -1
- package/src/relay/state.mjs +6 -1
- package/src/util/data-plane.mjs +180 -0
- package/src/util/redline-core.mjs +216 -0
- package/src/util/sealed-object.mjs +141 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GPW 路径继承语义(单一判定点)。
|
|
3
|
+
*
|
|
4
|
+
* 白名单 = 子树许可(向下任意深度、不向上、分段比较、自反)。
|
|
5
|
+
* 黑名单 = 子树拒绝,默认优先;同深度 deny 胜。
|
|
6
|
+
* 父黑名单下的子目录默认 deny;唯一通道是显式例外:
|
|
7
|
+
* 严格更深、仅配置来源、带 TTL、效果 approve(每次人工确认)。
|
|
8
|
+
* 删除还需父目录写权限;敏感底线不可被白名单覆盖。
|
|
9
|
+
* 未在名单内 → pass(问人),绝不自动 allow。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const SENSITIVE_SEGMENTS = new Set(['.ssh', '.aws', '.gnupg', '.kube', '.docker', 'credentials']);
|
|
13
|
+
const SENSITIVE_EXT = /\.(pem|key|pfx|p12|jks|kdbx)$/i;
|
|
14
|
+
const SENSITIVE_NAMES = new Set(['.env', 'id_rsa', 'id_ed25519', 'wallet.dat', 'login data', 'gpw-folders.json', 'gpw-folders.json.key']);
|
|
15
|
+
|
|
16
|
+
export const DECISION = Object.freeze({ ALLOW: 'allow', DENY: 'deny', APPROVE: 'approve', PASS: 'pass' });
|
|
17
|
+
|
|
18
|
+
const isWindows = (platform) => platform === 'win32';
|
|
19
|
+
|
|
20
|
+
/** 词法规范化:别名、尾点/空格、. / ..、分隔符;Win32 只折 ASCII。 */
|
|
21
|
+
export function canonicalKey(raw, { platform = process.platform } = {}) {
|
|
22
|
+
if (typeof raw !== 'string') return null;
|
|
23
|
+
let text = raw.trim();
|
|
24
|
+
if (text === '' || text.includes('\u0000')) return null;
|
|
25
|
+
const win = isWindows(platform);
|
|
26
|
+
if (win) {
|
|
27
|
+
text = text.replace(/^\\\\[?.]\\UNC\\(.*)$/i, '\\\\$1').replace(/^\\\\[?.]\\(?=[A-Za-z]:)/, '');
|
|
28
|
+
text = text.replace(/\//g, '\\');
|
|
29
|
+
}
|
|
30
|
+
const segments = [];
|
|
31
|
+
const drive = win ? /^([A-Za-z]):(.*)$/.exec(text) : null;
|
|
32
|
+
let rest = text;
|
|
33
|
+
if (drive !== null) {
|
|
34
|
+
if (drive[2].startsWith('\\') === false) return null; // C:foo 盘符相对不可判定
|
|
35
|
+
rest = drive[2];
|
|
36
|
+
}
|
|
37
|
+
for (const part of rest.split(/[\\/]+/)) {
|
|
38
|
+
if (part === '' || part === '.') continue;
|
|
39
|
+
if (part === '..') { segments.pop(); continue; }
|
|
40
|
+
let segment = part;
|
|
41
|
+
if (win) segment = segment.replace(/[. ]+$/g, ''); // Win32 去尾点/空格
|
|
42
|
+
if (segment !== '') segments.push(win ? segment.replace(/[A-Z]/g, (c) => c.toLowerCase()) : segment);
|
|
43
|
+
}
|
|
44
|
+
if (win) return (drive === null ? '' : drive[1].toLowerCase() + ':\\') + segments.join('\\');
|
|
45
|
+
return '/' + segments.join('/');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function segmentsOf(key, platform) {
|
|
49
|
+
if (typeof key !== 'string' || key === '') return null;
|
|
50
|
+
if (isWindows(platform)) {
|
|
51
|
+
const match = /^([A-Za-z]):\\(.*)$/.exec(key);
|
|
52
|
+
return match === null ? null : [match[1] + ':', ...match[2].split('\\').filter(Boolean)];
|
|
53
|
+
}
|
|
54
|
+
return key.split('/').filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 分段包含:自反;C:\work 不含 C:\workshop。 */
|
|
58
|
+
export function inside(targetKey, rootKey, platform = process.platform) {
|
|
59
|
+
const target = segmentsOf(targetKey, platform);
|
|
60
|
+
const root = segmentsOf(rootKey, platform);
|
|
61
|
+
if (target === null || root === null || target.length < root.length) return false;
|
|
62
|
+
for (let index = 0; index < root.length; index += 1) {
|
|
63
|
+
if (target[index] !== root[index]) return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 敏感底线:段名/文件名/扩展名命中即 deny,任何白名单不能覆盖。 */
|
|
69
|
+
export function sensitiveMatch(raw) {
|
|
70
|
+
const lowered = String(raw).toLowerCase();
|
|
71
|
+
for (const segment of lowered.split(/[\\/]+/)) {
|
|
72
|
+
if (SENSITIVE_SEGMENTS.has(segment)) return { hit: true, reason: 'SENSITIVE_SEGMENT', segment };
|
|
73
|
+
if (SENSITIVE_NAMES.has(segment)) return { hit: true, reason: 'SENSITIVE_NAME', segment };
|
|
74
|
+
if (SENSITIVE_EXT.test(segment)) return { hit: true, reason: 'SENSITIVE_EXTENSION', segment };
|
|
75
|
+
}
|
|
76
|
+
return { hit: false };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ruleDepth(rule, target, platform) {
|
|
80
|
+
const root = canonicalKey(rule.path, { platform });
|
|
81
|
+
if (root === null || !inside(target, root, platform)) return -1;
|
|
82
|
+
return segmentsOf(root, platform).length;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 判定。规则:{path, effect:'allow'|'deny', source:'config'|'panel', expiresAt?, ops?}
|
|
87
|
+
* @returns {{decision: string, source: string, reason: string, rule?: object}}
|
|
88
|
+
*/
|
|
89
|
+
export function decidePath({ target, rules = [], exceptions = [], workspace = null, platform = process.platform, now = Date.now() }) {
|
|
90
|
+
const key = canonicalKey(target, { platform });
|
|
91
|
+
if (key === null) return { decision: DECISION.PASS, source: 'canonicalize', reason: 'UNRESOLVABLE' };
|
|
92
|
+
|
|
93
|
+
const sensitive = sensitiveMatch(target);
|
|
94
|
+
if (sensitive.hit) return { decision: DECISION.DENY, source: 'floor', reason: sensitive.reason };
|
|
95
|
+
|
|
96
|
+
const covering = [];
|
|
97
|
+
for (const rule of rules) {
|
|
98
|
+
const depth = ruleDepth(rule, key, platform);
|
|
99
|
+
if (depth >= 0) covering.push({ rule, depth });
|
|
100
|
+
}
|
|
101
|
+
const denies = covering.filter((entry) => entry.rule.effect === 'deny');
|
|
102
|
+
if (denies.length > 0) {
|
|
103
|
+
const deepestDeny = Math.max(...denies.map((entry) => entry.depth));
|
|
104
|
+
const exception = exceptions.find((entry) => entry.source === 'config'
|
|
105
|
+
&& (entry.expiresAt === undefined || entry.expiresAt > now)
|
|
106
|
+
&& ruleDepth(entry, key, platform) > deepestDeny);
|
|
107
|
+
if (exception !== undefined) {
|
|
108
|
+
return { decision: DECISION.APPROVE, source: 'exception', reason: 'EXCEPTION_REQUIRES_APPROVAL', rule: exception };
|
|
109
|
+
}
|
|
110
|
+
return { decision: DECISION.DENY, source: 'rule', reason: 'DENY_RULE', rule: denies.sort((a, b) => b.depth - a.depth)[0].rule };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const allows = covering.filter((entry) => entry.rule.effect === 'allow').sort((a, b) => b.depth - a.depth);
|
|
114
|
+
if (allows.length > 0) return { decision: DECISION.ALLOW, source: 'rule', reason: 'ALLOW_RULE', rule: allows[0].rule };
|
|
115
|
+
if (workspace !== null) {
|
|
116
|
+
const root = canonicalKey(workspace, { platform });
|
|
117
|
+
if (root !== null && inside(key, root, platform)) return { decision: DECISION.ALLOW, source: 'workspace', reason: 'ALLOW_WORKSPACE' };
|
|
118
|
+
}
|
|
119
|
+
return { decision: DECISION.PASS, source: 'unlisted', reason: 'NO_RULE' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 操作维度:delete/rename 需要父目录写权限。
|
|
124
|
+
* @param {{target: string, operation: 'read'|'write'|'delete'|'exec', ...decidePathInput}} input
|
|
125
|
+
*/
|
|
126
|
+
export function decideOperation(input) {
|
|
127
|
+
const verdict = decidePath(input);
|
|
128
|
+
if (verdict.decision !== DECISION.ALLOW || input.operation !== 'delete') return verdict;
|
|
129
|
+
const parent = canonicalKey(input.target, { platform: input.platform });
|
|
130
|
+
if (parent === null) return verdict;
|
|
131
|
+
const parentPath = parent.replace(/[\\/][^\\/]+$/, '');
|
|
132
|
+
const parentVerdict = decidePath({ ...input, target: parentPath, operation: 'write' });
|
|
133
|
+
if (parentVerdict.decision !== DECISION.ALLOW) {
|
|
134
|
+
return { decision: DECISION.APPROVE, source: 'parent', reason: 'PARENT_WRITE_REQUIRED', parent: parentVerdict };
|
|
135
|
+
}
|
|
136
|
+
return verdict;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 写入期:不允许在黑名单子树内添加白名单(避免静默开洞)。 */
|
|
140
|
+
export function assertRuleWritable({ path, rules = [], platform = process.platform }) {
|
|
141
|
+
const key = canonicalKey(path, { platform });
|
|
142
|
+
if (key === null) return { ok: false, code: 'RULE_PATH_INVALID' };
|
|
143
|
+
for (const rule of rules) {
|
|
144
|
+
if (rule.effect !== 'deny') continue;
|
|
145
|
+
const depth = ruleDepth(rule, key, platform);
|
|
146
|
+
if (depth >= 0) return { ok: false, code: 'RULE_CONFLICT_DENY_ANCESTOR', detail: { ancestor: rule.path } };
|
|
147
|
+
}
|
|
148
|
+
return { ok: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 多路径聚合(C36 语义):任一 deny → deny;全部 allow → allow;否则 approve。
|
|
153
|
+
* 现状 gpw 的 classifyToolCall 是"任一 allow 就 allow"(fail-open),这里是修正版。
|
|
154
|
+
*/
|
|
155
|
+
export function decideMany({ targets, ...input }) {
|
|
156
|
+
if (!Array.isArray(targets) || targets.length === 0) {
|
|
157
|
+
return { decision: DECISION.PASS, source: 'unlisted', reason: 'NO_PATHS', results: [] };
|
|
158
|
+
}
|
|
159
|
+
const results = targets.map((target) => decidePath({ ...input, target }));
|
|
160
|
+
if (results.some((entry) => entry.decision === DECISION.DENY)) {
|
|
161
|
+
return { decision: DECISION.DENY, source: 'aggregate', reason: 'ANY_DENY', results };
|
|
162
|
+
}
|
|
163
|
+
if (results.every((entry) => entry.decision === DECISION.PASS)) {
|
|
164
|
+
return { decision: DECISION.PASS, source: 'aggregate', reason: 'NO_RULE', results };
|
|
165
|
+
}
|
|
166
|
+
if (results.every((entry) => entry.decision === DECISION.ALLOW)) {
|
|
167
|
+
return { decision: DECISION.ALLOW, source: 'aggregate', reason: 'ALL_ALLOW', results };
|
|
168
|
+
}
|
|
169
|
+
return { decision: DECISION.APPROVE, source: 'aggregate', reason: 'NEEDS_APPROVAL', results };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** P0-2:判定 → 颜色(颜色只做渲染,不参与判定)。 */
|
|
173
|
+
export function colorOf(decision) {
|
|
174
|
+
if (decision === DECISION.ALLOW) return 'green';
|
|
175
|
+
if (decision === DECISION.DENY) return 'red';
|
|
176
|
+
return 'yellow';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
import { realpathSync } from 'node:fs';
|
|
180
|
+
import { dirname } from 'node:path';
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* FS 层:对最长已存在前缀取 realpath,剩余不存在尾部按词法拼回。
|
|
184
|
+
* 返回 { real, key, rest };无法解析时 real=null(调用方按更严处理)。
|
|
185
|
+
*/
|
|
186
|
+
export function realpathPrefix(raw, { platform = process.platform } = {}) {
|
|
187
|
+
const key = canonicalKey(raw, { platform });
|
|
188
|
+
if (key === null) return null;
|
|
189
|
+
let probe = key;
|
|
190
|
+
for (;;) {
|
|
191
|
+
try {
|
|
192
|
+
const real = realpathSync.native(probe);
|
|
193
|
+
const normalized = canonicalKey(real, { platform });
|
|
194
|
+
if (normalized === null) return { real: null, key, rest: key };
|
|
195
|
+
const rest = key.slice(probe.length);
|
|
196
|
+
return { real, key: normalized + rest, rest };
|
|
197
|
+
} catch {
|
|
198
|
+
const parent = dirname(probe);
|
|
199
|
+
if (parent === probe) return { real: null, key, rest: key };
|
|
200
|
+
probe = parent;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const SEVERITY = { allow: 0, pass: 1, approve: 2, deny: 3 };
|
|
206
|
+
|
|
207
|
+
/** 词法与 FS 两层取更严(更严者胜);FS 不可用则用词法。 */
|
|
208
|
+
export function decidePathSecure(input) {
|
|
209
|
+
const lexical = decidePath(input);
|
|
210
|
+
const resolved = realpathPrefix(input.target, { platform: input.platform ?? process.platform });
|
|
211
|
+
if (resolved === null || resolved.real === null || resolved.key === canonicalKey(input.target, { platform: input.platform ?? process.platform })) return lexical;
|
|
212
|
+
const real = decidePath({ ...input, target: resolved.key });
|
|
213
|
+
return SEVERITY[real.decision] > SEVERITY[lexical.decision] ? real : lexical;
|
|
214
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 最终机器选择策略(纯函数,独立可测;尚未接线到 dispatch/tools)。
|
|
3
|
+
*
|
|
4
|
+
* 优先级:指令显式点名 > 输入框选中 > 默认本机
|
|
5
|
+
* 规则:
|
|
6
|
+
* · 点名机器不存在/离线 → MACHINE_UNAVAILABLE,绝不换机;
|
|
7
|
+
* · 选中机器失效(离线/退编队)→ 自动回默认本机,并返回 SELECTION_FALLBACK_LOCAL;
|
|
8
|
+
* · 只有 online && inFleet 的机器可被选中(assertSelectable);
|
|
9
|
+
* · 选中“全部机器/联合开发” → fleet 模式(显式),不是本机。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const TARGET_SOURCES = Object.freeze(['explicit', 'selected', 'local', 'fleet']);
|
|
13
|
+
|
|
14
|
+
function isUsable(machine) {
|
|
15
|
+
return machine !== null && typeof machine === 'object'
|
|
16
|
+
&& typeof machine.id === 'string' && machine.id !== ''
|
|
17
|
+
&& machine.online === true && machine.inFleet === true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 可选择性校验:离线/不在编队的机器不能被写入选择。 */
|
|
21
|
+
export function assertSelectable(machine) {
|
|
22
|
+
if (machine === null || typeof machine !== 'object') {
|
|
23
|
+
return { ok: false, code: 'SELECTION_INVALID', detail: { reason: 'not-an-object' } };
|
|
24
|
+
}
|
|
25
|
+
if (typeof machine.id !== 'string' || machine.id === '') {
|
|
26
|
+
return { ok: false, code: 'SELECTION_INVALID', detail: { reason: 'missing-id' } };
|
|
27
|
+
}
|
|
28
|
+
if (machine.online !== true) {
|
|
29
|
+
return { ok: false, code: 'SELECTION_MACHINE_OFFLINE', detail: { machine: machine.id } };
|
|
30
|
+
}
|
|
31
|
+
if (machine.inFleet !== true) {
|
|
32
|
+
return { ok: false, code: 'SELECTION_MACHINE_NOT_IN_FLEET', detail: { machine: machine.id } };
|
|
33
|
+
}
|
|
34
|
+
return { ok: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 解析本次调用的目标。
|
|
39
|
+
* @param {{explicitMachine?: {id,name?,online?,inFleet?}|string|null,
|
|
40
|
+
* selected?: {mode?: 'machine'|'fleet', machine?: object|null}|null,
|
|
41
|
+
* localMachine: {id,name?},
|
|
42
|
+
* fleetMachines?: Array<object>}} input
|
|
43
|
+
* @returns {{ok: true, source: string, machine: object|null, warning?: string}
|
|
44
|
+
* | {ok: false, code: string, detail: object}}
|
|
45
|
+
*/
|
|
46
|
+
export function resolveTarget(input) {
|
|
47
|
+
const local = input?.localMachine;
|
|
48
|
+
if (local === null || typeof local !== 'object' || typeof local.id !== 'string' || local.id === '') {
|
|
49
|
+
return { ok: false, code: 'LOCAL_MACHINE_UNKNOWN', detail: {} };
|
|
50
|
+
}
|
|
51
|
+
const fleet = Array.isArray(input?.fleetMachines) ? input.fleetMachines : [];
|
|
52
|
+
const find = (id) => fleet.find((machine) => machine.id === id) ?? null;
|
|
53
|
+
|
|
54
|
+
// 1) 显式点名:最高优先;找不到/离线即拒绝,不换机。
|
|
55
|
+
const explicit = input?.explicitMachine;
|
|
56
|
+
if (explicit !== undefined && explicit !== null && explicit !== '') {
|
|
57
|
+
const id = typeof explicit === 'string' ? explicit : explicit.id;
|
|
58
|
+
if (typeof id !== 'string' || id === '') {
|
|
59
|
+
return { ok: false, code: 'MACHINE_UNAVAILABLE', detail: { reason: 'missing-id' } };
|
|
60
|
+
}
|
|
61
|
+
if (id === local.id) return { ok: true, source: 'local', machine: { id: local.id, name: local.name ?? null } };
|
|
62
|
+
const named = find(id);
|
|
63
|
+
if (named === null) return { ok: false, code: 'MACHINE_UNAVAILABLE', detail: { machine: id, reason: 'not-in-fleet' } };
|
|
64
|
+
if (named.online !== true) return { ok: false, code: 'MACHINE_UNAVAILABLE', detail: { machine: id, reason: 'offline' } };
|
|
65
|
+
if (named.inFleet !== true) return { ok: false, code: 'MACHINE_UNAVAILABLE', detail: { machine: id, reason: 'not-in-fleet' } };
|
|
66
|
+
return { ok: true, source: 'explicit', machine: named };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 2) 选中项:fleet 模式 → 整编队;machine 模式 → 该机;失效 → TARGET_UNAVAILABLE(不换机)。
|
|
70
|
+
const selected = input?.selected ?? null;
|
|
71
|
+
if (selected !== null && selected.mode === 'fleet') {
|
|
72
|
+
return { ok: true, source: 'fleet', machine: null };
|
|
73
|
+
}
|
|
74
|
+
if (selected !== null && selected.machine !== undefined && selected.machine !== null) {
|
|
75
|
+
const picked = selected.machine;
|
|
76
|
+
if (picked.id === local.id) return { ok: true, source: 'local', machine: { id: local.id, name: local.name ?? null } };
|
|
77
|
+
const current = typeof picked.id === 'string' ? find(picked.id) : null;
|
|
78
|
+
if (isUsable(current)) {
|
|
79
|
+
return { ok: true, source: 'selected', machine: current };
|
|
80
|
+
}
|
|
81
|
+
return { ok: false, code: 'TARGET_UNAVAILABLE', detail: { machine: picked.id ?? null, reason: 'selection-invalid' } };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3) 未选中:指令显式要求“全队/整编队/所有机器” → fleet;否则默认本机。
|
|
85
|
+
const phrase = typeof input?.promptText === 'string' ? input.promptText : '';
|
|
86
|
+
if (/(全队|整编队|所有机器|全部机器)/.test(phrase)) {
|
|
87
|
+
return { ok: true, source: 'fleet', machine: null };
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, source: 'local', machine: { id: local.id, name: local.name ?? null } };
|
|
90
|
+
}
|