ai-agent-guardrails 0.0.1

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.
@@ -0,0 +1,158 @@
1
+ import type { GuardToolsOptions, ToolLike, GuardContext } from './types.js';
2
+ import { createDefaultContext, withTimeout } from './utils.js';
3
+
4
+ /**
5
+ * Wrap a toolset with guardrails enforcement
6
+ *
7
+ * This is the main entry point for the guardrails package. It wraps AI SDK tools
8
+ * with policy enforcement, budget checks, timeouts, and audit logging.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * const tools = guardTools(mcpTools, {
13
+ * policy: createSimplePolicy({ requireApprovalForRisk: ['write', 'admin'] }),
14
+ * audit: new ConsoleAuditSink(),
15
+ * timeoutMs: 10_000,
16
+ * });
17
+ * ```
18
+ */
19
+ export function guardTools<T extends Record<string, ToolLike>>(
20
+ tools: T,
21
+ opts: GuardToolsOptions
22
+ ): T {
23
+ const ctx = opts.ctx ?? createDefaultContext();
24
+ const audit = opts.audit;
25
+ const timeoutMs = opts.timeoutMs ?? 15_000;
26
+ const redactor = opts.redactor;
27
+
28
+ const wrapped: Record<string, ToolLike> = {};
29
+
30
+ for (const [toolName, tool] of Object.entries(tools)) {
31
+ const originalExecute = tool.execute;
32
+
33
+ // Set needsApproval based on policy decision
34
+ const needsApproval =
35
+ tool.needsApproval ??
36
+ (async (input: unknown) => {
37
+ try {
38
+ const { risk, reason } = await opts.policy.classify(toolName, input);
39
+ const decision = await opts.policy.decide({ toolName, input, ctx, risk, reason });
40
+
41
+ // Return true if decision requires approval
42
+ return Boolean((decision as any).needsApproval);
43
+ } catch (error) {
44
+ console.error(`[guardTools] Error in needsApproval check for ${toolName}:`, error);
45
+ // Fail closed: require approval on error
46
+ return true;
47
+ }
48
+ });
49
+
50
+ wrapped[toolName] = {
51
+ ...tool,
52
+ needsApproval,
53
+ execute: originalExecute
54
+ ? async (input: any) => {
55
+ const timestamp = Date.now();
56
+
57
+ // Redact input before logging if redactor is provided
58
+ const redactedInput = redactor ? redactor.redact(input) : input;
59
+
60
+ audit?.emit({
61
+ type: 'tool_call_attempted',
62
+ toolName,
63
+ input: redactedInput,
64
+ requestId: ctx.requestId,
65
+ timestamp,
66
+ });
67
+
68
+ // Check tool call budget
69
+ ctx.toolCalls += 1;
70
+ if (ctx.toolCalls > ctx.maxToolCalls) {
71
+ const reason = `Tool budget exceeded (maxToolCalls=${ctx.maxToolCalls})`;
72
+ audit?.emit({
73
+ type: 'budget_exceeded',
74
+ reason,
75
+ requestId: ctx.requestId,
76
+ timestamp: Date.now(),
77
+ });
78
+ throw new Error(reason);
79
+ }
80
+
81
+ // Check elapsed time budget
82
+ if (ctx.maxDurationMs) {
83
+ const elapsed = Date.now() - ctx.startTime;
84
+ if (elapsed > ctx.maxDurationMs) {
85
+ const reason = `Time budget exceeded (maxDurationMs=${ctx.maxDurationMs})`;
86
+ audit?.emit({
87
+ type: 'budget_exceeded',
88
+ reason,
89
+ requestId: ctx.requestId,
90
+ timestamp: Date.now(),
91
+ });
92
+ throw new Error(reason);
93
+ }
94
+ }
95
+
96
+ // Classify and decide
97
+ const { risk, reason } = await opts.policy.classify(toolName, input);
98
+ const decision = await opts.policy.decide({ toolName, input, ctx, risk, reason });
99
+
100
+ // Block if not allowed
101
+ if (!decision.allow) {
102
+ audit?.emit({
103
+ type: 'tool_call_blocked',
104
+ toolName,
105
+ reason: decision.reason,
106
+ requestId: ctx.requestId,
107
+ timestamp: Date.now(),
108
+ });
109
+ throw new Error(`Tool call blocked: ${decision.reason}`);
110
+ }
111
+
112
+ // Log if approval is needed (AI SDK will handle the actual approval flow)
113
+ if ((decision as any).needsApproval) {
114
+ audit?.emit({
115
+ type: 'tool_call_needs_approval',
116
+ toolName,
117
+ reason: (decision as any).reason ?? 'approval required',
118
+ requestId: ctx.requestId,
119
+ timestamp: Date.now(),
120
+ });
121
+ }
122
+
123
+ // Execute with timeout
124
+ const t0 = Date.now();
125
+ try {
126
+ const result = await withTimeout(originalExecute(input), timeoutMs);
127
+
128
+ // Redact output if redactor is provided
129
+ const redactedResult = redactor ? redactor.redact(result) : result;
130
+
131
+ audit?.emit({
132
+ type: 'tool_call_executed',
133
+ toolName,
134
+ durationMs: Date.now() - t0,
135
+ requestId: ctx.requestId,
136
+ timestamp: Date.now(),
137
+ });
138
+
139
+ return result; // Return original result, not redacted (redaction is for logging only)
140
+ } catch (error: any) {
141
+ if (error.message?.includes('timed out')) {
142
+ audit?.emit({
143
+ type: 'tool_call_timeout',
144
+ toolName,
145
+ timeoutMs,
146
+ requestId: ctx.requestId,
147
+ timestamp: Date.now(),
148
+ });
149
+ }
150
+ throw error;
151
+ }
152
+ }
153
+ : undefined,
154
+ };
155
+ }
156
+
157
+ return wrapped as T;
158
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ // Types
2
+ export type {
3
+ Risk,
4
+ GuardDecision,
5
+ GuardContext,
6
+ GuardPolicy,
7
+ AuditEvent,
8
+ AuditSink,
9
+ GuardToolsOptions,
10
+ ToolLike,
11
+ Redactor,
12
+ } from './types.js';
13
+
14
+ // Core functions
15
+ export { guardTools } from './guard-tools.js';
16
+ export { createDefaultContext, withTimeout } from './utils.js';
17
+
18
+ // Policy utilities
19
+ export { createSimplePolicy, PolicyBuilder } from './policy.js';
20
+
21
+ // Audit sinks
22
+ export { InMemoryAuditSink, ConsoleAuditSink, FileAuditSink } from './audit.js';
23
+
24
+ // Redaction utilities
25
+ export {
26
+ createRegexRedactor,
27
+ createDefaultRedactor,
28
+ createFieldRedactor,
29
+ composeRedactors,
30
+ } from './redaction.js';
package/src/policy.ts ADDED
@@ -0,0 +1,134 @@
1
+ import type { Risk, GuardPolicy, GuardDecision, GuardContext } from './types.js';
2
+
3
+ /**
4
+ * Create a simple policy with allowlist/denylist
5
+ */
6
+ export function createSimplePolicy(options: {
7
+ allowlist?: string[];
8
+ denylist?: string[];
9
+ requireApprovalForRisk?: Risk[];
10
+ }): GuardPolicy {
11
+ const { allowlist, denylist, requireApprovalForRisk = ['write', 'admin'] } = options;
12
+
13
+ return {
14
+ classify(toolName: string) {
15
+ // Classify based on tool name patterns
16
+ const lowered = toolName.toLowerCase();
17
+
18
+ if (
19
+ lowered.includes('delete') ||
20
+ lowered.includes('remove') ||
21
+ lowered.includes('destroy') ||
22
+ lowered.includes('drop')
23
+ ) {
24
+ return { risk: 'admin', reason: 'destructive operation' };
25
+ }
26
+
27
+ if (
28
+ lowered.includes('create') ||
29
+ lowered.includes('write') ||
30
+ lowered.includes('update') ||
31
+ lowered.includes('modify') ||
32
+ lowered.includes('insert') ||
33
+ lowered.includes('send') ||
34
+ lowered.includes('post')
35
+ ) {
36
+ return { risk: 'write', reason: 'write operation' };
37
+ }
38
+
39
+ return { risk: 'read', reason: 'read-only operation' };
40
+ },
41
+
42
+ decide({ toolName, risk, ctx }): GuardDecision {
43
+ // Check denylist first
44
+ if (denylist && denylist.includes(toolName)) {
45
+ return { allow: false, reason: `Tool '${toolName}' is in denylist` };
46
+ }
47
+
48
+ // Check allowlist if provided
49
+ if (allowlist && !allowlist.includes(toolName)) {
50
+ return { allow: false, reason: `Tool '${toolName}' is not in allowlist` };
51
+ }
52
+
53
+ // Check if approval is required for this risk level
54
+ if (requireApprovalForRisk.includes(risk)) {
55
+ return {
56
+ allow: true,
57
+ needsApproval: true,
58
+ reason: `${risk} operation requires approval`,
59
+ };
60
+ }
61
+
62
+ return { allow: true };
63
+ },
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Create a composable policy builder
69
+ */
70
+ export class PolicyBuilder {
71
+ private classifiers: Array<(toolName: string, input: unknown) => { risk: Risk; reason?: string } | null> = [];
72
+ private rules: Array<
73
+ (args: {
74
+ toolName: string;
75
+ input: unknown;
76
+ ctx: GuardContext;
77
+ risk: Risk;
78
+ reason?: string;
79
+ }) => GuardDecision | null
80
+ > = [];
81
+
82
+ /**
83
+ * Add a classifier function
84
+ */
85
+ addClassifier(
86
+ classifier: (toolName: string, input: unknown) => { risk: Risk; reason?: string } | null
87
+ ): this {
88
+ this.classifiers.push(classifier);
89
+ return this;
90
+ }
91
+
92
+ /**
93
+ * Add a decision rule
94
+ */
95
+ addRule(
96
+ rule: (args: {
97
+ toolName: string;
98
+ input: unknown;
99
+ ctx: GuardContext;
100
+ risk: Risk;
101
+ reason?: string;
102
+ }) => GuardDecision | null
103
+ ): this {
104
+ this.rules.push(rule);
105
+ return this;
106
+ }
107
+
108
+ /**
109
+ * Build the final policy
110
+ */
111
+ build(): GuardPolicy {
112
+ return {
113
+ classify: (toolName: string, input: unknown) => {
114
+ // Try each classifier in order
115
+ for (const classifier of this.classifiers) {
116
+ const result = classifier(toolName, input);
117
+ if (result) return result;
118
+ }
119
+ // Default to read if no classifier matches
120
+ return { risk: 'read' };
121
+ },
122
+
123
+ decide: args => {
124
+ // Try each rule in order
125
+ for (const rule of this.rules) {
126
+ const decision = rule(args);
127
+ if (decision) return decision;
128
+ }
129
+ // Default to allow if no rule blocks
130
+ return { allow: true };
131
+ },
132
+ };
133
+ }
134
+ }
@@ -0,0 +1,107 @@
1
+ import type { Redactor } from './types.js';
2
+
3
+ /**
4
+ * Default patterns for secret detection
5
+ */
6
+ const DEFAULT_SECRET_PATTERNS = [
7
+ /\b(sk-[a-zA-Z0-9]{48})\b/g, // OpenAI API keys
8
+ /\b(sk_live_[a-zA-Z0-9]{24,})\b/g, // Stripe live keys
9
+ /\b(sk_test_[a-zA-Z0-9]{24,})\b/g, // Stripe test keys
10
+ /\b([a-zA-Z0-9_-]{40})\b/g, // GitHub tokens (40 chars)
11
+ /\b(ghp_[a-zA-Z0-9]{36})\b/g, // GitHub personal access tokens
12
+ /\b(gho_[a-zA-Z0-9]{36})\b/g, // GitHub OAuth tokens
13
+ /\b(AKIA[0-9A-Z]{16})\b/g, // AWS access keys
14
+ /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/g, // Private keys
15
+ /\b([a-zA-Z0-9_-]{32,})\b/g, // Generic long tokens
16
+ ];
17
+
18
+ /**
19
+ * Default patterns for PII detection
20
+ */
21
+ const DEFAULT_PII_PATTERNS = [
22
+ /\b\d{3}-\d{2}-\d{4}\b/g, // SSN
23
+ /\b\d{16}\b/g, // Credit card numbers
24
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, // Email addresses
25
+ ];
26
+
27
+ /**
28
+ * Create a regex-based redactor
29
+ */
30
+ export function createRegexRedactor(patterns: RegExp[], replacement = '[REDACTED]'): Redactor {
31
+ return {
32
+ redact(value: unknown): unknown {
33
+ if (typeof value === 'string') {
34
+ let redacted = value;
35
+ for (const pattern of patterns) {
36
+ redacted = redacted.replace(pattern, replacement);
37
+ }
38
+ return redacted;
39
+ }
40
+
41
+ if (Array.isArray(value)) {
42
+ return value.map(item => this.redact(item));
43
+ }
44
+
45
+ if (value && typeof value === 'object') {
46
+ const result: Record<string, unknown> = {};
47
+ for (const [key, val] of Object.entries(value)) {
48
+ result[key] = this.redact(val);
49
+ }
50
+ return result;
51
+ }
52
+
53
+ return value;
54
+ },
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Create a default redactor with common secret and PII patterns
60
+ */
61
+ export function createDefaultRedactor(): Redactor {
62
+ return createRegexRedactor([...DEFAULT_SECRET_PATTERNS, ...DEFAULT_PII_PATTERNS]);
63
+ }
64
+
65
+ /**
66
+ * Field-based redactor that redacts specific fields
67
+ */
68
+ export function createFieldRedactor(fieldsToRedact: string[], replacement = '[REDACTED]'): Redactor {
69
+ const fieldSet = new Set(fieldsToRedact.map(f => f.toLowerCase()));
70
+
71
+ return {
72
+ redact(value: unknown): unknown {
73
+ if (Array.isArray(value)) {
74
+ return value.map(item => this.redact(item));
75
+ }
76
+
77
+ if (value && typeof value === 'object') {
78
+ const result: Record<string, unknown> = {};
79
+ for (const [key, val] of Object.entries(value)) {
80
+ if (fieldSet.has(key.toLowerCase())) {
81
+ result[key] = replacement;
82
+ } else {
83
+ result[key] = this.redact(val);
84
+ }
85
+ }
86
+ return result;
87
+ }
88
+
89
+ return value;
90
+ },
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Composite redactor that chains multiple redactors
96
+ */
97
+ export function composeRedactors(...redactors: Redactor[]): Redactor {
98
+ return {
99
+ redact(value: unknown): unknown {
100
+ let result = value;
101
+ for (const redactor of redactors) {
102
+ result = redactor.redact(result);
103
+ }
104
+ return result;
105
+ },
106
+ };
107
+ }
package/src/types.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Risk classification for tools
3
+ */
4
+ export type Risk = 'read' | 'write' | 'admin';
5
+
6
+ /**
7
+ * Decision outcome from policy evaluation
8
+ */
9
+ export type GuardDecision =
10
+ | { allow: true }
11
+ | { allow: false; reason: string }
12
+ | { allow: true; needsApproval: true; reason: string };
13
+
14
+ /**
15
+ * Context passed through guard execution
16
+ */
17
+ export type GuardContext = {
18
+ requestId: string;
19
+ toolCalls: number;
20
+ maxToolCalls: number;
21
+ startTime: number;
22
+ maxDurationMs?: number;
23
+ };
24
+
25
+ /**
26
+ * Tool-like interface that matches AI SDK tool structure
27
+ */
28
+ export type ToolLike = {
29
+ description?: string;
30
+ inputSchema?: unknown;
31
+ parameters?: unknown;
32
+ needsApproval?: boolean | ((input: any) => boolean | Promise<boolean>);
33
+ execute?: (input: any) => Promise<any>;
34
+ };
35
+
36
+ /**
37
+ * Policy interface for classification and decision making
38
+ */
39
+ export type GuardPolicy = {
40
+ /**
41
+ * Classify a tool call by risk level
42
+ */
43
+ classify: (
44
+ toolName: string,
45
+ input: unknown
46
+ ) => Promise<{ risk: Risk; reason?: string }> | { risk: Risk; reason?: string };
47
+
48
+ /**
49
+ * Decide whether to allow, block, or require approval
50
+ */
51
+ decide: (args: {
52
+ toolName: string;
53
+ input: unknown;
54
+ ctx: GuardContext;
55
+ risk: Risk;
56
+ reason?: string;
57
+ }) => Promise<GuardDecision> | GuardDecision;
58
+ };
59
+
60
+ /**
61
+ * Audit event types
62
+ */
63
+ export type AuditEvent =
64
+ | { type: 'tool_call_attempted'; toolName: string; input: unknown; requestId: string; timestamp: number }
65
+ | { type: 'tool_call_blocked'; toolName: string; reason: string; requestId: string; timestamp: number }
66
+ | {
67
+ type: 'tool_call_needs_approval';
68
+ toolName: string;
69
+ reason: string;
70
+ requestId: string;
71
+ timestamp: number;
72
+ }
73
+ | {
74
+ type: 'tool_call_executed';
75
+ toolName: string;
76
+ durationMs: number;
77
+ requestId: string;
78
+ timestamp: number;
79
+ }
80
+ | {
81
+ type: 'tool_call_timeout';
82
+ toolName: string;
83
+ timeoutMs: number;
84
+ requestId: string;
85
+ timestamp: number;
86
+ }
87
+ | {
88
+ type: 'budget_exceeded';
89
+ reason: string;
90
+ requestId: string;
91
+ timestamp: number;
92
+ };
93
+
94
+ /**
95
+ * Audit sink interface for logging events
96
+ */
97
+ export type AuditSink = {
98
+ emit: (event: AuditEvent) => void | Promise<void>;
99
+ };
100
+
101
+ /**
102
+ * Options for guardTools wrapper
103
+ */
104
+ export type GuardToolsOptions = {
105
+ policy: GuardPolicy;
106
+ ctx?: GuardContext;
107
+ audit?: AuditSink;
108
+ timeoutMs?: number;
109
+ redactor?: Redactor;
110
+ };
111
+
112
+ /**
113
+ * Redactor interface for PII/secret removal
114
+ */
115
+ export type Redactor = {
116
+ redact: (value: unknown) => unknown;
117
+ };
package/src/utils.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { GuardContext } from './types.js';
2
+
3
+ /**
4
+ * Generate a unique request ID
5
+ */
6
+ function cryptoRandomId(): string {
7
+ // Use crypto.randomUUID() in Node 20+ and modern browsers
8
+ if (typeof globalThis.crypto?.randomUUID === 'function') {
9
+ return globalThis.crypto.randomUUID();
10
+ }
11
+ // Fallback for environments without crypto.randomUUID
12
+ return `req_${Math.random().toString(16).slice(2)}`;
13
+ }
14
+
15
+ /**
16
+ * Create a default guard context with budget limits
17
+ */
18
+ export function createDefaultContext(requestId?: string): GuardContext {
19
+ return {
20
+ requestId: requestId ?? cryptoRandomId(),
21
+ toolCalls: 0,
22
+ maxToolCalls: 8,
23
+ startTime: Date.now(),
24
+ maxDurationMs: 60_000, // 60 seconds default
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Wrap a promise with a timeout
30
+ */
31
+ export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
32
+ return new Promise((resolve, reject) => {
33
+ const timeoutId = setTimeout(() => {
34
+ reject(new Error(`Operation timed out after ${ms}ms`));
35
+ }, ms);
36
+
37
+ promise
38
+ .then(value => {
39
+ clearTimeout(timeoutId);
40
+ resolve(value);
41
+ })
42
+ .catch(error => {
43
+ clearTimeout(timeoutId);
44
+ reject(error);
45
+ });
46
+ });
47
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "incremental": false
7
+ },
8
+ "include": ["src"]
9
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['esm'],
6
+ dts: true,
7
+ sourcemap: true,
8
+ clean: true,
9
+ treeshake: true,
10
+ });