@twinsearth/w2m-dsh-plugin 1.7.4 → 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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +27 -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/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 +426 -165
- package/src/plugin/tools.mjs +1 -1
|
@@ -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
|
+
}
|