@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 +21 -0
- package/README.md +120 -0
- package/cordis.patch.yml +5 -0
- package/lib/index.js +82 -0
- package/lib/rule.js +147 -0
- package/lib/shell.js +116 -0
- package/lib/types/index.d.ts +33 -0
- package/lib/types/rule.d.ts +64 -0
- package/lib/types/shell.d.ts +22 -0
- package/package.json +71 -0
- package/preview.png +0 -0
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
|
+

|
|
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
|
package/cordis.patch.yml
ADDED
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
|