@ory/argus 0.9.1 → 0.11.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/assets/skills/auth-setup/SKILL.md +3 -1
- package/assets/skills/login-flow/SKILL.md +118 -4
- package/dist/adapters.d.ts +104 -0
- package/dist/adapters.js +216 -0
- package/dist/agent-auth.js +33 -0
- package/dist/config.js +35 -3
- package/dist/contract-suite.d.ts +87 -0
- package/dist/contract-suite.js +239 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +9 -1
- package/dist/lifecycle.js +130 -0
- package/dist/local/configs.d.ts +2 -2
- package/dist/local/configs.js +10 -1
- package/dist/local/manager.d.ts +6 -4
- package/dist/local/manager.js +91 -16
- package/dist/permissions.d.ts +36 -8
- package/dist/permissions.js +62 -7
- package/dist/subject.d.ts +13 -1
- package/dist/subject.js +29 -2
- package/dist/testing.d.ts +214 -0
- package/dist/testing.js +372 -0
- package/dist/tool-catalog.d.ts +7 -0
- package/dist/tool-catalog.js +73 -0
- package/dist/user-login.js +7 -2
- package/package.json +1 -1
package/dist/local/manager.js
CHANGED
|
@@ -57,6 +57,7 @@ exports.localEnv = localEnv;
|
|
|
57
57
|
exports.localConfigure = localConfigure;
|
|
58
58
|
const fs = __importStar(require("node:fs"));
|
|
59
59
|
const path = __importStar(require("node:path"));
|
|
60
|
+
const crypto = __importStar(require("node:crypto"));
|
|
60
61
|
const node_child_process_1 = require("node:child_process");
|
|
61
62
|
const configs_js_1 = require("./configs.js");
|
|
62
63
|
const health_js_1 = require("./health.js");
|
|
@@ -196,28 +197,86 @@ function dumpCapturedOutput(label, result) {
|
|
|
196
197
|
process.stderr.write(`── end ${label} output ──\n\n`);
|
|
197
198
|
}
|
|
198
199
|
// ─── Write config files ────────────────────────────────────────────
|
|
200
|
+
/**
|
|
201
|
+
* Render the full set of local-stack config files as `{ relPath, content }`
|
|
202
|
+
* pairs. Pure function of the code, `projectRoot`, and `consoleCfg` — no I/O.
|
|
203
|
+
* `writeConfigs` writes these and `configFingerprint` hashes them, so the
|
|
204
|
+
* on-disk stack and its change-detection signal never drift.
|
|
205
|
+
*/
|
|
206
|
+
function renderConfigs(projectRoot, consoleCfg) {
|
|
207
|
+
return [
|
|
208
|
+
{
|
|
209
|
+
relPath: "docker-compose.yml",
|
|
210
|
+
content: (0, configs_js_1.dockerComposeYaml)(projectRoot, consoleCfg),
|
|
211
|
+
},
|
|
212
|
+
{ relPath: "kratos/kratos.yml", content: (0, configs_js_1.kratosConfigYaml)() },
|
|
213
|
+
{
|
|
214
|
+
relPath: "kratos/identity-schema.json",
|
|
215
|
+
content: (0, configs_js_1.kratosIdentitySchema)(),
|
|
216
|
+
},
|
|
217
|
+
{ relPath: "keto/keto.yml", content: (0, configs_js_1.ketoConfigYaml)() },
|
|
218
|
+
{ relPath: "hydra/hydra.yml", content: (0, configs_js_1.hydraConfigYaml)() },
|
|
219
|
+
// nginx config is always emitted — the gateway is no longer optional.
|
|
220
|
+
{ relPath: "nginx/nginx.conf", content: (0, configs_js_1.nginxConf)() },
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Stable content hash of the rendered config set. Used to decide whether an
|
|
225
|
+
* already-running stack still matches the config the current code would
|
|
226
|
+
* generate; a change (e.g. the login-UI port, a service env var) invalidates
|
|
227
|
+
* the fast-path short-circuit so the stack is reconciled instead of reused.
|
|
228
|
+
*/
|
|
229
|
+
function configFingerprint(projectRoot, consoleCfg) {
|
|
230
|
+
const hash = crypto.createHash("sha256");
|
|
231
|
+
for (const { relPath, content } of renderConfigs(projectRoot, consoleCfg)) {
|
|
232
|
+
hash.update(relPath);
|
|
233
|
+
hash.update("\0");
|
|
234
|
+
hash.update(content);
|
|
235
|
+
hash.update("\0");
|
|
236
|
+
}
|
|
237
|
+
return hash.digest("hex");
|
|
238
|
+
}
|
|
239
|
+
const CONFIG_FINGERPRINT_FILE = ".config-fingerprint";
|
|
240
|
+
/** Read the fingerprint recorded after the last successful bring-up. */
|
|
241
|
+
function readConfigFingerprint(localDir) {
|
|
242
|
+
try {
|
|
243
|
+
return fs
|
|
244
|
+
.readFileSync(path.join(localDir, CONFIG_FINGERPRINT_FILE), "utf8")
|
|
245
|
+
.trim();
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** Record the fingerprint of the config the running stack was brought up with. */
|
|
252
|
+
function writeConfigFingerprint(localDir, fingerprint) {
|
|
253
|
+
try {
|
|
254
|
+
fs.writeFileSync(path.join(localDir, CONFIG_FINGERPRINT_FILE), fingerprint + "\n");
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// Best-effort: a missing fingerprint only costs a reconcile next launch.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
199
260
|
function writeConfigs(localDir, projectRoot, consoleCfg) {
|
|
200
261
|
ensureDir(localDir);
|
|
201
262
|
ensureDir(path.join(localDir, "kratos"));
|
|
202
263
|
ensureDir(path.join(localDir, "keto"));
|
|
203
264
|
ensureDir(path.join(localDir, "hydra"));
|
|
204
265
|
ensureDir(path.join(localDir, "nginx"));
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
fs.writeFileSync(path.join(localDir, "keto", "keto.yml"), (0, configs_js_1.ketoConfigYaml)());
|
|
209
|
-
fs.writeFileSync(path.join(localDir, "hydra", "hydra.yml"), (0, configs_js_1.hydraConfigYaml)());
|
|
210
|
-
// nginx config is always emitted — the gateway is no longer optional.
|
|
211
|
-
fs.writeFileSync(path.join(localDir, "nginx", "nginx.conf"), (0, configs_js_1.nginxConf)());
|
|
266
|
+
for (const { relPath, content } of renderConfigs(projectRoot, consoleCfg)) {
|
|
267
|
+
fs.writeFileSync(path.join(localDir, relPath), content);
|
|
268
|
+
}
|
|
212
269
|
}
|
|
213
270
|
/**
|
|
214
271
|
* Bring up the local Ory stack and wait for the gateway to be healthy.
|
|
215
272
|
*
|
|
216
273
|
* Idempotent and fail-graceful: returns a typed result instead of exiting.
|
|
217
|
-
* If the gateway is already healthy the
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
274
|
+
* If the gateway is already healthy AND the running stack's config matches
|
|
275
|
+
* what the current code generates (verified by a config fingerprint), the
|
|
276
|
+
* function short-circuits without touching Docker; otherwise it regenerates
|
|
277
|
+
* the config and runs `docker compose up -d --wait` to reconcile. When
|
|
278
|
+
* `quiet` is set, progress is logged to stderr only (suitable for the dev
|
|
279
|
+
* launcher); otherwise it prints to stdout in the format `localUp` historically used.
|
|
221
280
|
*/
|
|
222
281
|
async function ensureLocalOryStack(opts = {}) {
|
|
223
282
|
const localDir = opts.localDir ?? getLocalDir();
|
|
@@ -232,13 +291,25 @@ async function ensureLocalOryStack(opts = {}) {
|
|
|
232
291
|
}
|
|
233
292
|
const gatewayCfg = (0, configs_js_1.getGatewayConfig)();
|
|
234
293
|
const healthUrl = `${configs_js_1.GATEWAY_URL}${gatewayCfg.healthPath}`;
|
|
235
|
-
// Short-circuit if the gateway is already serving
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
294
|
+
// Short-circuit if the gateway is already serving AND the running stack was
|
|
295
|
+
// brought up with the same config the current code would generate — keeps
|
|
296
|
+
// repeated dev launches fast without re-pulling/restarting healthy
|
|
297
|
+
// containers. Gateway health alone is not enough: a config change to any
|
|
298
|
+
// service (e.g. the login-UI port, a service env var) leaves the gateway
|
|
299
|
+
// healthy while the rest of the stack runs stale config, so we also require
|
|
300
|
+
// the config fingerprint to match. On a mismatch we fall through and let
|
|
301
|
+
// `docker compose up -d --wait` diff the regenerated YAML and recreate only
|
|
302
|
+
// the changed containers.
|
|
303
|
+
//
|
|
304
|
+
// Skip the short-circuit entirely when ORY_CONSOLE_LITE_PATH is explicitly
|
|
305
|
+
// set so a rebuild is forced even when the path is unchanged (the console
|
|
306
|
+
// source tree behind the path may have changed, which the fingerprint of the
|
|
307
|
+
// generated config can't see).
|
|
240
308
|
const explicitConsoleLiteOverride = (process.env.ORY_CONSOLE_LITE_PATH ?? "").trim() !== "";
|
|
309
|
+
const fingerprint = configFingerprint(projectRoot, consoleCfg);
|
|
310
|
+
const configUnchanged = readConfigFingerprint(localDir) === fingerprint;
|
|
241
311
|
if (!explicitConsoleLiteOverride &&
|
|
312
|
+
configUnchanged &&
|
|
242
313
|
(await isGatewayHealthy(1_500, healthUrl))) {
|
|
243
314
|
return { status: "already-running", gatewayUrl: configs_js_1.GATEWAY_URL, localDir };
|
|
244
315
|
}
|
|
@@ -320,6 +391,10 @@ async function ensureLocalOryStack(opts = {}) {
|
|
|
320
391
|
detail: "Gateway did not become healthy within 2 minutes.",
|
|
321
392
|
};
|
|
322
393
|
}
|
|
394
|
+
// Record the fingerprint of the config the stack was actually brought up
|
|
395
|
+
// with (consoleCfg may have fallen back to disabled above), so the next
|
|
396
|
+
// launch can short-circuit only while the config is unchanged.
|
|
397
|
+
writeConfigFingerprint(localDir, configFingerprint(projectRoot, consoleCfg));
|
|
323
398
|
return { status: "started", gatewayUrl: configs_js_1.GATEWAY_URL, localDir };
|
|
324
399
|
}
|
|
325
400
|
async function localUp(opts = {}) {
|
package/dist/permissions.d.ts
CHANGED
|
@@ -12,8 +12,15 @@
|
|
|
12
12
|
* return `{ kind: "observe", … }` so the caller allows the
|
|
13
13
|
* tool through.
|
|
14
14
|
* - `enforce` — return `{ kind: "deny", … }` so the caller blocks.
|
|
15
|
-
* 3. On a thrown {@link OryError}
|
|
16
|
-
*
|
|
15
|
+
* 3. On a thrown {@link OryError}, inspects the classified code:
|
|
16
|
+
* - Infrastructure errors (`network_error`, `rate_limited`,
|
|
17
|
+
* `not_found`, `unknown`) — returns `{ kind: "fail_open", … }`.
|
|
18
|
+
* Callers should log and allow.
|
|
19
|
+
* - Auth-rejected check calls (`forbidden`, `session_inactive`,
|
|
20
|
+
* `session_aal2_required` — the check itself was rejected, so
|
|
21
|
+
* its result cannot be trusted) — in `enforce` mode returns
|
|
22
|
+
* `{ kind: "deny", … }` so the caller blocks; in `observe`
|
|
23
|
+
* mode falls back to `fail_open` (observe never blocks).
|
|
17
24
|
*
|
|
18
25
|
* The discriminated `kind` lets plugins map cleanly to their native
|
|
19
26
|
* decision shape (claude-code exit codes, openclaw `{ block: true }`,
|
|
@@ -22,7 +29,7 @@
|
|
|
22
29
|
*/
|
|
23
30
|
import type { OryAgentClient } from "./client.js";
|
|
24
31
|
import { type PermissionMode } from "./config.js";
|
|
25
|
-
import type { OryError, PermissionCheck, PermissionResult } from "./types.js";
|
|
32
|
+
import type { OryError, OryErrorCode, PermissionCheck, PermissionResult } from "./types.js";
|
|
26
33
|
/**
|
|
27
34
|
* Attributes describing *what was checked* and *under which posture*.
|
|
28
35
|
* Plugins spread this onto their `tool.invoke` / `tool.block` spans so
|
|
@@ -36,6 +43,13 @@ export interface DecisionSpanAttributes {
|
|
|
36
43
|
subjectId?: string;
|
|
37
44
|
/** SubjectSet rendered as `<namespace>:<object>#<relation>` when used. */
|
|
38
45
|
subjectSet?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Set when the check call itself was auth-rejected and enforce mode
|
|
48
|
+
* turned that into a deny — the classified {@link OryErrorCode} that
|
|
49
|
+
* caused it. Makes the "denied because the check failed" case
|
|
50
|
+
* distinguishable from a normal Keto deny in the audit trail.
|
|
51
|
+
*/
|
|
52
|
+
checkRejected?: OryErrorCode;
|
|
39
53
|
}
|
|
40
54
|
export type PermissionDecision = {
|
|
41
55
|
kind: "allow";
|
|
@@ -118,12 +132,20 @@ export interface ApplyPermissionModeContext {
|
|
|
118
132
|
*/
|
|
119
133
|
export declare function applyPermissionMode(client: OryAgentClient, allowed: boolean, context?: ApplyPermissionModeContext): ModeDecision;
|
|
120
134
|
/**
|
|
121
|
-
* Outcome of {@link gateToolCall}. Either
|
|
135
|
+
* Outcome of {@link gateToolCall}. Either Ory is disabled entirely
|
|
136
|
+
* (`config.auditOnly` kill switch), the tool is a user-interaction
|
|
122
137
|
* primitive (`AskUserQuestion`, `ExitPlanMode`, `TodoWrite`, …) and we
|
|
123
138
|
* pass through with a single audit span, or it's a real tool execution
|
|
124
139
|
* and the caller gets the standard {@link PermissionDecision}.
|
|
125
140
|
*/
|
|
126
141
|
export type ToolGateOutcome = {
|
|
142
|
+
kind: "audit_only";
|
|
143
|
+
/** Attributes to attach to the caller's pass-through trace, if any. */
|
|
144
|
+
spanAttributes: {
|
|
145
|
+
auditOnly: true;
|
|
146
|
+
toolName: string;
|
|
147
|
+
};
|
|
148
|
+
} | {
|
|
127
149
|
kind: "interactive";
|
|
128
150
|
/** Attributes to attach to the caller's pass-through trace, if any. */
|
|
129
151
|
spanAttributes: {
|
|
@@ -147,8 +169,12 @@ export interface GateToolCallArgs {
|
|
|
147
169
|
modeOverride?: PermissionMode;
|
|
148
170
|
}
|
|
149
171
|
/**
|
|
150
|
-
* Single entry point for the pre-tool-use gate.
|
|
151
|
-
*
|
|
172
|
+
* Single entry point for the pre-tool-use gate. Applies the
|
|
173
|
+
* `config.auditOnly` kill switch first — when set, Ory is disabled
|
|
174
|
+
* entirely and the caller gets `{ kind: "audit_only" }` back without any
|
|
175
|
+
* permission check (record the invocation as an audit span and proceed).
|
|
176
|
+
* Otherwise splits the harness's incoming "tool" into two semantic
|
|
177
|
+
* categories:
|
|
152
178
|
*
|
|
153
179
|
* - **Interactive** — the tool surfaces UI to the user
|
|
154
180
|
* (`AskUserQuestion`, `ExitPlanMode`, `TodoWrite`, plus anything
|
|
@@ -170,7 +196,9 @@ export interface GateToolCallArgs {
|
|
|
170
196
|
export declare function gateToolCall(client: OryAgentClient, args: GateToolCallArgs): Promise<ToolGateOutcome>;
|
|
171
197
|
/**
|
|
172
198
|
* Run a permission check and resolve the configured mode against the
|
|
173
|
-
* result. Never throws —
|
|
174
|
-
* `{ kind: "fail_open" }` decision
|
|
199
|
+
* result. Never throws — infrastructure errors surface as a typed
|
|
200
|
+
* `{ kind: "fail_open" }` decision, and auth-rejected check calls in
|
|
201
|
+
* enforce mode as `{ kind: "deny" }` (see
|
|
202
|
+
* {@link CHECK_AUTH_REJECTED_CODES}).
|
|
175
203
|
*/
|
|
176
204
|
export declare function checkAndDecide(client: OryAgentClient, check: PermissionCheck, opts?: CheckAndDecideOptions): Promise<PermissionDecision>;
|
package/dist/permissions.js
CHANGED
|
@@ -13,8 +13,15 @@
|
|
|
13
13
|
* return `{ kind: "observe", … }` so the caller allows the
|
|
14
14
|
* tool through.
|
|
15
15
|
* - `enforce` — return `{ kind: "deny", … }` so the caller blocks.
|
|
16
|
-
* 3. On a thrown {@link OryError}
|
|
17
|
-
*
|
|
16
|
+
* 3. On a thrown {@link OryError}, inspects the classified code:
|
|
17
|
+
* - Infrastructure errors (`network_error`, `rate_limited`,
|
|
18
|
+
* `not_found`, `unknown`) — returns `{ kind: "fail_open", … }`.
|
|
19
|
+
* Callers should log and allow.
|
|
20
|
+
* - Auth-rejected check calls (`forbidden`, `session_inactive`,
|
|
21
|
+
* `session_aal2_required` — the check itself was rejected, so
|
|
22
|
+
* its result cannot be trusted) — in `enforce` mode returns
|
|
23
|
+
* `{ kind: "deny", … }` so the caller blocks; in `observe`
|
|
24
|
+
* mode falls back to `fail_open` (observe never blocks).
|
|
18
25
|
*
|
|
19
26
|
* The discriminated `kind` lets plugins map cleanly to their native
|
|
20
27
|
* decision shape (claude-code exit codes, openclaw `{ block: true }`,
|
|
@@ -27,6 +34,19 @@ exports.gateToolCall = gateToolCall;
|
|
|
27
34
|
exports.checkAndDecide = checkAndDecide;
|
|
28
35
|
const config_js_1 = require("./config.js");
|
|
29
36
|
const tool_catalog_js_1 = require("./tool-catalog.js");
|
|
37
|
+
/**
|
|
38
|
+
* Error codes meaning the check call itself was rejected for
|
|
39
|
+
* authentication/authorization reasons — an expired or misconfigured
|
|
40
|
+
* agent credential, an inactive session, or a missing MFA step. The
|
|
41
|
+
* check result cannot be trusted, so `enforce` mode denies instead of
|
|
42
|
+
* failing open. Infrastructure errors (`network_error`, `rate_limited`,
|
|
43
|
+
* `not_found`, `unknown`) keep the fail-open posture.
|
|
44
|
+
*/
|
|
45
|
+
const CHECK_AUTH_REJECTED_CODES = new Set([
|
|
46
|
+
"forbidden",
|
|
47
|
+
"session_inactive",
|
|
48
|
+
"session_aal2_required",
|
|
49
|
+
]);
|
|
30
50
|
function formatSubjectSet(set) {
|
|
31
51
|
if (!set)
|
|
32
52
|
return undefined;
|
|
@@ -78,8 +98,12 @@ function applyPermissionMode(client, allowed, context = {}) {
|
|
|
78
98
|
return { kind: "deny", mode: "enforce", spanAttributes };
|
|
79
99
|
}
|
|
80
100
|
/**
|
|
81
|
-
* Single entry point for the pre-tool-use gate.
|
|
82
|
-
*
|
|
101
|
+
* Single entry point for the pre-tool-use gate. Applies the
|
|
102
|
+
* `config.auditOnly` kill switch first — when set, Ory is disabled
|
|
103
|
+
* entirely and the caller gets `{ kind: "audit_only" }` back without any
|
|
104
|
+
* permission check (record the invocation as an audit span and proceed).
|
|
105
|
+
* Otherwise splits the harness's incoming "tool" into two semantic
|
|
106
|
+
* categories:
|
|
83
107
|
*
|
|
84
108
|
* - **Interactive** — the tool surfaces UI to the user
|
|
85
109
|
* (`AskUserQuestion`, `ExitPlanMode`, `TodoWrite`, plus anything
|
|
@@ -99,6 +123,17 @@ function applyPermissionMode(client, allowed, context = {}) {
|
|
|
99
123
|
* semantics; the new `interactive` kind means "do nothing else."
|
|
100
124
|
*/
|
|
101
125
|
async function gateToolCall(client, args) {
|
|
126
|
+
if ((0, config_js_1.resolveConfig)().auditOnly) {
|
|
127
|
+
client.logger.debug("tool.audit_only", {
|
|
128
|
+
harness: args.harness,
|
|
129
|
+
toolName: args.toolName,
|
|
130
|
+
note: "audit-only mode — skipping permission check",
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
kind: "audit_only",
|
|
134
|
+
spanAttributes: { auditOnly: true, toolName: args.toolName },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
102
137
|
if ((0, tool_catalog_js_1.isInteractiveTool)(args.harness, args.toolName)) {
|
|
103
138
|
client.logger.debug("tool.interactive", {
|
|
104
139
|
harness: args.harness,
|
|
@@ -125,8 +160,10 @@ async function gateToolCall(client, args) {
|
|
|
125
160
|
}
|
|
126
161
|
/**
|
|
127
162
|
* Run a permission check and resolve the configured mode against the
|
|
128
|
-
* result. Never throws —
|
|
129
|
-
* `{ kind: "fail_open" }` decision
|
|
163
|
+
* result. Never throws — infrastructure errors surface as a typed
|
|
164
|
+
* `{ kind: "fail_open" }` decision, and auth-rejected check calls in
|
|
165
|
+
* enforce mode as `{ kind: "deny" }` (see
|
|
166
|
+
* {@link CHECK_AUTH_REJECTED_CODES}).
|
|
130
167
|
*/
|
|
131
168
|
async function checkAndDecide(client, check, opts = {}) {
|
|
132
169
|
const mode = opts.modeOverride ?? (0, config_js_1.resolveConfig)().permissionMode;
|
|
@@ -138,7 +175,25 @@ async function checkAndDecide(client, check, opts = {}) {
|
|
|
138
175
|
});
|
|
139
176
|
}
|
|
140
177
|
catch (err) {
|
|
141
|
-
|
|
178
|
+
const oryErr = err;
|
|
179
|
+
if (mode === "enforce" && oryErr && CHECK_AUTH_REJECTED_CODES.has(oryErr.code)) {
|
|
180
|
+
// The check call itself was rejected for auth reasons — the
|
|
181
|
+
// result cannot be trusted, so enforce mode must not fail open.
|
|
182
|
+
client.logger.warn("permission.check_rejected", {
|
|
183
|
+
namespace: check.namespace,
|
|
184
|
+
object: check.object,
|
|
185
|
+
relation: check.relation,
|
|
186
|
+
code: oryErr.code,
|
|
187
|
+
note: `permission check rejected (${oryErr.code}); enforce mode denies when checks cannot be completed`,
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
kind: "deny",
|
|
191
|
+
result: { allowed: false, checkedAt: new Date().toISOString(), check },
|
|
192
|
+
mode: "enforce",
|
|
193
|
+
spanAttributes: { ...spanAttributes, checkRejected: oryErr.code },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return { kind: "fail_open", error: oryErr, mode, spanAttributes };
|
|
142
197
|
}
|
|
143
198
|
const inner = applyPermissionMode(client, result.allowed, {
|
|
144
199
|
namespace: check.namespace,
|
package/dist/subject.d.ts
CHANGED
|
@@ -27,7 +27,19 @@ export type UserSubjectRef = {
|
|
|
27
27
|
};
|
|
28
28
|
};
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
30
|
+
* Run `fn` with a per-call user subject that takes precedence over the
|
|
31
|
+
* client's user principal and the env overrides in
|
|
32
|
+
* {@link resolveUserSubject}. The override only applies within `fn`'s
|
|
33
|
+
* async context, so concurrent calls cannot observe each other's subject.
|
|
34
|
+
* `ORY_USER_SUBJECT_NAMESPACE` SubjectSet shaping still applies.
|
|
35
|
+
*
|
|
36
|
+
* A missing/empty `subject` is a no-op: `fn` runs with the normal
|
|
37
|
+
* resolution chain.
|
|
38
|
+
*/
|
|
39
|
+
export declare function runWithUserSubject<T>(subject: string | undefined, fn: () => T): T;
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the user subject for permission checks. Prefers a per-call
|
|
42
|
+
* override installed via {@link runWithUserSubject}, then the user
|
|
31
43
|
* login's `userPrincipal.subject`, falls back to `ORY_USER_SUBJECT_ID`, then the
|
|
32
44
|
* legacy `ORY_AGENT_SUBJECT_ID`, then the caller-supplied `fallback`
|
|
33
45
|
* (typically `session:<id>`).
|
package/dist/subject.js
CHANGED
|
@@ -18,10 +18,36 @@
|
|
|
18
18
|
* falls back to the direct SubjectID chain.
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.runWithUserSubject = runWithUserSubject;
|
|
21
22
|
exports.resolveUserSubject = resolveUserSubject;
|
|
22
23
|
exports.subjectLabel = subjectLabel;
|
|
24
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
23
25
|
/**
|
|
24
|
-
*
|
|
26
|
+
* Per-async-context user-subject override. Populated by
|
|
27
|
+
* {@link runWithUserSubject} so multi-user server integrations (Vercel AI
|
|
28
|
+
* SDK, Cloudflare Agents) can attribute each request to its own acting
|
|
29
|
+
* user without mutating the shared client or process env — both of which
|
|
30
|
+
* would race under concurrent requests.
|
|
31
|
+
*/
|
|
32
|
+
const perCallUserSubject = new node_async_hooks_1.AsyncLocalStorage();
|
|
33
|
+
/**
|
|
34
|
+
* Run `fn` with a per-call user subject that takes precedence over the
|
|
35
|
+
* client's user principal and the env overrides in
|
|
36
|
+
* {@link resolveUserSubject}. The override only applies within `fn`'s
|
|
37
|
+
* async context, so concurrent calls cannot observe each other's subject.
|
|
38
|
+
* `ORY_USER_SUBJECT_NAMESPACE` SubjectSet shaping still applies.
|
|
39
|
+
*
|
|
40
|
+
* A missing/empty `subject` is a no-op: `fn` runs with the normal
|
|
41
|
+
* resolution chain.
|
|
42
|
+
*/
|
|
43
|
+
function runWithUserSubject(subject, fn) {
|
|
44
|
+
if (!subject)
|
|
45
|
+
return fn();
|
|
46
|
+
return perCallUserSubject.run(subject, fn);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the user subject for permission checks. Prefers a per-call
|
|
50
|
+
* override installed via {@link runWithUserSubject}, then the user
|
|
25
51
|
* login's `userPrincipal.subject`, falls back to `ORY_USER_SUBJECT_ID`, then the
|
|
26
52
|
* legacy `ORY_AGENT_SUBJECT_ID`, then the caller-supplied `fallback`
|
|
27
53
|
* (typically `session:<id>`).
|
|
@@ -30,7 +56,8 @@ exports.subjectLabel = subjectLabel;
|
|
|
30
56
|
* concrete subject is available; otherwise a direct SubjectID.
|
|
31
57
|
*/
|
|
32
58
|
function resolveUserSubject(client, fallback) {
|
|
33
|
-
const subject =
|
|
59
|
+
const subject = perCallUserSubject.getStore()
|
|
60
|
+
?? client.userPrincipal.subject
|
|
34
61
|
?? process.env.ORY_USER_SUBJECT_ID
|
|
35
62
|
?? process.env.ORY_AGENT_SUBJECT_ID
|
|
36
63
|
?? fallback;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared test utilities for Ory agent plugin integration tests.
|
|
3
|
+
*
|
|
4
|
+
* These helpers provide mock Ory API responses, error factories, and
|
|
5
|
+
* common assertion patterns so that harness plugin tests focus on
|
|
6
|
+
* harness-specific behavior rather than duplicating boilerplate.
|
|
7
|
+
*/
|
|
8
|
+
import { vi } from "vitest";
|
|
9
|
+
import { OryAgentClient } from "./client.js";
|
|
10
|
+
import type { TraceSpan, TraceEvent } from "./tracer.js";
|
|
11
|
+
export { runHarnessContractSuite, type HarnessContractAdapter, type ContractContext, type ContractGates, type ContractOutcome, } from "./contract-suite.js";
|
|
12
|
+
/**
|
|
13
|
+
* Create an OryAgentClient with session caching disabled.
|
|
14
|
+
* Pass overrides to customize (e.g. a different harness name).
|
|
15
|
+
*/
|
|
16
|
+
export declare function createMockClient(overrides?: Partial<ConstructorParameters<typeof OryAgentClient>[0]>): OryAgentClient;
|
|
17
|
+
/**
|
|
18
|
+
* Stub an internal API instance method on the client.
|
|
19
|
+
* Returns a vi.fn mock so callers can assert on calls.
|
|
20
|
+
*/
|
|
21
|
+
export declare function stubApi<K extends "frontend" | "oauth2" | "permission" | "relationship">(client: OryAgentClient, api: K, method: string, impl: (...args: unknown[]) => unknown): ReturnType<typeof vi.fn>;
|
|
22
|
+
/** Successful session verification response (wraps Ory API shape). */
|
|
23
|
+
export declare const MOCK_SESSION_RESPONSE: {
|
|
24
|
+
data: {
|
|
25
|
+
id: string;
|
|
26
|
+
active: boolean;
|
|
27
|
+
authenticated_at: string;
|
|
28
|
+
expires_at: string;
|
|
29
|
+
authenticator_assurance_level: string;
|
|
30
|
+
authentication_methods: {
|
|
31
|
+
method: string;
|
|
32
|
+
completed_at: string;
|
|
33
|
+
}[];
|
|
34
|
+
identity: {
|
|
35
|
+
id: string;
|
|
36
|
+
traits: {
|
|
37
|
+
email: string;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
/** Inactive session (same shape, active: false). */
|
|
43
|
+
export declare const MOCK_INACTIVE_SESSION_RESPONSE: {
|
|
44
|
+
data: {
|
|
45
|
+
active: boolean;
|
|
46
|
+
id: string;
|
|
47
|
+
authenticated_at: string;
|
|
48
|
+
expires_at: string;
|
|
49
|
+
authenticator_assurance_level: string;
|
|
50
|
+
authentication_methods: {
|
|
51
|
+
method: string;
|
|
52
|
+
completed_at: string;
|
|
53
|
+
}[];
|
|
54
|
+
identity: {
|
|
55
|
+
id: string;
|
|
56
|
+
traits: {
|
|
57
|
+
email: string;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
/** Successful OAuth2 token introspection response. */
|
|
63
|
+
export declare const MOCK_OAUTH2_RESPONSE: {
|
|
64
|
+
data: {
|
|
65
|
+
active: boolean;
|
|
66
|
+
client_id: string;
|
|
67
|
+
sub: string;
|
|
68
|
+
scope: string;
|
|
69
|
+
aud: string[];
|
|
70
|
+
exp: number;
|
|
71
|
+
iat: number;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
/** Inactive OAuth2 token response. */
|
|
75
|
+
export declare const MOCK_INACTIVE_OAUTH2_RESPONSE: {
|
|
76
|
+
data: {
|
|
77
|
+
active: boolean;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
/** Permission check allowed response. */
|
|
81
|
+
export declare const PERMISSION_ALLOWED: {
|
|
82
|
+
data: {
|
|
83
|
+
allowed: boolean;
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
/** Permission check denied response. */
|
|
87
|
+
export declare const PERMISSION_DENIED: {
|
|
88
|
+
data: {
|
|
89
|
+
allowed: boolean;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
/** Batch permission check: both results allowed. */
|
|
93
|
+
export declare const BATCH_BOTH_ALLOWED: {
|
|
94
|
+
data: {
|
|
95
|
+
results: {
|
|
96
|
+
allowed: boolean;
|
|
97
|
+
}[];
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
/** Batch permission check: server allowed, tool denied. */
|
|
101
|
+
export declare const BATCH_SERVER_ALLOWED_TOOL_DENIED: {
|
|
102
|
+
data: {
|
|
103
|
+
results: {
|
|
104
|
+
allowed: boolean;
|
|
105
|
+
}[];
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
/** Batch permission check: server denied. */
|
|
109
|
+
export declare const BATCH_SERVER_DENIED: {
|
|
110
|
+
data: {
|
|
111
|
+
results: {
|
|
112
|
+
allowed: boolean;
|
|
113
|
+
}[];
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
/** Create an Axios-shaped error with a response. */
|
|
117
|
+
export declare function makeAxiosError(status: number, body?: unknown, code?: string): Record<string, unknown>;
|
|
118
|
+
/** Create a Node.js network error (ECONNREFUSED, ETIMEDOUT, etc.). */
|
|
119
|
+
export declare function makeNetworkError(code?: string): NodeJS.ErrnoException;
|
|
120
|
+
/** Axios 429 rate-limit error. */
|
|
121
|
+
export declare function makeRateLimitError(): Record<string, unknown>;
|
|
122
|
+
/** Axios 403 with session_aal2_required error id. */
|
|
123
|
+
export declare function makeMfaRequiredError(): Record<string, unknown>;
|
|
124
|
+
/** Axios 401 with session_inactive error id. */
|
|
125
|
+
export declare function makeSessionInactiveError(): Record<string, unknown>;
|
|
126
|
+
/**
|
|
127
|
+
* Get all recorded trace spans from a client, optionally filtered by event.
|
|
128
|
+
*/
|
|
129
|
+
export declare function getTraceSpans(client: OryAgentClient, event?: TraceEvent): TraceSpan[];
|
|
130
|
+
/**
|
|
131
|
+
* Assert the tracer recorded the expected tool names for a given event type.
|
|
132
|
+
* Checks the `toolName` attribute on each span.
|
|
133
|
+
*/
|
|
134
|
+
export declare function expectTracedTools(client: OryAgentClient, event: TraceEvent, expectedTools: string[]): void;
|
|
135
|
+
/**
|
|
136
|
+
* Set standard Ory env vars for a configured + authenticated test.
|
|
137
|
+
* Returns a cleanup function that restores the previous state.
|
|
138
|
+
*/
|
|
139
|
+
export declare function setOryEnv(overrides?: Partial<{
|
|
140
|
+
projectUrl: string;
|
|
141
|
+
sessionToken: string;
|
|
142
|
+
oauth2Token: string;
|
|
143
|
+
subjectId: string;
|
|
144
|
+
namespace: string;
|
|
145
|
+
/**
|
|
146
|
+
* Permission mode for the test. Defaults to `"enforce"` so existing
|
|
147
|
+
* test assertions that exercise the deny path still see a block.
|
|
148
|
+
* Pass `"observe"` explicitly to exercise the new observe branch.
|
|
149
|
+
*/
|
|
150
|
+
permissionMode: "observe" | "enforce";
|
|
151
|
+
}>): () => void;
|
|
152
|
+
/**
|
|
153
|
+
* Point `XDG_CONFIG_HOME` at a fresh temp directory so `resolveConfig()`
|
|
154
|
+
* reads from a known-empty state instead of the developer's real
|
|
155
|
+
* `~/.config/ory-agent-plugins/config.json`. Returns a cleanup function
|
|
156
|
+
* that restores the env var and removes the temp directory.
|
|
157
|
+
*
|
|
158
|
+
* Use `saveConfig(...)` from `./config.js` inside the test to shape the
|
|
159
|
+
* isolated config (e.g. `saveConfig({ auditOnly: true })`).
|
|
160
|
+
*/
|
|
161
|
+
export declare function useTempConfigDir(): () => void;
|
|
162
|
+
/**
|
|
163
|
+
* Clear all Ory env vars (simulate unconfigured state).
|
|
164
|
+
*/
|
|
165
|
+
export declare function clearOryEnv(): void;
|
|
166
|
+
/** Stub verifySession to succeed. */
|
|
167
|
+
export declare function stubSessionSuccess(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
168
|
+
/** Stub verifySession to return inactive. */
|
|
169
|
+
export declare function stubSessionInactive(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
170
|
+
/** Stub verifySession to throw a network error. */
|
|
171
|
+
export declare function stubSessionNetworkError(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
172
|
+
/** Stub verifySession to throw MFA required. */
|
|
173
|
+
export declare function stubSessionMfaRequired(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
174
|
+
/** Stub verifySession to throw session_inactive. */
|
|
175
|
+
export declare function stubSessionExpired(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
176
|
+
/** Stub introspectToken to succeed. */
|
|
177
|
+
export declare function stubOAuth2Success(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
178
|
+
/** Stub introspectToken to return inactive. */
|
|
179
|
+
export declare function stubOAuth2Inactive(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
180
|
+
/** Stub checkPermission to allow. */
|
|
181
|
+
export declare function stubPermissionAllowed(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
182
|
+
/** Stub checkPermission to deny. */
|
|
183
|
+
export declare function stubPermissionDenied(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
184
|
+
/** Stub checkPermission to throw a network error. */
|
|
185
|
+
export declare function stubPermissionNetworkError(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
186
|
+
/** Stub checkPermission to throw a rate-limit error. */
|
|
187
|
+
export declare function stubPermissionRateLimited(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
188
|
+
/**
|
|
189
|
+
* Spy on the public `createRelationship` method and resolve successfully.
|
|
190
|
+
* Prefer this over stubbing the internal relationship API: `setAgentPrincipal`
|
|
191
|
+
* rebuilds the API instances when the agent token changes (e.g. inside
|
|
192
|
+
* `sessionStart`), which silently discards API-level stubs.
|
|
193
|
+
*/
|
|
194
|
+
export declare function spyRelationshipCreated(client: OryAgentClient): import("vitest").Mock<(check: import("./types.js").PermissionCheck, options?: {
|
|
195
|
+
spanAttributes?: Record<string, unknown>;
|
|
196
|
+
}) => Promise<{
|
|
197
|
+
created: boolean;
|
|
198
|
+
alreadyExisted: boolean;
|
|
199
|
+
}>>;
|
|
200
|
+
/** Spy on the public `createRelationship` method and reject. */
|
|
201
|
+
export declare function spyRelationshipCreateError(client: OryAgentClient): import("vitest").Mock<(check: import("./types.js").PermissionCheck, options?: {
|
|
202
|
+
spanAttributes?: Record<string, unknown>;
|
|
203
|
+
}) => Promise<{
|
|
204
|
+
created: boolean;
|
|
205
|
+
alreadyExisted: boolean;
|
|
206
|
+
}>>;
|
|
207
|
+
/** Stub both checkPermission (server allow) and batchCheckPermission (both allow). */
|
|
208
|
+
export declare function stubMcpAllowed(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
209
|
+
/** Stub checkPermission to deny (server-only MCP check). */
|
|
210
|
+
export declare function stubMcpServerDenied(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
211
|
+
/** Stub batchCheckPermission: server allowed, tool denied. */
|
|
212
|
+
export declare function stubMcpToolDenied(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
213
|
+
/** Stub checkPermission to throw network error (MCP fail-open). */
|
|
214
|
+
export declare function stubMcpNetworkError(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|