@orangecheck/agent-core 1.1.0 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangecheck/agent-core",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "OC Agent canonical messages, envelope formats (delegation/action/revocation), scope grammar, and verification. See https://github.com/orangecheck/oc-agent-protocol.",
5
5
  "keywords": [
6
6
  "bitcoin",
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { ScopeNotGrantedError, assertScopeGranted } from './assert-scope.js';
4
+ import type { ScopesEncryptedEnvelope } from './types.js';
5
+
6
+ const SEALED = {
7
+ v: 1,
8
+ alg: 'x25519-xchacha20poly1305',
9
+ recipients: [{ device_id: 'dev1', ct: 'AA' }],
10
+ ct: 'BB',
11
+ nonce: 'CC',
12
+ } as unknown as ScopesEncryptedEnvelope;
13
+
14
+ function reasonOf(fn: () => void): string {
15
+ try {
16
+ fn();
17
+ } catch (e) {
18
+ return e instanceof ScopeNotGrantedError ? e.reason : `wrong-error:${String(e)}`;
19
+ }
20
+ return 'no-throw';
21
+ }
22
+
23
+ describe('assertScopeGranted', () => {
24
+ it('permits an exact grant and a sub-scope of one', () => {
25
+ expect(() =>
26
+ assertScopeGranted({ scopes: ['mcp:invoke(server=s,tool=t)'] }, 'mcp:invoke(server=s,tool=t)', 'f')
27
+ ).not.toThrow();
28
+ expect(() =>
29
+ assertScopeGranted({ scopes: ['mcp:invoke(server=s)'] }, 'mcp:invoke(server=s,tool=t)', 'f')
30
+ ).not.toThrow();
31
+ });
32
+
33
+ it('refuses a scope outside the grant', () => {
34
+ expect(
35
+ reasonOf(() => assertScopeGranted({ scopes: ['mcp:invoke(server=a)'] }, 'mcp:invoke(server=b)', 'f'))
36
+ ).toBe('not_subscope');
37
+ });
38
+
39
+ // The whole reason this module exists. Five adapters open-coded
40
+ // `(scopes ?? []).map(parseScope)`, so a v1.2 private delegation — where
41
+ // `scopes` is absent BECAUSE it is sealed — reported "not a sub-scope of
42
+ // any granted scope". The refusal was right; the diagnosis sent the
43
+ // integrator to audit a scope string that was very possibly correct.
44
+ it('names encrypted scopes as such rather than blaming the scope string', () => {
45
+ const d = { scopes_encrypted: SEALED };
46
+ expect(reasonOf(() => assertScopeGranted(d, 'mcp:invoke(server=s)', 'f'))).toBe('scopes_encrypted');
47
+ try {
48
+ assertScopeGranted(d, 'mcp:invoke(server=s)', 'f');
49
+ } catch (e) {
50
+ expect((e as Error).message).toMatch(/decryptPrivateScopes/);
51
+ expect((e as Error).message).not.toMatch(/not a sub-scope/);
52
+ }
53
+ });
54
+
55
+ it('treats absent and empty scopes as granting nothing, not everything', () => {
56
+ expect(reasonOf(() => assertScopeGranted({}, 'x:y', 'f'))).toBe('no_scopes');
57
+ expect(reasonOf(() => assertScopeGranted({ scopes: [] }, 'x:y', 'f'))).toBe('no_scopes');
58
+ });
59
+
60
+ // A sealed delegation that ALSO carries a scopes array must not be
61
+ // evaluated against that array: the fields are exclusive per the spec, so
62
+ // a populated `scopes` alongside `scopes_encrypted` is a malformed
63
+ // envelope and possibly an attempt to present a benign readable grant
64
+ // while the real one stays hidden.
65
+ it('refuses on the sealed field even when a scopes array is also present', () => {
66
+ expect(
67
+ reasonOf(() =>
68
+ assertScopeGranted({ scopes: ['x:y'], scopes_encrypted: SEALED }, 'x:y', 'f')
69
+ )
70
+ ).toBe('scopes_encrypted');
71
+ });
72
+ });
@@ -0,0 +1,80 @@
1
+ import { hasPrivateScopes } from './private-scope.js';
2
+ import { isSubScope, parseScope } from './scope.js';
3
+ import type { ScopesEncryptedEnvelope } from './types.js';
4
+
5
+ /**
6
+ * The minimum an adapter needs to make the authorization decision. Kept
7
+ * structural rather than importing `Delegation` so `stampX` helpers can accept
8
+ * the narrower shapes they already declare.
9
+ */
10
+ export interface ScopeBearingDelegation {
11
+ scopes?: string[];
12
+ scopes_encrypted?: ScopesEncryptedEnvelope;
13
+ }
14
+
15
+ /** Distinguishes the three reasons a stamp can be refused. */
16
+ export class ScopeNotGrantedError extends Error {
17
+ readonly reason: 'not_subscope' | 'scopes_encrypted' | 'no_scopes';
18
+ constructor(reason: ScopeNotGrantedError['reason'], message: string) {
19
+ super(message);
20
+ this.name = 'ScopeNotGrantedError';
21
+ this.reason = reason;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Refuse unless `scopeExercised` is a sub-scope of something the delegation
27
+ * actually grants. Every `agent-*` adapter's pre-flight check routes here.
28
+ *
29
+ * The check itself is unchanged and still fails closed. What this adds is
30
+ * telling the caller WHICH of three things went wrong, because the five
31
+ * adapters each open-coded `(delegation.scopes ?? []).map(parseScope)` and so
32
+ * reported all three as the same thing:
33
+ *
34
+ * - **not_subscope** — scopes are readable and this one is not among them.
35
+ * The integrator's own mistake, and the only case the old message fit.
36
+ * - **scopes_encrypted** — v1.2 private mode. `scopes` is absent *because it
37
+ * is sealed to a device key*, so the adapter cannot evaluate the request at
38
+ * all. `?? []` collapsed this into "not a sub-scope of any granted scope",
39
+ * which sends the integrator to audit a scope string that may well be
40
+ * correct, when what they need is `decryptPrivateScopes` and to pass the
41
+ * recovered list. Refusing is right; misnaming why is not.
42
+ * - **no_scopes** — neither field present. A delegation granting nothing.
43
+ *
44
+ * Fail-closed is not a judgement call here: an over-broad stamp is a signed,
45
+ * content-addressed authorization artifact that a verifier will accept.
46
+ */
47
+ export function assertScopeGranted(
48
+ delegation: ScopeBearingDelegation,
49
+ scopeExercised: string,
50
+ fnName: string
51
+ ): void {
52
+ if (hasPrivateScopes(delegation)) {
53
+ throw new ScopeNotGrantedError(
54
+ 'scopes_encrypted',
55
+ `${fnName}: this delegation uses v1.2 private scopes — \`scopes_encrypted\` is ` +
56
+ `set and \`scopes\` is absent, so the granted set cannot be read here. ` +
57
+ `Decrypt it with decryptPrivateScopes() using a matching device key and pass ` +
58
+ `the recovered scopes as \`delegation.scopes\`. Refusing to stamp ` +
59
+ `\`${scopeExercised}\` against an unreadable grant.`
60
+ );
61
+ }
62
+
63
+ const scopes = delegation.scopes;
64
+ if (scopes === undefined || scopes.length === 0) {
65
+ throw new ScopeNotGrantedError(
66
+ 'no_scopes',
67
+ `${fnName}: delegation grants no scopes, so \`${scopeExercised}\` cannot be ` +
68
+ `exercised. An absent or empty \`scopes\` means nothing is granted, never everything.`
69
+ );
70
+ }
71
+
72
+ const exercised = parseScope(scopeExercised);
73
+ if (!scopes.map(parseScope).some((g) => isSubScope(exercised, g))) {
74
+ throw new ScopeNotGrantedError(
75
+ 'not_subscope',
76
+ `${fnName}: scope_exercised (${scopeExercised}) is not a sub-scope of any granted ` +
77
+ `scope (${scopes.join(', ')})`
78
+ );
79
+ }
80
+ }
package/src/index.ts CHANGED
@@ -79,3 +79,6 @@ export type {
79
79
  UnsealScopesInput,
80
80
  UnsealedScopes,
81
81
  } from './private-scope.js';
82
+
83
+ export { assertScopeGranted, ScopeNotGrantedError } from './assert-scope.js';
84
+ export type { ScopeBearingDelegation } from './assert-scope.js';