palaryn 0.5.11 → 0.6.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/src/billing/plan-enforcer.d.ts.map +1 -1
- package/dist/src/billing/plan-enforcer.js +0 -2
- package/dist/src/billing/plan-enforcer.js.map +1 -1
- package/dist/src/dlp/circuit-breaker.d.ts +44 -0
- package/dist/src/dlp/circuit-breaker.d.ts.map +1 -0
- package/dist/src/dlp/circuit-breaker.js +69 -0
- package/dist/src/dlp/circuit-breaker.js.map +1 -0
- package/dist/src/dlp/deberta-backend.d.ts +2 -0
- package/dist/src/dlp/deberta-backend.d.ts.map +1 -1
- package/dist/src/dlp/deberta-backend.js +21 -3
- package/dist/src/dlp/deberta-backend.js.map +1 -1
- package/dist/src/dlp/index.d.ts +2 -0
- package/dist/src/dlp/index.d.ts.map +1 -1
- package/dist/src/dlp/index.js +5 -1
- package/dist/src/dlp/index.js.map +1 -1
- package/dist/src/dlp/llm-classifier.d.ts +2 -0
- package/dist/src/dlp/llm-classifier.d.ts.map +1 -1
- package/dist/src/dlp/llm-classifier.js +8 -0
- package/dist/src/dlp/llm-classifier.js.map +1 -1
- package/dist/src/dlp/multipart-extractor.d.ts +20 -0
- package/dist/src/dlp/multipart-extractor.d.ts.map +1 -0
- package/dist/src/dlp/multipart-extractor.js +60 -0
- package/dist/src/dlp/multipart-extractor.js.map +1 -0
- package/dist/src/dlp/navigation-instruction-backend.d.ts +6 -0
- package/dist/src/dlp/navigation-instruction-backend.d.ts.map +1 -0
- package/dist/src/dlp/navigation-instruction-backend.js +286 -0
- package/dist/src/dlp/navigation-instruction-backend.js.map +1 -0
- package/dist/src/dlp/nemo-backend.d.ts +2 -0
- package/dist/src/dlp/nemo-backend.d.ts.map +1 -1
- package/dist/src/dlp/nemo-backend.js +8 -0
- package/dist/src/dlp/nemo-backend.js.map +1 -1
- package/dist/src/dlp/prompt-injection-patterns.d.ts.map +1 -1
- package/dist/src/dlp/prompt-injection-patterns.js +36 -0
- package/dist/src/dlp/prompt-injection-patterns.js.map +1 -1
- package/dist/src/dlp/text-normalizer.d.ts +2 -15
- package/dist/src/dlp/text-normalizer.d.ts.map +1 -1
- package/dist/src/dlp/text-normalizer.js +34 -7
- package/dist/src/dlp/text-normalizer.js.map +1 -1
- package/dist/src/dlp/tool-patterns.d.ts +10 -0
- package/dist/src/dlp/tool-patterns.d.ts.map +1 -1
- package/dist/src/dlp/tool-patterns.js +40 -1
- package/dist/src/dlp/tool-patterns.js.map +1 -1
- package/dist/src/executor/filesystem-executor.d.ts +5 -5
- package/dist/src/executor/filesystem-executor.d.ts.map +1 -1
- package/dist/src/executor/filesystem-executor.js +43 -0
- package/dist/src/executor/filesystem-executor.js.map +1 -1
- package/dist/src/metrics/collector.d.ts +5 -0
- package/dist/src/metrics/collector.d.ts.map +1 -1
- package/dist/src/metrics/collector.js +14 -0
- package/dist/src/metrics/collector.js.map +1 -1
- package/dist/src/policy/engine.d.ts.map +1 -1
- package/dist/src/policy/engine.js +39 -3
- package/dist/src/policy/engine.js.map +1 -1
- package/dist/src/policy/opa-engine.d.ts.map +1 -1
- package/dist/src/policy/opa-engine.js +2 -1
- package/dist/src/policy/opa-engine.js.map +1 -1
- package/dist/src/server/app.d.ts.map +1 -1
- package/dist/src/server/app.js +17 -9
- package/dist/src/server/app.js.map +1 -1
- package/dist/src/server/gateway.d.ts +4 -0
- package/dist/src/server/gateway.d.ts.map +1 -1
- package/dist/src/server/gateway.js +146 -4
- package/dist/src/server/gateway.js.map +1 -1
- package/dist/src/types/config.d.ts +9 -0
- package/dist/src/types/config.d.ts.map +1 -1
- package/dist/src/types/policy.d.ts +4 -0
- package/dist/src/types/policy.d.ts.map +1 -1
- package/dist/src/types/tool-call.d.ts +4 -0
- package/dist/src/types/tool-call.d.ts.map +1 -1
- package/dist/tests/integration/navigation-chain.test.d.ts +9 -0
- package/dist/tests/integration/navigation-chain.test.d.ts.map +1 -0
- package/dist/tests/integration/navigation-chain.test.js +474 -0
- package/dist/tests/integration/navigation-chain.test.js.map +1 -0
- package/dist/tests/unit/adversarial-pipeline.test.js +173 -15
- package/dist/tests/unit/adversarial-pipeline.test.js.map +1 -1
- package/dist/tests/unit/cli.test.js +3 -7
- package/dist/tests/unit/cli.test.js.map +1 -1
- package/dist/tests/unit/filesystem-executor.test.js +88 -0
- package/dist/tests/unit/filesystem-executor.test.js.map +1 -1
- package/dist/tests/unit/multipart-extractor.test.d.ts +2 -0
- package/dist/tests/unit/multipart-extractor.test.d.ts.map +1 -0
- package/dist/tests/unit/multipart-extractor.test.js +118 -0
- package/dist/tests/unit/multipart-extractor.test.js.map +1 -0
- package/dist/tests/unit/navigation-instruction-backend.test.d.ts +8 -0
- package/dist/tests/unit/navigation-instruction-backend.test.d.ts.map +1 -0
- package/dist/tests/unit/navigation-instruction-backend.test.js +561 -0
- package/dist/tests/unit/navigation-instruction-backend.test.js.map +1 -0
- package/dist/tests/unit/policy-engine.test.js +314 -1
- package/dist/tests/unit/policy-engine.test.js.map +1 -1
- package/dist/tests/unit/prompt-injection-backend.test.js +1 -1
- package/dist/tests/unit/prompt-injection-backend.test.js.map +1 -1
- package/package.json +1 -1
- package/policy-packs/default.yaml +76 -0
- package/src/billing/plan-enforcer.ts +0 -2
- package/src/dlp/circuit-breaker.ts +83 -0
- package/src/dlp/deberta-backend.ts +21 -3
- package/src/dlp/index.ts +2 -0
- package/src/dlp/llm-classifier.ts +10 -0
- package/src/dlp/multipart-extractor.ts +66 -0
- package/src/dlp/navigation-instruction-backend.ts +309 -0
- package/src/dlp/nemo-backend.ts +10 -0
- package/src/dlp/prompt-injection-patterns.ts +37 -0
- package/src/dlp/text-normalizer.ts +36 -7
- package/src/dlp/tool-patterns.ts +40 -0
- package/src/executor/filesystem-executor.ts +51 -0
- package/src/metrics/collector.ts +17 -0
- package/src/policy/engine.ts +39 -3
- package/src/policy/opa-engine.ts +2 -1
- package/src/server/app.ts +19 -10
- package/src/server/gateway.ts +155 -4
- package/src/types/config.ts +9 -0
- package/src/types/policy.ts +5 -0
- package/src/types/tool-call.ts +4 -0
package/src/policy/engine.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as net from 'net';
|
|
3
3
|
import * as yaml from 'js-yaml';
|
|
4
|
+
import { logger } from '../server/logger';
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
6
|
+
const punycode = require('punycode/');
|
|
4
7
|
import { ToolCall } from '../types/tool-call';
|
|
5
8
|
import {
|
|
6
9
|
PolicyDecision,
|
|
@@ -353,13 +356,13 @@ export class PolicyEngine {
|
|
|
353
356
|
}
|
|
354
357
|
// ReDoS protection: block patterns with nested quantifiers or excessive length
|
|
355
358
|
if (!PolicyEngine.isSafeRegex(pattern)) {
|
|
356
|
-
|
|
359
|
+
logger.warn('Rejecting potentially unsafe regex pattern (ReDoS risk)', { component: 'policy-engine', pattern: pattern.slice(0, 100) });
|
|
357
360
|
return null;
|
|
358
361
|
}
|
|
359
362
|
try {
|
|
360
363
|
re = new RegExp(pattern);
|
|
361
364
|
} catch (err) {
|
|
362
|
-
|
|
365
|
+
logger.warn('Invalid regex pattern', { component: 'policy-engine', pattern, error: err instanceof Error ? err.message : String(err) });
|
|
363
366
|
return null;
|
|
364
367
|
}
|
|
365
368
|
// Evict oldest entry if at capacity
|
|
@@ -775,6 +778,36 @@ export class PolicyEngine {
|
|
|
775
778
|
if (!conditions.provider_tool_types.includes(toolType as string)) return false;
|
|
776
779
|
}
|
|
777
780
|
|
|
781
|
+
// --- Referrer/provenance conditions ---
|
|
782
|
+
if (conditions.has_referrer !== undefined) {
|
|
783
|
+
const hasReferrer = !!toolCall.context?.referrer_url;
|
|
784
|
+
if (conditions.has_referrer !== hasReferrer) {
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (conditions.referrer_domains && conditions.referrer_domains.length > 0) {
|
|
790
|
+
const referrerUrl = toolCall.context?.referrer_url;
|
|
791
|
+
if (!referrerUrl) {
|
|
792
|
+
// Rule requires specific referrer domain but no referrer is present — no match.
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
795
|
+
const referrerDomain = this.extractDomain(referrerUrl);
|
|
796
|
+
if (!referrerDomain || !this.isDomainInList(referrerDomain, conditions.referrer_domains)) {
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (conditions.referrer_domains_blocklist && conditions.referrer_domains_blocklist.length > 0) {
|
|
802
|
+
const referrerUrl = toolCall.context?.referrer_url;
|
|
803
|
+
if (referrerUrl) {
|
|
804
|
+
const referrerDomain = this.extractDomain(referrerUrl);
|
|
805
|
+
if (referrerDomain && this.isDomainInList(referrerDomain, conditions.referrer_domains_blocklist)) {
|
|
806
|
+
return false;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
778
811
|
// --- DLP conditions (only evaluated when dlpContext is provided) ---
|
|
779
812
|
if (conditions.dlp_detected !== undefined) {
|
|
780
813
|
if (!dlpContext) return false; // DLP conditions require DLP context
|
|
@@ -812,7 +845,10 @@ export class PolicyEngine {
|
|
|
812
845
|
private extractDomain(url: string): string | null {
|
|
813
846
|
try {
|
|
814
847
|
const parsed = new URL(url);
|
|
815
|
-
|
|
848
|
+
let hostname = parsed.hostname.toLowerCase();
|
|
849
|
+
// Decode Punycode (xn--) domains to Unicode for consistent matching
|
|
850
|
+
try { hostname = punycode.toUnicode(hostname); } catch { /* leave as-is */ }
|
|
851
|
+
return hostname;
|
|
816
852
|
} catch {
|
|
817
853
|
return null;
|
|
818
854
|
}
|
package/src/policy/opa-engine.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as https from 'https';
|
|
|
3
3
|
import { URL } from 'url';
|
|
4
4
|
import { ToolCall } from '../types/tool-call';
|
|
5
5
|
import { PolicyDecision, PolicyEvalResult, PolicyTransformation } from '../types/policy';
|
|
6
|
+
import { logger } from '../server/logger';
|
|
6
7
|
import { OPAConfig } from '../types/config';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -79,7 +80,7 @@ export class OPAEngine {
|
|
|
79
80
|
throw new Error(`OPA server URL must not use private/reserved IP address in production: "${hostname}"`);
|
|
80
81
|
}
|
|
81
82
|
// In development, allow but warn
|
|
82
|
-
|
|
83
|
+
logger.warn('OPA server URL points to private address — blocked in production', { component: 'opa-engine', hostname });
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
// Normalize: remove trailing slashes for consistency
|
package/src/server/app.ts
CHANGED
|
@@ -43,7 +43,7 @@ import { StripeClient, WebhookHandler, createWebhookRouter, createBillingRouter,
|
|
|
43
43
|
import { createPlanEnforcerMiddleware } from '../billing/plan-enforcer';
|
|
44
44
|
import { FilePersistedStores } from '../storage/file-persistence';
|
|
45
45
|
import { hashPassword } from '../auth/password';
|
|
46
|
-
import { log as devLog } from './logger';
|
|
46
|
+
import { log as devLog, logger } from './logger';
|
|
47
47
|
|
|
48
48
|
const MAX_TRACKED_IPS = 10000;
|
|
49
49
|
|
|
@@ -262,6 +262,12 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
|
|
|
262
262
|
gateway.setStores({ policyStore: saasStores.policyStore });
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
// Inject workspace store for plan enforcement inside the gateway pipeline
|
|
266
|
+
// (covers MCP bridge and proxy entry points, not just Express middleware)
|
|
267
|
+
if (saasStores.workspaceStore) {
|
|
268
|
+
gateway.setStores({ workspaceStore: saasStores.workspaceStore });
|
|
269
|
+
}
|
|
270
|
+
|
|
265
271
|
// Register noop executors for non-HTTP tools (e.g. pre-flight validation from Android)
|
|
266
272
|
gateway.registerExecutor('web_search', new NoopExecutor({ body: { validated: true, tool: 'web_search' } }));
|
|
267
273
|
gateway.registerExecutor('chat.completion', new NoopExecutor({ body: { validated: true, tool: 'chat.completion' } }));
|
|
@@ -500,10 +506,13 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
|
|
|
500
506
|
const rbacPolicyWrite = createRBACMiddleware(config.auth, 'policy:write');
|
|
501
507
|
|
|
502
508
|
// Admin routes (require auth + admin:full permission)
|
|
503
|
-
//
|
|
504
|
-
|
|
509
|
+
// Deprecated: use React SPA frontend instead (FRONTEND_ENABLED=true).
|
|
510
|
+
// Server-rendered admin is kept as fallback when frontend is disabled.
|
|
505
511
|
const rbacAdmin = createRBACMiddleware(config.auth, 'admin:full');
|
|
506
|
-
|
|
512
|
+
if (!config.frontend?.enabled) {
|
|
513
|
+
const adminRouter = createAdminRouter(gateway, config);
|
|
514
|
+
app.use('/admin', authMiddleware, rbacAdmin, adminRouter);
|
|
515
|
+
}
|
|
507
516
|
|
|
508
517
|
// Plan enforcer middleware — blocks calls when workspace exceeds plan limits
|
|
509
518
|
const planEnforcerMiddleware = createPlanEnforcerMiddleware({
|
|
@@ -568,13 +577,13 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
|
|
|
568
577
|
}
|
|
569
578
|
// S3: Never leak raw error messages to clients (may contain internal paths, IPs, secrets)
|
|
570
579
|
if (result.error) {
|
|
571
|
-
|
|
580
|
+
logger.error('Gateway error', { component: 'tool-execute', error: result.error });
|
|
572
581
|
result.error = 'Tool execution failed';
|
|
573
582
|
}
|
|
574
583
|
res.status(httpStatus).json(result);
|
|
575
584
|
} catch (err) {
|
|
576
585
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
577
|
-
|
|
586
|
+
logger.error('Execution error', { component: 'tool-execute', error: errorMessage });
|
|
578
587
|
sendError(res, 500, ErrorCode.TOOL_EXECUTION_ERROR, 'Tool execution failed');
|
|
579
588
|
}
|
|
580
589
|
});
|
|
@@ -635,13 +644,13 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
|
|
|
635
644
|
headers: proxyResult.headers,
|
|
636
645
|
}, pre);
|
|
637
646
|
} catch (postErr) {
|
|
638
|
-
|
|
647
|
+
logger.error('Post-execute failed', { component: 'stream-proxy', error: postErr instanceof Error ? postErr.message : postErr });
|
|
639
648
|
// Don't fail the response — it's already sent for streaming
|
|
640
649
|
}
|
|
641
650
|
|
|
642
651
|
} catch (err) {
|
|
643
652
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
644
|
-
|
|
653
|
+
logger.error('Execution error', { component: 'stream-proxy', error: errorMessage });
|
|
645
654
|
if (!res.headersSent) {
|
|
646
655
|
sendError(res, 502, ErrorCode.TOOL_EXECUTION_ERROR, 'Stream proxy failed');
|
|
647
656
|
} else {
|
|
@@ -684,7 +693,7 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
|
|
|
684
693
|
}
|
|
685
694
|
} catch (err) {
|
|
686
695
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
687
|
-
|
|
696
|
+
logger.error('Approval error', { component: 'tool-approve', error: errorMessage });
|
|
688
697
|
sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Approval processing failed');
|
|
689
698
|
}
|
|
690
699
|
});
|
|
@@ -751,7 +760,7 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
|
|
|
751
760
|
res.json({ status: 'ok' });
|
|
752
761
|
} catch (err) {
|
|
753
762
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
754
|
-
|
|
763
|
+
logger.error('Usage report error', { component: 'usage-report', error: errorMessage });
|
|
755
764
|
sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Usage reporting failed');
|
|
756
765
|
}
|
|
757
766
|
});
|
package/src/server/gateway.ts
CHANGED
|
@@ -14,6 +14,9 @@ import { HeuristicScorerBackend } from '../dlp/heuristic-scorer';
|
|
|
14
14
|
import { scorePromptInjection } from '../dlp/heuristic-scorer';
|
|
15
15
|
import { TruffleHogBackend } from '../dlp/trufflehog-backend';
|
|
16
16
|
import { ExfiltrationDetectionBackend } from '../dlp/exfiltration-backend';
|
|
17
|
+
import { NavigationInstructionBackend } from '../dlp/navigation-instruction-backend';
|
|
18
|
+
import { SensitiveFileBackend } from '../dlp/tool-patterns';
|
|
19
|
+
import { extractMultipartTextParts } from '../dlp/multipart-extractor';
|
|
17
20
|
import { NemoGuardrailsBackend } from '../dlp/nemo-backend';
|
|
18
21
|
import { DeBERTaBackend } from '../dlp/deberta-backend';
|
|
19
22
|
import { BudgetManager, CostRecord } from '../budget/manager';
|
|
@@ -37,6 +40,9 @@ import { GatewayMetrics } from '../metrics';
|
|
|
37
40
|
import { GatewayTracer } from '../tracing';
|
|
38
41
|
import { AnomalyDetector } from '../anomaly';
|
|
39
42
|
import { LlmPromptInjectionClassifier } from '../dlp/llm-classifier';
|
|
43
|
+
import { PlanEnforcer } from '../billing/plan-enforcer';
|
|
44
|
+
import { WorkspaceStore } from '../storage/interfaces';
|
|
45
|
+
import { PlanTier } from '../types/subscription';
|
|
40
46
|
import { log as devLog, logger } from './logger';
|
|
41
47
|
|
|
42
48
|
export interface PreExecuteResult {
|
|
@@ -169,6 +175,7 @@ export class Gateway {
|
|
|
169
175
|
* the in-flight result instead of executing a second time.
|
|
170
176
|
*/
|
|
171
177
|
private inFlightCalls = new Map<string, InFlightEntry>();
|
|
178
|
+
private workspaceStore?: WorkspaceStore;
|
|
172
179
|
|
|
173
180
|
constructor(config: GatewayConfig, metrics?: GatewayMetrics, tracer?: GatewayTracer) {
|
|
174
181
|
this.config = config;
|
|
@@ -183,6 +190,8 @@ export class Gateway {
|
|
|
183
190
|
}));
|
|
184
191
|
dlpBackends.push(new HeuristicScorerBackend());
|
|
185
192
|
dlpBackends.push(new ExfiltrationDetectionBackend());
|
|
193
|
+
dlpBackends.push(new NavigationInstructionBackend());
|
|
194
|
+
dlpBackends.push(new SensitiveFileBackend());
|
|
186
195
|
}
|
|
187
196
|
if (config.dlp.deberta?.enabled) {
|
|
188
197
|
dlpBackends.push(new DeBERTaBackend({
|
|
@@ -263,7 +272,7 @@ export class Gateway {
|
|
|
263
272
|
// A1: Periodic cleanup of stale inFlightCalls entries (every 60s, remove entries older than 5min)
|
|
264
273
|
this.inFlightCleanupInterval = setInterval(() => {
|
|
265
274
|
const now = Date.now();
|
|
266
|
-
const MAX_AGE_MS =
|
|
275
|
+
const MAX_AGE_MS = this.config.idempotency_cache_ttl_ms ?? 300_000;
|
|
267
276
|
for (const [key, entry] of this.inFlightCalls) {
|
|
268
277
|
if (now - entry.createdAt > MAX_AGE_MS) {
|
|
269
278
|
clearTimeout(entry.timeout);
|
|
@@ -368,6 +377,38 @@ export class Gateway {
|
|
|
368
377
|
}
|
|
369
378
|
devLog.anomaly(anomalyAlerts.length, false);
|
|
370
379
|
|
|
380
|
+
// Plan enforcement — block calls when workspace exceeds plan limits.
|
|
381
|
+
// Covers ALL entry points (REST, MCP bridge, MCP HTTP, proxy).
|
|
382
|
+
if (this.workspaceStore && toolCall.workspace_id) {
|
|
383
|
+
stepStart = Date.now();
|
|
384
|
+
const workspace = this.workspaceStore.getById(toolCall.workspace_id);
|
|
385
|
+
if (workspace) {
|
|
386
|
+
const plan = (workspace.plan || 'free') as PlanTier;
|
|
387
|
+
const now = new Date();
|
|
388
|
+
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
|
389
|
+
const events = this.auditLogger.getAllEvents();
|
|
390
|
+
const monthlyCount = events.filter(
|
|
391
|
+
e => e.workspace_id === toolCall.workspace_id
|
|
392
|
+
&& e.event_type === 'TOOL_CALL_RECEIVED'
|
|
393
|
+
&& e.timestamp >= monthStart
|
|
394
|
+
).length;
|
|
395
|
+
const planResult = PlanEnforcer.enforce(plan, monthlyCount);
|
|
396
|
+
if (!planResult.allowed) {
|
|
397
|
+
const durationSec = (Date.now() - startTime) / 1000;
|
|
398
|
+
this.metrics?.recordRequest('blocked', toolCall.tool.name, toolCall.tool.capability, durationSec);
|
|
399
|
+
const result = this.buildResult(toolCall, 'blocked', {
|
|
400
|
+
decision: 'deny',
|
|
401
|
+
rule_id: 'plan_limit',
|
|
402
|
+
rule_name: 'Plan limit',
|
|
403
|
+
reasons: [planResult.reason || 'Plan limit exceeded'],
|
|
404
|
+
}, startTime, undefined, planResult.reason);
|
|
405
|
+
devLog.pipelineEnd('blocked', Date.now() - startTime);
|
|
406
|
+
return { allowed: false, result, stepTimings, startTime };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
stepTimings.plan_enforcement = Date.now() - stepStart;
|
|
410
|
+
}
|
|
411
|
+
|
|
371
412
|
// DLP scan on full toolCall (args + context + actor fields)
|
|
372
413
|
// Runs BEFORE policy evaluation so secrets are always detected regardless of policy outcome
|
|
373
414
|
stepStart = Date.now();
|
|
@@ -628,6 +669,8 @@ export class Gateway {
|
|
|
628
669
|
stepTimings.policy = Date.now() - stepStart;
|
|
629
670
|
this.auditLogger.logPolicyDecided(toolCall, policyResult.decision, policyResult.rule_id, policyResult.reasons);
|
|
630
671
|
this.metrics?.recordPolicyDecision(policyResult.decision, policyResult.rule_id || 'unknown');
|
|
672
|
+
const policyEngine_ = usedWorkspacePolicy ? 'workspace' : (this.opaEngine ? 'opa' : 'yaml');
|
|
673
|
+
this.metrics?.recordPolicyEvaluation(policyEngine_, stepTimings.policy / 1000);
|
|
631
674
|
devLog.policy(policyResult.decision, policyResult.rule_id, policyResult.reasons);
|
|
632
675
|
|
|
633
676
|
// Policy: deny
|
|
@@ -741,12 +784,51 @@ export class Gateway {
|
|
|
741
784
|
this.anomalyDetector?.recordResult(toolCall, executionDuration, false);
|
|
742
785
|
this.anomalyDetector?.analyzeResult(toolCall, executionDuration, false);
|
|
743
786
|
|
|
787
|
+
// Content-type inspection: determine if output can be meaningfully text-scanned
|
|
788
|
+
const responseContentType = (output.headers?.['content-type'] || '').toLowerCase();
|
|
789
|
+
const OPAQUE_PREFIXES = ['image/', 'audio/', 'video/', 'application/pdf', 'application/zip',
|
|
790
|
+
'application/gzip', 'application/octet-stream', 'application/wasm', 'font/'];
|
|
791
|
+
const isOpaqueContent = OPAQUE_PREFIXES.some(p => responseContentType.startsWith(p))
|
|
792
|
+
&& !responseContentType.includes('image/svg+xml'); // SVGs are text-based XML
|
|
793
|
+
|
|
794
|
+
// For multipart responses, extract text parts for scanning
|
|
795
|
+
let multipartTextParts: string[] | undefined;
|
|
796
|
+
if (responseContentType.includes('multipart/') && typeof output.body === 'string') {
|
|
797
|
+
const boundaryMatch = responseContentType.match(/boundary=([^\s;]+)/);
|
|
798
|
+
if (boundaryMatch) {
|
|
799
|
+
multipartTextParts = extractMultipartTextParts(output.body, boundaryMatch[1]);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
744
803
|
// DLP scan on output
|
|
745
804
|
let stepStart = Date.now();
|
|
746
805
|
let outputDlp: { detected: string[]; redactions: any[]; severity: DLPSeverity } = { detected: [], redactions: [], severity: 'low' };
|
|
747
806
|
if (this.config.dlp.scan_output && output.body) {
|
|
748
807
|
outputDlp = childSpanWithAttrs(otel, SPAN.DLP_OUTPUT, (s) => {
|
|
749
|
-
|
|
808
|
+
let result;
|
|
809
|
+
if (isOpaqueContent) {
|
|
810
|
+
// Binary content cannot be text-scanned — flag it as unscannable
|
|
811
|
+
result = {
|
|
812
|
+
detected: ['opaque_content_type_unscannable'],
|
|
813
|
+
redactions: [],
|
|
814
|
+
severity: 'medium' as DLPSeverity,
|
|
815
|
+
};
|
|
816
|
+
devLog.pipelineStep('⚠️', 'DLP_OPAQUE_CONTENT',
|
|
817
|
+
`Response content-type ${responseContentType} cannot be text-scanned`);
|
|
818
|
+
} else if (multipartTextParts && multipartTextParts.length > 0) {
|
|
819
|
+
// Scan extracted text parts from multipart response
|
|
820
|
+
result = this.dlpScanner.scan(output, 'output');
|
|
821
|
+
for (const part of multipartTextParts) {
|
|
822
|
+
const partReport = this.dlpScanner.scan({ content: part }, 'output.multipart');
|
|
823
|
+
for (const det of partReport.detected) {
|
|
824
|
+
if (!result.detected.includes(det)) result.detected.push(det);
|
|
825
|
+
}
|
|
826
|
+
result.redactions.push(...partReport.redactions);
|
|
827
|
+
result.severity = this.maxSeverity(result.severity, partReport.severity);
|
|
828
|
+
}
|
|
829
|
+
} else {
|
|
830
|
+
result = this.dlpScanner.scan(output, 'output');
|
|
831
|
+
}
|
|
750
832
|
s.setAttribute(ATTR.DLP_DETECTION_COUNT, result.detected.length);
|
|
751
833
|
s.setAttribute(ATTR.DLP_SEVERITY, result.severity);
|
|
752
834
|
if (result.detected.length > 0) s.setAttribute(ATTR.DLP_TYPES, result.detected.join(','));
|
|
@@ -847,6 +929,70 @@ export class Gateway {
|
|
|
847
929
|
}
|
|
848
930
|
}
|
|
849
931
|
|
|
932
|
+
// Navigation instruction handling (runs after prompt injection output check)
|
|
933
|
+
const navAction = this.config.dlp.navigation_instruction_action || 'log';
|
|
934
|
+
if (navAction !== 'log' && outputDlp.detected.length > 0) {
|
|
935
|
+
const navDetections = outputDlp.detected.filter((d: string) =>
|
|
936
|
+
d.startsWith('navigation_instruction_')
|
|
937
|
+
);
|
|
938
|
+
if (navDetections.length > 0) {
|
|
939
|
+
if (navAction === 'block') {
|
|
940
|
+
// Release budget reservation since we're blocking
|
|
941
|
+
if (reservationKey) {
|
|
942
|
+
this.budgetManager.commitReservation(reservationKey, 0);
|
|
943
|
+
}
|
|
944
|
+
devLog.pipelineStep('🧭', 'NAVIGATION_INSTRUCTION_BLOCK',
|
|
945
|
+
`Blocked output: ${navDetections.join(', ')}`);
|
|
946
|
+
const durationSec = (Date.now() - startTime) / 1000;
|
|
947
|
+
this.metrics?.recordRequest('blocked', toolCall.tool.name, toolCall.tool.capability, durationSec);
|
|
948
|
+
const defaultPolicy = policyResult || { decision: 'allow' as const, rule_id: 'passthrough', rule_name: 'Passthrough', reasons: [] };
|
|
949
|
+
const argsDlpSafe = argsDlp || { detected: [], redactions: [], severity: 'low' as DLPSeverity };
|
|
950
|
+
const mergedDlp = {
|
|
951
|
+
detected: [...new Set([...argsDlpSafe.detected, ...outputDlp.detected])],
|
|
952
|
+
redactions: [...argsDlpSafe.redactions, ...outputDlp.redactions],
|
|
953
|
+
severity: this.maxSeverity(argsDlpSafe.severity, outputDlp.severity),
|
|
954
|
+
};
|
|
955
|
+
const result = this.buildResult(toolCall, 'blocked', {
|
|
956
|
+
decision: 'deny',
|
|
957
|
+
rule_id: 'navigation_instruction_output_block',
|
|
958
|
+
rule_name: 'Navigation instructions detected in output',
|
|
959
|
+
reasons: [`Navigation instructions detected in output: ${navDetections.join(', ')}`],
|
|
960
|
+
}, startTime, undefined,
|
|
961
|
+
`Blocked by navigation instruction detection in output: ${navDetections.join(', ')}`,
|
|
962
|
+
undefined, mergedDlp);
|
|
963
|
+
this.auditLogger.logToolResultReturned(toolCall, 'blocked', Date.now() - startTime, { navigation_instruction_blocked: true, detections: navDetections });
|
|
964
|
+
devLog.pipelineEnd('blocked', Date.now() - startTime);
|
|
965
|
+
return result;
|
|
966
|
+
} else if (navAction === 'strip' && output.body) {
|
|
967
|
+
// Re-scan the body string with NavigationInstructionBackend to get
|
|
968
|
+
// actual match positions (the DLPReport only carries pattern names).
|
|
969
|
+
let bodyStr = typeof output.body === 'string' ? output.body : JSON.stringify(output.body);
|
|
970
|
+
const navBackend = new NavigationInstructionBackend();
|
|
971
|
+
const matches = navBackend.scanString(bodyStr);
|
|
972
|
+
|
|
973
|
+
if (matches.length > 0) {
|
|
974
|
+
// Sort by start position descending so splicing from the end
|
|
975
|
+
// doesn't invalidate earlier indices.
|
|
976
|
+
matches.sort((a, b) => b.start - a.start);
|
|
977
|
+
for (const m of matches) {
|
|
978
|
+
const before = bodyStr.slice(0, m.start);
|
|
979
|
+
const after = bodyStr.slice(m.end);
|
|
980
|
+
bodyStr = before + '[NAVIGATION_INSTRUCTION_REDACTED]' + after;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Re-parse if original was JSON
|
|
985
|
+
if (typeof output.body !== 'string') {
|
|
986
|
+
try { output.body = JSON.parse(bodyStr); } catch { output.body = bodyStr; }
|
|
987
|
+
} else {
|
|
988
|
+
output.body = bodyStr;
|
|
989
|
+
}
|
|
990
|
+
devLog.pipelineStep('🧭', 'NAVIGATION_INSTRUCTION_STRIP',
|
|
991
|
+
`Stripped ${matches.length} navigation instruction(s) from output`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
850
996
|
// Extract usage data from response
|
|
851
997
|
const headerUsage = this.usageExtractor.extractFromHeaders(output.headers);
|
|
852
998
|
const bodyUsage = this.usageExtractor.extractFromBody(output.body);
|
|
@@ -1084,8 +1230,8 @@ export class Gateway {
|
|
|
1084
1230
|
// Run post-execution pipeline (DLP output, usage, budget recording, metrics, audit)
|
|
1085
1231
|
const result = await this.postExecute(toolCall, output, pre, otel);
|
|
1086
1232
|
|
|
1087
|
-
// Cache result for idempotency
|
|
1088
|
-
this.idempotencyStore.set(cacheKey, result,
|
|
1233
|
+
// Cache result for idempotency using cache key with body hash
|
|
1234
|
+
this.idempotencyStore.set(cacheKey, result, this.config.idempotency_cache_ttl_ms ?? 300_000);
|
|
1089
1235
|
if (this.idempotencyStore.flush) await this.idempotencyStore.flush();
|
|
1090
1236
|
|
|
1091
1237
|
return result;
|
|
@@ -1260,7 +1406,11 @@ export class Gateway {
|
|
|
1260
1406
|
policyStore?: PolicyStore;
|
|
1261
1407
|
rateLimitConfigStore?: RateLimitConfigStore;
|
|
1262
1408
|
budgetConfigStore?: BudgetConfigStore;
|
|
1409
|
+
workspaceStore?: WorkspaceStore;
|
|
1263
1410
|
}): void {
|
|
1411
|
+
if (stores.workspaceStore) {
|
|
1412
|
+
this.workspaceStore = stores.workspaceStore;
|
|
1413
|
+
}
|
|
1264
1414
|
if (stores.idempotencyStore) {
|
|
1265
1415
|
this.idempotencyStore = stores.idempotencyStore;
|
|
1266
1416
|
}
|
|
@@ -1305,6 +1455,7 @@ export class Gateway {
|
|
|
1305
1455
|
getPolicyStore(): PolicyStore | undefined { return this.policyStore; }
|
|
1306
1456
|
getRateLimitConfigStore(): RateLimitConfigStore | undefined { return this.rateLimitConfigStore; }
|
|
1307
1457
|
getBudgetConfigStore(): BudgetConfigStore | undefined { return this.budgetConfigStore; }
|
|
1458
|
+
getWorkspaceStore(): WorkspaceStore | undefined { return this.workspaceStore; }
|
|
1308
1459
|
|
|
1309
1460
|
/** Return the workspace-specific policy if one exists, otherwise the global policy. */
|
|
1310
1461
|
getWorkspacePolicy(workspaceId: string): { policy: PolicyPack; is_custom: boolean } {
|
package/src/types/config.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface GatewayConfig {
|
|
|
37
37
|
mcp_oauth?: MCPOAuthConfig;
|
|
38
38
|
/** Webhook alerting for governance events (DLP, budget, anomaly, policy denials) */
|
|
39
39
|
alerting?: AlertingConfig;
|
|
40
|
+
/** Idempotency cache TTL in milliseconds. Default: 300000 (5 minutes). */
|
|
41
|
+
idempotency_cache_ttl_ms?: number;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export interface MCPOAuthConfig {
|
|
@@ -190,6 +192,13 @@ export interface DLPConfig {
|
|
|
190
192
|
api_url: string;
|
|
191
193
|
timeout_ms?: number;
|
|
192
194
|
};
|
|
195
|
+
/**
|
|
196
|
+
* Action when navigation instructions are detected in output responses.
|
|
197
|
+
* - 'log' (default): detect and log, pass response through unchanged
|
|
198
|
+
* - 'strip': redact detected navigation instructions from the response body
|
|
199
|
+
* - 'block': block the entire response if navigation instructions are found
|
|
200
|
+
*/
|
|
201
|
+
navigation_instruction_action?: 'log' | 'strip' | 'block';
|
|
193
202
|
}
|
|
194
203
|
|
|
195
204
|
export interface AuditConfig {
|
package/src/types/policy.ts
CHANGED
|
@@ -57,6 +57,11 @@ export interface PolicyConditions {
|
|
|
57
57
|
dlp_severity?: string[]; // e.g., ['high', 'critical'] — match if DLP max severity is in this list
|
|
58
58
|
dlp_detected?: boolean; // true = rule matches only when DLP detections exist
|
|
59
59
|
dlp_pattern_names?: string[]; // match if any of these pattern names were detected
|
|
60
|
+
|
|
61
|
+
/** Referrer/provenance conditions — match based on context.referrer_url */
|
|
62
|
+
has_referrer?: boolean; // true = match only when referrer_url is present; false = only when absent
|
|
63
|
+
referrer_domains?: string[]; // allowlist: match only if referrer URL domain is in this list (supports glob, e.g. "*.github.com")
|
|
64
|
+
referrer_domains_blocklist?: string[]; // blocklist: match if referrer URL domain is in this list
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
export interface PolicyPack {
|
package/src/types/tool-call.ts
CHANGED
|
@@ -31,6 +31,10 @@ export interface Constraints {
|
|
|
31
31
|
export interface CallContext {
|
|
32
32
|
purpose?: string;
|
|
33
33
|
labels?: string[];
|
|
34
|
+
/** URL that triggered this request (set by agent/SDK when following a link from a previous response). */
|
|
35
|
+
referrer_url?: string;
|
|
36
|
+
/** Tool call ID that produced the response containing the referrer URL. */
|
|
37
|
+
referrer_tool_call_id?: string;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
export interface ToolCall {
|