@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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.6
|
|
4
|
+
|
|
5
|
+
- Security: автоматическое раскрытие домашних путей `expandHome` (`~`, `$HOME`, `${HOME}`, `%USERPROFILE%`) в путях файлов, аргументах инструментов записи и перенаправлениях shell (Refs: #114).
|
|
6
|
+
- Security: синтаксический разбор составных цепочек shell-команд `splitSubcommands` по операторам `&&`, `||`, `;`, `|`, `&` с сохранением кавычек и подстановок (Refs: #113).
|
|
7
|
+
- Security: автоматический предохранитель сессии `CircuitBreaker` (блокировка зацикливания агента при 3 подряд или 20 общих отказах с принудительным запросом человека) (Refs: #110).
|
|
8
|
+
- Engine: двухполосная предварительная фильтрация `BandsEngine`: Band 0 (Hard Deny regex) для мгновенной блокировки деструктивных команд и Band 1 (Safe Allow glob) для нулевой задержки на рутинных командах инспекции (Refs: #112).
|
|
9
|
+
- Engine: 5-уровневый каскад принятия решений `SecurityEngine` (P0 Hard-Deny -> P1 Grants -> P2 Static Rules -> P3 LLM -> P4 Human Ask) с координацией предохранителя и правил (Refs: #122).
|
|
10
|
+
|
|
11
|
+
## 0.1.5
|
|
12
|
+
|
|
13
|
+
- Security: block recursive `rm` with uppercase `-R` and `-Rf` flags in compliance with POSIX standard (Refs: #28).
|
|
14
|
+
- Security: inspect `chmod` and `chown` in `inspectArgv`, blocking modifications to protected secret files and recursive changes to root/system paths (Refs: #29).
|
|
15
|
+
- Security: inspect `source` and `.` shell built-in commands as script executions, requiring approval for external script files and process substitutions (Refs: #30).
|
|
16
|
+
- Security: detect destructive SQL (`DROP`, `TRUNCATE`, `ALTER`) delivered via `heredoc` (`<< EOF`) and pipelines to database clients (Refs: #31).
|
|
17
|
+
- Security: block `curl` and `wget` streaming pipelines into interpreter processes like `python`, `node`, `perl`, and `ruby` (Refs: #32).
|
|
18
|
+
- Security: expand `isProtectedPath` to protect `/etc/shadow`, `/etc/sudoers`, `/etc/sudoers.d/`, and SSH `authorized_keys` from writes and redirects (Refs: #33).
|
|
19
|
+
- Bugfix: support GNU sed `--in-place=<suffix>` prefix syntax when checking modifications to protected files (Refs: #34).
|
|
20
|
+
- 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).
|
|
21
|
+
- Localization: add complete built-in Russian (`ru`) dictionary covering all rules, syntax reasons, and approval prompts in `lib/messages.js` (Refs: #36).
|
|
22
|
+
|
|
1
23
|
## 0.1.4
|
|
2
24
|
|
|
3
25
|
- The gate no longer raises an ask it knows cannot be answered. It reads the effective
|
|
@@ -12,7 +34,6 @@
|
|
|
12
34
|
the policy is not visible. An unknown policy keeps the safe direction and still asks.
|
|
13
35
|
- 8 tests for the new behaviour (23 in total).
|
|
14
36
|
|
|
15
|
-
# Changelog
|
|
16
37
|
|
|
17
38
|
## 0.1.3
|
|
18
39
|
|
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/bands.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// lib/bands.js
|
|
2
|
+
// Two-band deterministic filtering engine for dsh-approval-gate.
|
|
3
|
+
// Band 0: Hard Deny (fast regexes for exfiltration, fork-bombs, disk wipes)
|
|
4
|
+
// Band 1: Safe Allow (prefix globs & curated safe inspection commands)
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_DENY_PATTERNS = Object.freeze([
|
|
7
|
+
/\b(?:nc|ncat|netcat)\s+.*-e\s+/i,
|
|
8
|
+
/\/dev\/tcp\/\d+\.\d+\.\d+\.\d+\/\d+/i,
|
|
9
|
+
/\/dev\/udp\/\d+\.\d+\.\d+\.\d+\/\d+/i,
|
|
10
|
+
/\bmkfs(?:\.[a-z0-9]+)?\s+/i,
|
|
11
|
+
/\bdd\s+.*of=\/dev\/(?:sd|hd|vd|xvd|nvme|mmcblk|loop|mapper)/i,
|
|
12
|
+
/:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
|
|
13
|
+
/\bchmod\s+(?:-R\s+)?(?:0?777|a\+rwx)\s+\/(?!tmp)/i,
|
|
14
|
+
/\bchown\s+(?:-R\s+)?root\b/i,
|
|
15
|
+
/\b(?:shutdown|reboot|poweroff|halt)\s*(?:-|$)/i,
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_ALLOW_GLOBS = Object.freeze([
|
|
19
|
+
"git status*",
|
|
20
|
+
"git diff*",
|
|
21
|
+
"git log*",
|
|
22
|
+
"git branch*",
|
|
23
|
+
"git show*",
|
|
24
|
+
"ls*",
|
|
25
|
+
"pwd*",
|
|
26
|
+
"cat *",
|
|
27
|
+
"head *",
|
|
28
|
+
"tail *",
|
|
29
|
+
"wc *",
|
|
30
|
+
"which *",
|
|
31
|
+
"whereis *",
|
|
32
|
+
"whoami*",
|
|
33
|
+
"node --version*",
|
|
34
|
+
"node -v*",
|
|
35
|
+
"npm test*",
|
|
36
|
+
"npm --version*",
|
|
37
|
+
"npm -v*",
|
|
38
|
+
"pnpm test*",
|
|
39
|
+
"pnpm --version*",
|
|
40
|
+
"pnpm -v*",
|
|
41
|
+
"yarn test*",
|
|
42
|
+
"cargo test*",
|
|
43
|
+
"cargo check*",
|
|
44
|
+
"cargo --version*",
|
|
45
|
+
"pytest*",
|
|
46
|
+
"python --version*",
|
|
47
|
+
"python3 --version*",
|
|
48
|
+
"echo *",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
export const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
|
|
52
|
+
"read",
|
|
53
|
+
"read_file",
|
|
54
|
+
"readFile",
|
|
55
|
+
"view_file",
|
|
56
|
+
"glob",
|
|
57
|
+
"grep",
|
|
58
|
+
"find_files",
|
|
59
|
+
"search_code",
|
|
60
|
+
"file_search",
|
|
61
|
+
"list_dir",
|
|
62
|
+
"list_directory",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Compile a prefix-glob into an anchored RegExp.
|
|
67
|
+
* @param {string} pattern
|
|
68
|
+
* @returns {RegExp}
|
|
69
|
+
*/
|
|
70
|
+
export function compileGlob(pattern) {
|
|
71
|
+
let source = "";
|
|
72
|
+
for (const ch of pattern) {
|
|
73
|
+
if (ch === "*") source += ".*";
|
|
74
|
+
else if (ch === "?") source += ".";
|
|
75
|
+
else source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
76
|
+
}
|
|
77
|
+
return new RegExp(`^${source}$`, "i");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class BandsEngine {
|
|
81
|
+
/**
|
|
82
|
+
* @param {{ denyPatterns?: Array<RegExp|string>, allowGlobs?: Array<RegExp|string>, readOnlyTools?: Array<string> }} [options]
|
|
83
|
+
*/
|
|
84
|
+
constructor(options = {}) {
|
|
85
|
+
this.denyPatterns = (options.denyPatterns || DEFAULT_DENY_PATTERNS).map((p) =>
|
|
86
|
+
typeof p === "string" ? new RegExp(p, "i") : p
|
|
87
|
+
);
|
|
88
|
+
this.allowGlobs = (options.allowGlobs || DEFAULT_ALLOW_GLOBS).map((g) =>
|
|
89
|
+
typeof g === "string" ? compileGlob(g) : g
|
|
90
|
+
);
|
|
91
|
+
this.readOnlyTools = new Set(options.readOnlyTools || DEFAULT_READ_ONLY_TOOLS);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Evaluates an execution request through Band 0 and Band 1.
|
|
96
|
+
* @param {string} tool - Tool name
|
|
97
|
+
* @param {string} [command] - Normalized command text (for bash)
|
|
98
|
+
* @returns {"deny" | "allow" | "unmatched"}
|
|
99
|
+
*/
|
|
100
|
+
evaluate(tool, command) {
|
|
101
|
+
if (this.readOnlyTools.has(tool)) {
|
|
102
|
+
return "allow";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (typeof command === "string" && command.trim().length > 0) {
|
|
106
|
+
const text = command.trim();
|
|
107
|
+
|
|
108
|
+
// Band 0: Hard Deny
|
|
109
|
+
for (const pattern of this.denyPatterns) {
|
|
110
|
+
if (pattern.test(text)) {
|
|
111
|
+
return "deny";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Band 1: Safe Allow
|
|
116
|
+
for (const glob of this.allowGlobs) {
|
|
117
|
+
if (glob.test(text)) {
|
|
118
|
+
return "allow";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return "unmatched";
|
|
124
|
+
}
|
|
125
|
+
}
|
package/lib/breaker.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// lib/breaker.js
|
|
2
|
+
// Session-scoped Circuit Breaker for dsh-approval-gate.
|
|
3
|
+
// Protects against agent thrashing / loops when blocked by security gates.
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_MAX_CONSECUTIVE_DENIES = 3;
|
|
6
|
+
export const DEFAULT_MAX_TOTAL_DENIES = 20;
|
|
7
|
+
|
|
8
|
+
export class CircuitBreaker {
|
|
9
|
+
/**
|
|
10
|
+
* @param {{ maxConsecutive?: number, maxTotal?: number }} [options]
|
|
11
|
+
*/
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.maxConsecutive = options.maxConsecutive || DEFAULT_MAX_CONSECUTIVE_DENIES;
|
|
14
|
+
this.maxTotal = options.maxTotal || DEFAULT_MAX_TOTAL_DENIES;
|
|
15
|
+
/** @type {Map<string, { consecutive: number, total: number, tripped: boolean }>} */
|
|
16
|
+
this.sessions = new Map();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get current state snapshot for a session.
|
|
21
|
+
* @param {string} [sessionId]
|
|
22
|
+
* @returns {{ consecutive: number, total: number, tripped: boolean }}
|
|
23
|
+
*/
|
|
24
|
+
get(sessionId) {
|
|
25
|
+
const key = sessionId || "default";
|
|
26
|
+
let state = this.sessions.get(key);
|
|
27
|
+
if (!state) {
|
|
28
|
+
state = { consecutive: 0, total: 0, tripped: false };
|
|
29
|
+
this.sessions.set(key, state);
|
|
30
|
+
}
|
|
31
|
+
return { ...state };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether the breaker is currently tripped for this session.
|
|
36
|
+
* @param {string} [sessionId]
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
isTripped(sessionId) {
|
|
40
|
+
const key = sessionId || "default";
|
|
41
|
+
const state = this.sessions.get(key);
|
|
42
|
+
return Boolean(state && state.tripped);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Record a security denial. Returns true if this call tripped the breaker.
|
|
47
|
+
* @param {string} [sessionId]
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
countDeny(sessionId) {
|
|
51
|
+
const key = sessionId || "default";
|
|
52
|
+
let state = this.sessions.get(key);
|
|
53
|
+
if (!state) {
|
|
54
|
+
state = { consecutive: 0, total: 0, tripped: false };
|
|
55
|
+
this.sessions.set(key, state);
|
|
56
|
+
}
|
|
57
|
+
state.consecutive += 1;
|
|
58
|
+
state.total += 1;
|
|
59
|
+
const wasTripped = state.tripped;
|
|
60
|
+
if (state.consecutive >= this.maxConsecutive || state.total >= this.maxTotal) {
|
|
61
|
+
state.tripped = true;
|
|
62
|
+
return !wasTripped;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reset the consecutive counter on an allowed operation.
|
|
69
|
+
* @param {string} [sessionId]
|
|
70
|
+
*/
|
|
71
|
+
resetConsecutive(sessionId) {
|
|
72
|
+
const key = sessionId || "default";
|
|
73
|
+
const state = this.sessions.get(key);
|
|
74
|
+
if (state) state.consecutive = 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Fully resume auto-mode after human approval.
|
|
79
|
+
* @param {string} [sessionId]
|
|
80
|
+
*/
|
|
81
|
+
resume(sessionId) {
|
|
82
|
+
const key = sessionId || "default";
|
|
83
|
+
const state = this.sessions.get(key);
|
|
84
|
+
if (state) {
|
|
85
|
+
state.consecutive = 0;
|
|
86
|
+
state.total = 0;
|
|
87
|
+
state.tripped = false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Clear all session tracking states.
|
|
93
|
+
*/
|
|
94
|
+
clear() {
|
|
95
|
+
this.sessions.clear();
|
|
96
|
+
}
|
|
97
|
+
}
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// lib/engine.js
|
|
2
|
+
// 5-tier Security Decision Engine for dsh-approval-gate.
|
|
3
|
+
// Waterfall:
|
|
4
|
+
// P0: Hard Deny (Bands Band 0 + inspectExecution hard denials + protected files)
|
|
5
|
+
// P1: Session Grants (scoped temporary allowances)
|
|
6
|
+
// P2: Static Rules / Safe Allow (Bands Band 1 + safe read-only commands)
|
|
7
|
+
// P3: Optional Semantic Classifier
|
|
8
|
+
// P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker)
|
|
9
|
+
|
|
10
|
+
import { BandsEngine } from "./bands.js";
|
|
11
|
+
import { CircuitBreaker } from "./breaker.js";
|
|
12
|
+
import { inspectExecution } from "./inspect.js";
|
|
13
|
+
|
|
14
|
+
export class SecurityEngine {
|
|
15
|
+
/**
|
|
16
|
+
* @param {{
|
|
17
|
+
* bands?: BandsEngine,
|
|
18
|
+
* breaker?: CircuitBreaker,
|
|
19
|
+
* inspectConfig?: { toolName?: string, fileWriteTools?: string[] },
|
|
20
|
+
* classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
|
|
21
|
+
* grantRegistry?: { decide: (tool: string, args: any) => "allow"|"no-match" }
|
|
22
|
+
* }} [options]
|
|
23
|
+
*/
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
this.bands = options.bands || new BandsEngine(options);
|
|
26
|
+
this.breaker = options.breaker || new CircuitBreaker(options);
|
|
27
|
+
this.inspectConfig = options.inspectConfig || {};
|
|
28
|
+
this.classifier = options.classifier || null;
|
|
29
|
+
this.grantRegistry = options.grantRegistry || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Evaluates an execution request through the P0-P4 waterfall.
|
|
34
|
+
*
|
|
35
|
+
* @param {{ name: string, arguments?: any }} execution - Tool call
|
|
36
|
+
* @param {{ sessionId?: string, knobs?: any }} [context] - Context options
|
|
37
|
+
* @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string }}
|
|
38
|
+
*/
|
|
39
|
+
decide(execution, context = {}) {
|
|
40
|
+
const sessionId = context.sessionId || "default";
|
|
41
|
+
const toolName = execution && execution.name;
|
|
42
|
+
const command = execution && execution.arguments && execution.arguments.command;
|
|
43
|
+
|
|
44
|
+
// Fast-path Band 0 Hard Deny
|
|
45
|
+
const bandVerdict = this.bands.evaluate(toolName, command);
|
|
46
|
+
if (bandVerdict === "deny") {
|
|
47
|
+
this.breaker.countDeny(sessionId);
|
|
48
|
+
return { verdict: "deny", stage: "P0", reason: "band-hard-deny", detail: command };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Inspect execution for hard denials and syntax checks
|
|
52
|
+
const hit = inspectExecution(execution, this.inspectConfig);
|
|
53
|
+
if (hit.deny) {
|
|
54
|
+
this.breaker.countDeny(sessionId);
|
|
55
|
+
return { verdict: "deny", stage: "P0", reason: hit.reason || "hard-deny", detail: hit.detail || hit.snippet };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// P1: Session Grants
|
|
59
|
+
if (this.grantRegistry && typeof this.grantRegistry.decide === "function") {
|
|
60
|
+
const grantMatch = this.grantRegistry.decide(toolName, execution.arguments);
|
|
61
|
+
if (grantMatch === "allow") {
|
|
62
|
+
this.breaker.resetConsecutive(sessionId);
|
|
63
|
+
return { verdict: "allow", stage: "P1", reason: "session-grant" };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// If Circuit Breaker is tripped, suspend auto-mode and force P4 Human Ask
|
|
68
|
+
if (this.breaker.isTripped(sessionId)) {
|
|
69
|
+
return {
|
|
70
|
+
verdict: "ask",
|
|
71
|
+
stage: "P4",
|
|
72
|
+
reason: "circuit-breaker-tripped",
|
|
73
|
+
detail: "Circuit breaker tripped due to repeated security denials. Manual confirmation required."
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// P2: Static Rules / Safe Allow
|
|
78
|
+
if (bandVerdict === "allow") {
|
|
79
|
+
this.breaker.resetConsecutive(sessionId);
|
|
80
|
+
return { verdict: "allow", stage: "P2", reason: "safe-allow" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// P3: Optional Semantic Classifier
|
|
84
|
+
if (this.classifier && typeof this.classifier === "function") {
|
|
85
|
+
try {
|
|
86
|
+
const cls = this.classifier(execution, context);
|
|
87
|
+
if (cls && cls.verdict === "allow") {
|
|
88
|
+
this.breaker.resetConsecutive(sessionId);
|
|
89
|
+
return { verdict: "allow", stage: "P3", reason: cls.reason || "classifier-allow" };
|
|
90
|
+
}
|
|
91
|
+
if (cls && cls.verdict === "deny") {
|
|
92
|
+
this.breaker.countDeny(sessionId);
|
|
93
|
+
return { verdict: "deny", stage: "P3", reason: cls.reason || "classifier-deny" };
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// Classifier error fails closed to P4 Ask
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// P4: Human Ask (uncertain syntax or unlisted command)
|
|
101
|
+
if (hit.ask) {
|
|
102
|
+
return { verdict: "ask", stage: "P4", reason: hit.reason || "uncertain-syntax", detail: hit.detail || hit.snippet };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Safe default for unclassified commands
|
|
106
|
+
return { verdict: "allow", stage: "P2", reason: "unmatched-allow" };
|
|
107
|
+
}
|
|
108
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -130,3 +130,9 @@ export function apply(ctx, config) {
|
|
|
130
130
|
}
|
|
131
131
|
return mount();
|
|
132
132
|
}
|
|
133
|
+
|
|
134
|
+
export { SecurityEngine } from "./engine.js";
|
|
135
|
+
export { BandsEngine } from "./bands.js";
|
|
136
|
+
export { CircuitBreaker } from "./breaker.js";
|
|
137
|
+
export { expandHome } from "./paths.js";
|
|
138
|
+
export { splitSubcommands } from "./tokenizer.js";
|