@xemahq/kernel-contracts 0.2.0 → 0.2.2

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 (147) hide show
  1. package/dist/agent-workspace/awp-spec.json +1 -1
  2. package/dist/biome/index.d.ts +5 -0
  3. package/dist/biome/index.d.ts.map +1 -1
  4. package/dist/biome/index.js +5 -0
  5. package/dist/biome/index.js.map +1 -1
  6. package/dist/biome/lib/biome-audience.d.ts +8 -0
  7. package/dist/biome/lib/biome-audience.d.ts.map +1 -0
  8. package/dist/biome/lib/biome-audience.js +12 -0
  9. package/dist/biome/lib/biome-audience.js.map +1 -0
  10. package/dist/biome/lib/biome-availability-grant.d.ts +10 -0
  11. package/dist/biome/lib/biome-availability-grant.d.ts.map +1 -0
  12. package/dist/biome/lib/biome-availability-grant.js +12 -0
  13. package/dist/biome/lib/biome-availability-grant.js.map +1 -0
  14. package/dist/biome/lib/biome-origin.d.ts +8 -0
  15. package/dist/biome/lib/biome-origin.d.ts.map +1 -0
  16. package/dist/biome/lib/biome-origin.js +12 -0
  17. package/dist/biome/lib/biome-origin.js.map +1 -0
  18. package/dist/biome/lib/biome-target.d.ts +7 -0
  19. package/dist/biome/lib/biome-target.d.ts.map +1 -0
  20. package/dist/biome/lib/biome-target.js +11 -0
  21. package/dist/biome/lib/biome-target.js.map +1 -0
  22. package/dist/biome/lib/biome-tier.d.ts +10 -0
  23. package/dist/biome/lib/biome-tier.d.ts.map +1 -0
  24. package/dist/biome/lib/biome-tier.js +19 -0
  25. package/dist/biome/lib/biome-tier.js.map +1 -0
  26. package/dist/contribution/lib/contribution-kind.d.ts +2 -1
  27. package/dist/contribution/lib/contribution-kind.d.ts.map +1 -1
  28. package/dist/contribution/lib/contribution-kind.js +1 -0
  29. package/dist/contribution/lib/contribution-kind.js.map +1 -1
  30. package/dist/distribution/index.d.ts +5 -0
  31. package/dist/distribution/index.d.ts.map +1 -0
  32. package/dist/distribution/index.js +21 -0
  33. package/dist/distribution/index.js.map +1 -0
  34. package/dist/distribution/lib/distribution-lock.d.ts +24 -0
  35. package/dist/distribution/lib/distribution-lock.d.ts.map +1 -0
  36. package/dist/distribution/lib/distribution-lock.js +34 -0
  37. package/dist/distribution/lib/distribution-lock.js.map +1 -0
  38. package/dist/distribution/lib/distribution-selector.d.ts +12 -0
  39. package/dist/distribution/lib/distribution-selector.d.ts.map +1 -0
  40. package/dist/distribution/lib/distribution-selector.js +13 -0
  41. package/dist/distribution/lib/distribution-selector.js.map +1 -0
  42. package/dist/distribution/lib/distribution.d.ts +21 -0
  43. package/dist/distribution/lib/distribution.d.ts.map +1 -0
  44. package/dist/distribution/lib/distribution.js +30 -0
  45. package/dist/distribution/lib/distribution.js.map +1 -0
  46. package/dist/distribution/lib/image-lock.d.ts +20 -0
  47. package/dist/distribution/lib/image-lock.d.ts.map +1 -0
  48. package/dist/distribution/lib/image-lock.js +30 -0
  49. package/dist/distribution/lib/image-lock.js.map +1 -0
  50. package/dist/inquiry/lib/enums.d.ts +4 -0
  51. package/dist/inquiry/lib/enums.d.ts.map +1 -1
  52. package/dist/inquiry/lib/enums.js +6 -1
  53. package/dist/inquiry/lib/enums.js.map +1 -1
  54. package/dist/inquiry/lib/inquiry.d.ts +5 -9
  55. package/dist/inquiry/lib/inquiry.d.ts.map +1 -1
  56. package/dist/inquiry/lib/inquiry.js +4 -2
  57. package/dist/inquiry/lib/inquiry.js.map +1 -1
  58. package/dist/invocation/index.d.ts +10 -0
  59. package/dist/invocation/index.d.ts.map +1 -0
  60. package/dist/invocation/index.js +26 -0
  61. package/dist/invocation/index.js.map +1 -0
  62. package/dist/invocation/lib/execution-requirements.d.ts +24 -0
  63. package/dist/invocation/lib/execution-requirements.d.ts.map +1 -0
  64. package/dist/invocation/lib/execution-requirements.js +25 -0
  65. package/dist/invocation/lib/execution-requirements.js.map +1 -0
  66. package/dist/invocation/lib/invocation-decision.d.ts +8 -0
  67. package/dist/invocation/lib/invocation-decision.d.ts.map +1 -0
  68. package/dist/invocation/lib/invocation-decision.js +10 -0
  69. package/dist/invocation/lib/invocation-decision.js.map +1 -0
  70. package/dist/invocation/lib/invocation-mode.d.ts +8 -0
  71. package/dist/invocation/lib/invocation-mode.d.ts.map +1 -0
  72. package/dist/invocation/lib/invocation-mode.js +12 -0
  73. package/dist/invocation/lib/invocation-mode.js.map +1 -0
  74. package/dist/invocation/lib/invocation-priority.d.ts +8 -0
  75. package/dist/invocation/lib/invocation-priority.d.ts.map +1 -0
  76. package/dist/invocation/lib/invocation-priority.js +12 -0
  77. package/dist/invocation/lib/invocation-priority.js.map +1 -0
  78. package/dist/invocation/lib/invocation-record.d.ts +27 -0
  79. package/dist/invocation/lib/invocation-record.d.ts.map +1 -0
  80. package/dist/invocation/lib/invocation-record.js +29 -0
  81. package/dist/invocation/lib/invocation-record.js.map +1 -0
  82. package/dist/invocation/lib/invocation-status.d.ts +13 -0
  83. package/dist/invocation/lib/invocation-status.d.ts.map +1 -0
  84. package/dist/invocation/lib/invocation-status.js +17 -0
  85. package/dist/invocation/lib/invocation-status.js.map +1 -0
  86. package/dist/invocation/lib/invoke-request.d.ts +13 -0
  87. package/dist/invocation/lib/invoke-request.d.ts.map +1 -0
  88. package/dist/invocation/lib/invoke-request.js +15 -0
  89. package/dist/invocation/lib/invoke-request.js.map +1 -0
  90. package/dist/invocation/lib/invoke-response.d.ts +17 -0
  91. package/dist/invocation/lib/invoke-response.d.ts.map +1 -0
  92. package/dist/invocation/lib/invoke-response.js +19 -0
  93. package/dist/invocation/lib/invoke-response.js.map +1 -0
  94. package/dist/invocation/lib/isolation-level.d.ts +7 -0
  95. package/dist/invocation/lib/isolation-level.d.ts.map +1 -0
  96. package/dist/invocation/lib/isolation-level.js +11 -0
  97. package/dist/invocation/lib/isolation-level.js.map +1 -0
  98. package/dist/mail-source/index.d.ts +2 -0
  99. package/dist/mail-source/index.d.ts.map +1 -0
  100. package/dist/mail-source/index.js +18 -0
  101. package/dist/mail-source/index.js.map +1 -0
  102. package/dist/mail-source/lib/mail-event.d.ts +58 -0
  103. package/dist/mail-source/lib/mail-event.d.ts.map +1 -0
  104. package/dist/mail-source/lib/mail-event.js +41 -0
  105. package/dist/mail-source/lib/mail-event.js.map +1 -0
  106. package/dist/policy/index.d.ts +1 -0
  107. package/dist/policy/index.d.ts.map +1 -1
  108. package/dist/policy/index.js +1 -0
  109. package/dist/policy/index.js.map +1 -1
  110. package/dist/policy/lib/egress-allowlist.d.ts +18 -0
  111. package/dist/policy/lib/egress-allowlist.d.ts.map +1 -0
  112. package/dist/policy/lib/egress-allowlist.js +130 -0
  113. package/dist/policy/lib/egress-allowlist.js.map +1 -0
  114. package/dist/policy/lib/obligations.d.ts +12 -1
  115. package/dist/policy/lib/obligations.d.ts.map +1 -1
  116. package/dist/policy/lib/obligations.js +14 -1
  117. package/dist/policy/lib/obligations.js.map +1 -1
  118. package/package.json +1 -1
  119. package/src/biome/index.ts +5 -0
  120. package/src/biome/lib/biome-audience.ts +27 -0
  121. package/src/biome/lib/biome-availability-grant.ts +42 -0
  122. package/src/biome/lib/biome-origin.ts +26 -0
  123. package/src/biome/lib/biome-target.ts +16 -0
  124. package/src/biome/lib/biome-tier.ts +44 -0
  125. package/src/contribution/lib/contribution-kind.ts +14 -0
  126. package/src/distribution/index.ts +4 -0
  127. package/src/distribution/lib/distribution-lock.ts +88 -0
  128. package/src/distribution/lib/distribution-selector.ts +35 -0
  129. package/src/distribution/lib/distribution.ts +82 -0
  130. package/src/distribution/lib/image-lock.ts +77 -0
  131. package/src/inquiry/lib/enums.ts +13 -0
  132. package/src/inquiry/lib/inquiry.ts +22 -4
  133. package/src/invocation/index.ts +9 -0
  134. package/src/invocation/lib/execution-requirements.ts +61 -0
  135. package/src/invocation/lib/invocation-decision.ts +21 -0
  136. package/src/invocation/lib/invocation-mode.ts +26 -0
  137. package/src/invocation/lib/invocation-priority.ts +16 -0
  138. package/src/invocation/lib/invocation-record.ts +59 -0
  139. package/src/invocation/lib/invocation-status.ts +32 -0
  140. package/src/invocation/lib/invoke-request.ts +34 -0
  141. package/src/invocation/lib/invoke-response.ts +39 -0
  142. package/src/invocation/lib/isolation-level.ts +20 -0
  143. package/src/mail-source/index.ts +1 -0
  144. package/src/mail-source/lib/mail-event.ts +106 -0
  145. package/src/policy/index.ts +1 -0
  146. package/src/policy/lib/egress-allowlist.ts +239 -0
  147. package/src/policy/lib/obligations.ts +61 -0
