@twinsearth/w2m-dsh-plugin 1.7.4 → 1.7.7
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 +38 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.7.5-beta.1/GPW-FOLDER-GATE.json +24 -0
- package/docs/acceptance/1.7.6/AUDIT-ZERO-RESIDUE.json +25 -0
- package/docs/acceptance/1.7.7/GPW-INJECT.json +21 -0
- package/docs/audit/AUDIT-1.6.1-1.7.4.md +121 -0
- package/lib/client.js +216 -1
- package/package.json +3 -3
- package/plugin.json +1 -1
- package/src/plugin/folder-policy.mjs +177 -0
- package/src/plugin/gpw.mjs +431 -164
- package/src/plugin/tools.mjs +1 -1
package/src/plugin/gpw.mjs
CHANGED
|
@@ -1,205 +1,472 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DSH-GPW
|
|
2
|
+
* DSH-GPW v2 (v1.7.5-beta.1) — Global Permission Watchdog, now a folder gate rather than an approval answerer.
|
|
3
3
|
*
|
|
4
|
-
* ##
|
|
4
|
+
* ## The three classes and who answers
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* A. blacklist -> denied. A synchronous `ctx.tools.guard()` is registered as well, so the denial holds no matter
|
|
7
|
+
* which other pre-execute listener runs first.
|
|
8
|
+
* B. whitelist -> allowed without a human. The whitelist is an explicit extension of the workspace (same decision,
|
|
9
|
+
* plus the auto-approval budget below).
|
|
10
|
+
* C. other -> GPW's own pending queue: the call is held until the operator allows/denies it in the GPW panel
|
|
11
|
+
* (or the pending timeout fires, which denies). This is the plugin's requirement, not DSH's
|
|
12
|
+
* approval policy, which is why it keeps working under "完全权限" (approval: never).
|
|
10
13
|
*
|
|
11
|
-
* ##
|
|
14
|
+
* ## Full-access only
|
|
12
15
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* "mounted and doing nothing" and "mounted and working" look different.
|
|
16
|
+
* The gate runs only when `ctx.permissionPresets.current(session) === 'danger-full-access'` (the GUI preset labelled
|
|
17
|
+
* 完全权限). In the other presets DSH's own sandbox/approval answers the question and a second gate would only add
|
|
18
|
+
* surprises; the listener returns `next()` untouched.
|
|
17
19
|
*
|
|
18
|
-
* ##
|
|
20
|
+
* ## The budget still exists
|
|
19
21
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
+
* Whitelisted auto-approvals are still permission decisions made without a human reading them, so the same rolling
|
|
23
|
+
* window applies. When the budget is exhausted a whitelisted call does not silently pass: it falls into the pending
|
|
24
|
+
* queue like a class-C call. That is the "升级自动审批" half: automatic, but bounded and visible.
|
|
22
25
|
*
|
|
23
|
-
*
|
|
24
|
-
* - id: dsh-gpw
|
|
25
|
-
* name: '@twinsearth/w2m-dsh-plugin/gpw'
|
|
26
|
-
* config:
|
|
27
|
-
* maxPerWindow: 10 # optional; these are the defaults
|
|
28
|
-
* windowMs: 60000
|
|
29
|
-
* minGapMs: 3000
|
|
26
|
+
* ## Operator surface
|
|
30
27
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
28
|
+
* The panel is rendered by the w2m client (`lib/client.js`): a floating "GPW 文件夹" button opens the three sections
|
|
29
|
+
* (whitelist / blacklist / recently intercepted) plus the pending queue. Folders can be typed (validated against the
|
|
30
|
+
* filesystem here) or taken from the recently-intercepted list in one click. Lists live in a JSON file under
|
|
31
|
+
* `$DSH_HOME/xclient/`, never in the profile patch.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { spawn } from 'node:child_process';
|
|
35
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
36
|
+
import { homedir } from 'node:os';
|
|
37
|
+
import { dirname, join, resolve } from 'node:path';
|
|
38
|
+
import { randomUUID } from 'node:crypto';
|
|
39
|
+
|
|
40
|
+
import { classifyToolCall } from './folder-policy.mjs';
|
|
41
|
+
|
|
42
|
+
export const name = 'dsh-gpw';
|
|
43
|
+
|
|
44
|
+
/** Services this plugin cannot work without. `connection` is used only for the operator routes. */
|
|
45
|
+
export const inject = ['permissionPresets', 'tools'];
|
|
46
|
+
|
|
47
|
+
export const FULL_ACCESS_PRESET = 'danger-full-access';
|
|
48
|
+
export const GPW_ROUTE = '/api/w2m/gpw/state';
|
|
49
|
+
export const GPW_FOLDERS_ROUTE = '/api/w2m/gpw/folders';
|
|
50
|
+
export const GPW_DECIDE_ROUTE = '/api/w2m/gpw/decide';
|
|
51
|
+
export const GPW_PICK_ROUTE = '/api/w2m/gpw/pick';
|
|
52
|
+
|
|
53
|
+
const DEFAULT_BUDGET = { maxPerWindow: 10, windowMs: 60_000, minGapMs: 3_000 };
|
|
54
|
+
const DEFAULT_PENDING_TIMEOUT_MS = 120_000;
|
|
55
|
+
const RECENT_LIMIT = 20;
|
|
56
|
+
const NO_STORE = { 'cache-control': 'no-store' };
|
|
57
|
+
|
|
58
|
+
/** Resolve the folders file: config first, then `$DSH_HOME/xclient/gpw-folders.json`. */
|
|
59
|
+
export function foldersFileOf(config = {}) {
|
|
60
|
+
if (typeof config.foldersFile === 'string' && config.foldersFile.trim() !== '') return config.foldersFile.trim();
|
|
61
|
+
const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME !== '' ? process.env.DSH_HOME : join(homedir(), '.dsh');
|
|
62
|
+
return join(home, 'xclient', 'gpw-folders.json');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read the operator-managed lists. A missing or corrupt file is empty lists, never a crash on mount. */
|
|
66
|
+
export function readFolders(file) {
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
69
|
+
return {
|
|
70
|
+
whitelist: Array.isArray(parsed?.whitelist) ? parsed.whitelist.filter((v) => typeof v === 'string' && v !== '') : [],
|
|
71
|
+
blacklist: Array.isArray(parsed?.blacklist) ? parsed.blacklist.filter((v) => typeof v === 'string' && v !== '') : [],
|
|
72
|
+
};
|
|
73
|
+
} catch {
|
|
74
|
+
return { whitelist: [], blacklist: [] };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Persist the operator-managed lists. */
|
|
79
|
+
export function writeFolders(file, folders) {
|
|
80
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
81
|
+
const next = {
|
|
82
|
+
whitelist: [...new Set((folders?.whitelist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))],
|
|
83
|
+
blacklist: [...new Set((folders?.blacklist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))],
|
|
84
|
+
};
|
|
85
|
+
writeFileSync(file, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
|
86
|
+
return next;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Validate a typed folder before it enters a list.
|
|
40
91
|
*
|
|
41
|
-
*
|
|
92
|
+
* The panel's input is the one place the operator can write a path by hand, so the check belongs here: an absolute path
|
|
93
|
+
* to an existing directory. `resolve` normalises `..` and trailing separators so the same folder is one entry.
|
|
42
94
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* the original.
|
|
95
|
+
* @param {unknown} input
|
|
96
|
+
* @returns {{ok: true, path: string} | {ok: false, error: string}}
|
|
46
97
|
*/
|
|
98
|
+
export function validateFolderInput(input) {
|
|
99
|
+
if (typeof input !== 'string' || input.trim() === '') return { ok: false, error: 'a folder path is required' };
|
|
100
|
+
const raw = input.trim();
|
|
101
|
+
if (/^[a-zA-Z]:$/.test(raw) || raw === '/') return { ok: false, error: 'a drive root cannot be a policy folder' };
|
|
102
|
+
const absolute = resolve(raw);
|
|
103
|
+
try {
|
|
104
|
+
if (!statSync(absolute).isDirectory()) return { ok: false, error: `not a directory: ${absolute}` };
|
|
105
|
+
} catch {
|
|
106
|
+
return { ok: false, error: `folder does not exist: ${absolute}` };
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, path: absolute };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The nearest existing directory at or above `target`, for one-click "add this to a list". */
|
|
112
|
+
export function folderForPath(target) {
|
|
113
|
+
if (typeof target !== 'string' || target.trim() === '') return null;
|
|
114
|
+
let current = resolve(target.trim());
|
|
115
|
+
for (let depth = 0; depth < 40; depth += 1) {
|
|
116
|
+
try {
|
|
117
|
+
if (statSync(current).isDirectory()) return current;
|
|
118
|
+
} catch {
|
|
119
|
+
/* keep walking up */
|
|
120
|
+
}
|
|
121
|
+
const parent = dirname(current);
|
|
122
|
+
if (parent === current) return null;
|
|
123
|
+
current = parent;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
47
127
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
128
|
+
/** Map a tool-exec object onto the shape folder-policy understands. */
|
|
129
|
+
export function toolCallOf(exec) {
|
|
130
|
+
const parameters = exec?.parameters ?? exec?.arguments ?? exec?.input ?? exec?.args ?? exec?.toolCall?.parameters ?? null;
|
|
131
|
+
return {
|
|
132
|
+
name: typeof exec?.name === 'string' ? exec.name : (exec?.toolName ?? exec?.tool ?? null),
|
|
133
|
+
arguments: parameters,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
50
136
|
|
|
51
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Pure gate decision, exported so the three classes are testable without a session.
|
|
139
|
+
*
|
|
140
|
+
* @param {{classification: {decision: 'deny'|'allow'|'pass', reason?: string},
|
|
141
|
+
* autoAllowBudgetRemaining?: boolean}} input
|
|
142
|
+
* @returns {{kind: 'allow'|'deny'|'pending', reason: string}}
|
|
143
|
+
*/
|
|
144
|
+
export function planGate(input) {
|
|
145
|
+
const classification = input?.classification ?? { decision: 'pass', reason: '' };
|
|
146
|
+
if (classification.decision === 'deny') {
|
|
147
|
+
return { kind: 'deny', reason: classification.reason ?? 'the target is on the blacklist' };
|
|
148
|
+
}
|
|
149
|
+
if (classification.decision === 'allow') {
|
|
150
|
+
if (input.autoAllowBudgetRemaining === false) {
|
|
151
|
+
return { kind: 'pending', reason: 'whitelisted, but the auto-approval budget is exhausted' };
|
|
152
|
+
}
|
|
153
|
+
return { kind: 'allow', reason: classification.reason ?? 'the target is on the whitelist' };
|
|
154
|
+
}
|
|
155
|
+
return { kind: 'pending', reason: classification.reason ?? 'the target is on neither list' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Coerce a config value to a positive integer, falling back to the default. */
|
|
159
|
+
function positiveInt(value, fallback) {
|
|
160
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
161
|
+
}
|
|
52
162
|
|
|
53
|
-
|
|
163
|
+
/** Launch a native folder picker and resolve with its path, or null. Never rejects. */
|
|
164
|
+
function pickFolder() {
|
|
165
|
+
return new Promise((resolvePath) => {
|
|
166
|
+
const done = (value) => resolvePath(typeof value === 'string' && value.trim() !== '' ? value.trim() : null);
|
|
167
|
+
try {
|
|
168
|
+
if (process.platform === 'win32') {
|
|
169
|
+
const script = [
|
|
170
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
171
|
+
'$d = New-Object System.Windows.Forms.FolderBrowserDialog',
|
|
172
|
+
'$d.Description = "GPW: choose a folder for the whitelist or blacklist"',
|
|
173
|
+
'if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }',
|
|
174
|
+
].join('; ');
|
|
175
|
+
const child = spawn('powershell.exe', ['-NoProfile', '-STA', '-Command', script], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
176
|
+
let out = '';
|
|
177
|
+
let settled = false;
|
|
178
|
+
const finish = () => {
|
|
179
|
+
if (settled) return;
|
|
180
|
+
settled = true;
|
|
181
|
+
done(out.trim());
|
|
182
|
+
};
|
|
183
|
+
child.stdout?.on('data', (chunk) => {
|
|
184
|
+
out = (out + chunk.toString('utf8')).slice(-4096);
|
|
185
|
+
});
|
|
186
|
+
child.on('error', () => finish());
|
|
187
|
+
child.on('close', finish);
|
|
188
|
+
setTimeout(() => {
|
|
189
|
+
try {
|
|
190
|
+
child.kill();
|
|
191
|
+
} catch {
|
|
192
|
+
/* already gone */
|
|
193
|
+
}
|
|
194
|
+
finish();
|
|
195
|
+
}, 120_000).unref?.();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (process.platform === 'darwin') {
|
|
199
|
+
const child = spawn('osascript', ['-e', 'POSIX path of (choose folder with prompt "GPW: choose a folder")'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
200
|
+
let out = '';
|
|
201
|
+
child.stdout?.on('data', (chunk) => {
|
|
202
|
+
out += chunk.toString('utf8');
|
|
203
|
+
});
|
|
204
|
+
child.on('error', () => done(null));
|
|
205
|
+
child.on('close', () => done(out.trim()));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const child = spawn('zenity', ['--file-selection', '--directory', '--title=GPW: choose a folder'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
209
|
+
let out = '';
|
|
210
|
+
child.stdout?.on('data', (chunk) => {
|
|
211
|
+
out += chunk.toString('utf8');
|
|
212
|
+
});
|
|
213
|
+
child.on('error', () => done(null));
|
|
214
|
+
child.on('close', () => done(out.trim()));
|
|
215
|
+
} catch {
|
|
216
|
+
done(null);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
54
220
|
|
|
55
221
|
/**
|
|
56
222
|
* @param {object} ctx Cordis context.
|
|
57
223
|
* @param {object} [config]
|
|
58
|
-
* @param {number} [config.maxPerWindow] Auto-approvals allowed per rolling window. Default 10.
|
|
59
|
-
* @param {number} [config.windowMs] Window length in ms. Default 60000.
|
|
60
|
-
* @param {number} [config.minGapMs] Minimum gap between two auto-approvals, in ms. Default 3000.
|
|
61
224
|
*/
|
|
62
225
|
export function apply(ctx, config = {}) {
|
|
63
|
-
const maxPerWindow = positiveInt(config.maxPerWindow,
|
|
64
|
-
const windowMs = positiveInt(config.windowMs,
|
|
65
|
-
const minGapMs = positiveInt(config.minGapMs,
|
|
226
|
+
const maxPerWindow = positiveInt(config.maxPerWindow, DEFAULT_BUDGET.maxPerWindow);
|
|
227
|
+
const windowMs = positiveInt(config.windowMs, DEFAULT_BUDGET.windowMs);
|
|
228
|
+
const minGapMs = positiveInt(config.minGapMs, DEFAULT_BUDGET.minGapMs);
|
|
229
|
+
const pendingTimeoutMs = positiveInt(config.pendingTimeoutMs, DEFAULT_PENDING_TIMEOUT_MS);
|
|
230
|
+
const requireFullAccess = config.requireFullAccess !== false;
|
|
231
|
+
const foldersFile = foldersFileOf(config);
|
|
66
232
|
|
|
67
|
-
/** Timestamps of
|
|
233
|
+
/** Timestamps of auto-approvals in the current window. */
|
|
68
234
|
const approvals = [];
|
|
69
|
-
/** Timestamp of the most recent auto-approval, or null. */
|
|
70
235
|
let lastApprovalAt = null;
|
|
71
|
-
/** Counters for the periodic line, so the operator can see this thing working. */
|
|
72
|
-
let deferredTotal = 0;
|
|
73
236
|
let allowedTotal = 0;
|
|
237
|
+
let pendingTotal = 0;
|
|
238
|
+
let deniedTotal = 0;
|
|
239
|
+
/** id -> { resolve, createdAt, tool, reason, paths } */
|
|
240
|
+
const pending = new Map();
|
|
241
|
+
/** Newest-first record of calls the gate saw, so the panel can offer "add this folder" in one click. */
|
|
242
|
+
const recent = [];
|
|
74
243
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
* straddling a boundary (ten at the end of one minute, ten at the start of the next).
|
|
80
|
-
*
|
|
81
|
-
* @param {number} now
|
|
82
|
-
*/
|
|
83
|
-
function prune(now) {
|
|
84
|
-
const cutoff = now - windowMs;
|
|
85
|
-
while (approvals.length > 0 && approvals[0] <= cutoff) approvals.shift();
|
|
86
|
-
}
|
|
244
|
+
const noteRecent = (entry) => {
|
|
245
|
+
recent.unshift({ at: Date.now(), ...entry });
|
|
246
|
+
if (recent.length > RECENT_LIMIT) recent.length = RECENT_LIMIT;
|
|
247
|
+
};
|
|
87
248
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
249
|
+
const folderLists = () => {
|
|
250
|
+
const stored = readFolders(foldersFile);
|
|
251
|
+
const merge = (a, b) => [...new Set([...(Array.isArray(a) ? a : []), ...b])];
|
|
252
|
+
return {
|
|
253
|
+
whitelist: merge(config.whitelist, stored.whitelist),
|
|
254
|
+
blacklist: merge(config.blacklist, stored.blacklist),
|
|
255
|
+
};
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const prune = (nowMs) => {
|
|
259
|
+
while (approvals.length > 0 && nowMs - approvals[0] >= windowMs) approvals.shift();
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/** Consume one budget slot if one is available. */
|
|
263
|
+
const takeBudgetSlot = () => {
|
|
264
|
+
const nowMs = Date.now();
|
|
265
|
+
prune(nowMs);
|
|
266
|
+
if (lastApprovalAt !== null && nowMs - lastApprovalAt < minGapMs) return false;
|
|
267
|
+
if (approvals.length >= maxPerWindow) return false;
|
|
268
|
+
approvals.push(nowMs);
|
|
269
|
+
lastApprovalAt = nowMs;
|
|
270
|
+
allowedTotal += 1;
|
|
271
|
+
return true;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const isFullAccess = (agent) => {
|
|
275
|
+
try {
|
|
276
|
+
if (typeof ctx.permissionPresets?.current !== 'function') return false;
|
|
277
|
+
return ctx.permissionPresets.current(agent?.session) === FULL_ACCESS_PRESET;
|
|
278
|
+
} catch {
|
|
279
|
+
return false;
|
|
109
280
|
}
|
|
110
|
-
|
|
111
|
-
}
|
|
281
|
+
};
|
|
112
282
|
|
|
113
|
-
/**
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
logTimer = setInterval(() => {
|
|
123
|
-
if (allowedTotal === 0 && deferredTotal === 0) return;
|
|
124
|
-
ctx.logger?.info?.(
|
|
125
|
-
`approval-ratelimit: ${allowedTotal} auto-approved, ${deferredTotal} deferred to the human `
|
|
126
|
-
+ `(limit ${maxPerWindow}/${Math.round(windowMs / 1000)}s, gap ${minGapMs}ms, ${approvals.length} in the current window)`,
|
|
127
|
-
);
|
|
128
|
-
}, windowMs);
|
|
129
|
-
logTimer.unref?.();
|
|
130
|
-
return () => {
|
|
131
|
-
if (logTimer !== null) {
|
|
132
|
-
clearInterval(logTimer);
|
|
133
|
-
logTimer = null;
|
|
134
|
-
}
|
|
283
|
+
/** Hold a class-C (or over-budget B) call until the operator answers. */
|
|
284
|
+
const waitForOperator = (exec, reason, paths) => new Promise((resolveDecision) => {
|
|
285
|
+
const id = randomUUID();
|
|
286
|
+
const entry = {
|
|
287
|
+
resolve: resolveDecision,
|
|
288
|
+
createdAt: Date.now(),
|
|
289
|
+
tool: String(toolCallOf(exec).name ?? 'tool'),
|
|
290
|
+
reason,
|
|
291
|
+
paths,
|
|
135
292
|
};
|
|
293
|
+
pending.set(id, entry);
|
|
294
|
+
pendingTotal += 1;
|
|
295
|
+
const timer = setTimeout(() => {
|
|
296
|
+
if (!pending.has(id)) return;
|
|
297
|
+
pending.delete(id);
|
|
298
|
+
deniedTotal += 1;
|
|
299
|
+
resolveDecision({ kind: 'deny', reason: `GPW: no operator answer within ${Math.round(pendingTimeoutMs / 1000)} s for ${entry.tool}` });
|
|
300
|
+
}, pendingTimeoutMs);
|
|
301
|
+
timer.unref?.();
|
|
302
|
+
entry.timer = timer;
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const decidePending = (id, decision) => {
|
|
306
|
+
const entry = pending.get(id);
|
|
307
|
+
if (entry === undefined) return { ok: false, error: 'unknown pending request' };
|
|
308
|
+
pending.delete(id);
|
|
309
|
+
try {
|
|
310
|
+
clearTimeout(entry.timer);
|
|
311
|
+
} catch {
|
|
312
|
+
/* already fired */
|
|
313
|
+
}
|
|
314
|
+
if (decision === 'allow') {
|
|
315
|
+
allowedTotal += 1;
|
|
316
|
+
entry.resolve({ kind: 'allow', reason: 'GPW: allowed by the operator' });
|
|
317
|
+
} else {
|
|
318
|
+
deniedTotal += 1;
|
|
319
|
+
entry.resolve({ kind: 'deny', reason: 'GPW: denied by the operator' });
|
|
320
|
+
}
|
|
321
|
+
return { ok: true };
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// --- A: the blacklist denial must hold regardless of listener order -------------------------------------------------
|
|
325
|
+
let guard = null;
|
|
326
|
+
try {
|
|
327
|
+
guard = ctx.tools?.guard;
|
|
328
|
+
} catch {
|
|
329
|
+
guard = null; // older host without the guard extension: pre-execute still denies
|
|
136
330
|
}
|
|
331
|
+
guard?.((exec) => {
|
|
332
|
+
if (!isFullAccess(exec?.agent)) return undefined;
|
|
333
|
+
const lists = folderLists();
|
|
334
|
+
if (lists.blacklist.length === 0) return undefined;
|
|
335
|
+
const call = toolCallOf(exec);
|
|
336
|
+
const result = classifyToolCall({ ...call, blacklist: lists.blacklist });
|
|
337
|
+
if (result.decision !== 'deny') return undefined;
|
|
338
|
+
deniedTotal += 1;
|
|
339
|
+
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: result.reason, paths: result.paths });
|
|
340
|
+
ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- ${result.reason}`);
|
|
341
|
+
return `GPW: ${result.reason}; the call was not executed`;
|
|
342
|
+
});
|
|
137
343
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
deferredTotal += 1;
|
|
150
|
-
// Debug, not warn: deferring is the designed behaviour of this plugin, and warning about the
|
|
151
|
-
// normal path trains an operator to ignore the line that would matter.
|
|
152
|
-
ctx.logger?.debug?.(
|
|
153
|
-
`approval-ratelimit: deferring to the human -- ${verdict.reason}`
|
|
154
|
-
+ (request?.toolName ? ` (tool ${request.toolName})` : ''),
|
|
155
|
-
);
|
|
156
|
-
return next();
|
|
344
|
+
// --- B/C: the pre-execute gate ---------------------------------------------------------------------------------------
|
|
345
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
346
|
+
if (requireFullAccess && !isFullAccess(exec?.agent)) return next();
|
|
347
|
+
const lists = folderLists();
|
|
348
|
+
if (lists.blacklist.length === 0 && lists.whitelist.length === 0) return next();
|
|
349
|
+
const call = toolCallOf(exec);
|
|
350
|
+
let cwd = null;
|
|
351
|
+
try {
|
|
352
|
+
cwd = await ctx.workingDirectory?.ensure?.(exec.agent, exec.signal);
|
|
353
|
+
} catch {
|
|
354
|
+
cwd = null;
|
|
157
355
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
356
|
+
const classification = classifyToolCall({ ...call, whitelist: lists.whitelist, blacklist: lists.blacklist, workspace: cwd ?? null });
|
|
357
|
+
if (classification.decision === 'pass' && classification.paths.length === 0) return next();
|
|
358
|
+
const budget = classification.decision === 'allow' ? takeBudgetSlot() : false;
|
|
359
|
+
const plan = planGate({ classification, autoAllowBudgetRemaining: classification.decision === 'allow' ? budget !== false : undefined });
|
|
360
|
+
if (plan.kind === 'deny') {
|
|
361
|
+
deniedTotal += 1;
|
|
362
|
+
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: plan.reason, paths: classification.paths });
|
|
363
|
+
ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- ${plan.reason}`);
|
|
364
|
+
return { kind: 'deny', reason: `GPW: ${plan.reason}` };
|
|
365
|
+
}
|
|
366
|
+
if (plan.kind === 'allow') {
|
|
367
|
+
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'allow', reason: plan.reason, paths: classification.paths });
|
|
368
|
+
ctx.logger?.info?.(`dsh-gpw: auto-allowed ${String(call.name ?? 'tool')} -- ${plan.reason}`);
|
|
369
|
+
return { kind: 'allow' };
|
|
370
|
+
}
|
|
371
|
+
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'pending', reason: plan.reason, paths: classification.paths });
|
|
372
|
+
ctx.logger?.info?.(`dsh-gpw: holding ${String(call.name ?? 'tool')} for operator -- ${plan.reason}`);
|
|
373
|
+
return waitForOperator(exec, plan.reason, classification.paths);
|
|
167
374
|
});
|
|
168
375
|
|
|
169
|
-
|
|
376
|
+
// --- operator routes (folders + pending queue + recent list + native picker) -----------------------------------------
|
|
377
|
+
ctx.inject(['connection'], (webCtx) => {
|
|
378
|
+
const registry = webCtx?.connection?.fetch;
|
|
379
|
+
if (registry === undefined || typeof registry.register !== 'function') return;
|
|
380
|
+
const json = (body, status = 200) => Response.json(body, { status, headers: NO_STORE });
|
|
170
381
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
382
|
+
registry.register({
|
|
383
|
+
path: GPW_ROUTE,
|
|
384
|
+
methods: ['GET'],
|
|
385
|
+
requestBody: 'buffered',
|
|
386
|
+
fetch: async () => json({
|
|
387
|
+
ok: true,
|
|
388
|
+
enabled: true,
|
|
389
|
+
require_full_access: requireFullAccess,
|
|
390
|
+
folders: folderLists(),
|
|
391
|
+
counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow },
|
|
392
|
+
recent: recent.map((entry) => ({
|
|
393
|
+
at: entry.at,
|
|
394
|
+
tool: entry.tool,
|
|
395
|
+
decision: entry.decision,
|
|
396
|
+
reason: entry.reason,
|
|
397
|
+
paths: entry.paths,
|
|
398
|
+
folder: entry.paths.length > 0 ? folderForPath(entry.paths[0]) : null,
|
|
399
|
+
})),
|
|
400
|
+
pending: [...pending.entries()].map(([id, entry]) => ({
|
|
401
|
+
id,
|
|
402
|
+
tool: entry.tool,
|
|
403
|
+
reason: entry.reason,
|
|
404
|
+
paths: entry.paths,
|
|
405
|
+
pending_ms: Date.now() - entry.createdAt,
|
|
406
|
+
})),
|
|
407
|
+
}),
|
|
177
408
|
});
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
409
|
+
|
|
410
|
+
registry.register({
|
|
411
|
+
path: GPW_FOLDERS_ROUTE,
|
|
412
|
+
methods: ['POST'],
|
|
413
|
+
requestBody: 'buffered',
|
|
414
|
+
fetch: async (request) => {
|
|
415
|
+
let body = null;
|
|
416
|
+
try {
|
|
417
|
+
body = await request.json();
|
|
418
|
+
} catch {
|
|
419
|
+
/* refused below */
|
|
420
|
+
}
|
|
421
|
+
const list = body?.list;
|
|
422
|
+
if (list !== 'whitelist' && list !== 'blacklist') return json({ ok: false, error: 'list must be whitelist or blacklist' }, 400);
|
|
423
|
+
if (body?.action !== 'add' && body?.action !== 'remove') return json({ ok: false, error: 'action must be add or remove' }, 400);
|
|
424
|
+
if (body.action === 'remove') {
|
|
425
|
+
if (typeof body?.path !== 'string' || body.path.trim() === '') return json({ ok: false, error: 'path must be a non-empty string' }, 400);
|
|
426
|
+
const stored = readFolders(foldersFile);
|
|
427
|
+
const next = { ...stored, [list]: stored[list].filter((entry) => entry !== body.path) };
|
|
428
|
+
writeFolders(foldersFile, next);
|
|
429
|
+
return json({ ok: true, folders: folderLists() });
|
|
430
|
+
}
|
|
431
|
+
const validated = validateFolderInput(body?.path);
|
|
432
|
+
if (!validated.ok) return json({ ok: false, error: validated.error }, 400);
|
|
433
|
+
const stored = readFolders(foldersFile);
|
|
434
|
+
const next = { ...stored, [list]: [...new Set([...stored[list], validated.path])] };
|
|
435
|
+
writeFolders(foldersFile, next);
|
|
436
|
+
ctx.logger?.info?.(`dsh-gpw: added ${validated.path} to ${list}`);
|
|
437
|
+
return json({ ok: true, folders: folderLists() });
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
registry.register({
|
|
442
|
+
path: GPW_DECIDE_ROUTE,
|
|
443
|
+
methods: ['POST'],
|
|
444
|
+
requestBody: 'buffered',
|
|
445
|
+
fetch: async (request) => {
|
|
446
|
+
let body = null;
|
|
447
|
+
try {
|
|
448
|
+
body = await request.json();
|
|
449
|
+
} catch {
|
|
450
|
+
/* refused below */
|
|
451
|
+
}
|
|
452
|
+
if (typeof body?.id !== 'string' || (body?.decision !== 'allow' && body?.decision !== 'deny')) {
|
|
453
|
+
return json({ ok: false, error: 'id and decision (allow|deny) are required' }, 400);
|
|
454
|
+
}
|
|
455
|
+
const result = decidePending(body.id, body.decision);
|
|
456
|
+
return json(result.ok ? { ok: true } : { ok: false, error: result.error }, result.ok ? 200 : 404);
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
registry.register({
|
|
461
|
+
path: GPW_PICK_ROUTE,
|
|
462
|
+
methods: ['POST'],
|
|
463
|
+
requestBody: 'buffered',
|
|
464
|
+
fetch: async () => json({ ok: true, path: await pickFolder() }),
|
|
465
|
+
});
|
|
466
|
+
});
|
|
185
467
|
|
|
186
468
|
ctx.logger?.info?.(
|
|
187
|
-
`
|
|
188
|
-
+ `
|
|
469
|
+
`dsh-gpw: active -- full-access only=${requireFullAccess}, at most ${maxPerWindow} auto-approvals per ${Math.round(windowMs / 1000)} s, `
|
|
470
|
+
+ `pending timeout ${Math.round(pendingTimeoutMs / 1000)} s; blacklist denies, whitelist passes, everything else waits for the operator`,
|
|
189
471
|
);
|
|
190
472
|
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Coerce a config value to a positive integer, falling back to the default.
|
|
194
|
-
*
|
|
195
|
-
* A misconfigured limit must not silently become "0", which would disable the plugin while looking
|
|
196
|
-
* like it was on -- and must not become a non-finite number, which would break the comparison.
|
|
197
|
-
*
|
|
198
|
-
* @param {unknown} value
|
|
199
|
-
* @param {number} fallback
|
|
200
|
-
* @returns {number}
|
|
201
|
-
*/
|
|
202
|
-
function positiveInt(value, fallback) {
|
|
203
|
-
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback;
|
|
204
|
-
return Math.floor(value);
|
|
205
|
-
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -283,7 +283,7 @@ const MAX_STAGES = 16;
|
|
|
283
283
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
284
284
|
* overrides it at runtime for tests and packagers.
|
|
285
285
|
*/
|
|
286
|
-
const PLUGIN_VERSION = '1.7.
|
|
286
|
+
const PLUGIN_VERSION = '1.7.7';
|
|
287
287
|
|
|
288
288
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
289
289
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|