Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 51 additions & 140 deletions .claude/skills/security/content.md
Original file line number Diff line number Diff line change
@@ -1,180 +1,91 @@
# Skill: security — BaseMyAI
# Skill: security — BaseMyAI (moteur natif, ADR-033)

## Surface d'attaque principale

BaseMyAI est un moteur de mémoire local. Les vecteurs d'attaque réalistes sont :
BaseMyAI est un moteur de mémoire **local**. Vecteurs réalistes :

1. **Injection SQL** via `agent_id` ou contenu fourni par un agent externe
2. **Exfiltration de clé de chiffrement** (libSQL crypto)
3. **Contournement d'isolation** entre agents (mauvais scope `agent_id`)
4. **Timing attacks** sur la comparaison de tokens (MCP auth)
5. **Auto-download silencieux** de modèles (réseau non consenti)
6. **Logging de contenu** dans les traces d'audit
1. **Contournement d'isolation** entre `agent_id` (fuite cross-tenant)
2. **Memory poisoning** — contenu hostile rappelé dans le contexte LLM
3. **Exfiltration de passphrase** (logs, config, disque)
4. **Store corrompu** — panic ou lecture silencieuse sur `.bmai` malveillant
5. **REST/MCP exposés** sans auth ou hors loopback
6. **Auto-download silencieux** de modèles (réseau non consenti)

---

## Anti-SQL injection : `Filter` paramétré (ADR-006)
Docs détaillées : `docs/security/` et `SECURITY.md`.

**Règle absolue** : tout input externe (agent_id, texte utilisateur, query) va dans `params`, jamais interpolé.
---

```rust
// BON — paramètres liés
let filter = Filter {
sql: "agent_id = ?1 AND valid_until IS NULL".into(),
params: vec![Value::Text(agent_id.as_str().to_string())],
};

// MAUVAIS — injection possible
let filter = Filter {
sql: format!("agent_id = '{}'", agent_id.as_str()), // DANGEREUX
params: vec![],
};
```
## Isolation multi-agent (ADR-006)

