@goodandready/dsh-approval-gate 0.1.12 → 0.1.14

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.
@@ -0,0 +1,267 @@
1
+ // lib/parsers/shell-cmds.js
2
+ // Specialized shell AST parser for redirections, subshells, pipelines, and error masking.
3
+ // Resolves: #126, #90
4
+
5
+ import { REDIR_OPS, SPLIT_OPS } from "../tokenizer.js";
6
+ import { isProtectedConfigPath, isPathTraversal, normalizeSeparators } from "../paths.js";
7
+
8
+ const SHELLS = new Set(["bash", "sh", "dash", "zsh", "ksh", "ash"]);
9
+ const INTERPRETERS = new Set(["python", "python2", "python3", "node", "nodejs", "perl", "ruby", "php"]);
10
+ const DB_CLIENTS = new Set([
11
+ "sqlite3", "mysql", "psql", "pg_restore", "mongo", "mongosh", "redis-cli", "clickhouse-client",
12
+ ]);
13
+ const DESTRUCTIVE_SQL = /\b(?:ALTER|DROP|TRUNCATE|DELETE\s+FROM|CREATE\s+(?:TABLE|DATABASE|INDEX))\b/i;
14
+ const WRAPPERS = new Set(["sudo", "command", "builtin", "nohup", "time", "nice", "ionice", "stdbuf", "timeout", "exec"]);
15
+
16
+ const SILENT_STDERR_PATTERN = /(?:2\s*>\s*\/dev\/null|&>\s*\/dev\/null|2>&1\s*>\s*\/dev\/null|>\s*\/dev\/null\s*2>&1)/i;
17
+ const SILENT_EXIT_ZERO_PATTERN = /\|\|\s*(?:true|:|exit\s+0)\b/i;
18
+ const SET_PLUS_E_PATTERN = /\bset\s+(?:\+[eE]|\+o\s+errexit)\b/i;
19
+
20
+ /**
21
+ * Checks whether a path represents a raw physical or virtual disk device.
22
+ *
23
+ * @param {string} path
24
+ * @returns {boolean}
25
+ */
26
+ export function isDevicePath(path) {
27
+ if (typeof path !== "string" || !path.startsWith("/dev/")) return false;
28
+ const device = path.slice(5);
29
+ if (["null", "zero", "full", "random", "urandom", "stdin", "stdout", "stderr", "tty"].includes(device)) {
30
+ return false;
31
+ }
32
+ if (/^(?:sd[a-z]+\d*|hd[a-z]+\d*|vd[a-z]+\d*|xvd[a-z]+\d*|nvme\d+n\d+(?:p\d+)?|mmcblk\d+(?:p\d+)?|loop\d+)$/i.test(device)) {
33
+ return true;
34
+ }
35
+ return device.startsWith("mapper/") || device.startsWith("disk/by-id/") || device.startsWith("disk/by-uuid/");
36
+ }
37
+
38
+ /**
39
+ * Extracts redirection operators and their target paths from segment tokens.
40
+ *
41
+ * @param {Array<{ kind: string, value: string, dynamic?: boolean }>} seg
42
+ * @returns {Array<{ operator: string, path: string, dynamic: boolean }>}
43
+ */
44
+ export function extractRedirects(seg) {
45
+ if (!Array.isArray(seg)) return [];
46
+ const redirects = [];
47
+ for (let i = 0; i < seg.length; i += 1) {
48
+ const t = seg[i];
49
+ if (t.kind === "op" && REDIR_OPS.has(t.value)) {
50
+ const next = seg[i + 1];
51
+ if (next && next.kind === "word") {
52
+ redirects.push({
53
+ operator: t.value,
54
+ path: next.value,
55
+ dynamic: Boolean(next.dynamic),
56
+ });
57
+ }
58
+ }
59
+ }
60
+ return redirects;
61
+ }
62
+
63
+ /**
64
+ * Evaluates security of extracted redirections (device writes, secret writes, path traversals).
65
+ *
66
+ * @param {Array<{ operator: string, path: string, dynamic: boolean }>} redirects
67
+ * @param {{ workspaceDir?: string, env?: Record<string, string> }} [options]
68
+ * @returns {{ deny?: boolean, ask?: boolean, reason?: string, detail?: string, path?: string } | null}
69
+ */
70
+ export function inspectRedirectSecurity(redirects, options = {}) {
71
+ const workspaceDir = options.workspaceDir || process.cwd();
72
+ const env = options.env || process.env;
73
+
74
+ for (const redir of redirects) {
75
+ if (redir.dynamic) {
76
+ return {
77
+ ask: true,
78
+ reason: "dynamic-redirect",
79
+ detail: "The expanded redirect target cannot be verified; review before allowing",
80
+ path: redir.path,
81
+ };
82
+ }
83
+
84
+ const norm = normalizeSeparators(redir.path);
85
+
86
+ // Block device redirection
87
+ if (isDevicePath(norm)) {
88
+ return {
89
+ deny: true,
90
+ reason: "device-write",
91
+ detail: `Redirection targets block device ${norm}`,
92
+ path: redir.path,
93
+ };
94
+ }
95
+
96
+ // Block protected secrets redirection
97
+ if (isProtectedConfigPath(norm, env)) {
98
+ return {
99
+ deny: true,
100
+ reason: "secret-write",
101
+ detail: `Redirection targets protected configuration or credentials file ${norm}`,
102
+ path: redir.path,
103
+ };
104
+ }
105
+
106
+ // Block path traversal outside workspace
107
+ const isRel = !/^(?:\/|[A-Za-z]:\/|\/\/)/.test(norm);
108
+ const isExempt = !isRel && (
109
+ /^\/dev\/(?:null|zero|stdout|stderr|fd\/\d+)$/.test(norm) ||
110
+ /^(?:\/tmp|\/var\/tmp)(?:\/|$)/i.test(norm)
111
+ );
112
+
113
+ if (!isExempt && isPathTraversal(redir.path, workspaceDir)) {
114
+ return {
115
+ deny: true,
116
+ reason: "path-traversal",
117
+ detail: `Redirection escapes workspace boundary: ${redir.path}`,
118
+ path: redir.path,
119
+ };
120
+ }
121
+ }
122
+
123
+ return null;
124
+ }
125
+
126
+ /**
127
+ * Detects error masking and suppressed failures in shell commands.
128
+ * Resolves: #90 (SilentErrorGuard).
129
+ *
130
+ * @param {string} command - Raw command line
131
+ * @param {string[]} [argv] - Optional parsed argv
132
+ * @returns {{
133
+ * masked: boolean,
134
+ * kind?: "stderr-suppressed" | "exit-zero-forced" | "errexit-disabled",
135
+ * pattern?: string,
136
+ * reason?: string,
137
+ * detail?: string
138
+ * }}
139
+ */
140
+ export function inspectSilentErrors(command, argv = []) {
141
+ if (typeof command !== "string" || !command.trim()) {
142
+ return { masked: false };
143
+ }
144
+
145
+ // 1. Check stderr suppression to /dev/null
146
+ if (SILENT_STDERR_PATTERN.test(command)) {
147
+ return {
148
+ masked: true,
149
+ kind: "stderr-suppressed",
150
+ pattern: "2>/dev/null",
151
+ reason: "silent-error-masked",
152
+ detail: "Command suppresses error output via stderr redirection to /dev/null",
153
+ };
154
+ }
155
+
156
+ // 2. Check forced success masking (|| true, || :)
157
+ if (SILENT_EXIT_ZERO_PATTERN.test(command)) {
158
+ return {
159
+ masked: true,
160
+ kind: "exit-zero-forced",
161
+ pattern: "|| true",
162
+ reason: "silent-error-masked",
163
+ detail: "Command forces a success exit code via logical OR fallback (|| true or || :)",
164
+ };
165
+ }
166
+
167
+ // 3. Check shell errexit unsetting (set +e)
168
+ if (SET_PLUS_E_PATTERN.test(command)) {
169
+ return {
170
+ masked: true,
171
+ kind: "errexit-disabled",
172
+ pattern: "set +e",
173
+ reason: "silent-error-masked",
174
+ detail: "Command disables shell error-exit checking (set +e)",
175
+ };
176
+ }
177
+
178
+ return { masked: false };
179
+ }
180
+
181
+ function unwrapExecutableFromSegment(seg) {
182
+ const words = seg.filter((t) => t.kind === "word").map((t) => t.value);
183
+ const takesValue = new Set(["-u", "--user", "-g", "--group", "-p", "-s", "--signal", "-k", "--kill-after", "-o", "-e", "-i"]);
184
+ let i = 0;
185
+ while (i < words.length) {
186
+ const val = words[i];
187
+ if (val === "then" || val === "do" || val === "{" || val === "!" || val === "}") {
188
+ i += 1;
189
+ continue;
190
+ }
191
+ if (val === "env") {
192
+ i += 1;
193
+ while (i < words.length && words[i].includes("=") && !words[i].startsWith("-")) i += 1;
194
+ continue;
195
+ }
196
+ if (val.includes("=") && !val.startsWith("-")) {
197
+ i += 1;
198
+ continue;
199
+ }
200
+ if (!WRAPPERS.has(val)) {
201
+ const parts = val.replace(/\\/g, "/").split("/");
202
+ return parts[parts.length - 1] || "";
203
+ }
204
+ i += 1;
205
+ while (i < words.length && words[i].startsWith("-")) {
206
+ const flag = words[i];
207
+ if (flag === "--") { i += 1; break; }
208
+ if (takesValue.has(flag)) i += 2;
209
+ else i += 1;
210
+ }
211
+ if (val === "timeout" && i < words.length) i += 1;
212
+ if (val === "nice" && i < words.length && /^[0-9-]+$/.test(words[i])) i += 1;
213
+ }
214
+ return "";
215
+ }
216
+
217
+ /**
218
+ * Scans tokens for dangerous downloader-to-shell or destructive SQL pipelines.
219
+ *
220
+ * @param {Array<{ kind: string, value: string }>} tokens
221
+ * @returns {{ dangerousPipeline: boolean, kind?: "curl-pipe" | "destructive-sql", reason?: string }}
222
+ */
223
+ export function inspectPipelines(tokens) {
224
+ if (!Array.isArray(tokens)) return { dangerousPipeline: false };
225
+
226
+ let segment = [];
227
+ let pipelineHasDownloader = false;
228
+ let pipelineHasDestructiveSql = false;
229
+ let inPipeline = false;
230
+
231
+ for (const token of tokens) {
232
+ if (token.kind === "op" && SPLIT_OPS.has(token.value)) {
233
+ const cmd = unwrapExecutableFromSegment(segment);
234
+ if (inPipeline && (SHELLS.has(cmd) || INTERPRETERS.has(cmd)) && pipelineHasDownloader) {
235
+ return { dangerousPipeline: true, kind: "curl-pipe", reason: "curl-pipe" };
236
+ }
237
+ if (inPipeline && DB_CLIENTS.has(cmd) && pipelineHasDestructiveSql) {
238
+ return { dangerousPipeline: true, kind: "destructive-sql", reason: "destructive-sql" };
239
+ }
240
+
241
+ const continues = token.value === "|" || token.value === "|&";
242
+ if (continues) {
243
+ pipelineHasDownloader = pipelineHasDownloader || cmd === "curl" || cmd === "wget";
244
+ const segText = segment.filter((t) => t.kind === "word").map((t) => t.value).join(" ");
245
+ pipelineHasDestructiveSql = pipelineHasDestructiveSql || DESTRUCTIVE_SQL.test(segText);
246
+ inPipeline = true;
247
+ } else {
248
+ pipelineHasDownloader = false;
249
+ pipelineHasDestructiveSql = false;
250
+ inPipeline = false;
251
+ }
252
+ segment = [];
253
+ continue;
254
+ }
255
+ segment.push(token);
256
+ }
257
+
258
+ const finalCmd = unwrapExecutableFromSegment(segment);
259
+ if (inPipeline && (SHELLS.has(finalCmd) || INTERPRETERS.has(finalCmd)) && pipelineHasDownloader) {
260
+ return { dangerousPipeline: true, kind: "curl-pipe", reason: "curl-pipe" };
261
+ }
262
+ if (inPipeline && DB_CLIENTS.has(finalCmd) && pipelineHasDestructiveSql) {
263
+ return { dangerousPipeline: true, kind: "destructive-sql", reason: "destructive-sql" };
264
+ }
265
+
266
+ return { dangerousPipeline: false };
267
+ }
@@ -0,0 +1,171 @@
1
+ // lib/retry-exhaustion.js
2
+ // Anti-replay and Retry Exhaustion guard for dsh-approval-gate (#96).
3
+ // Blocks looping/thrashing agents attempting repeated bypasses after consecutive denials.
4
+
5
+ export const DEFAULT_MAX_RETRY_ATTEMPTS = 3;
6
+
7
+ /**
8
+ * Extracts base command root from execution for grouping repeated bypass attempts.
9
+ *
10
+ * @param {any} execution
11
+ * @returns {string}
12
+ */
13
+ export function extractCommandRoot(execution) {
14
+ if (!execution) return "";
15
+ const tool = String(execution.name || execution.tool || "").trim().toLowerCase();
16
+ const args = execution.arguments || {};
17
+ if (tool === "bash" || tool === "sh" || tool === "shell") {
18
+ const cmd = typeof args.command === "string" ? args.command.trim() : "";
19
+ if (!cmd) return "bash";
20
+ const parts = cmd.split(/[\s;&|]+/);
21
+ for (const p of parts) {
22
+ const clean = p.replace(/^['"]+|['"]+$/g, "").trim();
23
+ if (!clean) continue;
24
+ if (clean === "sudo" || clean === "env" || clean === "nohup") continue;
25
+ return clean;
26
+ }
27
+ return "bash";
28
+ }
29
+ const path = args.path || args.file || args.target;
30
+ if (typeof path === "string") return `${tool}:${path}`;
31
+ return tool;
32
+ }
33
+
34
+ /**
35
+ * @typedef {Object} RetrySessionState
36
+ * @property {string} lastRoot
37
+ * @property {number} rootRetries
38
+ * @property {number} totalDenies
39
+ * @property {boolean} exhausted
40
+ * @property {number} trippedAt
41
+ * @property {string[]} recentRoots
42
+ */
43
+
44
+ export class RetryExhaustionGuard {
45
+ /**
46
+ * @param {{ maxAttempts?: number }} [options]
47
+ */
48
+ constructor(options = {}) {
49
+ this.maxAttempts = options.maxAttempts || DEFAULT_MAX_RETRY_ATTEMPTS;
50
+ /** @type {Map<string, RetrySessionState>} */
51
+ this.sessions = new Map();
52
+ }
53
+
54
+ /**
55
+ * @private
56
+ */
57
+ _getOrCreate(sessionId) {
58
+ const key = sessionId || "default";
59
+ let state = this.sessions.get(key);
60
+ if (!state) {
61
+ state = {
62
+ lastRoot: "",
63
+ rootRetries: 0,
64
+ totalDenies: 0,
65
+ exhausted: false,
66
+ trippedAt: 0,
67
+ recentRoots: [],
68
+ };
69
+ this.sessions.set(key, state);
70
+ }
71
+ return state;
72
+ }
73
+
74
+ /**
75
+ * Check if session has exhausted retries.
76
+ *
77
+ * @param {string} [sessionId]
78
+ * @returns {boolean}
79
+ */
80
+ isExhausted(sessionId) {
81
+ const key = sessionId || "default";
82
+ const state = this.sessions.get(key);
83
+ return Boolean(state && state.exhausted);
84
+ }
85
+
86
+ /**
87
+ * Get consecutive denials count for session.
88
+ *
89
+ * @param {string} [sessionId]
90
+ * @returns {number}
91
+ */
92
+ getConsecutiveDenials(sessionId) {
93
+ const key = sessionId || "default";
94
+ const state = this.sessions.get(key);
95
+ return state ? state.rootRetries : 0;
96
+ }
97
+
98
+ /**
99
+ * Record a verdict for session and evaluate exhaustion threshold.
100
+ *
101
+ * @param {string} sessionId
102
+ * @param {any} execution
103
+ * @param {"allow" | "deny" | "ask"} verdict
104
+ * @returns {boolean} True if this verdict caused exhaustion to trip
105
+ */
106
+ recordVerdict(sessionId, execution, verdict) {
107
+ const state = this._getOrCreate(sessionId);
108
+ const root = extractCommandRoot(execution);
109
+
110
+ if (verdict === "deny") {
111
+ state.totalDenies += 1;
112
+ if (state.lastRoot === root) {
113
+ state.rootRetries += 1;
114
+ } else {
115
+ state.lastRoot = root;
116
+ state.rootRetries = 1;
117
+ }
118
+
119
+ state.recentRoots.push(root);
120
+ if (state.recentRoots.length > 10) {
121
+ state.recentRoots.shift();
122
+ }
123
+
124
+ if (state.rootRetries >= this.maxAttempts && !state.exhausted) {
125
+ state.exhausted = true;
126
+ state.trippedAt = Date.now();
127
+ return true;
128
+ }
129
+ } else if (verdict === "allow") {
130
+ state.rootRetries = 0;
131
+ state.lastRoot = "";
132
+ }
133
+
134
+ return false;
135
+ }
136
+
137
+ /**
138
+ * Reset consecutive denies without clearing total denies or exhaustion.
139
+ *
140
+ * @param {string} [sessionId]
141
+ */
142
+ resetConsecutive(sessionId) {
143
+ const key = sessionId || "default";
144
+ const state = this.sessions.get(key);
145
+ if (state && state.rootRetries > 0) {
146
+ state.rootRetries = 0;
147
+ state.lastRoot = "";
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Resume / reset a session to active state.
153
+ *
154
+ * @param {string} [sessionId]
155
+ */
156
+ reset(sessionId) {
157
+ const key = sessionId || "default";
158
+ const state = this.sessions.get(key);
159
+ if (state) {
160
+ state.lastRoot = "";
161
+ state.rootRetries = 0;
162
+ state.exhausted = false;
163
+ state.trippedAt = 0;
164
+ state.recentRoots = [];
165
+ }
166
+ }
167
+
168
+ clear() {
169
+ this.sessions.clear();
170
+ }
171
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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",