@netcatty/dsh-guard 0.0.0-stage → 0.2.1

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/lib/preview.js ADDED
@@ -0,0 +1,152 @@
1
+ // Human-readable previews for an approval prompt.
2
+ //
3
+ // The gate knows what the call is about to do but a three-button dialog cannot
4
+ // show it. Before asking, this module turns the call into a short text block
5
+ // that says what the file looks like now and what it will look like after —
6
+ // enough to notice "it says it fixes a typo but deletes 40 lines".
7
+ //
8
+ // It never fails an approval: any unexpected argument shape yields no preview
9
+ // rather than an error.
10
+
11
+ const MAX_CHARS = 1600;
12
+ const MAX_LINES = 6;
13
+ const MAX_READ_BYTES = 128 * 1024;
14
+
15
+ function lines(text) {
16
+ return text.split('\n');
17
+ }
18
+
19
+ function head(list, prefix) {
20
+ const shown = list.slice(0, MAX_LINES).map((line) => `${prefix} ${line}`).join('\n');
21
+ const rest = list.length - MAX_LINES;
22
+ return rest > 0 ? `${shown}\n${prefix} …(还有 ${rest} 行)` : shown;
23
+ }
24
+
25
+ /** Keep one preview block bounded so a huge rewrite cannot flood the dialog. */
26
+ export function clamp(text) {
27
+ if (typeof text !== 'string' || text.length <= MAX_CHARS) return text;
28
+ return `${text.slice(0, MAX_CHARS)}\n…(预览截断)`;
29
+ }
30
+
31
+ /**
32
+ * Preview a `write` call: the current file against the content being written.
33
+ *
34
+ * @param {{before?: string, after?: string, path?: string}} input
35
+ * @returns {string} empty string when there is nothing useful to show
36
+ */
37
+ export function writePreview(input) {
38
+ const after = input?.after;
39
+ if (typeof after !== 'string') return '';
40
+ const path = typeof input?.path === 'string' ? input.path : '文件';
41
+ const newLines = lines(after);
42
+ const before = input?.before;
43
+
44
+ if (typeof before !== 'string') {
45
+ return clamp(`新建 ${path}:${newLines.length} 行\n${head(newLines, '+')}`);
46
+ }
47
+
48
+ const oldLines = lines(before);
49
+ const delta = newLines.length - oldLines.length;
50
+ const sign = delta >= 0 ? `+${delta}` : String(delta);
51
+ const parts = [`改写 ${path}:${oldLines.length} → ${newLines.length} 行(${sign})`];
52
+
53
+ // Identical content is worth saying out loud: a "write" that changes nothing
54
+ // is often a model mistake.
55
+ if (before === after) return `改写 ${path}:内容与当前完全相同(${newLines.length} 行)`;
56
+
57
+ // Showing the first six lines of each side says nothing about a change in the
58
+ // middle of a file, so window around the actual difference instead.
59
+ const window = changeWindow(oldLines, newLines);
60
+ if (window === undefined) {
61
+ parts.push(`(改动位于第 ${oldLines.length} 行之后)`);
62
+ return clamp(parts.join('\n'));
63
+ }
64
+ parts.push(`@@ 第 ${window.startLine} 行`);
65
+ for (const line of window.removed) parts.push(`- ${line}`);
66
+ for (const line of window.added) parts.push(`+ ${line}`);
67
+ return clamp(parts.join('\n'));
68
+ }
69
+
70
+ /**
71
+ * The span that actually differs, with a few lines of context on each side.
72
+ * Common prefix and suffix are trimmed, so a one-word edit shows the line it
73
+ * touches rather than the head of the file.
74
+ *
75
+ * @param {string[]} a
76
+ * @param {string[]} b
77
+ * @param {number} [context]
78
+ * @returns {{startLine: number, removed: string[], added: string[]} | undefined}
79
+ */
80
+ export function changeWindow(a, b, context = 3) {
81
+ let start = 0;
82
+ while (start < a.length && start < b.length && a[start] === b[start]) start += 1;
83
+ let tailA = a.length - 1;
84
+ let tailB = b.length - 1;
85
+ while (tailA >= start && tailB >= start && a[tailA] === b[tailB]) {
86
+ tailA -= 1;
87
+ tailB -= 1;
88
+ }
89
+ if (start > tailA && start > tailB) return undefined; // identical
90
+ const from = Math.max(0, start - context);
91
+ const toA = Math.min(a.length - 1, tailA + context);
92
+ const toB = Math.min(b.length - 1, tailB + context);
93
+ return {
94
+ startLine: from + 1,
95
+ removed: a.slice(from, toA + 1).slice(0, MAX_LINES * 2),
96
+ added: b.slice(from, toB + 1).slice(0, MAX_LINES * 2),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Preview an `edit` call: the exact fragment being replaced.
102
+ *
103
+ * @param {{oldString?: string, newString?: string, replaceAll?: boolean, path?: string}} input
104
+ * @returns {string}
105
+ */
106
+ export function editPreview(input) {
107
+ const oldString = input?.oldString;
108
+ const newString = input?.newString;
109
+ if (typeof oldString !== 'string' && typeof newString !== 'string') return '';
110
+ const path = typeof input?.path === 'string' ? input.path : '文件';
111
+ const scope = input?.replaceAll === true ? '全部匹配' : '一处匹配';
112
+ const parts = [`编辑 ${path}(${scope})`];
113
+
114
+ if (typeof oldString === 'string' && oldString !== '') {
115
+ parts.push(`--- 删\n${head(lines(oldString), '-')}`);
116
+ }
117
+ if (typeof newString === 'string' && newString !== '') {
118
+ parts.push(`+++ 增\n${head(lines(newString), '+')}`);
119
+ }
120
+ if (typeof oldString === 'string' && oldString === newString) {
121
+ parts.push('(新旧内容相同)');
122
+ }
123
+ return clamp(parts.join('\n'));
124
+ }
125
+
126
+ /**
127
+ * Build the preview for one tool call, or '' when the tool is not a file write.
128
+ *
129
+ * @param {{name?: string, arguments?: object}} exec
130
+ * @param {(path: string) => string} readFile bounded file read supplied by the caller
131
+ * @returns {string}
132
+ */
133
+ export function previewFor(exec, readFile) {
134
+ const args = exec?.arguments;
135
+ if (typeof args !== 'object' || args === null) return '';
136
+ const path = typeof args.file_path === 'string' ? args.file_path : (typeof args.path === 'string' ? args.path : undefined);
137
+ if (exec.name === 'write') {
138
+ const before = path === undefined ? undefined : readFile(path);
139
+ return writePreview({ before, after: args.content, path });
140
+ }
141
+ if (exec.name === 'edit') {
142
+ return editPreview({
143
+ oldString: args.old_string,
144
+ newString: args.new_string,
145
+ replaceAll: args.replace_all,
146
+ path,
147
+ });
148
+ }
149
+ return '';
150
+ }
151
+
152
+ export { MAX_READ_BYTES };
package/lib/shell.js ADDED
@@ -0,0 +1,127 @@
1
+ // Lexical shell decomposition for permission matching.
2
+ //
3
+ // Splits one command line into simple commands (pipes, `;`, `&&`, `||`, `&`,
4
+ // newlines), and each into command word, argument tokens and redirect targets.
5
+ // Quote- and escape-aware; NOT a POSIX parser — command substitution, backticks
6
+ // and brace expansion stay opaque.
7
+
8
+ /**
9
+ * @typedef {{ command: string, args: string[], redirects: string[] }} SimpleCommand
10
+ * @typedef {{ commands: SimpleCommand[] }} Decomposition
11
+ */
12
+
13
+ /**
14
+ * @param {string} text
15
+ * @returns {Decomposition}
16
+ */
17
+ export function decomposeShellCommand(text) {
18
+ const commands = [];
19
+ let words = [];
20
+ let buffer = '';
21
+ let started = false;
22
+ let redirect = false;
23
+
24
+ const flush = () => {
25
+ if (!started) return;
26
+ words.push({ text: buffer, redirect });
27
+ buffer = '';
28
+ started = false;
29
+ redirect = false;
30
+ };
31
+
32
+ const endCommand = () => {
33
+ flush();
34
+ if (words.length === 0) return;
35
+ const commandIndex = words.findIndex((w) => !w.redirect);
36
+ const command = commandIndex < 0 ? '' : words[commandIndex].text;
37
+ const args = [];
38
+ const redirects = [];
39
+ words.forEach((word, index) => {
40
+ if (index === commandIndex) return;
41
+ if (word.redirect) redirects.push(word.text);
42
+ else args.push(word.text);
43
+ });
44
+ commands.push({ command, args, redirects });
45
+ words = [];
46
+ };
47
+
48
+ const endWordBeforeRedirect = () => {
49
+ if (started && /^\d+$/.test(buffer)) {
50
+ buffer = '';
51
+ started = false;
52
+ return;
53
+ }
54
+ flush();
55
+ };
56
+
57
+ let i = 0;
58
+ while (i < text.length) {
59
+ const ch = text[i];
60
+ if (ch === '\\' && process.platform !== 'win32') {
61
+ buffer += text[i + 1] ?? '\\';
62
+ started = true;
63
+ i += 2;
64
+ continue;
65
+ }
66
+ if (ch === "'") {
67
+ started = true;
68
+ i += 1;
69
+ while (i < text.length && text[i] !== "'") buffer += text[i++];
70
+ i += 1;
71
+ continue;
72
+ }
73
+ if (ch === '"') {
74
+ started = true;
75
+ i += 1;
76
+ while (i < text.length && text[i] !== '"') {
77
+ const next = text[i + 1];
78
+ if (text[i] === '\\' && (next === '"' || next === '\\' || next === '$' || next === '`')) {
79
+ buffer += next;
80
+ i += 2;
81
+ } else {
82
+ buffer += text[i++];
83
+ }
84
+ }
85
+ i += 1;
86
+ continue;
87
+ }
88
+ if (ch === ' ' || ch === '\t' || ch === '\r') {
89
+ flush();
90
+ i += 1;
91
+ continue;
92
+ }
93
+ if (ch === '\n') {
94
+ endCommand();
95
+ i += 1;
96
+ continue;
97
+ }
98
+ if (ch === ';' || ch === '&' || ch === '|') {
99
+ const pair = text.slice(i, i + 2);
100
+ if (pair === '&&' || pair === '||') {
101
+ endCommand();
102
+ i += 2;
103
+ continue;
104
+ }
105
+ if (ch === ';' || ch === '&' || ch === '|') {
106
+ endCommand();
107
+ i += 1;
108
+ continue;
109
+ }
110
+ }
111
+ if (ch === '>' || ch === '<') {
112
+ endWordBeforeRedirect();
113
+ let op = ch;
114
+ i += 1;
115
+ while (i < text.length && (text[i] === '>' || text[i] === '&' || text[i] === '<')) {
116
+ op += text[i++];
117
+ }
118
+ redirect = true;
119
+ continue;
120
+ }
121
+ buffer += ch;
122
+ started = true;
123
+ i += 1;
124
+ }
125
+ endCommand();
126
+ return { commands };
127
+ }
@@ -0,0 +1,198 @@
1
+ // Pre-write file snapshots and restore.
2
+ //
3
+ // A `write`/`edit` call is irreversible once it lands: the previous bytes are
4
+ // gone. Before letting one through, dsh-guard copies every regular file the
5
+ // call names into
6
+ //
7
+ // $DSH_HOME/dsh-plugins/dsh-guard/snapshots/<id>/meta.json
8
+ // $DSH_HOME/dsh-plugins/dsh-guard/snapshots/<id>/0000.bin
9
+ //
10
+ // `meta.json` records, per file, whether it was `saved`, did not exist yet
11
+ // (`absent`), or was refused as too large / not a regular file (`skipped`).
12
+ // Restore rewrites the saved bytes, deletes the ones that were absent, and
13
+ // reports the skipped ones instead of pretending they came back.
14
+ //
15
+ // The store is bounded by two knobs: `snapshotMaxFiles` per call and
16
+ // `snapshotRetain` directories overall (oldest pruned first).
17
+
18
+ import { readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
19
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
20
+ import { basename, dirname, join } from 'node:path';
21
+
22
+ export const DEFAULT_MAX_FILES = 20;
23
+ export const DEFAULT_MAX_BYTES = 256 * 1024;
24
+ export const DEFAULT_RETAIN = 100;
25
+
26
+ function mintId(tool, callId) {
27
+ const stamp = new Date().toISOString().replace(/[:.]/gu, '-');
28
+ const suffix = typeof callId === 'string' && callId !== '' ? callId.replace(/[^A-Za-z0-9_-]/gu, '').slice(0, 12) : '';
29
+ const random = Math.random().toString(16).slice(2, 8);
30
+ const label = typeof tool === 'string' ? tool.replace(/[^A-Za-z0-9_-]/gu, '').slice(0, 16) : 'tool';
31
+ return `${stamp}-${label}${suffix === '' ? '' : `-${suffix}`}-${random}`;
32
+ }
33
+
34
+ /**
35
+ * Capture the current bytes of every path.
36
+ *
37
+ * @param {string} root snapshots directory
38
+ * @param {{tool?: string, callId?: string, paths: string[], maxFiles?: number, maxBytes?: number, retain?: number}} request
39
+ * @returns {Promise<{id: string, at: string, tool: string|null, callId: string|null, files: Array<{path: string, status: string, size?: number, file?: string}>, dropped: number} | undefined>}
40
+ */
41
+ export async function capture(root, request) {
42
+ const paths = (request.paths ?? []).filter((value) => typeof value === 'string' && value !== '');
43
+ if (paths.length === 0) return undefined;
44
+
45
+ const maxFiles = request.maxFiles ?? DEFAULT_MAX_FILES;
46
+ const maxBytes = request.maxBytes ?? DEFAULT_MAX_BYTES;
47
+ const id = mintId(request.tool, request.callId);
48
+ const dir = join(root, id);
49
+ const files = [];
50
+ let dropped = 0;
51
+
52
+ try {
53
+ await mkdir(dir, { recursive: true, mode: 0o700 });
54
+ for (const path of paths) {
55
+ if (files.length >= maxFiles) {
56
+ dropped += 1;
57
+ continue;
58
+ }
59
+ let stat;
60
+ try {
61
+ stat = statSync(path);
62
+ } catch {
63
+ files.push({ path, status: 'absent' });
64
+ continue;
65
+ }
66
+ if (!stat.isFile()) {
67
+ files.push({ path, status: 'skipped' });
68
+ continue;
69
+ }
70
+ if (stat.size > maxBytes) {
71
+ files.push({ path, status: 'skipped', size: stat.size });
72
+ continue;
73
+ }
74
+ const name = `${String(files.length).padStart(4, '0')}.bin`;
75
+ try {
76
+ const bytes = await readFile(path);
77
+ await writeFile(join(dir, name), bytes, { mode: 0o600 });
78
+ files.push({ path, status: 'saved', size: bytes.length, file: name });
79
+ } catch {
80
+ files.push({ path, status: 'skipped' });
81
+ }
82
+ }
83
+
84
+ const meta = {
85
+ id,
86
+ at: new Date().toISOString(),
87
+ tool: request.tool ?? null,
88
+ callId: request.callId ?? null,
89
+ files,
90
+ dropped,
91
+ };
92
+ await writeFile(join(dir, 'meta.json'), JSON.stringify(meta, null, 2), { mode: 0o600 });
93
+ prune(root, request.retain ?? DEFAULT_RETAIN);
94
+ return meta;
95
+ } catch {
96
+ // A snapshot that cannot be completed is not worth failing the call over;
97
+ // drop the half-written directory so it never looks restorable.
98
+ try {
99
+ rmSync(dir, { recursive: true, force: true });
100
+ } catch {
101
+ // Best effort.
102
+ }
103
+ return undefined;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Newest-first snapshot index.
109
+ * @param {string} root
110
+ * @returns {Array<{id: string, at: string, tool: string|null, paths: string[], saved: number, absent: number, skipped: number}>}
111
+ */
112
+ export function listSnapshots(root) {
113
+ let names;
114
+ try {
115
+ names = readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
116
+ } catch {
117
+ return [];
118
+ }
119
+ const out = [];
120
+ for (const id of names) {
121
+ try {
122
+ const meta = JSON.parse(readFileSync(join(root, id, 'meta.json'), 'utf8'));
123
+ const files = Array.isArray(meta.files) ? meta.files : [];
124
+ out.push({
125
+ id,
126
+ at: typeof meta.at === 'string' ? meta.at : '',
127
+ tool: typeof meta.tool === 'string' ? meta.tool : null,
128
+ paths: files.map((file) => file.path),
129
+ saved: files.filter((file) => file.status === 'saved').length,
130
+ absent: files.filter((file) => file.status === 'absent').length,
131
+ skipped: files.filter((file) => file.status === 'skipped').length,
132
+ });
133
+ } catch {
134
+ // Unreadable entry: it is neither listed nor restorable.
135
+ }
136
+ }
137
+ return out.sort((left, right) => (left.at < right.at ? 1 : -1));
138
+ }
139
+
140
+ /** Keep only the newest `retain` snapshot directories. */
141
+ export function prune(root, retain) {
142
+ try {
143
+ const entries = listSnapshots(root);
144
+ for (const entry of entries.slice(retain)) {
145
+ rmSync(join(root, entry.id), { recursive: true, force: true });
146
+ }
147
+ } catch {
148
+ // Pruning is housekeeping; never let it break a capture.
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Put one snapshot back.
154
+ *
155
+ * @param {string} root
156
+ * @param {string} id
157
+ * @returns {Promise<{restored: string[], removed: string[], skipped: string[]}>}
158
+ */
159
+ export async function restore(root, id) {
160
+ const dir = join(root, id);
161
+ const meta = JSON.parse(await readFile(join(dir, 'meta.json'), 'utf8'));
162
+ const result = { restored: [], removed: [], skipped: [] };
163
+ for (const file of meta.files ?? []) {
164
+ if (file.status === 'saved' && typeof file.file === 'string') {
165
+ const bytes = await readFile(join(dir, file.file));
166
+ await mkdir(dirname(file.path), { recursive: true });
167
+ await writeFile(file.path, bytes);
168
+ try {
169
+ await chmod(file.path, 0o644);
170
+ } catch {
171
+ // Mode restoration is cosmetic.
172
+ }
173
+ result.restored.push(file.path);
174
+ } else if (file.status === 'absent') {
175
+ await rm(file.path, { force: true });
176
+ result.removed.push(file.path);
177
+ } else {
178
+ result.skipped.push(file.path);
179
+ }
180
+ }
181
+ return result;
182
+ }
183
+
184
+ /** Absolute paths a write-capable call names. */
185
+ export function snapshotTargets(args) {
186
+ if (typeof args !== 'object' || args === null) return [];
187
+ const raw = args.file_path ?? args.path;
188
+ if (typeof raw !== 'string' || raw === '') return [];
189
+ return [raw];
190
+ }
191
+
192
+ /** One line for `/guard-undo list`. */
193
+ export function describeSnapshot(entry) {
194
+ const when = entry.at === '' ? entry.id : entry.at;
195
+ const names = entry.paths.map((path) => basename(path)).join(', ');
196
+ const extra = entry.skipped > 0 ? ` (${entry.skipped} skipped)` : '';
197
+ return `${entry.id} ${when} ${entry.tool ?? 'tool'} ${names}${extra}`;
198
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Permission Guard",
4
+ "description": "allow / ask / deny gates for tool calls across path, shell, params, agents and network, with an approval dialog, pre-write snapshots, audit and rollback."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "权限守卫",
4
+ "description": "给工具调用加 allow / ask / deny 权限门:路径、命令行、参数、Agent、网络五个面,支持审批弹窗、写前快照、审计与回滚。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,74 @@
1
1
  {
2
2
  "name": "@netcatty/dsh-guard",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "allow/ask/deny permission gates for DeepSeek Harness tools: path/params/agents surfaces, argv decomposition, network rules, redaction, grants, dry-run, fail-closed config",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "license": "MIT",
8
+ "files": [
9
+ "lib/index.js",
10
+ "lib/shell.js",
11
+ "lib/mask.js",
12
+ "lib/paths.js",
13
+ "lib/match.js",
14
+ "lib/params.js",
15
+ "lib/snapshots.js",
16
+ "lib/jsonc.js",
17
+ "lib/preview.js",
18
+ "cordis.patch.yml",
19
+ "policy.json",
20
+ "policy.schema.json",
21
+ "README.md",
22
+ "lib/policy-edit.js",
23
+ "lib/presets.js",
24
+ "lib/client.js",
25
+ "lib/audit-log.js",
26
+ "lib/diff-view.js",
27
+ "locale/*.json",
28
+ "CHANGELOG.md"
29
+ ],
30
+ "dsh": {
31
+ "bundle": {
32
+ "patch": "./cordis.patch.yml"
33
+ },
34
+ "client": {
35
+ "platform": "web",
36
+ "inject": [
37
+ "@deepseek-ai/dsh-client-ui-settings"
38
+ ]
39
+ }
40
+ },
41
+ "engines": {
42
+ "dsh": ">=0.2.0"
43
+ },
44
+ "exports": {
45
+ ".": {
46
+ "default": "./lib/index.js"
47
+ },
48
+ "./mask": {
49
+ "default": "./lib/mask.js"
50
+ },
51
+ "./client": {
52
+ "default": "./lib/client.js"
53
+ },
54
+ "./locale/*.json": "./locale/*.json"
55
+ },
56
+ "author": "netcatty",
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "https://github.com/netcatty/dsh-guard.git"
60
+ },
61
+ "keywords": [
62
+ "dsh-plugin",
63
+ "deepseek-harness",
64
+ "permissions",
65
+ "guard",
66
+ "security"
67
+ ],
68
+ "scripts": {
69
+ "test": "node --test test/guard.test.mjs test/features.test.mjs test/snapshots.test.mjs test/audit-log.test.mjs test/diff-view.test.mjs"
70
+ },
71
+ "publishConfig": {
72
+ "access": "public"
73
+ }
74
+ }