diff --git a/.dockerignore b/.dockerignore index 437a96b0..44ffd0e3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,5 @@ hawk_bin hawk_test_bin coverage.out coverage.html +go.work +go.work.sum diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..8a6cccd2 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# hawk daemon environment variables โ€” copy to .env and fill in +HAWK_DAEMON_API_KEY= +HAWK_DAEMON_PORT=4590 +HAWK_DAEMON_HOST=127.0.0.1 +# Eyrie connection (LLM provider runtime) +EYRIE_API_KEY= +EYRIE_BASE_URL=http://localhost:8080 +# Yaad connection (memory service) +YAAD_API_KEY= +YAAD_ADDR=127.0.0.1:3456 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..1d0f9bcf --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,63 @@ +name: Docker + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + paths: + - "Dockerfile" + - "**.go" + - "go.mod" + - "go.sum" + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: graycodeai/hawk + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=sha- + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + VERSION=${{ github.ref_name }} + COMMIT=${{ github.sha }} + BUILD_DATE=${{ github.event.head_commit.timestamp }} diff --git a/Dockerfile b/Dockerfile index f9c2904e..e316254e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,11 +4,18 @@ FROM golang:1.26.3-alpine AS builder RUN apk add --no-cache git ca-certificates tzdata WORKDIR /build + +# Clone eyrie (unpublished dependency with local-only packages) +RUN git clone --depth=1 https://github.com/GrayCodeAI/eyrie.git /eyrie + COPY go.mod go.sum ./ -RUN go mod download && go mod verify +RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath \ +# Add replace after source copy so it doesn't get overwritten +RUN echo "" >> go.mod && echo "replace github.com/GrayCodeAI/eyrie => /eyrie" >> go.mod && go mod tidy + +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -mod=mod \ -ldflags="-s -w -X main.Version=$(git describe --tags --always 2>/dev/null || echo dev)" \ -o hawk . diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 00000000..df1dfe16 --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,303 @@ +openapi: "3.1.0" +info: + title: Hawk Daemon API + description: | + HTTP API served by the hawk daemon on port 4590. + Used by hawk-sdk-go, hawk-sdk-python, and external integrations. + The daemon must be running (`hawk daemon start`) before calling these endpoints. + version: "0.1.0" + license: + name: MIT + url: https://github.com/GrayCodeAI/hawk/blob/main/LICENSE + contact: + url: https://github.com/GrayCodeAI/hawk + +servers: + - url: http://localhost:4590 + description: Local daemon (default port) + +security: + - ApiKeyAuth: [] + - BearerAuth: [] + +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer + + schemas: + ChatRequest: + type: object + required: [message] + properties: + message: + type: string + description: The user message to send to the agent + model: + type: string + description: Optional model override + session_id: + type: string + description: Continue an existing session (omit to start a new one) + stream: + type: boolean + default: false + description: Use SSE streaming for the response + + ChatResponse: + type: object + properties: + session_id: + type: string + response: + type: string + model: + type: string + tokens_in: + type: integer + tokens_out: + type: integer + + Session: + type: object + properties: + id: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + message_count: + type: integer + model: + type: string + + Message: + type: object + properties: + id: + type: string + session_id: + type: string + role: + type: string + enum: [user, assistant, tool] + content: + type: string + created_at: + type: string + format: date-time + + Stats: + type: object + properties: + total_sessions: + type: integer + active_sessions: + type: integer + total_messages: + type: integer + tokens_in: + type: integer + tokens_out: + type: integer + + Error: + type: object + properties: + error: + type: string + code: + type: string + +tags: + - name: system + description: Health and version + - name: agent + description: Agent chat + - name: sessions + description: Session management + - name: messages + description: Message history + - name: stats + description: Usage statistics + +paths: + /v1/health: + get: + tags: [system] + summary: Health check + security: [] + responses: + "200": + description: Daemon is running + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + + /v1/version: + get: + tags: [system] + summary: Version information + security: [] + responses: + "200": + description: Version details + content: + application/json: + schema: + type: object + properties: + version: + type: string + + /v1/chat: + post: + tags: [agent] + summary: Send a message to the agent + description: | + Non-streaming: returns the full response in one JSON object. + Streaming: set `stream: true` to receive Server-Sent Events. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ChatRequest" + responses: + "200": + description: Agent response (or SSE stream) + content: + application/json: + schema: + $ref: "#/components/schemas/ChatResponse" + text/event-stream: + schema: + type: string + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/sessions: + get: + tags: [sessions] + summary: List all sessions + parameters: + - name: limit + in: query + schema: + type: integer + default: 20 + - name: offset + in: query + schema: + type: integer + default: 0 + responses: + "200": + description: Paginated session list + content: + application/json: + schema: + type: object + properties: + sessions: + type: array + items: + $ref: "#/components/schemas/Session" + total: + type: integer + + /v1/sessions/{id}: + get: + tags: [sessions] + summary: Get a specific session + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Session details + content: + application/json: + schema: + $ref: "#/components/schemas/Session" + "404": + description: Session not found + delete: + tags: [sessions] + summary: Delete a session and all its messages + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "204": + description: Deleted + + /v1/sessions/{id}/messages: + get: + tags: [messages] + summary: Get messages for a session + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: limit + in: query + schema: + type: integer + default: 50 + - name: offset + in: query + schema: + type: integer + default: 0 + responses: + "200": + description: Paginated message list + content: + application/json: + schema: + type: object + properties: + messages: + type: array + items: + $ref: "#/components/schemas/Message" + total: + type: integer + + /v1/stats: + get: + tags: [stats] + summary: Aggregated usage statistics + responses: + "200": + description: Usage stats + content: + application/json: + schema: + $ref: "#/components/schemas/Stats" diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml new file mode 100644 index 00000000..d19f4b95 --- /dev/null +++ b/deploy/docker/docker-compose.yml @@ -0,0 +1,17 @@ +name: hawk + +services: + hawk: + build: + context: ../../ + dockerfile: Dockerfile + image: ghcr.io/graycodeai/hawk:dev + ports: + - "4590:4590" + environment: + - HAWK_DAEMON_API_KEY=${HAWK_DAEMON_API_KEY:-} + - HAWK_DAEMON_PORT=4590 + - HAWK_DAEMON_HOST=0.0.0.0 + env_file: + - path: ../../.env.example + required: false diff --git a/docs/architecture.md b/docs/architecture.md index 8790cbb3..3fdbd4f9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,114 +1,118 @@ -# Architecture +
-hawk is a terminal-native AI coding agent built in Go. It reads, writes, and runs code through natural language interaction. +# ๐Ÿฆ… hawk Architecture -## System Overview +**AI Coding Agent for Your Terminal** -``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ hawk CLI โ”‚ -โ”‚ cmd/ โ†’ cobra + bubbletea TUI โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ internal/engine/ โ”‚ -โ”‚ Agent loop, compaction, self-improvement โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ internal โ”‚ internal โ”‚ internal โ”‚ internal โ”‚ external โ”‚ -โ”‚ /tool/ โ”‚/session/ โ”‚ /config/ โ”‚/sandbox/ โ”‚ services โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ eyrie (LLM runtime) โ”‚ -โ”‚ tok (tokenizer) ยท yaad (memory) ยท trace โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` +[![Go](https://img.shields.io/badge/Go-1.26+-00ADD8?logo=go)](https://go.dev/) +[![Port](https://img.shields.io/badge/Port-4590-orange)](https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml) +[![Protocol](https://img.shields.io/badge/Protocol-REST-blue)](https://swagger.io/specification/) + +
+ +--- + +## ๐ŸŽฏ Overview + +hawk is an AI-powered coding agent for the terminal. It reads codebases, writes and edits files, runs tests, and manages git โ€” all through natural language. Zero CGO, single static binary for linux/darwin/windows on amd64/arm64. -## Directory Structure +--- + +## ๐Ÿงฑ Layered Architecture ``` hawk/ -โ”œโ”€โ”€ cmd/ # CLI entry point (Cobra + Bubble Tea TUI) +โ”œโ”€โ”€ main.go โšก Entry point โ€” calls cmd.Execute() +โ”œโ”€โ”€ api/openapi.yaml ๐Ÿ“œ Daemon REST API contract (OpenAPI 3.1) +โ”œโ”€โ”€ cmd/ ๐Ÿ–ฅ๏ธ Cobra CLI commands (200+ files) +โ”‚ โ”œโ”€โ”€ root.go โš™๏ธ Root command, flag definitions +โ”‚ โ”œโ”€โ”€ daemon.go ๐Ÿ”ฎ Daemon start/stop/status +โ”‚ โ”œโ”€โ”€ chat.go ๐Ÿ’ฌ Interactive TUI chat +โ”‚ โ””โ”€โ”€ ... โ”œโ”€โ”€ internal/ -โ”‚ โ”œโ”€โ”€ engine/ # Agent loop, compaction, beliefs, self-review -โ”‚ โ”‚ โ”œโ”€โ”€ compact/ # Context compaction strategies -โ”‚ โ”‚ โ”œโ”€โ”€ cost/ # Cost tracking & optimization -โ”‚ โ”‚ โ”œโ”€โ”€ token/ # Token counting & budget allocation -โ”‚ โ”‚ โ”œโ”€โ”€ prompt/ # Prompt construction & optimization -โ”‚ โ”‚ โ”œโ”€โ”€ diff/ # Diff handling, sandbox, summary -โ”‚ โ”‚ โ”œโ”€โ”€ review/ # Critic, self-assess, consensus -โ”‚ โ”‚ โ”œโ”€โ”€ control/ # Loop detection, stall, backtrack -โ”‚ โ”‚ โ”œโ”€โ”€ git/ # Git provider + context -โ”‚ โ”‚ โ””โ”€โ”€ ... # 15+ more sub-packages -โ”‚ โ”œโ”€โ”€ tool/ # 40+ built-in tools with safety layer -โ”‚ โ”œโ”€โ”€ config/ # Settings, budget tracking, validation -โ”‚ โ”œโ”€โ”€ session/ # Persistence (JSONL, WAL, checkpoints) -โ”‚ โ”œโ”€โ”€ api/ # HTTP API server -โ”‚ โ”œโ”€โ”€ daemon/ # Background HTTP/SSE server -โ”‚ โ”œโ”€โ”€ sandbox/ # Command isolation (landlock, seccomp) -โ”‚ โ”œโ”€โ”€ permissions/ # User approval system with auto-learning -โ”‚ โ”œโ”€โ”€ hooks/ # Event-driven plugin system -โ”‚ โ”œโ”€โ”€ mcp/ # Model Context Protocol client -โ”‚ โ”œโ”€โ”€ intelligence/ # Code intelligence -โ”‚ โ”‚ โ”œโ”€โ”€ repomap/ # PageRank, BM25, TF-IDF for file relevance -โ”‚ โ”‚ โ”œโ”€โ”€ memory/ # yaad bridge for persistent cross-session memory -โ”‚ โ”‚ โ””โ”€โ”€ planner/ # Multi-step planning with decomposition -โ”‚ โ”œโ”€โ”€ multiagent/ # Multi-agent orchestration -โ”‚ โ”‚ โ”œโ”€โ”€ mission/ # Parallel feature execution in worktrees -โ”‚ โ”‚ โ”œโ”€โ”€ parallel/ # Worktree-based parallel execution -โ”‚ โ”‚ โ””โ”€โ”€ agents/ # Custom persona loader -โ”‚ โ”œโ”€โ”€ observability/ # Analytics, metrics, logging, tracing -โ”‚ โ”œโ”€โ”€ resilience/ # Circuit breaker, rate limiting, retries, health -โ”‚ โ”œโ”€โ”€ feature/ # eval, fingerprint, voice, IDE, shellmode -โ”‚ โ”œโ”€โ”€ bridge/ # External bridges (sight, inspect, sessioncapture) -โ”‚ โ”œโ”€โ”€ provider/ # Provider routing -โ”‚ โ””โ”€โ”€ system/ # Bus, cron, retention, shutdown, staleness -โ”œโ”€โ”€ docs/ # Architecture, research notes -โ””โ”€โ”€ testdata/ # Test fixtures +โ”‚ โ”œโ”€โ”€ api/ ๐ŸŒ HTTP server (:4590) โ€” 8 REST endpoints +โ”‚ โ”œโ”€โ”€ daemon/ ๐Ÿ”ฎ Daemon lifecycle (PID file, socket) +โ”‚ โ”œโ”€โ”€ engine/ ๐Ÿง  Agent execution loop +โ”‚ โ”‚ โ”œโ”€โ”€ session.go ๐Ÿ”„ Core agent loop (Stream, agentLoop) +โ”‚ โ”‚ โ”œโ”€โ”€ ctxmgr/ ๐Ÿ“ฆ Context packing and visualization +โ”‚ โ”‚ โ”œโ”€โ”€ token/ ๐Ÿ’ฐ Budget allocation and prediction +โ”‚ โ”‚ โ”œโ”€โ”€ streaming/ ๐Ÿ“ก Response cache and stream optimizer +โ”‚ โ”‚ โ”œโ”€โ”€ planning/ ๐ŸŽฏ Goals and task decomposition +โ”‚ โ”‚ โ””โ”€โ”€ workflow/ ๐Ÿ”ง JSON-defined automation pipelines +โ”‚ โ”œโ”€โ”€ tool/ ๐Ÿ› ๏ธ 40+ built-in tools +โ”‚ โ”œโ”€โ”€ config/ โš™๏ธ Settings, env manager, migration +โ”‚ โ”œโ”€โ”€ session/ ๐Ÿ’พ SQLite persistence, search, export +โ”‚ โ”œโ”€โ”€ permissions/ ๐Ÿ›ก๏ธ Guardian, rules DSL, boundary checker +โ”‚ โ”œโ”€โ”€ sandbox/ ๐Ÿ–๏ธ Landlock + seccomp isolation +โ”‚ โ”œโ”€โ”€ intelligence/ ๐Ÿงฌ Repo map, AST analysis, deps +โ”‚ โ”œโ”€โ”€ multiagent/ ๐Ÿ‘ฅ Personas, inter-agent messaging +โ”‚ โ”œโ”€โ”€ mcp/ ๐Ÿ”Œ MCP client and server +โ”‚ โ”œโ”€โ”€ bridge/ ๐ŸŒ‰ Bridges to ecosystem services +โ”‚ โ””โ”€โ”€ resilience/ ๐Ÿ”„ Circuit breaker, retry, rate limit +โ”œโ”€โ”€ shared/types/ ๐Ÿ“ค Cross-repo exported types +โ”œโ”€โ”€ docs/ ๐Ÿ“– Architecture docs +โ””โ”€โ”€ external/ ๐Ÿ”— Local go.work checkouts ``` -## Data Flow +--- + +## ๐ŸŒ Daemon HTTP API (:4590) + +| | | +|---|---| +| **Contract** | [`api/openapi.yaml`](../api/openapi.yaml) | +| **Port** | `:4590` (default). Override: `HAWK_DAEMON_PORT` | +| **Auth** | Bearer token or `X-API-Key`. Set via `HAWK_DAEMON_API_KEY` | + +
+๐Ÿ“ก Endpoint Summary + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/v1/health` | ๐Ÿฉบ Health check | +| `GET` | `/v1/version` | ๐Ÿท๏ธ Version info | +| `POST` | `/v1/chat` | ๐Ÿ’ฌ Send message (JSON or SSE) | +| `GET` | `/v1/sessions` | ๐Ÿ“‹ List sessions | +| `GET` | `/v1/sessions/{id}` | ๐Ÿ” Get session | +| `GET` | `/v1/sessions/{id}/messages` | ๐Ÿ’ฌ Get messages | +| `DELETE` | `/v1/sessions/{id}` | ๐Ÿ—‘๏ธ Delete session | +| `GET` | `/v1/stats` | ๐Ÿ“Š Usage statistics | + +
+ +--- + +## ๐Ÿ”— Ecosystem Integration + +| Service | Role | Connection | +|---------|------|------------| +| ๐Ÿฆ… **eyrie** | LLM provider runtime | `:8080` โ€” all LLM calls routed here | +| ๐Ÿง  **yaad** | Persistent memory | `:3456` โ€” session context, recall | +| ๐Ÿ‘๏ธ **sight** | Code review | Library โ€” diff-based review | +| ๐Ÿ” **inspect** | Security audit | Library โ€” website scanning | +| โœ‚๏ธ **tok** | Token optimization | Library โ€” compression, secrets | +| ๐Ÿ“ธ **trace** | Session capture | CLI hook โ€” git-native capture | + +> ๐Ÿ’ก **hawk never talks to LLM APIs directly** โ€” all calls go through eyrie. + +--- + +## ๐Ÿ›ก๏ธ Tool Safety Layer + +Every tool call passes through the permission system before execution: ``` -User prompt - โ”‚ - โ–ผ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ cmd/ โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ engine/ โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ eyrie โ”‚ -โ”‚ (TUI) โ”‚ โ”‚ (agent loop) โ”‚ โ”‚ (LLM) โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ - โ”‚ โ”‚ - โ–ผ โ”‚ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ - โ”‚ tool/ โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ (execute)โ”‚ tool calls + text - โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ–ผ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ session/ โ”‚ (persist) - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +Tool Call โ†’ ๐Ÿ›ก๏ธ Guardian (rules DSL) โ†’ ๐Ÿงฑ Boundary Checker โ†’ ๐Ÿ‘ค User Approval โ†’ ๐Ÿ–๏ธ Sandbox (landlock/seccomp) โ†’ โœ… Execute ``` -1. **Input** โ€” User types prompt in TUI (`cmd/` via Bubble Tea) -2. **Context assembly** โ€” `engine/` builds message array with repomap, memory, system prompt -3. **LLM call** โ€” `eyrie` sends to provider (streaming SSE) -4. **Response** โ€” LLM returns text + tool calls -5. **Tool execution** โ€” `engine/` executes via `tool.Registry` (with permission checks) -6. **Feedback loop** โ€” Tool results fed back to LLM for next turn -7. **Persistence** โ€” Final response displayed, session saved to WAL +--- -## Key Design Decisions +## ๐Ÿ“ Key Design Decisions | Decision | Rationale | -|---|---| -| **Single static binary** | Zero runtime dependencies, easy distribution | -| **Streaming-first** | Token-by-token display, low perceived latency | -| **WAL crash recovery** | No data loss on unexpected exit | -| **Permission sandboxing** | All tool calls gated by configurable permission engine | -| **Model-agnostic** | eyrie abstracts all provider differences | -| **Offline-capable** | Works with local models (Ollama) without API keys | -| **`internal/` boundary** | Compiler-enforced privacy, free to refactor | - -## Security Model - -- **Sandbox** โ€” Landlock (Linux), seccomp-bpf, seatbelt (macOS), no-op on Windows -- **Permissions** โ€” Ask-before-execution for Bash, Write, Edit; auto-learn from decisions -- **Secrets** โ€” API keys never logged, masked in config display, stored encrypted at rest -- **Path validation** โ€” Symlink traversal prevention, no escape from working directory +|----------|-----------| +| `main.go` at root | Intentional โ€” goreleaser builds with `main: ./` producing `hawk` binary | +| `cmd/` is CLI library | Not a binary sub-directory โ€” holds 200+ cobra command files | +| Zero CGO | Pure Go, cross-compilable. Tree-sitter is optional | +| `internal/` is private | Other repos import `shared/types/` only | +| `external/` | go.work symlinks for local dev โ€” not committed | diff --git a/go.mod b/go.mod index 3e2bf6db..c482dc5f 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/sdk/metric v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 + golang.org/x/sys v0.45.0 golang.org/x/term v0.43.0 golang.org/x/text v0.37.0 gopkg.in/yaml.v3 v3.0.1 @@ -74,7 +75,6 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect golang.org/x/tools v0.45.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect diff --git a/go.sum b/go.sum index da54c5ae..f2bf3691 100644 --- a/go.sum +++ b/go.sum @@ -168,6 +168,7 @@ golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= diff --git a/internal/jsonc/validate.go b/internal/jsonc/validate.go index 39a964a4..9d0f23c7 100644 --- a/internal/jsonc/validate.go +++ b/internal/jsonc/validate.go @@ -57,20 +57,6 @@ func (r *ValidationResult) AddErr(err error) { r.Errors = append(r.Errors, err) } -// knownFields is the set of top-level field names recognized in -// Claude Code settings.json. Unknown fields are tolerated. -var knownFields = map[string]bool{ - "model": true, - "permissions": true, - "hooks": true, - "mcpServers": true, - "includeCoAuthoredBy": true, - "cleanupPeriodDays": true, - "forceLoginMethod": true, - "apiKeyHelper": true, - "env": true, -} - // ValidateClaudeSettings validates a parsed Claude Code settings // document (a map[string]interface{}). Returns a ValidationResult // describing any issues. The function never returns a non-nil error; diff --git a/internal/permissions/verdict.go b/internal/permissions/verdict.go index c5cf924b..3f48a882 100644 --- a/internal/permissions/verdict.go +++ b/internal/permissions/verdict.go @@ -125,7 +125,7 @@ func RequireApproval(reason, rule string, risk Risk) PermissionVerdict { // IsZero reports whether v is the zero value. Useful for // detecting "no verdict produced" cases. func (v PermissionVerdict) IsZero() bool { - return v.Allowed == false && v.Reason == "" && v.Rule == "" && + return !v.Allowed && v.Reason == "" && v.Rule == "" && v.Risk == 0 && v.Confidence == 0 && v.Source == "" } diff --git a/internal/safewrite/safewrite.go b/internal/safewrite/safewrite.go index 6e4791fe..a9e5965a 100644 --- a/internal/safewrite/safewrite.go +++ b/internal/safewrite/safewrite.go @@ -1,3 +1,5 @@ +//go:build !windows + // Package safewrite provides a hardened file-write helper that // protects against common symlink and permission attacks. // diff --git a/internal/safewrite/safewrite_test.go b/internal/safewrite/safewrite_test.go index 2a4d47a2..cb6b4cb2 100644 --- a/internal/safewrite/safewrite_test.go +++ b/internal/safewrite/safewrite_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package safewrite_test import ( diff --git a/internal/safewrite/safewrite_windows.go b/internal/safewrite/safewrite_windows.go new file mode 100644 index 00000000..72c712e2 --- /dev/null +++ b/internal/safewrite/safewrite_windows.go @@ -0,0 +1,40 @@ +//go:build windows + +package safewrite + +import ( + "errors" + "os" + "path/filepath" + "strings" +) + +var ( + ErrPathEscape = errors.New("safewrite: path escapes parent directory") + ErrSymlinkTarget = errors.New("safewrite: destination is a symlink") +) + +func WriteFile(path string, data []byte) error { + if path == "" { + return errors.New("safewrite: empty path") + } + if data == nil { + return errors.New("safewrite: nil data") + } + cleaned := filepath.Clean(path) + if !filepath.IsAbs(cleaned) { + abs, err := filepath.Abs(cleaned) + if err != nil { + return err + } + cleaned = abs + } + if strings.Contains(cleaned, "..") { + return ErrPathEscape + } + linfo, err := os.Lstat(cleaned) + if err == nil && linfo.Mode()&os.ModeSymlink != 0 { + return errors.New("safewrite: refusing to write through symlink") + } + return os.WriteFile(cleaned, data, 0o600) +} diff --git a/internal/session/session_gain.go b/internal/session/session_gain.go index ac4f3b5b..dd22a842 100644 --- a/internal/session/session_gain.go +++ b/internal/session/session_gain.go @@ -195,7 +195,7 @@ func (g *GainTracker) ListForSession(ctx context.Context, sessionID string, n in if err != nil { return nil, fmt.Errorf("session: list gains: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var out []GainEvent for rows.Next() { var ev GainEvent