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 CHANGED
@@ -5,10 +5,11 @@
5
5
  [![Test](https://img.shields.io/github/actions/workflow/status/hugobatista/opencode-branch-guard/test.yml?label=Test)](https://go.hugobatista.com/gh/opencode-branch-guard/actions/workflows/test.yml)
6
6
  [![npm](https://img.shields.io/npm/v/opencode-branch-guard.svg)](https://www.npmjs.com/package/opencode-branch-guard)
7
7
 
8
- OpenCode plugin. Blocks git mutations (`commit`, `push`, `merge`, `rebase`,
9
- `reset`, …) based on the current branch or repository, so protected branches stay
10
- clean. Deny a whole branch, allow a different policy per repository, and keep
11
- read-only commands untouched.
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 violates the resolved policy.
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. `allow` lists the permitted git operations; `deny` lists blocked ones. With no `default.allow`, every mutation is blocked. |
113
- | `branches` | none | Policy keyed by exact branch name, resolved at command time from the VCS. A matching entry replaces the baseline `allow` and unions its `deny` with the baseline. |
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
- A policy is `{ "allow": string[], "deny": string[] }`. Both fields are optional.
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
- Recognized git operations: `add`, `branch`, `checkout`, `cherry-pick`, `clean`,
119
- `commit`, `merge`, `mv`, `push`, `rebase`, `reset`, `restore`, `revert`, `rm`,
120
- `stash`, `switch`, `tag`.
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
- ### Semantics
169
+ ### How a policy is resolved
123
170
 
124
- - **Fail-closed.** No config, or an empty resolved `allow`, blocks every git
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
- The example above gives `main`/`master` an empty `allow` (no mutations), while
137
- every other branch inherits the permissive default.
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
- ### Per-repository override
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
- "plugins": [
147
- {
148
- "package": "opencode-branch-guard",
149
- "options": {
150
- "default": { "allow": ["commit", "push"] },
151
- "branches": { "main": { "allow": [] } },
152
- "repos": {
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
- ### Blocklist instead of allowlist
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
- Use `deny` when you want a permissive baseline with a few hard blocks:
383
+ #### 10. Effect precedence
166
384
 
167
385
  ```jsonc
168
386
  {
169
- "default": { "allow": ["commit", "push", "add", "checkout", "merge"], "deny": ["push"] }
387
+ "default": { "allow": ["commit"], "ask": ["commit"], "deny": [] }
170
388
  }
171
389
  ```
172
390
 
173
- `deny` wins over `allow`, so `push` is blocked even though it is listed.
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 command is denied with
196
- `Blocked: git commit is not allowed by config`.
197
- 2. On a feature branch: the same command is allowed.
198
- 3. `git status` and `git log` are always allowed.
199
- 4. On a directory listed in `repos`, the repository policy applies.
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 and policy resolution. No
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
- allowed: boolean;
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 { allowed: false, message: `Blocked: git ${op} is denied by config` };
74
+ return { effect: "deny", message: `Blocked: git ${op} is denied by config` };
74
75
  }
75
- if (!policy.allow.includes(op)) {
76
- return { allowed: false, message: `Blocked: git ${op} is not allowed by config` };
76
+ if (policy.ask.includes(op)) {
77
+ return { effect: "ask", message: `git ${op} requires approval by config` };
77
78
  }
78
- return { allowed: true };
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 (!decision.allowed) {
125
- event.effect = "deny";
126
- event.message = decision.message;
127
- return;
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=4EB1771F16AB9A6E64756E2164756E21
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 (!decision.allowed) {\n event.effect = \"deny\"\n event.message = decision.message\n return\n }\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 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 allowed: boolean\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` replaces the\n// baseline; its `deny` unions with the baseline. No resolved `allow` means no\n// 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 deny = [...(base.deny ?? []), ...(rule?.deny ?? [])]\n return { allow, deny }\n}\n\nexport function decide(op: string, policy: ResolvedPolicy): Decision {\n if (policy.deny.includes(op)) {\n return { allowed: false, message: `Blocked: git ${op} is denied by config` }\n }\n if (!policy.allow.includes(op)) {\n return { allowed: false, message: `Blocked: git ${op} is not allowed by config` }\n }\n return { allowed: true }\n}\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": "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",
9
- "debugId": "4EB1771F16AB9A6E64756E2164756E21",
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.1.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": {