@netcatty/dsh-guard 0.0.0-stage → 0.2.0
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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +595 -2
- package/cordis.patch.yml +3 -0
- package/lib/audit-log.js +64 -0
- package/lib/client.js +679 -0
- package/lib/diff-view.js +155 -0
- package/lib/index.js +1954 -0
- package/lib/jsonc.js +140 -0
- package/lib/mask.js +22 -0
- package/lib/match.js +119 -0
- package/lib/params.js +105 -0
- package/lib/paths.js +140 -0
- package/lib/policy-edit.js +74 -0
- package/lib/presets.js +50 -0
- package/lib/preview.js +152 -0
- package/lib/shell.js +127 -0
- package/lib/snapshots.js +198 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
- package/policy.json +289 -0
- package/policy.schema.json +317 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1954 @@
|
|
|
1
|
+
// dsh-guard — allow / ask / deny gates for DeepSeek Harness tool calls.
|
|
2
|
+
//
|
|
3
|
+
// Surfaces (all optional, defaults below):
|
|
4
|
+
// path cross-cutting file-path rules, both directions
|
|
5
|
+
// pathRead/pathWrite directional overrides
|
|
6
|
+
// externalDirectory cwd boundary
|
|
7
|
+
// bash command globs (last matching rule wins)
|
|
8
|
+
// tools.<name> per-tool path rules (read, write, edit, grep, glob, mcp__*)
|
|
9
|
+
//
|
|
10
|
+
// Decision rules: within one surface the LAST matching rule wins; across
|
|
11
|
+
// surfaces the most restrictive wins (deny > ask > allow). Invalid
|
|
12
|
+
// configuration fails closed: every call is denied with the validation errors.
|
|
13
|
+
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
16
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, watchFile, unwatchFile, writeFileSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { realpath } from 'node:fs/promises';
|
|
20
|
+
import { decomposeShellCommand } from './shell.js';
|
|
21
|
+
import { test as testPattern, check as checkPattern, DEFAULT_MAX_STARS } from './match.js';
|
|
22
|
+
import { flattenArguments, entryKeys, agentCandidates, agentLabel } from './params.js';
|
|
23
|
+
import { auditPath as defaultAuditPath, statusPath as defaultStatusPath, policyPath, snapshotsDir, ensureConfigDir, migrateLegacy, seedPolicy } from './paths.js';
|
|
24
|
+
import { capture, listSnapshots, restore, snapshotTargets, describeSnapshot, DEFAULT_MAX_BYTES, DEFAULT_MAX_FILES, DEFAULT_RETAIN } from './snapshots.js';
|
|
25
|
+
import { parseJsonc } from './jsonc.js';
|
|
26
|
+
import { previewFor, MAX_READ_BYTES } from './preview.js';
|
|
27
|
+
import { applyPatch, deepMerge, summarize } from './policy-edit.js';
|
|
28
|
+
import { getPreset, listPresets } from './presets.js';
|
|
29
|
+
import { readTail, describeDecision } from './audit-log.js';
|
|
30
|
+
import { remember as rememberDiff, lookup as lookupDiff, renderPage as renderDiffPage } from './diff-view.js';
|
|
31
|
+
|
|
32
|
+
export const name = 'guard';
|
|
33
|
+
function packageVersion() {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
36
|
+
} catch {
|
|
37
|
+
return '0.0.0';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const VERSION = packageVersion();
|
|
42
|
+
export const inject = ['tools', 'workingDirectory', 'sessionProjections'];
|
|
43
|
+
|
|
44
|
+
/** How many decisions `guard_status` and `status.json` keep in memory. */
|
|
45
|
+
export const RECENT_LIMIT = 10;
|
|
46
|
+
|
|
47
|
+
const SEVERITY = { allow: 0, ask: 1, deny: 2 };
|
|
48
|
+
const STATES = new Set(['allow', 'ask', 'deny']);
|
|
49
|
+
const APPROVAL_MODES = new Set(['approval', 'question', 'dialog']);
|
|
50
|
+
|
|
51
|
+
const DEFAULT_PERMISSION = {
|
|
52
|
+
'*': 'allow',
|
|
53
|
+
path: {
|
|
54
|
+
'*': 'allow',
|
|
55
|
+
'*.env': 'deny',
|
|
56
|
+
'*.env.*': 'deny',
|
|
57
|
+
'*id_rsa': 'deny',
|
|
58
|
+
'*id_ed25519': 'deny',
|
|
59
|
+
'*id_ecdsa': 'deny',
|
|
60
|
+
'*id_dsa': 'deny',
|
|
61
|
+
'*/.ssh': 'deny',
|
|
62
|
+
'*/.ssh/*': 'deny',
|
|
63
|
+
'*/.aws/*': 'deny',
|
|
64
|
+
'*/.gnupg/*': 'deny',
|
|
65
|
+
'*/.netrc': 'deny',
|
|
66
|
+
'*/.git-credentials': 'deny',
|
|
67
|
+
'*/.npmrc': 'deny',
|
|
68
|
+
'*/.pypirc': 'deny',
|
|
69
|
+
'*/.docker/config.json': 'deny',
|
|
70
|
+
'*/.kube/config': 'deny',
|
|
71
|
+
'*/.config/gh/hosts.yml': 'deny',
|
|
72
|
+
'*/.dsh/.credentials.yaml': 'deny',
|
|
73
|
+
'*.env.example': 'allow',
|
|
74
|
+
},
|
|
75
|
+
pathRead: {},
|
|
76
|
+
pathWrite: {},
|
|
77
|
+
externalDirectory: { '*': 'allow' },
|
|
78
|
+
bash: { '*': 'allow' },
|
|
79
|
+
tools: {},
|
|
80
|
+
params: {},
|
|
81
|
+
toolParams: {},
|
|
82
|
+
agents: {},
|
|
83
|
+
argv: [],
|
|
84
|
+
opaque: 'ask',
|
|
85
|
+
sandboxAware: true,
|
|
86
|
+
yolo: false,
|
|
87
|
+
audit: true,
|
|
88
|
+
auditMaxBytes: 5 * 1024 * 1024,
|
|
89
|
+
projectPolicy: true,
|
|
90
|
+
projectPolicyStrict: false,
|
|
91
|
+
projectPolicySearchUp: true,
|
|
92
|
+
caseInsensitivePaths: process.platform === 'win32',
|
|
93
|
+
patternMode: 'glob',
|
|
94
|
+
maxGlobStars: DEFAULT_MAX_STARS,
|
|
95
|
+
network: [],
|
|
96
|
+
wrappers: 'allow',
|
|
97
|
+
redact: true,
|
|
98
|
+
sessionApprovals: false,
|
|
99
|
+
sessionApprovalTtlMs: 0,
|
|
100
|
+
sessionApprovalMaxUses: 0,
|
|
101
|
+
approvePersist: true,
|
|
102
|
+
webBase: '',
|
|
103
|
+
enforce: true,
|
|
104
|
+
repeatLimit: 0,
|
|
105
|
+
repeatWindowMs: 60000,
|
|
106
|
+
repeatAction: 'ask',
|
|
107
|
+
snapshots: true,
|
|
108
|
+
snapshotMaxFiles: DEFAULT_MAX_FILES,
|
|
109
|
+
snapshotMaxBytes: DEFAULT_MAX_BYTES,
|
|
110
|
+
snapshotRetain: DEFAULT_RETAIN,
|
|
111
|
+
approvalMode: 'approval',
|
|
112
|
+
denyTools: [],
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const SHELL_TOOLS = new Set(['bash', 'pwsh', 'powershell']);
|
|
116
|
+
const READ_TOOLS = new Set(['read', 'read_image', 'grep', 'glob']);
|
|
117
|
+
const WRITE_TOOLS = new Set(['write', 'edit']);
|
|
118
|
+
const PATH_ARG_TOOLS = new Set([...READ_TOOLS, ...WRITE_TOOLS]);
|
|
119
|
+
const DENY_BASENAMES = new Set(['.netrc', '.git-credentials', '.npmrc', '.pypirc', '.credentials.yaml', 'id_rsa', 'id_ed25519', 'id_ecdsa', 'id_dsa']);
|
|
120
|
+
|
|
121
|
+
const OPAQUE = /(^|[^\\])\$\(|`|<\s*\(|<<-?\s*['\"]?[A-Za-z_]/;
|
|
122
|
+
const WRAPPER = /(^|\s)(sudo|eval|xargs|env)(\s|$)|\b(bash|sh|zsh|fish|dash)\s+-[a-z]*c\b|\bfind\b[^|;]*-exec\b/;
|
|
123
|
+
|
|
124
|
+
const REDACTIONS = [
|
|
125
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[redacted private key]'],
|
|
126
|
+
[/\b(?:sk|rk|pk)-[A-Za-z0-9_-]{16,}\b/g, '***'],
|
|
127
|
+
[/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, '***'],
|
|
128
|
+
[/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, '***'],
|
|
129
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, '***'],
|
|
130
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, '***'],
|
|
131
|
+
[/\bAIza[0-9A-Za-z_-]{35}\b/g, '***'],
|
|
132
|
+
[/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '***'],
|
|
133
|
+
[/\bBearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi, 'Bearer ***'],
|
|
134
|
+
[/([A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_KEY|PRIVATE_KEY|ACCESS_KEY)[A-Z0-9_]*\s*=\s*)(?:"[^"]{6,}"|'[^']{6,}'|[^\s"']{6,})/g, '$1***'],
|
|
135
|
+
[/([?&](?:token|access_token|api_key|apikey|key|secret|password)=)[^&\s"']+/gi, '$1***'],
|
|
136
|
+
[/"(token|access_token|api_key|apikey|secret|password|authorization)"\s*:\s*"[^"]{4,}"/gi, '"$1":"***"'],
|
|
137
|
+
[/^\s*(set-)?cookie\s*:\s*.*$/gim, 'Cookie: ***'],
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
/** Windows-style separators are matched as `/`; drive letters stay as written. */
|
|
141
|
+
export function toPosix(path) {
|
|
142
|
+
return typeof path === 'string' ? path.replace(/\\/g, '/') : path;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function matches(pattern, value, caseInsensitive = process.platform === 'win32') {
|
|
146
|
+
return testPattern(toPosix(pattern), toPosix(value), { caseInsensitive });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Pattern options shared by every path, command and argv surface (always glob). */
|
|
150
|
+
export function patternOptions(permission) {
|
|
151
|
+
return {
|
|
152
|
+
mode: 'glob',
|
|
153
|
+
maxStars: permission?.maxGlobStars ?? DEFAULT_MAX_STARS,
|
|
154
|
+
caseInsensitive: permission?.caseInsensitivePaths === true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** `params` is the one surface whose dialect is selectable (`patternMode`). */
|
|
159
|
+
export function paramOptions(permission) {
|
|
160
|
+
return {
|
|
161
|
+
...patternOptions(permission),
|
|
162
|
+
mode: permission?.patternMode === 'regex' ? 'regex' : 'glob',
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** One rule evaluation with the permission's dialect. */
|
|
167
|
+
function matchValue(pattern, value, permission) {
|
|
168
|
+
return testPattern(toPosix(pattern), toPosix(value), patternOptions(permission));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function matchParam(pattern, value, permission) {
|
|
172
|
+
return testPattern(pattern, value, paramOptions(permission));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function expand(p, cwd) {
|
|
176
|
+
if (typeof p !== 'string' || p === '') return undefined;
|
|
177
|
+
if (p === '~') return homedir();
|
|
178
|
+
if (p.startsWith('~/')) return resolve(homedir(), p.slice(2));
|
|
179
|
+
return isAbsolute(p) ? resolve(p) : resolve(cwd, p);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function candidates(raw, cwd) {
|
|
183
|
+
const abs = expand(raw, cwd);
|
|
184
|
+
if (abs === undefined) return [];
|
|
185
|
+
const set = new Set([raw, abs, relative(cwd, abs)]);
|
|
186
|
+
try {
|
|
187
|
+
set.add(await realpath(abs));
|
|
188
|
+
} catch {
|
|
189
|
+
// Missing path: the literal spelling is all we can match.
|
|
190
|
+
}
|
|
191
|
+
return [...set].filter((v) => typeof v === 'string' && v !== '');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function evalRules(rules, values, permission, surface) {
|
|
195
|
+
let hit;
|
|
196
|
+
for (const [pattern, state] of Object.entries(rules ?? {})) {
|
|
197
|
+
if (!STATES.has(state)) continue;
|
|
198
|
+
if (values.some((v) => matchValue(pattern, v, permission))) {
|
|
199
|
+
hit = { state, pattern, surface, key: pattern };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return hit;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function mostRestrictive(a, b) {
|
|
206
|
+
if (a === undefined) return b;
|
|
207
|
+
if (b === undefined) return a;
|
|
208
|
+
return SEVERITY[b.state] > SEVERITY[a.state] ? b : a;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A bash token is treated as a path only when it looks like one. This keeps
|
|
212
|
+
// identifiers (process.env.FOO, some.identifier) from being mistaken for dotenv
|
|
213
|
+
// files, while bare secret.env / .env / ~/.ssh/id_rsa still match.
|
|
214
|
+
function isPathLike(raw) {
|
|
215
|
+
if (typeof raw !== 'string' || raw === '') return false;
|
|
216
|
+
if (raw.includes('/') || raw.includes('\\') || raw.startsWith('~') || raw.startsWith('.') || /^[A-Za-z]:[\\/]/u.test(raw)) return true;
|
|
217
|
+
const base = raw.split(/[\\/]/u).at(-1) ?? '';
|
|
218
|
+
return DENY_BASENAMES.has(base) || /\.(?:env|pfx|p12)$/u.test(base);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function deriveArgvRules(bash) {
|
|
222
|
+
const rules = [];
|
|
223
|
+
for (const [pattern, action] of Object.entries(bash ?? {})) {
|
|
224
|
+
if (action !== 'ask' || !pattern.endsWith(' *')) continue;
|
|
225
|
+
const parts = pattern.slice(0, -2).split(/\s+/).filter(Boolean);
|
|
226
|
+
if (parts.length === 0 || parts.some((part) => /[*?]/.test(part))) continue;
|
|
227
|
+
const match = { command: [parts[0]] };
|
|
228
|
+
if (parts.length > 1) match.args = parts.slice(1);
|
|
229
|
+
rules.push({ match, action: 'ask', derived: true });
|
|
230
|
+
}
|
|
231
|
+
return rules;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function asList(value) {
|
|
235
|
+
if (value === undefined) return [];
|
|
236
|
+
return Array.isArray(value) ? value : [value];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function tokenMatches(pattern, token, permission) {
|
|
240
|
+
if (typeof pattern !== 'string') return false;
|
|
241
|
+
if (pattern.startsWith('!')) return !matchValue(pattern.slice(1), token, permission);
|
|
242
|
+
return matchValue(pattern, token, permission);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function argvMatches(match, decomposition, permission) {
|
|
246
|
+
if (typeof match !== 'object' || match === null) return false;
|
|
247
|
+
const commands = decomposition.commands;
|
|
248
|
+
const tokens = commands.flatMap((cmd) => [...cmd.args, ...cmd.redirects]);
|
|
249
|
+
let fields = 0;
|
|
250
|
+
|
|
251
|
+
const patterns = (key) => asList(match[key]).filter((p) => typeof p === 'string');
|
|
252
|
+
|
|
253
|
+
if (match.command !== undefined) {
|
|
254
|
+
fields += 1;
|
|
255
|
+
const pats = patterns('command');
|
|
256
|
+
if (!commands.some((cmd) => pats.some((p) => matchValue(p, cmd.command, permission)))) return false;
|
|
257
|
+
}
|
|
258
|
+
if (match.args !== undefined) {
|
|
259
|
+
fields += 1;
|
|
260
|
+
if (!patterns('args').every((p) => tokens.some((t) => tokenMatches(p, t, permission)))) return false;
|
|
261
|
+
}
|
|
262
|
+
if (match.anyArg !== undefined) {
|
|
263
|
+
fields += 1;
|
|
264
|
+
const pats = patterns('anyArg');
|
|
265
|
+
if (!tokens.some((t) => pats.some((p) => tokenMatches(p, t, permission)))) return false;
|
|
266
|
+
}
|
|
267
|
+
if (match.pipeline !== undefined) {
|
|
268
|
+
fields += 1;
|
|
269
|
+
const pipelines = commands.map((cmd) => cmd.command).filter((c) => c !== '').join('|');
|
|
270
|
+
if (!patterns('pipeline').some((p) => matchValue(p, pipelines, permission))) return false;
|
|
271
|
+
}
|
|
272
|
+
return fields > 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function bashTokens(command) {
|
|
276
|
+
const tokens = [];
|
|
277
|
+
for (const m of command.match(/"[^"]*"|'[^']*'|[^\s|;&<>()`]+/g) ?? []) {
|
|
278
|
+
tokens.push(m.replace(/^["']|["']$/g, ''));
|
|
279
|
+
}
|
|
280
|
+
return tokens;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function outside(cwd, abs) {
|
|
284
|
+
return abs !== cwd && !abs.startsWith(cwd + sep);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const PERSISTABLE = new Set(['path', 'pathRead', 'pathWrite', 'externalDirectory', 'bash']);
|
|
288
|
+
|
|
289
|
+
/** Whether a decision names a surface this plugin can write back as a rule. */
|
|
290
|
+
function canPersistDecision(decision) {
|
|
291
|
+
const surface = decision?.surface;
|
|
292
|
+
return typeof surface === 'string' && (PERSISTABLE.has(surface) || surface.startsWith('tools.'));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Append one `allow` rule to the project policy file so a one-off approval can
|
|
297
|
+
* become a standing rule. Only surfaces that are flat `{ pattern: state }` maps
|
|
298
|
+
* are written; argv/network/params shapes are not round-trippable from a single
|
|
299
|
+
* decision and are refused rather than guessed at.
|
|
300
|
+
*/
|
|
301
|
+
function persistAllow(cwd, decision) {
|
|
302
|
+
const surface = decision?.surface;
|
|
303
|
+
const key = decision?.key;
|
|
304
|
+
if (typeof surface !== 'string' || typeof key !== 'string' || key === '') return false;
|
|
305
|
+
let target;
|
|
306
|
+
let tool;
|
|
307
|
+
if (PERSISTABLE.has(surface)) target = surface;
|
|
308
|
+
else if (surface.startsWith('tools.')) {
|
|
309
|
+
target = 'tools';
|
|
310
|
+
tool = surface.slice('tools.'.length);
|
|
311
|
+
} else return false;
|
|
312
|
+
|
|
313
|
+
const file = resolve(cwd, PROJECT_POLICY);
|
|
314
|
+
let doc = {};
|
|
315
|
+
try {
|
|
316
|
+
doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
317
|
+
} catch {
|
|
318
|
+
// Missing or unparsable: start a fresh document rather than refuse the write.
|
|
319
|
+
}
|
|
320
|
+
const body = (doc.permission ?? doc ?? {});
|
|
321
|
+
if (target === 'tools') {
|
|
322
|
+
body.tools ??= {};
|
|
323
|
+
body.tools[tool] ??= {};
|
|
324
|
+
body.tools[tool][key] = 'allow';
|
|
325
|
+
} else {
|
|
326
|
+
body[target] ??= {};
|
|
327
|
+
body[target][key] = 'allow';
|
|
328
|
+
}
|
|
329
|
+
const out = doc.permission === undefined ? { permission: body } : { ...doc, permission: body };
|
|
330
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
331
|
+
writeFileSync(file, JSON.stringify(out, null, 2) + '\n');
|
|
332
|
+
projectCache.delete(file);
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Keep the audit trail bounded: one rolling `.1` file, no unbounded growth. */
|
|
337
|
+
function rotateAudit(path, maxBytes) {
|
|
338
|
+
if (typeof maxBytes !== 'number' || maxBytes <= 0) return;
|
|
339
|
+
let size;
|
|
340
|
+
try {
|
|
341
|
+
size = statSync(path).size;
|
|
342
|
+
} catch {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (size < maxBytes) return;
|
|
346
|
+
try {
|
|
347
|
+
renameSync(path, `${path}.1`);
|
|
348
|
+
} catch {
|
|
349
|
+
// A failed rotation must not stop the append that follows.
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function redactText(text) {
|
|
354
|
+
if (typeof text !== 'string' || text === '') return text;
|
|
355
|
+
let out = text;
|
|
356
|
+
for (const [pattern, replacement] of REDACTIONS) out = out.replace(pattern, replacement);
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* `tools` keys are tool-name globs, so `mcp__chrome-devtools__*` can govern one
|
|
362
|
+
* whole MCP server without naming its 32 tools. Every matching map is merged,
|
|
363
|
+
* later keys winning a collision, and the result then evaluated as one surface.
|
|
364
|
+
*/
|
|
365
|
+
function toolRules(permission, toolName) {
|
|
366
|
+
const rules = permission.tools;
|
|
367
|
+
if (rules === undefined || rules === null) return undefined;
|
|
368
|
+
if (rules[toolName] !== undefined) return rules[toolName];
|
|
369
|
+
let merged;
|
|
370
|
+
for (const [pattern, map] of Object.entries(rules)) {
|
|
371
|
+
if (!matchValue(pattern, toolName, permission)) continue;
|
|
372
|
+
merged = { ...(merged ?? {}), ...map };
|
|
373
|
+
}
|
|
374
|
+
return merged;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function pathDecision(permission, pathArg, cwd, direction, toolName) {
|
|
378
|
+
const values = await candidates(pathArg, cwd);
|
|
379
|
+
if (values.length === 0) return undefined;
|
|
380
|
+
let result = evalRules(permission.path, values, permission, 'path');
|
|
381
|
+
if (direction === 'read' || direction === 'both') result = mostRestrictive(result, evalRules(permission.pathRead, values, permission, 'pathRead'));
|
|
382
|
+
if (direction === 'write' || direction === 'both') result = mostRestrictive(result, evalRules(permission.pathWrite, values, permission, 'pathWrite'));
|
|
383
|
+
result = mostRestrictive(result, evalRules(toolRules(permission, toolName), values, permission, `tools.${toolName}`));
|
|
384
|
+
const abs = expand(pathArg, cwd);
|
|
385
|
+
if (abs !== undefined && outside(cwd, abs)) {
|
|
386
|
+
result = mostRestrictive(result, evalRules(permission.externalDirectory, values, permission, 'externalDirectory'));
|
|
387
|
+
}
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* `params` — key/value rules over any tool's argument object.
|
|
393
|
+
*
|
|
394
|
+
* Shape: `{ "<keyGlob>": { "<valueGlob>": "deny" } }`. A key pattern matches
|
|
395
|
+
* either the full dotted path (`arguments.command`) or its last segment
|
|
396
|
+
* (`command`). Within one key's value map the LAST matching rule wins; across
|
|
397
|
+
* keys the most restrictive result wins.
|
|
398
|
+
*/
|
|
399
|
+
function paramsDecision(permission, args) {
|
|
400
|
+
const rules = permission.params;
|
|
401
|
+
if (rules === undefined || rules === null || Object.keys(rules).length === 0) return undefined;
|
|
402
|
+
const entries = flattenArguments(args);
|
|
403
|
+
if (entries.length === 0) return undefined;
|
|
404
|
+
let result;
|
|
405
|
+
for (const [keyPattern, valueRules] of Object.entries(rules)) {
|
|
406
|
+
if (typeof valueRules !== 'object' || valueRules === null || Array.isArray(valueRules)) continue;
|
|
407
|
+
const matched = entries.filter((entry) => entryKeys(entry).some((key) => matchParam(keyPattern, key, permission)));
|
|
408
|
+
if (matched.length === 0) continue;
|
|
409
|
+
let hit;
|
|
410
|
+
for (const [valuePattern, state] of Object.entries(valueRules)) {
|
|
411
|
+
if (!STATES.has(state)) continue;
|
|
412
|
+
if (matched.some((entry) => matchParam(valuePattern, entry.value, permission))) {
|
|
413
|
+
hit = { state, pattern: `params ${keyPattern}=${valuePattern}`, surface: 'params', key: keyPattern, sub: valuePattern };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
result = mostRestrictive(result, hit);
|
|
417
|
+
}
|
|
418
|
+
return result;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* `toolParams` — key/value rules scoped to one tool, or to a tool-name glob.
|
|
423
|
+
*
|
|
424
|
+
* Shape: `{ "<toolGlob>": { "<keyGlob>": { "<valueGlob>": "deny" } } }`.
|
|
425
|
+
* `params` applies to every tool; this one lets
|
|
426
|
+
* `mcp__chrome-devtools__navigate_page` restrict its `url` without also
|
|
427
|
+
* restricting an unrelated tool that happens to have a `url` argument.
|
|
428
|
+
*/
|
|
429
|
+
function toolParamsDecision(permission, exec, args) {
|
|
430
|
+
const rules = permission.toolParams;
|
|
431
|
+
if (rules === undefined || rules === null || Object.keys(rules).length === 0) return undefined;
|
|
432
|
+
const entries = flattenArguments(args);
|
|
433
|
+
if (entries.length === 0) return undefined;
|
|
434
|
+
let result;
|
|
435
|
+
for (const [toolPattern, keyMap] of Object.entries(rules)) {
|
|
436
|
+
if (typeof keyMap !== 'object' || keyMap === null || Array.isArray(keyMap)) continue;
|
|
437
|
+
if (!matchValue(toolPattern, exec.name, permission)) continue;
|
|
438
|
+
for (const [keyPattern, valueRules] of Object.entries(keyMap)) {
|
|
439
|
+
if (typeof valueRules !== 'object' || valueRules === null || Array.isArray(valueRules)) continue;
|
|
440
|
+
const matched = entries.filter((entry) => entryKeys(entry).some((key) => matchParam(keyPattern, key, permission)));
|
|
441
|
+
if (matched.length === 0) continue;
|
|
442
|
+
for (const [valuePattern, state] of Object.entries(valueRules)) {
|
|
443
|
+
if (!STATES.has(state)) continue;
|
|
444
|
+
if (matched.some((entry) => matchParam(valuePattern, entry.value, permission))) {
|
|
445
|
+
result = mostRestrictive(result, {
|
|
446
|
+
state,
|
|
447
|
+
pattern: `toolParams ${toolPattern}.${keyPattern}=${valuePattern}`,
|
|
448
|
+
surface: 'toolParams',
|
|
449
|
+
key: toolPattern,
|
|
450
|
+
sub: keyPattern,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return result;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** `agents` — one state per agent identity, most specific identity first. */
|
|
460
|
+
function agentsDecision(permission, agent) {
|
|
461
|
+
if (agent === undefined) return undefined;
|
|
462
|
+
const rules = permission.agents;
|
|
463
|
+
if (rules === undefined || rules === null || Object.keys(rules).length === 0) return undefined;
|
|
464
|
+
for (const candidate of agentCandidates(agent)) {
|
|
465
|
+
for (const [pattern, state] of Object.entries(rules)) {
|
|
466
|
+
if (!STATES.has(state)) continue;
|
|
467
|
+
if (matchValue(pattern, candidate, permission)) {
|
|
468
|
+
return { state, pattern, surface: 'agents', key: pattern };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const PROJECT_POLICY = '.dsh/guard-policy.json';
|
|
476
|
+
const projectCache = new Map();
|
|
477
|
+
|
|
478
|
+
function overlay(basePermission, source) {
|
|
479
|
+
// Deep merge, not spread: a project file that sets one `tools.write` key must
|
|
480
|
+
// not wipe every other write rule inherited from the global policy.
|
|
481
|
+
const map = (name) => deepMerge(basePermission[name], source[name]);
|
|
482
|
+
const pick = (...values) => values.find((value) => value !== undefined);
|
|
483
|
+
return {
|
|
484
|
+
...basePermission,
|
|
485
|
+
'*': pick(source['*'], basePermission['*']),
|
|
486
|
+
path: map('path'),
|
|
487
|
+
pathRead: map('pathRead'),
|
|
488
|
+
pathWrite: map('pathWrite'),
|
|
489
|
+
externalDirectory: map('externalDirectory'),
|
|
490
|
+
bash: map('bash'),
|
|
491
|
+
tools: map('tools'),
|
|
492
|
+
params: map('params'),
|
|
493
|
+
toolParams: map('toolParams'),
|
|
494
|
+
agents: map('agents'),
|
|
495
|
+
argv: source.argv ?? basePermission.argv,
|
|
496
|
+
network: source.network ?? basePermission.network,
|
|
497
|
+
wrappers: pick(source.wrappers, basePermission.wrappers),
|
|
498
|
+
opaque: pick(source.opaque, basePermission.opaque),
|
|
499
|
+
redact: pick(source.redact, basePermission.redact),
|
|
500
|
+
sessionApprovals: pick(source.sessionApprovals, basePermission.sessionApprovals),
|
|
501
|
+
sessionApprovalTtlMs: pick(source.sessionApprovalTtlMs, basePermission.sessionApprovalTtlMs),
|
|
502
|
+
sessionApprovalMaxUses: pick(source.sessionApprovalMaxUses, basePermission.sessionApprovalMaxUses),
|
|
503
|
+
approvePersist: pick(source.approvePersist, basePermission.approvePersist),
|
|
504
|
+
webBase: pick(source.webBase, basePermission.webBase),
|
|
505
|
+
enforce: pick(source.enforce, basePermission.enforce),
|
|
506
|
+
repeatLimit: pick(source.repeatLimit, basePermission.repeatLimit),
|
|
507
|
+
repeatWindowMs: pick(source.repeatWindowMs, basePermission.repeatWindowMs),
|
|
508
|
+
repeatAction: pick(source.repeatAction, basePermission.repeatAction),
|
|
509
|
+
snapshots: pick(source.snapshots, basePermission.snapshots),
|
|
510
|
+
snapshotMaxFiles: pick(source.snapshotMaxFiles, basePermission.snapshotMaxFiles),
|
|
511
|
+
snapshotMaxBytes: pick(source.snapshotMaxBytes, basePermission.snapshotMaxBytes),
|
|
512
|
+
snapshotRetain: pick(source.snapshotRetain, basePermission.snapshotRetain),
|
|
513
|
+
patternMode: pick(source.patternMode, basePermission.patternMode),
|
|
514
|
+
maxGlobStars: pick(source.maxGlobStars, basePermission.maxGlobStars),
|
|
515
|
+
approvalMode: pick(source.approvalMode, basePermission.approvalMode),
|
|
516
|
+
yolo: pick(source.yolo, basePermission.yolo),
|
|
517
|
+
sandboxAware: pick(source.sandboxAware, basePermission.sandboxAware),
|
|
518
|
+
audit: pick(source.audit, basePermission.audit),
|
|
519
|
+
auditPath: pick(source.auditPath, basePermission.auditPath),
|
|
520
|
+
auditMaxBytes: pick(source.auditMaxBytes, basePermission.auditMaxBytes),
|
|
521
|
+
projectPolicy: basePermission.projectPolicy,
|
|
522
|
+
denyTools: pick(source.denyTools, basePermission.denyTools),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Cache one project file's parsed body, keyed by mtime. */
|
|
527
|
+
function readProjectFile(file) {
|
|
528
|
+
try {
|
|
529
|
+
const stat = statSync(file);
|
|
530
|
+
const cached = projectCache.get(file);
|
|
531
|
+
if (cached !== undefined && cached.mtimeMs === stat.mtimeMs) return cached.source;
|
|
532
|
+
const raw = parseJsonc(readFileSync(file, 'utf8'));
|
|
533
|
+
const body = raw?.permission ?? raw;
|
|
534
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) return undefined;
|
|
535
|
+
const { $schema, ...source } = body;
|
|
536
|
+
const errors = validate(source);
|
|
537
|
+
if (errors.length > 0) {
|
|
538
|
+
const bad = { invalid: errors };
|
|
539
|
+
projectCache.set(file, { mtimeMs: stat.mtimeMs, source: bad });
|
|
540
|
+
return bad;
|
|
541
|
+
}
|
|
542
|
+
projectCache.set(file, { mtimeMs: stat.mtimeMs, source });
|
|
543
|
+
return source;
|
|
544
|
+
} catch {
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Project rules stack from the filesystem root down to the session cwd, so a
|
|
551
|
+
* monorepo can put a baseline in `<repo>/.dsh/guard-policy.json` and override
|
|
552
|
+
* one package in `<repo>/pkg/.dsh/guard-policy.json`. The nearest file wins for
|
|
553
|
+
* a conflicting key (plain object spread), and the global policy stays the
|
|
554
|
+
* floor underneath all of them.
|
|
555
|
+
*/
|
|
556
|
+
export function projectPermission(basePermission, cwd) {
|
|
557
|
+
if (basePermission.projectPolicy === false) return basePermission;
|
|
558
|
+
const chain = [];
|
|
559
|
+
let dir = resolve(cwd);
|
|
560
|
+
for (;;) {
|
|
561
|
+
chain.unshift(dir);
|
|
562
|
+
const parent = dirname(dir);
|
|
563
|
+
if (parent === dir) break;
|
|
564
|
+
if (basePermission.projectPolicySearchUp === false) break;
|
|
565
|
+
dir = parent;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
let merged = basePermission;
|
|
569
|
+
for (const base of chain) {
|
|
570
|
+
const source = readProjectFile(join(base, PROJECT_POLICY));
|
|
571
|
+
if (source === undefined) continue;
|
|
572
|
+
if (source.invalid !== undefined) {
|
|
573
|
+
if (basePermission.projectPolicyStrict) return { invalid: source.invalid.map((error) => `project: ${error}`) };
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
merged = overlay(merged, source);
|
|
577
|
+
}
|
|
578
|
+
return merged;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function collectUrls(value, out = [], depth = 0) {
|
|
582
|
+
if (depth > 3 || value === null || value === undefined) return out;
|
|
583
|
+
if (typeof value === 'string') {
|
|
584
|
+
for (const match of value.matchAll(/https?:\/\/[^\s"'`;|)]+/g)) out.push(match[0]);
|
|
585
|
+
return out;
|
|
586
|
+
}
|
|
587
|
+
if (Array.isArray(value)) {
|
|
588
|
+
for (const item of value) collectUrls(item, out, depth + 1);
|
|
589
|
+
return out;
|
|
590
|
+
}
|
|
591
|
+
if (typeof value === 'object') {
|
|
592
|
+
for (const item of Object.values(value)) collectUrls(item, out, depth + 1);
|
|
593
|
+
}
|
|
594
|
+
return out;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function networkLayer(permission, exec) {
|
|
598
|
+
const rules = permission.network ?? [];
|
|
599
|
+
if (rules.length === 0) return undefined;
|
|
600
|
+
const urls = collectUrls(exec?.arguments ?? {});
|
|
601
|
+
if (urls.length === 0) return undefined;
|
|
602
|
+
let hit;
|
|
603
|
+
for (const rule of rules) {
|
|
604
|
+
const domains = asList(rule.match?.domains).filter((d) => typeof d === 'string');
|
|
605
|
+
for (const url of urls) {
|
|
606
|
+
try {
|
|
607
|
+
const host = new URL(url).hostname;
|
|
608
|
+
if (domains.some((pattern) => matchValue(pattern, host, permission))) {
|
|
609
|
+
hit = { state: rule.action, pattern: `network ${host}`, surface: 'network', key: host };
|
|
610
|
+
}
|
|
611
|
+
} catch {
|
|
612
|
+
// Not a parseable URL; ignore.
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return hit;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export async function decide(exec, cwd, permission = DEFAULT_PERMISSION) {
|
|
620
|
+
if (permission.invalid) {
|
|
621
|
+
return { state: 'deny', pattern: `invalid config: ${permission.invalid.join('; ')}` };
|
|
622
|
+
}
|
|
623
|
+
if (permission.denyTools?.includes(exec.name)) {
|
|
624
|
+
return { state: 'deny', pattern: `denyTools: ${exec.name}`, surface: 'denyTools', key: exec.name };
|
|
625
|
+
}
|
|
626
|
+
const args = (exec && typeof exec.arguments === 'object' && exec.arguments) || {};
|
|
627
|
+
const layers = [];
|
|
628
|
+
const direction = READ_TOOLS.has(exec.name) ? 'read' : WRITE_TOOLS.has(exec.name) ? 'write' : 'both';
|
|
629
|
+
|
|
630
|
+
// Agent identity is a cross-cutting gate: a subagent or a read-only preset
|
|
631
|
+
// can be held to a tighter baseline than the interactive main agent.
|
|
632
|
+
layers.push(agentsDecision(permission, exec.agent));
|
|
633
|
+
|
|
634
|
+
if (PATH_ARG_TOOLS.has(exec.name) || exec.name.startsWith('mcp__') || exec.name === 'mcp') {
|
|
635
|
+
const nested = (args.arguments && typeof args.arguments === 'object') ? args.arguments : {};
|
|
636
|
+
const pathArg = args.file_path ?? args.path ?? nested.file_path ?? nested.path;
|
|
637
|
+
if (typeof pathArg === 'string') {
|
|
638
|
+
layers.push(await pathDecision(permission, pathArg, cwd, direction, exec.name));
|
|
639
|
+
} else {
|
|
640
|
+
// A tool with no path argument still has `tools.<name>` rules; match them
|
|
641
|
+
// against the tool name so `{ "*": "deny" }` can refuse an MCP tool that
|
|
642
|
+
// takes only non-path arguments.
|
|
643
|
+
layers.push(evalRules(toolRules(permission, exec.name), [exec.name], permission, `tools.${exec.name}`));
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// `params` covers every tool, including ones with no path argument
|
|
648
|
+
// (web_search, skill, mcp non-path args).
|
|
649
|
+
layers.push(paramsDecision(permission, args));
|
|
650
|
+
// `toolParams` narrows the same matching to one tool or one MCP server.
|
|
651
|
+
layers.push(toolParamsDecision(permission, exec, args));
|
|
652
|
+
|
|
653
|
+
if (SHELL_TOOLS.has(exec.name) && typeof args.command === 'string') {
|
|
654
|
+
layers.push(evalRules(permission.bash, [args.command], permission, 'bash'));
|
|
655
|
+
if (WRAPPER.test(args.command) && permission.wrappers !== 'allow') {
|
|
656
|
+
layers.push({ state: permission.wrappers, pattern: 'wrapper (bash -c/sudo/eval/xargs/find -exec)', surface: 'wrappers', key: 'wrappers' });
|
|
657
|
+
}
|
|
658
|
+
if (OPAQUE.test(args.command) && permission.opaque !== 'allow') {
|
|
659
|
+
layers.push({ state: permission.opaque, pattern: 'opaque construct ($(...), backtick, heredoc, <())', surface: 'opaque', key: 'opaque' });
|
|
660
|
+
}
|
|
661
|
+
const decomposition = decomposeShellCommand(args.command);
|
|
662
|
+
for (const rule of permission.argv ?? []) {
|
|
663
|
+
if (argvMatches(rule.match, decomposition, permission)) {
|
|
664
|
+
layers.push({ state: rule.action, pattern: `argv ${JSON.stringify(rule.match)}`, surface: 'argv' });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
for (const simple of decomposition.commands) {
|
|
668
|
+
for (const target of simple.redirects) {
|
|
669
|
+
layers.push(await pathDecision(permission, target, cwd, 'write', 'bash'));
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
for (const token of bashTokens(args.command)) {
|
|
673
|
+
const raw = token.includes('=') ? token.slice(token.indexOf('=') + 1) : token;
|
|
674
|
+
if (raw === '') continue;
|
|
675
|
+
if (/[*?]/.test(raw)) continue; // glob patterns are patterns, not paths
|
|
676
|
+
if (!isPathLike(raw)) continue; // identifiers such as process.env.X are not paths
|
|
677
|
+
if (DENY_BASENAMES.has(raw.split(/[\\/]/u).at(-1) ?? '')) {
|
|
678
|
+
layers.push({ state: 'deny', pattern: `basename ${raw}`, surface: 'basename', key: raw });
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
layers.push(await pathDecision(permission, raw, cwd, 'both', exec.name));
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
layers.push(networkLayer(permission, exec));
|
|
686
|
+
let result;
|
|
687
|
+
for (const layer of layers) result = mostRestrictive(result, layer);
|
|
688
|
+
result = mostRestrictive(result, { state: permission['*'], pattern: '*', surface: 'default', key: '*' });
|
|
689
|
+
return result?.state === 'allow' ? undefined : result;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export function validate(source) {
|
|
693
|
+
const errors = [];
|
|
694
|
+
const checkMap = (name, value, nested = false) => {
|
|
695
|
+
if (value === undefined) return;
|
|
696
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
697
|
+
errors.push(`${name} must be an object`);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
for (const [key, state] of Object.entries(value)) {
|
|
701
|
+
// `null` removes an inherited rule (or a whole nested table) instead of
|
|
702
|
+
// setting one, so it is valid wherever a state would be.
|
|
703
|
+
if (state === null) continue;
|
|
704
|
+
if (nested) {
|
|
705
|
+
if (typeof state !== 'object' || Array.isArray(state)) {
|
|
706
|
+
errors.push(`${name}.${key} must be an object or null`);
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
for (const [k, s] of Object.entries(state)) if (s !== null && !STATES.has(s)) errors.push(`${name}.${key}.${k} must be allow, ask, deny, or null`);
|
|
710
|
+
} else if (!STATES.has(state)) {
|
|
711
|
+
errors.push(`${name}.${key} must be allow, ask, deny, or null`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
const globOpts = { mode: 'glob', maxStars: source.maxGlobStars ?? DEFAULT_MAX_STARS };
|
|
717
|
+
const paramOpts = { ...globOpts, mode: source.patternMode === 'regex' ? 'regex' : 'glob' };
|
|
718
|
+
const checkPatterns = (name, value, opts) => {
|
|
719
|
+
if (value === undefined || typeof value !== 'object' || value === null || Array.isArray(value)) return;
|
|
720
|
+
for (const key of Object.keys(value)) {
|
|
721
|
+
const err = checkPattern(key, opts);
|
|
722
|
+
if (err !== undefined) errors.push(`${name}: ${err}`);
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
if (source['*'] !== undefined && !STATES.has(source['*'])) errors.push('"*" must be allow, ask, or deny');
|
|
727
|
+
for (const name of ['path', 'pathRead', 'pathWrite', 'externalDirectory', 'bash']) {
|
|
728
|
+
checkMap(name, source[name]);
|
|
729
|
+
checkPatterns(name, source[name], globOpts);
|
|
730
|
+
}
|
|
731
|
+
checkMap('tools', source.tools, true);
|
|
732
|
+
checkMap('params', source.params, true);
|
|
733
|
+
// `toolParams` is one level deeper than `params`: tool -> key -> value -> state.
|
|
734
|
+
if (source.toolParams !== undefined) {
|
|
735
|
+
if (typeof source.toolParams !== 'object' || source.toolParams === null || Array.isArray(source.toolParams)) {
|
|
736
|
+
errors.push('toolParams must be an object');
|
|
737
|
+
} else {
|
|
738
|
+
for (const [tool, keyMap] of Object.entries(source.toolParams)) {
|
|
739
|
+
if (typeof keyMap !== 'object' || keyMap === null || Array.isArray(keyMap)) {
|
|
740
|
+
errors.push(`toolParams.${tool} must be an object`);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
for (const [key, valueMap] of Object.entries(keyMap)) {
|
|
744
|
+
if (valueMap === null) continue;
|
|
745
|
+
if (typeof valueMap !== 'object' || Array.isArray(valueMap)) {
|
|
746
|
+
errors.push(`toolParams.${tool}.${key} must be an object`);
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
for (const [valuePattern, state] of Object.entries(valueMap)) {
|
|
750
|
+
if (state !== null && !STATES.has(state)) errors.push(`toolParams.${tool}.${key}.${valuePattern} must be allow, ask, deny, or null`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
checkPatterns('params', source.params, paramOpts);
|
|
757
|
+
for (const value of Object.values(source.params ?? {})) {
|
|
758
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) checkPatterns('params.value', value, paramOpts);
|
|
759
|
+
}
|
|
760
|
+
checkMap('agents', source.agents);
|
|
761
|
+
checkPatterns('agents', source.agents, globOpts);
|
|
762
|
+
if (source.wrappers !== undefined && !STATES.has(source.wrappers)) errors.push('wrappers must be allow, ask, or deny');
|
|
763
|
+
if (source.redact !== undefined && typeof source.redact !== 'boolean') errors.push('redact must be a boolean');
|
|
764
|
+
if (source.sessionApprovals !== undefined && typeof source.sessionApprovals !== 'boolean') errors.push('sessionApprovals must be a boolean');
|
|
765
|
+
if (source.approvalMode !== undefined && !APPROVAL_MODES.has(source.approvalMode)) errors.push('approvalMode must be "approval", "question" or "dialog"');
|
|
766
|
+
if (source.denyTools !== undefined && !Array.isArray(source.denyTools)) errors.push('denyTools must be an array of tool names');
|
|
767
|
+
if (source.opaque !== undefined && !STATES.has(source.opaque)) errors.push('opaque must be allow, ask, or deny');
|
|
768
|
+
if (source.sandboxAware !== undefined && typeof source.sandboxAware !== 'boolean') errors.push('sandboxAware must be a boolean');
|
|
769
|
+
if (source.yolo !== undefined && typeof source.yolo !== 'boolean') errors.push('yolo must be a boolean');
|
|
770
|
+
if (source.audit !== undefined && typeof source.audit !== 'boolean') errors.push('audit must be a boolean');
|
|
771
|
+
if (source.auditPath !== undefined && typeof source.auditPath !== 'string') errors.push('auditPath must be a string');
|
|
772
|
+
if (source.auditMaxBytes !== undefined && (!Number.isInteger(source.auditMaxBytes) || source.auditMaxBytes < 0)) errors.push('auditMaxBytes must be a non-negative integer');
|
|
773
|
+
if (source.enforce !== undefined && typeof source.enforce !== 'boolean') errors.push('enforce must be a boolean');
|
|
774
|
+
if (source.repeatLimit !== undefined && (!Number.isInteger(source.repeatLimit) || source.repeatLimit < 0)) errors.push('repeatLimit must be a non-negative integer');
|
|
775
|
+
if (source.repeatWindowMs !== undefined && (!Number.isInteger(source.repeatWindowMs) || source.repeatWindowMs < 1)) errors.push('repeatWindowMs must be a positive integer');
|
|
776
|
+
if (source.repeatAction !== undefined && !STATES.has(source.repeatAction)) errors.push('repeatAction must be allow, ask, or deny');
|
|
777
|
+
if (source.snapshots !== undefined && typeof source.snapshots !== 'boolean') errors.push('snapshots must be a boolean');
|
|
778
|
+
if (source.snapshotMaxFiles !== undefined && (!Number.isInteger(source.snapshotMaxFiles) || source.snapshotMaxFiles < 0)) errors.push('snapshotMaxFiles must be a non-negative integer');
|
|
779
|
+
if (source.snapshotMaxBytes !== undefined && (!Number.isInteger(source.snapshotMaxBytes) || source.snapshotMaxBytes < 0)) errors.push('snapshotMaxBytes must be a non-negative integer');
|
|
780
|
+
if (source.snapshotRetain !== undefined && (!Number.isInteger(source.snapshotRetain) || source.snapshotRetain < 0)) errors.push('snapshotRetain must be a non-negative integer');
|
|
781
|
+
if (source.approvePersist !== undefined && typeof source.approvePersist !== 'boolean') errors.push('approvePersist must be a boolean');
|
|
782
|
+
if (source.webBase !== undefined && typeof source.webBase !== 'string') errors.push('webBase must be a string');
|
|
783
|
+
if (source.patternMode !== undefined && source.patternMode !== 'glob' && source.patternMode !== 'regex') errors.push('patternMode must be "glob" or "regex"');
|
|
784
|
+
if (source.maxGlobStars !== undefined && (!Number.isInteger(source.maxGlobStars) || source.maxGlobStars < 0)) errors.push('maxGlobStars must be a non-negative integer');
|
|
785
|
+
if (source.sessionApprovalTtlMs !== undefined && (!Number.isInteger(source.sessionApprovalTtlMs) || source.sessionApprovalTtlMs < 0)) errors.push('sessionApprovalTtlMs must be a non-negative integer');
|
|
786
|
+
if (source.sessionApprovalMaxUses !== undefined && (!Number.isInteger(source.sessionApprovalMaxUses) || source.sessionApprovalMaxUses < 0)) errors.push('sessionApprovalMaxUses must be a non-negative integer');
|
|
787
|
+
if (source.projectPolicy !== undefined && typeof source.projectPolicy !== 'boolean') errors.push('projectPolicy must be a boolean');
|
|
788
|
+
if (source.projectPolicyStrict !== undefined && typeof source.projectPolicyStrict !== 'boolean') errors.push('projectPolicyStrict must be a boolean');
|
|
789
|
+
if (source.projectPolicySearchUp !== undefined && typeof source.projectPolicySearchUp !== 'boolean') errors.push('projectPolicySearchUp must be a boolean');
|
|
790
|
+
if (source.caseInsensitivePaths !== undefined && typeof source.caseInsensitivePaths !== 'boolean') errors.push('caseInsensitivePaths must be a boolean');
|
|
791
|
+
if (source.network !== undefined) {
|
|
792
|
+
if (!Array.isArray(source.network)) errors.push('network must be an array of rules');
|
|
793
|
+
else source.network.forEach((rule, index) => {
|
|
794
|
+
if (typeof rule !== 'object' || rule === null) errors.push(`network[${index}] must be an object`);
|
|
795
|
+
else if (!STATES.has(rule.action)) errors.push(`network[${index}].action must be allow, ask, or deny`);
|
|
796
|
+
else if (typeof rule.match !== 'object' || rule.match === null) errors.push(`network[${index}].match must be an object`);
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
if (source.argv !== undefined) {
|
|
800
|
+
if (!Array.isArray(source.argv)) errors.push('argv must be an array of rules');
|
|
801
|
+
else {
|
|
802
|
+
source.argv.forEach((rule, index) => {
|
|
803
|
+
if (typeof rule !== 'object' || rule === null) errors.push(`argv[${index}] must be an object`);
|
|
804
|
+
else if (!STATES.has(rule.action)) errors.push(`argv[${index}].action must be allow, ask, or deny`);
|
|
805
|
+
else if (typeof rule.match !== 'object' || rule.match === null) errors.push(`argv[${index}].match must be an object`);
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return errors;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// The repository copy is the seed (first mount) and the fallback (data file
|
|
813
|
+
// missing or unreadable). The live policy is the user-owned file in the data
|
|
814
|
+
// directory, so an edit never dirties the checkout.
|
|
815
|
+
const BUILTIN_POLICY_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'policy.json');
|
|
816
|
+
const BUILTIN_SCHEMA_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'policy.schema.json');
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* The raw policy body on disk, with nothing merged in. `/guard-policy` edits
|
|
820
|
+
* this document, so it must not start from the defaults — otherwise the first
|
|
821
|
+
* write would freeze every default into the user's file.
|
|
822
|
+
*/
|
|
823
|
+
function readLivePolicyBody(file) {
|
|
824
|
+
try {
|
|
825
|
+
const raw = parseJsonc(readFileSync(file, 'utf8'));
|
|
826
|
+
const body = raw?.permission ?? raw;
|
|
827
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) return {};
|
|
828
|
+
const { $schema, ...permission } = body;
|
|
829
|
+
return permission;
|
|
830
|
+
} catch {
|
|
831
|
+
return {};
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function readPolicyFile(path) {
|
|
836
|
+
try {
|
|
837
|
+
const raw = parseJsonc(readFileSync(path, 'utf8'));
|
|
838
|
+
const body = raw?.permission ?? raw;
|
|
839
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) return undefined;
|
|
840
|
+
// `$schema` is editor metadata, not a rule surface.
|
|
841
|
+
const { $schema, ...permission } = body;
|
|
842
|
+
return permission;
|
|
843
|
+
} catch {
|
|
844
|
+
return undefined;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function loadPolicy() {
|
|
849
|
+
const user = readPolicyFile(policyPath());
|
|
850
|
+
if (user !== undefined) return user;
|
|
851
|
+
return readPolicyFile(BUILTIN_POLICY_PATH) ?? {};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
export function mergePermission(config = {}) {
|
|
855
|
+
const policy = loadPolicy();
|
|
856
|
+
const source = config.permission ?? config;
|
|
857
|
+
const errors = [...validate(policy), ...validate(source)];
|
|
858
|
+
if (errors.length > 0) return { invalid: errors };
|
|
859
|
+
const pick = (...values) => values.find((value) => value !== undefined);
|
|
860
|
+
const map = (name) => ({ ...(DEFAULT_PERMISSION[name] ?? {}), ...(policy[name] ?? {}), ...(source[name] ?? {}) });
|
|
861
|
+
return {
|
|
862
|
+
'*': pick(source['*'], policy['*'], DEFAULT_PERMISSION['*']),
|
|
863
|
+
path: map('path'),
|
|
864
|
+
pathRead: map('pathRead'),
|
|
865
|
+
pathWrite: map('pathWrite'),
|
|
866
|
+
externalDirectory: map('externalDirectory'),
|
|
867
|
+
bash: map('bash'),
|
|
868
|
+
tools: map('tools'),
|
|
869
|
+
params: map('params'),
|
|
870
|
+
toolParams: map('toolParams'),
|
|
871
|
+
agents: map('agents'),
|
|
872
|
+
argv: [...deriveArgvRules(pick(source.bash, policy.bash, DEFAULT_PERMISSION.bash)), ...pick(source.argv, policy.argv, DEFAULT_PERMISSION.argv)],
|
|
873
|
+
wrappers: STATES.has(pick(source.wrappers, policy.wrappers)) ? pick(source.wrappers, policy.wrappers) : 'allow',
|
|
874
|
+
opaque: STATES.has(pick(source.opaque, policy.opaque)) ? pick(source.opaque, policy.opaque) : 'ask',
|
|
875
|
+
sandboxAware: pick(source.sandboxAware, policy.sandboxAware) !== false,
|
|
876
|
+
yolo: pick(source.yolo, policy.yolo) === true,
|
|
877
|
+
audit: pick(source.audit, policy.audit) !== false,
|
|
878
|
+
auditPath: pick(source.auditPath, policy.auditPath) ?? defaultAuditPath(),
|
|
879
|
+
auditMaxBytes: pick(source.auditMaxBytes, policy.auditMaxBytes) ?? DEFAULT_PERMISSION.auditMaxBytes,
|
|
880
|
+
enforce: pick(source.enforce, policy.enforce) !== false,
|
|
881
|
+
repeatLimit: pick(source.repeatLimit, policy.repeatLimit) ?? 0,
|
|
882
|
+
repeatWindowMs: pick(source.repeatWindowMs, policy.repeatWindowMs) ?? 60000,
|
|
883
|
+
repeatAction: STATES.has(pick(source.repeatAction, policy.repeatAction)) ? pick(source.repeatAction, policy.repeatAction) : 'ask',
|
|
884
|
+
snapshots: pick(source.snapshots, policy.snapshots) !== false,
|
|
885
|
+
snapshotMaxFiles: pick(source.snapshotMaxFiles, policy.snapshotMaxFiles) ?? DEFAULT_MAX_FILES,
|
|
886
|
+
snapshotMaxBytes: pick(source.snapshotMaxBytes, policy.snapshotMaxBytes) ?? DEFAULT_MAX_BYTES,
|
|
887
|
+
snapshotRetain: pick(source.snapshotRetain, policy.snapshotRetain) ?? DEFAULT_RETAIN,
|
|
888
|
+
approvePersist: pick(source.approvePersist, policy.approvePersist) !== false,
|
|
889
|
+
webBase: pick(source.webBase, policy.webBase) ?? '',
|
|
890
|
+
patternMode: pick(source.patternMode, policy.patternMode) === 'regex' ? 'regex' : 'glob',
|
|
891
|
+
maxGlobStars: pick(source.maxGlobStars, policy.maxGlobStars) ?? DEFAULT_MAX_STARS,
|
|
892
|
+
projectPolicy: pick(source.projectPolicy, policy.projectPolicy) !== false,
|
|
893
|
+
projectPolicyStrict: pick(source.projectPolicyStrict, policy.projectPolicyStrict) === true,
|
|
894
|
+
projectPolicySearchUp: pick(source.projectPolicySearchUp, policy.projectPolicySearchUp) !== false,
|
|
895
|
+
caseInsensitivePaths: pick(source.caseInsensitivePaths, policy.caseInsensitivePaths) ?? (process.platform === 'win32'),
|
|
896
|
+
network: pick(source.network, policy.network, DEFAULT_PERMISSION.network),
|
|
897
|
+
redact: pick(source.redact, policy.redact) !== false,
|
|
898
|
+
sessionApprovals: pick(source.sessionApprovals, policy.sessionApprovals) === true,
|
|
899
|
+
sessionApprovalTtlMs: pick(source.sessionApprovalTtlMs, policy.sessionApprovalTtlMs) ?? 0,
|
|
900
|
+
sessionApprovalMaxUses: pick(source.sessionApprovalMaxUses, policy.sessionApprovalMaxUses) ?? 0,
|
|
901
|
+
approvalMode: APPROVAL_MODES.has(pick(source.approvalMode, policy.approvalMode)) ? pick(source.approvalMode, policy.approvalMode) : 'approval',
|
|
902
|
+
denyTools: pick(source.denyTools, policy.denyTools) ?? [],
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// DSH already enforces sandbox modes. Asking on top of `danger-full-access`
|
|
907
|
+
// (yolo) or `read-only` is noise: writes cannot happen under read-only, and the
|
|
908
|
+
// user asked for full access under danger-full-access. Deny rules still apply.
|
|
909
|
+
function sandboxYolo(ctx, exec, permission) {
|
|
910
|
+
if (permission.sandboxAware === false || exec?.agent === undefined) return false;
|
|
911
|
+
const mode = ctx.sessionProjections?.stateOf?.(exec.agent.session, 'sandboxMode');
|
|
912
|
+
return mode === 'danger-full-access' || mode === 'read-only';
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function sessionOf(exec) {
|
|
916
|
+
return exec?.agent?.session?.id ?? exec?.agent?.id;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
export function apply(ctx, config = {}) {
|
|
920
|
+
// Every file this plugin owns lives under $DSH_HOME/dsh-plugins/dsh-guard/.
|
|
921
|
+
// Files earlier versions wrote into the home root move there, and the shipped
|
|
922
|
+
// policy seeds the data directory on first mount so the live policy is the
|
|
923
|
+
// user-owned copy from then on. This has to run before the first read.
|
|
924
|
+
try {
|
|
925
|
+
migrateLegacy((message) => ctx.logger?.info?.(message));
|
|
926
|
+
ensureConfigDir();
|
|
927
|
+
seedPolicy(BUILTIN_POLICY_PATH, (message) => ctx.logger?.info?.(message), BUILTIN_SCHEMA_PATH);
|
|
928
|
+
} catch (error) {
|
|
929
|
+
ctx.logger?.warn?.(`dsh-guard: data directory setup failed: ${String(error)}`);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
let permission = mergePermission(config);
|
|
933
|
+
let lastDecision;
|
|
934
|
+
if (permission.invalid) {
|
|
935
|
+
ctx.logger?.warn?.(`dsh-guard: invalid config, denying every call: ${permission.invalid.join('; ')}`);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const pending = new Map();
|
|
939
|
+
// sid -> Map<pattern, { until, uses, maxUses }>; `until === 0` means no expiry
|
|
940
|
+
// and `maxUses === 0` means unlimited, so the defaults match the old Set.
|
|
941
|
+
const approved = new Map();
|
|
942
|
+
// A bounded in-memory window of decisions, newest first. Declared before
|
|
943
|
+
// `writeStatus` reads it — the first write happens during mount.
|
|
944
|
+
const recent = [];
|
|
945
|
+
const statusPath = defaultStatusPath();
|
|
946
|
+
const livePolicy = policyPath();
|
|
947
|
+
let commandRegistered = false;
|
|
948
|
+
// Diagnostic: the command names the host actually lists for the last agent
|
|
949
|
+
// that made a tool call. An empty list here means registration never reached
|
|
950
|
+
// the command directory; a populated list without `guard-test` means it is
|
|
951
|
+
// scoped elsewhere.
|
|
952
|
+
let commandNames = null;
|
|
953
|
+
// Why the custom question channel did or did not run.
|
|
954
|
+
// distinguishes "service missing" from "service rejected the call" from
|
|
955
|
+
// "answered", which the DSH-native approval fallback cannot show.
|
|
956
|
+
let questionStatus = 'not-tried';
|
|
957
|
+
// Which channel actually handled the last ask, and why. Written to status.json
|
|
958
|
+
// because a silently-allowed ask looks identical to a rule miss in the audit.
|
|
959
|
+
let lastChannel = 'none';
|
|
960
|
+
// Snapshot of the last dialog that was actually shown to a Client. The DOM
|
|
961
|
+
// lives in the browser, so this is the only server-side evidence of what the
|
|
962
|
+
// approval card contained.
|
|
963
|
+
let lastDialog = null;
|
|
964
|
+
// Advertised Web origin, resolved once the web server is mounted. Used to
|
|
965
|
+
// build an absolute link for the approval card's diff page.
|
|
966
|
+
let advertisedBase = '';
|
|
967
|
+
// Last time a Client asked for the queue. A dialog with no listener must not
|
|
968
|
+
// park the call for the full timeout, so this gates the dialog channel.
|
|
969
|
+
// Live approval clients. A Client registers with a random id and beats every
|
|
970
|
+
// 20s; presence is a direct signal, unlike inferring it from poll timing
|
|
971
|
+
// (background tabs get their timers throttled).
|
|
972
|
+
const liveClients = new Map();
|
|
973
|
+
const CLIENT_TTL_MS = 60000;
|
|
974
|
+
const clientsAlive = () => {
|
|
975
|
+
const now = Date.now();
|
|
976
|
+
for (const [id, seen] of liveClients) if (now - seen > CLIENT_TTL_MS) liveClients.delete(id);
|
|
977
|
+
return liveClients.size > 0;
|
|
978
|
+
};
|
|
979
|
+
const writeStatus = () => {
|
|
980
|
+
try {
|
|
981
|
+
writeFileSync(statusPath, JSON.stringify({
|
|
982
|
+
version: VERSION,
|
|
983
|
+
pid: process.pid,
|
|
984
|
+
at: new Date().toISOString(),
|
|
985
|
+
dataDir: dirname(statusPath),
|
|
986
|
+
policyPath: livePolicy,
|
|
987
|
+
commandNames,
|
|
988
|
+
config: {
|
|
989
|
+
invalid: permission.invalid ?? null,
|
|
990
|
+
approvalMode: permission.approvalMode,
|
|
991
|
+
wrappers: permission.wrappers,
|
|
992
|
+
redact: permission.redact,
|
|
993
|
+
sessionApprovals: permission.sessionApprovals,
|
|
994
|
+
denyTools: permission.denyTools,
|
|
995
|
+
bashRuleCount: Object.keys(permission.bash ?? {}).length,
|
|
996
|
+
bashRulesSample: Object.keys(permission.bash ?? {}).slice(0, 12),
|
|
997
|
+
toolRules: Object.keys(permission.tools ?? {}),
|
|
998
|
+
paramRules: Object.keys(permission.params ?? {}),
|
|
999
|
+
agentRules: Object.keys(permission.agents ?? {}),
|
|
1000
|
+
argvRules: (permission.argv ?? []).length,
|
|
1001
|
+
sandboxAware: permission.sandboxAware,
|
|
1002
|
+
projectPolicy: permission.projectPolicy,
|
|
1003
|
+
projectPolicyStrict: permission.projectPolicyStrict,
|
|
1004
|
+
caseInsensitivePaths: permission.caseInsensitivePaths,
|
|
1005
|
+
patternMode: permission.patternMode,
|
|
1006
|
+
maxGlobStars: permission.maxGlobStars,
|
|
1007
|
+
enforce: permission.enforce,
|
|
1008
|
+
repeatLimit: permission.repeatLimit,
|
|
1009
|
+
repeatWindowMs: permission.repeatWindowMs,
|
|
1010
|
+
repeatAction: permission.repeatAction,
|
|
1011
|
+
snapshots: permission.snapshots,
|
|
1012
|
+
snapshotRetain: permission.snapshotRetain,
|
|
1013
|
+
commandRegistered,
|
|
1014
|
+
questionStatus,
|
|
1015
|
+
lastChannel,
|
|
1016
|
+
lastDialog,
|
|
1017
|
+
webBase: advertisedBase,
|
|
1018
|
+
networkRules: (permission.network ?? []).length,
|
|
1019
|
+
audit: permission.audit,
|
|
1020
|
+
auditPath: permission.auditPath,
|
|
1021
|
+
yolo: permission.yolo,
|
|
1022
|
+
},
|
|
1023
|
+
approved: [...approved.entries()].map(([sid, grants]) => [sid, [...grants.keys()]]),
|
|
1024
|
+
lastDecision: lastDecision ?? null,
|
|
1025
|
+
recentDecisions: recent,
|
|
1026
|
+
}, null, 2));
|
|
1027
|
+
} catch {
|
|
1028
|
+
// Diagnostics must never break enforcement.
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
writeStatus();
|
|
1032
|
+
|
|
1033
|
+
// Hot reload follows the live data-directory policy. A deleted file falls
|
|
1034
|
+
// back to the shipped seed on the next read, so recovery never needs a
|
|
1035
|
+
// restart.
|
|
1036
|
+
let watchedSignature = '';
|
|
1037
|
+
const reloadPolicy = () => {
|
|
1038
|
+
try {
|
|
1039
|
+
const raw = readFileSync(livePolicy, 'utf8');
|
|
1040
|
+
if (raw === watchedSignature) return;
|
|
1041
|
+
watchedSignature = raw;
|
|
1042
|
+
const next = mergePermission(config);
|
|
1043
|
+
if (next.invalid) {
|
|
1044
|
+
ctx.logger?.warn?.(`dsh-guard: policy reload rejected: ${next.invalid.join('; ')}`);
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
permission = next;
|
|
1048
|
+
writeStatus();
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
ctx.logger?.warn?.(`dsh-guard: policy reload failed: ${String(error)}`);
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
try {
|
|
1054
|
+
watchedSignature = readFileSync(livePolicy, 'utf8');
|
|
1055
|
+
} catch {
|
|
1056
|
+
// Missing policy file: the shipped seed + profile config remain in force.
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const policyWatcher = watchFile(livePolicy, { interval: 1000 }, reloadPolicy);
|
|
1060
|
+
policyWatcher.unref?.();
|
|
1061
|
+
ctx.on('dispose', () => unwatchFile(livePolicy, reloadPolicy));
|
|
1062
|
+
|
|
1063
|
+
// Browser-facing routes for the settings panel. Only switches and scalar
|
|
1064
|
+
// knobs travel over HTTP; rules themselves stay in policy.json. The fence is
|
|
1065
|
+
// the same one every plugin uses: loopback or same-origin only.
|
|
1066
|
+
ctx.inject?.(['webServer'], (scope) => {
|
|
1067
|
+
const webServer = scope.webServer;
|
|
1068
|
+
if (webServer?.register === undefined) return;
|
|
1069
|
+
try {
|
|
1070
|
+
const webApp = ctx.get?.('webApp');
|
|
1071
|
+
if (typeof webApp?.getAdvertisedUrl === 'function') {
|
|
1072
|
+
advertisedBase = String(webApp.getAdvertisedUrl()).replace(/\/+$/u, '');
|
|
1073
|
+
writeStatus();
|
|
1074
|
+
}
|
|
1075
|
+
} catch {
|
|
1076
|
+
advertisedBase = '';
|
|
1077
|
+
}
|
|
1078
|
+
const allowed = (req) => {
|
|
1079
|
+
const remote = req.socket?.remoteAddress ?? '';
|
|
1080
|
+
if (remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1') return true;
|
|
1081
|
+
const host = String(req.headers.host ?? '').split(':')[0];
|
|
1082
|
+
const origin = String(req.headers.origin ?? '');
|
|
1083
|
+
try {
|
|
1084
|
+
return origin !== '' && new URL(origin).hostname === host;
|
|
1085
|
+
} catch {
|
|
1086
|
+
return false;
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
1089
|
+
const json = (res, status, body) => {
|
|
1090
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
1091
|
+
res.end(JSON.stringify(body));
|
|
1092
|
+
};
|
|
1093
|
+
const readBody = async (req, limit = 32 * 1024) => {
|
|
1094
|
+
let body = '';
|
|
1095
|
+
for await (const chunk of req) {
|
|
1096
|
+
body += String(chunk);
|
|
1097
|
+
if (body.length > limit) throw new Error('body too large');
|
|
1098
|
+
}
|
|
1099
|
+
return body === '' ? {} : JSON.parse(body);
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
ctx.effect(() => webServer.register({
|
|
1103
|
+
kind: 'exact',
|
|
1104
|
+
path: '/api/dsh-guard/state',
|
|
1105
|
+
handler: async (req, res) => {
|
|
1106
|
+
if (!allowed(req)) return json(res, 403, { error: 'forbidden' });
|
|
1107
|
+
return json(res, 200, {
|
|
1108
|
+
config: {
|
|
1109
|
+
enforce: permission.enforce,
|
|
1110
|
+
yolo: permission.yolo,
|
|
1111
|
+
snapshots: permission.snapshots,
|
|
1112
|
+
snapshotMaxFiles: permission.snapshotMaxFiles,
|
|
1113
|
+
snapshotMaxBytes: permission.snapshotMaxBytes,
|
|
1114
|
+
snapshotRetain: permission.snapshotRetain,
|
|
1115
|
+
audit: permission.audit,
|
|
1116
|
+
auditMaxBytes: permission.auditMaxBytes,
|
|
1117
|
+
repeatLimit: permission.repeatLimit,
|
|
1118
|
+
repeatWindowMs: permission.repeatWindowMs,
|
|
1119
|
+
repeatAction: permission.repeatAction,
|
|
1120
|
+
approvalMode: permission.approvalMode,
|
|
1121
|
+
},
|
|
1122
|
+
presets: listPresets(),
|
|
1123
|
+
recentDecisions: recent,
|
|
1124
|
+
snapshots: listSnapshots(snapshotsDir()).slice(0, 8).map((entry) => ({
|
|
1125
|
+
id: entry.id,
|
|
1126
|
+
at: entry.at,
|
|
1127
|
+
tool: entry.tool,
|
|
1128
|
+
paths: entry.paths,
|
|
1129
|
+
})),
|
|
1130
|
+
policyPath: livePolicy,
|
|
1131
|
+
});
|
|
1132
|
+
},
|
|
1133
|
+
}), 'dsh-guard: state route');
|
|
1134
|
+
|
|
1135
|
+
ctx.effect(() => webServer.register({
|
|
1136
|
+
kind: 'exact',
|
|
1137
|
+
path: '/api/dsh-guard/config',
|
|
1138
|
+
handler: async (req, res) => {
|
|
1139
|
+
if (!allowed(req)) return json(res, 403, { error: 'forbidden' });
|
|
1140
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
|
1141
|
+
try {
|
|
1142
|
+
const body = await readBody(req);
|
|
1143
|
+
const patch = typeof body.patch === 'object' && body.patch !== null ? body.patch : body;
|
|
1144
|
+
const merged = applyPatch(readLivePolicyBody(livePolicy), patch);
|
|
1145
|
+
const errors = validate(merged.next);
|
|
1146
|
+
if (errors.length > 0) return json(res, 400, { error: errors.join('; ') });
|
|
1147
|
+
if (merged.changed) {
|
|
1148
|
+
if (existsSync(livePolicy)) copyFileSync(livePolicy, `${livePolicy}.bak`);
|
|
1149
|
+
writeFileSync(livePolicy, `${JSON.stringify({ $schema: './policy.schema.json', permission: merged.next }, null, 2)}\n`, { mode: 0o600 });
|
|
1150
|
+
}
|
|
1151
|
+
return json(res, 200, { ok: true, changed: merged.changed });
|
|
1152
|
+
} catch (error) {
|
|
1153
|
+
return json(res, 400, { error: String(error?.message ?? error) });
|
|
1154
|
+
}
|
|
1155
|
+
},
|
|
1156
|
+
}), 'dsh-guard: config route');
|
|
1157
|
+
|
|
1158
|
+
ctx.effect(() => webServer.register({
|
|
1159
|
+
kind: 'exact',
|
|
1160
|
+
path: '/api/dsh-guard/preset',
|
|
1161
|
+
handler: async (req, res) => {
|
|
1162
|
+
if (!allowed(req)) return json(res, 403, { error: 'forbidden' });
|
|
1163
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
|
1164
|
+
try {
|
|
1165
|
+
const body = await readBody(req);
|
|
1166
|
+
const preset = getPreset(String(body.name ?? ''));
|
|
1167
|
+
if (preset === undefined) return json(res, 400, { error: `unknown preset: ${body.name}` });
|
|
1168
|
+
const merged = applyPatch(readLivePolicyBody(livePolicy), preset.permission);
|
|
1169
|
+
const errors = validate(merged.next);
|
|
1170
|
+
if (errors.length > 0) return json(res, 400, { error: errors.join('; ') });
|
|
1171
|
+
if (existsSync(livePolicy)) copyFileSync(livePolicy, `${livePolicy}.bak`);
|
|
1172
|
+
writeFileSync(livePolicy, `${JSON.stringify({ $schema: './policy.schema.json', permission: merged.next }, null, 2)}\n`, { mode: 0o600 });
|
|
1173
|
+
return json(res, 200, { ok: true });
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
return json(res, 400, { error: String(error?.message ?? error) });
|
|
1176
|
+
}
|
|
1177
|
+
},
|
|
1178
|
+
}), 'dsh-guard: preset route');
|
|
1179
|
+
|
|
1180
|
+
// The approval card can only carry Markdown; this page is the two-column
|
|
1181
|
+
// view the card links to. It renders nothing that was not already shown in
|
|
1182
|
+
// the card, and entries expire.
|
|
1183
|
+
const pageAllowed = (req) => {
|
|
1184
|
+
const remote = req.socket?.remoteAddress ?? '';
|
|
1185
|
+
if (remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1') return true;
|
|
1186
|
+
// A browser navigation may omit Origin; the web layer has already
|
|
1187
|
+
// authenticated the request, so requiring a Host header is enough here.
|
|
1188
|
+
return typeof req.headers.host === 'string' && req.headers.host !== '';
|
|
1189
|
+
};
|
|
1190
|
+
ctx.effect(() => webServer.register({
|
|
1191
|
+
kind: 'exact',
|
|
1192
|
+
path: '/api/dsh-guard/diff',
|
|
1193
|
+
handler: async (req, res) => {
|
|
1194
|
+
if (!pageAllowed(req)) {
|
|
1195
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
1196
|
+
res.end('forbidden');
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
const id = new URL(req.url ?? '/', 'http://local').searchParams.get('id') ?? '';
|
|
1200
|
+
const entry = lookupDiff(id);
|
|
1201
|
+
if (entry === undefined) {
|
|
1202
|
+
res.writeHead(404, { 'content-type': 'text/html; charset=utf-8' });
|
|
1203
|
+
res.end('<!doctype html><meta charset="utf-8"><p style="font:14px system-ui;padding:24px;color:#6b7280">预览已过期,请重新触发一次审批。</p>');
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
const embed = new URL(req.url ?? '/', 'http://local').searchParams.get('embed') === '1';
|
|
1207
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
1208
|
+
res.end(renderDiffPage(id, entry, { embed }));
|
|
1209
|
+
},
|
|
1210
|
+
}), 'dsh-guard: diff page route');
|
|
1211
|
+
|
|
1212
|
+
ctx.effect(() => webServer.register({
|
|
1213
|
+
kind: 'exact',
|
|
1214
|
+
path: '/api/dsh-guard/hello',
|
|
1215
|
+
handler: async (req, res) => {
|
|
1216
|
+
if (!allowed(req)) return json(res, 403, { error: 'forbidden' });
|
|
1217
|
+
const id = new URL(req.url ?? '/', 'http://local').searchParams.get('id') ?? '';
|
|
1218
|
+
if (id !== '') liveClients.set(id, Date.now());
|
|
1219
|
+
return json(res, 200, { ok: true, mode: permission.approvalMode });
|
|
1220
|
+
},
|
|
1221
|
+
}), 'dsh-guard: hello route');
|
|
1222
|
+
|
|
1223
|
+
ctx.effect(() => webServer.register({
|
|
1224
|
+
kind: 'exact',
|
|
1225
|
+
path: '/api/dsh-guard/pending',
|
|
1226
|
+
handler: async (req, res) => {
|
|
1227
|
+
if (!allowed(req)) return json(res, 403, { error: 'forbidden' });
|
|
1228
|
+
const poller = new URL(req.url ?? '/', 'http://local').searchParams.get('id') ?? '';
|
|
1229
|
+
if (poller !== '') liveClients.set(poller, Date.now());
|
|
1230
|
+
const pending = [...pendingApprovals.values()].map((entry) => ({
|
|
1231
|
+
id: entry.id,
|
|
1232
|
+
at: entry.at,
|
|
1233
|
+
tool: entry.tool,
|
|
1234
|
+
pattern: entry.pattern,
|
|
1235
|
+
surface: entry.surface,
|
|
1236
|
+
path: entry.path,
|
|
1237
|
+
before: entry.before,
|
|
1238
|
+
after: entry.after,
|
|
1239
|
+
canPersist: entry.canPersist,
|
|
1240
|
+
}));
|
|
1241
|
+
return json(res, 200, { pending, mode: permission.approvalMode });
|
|
1242
|
+
},
|
|
1243
|
+
}), 'dsh-guard: pending route');
|
|
1244
|
+
|
|
1245
|
+
ctx.effect(() => webServer.register({
|
|
1246
|
+
kind: 'exact',
|
|
1247
|
+
path: '/api/dsh-guard/decide',
|
|
1248
|
+
handler: async (req, res) => {
|
|
1249
|
+
if (!allowed(req)) return json(res, 403, { error: 'forbidden' });
|
|
1250
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
|
1251
|
+
try {
|
|
1252
|
+
const body = await readBody(req);
|
|
1253
|
+
const entry = pendingApprovals.get(String(body.id ?? ''));
|
|
1254
|
+
if (entry === undefined) return json(res, 404, { error: 'no such request' });
|
|
1255
|
+
const choice = String(body.choice ?? '');
|
|
1256
|
+
if (!['allow', 'session', 'persist', 'deny'].includes(choice)) return json(res, 400, { error: 'bad choice' });
|
|
1257
|
+
entry.finish({ choice, reason: typeof body.reason === 'string' ? body.reason : undefined });
|
|
1258
|
+
return json(res, 200, { ok: true });
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
return json(res, 400, { error: String(error?.message ?? error) });
|
|
1261
|
+
}
|
|
1262
|
+
},
|
|
1263
|
+
}), 'dsh-guard: decide route');
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
// The audit file is the durable record; this window is what `guard_status`
|
|
1267
|
+
// and `status.json` show without reading a multi-megabyte JSONL per call.
|
|
1268
|
+
const remember = (exec, decision, snapshotId) => {
|
|
1269
|
+
recent.unshift({
|
|
1270
|
+
at: new Date().toISOString(),
|
|
1271
|
+
tool: exec.name,
|
|
1272
|
+
state: decision?.state ?? 'allow',
|
|
1273
|
+
pattern: decision?.pattern ?? null,
|
|
1274
|
+
surface: decision?.surface ?? null,
|
|
1275
|
+
snapshot: typeof snapshotId === 'string' ? snapshotId : null,
|
|
1276
|
+
});
|
|
1277
|
+
if (recent.length > RECENT_LIMIT) recent.length = RECENT_LIMIT;
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
const audit = (exec, decision, snapshotId) => {
|
|
1281
|
+
remember(exec, decision, snapshotId);
|
|
1282
|
+
if (!permission.audit) return;
|
|
1283
|
+
try {
|
|
1284
|
+
rotateAudit(permission.auditPath, permission.auditMaxBytes);
|
|
1285
|
+
const rawArgs = typeof exec.arguments === 'object' ? JSON.stringify(exec.arguments) : '';
|
|
1286
|
+
appendFileSync(permission.auditPath, JSON.stringify({
|
|
1287
|
+
at: new Date().toISOString(),
|
|
1288
|
+
session: sessionOf(exec),
|
|
1289
|
+
agent: agentLabel(exec.agent),
|
|
1290
|
+
tool: exec.name,
|
|
1291
|
+
state: decision?.state ?? 'allow',
|
|
1292
|
+
pattern: decision?.pattern ?? null,
|
|
1293
|
+
surface: decision?.surface ?? null,
|
|
1294
|
+
dryRun: decision?.dryRun === true ? true : undefined,
|
|
1295
|
+
snapshot: typeof snapshotId === 'string' ? snapshotId : undefined,
|
|
1296
|
+
// Argument text is redacted before it reaches the disk: the trail must
|
|
1297
|
+
// never become the place a credential survives.
|
|
1298
|
+
args: rawArgs === '' ? undefined : redactText(rawArgs).slice(0, 300),
|
|
1299
|
+
}) + '\n', { mode: 0o600 });
|
|
1300
|
+
} catch {
|
|
1301
|
+
// Auditing must never break enforcement.
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1305
|
+
// Hide denyTools from every newly created agent, using the agent-scoped
|
|
1306
|
+
// `this` of agent/created. The list is filtered against that agent's actual
|
|
1307
|
+
// schema, so a preset that already lacks a tool is not an error.
|
|
1308
|
+
if ((permission.denyTools ?? []).length > 0) {
|
|
1309
|
+
ctx.on('agent/created', function onAgentCreated() {
|
|
1310
|
+
try {
|
|
1311
|
+
const names = new Set((this.tools?.schemas?.() ?? []).map((schema) => schema.name));
|
|
1312
|
+
const present = (permission.denyTools ?? []).filter((name) => names.size === 0 || names.has(name));
|
|
1313
|
+
if (present.length > 0) this.tools.restrict({ deny: present });
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
ctx.logger?.warn?.(`dsh-guard: per-agent hide failed: ${String(error)}`);
|
|
1316
|
+
}
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// `/guard-test` dry-evaluates a hypothetical call so a rule can be checked
|
|
1321
|
+
// without running the tool; `/guard-undo` puts back a pre-write snapshot.
|
|
1322
|
+
// Neither touches the policy — rules live in policy.json.
|
|
1323
|
+
const commands = ctx.get?.('commands');
|
|
1324
|
+
if (commands?.register !== undefined) {
|
|
1325
|
+
(async () => {
|
|
1326
|
+
let testId = 'dsh-guard/test';
|
|
1327
|
+
let undoId = 'dsh-guard/undo';
|
|
1328
|
+
let policyId = 'dsh-guard/policy';
|
|
1329
|
+
let logId = 'dsh-guard/log';
|
|
1330
|
+
try {
|
|
1331
|
+
const brand = await import('@deepseek-ai/dsh-commands/brand');
|
|
1332
|
+
testId = brand.CommandDefinitionId('dsh-guard/test');
|
|
1333
|
+
undoId = brand.CommandDefinitionId('dsh-guard/undo');
|
|
1334
|
+
policyId = brand.CommandDefinitionId('dsh-guard/policy');
|
|
1335
|
+
logId = brand.CommandDefinitionId('dsh-guard/log');
|
|
1336
|
+
} catch {
|
|
1337
|
+
// Test/stub environments may not resolve the brand module; the strings still work there.
|
|
1338
|
+
}
|
|
1339
|
+
try {
|
|
1340
|
+
commands.register({
|
|
1341
|
+
definitionId: testId,
|
|
1342
|
+
name: 'guard-test',
|
|
1343
|
+
description: '试算一次工具调用的判定,不执行:/guard-test bash {"command":"rm -rf /"}',
|
|
1344
|
+
// A command without `input` only claims its bare token: the Client's
|
|
1345
|
+
// enter handler returns early for `/name args`, so the line reaches
|
|
1346
|
+
// the model as ordinary chat. This one takes arguments by design.
|
|
1347
|
+
input: { hint: '<tool> <json arguments>' },
|
|
1348
|
+
handler: async (invocation) => {
|
|
1349
|
+
const text = typeof invocation?.rawInput === 'string' ? invocation.rawInput.trim() : '';
|
|
1350
|
+
const space = text.search(/\s/u);
|
|
1351
|
+
const tool = space === -1 ? text : text.slice(0, space);
|
|
1352
|
+
const json = space === -1 ? '' : text.slice(space).trim();
|
|
1353
|
+
if (tool === '') return { kind: 'error', text: '用法:/guard-test <tool> <json arguments>' };
|
|
1354
|
+
let args = {};
|
|
1355
|
+
if (json !== '') {
|
|
1356
|
+
try {
|
|
1357
|
+
args = JSON.parse(json);
|
|
1358
|
+
} catch {
|
|
1359
|
+
return { kind: 'error', text: `参数不是合法 JSON:${json}` };
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
let cwd = process.cwd();
|
|
1363
|
+
if (invocation?.agent !== undefined) {
|
|
1364
|
+
try {
|
|
1365
|
+
cwd = await ctx.workingDirectory.ensure(invocation.agent, invocation.signal);
|
|
1366
|
+
} catch {
|
|
1367
|
+
// Keep the process cwd when the session working directory is unavailable.
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
const effective = projectPermission(permission, cwd);
|
|
1371
|
+
const decision = await decide({ name: tool, arguments: args, agent: invocation.agent }, cwd, effective);
|
|
1372
|
+
lastDecision = { at: Date.now(), tool, state: decision?.state ?? 'allow', pattern: decision?.pattern ?? null, dryRun: true };
|
|
1373
|
+
writeStatus();
|
|
1374
|
+
if (decision === undefined) return { kind: 'success', text: `${tool}: allow(未命中任何规则)` };
|
|
1375
|
+
return {
|
|
1376
|
+
kind: 'success',
|
|
1377
|
+
text: `${tool}: ${decision.state} by "${decision.pattern}"${decision.surface === undefined ? '' : ` [${decision.surface}]`}`,
|
|
1378
|
+
};
|
|
1379
|
+
},
|
|
1380
|
+
});
|
|
1381
|
+
commands.register({
|
|
1382
|
+
definitionId: undoId,
|
|
1383
|
+
name: 'guard-undo',
|
|
1384
|
+
description: '回滚 dsh-guard 拍下的最近一次写前快照',
|
|
1385
|
+
input: { hint: '(空=最近一次)| list | <id>' },
|
|
1386
|
+
handler: async (invocation) => {
|
|
1387
|
+
const text = typeof invocation?.rawInput === 'string' ? invocation.rawInput.trim() : '';
|
|
1388
|
+
const root = snapshotsDir();
|
|
1389
|
+
const entries = listSnapshots(root);
|
|
1390
|
+
if (text === 'list' || text === 'ls') {
|
|
1391
|
+
if (entries.length === 0) return { kind: 'success', text: 'dsh-guard: 没有可回滚的快照' };
|
|
1392
|
+
return { kind: 'success', text: entries.slice(0, 10).map(describeSnapshot).join('\n') };
|
|
1393
|
+
}
|
|
1394
|
+
const id = text === '' ? entries[0]?.id : text;
|
|
1395
|
+
if (id === undefined) return { kind: 'success', text: 'dsh-guard: 没有可回滚的快照' };
|
|
1396
|
+
if (!entries.some((entry) => entry.id === id)) return { kind: 'error', text: `dsh-guard: 找不到快照 ${id}` };
|
|
1397
|
+
try {
|
|
1398
|
+
const result = await restore(root, id);
|
|
1399
|
+
const parts = [];
|
|
1400
|
+
if (result.restored.length > 0) parts.push(`恢复 ${result.restored.length} 个文件`);
|
|
1401
|
+
if (result.removed.length > 0) parts.push(`删除 ${result.removed.length} 个当时不存在的文件`);
|
|
1402
|
+
if (result.skipped.length > 0) parts.push(`跳过 ${result.skipped.length} 个(超限/非普通文件)`);
|
|
1403
|
+
const suffix = parts.length === 0 ? '' : `(${parts.join(',')})`;
|
|
1404
|
+
return { kind: 'success', text: `dsh-guard: 已回滚 ${id}${suffix}` };
|
|
1405
|
+
} catch (error) {
|
|
1406
|
+
return { kind: 'error', text: `dsh-guard: 回滚失败 ${String(error?.message ?? error)}` };
|
|
1407
|
+
}
|
|
1408
|
+
},
|
|
1409
|
+
});
|
|
1410
|
+
commands.register({
|
|
1411
|
+
definitionId: policyId,
|
|
1412
|
+
name: 'guard-policy',
|
|
1413
|
+
description: '查看或修改生效中的策略:/guard-policy list 或 /guard-policy {"bash":{"rm *":"ask"}}',
|
|
1414
|
+
input: { hint: 'list | {"<surface>":{"<pattern>":"<state>"}}' },
|
|
1415
|
+
handler: async (invocation) => {
|
|
1416
|
+
const text = typeof invocation?.rawInput === 'string' ? invocation.rawInput.trim() : '';
|
|
1417
|
+
const file = policyPath();
|
|
1418
|
+
if (text === '' || text === 'list') {
|
|
1419
|
+
const current = readLivePolicyBody(file);
|
|
1420
|
+
const json = JSON.stringify(current, null, 2);
|
|
1421
|
+
return { kind: 'success', text: json.length > 3000 ? `${json.slice(0, 3000)}\n…(已截断,完整内容见 ${file})` : json };
|
|
1422
|
+
}
|
|
1423
|
+
let patch;
|
|
1424
|
+
try {
|
|
1425
|
+
if (text === 'preset' || text.startsWith('preset ')) {
|
|
1426
|
+
const name = text.slice(6).trim();
|
|
1427
|
+
if (name === '' || name === 'list') {
|
|
1428
|
+
return { kind: 'success', text: ['可用模板:', ...listPresets()].join('\n') };
|
|
1429
|
+
}
|
|
1430
|
+
const preset = getPreset(name);
|
|
1431
|
+
if (preset === undefined) return { kind: 'error', text: `dsh-guard: 没有模板 ${name}` };
|
|
1432
|
+
patch = preset.permission;
|
|
1433
|
+
} else {
|
|
1434
|
+
patch = JSON.parse(text);
|
|
1435
|
+
}
|
|
1436
|
+
} catch {
|
|
1437
|
+
return { kind: 'error', text: '参数必须是 JSON 对象,例如 {"bash":{"rm *":"ask"}}' };
|
|
1438
|
+
}
|
|
1439
|
+
let merged;
|
|
1440
|
+
try {
|
|
1441
|
+
merged = applyPatch(readLivePolicyBody(file), patch);
|
|
1442
|
+
} catch (error) {
|
|
1443
|
+
return { kind: 'error', text: String(error?.message ?? error) };
|
|
1444
|
+
}
|
|
1445
|
+
if (!merged.changed) return { kind: 'success', text: 'dsh-guard: 策略没有变化' };
|
|
1446
|
+
const errors = validate(merged.next);
|
|
1447
|
+
if (errors.length > 0) return { kind: 'error', text: `dsh-guard: 拒绝写入,配置无效:${errors.join('; ')}` };
|
|
1448
|
+
try {
|
|
1449
|
+
// A rewrite normalizes the file and drops comments; keep the exact
|
|
1450
|
+
// previous text beside it so a hand-written policy is recoverable.
|
|
1451
|
+
if (existsSync(file)) copyFileSync(file, `${file}.bak`);
|
|
1452
|
+
writeFileSync(file, `${JSON.stringify({ $schema: './policy.schema.json', permission: merged.next }, null, 2)}\n`, { mode: 0o600 });
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
return { kind: 'error', text: `dsh-guard: 写入失败 ${String(error?.message ?? error)}` };
|
|
1455
|
+
}
|
|
1456
|
+
return { kind: 'success', text: `dsh-guard: 已写入 ${file}(备份 policy.json.bak,1 秒内热重载)\n${summarize(merged.next)}` };
|
|
1457
|
+
},
|
|
1458
|
+
});
|
|
1459
|
+
commands.register({
|
|
1460
|
+
definitionId: logId,
|
|
1461
|
+
name: 'guard-log',
|
|
1462
|
+
description: '查看最近的判定记录:/guard-log 或 /guard-log 50',
|
|
1463
|
+
input: { hint: '[条数,默认 20,最多 200]' },
|
|
1464
|
+
handler: async (invocation) => {
|
|
1465
|
+
if (!permission.audit) return { kind: 'error', text: 'dsh-guard: audit 已关闭,没有记录可看' };
|
|
1466
|
+
const text = typeof invocation?.rawInput === 'string' ? invocation.rawInput.trim() : '';
|
|
1467
|
+
const asked = text === '' ? 20 : Number(text);
|
|
1468
|
+
if (text !== '' && (!Number.isFinite(asked) || asked <= 0)) return { kind: 'error', text: '参数必须是正整数' };
|
|
1469
|
+
const rows = readTail(permission.auditPath, asked);
|
|
1470
|
+
if (rows.length === 0) return { kind: 'success', text: 'dsh-guard: 还没有判定记录' };
|
|
1471
|
+
return { kind: 'success', text: rows.map(describeDecision).join('\n') };
|
|
1472
|
+
},
|
|
1473
|
+
});
|
|
1474
|
+
commandRegistered = true;
|
|
1475
|
+
// CommandRuntime only notifies observers from its constructor, so a
|
|
1476
|
+
// command registered later never reaches an already-open page. Ask for
|
|
1477
|
+
// the change explicitly: the Client's `commands/change` listener then
|
|
1478
|
+
// repulls the directory instead of keeping a stale snapshot.
|
|
1479
|
+
commands.notifyChange?.();
|
|
1480
|
+
writeStatus();
|
|
1481
|
+
} catch (error) {
|
|
1482
|
+
ctx.logger?.warn?.(`dsh-guard: command registration failed: ${String(error)}`);
|
|
1483
|
+
}
|
|
1484
|
+
})();
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// One grant is keyed by (session, rule pattern): a grant for `rm *` never
|
|
1488
|
+
// widens to another rule. TTL and use budget are opt-in; the defaults keep
|
|
1489
|
+
// the old "rest of the session" behaviour.
|
|
1490
|
+
const grantFor = (sid, pattern) => {
|
|
1491
|
+
const grants = approved.get(sid);
|
|
1492
|
+
const grant = grants?.get(pattern);
|
|
1493
|
+
if (grant === undefined) return false;
|
|
1494
|
+
if (grant.until > 0 && Date.now() > grant.until) {
|
|
1495
|
+
grants.delete(pattern);
|
|
1496
|
+
return false;
|
|
1497
|
+
}
|
|
1498
|
+
if (grant.maxUses > 0 && grant.uses >= grant.maxUses) return false;
|
|
1499
|
+
grant.uses += 1;
|
|
1500
|
+
return true;
|
|
1501
|
+
};
|
|
1502
|
+
|
|
1503
|
+
const addGrant = (sid, pattern) => {
|
|
1504
|
+
const ttl = permission.sessionApprovalTtlMs ?? 0;
|
|
1505
|
+
const grants = approved.get(sid) ?? new Map();
|
|
1506
|
+
grants.set(pattern, {
|
|
1507
|
+
until: ttl > 0 ? Date.now() + ttl : 0,
|
|
1508
|
+
uses: 0,
|
|
1509
|
+
maxUses: permission.sessionApprovalMaxUses ?? 0,
|
|
1510
|
+
});
|
|
1511
|
+
approved.set(sid, grants);
|
|
1512
|
+
};
|
|
1513
|
+
|
|
1514
|
+
// Snapshot every existing file a write-capable call is about to replace.
|
|
1515
|
+
// The copy lands before the tool runs, so a bad edit is always recoverable —
|
|
1516
|
+
// including one that succeeds and then turns out to be wrong.
|
|
1517
|
+
const maybeSnapshot = async (exec, effective, cwd) => {
|
|
1518
|
+
if (effective.snapshots === false) return undefined;
|
|
1519
|
+
if (!WRITE_TOOLS.has(exec.name)) return undefined;
|
|
1520
|
+
const targets = snapshotTargets(exec.arguments ?? {});
|
|
1521
|
+
if (targets.length === 0) return undefined;
|
|
1522
|
+
const absolute = targets.map((target) => expand(target, cwd)).filter((path) => typeof path === 'string');
|
|
1523
|
+
if (absolute.length === 0) return undefined;
|
|
1524
|
+
const meta = await capture(snapshotsDir(), {
|
|
1525
|
+
tool: exec.name,
|
|
1526
|
+
callId: exec.callId,
|
|
1527
|
+
paths: absolute,
|
|
1528
|
+
maxFiles: effective.snapshotMaxFiles,
|
|
1529
|
+
maxBytes: effective.snapshotMaxBytes,
|
|
1530
|
+
retain: effective.snapshotRetain,
|
|
1531
|
+
});
|
|
1532
|
+
return meta?.id;
|
|
1533
|
+
};
|
|
1534
|
+
|
|
1535
|
+
// Loop detection: the same tool with byte-identical arguments more than
|
|
1536
|
+
// `repeatLimit` times inside `repeatWindowMs`. In-process and per-session-
|
|
1537
|
+
// agnostic on purpose — a loop is a loop whichever session runs it. The
|
|
1538
|
+
// fingerprint is the serialized arguments, truncated so a huge payload does
|
|
1539
|
+
// not become a memory sink.
|
|
1540
|
+
const repeats = new Map();
|
|
1541
|
+
const trackRepeat = (exec, effective) => {
|
|
1542
|
+
const limit = effective.repeatLimit ?? 0;
|
|
1543
|
+
if (limit <= 0) return undefined;
|
|
1544
|
+
const windowMs = effective.repeatWindowMs ?? 60000;
|
|
1545
|
+
let fingerprint = '';
|
|
1546
|
+
try {
|
|
1547
|
+
fingerprint = JSON.stringify(exec.arguments ?? {});
|
|
1548
|
+
} catch {
|
|
1549
|
+
fingerprint = '';
|
|
1550
|
+
}
|
|
1551
|
+
if (fingerprint.length > 400) fingerprint = fingerprint.slice(0, 400);
|
|
1552
|
+
const key = `${exec.name}\u0000${fingerprint}`;
|
|
1553
|
+
const now = Date.now();
|
|
1554
|
+
const entry = repeats.get(key);
|
|
1555
|
+
if (entry === undefined || now - entry.firstAt > windowMs) {
|
|
1556
|
+
repeats.set(key, { firstAt: now, count: 1 });
|
|
1557
|
+
if (repeats.size > 512) {
|
|
1558
|
+
for (const [candidate, seen] of repeats) {
|
|
1559
|
+
if (now - seen.firstAt > windowMs) repeats.delete(candidate);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
return undefined;
|
|
1563
|
+
}
|
|
1564
|
+
entry.count += 1;
|
|
1565
|
+
if (entry.count < limit) return undefined;
|
|
1566
|
+
return {
|
|
1567
|
+
state: effective.repeatAction ?? 'ask',
|
|
1568
|
+
pattern: `repeat ${exec.name} ×${entry.count}`,
|
|
1569
|
+
surface: 'repeat',
|
|
1570
|
+
key: exec.name,
|
|
1571
|
+
};
|
|
1572
|
+
};
|
|
1573
|
+
|
|
1574
|
+
// Self-drawn approval dialog. `pre-execute` parks one Promise per request in
|
|
1575
|
+
// this map; the Client fetches the queue over HTTP and posts the decision,
|
|
1576
|
+
// which resolves it. Nothing else touches the map, and every exit is
|
|
1577
|
+
// idempotent so a double click cannot settle twice.
|
|
1578
|
+
const pendingApprovals = new Map();
|
|
1579
|
+
const awaitDialog = (exec, decision, payload, effective) => {
|
|
1580
|
+
const id = Math.random().toString(36).slice(2, 10);
|
|
1581
|
+
const timeoutMs = Number.isInteger(effective.dialogTimeoutMs) && effective.dialogTimeoutMs > 0
|
|
1582
|
+
? effective.dialogTimeoutMs
|
|
1583
|
+
: 120000;
|
|
1584
|
+
return new Promise((resolve) => {
|
|
1585
|
+
const finish = (outcome) => {
|
|
1586
|
+
const entry = pendingApprovals.get(id);
|
|
1587
|
+
if (entry === undefined) return;
|
|
1588
|
+
pendingApprovals.delete(id);
|
|
1589
|
+
clearTimeout(entry.timer);
|
|
1590
|
+
resolve(outcome);
|
|
1591
|
+
};
|
|
1592
|
+
const seconds = Math.round(timeoutMs / 1000);
|
|
1593
|
+
const timer = setTimeout(() => finish({ choice: 'deny', reason: `用户未在 ${seconds} 秒内确认审批,已拒绝本次调用` }), timeoutMs);
|
|
1594
|
+
timer.unref?.();
|
|
1595
|
+
lastDialog = {
|
|
1596
|
+
at: Date.now(),
|
|
1597
|
+
tool: exec.name,
|
|
1598
|
+
pattern: decision.pattern,
|
|
1599
|
+
path: payload?.path ?? null,
|
|
1600
|
+
beforeLines: typeof payload?.before === 'string' ? payload.before.split('\n').length : 0,
|
|
1601
|
+
afterLines: typeof payload?.after === 'string' ? payload.after.split('\n').length : 0,
|
|
1602
|
+
};
|
|
1603
|
+
pendingApprovals.set(id, {
|
|
1604
|
+
id,
|
|
1605
|
+
at: Date.now(),
|
|
1606
|
+
tool: exec.name,
|
|
1607
|
+
pattern: decision.pattern,
|
|
1608
|
+
surface: decision.surface ?? null,
|
|
1609
|
+
path: payload?.path ?? null,
|
|
1610
|
+
before: payload?.before ?? null,
|
|
1611
|
+
after: payload?.after ?? null,
|
|
1612
|
+
canPersist: canPersistDecision(decision),
|
|
1613
|
+
finish,
|
|
1614
|
+
timer,
|
|
1615
|
+
});
|
|
1616
|
+
if (exec.signal?.addEventListener !== undefined) {
|
|
1617
|
+
exec.signal.addEventListener('abort', () => finish({ choice: 'deny', reason: '调用已取消' }), { once: true });
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
};
|
|
1621
|
+
|
|
1622
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
1623
|
+
try {
|
|
1624
|
+
const listed = commands?.list?.(exec.agent);
|
|
1625
|
+
if (Array.isArray(listed)) commandNames = listed.map((command) => command.name);
|
|
1626
|
+
} catch {
|
|
1627
|
+
commandNames = ['<list failed>'];
|
|
1628
|
+
}
|
|
1629
|
+
let cwd = process.cwd();
|
|
1630
|
+
if (exec.agent !== undefined) {
|
|
1631
|
+
try {
|
|
1632
|
+
cwd = await ctx.workingDirectory.ensure(exec.agent, exec.signal);
|
|
1633
|
+
} catch {
|
|
1634
|
+
// Keep the process cwd when the session working directory is unavailable.
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
const effective = projectPermission(permission, cwd);
|
|
1638
|
+
let decision = await decide(exec, cwd, effective);
|
|
1639
|
+
// Loop detection runs after the rule layers and before the snapshot: a
|
|
1640
|
+
// model stuck repeating one identical call deserves a question even when
|
|
1641
|
+
// each individual call would be allowed.
|
|
1642
|
+
const repeat = trackRepeat(exec, effective);
|
|
1643
|
+
if (repeat !== undefined) {
|
|
1644
|
+
decision = mostRestrictive(decision, repeat);
|
|
1645
|
+
if (decision?.state === 'allow') decision = undefined;
|
|
1646
|
+
}
|
|
1647
|
+
// A denied call never rewrites anything, so it needs no snapshot.
|
|
1648
|
+
const snapshotId = decision?.state === 'deny' ? undefined : await maybeSnapshot(exec, effective, cwd);
|
|
1649
|
+
if (decision === undefined) {
|
|
1650
|
+
lastDecision = { at: Date.now(), tool: exec.name, state: 'allow', pattern: null };
|
|
1651
|
+
audit(exec, undefined, snapshotId);
|
|
1652
|
+
writeStatus();
|
|
1653
|
+
return next();
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// Dry run: record what the rules would have done and let the call through.
|
|
1657
|
+
if (effective.enforce === false) {
|
|
1658
|
+
lastDecision = { at: Date.now(), tool: exec.name, state: `dry-run:${decision.state}`, pattern: decision.pattern };
|
|
1659
|
+
audit(exec, { ...decision, dryRun: true }, snapshotId);
|
|
1660
|
+
writeStatus();
|
|
1661
|
+
return next();
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
const reason = `dsh-guard: ${decision.state} by "${decision.pattern}"`;
|
|
1665
|
+
if (decision.state === 'deny') {
|
|
1666
|
+
lastDecision = { at: Date.now(), tool: exec.name, state: 'deny', pattern: decision.pattern };
|
|
1667
|
+
audit(exec, decision, snapshotId);
|
|
1668
|
+
writeStatus();
|
|
1669
|
+
return { kind: 'deny', reason };
|
|
1670
|
+
}
|
|
1671
|
+
if (effective.yolo || sandboxYolo(ctx, exec, effective)) {
|
|
1672
|
+
lastDecision = { at: Date.now(), tool: exec.name, state: 'allow', pattern: `yolo ${decision.pattern}` };
|
|
1673
|
+
audit(exec, { ...decision, state: 'allow', pattern: `yolo ${decision.pattern}` }, snapshotId);
|
|
1674
|
+
writeStatus();
|
|
1675
|
+
return next();
|
|
1676
|
+
}
|
|
1677
|
+
lastDecision = { at: Date.now(), tool: exec.name, state: decision.state, pattern: decision.pattern };
|
|
1678
|
+
audit(exec, decision, snapshotId);
|
|
1679
|
+
writeStatus();
|
|
1680
|
+
|
|
1681
|
+
const sid = sessionOf(exec);
|
|
1682
|
+
if (sid !== undefined && grantFor(sid, decision.pattern)) return next();
|
|
1683
|
+
|
|
1684
|
+
const dialogLive = clientsAlive();
|
|
1685
|
+
lastChannel = effective.approvalMode + ':' + (dialogLive ? 'live' : 'idle') + ':' + liveClients.size;
|
|
1686
|
+
if (effective.approvalMode === 'dialog' && !dialogLive) {
|
|
1687
|
+
// No approval UI is connected, so waiting would only delay the call. Tell
|
|
1688
|
+
// the model why it failed instead of silently degrading to another card.
|
|
1689
|
+
return { kind: 'deny', reason: 'dsh-guard: 没有已连接的审批界面,已拒绝本次调用(在 Web UI 打开页面后重试)' };
|
|
1690
|
+
}
|
|
1691
|
+
if (effective.approvalMode === 'dialog') {
|
|
1692
|
+
// Read the target once: the dialog shows it side by side, and the same
|
|
1693
|
+
// bytes go to the snapshot path below.
|
|
1694
|
+
let payload = null;
|
|
1695
|
+
try {
|
|
1696
|
+
const args = exec.arguments ?? {};
|
|
1697
|
+
const rawPath = typeof args.file_path === 'string' ? args.file_path : (typeof args.path === 'string' ? args.path : undefined);
|
|
1698
|
+
if (rawPath !== undefined) {
|
|
1699
|
+
const absolute = expand(rawPath, cwd);
|
|
1700
|
+
let before = '';
|
|
1701
|
+
if (absolute !== undefined) {
|
|
1702
|
+
try {
|
|
1703
|
+
const stat = statSync(absolute);
|
|
1704
|
+
if (stat.isFile() && stat.size <= MAX_READ_BYTES) before = readFileSync(absolute, 'utf8');
|
|
1705
|
+
} catch {
|
|
1706
|
+
before = '';
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
const after = typeof args.content === 'string' ? args.content
|
|
1710
|
+
: typeof args.new_string === 'string' ? args.new_string : '';
|
|
1711
|
+
payload = { path: rawPath, before, after };
|
|
1712
|
+
}
|
|
1713
|
+
} catch {
|
|
1714
|
+
payload = null;
|
|
1715
|
+
}
|
|
1716
|
+
lastChannel = 'dialog-wait';
|
|
1717
|
+
const outcome = await awaitDialog(exec, decision, payload, effective);
|
|
1718
|
+
lastChannel = 'dialog-' + (outcome.choice ?? 'none');
|
|
1719
|
+
if (outcome.choice === 'allow') return next();
|
|
1720
|
+
if (outcome.choice === 'session') {
|
|
1721
|
+
if (sid !== undefined) addGrant(sid, decision.pattern);
|
|
1722
|
+
return next();
|
|
1723
|
+
}
|
|
1724
|
+
if (outcome.choice === 'persist') {
|
|
1725
|
+
try {
|
|
1726
|
+
if (persistAllow(cwd, decision)) {
|
|
1727
|
+
if (sid !== undefined) addGrant(sid, decision.pattern);
|
|
1728
|
+
return next();
|
|
1729
|
+
}
|
|
1730
|
+
} catch (error) {
|
|
1731
|
+
ctx.logger?.warn?.('dsh-guard: project rule write failed: ' + String(error));
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
return { kind: 'deny', reason: `dsh-guard: ${outcome.reason ?? '用户拒绝'}` };
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
if (effective.approvalMode === 'question') {
|
|
1738
|
+
const uq = ctx.get?.('userQuestions');
|
|
1739
|
+
questionStatus = uq === undefined ? 'no-service' : exec.agent === undefined ? 'no-agent' : 'asking';
|
|
1740
|
+
if (uq !== undefined && exec.agent !== undefined) {
|
|
1741
|
+
try {
|
|
1742
|
+
const options = [
|
|
1743
|
+
{ label: '允许一次', description: '只放行这次调用' },
|
|
1744
|
+
{ label: '本会话允许', description: '本次会话内命中同一规则时不再询问' },
|
|
1745
|
+
];
|
|
1746
|
+
if (effective.approvePersist !== false && canPersistDecision(decision)) {
|
|
1747
|
+
options.push({ label: '写入项目规则', description: '把这条 allow 写进 .dsh/guard-policy.json' });
|
|
1748
|
+
}
|
|
1749
|
+
options.push({ label: '拒绝', description: '阻止这次调用' });
|
|
1750
|
+
// A three-button dialog cannot show what is about to change. The
|
|
1751
|
+
// preview turns the call into "current vs next" text so a write that
|
|
1752
|
+
// claims to fix a typo but deletes forty lines is visible before the
|
|
1753
|
+
// approval, not after.
|
|
1754
|
+
let previewText = '';
|
|
1755
|
+
try {
|
|
1756
|
+
previewText = previewFor(exec, (raw) => {
|
|
1757
|
+
const absolute = expand(raw, cwd);
|
|
1758
|
+
if (absolute === undefined) return undefined;
|
|
1759
|
+
const stat = statSync(absolute);
|
|
1760
|
+
if (!stat.isFile() || stat.size > MAX_READ_BYTES) return undefined;
|
|
1761
|
+
return readFileSync(absolute, 'utf8');
|
|
1762
|
+
});
|
|
1763
|
+
} catch {
|
|
1764
|
+
previewText = '';
|
|
1765
|
+
}
|
|
1766
|
+
// A link to a real side-by-side page: the card itself can only carry
|
|
1767
|
+
// Markdown, so the two-column view lives behind this URL.
|
|
1768
|
+
let diffLink = '';
|
|
1769
|
+
try {
|
|
1770
|
+
const args = exec.arguments ?? {};
|
|
1771
|
+
const rawPath = typeof args.file_path === 'string' ? args.file_path : (typeof args.path === 'string' ? args.path : undefined);
|
|
1772
|
+
const content = typeof args.content === 'string' ? args.content : undefined;
|
|
1773
|
+
if (exec.name === 'write' && rawPath !== undefined && content !== undefined) {
|
|
1774
|
+
const absolute = expand(rawPath, cwd);
|
|
1775
|
+
let before = '';
|
|
1776
|
+
if (absolute !== undefined) {
|
|
1777
|
+
try {
|
|
1778
|
+
const stat = statSync(absolute);
|
|
1779
|
+
if (stat.isFile() && stat.size <= MAX_READ_BYTES) before = readFileSync(absolute, 'utf8');
|
|
1780
|
+
} catch {
|
|
1781
|
+
before = '';
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
const diffId = rememberDiff(before, content, rawPath);
|
|
1785
|
+
// Markdown drops a relative href, so the link needs the absolute
|
|
1786
|
+
// origin the Web app advertises.
|
|
1787
|
+
let base = typeof effective.webBase === 'string' && effective.webBase !== ''
|
|
1788
|
+
? effective.webBase.replace(/\/+$/u, '')
|
|
1789
|
+
: advertisedBase;
|
|
1790
|
+
try {
|
|
1791
|
+
const webApp = base === '' ? ctx.get?.('webApp') : undefined;
|
|
1792
|
+
if (typeof webApp?.getAdvertisedUrl === 'function') {
|
|
1793
|
+
base = String(webApp.getAdvertisedUrl()).replace(/\/+$/u, '');
|
|
1794
|
+
}
|
|
1795
|
+
} catch {
|
|
1796
|
+
base = '';
|
|
1797
|
+
}
|
|
1798
|
+
if (base === '') {
|
|
1799
|
+
const fromEnv = process.env.DSH_WEB_URL;
|
|
1800
|
+
if (typeof fromEnv === 'string' && fromEnv !== '') base = fromEnv.replace(/\/+$/u, '');
|
|
1801
|
+
}
|
|
1802
|
+
if (base !== '') {
|
|
1803
|
+
// The approval renderer escapes raw HTML — an inline iframe shows
|
|
1804
|
+
// up as literal text — so a Markdown link is the only thing that
|
|
1805
|
+
// renders. The two-column view opens in its own tab.
|
|
1806
|
+
diffLink = `[打开双栏对比](${base}/api/dsh-guard/diff?id=${diffId})`;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
} catch {
|
|
1810
|
+
diffLink = '';
|
|
1811
|
+
}
|
|
1812
|
+
// The dialog renders `detail` as markdown, so a fenced block is what
|
|
1813
|
+
// keeps the diff's line breaks; plain newlines collapse into spaces.
|
|
1814
|
+
const body = previewText === ''
|
|
1815
|
+
? `规则:\`${decision.pattern}\``
|
|
1816
|
+
: [`规则:\`${decision.pattern}\``, '', '```diff', previewText, '```'].join('\n');
|
|
1817
|
+
const detail = diffLink === '' ? body : `${body}\n\n${diffLink}`;
|
|
1818
|
+
const answer = await uq.ask({
|
|
1819
|
+
agent: exec.agent,
|
|
1820
|
+
signal: exec.signal,
|
|
1821
|
+
questions: [{
|
|
1822
|
+
id: 'guard',
|
|
1823
|
+
header: 'dsh-guard',
|
|
1824
|
+
question: `允许 ${exec.name} 执行吗?`,
|
|
1825
|
+
detail,
|
|
1826
|
+
options,
|
|
1827
|
+
}],
|
|
1828
|
+
});
|
|
1829
|
+
questionStatus = 'answered';
|
|
1830
|
+
const item = answer?.answers?.find((a) => a.id === 'guard');
|
|
1831
|
+
const choice = item?.selected?.[0];
|
|
1832
|
+
if (choice === '本会话允许') {
|
|
1833
|
+
if (sid !== undefined) addGrant(sid, decision.pattern);
|
|
1834
|
+
return next();
|
|
1835
|
+
}
|
|
1836
|
+
if (choice === '允许一次') return next();
|
|
1837
|
+
if (choice === '写入项目规则') {
|
|
1838
|
+
let written = false;
|
|
1839
|
+
try {
|
|
1840
|
+
written = persistAllow(cwd, decision);
|
|
1841
|
+
} catch (error) {
|
|
1842
|
+
ctx.logger?.warn?.('dsh-guard: project rule write failed: ' + String(error));
|
|
1843
|
+
}
|
|
1844
|
+
if (written) {
|
|
1845
|
+
if (sid !== undefined) addGrant(sid, decision.pattern);
|
|
1846
|
+
return next();
|
|
1847
|
+
}
|
|
1848
|
+
return { kind: 'deny', reason: `dsh-guard: 无法写入项目规则(${decision.surface ?? 'unknown'} 不支持持久化)` };
|
|
1849
|
+
}
|
|
1850
|
+
const custom = typeof item?.custom === 'string' ? item.custom.trim() : '';
|
|
1851
|
+
const suffix = custom === '' ? '' : `:${custom}`;
|
|
1852
|
+
return { kind: 'deny', reason: `dsh-guard: 用户拒绝 "${decision.pattern}"${suffix}` };
|
|
1853
|
+
} catch (error) {
|
|
1854
|
+
// No answerer, or the question was aborted: fall back to DSH approval.
|
|
1855
|
+
questionStatus = `failed: ${String(error?.message ?? error)}`;
|
|
1856
|
+
ctx.logger?.warn?.(`dsh-guard: user question failed: ${questionStatus}`);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
if (effective.sessionApprovals && sid !== undefined) pending.set(exec.callId, { sid, pattern: decision.pattern });
|
|
1862
|
+
return { kind: 'ask', reason };
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
// `defineTool` lives in @deepseek-ai/dsh-tools, which a profile-root plugin
|
|
1866
|
+
// can only resolve at runtime. Earlier versions referenced it without
|
|
1867
|
+
// importing it, so this whole block threw a ReferenceError and the tool was
|
|
1868
|
+
// never registered.
|
|
1869
|
+
(async () => {
|
|
1870
|
+
try {
|
|
1871
|
+
const { defineTool } = await import('@deepseek-ai/dsh-tools');
|
|
1872
|
+
ctx.tools.register(defineTool({
|
|
1873
|
+
name: 'guard_status',
|
|
1874
|
+
description: 'Report the live dsh-guard version, effective configuration, session approvals, and the last decision. Read-only.',
|
|
1875
|
+
parameters: {},
|
|
1876
|
+
output: {
|
|
1877
|
+
schema: {
|
|
1878
|
+
version: { type: 'string', required: true },
|
|
1879
|
+
config: { type: 'json', required: true },
|
|
1880
|
+
approved: { type: 'json', required: true },
|
|
1881
|
+
lastDecision: { type: 'json' },
|
|
1882
|
+
recentDecisions: { type: 'json', required: true },
|
|
1883
|
+
},
|
|
1884
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
1885
|
+
},
|
|
1886
|
+
execute() {
|
|
1887
|
+
return Promise.resolve({
|
|
1888
|
+
version: VERSION,
|
|
1889
|
+
config: {
|
|
1890
|
+
invalid: permission.invalid ?? null,
|
|
1891
|
+
approvalMode: permission.approvalMode,
|
|
1892
|
+
enforce: permission.enforce,
|
|
1893
|
+
snapshots: permission.snapshots,
|
|
1894
|
+
snapshotRetain: permission.snapshotRetain,
|
|
1895
|
+
wrappers: permission.wrappers,
|
|
1896
|
+
redact: permission.redact,
|
|
1897
|
+
sessionApprovals: permission.sessionApprovals,
|
|
1898
|
+
denyTools: permission.denyTools,
|
|
1899
|
+
bashRules: Object.keys(permission.bash ?? {}),
|
|
1900
|
+
toolRules: Object.keys(permission.tools ?? {}),
|
|
1901
|
+
paramRules: Object.keys(permission.params ?? {}),
|
|
1902
|
+
agentRules: Object.keys(permission.agents ?? {}),
|
|
1903
|
+
argvRules: (permission.argv ?? []).length,
|
|
1904
|
+
sandboxAware: permission.sandboxAware,
|
|
1905
|
+
patternMode: permission.patternMode,
|
|
1906
|
+
yolo: permission.yolo,
|
|
1907
|
+
dataDir: dirname(statusPath),
|
|
1908
|
+
auditPath: permission.auditPath,
|
|
1909
|
+
commandNames,
|
|
1910
|
+
},
|
|
1911
|
+
approved: [...approved.entries()].map(([sid, grants]) => [sid, [...grants.keys()]]),
|
|
1912
|
+
lastDecision: lastDecision ?? null,
|
|
1913
|
+
recentDecisions: recent,
|
|
1914
|
+
});
|
|
1915
|
+
},
|
|
1916
|
+
}));
|
|
1917
|
+
} catch (error) {
|
|
1918
|
+
ctx.logger?.warn?.(`dsh-guard: guard_status registration failed: ${String(error)}`);
|
|
1919
|
+
}
|
|
1920
|
+
})();
|
|
1921
|
+
|
|
1922
|
+
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
1923
|
+
if (permission.sessionApprovals) {
|
|
1924
|
+
const entry = pending.get(exec.callId);
|
|
1925
|
+
if (entry !== undefined) {
|
|
1926
|
+
pending.delete(exec.callId);
|
|
1927
|
+
if (result?.isError !== true) {
|
|
1928
|
+
const ttl = permission.sessionApprovalTtlMs ?? 0;
|
|
1929
|
+
const grants = approved.get(entry.sid) ?? new Map();
|
|
1930
|
+
grants.set(entry.pattern, {
|
|
1931
|
+
until: ttl > 0 ? Date.now() + ttl : 0,
|
|
1932
|
+
uses: 0,
|
|
1933
|
+
maxUses: permission.sessionApprovalMaxUses ?? 0,
|
|
1934
|
+
});
|
|
1935
|
+
approved.set(entry.sid, grants);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
if (!permission.redact) return next();
|
|
1941
|
+
const blocks = result?.content;
|
|
1942
|
+
if (!Array.isArray(blocks) || blocks.length === 0) return next();
|
|
1943
|
+
let changed = false;
|
|
1944
|
+
const content = blocks.map((block) => {
|
|
1945
|
+
if (block?.type !== 'text' || typeof block.text !== 'string') return block;
|
|
1946
|
+
const text = redactText(block.text);
|
|
1947
|
+
if (text === block.text) return block;
|
|
1948
|
+
changed = true;
|
|
1949
|
+
return { ...block, text };
|
|
1950
|
+
});
|
|
1951
|
+
if (!changed) return next();
|
|
1952
|
+
return { kind: 'accept', content };
|
|
1953
|
+
});
|
|
1954
|
+
}
|