@gandalfix/opencode-permission-export 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Markus Eberle
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,58 @@
1
+ # opencode-permission-export
2
+
3
+ OpenCode plugin that exports granted permissions as a config snippet.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @gandalfix/opencode-permission-export
9
+ ```
10
+
11
+ Add to your `opencode.json`:
12
+
13
+ ```json
14
+ {
15
+ "plugin": ["@gandalfix/opencode-permission-export"]
16
+ }
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ During an OpenCode session, the plugin tracks all permission requests. When you want to export the granted permissions:
22
+
23
+ ```
24
+ export my permissions
25
+ ```
26
+
27
+ or
28
+
29
+ ```
30
+ run export-permissions
31
+ ```
32
+
33
+ The tool outputs a JSON snippet you can copy into your `opencode.json`:
34
+
35
+ ```json
36
+ {
37
+ "permission": {
38
+ "bash": {
39
+ "git status": "allow",
40
+ "npm run *": "allow"
41
+ },
42
+ "edit": {
43
+ "src/*.ts": "allow"
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ ## How It Works
50
+
51
+ 1. Hooks into `permission.ask` and `permission.replied` events
52
+ 2. Tracks which permissions were granted vs denied
53
+ 3. Generates valid OpenCode permission config syntax
54
+ 4. Only exports granted permissions (denied are noted but not included)
55
+
56
+ ## License
57
+
58
+ MIT
@@ -0,0 +1,17 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ interface PermissionEvent {
3
+ tool: string;
4
+ pattern: string;
5
+ outcome: "granted" | "denied";
6
+ }
7
+ declare function extractCommandGroup(pattern: string): string;
8
+ interface GroupOptions {
9
+ minGroupSize?: number;
10
+ excludeCommands?: string[];
11
+ }
12
+ declare function groupPermissions(events: PermissionEvent[], options?: GroupOptions): PermissionEvent[];
13
+ declare function generateConfig(events: PermissionEvent[]): Record<string, unknown>;
14
+ export declare const PermissionExportPlugin: Plugin;
15
+ export default PermissionExportPlugin;
16
+ export { extractCommandGroup, groupPermissions, generateConfig };
17
+ export type { PermissionEvent };
package/dist/index.js ADDED
@@ -0,0 +1,133 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ class PermissionTracker {
3
+ events = [];
4
+ pendingPermissions = new Map();
5
+ storePermission(permission) {
6
+ this.pendingPermissions.set(permission.id, permission);
7
+ }
8
+ getPermission(id) {
9
+ return this.pendingPermissions.get(id);
10
+ }
11
+ add(event) {
12
+ const exists = this.events.some((e) => e.tool === event.tool && e.pattern === event.pattern && e.outcome === event.outcome);
13
+ if (!exists) {
14
+ this.events.push(event);
15
+ }
16
+ }
17
+ getGranted() {
18
+ return this.events.filter((e) => e.outcome === "granted");
19
+ }
20
+ getDenied() {
21
+ return this.events.filter((e) => e.outcome === "denied");
22
+ }
23
+ clear() {
24
+ this.events = [];
25
+ this.pendingPermissions.clear();
26
+ }
27
+ hasEvents() {
28
+ return this.events.length > 0;
29
+ }
30
+ }
31
+ const tracker = new PermissionTracker();
32
+ function extractPattern(permission) {
33
+ if (permission.patterns && permission.patterns.length > 0) {
34
+ return permission.patterns.join(",");
35
+ }
36
+ const metadata = permission.metadata;
37
+ if (typeof metadata.command === "string")
38
+ return metadata.command;
39
+ if (typeof metadata.filePath === "string")
40
+ return metadata.filePath;
41
+ if (typeof metadata.path === "string")
42
+ return metadata.path;
43
+ if (typeof metadata.url === "string")
44
+ return metadata.url;
45
+ if (typeof metadata.query === "string")
46
+ return metadata.query;
47
+ return "*";
48
+ }
49
+ function extractCommandGroup(pattern) {
50
+ const firstCommand = pattern.split(",")[0]?.trim() ?? pattern;
51
+ const parts = firstCommand.split(/\s+/);
52
+ return parts[0] ?? "*";
53
+ }
54
+ const DEFAULT_EXCLUDE_COMMANDS = ["rm", "sudo", "curl", "wget"];
55
+ function groupPermissions(events, options = {}) {
56
+ const { minGroupSize = 2, excludeCommands = DEFAULT_EXCLUDE_COMMANDS } = options;
57
+ const groups = new Map();
58
+ for (const event of events) {
59
+ if (event.tool !== "bash") {
60
+ groups.set(`${event.tool}:${event.pattern}`, event);
61
+ continue;
62
+ }
63
+ const commandGroup = extractCommandGroup(event.pattern);
64
+ if (excludeCommands.includes(commandGroup)) {
65
+ groups.set(`${event.tool}:${event.pattern}`, event);
66
+ continue;
67
+ }
68
+ const groupKey = `${event.tool}:${commandGroup}:*`;
69
+ if (groups.has(groupKey))
70
+ continue;
71
+ const sameGroupCount = events.filter(e => e.tool === "bash" && extractCommandGroup(e.pattern) === commandGroup).length;
72
+ if (sameGroupCount >= minGroupSize) {
73
+ groups.set(groupKey, { tool: "bash", pattern: `${commandGroup}:*`, outcome: event.outcome });
74
+ }
75
+ else {
76
+ groups.set(`${event.tool}:${event.pattern}`, event);
77
+ }
78
+ }
79
+ return Array.from(groups.values());
80
+ }
81
+ function generateConfig(events) {
82
+ const grouped = groupPermissions(events);
83
+ const permission = {};
84
+ for (const event of grouped) {
85
+ if (!permission[event.tool]) {
86
+ permission[event.tool] = {};
87
+ }
88
+ permission[event.tool][event.pattern] = "allow";
89
+ }
90
+ return { permission };
91
+ }
92
+ export const PermissionExportPlugin = async (ctx) => {
93
+ return {
94
+ event: async (input) => {
95
+ const event = input.event;
96
+ if (event.type === "permission.asked") {
97
+ const props = event.properties;
98
+ tracker.storePermission({ id: props.id, type: props.permission, patterns: props.patterns, metadata: props.metadata || {} });
99
+ }
100
+ if (event.type === "permission.replied") {
101
+ const props = event.properties;
102
+ const permission = tracker.getPermission(props.requestID);
103
+ if (permission && (props.reply === "once" || props.reply === "always")) {
104
+ const tool = permission.type;
105
+ const pattern = extractPattern(permission);
106
+ tracker.add({ tool, pattern, outcome: "granted" });
107
+ }
108
+ }
109
+ },
110
+ tool: {
111
+ "export-permissions": tool({
112
+ description: "Export granted permissions as opencode config snippet. Paste the output into your opencode.json.",
113
+ args: {},
114
+ async execute(_args, _context) {
115
+ if (!tracker.hasEvents()) {
116
+ return "No permissions have been asked this session.";
117
+ }
118
+ const granted = tracker.getGranted();
119
+ if (granted.length === 0) {
120
+ const denied = tracker.getDenied();
121
+ return `No permissions were granted. ${denied.length} request(s) were denied.`;
122
+ }
123
+ const config = generateConfig(granted);
124
+ const denied = tracker.getDenied();
125
+ const deniedNote = denied.length > 0 ? `\n\nNote: ${denied.length} permission(s) were denied and not included.` : "";
126
+ return `Copy this into your opencode.json:\n\n${JSON.stringify(config, null, 2)}${deniedNote}`;
127
+ },
128
+ }),
129
+ },
130
+ };
131
+ };
132
+ export default PermissionExportPlugin;
133
+ export { extractCommandGroup, groupPermissions, generateConfig };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { extractCommandGroup, groupPermissions, generateConfig } from "./index.js";
3
+ describe("extractCommandGroup", () => {
4
+ it("extracts git command group", () => {
5
+ expect(extractCommandGroup("git status")).toBe("git");
6
+ expect(extractCommandGroup("git diff abafd15..a7d1836")).toBe("git");
7
+ });
8
+ it("extracts package manager commands", () => {
9
+ expect(extractCommandGroup("pnpm dev")).toBe("pnpm");
10
+ expect(extractCommandGroup("npm test -- --run")).toBe("npm");
11
+ });
12
+ it("handles comma-separated commands", () => {
13
+ expect(extractCommandGroup("git status,git diff --stat")).toBe("git");
14
+ });
15
+ it("returns first word for unknown commands", () => {
16
+ expect(extractCommandGroup("mkdir -p dir")).toBe("mkdir");
17
+ });
18
+ });
19
+ describe("groupPermissions", () => {
20
+ it("groups git commands under git:*", () => {
21
+ const events = [
22
+ { tool: "bash", pattern: "git status", outcome: "granted" },
23
+ { tool: "bash", pattern: "git diff", outcome: "granted" },
24
+ ];
25
+ expect(groupPermissions(events)).toEqual([
26
+ { tool: "bash", pattern: "git:*", outcome: "granted" },
27
+ ]);
28
+ });
29
+ it("keeps single commands as-is", () => {
30
+ const events = [
31
+ { tool: "bash", pattern: "pnpm dev", outcome: "granted" },
32
+ ];
33
+ expect(groupPermissions(events)).toEqual(events);
34
+ });
35
+ it("preserves non-bash tools unchanged", () => {
36
+ const events = [
37
+ { tool: "edit", pattern: "file.ts", outcome: "granted" },
38
+ ];
39
+ expect(groupPermissions(events)).toEqual(events);
40
+ });
41
+ });
42
+ describe("groupPermissions threshold", () => {
43
+ it("respects minimum threshold", () => {
44
+ const events = [
45
+ { tool: "bash", pattern: "git status", outcome: "granted" },
46
+ { tool: "bash", pattern: "git diff", outcome: "granted" },
47
+ ];
48
+ expect(groupPermissions(events, { minGroupSize: 3 })).toEqual(events);
49
+ });
50
+ });
51
+ describe("generateConfig with grouping", () => {
52
+ it("outputs grouped permissions", () => {
53
+ const events = [
54
+ { tool: "bash", pattern: "git status", outcome: "granted" },
55
+ { tool: "bash", pattern: "git diff", outcome: "granted" },
56
+ { tool: "bash", pattern: "pnpm build", outcome: "granted" },
57
+ { tool: "bash", pattern: "pnpm test", outcome: "granted" },
58
+ ];
59
+ const result = generateConfig(events);
60
+ expect(result.permission.bash).toEqual({
61
+ "git:*": "allow",
62
+ "pnpm:*": "allow",
63
+ });
64
+ });
65
+ });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@gandalfix/opencode-permission-export",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode plugin to export granted permissions as config snippet",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "dev": "tsc --watch",
17
+ "test": "vitest run",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "files": [
21
+ "dist/",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/GandalfIX/opencode-permission-exporter.git"
28
+ },
29
+ "bugs": "https://github.com/GandalfIX/opencode-permission-exporter/issues",
30
+ "homepage": "https://github.com/GandalfIX/opencode-permission-exporter#readme",
31
+ "peerDependencies": {
32
+ "@opencode-ai/plugin": ">=1.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "@opencode-ai/plugin": "latest",
36
+ "@types/node": "^25.5.0",
37
+ "typescript": "^5.0.0",
38
+ "vitest": "^4.1.2"
39
+ },
40
+ "keywords": [
41
+ "opencode",
42
+ "plugin",
43
+ "permissions"
44
+ ],
45
+ "license": "MIT"
46
+ }