@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.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * GPW v2 — folder policy for the approval gate (v1.7.5-beta.1).
3
+ *
4
+ * Folders are classified into exactly three classes, and the class decides who answers:
5
+ *
6
+ * A. blacklist -> denied outright (also registered as a synchronous tools.guard, so order cannot save it)
7
+ * B. whitelist -> allowed without a human (the whitelist is an explicit extension of the workspace)
8
+ * C. everything else -> a GPW pending approval (the plugin's own queue, not DSH's approval policy)
9
+ *
10
+ * ## Why this is a pure module
11
+ *
12
+ * The dangerous part of a path rule is not the decision, it is the comparison. `C:\work` must not contain
13
+ * `C:\workshop`; a trailing separator must not change the answer; `..` must be resolved before comparing, and on Windows
14
+ * the comparison must be case-insensitive while on POSIX it must not be. Those rules are cheap to test and expensive to
15
+ * discover in production, so they live here with no filesystem, no session and no plugin context.
16
+ *
17
+ * ## Deny wins
18
+ *
19
+ * If a folder is on both lists the operator contradicts themselves; the safe reading of a contradiction about access is
20
+ * the restrictive one. A call that touches one blacklisted path is denied even when another path is whitelisted.
21
+ */
22
+
23
+ import { posix, win32 } from 'node:path';
24
+
25
+ /**
26
+ * Normalise one configured folder.
27
+ *
28
+ * @param {string} folder
29
+ * @param {{base?: string, platform?: string}} [options]
30
+ * @returns {string} absolute, no trailing separator, lowercased on win32
31
+ */
32
+ export function normalizeFolder(folder, options = {}) {
33
+ if (typeof folder !== 'string' || folder.trim() === '') throw new TypeError('a folder must be a non-empty string');
34
+ const platform = options.platform ?? process.platform;
35
+ const base = options.base ?? process.cwd();
36
+ const raw = folder.trim();
37
+ const p = platform === 'win32' ? win32 : posix;
38
+ const absolute = p.isAbsolute(raw) ? raw : p.resolve(base, raw);
39
+ let out = p.normalize(absolute);
40
+ while (out.length > 1 && out.endsWith(p.sep) && !/^[A-Za-z]:[\\/]$/.test(out)) out = out.slice(0, -1);
41
+ return platform === 'win32' ? out.toLowerCase() : out;
42
+ }
43
+
44
+ /**
45
+ * Whether `target` is inside `folder` (or is the folder itself), compared segment-wise.
46
+ *
47
+ * @param {string} target already normalised
48
+ * @param {string} folder already normalised
49
+ * @param {{platform?: string}} [options]
50
+ * @returns {boolean}
51
+ */
52
+ export function isInside(target, folder, options = {}) {
53
+ const platform = options.platform ?? process.platform;
54
+ const t = platform === 'win32' ? target.toLowerCase() : target;
55
+ const f = platform === 'win32' ? folder.toLowerCase() : folder;
56
+ if (t === f) return true;
57
+ const sep = platform === 'win32' ? '\\' : '/';
58
+ const withSep = f.endsWith(sep) ? f : f + sep;
59
+ return t.startsWith(withSep);
60
+ }
61
+
62
+ /**
63
+ * Classify one target against the two lists and the workspace.
64
+ *
65
+ * @param {{target: string, whitelist?: string[], blacklist?: string[], workspace?: string|null,
66
+ * base?: string, platform?: string}} input
67
+ * @returns {{decision: 'deny'|'allow'|'pass', target: string, folder: string|null, reason: string}}
68
+ */
69
+ export function classifyPath(input) {
70
+ const platform = input.platform ?? process.platform;
71
+ const base = input.base ?? process.cwd();
72
+ const target = normalizeFolder(input.target, { base, platform });
73
+ const normalise = (list) => (Array.isArray(list) ? list : []).map((f) => normalizeFolder(f, { base, platform }));
74
+
75
+ for (const folder of normalise(input.blacklist)) {
76
+ if (isInside(target, folder, { platform })) {
77
+ return { decision: 'deny', target, folder, reason: `the folder "${folder}" is on the blacklist` };
78
+ }
79
+ }
80
+ for (const folder of normalise(input.whitelist)) {
81
+ if (isInside(target, folder, { platform })) {
82
+ return { decision: 'allow', target, folder, reason: `the folder "${folder}" is on the whitelist` };
83
+ }
84
+ }
85
+ if (typeof input.workspace === 'string' && input.workspace !== '') {
86
+ const workspace = normalizeFolder(input.workspace, { base, platform });
87
+ if (isInside(target, workspace, { platform })) {
88
+ return { decision: 'allow', target, folder: workspace, reason: 'the target is inside the workspace' };
89
+ }
90
+ }
91
+ return { decision: 'pass', target, folder: null, reason: 'no list mentions this target, so GPW asks the operator' };
92
+ }
93
+
94
+ /** Fields that carry a single filesystem path in the tool schemas this plugin sees. */
95
+ const PATH_FIELDS = ['path', 'file_path', 'filePath', 'target', 'cwd', 'old_path', 'oldPath', 'new_path', 'newPath', 'directory', 'dir', 'folder', 'root'];
96
+
97
+ /** Tools whose argument is a command line rather than a structured path; their text is scanned for absolute paths. */
98
+ const COMMAND_TOOL = /^(bash|pwsh|powershell|shell|terminal|run_command|exec)$/i;
99
+
100
+ /**
101
+ * Every path a tool call touches.
102
+ *
103
+ * Structured fields are exact; a command tool has no structured path, so absolute-looking tokens are extracted and then
104
+ * classified. That heuristic is stated rather than hidden: it can miss a path built at runtime, and it is the only
105
+ * information available before execution.
106
+ *
107
+ * @param {{name?: string, arguments?: string|object}} call
108
+ * @returns {string[]} raw (not yet normalised) path candidates
109
+ */
110
+ export function pathsOfToolCall(call) {
111
+ let args = call?.arguments;
112
+ if (typeof args === 'string') {
113
+ if (args.trim() === '') return [];
114
+ try {
115
+ args = JSON.parse(args);
116
+ } catch {
117
+ // Not JSON: for a command tool the raw string is the command line; otherwise it names nothing usable.
118
+ args = null;
119
+ }
120
+ }
121
+ const out = [];
122
+ const push = (value) => {
123
+ if (typeof value === 'string' && value.trim() !== '') out.push(value);
124
+ };
125
+ if (args !== null && typeof args === 'object') {
126
+ for (const field of PATH_FIELDS) push(args[field]);
127
+ if (Array.isArray(args.targets)) for (const value of args.targets) push(value);
128
+ if (Array.isArray(args.paths)) for (const value of args.paths) push(value);
129
+ }
130
+ if (out.length === 0 && COMMAND_TOOL.test(String(call?.name ?? ''))) {
131
+ const text = typeof call?.arguments === 'string' && call.arguments.trim().startsWith('{') === false
132
+ ? call.arguments
133
+ : typeof args?.command === 'string'
134
+ ? args.command
135
+ : typeof args?.cmd === 'string'
136
+ ? args.cmd
137
+ : typeof call?.arguments === 'string'
138
+ ? call.arguments
139
+ : JSON.stringify(args ?? '');
140
+ const matches = text.match(/(?:[A-Za-z]:[\\/][^"'\s|;&<>]*|\\\\[^"'\s|;&<>]+|\/(?:[^"'\s|;&<>]+\/?)+)/g) ?? [];
141
+ for (const m of matches) push(m);
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * Classify a whole tool call. Deny wins over allow: one blacklisted path refuses the call even when another path is
148
+ * whitelisted because a call that writes one protected file and one ordinary file must not be approved on the strength of
149
+ * the ordinary one.
150
+ *
151
+ * @param {{name?: string, arguments?: string|object, whitelist?: string[], blacklist?: string[],
152
+ * workspace?: string|null, base?: string, platform?: string}} call
153
+ * @returns {{decision: 'deny'|'allow'|'pass', path: string|null, folder: string|null, reason: string, paths: string[]}}
154
+ */
155
+ export function classifyToolCall(call) {
156
+ const candidates = pathsOfToolCall(call);
157
+ let weakest = { decision: 'pass', path: null, folder: null, reason: 'this call names no path', paths: [] };
158
+ for (const candidate of candidates) {
159
+ const result = classifyPath({
160
+ target: candidate,
161
+ whitelist: call.whitelist,
162
+ blacklist: call.blacklist,
163
+ workspace: call.workspace,
164
+ base: call.base,
165
+ platform: call.platform,
166
+ });
167
+ if (result.decision === 'deny') {
168
+ return { decision: 'deny', path: candidate, folder: result.folder, reason: result.reason, paths: candidates };
169
+ }
170
+ if (result.decision === 'allow') {
171
+ weakest = { decision: 'allow', path: candidate, folder: result.folder, reason: result.reason, paths: candidates };
172
+ } else if (weakest.path === null) {
173
+ weakest = { decision: 'pass', path: candidate, folder: null, reason: result.reason, paths: candidates };
174
+ }
175
+ }
176
+ return weakest;
177
+ }