@@ -0,0 +1,106 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── Canonical mail-event contract ──
3
+ //
4
+ // Domain biomes subscribe to these `type`s to add email workflows WITHOUT
5
+ // importing MailOps. A domain biome (editorial, finance, support, …) listens
6
+ // for `mail.message.received` / `mail.message.classified` / `mail.thread.updated`
7
+ // on the event bus and dispatches its OWN workflows — exactly the way the
8
+ // search-source registry (`@xemahq/kernel-contracts/search-source`) lets
9
+ // search-api index any biome without importing a single source client.
10
+ //
11
+ // These contracts live in the KERNEL so a subscriber depends on the kernel
12
+ // package, never on `mailops-api`. MailOps is the single producer: its
13
+ // publisher imports this contract and emits the events; subscribers import the
14
+ // same contract and match on it. Producer and consumers share one source of
15
+ // truth, with zero service-to-service code coupling.
16
+ //
17
+ // The payloads are deliberately domain-AGNOSTIC: stable identity + addressing
18
+ // fields that fit any mailbox. `classification` and `labels` are optional
19
+ // passthrough — a domain that runs a classifier upstream can carry its verdict
20
+ // here, but the contract itself never invents domain semantics.
21
+ // ═══════════════════════════════════════════════════════════════════════════
22
+
23
+ import { z } from 'zod';
24
+
25
+ /**
26
+ * Closed set of canonical mail-event `type`s. These are the wire identifiers
27
+ * domain biomes subscribe to — never a free-form string at a call site.
28
+ */
29
+ export enum MailEventType {
30
+ /** A mail message was received + persisted. */
31
+ MessageReceived = 'mail.message.received',
32
+ /** A received message carries (or was given) a classification verdict. */
33
+ MessageClassified = 'mail.message.classified',
34
+ /** A thread's roll-up (membership / latest activity) changed. */
35
+ ThreadUpdated = 'mail.thread.updated',
36
+ }
37
+
38
+ /**
39
+ * The stable identity + addressing fields every mail event shares. Domain
40
+ * biomes match/route on exactly these — nothing producer-internal leaks here.
41
+ */
42
+ export const MailEventBaseSchema = z.object({
43
+ orgId: z.string().min(1),
44
+ projectId: z.string().min(1).optional(),
45
+ /** Mailbox the message belongs to (provider-native key or address). */
46
+ mailboxRef: z.string().min(1),
47
+ /** Provider message id of the inbound email. */
48
+ messageId: z.string().min(1),
49
+ /** Opaque per-conversation thread reference, when the message threads. */
50
+ threadRef: z.string().min(1).optional(),
51
+ from: z.string().min(1),
52
+ subject: z.string().optional(),
53
+ /** ISO-8601 timestamp the provider stamped on the message. */
54
+ receivedAt: z.string().min(1),
55
+ /** Optional provider/domain labels carried verbatim. */
56
+ labels: z.array(z.string()).optional(),
57
+ });
58
+
59
+ /** `mail.message.received` payload. */
60
+ export const MailMessageReceivedSchema = MailEventBaseSchema.extend({
61
+ /**
62
+ * Optional classification passthrough. Present when an upstream classifier
63
+ * already tagged the message at ingest; absent otherwise.
64
+ */
65
+ classification: z.string().min(1).optional(),
66
+ });
67
+
68
+ /**
69
+ * `mail.message.classified` payload. Same identity as received, with the
70
+ * classification required (the event exists BECAUSE a verdict was produced).
71
+ */
72
+ export const MailMessageClassifiedSchema = MailEventBaseSchema.extend({
73
+ classification: z.string().min(1),
74
+ });
75
+
76
+ /** `mail.thread.updated` payload — a thread roll-up changed. */
77
+ export const MailThreadUpdatedSchema = z.object({
78
+ orgId: z.string().min(1),
79
+ projectId: z.string().min(1).optional(),
80
+ mailboxRef: z.string().min(1),
81
+ threadRef: z.string().min(1),
82
+ subject: z.string().optional(),
83
+ /** Distinct participant addresses in the thread. */
84
+ participants: z.array(z.string()),
85
+ /** Number of messages in the thread at the time of the event. */
86
+ messageCount: z.number().int().nonnegative(),
87
+ /** ISO-8601 timestamp of the most recent message in the thread. */
88
+ lastReceivedAt: z.string().min(1),
89
+ });
90
+
91
+ export type MailMessageReceivedPayload = z.infer<
92
+ typeof MailMessageReceivedSchema
93
+ >;
94
+ export type MailMessageClassifiedPayload = z.infer<
95
+ typeof MailMessageClassifiedSchema
96
+ >;
97
+ export type MailThreadUpdatedPayload = z.infer<typeof MailThreadUpdatedSchema>;
98
+
99
+ /** Schema version stamped on every canonical mail event. */
100
+ export const MAIL_EVENT_SCHEMA_VERSION = '1.0.0' as const;
101
+
102
+ /** Producer URI for every canonical mail event. */
103
+ export const MAIL_EVENT_SOURCE = '/xema/services/mailops-api' as const;
104
+
105
+ /** `ehdatatype` discriminator for the mail-event family. */
106
+ export const MAIL_EVENT_DATA_TYPE = 'mailops' as const;
@@ -1,3 +1,4 @@
1
1
  export * from './lib/obligations';
