opencode-branch-guard 0.1.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/LICENSE +21 -0
- package/README.md +244 -0
- package/dist/core.d.ts +23 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +137 -0
- package/dist/index.js.map +11 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hugo Batista
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# opencode-branch-guard
|
|
2
|
+
|
|
3
|
+
[](https://go.hugobatista.com/gh/opencode-branch-guard/releases)
|
|
4
|
+
[](https://go.hugobatista.com/gh/opencode-branch-guard/actions/workflows/lint.yml)
|
|
5
|
+
[](https://go.hugobatista.com/gh/opencode-branch-guard/actions/workflows/test.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/opencode-branch-guard)
|
|
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.
|
|
12
|
+
|
|
13
|
+
> **Requires OpenCode V2.** OpenCode V2 changed the plugin API; V1 plugin
|
|
14
|
+
> implementations do not run in V2. This plugin is built against
|
|
15
|
+
> `@opencode/plugin` V2 only.
|
|
16
|
+
|
|
17
|
+
## What it does
|
|
18
|
+
|
|
19
|
+
- Intercepts the `shell` permission via `ctx.permission.hook("evaluate")` and
|
|
20
|
+
returns `effect: "deny"` when a git mutation violates the resolved policy.
|
|
21
|
+
- Resolves the branch with `git -C <directory> branch --show-current`; the
|
|
22
|
+
directory comes from the session (`ctx.session.get().location.directory`), not
|
|
23
|
+
the plugin instance.
|
|
24
|
+
- Resolves the policy hierarchically: `repos[<directory>]` overrides
|
|
25
|
+
`branches[<branch>]`, which overrides `default`.
|
|
26
|
+
- Applies the same decision to every resource of a compound command, so
|
|
27
|
+
`cd /tmp && git commit` is caught.
|
|
28
|
+
- Passes read-only commands (`status`, `log`, `diff`, `fetch`, …) and anything
|
|
29
|
+
that is not a known git mutation.
|
|
30
|
+
- Fails closed: with no options, every git mutation is denied.
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
- **OpenCode V2.** The V2 release changed the plugin API; V1 plugin
|
|
35
|
+
implementations do not run in V2.
|
|
36
|
+
- [Bun](https://bun.sh) to install dependencies (dev only).
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
opencode plugin add opencode-branch-guard
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or add the package to `opencode.jsonc` (project or
|
|
45
|
+
`~/.config/opencode/opencode.jsonc`):
|
|
46
|
+
|
|
47
|
+
```jsonc
|
|
48
|
+
{
|
|
49
|
+
"$schema": "https://opencode.ai/config.json",
|
|
50
|
+
"plugins": ["opencode-branch-guard"]
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
With no options the plugin is **fail-closed**: it denies every git mutation.
|
|
55
|
+
Pass [options](#configuration) to allow the operations you want.
|
|
56
|
+
|
|
57
|
+
> This is a **server** plugin. Configure it in `opencode.json(c)`. The
|
|
58
|
+
> `cli.json` file is for terminal (TUI) plugins only.
|
|
59
|
+
|
|
60
|
+
### Install from source (local dev)
|
|
61
|
+
|
|
62
|
+
1. Clone the repository and install dependencies:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
git clone https://github.com/hugobatista/opencode-branch-guard.git ~/code/projects/opencode-branch-guard
|
|
66
|
+
cd ~/code/projects/opencode-branch-guard
|
|
67
|
+
bun install
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
2. Register the plugin in your `opencode.jsonc` with an absolute path to the
|
|
71
|
+
`src` directory (a local plugin directory must contain `index.ts` at its
|
|
72
|
+
root):
|
|
73
|
+
|
|
74
|
+
```jsonc
|
|
75
|
+
{
|
|
76
|
+
"$schema": "https://opencode.ai/config.json",
|
|
77
|
+
"plugins": ["/home/your-user/code/projects/opencode-branch-guard/src"]
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
3. Restart OpenCode.
|
|
82
|
+
|
|
83
|
+
## Configuration
|
|
84
|
+
|
|
85
|
+
Pass options with the object form:
|
|
86
|
+
|
|
87
|
+
```jsonc
|
|
88
|
+
{
|
|
89
|
+
"$schema": "https://opencode.ai/config.json",
|
|
90
|
+
"plugins": [
|
|
91
|
+
{
|
|
92
|
+
"package": "opencode-branch-guard",
|
|
93
|
+
"options": {
|
|
94
|
+
"default": {
|
|
95
|
+
"allow": ["add", "branch", "checkout", "commit", "push", "fetch", "merge", "pull", "rebase", "reset", "restore", "stash", "switch", "tag"],
|
|
96
|
+
"deny": []
|
|
97
|
+
},
|
|
98
|
+
"branches": {
|
|
99
|
+
"main": { "allow": [] },
|
|
100
|
+
"master": { "allow": [] }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Options
|
|
109
|
+
|
|
110
|
+
| Option | Default | Description |
|
|
111
|
+
|---|---|---|
|
|
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. |
|
|
114
|
+
| `repos` | none | Policy keyed by absolute location directory. Takes precedence over `branches` and `default`. |
|
|
115
|
+
|
|
116
|
+
A policy is `{ "allow": string[], "deny": string[] }`. Both fields are optional.
|
|
117
|
+
|
|
118
|
+
Recognized git operations: `add`, `branch`, `checkout`, `cherry-pick`, `clean`,
|
|
119
|
+
`commit`, `merge`, `mv`, `push`, `rebase`, `reset`, `restore`, `revert`, `rm`,
|
|
120
|
+
`stash`, `switch`, `tag`.
|
|
121
|
+
|
|
122
|
+
### Semantics
|
|
123
|
+
|
|
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.
|
|
135
|
+
|
|
136
|
+
The example above gives `main`/`master` an empty `allow` (no mutations), while
|
|
137
|
+
every other branch inherits the permissive default.
|
|
138
|
+
|
|
139
|
+
### Per-repository override
|
|
140
|
+
|
|
141
|
+
Allow work in a single checkout even on a protected branch by keying it on the
|
|
142
|
+
location directory:
|
|
143
|
+
|
|
144
|
+
```jsonc
|
|
145
|
+
{
|
|
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
|
+
}
|
|
158
|
+
}
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Blocklist instead of allowlist
|
|
164
|
+
|
|
165
|
+
Use `deny` when you want a permissive baseline with a few hard blocks:
|
|
166
|
+
|
|
167
|
+
```jsonc
|
|
168
|
+
{
|
|
169
|
+
"default": { "allow": ["commit", "push", "add", "checkout", "merge"], "deny": ["push"] }
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`deny` wins over `allow`, so `push` is blocked even though it is listed.
|
|
174
|
+
|
|
175
|
+
## Limitations
|
|
176
|
+
|
|
177
|
+
The plugin inspects the shell command string, so it can be bypassed by:
|
|
178
|
+
|
|
179
|
+
- Git hidden behind a wrapper the scanner does not unwrap (`sudo git commit`,
|
|
180
|
+
shell aliases, scripts that call git).
|
|
181
|
+
- Git invoked through a tool other than the shell tool (for example a
|
|
182
|
+
subprocess started by a program the agent runs).
|
|
183
|
+
- Compound commands the scanner cannot split.
|
|
184
|
+
|
|
185
|
+
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
|
|
187
|
+
`git rev-parse --abbrev-ref HEAD` if you need a read-only branch check there.
|
|
188
|
+
|
|
189
|
+
It is a guardrail against accidental mutations, not a security boundary.
|
|
190
|
+
|
|
191
|
+
## Verify
|
|
192
|
+
|
|
193
|
+
After configuring, restart OpenCode and try:
|
|
194
|
+
|
|
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.
|
|
200
|
+
|
|
201
|
+
## Uninstall
|
|
202
|
+
|
|
203
|
+
```sh
|
|
204
|
+
opencode plugin remove opencode-branch-guard
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Or remove the entry from `plugins` in your `opencode.jsonc` and restart
|
|
208
|
+
OpenCode.
|
|
209
|
+
|
|
210
|
+
## Development
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
bun install
|
|
214
|
+
bun run typecheck # tsc --noEmit, strict
|
|
215
|
+
bun test # unit (core logic) + functional (mocked plugin context)
|
|
216
|
+
bun run build # dist/index.js + dist/index.d.ts (npm entrypoint)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- `src/core.ts` — pure logic: git operation parsing and policy resolution. No
|
|
220
|
+
OpenCode imports. Fully unit-tested.
|
|
221
|
+
- `src/index.ts` — the plugin (`id: "branch-guard"`), a
|
|
222
|
+
`Plugin.define({ id, setup })` from `@opencode/plugin`. It registers a
|
|
223
|
+
`ctx.permission.hook("evaluate")`, resolves the session directory, and reads
|
|
224
|
+
the branch with `git branch --show-current`.
|
|
225
|
+
- `scripts/build.ts` — bundles `src/index.ts` to `dist/index.js` with
|
|
226
|
+
`@opencode/plugin` external, then emits declarations with `tsc`.
|
|
227
|
+
|
|
228
|
+
## Pre-release checklist
|
|
229
|
+
|
|
230
|
+
```sh
|
|
231
|
+
bun install
|
|
232
|
+
bun run typecheck
|
|
233
|
+
bun test
|
|
234
|
+
bun run build
|
|
235
|
+
npm pack --dry-run
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Inspect the pack list (`dist/`, `README.md`, `LICENSE` only). Scan for secrets
|
|
239
|
+
before `npm publish`.
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
MIT — see [LICENSE](./LICENSE). Author: Hugo Batista
|
|
244
|
+
(<https://github.com/hugobatista>).
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const OPS: readonly ["add", "branch", "checkout", "cherry-pick", "clean", "commit", "merge", "mv", "push", "rebase", "reset", "restore", "revert", "rm", "stash", "switch", "tag"];
|
|
2
|
+
export type GitOp = (typeof OPS)[number];
|
|
3
|
+
export type BranchPolicy = {
|
|
4
|
+
allow?: string[];
|
|
5
|
+
deny?: string[];
|
|
6
|
+
};
|
|
7
|
+
export type Config = {
|
|
8
|
+
default?: BranchPolicy;
|
|
9
|
+
branches?: Record<string, BranchPolicy>;
|
|
10
|
+
repos?: Record<string, BranchPolicy>;
|
|
11
|
+
};
|
|
12
|
+
export type ResolvedPolicy = {
|
|
13
|
+
allow: string[];
|
|
14
|
+
deny: string[];
|
|
15
|
+
};
|
|
16
|
+
export type Decision = {
|
|
17
|
+
allowed: boolean;
|
|
18
|
+
message?: string;
|
|
19
|
+
};
|
|
20
|
+
export declare function isGitOp(value: string): value is GitOp;
|
|
21
|
+
export declare function gitOp(cmd: string): GitOp | null;
|
|
22
|
+
export declare function resolvePolicy(config: Config, branch: string | undefined, directory: string | undefined): ResolvedPolicy;
|
|
23
|
+
export declare function decide(op: string, policy: ResolvedPolicy): Decision;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/index.ts
|
|
3
|
+
import { execFile } from "child_process";
|
|
4
|
+
import { promisify } from "util";
|
|
5
|
+
import { Plugin } from "@opencode/plugin";
|
|
6
|
+
|
|
7
|
+
// src/core.ts
|
|
8
|
+
var OPS = [
|
|
9
|
+
"add",
|
|
10
|
+
"branch",
|
|
11
|
+
"checkout",
|
|
12
|
+
"cherry-pick",
|
|
13
|
+
"clean",
|
|
14
|
+
"commit",
|
|
15
|
+
"merge",
|
|
16
|
+
"mv",
|
|
17
|
+
"push",
|
|
18
|
+
"rebase",
|
|
19
|
+
"reset",
|
|
20
|
+
"restore",
|
|
21
|
+
"revert",
|
|
22
|
+
"rm",
|
|
23
|
+
"stash",
|
|
24
|
+
"switch",
|
|
25
|
+
"tag"
|
|
26
|
+
];
|
|
27
|
+
var FLAG_WITH_VALUE = new Set([
|
|
28
|
+
"-C",
|
|
29
|
+
"-c",
|
|
30
|
+
"--git-dir",
|
|
31
|
+
"--work-tree",
|
|
32
|
+
"--exec-path",
|
|
33
|
+
"--namespace",
|
|
34
|
+
"--separate-git-dir",
|
|
35
|
+
"--config-env"
|
|
36
|
+
]);
|
|
37
|
+
function isGitOp(value) {
|
|
38
|
+
return OPS.includes(value);
|
|
39
|
+
}
|
|
40
|
+
function gitOp(cmd) {
|
|
41
|
+
const tokens = cmd.trim().split(/\s+/).filter(Boolean);
|
|
42
|
+
if (tokens[0] !== "git")
|
|
43
|
+
return null;
|
|
44
|
+
let i = 1;
|
|
45
|
+
while (i < tokens.length) {
|
|
46
|
+
const token = tokens[i];
|
|
47
|
+
if (token === undefined)
|
|
48
|
+
return null;
|
|
49
|
+
if (!token.startsWith("-")) {
|
|
50
|
+
return isGitOp(token) ? token : null;
|
|
51
|
+
}
|
|
52
|
+
if (token.startsWith("--") && token.includes("=")) {
|
|
53
|
+
i += 1;
|
|
54
|
+
} else if (FLAG_WITH_VALUE.has(token)) {
|
|
55
|
+
i += 2;
|
|
56
|
+
} else {
|
|
57
|
+
i += 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
function resolvePolicy(config, branch, directory) {
|
|
63
|
+
const base = config.default ?? {};
|
|
64
|
+
const branchRule = branch !== undefined ? config.branches?.[branch] : undefined;
|
|
65
|
+
const repoRule = directory !== undefined ? config.repos?.[directory] : undefined;
|
|
66
|
+
const rule = repoRule ?? branchRule;
|
|
67
|
+
const allow = rule?.allow ?? base.allow ?? [];
|
|
68
|
+
const deny = [...base.deny ?? [], ...rule?.deny ?? []];
|
|
69
|
+
return { allow, deny };
|
|
70
|
+
}
|
|
71
|
+
function decide(op, policy) {
|
|
72
|
+
if (policy.deny.includes(op)) {
|
|
73
|
+
return { allowed: false, message: `Blocked: git ${op} is denied by config` };
|
|
74
|
+
}
|
|
75
|
+
if (!policy.allow.includes(op)) {
|
|
76
|
+
return { allowed: false, message: `Blocked: git ${op} is not allowed by config` };
|
|
77
|
+
}
|
|
78
|
+
return { allowed: true };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/index.ts
|
|
82
|
+
var execFileAsync = promisify(execFile);
|
|
83
|
+
async function currentBranch(directory) {
|
|
84
|
+
try {
|
|
85
|
+
const { stdout } = await execFileAsync("git", ["-C", directory, "branch", "--show-current"], { timeout: 3000, windowsHide: true });
|
|
86
|
+
const branch = stdout.trim();
|
|
87
|
+
return branch.length > 0 ? branch : undefined;
|
|
88
|
+
} catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function sessionDirectory(ctx, sessionID) {
|
|
93
|
+
try {
|
|
94
|
+
return (await ctx.session.get({ sessionID })).location.directory;
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
var src_default = Plugin.define({
|
|
100
|
+
id: "branch-guard",
|
|
101
|
+
async setup(ctx) {
|
|
102
|
+
const config = ctx.options ?? {};
|
|
103
|
+
const directories = new Map;
|
|
104
|
+
await ctx.permission.hook("evaluate", async (event) => {
|
|
105
|
+
if (event.action !== "shell")
|
|
106
|
+
return;
|
|
107
|
+
let directory;
|
|
108
|
+
if (config.branches || config.repos) {
|
|
109
|
+
const cached = directories.get(event.sessionID);
|
|
110
|
+
if (cached !== undefined) {
|
|
111
|
+
directory = cached ?? undefined;
|
|
112
|
+
} else {
|
|
113
|
+
directory = await sessionDirectory(ctx, event.sessionID);
|
|
114
|
+
directories.set(event.sessionID, directory ?? null);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const branch = config.branches ? await currentBranch(directory ?? ctx.location.directory) : undefined;
|
|
118
|
+
const policy = resolvePolicy(config, branch, directory);
|
|
119
|
+
for (const resource of event.resources) {
|
|
120
|
+
const op = gitOp(resource);
|
|
121
|
+
if (op === null)
|
|
122
|
+
continue;
|
|
123
|
+
const decision = decide(op, policy);
|
|
124
|
+
if (!decision.allowed) {
|
|
125
|
+
event.effect = "deny";
|
|
126
|
+
event.message = decision.message;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
export {
|
|
134
|
+
src_default as default
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
//# debugId=4EB1771F16AB9A6E64756E2164756E21
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/index.ts", "../src/core.ts"],
|
|
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"
|
|
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;AAuBM,SAAS,OAAO,CAAC,OAA+B;AAAA,EACrD,OAAQ,IAA0B,SAAS,KAAK;AAAA;AAM3C,SAAS,KAAK,CAAC,KAA2B;AAAA,EAC/C,MAAM,SAAS,IAAI,KAAK,EAAE,MAAM,KAAK,EAAE,OAAO,OAAO;AAAA,EACrD,IAAI,OAAO,OAAO;AAAA,IAAO,OAAO;AAAA,EAEhC,IAAI,IAAI;AAAA,EACR,OAAO,IAAI,OAAO,QAAQ;AAAA,IACxB,MAAM,QAAQ,OAAO;AAAA,IACrB,IAAI,UAAU;AAAA,MAAW,OAAO;AAAA,IAChC,IAAI,CAAC,MAAM,WAAW,GAAG,GAAG;AAAA,MAC1B,OAAO,QAAQ,KAAK,IAAI,QAAQ;AAAA,IAClC;AAAA,IACA,IAAI,MAAM,WAAW,IAAI,KAAK,MAAM,SAAS,GAAG,GAAG;AAAA,MACjD,KAAK;AAAA,IACP,EAAO,SAAI,gBAAgB,IAAI,KAAK,GAAG;AAAA,MACrC,KAAK;AAAA,IACP,EAAO;AAAA,MACL,KAAK;AAAA;AAAA,EAET;AAAA,EACA,OAAO;AAAA;AAOF,SAAS,aAAa,CAC3B,QACA,QACA,WACgB;AAAA,EAChB,MAAM,OAAO,OAAO,WAAW,CAAC;AAAA,EAChC,MAAM,aAAa,WAAW,YAAY,OAAO,WAAW,UAAU;AAAA,EACtE,MAAM,WAAW,cAAc,YAAY,OAAO,QAAQ,aAAa;AAAA,EACvE,MAAM,OAAO,YAAY;AAAA,EACzB,MAAM,QAAQ,MAAM,SAAS,KAAK,SAAS,CAAC;AAAA,EAC5C,MAAM,OAAO,CAAC,GAAI,KAAK,QAAQ,CAAC,GAAI,GAAI,MAAM,QAAQ,CAAC,CAAE;AAAA,EACzD,OAAO,EAAE,OAAO,KAAK;AAAA;AAGhB,SAAS,MAAM,CAAC,IAAY,QAAkC;AAAA,EACnE,IAAI,OAAO,KAAK,SAAS,EAAE,GAAG;AAAA,IAC5B,OAAO,EAAE,SAAS,OAAO,SAAS,gBAAgB,yBAAyB;AAAA,EAC7E;AAAA,EACA,IAAI,CAAC,OAAO,MAAM,SAAS,EAAE,GAAG;AAAA,IAC9B,OAAO,EAAE,SAAS,OAAO,SAAS,gBAAgB,8BAA8B;AAAA,EAClF;AAAA,EACA,OAAO,EAAE,SAAS,KAAK;AAAA;;;AD1GzB,IAAM,gBAAgB,UAAU,QAAQ;AAIxC,eAAe,aAAa,CAAC,WAAgD;AAAA,EAC3E,IAAI;AAAA,IACF,QAAQ,WAAW,MAAM,cACvB,OACA,CAAC,MAAM,WAAW,UAAU,gBAAgB,GAC5C,EAAE,SAAS,MAAM,aAAa,KAAK,CACrC;AAAA,IACA,MAAM,SAAS,OAAO,KAAK;AAAA,IAC3B,OAAO,OAAO,SAAS,IAAI,SAAS;AAAA,IACpC,MAAM;AAAA,IACN;AAAA;AAAA;AAMJ,eAAe,gBAAgB,CAAC,KAAqB,WAAgD;AAAA,EACnG,IAAI;AAAA,IACF,QAAQ,MAAM,IAAI,QAAQ,IAAI,EAAE,UAAU,CAAC,GAAG,SAAS;AAAA,IACvD,MAAM;AAAA,IACN;AAAA;AAAA;AAIJ,IAAe,qBAAO,OAAO;AAAA,EAC3B,IAAI;AAAA,OACE,MAAK,CAAC,KAAK;AAAA,IACf,MAAM,SAAU,IAAI,WAAW,CAAC;AAAA,IAChC,MAAM,cAAc,IAAI;AAAA,IAExB,MAAM,IAAI,WAAW,KAAK,YAAY,OAAO,UAAU;AAAA,MAErD,IAAI,MAAM,WAAW;AAAA,QAAS;AAAA,MAE9B,IAAI;AAAA,MACJ,IAAI,OAAO,YAAY,OAAO,OAAO;AAAA,QACnC,MAAM,SAAS,YAAY,IAAI,MAAM,SAAS;AAAA,QAC9C,IAAI,WAAW,WAAW;AAAA,UACxB,YAAY,UAAU;AAAA,QACxB,EAAO;AAAA,UACL,YAAY,MAAM,iBAAiB,KAAK,MAAM,SAAS;AAAA,UACvD,YAAY,IAAI,MAAM,WAAW,aAAa,IAAI;AAAA;AAAA,MAEtD;AAAA,MAEA,MAAM,SAAS,OAAO,WAClB,MAAM,cAAc,aAAa,IAAI,SAAS,SAAS,IACvD;AAAA,MACJ,MAAM,SAAS,cAAc,QAAQ,QAAQ,SAAS;AAAA,MAEtD,WAAW,YAAY,MAAM,WAAW;AAAA,QACtC,MAAM,KAAK,MAAM,QAAQ;AAAA,QACzB,IAAI,OAAO;AAAA,UAAM;AAAA,QACjB,MAAM,WAAW,OAAO,IAAI,MAAM;AAAA,QAClC,IAAI,CAAC,SAAS,SAAS;AAAA,UACrB,MAAM,SAAS;AAAA,UACf,MAAM,UAAU,SAAS;AAAA,UACzB;AAAA,QACF;AAAA,MACF;AAAA,KACD;AAAA;AAEL,CAAC;",
|
|
9
|
+
"debugId": "4EB1771F16AB9A6E64756E2164756E21",
|
|
10
|
+
"names": []
|
|
11
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
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.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Hugo Batista",
|
|
9
|
+
"url": "https://github.com/hugobatista"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/hugobatista/opencode-branch-guard.git"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/hugobatista/opencode-branch-guard#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/hugobatista/opencode-branch-guard/issues"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"opencode",
|
|
21
|
+
"opencode-plugin",
|
|
22
|
+
"git",
|
|
23
|
+
"branch",
|
|
24
|
+
"guard",
|
|
25
|
+
"permissions"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"opencode": ">=2.0.0"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./dist/index.d.ts",
|
|
41
|
+
"import": "./dist/index.js",
|
|
42
|
+
"default": "./dist/index.js"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "bun test",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"build": "bun run scripts/build.ts",
|
|
49
|
+
"prepublishOnly": "bun run typecheck && bun test && bun run build"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@opencode/plugin": ">=2.0.10"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@opencode/plugin": "2.0.10",
|
|
56
|
+
"@types/bun": "^1.4.1",
|
|
57
|
+
"@types/node": "^24.0.0",
|
|
58
|
+
"typescript": "^5.8.0"
|
|
59
|
+
}
|
|
60
|
+
}
|