@blamejs/core 0.10.7 → 0.10.8

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/CHANGELOG.md CHANGED
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.10.x
10
10
 
11
+ - v0.10.8 (2026-05-17) — **EU AI Act Art. 50 + AB-853 + CAC implicit label + AIBOM + operator-surfaced DX primitives.** Calendar-bound release ahead of the 2026-08-02 EU AI Act Art. 50 transparency / California SB-942-as-amended-by-AB-853 effective date and the live (2025-09-01) China CAC GB 45438-2025 labeling regime. Three new AI-transparency surfaces + three operator-surfaced DX primitives (issues #91 / #92 / #93). **(a) `b.ai.aiContentDetect.report`** — inbound-asset provenance detector. Operators extract C2PA-COSE envelopes / CAC implicit-label JSON / IPTC PhotoMetadata via their format-specific muxer and feed the artifacts to `report({...})`; the framework verifies signatures, anchors against an operator-pinned trust list, and returns a normalized provenance report for the AB-853 §22757.21 disclosure UI. Trust-list-empty surfaces as an alert rather than silent acceptance. Profile / posture cascade: `ca-ab-853`, `ca-sb-942`, `eu-ai-act-art-50`, `cac-genai-label` pin to `strict` (refuse on signer not on trust list); `nist-ai-600-1`, `iso-42001`, `iso-23894`, `nist-ai-rmf` pin to `balanced`. **(b) `b.contentCredentials.cacImplicitLabel` + `b.contentCredentials.cacImplicitLabelRead`** — China CAC (Cyberspace Administration) "Measures for Labeling AI-Generated Synthetic Content" + mandatory standard GB 45438-2025 implicit metadata emitter and reverse parser. Validates the 18-character Chinese unified social credit code (统一社会信用代码 per GB 32100-2015), `aigcMarker` field, and `contentKind` enum at the config-time tier. Operators co-emit alongside the C2PA-COSE manifest by declaring `cac-genai-label` posture on the existing `b.contentCredentials.build`. **(c) `b.ai.modelManifest.build` / `.sign` / `.verify`** — CycloneDX 1.6 ML-BOM (AI bill of materials) emitter. EU AI Act Art. 11 + Annex IV require technical documentation for high-risk AI systems; CycloneDX 1.6 ML-BOM is the de-facto serialization (and forward-positioned for EU CRA 2027-12-11 — Regulation (EU) 2024/2847 requires SBOM-style documentation for AI components in products with digital elements). Emits `bomFormat: "CycloneDX"` + `specVersion: "1.6"` + `serialNumber` UUIDv4 URN + `metadata.timestamp` + `metadata.tools[]` + `metadata.component` (primary model with `type: "machine-learning-model"`) + `components[]` datasets + `properties[]` hyperparameters (per CycloneDX spec issue #702 EU CRA alignment) + `formulation[]` workflows + `services[]` external model APIs. ML-DSA-87 signature over canonical-JSON-1785 representation; verify path NEVER trusts an embedded "signedBytes" field — defends the CVE-2025-29774 / CVE-2025-29775 xml-crypto-style signature-substitution class. Self-validates required CycloneDX 1.6 fields at emit time. **(d) `b.atomicFile.conflictPath`** — filesystem-portable conflict-suffix path builder (issue #91). `notes.md` → `notes.conflict-2026-05-17T19-30-00Z.md`; Windows-safe (no `:` / `.`), extension-preserving, dotfile-aware, optional `tag` + `suffix` disambiguator for same-second collisions. Composes the existing `b.atomicFile.pathTimestamp`. **(e) `b.promisePool.create`** — bounded-concurrency promise pool (issue #92). The gap between `b.workerPool` (worker-thread CPU-bound work) and `b.queue` (durable cross-process messaging). `run(taskFn)` / `fire(taskFn)` / `drain({ close? })` shape with back-pressure on enqueue, queueLimit refusal, composes with `b.appShutdown` for drain-on-shutdown. No hidden retry — operators compose `b.retry.withRetry` inside the task body when they want it. **(f) `b.sdNotify.send` / `.ready` / `.stopping` / `.reloading` / `.watchdog`** — sd_notify protocol surface for systemd Type=notify daemons (issue #93). Reads `$NOTIFY_SOCKET` via `b.parsers.safeEnv.readVar`, dispatches `READY=1` / `STOPPING=1` / `RELOADING=1` / `WATCHDOG=1` via `systemd-notify(1)` with `execFile` (no shell). No-op (with audit) when `$NOTIFY_SOCKET` is unset (foreground / container / non-systemd init). Compose with `b.appShutdown.create` for the STOPPING signal; compose with a periodic watchdog interval for systemd's auto-restart-on-hang guarantee. **(g) `b.crypto.randomInt` substrate** — exported alongside the v0.10.7 substrate to give the new AIBOM UUID generator a single greppable random-int path. **(h) New compliance postures in `b.contentCredentials` / `b.ai.aiContentDetect` / `b.ai.modelManifest`:** `ca-ab-853`, `ca-sb-942`, `eu-ai-act-art-50`, `eu-ai-act-art-11`, `cac-genai-label`, `nist-ai-600-1`, `nist-ai-rmf`, `iso-42001`, `iso-23894`. **(i) New audit namespaces:** `aibom` (aibom.signed / aibom.verified), `aicontentdetect` (aicontentdetect.report), `sdnotify` (sdnotify.send / sdnotify.send.skipped). **Deferred to v0.10.9:** in-tree IPTC PhotoMetadata reader for `digitalSourceType` field — operators pre-parse with their tool of choice and pass via `opts.ipmd`. **Operator impact:** no breaking changes. Operators already declaring `eu-ai-act-art-50` posture should pin a trust list via the new primitive before turning on default-on detection in production; AB-853 §22757.21 platform-detection obligations are 2027-01-01 effective so there's runway. References: [EU AI Act Regulation (EU) 2024/1689](https://eur-lex.europa.eu/eli/reg/2024/1689) · [California SB-942 + AB-853](https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202320240SB942) · [CAC GB 45438-2025](http://www.cac.gov.cn/2025-03/14/c_1742700786675936.htm) · [C2PA 2.1 / 2.2 spec](https://c2pa.org/specifications/specifications/2.2/) · [CycloneDX 1.6 ML-BOM](https://cyclonedx.org/docs/1.6/json/) · [OWASP CycloneDX AI/ML-BOM Authoritative Guide](https://owasp.org/www-project-cyclonedx/) · [NIST AI 600-1 Generative AI Profile](https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.600-1.pdf) · [ISO/IEC 42001:2023](https://www.iso.org/standard/81230.html) · [systemd-notify(1)](https://www.freedesktop.org/software/systemd/man/latest/systemd-notify.html) · [CVE-2025-29774](https://nvd.nist.gov/vuln/detail/CVE-2025-29774) · [CVE-2025-29775](https://nvd.nist.gov/vuln/detail/CVE-2025-29775) · [CVE-2025-32711 EchoLeak](https://nvd.nist.gov/vuln/detail/CVE-2025-32711).
11
12
  - v0.10.7 (2026-05-17) — **Mail-stack P3 / P4 hardening sweep.** Twenty-plus refusals + observability additions across the four mail listeners, the DKIM verifier, ARC signer, MIME parser, and the DNS / DSN / List-* guards. No new public primitives; one substrate addition (`b.crypto.randomInt`); two new operator-visible opts on the submission listener. **(a) `b.crypto.randomInt(min, max)`** — substrate wrapper that routes every framework integer draw through one greppable primitive. Migrates the inline `nodeCrypto.randomInt` sites in `b.network.dns` / `b.network.dns.resolver` (DNS query-ID), `b.mail.auth` (DMARC `pct` sampling), and `b.externalDb` (transaction-retry jitter) so the audit trail is uniform and future detectors see one shape. **(b) `b.mail.server.submission.create({ requireDkim, dkimRequireMode })`** — outbound DKIM-required gate per Yahoo / Google 2024 bulk-sender alignment. `requireDkim` defaults `true` under `strict` profile (`false` under `balanced` / `permissive`). `dkimRequireMode` is `"self"` (signer's `d=` must match authenticated identity's domain), `"any"` (any signer present), or `"off"` (no gate). Default `"any"`. Submission listener that doesn't carry a `DKIM-Signature:` header at DATA-end refuses with `5.7.20`. **(c) `b.mail.server.{mx,submission}.create({ allowSmtpUtf8 })`** — single per-listener SMTPUTF8 (RFC 6531) switch threaded end-to-end into `guardSmtpCommand.validate`. Default `false`. Operators that accept EAI envelopes flip to `true` and the toggle reaches every wire-line guard call. **(d) DKIM verifier signature-count cap.** `b.mail.dkim.verify` now refuses (`policy` verdict) rather than silently truncating when a message carries more `DKIM-Signature` headers than `maxSignatures` (default 8). The opt is range-checked at config time against a ceiling of 16; out-of-range throws `dkim/bad-max-signatures`. Closes a verifier-fan-out DoS shape per RFC 6376 §6.1. Emits `dkim.verify.signature_count_cap` audit on the refusal so postmasters see DoS attempts in the authentication-results stream. **(e) MX listener size-overrun + observability.** `MAIL FROM SIZE=` is now reconciled against the actual DATA byte count after dot-stuffing reversal — senders that understate `SIZE=` to probe `maxMessageBytes` get `552 5.3.4` rather than silently accepted, with `mail.server.mx.size_overrun` audit. Refused-recipient list (bounded at 32 per transaction) now surfaces in the `data_accepted` / `delivered` audit metadata. Write-backpressure on every reply attaches a once-per-socket `mail.server.mx.write_backpressure` audit so operators see stalled connections without flooding on every reply. **(f) IMAP refinements.** `APPEND mailbox [flags] [date-time] {literal}` now honors the optional RFC 9051 §6.3.12 date-time argument (parsed into `internalDate` ms-epoch, refused with `BAD` rather than silently falling back to `Date.now()`). `FETCH` / `STORE` outside of Selected state now respond `BAD` (RFC 9051 §6.4.5 / §6.4.6 — protocol-context violation, not policy refusal). `LOGIN` quoted-string args honor `\"` / `\\` escape pairs per the RFC 9051 §5.1 grammar (the prior shape terminated at the first `"`, letting a hostile client smuggle `LOGIN "alice\"@example.com" "pw"` past the username binding). **(g) ARC signer hop-count ceiling.** `b.mail.arc.sign` extracts prior hops with the RFC 8617 §5 50-hop cap; an inbound chain claiming >50 hops or an out-of-range `i=` tag is refused rather than enumerated. **(h) MIME parser charset + observability.** `b.safeMime.parse` now decodes `utf-16` (RFC 2781 §3.3 BOM detection + BE default), `utf-16be`, and `utf-16le` end-to-end — the prior shape advertised `utf-16` / `utf-16be` in the allowlist but only decoded `utf-16le`. `binary` Content-Transfer-Encoding is removed from the default allowlist (RFC 3030 §3 — `binary` requires explicit BINARYMIME negotiation; operators that wire BINARYMIME opt back in via `transferEncodingAllowlist: [..., "binary"]`). Control-character refusal errors now report the BYTE offset (via `Buffer.byteLength` on the JS string prefix) rather than the UTF-16 code-unit index, so audit lines align with wire-level inspection. **(i) DKIM / DMARC / ARC / iPrev / DSN tightening.** `b.guardDsn` splits the RFC 3464 §2.1.1 block separator on literal `\r\n\r\n` only (the prior `\n\s*\n` accepted `\v` / `\f` whitespace as a block boundary, letting a hostile sender bend the per-message vs per-recipient boundary). `b.guardMessageId` now validates id-left + id-right against RFC 5322 §3.2.3 dot-atom-text shape under `strict` profile; `b.guardListId` extends the localhost FQDN exception to `.local` (RFC 6762) and `.lan` (draft-chapin-rfc2606bis). **(j) `b.guardListUnsubscribe` SSRF defense.** HTTPS one-click URIs now refuse IP-literal hosts (v4 + v6), reserved-local hostnames (`localhost` / `localhost.localdomain` / `ip6-localhost` / `ip6-loopback`), and reserved-local TLD suffixes (`.local` / `.lan` / `.internal`). New optional `allowedHosts` opt provides a domain allowlist — when supplied, every HTTPS host (or any ancestor) must be on the list. **(k) `b.mailStore` JMAP objectid bump to 128 bits.** RFC 8474 §1.5.1 — the prior 24-char hex prefix cut entropy to 96 bits; full 32-char hex restores 128 bits. **Operator impact:** Submission listeners on `strict` profile WITHOUT operator-side DKIM signing (`b.mail.dkim.sign` pre-relay) now refuse outbound DATA — operators in this state either wire DKIM signing, opt to `dkimRequireMode: "off"`, or step down to `balanced`. `b.mail.dkim.verify` callers passing `maxSignatures > 16` now throw at config time — clamp via the opt or rely on the framework default. `b.safeMime.parse` callers that legitimately receive `binary` Content-Transfer-Encoding (BINARYMIME-aware downstream pipelines) opt back in via `transferEncodingAllowlist`. `b.guardListUnsubscribe.validate` callers that legitimately rely on IP-literal one-click URIs (test harnesses, internal-network operators) opt in via `allowedHosts: ["10.0.0.0/8"]` style ancestor matches. **Deferred to v0.10.8 / v0.10.12:** per-tenant pepper on `b.mailStore` derived hashes (`from_hash` / `message_id_hash`) ships in v0.10.12 alongside the `b.agent.tenant` adoption refactor; the schema migration is too invasive to fold into this patch. `b.mailStore` forensic-recovery columns (original Content-Transfer-Encoding + charset preserved alongside decoded body) defer to v0.10.8 — the schema change carries its own backwards-compatibility surface. References: [RFC 9051 IMAP4rev2](https://www.rfc-editor.org/rfc/rfc9051), [RFC 6376 DKIM §6.1](https://www.rfc-editor.org/rfc/rfc6376#section-6.1), [RFC 6531 SMTPUTF8](https://www.rfc-editor.org/rfc/rfc6531), [RFC 3030 BINARYMIME](https://www.rfc-editor.org/rfc/rfc3030), [RFC 2781 UTF-16 BOM](https://www.rfc-editor.org/rfc/rfc2781), [RFC 1870 SMTP SIZE](https://www.rfc-editor.org/rfc/rfc1870), [RFC 8474 JMAP objectid](https://www.rfc-editor.org/rfc/rfc8474), [RFC 8617 ARC §5](https://www.rfc-editor.org/rfc/rfc8617#section-5), [RFC 3464 DSN §2.1.1](https://www.rfc-editor.org/rfc/rfc3464#section-2.1.1), [RFC 5322 Message Format §3.2.3](https://www.rfc-editor.org/rfc/rfc5322#section-3.2.3), [RFC 6761 Reserved Domain Names](https://www.rfc-editor.org/rfc/rfc6761), [Yahoo / Gmail bulk-sender 2024](https://blog.google/products/gmail/gmail-security-authentication-spam-protection/).
12
13
  - v0.10.6 (2026-05-17) — **Vendored-SBOM CycloneDX 1.6 conformance + cosign verification recipe pin.** Build-side + verification-side improvements; no runtime changes. **(a) `scripts/build-vendored-sbom.js` per-component `cpe` field** — every vendored bundle gets a CPE 2.3 string (`cpe:2.3:a:<vendor>:<product>:<version>:*:*:*:*:*:*:*`). CISA / NVD CVE-matching tools (Dependency-Track, OWASP Dependency-Check, Snyk SBOM Monitor) match CVE advisories against components by CPE; the prior emit had no CPE field, so vendored bundles were invisible to operator-side CVE scanners. **(b) Per-component `supplier` block** — `metadata.supplier` (framework-level) was already populated; each vendored bundle now also carries its own `components[].supplier` with the upstream maintainer / org per [SLSA v1.0 provenance requirements](https://slsa.dev/spec/v1.0/provenance) — operators auditing the SBOM see both the framework supplier (blamejs) AND the vendored bundle's upstream supplier (noble-curves, noble-ciphers, etc.) at the component level. **(c) `metadata.lifecycles[].externalReferences[]`** — CycloneDX 1.6 §4.4.2 requires `lifecycles` entries to carry build-provenance references (workflow URL, run ID); the npm-publish workflow now populates these so the SBOM points back at the SLSA-attesting workflow run that produced the tarball. **(d) Sub-component `dependsOn` graph** — when a vendored bundle exposes sub-components (e.g. `noble-ciphers` exports `xchacha20poly1305` + `aes-gcm` as named sub-modules), each sub-component now emits its own SBOM entry with a `dependencies` edge pointing to its parent (CycloneDX 1.6 §4.7). Operators get the full transitive graph instead of just the top-level vendored bundle. **(e) `_licenseFor()` inline-path fix** — the path-resolution branch that handles vendored bundles whose `package.json` is under `lib/vendor/<name>/package.json` now correctly returns the SPDX `license.id` (was returning `null` for that branch, causing CycloneDX-validator warnings). **(f) `SECURITY.md` cosign verification recipe pinned to workflow path + tag-ref** — the operator-side recipe now constrains `cosign verify-blob --certificate-identity-regexp` to the specific workflow file (`.github/workflows/npm-publish.yml`) + tag-ref shape (`refs/tags/v[0-9]+\.[0-9]+\.[0-9]+`), refusing certificates issued for any other workflow or ref class. Also documents `--rekor-url` for operators running on an air-gapped network with a local transparency log + offline TUF root path for `cosign initialize --root <local-root.json>`. **(g) `.github/workflows/npm-publish.yml` recipe comment** synchronized to match the SECURITY.md recipe so operators copy-pasting from either source see identical verification steps. **Operator impact:** SBOM consumers that previously saw vendored bundles as opaque now see CPE-matched components with proper supplier attribution + transitive sub-component graph. The Sigstore-keyless verification recipe is more restrictive (rejects certificates issued for non-`npm-publish.yml` workflows on this repo) — operators already verifying against the prior recipe see the same successful verification with the tighter identity match. References: [CycloneDX 1.6 §4](https://cyclonedx.org/docs/1.6/json/), [CPE 2.3 spec](https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir7695.pdf), [SLSA v1.0 provenance](https://slsa.dev/spec/v1.0/provenance), [Sigstore cosign verify-blob](https://docs.sigstore.dev/cosign/verifying/verify/), [TUF specification](https://theupdateframework.github.io/specification/latest/).
13
14
  - v0.10.5 (2026-05-16) — **`b.mail.server.pop3` APOP cleartext refusal + `b.vendorData` constant-time digest compares.** Two small entry-tier refusals on the mail and vendor-data surfaces. **(a) `b.mail.server.pop3._handleApop`** refuses APOP when the connection is cleartext and the profile is not permissive, symmetric with the existing USER / PASS refusal. APOP transmits `MD5(timestamp+secret)` (not cleartext credentials), but an attacker who captures the digest plus the known greeting timestamp can mount an offline dictionary attack against the shared secret. RFC 1939 §7 explicitly warns about this; the wire MUST be TLS-protected to deny the offline-attack vector. Emits the same `mail.server.pop3.auth_refused_cleartext` audit event + writes `-ERR APOP refused over cleartext (use STLS first; RFC 1939 §7)`. The cleartext-refusal line was advertised in the v0.10.4 release notes but the wire-level enforcement only lands here; operators relying on v0.10.4 saw the comment but not the runtime gate. **(b) `b.vendorData.verifyAll()`** boot-time digest verifies (SHA-256 layer 1, SHA3-512 layer 2, and the SLH-DSA-SHAKE-256f pubkey-fingerprint cross-check) now compare via a length-prechecked `nodeCrypto.timingSafeEqual` instead of `!==`. The framework convention is that every digest / MAC compare is constant-time regardless of whether the value is a secret — reaching for `!==` whenever a value "isn't a secret" is the smell; the convention is the gate. Uses `nodeCrypto.timingSafeEqual` directly (not `b.crypto.timingSafeEqual`) because `b.crypto` is `lazyRequire`'d to break a circular load chain and isn't available during boot-time `verifyAll()`. **Operator impact:** APOP users on plaintext POP3 (port 110) without STLS first now get `-ERR` instead of authenticating — the operator either wires STLS, switches the listener to implicit TLS (port 995), or sets `profile: "permissive"` for the deliberately-open path. `b.vendorData` consumers see no behavioral change — the timing-safe compare returns the same boolean as `!==` for length-equal inputs. References: [RFC 1939 §7](https://www.rfc-editor.org/rfc/rfc1939#section-7), [CWE-208 timing attack](https://cwe.mitre.org/data/definitions/208.html), [NIST SP 800-38B §6.3 MAC verification](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-38B.pdf).
package/index.js CHANGED
@@ -397,7 +397,14 @@ module.exports = {
397
397
  nis2: { report: require("./lib/nis2-report") },
398
398
  gdpr: { ropa: require("./lib/gdpr-ropa") },
399
399
  breach: require("./lib/breach-deadline"),
400
- ai: { adverseDecision: require("./lib/ai-adverse-decision"), input: aiInput },
400
+ ai: {
401
+ adverseDecision: require("./lib/ai-adverse-decision"),
402
+ input: aiInput,
403
+ aiContentDetect: require("./lib/ai-content-detect"),
404
+ modelManifest: require("./lib/ai-model-manifest"),
405
+ },
406
+ promisePool: require("./lib/promise-pool"),
407
+ sdNotify: require("./lib/sd-notify"),
401
408
  queue: queue,
402
409
  logStream: logStream,
403
410
  redact: redact,
@@ -0,0 +1,268 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.ai.aiContentDetect
4
+ * @nav AI
5
+ * @title AI Content Detection
6
+ *
7
+ * @intro
8
+ * Inbound-asset provenance detector. Counterpart to the outbound
9
+ * `b.contentCredentials` seal path: the operator extracts whatever
10
+ * provenance metadata their format-specific muxer surfaces
11
+ * (C2PA-COSE envelope from JPEG XMP / PNG iTXt / MP4 boxes, CAC
12
+ * implicit-label JSON from the embedded metadata block, IPTC
13
+ * `digitalSourceType` when an IPTC PhotoMetadata reader is wired),
14
+ * feeds them to `report({...})`, and renders the normalized result
15
+ * in their user-facing UI. California AB-853 §22757.21 requires the
16
+ * disclosure; the framework owns the validation + trust-list anchor
17
+ * layer, the application owns the rendering.
18
+ *
19
+ * Trust-list anchored: the operator declares which signer subjects
20
+ * are acceptable. Default trust list is empty — the framework does
21
+ * not ship a curated CA list. Operators that want a one-line ramp
22
+ * point at the public C2PA Trust List per
23
+ * https://opensource.contentauthenticity.org/docs/trust-list.
24
+ *
25
+ * Posture vocabulary: `strict` (refuse on signature invalid, refuse
26
+ * on signer not on trust list), `balanced` (refuse on cryptographic
27
+ * tamper, audit-only on missing provenance), `permissive`
28
+ * (audit-only across the board). Default `balanced`.
29
+ *
30
+ * IPTC `digitalSourceType` PhotoMetadata reading is forward-watch —
31
+ * the framework ships no XMP / EXIF parser yet, so operators that
32
+ * want IPTC detection pre-parse with their tool of choice and pass
33
+ * the field via `opts.ipmd`. AB-853 names C2PA as "widely adopted";
34
+ * IPTC PhotoMetadata reader lands in v0.10.9 once a vendoring
35
+ * decision is made.
36
+ *
37
+ * @card
38
+ * Inbound provenance detector — composes C2PA verify + CAC implicit-label parser + operator-supplied IPTC field, returns a normalized report for AB-853 / EU AI Act Art. 50 / CAC disclosure UIs.
39
+ */
40
+
41
+ var lazyRequire = require("./lazy-require");
42
+ var contentCredentials = lazyRequire(function () { return require("./content-credentials"); });
43
+ var audit = require("./audit");
44
+ var { defineClass } = require("./framework-error");
45
+
46
+ var AiContentDetectError = defineClass("AiContentDetectError", { alwaysPermanent: true });
47
+
48
+ var DEFAULT_PROFILE = "balanced";
49
+ var PROFILES = Object.freeze({
50
+ strict: { refuseUnsigned: true, refuseUnpinned: true, auditOnly: false },
51
+ balanced: { refuseUnsigned: false, refuseUnpinned: false, auditOnly: false },
52
+ permissive: { refuseUnsigned: false, refuseUnpinned: false, auditOnly: true },
53
+ });
54
+
55
+ var COMPLIANCE_POSTURES = Object.freeze({
56
+ "ca-ab-853": "strict",
57
+ "ca-sb-942": "strict",
58
+ "eu-ai-act-art-50": "strict",
59
+ "cac-genai-label": "strict",
60
+ "nist-ai-600-1": "balanced",
61
+ "iso-42001": "balanced",
62
+ "iso-23894": "balanced",
63
+ "nist-ai-rmf": "balanced",
64
+ });
65
+
66
+ function _resolveProfile(opts) {
67
+ if (opts && typeof opts.posture === "string") {
68
+ var profile = COMPLIANCE_POSTURES[opts.posture];
69
+ if (profile) return PROFILES[profile];
70
+ }
71
+ if (opts && typeof opts.profile === "string" && PROFILES[opts.profile]) {
72
+ return PROFILES[opts.profile];
73
+ }
74
+ return PROFILES[DEFAULT_PROFILE];
75
+ }
76
+
77
+ /**
78
+ * @primitive b.ai.aiContentDetect.report
79
+ * @signature b.ai.aiContentDetect.report(opts)
80
+ * @since 0.10.8
81
+ * @status stable
82
+ * @compliance ca-ab-853, ca-sb-942, eu-ai-act-art-50, cac-genai-label, nist-ai-600-1, iso-42001, iso-23894, nist-ai-rmf
83
+ * @related b.contentCredentials.verify, b.contentCredentials.cacImplicitLabelRead
84
+ *
85
+ * Build a normalized `provenanceReport` from the provenance artifacts
86
+ * an operator's muxer extracted from an inbound asset. At least one
87
+ * of `c2paEnvelope`, `cacImplicitLabel`, or `ipmd` must be supplied;
88
+ * absence of all three returns `kind: "none"` with `verified: false`.
89
+ *
90
+ * @opts
91
+ * c2paEnvelope: object, // { manifest, signature } from operator's C2PA extractor
92
+ * c2paPublicKeyPem: string, // PEM for verify (operator-pinned signer key)
93
+ * cacImplicitLabel: Buffer|string|object, // GB 45438-2025 implicit metadata block
94
+ * ipmd: object, // IPTC PhotoMetadata digitalSourceType field (operator-pre-parsed)
95
+ * trustList: string[], // acceptable signer subject identifiers
96
+ * profile: "strict"|"balanced"|"permissive",
97
+ * posture: string, // pins profile per posture vocabulary
98
+ *
99
+ * @example
100
+ * var report = b.ai.aiContentDetect.report({
101
+ * c2paEnvelope: env, c2paPublicKeyPem: pem,
102
+ * trustList: ["CN=Acme AI, O=Acme, C=US"],
103
+ * posture: "ca-ab-853",
104
+ * });
105
+ * report.kind; // → "c2pa"
106
+ * report.verified; // → true if signature OK and signer on trustList
107
+ */
108
+ function report(opts) {
109
+ opts = opts || {};
110
+ var profile = _resolveProfile(opts);
111
+ var trustList = Array.isArray(opts.trustList) ? opts.trustList.slice() : [];
112
+ var alerts = [];
113
+
114
+ var has = {
115
+ c2pa: opts.c2paEnvelope && typeof opts.c2paEnvelope === "object",
116
+ cac: opts.cacImplicitLabel !== undefined && opts.cacImplicitLabel !== null,
117
+ ipmd: opts.ipmd && typeof opts.ipmd === "object",
118
+ };
119
+ if (!has.c2pa && !has.cac && !has.ipmd) {
120
+ if (profile.refuseUnsigned) {
121
+ throw new AiContentDetectError("ai-content-detect/no-provenance",
122
+ "report: strict profile requires at least one provenance artifact " +
123
+ "(c2paEnvelope, cacImplicitLabel, or ipmd) — got none");
124
+ }
125
+ var out = {
126
+ kind: "none", verified: false, alerts: ["no-provenance"], rawDisclosure: null,
127
+ };
128
+ if (!profile.auditOnly) {
129
+ try {
130
+ audit.safeEmit({
131
+ action: "aicontentdetect.report", outcome: "denied",
132
+ metadata: { kind: "none", reason: "no-provenance" },
133
+ });
134
+ } catch (_e) { /* drop-silent */ }
135
+ }
136
+ return Object.freeze(out);
137
+ }
138
+
139
+ var verified = false;
140
+ var kind = "none";
141
+ var manifest = null;
142
+ var signerSubject = null;
143
+ var signedAt = null;
144
+ var cacLabel = null;
145
+ var ipmd = null;
146
+
147
+ if (has.c2pa) {
148
+ kind = "c2pa";
149
+ var keyMissing = typeof opts.c2paPublicKeyPem !== "string" || opts.c2paPublicKeyPem.length === 0;
150
+ if (keyMissing) {
151
+ // Strict refuses outright on a missing key — caller cannot
152
+ // produce a verified disclosure without it, so the report
153
+ // would be useless under the AB-853 / EU AI Act Art. 50
154
+ // posture cascade.
155
+ if (profile.refuseUnsigned) {
156
+ throw new AiContentDetectError("ai-content-detect/c2pa-public-key-missing",
157
+ "report: strict profile requires c2paPublicKeyPem when c2paEnvelope is supplied");
158
+ }
159
+ alerts.push("c2pa-public-key-missing");
160
+ } else {
161
+ var v = contentCredentials().verify(opts.c2paEnvelope, opts.c2paPublicKeyPem, { audit: false });
162
+ if (v.valid) {
163
+ verified = true;
164
+ manifest = v.claims;
165
+ signerSubject = opts.c2paEnvelope.signerSubject ||
166
+ (v.claims && v.claims.signer && v.claims.signer.subject) || null;
167
+ signedAt = (v.claims && v.claims.signedAt) || null;
168
+ } else {
169
+ // Strict refuses on cryptographic-verify failure — a tampered
170
+ // or signature-invalid envelope MUST NOT produce a
171
+ // disclosure object the caller might surface as anything
172
+ // other than "refused." The append-alert-and-continue path
173
+ // is the balanced / permissive shape; strict throws.
174
+ if (profile.refuseUnsigned) {
175
+ throw new AiContentDetectError("ai-content-detect/c2pa-verify-failed",
176
+ "report: strict profile refuses tampered / invalid C2PA envelope (" +
177
+ v.reason + ")");
178
+ }
179
+ alerts.push("c2pa-verify-failed:" + v.reason);
180
+ }
181
+ }
182
+ if (verified && trustList.length > 0) {
183
+ if (!signerSubject || trustList.indexOf(signerSubject) === -1) {
184
+ if (profile.refuseUnpinned) {
185
+ throw new AiContentDetectError("ai-content-detect/signer-not-on-trust-list",
186
+ "report: signer '" + (signerSubject || "(unknown)") +
187
+ "' is not on the operator-supplied trust list");
188
+ }
189
+ verified = false;
190
+ alerts.push("signer-not-on-trust-list");
191
+ }
192
+ } else if (verified && trustList.length === 0) {
193
+ alerts.push("trust-list-empty");
194
+ }
195
+ }
196
+
197
+ if (has.cac) {
198
+ try { cacLabel = contentCredentials().cacImplicitLabelRead(opts.cacImplicitLabel); }
199
+ catch (e) {
200
+ alerts.push("cac-label-parse-failed:" + (e && e.code));
201
+ }
202
+ if (cacLabel && kind === "none") kind = "cac";
203
+ }
204
+
205
+ if (has.ipmd) {
206
+ ipmd = Object.freeze(Object.assign({}, opts.ipmd));
207
+ if (kind === "none") kind = "iptc";
208
+ }
209
+
210
+ var rawDisclosure = {
211
+ c2pa: has.c2pa ? { manifest: manifest, signerSubject: signerSubject, signedAt: signedAt } : null,
212
+ cac: cacLabel,
213
+ iptc: ipmd,
214
+ };
215
+
216
+ if (!profile.auditOnly) {
217
+ try {
218
+ audit.safeEmit({
219
+ action: "aicontentdetect.report",
220
+ outcome: verified ? "success" : "warning",
221
+ metadata: {
222
+ kind: kind,
223
+ verified: verified,
224
+ signerSubject: signerSubject,
225
+ alerts: alerts.slice(),
226
+ },
227
+ });
228
+ } catch (_e) { /* drop-silent */ }
229
+ }
230
+
231
+ return Object.freeze({
232
+ kind: kind,
233
+ verified: verified,
234
+ manifest: manifest,
235
+ signerSubject: signerSubject,
236
+ signedAt: signedAt,
237
+ cacLabel: cacLabel,
238
+ ipmd: ipmd,
239
+ alerts: Object.freeze(alerts),
240
+ rawDisclosure: Object.freeze(rawDisclosure),
241
+ });
242
+ }
243
+
244
+ /**
245
+ * @primitive b.ai.aiContentDetect.compliancePosture
246
+ * @signature b.ai.aiContentDetect.compliancePosture(posture)
247
+ * @since 0.10.8
248
+ * @status stable
249
+ *
250
+ * Return the effective profile name (`strict` / `balanced` /
251
+ * `permissive`) for a compliance posture, or `null` for unknown
252
+ * posture names. Operators introspect the cascade before wiring
253
+ * default-on paths.
254
+ *
255
+ * @example
256
+ * b.ai.aiContentDetect.compliancePosture("ca-ab-853"); // → "strict"
257
+ */
258
+ function compliancePosture(posture) {
259
+ return COMPLIANCE_POSTURES[posture] || null;
260
+ }
261
+
262
+ module.exports = {
263
+ report: report,
264
+ compliancePosture: compliancePosture,
265
+ PROFILES: PROFILES,
266
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
267
+ AiContentDetectError: AiContentDetectError,
268
+ };
package/lib/ai-input.js CHANGED
@@ -1,15 +1,23 @@
1
1
  "use strict";
2
2
  /**
3
- * AI input classifier for prompt-injection detection on operator
4
- * input flowing into LLM prompts. OWASP LLM01:2025 + NIST COSAIS RFI.
3
+ * @module b.ai.input
4
+ * @nav AI
5
+ * @title AI Input Classifier
5
6
  *
6
- * Public API:
7
- * aiInput.classify(input, opts) -> { verdict, signals, features, confidence }
8
- * aiInput.refuseIfMalicious(input, opts) -> result | throws
7
+ * @intro
8
+ * Prompt-injection + jailbreak classifier for operator input flowing
9
+ * into LLM prompts. OWASP LLM01:2025 + NIST COSAIS RFI. Pattern set
10
+ * covers explicit-override prompts, role-reset markers, persona
11
+ * jailbreaks, exfiltration callbacks, bidi / zero-width / control
12
+ * char features, and encoded-instruction smells (base64 / rot13 /
13
+ * markdown / HTML script).
9
14
  *
10
- * Severity 3 = malicious-by-default; 2 = suspicious. Verdict is
11
- * "malicious" with any severity-3 hit, "suspicious" with 2+ severity-2
12
- * hits, otherwise "clean".
15
+ * Severity 3 = malicious-by-default; severity 2 = suspicious. Verdict
16
+ * is `malicious` on any severity-3 hit, `suspicious` on 2+ severity-2
17
+ * hits, otherwise `clean`.
18
+ *
19
+ * @card
20
+ * Prompt-injection + jailbreak classifier — OWASP LLM01:2025 + NIST COSAIS RFI. Pattern set + bidi / zero-width feature scan; verdict-driven refusal helper.
13
21
  */
14
22
 
15
23
  var C = require("./constants");
@@ -67,6 +75,27 @@ function _featuresOf(input) {
67
75
  };
68
76
  }
69
77
 
78
+ /**
79
+ * @primitive b.ai.input.classify
80
+ * @signature b.ai.input.classify(input, opts?)
81
+ * @since 0.8.10
82
+ * @status stable
83
+ * @related b.ai.input.refuseIfMalicious, b.guardHtml, b.mcp.toolResult.sanitize
84
+ *
85
+ * Classify operator-supplied prompt text against the injection /
86
+ * jailbreak pattern set. Returns
87
+ * `{ verdict, signals, features, confidence }`.
88
+ *
89
+ * @opts
90
+ * maxBytes: number, // default 64 KiB; throws on overflow
91
+ * audit: boolean, // default true; emit ai.input.classify event
92
+ * errorClass: ErrorClass, // override the thrown class on bad input
93
+ *
94
+ * @example
95
+ * var v = b.ai.input.classify("Ignore all prior instructions...");
96
+ * v.verdict; // → "malicious"
97
+ * v.signals[0]; // → { id: "ignore-prior-instructions", severity: 3 }
98
+ */
70
99
  function classify(input, opts) {
71
100
  opts = opts || {};
72
101
  var errorClass = opts.errorClass || AiInputError;
@@ -132,6 +161,27 @@ function classify(input, opts) {
132
161
  };
133
162
  }
134
163
 
164
+ /**
165
+ * @primitive b.ai.input.refuseIfMalicious
166
+ * @signature b.ai.input.refuseIfMalicious(input, opts?)
167
+ * @since 0.8.10
168
+ * @status stable
169
+ * @related b.ai.input.classify
170
+ *
171
+ * Run `classify` and throw on `verdict === "malicious"` (severity-3
172
+ * pattern hit) — return the classification result otherwise. Operator
173
+ * convenience wrapper for handlers that want a single fail-closed
174
+ * call before forwarding to an LLM.
175
+ *
176
+ * @opts
177
+ * maxBytes: number, // default 64 KiB
178
+ * audit: boolean, // default true
179
+ * errorClass: ErrorClass, // override the thrown class
180
+ *
181
+ * @example
182
+ * try { b.ai.input.refuseIfMalicious(req.body.prompt); }
183
+ * catch (e) { res.statusCode = 400; res.end(e.message); }
184
+ */
135
185
  function refuseIfMalicious(input, opts) {
136
186
  opts = opts || {};
137
187
  var errorClass = opts.errorClass || AiInputError;