/**
 * RagSourceAllowlist -- Per-agent, per-org RAG retrieval source allowlisting.
 *
 * When an agent performs retrieval (vector search, document lookup, web fetch
 * for grounding, MCP resource access), the source must be on the agent's
 * configured allowlist. Off-allowlist retrieval is rejected BEFORE the actual
 * retrieval call is made (pre-retrieval gate, not post-fetch).
 *
 * Entry kinds:
 *   - vector-store:         literal ID match (e.g. "vs_org_a_pubmed_2026")
 *   - document-collection:  literal ID match (e.g. "dc_legal_casebook_2025")
 *   - url-prefix:           URL prefix match with scheme-aware canonicalisation
 *   - mcp-resource:         MCP URI prefix match with path canonicalisation
 *
 * Strict-mode packs (fail-closed when no allowlist is configured):
 *   - hipaa     HIPAA §164.312(a)(1): access control to PHI sources
 *   - gdpr      GDPR Art. 5(1)(b): purpose limitation, storage limitation
 *   - pci-dss   PCI DSS Req 7: restrict access to system components
 *   - hmda      HMDA Reg C 12 CFR §1003: GMI data source integrity
 *   - dora      DORA Art. 9: ICT risk management, third-party data access
 *
 * Non-strict packs default to lenient mode: first retrieval per cycle emits
 * one audit warning and allows; subsequent retrievals in the same cycle do not
 * re-emit (spam prevention).
 *
 * Audit events:
 *   - RAG_SOURCE_REJECTED  severity WARNING, sourceHash (SHA-256, not raw URL)
 *   - RAG_SOURCE_ALLOWED   severity INFO
 *   - RAG_UNALLOWLISTED_WARN severity WARNING (lenient-mode first-call)
 *
 * Pack-binding is mandatory on every entry (UnboundDataError if missing).
 *
 * Integration:
 *   - LlamaIndex: call guardPreRetrieval() inside the documentRetrieval() hook
 *     before LlamaIndex executes the vector-store or document-store fetch.
 *   - Haystack:   call guardPreRetrieval() in the nodeStart() hook when
 *     nodeType is 'Retriever' or 'DocumentStore'.
 *   - Other adapters: any adapter exposing a retrieval surface should call
 *     guardPreRetrieval() before dispatching to the underlying retrieval API.
 *   - The PreRetrievalHook interface in src/governance/index.ts declares the
 *     integration contract for adapters that want to self-register.
 *
 * Pen-test gates:
 *   PT-RAG-ALLOWLIST-1: URL-encoding bypass -- encoded host confusables are
 *     normalised and rejected.
 *   PT-RAG-ALLOWLIST-2: vector-store ID confusable -- "vs_x_evil" is not
 *     accepted when allowlist has "vs_x"; exact match required.
 *   PT-RAG-ALLOWLIST-3: MCP URI traversal -- "../" segments are canonicalised
 *     away and traversal is blocked.
 *
 * BUILD_PLAN reference: line 246 "Retrieval source allowlisting per agent".
 *
 * @connexum/ai-governance
 */
import * as crypto from 'node:crypto';
// ---------------------------------------------------------------------------
// Strict-mode pack list
// ---------------------------------------------------------------------------
/**
 * Packs that force fail-closed behaviour when no allowlist is configured.
 * If an agent is bound to any of these packs and has no allowlist entries,
 * every retrieval throws RagSourceAllowlistRequiredError.
 *
 * hipaa  -- HIPAA §164.312(a)(1): PHI access controls; unconfigured source
 *           access violates minimum-necessary principle.
 * gdpr   -- GDPR Art. 5(1)(b) purpose limitation; Art. 25 data minimisation.
 * pci-dss -- PCI DSS Req 7: restrict access to system components to business
 *           need-to-know; cardholder data sources must be explicit.
 * hmda   -- HMDA Reg C §1003: GMI data (race, sex, ethnicity) must only flow
 *           from verified HMDA collection forms, not arbitrary sources.
 * dora   -- DORA Art. 9: ICT third-party risk; unknown data sources are an
 *           unacknowledged ICT risk requiring explicit registration.
 */
