@goodandready/dsh-approval-gate 0.1.9 → 0.1.11
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 -0
- package/lib/bands.js +11 -5
- package/lib/breaker.js +22 -11
- package/lib/engine.js +30 -1
- package/lib/index.js +155 -13
- package/lib/inspect.js +25 -24
- package/lib/messages.js +5 -1
- package/lib/paths.js +1 -1
- package/lib/tokenizer.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.11
|
|
4
|
+
|
|
5
|
+
- Security: проверка абсолютных путей перенаправлений shell на выход за пределы рабочего каталога `workspaceDir` с исключением canonical устройств (`/dev/null`) и временных каталогов (`/tmp`) (Refs: #143).
|
|
6
|
+
- Security: инспекция инструмента `str_replace_editor` как модификатора файлов с проверкой секретов и границ каталога (Refs: #144).
|
|
7
|
+
- Security: блокировка обхода через составные префиксы `{`, `}`, `!` в начале shell-команд (Refs: #147).
|
|
8
|
+
- Bands: строгий отказ Band 1 Safe Allow для составных команд, конвейеров и операторов цепочек (`&&`, `||`, `;`, `|`, `>`, `$()`) с перенаправлением на полную сегментную инспекцию (Refs: #155, #158).
|
|
9
|
+
- Paths: безопасное раскрытие переменной `$HOME` с привязкой к границе сегмента пути (`/\${HOME\}|\$HOME(?=$|[\/\\])/g`), предотвращающее повреждение путей вроде `/opt/$HOME-backup` (Refs: #154).
|
|
10
|
+
- Engine: строгий fail-closed fallback в `P4 Ask` при исключениях семантического классификатора (`reason: 'classifier-error'`) и полная изоляция `decide()` и хуков рантайма в защитные блоки `try/catch` с гарантией `inspect-error` (Refs: #145, #159, #161).
|
|
11
|
+
- Breaker: блокировка обхода сработавшего Circuit Breaker в unattended/full-access режиме — взведенный предохранитель принудительно возвращает `deny` вместо пропуска в `next()` (Refs: #157, #160).
|
|
12
|
+
- Breaker: ограничение размера истории состояний (`MAX_TRACKED_SESSIONS = 500`, `MAX_SESSION_HISTORY = 100`) и пассивные методы проверки без утечки памяти (Refs: #149).
|
|
13
|
+
- Policy: оптимизация сканирования истории событий сессии в `readKnobs` — однопроходный поиск, ограничение глубины до 100 событий и кэширование политики сессии на `_gateCachedPolicy` (Refs: #148).
|
|
14
|
+
- Schema: поддержка `.volatile()` на полях конфигурации, явные поля `workspaceDir` и `engine`, полифилл для DSH UI формы настроек и функция разворачивания реактивных боксов `plainConfig` (Refs: #142, #150, #151).
|
|
15
|
+
- Engine: методы `addGrant`, `getGrants` и `resume` на `SecurityEngine` и экспорт `ctx.approvalGate` для управления грантами и сбросом предохранителя (Refs: #152).
|
|
16
|
+
- Tokenizer: исправление передачи маркера heredoc (`marker: heredoc.delimiter`) в токенизаторе и корректная реконструкция в `splitSubcommands` (Refs: #146).
|
|
17
|
+
- Tests: матрица проверок на обходные векторы через операторы конвейеров и allow-префиксы (Refs: #164).
|
|
18
|
+
|
|
19
|
+
## 0.1.10
|
|
20
|
+
|
|
21
|
+
- Policy: точное определение сессионной политики апрува в `readKnobs` через `approval.effectivePolicy(session)`, историю событий `session.eventAt` и пресет `danger-full-access` (Refs: #140).
|
|
22
|
+
- Execution: корректный пропуск некритичных команд в режиме «Полный доступ» (`never`) без ложного поднятия `ask` и автоматического отказа DSH `Error: the user rejected tool` (Refs: #140).
|
|
23
|
+
- Observability: форматирование `sessionTag` с получением строкового идентификатора сессии `session.id` / `session.name` вместо `[object Object]` (Refs: #140).
|
|
24
|
+
|
|
3
25
|
## 0.1.9
|
|
4
26
|
|
|
5
27
|
- Inspect: декомпозиция `lib/inspect.js` с выносом `checkEnvExfiltration` в `lib/inspect-env.js`, строгое соблюдение порога 600 строк (Refs: #35).
|
package/lib/bands.js
CHANGED
|
@@ -153,6 +153,10 @@ export class BandsEngine {
|
|
|
153
153
|
isSafeInspectionCommand(command) {
|
|
154
154
|
if (typeof command !== "string" || command.trim() === "") return false;
|
|
155
155
|
const text = command.trim();
|
|
156
|
+
// Chained commands, subshells, substitutions, and redirections cannot match whole-string safe-allow
|
|
157
|
+
if (/[\n;&|`<>]|\$\([\s\S]*\)|\$\{[\s\S]*\}/.test(text)) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
156
160
|
for (const pattern of this.denyPatterns) {
|
|
157
161
|
if (pattern.test(text)) return false;
|
|
158
162
|
}
|
|
@@ -203,10 +207,12 @@ export class BandsEngine {
|
|
|
203
207
|
}
|
|
204
208
|
}
|
|
205
209
|
|
|
206
|
-
// Band 1: Safe Allow
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
+
// Band 1: Safe Allow (applies strictly to single commands without chaining, subshells or redirects)
|
|
211
|
+
if (!/[\n;&|`<>]|\$\([\s\S]*\)|\$\{[\s\S]*\}/.test(text)) {
|
|
212
|
+
for (const glob of this.allowGlobs) {
|
|
213
|
+
if (glob.test(text)) {
|
|
214
|
+
return "allow";
|
|
215
|
+
}
|
|
210
216
|
}
|
|
211
217
|
}
|
|
212
218
|
}
|
|
@@ -214,4 +220,4 @@ export class BandsEngine {
|
|
|
214
220
|
return "unmatched";
|
|
215
221
|
}
|
|
216
222
|
}
|
|
217
|
-
|
|
223
|
+
|
package/lib/breaker.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
export const DEFAULT_MAX_CONSECUTIVE_DENIES = 3;
|
|
6
6
|
export const DEFAULT_MAX_TOTAL_DENIES = 20;
|
|
7
|
+
export const MAX_TRACKED_SESSIONS = 500;
|
|
8
|
+
export const MAX_SESSION_HISTORY = 100;
|
|
7
9
|
|
|
8
10
|
export const CIRCUIT_STATE_ACTIVE = "active";
|
|
9
11
|
export const CIRCUIT_STATE_TRIPPED = "tripped";
|
|
@@ -70,6 +72,9 @@ export class CircuitBreaker {
|
|
|
70
72
|
|
|
71
73
|
sessionState.state = to;
|
|
72
74
|
sessionState.history.push(event);
|
|
75
|
+
if (sessionState.history.length > MAX_SESSION_HISTORY) {
|
|
76
|
+
sessionState.history.shift();
|
|
77
|
+
}
|
|
73
78
|
|
|
74
79
|
if (this.logger) {
|
|
75
80
|
const logLine = `[dsh-approval-gate:breaker] session=${sessionId} state=${to} (was ${from}) reason=${reason} consecutive=${sessionState.consecutive} total=${sessionState.total}`;
|
|
@@ -97,6 +102,10 @@ export class CircuitBreaker {
|
|
|
97
102
|
const key = sessionId || "default";
|
|
98
103
|
let state = this.sessions.get(key);
|
|
99
104
|
if (!state) {
|
|
105
|
+
if (this.sessions.size >= MAX_TRACKED_SESSIONS) {
|
|
106
|
+
const oldestKey = this.sessions.keys().next().value;
|
|
107
|
+
this.sessions.delete(oldestKey);
|
|
108
|
+
}
|
|
100
109
|
state = {
|
|
101
110
|
consecutive: 0,
|
|
102
111
|
total: 0,
|
|
@@ -115,12 +124,11 @@ export class CircuitBreaker {
|
|
|
115
124
|
* @returns {{ consecutive: number, total: number, tripped: boolean }}
|
|
116
125
|
*/
|
|
117
126
|
get(sessionId) {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
total: state.total,
|
|
122
|
-
tripped:
|
|
123
|
-
};
|
|
127
|
+
const key = sessionId || "default";
|
|
128
|
+
const state = this.sessions.get(key);
|
|
129
|
+
return state
|
|
130
|
+
? { consecutive: state.consecutive, total: state.total, tripped: state.tripped }
|
|
131
|
+
: { consecutive: 0, total: 0, tripped: false };
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
/**
|
|
@@ -129,8 +137,9 @@ export class CircuitBreaker {
|
|
|
129
137
|
* @returns {string}
|
|
130
138
|
*/
|
|
131
139
|
getState(sessionId) {
|
|
132
|
-
const
|
|
133
|
-
|
|
140
|
+
const key = sessionId || "default";
|
|
141
|
+
const state = this.sessions.get(key);
|
|
142
|
+
return state ? state.state : CIRCUIT_STATE_ACTIVE;
|
|
134
143
|
}
|
|
135
144
|
|
|
136
145
|
/**
|
|
@@ -139,8 +148,9 @@ export class CircuitBreaker {
|
|
|
139
148
|
* @returns {CircuitTransitionEvent[]}
|
|
140
149
|
*/
|
|
141
150
|
getHistory(sessionId) {
|
|
142
|
-
const
|
|
143
|
-
|
|
151
|
+
const key = sessionId || "default";
|
|
152
|
+
const state = this.sessions.get(key);
|
|
153
|
+
return state ? state.history.map((ev) => ({ ...ev })) : [];
|
|
144
154
|
}
|
|
145
155
|
|
|
146
156
|
/**
|
|
@@ -149,7 +159,8 @@ export class CircuitBreaker {
|
|
|
149
159
|
* @returns {boolean}
|
|
150
160
|
*/
|
|
151
161
|
isTripped(sessionId) {
|
|
152
|
-
const
|
|
162
|
+
const key = sessionId || "default";
|
|
163
|
+
const state = this.sessions.get(key);
|
|
153
164
|
return Boolean(state && state.tripped);
|
|
154
165
|
}
|
|
155
166
|
|
package/lib/engine.js
CHANGED
|
@@ -38,7 +38,8 @@ export class SecurityEngine {
|
|
|
38
38
|
* @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string, hit?: any }}
|
|
39
39
|
*/
|
|
40
40
|
decide(execution, context = {}) {
|
|
41
|
-
|
|
41
|
+
try {
|
|
42
|
+
const sessionId = context.sessionId || "default";
|
|
42
43
|
const toolName = execution && execution.name;
|
|
43
44
|
const command = execution && execution.arguments && execution.arguments.command;
|
|
44
45
|
|
|
@@ -109,10 +110,38 @@ export class SecurityEngine {
|
|
|
109
110
|
}
|
|
110
111
|
} catch (err) {
|
|
111
112
|
// Classifier error fails closed to P4 Ask
|
|
113
|
+
return { verdict: "ask", stage: "P4", reason: "classifier-error", detail: String(err && err.message || err), hit: null };
|
|
112
114
|
}
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
// Safe default for unclassified commands
|
|
116
118
|
return { verdict: "allow", stage: "P2", reason: "unmatched-allow", hit };
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return {
|
|
121
|
+
verdict: "ask",
|
|
122
|
+
stage: "P4",
|
|
123
|
+
reason: "inspect-error",
|
|
124
|
+
detail: String(err && err.message || err),
|
|
125
|
+
hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
addGrant(options) {
|
|
131
|
+
if (this.grantRegistry && typeof this.grantRegistry.addGrant === "function") {
|
|
132
|
+
return this.grantRegistry.addGrant(options);
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
getGrants(sessionId) {
|
|
138
|
+
if (this.grantRegistry && typeof this.grantRegistry.getGrants === "function") {
|
|
139
|
+
return this.grantRegistry.getGrants(sessionId);
|
|
140
|
+
}
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
resume(sessionId, reason) {
|
|
145
|
+
return this.breaker.resume(sessionId, reason);
|
|
117
146
|
}
|
|
118
147
|
}
|
package/lib/index.js
CHANGED
|
@@ -13,6 +13,26 @@ import { SecurityEngine } from "./engine.js";
|
|
|
13
13
|
// Only the agent tool calls pass through the guard (cron/systemd scripts
|
|
14
14
|
// do not), so legitimate unattended operations are unaffected.
|
|
15
15
|
import Schema from "@deepseek-ai/schemastery";
|
|
16
|
+
|
|
17
|
+
if (typeof Schema.prototype?.volatile !== "function") {
|
|
18
|
+
if (Schema.prototype) {
|
|
19
|
+
Schema.prototype.volatile = function volatile() {
|
|
20
|
+
if (this.meta && this.meta.volatile) return this;
|
|
21
|
+
return typeof this.extra === "function" ? this.extra("volatile", true) : this;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isVolatileRef(value) {
|
|
27
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && typeof value.get === "function";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function plainConfig(value) {
|
|
31
|
+
if (isVolatileRef(value)) return plainConfig(value.get());
|
|
32
|
+
if (value === null || typeof value !== "object") return value;
|
|
33
|
+
if (Array.isArray(value)) return value.map(plainConfig);
|
|
34
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]));
|
|
35
|
+
}
|
|
16
36
|
import {
|
|
17
37
|
DEFAULT_BASH_TOOL,
|
|
18
38
|
DEFAULT_FILE_WRITE_TOOLS,
|
|
@@ -44,8 +64,15 @@ export function isUnattended(knobs) {
|
|
|
44
64
|
return policies.includes(policy);
|
|
45
65
|
}
|
|
46
66
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Effective permission knobs, as the harness exposes them.
|
|
69
|
+
* Resolves session-level approval policy if an active execution carrying a session is provided.
|
|
70
|
+
*
|
|
71
|
+
* @param {any} ctx - Cordis context
|
|
72
|
+
* @param {any} [execution] - Optional tool execution carrying agent/session
|
|
73
|
+
* @returns {{ policy: string, sandbox: string }}
|
|
74
|
+
*/
|
|
75
|
+
export function readKnobs(ctx, execution) {
|
|
49
76
|
const read = (name) => {
|
|
50
77
|
try {
|
|
51
78
|
return ctx && typeof ctx.get === "function" ? ctx.get(name) : undefined;
|
|
@@ -55,21 +82,96 @@ function readKnobs(ctx) {
|
|
|
55
82
|
};
|
|
56
83
|
const approval = read("approval");
|
|
57
84
|
const shell = read("shell");
|
|
85
|
+
|
|
86
|
+
const agent = execution && execution.agent;
|
|
87
|
+
const session = agent && (agent.session || (typeof agent === "object" && typeof agent.eventAt === "function" ? agent : undefined));
|
|
88
|
+
|
|
89
|
+
let policy = "";
|
|
90
|
+
|
|
91
|
+
// 1. Live approval service effectivePolicy(session)
|
|
92
|
+
if (approval && session && typeof approval.effectivePolicy === "function") {
|
|
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") {
|
|
107
|
+
if (session._gateCachedSeq === session.seq && session._gateCachedPolicy !== undefined) {
|
|
108
|
+
policy = session._gateCachedPolicy;
|
|
109
|
+
} else {
|
|
110
|
+
try {
|
|
111
|
+
const minSeq = Math.max(0, session.seq - 100);
|
|
112
|
+
for (let seq = session.seq - 1; seq >= minSeq; seq -= 1) {
|
|
113
|
+
const event = session.eventAt(seq);
|
|
114
|
+
if (event && event.type === "approval/policy" && event.data && event.data.policy) {
|
|
115
|
+
policy = event.data.policy;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
if (event && event.type === "permission/preset" && event.data && event.data.preset === "danger-full-access") {
|
|
119
|
+
policy = "never";
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
session._gateCachedSeq = session.seq;
|
|
124
|
+
session._gateCachedPolicy = policy || "";
|
|
125
|
+
} catch {
|
|
126
|
+
// ignore
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 4. In-memory session.events array fallback (common in mocks / tests)
|
|
132
|
+
if (!policy && session && Array.isArray(session.events)) {
|
|
133
|
+
try {
|
|
134
|
+
const minIdx = Math.max(0, session.events.length - 100);
|
|
135
|
+
for (let i = session.events.length - 1; i >= minIdx; i -= 1) {
|
|
136
|
+
const event = session.events[i];
|
|
137
|
+
if (event && event.type === "approval/policy" && event.data && event.data.policy) {
|
|
138
|
+
policy = event.data.policy;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
if (event && event.type === "permission/preset" && event.data && event.data.preset === "danger-full-access") {
|
|
142
|
+
policy = "never";
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// ignore
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 6. Global static fallback from approval service config
|
|
152
|
+
if (!policy && approval && approval.config && approval.config.policy) {
|
|
153
|
+
policy = approval.config.policy;
|
|
154
|
+
}
|
|
155
|
+
|
|
58
156
|
return {
|
|
59
|
-
policy:
|
|
157
|
+
policy: policy || "",
|
|
60
158
|
sandbox: (shell && shell.sandboxMode) || "",
|
|
61
159
|
};
|
|
62
160
|
}
|
|
63
161
|
|
|
64
162
|
export const Config = Schema.object({
|
|
65
163
|
/** Name of the tool whose `command` arg is inspected (the DSH bash tool). */
|
|
66
|
-
toolName: Schema.string().default(DEFAULT_BASH_TOOL),
|
|
164
|
+
toolName: Schema.string().default(DEFAULT_BASH_TOOL).volatile(),
|
|
67
165
|
/** Extra tool names treated as file writes; paths are matched against secret files. */
|
|
68
|
-
fileWriteTools: Schema.array(Schema.string()).default(DEFAULT_FILE_WRITE_TOOLS.slice()),
|
|
166
|
+
fileWriteTools: Schema.array(Schema.string()).default(DEFAULT_FILE_WRITE_TOOLS.slice()).volatile(),
|
|
69
167
|
/** Approval policies under which an ask could not be answered, so it is not raised. */
|
|
70
|
-
unattendedPolicies: Schema.array(Schema.string()).default(DEFAULT_UNATTENDED_POLICIES.slice()),
|
|
168
|
+
unattendedPolicies: Schema.array(Schema.string()).default(DEFAULT_UNATTENDED_POLICIES.slice()).volatile(),
|
|
71
169
|
/** Force unattended behaviour where the approval policy is not visible. */
|
|
72
|
-
unattended: Schema.boolean().default(false),
|
|
170
|
+
unattended: Schema.boolean().default(false).volatile(),
|
|
171
|
+
/** Workspace directory for path traversal boundary checks. */
|
|
172
|
+
workspaceDir: Schema.string().default("").volatile(),
|
|
173
|
+
/** Security engine instance (internal/testing). */
|
|
174
|
+
engine: Schema.any().hidden(),
|
|
73
175
|
});
|
|
74
176
|
|
|
75
177
|
function logBlocked(ctx, hit, who) {
|
|
@@ -78,7 +180,7 @@ function logBlocked(ctx, hit, who) {
|
|
|
78
180
|
}
|
|
79
181
|
|
|
80
182
|
export function apply(ctx, config) {
|
|
81
|
-
const cfg = Config(config || {}) || {};
|
|
183
|
+
const cfg = plainConfig(Config(plainConfig(config) || {}) || {});
|
|
82
184
|
const toolName = cfg.toolName || DEFAULT_BASH_TOOL;
|
|
83
185
|
const fileWriteTools = cfg.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS.slice();
|
|
84
186
|
const getLocale = function () {
|
|
@@ -99,19 +201,47 @@ export function apply(ctx, config) {
|
|
|
99
201
|
};
|
|
100
202
|
const unattendedPolicies = cfg.unattendedPolicies || DEFAULT_UNATTENDED_POLICIES.slice();
|
|
101
203
|
const forcedUnattended = cfg.unattended === true;
|
|
102
|
-
const engine = cfg.engine || new SecurityEngine({
|
|
103
|
-
inspectConfig: { toolName: toolName, fileWriteTools: fileWriteTools },
|
|
204
|
+
const engine = (config && config.engine) || cfg.engine || new SecurityEngine({
|
|
205
|
+
inspectConfig: { toolName: toolName, fileWriteTools: fileWriteTools, workspaceDir: cfg.workspaceDir || "" },
|
|
104
206
|
});
|
|
207
|
+
if (ctx && typeof ctx === "object") {
|
|
208
|
+
ctx.approvalGate = {
|
|
209
|
+
engine,
|
|
210
|
+
addGrant: (options) => engine.addGrant(options),
|
|
211
|
+
resume: (sessionId, reason) => engine.resume(sessionId, reason),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
105
214
|
|
|
106
215
|
const hasPreExecute = ctx && typeof ctx.on === "function";
|
|
107
216
|
if (hasPreExecute) {
|
|
108
217
|
ctx.on("tools/pre-execute", function (execution, next) {
|
|
109
|
-
const knobs = readKnobs(ctx);
|
|
218
|
+
const knobs = readKnobs(ctx, execution);
|
|
110
219
|
const who = sessionTag(execution);
|
|
111
220
|
const sessionId = (execution && execution.agent && (execution.agent.session || execution.agent)) || "default";
|
|
112
|
-
|
|
221
|
+
let decision;
|
|
222
|
+
try {
|
|
223
|
+
decision = engine.decide(execution, { sessionId, knobs });
|
|
224
|
+
} catch (err) {
|
|
225
|
+
const hit = {
|
|
226
|
+
ask: true,
|
|
227
|
+
reason: "inspect-error",
|
|
228
|
+
detail: String(err && err.message || err),
|
|
229
|
+
snippet: (execution && execution.arguments && execution.arguments.command) || execution.name,
|
|
230
|
+
};
|
|
231
|
+
return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
|
|
232
|
+
}
|
|
113
233
|
|
|
114
234
|
if (decision.verdict === "ask") {
|
|
235
|
+
if (decision.reason === "circuit-breaker-tripped") {
|
|
236
|
+
const hit = decision.hit || {
|
|
237
|
+
deny: true,
|
|
238
|
+
reason: "circuit-breaker-tripped",
|
|
239
|
+
detail: decision.detail,
|
|
240
|
+
snippet: (execution && execution.arguments && execution.arguments.command) || execution.name,
|
|
241
|
+
};
|
|
242
|
+
logBlocked(ctx, hit, who);
|
|
243
|
+
return denyMessage(hit, who, translate, ctx && ctx.logger);
|
|
244
|
+
}
|
|
115
245
|
if (forcedUnattended || isUnattended({ policy: knobs.policy, unattendedPolicies })) {
|
|
116
246
|
return next();
|
|
117
247
|
}
|
|
@@ -130,7 +260,19 @@ export function apply(ctx, config) {
|
|
|
130
260
|
const guard = function (execution) {
|
|
131
261
|
const who = sessionTag(execution);
|
|
132
262
|
const sessionId = (execution && execution.agent && (execution.agent.session || execution.agent)) || "default";
|
|
133
|
-
|
|
263
|
+
let decision;
|
|
264
|
+
try {
|
|
265
|
+
decision = engine.decide(execution, { sessionId });
|
|
266
|
+
} catch (err) {
|
|
267
|
+
const hit = {
|
|
268
|
+
deny: true,
|
|
269
|
+
reason: "inspect-error",
|
|
270
|
+
detail: String(err && err.message || err),
|
|
271
|
+
snippet: (execution && execution.arguments && execution.arguments.command) || execution.name,
|
|
272
|
+
};
|
|
273
|
+
logBlocked(ctx, hit, who);
|
|
274
|
+
return denyMessage(hit, who, translate, ctx && ctx.logger);
|
|
275
|
+
}
|
|
134
276
|
|
|
135
277
|
if (decision.verdict === "deny" || (decision.verdict === "ask" && !hasPreExecute)) {
|
|
136
278
|
const hit = decision.hit || {
|
package/lib/inspect.js
CHANGED
|
@@ -17,11 +17,12 @@ export const DEFAULT_FILE_WRITE_TOOLS = Object.freeze([
|
|
|
17
17
|
"edit",
|
|
18
18
|
"Write",
|
|
19
19
|
"Edit",
|
|
20
|
+
"str_replace_editor",
|
|
20
21
|
"str_replace",
|
|
21
22
|
"apply_patch",
|
|
22
23
|
]);
|
|
23
24
|
|
|
24
|
-
const PREFIXES = new Set(["then", "do"]);
|
|
25
|
+
const PREFIXES = new Set(["then", "do", "{", "}", "!"]);
|
|
25
26
|
const SHELLS = new Set(["bash", "sh", "dash", "zsh", "ksh", "ash"]);
|
|
26
27
|
const INTERPRETERS = new Set(["python", "python2", "python3", "node", "nodejs", "perl", "ruby", "php"]);
|
|
27
28
|
const DB_CLIENTS = new Set([
|
|
@@ -81,7 +82,7 @@ function splitSegments(tokens) {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
function stripPrefixes(argv) {
|
|
84
|
-
const out = argv.
|
|
85
|
+
const out = argv.filter(function (a) { return a !== "}" && a !== ";"; });
|
|
85
86
|
while (out.length) {
|
|
86
87
|
const head = out[0];
|
|
87
88
|
if (PREFIXES.has(head)) { out.shift(); continue; }
|
|
@@ -175,7 +176,7 @@ function inspectInterpreterPayload(cmd, payload) {
|
|
|
175
176
|
return allow();
|
|
176
177
|
}
|
|
177
178
|
|
|
178
|
-
function inspectArgv(argv, depth, hasHeredoc) {
|
|
179
|
+
function inspectArgv(argv, depth, hasHeredoc, config = {}) {
|
|
179
180
|
if (!argv.length) return allow();
|
|
180
181
|
const cmd = basename(argv[0]);
|
|
181
182
|
const rest = argv.slice(1);
|
|
@@ -188,7 +189,7 @@ function inspectArgv(argv, depth, hasHeredoc) {
|
|
|
188
189
|
if (cmd === "env") {
|
|
189
190
|
const sub = rest.filter(a => !a.startsWith("-") && !a.includes("="));
|
|
190
191
|
if (sub.length > 0) {
|
|
191
|
-
return inspectArgv(sub, depth, hasHeredoc);
|
|
192
|
+
return inspectArgv(sub, depth, hasHeredoc, config);
|
|
192
193
|
}
|
|
193
194
|
}
|
|
194
195
|
|
|
@@ -197,7 +198,7 @@ function inspectArgv(argv, depth, hasHeredoc) {
|
|
|
197
198
|
let next = skipWrapper(rest, arity);
|
|
198
199
|
if (cmd === "timeout" && next.length) next = next.slice(1);
|
|
199
200
|
if (cmd === "nice" && next.length && /^[0-9-]+$/.test(next[0])) next = next.slice(1);
|
|
200
|
-
return inspectArgv(next, depth, hasHeredoc);
|
|
201
|
+
return inspectArgv(next, depth, hasHeredoc, config);
|
|
201
202
|
}
|
|
202
203
|
|
|
203
204
|
if (cmd === "eval") return deny("eval", argv.join(" "));
|
|
@@ -256,7 +257,7 @@ function inspectArgv(argv, depth, hasHeredoc) {
|
|
|
256
257
|
const script = scriptAfterDashC(rest);
|
|
257
258
|
if (typeof script === "string") {
|
|
258
259
|
if (depth > 4) return deny("nested-shell", script);
|
|
259
|
-
return inspectBashCommand(script, depth + 1);
|
|
260
|
+
return inspectBashCommand(script, depth + 1, config);
|
|
260
261
|
}
|
|
261
262
|
const scriptFile = rest.find(function (a) { return a && a[0] !== "-"; });
|
|
262
263
|
if (scriptFile) return ask("script-file", "shell script contents are not available to the guard", scriptFile);
|
|
@@ -359,7 +360,7 @@ function commandNameFromSegment(seg) {
|
|
|
359
360
|
return token ? basename(token.value) : "";
|
|
360
361
|
}
|
|
361
362
|
|
|
362
|
-
function inspectEmbeddedExpansions(source, depth) {
|
|
363
|
+
function inspectEmbeddedExpansions(source, depth, config = {}) {
|
|
363
364
|
if (depth > 8) return ask("unparseable", "nesting-depth", source);
|
|
364
365
|
for (let i = 0; i < source.length; i += 1) {
|
|
365
366
|
if (source[i] === "\\") { i += 1; continue; }
|
|
@@ -368,7 +369,7 @@ function inspectEmbeddedExpansions(source, depth) {
|
|
|
368
369
|
? readCommandSubstitution(source, i)
|
|
369
370
|
: readBacktick(source, i);
|
|
370
371
|
if (!sub) return ask("unparseable", "unclosed-command-substitution", source.slice(i));
|
|
371
|
-
const nested = inspectBashCommand(sub.script, depth + 1);
|
|
372
|
+
const nested = inspectBashCommand(sub.script, depth + 1, config);
|
|
372
373
|
if (nested.deny || nested.ask) return nested;
|
|
373
374
|
i = sub.end - 1;
|
|
374
375
|
continue;
|
|
@@ -377,7 +378,7 @@ function inspectEmbeddedExpansions(source, depth) {
|
|
|
377
378
|
const expansion = readParameterExpansion(source, i);
|
|
378
379
|
if (!expansion) return ask("unparseable", "unclosed-parameter-expansion", source.slice(i));
|
|
379
380
|
if (expansion.body !== undefined) {
|
|
380
|
-
const nested = inspectEmbeddedExpansions(expansion.body, depth + 1);
|
|
381
|
+
const nested = inspectEmbeddedExpansions(expansion.body, depth + 1, config);
|
|
381
382
|
if (nested.deny || nested.ask) return nested;
|
|
382
383
|
}
|
|
383
384
|
i = expansion.end - 1;
|
|
@@ -386,7 +387,7 @@ function inspectEmbeddedExpansions(source, depth) {
|
|
|
386
387
|
return allow();
|
|
387
388
|
}
|
|
388
389
|
|
|
389
|
-
function inspectPipelineHeredocs(tokens, depth) {
|
|
390
|
+
function inspectPipelineHeredocs(tokens, depth, config = {}) {
|
|
390
391
|
let group = [];
|
|
391
392
|
let segment = [];
|
|
392
393
|
function finishGroup() {
|
|
@@ -395,7 +396,7 @@ function inspectPipelineHeredocs(tokens, depth) {
|
|
|
395
396
|
if (hasShell) {
|
|
396
397
|
for (const part of group) {
|
|
397
398
|
for (const heredoc of part.filter(function (t) { return t.kind === "heredoc"; })) {
|
|
398
|
-
const nested = inspectBashCommand(heredoc.body, depth + 1);
|
|
399
|
+
const nested = inspectBashCommand(heredoc.body, depth + 1, config);
|
|
399
400
|
if (nested.deny || nested.ask) return nested;
|
|
400
401
|
}
|
|
401
402
|
}
|
|
@@ -440,7 +441,7 @@ function inspectUnparsedDanger(command) {
|
|
|
440
441
|
return null;
|
|
441
442
|
}
|
|
442
443
|
|
|
443
|
-
export function inspectBashCommand(command, depth) {
|
|
444
|
+
export function inspectBashCommand(command, depth = 0, config = {}) {
|
|
444
445
|
if (depth === undefined) depth = 0;
|
|
445
446
|
if (depth > 8) return ask("unparseable", "nesting-depth", String(command || ""));
|
|
446
447
|
if (typeof command !== "string") return ask("unknown-format", "command is not a string", "non-string command");
|
|
@@ -453,7 +454,7 @@ export function inspectBashCommand(command, depth) {
|
|
|
453
454
|
}
|
|
454
455
|
if (hasDangerousPipeline(tokens)) return deny("curl-pipe", command);
|
|
455
456
|
if (hasDestructiveSqlPipeline(tokens)) return deny("destructive-sql", command);
|
|
456
|
-
const pipelineHeredocHit = inspectPipelineHeredocs(tokens, depth);
|
|
457
|
+
const pipelineHeredocHit = inspectPipelineHeredocs(tokens, depth, config);
|
|
457
458
|
if (pipelineHeredocHit.deny || pipelineHeredocHit.ask) return pipelineHeredocHit;
|
|
458
459
|
|
|
459
460
|
const segs = splitSegments(tokens);
|
|
@@ -472,19 +473,19 @@ export function inspectBashCommand(command, depth) {
|
|
|
472
473
|
for (const token of wordTokens) {
|
|
473
474
|
for (const substitution of token.substitutions || []) {
|
|
474
475
|
const nested = substitution.kind === "parameter-expansion"
|
|
475
|
-
? inspectEmbeddedExpansions(substitution.script, depth + 1)
|
|
476
|
-
: inspectBashCommand(substitution.script, depth + 1);
|
|
476
|
+
? inspectEmbeddedExpansions(substitution.script, depth + 1, config)
|
|
477
|
+
: inspectBashCommand(substitution.script, depth + 1, config);
|
|
477
478
|
if (nested.deny || nested.ask) return nested;
|
|
478
479
|
}
|
|
479
480
|
}
|
|
480
481
|
|
|
481
482
|
for (const redirect of redirectsOf(seg)) {
|
|
482
483
|
if (redirect.dynamic) return ask("dynamic-redirect", "expanded redirect target cannot be verified", redirect.path);
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if (!
|
|
484
|
+
const baseDir = (config && config.workspaceDir) || process.cwd();
|
|
485
|
+
const norm = normalizeSeparators(redirect.path);
|
|
486
|
+
const isRel = !/^(?:\/|[A-Za-z]:\/|\/\/)/.test(norm);
|
|
487
|
+
const isExempt = !isRel && (/^\/dev\/(?:null|zero|stdout|stderr|fd\/\d+)$/.test(norm) || /^(?:\/tmp|\/var\/tmp)(?:\/|$)/i.test(norm));
|
|
488
|
+
if (!isExempt && isPathTraversal(redirect.path, baseDir)) {
|
|
488
489
|
return deny("path-traversal", redirect.path);
|
|
489
490
|
}
|
|
490
491
|
if (isProtectedPath(redirect.path)) return deny("secret-write", redirect.path);
|
|
@@ -504,13 +505,13 @@ export function inspectBashCommand(command, depth) {
|
|
|
504
505
|
if (heredocs.length) {
|
|
505
506
|
for (const heredoc of heredocs) {
|
|
506
507
|
if (!heredoc.quoted) {
|
|
507
|
-
const expansionHit = inspectEmbeddedExpansions(heredoc.body, depth);
|
|
508
|
+
const expansionHit = inspectEmbeddedExpansions(heredoc.body, depth, config);
|
|
508
509
|
if (expansionHit.deny || expansionHit.ask) return expansionHit;
|
|
509
510
|
}
|
|
510
511
|
}
|
|
511
512
|
if (SHELLS.has(commandName)) {
|
|
512
513
|
for (const heredoc of heredocs) {
|
|
513
|
-
const nested = inspectBashCommand(heredoc.body, depth + 1);
|
|
514
|
+
const nested = inspectBashCommand(heredoc.body, depth + 1, config);
|
|
514
515
|
if (nested.deny || nested.ask) return nested;
|
|
515
516
|
}
|
|
516
517
|
} else if (INTERPRETERS.has(commandName)) {
|
|
@@ -527,7 +528,7 @@ export function inspectBashCommand(command, depth) {
|
|
|
527
528
|
}
|
|
528
529
|
}
|
|
529
530
|
|
|
530
|
-
const hit = inspectArgv(argv, depth, heredocs.length > 0);
|
|
531
|
+
const hit = inspectArgv(argv, depth, heredocs.length > 0, config);
|
|
531
532
|
if (hit.deny || hit.ask) return hit;
|
|
532
533
|
}
|
|
533
534
|
|
|
@@ -556,7 +557,7 @@ export function inspectExecution(execution, config) {
|
|
|
556
557
|
const command = args && typeof args === "object" ? args.command : undefined;
|
|
557
558
|
if (command === undefined || command === null || command === "") return allow();
|
|
558
559
|
if (typeof command !== "string") return ask("unknown-format", "command is not a string", "non-string command");
|
|
559
|
-
return inspectBashCommand(command);
|
|
560
|
+
return inspectBashCommand(command, 0, config);
|
|
560
561
|
}
|
|
561
562
|
if (typeof name === "string" && fileTools.has(name)) {
|
|
562
563
|
const path = pathFromArgs(args);
|
package/lib/messages.js
CHANGED
|
@@ -178,7 +178,11 @@ export const MESSAGES = Object.freeze({
|
|
|
178
178
|
export function sessionTag(execution) {
|
|
179
179
|
const agent = execution && execution.agent;
|
|
180
180
|
if (typeof agent === "string") return " (session " + agent + ")";
|
|
181
|
-
if (agent && agent.session)
|
|
181
|
+
if (agent && agent.session) {
|
|
182
|
+
const s = agent.session;
|
|
183
|
+
const sid = typeof s === "string" ? s : (s.id || s.name || s.sessionId || s.sessionKey);
|
|
184
|
+
return sid ? " (session " + String(sid) + ")" : "";
|
|
185
|
+
}
|
|
182
186
|
return "";
|
|
183
187
|
}
|
|
184
188
|
|
package/lib/paths.js
CHANGED
|
@@ -69,7 +69,7 @@ export function expandHome(p, env = process.env) {
|
|
|
69
69
|
// Expand leading ~ or ~/ or ~\
|
|
70
70
|
res = res.replace(/^~(?=$|[/\\])/, cleanHome);
|
|
71
71
|
// Expand ${HOME} and $HOME
|
|
72
|
-
res = res.replace(/\$\{HOME\}|\$HOME/g, cleanHome);
|
|
72
|
+
res = res.replace(/\$\{HOME\}|\$HOME(?=\$|[\/\\])/g, cleanHome);
|
|
73
73
|
// Expand %USERPROFILE% (Windows)
|
|
74
74
|
res = res.replace(/%USERPROFILE%/gi, cleanHome);
|
|
75
75
|
return res;
|
package/lib/tokenizer.js
CHANGED
|
@@ -170,7 +170,7 @@ export function tokenize(src) {
|
|
|
170
170
|
i = lineEnd + 1;
|
|
171
171
|
}
|
|
172
172
|
if (!found) return lexFail(src, i, "unterminated-heredoc", tokens);
|
|
173
|
-
tokens.push({ kind: "heredoc", body: body, quoted: heredoc.quoted });
|
|
173
|
+
tokens.push({ kind: "heredoc", body: body, quoted: heredoc.quoted, marker: heredoc.delimiter });
|
|
174
174
|
}
|
|
175
175
|
pendingHeredocs.length = 0;
|
|
176
176
|
tokens.push({ kind: "op", value: ";" });
|
|
@@ -340,7 +340,7 @@ export function splitSubcommands(command) {
|
|
|
340
340
|
.map((t) => t.value);
|
|
341
341
|
const subStr = tokens
|
|
342
342
|
.map((t) => {
|
|
343
|
-
if (t.kind === "heredoc") return `<<${t.marker}`;
|
|
343
|
+
if (t.kind === "heredoc") return `<<${t.marker || t.body || ""}`;
|
|
344
344
|
return t.value;
|
|
345
345
|
})
|
|
346
346
|
.join(" ")
|
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.11",
|
|
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",
|