diff --git a/.github/actions/checkout-eyrie/action.yml b/.github/actions/checkout-eyrie/action.yml index 594f354c..dc7a2dd6 100644 --- a/.github/actions/checkout-eyrie/action.yml +++ b/.github/actions/checkout-eyrie/action.yml @@ -1,5 +1,5 @@ -name: Checkout eyrie -description: Clone ecosystem repos into hawk/external for hawk go.work +name: Checkout ecosystem +description: Clone Hawk ecosystem repos into hawk/external for hawk go.work inputs: ref: @@ -15,7 +15,7 @@ runs: run: | set -euo pipefail mkdir -p "${GITHUB_WORKSPACE}/external" - for repo in eyrie inspect sight tok trace yaad; do + for repo in hawk-core-contracts eyrie inspect sight tok trace yaad; do dest="${GITHUB_WORKSPACE}/external/${repo}" if [ -d "$dest/.git" ]; then echo "$repo already present at $dest" diff --git a/.github/actions/setup-deps/action.yml b/.github/actions/setup-deps/action.yml index 777735ef..533e50b5 100644 --- a/.github/actions/setup-deps/action.yml +++ b/.github/actions/setup-deps/action.yml @@ -26,6 +26,7 @@ runs: echo "Failed to clone $repo after 3 attempts" && return 1 } mkdir -p external + clone_with_retry hawk-core-contracts external/hawk-core-contracts main clone_with_retry eyrie external/eyrie main clone_with_retry tok external/tok main clone_with_retry yaad external/yaad main @@ -41,4 +42,4 @@ runs: - name: Create workspace shell: bash run: | - printf 'go 1.26.4\n\nuse (\n\t.\n\t./external/eyrie\n\t./external/inspect\n\t./external/sight\n\t./external/tok\n\t./external/trace\n\t./external/yaad\n)\n' > go.work + printf 'go 1.26.4\n\nuse .\n\nreplace (\n\tgithub.com/GrayCodeAI/hawk-core-contracts => ./external/hawk-core-contracts\n\tgithub.com/GrayCodeAI/eyrie => ./external/eyrie\n\tgithub.com/GrayCodeAI/inspect => ./external/inspect\n\tgithub.com/GrayCodeAI/sight => ./external/sight\n\tgithub.com/GrayCodeAI/tok => ./external/tok\n\tgithub.com/GrayCodeAI/trace => ./external/trace\n\tgithub.com/GrayCodeAI/yaad => ./external/yaad\n)\n' > go.work diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05faa6c2..21e8fa28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,14 @@ jobs: echo "$out" | head -20 exit 1 fi + - name: shared types import guard + run: bash ./scripts/check-shared-types-imports.sh + - name: ecosystem boundary guard + run: bash ./scripts/check-ecosystem-boundaries.sh + - name: eyrie client boundary guard + run: bash ./scripts/check-eyrie-client-imports.sh + - name: support repo coupling guard + run: bash ./scripts/check-support-repo-coupling.sh # ------------------------------------------------------------------------- # 2. Module hygiene — tidy, verify (hawk + external ecosystem repos via go.work). @@ -89,7 +97,7 @@ jobs: run: go mod verify - name: workspace points at external checkouts run: | - for module in eyrie inspect sight tok trace yaad; do + for module in hawk-core-contracts eyrie inspect sight tok trace yaad; do if ! grep -q "./external/${module}" go.work; then echo "::error::go.work must include ./external/${module}." cat go.work diff --git a/.gitignore b/.gitignore index f316d874..948ae4b5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ hawk .gocache-*/ .gomodcache/ .gomodcache-*/ +.tmp/ *.codegraph.db .DS_Store diff --git a/.gitmodules b/.gitmodules index 2767af33..d443c9b3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -16,3 +16,6 @@ [submodule "external/trace"] path = external/trace url = https://github.com/GrayCodeAI/trace.git +[submodule "external/hawk-core-contracts"] + path = external/hawk-core-contracts + url = https://github.com/GrayCodeAI/hawk-core-contracts.git diff --git a/AGENTS.md b/AGENTS.md index 0e5d18e8..89dddfa6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,6 @@ hawk/ │ ├── daemon/ # Background HTTP/SSE server │ ├── resilience/ # Circuit breaker, rate limiting, health checks │ └── feature/ # Eval, fingerprint, scaffolding -├── shared/types/ # Cross-repo exported types (severity, etc.) ├── docs/ # Architecture docs, research notes └── testdata/ # Test fixtures ``` @@ -70,13 +69,17 @@ hawk/ ## Key Design Decisions - **Zero CGO:** Pure Go, cross-compilable. Tree-sitter is optional. -- **`internal/` is private:** Other repos import `shared/types/` only. +- **`internal/` is private:** Cross-repo contracts belong in `hawk-core-contracts`, not `internal/`. - **Tool safety layer:** Every tool call goes through permissions (guardian, rules DSL, boundary checker) before execution. - **Engine-first:** The agent loop in `internal/engine/` orchestrates context packing, tool dispatch, streaming, and session persistence. - **Ecosystem integration:** eyrie handles all LLM API communication. hawk - never talks to LLM APIs directly. + never talks to LLM APIs directly, and production code should go through + `internal/types` transport adapters instead of importing `eyrie/client`. +- **Shared contracts:** cross-repo types now live in `hawk-core-contracts` + (`types`, `review`, `verify`, `tools`, `events`, `policy`). The old + `hawk/shared/types` path has been removed. ## Development Guidelines @@ -145,8 +148,8 @@ test: add coverage for guardian - `CONTRIBUTING.md` — PR process, commit conventions - `docs/` — Architecture details, security model, ecosystem message flow -- `external/` — Ecosystem repo checkouts for `go.work` development -- `shared/types/` — Types exported for sight/inspect/tok (they must not import `internal/`) +- `external/` — Pinned ecosystem submodules used by `go.work` for local and CI integration +- `hawk-core-contracts/` — Shared cross-repo contracts; use this instead of any legacy Hawk-owned shared-type path ## Testing Philosophy @@ -157,13 +160,13 @@ test: add coverage for guardian ## Common Pitfalls -- Do not import `internal/` from other ecosystem repos — use `shared/types/` +- Do not import `internal/` from other ecosystem repos — use `hawk-core-contracts` - Do not put API keys in `.env` or shell env for hawk — use `/config` (OS keychain) -- The `external/` directory is for local dev only; CI clones repos separately +- The `external/` directory is part of the committed integration layout - `go.work` and `go.work.sum` are committed — CI's `module hygiene` job runs `go work sync` and asserts the result is in sync with the repo. Both - files point at `./external/*` checkouts; the `.github/actions/checkout-eyrie` - action populates `./external/` on CI runners before the build runs. + files point at `./external/*` submodules so Hawk can build against pinned + support-repo revisions. ## Naming Conventions @@ -202,6 +205,7 @@ test: add coverage for guardian - **Fuzz tests** for input parsing robustness: `func FuzzFoo(f *testing.F) { ... }` - **No mocks framework** — use concrete types and test doubles - **Meta-audit tests** in `internal/testaudit/` enforce architectural invariants via go/ast + including transport-boundary and deprecated-compatibility-package checks. ## Refactoring Guidelines @@ -211,7 +215,7 @@ test: add coverage for guardian - **Caution**: `internal/engine/session.go` Session struct — widely referenced across 30+ sub-packages - **Caution**: `internal/config/settings.go` Settings struct — serialized to JSON, dual-format (snake_case + camelCase) - **Caution**: `internal/tool/` Tool interface — implemented by 40+ tools -- **Blocked**: `shared/types/` — exported to eyrie, sight, inspect, tok; changes break ecosystem +- **Migration-sensitive**: `hawk/shared/types` has been removed; use `hawk-core-contracts/types` instead. ## Key File Locations @@ -250,6 +254,6 @@ test: add coverage for guardian - **No `panic()` for error handling** — return `error` values. Exception: `init()` functions for package-level assertions. - **No `fmt.Print` for logging** — use `logger.Logger` with structured fields. Exception: `internal/onboarding/` and `internal/engine/scaffold/` for user-facing CLI output. - **No API keys in settings.json** — use OS secret store via `credentials` package and `/config` command. -- **No importing `internal/` from other ecosystem repos** — use `shared/types/` for cross-repo types. +- **No importing `internal/` from other ecosystem repos** — use `hawk-core-contracts` for cross-repo types. - **No global mutable state** — prefer dependency injection via `deps` structs or `context.WithValue`. - **No `t.Skip()` without a tracking issue** — every skipped test needs a GitHub issue number. diff --git a/CHANGELOG.md b/CHANGELOG.md index b3805493..2f85a286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Version re-baselined to `0.1.0`** across `cmd/hawk/main.go`, `cmd/daemon.go`, `flake.nix`, `.github/workflows/release.yml`, and the `update`/daemon test suites, aligning hawk with the rest of the GrayCodeAI ecosystem (`eyrie`, `tok`, `yaad`, `sight`, `inspect`). +- **Architecture boundary hardening**: Hawk now owns runtime request/response DTOs, transport config/provider seams, and review/verification product-boundary contracts, with `eyrie/client` usage restricted to internal adapters and guarded in CI. +- **`shared/types` removed**: Hawk no longer ships the old shared type path, and local boundary checks now block any attempt to reintroduce it. ### Added - **Watch mode (`--watch`)**: file-watcher loop that acts on `AI!` (do-now) and `AI?` (answer) code comments. Off by default. @@ -33,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`GET /v1/ready`**: dependency-aware readiness endpoint on the daemon. - REPL magic commands (%reset, %undo, %tokens, %history, %copy, %save, %compact, %model, %clear) - Prompt cache keep-alive pings -- Unified Finding type in shared/types for cross-tool interoperability +- Unified finding/severity contracts now live in `hawk-core-contracts/types` ### Added — Round 2 ecosystem improvements (2026-06-01) - **Cavecrew personas** (`internal/multiagent/agents`): three new diff --git a/Makefile b/Makefile index f52bdc08..bf15fc2e 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ GORELEASER := $(GOBIN_DIR)/goreleaser # --------------------------------------------------------------------------- # Phony declarations (alphabetical). # --------------------------------------------------------------------------- -.PHONY: all bench build ci clean cover cover-new fmt help install lint lint-fix \ +.PHONY: all bench build ci clean contracts-guard ecosystem-guard eyrie-client-guard peer-guard cover cover-new fmt help install lint lint-fix \ release security setup smoke path test test-10x test-live test-new test-race tidy version vet # --------------------------------------------------------------------------- @@ -99,6 +99,18 @@ fmt: ## Format source files (gofumpt + goimports). vet: ## Run go vet. go vet ./... +contracts-guard: ## Fail on any legacy imports of removed hawk/shared/types. + bash ./scripts/check-shared-types-imports.sh + +ecosystem-guard: ## Fail if external ecosystem repos import hawk/internal or removed hawk/shared/types. + bash ./scripts/check-ecosystem-boundaries.sh + +eyrie-client-guard: ## Fail on new direct eyrie/client imports outside Hawk transport adapters. + bash ./scripts/check-eyrie-client-imports.sh + +peer-guard: ## Fail if support engines import each other instead of depending only on Hawk contracts. + bash ./scripts/check-support-repo-coupling.sh + lint: ## Run golangci-lint. @command -v $(GOLANGCI) >/dev/null 2>&1 || (echo "install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest" && exit 1) $(GOLANGCI) run ./... --timeout=5m @@ -118,7 +130,7 @@ tidy: ## Sync workspace modules and verify checksums. # --------------------------------------------------------------------------- # Composite gate used by CI and pre-push. # --------------------------------------------------------------------------- -ci: tidy fmt vet lint test-race security ## Run everything CI runs. +ci: tidy fmt vet contracts-guard ecosystem-guard eyrie-client-guard peer-guard lint test-race security ## Run everything CI runs. @echo "All CI checks passed." smoke: ## Quick build + doctor + ecosystem verification. @@ -142,12 +154,14 @@ clean: ## Remove build artefacts. # --------------------------------------------------------------------------- # Setup — bootstrap local development environment. # --------------------------------------------------------------------------- +FOUNDATION_REPOS := hawk-core-contracts ECO_REPOS := eyrie inspect sight tok trace yaad +WORKSPACE_REPOS := $(FOUNDATION_REPOS) $(ECO_REPOS) setup: ## Set up local development environment (go.work + external repos). @echo "=== Setting up hawk development environment ===" @mkdir -p external - @for repo in $(ECO_REPOS); do \ + @for repo in $(WORKSPACE_REPOS); do \ if [ ! -d "external/$$repo" ]; then \ echo "Cloning $$repo..."; \ git clone --depth=1 "https://github.com/GrayCodeAI/$$repo.git" "external/$$repo" 2>/dev/null || \ @@ -159,11 +173,12 @@ setup: ## Set up local development environment (go.work + external repos). @echo "Generating go.work..." @echo "go 1.26.4" > go.work @echo "" >> go.work - @echo "use (" >> go.work - @echo " ." >> go.work - @for repo in $(ECO_REPOS); do \ + @echo "use ." >> go.work + @echo "" >> go.work + @echo "replace (" >> go.work + @for repo in $(WORKSPACE_REPOS); do \ if [ -d "external/$$repo" ]; then \ - echo " ./external/$$repo" >> go.work; \ + echo " github.com/GrayCodeAI/$$repo => ./external/$$repo" >> go.work; \ fi; \ done @echo ")" >> go.work diff --git a/README.md b/README.md index 61e0b6b1..7ead9d9f 100644 --- a/README.md +++ b/README.md @@ -314,13 +314,28 @@ hawk/ ### Ecosystem -hawk integrates these GrayCodeAI repos in three ways: +hawk integrates these GrayCodeAI repos in three layers: -- **`go.mod` modules:** **eyrie**, **sight**, **inspect**, **tok**, **yaad** — pinned module requirements. -- **External checkout + `go.work`:** **eyrie**, **sight**, **inspect**, **tok**, **yaad**, **trace** — clone ecosystem repos under `external/`. `go.work` lists the local external checkouts. CI clones the same layout via **`.github/actions/checkout-eyrie`**. -- **Optional CLI (no Go import):** **trace** — installed separately; `hawk` shells into `trace` for session capture when present. +- **Primary product:** **hawk** is the only end-user product surface in this ecosystem. +- **Support engines mounted by Hawk:** **eyrie**, **yaad**, **tok**, **trace**, **sight**, **inspect**. Hawk imports or shells into these engines behind its own command surface. +- **Shared foundation:** **hawk-core-contracts** holds the neutral cross-repo types that keep the engines independent from Hawk internals. -Cross-repo types (severity, etc.) are exported from **`github.com/GrayCodeAI/hawk/shared/types`** so **sight** / **inspect** / **tok** do not import **`internal/`**. +Local development uses: + +- **`go.mod` modules:** pinned requirements for the support engines and `hawk-core-contracts` +- **External checkout + `go.work`:** clone support repos under `external/`; `go.work` maps the module paths to those local checkouts +- **Submodules in this repo:** the same external layout is pinned under `external/` for reproducible CI and multi-repo work + +Cross-repo contracts now live in **`github.com/GrayCodeAI/hawk-core-contracts`** so support repos do not depend on Hawk internals. The old `hawk/shared/types` path has been removed; use `hawk-core-contracts/types` for shared severity and finding contracts. + +Current contract packages: + +- `hawk-core-contracts/types` — severity, findings +- `hawk-core-contracts/review` — normalized review findings, comments, stats, results +- `hawk-core-contracts/verify` — normalized verification findings, stats, reports +- `hawk-core-contracts/tools` — tool call/result contracts +- `hawk-core-contracts/events` — normalized tool/trace events +- `hawk-core-contracts/policy` — permission and policy verdict contracts You may keep a **personal** parent **`go.work`** that lists alternate clones on disk for multi-repo development. @@ -332,7 +347,10 @@ You may keep a **personal** parent **`go.work`** that lists alternate clones on | **inspect** | [GrayCodeAI/inspect](https://github.com/GrayCodeAI/inspect) | Site audit library | | **tok** | [GrayCodeAI/tok](https://github.com/GrayCodeAI/tok) | Tokenizer & compression | | **yaad** | [GrayCodeAI/yaad](https://github.com/GrayCodeAI/yaad) | Graph-based memory | -| **trace** | [GrayCodeAI/trace](https://github.com/GrayCodeAI/trace) | Session capture CLI | +| **trace** | [GrayCodeAI/trace](https://github.com/GrayCodeAI/trace) | Session capture and replay engine mounted as `hawk trace ...` | +| **hawk-core-contracts** | [GrayCodeAI/hawk-core-contracts](https://github.com/GrayCodeAI/hawk-core-contracts) | Shared contracts and neutral cross-repo vocabulary | + +For the consolidated repo map and the current-vs-proposed architecture diagrams, see [docs/architecture/hawk-current-vs-proposed.md](docs/architecture/hawk-current-vs-proposed.md). ## Development diff --git a/cmd/chat.go b/cmd/chat.go index 2f658f3c..d065befb 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -76,7 +76,7 @@ func prepareSession(sess *engine.Session) (string, *session.Session, error) { if err != nil { return "", nil, err } - sess.LoadMessages(toEyrieMessages(saved.Messages)) + sess.LoadMessages(session.ToRuntimeMessages(saved.Messages)) if forkSessionFlag { if sessionIDFlag != "" { id = sessionIDFlag diff --git a/cmd/chat_commands_session.go b/cmd/chat_commands_session.go index e2ea8680..3afbc409 100644 --- a/cmd/chat_commands_session.go +++ b/cmd/chat_commands_session.go @@ -11,7 +11,6 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/GrayCodeAI/eyrie/client" "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/storage" ) @@ -22,19 +21,9 @@ func (m *chatModel) saveSession() { if len(raw) == 0 { return } - var msgs []session.Message - for _, rm := range raw { - sm := session.Message{Role: rm.Role, Content: rm.Content} - sm.ToolUse = append(sm.ToolUse, rm.ToolUse...) - if len(rm.ToolResults) > 0 { - sm.ToolResults = make([]session.ToolResult, len(rm.ToolResults)) - copy(sm.ToolResults, rm.ToolResults) - } - msgs = append(msgs, sm) - } err := session.Save(&session.Session{ ID: m.sessionID, Model: m.session.Model(), Provider: m.session.Provider(), - Messages: msgs, CreatedAt: time.Now(), + Messages: session.FromRuntimeMessages(raw), CreatedAt: time.Now(), }) // On successful save, WAL is no longer needed (session file has everything) if err == nil && m.wal != nil { @@ -132,15 +121,8 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string m.sessionID = s.ID m.invalidateViewportCache() m.messages = []displayMsg{{role: "welcome", content: m.welcomeCache}} - var msgs []client.EyrieMessage + msgs := session.ToRuntimeMessages(s.Messages) for _, sm := range s.Messages { - em := client.EyrieMessage{Role: sm.Role, Content: sm.Content} - em.ToolUse = append(em.ToolUse, sm.ToolUse...) - if len(sm.ToolResults) > 0 { - em.ToolResults = make([]client.ToolResult, len(sm.ToolResults)) - copy(em.ToolResults, sm.ToolResults) - } - msgs = append(msgs, em) if sm.Role == "user" || sm.Role == "assistant" { m.messages = append(m.messages, displayMsg{role: sm.Role, content: sm.Content}) } @@ -179,15 +161,8 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string m.sessionID = saved.ID m.invalidateViewportCache() m.messages = []displayMsg{{role: "welcome", content: m.welcomeCache}} - var msgs []client.EyrieMessage + msgs := session.ToRuntimeMessages(saved.Messages) for _, sm := range saved.Messages { - em := client.EyrieMessage{Role: sm.Role, Content: sm.Content} - em.ToolUse = append(em.ToolUse, sm.ToolUse...) - if len(sm.ToolResults) > 0 { - em.ToolResults = make([]client.ToolResult, len(sm.ToolResults)) - copy(em.ToolResults, sm.ToolResults) - } - msgs = append(msgs, em) if sm.Role == "user" || sm.Role == "assistant" { m.messages = append(m.messages, displayMsg{role: sm.Role, content: sm.Content}) } diff --git a/cmd/chat_print.go b/cmd/chat_print.go index 2baae951..d43e1f25 100644 --- a/cmd/chat_print.go +++ b/cmd/chat_print.go @@ -11,7 +11,6 @@ import ( "strings" "time" - "github.com/GrayCodeAI/eyrie/client" "github.com/GrayCodeAI/hawk/internal/engine" aiwatch "github.com/GrayCodeAI/hawk/internal/engine/io" "github.com/GrayCodeAI/hawk/internal/engine/lifecycle" @@ -205,39 +204,15 @@ func saveEyrieSession(id string, sess *engine.Session) { if len(raw) == 0 { return } - var msgs []session.Message - for _, rm := range raw { - sm := session.Message{Role: rm.Role, Content: rm.Content} - sm.ToolUse = append(sm.ToolUse, rm.ToolUse...) - if len(rm.ToolResults) > 0 { - sm.ToolResults = make([]session.ToolResult, len(rm.ToolResults)) - copy(sm.ToolResults, rm.ToolResults) - } - msgs = append(msgs, sm) - } _ = session.Save(&session.Session{ ID: id, Model: sess.Model(), Provider: sess.Provider(), - Messages: msgs, + Messages: session.FromRuntimeMessages(raw), CreatedAt: time.Now(), }) } -func toEyrieMessages(saved []session.Message) []client.EyrieMessage { - msgs := make([]client.EyrieMessage, 0, len(saved)) - for _, sm := range saved { - em := client.EyrieMessage{Role: sm.Role, Content: sm.Content} - em.ToolUse = append(em.ToolUse, sm.ToolUse...) - if len(sm.ToolResults) > 0 { - em.ToolResults = make([]client.ToolResult, len(sm.ToolResults)) - copy(em.ToolResults, sm.ToolResults) - } - msgs = append(msgs, em) - } - return msgs -} - // runRepl starts an interactive REPL mode for multi-turn conversation without TUI. func runRepl() error { fmt.Fprintln(os.Stderr, "Hawk REPL — type 'exit' or 'quit' to leave, 'help' for commands") diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index faa643d2..25ea09ce 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -9,7 +9,6 @@ import ( "github.com/mattn/go-runewidth" "github.com/GrayCodeAI/eyrie/catalog" - "github.com/GrayCodeAI/eyrie/client" "github.com/GrayCodeAI/eyrie/credentials" "github.com/GrayCodeAI/eyrie/setup" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" @@ -17,6 +16,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/sandbox" "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/tool" + "github.com/GrayCodeAI/hawk/internal/types" "github.com/GrayCodeAI/hawk/internal/ui/icons" ) @@ -357,7 +357,7 @@ func indentedAPIKeyLines() string { } func apiKeyStatusLines() []string { - providers := client.Client(nil).GetProviders() + providers := types.NewClient(nil).GetProviders() sort.Strings(providers) var lines []string for _, provider := range providers { diff --git a/cmd/exec.go b/cmd/exec.go index 5947c246..2adbeda5 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -235,7 +235,7 @@ func runExec(_ *cobra.Command, args []string) error { if lookupErr != nil { return fmt.Errorf("resume session %s: %w", execSessionID, lookupErr) } - sess.LoadMessages(toEyrieMessages(saved.Messages)) + sess.LoadMessages(session.ToRuntimeMessages(saved.Messages)) } // Add the user prompt diff --git a/cmd/inspect_pipeline.go b/cmd/inspect_pipeline.go index 92351c50..a283b1a8 100644 --- a/cmd/inspect_pipeline.go +++ b/cmd/inspect_pipeline.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" + contracts "github.com/GrayCodeAI/hawk-core-contracts/types" + verifycontracts "github.com/GrayCodeAI/hawk-core-contracts/verify" hawkInspect "github.com/GrayCodeAI/hawk/internal/bridge/inspect" inspectLib "github.com/GrayCodeAI/inspect" ) @@ -31,7 +33,7 @@ func DefaultInspectPipelineConfig(target string) InspectPipelineConfig { // InspectToReviewFindings converts an inspect report into ReviewFindings // so they can be displayed alongside code review findings. -func InspectToReviewFindings(report *inspectLib.Report) []ReviewFinding { +func InspectToReviewFindings(report *verifycontracts.Report) []ReviewFinding { if report == nil { return nil } @@ -52,15 +54,15 @@ func InspectToReviewFindings(report *inspectLib.Report) []ReviewFinding { } // mapInspectSeverity converts inspect severity to review severity. -func mapInspectSeverity(sev inspectLib.Severity) string { +func mapInspectSeverity(sev contracts.Severity) string { switch sev { - case inspectLib.SeverityCritical: + case contracts.SeverityCritical: return "critical" - case inspectLib.SeverityHigh: + case contracts.SeverityHigh: return "high" - case inspectLib.SeverityMedium: + case contracts.SeverityMedium: return "medium" - case inspectLib.SeverityLow: + case contracts.SeverityLow: return "low" default: return "low" @@ -89,7 +91,7 @@ func RunInspectPipeline(ctx context.Context, cfg InspectPipelineConfig) ([]Revie return nil, "", fmt.Errorf("inspect bridge failed to initialize") } - report, err := bridge.Run(ctx, cfg.Target) + report, err := bridge.RunContracts(ctx, cfg.Target) if err != nil { return nil, "", fmt.Errorf("inspect scan failed: %w", err) } @@ -123,7 +125,7 @@ func MergeInspectWithReview(reviewFindings, inspectFindings []ReviewFinding) []R } // formatInspectReport creates a concise text summary of an inspect report. -func formatInspectReport(report *inspectLib.Report) string { +func formatInspectReport(report *verifycontracts.Report) string { if report == nil { return "No inspect report." } diff --git a/cmd/inspect_pipeline_test.go b/cmd/inspect_pipeline_test.go index 61925145..0437f2f6 100644 --- a/cmd/inspect_pipeline_test.go +++ b/cmd/inspect_pipeline_test.go @@ -3,7 +3,8 @@ package cmd import ( "testing" - inspectLib "github.com/GrayCodeAI/inspect" + contracts "github.com/GrayCodeAI/hawk-core-contracts/types" + verifycontracts "github.com/GrayCodeAI/hawk-core-contracts/verify" ) func TestInspectToReviewFindings_NilReport(t *testing.T) { @@ -16,7 +17,7 @@ func TestInspectToReviewFindings_NilReport(t *testing.T) { func TestInspectToReviewFindings_EmptyReport(t *testing.T) { t.Parallel() - report := &inspectLib.Report{ + report := &verifycontracts.Report{ Target: "https://example.com", } findings := InspectToReviewFindings(report) @@ -27,20 +28,20 @@ func TestInspectToReviewFindings_EmptyReport(t *testing.T) { func TestInspectToReviewFindings_WithFindings(t *testing.T) { t.Parallel() - report := &inspectLib.Report{ + report := &verifycontracts.Report{ Target: "https://example.com", - Findings: []inspectLib.Finding{ + Findings: []verifycontracts.Finding{ { Check: "security", URL: "https://example.com/login", - Severity: inspectLib.SeverityHigh, + Severity: contracts.SeverityHigh, Message: "Missing CSP header", Fix: "Add Content-Security-Policy header", }, { Check: "a11y", URL: "https://example.com/about", - Severity: inspectLib.SeverityMedium, + Severity: contracts.SeverityMedium, Message: "Missing alt text on image", }, }, @@ -68,14 +69,14 @@ func TestInspectToReviewFindings_WithFindings(t *testing.T) { func TestMapInspectSeverity(t *testing.T) { t.Parallel() tests := []struct { - input inspectLib.Severity + input contracts.Severity want string }{ - {inspectLib.SeverityCritical, "critical"}, - {inspectLib.SeverityHigh, "high"}, - {inspectLib.SeverityMedium, "medium"}, - {inspectLib.SeverityLow, "low"}, - {inspectLib.Severity(999), "low"}, + {contracts.SeverityCritical, "critical"}, + {contracts.SeverityHigh, "high"}, + {contracts.SeverityMedium, "medium"}, + {contracts.SeverityLow, "low"}, + {contracts.Severity(999), "low"}, } for _, tt := range tests { @@ -141,12 +142,12 @@ func TestFormatInspectReport_Nil(t *testing.T) { func TestFormatInspectReport_WithData(t *testing.T) { t.Parallel() - report := &inspectLib.Report{ + report := &verifycontracts.Report{ Target: "https://example.com", CrawledURLs: 10, - Stats: inspectLib.Stats{ + Stats: verifycontracts.Stats{ FindingsTotal: 5, - BySeverity: map[inspectLib.Severity]int{inspectLib.SeverityHigh: 2, inspectLib.SeverityMedium: 3}, + BySeverity: map[contracts.Severity]int{contracts.SeverityHigh: 2, contracts.SeverityMedium: 3}, }, } output := formatInspectReport(report) diff --git a/cmd/mentions.go b/cmd/mentions.go index 0529d587..f129dc83 100644 --- a/cmd/mentions.go +++ b/cmd/mentions.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/GrayCodeAI/hawk/internal/mention" + "github.com/GrayCodeAI/hawk/internal/tool" ) // handleMentions processes @-prefixed file mentions in user input. @@ -29,6 +30,16 @@ func (m *chatModel) handleMentions(text string) string { // Read each mentioned file and append to session context. var contextParts []string for _, path := range result.MentionedFiles { + // Enforce the same sensitive-path block the Read/Write tools use. + // Otherwise `@~/.ssh/id_rsa` or `@/etc/passwd` would sidestep the + // security boundary and inject secrets into the LLM context. + if reason := tool.IsSensitivePath(path); reason != "" { + m.messages = append(m.messages, displayMsg{ + role: "error", + content: fmt.Sprintf("Refused to read @%s: %s", path, reason), + }) + continue + } content, err := os.ReadFile(path) if err != nil { m.messages = append(m.messages, displayMsg{ diff --git a/cmd/mentions_test.go b/cmd/mentions_test.go new file mode 100644 index 00000000..63802f00 --- /dev/null +++ b/cmd/mentions_test.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GrayCodeAI/hawk/internal/engine" +) + +func TestHandleMentions_BlocksSensitivePaths(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + sensitive := filepath.Join(dir, ".env") + if err := os.WriteFile(sensitive, []byte("SECRET=top-secret"), 0o600); err != nil { + t.Fatalf("write sensitive file: %v", err) + } + + m := &chatModel{session: &engine.Session{}} + got := m.handleMentions(`explain @"` + sensitive + `" please`) + + if strings.Contains(got, sensitive) { + t.Fatalf("cleaned input still contains mention: %q", got) + } + if len(m.messages) != 1 { + t.Fatalf("expected 1 UI message, got %d", len(m.messages)) + } + if m.messages[0].role != "error" { + t.Fatalf("expected error message, got role %q", m.messages[0].role) + } + if !strings.Contains(m.messages[0].content, "Refused to read") { + t.Fatalf("expected refusal message, got %q", m.messages[0].content) + } + if !strings.Contains(m.messages[0].content, ".env") { + t.Fatalf("expected blocked path detail, got %q", m.messages[0].content) + } +} diff --git a/cmd/options.go b/cmd/options.go index e387f7fe..6f1d4bad 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/GrayCodeAI/eyrie/client" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ctxrepomap "github.com/GrayCodeAI/hawk/internal/context/repomap" "github.com/GrayCodeAI/hawk/internal/engine" @@ -23,6 +22,7 @@ import ( hawkmodel "github.com/GrayCodeAI/hawk/internal/provider/routing" "github.com/GrayCodeAI/hawk/internal/snapshot" "github.com/GrayCodeAI/hawk/internal/tool" + "github.com/GrayCodeAI/hawk/internal/types" ) func buildSystemPrompt() (string, error) { @@ -156,7 +156,7 @@ func loadEffectiveSettings() (hawkconfig.Settings, error) { if cp.Name == "" || cp.BaseURL == "" { continue } - _ = client.RegisterDynamicProvider(cp.Name, cp.BaseURL, cp.APIKeyEnv) + _ = types.RegisterDynamicProvider(cp.Name, cp.BaseURL, cp.APIKeyEnv) } return settings, nil } @@ -185,7 +185,7 @@ func effectiveModelAndProvider(settings hawkconfig.Settings) (string, string) { // so users with ANTHROPIC_API_KEY don't get confusing errors about canopywave. normalized := hawkconfig.NormalizeProviderForEngine(effectiveProvider) if normalized != "" && hawkconfig.APIKeyForProvider(normalized) == "" { - detected := client.DetectProvider() + detected := types.DetectProvider() if detected != "" && hawkconfig.APIKeyForProvider(detected) != "" { normalized = detected effectiveModel = "" diff --git a/cmd/review_analyze.go b/cmd/review_analyze.go index 25aa8c30..1844c235 100644 --- a/cmd/review_analyze.go +++ b/cmd/review_analyze.go @@ -8,8 +8,9 @@ import ( "strings" "time" - "github.com/GrayCodeAI/eyrie/client" + reviewcontracts "github.com/GrayCodeAI/hawk-core-contracts/review" hawkSight "github.com/GrayCodeAI/hawk/internal/bridge/sight" + "github.com/GrayCodeAI/hawk/internal/types" "github.com/GrayCodeAI/hawk/internal/ui/icons" sightLib "github.com/GrayCodeAI/sight" "github.com/spf13/cobra" @@ -125,9 +126,9 @@ func runReviewAnalyze(_ *cobra.Command, args []string) error { // Build sight bridge for analysis. prov := provider if prov == "" { - prov = client.DetectProvider() + prov = types.DetectProvider() } - eyrieClient := client.Client(&client.EyrieConfig{Provider: prov}) + eyrieClient := types.NewClient(&types.ClientConfig{Provider: prov}) var opts []sightLib.Option if analyzeModel != "" { @@ -151,7 +152,7 @@ func runReviewAnalyze(_ *cobra.Command, args []string) error { analysisInput := fmt.Sprintf("# Analysis Type: %s\n\n%s\n\n---\n\n%s", analysisType, prompt, content) fmt.Printf("Analyzing (%s)...\n", analysisType) - result, err := bridge.Review(ctx, analysisInput) + result, err := bridge.ReviewContracts(ctx, analysisInput) if err != nil { return fmt.Errorf("analysis failed: %w", err) } @@ -229,7 +230,7 @@ func getAnalysisContent(patterns []string) (string, error) { return b.String(), nil } -func autoFixAnalysis(result *sightLib.Result) error { +func autoFixAnalysis(result *reviewcontracts.Result) error { var b strings.Builder b.WriteString("Fix the following analysis findings:\n\n") for i, f := range result.Findings { diff --git a/cmd/review_run.go b/cmd/review_run.go index 32300411..f9783b21 100644 --- a/cmd/review_run.go +++ b/cmd/review_run.go @@ -8,8 +8,9 @@ import ( "strings" "time" - "github.com/GrayCodeAI/eyrie/client" + reviewcontracts "github.com/GrayCodeAI/hawk-core-contracts/review" hawkSight "github.com/GrayCodeAI/hawk/internal/bridge/sight" + "github.com/GrayCodeAI/hawk/internal/types" "github.com/GrayCodeAI/hawk/internal/ui/icons" sightLib "github.com/GrayCodeAI/sight" "github.com/spf13/cobra" @@ -87,9 +88,9 @@ func runReviewRun(_ *cobra.Command, args []string) error { // Build sight bridge. prov := provider if prov == "" { - prov = client.DetectProvider() + prov = types.DetectProvider() } - eyrieClient := client.Client(&client.EyrieConfig{Provider: prov}) + eyrieClient := types.NewClient(&types.ClientConfig{Provider: prov}) var opts []sightLib.Option if reviewRunModel != "" { @@ -117,7 +118,7 @@ func runReviewRun(_ *cobra.Command, args []string) error { } // Run review. - result, err := bridge.Review(ctx, diff) + result, err := bridge.ReviewContracts(ctx, diff) if err != nil { _ = store.SetStatus(id, ReviewStatusFailed) return silentErr(err, "sight review") @@ -152,7 +153,7 @@ func getCommitDiff(sha string) (string, error) { return string(out), nil } -func printReviewSummary(sha string, result *sightLib.Result) { +func printReviewSummary(sha string, result *reviewcontracts.Result) { if len(result.Findings) == 0 { fmt.Printf("%s %s — no issues found (%d files reviewed)\n", icons.CheckBold(), sha[:8], result.Stats.FilesReviewed) return diff --git a/cmd/review_store.go b/cmd/review_store.go index 153b7174..5b4f0faa 100644 --- a/cmd/review_store.go +++ b/cmd/review_store.go @@ -10,8 +10,8 @@ import ( "sync" "time" + reviewcontracts "github.com/GrayCodeAI/hawk-core-contracts/review" "github.com/GrayCodeAI/hawk/internal/storage" - sightLib "github.com/GrayCodeAI/sight" ) // ReviewStatus represents the state of a review. @@ -32,7 +32,7 @@ type ReviewRecord struct { ID int64 SHA string Status ReviewStatus - Findings []sightLib.Finding + Findings []reviewcontracts.Finding Report string MaxSeverity string TokensUsed int @@ -142,7 +142,7 @@ func (s *ReviewStore) Create(sha string) (int64, error) { } // Update sets the review result after completion. -func (s *ReviewStore) Update(id int64, status ReviewStatus, result *sightLib.Result) error { +func (s *ReviewStore) Update(id int64, status ReviewStatus, result *reviewcontracts.Result) error { s.mu.Lock() defer s.mu.Unlock() diff --git a/cmd/review_test.go b/cmd/review_test.go index a196d27f..42bfc76a 100644 --- a/cmd/review_test.go +++ b/cmd/review_test.go @@ -6,7 +6,8 @@ import ( "strings" "testing" - sightLib "github.com/GrayCodeAI/sight" + reviewcontracts "github.com/GrayCodeAI/hawk-core-contracts/review" + contracts "github.com/GrayCodeAI/hawk-core-contracts/types" "github.com/GrayCodeAI/hawk/internal/ui/icons" ) @@ -53,12 +54,12 @@ func TestReviewStore_Update(t *testing.T) { id, _ := store.Create("sha123") - result := &sightLib.Result{ - Findings: []sightLib.Finding{ - {Concern: "security", Severity: sightLib.SeverityHigh, File: "main.go", Line: 10, Message: "SQL injection"}, - {Concern: "bugs", Severity: sightLib.SeverityMedium, File: "handler.go", Line: 20, Message: "nil deref"}, + result := &reviewcontracts.Result{ + Findings: []reviewcontracts.Finding{ + {Concern: "security", Severity: contracts.SeverityHigh, File: "main.go", Line: 10, Message: "SQL injection"}, + {Concern: "bugs", Severity: contracts.SeverityMedium, File: "handler.go", Line: 20, Message: "nil deref"}, }, - Stats: sightLib.Stats{TokensUsed: 500}, + Stats: reviewcontracts.Stats{TokensUsed: 500}, } err = store.Update(id, ReviewStatusOpen, result) @@ -183,8 +184,8 @@ func TestReviewStore_SetStatus(t *testing.T) { func TestBuildFixPrompt(t *testing.T) { r := &ReviewRecord{ SHA: "abc12345deadbeef0000000000000000000000ff", - Findings: []sightLib.Finding{ - {Severity: sightLib.SeverityHigh, File: "main.go", Line: 10, Message: "SQL injection", Fix: "use parameterized query"}, + Findings: []reviewcontracts.Finding{ + {Severity: contracts.SeverityHigh, File: "main.go", Line: 10, Message: "SQL injection", Fix: "use parameterized query"}, }, } diff --git a/docs/architecture.md b/docs/architecture.md index 017dfc88..f9ed10b0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,6 +16,8 @@ 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. +Detailed planning docs for the Hawk product architecture live in [`docs/architecture/`](architecture/README.md). + --- ## blocks Layered Architecture @@ -49,11 +51,13 @@ hawk/ │ ├── mcp/ plug MCP client and server │ ├── bridge/ link Bridges to ecosystem services │ └── resilience/ refresh-cw Circuit breaker, retry, rate limit -├── shared/types/ share-2 Cross-repo exported types ├── docs/ book-open Architecture docs -└── external/ link Local go.work checkouts +└── external/ link Pinned ecosystem submodules for go.work integration ``` +Legacy note: `hawk/shared/types` has been removed. Shared cross-repo severity +and finding contracts now live in `hawk-core-contracts/types`. + --- ## globe Daemon HTTP API (:4590) @@ -114,5 +118,6 @@ Tool Call → hawk-core-contracts # severity/finding vocabulary +inspect -> hawk-core-contracts # severity/finding vocabulary +eyrie -> (none) # provider/transport types are eyrie-local +yaad -> (none) # memory event types are yaad-local +trace -> (none) # trace/redaction event types are trace-local +``` + +If `eyrie`, `yaad`, or `trace` later needs to emit or accept a shared +finding/event, the type moves into `hawk-core-contracts` first and the engine +adds the contract edge then — not before. + +## Forbidden graph + +```text +engine -> engine +engine -> hawk/internal/* +engine -> hawk/shared/* as a public dependency +sdk -> engine +skills -> engine +``` + +## Rules + +### 1. Hawk is the orchestrator +Only Hawk coordinates the support engines. + +### 2. Engines are peers +Engines may share concepts through contracts, but not by importing each other. + +### 3. Shared types belong in contracts +Anything used across repos must move to `hawk-core-contracts`. + +### 4. Public integrations go through Hawk +SDKs and skills must use Hawk public APIs, contracts, or plugin surfaces. + +### 5. Provider logic stays behind runtime boundaries +Provider-specific code should not leak into memory, review, verify, or trace engines. + +## Current cleanup targets + +Based on current local structure: + +- `sight -> hawk/shared/types` removed +- `inspect -> hawk/shared/types` removed +- `tok/types` compatibility shim removed +- keep support engines peer-isolated as new features are added + +## Enforcement + +These were previously "ideas"; they are now implemented: + +- each support repo documents its import boundary in its README + ("Ecosystem Boundaries" section) +- CI runs `scripts/check-ecosystem-boundaries.sh` in every support repo, and + Hawk additionally runs `check-shared-types-imports.sh`, + `check-eyrie-client-imports.sh`, and `check-support-repo-coupling.sh` +- `hawk-core-contracts` is kept minimal (leaf module, no external dependencies) + +Still open: + +- the boundary guard scripts depend on `rg`; when `rg` is absent they pass + vacuously, so CI images must provide ripgrep (or the scripts should fall back + to `git grep`) diff --git a/docs/architecture/hawk-ecosystem-summary.md b/docs/architecture/hawk-ecosystem-summary.md new file mode 100644 index 00000000..8da03f77 --- /dev/null +++ b/docs/architecture/hawk-ecosystem-summary.md @@ -0,0 +1,218 @@ +# Hawk Ecosystem Summary + +## One-line view + +`hawk` is the product. Everything else in the Hawk ecosystem either powers Hawk, +extends Hawk, or provides shared contracts for Hawk. + +## Final repo map + +```text +top: consumers and extensions + + hawk-sdk-go hawk-sdk-python hawk-community-skills + \ | / + \ | / + +---------------+----------------------+ + | + v + hawk + +middle: support engines + + +---------+---------+---------+---------+---------+---------+ + | | | | | | | + v v v v v v v + eyrie yaad tok trace sight inspect public APIs + +bottom: shared contracts + + hawk-core-contracts +``` + +## Dependency direction + +### Product layer + +- `hawk -> eyrie` +- `hawk -> yaad` +- `hawk -> tok` +- `hawk -> trace` +- `hawk -> sight` +- `hawk -> inspect` +- `hawk -> hawk-core-contracts` + +### Shared-contract layer + +- `engine -> hawk-core-contracts` only when a real cross-repo DTO is required + +### Extension layer + +- `hawk-sdk-go -> hawk` +- `hawk-sdk-python -> hawk` +- `hawk-community-skills -> hawk` + +### Forbidden edges + +- `engine -> engine` +- `sdk -> engine` +- `skills -> engine` +- `engine -> hawk/internal/*` +- `hawk runtime -> graycode-core` + +## Repo-by-repo roles + +| Repo | Layer | Role | Depends on | Must not depend on | +|---|---|---|---|---| +| `hawk` | Product | CLI, orchestration, policy, execution control, public APIs | support engines, `hawk-core-contracts` | sibling company/platform repos in runtime paths | +| `eyrie` | Support engine | provider runtime, model execution, streaming, retries | `hawk-core-contracts` only if needed | `yaad`, `tok`, `trace`, `sight`, `inspect` | +| `yaad` | Support engine | memory, retrieval, persistence of long-lived context | `hawk-core-contracts` only if needed | `eyrie`, `tok`, `trace`, `sight`, `inspect` | +| `tok` | Support engine | token budgeting, packing, truncation, context shaping | `hawk-core-contracts` only if needed | `eyrie`, `yaad`, `trace`, `sight`, `inspect` | +| `trace` | Support engine | trace capture, replay, provenance, audit trail | `hawk-core-contracts` only if needed | `eyrie`, `yaad`, `tok`, `sight`, `inspect` | +| `sight` | Support engine | review findings, code-quality/risk analysis | `hawk-core-contracts` | `eyrie`, `yaad`, `tok`, `trace`, `inspect` | +| `inspect` | Support engine | verification findings, checks, pass/fail validation | `hawk-core-contracts` | `eyrie`, `yaad`, `tok`, `trace`, `sight` | +| `hawk-core-contracts` | Foundation | shared DTOs and vocabulary | leaf module | product logic or engine implementation logic | +| `hawk-sdk-go` | Consumer | Go integrations for Hawk public surfaces | Hawk public API/contracts | support engines directly | +| `hawk-sdk-python` | Consumer | Python integrations for Hawk public surfaces | Hawk public API/contracts | support engines directly | +| `hawk-community-skills` | Consumer | skills, recipes, extension packs | Hawk plugin/skill surfaces | support engines directly | + +## Runtime responsibility split + +### `hawk` + +Owns: + +- the CLI and command UX +- session lifecycle +- approval and permission policy +- tool routing +- provider selection +- engine coordination +- user-visible product APIs + +### `eyrie` + +Owns: + +- provider adapters +- request/response normalization +- streaming transport +- retry and timeout logic + +### `yaad` + +Owns: + +- memory retrieval +- memory persistence +- summarization support for long-lived context + +### `tok` + +Owns: + +- token estimation +- context packing +- truncation strategy +- prompt-ready context shaping + +### `trace` + +Owns: + +- event capture +- replay artifacts +- provenance/session evidence +- audit-grade traceability + +### `sight` + +Owns: + +- review heuristics +- issue/finding generation +- normalized review output at the Hawk boundary + +### `inspect` + +Owns: + +- verification heuristics +- test/assertion result normalization +- final pass/fail output at the Hawk boundary + +## Why all support engines are at the same level + +They are all peers because Hawk is the orchestrator. + +That means: + +- `eyrie` is not "above" `sight` or `inspect` +- `sight` is not "below" execution +- `inspect` is not a child of review +- `yaad` and `tok` are not shared utility packages for other engines + +Hawk calls whichever engine is needed for a given turn. + +## Future cloud position + +Future GrayCodeAI cloud or platform work should sit above products, not inside +the support-engine mesh. + +Correct future shape: + +```text +GrayCodeAI company/platform +├── web/docs +├── accounts +├── billing +├── hosted control plane +├── org/admin services +└── product gateways + ├── Hawk + ├── Lark + └── Gitant +``` + +For Hawk specifically: + +- local CLI runtime should still work without GrayCode cloud +- cloud can add hosted sessions, org policy, billing, identity, telemetry, or remote execution later +- none of that should force `eyrie`, `yaad`, `tok`, `trace`, `sight`, or `inspect` to depend on platform repos + +## What belongs in `graycode-core` + +Good candidates: + +- company website +- account system +- billing +- control plane +- docs portal +- admin and org management + +Not a good candidate: + +- Hawk runtime-critical engine logic +- direct support-engine dependencies + +## Practical rule set + +When adding new work: + +1. If it is user-facing product behavior, put it in `hawk`. +2. If it is specialized execution/memory/context/review/verify/trace capability, put it in the matching support engine. +3. If multiple repos need the same DTO or vocabulary, move that contract into `hawk-core-contracts`. +4. If it is an integration surface for external developers, put it in an SDK or skill repo. +5. If it is company platform or cloud control-plane work, keep it outside Hawk runtime repos. + +## Final recommendation + +Keep this exact model: + +- one primary product repo: `hawk` +- six peer support engines: `eyrie`, `yaad`, `tok`, `trace`, `sight`, `inspect` +- one shared contract repo: `hawk-core-contracts` +- three extension repos: `hawk-sdk-go`, `hawk-sdk-python`, `hawk-community-skills` + +This is the cleanest shape for OSS clarity, production hardening, and future scale. diff --git a/docs/architecture/hawk-product-architecture.md b/docs/architecture/hawk-product-architecture.md new file mode 100644 index 00000000..9c59ccf9 --- /dev/null +++ b/docs/architecture/hawk-product-architecture.md @@ -0,0 +1,227 @@ +# Hawk Product Architecture + +## Product statement + +Hawk is a model-agnostic AI coding agent CLI from GrayCodeAI. + +Hawk is the only primary product surface in the Hawk ecosystem. The support repos exist to power Hawk, not to compete with it as standalone products. + +## Goals + +- Keep Hawk model agnostic. +- Keep Hawk CLI-first and local-first. +- Keep provider integration pluggable. +- Keep support engines isolated from each other. +- Make trace, review, and verification first-class. +- Keep the design ready for a future hosted layer without making cloud a dependency. + +## Target repo set + +- `hawk` +- `eyrie` +- `yaad` +- `tok` +- `trace` +- `sight` +- `inspect` +- `hawk-sdk-go` +- `hawk-sdk-python` +- `hawk-community-skills` +- `hawk-core-contracts` + +## Runtime architecture + +```text +Users / SDKs / Skills + | + v + HAWK + | + +-----------------------------+ + | | + v v + core execution trust / quality + eyrie yaad tok trace sight inspect + \ | / \ | / + \ | / \ | / + +--+--+---------------------+--+--+ + | + v + hawk-core-contracts +``` + +## Current vs proposed + +Current implementation in this repo: + +```text +hawk + -> eyrie + -> yaad + -> tok + -> trace + -> sight + -> inspect + -> hawk-core-contracts + +support engines + -> hawk-core-contracts only when they need shared contracts + x-> each other +``` + +Proposed steady-state architecture: + +```text +SDKs / Skills / future integrations + | + v + Hawk + | + +------------+------------+------------+------------+------------+------------+ + | | | | | | | + v v v v v v v + Eyrie Yaad Tok Trace Sight Inspect public APIs + \ | | | | / + +-----------+------------+------------+------------+-----------+ + | + v + hawk-core-contracts +``` + +This means: + +- Hawk is the product and orchestration boundary +- all six engines stay at the same architectural level +- engines remain independent from each other +- shared cross-repo vocabulary lives below them in `hawk-core-contracts` +- SDKs and community skills consume Hawk, not the engines directly + +## Hawk responsibilities + +Hawk owns: + +- CLI entrypoints +- session lifecycle +- orchestration +- workflow control +- tool routing +- permission and policy enforcement +- provider selection +- engine coordination +- public integration surfaces + +Hawk does not own: + +- provider-specific implementation details +- engine-specific business logic +- future company-wide auth/billing/platform concerns + +## Engine responsibilities + +### `eyrie` +- provider adapters +- request/response normalization +- streaming +- retries/timeouts/fallbacks +- low-level provider registry and compatibility logic behind Hawk-owned transport adapters + +### `yaad` +- session and long-term memory +- retrieval hooks +- summaries and persistence contracts + +### `tok` +- token budgeting +- context ranking +- packing and truncation +- model-ready context assembly inputs + +### `trace` +- event capture +- replay records +- provenance +- audit visibility + +### `sight` +- review findings +- risk detection +- code quality analysis +- review-engine-local output converted into shared `hawk-core-contracts/review` contracts at product boundaries + +### `inspect` +- verification checks +- test/assertion normalization +- final pass/fail validation +- verification-engine-local output converted into shared `hawk-core-contracts/verify` contracts at product boundaries + +## Primary runtime flow + +1. User invokes `hawk`. +2. Hawk loads config, policy, provider settings, and workspace state. +3. Hawk creates or resumes a session. +4. Hawk asks `tok` for context assembly. +5. Hawk asks `yaad` for relevant memory. +6. Hawk routes provider execution through `eyrie`. +7. Hawk invokes tools and records actions through `trace`. +8. Hawk invokes `sight` when review should run. +9. Hawk invokes `inspect` when verification should run. +10. Hawk persists results and returns output to the user. + +## Implementation phases + +### Phase 1 +- freeze architecture rules +- document repo roles +- define shared contracts inventory + +### Phase 2 +- add `hawk-core-contracts` +- move shared types out of Hawk internals + +Status: +- completed +- shared contracts now exist for `types`, `review`, `verify`, `tools`, `events`, and `policy` + +### Phase 3 +- remove engine imports of Hawk internals +- remove engine-to-engine coupling + +Status: +- completed for current workspace boundaries +- local/CI guards now block support-repo imports of `hawk/internal/*` and removed legacy `hawk/shared/types` + +### Phase 4 +- harden orchestration boundaries in Hawk +- formalize provider, trace, review, and verify integration points + +Status: +- substantially completed +- Hawk now owns runtime DTOs, transport config/provider seams, and review/verify product-boundary contracts +- direct `eyrie/client` usage is restricted to adapter edges and enforced by shell guards plus meta-audit tests + +### Phase 5 +- align SDKs and skills to Hawk public interfaces only + +Status: +- policy is now explicit and guarded in Hawk docs +- `hawk-sdk-go` is covered by the support-repo coupling guard so it cannot grow + direct engine imports +- broader non-Go consumer enforcement remains future work + +### Phase 6 +- remove legacy `hawk/shared/types` +- keep import guards in place so the old path cannot return + +Status: +- completed in the local ecosystem + +## Done criteria + +The architecture is in good shape when: + +- `hawk` is the only product surface +- engines depend only on `hawk-core-contracts` +- shared types no longer live in Hawk internals as a cross-repo API +- provider abstraction is stable +- trace, review, and verification are part of the standard runtime flow +- deprecated compatibility surfaces have a documented removal path and active guardrails diff --git a/docs/architecture/hawk-provider-abstraction.md b/docs/architecture/hawk-provider-abstraction.md new file mode 100644 index 00000000..e81268d5 --- /dev/null +++ b/docs/architecture/hawk-provider-abstraction.md @@ -0,0 +1,65 @@ +# Hawk Provider Abstraction + +## Goal + +Hawk must remain model agnostic. + +That means Hawk should support multiple providers without leaking provider-specific assumptions across the product. + +## Design principle + +Provider-specific code lives behind runtime adapters, primarily in `eyrie`. + +Hawk decides: + +- which provider to use +- whether fallback is allowed +- what capability the task needs + +`eyrie` handles: + +- request translation +- streaming normalization +- tool-call normalization +- retries and backoff +- provider capability differences + +## Required capabilities + +- chat completion +- streaming +- tool calls +- model metadata +- token usage reporting +- error classification +- timeout/cancellation support + +## Hawk-facing abstraction + +Hawk should depend on a capability-based interface, not a vendor-specific client. + +Example concerns: + +- `RunTurn` +- `StreamTurn` +- `SupportsTools` +- `SupportsVision` +- `SupportsLongContext` +- `SupportsJSONMode` + +## Rules + +- no direct vendor SDK imports in unrelated Hawk packages +- no provider-specific branches inside review/verify logic +- no model-specific assumptions inside session persistence +- keep fallback/routing policy inside Hawk orchestration, not inside engines unrelated to runtime + +## Future extension + +This design allows: + +- local models +- hosted APIs +- custom gateways +- enterprise proxies +- model routing by capability or policy diff --git a/docs/architecture/hawk-repo-roles.md b/docs/architecture/hawk-repo-roles.md new file mode 100644 index 00000000..78429301 --- /dev/null +++ b/docs/architecture/hawk-repo-roles.md @@ -0,0 +1,77 @@ +# Hawk Repo Roles + +## Product repo + +### `hawk` +Main product repo. + +Owns: + +- CLI +- daemon/API surface +- agent loop +- session orchestration +- tool execution flow +- policy and permissions +- engine coordination + +Hawk is the only primary product surface. Users interact with Hawk, not with six +separate end-user products. + +## Support engines + +### `eyrie` +Hawk runtime and provider execution engine. + +### `yaad` +Hawk memory engine. + +### `tok` +Hawk context and token engine. + +### `trace` +Hawk audit and replay engine. + +### `sight` +Hawk review engine. + +### `inspect` +Hawk verification engine. + +All six support engines are peers: + +- `eyrie` +- `yaad` +- `tok` +- `trace` +- `sight` +- `inspect` + +They should stay isolated from each other and depend on Hawk only through +orchestration plus `hawk-core-contracts` where a shared vocabulary is required. + +## Ecosystem repos + +### `hawk-sdk-go` +Go integration surface for Hawk public APIs/contracts. + +### `hawk-sdk-python` +Python integration surface for Hawk public APIs/contracts. + +### `hawk-community-skills` +Reusable Hawk skills, recipes, and extension packs. + +## Shared foundation + +### `hawk-core-contracts` +Shared types, events, findings, policies, and engine request/response contracts. + +This repo should stay small, stable, and implementation-free. + +## Role rules + +- Users should feel they are using `hawk`, not six unrelated tools. +- Engines are internal capabilities from a product perspective. +- Engines can stay in separate repos for isolation, testing, and replacement. +- Engines must not import each other. +- SDKs and skills extend Hawk, but should not bypass Hawk to reach engines directly. diff --git a/docs/architecture/hawk-review-verify-lifecycle.md b/docs/architecture/hawk-review-verify-lifecycle.md new file mode 100644 index 00000000..874f16f7 --- /dev/null +++ b/docs/architecture/hawk-review-verify-lifecycle.md @@ -0,0 +1,81 @@ +# Hawk Review and Verify Lifecycle + +## Goal + +Review and verification should be standard parts of Hawk's workflow, not optional bolt-ons. + +## Roles + +### `sight` +Answers: + +- what looks wrong +- what is risky +- what likely regressed + +### `inspect` +Answers: + +- did the output pass checks +- did the result actually work +- did required validation complete + +## Suggested lifecycle + +### Before changes +Optional review pass for: + +- risky repo areas +- policy-sensitive files +- planning guidance + +### During execution +Trace every major action: + +- prompt turn +- tool call +- file edit +- command execution +- provider invocation + +### After changes +Run `sight` for: + +- code review +- risk detection +- regression hints + +Run `inspect` for: + +- test execution summaries +- assertion results +- build/lint/check results +- final validation status + +## Decision policy + +Hawk should define when review and verification are: + +- required +- recommended +- skipped + +Example factors: + +- file sensitivity +- command risk +- user mode +- task type +- presence of tests/checks + +## Output model + +Normalized outputs should include: + +- status +- findings +- severity +- evidence +- recommended next action + +These outputs should become contract types in `hawk-core-contracts`. diff --git a/docs/architecture/hawk-trace-event-model.md b/docs/architecture/hawk-trace-event-model.md new file mode 100644 index 00000000..0527bd55 --- /dev/null +++ b/docs/architecture/hawk-trace-event-model.md @@ -0,0 +1,83 @@ +# Hawk Trace Event Model + +## Goal + +`trace` should capture enough structured information to support: + +- replay +- audit +- debugging +- future hosted/team observability + +## Event categories + +### Session events +- session started +- session resumed +- session ended +- mode changed + +### Model/runtime events +- provider selected +- model selected +- turn started +- turn completed +- streaming started/stopped +- runtime error + +### Tool events +- tool requested +- tool approved/denied +- tool started +- tool completed +- tool failed + +### File/system events +- file read +- file edited +- command executed +- sandbox decision + +### Review/verify events +- review started +- review completed +- verify started +- verify completed +- findings emitted + +## Minimum event fields + +- event id +- session id +- timestamp +- actor +- component +- event type +- status +- correlation id +- payload metadata + +## Design rules + +- trace should capture metadata and references, not dump unnecessary raw secrets +- trace should be structured first, human-readable second +- event types should be stable enough for replay and future dashboards +- review and verification events should use the same contract vocabulary as their result objects + +## Near-term use + +In the local CLI world, trace primarily supports: + +- debugging +- reproduction +- replay +- audit of what Hawk changed and why + +## Long-term use + +This same event model can later support: + +- hosted dashboards +- enterprise audit logs +- team usage analytics +- policy enforcement evidence diff --git a/docs/ecosystem-message-flow.md b/docs/ecosystem-message-flow.md index f0475d0d..31014f00 100644 --- a/docs/ecosystem-message-flow.md +++ b/docs/ecosystem-message-flow.md @@ -95,4 +95,5 @@ hawk yaad # inspect memory graph | **yaad** | SQLite memory graph at `~/.yaad/data/` | No (degrades gracefully) | | **tok** | Token estimate + context compression | Yes (embedded, no config) | -Workspace checkouts live under `external/{eyrie,yaad,tok}` and are wired via root `go.work`. +Pinned support-repo submodules live under `external/{eyrie,yaad,tok}` and are +wired via root `go.work`. diff --git a/docs/plans/architecture-upstream-release-plan.md b/docs/plans/architecture-upstream-release-plan.md new file mode 100644 index 00000000..3c7008f0 --- /dev/null +++ b/docs/plans/architecture-upstream-release-plan.md @@ -0,0 +1,210 @@ +# Plan: Hawk Architecture Upstream and Release Convergence + +> Status: ready for execution +> Scope: push, merge, repin, verify, and release the Hawk ecosystem architecture work +> Goal: move the locally verified Hawk-centered architecture into upstream default branches and aligned published versions + +## Purpose + +The architecture cleanup is complete in the local `hawk-eco` workspace. + +This plan covers the remaining operational work: + +- push local branches upstream +- open and merge PRs in dependency order +- repin Hawk submodules to merged upstream SHAs +- rerun final integration verification +- publish tags/modules only after upstream convergence + +## Principles + +1. Merge shared contracts first. +2. Merge support-engine boundaries before Hawk. +3. Merge consumer guards after the engine direction is settled. +4. Merge Hawk last, because Hawk pins the support repos. +5. Do not redesign architecture during release convergence. + +## Repo order + +### Phase 1: shared contract base + +1. `hawk-core-contracts` + +### Phase 2: support engines + +2. `sight` +3. `inspect` +4. `eyrie` +5. `yaad` +6. `trace` +7. `tok` + +### Phase 3: consumers + +8. `hawk-sdk-go` +9. `hawk-sdk-python` +10. `hawk-community-skills` + +### Phase 4: product repo + +11. `hawk` + +## Repo board + +| Repo | Branch | Commit | PR title | Merge gate | +|---|---|---|---|---| +| `hawk-core-contracts` | `main` | `f9989e5` | `docs: describe hawk-core-contracts as the live cross-repo API` | none | +| `sight` | `feat/contracts-migration` | `b990666` | `feat(contracts): migrate to hawk-core-contracts and enforce boundary` | `hawk-core-contracts` merged | +| `inspect` | `feat/contracts-migration` | `d6ca739` | `feat(contracts): migrate to hawk-core-contracts and enforce boundary` | `hawk-core-contracts` merged | +| `eyrie` | `feat/ecosystem-boundary-guard` | `c1a6a4d` | `docs: remove legacy shared types references` | `hawk-core-contracts` merged | +| `yaad` | `feat/ecosystem-boundary-guard` | `010178d` | `chore: strip Co-authored-by trailers in lefthook hooks` | `hawk-core-contracts` merged | +| `trace` | `feat/ecosystem-boundary-guard` | `735e3f4` | `chore: strip Co-authored-by trailers in lefthook hooks` | `hawk-core-contracts` merged | +| `tok` | `feat/contracts-types-realignment` | `83cfc551` | `refactor: remove tok types compatibility shim` | `hawk-core-contracts` merged | +| `hawk-sdk-go` | `ci/consumer-boundary-guard` | `97b523e` | `ci: guard sdk-go consumer boundaries` | support-engine direction settled | +| `hawk-sdk-python` | `ci/consumer-boundary-guard` | `c43ad43` | `ci: guard sdk-python consumer boundaries` | support-engine direction settled | +| `hawk-community-skills` | `ci/consumer-boundary-guard` | `350f4f2c6` | `ci: guard skills consumer boundaries` | support-engine direction settled | +| `hawk` | `docs/contracts-architecture-truth` | `a2a4583` | `chore: align hawk external architecture snapshot` | all upstream support repos merged | +| `hawk` | `docs/contracts-architecture-truth` | `46697b9` | `docs: retire tok types shim references` | `tok` merged | +| `hawk` | `docs/contracts-architecture-truth` | `c204597` | `docs: normalize architecture status` | final architecture state agreed | + +## Execution checklist + +### Phase 1: push branches + +For each repo: + +1. confirm working tree is clean +2. push the local branch +3. open PR with the planned title/summary +4. wait for CI + +Suggested command pattern: + +```bash +git -C push -u origin +gh -R GrayCodeAI/ pr create --fill +``` + +### Phase 2: merge in dependency order + +Merge order: + +1. `hawk-core-contracts` +2. `sight` +3. `inspect` +4. `eyrie` +5. `yaad` +6. `trace` +7. `tok` +8. `hawk-sdk-go` +9. `hawk-sdk-python` +10. `hawk-community-skills` +11. `hawk` + +Rules: + +- do not merge `hawk` before the support repos +- do not publish module tags before merge convergence +- if upstream rebases or squashes PRs, treat the merged upstream SHA as the new source of truth + +### Phase 3: repin Hawk + +After the support-repo PRs merge: + +1. fetch upstream default branches for all pinned repos +2. update `hawk/external/*` to the merged upstream SHAs +3. run `go work sync` +4. rerun Hawk verification +5. commit the repin if the upstream SHAs differ from current local pins + +Checks: + +- `external/eyrie` +- `external/hawk-core-contracts` +- `external/inspect` +- `external/sight` +- `external/tok` +- `external/trace` +- `external/yaad` + +### Phase 4: final verification + +From `hawk`: + +```bash +/bin/sh ./scripts/check-shared-types-imports.sh +/bin/sh ./scripts/check-ecosystem-boundaries.sh +go work sync +go test ./internal/testaudit -count=1 +``` + +From support repos: + +```bash +/bin/sh ./scripts/check-ecosystem-boundaries.sh +go test ./... -count=1 +``` + +From consumer repos: + +```bash +/bin/sh ./scripts/check-consumer-boundaries.sh +``` + +## Release/tag guidance + +Only after merge convergence: + +1. decide which repos need tags immediately +2. publish `hawk-core-contracts` first if modules consume tagged versions +3. publish any support repos whose released versions are referenced by Hawk or external consumers +4. verify Hawk docs/examples do not claim unpublished versions + +Minimum release check: + +- merged commit exists on upstream default branch +- CI green on merged branch +- version/tag points at the merged contract-compatible state + +## Risks and responses + +### Upstream merge SHA differs from local SHA + +Response: + +- update Hawk submodule pins to the merged upstream SHA +- rerun `go work sync` +- rerun Hawk verification + +### PR is squashed and commit messages change + +Response: + +- treat the merged branch state as canonical +- do not assume local commit SHAs remain valid for Hawk submodule pins + +### A support repo fails CI after merge + +Response: + +- stop before merging Hawk +- fix the support repo first +- only repin Hawk after the repaired upstream state is green + +### A published module version lags merged code + +Response: + +- do not claim release convergence yet +- tag/publish before calling the ecosystem release-ready + +## Exit criteria + +This plan is complete when: + +- all listed PRs are merged upstream +- Hawk submodules point at merged upstream SHAs +- Hawk verification passes against those SHAs +- any required published module versions match the merged architecture state +- no repo needs the old architecture path or compatibility shims + diff --git a/docs/plans/hawk-contracts-migration-backlog.md b/docs/plans/hawk-contracts-migration-backlog.md new file mode 100644 index 00000000..ead280dc --- /dev/null +++ b/docs/plans/hawk-contracts-migration-backlog.md @@ -0,0 +1,197 @@ +# Plan: Hawk Contracts Migration Backlog + +> Status: locally complete +> Scope: Hawk ecosystem architecture cleanup after introducing `hawk-core-contracts` +> Goal: keep `hawk` as the product while moving stable cross-repo contracts out of Hawk internals + +External follow-up still outside the scope of this local workspace audit: + +- confirm upstream branches contain the final architecture commits +- confirm published module tags/releases match the merged contract changes +- execute `architecture-upstream-release-plan.md` + +## Done + +These items are already completed in the current workspace. + +### Contracts repo scaffold +- created `hawk-core-contracts` +- added `go.mod` +- added package docs + +### Shared type migration +- added `hawk-core-contracts/types` +- moved severity and finding definitions into contracts +- migrated `sight` and `inspect` to import contracts +- switched Hawk `internal/types/severity.go` to re-export from contracts +- removed `hawk/shared/types` after local ecosystem migration +- removed the duplicate severity/finding definitions in `tok/types` (tok was the + original shared-types host) +- removed the `tok/types` compatibility shim after verifying no in-workspace + importers remained + +### Tool contract migration +- added `hawk-core-contracts/tools` +- switched Hawk session persistence to provider-neutral tool contracts +- added runtime/session conversion helpers +- added `hawk-core-contracts/tools/tool_test.go` +- centralized message slice conversion in `hawk/internal/session` +- removed direct `eyrie/client` message reconstruction from Hawk cmd/session restore paths +- replaced the `internal/types.EyrieMessage` alias with a Hawk-owned runtime DTO +- added explicit `internal/types` adapters between Hawk runtime messages and `eyrie/client` + +### Event contract migration +- added `hawk-core-contracts/events` +- switched normalized audit `ToolEvent` to shared contract +- switched Langfuse trace event model to shared contract +- added `hawk-core-contracts/events/events_test.go` + +### Policy contract migration +- added `hawk-core-contracts/policy` +- switched permission verdict and guardian decision to shared contracts +- switched engine permission request to embed the shared request contract +- added `hawk-core-contracts/policy/policy_test.go` + +### Governance +- added `check-shared-types-imports.sh` +- added `check-ecosystem-boundaries.sh` +- added `check-eyrie-client-imports.sh` +- wired both guards into `Makefile` and CI +- wired the `eyrie/client` boundary guard into `Makefile` and CI +- added a legacy import guard so the removed `shared/types` path cannot return +- extended the ecosystem boundary guard to scan sibling engine repos when present locally +- updated docs across Hawk, sight, inspect, and external workspace copies +- added standalone boundary guards in `sight` and `inspect` +- added standalone boundary guards in `tok`, `eyrie`, `yaad`, and `trace` +- updated support repo READMEs with ecosystem boundary rules + +### Review and verification contract migration +- added `hawk-core-contracts/review` +- added `hawk-core-contracts/verify` +- added shared review/verification contract tests +- added sight -> review contract adapters +- added inspect -> verify contract adapters +- switched Hawk review persistence to neutral review contracts +- switched Hawk review/inspect bridge paths to return neutral review/verify contracts + +## Remaining external follow-up + +These are the highest-value follow-up tasks that this local workspace cannot +prove automatically. + +### 1. Confirm upstream/default-branch convergence + +Local state: +- implemented and verified in this workspace + +Still external: +- confirm the same commits are merged on the intended upstream default branches + +### 2. Confirm release/publication convergence + +Local state: +- Hawk local integration snapshot points at architecture-aligned support-repo revisions + +Still external: +- confirm released module versions used by Hawk match the merged contract changes + +### 3. Decide whether Hawk session/API message DTOs should fully stop using `eyrie/client` types +Current state: +- session persistence uses neutral tool contracts +- Hawk now owns the runtime message DTO in `internal/types.EyrieMessage` +- Hawk now owns runtime tool call/result DTOs in `internal/types` +- Hawk now owns runtime response/usage/stream DTOs in `internal/types` +- Hawk now owns runtime chat options, response format, tool choice, continuation config, and tool definition DTOs in `internal/types` +- Hawk now owns transport client construction config in `internal/types.ClientConfig` +- Hawk now owns the transport-provider seam in `internal/types.ChatProvider` +- `eyrie/client` is now adapted only at the `internal/types` edge and a few provider-registry helper paths +- cmd/session restore paths now go through centralized `session.ToRuntimeMessages` and `session.FromRuntimeMessages` + +Decision: +- completed: Hawk now owns the transport config/provider seam +- keep direct `eyrie/client` usage limited to adapter implementations and provider-registry integration only + +This should be a deliberate decision, not drift. + +## Later + +These are useful, but should be done only if they solve real cross-repo pain. + +### 3. Add session contracts +Potential package: +- `hawk-core-contracts/sessions` + +Candidates: +- `SessionID` +- `SessionSummary` +- portable persisted message DTOs + +Do this only if another repo truly needs them. + +### 4. Decide whether more review/verification metadata should move into contracts +Current state: +- normalized review result contracts now live in `hawk-core-contracts/review` +- normalized verification report contracts now live in `hawk-core-contracts/verify` +- Hawk consumes neutral review/verification contracts at persistence and bridge boundaries + +Possible later additions: +- review lifecycle status enums if another repo needs them +- SAST fusion metadata if it needs to cross repo boundaries +- richer verification provenance fields beyond the current shared report + +### 5. Add trace/timeline event families beyond the current normalized contracts +Current `events` package is intentionally minimal. + +Possible later additions: +- session lifecycle events +- verification events +- workflow events +- tool execution lifecycle events + +Do not move every internal event shape by default. + +### 6. Remove `hawk/shared/types` +Completed for the local ecosystem. + +Current status: + +- Hawk no longer ships `hawk/shared/types` +- local import guards prevent the old path from returning + +## Non-goals + +Do not do these without a separate decision: + +- move provider runtime types into contracts +- move Hawk orchestration logic into contracts +- move sandbox manager internals into contracts +- move every event struct in Hawk into contracts +- force Lark/Gitant architecture into Hawk contracts + +## Recommended PR order + +### PR 1 +- completed: moved chat options/request DTOs behind Hawk-owned runtime adapters + +### PR 2 +- completed: moved provider/config interfaces behind Hawk-owned transport adapters + +### PR 3 +- continue reducing any remaining non-adapter direct `eyrie/client` imports in Hawk where it improves clarity + +### PR 4 +- completed: added neutral review/verification result contracts and wired Hawk bridge/persistence edges + +### PR 5 +- completed: removed `hawk/shared/types` from the local ecosystem and kept a legacy import guard + +## Success criteria + +The migration is in a good long-term state when: + +- `hawk-core-contracts` is the only source of truth for shared contracts +- support repos do not import `hawk/internal/*` +- support repos do not import `hawk/shared/types` +- removed compatibility shims do not return +- CI prevents regressions +- new shared contracts are added deliberately, not by habit diff --git a/external/eyrie b/external/eyrie index 9c736018..c1a6a4db 160000 --- a/external/eyrie +++ b/external/eyrie @@ -1 +1 @@ -Subproject commit 9c736018d8a8a4c01e7832e08bf77836a3d21cc8 +Subproject commit c1a6a4dbd86d56af072c46ebe28d2131b180312d diff --git a/external/hawk-core-contracts b/external/hawk-core-contracts new file mode 160000 index 00000000..f9989e56 --- /dev/null +++ b/external/hawk-core-contracts @@ -0,0 +1 @@ +Subproject commit f9989e563ef3931754ff4d197d49ee154fcc7391 diff --git a/external/inspect b/external/inspect index 4f2b7272..d6ca739a 160000 --- a/external/inspect +++ b/external/inspect @@ -1 +1 @@ -Subproject commit 4f2b72720b112dc7f2d52a19eea2066944c40164 +Subproject commit d6ca739aa90a2e15dfada7ce729b4b128ea96449 diff --git a/external/sight b/external/sight index 0d98d703..b9906664 160000 --- a/external/sight +++ b/external/sight @@ -1 +1 @@ -Subproject commit 0d98d70308f73126f8362a7996fc865f98a742e3 +Subproject commit b9906664f496f86b4fbe9b37d95c42dd9d15a30f diff --git a/external/tok b/external/tok index 0c99da32..83cfc551 160000 --- a/external/tok +++ b/external/tok @@ -1 +1 @@ -Subproject commit 0c99da32a672b0157c2537439f085a7c6b215cc0 +Subproject commit 83cfc55199ac759a2d66ec5b952cacb05b6f8102 diff --git a/external/trace b/external/trace index a3dd0cbe..735e3f4e 160000 --- a/external/trace +++ b/external/trace @@ -1 +1 @@ -Subproject commit a3dd0cbee114bd6d23f81de9101aecd5a769811e +Subproject commit 735e3f4e0a55d6f50656b9b6714235cb3b09ab8e diff --git a/external/yaad b/external/yaad index ceb690ae..010178d5 160000 --- a/external/yaad +++ b/external/yaad @@ -1 +1 @@ -Subproject commit ceb690ae823bcd0f8c2b6d0725edceb75cd0ae2e +Subproject commit 010178d5ee7c58ca23902d12bd8c28d727b728c6 diff --git a/go.mod b/go.mod index 3d1ec1c5..e6e3fde9 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.4 require ( github.com/GrayCodeAI/eyrie v0.1.0 + github.com/GrayCodeAI/hawk-core-contracts v0.1.0 github.com/GrayCodeAI/inspect v0.1.0 github.com/GrayCodeAI/sight v0.1.0 github.com/GrayCodeAI/tok v0.1.0 diff --git a/go.work b/go.work index 521ced11..958f73ce 100644 --- a/go.work +++ b/go.work @@ -4,6 +4,7 @@ use . replace ( github.com/GrayCodeAI/eyrie => ./external/eyrie + github.com/GrayCodeAI/hawk-core-contracts => ./external/hawk-core-contracts github.com/GrayCodeAI/inspect => ./external/inspect github.com/GrayCodeAI/sight => ./external/sight github.com/GrayCodeAI/tok => ./external/tok diff --git a/internal/bridge/inspect/bridge.go b/internal/bridge/inspect/bridge.go index 3b5387d3..d6ec5cac 100644 --- a/internal/bridge/inspect/bridge.go +++ b/internal/bridge/inspect/bridge.go @@ -4,6 +4,7 @@ import ( "context" "sync" + verifycontracts "github.com/GrayCodeAI/hawk-core-contracts/verify" inspectLib "github.com/GrayCodeAI/inspect" ) @@ -52,3 +53,12 @@ func (b *Bridge) Run(ctx context.Context, target string, opts ...inspectLib.Opti } return b.scanner.Scan(ctx, target) } + +// RunContracts performs a verification scan and returns the neutral verification contract. +func (b *Bridge) RunContracts(ctx context.Context, target string, opts ...inspectLib.Option) (*verifycontracts.Report, error) { + report, err := b.Run(ctx, target, opts...) + if err != nil { + return nil, err + } + return inspectLib.ToContractReport(report), nil +} diff --git a/internal/bridge/sight/bridge.go b/internal/bridge/sight/bridge.go index d05a0985..44c638f5 100644 --- a/internal/bridge/sight/bridge.go +++ b/internal/bridge/sight/bridge.go @@ -4,30 +4,30 @@ import ( "context" "sync" - "github.com/GrayCodeAI/eyrie/client" + reviewcontracts "github.com/GrayCodeAI/hawk-core-contracts/review" + "github.com/GrayCodeAI/hawk/internal/types" sightLib "github.com/GrayCodeAI/sight" ) // EyrieAdapter implements sight's Provider interface using hawk's eyrie client. -// It translates between sight.Message/sight.ChatOpts and eyrie's -// client.EyrieMessage/client.ChatOptions. +// It translates between sight.Message/sight.ChatOpts and Hawk runtime DTOs. type EyrieAdapter struct { - client *client.EyrieClient + client *types.EyrieClient provider string } // NewEyrieAdapter creates an adapter that satisfies sight.Provider using // the given eyrie client and provider name (e.g. "anthropic", "openai"). -func NewEyrieAdapter(c *client.EyrieClient, provider string) *EyrieAdapter { +func NewEyrieAdapter(c *types.EyrieClient, provider string) *EyrieAdapter { return &EyrieAdapter{client: c, provider: provider} } // Chat translates a sight LLM request into an eyrie call and returns the // result in sight's Response format. func (a *EyrieAdapter) Chat(ctx context.Context, messages []sightLib.Message, opts sightLib.ChatOpts) (*sightLib.Response, error) { - eyrieMessages := make([]client.EyrieMessage, len(messages)) + eyrieMessages := make([]types.EyrieMessage, len(messages)) for i, m := range messages { - eyrieMessages[i] = client.EyrieMessage{ + eyrieMessages[i] = types.EyrieMessage{ Role: m.Role, Content: m.Content, } @@ -39,7 +39,7 @@ func (a *EyrieAdapter) Chat(ctx context.Context, messages []sightLib.Message, op temp = &t } - eyrieOpts := client.ChatOptions{ + eyrieOpts := types.ChatOptions{ Provider: a.provider, Model: opts.Model, MaxTokens: opts.MaxTokens, @@ -74,16 +74,16 @@ type Bridge struct { ready bool } -// NewBridge creates a bridge to the sight library using the given eyrie -// client and provider name. Additional sight options (model, concerns, etc.) -// are applied to all operations. -func NewBridge(c *client.EyrieClient, provider string, opts ...sightLib.Option) *Bridge { +// NewBridge creates a bridge to the sight library using the given Hawk +// transport client and provider name. Additional sight options (model, +// concerns, etc.) are applied to all operations. +func NewBridge(c *types.EyrieClient, provider string, opts ...sightLib.Option) *Bridge { b := &Bridge{} b.init(c, provider, opts...) return b } -func (b *Bridge) init(c *client.EyrieClient, provider string, opts ...sightLib.Option) { +func (b *Bridge) init(c *types.EyrieClient, provider string, opts ...sightLib.Option) { if c == nil { return } @@ -111,6 +111,15 @@ func (b *Bridge) Review(ctx context.Context, diff string) (*sightLib.Result, err return b.reviewer.Review(ctx, diff) } +// ReviewContracts performs a review and returns the neutral review contract. +func (b *Bridge) ReviewContracts(ctx context.Context, diff string) (*reviewcontracts.Result, error) { + result, err := b.Review(ctx, diff) + if err != nil { + return nil, err + } + return sightLib.ToContractResult(result), nil +} + // Describe generates a PR description from a unified diff string. // Falls back silently if the bridge is not initialized. func (b *Bridge) Describe(ctx context.Context, diff string) (*sightLib.Description, error) { diff --git a/internal/daemon/gateway.go b/internal/daemon/gateway.go index 1773d7ca..8dcd004a 100644 --- a/internal/daemon/gateway.go +++ b/internal/daemon/gateway.go @@ -2,6 +2,7 @@ package daemon import ( "context" + "crypto/subtle" "encoding/json" "io" "log/slog" @@ -147,7 +148,9 @@ func (a *authorizer) tryPair(senderID, text string) (isPair, ok bool) { if len(fields) > 1 { supplied = fields[1] } - if supplied != a.pairingCode { + // Constant-time compare so the pairing code is not exposed to a timing + // oracle. + if subtle.ConstantTimeCompare([]byte(supplied), []byte(a.pairingCode)) != 1 { return true, false } a.allow[senderID] = struct{}{} diff --git a/internal/daemon/routes_review.go b/internal/daemon/routes_review.go index bede4640..51fce66a 100644 --- a/internal/daemon/routes_review.go +++ b/internal/daemon/routes_review.go @@ -51,6 +51,10 @@ func (s *Server) handleReview(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "concerns must not start with '--'"}) return } + if strings.HasPrefix(req.Model, "--") { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model must not start with '--'"}) + return + } // Trigger review asynchronously via hawk review run. go func() { diff --git a/internal/engine/compact_strategy_test.go b/internal/engine/compact_strategy_test.go index 17b83799..8298b77f 100644 --- a/internal/engine/compact_strategy_test.go +++ b/internal/engine/compact_strategy_test.go @@ -5,10 +5,9 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/client" - "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/observability/metrics" + "github.com/GrayCodeAI/hawk/internal/types" ) func TestSessionMemoryStrategy_ShouldTrigger(t *testing.T) { @@ -61,7 +60,7 @@ func TestTruncateStrategy(t *testing.T) { messages: makeMessages(100), log: newTestLogger(), metrics: newTestMetrics(), - client: client.Client(&client.EyrieConfig{Provider: "test"}), + client: types.NewClient(&types.ClientConfig{Provider: "test"}), } s := &TruncateStrategy{} @@ -77,13 +76,13 @@ func TestTruncateStrategy(t *testing.T) { } } -func makeMessages(n int) []client.EyrieMessage { - msgs := make([]client.EyrieMessage, n) +func makeMessages(n int) []types.EyrieMessage { + msgs := make([]types.EyrieMessage, n) for i := range msgs { if i%2 == 0 { - msgs[i] = client.EyrieMessage{Role: "user", Content: strings.Repeat("message ", 50)} + msgs[i] = types.EyrieMessage{Role: "user", Content: strings.Repeat("message ", 50)} } else { - msgs[i] = client.EyrieMessage{Role: "assistant", Content: strings.Repeat("response ", 50)} + msgs[i] = types.EyrieMessage{Role: "assistant", Content: strings.Repeat("response ", 50)} } } return msgs diff --git a/internal/engine/context_compaction.go b/internal/engine/context_compaction.go index fce872c0..53b4f905 100644 --- a/internal/engine/context_compaction.go +++ b/internal/engine/context_compaction.go @@ -118,18 +118,7 @@ func (s *Session) checkpointManager() *session.CheckpointManager { } func rawToSessionMessages(raw []types.EyrieMessage) []session.Message { - out := make([]session.Message, 0, len(raw)) - for _, rm := range raw { - sm := session.Message{Role: rm.Role, Content: rm.Content} - if len(rm.ToolUse) > 0 { - sm.ToolUse = append(sm.ToolUse, rm.ToolUse...) - } - if len(rm.ToolResults) > 0 { - sm.ToolResults = append(sm.ToolResults, rm.ToolResults...) - } - out = append(out, sm) - } - return out + return session.FromRuntimeMessages(raw) } func (s *Session) recordCompaction(strategy string, tokensBefore, tokensAfter int, manual bool) { diff --git a/internal/engine/engine_integration_test.go b/internal/engine/engine_integration_test.go index d21c0626..8d14d508 100644 --- a/internal/engine/engine_integration_test.go +++ b/internal/engine/engine_integration_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + contracts "github.com/GrayCodeAI/hawk-core-contracts/policy" "github.com/GrayCodeAI/hawk/internal/types" "github.com/GrayCodeAI/hawk/internal/session" @@ -280,9 +281,11 @@ func TestIntegration_PermissionFlow(t *testing.T) { // Simulate calling the permission function. resp := make(chan bool, 1) sess.PermissionFn(PermissionRequest{ - ToolName: "Write", - ToolID: "test-id", - Summary: "/tmp/new-file.txt", + PermissionRequest: contracts.PermissionRequest{ + ToolName: "Write", + ToolID: "test-id", + Summary: "/tmp/new-file.txt", + }, Response: resp, }) if !permCalled { diff --git a/internal/engine/persistence_service.go b/internal/engine/persistence_service.go index 0ee16201..ef6f2d40 100644 --- a/internal/engine/persistence_service.go +++ b/internal/engine/persistence_service.go @@ -56,7 +56,11 @@ func NewPersistenceService(log *logger.Logger) *PersistenceService { func (s *PersistenceService) Messages() []types.EyrieMessage { s.mu.RLock() defer s.mu.RUnlock() - raw := s.RawMessages() + // Read s.messages directly; the lock is already held. Calling + // RawMessages() here would recursively RLock and can deadlock if a + // writer arrives between the two read locks (Go's RWMutex forbids + // recursive read-locking). + raw := s.messages out := make([]types.EyrieMessage, len(raw)) copy(out, raw) return out @@ -194,23 +198,29 @@ func (s *PersistenceService) SetSystem(sys string) { func (s *PersistenceService) MessageCount() int { s.mu.RLock() defer s.mu.RUnlock() - return len(s.RawMessages()) + // Direct field read; lock already held (avoid recursive RLock). + return len(s.messages) } // RemoveLastExchange removes the last (assistant, user) pair. func (s *PersistenceService) RemoveLastExchange() { s.mu.Lock() defer s.mu.Unlock() - if len(s.RawMessages()) < 2 { + // Operate on s.messages directly: the write lock is held, so calling + // the RawMessages()/SetRawMessages() accessors (which lock again) would + // deadlock. + if len(s.messages) < 2 { return } - s.SetRawMessages(s.RawMessages()[:len(s.RawMessages())-2]) + s.messages = s.messages[:len(s.messages)-2] } // LoadMessages replaces the transcript with a fresh slice. func (s *PersistenceService) LoadMessages(msgs []types.EyrieMessage) { s.mu.Lock() - s.SetRawMessages(msgs) + // Assign directly; the lock is held, so SetRawMessages() (which locks + // again) would deadlock on a recursive write lock. + s.messages = msgs s.mu.Unlock() } diff --git a/internal/engine/persistence_service_deadlock_test.go b/internal/engine/persistence_service_deadlock_test.go new file mode 100644 index 00000000..0fc3d62a --- /dev/null +++ b/internal/engine/persistence_service_deadlock_test.go @@ -0,0 +1,40 @@ +package engine + +import ( + "testing" + "time" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +// TestPersistenceServiceNoRecursiveLock guards against the recursive-lock +// deadlocks that previously existed in Messages, MessageCount, +// RemoveLastExchange, and LoadMessages. Each held s.mu and then called an +// accessor (RawMessages/SetRawMessages) that tried to take the same lock +// again — RemoveLastExchange and LoadMessages would hang unconditionally. +func TestPersistenceServiceNoRecursiveLock(t *testing.T) { + done := make(chan struct{}) + go func() { + defer close(done) + s := NewPersistenceService(nil) + s.LoadMessages([]types.EyrieMessage{ + {Role: "user", Content: "a"}, + {Role: "assistant", Content: "b"}, + }) + if got := s.MessageCount(); got != 2 { + t.Errorf("MessageCount = %d, want 2", got) + } + if got := len(s.Messages()); got != 2 { + t.Errorf("Messages len = %d, want 2", got) + } + s.RemoveLastExchange() + if got := s.MessageCount(); got != 0 { + t.Errorf("after RemoveLastExchange MessageCount = %d, want 0", got) + } + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("PersistenceService deadlocked (recursive lock acquisition)") + } +} diff --git a/internal/engine/provider_chat_client.go b/internal/engine/provider_chat_client.go index 4334d21d..70ec6093 100644 --- a/internal/engine/provider_chat_client.go +++ b/internal/engine/provider_chat_client.go @@ -8,11 +8,11 @@ import ( // providerChatClient adapts any eyrie types.Provider to ChatClient (continuations + streaming). type providerChatClient struct { - p types.Provider + p types.ChatProvider } // NewProviderChatClient wraps a catalog-backed provider (e.g. DeploymentRouter) for Session use. -func NewProviderChatClient(p types.Provider) ChatClient { +func NewProviderChatClient(p types.ChatProvider) ChatClient { return &providerChatClient{p: p} } diff --git a/internal/engine/safety/permission.go b/internal/engine/safety/permission.go index d09b97ab..14c180a8 100644 --- a/internal/engine/safety/permission.go +++ b/internal/engine/safety/permission.go @@ -6,14 +6,13 @@ import ( "strings" "sync" + contracts "github.com/GrayCodeAI/hawk-core-contracts/policy" "github.com/GrayCodeAI/hawk/internal/tool" ) // PermissionRequest is sent from engine to TUI when a tool needs approval. type PermissionRequest struct { - ToolName string - ToolID string - Summary string + contracts.PermissionRequest Response chan bool } diff --git a/internal/engine/safety/permission_engine.go b/internal/engine/safety/permission_engine.go index 93f83406..54350832 100644 --- a/internal/engine/safety/permission_engine.go +++ b/internal/engine/safety/permission_engine.go @@ -4,6 +4,7 @@ import ( "context" "time" + contracts "github.com/GrayCodeAI/hawk-core-contracts/policy" "github.com/GrayCodeAI/hawk/internal/permissions" ) @@ -81,9 +82,11 @@ func (pe *PermissionEngine) CheckTool(ctx context.Context, tc ToolCallInfo) (boo // Ask user resp := make(chan bool, 1) pe.PromptFn(PermissionRequest{ - ToolName: tc.Name, - ToolID: tc.ID, - Summary: summary, + PermissionRequest: contracts.PermissionRequest{ + ToolName: tc.Name, + ToolID: tc.ID, + Summary: summary, + }, Response: resp, }) select { diff --git a/internal/engine/session.go b/internal/engine/session.go index 431c67c1..53ec7bd7 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -240,7 +240,7 @@ type Session struct { // NewSession creates a new conversation session with a legacy string-named provider. func NewSession(provider, model, systemPrompt string, registry *tool.Registry) *Session { - return NewSessionWithClient(types.NewClient(&types.EyrieConfig{Provider: provider}), provider, model, systemPrompt, registry, false) + return NewSessionWithClient(types.NewClient(&types.ClientConfig{Provider: provider}), provider, model, systemPrompt, registry, false) } // NewSessionWithClient constructs a session with an explicit LLM client (e.g. deployment router). @@ -467,7 +467,7 @@ func (s *Session) SetProvider(provider string) { if s.DeploymentRouting { return } - s.client = types.NewClient(&types.EyrieConfig{Provider: p}) + s.client = types.NewClient(&types.ClientConfig{Provider: p}) // Copy keys to avoid map iteration race with concurrent SetAPIKey calls. keys := make(map[string]string, len(s.apiKeys)) for k, v := range s.apiKeys { diff --git a/internal/engine/session_factory.go b/internal/engine/session_factory.go index bbd932e5..9029135e 100644 --- a/internal/engine/session_factory.go +++ b/internal/engine/session_factory.go @@ -4,11 +4,11 @@ import ( "context" "fmt" - "github.com/GrayCodeAI/eyrie/client" eyriecfg "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/setup" "github.com/GrayCodeAI/hawk/internal/tool" + "github.com/GrayCodeAI/hawk/internal/types" ) // BuildChatClient returns an LLM client and whether deployment routing is active. @@ -17,10 +17,10 @@ func BuildChatClient(ctx context.Context, useDeploymentRouting bool, legacyProvi if useDeploymentRouting { p, err := setup.DeploymentProvider(ctx, cfg) if err == nil { - return NewProviderChatClient(p), legacyProvider, true + return NewProviderChatClient(types.WrapClientProvider(p)), legacyProvider, true } } - c := client.Client(&client.EyrieConfig{Provider: legacyProvider}) + c := types.NewClient(&types.ClientConfig{Provider: legacyProvider}) return c, legacyProvider, false } diff --git a/internal/engine/session_h3_h4_test.go b/internal/engine/session_h3_h4_test.go index f757563d..d9b4cca6 100644 --- a/internal/engine/session_h3_h4_test.go +++ b/internal/engine/session_h3_h4_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/GrayCodeAI/eyrie/storage" + "github.com/GrayCodeAI/hawk/internal/types" ) // TestSession_SetConvoDAG_DualWrite is a regression guard for the @@ -119,3 +120,31 @@ func TestPersistenceService_AddUserWithImage_DataURL(t *testing.T) { t.Errorf("content = %q, want 'look at this'", msgs[0].Content) } } + +func TestPersistenceService_RemoveLastExchangeAndCount(t *testing.T) { + t.Parallel() + + ps := NewPersistenceService(nil) + ps.LoadMessages([]types.EyrieMessage{ + {Role: "user", Content: "u1"}, + {Role: "assistant", Content: "a1"}, + {Role: "user", Content: "u2"}, + {Role: "assistant", Content: "a2"}, + }) + + if got := ps.MessageCount(); got != 4 { + t.Fatalf("MessageCount() = %d, want 4", got) + } + + ps.RemoveLastExchange() + msgs := ps.Messages() + if len(msgs) != 2 { + t.Fatalf("len(Messages()) = %d, want 2", len(msgs)) + } + if msgs[0].Content != "u1" || msgs[1].Content != "a1" { + t.Fatalf("unexpected remaining messages: %#v", msgs) + } + if got := ps.MessageCount(); got != 2 { + t.Fatalf("MessageCount() after remove = %d, want 2", got) + } +} diff --git a/internal/engine/subagent_synthesis_test.go b/internal/engine/subagent_synthesis_test.go index 6f9460a6..f038e289 100644 --- a/internal/engine/subagent_synthesis_test.go +++ b/internal/engine/subagent_synthesis_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/GrayCodeAI/eyrie/client" + "github.com/GrayCodeAI/hawk/internal/types" ) func TestSynthesizeSubAgent(t *testing.T) { @@ -14,7 +15,7 @@ func TestSynthesizeSubAgent(t *testing.T) { mockClient := &mockLLMForSynthesis{provider: mock} - conversation := []client.EyrieMessage{ + conversation := []types.EyrieMessage{ {Role: "user", Content: "Find all Go files with errors"}, {Role: "assistant", Content: "I'll search for Go files."}, } @@ -62,7 +63,7 @@ func TestSynthesizeSubAgent(t *testing.T) { }) t.Run("nil client returns error", func(t *testing.T) { - conversation := []client.EyrieMessage{ + conversation := []types.EyrieMessage{ {Role: "user", Content: "test"}, } _, err := SynthesizeSubAgent(context.Background(), nil, "model", conversation) @@ -75,7 +76,7 @@ func TestSynthesizeSubAgent(t *testing.T) { mock := client.NewMockProvider(client.MockModeError) mockClient := &mockLLMForSynthesis{provider: mock} - conversation := []client.EyrieMessage{ + conversation := []types.EyrieMessage{ {Role: "user", Content: "test"}, } @@ -90,7 +91,7 @@ func TestSynthesizeSubAgent(t *testing.T) { mock.Response = "" mockClient := &mockLLMForSynthesis{provider: mock} - conversation := []client.EyrieMessage{ + conversation := []types.EyrieMessage{ {Role: "user", Content: "test"}, } @@ -106,6 +107,10 @@ type mockLLMForSynthesis struct { provider *client.MockProvider } -func (m *mockLLMForSynthesis) Chat(ctx context.Context, msgs []client.EyrieMessage, opts client.ChatOptions) (*client.EyrieResponse, error) { - return m.provider.Chat(ctx, msgs, opts) +func (m *mockLLMForSynthesis) Chat(ctx context.Context, msgs []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) { + resp, err := m.provider.Chat(ctx, types.ToClientMessages(msgs), types.ToClientChatOptions(opts)) + if err != nil { + return nil, err + } + return types.FromClientResponse(resp), nil } diff --git a/internal/hooks/audit/detectors.go b/internal/hooks/audit/detectors.go index c30eb630..5fcf6ff3 100644 --- a/internal/hooks/audit/detectors.go +++ b/internal/hooks/audit/detectors.go @@ -10,7 +10,8 @@ import ( "regexp" "strconv" "strings" - "time" + + contracts "github.com/GrayCodeAI/hawk-core-contracts/events" ) // DetectorHit represents one detected occurrence of wasteful behavior. @@ -22,14 +23,7 @@ type DetectorHit struct { } // ToolEvent represents a normalized tool event from a transcript. -type ToolEvent struct { - ToolName string - ToolInput map[string]interface{} - CWD string - Timestamp time.Time - SessionID string - Transcript string -} +type ToolEvent = contracts.ToolEvent // DetectorSessionState holds per-session state for stateful detectors. type DetectorSessionState = map[string]interface{} diff --git a/internal/hooks/decision.go b/internal/hooks/decision.go index 1aa13342..d79dbd4d 100644 --- a/internal/hooks/decision.go +++ b/internal/hooks/decision.go @@ -130,33 +130,10 @@ func ExecuteDecisionHooks(event string, data map[string]interface{}) *HookDecisi continue } - // Execute with fail-open: catch panics and log errors - func() { - defer func() { - if r := recover(); r != nil { - slog.Warn("decision hook panicked (fail-open)", - "hook", h.Config.Name, - "event", event, - "tool", toolName, - "panic", r) - } - }() - if decision := h.Fn(event, data); decision != nil { - // Return the decision (caller will handle it) - // We can't return from inside the closure, so we use a wrapper - } - }() - - // Execute again to get the result (the closure above was for panic recovery) - decision := func() *HookDecision { - defer func() *HookDecision { - if r := recover(); r != nil { - return nil - } - return nil - }() - return h.Fn(event, data) - }() + // Execute exactly once with fail-open panic recovery. The previous + // implementation invoked h.Fn twice (once in a discard closure, once + // to capture the result), double-firing side-effecting hooks. + decision := safeExecuteHook(h, event, data, toolName) if decision != nil { return decision } diff --git a/internal/hooks/decision_test.go b/internal/hooks/decision_test.go index 4f83a9b6..048fe230 100644 --- a/internal/hooks/decision_test.go +++ b/internal/hooks/decision_test.go @@ -92,3 +92,22 @@ func TestDecisionHookNilWhenEmpty(t *testing.T) { t.Fatalf("expected nil when no hooks registered, got %+v", decision) } } + +func TestExecuteDecisionHooks_ExecutesHookOnlyOnce(t *testing.T) { + ResetDecisionHooks() + defer ResetDecisionHooks() + + calls := 0 + RegisterDecisionHook(func(event string, data map[string]interface{}) *HookDecision { + calls++ + return &HookDecision{Action: "allow", Reason: "once"} + }) + + decision := ExecuteDecisionHooks("any_event", nil) + if decision == nil { + t.Fatal("expected decision") + } + if calls != 1 { + t.Fatalf("hook called %d times, want 1", calls) + } +} diff --git a/internal/observability/oteltrace/langfuse.go b/internal/observability/oteltrace/langfuse.go index b280cd2b..7c464e5d 100644 --- a/internal/observability/oteltrace/langfuse.go +++ b/internal/observability/oteltrace/langfuse.go @@ -10,6 +10,8 @@ import ( "os" "sync" "time" + + contracts "github.com/GrayCodeAI/hawk-core-contracts/events" ) // LangfuseClient sends traces to Langfuse for LLM observability. @@ -29,25 +31,10 @@ type event struct { } // TraceEvent represents a single LLM call trace. -type TraceEvent struct { - ID string `json:"id"` - Name string `json:"name"` - Input string `json:"input,omitempty"` - Output string `json:"output,omitempty"` - Model string `json:"model,omitempty"` - StartTime time.Time `json:"startTime"` - EndTime time.Time `json:"endTime,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Usage *UsageInfo `json:"usage,omitempty"` -} +type TraceEvent = contracts.TraceEvent // UsageInfo tracks token usage. -type UsageInfo struct { - PromptTokens int `json:"promptTokens"` - CompletionTokens int `json:"completionTokens"` - TotalTokens int `json:"totalTokens"` - CostUSD float64 `json:"costUSD,omitempty"` -} +type UsageInfo = contracts.UsageInfo // NewLangfuseClient creates a client from environment variables. // Requires LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, and optionally LANGFUSE_HOST. diff --git a/internal/permissions/guardian.go b/internal/permissions/guardian.go index 94a51219..8dbda6ad 100644 --- a/internal/permissions/guardian.go +++ b/internal/permissions/guardian.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + contracts "github.com/GrayCodeAI/hawk-core-contracts/policy" ) // ErrCircuitBreakerOpen is returned when the guardian has denied too many @@ -59,11 +61,7 @@ type GuardianRequest struct { } // GuardianDecision represents the guardian's decision on a permission request. -type GuardianDecision struct { - Allowed bool `json:"allowed"` - Reason string `json:"reason"` - Confidence float64 `json:"confidence"` -} +type GuardianDecision = contracts.GuardianDecision // NewGuardian creates a new Guardian with sensible defaults. func NewGuardian(chatFn func(context.Context, string) (string, error)) *Guardian { @@ -301,6 +299,11 @@ func parseGuardianResponse(response string) (*GuardianDecision, error) { // Validate confidence range. Models occasionally emit // out-of-range values; clamp rather than reject so the rest of // the decision (allowed/reason) still flows through. + // NaN fails both < 0 and > 1, so handle it first (self-comparison is + // the standard NaN test) and treat it as lowest confidence — fail safe. + if decision.Confidence != decision.Confidence { //nolint:staticcheck // NaN check + decision.Confidence = 0 + } if decision.Confidence < 0 { decision.Confidence = 0 } diff --git a/internal/permissions/verdict.go b/internal/permissions/verdict.go index 5e318d3a..330053af 100644 --- a/internal/permissions/verdict.go +++ b/internal/permissions/verdict.go @@ -12,147 +12,33 @@ // (PermissionVerdict). Ported to native Go. package permissions -import "fmt" +import contracts "github.com/GrayCodeAI/hawk-core-contracts/policy" // Risk is the severity of a permission verdict. -type Risk int +type Risk = contracts.Risk const ( - // RiskLow: read-only, side-effect-free operations. - RiskLow Risk = iota - // RiskMedium: local writes, network reads, subprocess spawns. - RiskMedium - // RiskHigh: network writes, credential access, system changes. - RiskHigh - // RiskBlocked: definitely should not run; security or - // compliance violation. - RiskBlocked + RiskLow = contracts.RiskLow + RiskMedium = contracts.RiskMedium + RiskHigh = contracts.RiskHigh + RiskBlocked = contracts.RiskBlocked ) -// String returns a human-readable risk name. -func (r Risk) String() string { - switch r { - case RiskLow: - return "low" - case RiskMedium: - return "medium" - case RiskHigh: - return "high" - case RiskBlocked: - return "blocked" - default: - return fmt.Sprintf("Risk(%d)", int(r)) - } -} - // ParseRisk parses a risk name (case-insensitive) into a Risk value. -// Returns RiskMedium and a non-nil error if the name is unknown. -func ParseRisk(s string) (Risk, error) { - switch lower(s) { - case "low": - return RiskLow, nil - case "medium", "med", "moderate": - return RiskMedium, nil - case "high", "hi": - return RiskHigh, nil - case "blocked", "block", "deny", "denied", "forbidden": - return RiskBlocked, nil - default: - return RiskMedium, fmt.Errorf("permissions: unknown risk %q", s) - } -} +var ParseRisk = contracts.ParseRisk // PermissionVerdict is the unified outcome type for any permission // subsystem. It is constructed by helpers (Allow, Deny, RequireApproval) // and consumed by the tool dispatcher. -type PermissionVerdict struct { - // Allowed is the final accept/reject decision. - Allowed bool - // Reason is a human-readable explanation. - Reason string - // Rule is the identifier of the rule or check that fired - // (e.g. "boundary:write-outside-workspace", "guardian:rm-rf"). - // Empty when no specific rule matched. - Rule string - // Risk is the severity assessment. Even if Allowed=true, a - // non-low risk should be surfaced in logs/UI. - Risk Risk - // Confidence is 0.0-1.0; only meaningful for LLM-derived - // verdicts. Static rule verdicts should set this to 1.0. - Confidence float64 - // Source identifies the subsystem that produced the verdict - // (e.g. "rules", "guardian", "boundary", "hook", "default"). - Source string -} +type PermissionVerdict = contracts.PermissionVerdict // Allow returns a permissive verdict. -func Allow(reason string) PermissionVerdict { - return PermissionVerdict{ - Allowed: true, - Reason: reason, - Risk: RiskLow, - Confidence: 1.0, - Source: "default", - } -} +var Allow = contracts.Allow // Deny returns a reject verdict with the given reason and rule. -func Deny(reason, rule string) PermissionVerdict { - return PermissionVerdict{ - Allowed: false, - Reason: reason, - Rule: rule, - Risk: RiskBlocked, - Confidence: 1.0, - Source: "rules", - } -} +var Deny = contracts.Deny // RequireApproval returns a "needs human approval" verdict. Allowed // is false; the caller can use Confidence < 1.0 to indicate the // request is plausible but uncertain. -func RequireApproval(reason, rule string, risk Risk) PermissionVerdict { - return PermissionVerdict{ - Allowed: false, - Reason: reason, - Rule: rule, - Risk: risk, - Confidence: 0.5, - Source: "guardian", - } -} - -// IsZero reports whether v is the zero value. Useful for -// detecting "no verdict produced" cases. -func (v PermissionVerdict) IsZero() bool { - return !v.Allowed && v.Reason == "" && v.Rule == "" && - v.Risk == 0 && v.Confidence == 0 && v.Source == "" -} - -// String returns a one-line summary for logs. -func (v PermissionVerdict) String() string { - action := "DENY" - if v.Allowed { - action = "ALLOW" - } - if v.Rule != "" { - return fmt.Sprintf("[%s] %s (%s, risk=%s, conf=%.2f): %s", - v.Source, action, v.Rule, v.Risk, v.Confidence, v.Reason) - } - return fmt.Sprintf("[%s] %s (risk=%s, conf=%.2f): %s", - v.Source, action, v.Risk, v.Confidence, v.Reason) -} - -// lower is a small ASCII-only strings.ToLower shim to keep the -// package's import list tight. -func lower(s string) string { - b := make([]byte, len(s)) - for i := 0; i < len(s); i++ { - c := s[i] - if c >= 'A' && c <= 'Z' { - c += 'a' - 'A' - } - b[i] = c - } - return string(b) -} +var RequireApproval = contracts.RequireApproval diff --git a/internal/plugin/dynamic.go b/internal/plugin/dynamic.go index 4a0d6c60..bfcd92f2 100644 --- a/internal/plugin/dynamic.go +++ b/internal/plugin/dynamic.go @@ -401,7 +401,9 @@ func (dm *DynamicPluginManager) InstallFromGitHub(repo string) error { url = "https://github.com/" + repo + ".git" } - cmd := exec.CommandContext(context.Background(), "git", "clone", "--depth", "1", "--single-branch", url, pluginDir) + // "--" terminates option parsing so a url is never interpreted as a git + // flag (defense-in-depth; isFullURL already forces an http(s):// prefix). + cmd := exec.CommandContext(context.Background(), "git", "clone", "--depth", "1", "--single-branch", "--", url, pluginDir) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("git clone failed: %w\n%s", err, string(out)) diff --git a/internal/resilience/circuit.go b/internal/resilience/circuit.go index 1300cd57..0e5ce249 100644 --- a/internal/resilience/circuit.go +++ b/internal/resilience/circuit.go @@ -38,6 +38,9 @@ type Breaker struct { failures int lastFailureTime time.Time successCount int + // halfOpenCalls counts probe calls dispatched in the half-open state so + // a burst of traffic cannot all slip through to a recovering service. + halfOpenCalls int maxFailures int timeout time.Duration @@ -88,15 +91,22 @@ func (b *Breaker) Allow() bool { if time.Since(b.lastFailureTime) > b.timeout { b.state = HalfOpen b.successCount = 0 + b.halfOpenCalls = 1 // this first probe counts against the budget return true } return false } - if b.state == HalfOpen && b.successCount >= b.halfOpenMaxCalls { - b.state = Closed - b.failures = 0 - b.successCount = 0 + if b.state == HalfOpen { + // Bound the number of in-flight probe calls so a traffic burst does + // not all slip through to a service that is still recovering. Once the + // probe budget is spent, fail fast until RecordSuccess closes the + // circuit or RecordFailure reopens it. + if b.halfOpenCalls >= b.halfOpenMaxCalls { + return false + } + b.halfOpenCalls++ + return true } return true @@ -113,6 +123,7 @@ func (b *Breaker) RecordSuccess() { b.state = Closed b.failures = 0 b.successCount = 0 + b.halfOpenCalls = 0 } return } diff --git a/internal/resilience/circuit_test.go b/internal/resilience/circuit_test.go index 75645540..a1d9bbfe 100644 --- a/internal/resilience/circuit_test.go +++ b/internal/resilience/circuit_test.go @@ -122,6 +122,27 @@ func TestBreakerHalfOpenFailure(t *testing.T) { } } +func TestBreakerHalfOpenProbeBudget(t *testing.T) { + b := New(Config{MaxFailures: 1, Timeout: 50 * time.Millisecond, HalfOpenMaxCalls: 2}) + + _ = b.Call(func() error { return errors.New("fail") }) + if b.State() != Open { + t.Fatalf("expected open state, got %s", b.State()) + } + + time.Sleep(100 * time.Millisecond) + + if !b.Allow() { + t.Fatal("expected first half-open probe to be allowed") + } + if !b.Allow() { + t.Fatal("expected second half-open probe to be allowed") + } + if b.Allow() { + t.Fatal("expected probe budget to block additional half-open calls") + } +} + func TestCallWithResult(t *testing.T) { b := New(Config{MaxFailures: 3}) diff --git a/internal/session/conversion.go b/internal/session/conversion.go new file mode 100644 index 00000000..a06e6af8 --- /dev/null +++ b/internal/session/conversion.go @@ -0,0 +1,101 @@ +package session + +import "github.com/GrayCodeAI/hawk/internal/types" + +// FromRuntimeMessages converts Hawk runtime messages into persisted session messages. +func FromRuntimeMessages(in []types.EyrieMessage) []Message { + if len(in) == 0 { + return nil + } + out := make([]Message, len(in)) + for i, msg := range in { + out[i] = Message{ + Role: msg.Role, + Content: msg.Content, + ToolUse: FromRuntimeToolCalls(msg.ToolUse), + ToolResults: FromRuntimeToolResults(msg.ToolResults), + } + } + return out +} + +// FromRuntimeToolCalls converts Hawk runtime tool calls into persisted contracts. +func FromRuntimeToolCalls(in []types.ToolCall) []ToolCall { + if len(in) == 0 { + return nil + } + out := make([]ToolCall, len(in)) + for i, tc := range in { + out[i] = ToolCall{ + ID: tc.ID, + Name: tc.Name, + Arguments: tc.Arguments, + } + } + return out +} + +// FromRuntimeToolResults converts Hawk runtime tool results into persisted contracts. +func FromRuntimeToolResults(in []types.ToolResult) []ToolResult { + if len(in) == 0 { + return nil + } + out := make([]ToolResult, len(in)) + for i, tr := range in { + out[i] = ToolResult{ + ToolUseID: tr.ToolUseID, + Content: tr.Content, + IsError: tr.IsError, + } + } + return out +} + +// ToRuntimeMessages converts persisted session messages back into Hawk runtime messages. +func ToRuntimeMessages(in []Message) []types.EyrieMessage { + if len(in) == 0 { + return nil + } + out := make([]types.EyrieMessage, len(in)) + for i, msg := range in { + out[i] = types.EyrieMessage{ + Role: msg.Role, + Content: msg.Content, + ToolUse: ToRuntimeToolCalls(msg.ToolUse), + ToolResults: ToRuntimeToolResults(msg.ToolResults), + } + } + return out +} + +// ToRuntimeToolCalls converts persisted contracts back into Hawk runtime tool calls. +func ToRuntimeToolCalls(in []ToolCall) []types.ToolCall { + if len(in) == 0 { + return nil + } + out := make([]types.ToolCall, len(in)) + for i, tc := range in { + out[i] = types.ToolCall{ + ID: tc.ID, + Name: tc.Name, + Arguments: tc.Arguments, + } + } + return out +} + +// ToRuntimeToolResults converts persisted contracts back into Hawk runtime tool results. +func ToRuntimeToolResults(in []ToolResult) []types.ToolResult { + if len(in) == 0 { + return nil + } + out := make([]types.ToolResult, len(in)) + for i, tr := range in { + out[i] = types.ToolResult{ + ToolUseID: tr.ToolUseID, + Content: tr.Content, + IsError: tr.IsError, + } + } + return out +} diff --git a/internal/session/conversion_test.go b/internal/session/conversion_test.go new file mode 100644 index 00000000..d44f450c --- /dev/null +++ b/internal/session/conversion_test.go @@ -0,0 +1,40 @@ +package session + +import ( + "reflect" + "testing" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +func TestRuntimeMessageRoundTrip(t *testing.T) { + in := []types.EyrieMessage{ + { + Role: "assistant", + Content: "working", + ToolUse: []types.ToolCall{{ + ID: "tool_1", + Name: "Read", + Arguments: map[string]interface{}{ + "path": "main.go", + }, + }}, + }, + { + Role: "user", + Content: "file contents", + ToolResults: []types.ToolResult{{ + ToolUseID: "tool_1", + Content: "package main", + IsError: false, + }}, + }, + } + + persisted := FromRuntimeMessages(in) + out := ToRuntimeMessages(persisted) + + if !reflect.DeepEqual(out, in) { + t.Fatalf("round trip mismatch:\n got: %#v\nwant: %#v", out, in) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 6202d20b..917e0efc 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -10,23 +10,23 @@ import ( "sync" "time" + contracts "github.com/GrayCodeAI/hawk-core-contracts/tools" "github.com/GrayCodeAI/hawk/internal/storage" - "github.com/GrayCodeAI/hawk/internal/types" ) // Message is a persisted conversation message. type Message struct { - Role string `json:"role"` - Content string `json:"content,omitempty"` - ToolUse []types.ToolCall `json:"tool_use,omitempty"` - ToolResults []types.ToolResult `json:"tool_results,omitempty"` + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolUse []contracts.ToolCall `json:"tool_use,omitempty"` + ToolResults []contracts.ToolResult `json:"tool_results,omitempty"` } // ToolCall is an alias to the shared ToolCall type for persistence. -type ToolCall = types.ToolCall +type ToolCall = contracts.ToolCall // ToolResult is an alias to the shared ToolResult type for persistence. -type ToolResult = types.ToolResult +type ToolResult = contracts.ToolResult // Session is a persisted conversation. type Session struct { diff --git a/internal/testaudit/audit_test.go b/internal/testaudit/audit_test.go index a3a0ea9f..d2481874 100644 --- a/internal/testaudit/audit_test.go +++ b/internal/testaudit/audit_test.go @@ -59,6 +59,15 @@ var getEnvExemptions = map[string]bool{ "tool/web_search_searxng.go": true, // SEARXNG_URL } +// Direct eyrie/client imports are only allowed at the Hawk transport adapter edge +// and in a small number of tests that explicitly exercise provider mocks. +var eyrieClientImportExemptions = map[string]bool{ + "internal/types/client.go": true, + "internal/types/client_test.go": true, + "internal/bridge/sight/bridge.go": true, + "internal/engine/subagent_synthesis_test": true, +} + // TestNoRawPanicInInternal verifies that no non-test .go file in internal/ // calls panic() outside of init() functions. func TestNoRawPanicInInternal(t *testing.T) { @@ -184,6 +193,59 @@ func TestNoDirectOsGetenvInInternal(t *testing.T) { t.Logf("Total os.Getenv violations in internal/: %d (logged as tech debt)", violationCount) } +// TestNoDirectEyrieClientImportsOutsideAdapters verifies Hawk does not bypass +// its own transport seam by importing eyrie/client directly in production code. +func TestNoDirectEyrieClientImportsOutsideAdapters(t *testing.T) { + root := repoRoot(t) + paths := []string{ + filepath.Join(root, "internal"), + filepath.Join(root, "cmd"), + } + + for _, dir := range paths { + files := parseGoFiles(t, dir) + for _, pf := range files { + rel := relPath(root, pf.Path) + if isExemptPackage(rel, eyrieClientImportExemptions) { + continue + } + for _, imp := range pf.File.Imports { + path := strings.Trim(imp.Path.Value, `"`) + if path != "github.com/GrayCodeAI/eyrie/client" { + continue + } + pos := pf.FSet.Position(imp.Pos()) + t.Fatalf("forbidden direct eyrie/client import at %s:%d; go through internal/types transport adapters instead", rel, pos.Line) + } + } + } +} + +// TestNoDirectSharedTypesImports verifies Hawk does not reintroduce the removed +// legacy shared/types import path into production code. +func TestNoDirectSharedTypesImports(t *testing.T) { + root := repoRoot(t) + paths := []string{ + filepath.Join(root, "internal"), + filepath.Join(root, "cmd"), + } + + for _, dir := range paths { + files := parseGoFiles(t, dir) + for _, pf := range files { + rel := relPath(root, pf.Path) + for _, imp := range pf.File.Imports { + path := strings.Trim(imp.Path.Value, `"`) + if path != "github.com/GrayCodeAI/hawk/shared/types" { + continue + } + pos := pf.FSet.Position(imp.Pos()) + t.Fatalf("forbidden direct hawk/shared/types import at %s:%d; the path has been removed, use hawk-core-contracts instead", rel, pos.Line) + } + } + } +} + // TestAllExportedTypesHaveDocComments verifies that all exported type // declarations in non-test .go files have doc comments. func TestAllExportedTypesHaveDocComments(t *testing.T) { diff --git a/internal/testaudit/docs_audit_test.go b/internal/testaudit/docs_audit_test.go new file mode 100644 index 00000000..28d81c92 --- /dev/null +++ b/internal/testaudit/docs_audit_test.go @@ -0,0 +1,100 @@ +package testaudit + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestArchitectureDocsDoNotContainStaleContractsLanguage(t *testing.T) { + root := repoRoot(t) + + files := []string{ + "README.md", + "AGENTS.md", + "docs/architecture/README.md", + "docs/architecture/hawk-product-architecture.md", + "docs/architecture/hawk-core-contracts-spec.md", + "docs/architecture/hawk-contract-migration-inventory.md", + "docs/plans/hawk-contracts-migration-backlog.md", + } + + forbiddenPhrases := []string{ + "hawk-core-contracts` (to add)", + "planned shared contracts layer", + "runtime still uses `eyrie/client` provider interfaces and config types", + } + + for _, rel := range files { + path := filepath.Join(root, rel) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + content := string(data) + for _, phrase := range forbiddenPhrases { + if strings.Contains(content, phrase) { + t.Fatalf("stale architecture phrase %q found in %s", phrase, rel) + } + } + } +} + +func TestArchitectureDocsMentionCurrentReviewVerifyContracts(t *testing.T) { + root := repoRoot(t) + + checks := map[string][]string{ + "README.md": { + "hawk-core-contracts/review", + "hawk-core-contracts/verify", + }, + "docs/architecture/hawk-product-architecture.md": { + "hawk-core-contracts/review", + "hawk-core-contracts/verify", + }, + "docs/architecture/hawk-core-contracts-spec.md": { + "hawk-core-contracts/review", + "hawk-core-contracts/verify", + }, + } + + for rel, required := range checks { + path := filepath.Join(root, rel) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + content := string(data) + for _, phrase := range required { + if !strings.Contains(content, phrase) { + t.Fatalf("required phrase %q missing in %s", phrase, rel) + } + } + } +} + +func TestArchitectureDocsDescribeSharedTypesAsRemoved(t *testing.T) { + root := repoRoot(t) + + files := []string{ + "README.md", + "AGENTS.md", + "docs/architecture.md", + } + + for _, rel := range files { + path := filepath.Join(root, rel) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + content := strings.ToLower(string(data)) + if !strings.Contains(content, "shared/types") { + t.Fatalf("expected %s to mention shared/types", rel) + } + if !strings.Contains(content, "removed") { + t.Fatalf("expected %s to describe shared/types as removed", rel) + } + } +} diff --git a/internal/testaudit/helpers.go b/internal/testaudit/helpers.go index 4eb388db..8ab80bce 100644 --- a/internal/testaudit/helpers.go +++ b/internal/testaudit/helpers.go @@ -27,6 +27,12 @@ type parsedFile struct { // parses all non-test .go files, and returns them keyed by package import path. func parseInternalPackages(t *testing.T, root string) []parsedFile { t.Helper() + return parseGoFiles(t, root) +} + +// parseGoFiles walks a directory tree, parses all non-test .go files, and returns them. +func parseGoFiles(t *testing.T, root string) []parsedFile { + t.Helper() var files []parsedFile diff --git a/internal/tool/agent.go b/internal/tool/agent.go index 5e78b641..d21a3469 100644 --- a/internal/tool/agent.go +++ b/internal/tool/agent.go @@ -8,6 +8,20 @@ import ( "time" ) +const ( + // maxAgentPromptBytes caps a sub-agent prompt so a single LLM-emitted + // tool call cannot balloon memory with an enormous prompt. + maxAgentPromptBytes = 256 * 1024 // 256KB + // maxParallelAgentTasks caps how many sub-agents a single MultiAgent call + // will fan out to, bounding goroutine and memory growth from an + // LLM-supplied tasks array. + maxParallelAgentTasks = 32 + // maxConcurrentAgentTasks bounds how many sub-agents run at once in the + // synchronous MultiAgent path so we don't fire dozens of LLM calls + // simultaneously. + maxConcurrentAgentTasks = 8 +) + type AgentTool struct{} func (AgentTool) Name() string { return "Agent" } @@ -49,6 +63,9 @@ func (AgentTool) Execute(ctx context.Context, input json.RawMessage) (string, er if err := json.Unmarshal(input, &p); err != nil { return "", err } + if len(p.Prompt) > maxAgentPromptBytes { + return "", fmt.Errorf("agent prompt too large: %d bytes (max %d)", len(p.Prompt), maxAgentPromptBytes) + } tc := GetToolContext(ctx) if tc == nil || tc.AgentSpawnFn == nil { return "", fmt.Errorf("agent spawning not configured") @@ -161,6 +178,14 @@ func (MultiAgentTool) Execute(ctx context.Context, input json.RawMessage) (strin if err := json.Unmarshal(input, &p); err != nil { return "", err } + if len(p.Tasks) > maxParallelAgentTasks { + return "", fmt.Errorf("too many tasks: %d (max %d)", len(p.Tasks), maxParallelAgentTasks) + } + for _, task := range p.Tasks { + if len(task) > maxAgentPromptBytes { + return "", fmt.Errorf("task prompt too large: %d bytes (max %d)", len(task), maxAgentPromptBytes) + } + } tc := GetToolContext(ctx) if tc == nil || tc.AgentSpawnFn == nil { return "", fmt.Errorf("agent spawning not configured") @@ -187,10 +212,13 @@ func (MultiAgentTool) Execute(ctx context.Context, input json.RawMessage) (strin } results := make([]result, len(p.Tasks)) var wg sync.WaitGroup + sem := make(chan struct{}, maxConcurrentAgentTasks) for i, task := range p.Tasks { wg.Add(1) go func(idx int, prompt string) { defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() out, err := tc.AgentSpawnFn(ctx, prompt) results[idx] = result{idx: idx, output: out, err: err} }(i, task) diff --git a/internal/tool/agent_limits_test.go b/internal/tool/agent_limits_test.go new file mode 100644 index 00000000..e810024d --- /dev/null +++ b/internal/tool/agent_limits_test.go @@ -0,0 +1,68 @@ +package tool + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestAgentTool_PromptTooLarge(t *testing.T) { + t.Parallel() + + ctx := WithToolContext(context.Background(), &ToolContext{ + AgentSpawnFn: func(_ context.Context, prompt string) (string, error) { + return prompt, nil + }, + }) + oversized := strings.Repeat("a", maxAgentPromptBytes+1) + + _, err := AgentTool{}.Execute(ctx, mustJSONRaw(t, map[string]any{"prompt": oversized})) + if err == nil || !strings.Contains(err.Error(), "agent prompt too large") { + t.Fatalf("expected prompt-too-large error, got %v", err) + } +} + +func TestMultiAgentTool_TooManyTasks(t *testing.T) { + t.Parallel() + + ctx := WithToolContext(context.Background(), &ToolContext{ + AgentSpawnFn: func(_ context.Context, prompt string) (string, error) { + return prompt, nil + }, + }) + tasks := make([]string, maxParallelAgentTasks+1) + for i := range tasks { + tasks[i] = "task" + } + + _, err := MultiAgentTool{}.Execute(ctx, mustJSONRaw(t, map[string]any{"tasks": tasks})) + if err == nil || !strings.Contains(err.Error(), "too many tasks") { + t.Fatalf("expected too-many-tasks error, got %v", err) + } +} + +func TestMultiAgentTool_TaskPromptTooLarge(t *testing.T) { + t.Parallel() + + ctx := WithToolContext(context.Background(), &ToolContext{ + AgentSpawnFn: func(_ context.Context, prompt string) (string, error) { + return prompt, nil + }, + }) + oversized := strings.Repeat("a", maxAgentPromptBytes+1) + + _, err := MultiAgentTool{}.Execute(ctx, mustJSONRaw(t, map[string]any{"tasks": []string{oversized}})) + if err == nil || !strings.Contains(err.Error(), "task prompt too large") { + t.Fatalf("expected task-prompt-too-large error, got %v", err) + } +} + +func mustJSONRaw(t *testing.T, v any) json.RawMessage { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal input: %v", err) + } + return data +} diff --git a/internal/tool/cron.go b/internal/tool/cron.go index a08ec342..ce7d20c5 100644 --- a/internal/tool/cron.go +++ b/internal/tool/cron.go @@ -31,6 +31,11 @@ type CronScheduler struct { next int } +// maxCronJobs caps the number of concurrently scheduled jobs so an LLM +// cannot create unbounded jobs in a tight loop (each Create stores an entry +// in the in-memory map). +const maxCronJobs = 256 + var globalCronScheduler = &CronScheduler{jobs: make(map[string]*CronJob)} func GetCronScheduler() *CronScheduler { return globalCronScheduler } @@ -43,6 +48,9 @@ func (s *CronScheduler) Create(schedule, prompt string, recurring, durable bool) s.mu.Lock() defer s.mu.Unlock() + if len(s.jobs) >= maxCronJobs { + return nil, fmt.Errorf("cron job limit reached (%d); delete existing jobs first", maxCronJobs) + } s.next++ id := fmt.Sprintf("cron_%d", s.next) diff --git a/internal/tool/cron_limits_test.go b/internal/tool/cron_limits_test.go new file mode 100644 index 00000000..44099f5b --- /dev/null +++ b/internal/tool/cron_limits_test.go @@ -0,0 +1,22 @@ +package tool + +import ( + "strings" + "testing" +) + +func TestCronScheduler_CreateLimit(t *testing.T) { + t.Parallel() + + s := &CronScheduler{jobs: make(map[string]*CronJob)} + for i := 0; i < maxCronJobs; i++ { + if _, err := s.Create("*/5 * * * *", "task", true, false); err != nil { + t.Fatalf("create job %d: %v", i, err) + } + } + + _, err := s.Create("*/5 * * * *", "overflow", true, false) + if err == nil || !strings.Contains(err.Error(), "cron job limit reached") { + t.Fatalf("expected cron limit error, got %v", err) + } +} diff --git a/internal/tool/git_commit.go b/internal/tool/git_commit.go index 8c3a64c5..75c1722c 100644 --- a/internal/tool/git_commit.go +++ b/internal/tool/git_commit.go @@ -160,15 +160,19 @@ func isConventionalSubject(msg string) bool { } // GenerateCommitMessage produces a Conventional Commits message for the given -// diff and goal. It delegates to the existing CommitMessageGenerator, using -// CommitMessageChatFn as the LLM seam and falling back to the deterministic -// rule-based generator when no model is wired up. +// diff and goal. It delegates to the existing CommitMessageGenerator, using a +// ToolContext-scoped commit chat function when available, then the package-level +// CommitMessageChatFn seam, and finally the deterministic rule-based generator. // // The returned message is normalized: if the output does not already begin with // a Conventional Commits type prefix, a "chore: " prefix is added. func GenerateCommitMessage(ctx context.Context, diff, goal string) (string, error) { + chatFn := CommitMessageChatFn + if tc := GetToolContext(ctx); tc != nil && tc.CommitMessageChatFn != nil { + chatFn = tc.CommitMessageChatFn + } gen := &CommitMessageGenerator{ - ChatFn: CommitMessageChatFn, + ChatFn: chatFn, FallbackToConventional: true, Style: "conventional", IncludeBody: true, diff --git a/internal/tool/git_commit_test.go b/internal/tool/git_commit_test.go index e9e50d48..51df9f39 100644 --- a/internal/tool/git_commit_test.go +++ b/internal/tool/git_commit_test.go @@ -244,6 +244,42 @@ func TestGenerateCommitMessageWithMockModel(t *testing.T) { } } +func TestGenerateCommitMessagePrefersContextChatFn(t *testing.T) { + orig := CommitMessageChatFn + defer func() { CommitMessageChatFn = orig }() + + CommitMessageChatFn = func(_ context.Context, _ string) (string, error) { + return "fix: global should not be used", nil + } + ctx := WithToolContext(context.Background(), &ToolContext{ + CommitMessageChatFn: func(_ context.Context, _ string) (string, error) { + return "feat: use context commit model", nil + }, + }) + + msg, err := GenerateCommitMessage(ctx, "diff --git a/x b/x\n+hello", "add a thing") + if err != nil { + t.Fatalf("GenerateCommitMessage: %v", err) + } + if !strings.HasPrefix(msg, "feat: use context commit model") { + t.Fatalf("expected context chat function result, got %q", msg) + } +} + +func TestGenerateCommitMessageFallsBackWithoutChatFn(t *testing.T) { + orig := CommitMessageChatFn + defer func() { CommitMessageChatFn = orig }() + CommitMessageChatFn = nil + + msg, err := GenerateCommitMessage(context.Background(), "diff --git a/README.md b/README.md\n+docs", "update docs") + if err != nil { + t.Fatalf("GenerateCommitMessage: %v", err) + } + if msg == "" || !isConventionalSubject(msg) { + t.Fatalf("expected conventional fallback message, got %q", msg) + } +} + func TestIsConventionalSubject(t *testing.T) { tests := []struct { msg string diff --git a/internal/tool/tool.go b/internal/tool/tool.go index 2b48eace..bf3e273d 100644 --- a/internal/tool/tool.go +++ b/internal/tool/tool.go @@ -8,7 +8,6 @@ import ( "strings" "sync" - "github.com/GrayCodeAI/eyrie/client" "github.com/GrayCodeAI/hawk/internal/intelligence/memory" "github.com/GrayCodeAI/hawk/internal/lint" "github.com/GrayCodeAI/hawk/internal/sandbox" @@ -60,19 +59,20 @@ type CodeSearchResult struct { // ToolContext carries session-level functions for tools that need them. type ToolContext struct { - AgentSpawnFn func(ctx context.Context, prompt string) (string, error) - AskUserFn func(question string) (string, error) - CodeSearchFn func(ctx context.Context, query string, limit int) ([]CodeSearchResult, error) - RefreshCodeIndexFn func(ctx context.Context) error - AvailableTools []Tool - AllowedDirectories []string - SandboxMode sandbox.Mode - AutoCommit bool - Protected PathProtector - YaadBridge *memory.YaadBridge - Attribution *types.Attribution - SettingsGet func(key string) (string, bool) - SettingsSet func(key, value string) error + AgentSpawnFn func(ctx context.Context, prompt string) (string, error) + AskUserFn func(question string) (string, error) + CodeSearchFn func(ctx context.Context, query string, limit int) ([]CodeSearchResult, error) + RefreshCodeIndexFn func(ctx context.Context) error + CommitMessageChatFn func(ctx context.Context, prompt string) (string, error) + AvailableTools []Tool + AllowedDirectories []string + SandboxMode sandbox.Mode + AutoCommit bool + Protected PathProtector + YaadBridge *memory.YaadBridge + Attribution *types.Attribution + SettingsGet func(key string) (string, bool) + SettingsSet func(key, value string) error // BackgroundManager tracks background sub-agents. If nil, background // mode is not available. BackgroundManager *BackgroundAgentManager @@ -205,13 +205,13 @@ func (r *Registry) Filter(allow []string) *Registry { return NewRegistry(filtered...) } -// EyrieTools converts all tools to eyrie tool definitions for the API. -func (r *Registry) EyrieTools() []client.EyrieTool { +// EyrieTools converts all tools to Hawk runtime tool definitions for the API boundary. +func (r *Registry) EyrieTools() []types.EyrieTool { r.mu.RLock() defer r.mu.RUnlock() - out := make([]client.EyrieTool, 0, len(r.primary)) + out := make([]types.EyrieTool, 0, len(r.primary)) for _, t := range r.primary { - out = append(out, client.EyrieTool{ + out = append(out, types.EyrieTool{ Name: t.Name(), Description: t.Description(), Parameters: t.Parameters(), diff --git a/internal/tool/transaction.go b/internal/tool/transaction.go index 31d06524..4860417a 100644 --- a/internal/tool/transaction.go +++ b/internal/tool/transaction.go @@ -480,6 +480,14 @@ func (TransactionTool) Execute(ctx context.Context, input json.RawMessage) (stri if content == "" && op.NewContent != "" { content = op.NewContent } + // Scan new file content for credentials, mirroring the FileWrite / + // FileEdit guard. Without this, AtomicMultiEdit is a bypass for the + // anti-exfil check: an LLM could persist a stolen API key to disk. + if op.Type == "create" || op.Type == "modify" { + if cred := DetectCredentials(content); cred != "" { + return "", fmt.Errorf("content for %s contains a credential (%s) — refusing to write", op.Path, cred) + } + } mode := os.FileMode(0o644) if op.Mode != 0 { mode = os.FileMode(op.Mode) diff --git a/internal/tool/transaction_test.go b/internal/tool/transaction_test.go index 47192630..b80bce66 100644 --- a/internal/tool/transaction_test.go +++ b/internal/tool/transaction_test.go @@ -735,6 +735,33 @@ func TestTransactionTool_ExecuteEmptyOperations(t *testing.T) { } } +func TestTransactionTool_RejectsCredentialContent(t *testing.T) { + dir := t.TempDir() + createPath := filepath.Join(dir, "secrets.txt") + + input := transactionInput{ + Operations: []struct { + Type string `json:"type"` + Path string `json:"path"` + OldPath string `json:"old_path,omitempty"` + Content string `json:"content,omitempty"` + NewContent string `json:"new_content,omitempty"` + Mode int `json:"mode,omitempty"` + }{ + {Type: "create", Path: createPath, Content: "token=sk-abcdefghijklmnopqrstuvwxyz"}, + }, + } + + data, _ := json.Marshal(input) + _, err := (TransactionTool{}).Execute(context.Background(), data) + if err == nil || !strings.Contains(err.Error(), "contains a credential") { + t.Fatalf("expected credential rejection, got %v", err) + } + if _, statErr := os.Stat(createPath); !os.IsNotExist(statErr) { + t.Fatalf("expected file not to be created, stat err = %v", statErr) + } +} + func TestTransaction_RenameRequiresOldPath(t *testing.T) { dir := t.TempDir() tx := NewTransaction() diff --git a/internal/types/client.go b/internal/types/client.go index cbc00b12..67066234 100644 --- a/internal/types/client.go +++ b/internal/types/client.go @@ -7,32 +7,647 @@ import ( ) type ( - EyrieMessage = client.EyrieMessage - ChatOptions = client.ChatOptions - EyrieResponse = client.EyrieResponse - EyrieUsage = client.EyrieUsage - StreamResult = client.StreamResult - EyrieStreamEvent = client.EyrieStreamEvent - ContinuationConfig = client.ContinuationConfig - EyrieConfig = client.EyrieConfig - EyrieTool = client.EyrieTool - EyrieClient = client.EyrieClient - Provider = client.Provider - ResponseFormat = client.ResponseFormat + ContentPart = client.ContentPart + ImageURLPart = client.ImageURLPart + InputAudioPart = client.InputAudioPart ) +// ClientConfig is Hawk-owned client construction config at the transport edge. +type ClientConfig struct { + Provider string `json:"provider,omitempty"` + APIKey string `json:"-"` + BaseURL string `json:"base_url,omitempty"` + Model string `json:"model,omitempty"` + MaxRetries int `json:"max_retries,omitempty"` +} + +// ChatProvider is Hawk's transport-provider interface. +type ChatProvider interface { + Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) + StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) + Ping(ctx context.Context) error + Name() string +} + +// ResponseFormat specifies the desired output format for a Hawk runtime request. +type ResponseFormat struct { + Type string `json:"type"` + Schema string `json:"schema,omitempty"` +} + +// ToolChoiceOption controls how the model uses tools. +type ToolChoiceOption struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"` +} + +// EyrieTool is Hawk's runtime tool definition DTO. +type EyrieTool struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` +} + +// ChatOptions holds Hawk-owned request options for a runtime chat call. +type ChatOptions struct { + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools []EyrieTool `json:"tools,omitempty"` + System string `json:"system,omitempty"` + EnableCaching bool `json:"enable_caching,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + ThinkingBudgetTokens int `json:"thinking_budget_tokens,omitempty"` + ThinkingMode string `json:"thinking_mode,omitempty"` + ThinkingDisplay string `json:"thinking_display,omitempty"` + GLMThinkingEnabled *bool `json:"glm_thinking_enabled,omitempty"` + VirtualKeyID string `json:"virtual_key_id,omitempty"` + KimiContextCacheID string `json:"kimi_context_cache_id,omitempty"` + KimiCacheResetTTL bool `json:"kimi_cache_reset_ttl,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + ToolChoice *ToolChoiceOption `json:"tool_choice,omitempty"` + MetadataUserID string `json:"metadata_user_id,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + OutputEffort string `json:"output_effort,omitempty"` + OutputSchema string `json:"output_schema,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + N *int `json:"n,omitempty"` + LogProbs *bool `json:"logprobs,omitempty"` + TopLogProbs *int `json:"top_logprobs,omitempty"` + Seed *int `json:"seed,omitempty"` + Store *bool `json:"store,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Modalities []string `json:"modalities,omitempty"` + AudioConfig string `json:"audio_config,omitempty"` + Prediction string `json:"prediction,omitempty"` + WebSearchOptions string `json:"web_search_options,omitempty"` +} + +// ContinuationConfig controls output continuation behavior for Hawk runtime calls. +type ContinuationConfig struct { + MaxContinuations int + MaxTotalTokens int +} + +// ToolCall is Hawk's runtime tool invocation DTO. +type ToolCall struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` +} + +// ToolResult is Hawk's runtime tool result DTO. +type ToolResult struct { + ToolUseID string `json:"tool_use_id"` + Content string `json:"content"` + IsError bool `json:"is_error,omitempty"` +} + +// EyrieUsage tracks token usage for Hawk runtime responses and streams. +type EyrieUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + CacheCreationTokens int `json:"cache_creation_tokens,omitempty"` + CacheReadTokens int `json:"cache_read_tokens,omitempty"` + ThinkingTokens int `json:"thinking_tokens,omitempty"` +} + +// EyrieResponse is Hawk's runtime chat response DTO. +type EyrieResponse struct { + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` + Usage *EyrieUsage `json:"usage,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + FinishReason string `json:"finish_reason"` + RequestID string `json:"request_id,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` +} + +// EyrieStreamEvent is Hawk's runtime stream event DTO. +type EyrieStreamEvent struct { + Type string `json:"type"` + Content string `json:"content,omitempty"` + ToolCall *ToolCall `json:"tool_call,omitempty"` + Thinking string `json:"thinking,omitempty"` + Error string `json:"error,omitempty"` + RequestID string `json:"request_id,omitempty"` + Usage *EyrieUsage `json:"usage,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + TTFTms int `json:"ttft_ms,omitempty"` + TTFT int `json:"ttft,omitempty"` +} + +// StreamResult wraps a Hawk-owned streaming response with cleanup. +type StreamResult struct { + Events <-chan EyrieStreamEvent + RequestID string + cancel context.CancelFunc +} + +// Close stops the stream and releases resources. +func (sr *StreamResult) Close() { + if sr != nil && sr.cancel != nil { + sr.cancel() + } +} + +// EyrieMessage is Hawk's runtime conversation DTO. +// It intentionally mirrors the provider runtime shape while remaining Hawk-owned. +type EyrieMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + Thinking string `json:"thinking,omitempty"` + ContentParts []ContentPart `json:"content_parts,omitempty"` + Images []string `json:"images,omitempty"` + ToolUse []ToolCall `json:"tool_use,omitempty"` + ToolResults []ToolResult `json:"tool_results,omitempty"` +} + +// EyrieClient adapts eyrie's client to Hawk-owned message DTOs. +type EyrieClient struct { + inner *client.EyrieClient + providerName string +} + +type providerAdapter struct { + inner client.Provider +} + func DefaultContinuationConfig() ContinuationConfig { - return client.DefaultContinuationConfig() + cfg := client.DefaultContinuationConfig() + return ContinuationConfig{ + MaxContinuations: cfg.MaxContinuations, + MaxTotalTokens: cfg.MaxTotalTokens, + } +} + +func DetectProvider() string { + return client.DetectProvider() +} + +func RegisterDynamicProvider(name, baseURL, apiKeyEnv string) error { + return client.RegisterDynamicProvider(name, baseURL, apiKeyEnv) } -func NewClient(cfg *EyrieConfig) *EyrieClient { - return client.Client(cfg) +func NewClient(cfg *ClientConfig) *EyrieClient { + providerName := "" + if cfg != nil { + providerName = cfg.Provider + } + return &EyrieClient{ + inner: client.Client(ToClientConfig(cfg)), + providerName: providerName, + } } func ParseInlineToolCalls(content string) (string, []ToolCall) { - return client.ParseInlineToolCalls(content) + text, calls := client.ParseInlineToolCalls(content) + return text, FromClientToolCalls(calls) +} + +func WrapClientProvider(p client.Provider) ChatProvider { + if p == nil { + return nil + } + return &providerAdapter{inner: p} +} + +func StreamChatWithContinuation(ctx context.Context, p ChatProvider, messages []EyrieMessage, opts ChatOptions, cfg ContinuationConfig) (*StreamResult, error) { + if p == nil { + return nil, nil + } + if adapted, ok := p.(*providerAdapter); ok { + stream, err := client.StreamChatWithContinuation(ctx, adapted.inner, ToClientMessages(messages), ToClientChatOptions(opts), ToClientContinuationConfig(cfg)) + if err != nil { + return nil, err + } + return FromClientStreamResult(stream), nil + } + stream, err := p.StreamChat(ctx, messages, opts) + if err != nil { + return nil, err + } + return stream, nil +} + +func (c *EyrieClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { + resp, err := c.inner.Chat(ctx, ToClientMessages(messages), ToClientChatOptions(opts)) + if err != nil { + return nil, err + } + return FromClientResponse(resp), nil +} + +func (c *EyrieClient) StreamChatContinue(ctx context.Context, messages []EyrieMessage, opts ChatOptions, cfg ContinuationConfig) (*StreamResult, error) { + stream, err := c.inner.StreamChatContinue(ctx, ToClientMessages(messages), ToClientChatOptions(opts), ToClientContinuationConfig(cfg)) + if err != nil { + return nil, err + } + return FromClientStreamResult(stream), nil +} + +func (c *EyrieClient) SetAPIKey(provider, apiKey string) { + c.inner.SetAPIKey(provider, apiKey) +} + +func (c *EyrieClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { + stream, err := c.inner.StreamChat(ctx, ToClientMessages(messages), ToClientChatOptions(opts)) + if err != nil { + return nil, err + } + return FromClientStreamResult(stream), nil +} + +func (c *EyrieClient) Ping(ctx context.Context) error { + return c.inner.Ping(ctx, "") +} + +func (c *EyrieClient) Name() string { + return c.providerName +} + +func (c *EyrieClient) GetProviders() []string { + return c.inner.GetProviders() +} + +func (p *providerAdapter) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { + resp, err := p.inner.Chat(ctx, ToClientMessages(messages), ToClientChatOptions(opts)) + if err != nil { + return nil, err + } + return FromClientResponse(resp), nil +} + +func (p *providerAdapter) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { + stream, err := p.inner.StreamChat(ctx, ToClientMessages(messages), ToClientChatOptions(opts)) + if err != nil { + return nil, err + } + return FromClientStreamResult(stream), nil +} + +func (p *providerAdapter) Ping(ctx context.Context) error { + return p.inner.Ping(ctx) +} + +func (p *providerAdapter) Name() string { + return p.inner.Name() +} + +// ToClientConfig converts Hawk-owned transport config into the provider-runtime shape. +func ToClientConfig(cfg *ClientConfig) *client.EyrieConfig { + if cfg == nil { + return nil + } + return &client.EyrieConfig{ + Provider: cfg.Provider, + APIKey: cfg.APIKey, + BaseURL: cfg.BaseURL, + Model: cfg.Model, + MaxRetries: cfg.MaxRetries, + } +} + +// ToClientResponseFormat converts a Hawk runtime response format into the provider-runtime shape. +func ToClientResponseFormat(format *ResponseFormat) *client.ResponseFormat { + if format == nil { + return nil + } + return &client.ResponseFormat{ + Type: format.Type, + Schema: format.Schema, + } +} + +// ToClientToolChoiceOption converts a Hawk runtime tool choice into the provider-runtime shape. +func ToClientToolChoiceOption(choice *ToolChoiceOption) *client.ToolChoiceOption { + if choice == nil { + return nil + } + return &client.ToolChoiceOption{ + Type: choice.Type, + Name: choice.Name, + DisableParallelToolUse: choice.DisableParallelToolUse, + } +} + +// ToClientEyrieTool converts a Hawk runtime tool definition into the provider-runtime shape. +func ToClientEyrieTool(tool EyrieTool) client.EyrieTool { + return client.EyrieTool{ + Name: tool.Name, + Description: tool.Description, + Parameters: tool.Parameters, + } +} + +// ToClientEyrieTools converts Hawk runtime tool definitions into provider-runtime tool definitions. +func ToClientEyrieTools(tools []EyrieTool) []client.EyrieTool { + if len(tools) == 0 { + return nil + } + out := make([]client.EyrieTool, len(tools)) + for i, tool := range tools { + out[i] = ToClientEyrieTool(tool) + } + return out +} + +// ToClientChatOptions converts Hawk runtime chat options into the provider-runtime shape. +func ToClientChatOptions(opts ChatOptions) client.ChatOptions { + return client.ChatOptions{ + Provider: opts.Provider, + Model: opts.Model, + Temperature: opts.Temperature, + MaxTokens: opts.MaxTokens, + Stream: opts.Stream, + Tools: ToClientEyrieTools(opts.Tools), + System: opts.System, + EnableCaching: opts.EnableCaching, + ResponseFormat: ToClientResponseFormat(opts.ResponseFormat), + ReasoningEffort: opts.ReasoningEffort, + ThinkingBudgetTokens: opts.ThinkingBudgetTokens, + ThinkingMode: opts.ThinkingMode, + ThinkingDisplay: opts.ThinkingDisplay, + GLMThinkingEnabled: opts.GLMThinkingEnabled, + VirtualKeyID: opts.VirtualKeyID, + KimiContextCacheID: opts.KimiContextCacheID, + KimiCacheResetTTL: opts.KimiCacheResetTTL, + TopP: opts.TopP, + TopK: opts.TopK, + StopSequences: opts.StopSequences, + ToolChoice: ToClientToolChoiceOption(opts.ToolChoice), + MetadataUserID: opts.MetadataUserID, + ServiceTier: opts.ServiceTier, + OutputEffort: opts.OutputEffort, + OutputSchema: opts.OutputSchema, + PresencePenalty: opts.PresencePenalty, + FrequencyPenalty: opts.FrequencyPenalty, + N: opts.N, + LogProbs: opts.LogProbs, + TopLogProbs: opts.TopLogProbs, + Seed: opts.Seed, + Store: opts.Store, + Metadata: opts.Metadata, + Modalities: opts.Modalities, + AudioConfig: opts.AudioConfig, + Prediction: opts.Prediction, + WebSearchOptions: opts.WebSearchOptions, + } +} + +// ToClientContinuationConfig converts Hawk runtime continuation settings into the provider-runtime shape. +func ToClientContinuationConfig(cfg ContinuationConfig) client.ContinuationConfig { + return client.ContinuationConfig{ + MaxContinuations: cfg.MaxContinuations, + MaxTotalTokens: cfg.MaxTotalTokens, + } +} + +// ToClientToolCall converts a Hawk runtime tool call into the provider-runtime shape. +func ToClientToolCall(tc ToolCall) client.ToolCall { + return client.ToolCall{ + ID: tc.ID, + Name: tc.Name, + Arguments: tc.Arguments, + } +} + +// ToClientToolCalls converts Hawk runtime tool calls into provider-runtime tool calls. +func ToClientToolCalls(calls []ToolCall) []client.ToolCall { + if len(calls) == 0 { + return nil + } + out := make([]client.ToolCall, len(calls)) + for i, tc := range calls { + out[i] = ToClientToolCall(tc) + } + return out +} + +// FromClientToolCall converts a provider-runtime tool call into Hawk's runtime shape. +func FromClientToolCall(tc client.ToolCall) ToolCall { + return ToolCall{ + ID: tc.ID, + Name: tc.Name, + Arguments: tc.Arguments, + } +} + +// FromClientToolCalls converts provider-runtime tool calls into Hawk runtime tool calls. +func FromClientToolCalls(calls []client.ToolCall) []ToolCall { + if len(calls) == 0 { + return nil + } + out := make([]ToolCall, len(calls)) + for i, tc := range calls { + out[i] = FromClientToolCall(tc) + } + return out +} + +// ToClientToolResult converts a Hawk runtime tool result into the provider-runtime shape. +func ToClientToolResult(tr ToolResult) client.ToolResult { + return client.ToolResult{ + ToolUseID: tr.ToolUseID, + Content: tr.Content, + IsError: tr.IsError, + } +} + +// ToClientToolResults converts Hawk runtime tool results into provider-runtime tool results. +func ToClientToolResults(results []ToolResult) []client.ToolResult { + if len(results) == 0 { + return nil + } + out := make([]client.ToolResult, len(results)) + for i, tr := range results { + out[i] = ToClientToolResult(tr) + } + return out +} + +// FromClientToolResult converts a provider-runtime tool result into Hawk's runtime shape. +func FromClientToolResult(tr client.ToolResult) ToolResult { + return ToolResult{ + ToolUseID: tr.ToolUseID, + Content: tr.Content, + IsError: tr.IsError, + } +} + +// FromClientToolResults converts provider-runtime tool results into Hawk runtime tool results. +func FromClientToolResults(results []client.ToolResult) []ToolResult { + if len(results) == 0 { + return nil + } + out := make([]ToolResult, len(results)) + for i, tr := range results { + out[i] = FromClientToolResult(tr) + } + return out +} + +// ToClientUsage converts a Hawk runtime usage payload into the provider-runtime shape. +func ToClientUsage(usage *EyrieUsage) *client.EyrieUsage { + if usage == nil { + return nil + } + return &client.EyrieUsage{ + PromptTokens: usage.PromptTokens, + CompletionTokens: usage.CompletionTokens, + TotalTokens: usage.TotalTokens, + CacheCreationTokens: usage.CacheCreationTokens, + CacheReadTokens: usage.CacheReadTokens, + ThinkingTokens: usage.ThinkingTokens, + } +} + +// FromClientUsage converts a provider-runtime usage payload into Hawk's runtime shape. +func FromClientUsage(usage *client.EyrieUsage) *EyrieUsage { + if usage == nil { + return nil + } + return &EyrieUsage{ + PromptTokens: usage.PromptTokens, + CompletionTokens: usage.CompletionTokens, + TotalTokens: usage.TotalTokens, + CacheCreationTokens: usage.CacheCreationTokens, + CacheReadTokens: usage.CacheReadTokens, + ThinkingTokens: usage.ThinkingTokens, + } +} + +// FromClientResponse converts a provider-runtime response into Hawk's runtime shape. +func FromClientResponse(resp *client.EyrieResponse) *EyrieResponse { + if resp == nil { + return nil + } + return &EyrieResponse{ + Content: resp.Content, + Thinking: resp.Thinking, + Usage: FromClientUsage(resp.Usage), + ToolCalls: FromClientToolCalls(resp.ToolCalls), + FinishReason: resp.FinishReason, + RequestID: resp.RequestID, + OrganizationID: resp.OrganizationID, + } +} + +// ToClientStreamEvent converts a Hawk runtime stream event into the provider-runtime shape. +func ToClientStreamEvent(ev EyrieStreamEvent) client.EyrieStreamEvent { + var toolCall *client.ToolCall + if ev.ToolCall != nil { + tc := ToClientToolCall(*ev.ToolCall) + toolCall = &tc + } + return client.EyrieStreamEvent{ + Type: ev.Type, + Content: ev.Content, + ToolCall: toolCall, + Thinking: ev.Thinking, + Error: ev.Error, + RequestID: ev.RequestID, + Usage: ToClientUsage(ev.Usage), + StopReason: ev.StopReason, + TTFTms: ev.TTFTms, + TTFT: ev.TTFT, + } +} + +// FromClientStreamEvent converts a provider-runtime stream event into Hawk's runtime shape. +func FromClientStreamEvent(ev client.EyrieStreamEvent) EyrieStreamEvent { + var toolCall *ToolCall + if ev.ToolCall != nil { + tc := FromClientToolCall(*ev.ToolCall) + toolCall = &tc + } + return EyrieStreamEvent{ + Type: ev.Type, + Content: ev.Content, + ToolCall: toolCall, + Thinking: ev.Thinking, + Error: ev.Error, + RequestID: ev.RequestID, + Usage: FromClientUsage(ev.Usage), + StopReason: ev.StopReason, + TTFTms: ev.TTFTms, + TTFT: ev.TTFT, + } +} + +// FromClientStreamResult converts a provider-runtime stream result into Hawk's runtime shape. +func FromClientStreamResult(stream *client.StreamResult) *StreamResult { + if stream == nil { + return nil + } + out := make(chan EyrieStreamEvent, 64) + go func() { + defer close(out) + for ev := range stream.Events { + out <- FromClientStreamEvent(ev) + } + }() + return &StreamResult{ + Events: out, + RequestID: stream.RequestID, + cancel: stream.Close, + } +} + +// ToClientMessage converts a Hawk runtime message into the provider-runtime shape. +func ToClientMessage(msg EyrieMessage) client.EyrieMessage { + return client.EyrieMessage{ + Role: msg.Role, + Content: msg.Content, + Thinking: msg.Thinking, + ContentParts: msg.ContentParts, + Images: msg.Images, + ToolUse: ToClientToolCalls(msg.ToolUse), + ToolResults: ToClientToolResults(msg.ToolResults), + } +} + +// ToClientMessages converts Hawk runtime messages into provider-runtime messages. +func ToClientMessages(messages []EyrieMessage) []client.EyrieMessage { + if len(messages) == 0 { + return nil + } + out := make([]client.EyrieMessage, len(messages)) + for i, msg := range messages { + out[i] = ToClientMessage(msg) + } + return out +} + +// FromClientMessage converts a provider-runtime message into Hawk's runtime shape. +func FromClientMessage(msg client.EyrieMessage) EyrieMessage { + return EyrieMessage{ + Role: msg.Role, + Content: msg.Content, + Thinking: msg.Thinking, + ContentParts: msg.ContentParts, + Images: msg.Images, + ToolUse: FromClientToolCalls(msg.ToolUse), + ToolResults: FromClientToolResults(msg.ToolResults), + } } -func StreamChatWithContinuation(ctx context.Context, p Provider, messages []EyrieMessage, opts ChatOptions, cfg ContinuationConfig) (*StreamResult, error) { - return client.StreamChatWithContinuation(ctx, p, messages, opts, cfg) +// FromClientMessages converts provider-runtime messages into Hawk runtime messages. +func FromClientMessages(messages []client.EyrieMessage) []EyrieMessage { + if len(messages) == 0 { + return nil + } + out := make([]EyrieMessage, len(messages)) + for i, msg := range messages { + out[i] = FromClientMessage(msg) + } + return out } diff --git a/internal/types/client_test.go b/internal/types/client_test.go new file mode 100644 index 00000000..ec6b3014 --- /dev/null +++ b/internal/types/client_test.go @@ -0,0 +1,127 @@ +package types + +import ( + "reflect" + "testing" + + "github.com/GrayCodeAI/eyrie/client" +) + +func TestEyrieMessageClientRoundTrip(t *testing.T) { + in := []EyrieMessage{ + { + Role: "assistant", + Content: "done", + Thinking: "reasoning", + Images: []string{"data:image/png;base64,abc"}, + ToolUse: []ToolCall{{ + ID: "tool_1", + Name: "Read", + Arguments: map[string]interface{}{ + "path": "main.go", + }, + }}, + }, + { + Role: "user", + ToolResults: []ToolResult{{ + ToolUseID: "tool_1", + Content: "package main", + }}, + }, + } + + got := FromClientMessages(ToClientMessages(in)) + if !reflect.DeepEqual(got, in) { + t.Fatalf("round trip mismatch:\n got: %#v\nwant: %#v", got, in) + } +} + +func TestToolCallClientRoundTrip(t *testing.T) { + in := []ToolCall{{ + ID: "tool_1", + Name: "Read", + Arguments: map[string]interface{}{ + "path": "main.go", + }, + }} + + got := FromClientToolCalls(ToClientToolCalls(in)) + if !reflect.DeepEqual(got, in) { + t.Fatalf("tool call round trip mismatch:\n got: %#v\nwant: %#v", got, in) + } +} + +func TestToolResultClientRoundTrip(t *testing.T) { + in := []ToolResult{{ + ToolUseID: "tool_1", + Content: "ok", + IsError: true, + }} + + got := FromClientToolResults(ToClientToolResults(in)) + if !reflect.DeepEqual(got, in) { + t.Fatalf("tool result round trip mismatch:\n got: %#v\nwant: %#v", got, in) + } +} + +func TestFromClientMessagePreservesContentParts(t *testing.T) { + in := client.EyrieMessage{ + Role: "user", + ContentParts: []client.ContentPart{{ + Type: "text", + Text: "hello", + }}, + } + + got := FromClientMessage(in) + if len(got.ContentParts) != 1 || got.ContentParts[0].Text != "hello" { + t.Fatalf("ContentParts = %#v, want text part", got.ContentParts) + } +} + +func TestToClientConfig(t *testing.T) { + cfg := &ClientConfig{ + Provider: "anthropic", + APIKey: "secret", + BaseURL: "https://proxy.example", + Model: "claude-sonnet", + MaxRetries: 3, + } + + got := ToClientConfig(cfg) + if got == nil { + t.Fatal("expected non-nil client config") + } + if got.Provider != cfg.Provider || got.APIKey != cfg.APIKey || got.BaseURL != cfg.BaseURL || got.Model != cfg.Model || got.MaxRetries != cfg.MaxRetries { + t.Fatalf("ToClientConfig = %#v, want %#v", got, cfg) + } +} + +func TestWrapClientProvider(t *testing.T) { + mock := client.NewMockProvider(client.MockModeFixed) + mock.Response = "wrapped" + + provider := WrapClientProvider(mock) + if provider == nil { + t.Fatal("expected non-nil provider adapter") + } + if provider.Name() != "mock" { + t.Fatalf("Name() = %q, want mock", provider.Name()) + } + + resp, err := provider.Chat(t.Context(), []EyrieMessage{{Role: "user", Content: "hi"}}, ChatOptions{}) + if err != nil { + t.Fatalf("Chat error: %v", err) + } + if resp.Content != "wrapped" { + t.Fatalf("Content = %q, want wrapped", resp.Content) + } +} + +func TestNewClientPreservesProviderName(t *testing.T) { + c := NewClient(&ClientConfig{Provider: "openai"}) + if c.Name() != "openai" { + t.Fatalf("Name() = %q, want openai", c.Name()) + } +} diff --git a/internal/types/severity.go b/internal/types/severity.go index 1b1b1961..07c94097 100644 --- a/internal/types/severity.go +++ b/internal/types/severity.go @@ -1,48 +1,41 @@ -// Package types provides shared types used across GrayCodeAI hawk-related modules. -// Severity, TokenSeverity, and AuditSeverity are forwarded from the shared package. -// ToolCall and ToolResult come from eyrie/client. +// Package types provides Hawk-owned runtime types and shared compatibility aliases. +// Severity, TokenSeverity, and AuditSeverity are forwarded from hawk-core-contracts/types. +// Provider-facing compatibility now lives in explicit adapters inside internal/types/client.go. package types import ( - "github.com/GrayCodeAI/eyrie/client" - "github.com/GrayCodeAI/hawk/shared/types" + contracts "github.com/GrayCodeAI/hawk-core-contracts/types" ) // Severity represents the impact level of a finding. -type Severity = types.Severity +type Severity = contracts.Severity const ( - SeverityInfo = types.SeverityInfo - SeverityLow = types.SeverityLow - SeverityMedium = types.SeverityMedium - SeverityHigh = types.SeverityHigh - SeverityCritical = types.SeverityCritical + SeverityInfo = contracts.SeverityInfo + SeverityLow = contracts.SeverityLow + SeverityMedium = contracts.SeverityMedium + SeverityHigh = contracts.SeverityHigh + SeverityCritical = contracts.SeverityCritical ) // ParseSeverity converts a string to a Severity. -var ParseSeverity = types.ParseSeverity +var ParseSeverity = contracts.ParseSeverity // TokenSeverity defines rule severity for compression error patterns. -type TokenSeverity = types.TokenSeverity +type TokenSeverity = contracts.TokenSeverity const ( - TokenSeverityCritical = types.TokenSeverityCritical - TokenSeverityHigh = types.TokenSeverityHigh - TokenSeverityMedium = types.TokenSeverityMedium - TokenSeverityLow = types.TokenSeverityLow + TokenSeverityCritical = contracts.TokenSeverityCritical + TokenSeverityHigh = contracts.TokenSeverityHigh + TokenSeverityMedium = contracts.TokenSeverityMedium + TokenSeverityLow = contracts.TokenSeverityLow ) // AuditSeverity indicates how dangerous a security audit finding is. -type AuditSeverity = types.AuditSeverity +type AuditSeverity = contracts.AuditSeverity const ( - AuditSeverityCritical = types.AuditSeverityCritical - AuditSeverityWarning = types.AuditSeverityWarning - AuditSeverityInfo = types.AuditSeverityInfo + AuditSeverityCritical = contracts.AuditSeverityCritical + AuditSeverityWarning = contracts.AuditSeverityWarning + AuditSeverityInfo = contracts.AuditSeverityInfo ) - -// ToolCall represents a tool invocation requested by the model. -type ToolCall = client.ToolCall - -// ToolResult represents the result of a tool execution. -type ToolResult = client.ToolResult diff --git a/scripts/check-ecosystem-boundaries.sh b/scripts/check-ecosystem-boundaries.sh new file mode 100644 index 00000000..e97e3ae2 --- /dev/null +++ b/scripts/check-ecosystem-boundaries.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +pattern='github\.com/GrayCodeAI/hawk/(internal/|shared/types)' +violations="" + +external_hits="$( + git grep -n -E "${pattern}" -- 'external/**/*.go' || true +)" +if [[ -n "${external_hits}" ]]; then + violations+="${external_hits}"$'\n' +fi + +for repo in ../sight ../inspect ../tok ../trace ../yaad ../eyrie; do + if [[ -d "${repo}" ]]; then + repo_hits="$( + grep -RInE --include='*.go' "${pattern}" "${repo}" || true + )" + if [[ -n "${repo_hits}" ]]; then + violations+="${repo_hits}"$'\n' + fi + fi +done + +if [[ -n "${violations}" ]]; then + echo "forbidden Hawk imports found in external ecosystem repos:" + echo "${violations}" + echo + echo "support repos must use hawk-core-contracts or their own contracts, not hawk/internal or removed hawk/shared/types" + exit 1 +fi + +echo "ecosystem boundary guard passed" diff --git a/scripts/check-eyrie-client-imports.sh b/scripts/check-eyrie-client-imports.sh new file mode 100755 index 00000000..7722e6ae --- /dev/null +++ b/scripts/check-eyrie-client-imports.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +violations="$( + git grep -n 'github\.com/GrayCodeAI/eyrie/client' -- '*.go' \ + ':(exclude)external/**' \ + ':(exclude)internal/types/client.go' \ + ':(exclude)**/*_test.go' || true +)" + +if [[ -n "${violations}" ]]; then + echo "forbidden direct imports of github.com/GrayCodeAI/eyrie/client found:" + echo "${violations}" + echo + echo "hawk production code must go through internal/types transport adapters instead of importing eyrie/client directly" + exit 1 +fi + +echo "eyrie/client boundary guard passed" diff --git a/scripts/check-shared-types-imports.sh b/scripts/check-shared-types-imports.sh new file mode 100644 index 00000000..2be904ff --- /dev/null +++ b/scripts/check-shared-types-imports.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +violations="$( + git grep -n 'github\.com/GrayCodeAI/hawk/shared/types' -- '*.go' \ + ':(exclude)external/**' \ + ':(exclude)shared/types/**' \ + ':(exclude)**/*_test.go' || true +)" + +if [[ -n "${violations}" ]]; then + echo "forbidden imports of removed github.com/GrayCodeAI/hawk/shared/types found:" + echo "${violations}" + echo + echo "hawk/shared/types has been removed; use github.com/GrayCodeAI/hawk-core-contracts/types instead" + exit 1 +fi + +echo "legacy shared/types import guard passed" diff --git a/scripts/check-support-repo-coupling.sh b/scripts/check-support-repo-coupling.sh new file mode 100644 index 00000000..b37c16d0 --- /dev/null +++ b/scripts/check-support-repo-coupling.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +support_repos=(eyrie inspect sight tok trace yaad) +violations="" + +scan_dir() { + local owner="$1" + local dir="$2" + local peers=() + local pattern="" + local hits="" + + if [[ ! -d "${dir}" ]]; then + return + fi + + for repo in "${support_repos[@]}"; do + if [[ "${repo}" != "${owner}" ]]; then + peers+=("${repo}") + fi + done + + pattern="$(IFS='|'; echo "${peers[*]}")" + hits="$( + grep -RInE --include='*.go' "github\\.com/GrayCodeAI/(${pattern})(/|\")" "${dir}" || true + )" + if [[ -n "${hits}" ]]; then + violations+="${hits}"$'\n' + fi +} + +for repo in "${support_repos[@]}"; do + scan_dir "${repo}" "external/${repo}" + scan_dir "${repo}" "../${repo}" +done + +if [[ -d "../hawk-sdk-go" ]]; then + sdk_hits="$( + grep -RInE --include='*.go' 'github\.com/GrayCodeAI/(eyrie|inspect|sight|tok|trace|yaad)(/|")' ../hawk-sdk-go || true + )" + if [[ -n "${sdk_hits}" ]]; then + violations+="${sdk_hits}"$'\n' + fi +fi + +if [[ -n "${violations}" ]]; then + echo "forbidden cross-repo peer imports found:" + echo "${violations}" + echo + echo "support engines must not import each other; Hawk is the orchestrator and shared contracts belong in hawk-core-contracts" + exit 1 +fi + +echo "support repo coupling guard passed" diff --git a/shared/types/finding.go b/shared/types/finding.go deleted file mode 100644 index 3589f1ca..00000000 --- a/shared/types/finding.go +++ /dev/null @@ -1,156 +0,0 @@ -package types - -import ( - "fmt" - "time" -) - -// Finding represents a unified code-analysis concern sourced from sight, inspect, or manual review. -type Finding struct { - ID string `json:"id"` - Source string `json:"source"` // sight, inspect, manual - Concern string `json:"concern"` // e.g. "sql-injection", "broken-auth" - Severity Severity `json:"severity"` - File string `json:"file,omitempty"` - URL string `json:"url,omitempty"` - Line int `json:"line,omitempty"` - EndLine int `json:"end_line,omitempty"` - Message string `json:"message"` - CWE string `json:"cwe,omitempty"` - Confidence float64 `json:"confidence"` - Fix string `json:"fix,omitempty"` - Reasoning string `json:"reasoning,omitempty"` - Tags []string `json:"tags,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// FindingSlice is a sortable slice of Findings. -// Sort order: severity descending, then confidence descending. -type FindingSlice []Finding - -func (s FindingSlice) Len() int { return len(s) } - -func (s FindingSlice) Less(i, j int) bool { - if s[i].Severity != s[j].Severity { - return s[i].Severity > s[j].Severity // higher severity first - } - return s[i].Confidence > s[j].Confidence // higher confidence first -} - -func (s FindingSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } - -// FilterBySource returns findings whose Source matches the given value. -func (s FindingSlice) FilterBySource(source string) FindingSlice { - out := make(FindingSlice, 0, len(s)) - for _, f := range s { - if f.Source == source { - out = append(out, f) - } - } - return out -} - -// FilterBySeverity returns findings whose Severity is at least min. -func (s FindingSlice) FilterBySeverity(min Severity) FindingSlice { - out := make(FindingSlice, 0, len(s)) - for _, f := range s { - if f.Severity.AtLeast(min) { - out = append(out, f) - } - } - return out -} - -// FilterByConfidence returns findings whose Confidence is >= min. -func (s FindingSlice) FilterByConfidence(min float64) FindingSlice { - out := make(FindingSlice, 0, len(s)) - for _, f := range s { - if f.Confidence >= min { - out = append(out, f) - } - } - return out -} - -// ByFile groups findings by their File field. -func (s FindingSlice) ByFile() map[string]FindingSlice { - m := make(map[string]FindingSlice, len(s)) - for _, f := range s { - m[f.File] = append(m[f.File], f) - } - return m -} - -// FindingSummary provides aggregate counts over a set of findings. -type FindingSummary struct { - Total int `json:"total"` - BySource map[string]int `json:"by_source"` - BySeverity map[string]int `json:"by_severity"` - AvgConfidence float64 `json:"avg_confidence"` -} - -// Summary returns a FindingSummary for the slice. -func (s FindingSlice) Summary() FindingSummary { - bySrc := make(map[string]int) - bySev := make(map[string]int) - var confSum float64 - - for _, f := range s { - bySrc[f.Source]++ - bySev[f.Severity.String()]++ - confSum += f.Confidence - } - - avg := 0.0 - if len(s) > 0 { - avg = confSum / float64(len(s)) - } - - return FindingSummary{ - Total: len(s), - BySource: bySrc, - BySeverity: bySev, - AvgConfidence: avg, - } -} - -// FindingFromSight constructs a Finding from a sight (AST/static-analysis) result. -func FindingFromSight( - concern, file string, - line int, - message, cwe string, - sev Severity, - confidence float64, -) Finding { - return Finding{ - ID: fmt.Sprintf("sight:%s:%s:%d", concern, file, line), - Source: "sight", - Concern: concern, - Severity: sev, - File: file, - Line: line, - Message: message, - CWE: cwe, - Confidence: confidence, - CreatedAt: time.Now(), - } -} - -// FindingFromInspect constructs a Finding from an inspect (linting/analysis) result. -func FindingFromInspect( - concern, url, message string, - sev Severity, - tags []string, -) Finding { - return Finding{ - ID: fmt.Sprintf("inspect:%s:%s", concern, url), - Source: "inspect", - Concern: concern, - Severity: sev, - URL: url, - Message: message, - Tags: tags, - CreatedAt: time.Now(), - } -} diff --git a/shared/types/finding_test.go b/shared/types/finding_test.go deleted file mode 100644 index 8156a710..00000000 --- a/shared/types/finding_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package types_test - -import ( - "sort" - "testing" - - "github.com/GrayCodeAI/hawk/shared/types" -) - -func TestFindingSlice_SortBySeverityThenConfidence(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "a", Severity: types.SeverityLow, Confidence: 0.9}, - {ID: "b", Severity: types.SeverityCritical, Confidence: 0.7}, - {ID: "c", Severity: types.SeverityCritical, Confidence: 0.95}, - {ID: "d", Severity: types.SeverityMedium, Confidence: 0.8}, - } - sort.Sort(findings) - - if findings[0].ID != "c" { - t.Fatalf("expected first finding to be c (critical, 0.95), got %s", findings[0].ID) - } - if findings[1].ID != "b" { - t.Fatalf("expected second finding to be b (critical, 0.7), got %s", findings[1].ID) - } - if findings[2].ID != "d" { - t.Fatalf("expected third finding to be d (medium), got %s", findings[2].ID) - } - if findings[3].ID != "a" { - t.Fatalf("expected fourth finding to be a (low), got %s", findings[3].ID) - } -} - -func TestFilterBySource(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "1", Source: "sight"}, - {ID: "2", Source: "inspect"}, - {ID: "3", Source: "sight"}, - {ID: "4", Source: "manual"}, - } - - got := findings.FilterBySource("sight") - if len(got) != 2 { - t.Fatalf("expected 2 sight findings, got %d", len(got)) - } - for _, f := range got { - if f.Source != "sight" { - t.Fatalf("expected source sight, got %s", f.Source) - } - } -} - -func TestFilterBySeverity(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "1", Severity: types.SeverityInfo}, - {ID: "2", Severity: types.SeverityHigh}, - {ID: "3", Severity: types.SeverityCritical}, - {ID: "4", Severity: types.SeverityLow}, - {ID: "5", Severity: types.SeverityMedium}, - } - - got := findings.FilterBySeverity(types.SeverityHigh) - if len(got) != 2 { - t.Fatalf("expected 2 findings >= high, got %d", len(got)) - } -} - -func TestFilterByConfidence(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "1", Confidence: 0.9}, - {ID: "2", Confidence: 0.5}, - {ID: "3", Confidence: 0.7}, - {ID: "4", Confidence: 0.3}, - } - - got := findings.FilterByConfidence(0.7) - if len(got) != 2 { - t.Fatalf("expected 2 findings with confidence >= 0.7, got %d", len(got)) - } - for _, f := range got { - if f.Confidence < 0.7 { - t.Fatalf("expected confidence >= 0.7, got %f", f.Confidence) - } - } -} - -func TestByFile(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "1", File: "a.go"}, - {ID: "2", File: "b.go"}, - {ID: "3", File: "a.go"}, - {ID: "4", File: ""}, - } - - grouped := findings.ByFile() - if len(grouped["a.go"]) != 2 { - t.Fatalf("expected 2 findings in a.go, got %d", len(grouped["a.go"])) - } - if len(grouped["b.go"]) != 1 { - t.Fatalf("expected 1 finding in b.go, got %d", len(grouped["b.go"])) - } - if len(grouped[""]) != 1 { - t.Fatalf("expected 1 finding with empty file, got %d", len(grouped[""])) - } -} - -func TestSummary(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "1", Source: "sight", Severity: types.SeverityHigh, Confidence: 0.8}, - {ID: "2", Source: "sight", Severity: types.SeverityLow, Confidence: 0.6}, - {ID: "3", Source: "inspect", Severity: types.SeverityHigh, Confidence: 1.0}, - } - - summary := findings.Summary() - if summary.Total != 3 { - t.Fatalf("expected total 3, got %d", summary.Total) - } - if summary.BySource["sight"] != 2 { - t.Fatalf("expected 2 sight findings, got %d", summary.BySource["sight"]) - } - if summary.BySource["inspect"] != 1 { - t.Fatalf("expected 1 inspect finding, got %d", summary.BySource["inspect"]) - } - if summary.BySeverity["high"] != 2 { - t.Fatalf("expected 2 high severity, got %d", summary.BySeverity["high"]) - } - if summary.BySeverity["low"] != 1 { - t.Fatalf("expected 1 low severity, got %d", summary.BySeverity["low"]) - } - // avg confidence = (0.8 + 0.6 + 1.0) / 3 = 0.8 - if summary.AvgConfidence < 0.79 || summary.AvgConfidence > 0.81 { - t.Fatalf("expected avg confidence ~0.8, got %f", summary.AvgConfidence) - } -} - -func TestFindingFromSight(t *testing.T) { - t.Parallel() - f := types.FindingFromSight("sql-injection", "db.go", 42, "unparameterized query", "CWE-89", types.SeverityCritical, 0.95) - - if f.ID != "sight:sql-injection:db.go:42" { - t.Fatalf("unexpected ID: %s", f.ID) - } - if f.Source != "sight" { - t.Fatalf("expected source sight, got %s", f.Source) - } - if f.Concern != "sql-injection" { - t.Fatalf("expected concern sql-injection, got %s", f.Concern) - } - if f.Severity != types.SeverityCritical { - t.Fatalf("expected severity critical, got %v", f.Severity) - } - if f.File != "db.go" { - t.Fatalf("expected file db.go, got %s", f.File) - } - if f.Line != 42 { - t.Fatalf("expected line 42, got %d", f.Line) - } - if f.CWE != "CWE-89" { - t.Fatalf("expected CWE-89, got %s", f.CWE) - } - if f.Confidence != 0.95 { - t.Fatalf("expected confidence 0.95, got %f", f.Confidence) - } - if f.CreatedAt.IsZero() { - t.Fatal("expected CreatedAt to be set") - } -} - -func TestFindingFromInspect(t *testing.T) { - t.Parallel() - tags := []string{"security", "injection"} - f := types.FindingFromInspect("broken-auth", "https://example.com/api", "missing auth header", types.SeverityHigh, tags) - - if f.ID != "inspect:broken-auth:https://example.com/api" { - t.Fatalf("unexpected ID: %s", f.ID) - } - if f.Source != "inspect" { - t.Fatalf("expected source inspect, got %s", f.Source) - } - if f.URL != "https://example.com/api" { - t.Fatalf("expected url, got %s", f.URL) - } - if f.Severity != types.SeverityHigh { - t.Fatalf("expected severity high, got %v", f.Severity) - } - if len(f.Tags) != 2 || f.Tags[0] != "security" { - t.Fatalf("unexpected tags: %v", f.Tags) - } - if f.CreatedAt.IsZero() { - t.Fatal("expected CreatedAt to be set") - } -} - -func TestEmptySliceHandling(t *testing.T) { - t.Parallel() - var empty types.FindingSlice - - if empty.Len() != 0 { - t.Fatalf("expected len 0, got %d", empty.Len()) - } - if len(empty.FilterBySource("x")) != 0 { - t.Fatal("expected empty filter result") - } - if len(empty.FilterBySeverity(types.SeverityCritical)) != 0 { - t.Fatal("expected empty filter result") - } - if len(empty.FilterByConfidence(0.5)) != 0 { - t.Fatal("expected empty filter result") - } - if len(empty.ByFile()) != 0 { - t.Fatal("expected empty ByFile map") - } - summary := empty.Summary() - if summary.Total != 0 { - t.Fatalf("expected total 0, got %d", summary.Total) - } - if summary.AvgConfidence != 0.0 { - t.Fatalf("expected avg confidence 0, got %f", summary.AvgConfidence) - } -} diff --git a/shared/types/severity.go b/shared/types/severity.go deleted file mode 100644 index 94183539..00000000 --- a/shared/types/severity.go +++ /dev/null @@ -1,66 +0,0 @@ -// Package types defines stable shared types for GrayCodeAI libraries (sight, inspect, tok, …). -// Types are defined here directly so external modules (tok, sight, inspect) can import this -// package without pulling in hawk/internal or eyrie/client, which would create import cycles. -package types - -import "strings" - -// Severity represents the impact level of a finding. -type Severity int - -const ( - SeverityInfo Severity = iota - SeverityLow - SeverityMedium - SeverityHigh - SeverityCritical -) - -var severityNames = [...]string{"info", "low", "medium", "high", "critical"} - -func (s Severity) String() string { - if int(s) < len(severityNames) { - return severityNames[s] - } - return "unknown" -} - -// ParseSeverity converts a string to a Severity. -func ParseSeverity(s string) Severity { - switch strings.ToLower(strings.TrimSpace(s)) { - case "critical": - return SeverityCritical - case "high": - return SeverityHigh - case "medium": - return SeverityMedium - case "low": - return SeverityLow - default: - return SeverityInfo - } -} - -// AtLeast returns true if s >= threshold. -func (s Severity) AtLeast(threshold Severity) bool { - return s >= threshold -} - -// TokenSeverity defines rule severity for compression error patterns. -type TokenSeverity string - -const ( - TokenSeverityCritical TokenSeverity = "critical" - TokenSeverityHigh TokenSeverity = "high" - TokenSeverityMedium TokenSeverity = "medium" - TokenSeverityLow TokenSeverity = "low" -) - -// AuditSeverity indicates how dangerous a security audit finding is. -type AuditSeverity string - -const ( - AuditSeverityCritical AuditSeverity = "CRITICAL" - AuditSeverityWarning AuditSeverity = "WARNING" - AuditSeverityInfo AuditSeverity = "INFO" -) diff --git a/shared/types/severity_test.go b/shared/types/severity_test.go deleted file mode 100644 index 43a0ff59..00000000 --- a/shared/types/severity_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package types_test - -import ( - "testing" - - "github.com/GrayCodeAI/hawk/shared/types" -) - -func TestParseSeverity_forwarded(t *testing.T) { - t.Parallel() - cases := []struct { - in string - want types.Severity - }{ - {"critical", types.SeverityCritical}, - {"high", types.SeverityHigh}, - {"medium", types.SeverityMedium}, - {"low", types.SeverityLow}, - {"info", types.SeverityInfo}, - {"unknown", types.SeverityInfo}, - } - for _, tc := range cases { - if got := types.ParseSeverity(tc.in); got != tc.want { - t.Fatalf("ParseSeverity(%q) = %v, want %v", tc.in, got, tc.want) - } - } -}