@ory/argus 0.7.0 → 0.7.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.
@@ -0,0 +1,441 @@
1
+ ---
2
+ name: ory-build-agent
3
+ description: Build your own AI agent that authenticates the user, authorizes every tool call against Ory Permissions, and emits trace spans — by dropping `@ory/argus` directly into the Claude Agent SDK, OpenAI Agents SDK, Mastra, Vercel AI SDK, PydanticAI / LangGraph, or as an external service called by Salesforce Agentforce. Use when the user wants to wire Ory into a custom agent they own — phrases like "add Ory to my own agent", "build a custom agent with Ory auth", "wrap my Claude Agent SDK tools with Ory permissions", "OpenAI Agents SDK with Ory", "Mastra agent with Ory permissions", "Agentforce action with Ory", "use `@ory/argus` directly". For wiring Ory into an existing agent harness (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode) use the corresponding `@ory/<harness>` plugin instead.
4
+ ---
5
+
6
+ # Build your own agent with `@ory/argus`
7
+
8
+ You are helping the user wire Ory Identities, Permissions, and tracing into
9
+ **an agent they are building themselves**. They are not extending Claude Code,
10
+ Codex, or one of the other harness plugins — they own the agent loop and
11
+ choose where to intercept tool calls.
12
+
13
+ The integration is the same three moves regardless of SDK:
14
+
15
+ 1. **User gate at start.** `ensureUserAuthenticated(client, …)` — the human at
16
+ the keyboard becomes the subject of every permission check.
17
+ 2. **Agent gate at start.** `ensureAgentIdentity(client, …)` — the process
18
+ making outbound Ory API calls gets its own credential (OAuth2 Dynamic
19
+ Client Registration by default, persisted across sessions).
20
+ 3. **Permission check on every tool call.** Wrap the SDK's tool dispatch with
21
+ `checkAndDecide(client, …)` and branch on `decision.kind`. Record a
22
+ `tool.complete` span after the tool returns.
23
+
24
+ `@ory/argus` ships every helper and handles fail-open semantics (network
25
+ errors, rate limits, unconfigured project → allow). The SDKs differ only in
26
+ **where** that wrapper goes.
27
+
28
+ > **Precondition:** the user has an Ory project (or will spin up the local
29
+ > stack — see {{REF_LOCAL_DEV}}) and has the env vars from
30
+ > {{REF_AUTH_SETUP}} figured out. Do not fabricate credentials or scaffold a
31
+ > project on their behalf.
32
+
33
+ ## Step 1 — Pick the SDK and confirm the agent shape
34
+
35
+ Ask the user which SDK they're using and what the agent looks like. Below
36
+ are the SDKs this skill carries explicit recipes for. Others (LangChain,
37
+ LlamaIndex, generic OpenAI tool-calling loops) follow the same pattern —
38
+ wrap each tool dispatch with the gate from Step 4.
39
+
40
+ | SDK | Language | Where Ory hooks in |
41
+ |---|---|---|
42
+ | Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`) | TypeScript / Python | `canUseTool` callback on `query({...})` |
43
+ | OpenAI Agents SDK (`@openai/agents`) | TypeScript | Per-tool `execute` wrapper or `RunHooks.onToolStart` |
44
+ | Salesforce Agentforce (Agent Builder) | declarative + Apex | External Service / side-car — see "Salesforce" below |
45
+ | Mastra (`@mastra/core`) | TypeScript | Higher-order wrapper around each tool's `execute` |
46
+ | Mistral AI (`@mistralai/mistralai` / `mistralai`) | TypeScript / Python | Per-tool wrapper inside the chat-completion loop or the Agents API tool registry |
47
+ | PydanticAI (`pydantic-ai`) | Python | `@agent.tool` decorator stack |
48
+ | Vercel AI SDK (`ai`) | TypeScript | Higher-order wrapper at `streamText({ tools })` |
49
+ | LangGraph (`langgraph`) | Python / TypeScript | `ToolNode` wrapper or `RunnableLambda` per tool |
50
+
51
+ Also establish:
52
+
53
+ - **Interactive vs headless.** Desktop / terminal agents can run PKCE login.
54
+ Headless services (CI, daemons, Salesforce side-cars) must pre-supply
55
+ `ORY_USER_SESSION_TOKEN` or `ORY_USER_OAUTH2_TOKEN`.
56
+ - **Which tools to gate.** Usually all of them. Some SDKs have built-in
57
+ "safe" steps (an LLM-only reasoning step, a model-provided memory tool)
58
+ that don't need a permission check.
59
+ - **Language.** `@ory/argus` is JavaScript-first. Python frameworks call out
60
+ to a tiny Node side-car or use the official `ory-client` Python SDK
61
+ directly; the snippet below shows the side-car shape.
62
+
63
+ ## Step 2 — Install `@ory/argus`
64
+
65
+ ```bash
66
+ npm install @ory/argus
67
+ ```
68
+
69
+ That's the only Ory dependency you need. The Ory SDK clients
70
+ (`@ory/client`) and the OAuth2/PKCE plumbing are re-exported and ready to
71
+ use.
72
+
73
+ ## Step 3 — Construct the client and run both gates
74
+
75
+ Put this at the top of the agent's bootstrap, before the agent loop starts
76
+ processing the first message:
77
+
78
+ ```ts
79
+ import {
80
+ OryAgentClient,
81
+ ensureUserAuthenticated,
82
+ ensureAgentIdentity,
83
+ resolveConfig,
84
+ } from "@ory/argus";
85
+
86
+ const client = OryAgentClient.fromEnv("my-agent");
87
+ const { projectUrl } = resolveConfig();
88
+
89
+ // 1. User gate — interactive PKCE when ORY_USER_LOGIN=1, no-op otherwise.
90
+ const userDecision = await ensureUserAuthenticated(client, {
91
+ binName: "my-agent",
92
+ harness: "my-agent",
93
+ allowBlock: true, // flip to false if your agent can't refuse to start
94
+ });
95
+ if (userDecision.proceed === false) {
96
+ console.error(`Ory user login: ${userDecision.reason}`);
97
+ process.exit(2);
98
+ }
99
+
100
+ // 2. Agent gate — never blocks; resolves machine credentials (DCR by default).
101
+ await ensureAgentIdentity(client, { projectUrl, harness: "my-agent" });
102
+
103
+ // 3. (optional) write the user→agent delegation tuple for audit.
104
+ if (client.userPrincipal.subject && client.agentPrincipal.subject) {
105
+ await client
106
+ .createRelationship({
107
+ namespace: process.env.ORY_PERMISSION_NAMESPACE ?? "AgentTools",
108
+ object: `agent:${client.agentPrincipal.subject}`,
109
+ relation: "delegate",
110
+ subjectId: `user:${client.userPrincipal.subject}`,
111
+ })
112
+ .catch(() => undefined); // audit-only — swallow failures
113
+ }
114
+ ```
115
+
116
+ Set `allowBlock: false` when the agent runs in-process inside a parent
117
+ application and can't refuse to start. The gate still runs in advisory mode
118
+ — it refreshes tokens, prompts on TTY, emits the `user.auth` span — but
119
+ always returns `proceed: true`.
120
+
121
+ ## Step 4 — The shared gate body
122
+
123
+ This snippet is reused verbatim from every SDK-specific section in Step 5.
124
+ Put it next to where you construct the client.
125
+
126
+ ```ts
127
+ import {
128
+ checkAndDecide,
129
+ resolveUserSubject,
130
+ subjectLabel,
131
+ } from "@ory/argus";
132
+
133
+ async function gateTool(toolName: string, sessionId: string) {
134
+ const subject = resolveUserSubject(client, `session:${sessionId}`);
135
+ const decision = await checkAndDecide(
136
+ client,
137
+ {
138
+ namespace: process.env.ORY_PERMISSION_NAMESPACE ?? "AgentTools",
139
+ object: toolName,
140
+ relation: "use",
141
+ ...subject,
142
+ },
143
+ { spanAttributes: { toolName } }
144
+ );
145
+
146
+ switch (decision.kind) {
147
+ case "allow":
148
+ case "observe":
149
+ case "fail_open":
150
+ return { allow: true as const };
151
+ case "deny":
152
+ return {
153
+ allow: false as const,
154
+ message: `Ory denied ${toolName} for ${subjectLabel(subject)}.`,
155
+ };
156
+ }
157
+ }
158
+ ```
159
+
160
+ After the tool finishes (whichever SDK), record a completion span:
161
+
162
+ ```ts
163
+ client.tracer.record("tool.complete", "ok", {
164
+ attributes: { toolName, durationMs },
165
+ });
166
+ ```
167
+
168
+ `checkAndDecide` already records `permission.check`, `permission.observe_deny`
169
+ (on observe), and `tool.block` (on deny). You only own `tool.complete`.
170
+
171
+ ## Step 5 — SDK-specific wiring
172
+
173
+ ### Claude Agent SDK
174
+
175
+ The Claude Agent SDK exposes a `canUseTool` callback that fires before every
176
+ tool invocation. Drop the gate there:
177
+
178
+ ```ts
179
+ import { query } from "@anthropic-ai/claude-agent-sdk";
180
+
181
+ const stream = query({
182
+ prompt,
183
+ options: {
184
+ canUseTool: async (toolName, input, { signal }) => {
185
+ const sessionId = currentSessionId(); // your own correlation id
186
+ const gate = await gateTool(toolName, sessionId);
187
+ if (!gate.allow) {
188
+ return { behavior: "deny", message: gate.message };
189
+ }
190
+ return { behavior: "allow", updatedInput: input };
191
+ },
192
+ },
193
+ });
194
+
195
+ for await (const msg of stream) { /* standard handling */ }
196
+ ```
197
+
198
+ `canUseTool` only fires for tools the SDK controls. If the agent also
199
+ registers MCP servers, wrap each MCP tool handler the same way — see the
200
+ `@ory/argus` `parseClaudeCodeMcpTool` / `checkMcpPermission` helpers for an
201
+ MCP-flavored version of the gate.
202
+
203
+ ### OpenAI Agents SDK
204
+
205
+ The OpenAI Agents SDK supports per-run lifecycle hooks via `RunHooks` plus
206
+ per-tool `execute` overrides. Pick whichever you prefer:
207
+
208
+ ```ts
209
+ import { Agent, Runner, tool } from "@openai/agents";
210
+
211
+ const search = tool({
212
+ name: "search",
213
+ description: "...",
214
+ parameters: SearchParams,
215
+ execute: async (input, ctx) => {
216
+ const gate = await gateTool("search", ctx.runId);
217
+ if (!gate.allow) return { error: gate.message };
218
+ return realSearch(input);
219
+ },
220
+ });
221
+
222
+ const agent = new Agent({ name: "my-agent", tools: [search], model: "gpt-4.1" });
223
+ await new Runner().run(agent, prompt);
224
+ ```
225
+
226
+ For agent-wide enforcement without per-tool wrapping, register a
227
+ `on_tool_start` hook on the runner and throw on deny — the SDK surfaces the
228
+ throw to the model as a tool error.
229
+
230
+ ### Salesforce Agentforce (Agent Builder)
231
+
232
+ Agentforce is a declarative agent inside the Salesforce platform — you
233
+ **cannot** embed `@ory/argus` in the agent process. Instead:
234
+
235
+ 1. Stand up a small Node.js service that hosts the gated tools and exposes
236
+ each as an HTTP endpoint. Inside that service, run Steps 3 + 4 exactly as
237
+ above, then call `gateTool(...)` at the top of every handler.
238
+ 2. Register the service in Salesforce as a **Named Credential** plus an
239
+ **External Service** (OpenAPI 3 spec). Each operation becomes an
240
+ Agentforce **Action**.
241
+ 3. Define an Agentforce **Topic** whose actions call the External Service
242
+ operations. The gate runs inside your Node service on every call; denies
243
+ come back as tool errors the agent surfaces to the user.
244
+
245
+ Pre-supply `ORY_USER_SESSION_TOKEN` (or `ORY_USER_OAUTH2_TOKEN`) to the
246
+ side-car from a session the user established out-of-band — for example, a
247
+ PKCE flow at sign-on into the Experience Cloud site that fronts the agent.
248
+ A headless side-car cannot run PKCE on its own.
249
+
250
+ ### Mastra Agent Framework
251
+
252
+ Mastra runs tools via `tool.execute({ context, runtimeContext })`. Wrap the
253
+ agent's tool registry at construction:
254
+
255
+ ```ts
256
+ import { Agent } from "@mastra/core";
257
+
258
+ function gated<T extends { id: string; execute: (a: any) => Promise<any> }>(t: T): T {
259
+ const original = t.execute.bind(t);
260
+ return {
261
+ ...t,
262
+ execute: async (args: any) => {
263
+ const sessionId = args.runtimeContext?.sessionId ?? "unknown";
264
+ const gate = await gateTool(t.id, sessionId);
265
+ if (!gate.allow) return { error: gate.message };
266
+ return original(args);
267
+ },
268
+ };
269
+ }
270
+
271
+ const agent = new Agent({
272
+ name: "my-agent",
273
+ model,
274
+ tools: Object.fromEntries(
275
+ Object.entries(tools).map(([id, t]) => [id, gated(t)])
276
+ ),
277
+ });
278
+ ```
279
+
280
+ ### Mistral AI
281
+
282
+ Mistral's SDK (`@mistralai/mistralai` for TypeScript, `mistralai` for
283
+ Python) exposes two surfaces. Both gate the same way.
284
+
285
+ **Chat-completion loop with tools.** You own the loop: call `chat.complete`,
286
+ inspect `tool_calls` on the response, run each tool, send the results back.
287
+ Gate inside the tool dispatcher:
288
+
289
+ ```ts
290
+ import { Mistral } from "@mistralai/mistralai";
291
+
292
+ const client_ai = new Mistral({ apiKey: process.env.MISTRAL_API_KEY });
293
+
294
+ async function step(messages: any[], sessionId: string) {
295
+ const res = await client_ai.chat.complete({
296
+ model: "mistral-large-latest",
297
+ messages,
298
+ tools, // [{ type: "function", function: { name, parameters, description } }]
299
+ toolChoice: "auto",
300
+ });
301
+ const choice = res.choices[0];
302
+ if (!choice.message.toolCalls?.length) return choice.message;
303
+
304
+ const toolResults = await Promise.all(
305
+ choice.message.toolCalls.map(async (call) => {
306
+ const gate = await gateTool(call.function.name, sessionId);
307
+ if (!gate.allow) {
308
+ return { toolCallId: call.id, name: call.function.name, content: gate.message };
309
+ }
310
+ const output = await runTool(call.function.name, JSON.parse(call.function.arguments));
311
+ return { toolCallId: call.id, name: call.function.name, content: JSON.stringify(output) };
312
+ })
313
+ );
314
+ return step(
315
+ [...messages, choice.message, ...toolResults.map((r) => ({ role: "tool", ...r }))],
316
+ sessionId
317
+ );
318
+ }
319
+ ```
320
+
321
+ **Mistral Agents API (la Plateforme).** When you use the managed Agents API
322
+ (`agents.create({ tools })`, `conversations.start`), Mistral runs the tool
323
+ loop server-side and only calls back to your code for tools it can't
324
+ execute itself — i.e. your "function" tools delivered via webhook. Wrap
325
+ each webhook handler with `gateTool(...)` and return either the result or
326
+ the gate's denial message. The server-side connectors (`web_search`,
327
+ `code_interpreter`, MCP connectors) execute inside Mistral and bypass your
328
+ gate — model them explicitly in your Ory namespace if you want to control
329
+ them, e.g. by writing per-connector tuples and skipping the agent
330
+ definition for users without the relation.
331
+
332
+ The same Python recipe applies via the `mistralai` package — replace the
333
+ `client_ai.chat.complete(...)` call with `client_ai.chat.complete(...)` from
334
+ the Python SDK and use the side-car pattern for `gateTool` (see
335
+ PydanticAI).
336
+
337
+ ### PydanticAI (Python — covers the "Pi"-style framework slot)
338
+
339
+ Pure-Python agents don't link `@ory/argus` directly. The two supported
340
+ patterns:
341
+
342
+ - **Side-car HTTP service.** Run a small Node process that exposes
343
+ `POST /gate` (calls `gateTool`) and `POST /trace` (calls
344
+ `client.tracer.record(...)`). Your Python agent calls these from inside
345
+ each `@agent.tool`.
346
+ - **Direct Ory APIs.** Use the official `ory-client` Python SDK to call
347
+ `PermissionApi.check_permission()` and post audit spans to your own
348
+ collector. You lose the fail-open / observe-mode helpers; re-implement
349
+ them in Python.
350
+
351
+ Side-car pattern:
352
+
353
+ ```python
354
+ from pydantic_ai import Agent, RunContext
355
+ import httpx
356
+
357
+ agent = Agent("openai:gpt-4.1", deps_type=AgentDeps)
358
+
359
+ @agent.tool
360
+ async def search(ctx: RunContext[AgentDeps], q: str) -> str:
361
+ r = await httpx.post("http://localhost:5310/gate",
362
+ json={"tool": "search", "session": ctx.deps.session_id})
363
+ if not r.json()["allow"]:
364
+ return r.json()["message"]
365
+ return real_search(q)
366
+ ```
367
+
368
+ The same Python pattern applies verbatim to **LangGraph** (wrap each
369
+ `ToolNode` in a `RunnableLambda` that calls `/gate` first) and to
370
+ **LlamaIndex** agents (override `FunctionTool.acall`).
371
+
372
+ ### Vercel AI SDK
373
+
374
+ The `ai` package's `tool()` helper produces descriptors consumed by
375
+ `streamText` / `generateText`. Wrap them at construction:
376
+
377
+ ```ts
378
+ import { streamText, tool } from "ai";
379
+
380
+ function gated(name: string, def: ReturnType<typeof tool>) {
381
+ return tool({
382
+ ...def,
383
+ execute: async (input, ctx) => {
384
+ const gate = await gateTool(name, ctx.toolCallId);
385
+ if (!gate.allow) return { error: gate.message };
386
+ return def.execute(input, ctx);
387
+ },
388
+ });
389
+ }
390
+
391
+ await streamText({
392
+ model,
393
+ tools: { search: gated("search", searchTool), write: gated("write", writeTool) },
394
+ prompt,
395
+ });
396
+ ```
397
+
398
+ ### LangGraph (TypeScript)
399
+
400
+ LangGraph's `ToolNode` runs a registered tool array. Wrap each tool the same
401
+ way Vercel AI SDK does, then pass the wrapped array to `new ToolNode(...)`.
402
+ The `gateTool` body does not change.
403
+
404
+ ## Step 6 — Test against the local Ory stack
405
+
406
+ Before pointing at production, run the gate against the local stack so the
407
+ PKCE flow, permission tuples, and trace spans are all visible:
408
+
409
+ 1. {{REF_LOCAL_UP}} — brings up Kratos / Keto / Hydra on `localhost:4000`
410
+ and seeds a demo user. The banner prints the email + password.
411
+ 2. Export the env vars the launcher writes (`ORY_PROJECT_URL`,
412
+ `ORY_USER_LOGIN=1`, `ORY_OAUTH2_CLIENT_ID`, optional
413
+ `ORY_AGENT_TRACE_FILE` for an NDJSON span log).
414
+ 3. Start your agent. Confirm the browser opens for PKCE login.
415
+ 4. Invoke a gated tool and `tail -f $ORY_AGENT_TRACE_FILE | jq .` — you
416
+ should see `user.auth` → `agent.auth` → `permission.check` →
417
+ `tool.complete` for every call.
418
+ 5. Promote to enforce once the `use` tuples are seeded: either
419
+ `ORY_PERMISSION_MODE=enforce` for one launch, or use one of the harness
420
+ CLIs to flip it persistently (e.g. `npx -y -p @ory/claude-code ory-claude
421
+ permissions enforce` — same shared config file).
422
+ 6. {{REF_LOCAL_DOWN}} when done. Volumes persist, so the seeded user
423
+ survives across runs.
424
+
425
+ For full env-var coverage (including the user/agent split,
426
+ `ORY_USER_SUBJECT_NAMESPACE`, agent DCR knobs), see {{REF_AUTH_SETUP}}.
427
+
428
+ ## What this skill does NOT do
429
+
430
+ - It does not generate the agent. The user owns the agent loop, tool
431
+ catalog, and deployment shape. This skill only drops `@ory/argus` into
432
+ whatever they already have.
433
+ - It does not write the permission tuples. Seed them with
434
+ `... permissions bootstrap` (run via any of the harness CLIs — same shared
435
+ config file) or by calling `client.createRelationship` directly.
436
+ - It does not adapt one of the existing harness plugins (`@ory/claude-code`,
437
+ `@ory/codex`, `@ory/gemini-cli`, `@ory/openclaw`, `@ory/opencode`). Those
438
+ are for users running those harnesses — not building a custom agent.
439
+ - It does not invent SDK-internal types. The snippets are the canonical
440
+ shape, but SDK hook signatures drift release-to-release — verify against
441
+ the user's pinned version before pasting.
package/dist/skills.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The skill playbooks (auth-setup, login-flow, social-login, local-dev,
5
5
  * permissions-onboarding, contribute-integration, build-integration,
6
- * e2b-sandbox) and the local-stack commands (local-up, local-down) live once,
6
+ * e2b-sandbox, build-agent) and the local-stack commands (local-up, local-down) live once,
7
7
  * as token-bearing templates under `packages/core/assets/`. Every harness plugin renders them
8
8
  * through {@link renderOrySkills} / {@link renderOryCommands}, substituting the
9
9
  * harness's CLI binary, package name, and the way it references sibling skills
package/dist/skills.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * The skill playbooks (auth-setup, login-flow, social-login, local-dev,
6
6
  * permissions-onboarding, contribute-integration, build-integration,
7
- * e2b-sandbox) and the local-stack commands (local-up, local-down) live once,
7
+ * e2b-sandbox, build-agent) and the local-stack commands (local-up, local-down) live once,
8
8
  * as token-bearing templates under `packages/core/assets/`. Every harness plugin renders them
9
9
  * through {@link renderOrySkills} / {@link renderOryCommands}, substituting the
10
10
  * harness's CLI binary, package name, and the way it references sibling skills
@@ -86,6 +86,11 @@ const SKILL_SOURCES = [
86
86
  name: "ory-e2b-sandbox",
87
87
  file: "skills/ory-e2b-sandbox/SKILL.md",
88
88
  },
89
+ {
90
+ id: "build-agent",
91
+ name: "ory-build-agent",
92
+ file: "skills/ory-build-agent/SKILL.md",
93
+ },
89
94
  ];
90
95
  const COMMAND_SOURCES = [
91
96
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ory/argus",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Ory Argus: the core API for building authentication, authorization, and audit into AI agent harness plugins, extensions, and custom integrations",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://ory.com",