@ory/claude-code 1.9.2 → 1.10.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/README.md CHANGED
@@ -12,9 +12,11 @@ One command installs two independent halves:
12
12
  ## What you'll need
13
13
 
14
14
  - [Claude Code](https://docs.anthropic.com/en/docs/claude-code), installed and signed in
15
+ <!-- BEGIN GENERATED requirements -->
15
16
  - Node.js **22 or newer**
16
17
  - [Docker](https://docs.docker.com/get-docker/) — only if you want to run Ory locally
17
18
  - macOS or Linux (Windows works via WSL2)
19
+ <!-- END GENERATED requirements -->
18
20
 
19
21
  ## Get started
20
22
 
@@ -105,6 +107,7 @@ Then a denied tool is actually blocked and Claude shows why.
105
107
 
106
108
  ## Connect to Ory Agent Security
107
109
 
110
+ <!-- BEGIN GENERATED connect -->
108
111
  Copy the connection details from the [Ory Console](https://console.ory.sh) under **Agent Security**:
109
112
 
110
113
  | Value | Flag | Environment variable |
@@ -128,6 +131,7 @@ npx -y -p @ory/claude-code ory-claude configure \
128
131
  Sign-in runs at the start of every session and never blocks — a declined, skipped, or timed-out login simply leaves that session without a user identity.
129
132
 
130
133
  Settings are saved to `~/.config/ory-agent-plugins/config.json` and shared across all your Ory agent plugins; environment variables win when both are set. Running headless with an OAuth2 access token already? Set `ORY_USER_OAUTH2_TOKEN` and the browser step is skipped entirely.
134
+ <!-- END GENERATED connect -->
131
135
 
132
136
  ## Commands
133
137
 
@@ -145,14 +149,16 @@ All prefixed with `npx -y -p @ory/claude-code`.
145
149
 
146
150
  ## Troubleshooting
147
151
 
148
- - **`local-up` fails** — make sure Docker is running and ports `4000`, `4100`, and `4455` are free.
152
+ - **`local-up` fails** — <!-- BEGIN GENERATED local-stack-ports -->make sure Docker is running and ports `4000`, `4100`, and `4455` are free.<!-- END GENERATED local-stack-ports -->
149
153
  - **Browser sign-in loops** (after connecting) — confirm the reserved login client has all four documented loopback redirect URIs; `agent unregister` only clears legacy agent OAuth state.
150
154
  - **Running an older CLI than expected** — `npx -p @ory/claude-code` (no version pin) reuses a previously-downloaded copy instead of re-resolving to the latest. The install banner prints the running version; confirm it with `npx -y -p @ory/claude-code ory-claude version`. To force the current release, clear the cache and reinstall: `rm -rf ~/.npm/_npx` then `npx -y -p @ory/claude-code ory-claude install`. Pinning an exact version (`@ory/claude-code@<version>`) also bypasses the cached copy.
151
155
  - **Hooks stuck on an old version** — run `/plugin marketplace update ory` inside Claude Code, or re-run the installer.
152
156
 
153
157
  ## Learn more
154
158
 
159
+ <!-- BEGIN GENERATED ory-links -->
155
160
  - [Ory documentation](https://www.ory.com/docs/) · [Ory Console](https://console.ory.sh) · [Ory Elements](https://github.com/ory/elements)
161
+ <!-- END GENERATED ory-links -->
156
162
  - [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code)
157
163
 
158
164
  ## License
package/dist/cli/main.js CHANGED
@@ -47,100 +47,25 @@ Object.defineProperty(exports, "__esModule", { value: true });
47
47
  const path = __importStar(require("node:path"));
48
48
  const fs = __importStar(require("node:fs"));
49
49
  const argus_1 = require("@ory/argus");
50
+ const cli_1 = require("@ory/argus/cli");
51
+ const install_1 = require("@ory/argus/install");
50
52
  const setup_js_1 = require("./setup.js");
51
53
  const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
52
54
  /** Resolved per call: see the note on `persistentDir()` in ./setup.ts. */
53
55
  function persistentDir() {
54
56
  return (0, argus_1.getHarnessDataDir)("claude-code");
55
57
  }
56
- function main() {
57
- const [command, ...args] = process.argv.slice(2);
58
- // `--help` after a matched subcommand: print usage, do nothing else.
59
- // Without this the switch below ignores the flag and `install --help`
60
- // performs a real install (#221).
61
- if ((0, argus_1.shouldPrintHelp)(command, args))
62
- return help();
63
- switch (command) {
64
- case "install":
65
- if (args.includes("--print")) {
66
- (0, setup_js_1.printInstallPlan)(args);
67
- break;
68
- }
69
- (0, argus_1.beginDeferNextSteps)();
70
- (0, setup_js_1.install)(args);
71
- (0, argus_1.runPostInstall)("ory-claude", "claude-code", args).then(() => process.exit(0), (err) => {
72
- console.error(err.message ?? err);
73
- process.exit(1);
74
- });
75
- break;
76
- case "uninstall":
77
- (0, setup_js_1.uninstall)(args);
78
- (0, argus_1.clearCredentialsForUninstall)({
79
- harness: "claude-code",
80
- purge: args.includes("--purge"),
81
- }).then(() => process.exit(0), (err) => {
82
- console.error(err.message ?? err);
83
- process.exit(1);
84
- });
85
- break;
86
- case "configure":
87
- (0, argus_1.runConfigureCommand)("ory-claude", args);
88
- break;
89
- case "agent":
90
- (0, argus_1.runAgentCommand)("ory-claude", "claude-code", args).then((code) => process.exit(code), (err) => {
91
- console.error(err.message ?? err);
92
- process.exit(1);
93
- });
94
- break;
95
- case "permissions":
96
- (0, argus_1.runPermissionsCommand)("ory-claude", "claude-code", args).then((code) => process.exit(code), (err) => {
97
- console.error(err.message ?? err);
98
- process.exit(1);
99
- });
100
- break;
101
- case "status":
102
- status().then(() => process.exit(0), (err) => {
103
- console.error(err.message ?? err);
104
- process.exit(1);
105
- });
106
- break;
107
- case "watch":
108
- (0, argus_1.runWatchCommand)("ory-claude", "claude-code", args).then((code) => process.exit(code), (err) => { console.error(err.message ?? err); process.exit(1); });
109
- break;
110
- case "local":
111
- (0, argus_1.runLocalCommand)("ory-claude", args).catch((err) => {
112
- console.error(err.message ?? err);
113
- process.exit(1);
114
- });
115
- break;
116
- case "version":
117
- case "--version":
118
- case "-v":
119
- process.exit((0, argus_1.runVersionCommand)("ory-claude", args, { packageRoot: PACKAGE_ROOT }));
120
- break;
121
- case "help":
122
- case "--help":
123
- case "-h":
124
- case undefined:
125
- help();
126
- break;
127
- default:
128
- console.error(`Unknown command: ${command}`);
129
- help();
130
- process.exit(1);
131
- }
132
- }
133
58
  async function status() {
134
- await (0, argus_1.runStatusCommand)("ory-claude", "claude-code", {
59
+ await (0, cli_1.runStatusCommand)("ory-claude", "claude-code", {
135
60
  title: "Claude Code",
136
61
  printPluginSection: () => {
137
62
  // A wiped data dir leaves the harness pointing at a marketplace root
138
63
  // that no longer exists, and its plugin surface then fails with an error
139
64
  // that never mentions Ory. Say so here, with the repair (#206).
140
- const dangling = (0, argus_1.findDanglingRegistration)("claude-code");
65
+ const dangling = (0, install_1.findDanglingRegistration)("claude-code");
141
66
  if (dangling) {
142
67
  console.log("");
143
- console.log(` ! ${(0, argus_1.describeDanglingRegistration)(dangling)}`);
68
+ console.log(` ! ${(0, install_1.describeDanglingRegistration)(dangling)}`);
144
69
  console.log("");
145
70
  }
146
71
  console.log("Hooks & plugin:");
@@ -148,7 +73,7 @@ async function status() {
148
73
  // install time, so the wired code and this CLI's own version can
149
74
  // legitimately differ — report the runtime, not our own version. Run
150
75
  // `claude plugin list` for the `claude` CLI's view of registrations.
151
- console.log(` Runtime: ${(0, argus_1.describeRuntimeHealth)((0, argus_1.checkRuntimeHealth)("claude-code"))}`);
76
+ console.log(` Runtime: ${(0, install_1.describeRuntimeHealth)((0, install_1.checkRuntimeHealth)("claude-code"))}`);
152
77
  // The data dir doubles as the default activity/debug-log home, so it exists
153
78
  // after any session regardless of install state — its mere presence does
154
79
  // NOT mean a plugin was assembled here. `isPluginAssembled` keys off the
@@ -190,4 +115,13 @@ Examples:
190
115
  npx -y -p @ory/claude-code ory-claude uninstall # Remove plugin
191
116
  `);
192
117
  }
193
- main();
118
+ (0, cli_1.runHarnessCli)({
119
+ bin: "ory-claude",
120
+ harness: "claude-code",
121
+ packageRoot: PACKAGE_ROOT,
122
+ help,
123
+ install: setup_js_1.install,
124
+ printInstallPlan: setup_js_1.printInstallPlan,
125
+ uninstall: setup_js_1.uninstall,
126
+ status: () => status(),
127
+ });
@@ -17,7 +17,7 @@
17
17
  * cannot. The public `ory/claude-plugins` mirror stays a valid install route
18
18
  * on its own terms (it ships a bootstrap keyed off `${CLAUDE_PLUGIN_ROOT}`).
19
19
  */
20
- import { type PreparedRuntime } from "@ory/argus";
20
+ import { type PreparedRuntime } from "@ory/argus/install";
21
21
  /**
22
22
  * Assemble the Claude Code plugin directory in the persistent location and
23
23
  * return its path (the marketplace root).
package/dist/cli/setup.js CHANGED
@@ -61,6 +61,8 @@ const fs = __importStar(require("node:fs"));
61
61
  const path = __importStar(require("node:path"));
62
62
  const claude_command_js_1 = require("./claude-command.js");
63
63
  const argus_1 = require("@ory/argus");
64
+ const cli_1 = require("@ory/argus/cli");
65
+ const install_1 = require("@ory/argus/install");
64
66
  const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
65
67
  const PACKAGE_NAME = "@ory/claude-code";
66
68
  const HARNESS = "claude-code";
@@ -154,13 +156,13 @@ function assemblePluginDir(runtime) {
154
156
  // Skills — materialized fresh from the shared core templates.
155
157
  const skillsDir = path.join(persistentDir(), "skills");
156
158
  fs.rmSync(skillsDir, { recursive: true, force: true });
157
- (0, argus_1.writeSkillTree)(skillsDir, (0, argus_1.renderOrySkills)("claude-code", RENDER_OPTS));
159
+ (0, install_1.writeSkillTree)(skillsDir, (0, install_1.renderOrySkills)("claude-code", RENDER_OPTS));
158
160
  // Commands — plain markdown command files (Claude Code's `/ory:<name>`).
159
161
  const commandsDir = path.join(persistentDir(), "commands");
160
162
  fs.rmSync(commandsDir, { recursive: true, force: true });
161
163
  fs.mkdirSync(commandsDir, { recursive: true });
162
- for (const cmd of (0, argus_1.renderOryCommands)("claude-code", RENDER_OPTS)) {
163
- fs.writeFileSync(path.join(commandsDir, `${cmd.slug}.md`), (0, argus_1.commandToPlainMarkdown)(cmd));
164
+ for (const cmd of (0, install_1.renderOryCommands)("claude-code", RENDER_OPTS)) {
165
+ fs.writeFileSync(path.join(commandsDir, `${cmd.slug}.md`), (0, install_1.commandToPlainMarkdown)(cmd));
164
166
  }
165
167
  // MCP server — points at the shim, so the server starts from the resolved
166
168
  // runtime. Omitted entirely when this runtime ships no MCP server, rather
@@ -182,7 +184,7 @@ function assemblePluginDir(runtime) {
182
184
  // window the interactive login needs, so leaving it undeclared let the hook
183
185
  // be killed mid-sign-in (#220). Both values come from core so they cannot
184
186
  // drift from the login timeout they have to outlast.
185
- const command = (0, argus_1.requireHookCommand)(runtime);
187
+ const command = (0, install_1.requireHookCommand)(runtime);
186
188
  const entry = (timeout) => [
187
189
  { matcher: "", hooks: [{ type: "command", command, timeout }] },
188
190
  ];
@@ -249,7 +251,7 @@ function install(args) {
249
251
  process.exit(1);
250
252
  }
251
253
  installAssembled(scope, args);
252
- (0, argus_1.printNextSteps)("Claude Code", "npx -y -p @ory/claude-code ory-claude uninstall", {
254
+ (0, install_1.printNextSteps)("Claude Code", "npx -y -p @ory/claude-code ory-claude uninstall", {
253
255
  binName: "ory-claude",
254
256
  harness: HARNESS,
255
257
  });
@@ -268,7 +270,7 @@ function printInstallPlan(args) {
268
270
  * tree with the `claude` CLI as a local-file marketplace.
269
271
  */
270
272
  function installAssembled(scope, args) {
271
- const runtime = (0, argus_1.wireRuntime)({
273
+ const runtime = (0, install_1.wireRuntime)({
272
274
  harness: HARNESS,
273
275
  packageName: PACKAGE_NAME,
274
276
  packageRoot: PACKAGE_ROOT,
@@ -334,7 +336,7 @@ function installAssembled(scope, args) {
334
336
  // Claude Code stores this marketplace path and re-reads it, so a data-dir
335
337
  // wipe that skips uninstall would leave it pointing at nothing. Record how to
336
338
  // undo the registration so the destructive paths can replay it (#206).
337
- (0, argus_1.recordExternalRegistration)({
339
+ (0, install_1.recordExternalRegistration)({
338
340
  harness: "claude-code",
339
341
  tool: "claude",
340
342
  description: `Claude Code marketplace "${marketplaceName}"`,
@@ -385,12 +387,12 @@ function uninstall(args) {
385
387
  console.log(" Marketplace removed.");
386
388
  }
387
389
  // Deregistered above, so nothing is left to undo.
388
- (0, argus_1.clearExternalRegistration)("claude-code");
390
+ (0, install_1.clearExternalRegistration)("claude-code");
389
391
  cleanPersistentDir();
390
392
  // Drop the shim and GC the runtime store, unless another harness still
391
393
  // points at it.
392
- (0, argus_1.removeRuntimeWiring)(HARNESS);
393
- for (const pruned of (0, argus_1.pruneRuntimeStores)()) {
394
+ (0, install_1.removeRuntimeWiring)(HARNESS);
395
+ for (const pruned of (0, install_1.pruneRuntimeStores)()) {
394
396
  console.log(` Removed runtime: ${pruned}`);
395
397
  }
396
398
  }
@@ -422,7 +424,7 @@ with the 'claude' CLI as a local-file marketplace.
422
424
  uninstall(args);
423
425
  // Direct `-setup --uninstall` path: also clear stored Ory credentials.
424
426
  // (The `ory-claude uninstall` command handles this itself.)
425
- (0, argus_1.clearCredentialsForUninstall)({
427
+ (0, cli_1.clearCredentialsForUninstall)({
426
428
  harness: "claude-code",
427
429
  purge: process.argv.includes("--purge"),
428
430
  }).then(() => process.exit(0), (err) => {
@@ -1,9 +1,21 @@
1
1
  import { ensureAgentIdentity, ensureSubAgentIdentity, ensureUserAuthenticated } from "@ory/argus";
2
- import type { OryAgentClient } from "@ory/argus";
3
2
  import type { ClaudeCodeHookInput, ClaudeCodeHookOutput } from "./types.js";
4
3
  export interface HandleHookEventDeps {
5
4
  userLogin?: typeof ensureUserAuthenticated;
6
5
  agentGate?: typeof ensureAgentIdentity;
7
6
  subAgentGate?: typeof ensureSubAgentIdentity;
8
7
  }
9
- export declare function handleHookEvent(input: ClaudeCodeHookInput, client: OryAgentClient, deps?: HandleHookEventDeps): Promise<ClaudeCodeHookOutput>;
8
+ export declare const handleHookEvent: import("@ory/argus").HookDispatcher<ClaudeCodeHookInput, ClaudeCodeHookOutput, HandleHookEventDeps>;
9
+ /**
10
+ * Tell the user and the model when the Ory sign-in failed. The session still
11
+ * starts, but without a user identity, enforce mode can deny every tool call.
12
+ * Without this notice nothing would explain those denials. Skipped logins
13
+ * (headless runs, pre-provisioned agents) are expected and stay silent.
14
+ */
15
+ export declare function signInFailureNotice(userMode: string, userReason: string): ClaudeCodeHookOutput;
16
+ /**
17
+ * Deny a PreToolUse call. Claude Code shows `permissionDecisionReason` to the
18
+ * model, which is how the agent learns why the tool was stopped. This replaces
19
+ * the deprecated top-level `decision: "block"` / `reason` fields.
20
+ */
21
+ export declare function preToolUseDeny(reason: string): ClaudeCodeHookOutput;
package/dist/handlers.js CHANGED
@@ -1,60 +1,51 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.handleHookEvent = handleHookEvent;
3
+ exports.handleHookEvent = void 0;
4
+ exports.signInFailureNotice = signInFailureNotice;
5
+ exports.preToolUseDeny = preToolUseDeny;
4
6
  const argus_1 = require("@ory/argus");
5
7
  const mcp_servers_js_1 = require("./mcp-servers.js");
6
8
  const HARNESS = "claude-code";
7
- function handleHookEvent(input, client, deps = {}) {
8
- const event = input.hook_event_name;
9
- client.logger.debug("hook.received", {
10
- event,
9
+ /**
10
+ * A tool event fired inside a sub-agent carries that sub-agent's id and type.
11
+ * Restore its runtime credential so the gate authenticates as the sub-agent,
12
+ * not as its parent. Never enrolls: `SubagentStart` owns registration.
13
+ */
14
+ async function resolveActingSubagent(input, client, deps, event) {
15
+ const activeType = input.agent_type ?? input.subagent_type;
16
+ if (event === "SubagentStart" || !input.agent_id || !activeType || !(0, argus_1.isSecurityConnected)()) {
17
+ return { runtimeCredential: undefined };
18
+ }
19
+ const identity = await (deps.subAgentGate ?? argus_1.ensureSubAgentIdentity)(client, {
20
+ harness: HARNESS,
11
21
  sessionId: input.session_id,
12
- toolName: input.tool_name,
13
- });
14
- return (0, argus_1.withHookContext)(client, { sessionId: input.session_id }, async () => {
15
- const activeType = input.agent_type ?? input.subagent_type;
16
- let runtimeCredential;
17
- if (event !== "SubagentStart" && input.agent_id && activeType && (0, argus_1.isSecurityConnected)()) {
18
- const identity = await (deps.subAgentGate ?? argus_1.ensureSubAgentIdentity)(client, {
19
- harness: HARNESS,
20
- sessionId: input.session_id,
21
- subAgentType: activeType,
22
- perSpawnId: input.agent_id,
23
- allowEnrollment: false,
24
- emitActivity: false,
25
- });
26
- if (identity.runtimeCredential) {
27
- runtimeCredential = identity.runtimeCredential;
28
- }
29
- }
30
- return (0, argus_1.withHookContext)(client, { runtimeCredential }, async () => {
31
- switch (event) {
32
- case "SessionStart":
33
- return handleSessionStart(input, client, deps);
34
- case "PreToolUse":
35
- return handlePreToolUse(input, client, deps);
36
- case "PostToolUse":
37
- return handlePostToolUse(input, client);
38
- case "PostToolUseFailure":
39
- return handlePostToolUseFailure(input, client);
40
- case "PermissionRequest":
41
- return handlePermissionRequest(input, client);
42
- case "UserPromptSubmit":
43
- return handleUserPromptSubmit(input, client);
44
- case "SubagentStart":
45
- return handleSubagentStart(input, client, deps);
46
- case "SubagentStop":
47
- return handleSubagentStop(input, client);
48
- case "SessionEnd":
49
- return handleSessionEnd(input, client);
50
- default:
51
- client.logger.debug("hook.passthrough", { event });
52
- client.logger.activity("hook.passthrough", "skipped", { attributes: { event } });
53
- return { continue: true };
54
- }
55
- });
22
+ subAgentType: activeType,
23
+ perSpawnId: input.agent_id,
24
+ allowEnrollment: false,
25
+ emitActivity: false,
56
26
  });
27
+ return { runtimeCredential: identity.runtimeCredential };
57
28
  }
29
+ const routes = {
30
+ SessionStart: handleSessionStart,
31
+ PreToolUse: handlePreToolUse,
32
+ PostToolUse: handlePostToolUse,
33
+ PostToolUseFailure: handlePostToolUseFailure,
34
+ PermissionRequest: handlePermissionRequest,
35
+ UserPromptSubmit: handleUserPromptSubmit,
36
+ SubagentStart: handleSubagentStart,
37
+ SubagentStop: handleSubagentStop,
38
+ SessionEnd: handleSessionEnd,
39
+ };
40
+ exports.handleHookEvent = (0, argus_1.createHookDispatcher)({
41
+ harness: HARNESS,
42
+ event: (input) => input.hook_event_name,
43
+ sessionId: (input) => input.session_id,
44
+ log: (input) => ({ sessionId: input.session_id, toolName: input.tool_name }),
45
+ context: resolveActingSubagent,
46
+ routes,
47
+ passthrough: () => ({ continue: true }),
48
+ });
58
49
  async function handleSessionStart(input, client, deps) {
59
50
  client.logger.info("lifecycle.session_start", {
60
51
  sessionId: input.session_id,
@@ -62,7 +53,7 @@ async function handleSessionStart(input, client, deps) {
62
53
  source: input.source,
63
54
  cwd: input.cwd,
64
55
  });
65
- await (0, argus_1.sessionStart)(client, {
56
+ const result = await (0, argus_1.sessionStart)(client, {
66
57
  harness: HARNESS,
67
58
  sessionId: input.session_id,
68
59
  binName: "ory-claude",
@@ -70,7 +61,27 @@ async function handleSessionStart(input, client, deps) {
70
61
  agentGate: deps.agentGate,
71
62
  activityAttributes: { model: input.model, source: input.source },
72
63
  });
73
- return {};
64
+ return signInFailureNotice(result.userMode, result.userReason);
65
+ }
66
+ /**
67
+ * Tell the user and the model when the Ory sign-in failed. The session still
68
+ * starts, but without a user identity, enforce mode can deny every tool call.
69
+ * Without this notice nothing would explain those denials. Skipped logins
70
+ * (headless runs, pre-provisioned agents) are expected and stay silent.
71
+ */
72
+ function signInFailureNotice(userMode, userReason) {
73
+ if (userMode !== "declined" && userMode !== "error")
74
+ return {};
75
+ const reason = userReason.replace(/[.\s]+$/, "");
76
+ return {
77
+ systemMessage: `Ory sign-in did not complete: ${reason}. Tool calls may be denied this session.`,
78
+ hookSpecificOutput: {
79
+ hookEventName: "SessionStart",
80
+ additionalContext: `Ory Agent Security sign-in did not complete for this session (${reason}). ` +
81
+ "If Ory denies a tool call, tell the user that starting a new session and " +
82
+ "signing in may resolve it. Do not retry the denied call.",
83
+ },
84
+ };
74
85
  }
75
86
  async function handlePreToolUse(input, client, deps) {
76
87
  const toolName = input.tool_name ?? "unknown";
@@ -87,9 +98,21 @@ async function handlePreToolUse(input, client, deps) {
87
98
  mcpTool: identifyMcpCall(toolName, input) ?? undefined,
88
99
  principals: actingSubagentPrincipals(input, client),
89
100
  });
90
- return result.blocked
91
- ? { decision: "block", reason: result.denialMessage }
92
- : {};
101
+ return result.blocked ? preToolUseDeny(result.denialMessage ?? "Ory permission denied") : {};
102
+ }
103
+ /**
104
+ * Deny a PreToolUse call. Claude Code shows `permissionDecisionReason` to the
105
+ * model, which is how the agent learns why the tool was stopped. This replaces
106
+ * the deprecated top-level `decision: "block"` / `reason` fields.
107
+ */
108
+ function preToolUseDeny(reason) {
109
+ return {
110
+ hookSpecificOutput: {
111
+ hookEventName: "PreToolUse",
112
+ permissionDecision: "deny",
113
+ permissionDecisionReason: reason,
114
+ },
115
+ };
93
116
  }
94
117
  function identifyMcpCall(toolName, input) {
95
118
  return ((0, mcp_servers_js_1.identifyClaudeCodeMcpTool)(toolName, input.cwd) ??
@@ -155,36 +178,48 @@ async function handlePermissionRequest(input, client) {
155
178
  mcpTool: mcpTool ?? undefined,
156
179
  });
157
180
  if (result.kind === "not_connected") {
158
- return permissionRequestFallback("Ory Agent Security not connected — deferring to user");
181
+ return permissionRequestFallback(client, "Ory Agent Security not connected — deferring to user");
159
182
  }
160
183
  if (result.kind === "interactive") {
161
- return permissionRequestFallback("Interactive tool — deferring to user");
184
+ return permissionRequestFallback(client, "Interactive tool — deferring to user");
162
185
  }
163
186
  if (result.kind === "fail_open") {
164
- return permissionRequestFallback(permissionFailureReason(result.decision));
187
+ return permissionRequestFallback(client, permissionFailureReason(result.decision));
165
188
  }
166
189
  if (result.kind === "deny") {
167
- return permissionRequestDecision("deny", result.denialMessage ?? "Ory permission denied");
190
+ return permissionRequestDeny(result.denialMessage ?? "Ory permission denied");
191
+ }
192
+ if (result.kind === "observe") {
193
+ // Policy denied the call but observe mode lets it through. Auto-approving
194
+ // here would also skip the user's own approval prompt, so defer to it.
195
+ return permissionRequestFallback(client, "Ory observe mode — denied by policy, deferring to user");
168
196
  }
169
- return permissionRequestDecision("allow", result.kind === "observe"
170
- ? "Ory observe mode — denied by policy but allowed through"
171
- : mcpTool
172
- ? "Ory MCP server permission granted"
173
- : "Ory permission granted");
197
+ client.logger.debug("permission_request.allow", {
198
+ reason: mcpTool ? "Ory MCP server permission granted" : "Ory permission granted",
199
+ });
200
+ return {
201
+ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow" } },
202
+ };
174
203
  }
175
- function permissionRequestDecision(behavior, reason) {
204
+ /**
205
+ * Deny a PermissionRequest. Claude Code reads the reason from
206
+ * `decision.message` and shows it to the model.
207
+ */
208
+ function permissionRequestDeny(message) {
176
209
  return {
177
210
  hookSpecificOutput: {
178
211
  hookEventName: "PermissionRequest",
179
- decision: { behavior },
180
- message: reason,
212
+ decision: { behavior: "deny", message },
181
213
  },
182
214
  };
183
215
  }
184
- function permissionRequestFallback(reason) {
185
- return {
186
- hookSpecificOutput: { hookEventName: "PermissionRequest", message: reason },
187
- };
216
+ /**
217
+ * Return no decision so Claude Code shows its normal permission prompt. Claude
218
+ * Code has no field for a fallback reason, so the reason goes to the debug log.
219
+ */
220
+ function permissionRequestFallback(client, reason) {
221
+ client.logger.debug("permission_request.defer", { reason });
222
+ return {};
188
223
  }
189
224
  function permissionFailureReason(decision) {
190
225
  if (decision.kind !== "fail_open")
package/dist/hook.d.ts CHANGED
@@ -4,6 +4,6 @@
4
4
  * This script is invoked by Claude Code for each hook event.
5
5
  * Input: JSON on stdin
6
6
  * Output: JSON on stdout
7
- * Exit code 2 = block the action
7
+ * Exit code 2 = PreToolUse deny (the reason is in the JSON and on stderr)
8
8
  */
9
9
  export {};
package/dist/hook.js CHANGED
@@ -5,102 +5,32 @@
5
5
  * This script is invoked by Claude Code for each hook event.
6
6
  * Input: JSON on stdin
7
7
  * Output: JSON on stdout
8
- * Exit code 2 = block the action
8
+ * Exit code 2 = PreToolUse deny (the reason is in the JSON and on stderr)
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
11
  const argus_1 = require("@ory/argus");
12
12
  const handlers_js_1 = require("./handlers.js");
13
- const EVENT_FLUSH_TIMEOUT_MS = 750;
14
- let client;
15
- async function flushEvents() {
16
- try {
17
- await client?.flushEvents(EVENT_FLUSH_TIMEOUT_MS);
18
- }
19
- catch {
20
- // The hook decision must not depend on activity delivery.
21
- }
22
- }
23
- /**
24
- * Read all of stdin as a string.
25
- *
26
- * Uses event listeners instead of `for await` so that we don't depend on the
27
- * parent process closing stdin (sending EOF). If no EOF arrives, we resolve
28
- * after a short idle gap once data has been received.
29
- */
30
- function readStdin() {
31
- return new Promise((resolve, reject) => {
32
- const chunks = [];
33
- let resolved = false;
34
- let idleTimer;
35
- function cleanup() {
36
- if (idleTimer)
37
- clearTimeout(idleTimer);
38
- process.stdin.off("data", onData);
39
- process.stdin.off("end", done);
40
- process.stdin.off("error", onError);
41
- process.stdin.pause();
42
- }
43
- function done() {
44
- if (!resolved) {
45
- resolved = true;
46
- cleanup();
47
- resolve(Buffer.concat(chunks).toString("utf-8"));
48
- }
49
- }
50
- function onData(chunk) {
51
- chunks.push(chunk);
52
- // Reset idle timer on every chunk. Once data stops arriving for 100 ms
53
- // we assume the full payload has been delivered.
54
- if (idleTimer)
55
- clearTimeout(idleTimer);
56
- idleTimer = setTimeout(done, 100);
13
+ void (0, argus_1.runHookProcess)({
14
+ harness: "claude-code",
15
+ handle: handlers_js_1.handleHookEvent,
16
+ respond: (output, input) => {
17
+ if (input.hook_event_name === "SessionEnd") {
18
+ // Claude is already shutting down and may cancel hooks that remain alive.
19
+ // Activity enqueueing synchronously persists the event to the durable outbox,
20
+ // so a later hook can deliver it without delaying session termination.
21
+ return { exitNow: true };
57
22
  }
58
- function onError(err) {
59
- if (!resolved) {
60
- resolved = true;
61
- cleanup();
62
- reject(err);
63
- }
23
+ const specific = output.hookSpecificOutput;
24
+ if (specific?.permissionDecision === "deny") {
25
+ // Exit 2 blocks the tool even if Claude Code rejects the JSON. Claude Code
26
+ // gives the model the JSON deny reason when it parses, and stderr otherwise,
27
+ // so the same reason goes to both.
28
+ return {
29
+ stdout: JSON.stringify(output),
30
+ stderr: `${specific.permissionDecisionReason ?? ""}\n`,
31
+ exitCode: 2,
32
+ };
64
33
  }
65
- process.stdin.on("data", onData);
66
- process.stdin.on("end", done);
67
- process.stdin.on("error", onError);
68
- });
69
- }
70
- async function main() {
71
- client = argus_1.OryAgentClient.fromEnv("claude-code");
72
- const raw = await readStdin();
73
- let input;
74
- try {
75
- input = JSON.parse(raw);
76
- }
77
- catch {
78
- client.logger.error("hook.stdin.parse_failed", { raw: raw.slice(0, 200) });
79
- process.exitCode = 0; // Don't block on parse errors
80
- return;
81
- }
82
- const output = await (0, handlers_js_1.handleHookEvent)(input, client);
83
- if (input.hook_event_name === "SessionEnd") {
84
- // Claude is already shutting down and may cancel hooks that remain alive.
85
- // Activity enqueueing synchronously persists the event to the durable outbox,
86
- // so a later hook can deliver it without delaying session termination.
87
- process.exit(0);
88
- }
89
- if (output.decision === "block") {
90
- process.stdout.write(JSON.stringify(output));
91
- await flushEvents();
92
- process.exitCode = 2;
93
- return;
94
- }
95
- if (output.hookSpecificOutput ||
96
- output.updatedInput ||
97
- output.decision === "approve") {
98
- process.stdout.write(JSON.stringify(output));
99
- }
100
- await flushEvents();
101
- }
102
- main().catch(async (err) => {
103
- process.stderr.write(`[ory-agent] fatal: ${err}\n`);
104
- await flushEvents();
105
- process.exitCode = 0; // Fail open
34
+ return Object.keys(output).length > 0 ? { stdout: JSON.stringify(output) } : {};
35
+ },
106
36
  });
package/dist/settings.js CHANGED
@@ -37,7 +37,7 @@ exports.generateSettings = generateSettings;
37
37
  exports.generateInstalledSettings = generateInstalledSettings;
38
38
  exports.generateDevSettings = generateDevSettings;
39
39
  const path = __importStar(require("node:path"));
40
- const argus_1 = require("@ory/argus");
40
+ const install_1 = require("@ory/argus/install");
41
41
  /**
42
42
  * Generate the .claude/settings.local.json that configures Ory hooks.
43
43
  */
@@ -45,10 +45,10 @@ function generateSettings(hookScriptPath) {
45
45
  const cmd = `node ${hookScriptPath}`;
46
46
  return {
47
47
  hooks: {
48
- SessionStart: (0, argus_1.matcherHookEntry)(cmd),
49
- PreToolUse: (0, argus_1.matcherHookEntry)(cmd),
50
- PostToolUse: (0, argus_1.matcherHookEntry)(cmd),
51
- PermissionRequest: (0, argus_1.matcherHookEntry)(cmd),
48
+ SessionStart: (0, install_1.matcherHookEntry)(cmd),
49
+ PreToolUse: (0, install_1.matcherHookEntry)(cmd),
50
+ PostToolUse: (0, install_1.matcherHookEntry)(cmd),
51
+ PermissionRequest: (0, install_1.matcherHookEntry)(cmd),
52
52
  },
53
53
  };
54
54
  }
package/dist/types.d.ts CHANGED
@@ -30,29 +30,27 @@ export interface ClaudeCodeHookInput {
30
30
  }
31
31
  /**
32
32
  * Claude Code hook output written to stdout.
33
+ * See: https://code.claude.com/docs/en/hooks
33
34
  *
34
- * Top-level `decision: "block"` is honored on blocking-capable events
35
- * (PreToolUse, UserPromptSubmit, PostToolUse, Stop, …). PermissionRequest
36
- * uses the structured `hookSpecificOutput.decision.{behavior}` shape below
37
- * instead — the top-level `decision` is ignored there.
35
+ * `systemMessage` is shown to the user and never to the model. Decisions and
36
+ * model-facing text live in the per-event `hookSpecificOutput`.
38
37
  */
39
38
  export interface ClaudeCodeHookOutput {
40
39
  continue?: boolean;
41
- decision?: "block";
42
- reason?: string;
43
40
  stopReason?: string;
44
41
  suppressOutput?: boolean;
45
42
  systemMessage?: string;
46
- updatedInput?: Record<string, unknown>;
47
43
  hookSpecificOutput?: ClaudeCodeHookSpecificOutput;
48
44
  }
49
45
  /**
50
46
  * Per-event structured output. Field shape depends on `hookEventName`:
51
- * - PreToolUse: `permissionDecision` + `permissionDecisionReason`,
52
- * plus optional `permissionRules` to persist a rule for future calls.
53
- * - PermissionRequest: nested `decision: { behavior, updatedInput?, permissionRules? }`.
54
- * Omit `decision` entirely to fall through to Claude's normal user prompt.
55
- * - SessionStart / UserPromptSubmit: `additionalContext` for context injection.
47
+ * - PreToolUse: `permissionDecision` + `permissionDecisionReason`. The
48
+ * reason is shown to the model for `deny` and the user for `ask`.
49
+ * - PermissionRequest: nested `decision: { behavior, message?, interrupt? }`.
50
+ * `message` is the deny reason shown to the model. Omit `decision` to fall
51
+ * through to Claude Code's normal user prompt.
52
+ * - SessionStart: `additionalContext` is added to the model's context and is
53
+ * not shown to the user.
56
54
  */
57
55
  export interface ClaudeCodeHookSpecificOutput {
58
56
  hookEventName?: string;
@@ -61,12 +59,10 @@ export interface ClaudeCodeHookSpecificOutput {
61
59
  decision?: {
62
60
  behavior: "allow" | "deny";
63
61
  updatedInput?: Record<string, unknown>;
64
- permissionRules?: string[];
62
+ /** Deny only: the reason shown to the model. */
63
+ message?: string;
64
+ /** Deny only: stop Claude after denying. */
65
+ interrupt?: boolean;
65
66
  };
66
- permissionRules?: string[];
67
67
  additionalContext?: string;
68
- /** Set when the plugin needs the agent to prompt the user for config. */
69
- oryConfigRequired?: boolean;
70
- message?: string;
71
- [key: string]: unknown;
72
68
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ory/claude-code",
3
- "version": "1.9.2",
3
+ "version": "1.10.0",
4
4
  "description": "Ory plugin for Claude Code: 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://github.com/ory/claude-plugins/tree/master/plugins/ory-agent-plugin",
@@ -78,7 +78,7 @@
78
78
  ],
79
79
  "dependencies": {
80
80
  "reova": "^0.7.0",
81
- "@ory/argus": "1.9.2"
81
+ "@ory/argus": "1.10.0"
82
82
  },
83
83
  "engines": {
84
84
  "node": ">=22"