@twinsearth/w2m-dsh-plugin 1.7.3 → 1.7.6

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.
@@ -1,205 +1,466 @@
1
1
  /**
2
- * DSH-GPW -- Global Permission Watchdog (v1.0.5), shipped as an OPTIONAL sub-plugin of @twinsearth/w2m-dsh-plugin.
2
+ * DSH-GPW v2 (v1.7.5-beta.1) — Global Permission Watchdog, now a folder gate rather than an approval answerer.
3
3
  *
4
- * ## What it does, in one paragraph
4
+ * ## The three classes and who answers
5
5
  *
6
- * It answers permission requests on the `approval/request` waterfall with a hard global budget: **at most 10
7
- * auto-approvals per rolling 60 s, at least 3000 ms apart.** Anything past either limit returns `next()`, which is
8
- * *"no opinion"* -- the request falls through to the human prompt and **waits**. It never refuses, and it never
9
- * changes what is being asked; it only decides who answers and how fast.
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
- * ## Why the budget exists
14
+ * ## Full-access only
12
15
  *
13
- * Every auto-approval is a permission decision made without a human reading it. A watchdog with no ceiling is a
14
- * plugin that grants everything and is indistinguishable from one that is working correctly -- the failure mode this
15
- * repository has recorded seven times. The two counters it logs (`allowedTotal`, `deferredTotal`) exist so that
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
- * ## How it is enabled -- it is NOT on by default
20
+ * ## The budget still exists
19
21
  *
20
- * Installing this package does **not** mount the watchdog: its own `dsh.bundle.patch` mounts only the w2m plugin.
21
- * To enable it, add ONE `insert:` to the profile's `cordis.patch.yml`:
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
- * - insert:
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
- * `insert:` is required: no earlier layer creates a `dsh-gpw` row, and a bare `- id:` patch aimed at a
32
- * non-existent row is skipped **silently**. Remove the row to disable it again -- no other change is needed.
33
- *
34
- * ## Its dependency, and the bug that shipped in the standalone copy
35
- *
36
- * `inject = ['approval']`. The first version of this module declared `{ required: ['approval'] }`; the host reads
37
- * `inject` as an array (**283 array-shaped declarations across the shipped tree, 0 object-shaped**), so it waited
38
- * for a service named `required` and stayed `pending (waiting for service: required)` forever. `approval` itself
39
- * exists: `@deepseek-ai/dsh-base`'s patch mounts `@deepseek-ai/dsh-user-approval` as id `approval`.
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'];
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
- * ## Provenance
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
- * Written as a standalone package `dsh-approval-ratelimit@1.0.0`, then folded in here so a fleet operator installs
44
- * one thing. The logic is unchanged apart from the name and the `inject` fix; every comment explaining a decision is
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
+ }
47
110
 
48
- /** @type {import('node:timers').Timeout | null} */
49
- let logTimer = null;
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
+ }
50
127
 
51
- export const name = 'dsh-gpw';
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
+ }
52
136
 
53
- export const inject = ['approval'];
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
+ }
162
+
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, 10);
64
- const windowMs = positiveInt(config.windowMs, 60_000);
65
- const minGapMs = positiveInt(config.minGapMs, 3_000);
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 the auto-approvals inside the current window, oldest first. */
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
- * Drop approvals that have aged out of the window.
77
- *
78
- * A rolling window is the requirement; a fixed one would allow a burst of `2 * maxPerWindow`
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
- * Decide one request, without ever rejecting.
90
- *
91
- * @param {number} now
92
- * @returns {{ allow: boolean, reason: string, inWindow: number }}
93
- */
94
- function decide(now) {
95
- prune(now);
96
- if (lastApprovalAt !== null && now - lastApprovalAt < minGapMs) {
97
- return {
98
- allow: false,
99
- reason: `only ${now - lastApprovalAt} ms since the last auto-approval (needs ${minGapMs} ms)`,
100
- inWindow: approvals.length,
101
- };
102
- }
103
- if (approvals.length >= maxPerWindow) {
104
- return {
105
- allow: false,
106
- reason: `${approvals.length} auto-approvals already in the last ${Math.round(windowMs / 1000)} s (limit ${maxPerWindow})`,
107
- inWindow: approvals.length,
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
- return { allow: true, reason: 'within both limits', inWindow: approvals.length };
111
- }
281
+ };
112
282
 
113
- /**
114
- * One line per window of real activity, so a silent plugin and a broken plugin look different.
115
- *
116
- * The failure mode this guards against is the one this whole session keeps meeting: a component
117
- * that is mounted, doing nothing, and indistinguishable from a healthy one. `allowedTotal` and
118
- * `deferredTotal` are the two numbers that tell them apart.
119
- */
120
- function startReporter() {
121
- if (logTimer !== null) return () => {};
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
  };
136
- }
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
+ });
137
304
 
