@ory/opencode 0.14.0 → 1.0.1

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/dist/plugin.js CHANGED
@@ -2,18 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createOryPlugin = createOryPlugin;
4
4
  const argus_1 = require("@ory/argus");
5
- /**
6
- * Create the Ory plugin module for OpenCode.
7
- *
8
- * OpenCode plugins export { server: Plugin } where Plugin receives
9
- * context and returns a Hooks object. Hook functions use a mutating
10
- * (input, output) => Promise<void> pattern.
11
- *
12
- * The user login runs on every session at startup — it emits the audit
13
- * span, refreshes tokens, and may launch a browser flow when interactive.
14
- * It never blocks the session; enforcement is governed solely by
15
- * permissionMode at tool-call time.
16
- */
5
+ const HARNESS = "opencode";
6
+ const SESSION_CACHE_LIMIT = 256;
17
7
  function createOryPlugin(clientOrConfig, deps = {}) {
18
8
  return {
19
9
  id: "ory",
@@ -21,45 +11,66 @@ function createOryPlugin(clientOrConfig, deps = {}) {
21
11
  const client = clientOrConfig instanceof argus_1.OryAgentClient
22
12
  ? clientOrConfig
23
13
  : clientOrConfig
24
- ? new argus_1.OryAgentClient({
25
- ...clientOrConfig,
26
- harness: "opencode",
27
- })
28
- : argus_1.OryAgentClient.fromEnv("opencode");
29
- // Log session status on startup (never blocks)
30
- await verifyOnStartup(client, deps);
14
+ ? new argus_1.OryAgentClient({ ...clientOrConfig, harness: HARNESS })
15
+ : argus_1.OryAgentClient.fromEnv(HARNESS);
16
+ const ensureSessionStarted = createSessionStarter(client, deps);
31
17
  return {
32
18
  config: createConfigHandler(client),
33
- "chat.message": createChatMessageHandler(client),
34
- "permission.ask": createPermissionHandler(client, options),
35
- "tool.execute.before": createToolBeforeHandler(client),
19
+ "chat.message": createChatMessageHandler(client, ensureSessionStarted),
20
+ "permission.ask": createPermissionHandler(client, ensureSessionStarted, options),
21
+ "tool.execute.before": createToolBeforeHandler(client, ensureSessionStarted),
36
22
  "tool.execute.after": createToolAfterHandler(client),
37
23
  };
38
24
  },
39
25
  };
40
26
  }