export const STRICT_MODE_PACKS = new Set([
    'hipaa',
    'gdpr',
    'pci-dss',
    'hmda',
    'dora',
]);
// ---------------------------------------------------------------------------
// Error classes
// ---------------------------------------------------------------------------
/**
 * Thrown when a retrieval source is not on the agent's allowlist.
 */
export class RagSourceNotAllowlistedError extends Error {
    orgId;
    agentId;
    sourceHash;
    kind;
    constructor(opts) {
        super(`RAG retrieval blocked: agent '${opts.agentId}' (org '${opts.orgId}') attempted ` +
            `to retrieve from a source (kind: ${opts.kind}) that is not on its configured allowlist.`);
        this.name = 'RagSourceNotAllowlistedError';
        this.orgId = opts.orgId;
        this.agentId = opts.agentId;
        this.sourceHash = opts.sourceHash;
        this.kind = opts.kind;
    }
}
/**
 * Thrown when a strict-mode pack is bound to an agent that has no allowlist
 * configured. Fail-closed: every retrieval is rejected.
 */
export class RagSourceAllowlistRequiredError extends Error {
    orgId;
    agentId;
    strictPacks;
    constructor(opts) {
        super(`RAG retrieval blocked: agent '${opts.agentId}' (org '${opts.orgId}') has no ` +
            `RAG source allowlist configured but is bound to strict-mode pack(s): ` +
            `[${opts.strictPacks.join(', ')}]. ` +
            `Configure an allowlist via RagSourceAllowlist.configure() before enabling retrieval.`);
        this.name = 'RagSourceAllowlistRequiredError';
        this.orgId = opts.orgId;
        this.agentId = opts.agentId;
        this.strictPacks = opts.strictPacks;
    }
}
/**
 * Thrown when configure() receives an entry with an empty packBinding.
 * Per feedback_pack_binding_primacy.md: every entry MUST carry packBinding.
 */
export class UnboundDataError extends Error {
    entryPattern;
    constructor(pattern) {
        super(`RagSourceAllowlist entry '${pattern}' has an empty packBinding. ` +
            `Every allowlist entry must carry at least one pack binding. ` +
            `Set packBinding to the compliance pack(s) governing this data source.`);
        this.name = 'UnboundDataError';
        this.entryPattern = pattern;
    }
}
// ---------------------------------------------------------------------------
// Internal agent key
// ---------------------------------------------------------------------------
function agentKey(orgId, agentId) {
    return `${orgId}::${agentId}`;
}
// ---------------------------------------------------------------------------
// URL canonicalisation helpers
// ---------------------------------------------------------------------------
/**
 * Canonicalise a URL for allowlist matching.
 *
 * Rules:
 * 1. Parse with the built-in URL class (throws on malformed URLs).
 * 2. Reject any URL with embedded credentials (user or password in authority).
 * 3. Normalise scheme to lowercase.
 * 4. Normalise host to lowercase.
 * 5. Decode percent-encoded characters in the host (prevents encoding bypass).
 * 6. Resolve any ".." or "." segments in the pathname.
 * 7. Return a normalised URL string (scheme + host + path) without query or hash.
 *
 * Returns null if the URL is invalid or has embedded credentials.
 */