138
- /**
139
- * The answerer.
140
- *
141
- * Registered on the `approval/request` waterfall. Returning `'allowed-once'` grants this one
142
- * request; returning `next()` is "no opinion" and hands the decision to whoever answers next --
143
- * which is the human prompt, and it *waits* there rather than being refused.
144
- */
145
- const disposeListener = ctx.on('approval/request', (request, next) => {
146
- const now = Date.now();
147
- const verdict = decide(now);
148
- if (!verdict.allow) {
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();
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 */
157
313
  }
158
- approvals.push(now);
159
- lastApprovalAt = now;
160
- allowedTotal += 1;
161
- ctx.logger?.info?.(
162
- `approval-ratelimit: auto-approved (${approvals.length}/${maxPerWindow} in the current window)`
163
- + (request?.toolName ? ` tool ${request.toolName}` : '')
164
- + (request?.reason ? ` -- ${request.reason}` : ''),
165
- );
166
- return 'allowed-once';
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
+ ctx.tools?.guard?.((exec) => {
326
+ if (!isFullAccess(exec?.agent)) return undefined;
327
+ const lists = folderLists();
328
+ if (lists.blacklist.length === 0) return undefined;
329
+ const call = toolCallOf(exec);
330
+ const result = classifyToolCall({ ...call, blacklist: lists.blacklist });
331
+ if (result.decision !== 'deny') return undefined;
332
+ deniedTotal += 1;
333
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: result.reason, paths: result.paths });
334
+ ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- ${result.reason}`);
335
+ return `GPW: ${result.reason}; the call was not executed`;
336
+ });
337
+
338
+ // --- B/C: the pre-execute gate ---------------------------------------------------------------------------------------
339
+ ctx.on('tools/pre-execute', async (exec, next) => {
340
+ if (requireFullAccess && !isFullAccess(exec?.agent)) return next();
341
+ const lists = folderLists();
342
+ if (lists.blacklist.length === 0 && lists.whitelist.length === 0) return next();
343
+ const call = toolCallOf(exec);
344
+ let cwd = null;
345
+ try {
346
+ cwd = await ctx.workingDirectory?.ensure?.(exec.agent, exec.signal);
347
+ } catch {
348
+ cwd = null;
349
+ }
350
+ const classification = classifyToolCall({ ...call, whitelist: lists.whitelist, blacklist: lists.blacklist, workspace: cwd ?? null });
351
+ if (classification.decision === 'pass' && classification.paths.length === 0) return next();
352
+ const budget = classification.decision === 'allow' ? takeBudgetSlot() : false;
353
+ const plan = planGate({ classification, autoAllowBudgetRemaining: classification.decision === 'allow' ? budget !== false : undefined });
354
+ if (plan.kind === 'deny') {
355
+ deniedTotal += 1;
356
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: plan.reason, paths: classification.paths });
357
+ ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- ${plan.reason}`);
358
+ return { kind: 'deny', reason: `GPW: ${plan.reason}` };
359
+ }
360
+ if (plan.kind === 'allow') {
361
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'allow', reason: plan.reason, paths: classification.paths });
362
+ ctx.logger?.info?.(`dsh-gpw: auto-allowed ${String(call.name ?? 'tool')} -- ${plan.reason}`);
363
+ return { kind: 'allow' };
364
+ }
365
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'pending', reason: plan.reason, paths: classification.paths });
366
+ ctx.logger?.info?.(`dsh-gpw: holding ${String(call.name ?? 'tool')} for operator -- ${plan.reason}`);
367
+ return waitForOperator(exec, plan.reason, classification.paths);
167
368
  });
168
369
 
