@goodandready/dsh-approval-gate 0.1.10 → 0.1.12

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 CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.12
4
+
5
+ - Fix: корректная регистрация сервиса `approvalGate` в Cordis через `ctx.provide('approvalGate', ...)` и экспорт `export const provide = ['approvalGate']`, устраняющая ошибку `cannot set property "approvalGate" without provide` при старте хоста DSH.
6
+ - Policy: надежное определение режима «Полный доступ» (`danger-full-access` / `never`) в `readKnobs` и `isUnattended` даже при отсутствии предварительного события `approval/policy` в сессии (приоритет пресета и sandbox режима над дефолтным `ask` от `ApprovalService`), предотвращающая ложный отказ `the user rejected tool` на всех командах агента.
7
+ - Localization: удаление захардкоженного русского словаря из `lib/messages.js` в строгом соответствии со стандартом разработки DSH плагинов (только `en` и `zh`, русская локализация централизованно обслуживается `dsh-russian-lang`) с защитной обработкой `locale.register` в `apply` (Refs: #153, #162).
8
+ - Bands: динамическое расширение списка read-only инструментов из хостового `toolRegistry` (`options.toolRegistry`) с поддержкой аннотаций `readOnly` / `readonly` (Refs: #156).
9
+ - Engine: настраиваемый дефолтный вердикт для нераспознанных команд `defaultVerdict` (`allow` по умолчанию, опциональный fail-closed `ask`) с добавлением в схему `Config` (Refs: #163).
10
+ - Session: гарантированное извлечение строкового `sessionId` в `resolveSessionId` для любых структур объекта `agent` / `session`.
11
+
12
+ ## 0.1.11
13
+
14
+ - Security: проверка абсолютных путей перенаправлений shell на выход за пределы рабочего каталога `workspaceDir` с исключением canonical устройств (`/dev/null`) и временных каталогов (`/tmp`) (Refs: #143).
15
+ - Security: инспекция инструмента `str_replace_editor` как модификатора файлов с проверкой секретов и границ каталога (Refs: #144).
16
+ - Security: блокировка обхода через составные префиксы `{`, `}`, `!` в начале shell-команд (Refs: #147).
17
+ - Bands: строгий отказ Band 1 Safe Allow для составных команд, конвейеров и операторов цепочек (`&&`, `||`, `;`, `|`, `>`, `$()`) с перенаправлением на полную сегментную инспекцию (Refs: #155, #158).
18
+ - Paths: безопасное раскрытие переменной `$HOME` с привязкой к границе сегмента пути (`/\${HOME\}|\$HOME(?=$|[\/\\])/g`), предотвращающее повреждение путей вроде `/opt/$HOME-backup` (Refs: #154).
19
+ - Engine: строгий fail-closed fallback в `P4 Ask` при исключениях семантического классификатора (`reason: 'classifier-error'`) и полная изоляция `decide()` и хуков рантайма в защитные блоки `try/catch` с гарантией `inspect-error` (Refs: #145, #159, #161).
20
+ - Breaker: блокировка обхода сработавшего Circuit Breaker в unattended/full-access режиме — взведенный предохранитель принудительно возвращает `deny` вместо пропуска в `next()` (Refs: #157, #160).
21
+ - Breaker: ограничение размера истории состояний (`MAX_TRACKED_SESSIONS = 500`, `MAX_SESSION_HISTORY = 100`) и пассивные методы проверки без утечки памяти (Refs: #149).
22
+ - Policy: оптимизация сканирования истории событий сессии в `readKnobs` — однопроходный поиск, ограничение глубины до 100 событий и кэширование политики сессии на `_gateCachedPolicy` (Refs: #148).
23
+ - Schema: поддержка `.volatile()` на полях конфигурации, явные поля `workspaceDir` и `engine`, полифилл для DSH UI формы настроек и функция разворачивания реактивных боксов `plainConfig` (Refs: #142, #150, #151).
24
+ - Engine: методы `addGrant`, `getGrants` и `resume` на `SecurityEngine` и экспорт `ctx.approvalGate` для управления грантами и сбросом предохранителя (Refs: #152).
25
+ - Tokenizer: исправление передачи маркера heredoc (`marker: heredoc.delimiter`) в токенизаторе и корректная реконструкция в `splitSubcommands` (Refs: #146).
26
+ - Tests: матрица проверок на обходные векторы через операторы конвейеров и allow-префиксы (Refs: #164).
27
+
3
28
  ## 0.1.10
4
29
 
5
30
  - Policy: точное определение сессионной политики апрува в `readKnobs` через `approval.effectivePolicy(session)`, историю событий `session.eventAt` и пресет `danger-full-access` (Refs: #140).
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
  /**
@@ -153,6 +169,10 @@ export class BandsEngine {
153
169
  isSafeInspectionCommand(command) {
154
170
  if (typeof command !== "string" || command.trim() === "") return false;
155
171
  const text = command.trim();
172
+ // Chained commands, subshells, substitutions, and redirections cannot match whole-string safe-allow
173
+ if (/[\n;&|`<>]|\$\([\s\S]*\)|\$\{[\s\S]*\}/.test(text)) {
174
+ return false;
175
+ }
156
176
  for (const pattern of this.denyPatterns) {
157
177
  if (pattern.test(text)) return false;
158
178
  }
@@ -203,10 +223,12 @@ export class BandsEngine {
203
223
  }
204
224
  }
205
225
 
206
- // Band 1: Safe Allow
207
- for (const glob of this.allowGlobs) {
208
- if (glob.test(text)) {
209
- return "allow";
226
+ // Band 1: Safe Allow (applies strictly to single commands without chaining, subshells or redirects)
227
+ if (!/[\n;&|`<>]|\$\([\s\S]*\)|\$\{[\s\S]*\}/.test(text)) {
228
+ for (const glob of this.allowGlobs) {
229
+ if (glob.test(text)) {
230
+ return "allow";
231
+ }
210
232
  }
211
233
  }
212
234
  }
@@ -214,4 +236,4 @@ export class BandsEngine {
214
236
  return "unmatched";
215
237
  }
216
238
  }
217
-
239
+
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 state = this._getOrCreate(sessionId);
119
- return {
120
- consecutive: state.consecutive,
121
- total: state.total,
122
- tripped: state.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 state = this._getOrCreate(sessionId);
133
- return state.state;
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 state = this._getOrCreate(sessionId);
143
- return state.history.map((ev) => ({ ...ev }));
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 state = this._getOrCreate(sessionId);
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
@@ -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
  /**
@@ -38,7 +39,8 @@ export class SecurityEngine {
38
39
  * @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string, hit?: any }}
39
40
  */
40
41
  decide(execution, context = {}) {
41
- const sessionId = context.sessionId || "default";
42
+ try {
43
+ const sessionId = context.sessionId || "default";
42
44
  const toolName = execution && execution.name;
43
45
  const command = execution && execution.arguments && execution.arguments.command;
44
46
 
@@ -109,10 +111,42 @@ export class SecurityEngine {
109
111
  }
110
112
  } catch (err) {
111
113
  // Classifier error fails closed to P4 Ask
114
+ return { verdict: "ask", stage: "P4", reason: "classifier-error", detail: String(err && err.message || err), hit: null };
112
115
  }
113
116
  }
114
117
 
115
- // Safe default for unclassified commands
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
+ }
116
123
  return { verdict: "allow", stage: "P2", reason: "unmatched-allow", hit };
124
+ } catch (err) {
125
+ return {
126
+ verdict: "ask",
127
+ stage: "P4",
128
+ reason: "inspect-error",
129
+ detail: String(err && err.message || err),
130
+ hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
131
+ };
132
+ }
133
+ }
134
+
135
+ addGrant(options) {
136
+ if (this.grantRegistry && typeof this.grantRegistry.addGrant === "function") {
137
+ return this.grantRegistry.addGrant(options);
138
+ }
139
+ return null;
140
+ }
141
+
142
+ getGrants(sessionId) {
143
+ if (this.grantRegistry && typeof this.grantRegistry.getGrants === "function") {
144
+ return this.grantRegistry.getGrants(sessionId);
145
+ }
146
+ return [];
147
+ }
148
+
149
+ resume(sessionId, reason) {
150
+ return this.breaker.resume(sessionId, reason);
117
151
  }
118
152
  }
package/lib/index.js CHANGED
@@ -1,18 +1,26 @@
1
1
  import { SecurityEngine } from "./engine.js";
2
- // dsh-approval-gate - approval gate for DeepSeek Harness.
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";
4
+
5
+ if (typeof Schema.prototype?.volatile !== "function") {
6
+ if (Schema.prototype) {
7
+ Schema.prototype.volatile = function volatile() {
8
+ if (this.meta && this.meta.volatile) return this;
9
+ return typeof this.extra === "function" ? this.extra("volatile", true) : this;
10
+ };
11
+ }
12
+ }
13
+
14
+ function isVolatileRef(value) {
15
+ return value !== null && typeof value === "object" && !Array.isArray(value) && typeof value.get === "function";
16
+ }
17
+
18
+ export function plainConfig(value) {
19
+ if (isVolatileRef(value)) return plainConfig(value.get());
20
+ if (value === null || typeof value !== "object") return value;
21
+ if (Array.isArray(value)) return value.map(plainConfig);
22
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]));
23
+ }
16
24
  import {
17
25
  DEFAULT_BASH_TOOL,
18
26
  DEFAULT_FILE_WRITE_TOOLS,
@@ -25,6 +33,7 @@ import { LOCALE_NS, MESSAGES } from "./messages.js";
25
33
 
26
34
  export const name = "dsh-approval-gate";
27
35
  export const inject = ["tools"];
36
+ export const provide = ["approvalGate"];
28
37
 
29
38
  /** Policies under which a prompt cannot be answered, so raising one is a silent denial. */
30
39
  export const DEFAULT_UNATTENDED_POLICIES = ["never"];
@@ -32,25 +41,48 @@ export const DEFAULT_UNATTENDED_POLICIES = ["never"];
32
41
  /**
33
42
  * Whether a raised ask would be answerable in this session.
34
43
  *
35
- * `policy` comes from the approval service (`ctx.approval.config.policy`); an empty value
36
- * means the knob is unknown and the gate keeps asking, which is the safe direction.
37
- * @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
38
47
  * @returns {boolean}
39
48
  */
40
49
  export function isUnattended(knobs) {
41
- const policy = knobs && knobs.policy;
50
+ if (!knobs) return false;
51
+ if (knobs.preset === "danger-full-access" || knobs.sandbox === "danger-full-access") return true;
52
+ const policy = knobs.policy;
42
53
  const policies = (knobs && knobs.unattendedPolicies) || DEFAULT_UNATTENDED_POLICIES;
43
54
  if (typeof policy !== "string" || policy === "") return false;
44
55
  return policies.includes(policy);
45
56
  }
46
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
+
47
79
  /**
48
80
  * Effective permission knobs, as the harness exposes them.
49
- * Resolves session-level approval policy if an active execution carrying a session is provided.
81
+ * Resolves session-level approval policy, preset, and sandbox mode.
50
82
  *
51
83
  * @param {any} ctx - Cordis context
52
84
  * @param {any} [execution] - Optional tool execution carrying agent/session
53
- * @returns {{ policy: string, sandbox: string }}
85
+ * @returns {{ policy: string, sandbox: string, preset: string }}
54
86
  */
55
87
  export function readKnobs(ctx, execution) {
56
88
  const read = (name) => {
@@ -67,67 +99,77 @@ export function readKnobs(ctx, execution) {
67
99
  const session = agent && (agent.session || (typeof agent === "object" && typeof agent.eventAt === "function" ? agent : undefined));
68
100
 
69
101
  let policy = "";
102
+ let preset = "";
103
+ let sessionSandbox = "";
70
104
 
71
- // 1. Live approval service effectivePolicy(session)
72
- if (approval && session && typeof approval.effectivePolicy === "function") {
73
- try {
74
- policy = approval.effectivePolicy(session) || "";
75
- } catch {
76
- // ignore
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
+ if (session._gateCachedSeq === session.seq && session._gateCachedPolicy !== undefined) {
108
+ policy = session._gateCachedPolicy;
109
+ preset = session._gateCachedPreset || "";
110
+ sessionSandbox = session._gateCachedSandbox || "";
111
+ } else {
112
+ try {
113
+ const minSeq = Math.max(0, session.seq - 100);
114
+ for (let seq = session.seq - 1; seq >= minSeq; seq -= 1) {
115
+ const event = session.eventAt(seq);
116
+ if (!policy && event && event.type === "approval/policy" && event.data && event.data.policy) {
117
+ policy = event.data.policy;
118
+ }
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;
124
+ }
125
+ if (policy && preset && sessionSandbox) break;
126
+ }
127
+ session._gateCachedSeq = session.seq;
128
+ session._gateCachedPolicy = policy || "";
129
+ session._gateCachedPreset = preset || "";
130
+ session._gateCachedSandbox = sessionSandbox || "";
131
+ } catch {
132
+ // ignore
133
+ }
77
134
  }
78
135
  }
79
136
 
80
- // 2. Direct session property fallback
81
- if (!policy && session && typeof session.approvalPolicy === "string") {
82
- policy = session.approvalPolicy;
83
- }
84
-
85
- // 3. Fallback: inspect session events backwards for approval/policy
86
- if (!policy && session && typeof session.eventAt === "function" && typeof session.seq === "number") {
137
+ // 2. In-memory session.events array fallback (common in mocks / tests)
138
+ if (session && Array.isArray(session.events)) {
87
139
  try {
88
- for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
89
- const event = session.eventAt(seq);
90
- if (event && event.type === "approval/policy" && event.data && event.data.policy) {
140
+ const minIdx = Math.max(0, session.events.length - 100);
141
+ for (let i = session.events.length - 1; i >= minIdx; i -= 1) {
142
+ const event = session.events[i];
143
+ if (!policy && event && event.type === "approval/policy" && event.data && event.data.policy) {
91
144
  policy = event.data.policy;
92
- break;
93
145
  }
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;
151
+ }
152
+ if (policy && preset && sessionSandbox) break;
94
153
  }
95
154
  } catch {
96
155
  // ignore
97
156
  }
98
157
  }
99
158
 
100
- // 4. Fallback: inspect session events for permission/preset === 'danger-full-access'
101
- if (!policy && session && typeof session.eventAt === "function" && typeof session.seq === "number") {
102
- try {
103
- for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
104
- const event = session.eventAt(seq);
105
- if (event && event.type === "permission/preset" && event.data && event.data.preset) {
106
- if (event.data.preset === "danger-full-access") {
107
- policy = "never";
108
- }
109
- break;
110
- }
111
- }
112
- } catch {
113
- // ignore
114
- }
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";
115
162
  }
116
163
 
117
- // 5. In-memory session.events array fallback (common in mocks / tests)
118
- if (!policy && session && Array.isArray(session.events)) {
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") {
119
171
  try {
120
- for (let i = session.events.length - 1; i >= 0; i -= 1) {
121
- const event = session.events[i];
122
- if (event && event.type === "approval/policy" && event.data && event.data.policy) {
123
- policy = event.data.policy;
124
- break;
125
- }
126
- if (event && event.type === "permission/preset" && event.data && event.data.preset === "danger-full-access") {
127
- policy = "never";
128
- break;
129
- }
130
- }
172
+ policy = approval.effectivePolicy(session) || "";
131
173
  } catch {
132
174
  // ignore
133
175
  }
@@ -138,21 +180,33 @@ export function readKnobs(ctx, execution) {
138
180
  policy = approval.config.policy;
139
181
  }
140
182
 
183
+ const sandbox = sessionSandbox || (shell && shell.sandboxMode) || "";
184
+ if (sandbox === "danger-full-access" && !policy) {
185
+ policy = "never";
186
+ }
187
+
141
188
  return {
142
189
  policy: policy || "",
143
- sandbox: (shell && shell.sandboxMode) || "",
190
+ sandbox: sandbox || "",
191
+ preset: preset || "",
144
192
  };
145
193
  }
146
194
 
147
195
  export const Config = Schema.object({
148
196
  /** Name of the tool whose `command` arg is inspected (the DSH bash tool). */
149
- toolName: Schema.string().default(DEFAULT_BASH_TOOL),
197
+ toolName: Schema.string().default(DEFAULT_BASH_TOOL).volatile(),
150
198
  /** Extra tool names treated as file writes; paths are matched against secret files. */
151
- fileWriteTools: Schema.array(Schema.string()).default(DEFAULT_FILE_WRITE_TOOLS.slice()),
199
+ fileWriteTools: Schema.array(Schema.string()).default(DEFAULT_FILE_WRITE_TOOLS.slice()).volatile(),
152
200
  /** Approval policies under which an ask could not be answered, so it is not raised. */
153
- unattendedPolicies: Schema.array(Schema.string()).default(DEFAULT_UNATTENDED_POLICIES.slice()),
201
+ unattendedPolicies: Schema.array(Schema.string()).default(DEFAULT_UNATTENDED_POLICIES.slice()).volatile(),
154
202
  /** Force unattended behaviour where the approval policy is not visible. */
155
- unattended: Schema.boolean().default(false),
203
+ unattended: Schema.boolean().default(false).volatile(),
204
+ /** Workspace directory for path traversal boundary checks. */
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(),
208
+ /** Security engine instance (internal/testing). */
209
+ engine: Schema.any().hidden(),
156
210
  });
157
211
 
158
212
  function logBlocked(ctx, hit, who) {
@@ -161,7 +215,7 @@ function logBlocked(ctx, hit, who) {
161
215
  }
162
216
 
163
217
  export function apply(ctx, config) {
164
- const cfg = Config(config || {}) || {};
218
+ const cfg = plainConfig(Config(plainConfig(config) || {}) || {});
165
219
  const toolName = cfg.toolName || DEFAULT_BASH_TOOL;
166
220
  const fileWriteTools = cfg.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS.slice();
167
221
  const getLocale = function () {
@@ -170,7 +224,15 @@ export function apply(ctx, config) {
170
224
  };
171
225
  const registerLocale = function () {
172
226
  const locale = getLocale();
173
- if (locale && typeof locale.register === "function") return locale.register(LOCALE_NS, MESSAGES);
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
+ }
174
236
  };
175
237
  if (ctx && typeof ctx.effect === "function") ctx.effect(registerLocale, "dsh-approval-gate: dictionaries");
176
238
  else registerLocale();
@@ -182,20 +244,56 @@ export function apply(ctx, config) {
182
244
  };
183
245
  const unattendedPolicies = cfg.unattendedPolicies || DEFAULT_UNATTENDED_POLICIES.slice();
184
246
  const forcedUnattended = cfg.unattended === true;
185
- const engine = cfg.engine || new SecurityEngine({
186
- inspectConfig: { toolName: toolName, fileWriteTools: fileWriteTools },
247
+ const engine = (config && config.engine) || cfg.engine || new SecurityEngine({
248
+ inspectConfig: { toolName: toolName, fileWriteTools: fileWriteTools, workspaceDir: cfg.workspaceDir || "" },
249
+ bands: new BandsEngine({ toolRegistry: ctx && ctx.tools }),
250
+ defaultVerdict: cfg.defaultVerdict || "allow",
187
251
  });
188
252
 
253
+ const gateService = {
254
+ engine,
255
+ addGrant: (options) => engine.addGrant(options),
256
+ resume: (sessionId, reason) => engine.resume(sessionId, reason),
257
+ };
258
+ if (ctx && typeof ctx === "object") {
259
+ if (typeof ctx.provide === "function") {
260
+ ctx.provide("approvalGate", gateService);
261
+ } else {
262
+ ctx.approvalGate = gateService;
263
+ }
264
+ }
265
+
189
266
  const hasPreExecute = ctx && typeof ctx.on === "function";
190
267
  if (hasPreExecute) {
191
268
  ctx.on("tools/pre-execute", function (execution, next) {
192
269
  const knobs = readKnobs(ctx, execution);
193
270
  const who = sessionTag(execution);
194
- const sessionId = (execution && execution.agent && (execution.agent.session || execution.agent)) || "default";
195
- const decision = engine.decide(execution, { sessionId, knobs });
271
+ const sessionId = resolveSessionId(execution);
272
+ let decision;
273
+ try {
274
+ decision = engine.decide(execution, { sessionId, knobs });
275
+ } catch (err) {
276
+ const hit = {
277
+ ask: true,
278
+ reason: "inspect-error",
279
+ detail: String(err && err.message || err),
280
+ snippet: (execution && execution.arguments && execution.arguments.command) || execution.name,
281
+ };
282
+ return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
283
+ }
196
284
 
197
285
  if (decision.verdict === "ask") {
198
- if (forcedUnattended || isUnattended({ policy: knobs.policy, unattendedPolicies })) {
286
+ if (decision.reason === "circuit-breaker-tripped") {
287
+ const hit = decision.hit || {
288
+ deny: true,
289
+ reason: "circuit-breaker-tripped",
290
+ detail: decision.detail,
291
+ snippet: (execution && execution.arguments && execution.arguments.command) || execution.name,
292
+ };
293
+ logBlocked(ctx, hit, who);
294
+ return denyMessage(hit, who, translate, ctx && ctx.logger);
295
+ }
296
+ if (forcedUnattended || isUnattended({ policy: knobs.policy, preset: knobs.preset, sandbox: knobs.sandbox, unattendedPolicies })) {
199
297
  return next();
200
298
  }
201
299
  const hit = decision.hit || {
@@ -212,8 +310,20 @@ export function apply(ctx, config) {
212
310
 
213
311
  const guard = function (execution) {
214
312
  const who = sessionTag(execution);
215
- const sessionId = (execution && execution.agent && (execution.agent.session || execution.agent)) || "default";
216
- const decision = engine.decide(execution, { sessionId });
313
+ const sessionId = resolveSessionId(execution);
314
+ let decision;
315
+ try {
316
+ decision = engine.decide(execution, { sessionId });
317
+ } catch (err) {
318
+ const hit = {
319
+ deny: true,
320
+ reason: "inspect-error",
321
+ detail: String(err && err.message || err),
322
+ snippet: (execution && execution.arguments && execution.arguments.command) || execution.name,
323
+ };
324
+ logBlocked(ctx, hit, who);
325
+ return denyMessage(hit, who, translate, ctx && ctx.logger);
326
+ }
217
327
 
218
328
  if (decision.verdict === "deny" || (decision.verdict === "ask" && !hasPreExecute)) {
219
329
  const hit = decision.hit || {
@@ -259,6 +369,4 @@ export {
259
369
  } from "./canonical.js";
260
370
  export { expandHome, resolveSafePath, isPathTraversal, canonicalizeFsPath } from "./paths.js";
261
371
  export { splitSubcommands } from "./tokenizer.js";
262
-
263
-
264
372
  export { redactCredentials, redactUrl } from "./redact.js";
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.slice();
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 isRel = !/^(?:\/|[A-Za-z]:\/|\/\/)/.test(normalizeSeparators(redirect.path));
484
- if (isRel && isPathTraversal(redirect.path, process.cwd())) {
485
- return deny("path-traversal", redirect.path);
486
- }
487
- if (!isRel && isPathTraversal(redirect.path, "/")) {
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
@@ -116,63 +116,6 @@ export const MESSAGES = Object.freeze({
116
116
  hintDestructiveSql: "请使用版本化数据库迁移,或执行非破坏性查询",
117
117
  hintCurlPipe: "先将脚本下载到本地检查内容,再安全执行",
118
118
  },
119
- ru: {
120
- blockedPrefix: "Заблокировано правилом dsh-approval-gate",
121
- detectedLabel: "Обнаружено",
122
- commandLabel: "Команда",
123
- parseReasonLabel: "Причина разбора",
124
- hintPrefix: "Подсказка",
125
- detailProcessSubstitution: "подстановка процесса требует подтверждения",
126
- detailUnclosedProcessSubstitution: "незакрытая подстановка процесса",
127
- detailUnclosedCommandSubstitution: "незакрытая подстановка команды",
128
- detailUnclosedBacktick: "незакрытая подстановка в обратных кавычках",
129
- detailUnclosedParameterExpansion: "незакрытое расширение параметра",
130
- detailUnclosedSingleQuote: "незакрытая одинарная кавычка",
131
- detailUnclosedDoubleQuote: "незакрытая двойная кавычка",
132
- detailDanglingEscape: "незавершённое экранирование",
133
- detailInvalidHeredoc: "неверный разделитель here-document",
134
- detailUnterminatedHeredoc: "ограничитель here-document не найден",
135
- detailUnsupportedGrouping: "группировка shell не поддерживается анализатором",
136
- detailUnsupported: "неподдерживаемый синтаксис shell",
137
- approvalRequired: "Требуется подтверждение",
138
- askUnknown: "Команда не может быть проверена полностью; проверьте её перед подтверждением",
139
- askUnparseable: "Синтаксис shell не может быть проверен полностью; проверьте его перед подтверждением",
140
- askUnknownFormat: "Формат вызова инструмента не может быть проверен; проверьте его перед подтверждением",
141
- askUnknownWriteTarget: "Цель записи файла не может быть определена; проверьте её перед подтверждением",
142
- askDynamicCommand: "Развёрнутое имя команды не может быть проверено; проверьте его перед подтверждением",
143
- askDynamicArguments: "Развёрнутые аргументы чувствительной команды не могут быть проверены; проверьте их перед подтверждением",
144
- askDynamicRedirect: "Развёрнутая цель перенаправления не может быть проверена; проверьте её перед подтверждением",
145
- askScriptFile: "Содержимое файла скрипта недоступно для гейта; проверьте его перед подтверждением",
146
- askShellStdin: "Ввод шелла недоступен для гейта в полном объёме; проверьте его перед подтверждением",
147
- askInterpreterPayload: "Побочные эффекты интерпретатора не могут быть полностью проанализированы; проверьте их перед подтверждением",
148
- askCredentialInArgv: "Развёрнутые учётные данные передаются как аргументы процесса; используйте безопасный API или проверьте перед подтверждением",
149
- ruleUnknown: "Сработало защитное правило блокировки",
150
- ruleRecursiveRm: "Рекурсивное удаление файлов",
151
- ruleProcessSignal: "Завершение процесса",
152
- ruleServiceControl: "Изменение состояния службы",
153
- ruleGitClean: "Разрушительный git clean",
154
- ruleGitResetHard: "Разрушительный git reset --hard",
155
- ruleFindDelete: "find -delete",
156
- ruleDestructiveSql: "Разрушительный SQL",
157
- ruleSecretWrite: "Запись в защищённый файл с секретами",
158
- ruleDeviceWrite: "Запись в системное устройство или защищённый файл",
159
- ruleMkfsDevice: "Форматирование файловой системы",
160
- ruleCurlPipe: "Передача скачанного содержимого в шелл",
161
- ruleEval: "Динамическое выполнение шелла (eval)",
162
- ruleNestedShell: "Превышен лимит вложенности шелла",
163
- ruleInterpreterPayload: "Небезопасный исполняемый код интерпретатора",
164
- ruleEnvDumpLeak: "Попытка утечки переменных окружения и секретов",
165
- rulePathTraversal: "Обнаружена попытка обхода каталогов (path traversal)",
166
- ruleBandHardDeny: "Команда заблокирована критическим списком безопасности Band 0",
167
- ruleCircuitBreakerTripped: "Предохранитель сессии сработал из-за повторяющихся блокировок",
168
- hintRecursiveRm: "Указывайте конкретные файлы вместо рекурсивного удаления",
169
- hintPathTraversal: "Ограничьте операции пределами рабочего каталога проекта",
170
- hintSecretWrite: "Изменяйте настройки через UI или переменные окружения, не перезаписывайте секретные файлы напрямую",
171
- hintEnvDumpLeak: "Запрашивайте конкретные несекретные переменные по отдельности",
172
- hintCircuitBreakerTripped: "Запросите подтверждение оператора перед повторной отправкой системных команд",
173
- hintDestructiveSql: "Используйте версионированные миграции или выполняйте неразрушающие запросы",
174
- hintCurlPipe: "Скачайте скрипт локально, проверьте его содержимое и выполните безопасно",
175
- },
176
119
  });
177
120
 
178
121
  export function sessionTag(execution) {
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.10",
3
+ "version": "0.1.12",
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",