@amaster.ai/dsh-policy 0.1.33 → 0.1.35

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/README.md CHANGED
@@ -73,9 +73,9 @@ Plugin-level fields: `enabled` (master switch) and `commandKeys` (argument keys
73
73
  ### Matching semantics
74
74
 
75
75
  - **Every condition on a rule must hold** for the rule to match (AND). A rule with no conditions beyond `tool` matches every call of that tool.
76
- - **Shell commands are checked segment by segment.** Compound commands (`a && b | c`, newlines, background `&`) are split, quote-aware, and `$( )` / backtick substitutions are extracted as their own segments — `cd /tmp && npm install` and `echo "$(npm install)"` both hit the `npm` rule.
76
+ - **Shell commands are checked segment by segment.** Compound commands (`a && b | c`, newlines, background `&`) are split, quote-aware, and the executable command word is normalized through quotes, escapes and line continuations. Bash `$( )`, backtick and `<()` / `>()` substitutions are extracted as their own segments; here-doc bodies are data, except for real substitutions in unquoted bodies. PowerShell backticks are escapes, not Bash-style substitutions.
77
77
  - **Priority resolves competition within one segment**; across segments the aggregation is conservative: any segment's `deny` denies the whole call, then any `ask` escalates, and `allow` requires every segment decided allow. A broad deny is still overridable by a specific allow because both compete on the same segment (`bun run lint` at 300 vs `bun run` at 200).
78
- - **No rule matches → the call passes through** to the rest of the `tools/pre-execute` chain (`next()`), so dsh's own gates and other plugins keep their say. Invalid regexes are config errors and fail the plugin load; a runtime evaluation failure is logged with the `[dsh-policy]` prefix and delegates onward — the gate never breaks the agent loop.
78
+ - **No rule matches → the call passes through** to the rest of the `tools/pre-execute` chain (`next()`), so dsh's own gates and other plugins keep their say. Invalid regexes are config errors and fail the plugin load; a runtime evaluation failure is logged with the `[dsh-policy]` prefix and denied (fail-closed) without breaking the agent loop.
79
79
 
80
80
  ### What the gate covers
81
81
 
@@ -111,7 +111,7 @@ A policy plugin is advisory gating, not containment: deny rules keep a well-beha
111
111
 
112
112
  | @amaster.ai/dsh-policy | dsh | cordis |
113
113
  | --- | --- | --- |
114
- | 0.1.x | `>=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1` (tested `0.2.1-rc.4`) | `^4.0.1` (tested `4.0.5`) |
114
+ | 0.1.x | `>=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1` (tested `0.2.1-rc.5`) | `^4.0.1` (tested `4.0.5`) |
115
115
 
116
116
  Peer dependency: `@deepseek-ai/dsh-tools` (the `tools/pre-execute` gate). The approval seam (`@deepseek-ai/dsh-user-approval`) is optional and only involved in `ask` decisions.
117
117
 
package/lib/index.js CHANGED
@@ -73,10 +73,9 @@ export function apply(ctx, config) {
73
73
  }
74
74
  }
75
75
  catch (error) {
76
- // A policy gate must never break the agent loop: a broken evaluation
77
- // delegates to the rest of the chain (dsh's own gates keep their say).
76
+ // Keep the agent loop alive without turning a broken policy into an allow.
78
77
  console.error('[dsh-policy] evaluation failed:', error);
79
- return next();
78
+ return { kind: 'deny', reason: 'policy evaluation failed' };
80
79
  }
81
80
  });
82
81
  }
package/lib/rule.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * - No matching rule at all → `undefined` → the plugin delegates to the rest
18
18
  * of the `tools/pre-execute` chain (dsh's own gates keep their say).
19
19
  */
20
- import { matchesPrefix, splitSegments } from './shell.js';
20
+ import { matchesPrefix, normalizeCommandWord, splitSegments } from './shell.js';
21
21
  /** Fail-closed tie-break within one priority tier. */
22
22
  const SEVERITY = { allow: 0, ask: 1, deny: 2 };
23
23
  const prefer = (a, b) => {
@@ -101,13 +101,13 @@ function argsMatch(rule, args, argsJson) {
101
101
  return cond.res.some((re) => re.test(text));
102
102
  });
103
103
  }
