@ory/argus 0.13.9 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -45
- package/assets/commands/temporal-up.md +1 -1
- package/assets/skills/auth-setup/SKILL.md +1 -1
- package/assets/skills/local-dev/SKILL.md +17 -7
- package/assets/skills/ory-build-agent/SKILL.md +43 -97
- package/assets/skills/ory-e2b-sandbox/SKILL.md +18 -18
- package/assets/skills/ory-temporal-worker/SKILL.md +38 -42
- package/assets/skills/permissions-onboarding/SKILL.md +131 -104
- package/dist/adapters.d.ts +93 -30
- package/dist/adapters.js +464 -136
- package/dist/agent-auth.d.ts +258 -68
- package/dist/agent-auth.js +998 -202
- package/dist/auth-store.d.ts +37 -2
- package/dist/auth-store.js +37 -3
- package/dist/auth.d.ts +40 -4
- package/dist/auth.js +247 -19
- package/dist/bash-parser.d.ts +98 -0
- package/dist/bash-parser.js +396 -0
- package/dist/branding.d.ts +128 -0
- package/dist/branding.js +151 -0
- package/dist/build-info.json +4 -4
- package/dist/cli-invocation.d.ts +1 -1
- package/dist/cli-invocation.js +2 -1
- package/dist/cli.d.ts +20 -29
- package/dist/cli.js +271 -278
- package/dist/client.d.ts +175 -138
- package/dist/client.js +672 -391
- package/dist/config.d.ts +249 -57
- package/dist/config.js +486 -62
- package/dist/context.d.ts +10 -0
- package/dist/context.js +21 -0
- package/dist/contract-suite.d.ts +8 -8
- package/dist/contract-suite.js +88 -69
- package/dist/denial.d.ts +36 -3
- package/dist/denial.js +79 -10
- package/dist/event-reporter.d.ts +77 -0
- package/dist/event-reporter.js +776 -0
- package/dist/external-registrations-main.d.ts +10 -0
- package/dist/external-registrations-main.js +38 -0
- package/dist/external-registrations.d.ts +79 -0
- package/dist/external-registrations.js +188 -0
- package/dist/help-cli.d.ts +39 -0
- package/dist/help-cli.js +55 -0
- package/dist/hook-timeout.d.ts +64 -0
- package/dist/hook-timeout.js +88 -0
- package/dist/index.d.ts +31 -19
- package/dist/index.js +182 -31
- package/dist/lifecycle.d.ts +3 -3
- package/dist/lifecycle.js +38 -6
- package/dist/local/cli.js +11 -6
- package/dist/local/configs.d.ts +74 -18
- package/dist/local/configs.js +291 -84
- package/dist/local/health.d.ts +14 -0
- package/dist/local/health.js +50 -4
- package/dist/local/index.d.ts +2 -2
- package/dist/local/index.js +24 -10
- package/dist/local/manager.d.ts +20 -1
- package/dist/local/manager.js +160 -39
- package/dist/local/ports.d.ts +158 -0
- package/dist/local/ports.js +443 -0
- package/dist/local/seed.d.ts +22 -25
- package/dist/local/seed.js +88 -56
- package/dist/logger.d.ts +54 -25
- package/dist/logger.js +329 -63
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +10 -5
- package/dist/mirror-bootstrap.d.ts +48 -0
- package/dist/mirror-bootstrap.js +254 -0
- package/dist/opl.d.ts +289 -0
- package/dist/opl.js +446 -0
- package/dist/permission-mode.d.ts +87 -0
- package/dist/permission-mode.js +307 -0
- package/dist/permissions-cli.d.ts +13 -49
- package/dist/permissions-cli.js +154 -348
- package/dist/permissions.d.ts +148 -38
- package/dist/permissions.js +591 -45
- package/dist/post-install.d.ts +33 -0
- package/dist/post-install.js +127 -0
- package/dist/read-credential.d.ts +65 -0
- package/dist/read-credential.js +86 -0
- package/dist/registry/cli.js +5 -2
- package/dist/registry/config.d.ts +0 -17
- package/dist/registry/config.js +0 -23
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.js +2 -2
- package/dist/registry/manager.d.ts +4 -21
- package/dist/registry/manager.js +83 -55
- package/dist/runtime-credential.d.ts +140 -0
- package/dist/runtime-credential.js +572 -0
- package/dist/runtime.d.ts +408 -0
- package/dist/runtime.js +748 -0
- package/dist/setup.d.ts +23 -28
- package/dist/setup.js +57 -84
- package/dist/status-cli.d.ts +29 -13
- package/dist/status-cli.js +124 -144
- package/dist/status-data.d.ts +195 -0
- package/dist/status-data.js +333 -0
- package/dist/status-system.d.ts +24 -0
- package/dist/status-system.js +56 -0
- package/dist/subject.d.ts +126 -20
- package/dist/subject.js +215 -30
- package/dist/testing.d.ts +74 -38
- package/dist/testing.js +185 -68
- package/dist/tool-catalog.d.ts +53 -11
- package/dist/tool-catalog.js +164 -13
- package/dist/tool-metadata.d.ts +7 -6
- package/dist/tool-metadata.js +6 -5
- package/dist/types.d.ts +11 -1
- package/dist/uninstall.d.ts +74 -19
- package/dist/uninstall.js +224 -49
- package/dist/user-login.d.ts +22 -16
- package/dist/user-login.js +67 -96
- package/dist/watch-cli.d.ts +6 -0
- package/dist/watch-cli.js +217 -0
- package/package.json +3 -11
- package/dist/dev.d.ts +0 -103
- package/dist/dev.js +0 -584
- package/dist/interactive-setup.d.ts +0 -165
- package/dist/interactive-setup.js +0 -1546
- package/dist/local/jaeger-main.d.ts +0 -13
- package/dist/local/jaeger-main.js +0 -85
- package/dist/local/jaeger.d.ts +0 -50
- package/dist/local/jaeger.js +0 -162
- package/dist/otel/exporter.d.ts +0 -17
- package/dist/otel/exporter.js +0 -12
- package/dist/otel/index.d.ts +0 -2
- package/dist/otel/index.js +0 -8
- package/dist/otel/otlp.d.ts +0 -103
- package/dist/otel/otlp.js +0 -385
- package/dist/tracer.d.ts +0 -190
- package/dist/tracer.js +0 -481
- package/dist/watch-sandbox.d.ts +0 -9
- package/dist/watch-sandbox.js +0 -81
package/dist/permissions.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* 1. Runs the check.
|
|
11
11
|
* 2. On a Keto deny (`result.allowed === false`), applies the mode:
|
|
12
|
-
* - `observe` — record a `permission.observe_deny` audit
|
|
12
|
+
* - `observe` — record a `permission.observe_deny` audit event,
|
|
13
13
|
* return `{ kind: "observe", … }` so the caller allows the
|
|
14
14
|
* tool through.
|
|
15
15
|
* - `enforce` — return `{ kind: "deny", … }` so the caller blocks.
|
|
@@ -29,11 +29,29 @@
|
|
|
29
29
|
* mode logic.
|
|
30
30
|
*/
|
|
31
31
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.resolveCheckRelation = resolveCheckRelation;
|
|
32
33
|
exports.applyPermissionMode = applyPermissionMode;
|
|
33
34
|
exports.gateToolCall = gateToolCall;
|
|
35
|
+
exports.decomposeAndCheck = decomposeAndCheck;
|
|
34
36
|
exports.checkAndDecide = checkAndDecide;
|
|
37
|
+
const bash_parser_js_1 = require("./bash-parser.js");
|
|
35
38
|
const config_js_1 = require("./config.js");
|
|
39
|
+
const permission_mode_js_1 = require("./permission-mode.js");
|
|
40
|
+
const read_credential_js_1 = require("./read-credential.js");
|
|
41
|
+
const opl_js_1 = require("./opl.js");
|
|
42
|
+
const subject_js_1 = require("./subject.js");
|
|
36
43
|
const tool_catalog_js_1 = require("./tool-catalog.js");
|
|
44
|
+
/**
|
|
45
|
+
* Map the logical grant relation a caller passes (`use`) onto the relation or
|
|
46
|
+
* permit Argus actually checks. A `use` check always routes through the
|
|
47
|
+
* block-aware `access` permit (`use && !blocked`), so an explicit block is
|
|
48
|
+
* always respected — the permission model must therefore be applied to the
|
|
49
|
+
* project (done in the Ory Console, Agent Security) for checks to resolve. Any
|
|
50
|
+
* other relation passes through unchanged.
|
|
51
|
+
*/
|
|
52
|
+
function resolveCheckRelation(relation) {
|
|
53
|
+
return relation === opl_js_1.RELATION_USERS ? opl_js_1.PERMIT_USE : relation;
|
|
54
|
+
}
|
|
37
55
|
/**
|
|
38
56
|
* Error codes meaning the check call itself was rejected for
|
|
39
57
|
* authentication/authorization reasons — an expired or misconfigured
|
|
@@ -42,66 +60,109 @@ const tool_catalog_js_1 = require("./tool-catalog.js");
|
|
|
42
60
|
* failing open. Infrastructure errors (`network_error`, `rate_limited`,
|
|
43
61
|
* `not_found`, `unknown`) keep the fail-open posture.
|
|
44
62
|
*/
|
|
45
|
-
const
|
|
63
|
+
const CHECK_UNTRUSTED_CODES = new Set([
|
|
64
|
+
// The check reached Ory and was refused — an expired or misconfigured agent
|
|
65
|
+
// credential, an inactive session, a missing MFA step.
|
|
46
66
|
"forbidden",
|
|
47
67
|
"session_inactive",
|
|
48
68
|
"session_aal2_required",
|
|
69
|
+
// TLS/certificate verification failed, so we cannot establish that we spoke to
|
|
70
|
+
// Ory at all. Strictly less trustworthy than the three above — with those we at
|
|
71
|
+
// least know who refused us. Treating it as a transport blip (fail open) meant a
|
|
72
|
+
// cert problem silently downgraded an `enforce` project to `observe`, and
|
|
73
|
+
// breaking TLS was sufficient to disable all tool gating (#218).
|
|
74
|
+
"tls_error",
|
|
49
75
|
]);
|
|
76
|
+
/**
|
|
77
|
+
* Max distinct command words checked per shell command (issue #76). A command
|
|
78
|
+
* with more than this is pathological (no legitimate command runs 100+ distinct
|
|
79
|
+
* binaries). Past this we mark the command too-complex (fail-closed in enforce)
|
|
80
|
+
* rather than issuing an unbounded number of batch round-trips.
|
|
81
|
+
*/
|
|
82
|
+
const MAX_SHELL_WORDS = 100;
|
|
83
|
+
/**
|
|
84
|
+
* Words per Keto `batchCheckPermission` request. Keto caps a batch at
|
|
85
|
+
* `limit.max_batch_check_size` (default **10**); a batch over that limit is
|
|
86
|
+
* rejected with a 400 that would classify as `unknown` and fail the gate OPEN —
|
|
87
|
+
* silently defeating decomposition, and trivially exploitable by padding a
|
|
88
|
+
* command with >10 harmless distinct words. So we chunk the word list into
|
|
89
|
+
* requests of this size and aggregate. Default 10 (Keto's floor); raise via
|
|
90
|
+
* `ORY_SHELL_BATCH_SIZE` when the project configures a larger Keto limit.
|
|
91
|
+
*/
|
|
92
|
+
const DEFAULT_SHELL_BATCH_SIZE = 10;
|
|
93
|
+
function resolveShellBatchSize() {
|
|
94
|
+
const raw = process.env.ORY_SHELL_BATCH_SIZE;
|
|
95
|
+
if (!raw)
|
|
96
|
+
return DEFAULT_SHELL_BATCH_SIZE;
|
|
97
|
+
const n = Number.parseInt(raw, 10);
|
|
98
|
+
if (!Number.isFinite(n) || n < 1)
|
|
99
|
+
return DEFAULT_SHELL_BATCH_SIZE;
|
|
100
|
+
return n;
|
|
101
|
+
}
|
|
50
102
|
function formatSubjectSet(set) {
|
|
51
103
|
if (!set)
|
|
52
104
|
return undefined;
|
|
53
105
|
return `${set.namespace}:${set.object}#${set.relation}`;
|
|
54
106
|
}
|
|
55
|
-
function buildDecisionAttributes(mode, subjectId, subjectSet) {
|
|
107
|
+
function buildDecisionAttributes(mode, subjectId, subjectSet, blockReason) {
|
|
56
108
|
const set = formatSubjectSet(subjectSet);
|
|
57
|
-
|
|
109
|
+
const reason = blockReason ? { blockReason } : {};
|
|
110
|
+
// SubjectSet supersedes subjectId in activity (the printable label is
|
|
58
111
|
// derivable from it); avoid emitting both so consumers don't need to
|
|
59
112
|
// dedupe.
|
|
60
113
|
if (set)
|
|
61
|
-
return { permissionMode: mode, subjectSet: set };
|
|
114
|
+
return { permissionMode: mode, subjectSet: set, ...reason };
|
|
62
115
|
if (subjectId)
|
|
63
|
-
return { permissionMode: mode, subjectId };
|
|
64
|
-
return { permissionMode: mode };
|
|
116
|
+
return { permissionMode: mode, subjectId, ...reason };
|
|
117
|
+
return { permissionMode: mode, ...reason };
|
|
65
118
|
}
|
|
66
119
|
/**
|
|
67
120
|
* Map an `allowed` boolean from any permission check (plain or MCP)
|
|
68
121
|
* onto a {@link ModeDecision}. When `allowed === false` and the mode
|
|
69
|
-
* is `observe`, emits the `permission.observe_deny` audit
|
|
122
|
+
* is `observe`, emits the `permission.observe_deny` audit event and
|
|
70
123
|
* returns `{ kind: "observe" }` so the caller knows to let the action
|
|
71
124
|
* proceed despite the deny.
|
|
72
125
|
*/
|
|
73
126
|
function applyPermissionMode(client, allowed, context = {}) {
|
|
74
127
|
const mode = context.modeOverride ?? (0, config_js_1.resolveConfig)().permissionMode;
|
|
75
|
-
const
|
|
128
|
+
const activityAttributes = buildDecisionAttributes(mode, context.subjectId, context.subjectSet, context.blockReason);
|
|
76
129
|
if (allowed)
|
|
77
|
-
return { kind: "allow", mode,
|
|
130
|
+
return { kind: "allow", mode, activityAttributes };
|
|
131
|
+
const isBlock = context.blockReason === "explicit_block";
|
|
78
132
|
if (mode === "observe") {
|
|
79
|
-
|
|
133
|
+
// An explicit block is still respected *as an observe-mode deny* — it is
|
|
134
|
+
// logged as a distinct `permission.block_observed` event (so an admin can
|
|
135
|
+
// see the block would have fired) but, per the observe posture, the tool
|
|
136
|
+
// still proceeds. Promote to enforce to make blocks actually stop tools.
|
|
137
|
+
const event = isBlock ? "permission.block_observed" : "permission.observe_deny";
|
|
138
|
+
client.logger.warn(event, {
|
|
80
139
|
namespace: context.namespace,
|
|
81
140
|
object: context.object,
|
|
82
141
|
relation: context.relation,
|
|
83
142
|
subjectId: context.subjectId,
|
|
84
|
-
subjectSet:
|
|
85
|
-
note:
|
|
143
|
+
subjectSet: activityAttributes.subjectSet,
|
|
144
|
+
note: isBlock
|
|
145
|
+
? "explicitly blocked by Ory; allowed by plugin in observe mode"
|
|
146
|
+
: "denied by Ory; allowed by plugin in observe mode",
|
|
86
147
|
});
|
|
87
|
-
client.
|
|
148
|
+
client.logger.activity(event, "denied", {
|
|
88
149
|
attributes: {
|
|
89
150
|
namespace: context.namespace,
|
|
90
151
|
object: context.object,
|
|
91
152
|
relation: context.relation,
|
|
92
|
-
...
|
|
93
|
-
...context.
|
|
153
|
+
...activityAttributes,
|
|
154
|
+
...context.activityAttributes,
|
|
94
155
|
},
|
|
95
156
|
});
|
|
96
|
-
return { kind: "observe", mode: "observe",
|
|
157
|
+
return { kind: "observe", mode: "observe", activityAttributes };
|
|
97
158
|
}
|
|
98
|
-
return { kind: "deny", mode: "enforce",
|
|
159
|
+
return { kind: "deny", mode: "enforce", activityAttributes };
|
|
99
160
|
}
|
|
100
161
|
/**
|
|
101
|
-
* Single entry point for the pre-tool-use gate.
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
162
|
+
* Single entry point for the pre-tool-use gate. Checks whether Agent Security
|
|
163
|
+
* is connected first — with no project URL there is
|
|
164
|
+
* nothing to check against, so the caller gets `{ kind: "not_connected" }`
|
|
165
|
+
* back and should record the invocation as an audit event and proceed.
|
|
105
166
|
* Otherwise splits the harness's incoming "tool" into two semantic
|
|
106
167
|
* categories:
|
|
107
168
|
*
|
|
@@ -111,7 +172,7 @@ function applyPermissionMode(client, allowed, context = {}) {
|
|
|
111
172
|
* through Ory: the user is the decision-maker, and blocking them in
|
|
112
173
|
* enforce mode (or logging a misleading observe-deny) hides the very
|
|
113
174
|
* prompt the user needs to see. We record one `user.interaction`
|
|
114
|
-
* audit
|
|
175
|
+
* audit event and return — no permission check, no `tool.invoke`,
|
|
115
176
|
* no `tool.block`.
|
|
116
177
|
*
|
|
117
178
|
* - **Execution** — every other tool. Delegates to
|
|
@@ -123,15 +184,17 @@ function applyPermissionMode(client, allowed, context = {}) {
|
|
|
123
184
|
* semantics; the new `interactive` kind means "do nothing else."
|
|
124
185
|
*/
|
|
125
186
|
async function gateToolCall(client, args) {
|
|
126
|
-
|
|
127
|
-
|
|
187
|
+
const security = (0, config_js_1.resolveConfig)().security;
|
|
188
|
+
if (!security.connected) {
|
|
189
|
+
client.logger.debug("tool.security_not_connected", {
|
|
128
190
|
harness: args.harness,
|
|
129
191
|
toolName: args.toolName,
|
|
130
|
-
|
|
192
|
+
missing: security.missing,
|
|
193
|
+
note: "Agent Security is not connected — skipping permission check",
|
|
131
194
|
});
|
|
132
195
|
return {
|
|
133
|
-
kind: "
|
|
134
|
-
|
|
196
|
+
kind: "not_connected",
|
|
197
|
+
activityAttributes: { securityConnected: false, toolName: args.toolName },
|
|
135
198
|
};
|
|
136
199
|
}
|
|
137
200
|
if ((0, tool_catalog_js_1.isInteractiveTool)(args.harness, args.toolName)) {
|
|
@@ -140,43 +203,406 @@ async function gateToolCall(client, args) {
|
|
|
140
203
|
toolName: args.toolName,
|
|
141
204
|
note: "user-interaction primitive — skipping permission check",
|
|
142
205
|
});
|
|
143
|
-
client.
|
|
206
|
+
client.logger.activity("user.interaction", "ok", {
|
|
144
207
|
attributes: {
|
|
145
208
|
harness: args.harness,
|
|
146
209
|
toolName: args.toolName,
|
|
147
210
|
kind: "tool",
|
|
148
|
-
...args.
|
|
211
|
+
...args.activityAttributes,
|
|
149
212
|
},
|
|
150
213
|
});
|
|
151
214
|
return {
|
|
152
215
|
kind: "interactive",
|
|
153
|
-
|
|
216
|
+
activityAttributes: { interactive: true, toolName: args.toolName },
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
// Every read below is authenticated by Ory's Oathkeeper `/user` rule, so the
|
|
220
|
+
// client needs an access token before the first one goes out. On a subprocess
|
|
221
|
+
// harness this event's process has never run the agent gate, so without this
|
|
222
|
+
// the only credential is the user's persisted token — and once its hour is up
|
|
223
|
+
// every read 401s: `session_inactive` denies every tool under `enforce`, and
|
|
224
|
+
// under `observe` stops checking anything at all (#242).
|
|
225
|
+
await (0, read_credential_js_1.ensureReadCredential)(client, { harness: args.harness });
|
|
226
|
+
// Resolve the mode ONCE for this gate — a per-principal Keto permission read
|
|
227
|
+
// (server → cache → observe), cached with a short TTL. Threaded downstream as
|
|
228
|
+
// `modeOverride` so the whole gate (top-level check + every decomposed shell
|
|
229
|
+
// word) decides against one consistent mode. An explicit `modeOverride`
|
|
230
|
+
// (tests) preempts the server read.
|
|
231
|
+
const mode = args.modeOverride ??
|
|
232
|
+
(await (0, permission_mode_js_1.warmPermissionModeCache)(client, {
|
|
233
|
+
subjectId: args.check.subjectId,
|
|
234
|
+
subjectSet: args.check.subjectSet,
|
|
235
|
+
}, {
|
|
236
|
+
additionalSubjects: [
|
|
237
|
+
{ scope: "agent", subject: (0, subject_js_1.resolveAgentSubject)(client) },
|
|
238
|
+
{ scope: "subagent", subject: (0, subject_js_1.resolveSubAgentSubject)(args.principals?.subAgentClientId) },
|
|
239
|
+
].filter((item) => item.subject !== undefined),
|
|
240
|
+
})).mode;
|
|
241
|
+
// An explicit block on the acting agent / session / sub-agent / spawn denies
|
|
242
|
+
// regardless of the user's grants. Run it alongside the user decision rather
|
|
243
|
+
// than before it, so the extra read costs no serial latency, and let a block
|
|
244
|
+
// override an allow afterwards.
|
|
245
|
+
const [decision, blocked] = await Promise.all([
|
|
246
|
+
args.shellCommand && (0, tool_catalog_js_1.isShellTool)(args.harness, args.toolName)
|
|
247
|
+
? // Shell tools (issue #76): decompose the command into per-word sub-checks
|
|
248
|
+
// in addition to the top-level tool check. Only when the harness marks
|
|
249
|
+
// this a shell tool AND the raw command was threaded through — otherwise
|
|
250
|
+
// fall back to the plain single check (never breaks).
|
|
251
|
+
decomposeAndCheck(client, { ...args, modeOverride: mode })
|
|
252
|
+
: checkAndDecide(client, args.check, {
|
|
253
|
+
activityAttributes: args.activityAttributes,
|
|
254
|
+
modeOverride: mode,
|
|
255
|
+
}),
|
|
256
|
+
findBlockedPrincipal(client, {
|
|
257
|
+
namespace: args.check.namespace,
|
|
258
|
+
object: args.check.object,
|
|
259
|
+
principals: args.principals,
|
|
260
|
+
}),
|
|
261
|
+
]);
|
|
262
|
+
if (!blocked)
|
|
263
|
+
return decision;
|
|
264
|
+
// Already denied on the user's own check — nothing to add to the outcome, but
|
|
265
|
+
// the block is worth recording as the stronger reason it would not have run.
|
|
266
|
+
if (decision.kind === "deny") {
|
|
267
|
+
return {
|
|
268
|
+
...decision,
|
|
269
|
+
activityAttributes: {
|
|
270
|
+
...decision.activityAttributes,
|
|
271
|
+
blockReason: "explicit_block",
|
|
272
|
+
blockedPrincipal: blocked.subject,
|
|
273
|
+
blockedPrincipalLevel: blocked.level,
|
|
274
|
+
},
|
|
154
275
|
};
|
|
155
276
|
}
|
|
156
|
-
return
|
|
157
|
-
|
|
277
|
+
return applyPrincipalBlock(client, { args, mode, decision, blocked });
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Turn a machine-principal block into the gate's outcome, respecting
|
|
281
|
+
* `permissionMode` exactly as any other deny does: `enforce` blocks, `observe`
|
|
282
|
+
* records `permission.block_observed` and passes the tool through.
|
|
283
|
+
*
|
|
284
|
+
* The synthesized `PermissionResult` reports the *blocked principal* as the
|
|
285
|
+
* subject, not the user — the user may well be fully granted, and an audit trail
|
|
286
|
+
* that named them would send an admin looking at the wrong relation.
|
|
287
|
+
*/
|
|
288
|
+
function applyPrincipalBlock(client, ctx) {
|
|
289
|
+
const { args, mode, blocked } = ctx;
|
|
290
|
+
const inner = applyPermissionMode(client, false, {
|
|
291
|
+
namespace: args.check.namespace,
|
|
292
|
+
object: args.check.object,
|
|
293
|
+
relation: opl_js_1.RELATION_BLOCKED_SUBJECTS,
|
|
294
|
+
subjectId: blocked.subject,
|
|
295
|
+
blockReason: "explicit_block",
|
|
296
|
+
modeOverride: mode,
|
|
297
|
+
activityAttributes: {
|
|
298
|
+
...args.activityAttributes,
|
|
299
|
+
blockedPrincipal: blocked.subject,
|
|
300
|
+
blockedPrincipalLevel: blocked.level,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
const result = {
|
|
304
|
+
allowed: false,
|
|
305
|
+
checkedAt: new Date().toISOString(),
|
|
306
|
+
check: {
|
|
307
|
+
namespace: args.check.namespace,
|
|
308
|
+
object: args.check.object,
|
|
309
|
+
relation: opl_js_1.RELATION_BLOCKED_SUBJECTS,
|
|
310
|
+
subjectId: blocked.subject,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
const activityAttributes = {
|
|
314
|
+
...inner.activityAttributes,
|
|
315
|
+
blockedPrincipal: blocked.subject,
|
|
316
|
+
blockedPrincipalLevel: blocked.level,
|
|
317
|
+
};
|
|
318
|
+
return inner.kind === "observe"
|
|
319
|
+
? { kind: "observe", result, mode: "observe", activityAttributes }
|
|
320
|
+
: { kind: "deny", result, mode: "enforce", activityAttributes };
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Gate a shell tool by decomposing its command into the program/builtin words
|
|
324
|
+
* that will run and checking each as `ShellTool:<word>#use`, on top of the
|
|
325
|
+
* existing top-level tool check.
|
|
326
|
+
*
|
|
327
|
+
* Aggregation: **any deny → deny** (naming the offending word); else any observe
|
|
328
|
+
* → observe (pass through, per-word `permission.observe_deny` events recorded);
|
|
329
|
+
* else allow. A too-complex / unparseable command is **fail-closed** in enforce
|
|
330
|
+
* (deny + alert) and logged-then-passed in observe. A parser-unavailable or
|
|
331
|
+
* infrastructure error is **fail-open** (top-level decision only) — availability
|
|
332
|
+
* must not hinge on the parser.
|
|
333
|
+
*/
|
|
334
|
+
async function decomposeAndCheck(client, args) {
|
|
335
|
+
const top = await checkAndDecide(client, args.check, {
|
|
336
|
+
activityAttributes: args.activityAttributes,
|
|
158
337
|
modeOverride: args.modeOverride,
|
|
159
338
|
});
|
|
339
|
+
// Top-level Bash denied (enforce) or unreachable → no point decomposing.
|
|
340
|
+
if (top.kind === "deny" || top.kind === "fail_open")
|
|
341
|
+
return top;
|
|
342
|
+
const mode = top.mode;
|
|
343
|
+
// The parser is written to be total, but this is a security boundary: if it
|
|
344
|
+
// ever throws, treat it as too-complex (fail-closed in enforce) rather than
|
|
345
|
+
// letting the exception bubble to the harness catch, which fails open.
|
|
346
|
+
let parsed;
|
|
347
|
+
try {
|
|
348
|
+
parsed = await (0, bash_parser_js_1.extractBashCommandWords)(args.shellCommand ?? "");
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
parsed = { words: [], tooComplex: true };
|
|
352
|
+
}
|
|
353
|
+
if (parsed.parserUnavailable) {
|
|
354
|
+
client.logger.warn("bash.parser_unavailable", {
|
|
355
|
+
harness: args.harness,
|
|
356
|
+
toolName: args.toolName,
|
|
357
|
+
note: "shell parser unavailable; falling back to top-level tool check",
|
|
358
|
+
});
|
|
359
|
+
client.logger.activity("bash.parser_unavailable", "skipped", {
|
|
360
|
+
attributes: { harness: args.harness, toolName: args.toolName },
|
|
361
|
+
});
|
|
362
|
+
return top;
|
|
363
|
+
}
|
|
364
|
+
// Too many distinct words to check safely in one batch — fail closed rather
|
|
365
|
+
// than send an oversized request that could error into a fail-open fallback.
|
|
366
|
+
if (parsed.words.length > MAX_SHELL_WORDS) {
|
|
367
|
+
parsed = { words: [], tooComplex: true };
|
|
368
|
+
}
|
|
369
|
+
const subject = { subjectId: args.check.subjectId, subjectSet: args.check.subjectSet };
|
|
370
|
+
const baseAttrs = buildDecisionAttributes(mode, subject.subjectId, subject.subjectSet);
|
|
371
|
+
// Decomposition attributes attached to activity so the shell breakdown is
|
|
372
|
+
// observable in real time — binary/builtin *names* only. The RAW command is
|
|
373
|
+
// NEVER put in activity; it is written only to the local debug log below,
|
|
374
|
+
// gated by
|
|
375
|
+
// `ORY_AGENT_DEBUG`.
|
|
376
|
+
const decompAttrs = {
|
|
377
|
+
shellWords: parsed.words,
|
|
378
|
+
shellCommandCount: parsed.words.length,
|
|
379
|
+
};
|
|
380
|
+
// Raw command → local debug log only (DebugLogger writes to stderr + the
|
|
381
|
+
// optional local log file, and only when ORY_AGENT_DEBUG=true). This keeps
|
|
382
|
+
// the raw command entirely out of always-filed activity.
|
|
383
|
+
client.logger.debug("bash.decomposition", {
|
|
384
|
+
harness: args.harness,
|
|
385
|
+
toolName: args.toolName,
|
|
386
|
+
command: args.shellCommand,
|
|
387
|
+
words: parsed.words,
|
|
388
|
+
tooComplex: parsed.tooComplex,
|
|
389
|
+
});
|
|
390
|
+
// Too-complex → fail-closed in enforce, logged-and-passed in observe.
|
|
391
|
+
if (parsed.tooComplex) {
|
|
392
|
+
if (mode === "enforce") {
|
|
393
|
+
client.logger.warn("bash.too_complex", {
|
|
394
|
+
harness: args.harness,
|
|
395
|
+
toolName: args.toolName,
|
|
396
|
+
note: "shell command could not be safely parsed; enforce mode denies",
|
|
397
|
+
});
|
|
398
|
+
return {
|
|
399
|
+
kind: "deny",
|
|
400
|
+
result: { allowed: false, checkedAt: new Date().toISOString(), check: args.check },
|
|
401
|
+
mode: "enforce",
|
|
402
|
+
activityAttributes: { ...baseAttrs, ...decompAttrs, shellTooComplex: true },
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
// observe: record the deny signal, then continue (a partial word set may
|
|
406
|
+
// still be present and worth logging below).
|
|
407
|
+
applyPermissionMode(client, false, {
|
|
408
|
+
namespace: args.check.namespace,
|
|
409
|
+
object: args.toolName,
|
|
410
|
+
relation: args.check.relation,
|
|
411
|
+
subjectId: subject.subjectId,
|
|
412
|
+
subjectSet: subject.subjectSet,
|
|
413
|
+
activityAttributes: { ...args.activityAttributes, ...decompAttrs, shellTooComplex: true },
|
|
414
|
+
modeOverride: args.modeOverride,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
// Check each distinct command word. Chunk into batches no larger than Keto's
|
|
418
|
+
// `max_batch_check_size` (see DEFAULT_SHELL_BATCH_SIZE) so a word-heavy command
|
|
419
|
+
// never trips the server's batch limit into a fail-open fallback.
|
|
420
|
+
const namespace = (0, tool_catalog_js_1.resolveShellCommandNamespace)();
|
|
421
|
+
const words = parsed.words;
|
|
422
|
+
const deniedWords = [];
|
|
423
|
+
const affectedWords = [];
|
|
424
|
+
let sawObserve = false;
|
|
425
|
+
const matchedBlocks = new Map();
|
|
426
|
+
if (words.length > 0) {
|
|
427
|
+
const batchSize = resolveShellBatchSize();
|
|
428
|
+
const results = [];
|
|
429
|
+
for (let start = 0; start < words.length; start += batchSize) {
|
|
430
|
+
const chunk = words.slice(start, start + batchSize);
|
|
431
|
+
// Route each word through the block-aware `access` permit (like the
|
|
432
|
+
// top-level check), so an explicit `ShellTool:<word>#blocked` overrides
|
|
433
|
+
// a `use` grant. With no block present, `access` == bare `use`.
|
|
434
|
+
const wordRelation = resolveCheckRelation("users");
|
|
435
|
+
const checks = chunk.map((word) => ({
|
|
436
|
+
namespace,
|
|
437
|
+
object: word,
|
|
438
|
+
relation: wordRelation,
|
|
439
|
+
...subject,
|
|
440
|
+
}));
|
|
441
|
+
try {
|
|
442
|
+
const [batch, blocked] = await Promise.all([
|
|
443
|
+
client.batchCheckPermissions(checks, {
|
|
444
|
+
activityAttributes: { ...args.activityAttributes, ...decompAttrs, shellDecomposition: true },
|
|
445
|
+
}),
|
|
446
|
+
findBlockedPrincipals(client, {
|
|
447
|
+
namespace,
|
|
448
|
+
objects: chunk,
|
|
449
|
+
principals: args.principals,
|
|
450
|
+
}),
|
|
451
|
+
]);
|
|
452
|
+
results.push(...batch.results);
|
|
453
|
+
for (const [word, principal] of blocked) {
|
|
454
|
+
if (!matchedBlocks.has(word))
|
|
455
|
+
matchedBlocks.set(word, principal);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
const oryErr = err;
|
|
460
|
+
if (mode === "enforce" && oryErr && CHECK_UNTRUSTED_CODES.has(oryErr.code)) {
|
|
461
|
+
return {
|
|
462
|
+
kind: "deny",
|
|
463
|
+
result: { allowed: false, checkedAt: new Date().toISOString(), check: args.check },
|
|
464
|
+
mode: "enforce",
|
|
465
|
+
activityAttributes: { ...baseAttrs, checkRejected: oryErr.code },
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
// Infrastructure error — fail open to the top-level decision.
|
|
469
|
+
client.logger.warn("bash.batch_check_failed", {
|
|
470
|
+
harness: args.harness,
|
|
471
|
+
toolName: args.toolName,
|
|
472
|
+
code: oryErr?.code,
|
|
473
|
+
note: "shell sub-tool batch check failed; falling back to top-level decision",
|
|
474
|
+
});
|
|
475
|
+
return top;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
for (let i = 0; i < words.length; i++) {
|
|
479
|
+
const word = words[i];
|
|
480
|
+
const allowed = results[i]?.allowed ?? false;
|
|
481
|
+
const blocked = matchedBlocks.get(word);
|
|
482
|
+
const decision = applyPermissionMode(client, blocked ? false : allowed, {
|
|
483
|
+
namespace,
|
|
484
|
+
object: word,
|
|
485
|
+
relation: blocked ? opl_js_1.RELATION_BLOCKED_SUBJECTS : opl_js_1.RELATION_USERS,
|
|
486
|
+
subjectId: blocked?.subject ?? subject.subjectId,
|
|
487
|
+
subjectSet: blocked ? undefined : subject.subjectSet,
|
|
488
|
+
activityAttributes: {
|
|
489
|
+
...args.activityAttributes,
|
|
490
|
+
shellWord: word,
|
|
491
|
+
...(blocked ? {
|
|
492
|
+
blockedPrincipal: blocked.subject,
|
|
493
|
+
blockedPrincipalLevel: blocked.level,
|
|
494
|
+
} : {}),
|
|
495
|
+
},
|
|
496
|
+
modeOverride: args.modeOverride,
|
|
497
|
+
blockReason: blocked ? "explicit_block" : undefined,
|
|
498
|
+
});
|
|
499
|
+
if (decision.kind === "deny") {
|
|
500
|
+
deniedWords.push(word);
|
|
501
|
+
affectedWords.push(word);
|
|
502
|
+
}
|
|
503
|
+
else if (decision.kind === "observe") {
|
|
504
|
+
affectedWords.push(word);
|
|
505
|
+
sawObserve = true;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const primaryBlockedWord = words.find((word) => matchedBlocks.has(word));
|
|
510
|
+
const primaryBlock = primaryBlockedWord ? matchedBlocks.get(primaryBlockedWord) : undefined;
|
|
511
|
+
// Aggregate. Enforce: any denied word blocks. Observe: pass through.
|
|
512
|
+
if (deniedWords.length > 0) {
|
|
513
|
+
// Disambiguate an explicit block from a plain missing grant on the first
|
|
514
|
+
// denied word, so the deny event and denial message say "explicitly blocked".
|
|
515
|
+
const primaryWord = primaryBlockedWord ?? deniedWords[0];
|
|
516
|
+
const blockReason = primaryBlock
|
|
517
|
+
? "explicit_block"
|
|
518
|
+
: await probeBlockReason(client, {
|
|
519
|
+
namespace,
|
|
520
|
+
object: primaryWord,
|
|
521
|
+
relation: opl_js_1.RELATION_USERS,
|
|
522
|
+
...subject,
|
|
523
|
+
});
|
|
524
|
+
return {
|
|
525
|
+
kind: "deny",
|
|
526
|
+
result: {
|
|
527
|
+
allowed: false,
|
|
528
|
+
checkedAt: new Date().toISOString(),
|
|
529
|
+
check: primaryBlock
|
|
530
|
+
? {
|
|
531
|
+
namespace,
|
|
532
|
+
object: primaryWord,
|
|
533
|
+
relation: opl_js_1.RELATION_BLOCKED_SUBJECTS,
|
|
534
|
+
subjectId: primaryBlock.subject,
|
|
535
|
+
}
|
|
536
|
+
: { namespace, object: primaryWord, relation: opl_js_1.RELATION_USERS, ...subject },
|
|
537
|
+
},
|
|
538
|
+
mode: "enforce",
|
|
539
|
+
activityAttributes: {
|
|
540
|
+
...baseAttrs,
|
|
541
|
+
...decompAttrs,
|
|
542
|
+
shellWord: primaryWord,
|
|
543
|
+
shellDeniedWords: deniedWords,
|
|
544
|
+
blockReason,
|
|
545
|
+
...(primaryBlock ? {
|
|
546
|
+
blockedPrincipal: primaryBlock.subject,
|
|
547
|
+
blockedPrincipalLevel: primaryBlock.level,
|
|
548
|
+
} : {}),
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
if (top.kind === "observe" || sawObserve || parsed.tooComplex) {
|
|
553
|
+
return {
|
|
554
|
+
kind: "observe",
|
|
555
|
+
result: top.result,
|
|
556
|
+
mode: "observe",
|
|
557
|
+
activityAttributes: {
|
|
558
|
+
...baseAttrs,
|
|
559
|
+
...decompAttrs,
|
|
560
|
+
...(parsed.tooComplex ? { shellTooComplex: true } : {}),
|
|
561
|
+
...(affectedWords.length > 0 ? { shellDeniedWords: affectedWords } : {}),
|
|
562
|
+
...(primaryBlock && primaryBlockedWord ? {
|
|
563
|
+
shellWord: primaryBlockedWord,
|
|
564
|
+
blockReason: "explicit_block",
|
|
565
|
+
blockedPrincipal: primaryBlock.subject,
|
|
566
|
+
blockedPrincipalLevel: primaryBlock.level,
|
|
567
|
+
} : {}),
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
// Allow: still surface the decomposition so an allowed command's breakdown is
|
|
572
|
+
// observable too (not just denials).
|
|
573
|
+
return {
|
|
574
|
+
kind: "allow",
|
|
575
|
+
result: top.result,
|
|
576
|
+
mode: top.mode,
|
|
577
|
+
activityAttributes: { ...top.activityAttributes, ...decompAttrs },
|
|
578
|
+
};
|
|
160
579
|
}
|
|
161
580
|
/**
|
|
162
581
|
* Run a permission check and resolve the configured mode against the
|
|
163
582
|
* result. Never throws — infrastructure errors surface as a typed
|
|
164
583
|
* `{ kind: "fail_open" }` decision, and auth-rejected check calls in
|
|
165
584
|
* enforce mode as `{ kind: "deny" }` (see
|
|
166
|
-
* {@link
|
|
585
|
+
* {@link CHECK_UNTRUSTED_CODES}).
|
|
167
586
|
*/
|
|
168
587
|
async function checkAndDecide(client, check, opts = {}) {
|
|
169
|
-
const
|
|
170
|
-
const
|
|
588
|
+
const resolved = (0, config_js_1.resolveConfig)();
|
|
589
|
+
const mode = opts.modeOverride ?? resolved.permissionMode;
|
|
590
|
+
const activityAttributes = buildDecisionAttributes(mode, check.subjectId, check.subjectSet);
|
|
591
|
+
// Route a `use` grant check through the block-aware `access` permit so
|
|
592
|
+
// explicit blocks are always respected.
|
|
593
|
+
const effectiveCheck = {
|
|
594
|
+
...check,
|
|
595
|
+
relation: resolveCheckRelation(check.relation),
|
|
596
|
+
};
|
|
171
597
|
let result;
|
|
172
598
|
try {
|
|
173
|
-
result = await client.checkPermission(
|
|
174
|
-
|
|
599
|
+
result = await client.checkPermission(effectiveCheck, {
|
|
600
|
+
activityAttributes: opts.activityAttributes,
|
|
175
601
|
});
|
|
176
602
|
}
|
|
177
603
|
catch (err) {
|
|
178
604
|
const oryErr = err;
|
|
179
|
-
if (mode === "enforce" && oryErr &&
|
|
605
|
+
if (mode === "enforce" && oryErr && CHECK_UNTRUSTED_CODES.has(oryErr.code)) {
|
|
180
606
|
// The check call itself was rejected for auth reasons — the
|
|
181
607
|
// result cannot be trusted, so enforce mode must not fail open.
|
|
182
608
|
client.logger.warn("permission.check_rejected", {
|
|
@@ -190,23 +616,143 @@ async function checkAndDecide(client, check, opts = {}) {
|
|
|
190
616
|
kind: "deny",
|
|
191
617
|
result: { allowed: false, checkedAt: new Date().toISOString(), check },
|
|
192
618
|
mode: "enforce",
|
|
193
|
-
|
|
619
|
+
activityAttributes: { ...activityAttributes, checkRejected: oryErr.code },
|
|
194
620
|
};
|
|
195
621
|
}
|
|
196
|
-
return { kind: "fail_open", error: oryErr, mode,
|
|
622
|
+
return { kind: "fail_open", error: oryErr, mode, activityAttributes };
|
|
197
623
|
}
|
|
624
|
+
// Report the decision against the caller's original check (relation `use`),
|
|
625
|
+
// not the internal permit rewrite, so callers see the coordinates they passed.
|
|
626
|
+
result = { ...result, check };
|
|
627
|
+
// On a deny, disambiguate "explicitly blocked" from "never granted" so the
|
|
628
|
+
// audit trail and denial messaging can tell them apart. Best-effort.
|
|
629
|
+
const blockReason = result.allowed
|
|
630
|
+
? undefined
|
|
631
|
+
: await probeBlockReason(client, check);
|
|
198
632
|
const inner = applyPermissionMode(client, result.allowed, {
|
|
199
633
|
namespace: check.namespace,
|
|
200
634
|
object: check.object,
|
|
201
635
|
relation: check.relation,
|
|
202
636
|
subjectId: check.subjectId,
|
|
203
637
|
subjectSet: check.subjectSet,
|
|
204
|
-
|
|
638
|
+
activityAttributes: opts.activityAttributes,
|
|
205
639
|
modeOverride: opts.modeOverride,
|
|
640
|
+
blockReason,
|
|
206
641
|
});
|
|
207
642
|
if (inner.kind === "allow")
|
|
208
|
-
return { kind: "allow", result, mode: inner.mode,
|
|
643
|
+
return { kind: "allow", result, mode: inner.mode, activityAttributes: inner.activityAttributes };
|
|
209
644
|
if (inner.kind === "observe")
|
|
210
|
-
return { kind: "observe", result, mode: "observe",
|
|
211
|
-
return { kind: "deny", result, mode: "enforce",
|
|
645
|
+
return { kind: "observe", result, mode: "observe", activityAttributes: inner.activityAttributes };
|
|
646
|
+
return { kind: "deny", result, mode: "enforce", activityAttributes: inner.activityAttributes };
|
|
647
|
+
}
|
|
648
|
+
const PROJECT_BLOCK_SUBJECT = {
|
|
649
|
+
subjectSet: {
|
|
650
|
+
namespace: opl_js_1.PERMISSION_MODE_NAMESPACE,
|
|
651
|
+
object: opl_js_1.PROJECT_POSTURE_OBJECT,
|
|
652
|
+
relation: opl_js_1.RELATION_ENFORCED_SUBJECTS,
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
/**
|
|
656
|
+
* Check whether the project or any acting machine principal is explicitly blocked from this
|
|
657
|
+
* tool, and name the one that is.
|
|
658
|
+
*
|
|
659
|
+
* Project checks are sent separately from the credential-, session-, and
|
|
660
|
+
* spawn-level forms from `subject.ts`. Older brokers do not understand project
|
|
661
|
+
* tool blocks; isolating that read keeps their rejection from suppressing the
|
|
662
|
+
* established machine-principal controls during an asynchronous rollout.
|
|
663
|
+
*
|
|
664
|
+
* This can only ever *remove* access: the user's `access` permit remains the
|
|
665
|
+
* allow decision, so a project that has written no principal blocks behaves
|
|
666
|
+
* exactly as it did before this pass existed.
|
|
667
|
+
*
|
|
668
|
+
* Each read fails open independently. The merged result is empty only when no
|
|
669
|
+
* successful scope reports a block; a project-read failure cannot erase a
|
|
670
|
+
* machine hit, and a machine-read failure cannot erase a project hit.
|
|
671
|
+
*/
|
|
672
|
+
async function findBlockedPrincipal(client, args) {
|
|
673
|
+
return (await findBlockedPrincipals(client, {
|
|
674
|
+
namespace: args.namespace,
|
|
675
|
+
objects: [args.object],
|
|
676
|
+
principals: args.principals,
|
|
677
|
+
})).get(args.object);
|
|
678
|
+
}
|
|
679
|
+
async function findBlockedPrincipals(client, args) {
|
|
680
|
+
const p = args.principals ?? {};
|
|
681
|
+
const machineCandidates = [
|
|
682
|
+
{ level: "agent", ref: (0, subject_js_1.resolveAgentSubject)(client) },
|
|
683
|
+
{ level: "agent_session", ref: (0, subject_js_1.resolveAgentSessionSubject)(client, p.sessionId) },
|
|
684
|
+
{ level: "subagent", ref: (0, subject_js_1.resolveSubAgentSubject)(p.subAgentClientId) },
|
|
685
|
+
{
|
|
686
|
+
level: "subagent_spawn",
|
|
687
|
+
ref: p.subAgentType
|
|
688
|
+
? (0, subject_js_1.resolveSubAgentSpawnSubject)(client, {
|
|
689
|
+
subAgentClientId: p.subAgentClientId,
|
|
690
|
+
subAgentType: p.subAgentType,
|
|
691
|
+
perSpawnId: p.perSpawnId,
|
|
692
|
+
sessionId: p.sessionId,
|
|
693
|
+
})
|
|
694
|
+
: undefined,
|
|
695
|
+
},
|
|
696
|
+
];
|
|
697
|
+
const presentMachines = machineCandidates.filter((c) => c.ref !== undefined);
|
|
698
|
+
const read = async (scope, candidates) => {
|
|
699
|
+
const found = new Map();
|
|
700
|
+
if (candidates.length === 0)
|
|
701
|
+
return found;
|
|
702
|
+
try {
|
|
703
|
+
const batch = await client.batchCheckPermissions(args.objects.flatMap((object) => candidates.map(({ ref }) => ({
|
|
704
|
+
namespace: args.namespace,
|
|
705
|
+
object,
|
|
706
|
+
relation: opl_js_1.RELATION_BLOCKED_SUBJECTS,
|
|
707
|
+
...ref,
|
|
708
|
+
}))), { activityAttributes: { source: "principal_block", scope } });
|
|
709
|
+
for (const [i, result] of batch.results.entries()) {
|
|
710
|
+
const object = args.objects[Math.floor(i / candidates.length)];
|
|
711
|
+
const candidate = candidates[i % candidates.length];
|
|
712
|
+
if (object && result.allowed && !result.error && candidate && !found.has(object)) {
|
|
713
|
+
found.set(object, {
|
|
714
|
+
subject: candidate.level === "project" ? "project" : (0, subject_js_1.subjectLabel)(candidate.ref),
|
|
715
|
+
level: candidate.level,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
catch (err) {
|
|
721
|
+
client.logger.debug("permission.principal_block.failed", {
|
|
722
|
+
scope,
|
|
723
|
+
note: `${scope} principal block read failed; falling open for that scope`,
|
|
724
|
+
message: err instanceof Error ? err.message : String(err),
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
return found;
|
|
728
|
+
};
|
|
729
|
+
const [projectBlocks, machineBlocks] = await Promise.all([
|
|
730
|
+
read("project", [{ level: "project", ref: PROJECT_BLOCK_SUBJECT }]),
|
|
731
|
+
read("machine", presentMachines),
|
|
732
|
+
]);
|
|
733
|
+
const blocked = new Map(machineBlocks);
|
|
734
|
+
for (const object of args.objects) {
|
|
735
|
+
const project = projectBlocks.get(object);
|
|
736
|
+
if (project)
|
|
737
|
+
blocked.set(object, project);
|
|
738
|
+
}
|
|
739
|
+
return blocked;
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* After a denied tool-access check, determine whether the deny was an explicit
|
|
743
|
+
* block or a plain missing grant by probing the `blocked` relation for the same
|
|
744
|
+
* subject/object. Uses `checkPermission` (not a literal tuple read) so a block
|
|
745
|
+
* inherited through a subject-set / group is caught. Best-effort: any error
|
|
746
|
+
* yields `not_granted` and never changes the decision.
|
|
747
|
+
*/
|
|
748
|
+
async function probeBlockReason(client, check) {
|
|
749
|
+
try {
|
|
750
|
+
const probe = await client.checkPermission({ ...check, relation: opl_js_1.RELATION_BLOCKED_SUBJECTS }, { activityAttributes: { source: "block_probe" } });
|
|
751
|
+
return probe.allowed ? "explicit_block" : "not_granted";
|
|
752
|
+
}
|
|
753
|
+
catch {
|
|
754
|
+
// The probe is diagnostic only; a failure must not upgrade or downgrade
|
|
755
|
+
// the deny that already stands.
|
|
756
|
+
return "not_granted";
|
|
757
|
+
}
|
|
212
758
|
}
|