@ax0l0tl/agent-governance-opencode 4.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/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@ax0l0tl/agent-governance-opencode",
3
+ "version": "4.0.0",
4
+ "description": "Public Preview — OpenCode CLI governance plugin for Agent Governance Toolkit developer protection policies (fork with OpenCode contract fixes)",
5
+ "type": "module",
6
+ "main": "src/index.mjs",
7
+ "exports": {
8
+ ".": "./src/index.mjs",
9
+ "./policy": "./lib/policy.mjs",
10
+ "./mcp-server": "./server/agt-mcp.mjs"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "config/",
15
+ "lib/",
16
+ "server/",
17
+ "src/",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "build": "npm run check",
22
+ "check": "node --check ./src/index.mjs && node --check ./lib/audit.mjs && node --check ./lib/poisoning.mjs && node --check ./lib/policy.mjs && node --check ./server/agt-mcp.mjs && node --test ./test/*.test.mjs",
23
+ "test": "node --test ./test/*.test.mjs"
24
+ },
25
+ "keywords": [
26
+ "opencode",
27
+ "agent",
28
+ "governance",
29
+ "security",
30
+ "policy",
31
+ "mcp"
32
+ ],
33
+ "author": {
34
+ "name": "Microsoft Corporation",
35
+ "email": "agentgovtoolkit@microsoft.com"
36
+ },
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/microsoft/agent-governance-toolkit.git",
41
+ "directory": "agent-governance-opencode"
42
+ },
43
+ "homepage": "https://github.com/microsoft/agent-governance-toolkit/tree/main/agent-governance-opencode",
44
+ "dependencies": {
45
+ "@microsoft/agent-governance-sdk": "3.7.0"
46
+ },
47
+ "devDependencies": {
48
+ "@opencode-ai/plugin": "^1.17.1"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.0.0"
52
+ }
53
+ }
@@ -0,0 +1,233 @@
1
+ // Copyright (c) Microsoft Corporation.
2
+ // Licensed under the MIT License.
3
+
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+
7
+ import { checkArbitraryText, getPolicyStatus, loadPolicy } from "../lib/policy.mjs";
8
+
9
+ const VERSION = "3.6.0";
10
+ const PROTOCOL_VERSION = "2024-11-05";
11
+ const TOOL_DEFINITIONS = [
12
+ {
13
+ name: "agt_policy_status",
14
+ description: "Return the active AGT OpenCode governance policy status and source.",
15
+ inputSchema: {
16
+ type: "object",
17
+ properties: {},
18
+ additionalProperties: false,
19
+ },
20
+ },
21
+ {
22
+ name: "agt_policy_check_text",
23
+ description: "Check text against AGT prompt, context-poisoning, and MCP-style threat detectors.",
24
+ inputSchema: {
25
+ type: "object",
26
+ properties: {
27
+ text: {
28
+ type: "string",
29
+ description: "Text to inspect.",
30
+ },
31
+ },
32
+ required: ["text"],
33
+ additionalProperties: false,
34
+ },
35
+ },
36
+ ];
37
+
38
+ export async function handleJsonRpcRequest(state, request) {
39
+ if (!request || typeof request !== "object" || request.jsonrpc !== "2.0") {
40
+ return jsonRpcError(null, -32600, "Invalid Request");
41
+ }
42
+
43
+ const { id = null, method, params = {} } = request;
44
+
45
+ if (typeof method !== "string") {
46
+ return jsonRpcError(id, -32600, "Invalid Request");
47
+ }
48
+
49
+ if (method === "initialize") {
50
+ const protocolVersion =
51
+ typeof params.protocolVersion === "string" ? params.protocolVersion : PROTOCOL_VERSION;
52
+
53
+ return jsonRpcResult(id, {
54
+ protocolVersion,
55
+ capabilities: {
56
+ tools: {},
57
+ },
58
+ serverInfo: {
59
+ name: "agt-governance",
60
+ version: VERSION,
61
+ },
62
+ });
63
+ }
64
+
65
+ if (method === "notifications/initialized") {
66
+ return null;
67
+ }
68
+
69
+ if (method === "ping") {
70
+ return jsonRpcResult(id, {});
71
+ }
72
+
73
+ if (method === "tools/list") {
74
+ return jsonRpcResult(id, { tools: TOOL_DEFINITIONS });
75
+ }
76
+
77
+ if (method === "tools/call") {
78
+ return jsonRpcResult(id, await callTool(state, params));
79
+ }
80
+
81
+ return jsonRpcError(id, -32601, `Method not found: ${method}`);
82
+ }
83
+
84
+ export function encodeJsonRpcMessage(message) {
85
+ const body = JSON.stringify(message);
86
+ return `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
87
+ }
88
+
89
+ async function callTool(state, params) {
90
+ const name = params?.name;
91
+ const args = params?.arguments ?? {};
92
+
93
+ if (name === "agt_policy_status") {
94
+ return asJsonContent(await getPolicyStatus(state));
95
+ }
96
+
97
+ if (name === "agt_policy_check_text") {
98
+ if (typeof args.text !== "string") {
99
+ return asJsonError("agt_policy_check_text requires a string 'text' argument.");
100
+ }
101
+
102
+ return asJsonContent(checkArbitraryText(state, args.text, "mcp-check"));
103
+ }
104
+
105
+ return asJsonError(`Unknown tool: ${String(name)}`);
106
+ }
107
+
108
+ function asJsonContent(value) {
109
+ return {
110
+ content: [
111
+ {
112
+ type: "text",
113
+ text: JSON.stringify(value, null, 2),
114
+ },
115
+ ],
116
+ };
117
+ }
118
+
119
+ function asJsonError(message) {
120
+ return {
121
+ content: [
122
+ {
123
+ type: "text",
124
+ text: JSON.stringify({ error: message }, null, 2),
125
+ },
126
+ ],
127
+ isError: true,
128
+ };
129
+ }
130
+
131
+ function jsonRpcResult(id, result) {
132
+ return {
133
+ jsonrpc: "2.0",
134
+ id,
135
+ result,
136
+ };
137
+ }
138
+
139
+ function jsonRpcError(id, code, message) {
140
+ return {
141
+ jsonrpc: "2.0",
142
+ id,
143
+ error: {
144
+ code,
145
+ message,
146
+ },
147
+ };
148
+ }
149
+
150
+ async function startServer() {
151
+ const state = await loadPolicy();
152
+ let buffer = "";
153
+
154
+ process.stdin.setEncoding("utf8");
155
+ process.stdin.on("data", async (chunk) => {
156
+ buffer += chunk;
157
+ try {
158
+ buffer = await drainBuffer(state, buffer);
159
+ } catch (error) {
160
+ const response = jsonRpcError(null, -32603, error instanceof Error ? error.message : String(error));
161
+ process.stdout.write(encodeJsonRpcMessage(response));
162
+ buffer = "";
163
+ }
164
+ });
165
+ }
166
+
167
+ async function drainBuffer(state, buffer) {
168
+ let remaining = buffer;
169
+
170
+ while (remaining.length > 0) {
171
+ const headerEnd = remaining.indexOf("\r\n\r\n");
172
+ if (headerEnd >= 0) {
173
+ const headerBlock = remaining.slice(0, headerEnd);
174
+ const lengthMatch = /Content-Length:\s*(\d+)/i.exec(headerBlock);
175
+ if (!lengthMatch) {
176
+ throw new Error("Missing Content-Length header");
177
+ }
178
+
179
+ const bodyStart = headerEnd + 4;
180
+ const bodyLength = Number(lengthMatch[1]);
181
+ if (remaining.length < bodyStart + bodyLength) {
182
+ return remaining;
183
+ }
184
+
185
+ const body = remaining.slice(bodyStart, bodyStart + bodyLength);
186
+ remaining = remaining.slice(bodyStart + bodyLength);
187
+ await respondToBody(state, body);
188
+ continue;
189
+ }
190
+
191
+ const newlineIndex = remaining.indexOf("\n");
192
+ if (newlineIndex < 0) {
193
+ return remaining;
194
+ }
195
+
196
+ const line = remaining.slice(0, newlineIndex).trim();
197
+ remaining = remaining.slice(newlineIndex + 1);
198
+ if (line.length === 0) {
199
+ continue;
200
+ }
201
+
202
+ await respondToBody(state, line);
203
+ }
204
+
205
+ return remaining;
206
+ }
207
+
208
+ async function respondToBody(state, body) {
209
+ let request;
210
+ try {
211
+ request = JSON.parse(body);
212
+ } catch {
213
+ process.stdout.write(encodeJsonRpcMessage(jsonRpcError(null, -32700, "Parse error")));
214
+ return;
215
+ }
216
+
217
+ const response = await handleJsonRpcRequest(state, request);
218
+ if (response) {
219
+ process.stdout.write(encodeJsonRpcMessage(response));
220
+ }
221
+ }
222
+
223
+ if (isMainModule(import.meta.url)) {
224
+ await startServer();
225
+ }
226
+
227
+ function isMainModule(moduleUrl) {
228
+ if (!process.argv[1]) {
229
+ return false;
230
+ }
231
+
232
+ return fileURLToPath(moduleUrl) === path.resolve(process.argv[1]);
233
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,212 @@
1
+ // Copyright (c) Microsoft Corporation.
2
+ // Licensed under the MIT License.
3
+
4
+ import { tool } from "@opencode-ai/plugin";
5
+ import {
6
+ checkArbitraryText,
7
+ evaluateOpenCodePrompt,
8
+ evaluateOpenCodeTool,
9
+ evaluateOpenCodeToolOutput,
10
+ getPolicyStatus,
11
+ loadPolicy,
12
+ } from "../lib/policy.mjs";
13
+
14
+ /**
15
+ * AGT governance plugin for OpenCode.
16
+ *
17
+ * Loads the AGT policy once per OpenCode process and wires it into the
18
+ * OpenCode plugin contract:
19
+ *
20
+ * - session.created — inject AGT governance context for the run
21
+ * - event (chat.params/start) — scan submitted prompts; throw to block
22
+ * - tool.execute.before — enforce policy; throw to deny, mark args
23
+ * for OpenCode's permission prompt on review
24
+ * - tool.execute.after — scan tool output for known secret patterns
25
+ * and redact in enforce mode
26
+ * - tool.agt_policy_status — return current policy snapshot
27
+ * - tool.agt_policy_check_text — inspect arbitrary text for poisoning
28
+ *
29
+ * The plugin fails closed: if AGT cannot evaluate a request and the active
30
+ * policy has `denyOnPolicyError: true` (the default), the request is denied.
31
+ *
32
+ * @typedef {(context: object) => Promise<object>} Plugin
33
+ * @type {Plugin}
34
+ */
35
+ export const AgtGovernance = async (ctx) => {
36
+ // OpenCode loads plugins once per process. Cache the compiled policy so we
37
+ // don't re-read it on every hook invocation.
38
+ let stateCache;
39
+ let stateError;
40
+
41
+ async function getState() {
42
+ if (stateCache) {
43
+ return stateCache;
44
+ }
45
+ if (stateError) {
46
+ throw stateError;
47
+ }
48
+ try {
49
+ stateCache = await loadPolicy();
50
+ return stateCache;
51
+ } catch (error) {
52
+ stateError = error instanceof Error ? error : new Error(String(error));
53
+ throw stateError;
54
+ }
55
+ }
56
+
57
+ return {
58
+ // Bug 1 fixed: session.start is not a valid OpenCode hook; use session.created.
59
+ // The additionalContext return value is also not part of the OpenCode contract.
60
+ "session.created": async () => {
61
+ try {
62
+ const state = await getState();
63
+ const status = await getPolicyStatus(state);
64
+ if (typeof ctx?.client?.app?.log === "function") {
65
+ await ctx.client.app.log({
66
+ body: {
67
+ service: "agt-governance",
68
+ level: "info",
69
+ message:
70
+ `[AGT] OpenCode governance active — mode=${status.mode} source=${status.source} ` +
71
+ `promptDefense=${status.promptDefenseGrade} audit=${status.auditEntries}`,
72
+ },
73
+ });
74
+ }
75
+ } catch {
76
+ // best-effort — do not block session creation
77
+ }
78
+ },
79
+
80
+ event: async ({ event } = {}) => {
81
+ // OpenCode emits a wide range of events. Only inspect prompt-bearing
82
+ // events; ignore the rest cheaply.
83
+ const prompt = extractPromptFromEvent(event);
84
+ if (!prompt) {
85
+ return;
86
+ }
87
+
88
+ const state = await getState();
89
+ const result = await evaluateOpenCodePrompt(state, {
90
+ prompt,
91
+ sessionId: event?.properties?.sessionID ?? event?.properties?.sessionId,
92
+ });
93
+ if (result.effect === "deny") {
94
+ throw new Error(result.reason || "AGT governance blocked the submitted prompt.");
95
+ }
96
+ },
97
+
98
+ // Bug 2 fixed: OpenCode expects flat string keys, not nested objects.
99
+ "tool.execute.before": async (input, output) => {
100
+ const state = await getState();
101
+ const result = await evaluateOpenCodeTool(state, {
102
+ tool: input?.tool,
103
+ args: output?.args,
104
+ cwd: ctx?.directory ?? ctx?.worktree,
105
+ sessionId: input?.sessionID,
106
+ });
107
+
108
+ if (result.effect === "deny") {
109
+ throw new Error(result.reason || `AGT policy denied tool '${input?.tool}'.`);
110
+ }
111
+ if (result.effect === "review") {
112
+ // OpenCode does not currently expose a server-side "ask"
113
+ // permission decision from inside a plugin hook. We mark the
114
+ // request as requiring review by appending a hint to the args
115
+ // so downstream permission integrations can pick it up, and we
116
+ // still record the audit entry. Operators who want hard-deny
117
+ // behaviour on review should switch the policy mode or set
118
+ // `defaultEffect` to `deny`.
119
+ if (output && typeof output === "object" && output.args && typeof output.args === "object") {
120
+ output.args.__agt_review_reason = result.reason || "AGT review required.";
121
+ }
122
+ }
123
+ },
124
+
125
+ "tool.execute.after": async (input, output) => {
126
+ if (!output || typeof output !== "object") {
127
+ return;
128
+ }
129
+ const state = await getState();
130
+ const text = typeof output.output === "string" ? output.output : "";
131
+ const result = await evaluateOpenCodeToolOutput(state, {
132
+ tool: input?.tool,
133
+ output: text,
134
+ sessionId: input?.sessionID,
135
+ });
136
+ if (result.redact && typeof result.redactedOutput === "string") {
137
+ output.output = result.redactedOutput;
138
+ if (typeof output.metadata === "object" && output.metadata !== null) {
139
+ output.metadata.agtRedacted = true;
140
+ output.metadata.agtRedactionReason = result.reason;
141
+ }
142
+ }
143
+ },
144
+
145
+ // Bug 3 fixed: `tool` (singular) with `args` (not `tools` with `parameters`).
146
+ // Bug 4 fixed: execute returns a plain string, not an MCP { content: [...] } envelope.
147
+ // tool.schema (Zod) is provided by @opencode-ai/plugin (devDependency), which OpenCode
148
+ // always makes available in its plugin runtime environment.
149
+ tool: {
150
+ agt_policy_status: {
151
+ description: "Return the active AGT OpenCode governance policy status and source.",
152
+ args: {},
153
+ async execute() {
154
+ const state = await getState();
155
+ return JSON.stringify(await getPolicyStatus(state), null, 2);
156
+ },
157
+ },
158
+ agt_policy_check_text: {
159
+ description:
160
+ "Check text against AGT prompt, context-poisoning, and MCP-style threat detectors.",
161
+ args: {
162
+ text: tool.schema.string().describe("Text to inspect."),
163
+ },
164
+ async execute(args) {
165
+ const state = await getState();
166
+ const text = typeof args?.text === "string" ? args.text : "";
167
+ return JSON.stringify(checkArbitraryText(state, text, "opencode-check"), null, 2);
168
+ },
169
+ },
170
+ },
171
+ };
172
+ };
173
+
174
+ export default AgtGovernance;
175
+
176
+ function extractPromptFromEvent(event) {
177
+ if (!event || typeof event !== "object") {
178
+ return "";
179
+ }
180
+ const type = String(event.type ?? "");
181
+ if (!type) {
182
+ return "";
183
+ }
184
+ // OpenCode emits chat.* events when the user sends a message. Different
185
+ // versions may key the prompt under different paths; check the common ones.
186
+ if (!/^(chat|message|prompt|user)\b/.test(type)) {
187
+ return "";
188
+ }
189
+ const props = event.properties ?? event.data ?? {};
190
+ const candidates = [
191
+ props.prompt,
192
+ props.message,
193
+ props.text,
194
+ props.content,
195
+ typeof props.message === "object" ? props.message?.content : undefined,
196
+ ];
197
+ for (const candidate of candidates) {
198
+ if (typeof candidate === "string" && candidate.trim()) {
199
+ return candidate;
200
+ }
201
+ if (Array.isArray(candidate)) {
202
+ const joined = candidate
203
+ .map((part) => (typeof part === "string" ? part : part?.text ?? ""))
204
+ .filter(Boolean)
205
+ .join("\n");
206
+ if (joined.trim()) {
207
+ return joined;
208
+ }
209
+ }
210
+ }
211
+ return "";
212
+ }