diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f0a2fb7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: macos-latest # arm64 (Apple Silicon) + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build + run: make all + + - name: Package + run: | + VERSION="${GITHUB_REF_NAME}" + ARCHIVE="night-agent-${VERSION}-darwin-arm64.tar.gz" + + mkdir -p dist + cp nightagent guardian-shim guardian-intercept.dylib dist/ + cp -r configs dist/ + + tar -czf "${ARCHIVE}" -C dist . + shasum -a 256 "${ARCHIVE}" | awk '{print $1}' > "${ARCHIVE}.sha256" + + echo "ARCHIVE=${ARCHIVE}" >> "$GITHUB_ENV" + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if gh release view "${VERSION}" &>/dev/null; then + gh release upload "${VERSION}" "${ARCHIVE}" "${ARCHIVE}.sha256" --clobber + else + gh release create "${VERSION}" \ + --title "Night Agent ${VERSION}" \ + --generate-notes \ + "${ARCHIVE}" \ + "${ARCHIVE}.sha256" + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74e8c1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Binaries (solo root, non directory col nome) +/nightagent +/night-agent +/guardian +/guardian-shim +/guardian-intercept.dylib + +# macOS +.DS_Store + +# Go +*.test +*.out +/vendor/ + +# Night Agent config locale +.nightagent/ + +# Documenti privati (non versionati) +docs/private/ diff --git a/CLAUDE.md b/CLAUDE.md index 59a4d4f..2e85fb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,92 +4,136 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**AI Guardian** è un CLI tool per macOS che funge da runtime security layer per agenti AI (Claude Code, Codex, ecc.). Intercetta e governa le azioni degli agenti prima che vengano eseguite. Il progetto è attualmente in fase di pianificazione/design — il codice è ancora da scrivere. +**Night Agent** è un CLI tool per macOS che funge da runtime security layer per agenti AI (Claude Code, Codex, ecc.). Intercetta e governa le azioni degli agenti prima che vengano eseguite. -Documento di specifica completo: [docs/ai-agent-guardian-mvp-plan.md](docs/ai-agent-guardian-mvp-plan.md) +Documento di specifica completo: [docs/ai-night-agent-mvp-plan.md](docs/ai-night-agent-mvp-plan.md) -## Tech Stack Pianificato +**Stato attuale: Cycle 1 ✅ + Cycle 2 ✅ + Cycle 3 ✅** -- **Linguaggio**: Go (single binary, process handling, cross-platform) +## Tech Stack + +- **Linguaggio**: Go (single binary) + C (shim + dylib) - **Config**: YAML -- **Logging**: SQLite o JSONL -- **Packaging**: Homebrew tap -- **Sandbox (Cycle 2)**: Docker Desktop +- **Logging**: JSONL +- **Sandbox**: Docker Desktop (Cycle 2) +- **Packaging**: Homebrew tap (futuro) + +## Struttura del Progetto + +``` +cmd/guardian/ CLI principale (Cobra) — init, run, start, logs, policy, doctor, sandbox, uninstall +internal/ + policy/ Policy engine — load/evaluate YAML, glob/regex matching, SandboxConfig + audit/ Audit logger — JSONL append-only, filtri decision/action_type, campi sandbox + risk + daemon/ Unix socket server — valuta policy, gestisce sandbox Docker, routing decisioni + sandbox/ Sandbox manager — docker run wrapper, resolveDockerBinary, BuildDockerArgs + scorer/ Risk scorer — heuristics pesate, anomaly burst detection (Cycle 3) + suggestions/ Policy suggestion engine — hints contestuali su path, override, anomalie (Cycle 3) + shim/ PATH shim — CreateSymlinks, ShimmedCommands (include python/python3) + intercept/ DYLD injection — per agenti senza Hardened Runtime + interception/ Normalizer — classifica comandi in shell/git/file + shell/ Hook injector — preexec in .zshrc, gestisce block + sandbox response + wizard/ Setup wizard — UI ASCII art + progress bar + policyeditor/ Policy CRUD — toggle/add/remove rules + launchagent/ LaunchAgent macOS — autostart al login +configs/ + default_policy.yaml Policy di default con regole Cycle 1 + Cycle 2 (sandbox) +``` ## Architettura — 5 Layer Logici ``` Agente AI ↓ -[Interception Layer] ← cattura shell commands, file ops, git ops, processi +[Interception Layer] ← PATH shims + DYLD injection + shell hook preexec ↓ -[Policy Engine] ← valuta regole YAML → decisione: allow / block / ask / sandbox +[Policy Engine] ← valuta regole YAML → allow / block / ask / sandbox ↓ -[Execution Layer] ← esegue azioni approvate (locale o isolato) +[Execution Layer] ← locale (allow) · bloccato (block) · Docker isolato (sandbox) ↓ -[Audit Layer] ← log con timestamp, decisione, motivo, contesto +[Audit Layer] ← JSONL con campi sandbox (sandboxed, sandbox_image, sandbox_exit_code) ↑ -[Config & UX Layer] ← CLI per init, logs, policy edit, approve +[Config & UX Layer] ← CLI completo ``` -## CLI Commands Pianificati +## CLI Commands Implementati ```bash -guardian init # Inizializza progetto con policy di default -guardian run # Esegue agente con protezione attiva -guardian logs # Visualizza audit trail -guardian policy edit # Modifica regole di policy -guardian doctor # Diagnostica e verifica setup -guardian sandbox run # Esegue in container isolato (Cycle 2) +nightagent init [--yes] # Setup + wizard policy +nightagent start # Avvia daemon (Unix socket) +nightagent run # Avvia agente con interception attiva +nightagent sandbox run # Esegui comando in Docker sandbox (Cycle 2) + --image # Immagine Docker (default: alpine:3.20) + --network # Rete: none (default) o bridge +nightagent logs [--decision] [--type] [--limit] [--json] +nightagent policy list|toggle|add|remove +nightagent doctor # Check installazione + Docker status +nightagent uninstall +nightagent help ``` ## Policy Model ```yaml +version: 1 rules: - id: block_sudo when: {action_type: shell, command_matches: ["sudo *"]} + match_type: glob decision: block - reason: "sudo disabled" - - - id: ask_git_push_main - when: {action_type: git, command_matches: ["git push origin main"]} - decision: ask - reason: "push to protected branch" + reason: "sudo disabilitato" + + - id: sandbox_python_scripts + when: {action_type: shell, command_matches: ["python3 *.py"]} + match_type: glob + decision: sandbox + sandbox: + image: "python:3.12-alpine" + network: "none" + reason: "script Python in ambiente isolato" ``` -Decisioni possibili: `allow`, `block`, `ask`, `sandbox` +Decisioni: `allow`, `block`, `ask` (= block a runtime), `sandbox` + +## Comportamento Sandbox (Cycle 2) + +Quando il policy engine restituisce `sandbox`: + +1. Il daemon verifica che Docker sia disponibile (`resolveDockerBinary` — cerca anche in path fissi macOS) +2. Avvia `docker run --rm --network -v :/workspace:rw -w /workspace sh -c ` +3. I path host nel comando vengono riscritti (`workdir` → `/workspace`) +4. Cattura stdout/stderr/exit code +5. Restituisce risposta `{"decision":"sandbox","exit_code":N,"output":"..."}` al shim +6. Il shim stampa `[⬡ sandbox] ` su stderr e propaga l'exit code +7. L'evento viene loggato con `sandboxed: true`, `sandbox_image`, `sandbox_exit_code` + +Fail-safe: se Docker non è disponibile → blocca con messaggio esplicito. ## Roadmap -- **Cycle 1 (MVP)**: Policy engine rule-based, intercettazione shell + git, audit log -- **Cycle 2**: Docker sandbox integration, network isolation -- **Cycle 3**: Risk scoring, anomaly detection, policy suggestions +- **Cycle 1** ✅ — Policy engine, PATH shims, DYLD, shell hook, audit log, LaunchAgent +- **Cycle 2** ✅ — Docker sandbox, `nightagent sandbox run`, routing automatico, path rewriting +- **Cycle 3** ✅ — Risk scorer (heuristics), anomaly detection, policy suggestions, risk score in logs ## Repository & Git Workflow -- **Remote**: [github.com/pietroperona/agent-guardian](https://github.com/pietroperona/agent-guardian) -- **Branch principali**: - - `main` — produzione, solo merge da feature branch o staging - - `develop` — sviluppo locale - - `staging` — ambiente di staging (futuro) -- **Feature branch**: ogni funzione specifica ha il suo branch (`feature/nome`), poi merge su `main` +- **Remote**: [github.com/night-agent-cli/night-agent](https://github.com/night-agent-cli/night-agent) +- **Branch principali**: `main` (produzione), `develop` - **Commit**: non citare mai il nome di strumenti AI nei messaggi di commit ## Approccio di Sviluppo — TDD Tutto lo sviluppo segue **Test-Driven Development**: -1. Scrivi il test che descrive il comportamento atteso (red) +1. Scrivi il test (red) 2. Scrivi il codice minimo per farlo passare (green) -3. Refactoring se necessario (refactor) - -In Go, ogni package ha il suo file `_test.go`. Eseguire i test con: +3. Refactoring se necessario ```bash -go test ./... # tutti i test -go test ./internal/policy # test di un package specifico +go test ./... # tutti i test +go test ./internal/sandbox/... # solo sandbox go test -run TestNomeFunzione ./... # singolo test +make # build completo (dylib + shim + nightagent) ``` ## Principi di Design Fondamentali @@ -97,13 +141,6 @@ go test -run TestNomeFunzione ./... # singolo test 1. Controlla **azioni**, non intenzioni 2. **Determinismo** prima di intelligenza — no ML per decisioni hard 3. **Explainability** — l'utente deve sempre capire perché qualcosa è bloccato -4. **Safe failure mode** — in caso di errore del guardian, blocca (non permette) +4. **Safe failure mode** — in caso di errore, blocca (non permette) 5. Security by default, friction minimale 6. Framework-agnostic — funziona con qualsiasi agente AI - -## Superfici da Proteggere (Scope MVP) - -- Shell commands pericolosi (`rm -rf`, `curl | bash`, `chmod 777`, ecc.) -- File operations su path sensibili (`~/.ssh`, `~/.aws`, `.env`) -- Git operations (`git push --force`, push su main/master) -- Installazione package/processi non autorizzati diff --git a/Formula/night-agent.rb b/Formula/night-agent.rb new file mode 100644 index 0000000..3e2aaed --- /dev/null +++ b/Formula/night-agent.rb @@ -0,0 +1,76 @@ +class NightAgent < Formula + desc "Runtime security layer for AI agents on macOS" + homepage "https://github.com/night-agent-cli/night-agent" + url "https://github.com/night-agent-cli/night-agent/archive/refs/tags/v0.2.2.tar.gz" + sha256 "0019dfc4b32d63c1392aa264aed2253c1e0c2fb09216f8e2cc269bbfb8bb49b5" + license "MIT" + head "https://github.com/night-agent-cli/night-agent.git", branch: "main" + + depends_on "go" => :build + depends_on :macos + + def install + # Compila il binario principale Go + system "go", "build", "-o", bin/"nightagent", "./cmd/guardian" + + # Compila lo shim C (intercettazione comandi via PATH) + system "clang", "-o", libexec/"guardian-shim", + "internal/shim/csrc/guardian_shim.c", + "-Wall", "-Wextra", "-Wno-unused-parameter" + + # Compila la dylib DYLD_INSERT_LIBRARIES (opzionale, per intercettazione avanzata) + system "clang", "-dynamiclib", + "-o", lib/"guardian-intercept.dylib", + "internal/intercept/csrc/guardian_intercept.c", + "-Wall", "-Wextra", "-Wno-unused-parameter", + "-current_version", "1.0", + "-compatibility_version", "1.0" + + # Installa la policy di default + pkgshare.install "configs" + + # Crea symlink per lo shim nella libexec + (libexec/"shims").mkpath + end + + def post_install + # Copia la policy di default se non esiste già + guardian_dir = Pathname.new(ENV["HOME"]) / ".night-agent" + policy_dest = guardian_dir / "policy.yaml" + policy_src = pkgshare / "configs" / "default_policy.yaml" + + unless policy_dest.exist? + guardian_dir.mkpath + FileUtils.cp policy_src, policy_dest + policy_dest.chmod(0600) + end + end + + def caveats + <<~EOS + Night Agent è stato installato. Per completare la configurazione: + + 1. Inizializza Night Agent: + nightagent init + + 2. Per le funzionalità sandbox, installa Docker Desktop: + https://www.docker.com/products/docker-desktop/ + Avvialo almeno una volta manualmente dopo l'installazione. + + 3. Riavvia il terminale o esegui: + source ~/.zshrc + + Per verificare che tutto funzioni: + nightagent doctor + EOS + end + + test do + # Test che il binario risponde correttamente + output = shell_output("#{bin}/nightagent --help") + assert_match "nightagent", output + + # Test che la policy di default è accessibile + assert_predicate pkgshare / "configs" / "default_policy.yaml", :exist? + end +end diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..33fcc67 --- /dev/null +++ b/Makefile @@ -0,0 +1,102 @@ +# ── Platform detection ────────────────────────────────────────────── +ifeq ($(OS), Windows_NT) + PLATFORM = windows + CC = gcc + DYLIB_EXT = dll + DYLIB_LDFLAGS = -shared + EXTRA_LDFLAGS = -lws2_32 + EXE = .exe + CLEAN_CMD = del /f +else + UNAME := $(shell uname) + EXTRA_LDFLAGS = + EXE = + CLEAN_CMD = rm -f + + ifeq ($(UNAME), Darwin) + PLATFORM = darwin + CC = clang + DYLIB_EXT = dylib + DYLIB_LDFLAGS = -dynamiclib \ + -current_version 1.0 \ + -compatibility_version 1.0 + else + PLATFORM = linux + CC = clang + DYLIB_EXT = so + DYLIB_LDFLAGS = -shared -fPIC + endif +endif + +# ── Variables ──────────────────────────────────────────────────────── +DYLIB = guardian-intercept.$(DYLIB_EXT) +DYLIB_SRC = internal/intercept/csrc/guardian_intercept.c +BINARY = nightagent$(EXE) +HELPER_SRC = internal/intercept/testdata/exec-helper/main.c +HELPER = internal/intercept/testdata/exec-helper/exec-helper$(EXE) +SHIM = guardian-shim$(EXE) +SHIM_SRC = internal/shim/csrc/guardian_shim.c + +ENDPOINT_PKG = github.com/pietroperona/night-agent/internal/cloudconfig.defaultEndpoint +ENDPOINT_PROD = https://api.nightagent.dev +ENDPOINT_STAGING = https://staging.api.nightagent.dev +ENDPOINT_DEV = http://localhost:8000 + +.PHONY: all build build-dev build-staging dylib shim helper test integration-test clean install-dev + +all: dylib shim helper build + +# ── Build targets ──────────────────────────────────────────────────── + +build: + go build -ldflags "-X '$(ENDPOINT_PKG)=$(ENDPOINT_PROD)'" -o $(BINARY) ./cmd/guardian + +build-staging: + go build -ldflags "-X '$(ENDPOINT_PKG)=$(ENDPOINT_STAGING)'" -o $(BINARY) ./cmd/guardian + +build-dev: + go build -ldflags "-X '$(ENDPOINT_PKG)=$(ENDPOINT_DEV)'" -o $(BINARY) ./cmd/guardian + +# install-dev: disponibile solo su Unix (Linux / macOS) +ifeq ($(OS), Windows_NT) +install-dev: + @echo "install-dev non supportato su Windows" +else +install-dev: build-dev + cp $(BINARY) $(HOME)/.local/bin/$(BINARY) + @echo "installato: $(HOME)/.local/bin/$(BINARY) -> $(ENDPOINT_DEV)" +endif + +# ── C compilation ──────────────────────────────────────────────────── + +dylib: + $(CC) $(DYLIB_LDFLAGS) \ + -o $(DYLIB) $(DYLIB_SRC) \ + -Wall -Wextra \ + -Wno-unused-parameter \ + $(EXTRA_LDFLAGS) + @echo "dylib compilata: $(DYLIB) [$(PLATFORM)]" + +shim: + $(CC) -o $(SHIM) $(SHIM_SRC) \ + -Wall -Wextra \ + -Wno-unused-parameter \ + $(EXTRA_LDFLAGS) + @echo "shim compilato: $(SHIM)" + +helper: + $(CC) -o $(HELPER) $(HELPER_SRC) -Wall + @echo "exec-helper compilato: $(HELPER)" + +# ── Tests ───────────────────────────────────────────────────────────── + +test: + go test ./... + +integration-test: dylib shim helper + go test -tags integration ./internal/intercept/... -v + +# ── Clean ───────────────────────────────────────────────────────────── + +clean: + $(CLEAN_CMD) $(BINARY) $(DYLIB) $(SHIM) $(HELPER) diff --git a/README.md b/README.md new file mode 100644 index 0000000..84c05ab --- /dev/null +++ b/README.md @@ -0,0 +1,289 @@ +# Night Agent CLI + +![License: MIT](https://img.shields.io/badge/license-MIT-blue) +![Platform: macOS](https://img.shields.io/badge/platform-macOS-lightgrey) +![Arch: arm64](https://img.shields.io/badge/arch-arm64-lightgrey) +![Release](https://img.shields.io/github/v/release/night-agent-cli/night-agent) + +**Intercetta e governa ogni comando eseguito dagli agenti AI sul tuo Mac.** + +Gli agenti AI come Claude Code, Codex o Cursor possono eseguire comandi shell, modificare file, fare push su Git spesso senza che tu li veda. Night Agent si mette in mezzo: ogni azione passa per un policy engine che decide se permetterla, bloccarla o eseguirla in un container Docker isolato, secondo regole che definisci tu in YAML. + +--- + +## Installazione rapida + +```bash +curl -sSL https://raw.githubusercontent.com/night-agent-cli/night-agent/main/install.sh | bash +``` + +Poi inizializza e verifica: + +```bash +nightagent init +nightagent doctor +``` + +`init` avvia un wizard interattivo per configurare le prime regole di policy e registra il daemon come LaunchAgent (si avvia automaticamente al login). `doctor` conferma che tutto funziona. + +> **macOS Gatekeeper**: se vedi "cannot be opened because Apple cannot verify the developer", esegui: +> ```bash +> sudo xattr -d com.apple.quarantine /usr/local/bin/nightagent +> sudo xattr -d com.apple.quarantine /usr/local/lib/night-agent/guardian-shim +> sudo xattr -d com.apple.quarantine /usr/local/lib/night-agent/guardian-intercept.dylib +> ``` + +**Requisiti**: macOS arm64 (Apple Silicon). Docker Desktop opzionale, necessario solo per la modalita sandbox. + +--- + +## Agenti supportati + +| Agente | Intercettazione | +|--------|----------------| +| Claude Code | PATH shims + MCP hooks (PreToolUse) | +| Codex CLI | PATH shims | +| GitHub Copilot Workspace | PATH shims | +| Cursor | PATH shims | +| Qualsiasi agente CLI | PATH shims | + +I **PATH shims** intercettano i comandi shell (`git`, `curl`, `rm`, `python3`...) per tutti gli agenti. +I **MCP hooks** intercettano le tool call interne di Claude Code (`Bash`, `Edit`, `Write`, `WebFetch`...) che non passano per la shell. + +--- + +## Come funziona + +``` +Agente AI (Claude Code, python3, bash...) + | + [PATH shims] ogni comando passa per guardian-shim + | + [Policy engine] valuta le regole YAML + | + allow --> esegue il comando sull'host + block --> blocca con messaggio + sandbox --> esegue in container Docker isolato + | + [Audit log] ogni evento registrato in ~/.night-agent/audit.jsonl +``` + +Il daemon gira in background, avviato automaticamente al login tramite LaunchAgent macOS. + +--- + +## Integrazione Claude Code + +Per intercettare anche le tool call MCP (non solo i comandi shell), aggiungi questo hook in `~/.claude/settings.json`: + +```json +{ + "hooks": { + "PreToolUse": [{ + "matcher": "*", + "hooks": [{ + "type": "command", + "command": "nightagent mcp-hook --tool $TOOL_NAME --input-file $TOOL_INPUT_FILE" + }] + }] + } +} +``` + +Se `~/.claude/settings.json` non esiste, crealo. Se esiste gia, aggiungi solo la chiave `hooks` preservando il resto. Riavvia Claude Code dopo la modifica. + +Da questo momento ogni tool call (`Bash`, `Edit`, `Write`, `Read`, `WebFetch`...) viene valutata dalla stessa policy YAML che governa i comandi shell. Nessuna configurazione doppia. + +**Tool intercettati:** + +| Tool | Cosa viene valutato | +|------|-------------------| +| `Bash` | comando shell completo + workdir | +| `Edit` | path del file modificato | +| `Write` | path del file scritto | +| `Read` | path del file letto | +| `WebFetch` | URL della richiesta | +| `WebSearch` | query di ricerca | +| tool custom | nome del tool come identificatore | + +Se il daemon non e in ascolto, `mcp-hook` consente l'esecuzione senza bloccare (fail-open). Avvia il daemon prima di usare Claude Code: + +```bash +nightagent start +``` + +--- + +## Policy + +Le regole sono in `~/.night-agent/policy.yaml`. Ogni regola ha un'azione (`allow`, `block`, `ask`, `sandbox`) e un pattern di matching. + +### Esempio + +```yaml +version: 1 +rules: + - id: block_sudo + when: + action_type: shell + command_matches: ["sudo *", "*/sudo *"] + match_type: glob + decision: block + reason: "sudo disabilitato per gli agenti AI" + + - id: block_rm_rf + when: + action_type: shell + command_matches: ["rm -rf *", "rm -fr *"] + match_type: glob + decision: ask + reason: "cancellazione ricorsiva richiede conferma" + + - id: ask_git_push_main + when: + action_type: git + command_matches: ["git push * main", "git push * master", "git push --force *"] + match_type: glob + decision: ask + reason: "push su branch protetto richiede conferma" + + - id: sandbox_python_scripts + when: + action_type: shell + command_matches: ["python *.py", "python3 *.py"] + match_type: glob + decision: sandbox + sandbox: + image: "python:3.12-alpine" + network: "none" + reason: "script Python eseguito in ambiente isolato" +``` + +**Decisioni**: `allow` esegue, `block` blocca, `ask` blocca con messaggio (trattato come block a runtime), `sandbox` esegue in Docker. + +### Configurazione sandbox + +Il campo `sandbox` si applica solo alle regole con `decision: sandbox`. Il workspace corrente viene montato automaticamente come `/workspace` nel container. + +| Campo | Default | Valori | +|-------|---------|--------| +| `image` | `alpine:3.20` | qualsiasi immagine Docker | +| `network` | `none` | `none`, `bridge` | + +### Gestione regole via CLI + +```bash +nightagent policy list # mostra tutte le regole +nightagent policy toggle block_sudo # attiva/disattiva una regola +nightagent policy add # aggiungi una regola interattivamente +nightagent policy remove block_sudo # rimuovi una regola +``` + +--- + +## Risk scoring + +Night Agent assegna a ogni azione un punteggio di rischio euristico, indipendente dalla policy. Il punteggio e visibile nei log come segnale aggiuntivo — non modifica mai la decisione della policy. + +| Segnale | Peso | +|---------|------| +| `sudo` nel comando | +0.50 | +| `curl`/`wget` piped a `bash`/`sh` | +0.70 | +| `rm` ricorsivo (`-r`, `-rf`) | +0.30 | +| `chmod 777` | +0.30 | +| `git push --force` | +0.50 | +| `git push` su `main`/`master` | +0.20 | +| Accesso a path sensibili (`.env`, `.ssh`, `.aws`) | +0.30 | +| Installazione pacchetti (`pip`, `npm`, `brew`) | +0.15 | +| Script shell (`bash *.sh`) | +0.20 | +| Burst anomalo (>10 azioni in 30s) | +0.25 | +| 3 o piu blocchi nelle azioni recenti | +0.25 | + +Livelli: `low` (<0.3) · `medium` (0.3-0.7) · `high` (>=0.7). Il `!` nel log segnala un'anomalia contestuale. + +--- + +## Log e audit + +```bash +nightagent logs # tutti gli eventi +nightagent logs --limit 20 # ultimi 20 +nightagent logs --decision block # solo eventi bloccati +nightagent logs --decision sandbox # solo eventi sandbox +nightagent logs --json # output strutturato JSONL +``` + +Output: + +``` +TIMESTAMP DECISIONE RISCHIO TIPO COMANDO MOTIVO +2026-04-12 10:01:02 allow low(0.00) git git status +2026-04-12 10:01:15 block high(0.80) shell sudo rm -rf /var/log sudo disabilitato +2026-04-12 10:01:33 sandbox medium(0.35)! shell python3 deploy.py script Python in sandbox +``` + +Ogni evento e firmato con HMAC-SHA256 e collegato al precedente tramite una catena hash (tamper-evident log). Per verificare l'integrita: + +```bash +nightagent verify +``` + +La chiave di firma e in `~/.night-agent/signing.key` (permessi `0600`), generata durante `init` e mai trasmessa. + +--- + +## Comandi + +``` +nightagent init avvia il wizard di configurazione +nightagent init --yes configura con tutti i default, senza wizard +nightagent start avvia il daemon in foreground +nightagent run [args...] avvia un agente AI sotto protezione +nightagent sandbox run esegui un comando esplicitamente in sandbox Docker + --image immagine Docker da usare (default: alpine:3.20) + --network modalita rete: none (default) o bridge +nightagent policy list mostra tutte le regole +nightagent policy toggle attiva/disattiva una regola +nightagent policy add aggiungi una regola interattivamente +nightagent policy remove rimuovi una regola +nightagent logs mostra l'audit trail +nightagent logs --decision filtra per decisione (allow/block/sandbox) +nightagent logs --type filtra per tipo azione +nightagent logs --limit limita il numero di righe +nightagent logs --json output JSONL strutturato +nightagent verify verifica integrita firme nell'audit log +nightagent doctor diagnostica installazione e stato Docker +nightagent uninstall rimuove Night Agent dal sistema +nightagent help mostra l'help +``` + +--- + +## Build da sorgente + +**Prerequisiti**: macOS arm64, Go 1.21+, Xcode Command Line Tools, Docker Desktop (opzionale). + +```bash +xcode-select --install +git clone https://github.com/night-agent-cli/night-agent +cd night-agent +make all +./nightagent init +``` + +`make all` produce tre artifact: `nightagent` (CLI Go), `guardian-shim` (C, intercettazione PATH), `guardian-intercept.dylib` (C, DYLD injection per agenti senza Hardened Runtime). + +--- + +## Limitazioni + +- Claude Code e altri agenti con Hardened Runtime non sono intercettabili via `DYLD_INSERT_LIBRARIES`. Night Agent usa PATH shims come approccio principale, compatibile con qualsiasi agente. +- Intercetta comandi eseguiti via shell. Syscall dirette o chiamate native non passano per il layer di interception. +- La sandbox richiede Docker Desktop installato e in esecuzione. Se Docker non e disponibile, le regole `sandbox` fanno fail-safe su `block`. +- Richiede macOS arm64. Linux e Windows al momento non sono ancora supportati. + +--- + +## Licenza + +MIT diff --git a/cmd/guardian/cloud.go b/cmd/guardian/cloud.go new file mode 100644 index 0000000..ccf6e8f --- /dev/null +++ b/cmd/guardian/cloud.go @@ -0,0 +1,281 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/night-agent-cli/night-agent/internal/audit" + "github.com/night-agent-cli/night-agent/internal/cloudconfig" + "github.com/night-agent-cli/night-agent/internal/configdir" + cloudsync "github.com/night-agent-cli/night-agent/internal/sync" + "github.com/spf13/cobra" +) + +var cloudCmd = &cobra.Command{ + Use: "cloud", + Short: "Gestisci connessione cloud Night Agent", +} + +var cloudConnectCmd = &cobra.Command{ + Use: "connect ", + Short: "Connetti al cloud Night Agent con il token fornito", + Args: cobra.ExactArgs(1), + RunE: runCloudConnect, +} + +var cloudStatusCmd = &cobra.Command{ + Use: "status", + Short: "Mostra stato connessione cloud", + RunE: runCloudStatus, +} + +var cloudDisconnectCmd = &cobra.Command{ + Use: "disconnect", + Short: "Disconnetti dal cloud Night Agent", + RunE: runCloudDisconnect, +} + +var cloudSyncCmd = &cobra.Command{ + Use: "sync", + Short: "Sincronizza manualmente gli eventi con il cloud", + RunE: runCloudSync, +} + +func init() { + cloudCmd.AddCommand(cloudConnectCmd) + cloudCmd.AddCommand(cloudStatusCmd) + cloudCmd.AddCommand(cloudDisconnectCmd) + cloudCmd.AddCommand(cloudSyncCmd) + rootCmd.AddCommand(cloudCmd) +} + +// cloudConfigPath restituisce il path di cloud.yaml nella config dir risolta. +func cloudConfigPath() (string, error) { + dir, err := resolveConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "cloud.yaml"), nil +} + +// cloudLogPath restituisce il path di audit.jsonl nella config dir risolta. +func cloudLogPath() (string, error) { + dir, err := resolveConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "audit.jsonl"), nil +} + +func runCloudConnect(_ *cobra.Command, args []string) error { + token := args[0] + + cwd, err := os.Getwd() + if err != nil { + return err + } + + // connect crea sempre una config dir locale (a meno che --global) + var cfgDir string + if globalConfig { + cfgDir, err = configdir.Global() + } else { + cfgDir, err = configdir.CreateLocal(cwd) + } + if err != nil { + return err + } + + cfgPath := filepath.Join(cfgDir, "cloud.yaml") + + // genera signing key dedicata per questa config dir + keyPath := filepath.Join(cfgDir, "signing.key") + if err := audit.GenerateKey(keyPath); err != nil { + return fmt.Errorf("generazione signing key: %w", err) + } + + cfg, err := cloudconfig.Connect(cfgPath, token) + if err != nil { + return fmt.Errorf("connessione fallita: %w", err) + } + + // aggiungi .nightagent/ al .gitignore del progetto se non è config globale + if configdir.IsLocal(cfgDir) { + if err := addToGitignore(cwd, configdir.LocalDirName+"/"); err != nil { + fmt.Fprintf(os.Stderr, " avviso: impossibile aggiornare .gitignore: %v\n", err) + } + } + + if err := registerSigningKey(cfg, keyPath); err != nil { + fmt.Fprintf(os.Stderr, " avviso: registrazione chiave di firma fallita: %v\n", err) + } + + fmt.Println(" ✓ connesso al cloud Night Agent") + fmt.Printf(" config dir : %s\n", cfgDir) + fmt.Printf(" endpoint : %s\n", cfg.Endpoint) + fmt.Printf(" machine : %s\n", cfg.MachineID) + fmt.Println() + fmt.Println(" sync automatico: avvia il daemon con 'nightagent start'") + fmt.Println(" sync manuale : 'nightagent cloud sync'") + return nil +} + +// addToGitignore aggiunge entry al .gitignore nella dir indicata se esiste. +// Idempotente: non aggiunge se entry è già presente. +func addToGitignore(dir, entry string) error { + gitignorePath := filepath.Join(dir, ".gitignore") + if _, err := os.Stat(gitignorePath); os.IsNotExist(err) { + return nil // nessun .gitignore, niente da fare + } + + // controlla se entry già presente + f, err := os.Open(gitignorePath) + if err != nil { + return err + } + scanner := bufio.NewScanner(f) + for scanner.Scan() { + if strings.TrimSpace(scanner.Text()) == entry { + f.Close() + return nil // già presente + } + } + f.Close() + + // appendi entry + out, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer out.Close() + _, err = fmt.Fprintf(out, "\n# Night Agent config locale\n%s\n", entry) + return err +} + +// registerSigningKey invia la chiave di firma al backend cloud. +// La chiave è in formato hex — viene letta e inviata as-is. +func registerSigningKey(cfg *cloudconfig.Config, keyPath string) error { + keyBytes, err := os.ReadFile(keyPath) + if err != nil { + return fmt.Errorf("lettura signing.key: %w", err) + } + + body, err := json.Marshal(map[string]string{ + "machine_id": cfg.MachineID, + "signing_key": strings.TrimSpace(string(keyBytes)), + }) + if err != nil { + return err + } + + url := cfg.Endpoint + "/api/machines/signing-key" + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+cfg.Token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server ha risposto %d", resp.StatusCode) + } + return nil +} + +func runCloudStatus(_ *cobra.Command, _ []string) error { + dir, err := resolveConfigDir() + if err != nil { + return err + } + cfgPath := filepath.Join(dir, "cloud.yaml") + + cfg, err := cloudconfig.Load(cfgPath) + if err != nil { + return err + } + + if !cfg.Connected || cfg.Token == "" { + fmt.Println(" cloud: non connesso") + fmt.Println(" usa 'nightagent cloud connect ' per connetterti") + return nil + } + + fmt.Println(" cloud: connesso") + fmt.Printf(" config dir : %s\n", dir) + fmt.Printf(" endpoint : %s\n", cfg.Endpoint) + fmt.Printf(" machine : %s\n", cfg.MachineID) + + if cfg.Cursor != "" { + fmt.Printf(" cursore : %s\n", cfg.Cursor) + } else { + fmt.Println(" cursore : nessun sync effettuato") + } + + if !cfg.LastSync.IsZero() { + ago := time.Since(cfg.LastSync).Round(time.Second) + fmt.Printf(" ultimo sync: %s fa (%s)\n", ago, cfg.LastSync.Format("2006-01-02 15:04:05")) + } else { + fmt.Println(" ultimo sync: mai") + } + return nil +} + +func runCloudDisconnect(_ *cobra.Command, _ []string) error { + dir, err := resolveConfigDir() + if err != nil { + return err + } + cfgPath := filepath.Join(dir, "cloud.yaml") + + if err := cloudconfig.Disconnect(cfgPath); err != nil { + return fmt.Errorf("disconnessione fallita: %w", err) + } + + fmt.Println(" ✓ disconnesso dal cloud Night Agent") + fmt.Printf(" token rimosso da %s\n", cfgPath) + return nil +} + +func runCloudSync(_ *cobra.Command, _ []string) error { + dir, err := resolveConfigDir() + if err != nil { + return err + } + cfgPath := filepath.Join(dir, "cloud.yaml") + logPath := filepath.Join(dir, "audit.jsonl") + + cfg, err := cloudconfig.Load(cfgPath) + if err != nil { + return err + } + if !cfg.Connected || cfg.Token == "" { + return fmt.Errorf("non connesso — esegui 'nightagent cloud connect '") + } + + fmt.Print(" sincronizzazione in corso... ") + agent := cloudsync.NewAgent(cfgPath, logPath) + if err := agent.SyncOnce(); err != nil { + fmt.Println("✗") + return err + } + + updated, _ := cloudconfig.Load(cfgPath) + fmt.Println("✓") + if updated != nil && updated.Cursor != "" { + fmt.Printf(" ultimo evento sincronizzato: %s\n", updated.Cursor) + } + return nil +} diff --git a/cmd/guardian/configdir.go b/cmd/guardian/configdir.go new file mode 100644 index 0000000..b4bd192 --- /dev/null +++ b/cmd/guardian/configdir.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + + "github.com/night-agent-cli/night-agent/internal/configdir" +) + +// resolveConfigDir restituisce la config dir da usare per il comando corrente. +// Se --global è passato → ~/.night-agent/. +// Altrimenti → .nightagent/ nella cwd se esiste, fallback ~/.night-agent/. +func resolveConfigDir() (string, error) { + if globalConfig { + return configdir.Global() + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return configdir.Resolve(cwd) +} diff --git a/cmd/guardian/doctor.go b/cmd/guardian/doctor.go new file mode 100644 index 0000000..c75f0c4 --- /dev/null +++ b/cmd/guardian/doctor.go @@ -0,0 +1,98 @@ +package main + +import ( + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/night-agent-cli/night-agent/internal/shell" + "github.com/spf13/cobra" +) + +var doctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Verifica lo stato dell'installazione di Night Agent", + RunE: runDoctor, +} + +func init() { + rootCmd.AddCommand(doctorCmd) +} + +func runDoctor(cmd *cobra.Command, args []string) error { + home, _ := os.UserHomeDir() + guardianDir := filepath.Join(home, ".night-agent") + policyPath := filepath.Join(guardianDir, "policy.yaml") + socketPath := filepath.Join(guardianDir, "night-agent.sock") + + allOK := true + + check := func(label string, ok bool, detail string) { + status := "✓" + if !ok { + status = "✗" + allOK = false + } + if detail != "" { + fmt.Printf(" %s %s — %s\n", status, label, detail) + } else { + fmt.Printf(" %s %s\n", status, label) + } + } + + fmt.Println("Night Agent — diagnostica:") + + _, errDir := os.Stat(guardianDir) + check("directory ~/.night-agent", errDir == nil, guardianDir) + + _, errPolicy := os.Stat(policyPath) + check("policy.yaml", errPolicy == nil, policyPath) + + rcPath := filepath.Join(home, ".zshrc") + check("hook shell (.zshrc)", shell.IsInjected(rcPath), "") + + daemonRunning := isDaemonRunning(socketPath) + check("daemon in esecuzione", daemonRunning, socketPath) + + fmt.Println() + fmt.Println("Sandbox (Ciclo 2):") + + dockerInstalled := isDockerInstalled() + check("Docker installato", dockerInstalled, "") + + dockerRunning := false + if dockerInstalled { + dockerRunning = isDockerRunning() + } + check("Docker daemon in esecuzione", dockerRunning, "") + + fmt.Println() + if allOK { + fmt.Println("tutto ok — night-agent è operativo") + } else { + fmt.Println("alcuni controlli falliti — esegui 'night-agent init' per configurare") + } + return nil +} + +func isDockerInstalled() bool { + _, err := exec.LookPath("docker") + return err == nil +} + +func isDockerRunning() bool { + cmd := exec.Command("docker", "info") + return cmd.Run() == nil +} + +func isDaemonRunning(socketPath string) bool { + conn, err := net.DialTimeout("unix", socketPath, 500*time.Millisecond) + if err != nil { + return false + } + conn.Close() + return true +} diff --git a/cmd/guardian/help.go b/cmd/guardian/help.go new file mode 100644 index 0000000..fb7a346 --- /dev/null +++ b/cmd/guardian/help.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +// ANSI — riutilizza le costanti già definite in policy.go +// (stesso package main, stesso file di build) + +var helpCmd = &cobra.Command{ + Use: "help", + Short: "Mostra tutti i comandi disponibili", + Run: runHelp, +} + +func init() { + // sostituisce il comando help di default di cobra + rootCmd.SetHelpCommand(helpCmd) + rootCmd.AddCommand(helpCmd) +} + +func runHelp(cmd *cobra.Command, args []string) { + w := os.Stdout + + fmt.Fprintln(w, ansiBold+ansiBoldCyan+` + ███╗ ██╗██╗ ██████╗ ██╗ ██╗████████╗ + ████╗ ██║██║██╔════╝ ██║ ██║╚══██╔══╝ + ██╔██╗ ██║██║██║ ███╗███████║ ██║ + ██║╚██╗██║██║██║ ██║██╔══██║ ██║ + ██║ ╚████║██║╚██████╔╝██║ ██║ ██║ + ╚═╝ ╚═══╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ + + █████╗ ██████╗ ███████╗███╗ ██╗████████╗ + ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝ + ███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ + ██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ + ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ + ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝`+ansiReset) + + fmt.Fprintln(w, ansiDim+" ─────────────────────────────────────────────────────────"+ansiReset) + fmt.Fprintln(w, ansiBold+ansiBoldWhite+" Runtime security layer per agenti AI"+ansiReset) + fmt.Fprintln(w, ansiDim+" ─────────────────────────────────────────────────────────"+ansiReset) + fmt.Fprintln(w) + + section := func(title string) { + fmt.Fprintf(w, " %s%s%s\n", ansiBold+ansiCyan, title, ansiReset) + } + command := func(name, desc string) { + fmt.Fprintf(w, " %s%-34s%s%s%s\n", ansiBold, name, ansiReset, ansiDim+desc, ansiReset) + } + sub := func(name, desc string) { + fmt.Fprintf(w, " %s%-32s%s%s%s\n", ansiDim, name, ansiReset, ansiDim+desc, ansiReset) + } + + // Setup + section("Setup") + command("night-agent init", "Installa Guardian, esegui il wizard di policy") + command("night-agent init --yes", "Installa con tutti i default senza wizard") + command("night-agent uninstall", "Rimuovi Night Agent dal sistema") + fmt.Fprintln(w) + + // Runtime + section("Runtime") + command("night-agent start", "Avvia il daemon in foreground (terminale dedicato)") + command("night-agent run ", "Avvia un agente AI sotto protezione") + sub("night-agent run claude", "Claude Code") + sub("night-agent run python3 agent.py", "Script Python") + sub("night-agent run node agent.js", "Script Node.js") + fmt.Fprintln(w) + + // Policy + section("Policy") + command("night-agent policy list", "Mostra tutte le regole e il loro stato") + command("night-agent policy toggle ", "Attiva/disattiva una regola (block ↔ allow)") + command("night-agent policy add", "Aggiungi una nuova regola in modo interattivo") + command("night-agent policy remove ", "Rimuovi una regola dalla policy") + fmt.Fprintln(w) + + // Logs + section("Logs") + command("night-agent logs", "Mostra l'audit trail degli ultimi eventi") + sub("night-agent logs --limit 20", "Ultimi N eventi") + sub("night-agent logs --decision block", "Filtra per decisione (block/allow)") + sub("night-agent logs --type shell", "Filtra per tipo (shell/git/file)") + sub("night-agent logs --json", "Output raw JSONL") + fmt.Fprintln(w) + + // Sicurezza + section("Sicurezza") + command("night-agent verify", "Verifica integrità firme nell'audit log") + command("night-agent mcp-hook --tool ", "Hook PreToolUse per Claude Code (MCP)") + fmt.Fprintln(w) + + // Diagnostica + section("Diagnostica") + command("night-agent doctor", "Verifica che tutto sia configurato correttamente") + fmt.Fprintln(w) + + fmt.Fprintln(w, ansiDim+" ─────────────────────────────────────────────────────────"+ansiReset) + fmt.Fprintf(w, " %sDocumentazione:%s github.com/night-agent-cli/night-agent\n\n", + ansiDim, ansiReset) +} diff --git a/cmd/guardian/init.go b/cmd/guardian/init.go new file mode 100644 index 0000000..9dd8052 --- /dev/null +++ b/cmd/guardian/init.go @@ -0,0 +1,254 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/night-agent-cli/night-agent/internal/audit" + "github.com/night-agent-cli/night-agent/internal/claudehook" + "github.com/night-agent-cli/night-agent/internal/launchagent" + "github.com/night-agent-cli/night-agent/internal/policy" + "github.com/night-agent-cli/night-agent/internal/shell" + "github.com/night-agent-cli/night-agent/internal/shim" + "github.com/night-agent-cli/night-agent/internal/wizard" + "github.com/spf13/cobra" +) + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Inizializza Night Agent e configura la policy", + Long: "Crea la directory di configurazione, esegue il wizard di policy e avvia il daemon automatico.", + RunE: runInit, +} + +var flagYes bool + +func init() { + initCmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "accetta tutti i default senza wizard interattivo") + rootCmd.AddCommand(initCmd) +} + +func runInit(cmd *cobra.Command, args []string) error { + guardianDir, err := ensureGuardianDir() + if err != nil { + return err + } + + policyPath := filepath.Join(guardianDir, "policy.yaml") + if err := copyDefaultPolicy(policyPath); err != nil { + return err + } + + // wizard di configurazione policy (saltabile con --yes) + if !flagYes { + if err := runPolicyWizard(policyPath); err != nil { + return err + } + } else { + fmt.Println("policy: tutte le regole di default attive (--yes)") + } + fmt.Printf("policy: %s\n", policyPath) + + // lock policy file: imposta user-immutable flag (chflags uchg) + // impedisce scrittura da qualsiasi processo inclusi subprocess non-interattivi + if err := lockPolicyFile(policyPath); err != nil { + fmt.Printf("avviso: lock policy file fallito (%v)\n", err) + } else { + fmt.Printf("policy lock: immutabile (usa 'nightagent policy edit' per modificare)\n") + } + + rcPath, err := detectZshrc() + if err != nil { + return err + } + + socketPath := filepath.Join(guardianDir, "night-agent.sock") + injected, err := shell.Inject(rcPath, socketPath) + if err != nil { + return fmt.Errorf("errore iniezione hook shell: %w", err) + } + if injected { + fmt.Printf("hook iniettato in: %s\n", rcPath) + } else { + fmt.Printf("hook shell già presente in: %s\n", rcPath) + } + + // installa PATH shims + shimDir := shim.ShimDir(guardianDir) + shimBinary := filepath.Join(shimDir, shim.ShimBinaryName) + if err := installShims(guardianDir, shimBinary); err != nil { + fmt.Printf("avviso: shims non installati (%v)\n", err) + fmt.Printf(" esegui 'make shim && night-agent init' per abilitarli\n") + } else { + fmt.Printf("shims installati in: %s\n", shimDir) + } + + // installa LaunchAgent + home, _ := os.UserHomeDir() + binaryPath, err := resolveAbsBinary() + if err != nil { + fmt.Printf("avviso: LaunchAgent non installato (%v)\n", err) + } else if err := launchagent.Install(home, binaryPath, guardianDir); err != nil { + fmt.Printf("avviso: LaunchAgent non installato (%v)\n", err) + } else { + fmt.Printf("LaunchAgent installato: avvio automatico al login attivo\n") + } + + // configura hook Claude Code (solo se Claude Code è installato) + if claudehook.IsClaudeInstalled() { + settingsPath, err := claudehook.SettingsPath() + if err == nil { + binaryPath, err := resolveAbsBinary() + if err == nil { + if err := claudehook.Install(settingsPath, binaryPath); err != nil { + fmt.Printf("avviso: hook Claude Code non configurato (%v)\n", err) + } else if claudehook.IsConfigured(settingsPath) { + fmt.Printf("hook Claude Code: %s\n", settingsPath) + } + } + } + } + + // genera la chiave di firma (idempotente — non sovrascrive se esiste già) + keyPath := filepath.Join(guardianDir, "signing.key") + if err := audit.GenerateKey(keyPath); err != nil { + fmt.Printf("avviso: chiave di firma non generata (%v)\n", err) + } else { + fmt.Printf("firma audit: %s\n", keyPath) + } + + if injected { + fmt.Println("\nnight-agent inizializzato. Riavvia il terminale o esegui: source " + rcPath) + } else { + fmt.Println("\nnight-agent aggiornato.") + } + return nil +} + +// runPolicyWizard esegue il wizard interattivo e aggiorna la policy. +// Le regole non selezionate dall'utente vengono impostate su "allow". +func runPolicyWizard(policyPath string) error { + blockedRuleIDs, err := wizard.Run(os.Stdin, os.Stdout) + if err != nil { + return err + } + + p, err := policy.LoadFile(policyPath) + if err != nil { + return err + } + + blockedSet := make(map[string]bool, len(blockedRuleIDs)) + for _, id := range blockedRuleIDs { + blockedSet[id] = true + } + + for i, rule := range p.Rules { + if !blockedSet[rule.ID] { + p.Rules[i].Decision = policy.DecisionAllow + p.Rules[i].Reason = "consentito dall'utente durante init" + } + } + + return policy.Save(policyPath, p) +} + +func resolveAbsBinary() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + return filepath.EvalSymlinks(exe) +} + +func installShims(guardianDir, shimBinaryPath string) error { + candidates := []string{ + filepath.Join(filepath.Dir(os.Args[0]), shim.ShimBinaryName), + filepath.Join(".", shim.ShimBinaryName), + } + for _, candidate := range candidates { + if _, err := os.Stat(candidate); err == nil { + return shim.Install(guardianDir, candidate) + } + } + if info, err := os.Stat(shimBinaryPath); err == nil && info.Size() > 0 { + return shim.CreateSymlinks(shim.ShimDir(guardianDir), shimBinaryPath) + } + return fmt.Errorf("binario %s non trovato — esegui 'make shim'", shim.ShimBinaryName) +} + +func ensureGuardianDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("impossibile determinare la home directory: %w", err) + } + dir := filepath.Join(home, ".night-agent") + + // Migrazione automatica da ~/.guardian (installazione precedente) + oldDir := filepath.Join(home, ".guardian") + if _, errOld := os.Stat(oldDir); errOld == nil { + if _, errNew := os.Stat(dir); os.IsNotExist(errNew) { + if errRename := os.Rename(oldDir, dir); errRename == nil { + fmt.Printf("migrazione: ~/.guardian → ~/.night-agent\n") + } else { + fmt.Printf("avviso: migrazione ~/.guardian fallita (%v) — procedo con nuova directory\n", errRename) + } + } + } + + if err := os.MkdirAll(dir, 0700); err != nil { + return "", fmt.Errorf("impossibile creare %s: %w", dir, err) + } + return dir, nil +} + +func copyDefaultPolicy(dest string) error { + if _, err := os.Stat(dest); err == nil { + return nil // già esiste, non sovrascrivere + } + candidates := []string{ + "configs/default_policy.yaml", + filepath.Join(filepath.Dir(os.Args[0]), "configs", "default_policy.yaml"), + } + for _, src := range candidates { + data, err := os.ReadFile(src) + if err != nil { + continue + } + if err := os.WriteFile(dest, data, 0600); err != nil { + return err + } + // lock immediato — chflags uchg blocca scrittura da subprocess non-interattivi + _ = policy.LockFile(dest) + return nil + } + return fmt.Errorf("policy di default non trovata") +} + +func detectZshrc() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + if isZsh() { + return filepath.Join(home, ".zshrc"), nil + } + return filepath.Join(home, ".bashrc"), nil +} + +func isZsh() bool { + s := os.Getenv("SHELL") + if s != "" { + return filepath.Base(s) == "zsh" + } + _, err := exec.LookPath("zsh") + return err == nil +} + +// lockPolicyFile imposta il flag user-immutable (chflags uchg) sul file policy. +// Blocca scrittura da qualsiasi processo, inclusi subprocess non-interattivi. +func lockPolicyFile(path string) error { + return policy.LockFile(path) +} diff --git a/cmd/guardian/logs.go b/cmd/guardian/logs.go new file mode 100644 index 0000000..a7adc0d --- /dev/null +++ b/cmd/guardian/logs.go @@ -0,0 +1,113 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "text/tabwriter" + "time" + + "github.com/night-agent-cli/night-agent/internal/audit" + "github.com/spf13/cobra" +) + +var logsCmd = &cobra.Command{ + Use: "logs", + Short: "Visualizza l'audit trail degli eventi", + RunE: runLogs, +} + +var ( + flagDecision string + flagActionType string + flagJSONOutput bool + flagLimit int +) + +func init() { + logsCmd.Flags().StringVar(&flagDecision, "decision", "", "filtra per decisione (allow, block, ask)") + logsCmd.Flags().StringVar(&flagActionType, "type", "", "filtra per tipo azione (shell, git, file)") + logsCmd.Flags().BoolVar(&flagJSONOutput, "json", false, "output in formato JSON raw") + logsCmd.Flags().IntVar(&flagLimit, "limit", 50, "numero massimo di eventi da mostrare") + rootCmd.AddCommand(logsCmd) +} + +func runLogs(cmd *cobra.Command, args []string) error { + logPath, err := guardianLogPath() + if err != nil { + return err + } + + events, err := audit.ReadFiltered(logPath, audit.Filter{ + Decision: flagDecision, + ActionType: flagActionType, + }) + if err != nil { + return fmt.Errorf("impossibile leggere il log: %w", err) + } + + if len(events) > flagLimit { + events = events[len(events)-flagLimit:] + } + + if flagJSONOutput { + return printJSON(events) + } + return printTable(events) +} + +func printTable(events []audit.Event) error { + if len(events) == 0 { + fmt.Println("nessun evento trovato") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "TIMESTAMP\tDECISIONE\tRISCHIO\tTIPO\tCOMANDO\tMOTIVO") + fmt.Fprintln(w, "---------\t---------\t-------\t----\t-------\t------") + for _, e := range events { + ts := e.Timestamp.Format(time.DateTime) + cmd := e.Command + if len(cmd) > 45 { + cmd = cmd[:42] + "..." + } + risk := riskLabel(e.RiskLevel, e.RiskScore, e.AnomalyDetected) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", ts, e.Decision, risk, e.ActionType, cmd, e.Reason) + // stampa suggerimenti inline se presenti + for _, h := range e.Suggestions { + fmt.Fprintf(w, "\t\t\t\t→ %s\t\n", h) + } + } + return w.Flush() +} + +// riskLabel formatta il livello di rischio per la tabella. +func riskLabel(level string, score float64, anomaly bool) string { + if level == "" { + return "-" + } + label := fmt.Sprintf("%s(%.2f)", level, score) + if anomaly { + label += "!" + } + return label +} + +func printJSON(events []audit.Event) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(events) +} + +func guardianLogPath() (string, error) { + dir, err := resolveConfigDir() + if err != nil { + return "", err + } + path := filepath.Join(dir, "audit.jsonl") + if _, err := os.Stat(path); os.IsNotExist(err) { + return "", fmt.Errorf("log non trovato in %s — esegui prima 'night-agent init'", path) + } + return path, nil +} diff --git a/cmd/guardian/main.go b/cmd/guardian/main.go new file mode 100644 index 0000000..f2abcfe --- /dev/null +++ b/cmd/guardian/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var globalConfig bool + +var rootCmd = &cobra.Command{ + Use: "nightagent", + Short: "Night Agent — runtime security layer for AI agents", +} + +func init() { + rootCmd.PersistentFlags().BoolVar(&globalConfig, "global", false, "usa ~/.night-agent/ ignorando la config locale del progetto") +} + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/guardian/mcphook.go b/cmd/guardian/mcphook.go new file mode 100644 index 0000000..2d7d0d2 --- /dev/null +++ b/cmd/guardian/mcphook.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/night-agent-cli/night-agent/internal/mcphook" + "github.com/spf13/cobra" +) + +var mcpHookCmd = &cobra.Command{ + Use: "mcp-hook", + Short: "Hook PreToolUse per Claude Code — valuta tool call prima dell'esecuzione", + Long: `Invocato automaticamente da Claude Code via PreToolUse hook. +Legge il contesto della tool call da stdin (JSON), invia al daemon e +restituisce exit code 0 (allow) o 2 (block). + +Configurazione in ~/.claude/settings.json: + { + "hooks": { + "PreToolUse": [{ + "matcher": "*", + "hooks": [{"type": "command", "command": "/path/to/nightagent mcp-hook"}] + }] + } + }`, + RunE: runMCPHook, +} + +func init() { + rootCmd.AddCommand(mcpHookCmd) +} + +func runMCPHook(cmd *cobra.Command, args []string) error { + parsed, err := mcphook.ParseStdin(os.Stdin) + if err != nil { + // fail-open: input malformato non deve bloccare il workflow + fmt.Fprintf(os.Stderr, "[night-agent] errore parsing input: %v — consento\n", err) + os.Exit(0) + } + + req := mcphook.BuildDaemonRequest(parsed) + + cfgDir, _ := resolveConfigDir() + socketPath := filepath.Join(cfgDir, "night-agent.sock") + + decision, reason := mcphook.QueryDaemon(socketPath, req) + + code := mcphook.ExitCode(decision) + if code != 0 { + fmt.Fprintf(os.Stderr, "[guardian] bloccato: %s — %s\n", parsed.Command, reason) + } else if decision == "sandbox" { + fmt.Fprintf(os.Stderr, "[guardian] sandbox: %s — %s\n", parsed.Command, reason) + } + + os.Exit(code) + return nil +} + diff --git a/cmd/guardian/policy.go b/cmd/guardian/policy.go new file mode 100644 index 0000000..2940f4c --- /dev/null +++ b/cmd/guardian/policy.go @@ -0,0 +1,327 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/night-agent-cli/night-agent/internal/policy" + "github.com/night-agent-cli/night-agent/internal/policyeditor" + "github.com/spf13/cobra" +) + +// ANSI +const ( + ansiReset = "\033[0m" + ansiBold = "\033[1m" + ansiDim = "\033[2m" + ansiRed = "\033[31m" + ansiGreen = "\033[32m" + ansiCyan = "\033[36m" + ansiBoldCyan = "\033[1;36m" + ansiBoldWhite = "\033[1;37m" +) + +var policyCmd = &cobra.Command{ + Use: "policy", + Short: "Gestisci le regole di policy", +} + +var policyListCmd = &cobra.Command{ + Use: "list", + Short: "Mostra tutte le regole attive", + RunE: runPolicyList, +} + +var policyToggleCmd = &cobra.Command{ + Use: "toggle ", + Short: "Attiva/disattiva una regola (block ↔ allow)", + Args: cobra.ExactArgs(1), + RunE: runPolicyToggle, +} + +var policyAddCmd = &cobra.Command{ + Use: "add", + Short: "Aggiungi una nuova regola in modo interattivo", + RunE: runPolicyAdd, +} + +var policyRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Rimuovi una regola dalla policy", + Args: cobra.ExactArgs(1), + RunE: runPolicyRemove, +} + +var policyEditCmd = &cobra.Command{ + Use: "edit", + Short: "Modifica la policy nell'editor di sistema ($EDITOR)", + RunE: runPolicyEdit, +} + +func init() { + policyCmd.AddCommand(policyListCmd) + policyCmd.AddCommand(policyToggleCmd) + policyCmd.AddCommand(policyAddCmd) + policyCmd.AddCommand(policyRemoveCmd) + policyCmd.AddCommand(policyEditCmd) + rootCmd.AddCommand(policyCmd) +} + +func policyPath() (string, error) { + dir, err := resolveConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "policy.yaml"), nil +} + +func runPolicyList(cmd *cobra.Command, args []string) error { + path, err := policyPath() + if err != nil { + return err + } + p, err := policy.LoadFile(path) + if err != nil { + return fmt.Errorf("errore caricamento policy: %w", err) + } + fmt.Print(policyeditor.RenderTable(p)) + fmt.Printf(" %snight-agent policy toggle %s per attivare/disattivare\n", + ansiDim, ansiReset) + fmt.Printf(" %snight-agent policy add%s per aggiungere una regola\n", + ansiDim, ansiReset) + fmt.Printf(" %snight-agent policy remove %s per rimuovere una regola\n\n", + ansiDim, ansiReset) + return nil +} + +func runPolicyToggle(cmd *cobra.Command, args []string) error { + path, err := policyPath() + if err != nil { + return err + } + ruleID := args[0] + + // leggi stato attuale per dare feedback + p, err := policy.LoadFile(path) + if err != nil { + return err + } + var current policy.Decision + for _, r := range p.Rules { + if r.ID == ruleID { + current = r.Decision + break + } + } + + if err := policyeditor.ToggleRule(path, ruleID); err != nil { + return err + } + + var arrow string + if current == policy.DecisionBlock || current == policy.DecisionAsk { + arrow = fmt.Sprintf("%s✗ block%s → %s✓ allow%s", ansiRed, ansiReset, ansiGreen, ansiReset) + } else { + arrow = fmt.Sprintf("%s✓ allow%s → %s✗ block%s", ansiGreen, ansiReset, ansiRed, ansiReset) + } + fmt.Printf("\n %s%s%s %s\n\n", ansiBold, ruleID, ansiReset, arrow) + fmt.Printf(" %sriavvia il daemon per applicare le modifiche: night-agent start%s\n\n", + ansiDim, ansiReset) + return nil +} + +func runPolicyAdd(cmd *cobra.Command, args []string) error { + path, err := policyPath() + if err != nil { + return err + } + + scanner := bufio.NewScanner(os.Stdin) + ask := func(label, placeholder string) string { + fmt.Printf(" %s%s%s %s(%s)%s: ", ansiBold, label, ansiReset, ansiDim, placeholder, ansiReset) + scanner.Scan() + v := strings.TrimSpace(scanner.Text()) + if v == "" { + return placeholder + } + return v + } + + fmt.Printf("\n%s Nuova regola%s\n", ansiBold+ansiBoldCyan, ansiReset) + fmt.Printf("%s ──────────────────────────────────────%s\n\n", ansiDim, ansiReset) + + id := ask("ID regola", "custom_rule") + actionType := ask("Tipo azione", "shell") + pattern := ask("Pattern glob", "comando *") + decision := ask("Decisione (block/allow)", "block") + reason := ask("Motivo", "regola personalizzata") + + spec := policyeditor.NewRuleSpec{ + ID: id, + ActionType: actionType, + Pattern: pattern, + Decision: decision, + Reason: reason, + } + + if err := policyeditor.AddRule(path, spec); err != nil { + return err + } + + decColor := ansiRed + decIcon := "✗" + if decision == "allow" { + decColor = ansiGreen + decIcon = "✓" + } + fmt.Printf("\n %s%s %s%s regola %s'%s'%s aggiunta\n\n", + decColor, decIcon, decision, ansiReset, + ansiBold, id, ansiReset) + return nil +} + +func runPolicyRemove(cmd *cobra.Command, args []string) error { + path, err := policyPath() + if err != nil { + return err + } + ruleID := args[0] + + fmt.Printf("\n Rimuovere la regola %s'%s'%s? [s/N] ", ansiBold, ruleID, ansiReset) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "s" && answer != "y" && answer != "si" && answer != "yes" { + fmt.Println(" annullato") + return nil + } + + if err := policyeditor.RemoveRule(path, ruleID); err != nil { + return err + } + + fmt.Printf(" %s✓%s regola '%s' rimossa\n\n", ansiGreen, ansiReset, ruleID) + return nil +} + +func runPolicyEdit(cmd *cobra.Command, args []string) error { + path, err := policyPath() + if err != nil { + return err + } + + // leggi policy corrente (crea file vuoto se non esiste) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + data = []byte("version: 1\nrules: []\n") + } else { + return fmt.Errorf("impossibile leggere la policy: %w", err) + } + } + + // scrivi in temp file + tmpFile, err := os.CreateTemp("", "nightagent-policy-*.yaml") + if err != nil { + return fmt.Errorf("impossibile creare file temporaneo: %w", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + return err + } + tmpFile.Close() + + // apri editor + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "nano" + } + + editorCmd := exec.Command(editor, tmpPath) + editorCmd.Stdin = os.Stdin + editorCmd.Stdout = os.Stdout + editorCmd.Stderr = os.Stderr + if err := editorCmd.Run(); err != nil { + return fmt.Errorf("editor terminato con errore: %w", err) + } + + // leggi contenuto modificato + newData, err := os.ReadFile(tmpPath) + if err != nil { + return err + } + + // valida YAML prima di applicare + if _, err := policy.LoadBytes(newData); err != nil { + return fmt.Errorf("%spolicy non valida%s: %w", ansiRed, ansiReset, err) + } + + // invia al daemon via socket (canale autorizzato) + cfgDir, err := resolveConfigDir() + if err != nil { + return err + } + socketPath := filepath.Join(cfgDir, "night-agent.sock") + + if err := sendPolicyUpdate(socketPath, newData); err != nil { + // daemon non disponibile: scrivi direttamente con avviso + fmt.Fprintf(os.Stderr, " %savviso%s: daemon non raggiungibile, scrivo direttamente su disco\n", + ansiDim, ansiReset) + if err2 := os.WriteFile(path, newData, 0600); err2 != nil { + return fmt.Errorf("errore scrittura policy: %w", err2) + } + } + + fmt.Printf("\n %s✓%s policy aggiornata\n\n", ansiGreen, ansiReset) + return nil +} + +// policyWriteRequest è il payload inviato al daemon per aggiornare la policy. +type policyWriteRequest struct { + Type string `json:"type"` + PolicyYAML string `json:"policy_yaml"` +} + +// policyWriteResponse è la risposta del daemon alla richiesta policy_write. +type policyWriteResponse struct { + Decision string `json:"decision"` + Reason string `json:"reason"` +} + +// sendPolicyUpdate invia il nuovo YAML al daemon via Unix socket. +// Restituisce errore se il daemon non è raggiungibile o rifiuta la policy. +func sendPolicyUpdate(socketPath string, yamlContent []byte) error { + conn, err := net.DialTimeout("unix", socketPath, 2*time.Second) + if err != nil { + return fmt.Errorf("daemon non raggiungibile: %w", err) + } + defer conn.Close() + + req := policyWriteRequest{ + Type: "policy_write", + PolicyYAML: string(yamlContent), + } + if err := json.NewEncoder(conn).Encode(req); err != nil { + return fmt.Errorf("errore invio richiesta: %w", err) + } + + var resp policyWriteResponse + if err := json.NewDecoder(conn).Decode(&resp); err != nil { + return fmt.Errorf("errore lettura risposta: %w", err) + } + + if resp.Decision == "block" { + return fmt.Errorf("daemon ha rifiutato: %s", resp.Reason) + } + return nil +} diff --git a/cmd/guardian/run.go b/cmd/guardian/run.go new file mode 100644 index 0000000..d2c3c13 --- /dev/null +++ b/cmd/guardian/run.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/night-agent-cli/night-agent/internal/intercept" + "github.com/night-agent-cli/night-agent/internal/shim" + "github.com/spf13/cobra" +) + +var runCmd = &cobra.Command{ + Use: "run [args...]", + Short: "Avvia un agente AI sotto la protezione di Night Agent", + Long: `Avvia un agente AI con protezione attiva via due meccanismi: + + 1. PATH shims — intercetta i comandi eseguiti dall'agente via shell + (funziona con tutti gli agenti, incluso Claude Code con Hardened Runtime) + + 2. DYLD_INSERT_LIBRARIES — intercetta syscall di processo a livello C + (funziona per agenti senza Hardened Runtime: node, python3, ecc.) + +Esempi: + night-agent run claude + night-agent run python3 my_agent.py + night-agent run node agent.js`, + Args: cobra.MinimumNArgs(1), + RunE: runAgent, +} + +func init() { + rootCmd.AddCommand(runCmd) +} + +func runAgent(cmd *cobra.Command, args []string) error { + guardianDir, err := resolveConfigDir() + if err != nil { + return err + } + + socketPath := filepath.Join(guardianDir, "night-agent.sock") + + if !isDaemonRunning(socketPath) { + return fmt.Errorf("daemon non in esecuzione — avvia prima 'night-agent start' in un altro terminale") + } + + shimDir := shim.ShimDir(guardianDir) + shimBinary := filepath.Join(shimDir, shim.ShimBinaryName) + + env := os.Environ() + env = append(env, "NIGHTAGENT_AGENT="+args[0]) + + // PATH shims: funzionano con tutti gli agenti indipendentemente da Hardened Runtime + if _, err := os.Stat(shimBinary); err == nil { + env = shim.PrependPath(env, shimDir) + env = append(env, "GUARDIAN_SHIM_DIR="+shimDir) + env = append(env, "GUARDIAN_SOCKET="+socketPath) + fmt.Printf("night-agent: shims → %s\n", shimDir) + } else { + fmt.Printf("night-agent: avviso — shim dir non trovata (%s)\n", shimDir) + fmt.Printf("night-agent: esegui 'make shim' per abilitare l'interception PATH\n") + } + + // DYLD: copertura aggiuntiva per agenti senza Hardened Runtime (node, python3...) + binaryDir := filepath.Dir(os.Args[0]) + if dylibPath, err := findDylibCandidates(binaryDir); err == nil { + env = intercept.BuildEnv(env, dylibPath, socketPath) + fmt.Printf("night-agent: dylib → %s\n", dylibPath) + } + + agentBinary, err := exec.LookPath(args[0]) + if err != nil { + return fmt.Errorf("agente '%s' non trovato nel PATH: %w", args[0], err) + } + + fmt.Printf("night-agent: avvio '%s' con interception attiva\n", args[0]) + fmt.Printf("night-agent: socket → %s\n\n", socketPath) + + agentCmd := exec.Command(agentBinary, args[1:]...) + agentCmd.Env = env + agentCmd.Stdin = os.Stdin + agentCmd.Stdout = os.Stdout + agentCmd.Stderr = os.Stderr + + return agentCmd.Run() +} + +func findDylibCandidates(binaryDir string) (string, error) { + candidates := []string{ + binaryDir, + ".", // directory corrente (sviluppo locale) + } + for _, dir := range candidates { + if path, err := intercept.FindDylib(dir); err == nil { + return path, nil + } + } + return "", fmt.Errorf( + "guardian-intercept.dylib non trovata — esegui 'make dylib' nella root del progetto", + ) +} diff --git a/cmd/guardian/sandbox.go b/cmd/guardian/sandbox.go new file mode 100644 index 0000000..a73c372 --- /dev/null +++ b/cmd/guardian/sandbox.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/night-agent-cli/night-agent/internal/sandbox" + "github.com/spf13/cobra" +) + +var sandboxCmd = &cobra.Command{ + Use: "sandbox", + Short: "Esegui comandi in ambiente Docker isolato", +} + +var sandboxResetCmd = &cobra.Command{ + Use: "reset", + Short: "Ferma tutti i container sandbox attivi gestiti da Night Agent", + RunE: runSandboxReset, +} + +var sandboxRunCmd = &cobra.Command{ + Use: "run ", + Short: "Esegui un comando in sandbox Docker", + Long: `Esegue il comando specificato all'interno di un container Docker isolato. + +Il workspace corrente viene montato nel container. +La rete è disabilitata per default (--network none). + +Esempi: + night-agent sandbox run "python migration_script.py" + night-agent sandbox run "bash deploy.sh" + night-agent sandbox run --image alpine:3.20 --network bridge "curl https://example.com"`, + Args: cobra.MinimumNArgs(1), + RunE: runSandbox, +} + +var ( + sandboxImage string + sandboxNetwork string +) + +func init() { + sandboxRunCmd.Flags().StringVar(&sandboxImage, "image", sandbox.DefaultImage, + "immagine Docker da usare") + sandboxRunCmd.Flags().StringVar(&sandboxNetwork, "network", sandbox.DefaultNetwork, + "modalità rete del container: none o bridge") + + sandboxCmd.AddCommand(sandboxRunCmd) + sandboxCmd.AddCommand(sandboxResetCmd) + rootCmd.AddCommand(sandboxCmd) +} + +func runSandboxReset(cmd *cobra.Command, args []string) error { + mgr := sandbox.New() + + if !mgr.IsAvailable() { + return fmt.Errorf("Docker non disponibile") + } + + fmt.Print("fermando container sandbox attivi... ") + stopped, err := mgr.Reset(context.Background()) + if err != nil { + return fmt.Errorf("errore reset sandbox: %w", err) + } + + if stopped == 0 { + fmt.Println("nessun container attivo.") + } else { + fmt.Printf("%d container fermati.\n", stopped) + } + return nil +} + +func runSandbox(cmd *cobra.Command, args []string) error { + mgr := sandbox.New() + + if !mgr.IsAvailable() { + return fmt.Errorf("Docker non disponibile — installa Docker Desktop e assicurati che il daemon sia in esecuzione") + } + + // Ricostruisce il comando completo dagli argomenti + command := strings.Join(args, " ") + + workDir, err := os.Getwd() + if err != nil { + workDir = "" + } + + cfg := sandbox.Config{ + Image: sandboxImage, + Network: sandboxNetwork, + WorkDir: workDir, + } + + fmt.Printf("\033[33m[⬡ sandbox]\033[0m %s\n", command) + fmt.Printf(" immagine: %s rete: %s\n", cfg.Image, cfg.Network) + if workDir != "" { + fmt.Printf(" workspace: %s → /workspace\n", workDir) + } + fmt.Println() + + result, err := mgr.Execute(context.Background(), command, cfg) + if err != nil { + return fmt.Errorf("esecuzione sandbox fallita: %w", err) + } + + if result.Stdout != "" { + fmt.Print(result.Stdout) + } + if result.Stderr != "" { + fmt.Fprint(os.Stderr, result.Stderr) + } + + fmt.Printf("\n\033[33m[⬡ sandbox]\033[0m completato con exit code %d\n", result.ExitCode) + + if result.ExitCode != 0 { + os.Exit(result.ExitCode) + } + return nil +} diff --git a/cmd/guardian/start.go b/cmd/guardian/start.go new file mode 100644 index 0000000..11bc19e --- /dev/null +++ b/cmd/guardian/start.go @@ -0,0 +1,185 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/night-agent-cli/night-agent/internal/audit" + "github.com/night-agent-cli/night-agent/internal/cloudconfig" + "github.com/night-agent-cli/night-agent/internal/configdir" + "github.com/night-agent-cli/night-agent/internal/daemon" + "github.com/night-agent-cli/night-agent/internal/policy" + nightsync "github.com/night-agent-cli/night-agent/internal/sync" + "github.com/spf13/cobra" +) + +var localPolicyOnly bool + +var startCmd = &cobra.Command{ + Use: "start", + Short: "Avvia il daemon Guardian in foreground", + RunE: runStart, +} + +func init() { + startCmd.Flags().BoolVar(&localPolicyOnly, "local-policy-only", false, "ignora la policy cloud, usa solo locale/globale") + rootCmd.AddCommand(startCmd) +} + +func runStart(_ *cobra.Command, _ []string) error { + cfgDir, err := resolveConfigDir() + if err != nil { + return err + } + + socketPath := filepath.Join(cfgDir, "night-agent.sock") + logPath := filepath.Join(cfgDir, "audit.jsonl") + cloudCfgPath := filepath.Join(cfgDir, "cloud.yaml") + + cwd, err := os.Getwd() + if err != nil { + return err + } + + // costruisci cloud client se configurato e non --local-policy-only + var cloudClient policy.CloudClient + var machineID string + if !localPolicyOnly { + if cfg, err := cloudconfig.Load(cloudCfgPath); err == nil && cfg.Connected && cfg.Token != "" { + cloudClient = &policy.HTTPCloudClient{ + Endpoint: cfg.Endpoint, + Token: cfg.Token, + } + machineID = cfg.MachineID + } + } + + // carica policy con priorità cloud → locale → globale → none + lp, err := policy.Load(cwd, cloudClient, machineID) + if err != nil { + return fmt.Errorf("errore caricamento policy: %w", err) + } + fmt.Println(policy.FormatSource(lp)) + + // policy permissiva se SourceNone (nessuna policy trovata) + var p *policy.Policy + if lp.Policy != nil { + p = lp.Policy + } else { + p = &policy.Policy{} + } + + // costruisci il logger con la SignFunc appropriata (remote se cloud connesso, locale altrimenti) + keyPath := filepath.Join(cfgDir, "signing.key") + var logger *audit.Logger + if signFn, _, buildErr := buildSignFunc(cloudCfgPath, keyPath); buildErr == nil { + logger, err = audit.NewSignedLoggerWithFunc(logPath, signFn) + } else { + logger, err = audit.NewLogger(logPath) + } + if err != nil { + return fmt.Errorf("errore apertura log: %w", err) + } + defer logger.Close() + + policyPath := lp.Path + if lp.Source == policy.SourceNone { + policyPath = filepath.Join(cfgDir, "policy.yaml") + } + + srv, err := daemon.NewServerWithPolicyPath(socketPath, policyPath, p, logger) + if err != nil { + return fmt.Errorf("errore avvio daemon: %w", err) + } + srv.WithLogPath(logPath) + + // imposta hash iniziale per il trust checker (file già presente su disco) + if lp.Source != policy.SourceNone && lp.Path != "" { + if data, readErr := os.ReadFile(lp.Path); readErr == nil { + srv.SetInitialHash(data) + } + } + + fmt.Printf("night-agent in ascolto su %s\n", socketPath) + + go srv.Serve() + + // hot-reload: watch cwd per nightagent-policy.yaml. + // isTrustedFileContent impedisce al daemon di ricaricare modifiche esterne non autorizzate. + stopWatch, watchErr := policy.Watch(cwd, cloudClient, machineID, func(reloaded *policy.LoadedPolicy) { + if reloaded.Policy != nil { + srv.UpdatePolicy(reloaded.Policy) + } else { + srv.UpdatePolicy(&policy.Policy{}) + } + fmt.Printf("[policy] reloaded from %s\n", reloaded.Path) + }, srv.IsTrustedFileContent) + if watchErr != nil { + fmt.Fprintf(os.Stderr, " avviso: hot-reload non disponibile: %v\n", watchErr) + stopWatch = func() {} + } + defer stopWatch() + + // sync cloud periodico ogni 30s — fail-open, errori ignorati + if _, err := os.Stat(cloudCfgPath); err == nil { + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + agent := nightsync.NewAgent(cloudCfgPath, logPath) + // includi policy nel payload solo se locale o globale (non cloud) + if lp.Source == policy.SourceLocal || lp.Source == policy.SourceGlobal { + agent.WithPolicyPath(lp.Path) + } + for range ticker.C { + _ = agent.SyncOnce() + } + }() + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + fmt.Println("\nnight-agent fermato") + srv.Stop() + return nil +} + +// buildSignFunc costruisce la SignFunc appropriata in base alla config cloud. +// Se il cloud è connesso, usa RemoteSigner con fallback locale. +// Altrimenti usa LocalSignFunc con il signer locale. +// Restituisce anche il Signer locale (può essere nil se la chiave non esiste). +func buildSignFunc(cloudCfgPath, keyPath string) (audit.SignFunc, *audit.Signer, error) { + cfg, err := cloudconfig.Load(cloudCfgPath) + if err != nil { + return nil, nil, err + } + + localSigner, _ := audit.NewSigner(keyPath) + // Se la chiave locale non esiste, localSigner è nil — ok per modalità remote-only + + if cfg.IsConnected() { + remoteSigner := cloudconfig.NewRemoteSigner(cfg) + return remoteSigner.SignFunc(localSigner), localSigner, nil + } + + if localSigner == nil { + return nil, nil, fmt.Errorf("nessuna chiave locale e cloud non connesso") + } + return audit.LocalSignFunc(localSigner), localSigner, nil +} + +// resolvePolicyPath e fetchCloudPolicy non più necessari — sostituiti da policy.Load() +// Mantenute per compatibilità con altri comandi che potrebbero usarle. + +func globalPolicyPath() (string, error) { + dir, err := configdir.Global() + if err != nil { + return "", err + } + return filepath.Join(dir, "policy.yaml"), nil +} diff --git a/cmd/guardian/uninstall.go b/cmd/guardian/uninstall.go new file mode 100644 index 0000000..a9e411b --- /dev/null +++ b/cmd/guardian/uninstall.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/night-agent-cli/night-agent/internal/claudehook" + "github.com/night-agent-cli/night-agent/internal/launchagent" + "github.com/night-agent-cli/night-agent/internal/shell" + "github.com/spf13/cobra" +) + +var uninstallCmd = &cobra.Command{ + Use: "uninstall", + Short: "Rimuove Night Agent dal sistema", + Long: "Ferma il daemon, rimuove il LaunchAgent e rimuove l'hook dallo shell profile.", + RunE: runUninstall, +} + +func init() { + rootCmd.AddCommand(uninstallCmd) +} + +func runUninstall(cmd *cobra.Command, args []string) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + + // rimuovi LaunchAgent + if launchagent.IsInstalled(home) { + if err := launchagent.Uninstall(home); err != nil { + fmt.Printf("avviso: errore rimozione LaunchAgent: %v\n", err) + } else { + fmt.Println("LaunchAgent rimosso") + } + } + + // rimuovi hook dallo shell profile + rcPath := filepath.Join(home, ".zshrc") + if shell.IsInjected(rcPath) { + if err := shell.Remove(rcPath); err != nil { + fmt.Printf("avviso: errore rimozione hook da %s: %v\n", rcPath, err) + } else { + fmt.Printf("hook rimosso da: %s\n", rcPath) + } + } + + // rimuovi hook Claude Code se presente + if settingsPath, err := claudehook.SettingsPath(); err == nil { + if claudehook.IsConfigured(settingsPath) { + if err := claudehook.Remove(settingsPath); err != nil { + fmt.Printf("avviso: errore rimozione hook Claude Code: %v\n", err) + } else { + fmt.Printf("hook Claude Code rimosso da: %s\n", settingsPath) + } + } + } + + fmt.Println("\nnight-agent disinstallato.") + fmt.Printf("I dati in ~/.night-agent/ sono stati preservati. Per rimuoverli: rm -rf ~/.night-agent\n") + return nil +} diff --git a/cmd/guardian/verify.go b/cmd/guardian/verify.go new file mode 100644 index 0000000..9a5272c --- /dev/null +++ b/cmd/guardian/verify.go @@ -0,0 +1,82 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/night-agent-cli/night-agent/internal/audit" + "github.com/spf13/cobra" +) + +var verifyCmd = &cobra.Command{ + Use: "verify", + Short: "Verifica l'integrità delle firme nell'audit log", + RunE: runVerify, +} + +func init() { + rootCmd.AddCommand(verifyCmd) +} + +func runVerify(cmd *cobra.Command, args []string) error { + guardianDir, err := resolveConfigDir() + if err != nil { + return err + } + keyPath := filepath.Join(guardianDir, "signing.key") + logPath := filepath.Join(guardianDir, "audit.jsonl") + + if _, err := os.Stat(keyPath); os.IsNotExist(err) { + return fmt.Errorf("chiave di firma non trovata in %s\nesegui 'nightagent init' per generarla", keyPath) + } + if _, err := os.Stat(logPath); os.IsNotExist(err) { + return fmt.Errorf("log non trovato in %s", logPath) + } + + signer, err := audit.NewSigner(keyPath) + if err != nil { + return fmt.Errorf("caricamento chiave: %w", err) + } + + results, err := audit.VerifyAll(logPath, signer) + if err != nil { + return fmt.Errorf("lettura log: %w", err) + } + + if len(results) == 0 { + fmt.Println("log vuoto — nessun evento da verificare") + return nil + } + + var invalid, unsigned, valid int + for _, r := range results { + switch { + case r.Err == nil: + valid++ + case r.EventID == "": + invalid++ + fmt.Fprintf(os.Stderr, " [✗] evento #%d: %v\n", r.Index+1, r.Err) + default: + if r.Err.Error() == "evento senza firma (sig assente)" { + unsigned++ + } else { + invalid++ + fmt.Fprintf(os.Stderr, " [✗] evento %s (#%d): %v\n", r.EventID, r.Index+1, r.Err) + } + } + } + + fmt.Printf("audit log: %d eventi totali\n", len(results)) + fmt.Printf(" ✓ validi: %d\n", valid) + if unsigned > 0 { + fmt.Printf(" · non firmati: %d (eventi precedenti all'attivazione della firma)\n", unsigned) + } + if invalid > 0 { + fmt.Printf(" ✗ manomessi: %d\n", invalid) + return fmt.Errorf("%d eventi con firma non valida — log potenzialmente manomesso", invalid) + } + + fmt.Println("\nintegrità verificata.") + return nil +} diff --git a/configs/default_policy.yaml b/configs/default_policy.yaml new file mode 100644 index 0000000..5b376a9 --- /dev/null +++ b/configs/default_policy.yaml @@ -0,0 +1,82 @@ +version: 1 + +rules: + - id: block_sudo + when: + action_type: shell + command_matches: ["sudo *", "*/sudo *"] + match_type: glob + decision: block + reason: "sudo è disabilitato per gli agenti AI" + + - id: block_rm_rf + when: + action_type: shell + command_matches: ["rm -rf *", "rm -fr *"] + match_type: glob + decision: ask + reason: "cancellazione ricorsiva richiede conferma" + + - id: block_curl_pipe + when: + action_type: shell + command_matches: ["curl * | *", "wget * | *"] + match_type: glob + decision: block + reason: "esecuzione di script remoti non è consentita" + + - id: block_sensitive_paths + when: + action_type: file + path_matches: ["~/.ssh/*", "~/.aws/*", "**/.env", "**/.env.*"] + match_type: glob + decision: block + reason: "accesso a file sensibili non è consentito" + + - id: ask_git_push_main + when: + action_type: git + command_matches: ["git push * main", "git push * master", "git push --force *"] + match_type: glob + decision: ask + reason: "push su branch protetto richiede conferma" + + # --- Protezione integrità policy --- + + - id: protect_policy_write + when: + action_type: shell + command_matches: + - "* > *nightagent*" + - "* >> *nightagent*" + - "tee *nightagent*" + - "sed * nightagent*" + - "* > *.nightagent*" + - "* >> *.nightagent*" + match_type: glob + decision: block + reason: "modifica diretta dei file policy non consentita — usa 'nightagent policy edit'" + + # --- Regole sandbox (Ciclo 2) --- + + - id: sandbox_shell_scripts + when: + action_type: shell + command_matches: ["bash *.sh", "sh *.sh", "zsh *.sh"] + match_type: glob + decision: sandbox + sandbox: + image: "alpine:3.20" + network: "none" + reason: "script shell eseguito in ambiente isolato" + + - id: sandbox_python_scripts + when: + action_type: shell + command_matches: ["python *.py", "python3 *.py"] + match_type: glob + decision: sandbox + sandbox: + image: "python:3.12-alpine" + network: "none" + reason: "script Python eseguito in ambiente isolato" diff --git a/docs/ai-agent-guardian-mvp-plan.md b/docs/ai-agent-guardian-mvp-plan.md index 588ad27..0014d06 100644 --- a/docs/ai-agent-guardian-mvp-plan.md +++ b/docs/ai-agent-guardian-mvp-plan.md @@ -423,7 +423,7 @@ Per questo entra nel ciclo 2, non nel ciclo 1. --- -## 16. Roadmap MVP in 3 cicli Lean Startup +## 16. Roadmap L'approccio è: build -> measure -> learn @@ -435,7 +435,7 @@ Ogni ciclo deve produrre: --- -# CICLO 1 +# CICLO 1 ✅ COMPLETATO Guardian locale rule-based per shell e git ## 16.1 Obiettivo @@ -551,7 +551,7 @@ oppure --- -# CICLO 2 +# CICLO 2 ✅ COMPLETATO Sandbox Docker + isolamento operativo ## 17.1 Obiettivo @@ -637,7 +637,7 @@ Il ciclo 2 è riuscito se: --- -# CICLO 3 +# CICLO 3 ✅ COMPLETATO Minimo layer intelligente + policy suggestions ## 18.1 Obiettivo @@ -996,21 +996,188 @@ Deve risolvere molto bene un problema iniziale chiaro: rendere sicuro e osservabile l'uso di agenti locali che possono lanciare comandi, toccare file e usare git su Mac. -Roadmap consigliata: +Roadmap completata: -- ciclo 1: controllo deterministico locale su shell e git -- ciclo 2: sandbox Docker per confinare il rischio -- ciclo 3: minimo layer intelligente per ridurre rumore e migliorare contesto - -Se i test confermano valore, il prodotto può poi evolvere verso: - -- policy management per team -- runtime governance cross-tool -- controllo di API e dati enterprise -- piattaforma di sicurezza per agenti in ambienti professionali +- ciclo 1 ✅ — controllo deterministico locale su shell e git +- ciclo 2 ✅ — sandbox Docker per confinare il rischio +- ciclo 3 ✅ — layer intelligente: risk scoring, anomaly detection, policy suggestions +- ciclo 4 ✅ (parziale) — signed audit trail + MCP hook Claude Code +- ciclo 5 → cloud dashboard, sync agent, AI analysis layer Il principio fondante resta uno: non fidarsi delle intenzioni dell'agente controllare le sue azioni e, quando serve, isolarne l'esecuzione + +--- + +# CICLO 4 ✅ COMPLETATO +Trust layer + protezione MCP + +## Obiettivo +Rendere il log probatorio e estendere l'interception alle MCP tool calls di Claude Code. + +## Funzioni implementate + +### Signed audit trail +- Ogni evento firmato con HMAC-SHA256 + catena hash (prev_hash) +- Struttura blockchain-like: cancellare o modificare qualsiasi evento rompe la catena +- `nightagent verify` controlla l'integrità retroattiva dell'intero log +- Chiave locale 32 byte in `~/.night-agent/signing.key` (generata durante init) +- Prerequisito tecnico per la cloud dashboard: eventi verificabili anche server-side + +### MCP hook (Claude Code) +- `nightagent mcp-hook` intercetta le tool call MCP via PreToolUse hook +- Tool intercettati: Bash, Edit, Write, Read, Glob, Grep, WebFetch, WebSearch +- Stessa policy YAML dei comandi shell — nessuna configurazione doppia +- Fail-open se daemon non disponibile +- Configurazione in `~/.claude/settings.json` + +## Ipotesi validate +- Il log firmato aumenta la fiducia nel prodotto come strumento di audit serio +- Le MCP tool call sono intercettabili senza modificare Claude Code +- Un hook leggero è sufficiente per coprire i casi d'uso principali + +--- + +# CICLO 5 — Cloud dashboard + sync agent + +## Obiettivo +Portare Night Agent da strumento locale a piattaforma osservabile via web. L'agente locale resta OSS. Il cloud è opt-in, premium. + +## Ipotesi da validare +1. Gli utenti vogliono vedere i dati degli agenti da browser, non solo da terminale +2. Il modello OSS core + cloud premium è accettato dagli utenti developer +3. La connessione macchina → workspace tramite token è sufficientemente semplice +4. Un AI layer sopra i dati di audit aggiunge valore percepito concreto + +## Output del ciclo +Un sistema connesso composto da tre parti: + +### Parte 1 — Sync agent locale +Nuovo processo leggero che legge `audit.jsonl` e invia gli eventi firmati alla cloud API. + +- `nightagent cloud connect ` — attiva la sincronizzazione +- Batchizza eventi ogni N secondi (configurabile) +- Non tocca il daemon esistente — zero impatto sull'uso offline +- Verifica le firme prima dell'invio: non invia eventi corrotti +- Riprende dall'ultimo evento sincronizzato in caso di interruzione + +### Parte 2 — Cloud API +Backend multi-tenant che riceve, archivia e serve i dati delle macchine connesse. + +- Autenticazione per workspace token +- Verifica server-side della catena hash: rileva se il client ha omesso eventi +- API REST per la dashboard +- Webhook configurabili per alert su eventi ad alto rischio + +### Parte 3 — Dashboard web +Interfaccia web per osservare e governare gli agenti in real-time. + +Funzioni prioritarie: +1. Feed eventi real-time con filtri (decisione, rischio, agente, macchina) +2. Heatmap rischio per ora/giorno +3. Top comandi bloccati e pattern anomali ricorrenti +4. Policy editor web — modifica YAML e sincronizza sul client +5. Alert email/Slack su eventi ad alto rischio +6. Gestione multi-macchina — connessione tramite token alfanumerico + +## Architettura + +``` +~/.night-agent/audit.jsonl + ↓ +sync agent (Go, nuovo processo) + ↓ HTTPS + token +Cloud API (Go) → Postgres + ↓ +Dashboard (Next.js) +``` + +## Connessione macchina + +```bash +# sul web: "Aggiungi macchina" → genera TOKEN +nightagent cloud connect ABC123XYZ +# sync agent si avvia in background e inizia a inviare eventi +``` + +## Componenti nuovi da costruire + +``` +cmd/guardian/cloud.go — comandi: cloud connect, cloud status, cloud disconnect +internal/sync/agent.go — sync agent: legge JSONL, batchizza, invia +cloud/api/ — backend Go: auth, ingest, query API +cloud/dashboard/ — Next.js frontend +``` + +## Stack tecnologico +- Sync agent: Go (stdlib `net/http`, nessuna dipendenza esterna) +- Backend API: Go o Node.js +- Database: Supabase (Postgres + realtime) o Postgres standalone +- Frontend: Next.js + Tailwind +- Auth: token workspace (alpha), OAuth per team (beta) + +## Esclusioni di questo ciclo +- nessun AI layer (ciclo 6) +- nessun policy editor completo (solo YAML upload/download) +- nessun multi-utente per team (un account = una o più macchine personali) + +## Metriche del ciclo +- numero di macchine connesse +- eventi sincronizzati per sessione +- tempo medio di latenza sync +- tasso di adozione cloud vs solo locale +- retention a 2 settimane + +## Criterio di successo +Il ciclo 5 è riuscito se: +- gli utenti connettono almeno una macchina e tornano sulla dashboard +- la latenza di sync è percepita come accettabile (< 5 secondi) +- almeno un utente usa la dashboard come strumento primario di osservazione + +--- + +# CICLO 6 — AI analysis layer + +## Obiettivo +Aggiungere un layer di intelligenza sopra i dati di audit cloud. Claude API come motore di analisi, non come decisore. + +## Ipotesi da validare +1. Una AI che legge i log e risponde in linguaggio naturale è utile in pratica +2. I pattern identificati dalla AI sono diversi e complementari rispetto all'heuristic scorer locale +3. Gli utenti accettano di pagare per questo layer + +## Funzioni incluse + +### AI chat contestuale +- "cosa ha fatto il mio agente nelle ultime 2 ore?" +- "ci sono pattern anomali questa settimana?" +- "quali comandi sono stati bloccati più spesso e perché?" +- Risponde con citazioni dirette agli eventi nel log + +### Report automatici +- Riepilogo giornaliero via email: eventi ad alto rischio, anomalie, suggerimenti policy +- Report settimanale: trend, comandi nuovi non coperti da policy, pattern ricorrenti + +### Policy suggestions avanzate +- Identifica comandi ripetuti senza regola esplicita e suggerisce di aggiungerla +- Confronta la policy corrente con i pattern reali d'uso e segnala regole inutili o mancanti + +## Architettura +``` +Cloud API → Claude API (analisi batch + query real-time) + ↓ + risposta in linguaggio naturale + ↓ + Dashboard (chat UI + report) +``` + +La decisione finale resta sempre nel policy engine locale deterministico. +Claude API è advisory — non ha accesso al daemon e non può modificare policy senza conferma esplicita dell'utente. + +## Esclusioni +- Claude API non ha mai accesso diretto al daemon o alla policy locale +- Nessuna decisione di enforcement delegata alla AI +- I dati inviati a Claude API sono gli stessi già sincronizzati in cloud (nessun dato aggiuntivo) diff --git a/docs/policy-yaml-reference.md b/docs/policy-yaml-reference.md new file mode 100644 index 0000000..2c3d99f --- /dev/null +++ b/docs/policy-yaml-reference.md @@ -0,0 +1,370 @@ +# Policy YAML — Riferimento completo + +Night Agent usa file YAML per definire le regole di controllo degli agenti AI. +Ogni regola specifica **quando** applicarsi e **cosa fare**. + +--- + +## Struttura base + +```yaml +version: 1 + +rules: + - id: nome_regola + when: + action_type: shell + command_matches: + - "sudo *" + match_type: glob + decision: block + reason: "spiegazione mostrata all'utente" +``` + +Il campo `version` è obbligatorio e deve essere `1`. + +--- + +## Campi di una regola + +### `id` — string, obbligatorio + +Identificatore univoco della regola. Usato da `nightagent policy toggle ` e nei log. + +```yaml +id: block_sudo +``` + +Usare snake_case. Non usare spazi o caratteri speciali. + +--- + +### `when` — oggetto, obbligatorio + +Condizione che attiva la regola. Contiene `action_type` e uno tra `command_matches` o `path_matches`. + +--- + +### `when.action_type` — string, obbligatorio + +Tipo di azione da intercettare. + +| Valore | Intercetta | +|--------|-----------| +| `shell` | Comandi shell (bash, python, npm, git, ecc.) | +| `git` | Operazioni git | +| `file` | Operazioni su file (lettura/scrittura/cancellazione) | + +```yaml +when: + action_type: shell +``` + +--- + +### `when.command_matches` — lista di pattern + +Lista di pattern da confrontare con il comando completo (inclusi argomenti). +Usato con `action_type: shell` o `action_type: git`. + +```yaml +when: + action_type: shell + command_matches: + - "sudo *" + - "rm -rf *" +``` + +La regola scatta se **almeno uno** dei pattern matcha. + +--- + +### `when.path_matches` — lista di pattern + +Lista di pattern da confrontare con il path del file. +Usato con `action_type: file`. + +```yaml +when: + action_type: file + path_matches: + - "~/.ssh/*" + - "**/.env" + - "**/.env.*" +``` + +--- + +### `match_type` — string, opzionale + +Modalità di confronto dei pattern. Default: `glob`. + +| Valore | Comportamento | +|--------|--------------| +| `glob` | Pattern con wildcard `*` e `**` (default) | +| `regex` | Espressione regolare completa | + +```yaml +match_type: glob # default +match_type: regex # per pattern avanzati +``` + +--- + +### `decision` — string, obbligatorio + +Cosa fare quando la regola matcha. + +| Valore | Comportamento | +|--------|--------------| +| `allow` | Consenti l'azione | +| `block` | Blocca l'azione, mostra `reason` | +| `ask` | Trattato come `block` — richiede conferma manuale | +| `sandbox` | Esegui in container Docker isolato | + +```yaml +decision: block +``` + +--- + +### `reason` — string, obbligatorio + +Messaggio mostrato all'utente quando la regola scatta (block/ask/sandbox). +Comparirà nel log e nel terminale. + +```yaml +reason: "sudo è disabilitato per gli agenti AI" +``` + +--- + +### `sandbox` — oggetto, opzionale + +Obbligatorio se `decision: sandbox`. Configura il container Docker. + +```yaml +decision: sandbox +sandbox: + image: "python:3.12-alpine" + network: "none" +``` + +| Campo | Tipo | Default | Valori | +|-------|------|---------|--------| +| `image` | string | `alpine:3.20` | Qualsiasi immagine Docker valida | +| `network` | string | `none` | `none` (isolato), `bridge` (internet) | + +Il workspace corrente viene montato come `/workspace` nel container. +`/tmp` è montato read-only. + +--- + +## Glob — sintassi + +Quando `match_type: glob` (default): + +| Pattern | Matcha | +|---------|--------| +| `*` | Qualsiasi sequenza di caratteri (inclusi spazi e `/` nei comandi) | +| `**` | Qualsiasi sequenza incluso separatori di path (nei path_matches) | +| `?` | Un singolo carattere | +| `[abc]` | Uno tra i caratteri elencati | + +Esempi pratici: + +```yaml +command_matches: + - "sudo *" # sudo qualsiasi-cosa + - "rm -rf *" # rm -rf qualsiasi-cosa + - "curl * | *" # curl con pipe + - "git push * main" # push su main da qualsiasi remote + - "python3 *.py" # qualsiasi script .py + - "bash *.sh" # qualsiasi script .sh + +path_matches: + - "~/.ssh/*" # qualsiasi file in .ssh + - "**/.env" # .env in qualsiasi subdirectory + - "**/.env.*" # .env.local, .env.production, ecc. + - "~/.aws/*" # credenziali AWS +``` + +--- + +## Regex — sintassi + +Quando `match_type: regex`, il pattern è una regexp Go standard. + +```yaml +- id: sandbox_python + when: + action_type: shell + command_matches: + - "python3?\\s+.*\\.py" + match_type: regex + decision: sandbox + sandbox: + image: "python:3.12-alpine" + network: "none" + reason: "script Python in sandbox" +``` + +Nota: usare `\\s` invece di `\s` (il backslash va escapato in YAML). + +--- + +## Ordine di valutazione + +Le regole vengono valutate **in ordine dall'alto verso il basso**. +La **prima regola che matcha** vince. Le successive vengono ignorate. + +```yaml +rules: + - id: allow_safe_rm # prima regola: allow per rm specifico + when: + action_type: shell + command_matches: ["rm ./build/*"] + decision: allow + + - id: block_rm_rf # seconda regola: block rm -rf generico + when: + action_type: shell + command_matches: ["rm -rf *"] + decision: block + reason: "cancellazione ricorsiva bloccata" +``` + +In questo esempio `rm ./build/*` è consentito, `rm -rf qualsiasi` è bloccato. + +Se nessuna regola matcha → `allow` (fail-open). + +--- + +## Priorità file policy + +Night Agent carica la policy con questa priorità (la prima trovata vince): + +1. **Cloud** — policy sincronizzata dal server (se connesso) +2. **Locale progetto** — `nightagent-policy.yaml` nella directory corrente o in un parent +3. **Locale config dir** — `.nightagent/policy.yaml` nella directory corrente +4. **Globale** — `~/.night-agent/policy.yaml` +5. **Permissiva** — tutto allow (se nessun file trovato) + +Per forzare la policy globale ignorando quella del progetto: + +```bash +nightagent start --local-policy-only +``` + +--- + +## Esempi completi + +### Policy minimale + +```yaml +version: 1 + +rules: + - id: block_sudo + when: + action_type: shell + command_matches: ["sudo *"] + decision: block + reason: "sudo disabilitato" +``` + +--- + +### Policy per progetto Python + +```yaml +version: 1 + +rules: + - id: sandbox_python + when: + action_type: shell + command_matches: ["python3 *.py", "python *.py"] + match_type: glob + decision: sandbox + sandbox: + image: "python:3.12-alpine" + network: "none" + reason: "script Python eseguito in ambiente isolato" + + - id: sandbox_pip + when: + action_type: shell + command_matches: ["pip install *", "pip3 install *"] + match_type: glob + decision: sandbox + sandbox: + image: "python:3.12-alpine" + network: "bridge" + reason: "installazione pacchetti in sandbox (rete abilitata)" + + - id: block_sensitive + when: + action_type: file + path_matches: ["**/.env", "**/.env.*", "~/.ssh/*"] + decision: block + reason: "accesso a file sensibili non consentito" +``` + +--- + +### Policy con override allow esplicito + +```yaml +version: 1 + +rules: + # allow esplicito prima del block generico + - id: allow_rm_dist + when: + action_type: shell + command_matches: ["rm -rf ./dist", "rm -rf ./build"] + decision: allow + + - id: block_rm_rf + when: + action_type: shell + command_matches: ["rm -rf *", "rm -fr *"] + decision: block + reason: "cancellazione ricorsiva bloccata" + + - id: ask_git_push + when: + action_type: git + command_matches: ["git push * main", "git push --force *"] + decision: ask + reason: "push su branch protetto — conferma richiesta" + + - id: block_remote_scripts + when: + action_type: shell + command_matches: ["curl * | *", "wget * | *", "bash <(*"] + decision: block + reason: "esecuzione di script remoti non consentita" + + - id: block_sudo + when: + action_type: shell + command_matches: ["sudo *"] + decision: block + reason: "sudo disabilitato per agenti AI" +``` + +--- + +## Comandi utili per gestire la policy + +```bash +nightagent policy list # mostra tutte le regole attive +nightagent policy toggle # attiva/disattiva una regola (block ↔ allow) +nightagent policy add # aggiunge una regola in modo interattivo +nightagent policy remove # rimuove una regola + +nightagent start # hot-reload: le modifiche al file sono applicate senza restart +``` diff --git a/docs/website-brief.md b/docs/website-brief.md new file mode 100644 index 0000000..3651985 --- /dev/null +++ b/docs/website-brief.md @@ -0,0 +1,352 @@ +# Night Agent — Website Brief + +Documento di istruzioni per il web developer. +Contiene struttura, copy, requisiti tecnici e note di design per ogni sezione della landing page. + +--- + +## Tono e riferimenti visivi + +**Tono:** enterprise sobrio, soft nerd. Niente sensazionalismo, niente fear marketing. Il prodotto si presenta come infrastruttura seria — il riferimento è Linear, Tailscale, Fly.io. + +**Palette:** scura (dark mode first). Sfondo near-black, testo bianco/grigio chiaro, accento monocromatico o verde terminale tenue. Niente rossi allarmistici. + +**Typography:** monospace per codice e UI chrome, sans-serif pulito per il copy. + +**Tono del copy:** frasi corte. Niente esclamativi. Niente aggettivi superlativi. Il prodotto parla con i fatti. + +--- + +## Struttura della pagina + +``` +1. Nav +2. Hero +3. How it works +4. Features (3 colonne) +5. Policy example (codice) +6. Audit log (codice) +7. Install +8. Footer +``` + +--- + +## 1. Nav + +**Logo:** `Night Agent` — wordmark testo, monospace, niente icone elaborate. + +**Link nav (desktop):** +- How it works +- Docs +- GitHub ↗ + +**CTA nav:** nessuna. Il nav è minimale, niente pulsanti colorati. + +**Comportamento:** sticky, sfondo leggermente più scuro dell'hero al scroll (blur/frosted glass opzionale). + +--- + +## 2. Hero + +**Layout:** centrato, full-width, padding generoso sopra e sotto. + +**Eyebrow (piccolo, monospace, opacità ridotta):** +``` +macOS · CLI · Open Source +``` + +**Headline (H1, grande, peso bold):** +``` +Runtime policy enforcement +for AI agents. +``` + +**Subheadline (corpo, max ~60 caratteri per riga):** +``` +Night Agent sits between your system and your AI agents. +Define what they can do. Audit what they did. +``` + +**CTA block — due elementi in riga:** + +1. Input copiabile con syntax highlight: +```bash +brew install nightagent +``` +Accompagnato da icona copy-to-clipboard. + +2. Link testo: +``` +View on GitHub → +``` + +**Nota design:** nessuna hero image, nessun mockup. Solo tipo e codice. Eventuale elemento decorativo: una riga sottile animata o terminale statico come sfondo a bassa opacità. + +--- + +## 3. How it works + +**Titolo sezione (H2):** +``` +How it works +``` + +**Layout:** schema orizzontale a tre step con frecce/connettori. Su mobile diventa verticale. + +**Step 1** +``` +Label: AI Agent +Detail: Claude Code, Codex, or any shell-based agent +``` + +**Connettore → freccia con label:** +``` +intercepts before execution +``` + +**Step 2** +``` +Label: Night Agent +Detail: Evaluates every command against your policy rules +``` + +**Connettore → freccia con tre uscite:** +``` +allow · block · sandbox +``` + +**Step 3** +``` +Label: Your system +Detail: Only permitted actions reach execution +``` + +**Nota design:** lo schema deve essere grafico ma asciutto — niente illustrazioni, niente icone decorative. Linee, box, label. Stile quasi wireframe ma rifinito. + +--- + +## 4. Features — tre colonne + +**Titolo sezione:** nessuno. Le card parlano da sole. + +**Layout:** griglia 3 colonne desktop, 1 colonna mobile. Niente bordi spessi, niente shadow elaborate — separatori sottili o spazio bianco. + +--- + +**Card 1** + +``` +Titolo: Policy as code +Body: Define allow, block, and sandbox rules in YAML. + Versioned in git, readable by humans. +``` + +--- + +**Card 2** + +``` +Titolo: Execution control +Body: Commands are evaluated before they run. Risky + operations are routed to an isolated Docker + environment automatically. +``` + +--- + +**Card 3** + +``` +Titolo: Audit log +Body: Every agent action is logged as structured JSONL. + Filterable by decision, command type, or outcome. + Risk score and anomaly signals included. +``` + +--- + +**Card 4** + +``` +Titolo: Framework-agnostic +Body: Works with Claude Code, Codex, and any agent + that executes shell commands. +``` + +--- + +**Card 5** + +``` +Titolo: macOS native +Body: Runs as a LaunchAgent. Starts at login, + no manual setup after init. +``` + +--- + +**Card 6** + +``` +Titolo: Low-level interception +Body: PATH shims and DYLD injection. No agent + modification required. +``` + +**Card 7** + +``` +Titolo: Risk scoring +Body: Every action gets a contextual risk score based + on heuristics — sudo, pipes, sensitive paths, + action bursts. No ML. No black box. +``` + +**Card 8** + +``` +Titolo: Policy suggestions +Body: Night Agent surfaces patterns it notices: + repeated overrides, anomalous sequences, risky + commands without a rule. You decide what to do. +``` + +**Nota design:** le card 7 e 8 estendono la griglia a 3×3 (o 4×2 a scelta). Stessa uniformità delle precedenti. Nessun accento cromatico diverso. Titolo in monospace, corpo in sans-serif. Nessun colore di accento per singola card — uniformità totale. + +--- + +## 5. Policy example + +**Titolo sezione (H2):** +``` +Your rules. Your system. +``` + +**Subhead:** +``` +Write policy in YAML. Night Agent enforces it at runtime. +``` + +**Blocco codice (syntax highlighted, dark theme, monospace):** + +```yaml +version: 1 +rules: + - id: block_rm_rf + when: + action_type: shell + command_matches: ["rm -rf *"] + decision: block + reason: "destructive operation not permitted" + + - id: sandbox_python_scripts + when: + action_type: shell + command_matches: ["python3 *.py"] + decision: sandbox + sandbox: + image: "python:3.12-alpine" + network: "none" + reason: "execute in isolated environment" + + - id: allow_git_status + when: + action_type: git + command_matches: ["git status", "git log *"] + decision: allow +``` + +**Sotto il codice, tre label inline (piccole, monospace):** +``` +allow · block · sandbox +``` + +**Nota design:** il blocco codice è il protagonista visivo della sezione. Occupa 60-70% della larghezza su desktop. Sfondo leggermente più chiaro del background pagina, bordo sottile, niente decorazioni. + +--- + +## 6. Audit log + +**Titolo sezione (H2):** +``` +Full visibility. Always. +``` + +**Subhead:** +``` +Every command evaluated by Night Agent is logged +as structured JSONL. Queryable, storable, yours. +``` + +**Blocco codice (come sopra, stesso stile):** + +```jsonl +{"timestamp":"2026-04-11T02:14:33Z","agent":"claude-code","action_type":"shell","command":"rm -rf ./dist","decision":"block","reason":"destructive operation not permitted","risk_score":0.30,"risk_level":"medium","risk_signals":["rm ricorsivo"]} +{"timestamp":"2026-04-11T02:14:41Z","agent":"claude-code","action_type":"shell","command":"python3 deploy.py","decision":"sandbox","sandboxed":true,"sandbox_image":"python:3.12-alpine","sandbox_exit_code":0,"risk_score":0.35,"risk_level":"medium","anomaly_detected":true,"suggestions":["burst anomalo rilevato — considera sandbox per questo pattern"]} +{"timestamp":"2026-04-11T02:14:55Z","agent":"claude-code","action_type":"git","command":"git status","decision":"allow","risk_score":0.00,"risk_level":"low"} +``` + +**Nota design:** il codice può scrollare orizzontalmente su mobile. Non troncarlo. I campi chiave (`decision`, `sandboxed`) possono avere highlight di colore tenue differenziato per valore: verde per allow, neutro per block, blu per sandbox. + +--- + +## 7. Install + +**Layout:** sezione centrata, sfondo leggermente diverso (un tono più chiaro o più scuro del body) per creare separazione visiva. + +**Titolo (H2):** +``` +Get started in 30 seconds. +``` + +**Tre comandi in sequenza, numerati, monospace:** + +``` +1. brew tap pietroperona/nightagent +2. brew install nightagent +3. nightagent init +``` + +**Sotto i comandi, copy piccolo:** +``` +Requires macOS and Docker Desktop for sandbox mode. +``` + +**Due link testo:** +``` +Read the docs → View on GitHub → +``` + +**Nota design:** nessun pulsante colorato. I link sono testo con freccia. La semplicità della sezione è il messaggio. + +--- + +## 8. Footer + +**Layout:** una riga orizzontale, minimal. + +**Sinistra:** +``` +Night Agent — Open Source +``` + +**Destra:** +``` +GitHub · Docs · MIT License +``` + +**Nota:** nessun copyright year, nessuna newsletter, nessun social. Pulito. + +--- + +## Note tecniche generali + +- **Dark mode only.** Non implementare light mode in questa versione. +- **Nessuna animazione pesante.** Al massimo fade-in leggero sullo scroll (Intersection Observer). Il prodotto è serio, non un portfolio creativo. +- **Performance first.** Nessun framework JS pesante se la pagina è statica. HTML/CSS puro o framework leggero (Astro, 11ty). +- **Codice sempre copiabile.** Tutti i blocchi codice hanno icona copy-to-clipboard. +- **Mobile responsive** ma il target primario è desktop (developer tool). +- **Nessuna immagine raster.** Solo testo, codice, SVG se necessario. +- **Meta tag:** title `Night Agent — Runtime policy enforcement for AI agents`, description `Night Agent sits between your system and your AI agents. Define what they can do. Audit what they did.` +- **OG image:** wordmark `Night Agent` su sfondo scuro, niente altro. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5bcee2a --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/night-agent-cli/night-agent + +go 1.26.1 + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.13.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7149370 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..d4589a9 --- /dev/null +++ b/install.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="night-agent-cli/night-agent" +INSTALL_BIN="/usr/local/bin" +INSTALL_LIB="/usr/local/lib/night-agent" +CONFIG_DIR="$HOME/.night-agent" + +# Colori +RED='\033[0;31m' +GREEN='\033[0;32m' +BOLD='\033[1m' +RESET='\033[0m' + +die() { echo -e "${RED}Errore: $1${RESET}" >&2; exit 1; } +ok() { echo -e "${GREEN}✓${RESET} $1"; } + +# Verifica prerequisiti +command -v curl >/dev/null 2>&1 || die "curl non trovato" +command -v tar >/dev/null 2>&1 || die "tar non trovato" +command -v shasum >/dev/null 2>&1 || die "shasum non trovato" + +# Verifica architettura +ARCH="$(uname -m)" +[ "$ARCH" = "arm64" ] || die "Night Agent supporta solo Apple Silicon (arm64). Rilevato: $ARCH" + +# Trova ultima versione +echo "Ricerca ultima versione..." +VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' \ + | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/') +[ -n "$VERSION" ] || die "impossibile rilevare l'ultima versione" + +ARCHIVE="night-agent-${VERSION}-darwin-arm64.tar.gz" +BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}" + +echo "Installo Night Agent ${VERSION}..." + +# Download in directory temporanea +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +curl -fsSL "${BASE_URL}/${ARCHIVE}" -o "${TMP}/${ARCHIVE}" +curl -fsSL "${BASE_URL}/${ARCHIVE}.sha256" -o "${TMP}/${ARCHIVE}.sha256" + +# Verifica checksum +EXPECTED=$(cat "${TMP}/${ARCHIVE}.sha256") +ACTUAL=$(shasum -a 256 "${TMP}/${ARCHIVE}" | awk '{print $1}') +[ "$EXPECTED" = "$ACTUAL" ] || die "checksum non valido — download corrotto" +ok "Checksum verificato" + +# Estrai +tar -xzf "${TMP}/${ARCHIVE}" -C "$TMP" + +# Installa binario principale +sudo install -m 755 "${TMP}/nightagent" "${INSTALL_BIN}/nightagent" +ok "nightagent → ${INSTALL_BIN}/nightagent" + +# Installa shim e dylib +sudo mkdir -p "${INSTALL_LIB}" +sudo install -m 755 "${TMP}/guardian-shim" "${INSTALL_LIB}/guardian-shim" +sudo install -m 644 "${TMP}/guardian-intercept.dylib" "${INSTALL_LIB}/guardian-intercept.dylib" +ok "guardian-shim + dylib → ${INSTALL_LIB}/" + +# Copia policy di default se non esiste +if [ ! -f "${CONFIG_DIR}/policy.yaml" ]; then + mkdir -p "${CONFIG_DIR}" + install -m 600 "${TMP}/configs/default_policy.yaml" "${CONFIG_DIR}/policy.yaml" + ok "Policy di default → ${CONFIG_DIR}/policy.yaml" +fi + +echo "" +echo -e "${BOLD}Night Agent ${VERSION} installato.${RESET}" +echo "" +echo "Se macOS blocca il binario con un avviso Gatekeeper, esegui:" +echo " sudo xattr -d com.apple.quarantine ${INSTALL_BIN}/nightagent" +echo " sudo xattr -d com.apple.quarantine ${INSTALL_LIB}/guardian-shim" +echo " sudo xattr -d com.apple.quarantine ${INSTALL_LIB}/guardian-intercept.dylib" +echo "" +echo "Prossimo step:" +echo " nightagent init" diff --git a/internal/audit/logger.go b/internal/audit/logger.go new file mode 100644 index 0000000..16fc810 --- /dev/null +++ b/internal/audit/logger.go @@ -0,0 +1,159 @@ +package audit + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "time" +) + +// Event rappresenta un singolo evento di audit nel log JSONL. +type Event struct { + ID string `json:"id"` + Timestamp time.Time `json:"timestamp"` + SessionID string `json:"session_id,omitempty"` + AgentName string `json:"agent_name,omitempty"` + ProjectPath string `json:"project_path,omitempty"` + ActionType string `json:"action_type,omitempty"` + Command string `json:"command,omitempty"` + Path string `json:"path,omitempty"` + WorkDir string `json:"work_dir,omitempty"` + Decision string `json:"decision"` + RuleID string `json:"rule_id,omitempty"` + Reason string `json:"reason,omitempty"` + UserOverride bool `json:"user_override,omitempty"` + // Campi sandbox (Ciclo 2) + Sandboxed bool `json:"sandboxed,omitempty"` + SandboxImage string `json:"sandbox_image,omitempty"` + SandboxExitCode *int `json:"sandbox_exit_code,omitempty"` + // Campi risk scoring (Ciclo 3) + RiskScore float64 `json:"risk_score,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + RiskSignals []string `json:"risk_signals,omitempty"` + AnomalyDetected bool `json:"anomaly_detected,omitempty"` + Suggestions []string `json:"suggestions,omitempty"` + // Signed audit trail — catena hash (blockchain-like) + PrevHash string `json:"prev_hash,omitempty"` // hash SHA256 dell'evento precedente + Sig string `json:"sig,omitempty"` // HMAC-SHA256 di tutto l'evento (incluso prev_hash) + SigSource string `json:"sig_source,omitempty"` // "local" | "remote" +} + +// Filter specifica criteri di filtro per ReadFiltered. +type Filter struct { + Decision string + ActionType string +} + +// Logger scrive eventi in formato JSONL su file. +type Logger struct { + file *os.File + enc *json.Encoder + signFn SignFunc // nil = nessuna firma + lastHash string // hash dell'ultimo evento scritto (per catena) + signer *Signer // mantenuto per retrocompatibilità con VerifyAll +} + +// NewLogger apre (o crea) il file di log e restituisce un Logger senza firma. +func NewLogger(path string) (*Logger, error) { + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return nil, fmt.Errorf("impossibile aprire il file di log: %w", err) + } + enc := json.NewEncoder(f) + enc.SetEscapeHTML(false) + return &Logger{file: f, enc: enc}, nil +} + +// NewSignedLogger apre (o crea) il file di log con firma HMAC-SHA256 attiva. +func NewSignedLogger(path string, signer *Signer) (*Logger, error) { + l, err := NewSignedLoggerWithFunc(path, LocalSignFunc(signer)) + if err != nil { + return nil, err + } + l.signer = signer // mantieni per VerifyAll + return l, nil +} + +// NewSignedLoggerWithFunc apre il file di log e usa la SignFunc fornita per firmare. +func NewSignedLoggerWithFunc(path string, fn SignFunc) (*Logger, error) { + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return nil, fmt.Errorf("impossibile aprire il file di log: %w", err) + } + enc := json.NewEncoder(f) + enc.SetEscapeHTML(false) + return &Logger{file: f, enc: enc, signFn: fn}, nil +} + +// Write scrive un evento nel log. Se l'evento non ha timestamp, lo imposta ora. +// Se il logger ha una signFn, aggiunge prev_hash (catena) e firma l'evento. +func (l *Logger) Write(event Event) error { + if event.Timestamp.IsZero() { + event.Timestamp = time.Now().UTC() + } + if l.signFn != nil { + event.PrevHash = l.lastHash + event.Sig = "" // azzera prima di firmare + sig, source, err := l.signFn(event) + if err != nil { + return fmt.Errorf("firma evento: %w", err) + } + event.Sig = sig + event.SigSource = source + l.lastHash = sig + } + if err := l.enc.Encode(event); err != nil { + return fmt.Errorf("errore scrittura evento: %w", err) + } + return nil +} + +// Close chiude il file di log. +func (l *Logger) Close() error { + return l.file.Close() +} + +// ReadAll legge tutti gli eventi dal file JSONL. +func ReadAll(path string) ([]Event, error) { + return ReadFiltered(path, Filter{}) +} + +// ReadFiltered legge gli eventi applicando un filtro opzionale. +func ReadFiltered(path string, filter Filter) ([]Event, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("impossibile aprire il log: %w", err) + } + defer f.Close() + + var events []Event + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var e Event + if err := json.Unmarshal(line, &e); err != nil { + continue // riga corrotta: salta senza fallire + } + if matchesFilter(e, filter) { + events = append(events, e) + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("errore lettura log: %w", err) + } + return events, nil +} + +func matchesFilter(e Event, f Filter) bool { + if f.Decision != "" && e.Decision != f.Decision { + return false + } + if f.ActionType != "" && e.ActionType != f.ActionType { + return false + } + return true +} diff --git a/internal/audit/logger_test.go b/internal/audit/logger_test.go new file mode 100644 index 0000000..2b5b46f --- /dev/null +++ b/internal/audit/logger_test.go @@ -0,0 +1,160 @@ +package audit_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/night-agent-cli/night-agent/internal/audit" + "github.com/night-agent-cli/night-agent/internal/policy" +) + +func TestLogger_WriteAndRead(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "audit.jsonl") + + logger, err := audit.NewLogger(logPath) + if err != nil { + t.Fatalf("errore creazione logger: %v", err) + } + defer logger.Close() + + event := audit.Event{ + ID: "evt-001", + Timestamp: time.Now().UTC(), + AgentName: "claude-code", + ActionType: "shell", + Command: "sudo rm -rf /tmp", + WorkDir: "/home/user/project", + Decision: string(policy.DecisionBlock), + RuleID: "block_sudo", + Reason: "sudo disabilitato", + } + + if err := logger.Write(event); err != nil { + t.Fatalf("errore scrittura evento: %v", err) + } + + events, err := audit.ReadAll(logPath) + if err != nil { + t.Fatalf("errore lettura log: %v", err) + } + + if len(events) != 1 { + t.Fatalf("atteso 1 evento, ottenuti %d", len(events)) + } + if events[0].ID != "evt-001" { + t.Errorf("atteso id=evt-001, ottenuto %s", events[0].ID) + } + if events[0].Decision != string(policy.DecisionBlock) { + t.Errorf("atteso decision=block, ottenuto %s", events[0].Decision) + } +} + +func TestLogger_MultipleEvents(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "audit.jsonl") + + logger, err := audit.NewLogger(logPath) + if err != nil { + t.Fatalf("errore creazione logger: %v", err) + } + defer logger.Close() + + for i := range 3 { + _ = logger.Write(audit.Event{ + ID: "evt-00" + string(rune('1'+i)), + Decision: string(policy.DecisionAllow), + }) + } + + events, err := audit.ReadAll(logPath) + if err != nil { + t.Fatalf("errore lettura: %v", err) + } + if len(events) != 3 { + t.Errorf("attesi 3 eventi, ottenuti %d", len(events)) + } +} + +func TestLogger_ValidJSONL(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "audit.jsonl") + + logger, _ := audit.NewLogger(logPath) + defer logger.Close() + _ = logger.Write(audit.Event{ID: "evt-001", Decision: "block"}) + + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("errore lettura file: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Errorf("ogni riga deve essere JSON valido: %v", err) + } +} + +func TestReadAll_EmptyFile(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "audit.jsonl") + _ = os.WriteFile(logPath, []byte{}, 0600) + + events, err := audit.ReadAll(logPath) + if err != nil { + t.Fatalf("atteso nessun errore per file vuoto, ottenuto: %v", err) + } + if len(events) != 0 { + t.Errorf("attesi 0 eventi, ottenuti %d", len(events)) + } +} + +func TestReadAll_FileNotFound(t *testing.T) { + _, err := audit.ReadAll("/nonexistent/path/audit.jsonl") + if err == nil { + t.Fatal("atteso errore per file mancante, ottenuto nil") + } +} + +func TestLogger_FilterByDecision(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "audit.jsonl") + + logger, _ := audit.NewLogger(logPath) + defer logger.Close() + + _ = logger.Write(audit.Event{ID: "1", Decision: "block"}) + _ = logger.Write(audit.Event{ID: "2", Decision: "allow"}) + _ = logger.Write(audit.Event{ID: "3", Decision: "block"}) + + events, err := audit.ReadFiltered(logPath, audit.Filter{Decision: "block"}) + if err != nil { + t.Fatalf("errore lettura filtrata: %v", err) + } + if len(events) != 2 { + t.Errorf("attesi 2 eventi block, ottenuti %d", len(events)) + } +} + +func TestLogger_FilterByActionType(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "audit.jsonl") + + logger, _ := audit.NewLogger(logPath) + defer logger.Close() + + _ = logger.Write(audit.Event{ID: "1", ActionType: "shell", Decision: "block"}) + _ = logger.Write(audit.Event{ID: "2", ActionType: "git", Decision: "ask"}) + _ = logger.Write(audit.Event{ID: "3", ActionType: "shell", Decision: "allow"}) + + events, err := audit.ReadFiltered(logPath, audit.Filter{ActionType: "git"}) + if err != nil { + t.Fatalf("errore lettura filtrata: %v", err) + } + if len(events) != 1 { + t.Errorf("atteso 1 evento git, ottenuti %d", len(events)) + } +} diff --git a/internal/audit/signing.go b/internal/audit/signing.go new file mode 100644 index 0000000..60d3523 --- /dev/null +++ b/internal/audit/signing.go @@ -0,0 +1,155 @@ +package audit + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" +) + +// SignFunc è la funzione iniettata nel Logger per firmare eventi. +// Riceve l'evento da firmare, restituisce la firma (sig), la sorgente ("local"/"remote") e un eventuale errore. +type SignFunc func(e Event) (sig, source string, err error) + +// LocalSignFunc restituisce una SignFunc che firma con il Signer locale. +func LocalSignFunc(s *Signer) SignFunc { + return func(e Event) (string, string, error) { + signed, err := s.Sign(e) + if err != nil { + return "", "", err + } + return signed.Sig, "local", nil + } +} + +// Signer gestisce la chiave HMAC-SHA256 per firmare e verificare eventi. +// La chiave è un segreto locale a 32 byte conservato in ~/.night-agent/signing.key. +// Con la futura cloud dashboard la chiave pubblica potrà essere caricata per +// verifica server-side (upgrade a Ed25519 pianificato). +type Signer struct { + key []byte +} + +// GenerateKey crea una nuova chiave casuale a 32 byte nel file keyPath. +// Se il file esiste già non fa nulla (idempotente). +func GenerateKey(keyPath string) error { + if _, err := os.Stat(keyPath); err == nil { + return nil // già presente + } + + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return fmt.Errorf("generazione chiave: %w", err) + } + + encoded := hex.EncodeToString(key) + return os.WriteFile(keyPath, []byte(encoded), 0600) +} + +// NewSigner carica la chiave da keyPath e restituisce un Signer pronto. +func NewSigner(keyPath string) (*Signer, error) { + data, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("lettura chiave: %w", err) + } + + key, err := hex.DecodeString(string(data)) + if err != nil { + return nil, fmt.Errorf("chiave non valida: %w", err) + } + if len(key) != 32 { + return nil, fmt.Errorf("chiave deve essere 32 byte, trovati %d", len(key)) + } + + return &Signer{key: key}, nil +} + +// Sign aggiunge la firma HMAC-SHA256 all'evento e lo restituisce. +// La firma copre la serializzazione JSON dell'evento con Sig="" e SigSource="" +// (sig_source è campo informativo, non incluso nel payload firmato). +func (s *Signer) Sign(e Event) (Event, error) { + e.Sig = "" // azzera prima di firmare + e.SigSource = "" // non incluso nel payload firmato + payload, err := json.Marshal(e) + if err != nil { + return e, fmt.Errorf("serializzazione evento: %w", err) + } + e.Sig = s.computeHMAC(payload) + return e, nil +} + +// Verify controlla che la firma dell'evento sia valida. +// sig_source non fa parte del payload firmato: viene azzerato prima della verifica. +func (s *Signer) Verify(e Event) error { + if e.Sig == "" { + return fmt.Errorf("evento senza firma (sig assente)") + } + if len(s.key) == 0 { + return fmt.Errorf("signer senza chiave") + } + + sig := e.Sig + e.Sig = "" + e.SigSource = "" // non incluso nel payload firmato + payload, err := json.Marshal(e) + if err != nil { + return fmt.Errorf("serializzazione evento: %w", err) + } + + expected := s.computeHMAC(payload) + if !hmac.Equal([]byte(sig), []byte(expected)) { + return fmt.Errorf("firma non valida — evento potenzialmente manomesso") + } + return nil +} + +func (s *Signer) computeHMAC(data []byte) string { + mac := hmac.New(sha256.New, s.key) + mac.Write(data) + return hex.EncodeToString(mac.Sum(nil)) +} + +// VerifyResult è il risultato della verifica di un singolo evento. +type VerifyResult struct { + EventID string + Index int + Err error +} + +// VerifyAll legge tutti gli eventi da logPath e ne verifica: +// 1. la firma HMAC-SHA256 (integrità del contenuto) +// 2. la catena prev_hash (nessun evento eliminato o riordinato) +// Restituisce un risultato per ogni evento (Err nil = tutto valido). +func VerifyAll(logPath string, signer *Signer) ([]VerifyResult, error) { + events, err := ReadAll(logPath) + if err != nil { + return nil, err + } + + results := make([]VerifyResult, len(events)) + var prevSig string + + for i, e := range events { + r := VerifyResult{EventID: e.ID, Index: i} + + // 1. verifica firma + if sigErr := signer.Verify(e); sigErr != nil { + r.Err = sigErr + results[i] = r + prevSig = e.Sig + continue + } + + // 2. verifica catena: prev_hash deve corrispondere alla firma dell'evento precedente + if i > 0 && e.PrevHash != "" && e.PrevHash != prevSig { + r.Err = fmt.Errorf("catena hash spezzata — evento precedente mancante o modificato") + } + + prevSig = e.Sig + results[i] = r + } + return results, nil +} diff --git a/internal/audit/signing_test.go b/internal/audit/signing_test.go new file mode 100644 index 0000000..59b700d --- /dev/null +++ b/internal/audit/signing_test.go @@ -0,0 +1,265 @@ +package audit_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/night-agent-cli/night-agent/internal/audit" +) + +func TestGenerateKey_CreatesFile(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "signing.key") + + if err := audit.GenerateKey(keyPath); err != nil { + t.Fatalf("GenerateKey: %v", err) + } + + info, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("key file non creato: %v", err) + } + if info.Mode().Perm() != 0600 { + t.Errorf("permessi attesi 0600, got %o", info.Mode().Perm()) + } +} + +func TestGenerateKey_Idempotent(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "signing.key") + + if err := audit.GenerateKey(keyPath); err != nil { + t.Fatal(err) + } + data1, _ := os.ReadFile(keyPath) + + // seconda chiamata non deve sovrascrivere + if err := audit.GenerateKey(keyPath); err != nil { + t.Fatal(err) + } + data2, _ := os.ReadFile(keyPath) + + if string(data1) != string(data2) { + t.Error("GenerateKey sovrascrive chiave esistente") + } +} + +func TestSignAndVerify_RoundTrip(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "signing.key") + audit.GenerateKey(keyPath) + + signer, err := audit.NewSigner(keyPath) + if err != nil { + t.Fatalf("NewSigner: %v", err) + } + + event := audit.Event{ + ID: "test-123", + Command: "git push origin main", + Decision: "block", + Reason: "test", + } + + signed, err := signer.Sign(event) + if err != nil { + t.Fatalf("Sign: %v", err) + } + if signed.Sig == "" { + t.Error("firma assente dopo Sign") + } + + if err := signer.Verify(signed); err != nil { + t.Errorf("Verify fallito su evento valido: %v", err) + } +} + +func TestVerify_DetectsTampering(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "signing.key") + audit.GenerateKey(keyPath) + + signer, _ := audit.NewSigner(keyPath) + + event := audit.Event{ + ID: "test-456", + Command: "go build ./...", + Decision: "allow", + } + signed, _ := signer.Sign(event) + + // manomissione: cambia il comando dopo la firma + signed.Command = "sudo rm -rf /" + + if err := signer.Verify(signed); err == nil { + t.Error("Verify dovrebbe fallire su evento manomesso") + } +} + +func TestVerify_NoKey(t *testing.T) { + event := audit.Event{ID: "x", Decision: "allow"} + // evento senza sig → Verify restituisce errore specifico + signer := &audit.Signer{} + if err := signer.Verify(event); err == nil { + t.Error("Verify dovrebbe fallire su evento senza firma") + } +} + +func TestLoggerWithSigning_WritesAndVerifies(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "signing.key") + logPath := filepath.Join(dir, "audit.jsonl") + + audit.GenerateKey(keyPath) + signer, _ := audit.NewSigner(keyPath) + + logger, err := audit.NewSignedLogger(logPath, signer) + if err != nil { + t.Fatalf("NewSignedLogger: %v", err) + } + + events := []audit.Event{ + {ID: "1", Command: "git status", Decision: "allow"}, + {ID: "2", Command: "sudo su", Decision: "block"}, + } + for _, e := range events { + if err := logger.Write(e); err != nil { + t.Fatalf("Write: %v", err) + } + } + logger.Close() + + // leggi e verifica tutti + results, err := audit.VerifyAll(logPath, signer) + if err != nil { + t.Fatalf("VerifyAll: %v", err) + } + for _, r := range results { + if r.Err != nil { + t.Errorf("evento %s: verifica fallita: %v", r.EventID, r.Err) + } + } +} + +func TestChain_DetectsDeletedEvent(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "signing.key") + logPath := filepath.Join(dir, "audit.jsonl") + + audit.GenerateKey(keyPath) + signer, _ := audit.NewSigner(keyPath) + + logger, _ := audit.NewSignedLogger(logPath, signer) + logger.Write(audit.Event{ID: "1", Command: "ls", Decision: "allow"}) + logger.Write(audit.Event{ID: "2", Command: "git status", Decision: "allow"}) + logger.Write(audit.Event{ID: "3", Command: "sudo su", Decision: "block"}) + logger.Close() + + // leggi le righe, rimuovi la riga 2 (evento nel mezzo) + data, _ := os.ReadFile(logPath) + lines := splitLines(data) + if len(lines) < 3 { + t.Skip("meno di 3 eventi nel log") + } + // rimuovi riga 2 (indice 1) + trimmed := append(lines[:1], lines[2:]...) + os.WriteFile(logPath, joinLines(trimmed), 0600) + + results, _ := audit.VerifyAll(logPath, signer) + chainBroken := false + for _, r := range results { + if r.Err != nil { + chainBroken = true + } + } + if !chainBroken { + t.Error("VerifyAll dovrebbe rilevare evento eliminato (catena hash spezzata)") + } +} + +func splitLines(data []byte) [][]byte { + var lines [][]byte + start := 0 + for i, b := range data { + if b == '\n' { + if i > start { + lines = append(lines, data[start:i]) + } + start = i + 1 + } + } + return lines +} + +func joinLines(lines [][]byte) []byte { + var out []byte + for _, l := range lines { + out = append(out, l...) + out = append(out, '\n') + } + return out +} + +func TestVerifyAll_DetectsTamperedLine(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "signing.key") + logPath := filepath.Join(dir, "audit.jsonl") + + audit.GenerateKey(keyPath) + signer, _ := audit.NewSigner(keyPath) + + logger, _ := audit.NewSignedLogger(logPath, signer) + logger.Write(audit.Event{ID: "1", Command: "ls", Decision: "allow"}) + logger.Close() + + // manometti il file JSONL direttamente + data, _ := os.ReadFile(logPath) + tampered := append(data[:len(data)-2], []byte(`,"decision":"block"}`)...) + os.WriteFile(logPath, append(tampered, '\n'), 0600) + + results, _ := audit.VerifyAll(logPath, signer) + if len(results) == 0 || results[0].Err == nil { + t.Error("VerifyAll dovrebbe rilevare manomissione") + } +} + +func TestSignFunc_Local(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "signing.key") + audit.GenerateKey(keyPath) + signer, _ := audit.NewSigner(keyPath) + + fn := audit.LocalSignFunc(signer) + e := audit.Event{ID: "test-1", Decision: "allow"} + sig, source, err := fn(e) + if err != nil { + t.Fatal(err) + } + if sig == "" { + t.Error("sig vuota") + } + if source != "local" { + t.Errorf("source=%s, want local", source) + } +} + +func TestLogger_WriteSigSource(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "signing.key") + logPath := filepath.Join(dir, "audit.jsonl") + audit.GenerateKey(keyPath) + signer, _ := audit.NewSigner(keyPath) + + logger, _ := audit.NewSignedLoggerWithFunc(logPath, audit.LocalSignFunc(signer)) + logger.Write(audit.Event{ID: "1", Decision: "allow"}) + logger.Close() + + events, _ := audit.ReadAll(logPath) + if len(events) == 0 { + t.Fatal("nessun evento") + } + if events[0].SigSource != "local" { + t.Errorf("sig_source=%q, want local", events[0].SigSource) + } +} diff --git a/internal/claudehook/claudehook.go b/internal/claudehook/claudehook.go new file mode 100644 index 0000000..0b198a3 --- /dev/null +++ b/internal/claudehook/claudehook.go @@ -0,0 +1,188 @@ +// Package claudehook gestisce la configurazione del hook PreToolUse +// in ~/.claude/settings.json per l'integrazione con Claude Code. +package claudehook + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +const hookMarker = "nightagent mcp-hook" + +// IsClaudeInstalled rileva se Claude Code è installato controllando: +// 1. il binario `claude` nel PATH +// 2. l'esistenza di ~/.claude/ (directory di configurazione Claude Code) +func IsClaudeInstalled() bool { + if _, err := exec.LookPath("claude"); err == nil { + return true + } + home, err := os.UserHomeDir() + if err != nil { + return false + } + _, err = os.Stat(filepath.Join(home, ".claude")) + return err == nil +} + +// IsConfigured verifica se il hook nightagent è già in settings.json. +func IsConfigured(settingsPath string) bool { + data, err := os.ReadFile(settingsPath) + if err != nil { + return false + } + // ricerca veloce stringa — evita parsing JSON completo + for i := 0; i+len(hookMarker) <= len(data); i++ { + if string(data[i:i+len(hookMarker)]) == hookMarker { + return true + } + } + return false +} + +// Install aggiunge il hook PreToolUse a ~/.claude/settings.json. +// Crea il file se non esiste. Idempotente — non aggiunge il hook due volte. +// nightagentBin è il path assoluto del binario nightagent da usare nel hook. +func Install(settingsPath, nightagentBin string) error { + if IsConfigured(settingsPath) { + return nil // già configurato + } + + settings, err := loadSettings(settingsPath) + if err != nil { + return fmt.Errorf("lettura settings.json: %w", err) + } + + hookCmd := nightagentBin + " mcp-hook" + addHook(settings, hookCmd) + + return writeSettings(settingsPath, settings) +} + +// Remove rimuove il hook nightagent da ~/.claude/settings.json. +func Remove(settingsPath string) error { + if !IsConfigured(settingsPath) { + return nil // non presente + } + + settings, err := loadSettings(settingsPath) + if err != nil { + return fmt.Errorf("lettura settings.json: %w", err) + } + + removeHook(settings) + return writeSettings(settingsPath, settings) +} + +// SettingsPath restituisce il path standard di ~/.claude/settings.json. +func SettingsPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".claude", "settings.json"), nil +} + +// --- helpers --- + +func loadSettings(path string) (map[string]interface{}, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return map[string]interface{}{}, nil + } + if err != nil { + return nil, err + } + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + return m, nil +} + +func writeSettings(path string, settings map[string]interface{}) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0600) +} + +// addHook inserisce il hook PreToolUse nella struttura settings. +func addHook(settings map[string]interface{}, hookCmd string) { + hooks, _ := settings["hooks"].(map[string]interface{}) + if hooks == nil { + hooks = map[string]interface{}{} + } + + newEntry := map[string]interface{}{ + "matcher": "*", + "hooks": []interface{}{ + map[string]interface{}{ + "type": "command", + "command": hookCmd, + }, + }, + } + + preToolUse, _ := hooks["PreToolUse"].([]interface{}) + hooks["PreToolUse"] = append(preToolUse, newEntry) + settings["hooks"] = hooks +} + +// removeHook rimuove le voci PreToolUse che contengono il marker nightagent. +func removeHook(settings map[string]interface{}) { + hooks, ok := settings["hooks"].(map[string]interface{}) + if !ok { + return + } + + preToolUse, ok := hooks["PreToolUse"].([]interface{}) + if !ok { + return + } + + filtered := preToolUse[:0] + for _, entry := range preToolUse { + entryMap, ok := entry.(map[string]interface{}) + if !ok { + filtered = append(filtered, entry) + continue + } + innerHooks, _ := entryMap["hooks"].([]interface{}) + hasMarker := false + for _, h := range innerHooks { + hMap, _ := h.(map[string]interface{}) + cmd, _ := hMap["command"].(string) + if len(cmd) >= len(hookMarker) && cmd[len(cmd)-len(hookMarker):] == hookMarker { + hasMarker = true + break + } + // controlla anche se la stringa contiene il marker (path assoluto) + for i := 0; i+len(hookMarker) <= len(cmd); i++ { + if cmd[i:i+len(hookMarker)] == hookMarker { + hasMarker = true + break + } + } + } + if !hasMarker { + filtered = append(filtered, entry) + } + } + + if len(filtered) == 0 { + delete(hooks, "PreToolUse") + } else { + hooks["PreToolUse"] = filtered + } + + if len(hooks) == 0 { + delete(settings, "hooks") + } +} diff --git a/internal/claudehook/claudehook_test.go b/internal/claudehook/claudehook_test.go new file mode 100644 index 0000000..eefafbc --- /dev/null +++ b/internal/claudehook/claudehook_test.go @@ -0,0 +1,108 @@ +package claudehook_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/night-agent-cli/night-agent/internal/claudehook" +) + +func TestIsConfigured_FalseWhenMissing(t *testing.T) { + dir := t.TempDir() + if claudehook.IsConfigured(filepath.Join(dir, "settings.json")) { + t.Error("atteso false su file mancante") + } +} + +func TestInstall_CreatesHook(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + + if err := claudehook.Install(path, "/usr/local/bin/nightagent"); err != nil { + t.Fatalf("Install: %v", err) + } + + if !claudehook.IsConfigured(path) { + t.Error("hook non trovato dopo Install") + } + + // verifica struttura JSON + data, _ := os.ReadFile(path) + var m map[string]interface{} + json.Unmarshal(data, &m) + + hooks, _ := m["hooks"].(map[string]interface{}) + if hooks == nil { + t.Fatal("campo hooks mancante") + } + preToolUse, _ := hooks["PreToolUse"].([]interface{}) + if len(preToolUse) == 0 { + t.Fatal("PreToolUse vuoto") + } +} + +func TestInstall_Idempotent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + + claudehook.Install(path, "/usr/local/bin/nightagent") + claudehook.Install(path, "/usr/local/bin/nightagent") + + data, _ := os.ReadFile(path) + var m map[string]interface{} + json.Unmarshal(data, &m) + + hooks := m["hooks"].(map[string]interface{}) + preToolUse := hooks["PreToolUse"].([]interface{}) + if len(preToolUse) != 1 { + t.Errorf("attesa 1 entry PreToolUse, trovate %d", len(preToolUse)) + } +} + +func TestInstall_PreservesExistingSettings(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + + existing := `{"env": {}, "permissions": {"allow": ["Bash"]}}` + os.WriteFile(path, []byte(existing), 0600) + + if err := claudehook.Install(path, "/usr/local/bin/nightagent"); err != nil { + t.Fatalf("Install: %v", err) + } + + data, _ := os.ReadFile(path) + var m map[string]interface{} + json.Unmarshal(data, &m) + + // campo pre-esistente deve essere preservato + perms, _ := m["permissions"].(map[string]interface{}) + if perms == nil { + t.Error("permissions rimosso dopo Install") + } +} + +func TestRemove_RemovesHook(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + + claudehook.Install(path, "/usr/local/bin/nightagent") + if err := claudehook.Remove(path); err != nil { + t.Fatalf("Remove: %v", err) + } + if claudehook.IsConfigured(path) { + t.Error("hook ancora presente dopo Remove") + } +} + +func TestRemove_Idempotent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + + claudehook.Install(path, "/usr/local/bin/nightagent") + claudehook.Remove(path) + if err := claudehook.Remove(path); err != nil { + t.Errorf("secondo Remove ha restituito errore: %v", err) + } +} diff --git a/internal/cloudconfig/config.go b/internal/cloudconfig/config.go new file mode 100644 index 0000000..bca6b70 --- /dev/null +++ b/internal/cloudconfig/config.go @@ -0,0 +1,111 @@ +package cloudconfig + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" + "gopkg.in/yaml.v3" +) + +// defaultEndpoint è iniettato a compile time via ldflags: +// +// go build -ldflags "-X github.com/night-agent-cli/night-agent/internal/cloudconfig.defaultEndpoint=https://..." +// +// Non esporre mai questo valore come flag CLI — l'utente non deve poterlo cambiare. +var defaultEndpoint = "https://api.nightagent.dev" + +// Config contiene la configurazione per la connessione cloud. +// Salvata in ~/.night-agent/cloud.yaml. +type Config struct { + Token string `yaml:"token"` + Endpoint string `yaml:"endpoint"` + MachineID string `yaml:"machine_id"` + Cursor string `yaml:"cursor,omitempty"` // ID ultimo evento sincronizzato + LastSync time.Time `yaml:"last_sync,omitempty"` // timestamp ultimo sync riuscito + Connected bool `yaml:"connected"` +} + +// Load legge la configurazione da path. Se il file non esiste, restituisce +// una Config vuota senza errore. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return &Config{Endpoint: defaultEndpoint}, nil + } + if err != nil { + return nil, fmt.Errorf("lettura cloud.yaml: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing cloud.yaml: %w", err) + } + // Usa endpoint dal YAML se presente, altrimenti quello compilato. + if cfg.Endpoint == "" { + cfg.Endpoint = defaultEndpoint + } + return &cfg, nil +} + +// Save scrive la configurazione su path (crea directory se mancante). +func Save(path string, cfg *Config) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return fmt.Errorf("creazione directory: %w", err) + } + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("serializzazione cloud.yaml: %w", err) + } + return os.WriteFile(path, data, 0600) +} + +// Connect imposta il token e genera un machine_id se non già presente. +// Aggiorna il file di configurazione su disco. +func Connect(path, token string) (*Config, error) { + cfg, err := Load(path) + if err != nil { + return nil, err + } + + cfg.Token = token + cfg.Connected = true + + if cfg.MachineID == "" { + cfg.MachineID = uuid.New().String() + } + + if err := Save(path, cfg); err != nil { + return nil, err + } + return cfg, nil +} + +// Disconnect rimuove il token e segna la connessione come inattiva. +func Disconnect(path string) error { + cfg, err := Load(path) + if err != nil { + return err + } + cfg.Token = "" + cfg.Connected = false + return Save(path, cfg) +} + +// IsConnected restituisce true se il cloud è configurato e connesso. +func (c *Config) IsConnected() bool { + return c != nil && c.Connected && c.Token != "" && c.MachineID != "" +} + +// UpdateCursor aggiorna il cursore e il timestamp dell'ultimo sync. +func UpdateCursor(path, cursor string) error { + cfg, err := Load(path) + if err != nil { + return err + } + cfg.Cursor = cursor + cfg.LastSync = time.Now().UTC() + return Save(path, cfg) +} diff --git a/internal/cloudconfig/config_test.go b/internal/cloudconfig/config_test.go new file mode 100644 index 0000000..10e1217 --- /dev/null +++ b/internal/cloudconfig/config_test.go @@ -0,0 +1,169 @@ +package cloudconfig_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/night-agent-cli/night-agent/internal/cloudconfig" +) + +func TestLoad_FileNotExist_ReturnsEmpty(t *testing.T) { + dir := t.TempDir() + cfg, err := cloudconfig.Load(filepath.Join(dir, "cloud.yaml")) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Token != "" { + t.Errorf("token atteso vuoto, got %q", cfg.Token) + } + if cfg.Endpoint == "" { + t.Error("endpoint default atteso non vuoto") + } + if cfg.Connected { + t.Error("connected atteso false su config vuota") + } +} + +func TestSaveAndLoad_RoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cloud.yaml") + + cfg := &cloudconfig.Config{ + Token: "tok-abc", + Endpoint: "https://api.example.com", // ignorato: Load usa sempre defaultEndpoint + MachineID: "machine-123", + Connected: true, + } + if err := cloudconfig.Save(path, cfg); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := cloudconfig.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.Token != cfg.Token { + t.Errorf("token: want %q, got %q", cfg.Token, loaded.Token) + } + // Endpoint sempre dal valore compilato, non dal file. + if loaded.Endpoint == "" { + t.Error("endpoint atteso non vuoto") + } + if loaded.MachineID != cfg.MachineID { + t.Errorf("machine_id: want %q, got %q", cfg.MachineID, loaded.MachineID) + } + if !loaded.Connected { + t.Error("connected atteso true") + } +} + +func TestSave_CreatesDirectoryIfMissing(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "subdir", "cloud.yaml") + + cfg := &cloudconfig.Config{Token: "tok", Connected: true} + if err := cloudconfig.Save(path, cfg); err != nil { + t.Fatalf("Save: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("file non creato: %v", err) + } +} + +func TestSave_FilePermissions(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cloud.yaml") + + if err := cloudconfig.Save(path, &cloudconfig.Config{Token: "tok"}); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Errorf("permessi attesi 0600, got %o", info.Mode().Perm()) + } +} + +func TestConnect_SetsTokenAndMachineID(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cloud.yaml") + + cfg, err := cloudconfig.Connect(path, "my-token") + if err != nil { + t.Fatalf("Connect: %v", err) + } + if cfg.Token != "my-token" { + t.Errorf("token: want 'my-token', got %q", cfg.Token) + } + if cfg.MachineID == "" { + t.Error("machine_id non generato") + } + if !cfg.Connected { + t.Error("connected atteso true dopo Connect") + } +} + +func TestConnect_PreservesMachineID(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cloud.yaml") + + // primo connect — genera machine_id + first, err := cloudconfig.Connect(path, "tok1") + if err != nil { + t.Fatalf("primo Connect: %v", err) + } + + // secondo connect (rinnovo token) — machine_id invariato + second, err := cloudconfig.Connect(path, "tok2") + if err != nil { + t.Fatalf("secondo Connect: %v", err) + } + if first.MachineID != second.MachineID { + t.Errorf("machine_id cambiato: %q → %q", first.MachineID, second.MachineID) + } + if second.Token != "tok2" { + t.Errorf("token non aggiornato: got %q", second.Token) + } +} + +func TestDisconnect_ClearsToken(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cloud.yaml") + + cloudconfig.Connect(path, "tok") + if err := cloudconfig.Disconnect(path); err != nil { + t.Fatalf("Disconnect: %v", err) + } + + cfg, _ := cloudconfig.Load(path) + if cfg.Token != "" { + t.Errorf("token atteso vuoto dopo Disconnect, got %q", cfg.Token) + } + if cfg.Connected { + t.Error("connected atteso false dopo Disconnect") + } +} + +func TestUpdateCursor_SetsCursorAndLastSync(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cloud.yaml") + + cloudconfig.Save(path, &cloudconfig.Config{Token: "tok", Connected: true}) + before := time.Now() + + if err := cloudconfig.UpdateCursor(path, "event-99"); err != nil { + t.Fatalf("UpdateCursor: %v", err) + } + + cfg, _ := cloudconfig.Load(path) + if cfg.Cursor != "event-99" { + t.Errorf("cursor: want 'event-99', got %q", cfg.Cursor) + } + if cfg.LastSync.Before(before) { + t.Error("LastSync non aggiornato") + } +} diff --git a/internal/cloudconfig/remote_signer.go b/internal/cloudconfig/remote_signer.go new file mode 100644 index 0000000..ced29d3 --- /dev/null +++ b/internal/cloudconfig/remote_signer.go @@ -0,0 +1,115 @@ +package cloudconfig + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/night-agent-cli/night-agent/internal/audit" +) + +const remoteSignTimeout = 3 * time.Second + +// RemoteSigner chiama POST /api/sign per ottenere la firma del cloud. +type RemoteSigner struct { + cfg *Config + client *http.Client +} + +// NewRemoteSigner crea un RemoteSigner dalla Config corrente. +func NewRemoteSigner(cfg *Config) *RemoteSigner { + return &RemoteSigner{ + cfg: cfg, + client: &http.Client{Timeout: remoteSignTimeout}, + } +} + +// SignFunc restituisce una audit.SignFunc che: +// - tenta firma remota (timeout 3s) +// - in caso di errore, fa fallback locale usando localSigner +func (r *RemoteSigner) SignFunc(localSigner *audit.Signer) audit.SignFunc { + return func(e audit.Event) (string, string, error) { + // calcola hash dell'evento (con sig="" e sig_source="" come fa Sign locale) + e.Sig = "" + e.SigSource = "" + payload, err := json.Marshal(e) + if err != nil { + return r.fallback(localSigner, e, fmt.Errorf("marshal: %w", err)) + } + hash := sha256.Sum256(payload) + hashHex := hex.EncodeToString(hash[:]) + + // chiama /api/sign + sig, err := r.callSign(hashHex, e.ID) + if err != nil { + return r.fallback(localSigner, e, err) + } + return sig, "remote", nil + } +} + +type signRequest struct { + MachineID string `json:"machine_id"` + EventID string `json:"event_id"` + Hash string `json:"hash"` +} + +type signResponse struct { + Sig string `json:"sig"` +} + +func (r *RemoteSigner) callSign(hashHex, eventID string) (string, error) { + body, err := json.Marshal(signRequest{ + MachineID: r.cfg.MachineID, + EventID: eventID, + Hash: hashHex, + }) + if err != nil { + return "", err + } + + ctx, cancel := context.WithTimeout(context.Background(), remoteSignTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.cfg.Endpoint+"/api/sign", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+r.cfg.Token) + + resp, err := r.client.Do(req) + if err != nil { + return "", fmt.Errorf("remote sign request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("remote sign: status %d", resp.StatusCode) + } + + var res signResponse + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return "", err + } + if res.Sig == "" { + return "", fmt.Errorf("remote sign: empty sig") + } + return res.Sig, nil +} + +func (r *RemoteSigner) fallback(localSigner *audit.Signer, e audit.Event, reason error) (string, string, error) { + if localSigner == nil { + return "", "local", fmt.Errorf("remote sign fallito e nessun signer locale: %w", reason) + } + signed, err := localSigner.Sign(e) + if err != nil { + return "", "local", fmt.Errorf("fallback locale: %w", err) + } + return signed.Sig, "local", nil +} diff --git a/internal/cloudconfig/remote_signer_test.go b/internal/cloudconfig/remote_signer_test.go new file mode 100644 index 0000000..90a3647 --- /dev/null +++ b/internal/cloudconfig/remote_signer_test.go @@ -0,0 +1,120 @@ +package cloudconfig_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/night-agent-cli/night-agent/internal/audit" + "github.com/night-agent-cli/night-agent/internal/cloudconfig" +) + +func TestRemoteSigner_RemoteSuccess(t *testing.T) { + // Mock HTTP server che restituisce sig + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/sign" { + t.Errorf("path=%s", r.URL.Path) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"sig": "fakesig123"}) + })) + defer srv.Close() + + cfg := &cloudconfig.Config{ + Endpoint: srv.URL, + Token: "testtoken", + MachineID: "machine-1", + Connected: true, + } + rs := cloudconfig.NewRemoteSigner(cfg) + fn := rs.SignFunc(nil) // nil = nessun fallback locale + + e := audit.Event{ID: "evt-1", Decision: "allow"} + sig, source, err := fn(e) + if err != nil { + t.Fatal(err) + } + if sig != "fakesig123" { + t.Errorf("sig=%s, want fakesig123", sig) + } + if source != "remote" { + t.Errorf("source=%s, want remote", source) + } +} + +func TestRemoteSigner_TimeoutFallback(t *testing.T) { + // Server che non risponde mai → fallback locale + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(10 * time.Second) // > 3s timeout + })) + defer srv.Close() + + dir := t.TempDir() + keyPath := filepath.Join(dir, "signing.key") + audit.GenerateKey(keyPath) + localSigner, _ := audit.NewSigner(keyPath) + + cfg := &cloudconfig.Config{Endpoint: srv.URL, Token: "t", MachineID: "m1", Connected: true} + rs := cloudconfig.NewRemoteSigner(cfg) + fn := rs.SignFunc(localSigner) + + e := audit.Event{ID: "evt-2", Decision: "block"} + _, source, err := fn(e) + if err != nil { + t.Fatal(err) // fallback non deve restituire errore + } + if source != "local" { + t.Errorf("source=%s, want local (fallback)", source) + } +} + +func TestRemoteSigner_NoCloudFallback(t *testing.T) { + // Server che risponde 500 → fallback locale + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + })) + defer srv.Close() + + dir := t.TempDir() + keyPath := filepath.Join(dir, "signing.key") + audit.GenerateKey(keyPath) + localSigner, _ := audit.NewSigner(keyPath) + + cfg := &cloudconfig.Config{Endpoint: srv.URL, Token: "t", MachineID: "m1", Connected: true} + rs := cloudconfig.NewRemoteSigner(cfg) + fn := rs.SignFunc(localSigner) + + _, source, err := fn(audit.Event{ID: "evt-3", Decision: "allow"}) + if err != nil { + t.Fatal(err) + } + if source != "local" { + t.Errorf("source=%s, want local", source) + } +} + +func TestIsConnected(t *testing.T) { + tests := []struct { + name string + cfg *cloudconfig.Config + want bool + }{ + {"nil config", nil, false}, + {"empty config", &cloudconfig.Config{}, false}, + {"connected=true but no token", &cloudconfig.Config{Connected: true, MachineID: "m"}, false}, + {"connected=true but no machine_id", &cloudconfig.Config{Connected: true, Token: "t"}, false}, + {"fully connected", &cloudconfig.Config{Connected: true, Token: "t", MachineID: "m"}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.cfg.IsConnected() + if got != tc.want { + t.Errorf("IsConnected()=%v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/configdir/resolve.go b/internal/configdir/resolve.go new file mode 100644 index 0000000..f33a818 --- /dev/null +++ b/internal/configdir/resolve.go @@ -0,0 +1,46 @@ +package configdir + +import ( + "os" + "path/filepath" +) + +const ( + LocalDirName = ".nightagent" // config locale nel progetto + GlobalDirName = ".night-agent" // config globale utente +) + +// Resolve restituisce la config dir da usare. +// Se .nightagent/ esiste nella cwd → usa quella (config locale del progetto). +// Altrimenti → fallback su ~/.night-agent/ (config globale). +func Resolve(cwd string) (string, error) { + local := filepath.Join(cwd, LocalDirName) + if info, err := os.Stat(local); err == nil && info.IsDir() { + return local, nil + } + return Global() +} + +// Global restituisce sempre ~/.night-agent/ indipendentemente dalla cwd. +func Global() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, GlobalDirName), nil +} + +// CreateLocal crea .nightagent/ nella cwd e restituisce il path. +// Idempotente: se già esiste non fa nulla. +func CreateLocal(cwd string) (string, error) { + local := filepath.Join(cwd, LocalDirName) + if err := os.MkdirAll(local, 0700); err != nil { + return "", err + } + return local, nil +} + +// IsLocal controlla se il path è una config dir locale (.nightagent/). +func IsLocal(dir string) bool { + return filepath.Base(dir) == LocalDirName +} diff --git a/internal/configdir/resolve_test.go b/internal/configdir/resolve_test.go new file mode 100644 index 0000000..0bc19af --- /dev/null +++ b/internal/configdir/resolve_test.go @@ -0,0 +1,97 @@ +package configdir_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/night-agent-cli/night-agent/internal/configdir" +) + +func TestResolve_LocalExists_ReturnsLocal(t *testing.T) { + dir := t.TempDir() + local := filepath.Join(dir, configdir.LocalDirName) + if err := os.Mkdir(local, 0700); err != nil { + t.Fatal(err) + } + + got, err := configdir.Resolve(dir) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != local { + t.Errorf("want %q, got %q", local, got) + } +} + +func TestResolve_NoLocal_ReturnsGlobal(t *testing.T) { + dir := t.TempDir() // no .nightagent/ inside + + got, err := configdir.Resolve(dir) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + global, _ := configdir.Global() + if got != global { + t.Errorf("want global %q, got %q", global, got) + } +} + +func TestResolve_LocalFile_NotDir_FallsBackToGlobal(t *testing.T) { + dir := t.TempDir() + // crea un file (non dir) col nome .nightagent + localPath := filepath.Join(dir, configdir.LocalDirName) + if err := os.WriteFile(localPath, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + + got, err := configdir.Resolve(dir) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + global, _ := configdir.Global() + if got != global { + t.Errorf("atteso fallback global %q, got %q", global, got) + } +} + +func TestCreateLocal_CreatesDir(t *testing.T) { + dir := t.TempDir() + got, err := configdir.CreateLocal(dir) + if err != nil { + t.Fatalf("CreateLocal: %v", err) + } + expected := filepath.Join(dir, configdir.LocalDirName) + if got != expected { + t.Errorf("want %q, got %q", expected, got) + } + info, err := os.Stat(got) + if err != nil { + t.Fatalf("dir non creata: %v", err) + } + if !info.IsDir() { + t.Error("atteso directory") + } +} + +func TestCreateLocal_Idempotent(t *testing.T) { + dir := t.TempDir() + if _, err := configdir.CreateLocal(dir); err != nil { + t.Fatal(err) + } + // seconda chiamata non deve dare errore + if _, err := configdir.CreateLocal(dir); err != nil { + t.Errorf("seconda CreateLocal: %v", err) + } +} + +func TestIsLocal(t *testing.T) { + if !configdir.IsLocal("/some/project/.nightagent") { + t.Error("atteso true per .nightagent") + } + if configdir.IsLocal("/home/user/.night-agent") { + t.Error("atteso false per .night-agent") + } +} diff --git a/internal/daemon/server.go b/internal/daemon/server.go new file mode 100644 index 0000000..20a0492 --- /dev/null +++ b/internal/daemon/server.go @@ -0,0 +1,395 @@ +package daemon + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net" + "os" + "strings" + "sync" + + "github.com/google/uuid" + "github.com/night-agent-cli/night-agent/internal/audit" + "github.com/night-agent-cli/night-agent/internal/interception" + "github.com/night-agent-cli/night-agent/internal/policy" + "github.com/night-agent-cli/night-agent/internal/sandbox" + "github.com/night-agent-cli/night-agent/internal/scorer" + "github.com/night-agent-cli/night-agent/internal/suggestions" +) + +// Request è il messaggio inviato dalla shell hook al daemon. +// Type può essere "eval" (default) o "policy_write". +type Request struct { + Type string `json:"type,omitempty"` // "eval" (default) | "policy_write" + Command string `json:"command"` + WorkDir string `json:"work_dir"` + AgentName string `json:"agent_name"` + PolicyYAML string `json:"policy_yaml,omitempty"` // per type="policy_write" +} + +// Response è la risposta del daemon alla shell hook. +type Response struct { + Decision string `json:"decision"` + Reason string `json:"reason"` + RuleID string `json:"rule_id"` + // Campi sandbox: presenti solo quando il daemon ha eseguito il comando in Docker. + ExitCode *int `json:"exit_code,omitempty"` + Output string `json:"output,omitempty"` +} + +// Server è il daemon che ascolta su Unix socket e valuta le richieste. +type Server struct { + socketPath string + mu sync.RWMutex + policy *policy.Policy + policyPath string + logger *audit.Logger + listener net.Listener + quit chan struct{} + scorer *scorer.Scorer + suggestions *suggestions.Engine + logPath string // path del log JSONL per leggere la storia eventi + lastWrittenHash [32]byte // hash SHA256 dell'ultima policy scritta dal daemon + hashMu sync.Mutex +} + +// UpdatePolicy sostituisce la policy attiva in modo thread-safe. +func (s *Server) UpdatePolicy(p *policy.Policy) { + s.mu.Lock() + defer s.mu.Unlock() + s.policy = p +} + +// SetInitialHash imposta l'hash dell'ultima policy scritta (chiamato al boot +// con il contenuto del file già presente su disco). +func (s *Server) SetInitialHash(content []byte) { + h := sha256.Sum256(content) + s.hashMu.Lock() + s.lastWrittenHash = h + s.hashMu.Unlock() +} + +// IsTrustedFileContent verifica che il contenuto del file corrisponda all'ultimo +// hash scritto dal daemon. Usato da Watch() per rifiutare modifiche esterne. +func (s *Server) IsTrustedFileContent(content []byte) bool { + h := sha256.Sum256(content) + s.hashMu.Lock() + defer s.hashMu.Unlock() + return h == s.lastWrittenHash +} + +// WritePolicyFile valida il YAML, aggiorna l'hash trusted, scrive su disco e +// aggiorna la policy in-memory. È l'unico canale autorizzato per modificare +// i file di policy su disco. +func (s *Server) WritePolicyFile(yamlContent []byte) error { + p, err := policy.LoadBytes(yamlContent) + if err != nil { + return fmt.Errorf("policy YAML non valido: %w", err) + } + if s.policyPath == "" { + return fmt.Errorf("policyPath non configurato nel daemon") + } + // aggiorna hash trusted prima di scrivere + h := sha256.Sum256(yamlContent) + s.hashMu.Lock() + s.lastWrittenHash = h + s.hashMu.Unlock() + + // rimuovi lock temporaneamente, scrivi, ri-applica lock + _ = policy.UnlockFile(s.policyPath) + writeErr := os.WriteFile(s.policyPath, yamlContent, 0600) + _ = policy.RelockFile(s.policyPath) + if writeErr != nil { + return fmt.Errorf("errore scrittura policy: %w", writeErr) + } + s.UpdatePolicy(p) + return nil +} + +// NewServer crea il daemon e apre il Unix socket. +func NewServer(socketPath string, p *policy.Policy, logger *audit.Logger) (*Server, error) { + return newServer(socketPath, "", p, logger) +} + +// NewServerWithPolicyPath crea il daemon con il path della policy per allow_always. +func NewServerWithPolicyPath(socketPath, policyPath string, p *policy.Policy, logger *audit.Logger) (*Server, error) { + return newServer(socketPath, policyPath, p, logger) +} + +func newServer(socketPath, policyPath string, p *policy.Policy, logger *audit.Logger) (*Server, error) { + _ = os.Remove(socketPath) + + ln, err := net.Listen("unix", socketPath) + if err != nil { + return nil, fmt.Errorf("impossibile creare il socket: %w", err) + } + + return &Server{ + socketPath: socketPath, + policy: p, + policyPath: policyPath, + logger: logger, + listener: ln, + quit: make(chan struct{}), + scorer: scorer.New(), + suggestions: suggestions.New(), + }, nil +} + +// WithLogPath imposta il path del log JSONL per il context-aware scoring. +func (s *Server) WithLogPath(logPath string) { + s.logPath = logPath +} + +// Serve avvia il loop di accettazione delle connessioni. +func (s *Server) Serve() { + for { + conn, err := s.listener.Accept() + if err != nil { + select { + case <-s.quit: + return + default: + continue + } + } + go s.handle(conn) + } +} + +// Stop ferma il daemon. +func (s *Server) Stop() { + close(s.quit) + s.listener.Close() + os.Remove(s.socketPath) +} + +func (s *Server) handle(conn net.Conn) { + defer conn.Close() + + var req Request + if err := json.NewDecoder(conn).Decode(&req); err != nil { + writeError(conn, "richiesta non valida") + return + } + + // Gestione policy_write: solo dal CLI nightagent policy edit + if req.Type == "policy_write" { + s.handlePolicyWrite(conn, req) + return + } + + action, err := interception.Normalize(req.Command, req.WorkDir, req.AgentName) + if err != nil { + writeError(conn, err.Error()) + return + } + + s.mu.RLock() + result := s.policy.Evaluate(action.ToPolicyAction()) + s.mu.RUnlock() + + // "ask" a runtime si comporta come "block" — la configurazione avviene durante init + decision := result.Decision + if decision == policy.DecisionAsk { + decision = policy.DecisionBlock + } + + // --- Cycle 3: risk scoring contestuale --- + scorerAction := scorer.Action{ + Type: string(action.Type), + Command: req.Command, + Path: action.Path, + WorkDir: req.WorkDir, + } + + // Leggi storia eventi recenti per scoring contestuale (ultimi 50) + recentEvents := s.recentEvents(50) + scoreResult := s.scorer.Score(scorerAction, recentEvents) + hints := s.suggestions.Suggest(scorerAction, scoreResult, recentEvents) + + // Stampa segnali di anomalia se presenti + if scoreResult.AnomalyDetected { + fmt.Printf(" [!] anomalia rilevata: %v\n", scoreResult.Signals) + } + if len(hints) > 0 { + for _, h := range hints { + fmt.Printf(" [→] suggerimento: %s\n", h) + } + } + + event := audit.Event{ + ID: uuid.New().String(), + AgentName: req.AgentName, + WorkDir: req.WorkDir, + Command: req.Command, + ActionType: string(action.Type), + Decision: string(decision), + RuleID: result.RuleID, + Reason: result.Reason, + RiskScore: scoreResult.Score, + RiskLevel: string(scoreResult.Level), + RiskSignals: scoreResult.Signals, + AnomalyDetected: scoreResult.AnomalyDetected, + Suggestions: hints, + } + + resp := Response{ + Decision: string(decision), + Reason: result.Reason, + RuleID: result.RuleID, + } + + // Gestione sandbox: esegui il comando in Docker e restituisci il risultato. + if decision == policy.DecisionSandbox { + mgr := sandbox.New() + if !mgr.IsAvailable() { + // Docker non disponibile: fail safe — blocca e notifica. + event.Decision = string(policy.DecisionBlock) + event.Reason = "Docker non disponibile — sandbox non attivabile" + _ = s.logger.Write(event) + logDecision(policy.DecisionBlock, req.Command, event.Reason) + resp.Decision = string(policy.DecisionBlock) + resp.Reason = event.Reason + _ = json.NewEncoder(conn).Encode(resp) + return + } + + cfg := sandbox.Config{ + Image: sandboxImage(result), + Network: sandboxNetwork(result), + WorkDir: req.WorkDir, + } + + // Carica il profilo sandbox del progetto (.guardian.yaml nella workdir) + // e lo fonde con la config della regola (la regola ha priorità). + if req.WorkDir != "" { + profile, profileErr := sandbox.LoadProfile(req.WorkDir) + if profileErr != nil { + fmt.Printf(" avviso profilo sandbox: %v\n", profileErr) + } else { + cfg = sandbox.MergeConfig(cfg, profile) + } + } + + // Riscrive i path host nel comando con i path container equivalenti. + // Il workspace è montato come /workspace nel container. + command := rewriteHostPaths(req.Command, req.WorkDir) + + sandboxResult, execErr := mgr.Execute(context.Background(), command, cfg) + if execErr != nil { + event.Decision = string(policy.DecisionBlock) + event.Reason = fmt.Sprintf("errore sandbox: %v", execErr) + _ = s.logger.Write(event) + logDecision(policy.DecisionBlock, req.Command, event.Reason) + resp.Decision = string(policy.DecisionBlock) + resp.Reason = event.Reason + _ = json.NewEncoder(conn).Encode(resp) + return + } + + // Aggiorna evento con dettagli sandbox + event.Sandboxed = true + event.SandboxImage = cfg.Image + event.SandboxExitCode = &sandboxResult.ExitCode + _ = s.logger.Write(event) + + logDecision(policy.DecisionSandbox, req.Command, result.Reason) + fmt.Printf(" immagine: %s rete: %s exit: %d\n", cfg.Image, cfg.Network, sandboxResult.ExitCode) + + resp.ExitCode = &sandboxResult.ExitCode + resp.Output = sandboxResult.Stdout + _ = json.NewEncoder(conn).Encode(resp) + return + } + + _ = s.logger.Write(event) + logDecision(decision, req.Command, result.Reason) + _ = json.NewEncoder(conn).Encode(resp) +} + +// handlePolicyWrite gestisce le richieste di aggiornamento policy dal CLI. +func (s *Server) handlePolicyWrite(conn net.Conn, req Request) { + if req.PolicyYAML == "" { + writeError(conn, "policy_yaml vuoto") + return + } + if err := s.WritePolicyFile([]byte(req.PolicyYAML)); err != nil { + writeError(conn, err.Error()) + return + } + fmt.Println("[policy] aggiornata via 'nightagent policy edit'") + resp := Response{Decision: string(policy.DecisionAllow), Reason: "policy aggiornata"} + _ = json.NewEncoder(conn).Encode(resp) +} + +// recentEvents legge gli ultimi n eventi dal log JSONL. +// Se il log non è disponibile restituisce slice vuota (fail-safe). +func (s *Server) recentEvents(n int) []audit.Event { + if s.logPath == "" { + return nil + } + events, err := audit.ReadAll(s.logPath) + if err != nil || len(events) == 0 { + return nil + } + if len(events) <= n { + return events + } + return events[len(events)-n:] +} + +func writeError(conn net.Conn, msg string) { + resp := Response{Decision: string(policy.DecisionBlock), Reason: msg} + _ = json.NewEncoder(conn).Encode(resp) +} + +func logDecision(decision policy.Decision, command, reason string) { + icon := map[policy.Decision]string{ + policy.DecisionAllow: "✓", + policy.DecisionBlock: "✗", + policy.DecisionAsk: "?", + policy.DecisionSandbox: "⬡", + }[decision] + + cmd := command + if len(cmd) > 60 { + cmd = cmd[:57] + "..." + } + + if reason != "" { + fmt.Printf("[%s] %s → %s\n", icon, cmd, reason) + } else { + fmt.Printf("[%s] %s\n", icon, cmd) + } +} + +// sandboxImage restituisce l'immagine Docker dalla SandboxConfig della regola, +// o il default se non specificata. +func sandboxImage(result policy.EvalResult) string { + if result.Sandbox != nil && result.Sandbox.Image != "" { + return result.Sandbox.Image + } + return sandbox.DefaultImage +} + +// sandboxNetwork restituisce la modalità rete dalla SandboxConfig della regola, +// o il default (none) se non specificata. +func sandboxNetwork(result policy.EvalResult) string { + if result.Sandbox != nil && result.Sandbox.Network != "" { + return result.Sandbox.Network + } + return sandbox.DefaultNetwork +} + +// rewriteHostPaths sostituisce i riferimenti al workDir host con /workspace +// nel comando, in modo che i path assoluti funzionino dentro il container. +// Es: "python3 /Users/foo/project/test.py" → "python3 /workspace/test.py" +func rewriteHostPaths(command, workDir string) string { + if workDir == "" { + return command + } + return strings.ReplaceAll(command, workDir, "/workspace") +} diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go new file mode 100644 index 0000000..af98f54 --- /dev/null +++ b/internal/daemon/server_test.go @@ -0,0 +1,192 @@ +package daemon_test + +import ( + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/night-agent-cli/night-agent/internal/audit" + "github.com/night-agent-cli/night-agent/internal/daemon" + "github.com/night-agent-cli/night-agent/internal/policy" +) + +func buildTestPolicy() *policy.Policy { + return &policy.Policy{ + Version: 1, + Rules: []policy.Rule{ + { + ID: "block_sudo", + MatchType: policy.MatchGlob, + When: policy.Condition{ActionType: "shell", CommandMatches: []string{"sudo *"}}, + Decision: policy.DecisionBlock, + Reason: "sudo disabilitato", + }, + { + ID: "ask_push_main", + MatchType: policy.MatchGlob, + When: policy.Condition{ActionType: "git", CommandMatches: []string{"git push * main"}}, + Decision: policy.DecisionAsk, + Reason: "push su main richiede conferma", + }, + }, + } +} + +func startTestServer(t *testing.T) (socketPath string, logPath string) { + t.Helper() + // Su macOS i path Unix socket hanno un limite di 104 caratteri: usiamo /tmp con nome breve. + socketPath = fmt.Sprintf("/tmp/grd-%d.sock", os.Getpid()) + logDir := t.TempDir() + logPath = filepath.Join(logDir, "audit.jsonl") + t.Cleanup(func() { os.Remove(socketPath) }) + + p := buildTestPolicy() + logger, err := audit.NewLogger(logPath) + if err != nil { + t.Fatalf("errore creazione logger: %v", err) + } + + srv, err := daemon.NewServer(socketPath, p, logger) + if err != nil { + t.Fatalf("errore creazione server: %v", err) + } + + go srv.Serve() + t.Cleanup(func() { + srv.Stop() + logger.Close() + }) + + // attendi che il socket sia disponibile + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(socketPath); err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + return socketPath, logPath +} + +func sendRequest(t *testing.T, socketPath string, req daemon.Request) daemon.Response { + t.Helper() + conn, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("errore connessione al socket: %v", err) + } + defer conn.Close() + + if err := json.NewEncoder(conn).Encode(req); err != nil { + t.Fatalf("errore invio richiesta: %v", err) + } + + var resp daemon.Response + if err := json.NewDecoder(conn).Decode(&resp); err != nil { + t.Fatalf("errore lettura risposta: %v", err) + } + return resp +} + +func TestDaemon_BlocksMatchingRule(t *testing.T) { + socketPath, _ := startTestServer(t) + + resp := sendRequest(t, socketPath, daemon.Request{ + Command: "sudo rm -rf /", + WorkDir: "/home/user", + AgentName: "claude-code", + }) + + if resp.Decision != string(policy.DecisionBlock) { + t.Errorf("atteso block, ottenuto %s", resp.Decision) + } + if resp.Reason == "" { + t.Error("atteso reason non vuoto") + } +} + +func TestDaemon_AllowsNonMatchingCommand(t *testing.T) { + socketPath, _ := startTestServer(t) + + resp := sendRequest(t, socketPath, daemon.Request{ + Command: "ls -la", + WorkDir: "/home/user", + AgentName: "claude-code", + }) + + if resp.Decision != string(policy.DecisionAllow) { + t.Errorf("atteso allow, ottenuto %s", resp.Decision) + } +} + +// TestDaemon_AskDecision verifica che "ask" a runtime si comporti come "block". +// La configurazione delle eccezioni avviene durante guardian init, non a runtime. +func TestDaemon_AskDecision(t *testing.T) { + socketPath, _ := startTestServer(t) + + resp := sendRequest(t, socketPath, daemon.Request{ + Command: "git push origin main", + WorkDir: "/home/user/project", + AgentName: "claude-code", + }) + + if resp.Decision != string(policy.DecisionBlock) { + t.Errorf("atteso block per regola ask a runtime, ottenuto %s", resp.Decision) + } + if resp.Reason == "" { + t.Error("atteso reason non vuoto") + } +} + +func TestDaemon_WritesAuditLog(t *testing.T) { + socketPath, logPath := startTestServer(t) + + sendRequest(t, socketPath, daemon.Request{ + Command: "sudo rm -rf /", + WorkDir: "/home/user", + AgentName: "claude-code", + }) + + // piccola attesa per flush asincrono + time.Sleep(20 * time.Millisecond) + + events, err := audit.ReadAll(logPath) + if err != nil { + t.Fatalf("errore lettura log: %v", err) + } + if len(events) == 0 { + t.Fatal("atteso almeno 1 evento nel log") + } + if events[0].Decision != string(policy.DecisionBlock) { + t.Errorf("atteso decision=block nel log, ottenuto %s", events[0].Decision) + } +} + +func TestDaemon_MultipleClients(t *testing.T) { + socketPath, _ := startTestServer(t) + + results := make(chan string, 3) + for range 3 { + go func() { + resp := sendRequest(t, socketPath, daemon.Request{ + Command: "ls -la", + WorkDir: "/home/user", + }) + results <- resp.Decision + }() + } + + for range 3 { + select { + case decision := <-results: + if decision != string(policy.DecisionAllow) { + t.Errorf("atteso allow, ottenuto %s", decision) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout in attesa risposta") + } + } +} diff --git a/internal/intercept/csrc/guardian_intercept.c b/internal/intercept/csrc/guardian_intercept.c new file mode 100644 index 0000000..b48498d --- /dev/null +++ b/internal/intercept/csrc/guardian_intercept.c @@ -0,0 +1,184 @@ +#include +#include +#include +#include +#include + +#ifdef _WIN32 + #include + #include + #include + #include + + // Rimuoviamo la nostra definizione di pid_t perché esiste già in sys/types.h + // Includiamo sys/types.h esplicitamente per sicurezza + #include + + typedef void* posix_spawn_file_actions_t; + typedef void* posix_spawnattr_t; + + #ifndef EPERM + #define EPERM 1 + #endif + #ifndef ENOSYS + #define ENOSYS 40 + #endif + + #define strlcpy(dst, src, sz) snprintf(dst, sz, "%s", src) + #define strlcat(dst, src, sz) strncat(dst, src, sz - strlen(dst) - 1) +#else + #define _GNU_SOURCE + #include + #include + #include + #include + #include + #include +#endif + +/* ---------- debug ---------------------------------------------------- */ + +static int debug_enabled(void) +{ + return getenv("GUARDIAN_DEBUG") != NULL; +} + +#define DBG(fmt, ...) do { \ + if (debug_enabled()) \ + fprintf(stderr, "[guardian] " fmt "\n", ##__VA_ARGS__); \ +} while (0) + +#ifndef _WIN32 +/* ---------- DYLD_INTERPOSE macro (Solo macOS) ------------------------ */ +#define DYLD_INTERPOSE(_replacement, _replacee) \ + static __attribute__((used)) __attribute__((section("__DATA,__interpose"))) \ + struct { const void *replacement; const void *replacee; } \ + _interpose_##_replacee = { \ + (const void *)(unsigned long)&(_replacement), \ + (const void *)(unsigned long)&(_replacee) \ + } +#endif + +#ifdef _WIN32 +__attribute__((constructor)) +#endif +static void guardian_init(void) +{ + DBG("dylib caricata. socket=%s", + getenv("GUARDIAN_SOCKET") ? getenv("GUARDIAN_SOCKET") : "(non impostato)"); +} + +/* ---------- comunicazione con il daemon ------------------------------ */ + +static int guardian_check(const char *path, char *const argv[]) +{ +#ifdef _WIN32 + // Su Windows, per ora, facciamo un bypass totale + return 0; +#else + if (getenv("GUARDIAN_BYPASS") != NULL) return 0; + + const char *sock_path = getenv("GUARDIAN_SOCKET"); + if (sock_path == NULL || sock_path[0] == '\0') return 0; + + DBG("hook: %s", path); + + char command[4096] = {0}; + strlcpy(command, path, sizeof(command)); + if (argv != NULL) { + for (int i = 1; argv[i] != NULL; i++) { + strlcat(command, " ", sizeof(command)); + strlcat(command, argv[i], sizeof(command)); + } + } + + char escaped[8192] = {0}; + int j = 0; + for (int i = 0; command[i] && j < (int)sizeof(escaped) - 2; i++) { + if (command[i] == '"' || command[i] == '\\') + escaped[j++] = '\\'; + escaped[j++] = command[i]; + } + + char payload[8192]; + snprintf(payload, sizeof(payload), + "{\"command\":\"%s\",\"work_dir\":\"\",\"agent_name\":\"\"}\n", + escaped); + + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { DBG("socket() fallita"); return 0; } + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strlcpy(addr.sun_path, sock_path, sizeof(addr.sun_path)); + + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + close(fd); + DBG("connect() fallita (%s)", sock_path); + return 0; + } + + if (write(fd, payload, strlen(payload)) < 0) { + close(fd); + return 0; + } + + char response[2048] = {0}; + ssize_t n = read(fd, response, sizeof(response) - 1); + close(fd); + + if (n <= 0) return 0; + + if (strstr(response, "\"decision\":\"block\"") != NULL) { + fprintf(stderr, "guardian: bloccato dalla policy\n"); + return 1; + } + return 0; +#endif +} + +/* ---------- sostituzioni --------------------------------------------- */ + +#ifndef _WIN32 +static int guardian_execve(const char *path, char *const argv[], char *const envp[]) +{ + if (guardian_check(path, argv)) { + errno = EPERM; + return -1; + } + return execve(path, argv, envp); +} + +static int guardian_posix_spawn( + pid_t *pid, const char *path, + const posix_spawn_file_actions_t *fa, + const posix_spawnattr_t *attr, + char *const argv[], char *const envp[]) +{ + if (guardian_check(path, argv)) { + errno = EPERM; + return EPERM; + } + return posix_spawn(pid, path, fa, attr, argv, envp); +} + +static int guardian_posix_spawnp( + pid_t *pid, const char *file, + const posix_spawn_file_actions_t *fa, + const posix_spawnattr_t *attr, + char *const argv[], char *const envp[]) +{ + if (guardian_check(file, argv)) { + errno = EPERM; + return EPERM; + } + return posix_spawnp(pid, file, fa, attr, argv, envp); +} + +/* ---------- dichiarazioni DYLD_INTERPOSE ----------------------------- */ + +DYLD_INTERPOSE(guardian_execve, execve); +DYLD_INTERPOSE(guardian_posix_spawn, posix_spawn); +DYLD_INTERPOSE(guardian_posix_spawnp, posix_spawnp); +#endif \ No newline at end of file diff --git a/internal/intercept/integration_test.go b/internal/intercept/integration_test.go new file mode 100644 index 0000000..04b7ad6 --- /dev/null +++ b/internal/intercept/integration_test.go @@ -0,0 +1,212 @@ +//go:build integration + +package intercept_test + +import ( + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/night-agent-cli/night-agent/internal/audit" + "github.com/night-agent-cli/night-agent/internal/daemon" + "github.com/night-agent-cli/night-agent/internal/intercept" + "github.com/night-agent-cli/night-agent/internal/policy" +) + +// TestDYLD_BlocksCommandViaLibrary è un integration test end-to-end. +// +// Usa exec-helper — un binario non-SIP che chiama exec.Command internamente, +// simulando esattamente come un agente AI (claude-code, node, python) esegue comandi. +// DYLD_INSERT_LIBRARIES non funziona su /bin/sh e /bin/bash (SIP-protetti), +// ma funziona su binari installati dall'utente come claude-code o questo helper. +func TestDYLD_BlocksCommandViaLibrary(t *testing.T) { + dylibPath := findTestDylib(t) + helperPath := buildExecHelper(t) + socketPath, logPath := startIntegrationDaemon(t) + + // exec-helper eseguirà "sudo echo SHOULD_NOT_PRINT" via exec.Command. + // La dylib intercetta execve() di sudo prima che venga creato il processo. + cmd := exec.Command(helperPath, "sudo", "echo", "SHOULD_NOT_PRINT") + cmd.Env = intercept.BuildEnv(os.Environ(), dylibPath, socketPath) + + output, _ := cmd.CombinedOutput() + out := string(output) + + if containsString(out, "SHOULD_NOT_PRINT") { + t.Errorf("comando bloccato è stato eseguito lo stesso: %s", out) + } + if !containsString(out, "guardian") { + t.Errorf("atteso messaggio guardian nell'output, ottenuto: %s", out) + } + + time.Sleep(30 * time.Millisecond) + events, err := audit.ReadAll(logPath) + if err != nil { + t.Fatalf("errore lettura log: %v", err) + } + if len(events) == 0 { + t.Error("atteso almeno 1 evento nel log dopo il blocco") + } +} + +func TestDYLD_AllowsNonBlockedCommand(t *testing.T) { + dylibPath := findTestDylib(t) + helperPath := buildExecHelper(t) + socketPath, _ := startIntegrationDaemon(t) + + cmd := exec.Command(helperPath, "echo", "ALLOWED_OUTPUT") + cmd.Env = intercept.BuildEnv(os.Environ(), dylibPath, socketPath) + + output, err := cmd.Output() + if err != nil { + t.Fatalf("comando permesso ha fallito: %v\noutput: %s", err, output) + } + if !containsString(string(output), "ALLOWED_OUTPUT") { + t.Errorf("atteso ALLOWED_OUTPUT, ottenuto: %s", output) + } +} + +func TestDYLD_SafeFailure_DaemonDown(t *testing.T) { + dylibPath := findTestDylib(t) + helperPath := buildExecHelper(t) + + // socket inesistente — daemon non in ascolto: safe failure = blocca + cmd := exec.Command(helperPath, "echo", "SHOULD_BE_BLOCKED") + cmd.Env = intercept.BuildEnv(os.Environ(), dylibPath, "/tmp/nonexistent-night-agent.sock") + + output, _ := cmd.CombinedOutput() + if containsString(string(output), "SHOULD_BE_BLOCKED") { + t.Error("con daemon down, il comando non dovrebbe essere eseguito (safe failure)") + } +} + +/* ---------- helpers -------------------------------------------------- */ + +// buildExecHelper compila il C helper e ne restituisce il path. +// Usa C (non Go) perché Go bypassa libc con syscall raw, +// mentre C usa execvp() via libc — esattamente come Node.js/Python/Ruby. +func buildExecHelper(t *testing.T) string { + t.Helper() + src := "testdata/exec-helper/main.c" + out := filepath.Join(t.TempDir(), "exec-helper") + cmd := exec.Command("clang", "-o", out, src, "-Wall") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("errore compilazione exec-helper C: %v\n%s", err, output) + } + return out +} + +func findTestDylib(t *testing.T) string { + t.Helper() + // cerca nella root del progetto (dove make dylib lo produce) + candidates := []string{ + "../../guardian-intercept.dylib", + "guardian-intercept.dylib", + } + for _, p := range candidates { + abs, err := filepath.Abs(p) + if err != nil { + continue + } + if _, err := os.Stat(abs); err == nil { + return abs + } + } + t.Skip("guardian-intercept.dylib non trovata — esegui 'make dylib' prima degli integration test") + return "" +} + +func startIntegrationDaemon(t *testing.T) (socketPath, logPath string) { + t.Helper() + + socketPath = fmt.Sprintf("/tmp/grd-integ-%d.sock", os.Getpid()) + logDir := t.TempDir() + logPath = filepath.Join(logDir, "audit.jsonl") + + p := &policy.Policy{ + Version: 1, + Rules: []policy.Rule{ + { + ID: "block_sudo", + MatchType: policy.MatchGlob, + When: policy.Condition{ActionType: "shell", CommandMatches: []string{"sudo *", "*/sudo *", "*sudo *"}}, + Decision: policy.DecisionBlock, + Reason: "sudo disabilitato", + }, + }, + } + + logger, err := audit.NewLogger(logPath) + if err != nil { + t.Fatalf("errore logger: %v", err) + } + + srv, err := daemon.NewServer(socketPath, p, logger) + if err != nil { + t.Fatalf("errore daemon: %v", err) + } + + go srv.Serve() + t.Cleanup(func() { + srv.Stop() + logger.Close() + os.Remove(socketPath) + }) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if conn, err := net.Dial("unix", socketPath); err == nil { + conn.Close() + break + } + time.Sleep(10 * time.Millisecond) + } + return socketPath, logPath +} + +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || + len(substr) == 0 || + findSubstring(s, substr)) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// verifica che il JSON del daemon sia valido (smoke test) +func TestDaemonResponseIsValidJSON(t *testing.T) { + socketPath, _ := startIntegrationDaemon(t) + + conn, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("connessione fallita: %v", err) + } + defer conn.Close() + + payload := `{"command":"ls -la","work_dir":"/tmp","agent_name":"test"}` + "\n" + if _, err := conn.Write([]byte(payload)); err != nil { + t.Fatalf("errore invio: %v", err) + } + + buf := make([]byte, 1024) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("errore lettura: %v", err) + } + + var resp map[string]any + if err := json.Unmarshal(buf[:n], &resp); err != nil { + t.Errorf("risposta daemon non è JSON valido: %v\nRisposta: %s", err, buf[:n]) + } +} diff --git a/internal/intercept/runner.go b/internal/intercept/runner.go new file mode 100644 index 0000000..4587b86 --- /dev/null +++ b/internal/intercept/runner.go @@ -0,0 +1,56 @@ +package intercept + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + dylibName = "guardian-intercept.dylib" + envDYLD = "DYLD_INSERT_LIBRARIES" + envSocket = "GUARDIAN_SOCKET" + envBypass = "GUARDIAN_BYPASS" +) + +// BuildEnv costruisce le variabili d'ambiente per il processo agente, +// aggiungendo DYLD_INSERT_LIBRARIES e GUARDIAN_SOCKET. +// Le variabili già presenti in base vengono preservate, +// eccetto quelle che sovrascriviamo esplicitamente. +func BuildEnv(base []string, dylibPath, socketPath string) []string { + overrides := map[string]string{ + envDYLD: dylibPath, + envSocket: socketPath, + } + + result := make([]string, 0, len(base)+2) + for _, e := range base { + key := envKey(e) + if _, skip := overrides[key]; skip { + continue + } + result = append(result, e) + } + + for k, v := range overrides { + result = append(result, k+"="+v) + } + return result +} + +// FindDylib cerca il file guardian-intercept.dylib in una directory. +func FindDylib(searchDir string) (string, error) { + candidate := filepath.Join(searchDir, dylibName) + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + return "", fmt.Errorf("%s non trovata in %s — esegui 'make' nella root del progetto", dylibName, searchDir) +} + +func envKey(entry string) string { + if idx := strings.IndexByte(entry, '='); idx >= 0 { + return entry[:idx] + } + return entry +} diff --git a/internal/intercept/runner_test.go b/internal/intercept/runner_test.go new file mode 100644 index 0000000..e1ccc3c --- /dev/null +++ b/internal/intercept/runner_test.go @@ -0,0 +1,96 @@ +package intercept_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/night-agent-cli/night-agent/internal/intercept" +) + +func TestBuildEnv_SetsDYLD(t *testing.T) { + dylibPath := "/tmp/guardian-intercept.dylib" + socketPath := "/tmp/night-agent.sock" + + env := intercept.BuildEnv(os.Environ(), dylibPath, socketPath) + + found := false + for _, e := range env { + if e == "DYLD_INSERT_LIBRARIES="+dylibPath { + found = true + } + } + if !found { + t.Errorf("DYLD_INSERT_LIBRARIES non trovato nell'env") + } +} + +func TestBuildEnv_SetsSocketPath(t *testing.T) { + socketPath := "/tmp/night-agent.sock" + env := intercept.BuildEnv(os.Environ(), "/tmp/test.dylib", socketPath) + + found := false + for _, e := range env { + if e == "GUARDIAN_SOCKET="+socketPath { + found = true + } + } + if !found { + t.Errorf("GUARDIAN_SOCKET non trovato nell'env") + } +} + +func TestBuildEnv_PreservesExistingEnv(t *testing.T) { + base := []string{"HOME=/home/user", "PATH=/usr/bin:/bin"} + env := intercept.BuildEnv(base, "/tmp/test.dylib", "/tmp/night-agent.sock") + + foundHome := false + for _, e := range env { + if e == "HOME=/home/user" { + foundHome = true + } + } + if !foundHome { + t.Error("variabili d'ambiente originali non preservate") + } +} + +func TestBuildEnv_OverridesDYLDIfAlreadySet(t *testing.T) { + base := []string{"DYLD_INSERT_LIBRARIES=/old/lib.dylib"} + newDylib := "/new/guardian-intercept.dylib" + env := intercept.BuildEnv(base, newDylib, "/tmp/night-agent.sock") + + count := 0 + for _, e := range env { + if len(e) > 22 && e[:22] == "DYLD_INSERT_LIBRARIES=" { + count++ + if e != "DYLD_INSERT_LIBRARIES="+newDylib { + t.Errorf("atteso nuovo valore DYLD_INSERT_LIBRARIES, ottenuto %s", e) + } + } + } + if count != 1 { + t.Errorf("attesa esattamente 1 occorrenza di DYLD_INSERT_LIBRARIES, trovate %d", count) + } +} + +func TestFindDylib_FindsInSameDir(t *testing.T) { + dir := t.TempDir() + dylibPath := filepath.Join(dir, "guardian-intercept.dylib") + _ = os.WriteFile(dylibPath, []byte("fake dylib"), 0755) + + found, err := intercept.FindDylib(dir) + if err != nil { + t.Fatalf("atteso nessun errore, ottenuto: %v", err) + } + if found != dylibPath { + t.Errorf("atteso %s, ottenuto %s", dylibPath, found) + } +} + +func TestFindDylib_ErrorIfNotFound(t *testing.T) { + _, err := intercept.FindDylib("/nonexistent/dir") + if err == nil { + t.Fatal("atteso errore se dylib non trovata, ottenuto nil") + } +} diff --git a/internal/intercept/testdata/exec-helper/exec-helper b/internal/intercept/testdata/exec-helper/exec-helper new file mode 100755 index 0000000..eb1f6ec Binary files /dev/null and b/internal/intercept/testdata/exec-helper/exec-helper differ diff --git a/internal/intercept/testdata/exec-helper/main.c b/internal/intercept/testdata/exec-helper/main.c new file mode 100644 index 0000000..b807c3e --- /dev/null +++ b/internal/intercept/testdata/exec-helper/main.c @@ -0,0 +1,58 @@ +#include +#include +#include + +#ifdef _WIN32 + #include + #include + /* Su Windows definiamo le macro per simulare il comportamento di waitpid */ + #define WIFEXITED(status) (1) + #define WEXITSTATUS(status) (status) +#else + #include + #include + #include +#endif + +int main(int argc, char *argv[]) +{ + if (argc < 2) { + fprintf(stderr, "usage: exec-helper [args...]\n"); + return 1; + } + +#ifdef _WIN32 + /* + * Windows non ha fork(). + * Usiamo _spawnvp che combina la creazione del processo e l'esecuzione. + * _P_WAIT blocca il padre finché il figlio non termina, simulando waitpid. + */ + intptr_t status = _spawnvp(_P_WAIT, argv[1], (const char* const*)&argv[1]); + + if (status == -1) { + perror("_spawnvp"); + return 1; + } + return (int)status; + +#else + /* Codice originale per Linux/macOS */ + pid_t pid = fork(); + if (pid < 0) { + perror("fork"); + return 1; + } + + if (pid == 0) { + /* processo figlio: esegue il comando via execvp */ + execvp(argv[1], &argv[1]); + perror("execvp"); + _exit(1); + } + + /* processo padre: attende il figlio */ + int status; + waitpid(pid, &status, 0); + return WIFEXITED(status) ? WEXITSTATUS(status) : 1; +#endif +} \ No newline at end of file diff --git a/internal/intercept/testdata/exec-helper/main.go b/internal/intercept/testdata/exec-helper/main.go new file mode 100644 index 0000000..3d81c23 --- /dev/null +++ b/internal/intercept/testdata/exec-helper/main.go @@ -0,0 +1,23 @@ +// exec-helper è un binario non-SIP usato negli integration test di guardian. +// Riceve un comando come argomento e lo esegue via exec.Command, +// simulando il comportamento di un agente AI (es. claude-code). +package main + +import ( + "fmt" + "os" + "os/exec" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: exec-helper [args...]") + os.Exit(1) + } + cmd := exec.Command(os.Args[1], os.Args[2:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + os.Exit(1) + } +} diff --git a/internal/interception/normalizer.go b/internal/interception/normalizer.go new file mode 100644 index 0000000..939d95c --- /dev/null +++ b/internal/interception/normalizer.go @@ -0,0 +1,95 @@ +package interception + +import ( + "fmt" + "strings" + + "github.com/night-agent-cli/night-agent/internal/policy" +) + +// NormalizedAction è il risultato della normalizzazione di un comando raw. +type NormalizedAction struct { + Type policy.ActionType + Command string + Path string + WorkDir string + AgentName string + IsForce bool +} + +// ToPolicyAction converte NormalizedAction in policy.Action per la valutazione. +func (a NormalizedAction) ToPolicyAction() policy.Action { + return policy.Action{ + Type: string(a.Type), + Command: a.Command, + Path: a.Path, + } +} + +// Normalize analizza un comando raw e produce un NormalizedAction classificato. +func Normalize(raw, workDir, agentName string) (NormalizedAction, error) { + cmd := strings.TrimSpace(raw) + if cmd == "" { + return NormalizedAction{}, fmt.Errorf("comando vuoto") + } + + action := NormalizedAction{ + Command: cmd, + WorkDir: workDir, + AgentName: agentName, + } + + action.Type = classifyCommand(cmd) + + if action.Type == policy.ActionTypeGit { + action.IsForce = isForceGitPush(cmd) + } + + if action.Type == policy.ActionTypeFile { + action.Path = extractFilePath(cmd) + } + + return action, nil +} + +// classifyCommand determina il tipo di azione in base al prefisso del comando. +func classifyCommand(cmd string) policy.ActionType { + if strings.HasPrefix(cmd, "git ") { + return policy.ActionTypeGit + } + if isFileOperation(cmd) { + return policy.ActionTypeFile + } + return policy.ActionTypeShell +} + +// isFileOperation rileva redirect di scrittura verso file sensibili o operazioni file esplicite. +func isFileOperation(cmd string) bool { + fileOps := []string{"cp ", "mv ", "touch ", "chmod ", "chown "} + for _, op := range fileOps { + if strings.HasPrefix(cmd, op) { + return true + } + } + // redirect di scrittura: cmd > file o cmd >> file + return strings.Contains(cmd, "> ~/") || strings.Contains(cmd, "> /") +} + +// isForceGitPush rileva flag di force push. +func isForceGitPush(cmd string) bool { + return strings.Contains(cmd, "--force") || strings.Contains(cmd, " -f ") +} + +// extractFilePath tenta di estrarre il percorso target da un comando file. +func extractFilePath(cmd string) string { + // per redirect: "cat > /path" → "/path" + if idx := strings.Index(cmd, "> "); idx != -1 { + return strings.TrimSpace(cmd[idx+2:]) + } + // per comandi file: prendi l'ultimo token + parts := strings.Fields(cmd) + if len(parts) > 1 { + return parts[len(parts)-1] + } + return "" +} diff --git a/internal/interception/normalizer_test.go b/internal/interception/normalizer_test.go new file mode 100644 index 0000000..6587dd6 --- /dev/null +++ b/internal/interception/normalizer_test.go @@ -0,0 +1,106 @@ +package interception_test + +import ( + "testing" + + "github.com/night-agent-cli/night-agent/internal/interception" + "github.com/night-agent-cli/night-agent/internal/policy" +) + +func TestNormalize_ShellCommand(t *testing.T) { + action, err := interception.Normalize("sudo rm -rf /tmp", "/home/user/project", "") + if err != nil { + t.Fatalf("atteso nessun errore, ottenuto: %v", err) + } + if action.Type != policy.ActionTypeShell { + t.Errorf("atteso type=shell, ottenuto %s", action.Type) + } + if action.Command != "sudo rm -rf /tmp" { + t.Errorf("atteso command invariato, ottenuto %s", action.Command) + } + if action.WorkDir != "/home/user/project" { + t.Errorf("atteso workdir=/home/user/project, ottenuto %s", action.WorkDir) + } +} + +func TestNormalize_GitCommand(t *testing.T) { + action, err := interception.Normalize("git push origin main", "/home/user/project", "") + if err != nil { + t.Fatalf("atteso nessun errore, ottenuto: %v", err) + } + if action.Type != policy.ActionTypeGit { + t.Errorf("atteso type=git, ottenuto %s", action.Type) + } +} + +func TestNormalize_GitForcePush(t *testing.T) { + action, err := interception.Normalize("git push --force origin main", "/home/user/project", "") + if err != nil { + t.Fatalf("atteso nessun errore, ottenuto: %v", err) + } + if action.Type != policy.ActionTypeGit { + t.Errorf("atteso type=git, ottenuto %s", action.Type) + } + if !action.IsForce { + t.Error("atteso IsForce=true per git push --force") + } +} + +func TestNormalize_FileOperation_Write(t *testing.T) { + action, err := interception.Normalize("cat > ~/.ssh/id_rsa", "/home/user", "") + if err != nil { + t.Fatalf("atteso nessun errore, ottenuto: %v", err) + } + if action.Type != policy.ActionTypeFile { + t.Errorf("atteso type=file, ottenuto %s", action.Type) + } +} + +func TestNormalize_AgentName(t *testing.T) { + action, err := interception.Normalize("ls -la", "/home/user", "claude-code") + if err != nil { + t.Fatalf("atteso nessun errore, ottenuto: %v", err) + } + if action.AgentName != "claude-code" { + t.Errorf("atteso agent_name=claude-code, ottenuto %s", action.AgentName) + } +} + +func TestNormalize_EmptyCommand(t *testing.T) { + _, err := interception.Normalize("", "/home/user", "") + if err == nil { + t.Fatal("atteso errore per comando vuoto, ottenuto nil") + } +} + +func TestNormalize_CommandTrimmed(t *testing.T) { + action, err := interception.Normalize(" ls -la ", "/home/user", "") + if err != nil { + t.Fatalf("atteso nessun errore, ottenuto: %v", err) + } + if action.Command != "ls -la" { + t.Errorf("atteso comando trimmed, ottenuto '%s'", action.Command) + } +} + +func TestNormalize_GitPushFShortFlag(t *testing.T) { + action, err := interception.Normalize("git push -f origin main", "/home/user/project", "") + if err != nil { + t.Fatalf("atteso nessun errore, ottenuto: %v", err) + } + if !action.IsForce { + t.Error("atteso IsForce=true per git push -f") + } +} + +func TestActionToPolicy_Conversion(t *testing.T) { + action, _ := interception.Normalize("sudo apt-get install curl", "/home/user", "claude-code") + policyAction := action.ToPolicyAction() + + if policyAction.Type != "shell" { + t.Errorf("atteso type=shell, ottenuto %s", policyAction.Type) + } + if policyAction.Command != "sudo apt-get install curl" { + t.Errorf("atteso command invariato, ottenuto %s", policyAction.Command) + } +} diff --git a/internal/launchagent/launchagent.go b/internal/launchagent/launchagent.go new file mode 100644 index 0000000..9f02255 --- /dev/null +++ b/internal/launchagent/launchagent.go @@ -0,0 +1,97 @@ +package launchagent + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +const label = "com.night-agent.daemon" + +// PlistPath restituisce il path del file plist dato la home directory. +func PlistPath(homeDir string) string { + return filepath.Join(homeDir, "Library", "LaunchAgents", label+".plist") +} + +// IsInstalled verifica se il LaunchAgent è già installato. +func IsInstalled(homeDir string) bool { + _, err := os.Stat(PlistPath(homeDir)) + return err == nil +} + +// GeneratePlist genera il contenuto XML del plist per il LaunchAgent. +// binaryPath è il path assoluto del binario guardian. +// guardianDir è ~/.night-agent (usato per stdout/stderr log). +func GeneratePlist(binaryPath, guardianDir string) string { + stdoutLog := filepath.Join(guardianDir, "daemon.log") + stderrLog := filepath.Join(guardianDir, "daemon-error.log") + + return fmt.Sprintf(` + + + + Label + %s + + ProgramArguments + + %s + start + + + RunAtLoad + + + KeepAlive + + + LimitLoadToSessionType + Aqua + + StandardOutPath + %s + + StandardErrorPath + %s + + +`, label, binaryPath, stdoutLog, stderrLog) +} + +// Install scrive il plist e carica il LaunchAgent con launchctl. +// Se già installato, aggiorna il file e ricarica. +func Install(homeDir, binaryPath, guardianDir string) error { + plistPath := PlistPath(homeDir) + launchAgentsDir := filepath.Dir(plistPath) + + if err := os.MkdirAll(launchAgentsDir, 0755); err != nil { + return fmt.Errorf("impossibile creare LaunchAgents dir: %w", err) + } + + plist := GeneratePlist(binaryPath, guardianDir) + if err := os.WriteFile(plistPath, []byte(plist), 0644); err != nil { + return fmt.Errorf("impossibile scrivere plist: %w", err) + } + + // se già caricato, fai unload prima + _ = exec.Command("launchctl", "unload", plistPath).Run() + + if err := exec.Command("launchctl", "load", plistPath).Run(); err != nil { + return fmt.Errorf("launchctl load fallito: %w", err) + } + return nil +} + +// Uninstall ferma il LaunchAgent e rimuove il plist. +func Uninstall(homeDir string) error { + plistPath := PlistPath(homeDir) + + _ = exec.Command("launchctl", "unload", plistPath).Run() + + if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("impossibile rimuovere plist: %w", err) + } + return nil +} diff --git a/internal/launchagent/launchagent_test.go b/internal/launchagent/launchagent_test.go new file mode 100644 index 0000000..77adf02 --- /dev/null +++ b/internal/launchagent/launchagent_test.go @@ -0,0 +1,102 @@ +package launchagent_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/night-agent-cli/night-agent/internal/launchagent" +) + +func TestPlistPath(t *testing.T) { + path := launchagent.PlistPath("/home/user") + expected := "/home/user/Library/LaunchAgents/com.night-agent.daemon.plist" + if path != expected { + t.Errorf("atteso %s, ottenuto %s", expected, path) + } +} + +func TestGeneratePlist_ContainsLabel(t *testing.T) { + plist := launchagent.GeneratePlist("/usr/local/bin/guardian", "/home/user/.guardian") + if !strings.Contains(plist, "com.night-agent.daemon") { + t.Error("plist non contiene il label com.night-agent.daemon") + } +} + +func TestGeneratePlist_ContainsBinaryPath(t *testing.T) { + binaryPath := "/usr/local/bin/guardian" + plist := launchagent.GeneratePlist(binaryPath, "/home/user/.guardian") + if !strings.Contains(plist, binaryPath) { + t.Errorf("plist non contiene il path del binario %s", binaryPath) + } +} + +func TestGeneratePlist_ContainsStartCommand(t *testing.T) { + plist := launchagent.GeneratePlist("/usr/local/bin/guardian", "/home/user/.guardian") + if !strings.Contains(plist, "start") { + t.Error("plist non contiene il sottocomando 'start'") + } +} + +func TestGeneratePlist_ContainsLogPaths(t *testing.T) { + guardianDir := "/home/user/.guardian" + plist := launchagent.GeneratePlist("/usr/local/bin/guardian", guardianDir) + if !strings.Contains(plist, guardianDir) { + t.Errorf("plist non contiene la guardian dir %s", guardianDir) + } +} + +func TestGeneratePlist_RunAtLoad(t *testing.T) { + plist := launchagent.GeneratePlist("/usr/local/bin/guardian", "/home/user/.guardian") + if !strings.Contains(plist, "RunAtLoad") { + t.Error("plist non contiene RunAtLoad") + } + if !strings.Contains(plist, "") { + t.Error("plist non ha RunAtLoad impostato a true") + } +} + +func TestInstall_WritesFile(t *testing.T) { + dir := t.TempDir() + launchAgentsDir := filepath.Join(dir, "Library", "LaunchAgents") + if err := os.MkdirAll(launchAgentsDir, 0755); err != nil { + t.Fatal(err) + } + + plistPath := filepath.Join(launchAgentsDir, "com.night-agent.daemon.plist") + plist := launchagent.GeneratePlist("/usr/local/bin/guardian", dir+"/.guardian") + if err := os.WriteFile(plistPath, []byte(plist), 0644); err != nil { + t.Fatalf("errore scrittura plist: %v", err) + } + + data, err := os.ReadFile(plistPath) + if err != nil { + t.Fatalf("errore lettura plist: %v", err) + } + if !strings.Contains(string(data), "com.night-agent.daemon") { + t.Error("file plist scritto non contiene il label atteso") + } +} + +func TestIsInstalled_True(t *testing.T) { + dir := t.TempDir() + plistPath := launchagent.PlistPath(dir) + launchAgentsDir := filepath.Dir(plistPath) + if err := os.MkdirAll(launchAgentsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(plistPath, []byte("fake"), 0644); err != nil { + t.Fatal(err) + } + if !launchagent.IsInstalled(dir) { + t.Error("atteso IsInstalled=true, ottenuto false") + } +} + +func TestIsInstalled_False(t *testing.T) { + dir := t.TempDir() + if launchagent.IsInstalled(dir) { + t.Error("atteso IsInstalled=false, ottenuto true") + } +} diff --git a/internal/mcphook/mcphook.go b/internal/mcphook/mcphook.go new file mode 100644 index 0000000..223c967 --- /dev/null +++ b/internal/mcphook/mcphook.go @@ -0,0 +1,195 @@ +// Package mcphook implementa il bridge tra i Claude Code hooks (PreToolUse) +// e il daemon di Night Agent. Quando Claude Code invoca nightagent mcp-hook, +// questo package normalizza la tool call MCP in una richiesta daemon standard +// e restituisce l'exit code che Claude Code interpreta come allow/block. +// +// Claude Code invia il contesto del hook via stdin come JSON: +// +// {"tool_name": "Bash", "tool_input": {"command": "sudo ls", "workdir": "/tmp"}} +// +// Integrazione Claude Code (~/.claude/settings.json): +// +// { +// "hooks": { +// "PreToolUse": [{"matcher": "*", "hooks": [{"type": "command", "command": "/path/to/nightagent mcp-hook"}]}] +// } +// } +// +// Exit codes: 0 = allow, 2 = block (Claude Code interrompe l'esecuzione). +package mcphook + +import ( + "encoding/json" + "fmt" + "io" + "net" + "strings" + "time" +) + +// HookInput è il JSON inviato da Claude Code su stdin al PreToolUse hook. +type HookInput struct { + ToolName string `json:"tool_name"` + ToolInput map[string]interface{} `json:"tool_input"` +} + +// ParseStdin legge il JSON inviato da Claude Code su stdin e restituisce +// una ParsedCall normalizzata pronta per essere inviata al daemon. +func ParseStdin(r io.Reader) (ParsedCall, error) { + var input HookInput + if err := json.NewDecoder(r).Decode(&input); err != nil { + return ParsedCall{}, fmt.Errorf("parsing stdin: %w", err) + } + + raw, err := json.Marshal(input.ToolInput) + if err != nil { + raw = []byte("{}") + } + + return ParseInput(input.ToolName, string(raw)) +} + +// ParsedCall è la rappresentazione normalizzata di una MCP tool call. +type ParsedCall struct { + ToolName string + Command string // comando shell (per Bash) o descrizione (per altri tool) + Path string // file path (per Edit, Write, Read, Glob) + WorkDir string + AgentName string + RawInput string +} + +// DaemonRequest è la struttura inviata al daemon via Unix socket. +// Rispecchia daemon.Request per evitare dipendenza circolare. +type DaemonRequest struct { + Command string `json:"command"` + WorkDir string `json:"work_dir"` + AgentName string `json:"agent_name"` +} + +// ParseInput analizza il JSON di input di una tool call MCP e restituisce +// una ParsedCall normalizzata. Non fallisce su tool sconosciuti — li passa +// attraverso con il nome del tool come comando (fail-open per tool non rischiosi). +func ParseInput(toolName, inputJSON string) (ParsedCall, error) { + parsed := ParsedCall{ + ToolName: toolName, + RawInput: inputJSON, + AgentName: "claude-code", + } + + var raw map[string]interface{} + if err := json.Unmarshal([]byte(inputJSON), &raw); err != nil { + // input non JSON — tratta il tool name come comando generico + parsed.Command = toolName + return parsed, nil + } + + switch toolName { + case "Bash": + parsed.Command = stringField(raw, "command") + parsed.WorkDir = stringField(raw, "workdir") + + case "Edit": + path := stringField(raw, "file_path") + parsed.Path = path + parsed.Command = fmt.Sprintf("edit %s", path) + + case "Write": + path := stringField(raw, "file_path") + parsed.Path = path + parsed.Command = fmt.Sprintf("write %s", path) + + case "Read": + path := stringField(raw, "file_path") + parsed.Path = path + parsed.Command = fmt.Sprintf("read %s", path) + + case "Glob": + pattern := stringField(raw, "pattern") + parsed.Command = fmt.Sprintf("glob %s", pattern) + + case "Grep": + pattern := stringField(raw, "pattern") + path := stringField(raw, "path") + parsed.Command = fmt.Sprintf("grep %s %s", pattern, path) + parsed.Path = path + + case "WebFetch", "WebSearch": + url := stringField(raw, "url") + if url == "" { + url = stringField(raw, "query") + } + parsed.Command = fmt.Sprintf("%s %s", strings.ToLower(toolName), url) + + default: + // tool non mappato — costruisce un comando descrittivo + parsed.Command = fmt.Sprintf("mcp:%s", toolName) + } + + return parsed, nil +} + +// BuildDaemonRequest costruisce la richiesta da inviare al daemon. +func BuildDaemonRequest(p ParsedCall) DaemonRequest { + cmd := p.Command + if cmd == "" { + cmd = fmt.Sprintf("mcp:%s", p.ToolName) + } + return DaemonRequest{ + Command: cmd, + WorkDir: p.WorkDir, + AgentName: p.AgentName, + } +} + +// ExitCode converte la decisione del daemon nell'exit code da restituire +// a Claude Code. Claude Code interrompe l'esecuzione se exit code != 0. +// +// allow → 0 (procedi) +// sandbox → 0 (eseguito in isolamento, Claude Code non deve intervenire) +// ask → 2 (block a runtime — l'utente non è disponibile a rispondere) +// block → 2 (blocca) +func ExitCode(decision string) int { + switch decision { + case "allow", "sandbox": + return 0 + default: + return 2 + } +} + +// QueryDaemon invia la richiesta al daemon via Unix socket e restituisce la +// decisione e il motivo. Se il daemon non è raggiungibile, blocca (fail-closed): +// restituisce "block" con messaggio esplicito invece di permettere l'esecuzione. +func QueryDaemon(socketPath string, req DaemonRequest) (decision, reason string) { + conn, err := net.DialTimeout("unix", socketPath, 2*time.Second) + if err != nil { + return "block", "daemon non in ascolto — avvia nightagent start" + } + defer conn.Close() + + conn.SetDeadline(time.Now().Add(3 * time.Second)) + + if err := json.NewEncoder(conn).Encode(req); err != nil { + return "block", "daemon non in ascolto — errore invio richiesta" + } + + var resp struct { + Decision string `json:"decision"` + Reason string `json:"reason"` + } + if err := json.NewDecoder(conn).Decode(&resp); err != nil { + return "block", "daemon non in ascolto — errore lettura risposta" + } + + return resp.Decision, resp.Reason +} + +func stringField(m map[string]interface{}, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} diff --git a/internal/mcphook/mcphook_test.go b/internal/mcphook/mcphook_test.go new file mode 100644 index 0000000..daa0460 --- /dev/null +++ b/internal/mcphook/mcphook_test.go @@ -0,0 +1,147 @@ +package mcphook_test + +import ( + "strings" + "testing" + + "github.com/night-agent-cli/night-agent/internal/mcphook" +) + +func TestParseInput_BashCommand(t *testing.T) { + input := `{"command":"sudo rm -rf /tmp","workdir":"/home/user"}` + parsed, err := mcphook.ParseInput("Bash", input) + if err != nil { + t.Fatalf("ParseInput: %v", err) + } + if parsed.Command != "sudo rm -rf /tmp" { + t.Errorf("command: got %q", parsed.Command) + } + if parsed.WorkDir != "/home/user" { + t.Errorf("workdir: got %q", parsed.WorkDir) + } +} + +func TestParseInput_EditFile(t *testing.T) { + input := `{"file_path":"/etc/passwd","old_string":"foo","new_string":"bar"}` + parsed, err := mcphook.ParseInput("Edit", input) + if err != nil { + t.Fatalf("ParseInput: %v", err) + } + if parsed.Path != "/etc/passwd" { + t.Errorf("path: got %q", parsed.Path) + } + if parsed.Command == "" { + t.Error("command non costruito per Edit") + } +} + +func TestParseInput_WriteFile(t *testing.T) { + input := `{"file_path":"/home/user/.ssh/authorized_keys","content":"..."}` + parsed, err := mcphook.ParseInput("Write", input) + if err != nil { + t.Fatalf("ParseInput: %v", err) + } + if parsed.Path != "/home/user/.ssh/authorized_keys" { + t.Errorf("path: got %q", parsed.Path) + } +} + +func TestParseInput_UnknownTool(t *testing.T) { + input := `{"anything":"value"}` + parsed, err := mcphook.ParseInput("UnknownTool", input) + if err != nil { + t.Fatalf("ParseInput su tool sconosciuto non deve fallire: %v", err) + } + if parsed.ToolName != "UnknownTool" { + t.Errorf("tool_name: got %q", parsed.ToolName) + } +} + +func TestBuildDaemonRequest(t *testing.T) { + parsed := mcphook.ParsedCall{ + ToolName: "Bash", + Command: "git push origin main", + WorkDir: "/home/user/project", + AgentName: "claude-code", + } + req := mcphook.BuildDaemonRequest(parsed) + if req.Command != "git push origin main" { + t.Errorf("command: got %q", req.Command) + } + if req.AgentName != "claude-code" { + t.Errorf("agent_name: got %q", req.AgentName) + } +} + +func TestExitCode_AllowIsZero(t *testing.T) { + if mcphook.ExitCode("allow") != 0 { + t.Error("allow deve restituire exit code 0") + } +} + +func TestExitCode_BlockIsNonZero(t *testing.T) { + if mcphook.ExitCode("block") == 0 { + t.Error("block deve restituire exit code non zero") + } +} + +func TestExitCode_SandboxIsZero(t *testing.T) { + // sandbox = eseguito in isolamento, Claude Code può continuare + if mcphook.ExitCode("sandbox") != 0 { + t.Error("sandbox deve restituire exit code 0") + } +} + +func TestParseStdin_BashCommand(t *testing.T) { + stdin := `{"tool_name":"Bash","tool_input":{"command":"sudo rm -rf /tmp","workdir":"/home/user"}}` + parsed, err := mcphook.ParseStdin(strings.NewReader(stdin)) + if err != nil { + t.Fatalf("ParseStdin: %v", err) + } + if parsed.ToolName != "Bash" { + t.Errorf("tool_name: got %q", parsed.ToolName) + } + if parsed.Command != "sudo rm -rf /tmp" { + t.Errorf("command: got %q", parsed.Command) + } +} + +func TestParseStdin_EditFile(t *testing.T) { + stdin := `{"tool_name":"Edit","tool_input":{"file_path":"/etc/passwd","old_string":"a","new_string":"b"}}` + parsed, err := mcphook.ParseStdin(strings.NewReader(stdin)) + if err != nil { + t.Fatalf("ParseStdin: %v", err) + } + if parsed.Path != "/etc/passwd" { + t.Errorf("path: got %q", parsed.Path) + } +} + +func TestParseStdin_MalformedJSON(t *testing.T) { + _, err := mcphook.ParseStdin(strings.NewReader("not json")) + if err == nil { + t.Error("atteso errore su JSON malformato") + } +} + +func TestQueryDaemon_DaemonNotRunning_Blocks(t *testing.T) { + decision, reason := mcphook.QueryDaemon("/nonexistent/night-agent.sock", mcphook.DaemonRequest{ + Command: "sudo rm -rf /", + AgentName: "claude-code", + }) + if decision != "block" { + t.Errorf("daemon non raggiungibile: atteso block, got %q", decision) + } + if !strings.Contains(reason, "daemon") { + t.Errorf("reason deve menzionare 'daemon': got %q", reason) + } +} + +func TestQueryDaemon_DaemonNotRunning_ExitCodeIsTwo(t *testing.T) { + decision, _ := mcphook.QueryDaemon("/nonexistent/night-agent.sock", mcphook.DaemonRequest{ + Command: "ls /etc", + }) + if mcphook.ExitCode(decision) != 2 { + t.Errorf("daemon non raggiungibile: atteso exit code 2, got %d", mcphook.ExitCode(decision)) + } +} diff --git a/internal/policy/loader.go b/internal/policy/loader.go new file mode 100644 index 0000000..2c0b2ae --- /dev/null +++ b/internal/policy/loader.go @@ -0,0 +1,261 @@ +package policy + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/fsnotify/fsnotify" +) + +// Source indica da dove è stata caricata la policy. +type Source int + +const ( + SourceNone Source = iota // nessuna policy trovata — tutto consentito + SourceCloud // scaricata dal cloud + SourceLocal // file locale nel progetto (walk-up) + SourceGlobal // ~/.night-agent/policy.yaml +) + +func (s Source) String() string { + switch s { + case SourceCloud: + return "cloud" + case SourceLocal: + return "local" + case SourceGlobal: + return "global" + default: + return "none" + } +} + +// LoadedPolicy è il risultato del caricamento con metadati sulla sorgente. +type LoadedPolicy struct { + *Policy + Source Source + Path string // path file oppure "cloud:" +} + +// CloudClient è l'interfaccia per scaricare la policy dal cloud. +// Facilita il mock nei test. +type CloudClient interface { + FetchPolicy(machineID string) ([]byte, error) +} + +// HTTPCloudClient implementa CloudClient con chiamate HTTP reali. +type HTTPCloudClient struct { + Endpoint string + Token string +} + +type cloudPolicyResponse struct { + MachineID string `json:"machine_id"` + PolicyYAML *string `json:"policy_yaml"` +} + +func (c *HTTPCloudClient) FetchPolicy(machineID string) ([]byte, error) { + url := c.Endpoint + "/api/policy?machine_id=" + machineID + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.Token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cloud policy: status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var r cloudPolicyResponse + if err := json.Unmarshal(body, &r); err != nil { + // risposta non JSON — prova a trattarla come YAML diretto + return body, nil + } + if r.PolicyYAML == nil { + return nil, fmt.Errorf("cloud policy: policy_yaml è null") + } + return []byte(*r.PolicyYAML), nil +} + +// localPolicyNames sono i nomi file cercati risalendo i parent. +var localPolicyNames = []string{"nightagent-policy.yaml", ".nightagent/policy.yaml"} + +// Load carica la policy con la seguente priorità: +// 1. Cloud (se client != nil e machineID != "") +// 2. File locale (nightagent-policy.yaml, walk-up fino a home) +// 3. ~/.night-agent/policy.yaml (globale) +// 4. SourceNone — nessun errore, tutto consentito +// +// Se il cloud fallisce, logga il warning e scende alla priorità successiva. +func Load(workDir string, client CloudClient, machineID string) (*LoadedPolicy, error) { + // 1. Cloud + if client != nil && machineID != "" { + if yamlBytes, err := client.FetchPolicy(machineID); err == nil { + if p, err := LoadBytes(yamlBytes); err == nil { + return &LoadedPolicy{ + Policy: p, + Source: SourceCloud, + Path: "cloud:" + machineID, + }, nil + } + } + // fallthrough silenzioso — rete down, 404, policy non valida + } + + // 2. Locale — walk-up da workDir fino a home + home, _ := os.UserHomeDir() + dir := workDir + for { + for _, name := range localPolicyNames { + candidate := filepath.Join(dir, name) + if data, err := os.ReadFile(candidate); err == nil { + if p, err := LoadBytes(data); err == nil { + return &LoadedPolicy{Policy: p, Source: SourceLocal, Path: candidate}, nil + } + } + } + if dir == home || dir == filepath.Dir(dir) { + break + } + dir = filepath.Dir(dir) + } + + // 3. Globale + if home != "" { + globalPath := filepath.Join(home, ".night-agent", "policy.yaml") + if data, err := os.ReadFile(globalPath); err == nil { + if p, err := LoadBytes(data); err == nil { + return &LoadedPolicy{Policy: p, Source: SourceGlobal, Path: globalPath}, nil + } + } + } + + // 4. Nessuna policy — permissive + return &LoadedPolicy{Source: SourceNone, Path: ""}, nil +} + +// FormatSource restituisce la stringa di log per la policy caricata. +func FormatSource(lp *LoadedPolicy) string { + switch lp.Source { + case SourceCloud: + machineID := lp.Path + if len(machineID) > len("cloud:") { + machineID = machineID[len("cloud:"):] + } + return fmt.Sprintf("[policy] loaded from cloud (machine: %s)", machineID) + case SourceLocal: + return fmt.Sprintf("[policy] loaded from %s", lp.Path) + case SourceGlobal: + home, _ := os.UserHomeDir() + path := lp.Path + if home != "" { + if rel, err := filepath.Rel(home, lp.Path); err == nil { + path = "~/" + rel + } + } + return fmt.Sprintf("[policy] loaded from %s (global)", path) + default: + return "[policy] no policy found — all actions allowed" + } +} + +// cloudPollInterval è l'intervallo di polling per aggiornamenti policy dal cloud. +const cloudPollInterval = 60 * time.Second + +// Watch avvia un watcher fs su workDir per nightagent-policy.yaml. +// Quando il file viene creato, modificato o eliminato, chiama onChange con +// la policy ricalcolata (usando Load con gli stessi parametri). +// Se client != nil, ricontrolla la policy cloud ogni 60 secondi. +// Ritorna una funzione stop per il cleanup. Fail-open: errori del watcher +// vengono ignorati silenziosamente. +// +// isTrustedFile (opzionale): se fornita, viene chiamata con il contenuto del file +// prima di ricaricare. Se restituisce false, la modifica viene rifiutata con un +// avviso di sicurezza. Non viene applicata agli aggiornamenti cloud. +func Watch(workDir string, client CloudClient, machineID string, onChange func(*LoadedPolicy), isTrustedFile ...func([]byte) bool) (func(), error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return func() {}, fmt.Errorf("fsnotify: %w", err) + } + + if err := watcher.Add(workDir); err != nil { + watcher.Close() + return func() {}, fmt.Errorf("watch %s: %w", workDir, err) + } + + var trustChecker func([]byte) bool + if len(isTrustedFile) > 0 { + trustChecker = isTrustedFile[0] + } + + stop := make(chan struct{}) + + go func() { + defer watcher.Close() + + var cloudTicker *time.Ticker + var cloudTickC <-chan time.Time + if client != nil && machineID != "" { + cloudTicker = time.NewTicker(cloudPollInterval) + cloudTickC = cloudTicker.C + defer cloudTicker.Stop() + } + + for { + select { + case <-stop: + return + case <-cloudTickC: + // Aggiornamenti cloud: sempre attendibili + if lp, err := Load(workDir, client, machineID); err == nil { + onChange(lp) + } + case event, ok := <-watcher.Events: + if !ok { + return + } + base := filepath.Base(event.Name) + if base != "nightagent-policy.yaml" { + continue + } + if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) || event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) { + // Verifica integrità: rifiuta modifiche esterne non autorizzate + if trustChecker != nil && (event.Has(fsnotify.Write) || event.Has(fsnotify.Create)) { + data, readErr := os.ReadFile(event.Name) + if readErr == nil && !trustChecker(data) { + fmt.Fprintf(os.Stderr, + "[security] policy file modificato esternamente — modifica ignorata. Usa 'nightagent policy edit'\n") + continue + } + } + if lp, err := Load(workDir, client, machineID); err == nil { + onChange(lp) + } + } + case _, ok := <-watcher.Errors: + if !ok { + return + } + // errori watcher ignorati silenziosamente + } + } + }() + + return func() { close(stop) }, nil +} diff --git a/internal/policy/loader_test.go b/internal/policy/loader_test.go new file mode 100644 index 0000000..6bd42e6 --- /dev/null +++ b/internal/policy/loader_test.go @@ -0,0 +1,146 @@ +package policy_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/night-agent-cli/night-agent/internal/policy" +) + +// mockClient implementa CloudClient per i test. +type mockClient struct { + yaml []byte + err error +} + +func (m *mockClient) FetchPolicy(_ string) ([]byte, error) { + return m.yaml, m.err +} + +var validPolicy = []byte(` +version: 1 +rules: + - id: test_block + when: + action_type: shell + command_matches: ["sudo *"] + match_type: glob + decision: block + reason: test +`) + +func TestLoadCloud(t *testing.T) { + client := &mockClient{yaml: validPolicy} + lp, err := policy.Load(t.TempDir(), client, "machine-1") + if err != nil { + t.Fatalf("Load: %v", err) + } + if lp.Source != policy.SourceCloud { + t.Errorf("source: want Cloud, got %v", lp.Source) + } + if lp.Path != "cloud:machine-1" { + t.Errorf("path: want 'cloud:machine-1', got %q", lp.Path) + } +} + +func TestLoadCloudFallbackLocal(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "nightagent-policy.yaml"), validPolicy, 0600); err != nil { + t.Fatal(err) + } + + client := &mockClient{err: fmt.Errorf("404")} + lp, err := policy.Load(dir, client, "machine-1") + if err != nil { + t.Fatalf("Load: %v", err) + } + if lp.Source != policy.SourceLocal { + t.Errorf("source: want Local, got %v", lp.Source) + } +} + +func TestLoadLocal(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "nightagent-policy.yaml"), validPolicy, 0600); err != nil { + t.Fatal(err) + } + + lp, err := policy.Load(dir, nil, "") + if err != nil { + t.Fatalf("Load: %v", err) + } + if lp.Source != policy.SourceLocal { + t.Errorf("source: want Local, got %v", lp.Source) + } + if lp.Path != filepath.Join(dir, "nightagent-policy.yaml") { + t.Errorf("path: want %q, got %q", filepath.Join(dir, "nightagent-policy.yaml"), lp.Path) + } +} + +func TestLoadLocalParent(t *testing.T) { + parent := t.TempDir() + child := filepath.Join(parent, "subdir") + if err := os.MkdirAll(child, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(parent, "nightagent-policy.yaml"), validPolicy, 0600); err != nil { + t.Fatal(err) + } + + lp, err := policy.Load(child, nil, "") + if err != nil { + t.Fatalf("Load: %v", err) + } + if lp.Source != policy.SourceLocal { + t.Errorf("source: want Local, got %v", lp.Source) + } + if lp.Path != filepath.Join(parent, "nightagent-policy.yaml") { + t.Errorf("path: want parent policy, got %q", lp.Path) + } +} + +func TestLoadGlobal(t *testing.T) { + dir := t.TempDir() // no local policy + + lp, err := policy.Load(dir, nil, "") + if err != nil { + t.Fatalf("Load: %v", err) + } + // può essere Global o None a seconda di ~/.night-agent/policy.yaml + if lp.Source != policy.SourceGlobal && lp.Source != policy.SourceNone { + t.Errorf("source: want Global or None, got %v", lp.Source) + } +} + +func TestLoadNone(t *testing.T) { + // dir isolata senza policy, nessun cloud, home senza ~/.night-agent/policy.yaml + // Non possiamo garantire assenza di ~/.night-agent/policy.yaml nell'ambiente CI, + // quindi verifichiamo solo che non restituisca errore. + lp, err := policy.Load(t.TempDir(), nil, "") + if err != nil { + t.Fatalf("Load non deve restituire errore: %v", err) + } + if lp == nil { + t.Fatal("LoadedPolicy non deve essere nil") + } +} + +func TestLoadPriorityCloudOverLocal(t *testing.T) { + dir := t.TempDir() + // crea policy locale + if err := os.WriteFile(filepath.Join(dir, "nightagent-policy.yaml"), validPolicy, 0600); err != nil { + t.Fatal(err) + } + + // cloud risponde con policy valida + client := &mockClient{yaml: validPolicy} + lp, err := policy.Load(dir, client, "machine-cloud") + if err != nil { + t.Fatalf("Load: %v", err) + } + if lp.Source != policy.SourceCloud { + t.Errorf("cloud deve vincere su locale: got source %v", lp.Source) + } +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go new file mode 100644 index 0000000..8c7de34 --- /dev/null +++ b/internal/policy/policy.go @@ -0,0 +1,299 @@ +package policy + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + + "github.com/gobwas/glob" + "gopkg.in/yaml.v3" +) + +type MatchType string +type Decision string +type ActionType string + +const ( + MatchGlob MatchType = "glob" + MatchRegex MatchType = "regex" + + DecisionAllow Decision = "allow" + DecisionBlock Decision = "block" + DecisionAsk Decision = "ask" + DecisionSandbox Decision = "sandbox" + + ActionTypeShell ActionType = "shell" + ActionTypeGit ActionType = "git" + ActionTypeFile ActionType = "file" +) + +type Condition struct { + ActionType string `yaml:"action_type"` + CommandMatches []string `yaml:"command_matches"` + PathMatches []string `yaml:"path_matches"` +} + +// SandboxConfig configura il container Docker per le regole con decision: sandbox. +type SandboxConfig struct { + Image string `yaml:"image"` // immagine Docker, es: "alpine:3.20" + Network string `yaml:"network"` // "none" (default) o "bridge" +} + +type Rule struct { + ID string `yaml:"id"` + When Condition `yaml:"when"` + MatchType MatchType `yaml:"match_type"` + Decision Decision `yaml:"decision"` + Reason string `yaml:"reason"` + Sandbox *SandboxConfig `yaml:"sandbox,omitempty"` +} + +type Policy struct { + Version int `yaml:"version"` + Rules []Rule `yaml:"rules"` +} + +type Action struct { + Type string + Command string + Path string +} + +type EvalResult struct { + Decision Decision + RuleID string + Reason string + Sandbox *SandboxConfig // non nil se la regola ha decision: sandbox +} + +// LoadFile carica la policy da un file YAML. +func LoadFile(path string) (*Policy, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("impossibile leggere il file di policy: %w", err) + } + return LoadBytes(data) +} + +// LoadBytes parsa e valida una policy da slice di byte YAML. +func LoadBytes(data []byte) (*Policy, error) { + var p Policy + if err := yaml.Unmarshal(data, &p); err != nil { + return nil, fmt.Errorf("YAML non valido: %w", err) + } + if p.Version == 0 { + return nil, fmt.Errorf("campo 'version' mancante o zero nella policy") + } + return &p, nil +} + +// hardcodedRules sono regole immutabili che proteggono l'integrità del sistema. +// Vengono valutate PRIMA delle regole YAML e non possono essere sovrascritte. +var hardcodedRules = []Rule{ + { + ID: "_protect_policy_write_shell", + MatchType: MatchGlob, + When: Condition{ + ActionType: string(ActionTypeShell), + CommandMatches: []string{ + "* > *nightagent*", + "* >> *nightagent*", + "* > *.nightagent*", + "* >> *.nightagent*", + "tee *nightagent*", + "tee *.nightagent*", + "sed * nightagent*", + "chflags nouchg *nightagent*", + "chflags nouchg *night-agent*", + }, + }, + Decision: DecisionBlock, + Reason: "modifica diretta dei file policy non consentita — usa 'nightagent policy edit'", + }, + { + ID: "_protect_policy_write_file", + MatchType: MatchGlob, + When: Condition{ + ActionType: string(ActionTypeFile), + PathMatches: []string{ + "*nightagent-policy*", + "*.nightagent*policy*", + "*night-agent*policy*", + }, + }, + Decision: DecisionBlock, + Reason: "modifica diretta dei file policy non consentita — usa 'nightagent policy edit'", + }, +} + +func (p *Policy) Evaluate(action Action) EvalResult { + // Regole immutabili — valutate prima di qualsiasi regola YAML + for _, rule := range hardcodedRules { + if rule.When.ActionType != action.Type { + continue + } + if matches(rule, action) { + return EvalResult{ + Decision: rule.Decision, + RuleID: rule.ID, + Reason: rule.Reason, + } + } + } + + for _, rule := range p.Rules { + if rule.When.ActionType != action.Type { + continue + } + + if matches(rule, action) { + return EvalResult{ + Decision: rule.Decision, + RuleID: rule.ID, + Reason: rule.Reason, + Sandbox: rule.Sandbox, + } + } + } + + return EvalResult{Decision: DecisionAllow} +} + +func matches(rule Rule, action Action) bool { + mt := rule.MatchType + if mt == "" { + mt = MatchGlob + } + + // match su command + if len(rule.When.CommandMatches) > 0 && action.Command != "" { + for _, pattern := range rule.When.CommandMatches { + if matchPattern(mt, pattern, action.Command, false) { + return true + } + } + return false + } + + // match su path + if len(rule.When.PathMatches) > 0 && action.Path != "" { + for _, pattern := range rule.When.PathMatches { + if matchPattern(mt, pattern, action.Path, true) { + return true + } + } + return false + } + + return false +} + +// Save serializza la policy e la scrive su file. +func Save(path string, p *Policy) error { + data, err := yaml.Marshal(p) + if err != nil { + return fmt.Errorf("errore serializzazione policy: %w", err) + } + return os.WriteFile(path, data, 0600) +} + +// LockFile imposta il flag user-immutable (chflags uchg) sul file. +// Blocca scrittura da qualsiasi processo inclusi subprocess non-interattivi. +func LockFile(path string) error { + return exec.Command("chflags", "uchg", path).Run() +} + +// UnlockFile rimuove il flag user-immutable prima di una scrittura autorizzata. +func UnlockFile(path string) error { + return exec.Command("chflags", "nouchg", path).Run() +} + +// RelockFile re-imposta il flag user-immutable dopo una scrittura autorizzata. +func RelockFile(path string) error { + return exec.Command("chflags", "uchg", path).Run() +} + +// AppendAllowRule aggiunge una regola allow permanente nella policy YAML per +// il comando esatto dato. Se una regola identica esiste già, non duplica. +func AppendAllowRule(policyPath, agentName, command string) error { + p, err := LoadFile(policyPath) + if err != nil { + return err + } + + // genera un ID univoco basato su agente e comando + id := "allow_" + sanitizeID(agentName) + "_" + sanitizeID(command) + + // controlla se esiste già una regola identica + for _, r := range p.Rules { + if r.ID == id { + return nil // già presente, niente da fare + } + } + + newRule := Rule{ + ID: id, + When: Condition{ + ActionType: string(ActionTypeShell), + CommandMatches: []string{command}, + }, + MatchType: MatchGlob, + Decision: DecisionAllow, + Reason: fmt.Sprintf("consentito sempre per %s", agentName), + } + + // inserisci come prima regola (priorità massima — first-match-wins) + p.Rules = append([]Rule{newRule}, p.Rules...) + + data, err := yaml.Marshal(p) + if err != nil { + return fmt.Errorf("errore serializzazione policy: %w", err) + } + return os.WriteFile(policyPath, data, 0600) +} + +// sanitizeID trasforma una stringa in un identificatore YAML-safe. +func sanitizeID(s string) string { + result := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') { + result = append(result, c) + } else { + result = append(result, '_') + } + } + if len(result) > 40 { + result = result[:40] + } + return string(result) +} + +// matchPattern valuta un pattern contro un valore. +// Per command_matches usa glob senza separatori (il * matcha spazi e /). +// Per path_matches usa glob con filepath.Separator (il * non matcha /). +func matchPattern(mt MatchType, pattern, value string, isPath bool) bool { + switch mt { + case MatchRegex: + re, err := regexp.Compile(pattern) + if err != nil { + return false + } + return re.MatchString(value) + default: // glob + var g glob.Glob + var err error + if isPath { + g, err = glob.Compile(pattern, filepath.Separator) + } else { + // per comandi shell: * deve matchare spazi, slash e qualsiasi carattere + g, err = glob.Compile(pattern) + } + if err != nil { + return false + } + return g.Match(value) + } +} diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go new file mode 100644 index 0000000..7bbd197 --- /dev/null +++ b/internal/policy/policy_test.go @@ -0,0 +1,307 @@ +package policy_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/night-agent-cli/night-agent/internal/policy" +) + +func TestLoadPolicy_ValidFile(t *testing.T) { + yaml := ` +version: 1 +rules: + - id: block_sudo + when: + action_type: shell + command_matches: ["sudo *"] + match_type: glob + decision: block + reason: "sudo disabilitato" +` + f := writeTempYAML(t, yaml) + p, err := policy.LoadFile(f) + if err != nil { + t.Fatalf("atteso nessun errore, ottenuto: %v", err) + } + if p.Version != 1 { + t.Errorf("atteso version=1, ottenuto %d", p.Version) + } + if len(p.Rules) != 1 { + t.Fatalf("atteso 1 regola, ottenute %d", len(p.Rules)) + } + if p.Rules[0].ID != "block_sudo" { + t.Errorf("atteso id=block_sudo, ottenuto %s", p.Rules[0].ID) + } +} + +func TestLoadPolicy_FileNotFound(t *testing.T) { + _, err := policy.LoadFile("/nonexistent/path/policy.yaml") + if err == nil { + t.Fatal("atteso errore per file mancante, ottenuto nil") + } +} + +func TestLoadPolicy_InvalidYAML(t *testing.T) { + f := writeTempYAML(t, "{ invalid yaml ::::") + _, err := policy.LoadFile(f) + if err == nil { + t.Fatal("atteso errore per YAML invalido, ottenuto nil") + } +} + +func TestLoadPolicy_MissingVersion(t *testing.T) { + yaml := ` +rules: + - id: test_rule + when: + action_type: shell + command_matches: ["sudo *"] + decision: block + reason: "test" +` + f := writeTempYAML(t, yaml) + _, err := policy.LoadFile(f) + if err == nil { + t.Fatal("atteso errore per version mancante, ottenuto nil") + } +} + +// --- Evaluate tests --- + +func TestEvaluate_BlockGlob(t *testing.T) { + p := &policy.Policy{ + Version: 1, + Rules: []policy.Rule{ + { + ID: "block_sudo", + MatchType: policy.MatchGlob, + When: policy.Condition{ActionType: "shell", CommandMatches: []string{"sudo *"}}, + Decision: policy.DecisionBlock, + Reason: "sudo disabilitato", + }, + }, + } + + result := p.Evaluate(policy.Action{Type: "shell", Command: "sudo rm -rf /"}) + if result.Decision != policy.DecisionBlock { + t.Errorf("atteso block, ottenuto %s", result.Decision) + } + if result.RuleID != "block_sudo" { + t.Errorf("atteso rule_id=block_sudo, ottenuto %s", result.RuleID) + } +} + +func TestEvaluate_AllowByDefault(t *testing.T) { + p := &policy.Policy{ + Version: 1, + Rules: []policy.Rule{ + { + ID: "block_sudo", + MatchType: policy.MatchGlob, + When: policy.Condition{ActionType: "shell", CommandMatches: []string{"sudo *"}}, + Decision: policy.DecisionBlock, + Reason: "sudo disabilitato", + }, + }, + } + + result := p.Evaluate(policy.Action{Type: "shell", Command: "ls -la"}) + if result.Decision != policy.DecisionAllow { + t.Errorf("atteso allow per default, ottenuto %s", result.Decision) + } +} + +func TestEvaluate_AskGlob(t *testing.T) { + p := &policy.Policy{ + Version: 1, + Rules: []policy.Rule{ + { + ID: "ask_push_main", + MatchType: policy.MatchGlob, + When: policy.Condition{ActionType: "git", CommandMatches: []string{"git push * main"}}, + Decision: policy.DecisionAsk, + Reason: "push su main richiede conferma", + }, + }, + } + + result := p.Evaluate(policy.Action{Type: "git", Command: "git push origin main"}) + if result.Decision != policy.DecisionAsk { + t.Errorf("atteso ask, ottenuto %s", result.Decision) + } +} + +func TestEvaluate_FirstMatchWins(t *testing.T) { + p := &policy.Policy{ + Version: 1, + Rules: []policy.Rule{ + { + ID: "first_rule", + MatchType: policy.MatchGlob, + When: policy.Condition{ActionType: "shell", CommandMatches: []string{"rm *"}}, + Decision: policy.DecisionBlock, + Reason: "prima regola", + }, + { + ID: "second_rule", + MatchType: policy.MatchGlob, + When: policy.Condition{ActionType: "shell", CommandMatches: []string{"rm -rf *"}}, + Decision: policy.DecisionAsk, + Reason: "seconda regola", + }, + }, + } + + result := p.Evaluate(policy.Action{Type: "shell", Command: "rm -rf /tmp/test"}) + if result.RuleID != "first_rule" { + t.Errorf("atteso first_rule (first-match-wins), ottenuto %s", result.RuleID) + } +} + +func TestEvaluate_RegexMatch(t *testing.T) { + p := &policy.Policy{ + Version: 1, + Rules: []policy.Rule{ + { + ID: "regex_sudo", + MatchType: policy.MatchRegex, + When: policy.Condition{ActionType: "shell", CommandMatches: []string{`^sudo\s+.*`}}, + Decision: policy.DecisionBlock, + Reason: "sudo disabilitato (regex)", + }, + }, + } + + result := p.Evaluate(policy.Action{Type: "shell", Command: "sudo apt-get install curl"}) + if result.Decision != policy.DecisionBlock { + t.Errorf("atteso block con regex, ottenuto %s", result.Decision) + } +} + +func TestEvaluate_ActionTypeMismatch(t *testing.T) { + p := &policy.Policy{ + Version: 1, + Rules: []policy.Rule{ + { + ID: "block_sudo", + MatchType: policy.MatchGlob, + When: policy.Condition{ActionType: "shell", CommandMatches: []string{"sudo *"}}, + Decision: policy.DecisionBlock, + Reason: "sudo disabilitato", + }, + }, + } + + // azione tipo "git" non deve matchare una regola shell + result := p.Evaluate(policy.Action{Type: "git", Command: "sudo rm -rf /"}) + if result.Decision != policy.DecisionAllow { + t.Errorf("atteso allow (action_type mismatch), ottenuto %s", result.Decision) + } +} + +func TestEvaluate_PathMatch(t *testing.T) { + p := &policy.Policy{ + Version: 1, + Rules: []policy.Rule{ + { + ID: "block_ssh", + MatchType: policy.MatchGlob, + When: policy.Condition{ActionType: "file", PathMatches: []string{"~/.ssh/*"}}, + Decision: policy.DecisionBlock, + Reason: "accesso ssh vietato", + }, + }, + } + + result := p.Evaluate(policy.Action{Type: "file", Path: "~/.ssh/id_rsa"}) + if result.Decision != policy.DecisionBlock { + t.Errorf("atteso block per path ~/.ssh/id_rsa, ottenuto %s", result.Decision) + } +} + +// --- AppendAllowRule --- + +func TestAppendAllowRule_AddsRule(t *testing.T) { + path := writeTempYAML(t, "version: 1\nrules: []\n") + + if err := policy.AppendAllowRule(path, "claude", "sudo ls"); err != nil { + t.Fatalf("AppendAllowRule fallita: %v", err) + } + + p, err := policy.LoadFile(path) + if err != nil { + t.Fatalf("errore caricamento policy dopo append: %v", err) + } + + result := p.Evaluate(policy.Action{Type: "shell", Command: "sudo ls"}) + if result.Decision != policy.DecisionAllow { + t.Errorf("atteso allow dopo AppendAllowRule, ottenuto %s", result.Decision) + } +} + +func TestAppendAllowRule_Idempotent(t *testing.T) { + path := writeTempYAML(t, "version: 1\nrules: []\n") + + if err := policy.AppendAllowRule(path, "claude", "sudo ls"); err != nil { + t.Fatalf("prima AppendAllowRule fallita: %v", err) + } + if err := policy.AppendAllowRule(path, "claude", "sudo ls"); err != nil { + t.Fatalf("seconda AppendAllowRule fallita: %v", err) + } + + p, err := policy.LoadFile(path) + if err != nil { + t.Fatalf("errore caricamento policy: %v", err) + } + // conta regole allow per questo comando + count := 0 + for _, r := range p.Rules { + if r.Decision == policy.DecisionAllow { + count++ + } + } + if count != 1 { + t.Errorf("attesa 1 regola allow, trovate %d", count) + } +} + +func TestAppendAllowRule_PreservesExistingRules(t *testing.T) { + path := writeTempYAML(t, `version: 1 +rules: + - id: block_sudo + when: + action_type: shell + command_matches: ["sudo *"] + match_type: glob + decision: block + reason: "sudo disabilitato" +`) + + if err := policy.AppendAllowRule(path, "claude", "git status"); err != nil { + t.Fatalf("AppendAllowRule fallita: %v", err) + } + + p, err := policy.LoadFile(path) + if err != nil { + t.Fatalf("errore caricamento policy: %v", err) + } + // la regola block_sudo deve ancora esistere + result := p.Evaluate(policy.Action{Type: "shell", Command: "sudo rm -rf /"}) + if result.Decision != policy.DecisionBlock { + t.Errorf("regola block_sudo rimossa dopo AppendAllowRule, atteso block ottenuto %s", result.Decision) + } +} + +// --- helpers --- + +func writeTempYAML(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + f := filepath.Join(dir, "policy.yaml") + if err := os.WriteFile(f, []byte(content), 0600); err != nil { + t.Fatalf("errore scrittura file temporaneo: %v", err) + } + return f +} diff --git a/internal/policyeditor/editor.go b/internal/policyeditor/editor.go new file mode 100644 index 0000000..196f1ef --- /dev/null +++ b/internal/policyeditor/editor.go @@ -0,0 +1,162 @@ +package policyeditor + +import ( + "fmt" + "strings" + + "github.com/night-agent-cli/night-agent/internal/policy" +) + +// NewRuleSpec contiene i dati per creare una nuova regola. +type NewRuleSpec struct { + ID string + ActionType string + Pattern string + Decision string + Reason string +} + +// ToggleRule inverte la decisione di una regola (block↔allow). +func ToggleRule(policyPath, ruleID string) error { + p, err := policy.LoadFile(policyPath) + if err != nil { + return err + } + + found := false + for i, r := range p.Rules { + if r.ID == ruleID { + if p.Rules[i].Decision == policy.DecisionBlock || p.Rules[i].Decision == policy.DecisionAsk { + p.Rules[i].Decision = policy.DecisionAllow + p.Rules[i].Reason = "consentito dall'utente" + } else { + p.Rules[i].Decision = policy.DecisionBlock + p.Rules[i].Reason = r.Reason + } + found = true + break + } + } + if !found { + return fmt.Errorf("regola '%s' non trovata nella policy", ruleID) + } + + return policy.Save(policyPath, p) +} + +// AddRule aggiunge una nuova regola alla policy. +// Restituisce errore se esiste già una regola con lo stesso ID. +func AddRule(policyPath string, spec NewRuleSpec) error { + p, err := policy.LoadFile(policyPath) + if err != nil { + return err + } + + for _, r := range p.Rules { + if r.ID == spec.ID { + return fmt.Errorf("regola con ID '%s' già esistente", spec.ID) + } + } + + decision := policy.Decision(spec.Decision) + if decision == "" { + decision = policy.DecisionBlock + } + + newRule := policy.Rule{ + ID: spec.ID, + When: policy.Condition{ + ActionType: spec.ActionType, + CommandMatches: []string{spec.Pattern}, + }, + MatchType: policy.MatchGlob, + Decision: decision, + Reason: spec.Reason, + } + + p.Rules = append(p.Rules, newRule) + return policy.Save(policyPath, p) +} + +// RemoveRule rimuove una regola dalla policy per ID. +func RemoveRule(policyPath, ruleID string) error { + p, err := policy.LoadFile(policyPath) + if err != nil { + return err + } + + newRules := make([]policy.Rule, 0, len(p.Rules)) + found := false + for _, r := range p.Rules { + if r.ID == ruleID { + found = true + continue + } + newRules = append(newRules, r) + } + if !found { + return fmt.Errorf("regola '%s' non trovata nella policy", ruleID) + } + + p.Rules = newRules + return policy.Save(policyPath, p) +} + +// ANSI +const ( + reset = "\033[0m" + bold = "\033[1m" + dim = "\033[2m" + red = "\033[31m" + green = "\033[32m" + yellow = "\033[33m" + cyan = "\033[36m" + boldRed = "\033[1;31m" + boldGreen = "\033[1;32m" + boldCyan = "\033[1;36m" + boldWhite = "\033[1;37m" +) + +// RenderTable restituisce la rappresentazione colorata della policy corrente. +func RenderTable(p *policy.Policy) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("\n%s Policy attiva%s %s(v%d, %d regole)%s\n", + bold+boldCyan, reset, dim, p.Version, len(p.Rules), reset)) + sb.WriteString(dim + " ─────────────────────────────────────────────────────────────────\n" + reset) + sb.WriteString(fmt.Sprintf(" %s%-4s %-30s %-6s %-8s %s%s\n", + bold, "#", "ID", "TIPO", "DECISIONE", "MOTIVO", reset)) + sb.WriteString(dim + " ─────────────────────────────────────────────────────────────────\n" + reset) + + for i, r := range p.Rules { + decisionStr := formatDecision(r.Decision) + actionType := string(r.When.ActionType) + reason := r.Reason + if len(reason) > 35 { + reason = reason[:32] + "..." + } + sb.WriteString(fmt.Sprintf(" %s%-4d%s %-30s %-6s %s %s%s%s\n", + dim, i+1, reset, + r.ID, + actionType, + decisionStr, + dim, reason, reset, + )) + } + + sb.WriteString(dim + " ─────────────────────────────────────────────────────────────────\n" + reset) + return sb.String() +} + +func formatDecision(d policy.Decision) string { + switch d { + case policy.DecisionBlock: + return boldRed + "✗ block " + reset + case policy.DecisionAllow: + return boldGreen + "✓ allow " + reset + case policy.DecisionAsk: + return yellow + "? ask " + reset + default: + return dim + string(d) + reset + } +} diff --git a/internal/policyeditor/editor_test.go b/internal/policyeditor/editor_test.go new file mode 100644 index 0000000..021616e --- /dev/null +++ b/internal/policyeditor/editor_test.go @@ -0,0 +1,185 @@ +package policyeditor_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/night-agent-cli/night-agent/internal/policyeditor" + "github.com/night-agent-cli/night-agent/internal/policy" +) + +func writePolicy(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "policy.yaml") + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + return path +} + +var sampleYAML = `version: 1 +rules: + - id: block_sudo + when: + action_type: shell + command_matches: ["sudo *"] + match_type: glob + decision: block + reason: "sudo disabilitato" + - id: block_rm_rf + when: + action_type: shell + command_matches: ["rm -rf *"] + match_type: glob + decision: allow + reason: "consentito dall'utente" + - id: ask_git_push_main + when: + action_type: git + command_matches: ["git push * main"] + match_type: glob + decision: block + reason: "push su main richiede conferma" +` + +func TestToggleRule_BlockToAllow(t *testing.T) { + path := writePolicy(t, sampleYAML) + + if err := policyeditor.ToggleRule(path, "block_sudo"); err != nil { + t.Fatalf("ToggleRule fallita: %v", err) + } + + p, _ := policy.LoadFile(path) + for _, r := range p.Rules { + if r.ID == "block_sudo" { + if r.Decision != policy.DecisionAllow { + t.Errorf("atteso allow dopo toggle, ottenuto %s", r.Decision) + } + return + } + } + t.Error("regola block_sudo non trovata dopo toggle") +} + +func TestToggleRule_AllowToBlock(t *testing.T) { + path := writePolicy(t, sampleYAML) + + if err := policyeditor.ToggleRule(path, "block_rm_rf"); err != nil { + t.Fatalf("ToggleRule fallita: %v", err) + } + + p, _ := policy.LoadFile(path) + for _, r := range p.Rules { + if r.ID == "block_rm_rf" { + if r.Decision != policy.DecisionBlock { + t.Errorf("atteso block dopo toggle, ottenuto %s", r.Decision) + } + return + } + } + t.Error("regola block_rm_rf non trovata dopo toggle") +} + +func TestToggleRule_NotFound(t *testing.T) { + path := writePolicy(t, sampleYAML) + err := policyeditor.ToggleRule(path, "nonexistent_rule") + if err == nil { + t.Error("atteso errore per regola inesistente") + } +} + +func TestAddRule_NewRule(t *testing.T) { + path := writePolicy(t, sampleYAML) + + rule := policyeditor.NewRuleSpec{ + ID: "block_chmod", + ActionType: "shell", + Pattern: "chmod 777 *", + Decision: "block", + Reason: "chmod 777 non consentito", + } + if err := policyeditor.AddRule(path, rule); err != nil { + t.Fatalf("AddRule fallita: %v", err) + } + + p, _ := policy.LoadFile(path) + for _, r := range p.Rules { + if r.ID == "block_chmod" { + if r.Decision != policy.DecisionBlock { + t.Errorf("atteso block, ottenuto %s", r.Decision) + } + return + } + } + t.Error("regola block_chmod non trovata dopo AddRule") +} + +func TestAddRule_DuplicateID(t *testing.T) { + path := writePolicy(t, sampleYAML) + + rule := policyeditor.NewRuleSpec{ + ID: "block_sudo", + ActionType: "shell", + Pattern: "sudo *", + Decision: "block", + Reason: "duplicato", + } + err := policyeditor.AddRule(path, rule) + if err == nil { + t.Error("atteso errore per ID duplicato") + } +} + +func TestRemoveRule(t *testing.T) { + path := writePolicy(t, sampleYAML) + + if err := policyeditor.RemoveRule(path, "block_sudo"); err != nil { + t.Fatalf("RemoveRule fallita: %v", err) + } + + p, _ := policy.LoadFile(path) + for _, r := range p.Rules { + if r.ID == "block_sudo" { + t.Error("regola block_sudo ancora presente dopo RemoveRule") + } + } +} + +func TestRemoveRule_NotFound(t *testing.T) { + path := writePolicy(t, sampleYAML) + err := policyeditor.RemoveRule(path, "nonexistent") + if err == nil { + t.Error("atteso errore per regola inesistente") + } +} + +func TestRenderTable_NotEmpty(t *testing.T) { + path := writePolicy(t, sampleYAML) + p, _ := policy.LoadFile(path) + + out := policyeditor.RenderTable(p) + if out == "" { + t.Error("RenderTable non deve restituire stringa vuota") + } + if !containsStr(out, "block_sudo") { + t.Error("tabella non contiene block_sudo") + } + if !containsStr(out, "block_rm_rf") { + t.Error("tabella non contiene block_rm_rf") + } +} + +func containsStr(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || findSubstr(s, sub)) +} + +func findSubstr(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go new file mode 100644 index 0000000..eaa39bd --- /dev/null +++ b/internal/prompt/prompt.go @@ -0,0 +1,65 @@ +package prompt + +import ( + "sync" +) + +// Response rappresenta la scelta dell'utente al prompt interattivo. +type Response int + +const ( + ResponseBlock Response = iota // blocca questa volta + ResponseAllowOnce // consenti solo questa volta + ResponseAllowSession // consenti per tutta la sessione + ResponseAllowAlways // scrivi regola allow permanente +) + +func (r Response) String() string { + switch r { + case ResponseBlock: + return "block" + case ResponseAllowOnce: + return "allow_once" + case ResponseAllowSession: + return "allow_session" + case ResponseAllowAlways: + return "allow_always" + default: + return "block" + } +} + +// SessionAllowlist mantiene in memoria i comandi consentiti per la sessione corrente. +// Thread-safe. +type SessionAllowlist struct { + mu sync.RWMutex + entries map[string]map[string]struct{} // agentName → set di comandi +} + +func NewSessionAllowlist() *SessionAllowlist { + return &SessionAllowlist{ + entries: make(map[string]map[string]struct{}), + } +} + +// Add aggiunge un comando all'allowlist di sessione per l'agente dato. +func (s *SessionAllowlist) Add(agentName, command string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.entries[agentName] == nil { + s.entries[agentName] = make(map[string]struct{}) + } + s.entries[agentName][command] = struct{}{} +} + +// IsAllowed verifica se il comando è nella allowlist di sessione per l'agente. +func (s *SessionAllowlist) IsAllowed(agentName, command string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + cmds, ok := s.entries[agentName] + if !ok { + return false + } + _, allowed := cmds[command] + return allowed +} diff --git a/internal/prompt/prompt_test.go b/internal/prompt/prompt_test.go new file mode 100644 index 0000000..8bec36a --- /dev/null +++ b/internal/prompt/prompt_test.go @@ -0,0 +1,81 @@ +package prompt_test + +import ( + "testing" + + "github.com/night-agent-cli/night-agent/internal/prompt" +) + +// --- PromptResponse --- + +func TestPromptResponse_String(t *testing.T) { + cases := []struct { + r prompt.Response + want string + }{ + {prompt.ResponseBlock, "block"}, + {prompt.ResponseAllowOnce, "allow_once"}, + {prompt.ResponseAllowSession, "allow_session"}, + {prompt.ResponseAllowAlways, "allow_always"}, + } + for _, c := range cases { + if got := c.r.String(); got != c.want { + t.Errorf("Response(%d).String() = %q, atteso %q", c.r, got, c.want) + } + } +} + +// --- SessionAllowlist --- + +func TestSessionAllowlist_Empty(t *testing.T) { + sa := prompt.NewSessionAllowlist() + if sa.IsAllowed("claude", "sudo ls") { + t.Error("atteso false per allowlist vuota") + } +} + +func TestSessionAllowlist_AddAndCheck(t *testing.T) { + sa := prompt.NewSessionAllowlist() + sa.Add("claude", "sudo ls") + if !sa.IsAllowed("claude", "sudo ls") { + t.Error("atteso true dopo Add") + } +} + +func TestSessionAllowlist_DifferentAgent(t *testing.T) { + sa := prompt.NewSessionAllowlist() + sa.Add("claude", "sudo ls") + if sa.IsAllowed("codex", "sudo ls") { + t.Error("allowlist per 'claude' non deve applicarsi a 'codex'") + } +} + +func TestSessionAllowlist_DifferentCommand(t *testing.T) { + sa := prompt.NewSessionAllowlist() + sa.Add("claude", "sudo ls") + if sa.IsAllowed("claude", "sudo rm -rf /") { + t.Error("allowlist per 'sudo ls' non deve applicarsi a 'sudo rm -rf /'") + } +} + +func TestSessionAllowlist_MultipleCommands(t *testing.T) { + sa := prompt.NewSessionAllowlist() + sa.Add("claude", "sudo ls") + sa.Add("claude", "git push origin main") + + if !sa.IsAllowed("claude", "sudo ls") { + t.Error("atteso true per 'sudo ls'") + } + if !sa.IsAllowed("claude", "git push origin main") { + t.Error("atteso true per 'git push origin main'") + } +} + +func TestSessionAllowlist_Idempotent(t *testing.T) { + sa := prompt.NewSessionAllowlist() + sa.Add("claude", "sudo ls") + sa.Add("claude", "sudo ls") + if !sa.IsAllowed("claude", "sudo ls") { + t.Error("atteso true dopo Add duplicato") + } +} diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go new file mode 100644 index 0000000..d4842eb --- /dev/null +++ b/internal/sandbox/manager.go @@ -0,0 +1,208 @@ +package sandbox + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" +) + +const ( + // DefaultImage è l'immagine Docker usata se non specificata nella policy o nel profilo. + DefaultImage = "alpine:3.20" + // DefaultNetwork disabilita la rete nel container per sicurezza. + DefaultNetwork = "none" + // SandboxLabel è il label applicato a tutti i container gestiti da Guardian. + SandboxLabel = "guardian.sandbox=true" +) + +// Mount descrive un mount aggiuntivo nel container. +type Mount struct { + Source string // path assoluto sull'host + Target string // path nel container + Readonly bool // true → montato :ro +} + +// Config descrive come eseguire un comando nel container sandbox. +type Config struct { + Image string // immagine Docker, es: "alpine:3.20" + Network string // modalità rete: "none" (default) o "bridge" + WorkDir string // path host da montare come /workspace nel container + Env []string // variabili d'ambiente KEY=VALUE + ExtraMounts []Mount // mount aggiuntivi oltre al workspace principale +} + +// ApplyDefaults imposta i valori mancanti con i default sicuri. +func (c *Config) ApplyDefaults() { + if c.Image == "" { + c.Image = DefaultImage + } + if c.Network == "" { + c.Network = DefaultNetwork + } +} + +// Result contiene il risultato di un'esecuzione sandbox. +type Result struct { + ExitCode int + Stdout string + Stderr string +} + +// Manager gestisce l'esecuzione di comandi in container Docker isolati. +type Manager struct{} + +// New crea un nuovo SandboxManager. +func New() *Manager { + return &Manager{} +} + +// IsAvailable verifica se Docker è installato e il daemon è in esecuzione. +// Cerca il binario docker anche nei path comuni di macOS (Docker Desktop) +// nel caso in cui il processo chiamante abbia un PATH minimale (es. LaunchAgent). +func (m *Manager) IsAvailable() bool { + dockerBin := resolveDockerBinary() + if dockerBin == "" { + return false + } + cmd := exec.Command(dockerBin, "info") + return cmd.Run() == nil +} + +// Execute esegue un comando shell all'interno di un container Docker. +// Il container viene rimosso automaticamente al termine (--rm). +// L'output viene catturato e restituito nel Result. +func (m *Manager) Execute(ctx context.Context, command string, cfg Config) (*Result, error) { + cfg.ApplyDefaults() + + dockerBin := resolveDockerBinary() + if dockerBin == "" { + return nil, fmt.Errorf("Docker non trovato — installa Docker Desktop") + } + + args := BuildDockerArgs(command, cfg) + cmd := exec.CommandContext(ctx, dockerBin, args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return nil, fmt.Errorf("errore esecuzione Docker: %w", err) + } + } + + return &Result{ + ExitCode: exitCode, + Stdout: stdout.String(), + Stderr: stderr.String(), + }, nil +} + +// Reset ferma tutti i container sandbox attivi gestiti da Guardian. +// Poiché i container sono avviati con --rm, di solito si auto-rimuovono. +// Reset serve per container rimasti appesi o avviati senza --rm. +func (m *Manager) Reset(ctx context.Context) (int, error) { + dockerBin := resolveDockerBinary() + if dockerBin == "" { + return 0, fmt.Errorf("Docker non trovato") + } + + // lista container attivi con il label guardian + listCmd := exec.CommandContext(ctx, dockerBin, + "ps", "-q", "--filter", "label="+SandboxLabel) + out, err := listCmd.Output() + if err != nil { + return 0, fmt.Errorf("errore lista container: %w", err) + } + + ids := parseContainerIDs(string(out)) + if len(ids) == 0 { + return 0, nil + } + + // ferma tutti i container trovati + args := append([]string{"stop"}, ids...) + stopCmd := exec.CommandContext(ctx, dockerBin, args...) + if err := stopCmd.Run(); err != nil { + return 0, fmt.Errorf("errore stop container: %w", err) + } + + return len(ids), nil +} + +// BuildDockerArgs costruisce la lista di argomenti per il comando docker run. +// È esportata per facilitare i test unitari senza richiedere Docker attivo. +func BuildDockerArgs(command string, cfg Config) []string { + args := []string{ + "run", "--rm", + "--network", cfg.Network, + "--label", SandboxLabel, + } + + // workspace principale + if cfg.WorkDir != "" { + args = append(args, "-v", cfg.WorkDir+":/workspace:rw") + args = append(args, "-w", "/workspace") + } + + // /tmp host montato in sola lettura — permette accesso a file temporanei + args = append(args, "-v", "/tmp:/tmp:ro") + + // mount aggiuntivi dal profilo di progetto + for _, m := range cfg.ExtraMounts { + mode := "rw" + if m.Readonly { + mode = "ro" + } + args = append(args, "-v", m.Source+":"+m.Target+":"+mode) + } + + // variabili d'ambiente + for _, e := range cfg.Env { + args = append(args, "-e", e) + } + + args = append(args, cfg.Image) + args = append(args, "sh", "-c", command) + + return args +} + +// resolveDockerBinary restituisce il path del binario docker. +// Prova prima exec.LookPath (usa PATH corrente), poi i path fissi di macOS. +func resolveDockerBinary() string { + if p, err := exec.LookPath("docker"); err == nil { + return p + } + // path comuni su macOS quando il PATH del processo è minimale + candidates := []string{ + "/usr/local/bin/docker", + "/opt/homebrew/bin/docker", + "/Applications/Docker.app/Contents/Resources/bin/docker", + } + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +// parseContainerIDs split l'output di docker ps -q in una lista di ID. +func parseContainerIDs(out string) []string { + var ids []string + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if line = strings.TrimSpace(line); line != "" { + ids = append(ids, line) + } + } + return ids +} diff --git a/internal/sandbox/manager_test.go b/internal/sandbox/manager_test.go new file mode 100644 index 0000000..e2db2b9 --- /dev/null +++ b/internal/sandbox/manager_test.go @@ -0,0 +1,271 @@ +package sandbox_test + +import ( + "context" + "os/exec" + "strings" + "testing" + "time" + + "github.com/night-agent-cli/night-agent/internal/sandbox" +) + +// dockerAvailable verifica se Docker è installato e il daemon è in esecuzione. +func dockerAvailable() bool { + cmd := exec.Command("docker", "info") + return cmd.Run() == nil +} + +// --- Unit tests (non richiedono Docker) --- + +func TestNew_ReturnsManager(t *testing.T) { + m := sandbox.New() + if m == nil { + t.Fatal("New() ha restituito nil") + } +} + +func TestConfig_Defaults(t *testing.T) { + cfg := sandbox.Config{} + cfg.ApplyDefaults() + + if cfg.Image == "" { + t.Error("ApplyDefaults() deve impostare Image") + } + if cfg.Network == "" { + t.Error("ApplyDefaults() deve impostare Network") + } +} + +func TestConfig_DefaultImage(t *testing.T) { + cfg := sandbox.Config{} + cfg.ApplyDefaults() + + if cfg.Image != sandbox.DefaultImage { + t.Errorf("DefaultImage atteso %q, ottenuto %q", sandbox.DefaultImage, cfg.Image) + } +} + +func TestConfig_DefaultNetwork(t *testing.T) { + cfg := sandbox.Config{} + cfg.ApplyDefaults() + + if cfg.Network != sandbox.DefaultNetwork { + t.Errorf("DefaultNetwork atteso %q, ottenuto %q", sandbox.DefaultNetwork, cfg.Network) + } +} + +func TestConfig_UserValuesNotOverridden(t *testing.T) { + cfg := sandbox.Config{ + Image: "alpine:3.20", + Network: "bridge", + } + cfg.ApplyDefaults() + + if cfg.Image != "alpine:3.20" { + t.Errorf("Image utente non deve essere sovrascritta: ottenuto %q", cfg.Image) + } + if cfg.Network != "bridge" { + t.Errorf("Network utente non deve essere sovrascritto: ottenuto %q", cfg.Network) + } +} + +func TestBuildDockerArgs_ContainsImage(t *testing.T) { + cfg := sandbox.Config{ + Image: "alpine:3.20", + Network: "none", + } + args := sandbox.BuildDockerArgs("echo hello", cfg) + + found := false + for _, a := range args { + if a == "alpine:3.20" { + found = true + break + } + } + if !found { + t.Errorf("args Docker devono contenere l'immagine, ottenuto: %v", args) + } +} + +func TestBuildDockerArgs_ContainsNetwork(t *testing.T) { + cfg := sandbox.Config{ + Image: "alpine:3.20", + Network: "none", + } + args := sandbox.BuildDockerArgs("echo hello", cfg) + + foundFlag := false + foundValue := false + for i, a := range args { + if a == "--network" { + foundFlag = true + if i+1 < len(args) && args[i+1] == "none" { + foundValue = true + } + } + } + if !foundFlag || !foundValue { + t.Errorf("args devono contenere --network none, ottenuto: %v", args) + } +} + +func TestBuildDockerArgs_ContainsRm(t *testing.T) { + cfg := sandbox.Config{Image: "alpine:3.20", Network: "none"} + args := sandbox.BuildDockerArgs("echo hello", cfg) + + found := false + for _, a := range args { + if a == "--rm" { + found = true + break + } + } + if !found { + t.Errorf("args devono contenere --rm per pulizia automatica: %v", args) + } +} + +func TestBuildDockerArgs_WithWorkDir(t *testing.T) { + cfg := sandbox.Config{ + Image: "alpine:3.20", + Network: "none", + WorkDir: "/tmp/myproject", + } + args := sandbox.BuildDockerArgs("ls", cfg) + + foundMount := false + for _, a := range args { + if a == "/tmp/myproject:/workspace:rw" { + foundMount = true + break + } + } + if !foundMount { + t.Errorf("args devono contenere il mount workspace, ottenuto: %v", args) + } +} + +func TestBuildDockerArgs_WithoutWorkDir_NoWorkspaceMount(t *testing.T) { + cfg := sandbox.Config{Image: "alpine:3.20", Network: "none"} + args := sandbox.BuildDockerArgs("ls", cfg) + + // senza WorkDir non deve esserci il mount /workspace + for i, a := range args { + if a == "-v" && i+1 < len(args) && strings.Contains(args[i+1], "/workspace") { + t.Errorf("senza WorkDir non deve esserci mount /workspace: %v", args) + } + } +} + +func TestBuildDockerArgs_CommandWrappedInShell(t *testing.T) { + cfg := sandbox.Config{Image: "alpine:3.20", Network: "none"} + args := sandbox.BuildDockerArgs("echo hello world", cfg) + + // ultimi 3 elementi devono essere: sh -c "echo hello world" + n := len(args) + if n < 3 || args[n-3] != "sh" || args[n-2] != "-c" || args[n-1] != "echo hello world" { + t.Errorf("comando deve essere wrappato in sh -c, ultimi args: %v", args[max(0, n-3):]) + } +} + +// --- Integration tests (richiedono Docker attivo) --- + +func TestExecute_SimpleCommand(t *testing.T) { + if !dockerAvailable() { + t.Skip("Docker non disponibile, skip integration test") + } + + m := sandbox.New() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + cfg := sandbox.Config{ + Image: "alpine:3.20", + Network: "none", + } + + result, err := m.Execute(ctx, "echo hello", cfg) + if err != nil { + t.Fatalf("Execute() errore inatteso: %v", err) + } + if result.ExitCode != 0 { + t.Errorf("ExitCode atteso 0, ottenuto %d", result.ExitCode) + } + if result.Stdout != "hello\n" { + t.Errorf("Stdout atteso %q, ottenuto %q", "hello\n", result.Stdout) + } +} + +func TestExecute_CapturesExitCode(t *testing.T) { + if !dockerAvailable() { + t.Skip("Docker non disponibile, skip integration test") + } + + m := sandbox.New() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + cfg := sandbox.Config{Image: "alpine:3.20", Network: "none"} + result, err := m.Execute(ctx, "exit 42", cfg) + if err != nil { + t.Fatalf("Execute() errore inatteso: %v", err) + } + if result.ExitCode != 42 { + t.Errorf("ExitCode atteso 42, ottenuto %d", result.ExitCode) + } +} + +func TestExecute_CapturesStderr(t *testing.T) { + if !dockerAvailable() { + t.Skip("Docker non disponibile, skip integration test") + } + + m := sandbox.New() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + cfg := sandbox.Config{Image: "alpine:3.20", Network: "none"} + result, err := m.Execute(ctx, "echo errore >&2", cfg) + if err != nil { + t.Fatalf("Execute() errore inatteso: %v", err) + } + if result.Stderr != "errore\n" { + t.Errorf("Stderr atteso %q, ottenuto %q", "errore\n", result.Stderr) + } +} + +func TestExecute_NetworkIsolated(t *testing.T) { + if !dockerAvailable() { + t.Skip("Docker non disponibile, skip integration test") + } + + m := sandbox.New() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // Con network "none" non deve poter fare ping esterno + cfg := sandbox.Config{Image: "alpine:3.20", Network: "none"} + result, err := m.Execute(ctx, "ping -c 1 -W 1 8.8.8.8", cfg) + if err != nil { + t.Fatalf("Execute() errore inatteso: %v", err) + } + if result.ExitCode == 0 { + t.Error("ping verso esterno non deve riuscire con network=none") + } +} + +func TestIsAvailable_ReturnsBool(t *testing.T) { + m := sandbox.New() + // non testiamo il valore (dipende dall'ambiente), solo che non panica + _ = m.IsAvailable() +} + +// max è helper per Go < 1.21 +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go new file mode 100644 index 0000000..bc0f0c2 --- /dev/null +++ b/internal/sandbox/profile.go @@ -0,0 +1,90 @@ +package sandbox + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// ProfileFileName è il nome del file di configurazione sandbox per progetto. +const ProfileFileName = ".night-agent.yaml" + +// Profile contiene la configurazione sandbox specifica per un progetto. +// Viene letta da /.guardian.yaml e ha priorità sui default globali, +// ma le SandboxConfig delle singole regole hanno priorità sul profilo. +type Profile struct { + DefaultImage string `yaml:"default_image"` + DefaultNetwork string `yaml:"default_network"` + Mounts []ProfileMount `yaml:"mounts"` + Env []string `yaml:"env"` +} + +// ProfileMount descrive un mount aggiuntivo oltre al workspace principale. +type ProfileMount struct { + Source string `yaml:"source"` // path relativo o assoluto sull'host + Target string `yaml:"target"` // path nel container + Readonly bool `yaml:"readonly"` // true → montato :ro +} + +// profileFile è la struttura YAML con la chiave radice "sandbox". +type profileFile struct { + Sandbox *Profile `yaml:"sandbox"` +} + +// LoadProfile carica il profilo sandbox da /.guardian.yaml. +// Restituisce nil, nil se il file non esiste (nessun profilo di progetto). +func LoadProfile(workDir string) (*Profile, error) { + path := filepath.Join(workDir, ProfileFileName) + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("errore lettura %s: %w", ProfileFileName, err) + } + + var f profileFile + if err := yaml.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("YAML non valido in %s: %w", ProfileFileName, err) + } + + return f.Sandbox, nil +} + +// MergeConfig unisce la Config della regola con il profilo di progetto. +// Ordine di priorità (dal più alto): regola > profilo > default globale. +func MergeConfig(cfg Config, profile *Profile) Config { + if profile == nil { + return cfg + } + + // image e network: il profilo si applica solo se la regola non ha preferenze + if cfg.Image == "" && profile.DefaultImage != "" { + cfg.Image = profile.DefaultImage + } + if cfg.Network == "" && profile.DefaultNetwork != "" { + cfg.Network = profile.DefaultNetwork + } + + // env: aggiunti sempre dal profilo + cfg.Env = append(cfg.Env, profile.Env...) + + // mount extra dal profilo → convertiti in Mount + for _, pm := range profile.Mounts { + source := pm.Source + // path relativi risolti rispetto al workDir (se disponibile) + if cfg.WorkDir != "" && !filepath.IsAbs(source) { + source = filepath.Join(cfg.WorkDir, source) + } + cfg.ExtraMounts = append(cfg.ExtraMounts, Mount{ + Source: source, + Target: pm.Target, + Readonly: pm.Readonly, + }) + } + + return cfg +} diff --git a/internal/sandbox/profile_test.go b/internal/sandbox/profile_test.go new file mode 100644 index 0000000..f384988 --- /dev/null +++ b/internal/sandbox/profile_test.go @@ -0,0 +1,218 @@ +package sandbox_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/night-agent-cli/night-agent/internal/sandbox" +) + +// --- TestLoadProfile --- + +func TestLoadProfile_FileNotFound_ReturnsNil(t *testing.T) { + profile, err := sandbox.LoadProfile("/nonexistent/dir") + if err != nil { + t.Fatalf("atteso nil error per file mancante, ottenuto: %v", err) + } + if profile != nil { + t.Error("atteso nil profile quando .guardian.yaml non esiste") + } +} + +func TestLoadProfile_ValidFile(t *testing.T) { + dir := t.TempDir() + content := `sandbox: + default_image: "node:20-alpine" + default_network: "bridge" + env: + - "NODE_ENV=test" +` + writeGuardianYAML(t, dir, content) + + profile, err := sandbox.LoadProfile(dir) + if err != nil { + t.Fatalf("errore caricamento profilo: %v", err) + } + if profile == nil { + t.Fatal("atteso profilo non nil") + } + if profile.DefaultImage != "node:20-alpine" { + t.Errorf("DefaultImage: atteso %q, ottenuto %q", "node:20-alpine", profile.DefaultImage) + } + if profile.DefaultNetwork != "bridge" { + t.Errorf("DefaultNetwork: atteso %q, ottenuto %q", "bridge", profile.DefaultNetwork) + } + if len(profile.Env) != 1 || profile.Env[0] != "NODE_ENV=test" { + t.Errorf("Env: atteso [NODE_ENV=test], ottenuto %v", profile.Env) + } +} + +func TestLoadProfile_InvalidYAML(t *testing.T) { + dir := t.TempDir() + writeGuardianYAML(t, dir, "sandbox: {invalid yaml [[[") + + _, err := sandbox.LoadProfile(dir) + if err == nil { + t.Fatal("atteso errore per YAML non valido") + } +} + +func TestLoadProfile_WithMounts(t *testing.T) { + dir := t.TempDir() + content := `sandbox: + default_image: "alpine:3.20" + default_network: "none" + mounts: + - source: "./data" + target: "/data" + readonly: true + - source: "./config" + target: "/config" + readonly: false +` + writeGuardianYAML(t, dir, content) + + profile, err := sandbox.LoadProfile(dir) + if err != nil { + t.Fatalf("errore: %v", err) + } + if len(profile.Mounts) != 2 { + t.Fatalf("attesi 2 mount, ottenuti %d", len(profile.Mounts)) + } + if profile.Mounts[0].Source != "./data" { + t.Errorf("Mount[0].Source: atteso %q, ottenuto %q", "./data", profile.Mounts[0].Source) + } + if !profile.Mounts[0].Readonly { + t.Error("Mount[0].Readonly: atteso true") + } +} + +// --- TestMergeConfig --- + +func TestMergeConfig_NoProfile_ConfigUnchanged(t *testing.T) { + cfg := sandbox.Config{Image: "alpine:3.20", Network: "none"} + merged := sandbox.MergeConfig(cfg, nil) + + if merged.Image != "alpine:3.20" { + t.Errorf("Image: atteso %q, ottenuto %q", "alpine:3.20", merged.Image) + } +} + +func TestMergeConfig_ProfileDefaultsApplied(t *testing.T) { + cfg := sandbox.Config{} // nessuna preferenza dalla regola + profile := &sandbox.Profile{ + DefaultImage: "node:20-alpine", + DefaultNetwork: "bridge", + } + merged := sandbox.MergeConfig(cfg, profile) + + if merged.Image != "node:20-alpine" { + t.Errorf("Image: atteso profilo %q, ottenuto %q", "node:20-alpine", merged.Image) + } + if merged.Network != "bridge" { + t.Errorf("Network: atteso profilo %q, ottenuto %q", "bridge", merged.Network) + } +} + +func TestMergeConfig_RuleOverridesProfile(t *testing.T) { + cfg := sandbox.Config{Image: "python:3.12-alpine", Network: "none"} + profile := &sandbox.Profile{ + DefaultImage: "node:20-alpine", + DefaultNetwork: "bridge", + } + merged := sandbox.MergeConfig(cfg, profile) + + // la regola ha priorità sul profilo + if merged.Image != "python:3.12-alpine" { + t.Errorf("Image: la regola deve avere priorità, ottenuto %q", merged.Image) + } + if merged.Network != "none" { + t.Errorf("Network: la regola deve avere priorità, ottenuto %q", merged.Network) + } +} + +func TestMergeConfig_EnvFromProfileAppended(t *testing.T) { + cfg := sandbox.Config{Image: "alpine:3.20", Network: "none"} + profile := &sandbox.Profile{ + DefaultImage: "alpine:3.20", + Env: []string{"NODE_ENV=test", "DEBUG=1"}, + } + merged := sandbox.MergeConfig(cfg, profile) + + if len(merged.Env) != 2 { + t.Errorf("Env: attesi 2 elementi, ottenuti %d", len(merged.Env)) + } +} + +func TestMergeConfig_ExtraMountsFromProfileAppended(t *testing.T) { + cfg := sandbox.Config{Image: "alpine:3.20", Network: "none", WorkDir: "/tmp/proj"} + profile := &sandbox.Profile{ + DefaultImage: "alpine:3.20", + Mounts: []sandbox.ProfileMount{ + {Source: "./data", Target: "/data", Readonly: true}, + }, + } + merged := sandbox.MergeConfig(cfg, profile) + + if len(merged.ExtraMounts) != 1 { + t.Errorf("ExtraMounts: atteso 1, ottenuto %d", len(merged.ExtraMounts)) + } + if merged.ExtraMounts[0].Target != "/data" { + t.Errorf("ExtraMounts[0].Target: atteso /data, ottenuto %q", merged.ExtraMounts[0].Target) + } + if !merged.ExtraMounts[0].Readonly { + t.Error("ExtraMounts[0].Readonly: atteso true") + } +} + +// --- TestBuildDockerArgs con ExtraMounts --- + +func TestBuildDockerArgs_ExtraMountsReadOnly(t *testing.T) { + cfg := sandbox.Config{ + Image: "alpine:3.20", + Network: "none", + WorkDir: "/tmp/proj", + ExtraMounts: []sandbox.Mount{ + {Source: "/tmp/proj/data", Target: "/data", Readonly: true}, + }, + } + args := sandbox.BuildDockerArgs("ls /data", cfg) + + found := false + for _, a := range args { + if a == "/tmp/proj/data:/data:ro" { + found = true + break + } + } + if !found { + t.Errorf("atteso mount read-only /tmp/proj/data:/data:ro, ottenuto: %v", args) + } +} + +func TestBuildDockerArgs_ContainsGuardianLabel(t *testing.T) { + cfg := sandbox.Config{Image: "alpine:3.20", Network: "none"} + args := sandbox.BuildDockerArgs("echo hello", cfg) + + foundLabel := false + for i, a := range args { + if a == "--label" && i+1 < len(args) && args[i+1] == "guardian.sandbox=true" { + foundLabel = true + break + } + } + if !foundLabel { + t.Errorf("args devono contenere --label guardian.sandbox=true, ottenuto: %v", args) + } +} + +// --- helpers --- + +func writeGuardianYAML(t *testing.T, dir, content string) { + t.Helper() + path := filepath.Join(dir, sandbox.ProfileFileName) + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("errore scrittura .guardian.yaml: %v", err) + } +} diff --git a/internal/scorer/scorer.go b/internal/scorer/scorer.go new file mode 100644 index 0000000..9926c9b --- /dev/null +++ b/internal/scorer/scorer.go @@ -0,0 +1,208 @@ +// Package scorer implementa il risk scoring contestuale (Cycle 3). +// Le decisioni deterministiche del policy engine hanno sempre priorità; +// il score è un segnale aggiuntivo per alzare o abbassare il livello di +// attenzione su azioni che le regole hard non coprono esattamente. +package scorer + +import ( + "strings" + "time" + + "github.com/night-agent-cli/night-agent/internal/audit" +) + +// RiskLevel rappresenta il livello di rischio calcolato dallo scorer. +type RiskLevel string + +const ( + LevelLow RiskLevel = "low" + LevelMedium RiskLevel = "medium" + LevelHigh RiskLevel = "high" +) + +// Action descrive l'azione da valutare. +type Action struct { + Type string + Command string + Path string + WorkDir string +} + +// Result è l'output dello scorer per una singola azione. +type Result struct { + Score float64 // [0.0 – 1.0] + Level RiskLevel + Signals []string // segnali che hanno contribuito al punteggio + AnomalyDetected bool // true se rilevato burst anomalo di azioni +} + +// Scorer valuta il rischio contestuale di un'azione. +type Scorer struct{} + +// New crea uno Scorer. +func New() *Scorer { return &Scorer{} } + +// Score calcola il rischio di action considerando la storia recente events. +// Formula: score = sum(pesi segnali) clamped a [0.0, 1.0] +// La decisione finale del daemon resta deterministica (policy hard rules); +// il score alimenta segnali aggiuntivi nel log e le suggestions. +func (s *Scorer) Score(action Action, events []audit.Event) Result { + var total float64 + var signals []string + + // --- Segnali sul comando --- + + cmd := strings.ToLower(action.Command) + + // sudo: alta pericolosità + if strings.Contains(cmd, "sudo ") { + total += 0.5 + signals = append(signals, "comando con sudo") + } + + // curl/wget piped a shell: molto pericoloso + if (strings.Contains(cmd, "curl ") || strings.Contains(cmd, "wget ")) && + (strings.Contains(cmd, "| bash") || strings.Contains(cmd, "| sh") || strings.Contains(cmd, "|bash") || strings.Contains(cmd, "|sh")) { + total += 0.7 + signals = append(signals, "script remoto eseguito via pipe") + } + + // rm con flag ricorsivo + if strings.Contains(cmd, "rm ") && strings.Contains(cmd, "-r") { + total += 0.3 + signals = append(signals, "rm ricorsivo") + } + + // chmod molto permissivo (777, 755 su path non di progetto) + if strings.Contains(cmd, "chmod ") && strings.Contains(cmd, "777") { + total += 0.3 + signals = append(signals, "chmod 777") + } + + // git force push + if action.Type == "git" && + (strings.Contains(cmd, "--force") || strings.Contains(cmd, " -f ")) { + total += 0.5 + signals = append(signals, "git force push") + } + + // git push su branch protetto + if action.Type == "git" && + (strings.Contains(cmd, "push origin main") || strings.Contains(cmd, "push origin master")) { + total += 0.2 + signals = append(signals, "push su branch principale") + } + + // accesso a path sensibili + sensitivePaths := []string{".env", ".aws", ".ssh", "id_rsa", "credentials", "secrets", "token"} + targetStr := strings.ToLower(action.Command + " " + action.Path) + for _, sp := range sensitivePaths { + if strings.Contains(targetStr, sp) { + total += 0.3 + signals = append(signals, "accesso path sensibile: "+sp) + break + } + } + + // install di pacchetti: medio rischio + pkgManagers := []string{"pip install", "pip3 install", "npm install", "yarn add", "brew install", "apt install", "apt-get install"} + for _, pm := range pkgManagers { + if strings.Contains(cmd, pm) { + total += 0.15 + signals = append(signals, "installazione pacchetto: "+pm) + break + } + } + + // script shell generici + if (strings.HasPrefix(cmd, "bash ") || strings.HasPrefix(cmd, "sh ")) && strings.HasSuffix(strings.Fields(cmd)[len(strings.Fields(cmd))-1], ".sh") { + total += 0.2 + signals = append(signals, "esecuzione script shell") + } + + // --- Segnali contestuali (storia eventi) --- + + anomaly, anomalySignals := detectAnomalies(events) + if anomaly { + total += 0.25 + signals = append(signals, anomalySignals...) + } + + // --- Clamp e livello --- + + if total > 1.0 { + total = 1.0 + } + + return Result{ + Score: total, + Level: LevelFromScore(total), + Signals: signals, + AnomalyDetected: anomaly, + } +} + +// LevelFromScore converte un punteggio numerico in RiskLevel. +// < 0.3 → low, 0.3–0.7 → medium, ≥ 0.7 → high +func LevelFromScore(score float64) RiskLevel { + switch { + case score >= 0.7: + return LevelHigh + case score >= 0.3: + return LevelMedium + default: + return LevelLow + } +} + +// detectAnomalies analizza la storia recente degli eventi per rilevare +// pattern anomali: burst di azioni, sequenze di blocchi ravvicinati, ecc. +func detectAnomalies(events []audit.Event) (bool, []string) { + if len(events) == 0 { + return false, nil + } + + var signals []string + anomaly := false + + now := time.Now() + window := 30 * time.Second + + // Burst: >10 azioni nei 30 secondi precedenti + var recentCount int + var recentBlocks int + for _, e := range events { + if now.Sub(e.Timestamp) <= window { + recentCount++ + if e.Decision == "block" { + recentBlocks++ + } + } + } + + if recentCount > 10 { + anomaly = true + signals = append(signals, "burst anomalo: "+itoa(recentCount)+" azioni in 30s") + } + + // Sequenza di blocchi: ≥3 blocchi recenti + if recentBlocks >= 3 { + anomaly = true + signals = append(signals, itoa(recentBlocks)+" blocchi nelle ultime azioni") + } + + return anomaly, signals +} + +// itoa converte int in stringa senza import "strconv" per non appesantire le dipendenze. +func itoa(n int) string { + if n == 0 { + return "0" + } + buf := make([]byte, 0, 10) + for n > 0 { + buf = append([]byte{byte('0' + n%10)}, buf...) + n /= 10 + } + return string(buf) +} diff --git a/internal/scorer/scorer_test.go b/internal/scorer/scorer_test.go new file mode 100644 index 0000000..8b3f5a4 --- /dev/null +++ b/internal/scorer/scorer_test.go @@ -0,0 +1,166 @@ +package scorer_test + +import ( + "testing" + "time" + + "github.com/night-agent-cli/night-agent/internal/audit" + "github.com/night-agent-cli/night-agent/internal/scorer" +) + +func TestScore_AllowLowRisk(t *testing.T) { + s := scorer.New() + events := []audit.Event{} + + action := scorer.Action{ + Type: "shell", + Command: "go build ./...", + WorkDir: "/home/user/project", + } + + result := s.Score(action, events) + if result.Score >= 0.3 { + t.Errorf("expected low risk score, got %.2f", result.Score) + } + if result.Level != scorer.LevelLow { + t.Errorf("expected level low, got %s", result.Level) + } +} + +func TestScore_SudoHighRisk(t *testing.T) { + s := scorer.New() + events := []audit.Event{} + + action := scorer.Action{ + Type: "shell", + Command: "sudo rm -rf /var/log", + WorkDir: "/home/user/project", + } + + result := s.Score(action, events) + if result.Score < 0.7 { + t.Errorf("expected high risk score, got %.2f", result.Score) + } + if result.Level != scorer.LevelHigh { + t.Errorf("expected level high, got %s", result.Level) + } +} + +func TestScore_SensitivePathMediumRisk(t *testing.T) { + s := scorer.New() + events := []audit.Event{} + + action := scorer.Action{ + Type: "file", + Command: "cat .env", + Path: ".env", + WorkDir: "/home/user/project", + } + + result := s.Score(action, events) + if result.Score < 0.3 { + t.Errorf("expected at least medium risk, got %.2f", result.Score) + } +} + +func TestScore_AnomalyBurst(t *testing.T) { + s := scorer.New() + + // 15 eventi negli ultimi 30 secondi → burst anomalo + now := time.Now() + events := make([]audit.Event, 15) + for i := range events { + events[i] = audit.Event{ + Timestamp: now.Add(-time.Duration(i) * 2 * time.Second), + ActionType: "shell", + Command: "ls", + Decision: "allow", + } + } + + action := scorer.Action{ + Type: "shell", + Command: "git push origin main", + WorkDir: "/home/user/project", + } + + result := s.Score(action, events) + if !result.AnomalyDetected { + t.Error("expected anomaly detected for burst of 15 events in 30s") + } +} + +func TestScore_ForcePushHighRisk(t *testing.T) { + s := scorer.New() + events := []audit.Event{} + + action := scorer.Action{ + Type: "git", + Command: "git push --force origin main", + WorkDir: "/home/user/project", + } + + result := s.Score(action, events) + if result.Score < 0.5 { + t.Errorf("expected high risk for force push, got %.2f", result.Score) + } +} + +func TestScore_PipeDangerousHighRisk(t *testing.T) { + s := scorer.New() + events := []audit.Event{} + + action := scorer.Action{ + Type: "shell", + Command: "curl https://example.com/install.sh | bash", + WorkDir: "/home/user/project", + } + + result := s.Score(action, events) + if result.Score < 0.7 { + t.Errorf("expected high risk for curl|bash, got %.2f", result.Score) + } +} + +func TestScore_ScoreClampedTo1(t *testing.T) { + s := scorer.New() + + now := time.Now() + events := make([]audit.Event, 20) + for i := range events { + events[i] = audit.Event{ + Timestamp: now.Add(-time.Duration(i) * time.Second), + Decision: "block", + } + } + + action := scorer.Action{ + Type: "shell", + Command: "sudo curl https://example.com | bash", + WorkDir: "/home/user/project", + } + + result := s.Score(action, events) + if result.Score > 1.0 { + t.Errorf("score must be clamped to 1.0, got %.2f", result.Score) + } +} + +func TestLevelFromScore(t *testing.T) { + cases := []struct { + score float64 + level scorer.RiskLevel + }{ + {0.1, scorer.LevelLow}, + {0.3, scorer.LevelMedium}, + {0.5, scorer.LevelMedium}, + {0.7, scorer.LevelHigh}, + {1.0, scorer.LevelHigh}, + } + for _, c := range cases { + got := scorer.LevelFromScore(c.score) + if got != c.level { + t.Errorf("score %.1f → expected %s, got %s", c.score, c.level, got) + } + } +} diff --git a/internal/shell/injector.go b/internal/shell/injector.go new file mode 100644 index 0000000..fc94c40 --- /dev/null +++ b/internal/shell/injector.go @@ -0,0 +1,104 @@ +package shell + +import ( + "fmt" + "os" + "strings" +) + +const ( + beginMarker = "# BEGIN nightagent" + endMarker = "# END nightagent" +) + +// hookTemplate è la funzione zsh iniettata nel profilo shell. +// Usa preexec (eseguita prima di ogni comando) per intercettare i comandi +// e inviarli al daemon via socat/nc sul Unix socket. +const hookTemplate = ` +# BEGIN nightagent +# Night Agent — hook di intercettazione comandi (non modificare manualmente) +_nightagent_socket="%s" +_nightagent_preexec() { + local cmd="$1" + local workdir="$(pwd)" + if [[ -S "$_nightagent_socket" ]]; then + local payload="{\"command\":$(printf '%%s' "$cmd" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'),\"work_dir\":\"$workdir\",\"agent_name\":\"\"}" + local response + response=$(echo "$payload" | nc -U "$_nightagent_socket" 2>/dev/null) + local decision + decision=$(echo "$response" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("decision","allow"))' 2>/dev/null) + if [[ "$decision" == "block" ]]; then + local reason + reason=$(echo "$response" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("reason",""))' 2>/dev/null) + echo "nightagent: comando bloccato — $reason" >&2 + return 1 + fi + if [[ "$decision" == "sandbox" ]]; then + local reason + reason=$(echo "$response" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("reason",""))' 2>/dev/null) + echo "nightagent: esecuzione in sandbox — $reason" >&2 + local output + output=$(echo "$response" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("output",""), end="")' 2>/dev/null) + [[ -n "$output" ]] && echo "$output" + return 1 + fi + fi +} +autoload -Uz add-zsh-hook +add-zsh-hook preexec _nightagent_preexec +# END nightagent +` + +// Inject aggiunge l'hook nightagent al file di profilo shell specificato. +// L'operazione è idempotente: se l'hook è già presente non viene duplicato. +// Restituisce (true, nil) se l'hook è stato iniettato ora, +// (false, nil) se era già presente, (false, err) in caso di errore. +func Inject(rcPath, socketPath string) (bool, error) { + content, err := os.ReadFile(rcPath) + if err != nil { + return false, fmt.Errorf("impossibile leggere %s: %w", rcPath, err) + } + + if strings.Contains(string(content), beginMarker) { + return false, nil // già iniettato + } + + hook := fmt.Sprintf(hookTemplate, socketPath) + updated := string(content) + hook + + return true, os.WriteFile(rcPath, []byte(updated), 0600) +} + +// Remove elimina il blocco nightagent dal file di profilo shell. +func Remove(rcPath string) error { + content, err := os.ReadFile(rcPath) + if err != nil { + return fmt.Errorf("impossibile leggere %s: %w", rcPath, err) + } + + s := string(content) + start := strings.Index(s, beginMarker) + end := strings.Index(s, endMarker) + + if start == -1 || end == -1 { + return nil // nessun hook da rimuovere + } + + end += len(endMarker) + // rimuovi anche l'eventuale newline dopo il marker di chiusura + if end < len(s) && s[end] == '\n' { + end++ + } + + updated := s[:start] + s[end:] + return os.WriteFile(rcPath, []byte(updated), 0600) +} + +// IsInjected verifica se l'hook nightagent è già presente nel file. +func IsInjected(rcPath string) bool { + content, err := os.ReadFile(rcPath) + if err != nil { + return false + } + return strings.Contains(string(content), beginMarker) +} diff --git a/internal/shell/injector_test.go b/internal/shell/injector_test.go new file mode 100644 index 0000000..6d65038 --- /dev/null +++ b/internal/shell/injector_test.go @@ -0,0 +1,116 @@ +package shell_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/night-agent-cli/night-agent/internal/shell" +) + +const testSocketPath = "/tmp/nightagent-test.sock" + +func TestInject_AddsHookToZshrc(t *testing.T) { + zshrc := writeTempRC(t, "# existing config\nexport PATH=$PATH:/usr/local/bin\n") + + injected, err := shell.Inject(zshrc, testSocketPath) + if err != nil { + t.Fatalf("errore iniezione: %v", err) + } + if !injected { + t.Error("atteso injected=true alla prima iniezione") + } + + content, _ := os.ReadFile(zshrc) + if !strings.Contains(string(content), "nightagent") { + t.Error("atteso hook nightagent nel file") + } + if !strings.Contains(string(content), "preexec") { + t.Error("atteso uso di preexec nell'hook") + } + // contenuto originale preservato + if !strings.Contains(string(content), "existing config") { + t.Error("il contenuto originale deve essere preservato") + } +} + +func TestInject_Idempotent(t *testing.T) { + zshrc := writeTempRC(t, "# existing config\n") + + if _, err := shell.Inject(zshrc, testSocketPath); err != nil { + t.Fatalf("prima iniezione fallita: %v", err) + } + injected, err := shell.Inject(zshrc, testSocketPath) + if err != nil { + t.Fatalf("seconda iniezione fallita: %v", err) + } + if injected { + t.Error("atteso injected=false alla seconda iniezione (già presente)") + } + + content, _ := os.ReadFile(zshrc) + count := strings.Count(string(content), "# BEGIN nightagent") + if count != 1 { + t.Errorf("atteso 1 blocco nightagent, trovati %d", count) + } +} + +func TestInject_FileNotFound(t *testing.T) { + _, err := shell.Inject("/nonexistent/path/.zshrc", testSocketPath) + if err == nil { + t.Fatal("atteso errore per file mancante, ottenuto nil") + } +} + +func TestRemove_RemovesHookFromZshrc(t *testing.T) { + zshrc := writeTempRC(t, "# existing config\n") + + _, _ = shell.Inject(zshrc, testSocketPath) + if err := shell.Remove(zshrc); err != nil { + t.Fatalf("errore rimozione: %v", err) + } + + content, _ := os.ReadFile(zshrc) + if strings.Contains(string(content), "nightagent") { + t.Error("nightagent non dovrebbe essere presente dopo la rimozione") + } + if !strings.Contains(string(content), "existing config") { + t.Error("il contenuto originale deve essere preservato dopo la rimozione") + } +} + +func TestRemove_NoopIfNotInjected(t *testing.T) { + zshrc := writeTempRC(t, "# existing config\n") + + if err := shell.Remove(zshrc); err != nil { + t.Fatalf("rimozione su file senza hook non deve fallire: %v", err) + } +} + +func TestIsInjected_True(t *testing.T) { + zshrc := writeTempRC(t, "") + _, _ = shell.Inject(zshrc, testSocketPath) + + if !shell.IsInjected(zshrc) { + t.Error("atteso IsInjected=true dopo l'iniezione") + } +} + +func TestIsInjected_False(t *testing.T) { + zshrc := writeTempRC(t, "# clean config\n") + + if shell.IsInjected(zshrc) { + t.Error("atteso IsInjected=false su file senza hook") + } +} + +func writeTempRC(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + f := filepath.Join(dir, ".zshrc") + if err := os.WriteFile(f, []byte(content), 0600); err != nil { + t.Fatalf("errore scrittura file temporaneo: %v", err) + } + return f +} diff --git a/internal/shim/csrc/guardian_shim.c b/internal/shim/csrc/guardian_shim.c new file mode 100644 index 0000000..4a32205 --- /dev/null +++ b/internal/shim/csrc/guardian_shim.c @@ -0,0 +1,189 @@ +#include +#include +#include + +#ifdef _WIN32 + #include + #include + #include + #include + #define getcwd _getcwd + #define access _access + #define X_OK 0 + // Winsock per i socket + #include + #include + #include // Per AF_UNIX su Windows moderno + #pragma comment(lib, "ws2_32.lib") + + // Sostituto per basename + static char *win_basename(char *path) { + char *p = strrchr(path, '\\'); + if (!p) p = strrchr(path, '/'); + return p ? p + 1 : path; + } + #define basename win_basename + #define PATH_SEP ";" +#else + #include + #include + #include + #include + #define PATH_SEP ":" +#endif + +#define SOCKET_ENV "GUARDIAN_SOCKET" +#define SHIM_DIR_ENV "GUARDIAN_SHIM_DIR" +#define MAX_CMD 8192 +#define MAX_RESP 4096 + +#define GUARDIAN_ALLOW 1 +#define GUARDIAN_BLOCK 0 +#define GUARDIAN_SANDBOX (-1) + +// --- Helper Functions (parse_reason, parse_exit_code rimangono identiche) --- +static char *parse_reason(const char *resp, char *buf, size_t buf_size) { + buf[0] = '\0'; + const char *key = "\"reason\":\""; + const char *p = strstr(resp, key); + if (!p) return buf; + p += strlen(key); + size_t i = 0; + while (*p && *p != '"' && i < buf_size - 1) { + if (*p == '\\' && *(p+1) == '"') { buf[i++] = '"'; p += 2; } + else if (*p == '\\' && *(p+1) == 'n') { buf[i++] = ' '; p += 2; } + else { buf[i++] = *p++; } + } + buf[i] = '\0'; + return buf; +} + +static int parse_exit_code(const char *resp) { + const char *key = "\"exit_code\":"; + const char *p = strstr(resp, key); + if (!p) return 0; + p += strlen(key); + while (*p == ' ') p++; + return atoi(p); +} + +// --- Guardian Check (Versione Windows/Unix) --- +static int guardian_check(const char *socket_path, const char *command, int *out_exit_code, + char *resp_buf, size_t resp_buf_size) { + *out_exit_code = 0; + +#ifdef _WIN32 + WSADATA wsaData; + WSAStartup(MAKEWORD(2, 2), &wsaData); +#endif + + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return GUARDIAN_BLOCK; + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, socket_path, sizeof(addr.sun_path) - 1); + + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { +#ifdef _WIN32 + closesocket(fd); + WSACleanup(); +#else + close(fd); +#endif + return GUARDIAN_BLOCK; + } + + // Costruzione richiesta JSON (semplificata) + char workdir[4096]; + getcwd(workdir, sizeof(workdir)); + const char *agent_name = getenv("NIGHTAGENT_AGENT") ? getenv("NIGHTAGENT_AGENT") : "shim"; + + char req[MAX_CMD + 1024]; + snprintf(req, sizeof(req), "{\"command\":\"%s\",\"work_dir\":\"%s\",\"agent_name\":\"%s\"}\n", + command, workdir, agent_name); + + send(fd, req, strlen(req), 0); + + char resp[MAX_RESP]; + int n = recv(fd, resp, sizeof(resp) - 1, 0); + if (n > 0) resp[n] = '\0'; + +#ifdef _WIN32 + closesocket(fd); + WSACleanup(); +#else + close(fd); +#endif + + if (n <= 0) return GUARDIAN_BLOCK; + if (resp_buf) strncpy(resp_buf, resp, resp_buf_size); + + if (strstr(resp, "\"allow\"")) return GUARDIAN_ALLOW; + return GUARDIAN_BLOCK; +} + +static char *find_real_binary(const char *cmdname, const char *shim_dir) { + const char *path_env = getenv("PATH"); + if (!path_env) return NULL; + + char *path_copy = strdup(path_env); + static char result[4096]; + char *dir = strtok(path_copy, PATH_SEP); + while (dir) { + if (shim_dir == NULL || strcmp(dir, shim_dir) != 0) { + // Prova con .exe e senza + snprintf(result, sizeof(result), "%s/%s", dir, cmdname); + if (access(result, X_OK) == 0) { free(path_copy); return result; } + snprintf(result, sizeof(result), "%s/%s.exe", dir, cmdname); + if (access(result, X_OK) == 0) { free(path_copy); return result; } + } + dir = strtok(NULL, PATH_SEP); + } + free(path_copy); + return NULL; +} + +int main(int argc, char *argv[]) { + char argv0_copy[4096]; + strncpy(argv0_copy, argv[0], sizeof(argv0_copy) - 1); + char *cmdname = basename(argv0_copy); + + char full_cmd[MAX_CMD] = {0}; + for (int i = 0; i < argc; i++) { + strncat(full_cmd, argv[i], sizeof(full_cmd) - strlen(full_cmd) - 1); + if (i < argc - 1) strncat(full_cmd, " ", sizeof(full_cmd) - strlen(full_cmd) - 1); + } + + const char *socket_path = getenv(SOCKET_ENV); + const char *shim_dir = getenv(SHIM_DIR_ENV); + + if (socket_path != NULL) { + int exit_code = 0; + char raw_resp[MAX_RESP] = {0}; + int result = guardian_check(socket_path, full_cmd, &exit_code, raw_resp, sizeof(raw_resp)); + + if (result == GUARDIAN_BLOCK) { + char reason[512]; + parse_reason(raw_resp, reason, sizeof(reason)); + fprintf(stderr, "[guardian] bloccato: %s\n", reason); + return 1; + } + } + + char *real_binary = find_real_binary(cmdname, shim_dir); + if (!real_binary) { + fprintf(stderr, "guardian-shim: %s: command not found\n", cmdname); + return 127; + } + +#ifdef _WIN32 + // Su Windows execvp non sostituisce davvero il processo, usiamo spawnvp + intptr_t ret = _spawnvp(_P_WAIT, real_binary, (const char* const*)argv); + return (int)ret; +#else + execvp(real_binary, argv); + return 1; +#endif +} \ No newline at end of file diff --git a/internal/shim/shim.go b/internal/shim/shim.go new file mode 100644 index 0000000..d974b7b --- /dev/null +++ b/internal/shim/shim.go @@ -0,0 +1,113 @@ +package shim + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// ShimmedCommands è la lista dei comandi intercettati via PATH shim. +// guardian-shim viene installato come symlink con ognuno di questi nomi. +var ShimmedCommands = []string{ + "sudo", "rm", "git", "curl", "wget", + "chmod", "chown", "mv", "cp", + "bash", "sh", "pip", "pip3", + "npm", "brew", + "python", "python3", + "tee", + "chflags", +} + +const ShimBinaryName = "guardian-shim" + +// ShimDir restituisce il path della directory degli shim dato guardianDir. +func ShimDir(guardianDir string) string { + return filepath.Join(guardianDir, "shims") +} + +// PrependPath prepende shimDir alla variabile PATH nell'env dato. +// Se shimDir è già il primo elemento, non duplica. +// Se PATH non esiste, la crea con solo shimDir. +func PrependPath(env []string, shimDir string) []string { + result := make([]string, 0, len(env)+1) + found := false + for _, e := range env { + if strings.HasPrefix(e, "PATH=") { + existing := e[5:] + // evita duplicazione se shimDir è già in prima posizione + if strings.HasPrefix(existing, shimDir+":") || existing == shimDir { + result = append(result, e) + } else { + result = append(result, "PATH="+shimDir+":"+existing) + } + found = true + } else { + result = append(result, e) + } + } + if !found { + result = append(result, "PATH="+shimDir) + } + return result +} + +// CreateSymlinks crea symlink per ogni ShimmedCommand che punta a shimBinaryPath. +// Sovrascrive symlink esistenti. Non tocca file regolari (non-symlink). +func CreateSymlinks(shimDir, shimBinaryPath string) error { + for _, cmd := range ShimmedCommands { + linkPath := filepath.Join(shimDir, cmd) + if info, err := os.Lstat(linkPath); err == nil { + if info.Mode()&os.ModeSymlink != 0 { + if err := os.Remove(linkPath); err != nil { + return fmt.Errorf("impossibile rimuovere symlink esistente %s: %w", linkPath, err) + } + } + // se è un file regolare, lo lasciamo stare (non sovrascriviamo binari reali) + } + if err := os.Symlink(shimBinaryPath, linkPath); err != nil { + return fmt.Errorf("impossibile creare symlink per %s: %w", cmd, err) + } + } + return nil +} + +// Install crea la directory degli shim, copia il binario guardian-shim al suo interno +// e installa le symlink. I symlink puntano al binario copiato nella stessa directory, +// così la shim dir è autocontenuta e indipendente dalla directory di build. +func Install(guardianDir, shimBinaryPath string) error { + shimDirPath := ShimDir(guardianDir) + if err := os.MkdirAll(shimDirPath, 0755); err != nil { + return fmt.Errorf("impossibile creare %s: %w", shimDirPath, err) + } + destBinary := filepath.Join(shimDirPath, ShimBinaryName) + if err := copyFile(shimBinaryPath, destBinary, 0755); err != nil { + return fmt.Errorf("impossibile copiare %s: %w", ShimBinaryName, err) + } + return CreateSymlinks(shimDirPath, destBinary) +} + +func copyFile(src, dst string, mode os.FileMode) error { + // evita di copiare un file su se stesso (O_TRUNC azzererebbe il file sorgente) + srcAbs, _ := filepath.EvalSymlinks(src) + dstAbs, _ := filepath.EvalSymlinks(dst) + if srcAbs != "" && srcAbs == dstAbs { + return nil + } + + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} diff --git a/internal/shim/shim_test.go b/internal/shim/shim_test.go new file mode 100644 index 0000000..e89aa6c --- /dev/null +++ b/internal/shim/shim_test.go @@ -0,0 +1,144 @@ +package shim_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/night-agent-cli/night-agent/internal/shim" +) + +func TestShimDir(t *testing.T) { + dir := shim.ShimDir("/home/user/.guardian") + expected := "/home/user/.guardian/shims" + if dir != expected { + t.Errorf("atteso %s, ottenuto %s", expected, dir) + } +} + +func TestPrependPath_NoExistingPath(t *testing.T) { + shimDir := "/tmp/guardian-shims" + env := []string{"HOME=/home/user"} + result := shim.PrependPath(env, shimDir) + + for _, e := range result { + if e == "PATH="+shimDir { + return + } + } + t.Errorf("PATH=%s non trovato nell'env risultante: %v", shimDir, result) +} + +func TestPrependPath_ExistingPath(t *testing.T) { + shimDir := "/tmp/guardian-shims" + env := []string{"PATH=/usr/bin:/bin"} + result := shim.PrependPath(env, shimDir) + + expected := "PATH=" + shimDir + ":/usr/bin:/bin" + for _, e := range result { + if e == expected { + return + } + } + t.Errorf("atteso %s nell'env, non trovato. env: %v", expected, result) +} + +func TestPrependPath_AlreadyFirst(t *testing.T) { + shimDir := "/tmp/guardian-shims" + env := []string{"PATH=" + shimDir + ":/usr/bin:/bin"} + result := shim.PrependPath(env, shimDir) + + count := 0 + for _, e := range result { + if len(e) > 5 && e[:5] == "PATH=" { + count++ + expected := "PATH=" + shimDir + ":/usr/bin:/bin" + if e != expected { + t.Errorf("atteso %s, ottenuto %s", expected, e) + } + } + } + if count != 1 { + t.Errorf("attesa esattamente 1 occorrenza di PATH, trovate %d", count) + } +} + +func TestPrependPath_PreservesOtherVars(t *testing.T) { + shimDir := "/tmp/guardian-shims" + env := []string{"HOME=/home/user", "PATH=/usr/bin", "TERM=xterm"} + result := shim.PrependPath(env, shimDir) + + foundHome := false + foundTerm := false + for _, e := range result { + if e == "HOME=/home/user" { + foundHome = true + } + if e == "TERM=xterm" { + foundTerm = true + } + } + if !foundHome { + t.Error("HOME non preservata nell'env risultante") + } + if !foundTerm { + t.Error("TERM non preservata nell'env risultante") + } +} + +func TestCreateSymlinks(t *testing.T) { + shimDir := t.TempDir() + fakeBinary := filepath.Join(shimDir, "guardian-shim") + if err := os.WriteFile(fakeBinary, []byte("fake"), 0755); err != nil { + t.Fatal(err) + } + + if err := shim.CreateSymlinks(shimDir, fakeBinary); err != nil { + t.Fatalf("CreateSymlinks fallita: %v", err) + } + + for _, cmd := range shim.ShimmedCommands { + linkPath := filepath.Join(shimDir, cmd) + target, err := os.Readlink(linkPath) + if err != nil { + t.Errorf("symlink per %s non trovata: %v", cmd, err) + continue + } + if target != fakeBinary { + t.Errorf("symlink %s punta a %s invece di %s", cmd, target, fakeBinary) + } + } +} + +func TestCreateSymlinks_Idempotent(t *testing.T) { + shimDir := t.TempDir() + fakeBinary := filepath.Join(shimDir, "guardian-shim") + if err := os.WriteFile(fakeBinary, []byte("fake"), 0755); err != nil { + t.Fatal(err) + } + + // prima installazione + if err := shim.CreateSymlinks(shimDir, fakeBinary); err != nil { + t.Fatalf("prima CreateSymlinks fallita: %v", err) + } + // seconda installazione — deve sovrascrivere senza errori + if err := shim.CreateSymlinks(shimDir, fakeBinary); err != nil { + t.Fatalf("seconda CreateSymlinks fallita: %v", err) + } +} + +func TestShimmedCommands_NotEmpty(t *testing.T) { + if len(shim.ShimmedCommands) == 0 { + t.Error("ShimmedCommands non deve essere vuota") + } + // verifica che sudo e rm siano sempre presenti + found := map[string]bool{} + for _, cmd := range shim.ShimmedCommands { + found[cmd] = true + } + for _, required := range []string{"sudo", "rm", "git", "curl"} { + if !found[required] { + t.Errorf("comando richiesto '%s' non presente in ShimmedCommands", required) + } + } +} diff --git a/internal/suggestions/suggestions.go b/internal/suggestions/suggestions.go new file mode 100644 index 0000000..954090a --- /dev/null +++ b/internal/suggestions/suggestions.go @@ -0,0 +1,105 @@ +// Package suggestions implementa il policy suggestion engine (Cycle 3). +// Analizza l'azione corrente, il risk score e la storia degli eventi per +// suggerire all'utente modifiche utili alla policy. I suggerimenti sono +// informativi — non alterano mai la decisione del daemon. +package suggestions + +import ( + "fmt" + "strings" + + "github.com/night-agent-cli/night-agent/internal/audit" + "github.com/night-agent-cli/night-agent/internal/scorer" +) + +// Engine genera suggerimenti di policy contestuali. +type Engine struct{} + +// New crea un Engine. +func New() *Engine { return &Engine{} } + +// Suggest restituisce una lista di suggerimenti testuali (può essere vuota). +// I suggerimenti vengono inclusi nel log e stampati a terminale quando presenti. +func (e *Engine) Suggest(action scorer.Action, result scorer.Result, events []audit.Event) []string { + var hints []string + + // Nessun suggerimento per rischio basso senza anomalie + if result.Level == scorer.LevelLow && !result.AnomalyDetected { + return nil + } + + // Suggerimento: path sensibile → considera read-only in policy + for _, sig := range result.Signals { + if strings.Contains(sig, "path sensibile") { + path := extractSensitivePath(sig) + hints = append(hints, fmt.Sprintf("considera di aggiungere '%s' come path read-only nella policy", path)) + } + } + + // Suggerimento: comandi ripetuti con override manuale → rendi allow permanente + if countRepeatedOverrides(action.Command, events) >= 3 { + hints = append(hints, fmt.Sprintf("'%s' è stato approvato manualmente più volte — vuoi renderlo allow permanente nella policy?", truncate(action.Command, 50))) + } + + // Suggerimento: anomalia burst → considera sandbox per questo tipo di azione + if result.AnomalyDetected { + hints = append(hints, fmt.Sprintf("rilevato burst anomalo di azioni — considera di eseguire '%s' in sandbox", truncate(action.Command, 40))) + } + + // Suggerimento: rischio alto → suggerisci regola block esplicita + if result.Level == scorer.LevelHigh { + hints = append(hints, fmt.Sprintf("rischio alto rilevato — considera di aggiungere una regola block esplicita per questo pattern")) + } + + // Suggerimento: script remoto via pipe → suggerisci sandbox obbligatoria + for _, sig := range result.Signals { + if strings.Contains(sig, "pipe") { + hints = append(hints, "script eseguito via pipe: considera decision: sandbox nella policy per questo pattern") + } + } + + return deduplicate(hints) +} + +// countRepeatedOverrides conta quante volte command è stato approvato manualmente +// nella storia degli eventi (user_override = true). +func countRepeatedOverrides(command string, events []audit.Event) int { + count := 0 + for _, e := range events { + if e.Command == command && e.UserOverride { + count++ + } + } + return count +} + +// extractSensitivePath estrae il nome del path dal segnale di scorer. +// Formato atteso: "accesso path sensibile: " +func extractSensitivePath(signal string) string { + parts := strings.SplitN(signal, ": ", 2) + if len(parts) == 2 { + return parts[1] + } + return "path" +} + +// truncate accorcia la stringa a maxLen caratteri aggiungendo "..." se necessario. +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +// deduplicate rimuove stringhe duplicate mantenendo l'ordine. +func deduplicate(hints []string) []string { + seen := make(map[string]struct{}, len(hints)) + out := hints[:0] + for _, h := range hints { + if _, ok := seen[h]; !ok { + seen[h] = struct{}{} + out = append(out, h) + } + } + return out +} diff --git a/internal/suggestions/suggestions_test.go b/internal/suggestions/suggestions_test.go new file mode 100644 index 0000000..c123b49 --- /dev/null +++ b/internal/suggestions/suggestions_test.go @@ -0,0 +1,120 @@ +package suggestions_test + +import ( + "strings" + "testing" + + "github.com/night-agent-cli/night-agent/internal/audit" + "github.com/night-agent-cli/night-agent/internal/scorer" + "github.com/night-agent-cli/night-agent/internal/suggestions" +) + +func makeAction(actionType, command, path string) scorer.Action { + return scorer.Action{Type: actionType, Command: command, Path: path, WorkDir: "/home/user/project"} +} + +func TestSuggest_NoSuggestionLowRisk(t *testing.T) { + sg := suggestions.New() + action := makeAction("shell", "go build ./...", "") + result := scorer.Result{Score: 0.1, Level: scorer.LevelLow, Signals: nil} + events := []audit.Event{} + + hints := sg.Suggest(action, result, events) + if len(hints) != 0 { + t.Errorf("expected no suggestions for low risk, got %d: %v", len(hints), hints) + } +} + +func TestSuggest_SensitivePathReadOnly(t *testing.T) { + sg := suggestions.New() + action := makeAction("file", "cat .env", ".env") + result := scorer.Result{ + Score: 0.4, + Level: scorer.LevelMedium, + Signals: []string{"accesso path sensibile: .env"}, + } + events := []audit.Event{} + + hints := sg.Suggest(action, result, events) + found := false + for _, h := range hints { + if strings.Contains(h, "read-only") || strings.Contains(h, ".env") { + found = true + } + } + if !found { + t.Errorf("expected read-only suggestion for .env access, got: %v", hints) + } +} + +func TestSuggest_RepeatedAllowPermanent(t *testing.T) { + sg := suggestions.New() + action := makeAction("shell", "git push origin main", "") + result := scorer.Result{Score: 0.3, Level: scorer.LevelMedium, Signals: []string{"push su branch principale"}} + + // 4 eventi identici già approvati (user_override=true) + events := make([]audit.Event, 4) + for i := range events { + events[i] = audit.Event{ + Command: "git push origin main", + Decision: "allow", + UserOverride: true, + } + } + + hints := sg.Suggest(action, result, events) + found := false + for _, h := range hints { + if strings.Contains(h, "permanente") || strings.Contains(h, "always") || strings.Contains(h, "sempre") { + found = true + } + } + if !found { + t.Errorf("expected permanent allow suggestion after repeated overrides, got: %v", hints) + } +} + +func TestSuggest_AnomalyBurstSandbox(t *testing.T) { + sg := suggestions.New() + action := makeAction("shell", "python3 run.py", "") + result := scorer.Result{ + Score: 0.5, + Level: scorer.LevelMedium, + Signals: []string{"burst anomalo: 15 azioni in 30s"}, + AnomalyDetected: true, + } + events := []audit.Event{} + + hints := sg.Suggest(action, result, events) + found := false + for _, h := range hints { + if strings.Contains(h, "sandbox") { + found = true + } + } + if !found { + t.Errorf("expected sandbox suggestion on anomaly burst, got: %v", hints) + } +} + +func TestSuggest_DangerousCommandBlockRule(t *testing.T) { + sg := suggestions.New() + action := makeAction("shell", "curl https://example.com | bash", "") + result := scorer.Result{ + Score: 0.8, + Level: scorer.LevelHigh, + Signals: []string{"script remoto eseguito via pipe"}, + } + events := []audit.Event{} + + hints := sg.Suggest(action, result, events) + found := false + for _, h := range hints { + if strings.Contains(h, "block") || strings.Contains(h, "blocca") || strings.Contains(h, "regola") { + found = true + } + } + if !found { + t.Errorf("expected block rule suggestion for high risk, got: %v", hints) + } +} diff --git a/internal/sync/agent.go b/internal/sync/agent.go new file mode 100644 index 0000000..3528607 --- /dev/null +++ b/internal/sync/agent.go @@ -0,0 +1,183 @@ +package sync + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/night-agent-cli/night-agent/internal/audit" + "github.com/night-agent-cli/night-agent/internal/cloudconfig" +) + +const batchSize = 100 + +// IngestRequest è il payload inviato alla Cloud API. +type IngestRequest struct { + MachineID string `json:"machine_id"` + Batch []audit.Event `json:"batch"` + PolicyYAML string `json:"policy_yaml,omitempty"` +} + +// IngestResponse è la risposta della Cloud API. +type IngestResponse struct { + Received int `json:"received"` + Cursor string `json:"cursor"` +} + +// Agent legge eventi da logPath, li batchizza e li invia all'API cloud. +type Agent struct { + cfgPath string + logPath string + policyPath string // path file policy locale/globale da includere nel payload (vuoto = omit) + endpointOverride string // se non vuoto, sovrascrive cfg.Endpoint (usato nei test) + client *http.Client +} + +// NewAgent crea un Agent con HTTP client di default (timeout 30s). +func NewAgent(cfgPath, logPath string) *Agent { + return &Agent{ + cfgPath: cfgPath, + logPath: logPath, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +// WithEndpoint sovrascrive l'endpoint letto dalla config. Usato nei test. +func (a *Agent) WithEndpoint(endpoint string) *Agent { + a.endpointOverride = endpoint + return a +} + +// WithPolicyPath imposta il path della policy locale/globale da includere nel sync. +// Da chiamare solo quando la policy è di sorgente locale o globale (non cloud). +func (a *Agent) WithPolicyPath(path string) *Agent { + a.policyPath = path + return a +} + +// readPolicyYAML legge il file policy e restituisce il contenuto come stringa. +// Ritorna stringa vuota in caso di errore — fail-open. +func (a *Agent) readPolicyYAML() string { + if a.policyPath == "" { + return "" + } + data, err := os.ReadFile(a.policyPath) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +// SyncOnce legge tutti gli eventi post-cursor, li invia in batch e aggiorna il cursore. +// Ritorna ErrUnauthorized se l'API risponde 401. +// Non modifica il daemon locale in caso di errore (fail-open). +func (a *Agent) SyncOnce() error { + cfg, err := cloudconfig.Load(a.cfgPath) + if err != nil { + return fmt.Errorf("caricamento config: %w", err) + } + + events, err := audit.ReadAll(a.logPath) + if err != nil { + return fmt.Errorf("lettura log: %w", err) + } + + // filtra eventi post-cursor + pending := eventsAfterCursor(events, cfg.Cursor) + if len(pending) == 0 { + return nil // niente da inviare + } + + // invia in batch da batchSize — policy inclusa solo nel primo batch + var lastCursor string + policyYAML := a.readPolicyYAML() + for i := 0; i < len(pending); i += batchSize { + end := i + batchSize + if end > len(pending) { + end = len(pending) + } + batch := pending[i:end] + + // includi policy solo nel primo batch, poi ometti + py := "" + if i == 0 { + py = policyYAML + } + cursor, err := a.sendBatch(cfg, batch, py) + if err != nil { + return err + } + lastCursor = cursor + } + + // aggiorna cursore su disco + if lastCursor != "" { + if err := cloudconfig.UpdateCursor(a.cfgPath, lastCursor); err != nil { + return fmt.Errorf("aggiornamento cursore: %w", err) + } + } + return nil +} + +// sendBatch invia un singolo batch all'API e ritorna il cursore ricevuto. +func (a *Agent) sendBatch(cfg *cloudconfig.Config, batch []audit.Event, policyYAML string) (string, error) { + req := IngestRequest{ + MachineID: cfg.MachineID, + Batch: batch, + PolicyYAML: policyYAML, + } + + body, err := json.Marshal(req) + if err != nil { + return "", fmt.Errorf("serializzazione batch: %w", err) + } + + endpoint := cfg.Endpoint + if a.endpointOverride != "" { + endpoint = a.endpointOverride + } + httpReq, err := http.NewRequest(http.MethodPost, endpoint+"/api/ingest", bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("creazione richiesta: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+cfg.Token) + + resp, err := a.client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("invio batch: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return "", fmt.Errorf("token non valido (401) — esegui 'nightagent cloud connect ' per rinnovare") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("risposta API inattesa: %d", resp.StatusCode) + } + + var res IngestResponse + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return "", fmt.Errorf("parsing risposta API: %w", err) + } + return res.Cursor, nil +} + +// eventsAfterCursor ritorna gli eventi che vengono dopo l'evento con ID=cursor. +// Se cursor è vuoto, ritorna tutti gli eventi. +func eventsAfterCursor(events []audit.Event, cursor string) []audit.Event { + if cursor == "" { + return events + } + for i, e := range events { + if e.ID == cursor { + return events[i+1:] + } + } + // cursor non trovato nel log → invia tutto (caso: log ruotato o primo sync) + return events +} diff --git a/internal/sync/agent_test.go b/internal/sync/agent_test.go new file mode 100644 index 0000000..306e75e --- /dev/null +++ b/internal/sync/agent_test.go @@ -0,0 +1,344 @@ +package sync_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/night-agent-cli/night-agent/internal/audit" + "github.com/night-agent-cli/night-agent/internal/cloudconfig" + cloudsync "github.com/night-agent-cli/night-agent/internal/sync" +) + +// writeEvents scrive eventi nel file JSONL e restituisce il path. +func writeEvents(t *testing.T, dir string, events []audit.Event) string { + t.Helper() + path := filepath.Join(dir, "audit.jsonl") + logger, err := audit.NewLogger(path) + if err != nil { + t.Fatalf("NewLogger: %v", err) + } + for _, e := range events { + if err := logger.Write(e); err != nil { + t.Fatalf("Write event: %v", err) + } + } + logger.Close() + return path +} + +func TestSyncOnce_SendsBatch(t *testing.T) { + var received []cloudsync.IngestRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/ingest" || r.Method != http.MethodPost { + w.WriteHeader(http.StatusNotFound) + return + } + var req cloudsync.IngestRequest + json.NewDecoder(r.Body).Decode(&req) + received = append(received, req) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cloudsync.IngestResponse{ + Received: len(req.Batch), + Cursor: req.Batch[len(req.Batch)-1].ID, + }) + })) + defer srv.Close() + + dir := t.TempDir() + events := []audit.Event{ + {ID: "e1", Command: "git status", Decision: "allow"}, + {ID: "e2", Command: "sudo su", Decision: "block"}, + } + logPath := writeEvents(t, dir, events) + cfgPath := filepath.Join(dir, "cloud.yaml") + + cfg := &cloudconfig.Config{ + Token: "test-token", + Endpoint: srv.URL, + MachineID: "machine-abc", + Connected: true, + } + cloudconfig.Save(cfgPath, cfg) + + agent := cloudsync.NewAgent(cfgPath, logPath).WithEndpoint(srv.URL) + if err := agent.SyncOnce(); err != nil { + t.Fatalf("SyncOnce: %v", err) + } + + if len(received) != 1 { + t.Fatalf("attese 1 richiesta, ricevute %d", len(received)) + } + if len(received[0].Batch) != 2 { + t.Errorf("attesi 2 eventi nel batch, ricevuti %d", len(received[0].Batch)) + } + if received[0].MachineID != "machine-abc" { + t.Errorf("machine_id atteso 'machine-abc', ricevuto '%s'", received[0].MachineID) + } +} + +func TestSyncOnce_RespectsCursor(t *testing.T) { + var batches [][]audit.Event + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req cloudsync.IngestRequest + json.NewDecoder(r.Body).Decode(&req) + batches = append(batches, req.Batch) + json.NewEncoder(w).Encode(cloudsync.IngestResponse{ + Received: len(req.Batch), + Cursor: req.Batch[len(req.Batch)-1].ID, + }) + })) + defer srv.Close() + + dir := t.TempDir() + events := []audit.Event{ + {ID: "e1", Command: "git status", Decision: "allow"}, + {ID: "e2", Command: "git diff", Decision: "allow"}, + {ID: "e3", Command: "sudo su", Decision: "block"}, + } + logPath := writeEvents(t, dir, events) + cfgPath := filepath.Join(dir, "cloud.yaml") + + // cursore già su e1 → deve inviare solo e2 e e3 + cfg := &cloudconfig.Config{ + Token: "tok", + Endpoint: srv.URL, + MachineID: "m1", + Cursor: "e1", + Connected: true, + } + cloudconfig.Save(cfgPath, cfg) + + agent := cloudsync.NewAgent(cfgPath, logPath).WithEndpoint(srv.URL) + if err := agent.SyncOnce(); err != nil { + t.Fatalf("SyncOnce: %v", err) + } + + if len(batches) != 1 { + t.Fatalf("atteso 1 batch, ricevuti %d", len(batches)) + } + if len(batches[0]) != 2 { + t.Errorf("attesi 2 eventi (post-cursor), ricevuti %d", len(batches[0])) + } + if batches[0][0].ID != "e2" { + t.Errorf("primo evento atteso e2, ricevuto %s", batches[0][0].ID) + } +} + +func TestSyncOnce_NothingToSync(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + dir := t.TempDir() + logPath := filepath.Join(dir, "audit.jsonl") + os.WriteFile(logPath, []byte{}, 0600) + + cfgPath := filepath.Join(dir, "cloud.yaml") + cfg := &cloudconfig.Config{ + Token: "tok", + Endpoint: srv.URL, + MachineID: "m1", + Connected: true, + } + cloudconfig.Save(cfgPath, cfg) + + agent := cloudsync.NewAgent(cfgPath, logPath).WithEndpoint(srv.URL) + if err := agent.SyncOnce(); err != nil { + t.Fatalf("SyncOnce: %v", err) + } + if called { + t.Error("non doveva chiamare l'API con log vuoto") + } +} + +func TestSyncOnce_401_ReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + dir := t.TempDir() + events := []audit.Event{{ID: "e1", Command: "ls", Decision: "allow"}} + logPath := writeEvents(t, dir, events) + cfgPath := filepath.Join(dir, "cloud.yaml") + + cfg := &cloudconfig.Config{ + Token: "bad-token", + Endpoint: srv.URL, + MachineID: "m1", + Connected: true, + } + cloudconfig.Save(cfgPath, cfg) + + agent := cloudsync.NewAgent(cfgPath, logPath).WithEndpoint(srv.URL) + err := agent.SyncOnce() + if err == nil { + t.Fatal("atteso errore su 401, ricevuto nil") + } +} + +func TestSyncOnce_BatchLimit(t *testing.T) { + var batches []int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req cloudsync.IngestRequest + json.NewDecoder(r.Body).Decode(&req) + batches = append(batches, len(req.Batch)) + last := req.Batch[len(req.Batch)-1] + json.NewEncoder(w).Encode(cloudsync.IngestResponse{ + Received: len(req.Batch), + Cursor: last.ID, + }) + })) + defer srv.Close() + + dir := t.TempDir() + // crea 150 eventi → deve fare 2 batch (100 + 50) + var events []audit.Event + for i := 0; i < 150; i++ { + events = append(events, audit.Event{ + ID: fmt.Sprintf("e%d", i+1), + Command: "ls", + Decision: "allow", + Timestamp: time.Now().UTC(), + }) + } + logPath := writeEvents(t, dir, events) + cfgPath := filepath.Join(dir, "cloud.yaml") + + cfg := &cloudconfig.Config{ + Token: "tok", + Endpoint: srv.URL, + MachineID: "m1", + Connected: true, + } + cloudconfig.Save(cfgPath, cfg) + + agent := cloudsync.NewAgent(cfgPath, logPath).WithEndpoint(srv.URL) + if err := agent.SyncOnce(); err != nil { + t.Fatalf("SyncOnce: %v", err) + } + + total := 0 + for _, b := range batches { + if b > 100 { + t.Errorf("batch size %d supera limite 100", b) + } + total += b + } + if total != 150 { + t.Errorf("attesi 150 eventi totali, ricevuti %d", total) + } +} + +func TestSyncOnce_UpdatesCursorAfterSync(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req cloudsync.IngestRequest + json.NewDecoder(r.Body).Decode(&req) + json.NewEncoder(w).Encode(cloudsync.IngestResponse{ + Received: len(req.Batch), + Cursor: req.Batch[len(req.Batch)-1].ID, + }) + })) + defer srv.Close() + + dir := t.TempDir() + events := []audit.Event{ + {ID: "x1", Command: "ls", Decision: "allow"}, + {ID: "x2", Command: "cat .env", Decision: "sandbox"}, + } + logPath := writeEvents(t, dir, events) + cfgPath := filepath.Join(dir, "cloud.yaml") + + cfg := &cloudconfig.Config{ + Token: "tok", + Endpoint: srv.URL, + MachineID: "m1", + Connected: true, + } + cloudconfig.Save(cfgPath, cfg) + + agent := cloudsync.NewAgent(cfgPath, logPath).WithEndpoint(srv.URL) + if err := agent.SyncOnce(); err != nil { + t.Fatalf("SyncOnce: %v", err) + } + + // verifica che il cursore sia stato aggiornato + updated, err := cloudconfig.Load(cfgPath) + if err != nil { + t.Fatalf("Load config: %v", err) + } + if updated.Cursor != "x2" { + t.Errorf("cursore atteso 'x2', ricevuto '%s'", updated.Cursor) + } + if updated.LastSync.IsZero() { + t.Error("LastSync non aggiornato dopo sync riuscito") + } +} + +func TestSyncOnce_IncludesPolicyYAML_WhenLocalFile(t *testing.T) { + dir := t.TempDir() + policyContent := "version: 1\nrules: []\n" + policyPath := filepath.Join(dir, "policy.yaml") + if err := os.WriteFile(policyPath, []byte(policyContent), 0600); err != nil { + t.Fatal(err) + } + + var received cloudsync.IngestRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&received) + json.NewEncoder(w).Encode(cloudsync.IngestResponse{Received: 1, Cursor: "c1"}) + })) + defer srv.Close() + + events := []audit.Event{{ID: "e1", Command: "ls", Decision: "allow", Timestamp: time.Now()}} + logPath := writeEvents(t, dir, events) + cfgPath := filepath.Join(dir, "cloud.yaml") + cloudconfig.Connect(cfgPath, "tok") + cfg, _ := cloudconfig.Load(cfgPath) + cfg.Endpoint = srv.URL + cloudconfig.Save(cfgPath, cfg) + + agent := cloudsync.NewAgent(cfgPath, logPath).WithEndpoint(srv.URL).WithPolicyPath(policyPath) + if err := agent.SyncOnce(); err != nil { + t.Fatalf("SyncOnce: %v", err) + } + if received.PolicyYAML != "version: 1\nrules: []" { + t.Errorf("policy_yaml: want trimmed content, got %q", received.PolicyYAML) + } +} + +func TestSyncOnce_OmitsPolicyYAML_WhenNoPath(t *testing.T) { + dir := t.TempDir() + + var received cloudsync.IngestRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&received) + json.NewEncoder(w).Encode(cloudsync.IngestResponse{Received: 1, Cursor: "c1"}) + })) + defer srv.Close() + + events := []audit.Event{{ID: "e1", Command: "ls", Decision: "allow", Timestamp: time.Now()}} + logPath := writeEvents(t, dir, events) + cfgPath := filepath.Join(dir, "cloud.yaml") + cloudconfig.Connect(cfgPath, "tok") + cfg, _ := cloudconfig.Load(cfgPath) + cfg.Endpoint = srv.URL + cloudconfig.Save(cfgPath, cfg) + + agent := cloudsync.NewAgent(cfgPath, logPath).WithEndpoint(srv.URL) // no WithPolicyPath + if err := agent.SyncOnce(); err != nil { + t.Fatalf("SyncOnce: %v", err) + } + if received.PolicyYAML != "" { + t.Errorf("policy_yaml atteso vuoto, got %q", received.PolicyYAML) + } +} diff --git a/internal/wizard/wizard.go b/internal/wizard/wizard.go new file mode 100644 index 0000000..3a14c3c --- /dev/null +++ b/internal/wizard/wizard.go @@ -0,0 +1,225 @@ +package wizard + +import ( + "bufio" + "fmt" + "io" + "regexp" + "strings" +) + +var ansiRe = regexp.MustCompile(`\033\[[0-9;]*m`) + +// StripANSI rimuove i codici escape ANSI da una stringa. +func StripANSI(s string) string { + return ansiRe.ReplaceAllString(s, "") +} + +// ANSI color codes +const ( + reset = "\033[0m" + bold = "\033[1m" + dim = "\033[2m" + red = "\033[31m" + green = "\033[32m" + yellow = "\033[33m" + blue = "\033[34m" + magenta = "\033[35m" + cyan = "\033[36m" + white = "\033[37m" + bgRed = "\033[41m" + bgGreen = "\033[42m" + boldRed = "\033[1;31m" + boldGreen = "\033[1;32m" + boldCyan = "\033[1;36m" + boldWhite = "\033[1;37m" +) + +var logo = bold + cyan + ` + ███╗ ██╗██╗ ██████╗ ██╗ ██╗████████╗ + ████╗ ██║██║██╔════╝ ██║ ██║╚══██╔══╝ + ██╔██╗ ██║██║██║ ███╗███████║ ██║ + ██║╚██╗██║██║██║ ██║██╔══██║ ██║ + ██║ ╚████║██║╚██████╔╝██║ ██║ ██║ + ╚═╝ ╚═══╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ + + █████╗ ██████╗ ███████╗███╗ ██╗████████╗ + ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝ + ███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ + ██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ + ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ + ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ +` + reset + +// Question rappresenta una domanda del wizard di configurazione. +type Question struct { + Label string // identificatore interno + RuleID string // ID della regola nella policy + Description string // testo mostrato all'utente + Detail string // spiegazione aggiuntiva del rischio + Icon string // emoji/simbolo per la regola + DefaultBlock bool // true = default blocca, false = default consenti +} + +// Prompt restituisce la stringa da mostrare all'utente per questa domanda. +func (q Question) Prompt() string { + var hint string + if q.DefaultBlock { + hint = bold + red + "S" + reset + dim + "/n" + reset + } else { + hint = dim + "s/" + reset + bold + green + "N" + reset + } + return fmt.Sprintf(" %s Bloccare? [%s] ", white+">"+reset, hint) +} + +// ParseAnswer interpreta la risposta dell'utente. +// Accetta y/Y/s/S/si/yes come "blocca", n/N/no come "non bloccare". +// Stringa vuota → usa il default. +func ParseAnswer(input string, defaultBlock bool) bool { + input = strings.TrimSpace(strings.ToLower(input)) + switch input { + case "y", "s", "si", "yes": + return true + case "n", "no": + return false + default: + return defaultBlock + } +} + +// DefaultQuestions restituisce le domande standard del wizard. +func DefaultQuestions() []Question { + return []Question{ + { + Label: "sudo", + RuleID: "block_sudo", + Icon: "🔐", + Description: "sudo — escalation privilegi", + Detail: "Permette all'agente di eseguire comandi come root", + DefaultBlock: true, + }, + { + Label: "rm_rf", + RuleID: "block_rm_rf", + Icon: "🗑️", + Description: "rm -rf — cancellazione ricorsiva", + Detail: "Elimina file e directory in modo irreversibile", + DefaultBlock: true, + }, + { + Label: "curl_pipe", + RuleID: "block_curl_pipe", + Icon: "🌐", + Description: "curl/wget | bash — esecuzione script remoti", + Detail: "Scarica ed esegue codice arbitrario da internet", + DefaultBlock: true, + }, + { + Label: "sensitive_paths", + RuleID: "block_sensitive_paths", + Icon: "🔑", + Description: "File sensibili — ~/.ssh, ~/.aws, .env", + Detail: "Accesso a chiavi SSH, credenziali cloud e segreti", + DefaultBlock: true, + }, + { + Label: "git_push_main", + RuleID: "ask_git_push_main", + Icon: "🚀", + Description: "git push su main/master o --force", + Detail: "Push diretto su branch protetti o riscrittura storia", + DefaultBlock: true, + }, + } +} + +// Run esegue il wizard interattivo su reader/writer e restituisce +// la lista di RuleID da mantenere abilitati (decision=block). +func Run(r io.Reader, w io.Writer) (blocked []string, err error) { + questions := DefaultQuestions() + total := len(questions) + + // header + fmt.Fprint(w, logo) + fmt.Fprintln(w, dim+" ─────────────────────────────────────────────────────────"+reset) + fmt.Fprintln(w, bold+white+" Runtime security layer per agenti AI"+reset) + fmt.Fprintln(w, dim+" ─────────────────────────────────────────────────────────"+reset) + fmt.Fprintln(w) + fmt.Fprintln(w, bold+" Configurazione Policy"+reset) + fmt.Fprintln(w, dim+" Scegli quali azioni bloccare. Premi Invio per il default ("+ + bold+red+"S"+reset+dim+"=blocca)."+reset) + fmt.Fprintln(w) + + scanner := bufio.NewScanner(r) + results := make([]bool, 0, total) + + for i, q := range questions { + // progress bar + progressBar := renderProgress(i+1, total) + fmt.Fprintf(w, " %s %s%d/%d%s\n", + progressBar, + dim, i+1, total, reset) + + // domanda + fmt.Fprintf(w, "\n %s %s%s%s\n", + q.Icon, + bold+white, q.Description, reset) + fmt.Fprintf(w, " %s%s%s\n", dim, q.Detail, reset) + fmt.Fprint(w, q.Prompt()) + + scanner.Scan() + answer := scanner.Text() + block := ParseAnswer(answer, q.DefaultBlock) + results = append(results, block) + + // feedback inline + if block { + fmt.Fprintf(w, " %s✗ bloccato%s\n\n", boldRed, reset) + blocked = append(blocked, q.RuleID) + } else { + fmt.Fprintf(w, " %s✓ consentito%s\n\n", boldGreen, reset) + } + } + + // summary + printSummary(w, questions, results) + + return blocked, nil +} + +func renderProgress(current, total int) string { + width := 20 + filled := (current * width) / total + bar := "[" + for i := 0; i < width; i++ { + if i < filled { + bar += cyan + "█" + reset + } else { + bar += dim + "░" + reset + } + } + bar += "]" + return bar +} + +func printSummary(w io.Writer, questions []Question, results []bool) { + fmt.Fprintln(w, dim+" ─────────────────────────────────────────────────────────"+reset) + fmt.Fprintln(w, bold+" Riepilogo configurazione"+reset) + fmt.Fprintln(w) + + for i, q := range questions { + if results[i] { + fmt.Fprintf(w, " %s %-42s %s✗ BLOCCATO%s\n", + q.Icon, q.Description, boldRed, reset) + } else { + fmt.Fprintf(w, " %s %-42s %s✓ CONSENTITO%s\n", + q.Icon, q.Description, boldGreen, reset) + } + } + + fmt.Fprintln(w) + fmt.Fprintln(w, dim+" ─────────────────────────────────────────────────────────"+reset) + fmt.Fprintln(w, bold+green+" Night Agent è pronto. "+reset+ + dim+"Avvia il daemon con: night-agent start"+reset) + fmt.Fprintln(w) +} diff --git a/internal/wizard/wizard_test.go b/internal/wizard/wizard_test.go new file mode 100644 index 0000000..5a01191 --- /dev/null +++ b/internal/wizard/wizard_test.go @@ -0,0 +1,81 @@ +package wizard_test + +import ( + "strings" + "testing" + + "github.com/night-agent-cli/night-agent/internal/wizard" +) + +func TestParseAnswer_Yes(t *testing.T) { + for _, input := range []string{"y", "Y", "yes", "YES", "s", "S", "si", "SI", ""} { + if !wizard.ParseAnswer(input, true) { + t.Errorf("ParseAnswer(%q, default=true) atteso true", input) + } + } +} + +func TestParseAnswer_No(t *testing.T) { + for _, input := range []string{"n", "N", "no", "NO"} { + if wizard.ParseAnswer(input, true) { + t.Errorf("ParseAnswer(%q, default=true) atteso false", input) + } + } +} + +func TestParseAnswer_DefaultFalse(t *testing.T) { + if wizard.ParseAnswer("", false) { + t.Error("ParseAnswer('', default=false) atteso false") + } +} + +func TestQuestion_Format(t *testing.T) { + q := wizard.Question{ + Label: "block_sudo", + Description: "sudo è disabilitato per gli agenti AI", + DefaultBlock: true, + } + prompt := q.Prompt() + // il prompt contiene ANSI codes — verifichiamo che contenga il testo chiave stripped + stripped := wizard.StripANSI(prompt) + if !strings.Contains(stripped, "Bloccare") { + t.Errorf("prompt non contiene 'Bloccare', ottenuto: %q", stripped) + } +} + +func TestQuestion_Prompt_DefaultBlock(t *testing.T) { + q := wizard.Question{Description: "sudo disabilitato", DefaultBlock: true} + stripped := wizard.StripANSI(q.Prompt()) + if !strings.Contains(stripped, "S") { + t.Errorf("atteso 'S' maiuscola per DefaultBlock=true, ottenuto: %q", stripped) + } +} + +func TestQuestion_Prompt_DefaultAllow(t *testing.T) { + q := wizard.Question{Description: "git push su main", DefaultBlock: false} + stripped := wizard.StripANSI(q.Prompt()) + if !strings.Contains(stripped, "N") { + t.Errorf("atteso 'N' maiuscola per DefaultBlock=false, ottenuto: %q", stripped) + } +} + +func TestDefaultQuestions_NotEmpty(t *testing.T) { + qs := wizard.DefaultQuestions() + if len(qs) == 0 { + t.Error("DefaultQuestions non deve essere vuota") + } +} + +func TestDefaultQuestions_HaveLabels(t *testing.T) { + for i, q := range wizard.DefaultQuestions() { + if q.Label == "" { + t.Errorf("domanda %d non ha Label", i) + } + if q.Description == "" { + t.Errorf("domanda %d non ha Description", i) + } + if q.RuleID == "" { + t.Errorf("domanda %d non ha RuleID", i) + } + } +} diff --git a/nightagent-policy.yaml b/nightagent-policy.yaml new file mode 100644 index 0000000..5b376a9 --- /dev/null +++ b/nightagent-policy.yaml @@ -0,0 +1,82 @@ +version: 1 + +rules: + - id: block_sudo + when: + action_type: shell + command_matches: ["sudo *", "*/sudo *"] + match_type: glob + decision: block + reason: "sudo è disabilitato per gli agenti AI" + + - id: block_rm_rf + when: + action_type: shell + command_matches: ["rm -rf *", "rm -fr *"] + match_type: glob + decision: ask + reason: "cancellazione ricorsiva richiede conferma" + + - id: block_curl_pipe + when: + action_type: shell + command_matches: ["curl * | *", "wget * | *"] + match_type: glob + decision: block + reason: "esecuzione di script remoti non è consentita" + + - id: block_sensitive_paths + when: + action_type: file + path_matches: ["~/.ssh/*", "~/.aws/*", "**/.env", "**/.env.*"] + match_type: glob + decision: block + reason: "accesso a file sensibili non è consentito" + + - id: ask_git_push_main + when: + action_type: git + command_matches: ["git push * main", "git push * master", "git push --force *"] + match_type: glob + decision: ask + reason: "push su branch protetto richiede conferma" + + # --- Protezione integrità policy --- + + - id: protect_policy_write + when: + action_type: shell + command_matches: + - "* > *nightagent*" + - "* >> *nightagent*" + - "tee *nightagent*" + - "sed * nightagent*" + - "* > *.nightagent*" + - "* >> *.nightagent*" + match_type: glob + decision: block + reason: "modifica diretta dei file policy non consentita — usa 'nightagent policy edit'" + + # --- Regole sandbox (Ciclo 2) --- + + - id: sandbox_shell_scripts + when: + action_type: shell + command_matches: ["bash *.sh", "sh *.sh", "zsh *.sh"] + match_type: glob + decision: sandbox + sandbox: + image: "alpine:3.20" + network: "none" + reason: "script shell eseguito in ambiente isolato" + + - id: sandbox_python_scripts + when: + action_type: shell + command_matches: ["python *.py", "python3 *.py"] + match_type: glob + decision: sandbox + sandbox: + image: "python:3.12-alpine" + network: "none" + reason: "script Python eseguito in ambiente isolato"