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 โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-```
+[](https://go.dev/)
+[](https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml)
+[](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