@goodandready/dsh-approval-gate 0.1.11 → 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,14 @@
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
+
3
12
  ## 0.1.11
4
13
 
5
14
  - 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
  /**
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
- // 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
+ }
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
- // 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";
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
- * `policy` comes from the approval service (`ctx.approval.config.policy`); an empty value
56
- * means the knob is unknown and the gate keeps asking, which is the safe direction.
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
- 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;
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 if an active execution carrying a session is provided.
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. 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") {
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 === "danger-full-access") {
119
- policy = "never";
120
- break;
119
+ if (!preset && event && event.type === "permission/preset" && event.data && event.data.preset) {
120
+ preset = event.data.preset;
121
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;
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
- // 4. In-memory session.events array fallback (common in mocks / tests)
132
- if (!policy && session && Array.isArray(session.events)) {
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 === "danger-full-access") {
142
- policy = "never";
143
- break;
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: (shell && shell.sandboxMode) || "",
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") 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
+ }
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.approvalGate = {
209
- engine,
210
- addGrant: (options) => engine.addGrant(options),
211
- resume: (sessionId, reason) => engine.resume(sessionId, reason),
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 && execution.agent && (execution.agent.session || execution.agent)) || "default";
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 && execution.agent && (execution.agent.session || execution.agent)) || "default";
313
+ const sessionId = resolveSessionId(execution);
263
314
  let decision;
264
315
  try {
265
316
  decision = engine.decide(execution, { sessionId });
@@ -318,6 +369,4 @@ 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";
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.11",
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",