`AgentId` est un **newtype** `(String)` non-`Clone` public. Son constructeur valide le format (non-vide, pas d'espace). Cela empêche d'utiliser une string brute là où un `AgentId` est attendu.
**Invariant** : chaque opération storage est scellée par `AgentId` au niveau moteur
(préfixes KV natifs), pas seulement dans la façade `Memory`.

```rust
// basemyai/src/memory/isolation.rs
pub struct AgentId(String);

impl AgentId {
pub fn new(id: &str) -> Result<Self, MemoryError> {
if id.trim().is_empty() {
return Err(MemoryError::InvalidAgentId);
}
Ok(Self(id.to_string()))
}
pub fn as_str(&self) -> &str { &self.0 }
}
```

---
// BON — AgentId newtype, jamais de concat SQL
let agent = AgentId::new("tenant-a")?;
memory.remember("fact", layer).await?;

## Anti-timing attack : comparaison constante (MCP HTTP auth)

```rust
// basemyai-mcp/src/auth.rs
use subtle::ConstantTimeEq;

impl BearerAuthService {
fn verify(&self, token: &str) -> bool {
let expected = self.api_key.as_bytes();
let provided = token.as_bytes();
// Longueur différente → false en temps constant
expected.ct_eq(provided).into()
}
}
// MAUVAIS — string brute là où AgentId est attendu
```

**Ne jamais** comparer des tokens avec `==` ou `starts_with` — observable par timing.
Tests adversariaux CI : `p1_isolation_adversarial`, `export_isolation_adversarial`,
`isolation_recall_graph_adversarial`.

---

## Chiffrement libSQL (ADR-007)
## Chiffrement natif (ADR-030)

```rust
// basemyai EXIGE une clé — ne peut pas ouvrir sans
pub struct Memory {
store: Arc<Store>,
agent: AgentId,
enc_key: EncryptionKey, // obligatoire
}

impl Memory {
pub async fn open(
path: &Path,
agent_id: AgentId,
key: EncryptionKey, // pas Option<>
) -> Result<Self, MemoryError> { ... }

// Test-only (feature "test-util") — sans chiffrement
#[cfg(feature = "test-util")]
pub async fn open_in_memory(agent_id: &str) -> Result<Self, MemoryError> { ... }
}
// Production — toujours chiffré
NativeMemoryStore::open_encrypted(path, key)?;

// Clair — test-util uniquement
#[cfg(feature = "test-util")]
NativeMemoryStore::open(path)?;
```

**`basemyai-core`** : chiffrement optionnel (`Option<EncryptionKey>`).
**`basemyai`** : chiffrement **obligatoire** — refus explicite si pas de clé.
Passphrase : `EncryptionKey::resolve()` (ADR-034). Jamais dans `config.toml`.
`Debug` masqué. Rotation : `rotate_key` (re-wrap DEK O(1)).

Erreurs stables : `WRONG_ENCRYPTION_KEY`, `ENCRYPTION_KEY_REQUIRED`, etc.

---

## Audit MCP — ne jamais logger le contenu
## Memory poisoning (ADR-035)

```rust
// basemyai-mcp/src/audit.rs
pub fn emit_audit(tool: &str, agent_id: &str, outcome: Outcome, time_ms: u64) {
tracing::info!(
tool = tool,
agent_id = agent_id,
outcome = %outcome,
time_ms = time_ms,
"mcp_audit"
);
// INTERDIT dans cet appel :
// - le texte mémorisé
// - les vecteurs
// - les résultats de recall
// - toute PII
}
```
- `recall()` **exclut** `MemoryLayer::Procedural` par défaut.
- Opt-in : `RecallOptions { include_procedural: true }`.
- `Record.source` pour la provenance.
- Import JSONL : `--trusted` requis pour lignes procedural.

---

## Isolation multi-agent

Chaque `Memory` est construite avec un `AgentId` — toutes les requêtes SQL incluent `WHERE agent_id = ?`. Un agent ne peut pas accéder aux mémoires d'un autre.
## REST / MCP

```sql
-- Toutes les requêtes de recall incluent ce filtre
SELECT id, content, vec_distance_cosine(embedding, ?) AS score
FROM memories
WHERE agent_id = ?1
AND (valid_until IS NULL OR valid_until > unixepoch())
ORDER BY score
LIMIT ?
```
- REST : bind `127.0.0.1` par défaut ; Bearer obligatoire sauf `dev` + loopback.
- `Config::validate()` refuse `dev=true` + bind public.
- MCP HTTP : Bearer comparé en temps constant (`subtle`).

---

## Zéro réseau dans la lib (ADR-010)
## Moteur natif — formats

```rust
// INTERDIT dans basemyai-core et basemyai
impl CandleEmbedder {
// NON — téléchargement silencieux
pub fn new_auto() -> Result<Self> {
download_model_if_needed()?; // INTERDIT
...
}

// OUI — chemin fourni par l'appelant (setup::provision)
pub fn from_path(model_path: &Path, device: Device) -> Result<Self> { ... }
}
```

Le réseau est **uniquement** dans `basemyai/provision/embedder.rs` et `provision/llm.rs`, derrière un consentement explicite (`consent: bool`).
- WAL/SST : CRC32 + AEAD ; `MAX_BATCH_OPS` sur decode batch.
- Fuzz nightly : `crates/basemyai-engine/fuzz/` (Linux/WSL, pas Windows MSVC).
- `format.lock` en CI.

---

## Checklist de review sécurité
## Zero network

| Point | Check |
|-------|-------|
| Input externe dans SQL | Passe par `Filter.params`, jamais interpolé |
| Comparaison de token | `subtle::ConstantTimeEq`, jamais `==` |
| Chiffrement basemyai | `EncryptionKey` obligatoire (pas `Option`) |
| AgentId | Construit via `AgentId::new()` validé |
| Audit log | Contient outil + outcome + durée, JAMAIS le contenu |
| Réseau en lib | Aucun `reqwest`/`ureq`/`hyper` dans basemyai-core ou basemyai |
| `static mut` | Interdit — utiliser `OnceLock`/`RwLock` |
| Secrets dans les erreurs | Erreurs ne contiennent pas de clés ou de vecteurs |
Après setup explicite du modèle, `remember`/`recall`/graphe n'ouvrent pas de socket.
Test CI : `zero_network_recall`, `provision_without_consent_fails_when_model_absent`.

---

## Vecteurs d'attaque spécifiques MCP

Le serveur MCP écoute des connexions de clients (agents LLM externes). Risques :
## Checklist avant commit touchant la sécurité

- **Prompt injection via le contenu mémorisé** : le contenu rappelé est retourné tel quel à l'agent appelant. Ne peut pas être filtré (c'est la donnée). Mitigation : isolation stricte par `agent_id`.
- **DDoS mémoire** : recall de très grands volumes. Mitigation : `max_result_bytes` dans `Config` (défaut 256 KiB), troncation avec `TruncationMarker`.
- **Brute-force Bearer token** : Mitigation : `BearerAuthLayer` avec `ConstantTimeEq` + pas de différence de timing entre "token trop court" et "token invalide".
1. `cargo xtask ci` vert
2. Pas de `unwrap()` en lib ; pas de secret loggé
3. Nouveau comportement sensible → test adversarial + doc `docs/security/`
4. Décision architecturale → nouvel ADR (ne pas modifier les ADR existants)
51 changes: 44 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,14 @@ jobs:
with:
save-if: ${{ github.ref == 'refs/heads/main' }}

