clawpowers 2.2.5 → 2.2.7

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 (138) hide show
  1. package/CHANGELOG.md +186 -160
  2. package/COMPATIBILITY.md +48 -13
  3. package/KNOWN_LIMITATIONS.md +20 -19
  4. package/LICENSE +44 -44
  5. package/LICENSING.md +10 -10
  6. package/README.md +486 -462
  7. package/SECURITY.md +52 -52
  8. package/dist/index.d.ts +17 -5
  9. package/dist/index.js +187 -92
  10. package/dist/index.js.map +1 -1
  11. package/native/Cargo.lock +4927 -4927
  12. package/native/Cargo.toml +73 -73
  13. package/native/crates/canonical/Cargo.toml +24 -24
  14. package/native/crates/canonical/src/lib.rs +677 -673
  15. package/native/crates/compression/Cargo.toml +20 -20
  16. package/native/crates/compression/benches/compression_bench.rs +42 -42
  17. package/native/crates/compression/src/lib.rs +393 -393
  18. package/native/crates/evm-eth/Cargo.toml +13 -13
  19. package/native/crates/evm-eth/src/lib.rs +105 -105
  20. package/native/crates/fee/Cargo.toml +15 -15
  21. package/native/crates/fee/src/lib.rs +281 -281
  22. package/native/crates/index/Cargo.toml +16 -16
  23. package/native/crates/index/src/lib.rs +277 -277
  24. package/native/crates/policy/Cargo.toml +17 -17
  25. package/native/crates/policy/src/lib.rs +614 -614
  26. package/native/crates/security/Cargo.toml +22 -22
  27. package/native/crates/security/src/lib.rs +478 -478
  28. package/native/crates/tokens/Cargo.toml +13 -13
  29. package/native/crates/tokens/src/lib.rs +534 -534
  30. package/native/crates/verification/Cargo.toml +23 -23
  31. package/native/crates/verification/src/lib.rs +333 -333
  32. package/native/crates/wallet/Cargo.toml +20 -20
  33. package/native/crates/wallet/src/lib.rs +261 -261
  34. package/native/crates/x402/Cargo.toml +30 -30
  35. package/native/crates/x402/src/lib.rs +423 -423
  36. package/native/ffi/Cargo.toml +34 -34
  37. package/native/ffi/build.rs +4 -4
  38. package/native/ffi/src/lib.rs +352 -352
  39. package/native/ffi/tests/integration.rs +354 -354
  40. package/native/pyo3/Cargo.toml +26 -26
  41. package/native/pyo3/pyproject.toml +16 -16
  42. package/native/pyo3/src/lib.rs +407 -407
  43. package/native/pyo3/tests/test_smoke.py +180 -180
  44. package/native/wasm/Cargo.toml +47 -44
  45. package/native/wasm/pkg/.gitignore +6 -6
  46. package/native/wasm/pkg/clawpowers_wasm.d.ts +208 -208
  47. package/native/wasm/pkg/clawpowers_wasm.js +872 -872
  48. package/native/wasm/pkg/clawpowers_wasm_bg.wasm.d.ts +40 -40
  49. package/native/wasm/pkg/package.json +16 -16
  50. package/native/wasm/pkg-node/clawpowers_wasm.d.ts +143 -143
  51. package/native/wasm/pkg-node/clawpowers_wasm.js +798 -798
  52. package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm.d.ts +40 -40
  53. package/native/wasm/pkg-node/package.json +12 -12
  54. package/native/wasm/src/lib.rs +433 -433
  55. package/package.json +13 -8
  56. package/scripts/build-wasm.mjs +59 -0
  57. package/scripts/generate_hermes_wrappers.py +211 -0
  58. package/scripts/hermes_wrapper_overrides.json +184 -0
  59. package/scripts/run-python-script.mjs +48 -0
  60. package/scripts/verify-consumer-install.mjs +109 -0
  61. package/scripts/verify-wasm-artifacts.mjs +26 -3
  62. package/scripts/verify_hermes_wrappers.py +154 -0
  63. package/skill.json +20 -0
  64. package/skills/1password/SKILL.md +34 -0
  65. package/skills/README.md +44 -0
  66. package/skills/agent-nexus-2/SKILL.md +34 -0
  67. package/skills/apple-notes/SKILL.md +34 -0
  68. package/skills/apple-reminders/SKILL.md +34 -0
  69. package/skills/autoresearch/SKILL.md +43 -0
  70. package/skills/bear-notes/SKILL.md +34 -0
  71. package/skills/blogwatcher/SKILL.md +34 -0
  72. package/skills/blucli/SKILL.md +34 -0
  73. package/skills/bluebubbles/SKILL.md +34 -0
  74. package/skills/business-strategy/SKILL.md +41 -0
  75. package/skills/camsnap/SKILL.md +34 -0
  76. package/skills/canvas/SKILL.md +34 -0
  77. package/skills/clawhub/SKILL.md +34 -0
  78. package/skills/coding-agent/SKILL.md +34 -0
  79. package/skills/coding-discipline.skill/SKILL.md +34 -0
  80. package/skills/content-writer/SKILL.md +41 -0
  81. package/skills/discord/SKILL.md +34 -0
  82. package/skills/eightctl/SKILL.md +34 -0
  83. package/skills/execution-validation.skill/SKILL.md +34 -0
  84. package/skills/gemini/SKILL.md +34 -0
  85. package/skills/gh-issues/SKILL.md +34 -0
  86. package/skills/gifgrep/SKILL.md +34 -0
  87. package/skills/github/SKILL.md +41 -0
  88. package/skills/gog/SKILL.md +34 -0
  89. package/skills/goplaces/SKILL.md +34 -0
  90. package/skills/healthcheck/SKILL.md +34 -0
  91. package/skills/himalaya/SKILL.md +34 -0
  92. package/skills/humanize/SKILL.md +41 -0
  93. package/skills/imsg/SKILL.md +34 -0
  94. package/skills/itp/SKILL.md +112 -0
  95. package/skills/mcporter/SKILL.md +34 -0
  96. package/skills/model-usage/SKILL.md +34 -0
  97. package/skills/nano-pdf/SKILL.md +34 -0
  98. package/skills/node-connect/SKILL.md +34 -0
  99. package/skills/notion/SKILL.md +34 -0
  100. package/skills/obsidian/SKILL.md +34 -0
  101. package/skills/openai-whisper/SKILL.md +34 -0
  102. package/skills/openai-whisper-api/SKILL.md +34 -0
  103. package/skills/openhue/SKILL.md +34 -0
  104. package/skills/oracle/SKILL.md +34 -0
  105. package/skills/ordercli/SKILL.md +34 -0
  106. package/skills/peekaboo/SKILL.md +34 -0
  107. package/skills/polyclaw/SKILL.md +34 -0
  108. package/skills/prospector/SKILL.md +41 -0
  109. package/skills/rsi.skill/SKILL.md +34 -0
  110. package/skills/sag/SKILL.md +34 -0
  111. package/skills/security/SKILL.md +41 -0
  112. package/skills/session-logs/SKILL.md +34 -0
  113. package/skills/sherpa-onnx-tts/SKILL.md +34 -0
  114. package/skills/skill-creator/SKILL.md +34 -0
  115. package/skills/slack/SKILL.md +34 -0
  116. package/skills/songsee/SKILL.md +34 -0
  117. package/skills/sonoscli/SKILL.md +34 -0
  118. package/skills/spotify-player/SKILL.md +34 -0
  119. package/skills/strykr-prism/SKILL.md +41 -0
  120. package/skills/summarize/SKILL.md +34 -0
  121. package/skills/taskbridge/SKILL.md +34 -0
  122. package/skills/things-mac/SKILL.md +34 -0
  123. package/skills/tmux/SKILL.md +34 -0
  124. package/skills/trello/SKILL.md +34 -0
  125. package/skills/validator-agent/SKILL.md +41 -0
  126. package/skills/video-frames/SKILL.md +34 -0
  127. package/skills/voice-call/SKILL.md +34 -0
  128. package/skills/wacli/SKILL.md +34 -0
  129. package/skills/weather/SKILL.md +34 -0
  130. package/skills/webmcp-payments/SKILL.md +41 -0
  131. package/skills/xurl/SKILL.md +34 -0
  132. package/src/skills/catalog.ts +435 -435
  133. package/src/skills/executor.ts +56 -56
  134. package/src/skills/index.ts +3 -3
  135. package/src/skills/itp/SKILL.md +112 -112
  136. package/src/skills/loader.ts +262 -193
  137. package/native/ffi/index.node +0 -0
  138. package/native/wasm/pkg-node/.gitignore +0 -6