169
- const stopReporter = startReporter();
370
+ // --- operator routes (folders + pending queue + recent list + native picker) -----------------------------------------
371
+ ctx.inject(['connection'], (webCtx) => {
372
+ const registry = webCtx?.connection?.fetch;
373
+ if (registry === undefined || typeof registry.register !== 'function') return;
374
+ const json = (body, status = 200) => Response.json(body, { status, headers: NO_STORE });
170
375
 
171
- // Reversible: the host owns this effect, so an unload removes the answerer and the timer with it.
172
- // A listener that outlived its plugin would keep approving requests with nobody watching.
173
- if (typeof ctx.effect === 'function') {
174
- ctx.effect(() => () => {
175
- disposeListener?.();
176
- stopReporter();
376
+ registry.register({
377
+ path: GPW_ROUTE,
378
+ methods: ['GET'],
379
+ requestBody: 'buffered',
380
+ fetch: async () => json({
381
+ ok: true,
382
+ enabled: true,
383
+ require_full_access: requireFullAccess,
384
+ folders: folderLists(),
385
+ counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow },
386
+ recent: recent.map((entry) => ({
387
+ at: entry.at,
388
+ tool: entry.tool,
389
+ decision: entry.decision,
390
+ reason: entry.reason,
391
+ paths: entry.paths,
392
+ folder: entry.paths.length > 0 ? folderForPath(entry.paths[0]) : null,
393
+ })),
394
+ pending: [...pending.entries()].map(([id, entry]) => ({
395
+ id,
396
+ tool: entry.tool,
397
+ reason: entry.reason,
398
+ paths: entry.paths,
399
+ pending_ms: Date.now() - entry.createdAt,
400
+ })),
401
+ }),
177
402
  });
178
- } else {
179
- // No effect seam: say so rather than pretending the cleanup is guaranteed.
180
- ctx.logger?.warn?.(
181
- 'approval-ratelimit: the context provides no ctx.effect, so the answerer and its reporter '
182
- + 'cannot be released on unload',
183
- );
184
- }
403
+
404
+ registry.register({
405
+ path: GPW_FOLDERS_ROUTE,
406
+ methods: ['POST'],
407
+ requestBody: 'buffered',
408
+ fetch: async (request) => {
409
+ let body = null;
410
+ try {
411
+ body = await request.json();
412
+ } catch {
413
+ /* refused below */
414
+ }
415
+ const list = body?.list;
416
+ if (list !== 'whitelist' && list !== 'blacklist') return json({ ok: false, error: 'list must be whitelist or blacklist' }, 400);
417
+ if (body?.action !== 'add' && body?.action !== 'remove') return json({ ok: false, error: 'action must be add or remove' }, 400);
418
+ if (body.action === 'remove') {
419
+ if (typeof body?.path !== 'string' || body.path.trim() === '') return json({ ok: false, error: 'path must be a non-empty string' }, 400);
420
+ const stored = readFolders(foldersFile);
421
+ const next = { ...stored, [list]: stored[list].filter((entry) => entry !== body.path) };
422
+ writeFolders(foldersFile, next);
423
+ return json({ ok: true, folders: folderLists() });
424
+ }
425
+ const validated = validateFolderInput(body?.path);
426
+ if (!validated.ok) return json({ ok: false, error: validated.error }, 400);
427
+ const stored = readFolders(foldersFile);
428
+ const next = { ...stored, [list]: [...new Set([...stored[list], validated.path])] };
429
+ writeFolders(foldersFile, next);
430
+ ctx.logger?.info?.(`dsh-gpw: added ${validated.path} to ${list}`);
431
+ return json({ ok: true, folders: folderLists() });
432
+ },
433
+ });
434
+
435
+ registry.register({
436
+ path: GPW_DECIDE_ROUTE,
437
+ methods: ['POST'],
438
+ requestBody: 'buffered',
439
+ fetch: async (request) => {
440
+ let body = null;
441
+ try {
442
+ body = await request.json();
443
+ } catch {
444
+ /* refused below */
445
+ }
446
+ if (typeof body?.id !== 'string' || (body?.decision !== 'allow' && body?.decision !== 'deny')) {
447
+ return json({ ok: false, error: 'id and decision (allow|deny) are required' }, 400);
448
+ }
449
+ const result = decidePending(body.id, body.decision);
450
+ return json(result.ok ? { ok: true } : { ok: false, error: result.error }, result.ok ? 200 : 404);
451
+ },
452
+ });
453
+
454
+ registry.register({
455
+ path: GPW_PICK_ROUTE,
456
+ methods: ['POST'],
457
+ requestBody: 'buffered',
458
+ fetch: async () => json({ ok: true, path: await pickFolder() }),
459
+ });
460
+ });
185
461
 
186
462
  ctx.logger?.info?.(
187
- `approval-ratelimit: active -- at most ${maxPerWindow} auto-approvals per ${Math.round(windowMs / 1000)} s, `
188
- + `at least ${minGapMs} ms apart, process-wide; everything else waits for the human`,
463
+ `dsh-gpw: active -- full-access only=${requireFullAccess}, at most ${maxPerWindow} auto-approvals per ${Math.round(windowMs / 1000)} s, `
464
+ + `pending timeout ${Math.round(pendingTimeoutMs / 1000)} s; blacklist denies, whitelist passes, everything else waits for the operator`,
189
465
  );
190
466
  }
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
- }