function canonicaliseUrl(raw) {
    let parsed;
    try {
        parsed = new URL(raw);
    }
    catch {
        return null;
    }
    // Reject embedded credentials
    if (parsed.username || parsed.password) {
        return null;
    }
    const scheme = parsed.protocol.toLowerCase().replace(/:$/, '');
    const host = decodeURIComponent(parsed.hostname).toLowerCase();
    // Resolve ".." segments in pathname
    const segments = parsed.pathname.split('/');
    const resolved = [];
    for (const seg of segments) {
        if (seg === '..') {
            resolved.pop();
        }
        else if (seg !== '.') {
            resolved.push(seg);
        }
    }
    const path = resolved.join('/') || '/';
    const port = parsed.port ? `:${parsed.port}` : '';
    return `${scheme}://${host}${port}${path}`;
}
/**
 * Canonicalise an MCP URI for allowlist matching.
 *
 * Rules:
 * 1. Lowercase the scheme ("mcp://").
 * 2. Lowercase the host segment.
 * 3. Remove trailing slash before the path.
 * 4. Resolve ".." and "." segments in the path (traversal prevention).
 *
 * Returns null if the URI cannot be parsed (no valid mcp:// scheme) or
 * if it contains ".." segments that escape the root.
 */
function canonicaliseMcpUri(raw) {
    const lower = raw.toLowerCase();
    if (!lower.startsWith('mcp://'))
        return null;
    // Strip "mcp://" and split into host + path
    const withoutScheme = raw.slice('mcp://'.length);
    const slashIdx = withoutScheme.indexOf('/');
    const host = (slashIdx === -1 ? withoutScheme : withoutScheme.slice(0, slashIdx)).toLowerCase();
    const rawPath = slashIdx === -1 ? '' : withoutScheme.slice(slashIdx);
    // Resolve path segments
    const segments = rawPath.split('/');
    const resolved = [];
    for (const seg of segments) {
        if (seg === '..') {
            if (resolved.length === 0)
                return null; // traversal above root
            resolved.pop();
        }
        else if (seg !== '.') {
            resolved.push(seg);
        }
    }
    const path = resolved.join('/');
    return `mcp://${host}${path}`;
}
// ---------------------------------------------------------------------------
// RagSourceAllowlist
// ---------------------------------------------------------------------------
/**
 * Per-agent, per-org RAG retrieval source allowlist.
 *
 * Usage:
 *   const allowlist = new RagSourceAllowlist(auditSink);
 *
 *   // Operator configuration at agent-startup time:
 *   allowlist.configure('org-acme', 'agent-rag-01', [
 *     { pattern: 'vs_acme_pubmed_2026', kind: 'vector-store', packBinding: ['hipaa'] },
 *     { pattern: 'https://drugs.fda.gov/', kind: 'url-prefix', packBinding: ['hipaa'] },
 *   ]);
 *
 *   // Pre-retrieval gate (call BEFORE dispatching to the retrieval API):
 *   allowlist.guardPreRetrieval('org-acme', 'agent-rag-01', {
 *     source: 'vs_acme_pubmed_2026',
 *     kind:   'vector-store',
 *   }); // returns { allowed: true, matchedEntry }
 *
 *   // Throws RagSourceNotAllowlistedError if source is not on the list.
 *   // Throws RagSourceAllowlistRequiredError if strict pack is bound + no list.
 */