- name: Doc drift (ADR-033)
run: cargo run --quiet --manifest-path xtask/Cargo.toml -- doc-drift

- name: Clippy (-D warnings)
run: |
set -euxo pipefail
cargo clippy -p basemyai-core --all-targets -- -D warnings
cargo clippy -p basemyai-engine --all-targets -- -D warnings
cargo clippy -p basemyai-core --all-targets --features test-util -- -D warnings
cargo clippy -p basemyai-engine --all-targets --features test-util -- -D warnings
cargo clippy -p basemyai --features test-util --all-targets -- -D warnings
cargo clippy -p basemyai-mcp --no-default-features --features stdio,http,test-util --all-targets -- -D warnings
cargo clippy -p basemyai-rest --no-default-features --features test-util --all-targets -- -D warnings
Expand All @@ -82,23 +85,55 @@ jobs:
- name: Test
run: |
set -euxo pipefail
cargo test -p basemyai-core
cargo test -p basemyai-core --features test-util
# --lib --bins --test basic --test vector_recall --test vector_persistence --test vector_churn --test graph_parity --test format_lock :
# tests du moteur natif + harnais recall + persistance KV + churn
# insert/delete (tombstones + consolidation FreshDiskANN, recall APRÈS
# churn — ADR-026 §6) de l'index vectoriel (N3) + parité du graphe natif
# (N4, scénarios de crates/basemyai/tests/graph.rs portés contre RAM et
# persistant), SANS crash_consistency (kill-loop lent, job dédié ci-dessous)
cargo test -p basemyai-engine --lib --bins --test basic --test vector_recall --test vector_persistence --test vector_churn --test graph_parity --test format_lock
cargo test -p basemyai-engine --features test-util --lib --bins --test basic --test vector_recall --test vector_persistence --test vector_churn --test graph_parity --test malformed_open --test format_lock
cargo test -p basemyai --features test-util
# --test memory_tests : runner déclaratif du contrat MemoryStore sur
# le backend natif (clair + chiffré), zéro divergence tolérée.
cargo test -p basemyai --features test-util --test memory_tests
cargo test -p basemyai --features test-util --test p1_isolation_adversarial
cargo test -p basemyai --features test-util --test poisoning_procedural_recall --test provenance_trust --test plaintext_open_forbidden --test temporal_replacement_ci --test temporal_dedup_consolidation --test export_isolation_adversarial --test isolation_recall_graph_adversarial
cargo test -p basemyai-mcp --no-default-features --features stdio,http,test-util
cargo test -p basemyai-rest --no-default-features --features test-util
cargo test -p basemyai-cli

