pyric 0.1.0-alpha.20 → 0.1.0-alpha.21

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.
Files changed (225) hide show
  1. package/README.md +64 -89
  2. package/dist/ai/blocked.d.ts +52 -0
  3. package/dist/ai/blocked.d.ts.map +1 -0
  4. package/dist/ai/blocked.js +83 -0
  5. package/dist/ai/blocked.js.map +1 -0
  6. package/dist/ai/broker/broker.d.ts +49 -0
  7. package/dist/ai/broker/broker.d.ts.map +1 -1
  8. package/dist/ai/broker/broker.js +90 -0
  9. package/dist/ai/broker/broker.js.map +1 -1
  10. package/dist/ai/broker/gemini-engine.d.ts +14 -1
  11. package/dist/ai/broker/gemini-engine.d.ts.map +1 -1
  12. package/dist/ai/broker/gemini-engine.js +30 -9
  13. package/dist/ai/broker/gemini-engine.js.map +1 -1
  14. package/dist/ai/broker/index.d.ts +2 -2
  15. package/dist/ai/broker/index.d.ts.map +1 -1
  16. package/dist/ai/broker/index.js +2 -2
  17. package/dist/ai/broker/index.js.map +1 -1
  18. package/dist/ai/broker/openai-engine.d.ts +10 -1
  19. package/dist/ai/broker/openai-engine.d.ts.map +1 -1
  20. package/dist/ai/broker/openai-engine.js +26 -4
  21. package/dist/ai/broker/openai-engine.js.map +1 -1
  22. package/dist/ai/broker/synthesizer.d.ts +14 -0
  23. package/dist/ai/broker/synthesizer.d.ts.map +1 -1
  24. package/dist/ai/broker/synthesizer.js +31 -0
  25. package/dist/ai/broker/synthesizer.js.map +1 -1
  26. package/dist/ai/broker/types.d.ts +24 -0
  27. package/dist/ai/broker/types.d.ts.map +1 -1
  28. package/dist/ai/internal.d.ts +5 -0
  29. package/dist/ai/internal.d.ts.map +1 -1
  30. package/dist/ai/internal.js +5 -0
  31. package/dist/ai/internal.js.map +1 -1
  32. package/dist/ai/response-helpers.d.ts.map +1 -1
  33. package/dist/ai/response-helpers.js +6 -24
  34. package/dist/ai/response-helpers.js.map +1 -1
  35. package/dist/analytics/index.d.ts +40 -0
  36. package/dist/analytics/index.d.ts.map +1 -0
  37. package/dist/analytics/index.js +25 -0
  38. package/dist/analytics/index.js.map +1 -0
  39. package/dist/app-check/index.d.ts +31 -0
  40. package/dist/app-check/index.d.ts.map +1 -0
  41. package/dist/app-check/index.js +20 -0
  42. package/dist/app-check/index.js.map +1 -0
  43. package/dist/database/controls.d.ts +1 -1
  44. package/dist/database/controls.js +1 -1
  45. package/dist/database/sandbox/rules-eval.d.ts +1 -1
  46. package/dist/database/sandbox/rules-eval.d.ts.map +1 -1
  47. package/dist/database/sandbox/rules-eval.js +2 -2
  48. package/dist/database/sandbox/rules-eval.js.map +1 -1
  49. package/dist/database/sandbox/write-plane.js +2 -2
  50. package/dist/database/sandbox/write-plane.js.map +1 -1
  51. package/dist/database/sandbox-controls.d.ts +12 -5
  52. package/dist/database/sandbox-controls.d.ts.map +1 -1
  53. package/dist/database/sandbox-controls.js +20 -9
  54. package/dist/database/sandbox-controls.js.map +1 -1
  55. package/dist/deferred/entry.d.ts +75 -0
  56. package/dist/deferred/entry.d.ts.map +1 -0
  57. package/dist/deferred/entry.js +143 -0
  58. package/dist/deferred/entry.js.map +1 -0
  59. package/dist/firestore/lite.d.ts +72 -0
  60. package/dist/firestore/lite.d.ts.map +1 -0
  61. package/dist/firestore/lite.js +22 -0
  62. package/dist/firestore/lite.js.map +1 -0
  63. package/dist/firestore/persistence.d.ts +2 -2
  64. package/dist/firestore/persistence.js +1 -1
  65. package/dist/functions/index.d.ts +29 -0
  66. package/dist/functions/index.d.ts.map +1 -0
  67. package/dist/functions/index.js +20 -0
  68. package/dist/functions/index.js.map +1 -0
  69. package/dist/performance/index.d.ts +22 -0
  70. package/dist/performance/index.d.ts.map +1 -0
  71. package/dist/performance/index.js +19 -0
  72. package/dist/performance/index.js.map +1 -0
  73. package/dist/remote-config/index.d.ts +40 -0
  74. package/dist/remote-config/index.d.ts.map +1 -0
  75. package/dist/remote-config/index.js +25 -0
  76. package/dist/remote-config/index.js.map +1 -0
  77. package/dist/rules/grammar/FirestoreValidator.d.ts.map +1 -1
  78. package/dist/rules/grammar/FirestoreValidator.js +43 -27
  79. package/dist/rules/grammar/FirestoreValidator.js.map +1 -1
  80. package/dist/rules/grammar/document-access-count.d.ts +24 -0
  81. package/dist/rules/grammar/document-access-count.d.ts.map +1 -0
  82. package/dist/rules/grammar/document-access-count.js +100 -0
  83. package/dist/rules/grammar/document-access-count.js.map +1 -0
  84. package/dist/rules/linter/ast-utils.d.ts +2 -6
  85. package/dist/rules/linter/ast-utils.d.ts.map +1 -1
  86. package/dist/rules/linter/ast-utils.js +2 -63
  87. package/dist/rules/linter/ast-utils.js.map +1 -1
  88. package/dist/rules/linter/hallucinations.d.ts +8 -0
  89. package/dist/rules/linter/hallucinations.d.ts.map +1 -1
  90. package/dist/rules/linter/hallucinations.js +121 -31
  91. package/dist/rules/linter/hallucinations.js.map +1 -1
  92. package/dist/rules/linter/linter.d.ts +10 -1
  93. package/dist/rules/linter/linter.d.ts.map +1 -1
  94. package/dist/rules/linter/linter.js +23 -9
  95. package/dist/rules/linter/linter.js.map +1 -1
  96. package/dist/rules/rtdb/grammar/simulator.d.ts.map +1 -1
  97. package/dist/rules/rtdb/grammar/simulator.js +13 -13
  98. package/dist/rules/rtdb/grammar/simulator.js.map +1 -1
  99. package/dist/rules/rtdb/simulation/handler.d.ts.map +1 -1
  100. package/dist/rules/rtdb/simulation/handler.js +96 -35
  101. package/dist/rules/rtdb/simulation/handler.js.map +1 -1
  102. package/dist/rules/simulator/document-lookups.d.ts +4 -0
  103. package/dist/rules/simulator/document-lookups.d.ts.map +1 -1
  104. package/dist/rules/simulator/document-lookups.js +42 -3
  105. package/dist/rules/simulator/document-lookups.js.map +1 -1
  106. package/dist/rules/simulator/eval-error.d.ts +11 -0
  107. package/dist/rules/simulator/eval-error.d.ts.map +1 -1
  108. package/dist/rules/simulator/eval-error.js +14 -0
  109. package/dist/rules/simulator/eval-error.js.map +1 -1
  110. package/dist/rules/simulator/evaluation-builtins.d.ts.map +1 -1
  111. package/dist/rules/simulator/evaluation-builtins.js +33 -8
  112. package/dist/rules/simulator/evaluation-builtins.js.map +1 -1
  113. package/dist/rules/simulator/evaluation-context.d.ts +12 -0
  114. package/dist/rules/simulator/evaluation-context.d.ts.map +1 -1
  115. package/dist/rules/simulator/evaluator.d.ts +1 -1
  116. package/dist/rules/simulator/evaluator.d.ts.map +1 -1
  117. package/dist/rules/simulator/evaluator.js +16 -4
  118. package/dist/rules/simulator/evaluator.js.map +1 -1
  119. package/dist/rules/simulator/handler.d.ts.map +1 -1
  120. package/dist/rules/simulator/handler.js +52 -5
  121. package/dist/rules/simulator/handler.js.map +1 -1
  122. package/dist/rules/simulator/lookup-budget.d.ts +37 -0
  123. package/dist/rules/simulator/lookup-budget.d.ts.map +1 -0
  124. package/dist/rules/simulator/lookup-budget.js +96 -0
  125. package/dist/rules/simulator/lookup-budget.js.map +1 -0
  126. package/dist/rules/stdlib-modules.d.ts +7 -0
  127. package/dist/rules/stdlib-modules.d.ts.map +1 -1
  128. package/dist/rules/stdlib-modules.js +11 -11
  129. package/dist/rules/stdlib-modules.js.map +1 -1
  130. package/dist/sandbox/admin-firestore/get-firestore.d.ts +6 -0
  131. package/dist/sandbox/admin-firestore/get-firestore.d.ts.map +1 -1
  132. package/dist/sandbox/admin-firestore/get-firestore.js +22 -3
  133. package/dist/sandbox/admin-firestore/get-firestore.js.map +1 -1
  134. package/dist/sandbox/index.d.ts +1 -1
  135. package/dist/sandbox/index.d.ts.map +1 -1
  136. package/dist/sandbox/index.js +1 -1
  137. package/dist/sandbox/index.js.map +1 -1
  138. package/dist/sandbox/persistence/types.d.ts +1 -1
  139. package/dist/sandbox/remote.d.ts +2 -2
  140. package/dist/sandbox/sandbox-context.d.ts +8 -1
  141. package/dist/sandbox/sandbox-context.d.ts.map +1 -1
  142. package/dist/sandbox/sandbox-context.js +59 -8
  143. package/dist/sandbox/sandbox-context.js.map +1 -1
  144. package/dist/sandbox/types/auth-state.d.ts +1 -0
  145. package/dist/sandbox/types/auth-state.d.ts.map +1 -1
  146. package/dist/sandbox/types/events.d.ts +2 -1
  147. package/dist/sandbox/types/events.d.ts.map +1 -1
  148. package/dist/sandbox/types/operation.d.ts +1 -0
  149. package/dist/sandbox/types/operation.d.ts.map +1 -1
  150. package/dist/storage/enforce.d.ts +20 -1
  151. package/dist/storage/enforce.d.ts.map +1 -1
  152. package/dist/storage/enforce.js +47 -14
  153. package/dist/storage/enforce.js.map +1 -1
  154. package/dist/storage/index.d.ts +1 -1
  155. package/dist/storage/index.d.ts.map +1 -1
  156. package/dist/storage/list.js +1 -1
  157. package/dist/storage/list.js.map +1 -1
  158. package/dist/storage/sandbox/rules-evaluation-error.d.ts +25 -0
  159. package/dist/storage/sandbox/rules-evaluation-error.d.ts.map +1 -1
  160. package/dist/storage/sandbox/rules-evaluation-error.js +29 -0
  161. package/dist/storage/sandbox/rules-evaluation-error.js.map +1 -1
  162. package/dist/storage/sandbox/rules-evaluator.d.ts +10 -5
  163. package/dist/storage/sandbox/rules-evaluator.d.ts.map +1 -1
  164. package/dist/storage/sandbox/rules-evaluator.js +105 -33
  165. package/dist/storage/sandbox/rules-evaluator.js.map +1 -1
  166. package/dist/storage/sandbox/rules-methods.d.ts.map +1 -1
  167. package/dist/storage/sandbox/rules-methods.js +17 -7
  168. package/dist/storage/sandbox/rules-methods.js.map +1 -1
  169. package/dist/storage/service.d.ts +23 -2
  170. package/dist/storage/service.d.ts.map +1 -1
  171. package/dist/storage/service.js +36 -26
  172. package/dist/storage/service.js.map +1 -1
  173. package/package.json +41 -1
  174. package/src/ai/blocked.ts +97 -0
  175. package/src/ai/broker/broker.ts +88 -0
  176. package/src/ai/broker/gemini-engine.ts +36 -9
  177. package/src/ai/broker/index.ts +2 -1
  178. package/src/ai/broker/openai-engine.ts +31 -3
  179. package/src/ai/broker/synthesizer.ts +34 -0
  180. package/src/ai/broker/types.ts +25 -0
  181. package/src/ai/internal.ts +5 -0
  182. package/src/ai/response-helpers.ts +6 -25
  183. package/src/analytics/index.ts +53 -0
  184. package/src/app/dispatch.test.ts +3 -1
  185. package/src/app-check/index.ts +41 -0
  186. package/src/database/controls.ts +1 -1
  187. package/src/database/sandbox/rules-eval.ts +2 -2
  188. package/src/database/sandbox/write-plane.ts +2 -2
  189. package/src/database/sandbox-controls.ts +29 -9
  190. package/src/deferred/entry.ts +167 -0
  191. package/src/firestore/lite.ts +91 -0
  192. package/src/firestore/persistence.ts +2 -2
  193. package/src/functions/index.ts +38 -0
  194. package/src/performance/index.ts +31 -0
  195. package/src/remote-config/index.ts +53 -0
  196. package/src/rules/grammar/FirestoreValidator.ts +44 -28
  197. package/src/rules/grammar/document-access-count.ts +75 -0
  198. package/src/rules/linter/ast-utils.ts +2 -36
  199. package/src/rules/linter/hallucinations.ts +123 -25
  200. package/src/rules/linter/linter.ts +32 -10
  201. package/src/rules/rtdb/grammar/simulator.ts +12 -13
  202. package/src/rules/rtdb/simulation/handler.ts +149 -46
  203. package/src/rules/simulator/document-lookups.ts +46 -3
  204. package/src/rules/simulator/eval-error.ts +16 -0
  205. package/src/rules/simulator/evaluation-builtins.ts +35 -8
  206. package/src/rules/simulator/evaluation-context.ts +12 -0
  207. package/src/rules/simulator/evaluator.ts +14 -3
  208. package/src/rules/simulator/handler.ts +68 -4
  209. package/src/rules/simulator/lookup-budget.ts +101 -0
  210. package/src/rules/stdlib-modules.ts +19 -11
  211. package/src/sandbox/admin-firestore/get-firestore.ts +19 -3
  212. package/src/sandbox/index.ts +5 -1
  213. package/src/sandbox/persistence/types.ts +1 -1
  214. package/src/sandbox/remote.ts +2 -2
  215. package/src/sandbox/sandbox-context.ts +69 -9
  216. package/src/sandbox/types/auth-state.ts +1 -1
  217. package/src/sandbox/types/events.ts +2 -1
  218. package/src/sandbox/types/operation.ts +1 -1
  219. package/src/storage/enforce.ts +53 -14
  220. package/src/storage/index.ts +1 -1
  221. package/src/storage/list.ts +1 -1
  222. package/src/storage/sandbox/rules-evaluation-error.ts +32 -0
  223. package/src/storage/sandbox/rules-evaluator.ts +109 -32
  224. package/src/storage/sandbox/rules-methods.ts +21 -7
  225. package/src/storage/service.ts +92 -27
