@goodandready/dsh-approval-gate 0.1.4 → 0.1.5

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,3 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.1.5
4
+
5
+ - Security: block recursive `rm` with uppercase `-R` and `-Rf` flags in compliance with POSIX standard (Refs: #28).
6
+ - Security: inspect `chmod` and `chown` in `inspectArgv`, blocking modifications to protected secret files and recursive changes to root/system paths (Refs: #29).
7
+ - Security: inspect `source` and `.` shell built-in commands as script executions, requiring approval for external script files and process substitutions (Refs: #30).
8
+ - Security: detect destructive SQL (`DROP`, `TRUNCATE`, `ALTER`) delivered via `heredoc` (`<< EOF`) and pipelines to database clients (Refs: #31).
9
+ - Security: block `curl` and `wget` streaming pipelines into interpreter processes like `python`, `node`, `perl`, and `ruby` (Refs: #32).
10
+ - Security: expand `isProtectedPath` to protect `/etc/shadow`, `/etc/sudoers`, `/etc/sudoers.d/`, and SSH `authorized_keys` from writes and redirects (Refs: #33).
11
+ - Bugfix: support GNU sed `--in-place=<suffix>` prefix syntax when checking modifications to protected files (Refs: #34).
12
+ - Refactor: decompose `lib/inspect.js` below the 600-line guideline by extracting bash tokenization into `lib/tokenizer.js` and message formatters into `lib/messages.js` (Refs: #35).
13
+ - Localization: add complete built-in Russian (`ru`) dictionary covering all rules, syntax reasons, and approval prompts in `lib/messages.js` (Refs: #36).
14
+
1
15
  ## 0.1.4
2
16
 
3
17
  - The gate no longer raises an ask it knows cannot be answered. It reads the effective
@@ -12,7 +26,6 @@
12
26
  the policy is not visible. An unknown policy keeps the safe direction and still asks.
13
27
  - 8 tests for the new behaviour (23 in total).
14
28
 
15
- # Changelog
16
29
 
17
30
  ## 0.1.3
18
31
 
package/README.md CHANGED
@@ -58,8 +58,9 @@ graph LR
58
58
  | Module | Responsibility |
59
59
  |---|---|
60
60
  | lib/index.js | Registers the monotonic tools.guard and pre-execution approval hook; wires the shell and file-write inspectors into DSH. |
61
- | lib/inspect.js | Tokenizes bounded Bash syntax, inspects command argv and expansions, applies destructive-command and protected-write rules, and returns pass, deny, or ask decisions. |
62
- | lib/messages.js | Provides English and Simplified Chinese rule names, explanations, and redacted-result labels. |
61
+ | lib/tokenizer.js | Tokenizes bounded Bash syntax, tracks operators/heredocs, and extracts parameter/command substitutions. |
62
+ | lib/inspect.js | Evaluates safety rules, inspects command argv and expansions, and returns pass, deny, or ask decisions. |
63
+ | lib/messages.js | Provides English, Simplified Chinese, and Russian rule names, explanations, and formatted messages. |
63
64
  | cordis.patch.yml | Declares the host-side plugin bundle and optional tool configuration. |
64
65
 
65
66
  The guard runs before the tool body. A recognized dangerous action is denied even if a prompt could otherwise be shown. Uncertain syntax or targets are sent to DSH approval; DSH policy still decides whether a prompt is available.
@@ -68,7 +69,12 @@ The guard runs before the tool body. A recognized dangerous action is denied eve
68
69
 
69
70
  | Call | Result |
70
71
  |---|---|
71
- | `rm -rf /tmp/x`, `sudo rm -r ...` | block |
72
+ | `rm -rf /tmp/x`, `rm -Rf /tmp/x`, `sudo rm -r ...` | block |
73
+ | `chmod`/`chown` on `.env` / `id_rsa`, `chmod -R 777 /` | block |
74
+ | `source script.sh`, `. script.sh`, `source <(...)` | request DSH approval |
75
+ | `mysql << EOF DROP TABLE...`, `echo DROP \| mysql` | block |
76
+ | `curl ... \| python3`, `wget ... \| node` | block |
77
+ | writing to `/etc/shadow`, `/etc/sudoers`, `authorized_keys` | block |
72
78
  | `kill` / `pkill` / `killall` as the command | block |
73
79
  | `k''ill -9 1` (quoted fragments) | block |
74
80
  | `systemctl restart\|stop\|disable ...` | block |
package/README.ru.md CHANGED
@@ -38,7 +38,7 @@
38
38
  Установка (публичный пакет npm):
39
39
 
40
40
  ```sh
41
- dsh plugin --profile web add @goodandready/dsh-approval-gate@0.1.3
41
+ dsh plugin --profile web add @goodandready/dsh-approval-gate@0.1.5
42
42
  ```
43
43
 
44
44
  ## Изменения в v0.1.3
@@ -62,8 +62,9 @@ dsh plugin --profile web add @goodandready/dsh-approval-gate@0.1.3
62
62
  | Модуль | Назначение |
63
63
  |---|---|
64
64
  | lib/index.js | Регистрирует монотонный tools.guard и штатный pre-execution approval hook DSH, подключает проверки shell и файловых записей. |
65
- | lib/inspect.js | Разбирает ограниченный shell-синтаксис, проверяет argv и подстановки, применяет правила опасных команд и защищённых записей, возвращает pass, deny или ask. |
66
- | lib/messages.js | Содержит английские и китайские названия правил, пояснения и подписи для обезличенного фрагмента. |
65
+ | lib/tokenizer.js | Разбирает ограниченный shell-синтаксис, выделяет операторы, heredoc и подстановки команд/параметров. |
66
+ | lib/inspect.js | Применяет правила опасных команд и защищённых записей, проверяет argv и подстановки, возвращает pass, deny или ask. |
67
+ | lib/messages.js | Содержит английские, китайские и русские словари правил, пояснений и форматирования сообщений. |
67
68
  | cordis.patch.yml | Объявляет host-пакет плагина и необязательную конфигурацию инструментов. |
68
69
 
69
70
  ```mermaid
@@ -80,7 +81,12 @@ graph LR
80
81
 
81
82
  | Пример вызова | Результат |
82
83
  |---|---|
83
- | rm -rf /tmp/x, sudo rm -r ... | Запрет |
84
+ | rm -rf /tmp/x, rm -Rf /tmp/x, sudo rm -r ... | Запрет |
85
+ | chmod/chown на секретах (.env, id_rsa) или chmod -R на корне | Запрет |
86
+ | source script.sh, . script.sh, source <(...) | Запрос подтверждения DSH |
87
+ | mysql << EOF DROP TABLE..., echo DROP \| mysql | Запрет |
88
+ | curl ... \| python3, wget ... \| node | Запрет |
89
+ | Запись в /etc/shadow, /etc/sudoers, authorized_keys | Запрет |
84
90
  | kill, pkill, killall | Запрет |
85
91
  | systemctl stop/restart/disable | Запрет; systemctl is-active проходит |
86
92
  | service name stop/restart | Запрет |
@@ -116,7 +122,7 @@ graph LR
116
122
 
117
123
  ### Установка и ограничения
118
124
 
119
- <pre><code>dsh plugin --profile web add @goodandready/dsh-approval-gate@0.1.3</code></pre>
125
+ <pre><code>dsh plugin --profile web add @goodandready/dsh-approval-gate@0.1.5</code></pre>
120
126
 
121
127
  Плагин не добавляет HTTP-маршруты или отдельную CLI-команду и не читает содержимое файлов скриптов. Это не системная песочница: команды cron и systemd, запущенные отдельно от инструментов DSH, он не перехватывает.
122
128
 
package/README.zh.md CHANGED
@@ -38,7 +38,7 @@
38
38
  从公开 npm registry 安装:
39
39
 
40
40
  ```sh
41
- dsh plugin --profile web add @goodandready/dsh-approval-gate@0.1.3
41
+ dsh plugin --profile web add @goodandready/dsh-approval-gate@0.1.5
42
42
  ```
43
43
 
44
44
  ## v0.1.3 变更
@@ -62,8 +62,9 @@ shell 分析器现在支持命令替换、反引号、进程替换、常见重
62
62
  | 模块 | 职责 |
63
63
  |---|---|
64
64
  | lib/index.js | 注册单调的 tools.guard 和 DSH 原生预执行审批钩子,并连接 shell 与文件写入检查器。 |
65
- | lib/inspect.js | 对有界 shell 语法进行分词,检查 argv 和展开内容,应用危险命令与受保护写入规则,并返回通过、拒绝或请求审批的结果。 |
66
- | lib/messages.js | 提供英文和简体中文规则名称、说明及脱敏提示。 |
65
+ | lib/tokenizer.js | 对有界 shell 语法进行分词,提取操作符、here-document 和命令/参数替换。 |
66
+ | lib/inspect.js | 检查 argv 和展开内容,应用危险命令与受保护写入规则,并返回通过、拒绝或请求审批的结果。 |
67
+ | lib/messages.js | 提供英文、简体中文及俄文规则名称、说明及格式化提示。 |
67
68
  | cordis.patch.yml | 声明 host 端插件包和可选工具配置。 |
68
69
 
69
70
  ```mermaid
@@ -116,7 +117,7 @@ graph LR
116
117
 
117
118
  ### 安装与限制
118
119
 
119
- <pre><code>dsh plugin --profile web add @goodandready/dsh-approval-gate@0.1.3</code></pre>
120
+ <pre><code>dsh plugin --profile web add @goodandready/dsh-approval-gate@0.1.5</code></pre>
120
121
 
121
122
  本插件不添加 HTTP 路由或独立 CLI,也不读取脚本文件内容。它不是操作系统沙箱,不会检查 cron 或 systemd 自行执行的命令。
122
123
 
package/lib/inspect.js CHANGED
@@ -1,4 +1,8 @@
1
- import { MESSAGES } from "./messages.js";
1
+ import { DYNAMIC_MARKER, HEREDOC_OPS, REDIR_OPS, SPLIT_OPS, tokenize, readCommandSubstitution, readBacktick, readParameterExpansion } from "./tokenizer.js";
2
+ import { askMessage, denyMessage, sessionTag } from "./messages.js";
3
+
4
+ export { DYNAMIC_MARKER, tokenize } from "./tokenizer.js";
5
+ export { askMessage, denyMessage, sessionTag } from "./messages.js";
2
6
 
3
7
  // Pure command/path inspector for dsh-approval-gate.
4
8
  // No Cordis imports: unit tests run this without the harness.
@@ -13,10 +17,6 @@ export const DEFAULT_FILE_WRITE_TOOLS = Object.freeze([
13
17
  "apply_patch",
14
18
  ]);
15
19
 
16
- const SPLIT_OPS = new Set(["|", "|&", "||", "&&", ";", "&"]);
17
- const REDIR_OPS = new Set([">", ">>", ">|", "<>", "2>", "2>>", "&>", "&>>"]);
18
- const HEREDOC_OPS = new Set(["<<", "<<-"]);
19
- const DYNAMIC_MARKER = "__shell_dynamic__";
20
20
  const PREFIXES = new Set(["then", "do"]);
21
21
  const SHELLS = new Set(["bash", "sh", "dash", "zsh", "ksh", "ash"]);
22
22
  const INTERPRETERS = new Set(["python", "python2", "python3", "node", "nodejs", "perl", "ruby", "php"]);
@@ -38,6 +38,8 @@ export function basename(path) {
38
38
 
39
39
  export function isProtectedPath(path) {
40
40
  if (typeof path !== "string" || path.trim() === "") return false;
41
+ const normalized = path.replace(/\\/g, "/");
42
+ if (normalized.includes("/etc/sudoers.d/")) return true;
41
43
  const base = basename(path);
42
44
  if (base === ".env" || base.startsWith(".env.")) return true;
43
45
  if (/^credentials\.ya?ml$/i.test(base)) return true;
@@ -45,326 +47,11 @@ export function isProtectedPath(path) {
45
47
  if (base === "cordis.patch.yml") return true;
46
48
  if (/\.(pem|key|crt|p12|pfx)$/i.test(base)) return true;
47
49
  if (/^(id_rsa|id_ed25519|id_ecdsa)$/.test(base)) return true;
50
+ if (/^(shadow|gshadow|shadow-|gshadow-|sudoers|authorized_keys2?)$/i.test(base)) return true;
48
51
  if (/(?:^|[._-])(?:secret|token|credential|passwd|api[_-]?key)(?:$|[._-])/i.test(base)) return true;
49
52
  return false;
50
53
  }
51
54
 
52
- function fail(reason, snippet) {
53
- return { ok: false, reason: reason, snippet: snippet };
54
- }
55
-
56
- function opAt(s, i) {
57
- const rest = s.slice(i);
58
- if (rest.startsWith("<(") || rest.startsWith(">(")) return null;
59
- const candidates = ["2>&", "2>>", "&>>", "<<<", "<<-", "2>", ">>", ">|", "|&", "&&", "||", "&>", ">&", "<&", "<>", "<<", ">", "<", "|", ";", "&"];
60
- for (const op of candidates) {
61
- if (rest.startsWith(op)) return op;
62
- }
63
- return null;
64
- }
65
-
66
- function readCommandSubstitution(src, start) {
67
- let depth = 1;
68
- let quote = "";
69
- let escaped = false;
70
- for (let i = start + 2; i < src.length; i += 1) {
71
- const c = src[i];
72
- if (escaped) { escaped = false; continue; }
73
- if (c === "\\") { escaped = true; continue; }
74
- if (quote) {
75
- if (c === quote) quote = "";
76
- continue;
77
- }
78
- if (c === "'" || c === '"') { quote = c; continue; }
79
- if (c === String.fromCharCode(96)) {
80
- const end = readBacktick(src, i);
81
- if (!end) return null;
82
- i = end.end;
83
- continue;
84
- }
85
- if (src.startsWith("$(", i)) { depth += 1; i += 1; continue; }
86
- if (c === "(") { depth += 1; continue; }
87
- if (c === ")") {
88
- depth -= 1;
89
- if (depth === 0) return { script: src.slice(start + 2, i), end: i + 1 };
90
- }
91
- }
92
- return null;
93
- }
94
-
95
- function readBacktick(src, start) {
96
- let escaped = false;
97
- for (let i = start + 1; i < src.length; i += 1) {
98
- if (escaped) { escaped = false; continue; }
99
- if (src[i] === "\\") { escaped = true; continue; }
100
- if (src[i] === String.fromCharCode(96)) return { script: src.slice(start + 1, i), end: i + 1 };
101
- }
102
- return null;
103
- }
104
-
105
- function readParameterExpansion(src, start) {
106
- if (src[start + 1] !== "{") {
107
- let i = start + 1;
108
- if (/[A-Za-z_]/.test(src[i] || "")) {
109
- i += 1;
110
- while (i < src.length && /[A-Za-z0-9_]/.test(src[i])) i += 1;
111
- } else if (i < src.length) i += 1;
112
- return { end: i };
113
- }
114
- let depth = 1;
115
- let quote = "";
116
- for (let i = start + 2; i < src.length; i += 1) {
117
- const c = src[i];
118
- if (c === "\\") { i += 1; continue; }
119
- if (quote) {
120
- if (c === quote) quote = "";
121
- continue;
122
- }
123
- if (c === "'" || c === '"') { quote = c; continue; }
124
- if (c === String.fromCharCode(96)) {
125
- const nested = readBacktick(src, i);
126
- if (!nested) return null;
127
- i = nested.end - 1;
128
- continue;
129
- }
130
- if (src.startsWith("$" + "(", i)) {
131
- const nested = readCommandSubstitution(src, i);
132
- if (!nested) return null;
133
- i = nested.end - 1;
134
- continue;
135
- }
136
- if (src.startsWith("$" + "{", i)) {
137
- const nested = readParameterExpansion(src, i);
138
- if (!nested) return null;
139
- i = nested.end - 1;
140
- continue;
141
- }
142
- if (c === "{") depth += 1;
143
- if (c === "}") {
144
- depth -= 1;
145
- if (depth === 0) return { end: i + 1, body: src.slice(start + 2, i) };
146
- }
147
- }
148
- return null;
149
- }
150
-
151
- function lexFail(src, index, reason, tokens) {
152
- return { ok: false, reason: reason, snippet: src.slice(Math.max(0, index - 32), index + 96), tokens: tokens };
153
- }
154
-
155
- function readHeredocDelimiter(src, index) {
156
- let i = index;
157
- while (i < src.length && (src[i] === " " || src[i] === "\t")) i += 1;
158
- if (i >= src.length || src[i] === "\n" || src[i] === "\r") return null;
159
- let delimiter = "";
160
- let quote = "";
161
- let started = false;
162
- let quoted = false;
163
- while (i < src.length && !/\s/.test(src[i]) && !opAt(src, i)) {
164
- const c = src[i];
165
- if (c === "'" || c === '"') {
166
- if (!quote) { quote = c; quoted = true; started = true; i += 1; continue; }
167
- if (quote === c) { quote = ""; i += 1; continue; }
168
- }
169
- if (c === "\\") {
170
- if (i + 1 >= src.length) return null;
171
- quoted = true;
172
- delimiter += src[i + 1];
173
- started = true;
174
- i += 2;
175
- continue;
176
- }
177
- delimiter += c;
178
- started = true;
179
- i += 1;
180
- }
181
- if (quote || !started || !delimiter) return null;
182
- return { delimiter: delimiter, end: i, quoted: quoted };
183
- }
184
-
185
- export function tokenize(src) {
186
- if (typeof src !== "string") return { ok: false, reason: "not-a-string", snippet: "", tokens: [] };
187
- const tokens = [];
188
- const pendingHeredocs = [];
189
- let i = 0;
190
- const n = src.length;
191
-
192
- function flushWord(value, started, dynamic, substitutions) {
193
- if (started) tokens.push({ kind: "word", value: value, dynamic: dynamic, substitutions: substitutions });
194
- }
195
-
196
- while (i < n) {
197
- if (src.startsWith("\\\n", i)) { i += 2; continue; }
198
- if (src[i] === "\r" || src[i] === "\n") {
199
- if (src[i] === "\r" && src[i + 1] === "\n") i += 2;
200
- else i += 1;
201
- if (pendingHeredocs.length) {
202
- for (const heredoc of pendingHeredocs) {
203
- let body = "";
204
- let found = false;
205
- while (i <= n) {
206
- const lineEnd = src.indexOf("\n", i);
207
- const hasNewline = lineEnd !== -1;
208
- let line = src.slice(i, hasNewline ? lineEnd : n);
209
- if (line.endsWith("\r")) line = line.slice(0, -1);
210
- const compare = heredoc.stripTabs ? line.replace(/^\t+/, "") : line;
211
- if (compare === heredoc.delimiter) {
212
- i = hasNewline ? lineEnd + 1 : n;
213
- found = true;
214
- break;
215
- }
216
- body += (heredoc.stripTabs ? line.replace(/^\t+/, "") : line) + (hasNewline ? "\n" : "");
217
- if (!hasNewline) { i = n; break; }
218
- i = lineEnd + 1;
219
- }
220
- if (!found) return lexFail(src, i, "unterminated-heredoc", tokens);
221
- tokens.push({ kind: "heredoc", body: body, quoted: heredoc.quoted });
222
- }
223
- pendingHeredocs.length = 0;
224
- tokens.push({ kind: "op", value: ";" });
225
- } else {
226
- tokens.push({ kind: "op", value: ";" });
227
- }
228
- continue;
229
- }
230
- if (/\s/.test(src[i])) { i += 1; continue; }
231
- if (src[i] === "#" && (tokens.length === 0 || tokens[tokens.length - 1].kind === "op")) {
232
- while (i < n && src[i] !== "\n") i += 1;
233
- continue;
234
- }
235
-
236
- const op = opAt(src, i);
237
- if (op) {
238
- tokens.push({ kind: "op", value: op });
239
- i += op.length;
240
- if (HEREDOC_OPS.has(op)) {
241
- const parsed = readHeredocDelimiter(src, i);
242
- if (!parsed) return lexFail(src, i, "invalid-heredoc-delimiter", tokens);
243
- tokens.push({ kind: "heredoc-delimiter", value: parsed.delimiter });
244
- pendingHeredocs.push({ delimiter: parsed.delimiter, stripTabs: op === "<<-", quoted: parsed.quoted });
245
- i = parsed.end;
246
- }
247
- continue;
248
- }
249
-
250
- let word = "";
251
- let started = false;
252
- let dynamic = false;
253
- const substitutions = [];
254
- while (i < n && !/\s/.test(src[i]) && !opAt(src, i)) {
255
- const c = src[i];
256
- if (src.startsWith("<(", i) || src.startsWith(">(", i)) {
257
- const sub = readCommandSubstitution(src, i);
258
- if (!sub) return lexFail(src, i, "unclosed-process-substitution", tokens);
259
- substitutions.push({ kind: "process-substitution", script: sub.script });
260
- word += DYNAMIC_MARKER;
261
- dynamic = true;
262
- started = true;
263
- i = sub.end;
264
- continue;
265
- }
266
- if (c === "(" || c === ")") return lexFail(src, i, "unsupported-shell-grouping", tokens);
267
- if (c === "\\") {
268
- if (i + 1 >= n) return lexFail(src, i, "dangling-backslash", tokens);
269
- word += src[i + 1];
270
- started = true;
271
- i += 2;
272
- continue;
273
- }
274
- if (c === "'") {
275
- const j = src.indexOf("'", i + 1);
276
- if (j < 0) return lexFail(src, i, "unclosed-single-quote", tokens);
277
- word += src.slice(i + 1, j);
278
- started = true;
279
- i = j + 1;
280
- continue;
281
- }
282
- if (c === '"') {
283
- started = true;
284
- i += 1;
285
- let closed = false;
286
- while (i < n) {
287
- if (src[i] === '"') { i += 1; closed = true; break; }
288
- if (src[i] === "\\") {
289
- if (i + 1 >= n) return lexFail(src, i, "dangling-backslash", tokens);
290
- word += src[i + 1];
291
- i += 2;
292
- continue;
293
- }
294
- if (src.startsWith("$(", i)) {
295
- const sub = readCommandSubstitution(src, i);
296
- if (!sub) return lexFail(src, i, "unclosed-command-substitution", tokens);
297
- substitutions.push({ kind: "command-substitution", script: sub.script });
298
- word += DYNAMIC_MARKER;
299
- dynamic = true;
300
- i = sub.end;
301
- continue;
302
- }
303
- if (src[i] === String.fromCharCode(96)) {
304
- const sub = readBacktick(src, i);
305
- if (!sub) return lexFail(src, i, "unclosed-backtick-substitution", tokens);
306
- substitutions.push({ kind: "backtick-substitution", script: sub.script });
307
- word += DYNAMIC_MARKER;
308
- dynamic = true;
309
- i = sub.end;
310
- continue;
311
- }
312
- if (src[i] === "$") {
313
- const expansion = readParameterExpansion(src, i);
314
- if (!expansion) return lexFail(src, i, "unclosed-parameter-expansion", tokens);
315
- word += DYNAMIC_MARKER;
316
- dynamic = true;
317
- if (expansion.body !== undefined) substitutions.push({ kind: "parameter-expansion", script: expansion.body });
318
- i = expansion.end;
319
- continue;
320
- }
321
- word += src[i];
322
- i += 1;
323
- }
324
- if (!closed) return lexFail(src, i, "unclosed-double-quote", tokens);
325
- continue;
326
- }
327
- if (src.startsWith("$(", i)) {
328
- const sub = readCommandSubstitution(src, i);
329
- if (!sub) return lexFail(src, i, "unclosed-command-substitution", tokens);
330
- substitutions.push({ kind: "command-substitution", script: sub.script });
331
- word += DYNAMIC_MARKER;
332
- dynamic = true;
333
- started = true;
334
- i = sub.end;
335
- continue;
336
- }
337
- if (c === String.fromCharCode(96)) {
338
- const sub = readBacktick(src, i);
339
- if (!sub) return lexFail(src, i, "unclosed-backtick-substitution", tokens);
340
- substitutions.push({ kind: "backtick-substitution", script: sub.script });
341
- word += DYNAMIC_MARKER;
342
- dynamic = true;
343
- started = true;
344
- i = sub.end;
345
- continue;
346
- }
347
- if (c === "$") {
348
- const expansion = readParameterExpansion(src, i);
349
- if (!expansion) return lexFail(src, i, "unclosed-parameter-expansion", tokens);
350
- word += DYNAMIC_MARKER;
351
- dynamic = true;
352
- started = true;
353
- if (expansion.body !== undefined) substitutions.push({ kind: "parameter-expansion", script: expansion.body });
354
- i = expansion.end;
355
- continue;
356
- }
357
- if (c === "*" || c === "?") dynamic = true;
358
- word += c;
359
- started = true;
360
- i += 1;
361
- }
362
- flushWord(word, started, dynamic, substitutions);
363
- }
364
- if (pendingHeredocs.length) return lexFail(src, n, "unterminated-heredoc", tokens);
365
- return { ok: true, tokens: tokens };
366
- }
367
-
368
55
  function wordsOf(seg) {
369
56
  return seg.filter(function (t) { return t.kind === "word"; }).map(function (t) { return t.value; });
370
57
  }
@@ -510,7 +197,7 @@ function inspectArgv(argv, depth, hasHeredoc) {
510
197
 
511
198
  if (cmd === "eval") return deny("eval", argv.join(" "));
512
199
  if (KILL_CMDS.has(cmd)) return deny("process-signal", argv.join(" "));
513
- if (cmd === "rm" && (hasLetterFlag(argv, "r") || rest.indexOf("--recursive") !== -1)) {
200
+ if (cmd === "rm" && (hasLetterFlag(argv, "r") || hasLetterFlag(argv, "R") || rest.indexOf("--recursive") !== -1)) {
514
201
  return deny("recursive-rm", argv.join(" "));
515
202
  }
516
203
  if (cmd === "systemctl") {
@@ -543,17 +230,24 @@ function inspectArgv(argv, depth, hasHeredoc) {
543
230
  if (a.startsWith("of=") && (isDevicePath(a.slice(3)) || isProtectedPath(a.slice(3)))) return deny("device-write", a);
544
231
  }
545
232
  }
546
- if (cmd === "tee" || cmd === "install" || cmd === "cp" || cmd === "mv" || cmd === "truncate") {
233
+ if (cmd === "tee" || cmd === "install" || cmd === "cp" || cmd === "mv" || cmd === "truncate" || cmd === "chmod" || cmd === "chown") {
547
234
  for (const a of rest) {
548
235
  if (a[0] !== "-" && isProtectedPath(a)) return deny("secret-write", argv.join(" "));
549
236
  }
237
+ if ((cmd === "chmod" || cmd === "chown") && (hasLetterFlag(argv, "R") || hasLetterFlag(argv, "r") || rest.indexOf("--recursive") !== -1)) {
238
+ for (const a of rest) {
239
+ if (a === "/" || a === "/etc" || a === "/var" || a === "/usr" || a === "/root" || a === "/boot") {
240
+ return deny("device-write", argv.join(" "));
241
+ }
242
+ }
243
+ }
550
244
  }
551
- if (cmd === "sed" && (hasLetterFlag(argv, "i") || rest.indexOf("--in-place") !== -1)) {
245
+ if (cmd === "sed" && (hasLetterFlag(argv, "i") || rest.some(function (a) { return a === "--in-place" || a.startsWith("--in-place="); }))) {
552
246
  for (const a of rest) {
553
247
  if (a[0] !== "-" && isProtectedPath(a)) return deny("secret-write", argv.join(" "));
554
248
  }
555
249
  }
556
- if (SHELLS.has(cmd)) {
250
+ if (SHELLS.has(cmd) || cmd === "source" || cmd === ".") {
557
251
  const script = scriptAfterDashC(rest);
558
252
  if (typeof script === "string") {
559
253
  if (depth > 4) return deny("nested-shell", script);
@@ -583,7 +277,7 @@ function hasDangerousPipeline(tokens) {
583
277
  for (const token of tokens) {
584
278
  if (token.kind === "op" && SPLIT_OPS.has(token.value)) {
585
279
  const cmd = inspectSegment();
586
- if (inPipeline && SHELLS.has(cmd) && pipelineHasDownloader) return true;
280
+ if (inPipeline && (SHELLS.has(cmd) || INTERPRETERS.has(cmd)) && pipelineHasDownloader) return true;
587
281
  const continues = token.value === "|" || token.value === "|&";
588
282
  if (continues) {
589
283
  pipelineHasDownloader = pipelineHasDownloader || cmd === "curl" || cmd === "wget";
@@ -598,7 +292,33 @@ function hasDangerousPipeline(tokens) {
598
292
  segment.push(token);
599
293
  }
600
294
  const cmd = inspectSegment();
601
- return inPipeline && pipelineHasDownloader && SHELLS.has(cmd);
295
+ return inPipeline && pipelineHasDownloader && (SHELLS.has(cmd) || INTERPRETERS.has(cmd));
296
+ }
297
+
298
+ function hasDestructiveSqlPipeline(tokens) {
299
+ let pipelineHasDestructiveSql = false;
300
+ let inPipeline = false;
301
+ let segment = [];
302
+ for (const token of tokens) {
303
+ if (token.kind === "op" && SPLIT_OPS.has(token.value)) {
304
+ const cmd = commandNameFromSegment(segment);
305
+ if (inPipeline && DB_CLIENTS.has(cmd) && pipelineHasDestructiveSql) return true;
306
+ const continues = token.value === "|" || token.value === "|&";
307
+ if (continues) {
308
+ const segText = wordsOf(segment).join(" ");
309
+ pipelineHasDestructiveSql = pipelineHasDestructiveSql || DESTRUCTIVE_SQL.test(segText);
310
+ inPipeline = true;
311
+ } else {
312
+ pipelineHasDestructiveSql = false;
313
+ inPipeline = false;
314
+ }
315
+ segment = [];
316
+ continue;
317
+ }
318
+ segment.push(token);
319
+ }
320
+ const cmd = commandNameFromSegment(segment);
321
+ return inPipeline && pipelineHasDestructiveSql && DB_CLIENTS.has(cmd);
602
322
  }
603
323
 
604
324
  function executableTokenOf(seg) {
@@ -727,6 +447,7 @@ export function inspectBashCommand(command, depth) {
727
447
  if (knownDanger) return knownDanger;
728
448
  }
729
449
  if (hasDangerousPipeline(tokens)) return deny("curl-pipe", command);
450
+ if (hasDestructiveSqlPipeline(tokens)) return deny("destructive-sql", command);
730
451
  const pipelineHeredocHit = inspectPipelineHeredocs(tokens, depth);
731
452
  if (pipelineHeredocHit.deny || pipelineHeredocHit.ask) return pipelineHeredocHit;
732
453
 
@@ -780,6 +501,12 @@ export function inspectBashCommand(command, depth) {
780
501
  const nested = inspectInterpreterPayload(commandName, heredoc.body);
781
502
  if (nested.deny || nested.ask) return nested;
782
503
  }
504
+ } else if (DB_CLIENTS.has(commandName)) {
505
+ for (const heredoc of heredocs) {
506
+ if (DESTRUCTIVE_SQL.test(heredoc.body)) {
507
+ return deny("destructive-sql", heredoc.body);
508
+ }
509
+ }
783
510
  }
784
511
  }
785
512
 
@@ -822,97 +549,3 @@ export function inspectExecution(execution, config) {
822
549
  return allow();
823
550
  }
824
551
 
825
- export function sessionTag(execution) {
826
- const agent = execution && execution.agent;
827
- if (typeof agent === "string") return " (session " + agent + ")";
828
- if (agent && agent.session) return " (session " + String(agent.session) + ")";
829
- return "";
830
- }
831
-
832
- function safeSnippet(value) {
833
- return String(value || "")
834
- .replace(/[\r\n\t\u0000-\u001f]+/g, " ")
835
- .replace(/(authorization\s*:\s*(?:bearer|token)\s+)\S+/gi, "$1[redacted]")
836
- .replace(/((?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*)[^\s,;]+/gi, "$1[redacted]")
837
- .slice(0, 120);
838
- }
839
-
840
- function translated(translate, key, logger) {
841
- if (typeof translate === "function") {
842
- try {
843
- const value = translate(key);
844
- if (value && value !== key) return value;
845
- } catch {
846
- if (logger && typeof logger.warn === "function") {
847
- logger.warn("[dsh-approval-gate] localization lookup failed; using English fallback");
848
- }
849
- }
850
- }
851
- return MESSAGES.en[key] || key;
852
- }
853
-
854
- const RULE_KEYS = Object.freeze({
855
- "recursive-rm": "ruleRecursiveRm",
856
- "process-signal": "ruleProcessSignal",
857
- "service-control": "ruleServiceControl",
858
- "git-clean": "ruleGitClean",
859
- "git-reset-hard": "ruleGitResetHard",
860
- "find-delete": "ruleFindDelete",
861
- "destructive-sql": "ruleDestructiveSql",
862
- "secret-write": "ruleSecretWrite",
863
- "device-write": "ruleDeviceWrite",
864
- "mkfs-device": "ruleMkfsDevice",
865
- "curl-pipe": "ruleCurlPipe",
866
- "eval": "ruleEval",
867
- "nested-shell": "ruleNestedShell",
868
- "interpreter-payload": "ruleInterpreterPayload",
869
- });
870
-
871
- const ASK_KEYS = Object.freeze({
872
- unparseable: "askUnparseable",
873
- "unknown-format": "askUnknownFormat",
874
- "unknown-write-target": "askUnknownWriteTarget",
875
- "dynamic-command": "askDynamicCommand",
876
- "dynamic-arguments": "askDynamicArguments",
877
- "dynamic-redirect": "askDynamicRedirect",
878
- "script-file": "askScriptFile",
879
- "shell-stdin": "askShellStdin",
880
- "interpreter-payload": "askInterpreterPayload",
881
- "credential-in-argv": "askCredentialInArgv",
882
- });
883
-
884
- const DETAIL_KEYS = Object.freeze({
885
- "process-substitution": "detailProcessSubstitution",
886
- "unclosed-process-substitution": "detailUnclosedProcessSubstitution",
887
- "unclosed-command-substitution": "detailUnclosedCommandSubstitution",
888
- "unclosed-backtick-substitution": "detailUnclosedBacktick",
889
- "unclosed-parameter-expansion": "detailUnclosedParameterExpansion",
890
- "unclosed-single-quote": "detailUnclosedSingleQuote",
891
- "unclosed-double-quote": "detailUnclosedDoubleQuote",
892
- "dangling-backslash": "detailDanglingEscape",
893
- "invalid-heredoc-delimiter": "detailInvalidHeredoc",
894
- "unterminated-heredoc": "detailUnterminatedHeredoc",
895
- "unsupported-shell-grouping": "detailUnsupportedGrouping",
896
- });
897
-
898
- export function denyMessage(hit, who, translate, logger) {
899
- const key = RULE_KEYS[hit && hit.reason] || "ruleUnknown";
900
- const snippet = safeSnippet(hit && hit.snippet);
901
- const parts = [translated(translate, "blockedPrefix", logger), translated(translate, key, logger)];
902
- if (snippet) parts.push(translated(translate, "detectedLabel", logger) + ": " + snippet);
903
- if (who) parts.push(who.trim());
904
- return parts.join(" — ") + ".";
905
- }
906
-
907
- export function askMessage(hit, who, translate, logger) {
908
- const key = ASK_KEYS[hit && hit.reason] || "askUnknown";
909
- const snippet = safeSnippet(hit && hit.snippet);
910
- const parts = [translated(translate, "approvalRequired", logger), translated(translate, key, logger)];
911
- if (hit && hit.reason === "unparseable") {
912
- const detailKey = DETAIL_KEYS[hit.detail] || "detailUnsupported";
913
- parts.push(translated(translate, "parseReasonLabel", logger) + ": " + translated(translate, detailKey, logger));
914
- }
915
- if (snippet) parts.push(translated(translate, "commandLabel", logger) + ": " + snippet);
916
- if (who) parts.push(who.trim());
917
- return parts.join(" — ") + ".";
918
- }
package/lib/messages.js CHANGED
@@ -91,4 +91,144 @@ export const MESSAGES = Object.freeze({
91
91
  ruleNestedShell: "嵌套 shell 超出检查深度",
92
92
  ruleInterpreterPayload: "不安全的解释器代码",
93
93
  },
94
+ ru: {
95
+ blockedPrefix: "Заблокировано правилом dsh-approval-gate",
96
+ detectedLabel: "Обнаружено",
97
+ commandLabel: "Команда",
98
+ parseReasonLabel: "Причина синтаксического анализа",
99
+ detailProcessSubstitution: "процессная подстановка требует подтверждения",
100
+ detailUnclosedProcessSubstitution: "незакрытая процессная подстановка",
101
+ detailUnclosedCommandSubstitution: "незакрытая подстановка команды",
102
+ detailUnclosedBacktick: "незакрытая подстановка команды в обратных кавычках",
103
+ detailUnclosedParameterExpansion: "незакрытое расширение параметров",
104
+ detailUnclosedSingleQuote: "незакрытая одинарная кавычка",
105
+ detailUnclosedDoubleQuote: "незакрытая двойная кавычка",
106
+ detailDanglingEscape: "незавершённое экранирование",
107
+ detailInvalidHeredoc: "неверный разделитель here-document",
108
+ detailUnterminatedHeredoc: "завершитель here-document не найден",
109
+ detailUnsupportedGrouping: "группировка шелла не поддерживается анализатором",
110
+ detailUnsupported: "неподдерживаемый синтаксис шелла",
111
+ approvalRequired: "Требуется подтверждение",
112
+ askUnknown: "Команда не может быть проверена полностью; проверьте её перед подтверждением",
113
+ askUnparseable: "Синтаксис шелла не может быть проверен полностью; проверьте его перед подтверждением",
114
+ askUnknownFormat: "Формат вызова инструмента не может быть проверен; проверьте его перед подтверждением",
115
+ askUnknownWriteTarget: "Целевой файл для записи не определён; проверьте его перед подтверждением",
116
+ askDynamicCommand: "Развёрнутое имя команды не может быть проверено; проверьте его перед подтверждением",
117
+ askDynamicArguments: "Развёрнутые аргументы чувствительной команды не могут быть проверены; проверьте их перед подтверждением",
118
+ askDynamicRedirect: "Развёрнутая цель перенаправления не может быть проверена; проверьте её перед подтверждением",
119
+ askScriptFile: "Содержимое файла скрипта недоступно для гейта; проверьте его перед подтверждением",
120
+ askShellStdin: "Ввод шелла недоступен для гейта в полном объёме; проверьте его перед подтверждением",
121
+ askInterpreterPayload: "Побочные эффекты интерпретатора не могут быть полностью проанализированы; проверьте их перед подтверждением",
122
+ askCredentialInArgv: "Развёрнутые учётные данные передаются как аргументы процесса; используйте безопасный API или проверьте перед подтверждением",
123
+ ruleUnknown: "Сработало защитное правило блокировки",
124
+ ruleRecursiveRm: "Рекурсивное удаление файлов",
125
+ ruleProcessSignal: "Завершение процесса",
126
+ ruleServiceControl: "Изменение состояния службы",
127
+ ruleGitClean: "Разрушительный git clean",
128
+ ruleGitResetHard: "Разрушительный git reset --hard",
129
+ ruleFindDelete: "find -delete",
130
+ ruleDestructiveSql: "Разрушительный SQL",
131
+ ruleSecretWrite: "Запись в защищённый файл с секретами",
132
+ ruleDeviceWrite: "Запись в системное устройство или защищённый файл",
133
+ ruleMkfsDevice: "Форматирование файловой системы",
134
+ ruleCurlPipe: "Передача скачанного содержимого в шелл",
135
+ ruleEval: "Динамическое выполнение шелла (eval)",
136
+ ruleNestedShell: "Превышен лимит вложенности шелла",
137
+ ruleInterpreterPayload: "Небезопасный исполняемый код интерпретатора",
138
+ },
139
+ });
140
+
141
+ export function sessionTag(execution) {
142
+ const agent = execution && execution.agent;
143
+ if (typeof agent === "string") return " (session " + agent + ")";
144
+ if (agent && agent.session) return " (session " + String(agent.session) + ")";
145
+ return "";
146
+ }
147
+
148
+ function safeSnippet(value) {
149
+ return String(value || "")
150
+ .replace(/[\r\n\t\u0000-\u001f]+/g, " ")
151
+ .replace(/(authorization\s*:\s*(?:bearer|token)\s+)\S+/gi, "$1[redacted]")
152
+ .replace(/((?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*)[^\s,;]+/gi, "$1[redacted]")
153
+ .slice(0, 120);
154
+ }
155
+
156
+ function translated(translate, key, logger) {
157
+ if (typeof translate === "function") {
158
+ try {
159
+ const value = translate(key);
160
+ if (value && value !== key) return value;
161
+ } catch {
162
+ if (logger && typeof logger.warn === "function") {
163
+ logger.warn("[dsh-approval-gate] localization lookup failed; using English fallback");
164
+ }
165
+ }
166
+ }
167
+ return MESSAGES.en[key] || key;
168
+ }
169
+
170
+ const RULE_KEYS = Object.freeze({
171
+ "recursive-rm": "ruleRecursiveRm",
172
+ "process-signal": "ruleProcessSignal",
173
+ "service-control": "ruleServiceControl",
174
+ "git-clean": "ruleGitClean",
175
+ "git-reset-hard": "ruleGitResetHard",
176
+ "find-delete": "ruleFindDelete",
177
+ "destructive-sql": "ruleDestructiveSql",
178
+ "secret-write": "ruleSecretWrite",
179
+ "device-write": "ruleDeviceWrite",
180
+ "mkfs-device": "ruleMkfsDevice",
181
+ "curl-pipe": "ruleCurlPipe",
182
+ "eval": "ruleEval",
183
+ "nested-shell": "ruleNestedShell",
184
+ "interpreter-payload": "ruleInterpreterPayload",
94
185
  });
186
+
187
+ const ASK_KEYS = Object.freeze({
188
+ unparseable: "askUnparseable",
189
+ "unknown-format": "askUnknownFormat",
190
+ "unknown-write-target": "askUnknownWriteTarget",
191
+ "dynamic-command": "askDynamicCommand",
192
+ "dynamic-arguments": "askDynamicArguments",
193
+ "dynamic-redirect": "askDynamicRedirect",
194
+ "script-file": "askScriptFile",
195
+ "shell-stdin": "askShellStdin",
196
+ "interpreter-payload": "askInterpreterPayload",
197
+ "credential-in-argv": "askCredentialInArgv",
198
+ });
199
+
200
+ const DETAIL_KEYS = Object.freeze({
201
+ "process-substitution": "detailProcessSubstitution",
202
+ "unclosed-process-substitution": "detailUnclosedProcessSubstitution",
203
+ "unclosed-command-substitution": "detailUnclosedCommandSubstitution",
204
+ "unclosed-backtick-substitution": "detailUnclosedBacktick",
205
+ "unclosed-parameter-expansion": "detailUnclosedParameterExpansion",
206
+ "unclosed-single-quote": "detailUnclosedSingleQuote",
207
+ "unclosed-double-quote": "detailUnclosedDoubleQuote",
208
+ "dangling-backslash": "detailDanglingEscape",
209
+ "invalid-heredoc-delimiter": "detailInvalidHeredoc",
210
+ "unterminated-heredoc": "detailUnterminatedHeredoc",
211
+ "unsupported-shell-grouping": "detailUnsupportedGrouping",
212
+ });
213
+
214
+ export function denyMessage(hit, who, translate, logger) {
215
+ const key = RULE_KEYS[hit && hit.reason] || "ruleUnknown";
216
+ const snippet = safeSnippet(hit && hit.snippet);
217
+ const parts = [translated(translate, "blockedPrefix", logger), translated(translate, key, logger)];
218
+ if (snippet) parts.push(translated(translate, "detectedLabel", logger) + ": " + snippet);
219
+ if (who) parts.push(who.trim());
220
+ return parts.join(" — ") + ".";
221
+ }
222
+
223
+ export function askMessage(hit, who, translate, logger) {
224
+ const key = ASK_KEYS[hit && hit.reason] || "askUnknown";
225
+ const snippet = safeSnippet(hit && hit.snippet);
226
+ const parts = [translated(translate, "approvalRequired", logger), translated(translate, key, logger)];
227
+ if (hit && hit.reason === "unparseable") {
228
+ const detailKey = DETAIL_KEYS[hit.detail] || "detailUnsupported";
229
+ parts.push(translated(translate, "parseReasonLabel", logger) + ": " + translated(translate, detailKey, logger));
230
+ }
231
+ if (snippet) parts.push(translated(translate, "commandLabel", logger) + ": " + snippet);
232
+ if (who) parts.push(who.trim());
233
+ return parts.join(" — ") + ".";
234
+ }
@@ -0,0 +1,319 @@
1
+ // Pure bash lexer and tokenizer for dsh-approval-gate.
2
+
3
+ export const SPLIT_OPS = new Set(["|", "|&", "||", "&&", ";", "&"]);
4
+ export const REDIR_OPS = new Set([">", ">>", ">|", "<>", "2>", "2>>", "&>", "&>>"]);
5
+ export const HEREDOC_OPS = new Set(["<<", "<<-"]);
6
+ export const DYNAMIC_MARKER = "__shell_dynamic__";
7
+
8
+ function opAt(s, i) {
9
+ const rest = s.slice(i);
10
+ if (rest.startsWith("<(") || rest.startsWith(">(")) return null;
11
+ const candidates = ["2>&", "2>>", "&>>", "<<<", "<<-", "2>", ">>", ">|", "|&", "&&", "||", "&>", ">&", "<&", "<>", "<<", ">", "<", "|", ";", "&"];
12
+ for (const op of candidates) {
13
+ if (rest.startsWith(op)) return op;
14
+ }
15
+ return null;
16
+ }
17
+
18
+ export function readCommandSubstitution(src, start) {
19
+ let depth = 1;
20
+ let quote = "";
21
+ let escaped = false;
22
+ for (let i = start + 2; i < src.length; i += 1) {
23
+ const c = src[i];
24
+ if (escaped) { escaped = false; continue; }
25
+ if (c === "\\") { escaped = true; continue; }
26
+ if (quote) {
27
+ if (c === quote) quote = "";
28
+ continue;
29
+ }
30
+ if (c === "'" || c === '"') { quote = c; continue; }
31
+ if (c === String.fromCharCode(96)) {
32
+ const end = readBacktick(src, i);
33
+ if (!end) return null;
34
+ i = end.end;
35
+ continue;
36
+ }
37
+ if (src.startsWith("$(", i)) { depth += 1; i += 1; continue; }
38
+ if (c === "(") { depth += 1; continue; }
39
+ if (c === ")") {
40
+ depth -= 1;
41
+ if (depth === 0) return { script: src.slice(start + 2, i), end: i + 1 };
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ export function readBacktick(src, start) {
48
+ let escaped = false;
49
+ for (let i = start + 1; i < src.length; i += 1) {
50
+ if (escaped) { escaped = false; continue; }
51
+ if (src[i] === "\\") { escaped = true; continue; }
52
+ if (src[i] === String.fromCharCode(96)) return { script: src.slice(start + 1, i), end: i + 1 };
53
+ }
54
+ return null;
55
+ }
56
+
57
+ export function readParameterExpansion(src, start) {
58
+ if (src[start + 1] !== "{") {
59
+ let i = start + 1;
60
+ if (/[A-Za-z_]/.test(src[i] || "")) {
61
+ i += 1;
62
+ while (i < src.length && /[A-Za-z0-9_]/.test(src[i])) i += 1;
63
+ } else if (i < src.length) i += 1;
64
+ return { end: i };
65
+ }
66
+ let depth = 1;
67
+ let quote = "";
68
+ for (let i = start + 2; i < src.length; i += 1) {
69
+ const c = src[i];
70
+ if (c === "\\") { i += 1; continue; }
71
+ if (quote) {
72
+ if (c === quote) quote = "";
73
+ continue;
74
+ }
75
+ if (c === "'" || c === '"') { quote = c; continue; }
76
+ if (c === String.fromCharCode(96)) {
77
+ const nested = readBacktick(src, i);
78
+ if (!nested) return null;
79
+ i = nested.end - 1;
80
+ continue;
81
+ }
82
+ if (src.startsWith("$" + "(", i)) {
83
+ const nested = readCommandSubstitution(src, i);
84
+ if (!nested) return null;
85
+ i = nested.end - 1;
86
+ continue;
87
+ }
88
+ if (src.startsWith("$" + "{", i)) {
89
+ const nested = readParameterExpansion(src, i);
90
+ if (!nested) return null;
91
+ i = nested.end - 1;
92
+ continue;
93
+ }
94
+ if (c === "{") depth += 1;
95
+ if (c === "}") {
96
+ depth -= 1;
97
+ if (depth === 0) return { end: i + 1, body: src.slice(start + 2, i) };
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function lexFail(src, index, reason, tokens) {
104
+ return { ok: false, reason: reason, snippet: src.slice(Math.max(0, index - 32), index + 96), tokens: tokens };
105
+ }
106
+
107
+ function readHeredocDelimiter(src, index) {
108
+ let i = index;
109
+ while (i < src.length && (src[i] === " " || src[i] === "\t")) i += 1;
110
+ if (i >= src.length || src[i] === "\n" || src[i] === "\r") return null;
111
+ let delimiter = "";
112
+ let quote = "";
113
+ let started = false;
114
+ let quoted = false;
115
+ while (i < src.length && !/\s/.test(src[i]) && !opAt(src, i)) {
116
+ const c = src[i];
117
+ if (c === "'" || c === '"') {
118
+ if (!quote) { quote = c; quoted = true; started = true; i += 1; continue; }
119
+ if (quote === c) { quote = ""; i += 1; continue; }
120
+ }
121
+ if (c === "\\") {
122
+ if (i + 1 >= src.length) return null;
123
+ quoted = true;
124
+ delimiter += src[i + 1];
125
+ started = true;
126
+ i += 2;
127
+ continue;
128
+ }
129
+ delimiter += c;
130
+ started = true;
131
+ i += 1;
132
+ }
133
+ if (quote || !started || !delimiter) return null;
134
+ return { delimiter: delimiter, end: i, quoted: quoted };
135
+ }
136
+
137
+ export function tokenize(src) {
138
+ if (typeof src !== "string") return { ok: false, reason: "not-a-string", snippet: "", tokens: [] };
139
+ const tokens = [];
140
+ const pendingHeredocs = [];
141
+ let i = 0;
142
+ const n = src.length;
143
+
144
+ function flushWord(value, started, dynamic, substitutions) {
145
+ if (started) tokens.push({ kind: "word", value: value, dynamic: dynamic, substitutions: substitutions });
146
+ }
147
+
148
+ while (i < n) {
149
+ if (src.startsWith("\\\n", i)) { i += 2; continue; }
150
+ if (src[i] === "\r" || src[i] === "\n") {
151
+ if (src[i] === "\r" && src[i + 1] === "\n") i += 2;
152
+ else i += 1;
153
+ if (pendingHeredocs.length) {
154
+ for (const heredoc of pendingHeredocs) {
155
+ let body = "";
156
+ let found = false;
157
+ while (i <= n) {
158
+ const lineEnd = src.indexOf("\n", i);
159
+ const hasNewline = lineEnd !== -1;
160
+ let line = src.slice(i, hasNewline ? lineEnd : n);
161
+ if (line.endsWith("\r")) line = line.slice(0, -1);
162
+ const compare = heredoc.stripTabs ? line.replace(/^\t+/, "") : line;
163
+ if (compare === heredoc.delimiter) {
164
+ i = hasNewline ? lineEnd + 1 : n;
165
+ found = true;
166
+ break;
167
+ }
168
+ body += (heredoc.stripTabs ? line.replace(/^\t+/, "") : line) + (hasNewline ? "\n" : "");
169
+ if (!hasNewline) { i = n; break; }
170
+ i = lineEnd + 1;
171
+ }
172
+ if (!found) return lexFail(src, i, "unterminated-heredoc", tokens);
173
+ tokens.push({ kind: "heredoc", body: body, quoted: heredoc.quoted });
174
+ }
175
+ pendingHeredocs.length = 0;
176
+ tokens.push({ kind: "op", value: ";" });
177
+ } else {
178
+ tokens.push({ kind: "op", value: ";" });
179
+ }
180
+ continue;
181
+ }
182
+ if (/\s/.test(src[i])) { i += 1; continue; }
183
+ if (src[i] === "#" && (tokens.length === 0 || tokens[tokens.length - 1].kind === "op")) {
184
+ while (i < n && src[i] !== "\n") i += 1;
185
+ continue;
186
+ }
187
+
188
+ const op = opAt(src, i);
189
+ if (op) {
190
+ tokens.push({ kind: "op", value: op });
191
+ i += op.length;
192
+ if (HEREDOC_OPS.has(op)) {
193
+ const parsed = readHeredocDelimiter(src, i);
194
+ if (!parsed) return lexFail(src, i, "invalid-heredoc-delimiter", tokens);
195
+ tokens.push({ kind: "heredoc-delimiter", value: parsed.delimiter });
196
+ pendingHeredocs.push({ delimiter: parsed.delimiter, stripTabs: op === "<<-", quoted: parsed.quoted });
197
+ i = parsed.end;
198
+ }
199
+ continue;
200
+ }
201
+
202
+ let word = "";
203
+ let started = false;
204
+ let dynamic = false;
205
+ const substitutions = [];
206
+ while (i < n && !/\s/.test(src[i]) && !opAt(src, i)) {
207
+ const c = src[i];
208
+ if (src.startsWith("<(", i) || src.startsWith(">(", i)) {
209
+ const sub = readCommandSubstitution(src, i);
210
+ if (!sub) return lexFail(src, i, "unclosed-process-substitution", tokens);
211
+ substitutions.push({ kind: "process-substitution", script: sub.script });
212
+ word += DYNAMIC_MARKER;
213
+ dynamic = true;
214
+ started = true;
215
+ i = sub.end;
216
+ continue;
217
+ }
218
+ if (c === "(" || c === ")") return lexFail(src, i, "unsupported-shell-grouping", tokens);
219
+ if (c === "\\") {
220
+ if (i + 1 >= n) return lexFail(src, i, "dangling-backslash", tokens);
221
+ word += src[i + 1];
222
+ started = true;
223
+ i += 2;
224
+ continue;
225
+ }
226
+ if (c === "'") {
227
+ const j = src.indexOf("'", i + 1);
228
+ if (j < 0) return lexFail(src, i, "unclosed-single-quote", tokens);
229
+ word += src.slice(i + 1, j);
230
+ started = true;
231
+ i = j + 1;
232
+ continue;
233
+ }
234
+ if (c === '"') {
235
+ started = true;
236
+ i += 1;
237
+ let closed = false;
238
+ while (i < n) {
239
+ if (src[i] === '"') { i += 1; closed = true; break; }
240
+ if (src[i] === "\\") {
241
+ if (i + 1 >= n) return lexFail(src, i, "dangling-backslash", tokens);
242
+ word += src[i + 1];
243
+ i += 2;
244
+ continue;
245
+ }
246
+ if (src.startsWith("$(", i)) {
247
+ const sub = readCommandSubstitution(src, i);
248
+ if (!sub) return lexFail(src, i, "unclosed-command-substitution", tokens);
249
+ substitutions.push({ kind: "command-substitution", script: sub.script });
250
+ word += DYNAMIC_MARKER;
251
+ dynamic = true;
252
+ i = sub.end;
253
+ continue;
254
+ }
255
+ if (src[i] === String.fromCharCode(96)) {
256
+ const sub = readBacktick(src, i);
257
+ if (!sub) return lexFail(src, i, "unclosed-backtick-substitution", tokens);
258
+ substitutions.push({ kind: "backtick-substitution", script: sub.script });
259
+ word += DYNAMIC_MARKER;
260
+ dynamic = true;
261
+ i = sub.end;
262
+ continue;
263
+ }
264
+ if (src[i] === "$") {
265
+ const expansion = readParameterExpansion(src, i);
266
+ if (!expansion) return lexFail(src, i, "unclosed-parameter-expansion", tokens);
267
+ word += DYNAMIC_MARKER;
268
+ dynamic = true;
269
+ if (expansion.body !== undefined) substitutions.push({ kind: "parameter-expansion", script: expansion.body });
270
+ i = expansion.end;
271
+ continue;
272
+ }
273
+ word += src[i];
274
+ i += 1;
275
+ }
276
+ if (!closed) return lexFail(src, i, "unclosed-double-quote", tokens);
277
+ continue;
278
+ }
279
+ if (src.startsWith("$(", i)) {
280
+ const sub = readCommandSubstitution(src, i);
281
+ if (!sub) return lexFail(src, i, "unclosed-command-substitution", tokens);
282
+ substitutions.push({ kind: "command-substitution", script: sub.script });
283
+ word += DYNAMIC_MARKER;
284
+ dynamic = true;
285
+ started = true;
286
+ i = sub.end;
287
+ continue;
288
+ }
289
+ if (c === String.fromCharCode(96)) {
290
+ const sub = readBacktick(src, i);
291
+ if (!sub) return lexFail(src, i, "unclosed-backtick-substitution", tokens);
292
+ substitutions.push({ kind: "backtick-substitution", script: sub.script });
293
+ word += DYNAMIC_MARKER;
294
+ dynamic = true;
295
+ started = true;
296
+ i = sub.end;
297
+ continue;
298
+ }
299
+ if (c === "$") {
300
+ const expansion = readParameterExpansion(src, i);
301
+ if (!expansion) return lexFail(src, i, "unclosed-parameter-expansion", tokens);
302
+ word += DYNAMIC_MARKER;
303
+ dynamic = true;
304
+ started = true;
305
+ if (expansion.body !== undefined) substitutions.push({ kind: "parameter-expansion", script: expansion.body });
306
+ i = expansion.end;
307
+ continue;
308
+ }
309
+ if (c === "*" || c === "?") dynamic = true;
310
+ word += c;
311
+ started = true;
312
+ i += 1;
313
+ }
314
+ flushWord(word, started, dynamic, substitutions);
315
+ }
316
+ if (pendingHeredocs.length) return lexFail(src, n, "unterminated-heredoc", tokens);
317
+ return { ok: true, tokens: tokens };
318
+ }
319
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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",