@graphorin/agent 0.6.0 → 0.7.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.
Files changed (110) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +31 -11
  3. package/dist/errors/index.d.ts +17 -4
  4. package/dist/errors/index.d.ts.map +1 -1
  5. package/dist/errors/index.js +19 -3
  6. package/dist/errors/index.js.map +1 -1
  7. package/dist/factory.d.ts.map +1 -1
  8. package/dist/factory.js +153 -1930
  9. package/dist/factory.js.map +1 -1
  10. package/dist/fanout/index.d.ts +13 -1
  11. package/dist/fanout/index.d.ts.map +1 -1
  12. package/dist/fanout/index.js +13 -4
  13. package/dist/fanout/index.js.map +1 -1
  14. package/dist/index.d.ts +7 -6
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +11 -9
  17. package/dist/index.js.map +1 -1
  18. package/dist/lateral-leak/index.js +1 -1
  19. package/dist/package.js +6 -0
  20. package/dist/package.js.map +1 -0
  21. package/dist/run-state/index.d.ts +32 -6
  22. package/dist/run-state/index.d.ts.map +1 -1
  23. package/dist/run-state/index.js +46 -22
  24. package/dist/run-state/index.js.map +1 -1
  25. package/dist/runtime/agent-surface.js +122 -0
  26. package/dist/runtime/agent-surface.js.map +1 -0
  27. package/dist/runtime/agent-to-tool.d.ts +51 -0
  28. package/dist/runtime/agent-to-tool.d.ts.map +1 -0
  29. package/dist/runtime/agent-to-tool.js +145 -0
  30. package/dist/runtime/agent-to-tool.js.map +1 -0
  31. package/dist/runtime/approvals.js +0 -0
  32. package/dist/runtime/approvals.js.map +1 -0
  33. package/dist/runtime/dispatch.js +108 -0
  34. package/dist/runtime/dispatch.js.map +1 -0
  35. package/dist/runtime/executor-wiring.js +128 -0
  36. package/dist/runtime/executor-wiring.js.map +1 -0
  37. package/dist/runtime/fallback-chain.js +139 -0
  38. package/dist/runtime/fallback-chain.js.map +1 -0
  39. package/dist/runtime/handoff.js +307 -0
  40. package/dist/runtime/handoff.js.map +1 -0
  41. package/dist/runtime/messages.d.ts +22 -0
  42. package/dist/runtime/messages.d.ts.map +1 -0
  43. package/dist/runtime/messages.js +204 -0
  44. package/dist/runtime/messages.js.map +1 -0
  45. package/dist/runtime/provider-events.js +117 -0
  46. package/dist/runtime/provider-events.js.map +1 -0
  47. package/dist/runtime/run-compaction.js +210 -0
  48. package/dist/runtime/run-compaction.js.map +1 -0
  49. package/dist/runtime/run-finish.js +48 -0
  50. package/dist/runtime/run-finish.js.map +1 -0
  51. package/dist/runtime/run-gates.js +336 -0
  52. package/dist/runtime/run-gates.js.map +1 -0
  53. package/dist/runtime/run-init.js +81 -0
  54. package/dist/runtime/run-init.js.map +1 -0
  55. package/dist/runtime/run-input.js +46 -0
  56. package/dist/runtime/run-input.js.map +1 -0
  57. package/dist/runtime/step-catalogue.js +173 -0
  58. package/dist/runtime/step-catalogue.js.map +1 -0
  59. package/dist/runtime/tool-call-walk.js +189 -0
  60. package/dist/runtime/tool-call-walk.js.map +1 -0
  61. package/dist/runtime/tool-wiring.js +159 -0
  62. package/dist/runtime/tool-wiring.js.map +1 -0
  63. package/dist/tooling/adapters.js +1 -1
  64. package/dist/tooling/dataflow.js +1 -1
  65. package/dist/tooling/policy.js +2 -0
  66. package/dist/tooling/policy.js.map +1 -1
  67. package/dist/types.d.ts +63 -13
  68. package/dist/types.d.ts.map +1 -1
  69. package/package.json +20 -20
  70. package/src/errors/index.ts +320 -0
  71. package/src/evaluator-optimizer/index.ts +212 -0
  72. package/src/factory.ts +957 -0
  73. package/src/fallback/index.ts +108 -0
  74. package/src/fanout/index.ts +523 -0
  75. package/src/filters/index.ts +347 -0
  76. package/src/index.ts +180 -0
  77. package/src/internal/ids.ts +46 -0
  78. package/src/internal/usage-accumulator.ts +90 -0
  79. package/src/lateral-leak/causality-monitor.ts +221 -0
  80. package/src/lateral-leak/index.ts +35 -0
  81. package/src/lateral-leak/merge-guard.ts +151 -0
  82. package/src/lateral-leak/protocol-guard.ts +222 -0
  83. package/src/preferred-model/index.ts +210 -0
  84. package/src/progress/index.ts +238 -0
  85. package/src/run-state/index.ts +607 -0
  86. package/src/runtime/agent-surface.ts +218 -0
  87. package/src/runtime/agent-to-tool.ts +323 -0
  88. package/src/runtime/approvals.ts +0 -0
  89. package/src/runtime/dispatch.ts +183 -0
  90. package/src/runtime/executor-wiring.ts +331 -0
  91. package/src/runtime/fallback-chain.ts +250 -0
  92. package/src/runtime/handoff.ts +428 -0
  93. package/src/runtime/messages.ts +309 -0
  94. package/src/runtime/provider-events.ts +175 -0
  95. package/src/runtime/run-compaction.ts +288 -0
  96. package/src/runtime/run-finish.ts +93 -0
  97. package/src/runtime/run-gates.ts +419 -0
  98. package/src/runtime/run-init.ts +169 -0
  99. package/src/runtime/run-input.ts +102 -0
  100. package/src/runtime/step-catalogue.ts +338 -0
  101. package/src/runtime/tool-call-walk.ts +301 -0
  102. package/src/runtime/tool-wiring.ts +218 -0
  103. package/src/testing/replay-provider.ts +121 -0
  104. package/src/tooling/adapters.ts +403 -0
  105. package/src/tooling/catalogue.ts +36 -0
  106. package/src/tooling/dataflow.ts +171 -0
  107. package/src/tooling/plan.ts +123 -0
  108. package/src/tooling/policy.ts +67 -0
  109. package/src/tooling/registry-build.ts +191 -0
  110. package/src/types.ts +696 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `CausalityMonitor` - lateral-leak defense primitive that maintains