2
+ export * from './lib/egress-allowlist';
2
3
  export * from './lib/route-hints';
3
4
  export * from './lib/policy';
@@ -0,0 +1,239 @@
1
+ import {
2
+ EgressAllowlistObligationSchema,
3
+ type EgressAllowlistObligation,
4
+ } from './obligations';
5
+
6
+ /**
7
+ * Shared, deterministic egress-decision matcher for the
8
+ * `PolicyObligationKind.EgressAllowlist` obligation.
9
+ *
10
+ * This is the SINGLE implementation every outbound-fetch caller reuses —
11
+ * do not copy the wildcard semantics into a fetch activity. It is a pure
12
+ * function with zero IO and zero RegExp backtracking surface (patterns are
13
+ * tokenised and compared label-by-label / segment-by-segment), so it is
14
+ * safe to call on every outbound request.
15
+ *
16
+ * Layered control, NOT a replacement: the IP-level SSRF block (private /
17
+ * loopback / link-local / metadata ranges) lives in the fetch activity and
18
+ * is ALWAYS applied. This matcher adds a SEPARATE domain-level (hostname /
19
+ * URL-prefix wildcard) gate on top, driven by org policy.
20
+ */
21
+
22
+ /** Result of an egress evaluation. `allowed=false` carries a typed reason. */
23
+ export type EgressDecision =
24
+ | { readonly allowed: true }
25
+ | { readonly allowed: false; readonly reason: EgressDenyReason };
26
+
27
+ /** Closed set of egress-deny reasons — never a free-form string. */
28
+ export enum EgressDenyReason {
29
+ /** Target URL did not parse, or used a non-http(s) scheme. */
30
+ InvalidTarget = 'invalid-target',
31
+ /** Target matched a `deny` pattern. */
32
+ Blocklisted = 'blocklisted',
33
+ /** Target matched no `allow` pattern. */
34
+ NotAllowlisted = 'not-allowlisted',
35
+ }
36
+
37
+ /**
38
+ * Thrown when the obligation payload itself is malformed (a bad pattern,
39
+ * empty allowlist, …). This is a HARD error at evaluation time — a
40
+ * misconfigured policy must never silently allow egress.
41
+ */
42
+ export class MalformedEgressObligationError extends Error {
43
+ constructor(detail: string) {
44
+ super(`Malformed egress-allowlist obligation: ${detail}`);
45
+ this.name = 'MalformedEgressObligationError';
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Evaluate whether `targetUrl` is permitted by the egress obligation.
51
+ *
52
+ * Fail-fast: a malformed `obligation` payload throws
53
+ * `MalformedEgressObligationError` — it is NOT treated as "allow". A
54
+ * target that does not parse, or uses a non-http(s) scheme, is denied with
55
+ * `EgressDenyReason.InvalidTarget` (the IP-level SSRF guard also rejects
56
+ * those, but this matcher is defensive in isolation).
57
+ *
58
+ * Decision: a target is allowed iff it matches at least one `allow`
59
+ * pattern AND no `deny` pattern. `deny` always wins over `allow`.
60
+ */
61
+ export function evaluateEgress(
62
+ targetUrl: string,
63
+ obligation: EgressAllowlistObligation,
64
+ ): EgressDecision {
65
+ // Re-validate the payload at the evaluation boundary. A producer that
66
+ // bypassed the wire schema (e.g. a hand-built obligation) cannot smuggle
67
+ // a malformed pattern past the matcher.
68
+ const parsed = EgressAllowlistObligationSchema.safeParse(obligation);
69
+ if (!parsed.success) {
70
+ throw new MalformedEgressObligationError(parsed.error.issues[0]?.message ?? 'invalid payload');
71
+ }
72
+ const { allow, deny } = parsed.data;
73
+
74
+ const target = parseTarget(targetUrl);
75
+ if (target === null) {
76
+ return { allowed: false, reason: EgressDenyReason.InvalidTarget };
77
+ }
78
+
79
+ if (deny !== undefined) {
80
+ for (const pattern of deny) {
81
+ if (matchPattern(pattern, target)) {
82
+ return { allowed: false, reason: EgressDenyReason.Blocklisted };
83
+ }
84
+ }
85
+ }
86
+
87
+ for (const pattern of allow) {
88
+ if (matchPattern(pattern, target)) {
89
+ return { allowed: true };
90
+ }
91
+ }
92
+ return { allowed: false, reason: EgressDenyReason.NotAllowlisted };
93
+ }
94
+
95
+ /**
96
+ * Boolean convenience wrapper. Returns `true` iff `evaluateEgress`
97
+ * allows the target. Re-throws `MalformedEgressObligationError` — a bad
98
+ * obligation is never silently coerced to a boolean.
99
+ */
100
+ export function isEgressAllowed(
101
+ targetUrl: string,
102
+ obligation: EgressAllowlistObligation,
103
+ ): boolean {
104
+ return evaluateEgress(targetUrl, obligation).allowed;
105
+ }
106
+
107
+ // ── internals ───────────────────────────────────────────────────────────
108
+
109
+ interface ParsedTarget {
110
+ /** Lower-cased host with no port, no trailing dot. */
111
+ readonly host: string;
112
+ /** `http:` or `https:` (lower-cased, includes the colon, matches URL API). */
113
+ readonly scheme: string;
114
+ /** Path with a single normalised form: leading `/`, no trailing slash (except root `/`). */
115
+ readonly path: string;
116
+ }
117
+
118
+ /**
119
+ * Parse the target into the canonical `(scheme, host, path)` shape the
120
+ * matcher compares against. Returns `null` for an unparseable URL or a
121
+ * non-http(s) scheme — the WHATWG `URL` parser collapses
122
+ * credential/fragment/IP-literal bypass attempts (e.g.
123
+ * `https://evil.com#@good.com`, `https://user:pass@evil.com`) into their
124
+ * real host, so a crafted pattern-looking authority cannot smuggle past
125
+ * the host comparison.
126
+ */
127
+ function parseTarget(rawUrl: string): ParsedTarget | null {
128
+ let u: URL;
129
+ try {
130
+ u = new URL(rawUrl);
131
+ } catch {
132
+ return null;
133
+ }
134
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
135
+ return null;
136
+ }
137
+ const host = u.hostname.toLowerCase().replace(/\.$/, '');
138
+ if (host.length === 0) {
139
+ return null;
140
+ }
141
+ return { host, scheme: u.protocol, path: normalisePath(u.pathname) };
142
+ }
143
+
144
+ /** Collapse `//` and strip a trailing slash (keep root `/`). */
145
+ function normalisePath(pathname: string): string {
146
+ const collapsed = pathname.replace(/\/{2,}/g, '/');
147
+ if (collapsed.length > 1 && collapsed.endsWith('/')) {
148
+ return collapsed.slice(0, -1);
149
+ }
150
+ return collapsed.length === 0 ? '/' : collapsed;
151
+ }
152
+
153
+ /**
154
+ * Match a single pattern against the parsed target. A pattern with a
155
+ * scheme is a URL pattern (scheme + host + path-prefix); otherwise it is a
156
+ * host-only pattern. Deterministic, bounded, no RegExp.
157
+ */
158
+ function matchPattern(pattern: string, target: ParsedTarget): boolean {
159
+ const schemeSep = pattern.indexOf('://');
160
+ if (schemeSep === -1) {
161
+ // Host-only pattern — must contain no path separator.
162
+ if (pattern.includes('/')) {
163
+ // A pattern like `example.com/api` with no scheme is ambiguous;
164
+ // treat it as a non-match rather than guessing. The wire schema
165
+ // already steers authors toward an explicit `https://` URL pattern.
166
+ return false;
167
+ }
168
+ return matchHost(pattern.toLowerCase(), target.host);
169
+ }
170
+
171
+ const scheme = pattern.slice(0, schemeSep + 1).toLowerCase(); // includes trailing `:`
172
+ if (scheme !== target.scheme) {
173
+ return false;
174
+ }
175
+ const rest = pattern.slice(schemeSep + 3); // after `://`
176
+ const slash = rest.indexOf('/');
177
+ const hostPart = (slash === -1 ? rest : rest.slice(0, slash)).toLowerCase();
178
+ const pathPart = slash === -1 ? '' : rest.slice(slash);
179
+ if (!matchHost(hostPart, target.host)) {
180
+ return false;
181
+ }
182
+ return matchPath(pathPart, target.path);
183
+ }
184
+
185
+ /**
186
+ * Host match: label-by-label, `*` matches exactly one label. Both sides
187
+ * are tokenised on `.`; label counts must match exactly so `*.a.com`
188
+ * never matches `a.com` or `x.y.a.com`.
189
+ */
190
+ function matchHost(pattern: string, host: string): boolean {
191
+ if (pattern.length === 0) {
192
+ return false;
193
+ }
194
+ const pLabels = pattern.split('.');
195
+ const hLabels = host.split('.');
196
+ if (pLabels.length !== hLabels.length) {
197
+ return false;
198
+ }
199
+ for (let i = 0; i < pLabels.length; i++) {
200
+ const pl = pLabels[i];
201
+ const hl = hLabels[i];
202
+ if (pl === '*') {
203
+ // `*` matches exactly one non-empty label; reject an empty host label.
204
+ if (hl === undefined || hl.length === 0) {
205
+ return false;
206
+ }
207
+ continue;
208
+ }
209
+ if (pl !== hl) {
210
+ return false;
211
+ }
212
+ }
213
+ return true;
214
+ }
215
+
216
+ /**
217
+ * Path-prefix match for a URL pattern. Semantics:
218
+ * • `''` or `/` → matches any path (host-level rule).
219
+ * • `/api` → exact path `/api` only.
220
+ * • `/api/*` → `/api` and any sub-path `/api/...`.
221
+ * Compared segment-by-segment after normalising the pattern path the same
222
+ * way as the target.
223
+ */
224
+ function matchPath(patternPath: string, targetPath: string): boolean {
225
+ if (patternPath === '' || patternPath === '/') {
226
+ return true;
227
+ }
228
+ const wildcard = patternPath.endsWith('/*');
229
+ const base = normalisePath(wildcard ? patternPath.slice(0, -2) : patternPath);
230
+ if (!wildcard) {
231
+ return targetPath === base;
232
+ }
233
+ // `/api/*` matches `/api` exactly and any `/api/<seg>...`. Compare on a
234
+ // segment boundary so `/api/*` does NOT match `/apix`.
235
+ if (targetPath === base) {
236
+ return true;
237
+ }
238
+ return targetPath.startsWith(base === '/' ? '/' : `${base}/`);
239
+ }
@@ -18,6 +18,15 @@ export enum PolicyObligationKind {
18
18
  MaxCostUsd = 'max-cost-usd',
19
19
  RestrictOutputClassification = 'restrict-output-classification',
20
20
  DataResidency = 'data-residency',
21
+ /**
22
+ * Org-level egress allowlist (SSRF / exfiltration control). Carries a
23
+ * set of wildcard host/URL patterns the executing party MAY reach on an
24
+ * outbound fetch; an optional blocklist subtracts from it. Generic — it
25
+ * is NOT mail-specific; every biome's outbound-fetch path consults it.
26
+ * Evaluated by the shared `isEgressAllowed` matcher (this module) — never
27
+ * by an ad-hoc string compare. See `EgressAllowlistObligation`.
28
+ */
29
+ EgressAllowlist = 'egress-allowlist',
21
30
  }
22
31
 
23
32
  export const PolicyObligationKindSchema = z.nativeEnum(PolicyObligationKind);
@@ -134,6 +143,57 @@ export type DataResidencyObligation = z.infer<
134
143
  typeof DataResidencyObligationSchema
135
144
  >;
136
145
 
146
+ // ── Egress allowlist payload ────────────────────────────────────────────
147
+ //
148
+ // A pattern is matched against the target URL by `isEgressAllowed` below.
149
+ // Two pattern shapes are supported, distinguished structurally at match
150
+ // time (NOT by a free-form flag):
151
+ //
152
+ // • Host pattern — no scheme, no path. Matched against the URL host
153
+ // ONLY. `*` matches exactly ONE dns label; `**` is NOT supported (a
154
+ // single `*` already spans one label, and multi-label wildcards invite
155
+ // over-broad rules). Examples: `outlook.office365.com`,
156
+ // `*.office365.com` (matches `a.office365.com`, NOT `office365.com`
157
+ // and NOT `a.b.office365.com`).
158
+ // • URL pattern — has a scheme (`https://…`). Scheme + host are
159
+ // matched exactly (host may itself use the `*`-per-label wildcard) and
160
+ // the path prefix is matched with a single trailing `/*` meaning "this
161
+ // path or any sub-path". Examples: `https://example.com/api/*`
162
+ // (matches `https://example.com/api`, `https://example.com/api/v1`,
163
+ // NOT `https://example.com/apix`).
164
+ //
165
+ // Matching is deterministic and bounded — patterns are tokenised on `.`
166
+ // (host) / `/` (path) and compared label-by-label / segment-by-segment.
167
+ // No pattern is ever compiled to a backtracking RegExp, so there is no
168
+ // catastrophic-regex surface.
169
+ const EGRESS_PATTERN = z
170
+ .string()
171
+ .min(1)
172
+ // Disallow whitespace, embedded credentials (`@`), fragments (`#`) and
173
+ // query (`?`) in a PATTERN — those belong to the *target*, not the rule,
174
+ // and accepting them would let a rule author write an ambiguous pattern.
175
+ .refine((p) => !/[\s@#?]/.test(p), {
176
+ message:
177
+ 'egress pattern must not contain whitespace, "@", "#" or "?" (those belong to the target URL, not the rule)',
178
+ });
179
+
180
+ export const EgressAllowlistObligationSchema = z.object({
181
+ kind: z.literal(PolicyObligationKind.EgressAllowlist),
182
+ /**
183
+ * Non-empty allowlist. An outbound target is permitted only if it
184
+ * matches at least one `allow` pattern AND no `deny` pattern. An empty
185
+ * `allow` array is rejected — an egress obligation with nothing allowed
186
+ * would deny ALL egress, which is expressed by simply NOT shipping the
187
+ * obligation (opt-in semantics) rather than an empty allowlist.
188
+ */
189
+ allow: z.array(EGRESS_PATTERN).min(1),
190
+ /** Optional blocklist; a match here denies even if `allow` matched. */
191
+ deny: z.array(EGRESS_PATTERN).optional(),
192
+ });
193
+ export type EgressAllowlistObligation = z.infer<
194
+ typeof EgressAllowlistObligationSchema
195
+ >;
196
+
137
197
  /**
138
198
  * `PolicyObligation` — closed discriminated union (plan v4.3 §A.4).
139
199
  *
@@ -150,6 +210,7 @@ export const PolicyObligationSchema = z.discriminatedUnion('kind', [
150
210
  MaxCostUsdObligationSchema,
151
211
  RestrictOutputClassificationObligationSchema,
152
212
  DataResidencyObligationSchema,
213
+ EgressAllowlistObligationSchema,
153
214
  ]);
154
215
 
155
216
  export type PolicyObligation = z.infer<typeof PolicyObligationSchema>;