@goodandready/dsh-approval-gate 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -1
- package/README.md +9 -3
- package/README.ru.md +11 -5
- package/README.zh.md +5 -4
- package/lib/bands.js +125 -0
- package/lib/breaker.js +97 -0
- package/lib/engine.js +108 -0
- package/lib/index.js +6 -0
- package/lib/inspect.js +59 -423
- package/lib/messages.js +140 -0
- package/lib/paths.js +25 -0
- package/lib/tokenizer.js +380 -0
- package/package.json +1 -1
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/paths.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// lib/paths.js
|
|
2
|
+
// Home path expansion and path boundary helpers for dsh-approval-gate.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Expand `~`, `${HOME}`, `$HOME`, and `%USERPROFILE%` at the start of or within a path.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} p - Path or string containing path references
|
|
8
|
+
* @param {Record<string, string|undefined>} [env] - Environment dictionary (defaults to process.env)
|
|
9
|
+
* @returns {string} Normalized path with home expanded
|
|
10
|
+
*/
|
|
11
|
+
export function expandHome(p, env = process.env) {
|
|
12
|
+
if (typeof p !== "string" || p.trim() === "") return p;
|
|
13
|
+
const home = (env && (env.HOME || env.USERPROFILE)) || "";
|
|
14
|
+
if (!home) return p;
|
|
15
|
+
const cleanHome = home.replace(/[/\\]+$/, "");
|
|
16
|
+
|
|
17
|
+
let res = p;
|
|
18
|
+
// Expand leading ~ or ~/ or ~\
|
|
19
|
+
res = res.replace(/^~(?=$|[/\\])/, cleanHome);
|
|
20
|
+
// Expand ${HOME} and $HOME
|
|
21
|
+
res = res.replace(/\$\{HOME\}|\$HOME/g, cleanHome);
|
|
22
|
+
// Expand %USERPROFILE% (Windows)
|
|
23
|
+
res = res.replace(/%USERPROFILE%/gi, cleanHome);
|
|
24
|
+
return res;
|
|
25
|
+
}
|
package/lib/tokenizer.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
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
|
+
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Splits a complex shell command line into individual subcommands across pipeline
|
|
323
|
+
* and sequence operators (&&, ||, ;, |, &).
|
|
324
|
+
* Respects single quotes, double quotes, subshells $(...), backticks, and heredocs.
|
|
325
|
+
*
|
|
326
|
+
* @param {string} command - Raw shell command
|
|
327
|
+
* @returns {Array<{ command: string, argv: string[], operator: string|null, tokens: Array<any>, ok: boolean }>}
|
|
328
|
+
*/
|
|
329
|
+
export function splitSubcommands(command) {
|
|
330
|
+
if (typeof command !== "string" || command.trim() === "") return [];
|
|
331
|
+
const tok = tokenize(command);
|
|
332
|
+
const result = [];
|
|
333
|
+
let currentTokens = [];
|
|
334
|
+
let prevOp = null;
|
|
335
|
+
|
|
336
|
+
function pushSub(tokens, op) {
|
|
337
|
+
if (!tokens.length) return;
|
|
338
|
+
const words = tokens
|
|
339
|
+
.filter((t) => t.kind === "word")
|
|
340
|
+
.map((t) => t.value);
|
|
341
|
+
const subStr = tokens
|
|
342
|
+
.map((t) => {
|
|
343
|
+
if (t.kind === "heredoc") return `<<${t.marker}`;
|
|
344
|
+
return t.value;
|
|
345
|
+
})
|
|
346
|
+
.join(" ")
|
|
347
|
+
.trim();
|
|
348
|
+
result.push({
|
|
349
|
+
command: subStr,
|
|
350
|
+
argv: words,
|
|
351
|
+
operator: op,
|
|
352
|
+
tokens: tokens,
|
|
353
|
+
ok: tok.ok,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const t of tok.tokens) {
|
|
358
|
+
if (t.kind === "op" && SPLIT_OPS.has(t.value)) {
|
|
359
|
+
pushSub(currentTokens, prevOp);
|
|
360
|
+
currentTokens = [];
|
|
361
|
+
prevOp = t.value;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
currentTokens.push(t);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
pushSub(currentTokens, prevOp);
|
|
368
|
+
|
|
369
|
+
if (result.length === 0 && command.trim().length > 0) {
|
|
370
|
+
result.push({
|
|
371
|
+
command: command.trim(),
|
|
372
|
+
argv: command.trim().split(/\s+/),
|
|
373
|
+
operator: null,
|
|
374
|
+
tokens: [],
|
|
375
|
+
ok: false,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return result;
|
|
380
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-approval-gate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Host-only command safety gate for DeepSeek Harness: blocks recognized dangerous operations and requests DSH approval when a command cannot be inspected.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|