@goodandready/dsh-approval-gate 0.1.8 → 0.1.9

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.9
4
+
5
+ - Inspect: декомпозиция `lib/inspect.js` с выносом `checkEnvExfiltration` в `lib/inspect-env.js`, строгое соблюдение порога 600 строк (Refs: #35).
6
+ - Security: автоматическое маскирование и redaction чувствительных данных, токенов и паролей в сетевых URL и аудиторских логах `redactUrl` и `redactCredentials` (Refs: #42).
7
+ - Paths: интеграция `resolveSafePath` как основного примитива безопасности файловых путей и защита от symlink и path traversal обходов (Refs: #94, #137).
8
+ - Engine: интеграция 5-уровневого каскада принятия решений `SecurityEngine` (P0 Hard-Deny -> P1 Grants -> P2 Static -> P3 LLM -> P4 Ask) в хук `tools/pre-execute` и рантайм-гард `tools.guard` (Refs: #122).
9
+ - Messages: расширенная мультиязычная локализация причин блокировки (ru, en, zh) с контекстными подсказками безопасных альтернатив `hintPrefix` и `ruleHints` (Refs: #131).
10
+ - Sync: автоматический тест контроля паритета рантайм-файлов и синхронизация метаданных публичных релизов (Refs: #138).
11
+
3
12
  ## 0.1.8
4
13
 
5
14
  - Paths: нормализация разделителей путей `normalizeSeparators` для кроссплатформенной обработки Windows (`\`) и POSIX (`/`) слешей (Refs: #109).
package/README.md CHANGED
@@ -57,10 +57,18 @@ graph LR
57
57
 
58
58
  | Module | Responsibility |
59
59
  |---|---|
60
- | lib/index.js | Registers the monotonic tools.guard and pre-execution approval hook; wires the shell and file-write inspectors into DSH. |
60
+ | lib/index.js | Registers the monotonic tools.guard and pre-execution approval hook; wires SecurityEngine into DSH. |
61
+ | lib/engine.js | Coordinates the 5-tier decision cascade (P0 Hard-Deny, P1 Grants, P2 Static, P3 LLM, P4 Ask). |
62
+ | lib/bands.js | Fast two-band prefilter for critical hard-denials (Band 0) and zero-overhead safe inspections (Band 1). |
63
+ | lib/breaker.js | Session circuit breaker protecting against infinite agent retry loops upon security denials. |
64
+ | lib/grants.js | Temporary scoped session grant store with TTL and usage limits. |
65
+ | lib/canonical.js | Canonical serialization and hashing of tool execution calls. |
66
+ | lib/paths.js | Path normalization, home expansion, and symlink/traversal boundary verification via resolveSafePath. |
61
67
  | lib/tokenizer.js | Tokenizes bounded Bash syntax, tracks operators/heredocs, and extracts parameter/command substitutions. |
62
68
  | lib/inspect.js | Evaluates safety rules, inspects command argv and expansions, and returns pass, deny, or ask decisions. |
63
- | lib/messages.js | Provides English, Simplified Chinese, and Russian rule names, explanations, and formatted messages. |
69
+ | lib/inspect-env.js | Inspects and prevents environment exfiltration, procfs reads, and secret dumping. |
70
+ | lib/redact.js | Masks sensitive credentials, authorization headers, and query tokens in audit messages and URLs. |
71
+ | lib/messages.js | Provides English, Simplified Chinese, and Russian rule names, contextual hints, and formatted messages. |
64
72
  | cordis.patch.yml | Declares the host-side plugin bundle and optional tool configuration. |
65
73
 
66
74
  The guard runs before the tool body. A recognized dangerous action is denied even if a prompt could otherwise be shown. Uncertain syntax or targets are sent to DSH approval; DSH policy still decides whether a prompt is available.
package/lib/engine.js CHANGED
@@ -35,7 +35,7 @@ export class SecurityEngine {
35
35
  *
36
36
  * @param {{ name: string, arguments?: any }} execution - Tool call
37
37
  * @param {{ sessionId?: string, knobs?: any }} [context] - Context options
38
- * @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string }}
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
  const sessionId = context.sessionId || "default";
@@ -46,14 +46,15 @@ export class SecurityEngine {
46
46
  const bandVerdict = this.bands.evaluate(toolName, command);
47
47
  if (bandVerdict === "deny") {
48
48
  this.breaker.countDeny(sessionId);
49
- return { verdict: "deny", stage: "P0", reason: "band-hard-deny", detail: command };
49
+ const hit = { deny: true, reason: "band-hard-deny", detail: command, snippet: command };
50
+ return { verdict: "deny", stage: "P0", reason: "band-hard-deny", detail: command, hit };
50
51
  }
51
52
 
52
53
  // Inspect execution for hard denials and syntax checks
53
54
  const hit = inspectExecution(execution, this.inspectConfig);
54
55
  if (hit.deny) {
55
56
  this.breaker.countDeny(sessionId);
56
- return { verdict: "deny", stage: "P0", reason: hit.reason || "hard-deny", detail: hit.detail || hit.snippet };
57
+ return { verdict: "deny", stage: "P0", reason: hit.reason || "hard-deny", detail: hit.detail || hit.snippet, hit };
57
58
  }
58
59
 
59
60
  // P1: Session Grants
@@ -61,24 +62,36 @@ export class SecurityEngine {
61
62
  const grantMatch = this.grantRegistry.decide(toolName, execution.arguments, context);
62
63
  if (grantMatch === "allow") {
63
64
  this.breaker.resetConsecutive(sessionId);
64
- return { verdict: "allow", stage: "P1", reason: "session-grant" };
65
+ return { verdict: "allow", stage: "P1", reason: "session-grant", hit };
65
66
  }
66
67
  }
67
68
 
68
69
  // If Circuit Breaker is tripped, suspend auto-mode and force P4 Human Ask
69
70
  if (this.breaker.isTripped(sessionId)) {
71
+ const breakerHit = {
72
+ ask: true,
73
+ reason: "circuit-breaker-tripped",
74
+ detail: "Circuit breaker tripped due to repeated security denials. Manual confirmation required.",
75
+ snippet: command || toolName,
76
+ };
70
77
  return {
71
78
  verdict: "ask",
72
79
  stage: "P4",
73
80
  reason: "circuit-breaker-tripped",
74
- detail: "Circuit breaker tripped due to repeated security denials. Manual confirmation required."
81
+ detail: breakerHit.detail,
82
+ hit: breakerHit,
75
83
  };
76
84
  }
77
85
 
86
+ // P4: Human Ask if syntax is unparseable or uncertain
87
+ if (hit.ask) {
88
+ return { verdict: "ask", stage: "P4", reason: hit.reason || "uncertain-syntax", detail: hit.detail || hit.snippet, hit };
89
+ }
90
+
78
91
  // P2: Static Rules / Safe Allow
79
92
  if (bandVerdict === "allow") {
80
93
  this.breaker.resetConsecutive(sessionId);
81
- return { verdict: "allow", stage: "P2", reason: "safe-allow" };
94
+ return { verdict: "allow", stage: "P2", reason: "safe-allow", hit };
82
95
  }
83
96
 
84
97
  // P3: Optional Semantic Classifier
@@ -87,24 +100,19 @@ export class SecurityEngine {
87
100
  const cls = this.classifier(execution, context);
88
101
  if (cls && cls.verdict === "allow") {
89
102
  this.breaker.resetConsecutive(sessionId);
90
- return { verdict: "allow", stage: "P3", reason: cls.reason || "classifier-allow" };
103
+ return { verdict: "allow", stage: "P3", reason: cls.reason || "classifier-allow", hit };
91
104
  }
92
105
  if (cls && cls.verdict === "deny") {
93
106
  this.breaker.countDeny(sessionId);
94
- return { verdict: "deny", stage: "P3", reason: cls.reason || "classifier-deny" };
107
+ const clsHit = { deny: true, reason: cls.reason || "classifier-deny", snippet: command || toolName };
108
+ return { verdict: "deny", stage: "P3", reason: cls.reason || "classifier-deny", detail: cls.reason, hit: clsHit };
95
109
  }
96
110
  } catch (err) {
97
111
  // Classifier error fails closed to P4 Ask
98
112
  }
99
113
  }
100
114
 
101
- // P4: Human Ask (uncertain syntax or unlisted command)
102
- if (hit.ask) {
103
- return { verdict: "ask", stage: "P4", reason: hit.reason || "uncertain-syntax", detail: hit.detail || hit.snippet };
104
- }
105
-
106
115
  // Safe default for unclassified commands
107
- return { verdict: "allow", stage: "P2", reason: "unmatched-allow" };
116
+ return { verdict: "allow", stage: "P2", reason: "unmatched-allow", hit };
108
117
  }
109
118
  }
110
-
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { SecurityEngine } from "./engine.js";
1
2
  // dsh-approval-gate - approval gate for DeepSeek Harness.
2
3
  //
3
4
  // Registers tools.guard on the tool runtime and denies dangerous bash
@@ -98,27 +99,49 @@ export function apply(ctx, config) {
98
99
  };
99
100
  const unattendedPolicies = cfg.unattendedPolicies || DEFAULT_UNATTENDED_POLICIES.slice();
100
101
  const forcedUnattended = cfg.unattended === true;
102
+ const engine = cfg.engine || new SecurityEngine({
103
+ inspectConfig: { toolName: toolName, fileWriteTools: fileWriteTools },
104
+ });
105
+
101
106
  const hasPreExecute = ctx && typeof ctx.on === "function";
102
107
  if (hasPreExecute) {
103
108
  ctx.on("tools/pre-execute", function (execution, next) {
104
- const hit = inspectExecution(execution, { toolName: toolName, fileWriteTools: fileWriteTools });
105
- if (!hit.ask) return next();
106
109
  const knobs = readKnobs(ctx);
107
- if (forcedUnattended || isUnattended({ policy: knobs.policy, unattendedPolicies })) {
108
- // No one can answer here: an allowed command proceeds. Denials are the guard's job
109
- // and do not depend on this decision.
110
- return next();
110
+ const who = sessionTag(execution);
111
+ const sessionId = (execution && execution.agent && (execution.agent.session || execution.agent)) || "default";
112
+ const decision = engine.decide(execution, { sessionId, knobs });
113
+
114
+ if (decision.verdict === "ask") {
115
+ if (forcedUnattended || isUnattended({ policy: knobs.policy, unattendedPolicies })) {
116
+ return next();
117
+ }
118
+ const hit = decision.hit || {
119
+ ask: true,
120
+ reason: decision.reason,
121
+ detail: decision.detail,
122
+ snippet: decision.detail || (execution && execution.arguments && execution.arguments.command),
123
+ };
124
+ return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
111
125
  }
112
- return { kind: "ask", reason: askMessage(hit, sessionTag(execution), translate, ctx && ctx.logger) };
126
+ return next();
113
127
  });
114
128
  }
115
129
 
116
130
  const guard = function (execution) {
117
- const hit = inspectExecution(execution, { toolName: toolName, fileWriteTools: fileWriteTools });
118
- if (!hit.deny && !(hit.ask && !hasPreExecute)) return undefined;
119
131
  const who = sessionTag(execution);
120
- logBlocked(ctx, hit, who);
121
- return denyMessage(hit, who, translate, ctx && ctx.logger);
132
+ const sessionId = (execution && execution.agent && (execution.agent.session || execution.agent)) || "default";
133
+ const decision = engine.decide(execution, { sessionId });
134
+
135
+ if (decision.verdict === "deny" || (decision.verdict === "ask" && !hasPreExecute)) {
136
+ const hit = decision.hit || {
137
+ deny: true,
138
+ reason: decision.reason,
139
+ snippet: decision.detail || (execution && execution.arguments && execution.arguments.command) || execution.name,
140
+ };
141
+ logBlocked(ctx, hit, who);
142
+ return denyMessage(hit, who, translate, ctx && ctx.logger);
143
+ }
144
+ return undefined;
122
145
  };
123
146
 
124
147
  const mount = function () {
@@ -151,6 +174,8 @@ export {
151
174
  hashCall,
152
175
  sortKeys,
153
176
  } from "./canonical.js";
154
- export { expandHome } from "./paths.js";
177
+ export { expandHome, resolveSafePath, isPathTraversal, canonicalizeFsPath } from "./paths.js";
155
178
  export { splitSubcommands } from "./tokenizer.js";
156
-
179
+
180
+
181
+ export { redactCredentials, redactUrl } from "./redact.js";
@@ -0,0 +1,57 @@
1
+ // lib/inspect-env.js
2
+ // Inspection of environment exfiltration, memory dumps, and bare env commands.
3
+
4
+ export function basename(path) {
5
+ const parts = String(path).replace(/\\/g, "/").split("/");
6
+ return parts[parts.length - 1] || "";
7
+ }
8
+
9
+ /**
10
+ * Inspects argv and command strings for process environment leaks and credential dumping.
11
+ *
12
+ * @param {string[]} words - Command words
13
+ * @param {string} [fullCommand] - Full unparsed command string
14
+ * @returns {{ deny: boolean, reason?: string, detail?: string }}
15
+ */
16
+ export function checkEnvExfiltration(words, fullCommand = "") {
17
+ if (!Array.isArray(words) || words.length === 0) return { deny: false };
18
+ const cmd = basename(words[0]);
19
+
20
+ // 1. Reading /proc/*/environ
21
+ if (fullCommand && /\/proc\/(?:self|\$\$|\d+)\/environ\b/i.test(fullCommand)) {
22
+ return { deny: true, reason: "env-dump-leak", detail: "Attempted read of process environ file" };
23
+ }
24
+
25
+ // 2. Bare env
26
+ if (cmd === "env") {
27
+ if (words.length === 1) {
28
+ return { deny: true, reason: "env-dump-leak", detail: "Bare env command dumps all process secrets" };
29
+ }
30
+ const nonEnvWords = words.slice(1).filter((w) => !w.startsWith("-"));
31
+ const onlyAssignments = nonEnvWords.length > 0 && nonEnvWords.every((w) => w.includes("="));
32
+ if (nonEnvWords.length === 0 || onlyAssignments) {
33
+ return { deny: true, reason: "env-dump-leak", detail: "env invocation without command dumps environment" };
34
+ }
35
+ }
36
+
37
+ // 3. Bare printenv or sensitive key read
38
+ if (cmd === "printenv") {
39
+ if (words.length === 1) {
40
+ return { deny: true, reason: "env-dump-leak", detail: "Bare printenv command dumps all process secrets" };
41
+ }
42
+ for (const w of words.slice(1)) {
43
+ if (/(?:secret|token|key|password|credential|auth|dsh_)/i.test(w)) {
44
+ return { deny: true, reason: "env-dump-leak", detail: "printenv querying sensitive variable" };
45
+ }
46
+ }
47
+ }
48
+
49
+ // 4. export without assignment
50
+ if (cmd === "export") {
51
+ if (words.length === 1 || (words.length === 2 && words[1] === "-p")) {
52
+ return { deny: true, reason: "env-dump-leak", detail: "export without assignment dumps environment" };
53
+ }
54
+ }
55
+
56
+ return { deny: false };
57
+ }
package/lib/inspect.js CHANGED
@@ -1,54 +1,12 @@
1
-
2
- export function checkEnvExfiltration(words, fullCommand = "") {
3
- if (!Array.isArray(words) || words.length === 0) return { deny: false };
4
- const cmd = basename(words[0]);
5
-
6
- // 1. Reading /proc/*/environ
7
- if (fullCommand && /\/proc\/(?:self|\$\$|\d+)\/environ\b/i.test(fullCommand)) {
8
- return { deny: true, reason: "env-dump-leak", detail: "Attempted read of process environ file" };
9
- }
10
-
11
- // 2. Bare env
12
- if (cmd === "env") {
13
- if (words.length === 1) {
14
- return { deny: true, reason: "env-dump-leak", detail: "Bare env command dumps all process secrets" };
15
- }
16
- const nonEnvWords = words.slice(1).filter((w) => !w.startsWith("-"));
17
- const onlyAssignments = nonEnvWords.length > 0 && nonEnvWords.every((w) => w.includes("="));
18
- if (nonEnvWords.length === 0 || onlyAssignments) {
19
- return { deny: true, reason: "env-dump-leak", detail: "env invocation without command dumps environment" };
20
- }
21
- }
22
-
23
- // 3. Bare printenv or sensitive key read
24
- if (cmd === "printenv") {
25
- if (words.length === 1) {
26
- return { deny: true, reason: "env-dump-leak", detail: "Bare printenv command dumps all process secrets" };
27
- }
28
- for (const w of words.slice(1)) {
29
- if (/(?:secret|token|key|password|credential|auth|dsh_)/i.test(w)) {
30
- return { deny: true, reason: "env-dump-leak", detail: "printenv querying sensitive variable" };
31
- }
32
- }
33
- }
34
-
35
- // 4. export without assignment
36
- if (cmd === "export") {
37
- if (words.length === 1 || (words.length === 2 && words[1] === "-p")) {
38
- return { deny: true, reason: "env-dump-leak", detail: "export without assignment dumps environment" };
39
- }
40
- }
41
-
42
- return { deny: false };
43
- }
44
-
1
+ import { checkEnvExfiltration } from "./inspect-env.js";
2
+ export { checkEnvExfiltration } from "./inspect-env.js";
45
3
  import { DYNAMIC_MARKER, HEREDOC_OPS, REDIR_OPS, SPLIT_OPS, tokenize, readCommandSubstitution, readBacktick, readParameterExpansion } from "./tokenizer.js";
46
- import { expandHome, isProtectedConfigPath } from "./paths.js";
4
+ import { expandHome, isProtectedConfigPath, resolveSafePath, isPathTraversal, canonicalizeFsPath, normalizeSeparators } from "./paths.js";
47
5
  import { askMessage, denyMessage, sessionTag } from "./messages.js";
48
6
 
49
7
  export { DYNAMIC_MARKER, tokenize, splitSubcommands } from "./tokenizer.js";
50
8
  export { askMessage, denyMessage, sessionTag } from "./messages.js";
51
- export { expandHome, isProtectedConfigPath } from "./paths.js";
9
+ export { expandHome, isProtectedConfigPath, resolveSafePath, isPathTraversal, canonicalizeFsPath, normalizeSeparators } from "./paths.js";
52
10
 
53
11
  // Pure command/path inspector for dsh-approval-gate.
54
12
  // No Cordis imports: unit tests run this without the harness.
@@ -522,6 +480,13 @@ export function inspectBashCommand(command, depth) {
522
480
 
523
481
  for (const redirect of redirectsOf(seg)) {
524
482
  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, "/")) {
488
+ return deny("path-traversal", redirect.path);
489
+ }
525
490
  if (isProtectedPath(redirect.path)) return deny("secret-write", redirect.path);
526
491
  }
527
492
 
@@ -596,7 +561,16 @@ export function inspectExecution(execution, config) {
596
561
  if (typeof name === "string" && fileTools.has(name)) {
597
562
  const path = pathFromArgs(args);
598
563
  if (!path) return ask("unknown-write-target", "file write target could not be identified", name);
599
- if (isProtectedPath(path)) return deny("secret-write", path);
564
+ if (config.workspaceDir) {
565
+ const safeCheck = resolveSafePath(path, config.workspaceDir);
566
+ if (!safeCheck.safe) return deny("path-traversal", safeCheck.reason || "path-traversal-escape", path);
567
+ if (isProtectedPath(safeCheck.canonical || path)) return deny("secret-write", path);
568
+ } else {
569
+ if (isPathTraversal(path, process.cwd())) {
570
+ return deny("path-traversal", "path-traversal-escape", path);
571
+ }
572
+ if (isProtectedPath(path)) return deny("secret-write", path);
573
+ }
600
574
  }
601
575
  return allow();
602
576
  }
package/lib/messages.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { redactCredentials } from "./redact.js";
1
2
  export const LOCALE_NS = "dsh-approval-gate";
2
3
 
3
4
  export const MESSAGES = Object.freeze({
@@ -6,6 +7,7 @@ export const MESSAGES = Object.freeze({
6
7
  detectedLabel: "Detected",
7
8
  commandLabel: "Command",
8
9
  parseReasonLabel: "Parse reason",
10
+ hintPrefix: "Hint",
9
11
  detailProcessSubstitution: "process substitution needs confirmation",
10
12
  detailUnclosedProcessSubstitution: "unclosed process substitution",
11
13
  detailUnclosedCommandSubstitution: "unclosed command substitution",
@@ -45,12 +47,24 @@ export const MESSAGES = Object.freeze({
45
47
  ruleEval: "Dynamic shell evaluation",
46
48
  ruleNestedShell: "Nested shell depth limit",
47
49
  ruleInterpreterPayload: "Unsafe interpreter payload",
50
+ ruleEnvDumpLeak: "Process environment or credential exfiltration",
51
+ rulePathTraversal: "Directory traversal attack detected",
52
+ ruleBandHardDeny: "Command matched Band 0 critical security blocklist",
53
+ ruleCircuitBreakerTripped: "Circuit breaker tripped due to repeated security denials",
54
+ hintRecursiveRm: "Specify exact target files instead of recursive deletion",
55
+ hintPathTraversal: "Keep operations strictly within the project directory",
56
+ hintSecretWrite: "Modify configuration via settings or environment variables, not raw file overwrite",
57
+ hintEnvDumpLeak: "Query specific non-secret environment variables individually",
58
+ hintCircuitBreakerTripped: "Request manual operator approval before attempting new system commands",
59
+ hintDestructiveSql: "Use migrations or execute non-destructive database operations",
60
+ hintCurlPipe: "Download the script, inspect its content, and execute locally",
48
61
  },
49
62
  zh: {
50
63
  blockedPrefix: "dsh-approval-gate 规则已阻止",
51
64
  detectedLabel: "检测到",
52
65
  commandLabel: "命令",
53
66
  parseReasonLabel: "解析原因",
67
+ hintPrefix: "建议",
54
68
  detailProcessSubstitution: "进程替换需要确认",
55
69
  detailUnclosedProcessSubstitution: "进程替换未闭合",
56
70
  detailUnclosedCommandSubstitution: "命令替换未闭合",
@@ -58,24 +72,24 @@ export const MESSAGES = Object.freeze({
58
72
  detailUnclosedParameterExpansion: "参数展开未闭合",
59
73
  detailUnclosedSingleQuote: "单引号未闭合",
60
74
  detailUnclosedDoubleQuote: "双引号未闭合",
61
- detailDanglingEscape: "转义符不完整",
62
- detailInvalidHeredoc: "here-document 定界符无效",
63
- detailUnterminatedHeredoc: "未找到 here-document 结束标记",
64
- detailUnsupportedGrouping: "分析器不支持此 shell 分组语法",
75
+ detailDanglingEscape: "未完成的转义符",
76
+ detailInvalidHeredoc: "无效的 heredoc 定界符",
77
+ detailUnterminatedHeredoc: "未找到 heredoc 结束符",
78
+ detailUnsupportedGrouping: "分析器不支持该 shell 分组语法",
65
79
  detailUnsupported: "不支持的 shell 语法",
66
- approvalRequired: "需要审批",
67
- askUnknown: "无法完整检查此命令;允许前请先检查",
68
- askUnparseable: "无法完整检查 shell 语法;允许前请先检查",
69
- askUnknownFormat: "无法检查此工具调用格式;允许前请先检查",
70
- askUnknownWriteTarget: "无法识别文件写入目标;允许前请先检查",
71
- askDynamicCommand: "无法验证展开后的命令名称;允许前请先检查",
72
- askDynamicArguments: "无法验证敏感命令展开后的参数;允许前请先检查",
73
- askDynamicRedirect: "无法验证展开后的重定向目标;允许前请先检查",
74
- askScriptFile: "守卫无法读取脚本内容;允许前请先检查",
75
- askShellStdin: "守卫无法完整检查 shell 输入;允许前请先检查",
76
- askInterpreterPayload: "未能完整分析解释器的副作用;允许前请先检查",
77
- askCredentialInArgv: "展开后的授权凭据会成为进程参数;请使用安全的凭据 API 帮助程序或在允许前检查",
78
- ruleUnknown: "命中安全阻止规则",
80
+ approvalRequired: "需要批准",
81
+ askUnknown: "命令无法完整检查;请在允许前进行审查",
82
+ askUnparseable: "Shell 语法无法完整检查;请在允许前进行审查",
83
+ askUnknownFormat: "工具调用格式无法检查;请在允许前进行审查",
84
+ askUnknownWriteTarget: "无法识别文件写入目标;请在允许前进行审查",
85
+ askDynamicCommand: "无法验证展开后的命令名;请在允许前进行审查",
86
+ askDynamicArguments: "敏感命令的展开参数无法验证;请在允许前进行审查",
87
+ askDynamicRedirect: "展开后的重定向目标无法验证;请在允许前进行审查",
88
+ askScriptFile: "卫兵无法获取脚本文件内容;请在允许前进行审查",
89
+ askShellStdin: "卫兵无法完整获取 Shell 输入;请在允许前进行审查",
90
+ askInterpreterPayload: "解释器副作用未完全分析;请在允许前进行审查",
91
+ askCredentialInArgv: "展开后的授权凭据作为进程参数传递;请使用凭据安全的 API 辅助工具,或在允许前进行审查",
92
+ ruleUnknown: "命中阻止性安全规则",
79
93
  ruleRecursiveRm: "递归删除文件",
80
94
  ruleProcessSignal: "终止进程",
81
95
  ruleServiceControl: "更改服务状态",
@@ -84,35 +98,47 @@ export const MESSAGES = Object.freeze({
84
98
  ruleFindDelete: "find -delete",
85
99
  ruleDestructiveSql: "破坏性 SQL",
86
100
  ruleSecretWrite: "写入受保护的敏感文件",
87
- ruleDeviceWrite: "写入设备或受保护文件",
101
+ ruleDeviceWrite: "写入系统设备或受保护文件",
88
102
  ruleMkfsDevice: "格式化文件系统",
89
- ruleCurlPipe: "将下载内容传递给 shell",
90
- ruleEval: "动态 shell 求值",
91
- ruleNestedShell: "嵌套 shell 超出检查深度",
92
- ruleInterpreterPayload: "不安全的解释器代码",
103
+ ruleCurlPipe: "将下载内容通过管道传入 shell",
104
+ ruleEval: "动态 shell 执行 (eval)",
105
+ ruleNestedShell: "超出嵌套 shell 深度限制",
106
+ ruleInterpreterPayload: "不安全的解释器执行代码",
107
+ ruleEnvDumpLeak: "尝试泄露环境变量与敏感凭据",
108
+ rulePathTraversal: "检测到路径穿越攻击",
109
+ ruleBandHardDeny: "命令匹配 Band 0 关键安全黑名单",
110
+ ruleCircuitBreakerTripped: "因连续多次安全拒绝,断路熔断器已触发",
111
+ hintRecursiveRm: "请指定具体目标文件,避免递归删除",
112
+ hintPathTraversal: "请将操作限制在项目根目录内",
113
+ hintSecretWrite: "请通过设置或环境变量修改配置,不要直接覆盖敏感配置文件",
114
+ hintEnvDumpLeak: "请单独查询非敏感环境变量,避免全量导出",
115
+ hintCircuitBreakerTripped: "请在尝试新的系统命令前请求操作员人工授权",
116
+ hintDestructiveSql: "请使用版本化数据库迁移,或执行非破坏性查询",
117
+ hintCurlPipe: "先将脚本下载到本地检查内容,再安全执行",
93
118
  },
94
119
  ru: {
95
120
  blockedPrefix: "Заблокировано правилом dsh-approval-gate",
96
121
  detectedLabel: "Обнаружено",
97
122
  commandLabel: "Команда",
98
- parseReasonLabel: "Причина синтаксического анализа",
99
- detailProcessSubstitution: "процессная подстановка требует подтверждения",
100
- detailUnclosedProcessSubstitution: "незакрытая процессная подстановка",
123
+ parseReasonLabel: "Причина разбора",
124
+ hintPrefix: "Подсказка",
125
+ detailProcessSubstitution: "подстановка процесса требует подтверждения",
126
+ detailUnclosedProcessSubstitution: "незакрытая подстановка процесса",
101
127
  detailUnclosedCommandSubstitution: "незакрытая подстановка команды",
102
- detailUnclosedBacktick: "незакрытая подстановка команды в обратных кавычках",
103
- detailUnclosedParameterExpansion: "незакрытое расширение параметров",
128
+ detailUnclosedBacktick: "незакрытая подстановка в обратных кавычках",
129
+ detailUnclosedParameterExpansion: "незакрытое расширение параметра",
104
130
  detailUnclosedSingleQuote: "незакрытая одинарная кавычка",
105
131
  detailUnclosedDoubleQuote: "незакрытая двойная кавычка",
106
132
  detailDanglingEscape: "незавершённое экранирование",
107
133
  detailInvalidHeredoc: "неверный разделитель here-document",
108
- detailUnterminatedHeredoc: "завершитель here-document не найден",
109
- detailUnsupportedGrouping: "группировка шелла не поддерживается анализатором",
110
- detailUnsupported: "неподдерживаемый синтаксис шелла",
134
+ detailUnterminatedHeredoc: "ограничитель here-document не найден",
135
+ detailUnsupportedGrouping: "группировка shell не поддерживается анализатором",
136
+ detailUnsupported: "неподдерживаемый синтаксис shell",
111
137
  approvalRequired: "Требуется подтверждение",
112
138
  askUnknown: "Команда не может быть проверена полностью; проверьте её перед подтверждением",
113
- askUnparseable: "Синтаксис шелла не может быть проверен полностью; проверьте его перед подтверждением",
139
+ askUnparseable: "Синтаксис shell не может быть проверен полностью; проверьте его перед подтверждением",
114
140
  askUnknownFormat: "Формат вызова инструмента не может быть проверен; проверьте его перед подтверждением",
115
- askUnknownWriteTarget: "Целевой файл для записи не определён; проверьте его перед подтверждением",
141
+ askUnknownWriteTarget: "Цель записи файла не может быть определена; проверьте её перед подтверждением",
116
142
  askDynamicCommand: "Развёрнутое имя команды не может быть проверено; проверьте его перед подтверждением",
117
143
  askDynamicArguments: "Развёрнутые аргументы чувствительной команды не могут быть проверены; проверьте их перед подтверждением",
118
144
  askDynamicRedirect: "Развёрнутая цель перенаправления не может быть проверена; проверьте её перед подтверждением",
@@ -135,6 +161,17 @@ export const MESSAGES = Object.freeze({
135
161
  ruleEval: "Динамическое выполнение шелла (eval)",
136
162
  ruleNestedShell: "Превышен лимит вложенности шелла",
137
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: "Скачайте скрипт локально, проверьте его содержимое и выполните безопасно",
138
175
  },
139
176
  });
140
177
 
@@ -146,11 +183,8 @@ export function sessionTag(execution) {
146
183
  }
147
184
 
148
185
  function safeSnippet(value) {
149
- return String(value || "")
150
- .replace(/[\r\n\t\u0000-\u001f]+/g, " ")
151
- .replace(/(authorization\s*:\s*(?:bearer|token)\s+)\S+/gi, "$1[redacted]")
152
- .replace(/((?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*)[^\s,;]+/gi, "$1[redacted]")
153
- .slice(0, 120);
186
+ const flattened = String(value || "").replace(/[\r\n\t\u0000-\u001f]+/g, " ");
187
+ return redactCredentials(flattened).slice(0, 120);
154
188
  }
155
189
 
156
190
  function translated(translate, key, logger) {
@@ -183,6 +217,19 @@ const RULE_KEYS = Object.freeze({
183
217
  "nested-shell": "ruleNestedShell",
184
218
  "interpreter-payload": "ruleInterpreterPayload",
185
219
  "env-dump-leak": "ruleEnvDumpLeak",
220
+ "path-traversal": "rulePathTraversal",
221
+ "band-hard-deny": "ruleBandHardDeny",
222
+ "circuit-breaker-tripped": "ruleCircuitBreakerTripped",
223
+ });
224
+
225
+ const HINT_KEYS = Object.freeze({
226
+ "recursive-rm": "hintRecursiveRm",
227
+ "path-traversal": "hintPathTraversal",
228
+ "secret-write": "hintSecretWrite",
229
+ "env-dump-leak": "hintEnvDumpLeak",
230
+ "circuit-breaker-tripped": "hintCircuitBreakerTripped",
231
+ "destructive-sql": "hintDestructiveSql",
232
+ "curl-pipe": "hintCurlPipe",
186
233
  });
187
234
 
188
235
  const ASK_KEYS = Object.freeze({
@@ -213,10 +260,15 @@ const DETAIL_KEYS = Object.freeze({
213
260
  });
214
261
 
215
262
  export function denyMessage(hit, who, translate, logger) {
216
- const key = RULE_KEYS[hit && hit.reason] || "ruleUnknown";
263
+ const reason = hit && hit.reason;
264
+ const key = RULE_KEYS[reason] || "ruleUnknown";
217
265
  const snippet = safeSnippet(hit && hit.snippet);
218
266
  const parts = [translated(translate, "blockedPrefix", logger), translated(translate, key, logger)];
219
267
  if (snippet) parts.push(translated(translate, "detectedLabel", logger) + ": " + snippet);
268
+ const hintKey = HINT_KEYS[reason];
269
+ if (hintKey) {
270
+ parts.push(translated(translate, "hintPrefix", logger) + ": " + translated(translate, hintKey, logger));
271
+ }
220
272
  if (who) parts.push(who.trim());
221
273
  return parts.join(" — ") + ".";
222
274
  }
package/lib/paths.js CHANGED
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  // lib/paths.js
2
3
  // Cross-platform path normalization, home expansion, canonicalization, and path boundary traversal protection for dsh-approval-gate.
3
4
 
@@ -146,18 +147,28 @@ export function isPathTraversal(p, baseDir = "/workspace") {
146
147
  // Detect encoded traversal sequences
147
148
  if (/(?:%2e|%2f|%5c|\.\.)(?:%2e|%2f|%5c|\.\.)/i.test(p)) return true;
148
149
 
149
- const normBase = canonicalizeFsPath(baseDir);
150
- const isAbsolute = /^(?:\/|[A-Za-z]:\/|\/\/)/.test(normalizeSeparators(p));
150
+ const norm = normalizeSeparators(p);
151
+ if (/^\/dev\/(?:null|zero|stdout|stderr|fd\/\d+)$/.test(norm)) {
152
+ return false;
153
+ }
154
+
155
+ const isAbsolute = /^(?:\/|[A-Za-z]:\/|\/\/)/.test(norm);
151
156
 
152
- let candidate = "";
157
+ const normBase = canonicalizeFsPath(baseDir);
153
158
  if (isAbsolute) {
154
- candidate = canonicalizeFsPath(p);
155
- } else {
156
- candidate = canonicalizeFsPath(`${normBase}/${p}`);
159
+ // For absolute path, it is a traversal only if caller specifies a strict baseDir and candidate is outside
160
+ const candidate = canonicalizeFsPath(p);
161
+ if (baseDir && baseDir !== "/" && normBase !== "/") {
162
+ if (candidate === normBase) return false;
163
+ return !candidate.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`);
164
+ }
165
+ return false;
157
166
  }
158
167
 
168
+ // Relative path traversal checking
169
+ const candidate = canonicalizeFsPath(`${normBase}/${p}`);
159
170
  if (candidate === normBase) return false;
160
- return !(candidate.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`));
171
+ return !candidate.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`);
161
172
  }
162
173
 
163
174
  /**
@@ -168,6 +179,12 @@ export function isPathTraversal(p, baseDir = "/workspace") {
168
179
  * @returns {{ safe: boolean, path: string, canonical: string, reason?: string }}
169
180
  */
170
181
  export function resolveSafePath(p, baseDir = "/workspace") {
182
+ if (typeof p !== "string" || p.trim() === "") {
183
+ return { safe: false, reason: "empty-path", path: p, canonical: "" };
184
+ }
185
+
186
+ const normBase = canonicalizeFsPath(baseDir);
187
+
171
188
  if (isPathTraversal(p, baseDir)) {
172
189
  return {
173
190
  safe: false,
@@ -176,9 +193,42 @@ export function resolveSafePath(p, baseDir = "/workspace") {
176
193
  canonical: canonicalizeFsPath(p),
177
194
  };
178
195
  }
179
- const normBase = canonicalizeFsPath(baseDir);
196
+
180
197
  const isAbsolute = /^(?:\/|[A-Za-z]:\/|\/\/)/.test(normalizeSeparators(p));
181
198
  const resolved = isAbsolute ? canonicalizeFsPath(p) : canonicalizeFsPath(`${normBase}/${p}`);
199
+
200
+ if (isAbsolute && baseDir && normBase !== "/" && !resolved.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`) && resolved !== normBase) {
201
+ return {
202
+ safe: false,
203
+ reason: "path-traversal-escape",
204
+ path: p,
205
+ canonical: resolved,
206
+ };
207
+ }
208
+
209
+ // Inspect filesystem symlinks if the target file or its parent directory exists
210
+ try {
211
+ if (fs.existsSync(resolved)) {
212
+ const real = fs.realpathSync(resolved);
213
+ const normReal = canonicalizeFsPath(real);
214
+ if (!normReal.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`) && normReal !== normBase) {
215
+ return {
216
+ safe: false,
217
+ reason: "symlink-traversal-escape",
218
+ path: p,
219
+ canonical: normReal,
220
+ };
221
+ }
222
+ return {
223
+ safe: true,
224
+ path: p,
225
+ canonical: normReal,
226
+ };
227
+ }
228
+ } catch {
229
+ // Non-filesystem paths or missing directories fall back safely to lexical canonical
230
+ }
231
+
182
232
  return {
183
233
  safe: true,
184
234
  path: p,
@@ -267,4 +317,4 @@ export function isProtectedConfigPath(p, env = process.env) {
267
317
 
268
318
  return false;
269
319
  }
270
-
320
+
package/lib/redact.js ADDED
@@ -0,0 +1,47 @@
1
+ // lib/redact.js
2
+ // Sensitive credential and URL masking for audit logs, approval messages, and traces.
3
+
4
+ const SENSITIVE_QUERY_PARAMS = /([?&](?:token|key|api_key|apikey|access_token|secret|password|passwd|auth|client_secret)=)[^&#\s]+/gi;
5
+ const USER_INFO_CREDENTIALS = /((?:https?|ftp|ssh|git):\/\/[^:@\/\s]+:)[^@\/\s]+(@)/gi;
6
+ const AUTH_HEADERS = /((?:authorization|proxy-authorization)\s*:\s*(?:bearer|basic|token)\s+)\S+/gi;
7
+ const SENSITIVE_ASSIGNMENTS = /((?:^|[\s,;])(?:password|passwd|secret|token|api[_-]?key|access[_-]?key)\s*[:=]\s*)[^\s,;]+/gi;
8
+ const PRIVATE_KEY_BLOCK = /-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----/g;
9
+ const TOKEN_PREFIX_PATTERNS = /\b(?:ghp_[a-zA-Z0-9]{30,}|gho_[a-zA-Z0-9]{30,}|github_pat_[a-zA-Z0-9_]{30,}|xox[baprs]-[0-9a-zA-Z-]+|AKIA[0-9A-Z]{16})\b/g;
10
+
11
+ /**
12
+ * Redacts credentials in userinfo and sensitive query params from a URL string.
13
+ *
14
+ * @param {string} urlStr
15
+ * @returns {string}
16
+ */
17
+ export function redactUrl(urlStr) {
18
+ if (typeof urlStr !== "string" || urlStr.trim() === "") return "";
19
+ let redacted = urlStr.replace(USER_INFO_CREDENTIALS, "$1***$2");
20
+ redacted = redacted.replace(SENSITIVE_QUERY_PARAMS, "$1***");
21
+ return redacted;
22
+ }
23
+
24
+ /**
25
+ * Sanitizes and masks sensitive credentials, tokens, passwords, and URLs in arbitrary text.
26
+ *
27
+ * @param {string} text - Arbitrary command, snippet, or log text
28
+ * @returns {string} Text with sensitive tokens masked with ***
29
+ */
30
+ export function redactCredentials(text) {
31
+ if (typeof text !== "string") return "";
32
+ if (text.trim() === "") return text;
33
+
34
+ let out = text;
35
+ // 1. Private key blocks
36
+ out = out.replace(PRIVATE_KEY_BLOCK, "[REDACTED PRIVATE KEY]");
37
+ // 2. URLs with user:pass@host
38
+ out = redactUrl(out);
39
+ // 3. Authorization headers
40
+ out = out.replace(AUTH_HEADERS, "$1***");
41
+ // 4. Secret variable assignments
42
+ out = out.replace(SENSITIVE_ASSIGNMENTS, "$1***");
43
+ // 5. Well-known token prefixes
44
+ out = out.replace(TOKEN_PREFIX_PATTERNS, "***");
45
+
46
+ return out;
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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",