ai-shield-core 0.1.0 → 0.2.0
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/audit/logger.d.ts.map +1 -1
- package/dist/audit/logger.js +13 -14
- package/dist/audit/types.js +1 -2
- package/dist/cache/lru.js +1 -5
- package/dist/canary/memory.d.ts +75 -0
- package/dist/canary/memory.d.ts.map +1 -0
- package/dist/canary/memory.js +194 -0
- package/dist/context/wrap-context.d.ts +105 -0
- package/dist/context/wrap-context.d.ts.map +1 -0
- package/dist/context/wrap-context.js +188 -0
- package/dist/cost/anomaly.js +1 -4
- package/dist/cost/pricing.d.ts.map +1 -1
- package/dist/cost/pricing.js +18 -19
- package/dist/cost/tracker.d.ts +19 -1
- package/dist/cost/tracker.d.ts.map +1 -1
- package/dist/cost/tracker.js +27 -10
- package/dist/index.d.ts +31 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +51 -37
- package/dist/policy/circuit-breaker.d.ts +70 -0
- package/dist/policy/circuit-breaker.d.ts.map +1 -0
- package/dist/policy/circuit-breaker.js +376 -0
- package/dist/policy/engine.js +1 -5
- package/dist/policy/tools.js +4 -8
- package/dist/scanner/canary.js +4 -8
- package/dist/scanner/chain.js +1 -5
- package/dist/scanner/heuristic.d.ts +13 -0
- package/dist/scanner/heuristic.d.ts.map +1 -1
- package/dist/scanner/heuristic.js +50 -7
- package/dist/scanner/ingestion.d.ts +116 -0
- package/dist/scanner/ingestion.d.ts.map +1 -0
- package/dist/scanner/ingestion.js +452 -0
- package/dist/scanner/pii.d.ts.map +1 -1
- package/dist/scanner/pii.js +24 -12
- package/dist/shield.d.ts.map +1 -1
- package/dist/shield.js +34 -26
- package/dist/types.d.ts +140 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -2
- package/package.json +4 -3
- package/src/audit/logger.ts +6 -1
- package/src/canary/memory.ts +259 -0
- package/src/context/wrap-context.ts +304 -0
- package/src/cost/pricing.ts +13 -9
- package/src/cost/tracker.ts +35 -1
- package/src/index.ts +82 -1
- package/src/policy/circuit-breaker.ts +449 -0
- package/src/scanner/heuristic.ts +49 -2
- package/src/scanner/ingestion.ts +550 -0
- package/src/scanner/pii.ts +21 -7
- package/src/shield.ts +15 -2
- package/src/types.ts +175 -2
- package/tsconfig.json +2 -1
- package/dist/audit/logger.js.map +0 -1
- package/dist/audit/types.js.map +0 -1
- package/dist/cache/lru.js.map +0 -1
- package/dist/cost/anomaly.js.map +0 -1
- package/dist/cost/pricing.js.map +0 -1
- package/dist/cost/tracker.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/policy/engine.js.map +0 -1
- package/dist/policy/tools.js.map +0 -1
- package/dist/scanner/canary.js.map +0 -1
- package/dist/scanner/chain.js.map +0 -1
- package/dist/scanner/heuristic.js.map +0 -1
- package/dist/scanner/pii.js.map +0 -1
- package/dist/shield.js.map +0 -1
- package/dist/types.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../src/audit/logger.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACxE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAO7C,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,UAAU,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAa;IAC1B,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,UAAU,CAA+C;gBAErD,MAAM,EAAE,iBAAiB;
|
|
1
|
+
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../src/audit/logger.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACxE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAO7C,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,UAAU,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAa;IAC1B,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,UAAU,CAA+C;gBAErD,MAAM,EAAE,iBAAiB;IAerC,wBAAwB;IAClB,GAAG,CACP,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,UAAU,EAClB,OAAO,GAAE,WAAgB,EACzB,KAAK,GAAE;QACL,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;QACvB,OAAO,CAAC,EAAE,MAAM,CAAC;KACb,GACL,OAAO,CAAC,IAAI,CAAC;IA+BhB,sCAAsC;IAChC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAO5B,4CAA4C;IACtC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAQ7B;AAID,qBAAa,iBAAkB,YAAW,UAAU;IAC5C,KAAK,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAIzC,UAAU,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAIjD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IACtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAE5B,OAAO,CAAC,KAAK;CAUd;AAID,qBAAa,gBAAiB,YAAW,UAAU;IACjD,OAAO,EAAE,WAAW,EAAE,CAAM;IAEtB,KAAK,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAIzC,UAAU,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAIjD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IACtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAC7B"}
|
package/dist/audit/logger.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const node_crypto_1 = require("node:crypto");
|
|
5
|
-
const node_crypto_2 = require("node:crypto");
|
|
6
|
-
class AuditLogger {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
export class AuditLogger {
|
|
7
4
|
store;
|
|
8
5
|
buffer = [];
|
|
9
6
|
batchSize;
|
|
@@ -15,19 +12,24 @@ class AuditLogger {
|
|
|
15
12
|
this.flushTimer = setInterval(() => {
|
|
16
13
|
void this.flush();
|
|
17
14
|
}, flushMs);
|
|
15
|
+
// Allow the Node process to exit even if the timer is pending —
|
|
16
|
+
// clean shutdown should go through close(), not rely on the timer.
|
|
17
|
+
if (typeof this.flushTimer.unref === "function") {
|
|
18
|
+
this.flushTimer.unref();
|
|
19
|
+
}
|
|
18
20
|
}
|
|
19
21
|
/** Log a scan result */
|
|
20
22
|
async log(input, result, context = {}, extra = {}) {
|
|
21
23
|
const record = {
|
|
22
|
-
id:
|
|
24
|
+
id: randomUUID(),
|
|
23
25
|
timestamp: new Date(),
|
|
24
26
|
sessionId: context.sessionId,
|
|
25
27
|
agentId: context.agentId,
|
|
26
28
|
userIdHash: context.userId
|
|
27
|
-
?
|
|
29
|
+
? createHash("sha256").update(context.userId).digest("hex").substring(0, 32)
|
|
28
30
|
: undefined,
|
|
29
31
|
requestType: context.tools?.length ? "tool_call" : "chat",
|
|
30
|
-
inputHash:
|
|
32
|
+
inputHash: createHash("sha256").update(input).digest("hex"),
|
|
31
33
|
inputTokenCount: Math.ceil(input.length / 4), // rough estimate
|
|
32
34
|
model: extra.model,
|
|
33
35
|
securityDecision: result.decision,
|
|
@@ -62,9 +64,8 @@ class AuditLogger {
|
|
|
62
64
|
await this.store.close();
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
|
-
exports.AuditLogger = AuditLogger;
|
|
66
67
|
// --- Console Store (for development) ---
|
|
67
|
-
class ConsoleAuditStore {
|
|
68
|
+
export class ConsoleAuditStore {
|
|
68
69
|
async write(record) {
|
|
69
70
|
this.print(record);
|
|
70
71
|
}
|
|
@@ -83,9 +84,8 @@ class ConsoleAuditStore {
|
|
|
83
84
|
process.stderr.write(`[AI-Shield] ${icon} | ${record.scanDurationMs.toFixed(1)}ms | agent=${record.agentId ?? "-"} | ${record.inputHash.substring(0, 8)}...${violations}\n`);
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
|
-
exports.ConsoleAuditStore = ConsoleAuditStore;
|
|
87
87
|
// --- Memory Store (for testing) ---
|
|
88
|
-
class MemoryAuditStore {
|
|
88
|
+
export class MemoryAuditStore {
|
|
89
89
|
records = [];
|
|
90
90
|
async write(record) {
|
|
91
91
|
this.records.push(record);
|
|
@@ -96,5 +96,4 @@ class MemoryAuditStore {
|
|
|
96
96
|
async flush() { }
|
|
97
97
|
async close() { }
|
|
98
98
|
}
|
|
99
|
-
exports.MemoryAuditStore = MemoryAuditStore;
|
|
100
99
|
//# sourceMappingURL=logger.js.map
|
package/dist/audit/types.js
CHANGED
package/dist/cache/lru.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
// ============================================================
|
|
3
2
|
// LRU Cache — O(1) scan result caching with TTL
|
|
4
3
|
// Uses Map insertion-order for LRU eviction
|
|
5
4
|
// ============================================================
|
|
6
|
-
|
|
7
|
-
exports.ScanLRUCache = void 0;
|
|
8
|
-
class ScanLRUCache {
|
|
5
|
+
export class ScanLRUCache {
|
|
9
6
|
cache = new Map();
|
|
10
7
|
maxSize;
|
|
11
8
|
ttlMs;
|
|
@@ -70,5 +67,4 @@ class ScanLRUCache {
|
|
|
70
67
|
return removed;
|
|
71
68
|
}
|
|
72
69
|
}
|
|
73
|
-
exports.ScanLRUCache = ScanLRUCache;
|
|
74
70
|
//# sourceMappingURL=lru.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { MemoryCanaryEntry, MemoryCanaryVerification } from "../types.js";
|
|
2
|
+
export interface MintMemoryCanaryOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Override token byte length. Minimum 8 (16 hex chars). Default 16.
|
|
5
|
+
* Longer = stronger guessing resistance, but the hex string lives in
|
|
6
|
+
* sidecar storage so the overhead is negligible.
|
|
7
|
+
*/
|
|
8
|
+
tokenBytes?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Mint a canary for a memory entry. Call at write time, persist the
|
|
12
|
+
* returned `MemoryCanaryEntry` alongside (or in place of) the raw entry.
|
|
13
|
+
*
|
|
14
|
+
* @param id Stable identifier of the memory entry.
|
|
15
|
+
* @param content The content being stored.
|
|
16
|
+
* @param tenantId Optional tenant scope.
|
|
17
|
+
*/
|
|
18
|
+
export declare function mintMemoryCanary(id: string, content: string, tenantId?: string, options?: MintMemoryCanaryOptions): MemoryCanaryEntry;
|
|
19
|
+
/**
|
|
20
|
+
* Verify a previously minted canary against the content read back from
|
|
21
|
+
* storage. Returns `valid: true` only if both the content matches what
|
|
22
|
+
* was sealed and the tenant binding (if any) matches.
|
|
23
|
+
*
|
|
24
|
+
* Use case 1 — mutation detection:
|
|
25
|
+
* ```ts
|
|
26
|
+
* const entry = mintMemoryCanary("fact:42", "Sky is blue.");
|
|
27
|
+
* await db.write({ ...entry });
|
|
28
|
+
*
|
|
29
|
+
* // ... later ...
|
|
30
|
+
* const stored = await db.read("fact:42");
|
|
31
|
+
* const ver = verifyMemoryCanary(stored, stored.content);
|
|
32
|
+
* if (!ver.valid) {
|
|
33
|
+
* logger.security("Memory poisoning suspected", { id, reason: ver.reason });
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* Use case 2 — cross-tenant leak detection:
|
|
38
|
+
* ```ts
|
|
39
|
+
* // tenant A reads what should be a tenant-B-only entry
|
|
40
|
+
* const ver = verifyMemoryCanary(entry, entry.content, { tenantId: "tenant-A" });
|
|
41
|
+
* // ver.reason === "tenant_mismatch"
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export declare function verifyMemoryCanary(entry: MemoryCanaryEntry, observedContent: string, options?: {
|
|
45
|
+
tenantId?: string;
|
|
46
|
+
}): MemoryCanaryVerification;
|
|
47
|
+
/**
|
|
48
|
+
* Re-mint a canary after legitimate content edit. Returns a new
|
|
49
|
+
* sealed entry that supersedes the old one. The old `canaryToken`
|
|
50
|
+
* is rotated so a replay of the previous hash is also invalidated.
|
|
51
|
+
*/
|
|
52
|
+
export declare function rotateMemoryCanary(prev: MemoryCanaryEntry, newContent: string): MemoryCanaryEntry;
|
|
53
|
+
/**
|
|
54
|
+
* Inject a *sentinel* memory entry — a decoy fact whose mutation would
|
|
55
|
+
* indicate the store was tampered with. Pair with `findSentinelMutations()`
|
|
56
|
+
* in a periodic sweep over the memory store.
|
|
57
|
+
*
|
|
58
|
+
* Returns a `MemoryCanaryEntry` with deterministic content that callers
|
|
59
|
+
* can recognise. The content includes the canary token so even content
|
|
60
|
+
* inspection (not just hash compare) catches mutation.
|
|
61
|
+
*/
|
|
62
|
+
export declare function buildSentinelEntry(scope: string, tenantId?: string): MemoryCanaryEntry;
|
|
63
|
+
/**
|
|
64
|
+
* Bulk verify a set of stored entries against their canaries. Returns
|
|
65
|
+
* the IDs that failed verification, with the reason.
|
|
66
|
+
*/
|
|
67
|
+
export declare function bulkVerify(entries: Array<{
|
|
68
|
+
canary: MemoryCanaryEntry;
|
|
69
|
+
observedContent: string;
|
|
70
|
+
expectedTenantId?: string;
|
|
71
|
+
}>): Array<{
|
|
72
|
+
id: string;
|
|
73
|
+
reason: NonNullable<MemoryCanaryVerification["reason"]>;
|
|
74
|
+
}>;
|
|
75
|
+
//# sourceMappingURL=memory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/canary/memory.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,iBAAiB,EACjB,wBAAwB,EACzB,MAAM,aAAa,CAAC;AAgCrB,MAAM,WAAW,uBAAuB;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,MAAM,EACjB,OAAO,GAAE,uBAA4B,GACpC,iBAAiB,CAsBnB;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,iBAAiB,EACxB,eAAe,EAAE,MAAM,EACvB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAClC,wBAAwB,CAuD1B;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,iBAAiB,EACvB,UAAU,EAAE,MAAM,GACjB,iBAAiB,CAEnB;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,MAAM,EACb,QAAQ,CAAC,EAAE,MAAM,GAChB,iBAAiB,CAQnB;AAED;;;GAGG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,KAAK,CAAC;IACb,MAAM,EAAE,iBAAiB,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC,GACD,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,WAAW,CAAC,wBAAwB,CAAC,QAAQ,CAAC,CAAC,CAAA;CAAE,CAAC,CAchF"}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
// ============================================================
|
|
3
|
+
// Memory Canary — Persistence-Poisoning Detection
|
|
4
|
+
//
|
|
5
|
+
// Existing canary tokens defend the *system prompt*: inject a sentinel
|
|
6
|
+
// + check the response for leakage. That works for prompt-extraction
|
|
7
|
+
// attacks but does nothing for the broader class of *persistence
|
|
8
|
+
// poisoning*: an attacker mutates a stored memory entry, knowledge-graph
|
|
9
|
+
// fact, or RAG document so that the next retrieval steers the model.
|
|
10
|
+
//
|
|
11
|
+
// MemoryCanary seals each write with:
|
|
12
|
+
// 1. A random sentinel token bound to the entry (`canaryToken`).
|
|
13
|
+
// 2. A SHA-256 hash over `id || content || canaryToken` so any silent
|
|
14
|
+
// mutation of `content` between write and read is detectable.
|
|
15
|
+
// 3. Optional `tenantId` binding so a cross-tenant leak surfaces as
|
|
16
|
+
// an explicit `tenant_mismatch` reason.
|
|
17
|
+
//
|
|
18
|
+
// Storage of the canary metadata is the caller's responsibility — the
|
|
19
|
+
// returned `MemoryCanaryEntry` is JSON-serialisable and meant to be
|
|
20
|
+
// persisted alongside the memory entry (separate column / sidecar key).
|
|
21
|
+
//
|
|
22
|
+
// The library does not store secrets and does not need a key. The
|
|
23
|
+
// security property is integrity (mutation detection), not
|
|
24
|
+
// confidentiality.
|
|
25
|
+
// ============================================================
|
|
26
|
+
/** Minimum allowed canary-token length in bytes (= 16 hex chars). */
|
|
27
|
+
const MIN_TOKEN_BYTES = 8;
|
|
28
|
+
/** Default canary-token length: 16 random bytes -> 32 hex chars. */
|
|
29
|
+
const DEFAULT_TOKEN_BYTES = 16;
|
|
30
|
+
/**
|
|
31
|
+
* Mint a canary for a memory entry. Call at write time, persist the
|
|
32
|
+
* returned `MemoryCanaryEntry` alongside (or in place of) the raw entry.
|
|
33
|
+
*
|
|
34
|
+
* @param id Stable identifier of the memory entry.
|
|
35
|
+
* @param content The content being stored.
|
|
36
|
+
* @param tenantId Optional tenant scope.
|
|
37
|
+
*/
|
|
38
|
+
export function mintMemoryCanary(id, content, tenantId, options = {}) {
|
|
39
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
40
|
+
throw new TypeError("mintMemoryCanary: 'id' must be a non-empty string");
|
|
41
|
+
}
|
|
42
|
+
if (typeof content !== "string") {
|
|
43
|
+
throw new TypeError("mintMemoryCanary: 'content' must be a string");
|
|
44
|
+
}
|
|
45
|
+
const tokenBytes = Math.max(MIN_TOKEN_BYTES, options.tokenBytes ?? DEFAULT_TOKEN_BYTES);
|
|
46
|
+
const canaryToken = randomBytes(tokenBytes).toString("hex");
|
|
47
|
+
const contentHash = computeHash(id, content, canaryToken, tenantId);
|
|
48
|
+
return {
|
|
49
|
+
id,
|
|
50
|
+
content,
|
|
51
|
+
contentHash,
|
|
52
|
+
canaryToken,
|
|
53
|
+
createdAt: new Date(),
|
|
54
|
+
tenantId,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Verify a previously minted canary against the content read back from
|
|
59
|
+
* storage. Returns `valid: true` only if both the content matches what
|
|
60
|
+
* was sealed and the tenant binding (if any) matches.
|
|
61
|
+
*
|
|
62
|
+
* Use case 1 — mutation detection:
|
|
63
|
+
* ```ts
|
|
64
|
+
* const entry = mintMemoryCanary("fact:42", "Sky is blue.");
|
|
65
|
+
* await db.write({ ...entry });
|
|
66
|
+
*
|
|
67
|
+
* // ... later ...
|
|
68
|
+
* const stored = await db.read("fact:42");
|
|
69
|
+
* const ver = verifyMemoryCanary(stored, stored.content);
|
|
70
|
+
* if (!ver.valid) {
|
|
71
|
+
* logger.security("Memory poisoning suspected", { id, reason: ver.reason });
|
|
72
|
+
* }
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* Use case 2 — cross-tenant leak detection:
|
|
76
|
+
* ```ts
|
|
77
|
+
* // tenant A reads what should be a tenant-B-only entry
|
|
78
|
+
* const ver = verifyMemoryCanary(entry, entry.content, { tenantId: "tenant-A" });
|
|
79
|
+
* // ver.reason === "tenant_mismatch"
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export function verifyMemoryCanary(entry, observedContent, options = {}) {
|
|
83
|
+
if (!entry || typeof entry !== "object") {
|
|
84
|
+
return { valid: false, reason: "canary_missing" };
|
|
85
|
+
}
|
|
86
|
+
if (typeof entry.canaryToken !== "string" ||
|
|
87
|
+
entry.canaryToken.length < MIN_TOKEN_BYTES * 2) {
|
|
88
|
+
return { valid: false, reason: "canary_missing" };
|
|
89
|
+
}
|
|
90
|
+
if (typeof observedContent !== "string") {
|
|
91
|
+
return { valid: false, reason: "content_mutated" };
|
|
92
|
+
}
|
|
93
|
+
// Tenant binding is fail-closed. If the entry was minted with a
|
|
94
|
+
// tenantId, the caller MUST supply the same tenantId. A caller that
|
|
95
|
+
// omits `options.tenantId` against a tenant-bound entry surfaces as
|
|
96
|
+
// a leak rather than a silent pass (Critic C2 from round 1 review).
|
|
97
|
+
if (entry.tenantId !== undefined) {
|
|
98
|
+
if (options.tenantId === undefined ||
|
|
99
|
+
options.tenantId !== entry.tenantId) {
|
|
100
|
+
return {
|
|
101
|
+
valid: false,
|
|
102
|
+
reason: "tenant_mismatch",
|
|
103
|
+
observed: observedContent,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else if (options.tenantId !== undefined &&
|
|
108
|
+
entry.tenantId !== options.tenantId) {
|
|
109
|
+
// Caller passed a tenantId but the entry has none — also a mismatch.
|
|
110
|
+
return {
|
|
111
|
+
valid: false,
|
|
112
|
+
reason: "tenant_mismatch",
|
|
113
|
+
observed: observedContent,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const expectedHash = computeHash(entry.id, observedContent, entry.canaryToken, entry.tenantId);
|
|
117
|
+
if (!hashesEqual(expectedHash, entry.contentHash)) {
|
|
118
|
+
return {
|
|
119
|
+
valid: false,
|
|
120
|
+
reason: observedContent === entry.content ? "hash_mismatch" : "content_mutated",
|
|
121
|
+
observed: observedContent,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { valid: true };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Re-mint a canary after legitimate content edit. Returns a new
|
|
128
|
+
* sealed entry that supersedes the old one. The old `canaryToken`
|
|
129
|
+
* is rotated so a replay of the previous hash is also invalidated.
|
|
130
|
+
*/
|
|
131
|
+
export function rotateMemoryCanary(prev, newContent) {
|
|
132
|
+
return mintMemoryCanary(prev.id, newContent, prev.tenantId);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Inject a *sentinel* memory entry — a decoy fact whose mutation would
|
|
136
|
+
* indicate the store was tampered with. Pair with `findSentinelMutations()`
|
|
137
|
+
* in a periodic sweep over the memory store.
|
|
138
|
+
*
|
|
139
|
+
* Returns a `MemoryCanaryEntry` with deterministic content that callers
|
|
140
|
+
* can recognise. The content includes the canary token so even content
|
|
141
|
+
* inspection (not just hash compare) catches mutation.
|
|
142
|
+
*/
|
|
143
|
+
export function buildSentinelEntry(scope, tenantId) {
|
|
144
|
+
// ID combines timestamp (for ordering) + random suffix (so enumeration
|
|
145
|
+
// by approximate-time guessing is infeasible — Critic M2 round 1).
|
|
146
|
+
const idSuffix = randomBytes(4).toString("hex");
|
|
147
|
+
const id = `ai-shield-sentinel:${scope}:${Date.now().toString(36)}-${idSuffix}`;
|
|
148
|
+
const nonce = randomBytes(8).toString("hex");
|
|
149
|
+
const content = `[AI-Shield sentinel — do not modify] scope=${scope} nonce=${nonce}`;
|
|
150
|
+
return mintMemoryCanary(id, content, tenantId);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Bulk verify a set of stored entries against their canaries. Returns
|
|
154
|
+
* the IDs that failed verification, with the reason.
|
|
155
|
+
*/
|
|
156
|
+
export function bulkVerify(entries) {
|
|
157
|
+
const failures = [];
|
|
158
|
+
for (const e of entries) {
|
|
159
|
+
const v = verifyMemoryCanary(e.canary, e.observedContent, {
|
|
160
|
+
tenantId: e.expectedTenantId,
|
|
161
|
+
});
|
|
162
|
+
if (!v.valid && v.reason) {
|
|
163
|
+
failures.push({ id: e.canary.id, reason: v.reason });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return failures;
|
|
167
|
+
}
|
|
168
|
+
// --- Internal helpers ---
|
|
169
|
+
function computeHash(id, content, token, tenantId) {
|
|
170
|
+
return createHash("sha256")
|
|
171
|
+
.update(id)
|
|
172
|
+
.update("\0")
|
|
173
|
+
.update(content)
|
|
174
|
+
.update("\0")
|
|
175
|
+
.update(token)
|
|
176
|
+
.update("\0")
|
|
177
|
+
.update(tenantId ?? "")
|
|
178
|
+
.digest("hex");
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Constant-time hex string compare. Falls back to a non-timing-safe
|
|
182
|
+
* direct compare only when lengths differ (which is itself the leak
|
|
183
|
+
* we want surfaced as a `false`, so this is fine).
|
|
184
|
+
*/
|
|
185
|
+
function hashesEqual(a, b) {
|
|
186
|
+
if (typeof a !== "string" || typeof b !== "string")
|
|
187
|
+
return false;
|
|
188
|
+
if (a.length !== b.length)
|
|
189
|
+
return false;
|
|
190
|
+
// Safe — both buffers have identical length, both come from
|
|
191
|
+
// hex(SHA-256), no untrusted input.
|
|
192
|
+
return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=memory.js.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { WrappedContext, Violation } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Input shape for `wrapContext()`. Each named field is conventional;
|
|
4
|
+
* pass only what applies.
|
|
5
|
+
*/
|
|
6
|
+
export interface WrapContextInput {
|
|
7
|
+
/** Developer-controlled prompt. Always `trust: "system"`. */
|
|
8
|
+
system?: string;
|
|
9
|
+
/** Direct user message(s). `trust: "untrusted"`, `source: "user"`. */
|
|
10
|
+
user?: string | string[];
|
|
11
|
+
/** Retrieved documents. `trust: "untrusted"`, `source: "rag"`. */
|
|
12
|
+
retrieved?: Array<{
|
|
13
|
+
content: string;
|
|
14
|
+
label?: string;
|
|
15
|
+
} | string>;
|
|
16
|
+
/** MCP / function tool descriptions about to be exposed to the model. */
|
|
17
|
+
tools?: Array<{
|
|
18
|
+
content: string;
|
|
19
|
+
label?: string;
|
|
20
|
+
} | string>;
|
|
21
|
+
/** Stored memory facts. `trust: "untrusted"`, `source: "memory"`. */
|
|
22
|
+
memory?: Array<{
|
|
23
|
+
content: string;
|
|
24
|
+
label?: string;
|
|
25
|
+
} | string>;
|
|
26
|
+
/** Scraped / fetched web content. */
|
|
27
|
+
web?: Array<{
|
|
28
|
+
content: string;
|
|
29
|
+
label?: string;
|
|
30
|
+
} | string>;
|
|
31
|
+
/** Output from another agent (multi-agent pipelines). */
|
|
32
|
+
agentOutput?: Array<{
|
|
33
|
+
content: string;
|
|
34
|
+
label?: string;
|
|
35
|
+
} | string>;
|
|
36
|
+
/**
|
|
37
|
+
* Promote specific named segments to `"trusted"` (e.g. an internal
|
|
38
|
+
* knowledge base whose contents you control end-to-end).
|
|
39
|
+
* Match is by `label` substring, case-insensitive.
|
|
40
|
+
*/
|
|
41
|
+
trustedLabels?: string[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Build a `WrappedContext` from typed inputs.
|
|
45
|
+
*
|
|
46
|
+
* Trust assignment:
|
|
47
|
+
* - `system` -> system
|
|
48
|
+
* - `retrieved`/`tools`/`memory`/`web`/`agent-output` -> untrusted
|
|
49
|
+
* - `user` -> untrusted (a user is not trusted in this threat model — they
|
|
50
|
+
* can also inject; the `untrusted` label means "scan aggressively")
|
|
51
|
+
* - any segment whose `label` matches one of `trustedLabels` -> trusted
|
|
52
|
+
*
|
|
53
|
+
* Trust does NOT mean "skip scanning". It only governs how
|
|
54
|
+
* `assemblePrompt()` and the per-segment policy decide whether to
|
|
55
|
+
* include the segment in the final assembled prompt.
|
|
56
|
+
*/
|
|
57
|
+
export declare function wrapContext(input: WrapContextInput): WrappedContext;
|
|
58
|
+
/**
|
|
59
|
+
* Scan every segment with the source-specific ingestion profile.
|
|
60
|
+
* Mutates `ctx` in place by attaching `scanResults` + `decision`,
|
|
61
|
+
* AND returns the same object for chaining.
|
|
62
|
+
*/
|
|
63
|
+
export declare function scanWrappedContext(ctx: WrappedContext, options?: {
|
|
64
|
+
strictness?: "low" | "medium" | "high";
|
|
65
|
+
}): Promise<WrappedContext>;
|
|
66
|
+
/**
|
|
67
|
+
* Assemble a prompt string respecting tier boundaries.
|
|
68
|
+
*
|
|
69
|
+
* Order: `system` → `trusted` retrieved/memory/tool-desc → `user`
|
|
70
|
+
* → all remaining `untrusted` segments wrapped in fenced markers.
|
|
71
|
+
*
|
|
72
|
+
* Why `trusted` before `user`? Putting developer-marked trusted
|
|
73
|
+
* context above the user message reduces the chance an untrusted user
|
|
74
|
+
* prompt re-frames the trusted reference material below it.
|
|
75
|
+
*
|
|
76
|
+
* Untrusted segments are wrapped in an explicit fence so a downstream
|
|
77
|
+
* model has a chance to attend to provenance. This is not a guarantee
|
|
78
|
+
* (no in-band marker is) but it is the single highest-leverage
|
|
79
|
+
* mitigation we can apply at the toolkit layer per Anthropic +
|
|
80
|
+
* OpenAI Model Spec guidance.
|
|
81
|
+
*
|
|
82
|
+
* Pass `strictMode: true` to OMIT blocked segments entirely. Default
|
|
83
|
+
* keeps them but fences them with a `<BLOCKED>` marker so an auditor
|
|
84
|
+
* can see what was tried.
|
|
85
|
+
*/
|
|
86
|
+
export interface AssembleOptions {
|
|
87
|
+
strictMode?: boolean;
|
|
88
|
+
/** Custom fence labels. Defaults are sensible. */
|
|
89
|
+
fences?: {
|
|
90
|
+
untrusted?: {
|
|
91
|
+
open: string;
|
|
92
|
+
close: string;
|
|
93
|
+
};
|
|
94
|
+
blocked?: {
|
|
95
|
+
open: string;
|
|
96
|
+
close: string;
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export declare function assemblePrompt(ctx: WrappedContext, options?: AssembleOptions): string;
|
|
101
|
+
/**
|
|
102
|
+
* Convenience aggregator: violations across all scanned segments.
|
|
103
|
+
*/
|
|
104
|
+
export declare function flattenViolations(ctx: WrappedContext): Violation[];
|
|
105
|
+
//# sourceMappingURL=wrap-context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wrap-context.d.ts","sourceRoot":"","sources":["../../src/context/wrap-context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAIV,cAAc,EAGd,SAAS,EACV,MAAM,aAAa,CAAC;AAmBrB;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,6DAA6D;IAC7D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACzB,kEAAkE;IAClE,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC,CAAC;IAChE,yEAAyE;IACzE,KAAK,CAAC,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC,CAAC;IAC5D,qEAAqE;IACrE,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC,CAAC;IAC7D,qCAAqC;IACrC,GAAG,CAAC,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC,CAAC;IAC1D,yDAAyD;IACzD,WAAW,CAAC,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC,CAAC;IAClE;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,cAAc,CAmEnE;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,cAAc,EACnB,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAA;CAAO,GACvD,OAAO,CAAC,cAAc,CAAC,CAmCzB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,kDAAkD;IAClD,MAAM,CAAC,EAAE;QACP,SAAS,CAAC,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAA;SAAE,CAAC;QAC5C,OAAO,CAAC,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAA;SAAE,CAAC;KAC3C,CAAC;CACH;AAED,wBAAgB,cAAc,CAC5B,GAAG,EAAE,cAAc,EACnB,OAAO,GAAE,eAAoB,GAC5B,MAAM,CAyER;AAUD;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,cAAc,GAAG,SAAS,EAAE,CAGlE"}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { IngestionScanner } from "../scanner/ingestion.js";
|
|
3
|
+
/**
|
|
4
|
+
* Build a `WrappedContext` from typed inputs.
|
|
5
|
+
*
|
|
6
|
+
* Trust assignment:
|
|
7
|
+
* - `system` -> system
|
|
8
|
+
* - `retrieved`/`tools`/`memory`/`web`/`agent-output` -> untrusted
|
|
9
|
+
* - `user` -> untrusted (a user is not trusted in this threat model — they
|
|
10
|
+
* can also inject; the `untrusted` label means "scan aggressively")
|
|
11
|
+
* - any segment whose `label` matches one of `trustedLabels` -> trusted
|
|
12
|
+
*
|
|
13
|
+
* Trust does NOT mean "skip scanning". It only governs how
|
|
14
|
+
* `assemblePrompt()` and the per-segment policy decide whether to
|
|
15
|
+
* include the segment in the final assembled prompt.
|
|
16
|
+
*/
|
|
17
|
+
export function wrapContext(input) {
|
|
18
|
+
const segments = [];
|
|
19
|
+
const trustedLabels = (input.trustedLabels ?? []).map((s) => s.toLowerCase());
|
|
20
|
+
// Critic H1 — substring match would let an attacker-supplied label
|
|
21
|
+
// like "untrusted-doc-INTERNAL-kb-poisoned" claim trust because it
|
|
22
|
+
// CONTAINS the trusted prefix. Match exact or path-anchored only.
|
|
23
|
+
const isTrustedLabel = (label) => {
|
|
24
|
+
if (!label)
|
|
25
|
+
return false;
|
|
26
|
+
const lc = label.toLowerCase();
|
|
27
|
+
return trustedLabels.some((tl) => lc === tl || lc.startsWith(tl + "/"));
|
|
28
|
+
};
|
|
29
|
+
const push = (content, source, trust, label) => {
|
|
30
|
+
if (typeof content !== "string" || content.length === 0)
|
|
31
|
+
return;
|
|
32
|
+
segments.push({
|
|
33
|
+
source,
|
|
34
|
+
trust,
|
|
35
|
+
content,
|
|
36
|
+
label,
|
|
37
|
+
contentHash: hashContent(content),
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
// System: always trust=system. The `source` field is unused for
|
|
41
|
+
// system segments because `trust === "system"` is the authoritative
|
|
42
|
+
// signal — Analyst A2 round 1 review. We keep `source: "user"` here
|
|
43
|
+
// only because `ContextSegment.source` is non-optional; any code that
|
|
44
|
+
// branches on `seg.source` MUST first check `seg.trust !== "system"`.
|
|
45
|
+
if (input.system) {
|
|
46
|
+
push(input.system, "user", "system", "system-prompt");
|
|
47
|
+
}
|
|
48
|
+
// User messages.
|
|
49
|
+
if (input.user) {
|
|
50
|
+
const userInputs = Array.isArray(input.user) ? input.user : [input.user];
|
|
51
|
+
for (const u of userInputs) {
|
|
52
|
+
push(u, "user", "untrusted", "user");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Helper for the array-of-{content,label} groups.
|
|
56
|
+
const pushGroup = (items, source) => {
|
|
57
|
+
if (!items)
|
|
58
|
+
return;
|
|
59
|
+
for (const item of items) {
|
|
60
|
+
const content = typeof item === "string" ? item : item.content;
|
|
61
|
+
const label = typeof item === "string" ? undefined : item.label;
|
|
62
|
+
const trust = isTrustedLabel(label) ? "trusted" : "untrusted";
|
|
63
|
+
push(content, source, trust, label);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
pushGroup(input.retrieved, "rag");
|
|
67
|
+
pushGroup(input.tools, "tool-desc");
|
|
68
|
+
pushGroup(input.memory, "memory");
|
|
69
|
+
pushGroup(input.web, "web");
|
|
70
|
+
pushGroup(input.agentOutput, "agent-output");
|
|
71
|
+
return { segments };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Scan every segment with the source-specific ingestion profile.
|
|
75
|
+
* Mutates `ctx` in place by attaching `scanResults` + `decision`,
|
|
76
|
+
* AND returns the same object for chaining.
|
|
77
|
+
*/
|
|
78
|
+
export async function scanWrappedContext(ctx, options = {}) {
|
|
79
|
+
const scanner = new IngestionScanner({
|
|
80
|
+
strictness: options.strictness ?? "high",
|
|
81
|
+
});
|
|
82
|
+
const results = [];
|
|
83
|
+
let worst = "allow";
|
|
84
|
+
for (let i = 0; i < ctx.segments.length; i += 1) {
|
|
85
|
+
const seg = ctx.segments[i];
|
|
86
|
+
// System segments skip the scanner — they're developer-authored and
|
|
87
|
+
// running the heuristic over a real system prompt would flood with
|
|
88
|
+
// false positives (system prompts ARE instructions, by definition).
|
|
89
|
+
if (seg.trust === "system") {
|
|
90
|
+
results.push({ segmentIndex: i, decision: "allow", violations: [] });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const scanContext = {
|
|
94
|
+
source: seg.source,
|
|
95
|
+
trustTier: seg.trust,
|
|
96
|
+
};
|
|
97
|
+
const r = await scanner.scan(seg.content, scanContext);
|
|
98
|
+
results.push({
|
|
99
|
+
segmentIndex: i,
|
|
100
|
+
decision: r.decision,
|
|
101
|
+
violations: r.violations,
|
|
102
|
+
});
|
|
103
|
+
if (priority(r.decision) > priority(worst)) {
|
|
104
|
+
worst = r.decision;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
ctx.scanResults = results;
|
|
108
|
+
ctx.decision = worst;
|
|
109
|
+
return ctx;
|
|
110
|
+
}
|
|
111
|
+
export function assemblePrompt(ctx, options = {}) {
|
|
112
|
+
const fences = {
|
|
113
|
+
untrusted: options.fences?.untrusted ?? {
|
|
114
|
+
open: "<UNTRUSTED_CONTENT source=",
|
|
115
|
+
close: "</UNTRUSTED_CONTENT>",
|
|
116
|
+
},
|
|
117
|
+
blocked: options.fences?.blocked ?? {
|
|
118
|
+
open: "<BLOCKED_CONTENT source=",
|
|
119
|
+
close: "</BLOCKED_CONTENT>",
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
// Pre-build a segment→index map ONCE. Avoids O(n²) `indexOf` inside the
|
|
123
|
+
// assembly loop AND removes a TOCTOU on mutable `ctx.segments` (Critic
|
|
124
|
+
// H2 + Analyst A4 round 1 review).
|
|
125
|
+
const segmentIndexMap = new Map();
|
|
126
|
+
ctx.segments.forEach((s, i) => segmentIndexMap.set(s, i));
|
|
127
|
+
const segmentResultMap = new Map();
|
|
128
|
+
for (const r of ctx.scanResults ?? []) {
|
|
129
|
+
segmentResultMap.set(r.segmentIndex, r);
|
|
130
|
+
}
|
|
131
|
+
const ordered = [];
|
|
132
|
+
// 1. system
|
|
133
|
+
ordered.push(...ctx.segments.filter((s) => s.trust === "system"));
|
|
134
|
+
// 2. trusted (retrieved/memory/tool-desc the dev marked as trusted)
|
|
135
|
+
ordered.push(...ctx.segments.filter((s) => s.trust === "trusted"));
|
|
136
|
+
// 3. user (untrusted, source="user")
|
|
137
|
+
ordered.push(...ctx.segments.filter((s) => s.source === "user" && s.trust === "untrusted"));
|
|
138
|
+
// 4. all remaining untrusted, preserve original order within group.
|
|
139
|
+
for (const s of ctx.segments) {
|
|
140
|
+
if (s.trust === "untrusted" && s.source !== "user") {
|
|
141
|
+
ordered.push(s);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const parts = [];
|
|
145
|
+
for (const seg of ordered) {
|
|
146
|
+
const segIdx = segmentIndexMap.get(seg) ?? -1;
|
|
147
|
+
const segResult = segIdx >= 0 ? segmentResultMap.get(segIdx) : undefined;
|
|
148
|
+
const blocked = segResult?.decision === "block";
|
|
149
|
+
if (blocked) {
|
|
150
|
+
if (options.strictMode) {
|
|
151
|
+
// Drop entirely.
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
parts.push(`${fences.blocked.open}"${seg.source}" label="${seg.label ?? ""}">\n${seg.content}\n${fences.blocked.close}`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (seg.trust === "system") {
|
|
158
|
+
parts.push(seg.content);
|
|
159
|
+
}
|
|
160
|
+
else if (seg.trust === "trusted") {
|
|
161
|
+
parts.push(seg.content);
|
|
162
|
+
}
|
|
163
|
+
else if (seg.source === "user" && seg.trust === "untrusted") {
|
|
164
|
+
// User input keeps its natural shape — fencing every user message
|
|
165
|
+
// creates more noise than signal.
|
|
166
|
+
parts.push(seg.content);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
parts.push(`${fences.untrusted.open}"${seg.source}" label="${seg.label ?? ""}">\n${seg.content}\n${fences.untrusted.close}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return parts.join("\n\n");
|
|
173
|
+
}
|
|
174
|
+
function hashContent(content) {
|
|
175
|
+
return createHash("sha256").update(content).digest("hex");
|
|
176
|
+
}
|
|
177
|
+
function priority(d) {
|
|
178
|
+
return d === "block" ? 2 : d === "warn" ? 1 : 0;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Convenience aggregator: violations across all scanned segments.
|
|
182
|
+
*/
|
|
183
|
+
export function flattenViolations(ctx) {
|
|
184
|
+
if (!ctx.scanResults)
|
|
185
|
+
return [];
|
|
186
|
+
return ctx.scanResults.flatMap((r) => r.violations);
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=wrap-context.js.map
|