@xemahq/kernel-contracts 0.2.1 → 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.
- package/dist/agent-workspace/awp-spec.json +1 -1
- package/dist/biome/index.d.ts +5 -0
- package/dist/biome/index.d.ts.map +1 -1
- package/dist/biome/index.js +5 -0
- package/dist/biome/index.js.map +1 -1
- package/dist/biome/lib/biome-audience.d.ts +8 -0
- package/dist/biome/lib/biome-audience.d.ts.map +1 -0
- package/dist/biome/lib/biome-audience.js +12 -0
- package/dist/biome/lib/biome-audience.js.map +1 -0
- package/dist/biome/lib/biome-availability-grant.d.ts +10 -0
- package/dist/biome/lib/biome-availability-grant.d.ts.map +1 -0
- package/dist/biome/lib/biome-availability-grant.js +12 -0
- package/dist/biome/lib/biome-availability-grant.js.map +1 -0
- package/dist/biome/lib/biome-origin.d.ts +8 -0
- package/dist/biome/lib/biome-origin.d.ts.map +1 -0
- package/dist/biome/lib/biome-origin.js +12 -0
- package/dist/biome/lib/biome-origin.js.map +1 -0
- package/dist/biome/lib/biome-target.d.ts +7 -0
- package/dist/biome/lib/biome-target.d.ts.map +1 -0
- package/dist/biome/lib/biome-target.js +11 -0
- package/dist/biome/lib/biome-target.js.map +1 -0
- package/dist/biome/lib/biome-tier.d.ts +10 -0
- package/dist/biome/lib/biome-tier.d.ts.map +1 -0
- package/dist/biome/lib/biome-tier.js +19 -0
- package/dist/biome/lib/biome-tier.js.map +1 -0
- package/dist/contribution/lib/contribution-kind.d.ts +2 -1
- package/dist/contribution/lib/contribution-kind.d.ts.map +1 -1
- package/dist/contribution/lib/contribution-kind.js +1 -0
- package/dist/contribution/lib/contribution-kind.js.map +1 -1
- package/dist/distribution/index.d.ts +5 -0
- package/dist/distribution/index.d.ts.map +1 -0
- package/dist/distribution/index.js +21 -0
- package/dist/distribution/index.js.map +1 -0
- package/dist/distribution/lib/distribution-lock.d.ts +24 -0
- package/dist/distribution/lib/distribution-lock.d.ts.map +1 -0
- package/dist/distribution/lib/distribution-lock.js +34 -0
- package/dist/distribution/lib/distribution-lock.js.map +1 -0
- package/dist/distribution/lib/distribution-selector.d.ts +12 -0
- package/dist/distribution/lib/distribution-selector.d.ts.map +1 -0
- package/dist/distribution/lib/distribution-selector.js +13 -0
- package/dist/distribution/lib/distribution-selector.js.map +1 -0
- package/dist/distribution/lib/distribution.d.ts +21 -0
- package/dist/distribution/lib/distribution.d.ts.map +1 -0
- package/dist/distribution/lib/distribution.js +30 -0
- package/dist/distribution/lib/distribution.js.map +1 -0
- package/dist/distribution/lib/image-lock.d.ts +20 -0
- package/dist/distribution/lib/image-lock.d.ts.map +1 -0
- package/dist/distribution/lib/image-lock.js +30 -0
- package/dist/distribution/lib/image-lock.js.map +1 -0
- package/dist/inquiry/lib/enums.d.ts +4 -0
- package/dist/inquiry/lib/enums.d.ts.map +1 -1
- package/dist/inquiry/lib/enums.js +6 -1
- package/dist/inquiry/lib/enums.js.map +1 -1
- package/dist/inquiry/lib/inquiry.d.ts +5 -9
- package/dist/inquiry/lib/inquiry.d.ts.map +1 -1
- package/dist/inquiry/lib/inquiry.js +4 -2
- package/dist/inquiry/lib/inquiry.js.map +1 -1
- package/dist/invocation/index.d.ts +10 -0
- package/dist/invocation/index.d.ts.map +1 -0
- package/dist/invocation/index.js +26 -0
- package/dist/invocation/index.js.map +1 -0
- package/dist/invocation/lib/execution-requirements.d.ts +24 -0
- package/dist/invocation/lib/execution-requirements.d.ts.map +1 -0
- package/dist/invocation/lib/execution-requirements.js +25 -0
- package/dist/invocation/lib/execution-requirements.js.map +1 -0
- package/dist/invocation/lib/invocation-decision.d.ts +8 -0
- package/dist/invocation/lib/invocation-decision.d.ts.map +1 -0
- package/dist/invocation/lib/invocation-decision.js +10 -0
- package/dist/invocation/lib/invocation-decision.js.map +1 -0
- package/dist/invocation/lib/invocation-mode.d.ts +8 -0
- package/dist/invocation/lib/invocation-mode.d.ts.map +1 -0
- package/dist/invocation/lib/invocation-mode.js +12 -0
- package/dist/invocation/lib/invocation-mode.js.map +1 -0
- package/dist/invocation/lib/invocation-priority.d.ts +8 -0
- package/dist/invocation/lib/invocation-priority.d.ts.map +1 -0
- package/dist/invocation/lib/invocation-priority.js +12 -0
- package/dist/invocation/lib/invocation-priority.js.map +1 -0
- package/dist/invocation/lib/invocation-record.d.ts +27 -0
- package/dist/invocation/lib/invocation-record.d.ts.map +1 -0
- package/dist/invocation/lib/invocation-record.js +29 -0
- package/dist/invocation/lib/invocation-record.js.map +1 -0
- package/dist/invocation/lib/invocation-status.d.ts +13 -0
- package/dist/invocation/lib/invocation-status.d.ts.map +1 -0
- package/dist/invocation/lib/invocation-status.js +17 -0
- package/dist/invocation/lib/invocation-status.js.map +1 -0
- package/dist/invocation/lib/invoke-request.d.ts +13 -0
- package/dist/invocation/lib/invoke-request.d.ts.map +1 -0
- package/dist/invocation/lib/invoke-request.js +15 -0
- package/dist/invocation/lib/invoke-request.js.map +1 -0
- package/dist/invocation/lib/invoke-response.d.ts +17 -0
- package/dist/invocation/lib/invoke-response.d.ts.map +1 -0
- package/dist/invocation/lib/invoke-response.js +19 -0
- package/dist/invocation/lib/invoke-response.js.map +1 -0
- package/dist/invocation/lib/isolation-level.d.ts +7 -0
- package/dist/invocation/lib/isolation-level.d.ts.map +1 -0
- package/dist/invocation/lib/isolation-level.js +11 -0
- package/dist/invocation/lib/isolation-level.js.map +1 -0
- package/dist/mail-source/index.d.ts +2 -0
- package/dist/mail-source/index.d.ts.map +1 -0
- package/dist/mail-source/index.js +18 -0
- package/dist/mail-source/index.js.map +1 -0
- package/dist/mail-source/lib/mail-event.d.ts +58 -0
- package/dist/mail-source/lib/mail-event.d.ts.map +1 -0
- package/dist/mail-source/lib/mail-event.js +41 -0
- package/dist/mail-source/lib/mail-event.js.map +1 -0
- package/dist/policy/index.d.ts +1 -0
- package/dist/policy/index.d.ts.map +1 -1
- package/dist/policy/index.js +1 -0
- package/dist/policy/index.js.map +1 -1
- package/dist/policy/lib/egress-allowlist.d.ts +18 -0
- package/dist/policy/lib/egress-allowlist.d.ts.map +1 -0
- package/dist/policy/lib/egress-allowlist.js +130 -0
- package/dist/policy/lib/egress-allowlist.js.map +1 -0
- package/dist/policy/lib/obligations.d.ts +12 -1
- package/dist/policy/lib/obligations.d.ts.map +1 -1
- package/dist/policy/lib/obligations.js +14 -1
- package/dist/policy/lib/obligations.js.map +1 -1
- package/package.json +1 -1
- package/src/biome/index.ts +5 -0
- package/src/biome/lib/biome-audience.ts +27 -0
- package/src/biome/lib/biome-availability-grant.ts +42 -0
- package/src/biome/lib/biome-origin.ts +26 -0
- package/src/biome/lib/biome-target.ts +16 -0
- package/src/biome/lib/biome-tier.ts +44 -0
- package/src/contribution/lib/contribution-kind.ts +14 -0
- package/src/distribution/index.ts +4 -0
- package/src/distribution/lib/distribution-lock.ts +88 -0
- package/src/distribution/lib/distribution-selector.ts +35 -0
- package/src/distribution/lib/distribution.ts +82 -0
- package/src/distribution/lib/image-lock.ts +77 -0
- package/src/inquiry/lib/enums.ts +13 -0
- package/src/inquiry/lib/inquiry.ts +22 -4
- package/src/invocation/index.ts +9 -0
- package/src/invocation/lib/execution-requirements.ts +61 -0
- package/src/invocation/lib/invocation-decision.ts +21 -0
- package/src/invocation/lib/invocation-mode.ts +26 -0
- package/src/invocation/lib/invocation-priority.ts +16 -0
- package/src/invocation/lib/invocation-record.ts +59 -0
- package/src/invocation/lib/invocation-status.ts +32 -0
- package/src/invocation/lib/invoke-request.ts +34 -0
- package/src/invocation/lib/invoke-response.ts +39 -0
- package/src/invocation/lib/isolation-level.ts +20 -0
- package/src/mail-source/index.ts +1 -0
- package/src/mail-source/lib/mail-event.ts +106 -0
- package/src/policy/index.ts +1 -0
- package/src/policy/lib/egress-allowlist.ts +239 -0
- 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;
|
package/src/policy/index.ts
CHANGED
|
@@ -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>;
|