@goodandready/dsh-approval-gate 0.1.3 → 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 +27 -0
- package/README.md +9 -3
- package/README.ru.md +11 -5
- package/README.zh.md +5 -4
- package/lib/index.js +53 -3
- package/lib/inspect.js +54 -421
- package/lib/messages.js +140 -0
- package/lib/tokenizer.js +319 -0
- package/package.json +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
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
|
+
|
|
15
|
+
## 0.1.4
|
|
16
|
+
|
|
17
|
+
- The gate no longer raises an ask it knows cannot be answered. It reads the effective
|
|
18
|
+
approval policy (`ctx.approval.config.policy`) and, when that policy is in
|
|
19
|
+
`unattendedPolicies` (default `["never"]`, i.e. the full-access preset), an allowed command
|
|
20
|
+
proceeds instead of dying as a rejection nobody could consent to. On 20.09.2026 exactly that
|
|
21
|
+
turned every agreed write in such a session into `the user rejected tool`.
|
|
22
|
+
- Denials never depend on the policy: dangerous shell commands and writes to secret files are
|
|
23
|
+
still blocked unconditionally, and the uncertain-syntax case stays denied where no
|
|
24
|
+
pre-execute hook exists.
|
|
25
|
+
- New settings: `unattendedPolicies` and an explicit `unattended` flag for deployments where
|
|
26
|
+
the policy is not visible. An unknown policy keeps the safe direction and still asks.
|
|
27
|
+
- 8 tests for the new behaviour (23 in total).
|
|
28
|
+
|
|
29
|
+
|
|
3
30
|
## 0.1.3
|
|
4
31
|
|
|
5
32
|
- First public npmjs release under the canonical @goodandready/dsh-approval-gate identity.
|
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/
|
|
62
|
-
| lib/
|
|
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.
|
|
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/
|
|
66
|
-
| lib/
|
|
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.
|
|
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.
|
|
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/
|
|
66
|
-
| lib/
|
|
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.
|
|
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/index.js
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
// commands and writes to secret files even when sandbox mode would allow
|
|
5
5
|
// them. A guard return value (non-undefined) denies execution, so the
|
|
6
6
|
// dangerous call never runs on its own - it needs an explicit owner
|
|
7
|
-
// Approval is handled by DSH's native flow; no separate word is implemented.
|
|
7
|
+
// Approval is handled by DSH's native flow; no separate word is implemented. When the
|
|
8
|
+
// effective approval policy cannot be answered ("never", i.e. the full-access preset),
|
|
9
|
+
// the gate does not raise an ask at all: an allowed command proceeds instead of dying as
|
|
10
|
+
// a rejection nobody could consent to. Denials never depend on the policy.
|
|
8
11
|
//
|
|
9
12
|
// Only the agent tool calls pass through the guard (cron/systemd scripts
|
|
10
13
|
// do not), so legitimate unattended operations are unaffected.
|
|
@@ -22,11 +25,50 @@ import { LOCALE_NS, MESSAGES } from "./messages.js";
|
|
|
22
25
|
export const name = "dsh-approval-gate";
|
|
23
26
|
export const inject = ["tools"];
|
|
24
27
|
|
|
28
|
+
/** Policies under which a prompt cannot be answered, so raising one is a silent denial. */
|
|
29
|
+
export const DEFAULT_UNATTENDED_POLICIES = ["never"];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether a raised ask would be answerable in this session.
|
|
33
|
+
*
|
|
34
|
+
* `policy` comes from the approval service (`ctx.approval.config.policy`); an empty value
|
|
35
|
+
* means the knob is unknown and the gate keeps asking, which is the safe direction.
|
|
36
|
+
* @param {{policy?: string, unattendedPolicies?: string[]}} knobs
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
export function isUnattended(knobs) {
|
|
40
|
+
const policy = knobs && knobs.policy;
|
|
41
|
+
const policies = (knobs && knobs.unattendedPolicies) || DEFAULT_UNATTENDED_POLICIES;
|
|
42
|
+
if (typeof policy !== "string" || policy === "") return false;
|
|
43
|
+
return policies.includes(policy);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Effective permission knobs, as the harness exposes them. */
|
|
47
|
+
function readKnobs(ctx) {
|
|
48
|
+
const read = (name) => {
|
|
49
|
+
try {
|
|
50
|
+
return ctx && typeof ctx.get === "function" ? ctx.get(name) : undefined;
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const approval = read("approval");
|
|
56
|
+
const shell = read("shell");
|
|
57
|
+
return {
|
|
58
|
+
policy: (approval && approval.config && approval.config.policy) || "",
|
|
59
|
+
sandbox: (shell && shell.sandboxMode) || "",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
25
63
|
export const Config = Schema.object({
|
|
26
64
|
/** Name of the tool whose `command` arg is inspected (the DSH bash tool). */
|
|
27
65
|
toolName: Schema.string().default(DEFAULT_BASH_TOOL),
|
|
28
66
|
/** Extra tool names treated as file writes; paths are matched against secret files. */
|
|
29
67
|
fileWriteTools: Schema.array(Schema.string()).default(DEFAULT_FILE_WRITE_TOOLS.slice()),
|
|
68
|
+
/** Approval policies under which an ask could not be answered, so it is not raised. */
|
|
69
|
+
unattendedPolicies: Schema.array(Schema.string()).default(DEFAULT_UNATTENDED_POLICIES.slice()),
|
|
70
|
+
/** Force unattended behaviour where the approval policy is not visible. */
|
|
71
|
+
unattended: Schema.boolean().default(false),
|
|
30
72
|
});
|
|
31
73
|
|
|
32
74
|
function logBlocked(ctx, hit, who) {
|
|
@@ -54,12 +96,20 @@ export function apply(ctx, config) {
|
|
|
54
96
|
if (locale && typeof locale.bind === "function") return locale.bind(LOCALE_NS)(key);
|
|
55
97
|
return key;
|
|
56
98
|
};
|
|
99
|
+
const unattendedPolicies = cfg.unattendedPolicies || DEFAULT_UNATTENDED_POLICIES.slice();
|
|
100
|
+
const forcedUnattended = cfg.unattended === true;
|
|
57
101
|
const hasPreExecute = ctx && typeof ctx.on === "function";
|
|
58
102
|
if (hasPreExecute) {
|
|
59
103
|
ctx.on("tools/pre-execute", function (execution, next) {
|
|
60
104
|
const hit = inspectExecution(execution, { toolName: toolName, fileWriteTools: fileWriteTools });
|
|
61
|
-
if (hit.ask) return
|
|
62
|
-
|
|
105
|
+
if (!hit.ask) return next();
|
|
106
|
+
const knobs = readKnobs(ctx);
|
|
107
|
+
if (forcedUnattended || isUnattended({ policy: knobs.policy, unattendedPolicies })) {
|
|
108
|
+
// No one can answer here: an allowed command proceeds. Denials are the guard's job
|
|
109
|
+
// and do not depend on this decision.
|
|
110
|
+
return next();
|
|
111
|
+
}
|
|
112
|
+
return { kind: "ask", reason: askMessage(hit, sessionTag(execution), translate, ctx && ctx.logger) };
|
|
63
113
|
});
|
|
64
114
|
}
|
|
65
115
|
|
package/lib/inspect.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
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.
|
|
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
|
+
}
|
package/lib/tokenizer.js
ADDED
|
@@ -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.
|
|
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",
|
|
@@ -42,5 +42,13 @@
|
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"registry": "https://registry.npmjs.org",
|
|
44
44
|
"access": "public"
|
|
45
|
-
}
|
|
45
|
+
},
|
|
46
|
+
"keywords": [
|
|
47
|
+
"dsh",
|
|
48
|
+
"dsh-plugin",
|
|
49
|
+
"deepseek-harness",
|
|
50
|
+
"security",
|
|
51
|
+
"approval-gate",
|
|
52
|
+
"safety"
|
|
53
|
+
]
|
|
46
54
|
}
|