@amaster.ai/dsh-policy 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-plugins contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # @amaster.ai/dsh-policy
2
+
3
+ ![dsh-policy preview](preview.png)
4
+
5
+ Declarative tool-call policy for [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness): config-driven `allow` / `deny` / `ask` rules on dsh's `tools/pre-execute` gate — the semantics of Gemini CLI's TOML policy files, expressed as plain plugin config (Schemastery-validated YAML, no code, no rule files).
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ dsh plugin --profile my-agent add @amaster.ai/dsh-policy
11
+ ```
12
+
13
+ ## Configuration
14
+
15
+ The plugin is **disabled by default**. Rules live in the profile's `cordis.patch.yml` like every other dsh plugin config (edits hot-reload with the config layer):
16
+
17
+ ```yaml
18
+ - name: '@amaster.ai/dsh-policy'
19
+ config:
20
+ enabled: true
21
+ rules:
22
+ - tool: '*' # every tool
23
+ decision: allow
24
+ priority: 20
25
+ - tool: bash # dsh's shell tool (pwsh works the same)
26
+ decision: deny
27
+ commandPrefix: npm
28
+ priority: 200
29
+ message: 'npm is not allowed. Use bun install / bun add / bun test instead.'
30
+ - tool: bash
31
+ decision: deny
32
+ commandPrefix: bun run
33
+ priority: 200
34
+ - tool: bash # …but one specific subcommand is fine
35
+ decision: allow
36
+ commandPrefix: bun run lint
37
+ priority: 300 # higher priority overrides the broader deny
38
+ - tool: bash # grep as a pipe filter stays allowed
39
+ decision: allow
40
+ commandPrefix: grep
41
+ priority: 300
42
+ - tool: write
43
+ decision: deny
44
+ argsPattern:
45
+ file_path: '\.md$' # matched against the file_path value directly
46
+ priority: 200
47
+ - tool: write # …except the project contract file
48
+ decision: allow
49
+ argsPattern:
50
+ file_path: 'PRODUCT\.md$'
51
+ priority: 300
52
+ - tool: write
53
+ decision: ask # resolved via ctx.approval (human/answerer chain)
54
+ argsPattern:
55
+ file_path: '(^|/)etc/'
56
+ message: 'writes to a system path'
57
+ ```
58
+
59
+ ### Rule fields
60
+
61
+ | Field | Required | Meaning |
62
+ | --- | --- | --- |
63
+ | `tool` | yes | Tool name or list of names; `*` matches every tool |
64
+ | `decision` | yes | `allow` runs the call, `deny` blocks it (the `message` reaches the model as the tool error), `ask` defers to dsh's approval seam |
65
+ | `priority` | no (0) | Higher priority wins among rules competing for the same command segment; ties break fail-closed (deny > ask > allow) |
66
+ | `message` | no | Deny reason / ask explanation (a generated default names the matched rule) |
67
+ | `argsPattern` | no | Regex (or list, any-of) matched against the JSON-stringified arguments — or a map of argument name → regex (or list): each key's pattern is matched against that argument's **value** (non-string values are JSON-stringified first), all keys must hold. Prefer the map form for field-targeted rules — no quote escaping, no key-order or cross-field accidents |
68
+ | `commandPrefix` | no | Anchored prefix match (or list, any-of) on each shell command segment, at a word boundary (`npm` matches `npm install`, never `npmx`) |
69
+ | `commandRegex` | no | Regex (or list, any-of) anchored at each shell command segment's start — Gemini-compatible; use `.*` to match mid-segment |
70
+
71
+ Plugin-level fields: `enabled` (master switch) and `commandKeys` (argument keys holding a shell command string — default `['command']`, covering dsh's `bash`/`pwsh` tools).
72
+
73
+ ### Matching semantics
74
+
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.
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.
79
+
80
+ ### What the gate covers
81
+
82
+ Everything registered in `ctx.tools` passes `tools/pre-execute` — the gate is tool-agnostic and needs no per-tool support:
83
+
84
+ - **Official tools** (verified against the 0.1.5-rc.1 sources): `bash`, `pwsh`, `read`, `write`, `edit`, `read_image`, `web_search`, `web_fetch`, `list_subagent_models` and the delegation tool, `run_code`, plus goal/skill/workflow/cordis tools. Match them by `tool` + `argsPattern` (the object form fits their argument shapes: `file_path` for `write`/`edit`, `url` for `web_fetch`, `query` for `web_search`, …).
85
+ - **`commandPrefix`/`commandRegex`** apply to tools whose arguments carry a shell command string — `bash` and `pwsh` both use `command` (covered by the default `commandKeys`).
86
+ - **PTC mode**: `run_code` sub-dispatches re-enter the scheduler's `prepare` stage, which runs the same pre-execute gate — rules apply per sub-call, not just per `run_code`.
87
+ - **MCP tools** bridged by `dsh-mcp-client` register into the same `ctx.tools` pipeline — match them by their registered names like any other tool.
88
+
89
+ ### The `ask` decision
90
+
91
+ `ask` is resolved by dsh itself: through the composed answerers of `@deepseek-ai/dsh-user-approval` (a UI prompt, an auto-answerer, …), failing closed to deny when no approval service is composed, and short-circuiting to reject under a session's `approval/policy: never`. The model-facing deny reason is the rule's `message` only when no approval service exists at all; a `rejected`/`cancelled`/`unavailable` outcome carries dsh-tools' own reason wording (verified against `dsh-tools@0.1.5-rc.1`). Gemini's `modes` (`default`/`autoEdit`/`yolo`/`plan`) have no dsh counterpart — dsh models that axis as the per-session approval policy (see `dsh-permission-presets` for the user-facing selector), and dsh profiles/`cordis.patch.yml` already scope config per deployment, so the plugin carries no mode axis of its own.
92
+
93
+ ## Gemini CLI policy mapping
94
+
95
+ | Gemini TOML | dsh-policy config |
96
+ | --- | --- |
97
+ | `toolName = "*" / "name" / ["a", "b"]` | `tool: '*' / name / [a, b]` |
98
+ | `decision = "allow" / "deny" / "ask_user"` | `decision: allow / deny / ask` |
99
+ | `priority = 300` | `priority: 300` (same direction) |
100
+ | `denyMessage` | `message` |
101
+ | `argsPattern` (regex on the serialized args) | `argsPattern` — plus a dsh-native object form `{ file_path: '…' }` matching per-argument values (the string form keeps JSON.stringify key order, not Gemini's sorted-key form — patterns spanning multiple keys may need adjusting; the object form has no such issue) |
102
+ | `commandPrefix` / `commandRegex` (single or list) | same names, matched per command segment; `commandRegex` anchors at the segment start, like Gemini's at the command start |
103
+ | `modes = [...]` | no counterpart — use separate dsh profile patch rows |
104
+ | `allowRedirection` | not supported (dsh has no per-call redirection gate) |
105
+
106
+ ## Security
107
+
108
+ A policy plugin is advisory gating, not containment: deny rules keep a well-behaved agent off dangerous commands, but the agent process still runs with host privileges. Pair with dsh's sandbox stack (`dsh-sandbox` + a confining executor) for OS-level enforcement, and note that dsh ships no authentication or authorization of its own.
109
+
110
+ ## Compatibility
111
+
112
+ | @amaster.ai/dsh-policy | dsh | cordis |
113
+ | --- | --- | --- |
114
+ | 0.1.x | `0.1.5-rc.1` | `^4.0.1` |
115
+
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
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1,5 @@
1
+ # Inserts the dsh-policy plugin row; plugin config is the Config schema in
2
+ # src/index.ts (overridable in the profile's own cordis.patch.yml).
3
+ - insert:
4
+ - id: policy
5
+ name: '@amaster.ai/dsh-policy'
package/lib/index.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * dsh-policy — declarative tool-call policy for DeepSeek Harness.
3
+ *
4
+ * Gemini CLI-style policy rules expressed as plain plugin config (no code,
5
+ * no rule files): each rule matches a tool call by tool name, optional
6
+ * `argsPattern` (regex over the JSON-stringified arguments) and optional
7
+ * `commandPrefix`/`commandRegex` (per-segment over shell commands), and
8
+ * resolves to `allow` / `deny` / `ask` at a priority.
9
+ *
10
+ * The decision rides the `tools/pre-execute` gate
11
+ * (@deepseek-ai/dsh-tools): `allow` runs the call, `deny` materializes the
12
+ * message as a tool error the model can read, and `ask` is resolved by dsh
13
+ * itself through the approval seam (ctx.approval, fail-closed to deny when
14
+ * no answerer is composed). A call no rule matches is delegated onward via
15
+ * `next()` — the plugin only ever speaks for the calls its rules cover.
16
+ *
17
+ * Gate shape verified against @deepseek-ai/dsh-tools@0.1.5-rc.1
18
+ * (lib/types/index.d.ts: tools/pre-execute + PreToolDecision).
19
+ *
20
+ * @module dsh-policy
21
+ */
22
+ import Schema from '@deepseek-ai/schemastery';
23
+ import { compileRules, describeRule, evaluate } from './rule.js';
24
+ export const name = 'dsh-policy';
25
+ // One waterfall listener only needs the event to exist; no hard service
26
+ // dependency is declared so the plugin still loads in minimal compositions
27
+ // (it simply never gates anything). Add 'tools' to `inject` if you prefer
28
+ // fail-fast wiring.
29
+ export const inject = [];
30
+ /** A match field takes one pattern or an any-of list (Gemini's `field = [...]` form). */
31
+ const stringOrList = Schema.union([Schema.string(), Schema.array(Schema.string())]);
32
+ // Explicit annotation: the nested array/union inference otherwise references
33
+ // cosmokit types that consumers do not have installed (TS2742).
34
+ export const Config = Schema.object({
35
+ enabled: Schema.boolean().default(false).description('master switch'),
36
+ commandKeys: Schema.array(Schema.string())
37
+ .default(['command'])
38
+ .description('argument keys holding a shell command string (dsh bash/pwsh use `command`)'),
39
+ rules: Schema.array(Schema.object({
40
+ tool: stringOrList.required().description('tool name(s), or "*" for every tool'),
41
+ decision: Schema.union(['allow', 'deny', 'ask'])
42
+ .required()
43
+ .description('allow runs, deny blocks with `message`, ask goes through user approval'),
44
+ priority: Schema.number().default(0),
45
+ message: Schema.string().description('deny reason / ask explanation, surfaced to the model and the answerer'),
46
+ argsPattern: Schema.union([stringOrList, Schema.dict(stringOrList)]).description('regex(es) matched against the JSON-stringified arguments (any-of) — or a map of argument name → regex(es) matched against that argument value'),
47
+ commandPrefix: stringOrList.description('anchored word-boundary prefix match on each shell command segment (any-of)'),
48
+ commandRegex: stringOrList.description('regex(es) anchored at each shell command segment start (any-of)'),
49
+ })).default([]),
50
+ });
51
+ export function apply(ctx, config) {
52
+ if (!config.enabled || config.rules.length === 0)
53
+ return;
54
+ // Invalid regexes are config errors — compileRules throws here, at load.
55
+ const rules = compileRules(config.rules);
56
+ // Tests apply() directly (no schema defaults), so the fallback stays.
57
+ const commandKeys = config.commandKeys ?? ['command'];
58
+ ctx.on('tools/pre-execute', async (exec, next) => {
59
+ try {
60
+ const hit = evaluate(rules, exec.name, exec.arguments, commandKeys);
61
+ if (!hit)
62
+ return next();
63
+ switch (hit.decision) {
64
+ case 'allow':
65
+ return { kind: 'allow' };
66
+ case 'deny':
67
+ return {
68
+ kind: 'deny',
69
+ reason: hit.rule.message ?? `denied by policy: ${describeRule(hit.rule)}`,
70
+ };
71
+ case 'ask':
72
+ return { kind: 'ask', reason: hit.rule.message };
73
+ }
74
+ }
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).
78
+ console.error('[dsh-policy] evaluation failed:', error);
79
+ return next();
80
+ }
81
+ });
82
+ }
package/lib/rule.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The policy rule model and matching engine (pure — no cordis imports).
3
+ *
4
+ * Semantics, mirroring Gemini CLI's policy files:
5
+ *
6
+ * - A rule matches a tool call when the tool name matches (a listed name or
7
+ * `*`) AND every present condition group holds: `argsPattern` against the
8
+ * JSON-stringified arguments, `commandPrefix`/`commandRegex` against each
9
+ * shell command segment. Array fields are any-of within the group.
10
+ * - `commandRegex` is anchored at the segment start (Gemini anchors it at the
11
+ * command start) — use `.*` to match mid-segment.
12
+ * - Shell commands are checked segment by segment (see shell.ts): a compound
13
+ * command cannot launder a denied command behind an allowed one.
14
+ * - Priority resolves rules competing for the SAME segment; ties break
15
+ * fail-closed (deny > ask > allow). Across segments deny wins outright,
16
+ * then ask; allow only when EVERY segment decided allow.
17
+ * - No matching rule at all → `undefined` → the plugin delegates to the rest
18
+ * of the `tools/pre-execute` chain (dsh's own gates keep their say).
19
+ */
20
+ import { matchesPrefix, splitSegments } from './shell.js';
21
+ /** Fail-closed tie-break within one priority tier. */
22
+ const SEVERITY = { allow: 0, ask: 1, deny: 2 };
23
+ const prefer = (a, b) => {
24
+ if (a.priority !== b.priority)
25
+ return a.priority > b.priority ? a : b;
26
+ return SEVERITY[a.decision] >= SEVERITY[b.decision] ? a : b;
27
+ };
28
+ const asArray = (value) => value === undefined ? undefined : Array.isArray(value) ? value : [value];
29
+ /** Anchor at the (already trimmed) segment start, Gemini-style. */
30
+ const anchored = (pattern) => new RegExp(`^(?:${pattern})`);
31
+ /** Compile the argsPattern field: string/list → whole-JSON condition; object → per-key conditions. */
32
+ function compileArgsConditions(pattern) {
33
+ if (pattern === undefined)
34
+ return undefined;
35
+ const compile = (value) => (Array.isArray(value) ? value : [value]).map((p) => new RegExp(p));
36
+ if (typeof pattern === 'string' || Array.isArray(pattern))
37
+ return [{ res: compile(pattern) }];
38
+ return Object.entries(pattern).map(([key, value]) => ({ key, res: compile(value) }));
39
+ }
40
+ /** Compile config rules for evaluation. Invalid regexes are config errors: they throw here, at plugin load. */
41
+ export function compileRules(configs) {
42
+ return configs.map((rule, index) => {
43
+ const tools = asArray(rule.tool) ?? [];
44
+ try {
45
+ return {
46
+ tools,
47
+ decision: rule.decision,
48
+ priority: rule.priority ?? 0,
49
+ message: rule.message,
50
+ argsConds: compileArgsConditions(rule.argsPattern),
51
+ commandPrefixes: asArray(rule.commandPrefix),
52
+ commandRes: asArray(rule.commandRegex)?.map(anchored),
53
+ };
54
+ }
55
+ catch (error) {
56
+ const reason = error instanceof Error ? error.message : String(error);
57
+ throw new Error(`[dsh-policy] invalid regex in rule #${index} (tool "${tools.join(', ')}"): ${reason}`);
58
+ }
59
+ });
60
+ }
61
+ /** Human-readable rule identity for the default deny reason. */
62
+ export function describeRule(rule) {
63
+ const quoted = (values) => values.map((v) => `"${v}"`).join(' | ');
64
+ const parts = [`tool ${quoted(rule.tools)}`];
65
+ if (rule.commandPrefixes)
66
+ parts.push(`commandPrefix ${quoted(rule.commandPrefixes)}`);
67
+ if (rule.commandRes)
68
+ parts.push(`commandRegex ${rule.commandRes.join(' | ')}`);
69
+ if (rule.argsConds) {
70
+ parts.push(`argsPattern ${rule.argsConds
71
+ .map((cond) => `${cond.key === undefined ? '' : `${cond.key}=`}${cond.res.join(' | ')}`)
72
+ .join(', ')}`);
73
+ }
74
+ return `${parts.join(', ')} (priority ${rule.priority})`;
75
+ }
76
+ /** The first string-valued argument among `keys` — the shell command, for tools that take one. */
77
+ function extractCommand(args, keys) {
78
+ if (args === null || typeof args !== 'object' || Array.isArray(args))
79
+ return undefined;
80
+ for (const key of keys) {
81
+ const value = args[key];
82
+ if (typeof value === 'string')
83
+ return value;
84
+ }
85
+ return undefined;
86
+ }
87
+ const anyMatch = (values, test) => values === undefined || values.some(test);
88
+ /** Every args condition holds: whole-JSON sets test the serialized args; keyed sets test that argument's (stringified) value. */
89
+ function argsMatch(rule, args, argsJson) {
90
+ if (rule.argsConds === undefined)
91
+ return true;
92
+ return rule.argsConds.every((cond) => {
93
+ if (cond.key === undefined)
94
+ return cond.res.some((re) => re.test(argsJson));
95
+ if (args === null || typeof args !== 'object' || Array.isArray(args))
96
+ return false;
97
+ const value = args[cond.key];
98
+ if (value === undefined)
99
+ return false;
100
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
101
+ return cond.res.some((re) => re.test(text));
102
+ });
103
+ }
104
+ function matchesSegment(rule, segment) {
105
+ const hasCommandCondition = rule.commandPrefixes !== undefined || rule.commandRes !== undefined;
106
+ // No command string on this call: command conditions cannot be satisfied.
107
+ if (segment === undefined)
108
+ return !hasCommandCondition;
109
+ return (anyMatch(rule.commandPrefixes, (prefix) => matchesPrefix(segment, prefix)) &&
110
+ anyMatch(rule.commandRes, (re) => re.test(segment)));
111
+ }
112
+ export function evaluate(rules, toolName, args, commandKeys) {
113
+ const candidates = rules.filter((rule) => rule.tools.includes('*') || rule.tools.includes(toolName));
114
+ if (candidates.length === 0)
115
+ return undefined;
116
+ const argsJson = JSON.stringify(args ?? null);
117
+ const command = extractCommand(args, commandKeys);
118
+ // A call without a shell command is one virtual whole-call segment — only
119
+ // rules without command conditions can match it.
120
+ const segments = command === undefined ? [undefined] : splitSegments(command);
121
+ let undecided = false;
122
+ const best = {};
123
+ for (const segment of segments) {
124
+ let winner;
125
+ for (const rule of candidates) {
126
+ if (!argsMatch(rule, args, argsJson))
127
+ continue;
128
+ if (!matchesSegment(rule, segment))
129
+ continue;
130
+ winner = winner === undefined ? rule : prefer(winner, rule);
131
+ }
132
+ if (winner === undefined)
133
+ undecided = true;
134
+ else {
135
+ const held = best[winner.decision];
136
+ best[winner.decision] = held === undefined ? winner : prefer(held, winner);
137
+ }
138
+ }
139
+ // deny anywhere settles the call, then ask anywhere; allow must be unanimous.
140
+ if (best.deny)
141
+ return { decision: 'deny', rule: best.deny };
142
+ if (best.ask)
143
+ return { decision: 'ask', rule: best.ask };
144
+ if (!undecided && best.allow)
145
+ return { decision: 'allow', rule: best.allow };
146
+ return undefined;
147
+ }
package/lib/shell.js ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Shell command segmentation for policy matching.
3
+ *
4
+ * dsh's bash/pwsh tools receive one raw command string, so a prefix rule that
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.
10
+ */
11
+ const isWordBoundary = (ch) => ch === undefined || /\s/.test(ch);
12
+ /**
13
+ * A prefix matches a segment only at a word boundary: `npm` matches
14
+ * `npm install` and `npm` itself, never `npmx` or `npm-cache`.
15
+ */
16
+ export function matchesPrefix(segment, prefix) {
17
+ const trimmed = segment.trimStart();
18
+ return trimmed.startsWith(prefix) && isWordBoundary(trimmed[prefix.length]);
19
+ }
20
+ /** Read a `$(`...`)` body starting just after the opening paren. */
21
+ function readParenSubshell(text, start) {
22
+ let depth = 1;
23
+ let quote;
24
+ let i = start;
25
+ while (i < text.length) {
26
+ const ch = text[i];
27
+ if (quote !== undefined) {
28
+ if (ch === '\\' && quote !== "'")
29
+ i += 1;
30
+ else if (ch === quote)
31
+ quote = undefined;
32
+ }
33
+ else if (ch === "'" || ch === '"') {
34
+ quote = ch;
35
+ }
36
+ else if (ch === '(') {
37
+ depth += 1;
38
+ }
39
+ else if (ch === ')') {
40
+ depth -= 1;
41
+ if (depth === 0)
42
+ return { body: text.slice(start, i), end: i + 1 };
43
+ }
44
+ i += 1;
45
+ }
46
+ return { body: text.slice(start), end: text.length }; // unbalanced: take the rest
47
+ }
48
+ /**
49
+ * Split a compound command into the segments a shell would run: pipelines,
50
+ * `&&`/`||`/`;`/newline chains, background `&`, plus the interiors of
51
+ * `$( )` and backtick substitutions (which execute even inside double
52
+ * quotes). Quoted operator characters stay literal.
53
+ */
54
+ export function splitSegments(command) {
55
+ const segments = [];
56
+ let buf = '';
57
+ let quote;
58
+ const push = () => {
59
+ const trimmed = buf.trim();
60
+ if (trimmed !== '')
61
+ segments.push(trimmed);
62
+ buf = '';
63
+ };
64
+ let i = 0;
65
+ while (i < command.length) {
66
+ const ch = command[i];
67
+ // Substitution contents execute inside double quotes but are literal in
68
+ // single quotes — extract them from everywhere except '...'.
69
+ if (quote !== "'") {
70
+ if (ch === '$' && command[i + 1] === '(') {
71
+ const { body, end } = readParenSubshell(command, i + 2);
72
+ segments.push(...splitSegments(body));
73
+ i = end;
74
+ continue;
75
+ }
76
+ if (ch === '`') {
77
+ const close = command.indexOf('`', i + 1);
78
+ segments.push(...splitSegments(close === -1 ? command.slice(i + 1) : command.slice(i + 1, close)));
79
+ i = close === -1 ? command.length : close + 1;
80
+ continue;
81
+ }
82
+ }
83
+ if (quote !== undefined) {
84
+ buf += ch;
85
+ if (ch === '\\' && quote !== "'" && i + 1 < command.length) {
86
+ buf += command[i + 1];
87
+ i += 2;
88
+ continue;
89
+ }
90
+ if (ch === quote)
91
+ quote = undefined;
92
+ i += 1;
93
+ continue;
94
+ }
95
+ if (ch === "'" || ch === '"') {
96
+ quote = ch;
97
+ buf += ch;
98
+ i += 1;
99
+ continue;
100
+ }
101
+ if (ch === '\\' && i + 1 < command.length) {
102
+ buf += ch + command[i + 1];
103
+ i += 2;
104
+ continue;
105
+ }
106
+ if (ch === ';' || ch === '\n' || ch === '|' || ch === '&') {
107
+ push();
108
+ i += command[i + 1] === ch && (ch === '|' || ch === '&') ? 2 : 1;
109
+ continue;
110
+ }
111
+ buf += ch;
112
+ i += 1;
113
+ }
114
+ push();
115
+ return segments;
116
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * dsh-policy — declarative tool-call policy for DeepSeek Harness.
3
+ *
4
+ * Gemini CLI-style policy rules expressed as plain plugin config (no code,
5
+ * no rule files): each rule matches a tool call by tool name, optional
6
+ * `argsPattern` (regex over the JSON-stringified arguments) and optional
7
+ * `commandPrefix`/`commandRegex` (per-segment over shell commands), and
8
+ * resolves to `allow` / `deny` / `ask` at a priority.
9
+ *
10
+ * The decision rides the `tools/pre-execute` gate
11
+ * (@deepseek-ai/dsh-tools): `allow` runs the call, `deny` materializes the
12
+ * message as a tool error the model can read, and `ask` is resolved by dsh
13
+ * itself through the approval seam (ctx.approval, fail-closed to deny when
14
+ * no answerer is composed). A call no rule matches is delegated onward via
15
+ * `next()` — the plugin only ever speaks for the calls its rules cover.
16
+ *
17
+ * Gate shape verified against @deepseek-ai/dsh-tools@0.1.5-rc.1
18
+ * (lib/types/index.d.ts: tools/pre-execute + PreToolDecision).
19
+ *
20
+ * @module dsh-policy
21
+ */
22
+ import type { Context } from '@deepseek-ai/cordis';
23
+ import Schema from '@deepseek-ai/schemastery';
24
+ import { type PolicyRuleConfig } from './rule.js';
25
+ export declare const name = "dsh-policy";
26
+ export declare const inject: string[];
27
+ export interface PolicyPluginConfig {
28
+ enabled: boolean;
29
+ commandKeys?: string[];
30
+ rules: PolicyRuleConfig[];
31
+ }
32
+ export declare const Config: Schema<PolicyPluginConfig>;
33
+ export declare function apply(ctx: Context, config: PolicyPluginConfig): void;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The policy rule model and matching engine (pure — no cordis imports).
3
+ *
4
+ * Semantics, mirroring Gemini CLI's policy files:
5
+ *
6
+ * - A rule matches a tool call when the tool name matches (a listed name or
7
+ * `*`) AND every present condition group holds: `argsPattern` against the
8
+ * JSON-stringified arguments, `commandPrefix`/`commandRegex` against each
9
+ * shell command segment. Array fields are any-of within the group.
10
+ * - `commandRegex` is anchored at the segment start (Gemini anchors it at the
11
+ * command start) — use `.*` to match mid-segment.
12
+ * - Shell commands are checked segment by segment (see shell.ts): a compound
13
+ * command cannot launder a denied command behind an allowed one.
14
+ * - Priority resolves rules competing for the SAME segment; ties break
15
+ * fail-closed (deny > ask > allow). Across segments deny wins outright,
16
+ * then ask; allow only when EVERY segment decided allow.
17
+ * - No matching rule at all → `undefined` → the plugin delegates to the rest
18
+ * of the `tools/pre-execute` chain (dsh's own gates keep their say).
19
+ */
20
+ export type PolicyDecisionKind = 'allow' | 'deny' | 'ask';
21
+ /** One configured rule (the raw Config shape). */
22
+ export interface PolicyRuleConfig {
23
+ /** Tool name(s), or `*` for every tool. */
24
+ tool: string | string[];
25
+ decision: PolicyDecisionKind;
26
+ /** Higher wins among rules competing for the same segment; default 0. */
27
+ priority?: number;
28
+ /** Deny reason / ask explanation, surfaced to the model and the answerer. */
29
+ message?: string;
30
+ /**
31
+ * Regex(es) matched against the JSON-stringified arguments (any-of) — or a
32
+ * map of argument name → regex(es) matched against that argument's value
33
+ * (stringified when not a string; AND across keys, any-of within a list).
34
+ */
35
+ argsPattern?: string | string[] | Record<string, string | string[]>;
36
+ /** Anchored, word-boundary prefix match on each shell command segment (any-of). */
37
+ commandPrefix?: string | string[];
38
+ /** Regex(es) anchored at each shell command segment's start (any-of). */
39
+ commandRegex?: string | string[];
40
+ }
41
+ /** One compiled args condition: a whole-JSON regex set, or one argument key's regex set. */
42
+ interface ArgsCondition {
43
+ key?: string;
44
+ res: RegExp[];
45
+ }
46
+ export interface CompiledRule {
47
+ tools: string[];
48
+ decision: PolicyDecisionKind;
49
+ priority: number;
50
+ message?: string;
51
+ argsConds?: ArgsCondition[];
52
+ commandPrefixes?: string[];
53
+ commandRes?: RegExp[];
54
+ }
55
+ export interface RuleHit {
56
+ decision: PolicyDecisionKind;
57
+ rule: CompiledRule;
58
+ }
59
+ /** Compile config rules for evaluation. Invalid regexes are config errors: they throw here, at plugin load. */
60
+ export declare function compileRules(configs: PolicyRuleConfig[]): CompiledRule[];
61
+ /** Human-readable rule identity for the default deny reason. */
62
+ export declare function describeRule(rule: CompiledRule): string;
63
+ export declare function evaluate(rules: CompiledRule[], toolName: string, args: unknown, commandKeys: string[]): RuleHit | undefined;
64
+ export {};
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shell command segmentation for policy matching.
3
+ *
4
+ * dsh's bash/pwsh tools receive one raw command string, so a prefix rule that
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.
10
+ */
11
+ /**
12
+ * A prefix matches a segment only at a word boundary: `npm` matches
13
+ * `npm install` and `npm` itself, never `npmx` or `npm-cache`.
14
+ */
15
+ export declare function matchesPrefix(segment: string, prefix: string): boolean;
16
+ /**
17
+ * Split a compound command into the segments a shell would run: pipelines,
18
+ * `&&`/`||`/`;`/newline chains, background `&`, plus the interiors of
19
+ * `$( )` and backtick substitutions (which execute even inside double
20
+ * quotes). Quoted operator characters stay literal.
21
+ */
22
+ export declare function splitSegments(command: string): string[];
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@amaster.ai/dsh-policy",
3
+ "version": "0.1.1",
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
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/types/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "lib",
17
+ "cordis.patch.yml",
18
+ "preview.png"
19
+ ],
20
+ "license": "MIT",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/TGYD-helige/dsh-plugins.git",
27
+ "directory": "packages/dsh-policy"
28
+ },
29
+ "dsh": {
30
+ "bundle": {
31
+ "patch": "./cordis.patch.yml"
32
+ }
33
+ },
34
+ "dependencies": {
35
+ "@deepseek-ai/schemastery": "^3.18.1"
36
+ },
37
+ "peerDependencies": {
38
+ "@deepseek-ai/cordis": "^4.0.1",
39
+ "@deepseek-ai/dsh-tools": "0.1.5-rc.1"
40
+ },
41
+ "devDependencies": {
42
+ "@deepseek-ai/cordis": "^4.0.1",
43
+ "@deepseek-ai/dsh-agent": "0.1.5-rc.1",
44
+ "@deepseek-ai/dsh-home-paths": "0.1.5-rc.1",
45
+ "@deepseek-ai/dsh-http-proxy": "0.1.5-rc.1",
46
+ "@deepseek-ai/dsh-invariants": "0.1.5-rc.1",
47
+ "@deepseek-ai/dsh-jobs": "0.1.5-rc.1",
48
+ "@deepseek-ai/dsh-llm": "0.1.5-rc.1",
49
+ "@deepseek-ai/dsh-sandbox": "0.1.5-rc.1",
50
+ "@deepseek-ai/dsh-sandbox-policy": "0.1.5-rc.1",
51
+ "@deepseek-ai/dsh-scope": "0.1.5-rc.1",
52
+ "@deepseek-ai/dsh-shell": "0.1.5-rc.1",
53
+ "@deepseek-ai/dsh-shell-env": "0.1.5-rc.1",
54
+ "@deepseek-ai/dsh-subprocess": "0.1.5-rc.1",
55
+ "@deepseek-ai/dsh-system-prompt": "0.1.5-rc.1",
56
+ "@deepseek-ai/dsh-tool-bash": "0.1.5-rc.1",
57
+ "@deepseek-ai/dsh-tools": "0.1.5-rc.1",
58
+ "@deepseek-ai/dsh-user-approval": "0.1.5-rc.1",
59
+ "@types/node": "^22.0.0",
60
+ "typescript": "^5.6.3",
61
+ "vitest": "^4.1.11"
62
+ },
63
+ "engines": {
64
+ "node": "^20.19 || ^22.12 || >=24.0"
65
+ },
66
+ "scripts": {
67
+ "build": "tsc -p tsconfig.build.json",
68
+ "typecheck": "tsc -p tsconfig.json --noEmit",
69
+ "test": "vitest run"
70
+ }
71
+ }
package/preview.png ADDED
Binary file