@blamejs/core 0.9.19 → 0.9.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.
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardAgentRegistry
4
+ * @nav Guards
5
+ * @title Guard Agent Registry
6
+ * @order 435
7
+ *
8
+ * @intro
9
+ * Registry-op shape validator for `b.agent.orchestrator.register` /
10
+ * `lookup` / `unregister`. Refuses agent names that wouldn't be
11
+ * safe to surface in audit logs, registry queries, or routing
12
+ * keys:
13
+ *
14
+ * - non-ASCII (NFC-normalized + ASCII-only — operator-greppable)
15
+ * - path-traversal shapes (`..` / `/` / `\` / NUL / C0 / DEL)
16
+ * - oversized (default 64 bytes per name)
17
+ * - reserved `FRAMEWORK.*` / `ROOT` / `*` prefix from operator code
18
+ * - duplicate-on-register (caller must `unregister` first)
19
+ *
20
+ * @card
21
+ * Validates `b.agent.orchestrator.register` op shapes. Path-traversal
22
+ * refusal, reserved-prefix refusal, non-ASCII refusal, oversize cap.
23
+ */
24
+
25
+ var { defineClass } = require("./framework-error");
26
+
27
+ var GuardAgentRegistryError = defineClass("GuardAgentRegistryError", { alwaysPermanent: true });
28
+
29
+ var DEFAULT_PROFILE = "strict";
30
+
31
+ var PROFILES = Object.freeze({
32
+ strict: { maxNameBytes: 64, maxKindBytes: 32 }, // allow:raw-byte-literal
33
+ balanced: { maxNameBytes: 128, maxKindBytes: 64 }, // allow:raw-byte-literal
34
+ permissive: { maxNameBytes: 512, maxKindBytes: 128 }, // allow:raw-byte-literal
35
+ });
36
+
37
+ var COMPLIANCE_POSTURES = Object.freeze({
38
+ hipaa: "strict",
39
+ "pci-dss": "strict",
40
+ gdpr: "strict",
41
+ soc2: "strict",
42
+ });
43
+
44
+ var RESERVED_PREFIXES = Object.freeze(["FRAMEWORK.", "ROOT.", "framework.", "root."]);
45
+ var RESERVED_EXACT = Object.freeze({ "ROOT": true, "FRAMEWORK": true, "*": true });
46
+
47
+ /**
48
+ * @primitive b.guardAgentRegistry.validate
49
+ * @signature b.guardAgentRegistry.validate(op, opts?)
50
+ * @since 0.9.21
51
+ * @status stable
52
+ * @related b.agent.orchestrator.create
53
+ *
54
+ * Validate a `{ kind, name, agent, opts }` registry op shape. Returns
55
+ * the op on success; throws `GuardAgentRegistryError` on refusal.
56
+ *
57
+ * @opts
58
+ * profile: "strict" | "balanced" | "permissive",
59
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
60
+ *
61
+ * @example
62
+ * b.guardAgentRegistry.validate({
63
+ * kind: "register",
64
+ * name: "tenant-acme-mail",
65
+ * agentKind: "mail",
66
+ * });
67
+ */
68
+ function validate(op, opts) {
69
+ opts = opts || {};
70
+ var profile = PROFILES[_resolveProfile(opts)];
71
+ if (!op || typeof op !== "object") {
72
+ throw new GuardAgentRegistryError("agent-registry/bad-input",
73
+ "guardAgentRegistry.validate: op required");
74
+ }
75
+ if (op.kind !== "register" && op.kind !== "lookup" && op.kind !== "unregister" && op.kind !== "list") {
76
+ throw new GuardAgentRegistryError("agent-registry/bad-kind",
77
+ "guardAgentRegistry.validate: op.kind must be 'register' | 'lookup' | 'unregister' | 'list'");
78
+ }
79
+ if (op.kind === "list") return op; // list takes optional filters only
80
+
81
+ _checkName(op.name, profile);
82
+ if (op.kind === "register") {
83
+ if (typeof op.agentKind !== "string" || op.agentKind.length === 0) {
84
+ throw new GuardAgentRegistryError("agent-registry/no-kind",
85
+ "guardAgentRegistry.validate: register op requires agentKind");
86
+ }
87
+ _checkKind(op.agentKind, profile);
88
+ }
89
+ return op;
90
+ }
91
+
92
+ /**
93
+ * @primitive b.guardAgentRegistry.compliancePosture
94
+ * @signature b.guardAgentRegistry.compliancePosture(posture)
95
+ * @since 0.9.21
96
+ * @status stable
97
+ *
98
+ * Return the effective profile for a given compliance posture name.
99
+ * Returns `null` for unknown posture names so operator typos surface
100
+ * here instead of silently falling through to the default profile.
101
+ *
102
+ * @example
103
+ * b.guardAgentRegistry.compliancePosture("hipaa"); // → "strict"
104
+ */
105
+ function compliancePosture(posture) {
106
+ return COMPLIANCE_POSTURES[posture] || null;
107
+ }
108
+
109
+ function _checkName(name, profile) {
110
+ if (typeof name !== "string" || name.length === 0) {
111
+ throw new GuardAgentRegistryError("agent-registry/bad-name",
112
+ "guardAgentRegistry.validate: op.name must be a non-empty string");
113
+ }
114
+ if (Buffer.byteLength(name, "utf8") > profile.maxNameBytes) {
115
+ throw new GuardAgentRegistryError("agent-registry/name-too-long",
116
+ "guardAgentRegistry.validate: name exceeds maxNameBytes=" + profile.maxNameBytes);
117
+ }
118
+ if (RESERVED_EXACT[name]) {
119
+ throw new GuardAgentRegistryError("agent-registry/reserved-name",
120
+ "guardAgentRegistry.validate: name '" + name + "' is framework-reserved");
121
+ }
122
+ for (var p = 0; p < RESERVED_PREFIXES.length; p += 1) {
123
+ if (name.indexOf(RESERVED_PREFIXES[p]) === 0) {
124
+ throw new GuardAgentRegistryError("agent-registry/reserved-prefix",
125
+ "guardAgentRegistry.validate: name '" + name + "' uses reserved prefix '" +
126
+ RESERVED_PREFIXES[p] + "'");
127
+ }
128
+ }
129
+ if (name.indexOf("..") >= 0) {
130
+ throw new GuardAgentRegistryError("agent-registry/path-traversal",
131
+ "guardAgentRegistry.validate: name contains '..'");
132
+ }
133
+ for (var i = 0; i < name.length; i += 1) {
134
+ var c = name.charCodeAt(i);
135
+ if (c > 0x7F) { // allow:raw-byte-literal — ASCII-only cap
136
+ throw new GuardAgentRegistryError("agent-registry/non-ascii",
137
+ "guardAgentRegistry.validate: name contains non-ASCII codepoint at offset " + i);
138
+ }
139
+ if (c < 0x20 || c === 0x7F || c === 0x2F || c === 0x5C) { // allow:raw-byte-literal — C0 / DEL / slash / backslash
140
+ throw new GuardAgentRegistryError("agent-registry/bad-name-char",
141
+ "guardAgentRegistry.validate: name contains forbidden char 0x" + c.toString(16));
142
+ }
143
+ }
144
+ }
145
+
146
+ function _checkKind(kind, profile) {
147
+ if (Buffer.byteLength(kind, "utf8") > profile.maxKindBytes) {
148
+ throw new GuardAgentRegistryError("agent-registry/kind-too-long",
149
+ "guardAgentRegistry.validate: agentKind exceeds maxKindBytes=" + profile.maxKindBytes);
150
+ }
151
+ if (!/^[a-z][a-z0-9-]*$/.test(kind)) { // allow:regex-no-length-cap — kind length bounded above
152
+ throw new GuardAgentRegistryError("agent-registry/bad-kind-shape",
153
+ "guardAgentRegistry.validate: agentKind must match /^[a-z][a-z0-9-]*$/");
154
+ }
155
+ }
156
+
157
+ function _resolveProfile(opts) {
158
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
159
+ return COMPLIANCE_POSTURES[opts.posture];
160
+ }
161
+ var p = opts.profile || DEFAULT_PROFILE;
162
+ if (!PROFILES[p]) {
163
+ throw new GuardAgentRegistryError("agent-registry/bad-profile",
164
+ "guardAgentRegistry: unknown profile '" + p + "'");
165
+ }
166
+ return p;
167
+ }
168
+
169
+ module.exports = {
170
+ validate: validate,
171
+ compliancePosture: compliancePosture,
172
+ PROFILES: PROFILES,
173
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
174
+ RESERVED_PREFIXES: RESERVED_PREFIXES,
175
+ RESERVED_EXACT: RESERVED_EXACT,
176
+ GuardAgentRegistryError: GuardAgentRegistryError,
177
+ NAME: "agentRegistry",
178
+ KIND: "agent-registry",
179
+ };
@@ -0,0 +1,282 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardMailCompose
4
+ * @nav Guards
5
+ * @title Guard Mail Compose
6
+ * @order 431
7
+ *
8
+ * @intro
9
+ * Outbound draft validator for `b.mail.agent.compose` /
10
+ * `b.mail.agent.reply` / `b.mail.agent.forward`. Composes the
11
+ * existing `b.guardEmail.validateMessage` for address + header shape
12
+ * and adds compose-specific rules:
13
+ *
14
+ * - identity vs From alignment — operator-supplied `identity.email`
15
+ * must equal the From header local-part + domain (defends spoof-
16
+ * at-submission)
17
+ * - recipient deduplication — Sender / To / Cc / Bcc combined
18
+ * cardinality cap (default 100; envelope-from never duplicated)
19
+ * - attachment byte cap — sum of `body.attachments[*].size_bytes`
20
+ * must not exceed `maxAttachmentBytes` (default 25 MiB to match
21
+ * the RFC 5321 §4.5.3.1.10 receiver cap)
22
+ * - body shape — exactly one of `text` / `html` required (multipart
23
+ * at submission-time per RFC 2046 §5.1.3); both allowed when
24
+ * operator explicitly opts in via `allowMultipartAlternative`
25
+ * - Subject control-char refusal — same C0 / DEL rule the existing
26
+ * `b.guardEmail` applies to header values
27
+ *
28
+ * Profile vocabulary mirrors the rest of the guard family
29
+ * (`strict` / `balanced` / `permissive`); posture vocabulary
30
+ * (`hipaa` / `pci-dss` / `gdpr` / `soc2`) pins `strict`.
31
+ *
32
+ * @card
33
+ * Validates outbound mail drafts at `b.mail.agent.compose`.
34
+ * Identity-vs-From alignment, recipient dedup, attachment byte cap,
35
+ * body shape, header control-char refusal.
36
+ */
37
+
38
+ var { defineClass } = require("./framework-error");
39
+
40
+ var GuardMailComposeError = defineClass("GuardMailComposeError", { alwaysPermanent: true });
41
+
42
+ var DEFAULT_PROFILE = "strict";
43
+
44
+ var PROFILES = Object.freeze({
45
+ strict: { maxRecipients: 100, maxAttachmentBytes: 26214400, maxSubjectBytes: 998 }, // allow:raw-byte-literal — 25 MiB, RFC 5322 §2.1.1 line cap
46
+ balanced: { maxRecipients: 500, maxAttachmentBytes: 52428800, maxSubjectBytes: 998 }, // allow:raw-byte-literal — 50 MiB
47
+ permissive: { maxRecipients: 2000, maxAttachmentBytes: 104857600, maxSubjectBytes: 998 }, // allow:raw-byte-literal — 100 MiB
48
+ });
49
+
50
+ var COMPLIANCE_POSTURES = Object.freeze({
51
+ hipaa: "strict",
52
+ "pci-dss": "strict",
53
+ gdpr: "strict",
54
+ soc2: "strict",
55
+ });
56
+
57
+ /**
58
+ * @primitive b.guardMailCompose.validate
59
+ * @signature b.guardMailCompose.validate(draft, opts?)
60
+ * @since 0.9.20
61
+ * @status stable
62
+ * @related b.guardMailReply, b.guardEmail
63
+ *
64
+ * Validate an outbound draft envelope. Returns the input on success;
65
+ * throws `GuardMailComposeError` on refusal.
66
+ *
67
+ * @opts
68
+ * profile: "strict" | "balanced" | "permissive",
69
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
70
+ * identity: { email: string, name?: string }, // required if checkIdentity
71
+ * checkIdentity: boolean, // default true
72
+ * allowMultipartAlternative: boolean, // default false
73
+ *
74
+ * @example
75
+ * b.guardMailCompose.validate({
76
+ * from: "alice@example.com",
77
+ * to: ["bob@example.com"],
78
+ * subject: "hello",
79
+ * body: { text: "hi" },
80
+ * }, { identity: { email: "alice@example.com" } });
81
+ */
82
+ function validate(draft, opts) {
83
+ opts = opts || {};
84
+ var profileName = _resolveProfile(opts);
85
+ var profile = PROFILES[profileName];
86
+ if (!draft || typeof draft !== "object") {
87
+ throw new GuardMailComposeError("mail-compose/bad-input",
88
+ "guardMailCompose.validate: draft required");
89
+ }
90
+ if (typeof draft.from !== "string" || draft.from.length === 0) {
91
+ throw new GuardMailComposeError("mail-compose/no-from",
92
+ "guardMailCompose.validate: draft.from required");
93
+ }
94
+ _checkHeaderValue(draft.from, "from");
95
+ _checkAddrList(draft.to, "to", profile);
96
+ _checkAddrList(draft.cc, "cc", profile);
97
+ _checkAddrList(draft.bcc, "bcc", profile);
98
+ if (!_anyRecipient(draft)) {
99
+ throw new GuardMailComposeError("mail-compose/no-recipient",
100
+ "guardMailCompose.validate: at least one to/cc/bcc required");
101
+ }
102
+ _checkRecipientCardinality(draft, profile);
103
+
104
+ if (typeof draft.subject !== "undefined") {
105
+ if (typeof draft.subject !== "string") {
106
+ throw new GuardMailComposeError("mail-compose/bad-subject",
107
+ "guardMailCompose.validate: subject must be a string");
108
+ }
109
+ if (Buffer.byteLength(draft.subject, "utf8") > profile.maxSubjectBytes) {
110
+ throw new GuardMailComposeError("mail-compose/subject-too-long",
111
+ "guardMailCompose.validate: subject exceeds maxSubjectBytes=" + profile.maxSubjectBytes);
112
+ }
113
+ _checkHeaderValue(draft.subject, "subject");
114
+ }
115
+
116
+ _checkBody(draft.body, profile, !!opts.allowMultipartAlternative);
117
+
118
+ // Identity vs From alignment — defends spoof-at-submission. When the
119
+ // operator wires an identity for the actor, the draft's From: header
120
+ // must match that identity's email. Disable explicitly via
121
+ // checkIdentity: false (e.g. shared-mailbox submission roles).
122
+ var checkIdentity = opts.checkIdentity !== false;
123
+ if (checkIdentity && opts.identity && opts.identity.email) {
124
+ var fromAddr = _extractAddr(draft.from);
125
+ if (fromAddr.toLowerCase() !== String(opts.identity.email).toLowerCase()) {
126
+ throw new GuardMailComposeError("mail-compose/identity-mismatch",
127
+ "guardMailCompose.validate: From '" + fromAddr +
128
+ "' does not match identity '" + opts.identity.email + "'");
129
+ }
130
+ }
131
+ return draft;
132
+ }
133
+
134
+ /**
135
+ * @primitive b.guardMailCompose.compliancePosture
136
+ * @signature b.guardMailCompose.compliancePosture(posture)
137
+ * @since 0.9.20
138
+ * @status stable
139
+ *
140
+ * Return the effective profile for a given compliance posture name.
141
+ * Returns `null` for unknown posture names so operator typos surface
142
+ * here instead of silently falling through to the default profile.
143
+ *
144
+ * @example
145
+ * b.guardMailCompose.compliancePosture("hipaa"); // → "strict"
146
+ */
147
+ function compliancePosture(posture) {
148
+ return COMPLIANCE_POSTURES[posture] || null;
149
+ }
150
+
151
+ function _checkAddrList(list, label, profile) {
152
+ if (typeof list === "undefined" || list === null) return;
153
+ if (!Array.isArray(list)) {
154
+ throw new GuardMailComposeError("mail-compose/bad-addr-list",
155
+ "guardMailCompose.validate: " + label + " must be an array of strings");
156
+ }
157
+ if (list.length > profile.maxRecipients) {
158
+ throw new GuardMailComposeError("mail-compose/too-many-recipients",
159
+ "guardMailCompose.validate: " + label + " count " + list.length +
160
+ " exceeds maxRecipients=" + profile.maxRecipients);
161
+ }
162
+ for (var i = 0; i < list.length; i += 1) {
163
+ if (typeof list[i] !== "string" || list[i].length === 0) {
164
+ throw new GuardMailComposeError("mail-compose/bad-addr",
165
+ "guardMailCompose.validate: " + label + "[" + i + "] must be a non-empty string");
166
+ }
167
+ _checkHeaderValue(list[i], label + "[" + i + "]");
168
+ var addr = _extractAddr(list[i]);
169
+ if (addr.indexOf("@") < 0) {
170
+ throw new GuardMailComposeError("mail-compose/bad-addr",
171
+ "guardMailCompose.validate: " + label + "[" + i + "] missing '@'");
172
+ }
173
+ }
174
+ }
175
+
176
+ function _checkRecipientCardinality(draft, profile) {
177
+ var all = [];
178
+ ["to", "cc", "bcc"].forEach(function (k) {
179
+ if (Array.isArray(draft[k])) {
180
+ for (var i = 0; i < draft[k].length; i += 1) {
181
+ all.push(_extractAddr(draft[k][i]).toLowerCase());
182
+ }
183
+ }
184
+ });
185
+ if (all.length > profile.maxRecipients) {
186
+ throw new GuardMailComposeError("mail-compose/too-many-recipients",
187
+ "guardMailCompose.validate: combined recipient count " + all.length +
188
+ " exceeds maxRecipients=" + profile.maxRecipients);
189
+ }
190
+ var seen = Object.create(null);
191
+ for (var j = 0; j < all.length; j += 1) {
192
+ if (seen[all[j]]) {
193
+ throw new GuardMailComposeError("mail-compose/duplicate-recipient",
194
+ "guardMailCompose.validate: '" + all[j] + "' appears in multiple recipient fields");
195
+ }
196
+ seen[all[j]] = true;
197
+ }
198
+ }
199
+
200
+ function _checkBody(body, profile, allowAlt) {
201
+ if (!body || typeof body !== "object") {
202
+ throw new GuardMailComposeError("mail-compose/no-body",
203
+ "guardMailCompose.validate: draft.body required");
204
+ }
205
+ var hasText = typeof body.text === "string" && body.text.length > 0;
206
+ var hasHtml = typeof body.html === "string" && body.html.length > 0;
207
+ if (!hasText && !hasHtml) {
208
+ throw new GuardMailComposeError("mail-compose/empty-body",
209
+ "guardMailCompose.validate: body.text or body.html required");
210
+ }
211
+ if (hasText && hasHtml && !allowAlt) {
212
+ throw new GuardMailComposeError("mail-compose/multipart-alternative-disallowed",
213
+ "guardMailCompose.validate: both text + html supplied — set allowMultipartAlternative: true");
214
+ }
215
+ if (Array.isArray(body.attachments)) {
216
+ var total = 0;
217
+ for (var i = 0; i < body.attachments.length; i += 1) {
218
+ var a = body.attachments[i];
219
+ if (!a || typeof a !== "object") {
220
+ throw new GuardMailComposeError("mail-compose/bad-attachment",
221
+ "guardMailCompose.validate: attachment[" + i + "] must be an object");
222
+ }
223
+ var size = typeof a.sizeBytes === "number" ? a.sizeBytes :
224
+ (typeof a.size_bytes === "number" ? a.size_bytes : 0);
225
+ if (size < 0 || !isFinite(size)) {
226
+ throw new GuardMailComposeError("mail-compose/bad-attachment-size",
227
+ "guardMailCompose.validate: attachment[" + i + "].sizeBytes invalid");
228
+ }
229
+ total += size;
230
+ if (total > profile.maxAttachmentBytes) {
231
+ throw new GuardMailComposeError("mail-compose/attachment-too-big",
232
+ "guardMailCompose.validate: attachment total " + total +
233
+ " exceeds maxAttachmentBytes=" + profile.maxAttachmentBytes);
234
+ }
235
+ }
236
+ }
237
+ }
238
+
239
+ function _checkHeaderValue(v, label) {
240
+ for (var i = 0; i < v.length; i += 1) {
241
+ var c = v.charCodeAt(i);
242
+ if ((c < 0x20 && c !== 0x09) || c === 0x7F) { // allow:raw-byte-literal — C0 + DEL refusal in header
243
+ throw new GuardMailComposeError("mail-compose/control-char-in-header",
244
+ "guardMailCompose.validate: control char 0x" + c.toString(16) + " in " + label);
245
+ }
246
+ }
247
+ }
248
+
249
+ function _extractAddr(s) {
250
+ var lt = s.indexOf("<");
251
+ var gt = s.lastIndexOf(">");
252
+ if (lt >= 0 && gt > lt) return s.slice(lt + 1, gt).trim();
253
+ return s.trim();
254
+ }
255
+
256
+ function _anyRecipient(draft) {
257
+ return ["to", "cc", "bcc"].some(function (k) {
258
+ return Array.isArray(draft[k]) && draft[k].length > 0;
259
+ });
260
+ }
261
+
262
+ function _resolveProfile(opts) {
263
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
264
+ return COMPLIANCE_POSTURES[opts.posture];
265
+ }
266
+ var p = opts.profile || DEFAULT_PROFILE;
267
+ if (!PROFILES[p]) {
268
+ throw new GuardMailComposeError("mail-compose/bad-profile",
269
+ "guardMailCompose: unknown profile '" + p + "'");
270
+ }
271
+ return p;
272
+ }
273
+
274
+ module.exports = {
275
+ validate: validate,
276
+ compliancePosture: compliancePosture,
277
+ PROFILES: PROFILES,
278
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
279
+ GuardMailComposeError: GuardMailComposeError,
280
+ NAME: "mailCompose",
281
+ KIND: "mail-compose",
282
+ };
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardMailMove
4
+ * @nav Guards
5
+ * @title Guard Mail Move
6
+ * @order 433
7
+ *
8
+ * @intro
9
+ * Destination-folder allowlist validator for `b.mail.agent.move`.
10
+ * Refuses moves to system folders the actor doesn't have admin
11
+ * scope for, refuses cross-account moves (`fromFolder` and
12
+ * `toFolder` must belong to the same agent context), and refuses
13
+ * path-traversal-shaped folder names (`..` / leading `.` / NUL /
14
+ * bidi).
15
+ *
16
+ * System folders the framework treats specially:
17
+ *
18
+ * - **INBOX / Sent / Drafts**: always writable by the owner; no
19
+ * admin scope required.
20
+ * - **Junk / Trash**: always writable (Junk is the default Sieve
21
+ * junk destination; Trash is the soft-delete target).
22
+ * - **Archive**: always writable.
23
+ * - any operator-created folder: writable when in the actor's
24
+ * allowed-folders list (per the operator's RBAC) OR when the
25
+ * actor has `mailScope: "admin"`.
26
+ *
27
+ * The guard does NOT touch the underlying mail-store; that
28
+ * composition lives in `b.mail.agent.move`. The guard validates
29
+ * the SHAPE of the move call.
30
+ *
31
+ * @card
32
+ * Validates `b.mail.agent.move` destination. System-folder allowlist,
33
+ * path-traversal refusal, admin-scope gate for arbitrary destinations.
34
+ */
35
+
36
+ var { defineClass } = require("./framework-error");
37
+
38
+ var GuardMailMoveError = defineClass("GuardMailMoveError", { alwaysPermanent: true });
39
+
40
+ var DEFAULT_PROFILE = "strict";
41
+
42
+ var PROFILES = Object.freeze({
43
+ strict: { maxObjectIds: 1000, maxFolderNameBytes: 255 }, // allow:raw-byte-literal
44
+ balanced: { maxObjectIds: 5000, maxFolderNameBytes: 255 }, // allow:raw-byte-literal
45
+ permissive: { maxObjectIds: 50000, maxFolderNameBytes: 1024 }, // allow:raw-byte-literal
46
+ });
47
+
48
+ var COMPLIANCE_POSTURES = Object.freeze({
49
+ hipaa: "strict",
50
+ "pci-dss": "strict",
51
+ gdpr: "strict",
52
+ soc2: "strict",
53
+ });
54
+
55
+ // System folders every actor may write to without admin scope.
56
+ var SYSTEM_FOLDERS = Object.freeze({
57
+ INBOX: true, Sent: true, Drafts: true, Trash: true, Junk: true, Archive: true,
58
+ });
59
+
60
+ /**
61
+ * @primitive b.guardMailMove.validate
62
+ * @signature b.guardMailMove.validate(move, opts?)
63
+ * @since 0.9.20
64
+ * @status stable
65
+ * @related b.mail.agent.create
66
+ *
67
+ * Validate a `{ actor, fromFolder, toFolder, objectIds }` shape.
68
+ *
69
+ * @opts
70
+ * profile: "strict" | "balanced" | "permissive",
71
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
72
+ *
73
+ * @example
74
+ * b.guardMailMove.validate({
75
+ * actor: { id: "u1", mailScope: "user" },
76
+ * fromFolder: "INBOX",
77
+ * toFolder: "Archive",
78
+ * objectIds: ["abc123"],
79
+ * });
80
+ */
81
+ function validate(move, opts) {
82
+ opts = opts || {};
83
+ var profile = PROFILES[_resolveProfile(opts)];
84
+ if (!move || typeof move !== "object") {
85
+ throw new GuardMailMoveError("mail-move/bad-input",
86
+ "guardMailMove.validate: move required");
87
+ }
88
+ if (!move.actor || typeof move.actor !== "object" || typeof move.actor.id !== "string") {
89
+ throw new GuardMailMoveError("mail-move/no-actor",
90
+ "guardMailMove.validate: move.actor with .id required");
91
+ }
92
+ _checkFolderName(move.fromFolder, "fromFolder", profile);
93
+ _checkFolderName(move.toFolder, "toFolder", profile);
94
+ if (move.fromFolder === move.toFolder) {
95
+ throw new GuardMailMoveError("mail-move/same-folder",
96
+ "guardMailMove.validate: fromFolder and toFolder are the same");
97
+ }
98
+ if (!Array.isArray(move.objectIds)) {
99
+ throw new GuardMailMoveError("mail-move/bad-objectids",
100
+ "guardMailMove.validate: objectIds must be an array");
101
+ }
102
+ if (move.objectIds.length === 0) {
103
+ throw new GuardMailMoveError("mail-move/empty-objectids",
104
+ "guardMailMove.validate: objectIds must be non-empty");
105
+ }
106
+ if (move.objectIds.length > profile.maxObjectIds) {
107
+ throw new GuardMailMoveError("mail-move/too-many-objectids",
108
+ "guardMailMove.validate: objectIds count " + move.objectIds.length +
109
+ " exceeds maxObjectIds=" + profile.maxObjectIds);
110
+ }
111
+ for (var i = 0; i < move.objectIds.length; i += 1) {
112
+ var oid = move.objectIds[i];
113
+ if (typeof oid !== "string" || oid.length === 0) {
114
+ throw new GuardMailMoveError("mail-move/bad-objectid",
115
+ "guardMailMove.validate: objectIds[" + i + "] must be a non-empty string");
116
+ }
117
+ }
118
+
119
+ // System-folder allowlist OR admin scope OR allowed-folders.
120
+ var dest = move.toFolder;
121
+ if (SYSTEM_FOLDERS[dest]) return move;
122
+ var isAdmin = move.actor.mailScope === "admin";
123
+ if (isAdmin) return move;
124
+ var allowed = Array.isArray(move.actor.allowedFolders) ? move.actor.allowedFolders : null;
125
+ if (allowed && allowed.indexOf(dest) >= 0) return move;
126
+ throw new GuardMailMoveError("mail-move/destination-not-allowed",
127
+ "guardMailMove.validate: destination '" + dest +
128
+ "' requires mailScope:'admin' or membership in actor.allowedFolders");
129
+ }
130
+
131
+ /**
132
+ * @primitive b.guardMailMove.compliancePosture
133
+ * @signature b.guardMailMove.compliancePosture(posture)
134
+ * @since 0.9.20
135
+ * @status stable
136
+ *
137
+ * Return the effective profile for a given compliance posture name.
138
+ * Returns `null` for unknown posture names so operator typos surface
139
+ * here instead of silently falling through to the default profile.
140
+ *
141
+ * @example
142
+ * b.guardMailMove.compliancePosture("hipaa"); // → "strict"
143
+ */
144
+ function compliancePosture(posture) {
145
+ return COMPLIANCE_POSTURES[posture] || null;
146
+ }
147
+
148
+ function _checkFolderName(name, label, profile) {
149
+ if (typeof name !== "string" || name.length === 0) {
150
+ throw new GuardMailMoveError("mail-move/bad-folder-name",
151
+ "guardMailMove.validate: " + label + " must be a non-empty string");
152
+ }
153
+ if (Buffer.byteLength(name, "utf8") > profile.maxFolderNameBytes) {
154
+ throw new GuardMailMoveError("mail-move/folder-name-too-long",
155
+ "guardMailMove.validate: " + label + " exceeds maxFolderNameBytes=" + profile.maxFolderNameBytes);
156
+ }
157
+ // Path-traversal / control-char refusal. C0 controls, slash, NUL,
158
+ // leading `.`, and `..` segments are all refused regardless of
159
+ // profile.
160
+ if (name.indexOf("..") >= 0) {
161
+ throw new GuardMailMoveError("mail-move/path-traversal",
162
+ "guardMailMove.validate: " + label + " contains '..'");
163
+ }
164
+ if (name.charAt(0) === ".") {
165
+ throw new GuardMailMoveError("mail-move/hidden-name",
166
+ "guardMailMove.validate: " + label + " starts with '.' (hidden-folder shape refused)");
167
+ }
168
+ for (var i = 0; i < name.length; i += 1) {
169
+ var c = name.charCodeAt(i);
170
+ if (c < 0x20 || c === 0x7F) { // allow:raw-byte-literal — C0 + DEL refusal
171
+ throw new GuardMailMoveError("mail-move/control-char-in-name",
172
+ "guardMailMove.validate: " + label + " contains control char 0x" + c.toString(16));
173
+ }
174
+ if (c === 0x2F) { // allow:raw-byte-literal — '/' refusal
175
+ throw new GuardMailMoveError("mail-move/slash-in-name",
176
+ "guardMailMove.validate: " + label + " contains '/' (use IMAP '.' hierarchy separator)");
177
+ }
178
+ }
179
+ }
180
+
181
+ function _resolveProfile(opts) {
182
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
183
+ return COMPLIANCE_POSTURES[opts.posture];
184
+ }
185
+ var p = opts.profile || DEFAULT_PROFILE;
186
+ if (!PROFILES[p]) {
187
+ throw new GuardMailMoveError("mail-move/bad-profile",
188
+ "guardMailMove: unknown profile '" + p + "'");
189
+ }
190
+ return p;
191
+ }
192
+
193
+ module.exports = {
194
+ validate: validate,
195
+ compliancePosture: compliancePosture,
196
+ PROFILES: PROFILES,
197
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
198
+ SYSTEM_FOLDERS: SYSTEM_FOLDERS,
199
+ GuardMailMoveError: GuardMailMoveError,
200
+ NAME: "mailMove",
201
+ KIND: "mail-move",
202
+ };