@goodandready/dsh-approval-gate 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.6
4
+
5
+ - Security: автоматическое раскрытие домашних путей `expandHome` (`~`, `$HOME`, `${HOME}`, `%USERPROFILE%`) в путях файлов, аргументах инструментов записи и перенаправлениях shell (Refs: #114).
6
+ - Security: синтаксический разбор составных цепочек shell-команд `splitSubcommands` по операторам `&&`, `||`, `;`, `|`, `&` с сохранением кавычек и подстановок (Refs: #113).
7
+ - Security: автоматический предохранитель сессии `CircuitBreaker` (блокировка зацикливания агента при 3 подряд или 20 общих отказах с принудительным запросом человека) (Refs: #110).
8
+ - Engine: двухполосная предварительная фильтрация `BandsEngine`: Band 0 (Hard Deny regex) для мгновенной блокировки деструктивных команд и Band 1 (Safe Allow glob) для нулевой задержки на рутинных командах инспекции (Refs: #112).
9
+ - Engine: 5-уровневый каскад принятия решений `SecurityEngine` (P0 Hard-Deny -> P1 Grants -> P2 Static Rules -> P3 LLM -> P4 Human Ask) с координацией предохранителя и правил (Refs: #122).
10
+
3
11
  ## 0.1.5
4
12
 
5
13
  - Security: block recursive `rm` with uppercase `-R` and `-Rf` flags in compliance with POSIX standard (Refs: #28).
package/lib/bands.js ADDED
@@ -0,0 +1,125 @@
1
+ // lib/bands.js
2
+ // Two-band deterministic filtering engine for dsh-approval-gate.
3
+ // Band 0: Hard Deny (fast regexes for exfiltration, fork-bombs, disk wipes)
4
+ // Band 1: Safe Allow (prefix globs & curated safe inspection commands)
5
+
6
+ export const DEFAULT_DENY_PATTERNS = Object.freeze([
7
+ /\b(?:nc|ncat|netcat)\s+.*-e\s+/i,
8
+ /\/dev\/tcp\/\d+\.\d+\.\d+\.\d+\/\d+/i,
9
+ /\/dev\/udp\/\d+\.\d+\.\d+\.\d+\/\d+/i,
10
+ /\bmkfs(?:\.[a-z0-9]+)?\s+/i,
11
+ /\bdd\s+.*of=\/dev\/(?:sd|hd|vd|xvd|nvme|mmcblk|loop|mapper)/i,
12
+ /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
13
+ /\bchmod\s+(?:-R\s+)?(?:0?777|a\+rwx)\s+\/(?!tmp)/i,
14
+ /\bchown\s+(?:-R\s+)?root\b/i,
15
+ /\b(?:shutdown|reboot|poweroff|halt)\s*(?:-|$)/i,
16
+ ]);
17
+
18
+ export const DEFAULT_ALLOW_GLOBS = Object.freeze([
19
+ "git status*",
20
+ "git diff*",
21
+ "git log*",
22
+ "git branch*",
23
+ "git show*",
24
+ "ls*",
25
+ "pwd*",
26
+ "cat *",
27
+ "head *",
28
+ "tail *",
29
+ "wc *",
30
+ "which *",
31
+ "whereis *",
32
+ "whoami*",
33
+ "node --version*",
34
+ "node -v*",
35
+ "npm test*",
36
+ "npm --version*",
37
+ "npm -v*",
38
+ "pnpm test*",
39
+ "pnpm --version*",
40
+ "pnpm -v*",
41
+ "yarn test*",
42
+ "cargo test*",
43
+ "cargo check*",
44
+ "cargo --version*",
45
+ "pytest*",
46
+ "python --version*",
47
+ "python3 --version*",
48
+ "echo *",
49
+ ]);
50
+
51
+ export const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
52
+ "read",
53
+ "read_file",
54
+ "readFile",
55
+ "view_file",
56
+ "glob",
57
+ "grep",
58
+ "find_files",
59
+ "search_code",
60
+ "file_search",
61
+ "list_dir",
62
+ "list_directory",
63
+ ]);
64
+
65
+ /**
66
+ * Compile a prefix-glob into an anchored RegExp.
67
+ * @param {string} pattern
68
+ * @returns {RegExp}
69
+ */
70
+ export function compileGlob(pattern) {
71
+ let source = "";
72
+ for (const ch of pattern) {
73
+ if (ch === "*") source += ".*";
74
+ else if (ch === "?") source += ".";
75
+ else source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
76
+ }
77
+ return new RegExp(`^${source}$`, "i");
78
+ }
79
+
80
+ export class BandsEngine {
81
+ /**
82
+ * @param {{ denyPatterns?: Array<RegExp|string>, allowGlobs?: Array<RegExp|string>, readOnlyTools?: Array<string> }} [options]
83
+ */
84
+ constructor(options = {}) {
85
+ this.denyPatterns = (options.denyPatterns || DEFAULT_DENY_PATTERNS).map((p) =>
86
+ typeof p === "string" ? new RegExp(p, "i") : p
87
+ );
88
+ this.allowGlobs = (options.allowGlobs || DEFAULT_ALLOW_GLOBS).map((g) =>
89
+ typeof g === "string" ? compileGlob(g) : g
90
+ );
91
+ this.readOnlyTools = new Set(options.readOnlyTools || DEFAULT_READ_ONLY_TOOLS);
92
+ }
93
+
94
+ /**
95
+ * Evaluates an execution request through Band 0 and Band 1.
96
+ * @param {string} tool - Tool name
97
+ * @param {string} [command] - Normalized command text (for bash)
98
+ * @returns {"deny" | "allow" | "unmatched"}
99
+ */
100
+ evaluate(tool, command) {
101
+ if (this.readOnlyTools.has(tool)) {
102
+ return "allow";
103
+ }
104
+
105
+ if (typeof command === "string" && command.trim().length > 0) {
106
+ const text = command.trim();
107
+
108
+ // Band 0: Hard Deny
109
+ for (const pattern of this.denyPatterns) {
110
+ if (pattern.test(text)) {
111
+ return "deny";
112
+ }
113
+ }
114
+
115
+ // Band 1: Safe Allow
116
+ for (const glob of this.allowGlobs) {
117
+ if (glob.test(text)) {
118
+ return "allow";
119
+ }
120
+ }
121
+ }
122
+
123
+ return "unmatched";
124
+ }
125
+ }
package/lib/breaker.js ADDED
@@ -0,0 +1,97 @@
1
+ // lib/breaker.js
2
+ // Session-scoped Circuit Breaker for dsh-approval-gate.
3
+ // Protects against agent thrashing / loops when blocked by security gates.
4
+
5
+ export const DEFAULT_MAX_CONSECUTIVE_DENIES = 3;
6
+ export const DEFAULT_MAX_TOTAL_DENIES = 20;
7
+
8
+ export class CircuitBreaker {
9
+ /**
10
+ * @param {{ maxConsecutive?: number, maxTotal?: number }} [options]
11
+ */
12
+ constructor(options = {}) {
13
+ this.maxConsecutive = options.maxConsecutive || DEFAULT_MAX_CONSECUTIVE_DENIES;
14
+ this.maxTotal = options.maxTotal || DEFAULT_MAX_TOTAL_DENIES;
15
+ /** @type {Map<string, { consecutive: number, total: number, tripped: boolean }>} */
16
+ this.sessions = new Map();
17
+ }
18
+
19
+ /**
20
+ * Get current state snapshot for a session.
21
+ * @param {string} [sessionId]
22
+ * @returns {{ consecutive: number, total: number, tripped: boolean }}
23
+ */
24
+ get(sessionId) {
25
+ const key = sessionId || "default";
26
+ let state = this.sessions.get(key);
27
+ if (!state) {
28
+ state = { consecutive: 0, total: 0, tripped: false };
29
+ this.sessions.set(key, state);
30
+ }
31
+ return { ...state };
32
+ }
33
+
34
+ /**
35
+ * Whether the breaker is currently tripped for this session.
36
+ * @param {string} [sessionId]
37
+ * @returns {boolean}
38
+ */
39
+ isTripped(sessionId) {
40
+ const key = sessionId || "default";
41
+ const state = this.sessions.get(key);
42
+ return Boolean(state && state.tripped);
43
+ }
44
+
45
+ /**
46
+ * Record a security denial. Returns true if this call tripped the breaker.
47
+ * @param {string} [sessionId]
48
+ * @returns {boolean}
49
+ */
50
+ countDeny(sessionId) {
51
+ const key = sessionId || "default";
52
+ let state = this.sessions.get(key);
53
+ if (!state) {
54
+ state = { consecutive: 0, total: 0, tripped: false };
55
+ this.sessions.set(key, state);
56
+ }
57
+ state.consecutive += 1;
58
+ state.total += 1;
59
+ const wasTripped = state.tripped;
60
+ if (state.consecutive >= this.maxConsecutive || state.total >= this.maxTotal) {
61
+ state.tripped = true;
62
+ return !wasTripped;
63
+ }
64
+ return false;
65
+ }
66
+
67
+ /**
68
+ * Reset the consecutive counter on an allowed operation.
69
+ * @param {string} [sessionId]
70
+ */
71
+ resetConsecutive(sessionId) {
72
+ const key = sessionId || "default";
73
+ const state = this.sessions.get(key);
74
+ if (state) state.consecutive = 0;
75
+ }
76
+
77
+ /**
78
+ * Fully resume auto-mode after human approval.
79
+ * @param {string} [sessionId]
80
+ */
81
+ resume(sessionId) {
82
+ const key = sessionId || "default";
83
+ const state = this.sessions.get(key);
84
+ if (state) {
85
+ state.consecutive = 0;
86
+ state.total = 0;
87
+ state.tripped = false;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Clear all session tracking states.
93
+ */
94
+ clear() {
95
+ this.sessions.clear();
96
+ }
97
+ }
package/lib/engine.js ADDED
@@ -0,0 +1,108 @@
1
+ // lib/engine.js
2
+ // 5-tier Security Decision Engine for dsh-approval-gate.
3
+ // Waterfall:
4
+ // P0: Hard Deny (Bands Band 0 + inspectExecution hard denials + protected files)
5
+ // P1: Session Grants (scoped temporary allowances)
6
+ // P2: Static Rules / Safe Allow (Bands Band 1 + safe read-only commands)
7
+ // P3: Optional Semantic Classifier
8
+ // P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker)
9
+
10
+ import { BandsEngine } from "./bands.js";
11
+ import { CircuitBreaker } from "./breaker.js";
12
+ import { inspectExecution } from "./inspect.js";
13
+
14
+ export class SecurityEngine {
15
+ /**
16
+ * @param {{
17
+ * bands?: BandsEngine,
18
+ * breaker?: CircuitBreaker,
19
+ * inspectConfig?: { toolName?: string, fileWriteTools?: string[] },
20
+ * classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
21
+ * grantRegistry?: { decide: (tool: string, args: any) => "allow"|"no-match" }
22
+ * }} [options]
23
+ */
24
+ constructor(options = {}) {
25
+ this.bands = options.bands || new BandsEngine(options);
26
+ this.breaker = options.breaker || new CircuitBreaker(options);
27
+ this.inspectConfig = options.inspectConfig || {};
28
+ this.classifier = options.classifier || null;
29
+ this.grantRegistry = options.grantRegistry || null;
30
+ }
31
+
32
+ /**
33
+ * Evaluates an execution request through the P0-P4 waterfall.
34
+ *
35
+ * @param {{ name: string, arguments?: any }} execution - Tool call
36
+ * @param {{ sessionId?: string, knobs?: any }} [context] - Context options
37
+ * @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string }}
38
+ */
39
+ decide(execution, context = {}) {
40
+ const sessionId = context.sessionId || "default";
41
+ const toolName = execution && execution.name;
42
+ const command = execution && execution.arguments && execution.arguments.command;
43
+
44
+ // Fast-path Band 0 Hard Deny
45
+ const bandVerdict = this.bands.evaluate(toolName, command);
46
+ if (bandVerdict === "deny") {
47
+ this.breaker.countDeny(sessionId);
48
+ return { verdict: "deny", stage: "P0", reason: "band-hard-deny", detail: command };
49
+ }
50
+
51
+ // Inspect execution for hard denials and syntax checks
52
+ const hit = inspectExecution(execution, this.inspectConfig);
53
+ if (hit.deny) {
54
+ this.breaker.countDeny(sessionId);
55
+ return { verdict: "deny", stage: "P0", reason: hit.reason || "hard-deny", detail: hit.detail || hit.snippet };
56
+ }
57
+
58
+ // P1: Session Grants
59
+ if (this.grantRegistry && typeof this.grantRegistry.decide === "function") {
60
+ const grantMatch = this.grantRegistry.decide(toolName, execution.arguments);
61
+ if (grantMatch === "allow") {
62
+ this.breaker.resetConsecutive(sessionId);
63
+ return { verdict: "allow", stage: "P1", reason: "session-grant" };
64
+ }
65
+ }
66
+
67
+ // If Circuit Breaker is tripped, suspend auto-mode and force P4 Human Ask
68
+ if (this.breaker.isTripped(sessionId)) {
69
+ return {
70
+ verdict: "ask",
71
+ stage: "P4",
72
+ reason: "circuit-breaker-tripped",
73
+ detail: "Circuit breaker tripped due to repeated security denials. Manual confirmation required."
74
+ };
75
+ }
76
+
77
+ // P2: Static Rules / Safe Allow
78
+ if (bandVerdict === "allow") {
79
+ this.breaker.resetConsecutive(sessionId);
80
+ return { verdict: "allow", stage: "P2", reason: "safe-allow" };
81
+ }
82
+
83
+ // P3: Optional Semantic Classifier
84
+ if (this.classifier && typeof this.classifier === "function") {
85
+ try {
86
+ const cls = this.classifier(execution, context);
87
+ if (cls && cls.verdict === "allow") {
88
+ this.breaker.resetConsecutive(sessionId);
89
+ return { verdict: "allow", stage: "P3", reason: cls.reason || "classifier-allow" };
90
+ }
91
+ if (cls && cls.verdict === "deny") {
92
+ this.breaker.countDeny(sessionId);
93
+ return { verdict: "deny", stage: "P3", reason: cls.reason || "classifier-deny" };
94
+ }
95
+ } catch (err) {
96
+ // Classifier error fails closed to P4 Ask
97
+ }
98
+ }
99
+
100
+ // P4: Human Ask (uncertain syntax or unlisted command)
101
+ if (hit.ask) {
102
+ return { verdict: "ask", stage: "P4", reason: hit.reason || "uncertain-syntax", detail: hit.detail || hit.snippet };
103
+ }
104
+
105
+ // Safe default for unclassified commands
106
+ return { verdict: "allow", stage: "P2", reason: "unmatched-allow" };
107
+ }
108
+ }
package/lib/index.js CHANGED
@@ -130,3 +130,9 @@ export function apply(ctx, config) {
130
130
  }
131
131
  return mount();
132
132
  }
133
+
134
+ export { SecurityEngine } from "./engine.js";
135
+ export { BandsEngine } from "./bands.js";
136
+ export { CircuitBreaker } from "./breaker.js";
137
+ export { expandHome } from "./paths.js";
138
+ export { splitSubcommands } from "./tokenizer.js";
package/lib/inspect.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { DYNAMIC_MARKER, HEREDOC_OPS, REDIR_OPS, SPLIT_OPS, tokenize, readCommandSubstitution, readBacktick, readParameterExpansion } from "./tokenizer.js";
2
+ import { expandHome } from "./paths.js";
2
3
  import { askMessage, denyMessage, sessionTag } from "./messages.js";
3
4
 
4
- export { DYNAMIC_MARKER, tokenize } from "./tokenizer.js";
5
+ export { DYNAMIC_MARKER, tokenize, splitSubcommands } from "./tokenizer.js";
5
6
  export { askMessage, denyMessage, sessionTag } from "./messages.js";
7
+ export { expandHome } from "./paths.js";
6
8
 
7
9
  // Pure command/path inspector for dsh-approval-gate.
8
10
  // No Cordis imports: unit tests run this without the harness.
@@ -36,11 +38,12 @@ export function basename(path) {
36
38
  return parts[parts.length - 1] || "";
37
39
  }
38
40
 
39
- export function isProtectedPath(path) {
41
+ export function isProtectedPath(path, env = process.env) {
40
42
  if (typeof path !== "string" || path.trim() === "") return false;
41
- const normalized = path.replace(/\\/g, "/");
43
+ const expanded = expandHome(path, env);
44
+ const normalized = expanded.replace(/\\/g, "/");
42
45
  if (normalized.includes("/etc/sudoers.d/")) return true;
43
- const base = basename(path);
46
+ const base = basename(normalized);
44
47
  if (base === ".env" || base.startsWith(".env.")) return true;
45
48
  if (/^credentials\.ya?ml$/i.test(base)) return true;
46
49
  if (/^settings\.ya?ml$/i.test(base)) return true;
package/lib/paths.js ADDED
@@ -0,0 +1,25 @@
1
+ // lib/paths.js
2
+ // Home path expansion and path boundary helpers for dsh-approval-gate.
3
+
4
+ /**
5
+ * Expand `~`, `${HOME}`, `$HOME`, and `%USERPROFILE%` at the start of or within a path.
6
+ *
7
+ * @param {string} p - Path or string containing path references
8
+ * @param {Record<string, string|undefined>} [env] - Environment dictionary (defaults to process.env)
9
+ * @returns {string} Normalized path with home expanded
10
+ */
11
+ export function expandHome(p, env = process.env) {
12
+ if (typeof p !== "string" || p.trim() === "") return p;
13
+ const home = (env && (env.HOME || env.USERPROFILE)) || "";
14
+ if (!home) return p;
15
+ const cleanHome = home.replace(/[/\\]+$/, "");
16
+
17
+ let res = p;
18
+ // Expand leading ~ or ~/ or ~\
19
+ res = res.replace(/^~(?=$|[/\\])/, cleanHome);
20
+ // Expand ${HOME} and $HOME
21
+ res = res.replace(/\$\{HOME\}|\$HOME/g, cleanHome);
22
+ // Expand %USERPROFILE% (Windows)
23
+ res = res.replace(/%USERPROFILE%/gi, cleanHome);
24
+ return res;
25
+ }
package/lib/tokenizer.js CHANGED
@@ -317,3 +317,64 @@ export function tokenize(src) {
317
317
  return { ok: true, tokens: tokens };
318
318
  }
319
319
 
320
+
321
+ /**
322
+ * Splits a complex shell command line into individual subcommands across pipeline
323
+ * and sequence operators (&&, ||, ;, |, &).
324
+ * Respects single quotes, double quotes, subshells $(...), backticks, and heredocs.
325
+ *
326
+ * @param {string} command - Raw shell command
327
+ * @returns {Array<{ command: string, argv: string[], operator: string|null, tokens: Array<any>, ok: boolean }>}
328
+ */
329
+ export function splitSubcommands(command) {
330
+ if (typeof command !== "string" || command.trim() === "") return [];
331
+ const tok = tokenize(command);
332
+ const result = [];
333
+ let currentTokens = [];
334
+ let prevOp = null;
335
+
336
+ function pushSub(tokens, op) {
337
+ if (!tokens.length) return;
338
+ const words = tokens
339
+ .filter((t) => t.kind === "word")
340
+ .map((t) => t.value);
341
+ const subStr = tokens
342
+ .map((t) => {
343
+ if (t.kind === "heredoc") return `<<${t.marker}`;
344
+ return t.value;
345
+ })
346
+ .join(" ")
347
+ .trim();
348
+ result.push({
349
+ command: subStr,
350
+ argv: words,
351
+ operator: op,
352
+ tokens: tokens,
353
+ ok: tok.ok,
354
+ });
355
+ }
356
+
357
+ for (const t of tok.tokens) {
358
+ if (t.kind === "op" && SPLIT_OPS.has(t.value)) {
359
+ pushSub(currentTokens, prevOp);
360
+ currentTokens = [];
361
+ prevOp = t.value;
362
+ continue;
363
+ }
364
+ currentTokens.push(t);
365
+ }
366
+
367
+ pushSub(currentTokens, prevOp);
368
+
369
+ if (result.length === 0 && command.trim().length > 0) {
370
+ result.push({
371
+ command: command.trim(),
372
+ argv: command.trim().split(/\s+/),
373
+ operator: null,
374
+ tokens: [],
375
+ ok: false,
376
+ });
377
+ }
378
+
379
+ return result;
380
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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",