# ── Zero network : mémoire locale sans socket (proxy invalide) ───────────────

zero-network-after-setup:
name: zero-network-after-setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: zero-network
save-if: ${{ github.ref == 'refs/heads/main' }}

# Récupère toutes les dépendances (registre + build) AVANT de bloquer le
# réseau sortant : le cache `rust-cache` n'est réchauffé que sur `main`
# (save-if ci-dessus), donc une PR peut restaurer un cache obsolète —
# sans ce fetch, `cargo test` tenterait de compléter le registre une
# fois le proxy bloqué et échouerait sur une erreur réseau sans rapport
# avec le test "zéro réseau" lui-même.
- name: Fetch dependencies (network still open)
run: cargo fetch --locked

- name: Memory ops with outbound proxy blocked
env:
HTTP_PROXY: http://127.0.0.1:9
HTTPS_PROXY: http://127.0.0.1:9
run: |
set -euxo pipefail
cargo test -p basemyai --features test-util --test zero_network_recall --offline
cargo test -p basemyai --features test-util provision_without_consent_fails_when_model_absent --offline

# ── Feature `embed` : Candle ML (compile + tests réels #[ignore]) ────────────

embed:
Expand All @@ -121,8 +156,8 @@ jobs:
- name: Build + test (embed)
run: |
set -euxo pipefail
cargo test -p basemyai-core --features embed
cargo test -p basemyai --features embed
cargo test -p basemyai-core --features embed,test-util
cargo test -p basemyai --features embed,test-util

# ── `basemyai-engine` crash-consistency harness (N2, TODO-NATIVE-ENGINE.md) ─
# Kill/reopen/verify loop: spawns `crash_writer`, force-kills it mid-write
Expand All @@ -149,7 +184,7 @@ jobs:
- name: Kill/reopen/verify loop (basemyai-engine)
run: |
set -euxo pipefail
cargo test -p basemyai-engine --test crash_consistency -- --nocapture
cargo test -p basemyai-engine --features test-util --test crash_consistency -- --nocapture

# ── Required checks gate ─────────────────────────────────────────────────────
# Single job used as the branch-protection rule target.
Expand All @@ -162,6 +197,7 @@ jobs:
needs:
- format
- gate
- zero-network-after-setup
- embed
- crash-consistency
steps:
Expand All @@ -170,6 +206,7 @@ jobs:
results=(
"${{ needs.format.result }}"
"${{ needs.gate.result }}"
"${{ needs.zero-network-after-setup.result }}"
"${{ needs.embed.result }}"
"${{ needs.crash-consistency.result }}"
)
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Fuzz (nightly)

on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:

permissions:
contents: read

env:
CARGO_TERM_COLOR: always

jobs:
fuzz:
name: cargo-fuzz (${{ matrix.target }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target:
- key_roundtrip
- wal_decode
- sst_decode_structured
- vector_node_decode
- graph_entity_decode
steps:
- uses: actions/checkout@v7

- uses: dtolnay/rust-toolchain@nightly

- name: Install cargo-fuzz
run: cargo install cargo-fuzz --locked

- name: Run fuzz target (bounded)
working-directory: crates/basemyai-engine/fuzz
run: cargo fuzz run ${{ matrix.target }} -- -max_total_time=120 -rss_limit_mb=2048
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ dist/
*.p12
*.pfx
secrets/
.basemyai/key

# ── Profiling / coverage ──────────────────────────────────────────────────────
*.profraw
Expand Down
Loading
Loading