104
- function matchesSegment(rule, segment) {
104
+ function matchesSegment(rule, segment, dialect) {
105
105
  const hasCommandCondition = rule.commandPrefixes !== undefined || rule.commandRes !== undefined;
106
106
  // No command string on this call: command conditions cannot be satisfied.
107
107
  if (segment === undefined)
108
108
  return !hasCommandCondition;
109
- return (anyMatch(rule.commandPrefixes, (prefix) => matchesPrefix(segment, prefix)) &&
110
- anyMatch(rule.commandRes, (re) => re.test(segment)));
109
+ return (anyMatch(rule.commandPrefixes, (prefix) => matchesPrefix(segment, prefix, dialect)) &&
110
+ anyMatch(rule.commandRes, (re) => re.test(normalizeCommandWord(segment, dialect))));
111
111
  }
112
112
  export function evaluate(rules, toolName, args, commandKeys) {
113
113
  const candidates = rules.filter((rule) => rule.tools.includes('*') || rule.tools.includes(toolName));
@@ -117,7 +117,8 @@ export function evaluate(rules, toolName, args, commandKeys) {
117
117
  const command = extractCommand(args, commandKeys);
118
118
  // A call without a shell command is one virtual whole-call segment — only
119
119
  // rules without command conditions can match it.
120
- const segments = command === undefined ? [undefined] : splitSegments(command);
120
+ const dialect = toolName === 'pwsh' ? 'pwsh' : 'bash';
121
+ const segments = command === undefined ? [undefined] : splitSegments(command, dialect);
121
122
  let undecided = false;
122
123
  const best = {};
123
124
  for (const segment of segments) {
@@ -125,7 +126,7 @@ export function evaluate(rules, toolName, args, commandKeys) {
125
126
  for (const rule of candidates) {
126
127
  if (!argsMatch(rule, args, argsJson))
127
128
  continue;
128
- if (!matchesSegment(rule, segment))
129
+ if (!matchesSegment(rule, segment, dialect))
129
130
  continue;
130
131
  winner = winner === undefined ? rule : prefer(winner, rule);
131
132
  }
package/lib/shell.js CHANGED
@@ -3,18 +3,76 @@
3
3
  *
4
4
  * dsh's bash/pwsh tools receive one raw command string, so a prefix rule that
5
5
  * only examined the whole string would miss `cd /tmp && npm install`. The
6
- * splitter is quote-aware and pulls `$( )` / backtick substitutions out as
7
- * their own segments (recursively) — `echo "$(npm install)"` must still hit an
8
- * npm rule. Over-splitting is safe (every piece is checked on its own);
9
- * `commandPrefix` / `commandRegex` rules match against these segments.
6
+ * splitter is quote-aware, normalizes the executable command word, and pulls
7
+ * command/process substitutions out as their own segments (recursively).
8
+ * Here-doc bodies stay data except for real substitutions in unquoted bodies;
9
+ * `commandPrefix` / `commandRegex` rules match against the resulting segments.
10
10
  */
11
11
  const isWordBoundary = (ch) => ch === undefined || /\s/.test(ch);
12
+ /** Remove shell quoting and escapes from the first word, leaving its arguments untouched. */
13
+ export function normalizeCommandWord(segment, dialect = 'bash') {
14
+ let i = 0;
15
+ while (/\s/.test(segment[i] ?? ''))
16
+ i += 1;
17
+ const start = i;
18
+ let word = '';
19
+ let quote;
20
+ while (i < segment.length) {
21
+ const ch = segment[i];
22
+ if (quote === undefined && /\s/.test(ch))
23
+ break;
24
+ if (quote !== undefined) {
25
+ if (ch === quote) {
26
+ quote = undefined;
27
+ i += 1;
28
+ }
29
+ else if (dialect === 'pwsh' && ch === '`' && quote !== "'" && i + 1 < segment.length) {
30
+ word += segment[i + 1];
31
+ i += 2;
32
+ }
33
+ else if (dialect === 'bash' && ch === '\\' && quote !== "'" && i + 1 < segment.length) {
34
+ const next = segment[i + 1];
35
+ if (next === '\n')
36
+ i += 2;
37
+ else {
38
+ if ('$`"\\'.includes(next))
39
+ word += next;
40
+ else
41
+ word += ch + next;
42
+ i += 2;
43
+ }
44
+ }
45
+ else {
46
+ word += ch;
47
+ i += 1;
48
+ }
49
+ }
50
+ else if (ch === "'" || ch === '"') {
51
+ quote = ch;
52
+ i += 1;
53
+ }
54
+ else if (dialect === 'pwsh' && ch === '`' && i + 1 < segment.length) {
55
+ word += segment[i + 1];
56
+ i += 2;
57
+ }
58
+ else if (dialect === 'bash' && ch === '\\' && i + 1 < segment.length) {
59
+ if (segment[i + 1] !== '\n')
60
+ word += segment[i + 1];
61
+ i += 2;
62
+ }
63
+ else {
64
+ word += ch;
65
+ i += 1;
66
+ }
67
+ }
68
+ return segment.slice(0, start) + word + segment.slice(i);
69
+ }
12
70
  /**
13
71
  * A prefix matches a segment only at a word boundary: `npm` matches
14
72
  * `npm install` and `npm` itself, never `npmx` or `npm-cache`.
15
73
  */
16
- export function matchesPrefix(segment, prefix) {
17
- const trimmed = segment.trimStart();
74
+ export function matchesPrefix(segment, prefix, dialect = 'bash') {
75
+ const trimmed = normalizeCommandWord(segment, dialect).trimStart();
18
76
  return trimmed.startsWith(prefix) && isWordBoundary(trimmed[prefix.length]);
19
77
  }
20
78
  /** Read a `$(`...`)` body starting just after the opening paren. */
@@ -45,13 +103,122 @@ function readParenSubshell(text, start) {
45
103
  }
46
104
  return { body: text.slice(start), end: text.length }; // unbalanced: take the rest
47
105
  }
106
+ /** Read a simple here-doc, including a shell-chain tail on its declaration line. */
107
+ function readHereDoc(text, start) {
108
+ // Bash here-strings (<<<) are ordinary input, not here-doc declarations.
109
+ // Check both sides because the splitter also offers the second '<' as a start.
110
+ if (text[start + 2] === '<' || (start > 0 && text[start - 1] === '<'))
111
+ return;
112
+ const match = /^<<(-)?[ \t]*(?:'([^']*)'|"([^"]*)"|([^\s]+))/.exec(text.slice(start));
113
+ if (!match)
114
+ return;
115
+ const rawDelimiter = match[0].replace(/^<<-?[ \t]*/, '');
116
+ const delimiter = normalizeCommandWord(rawDelimiter);
117
+ // Any quote/escape in the delimiter suppresses body expansion, including mixed forms.
118
+ const quoted = delimiter !== rawDelimiter;
119
+ let cursor = start + match[0].length;
120
+ while (text[cursor] === ' ' || text[cursor] === '\t')
121
+ cursor += 1;
122
+ const tailStart = cursor;
123
+ const declarationEnd = text.indexOf('\n', cursor);
124
+ if (declarationEnd === -1)
125
+ return;
126
+ const rawTail = text.slice(tailStart, declarationEnd).replace(/\r$/, '');
127
+ const outputRedirect = /^\s*>>?\s+\S+\s*$/.test(rawTail);
128
+ // Keep unsupported declaration tails conservative instead of guessing shell grammar.
129
+ if (rawTail.trim() && !outputRedirect && !/^(?:&&|\|\||[;|&])/.test(rawTail.trimStart()))
130
+ return;
131
+ const declaration = text
132
+ .slice(start, outputRedirect ? declarationEnd : tailStart)
133
+ .replace(/\r$/, '');
134
+ const tail = rawTail;
135
+ const bodyStart = declarationEnd + 1;
136
+ let lineStart = bodyStart;
137
+ for (;;) {
138
+ const newline = text.indexOf('\n', lineStart);
139
+ const lineEnd = newline === -1 ? text.length : newline;
140
+ let line = text.slice(lineStart, lineEnd);
141
+ if (line.endsWith('\r'))
142
+ line = line.slice(0, -1);
143
+ const candidate = match[1] ? line.replace(/^\t+/, '') : line;
144
+ if (candidate === delimiter) {
145
+ return {
146
+ declaration,
147
+ tail,
148
+ outputRedirect,
149
+ body: text.slice(bodyStart, lineStart),
150
+ quoted,
151
+ end: newline === -1 ? text.length : lineEnd + 1,
152
+ };
153
+ }
154
+ if (newline === -1) {
155
+ return {
156
+ declaration,
157
+ tail,
158
+ outputRedirect,
159
+ body: text.slice(bodyStart),
160
+ quoted,
161
+ end: text.length,
162
+ };
163
+ }
164
+ lineStart = lineEnd + 1;
165
+ }
166
+ }
167
+ /** Extract real substitutions; shell command-line tails optionally honor single/double quotes. */
168
+ function hereDocCommands(body, respectQuotes = false) {
169
+ const commands = [];
170
+ let quote;
171
+ let i = 0;
172
+ while (i < body.length) {
173
+ const ch = body[i];
174
+ if (respectQuotes && quote === "'") {
175
+ if (ch === "'")
176
+ quote = undefined;
177
+ i += 1;
178
+ continue;
179
+ }
180
+ if (ch === '\\' && i + 1 < body.length) {
181
+ i += 2;
182
+ continue;
183
+ }
184
+ if (respectQuotes) {
185
+ if (ch === quote) {
186
+ quote = undefined;
187
+ i += 1;
188
+ continue;
189
+ }
190
+ if (quote === undefined && (ch === "'" || ch === '"')) {
191
+ quote = ch;
192
+ i += 1;
193
+ continue;
194
+ }
195
+ }
196
+ if (ch === '$' && body[i + 1] === '(') {
197
+ const { body: inner, end } = readParenSubshell(body, i + 2);
198
+ commands.push(...splitSegments(inner));
199
+ i = end;
200
+ continue;
201
+ }
202
+ if (ch === '`') {
203
+ let close = i + 1;
204
+ while (close < body.length && body[close] !== '`') {
205
+ close += body[close] === '\\' ? 2 : 1;
206
+ }
207
+ commands.push(...splitSegments(body.slice(i + 1, close)));
208
+ i = close < body.length ? close + 1 : body.length;
209
+ continue;
210
+ }
211
+ i += 1;
212
+ }
213
+ return commands;
214
+ }
48
215
  /**
49
216
  * Split a compound command into the segments a shell would run: pipelines,
50
217
  * `&&`/`||`/`;`/newline chains, background `&`, plus the interiors of
51
218
  * `$( )` and backtick substitutions (which execute even inside double
52
219
  * quotes). Quoted operator characters stay literal.
53
220
  */
54
- export function splitSegments(command) {
221
+ export function splitSegments(command, dialect = 'bash') {
55
222
  const segments = [];
56
223
  let buf = '';
57
224
  let quote;
@@ -64,25 +231,54 @@ export function splitSegments(command) {
64
231
  let i = 0;
65
232
  while (i < command.length) {
66
233
  const ch = command[i];
234
+ if (dialect === 'pwsh' && ch === '`' && quote !== "'" && i + 1 < command.length) {
235
+ buf += ch + command[i + 1];
236
+ i += 2;
237
+ continue;
238
+ }
239
+ if (dialect === 'bash' && quote === undefined && ch === '<' && command[i + 1] === '<') {
240
+ const doc = readHereDoc(command, i);
241
+ if (doc) {
242
+ buf += doc.declaration;
243
+ push();
244
+ if (!doc.quoted)
245
+ segments.push(...hereDocCommands(doc.body));
246
+ segments.push(...(doc.outputRedirect
247
+ ? hereDocCommands(doc.tail, true)
248
+ : splitSegments(doc.tail, dialect)));
249
+ i = doc.end;
250
+ continue;
251
+ }
252
+ }
253
+ // Process substitution is unquoted Bash syntax; its interior executes too.
254
+ if (dialect === 'bash' &&
255
+ quote === undefined &&
256
+ (ch === '<' || ch === '>') &&
257
+ command[i + 1] === '(') {
258
+ const { body, end } = readParenSubshell(command, i + 2);
259
+ segments.push(...splitSegments(body, dialect));
260
+ i = end;
261
+ continue;
262
+ }
67
263
  // Substitution contents execute inside double quotes but are literal in
68
264
  // single quotes — extract them from everywhere except '...'.
69
265
  if (quote !== "'") {
70
266
  if (ch === '$' && command[i + 1] === '(') {
71
267
  const { body, end } = readParenSubshell(command, i + 2);
72
- segments.push(...splitSegments(body));
268
+ segments.push(...splitSegments(body, dialect));
73
269
  i = end;
74
270
  continue;
75
271
  }
76
- if (ch === '`') {
272
+ if (dialect === 'bash' && ch === '`') {
77
273
  const close = command.indexOf('`', i + 1);
78
- segments.push(...splitSegments(close === -1 ? command.slice(i + 1) : command.slice(i + 1, close)));
274
+ segments.push(...splitSegments(close === -1 ? command.slice(i + 1) : command.slice(i + 1, close), dialect));
79
275
  i = close === -1 ? command.length : close + 1;
80
276
  continue;
81
277
  }
82
278
  }
83
279
  if (quote !== undefined) {
84
280
  buf += ch;
85
- if (ch === '\\' && quote !== "'" && i + 1 < command.length) {
281
+ if (dialect === 'bash' && ch === '\\' && quote !== "'" && i + 1 < command.length) {
86
282
  buf += command[i + 1];
87
283
  i += 2;
88
284
  continue;
@@ -98,7 +294,7 @@ export function splitSegments(command) {
98
294
  i += 1;
99
295
  continue;
100
296
  }
101
- if (ch === '\\' && i + 1 < command.length) {
297
+ if (dialect === 'bash' && ch === '\\' && i + 1 < command.length) {
102
298
  buf += ch + command[i + 1];
103
299
  i += 2;
104
300
  continue;
@@ -3,20 +3,23 @@
3
3
  *
4
4
  * dsh's bash/pwsh tools receive one raw command string, so a prefix rule that
5
5
  * only examined the whole string would miss `cd /tmp && npm install`. The
6
- * splitter is quote-aware and pulls `$( )` / backtick substitutions out as
7
- * their own segments (recursively) — `echo "$(npm install)"` must still hit an
8
- * npm rule. Over-splitting is safe (every piece is checked on its own);
9
- * `commandPrefix` / `commandRegex` rules match against these segments.
6
+ * splitter is quote-aware, normalizes the executable command word, and pulls
7
+ * command/process substitutions out as their own segments (recursively).
8
+ * Here-doc bodies stay data except for real substitutions in unquoted bodies;
9
+ * `commandPrefix` / `commandRegex` rules match against the resulting segments.
10
10
  */
11
+ export type ShellDialect = 'bash' | 'pwsh';
12
+ /** Remove shell quoting and escapes from the first word, leaving its arguments untouched. */
13
+ export declare function normalizeCommandWord(segment: string, dialect?: ShellDialect): string;
11
14
  /**
12
15
  * A prefix matches a segment only at a word boundary: `npm` matches
13
16
  * `npm install` and `npm` itself, never `npmx` or `npm-cache`.
14
17
  */
15
- export declare function matchesPrefix(segment: string, prefix: string): boolean;
18
+ export declare function matchesPrefix(segment: string, prefix: string, dialect?: ShellDialect): boolean;
16
19
  /**
17
20
  * Split a compound command into the segments a shell would run: pipelines,
18
21
  * `&&`/`||`/`;`/newline chains, background `&`, plus the interiors of
19
22
  * `$( )` and backtick substitutions (which execute even inside double
20
23
  * quotes). Quoted operator characters stay literal.
21
24
  */
22
- export declare function splitSegments(command: string): string[];
25
+ export declare function splitSegments(command: string, dialect?: ShellDialect): string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-policy",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "Declarative tool-call policy for DeepSeek Harness: config-driven allow/deny/ask rules (tool name, args pattern, shell command prefix/regex, priority) on the tools/pre-execute gate — Gemini CLI-style policy files expressed as plain plugin config.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -32,7 +32,7 @@
32
32
  }
33
33
  },
34
34
  "dependencies": {
35
- "@deepseek-ai/schemastery": "^3.18.1"
35
+ "@deepseek-ai/schemastery": "^3.18.5"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@deepseek-ai/cordis": "^4.0.1",
@@ -40,23 +40,23 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@deepseek-ai/cordis": "4.0.5",
43
- "@deepseek-ai/dsh-agent": "0.2.1-rc.4",
44
- "@deepseek-ai/dsh-home-paths": "0.2.1-rc.4",
45
- "@deepseek-ai/dsh-http-proxy": "0.2.1-rc.4",
46
- "@deepseek-ai/dsh-jobs": "0.2.1-rc.4",
47
- "@deepseek-ai/dsh-llm": "0.2.1-rc.4",
48
- "@deepseek-ai/dsh-ptc-runtime": "0.2.1-rc.4",
49
- "@deepseek-ai/dsh-sandbox": "0.2.1-rc.4",
50
- "@deepseek-ai/dsh-sandbox-policy": "0.2.1-rc.4",
51
- "@deepseek-ai/dsh-scope": "0.2.1-rc.4",
52
- "@deepseek-ai/dsh-session-projection": "0.2.1-rc.4",
53
- "@deepseek-ai/dsh-shell": "0.2.1-rc.4",
54
- "@deepseek-ai/dsh-shell-env": "0.2.1-rc.4",
55
- "@deepseek-ai/dsh-subprocess": "0.2.1-rc.4",
56
- "@deepseek-ai/dsh-system-prompt": "0.2.1-rc.4",
57
- "@deepseek-ai/dsh-tool-bash": "0.2.1-rc.4",
58
- "@deepseek-ai/dsh-tools": "0.2.1-rc.4",
59
- "@deepseek-ai/dsh-user-approval": "0.2.1-rc.4",
43
+ "@deepseek-ai/dsh-agent": "0.2.1-rc.5",
44
+ "@deepseek-ai/dsh-home-paths": "0.2.1-rc.5",
45
+ "@deepseek-ai/dsh-http-proxy": "0.2.1-rc.5",
46
+ "@deepseek-ai/dsh-jobs": "0.2.1-rc.5",
47
+ "@deepseek-ai/dsh-llm": "0.2.1-rc.5",
48
+ "@deepseek-ai/dsh-ptc-runtime": "0.2.1-rc.5",
49
+ "@deepseek-ai/dsh-sandbox": "0.2.1-rc.5",
50
+ "@deepseek-ai/dsh-sandbox-policy": "0.2.1-rc.5",
51
+ "@deepseek-ai/dsh-scope": "0.2.1-rc.5",
52
+ "@deepseek-ai/dsh-session-projection": "0.2.1-rc.5",
53
+ "@deepseek-ai/dsh-shell": "0.2.1-rc.5",
54
+ "@deepseek-ai/dsh-shell-env": "0.2.1-rc.5",
55
+ "@deepseek-ai/dsh-subprocess": "0.2.1-rc.5",
56
+ "@deepseek-ai/dsh-system-prompt": "0.2.1-rc.5",
57
+ "@deepseek-ai/dsh-tool-bash": "0.2.1-rc.5",
58
+ "@deepseek-ai/dsh-tools": "0.2.1-rc.5",
59
+ "@deepseek-ai/dsh-user-approval": "0.2.1-rc.5",
60
60
  "@types/node": "^22.0.0",
61
61
  "typescript": "^5.6.3",
62
62
  "vitest": "^4.1.11"