dsh-perm-gate 2.1.0 → 2.6.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/CHANGELOG.ja.md +108 -0
- package/CHANGELOG.ko.md +104 -0
- package/CHANGELOG.md +347 -0
- package/INSTALL.ja.md +84 -8
- package/INSTALL.ko.md +80 -9
- package/INSTALL.md +83 -8
- package/INSTALL.zh.md +70 -7
- package/README.ja.md +130 -16
- package/README.ko.md +128 -17
- package/README.md +179 -25
- package/README.zh.md +121 -12
- package/assets/allow1.png +0 -0
- package/assets/allow2.png +0 -0
- package/assets/gateoff.png +0 -0
- package/assets/llms.png +0 -0
- package/assets/permlist.png +0 -0
- package/assets/setting-head.png +0 -0
- package/assets/setting-rules.png +0 -0
- package/cordis.patch.yml +37 -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 +26 -3
- package/scripts/patch-permission-glyph.mjs +392 -0
|
@@ -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.
|