@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/jsonc.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// JSON with comments, for hand-edited policy files.
|
|
2
|
+
//
|
|
3
|
+
// A policy is something a person writes and re-reads later, so it needs to
|
|
4
|
+
// explain itself: `// deny everything under ~/.ssh`, a blank line between
|
|
5
|
+
// groups, a trailing comma after the last rule. Strict JSON rejects all three
|
|
6
|
+
// and the error points at a line that looks fine, which is exactly why people
|
|
7
|
+
// stop editing the file.
|
|
8
|
+
//
|
|
9
|
+
// This parser strips `//` and `/* */` comments and a single trailing comma per
|
|
10
|
+
// object/array, then hands the result to `JSON.parse`. It is a character
|
|
11
|
+
// scanner, not a regex: `https://example.com` and `"a // b"` survive because a
|
|
12
|
+
// comment starts only outside a string.
|
|
13
|
+
//
|
|
14
|
+
// Accepted extras are limited to trivia. Anything else — unquoted keys, single
|
|
15
|
+
// quotes, NaN — still fails, so a typo cannot silently become a rule.
|
|
16
|
+
|
|
17
|
+
/** @param {string} text @returns {string} */
|
|
18
|
+
export function stripJsonc(text) {
|
|
19
|
+
if (typeof text !== 'string') return text;
|
|
20
|
+
let out = '';
|
|
21
|
+
let index = 0;
|
|
22
|
+
let inString = false;
|
|
23
|
+
let quote = '"';
|
|
24
|
+
let inLineComment = false;
|
|
25
|
+
let inBlockComment = false;
|
|
26
|
+
|
|
27
|
+
while (index < text.length) {
|
|
28
|
+
const ch = text[index];
|
|
29
|
+
const next = text[index + 1];
|
|
30
|
+
|
|
31
|
+
if (inLineComment) {
|
|
32
|
+
if (ch === '\n') {
|
|
33
|
+
inLineComment = false;
|
|
34
|
+
out += ch;
|
|
35
|
+
}
|
|
36
|
+
index += 1;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (inBlockComment) {
|
|
40
|
+
if (ch === '*' && next === '/') {
|
|
41
|
+
inBlockComment = false;
|
|
42
|
+
index += 2;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (ch === '\n') out += '\n';
|
|
46
|
+
index += 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (inString) {
|
|
50
|
+
out += ch;
|
|
51
|
+
if (ch === '\\') {
|
|
52
|
+
out += next ?? '';
|
|
53
|
+
index += 2;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (ch === quote) inString = false;
|
|
57
|
+
index += 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (ch === '"') {
|
|
62
|
+
inString = true;
|
|
63
|
+
quote = ch;
|
|
64
|
+
out += ch;
|
|
65
|
+
index += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (ch === '/' && next === '/') {
|
|
69
|
+
inLineComment = true;
|
|
70
|
+
index += 2;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (ch === '/' && next === '*') {
|
|
74
|
+
inBlockComment = true;
|
|
75
|
+
index += 2;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
out += ch;
|
|
79
|
+
index += 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return removeTrailingCommas(out);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Drop a comma that is followed only by whitespace and a closing bracket.
|
|
87
|
+
* Done after comment removal, so `[1, // last\n]` is already `[1,\n]`.
|
|
88
|
+
* @param {string} text
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
function removeTrailingCommas(text) {
|
|
92
|
+
let out = '';
|
|
93
|
+
let index = 0;
|
|
94
|
+
let inString = false;
|
|
95
|
+
let quote = '"';
|
|
96
|
+
|
|
97
|
+
while (index < text.length) {
|
|
98
|
+
const ch = text[index];
|
|
99
|
+
if (inString) {
|
|
100
|
+
out += ch;
|
|
101
|
+
if (ch === '\\') {
|
|
102
|
+
out += text[index + 1] ?? '';
|
|
103
|
+
index += 2;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (ch === quote) inString = false;
|
|
107
|
+
index += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (ch === '"') {
|
|
111
|
+
inString = true;
|
|
112
|
+
quote = ch;
|
|
113
|
+
out += ch;
|
|
114
|
+
index += 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (ch === ',') {
|
|
118
|
+
// Look ahead past whitespace for a closing bracket.
|
|
119
|
+
let look = index + 1;
|
|
120
|
+
while (look < text.length && /\s/u.test(text[look])) look += 1;
|
|
121
|
+
if (text[look] === '}' || text[look] === ']') {
|
|
122
|
+
index += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
out += ch;
|
|
127
|
+
index += 1;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parse a JSONC document.
|
|
134
|
+
* @param {string} text
|
|
135
|
+
* @returns {unknown}
|
|
136
|
+
* @throws {SyntaxError} with the same message JSON.parse would give.
|
|
137
|
+
*/
|
|
138
|
+
export function parseJsonc(text) {
|
|
139
|
+
return JSON.parse(stripJsonc(text));
|
|
140
|
+
}
|
package/lib/mask.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// dsh-guard/mask — agent-scope tool masking.
|
|
2
|
+
//
|
|
3
|
+
// Mount this row INSIDE an agent preset (its ctx is the agent scope). It calls
|
|
4
|
+
// ctx.tools.restrict, which is the only way to hide tools from one agent;
|
|
5
|
+
// a profile-root plugin cannot reach that scope.
|
|
6
|
+
//
|
|
7
|
+
// Example preset row:
|
|
8
|
+
// - id: guard-mask
|
|
9
|
+
// name: dsh-guard/mask
|
|
10
|
+
// config: { denyTools: [write, edit] }
|
|
11
|
+
|
|
12
|
+
export const name = 'guard-mask';
|
|
13
|
+
|
|
14
|
+
export function apply(ctx, config = {}) {
|
|
15
|
+
const deny = Array.isArray(config.denyTools) ? config.denyTools : ['write', 'edit'];
|
|
16
|
+
if (deny.length === 0) return;
|
|
17
|
+
try {
|
|
18
|
+
ctx.tools.restrict({ deny });
|
|
19
|
+
} catch (error) {
|
|
20
|
+
ctx.logger?.warn?.(`dsh-guard/mask: restrict failed: ${String(error)}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
package/lib/match.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Pattern compilation for dsh-guard rules.
|
|
2
|
+
//
|
|
3
|
+
// Two dialects share one evaluator:
|
|
4
|
+
// glob — `*` (greedy, crosses `/`), `?` (one character), everything else
|
|
5
|
+
// literal. Anchored at both ends.
|
|
6
|
+
// regex — an unanchored JavaScript regular expression, so `git\s+push`
|
|
7
|
+
// matches a substring.
|
|
8
|
+
//
|
|
9
|
+
// Glob patterns are bounded by `maxStars`: each run of `*` becomes one `.*`,
|
|
10
|
+
// and a pattern with more than that many runs is refused. `*a*a*a*a` compiles
|
|
11
|
+
// to nested greedy quantifiers that backtrack exponentially on a long
|
|
12
|
+
// non-matching input, so an untrusted rule file must not be able to install it.
|
|
13
|
+
|
|
14
|
+
const MAX_CACHE = 1024;
|
|
15
|
+
const cache = new Map();
|
|
16
|
+
|
|
17
|
+
export class PatternError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'PatternError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_MAX_STARS = 2;
|
|
25
|
+
|
|
26
|
+
/** Number of unbounded `*` runs in a glob pattern. `**` counts as one run. */
|
|
27
|
+
export function countStarRuns(pattern) {
|
|
28
|
+
let runs = 0;
|
|
29
|
+
let inRun = false;
|
|
30
|
+
for (const ch of pattern) {
|
|
31
|
+
if (ch === '*') {
|
|
32
|
+
if (!inRun) runs += 1;
|
|
33
|
+
inRun = true;
|
|
34
|
+
} else {
|
|
35
|
+
inRun = false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return runs;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Structural backtracking guard for regex mode: a quantified group that is
|
|
42
|
+
// itself quantified (`(a+)+`, `(\w*)*`) is the classic ReDoS shape. This is a
|
|
43
|
+
// heuristic, not a proof; the documented rule is "prefer glob for untrusted
|
|
44
|
+
// files".
|
|
45
|
+
const NESTED_QUANTIFIER = /\([^)]*[+*][^)]*\)[+*{]/;
|
|
46
|
+
|
|
47
|
+
function escapeRegExp(ch) {
|
|
48
|
+
return ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function compileGlob(pattern, caseInsensitive, maxStars) {
|
|
52
|
+
const runs = countStarRuns(pattern);
|
|
53
|
+
if (maxStars > 0 && runs > maxStars) {
|
|
54
|
+
throw new PatternError(`glob "${pattern}" has ${runs} unbounded '*' runs (max ${maxStars})`);
|
|
55
|
+
}
|
|
56
|
+
let out = '^';
|
|
57
|
+
for (const ch of pattern) {
|
|
58
|
+
if (ch === '*') out += '.*';
|
|
59
|
+
else if (ch === '?') out += '.';
|
|
60
|
+
else out += escapeRegExp(ch);
|
|
61
|
+
}
|
|
62
|
+
return new RegExp(out + '$', caseInsensitive ? 'iu' : 'u');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function compileRegex(pattern, caseInsensitive) {
|
|
66
|
+
if (NESTED_QUANTIFIER.test(pattern)) {
|
|
67
|
+
throw new PatternError(`regex "${pattern}" nests a quantified group (ReDoS risk)`);
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return new RegExp(pattern, caseInsensitive ? 'iu' : 'u');
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw new PatternError(`regex "${pattern}" is invalid: ${String(error?.message ?? error)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compile one pattern through the cache.
|
|
78
|
+
* @param {string} pattern
|
|
79
|
+
* @param {{mode?: 'glob'|'regex', caseInsensitive?: boolean, maxStars?: number}} [options]
|
|
80
|
+
* @returns {RegExp}
|
|
81
|
+
*/
|
|
82
|
+
export function compile(pattern, options = {}) {
|
|
83
|
+
const mode = options.mode === 'regex' ? 'regex' : 'glob';
|
|
84
|
+
const caseInsensitive = options.caseInsensitive === true;
|
|
85
|
+
const maxStars = options.maxStars ?? DEFAULT_MAX_STARS;
|
|
86
|
+
const key = `${mode}\u0000${caseInsensitive ? 1 : 0}\u0000${maxStars}\u0000${pattern}`;
|
|
87
|
+
const hit = cache.get(key);
|
|
88
|
+
if (hit !== undefined) return hit;
|
|
89
|
+
const compiled = mode === 'regex'
|
|
90
|
+
? compileRegex(pattern, caseInsensitive)
|
|
91
|
+
: compileGlob(pattern, caseInsensitive, maxStars);
|
|
92
|
+
if (cache.size >= MAX_CACHE) cache.clear();
|
|
93
|
+
cache.set(key, compiled);
|
|
94
|
+
return compiled;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Test one value. Pattern errors are *not* thrown at call time: a rule file
|
|
99
|
+
* that slipped past validation must not take the whole gate down, so an
|
|
100
|
+
* uncompilable pattern simply never matches.
|
|
101
|
+
*/
|
|
102
|
+
export function test(pattern, value, options = {}) {
|
|
103
|
+
if (typeof pattern !== 'string' || typeof value !== 'string') return false;
|
|
104
|
+
try {
|
|
105
|
+
return compile(pattern, options).test(value);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Returns a validation error message, or undefined when the pattern compiles. */
|
|
112
|
+
export function check(pattern, options = {}) {
|
|
113
|
+
try {
|
|
114
|
+
compile(pattern, options);
|
|
115
|
+
return undefined;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return error instanceof PatternError ? error.message : String(error);
|
|
118
|
+
}
|
|
119
|
+
}
|
package/lib/params.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Argument flattening and agent identity for dsh-guard's `params` and `agents`
|
|
2
|
+
// surfaces.
|
|
3
|
+
//
|
|
4
|
+
// `flattenArguments` walks a tool's argument object to a bounded depth and
|
|
5
|
+
// emits one entry per scalar value, carrying two candidate keys: the full
|
|
6
|
+
// dotted path (`arguments.command`, `steps.0.content`) and its last segment
|
|
7
|
+
// (`command`, `content`). A rule may therefore name a key at either granularity
|
|
8
|
+
// — `command` is shorthand, `arguments.command` is the exact path — and get the
|
|
9
|
+
// same value.
|
|
10
|
+
//
|
|
11
|
+
// Numbers and booleans are stringified; arrays contribute an index segment;
|
|
12
|
+
// null/undefined contribute nothing. Depth and count are capped so a hostile
|
|
13
|
+
// or huge argument object cannot turn one tool call into unbounded work.
|
|
14
|
+
|
|
15
|
+
const MAX_DEPTH = 4;
|
|
16
|
+
const MAX_ENTRIES = 256;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {{ path: string, key: string, value: string }} ArgumentEntry
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {unknown} value
|
|
24
|
+
* @returns {ArgumentEntry[]}
|
|
25
|
+
*/
|
|
26
|
+
export function flattenArguments(value) {
|
|
27
|
+
const out = [];
|
|
28
|
+
walk(value, '', 0, out);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function walk(value, prefix, depth, out) {
|
|
33
|
+
if (out.length >= MAX_ENTRIES || depth > MAX_DEPTH) return;
|
|
34
|
+
if (value === null || value === undefined) return;
|
|
35
|
+
|
|
36
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
37
|
+
const key = prefix === '' ? '' : (prefix.split('.').at(-1) ?? '');
|
|
38
|
+
out.push({ path: prefix, key, value: String(value) });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
44
|
+
const child = prefix === '' ? String(index) : `${prefix}.${index}`;
|
|
45
|
+
walk(value[index], child, depth + 1, out);
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof value === 'object') {
|
|
51
|
+
for (const [name, child] of Object.entries(value)) {
|
|
52
|
+
if (child === undefined) continue;
|
|
53
|
+
const path = prefix === '' ? name : `${prefix}.${name}`;
|
|
54
|
+
walk(child, path, depth + 1, out);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Candidate keys one entry is matched by, most specific first. */
|
|
60
|
+
export function entryKeys(entry) {
|
|
61
|
+
if (entry.path === '') return [];
|
|
62
|
+
const keys = new Set([entry.path]);
|
|
63
|
+
if (entry.key !== '' && !/^\d+$/u.test(entry.key)) keys.add(entry.key);
|
|
64
|
+
// Ancestor paths keep array members reachable by their container's name:
|
|
65
|
+
// `queries.0` is also addressable as the `queries` argument.
|
|
66
|
+
const parts = entry.path.split('.');
|
|
67
|
+
for (let length = parts.length - 1; length > 0; length -= 1) {
|
|
68
|
+
keys.add(parts.slice(0, length).join('.'));
|
|
69
|
+
}
|
|
70
|
+
return [...keys];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Stable identity strings for one agent, most specific first. The `agents`
|
|
75
|
+
* surface evaluates them in this order and the first matching rule wins, so a
|
|
76
|
+
* `preset:<name>` rule beats the broad `main`/`subagent` fallback.
|
|
77
|
+
*
|
|
78
|
+
* `subagent` follows durable session metadata (`origin: 'subagent'`), so a
|
|
79
|
+
* forked session — which also carries a `parentSession` — is not mistaken for
|
|
80
|
+
* a delegated child. `preset:<name>` names the agent preset that composed the
|
|
81
|
+
* session, when the host recorded one.
|
|
82
|
+
*
|
|
83
|
+
* @param {any} agent
|
|
84
|
+
* @returns {string[]}
|
|
85
|
+
*/
|
|
86
|
+
export function agentCandidates(agent) {
|
|
87
|
+
const meta = agent?.session?.meta;
|
|
88
|
+
const subagent = meta?.origin === 'subagent' || (typeof meta?.delegationDepth === 'number' && meta.delegationDepth > 0);
|
|
89
|
+
const preset = typeof meta?.agentPreset === 'string' && meta.agentPreset !== '' ? meta.agentPreset : undefined;
|
|
90
|
+
const candidates = [];
|
|
91
|
+
if (preset !== undefined) {
|
|
92
|
+
candidates.push(`preset:${preset}`);
|
|
93
|
+
if (subagent) candidates.push(`subagent:${preset}`);
|
|
94
|
+
}
|
|
95
|
+
candidates.push(subagent ? 'subagent' : 'main');
|
|
96
|
+
return candidates;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The agent's own reviewable label, used in audit rows. */
|
|
100
|
+
export function agentLabel(agent) {
|
|
101
|
+
const meta = agent?.session?.meta;
|
|
102
|
+
const preset = typeof meta?.agentPreset === 'string' ? meta.agentPreset : undefined;
|
|
103
|
+
const kind = meta?.origin === 'subagent' ? 'subagent' : 'main';
|
|
104
|
+
return preset === undefined ? kind : `${kind}/${preset}`;
|
|
105
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// One directory for every file this plugin owns:
|
|
2
|
+
// $DSH_HOME/dsh-plugins/dsh-guard/policy.json the live permission policy
|
|
3
|
+
// $DSH_HOME/dsh-plugins/dsh-guard/audit.jsonl decision audit trail
|
|
4
|
+
// $DSH_HOME/dsh-plugins/dsh-guard/status.json live status projection
|
|
5
|
+
//
|
|
6
|
+
// `$DSH_HOME/storages/` belongs to the storage backend and the home root belongs
|
|
7
|
+
// to the platform, so every plugin of this author keeps its data under
|
|
8
|
+
// `$DSH_HOME/dsh-plugins/<plugin>/`. `migrateLegacy` moves the files earlier
|
|
9
|
+
// versions wrote into the home root.
|
|
10
|
+
//
|
|
11
|
+
// The repository ships a `policy.json` too, but only as the seed and fallback:
|
|
12
|
+
// user edits belong in the data directory so they neither dirty the checkout
|
|
13
|
+
// nor lose to a `git pull`.
|
|
14
|
+
|
|
15
|
+
import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
export const PLUGINS_DIR = 'dsh-plugins';
|
|
20
|
+
export const APP_DIR = 'dsh-guard';
|
|
21
|
+
export const AUDIT_FILE = 'audit.jsonl';
|
|
22
|
+
export const STATUS_FILE = 'status.json';
|
|
23
|
+
export const POLICY_FILE = 'policy.json';
|
|
24
|
+
|
|
25
|
+
export function dshHome() {
|
|
26
|
+
const fromEnv = process.env.DSH_HOME;
|
|
27
|
+
return typeof fromEnv === 'string' && fromEnv !== '' ? fromEnv : join(homedir(), '.dsh');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function configDir() {
|
|
31
|
+
return join(dshHome(), PLUGINS_DIR, APP_DIR);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function auditPath() {
|
|
35
|
+
return join(configDir(), AUDIT_FILE);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function statusPath() {
|
|
39
|
+
return join(configDir(), STATUS_FILE);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The live policy: user-owned, inside the data directory. */
|
|
43
|
+
export function policyPath() {
|
|
44
|
+
return join(configDir(), POLICY_FILE);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Pre-write file snapshots, one directory per captured call. */
|
|
48
|
+
export function snapshotsDir() {
|
|
49
|
+
return join(configDir(), 'snapshots');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Where every file this plugin writes lives. Created on demand, owner-only. */
|
|
53
|
+
export function ensureConfigDir() {
|
|
54
|
+
const dir = configDir();
|
|
55
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
56
|
+
return dir;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Copy the shipped policy and its schema into the data directory once, when the
|
|
61
|
+
* user has not written a policy yet. The schema travels with it so the
|
|
62
|
+
* `"$schema": "./policy.schema.json"` line an editor reads resolves next to the
|
|
63
|
+
* file. Returns true when a seed was written.
|
|
64
|
+
*/
|
|
65
|
+
export function seedPolicy(builtinPath, log, schemaPath) {
|
|
66
|
+
const target = policyPath();
|
|
67
|
+
if (existsSync(target)) return false;
|
|
68
|
+
try {
|
|
69
|
+
const raw = readFileSync(builtinPath, 'utf8');
|
|
70
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
71
|
+
writeFileSync(target, raw, { mode: 0o600 });
|
|
72
|
+
if (typeof schemaPath === 'string' && schemaPath !== '') {
|
|
73
|
+
const schemaTarget = join(configDir(), 'policy.schema.json');
|
|
74
|
+
if (!existsSync(schemaTarget)) {
|
|
75
|
+
try {
|
|
76
|
+
writeFileSync(schemaTarget, readFileSync(schemaPath, 'utf8'), { mode: 0o600 });
|
|
77
|
+
} catch {
|
|
78
|
+
// A missing schema only costs editor completion.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
log?.(`dsh-guard: seeded ${target}`);
|
|
83
|
+
return true;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
log?.(`dsh-guard: policy seed failed: ${String(error)}`);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Earlier locations, newest first. */
|
|
91
|
+
export function legacyPaths() {
|
|
92
|
+
const home = dshHome();
|
|
93
|
+
return {
|
|
94
|
+
audit: [join(home, 'guard-audit.jsonl')],
|
|
95
|
+
status: [join(home, 'guard-status.json')],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function moveFirst(sources, to) {
|
|
100
|
+
const from = sources.find((candidate) => existsSync(candidate));
|
|
101
|
+
if (from === undefined) return null;
|
|
102
|
+
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
|
|
103
|
+
if (existsSync(to)) {
|
|
104
|
+
// The new file already exists: the old audit trail was written before the
|
|
105
|
+
// migration and its tail would otherwise be lost. Append, then drop it.
|
|
106
|
+
try {
|
|
107
|
+
appendFileSync(to, readFileSync(from));
|
|
108
|
+
rmSync(from);
|
|
109
|
+
return from;
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
copyFileSync(from, to);
|
|
115
|
+
try {
|
|
116
|
+
// Preserve the owner bits but never wider than 0600: the audit trail can
|
|
117
|
+
// quote command lines and file paths.
|
|
118
|
+
chmodSync(to, (statSync(from).mode & 0o700) | 0o600);
|
|
119
|
+
} catch {
|
|
120
|
+
// Mode tightening is best-effort; the write path sets 0600 anyway.
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
rmSync(from);
|
|
124
|
+
} catch {
|
|
125
|
+
// Leaving the old file behind is harmless: the new one wins.
|
|
126
|
+
}
|
|
127
|
+
return from;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Move earlier files into the app directory. Returns the sources that moved. */
|
|
131
|
+
export function migrateLegacy(log) {
|
|
132
|
+
const legacy = legacyPaths();
|
|
133
|
+
const moved = [];
|
|
134
|
+
const audit = moveFirst(legacy.audit, auditPath());
|
|
135
|
+
if (audit !== null) moved.push(audit);
|
|
136
|
+
const status = moveFirst(legacy.status, statusPath());
|
|
137
|
+
if (status !== null) moved.push(status);
|
|
138
|
+
if (moved.length > 0) log?.(`dsh-guard: moved ${moved.length} file(s) to ${configDir()}`);
|
|
139
|
+
return moved;
|
|
140
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Editing the live policy from a command line.
|
|
2
|
+
//
|
|
3
|
+
// The policy is a nested document: `bash: { "rm *": ask }`, `tools: { "mcp__x__*":
|
|
4
|
+
// { "*/secrets/*": deny } }`, `toolParams: { tool: { key: { value: state } } }`.
|
|
5
|
+
// A `set surface key value` grammar needs three different arities and breaks on
|
|
6
|
+
// any key containing the separator.
|
|
7
|
+
//
|
|
8
|
+
// So the command takes a JSON fragment and deep-merges it. One grammar covers
|
|
9
|
+
// every shape, keys keep their exact spelling, and `null` deletes:
|
|
10
|
+
//
|
|
11
|
+
// { "bash": { "rm *": "ask" } }
|
|
12
|
+
// { "toolParams": { "navigate_page": { "url": { "https://prod-*": "deny" } } } }
|
|
13
|
+
// { "bash": { "rm *": null } } // remove the rule
|
|
14
|
+
//
|
|
15
|
+
// The merge is applied in memory, validated, and only then written — a bad
|
|
16
|
+
// fragment leaves the file untouched.
|
|
17
|
+
|
|
18
|
+
/** Deep-merge `patch` into `base`; `null` removes a key. Arrays replace wholesale. */
|
|
19
|
+
export function deepMerge(base, patch) {
|
|
20
|
+
const out = { ...(base ?? {}) };
|
|
21
|
+
for (const [key, value] of Object.entries(patch ?? {})) {
|
|
22
|
+
if (value === null) {
|
|
23
|
+
delete out[key];
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const current = out[key];
|
|
27
|
+
if (
|
|
28
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
29
|
+
&& typeof current === 'object' && current !== null && !Array.isArray(current)
|
|
30
|
+
) {
|
|
31
|
+
out[key] = deepMerge(current, value);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
out[key] = value;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Apply one fragment to a policy body.
|
|
41
|
+
* @param {object} current the current `permission` object
|
|
42
|
+
* @param {object} patch the JSON fragment
|
|
43
|
+
* @returns {{ next: object, changed: boolean }}
|
|
44
|
+
*/
|
|
45
|
+
export function applyPatch(current, patch) {
|
|
46
|
+
if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
|
|
47
|
+
throw new Error('补丁必须是一个 JSON 对象');
|
|
48
|
+
}
|
|
49
|
+
const next = deepMerge(current, patch);
|
|
50
|
+
const changed = JSON.stringify(next) !== JSON.stringify(current);
|
|
51
|
+
return { next, changed };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Short human summary of what a merged policy now says, for the command reply. */
|
|
55
|
+
export function summarize(permission) {
|
|
56
|
+
const parts = [];
|
|
57
|
+
for (const [name, value] of Object.entries(permission ?? {})) {
|
|
58
|
+
if (typeof value === 'string') {
|
|
59
|
+
parts.push(`${name} = ${value}`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (Array.isArray(value)) {
|
|
63
|
+
parts.push(`${name}: ${value.length} 条`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (typeof value === 'object' && value !== null) {
|
|
67
|
+
const keys = Object.keys(value);
|
|
68
|
+
parts.push(`${name}: ${keys.length} 条`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
parts.push(`${name} = ${String(value)}`);
|
|
72
|
+
}
|
|
73
|
+
return parts.join(' · ');
|
|
74
|
+
}
|
package/lib/presets.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Named policy templates.
|
|
2
|
+
//
|
|
3
|
+
// The alternative to a form full of per-category dropdowns: a template is one
|
|
4
|
+
// JSON fragment, applied through the same validated merge as `/guard-policy`,
|
|
5
|
+
// so a template can never write something a hand edit could not. Applying one
|
|
6
|
+
// is additive — it merges into the current policy and leaves everything else
|
|
7
|
+
// alone, so a template narrows or widens one area without erasing the rest.
|
|
8
|
+
//
|
|
9
|
+
// `off` is the exception worth understanding: it sets `enforce: false`, which
|
|
10
|
+
// makes the gate audit-only. Turning enforcement back on is `true`, and a
|
|
11
|
+
// template is the only thing here that touches that switch.
|
|
12
|
+
|
|
13
|
+
/** @type {Record<string, {summary: string, permission: object}>} */
|
|
14
|
+
export const PRESETS = {
|
|
15
|
+
baseline: {
|
|
16
|
+
summary: '外部目录要问、破坏性命令要问、写 .env 拒绝、子代理要问',
|
|
17
|
+
permission: {
|
|
18
|
+
externalDirectory: { '*': 'ask' },
|
|
19
|
+
bash: { 'rm *': 'ask', 'git push *': 'ask', 'sudo *': 'ask' },
|
|
20
|
+
pathWrite: { '*.env': 'deny' },
|
|
21
|
+
agents: { subagent: 'ask' },
|
|
22
|
+
repeatLimit: 5,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
strict: {
|
|
26
|
+
summary: '所有命令与写入都要问、外部目录拒绝、子代理拒绝',
|
|
27
|
+
permission: {
|
|
28
|
+
bash: { '*': 'ask' },
|
|
29
|
+
pathWrite: { '*': 'ask' },
|
|
30
|
+
externalDirectory: { '*': 'deny' },
|
|
31
|
+
agents: { subagent: 'deny' },
|
|
32
|
+
repeatLimit: 3,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
off: {
|
|
36
|
+
summary: '只审计不拦截(enforce: false),循环检测关闭',
|
|
37
|
+
permission: {
|
|
38
|
+
enforce: false,
|
|
39
|
+
repeatLimit: 0,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function getPreset(name) {
|
|
45
|
+
return Object.prototype.hasOwnProperty.call(PRESETS, name) ? PRESETS[name] : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function listPresets() {
|
|
49
|
+
return Object.entries(PRESETS).map(([name, preset]) => `${name.padEnd(10)} ${preset.summary}`);
|
|
50
|
+
}
|