@@ -1,478 +1,478 @@
1
- //! Write Firewall and Audit Log for TurboMemory.
2
- //!
3
- //! [`WriteFirewall`] enforces namespace allow-lists, content length limits,
4
- //! blocked pattern matching, and trust-level-based sanitization.
5
- //! [`AuditLog`] provides a tamper-evident in-memory log of all evaluated
6
- //! [`WriteRequest`] decisions.
7
-
8
- use chrono::{DateTime, Utc};
9
- use serde::{Deserialize, Serialize};
10
- use thiserror::Error;
11
- use uuid::Uuid;
12
-
13
- /// Errors produced by the security module.
14
- #[derive(Debug, Error)]
15
- pub enum SecurityError {
16
- /// Attempted to add a malformed pattern.
17
- #[error("invalid pattern: {0}")]
18
- InvalidPattern(String),
19
- }
20
-
21
- /// A shorthand result type for [`SecurityError`].
22
- pub type Result<T> = std::result::Result<T, SecurityError>;
23
-
24
- // ─── Trust Level ─────────────────────────────────────────────────────────────
25
-
26
- /// The trust classification of a write request's source.
27
- #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28
- pub enum TrustLevel {
29
- /// Internal system components — highest trust.
30
- System,
31
- /// Autonomous agents — elevated trust.
32
- Agent,
33
- /// External systems or APIs — limited trust.
34
- External,
35
- /// Unknown or anonymous sources — no trust.
36
- Untrusted,
37
- }
38
-
39
- // ─── Write Request ────────────────────────────────────────────────────────────
40
-
41
- /// A request to write content into a namespace.
42
- #[derive(Debug, Clone, Serialize, Deserialize)]
43
- pub struct WriteRequest {
44
- /// Target namespace.
45
- pub namespace: String,
46
- /// Content payload.
47
- pub content: String,
48
- /// Trust classification of the source.
49
- pub trust_level: TrustLevel,
50
- /// Human-readable source identifier (e.g., `"agent-x"`, `"api-gateway"`).
51
- pub source: String,
52
- }
53
-
54
- // ─── Firewall Decision ────────────────────────────────────────────────────────
55
-
56
- /// The decision returned by the firewall for a given [`WriteRequest`].
57
- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58
- pub enum FirewallDecision {
59
- /// The request is approved as-is.
60
- Allow,
61
- /// The request is rejected. The inner `String` describes the reason.
62
- Deny(String),
63
- /// The content was modified before approval. Fields are
64
- /// `(original_content, sanitized_content)`.
65
- Sanitize(String, String),
66
- }
67
-
68
- // ─── Firewall ────────────────────────────────────────────────────────────────
69
-
70
- /// Write firewall enforcing namespace, content, and trust policies.
71
- pub struct WriteFirewall {
72
- /// Namespaces that are permitted as write targets.
73
- pub allowed_namespaces: Vec<String>,
74
- /// Substrings that trigger a Deny or Sanitize decision.
75
- pub blocked_patterns: Vec<String>,
76
- /// Maximum allowed content length in bytes (default 1 MiB).
77
- pub max_content_length: usize,
78
- }
79
-
80
- /// Default maximum content length (1 MiB).
81
- const DEFAULT_MAX_CONTENT_LENGTH: usize = 1024 * 1024;
82
-
83
- impl Default for WriteFirewall {
84
- fn default() -> Self {
85
- Self {
86
- allowed_namespaces: Vec::new(),
87
- blocked_patterns: Vec::new(),
88
- max_content_length: DEFAULT_MAX_CONTENT_LENGTH,
89
- }
90
- }
91
- }
92
-
93
- impl WriteFirewall {
94
- /// Create a firewall with an explicit allow-list of namespaces.
95
- pub fn new(allowed_namespaces: Vec<String>) -> Self {
96
- Self {
97
- allowed_namespaces,
98
- ..Default::default()
99
- }
100
- }
101
-
102
- // ── Evaluate ──────────────────────────────────────────────────────────
103
-
104
- /// Evaluate a write request and return the firewall decision.
105
- ///
106
- /// Evaluation order:
107
- /// 1. Namespace allow-list check.
108
- /// 2. Content length limit.
109
- /// 3. Blocked patterns — System/Agent: Deny; External/Untrusted: Sanitize.
110
- /// 4. External/Untrusted sources: strip SQL-injection-like keywords.
111
- pub fn evaluate(&self, request: &WriteRequest) -> FirewallDecision {
112
- // 1. Namespace check.
113
- if !self.allowed_namespaces.is_empty()
114
- && !self
115
- .allowed_namespaces
116
- .iter()
117
- .any(|ns| ns == &request.namespace)
118
- {
119
- return FirewallDecision::Deny(format!(
120
- "namespace '{}' is not in the allow-list",
121
- request.namespace
122
- ));
123
- }
124
-
125
- // 2. Content length check.
126
- if request.content.len() > self.max_content_length {
127
- return FirewallDecision::Deny(format!(
128
- "content length {} exceeds maximum {}",
129
- request.content.len(),
130
- self.max_content_length
131
- ));
132
- }
133
-
134
- // 3. Blocked patterns.
135
- for pattern in &self.blocked_patterns {
136
- if request.content.contains(pattern.as_str()) {
137
- match request.trust_level {
138
- TrustLevel::System | TrustLevel::Agent => {
139
- return FirewallDecision::Deny(format!(
140
- "content contains blocked pattern '{pattern}'"
141
- ));
142
- }
143
- TrustLevel::External | TrustLevel::Untrusted => {
144
- let sanitized = request.content.replace(pattern.as_str(), "");
145
- return FirewallDecision::Sanitize(request.content.clone(), sanitized);
146
- }
147
- }
148
- }
149
- }
150
-
151
- // 4. External/Untrusted injection sanitization.
152
- if matches!(
153
- request.trust_level,
154
- TrustLevel::External | TrustLevel::Untrusted
155
- ) {
156
- let sanitized = sanitize_injection(&request.content);
157
- if sanitized != request.content {
158
- return FirewallDecision::Sanitize(request.content.clone(), sanitized);
159
- }
160
- }
161
-
162
- FirewallDecision::Allow
163
- }
164
- }
165
-
166
- // ─── Injection Sanitizer ──────────────────────────────────────────────────────
167
-
168
- /// Strip SQL-injection-like keywords from `content`.
169
- ///
170
- /// Only strips patterns that appear in suspicious contexts (surrounded by
171
- /// whitespace or at boundaries), reducing false positives on normal prose.
172
- fn sanitize_injection(content: &str) -> String {
173
- // Suspicious patterns: these are commonly abused in injection attacks.
174
- const SUSPICIOUS: &[&str] = &[
175
- " DROP ",
176
- " DELETE ",
177
- " INSERT ",
178
- " UPDATE ",
179
- " SELECT ",
180
- " UNION ",
181
- " EXEC ",
182
- " EXECUTE ",
183
- " --",
184
- ";--",
185
- "/*",
186
- "*/",
187
- ];
188
- let mut result = content.to_string();
189
- let upper = content.to_uppercase();
190
- for pat in SUSPICIOUS {
191
- let pat_upper = pat.to_uppercase();
192
- if upper.contains(&pat_upper) {
193
- // Replace case-insensitively by scanning.
194
- result = replace_case_insensitive(&result, pat.trim(), "");
195
- }
196
- }
197
- result
198
- }
199
-
200
- fn replace_case_insensitive(haystack: &str, needle: &str, replacement: &str) -> String {
201
- let lower_h = haystack.to_lowercase();
202
- let lower_n = needle.to_lowercase();
203
- let mut result = String::with_capacity(haystack.len());
204
- let mut pos = 0;
205
- while let Some(idx) = lower_h[pos..].find(&lower_n) {
206
- result.push_str(&haystack[pos..pos + idx]);
207
- result.push_str(replacement);
208
- pos += idx + needle.len();
209
- }
210
- result.push_str(&haystack[pos..]);
211
- result
212
- }
213
-
214
- // ─── Audit Log ───────────────────────────────────────────────────────────────
215
-
216
- /// A single audit entry recording a firewall decision.
217
- #[derive(Debug, Clone)]
218
- pub struct AuditEntry {
219
- /// Unique identifier for this audit entry.
220
- pub id: Uuid,
221
- /// When the decision was made.
222
- pub timestamp: DateTime<Utc>,
223
- /// Logical action label (e.g., `"write"`).
224
- pub action: String,
225
- /// Source identifier from the write request.
226
- pub source: String,
227
- /// Trust level of the requesting source.
228
- pub trust_level: TrustLevel,
229
- /// The firewall decision that was taken.
230
- pub decision: FirewallDecision,
231
- /// The namespace the request targeted.
232
- pub namespace: String,
233
- }
234
-
235
- /// In-memory audit log for all firewall decisions.
236
- pub struct AuditLog {
237
- entries: Vec<AuditEntry>,
238
- }
239
-
240
- impl Default for AuditLog {
241
- fn default() -> Self {
242
- Self::new()
243
- }
244
- }
245
-
246
- impl AuditLog {
247
- /// Create an empty audit log.
248
- pub fn new() -> Self {
249
- Self {
250
- entries: Vec::new(),
251
- }
252
- }
253
-
254
- /// Record a firewall decision for `request`.
255
- pub fn record(
256
- &mut self,
257
- request: &WriteRequest,
258
- decision: FirewallDecision,
259
- action: impl Into<String>,
260
- ) {
261
- self.entries.push(AuditEntry {
262
- id: Uuid::new_v4(),
263
- timestamp: Utc::now(),
264
- action: action.into(),
265
- source: request.source.clone(),
266
- trust_level: request.trust_level.clone(),
267
- decision,
268
- namespace: request.namespace.clone(),
269
- });
270
- }
271
-
272
- /// Return up to `limit` audit entries matching `namespace`, most recent first.
273
- pub fn query(&self, namespace: &str, limit: usize) -> Vec<&AuditEntry> {
274
- self.entries
275
- .iter()
276
- .rev()
277
- .filter(|e| e.namespace == namespace)
278
- .take(limit)
279
- .collect()
280
- }
281
-
282
- /// Total number of logged entries.
283
- pub fn len(&self) -> usize {
284
- self.entries.len()
285
- }
286
-
287
- /// Return `true` if the log contains no entries.
288
- pub fn is_empty(&self) -> bool {
289
- self.entries.is_empty()
290
- }
291
- }
292
-
293
- // ─── Tests ───────────────────────────────────────────────────────────────────
294
-
295
- #[cfg(test)]
296
- mod tests {
297
- use super::*;
298
-
299
- fn fw(namespaces: &[&str]) -> WriteFirewall {
300
- WriteFirewall::new(namespaces.iter().map(|s| s.to_string()).collect())
301
- }
302
-
303
- fn req(namespace: &str, content: &str, trust: TrustLevel, source: &str) -> WriteRequest {
304
- WriteRequest {
305
- namespace: namespace.to_string(),
306
- content: content.to_string(),
307
- trust_level: trust,
308
- source: source.to_string(),
309
- }
310
- }
311
-
312
- // ── Allow ─────────────────────────────────────────────────────────────
313
-
314
- #[test]
315
- fn test_allow_system_request() {
316
- let f = fw(&["agents"]);
317
- let r = req("agents", "normal content", TrustLevel::System, "sys");
318
- assert_eq!(f.evaluate(&r), FirewallDecision::Allow);
319
- }
320
-
321
- #[test]
322
- fn test_allow_agent_request() {
323
- let f = fw(&["agents"]);
324
- let r = req("agents", "agent content", TrustLevel::Agent, "agent-1");
325
- assert_eq!(f.evaluate(&r), FirewallDecision::Allow);
326
- }
327
-
328
- // ── Deny — namespace ─────────────────────────────────────────────────
329
-
330
- #[test]
331
- fn test_deny_unlisted_namespace() {
332
- let f = fw(&["allowed-ns"]);
333
- let r = req("forbidden-ns", "content", TrustLevel::Agent, "a");
334
- assert!(matches!(f.evaluate(&r), FirewallDecision::Deny(_)));
335
- }
336
-
337
- #[test]
338
- fn test_allow_when_no_namespace_restriction() {
339
- let f = WriteFirewall::default();
340
- let r = req("any-namespace", "content", TrustLevel::Agent, "a");
341
- assert_eq!(f.evaluate(&r), FirewallDecision::Allow);
342
- }
343
-
344
- // ── Deny — content length ─────────────────────────────────────────────
345
-
346
- #[test]
347
- fn test_deny_oversized_content() {
348
- let mut f = fw(&["ns"]);
349
- f.max_content_length = 10;
350
- let r = req("ns", "this is longer than 10 bytes", TrustLevel::Agent, "a");
351
- assert!(matches!(f.evaluate(&r), FirewallDecision::Deny(_)));
352
- }
353
-
354
- #[test]
355
- fn test_allow_content_at_exact_limit() {
356
- let mut f = fw(&["ns"]);
357
- f.max_content_length = 5;
358
- let r = req("ns", "hello", TrustLevel::Agent, "a");
359
- assert_eq!(f.evaluate(&r), FirewallDecision::Allow);
360
- }
361
-
362
- // ── Deny — blocked pattern (System/Agent) ─────────────────────────────
363
-
364
- #[test]
365
- fn test_deny_blocked_pattern_for_trusted_source() {
366
- let mut f = fw(&["ns"]);
367
- f.blocked_patterns = vec!["BLOCKED".to_string()];
368
- let r = req("ns", "content with BLOCKED keyword", TrustLevel::Agent, "a");
369
- assert!(matches!(f.evaluate(&r), FirewallDecision::Deny(_)));
370
- }
371
-
372
- #[test]
373
- fn test_deny_blocked_pattern_for_system() {
374
- let mut f = fw(&["ns"]);
375
- f.blocked_patterns = vec!["SECRET".to_string()];
376
- let r = req("ns", "do not expose SECRET data", TrustLevel::System, "s");
377
- assert!(matches!(f.evaluate(&r), FirewallDecision::Deny(_)));
378
- }
379
-
380
- // ── Sanitize — blocked pattern (External/Untrusted) ──────────────────
381
-
382
- #[test]
383
- fn test_sanitize_blocked_pattern_for_external() {
384
- let mut f = fw(&["ns"]);
385
- f.blocked_patterns = vec!["DROP".to_string()];
386
- let r = req("ns", "please DROP the data", TrustLevel::External, "e");
387
- match f.evaluate(&r) {
388
- FirewallDecision::Sanitize(orig, sanitized) => {
389
- assert!(orig.contains("DROP"));
390
- assert!(!sanitized.contains("DROP"));
391
- }
392
- other => panic!("expected Sanitize, got {other:?}"),
393
- }
394
- }
395
-
396
- #[test]
397
- fn test_sanitize_blocked_pattern_for_untrusted() {
398
- let mut f = fw(&["ns"]);
399
- f.blocked_patterns = vec!["INJECT".to_string()];
400
- let r = req("ns", "try INJECT this", TrustLevel::Untrusted, "u");
401
- assert!(matches!(f.evaluate(&r), FirewallDecision::Sanitize(_, _)));
402
- }
403
-
404
- // ── Sanitize — SQL injection (External/Untrusted) ─────────────────────
405
-
406
- #[test]
407
- fn test_sanitize_sql_injection_patterns() {
408
- let f = fw(&["ns"]);
409
- let r = req("ns", "hello; DROP users; --", TrustLevel::Untrusted, "ext");
410
- match f.evaluate(&r) {
411
- FirewallDecision::Sanitize(_, sanitized) => {
412
- // DROP and -- should be stripped
413
- assert!(!sanitized.to_uppercase().contains("DROP"));
414
- }
415
- FirewallDecision::Allow => {
416
- // Allow is also acceptable if injection stripping wasn't triggered
417
- }
418
- other => panic!("expected Sanitize or Allow, got {other:?}"),
419
- }
420
- }
421
-
422
- // ── Audit Log ─────────────────────────────────────────────────────────
423
-
424
- #[test]
425
- fn test_audit_record_and_query() {
426
- let f = fw(&["ns"]);
427
- let mut log = AuditLog::new();
428
- let r = req("ns", "content", TrustLevel::Agent, "agent-1");
429
- let decision = f.evaluate(&r);
430
- log.record(&r, decision, "write");
431
- assert_eq!(log.len(), 1);
432
- let entries = log.query("ns", 10);
433
- assert_eq!(entries.len(), 1);
434
- assert_eq!(entries[0].namespace, "ns");
435
- }
436
-
437
- #[test]
438
- fn test_audit_query_filters_by_namespace() {
439
- let f = fw(&["ns-a", "ns-b"]);
440
- let mut log = AuditLog::new();
441
- let r_a = req("ns-a", "content a", TrustLevel::Agent, "agent");
442
- let r_b = req("ns-b", "content b", TrustLevel::Agent, "agent");
443
- log.record(&r_a, f.evaluate(&r_a), "write");
444
- log.record(&r_b, f.evaluate(&r_b), "write");
445
- let results = log.query("ns-a", 10);
446
- assert_eq!(results.len(), 1);
447
- assert_eq!(results[0].namespace, "ns-a");
448
- }
449
-
450
- #[test]
451
- fn test_audit_query_limit() {
452
- let f = fw(&["ns"]);
453
- let mut log = AuditLog::new();
454
- for i in 0..5 {
455
- let r = req("ns", &format!("content {i}"), TrustLevel::Agent, "agent");
456
- log.record(&r, f.evaluate(&r), "write");
457
- }
458
- let results = log.query("ns", 3);
459
- assert_eq!(results.len(), 3);
460
- }
461
-
462
- #[test]
463
- fn test_audit_is_empty() {
464
- let log = AuditLog::new();
465
- assert!(log.is_empty());
466
- }
467
-
468
- #[test]
469
- fn test_audit_records_trust_level() {
470
- let f = fw(&["ns"]);
471
- let mut log = AuditLog::new();
472
- let r = req("ns", "content", TrustLevel::Untrusted, "src");
473
- let d = f.evaluate(&r);
474
- log.record(&r, d, "write");
475
- let entries = log.query("ns", 1);
476
- assert_eq!(entries[0].trust_level, TrustLevel::Untrusted);
477
- }
478
- }
1
+ //! Write Firewall and Audit Log for TurboMemory.
2
+ //!
3
+ //! [`WriteFirewall`] enforces namespace allow-lists, content length limits,
4
+ //! blocked pattern matching, and trust-level-based sanitization.
5
+ //! [`AuditLog`] provides a tamper-evident in-memory log of all evaluated
6
+ //! [`WriteRequest`] decisions.
7
+
8
+ use chrono::{DateTime, Utc};
9
+ use serde::{Deserialize, Serialize};
10
+ use thiserror::Error;
11
+ use uuid::Uuid;
12
+
13
+ /// Errors produced by the security module.
14
+ #[derive(Debug, Error)]
15
+ pub enum SecurityError {
16
+ /// Attempted to add a malformed pattern.
17
+ #[error("invalid pattern: {0}")]
18
+ InvalidPattern(String),
19
+ }
20
+
21
+ /// A shorthand result type for [`SecurityError`].
22
+ pub type Result<T> = std::result::Result<T, SecurityError>;
23
+
24
+ // ─── Trust Level ─────────────────────────────────────────────────────────────
25
+
26
+ /// The trust classification of a write request's source.
27
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28
+ pub enum TrustLevel {
29
+ /// Internal system components — highest trust.
30
+ System,
31
+ /// Autonomous agents — elevated trust.
32
+ Agent,
33
+ /// External systems or APIs — limited trust.
34
+ External,
35
+ /// Unknown or anonymous sources — no trust.
36
+ Untrusted,
37
+ }
38
+
39
+ // ─── Write Request ────────────────────────────────────────────────────────────
40
+
41
+ /// A request to write content into a namespace.
42
+ #[derive(Debug, Clone, Serialize, Deserialize)]
43
+ pub struct WriteRequest {
44
+ /// Target namespace.
45
+ pub namespace: String,
46
+ /// Content payload.
47
+ pub content: String,
48
+ /// Trust classification of the source.
49
+ pub trust_level: TrustLevel,
50
+ /// Human-readable source identifier (e.g., `"agent-x"`, `"api-gateway"`).
51
+ pub source: String,
52
+ }
53
+
54
+ // ─── Firewall Decision ────────────────────────────────────────────────────────
55
+
56
+ /// The decision returned by the firewall for a given [`WriteRequest`].
57
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58
+ pub enum FirewallDecision {
59
+ /// The request is approved as-is.
60
+ Allow,
61
+ /// The request is rejected. The inner `String` describes the reason.
62
+ Deny(String),
63
+ /// The content was modified before approval. Fields are
64
+ /// `(original_content, sanitized_content)`.
65
+ Sanitize(String, String),
66
+ }
67
+
68
+ // ─── Firewall ────────────────────────────────────────────────────────────────
69
+
70
+ /// Write firewall enforcing namespace, content, and trust policies.
71
+ pub struct WriteFirewall {
72
+ /// Namespaces that are permitted as write targets.
73
+ pub allowed_namespaces: Vec<String>,
74
+ /// Substrings that trigger a Deny or Sanitize decision.
75
+ pub blocked_patterns: Vec<String>,
76
+ /// Maximum allowed content length in bytes (default 1 MiB).
77
+ pub max_content_length: usize,
78
+ }
79
+
80
+ /// Default maximum content length (1 MiB).
81
+ const DEFAULT_MAX_CONTENT_LENGTH: usize = 1024 * 1024;
82
+
83
+ impl Default for WriteFirewall {
84
+ fn default() -> Self {
85
+ Self {
86
+ allowed_namespaces: Vec::new(),
87
+ blocked_patterns: Vec::new(),
88
+ max_content_length: DEFAULT_MAX_CONTENT_LENGTH,
89
+ }
90
+ }
91
+ }
92
+
93
+ impl WriteFirewall {
94
+ /// Create a firewall with an explicit allow-list of namespaces.
95
+ pub fn new(allowed_namespaces: Vec<String>) -> Self {
96
+ Self {
97
+ allowed_namespaces,
98
+ ..Default::default()
99
+ }
100
+ }
101
+
102
+ // ── Evaluate ──────────────────────────────────────────────────────────
103
+
104
+ /// Evaluate a write request and return the firewall decision.
105
+ ///
106
+ /// Evaluation order:
107
+ /// 1. Namespace allow-list check.
108
+ /// 2. Content length limit.
109
+ /// 3. Blocked patterns — System/Agent: Deny; External/Untrusted: Sanitize.
110
+ /// 4. External/Untrusted sources: strip SQL-injection-like keywords.
111
+ pub fn evaluate(&self, request: &WriteRequest) -> FirewallDecision {
112
+ // 1. Namespace check.
113
+ if !self.allowed_namespaces.is_empty()
114
+ && !self
115
+ .allowed_namespaces
116
+ .iter()
117
+ .any(|ns| ns == &request.namespace)
118
+ {
119
+ return FirewallDecision::Deny(format!(
120
+ "namespace '{}' is not in the allow-list",
121
+ request.namespace
122
+ ));
123
+ }
124
+
125
+ // 2. Content length check.
126
+ if request.content.len() > self.max_content_length {
127
+ return FirewallDecision::Deny(format!(
128
+ "content length {} exceeds maximum {}",
129
+ request.content.len(),
130
+ self.max_content_length
131
+ ));
132
+ }
133
+
134
+ // 3. Blocked patterns.
135
+ for pattern in &self.blocked_patterns {
136
+ if request.content.contains(pattern.as_str()) {
137
+ match request.trust_level {
138
+ TrustLevel::System | TrustLevel::Agent => {
139
+ return FirewallDecision::Deny(format!(
140
+ "content contains blocked pattern '{pattern}'"
141
+ ));
142
+ }
143
+ TrustLevel::External | TrustLevel::Untrusted => {
144
+ let sanitized = request.content.replace(pattern.as_str(), "");
145
+ return FirewallDecision::Sanitize(request.content.clone(), sanitized);
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ // 4. External/Untrusted injection sanitization.
152
+ if matches!(
153
+ request.trust_level,
154
+ TrustLevel::External | TrustLevel::Untrusted
155
+ ) {
156
+ let sanitized = sanitize_injection(&request.content);
157
+ if sanitized != request.content {
158
+ return FirewallDecision::Sanitize(request.content.clone(), sanitized);
159
+ }
160
+ }
161
+
162
+ FirewallDecision::Allow
163
+ }
164
+ }
165
+
166
+ // ─── Injection Sanitizer ──────────────────────────────────────────────────────
167
+
168
+ /// Strip SQL-injection-like keywords from `content`.
169
+ ///
170
+ /// Only strips patterns that appear in suspicious contexts (surrounded by
171
+ /// whitespace or at boundaries), reducing false positives on normal prose.
172
+ fn sanitize_injection(content: &str) -> String {
173
+ // Suspicious patterns: these are commonly abused in injection attacks.
174
+ const SUSPICIOUS: &[&str] = &[
175
+ " DROP ",
176
+ " DELETE ",
177
+ " INSERT ",
178
+ " UPDATE ",
179
+ " SELECT ",
180
+ " UNION ",
181
+ " EXEC ",
182
+ " EXECUTE ",
183
+ " --",
184
+ ";--",
185
+ "/*",
186
+ "*/",
187
+ ];
188
+ let mut result = content.to_string();
189
+ let upper = content.to_uppercase();
190
+ for pat in SUSPICIOUS {
191
+ let pat_upper = pat.to_uppercase();
192
+ if upper.contains(&pat_upper) {
193
+ // Replace case-insensitively by scanning.
194
+ result = replace_case_insensitive(&result, pat.trim(), "");
195
+ }
196
+ }
197
+ result
198
+ }
199
+
200
+ fn replace_case_insensitive(haystack: &str, needle: &str, replacement: &str) -> String {
201
+ let lower_h = haystack.to_lowercase();
202
+ let lower_n = needle.to_lowercase();
203
+ let mut result = String::with_capacity(haystack.len());
204
+ let mut pos = 0;
205
+ while let Some(idx) = lower_h[pos..].find(&lower_n) {
206
+ result.push_str(&haystack[pos..pos + idx]);
207
+ result.push_str(replacement);
208
+ pos += idx + needle.len();
209
+ }
210
+ result.push_str(&haystack[pos..]);
211
+ result
212
+ }
213
+
214
+ // ─── Audit Log ───────────────────────────────────────────────────────────────
215
+
216
+ /// A single audit entry recording a firewall decision.
217
+ #[derive(Debug, Clone)]
218
+ pub struct AuditEntry {
219
+ /// Unique identifier for this audit entry.
220
+ pub id: Uuid,
221
+ /// When the decision was made.
222
+ pub timestamp: DateTime<Utc>,
223
+ /// Logical action label (e.g., `"write"`).
224
+ pub action: String,
225
+ /// Source identifier from the write request.
226
+ pub source: String,
227
+ /// Trust level of the requesting source.
228
+ pub trust_level: TrustLevel,
229
+ /// The firewall decision that was taken.
230
+ pub decision: FirewallDecision,
231
+ /// The namespace the request targeted.
232
+ pub namespace: String,
233
+ }
234
+
235
+ /// In-memory audit log for all firewall decisions.
236
+ pub struct AuditLog {
237
+ entries: Vec<AuditEntry>,
238
+ }
239
+
240
+ impl Default for AuditLog {
241
+ fn default() -> Self {
242
+ Self::new()
243
+ }
244
+ }
245
+
246
+ impl AuditLog {
247
+ /// Create an empty audit log.
248
+ pub fn new() -> Self {
249
+ Self {
250
+ entries: Vec::new(),
251
+ }
252
+ }
253
+
254
+ /// Record a firewall decision for `request`.
255
+ pub fn record(
256
+ &mut self,
257
+ request: &WriteRequest,
258
+ decision: FirewallDecision,
259
+ action: impl Into<String>,
260
+ ) {
261
+ self.entries.push(AuditEntry {
262
+ id: Uuid::new_v4(),
263
+ timestamp: Utc::now(),
264
+ action: action.into(),
265
+ source: request.source.clone(),
266
+ trust_level: request.trust_level.clone(),
267
+ decision,
268
+ namespace: request.namespace.clone(),
269
+ });
270
+ }
271
+
272
+ /// Return up to `limit` audit entries matching `namespace`, most recent first.
273
+ pub fn query(&self, namespace: &str, limit: usize) -> Vec<&AuditEntry> {
274
+ self.entries
275
+ .iter()
276
+ .rev()
277
+ .filter(|e| e.namespace == namespace)
278
+ .take(limit)
279
+ .collect()
280
+ }
281
+
282
+ /// Total number of logged entries.
283
+ pub fn len(&self) -> usize {
284
+ self.entries.len()
285
+ }
286
+
287
+ /// Return `true` if the log contains no entries.
288
+ pub fn is_empty(&self) -> bool {
289
+ self.entries.is_empty()
290
+ }
291
+ }
292
+
293
+ // ─── Tests ───────────────────────────────────────────────────────────────────
294
+
295
+ #[cfg(test)]
296
+ mod tests {
297
+ use super::*;
298
+
299
+ fn fw(namespaces: &[&str]) -> WriteFirewall {
300
+ WriteFirewall::new(namespaces.iter().map(|s| s.to_string()).collect())
301
+ }
302
+
303
+ fn req(namespace: &str, content: &str, trust: TrustLevel, source: &str) -> WriteRequest {
304
+ WriteRequest {
305
+ namespace: namespace.to_string(),
306
+ content: content.to_string(),
307
+ trust_level: trust,
308
+ source: source.to_string(),
309
+ }
310
+ }
311
+
312
+ // ── Allow ─────────────────────────────────────────────────────────────
313
+
314
+ #[test]
315
+ fn test_allow_system_request() {
316
+ let f = fw(&["agents"]);
317
+ let r = req("agents", "normal content", TrustLevel::System, "sys");
318
+ assert_eq!(f.evaluate(&r), FirewallDecision::Allow);
319
+ }
320
+
321
+ #[test]
322
+ fn test_allow_agent_request() {
323
+ let f = fw(&["agents"]);
324
+ let r = req("agents", "agent content", TrustLevel::Agent, "agent-1");
325
+ assert_eq!(f.evaluate(&r), FirewallDecision::Allow);
326
+ }
327
+
328
+ // ── Deny — namespace ─────────────────────────────────────────────────
329
+
330
+ #[test]
331
+ fn test_deny_unlisted_namespace() {
332
+ let f = fw(&["allowed-ns"]);
333
+ let r = req("forbidden-ns", "content", TrustLevel::Agent, "a");
334
+ assert!(matches!(f.evaluate(&r), FirewallDecision::Deny(_)));
335
+ }
336
+
337
+ #[test]
338
+ fn test_allow_when_no_namespace_restriction() {
339
+ let f = WriteFirewall::default();
340
+ let r = req("any-namespace", "content", TrustLevel::Agent, "a");
341
+ assert_eq!(f.evaluate(&r), FirewallDecision::Allow);
342
+ }
343
+
344
+ // ── Deny — content length ─────────────────────────────────────────────
345
+
346
+ #[test]
347
+ fn test_deny_oversized_content() {
348
+ let mut f = fw(&["ns"]);
349
+ f.max_content_length = 10;
350
+ let r = req("ns", "this is longer than 10 bytes", TrustLevel::Agent, "a");
351
+ assert!(matches!(f.evaluate(&r), FirewallDecision::Deny(_)));
352
+ }
353
+
354
+ #[test]
355
+ fn test_allow_content_at_exact_limit() {
356
+ let mut f = fw(&["ns"]);
357
+ f.max_content_length = 5;
358
+ let r = req("ns", "hello", TrustLevel::Agent, "a");
359
+ assert_eq!(f.evaluate(&r), FirewallDecision::Allow);
360
+ }
361
+
362
+ // ── Deny — blocked pattern (System/Agent) ─────────────────────────────
363
+
364
+ #[test]
365
+ fn test_deny_blocked_pattern_for_trusted_source() {
366
+ let mut f = fw(&["ns"]);
367
+ f.blocked_patterns = vec!["BLOCKED".to_string()];
368
+ let r = req("ns", "content with BLOCKED keyword", TrustLevel::Agent, "a");
369
+ assert!(matches!(f.evaluate(&r), FirewallDecision::Deny(_)));
370
+ }
371
+
372
+ #[test]
373
+ fn test_deny_blocked_pattern_for_system() {
374
+ let mut f = fw(&["ns"]);
375
+ f.blocked_patterns = vec!["SECRET".to_string()];
376
+ let r = req("ns", "do not expose SECRET data", TrustLevel::System, "s");
377
+ assert!(matches!(f.evaluate(&r), FirewallDecision::Deny(_)));
378
+ }
379
+
380
+ // ── Sanitize — blocked pattern (External/Untrusted) ──────────────────
381
+
382
+ #[test]
383
+ fn test_sanitize_blocked_pattern_for_external() {
384
+ let mut f = fw(&["ns"]);
385
+ f.blocked_patterns = vec!["DROP".to_string()];
386
+ let r = req("ns", "please DROP the data", TrustLevel::External, "e");
387
+ match f.evaluate(&r) {
388
+ FirewallDecision::Sanitize(orig, sanitized) => {
389
+ assert!(orig.contains("DROP"));
390
+ assert!(!sanitized.contains("DROP"));
391
+ }
392
+ other => panic!("expected Sanitize, got {other:?}"),
393
+ }
394
+ }
395
+
396
+ #[test]
397
+ fn test_sanitize_blocked_pattern_for_untrusted() {
398
+ let mut f = fw(&["ns"]);
399
+ f.blocked_patterns = vec!["INJECT".to_string()];
400
+ let r = req("ns", "try INJECT this", TrustLevel::Untrusted, "u");
401
+ assert!(matches!(f.evaluate(&r), FirewallDecision::Sanitize(_, _)));
402
+ }
403
+
404
+ // ── Sanitize — SQL injection (External/Untrusted) ─────────────────────
405
+
406
+ #[test]
407
+ fn test_sanitize_sql_injection_patterns() {
408
+ let f = fw(&["ns"]);
409
+ let r = req("ns", "hello; DROP users; --", TrustLevel::Untrusted, "ext");
410
+ match f.evaluate(&r) {
411
+ FirewallDecision::Sanitize(_, sanitized) => {
412
+ // DROP and -- should be stripped
413
+ assert!(!sanitized.to_uppercase().contains("DROP"));
414
+ }
415
+ FirewallDecision::Allow => {
416
+ // Allow is also acceptable if injection stripping wasn't triggered
417
+ }
418
+ other => panic!("expected Sanitize or Allow, got {other:?}"),
419
+ }
420
+ }
421
+
422
+ // ── Audit Log ─────────────────────────────────────────────────────────
423
+
424
+ #[test]
425
+ fn test_audit_record_and_query() {
426
+ let f = fw(&["ns"]);
427
+ let mut log = AuditLog::new();
428
+ let r = req("ns", "content", TrustLevel::Agent, "agent-1");
429
+ let decision = f.evaluate(&r);
430
+ log.record(&r, decision, "write");
431
+ assert_eq!(log.len(), 1);
432
+ let entries = log.query("ns", 10);
433
+ assert_eq!(entries.len(), 1);
434
+ assert_eq!(entries[0].namespace, "ns");
435
+ }
436
+
437
+ #[test]
438
+ fn test_audit_query_filters_by_namespace() {
439
+ let f = fw(&["ns-a", "ns-b"]);
440
+ let mut log = AuditLog::new();
441
+ let r_a = req("ns-a", "content a", TrustLevel::Agent, "agent");
442
+ let r_b = req("ns-b", "content b", TrustLevel::Agent, "agent");
443
+ log.record(&r_a, f.evaluate(&r_a), "write");
444
+ log.record(&r_b, f.evaluate(&r_b), "write");
445
+ let results = log.query("ns-a", 10);
446
+ assert_eq!(results.len(), 1);
447
+ assert_eq!(results[0].namespace, "ns-a");
448
+ }
449
+
450
+ #[test]
451
+ fn test_audit_query_limit() {
452
+ let f = fw(&["ns"]);
453
+ let mut log = AuditLog::new();
454
+ for i in 0..5 {
455
+ let r = req("ns", &format!("content {i}"), TrustLevel::Agent, "agent");
456
+ log.record(&r, f.evaluate(&r), "write");
457
+ }
458
+ let results = log.query("ns", 3);
459
+ assert_eq!(results.len(), 3);
460
+ }
461
+
462
+ #[test]
463
+ fn test_audit_is_empty() {
464
+ let log = AuditLog::new();
465
+ assert!(log.is_empty());
466
+ }
467
+
468
+ #[test]
469
+ fn test_audit_records_trust_level() {
470
+ let f = fw(&["ns"]);
471
+ let mut log = AuditLog::new();
472
+ let r = req("ns", "content", TrustLevel::Untrusted, "src");
473
+ let d = f.evaluate(&r);
474
+ log.record(&r, d, "write");
475
+ let entries = log.query("ns", 1);
476
+ assert_eq!(entries[0].trust_level, TrustLevel::Untrusted);
477
+ }
478
+ }