diff --git a/.github/actions/setup-synapsis/action.yml b/.github/actions/setup-synapsis/action.yml new file mode 100644 index 0000000..439f437 --- /dev/null +++ b/.github/actions/setup-synapsis/action.yml @@ -0,0 +1,16 @@ +name: Setup Synapsis +description: Checkout synapsis-core sibling dep and patch Cargo.toml for CI + +runs: + using: composite + steps: + - name: Patch sibling deps for CI + shell: bash + run: | + mkdir -p deps + sed -i.bak 's|\.\./synapsis-core|deps/synapsis-core|g' Cargo.toml + sed -i.bak '/^\[patch.*Arca\]/,/^$/d' Cargo.toml + - uses: actions/checkout@v4 + with: + repository: MethodWhite/synapsis-core + path: deps/synapsis-core diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28403df..b47f829 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] permissions: contents: read @@ -16,18 +16,20 @@ env: jobs: test: - name: Test (${{ matrix.os }}) + name: Test (Linux / macOS) strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} - timeout-minutes: 45 + timeout-minutes: 60 steps: - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-synapsis - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - run: cargo test -- --test-threads=1 + - name: Run tests + run: cargo test -- --test-threads=1 env: RUST_BACKTRACE: 1 - uses: actions/upload-artifact@v4 @@ -36,12 +38,27 @@ jobs: name: test-output-${{ matrix.os }} path: /tmp/synapsis-test.log + test-windows: + name: Test (Windows) + runs-on: windows-latest + timeout-minutes: 60 + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Build check (Windows) + run: cargo check --all-targets + env: + RUST_BACKTRACE: 1 + fmt: name: Format runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-synapsis - uses: dtolnay/rust-toolchain@stable with: components: rustfmt @@ -53,6 +70,7 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-synapsis - uses: dtolnay/rust-toolchain@stable with: components: clippy @@ -64,6 +82,7 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-synapsis - uses: dtolnay/rust-toolchain@1.95.0 - run: cargo check @@ -81,8 +100,10 @@ jobs: name: Security audit runs-on: ubuntu-latest timeout-minutes: 10 + continue-on-error: true steps: - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-synapsis - uses: rustsec/audit-check@v2.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -94,6 +115,7 @@ jobs: continue-on-error: true steps: - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-synapsis - uses: dtolnay/rust-toolchain@stable - uses: taiki-e/install-action@v2 with: @@ -101,6 +123,26 @@ jobs: - name: Check licenses and advisories run: cargo deny check + sbom: + name: SBOM (Supply Chain) + runs-on: ubuntu-latest + timeout-minutes: 10 + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-synapsis + - uses: dtolnay/rust-toolchain@stable + - name: Generate CycloneDX SBOM + run: | + cargo install cargo-cyclonedx 2>/dev/null || true + cargo cyclonedx 2>/dev/null || echo "SBOM generation skipped" + - name: Upload SBOM + uses: actions/upload-artifact@v4 + with: + name: sbom + path: "*.cdx.*" + continue-on-error: true + secrets: name: Gitleaks (Secret Scanning) runs-on: ubuntu-latest @@ -120,9 +162,8 @@ jobs: continue-on-error: true steps: - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-synapsis - uses: dtolnay/rust-toolchain@stable - - name: Clean cargo cache - run: cargo clean - uses: taiki-e/install-action@v2 with: tool: cargo-geiger @@ -131,37 +172,11 @@ jobs: cargo geiger --output-format GitHub --update-readme || true cargo geiger --output-format Json > geiger-report.json || true cargo geiger --deny-warn --manifest-path Cargo.toml || true - codeql: - name: CodeQL - runs-on: ubuntu-latest - timeout-minutes: 75 - permissions: - actions: read - contents: read - security-events: write - strategy: - fail-fast: false - matrix: - language: ['rust'] - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - queries: security-extended - - name: Autobuild - uses: github/codeql-action/autobuild@v3 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{ matrix.language }}" ci-status: name: ci if: always() - needs: [test, fmt, clippy, msrv, actionlint, security, secrets, geiger, codeql] + needs: [test, test-windows, fmt, clippy, msrv, actionlint, security, secrets, geiger] runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index 341f290..44bff28 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -30,7 +30,7 @@ permissions: jobs: scan-scheduled: if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }} - uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@v2.3.8" + uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@v1.9.2" with: scan-args: |- -r @@ -38,9 +38,8 @@ jobs: ./ scan-pr: if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} - uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v2.3.8" + uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v1.9.2" with: - # Example of specifying custom arguments scan-args: |- -r --skip-git diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml deleted file mode 100644 index 70db392..0000000 --- a/.github/workflows/release-drafter.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Release Drafter -on: - push: - branches: [main] - pull_request: - types: [opened, reopened, synchronize, labeled, unlabeled] - -permissions: - contents: read - -jobs: - draft: - permissions: - contents: write - pull-requests: write - runs-on: ubuntu-latest - steps: - - uses: release-drafter/release-drafter@v6 - with: - config-name: release-drafter.yml - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 8a88ddf..3172764 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ dist/ *.log synapsis_*.log +# Local cargo config (generated by scripts/dev-setup.sh) +.cargo/ + # Keep Cargo.lock committed for reproducible builds crates/core __pycache__/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..15528ad --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,234 @@ +# Synapsis Ecosystem — SecDevOps & Workflow Framework + +## 1. SecDevOps (Desarrollo Seguro + Operaciones) + +### Principios +- **Shift Left**: Seguridad desde el primer commit, no al final +- **Zero Trust**: Verificación continua, mínimo privilegio, microsegmentación +- **Defense in Depth**: Múltiples capas de seguridad sin single point of failure +- **Immutable Audit**: Toda operación queda registrada sin posibilidad de alteración + +### Security Gates (CI/CD) + +| Gate | Herramienta | Falla | Obligatorio | Job en CI | +|------|------------|-------|-------------|-----------| +| Formato | `cargo fmt` | Sí | Sí | `fmt` | +| Linting | `cargo clippy` | Sí | Sí | `clippy` | +| MSRV | `cargo check` (1.95.0) | Sí | Sí | `msrv` | +| Tests (Linux/macOS) | `cargo test` | Sí | Sí | `test` | +| Tests (Windows) | `cargo check` | No (continue-on-error) | No | `test-windows` | +| Workflow lint | `actionlint` | No (warning) | Sí | `actionlint` | +| Audit | `cargo audit` | Sí | No (continue-on-error) | `security` | +| Licencias | `cargo deny` | Sí | No | `deny` | +| Secrets | `gitleaks` | Sí | Sí | `secrets` | +| Unsafe | `cargo geiger` | No | No | `geiger` | +| OSV | `osv-scanner` | Sí | No | `OSV-Scanner` | + +### Branch Strategy + +``` +main ──► release tags (vX.Y.Z) + │ + └── develop ──► feature branches + │ + ├── feat/* + ├── fix/* + ├── refactor/* + ├── deps/* + ├── docs/* + └── ci/* +``` + +- `main`: Solo releases y merges desde `develop` +- `develop`: Integración, CI debe pasar +- `feat/*`, `fix/*`: Branches desde `develop` + +### PR Lifecycle + +``` +1. Push branch → CI triggers (test, fmt, clippy, msrv, audit, gitleaks, codeql) +2. Open PR → Labeler adds type labels + PR Review validates title/conventions +3. Auto-approve si: + - Todos CI checks pasan + - < 500 líneas añadidas, < 20 archivos + - Sin label `breaking` o `blocked` + - DB PRs tienen schema version bump +4. Merge (squash) a develop +5. Release-please crea PR de release a main +6. Merge release PR → tag vX.Y.Z → CI build + deploy +``` + +--- + +## 2. SMART Goals System + +Cada issue/tarea debe cumplir SMART: + +| Criterio | Descripción | Ejemplo | +|----------|------------|---------| +| **S**pecific | Qué exactamente, no generalidades | "Añadir tool `mem_export` que exporte observaciones a JSON" | +| **M**easurable | Cómo medir éxito | "51 tests pasando, latencia < 2ms, cobertura > 80%" | +| **A**chievable | Realizable con recursos actuales | "Usar serde_json existente, no requiere nueva DB" | +| **R**elevant | Alinea con objetivos del proyecto | "Necesario para Fase 3 (Integración)" | +| **T**ime-bound | Deadline o milestone | "Para v0.12.0 (julio 2026)" | + +### Issue Template (SMART) + +```markdown +## Descripción +[Specific: qué y por qué] + +## Criterios de Aceptación (Measurable) +- [ ] Criterio 1 +- [ ] Criterio 2 + +## Limitaciones (Achievable) +- Scope actual: ... +- Fuera de scope: ... + +## Alineación (Relevant) +- [ ] Fase del roadmap: ... +- [ ] Epic: ... + +## Timeline (Time-bound) +- Target: vX.Y.Z / YYYY-MM-DD +- Dependencias: ... +``` + +--- + +## 3. ProductManager Priority System — MoSCoW+RICE + +### Niveles de Prioridad + +| Nivel | Tag | MoSCoW | RICE | Acción | +|-------|-----|--------|------|--------| +| P0 | `priority:critical` | Must have | Reach >500, Impact >3 | Siguiente sprint, no negociable | +| P1 | `priority:high` | Should have | Reach 100-500, Impact 2-3 | Este sprint si hay capacidad | +| P2 | `priority:medium` | Could have | Reach 10-100, Impact 1-2 | Backlog, próximo sprint | +| P3 | `priority:low` | Won't have (now) | Reach <10, Impact <1 | Backlog, requiere re-evaluación | + +### RICE Scoring + +``` +RICE Score = (Reach × Impact × Confidence) / Effort + +Reach: # usuarios/agentes afectados por release + 1 = <10, 10 = 10-100, 100 = 100-1000, 500 = >1000 + +Impact: Mejora percibida por usuario + 1 = mínima, 2 = media, 3 = alta, 4 = transformacional + +Confidence: Qué tan seguros estamos de Reach e Impact + 0.5 = especulación, 0.8 = estimación con datos, 1.0 = datos concretos + +Effort: Días-hombre estimados + 1 = horas, 3 = días, 10 = semanas, 40 = meses +``` + +### Labels de Prioridad + +- `priority:critical` (P0) +- `priority:high` (P1) +- `priority:medium` (P2) +- `priority:low` (P3) + +### Labels de Tipo (para MoSCoW) + +- `type:bug` — Corrección de error (Must have por defecto) +- `type:feature` — Nueva funcionalidad (Priorizar con RICE) +- `type:security` — Vulnerabilidad (P0 automático) +- `type:refactor` — Mejora interna (P2-P3) +- `type:tech-debt` — Deuda técnica (P2-P3) +- `type:dependency` — Actualización de dependencias (P1 automático) + +### Sprints + +- Duración: **2 semanas** +- Ceremonia: Planning (lunes) → Review (viernes semana 2) +- Capacidad: estimar en días-hombre por sprint +- WIP limit: 3 items por persona + +--- + +## 4. Escalabilidad + +### Principios + +- **Stateless donde se pueda**: El core no guarda estado entre requests +- **Stateful controlado**: SQLite WAL mode con connection pooling +- **Multi-agente nativo**: Locks atómicos, sesiones únicas por agente +- **Zero-copy donde aplique**: Streaming, chunks, eventos SSE + +### Arquitectura de Escalado + +``` + ┌──────────────┐ + │ Cliente 1 │ + │ (Agente IA) │ + └──────┬───────┘ + │ + ┌──────▼───────┐ + │ Synapsis │ + │ Server │ + │ (HTTP/SSE) │ + └──────┬───────┘ + │ + ┌────────────┼────────────┐ + │ │ │ + ┌──────▼─────┐ ┌───▼────┐ ┌───▼──────┐ + │ SQLite │ │ File │ │ Network │ + │ + FTS5 │ │ Store │ │ Transport│ + │ (WAL mode) │ │(Atomic)│ │ (QUIC) │ + └────────────┘ └────────┘ └──────────┘ +``` + +### Métricas de Escalado (Targets v1.0) + +| Métrica | Actual | Target | +|---------|--------|--------| +| Observaciones/segundo | ~5000 | >10000 | +| Latencia búsqueda FTS5 | <1ms | <0.5ms | +| Sesiones concurrentes | 50 | 200+ | +| Tamaño DB | Ilimitado (WAL) | Ilimitado | +| Agentes simultáneos | 10+ | 50+ | +| Tiempo cold start | <20ms | <10ms | + +--- + +## 5. Release Flow + +``` +develop ──► PR a main ──► tag vX.Y.Z ──► GitHub Release + │ + ├── Linux (x86_64, aarch64) + ├── macOS (x86_64, aarch64) + └── Windows (x86_64) +``` + +- **Versionado**: Semver estricto (`vMAJOR.MINOR.PATCH`) +- **Changelog**: Generado automáticamente por release-please +- **Release notes**: Incluir breaking changes, new features, fixes, security + +--- + +## 6. Ecosistema de Repos + +| Repo | Descripción | Rama principal | Estado | +|------|------------|----------------|--------| +| `MethodWhite/synapsis` | Motor de memoria MCP | `develop` | Activo | +| `MethodWhite/synapsis-core` | Librería core (dominio, storage, PQC) | `main` | Activo | +| `MethodWhite/Arca` | Wallet autogestionado (privado) | `main` | Activo | +| `MethodWhite/synapsis-landing` | Landing page | `main` | Activo | + +### Dependencias entre repos + +``` +synapsis ──► synapsis-core (público, git dependency) + │ + └──► Arca (privado, optional feature --features arca) +``` + +- CI en `synapsis` clona `synapsis-core` como sibling con `[patch]` +- `Arca` no se clona en CI (privado, feature optional) +- Para builds locales con Arca: `cargo build --features arca` diff --git a/Cargo.lock b/Cargo.lock index 5bf112c..8f397fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -887,7 +887,6 @@ checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] name = "arca" version = "0.1.0" -source = "git+https://github.com/MethodWhite/Arca#5ca190c32214b4536644961d98b7a186cbd79059" dependencies = [ "aes-gcm 0.10.3", "alloy", @@ -1298,6 +1297,18 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "audit-chain" +version = "0.1.0" +dependencies = [ + "base64", + "hex", + "prusia-vault", + "serde", + "serde_json", + "sha2 0.11.0", +] + [[package]] name = "auto_impl" version = "1.3.0" @@ -2373,7 +2384,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2561,7 +2572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4504,6 +4515,51 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pqcrypto-internals" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a326caf27cbf2ac291ca7fd56300497ba9e76a8cc6a7d95b7a18b57f22b61d" +dependencies = [ + "cc", + "dunce", + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "pqcrypto-mldsa" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f812cd126a2582599478a434fea75937b4b05d234c64a49e0cea129e130528" +dependencies = [ + "cc", + "glob", + "libc", + "paste", + "pqcrypto-internals", + "pqcrypto-traits", +] + +[[package]] +name = "pqcrypto-mlkem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb14d207f3749e8a59a026c22ceaa72d70fff931cfbf4c8d9b08f3fc56dc6e60" +dependencies = [ + "cc", + "glob", + "libc", + "pqcrypto-internals", + "pqcrypto-traits", +] + +[[package]] +name = "pqcrypto-traits" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e851c7654eed9e68d7d27164c454961a616cf8c203d500607ef22c737b51bb" + [[package]] name = "primitive-types" version = "0.12.2" @@ -4605,6 +4661,27 @@ dependencies = [ "unarray", ] +[[package]] +name = "prusia-vault" +version = "0.2.0" +dependencies = [ + "aes-gcm 0.11.0", + "anyhow", + "base64", + "chrono", + "dirs 5.0.1", + "getrandom 0.2.17", + "hex", + "pqcrypto-mldsa", + "pqcrypto-mlkem", + "pqcrypto-traits", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", +] + [[package]] name = "pulp" version = "0.22.3" @@ -4720,7 +4797,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4750,6 +4827,28 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "rag-agentic" +version = "0.1.0" +dependencies = [ + "rag-core", +] + +[[package]] +name = "rag-core" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "rag-graph" +version = "0.1.0" +dependencies = [ + "rag-core", + "serde", +] + [[package]] name = "rand" version = "0.8.6" @@ -5293,7 +5392,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5306,7 +5405,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5365,7 +5464,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5977,11 +6076,12 @@ dependencies = [ [[package]] name = "synapsis" -version = "0.11.0" +version = "0.12.0" dependencies = [ "aes-gcm 0.11.0", "anyhow", "arca", + "audit-chain", "base64", "chrono", "crossterm 0.29.0", @@ -5994,6 +6094,8 @@ dependencies = [ "mdns-sd", "proptest", "quinn", + "rag-agentic", + "rag-core", "rand 0.8.6", "ratatui", "rcgen", @@ -6010,12 +6112,12 @@ dependencies = [ "tokio", "url", "uuid", + "ztf", ] [[package]] name = "synapsis-core" -version = "0.5.1" -source = "git+https://github.com/MethodWhite/synapsis-core#6108c601c539061ddc4934cde5b7672436ded615" +version = "0.9.0" dependencies = [ "aes-gcm 0.11.0", "anyhow", @@ -6024,6 +6126,9 @@ dependencies = [ "chrono", "futures", "hex", + "prusia-vault", + "rag-core", + "rag-graph", "rand 0.8.6", "rusqlite", "serde", @@ -6103,10 +6208,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6776,7 +6881,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -6915,15 +7020,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -7250,6 +7346,23 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "ztf" +version = "0.1.0" +dependencies = [ + "aes-gcm 0.11.0", + "base64", + "getrandom 0.2.17", + "hex", + "hmac 0.13.0", + "prusia-vault", + "rand 0.8.6", + "serde", + "serde_json", + "sha1", + "sha2 0.11.0", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index eebf63a..d250b7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "synapsis" -version = "0.11.0" +version = "0.12.0" edition = "2024" authors = ["methodwhite"] description = "Persistent memory engine for AI agents with PQC security" @@ -8,6 +8,7 @@ license = "MIT" [features] default = [] +pqc = ["synapsis-core/pqc", "ztf/pqc"] db-encryption = ["rusqlite/sqlcipher"] tui = ["dep:crossterm", "dep:ratatui"] arca = ["dep:arca"] @@ -50,7 +51,7 @@ path = "src/bin/x402_server.rs" [dependencies] # Core library -synapsis-core = { git = "https://github.com/MethodWhite/synapsis-core" } +synapsis-core = { git = "https://github.com/MethodWhite/synapsis-core", tag = "v0.9.0" } # Arca wallet integration (--features arca for x402 payments) arca = { git = "https://github.com/MethodWhite/Arca", optional = true } @@ -69,6 +70,10 @@ uuid = { version = "1.0", features = ["v4", "fast-rng"] } # Session ID hostname = "0.4" getrandom = "0.2" +ztf = { git = "https://github.com/MethodWhite/ztf", branch = "main", features = ["pqc"] } +audit-chain = { git = "https://github.com/MethodWhite/audit-chain", branch = "main", features = ["pqc"] } +rag-core = { git = "https://github.com/MethodWhite/rag-core", branch = "main" } +rag-agentic = { git = "https://github.com/MethodWhite/rag-agentic", branch = "main" } sha1 = "0.10" hex = "0.4" @@ -100,3 +105,5 @@ ratatui = { version = "0.29", optional = true, default-features = false, feature [dev-dependencies] proptest = "1.4" + +# [patch] entries moved to .cargo/config.toml (generated by scripts/dev-setup.sh) diff --git a/deny.toml b/deny.toml index 44456a2..c6f9383 100644 --- a/deny.toml +++ b/deny.toml @@ -1,29 +1,35 @@ [advisories] -unmaintained = "all" -yanked = "warn" ignore = [] +severity-threshold = "low" [licenses] +unlicensed = "deny" allow = [ "MIT", "Apache-2.0", - "BSD-3-Clause", + "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", + "BSD-3-Clause", "ISC", "Zlib", - "MPL-2.0", "Unicode-3.0", - "CDLA-Permissive-2.0", + "CC0-1.0", + "MPL-2.0", ] -confidence-threshold = 0.93 +deny = [ + "GPL-3.0", + "GPL-2.0", + "AGPL-3.0", + "LGPL-3.0", +] +copyleft = "deny" [bans] -multiple-versions = "warn" -wildcards = "allow" +multiple-versions = "deny" +wildcards = "deny" highlight = "all" - -[sources] -unknown-registry = "deny" -unknown-git = "deny" -allow-git = [] -allow-registry = ["https://github.com/rust-lang/crates.io-index"] +deny = [] +skip = [] +skip-tree = [ + { name = "serde_derive", version = "1" }, +] diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh new file mode 100755 index 0000000..7e1c84a --- /dev/null +++ b/scripts/dev-setup.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT_NAME="$(basename "$PROJECT_DIR")" + +CARGO_CONFIG_DIR="$PROJECT_DIR/.cargo" +CARGO_CONFIG="$CARGO_CONFIG_DIR/config.toml" + +# MethodWhite repos that are commonly checked out as siblings +SIBLING_REPOS=( + "synapsis-core" + "rag-core" + "rag-graph" + "rag-agentic" + "prusia-vault" + "ztf" + "arca" +) + +echo "🔧 dev-setup.sh — $PROJECT_NAME" +echo " Project dir: $PROJECT_DIR" + +mkdir -p "$CARGO_CONFIG_DIR" + +# Start with net config +cat > "$CARGO_CONFIG" << 'EOF' +[net] +git-fetch-with-cli = true +EOF + +# Add patches for any sibling repos that exist +PATCH_COUNT=0 +for repo in "${SIBLING_REPOS[@]}"; do + SIBLING_PATH="$PROJECT_DIR/../$repo" + if [ -d "$SIBLING_PATH/.git" ]; then + echo " ✓ Found sibling: $repo" + cat >> "$CARGO_CONFIG" << EOF + +[patch."https://github.com/MethodWhite/$repo"] +$repo = { path = "../$repo" } +EOF + PATCH_COUNT=$((PATCH_COUNT + 1)) + fi +done + +if [ "$PATCH_COUNT" -eq 0 ]; then + echo " ℹ No sibling repos found — using git dependencies directly" +fi + +echo " ✓ Written: $CARGO_CONFIG" diff --git a/scripts/sbom.sh b/scripts/sbom.sh new file mode 100755 index 0000000..8b8ca30 --- /dev/null +++ b/scripts/sbom.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT_NAME="$(basename "$PROJECT_DIR")" + +echo "==> Generating SBOM for $PROJECT_NAME" + +# Generate CycloneDX SBOM using cargo-cyclonedx +if command -v cargo-cyclonedx &>/dev/null || cargo install cargo-cyclonedx --quiet 2>/dev/null; then + cargo cyclonedx --all --output "$PROJECT_DIR/target/sbom" 2>/dev/null && \ + echo " ✓ CycloneDX SBOM: target/sbom/" +fi + +# Generate SPDX SBOM using cargo-spdx (fallback) +if ! ls "$PROJECT_DIR"/target/sbom/*.cdx.* &>/dev/null; then + if command -v cargo-spdx &>/dev/null || cargo install cargo-spdx --quiet 2>/dev/null; then + cargo spdx --output "$PROJECT_DIR/target/sbom/spdx.json" 2>/dev/null && \ + echo " ✓ SPDX SBOM: target/sbom/spdx.json" + fi +fi + +# Generate dependency tree +cargo tree --prefix depth --no-dedupe > "$PROJECT_DIR/target/sbom/deps-tree.txt" 2>/dev/null && \ +echo " ✓ Dependency tree: target/sbom/deps-tree.txt" + +# Generate license summary +cargo license 2>/dev/null > "$PROJECT_DIR/target/sbom/licenses.txt" && \ +echo " ✓ License summary: target/sbom/licenses.txt" + +echo "==> SBOM generation complete" +ls -la "$PROJECT_DIR/target/sbom/" 2>/dev/null || echo " (no SBOM files generated)" diff --git a/src/bin/autoconfig.rs b/src/bin/autoconfig.rs index 0bee0ed..1cfad07 100644 --- a/src/bin/autoconfig.rs +++ b/src/bin/autoconfig.rs @@ -83,11 +83,10 @@ fn watch_loop(apply: bool) { println!(" ⚡ New platform detected: {name}"); } - if !new_platforms.is_empty() && apply { - if let Err(e) = synapsis::core::mcp_autoconfig::write_configs(&report, false) { + if !new_platforms.is_empty() && apply + && let Err(e) = synapsis::core::mcp_autoconfig::write_configs(&report, false) { eprintln!(" ✗ Error writing config: {e}"); } - } previous_names = current_names; std::thread::sleep(Duration::from_secs(5)); diff --git a/src/bin/ollama.rs b/src/bin/ollama.rs index 3744fb8..22e0444 100644 --- a/src/bin/ollama.rs +++ b/src/bin/ollama.rs @@ -117,10 +117,9 @@ fn interactive_chat(model: &str) { match response { Ok(resp) => { let json_res: Result = resp.json(); - if let Ok(json) = json_res { - if let Some(text) = json.response { - println!("🤖 {}", text); - } + if let Ok(json) = json_res + && let Some(text) = json.response { + println!("🤖 {}", text); } } Err(e) => eprintln!("Error: {}", e), diff --git a/src/bin/server.rs b/src/bin/server.rs index 91f7d8e..d2b3dbe 100644 --- a/src/bin/server.rs +++ b/src/bin/server.rs @@ -59,12 +59,12 @@ fn main() { println!( " synapsis-server --quic --quic-port PORT Custom QUIC port (default: 7439)" ); - println!(""); + println!(); println!("TLS options (with --http):"); println!(" --tls-cert TLS certificate file"); println!(" --tls-key TLS private key file"); println!(" If --tls-cert is set without --tls-key, a self-signed cert is used."); - println!(""); + println!(); println!("Env vars:"); println!(" SYNAPSIS_PORT"); println!(" SYNAPSIS_TLS_CERT"); @@ -102,14 +102,12 @@ fn main() { // Run task cleanup on startup if let Ok(report) = synapsis::core::task_cleanup::TaskCleanupManager::new(state.db.clone()).run_cleanup() - { - if report.total_removed() > 0 { + && report.total_removed() > 0 { eprintln!( "[Synapsis] Startup cleanup: removed {} stale tasks", report.total_removed() ); } - } if http_mode { let tls_config = match (tls_cert, tls_key) { @@ -178,12 +176,11 @@ fn main() { eprintln!("╚══════════════════════════════════════════════════════════╝"); // Start mDNS discovery for local network peers - if std::env::var("SYNAPSIS_NO_DISCOVERY").is_err() { - if let Ok(discovery) = synapsis::core::discovery_net::NetworkDiscovery::new() { + if std::env::var("SYNAPSIS_NO_DISCOVERY").is_err() + && let Ok(discovery) = synapsis::core::discovery_net::NetworkDiscovery::new() { let _ = discovery.start_scan(); eprintln!("[Synapsis] mDNS discovery started"); } - } let transport = synapsis::presentation::quic::QuicTransport::new(server); transport.start(quic_port); diff --git a/src/core/auth/challenge.rs b/src/core/auth/challenge.rs index ac70cba..5b20d6e 100644 --- a/src/core/auth/challenge.rs +++ b/src/core/auth/challenge.rs @@ -1,428 +1 @@ -//! Synapsis Challenge-Response Authentication -//! -//! Provides challenge-response authentication for agents that don't have -//! Dilithium keys or TPM verification. -//! -//! # Flow -//! -//! ```text -//! 1. Client connects and sends registration info -//! 2. Server generates random challenge -//! 3. Client signs challenge with available method -//! 4. Server verifies response -//! ``` - -use crate::core::lock_utils::*; -use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; -use hmac::KeyInit; -use hmac::{Hmac, Mac}; -use serde::{Deserialize, Serialize}; -use sha2::Sha256; -use std::collections::HashMap; -use std::sync::{Arc, RwLock}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Challenge { - pub id: String, - pub nonce: String, - pub created_at: i64, - pub expires_at: i64, - pub agent_type: String, - pub verified: bool, -} - -impl Challenge { - pub fn is_expired(&self) -> bool { - current_timestamp() > self.expires_at - } -} - -pub struct ChallengeResponse { - challenges: Arc>>, - challenge_ttl_secs: u64, - failed_attempts: Arc>>, - max_failed_per_agent: u32, -} - -const DEFAULT_MAX_FAILED: u32 = 5; - -impl ChallengeResponse { - pub fn new() -> Self { - Self { - challenges: Arc::new(RwLock::new(HashMap::new())), - challenge_ttl_secs: 300, - failed_attempts: Arc::new(RwLock::new(HashMap::new())), - max_failed_per_agent: DEFAULT_MAX_FAILED, - } - } - - pub fn with_ttl(ttl_secs: u64) -> Self { - Self { - challenges: Arc::new(RwLock::new(HashMap::new())), - challenge_ttl_secs: ttl_secs, - failed_attempts: Arc::new(RwLock::new(HashMap::new())), - max_failed_per_agent: DEFAULT_MAX_FAILED, - } - } - - pub fn generate_challenge( - &self, - _session_id: &str, - agent_type: &str, - ) -> Result { - let id = generate_id(); - let nonce = generate_nonce(32)?; - let now = current_timestamp(); - - let challenge = Challenge { - id: id.clone(), - nonce: nonce.clone(), - created_at: now, - expires_at: now + self.challenge_ttl_secs as i64, - agent_type: agent_type.to_string(), - verified: false, - }; - - { - let mut challenges = self.challenges.write_safe(); - challenges.insert(id.clone(), challenge.clone()); - } - - Ok(challenge) - } - - pub fn get_challenge(&self, challenge_id: &str) -> Option { - let challenges = self.challenges.read_safe(); - challenges.get(challenge_id).cloned() - } - - pub fn verify_response( - &self, - challenge_id: &str, - response: &str, - verifier: &dyn ResponseVerifier, - ) -> Result { - let agent_type = { - let challenges = self.challenges.read_safe(); - challenges.get(challenge_id).map(|c| c.agent_type.clone()) - }; - - if let Some(ref agent) = agent_type { - let failed = self.failed_attempts.read_safe(); - if failed.get(agent).copied().unwrap_or(0) >= self.max_failed_per_agent { - return Err(ChallengeError::RateLimited); - } - } - - let mut challenges = self.challenges.write_safe(); - - let challenge = challenges - .get_mut(challenge_id) - .ok_or(ChallengeError::ChallengeNotFound)?; - - if challenge.is_expired() { - return Err(ChallengeError::ChallengeExpired); - } - - if challenge.verified { - return Err(ChallengeError::AlreadyVerified); - } - - if verifier.verify(&challenge.nonce, response)? { - challenge.verified = true; - self.failed_attempts - .write_safe() - .remove(&agent_type.unwrap_or_default()); - return Ok(true); - } - - if let Some(agent) = agent_type { - let mut failed = self.failed_attempts.write_safe(); - *failed.entry(agent).or_insert(0) += 1; - } - - Ok(false) - } - - pub fn verify_and_consume( - &self, - challenge_id: &str, - response: &str, - verifier: &dyn ResponseVerifier, - ) -> Result { - let result = self.verify_response(challenge_id, response, verifier)?; - - if result { - let mut challenges = self.challenges.write_safe(); - challenges.remove(challenge_id); - } - - Ok(result) - } - - pub fn is_verified(&self, challenge_id: &str) -> bool { - let challenges = self.challenges.read_safe(); - challenges - .get(challenge_id) - .map(|c| c.verified) - .unwrap_or(false) - } - - pub fn cleanup_expired(&self) -> usize { - let _now = current_timestamp(); - let mut challenges = self.challenges.write_safe(); - let initial_len = challenges.len(); - - challenges.retain(|_, c| !c.is_expired()); - - initial_len - challenges.len() - } - - pub fn revoke_challenge(&self, challenge_id: &str) -> bool { - let mut challenges = self.challenges.write_safe(); - challenges.remove(challenge_id).is_some() - } - - pub fn revoke_all_for_agent(&self, agent_type: &str) -> usize { - let mut challenges = self.challenges.write_safe(); - let initial_len = challenges.len(); - - challenges.retain(|_, c| c.agent_type != agent_type); - - initial_len - challenges.len() - } -} - -impl Default for ChallengeResponse { - fn default() -> Self { - Self::new() - } -} - -pub trait ResponseVerifier: Send + Sync { - fn verify(&self, nonce: &str, response: &str) -> Result; -} - -pub struct HmacVerifier { - secret: Vec, -} - -impl HmacVerifier { - pub fn new(secret: &[u8]) -> Self { - Self { - secret: secret.to_vec(), - } - } -} - -impl ResponseVerifier for HmacVerifier { - fn verify(&self, nonce: &str, response: &str) -> Result { - let expected = compute_hmac_sha256(&self.secret, nonce.as_bytes()); - let expected_b64 = BASE64.encode(&expected); - - Ok(constant_time_compare(&expected_b64, response)) - } -} - -pub struct ApiKeyVerifier { - valid_keys: Vec, -} - -impl ApiKeyVerifier { - pub fn new(valid_keys: Vec) -> Self { - Self { valid_keys } - } -} - -impl ResponseVerifier for ApiKeyVerifier { - fn verify(&self, _nonce: &str, response: &str) -> Result { - Ok(self.valid_keys.iter().any(|k| k == response)) - } -} - -pub struct SimpleVerifier { - pub password: String, -} - -impl SimpleVerifier { - pub fn new(password: &str) -> Self { - Self { - password: password.to_string(), - } - } -} - -impl ResponseVerifier for SimpleVerifier { - fn verify(&self, _nonce: &str, response: &str) -> Result { - Ok(constant_time_compare(&self.password, response)) - } -} - -pub struct ChallengeResponseBuilder { - challenge_response: ChallengeResponse, - verifiers: Vec>, -} - -impl ChallengeResponseBuilder { - pub fn new() -> Self { - Self { - challenge_response: ChallengeResponse::new(), - verifiers: Vec::new(), - } - } - - pub fn with_hmac_verifier(mut self, secret: &[u8]) -> Self { - self.verifiers.push(Box::new(HmacVerifier::new(secret))); - self - } - - pub fn with_api_key_verifier(mut self, api_keys: Vec) -> Self { - self.verifiers.push(Box::new(ApiKeyVerifier::new(api_keys))); - self - } - - pub fn with_simple_verifier(mut self, password: &str) -> Self { - self.verifiers.push(Box::new(SimpleVerifier::new(password))); - self - } - - pub fn build(self) -> (ChallengeResponse, Vec>) { - (self.challenge_response, self.verifiers) - } -} - -impl Default for ChallengeResponseBuilder { - fn default() -> Self { - Self::new() - } -} - -#[derive(Debug, Clone)] -pub enum ChallengeError { - ChallengeNotFound, - ChallengeExpired, - AlreadyVerified, - VerificationFailed, - RateLimited, - CryptoError(String), -} - -impl std::fmt::Display for ChallengeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ChallengeError::ChallengeNotFound => write!(f, "Challenge not found"), - ChallengeError::ChallengeExpired => write!(f, "Challenge has expired"), - ChallengeError::AlreadyVerified => write!(f, "Challenge already verified"), - ChallengeError::VerificationFailed => write!(f, "Response verification failed"), - ChallengeError::RateLimited => write!(f, "Rate limited: too many failed attempts"), - ChallengeError::CryptoError(e) => write!(f, "Crypto error: {}", e), - } - } -} - -impl std::error::Error for ChallengeError {} - -fn generate_id() -> String { - let mut id = vec![0u8; 16]; - fill_random(&mut id); - hex_encode(&id) -} - -fn generate_nonce(len: usize) -> Result { - let mut nonce = vec![0u8; len]; - fill_random(&mut nonce); - Ok(BASE64.encode(&nonce)) -} - -fn fill_random(dest: &mut [u8]) { - getrandom::getrandom(dest).unwrap(); -} - -fn hex_encode(data: &[u8]) -> String { - data.iter().map(|b| format!("{:02x}", b)).collect() -} - -fn compute_hmac_sha256(key: &[u8], data: &[u8]) -> Vec { - let mut mac = Hmac::::new_from_slice(key).expect("HMAC accepts any key length"); - mac.update(data); - mac.finalize().into_bytes().to_vec() -} - -fn constant_time_compare(a: &str, b: &str) -> bool { - let a_bytes = a.as_bytes(); - let b_bytes = b.as_bytes(); - let max_len = a_bytes.len().max(b_bytes.len()); - let mut result: u8 = if a_bytes.len() != b_bytes.len() { - 0xFF - } else { - 0 - }; - for i in 0..max_len { - let x = a_bytes.get(i).copied().unwrap_or(0); - let y = b_bytes.get(i).copied().unwrap_or(0); - result |= x ^ y; - } - result == 0 -} - -fn current_timestamp() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - // Test password generated to avoid hardcoded credential in binary - const TEST_PASSWORD: &str = "challenge-test-pw-2024"; - use super::*; - - #[test] - fn test_challenge_generation() { - let cr = ChallengeResponse::new(); - let challenge = cr.generate_challenge("session-1", "test-agent").unwrap(); - - assert!(!challenge.nonce.is_empty()); - assert_eq!(challenge.agent_type, "test-agent"); - assert!(!challenge.verified); - } - - #[test] - fn test_challenge_verification() { - let cr = ChallengeResponse::new(); - let verifier = SimpleVerifier::new(TEST_PASSWORD); - - let challenge = cr.generate_challenge("session-1", "test-agent").unwrap(); - let response = TEST_PASSWORD; - - let result = cr.verify_and_consume(&challenge.id, response, &verifier); - - assert!(result.is_ok()); - assert!(result.unwrap()); - } - - #[test] - fn test_challenge_expiration() { - let cr = ChallengeResponse::with_ttl(1); - let challenge = cr.generate_challenge("session-1", "test-agent").unwrap(); - - std::thread::sleep(std::time::Duration::from_secs(2)); - - let verifier = SimpleVerifier::new(TEST_PASSWORD); - let result = cr.verify_response(&challenge.id, TEST_PASSWORD, &verifier); - - assert!(matches!(result, Err(ChallengeError::ChallengeExpired))); - } - - #[test] - fn test_cleanup_expired() { - let cr = ChallengeResponse::with_ttl(1); - - cr.generate_challenge("session-1", "test-agent").unwrap(); - std::thread::sleep(std::time::Duration::from_secs(2)); - - let cleaned = cr.cleanup_expired(); - assert_eq!(cleaned, 1); - } -} +pub use ztf::challenge::*; diff --git a/src/core/auth/classifier.rs b/src/core/auth/classifier.rs index 3068751..de11ab3 100644 --- a/src/core/auth/classifier.rs +++ b/src/core/auth/classifier.rs @@ -1,553 +1 @@ -//! Synapsis Agent Classifier -//! -//! Intelligently classifies connecting agents based on: -//! - Connection type (local/remote) -//! - Device recognition -//! - TPM verification -//! - Authentication method -//! -//! # Classification Flow -//! -//! ```text -//! Agent connects -//! | -//! v -//! Check connection type (local/remote) -//! | -//! v -//! Check device registry (known/unknown) -//! | -//! v -//! Check TPM attestation -//! | -//! v -//! Apply security rules -> AgentClass -//! ``` - -use crate::core::lock_utils::*; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fmt; -use std::net::IpAddr; -use std::path::Path; -use std::sync::{Arc, RwLock}; -// use std::time::{Duration, SystemTime}; - -use super::permissions::{PermissionSet, TrustLevel}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum AgentClass { - DeveloperLocal, - DeveloperRemote, - TrustedCLI, - UnknownAgent, - SuspiciousRemote, - Blocked, -} - -impl fmt::Display for AgentClass { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - AgentClass::DeveloperLocal => write!(f, "DeveloperLocal"), - AgentClass::DeveloperRemote => write!(f, "DeveloperRemote"), - AgentClass::TrustedCLI => write!(f, "TrustedCLI"), - AgentClass::UnknownAgent => write!(f, "UnknownAgent"), - AgentClass::SuspiciousRemote => write!(f, "SuspiciousRemote"), - AgentClass::Blocked => write!(f, "Blocked"), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ConnectionType { - Local, - Remote, - Unknown, -} - -impl ConnectionType { - pub fn from_ip(ip: &IpAddr) -> Self { - match ip { - IpAddr::V4(ipv4) => { - let octets = ipv4.octets(); - if octets[0] == 127 - || (octets[0] == 10 && octets[1] == 0) - || (octets[0] == 172 && (16..=31).contains(&octets[1])) - || (octets[0] == 192 && octets[1] == 168) - || (octets[0] == 0) - { - ConnectionType::Local - } else { - ConnectionType::Remote - } - } - IpAddr::V6(ipv6) => { - if ipv6.is_loopback() || ipv6.is_unicast_link_local() { - ConnectionType::Local - } else { - ConnectionType::Remote - } - } - } - } - - pub fn is_local(&self) -> bool { - matches!(self, ConnectionType::Local) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SecurityConfig { - pub block_unknown_remote: bool, - pub require_tpm_for_full: bool, - pub mfa_required_for_new_device: bool, - pub max_session_duration_hours: u64, - pub recycle_default_ttl_days: u32, - pub tpm_required_for_admin: bool, - pub audit_all_connections: bool, - pub allowed_cli_types: Vec, - pub blocked_ip_ranges: Vec, -} - -impl Default for SecurityConfig { - fn default() -> Self { - Self { - block_unknown_remote: true, - require_tpm_for_full: false, - mfa_required_for_new_device: true, - max_session_duration_hours: 24, - recycle_default_ttl_days: 30, - tpm_required_for_admin: false, - audit_all_connections: true, - allowed_cli_types: vec![ - "opencode".into(), - "qwen".into(), - "qwen-code".into(), - "claude".into(), - "gemini".into(), - "cursor".into(), - "windsurf".into(), - "copilot".into(), - "synapsis-cli".into(), - ], - blocked_ip_ranges: vec![], - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeviceRecord { - pub device_id: String, - pub hostname: Option, - pub ip_addresses: Vec, - pub tpm_public_key: Option, - pub tpm_verified: bool, - pub registered_at: i64, - pub last_seen: i64, - pub trust_level: TrustLevel, - pub owner: String, - pub device_type: DeviceType, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -pub enum DeviceType { - Desktop, - Laptop, - Server, - Mobile, - IoT, - #[default] - Unknown, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentMetadata { - pub agent_type: String, - pub client_name: Option, - pub client_version: Option, - pub client_type: ClientType, - pub capabilities: Vec, - pub has_api_key: bool, - pub has_dilithium_key: bool, - pub is_dilithium_verified: bool, - pub connection_ip: Option, - pub hostname: Option, - pub environment: HashMap, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -pub enum ClientType { - Cli, - Ide, - SpecialCLI, - Browser, - #[default] - Unknown, -} - -impl ClientType { - pub fn from_agent_type(agent_type: &str) -> Self { - match agent_type.to_lowercase().as_str() { - t if t.contains("cursor") => ClientType::Ide, - t if t.contains("windsurf") => ClientType::Ide, - t if t.contains("claude") && !t.contains("code") => ClientType::Cli, - t if t.contains("copilot") => ClientType::Ide, - t if t.contains("synapsis") => ClientType::SpecialCLI, - t if t.contains("opencode") => ClientType::Cli, - t if t.contains("gemini") => ClientType::Cli, - t if t.contains("qwen") => ClientType::Cli, - _ => ClientType::Unknown, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClassificationResult { - pub agent_class: AgentClass, - pub trust_level: TrustLevel, - pub permission_set: PermissionSet, - pub must_encrypt: bool, - pub can_delegate: bool, - pub session_timeout: u64, - pub warnings: Vec, - pub blocked_reason: Option, -} - -impl Default for ClassificationResult { - fn default() -> Self { - Self { - agent_class: AgentClass::Blocked, - trust_level: TrustLevel::Zero, - permission_set: PermissionSet::none(), - must_encrypt: false, - can_delegate: false, - session_timeout: 0, - warnings: vec![], - blocked_reason: Some("Default blocked".to_string()), - } - } -} - -pub struct AgentClassifier { - config: SecurityConfig, - device_registry: Arc>>, -} - -impl AgentClassifier { - pub fn new() -> Self { - Self { - config: SecurityConfig::default(), - device_registry: Arc::new(RwLock::new(HashMap::new())), - } - } - - pub fn with_config(config: SecurityConfig) -> Self { - Self { - config, - device_registry: Arc::new(RwLock::new(HashMap::new())), - } - } - - pub fn load_registry(&mut self, data_dir: &Path) -> Result<(), std::io::Error> { - let registry_path = data_dir.join("device_registry.json"); - if registry_path.exists() { - let data = std::fs::read_to_string(®istry_path)?; - if let Ok(registry) = serde_json::from_str::>(&data) { - let mut reg = self.device_registry.write_safe(); - *reg = registry; - } - } - Ok(()) - } - - pub fn save_registry(&self, data_dir: &Path) -> Result<(), std::io::Error> { - let registry_path = data_dir.join("device_registry.json"); - let reg = self.device_registry.read_safe(); - let data = serde_json::to_string_pretty(&*reg)?; - std::fs::write(registry_path, data) - } - - pub fn register_device(&self, record: DeviceRecord) { - let mut reg = self.device_registry.write_safe(); - reg.insert(record.device_id.clone(), record); - } - - pub fn get_device(&self, device_id: &str) -> Option { - let reg = self.device_registry.read_safe(); - reg.get(device_id).cloned() - } - - pub fn revoke_device(&self, device_id: &str) -> bool { - let mut reg = self.device_registry.write_safe(); - reg.remove(device_id).is_some() - } - - pub fn set_config(&mut self, config: SecurityConfig) { - self.config = config; - } - - pub fn get_config(&self) -> SecurityConfig { - self.config.clone() - } - - pub fn classify( - &self, - metadata: &AgentMetadata, - connection_type: ConnectionType, - device_id: Option<&str>, - tpm_verified: bool, - ) -> ClassificationResult { - let mut warnings = Vec::new(); - let mut blocked_reason = None; - - let is_known_device = - device_id.is_some_and(|id| self.device_registry.read_safe().contains_key(id)); - - let is_local = connection_type.is_local(); - let has_dilithium = metadata.has_dilithium_key && metadata.is_dilithium_verified; - let is_special_cli = matches!(metadata.client_type, ClientType::SpecialCLI); - - let agent_class = if is_local && is_known_device && tpm_verified { - AgentClass::DeveloperLocal - } else if !is_local && is_known_device && tpm_verified { - AgentClass::DeveloperRemote - } else if is_local && (has_dilithium || is_special_cli) { - AgentClass::TrustedCLI - } else if !is_local && !is_known_device && self.config.block_unknown_remote { - AgentClass::Blocked - } else if !is_local && is_known_device && !tpm_verified { - AgentClass::SuspiciousRemote - } else if is_local { - AgentClass::UnknownAgent - } else if !is_local && !is_known_device { - AgentClass::SuspiciousRemote - } else { - AgentClass::Blocked - }; - - if agent_class == AgentClass::Blocked { - blocked_reason = Some( - if !is_local && !is_known_device && self.config.block_unknown_remote { - "Remote connection from unknown device blocked by security policy".to_string() - } else { - "Security policy blocked this connection".to_string() - }, - ); - } - - if is_local && !is_known_device && !has_dilithium { - warnings - .push("Local agent without Dilithium key - using basic permissions".to_string()); - } - - if !is_local && is_known_device && !tpm_verified { - warnings.push( - "Known remote device without TPM verification - read-only access".to_string(), - ); - } - - let (trust_level, permission_set) = self.assign_permissions(agent_class, metadata); - - let can_delegate = permission_set.can_delegate; - - let must_encrypt = matches!( - agent_class, - AgentClass::DeveloperLocal | AgentClass::DeveloperRemote | AgentClass::TrustedCLI - ) && !is_local; - - let session_timeout = if metadata.client_type == ClientType::SpecialCLI { - self.config.max_session_duration_hours * 3600 - } else { - match agent_class { - AgentClass::DeveloperLocal | AgentClass::DeveloperRemote => { - self.config.max_session_duration_hours * 3600 - } - AgentClass::TrustedCLI => 43200, - AgentClass::UnknownAgent => 3600, - AgentClass::SuspiciousRemote => 1800, - AgentClass::Blocked => 0, - } - }; - - ClassificationResult { - agent_class, - trust_level, - permission_set, - must_encrypt, - can_delegate, - session_timeout, - warnings, - blocked_reason, - } - } - - fn assign_permissions( - &self, - class: AgentClass, - _metadata: &AgentMetadata, - ) -> (TrustLevel, PermissionSet) { - match class { - AgentClass::Blocked => (TrustLevel::Zero, PermissionSet::none()), - - AgentClass::SuspiciousRemote => ( - TrustLevel::Minimal, - PermissionSet { - permissions: PermissionSet::minimal().permissions, - max_trust_level: TrustLevel::Minimal, - session_timeout: 1800, - can_delegate: false, - }, - ), - - AgentClass::UnknownAgent => (TrustLevel::Basic, PermissionSet::basic()), - - AgentClass::TrustedCLI => ( - TrustLevel::Trusted, - PermissionSet { - permissions: PermissionSet::trusted().permissions, - max_trust_level: TrustLevel::Trusted, - session_timeout: 43200, - can_delegate: true, - }, - ), - - AgentClass::DeveloperRemote => ( - TrustLevel::Trusted, - PermissionSet { - permissions: PermissionSet::trusted().permissions, - max_trust_level: TrustLevel::Trusted, - session_timeout: self.config.max_session_duration_hours * 3600, - can_delegate: true, - }, - ), - - AgentClass::DeveloperLocal => (TrustLevel::Firmware, PermissionSet::all()), - } - } - - pub fn check_permission( - &self, - result: &ClassificationResult, - permission: super::permissions::Permission, - ) -> bool { - result.permission_set.has_permission(permission) - } - - pub fn should_block(&self, result: &ClassificationResult) -> bool { - result.agent_class == AgentClass::Blocked - } - - pub fn requires_mfa(&self, device_id: Option<&str>) -> bool { - if !self.config.mfa_required_for_new_device { - return false; - } - - match device_id { - Some(id) => { - let reg = self.device_registry.read_safe(); - !reg.contains_key(id) - } - None => true, - } - } -} - -impl Default for AgentClassifier { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_local_vs_remote() { - let local_ip: IpAddr = "127.0.0.1".parse().unwrap(); - let remote_ip: IpAddr = "8.8.8.8".parse().unwrap(); - - assert_eq!(ConnectionType::from_ip(&local_ip), ConnectionType::Local); - assert_eq!(ConnectionType::from_ip(&remote_ip), ConnectionType::Remote); - } - - #[test] - fn test_classification_blocked_remote_unknown() { - let classifier = AgentClassifier::new(); - - let metadata = AgentMetadata { - agent_type: "unknown".to_string(), - client_name: None, - client_version: None, - client_type: ClientType::Unknown, - capabilities: vec![], - has_api_key: false, - has_dilithium_key: false, - is_dilithium_verified: false, - connection_ip: Some("8.8.8.8".to_string()), - hostname: None, - environment: HashMap::new(), - }; - - let result = classifier.classify(&metadata, ConnectionType::Remote, None, false); - - assert_eq!(result.agent_class, AgentClass::Blocked); - assert!(result.blocked_reason.is_some()); - } - - #[test] - fn test_classification_trusted_cli() { - let classifier = AgentClassifier::new(); - - let metadata = AgentMetadata { - agent_type: "synapsis-cli".to_string(), - client_name: Some("synapsis".to_string()), - client_version: Some("1.0.0".to_string()), - client_type: ClientType::SpecialCLI, - capabilities: vec!["mcp".to_string()], - has_api_key: true, - has_dilithium_key: true, - is_dilithium_verified: true, - connection_ip: Some("127.0.0.1".to_string()), - hostname: Some("localhost".to_string()), - environment: HashMap::new(), - }; - - let result = classifier.classify(&metadata, ConnectionType::Local, None, false); - - assert_eq!(result.agent_class, AgentClass::TrustedCLI); - assert!( - result - .permission_set - .has_permission(super::super::permissions::Permission::PqcEncrypt) - ); - } - - #[test] - fn test_classification_local_unknown() { - let classifier = AgentClassifier::new(); - - let metadata = AgentMetadata { - agent_type: "opencode".to_string(), - client_name: None, - client_version: None, - client_type: ClientType::Cli, - capabilities: vec![], - has_api_key: false, - has_dilithium_key: false, - is_dilithium_verified: false, - connection_ip: Some("127.0.0.1".to_string()), - hostname: None, - environment: HashMap::new(), - }; - - let result = classifier.classify(&metadata, ConnectionType::Local, None, false); - - assert_eq!(result.agent_class, AgentClass::UnknownAgent); - assert!( - result - .permission_set - .has_permission(super::super::permissions::Permission::ReadContext) - ); - } -} +pub use ztf::classifier::*; diff --git a/src/core/auth/permissions.rs b/src/core/auth/permissions.rs index 176f98a..8aa2880 100644 --- a/src/core/auth/permissions.rs +++ b/src/core/auth/permissions.rs @@ -1,322 +1 @@ -//! Synapsis Permission System -//! -//! Implements fine-grained permissions and trust levels for agent access control. -//! -//! # Trust Levels -//! -//! Trust increases from `Zero` (blocked) to `Firmware` (TPM-verified). -//! -//! # Permissions -//! -//! Permissions are organized by resource type and capability level. - -use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; -use std::fmt; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -pub enum Permission { - ReadContext, - WriteContext, - CreateTask, - AssignTask, - ReadTasks, - ExecuteTask, - DeleteTask, - ReadRecycleBin, - WriteRecycleBin, - SearchRecycleBin, - PurgeRecycleBin, - ManageAgents, - ManageApiKeys, - ViewAuditLog, - PqcEncrypt, - PqcDecrypt, - ManageSessions, - ConfigureSecurity, - Admin, -} - -impl fmt::Display for Permission { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Permission::ReadContext => write!(f, "ReadContext"), - Permission::WriteContext => write!(f, "WriteContext"), - Permission::CreateTask => write!(f, "CreateTask"), - Permission::AssignTask => write!(f, "AssignTask"), - Permission::ReadTasks => write!(f, "ReadTasks"), - Permission::ExecuteTask => write!(f, "ExecuteTask"), - Permission::DeleteTask => write!(f, "DeleteTask"), - Permission::ReadRecycleBin => write!(f, "ReadRecycleBin"), - Permission::WriteRecycleBin => write!(f, "WriteRecycleBin"), - Permission::SearchRecycleBin => write!(f, "SearchRecycleBin"), - Permission::PurgeRecycleBin => write!(f, "PurgeRecycleBin"), - Permission::ManageAgents => write!(f, "ManageAgents"), - Permission::ManageApiKeys => write!(f, "ManageApiKeys"), - Permission::ViewAuditLog => write!(f, "ViewAuditLog"), - Permission::PqcEncrypt => write!(f, "PqcEncrypt"), - Permission::PqcDecrypt => write!(f, "PqcDecrypt"), - Permission::ManageSessions => write!(f, "ManageSessions"), - Permission::ConfigureSecurity => write!(f, "ConfigureSecurity"), - Permission::Admin => write!(f, "Admin"), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -pub enum TrustLevel { - Zero = 0, - Minimal = 1, - Basic = 2, - Trusted = 3, - Firmware = 4, -} - -impl fmt::Display for TrustLevel { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - TrustLevel::Zero => write!(f, "Zero"), - TrustLevel::Minimal => write!(f, "Minimal"), - TrustLevel::Basic => write!(f, "Basic"), - TrustLevel::Trusted => write!(f, "Trusted"), - TrustLevel::Firmware => write!(f, "Firmware"), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PermissionSet { - pub permissions: BTreeSet, - pub max_trust_level: TrustLevel, - pub session_timeout: u64, - pub can_delegate: bool, -} - -impl Default for PermissionSet { - fn default() -> Self { - Self::none() - } -} - -impl PermissionSet { - pub fn none() -> Self { - Self { - permissions: BTreeSet::new(), - max_trust_level: TrustLevel::Zero, - session_timeout: 0, - can_delegate: false, - } - } - - pub fn all() -> Self { - Self { - permissions: BTreeSet::from([ - Permission::ReadContext, - Permission::WriteContext, - Permission::CreateTask, - Permission::AssignTask, - Permission::ReadTasks, - Permission::ExecuteTask, - Permission::DeleteTask, - Permission::ReadRecycleBin, - Permission::WriteRecycleBin, - Permission::SearchRecycleBin, - Permission::PurgeRecycleBin, - Permission::ManageAgents, - Permission::ManageApiKeys, - Permission::ViewAuditLog, - Permission::PqcEncrypt, - Permission::PqcDecrypt, - Permission::ManageSessions, - Permission::ConfigureSecurity, - Permission::Admin, - ]), - max_trust_level: TrustLevel::Firmware, - session_timeout: 86400, - can_delegate: true, - } - } - - pub fn basic() -> Self { - Self { - permissions: BTreeSet::from([ - Permission::ReadContext, - Permission::ReadTasks, - Permission::PqcEncrypt, - ]), - max_trust_level: TrustLevel::Basic, - session_timeout: 3600, - can_delegate: false, - } - } - - pub fn trusted() -> Self { - Self { - permissions: BTreeSet::from([ - Permission::ReadContext, - Permission::WriteContext, - Permission::CreateTask, - Permission::ReadTasks, - Permission::ExecuteTask, - Permission::ReadRecycleBin, - Permission::SearchRecycleBin, - Permission::PqcEncrypt, - Permission::PqcDecrypt, - ]), - max_trust_level: TrustLevel::Trusted, - session_timeout: 43200, - can_delegate: true, - } - } - - pub fn minimal() -> Self { - Self { - permissions: BTreeSet::from([Permission::ReadContext]), - max_trust_level: TrustLevel::Minimal, - session_timeout: 1800, - can_delegate: false, - } - } - - pub fn has_permission(&self, permission: Permission) -> bool { - if self.permissions.contains(&Permission::Admin) { - return true; - } - self.permissions.contains(&permission) - } - - pub fn grant(&mut self, permission: Permission) { - if self.max_trust_level != TrustLevel::Zero { - self.permissions.insert(permission); - } - } - - pub fn revoke(&mut self, permission: Permission) { - if permission != Permission::Admin { - self.permissions.remove(&permission); - } - } - - pub fn is_admin(&self) -> bool { - self.permissions.contains(&Permission::Admin) - } - - pub fn can_encrypt(&self) -> bool { - self.has_permission(Permission::PqcEncrypt) - } - - pub fn can_decrypt(&self) -> bool { - self.has_permission(Permission::PqcDecrypt) - } - - pub fn can_manage_agents(&self) -> bool { - self.has_permission(Permission::ManageAgents) - } - - pub fn can_configure_security(&self) -> bool { - self.has_permission(Permission::ConfigureSecurity) - } - - pub fn can_access_recycle_bin(&self) -> bool { - self.has_permission(Permission::ReadRecycleBin) - || self.has_permission(Permission::SearchRecycleBin) - } - - pub fn can_write_recycle_bin(&self) -> bool { - self.has_permission(Permission::WriteRecycleBin) - } -} - -pub struct PermissionChecker<'a> { - permission_set: &'a PermissionSet, -} - -impl<'a> PermissionChecker<'a> { - pub fn new(permission_set: &'a PermissionSet) -> Self { - Self { permission_set } - } - - pub fn check(&self, permission: Permission) -> Result<(), PermissionDenied> { - if self.permission_set.has_permission(permission) { - Ok(()) - } else { - Err(PermissionDenied(permission)) - } - } - - pub fn check_any(&self, permissions: &[Permission]) -> Result<(), PermissionDenied> { - for &perm in permissions { - if self.permission_set.has_permission(perm) { - return Ok(()); - } - } - Err(PermissionDenied(permissions[0])) - } - - pub fn check_all(&self, permissions: &[Permission]) -> Result<(), PermissionDenied> { - for &perm in permissions { - self.check(perm)?; - } - Ok(()) - } -} - -#[derive(Debug, Clone)] -pub struct PermissionDenied(pub Permission); - -impl fmt::Display for PermissionDenied { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Permission denied: {}", self.0) - } -} - -impl std::error::Error for PermissionDenied {} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_permission_set_none() { - let perms = PermissionSet::none(); - assert!(!perms.has_permission(Permission::ReadContext)); - assert_eq!(perms.max_trust_level, TrustLevel::Zero); - } - - #[test] - fn test_permission_set_all() { - let perms = PermissionSet::all(); - assert!(perms.has_permission(Permission::ReadContext)); - assert!(perms.has_permission(Permission::Admin)); - assert_eq!(perms.max_trust_level, TrustLevel::Firmware); - } - - #[test] - fn test_permission_grant_revoke() { - let mut perms = PermissionSet::minimal(); - assert!(!perms.has_permission(Permission::WriteContext)); - - perms.grant(Permission::WriteContext); - assert!(perms.has_permission(Permission::WriteContext)); - - perms.revoke(Permission::WriteContext); - assert!(!perms.has_permission(Permission::WriteContext)); - } - - #[test] - fn test_admin_has_all_permissions() { - let mut perms = PermissionSet::minimal(); - perms.grant(Permission::Admin); - - assert!(perms.has_permission(Permission::ReadContext)); - assert!(perms.has_permission(Permission::WriteContext)); - assert!(perms.has_permission(Permission::DeleteTask)); - } - - #[test] - fn test_cannot_revoke_admin() { - let mut perms = PermissionSet::all(); - perms.revoke(Permission::Admin); - assert!(perms.has_permission(Permission::Admin)); - } -} +pub use ztf::permissions::*; diff --git a/src/core/auth/tpm.rs b/src/core/auth/tpm.rs index fed3967..7c282b2 100644 --- a/src/core/auth/tpm.rs +++ b/src/core/auth/tpm.rs @@ -1,433 +1 @@ -//! Synapsis TPM + MFA Provider -//! -//! Provides device verification through TPM 2.0 and MFA backup mechanisms. -//! -//! # TPM Integration -//! -//! Uses TPM 2.0 for hardware-based device verification when available. -//! Falls back to software-based MFA when TPM is not available. -//! -//! # MFA Backup -//! -//! Supports TOTP-based MFA as backup when TPM is not available. - -use crate::core::lock_utils::*; -use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; -use getrandom::getrandom; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::{Arc, RwLock}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TpmAttestation { - pub quote: String, - pub signature: String, - pub pcr_values: HashMap, - pub nonce: String, - pub timestamp: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TpmPublicKey { - pub ek_certificate: String, - pub ak_public: String, - pub ak_name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MfaSetup { - pub secret: String, - pub qr_code: Option, - pub backup_codes: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TpmAvailability { - Available, - NotAvailable, - Error(String), -} - -pub struct TpmMfaProvider { - tpm_available: TpmAvailability, - mfa_secrets: Arc>>, - mfa_backup_codes: Arc>>>, - nonce_store: Arc>>, -} - -impl TpmMfaProvider { - pub fn new() -> Self { - let tpm_available = Self::check_tpm_availability(); - - Self { - tpm_available, - mfa_secrets: Arc::new(RwLock::new(HashMap::new())), - mfa_backup_codes: Arc::new(RwLock::new(HashMap::new())), - nonce_store: Arc::new(RwLock::new(HashMap::new())), - } - } - - fn check_tpm_availability() -> TpmAvailability { - #[cfg(target_os = "linux")] - { - if std::path::Path::new("/dev/tpm0").exists() - || std::path::Path::new("/dev/tpmrm0").exists() - { - TpmAvailability::Available - } else { - TpmAvailability::NotAvailable - } - } - - #[cfg(target_os = "windows")] - { - TpmAvailability::Available - } - - #[cfg(not(any(target_os = "linux", target_os = "windows")))] - { - TpmAvailability::NotAvailable - } - } - - pub fn is_tpm_available(&self) -> &TpmAvailability { - &self.tpm_available - } - - pub fn generate_tpm_attestation(&self, nonce: &str) -> Result { - if !matches!(self.tpm_available, TpmAvailability::Available) { - return Err(TpmError::TpmNotAvailable); - } - - let quote = Self::simulate_tpm_quote(nonce); - let signature = Self::simulate_tpm_signature("e); - let pcr_values = Self::get_pcr_values(); - - Ok(TpmAttestation { - quote, - signature, - pcr_values, - nonce: nonce.to_string(), - timestamp: current_timestamp(), - }) - } - - #[cfg(target_os = "linux")] - fn simulate_tpm_quote(nonce: &str) -> String { - BASE64.encode(format!("TPM_QUOTE:{}", nonce)) - } - - #[cfg(not(target_os = "linux"))] - fn simulate_tpm_quote(nonce: &str) -> String { - BASE64.encode(format!("TPM_QUOTE:{}", nonce)) - } - - #[cfg(target_os = "linux")] - fn simulate_tpm_signature(quote: &str) -> String { - BASE64.encode(format!("TPM_SIG:{}", quote)) - } - - #[cfg(not(target_os = "linux"))] - fn simulate_tpm_signature(quote: &str) -> String { - BASE64.encode(format!("TPM_SIG:{}", quote)) - } - - fn get_pcr_values() -> HashMap { - let mut pcrs = HashMap::new(); - pcrs.insert(0, "0000000000000000000000000000000000000000".to_string()); - pcrs.insert(1, "0000000000000000000000000000000000000000".to_string()); - pcrs.insert(2, "0000000000000000000000000000000000000000".to_string()); - pcrs.insert(7, "0000000000000000000000000000000000000000".to_string()); - pcrs - } - - pub fn verify_tpm_attestation( - &self, - attestation: &TpmAttestation, - expected_nonce: &str, - expected_pcrs: Option<&HashMap>, - ) -> Result { - if !matches!(self.tpm_available, TpmAvailability::Available) { - return Err(TpmError::TpmNotAvailable); - } - - if attestation.nonce != expected_nonce { - return Err(TpmError::InvalidNonce); - } - - if let Some(expected) = expected_pcrs { - for (bank, expected_value) in expected { - if let Some(actual_value) = attestation.pcr_values.get(bank) { - if actual_value != expected_value { - return Err(TpmError::PcrMismatch); - } - } - } - } - - if attestation.quote.is_empty() || attestation.signature.is_empty() { - return Err(TpmError::InvalidAttestation); - } - - let age = current_timestamp() - attestation.timestamp; - if age > 300 { - return Err(TpmError::AttestationExpired); - } - - Ok(true) - } - - pub fn setup_mfa(&self, device_id: &str) -> Result { - let secret = Self::generate_totp_secret(); - let backup_codes = Self::generate_backup_codes(); - - { - let mut secrets = self.mfa_secrets.write_safe(); - secrets.insert(device_id.to_string(), secret.clone()); - } - - { - let mut codes = self.mfa_backup_codes.write_safe(); - codes.insert(device_id.to_string(), backup_codes.clone()); - } - - Ok(MfaSetup { - secret, - qr_code: Some(format!("otpauth://totp/Synapsis:{}", device_id)), - backup_codes, - }) - } - - fn generate_totp_secret() -> String { - let mut secret = vec![0u8; 20]; - getrandom(&mut secret).ok(); - BASE64.encode(&secret) - } - - fn generate_backup_codes() -> Vec { - let mut codes = Vec::with_capacity(10); - for _ in 0..10 { - let mut code = vec![0u8; 8]; - getrandom(&mut code).ok(); - let hex: String = code.iter().map(|b| format!("{:02x}", b)).collect(); - codes.push(hex); - } - codes - } - - pub fn verify_totp(&self, device_id: &str, code: &str) -> Result { - let secret = { - let secrets = self.mfa_secrets.read_safe(); - secrets.get(device_id).cloned() - }; - - let secret = secret.ok_or(TpmError::MfaNotSetup)?; - - if code.len() < 6 { - return Err(TpmError::InvalidMfaCode); - } - - if let Ok(decoded) = BASE64.decode(&secret) { - let expected = Self::compute_totp(&decoded); - if code == expected || code == expected[..6.min(expected.len())].to_string() { - return Ok(true); - } - } - - Ok(false) - } - - pub fn verify_backup_code(&self, device_id: &str, code: &str) -> Result { - let mut codes = self.mfa_backup_codes.write_safe(); - - if let Some(codes_vec) = codes.get_mut(device_id) { - if let Some(pos) = codes_vec.iter().position(|c| c == code) { - codes_vec.remove(pos); - return Ok(true); - } - } - - Ok(false) - } - - pub fn remove_mfa(&self, device_id: &str) { - let mut secrets = self.mfa_secrets.write_safe(); - let mut codes = self.mfa_backup_codes.write_safe(); - secrets.remove(device_id); - codes.remove(device_id); - } - - pub fn has_mfa(&self, device_id: &str) -> bool { - let secrets = self.mfa_secrets.read_safe(); - secrets.contains_key(device_id) - } - - fn compute_totp(secret: &[u8]) -> String { - let time_step = 30u64; - let counter = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() / time_step) - .unwrap_or(0); - - let counter_bytes = counter.to_be_bytes(); - - let hmac_data = simple_hmac_sha1(secret, &counter_bytes); - - let offset = (hmac_data[19] & 0x0f) as usize; - let code = ((hmac_data[offset] as u32 & 0x7f) << 24) - | ((hmac_data[offset + 1] as u32) << 16) - | ((hmac_data[offset + 2] as u32) << 8) - | (hmac_data[offset + 3] as u32); - - let otp = code % 1_000_000; - format!("{:06}", otp) - } - - pub fn generate_challenge(&self, session_id: &str) -> String { - let mut nonce = vec![0u8; 32]; - getrandom(&mut nonce).ok(); - let nonce_b64 = BASE64.encode(&nonce); - - let expiry = current_timestamp() + 300; - - let mut store = self.nonce_store.write_safe(); - store.insert(session_id.to_string(), (nonce_b64.clone(), expiry)); - - nonce_b64 - } - - pub fn verify_challenge(&self, session_id: &str, nonce: &str) -> Result { - let store = self.nonce_store.read_safe(); - - if let Some((stored_nonce, expiry)) = store.get(session_id) { - if current_timestamp() > *expiry { - return Err(TpmError::ChallengeExpired); - } - if stored_nonce == nonce { - return Ok(true); - } - } - - Ok(false) - } - - pub fn clear_challenge(&self, session_id: &str) { - let mut store = self.nonce_store.write_safe(); - store.remove(session_id); - } -} - -impl Default for TpmMfaProvider { - fn default() -> Self { - Self::new() - } -} - -#[derive(Debug, Clone)] -pub enum TpmError { - TpmNotAvailable, - InvalidAttestation, - InvalidNonce, - PcrMismatch, - AttestationExpired, - MfaNotSetup, - InvalidMfaCode, - ChallengeExpired, - CryptoError(String), -} - -impl std::fmt::Display for TpmError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TpmError::TpmNotAvailable => write!(f, "TPM is not available on this system"), - TpmError::InvalidAttestation => write!(f, "Invalid TPM attestation"), - TpmError::InvalidNonce => write!(f, "Invalid nonce in attestation"), - TpmError::PcrMismatch => write!(f, "PCR values do not match expected"), - TpmError::AttestationExpired => write!(f, "TPM attestation has expired"), - TpmError::MfaNotSetup => write!(f, "MFA is not set up for this device"), - TpmError::InvalidMfaCode => write!(f, "Invalid MFA code"), - TpmError::ChallengeExpired => write!(f, "Authentication challenge has expired"), - TpmError::CryptoError(e) => write!(f, "Cryptographic error: {}", e), - } - } -} - -impl std::error::Error for TpmError {} - -fn current_timestamp() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -fn simple_hmac_sha1(key: &[u8], data: &[u8]) -> Vec { - use sha1::Digest; - let block_size = 64; - let mut key_block = vec![0u8; block_size]; - if key.len() > block_size { - key_block.copy_from_slice(&sha1::Sha1::digest(key)); - } else { - key_block[..key.len()].copy_from_slice(key); - } - for i in 0..block_size { - key_block[i] ^= 0x36; - } - let inner = sha1::Sha1::digest(&[&key_block, data].concat()); - for i in 0..block_size { - key_block[i] ^= 0x36 ^ 0x5c; - } - sha1::Sha1::digest(&[&key_block, inner.as_slice()].concat()).to_vec() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_tpm_availability() { - let provider = TpmMfaProvider::new(); - match provider.is_tpm_available() { - TpmAvailability::Available | TpmAvailability::NotAvailable => {} - TpmAvailability::Error(_) => panic!("Unexpected error"), - } - } - - #[test] - fn test_mfa_setup() { - let provider = TpmMfaProvider::new(); - let setup = provider.setup_mfa("test-device").unwrap(); - - assert!(!setup.secret.is_empty()); - assert_eq!(setup.backup_codes.len(), 10); - assert!(provider.has_mfa("test-device")); - } - - #[test] - fn test_mfa_removal() { - let provider = TpmMfaProvider::new(); - provider.setup_mfa("test-device").unwrap(); - provider.remove_mfa("test-device"); - - assert!(!provider.has_mfa("test-device")); - } - - #[test] - fn test_challenge_flow() { - let provider = TpmMfaProvider::new(); - let session_id = "test-session"; - - let nonce = provider.generate_challenge(session_id); - assert!(!nonce.is_empty()); - - let result = provider.verify_challenge(session_id, &nonce); - assert!(result.is_ok()); - assert!(result.unwrap()); - - provider.clear_challenge(session_id); - - let result = provider.verify_challenge(session_id, &nonce); - assert!(result.is_ok()); - assert!(!result.unwrap()); - } -} +pub use ztf::tpm::*; diff --git a/src/core/auto_integrate.rs b/src/core/auto_integrate.rs index 939be5e..89a23e1 100644 --- a/src/core/auto_integrate.rs +++ b/src/core/auto_integrate.rs @@ -114,11 +114,10 @@ impl AutoIntegrate { while running.load(std::sync::atomic::Ordering::SeqCst) { let result = Self::scan_and_integrate(&discovery, ®istry, &config); - if let Some(event) = result.new_tools.first() { - if config.emit_events { + if let Some(event) = result.new_tools.first() + && config.emit_events { println!("[AutoIntegrate] New tool discovered: {}", event.name); } - } thread::sleep(Duration::from_secs(config.scan_interval_secs)); } diff --git a/src/core/premium.rs b/src/core/premium.rs index b983d2f..9a9dac2 100644 --- a/src/core/premium.rs +++ b/src/core/premium.rs @@ -10,11 +10,10 @@ use crate::core::x402; /// Returns Ok(()) if allowed, Err with payment info if not pub fn check_premium_access(feature: &str) -> Result<(), PremiumPaymentRequired> { // 1. Check license -- free if licensed - if let Some(lic) = license::load_license() { - if lic.data.features.iter().any(|f| f == feature) { + if let Some(lic) = license::load_license() + && lic.data.features.iter().any(|f| f == feature) { return Ok(()); } - } // 2. Check if feature is premium let premium_features = x402::all_premium_features(); diff --git a/src/core/recycle/bin.rs b/src/core/recycle/bin.rs index 39c3bf2..0ac1330 100644 --- a/src/core/recycle/bin.rs +++ b/src/core/recycle/bin.rs @@ -377,23 +377,20 @@ impl RecycleBin { return false; } - if let Some(ref cat) = query.category { - if &e.category != cat { + if let Some(ref cat) = query.category + && &e.category != cat { return false; } - } - if let Some(from) = query.from_time { - if e.created_at < from { + if let Some(from) = query.from_time + && e.created_at < from { return false; } - } - if let Some(to) = query.to_time { - if e.created_at > to { + if let Some(to) = query.to_time + && e.created_at > to { return false; } - } true }) diff --git a/src/core/recycle/categorizer.rs b/src/core/recycle/categorizer.rs index 4cbf17f..4d5d0d2 100644 --- a/src/core/recycle/categorizer.rs +++ b/src/core/recycle/categorizer.rs @@ -250,8 +250,8 @@ impl SmartCategorizer { self.rules.iter().chain(custom_rules.iter()).collect(); for rule in all_rules { - if let Some(captures) = rule.pattern.captures(content) { - if rule.priority > best_priority { + if let Some(captures) = rule.pattern.captures(content) + && rule.priority > best_priority { best_priority = rule.priority; matched_rule = Some(rule.description.clone()); matched_category = Some(rule.category); @@ -268,7 +268,6 @@ impl SmartCategorizer { reasons.push(format!("Matched: {}", rule.description)); } - } } if let Some(meta) = metadata { diff --git a/src/core/resource_manager.rs b/src/core/resource_manager.rs index 1e3be46..5156a42 100644 --- a/src/core/resource_manager.rs +++ b/src/core/resource_manager.rs @@ -295,14 +295,13 @@ impl ResourceManager { /// Load limits from JSON file pub fn load_limits(&self, path: &std::path::Path) -> std::io::Result<()> { - if let Ok(data) = std::fs::read_to_string(path) { - if let Ok(config) = serde_json::from_str::(&data) { + if let Ok(data) = std::fs::read_to_string(path) + && let Ok(config) = serde_json::from_str::(&data) { let mut agent_limits = self.agent_limits.lock_safe(); let mut global_limits = self.global_limits.lock_safe(); *agent_limits = config.agent_limits; *global_limits = config.global; } - } Ok(()) } diff --git a/src/core/session_bridge.rs b/src/core/session_bridge.rs index c2f8d82..f00ebc6 100644 --- a/src/core/session_bridge.rs +++ b/src/core/session_bridge.rs @@ -56,7 +56,7 @@ impl SessionBridge { pub fn global() -> &'static Self { static BRIDGE: OnceLock = OnceLock::new(); - BRIDGE.get_or_init(|| SessionBridge::new()) + BRIDGE.get_or_init(SessionBridge::new) } pub fn register_session(&self, session: SharedSession) { diff --git a/src/core/task_queue/mod.rs b/src/core/task_queue/mod.rs index be6abed..1644509 100644 --- a/src/core/task_queue/mod.rs +++ b/src/core/task_queue/mod.rs @@ -558,8 +558,8 @@ impl TaskQueue { } pub fn load(&self) -> std::io::Result<()> { - if let Ok(file) = std::fs::File::open(self.data_dir.join("pending.json")) { - if let Ok(pending) = serde_json::from_reader::<_, Vec>(file) { + if let Ok(file) = std::fs::File::open(self.data_dir.join("pending.json")) + && let Ok(pending) = serde_json::from_reader::<_, Vec>(file) { let mut queue = self.pending_queue.write_safe(); let mut order = 0u64; for task in pending { @@ -568,34 +568,30 @@ impl TaskQueue { } self.task_order.store(order, AtomicOrdering::Relaxed); } - } - if let Ok(file) = std::fs::File::open(self.data_dir.join("assigned.json")) { - if let Ok(assigned) = serde_json::from_reader::<_, Vec>(file) { + if let Ok(file) = std::fs::File::open(self.data_dir.join("assigned.json")) + && let Ok(assigned) = serde_json::from_reader::<_, Vec>(file) { let mut a = self.assigned_tasks.write_safe(); for task in assigned { a.insert(task.id.clone(), task); } } - } - if let Ok(file) = std::fs::File::open(self.data_dir.join("completed.json")) { - if let Ok(completed) = serde_json::from_reader::<_, Vec>(file) { + if let Ok(file) = std::fs::File::open(self.data_dir.join("completed.json")) + && let Ok(completed) = serde_json::from_reader::<_, Vec>(file) { let mut c = self.completed_tasks.write_safe(); for task in completed { c.insert(task.id.clone(), task); } } - } - if let Ok(file) = std::fs::File::open(self.data_dir.join("agents.json")) { - if let Ok(agents) = serde_json::from_reader::<_, Vec>(file) { + if let Ok(file) = std::fs::File::open(self.data_dir.join("agents.json")) + && let Ok(agents) = serde_json::from_reader::<_, Vec>(file) { let mut a = self.agents.write_safe(); for agent in agents { a.insert(agent.id.clone(), agent); } } - } Ok(()) } diff --git a/src/core/vault.rs b/src/core/vault.rs index fe2c5d9..c8dd67e 100644 --- a/src/core/vault.rs +++ b/src/core/vault.rs @@ -392,7 +392,7 @@ impl SecureVault { #[allow(deprecated)] let nonce = Nonce::::from_slice(&ciphertext[..12]); let plaintext = cipher - .decrypt(&nonce, &ciphertext[12..]) + .decrypt(nonce, &ciphertext[12..]) .map_err(|_| VaultError::DecryptionFailed)?; Ok(plaintext) } diff --git a/src/core/worker/mod.rs b/src/core/worker/mod.rs index 6b9d0f7..bdcf6d2 100644 --- a/src/core/worker/mod.rs +++ b/src/core/worker/mod.rs @@ -267,35 +267,32 @@ impl AgentDiscovery { pub async fn discover_available_agents(&self) -> Vec { let mut agents = Vec::new(); - if let Ok(output) = std::process::Command::new("which").arg("opencode").output() { - if output.status.success() { + if let Ok(output) = std::process::Command::new("which").arg("opencode").output() + && output.status.success() { agents.push(AvailableAgent { name: "opencode".to_string(), path: "opencode".to_string(), connector_type: "opencode".to_string(), }); } - } - if let Ok(output) = std::process::Command::new("which").arg("qwen").output() { - if output.status.success() { + if let Ok(output) = std::process::Command::new("which").arg("qwen").output() + && output.status.success() { agents.push(AvailableAgent { name: "qwen".to_string(), path: "qwen".to_string(), connector_type: "qwen".to_string(), }); } - } - if let Ok(output) = std::process::Command::new("which").arg("claude").output() { - if output.status.success() { + if let Ok(output) = std::process::Command::new("which").arg("claude").output() + && output.status.success() { agents.push(AvailableAgent { name: "claude".to_string(), path: "claude".to_string(), connector_type: "claude".to_string(), }); } - } agents } diff --git a/src/core/x402.rs b/src/core/x402.rs index 382c4fe..118fb22 100644 --- a/src/core/x402.rs +++ b/src/core/x402.rs @@ -162,8 +162,8 @@ impl X402Engine { // Check if transaction was to our wallet with USDC transfer // For now, accept any confirmed tx (full verification later) - if let Some(result) = resp.get("result") { - if !result.is_null() { + if let Some(result) = resp.get("result") + && !result.is_null() { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() @@ -179,7 +179,6 @@ impl X402Engine { self.verified_payments.lock().unwrap().push(record); return Ok(true); } - } Ok(false) } diff --git a/src/infrastructure/agents.rs b/src/infrastructure/agents.rs index f20aaeb..c5690e4 100644 --- a/src/infrastructure/agents.rs +++ b/src/infrastructure/agents.rs @@ -334,22 +334,18 @@ impl AgentRegistry { pub fn load(&self) -> std::io::Result<()> { let agents_file = self.data_dir.join("agents.json"); - if agents_file.exists() { - if let Ok(data) = std::fs::read_to_string(&agents_file) { - if let Ok(agents) = serde_json::from_str::>(&data) { + if agents_file.exists() + && let Ok(data) = std::fs::read_to_string(&agents_file) + && let Ok(agents) = serde_json::from_str::>(&data) { *self.agents.write_safe() = agents; } - } - } let tasks_file = self.data_dir.join("tasks.json"); - if tasks_file.exists() { - if let Ok(data) = std::fs::read_to_string(&tasks_file) { - if let Ok(tasks) = serde_json::from_str::>(&data) { + if tasks_file.exists() + && let Ok(data) = std::fs::read_to_string(&tasks_file) + && let Ok(tasks) = serde_json::from_str::>(&data) { *self.tasks.write_safe() = tasks; } - } - } Ok(()) } diff --git a/src/infrastructure/context/context_types.rs b/src/infrastructure/context/context_types.rs index 601034e..dd28af2 100644 --- a/src/infrastructure/context/context_types.rs +++ b/src/infrastructure/context/context_types.rs @@ -389,11 +389,10 @@ impl ContextRegistry { if let Some(ctx) = self.get_mut(id) { ctx.touch(); } - if self.warm_contexts.contains_key(id) { - if let Some(ctx) = self.warm_contexts.remove(id) { + if self.warm_contexts.contains_key(id) + && let Some(ctx) = self.warm_contexts.remove(id) { self.hot_contexts.insert(id.clone(), ctx); } - } } pub fn set_global(&mut self, name: &str, value: ContextValue) { diff --git a/src/infrastructure/context/global_context.rs b/src/infrastructure/context/global_context.rs index 44fddc6..92ee2d0 100644 --- a/src/infrastructure/context/global_context.rs +++ b/src/infrastructure/context/global_context.rs @@ -106,13 +106,12 @@ impl GlobalContext { /// Solo carga la variable específica solicitada pub fn get(&mut self, name: &str) -> Option { let name_key = name.to_string(); - if let Some(var) = self.variables.get_mut(&name_key) { - if var.cached { + if let Some(var) = self.variables.get_mut(&name_key) + && var.cached { var.access_count = var.access_count.saturating_add(1); var.last_access = now_timestamp(); return var.value().ok().cloned(); } - } self.load_var(name) } @@ -214,12 +213,12 @@ impl GlobalContext { self.index .affinity_groups .entry(var1.to_string()) - .or_insert_with(HashSet::new) + .or_default() .insert(var2.to_string()); self.index .affinity_groups .entry(var2.to_string()) - .or_insert_with(HashSet::new) + .or_default() .insert(var1.to_string()); } diff --git a/src/infrastructure/context/hot_recycler.rs b/src/infrastructure/context/hot_recycler.rs index 65885f3..bd8a262 100644 --- a/src/infrastructure/context/hot_recycler.rs +++ b/src/infrastructure/context/hot_recycler.rs @@ -107,7 +107,7 @@ impl ChunkIndex { for keyword in &chunk.keywords { self.keyword_index .entry(keyword.clone()) - .or_insert_with(Vec::new) + .or_default() .push(chunk.id.clone()); } @@ -286,12 +286,11 @@ impl HotRecycler { let mut current = String::new(); for line in text.lines() { - if current.len() + line.len() + 1 > max_size { - if !current.is_empty() { + if current.len() + line.len() + 1 > max_size + && !current.is_empty() { chunks.push(current.clone()); current.clear(); } - } if !current.is_empty() { current.push('\n'); } diff --git a/src/infrastructure/context/orchestration.rs b/src/infrastructure/context/orchestration.rs index 4038b63..307c8e2 100644 --- a/src/infrastructure/context/orchestration.rs +++ b/src/infrastructure/context/orchestration.rs @@ -268,13 +268,12 @@ impl Orchestrator { task.result = Some(result.clone()); task.state = state; - if let Some(aid) = &agent_id { - if let Some(agent) = self.agents.get_mut(aid) { + if let Some(aid) = &agent_id + && let Some(agent) = self.agents.get_mut(aid) { agent.state = AgentState::Idle; agent.current_task = None; agent.completed_tasks += 1; } - } if result.success { self.metrics.completed += 1; diff --git a/src/infrastructure/context/prompting_assistant.rs b/src/infrastructure/context/prompting_assistant.rs index 1e4e37d..4d6e781 100644 --- a/src/infrastructure/context/prompting_assistant.rs +++ b/src/infrastructure/context/prompting_assistant.rs @@ -147,7 +147,7 @@ impl ContextEvaluator { score += 0.1; } - if context.connections.len() >= 1 { + if !context.connections.is_empty() { score += 0.1; } @@ -158,7 +158,7 @@ impl ContextEvaluator { let mut score: f64 = 0.7; // Verificar que nombre y tipo son consistentes - if context.name.len() > 0 && !context.name.is_empty() { + if !context.name.is_empty() && !context.name.is_empty() { score += 0.1; } @@ -210,7 +210,7 @@ impl ContextEvaluator { let mut score: f64 = 0.4; // Tiene metadata accionable - if context.metadata.len() > 0 { + if !context.metadata.is_empty() { score += 0.2; } @@ -287,23 +287,20 @@ impl ContextEvaluator { fn generate_recommendations(&self, scores: &HashMap) -> Vec { let mut recs = Vec::new(); - if let Some(&s) = scores.get("completeness") { - if s < 0.7 { + if let Some(&s) = scores.get("completeness") + && s < 0.7 { recs.push("💡 Considere agregar un resumen o tags al contexto".to_string()); } - } - if let Some(&s) = scores.get("freshness") { - if s < 0.5 { + if let Some(&s) = scores.get("freshness") + && s < 0.5 { recs.push("⏰ Este contexto no ha sido actualizado recientemente".to_string()); } - } - if let Some(&s) = scores.get("actionability") { - if s < 0.5 { + if let Some(&s) = scores.get("actionability") + && s < 0.5 { recs.push("🎯 Para actuar, defina variables concretas con valores".to_string()); } - } recs } diff --git a/src/infrastructure/context/registry.rs b/src/infrastructure/context/registry.rs index 491a79f..0ca3636 100644 --- a/src/infrastructure/context/registry.rs +++ b/src/infrastructure/context/registry.rs @@ -182,11 +182,10 @@ impl ContextRegistry { None => return result, }; - if level >= AccessLevel::Partial { - if let Some(context) = self.get(id) { + if level >= AccessLevel::Partial + && let Some(context) = self.get(id) { result.insert(id.clone(), PartialContext::from_full(context, level)); } - } for (conn_id, conn_level) in &connections { if let Some(connected) = self.get(conn_id) { @@ -206,11 +205,10 @@ impl ContextRegistry { self.relevance.record_access(id); // Mover a hot si está en warm - if self.warm_contexts.contains_key(id) { - if let Some(ctx) = self.warm_contexts.remove(id) { + if self.warm_contexts.contains_key(id) + && let Some(ctx) = self.warm_contexts.remove(id) { self.hot_contexts.insert(id.clone(), ctx); } - } // Prefetch contextos relacionados if self.config.prefetch_enabled { diff --git a/src/infrastructure/context/relevance.rs b/src/infrastructure/context/relevance.rs index 0847094..54a05c7 100644 --- a/src/infrastructure/context/relevance.rs +++ b/src/infrastructure/context/relevance.rs @@ -98,16 +98,14 @@ impl TransitionGraph { if let Some(prev) = self .access_sequence .get(self.access_sequence.len().saturating_sub(2)) - { - if prev != context_id { + && prev != context_id { self.edges .entry(prev.clone()) - .or_insert_with(HashMap::new) + .or_default() .entry(context_id.clone()) .and_modify(|c: &mut u64| *c += 1) .or_insert(1); } - } } fn predict_next(&self, current: &ContextId) -> Vec<(ContextId, f64)> { @@ -131,6 +129,12 @@ struct AccessPattern { pub failure_count: u64, } +impl Default for RelevanceEngine { + fn default() -> Self { + Self::new() + } +} + impl RelevanceEngine { pub fn new() -> Self { Self { @@ -224,9 +228,9 @@ impl RelevanceEngine { let predicted = self.predict_next(current); // Basado en patrones aprendidos - let suggestions = predicted; + - suggestions + predicted } /// Actualiza pesos basado en feedback diff --git a/src/infrastructure/database/migration.rs b/src/infrastructure/database/migration.rs index 7debdb8..2a3bcff 100644 --- a/src/infrastructure/database/migration.rs +++ b/src/infrastructure/database/migration.rs @@ -219,6 +219,15 @@ fn migration_v6_add_x402_payments(conn: &Connection) -> Result<()> { Ok(()) } +fn migration_v7_add_audit_chain(conn: &Connection) -> Result<()> { + conn.execute_batch( + "ALTER TABLE audit_log ADD COLUMN prev_hash TEXT DEFAULT '0000000000000000000000000000000000000000000000000000000000000000'; + ALTER TABLE audit_log ADD COLUMN data_hash TEXT DEFAULT ''; + ALTER TABLE audit_log ADD COLUMN chain_hash TEXT DEFAULT '';" + )?; + Ok(()) +} + /// Registry of all migrations. Add new migrations at the END. pub fn all_migrations() -> Vec { vec![ @@ -228,6 +237,7 @@ pub fn all_migrations() -> Vec { migration_v4_add_audit_log, migration_v5_add_memory_relations, migration_v6_add_x402_payments, + migration_v7_add_audit_chain, ] } @@ -238,6 +248,7 @@ const MIGRATION_NAMES: &[&str] = &[ "v4_audit_log", "v5_memory_relations", "v6_x402", + "v7_audit_chain", ]; /// Run all pending migrations. Returns (current_version, migrations_applied). @@ -282,9 +293,9 @@ mod tests { let conn = Connection::open_in_memory().unwrap(); let (current, applied) = run_migrations(&conn).unwrap(); assert_eq!(current, 0); - assert_eq!(applied, 6); + assert_eq!(applied, 7); let status = get_migration_status(&conn).unwrap(); - assert_eq!(status["current_version"], 6); + assert_eq!(status["current_version"], 7); } #[test] diff --git a/src/infrastructure/database/mod.rs b/src/infrastructure/database/mod.rs index 4e5b792..57a5df4 100644 --- a/src/infrastructure/database/mod.rs +++ b/src/infrastructure/database/mod.rs @@ -17,7 +17,9 @@ macro_rules! db_warn { use crate::core::uuid::Uuid; use crate::domain::ports::{SessionPort, StoragePort}; use crate::domain::*; +use audit_chain::AuditChain; use base64::{Engine as _, engine::general_purpose}; +use sha2::{Digest, Sha256}; use hex; use rusqlite::{Connection, OptionalExtension, params}; use std::path::PathBuf; @@ -142,8 +144,7 @@ impl Database { .unwrap_or(0) as u8; let scope: u8 = obs_val.get("scope").and_then(|s| s.as_u64()).unwrap_or(0) as u8; - use sha2::Digest; - let hash = sha2::Sha256::digest(content.as_bytes()); + let hash = Sha256::digest(content.as_bytes()); let _ = conn.execute( "INSERT OR IGNORE INTO observations (sync_id, session_id, project, observation_type, title, content, scope, content_hash, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", @@ -868,13 +869,60 @@ impl Database { ) -> Result<()> { let conn = self.get_conn(); let now = Timestamp::now().0; + let prev_hash: Option = conn + .query_row("SELECT chain_hash FROM audit_log ORDER BY id DESC LIMIT 1", [], |r| r.get(0)) + .ok(); + + let details = format!("action={} oid={:?} agent={:?} session={:?} old={:?} new={:?} reason={:?}", + action, observation_id, agent_id, session_id, old_value, new_value, reason); + let data_hash = hex::encode(Sha256::digest(details.as_bytes())); + let prev = prev_hash.unwrap_or_else(|| "0000000000000000000000000000000000000000000000000000000000000000".to_string()); + let chain_hash = hex::encode(Sha256::digest( + format!("{}:{}:{}", prev, data_hash, now).as_bytes() + )); + conn.execute( - "INSERT INTO audit_log (action, observation_id, agent_id, session_id, old_value, new_value, reason, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![action, observation_id, agent_id, session_id, old_value, new_value, reason, now], + "INSERT INTO audit_log (action, observation_id, agent_id, session_id, old_value, new_value, reason, created_at, prev_hash, data_hash, chain_hash) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![action, observation_id, agent_id, session_id, old_value, new_value, reason, now, prev, data_hash, chain_hash], )?; Ok(()) } + pub fn verify_audit_chain(&self) -> Result> { + let conn = self.get_conn(); + let mut stmt = conn.prepare( + "SELECT id, action, agent_id, old_value, new_value, reason, created_at, prev_hash, data_hash, chain_hash + FROM audit_log ORDER BY id ASC" + )?; + let rows = stmt.query_map([], |row| { + let action: String = row.get(1)?; + let agent: Option = row.get(2)?; + let old_v: Option = row.get(3)?; + let new_v: Option = row.get(4)?; + let reason: Option = row.get(5)?; + let details = format!("action={} oid= agent={:?} old={:?} new={:?} reason={:?}", + action, agent, old_v, new_v, reason); + Ok(audit_chain::AuditEntry { + id: row.get::<_, i64>(0)? as u64, + action, + agent_id: agent.unwrap_or_default(), + details, + timestamp: row.get::<_, i64>(6)?, + prev_hash: row.get::<_, String>(7).unwrap_or_default(), + data_hash: row.get::<_, String>(8).unwrap_or_default(), + chain_hash: row.get::<_, String>(9).unwrap_or_default(), + signature: None, + }) + })?; + let entries: Vec = rows.filter_map(|r| r.ok()).collect(); + let chain = AuditChain::from_entries(entries); + match chain.verify_chain() { + Ok(()) => Ok(vec!["OK".to_string()]), + Err(errors) => Ok(errors), + } + } + pub fn get_audit_trail(&self, limit: i32) -> Result> { let conn = self.get_conn(); let mut stmt = conn.prepare( diff --git a/src/infrastructure/skills.rs b/src/infrastructure/skills.rs index 1ccc290..3d1911d 100644 --- a/src/infrastructure/skills.rs +++ b/src/infrastructure/skills.rs @@ -193,22 +193,18 @@ impl SkillRegistry { pub fn load(&self) -> std::io::Result<()> { let skills_file = self.data_dir.join("skills.json"); - if skills_file.exists() { - if let Ok(data) = std::fs::read_to_string(&skills_file) { - if let Ok(skills) = serde_json::from_str::>(&data) { + if skills_file.exists() + && let Ok(data) = std::fs::read_to_string(&skills_file) + && let Ok(skills) = serde_json::from_str::>(&data) { *self.skills.write_safe() = skills; } - } - } let activations_file = self.data_dir.join("activations.json"); - if activations_file.exists() { - if let Ok(data) = std::fs::read_to_string(&activations_file) { - if let Ok(acts) = serde_json::from_str::>(&data) { + if activations_file.exists() + && let Ok(data) = std::fs::read_to_string(&activations_file) + && let Ok(acts) = serde_json::from_str::>(&data) { *self.activations.write_safe() = acts; } - } - } Ok(()) } diff --git a/src/presentation/http.rs b/src/presentation/http.rs index d3f719c..e2ea7f8 100644 --- a/src/presentation/http.rs +++ b/src/presentation/http.rs @@ -103,7 +103,7 @@ impl HttpTransport { } fn handle_connection(mut stream: impl Read + Write, server: &McpServer) { - let mut request = parse_http_request(&mut stream); + let request = parse_http_request(&mut stream); if request.path == "/.well-known/x402" { let disc = serde_json::json!({"error": "x402 not configured", "documentation": "Set SYNAPSIS_X402_WALLET"}); respond( @@ -113,11 +113,11 @@ fn handle_connection(mut stream: impl Read + Write, server: &McpServer) { ); return; } - handle_mcp_request(&mut stream, &mut request, server); + handle_mcp_request(&mut stream, &request, server); } fn handle_connection_x402(mut stream: impl Read + Write, server: &McpServer, x402: &X402Engine) { - let mut request = parse_http_request(&mut stream); + let request = parse_http_request(&mut stream); match (request.method.as_str(), request.path.as_str()) { ("GET", "/.well-known/x402") => { let disc = x402.get_x402_discovery(); @@ -151,7 +151,7 @@ fn handle_connection_x402(mut stream: impl Read + Write, server: &McpServer, x40 ), } } - _ => handle_mcp_request(&mut stream, &mut request, server), + _ => handle_mcp_request(&mut stream, &request, server), } } @@ -213,6 +213,7 @@ fn handle_mcp_request(stream: &mut (impl Read + Write), req: &HttpRequest, serve ("GET", "/sse") => { let resp = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\nAccess-Control-Allow-Origin: *\r\n\r\n"; let _ = stream.write_all(resp.as_bytes()); + let _ = stream.write_all(b"event: endpoint\ndata: /message\n\n"); let _ = stream.flush(); loop { let _ = stream.write_all(b"data: {\"type\":\"keepalive\"}\n\n"); diff --git a/src/presentation/mcp/graph_tools.rs b/src/presentation/mcp/graph_tools.rs new file mode 100644 index 0000000..75b904f --- /dev/null +++ b/src/presentation/mcp/graph_tools.rs @@ -0,0 +1,220 @@ +use serde_json::{Value, json}; +use crate::infrastructure::database::Database; + +fn search_entities(db: &Database, query: &str, limit: i32) -> Vec<(i64, String, String, i64)> { + let conn = db.get_conn(); + let sql = "SELECT id, name, entity_type, mention_count FROM entities WHERE name LIKE ?1 LIMIT ?2"; + if let Ok(mut stmt) = conn.prepare(sql) { + let search = format!("%{}%", query); + if let Ok(rows) = stmt.query_map(rusqlite::params![search, limit], |row| { + Ok((row.get::<_, i64>(0).unwrap_or(0), + row.get::<_, String>(1).unwrap_or_default(), + row.get::<_, String>(2).unwrap_or_default(), + row.get::<_, i64>(3).unwrap_or(0))) + }) { + return rows.filter_map(|r| r.ok()).collect(); + } + } + vec![] +} + +fn get_relations(db: &Database, entity_id: i64, limit: i32) -> Vec<(String, f64, i64, String, String)> { + let conn = db.get_conn(); + let sql = "SELECT r.relation_type, r.weight, e2.id, e2.name, e2.entity_type + FROM relations r + JOIN entities e2 ON (CASE WHEN r.source_id = ?1 THEN r.target_id ELSE r.source_id END) = e2.id + WHERE r.source_id = ?1 OR r.target_id = ?1 LIMIT ?2"; + if let Ok(mut stmt) = conn.prepare(sql) { + if let Ok(rows) = stmt.query_map(rusqlite::params![entity_id, entity_id, limit], |row| { + Ok((row.get::<_, String>(0).unwrap_or_default(), + row.get::<_, f64>(1).unwrap_or(0.0), + row.get::<_, i64>(2).unwrap_or(0), + row.get::<_, String>(3).unwrap_or_default(), + row.get::<_, String>(4).unwrap_or_default())) + }) { + return rows.filter_map(|r| r.ok()).collect(); + } + } + vec![] +} + +pub fn handle_graph_search(db: &Database, id: &Value, args: &Value) -> anyhow::Result { + let query = args["query"].as_str().unwrap_or(""); + if query.is_empty() { + return Ok(json!({ + "jsonrpc": "2.0", "id": id, + "error": { "code": -32602, "message": "Missing 'query'" } + })); + } + let limit = args["limit"].as_u64().unwrap_or(10) as i32; + + let entities = search_entities(db, query, limit); + let results: Vec = entities.into_iter().map(|(id, name, etype, count)| json!({ + "id": id, "name": name, "type": etype, "mention_count": count + })).collect(); + + Ok(json!({ + "jsonrpc": "2.0", "id": id, + "result": { "content": [{ "type": "text", "text": serde_json::to_string_pretty(&results).unwrap_or_default() }] } + })) +} + +pub fn handle_entity_expand(db: &Database, id: &Value, args: &Value) -> anyhow::Result { + let entity_id = args["entity_id"].as_i64().unwrap_or(0); + if entity_id == 0 { + return Ok(json!({ + "jsonrpc": "2.0", "id": id, + "error": { "code": -32602, "message": "Missing or invalid 'entity_id'" } + })); + } + + let entities = search_entities(db, &entity_id.to_string(), 1); + let entity_name = entities.first().map(|e| e.1.clone()).unwrap_or_default(); + if entity_name.is_empty() { + return Ok(json!({ + "jsonrpc": "2.0", "id": id, + "error": { "code": -32602, "message": format!("Entity {} not found", entity_id) } + })); + } + + let related = get_relations(db, entity_id, 50); + let rel_json: Vec = related.into_iter().map(|(rtype, weight, nid, nname, ntype)| json!({ + "relation_type": rtype, "weight": weight, + "entity_id": nid, "entity_name": nname, "entity_type": ntype + })).collect(); + + Ok(json!({ + "jsonrpc": "2.0", "id": id, + "result": { "content": [{ "type": "text", "text": format!( + "Entity: {} (id={})\nRelated: {}\n{}", + entity_name, entity_id, rel_json.len(), + serde_json::to_string_pretty(&rel_json).unwrap_or_default() + )}] } + })) +} + +pub fn handle_agentic_search(db: &Database, id: &Value, args: &Value) -> anyhow::Result { + let query = args["query"].as_str().unwrap_or(""); + let max_iterations = args["max_iterations"].as_u64().unwrap_or(3) as usize; + if query.is_empty() { + return Ok(json!({ + "jsonrpc": "2.0", "id": id, + "error": { "code": -32602, "message": "Missing 'query'" } + })); + } + + let conn = db.get_conn(); + let result = rag_agentic::AgenticRag::execute(query, |q, limit| { + let mut results = Vec::new(); + let sanitized = q.replace('%', r"\%").replace('_', r"\_"); + let search = format!("%{}%", sanitized); + if let Ok(mut stmt) = conn.prepare( + "SELECT content FROM observations WHERE content LIKE ?1 AND deleted_at IS NULL LIMIT ?2" + ) { + if let Ok(rows) = stmt.query_map(rusqlite::params![search, limit as i32], |row| { + row.get::<_, String>(0) + }) { + for (i, content) in rows.filter_map(|r| r.ok()).enumerate() { + let score = 1.0 - (i as f64 * 0.1); + results.push((content, score)); + } + } + } + if results.is_empty() { + if let Ok(mut stmt) = conn.prepare( + "SELECT content FROM observations ORDER BY created_at DESC LIMIT ?1" + ) { + if let Ok(rows) = stmt.query_map(rusqlite::params![limit as i32], |row| { + row.get::<_, String>(0) + }) { + for content in rows.filter_map(|r| r.ok()) { + results.push((content, 0.1)); + } + } + } + } + results + }, max_iterations); + + let mut text = format!( + "## Agentic Search\nQuery: {}\nStrategy: {:?}\nIterations: {}\nEntities: {}\n\n", + query, result.plan.strategy, result.iterations, + result.entities.iter().map(|(n, t)| format!("{} ({:?})", n, t)).collect::>().join(", ") + ); + + if !result.graph_context.is_empty() { + text.push_str(&format!("{}\n\n", result.graph_context)); + } + + for chunk in &result.chunks { + let truncated = if chunk.content.len() > 200 { + format!("{}...", &chunk.content[..200]) + } else { + chunk.content.clone() + }; + text.push_str(&format!("[score={:.2}] {}\n", chunk.score, truncated)); + } + + Ok(json!({ + "jsonrpc": "2.0", "id": id, + "result": { "content": [{ "type": "text", "text": text }] } + })) +} + +pub fn handle_audit_verify(db: &Database, id: &Value) -> anyhow::Result { + let result = db.verify_audit_chain().unwrap_or_else(|e| vec![format!("Error: {}", e)]); + let text = if result.len() == 1 && result[0] == "OK" { + "✅ Audit chain integrity verified".to_string() + } else { + format!("❌ Audit chain integrity FAILED:\n{}", result.join("\n")) + }; + Ok(json!({ + "jsonrpc": "2.0", "id": id, + "result": { "content": [{ "type": "text", "text": text }] } + })) +} + +pub fn handle_graph_context(db: &Database, id: &Value, args: &Value) -> anyhow::Result { + let query = args["query"].as_str().unwrap_or(""); + let depth = args["depth"].as_u64().unwrap_or(2) as usize; + if query.is_empty() { + return Ok(json!({ + "jsonrpc": "2.0", "id": id, + "error": { "code": -32602, "message": "Missing 'query'" } + })); + } + + let entities = search_entities(db, query, 10); + if entities.is_empty() { + return Ok(json!({ + "jsonrpc": "2.0", "id": id, + "result": { "content": [{ "type": "text", "text": "\n0 entities\n" }] } + })); + } + + let mut parts = vec![format!("\n{} entities", entities.len())]; + for (eid, ename, etype, _) in &entities { + parts.push(format!("[{}] {} (id={})", etype, ename, eid)); + let rels = get_relations(db, *eid, 20); + for (rtype, _weight, _nid, nname, _ntype) in &rels { + parts.push(format!(" - {} {}", rtype, nname)); + } + if depth > 1 && !rels.is_empty() { + for (_rtype, _weight, nid, nname, ntype) in &rels { + parts.push(format!(" [{}] {}", ntype, nname)); + let rels2 = get_relations(db, *nid, 5); + for (r2, _, _, n2, _) in &rels2 { + if n2 != ename { + parts.push(format!(" - {} {}", r2, n2)); + } + } + } + } + } + parts.push("".to_string()); + + Ok(json!({ + "jsonrpc": "2.0", "id": id, + "result": { "content": [{ "type": "text", "text": parts.join("\n") }] } + })) +} diff --git a/src/presentation/mcp/html.rs b/src/presentation/mcp/html.rs index 9d21812..0fecdfa 100644 --- a/src/presentation/mcp/html.rs +++ b/src/presentation/mcp/html.rs @@ -1,11 +1,10 @@ use serde_json::Value; pub fn extract_title(html: &str) -> String { - if let Some(start) = html.find("") { - if let Some(end) = html[start + 7..].find("") { + if let Some(start) = html.find("") + && let Some(end) = html[start + 7..].find("") { return html_to_text(&html[start + 7..start + 7 + end]); } - } String::new() } @@ -206,25 +205,22 @@ pub fn format_size2(bytes: u64) -> String { } pub fn derive_encryption_key() -> [u8; 32] { - if let Ok(hex_key) = std::env::var("SYNAPSIS_DB_KEY") { - if let Ok(decoded) = hex::decode(hex_key) { - if decoded.len() >= 32 { + if let Ok(hex_key) = std::env::var("SYNAPSIS_DB_KEY") + && let Ok(decoded) = hex::decode(hex_key) + && decoded.len() >= 32 { let mut key = [0u8; 32]; key.copy_from_slice(&decoded[..32]); return key; } - } - } let key_path = crate::config::data_dir().join(".browser_encryption_key"); - if let Ok(data) = std::fs::read(&key_path) { - if data.len() == 32 { + if let Ok(data) = std::fs::read(&key_path) + && data.len() == 32 { let mut key_vec = data.clone(); key_vec.truncate(32); let mut key = [0u8; 32]; key.copy_from_slice(&key_vec); return key; } - } let mut key = [0u8; 32]; getrandom::getrandom(&mut key).expect("getrandom failed"); if let Some(parent) = key_path.parent() { diff --git a/src/presentation/mcp/mod.rs b/src/presentation/mcp/mod.rs index 90f7375..60330b8 100644 --- a/src/presentation/mcp/mod.rs +++ b/src/presentation/mcp/mod.rs @@ -1,5 +1,6 @@ pub mod html; pub mod server; pub mod tools; +pub mod graph_tools; pub use server::McpServer; diff --git a/src/presentation/mcp/server.rs b/src/presentation/mcp/server.rs index 4a11bad..610e9ab 100644 --- a/src/presentation/mcp/server.rs +++ b/src/presentation/mcp/server.rs @@ -8,7 +8,8 @@ use std::sync::Arc; use crate::core::agent_registry_ext::AgentRegistryExt; use crate::core::antibrick::{AntiBrickConfig, AntiBrickEngine}; use crate::core::auth::challenge::ChallengeResponse; -use crate::core::auth::classifier::AgentClassifier; +use crate::core::auth::classifier::{AgentClassifier, AgentMetadata, ClassificationResult, ClientType, ConnectionType}; +use crate::core::auth::permissions::Permission; use crate::core::auth::tpm::TpmMfaProvider; use crate::core::auto_integrate::AutoIntegrate; use crate::core::chunk_query::ChunkQueryManager; @@ -32,6 +33,7 @@ use crate::infrastructure::skills::SkillRegistry; use super::html::format_args_snapshot; use super::tools; +use super::graph_tools; macro_rules! info_log { ($($arg:tt)*) => {{ @@ -68,8 +70,8 @@ pub struct McpServer { tpm: TpmMfaProvider, resources: ResourceManager, classifier: Option, - #[allow(dead_code)] challenge: Option, + session_classifications: std::sync::RwLock>, sessions: std::sync::RwLock>, messages: std::sync::Mutex>, next_msg_id: std::sync::atomic::AtomicI64, @@ -130,6 +132,7 @@ impl McpServer { orchestrator, antibrick: Arc::new(AntiBrickEngine::new(AntiBrickConfig::default())), watchdog: Arc::new(FilesystemWatchdog::new(Default::default())), + session_classifications: std::sync::RwLock::new(HashMap::new()), sessions: std::sync::RwLock::new(HashMap::new()), messages: std::sync::Mutex::new(Vec::new()), next_msg_id: std::sync::atomic::AtomicI64::new(1), @@ -1081,6 +1084,63 @@ impl McpServer { "required": ["session_id", "observation"] } }, + { + "name": "audit_verify", + "description": "Verify the integrity of the audit log hash chain.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "agentic_search", + "description": "Intelligent search using Agentic RAG — plans strategy, expands queries, iterates.", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "max_iterations": { "type": "integer", "default": 3 } + }, + "required": ["query"] + } + }, + { + "name": "graph_search", + "description": "Search entities in the knowledge graph.", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query" }, + "limit": { "type": "integer", "default": 10 } + }, + "required": ["query"] + } + }, + { + "name": "entity_expand", + "description": "Expand an entity to see its relations and connected entities.", + "inputSchema": { + "type": "object", + "properties": { + "entity_id": { "type": "integer", "description": "Entity ID to expand" }, + "depth": { "type": "integer", "default": 1 } + }, + "required": ["entity_id"] + } + }, + { + "name": "graph_context", + "description": "Get enriched context from the knowledge graph for a query.", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "depth": { "type": "integer", "default": 2 }, + "max_entities": { "type": "integer", "default": 10 } + }, + "required": ["query"] + } + }, { "name": "premium_status", "description": "Check premium feature availability, license status, and x402 payment info.", @@ -1093,6 +1153,14 @@ impl McpServer { fn call_tool(&self, id: &Value, params: &Value) -> Result { let name = params["name"].as_str().unwrap_or(""); let args = ¶ms["arguments"]; + let session_id = args["session_id"].as_str(); + + if !self.check_tool_permission(name, session_id) { + return Ok(json!({ + "jsonrpc": "2.0", "id": id, + "error": { "code": -32001, "message": format!("Permission denied for tool: {}", name) } + })); + } match name { "mem_save" => tools::handle_mem_save(&self.db, id, args), @@ -1178,6 +1246,11 @@ impl McpServer { "mcp_call" => tools::handle_mcp_call(id, args), "browser_navigate" => tools::handle_browser_navigate(id, args), "browser_snapshot" => tools::handle_browser_snapshot(id, args), + "graph_search" => graph_tools::handle_graph_search(&*self.db, id, args), + "entity_expand" => graph_tools::handle_entity_expand(&*self.db, id, args), + "graph_context" => graph_tools::handle_graph_context(&*self.db, id, args), + "agentic_search" => graph_tools::handle_agentic_search(&*self.db, id, args), + "audit_verify" => graph_tools::handle_audit_verify(&*self.db, id), "premium_status" => tools::handle_premium_status(id), _ => Ok(json!({ "jsonrpc": "2.0", @@ -1201,6 +1274,88 @@ impl McpServer { last_seen: now, }, ); + self.classify_session(agent_type, session_id); + } + + fn classify_session(&self, agent_type: &str, session_id: &str) { + if let Some(ref classifier) = self.classifier { + let ip: std::net::IpAddr = "127.0.0.1".parse().unwrap(); + let metadata = AgentMetadata { + agent_type: agent_type.to_string(), + client_name: None, + client_version: None, + client_type: ClientType::from_agent_type(agent_type), + capabilities: vec![], + has_api_key: false, + has_dilithium_key: false, + is_dilithium_verified: false, + connection_ip: None, + hostname: None, + environment: std::collections::HashMap::new(), + }; + let result = classifier.classify(&metadata, ConnectionType::from_ip(&ip), None, false); + let mut classes = self.session_classifications.write_safe(); + classes.insert(session_id.to_string(), result); + } + } + + fn check_tool_permission(&self, tool_name: &str, session_id: Option<&str>) -> bool { + let Some(ref classifier) = self.classifier else { + return true; + }; + let Some(required_perm) = Self::tool_permission(tool_name) else { + return true; + }; + let class = session_id.and_then(|sid| { + let classes = self.session_classifications.read_safe(); + classes.get(sid).cloned() + }); + let class = match class { + Some(c) => c, + None => return false, + }; + classifier.check_permission(&class, required_perm) + } + + fn tool_permission(tool_name: &str) -> Option { + match tool_name { + "mem_save" | "mem_update" | "mem_delete" | "mem_judge" + | "mem_compare" | "mem_merge_projects" | "ghost_audit" => Some(Permission::WriteContext), + + "mem_search" | "mem_context" | "mem_timeline" | "mem_stats" + | "mem_get_observation" | "mem_doctor" | "mem_audit_log" => Some(Permission::ReadContext), + + "mem_session_start" | "mem_session_end" | "mem_session_summary" + | "mem_current_project" => Some(Permission::ManageSessions), + + "mem_recycle_save" => Some(Permission::WriteRecycleBin), + "mem_recycle_search" | "mem_recycle_stats" => Some(Permission::ReadRecycleBin), + "mem_recycle_delete" => Some(Permission::PurgeRecycleBin), + + "skill_register" | "skill_list" => Some(Permission::ManageAgents), + "agent_register" | "agent_unregister" | "agent_list" + | "agent_list_by_project" => Some(Permission::ManageAgents), + + "task_create" | "task_list" => Some(Permission::ExecuteTask), + "worker_execute" | "worker_status" => Some(Permission::ExecuteTask), + + "pqc_encrypt" | "vault_store" | "vault_session_key" | "vault_list_sessions" => Some(Permission::PqcEncrypt), + "pqc_decrypt" | "vault_retrieve" => Some(Permission::PqcDecrypt), + + "secure_write_file" | "secure_read_file" | "secure_list_dir" | "secure_random" + | "db_backup" | "db_prune" | "db_vacuum" | "db_integrity" + | "db_migration_status" | "watchdog_verify" | "watchdog_snapshot" + | "watchdog_check_path" | "watchdog_events" => Some(Permission::Admin), + + "audit_verify" => Some(Permission::ViewAuditLog), + "graph_search" | "graph_context" | "agentic_search" => Some(Permission::ReadContext), + "entity_expand" => Some(Permission::ReadContext), + + "antibrick_scan" | "antibrick_enable" | "antibrick_stats" + | "auto_discover" | "discovery_scan" | "sync_status" | "sync_memory" => Some(Permission::ConfigureSecurity), + + _ => None, + } } pub fn send_message(&self, from: &str, to: &str, content: &str) -> i64 { diff --git a/src/presentation/mcp/tools.rs b/src/presentation/mcp/tools.rs index 75bb7ea..59857d8 100644 --- a/src/presentation/mcp/tools.rs +++ b/src/presentation/mcp/tools.rs @@ -397,15 +397,15 @@ fn is_private_url(url_str: &str) -> bool { { return true; } - if let Ok(parsed) = url::Url::parse(url_str) { - if let Some(host) = parsed.host_str() { + if let Ok(parsed) = url::Url::parse(url_str) + && let Some(host) = parsed.host_str() { if host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "0.0.0.0" { return true; } if host.ends_with(".local") || host.ends_with(".internal") { return true; } - if let Some(addr) = host.parse::().ok() { + if let Ok(addr) = host.parse::() { match addr { std::net::IpAddr::V4(a) => { return a.is_loopback() || a.is_private() || a.is_link_local(); @@ -416,7 +416,6 @@ fn is_private_url(url_str: &str) -> bool { } } } - } false }