opencode-branch-guard 0.1.0 → 0.2.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/README.md +300 -54
- package/dist/core.d.ts +4 -1
- package/dist/index.js +24 -10
- package/dist/index.js.map +4 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
[](https://go.hugobatista.com/gh/opencode-branch-guard/actions/workflows/test.yml)
|
|
6
6
|
[](https://www.npmjs.com/package/opencode-branch-guard)
|
|
7
7
|
|
|
8
|
-
OpenCode plugin.
|
|
9
|
-
`reset`, …) based on the current branch or repository, so protected branches
|
|
10
|
-
clean.
|
|
11
|
-
read-only
|
|
8
|
+
OpenCode plugin. Controls git mutations (`commit`, `push`, `merge`, `rebase`,
|
|
9
|
+
`reset`, …) based on the current branch or repository, so protected branches
|
|
10
|
+
stay clean. Allow an operation, ask the user for approval, or deny it. Deny a
|
|
11
|
+
whole branch, allow a different policy per repository, and keep read-only
|
|
12
|
+
commands untouched.
|
|
12
13
|
|
|
13
14
|
> **Requires OpenCode V2.** OpenCode V2 changed the plugin API; V1 plugin
|
|
14
15
|
> implementations do not run in V2. This plugin is built against
|
|
@@ -17,14 +18,16 @@ read-only commands untouched.
|
|
|
17
18
|
## What it does
|
|
18
19
|
|
|
19
20
|
- Intercepts the `shell` permission via `ctx.permission.hook("evaluate")` and
|
|
20
|
-
returns `effect: "deny"` when a git mutation
|
|
21
|
+
returns `effect: "allow" | "ask" | "deny"` when a git mutation matches the
|
|
22
|
+
resolved policy.
|
|
21
23
|
- Resolves the branch with `git -C <directory> branch --show-current`; the
|
|
22
24
|
directory comes from the session (`ctx.session.get().location.directory`), not
|
|
23
25
|
the plugin instance.
|
|
24
26
|
- Resolves the policy hierarchically: `repos[<directory>]` overrides
|
|
25
27
|
`branches[<branch>]`, which overrides `default`.
|
|
26
28
|
- Applies the same decision to every resource of a compound command, so
|
|
27
|
-
`cd /tmp && git commit` is caught.
|
|
29
|
+
`cd /tmp && git commit` is caught. Precedence across resources is
|
|
30
|
+
`deny > ask > allow`.
|
|
28
31
|
- Passes read-only commands (`status`, `log`, `diff`, `fetch`, …) and anything
|
|
29
32
|
that is not a known git mutation.
|
|
30
33
|
- Fails closed: with no options, every git mutation is denied.
|
|
@@ -52,7 +55,7 @@ Or add the package to `opencode.jsonc` (project or
|
|
|
52
55
|
```
|
|
53
56
|
|
|
54
57
|
With no options the plugin is **fail-closed**: it denies every git mutation.
|
|
55
|
-
Pass [options](#configuration) to allow the operations you want.
|
|
58
|
+
Pass [options](#configuration) to allow or ask for the operations you want.
|
|
56
59
|
|
|
57
60
|
> This is a **server** plugin. Configure it in `opencode.json(c)`. The
|
|
58
61
|
> `cli.json` file is for terminal (TUI) plugins only.
|
|
@@ -78,11 +81,31 @@ Pass [options](#configuration) to allow the operations you want.
|
|
|
78
81
|
}
|
|
79
82
|
```
|
|
80
83
|
|
|
84
|
+
To pass [options](#configuration) with a local path, use the object form and
|
|
85
|
+
set `package` to the path:
|
|
86
|
+
|
|
87
|
+
```jsonc
|
|
88
|
+
{
|
|
89
|
+
"$schema": "https://opencode.ai/config.json",
|
|
90
|
+
"plugins": [
|
|
91
|
+
{
|
|
92
|
+
"package": "/home/your-user/code/projects/opencode-branch-guard/src",
|
|
93
|
+
"options": {
|
|
94
|
+
"default": { "allow": ["commit", "push"] },
|
|
95
|
+
"branches": {
|
|
96
|
+
"main": { "allow": [], "ask": ["commit"] }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
81
104
|
3. Restart OpenCode.
|
|
82
105
|
|
|
83
106
|
## Configuration
|
|
84
107
|
|
|
85
|
-
Pass options with the object form
|
|
108
|
+
Pass options with the object form. The plugin is configured under `options`:
|
|
86
109
|
|
|
87
110
|
```jsonc
|
|
88
111
|
{
|
|
@@ -93,6 +116,7 @@ Pass options with the object form:
|
|
|
93
116
|
"options": {
|
|
94
117
|
"default": {
|
|
95
118
|
"allow": ["add", "branch", "checkout", "commit", "push", "fetch", "merge", "pull", "rebase", "reset", "restore", "stash", "switch", "tag"],
|
|
119
|
+
"ask": [],
|
|
96
120
|
"deny": []
|
|
97
121
|
},
|
|
98
122
|
"branches": {
|
|
@@ -105,72 +129,274 @@ Pass options with the object form:
|
|
|
105
129
|
}
|
|
106
130
|
```
|
|
107
131
|
|
|
132
|
+
### Policy shape
|
|
133
|
+
|
|
134
|
+
A policy is `{ "allow": string[], "ask": string[], "deny": string[] }`. Every
|
|
135
|
+
field is optional and holds a list of git operations.
|
|
136
|
+
|
|
137
|
+
| Effect | Result |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `allow` | The operation runs without prompting. |
|
|
140
|
+
| `ask` | The client asks the user to approve the operation. |
|
|
141
|
+
| `deny` | The operation is blocked. The message explains why. |
|
|
142
|
+
|
|
143
|
+
An operation missing from every list is denied. This keeps the plugin
|
|
144
|
+
fail-closed.
|
|
145
|
+
|
|
108
146
|
### Options
|
|
109
147
|
|
|
110
148
|
| Option | Default | Description |
|
|
111
149
|
|---|---|---|
|
|
112
|
-
| `default` | none (fail-closed) | Baseline policy applied when no more specific rule matches.
|
|
113
|
-
| `branches` | none | Policy keyed by exact branch name, resolved at command time from the VCS.
|
|
150
|
+
| `default` | none (fail-closed) | Baseline policy applied when no more specific rule matches. Any mutation missing from the resolved `allow` and `ask` is denied. |
|
|
151
|
+
| `branches` | none | Policy keyed by exact branch name, resolved at command time from the VCS. |
|
|
114
152
|
| `repos` | none | Policy keyed by absolute location directory. Takes precedence over `branches` and `default`. |
|
|
115
153
|
|
|
116
|
-
|
|
154
|
+
### Effects and precedence
|
|
155
|
+
|
|
156
|
+
Precedence is **`deny > ask > allow`**:
|
|
157
|
+
|
|
158
|
+
1. If the operation is in `deny`, it is denied. This wins over every other list.
|
|
159
|
+
2. Otherwise, if it is in `ask`, the client asks the user.
|
|
160
|
+
3. Otherwise, if it is in `allow`, it runs.
|
|
161
|
+
4. Otherwise, it is denied (fail-closed).
|
|
162
|
+
|
|
163
|
+
An operation listed in both `ask` and `allow` is asked. `deny` always wins.
|
|
117
164
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
165
|
+
When OpenCode checks a compound command, the plugin aggregates every resource
|
|
166
|
+
with the same precedence: one `deny` blocks the command, otherwise one `ask`
|
|
167
|
+
escalates it, otherwise it is allowed.
|
|
121
168
|
|
|
122
|
-
###
|
|
169
|
+
### How a policy is resolved
|
|
123
170
|
|
|
124
|
-
|
|
125
|
-
mutation.
|
|
126
|
-
- **Hierarchical.** `default` is the baseline. A `branches.<name>` entry
|
|
127
|
-
overrides `allow` (replaces the baseline) and `deny` (unions with the
|
|
128
|
-
baseline). A `repos.<directory>` entry overrides both.
|
|
129
|
-
- **`deny` wins.** An operation is allowed only if it is in the resolved `allow`
|
|
130
|
-
and not in the resolved `deny`.
|
|
131
|
-
- **Exact branch match.** The branch is resolved at call time with
|
|
132
|
-
`git branch --show-current`. No globs.
|
|
133
|
-
- **Read-only commands pass.** `status`, `log`, `diff`, `fetch` and anything not
|
|
134
|
-
in the mutation list are never blocked.
|
|
171
|
+
For a command on branch `B` in directory `D`:
|
|
135
172
|
|
|
136
|
-
|
|
137
|
-
|
|
173
|
+
1. `base` is `default`.
|
|
174
|
+
2. `rule` is `repos[D]` if present, otherwise `branches[B]`, otherwise nothing.
|
|
175
|
+
3. `allow` is `rule.allow`, or `base.allow`, or `[]`.
|
|
176
|
+
4. `ask` is `rule.ask`, or `base.ask`, or `[]`.
|
|
177
|
+
5. `deny` is `base.deny` followed by `rule.deny`.
|
|
138
178
|
|
|
139
|
-
|
|
179
|
+
`allow` and `ask` **replace** the baseline. `deny` **unions** with the baseline.
|
|
180
|
+
This means a specific rule that omits `ask` inherits the baseline `ask`, and a
|
|
181
|
+
rule that sets `ask` replaces it.
|
|
182
|
+
|
|
183
|
+
Because `deny` only unions with the baseline, a `repos` rule does not inherit
|
|
184
|
+
the `deny` of the matching `branches` rule. Repeat the operation in the `repos`
|
|
185
|
+
rule if you need it there.
|
|
186
|
+
|
|
187
|
+
### Recognized git operations
|
|
188
|
+
|
|
189
|
+
`add`, `branch`, `checkout`, `cherry-pick`, `clean`, `commit`, `merge`, `mv`,
|
|
190
|
+
`push`, `rebase`, `reset`, `restore`, `revert`, `rm`, `stash`, `switch`, `tag`.
|
|
191
|
+
|
|
192
|
+
Anything not in this list is treated as read-only and passes. The branch is
|
|
193
|
+
matched exactly with `git branch --show-current`. There are no globs.
|
|
194
|
+
|
|
195
|
+
### Examples
|
|
196
|
+
|
|
197
|
+
Each example shows the `options` object. Wrap it in the `plugins` object form
|
|
198
|
+
shown in [Configuration](#configuration).
|
|
199
|
+
|
|
200
|
+
#### Common recipes
|
|
201
|
+
|
|
202
|
+
| Goal | Use |
|
|
203
|
+
|---|---|
|
|
204
|
+
| Allow work on normal branches, ask on protected branches | `default.allow` plus `branches.<name>.ask` |
|
|
205
|
+
| Block every mutation on protected branches | `branches.<name>: { "allow": [] }` |
|
|
206
|
+
| Ask for every mutation | `default: { "ask": [...] }` |
|
|
207
|
+
| Block one operation everywhere | `default: { "deny": ["push"] }` |
|
|
208
|
+
| Different policy for one checkout | `repos: { "/path": { ... } }` |
|
|
209
|
+
|
|
210
|
+
#### 1. Allow on normal branches, ask on protected branches
|
|
211
|
+
|
|
212
|
+
```jsonc
|
|
213
|
+
{
|
|
214
|
+
"default": {
|
|
215
|
+
"allow": ["add", "checkout", "switch", "commit", "merge", "push", "rebase", "stash"]
|
|
216
|
+
},
|
|
217
|
+
"branches": {
|
|
218
|
+
"main": {
|
|
219
|
+
"allow": [],
|
|
220
|
+
"ask": ["add", "checkout", "switch", "commit", "merge", "push", "rebase", "stash"]
|
|
221
|
+
},
|
|
222
|
+
"master": {
|
|
223
|
+
"allow": [],
|
|
224
|
+
"ask": ["add", "checkout", "switch", "commit", "merge", "push", "rebase", "stash"]
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
| Command | Feature branch | `main` / `master` |
|
|
231
|
+
|---|---|---|
|
|
232
|
+
| `git commit` | `allow` | `ask` |
|
|
233
|
+
| `git push` | `allow` | `ask` |
|
|
234
|
+
| `git switch` | `allow` | `ask` |
|
|
235
|
+
| `git reset` | `deny` | `deny` |
|
|
236
|
+
| `git tag` | `deny` | `deny` |
|
|
237
|
+
| `git status` | `allow` | `allow` |
|
|
238
|
+
|
|
239
|
+
`allow` and `ask` replace the baseline. On `main`, `allow` is empty and `ask`
|
|
240
|
+
lists the operations, so the listed ones ask. Anything else is denied.
|
|
241
|
+
|
|
242
|
+
#### 2. Block every mutation on protected branches
|
|
243
|
+
|
|
244
|
+
```jsonc
|
|
245
|
+
{
|
|
246
|
+
"default": { "allow": ["commit", "push"] },
|
|
247
|
+
"branches": {
|
|
248
|
+
"main": { "allow": [] },
|
|
249
|
+
"master": { "allow": [] }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
| Command | Feature branch | `main` / `master` |
|
|
255
|
+
|---|---|---|
|
|
256
|
+
| `git commit` | `allow` | `deny` |
|
|
257
|
+
| `git push` | `allow` | `deny` |
|
|
258
|
+
| `git status` | `allow` | `allow` |
|
|
259
|
+
|
|
260
|
+
An empty `allow` with no `ask` denies every mutation.
|
|
261
|
+
|
|
262
|
+
#### 3. Ask for every mutation
|
|
263
|
+
|
|
264
|
+
```jsonc
|
|
265
|
+
{
|
|
266
|
+
"default": {
|
|
267
|
+
"ask": ["add", "branch", "checkout", "cherry-pick", "clean", "commit", "merge", "mv", "push", "rebase", "reset", "restore", "revert", "rm", "stash", "switch", "tag"]
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Every mutation asks on every branch. Read-only commands pass. Nothing is blocked,
|
|
273
|
+
so the user decides.
|
|
274
|
+
|
|
275
|
+
#### 4. Ask on `main`, hard-deny `push`
|
|
276
|
+
|
|
277
|
+
```jsonc
|
|
278
|
+
{
|
|
279
|
+
"default": { "allow": ["commit", "push"] },
|
|
280
|
+
"branches": {
|
|
281
|
+
"main": {
|
|
282
|
+
"allow": [],
|
|
283
|
+
"ask": ["commit"],
|
|
284
|
+
"deny": ["push"]
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
| Command | Feature branch | `main` |
|
|
291
|
+
|---|---|---|
|
|
292
|
+
| `git commit` | `allow` | `ask` |
|
|
293
|
+
| `git push` | `allow` | `deny` |
|
|
294
|
+
| `git merge` | `deny` | `deny` |
|
|
295
|
+
|
|
296
|
+
On `main`, `commit` asks, `push` is denied, and `merge` is denied because it is
|
|
297
|
+
missing from every list.
|
|
298
|
+
|
|
299
|
+
#### 5. Block `push` everywhere
|
|
300
|
+
|
|
301
|
+
```jsonc
|
|
302
|
+
{
|
|
303
|
+
"default": {
|
|
304
|
+
"allow": ["add", "checkout", "switch", "commit", "merge", "rebase", "stash"],
|
|
305
|
+
"deny": ["push"]
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`deny` wins over `allow`. `push` is blocked on every branch, even though the
|
|
311
|
+
other mutations are allowed.
|
|
312
|
+
|
|
313
|
+
#### 6. Allow only a small set of operations
|
|
314
|
+
|
|
315
|
+
```jsonc
|
|
316
|
+
{
|
|
317
|
+
"default": { "allow": ["commit", "push"] }
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Only `commit` and `push` are permitted. Every other mutation is denied on every
|
|
322
|
+
branch. This is the strictest permissive form.
|
|
323
|
+
|
|
324
|
+
#### 7. Protect a release branch
|
|
325
|
+
|
|
326
|
+
```jsonc
|
|
327
|
+
{
|
|
328
|
+
"default": { "allow": ["commit", "push", "merge"] },
|
|
329
|
+
"branches": {
|
|
330
|
+
"release": {
|
|
331
|
+
"allow": [],
|
|
332
|
+
"ask": ["merge", "tag"],
|
|
333
|
+
"deny": ["push", "reset", "clean"]
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
On the branch named exactly `release`: `merge` and `tag` ask, `push`, `reset`,
|
|
340
|
+
and `clean` are denied, and everything else is denied. Branch names match
|
|
341
|
+
exactly. There are no globs.
|
|
342
|
+
|
|
343
|
+
#### 8. Per-repository override
|
|
140
344
|
|
|
141
345
|
Allow work in a single checkout even on a protected branch by keying it on the
|
|
142
346
|
location directory:
|
|
143
347
|
|
|
144
348
|
```jsonc
|
|
145
349
|
{
|
|
146
|
-
"
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"/home/you/code/projects/scratch": {
|
|
154
|
-
"allow": ["add", "commit", "push", "reset", "stash", "switch"]
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
350
|
+
"default": { "allow": ["commit", "push"] },
|
|
351
|
+
"branches": {
|
|
352
|
+
"main": { "allow": [], "ask": ["commit"], "deny": ["push"] }
|
|
353
|
+
},
|
|
354
|
+
"repos": {
|
|
355
|
+
"/home/you/code/projects/scratch": {
|
|
356
|
+
"allow": ["add", "commit", "push", "reset", "stash", "switch"]
|
|
158
357
|
}
|
|
159
|
-
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
In `/home/you/code/projects/scratch` the repo rule replaces the branch `allow`
|
|
363
|
+
and `ask`, so `commit` runs without prompting. The repo rule omits `deny`, so it
|
|
364
|
+
inherits only `default.deny` (empty here), not the branch `deny`.
|
|
365
|
+
|
|
366
|
+
#### 9. Compound commands
|
|
367
|
+
|
|
368
|
+
```jsonc
|
|
369
|
+
{
|
|
370
|
+
"default": { "allow": ["add", "commit"], "deny": ["push"] }
|
|
160
371
|
}
|
|
161
372
|
```
|
|
162
373
|
|
|
163
|
-
|
|
374
|
+
| Command | Result |
|
|
375
|
+
|---|---|
|
|
376
|
+
| `git add . && git commit -m x` | `allow` (both allowed) |
|
|
377
|
+
| `git add . && git push` | `deny` (one resource denied) |
|
|
378
|
+
| `git add . && git reset` | `deny` (one resource not allowed) |
|
|
379
|
+
|
|
380
|
+
The plugin applies the policy to every resource of the command. Precedence is
|
|
381
|
+
`deny > ask > allow`.
|
|
164
382
|
|
|
165
|
-
|
|
383
|
+
#### 10. Effect precedence
|
|
166
384
|
|
|
167
385
|
```jsonc
|
|
168
386
|
{
|
|
169
|
-
"default": { "allow": ["commit", "
|
|
387
|
+
"default": { "allow": ["commit"], "ask": ["commit"], "deny": [] }
|
|
170
388
|
}
|
|
171
389
|
```
|
|
172
390
|
|
|
173
|
-
`
|
|
391
|
+
`commit` is in both `allow` and `ask`, so it asks. `ask` wins over `allow`.
|
|
392
|
+
|
|
393
|
+
```jsonc
|
|
394
|
+
{
|
|
395
|
+
"default": { "allow": ["commit"], "ask": ["commit"], "deny": ["commit"] }
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Adding `commit` to `deny` blocks it. `deny` wins over everything.
|
|
174
400
|
|
|
175
401
|
## Limitations
|
|
176
402
|
|
|
@@ -183,20 +409,40 @@ The plugin inspects the shell command string, so it can be bypassed by:
|
|
|
183
409
|
- Compound commands the scanner cannot split.
|
|
184
410
|
|
|
185
411
|
Also note that `branch` is treated as a mutation, so `git branch` and
|
|
186
|
-
`git branch --show-current` are blocked on a protected branch. Use
|
|
412
|
+
`git branch --show-current` are blocked or asked on a protected branch. Use
|
|
187
413
|
`git rev-parse --abbrev-ref HEAD` if you need a read-only branch check there.
|
|
188
414
|
|
|
415
|
+
### About `ask`
|
|
416
|
+
|
|
417
|
+
The `ask` effect sends the operation to the OpenCode permission prompt. It has
|
|
418
|
+
these limits:
|
|
419
|
+
|
|
420
|
+
- **An explicit `deny` in your own `permissions` config is final.** The plugin
|
|
421
|
+
hook runs only for `allow` and `ask` decisions, so a `deny` rule in
|
|
422
|
+
`opencode.json(c)` blocks the command before the plugin sees it.
|
|
423
|
+
- **Non-interactive clients decide how to handle `ask`.** A run without a user
|
|
424
|
+
(for example CI) may reject or stall. Keep `deny` for those environments.
|
|
425
|
+
- **"Allow always" may not stick.** Approving always saves a durable `allow`
|
|
426
|
+
rule, but the plugin hook still runs and can escalate the command to `ask`
|
|
427
|
+
again. Use `allow` in the plugin options for a permanent decision.
|
|
428
|
+
- **The plugin never downgrades.** If the core resolved an `ask` from your
|
|
429
|
+
config, the plugin does not turn it into `allow`.
|
|
430
|
+
|
|
189
431
|
It is a guardrail against accidental mutations, not a security boundary.
|
|
190
432
|
|
|
191
433
|
## Verify
|
|
192
434
|
|
|
193
435
|
After configuring, restart OpenCode and try:
|
|
194
436
|
|
|
195
|
-
1. On `main`: ask the agent to run `git commit` — the
|
|
196
|
-
`Blocked: git commit is not allowed by config`.
|
|
197
|
-
2. On
|
|
198
|
-
|
|
199
|
-
|
|
437
|
+
1. On `main` with `{ "allow": [] }`: ask the agent to run `git commit` — the
|
|
438
|
+
command is denied with `Blocked: git commit is not allowed by config`.
|
|
439
|
+
2. On `main` with `{ "allow": [], "ask": ["commit"] }`: the same command opens a
|
|
440
|
+
permission prompt.
|
|
441
|
+
3. On a feature branch: the same command is allowed.
|
|
442
|
+
4. `git status` and `git log` are always allowed.
|
|
443
|
+
5. On a directory listed in `repos`, the repository policy applies.
|
|
444
|
+
6. With `{ "allow": [], "ask": ["commit"], "deny": ["push"] }` on `main`:
|
|
445
|
+
`git push` is denied, and `git commit` asks.
|
|
200
446
|
|
|
201
447
|
## Uninstall
|
|
202
448
|
|
|
@@ -216,8 +462,8 @@ bun test # unit (core logic) + functional (mocked plugin context)
|
|
|
216
462
|
bun run build # dist/index.js + dist/index.d.ts (npm entrypoint)
|
|
217
463
|
```
|
|
218
464
|
|
|
219
|
-
- `src/core.ts` — pure logic: git operation parsing
|
|
220
|
-
OpenCode imports. Fully unit-tested.
|
|
465
|
+
- `src/core.ts` — pure logic: git operation parsing, policy resolution, and the
|
|
466
|
+
`allow`/`ask`/`deny` decision. No OpenCode imports. Fully unit-tested.
|
|
221
467
|
- `src/index.ts` — the plugin (`id: "branch-guard"`), a
|
|
222
468
|
`Plugin.define({ id, setup })` from `@opencode/plugin`. It registers a
|
|
223
469
|
`ctx.permission.hook("evaluate")`, resolves the session directory, and reads
|
package/dist/core.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export declare const OPS: readonly ["add", "branch", "checkout", "cherry-pick",
|
|
|
2
2
|
export type GitOp = (typeof OPS)[number];
|
|
3
3
|
export type BranchPolicy = {
|
|
4
4
|
allow?: string[];
|
|
5
|
+
ask?: string[];
|
|
5
6
|
deny?: string[];
|
|
6
7
|
};
|
|
7
8
|
export type Config = {
|
|
@@ -11,10 +12,12 @@ export type Config = {
|
|
|
11
12
|
};
|
|
12
13
|
export type ResolvedPolicy = {
|
|
13
14
|
allow: string[];
|
|
15
|
+
ask: string[];
|
|
14
16
|
deny: string[];
|
|
15
17
|
};
|
|
18
|
+
export type Effect = "allow" | "ask" | "deny";
|
|
16
19
|
export type Decision = {
|
|
17
|
-
|
|
20
|
+
effect: Effect;
|
|
18
21
|
message?: string;
|
|
19
22
|
};
|
|
20
23
|
export declare function isGitOp(value: string): value is GitOp;
|
package/dist/index.js
CHANGED
|
@@ -65,17 +65,21 @@ function resolvePolicy(config, branch, directory) {
|
|
|
65
65
|
const repoRule = directory !== undefined ? config.repos?.[directory] : undefined;
|
|
66
66
|
const rule = repoRule ?? branchRule;
|
|
67
67
|
const allow = rule?.allow ?? base.allow ?? [];
|
|
68
|
+
const ask = rule?.ask ?? base.ask ?? [];
|
|
68
69
|
const deny = [...base.deny ?? [], ...rule?.deny ?? []];
|
|
69
|
-
return { allow, deny };
|
|
70
|
+
return { allow, ask, deny };
|
|
70
71
|
}
|
|
71
72
|
function decide(op, policy) {
|
|
72
73
|
if (policy.deny.includes(op)) {
|
|
73
|
-
return {
|
|
74
|
+
return { effect: "deny", message: `Blocked: git ${op} is denied by config` };
|
|
74
75
|
}
|
|
75
|
-
if (
|
|
76
|
-
return {
|
|
76
|
+
if (policy.ask.includes(op)) {
|
|
77
|
+
return { effect: "ask", message: `git ${op} requires approval by config` };
|
|
77
78
|
}
|
|
78
|
-
|
|
79
|
+
if (policy.allow.includes(op)) {
|
|
80
|
+
return { effect: "allow" };
|
|
81
|
+
}
|
|
82
|
+
return { effect: "deny", message: `Blocked: git ${op} is not allowed by config` };
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
// src/index.ts
|
|
@@ -116,17 +120,27 @@ var src_default = Plugin.define({
|
|
|
116
120
|
}
|
|
117
121
|
const branch = config.branches ? await currentBranch(directory ?? ctx.location.directory) : undefined;
|
|
118
122
|
const policy = resolvePolicy(config, branch, directory);
|
|
123
|
+
let effect = "allow";
|
|
124
|
+
let message;
|
|
119
125
|
for (const resource of event.resources) {
|
|
120
126
|
const op = gitOp(resource);
|
|
121
127
|
if (op === null)
|
|
122
128
|
continue;
|
|
123
129
|
const decision = decide(op, policy);
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
130
|
+
if (decision.effect === "deny") {
|
|
131
|
+
effect = "deny";
|
|
132
|
+
message = decision.message;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
if (decision.effect === "ask") {
|
|
136
|
+
effect = "ask";
|
|
137
|
+
message ??= decision.message;
|
|
128
138
|
}
|
|
129
139
|
}
|
|
140
|
+
if (effect !== "allow") {
|
|
141
|
+
event.effect = effect;
|
|
142
|
+
event.message = message;
|
|
143
|
+
}
|
|
130
144
|
});
|
|
131
145
|
}
|
|
132
146
|
});
|
|
@@ -134,4 +148,4 @@ export {
|
|
|
134
148
|
src_default as default
|
|
135
149
|
};
|
|
136
150
|
|
|
137
|
-
//# debugId=
|
|
151
|
+
//# debugId=8C2497AAA571E94264756E2164756E21
|
package/dist/index.js.map
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/index.ts", "../src/core.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"import { execFile } from \"node:child_process\"\nimport { promisify } from \"node:util\"\nimport { Plugin } from \"@opencode/plugin\"\nimport { decide, gitOp, resolvePolicy, type Config } from \"./core\"\n\nconst execFileAsync = promisify(execFile)\n\n// `ctx.vcs.get()` can return a stale or empty branch, so read it from git\n// directly. Returns undefined outside a repository.\nasync function currentBranch(directory: string): Promise<string | undefined> {\n try {\n const { stdout } = await execFileAsync(\n \"git\",\n [\"-C\", directory, \"branch\", \"--show-current\"],\n { timeout: 3000, windowsHide: true },\n )\n const branch = stdout.trim()\n return branch.length > 0 ? branch : undefined\n } catch {\n return undefined\n }\n}\n\n// The plugin context's location is not necessarily the session's repository, and\n// the permission event does not carry a directory. Resolve it from the session.\nasync function sessionDirectory(ctx: Plugin.Context, sessionID: string): Promise<string | undefined> {\n try {\n return (await ctx.session.get({ sessionID })).location.directory\n } catch {\n return undefined\n }\n}\n\nexport default Plugin.define({\n id: \"branch-guard\",\n async setup(ctx) {\n const config = (ctx.options ?? {}) as Config\n const directories = new Map<string, string | null>()\n\n await ctx.permission.hook(\"evaluate\", async (event) => {\n // V2 names the shell action \"shell\"; ignore every other action.\n if (event.action !== \"shell\") return\n\n let directory: string | undefined\n if (config.branches || config.repos) {\n const cached = directories.get(event.sessionID)\n if (cached !== undefined) {\n directory = cached ?? undefined\n } else {\n directory = await sessionDirectory(ctx, event.sessionID)\n directories.set(event.sessionID, directory ?? null)\n }\n }\n\n const branch = config.branches\n ? await currentBranch(directory ?? ctx.location.directory)\n : undefined\n const policy = resolvePolicy(config, branch, directory)\n\n for (const resource of event.resources) {\n const op = gitOp(resource)\n if (op === null) continue\n const decision = decide(op, policy)\n if (
|
|
6
|
-
"// Pure branch-guard logic. No OpenCode imports, so it is fully unit-testable.\n\nexport const OPS = [\n \"add\",\n \"branch\",\n \"checkout\",\n \"cherry-pick\",\n \"clean\",\n \"commit\",\n \"merge\",\n \"mv\",\n \"push\",\n \"rebase\",\n \"reset\",\n \"restore\",\n \"revert\",\n \"rm\",\n \"stash\",\n \"switch\",\n \"tag\",\n] as const\n\nexport type GitOp = (typeof OPS)[number]\n\n// Git global flags that consume the following token as their value.\nconst FLAG_WITH_VALUE = new Set([\n \"-C\",\n \"-c\",\n \"--git-dir\",\n \"--work-tree\",\n \"--exec-path\",\n \"--namespace\",\n \"--separate-git-dir\",\n \"--config-env\",\n])\n\nexport type BranchPolicy = {\n allow?: string[]\n deny?: string[]\n}\n\nexport type Config = {\n default?: BranchPolicy\n branches?: Record<string, BranchPolicy>\n repos?: Record<string, BranchPolicy>\n}\n\nexport type ResolvedPolicy = {\n allow: string[]\n deny: string[]\n}\n\nexport type Decision = {\n
|
|
5
|
+
"import { execFile } from \"node:child_process\"\nimport { promisify } from \"node:util\"\nimport { Plugin } from \"@opencode/plugin\"\nimport { decide, gitOp, resolvePolicy, type Config, type Effect } from \"./core\"\n\nconst execFileAsync = promisify(execFile)\n\n// `ctx.vcs.get()` can return a stale or empty branch, so read it from git\n// directly. Returns undefined outside a repository.\nasync function currentBranch(directory: string): Promise<string | undefined> {\n try {\n const { stdout } = await execFileAsync(\n \"git\",\n [\"-C\", directory, \"branch\", \"--show-current\"],\n { timeout: 3000, windowsHide: true },\n )\n const branch = stdout.trim()\n return branch.length > 0 ? branch : undefined\n } catch {\n return undefined\n }\n}\n\n// The plugin context's location is not necessarily the session's repository, and\n// the permission event does not carry a directory. Resolve it from the session.\nasync function sessionDirectory(ctx: Plugin.Context, sessionID: string): Promise<string | undefined> {\n try {\n return (await ctx.session.get({ sessionID })).location.directory\n } catch {\n return undefined\n }\n}\n\nexport default Plugin.define({\n id: \"branch-guard\",\n async setup(ctx) {\n const config = (ctx.options ?? {}) as Config\n const directories = new Map<string, string | null>()\n\n await ctx.permission.hook(\"evaluate\", async (event) => {\n // V2 names the shell action \"shell\"; ignore every other action.\n if (event.action !== \"shell\") return\n\n let directory: string | undefined\n if (config.branches || config.repos) {\n const cached = directories.get(event.sessionID)\n if (cached !== undefined) {\n directory = cached ?? undefined\n } else {\n directory = await sessionDirectory(ctx, event.sessionID)\n directories.set(event.sessionID, directory ?? null)\n }\n }\n\n const branch = config.branches\n ? await currentBranch(directory ?? ctx.location.directory)\n : undefined\n const policy = resolvePolicy(config, branch, directory)\n\n // Aggregate every resource of a compound command. Precedence is\n // `deny > ask > allow`: a single denied operation blocks the command,\n // otherwise a single ask escalates it. Never downgrade an effect the\n // core already resolved (for example an `ask` from the user's config).\n let effect: Effect = \"allow\"\n let message: string | undefined\n for (const resource of event.resources) {\n const op = gitOp(resource)\n if (op === null) continue\n const decision = decide(op, policy)\n if (decision.effect === \"deny\") {\n effect = \"deny\"\n message = decision.message\n break\n }\n if (decision.effect === \"ask\") {\n effect = \"ask\"\n message ??= decision.message\n }\n }\n if (effect !== \"allow\") {\n event.effect = effect\n event.message = message\n }\n })\n },\n})\n",
|
|
6
|
+
"// Pure branch-guard logic. No OpenCode imports, so it is fully unit-testable.\n\nexport const OPS = [\n \"add\",\n \"branch\",\n \"checkout\",\n \"cherry-pick\",\n \"clean\",\n \"commit\",\n \"merge\",\n \"mv\",\n \"push\",\n \"rebase\",\n \"reset\",\n \"restore\",\n \"revert\",\n \"rm\",\n \"stash\",\n \"switch\",\n \"tag\",\n] as const\n\nexport type GitOp = (typeof OPS)[number]\n\n// Git global flags that consume the following token as their value.\nconst FLAG_WITH_VALUE = new Set([\n \"-C\",\n \"-c\",\n \"--git-dir\",\n \"--work-tree\",\n \"--exec-path\",\n \"--namespace\",\n \"--separate-git-dir\",\n \"--config-env\",\n])\n\nexport type BranchPolicy = {\n allow?: string[]\n ask?: string[]\n deny?: string[]\n}\n\nexport type Config = {\n default?: BranchPolicy\n branches?: Record<string, BranchPolicy>\n repos?: Record<string, BranchPolicy>\n}\n\nexport type ResolvedPolicy = {\n allow: string[]\n ask: string[]\n deny: string[]\n}\n\nexport type Effect = \"allow\" | \"ask\" | \"deny\"\n\nexport type Decision = {\n effect: Effect\n message?: string\n}\n\nexport function isGitOp(value: string): value is GitOp {\n return (OPS as readonly string[]).includes(value)\n}\n\n// Extract the git mutation from a shell command string. Returns null for\n// non-git commands, git invocations without a known mutation, and read-only\n// commands (`status`, `log`, `diff`, ...).\nexport function gitOp(cmd: string): GitOp | null {\n const tokens = cmd.trim().split(/\\s+/).filter(Boolean)\n if (tokens[0] !== \"git\") return null\n\n let i = 1\n while (i < tokens.length) {\n const token = tokens[i]\n if (token === undefined) return null\n if (!token.startsWith(\"-\")) {\n return isGitOp(token) ? token : null\n }\n if (token.startsWith(\"--\") && token.includes(\"=\")) {\n i += 1\n } else if (FLAG_WITH_VALUE.has(token)) {\n i += 2\n } else {\n i += 1\n }\n }\n return null\n}\n\n// Resolve the effective policy for a command. `repos[directory]` overrides\n// `branches[branch]`, which overrides `default`. A rule's `allow` and `ask`\n// replace the baseline; its `deny` unions with the baseline. No resolved\n// `allow` means no mutation is permitted (fail-closed).\nexport function resolvePolicy(\n config: Config,\n branch: string | undefined,\n directory: string | undefined,\n): ResolvedPolicy {\n const base = config.default ?? {}\n const branchRule = branch !== undefined ? config.branches?.[branch] : undefined\n const repoRule = directory !== undefined ? config.repos?.[directory] : undefined\n const rule = repoRule ?? branchRule\n const allow = rule?.allow ?? base.allow ?? []\n const ask = rule?.ask ?? base.ask ?? []\n const deny = [...(base.deny ?? []), ...(rule?.deny ?? [])]\n return { allow, ask, deny }\n}\n\n// Resolve the effect for one git operation. Precedence is `deny > ask > allow`.\n// An operation missing from every list is denied (fail-closed).\nexport function decide(op: string, policy: ResolvedPolicy): Decision {\n if (policy.deny.includes(op)) {\n return { effect: \"deny\", message: `Blocked: git ${op} is denied by config` }\n }\n if (policy.ask.includes(op)) {\n return { effect: \"ask\", message: `git ${op} requires approval by config` }\n }\n if (policy.allow.includes(op)) {\n return { effect: \"allow\" }\n }\n return { effect: \"deny\", message: `Blocked: git ${op} is not allowed by config` }\n}\n"
|
|
7
7
|
],
|
|
8
|
-
"mappings": ";;AAAA;AACA;AACA;;;ACAO,IAAM,MAAM;AAAA,EACjB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAKA,IAAM,kBAAkB,IAAI,IAAI;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;
|
|
9
|
-
"debugId": "
|
|
8
|
+
"mappings": "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",
|
|
9
|
+
"debugId": "8C2497AAA571E94264756E2164756E21",
|
|
10
10
|
"names": []
|
|
11
11
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-branch-guard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "OpenCode plugin: blocks git mutations (commit, push, merge, rebase, ...) based on the current branch or repository, so protected branches stay clean.",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "OpenCode plugin: allows, asks, or blocks git mutations (commit, push, merge, rebase, ...) based on the current branch or repository, so protected branches stay clean.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": {
|