41
- // ─── config ────────────────────────────────────────────────────────
42
27
  function createConfigHandler(client) {
43
28
  return async (_input) => {
29
+ const resolved = (0, argus_1.resolveConfig)();
44
30
  client.logger.info("lifecycle.config", {
45
- projectUrl: (0, argus_1.resolveConfig)().projectUrl,
46
- auditOnly: (0, argus_1.resolveConfig)().auditOnly,
31
+ projectUrl: resolved.projectUrl,
32
+ securityConnected: resolved.security.connected,
47
33
  });
48
- client.tracer.record("config.resolve", "ok", {
34
+ client.logger.activity("config.resolve", "ok", {
49
35
  attributes: {
50
- projectUrl: (0, argus_1.resolveConfig)().projectUrl,
51
- auditOnly: (0, argus_1.resolveConfig)().auditOnly,
36
+ projectUrl: resolved.projectUrl,
37
+ securityConnected: resolved.security.connected,
52
38
  },
53
39
  });
54
40
  };
55
41
  }
56
- // ─── chat.message ──────────────────────────────────────────────────
57
- function createChatMessageHandler(client) {
58
- return async (input, _output) => {
59
- client.tracer.setContext({
60
- traceId: (0, argus_1.deriveTraceId)(input.sessionID),
61
- sessionId: input.sessionID,
62
- });
42
+ function createSessionStarter(client, deps) {
43
+ const sessions = new Map();
44
+ const completed = new Set();
45
+ return async (sessionId) => {
46
+ let start = sessions.get(sessionId);
47
+ if (!start) {
48
+ start = (0, argus_1.withHookContext)(client, { sessionId }, async () => {
49
+ await (0, argus_1.sessionStart)(client, {
50
+ harness: HARNESS,
51
+ binName: "ory-opencode",
52
+ userLogin: deps.userLogin,
53
+ agentGate: deps.agentGate,
54
+ });
55
+ });
56
+ sessions.set(sessionId, start);
57
+ start.then(() => {
58
+ completed.add(sessionId);
59
+ while (sessions.size > SESSION_CACHE_LIMIT) {
60
+ const oldest = completed.values().next().value;
61
+ if (oldest === undefined)
62
+ break;
63
+ completed.delete(oldest);
64
+ sessions.delete(oldest);
65
+ }
66
+ }, () => sessions.delete(sessionId));
67
+ }
68
+ await start;
69
+ };
70
+ }
71
+ function createChatMessageHandler(client, ensureSessionStarted) {
72
+ return (input, _output) => (0, argus_1.withHookContext)(client, { sessionId: input.sessionID }, async () => {
73
+ await ensureSessionStarted(input.sessionID);
63
74
  client.logger.info("lifecycle.chat_message", {
64
75
  sessionID: input.sessionID,
65
76
  messageID: input.messageID,
@@ -67,7 +78,7 @@ function createChatMessageHandler(client) {
67
78
  providerID: input.model?.providerID,
68
79
  modelID: input.model?.modelID,
69
80
  });
70
- client.tracer.record("user.prompt", "ok", {
81
+ client.logger.activity("user.prompt", "ok", {
71
82
  attributes: {
72
83
  messageID: input.messageID,
73
84
  agent: input.agent,
@@ -75,332 +86,66 @@ function createChatMessageHandler(client) {
75
86
  modelID: input.model?.modelID,
76
87
  },
77
88
  });
78
- };
79
- }
80
- // ─── Session verification on startup ────────────────────────────────
81
- async function verifyOnStartup(client, deps) {
82
- // Run the user login on every session. The flow emits the user.auth
83
- // audit span, refreshes tokens, and may prompt the user, but never
84
- // prevents the session from starting — enforcement is governed solely
85
- // by permissionMode at tool-call time.
86
- const userGate = deps.userLogin ?? argus_1.ensureUserAuthenticated;
87
- await userGate(client, {
88
- binName: "ory-opencode",
89
- harness: "opencode",
90
89
  });
91
- // Resolve the agent identity (machine credentials). Never blocks;
92
- // attaches the agent's bearer token to outgoing Ory API calls.
93
- const agentGate = deps.agentGate ?? argus_1.ensureAgentIdentity;
94
- await agentGate(client, { projectUrl: (0, argus_1.resolveConfig)().projectUrl, harness: "opencode" });
95
- // Record the user→agent delegation so the audit trail captures that
96
- // this user authorized this agent for this session. Written at most once
97
- // per install. Best-effort: requires both principals to be populated, and
98
- // any failure is logged and swallowed (fail-open — delegation tracking is
99
- // for audit, not enforcement).
100
- await (0, argus_1.writeUserDelegatesAgent)(client);
101
90
  }
102
- // ─── permission.ask ─────────────────────────────────────────────────
103
- function createPermissionHandler(client, _options) {
91
+ function createPermissionHandler(client, ensureSessionStarted, _options) {
104
92
  return async (input, output) => {
105
- // Canonical Permission uses `type`; older plugin versions read `toolID`.
106
- // Coalesce so the handler works regardless of which the runtime populates.
107
- const toolId = input.toolID ?? input.type ?? "unknown";
108
- client.logger.info("lifecycle.permission_ask", {
109
- toolID: toolId,
110
- title: input.title,
111
- });
112
- // In audit-only mode, defer to user prompt
113
- if ((0, argus_1.resolveConfig)().auditOnly) {
114
- return;
115
- }
116
- // Interactive tools: leave output.status at "ask" so the user makes
117
- // the final call. Pre-answering on Ory's behalf hides the prompt the
118
- // user is meant to see.
119
- if ((0, argus_1.isInteractiveTool)("opencode", toolId)) {
120
- client.tracer.record("user.interaction", "ok", {
121
- attributes: { harness: "opencode", toolName: toolId, kind: "permission-ask" },
93
+ const run = async () => {
94
+ if (input.sessionID)
95
+ await ensureSessionStarted(input.sessionID);
96
+ const toolName = input.toolID ?? input.type ?? "unknown";
97
+ client.logger.info("lifecycle.permission_ask", { toolID: toolName, title: input.title });
98
+ const result = await (0, argus_1.decideTool)(client, {
99
+ harness: HARNESS,
100
+ toolName,
101
+ subjectFallback: input.sessionID ? `session:${input.sessionID}` : undefined,
102
+ canBlock: true,
103
+ mcpTool: (0, argus_1.parseMcpToolGeneric)(toolName) ?? undefined,
104
+ extraActivityAttributes: { kind: "permission-ask" },
122
105
  });
123
- return;
106
+ applyPermissionAskDecision(output, result.kind);
107
+ };
108
+ if (input.sessionID) {
109
+ await (0, argus_1.withHookContext)(client, { sessionId: input.sessionID }, run);
124
110
  }
125
- // Use the session as the subject fallback, matching tool.execute.before.
126
- // Letting this default to the agent's own subject (the prior behavior)
127
- // would silently match the agent's tuples instead of the user's.
128
- const subject = (0, argus_1.resolveUserSubject)(client, input.sessionID ? `session:${input.sessionID}` : undefined);
129
- const mcpTool = (0, argus_1.parseMcpToolGeneric)(toolId);
130
- try {
131
- if (mcpTool) {
132
- const mcpResult = await (0, argus_1.checkMcpPermission)(client, mcpTool, {
133
- subject,
134
- spanAttributes: { toolName: toolId },
135
- });
136
- const decision = (0, argus_1.applyPermissionMode)(client, mcpResult.allowed, {
137
- object: mcpTool.serverName,
138
- relation: "use",
139
- subjectId: (0, argus_1.subjectLabel)(subject),
140
- ...("subjectSet" in subject ? { subjectSet: subject.subjectSet } : {}),
141
- spanAttributes: { toolName: toolId, mcpServer: mcpTool.serverName, mcpTool: mcpTool.toolName },
142
- });
143
- applyPermissionAskDecision(output, decision.kind);
144
- return;
145
- }
146
- const outcome = await (0, argus_1.gateToolCall)(client, {
147
- harness: "opencode",
148
- toolName: toolId,
149
- check: { namespace: resolveNamespace(), object: toolId, relation: "use", ...subject },
150
- spanAttributes: { toolName: toolId },
151
- });
152
- if (outcome.kind === "interactive" || outcome.kind === "audit_only") {
153
- // Ory is disabled (audit-only kill switch) or this is a
154
- // user-interaction primitive: leave output.status as "ask" (default)
155
- // so the harness's native prompt reaches the user.
156
- return;
157
- }
158
- const decision = outcome;
159
- if (decision.kind === "fail_open") {
160
- const code = decision.error.code;
161
- if (code === "network_error" || code === "rate_limited") {
162
- client.logger.warn("permission.fallback", {
163
- toolID: toolId,
164
- code,
165
- });
166
- // Leave output.status as "ask" (default)
167
- return;
168
- }
169
- output.status = "ask";
170
- return;
171
- }
172
- applyPermissionAskDecision(output, decision.kind);
173
- }
174
- catch (err) {
175
- if (isOryError(err) && (err.code === "network_error" || err.code === "rate_limited")) {
176
- client.logger.warn("permission.fallback", {
177
- toolID: toolId,
178
- code: err.code,
179
- });
180
- // Leave output.status as "ask" (default)
181
- return;
182
- }
183
- // For other errors, also fall back to ask
184
- output.status = "ask";
111
+ else {
112
+ await run();
185
113
  }
186
114
  };
187
115
  }
188
- // ─── tool.execute.before ────────────────────────────────────────────
189
- function createToolBeforeHandler(client) {
190
- return async (input, output) => {
191
- client.logger.info("lifecycle.tool_execute_before", {
192
- tool: input.tool,
193
- sessionID: input.sessionID,
194
- callID: input.callID,
116
+ function createToolBeforeHandler(client, ensureSessionStarted) {
117
+ return (input, output) => (0, argus_1.withHookContext)(client, { sessionId: input.sessionID }, async () => {
118
+ client.logger.info("lifecycle.tool_execute_before", input);
119
+ await ensureSessionStarted(input.sessionID);
120
+ const result = await (0, argus_1.gate)(client, {
121
+ harness: HARNESS,
122
+ toolName: input.tool,
123
+ toolArgs: output.args,
124
+ subjectFallback: `session:${input.sessionID}`,
125
+ canBlock: false,
126
+ mcpTool: (0, argus_1.parseMcpToolGeneric)(input.tool) ?? undefined,
195
127
  });
196
- // Set trace context for this tool invocation
197
- client.tracer.setContext({
198
- traceId: (0, argus_1.deriveTraceId)(input.sessionID),
199
- sessionId: input.sessionID,
200
- });
201
- const inputSummary = (0, argus_1.summarizeToolInput)(input.tool, output.args);
202
- // In audit-only mode, log the invocation but skip permission checks
203
- if ((0, argus_1.resolveConfig)().auditOnly) {
204
- client.tracer.record("tool.invoke", "ok", {
205
- attributes: { toolName: input.tool, ...inputSummary },
206
- });
207
- return;
208
- }
209
- const subject = (0, argus_1.resolveUserSubject)(client, input.sessionID ? `session:${input.sessionID}` : undefined);
210
- const subjectId = (0, argus_1.subjectLabel)(subject);
211
- const mcpTool = (0, argus_1.parseMcpToolGeneric)(input.tool);
212
- try {
213
- if (mcpTool) {
214
- const mcpResult = await (0, argus_1.checkMcpPermission)(client, mcpTool, {
215
- subject,
216
- spanAttributes: { toolName: input.tool },
217
- });
218
- const mcpAttrs = {
219
- toolName: input.tool,
220
- mcpServer: mcpTool.serverName,
221
- mcpTool: mcpTool.toolName,
222
- ...inputSummary,
223
- };
224
- const decision = (0, argus_1.applyPermissionMode)(client, mcpResult.allowed, {
225
- object: mcpTool.serverName,
226
- relation: "use",
227
- subjectId,
228
- ...("subjectSet" in subject ? { subjectSet: subject.subjectSet } : {}),
229
- spanAttributes: mcpAttrs,
230
- });
231
- const decisionAttrs = decision.spanAttributes;
232
- if (decision.kind === "allow") {
233
- client.tracer.record("tool.invoke", "ok", {
234
- attributes: { ...mcpAttrs, ...decisionAttrs },
235
- });
236
- return;
237
- }
238
- if (decision.kind === "observe") {
239
- client.tracer.record("tool.block", "denied", {
240
- attributes: { ...mcpAttrs, ...decisionAttrs, allowed: false, ...(0, argus_1.alertAttributes)(false) },
241
- });
242
- client.tracer.record("tool.invoke", "ok", {
243
- attributes: { ...mcpAttrs, ...decisionAttrs, allowed: false, observed: true },
244
- });
245
- return;
246
- }
247
- client.tracer.record("tool.block", "denied", {
248
- attributes: { ...mcpAttrs, ...decisionAttrs, allowed: false, ...(0, argus_1.alertAttributes)(false) },
249
- });
250
- const message = (0, argus_1.formatDenialMessage)({
251
- tool: input.tool,
252
- subjectId,
253
- mcp: mcpTool,
254
- });
255
- client.logger.warn("tool.denied", {
256
- tool: input.tool,
257
- subjectId,
258
- message,
259
- });
260
- // OpenCode has no native block signal in tool.execute.before;
261
- // throwing aborts execution (documented .env-protection pattern)
262
- // and surfaces error.message back to the agent.
263
- throw new argus_1.OryDenialError({
264
- tool: input.tool,
265
- subjectId,
266
- mcp: mcpTool,
267
- });
268
- }
269
- const namespace = resolveNamespace();
270
- const outcome = await (0, argus_1.gateToolCall)(client, {
271
- harness: "opencode",
272
- toolName: input.tool,
273
- check: { namespace, object: input.tool, relation: "use", ...subject },
274
- spanAttributes: { toolName: input.tool },
275
- });
276
- // Interactive tools (operator-extensible via ORY_INTERACTIVE_TOOLS):
277
- // user.interaction span is already recorded; pass through with no
278
- // throw, no tool.invoke, no tool.block.
279
- if (outcome.kind === "interactive") {
280
- return;
281
- }
282
- const decision = outcome;
283
- if (decision.kind === "fail_open") {
284
- handlePermissionError(decision.error, input.tool, client);
285
- return;
286
- }
287
- const attrs = { toolName: input.tool, ...inputSummary };
288
- const decisionAttrs = decision.spanAttributes;
289
- if (decision.kind === "allow") {
290
- client.tracer.record("tool.invoke", "ok", {
291
- attributes: { ...attrs, ...decisionAttrs, allowed: true },
292
- });
293
- return;
294
- }
295
- if (decision.kind === "observe") {
296
- client.tracer.record("tool.block", "denied", {
297
- attributes: { ...attrs, ...decisionAttrs, allowed: false, ...(0, argus_1.alertAttributes)(false) },
298
- });
299
- client.tracer.record("tool.invoke", "ok", {
300
- attributes: { ...attrs, ...decisionAttrs, allowed: false, observed: true },
301
- });
302
- return;
303
- }
304
- client.tracer.record("tool.block", "denied", {
305
- attributes: { ...attrs, ...decisionAttrs, allowed: false, ...(0, argus_1.alertAttributes)(false) },
306
- });
307
- const message = (0, argus_1.formatDenialMessage)({
308
- tool: input.tool,
309
- subjectId,
310
- namespace,
311
- });
312
- client.logger.warn("tool.denied", {
313
- tool: input.tool,
314
- subjectId,
315
- message,
316
- });
317
- throw new argus_1.OryDenialError({
128
+ if (result.kind === "deny") {
129
+ throw new argus_1.OryDenialError(result.denialContext ?? {
318
130
  tool: input.tool,
319
- subjectId,
320
- namespace,
131
+ subjectId: result.subject,
132
+ namespace: result.namespace,
321
133
  });
322
134
  }
323
- catch (err) {
324
- if (err instanceof argus_1.OryDenialError)
325
- throw err;
326
- handlePermissionError(err, input.tool, client);
327
- }
328
- };
135
+ });
329
136
  }
330
- // ─── tool.execute.after ─────────────────────────────────────────────
331
137
  function createToolAfterHandler(client) {
332
- return async (input, output) => {
333
- client.logger.info("lifecycle.tool_execute_after", {
334
- tool: input.tool,
335
- sessionID: input.sessionID,
336
- callID: input.callID,
337
- });
338
- client.tracer.setContext({
339
- traceId: (0, argus_1.deriveTraceId)(input.sessionID),
340
- sessionId: input.sessionID,
138
+ return (input, output) => (0, argus_1.withHookContext)(client, { sessionId: input.sessionID }, async () => {
139
+ client.logger.info("lifecycle.tool_execute_after", input);
140
+ (0, argus_1.complete)(client, {
141
+ toolName: input.tool,
142
+ output: { title: output.title, output: output.output },
341
143
  });
342
- // OpenCode's canonical tool.execute.after input has no `args`,
343
- // so input summary is unavailable here; output summary only.
344
- client.tracer.record("tool.complete", "ok", {
345
- attributes: {
346
- toolName: input.tool,
347
- ...(0, argus_1.summarizeToolOutput)(input.tool, {
348
- title: output.title,
349
- output: output.output,
350
- }),
351
- },
352
- });
353
- };
354
- }
355
- // ─── Helpers ────────────────────────────────────────────────────────
356
- function resolveNamespace() {
357
- return process.env.ORY_PERMISSION_NAMESPACE ?? "AgentTools";
144
+ });
358
145
  }
359
- /**
360
- * Map an Ory permission decision onto OpenCode's `permission.ask` output.
361
- *
362
- * Only explicit outcomes answer the prompt: `deny` → `"deny"`,
363
- * `allow` → `"allow"`. An `observe`-mode deny leaves `output.status`
364
- * untouched (OpenCode's default is `"ask"`), so the harness's native
365
- * user prompt still appears — observe mode must never escalate a
366
- * policy-denied tool into an auto-approval. The observe deny is already
367
- * recorded as a `permission.observe_deny` audit span by the gate.
368
- */
369
146
  function applyPermissionAskDecision(output, kind) {
370
- if (kind === "deny") {
147
+ if (kind === "deny")
371
148
  output.status = "deny";
372
- }
373
- else if (kind === "allow") {
149
+ else if (kind === "allow")
374
150
  output.status = "allow";
375
- }
376
- // kind === "observe": leave the harness's native prompt in place.
377
- }
378
- function isOryError(err) {
379
- return (typeof err === "object" &&
380
- err !== null &&
381
- "code" in err &&
382
- "message" in err);
383
- }
384
- function handlePermissionError(err, tool, client) {
385
- if (isOryError(err)) {
386
- if (err.code === "network_error" || err.code === "rate_limited") {
387
- client.logger.warn("permission.fallback", {
388
- tool,
389
- code: err.code,
390
- message: "Failing open",
391
- });
392
- return;
393
- }
394
- client.logger.error("permission.check.error", {
395
- tool,
396
- code: err.code,
397
- message: err.message,
398
- });
399
- }
400
- else {
401
- client.logger.error("permission.check.error", {
402
- tool,
403
- error: err instanceof Error ? err.message : String(err),
404
- });
405
- }
406
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ory/opencode",
3
- "version": "0.14.0",
3
+ "version": "1.0.1",
4
4
  "description": "Ory plugin for OpenCode: scaffolding skills, a local Ory instance, and authentication, authorization, and audit for every tool call",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://ory.com",
@@ -33,8 +33,7 @@
33
33
  "agent-security",
34
34
  "guardrails",
35
35
  "llm",
36
- "tracing",
37
- "distributed-tracing",
36
+ "activity-auditing",
38
37
  "observability",
39
38
  "kratos",
40
39
  "keto",
@@ -45,6 +44,10 @@
45
44
  "registry": "https://registry.npmjs.org/",
46
45
  "provenance": true
47
46
  },
47
+ "reova": {
48
+ "enabled": true,
49
+ "endpoint": "https://telemetry.reo.dev/data"
50
+ },
48
51
  "main": "dist/index.js",
49
52
  "types": "dist/index.d.ts",
50
53
  "exports": {
@@ -59,12 +62,11 @@
59
62
  },
60
63
  "files": [
61
64
  "dist",
62
- "!dist/dev",
63
65
  "!dist/**/*.tsbuildinfo"
64
66
  ],
65
67
  "dependencies": {
66
- "reo-census": "^1.2.8",
67
- "@ory/argus": "0.14.0"
68
+ "reova": "^0.7.0",
69
+ "@ory/argus": "1.0.1"
68
70
  },
69
71
  "engines": {
70
72
  "node": ">=22"
@@ -74,7 +76,6 @@
74
76
  "clean": "rm -rf dist *.tsbuildinfo",
75
77
  "test": "vitest run",
76
78
  "test:watch": "vitest",
77
- "typecheck": "tsc --noEmit",
78
- "dev": "node dist/dev/launcher.js"
79
+ "typecheck": "tsc --noEmit"
79
80
  }
80
81
  }