export class RagSourceAllowlist {
    /** Key: orgId::agentId -> entries */
    lists = new Map();
    /** Key: orgId::agentId -> pack bindings active for strict-mode checks */
    agentPackBindings = new Map();
    /**
     * Per-cycle lenient-warn tracking.
     * Key: orgId::agentId -> Set of cycle IDs that have already emitted the
     * single per-cycle warning. Prevents spam in lenient mode.
     */
    lenientWarnEmitted = new Set();
    /** SHA-256 of the last emitted audit event (chain linkage). */
    prevSha = '0'.repeat(64);
    auditSink;
    constructor(auditSink) {
        this.auditSink = auditSink ?? (() => undefined);
        // Re-initialise lenientWarnEmitted as a proper Map
        this.lenientWarnEmitted = new Map();
    }
    // ---------------------------------------------------------------------------
    // configure()
    // ---------------------------------------------------------------------------
    /**
     * Register the allowlist for an agent.
     *
     * Throws UnboundDataError if any entry has an empty packBinding (mandatory
     * per feedback_pack_binding_primacy.md).
     *
     * @param orgId   Operator/tenant identifier.
     * @param agentId Agent identifier.
     * @param entries Allowlist entries for this agent.
     * @param activePackIds Optional list of active compliance pack IDs for strict-mode determination.
     */
    configure(orgId, agentId, entries, activePackIds) {
        // Validate pack binding on every entry
        for (const entry of entries) {
            if (!entry.packBinding || entry.packBinding.length === 0) {
                throw new UnboundDataError(entry.pattern);
            }
        }
        const key = agentKey(orgId, agentId);
        this.lists.set(key, entries);
        if (activePackIds) {
            this.agentPackBindings.set(key, activePackIds);
        }
    }
    // ---------------------------------------------------------------------------
    // guardPreRetrieval()
    // ---------------------------------------------------------------------------
    /**
     * Pre-retrieval gate. Must be called BEFORE dispatching to the retrieval API.
     *
     * Returns { allowed: true, matchedEntry } on success.
     * Throws RagSourceNotAllowlistedError when the source is not allowlisted.
     * Throws RagSourceAllowlistRequiredError when a strict pack is bound and
     * no allowlist is configured.
     *
     * @param orgId   Operator/tenant identifier.
     * @param agentId Agent identifier.
     * @param request Retrieval request with source and kind.
     * @param cycleId Optional cycle identifier used for lenient-mode spam prevention.
     */
    guardPreRetrieval(orgId, agentId, request, cycleId) {
        const key = agentKey(orgId, agentId);
        const entries = this.lists.get(key);
        const sourceHash = this.sha256(request.source);
        // -- No allowlist configured -----------------------------------------------
        if (!entries || entries.length === 0) {
            const packBindings = this.agentPackBindings.get(key) ?? [];
            const strictActive = packBindings.filter((p) => STRICT_MODE_PACKS.has(p));
            if (strictActive.length > 0) {
                // Strict-mode pack with no allowlist: fail-closed
                this.emitRejection(orgId, agentId, sourceHash, request.kind, 'strict-mode-no-allowlist');
                throw new RagSourceAllowlistRequiredError({
                    orgId,
                    agentId,
                    strictPacks: strictActive,
                });
            }
            // Lenient mode: warn once per cycle, then allow
            const warnKey = key;
            const cycleSet = this.lenientWarnEmitted.get(warnKey) ?? new Set();
            const effectiveCycleId = cycleId ?? '_default';
            if (!cycleSet.has(effectiveCycleId)) {
                cycleSet.add(effectiveCycleId);
                this.lenientWarnEmitted.set(warnKey, cycleSet);
                const event = {
                    type: 'RAG_UNALLOWLISTED_WARN',
                    severity: 'WARNING',
                    orgId,
                    agentId,
                    sourceHash,
                    kind: request.kind,
                    timestamp: new Date().toISOString(),
                };
                this.auditSink(event);
            }
            // Lenient allow -- construct a synthetic entry so return type is consistent
            const syntheticEntry = {
                pattern: '*',
                kind: request.kind,
                packBinding: ['__lenient__'],
            };
            return { allowed: true, matchedEntry: syntheticEntry };
        }
        // -- Allowlist is configured: find a matching entry -----------------------
        const matched = this.findMatch(request, entries);
        if (!matched) {
            this.emitRejection(orgId, agentId, sourceHash, request.kind, 'not-allowlisted');
            throw new RagSourceNotAllowlistedError({
                orgId,
                agentId,
                sourceHash,
                kind: request.kind,
            });
        }
        const event = {
            type: 'RAG_SOURCE_ALLOWED',
            severity: 'INFO',
            orgId,
            agentId,
            sourceHash,
            kind: request.kind,
            matchedPattern: matched.pattern,
            timestamp: new Date().toISOString(),
        };
        this.auditSink(event);
        return { allowed: true, matchedEntry: matched };
    }
    // ---------------------------------------------------------------------------
    // getEntries()
    // ---------------------------------------------------------------------------
    /**
     * Read-only view of allowlist entries for an agent.
     * Returns an empty array if no allowlist is configured.
     */
    getEntries(orgId, agentId) {
        return this.lists.get(agentKey(orgId, agentId)) ?? [];
    }
    // ---------------------------------------------------------------------------
    // clearAgent()
    // ---------------------------------------------------------------------------
    /**
     * Remove all allowlist entries for an agent.
     * Called at agent shutdown for clean teardown.
     */
    clearAgent(orgId, agentId) {
        const key = agentKey(orgId, agentId);
        this.lists.delete(key);
        this.agentPackBindings.delete(key);
        this.lenientWarnEmitted.delete(key);
    }
    // ---------------------------------------------------------------------------
    // Private helpers
    // ---------------------------------------------------------------------------
    /**
     * Find the first matching allowlist entry for the request.
     * Returns null if no entry matches.
     */
    findMatch(request, entries) {
        for (const entry of entries) {
            if (entry.kind !== request.kind)
                continue;
            switch (request.kind) {
                case 'vector-store':
                case 'document-collection':
                    // Exact match, case-sensitive
                    if (request.source === entry.pattern)
                        return entry;
                    break;
                case 'url-prefix': {
                    const matched = this.matchUrlPrefix(request.source, entry.pattern);
                    if (matched)
                        return entry;
                    break;
                }
                case 'mcp-resource': {
                    const matched = this.matchMcpPrefix(request.source, entry.pattern);
                    if (matched)
                        return entry;
                    break;
                }
            }
        }
        return null;
    }
    /**
     * URL prefix matching with canonicalisation.
     *
     * - Reject URLs with embedded credentials immediately (no allowlist match).
     * - Canonicalise both the request URL and the allowlist pattern before comparing.
     * - Case-insensitive on scheme+host, case-sensitive on path.
     */
    matchUrlPrefix(rawSource, patternRaw) {
        // Reject embedded credentials in source (PT-RAG-ALLOWLIST-1 guard)
        const canonSource = canonicaliseUrl(rawSource);
        if (canonSource === null)
            return false;
        const canonPattern = canonicaliseUrl(patternRaw);
        if (canonPattern === null)
            return false;
        return canonSource === canonPattern || canonSource.startsWith(canonPattern);
    }
    /**
     * MCP URI prefix matching with canonicalisation and traversal prevention.
     * (PT-RAG-ALLOWLIST-3)
     */
    matchMcpPrefix(rawSource, patternRaw) {
        const canonSource = canonicaliseMcpUri(rawSource);
        if (canonSource === null)
            return false;
        const canonPattern = canonicaliseMcpUri(patternRaw);
        if (canonPattern === null)
            return false;
        return canonSource === canonPattern || canonSource.startsWith(canonPattern);
    }
    /**
     * Compute SHA-256 of a string. Used to hash source URLs for audit logs
     * so that sensitive URL parameters are never written to the audit trail.
     */
    sha256(input) {
        return crypto.createHash('sha256').update(input, 'utf8').digest('hex');
    }
    /**
     * Emit a RAG_SOURCE_REJECTED audit event with prev-SHA chain linkage.
     * The source is stored as its SHA-256 hash, never as the raw URL.
     */
    emitRejection(orgId, agentId, sourceHash, kind, reason) {
        const event = {
            type: 'RAG_SOURCE_REJECTED',
            severity: 'WARNING',
            orgId,
            agentId,
            sourceHash,
            kind,
            reason,
            prevSha: this.prevSha,
            timestamp: new Date().toISOString(),
        };
        this.auditSink(event);
        // Advance the chain: prevSha for the next event is SHA-256 of this event
        this.prevSha = crypto
            .createHash('sha256')
            .update(JSON.stringify(event), 'utf8')
            .digest('hex');
    }
}
//# sourceMappingURL=rag-source-allowlist.js.map