@@ -27,32 +27,84 @@ import { SandboxError } from './types/errors.js';
27
27
  import type { Sandbox } from './types/service.js';
28
28
  import { immutableOperationContext } from './operation-record.js';
29
29
 
30
+ /**
31
+ * Normalize AuthState during SandboxContextImpl context assembly.
32
+ * Automatically projects top-level `tenant` into `token.firebase.tenant`
33
+ * while preserving custom claims and explicit nested token overrides.
34
+ * Never mutates original input.
35
+ */
36
+ export function normalizeAuthState(auth: AuthState): AuthState {
37
+ if (auth === null) return null;
38
+
39
+ if (auth.tenant === undefined) {
40
+ if (auth.token === undefined) {
41
+ return { uid: auth.uid };
42
+ }
43
+ return {
44
+ uid: auth.uid,
45
+ token: structuredClone(auth.token),
46
+ };
47
+ }
48
+
49
+ const token = auth.token !== undefined ? structuredClone(auth.token) : {};
50
+ const existingFirebase = token.firebase;
51
+
52
+ let normalizedFirebase: Record<string, unknown>;
53
+ if (
54
+ typeof existingFirebase === 'object' &&
55
+ existingFirebase !== null &&
56
+ !Array.isArray(existingFirebase)
57
+ ) {
58
+ const fbObj = existingFirebase as Record<string, unknown>;
59
+ normalizedFirebase = {
60
+ ...fbObj,
61
+ tenant: fbObj.tenant !== undefined ? fbObj.tenant : auth.tenant,
62
+ };
63
+ } else {
64
+ normalizedFirebase = { tenant: auth.tenant };
65
+ }
66
+
67
+ token.firebase = normalizedFirebase;
68
+
69
+ return {
70
+ uid: auth.uid,
71
+ tenant: auth.tenant,
72
+ token,
73
+ };
74
+ }
75
+
30
76
  function authLensFor(auth: AuthState): AuthLens {
31
77
  if (auth === null) return { mode: 'anon' };
32
- return auth.token === undefined
33
- ? { mode: 'as', uid: auth.uid }
34
- : { mode: 'as', uid: auth.uid, token: auth.token };
78
+ const lens: Extract<AuthLens, { mode: 'as' }> = { mode: 'as', uid: auth.uid };
79
+ if (auth.token !== undefined) lens.token = auth.token;
80
+ if (auth.tenant !== undefined) lens.tenant = auth.tenant;
81
+ return lens;
35
82
  }
