@goodandready/dsh-approval-gate 0.1.11 → 0.1.13
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 +20 -0
- package/lib/bands.js +16 -0
- package/lib/dispatcher.js +208 -0
- package/lib/engine.js +6 -1
- package/lib/index.js +139 -59
- package/lib/inspect.js +103 -160
- package/lib/loopback.js +85 -0
- package/lib/messages.js +43 -58
- package/lib/parsers/git.js +321 -0
- package/lib/parsers/shell-cmds.js +267 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.13
|
|
4
|
+
|
|
5
|
+
- Architecture: выделение `CommandDispatcher` (`lib/dispatcher.js`) для полной изоляции синтаксического разбора аргументов и классификации типов инструментов от движка принятия решений `SecurityEngine` (Refs: #133).
|
|
6
|
+
- Git: специализированный парсер семантики команд Git (`lib/parsers/git.js`) с разграничением безопасных операций чтения (`status`, `log`, `diff`, `rev-parse`), локальных мутаций (`commit`, `checkout -b`, `add`, `branch`) и деструктивных действий (`reset --hard`, `clean -f`, `branch -D`) (Refs: #125).
|
|
7
|
+
- Git: разделение локальных операций и сетевой публикации — автоматическое разрешение локальных коммитов и веток при обязательном запросе подтверждения (`git-remote-push`) для `git push` в удаленный репозиторий (Refs: #103).
|
|
8
|
+
- Shell: специализированный парсер конструкций оболочки (`lib/parsers/shell-cmds.js`) для детекции опасных перенаправлений в системные блочные устройства (`/dev/sd*`), защищенные файлы секретов и предотвращения обхода через traversal (Refs: #126).
|
|
9
|
+
- Security: `SilentErrorGuard` — детекция маскировки критических ошибок компиляции и сбоев команд через подавление потока stderr (`2>/dev/null`, `&>/dev/null`), принудительный нулевой код возврата (`|| true`, `|| :`, `|| exit 0`) и отключение проверки ошибок (`set +e`) (Refs: #90).
|
|
10
|
+
- Security: строгая изоляция агента-ревьюера (`Reviewer`) в режиме read-only без возможности запуска shell-процессов или записи/модификации файлов (Refs: #65).
|
|
11
|
+
- Security: предотвращение рекурсивной или системной модификации прав и владельцев (`chmod -R`, `chown -R`, манипуляции над `/`, `/etc`, `/var`, `/usr`, `/root`) (Refs: #65).
|
|
12
|
+
- Network: безопасный loopback bypass (`lib/loopback.js`) с нулевой задержкой и автоматическим разрешением запросов к `localhost`, `127.0.0.1`, `::1`, `0.0.0.0` для локальных dev-серверов и тестов (Refs: #40).
|
|
13
|
+
|
|
14
|
+
## 0.1.12
|
|
15
|
+
|
|
16
|
+
- Fix: корректная регистрация сервиса `approvalGate` в Cordis через `ctx.provide('approvalGate', ...)` и экспорт `export const provide = ['approvalGate']`, устраняющая ошибку `cannot set property "approvalGate" without provide` при старте хоста DSH.
|
|
17
|
+
- Policy: надежное определение режима «Полный доступ» (`danger-full-access` / `never`) в `readKnobs` и `isUnattended` даже при отсутствии предварительного события `approval/policy` в сессии (приоритет пресета и sandbox режима над дефолтным `ask` от `ApprovalService`), предотвращающая ложный отказ `the user rejected tool` на всех командах агента.
|
|
18
|
+
- Localization: удаление захардкоженного русского словаря из `lib/messages.js` в строгом соответствии со стандартом разработки DSH плагинов (только `en` и `zh`, русская локализация централизованно обслуживается `dsh-russian-lang`) с защитной обработкой `locale.register` в `apply` (Refs: #153, #162).
|
|
19
|
+
- Bands: динамическое расширение списка read-only инструментов из хостового `toolRegistry` (`options.toolRegistry`) с поддержкой аннотаций `readOnly` / `readonly` (Refs: #156).
|
|
20
|
+
- Engine: настраиваемый дефолтный вердикт для нераспознанных команд `defaultVerdict` (`allow` по умолчанию, опциональный fail-closed `ask`) с добавлением в схему `Config` (Refs: #163).
|
|
21
|
+
- Session: гарантированное извлечение строкового `sessionId` в `resolveSessionId` для любых структур объекта `agent` / `session`.
|
|
22
|
+
|
|
3
23
|
## 0.1.11
|
|
4
24
|
|
|
5
25
|
- Security: проверка абсолютных путей перенаправлений shell на выход за пределы рабочего каталога `workspaceDir` с исключением canonical устройств (`/dev/null`) и временных каталогов (`/tmp`) (Refs: #143).
|
package/lib/bands.js
CHANGED
|
@@ -134,6 +134,22 @@ export class BandsEngine {
|
|
|
134
134
|
typeof g === "string" ? compileGlob(g) : g
|
|
135
135
|
);
|
|
136
136
|
this.readOnlyTools = new Set(options.readOnlyTools || DEFAULT_READ_ONLY_TOOLS);
|
|
137
|
+
|
|
138
|
+
// Derive read-only tools dynamically from host tool registry if provided (#156)
|
|
139
|
+
if (options.toolRegistry) {
|
|
140
|
+
try {
|
|
141
|
+
const tools = typeof options.toolRegistry.getTools === "function"
|
|
142
|
+
? options.toolRegistry.getTools()
|
|
143
|
+
: (Array.isArray(options.toolRegistry) ? options.toolRegistry : []);
|
|
144
|
+
for (const t of tools) {
|
|
145
|
+
if (t && (t.readOnly || t.readonly || t.isReadOnly) && typeof t.name === "string") {
|
|
146
|
+
this.readOnlyTools.add(t.name);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// ignore
|
|
151
|
+
}
|
|
152
|
+
}
|
|
137
153
|
}
|
|
138
154
|
|
|
139
155
|
/**
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// lib/dispatcher.js
|
|
2
|
+
// Command Dispatcher: architectural isolation of command parsing and syntactic dispatch
|
|
3
|
+
// from the safety evaluation engine.
|
|
4
|
+
// Resolves: #133, #65
|
|
5
|
+
|
|
6
|
+
import { tokenize, splitSubcommands } from "./tokenizer.js";
|
|
7
|
+
import { parseGitArgv, classifyGitOperation } from "./parsers/git.js";
|
|
8
|
+
import { extractRedirects, inspectRedirectSecurity, inspectSilentErrors, inspectPipelines } from "./parsers/shell-cmds.js";
|
|
9
|
+
import { isLoopbackCommand, isLoopbackUrl } from "./loopback.js";
|
|
10
|
+
import { resolveSafePath, isPathTraversal, isProtectedConfigPath } from "./paths.js";
|
|
11
|
+
|
|
12
|
+
export const TOOL_KIND_SHELL = "shell";
|
|
13
|
+
export const TOOL_KIND_FILE_WRITE = "file_write";
|
|
14
|
+
export const TOOL_KIND_FILE_READ = "file_read";
|
|
15
|
+
export const TOOL_KIND_NETWORK = "network";
|
|
16
|
+
export const TOOL_KIND_GENERIC = "generic";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_BASH_TOOL = "bash";
|
|
19
|
+
export const DEFAULT_FILE_WRITE_TOOLS = Object.freeze([
|
|
20
|
+
"write",
|
|
21
|
+
"edit",
|
|
22
|
+
"Write",
|
|
23
|
+
"Edit",
|
|
24
|
+
"str_replace_editor",
|
|
25
|
+
"str_replace",
|
|
26
|
+
"apply_patch",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
|
|
30
|
+
"read_file",
|
|
31
|
+
"view_file",
|
|
32
|
+
"read_url_content",
|
|
33
|
+
"search_web",
|
|
34
|
+
"list_dir",
|
|
35
|
+
"glob",
|
|
36
|
+
"grep",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_NETWORK_TOOLS = Object.freeze([
|
|
40
|
+
"fetch",
|
|
41
|
+
"web_fetch",
|
|
42
|
+
"http_request",
|
|
43
|
+
"read_url_content",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extracts target file path from various tool argument schemas.
|
|
48
|
+
*
|
|
49
|
+
* @param {any} args
|
|
50
|
+
* @returns {string | null}
|
|
51
|
+
*/
|
|
52
|
+
export function extractFilePath(args) {
|
|
53
|
+
if (typeof args === "string") return args;
|
|
54
|
+
if (!args || typeof args !== "object") return null;
|
|
55
|
+
const keys = [
|
|
56
|
+
"path",
|
|
57
|
+
"file_path",
|
|
58
|
+
"filePath",
|
|
59
|
+
"file",
|
|
60
|
+
"filename",
|
|
61
|
+
"target",
|
|
62
|
+
"to",
|
|
63
|
+
"TargetFile",
|
|
64
|
+
"AbsolutePath",
|
|
65
|
+
];
|
|
66
|
+
for (const k of keys) {
|
|
67
|
+
if (typeof args[k] === "string" && args[k].trim()) {
|
|
68
|
+
return args[k].trim();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Checks whether an execution originates from a designated read-only reviewer agent.
|
|
76
|
+
* Resolves: #65 (strict read-only reviewer subagent isolation).
|
|
77
|
+
*
|
|
78
|
+
* @param {any} execution
|
|
79
|
+
* @param {any} [options]
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
export function isReviewerAgent(execution, options = {}) {
|
|
83
|
+
if (options && options.reviewerMode === true) return true;
|
|
84
|
+
const agent = execution && execution.agent;
|
|
85
|
+
if (!agent) return false;
|
|
86
|
+
|
|
87
|
+
const role = typeof agent.role === "string" ? agent.role.toLowerCase() : "";
|
|
88
|
+
const name = typeof agent.name === "string" ? agent.name.toLowerCase() : "";
|
|
89
|
+
const type = typeof agent.type === "string" ? agent.type.toLowerCase() : "";
|
|
90
|
+
|
|
91
|
+
return role.includes("review") || name.includes("review") || type.includes("review");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class CommandDispatcher {
|
|
95
|
+
/**
|
|
96
|
+
* @param {{
|
|
97
|
+
* toolName?: string,
|
|
98
|
+
* fileWriteTools?: string[] | Set<string>,
|
|
99
|
+
* readOnlyTools?: string[] | Set<string>,
|
|
100
|
+
* networkTools?: string[] | Set<string>,
|
|
101
|
+
* workspaceDir?: string
|
|
102
|
+
* }} [options]
|
|
103
|
+
*/
|
|
104
|
+
constructor(options = {}) {
|
|
105
|
+
this.toolName = options.toolName || DEFAULT_BASH_TOOL;
|
|
106
|
+
this.fileWriteTools = new Set(options.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS);
|
|
107
|
+
this.readOnlyTools = new Set(options.readOnlyTools || DEFAULT_READ_ONLY_TOOLS);
|
|
108
|
+
this.networkTools = new Set(options.networkTools || DEFAULT_NETWORK_TOOLS);
|
|
109
|
+
this.workspaceDir = options.workspaceDir || process.cwd();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Categorizes the tool name into functional kind.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} name
|
|
116
|
+
* @returns {"shell" | "file_write" | "file_read" | "network" | "generic"}
|
|
117
|
+
*/
|
|
118
|
+
classifyTool(name) {
|
|
119
|
+
if (!name) return TOOL_KIND_GENERIC;
|
|
120
|
+
if (name === this.toolName) return TOOL_KIND_SHELL;
|
|
121
|
+
if (this.fileWriteTools.has(name)) return TOOL_KIND_FILE_WRITE;
|
|
122
|
+
if (this.readOnlyTools.has(name)) return TOOL_KIND_FILE_READ;
|
|
123
|
+
if (this.networkTools.has(name)) return TOOL_KIND_NETWORK;
|
|
124
|
+
return TOOL_KIND_GENERIC;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Dispatches and normalizes a tool execution request into a structured descriptor.
|
|
129
|
+
*
|
|
130
|
+
* @param {any} execution - The tool execution event carrying name, arguments, agent, session
|
|
131
|
+
* @param {any} [options] - Dynamic context overrides
|
|
132
|
+
* @returns {{
|
|
133
|
+
* tool: string,
|
|
134
|
+
* kind: "shell" | "file_write" | "file_read" | "network" | "generic",
|
|
135
|
+
* isMutating: boolean,
|
|
136
|
+
* reviewerViolation: boolean,
|
|
137
|
+
* command?: string,
|
|
138
|
+
* tokens?: any[],
|
|
139
|
+
* subcommands?: string[],
|
|
140
|
+
* git?: any,
|
|
141
|
+
* silentError?: any,
|
|
142
|
+
* loopback?: any,
|
|
143
|
+
* filePath?: string,
|
|
144
|
+
* pathSafety?: any
|
|
145
|
+
* }}
|
|
146
|
+
*/
|
|
147
|
+
dispatch(execution, options = {}) {
|
|
148
|
+
const name = execution && execution.name ? String(execution.name).trim() : "";
|
|
149
|
+
const args = execution ? execution.arguments : undefined;
|
|
150
|
+
const kind = this.classifyTool(name);
|
|
151
|
+
const isReviewer = isReviewerAgent(execution, options);
|
|
152
|
+
|
|
153
|
+
const isMutating = kind === TOOL_KIND_SHELL || kind === TOOL_KIND_FILE_WRITE;
|
|
154
|
+
const reviewerViolation = isReviewer && isMutating;
|
|
155
|
+
|
|
156
|
+
const descriptor = {
|
|
157
|
+
tool: name,
|
|
158
|
+
kind,
|
|
159
|
+
isMutating,
|
|
160
|
+
reviewerViolation,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (kind === TOOL_KIND_SHELL) {
|
|
164
|
+
const rawCommand = args && typeof args === "object" ? args.command : (typeof args === "string" ? args : "");
|
|
165
|
+
descriptor.command = typeof rawCommand === "string" ? rawCommand : "";
|
|
166
|
+
|
|
167
|
+
if (descriptor.command) {
|
|
168
|
+
const tokenized = tokenize(descriptor.command);
|
|
169
|
+
descriptor.tokens = tokenized.tokens || [];
|
|
170
|
+
descriptor.tokenizerOk = tokenized.ok;
|
|
171
|
+
descriptor.tokenizerReason = tokenized.reason;
|
|
172
|
+
|
|
173
|
+
// Subcommands breakdown
|
|
174
|
+
descriptor.subcommands = splitSubcommands(descriptor.command);
|
|
175
|
+
|
|
176
|
+
// Silent errors inspection (#90)
|
|
177
|
+
descriptor.silentError = inspectSilentErrors(descriptor.command);
|
|
178
|
+
|
|
179
|
+
// Git inspection (#125, #103)
|
|
180
|
+
if (/\bgit\b/.test(descriptor.command)) {
|
|
181
|
+
const firstWords = descriptor.command.trim().split(/\s+/);
|
|
182
|
+
descriptor.git = parseGitArgv(firstWords);
|
|
183
|
+
descriptor.gitClassification = classifyGitOperation(descriptor.git.verb, descriptor.git.args);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Loopback detection (#40)
|
|
187
|
+
descriptor.loopback = isLoopbackCommand(descriptor.command.trim().split(/\s+/));
|
|
188
|
+
}
|
|
189
|
+
} else if (kind === TOOL_KIND_FILE_WRITE) {
|
|
190
|
+
const filePath = extractFilePath(args);
|
|
191
|
+
descriptor.filePath = filePath;
|
|
192
|
+
if (filePath) {
|
|
193
|
+
const ws = options.workspaceDir || this.workspaceDir;
|
|
194
|
+
descriptor.pathSafety = resolveSafePath(filePath, ws);
|
|
195
|
+
descriptor.isProtected = isProtectedConfigPath(descriptor.pathSafety.canonical || filePath);
|
|
196
|
+
descriptor.isTraversal = !descriptor.pathSafety.safe;
|
|
197
|
+
}
|
|
198
|
+
} else if (kind === TOOL_KIND_NETWORK) {
|
|
199
|
+
const url = args && typeof args === "object" ? (args.url || args.Url || args.uri || args.Uri) : "";
|
|
200
|
+
descriptor.url = typeof url === "string" ? url : "";
|
|
201
|
+
if (descriptor.url) {
|
|
202
|
+
descriptor.isLoopback = isLoopbackUrl(descriptor.url);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return descriptor;
|
|
207
|
+
}
|
|
208
|
+
}
|
package/lib/engine.js
CHANGED
|
@@ -28,6 +28,7 @@ export class SecurityEngine {
|
|
|
28
28
|
this.inspectConfig = options.inspectConfig || {};
|
|
29
29
|
this.classifier = options.classifier || null;
|
|
30
30
|
this.grantRegistry = options.grantRegistry || new SessionGrantStore(options);
|
|
31
|
+
this.defaultVerdict = options.defaultVerdict || "allow";
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -114,7 +115,11 @@ export class SecurityEngine {
|
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
//
|
|
118
|
+
// Default verdict for unclassified commands (#163)
|
|
119
|
+
if (this.defaultVerdict === "ask") {
|
|
120
|
+
const askHit = { ask: true, reason: "unmatched-ask", snippet: command || toolName };
|
|
121
|
+
return { verdict: "ask", stage: "P4", reason: "unmatched-ask", hit: askHit };
|
|
122
|
+
}
|
|
118
123
|
return { verdict: "allow", stage: "P2", reason: "unmatched-allow", hit };
|
|
119
124
|
} catch (err) {
|
|
120
125
|
return {
|
package/lib/index.js
CHANGED
|
@@ -1,17 +1,5 @@
|
|
|
1
1
|
import { SecurityEngine } from "./engine.js";
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
// Registers tools.guard on the tool runtime and denies dangerous bash
|
|
5
|
-
// commands and writes to secret files even when sandbox mode would allow
|
|
6
|
-
// them. A guard return value (non-undefined) denies execution, so the
|
|
7
|
-
// dangerous call never runs on its own - it needs an explicit owner
|
|
8
|
-
// Approval is handled by DSH's native flow; no separate word is implemented. When the
|
|
9
|
-
// effective approval policy cannot be answered ("never", i.e. the full-access preset),
|
|
10
|
-
// the gate does not raise an ask at all: an allowed command proceeds instead of dying as
|
|
11
|
-
// a rejection nobody could consent to. Denials never depend on the policy.
|
|
12
|
-
//
|
|
13
|
-
// Only the agent tool calls pass through the guard (cron/systemd scripts
|
|
14
|
-
// do not), so legitimate unattended operations are unaffected.
|
|
2
|
+
import { BandsEngine } from "./bands.js";
|
|
15
3
|
import Schema from "@deepseek-ai/schemastery";
|
|
16
4
|
|
|
17
5
|
if (typeof Schema.prototype?.volatile !== "function") {
|
|
@@ -45,6 +33,7 @@ import { LOCALE_NS, MESSAGES } from "./messages.js";
|
|
|
45
33
|
|
|
46
34
|
export const name = "dsh-approval-gate";
|
|
47
35
|
export const inject = ["tools"];
|
|
36
|
+
export const provide = ["approvalGate"];
|
|
48
37
|
|
|
49
38
|
/** Policies under which a prompt cannot be answered, so raising one is a silent denial. */
|
|
50
39
|
export const DEFAULT_UNATTENDED_POLICIES = ["never"];
|
|
@@ -52,25 +41,48 @@ export const DEFAULT_UNATTENDED_POLICIES = ["never"];
|
|
|
52
41
|
/**
|
|
53
42
|
* Whether a raised ask would be answerable in this session.
|
|
54
43
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* @param {{policy?: string, unattendedPolicies?: string[]}} knobs
|
|
44
|
+
* Checks explicit unattended policies, as well as preset and sandbox modes.
|
|
45
|
+
* In 'danger-full-access' mode, approval prompts are disabled by host design.
|
|
46
|
+
* @param {{policy?: string, preset?: string, sandbox?: string, unattendedPolicies?: string[]}} knobs
|
|
58
47
|
* @returns {boolean}
|
|
59
48
|
*/
|
|
60
49
|
export function isUnattended(knobs) {
|
|
61
|
-
|
|
50
|
+
if (!knobs) return false;
|
|
51
|
+
if (knobs.preset === "danger-full-access" || knobs.sandbox === "danger-full-access") return true;
|
|
52
|
+
const policy = knobs.policy;
|
|
62
53
|
const policies = (knobs && knobs.unattendedPolicies) || DEFAULT_UNATTENDED_POLICIES;
|
|
63
54
|
if (typeof policy !== "string" || policy === "") return false;
|
|
64
55
|
return policies.includes(policy);
|
|
65
56
|
}
|
|
66
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Extracts a clean string session identifier from an execution object.
|
|
60
|
+
* @param {any} execution
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function resolveSessionId(execution) {
|
|
64
|
+
const agent = execution && execution.agent;
|
|
65
|
+
const session = agent && agent.session;
|
|
66
|
+
if (session) {
|
|
67
|
+
if (typeof session === "string") return session;
|
|
68
|
+
if (typeof session.id === "string") return session.id;
|
|
69
|
+
if (typeof session.sessionId === "string") return session.sessionId;
|
|
70
|
+
if (typeof session.sessionKey === "string") return session.sessionKey;
|
|
71
|
+
}
|
|
72
|
+
if (agent) {
|
|
73
|
+
if (typeof agent === "string") return agent;
|
|
74
|
+
if (typeof agent.id === "string") return agent.id;
|
|
75
|
+
}
|
|
76
|
+
return "default";
|
|
77
|
+
}
|
|
78
|
+
|
|
67
79
|
/**
|
|
68
80
|
* Effective permission knobs, as the harness exposes them.
|
|
69
|
-
* Resolves session-level approval policy
|
|
81
|
+
* Resolves session-level approval policy, preset, and sandbox mode.
|
|
70
82
|
*
|
|
71
83
|
* @param {any} ctx - Cordis context
|
|
72
84
|
* @param {any} [execution] - Optional tool execution carrying agent/session
|
|
73
|
-
* @returns {{ policy: string, sandbox: string }}
|
|
85
|
+
* @returns {{ policy: string, sandbox: string, preset: string }}
|
|
74
86
|
*/
|
|
75
87
|
export function readKnobs(ctx, execution) {
|
|
76
88
|
const read = (name) => {
|
|
@@ -87,75 +99,96 @@ export function readKnobs(ctx, execution) {
|
|
|
87
99
|
const session = agent && (agent.session || (typeof agent === "object" && typeof agent.eventAt === "function" ? agent : undefined));
|
|
88
100
|
|
|
89
101
|
let policy = "";
|
|
102
|
+
let preset = "";
|
|
103
|
+
let sessionSandbox = "";
|
|
90
104
|
|
|
91
|
-
// 1.
|
|
92
|
-
if (
|
|
93
|
-
try {
|
|
94
|
-
policy = approval.effectivePolicy(session) || "";
|
|
95
|
-
} catch {
|
|
96
|
-
// ignore
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// 2. Direct session property fallback
|
|
101
|
-
if (!policy && session && typeof session.approvalPolicy === "string") {
|
|
102
|
-
policy = session.approvalPolicy;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// 3. Fallback: inspect session events backwards for approval/policy and danger-full-access
|
|
106
|
-
if (!policy && session && typeof session.eventAt === "function" && typeof session.seq === "number") {
|
|
105
|
+
// 1. Inspect session events backwards (bounded to last 100 events) with sequence caching
|
|
106
|
+
if (session && typeof session.eventAt === "function" && typeof session.seq === "number") {
|
|
107
107
|
if (session._gateCachedSeq === session.seq && session._gateCachedPolicy !== undefined) {
|
|
108
108
|
policy = session._gateCachedPolicy;
|
|
109
|
+
preset = session._gateCachedPreset || "";
|
|
110
|
+
sessionSandbox = session._gateCachedSandbox || "";
|
|
109
111
|
} else {
|
|
110
112
|
try {
|
|
111
113
|
const minSeq = Math.max(0, session.seq - 100);
|
|
112
114
|
for (let seq = session.seq - 1; seq >= minSeq; seq -= 1) {
|
|
113
115
|
const event = session.eventAt(seq);
|
|
114
|
-
if (event && event.type === "approval/policy" && event.data && event.data.policy) {
|
|
116
|
+
if (!policy && event && event.type === "approval/policy" && event.data && event.data.policy) {
|
|
115
117
|
policy = event.data.policy;
|
|
116
|
-
break;
|
|
117
118
|
}
|
|
118
|
-
if (event && event.type === "permission/preset" && event.data && event.data.preset
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
if (!preset && event && event.type === "permission/preset" && event.data && event.data.preset) {
|
|
120
|
+
preset = event.data.preset;
|
|
121
|
+
}
|
|
122
|
+
if (!sessionSandbox && event && event.type === "sandbox/mode" && event.data && event.data.mode) {
|
|
123
|
+
sessionSandbox = event.data.mode;
|
|
121
124
|
}
|
|
125
|
+
if (policy && preset && sessionSandbox) break;
|
|
122
126
|
}
|
|
123
127
|
session._gateCachedSeq = session.seq;
|
|
124
128
|
session._gateCachedPolicy = policy || "";
|
|
129
|
+
session._gateCachedPreset = preset || "";
|
|
130
|
+
session._gateCachedSandbox = sessionSandbox || "";
|
|
125
131
|
} catch {
|
|
126
132
|
// ignore
|
|
127
133
|
}
|
|
128
134
|
}
|
|
129
135
|
}
|
|
130
136
|
|
|
131
|
-
//
|
|
132
|
-
if (
|
|
137
|
+
// 2. In-memory session.events array fallback (common in mocks / tests)
|
|
138
|
+
if (session && Array.isArray(session.events)) {
|
|
133
139
|
try {
|
|
134
140
|
const minIdx = Math.max(0, session.events.length - 100);
|
|
135
141
|
for (let i = session.events.length - 1; i >= minIdx; i -= 1) {
|
|
136
142
|
const event = session.events[i];
|
|
137
|
-
if (event && event.type === "approval/policy" && event.data && event.data.policy) {
|
|
143
|
+
if (!policy && event && event.type === "approval/policy" && event.data && event.data.policy) {
|
|
138
144
|
policy = event.data.policy;
|
|
139
|
-
break;
|
|
140
145
|
}
|
|
141
|
-
if (event && event.type === "permission/preset" && event.data && event.data.preset
|
|
142
|
-
|
|
143
|
-
|
|
146
|
+
if (!preset && event && event.type === "permission/preset" && event.data && event.data.preset) {
|
|
147
|
+
preset = event.data.preset;
|
|
148
|
+
}
|
|
149
|
+
if (!sessionSandbox && event && event.type === "sandbox/mode" && event.data && event.data.mode) {
|
|
150
|
+
sessionSandbox = event.data.mode;
|
|
144
151
|
}
|
|
152
|
+
if (policy && preset && sessionSandbox) break;
|
|
145
153
|
}
|
|
146
154
|
} catch {
|
|
147
155
|
// ignore
|
|
148
156
|
}
|
|
149
157
|
}
|
|
150
158
|
|
|
159
|
+
// 3. Preset / sandbox direct values: danger-full-access implies "never" approval
|
|
160
|
+
if (preset === "danger-full-access" || sessionSandbox === "danger-full-access") {
|
|
161
|
+
policy = "never";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 4. Direct session property fallback
|
|
165
|
+
if (!policy && session && typeof session.approvalPolicy === "string") {
|
|
166
|
+
policy = session.approvalPolicy;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 5. Live approval service effectivePolicy(session)
|
|
170
|
+
if (!policy && approval && session && typeof approval.effectivePolicy === "function") {
|
|
171
|
+
try {
|
|
172
|
+
policy = approval.effectivePolicy(session) || "";
|
|
173
|
+
} catch {
|
|
174
|
+
// ignore
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
151
178
|
// 6. Global static fallback from approval service config
|
|
152
179
|
if (!policy && approval && approval.config && approval.config.policy) {
|
|
153
180
|
policy = approval.config.policy;
|
|
154
181
|
}
|
|
155
182
|
|
|
183
|
+
const sandbox = sessionSandbox || (shell && shell.sandboxMode) || "";
|
|
184
|
+
if (sandbox === "danger-full-access" && !policy) {
|
|
185
|
+
policy = "never";
|
|
186
|
+
}
|
|
187
|
+
|
|
156
188
|
return {
|
|
157
189
|
policy: policy || "",
|
|
158
|
-
sandbox:
|
|
190
|
+
sandbox: sandbox || "",
|
|
191
|
+
preset: preset || "",
|
|
159
192
|
};
|
|
160
193
|
}
|
|
161
194
|
|
|
@@ -170,6 +203,8 @@ export const Config = Schema.object({
|
|
|
170
203
|
unattended: Schema.boolean().default(false).volatile(),
|
|
171
204
|
/** Workspace directory for path traversal boundary checks. */
|
|
172
205
|
workspaceDir: Schema.string().default("").volatile(),
|
|
206
|
+
/** Default verdict for unclassified commands: 'allow' (default) or 'ask' (fail-closed) (#163). */
|
|
207
|
+
defaultVerdict: Schema.union(["allow", "ask"]).default("allow").volatile(),
|
|
173
208
|
/** Security engine instance (internal/testing). */
|
|
174
209
|
engine: Schema.any().hidden(),
|
|
175
210
|
});
|
|
@@ -189,7 +224,15 @@ export function apply(ctx, config) {
|
|
|
189
224
|
};
|
|
190
225
|
const registerLocale = function () {
|
|
191
226
|
const locale = getLocale();
|
|
192
|
-
if (locale && typeof locale.register === "function")
|
|
227
|
+
if (locale && typeof locale.register === "function") {
|
|
228
|
+
try {
|
|
229
|
+
return locale.register(LOCALE_NS, MESSAGES);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
if (ctx && ctx.logger && typeof ctx.logger.warn === "function") {
|
|
232
|
+
ctx.logger.warn("[dsh-approval-gate] locale registration skipped: " + (err && err.message || err));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
193
236
|
};
|
|
194
237
|
if (ctx && typeof ctx.effect === "function") ctx.effect(registerLocale, "dsh-approval-gate: dictionaries");
|
|
195
238
|
else registerLocale();
|
|
@@ -203,13 +246,21 @@ export function apply(ctx, config) {
|
|
|
203
246
|
const forcedUnattended = cfg.unattended === true;
|
|
204
247
|
const engine = (config && config.engine) || cfg.engine || new SecurityEngine({
|
|
205
248
|
inspectConfig: { toolName: toolName, fileWriteTools: fileWriteTools, workspaceDir: cfg.workspaceDir || "" },
|
|
249
|
+
bands: new BandsEngine({ toolRegistry: ctx && ctx.tools }),
|
|
250
|
+
defaultVerdict: cfg.defaultVerdict || "allow",
|
|
206
251
|
});
|
|
252
|
+
|
|
253
|
+
const gateService = {
|
|
254
|
+
engine,
|
|
255
|
+
addGrant: (options) => engine.addGrant(options),
|
|
256
|
+
resume: (sessionId, reason) => engine.resume(sessionId, reason),
|
|
257
|
+
};
|
|
207
258
|
if (ctx && typeof ctx === "object") {
|
|
208
|
-
ctx.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
259
|
+
if (typeof ctx.provide === "function") {
|
|
260
|
+
ctx.provide("approvalGate", gateService);
|
|
261
|
+
} else {
|
|
262
|
+
ctx.approvalGate = gateService;
|
|
263
|
+
}
|
|
213
264
|
}
|
|
214
265
|
|
|
215
266
|
const hasPreExecute = ctx && typeof ctx.on === "function";
|
|
@@ -217,7 +268,7 @@ export function apply(ctx, config) {
|
|
|
217
268
|
ctx.on("tools/pre-execute", function (execution, next) {
|
|
218
269
|
const knobs = readKnobs(ctx, execution);
|
|
219
270
|
const who = sessionTag(execution);
|
|
220
|
-
const sessionId = (execution
|
|
271
|
+
const sessionId = resolveSessionId(execution);
|
|
221
272
|
let decision;
|
|
222
273
|
try {
|
|
223
274
|
decision = engine.decide(execution, { sessionId, knobs });
|
|
@@ -242,7 +293,7 @@ export function apply(ctx, config) {
|
|
|
242
293
|
logBlocked(ctx, hit, who);
|
|
243
294
|
return denyMessage(hit, who, translate, ctx && ctx.logger);
|
|
244
295
|
}
|
|
245
|
-
if (forcedUnattended || isUnattended({ policy: knobs.policy, unattendedPolicies })) {
|
|
296
|
+
if (forcedUnattended || isUnattended({ policy: knobs.policy, preset: knobs.preset, sandbox: knobs.sandbox, unattendedPolicies })) {
|
|
246
297
|
return next();
|
|
247
298
|
}
|
|
248
299
|
const hit = decision.hit || {
|
|
@@ -259,7 +310,7 @@ export function apply(ctx, config) {
|
|
|
259
310
|
|
|
260
311
|
const guard = function (execution) {
|
|
261
312
|
const who = sessionTag(execution);
|
|
262
|
-
const sessionId = (execution
|
|
313
|
+
const sessionId = resolveSessionId(execution);
|
|
263
314
|
let decision;
|
|
264
315
|
try {
|
|
265
316
|
decision = engine.decide(execution, { sessionId });
|
|
@@ -318,6 +369,35 @@ export {
|
|
|
318
369
|
} from "./canonical.js";
|
|
319
370
|
export { expandHome, resolveSafePath, isPathTraversal, canonicalizeFsPath } from "./paths.js";
|
|
320
371
|
export { splitSubcommands } from "./tokenizer.js";
|
|
321
|
-
|
|
322
|
-
|
|
323
372
|
export { redactCredentials, redactUrl } from "./redact.js";
|
|
373
|
+
export {
|
|
374
|
+
CommandDispatcher,
|
|
375
|
+
isReviewerAgent,
|
|
376
|
+
extractFilePath,
|
|
377
|
+
TOOL_KIND_SHELL,
|
|
378
|
+
TOOL_KIND_FILE_WRITE,
|
|
379
|
+
TOOL_KIND_FILE_READ,
|
|
380
|
+
TOOL_KIND_NETWORK,
|
|
381
|
+
TOOL_KIND_GENERIC,
|
|
382
|
+
} from "./dispatcher.js";
|
|
383
|
+
export {
|
|
384
|
+
parseGitArgv,
|
|
385
|
+
classifyGitOperation,
|
|
386
|
+
inspectGitCommand,
|
|
387
|
+
GIT_CATEGORY_READ,
|
|
388
|
+
GIT_CATEGORY_MUTATE_LOCAL,
|
|
389
|
+
GIT_CATEGORY_REMOTE,
|
|
390
|
+
GIT_CATEGORY_DESTRUCTIVE,
|
|
391
|
+
} from "./parsers/git.js";
|
|
392
|
+
export {
|
|
393
|
+
isDevicePath,
|
|
394
|
+
extractRedirects,
|
|
395
|
+
inspectRedirectSecurity,
|
|
396
|
+
inspectSilentErrors,
|
|
397
|
+
inspectPipelines,
|
|
398
|
+
} from "./parsers/shell-cmds.js";
|
|
399
|
+
export {
|
|
400
|
+
isLoopbackHost,
|
|
401
|
+
isLoopbackUrl,
|
|
402
|
+
isLoopbackCommand,
|
|
403
|
+
} from "./loopback.js";
|