@eddacraft/anvil-kindling-integration 0.1.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 (84) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +542 -0
  3. package/dist/adapter.d.ts +49 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +100 -0
  6. package/dist/config.d.ts +89 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +173 -0
  9. package/dist/emitters/action-emitter.d.ts +40 -0
  10. package/dist/emitters/action-emitter.d.ts.map +1 -0
  11. package/dist/emitters/action-emitter.js +52 -0
  12. package/dist/emitters/constraint-emitter.d.ts +32 -0
  13. package/dist/emitters/constraint-emitter.d.ts.map +1 -0
  14. package/dist/emitters/constraint-emitter.js +41 -0
  15. package/dist/emitters/error-emitter.d.ts +33 -0
  16. package/dist/emitters/error-emitter.d.ts.map +1 -0
  17. package/dist/emitters/error-emitter.js +50 -0
  18. package/dist/emitters/gate-emitter.d.ts +37 -0
  19. package/dist/emitters/gate-emitter.d.ts.map +1 -0
  20. package/dist/emitters/gate-emitter.js +53 -0
  21. package/dist/emitters/human-input-emitter.d.ts +30 -0
  22. package/dist/emitters/human-input-emitter.d.ts.map +1 -0
  23. package/dist/emitters/human-input-emitter.js +38 -0
  24. package/dist/emitters/index.d.ts +13 -0
  25. package/dist/emitters/index.d.ts.map +1 -0
  26. package/dist/emitters/index.js +19 -0
  27. package/dist/emitters/plan-emitter.d.ts +75 -0
  28. package/dist/emitters/plan-emitter.d.ts.map +1 -0
  29. package/dist/emitters/plan-emitter.js +116 -0
  30. package/dist/emitters/session-emitter.d.ts +57 -0
  31. package/dist/emitters/session-emitter.d.ts.map +1 -0
  32. package/dist/emitters/session-emitter.js +80 -0
  33. package/dist/index.d.ts +40 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +111 -0
  36. package/dist/kindling-service.d.ts +122 -0
  37. package/dist/kindling-service.d.ts.map +1 -0
  38. package/dist/kindling-service.js +203 -0
  39. package/dist/observation-contract.d.ts +561 -0
  40. package/dist/observation-contract.d.ts.map +1 -0
  41. package/dist/observation-contract.js +391 -0
  42. package/dist/query-contract.d.ts +463 -0
  43. package/dist/query-contract.d.ts.map +1 -0
  44. package/dist/query-contract.js +314 -0
  45. package/dist/query-limits.d.ts +40 -0
  46. package/dist/query-limits.d.ts.map +1 -0
  47. package/dist/query-limits.js +79 -0
  48. package/dist/query-service.d.ts +109 -0
  49. package/dist/query-service.d.ts.map +1 -0
  50. package/dist/query-service.js +140 -0
  51. package/dist/retention.d.ts +79 -0
  52. package/dist/retention.d.ts.map +1 -0
  53. package/dist/retention.js +81 -0
  54. package/dist/sensitive-data-validator.d.ts +47 -0
  55. package/dist/sensitive-data-validator.d.ts.map +1 -0
  56. package/dist/sensitive-data-validator.js +135 -0
  57. package/dist/status.d.ts +104 -0
  58. package/dist/status.d.ts.map +1 -0
  59. package/dist/status.js +136 -0
  60. package/dist/utils/debug.d.ts +9 -0
  61. package/dist/utils/debug.d.ts.map +1 -0
  62. package/dist/utils/debug.js +55 -0
  63. package/package.json +114 -0
  64. package/src/adapter.ts +117 -0
  65. package/src/config.ts +202 -0
  66. package/src/emitters/action-emitter.ts +90 -0
  67. package/src/emitters/constraint-emitter.ts +73 -0
  68. package/src/emitters/error-emitter.ts +86 -0
  69. package/src/emitters/gate-emitter.ts +87 -0
  70. package/src/emitters/human-input-emitter.ts +71 -0
  71. package/src/emitters/index.ts +40 -0
  72. package/src/emitters/plan-emitter.ts +183 -0
  73. package/src/emitters/session-emitter.ts +131 -0
  74. package/src/index.ts +254 -0
  75. package/src/kindling-service.ts +272 -0
  76. package/src/malicious-ai.test.ts +949 -0
  77. package/src/observation-contract.ts +500 -0
  78. package/src/query-contract.ts +389 -0
  79. package/src/query-limits.ts +106 -0
  80. package/src/query-service.ts +217 -0
  81. package/src/retention.ts +153 -0
  82. package/src/sensitive-data-validator.ts +167 -0
  83. package/src/status.ts +221 -0
  84. package/src/utils/debug.ts +65 -0
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Kindling Query Service (KINDLING-009)
3
+ *
4
+ * High-level query interface that wraps KindlingService.query() with
5
+ * convenience methods for each query scope. Applies query limit enforcement
6
+ * before returning results.
7
+ *
8
+ * This is the primary read API for Anvil CLI commands and tooling.
9
+ */
10
+
11
+ import type { KindlingService } from './kindling-service.js';
12
+ import type {
13
+ QueryResponse,
14
+ SessionQuery,
15
+ PlanQuery,
16
+ GateQuery,
17
+ ActionQuery,
18
+ ResultShape,
19
+ OutputFormat,
20
+ } from './query-contract.js';
21
+ import { enforceQueryLimits, limitsFromConfig } from './query-limits.js';
22
+ import { createDebugger } from './utils/debug.js';
23
+
24
+ const debug = createDebugger('kindling');
25
+
26
+ // =============================================================================
27
+ // Query Options
28
+ // =============================================================================
29
+
30
+ /**
31
+ * Common options for all query methods
32
+ */
33
+ export interface QueryOptions {
34
+ /** Result structure (default: 'list') */
35
+ shape?: ResultShape;
36
+ /** Output format (default: 'json') */
37
+ format?: OutputFormat;
38
+ /** Include observations after this time */
39
+ time_after?: string;
40
+ /** Include observations before this time */
41
+ time_before?: string;
42
+ /** Maximum observations to return (capped by config) */
43
+ max_results?: number;
44
+ /** Maximum total payload size in bytes (capped by config) */
45
+ max_payload_bytes?: number;
46
+ }
47
+
48
+ /**
49
+ * Options specific to session queries
50
+ */
51
+ export interface SessionQueryOptions extends QueryOptions {
52
+ /** Filter to specific phases */
53
+ include_phases?: Array<'plan' | 'gate' | 'action' | 'outcome' | 'error'>;
54
+ }
55
+
56
+ /**
57
+ * Options specific to plan queries
58
+ */
59
+ export interface PlanQueryOptions extends QueryOptions {
60
+ /** Include linked execution run IDs (default: true) */
61
+ include_executions?: boolean;
62
+ /** Include plan version history (default: true) */
63
+ include_versions?: boolean;
64
+ }
65
+
66
+ /**
67
+ * Options specific to action queries
68
+ */
69
+ export interface ActionQueryOptions extends QueryOptions {
70
+ /** Include approval chain (default: true) */
71
+ include_approval_chain?: boolean;
72
+ }
73
+
74
+ // =============================================================================
75
+ // Query Service
76
+ // =============================================================================
77
+
78
+ /**
79
+ * High-level query service for Kindling observations.
80
+ *
81
+ * Provides typed convenience methods for each query scope and enforces
82
+ * query limits from the service configuration.
83
+ */
84
+ export class KindlingQueryService {
85
+ private readonly service: KindlingService;
86
+
87
+ constructor(service: KindlingService) {
88
+ this.service = service;
89
+ }
90
+
91
+ /**
92
+ * Query: "What happened in this run?"
93
+ *
94
+ * Returns ordered observations for a specific session, optionally
95
+ * filtered by phase.
96
+ *
97
+ * @param sessionId - Session/run ID
98
+ * @param options - Query options
99
+ * @returns Query response with session observations
100
+ */
101
+ async querySession(sessionId: string, options: SessionQueryOptions = {}): Promise<QueryResponse> {
102
+ debug('querySession', { sessionId, shape: options.shape });
103
+ const request: SessionQuery = {
104
+ scope: 'session',
105
+ session_id: sessionId,
106
+ shape: options.shape ?? 'timeline',
107
+ format: options.format ?? 'json',
108
+ time_after: options.time_after,
109
+ time_before: options.time_before,
110
+ max_results: options.max_results ?? this.service.configuration.query_limits.max_results,
111
+ max_payload_bytes:
112
+ options.max_payload_bytes ?? this.service.configuration.query_limits.max_payload_bytes,
113
+ include_phases: options.include_phases,
114
+ };
115
+
116
+ const response = await this.service.query(request);
117
+ return this.applyLimits(response);
118
+ }
119
+
120
+ /**
121
+ * Query: "What happened because of this plan?"
122
+ *
123
+ * Returns plan metadata, versions, and linked executions.
124
+ * This is the only cross-session read allowed.
125
+ *
126
+ * @param planId - Plan ID
127
+ * @param options - Query options
128
+ * @returns Query response with plan observations
129
+ */
130
+ async queryPlan(planId: string, options: PlanQueryOptions = {}): Promise<QueryResponse> {
131
+ debug('queryPlan', { planId, shape: options.shape });
132
+ const request: PlanQuery = {
133
+ scope: 'plan',
134
+ plan_id: planId,
135
+ shape: options.shape ?? 'entity',
136
+ format: options.format ?? 'json',
137
+ time_after: options.time_after,
138
+ time_before: options.time_before,
139
+ max_results: options.max_results ?? this.service.configuration.query_limits.max_results,
140
+ max_payload_bytes:
141
+ options.max_payload_bytes ?? this.service.configuration.query_limits.max_payload_bytes,
142
+ include_executions: options.include_executions ?? true,
143
+ include_versions: options.include_versions ?? true,
144
+ };
145
+
146
+ const response = await this.service.query(request);
147
+ return this.applyLimits(response);
148
+ }
149
+
150
+ /**
151
+ * Query: "Why did this gate pass/fail?"
152
+ *
153
+ * Returns gate evaluation details with rule IDs, inputs, and outcomes.
154
+ *
155
+ * @param gateEvalId - Gate evaluation ID
156
+ * @param options - Query options
157
+ * @returns Query response with gate observations
158
+ */
159
+ async queryGate(gateEvalId: string, options: QueryOptions = {}): Promise<QueryResponse> {
160
+ debug('queryGate', { gateEvalId });
161
+ const request: GateQuery = {
162
+ scope: 'gate',
163
+ gate_eval_id: gateEvalId,
164
+ shape: options.shape ?? 'entity',
165
+ format: options.format ?? 'json',
166
+ time_after: options.time_after,
167
+ time_before: options.time_before,
168
+ max_results: options.max_results ?? this.service.configuration.query_limits.max_results,
169
+ max_payload_bytes:
170
+ options.max_payload_bytes ?? this.service.configuration.query_limits.max_payload_bytes,
171
+ };
172
+
173
+ const response = await this.service.query(request);
174
+ return this.applyLimits(response);
175
+ }
176
+
177
+ /**
178
+ * Query: "What exactly did this action do?"
179
+ *
180
+ * Returns action execution details with redacted command, environment,
181
+ * and linked governance.
182
+ *
183
+ * @param actionId - Action ID
184
+ * @param options - Query options
185
+ * @returns Query response with action observations
186
+ */
187
+ async queryAction(actionId: string, options: ActionQueryOptions = {}): Promise<QueryResponse> {
188
+ debug('queryAction', { actionId });
189
+ const request: ActionQuery = {
190
+ scope: 'action',
191
+ action_id: actionId,
192
+ shape: options.shape ?? 'entity',
193
+ format: options.format ?? 'json',
194
+ time_after: options.time_after,
195
+ time_before: options.time_before,
196
+ max_results: options.max_results ?? this.service.configuration.query_limits.max_results,
197
+ max_payload_bytes:
198
+ options.max_payload_bytes ?? this.service.configuration.query_limits.max_payload_bytes,
199
+ include_approval_chain: options.include_approval_chain ?? true,
200
+ };
201
+
202
+ const response = await this.service.query(request);
203
+ return this.applyLimits(response);
204
+ }
205
+
206
+ // ===========================================================================
207
+ // Private
208
+ // ===========================================================================
209
+
210
+ /**
211
+ * Apply query limits from the service configuration as a defense-in-depth layer.
212
+ */
213
+ private applyLimits(response: QueryResponse): QueryResponse {
214
+ const limits = limitsFromConfig(this.service.configuration.query_limits);
215
+ return enforceQueryLimits(response, limits);
216
+ }
217
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Retention Management (KINDLING-016)
3
+ *
4
+ * Handles pruning of old observations and storage statistics.
5
+ * Works against the abstract IKindlingStore interface.
6
+ *
7
+ * Since the abstract store does not expose a direct "delete older than" method,
8
+ * retention is implemented via a dedicated IRetentionCapableStore interface
9
+ * that concrete stores can optionally implement.
10
+ */
11
+
12
+ import type { KindlingConfig } from './config.js';
13
+
14
+ // =============================================================================
15
+ // Retention Store Interface
16
+ // =============================================================================
17
+
18
+ /**
19
+ * Extended store interface for stores that support retention operations.
20
+ *
21
+ * Not all stores need to implement this. The NoOpKindlingStore does not.
22
+ * Concrete SQLite or database-backed stores should implement this interface.
23
+ */
24
+ export interface IRetentionCapableStore {
25
+ /**
26
+ * Delete all observations with timestamps older than the given ISO8601 date.
27
+ *
28
+ * @param olderThan - ISO8601 datetime cutoff
29
+ * @returns Number of observations deleted
30
+ */
31
+ deleteObservationsOlderThan(olderThan: string): Promise<number>;
32
+
33
+ /**
34
+ * Get storage statistics.
35
+ *
36
+ * @returns Observation count and estimated storage size in bytes
37
+ */
38
+ getStats(): Promise<StorageStats>;
39
+ }
40
+
41
+ /**
42
+ * Storage statistics
43
+ */
44
+ export interface StorageStats {
45
+ /** Total number of observations in the store */
46
+ observation_count: number;
47
+ /** Estimated storage size in bytes */
48
+ estimated_size_bytes: number;
49
+ }
50
+
51
+ // =============================================================================
52
+ // Type Guard
53
+ // =============================================================================
54
+
55
+ /**
56
+ * Check if a store supports retention operations.
57
+ */
58
+ export function isRetentionCapable(store: unknown): store is IRetentionCapableStore {
59
+ if (store === null || typeof store !== 'object') {
60
+ return false;
61
+ }
62
+ const candidate = store as Record<string, unknown>;
63
+ return (
64
+ typeof candidate['deleteObservationsOlderThan'] === 'function' &&
65
+ typeof candidate['getStats'] === 'function'
66
+ );
67
+ }
68
+
69
+ // =============================================================================
70
+ // Pruning
71
+ // =============================================================================
72
+
73
+ /**
74
+ * Result of a pruning operation
75
+ */
76
+ export interface PruneResult {
77
+ /** Whether pruning was actually performed */
78
+ pruned: boolean;
79
+ /** Number of observations deleted (0 if not pruned) */
80
+ deleted_count: number;
81
+ /** The cutoff date used */
82
+ cutoff_date: string;
83
+ /** Reason if pruning was skipped */
84
+ skip_reason?: string;
85
+ }
86
+
87
+ /**
88
+ * Prune observations older than the configured retention period.
89
+ *
90
+ * If the store does not implement IRetentionCapableStore, this is a no-op
91
+ * that returns a skip result.
92
+ *
93
+ * @param store - The store to prune (must implement IRetentionCapableStore)
94
+ * @param config - Kindling configuration with retention settings
95
+ * @returns Prune result
96
+ */
97
+ export async function pruneOldObservations(
98
+ store: unknown,
99
+ config: KindlingConfig
100
+ ): Promise<PruneResult> {
101
+ if (!config.enabled) {
102
+ return {
103
+ pruned: false,
104
+ deleted_count: 0,
105
+ cutoff_date: '',
106
+ skip_reason: 'Kindling is disabled',
107
+ };
108
+ }
109
+
110
+ if (!isRetentionCapable(store)) {
111
+ return {
112
+ pruned: false,
113
+ deleted_count: 0,
114
+ cutoff_date: '',
115
+ skip_reason: 'Store does not support retention operations',
116
+ };
117
+ }
118
+
119
+ const cutoffDate = new Date();
120
+ cutoffDate.setDate(cutoffDate.getDate() - config.retention.days);
121
+ const cutoffIso = cutoffDate.toISOString();
122
+
123
+ const deletedCount = await store.deleteObservationsOlderThan(cutoffIso);
124
+
125
+ return {
126
+ pruned: true,
127
+ deleted_count: deletedCount,
128
+ cutoff_date: cutoffIso,
129
+ };
130
+ }
131
+
132
+ // =============================================================================
133
+ // Statistics
134
+ // =============================================================================
135
+
136
+ /**
137
+ * Get storage statistics from the store.
138
+ *
139
+ * If the store does not implement IRetentionCapableStore, returns zero values.
140
+ *
141
+ * @param store - The store to query
142
+ * @returns Storage statistics
143
+ */
144
+ export async function getStorageStats(store: unknown): Promise<StorageStats> {
145
+ if (!isRetentionCapable(store)) {
146
+ return {
147
+ observation_count: 0,
148
+ estimated_size_bytes: 0,
149
+ };
150
+ }
151
+
152
+ return store.getStats();
153
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Sensitive Data Validation (KINDLING-015)
3
+ *
4
+ * Validates and redacts sensitive data from observations before they
5
+ * are persisted to Kindling. This is a defense-in-depth layer on top of
6
+ * the `containsSensitiveData` check in observation-contract.ts.
7
+ *
8
+ * Patterns detected:
9
+ * - API keys (sk-*, ghp_*, AKIA*)
10
+ * - Long hex tokens (40+ characters)
11
+ * - Email addresses
12
+ * - Password-like values
13
+ *
14
+ * @see observation-contract.ts for the base containsSensitiveData utility
15
+ */
16
+
17
+ import { containsSensitiveData, type Observation } from './observation-contract.js';
18
+ import { createDebugger } from './utils/debug.js';
19
+
20
+ const debug = createDebugger('kindling');
21
+
22
+ // =============================================================================
23
+ // Sensitive Patterns
24
+ // =============================================================================
25
+
26
+ /**
27
+ * Known sensitive value patterns and their replacement strings
28
+ */
29
+ const SENSITIVE_PATTERNS: ReadonlyArray<{
30
+ name: string;
31
+ pattern: RegExp;
32
+ replacement: string;
33
+ }> = [
34
+ {
35
+ name: 'openai-api-key',
36
+ pattern: /sk-[a-zA-Z0-9]{20,}/g,
37
+ replacement: '[REDACTED:api-key]',
38
+ },
39
+ {
40
+ name: 'github-pat',
41
+ pattern: /ghp_[a-zA-Z0-9]{36,}/g,
42
+ replacement: '[REDACTED:github-token]',
43
+ },
44
+ {
45
+ name: 'github-fine-grained-pat',
46
+ pattern: /github_pat_[a-zA-Z0-9_]{36,}/g,
47
+ replacement: '[REDACTED:github-token]',
48
+ },
49
+ {
50
+ name: 'aws-access-key',
51
+ pattern: /AKIA[0-9A-Z]{16}/g,
52
+ replacement: '[REDACTED:aws-key]',
53
+ },
54
+ {
55
+ name: 'aws-secret-key',
56
+ pattern: /(?<=aws_secret_access_key\s*[=:]\s*)[^\s"',]+/gi,
57
+ replacement: '[REDACTED:aws-secret]',
58
+ },
59
+ {
60
+ name: 'hex-token',
61
+ pattern: /\b[0-9a-fA-F]{40,}\b/g,
62
+ replacement: '[REDACTED:token]',
63
+ },
64
+ {
65
+ name: 'email',
66
+ pattern: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
67
+ replacement: '[REDACTED:email]',
68
+ },
69
+ {
70
+ name: 'password-value',
71
+ pattern: /(?<=(?:password|passwd|pwd)\s*[=:]\s*["']?)[^\s"',]+/gi,
72
+ replacement: '[REDACTED:password]',
73
+ },
74
+ {
75
+ name: 'bearer-token',
76
+ pattern: /(?<=Bearer\s+)[a-zA-Z0-9._~+/=-]{20,}/gi,
77
+ replacement: '[REDACTED:bearer-token]',
78
+ },
79
+ {
80
+ name: 'npm-token',
81
+ pattern: /npm_[a-zA-Z0-9]{36,}/g,
82
+ replacement: '[REDACTED:npm-token]',
83
+ },
84
+ ];
85
+
86
+ // =============================================================================
87
+ // Validation
88
+ // =============================================================================
89
+
90
+ /**
91
+ * Result of sensitive data validation
92
+ */
93
+ export interface SensitiveDataValidationResult {
94
+ /** Whether any sensitive data was detected */
95
+ hasSensitiveData: boolean;
96
+ /** Detailed issues found */
97
+ issues: string[];
98
+ }
99
+
100
+ /**
101
+ * Validate that an observation does not contain sensitive data.
102
+ *
103
+ * Uses the contract-level `containsSensitiveData` check plus additional
104
+ * pattern matching for known credential formats.
105
+ *
106
+ * @param observation - The observation to validate
107
+ * @returns Validation result with issues if sensitive data found
108
+ */
109
+ export function validateNoSensitiveData(observation: Observation): SensitiveDataValidationResult {
110
+ const issues: string[] = [];
111
+
112
+ // Run the contract-level check first
113
+ const contractCheck = containsSensitiveData(observation);
114
+ if (contractCheck.hasSensitiveData) {
115
+ issues.push(...contractCheck.issues);
116
+ }
117
+
118
+ // Run additional pattern checks against the serialized payload
119
+ const serialized = JSON.stringify(observation);
120
+
121
+ for (const { name, pattern } of SENSITIVE_PATTERNS) {
122
+ // Reset lastIndex for global regex patterns
123
+ pattern.lastIndex = 0;
124
+ if (pattern.test(serialized)) {
125
+ issues.push(`Sensitive pattern detected: ${name}`);
126
+ }
127
+ }
128
+
129
+ if (issues.length > 0) {
130
+ debug('sensitive data detected in observation', {
131
+ issueCount: issues.length,
132
+ patterns: issues,
133
+ });
134
+ }
135
+
136
+ return {
137
+ hasSensitiveData: issues.length > 0,
138
+ issues,
139
+ };
140
+ }
141
+
142
+ // =============================================================================
143
+ // Redaction
144
+ // =============================================================================
145
+
146
+ /**
147
+ * Deep-clone an observation and redact all known sensitive patterns from
148
+ * string values. This operates on the JSON serialization to catch values
149
+ * regardless of nesting depth.
150
+ *
151
+ * The returned observation is safe to persist.
152
+ *
153
+ * @param observation - The observation to redact
154
+ * @returns A new observation with sensitive values replaced
155
+ */
156
+ export function redactSensitiveFields(observation: Observation): Observation {
157
+ debug('redacting sensitive fields from observation', { kind: observation.kind });
158
+ let serialized = JSON.stringify(observation);
159
+
160
+ for (const { pattern, replacement } of SENSITIVE_PATTERNS) {
161
+ // Reset lastIndex for global regex patterns
162
+ pattern.lastIndex = 0;
163
+ serialized = serialized.replace(pattern, replacement);
164
+ }
165
+
166
+ return JSON.parse(serialized) as Observation;
167
+ }