3
+ * a per-`RunContext` `causalityChain` of bounded depth and refuses
4
+ * to let an assistant message reference information about a denied
5
+ * earlier action.
6
+ *
7
+ * The monitor is opt-in: agents declare `causalityMonitor:
8
+ * { strictness, denialPatterns?, ... }` on `createAgent({...})`.
9
+ * When the field is absent, the runtime instantiates a no-op monitor
10
+ * that records nothing and never flags.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+
15
+ import type { LateralLeakVector } from '@graphorin/core';
16
+
17
+ /**
18
+ * Operator-tunable strictness level. Default `'detect-and-flag'`
19
+ * for cloud-tier providers; `'detect'` for loopback providers;
20
+ * `'off'` for v0.1-alpha backward compatibility.
21
+ *
22
+ * @stable
23
+ */
24
+ export type CausalityMonitorStrictness = 'off' | 'detect' | 'detect-and-flag' | 'detect-and-block';
25
+
26
+ /**
27
+ * Per-agent configuration accepted by `createAgent({ causalityMonitor })`.
28
+ *
29
+ * @stable
30
+ */
31
+ export interface CausalityMonitorConfig {
32
+ readonly strictness: CausalityMonitorStrictness;
33
+ /** Maximum depth of the chain. Default `32`. */
34
+ readonly maxChainDepth?: number;
35
+ /** Operator-extensible denial patterns. */
36
+ readonly denialPatterns?: ReadonlyArray<RegExp>;
37
+ /**
38
+ * When `true`, emit the chain on every `checkMessage(...)` call
39
+ * (high-cardinality; opt-in for compliance audits). Default
40
+ * `false` - only emit on detected leaks.
41
+ */
42
+ readonly auditAllChains?: boolean;
43
+ }
44
+
45
+ /**
46
+ * Default denial-pattern catalogue. The agent runtime extends this
47
+ * list when the operator supplies their own patterns.
48
+ *
49
+ * @stable
50
+ */
51
+ export const DEFAULT_DENIAL_PATTERNS: ReadonlyArray<RegExp> = [
52
+ /SecretAccessDenied/i,
53
+ /ToolApprovalDenied/i,
54
+ /HandoffPermissionDenied/i,
55
+ /SandboxViolation/i,
56
+ ];
57
+
58
+ /** Default chain depth when not specified. */
59
+ export const DEFAULT_MAX_CHAIN_DEPTH = 32;
60
+
61
+ /**
62
+ * Result returned by {@link CausalityMonitor.checkMessage}.
63
+ *
64
+ * @stable
65
+ */
66
+ export interface CausalityMonitorCheck {
67
+ readonly leakDetected: boolean;
68
+ readonly severity: 'info' | 'warn' | 'block';
69
+ readonly causalityChain: ReadonlyArray<string>;
70
+ readonly matchedPattern?: string;
71
+ readonly decision: 'detect' | 'flag' | 'strip' | 'block';
72
+ readonly vector: LateralLeakVector;
73
+ }
74
+
75
+ /**
76
+ * In-memory primitive instantiated per `RunContext`. Bounded-depth
77
+ * append discipline keeps the memory footprint trivial even on long
78
+ * runs.
79
+ *
80
+ * @stable
81
+ */
82
+ export class CausalityMonitor {
83
+ readonly strictness: CausalityMonitorStrictness;
84
+ readonly maxChainDepth: number;
85
+ readonly denialPatterns: ReadonlyArray<RegExp>;
86
+ readonly auditAllChains: boolean;
87
+ #chain: string[] = [];
88
+
89
+ constructor(cfg: CausalityMonitorConfig) {
90
+ this.strictness = cfg.strictness;
91
+ this.maxChainDepth = cfg.maxChainDepth ?? DEFAULT_MAX_CHAIN_DEPTH;
92
+ this.denialPatterns = cfg.denialPatterns
93
+ ? [...DEFAULT_DENIAL_PATTERNS, ...cfg.denialPatterns]
94
+ : DEFAULT_DENIAL_PATTERNS;
95
+ this.auditAllChains = cfg.auditAllChains ?? false;
96
+ }
97
+
98
+ /** Snapshot the current causality chain. */
99
+ get chain(): ReadonlyArray<string> {
100
+ return this.#chain.slice();
101
+ }
102
+
103
+ /**
104
+ * Append an entry to the causality chain, dropping the oldest
105
+ * when the chain exceeds `maxChainDepth`. Bounded-length, no PII,
106
+ * no secret values - entries are short opaque strings like
107
+ * `tool:slack-notify`, `tool.error:SecretAccessDenied`,
108
+ * `subagent:research-east`, `compaction:auto-trigger`.
109
+ */
110
+ recordCall(entry: string): void {
111
+ if (this.strictness === 'off') return;
112
+ if (entry.length === 0) return;
113
+ this.#chain.push(entry);
114
+ if (this.#chain.length > this.maxChainDepth) {
115
+ this.#chain.shift();
116
+ }
117
+ }
118
+
119
+ /** Reset the chain - e.g. on `agent.run` boundary. */
120
+ reset(): void {
121
+ this.#chain = [];
122
+ }
123
+
124
+ /**
125
+ * Inspect a candidate assistant-visible string and return whether
126
+ * the lateral-leak defense should fire. Pure decision based on
127
+ * the current chain + the operator-extensible denial patterns.
128
+ */
129
+ checkMessage(content: string): CausalityMonitorCheck {
130
+ if (this.strictness === 'off') {
131
+ return {
132
+ leakDetected: false,
133
+ severity: 'info',
134
+ causalityChain: [],
135
+ decision: 'detect',
136
+ vector: 'causality-laundering',
137
+ };
138
+ }
139
+ const chainHasDenial = this.#chain.some((entry) =>
140
+ this.denialPatterns.some((pat) => pat.test(entry)),
141
+ );
142
+ if (!chainHasDenial) {
143
+ return {
144
+ leakDetected: false,
145
+ severity: 'info',
146
+ causalityChain: this.chain,
147
+ decision: 'detect',
148
+ vector: 'causality-laundering',
149
+ };
150
+ }
151
+ let matchedPattern: string | undefined;
152
+ for (const pat of this.denialPatterns) {
153
+ if (pat.test(content)) {
154
+ matchedPattern = pat.source;
155
+ break;
156
+ }
157
+ }
158
+ const leakDetected = matchedPattern !== undefined;
159
+ if (!leakDetected) {
160
+ return {
161
+ leakDetected: false,
162
+ severity: 'info',
163
+ causalityChain: this.chain,
164
+ decision: 'detect',
165
+ vector: 'causality-laundering',
166
+ };
167
+ }
168
+ switch (this.strictness) {
169
+ case 'detect':
170
+ return {
171
+ leakDetected: true,
172
+ severity: 'info',
173
+ causalityChain: this.chain,
174
+ ...(matchedPattern !== undefined ? { matchedPattern } : {}),
175
+ decision: 'detect',
176
+ vector: 'causality-laundering',
177
+ };
178
+ case 'detect-and-flag':
179
+ return {
180
+ leakDetected: true,
181
+ severity: 'warn',
182
+ causalityChain: this.chain,
183
+ ...(matchedPattern !== undefined ? { matchedPattern } : {}),
184
+ decision: 'flag',
185
+ vector: 'causality-laundering',
186
+ };
187
+ case 'detect-and-block':
188
+ return {
189
+ leakDetected: true,
190
+ severity: 'block',
191
+ causalityChain: this.chain,
192
+ ...(matchedPattern !== undefined ? { matchedPattern } : {}),
193
+ decision: 'block',
194
+ vector: 'causality-laundering',
195
+ };
196
+ default: {
197
+ const _exhaustive: never = this.strictness;
198
+ void _exhaustive;
199
+ return {
200
+ leakDetected: false,
201
+ severity: 'info',
202
+ causalityChain: this.chain,
203
+ decision: 'detect',
204
+ vector: 'causality-laundering',
205
+ };
206
+ }
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Drain the chain to the audit log on `agent.run` completion or
212
+ * `agent.abort`. The runtime supplies the audit emitter - the
213
+ * primitive itself is storage-agnostic.
214
+ */
215
+ flush(reason: 'agent.run.complete' | 'agent.abort'): {
216
+ readonly chain: ReadonlyArray<string>;
217
+ readonly reason: 'agent.run.complete' | 'agent.abort';
218
+ } {
219
+ return { chain: this.chain, reason };
220
+ }
221
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Lateral-leak defense layer aggregate exports.
3
+ *
4
+ * The three primitives compose orthogonally with the four other
5
+ * security boundaries (sub-agent secrets isolation, handoff input
6
+ * filter, outbound redaction, inbound sanitization).
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ export {
12
+ CausalityMonitor,
13
+ type CausalityMonitorCheck,
14
+ type CausalityMonitorConfig,
15
+ type CausalityMonitorStrictness,
16
+ DEFAULT_DENIAL_PATTERNS,
17
+ DEFAULT_MAX_CHAIN_DEPTH,
18
+ } from './causality-monitor.js';
19
+ export {
20
+ type ChildTrustInput,
21
+ type ContentOriginKind,
22
+ computeSourceTrust,
23
+ evaluateMerge,
24
+ type MergeBiasDecision,
25
+ type MergeGuardConfig,
26
+ type TrustClass,
27
+ } from './merge-guard.js';
28
+ export {
29
+ type GuardOutcome,
30
+ guardOutboundContent,
31
+ type ProtocolBoundary,
32
+ type ProtocolEscapePolicy,
33
+ type ProtocolGuardConfig,
34
+ resolvePolicy,
35
+ } from './protocol-guard.js';
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `MergeAgentSidewaysInjectionGuard` - layered on top of the
3
+ * `Agent.fanOut(...)` `'judge-merge'` strategy. Computes a
4
+ * per-child `sourceTrust` score in `[0.0, 1.0]` and flags merges
5
+ * where a low-trust child's contribution-weight exceeds the
6
+ * configured `maxLowTrustWeight` threshold.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ /**
12
+ * Trust-class baseline used by the guard's `sourceTrust`
13
+ * computation. Mirrors the DEC-149 trust-class taxonomy from
14
+ * `@graphorin/provider`.
15
+ *
16
+ * @stable
17
+ */
18
+ export type TrustClass = 'loopback' | 'public-tls' | 'public-mtls' | 'untrusted-skill';
19
+
20
+ const TRUST_CLASS_BASELINE: Record<TrustClass, number> = {
21
+ loopback: 0.9,
22
+ 'public-tls': 0.7,
23
+ 'public-mtls': 0.8,
24
+ 'untrusted-skill': 0.3,
25
+ };
26
+
27
+ /**
28
+ * Tool-source provenance multiplier. Mirrors the `ContentOrigin`
29
+ * annotation from `@graphorin/memory.context-engine`.
30
+ *
31
+ * @stable
32
+ */
33
+ export type ContentOriginKind =
34
+ | 'built-in'
35
+ | 'user-defined'
36
+ | 'trusted-skill'
37
+ | 'untrusted-skill'
38
+ | 'mcp'
39
+ | 'web-search';
40
+
41
+ const ORIGIN_MULTIPLIER: Record<ContentOriginKind, number> = {
42
+ 'built-in': 1.0,
43
+ 'user-defined': 0.9,
44
+ 'trusted-skill': 0.85,
45
+ 'untrusted-skill': 0.4,
46
+ mcp: 0.7,
47
+ 'web-search': 0.5,
48
+ };
49
+
50
+ /**
51
+ * Per-agent guard configuration accepted by
52
+ * `createAgent({ mergeGuard })`.
53
+ *
54
+ * @stable
55
+ */
56
+ export interface MergeGuardConfig {
57
+ readonly strictness: 'off' | 'detect' | 'detect-and-flag' | 'detect-and-block';
58
+ /** Default `0.3`. */
59
+ readonly maxLowTrustWeight?: number;
60
+ /** Default `0.5`. */
61
+ readonly lowTrustThreshold?: number;
62
+ /** Operator overrides for known agent ids. */
63
+ readonly sourceTrustOverrides?: Readonly<Record<string, number>>;
64
+ }
65
+
66
+ /**
67
+ * Per-child input descriptor for {@link computeSourceTrust}.
68
+ *
69
+ * @stable
70
+ */
71
+ export interface ChildTrustInput {
72
+ readonly agentId: string;
73
+ readonly trustClass: TrustClass;
74
+ readonly origin: ContentOriginKind;
75
+ /** Rolling trust score in `[0.0, 1.0]`. Defaults to `1.0`. */
76
+ readonly historyAdjustment?: number;
77
+ }
78
+
79
+ /**
80
+ * Compose `baseline * provenance * historyAdjustment` and clamp.
81
+ *
82
+ * @stable
83
+ */
84
+ export function computeSourceTrust(
85
+ input: ChildTrustInput,
86
+ overrides: Readonly<Record<string, number>> = {},
87
+ ): number {
88
+ const override = overrides[input.agentId];
89
+ if (override !== undefined) return Math.max(0, Math.min(1, override));
90
+ const baseline = TRUST_CLASS_BASELINE[input.trustClass];
91
+ const provenance = ORIGIN_MULTIPLIER[input.origin];
92
+ const history = input.historyAdjustment ?? 1.0;
93
+ return Math.max(0, Math.min(1, baseline * provenance * history));
94
+ }
95
+
96
+ /**
97
+ * Pure decision returned by {@link evaluateMerge}.
98
+ *
99
+ * @stable
100
+ */
101
+ export interface MergeBiasDecision {
102
+ readonly biased: boolean;
103
+ readonly offendingChild?: string;
104
+ readonly contributionWeight?: number;
105
+ readonly sourceTrust?: number;
106
+ readonly decision: 'pass-through' | 'flag' | 'block';
107
+ }
108
+
109
+ /**
110
+ * Evaluate whether the merge is biased - a child with
111
+ * `sourceTrust < lowTrustThreshold` contributing more than
112
+ * `maxLowTrustWeight` of the merged output.
113
+ *
114
+ * Inputs are pre-computed per-child trust scores together with the
115
+ * estimated contribution weights (token-count overlap between each
116
+ * child's output and the merged output, normalized to sum to ~1.0).
117
+ *
118
+ * @stable
119
+ */
120
+ export function evaluateMerge(
121
+ perChild: ReadonlyArray<{
122
+ readonly agentId: string;
123
+ readonly sourceTrust: number;
124
+ readonly contributionWeight: number;
125
+ }>,
126
+ cfg: MergeGuardConfig,
127
+ ): MergeBiasDecision {
128
+ if (cfg.strictness === 'off') {
129
+ return { biased: false, decision: 'pass-through' };
130
+ }
131
+ const lowTrustThreshold = cfg.lowTrustThreshold ?? 0.5;
132
+ const maxLowTrustWeight = cfg.maxLowTrustWeight ?? 0.3;
133
+ for (const child of perChild) {
134
+ if (child.sourceTrust < lowTrustThreshold && child.contributionWeight > maxLowTrustWeight) {
135
+ const decision: MergeBiasDecision['decision'] =
136
+ cfg.strictness === 'detect-and-block'
137
+ ? 'block'
138
+ : cfg.strictness === 'detect-and-flag'
139
+ ? 'flag'
140
+ : 'pass-through';
141
+ return {
142
+ biased: true,
143
+ offendingChild: child.agentId,
144
+ contributionWeight: child.contributionWeight,
145
+ sourceTrust: child.sourceTrust,
146
+ decision,
147
+ };
148
+ }
149
+ }
150
+ return { biased: false, decision: 'pass-through' };
151
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Protocol/header injection guard - escapes control characters in
3
+ * tool result bodies before they cross internal-service delivery
4
+ * boundaries (SSE, WebSocket, REST body, HTTP header, audit row).
5
+ *
6
+ * The default policy mirrors the deployment-posture matrix from the
7
+ * lateral-leak design (DEC-171 / suggested ADR-059):
8
+ *
9
+ * - `'sse' | 'http-header'` → `'strict'` (escape control chars to
10
+ * `\xNN` hex literals; the safest default for cleartext frame
11
+ * protocols).
12
+ * - `'ws' | 'rest-body'` → `'replace'` (replace with the Unicode
13
+ * replacement character `\uFFFD`).
14
+ * - `'audit'` → `'strict'` (regulated deployments).
15
+ * - `'reject'` is operator opt-in - the guard throws
16
+ * {@link ProtocolInjectionRejectError} when control characters are
17
+ * detected.
18
+ *
19
+ * @packageDocumentation
20
+ */
21
+
22
+ import { ProtocolInjectionRejectError } from '../errors/index.js';
23
+
24
+ /**
25
+ * Per-boundary identifier used by the runtime when calling the
26
+ * guard.
27
+ *
28
+ * @stable
29
+ */
30
+ export type ProtocolBoundary = 'sse' | 'http-header' | 'ws' | 'rest-body' | 'audit';
31
+
32
+ /**
33
+ * Per-boundary escape policy.
34
+ *
35
+ * @stable
36
+ */
37
+ export type ProtocolEscapePolicy = 'strict' | 'replace' | 'reject';
38
+
39
+ /**
40
+ * Configurable per-boundary policy table. Operators may override
41
+ * specific boundaries via `Agent.protocolGuard?: { ... }`.
42
+ *
43
+ * @stable
44
+ */
45
+ export interface ProtocolGuardConfig {
46
+ readonly sse?: ProtocolEscapePolicy;
47
+ readonly httpHeader?: ProtocolEscapePolicy;
48
+ readonly ws?: ProtocolEscapePolicy;
49
+ readonly restBody?: ProtocolEscapePolicy;
50
+ readonly audit?: ProtocolEscapePolicy;
51
+ }
52
+
53
+ /**
54
+ * Resolved policy lookup. Pure function - no side effects.
55
+ *
56
+ * @stable
57
+ */
58
+ export function resolvePolicy(
59
+ boundary: ProtocolBoundary,
60
+ cfg: ProtocolGuardConfig = {},
61
+ ): ProtocolEscapePolicy {
62
+ switch (boundary) {
63
+ case 'sse':
64
+ return cfg.sse ?? 'strict';
65
+ case 'http-header':
66
+ return cfg.httpHeader ?? 'strict';
67
+ case 'ws':
68
+ return cfg.ws ?? 'replace';
69
+ case 'rest-body':
70
+ return cfg.restBody ?? 'replace';
71
+ case 'audit':
72
+ return cfg.audit ?? 'strict';
73
+ default: {
74
+ const _exhaustive: never = boundary;
75
+ void _exhaustive;
76
+ return 'strict';
77
+ }
78
+ }
79
+ }
80
+
81
+ // Control-character catalogue used to detect injection attempts at
82
+ // the framework's outbound delivery boundaries. Building the regex
83
+ // programmatically avoids embedding control bytes in the source -
84
+ // the bytes are exactly the ones a malicious tool result might
85
+ // inject to escape framing on cleartext protocols.
86
+ function buildControlCharsRegex(): RegExp {
87
+ const codePoints: number[] = [];
88
+ for (let n = 0; n < 0x20; n++) {
89
+ if (n === 0x09 || n === 0x0a || n === 0x0d) continue;
90
+ codePoints.push(n);
91
+ }
92
+ codePoints.push(0x7f);
93
+ const cls = codePoints.map((c) => `\\x${c.toString(16).padStart(2, '0')}`).join('');
94
+ return new RegExp(`[${cls}]`, 'g');
95
+ }
96
+ const CONTROL_CHARS_RE = buildControlCharsRegex();
97
+ const SSE_FRAME_RE = /\r?\n\r?\n/g;
98
+ const HEADER_CRLF_RE = /\r\n/g;
99
+
100
+ /**
101
+ * Outcome of {@link guardOutboundContent}.
102
+ *
103
+ * @stable
104
+ */
105
+ export interface GuardOutcome {
106
+ readonly content: string;
107
+ readonly escapedCharCount: number;
108
+ readonly matchedPattern?: string;
109
+ readonly decision: 'pass-through' | 'escaped' | 'replaced' | 'rejected';
110
+ readonly boundary: ProtocolBoundary;
111
+ readonly policy: ProtocolEscapePolicy;
112
+ }
113
+
114
+ function escapeStrict(
115
+ input: string,
116
+ boundary: ProtocolBoundary,
117
+ ): { content: string; count: number } {
118
+ let count = 0;
119
+ let out = input.replace(CONTROL_CHARS_RE, (ch) => {
120
+ count += 1;
121
+ return `\\x${ch.charCodeAt(0).toString(16).padStart(2, '0')}`;
122
+ });
123
+ if (boundary === 'sse') {
124
+ out = out.replace(SSE_FRAME_RE, (m) => {
125
+ count += 1;
126
+ return m.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
127
+ });
128
+ }
129
+ if (boundary === 'http-header' || boundary === 'audit') {
130
+ out = out.replace(HEADER_CRLF_RE, () => {
131
+ count += 1;
132
+ return '\\r\\n';
133
+ });
134
+ }
135
+ return { content: out, count };
136
+ }
137
+
138
+ function replaceUFFFD(input: string): { content: string; count: number } {
139
+ let count = 0;
140
+ const out = input.replace(CONTROL_CHARS_RE, () => {
141
+ count += 1;
142
+ return '\uFFFD';
143
+ });
144
+ return { content: out, count };
145
+ }
146
+
147
+ function findMatchedPattern(input: string): string | undefined {
148
+ if (CONTROL_CHARS_RE.test(input)) {
149
+ CONTROL_CHARS_RE.lastIndex = 0;
150
+ return 'control-char';
151
+ }
152
+ if (SSE_FRAME_RE.test(input)) {
153
+ SSE_FRAME_RE.lastIndex = 0;
154
+ return 'sse-frame';
155
+ }
156
+ if (HEADER_CRLF_RE.test(input)) {
157
+ HEADER_CRLF_RE.lastIndex = 0;
158
+ return 'header-crlf';
159
+ }
160
+ return undefined;
161
+ }
162
+
163
+ /**
164
+ * Apply the configured escape policy to a single string body. Pure
165
+ * - never mutates inputs.
166
+ *
167
+ * @stable
168
+ */
169
+ export function guardOutboundContent(
170
+ input: string,
171
+ boundary: ProtocolBoundary,
172
+ cfg: ProtocolGuardConfig = {},
173
+ ): GuardOutcome {
174
+ const policy = resolvePolicy(boundary, cfg);
175
+ const matchedPattern = findMatchedPattern(input);
176
+ if (matchedPattern === undefined) {
177
+ return {
178
+ content: input,
179
+ escapedCharCount: 0,
180
+ decision: 'pass-through',
181
+ boundary,
182
+ policy,
183
+ };
184
+ }
185
+ switch (policy) {
186
+ case 'strict': {
187
+ const r = escapeStrict(input, boundary);
188
+ return {
189
+ content: r.content,
190
+ escapedCharCount: r.count,
191
+ matchedPattern,
192
+ decision: 'escaped',
193
+ boundary,
194
+ policy,
195
+ };
196
+ }
197
+ case 'replace': {
198
+ const r = replaceUFFFD(input);
199
+ return {
200
+ content: r.content,
201
+ escapedCharCount: r.count,
202
+ matchedPattern,
203
+ decision: 'replaced',
204
+ boundary,
205
+ policy,
206
+ };
207
+ }
208
+ case 'reject':
209
+ throw new ProtocolInjectionRejectError(boundary, matchedPattern);
210
+ default: {
211
+ const _exhaustive: never = policy;
212
+ void _exhaustive;
213
+ return {
214
+ content: input,
215
+ escapedCharCount: 0,
216
+ decision: 'pass-through',
217
+ boundary,
218
+ policy,
219
+ };
220
+ }
221
+ }
222
+ }