@microsoft/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,50 @@
1
+ {
2
+ "name": "@microsoft/agent-governance-opencode",
3
+ "version": "4.0.0",
4
+ "description": "Public Preview — OpenCode CLI governance plugin for Agent Governance Toolkit developer protection policies",
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
+ "engines": {
48
+ "node": ">=22.0.0"
49
+ }
50
+ }
@@ -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,263 @@
1
+ // Copyright (c) Microsoft Corporation.
2
+ // Licensed under the MIT License.
3
+
4
+ import { dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import {
8
+ checkArbitraryText,
9
+ evaluateOpenCodePrompt,
10
+ evaluateOpenCodeTool,
11
+ evaluateOpenCodeToolOutput,
12
+ getPolicyStatus,
13
+ loadPolicy,
14
+ } from "../lib/policy.mjs";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+
18
+ /**
19
+ * AGT governance plugin for OpenCode.
20
+ *
21
+ * Loads the AGT policy once per OpenCode process and wires it into the
22
+ * OpenCode plugin contract:
23
+ *
24
+ * - session.start — inject AGT governance context for the run
25
+ * - event (chat.params/start) — scan submitted prompts; throw to block
26
+ * - tool.execute.before — enforce policy; throw to deny, mark args
27
+ * for OpenCode's permission prompt on review
28
+ * - tool.execute.after — scan tool output for known secret patterns
29
+ * and redact in enforce mode
30
+ * - tool.execute.error — record audit entry for failed tool calls
31
+ * - tools.agt_policy_status — return current policy snapshot
32
+ * - tools.agt_policy_check_text — inspect arbitrary text for poisoning
33
+ *
34
+ * The plugin fails closed: if AGT cannot evaluate a request and the active
35
+ * policy has `denyOnPolicyError: true` (the default), the request is denied.
36
+ *
37
+ * @typedef {(context: object) => Promise<object>} Plugin
38
+ * @type {Plugin}
39
+ */
40
+ export const AgtGovernance = async (ctx) => {
41
+ // OpenCode loads plugins once per process. Cache the compiled policy so we
42
+ // don't re-read it on every hook invocation.
43
+ let stateCache;
44
+ let stateError;
45
+
46
+ async function getState() {
47
+ if (stateCache) {
48
+ return stateCache;
49
+ }
50
+ if (stateError) {
51
+ throw stateError;
52
+ }
53
+ try {
54
+ stateCache = await loadPolicy();
55
+ return stateCache;
56
+ } catch (error) {
57
+ stateError = error instanceof Error ? error : new Error(String(error));
58
+ throw stateError;
59
+ }
60
+ }
61
+
62
+ return {
63
+ session: {
64
+ async start(input) {
65
+ try {
66
+ const state = await getState();
67
+ const status = await getPolicyStatus(state);
68
+ if (typeof ctx?.app?.log === "function") {
69
+ ctx.app.log(
70
+ `[AGT] OpenCode governance active — mode=${status.mode} source=${status.source} ` +
71
+ `promptDefense=${status.promptDefenseGrade} audit=${status.auditEntries}`,
72
+ );
73
+ }
74
+ return {
75
+ additionalContext: [
76
+ `AGT governance mode: ${status.mode}.`,
77
+ `Policy source: ${status.source}.`,
78
+ ...state.policy.additionalContext,
79
+ `Prompt defense grade: ${status.promptDefenseGrade} (${status.promptDefenseCoverage}).`,
80
+ ].join("\n"),
81
+ sessionId: input?.sessionID,
82
+ };
83
+ } catch (error) {
84
+ // Fail closed on session boot: surface the failure via context so
85
+ // operators can see it, but do not throw because OpenCode may not
86
+ // have a permission prompt available at session start.
87
+ return {
88
+ additionalContext: `AGT governance failed to initialize: ${
89
+ error instanceof Error ? error.message : String(error)
90
+ }`,
91
+ };
92
+ }
93
+ },
94
+ },
95
+
96
+ event: async ({ event } = {}) => {
97
+ // OpenCode emits a wide range of events. Only inspect prompt-bearing
98
+ // events; ignore the rest cheaply.
99
+ const prompt = extractPromptFromEvent(event);
100
+ if (!prompt) {
101
+ return;
102
+ }
103
+
104
+ const state = await getState();
105
+ const result = await evaluateOpenCodePrompt(state, {
106
+ prompt,
107
+ sessionId: event?.properties?.sessionID ?? event?.properties?.sessionId,
108
+ });
109
+ if (result.effect === "deny") {
110
+ throw new Error(result.reason || "AGT governance blocked the submitted prompt.");
111
+ }
112
+ },
113
+
114
+ tool: {
115
+ execute: {
116
+ before: async (input, output) => {
117
+ const state = await getState();
118
+ const result = await evaluateOpenCodeTool(state, {
119
+ tool: input?.tool,
120
+ args: output?.args,
121
+ cwd: ctx?.directory ?? ctx?.worktree,
122
+ sessionId: input?.sessionID,
123
+ });
124
+
125
+ if (result.effect === "deny") {
126
+ throw new Error(result.reason || `AGT policy denied tool '${input?.tool}'.`);
127
+ }
128
+ if (result.effect === "review") {
129
+ // OpenCode does not currently expose a server-side "ask"
130
+ // permission decision from inside a plugin hook. We mark the
131
+ // request as requiring review by appending a hint to the args
132
+ // so downstream permission integrations can pick it up, and we
133
+ // still record the audit entry. Operators who want hard-deny
134
+ // behaviour on review should switch the policy mode or set
135
+ // `defaultEffect` to `deny`.
136
+ if (output && typeof output === "object" && output.args && typeof output.args === "object") {
137
+ output.args.__agt_review_reason = result.reason || "AGT review required.";
138
+ }
139
+ }
140
+ },
141
+ after: async (input, output) => {
142
+ if (!output || typeof output !== "object") {
143
+ return;
144
+ }
145
+ const state = await getState();
146
+ const text = typeof output.output === "string" ? output.output : "";
147
+ const result = await evaluateOpenCodeToolOutput(state, {
148
+ tool: input?.tool,
149
+ output: text,
150
+ sessionId: input?.sessionID,
151
+ });
152
+ if (result.redact && typeof result.redactedOutput === "string") {
153
+ output.output = result.redactedOutput;
154
+ if (typeof output.metadata === "object" && output.metadata !== null) {
155
+ output.metadata.agtRedacted = true;
156
+ output.metadata.agtRedactionReason = result.reason;
157
+ }
158
+ }
159
+ },
160
+ error: async (input, output) => {
161
+ // Record an audit entry without re-running policy. We swallow any
162
+ // audit failure here because the tool already errored upstream.
163
+ try {
164
+ const state = await getState();
165
+ await evaluateOpenCodeToolOutput(state, {
166
+ tool: input?.tool,
167
+ output: String(output?.error ?? ""),
168
+ sessionId: input?.sessionID,
169
+ });
170
+ } catch {
171
+ // best-effort
172
+ }
173
+ },
174
+ },
175
+ },
176
+
177
+ tools: {
178
+ agt_policy_status: {
179
+ description: "Return the active AGT OpenCode governance policy status and source.",
180
+ parameters: {
181
+ type: "object",
182
+ properties: {},
183
+ additionalProperties: false,
184
+ },
185
+ async execute() {
186
+ const state = await getState();
187
+ return {
188
+ content: [
189
+ {
190
+ type: "text",
191
+ text: JSON.stringify(await getPolicyStatus(state), null, 2),
192
+ },
193
+ ],
194
+ };
195
+ },
196
+ },
197
+ agt_policy_check_text: {
198
+ description:
199
+ "Check text against AGT prompt, context-poisoning, and MCP-style threat detectors.",
200
+ parameters: {
201
+ type: "object",
202
+ properties: {
203
+ text: { type: "string", description: "Text to inspect." },
204
+ },
205
+ required: ["text"],
206
+ additionalProperties: false,
207
+ },
208
+ async execute(args) {
209
+ const state = await getState();
210
+ const text = typeof args?.text === "string" ? args.text : "";
211
+ return {
212
+ content: [
213
+ {
214
+ type: "text",
215
+ text: JSON.stringify(checkArbitraryText(state, text, "opencode-check"), null, 2),
216
+ },
217
+ ],
218
+ };
219
+ },
220
+ },
221
+ },
222
+ };
223
+ };
224
+
225
+ export default AgtGovernance;
226
+
227
+ function extractPromptFromEvent(event) {
228
+ if (!event || typeof event !== "object") {
229
+ return "";
230
+ }
231
+ const type = String(event.type ?? "");
232
+ if (!type) {
233
+ return "";
234
+ }
235
+ // OpenCode emits chat.* events when the user sends a message. Different
236
+ // versions may key the prompt under different paths; check the common ones.
237
+ if (!/^(chat|message|prompt|user)\b/.test(type)) {
238
+ return "";
239
+ }
240
+ const props = event.properties ?? event.data ?? {};
241
+ const candidates = [
242
+ props.prompt,
243
+ props.message,
244
+ props.text,
245
+ props.content,
246
+ typeof props.message === "object" ? props.message?.content : undefined,
247
+ ];
248
+ for (const candidate of candidates) {
249
+ if (typeof candidate === "string" && candidate.trim()) {
250
+ return candidate;
251
+ }
252
+ if (Array.isArray(candidate)) {
253
+ const joined = candidate
254
+ .map((part) => (typeof part === "string" ? part : part?.text ?? ""))
255
+ .filter(Boolean)
256
+ .join("\n");
257
+ if (joined.trim()) {
258
+ return joined;
259
+ }
260
+ }
261
+ }
262
+ return "";
263
+ }