@goodandready/dsh-approval-gate 0.1.7 → 0.1.8

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.8
4
+
5
+ - Paths: нормализация разделителей путей `normalizeSeparators` для кроссплатформенной обработки Windows (`\`) и POSIX (`/`) слешей (Refs: #109).
6
+ - Paths: канонизация путей и превентивная защита от path traversal обходов (`..`, `.` сегменты) `canonicalizePath` и `isPathTraversal` (Refs: #94).
7
+ - Security: блокировка модификации скрытых системных файлов и каталогов конфигурации (`.git`, `.env`, `.ssh`) в путях инструментов и перенаправлениях shell (Refs: #108).
8
+ - Security: защита от утечки переменных окружения и секретов через команды `env`, `printenv`, `export` (Refs: #105).
9
+ - Bands: безусловное разрешение безопасных read-only инструментов и инспекций файловой системы через Band 1 (`isReadOnlyTool`, `isSafeInspectionCommand`) (Refs: #120).
10
+
3
11
  ## 0.1.7
4
12
 
5
13
  - Canonical: каноническая сериализация вызовов инструментов `canonicalizeCall` со стабильной рекурсивной сортировкой ключей и хэшированием `hashCall` (Refs: #124).
package/lib/bands.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // lib/bands.js
2
2
  // Two-band deterministic filtering engine for dsh-approval-gate.
3
3
  // Band 0: Hard Deny (fast regexes for exfiltration, fork-bombs, disk wipes)
4
- // Band 1: Safe Allow (prefix globs & curated safe inspection commands)
4
+ // Band 1: Safe Allow (prefix globs & curated safe read-only inspection commands and tools)
5
5
 
6
6
  export const DEFAULT_DENY_PATTERNS = Object.freeze([
7
7
  /\b(?:nc|ncat|netcat)\s+.*-e\s+/i,
@@ -16,20 +16,50 @@ export const DEFAULT_DENY_PATTERNS = Object.freeze([
16
16
  ]);
17
17
 
18
18
  export const DEFAULT_ALLOW_GLOBS = Object.freeze([
19
+ // Git inspection commands
19
20
  "git status*",
20
21
  "git diff*",
21
22
  "git log*",
22
23
  "git branch*",
23
24
  "git show*",
24
- "ls*",
25
- "pwd*",
25
+ "git remote*",
26
+ "git tag*",
27
+ "git rev-parse*",
28
+
29
+ // File viewing & analysis
26
30
  "cat *",
27
31
  "head *",
28
32
  "tail *",
33
+ "more *",
34
+ "less *",
29
35
  "wc *",
36
+ "file *",
37
+ "stat *",
38
+ "diff *",
39
+
40
+ // Search & finding
41
+ "grep *",
42
+ "fgrep *",
43
+ "egrep *",
44
+ "rg *",
45
+ "ag *",
46
+ "find *",
47
+
48
+ // Directory listing & environment info
49
+ "ls*",
50
+ "dir*",
51
+ "pwd*",
30
52
  "which *",
31
53
  "whereis *",
32
54
  "whoami*",
55
+ "hostname*",
56
+ "uname*",
57
+ "date*",
58
+ "uptime*",
59
+ "echo *",
60
+ "printf *",
61
+
62
+ // Language / runtime versions & test runners
33
63
  "node --version*",
34
64
  "node -v*",
35
65
  "npm test*",
@@ -45,21 +75,32 @@ export const DEFAULT_ALLOW_GLOBS = Object.freeze([
45
75
  "pytest*",
46
76
  "python --version*",
47
77
  "python3 --version*",
48
- "echo *",
49
78
  ]);
50
79
 
51
80
  export const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
52
81
  "read",
53
82
  "read_file",
54
83
  "readFile",
84
+ "read_file_content",
55
85
  "view_file",
86
+ "view",
87
+ "show",
56
88
  "glob",
57
89
  "grep",
58
90
  "find_files",
91
+ "find_by_name",
59
92
  "search_code",
60
93
  "file_search",
61
94
  "list_dir",
62
95
  "list_directory",
96
+ "directory_list",
97
+ "tree",
98
+ "read_url_content",
99
+ "fetch_web_page",
100
+ "web_search",
101
+ "search_web",
102
+ "list_resources",
103
+ "read_resource",
63
104
  ]);
64
105
 
65
106
  /**
@@ -79,7 +120,11 @@ export function compileGlob(pattern) {
79
120
 
80
121
  export class BandsEngine {
81
122
  /**
82
- * @param {{ denyPatterns?: Array<RegExp|string>, allowGlobs?: Array<RegExp|string>, readOnlyTools?: Array<string> }} [options]
123
+ * @param {{
124
+ * denyPatterns?: Array<RegExp|string>,
125
+ * allowGlobs?: Array<RegExp|string>,
126
+ * readOnlyTools?: Array<string>
127
+ * }} [options]
83
128
  */
84
129
  constructor(options = {}) {
85
130
  this.denyPatterns = (options.denyPatterns || DEFAULT_DENY_PATTERNS).map((p) =>
@@ -91,6 +136,52 @@ export class BandsEngine {
91
136
  this.readOnlyTools = new Set(options.readOnlyTools || DEFAULT_READ_ONLY_TOOLS);
92
137
  }
93
138
 
139
+ /**
140
+ * Check if a tool is configured as an unconditional read-only tool.
141
+ * @param {string} tool
142
+ * @returns {boolean}
143
+ */
144
+ isReadOnlyTool(tool) {
145
+ return typeof tool === "string" && this.readOnlyTools.has(tool.trim());
146
+ }
147
+
148
+ /**
149
+ * Check if a command matches safe inspection patterns.
150
+ * @param {string} command
151
+ * @returns {boolean}
152
+ */
153
+ isSafeInspectionCommand(command) {
154
+ if (typeof command !== "string" || command.trim() === "") return false;
155
+ const text = command.trim();
156
+ for (const pattern of this.denyPatterns) {
157
+ if (pattern.test(text)) return false;
158
+ }
159
+ for (const glob of this.allowGlobs) {
160
+ if (glob.test(text)) return true;
161
+ }
162
+ return false;
163
+ }
164
+
165
+ /**
166
+ * Register an additional read-only tool name.
167
+ * @param {string} tool
168
+ */
169
+ addReadOnlyTool(tool) {
170
+ if (typeof tool === "string" && tool.trim()) {
171
+ this.readOnlyTools.add(tool.trim());
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Register an additional allow-glob pattern.
177
+ * @param {string} globPattern
178
+ */
179
+ addAllowGlob(globPattern) {
180
+ if (typeof globPattern === "string" && globPattern.trim()) {
181
+ this.allowGlobs.push(compileGlob(globPattern.trim()));
182
+ }
183
+ }
184
+
94
185
  /**
95
186
  * Evaluates an execution request through Band 0 and Band 1.
96
187
  * @param {string} tool - Tool name
@@ -98,7 +189,7 @@ export class BandsEngine {
98
189
  * @returns {"deny" | "allow" | "unmatched"}
99
190
  */
100
191
  evaluate(tool, command) {
101
- if (this.readOnlyTools.has(tool)) {
192
+ if (this.isReadOnlyTool(tool)) {
102
193
  return "allow";
103
194
  }
104
195
 
@@ -123,3 +214,4 @@ export class BandsEngine {
123
214
  return "unmatched";
124
215
  }
125
216
  }
217
+
package/lib/inspect.js CHANGED
@@ -1,10 +1,54 @@
1
+
2
+ export function checkEnvExfiltration(words, fullCommand = "") {
3
+ if (!Array.isArray(words) || words.length === 0) return { deny: false };
4
+ const cmd = basename(words[0]);
5
+
6
+ // 1. Reading /proc/*/environ
7
+ if (fullCommand && /\/proc\/(?:self|\$\$|\d+)\/environ\b/i.test(fullCommand)) {
8
+ return { deny: true, reason: "env-dump-leak", detail: "Attempted read of process environ file" };
9
+ }
10
+
11
+ // 2. Bare env
12
+ if (cmd === "env") {
13
+ if (words.length === 1) {
14
+ return { deny: true, reason: "env-dump-leak", detail: "Bare env command dumps all process secrets" };
15
+ }
16
+ const nonEnvWords = words.slice(1).filter((w) => !w.startsWith("-"));
17
+ const onlyAssignments = nonEnvWords.length > 0 && nonEnvWords.every((w) => w.includes("="));
18
+ if (nonEnvWords.length === 0 || onlyAssignments) {
19
+ return { deny: true, reason: "env-dump-leak", detail: "env invocation without command dumps environment" };
20
+ }
21
+ }
22
+
23
+ // 3. Bare printenv or sensitive key read
24
+ if (cmd === "printenv") {
25
+ if (words.length === 1) {
26
+ return { deny: true, reason: "env-dump-leak", detail: "Bare printenv command dumps all process secrets" };
27
+ }
28
+ for (const w of words.slice(1)) {
29
+ if (/(?:secret|token|key|password|credential|auth|dsh_)/i.test(w)) {
30
+ return { deny: true, reason: "env-dump-leak", detail: "printenv querying sensitive variable" };
31
+ }
32
+ }
33
+ }
34
+
35
+ // 4. export without assignment
36
+ if (cmd === "export") {
37
+ if (words.length === 1 || (words.length === 2 && words[1] === "-p")) {
38
+ return { deny: true, reason: "env-dump-leak", detail: "export without assignment dumps environment" };
39
+ }
40
+ }
41
+
42
+ return { deny: false };
43
+ }
44
+
1
45
  import { DYNAMIC_MARKER, HEREDOC_OPS, REDIR_OPS, SPLIT_OPS, tokenize, readCommandSubstitution, readBacktick, readParameterExpansion } from "./tokenizer.js";
2
- import { expandHome } from "./paths.js";
46
+ import { expandHome, isProtectedConfigPath } from "./paths.js";
3
47
  import { askMessage, denyMessage, sessionTag } from "./messages.js";
4
48
 
5
49
  export { DYNAMIC_MARKER, tokenize, splitSubcommands } from "./tokenizer.js";
6
50
  export { askMessage, denyMessage, sessionTag } from "./messages.js";
7
- export { expandHome } from "./paths.js";
51
+ export { expandHome, isProtectedConfigPath } from "./paths.js";
8
52
 
9
53
  // Pure command/path inspector for dsh-approval-gate.
10
54
  // No Cordis imports: unit tests run this without the harness.
@@ -40,19 +84,7 @@ export function basename(path) {
40
84
 
41
85
  export function isProtectedPath(path, env = process.env) {
42
86
  if (typeof path !== "string" || path.trim() === "") return false;
43
- const expanded = expandHome(path, env);
44
- const normalized = expanded.replace(/\\/g, "/");
45
- if (normalized.includes("/etc/sudoers.d/")) return true;
46
- const base = basename(normalized);
47
- if (base === ".env" || base.startsWith(".env.")) return true;
48
- if (/^credentials\.ya?ml$/i.test(base)) return true;
49
- if (/^settings\.ya?ml$/i.test(base)) return true;
50
- if (base === "cordis.patch.yml") return true;
51
- if (/\.(pem|key|crt|p12|pfx)$/i.test(base)) return true;
52
- if (/^(id_rsa|id_ed25519|id_ecdsa)$/.test(base)) return true;
53
- if (/^(shadow|gshadow|shadow-|gshadow-|sudoers|authorized_keys2?)$/i.test(base)) return true;
54
- if (/(?:^|[._-])(?:secret|token|credential|passwd|api[_-]?key)(?:$|[._-])/i.test(base)) return true;
55
- return false;
87
+ return isProtectedConfigPath(path, env);
56
88
  }
57
89
 
58
90
  function wordsOf(seg) {
@@ -190,6 +222,18 @@ function inspectArgv(argv, depth, hasHeredoc) {
190
222
  const cmd = basename(argv[0]);
191
223
  const rest = argv.slice(1);
192
224
 
225
+ const envHit = checkEnvExfiltration(argv, argv.join(" "));
226
+ if (envHit.deny) {
227
+ return deny("env-dump-leak", envHit.detail || argv.join(" "));
228
+ }
229
+
230
+ if (cmd === "env") {
231
+ const sub = rest.filter(a => !a.startsWith("-") && !a.includes("="));
232
+ if (sub.length > 0) {
233
+ return inspectArgv(sub, depth, hasHeredoc);
234
+ }
235
+ }
236
+
193
237
  if (cmd === "sudo" || cmd === "command" || cmd === "builtin" || cmd === "nohup" || cmd === "time" || cmd === "nice" || cmd === "ionice" || cmd === "stdbuf" || cmd === "timeout") {
194
238
  const arity = new Set(["-u", "--user", "-g", "--group", "-p", "-s", "--signal", "-k", "--kill-after", "-o", "-e", "-i"]);
195
239
  let next = skipWrapper(rest, arity);
@@ -456,6 +500,11 @@ export function inspectBashCommand(command, depth) {
456
500
 
457
501
  const segs = splitSegments(tokens);
458
502
  for (const seg of segs) {
503
+ const rawWords = wordsOf(seg);
504
+ const envHit = checkEnvExfiltration(rawWords, command);
505
+ if (envHit.deny) {
506
+ return deny("env-dump-leak", envHit.detail || command);
507
+ }
459
508
  const wordTokens = wordTokensOf(seg);
460
509
  const heredocs = seg.filter(function (t) { return t.kind === "heredoc"; });
461
510
  const argv = stripPrefixes(wordsOf(seg));
package/lib/messages.js CHANGED
@@ -182,6 +182,7 @@ const RULE_KEYS = Object.freeze({
182
182
  "eval": "ruleEval",
183
183
  "nested-shell": "ruleNestedShell",
184
184
  "interpreter-payload": "ruleInterpreterPayload",
185
+ "env-dump-leak": "ruleEnvDumpLeak",
185
186
  });
186
187
 
187
188
  const ASK_KEYS = Object.freeze({
package/lib/paths.js CHANGED
@@ -1,5 +1,55 @@
1
1
  // lib/paths.js
2
- // Home path expansion and path boundary helpers for dsh-approval-gate.
2
+ // Cross-platform path normalization, home expansion, canonicalization, and path boundary traversal protection for dsh-approval-gate.
3
+
4
+ const PROTECTED_BASENAMES = new Set([
5
+ ".bashrc",
6
+ ".bash_profile",
7
+ ".bash_login",
8
+ ".profile",
9
+ ".zshrc",
10
+ ".zprofile",
11
+ ".zshenv",
12
+ ".cshrc",
13
+ "cordis.patch.yml",
14
+ ]);
15
+
16
+ /**
17
+ * Normalizes Windows and POSIX path separators to uniform forward slashes.
18
+ * Collapses redundant slashes and standardizes Windows drive letters.
19
+ *
20
+ * @param {string} p
21
+ * @returns {string}
22
+ */
23
+ export function normalizeSeparators(p) {
24
+ if (typeof p !== "string") return "";
25
+ let s = p.trim();
26
+ if (s === "") return "";
27
+
28
+ // Check UNC network share (e.g. \\server\share or //server/share)
29
+ const isUnc = /^([\\/]{2})([^\\/]+)/.test(s);
30
+
31
+ // Convert all backslashes to forward slashes
32
+ s = s.replace(/\\/g, "/");
33
+
34
+ // Collapse multiple consecutive slashes
35
+ if (isUnc) {
36
+ s = "//" + s.slice(2).replace(/\/+/g, "/");
37
+ } else {
38
+ s = s.replace(/\/+/g, "/");
39
+ }
40
+
41
+ // Normalize Windows drive letter: c:/ -> C:/
42
+ s = s.replace(/^([a-zA-Z]):(\/|$)/, (_, drive, rest) => `${drive.toUpperCase()}:${rest}`);
43
+
44
+ // Strip trailing slash unless root / or C:/
45
+ if (s.length > 1 && s.endsWith("/")) {
46
+ if (!/^(?:\/|[A-Za-z]:\/)$/.test(s)) {
47
+ s = s.slice(0, -1);
48
+ }
49
+ }
50
+
51
+ return s;
52
+ }
3
53
 
4
54
  /**
5
55
  * Expand `~`, `${HOME}`, `$HOME`, and `%USERPROFILE%` at the start of or within a path.
@@ -23,3 +73,198 @@ export function expandHome(p, env = process.env) {
23
73
  res = res.replace(/%USERPROFILE%/gi, cleanHome);
24
74
  return res;
25
75
  }
76
+
77
+ /**
78
+ * Canonicalizes a path resolving dot and dot-dot segments without disk I/O.
79
+ *
80
+ * @param {string} p
81
+ * @param {{ env?: Record<string, string|undefined> }} [options]
82
+ * @returns {string}
83
+ */
84
+ export function canonicalizeFsPath(p, options = {}) {
85
+ if (typeof p !== "string") return "";
86
+ let s = expandHome(p, options.env);
87
+ s = normalizeSeparators(s);
88
+ if (!s) return "";
89
+
90
+ // Check prefix: / (root), C:/ (Windows root), //server/share (UNC), or relative
91
+ let prefix = "";
92
+ if (s.startsWith("//")) {
93
+ const m = s.match(/^(\/\/[^/]+\/[^/]+)(?:\/|$)/);
94
+ if (m) {
95
+ prefix = m[1];
96
+ s = s.slice(prefix.length);
97
+ }
98
+ } else if (/^[A-Za-z]:\//.test(s)) {
99
+ prefix = s.slice(0, 3);
100
+ s = s.slice(3);
101
+ } else if (s.startsWith("/")) {
102
+ prefix = "/";
103
+ s = s.slice(1);
104
+ }
105
+
106
+ const rawSegments = s.split("/");
107
+ const stack = [];
108
+
109
+ for (const seg of rawSegments) {
110
+ if (seg === "" || seg === ".") continue;
111
+ if (seg === "..") {
112
+ if (stack.length > 0 && stack[stack.length - 1] !== "..") {
113
+ stack.pop();
114
+ } else if (!prefix) {
115
+ stack.push("..");
116
+ }
117
+ // If there is an absolute prefix (e.g. / or C:/), .. at root cannot escape above root
118
+ } else {
119
+ stack.push(seg);
120
+ }
121
+ }
122
+
123
+ const resolvedBody = stack.join("/");
124
+ if (!prefix) {
125
+ return resolvedBody || ".";
126
+ }
127
+ if (!resolvedBody) {
128
+ return prefix;
129
+ }
130
+ return prefix.endsWith("/") ? `${prefix}${resolvedBody}` : `${prefix}/${resolvedBody}`;
131
+ }
132
+
133
+ /**
134
+ * Checks whether a path contains directory traversal sequences attempting to escape baseDir.
135
+ *
136
+ * @param {string} p
137
+ * @param {string} [baseDir]
138
+ * @returns {boolean}
139
+ */
140
+ export function isPathTraversal(p, baseDir = "/workspace") {
141
+ if (typeof p !== "string" || p.trim() === "") return false;
142
+
143
+ // Detect null byte injection
144
+ if (p.includes("\0") || /%00/i.test(p)) return true;
145
+
146
+ // Detect encoded traversal sequences
147
+ if (/(?:%2e|%2f|%5c|\.\.)(?:%2e|%2f|%5c|\.\.)/i.test(p)) return true;
148
+
149
+ const normBase = canonicalizeFsPath(baseDir);
150
+ const isAbsolute = /^(?:\/|[A-Za-z]:\/|\/\/)/.test(normalizeSeparators(p));
151
+
152
+ let candidate = "";
153
+ if (isAbsolute) {
154
+ candidate = canonicalizeFsPath(p);
155
+ } else {
156
+ candidate = canonicalizeFsPath(`${normBase}/${p}`);
157
+ }
158
+
159
+ if (candidate === normBase) return false;
160
+ return !(candidate.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`));
161
+ }
162
+
163
+ /**
164
+ * Safely resolves a path relative to a base directory, verifying boundaries.
165
+ *
166
+ * @param {string} p
167
+ * @param {string} [baseDir]
168
+ * @returns {{ safe: boolean, path: string, canonical: string, reason?: string }}
169
+ */
170
+ export function resolveSafePath(p, baseDir = "/workspace") {
171
+ if (isPathTraversal(p, baseDir)) {
172
+ return {
173
+ safe: false,
174
+ reason: "path-traversal-escape",
175
+ path: p,
176
+ canonical: canonicalizeFsPath(p),
177
+ };
178
+ }
179
+ const normBase = canonicalizeFsPath(baseDir);
180
+ const isAbsolute = /^(?:\/|[A-Za-z]:\/|\/\/)/.test(normalizeSeparators(p));
181
+ const resolved = isAbsolute ? canonicalizeFsPath(p) : canonicalizeFsPath(`${normBase}/${p}`);
182
+ return {
183
+ safe: true,
184
+ path: p,
185
+ canonical: resolved,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Checks whether a path targets protected system, git, ssh, or secret configuration.
191
+ *
192
+ * @param {string} p
193
+ * @param {Record<string, string|undefined>} [env]
194
+ * @returns {boolean}
195
+ */
196
+ export function isProtectedConfigPath(p, env = process.env) {
197
+ if (typeof p !== "string" || p.trim() === "") return false;
198
+
199
+ const expanded = expandHome(p, env);
200
+ const normalized = canonicalizeFsPath(expanded);
201
+ const lower = normalized.toLowerCase();
202
+
203
+ // 1. .git internal tree
204
+ if (
205
+ lower === ".git" ||
206
+ lower.startsWith(".git/") ||
207
+ lower.endsWith("/.git") ||
208
+ lower.includes("/.git/")
209
+ ) {
210
+ return true;
211
+ }
212
+
213
+ // 2. .ssh directory and key files
214
+ if (
215
+ lower === ".ssh" ||
216
+ lower.startsWith(".ssh/") ||
217
+ lower.endsWith("/.ssh") ||
218
+ lower.includes("/.ssh/")
219
+ ) {
220
+ return true;
221
+ }
222
+
223
+ // 3. System auth and security files
224
+ if (
225
+ lower === "/etc/sudoers" ||
226
+ lower.includes("/etc/sudoers.d/") ||
227
+ lower === "/etc/shadow" ||
228
+ lower === "/etc/gshadow" ||
229
+ lower.includes("/etc/pam.d/")
230
+ ) {
231
+ return true;
232
+ }
233
+
234
+ // 4. Shell startup files
235
+ const parts = normalized.split("/");
236
+ const base = parts[parts.length - 1];
237
+ const baseLower = base.toLowerCase();
238
+
239
+ if (PROTECTED_BASENAMES.has(baseLower)) {
240
+ return true;
241
+ }
242
+
243
+ // 5. Env files (.env, .env.local, .env.prod)
244
+ if (baseLower === ".env" || baseLower.startsWith(".env.")) {
245
+ return true;
246
+ }
247
+
248
+ // 6. Credentials and settings
249
+ if (/^credentials\.ya?ml$/i.test(base) || /^settings\.ya?ml$/i.test(base)) {
250
+ return true;
251
+ }
252
+
253
+ // 7. Certificates and keys
254
+ if (/\.(pem|key|crt|p12|pfx)$/i.test(base)) {
255
+ return true;
256
+ }
257
+
258
+ // 8. SSH key filenames anywhere
259
+ if (/^(id_rsa|id_ed25519|id_ecdsa|authorized_keys2?)$/i.test(base)) {
260
+ return true;
261
+ }
262
+
263
+ // 9. Generic secrets / tokens / passwords
264
+ if (/(?:^|[._-])(?:secret|token|credential|passwd|api[_-]?key)(?:$|[._-])/i.test(baseLower)) {
265
+ return true;
266
+ }
267
+
268
+ return false;
269
+ }
270
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Host-only command safety gate for DeepSeek Harness: blocks recognized dangerous operations and requests DSH approval when a command cannot be inspected.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",