@tiertwo/shared 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tier Two
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # @tiertwo/shared
2
+
3
+ Internal shared types and pure, zero-dependency helpers for the `@tiertwo/*` packages —
4
+ the single source of truth for token/claim/request shapes (so the backend, SDK, and MCP
5
+ server never drift), the device-auth header constants, and `canonicalJSON` (the
6
+ deterministic serialization the device signs and the backend verifies over).
7
+
8
+ **This is not a stable public API.** It's published only because
9
+ [`@tiertwo/sdk`](https://www.npmjs.com/package/@tiertwo/sdk) and
10
+ [`@tiertwo/mcp`](https://www.npmjs.com/package/@tiertwo/mcp) depend on it at runtime.
11
+ Use those packages; import from `@tiertwo/shared` directly at your own risk — its
12
+ surface may change without a major-version bump.
13
+
14
+ See [`docs/ARCHITECTURE.md`](https://github.com/Jordan-M/verified-human/blob/master/docs/ARCHITECTURE.md)
15
+ §6 (token) and §7 (request flow).
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ // Unit suite for the client-metadata sanitizer (Stage 4.9) — the single
2
+ // validator for the client-claimed X-VH-Client / pair-init `client` payloads
3
+ // that web, mcp, and the gateway all import.
4
+ import { describe, it, expect } from "vitest";
5
+ import { CLIENT_META_MAX, sanitizeClientMeta } from "./index.js";
6
+ describe("sanitizeClientMeta", () => {
7
+ it("keeps well-typed, length-capped fields and trims whitespace", () => {
8
+ expect(sanitizeClientMeta({
9
+ product: " claude-code ",
10
+ productVersion: "2.1.3",
11
+ runtime: "local",
12
+ platform: "darwin 25.5.0",
13
+ })).toEqual({
14
+ product: "claude-code",
15
+ productVersion: "2.1.3",
16
+ runtime: "local",
17
+ platform: "darwin 25.5.0",
18
+ });
19
+ });
20
+ it("drops invalid fields field-wise, keeping the valid remainder", () => {
21
+ expect(sanitizeClientMeta({
22
+ product: "tiertwo-gateway",
23
+ productVersion: 42, // wrong type — dropped
24
+ runtime: "docker", // outside the sanitized set — dropped
25
+ platform: "p".repeat(CLIENT_META_MAX.platform + 1), // oversized — dropped
26
+ })).toEqual({ product: "tiertwo-gateway" });
27
+ });
28
+ it("drops oversized strings rather than truncating", () => {
29
+ expect(sanitizeClientMeta({ product: "a".repeat(CLIENT_META_MAX.product + 1) })).toBeNull();
30
+ expect(sanitizeClientMeta({ product: "a".repeat(CLIENT_META_MAX.product) })).toEqual({ product: "a".repeat(CLIENT_META_MAX.product) });
31
+ });
32
+ it("ignores unknown keys — only the four known fields survive", () => {
33
+ expect(sanitizeClientMeta({ runtime: "cloud", attested: true, extra: "x" })).toEqual({ runtime: "cloud" });
34
+ });
35
+ it("returns null on garbage, empties, and non-objects", () => {
36
+ expect(sanitizeClientMeta(null)).toBeNull();
37
+ expect(sanitizeClientMeta(undefined)).toBeNull();
38
+ expect(sanitizeClientMeta("claude-code")).toBeNull();
39
+ expect(sanitizeClientMeta(42)).toBeNull();
40
+ expect(sanitizeClientMeta([{ product: "x" }])).toBeNull(); // arrays rejected
41
+ expect(sanitizeClientMeta({})).toBeNull();
42
+ expect(sanitizeClientMeta({ product: " " })).toBeNull(); // whitespace-only
43
+ expect(sanitizeClientMeta({ runtime: "LOCAL" })).toBeNull(); // case-sensitive set
44
+ });
45
+ });
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Action — an arbitrary scoped capability an agent may be authorized for (an open
3
+ * validated string, not a closed enum). `isValidAction` is the single source of
4
+ * truth for validity; `WELL_KNOWN_ACTIONS` is a discoverability hint only (e.g. for
5
+ * MCP tool descriptions), never an allow-list.
6
+ */
7
+ export type Action = string;
8
+ export declare const WELL_KNOWN_ACTIONS: readonly ["create_account", "deploy"];
9
+ export declare function isValidAction(s: unknown): s is Action;
10
+ /**
11
+ * Resource — an optional scope qualifier for an action (a service name, repo,
12
+ * path, …). Present only for scoped actions. Case-insensitive.
13
+ */
14
+ export type Resource = string;
15
+ export declare function isValidResource(s: unknown): s is Resource;
16
+ /**
17
+ * Assurance tier of the human behind an action, ordered weakest → strongest:
18
+ * - "sso": an authenticated org employee (SSO); identity not independently proofed.
19
+ * - "identity_proofed": a Stripe-Identity-verified real human (the consumer bar).
20
+ * `meetsAuthLevel` enforces an "at least this tier" requirement.
21
+ */
22
+ export declare const AUTH_LEVELS: readonly ["sso", "identity_proofed"];
23
+ export type AuthLevel = (typeof AUTH_LEVELS)[number];
24
+ export declare function authLevelRank(l: AuthLevel): number;
25
+ export declare function meetsAuthLevel(have: unknown, need: AuthLevel): boolean;
26
+ export declare function isAuthLevel(s: unknown): s is AuthLevel;
27
+ /**
28
+ * Device-trust ladder (Stage 4.12), ordered weakest → strongest. LINEAR:
29
+ * - "paired": the device authenticated (exists, not revoked) — key possession,
30
+ * the floor every caller clears.
31
+ * - "managed": + the device belongs to an organization (admins can see, gate,
32
+ * and revoke it).
33
+ * - "verified": + the owning human is identity-proofed (Stripe Identity).
34
+ * A consumer device with a verified owner is still just "paired" — the ladder
35
+ * measures how GOVERNED the worker is, and an ungoverned device tops out at the
36
+ * bottom rung by design. Distinct axis from AUTH_LEVELS ("how proofed is the
37
+ * human") — see agent-directory-roadmap §4e; do not conflate them.
38
+ */
39
+ export declare const DEVICE_TRUST_LEVELS: readonly ["paired", "managed", "verified"];
40
+ export type DeviceTrustLevel = (typeof DEVICE_TRUST_LEVELS)[number];
41
+ export declare function deviceTrustRank(l: DeviceTrustLevel): number;
42
+ export declare function meetsDeviceTrust(have: unknown, need: DeviceTrustLevel): boolean;
43
+ export declare function isDeviceTrustLevel(s: unknown): s is DeviceTrustLevel;
44
+ /**
45
+ * Why a call was refused (Stage 4.10) — the SignEvent.denyReason vocabulary.
46
+ * The server adjudicates the first three (authorize route); the last three are
47
+ * gateway-local policy results that reach the ledger via /api/gateway/telemetry.
48
+ * That split is load-bearing: a gateway may only CLAIM the local subset, so deny
49
+ * provenance stays derivable with no extra column.
50
+ */
51
+ export declare const DENY_REASONS: readonly ["access_required", "not_verified", "insufficient_auth_level", "policy_deny", "no_rule", "unresolved_resource", "insufficient_device_trust", "approval_required"];
52
+ export type DenyReason = (typeof DENY_REASONS)[number];
53
+ export declare function isDenyReason(s: unknown): s is DenyReason;
54
+ /** The subset a gateway may claim via telemetry — server-adjudicated reasons are
55
+ * not claimable (the telemetry narrower rejects them). */
56
+ export declare const GATEWAY_LOCAL_DENY_REASONS: readonly ["no_rule", "unresolved_resource", "policy_deny"];
57
+ export type GatewayLocalDenyReason = (typeof GATEWAY_LOCAL_DENY_REASONS)[number];
58
+ export declare function isGatewayLocalDenyReason(s: unknown): s is GatewayLocalDenyReason;
59
+ /**
60
+ * SignEvent.action sentinel for denied calls that no rule mapped to a capability
61
+ * (no_rule / unresolved_resource) — self-describing, and never fabricates a
62
+ * capability name an admin might have authored. Display/filter only; appears on
63
+ * deny rows exclusively.
64
+ */
65
+ export declare function unmappedToolAction(tool: string): string;
66
+ /** Derived identity claims surfaced to the merchant + MCP get_identity. (§6, §11) */
67
+ export type Claims = {
68
+ verified_human: boolean;
69
+ /** ISO-3166 alpha-2, derived; null when unknown. */
70
+ country: string | null;
71
+ age_over_18: boolean;
72
+ };
73
+ /** Full merchant-facing JWT payload. (ARCHITECTURE §6) */
74
+ export type TokenPayload = {
75
+ /** Issuer, e.g. "https://trytiertwo.com". */
76
+ iss: string;
77
+ /** PAIRWISE/directed subject: base64url(HMAC(userId+aud)). (§18.3) */
78
+ sub: string;
79
+ /** DERIVED from auth_level === "identity_proofed"; kept for merchant convenience. */
80
+ verified_human: boolean;
81
+ country: string | null;
82
+ age_over_18: boolean;
83
+ device_id: string;
84
+ /** Merchant domain — SDK MUST validate. */
85
+ aud: string;
86
+ /** Validated string (isValidAction) — SDK MUST match its expected action. */
87
+ action: Action;
88
+ /** Present only for scoped actions — SDK matches it when it expects a resource. */
89
+ resource?: Resource;
90
+ /** Assurance tier — SDK MAY require a minimum via meetsAuthLevel. */
91
+ auth_level: AuthLevel;
92
+ /** UUID — merchant SHOULD dedupe (SDK ships a helper). */
93
+ jti: string;
94
+ /** Issued-at, unix seconds. */
95
+ iat: number;
96
+ /** Expiry, unix seconds (iat + 300). */
97
+ exp: number;
98
+ };
99
+ /** Endpoint class each device-signed body is bound to (checked by lib/deviceAuth). */
100
+ export type DevicePurpose = "identity" | "approve" | "approve_request" | "sign" | "access_request" | "gateway" | "gateway_credential" | "gateway_policy" | "gateway_telemetry" | "gateway_proxy";
101
+ /** Transport envelope present on EVERY device-signed body. */
102
+ export type DeviceAuthEnvelope = {
103
+ purpose: DevicePurpose;
104
+ /** Random 16B, base64url — one-time nonce. */
105
+ nonce: string;
106
+ /** Unix milliseconds. */
107
+ ts: number;
108
+ };
109
+ /** Application payload for the action-scoped endpoints. */
110
+ export type ScopedAction = {
111
+ domain: string;
112
+ action: Action;
113
+ resource?: Resource;
114
+ };
115
+ export type IdentityRequest = DeviceAuthEnvelope & {
116
+ purpose: "identity";
117
+ };
118
+ export type ApproveRequest = DeviceAuthEnvelope & {
119
+ purpose: "approve";
120
+ } & ScopedAction;
121
+ export type ApproveOOBRequest = DeviceAuthEnvelope & {
122
+ purpose: "approve_request";
123
+ } & ScopedAction;
124
+ export type SignTokenRequest = DeviceAuthEnvelope & {
125
+ purpose: "sign";
126
+ } & ScopedAction;
127
+ export type AccessRequest = DeviceAuthEnvelope & {
128
+ purpose: "access_request";
129
+ } & ScopedAction & {
130
+ reason?: string;
131
+ };
132
+ /**
133
+ * Application payload for a governed gateway tool call (agent-governance Stage 3a).
134
+ * Its OWN type — deliberately NOT ScopedAction: `backend`/`tool` are gateway-only
135
+ * routing/audit context (never part of the shared action-scoped payload), and the
136
+ * gateway — not the agent — sets `action`/`resource` from policy applied to the
137
+ * observed call. `reason` justifies the pending grant opened on access_required.
138
+ */
139
+ export type GatewayCall = {
140
+ /** Config backend id → SignEvent.domain (audit). */
141
+ backend: string;
142
+ /** Un-namespaced downstream tool name (audit context). */
143
+ tool: string;
144
+ action: Action;
145
+ resource?: Resource;
146
+ /** ≤500 chars — justification for the pending grant on access_required. */
147
+ reason?: string;
148
+ /**
149
+ * Set by the GATEWAY (never the agent) from its own policy `effect: "audit_only"`
150
+ * rule (agent-governance Stage 3b): record a SignEvent for this call and forward it
151
+ * WITHOUT requiring a grant/tier — reads are audited, never blocked. Absent ⇒ the
152
+ * normal gated authorizeMint path.
153
+ */
154
+ auditOnly?: boolean;
155
+ /**
156
+ * Set by the GATEWAY (never the agent) from the matched rule when its effect is
157
+ * "gate" (Stage 4.10) — mirrors auditOnly exactly. The server enforces
158
+ * meetsAuthLevel AFTER authorizeMint allows and BEFORE the allow SignEvent is
159
+ * written, so a tier-blocked call records a deny and never an allow. Same trust
160
+ * class as auditOnly/action/resource: the server cannot re-derive it (rule
161
+ * selection needs the observed call args, which never leave the data plane).
162
+ * Illegal in combination with auditOnly (audit_only rules carry no tier gate).
163
+ */
164
+ requiredAuthLevel?: AuthLevel;
165
+ /**
166
+ * Set by the GATEWAY (never the agent) from the matched gate rule (Stage 4.12) —
167
+ * mirrors requiredAuthLevel exactly, same trust class. The server enforces
168
+ * meetsDeviceTrust against facts it already holds (device org, owner
169
+ * verification) BEFORE authorizeMint, so a trust-blocked call never opens a
170
+ * pending Grant that could be approved yet never take effect. Illegal in
171
+ * combination with auditOnly.
172
+ */
173
+ requiredDeviceTrust?: DeviceTrustLevel;
174
+ /**
175
+ * Set by the GATEWAY (never the agent) from the matched gate rule (Stage 4.13) —
176
+ * same trust class and threading as requiredAuthLevel/requiredDeviceTrust.
177
+ * True ⇒ authorizeMint SKIPS the standing-grant arm: only a fresh ad-hoc
178
+ * Approval satisfies the call; without one the deny is approval_required and
179
+ * opens an ApprovalRequest. Illegal in combination with auditOnly.
180
+ */
181
+ requireFreshApproval?: boolean;
182
+ };
183
+ export type GatewayRequest = DeviceAuthEnvelope & {
184
+ purpose: "gateway";
185
+ } & GatewayCall;
186
+ /**
187
+ * A gateway-local deny, reported best-effort to POST /api/gateway/telemetry
188
+ * (Stage 4.10). action/resource are legal ONLY for policy_deny (the denying rule
189
+ * names them — action is required there); for no_rule / unresolved_resource no
190
+ * capability mapping exists and the server records the unmappedToolAction
191
+ * sentinel instead. Since Stage 4.11 telemetry bodies are a discriminated
192
+ * union — `kind` selects the report class.
193
+ */
194
+ export type GatewayDenyReport = {
195
+ kind: "deny";
196
+ /** Route-table backend id → SignEvent.domain. */
197
+ backend: string;
198
+ /** Raw un-namespaced downstream tool name. */
199
+ tool: string;
200
+ denyReason: GatewayLocalDenyReason;
201
+ action?: Action;
202
+ resource?: Resource;
203
+ };
204
+ /**
205
+ * Cumulative session totals (Stage 4.11) — MONOTONE within one session: every
206
+ * report carries the running totals since session start, so open, heartbeat,
207
+ * flush, and end are ONE message shape and a lost report costs nothing once the
208
+ * next arrives. All non-negative integers ≤ SESSION_COUNTER_MAX. Invariants the
209
+ * gateway maintains (the server does NOT cross-check — counters are claims):
210
+ * toolCalls = allowed + denied; errors ⊆ allowed (only forwarded calls can
211
+ * error); latency is measured around connection.callTool only.
212
+ */
213
+ export type GatewaySessionCounters = {
214
+ /** Decided governed calls = allowed + denied (unknown_tool is protocol misuse, not counted). */
215
+ toolCalls: number;
216
+ allowed: number;
217
+ denied: number;
218
+ /** Failed FORWARDS: downstream isError results (incl. repair-flips), link_required, backend_unavailable. */
219
+ errors: number;
220
+ /** Wall-clock ms around connection.callTool, forwarded calls only. */
221
+ latencyMsSum: number;
222
+ latencyMsMax: number;
223
+ };
224
+ /** The only STORED end reason. "lost" is derived read-side from a stale
225
+ * heartbeat (lib/agentSessions.sessionStatusOf) and is never on the wire. */
226
+ export type GatewaySessionEndReason = "shutdown";
227
+ /**
228
+ * Self-reported device posture (Stage 4.12), carried on session reports.
229
+ * AFFIRMATIVE KNOWLEDGE ONLY: a fact the collector can't determine is ABSENT,
230
+ * never "unknown" — the server and UI treat absence as "not reported". Every
231
+ * field is CLAIMED (a compromised host lies about its own posture); the value
232
+ * is inventory/compliance visibility, never a security control, and it is
233
+ * NEVER an authorization input. Rendered with an explicit "self-reported"
234
+ * label — no attestation theater (agent-directory-roadmap §4e).
235
+ */
236
+ export type PostureFlag = "enabled" | "disabled";
237
+ export type GatewayPosture = {
238
+ /** os.version() (e.g. "Darwin Kernel Version 25.5.0: …"), trimmed + capped. */
239
+ osVersion?: string;
240
+ /** FileVault (darwin, fdesetup) / dm-crypt device present (linux, lsblk). */
241
+ diskEncryption?: PostureFlag;
242
+ /** UEFI SecureBoot efivar (linux). Absent on darwin/win32 in the v1 matrix. */
243
+ secureBoot?: PostureFlag;
244
+ };
245
+ export declare const POSTURE_MAX: {
246
+ readonly osVersion: 64;
247
+ };
248
+ /**
249
+ * A gateway session report (Stage 4.11) — one shape for open/heartbeat/flush/
250
+ * end. The server upserts by (deviceId, sessionId): the first report creates
251
+ * the row, later reports overwrite counters (refusing regressions — two
252
+ * in-flight POSTs may land out of order), a report carrying `endedReason`
253
+ * closes it (sticky). Everything here is device-CLAIMED except what the server
254
+ * stamps itself (lastHeartbeatAt = receive time; startedAt is clamped to it).
255
+ */
256
+ export type GatewaySessionReport = {
257
+ kind: "session";
258
+ /** Gateway-generated UUID (crypto.randomUUID); scoped to the device by the upsert key. */
259
+ sessionId: string;
260
+ /** Unix ms, client-claimed — the server clamps future skew to receive time. */
261
+ startedAt: number;
262
+ /** Connected MCP host per clientInfo (e.g. "claude-code") — the per-connection
263
+ * attribution that fixes the 4.9 metadata flap; Device.product stays the gateway. */
264
+ clientProduct?: string;
265
+ clientProductVersion?: string;
266
+ /** Self-reported device posture (Stage 4.12) — collected once at gateway
267
+ * startup, absent until collection completes (the clientProduct lazy-capture
268
+ * pattern; the idempotent overwrite absorbs the gap). */
269
+ posture?: GatewayPosture;
270
+ /** Present ⇒ this report closes the session. */
271
+ endedReason?: GatewaySessionEndReason;
272
+ } & GatewaySessionCounters;
273
+ /** Postgres Int4 ceiling — the gateway accumulator saturates here and the
274
+ * server-side narrower rejects beyond it, so the DB column can never overflow. */
275
+ export declare const SESSION_COUNTER_MAX = 2147483647;
276
+ export type GatewayTelemetryReport = GatewayDenyReport | GatewaySessionReport;
277
+ export type GatewayTelemetryRequest = DeviceAuthEnvelope & {
278
+ purpose: "gateway_telemetry";
279
+ } & GatewayTelemetryReport;
280
+ /** HTTP header names for device-authed requests. (§7) */
281
+ export declare const DEVICE_HEADER: "X-VH-Device";
282
+ export declare const SIGNATURE_HEADER: "X-VH-Signature";
283
+ /** Client-reported agent metadata (Stage 4.9). INFORMATIONAL ONLY — the values
284
+ * originate from the client and are never an authorization input. */
285
+ export type DeviceClientMeta = {
286
+ product?: string;
287
+ productVersion?: string;
288
+ runtime?: "local" | "cloud";
289
+ platform?: string;
290
+ };
291
+ /** Unsigned metadata header on device-signed requests. Deliberately NOT part of
292
+ * the canonical signed body: the metadata is client-claimed either way — a
293
+ * signature by the device key proves only "the key holder asserts this", and the
294
+ * key holder is exactly the party who could lie — so signing it adds zero trust
295
+ * while growing the envelope surface (see stage-4.9 §3.3). */
296
+ export declare const CLIENT_META_HEADER: "X-VH-Client";
297
+ export declare const CLIENT_META_MAX: {
298
+ readonly product: 64;
299
+ readonly productVersion: 32;
300
+ readonly platform: 64;
301
+ readonly raw: 512;
302
+ };
303
+ /** Field-wise sanitizer: keeps only well-typed, length-capped fields; anything
304
+ * else is DROPPED, never fatal — metadata must never break pairing or a mint.
305
+ * Returns null when nothing valid remains. */
306
+ export declare function sanitizeClientMeta(value: unknown): DeviceClientMeta | null;
307
+ /** Parsed device-auth headers. */
308
+ export type DeviceAuthHeader = {
309
+ /** Value of X-VH-Device. */
310
+ deviceId: string;
311
+ /** Value of X-VH-Signature — base64url Ed25519 signature over canonicalJSON(body). */
312
+ signature: string;
313
+ };
314
+ /**
315
+ * Deterministic JSON with stably-sorted object keys. The device signs the bytes
316
+ * of canonicalJSON(body) and the backend verifies over the same bytes, so signer
317
+ * (MCP) and verifier (backend) must use this identical function.
318
+ */
319
+ export declare function canonicalJSON(value: unknown): string;
320
+ export * from "./policy.js";
package/dist/index.js ADDED
@@ -0,0 +1,168 @@
1
+ // @tiertwo/shared — shared types + pure helpers (zero runtime deps).
2
+ // Single source of truth for token/claim/envelope/payload shapes AND their
3
+ // validators, so web, sdk, and mcp never drift. See docs/ARCHITECTURE.md §6
4
+ // (token), §7 (request flow) and docs/plans/agent-governance/CLEAN-ARCHITECTURE.md
5
+ // §2 (the signed-request model this file implements).
6
+ export const WELL_KNOWN_ACTIONS = ["create_account", "deploy"];
7
+ const ACTION_RE = /^[a-z][a-z0-9_.:-]{0,63}$/;
8
+ export function isValidAction(s) {
9
+ return typeof s === "string" && ACTION_RE.test(s);
10
+ }
11
+ const RESOURCE_RE = /^[a-z0-9][a-z0-9_.:/-]{0,127}$/i;
12
+ export function isValidResource(s) {
13
+ return typeof s === "string" && RESOURCE_RE.test(s);
14
+ }
15
+ /**
16
+ * Assurance tier of the human behind an action, ordered weakest → strongest:
17
+ * - "sso": an authenticated org employee (SSO); identity not independently proofed.
18
+ * - "identity_proofed": a Stripe-Identity-verified real human (the consumer bar).
19
+ * `meetsAuthLevel` enforces an "at least this tier" requirement.
20
+ */
21
+ export const AUTH_LEVELS = ["sso", "identity_proofed"];
22
+ export function authLevelRank(l) {
23
+ return AUTH_LEVELS.indexOf(l);
24
+ }
25
+ export function meetsAuthLevel(have, need) {
26
+ return (typeof have === "string" &&
27
+ AUTH_LEVELS.includes(have) &&
28
+ authLevelRank(have) >= authLevelRank(need));
29
+ }
30
+ export function isAuthLevel(s) {
31
+ return typeof s === "string" && AUTH_LEVELS.includes(s);
32
+ }
33
+ /**
34
+ * Device-trust ladder (Stage 4.12), ordered weakest → strongest. LINEAR:
35
+ * - "paired": the device authenticated (exists, not revoked) — key possession,
36
+ * the floor every caller clears.
37
+ * - "managed": + the device belongs to an organization (admins can see, gate,
38
+ * and revoke it).
39
+ * - "verified": + the owning human is identity-proofed (Stripe Identity).
40
+ * A consumer device with a verified owner is still just "paired" — the ladder
41
+ * measures how GOVERNED the worker is, and an ungoverned device tops out at the
42
+ * bottom rung by design. Distinct axis from AUTH_LEVELS ("how proofed is the
43
+ * human") — see agent-directory-roadmap §4e; do not conflate them.
44
+ */
45
+ export const DEVICE_TRUST_LEVELS = ["paired", "managed", "verified"];
46
+ export function deviceTrustRank(l) {
47
+ return DEVICE_TRUST_LEVELS.indexOf(l);
48
+ }
49
+ export function meetsDeviceTrust(have, need) {
50
+ return (typeof have === "string" &&
51
+ DEVICE_TRUST_LEVELS.includes(have) &&
52
+ deviceTrustRank(have) >= deviceTrustRank(need));
53
+ }
54
+ export function isDeviceTrustLevel(s) {
55
+ return typeof s === "string" && DEVICE_TRUST_LEVELS.includes(s);
56
+ }
57
+ /**
58
+ * Why a call was refused (Stage 4.10) — the SignEvent.denyReason vocabulary.
59
+ * The server adjudicates the first three (authorize route); the last three are
60
+ * gateway-local policy results that reach the ledger via /api/gateway/telemetry.
61
+ * That split is load-bearing: a gateway may only CLAIM the local subset, so deny
62
+ * provenance stays derivable with no extra column.
63
+ */
64
+ export const DENY_REASONS = [
65
+ "access_required",
66
+ "not_verified",
67
+ "insufficient_auth_level",
68
+ "policy_deny",
69
+ "no_rule",
70
+ "unresolved_resource",
71
+ "insufficient_device_trust", // Stage 4.12 — server-adjudicated, like the tier check
72
+ // Stage 4.13 — a requireFreshApproval rule matched but no consumable ad-hoc
73
+ // Approval existed. Server-adjudicated; distinct from access_required on purpose:
74
+ // this deny opens an ApprovalRequest (fresh per-call confirmation), never a
75
+ // pending Grant (a standing grant can't satisfy a step-up rule by design).
76
+ "approval_required",
77
+ ];
78
+ export function isDenyReason(s) {
79
+ return typeof s === "string" && DENY_REASONS.includes(s);
80
+ }
81
+ /** The subset a gateway may claim via telemetry — server-adjudicated reasons are
82
+ * not claimable (the telemetry narrower rejects them). */
83
+ export const GATEWAY_LOCAL_DENY_REASONS = [
84
+ "no_rule",
85
+ "unresolved_resource",
86
+ "policy_deny",
87
+ ];
88
+ export function isGatewayLocalDenyReason(s) {
89
+ return (typeof s === "string" &&
90
+ GATEWAY_LOCAL_DENY_REASONS.includes(s));
91
+ }
92
+ /**
93
+ * SignEvent.action sentinel for denied calls that no rule mapped to a capability
94
+ * (no_rule / unresolved_resource) — self-describing, and never fabricates a
95
+ * capability name an admin might have authored. Display/filter only; appears on
96
+ * deny rows exclusively.
97
+ */
98
+ export function unmappedToolAction(tool) {
99
+ return `tool:${tool}`;
100
+ }
101
+ export const POSTURE_MAX = { osVersion: 64 };
102
+ /** Postgres Int4 ceiling — the gateway accumulator saturates here and the
103
+ * server-side narrower rejects beyond it, so the DB column can never overflow. */
104
+ export const SESSION_COUNTER_MAX = 2_147_483_647;
105
+ /** HTTP header names for device-authed requests. (§7) */
106
+ export const DEVICE_HEADER = "X-VH-Device";
107
+ export const SIGNATURE_HEADER = "X-VH-Signature";
108
+ /** Unsigned metadata header on device-signed requests. Deliberately NOT part of
109
+ * the canonical signed body: the metadata is client-claimed either way — a
110
+ * signature by the device key proves only "the key holder asserts this", and the
111
+ * key holder is exactly the party who could lie — so signing it adds zero trust
112
+ * while growing the envelope surface (see stage-4.9 §3.3). */
113
+ export const CLIENT_META_HEADER = "X-VH-Client";
114
+ export const CLIENT_META_MAX = {
115
+ product: 64,
116
+ productVersion: 32,
117
+ platform: 64,
118
+ raw: 512,
119
+ };
120
+ const RUNTIMES = new Set(["local", "cloud"]);
121
+ /** Field-wise sanitizer: keeps only well-typed, length-capped fields; anything
122
+ * else is DROPPED, never fatal — metadata must never break pairing or a mint.
123
+ * Returns null when nothing valid remains. */
124
+ export function sanitizeClientMeta(value) {
125
+ if (value === null || typeof value !== "object" || Array.isArray(value))
126
+ return null;
127
+ const v = value;
128
+ const out = {};
129
+ const str = (x, max) => typeof x === "string" && x.trim().length > 0 && x.length <= max ? x.trim() : undefined;
130
+ const product = str(v.product, CLIENT_META_MAX.product);
131
+ const productVersion = str(v.productVersion, CLIENT_META_MAX.productVersion);
132
+ const platform = str(v.platform, CLIENT_META_MAX.platform);
133
+ const runtime = typeof v.runtime === "string" && RUNTIMES.has(v.runtime)
134
+ ? v.runtime
135
+ : undefined;
136
+ if (product)
137
+ out.product = product;
138
+ if (productVersion)
139
+ out.productVersion = productVersion;
140
+ if (runtime)
141
+ out.runtime = runtime;
142
+ if (platform)
143
+ out.platform = platform;
144
+ return Object.keys(out).length > 0 ? out : null;
145
+ }
146
+ /**
147
+ * Deterministic JSON with stably-sorted object keys. The device signs the bytes
148
+ * of canonicalJSON(body) and the backend verifies over the same bytes, so signer
149
+ * (MCP) and verifier (backend) must use this identical function.
150
+ */
151
+ export function canonicalJSON(value) {
152
+ return JSON.stringify(sortKeys(value));
153
+ }
154
+ function sortKeys(value) {
155
+ if (Array.isArray(value))
156
+ return value.map(sortKeys);
157
+ if (value && typeof value === "object") {
158
+ const obj = value;
159
+ return Object.fromEntries(Object.keys(obj)
160
+ .sort()
161
+ .map((key) => [key, sortKeys(obj[key])]));
162
+ }
163
+ return value;
164
+ }
165
+ // The pure policy evaluator + the policy-bundle wire contract (Stage 4). A separate
166
+ // module so the engine's tests live beside it; re-exported here so every consumer
167
+ // (gateway enforcement, apps/web dry-run simulator) imports "@tiertwo/shared".
168
+ export * from "./policy.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,115 @@
1
+ // Unit suite for the shared validators — the single source of truth for
2
+ // action/resource/tier validity that web, sdk, and mcp all import.
3
+ import { describe, it, expect } from "vitest";
4
+ import { authLevelRank, deviceTrustRank, isAuthLevel, isDenyReason, isDeviceTrustLevel, isGatewayLocalDenyReason, isValidAction, isValidResource, meetsAuthLevel, meetsDeviceTrust, unmappedToolAction, } from "./index.js";
5
+ describe("isValidAction", () => {
6
+ it("accepts well-known and custom lowercase actions", () => {
7
+ expect(isValidAction("create_account")).toBe(true);
8
+ expect(isValidAction("deploy")).toBe(true);
9
+ expect(isValidAction("db.migrate")).toBe(true);
10
+ expect(isValidAction("scope:read")).toBe(true);
11
+ });
12
+ it("rejects malformed actions and non-strings", () => {
13
+ expect(isValidAction("Bad Action!")).toBe(false); // uppercase + space + punctuation
14
+ expect(isValidAction("")).toBe(false);
15
+ expect(isValidAction("1leading_digit")).toBe(false); // must start [a-z]
16
+ expect(isValidAction("a".repeat(65))).toBe(false); // too long
17
+ expect(isValidAction(42)).toBe(false);
18
+ expect(isValidAction(undefined)).toBe(false);
19
+ });
20
+ });
21
+ describe("isValidResource", () => {
22
+ it("accepts service names and paths, case-insensitively", () => {
23
+ expect(isValidResource("svc-x")).toBe(true);
24
+ expect(isValidResource("Repo/Path")).toBe(true);
25
+ expect(isValidResource("a.b:c/d-e")).toBe(true);
26
+ });
27
+ it("rejects malformed resources and non-strings", () => {
28
+ expect(isValidResource("has space")).toBe(false);
29
+ expect(isValidResource("")).toBe(false);
30
+ expect(isValidResource("-leading-dash")).toBe(false); // must start alnum
31
+ expect(isValidResource(123)).toBe(false);
32
+ });
33
+ });
34
+ describe("meetsAuthLevel", () => {
35
+ it("enforces at-least ordering", () => {
36
+ expect(meetsAuthLevel("identity_proofed", "sso")).toBe(true);
37
+ expect(meetsAuthLevel("identity_proofed", "identity_proofed")).toBe(true);
38
+ expect(meetsAuthLevel("sso", "sso")).toBe(true);
39
+ expect(meetsAuthLevel("sso", "identity_proofed")).toBe(false);
40
+ });
41
+ it("fails closed on unknown / garbage values", () => {
42
+ expect(meetsAuthLevel("nonsense", "sso")).toBe(false);
43
+ expect(meetsAuthLevel(undefined, "sso")).toBe(false);
44
+ expect(meetsAuthLevel(null, "identity_proofed")).toBe(false);
45
+ });
46
+ it("ranks tiers weakest → strongest", () => {
47
+ expect(authLevelRank("sso")).toBeLessThan(authLevelRank("identity_proofed"));
48
+ });
49
+ });
50
+ describe("isAuthLevel", () => {
51
+ it("accepts exactly the two tiers, fails closed otherwise", () => {
52
+ expect(isAuthLevel("sso")).toBe(true);
53
+ expect(isAuthLevel("identity_proofed")).toBe(true);
54
+ expect(isAuthLevel("root")).toBe(false);
55
+ expect(isAuthLevel("")).toBe(false);
56
+ expect(isAuthLevel(undefined)).toBe(false);
57
+ });
58
+ });
59
+ describe("meetsDeviceTrust (Stage 4.12)", () => {
60
+ it("enforces at-least ordering on the linear ladder", () => {
61
+ expect(meetsDeviceTrust("verified", "paired")).toBe(true);
62
+ expect(meetsDeviceTrust("verified", "managed")).toBe(true);
63
+ expect(meetsDeviceTrust("managed", "managed")).toBe(true);
64
+ expect(meetsDeviceTrust("managed", "verified")).toBe(false);
65
+ expect(meetsDeviceTrust("paired", "managed")).toBe(false);
66
+ });
67
+ it("fails closed on unknown / garbage values", () => {
68
+ expect(meetsDeviceTrust("root", "paired")).toBe(false);
69
+ expect(meetsDeviceTrust(undefined, "paired")).toBe(false);
70
+ expect(meetsDeviceTrust(null, "verified")).toBe(false);
71
+ });
72
+ it("ranks rungs weakest → strongest", () => {
73
+ expect(deviceTrustRank("paired")).toBeLessThan(deviceTrustRank("managed"));
74
+ expect(deviceTrustRank("managed")).toBeLessThan(deviceTrustRank("verified"));
75
+ });
76
+ });
77
+ describe("isDeviceTrustLevel", () => {
78
+ it("accepts exactly the three rungs, fails closed otherwise", () => {
79
+ expect(isDeviceTrustLevel("paired")).toBe(true);
80
+ expect(isDeviceTrustLevel("managed")).toBe(true);
81
+ expect(isDeviceTrustLevel("verified")).toBe(true);
82
+ expect(isDeviceTrustLevel("attested")).toBe(false);
83
+ expect(isDeviceTrustLevel("")).toBe(false);
84
+ expect(isDeviceTrustLevel(undefined)).toBe(false);
85
+ });
86
+ });
87
+ describe("deny-reason vocabulary (Stage 4.10; grown in 4.12)", () => {
88
+ it("isDenyReason covers all seven reasons and rejects garbage", () => {
89
+ for (const r of [
90
+ "access_required",
91
+ "not_verified",
92
+ "insufficient_auth_level",
93
+ "policy_deny",
94
+ "no_rule",
95
+ "unresolved_resource",
96
+ "insufficient_device_trust",
97
+ ]) {
98
+ expect(isDenyReason(r)).toBe(true);
99
+ }
100
+ expect(isDenyReason("denied")).toBe(false);
101
+ expect(isDenyReason(null)).toBe(false);
102
+ });
103
+ it("the gateway-claimable subset excludes server-adjudicated reasons", () => {
104
+ expect(isGatewayLocalDenyReason("no_rule")).toBe(true);
105
+ expect(isGatewayLocalDenyReason("unresolved_resource")).toBe(true);
106
+ expect(isGatewayLocalDenyReason("policy_deny")).toBe(true);
107
+ expect(isGatewayLocalDenyReason("access_required")).toBe(false);
108
+ expect(isGatewayLocalDenyReason("not_verified")).toBe(false);
109
+ expect(isGatewayLocalDenyReason("insufficient_auth_level")).toBe(false);
110
+ expect(isGatewayLocalDenyReason("insufficient_device_trust")).toBe(false);
111
+ });
112
+ it("unmappedToolAction prefixes the raw tool name, self-describing", () => {
113
+ expect(unmappedToolAction("drop_table")).toBe("tool:drop_table");
114
+ });
115
+ });
@@ -0,0 +1,106 @@
1
+ import { type Action, type AuthLevel, type DeviceTrustLevel, type Resource } from "./index.js";
2
+ /**
3
+ * One policy rule, as the evaluator consumes it. The gateway's config type and the
4
+ * wire `SerializedPolicyRule` are both structurally assignable to this. `resource`
5
+ * and `resourceFrom` combine into three match shapes per (backend, tool):
6
+ * - pinned (resourceFrom + resource): matches ONE observed value — an exception row;
7
+ * - static (resource only): a fixed scope, matches every call;
8
+ * - wildcard (resourceFrom only, or neither): covers all values of the tool.
9
+ */
10
+ export type PolicyRuleInput = {
11
+ backend: string;
12
+ /** Exact downstream tool name (un-namespaced). */
13
+ tool: string;
14
+ /** The capability this tool exercises → SignEvent.action + Grant.action. */
15
+ action: Action;
16
+ /** Pinned match value (with resourceFrom) or static scope; absent = wildcard. */
17
+ resource?: Resource;
18
+ /** Dotted path into the call args the observed resource is extracted from. */
19
+ resourceFrom?: string;
20
+ requiredAuthLevel?: AuthLevel;
21
+ /** Minimum device-trust rung for gate rules (Stage 4.12); absent = no gate. */
22
+ requiredDeviceTrust?: DeviceTrustLevel;
23
+ /** Assurance step-up (Stage 4.13): a matching call needs a FRESH ad-hoc approval
24
+ * even under a standing grant. Gate rules only (write-validated upstream). */
25
+ requireFreshApproval?: boolean;
26
+ /** Default "gate". "deny" = explicit block (a per-resource exception over a wildcard). */
27
+ effect?: "gate" | "audit_only" | "deny";
28
+ };
29
+ /** What a matched rule resolves the observed call to. */
30
+ export type PolicyMatch = {
31
+ action: string;
32
+ resource: string | null;
33
+ requiredAuthLevel: AuthLevel | null;
34
+ /** Carried, never enforced here — the authorize route enforces (Stage 4.12). */
35
+ requiredDeviceTrust: DeviceTrustLevel | null;
36
+ /** Carried, never enforced here — authorizeMint enforces (Stage 4.13). */
37
+ requireFreshApproval: boolean;
38
+ effect: "gate" | "audit_only";
39
+ };
40
+ /**
41
+ * Why evaluate() refused (Stage 4.10). Discriminated on the SAME `effect` field
42
+ * PolicyMatch uses ("gate" | "audit_only" there), so callers narrow with one
43
+ * check. The policy_deny arm carries the denying rule's real capability so
44
+ * ledger rows and the simulator never fabricate an action name.
45
+ */
46
+ export type PolicyDeny = {
47
+ effect: "deny";
48
+ reason: "no_rule";
49
+ } | {
50
+ effect: "deny";
51
+ reason: "unresolved_resource";
52
+ } | {
53
+ effect: "deny";
54
+ reason: "policy_deny";
55
+ action: string;
56
+ resource: string | null;
57
+ };
58
+ export type PolicyDecision = PolicyMatch | PolicyDeny;
59
+ /**
60
+ * Default-deny allowlist with most-specific-(backend, tool, resource) selection
61
+ * (Stage 4 §4.1). For a call (backend, tool, args):
62
+ *
63
+ * 1. Candidates = rules matching (backend, tool). None ⇒ deny.
64
+ * 2. Resolve the OBSERVED resource once, from the candidates' shared resourceFrom
65
+ * (the write side — apps/web lib/policy — enforces that all rules for one
66
+ * (backend, tool) agree on it; the first declared wins here). A declared but
67
+ * unresolvable/invalid resourceFrom ⇒ deny — never guess, even when a wildcard
68
+ * rule also matches. Finite numbers coerce to their string form (real backends
69
+ * use integer ids); other non-strings are not guessed.
70
+ * 3. Select the most specific rule: pinned (resource === observed) beats static
71
+ * (fixed resource, no resourceFrom) beats dynamic wildcard (resourceFrom only)
72
+ * beats unscoped wildcard (neither).
73
+ * 4. Selected rule's effect "deny" ⇒ a discriminated policy_deny. Otherwise
74
+ * resolve the match.
75
+ *
76
+ * Backward-compatible with the Stage 3a/3b single-rule shape: with one candidate,
77
+ * most-specific ≡ first-match. NOTE one deliberate semantics change: a rule with
78
+ * BOTH resource and resourceFrom used to resolve as static (resourceFrom ignored);
79
+ * it is now a pinned exception that only matches its exact observed value.
80
+ */
81
+ export declare function evaluate(policy: PolicyRuleInput[], backend: string, tool: string, args: Record<string, unknown>): PolicyDecision;
82
+ export type SerializedBackend = {
83
+ /** Namespace prefix + SignEvent.domain, e.g. "metabase". */
84
+ name: string;
85
+ /** Remote MCP endpoint (streamable-HTTP). */
86
+ url: string;
87
+ /**
88
+ * How credentials are minted. Per-user kinds (Stage 4.6) ride the gateway's
89
+ * brokerTokenSource → /api/gateway/credential: "oauth-broker" = each user links,
90
+ * "api-key" = each user pastes a personal key into the vault. "org-api-key"
91
+ * (Stage 4.13) is ORG-provisioned: the admin pastes one service key, and calls
92
+ * are forwarded server-side via /api/gateway/proxy — the credential never
93
+ * reaches a device. Nothing secret here.
94
+ */
95
+ authKind: "oauth-broker" | "api-key" | "org-api-key";
96
+ /** Pinned tools/list (array of MCP Tool), served without a per-user token. */
97
+ toolsSchema: unknown[];
98
+ };
99
+ /** A PolicyRuleInput with `effect` made explicit on the wire (no implicit default). */
100
+ export type SerializedPolicyRule = PolicyRuleInput & {
101
+ effect: "gate" | "audit_only" | "deny";
102
+ };
103
+ export type PolicyBundle = {
104
+ backends: SerializedBackend[];
105
+ policy: SerializedPolicyRule[];
106
+ };
package/dist/policy.js ADDED
@@ -0,0 +1,87 @@
1
+ // The pure, default-deny policy evaluator (agent-governance Stage 3a; per-resource
2
+ // selection + `deny` effect added in Stage 4; discriminated deny in Stage 4.10).
3
+ // Lives in @tiertwo/shared — NOT the gateway — because two consumers must run the
4
+ // IDENTICAL function: the gateway's enforcement path (apps/gateway/src/handler.ts)
5
+ // and the dashboard's dry-run simulator (apps/web /api/policy/simulate), which must
6
+ // never drift from what the data plane enforces. Zero I/O; shares the resource
7
+ // grammar via isValidResource.
8
+ //
9
+ // The single most important behavior in the whole gateway: a decision with
10
+ // `effect: "deny"` MUST be treated as "refuse, do NOT forward". Read tools are
11
+ // gated too unless an explicit rule allows them. Since Stage 4.10 the deny is
12
+ // discriminated (no_rule vs unresolved_resource vs policy_deny) so the refusal is
13
+ // reportable to the decision ledger — evaluate() never returns null.
14
+ // See stage-4-governance-control-plane.md §4 and stage-4.10-decision-ledger.md §3.2.
15
+ import { isValidResource, } from "./index.js";
16
+ /** Safe dotted lookup into the call arguments. Returns unknown; never throws. */
17
+ function extractPath(args, path) {
18
+ return path.split(".").reduce((acc, k) => acc && typeof acc === "object"
19
+ ? acc[k]
20
+ : undefined, args);
21
+ }
22
+ /**
23
+ * Default-deny allowlist with most-specific-(backend, tool, resource) selection
24
+ * (Stage 4 §4.1). For a call (backend, tool, args):
25
+ *
26
+ * 1. Candidates = rules matching (backend, tool). None ⇒ deny.
27
+ * 2. Resolve the OBSERVED resource once, from the candidates' shared resourceFrom
28
+ * (the write side — apps/web lib/policy — enforces that all rules for one
29
+ * (backend, tool) agree on it; the first declared wins here). A declared but
30
+ * unresolvable/invalid resourceFrom ⇒ deny — never guess, even when a wildcard
31
+ * rule also matches. Finite numbers coerce to their string form (real backends
32
+ * use integer ids); other non-strings are not guessed.
33
+ * 3. Select the most specific rule: pinned (resource === observed) beats static
34
+ * (fixed resource, no resourceFrom) beats dynamic wildcard (resourceFrom only)
35
+ * beats unscoped wildcard (neither).
36
+ * 4. Selected rule's effect "deny" ⇒ a discriminated policy_deny. Otherwise
37
+ * resolve the match.
38
+ *
39
+ * Backward-compatible with the Stage 3a/3b single-rule shape: with one candidate,
40
+ * most-specific ≡ first-match. NOTE one deliberate semantics change: a rule with
41
+ * BOTH resource and resourceFrom used to resolve as static (resourceFrom ignored);
42
+ * it is now a pinned exception that only matches its exact observed value.
43
+ */
44
+ export function evaluate(policy, backend, tool, args) {
45
+ const candidates = policy.filter((r) => r.backend === backend && r.tool === tool);
46
+ if (candidates.length === 0) {
47
+ return { effect: "deny", reason: "no_rule" }; // default-deny
48
+ }
49
+ const from = candidates.find((r) => r.resourceFrom !== undefined)?.resourceFrom;
50
+ let observed = null;
51
+ if (from !== undefined) {
52
+ const v = extractPath(args, from);
53
+ const s = typeof v === "number" && Number.isFinite(v) ? String(v) : v;
54
+ if (!isValidResource(s)) {
55
+ // Declared but unresolvable/invalid → deny, don't guess. A DISTINCT reason
56
+ // from no_rule (Stage 4.10): "a rule governs this tool but the args
57
+ // couldn't be scoped" is a different — and more suspicious — signal.
58
+ return { effect: "deny", reason: "unresolved_resource" };
59
+ }
60
+ observed = s;
61
+ }
62
+ const pinned = candidates.find((r) => r.resourceFrom !== undefined && r.resource === observed);
63
+ const staticRule = candidates.find((r) => r.resource !== undefined && r.resourceFrom === undefined);
64
+ const dynamicWildcard = candidates.find((r) => r.resourceFrom !== undefined && r.resource === undefined);
65
+ const unscopedWildcard = candidates.find((r) => r.resource === undefined && r.resourceFrom === undefined);
66
+ const rule = pinned ?? staticRule ?? dynamicWildcard ?? unscopedWildcard;
67
+ // Candidates exist but none selected (e.g. only a pinned exception for a
68
+ // different resource): honestly "no rule allows THIS call".
69
+ if (!rule)
70
+ return { effect: "deny", reason: "no_rule" };
71
+ if (rule.effect === "deny") {
72
+ return {
73
+ effect: "deny",
74
+ reason: "policy_deny",
75
+ action: rule.action,
76
+ resource: rule.resourceFrom !== undefined ? observed : (rule.resource ?? null),
77
+ };
78
+ }
79
+ return {
80
+ action: rule.action,
81
+ resource: rule.resourceFrom !== undefined ? observed : (rule.resource ?? null),
82
+ requiredAuthLevel: rule.requiredAuthLevel ?? null,
83
+ requiredDeviceTrust: rule.requiredDeviceTrust ?? null,
84
+ requireFreshApproval: rule.requireFreshApproval === true,
85
+ effect: rule.effect === "audit_only" ? "audit_only" : "gate",
86
+ };
87
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,204 @@
1
+ // Pure unit tests for the most-specific-match policy evaluator (Stage 4 §4;
2
+ // discriminated deny in Stage 4.10). The load-bearing assertions: default-deny
3
+ // holds (no candidates ⇒ deny no_rule), a pinned rule beats every wildcard, a
4
+ // selected `deny` returns a policy_deny CARRYING the denying rule's action (and
5
+ // only for ITS resource — the wildcard still governs the rest), and a
6
+ // declared-but-unresolvable resourceFrom denies with the DISTINCT
7
+ // unresolved_resource reason even when a wildcard rule would otherwise match.
8
+ import { describe, it, expect } from "vitest";
9
+ import { evaluate } from "./policy.js";
10
+ const NO_RULE = { effect: "deny", reason: "no_rule" };
11
+ const UNRESOLVED = { effect: "deny", reason: "unresolved_resource" };
12
+ describe("evaluate — most-specific (backend, tool, resource) selection", () => {
13
+ it("denies an unmatched (backend, tool) with no_rule — default-deny holds", () => {
14
+ const rules = [
15
+ { backend: "wh", tool: "read", action: "wh.read" },
16
+ ];
17
+ expect(evaluate(rules, "wh", "drop_table", {})).toEqual(NO_RULE);
18
+ expect(evaluate(rules, "other", "read", {})).toEqual(NO_RULE);
19
+ expect(evaluate([], "wh", "read", {})).toEqual(NO_RULE);
20
+ });
21
+ it("selects a pinned rule over the dynamic wildcard for its exact resource", () => {
22
+ const rules = [
23
+ { backend: "wh", tool: "sql", action: "wh.sql", resourceFrom: "db" },
24
+ {
25
+ backend: "wh",
26
+ tool: "sql",
27
+ action: "wh.sql_billing",
28
+ resourceFrom: "db",
29
+ resource: "billing",
30
+ requiredAuthLevel: "identity_proofed",
31
+ },
32
+ ];
33
+ expect(evaluate(rules, "wh", "sql", { db: "billing" })).toEqual({
34
+ action: "wh.sql_billing",
35
+ resource: "billing",
36
+ requiredAuthLevel: "identity_proofed",
37
+ requiredDeviceTrust: null,
38
+ requireFreshApproval: false,
39
+ effect: "gate",
40
+ });
41
+ // Any other observed value falls through to the wildcard.
42
+ expect(evaluate(rules, "wh", "sql", { db: "sales" })).toEqual({
43
+ action: "wh.sql",
44
+ resource: "sales",
45
+ requiredAuthLevel: null,
46
+ requiredDeviceTrust: null,
47
+ requireFreshApproval: false,
48
+ effect: "gate",
49
+ });
50
+ });
51
+ it("a pinned deny carves out one resource while the wildcard still allows others", () => {
52
+ const rules = [
53
+ { backend: "wh", tool: "sql", action: "wh.sql", resourceFrom: "db" },
54
+ { backend: "wh", tool: "sql", action: "wh.sql", resourceFrom: "db", resource: "5", effect: "deny" },
55
+ ];
56
+ // The policy_deny carries the denying rule's real action + observed resource.
57
+ const denied = { effect: "deny", reason: "policy_deny", action: "wh.sql", resource: "5" };
58
+ expect(evaluate(rules, "wh", "sql", { db: 5 })).toEqual(denied); // numeric id coerces, then denies
59
+ expect(evaluate(rules, "wh", "sql", { db: "5" })).toEqual(denied);
60
+ expect(evaluate(rules, "wh", "sql", { db: "6" })).toEqual({
61
+ action: "wh.sql",
62
+ resource: "6",
63
+ requiredAuthLevel: null,
64
+ requiredDeviceTrust: null,
65
+ requireFreshApproval: false,
66
+ effect: "gate",
67
+ });
68
+ });
69
+ it("denies with policy_deny when the only matching rule is a deny", () => {
70
+ const rules = [
71
+ { backend: "wh", tool: "wipe", action: "wh.wipe", effect: "deny" },
72
+ ];
73
+ expect(evaluate(rules, "wh", "wipe", {})).toEqual({
74
+ effect: "deny",
75
+ reason: "policy_deny",
76
+ action: "wh.wipe",
77
+ resource: null,
78
+ });
79
+ });
80
+ it("selects a static rule over wildcards, but under a pinned rule", () => {
81
+ const rules = [
82
+ { backend: "wh", tool: "cfg", action: "wh.cfg_any" }, // unscoped wildcard
83
+ { backend: "wh", tool: "cfg", action: "wh.cfg_fixed", resource: "prod" }, // static
84
+ ];
85
+ expect(evaluate(rules, "wh", "cfg", {})).toEqual({
86
+ action: "wh.cfg_fixed",
87
+ resource: "prod",
88
+ requiredAuthLevel: null,
89
+ requiredDeviceTrust: null,
90
+ requireFreshApproval: false,
91
+ effect: "gate",
92
+ });
93
+ });
94
+ it("selects the dynamic wildcard over the unscoped wildcard", () => {
95
+ const rules = [
96
+ { backend: "wh", tool: "sql", action: "wh.sql_unscoped" },
97
+ { backend: "wh", tool: "sql", action: "wh.sql_scoped", resourceFrom: "db" },
98
+ ];
99
+ expect(evaluate(rules, "wh", "sql", { db: "sales" })).toEqual({
100
+ action: "wh.sql_scoped",
101
+ resource: "sales",
102
+ requiredAuthLevel: null,
103
+ requiredDeviceTrust: null,
104
+ requireFreshApproval: false,
105
+ effect: "gate",
106
+ });
107
+ });
108
+ it("denies an unresolvable resourceFrom (unresolved_resource) even when an unscoped wildcard exists", () => {
109
+ // Once any candidate declares resourceFrom, an unresolvable observed resource
110
+ // denies the call — falling back to a coarser rule would guess at scope. The
111
+ // reason is DISTINCT from no_rule: a rule governs this tool.
112
+ const rules = [
113
+ { backend: "wh", tool: "sql", action: "wh.sql", resourceFrom: "db" },
114
+ { backend: "wh", tool: "sql", action: "wh.sql_any" },
115
+ ];
116
+ expect(evaluate(rules, "wh", "sql", {})).toEqual(UNRESOLVED); // missing
117
+ expect(evaluate(rules, "wh", "sql", { db: {} })).toEqual(UNRESOLVED); // non-coercible
118
+ expect(evaluate(rules, "wh", "sql", { db: true })).toEqual(UNRESOLVED); // boolean
119
+ expect(evaluate(rules, "wh", "sql", { db: "" })).toEqual(UNRESOLVED); // invalid string
120
+ });
121
+ it("backward-compat: the migrated Metabase execute_sql rule resolves exactly as 3b (§4.2)", () => {
122
+ const rules = [
123
+ {
124
+ backend: "metabase",
125
+ tool: "execute_sql",
126
+ action: "metabase.execute_sql",
127
+ resourceFrom: "database_id",
128
+ requiredAuthLevel: "identity_proofed",
129
+ },
130
+ ];
131
+ expect(evaluate(rules, "metabase", "execute_sql", { database_id: 5, sql: "select 1" })).toEqual({
132
+ action: "metabase.execute_sql",
133
+ resource: "5",
134
+ requiredAuthLevel: "identity_proofed",
135
+ requiredDeviceTrust: null,
136
+ requireFreshApproval: false,
137
+ effect: "gate",
138
+ });
139
+ });
140
+ it("resolves audit_only and defaults effect to gate (unchanged from 3b)", () => {
141
+ const rules = [
142
+ { backend: "mb", tool: "search", action: "mb.read", effect: "audit_only" },
143
+ { backend: "mb", tool: "ping", action: "mb.ping" },
144
+ ];
145
+ expect(evaluate(rules, "mb", "search", {}).effect).toBe("audit_only");
146
+ expect(evaluate(rules, "mb", "ping", {}).effect).toBe("gate");
147
+ });
148
+ it("extracts a dotted resourceFrom path", () => {
149
+ const rules = [
150
+ { backend: "deep", tool: "nested", action: "deep.read", resourceFrom: "params.db" },
151
+ ];
152
+ expect(evaluate(rules, "deep", "nested", { params: { db: "warehouse" } })).toMatchObject({
153
+ resource: "warehouse",
154
+ });
155
+ expect(evaluate(rules, "deep", "nested", { params: {} })).toEqual(UNRESOLVED);
156
+ });
157
+ it("carries requiredDeviceTrust in the match without enforcing it (Stage 4.12)", () => {
158
+ const rules = [
159
+ {
160
+ backend: "wh",
161
+ tool: "deploy",
162
+ action: "wh.deploy",
163
+ requiredDeviceTrust: "verified",
164
+ },
165
+ ];
166
+ // Carried, never enforced here — the authorize route enforces.
167
+ expect(evaluate(rules, "wh", "deploy", {})).toEqual({
168
+ action: "wh.deploy",
169
+ resource: null,
170
+ requiredAuthLevel: null,
171
+ requiredDeviceTrust: "verified",
172
+ requireFreshApproval: false,
173
+ effect: "gate",
174
+ });
175
+ });
176
+ it("carries requireFreshApproval in the match without enforcing it (Stage 4.13)", () => {
177
+ const rules = [
178
+ {
179
+ backend: "wh",
180
+ tool: "deploy",
181
+ action: "wh.deploy",
182
+ requireFreshApproval: true,
183
+ },
184
+ ];
185
+ // Carried, never enforced here — authorizeMint skips the standing-grant arm.
186
+ expect(evaluate(rules, "wh", "deploy", {})).toEqual({
187
+ action: "wh.deploy",
188
+ resource: null,
189
+ requiredAuthLevel: null,
190
+ requiredDeviceTrust: null,
191
+ requireFreshApproval: true,
192
+ effect: "gate",
193
+ });
194
+ });
195
+ it("a pinned-only candidate set denies OTHER resources as no_rule, not policy_deny", () => {
196
+ // The only rule is a pinned exception for resource "5"; an observed "7" selects
197
+ // nothing — honestly "no rule allows THIS call" (a distinct fact from an
198
+ // explicit deny rule matching it).
199
+ const rules = [
200
+ { backend: "wh", tool: "sql", action: "wh.sql", resourceFrom: "db", resource: "5" },
201
+ ];
202
+ expect(evaluate(rules, "wh", "sql", { db: "7" })).toEqual(NO_RULE);
203
+ });
204
+ });
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@tiertwo/shared",
3
+ "version": "0.1.0",
4
+ "description": "Internal shared types and pure helpers for @tiertwo/* — token/claim shapes, canonicalJSON, and device-auth header constants. Not a stable public API.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Tier Two",
8
+ "homepage": "https://github.com/Jordan-M/verified-human/tree/master/packages/shared#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Jordan-M/verified-human.git",
12
+ "directory": "packages/shared"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/Jordan-M/verified-human/issues"
16
+ },
17
+ "keywords": [
18
+ "tiertwo",
19
+ "verified-human"
20
+ ],
21
+ "main": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public",
28
+ "provenance": true
29
+ },
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js",
34
+ "default": "./dist/index.js"
35
+ }
36
+ },
37
+ "devDependencies": {
38
+ "typescript": "^5",
39
+ "vitest": "^3.2.0"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.json",
43
+ "lint": "echo no-op",
44
+ "test": "vitest run",
45
+ "test:watch": "vitest"
46
+ }
47
+ }