36
83
 
37
84
  export class SandboxContextImpl implements SandboxContext {
85
+ public readonly auth: AuthState;
86
+ public readonly operationContext: OperationContext;
87
+
38
88
  constructor(
39
89
  public readonly sandbox: Sandbox,
40
- public readonly auth: AuthState,
90
+ auth: AuthState,
41
91
  operationContext?: OperationContext,
42
92
  ) {
93
+ validateAuthState(auth);
94
+ const normalized = normalizeAuthState(auth);
95
+ this.auth = normalized;
43
96
  this.operationContext = immutableOperationContext(operationContext ?? {
44
97
  source: { kind: 'unattributed' },
45
- authLens: authLensFor(auth),
98
+ authLens: authLensFor(normalized),
46
99
  });
47
100
  }
48
101
 
49
- public readonly operationContext: OperationContext;
50
-
51
102
  withAuth(auth: AuthState): SandboxContext {
52
103
  validateAuthState(auth);
53
- return new SandboxContextImpl(this.sandbox, auth, {
104
+ const normalized = normalizeAuthState(auth);
105
+ return new SandboxContextImpl(this.sandbox, normalized, {
54
106
  source: this.operationContext.source,
55
- authLens: authLensFor(auth),
107
+ authLens: authLensFor(normalized),
56
108
  ...(this.operationContext.planId === undefined
57
109
  ? {}
58
110
  : { planId: this.operationContext.planId }),
@@ -106,6 +158,14 @@ export function validateAuthState(auth: unknown): asserts auth is AuthState {
106
158
  'withAuth() requires `uid` to be a non-empty string.',
107
159
  );
108
160
  }
161
+ if ('tenant' in obj && obj.tenant !== undefined) {
162
+ if (typeof obj.tenant !== 'string' || obj.tenant.length === 0) {
163
+ throw new SandboxError(
164
+ 'invalid-argument',
165
+ 'withAuth() requires `tenant` (when present) to be a non-empty string.',
166
+ );
167
+ }
168
+ }
109
169
  if ('token' in obj && obj.token !== undefined) {
110
170
  if (obj.token === null || typeof obj.token !== 'object' || Array.isArray(obj.token)) {
111
171
  throw new SandboxError(
@@ -18,5 +18,5 @@
18
18
  * names should reflect that.
19
19
  */
20
20
  export type AuthState =
21
- | { uid: string; token?: Record<string, unknown> }
21
+ | { uid: string; token?: Record<string, unknown>; tenant?: string }
22
22
  | null;
@@ -379,7 +379,8 @@ export interface ServiceMutationEvent {
379
379
  * - storage: `object_put` | `object_delete` | `metadata_update`
380
380
  * - rtdb: `set` | `update` | `remove` | `transaction`
381
381
  * - ai: `generate_content` | `stream_generate_content` |
382
- * `count_tokens` | `request_rejected`
382
+ * `count_tokens` | `request_rejected` | `response_blocked` |
383
+ * `model_substituted`
383
384
  * New ops can be added without a breaking change (consumers switch with a
384
385
  * default branch).
385
386
  */
@@ -15,7 +15,7 @@ export type EventActor =
15
15
  /** The identity/rules lens an operation actually ran under. */
16
16
  export type AuthLens =
17
17
  | { mode: 'admin' }
18
- | { mode: 'as'; uid: string; token?: Record<string, unknown> }
18
+ | { mode: 'as'; uid: string; tenant?: string; token?: Record<string, unknown> }
19
19
  | { mode: 'app-session' }
20
20
  | { mode: 'anon' };
21
21
 
@@ -4,10 +4,9 @@
4
4
  * grow their own copy of the same dispatch.
5
5
  *
6
6
  * Behavior:
7
- * - No rules configured → allow. The v1 scope's session-archive
8
- * ruleset is opt-in; bare `getStorage` with no `rules` option
9
- * keeps the open-by-default semantics consistent with the
10
- * pre-Slice-8 surface.
7
+ * - No rules configured → deny. Matches Firebase production fail-closed
8
+ * security invariants: bare `getStorage` with no `rules` option
9
+ * rejects client operations with `storage/unauthorized`.
11
10
  * - Rules configured → call `evaluateStorageRules`. Throw
12
11
  * `storage/unauthorized` with the evaluator's `reasons` joined
13
12
  * into the message on denial.
@@ -16,9 +15,8 @@
16
15
  * permission governs both download and list, so `list.ts` enforces a
17
16
  * `read` check on the scanned prefix path. A previous v1 scope build
18
17
  * silently bypassed list — that contradicted the rules-enforcement
19
- * contract (a denied tree was still enumerable). With no rules
20
- * configured the check is a no-op (open-by-default), so the
21
- * session-archive demo is unaffected.
18
+ * contract (a denied tree was still enumerable). When no rules are
19
+ * configured the check fails closed (default-deny).
22
20
  *
23
21
  * Studio observability (storage-denial-events): every enforcement
24
22
  * decision — allow, deny, AND admin-plane bypass — lands on the
@@ -44,10 +42,12 @@ import { emitSandboxEvent, makeSandboxOperationEvent } from 'pyric/sandbox/inter
44
42
  import type { EventProvenance } from 'pyric/sandbox';
45
43
  import {
46
44
  storageOperationProvenance,
45
+ type CrossServiceIam,
47
46
  type StorageService,
48
47
  type Target,
49
48
  } from './service.js';
50
49
  import { evaluateStorageRules } from './sandbox/rules-evaluator.js';
50
+ import { RuleEvalError } from './sandbox/rules-evaluation-error.js';
51
51
  import type { EvaluationInput, FirestoreLookup } from './sandbox/rules.js';
52
52
  import { unauthorized } from './errors.js';
53
53
 
@@ -71,18 +71,16 @@ export function enforceRules(
71
71
  return;
72
72
  }
73
73
  if (!service.rules) {
74
- // Open-by-default: no rules configured, no evaluation happened.
75
- // Still emit `allow` for parity — an unrestricted op is legitimately
76
- // "allowed", just never evaluated.
77
- emitOperation(target, input, 'allow', undefined, 'user', false, boundProvenance);
78
- return;
74
+ const reasons = ['No Storage rules configured; default deny.'];
75
+ emitOperation(target, input, 'deny', reasons, 'user', false, boundProvenance);
76
+ throw unauthorized(input.request.method, input.request.path, ' — No Storage rules configured; default deny.');
79
77
  }
80
78
  const evaluationInput = target ? withCanonicalRulesPath(input, target.bucket) : input;
81
79
  const result = evaluateStorageRules(
82
80
  service.rules,
83
81
  evaluationInput,
84
82
  undefined,
85
- firestoreLookupFor(target),
83
+ firestoreLookupFor(target, service.crossServiceIam),
86
84
  );
87
85
  if (!result.allowed) {
88
86
  emitOperation(target, input, 'deny', result.reasons, 'user', true, boundProvenance);
@@ -164,12 +162,53 @@ function emitOperation(
164
162
  *
165
163
  * Calls without a target get no lookup — a rule that reaches for
166
164
  * `firestore.*` there denies "unsupported" rather than false-allowing.
165
+ *
166
+ * `crossServiceIam: 'denied'` models the production project state WITHOUT
167
+ * `roles/firebaserules.firestoreServiceAgent` on the Storage service agent:
168
+ * the lookup capability is still injected (so path validation and laziness
169
+ * behave identically), but every EXECUTED get/exists fails. See
170
+ * {@link crossServiceIamDeniedLookup}.
167
171
  */
168
- function firestoreLookupFor(target?: Target): FirestoreLookup | undefined {
172
+ function firestoreLookupFor(
173
+ target: Target | undefined,
174
+ crossServiceIam: CrossServiceIam,
175
+ ): FirestoreLookup | undefined {
169
176
  if (!target) return undefined;
177
+ if (crossServiceIam === 'denied') return crossServiceIamDeniedLookup();
170
178
  const admin = target.sandbox.admin;
171
179
  return {
172
180
  get: (path) => admin.getDocument(path) as Record<string, unknown> | null,
173
181
  exists: (path) => admin.getDocument(path) !== null,
174
182
  };
175
183
  }
184
+
185
+ /**
186
+ * The `crossServiceIam: 'denied'` lookup: every executed
187
+ * `firestore.get()/exists()` throws a {@link RuleEvalError} naming the
188
+ * missing service-agent role, so the rule denies with that reason,
189
+ * mirroring production's IAM-disabled boundary captured by conformance
190
+ * observation `stdlib-realstorage-p3-lookup-budget` (row storage-rules#134):
191
+ * every lookup-executing family DENIES while a short-circuited lookup is
192
+ * never invoked and its rule still ALLOWS. The capture pins only that
193
+ * executed-lookup/short-circuit boundary; how an IAM failure interacts
194
+ * with CEL `&&`/`||` error absorption is NOT pinned by it (no absorption
195
+ * family ran IAM-disabled), so this uses the same absorbable
196
+ * {@link RuleEvalError} class as the existing no-capability deny path.
197
+ *
198
+ * Exported for the conformance replay test
199
+ * (`packages/conformance/test/src/storage-stdlib-real-replay.test.ts`),
200
+ * which runs the captured IAM-disabled matrix against the evaluator with
201
+ * THIS production lookup, not a hand-rolled twin.
202
+ */
203
+ export function crossServiceIamDeniedLookup(): FirestoreLookup {
204
+ const fail = (method: 'get' | 'exists'): never => {
205
+ throw new RuleEvalError(
206
+ `firestore.${method}() failed: cross-service Firestore access is not authorized: ` +
207
+ "the Storage service agent lacks roles/firebaserules.firestoreServiceAgent (crossServiceIam: 'denied')",
208
+ );
209
+ };
210
+ return {
211
+ get: () => fail('get'),
212
+ exists: () => fail('exists'),
213
+ };
214
+ }
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  export { getStorageSandbox, TARGET_SYMBOL } from './service.js';
25
- export type { FirebaseStorage, StorageOptions, Target, SandboxTarget } from './service.js';
25
+ export type { CrossServiceIam, FirebaseStorage, StorageOptions, Target, SandboxTarget } from './service.js';
26
26
  export { getStorage, connectStorageEmulator } from './instances.js';
27
27
 
28
28
  export { StorageError } from './errors.js';
@@ -52,7 +52,7 @@ export async function listAll(refIn: StorageReference): Promise<ListResult> {
52
52
  // requires `read` on the scanned ref's path. Prefixes have no
53
53
  // backing object, so `resource` is null (a list of an unauthorized
54
54
  // tree throws `storage/unauthorized`, same as a denied read). When
55
- // no rules are configured this is a no-op (open-by-default).
55
+ // no rules are configured, enforcement fails closed.
56
56
  enforceRules(service, {
57
57
  request: {
58
58
  auth: storageAuth(target),
@@ -1,2 +1,34 @@
1
1
  /** Fatal evaluator misuse caught at the allow boundary and converted to a deny. */
2
2
  export class RuleEvalError extends Error {}
3
+
4
+ /**
5
+ * A construct whose production verdict is unknowable locally: either
6
+ * production rejects the ruleset at deploy time (undefined function, wrong
7
+ * arity, unresolved import, unknown namespace method) or the simulator
8
+ * cannot model the construct at all. Its effect is EVALUATION-WIDE in
9
+ * production, where a determining `&&`/`||` operand cannot rescue it, so it is
10
+ * never absorbed and always fails closed.
11
+ */
12
+ export class RuleUnsupportedError extends RuleEvalError {}
13
+
14
+ /**
15
+ * A resource-limit exhaustion (the two-document Firestore lookup cap, the
16
+ * max call depth). Production fails the WHOLE evaluation closed on these:
17
+ * they are not CEL error values, so commutative `&&`/`||` absorption must
18
+ * not turn one into an allow (same posture as the Firestore simulator's
19
+ * LookupBudgetError precedent).
20
+ */
21
+ export class RuleResourceLimitError extends RuleEvalError {}
22
+
23
+ /**
24
+ * True for the errors that CEL `&&`/`||` absorption may treat as an error
25
+ * VALUE at an operand boundary: genuine rule-evaluation failures, excluding
26
+ * the unsupported/compile-reject and resource-limit classes above.
27
+ */
28
+ export function isAbsorbableEvalError(err: unknown): err is RuleEvalError {
29
+ return (
30
+ err instanceof RuleEvalError
31
+ && !(err instanceof RuleUnsupportedError)
32
+ && !(err instanceof RuleResourceLimitError)
33
+ );
34
+ }
@@ -12,7 +12,12 @@ import {
12
12
  } from './rules.js';
13
13
  import { evalMethodCall } from './rules-methods.js';
14
14
  import { formatPath, matchSegments, splitPath } from './rules-path-match.js';
15
- import { RuleEvalError } from './rules-evaluation-error.js';
15
+ import {
16
+ RuleEvalError,
17
+ RuleResourceLimitError,
18
+ RuleUnsupportedError,
19
+ isAbsorbableEvalError,
20
+ } from './rules-evaluation-error.js';
16
21
  import {
17
22
  RuleError,
18
23
  describeRulesType as describeType,
@@ -92,6 +97,10 @@ export function evaluateStorageRules(
92
97
  );
93
98
  continue;
94
99
  }
100
+ // Unverified: production's behavior for a non-boolean allow
101
+ // condition (a CEL type error there, rather than this truthiness
102
+ // coercion) has not been captured. The coercion stays as written
103
+ // until a production capture settles it.
95
104
  result = truthy(value);
96
105
  } catch (err) {
97
106
  // Any function-evaluation failure (undefined function, wrong
@@ -186,12 +195,17 @@ export interface EvalCtx {
186
195
  /**
187
196
  * Walk an `Expr` against the bindings + path params. Missing bindings or
188
197
  * members and invalid operations produce `RuleError` values. They propagate
189
- * unless evaluation short-circuits around them or an explicitly modeled
190
- * boolean case absorbs them (for example, `<error> || true`). Any `RuleError`
191
- * that reaches an allow boundary denies with its production-shaped reason.
198
+ * unless a `&&`/`||` operand that uniquely determines the result absorbs
199
+ * them COMMUTATIVELY, CEL-style (`<error> || true` → true, and
200
+ * `<error> && false` → false, see the binary case). Any `RuleError` that
201
+ * reaches an allow boundary denies with its production-shaped reason.
192
202
  *
193
- * User-defined function failures throw `RuleEvalError`; the allow boundary
194
- * likewise catches them and denies instead of falling through to a
203
+ * Function-evaluation failures throw `RuleEvalError`; at a `&&`/`||`
204
+ * operand boundary the ABSORBABLE ones are converted to error values so
205
+ * they participate in the same absorption, while unsupported/compile-reject
206
+ * (`RuleUnsupportedError`) and resource-limit (`RuleResourceLimitError`)
207
+ * failures re-throw and fail the evaluation closed. The allow boundary
208
+ * catches whatever still throws and denies instead of falling through to a
195
209
  * potentially truthy value.
196
210
  */
197
211
  export function evalExpr(expr: Expr, ctx: EvalCtx): unknown {
@@ -236,25 +250,37 @@ export function evalExpr(expr: Expr, ctx: EvalCtx): unknown {
236
250
  case 'path':
237
251
  // A path literal is only meaningful as a `firestore.get()/exists()`
238
252
  // argument (handled directly there). Reaching it anywhere else means
239
- // the rule used it out of position — deny rather than coerce.
240
- throw new RuleEvalError('a Firestore path literal is only valid as an argument to firestore.get()/exists()');
253
+ // the rule used it out of position, a compile-reject-class failure
254
+ // (never absorbed): deny rather than coerce.
255
+ throw new RuleUnsupportedError('a Firestore path literal is only valid as an argument to firestore.get()/exists()');
241
256
  case 'unary': {
242
- // An error survives negation (production: `!(resource.name == 'x')` with
243
- // `name` absent DENIES). Propagate rather than flipping it to `true`.
244
257
  const a = evalExpr(expr.arg, ctx);
258
+ if (expr.op === '!') {
259
+ if (isErr(a)) {
260
+ throw new RuleEvalError(a.message);
261
+ }
262
+ if (typeof a !== 'boolean') {
263
+ throw new RuleEvalError(`Unary '!' expects a boolean, got ${describeType(a)}.`);
264
+ }
265
+ return !a;
266
+ }
245
267
  if (isErr(a)) return a;
246
268
  if (expr.op === '-') {
247
269
  if (a instanceof RulesFloat) return new RulesFloat(-a.value);
248
270
  if (typeof a !== 'number') return new RuleError(`Unary '-' applied to ${describeType(a)}.`);
249
271
  return -a;
250
272
  }
251
- return !truthy(a);
273
+ return undefined;
252
274
  }
253
275
  case 'ternary': {
254
276
  const c = evalExpr(expr.cond, ctx);
255
277
  // An error condition denies the whole conditional; it must not fall
256
278
  // through to the alternate branch and potentially allow.
257
279
  if (isErr(c)) return c;
280
+ // Unverified: production's behavior for a non-boolean ternary
281
+ // condition (a CEL type error there, rather than this truthiness
282
+ // coercion) has not been captured. The coercion stays as written
283
+ // until a production capture settles it.
258
284
  return truthy(c) ? evalExpr(expr.then, ctx) : evalExpr(expr.else, ctx);
259
285
  }
260
286
  case 'in': {
@@ -318,23 +344,12 @@ export function evalExpr(expr: Expr, ctx: EvalCtx): unknown {
318
344
  return new RuleError(`Slice applied to ${describeType(t)} (expected a list or string).`);
319
345
  }
320
346
  case 'binary': {
321
- // Short-circuit && / || so half-undefined chains don't trip
322
- // (e.g. `request.auth != null && request.auth.uid == 'a'`).
323
- if (expr.op === '&&') {
324
- const l = evalExpr(expr.left, ctx);
325
- if (isErr(l)) return l;
326
- return truthy(l) ? evalExpr(expr.right, ctx) : l;
327
- }
328
- if (expr.op === '||') {
329
- const l = evalExpr(expr.left, ctx);
330
- if (isErr(l)) {
331
- // Production: `<error> || true` ALLOWS — a true disjunct rescues the
332
- // error; `<error> || false` stays an error.
333
- const r = evalExpr(expr.right, ctx);
334
- return truthy(r) ? r : l;
335
- }
336
- return truthy(l) ? l : evalExpr(expr.right, ctx);
337
- }
347
+ // RULES-B3: && and || are COMMUTATIVE error-absorbing operators in
348
+ // CEL, not JS left-to-right short-circuit. The two operators differ
349
+ // only in which operand value uniquely determines the result: false
350
+ // for &&, true for ||. Both are evaluated by the one helper below.
351
+ if (expr.op === '&&') return evalAbsorbingOperator(expr.left, expr.right, false, ctx);
352
+ if (expr.op === '||') return evalAbsorbingOperator(expr.left, expr.right, true, ctx);
338
353
  const l = evalExpr(expr.left, ctx);
339
354
  if (isErr(l)) return l;
340
355
  const r = evalExpr(expr.right, ctx);
@@ -372,6 +387,64 @@ export function evalExpr(expr: Expr, ctx: EvalCtx): unknown {
372
387
  }
373
388
  }
374
389
 
390
+ /**
391
+ * Evaluate one commutative error-absorbing operator, `&&` or `||`. The two
392
+ * differ only in `determining`, the operand value that fixes the result on
393
+ * its own: `false` for `&&`, `true` for `||`.
394
+ *
395
+ * If either operand evaluates to `determining`, that is the result and an
396
+ * error in the other operand is absorbed, whichever side it sits on. So
397
+ * `error && false` evaluates to false and `error || true` to true, while
398
+ * `error && true` and `error || false` propagate the error and deny.
399
+ * Laziness is preserved in the no-error path: a determining left operand
400
+ * skips the right one entirely.
401
+ */
402
+ function evalAbsorbingOperator(
403
+ left: Expr,
404
+ right: Expr,
405
+ determining: boolean,
406
+ ctx: EvalCtx,
407
+ ): boolean | RuleError {
408
+ const l = evalLogicalOperand(left, ctx);
409
+ if (l === determining) return determining; // left determines; right unevaluated
410
+ const r = evalLogicalOperand(right, ctx);
411
+ if (r === determining) return determining; // right determines and absorbs any left error
412
+ if (isErr(l)) return l; // left errored and nothing determined
413
+ return r; // left was non-determining; right decides
414
+ }
415
+
416
+ /**
417
+ * Evaluate one `&&`/`||` operand tri-state: `true`, `false`, or a
418
+ * {@link RuleError} value the operator may absorb commutatively.
419
+ *
420
+ * - A thrown ABSORBABLE {@link RuleEvalError} (for example
421
+ * `firestore.get()` without an injected capability, or `!` on an error)
422
+ * is converted to an error VALUE here so a determining sibling operand
423
+ * can absorb it: production evaluates these positions to a
424
+ * position-local error.
425
+ * - {@link RuleUnsupportedError} (compile-reject or unmodelable) and
426
+ * {@link RuleResourceLimitError} (lookup cap, call depth) re-throw:
427
+ * production fails those closed for the WHOLE evaluation, so no
428
+ * determining operand may rescue them (the lookup-budget precedent).
429
+ * - A non-boolean, non-error operand is a CEL TYPE error (RULES-B6,
430
+ * captured by rules-firestore-strict-boolean-control-flow): it becomes
431
+ * an absorbable error value, never a truthy/falsy coercion.
432
+ */
433
+ function evalLogicalOperand(expr: Expr, ctx: EvalCtx): boolean | RuleError {
434
+ let v: unknown;
435
+ try {
436
+ v = evalExpr(expr, ctx);
437
+ } catch (err) {
438
+ if (isAbsorbableEvalError(err)) return new RuleError(err.message);
439
+ throw err;
440
+ }
441
+ if (isErr(v)) return v;
442
+ if (typeof v !== 'boolean') {
443
+ return new RuleError(`Expected a boolean '&&'/'||' operand, got ${describeType(v)}.`);
444
+ }
445
+ return v;
446
+ }
447
+
375
448
  /**
376
449
  * Evaluate a user-defined function call. Arguments are evaluated in the
377
450
  * CALLER's context, then bound to the function's parameters; the body
@@ -382,22 +455,26 @@ export function evalExpr(expr: Expr, ctx: EvalCtx): unknown {
382
455
  */
383
456
  function evalCall(expr: Extract<Expr, { kind: 'call' }>, ctx: EvalCtx): unknown {
384
457
  const fn = ctx.funcs.get(expr.name);
458
+ // Undefined functions, unresolved imports, and arity mismatches are
459
+ // COMPILE-reject failures in production (the ruleset never deploys), so
460
+ // they are RuleUnsupportedError: unabsorbable by &&/||, always deny.
385
461
  if (!fn) {
386
- throw new RuleEvalError(`undefined function ${expr.name}()`);
462
+ throw new RuleUnsupportedError(`undefined function ${expr.name}()`);
387
463
  }
388
464
  if (fn.unresolvedImport !== undefined) {
389
- throw new RuleEvalError(
465
+ throw new RuleUnsupportedError(
390
466
  `function ${expr.name}() is imported from '${fn.unresolvedImport}', but import module resolution is not implemented`,
391
467
  );
392
468
  }
393
469
  if (fn.params.length !== expr.args.length) {
394
- throw new RuleEvalError(
470
+ throw new RuleUnsupportedError(
395
471
  `function ${expr.name}() expects ${fn.params.length} argument(s), got ${expr.args.length}`,
396
472
  );
397
473
  }
398
474
  const depth = ctx.depth + 1;
399
475
  if (depth > MAX_CALL_DEPTH) {
400
- throw new RuleEvalError(
476
+ // Resource-limit class: fails the evaluation closed, never absorbed.
477
+ throw new RuleResourceLimitError(
401
478
  `function ${expr.name}() exceeded max call depth ${MAX_CALL_DEPTH}`,
402
479
  );
403
480
  }
@@ -1,7 +1,11 @@
1
1
  import { RulesFloat } from '../../rules/simulator/wrappers/float.js';
2
2
  import type { Expr } from './rules.js';
3
3
  import { evalExpr, type EvalCtx } from './rules-evaluator.js';
4
- import { RuleEvalError } from './rules-evaluation-error.js';
4
+ import {
5
+ RuleEvalError,
6
+ RuleResourceLimitError,
7
+ RuleUnsupportedError,
8
+ } from './rules-evaluation-error.js';
5
9
  import {
6
10
  RuleError,
7
11
  describeRulesType as describeType,
@@ -86,7 +90,10 @@ export function evalMethodCall(expr: Extract<Expr, { kind: 'methodcall' }>, ctx:
86
90
  return evalMapGet(expr, ctx);
87
91
  }
88
92
 
89
- throw new RuleEvalError(`unsupported method .${expr.method}()`);
93
+ // An unknown method name is either unmodeled here or rejected by
94
+ // production's compiler. Its verdict is unknowable locally, so it is
95
+ // unabsorbable (fails closed even under a determining &&/|| operand).
96
+ throw new RuleUnsupportedError(`unsupported method .${expr.method}()`);
90
97
  }
91
98
 
92
99
  /**
@@ -111,7 +118,8 @@ function evalFirestoreBuiltin(
111
118
  ctx: EvalCtx,
112
119
  ): unknown {
113
120
  if (expr.method !== 'get' && expr.method !== 'exists') {
114
- throw new RuleEvalError(`unsupported method firestore.${expr.method}()`);
121
+ // Unknown namespace method, compile-reject class, never absorbed.
122
+ throw new RuleUnsupportedError(`unsupported method firestore.${expr.method}()`);
115
123
  }
116
124
  if (!ctx.firestoreLookup) {
117
125
  // No sandbox-backed capability injected — keep the deny-with-reason
@@ -121,16 +129,20 @@ function evalFirestoreBuiltin(
121
129
  );
122
130
  }
123
131
  if (expr.args.length !== 1) {
124
- throw new RuleEvalError(`firestore.${expr.method}() expects a single path argument`);
132
+ // Wrong call shape: production rejects at compile; never absorbed.
133
+ throw new RuleUnsupportedError(`firestore.${expr.method}() expects a single path argument`);
125
134
  }
126
135
  const arg = expr.args[0];
127
136
  if (arg.kind !== 'path') {
128
- throw new RuleEvalError(`firestore.${expr.method}() requires a /databases/.../documents/... path literal`);
137
+ throw new RuleUnsupportedError(`firestore.${expr.method}() requires a /databases/.../documents/... path literal`);
129
138
  }
130
139
  const docPath = buildFirestoreDocPath(arg, ctx);
131
140
  if (!ctx.firestoreAccesses.has(docPath)) {
132
141
  if (ctx.firestoreAccesses.size >= 2) {
133
- throw new RuleEvalError('firestore access limit exceeded: at most two distinct documents');
142
+ // Resource-limit class, the same posture as the Firestore lookup
143
+ // budget: production fails the whole evaluation closed, so a
144
+ // determining &&/|| operand must NOT absorb this into an allow.
145
+ throw new RuleResourceLimitError('firestore access limit exceeded: at most two distinct documents');
134
146
  }
135
147
  ctx.firestoreAccesses.add(docPath);
136
148
  }
@@ -140,7 +152,9 @@ function evalFirestoreBuiltin(
140
152
  const fields = ctx.firestoreLookup.get(docPath);
141
153
  if (fields === null) {
142
154
  // A missing get is a Rules error VALUE: it denies at the allow boundary,
143
- // but participates in CEL error absorption (`error || true` allows).
155
+ // but participates in COMMUTATIVE CEL error absorption in `&&`/`||`
156
+ // (`error || true` allows, and `error && false` evaluates to false,
157
+ // see the tri-state operand handling in rules-evaluator.ts).
144
158
  return new RuleError(`firestore.get() targeted a nonexistent document: ${docPath}`);
145
159
  }
146
160
  return { data: fields };