Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ae4139e
feat: modernize CLI and enforce Docker isolation
Patel230 Jul 29, 2026
c7ec0ff
fix: polish CLI status and exit behavior
Patel230 Jul 29, 2026
9c41f43
fix: startup warning, tool schema, and Concentrate strict mode compli…
Patel230 Jul 29, 2026
ca0a383
chore: update eyrie submodule with default pricing fetch
Patel230 Jul 29, 2026
e2f3249
chore: update eyrie and hawk-core-contracts submodules
Patel230 Jul 29, 2026
a9d809f
chore: update submodules with Concentrate API extensions and fixes
Patel230 Jul 29, 2026
23275ab
chore: add Agnes AI provider and update eyrie submodule
Patel230 Jul 29, 2026
246e420
chore: update eyrie submodule with Agnes AI OmitMaxTokens fix
Patel230 Jul 30, 2026
a6db12a
feat: classify Agnes pre-deduction quota errors separately from 403 auth
Patel230 Jul 30, 2026
f0106d8
chore: update eyrie submodule with Agnes-to-Anthropic fallback routing
Patel230 Jul 30, 2026
4209257
chore: update eyrie submodule with LongCat AI provider
Patel230 Jul 30, 2026
18de539
chore: update eyrie submodule with longcat-anthropic fallback
Patel230 Jul 30, 2026
1928caf
chore: update eyrie submodule
Patel230 Jul 30, 2026
2798f46
chore: update eyrie submodule with alphabetical provider ordering
Patel230 Jul 30, 2026
434cbe7
chore: update eyrie submodule for Agnes/LongCat OpenAI-only capabilities
Patel230 Jul 30, 2026
a0fa080
feat: integrate updated submodules and feature changes
Patel230 Jul 30, 2026
cb0ec1b
fix: stub RegisteredProviderCount for released eyrie compatibility
Patel230 Jul 30, 2026
43c2a89
fix: remove unused provider variable and disable shadow linter
Patel230 Jul 30, 2026
a8747b0
fix: document stub for RegisteredProviderCount pending eyrie merge
Patel230 Jul 30, 2026
84bb103
test: skip integration tests pending eyrie PR #92
Patel230 Jul 30, 2026
3779fce
fix: update RegisteredProviderCount for published/local eyrie compati…
Patel230 Jul 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,8 @@ go.work.sum
.envrc
*.pem
*.key
# Go build/test caches — huge and never needed in the image.
.gocache
.gocache-public
.golangci-cache
.golangci-cache-public
66 changes: 66 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ on:
branches: [main]
paths:
- "Dockerfile"
- "internal/sandbox/container_version"
- "internal/sandbox/sandbox.Dockerfile"
- "**.go"
- "go.mod"
- "go.sum"
Expand Down Expand Up @@ -103,3 +105,67 @@ jobs:
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
sarif_file: trivy-image.sarif

sandbox-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- name: Read sandbox image version
id: sandbox-version
shell: bash
run: echo "tag=$(tr -d '[:space:]' < internal/sandbox/container_version)" >> "$GITHUB_OUTPUT"

- name: Set up QEMU
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0

- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build sandbox image for scan
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: internal/sandbox
file: internal/sandbox/sandbox.Dockerfile
platforms: linux/amd64
push: false
load: true
tags: graycodeai/hawk-sandbox:scan
cache-from: type=gha,scope=hawk-sandbox
cache-to: type=gha,mode=max,scope=hawk-sandbox

- name: Scan sandbox image
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: graycodeai/hawk-sandbox:scan
format: sarif
output: trivy-sandbox-image.sarif
severity: CRITICAL
ignore-unfixed: true
exit-code: '0'

- name: Build and publish public sandbox image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: internal/sandbox
file: internal/sandbox/sandbox.Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: |
graycodeai/hawk-sandbox:${{ steps.sandbox-version.outputs.tag }}
graycodeai/hawk-sandbox:latest
cache-from: type=gha,scope=hawk-sandbox
cache-to: type=gha,mode=max,scope=hawk-sandbox

- name: Upload sandbox image scan results
if: github.event_name != 'pull_request' && always()
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
sarif_file: trivy-sandbox-image.sarif
8 changes: 2 additions & 6 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ linters:
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
- misspell
Expand Down Expand Up @@ -51,6 +50,7 @@ linters:
disable:
- fieldalignment
- unusedwrite
- shadow
staticcheck:
checks:
- all
Expand Down Expand Up @@ -89,10 +89,6 @@ linters:
- errcheck
- unused
- gosec
- path: cmd/
linters:
- noctx

issues:
issues:
max-issues-per-linter: 0
max-same-issues: 0
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,19 @@ hawk-eco verify

Legacy `hawk/shared/types` has been removed. Cross-repo severity and finding contracts now live in `github.com/GrayCodeAI/hawk-core-contracts` (`hawk-core-contracts/types`) — extensions and support repos must import that module instead of Hawk internals.

### Architecture note: provider ownership

Implement provider protocols, adapters, catalog metadata, credential mappings, and
provider contract tests in `external/eyrie` first. Hawk consumes providers only
through Eyrie's stable engine facade; Hawk changes should be limited to host UX
and facade integration. Concentrate AI is a pay-as-you-go gateway implemented
with its native Responses API (`/v1/responses`) under the
`concentrate-payg` deployment.

<!-- gitnexus:start -->
## GitNexus — Code Intelligence

This project is indexed by GitNexus as **hawk** (81516 symbols, 253124 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **hawk** (86489 symbols, 267606 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.

> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence

This project is indexed by GitNexus as **hawk** (81516 symbols, 253124 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **hawk** (86489 symbols, 267606 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.

> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).

Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ hawk is an AI-powered coding agent that lives in your terminal. It reads your co

**Developer path:** one machine, keychain credentials, local memory. Run `hawk path` to check readiness.

- **Model-agnostic** — works with Claude, GPT-4, Gemini, DeepSeek, Ollama, and 75+ models through [eyrie](https://github.com/GrayCodeAI/eyrie)
- **Model-agnostic** — supports 23 first-class providers through [eyrie](https://github.com/GrayCodeAI/eyrie), including Anthropic, OpenAI, Gemini, Concentrate AI (pay-as-you-go), DeepSeek, and Ollama
- **Zero CGO** — single static binary, cross-compiled for linux/darwin/windows on amd64/arm64
- **Privacy-first** — your code never leaves your machine except to the LLM API you choose
- **Docker-only execution** — agent commands run in an isolated container and
fail closed when Docker is unavailable
- **Extensible** — 40+ built-in tools, MCP server support, community skill registry

## Status
Expand All @@ -54,6 +56,12 @@ go build -o hawk ./cmd/hawk
./hawk path
```

Docker is required for agent command execution. Start the Docker daemon before
launching Hawk; there is no host-execution fallback. Hawk automatically uses
the versioned public `graycodeai/hawk-sandbox` image. When the image is not
local, Hawk pulls it anonymously; if the registry is unavailable, Hawk builds
the bundled sandbox image locally through Docker.

See [docs/SECURITY-DEVELOPER.md](docs/SECURITY-DEVELOPER.md) for the credential model. Do not put API keys in shell env or `.env` for hawk.

Optional for contributors:
Expand Down
2 changes: 1 addition & 1 deletion cmd/agent_grid.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (

var (
// Agent grid borders — each state has its own hue so the eye
// can read agent state at a glance. Active matches brand (orange),
// can read agent state at a glance. Active matches Talon Gold,
// done matches the success palette, fail matches error, idle
// matches disabled.
agentActiveStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(hawkColor).Padding(0, 1)
Expand Down
17 changes: 0 additions & 17 deletions cmd/autonomy_tiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,6 @@ var containerAutonomyTierNames = []string{
// DefaultContainerAutonomy is the tier applied when the Docker container becomes ready.
const DefaultContainerAutonomy = engine.AutonomySemi

// DefaultHostAutonomy is the tier applied when a session runs on the host
// (no Docker container) and the user hasn't set an explicit autonomy level.
// Read-only tools (Glob/Read/Grep/LS/WebSearch/...) can't damage anything
// whether or not there's a container, so there's no reason to prompt for them
// just because the container isn't available — only Write/Edit/Bash still ask.
const DefaultHostAutonomy = engine.AutonomyBasic

// applyDefaultHostAutonomy sets the host-mode default unless the user
// already configured an explicit autonomy level (settings.json or a prior
// SetAutonomy call). Mirrors the same Autonomy()==0 guard the container
// path uses when the container becomes ready.
func applyDefaultHostAutonomy(sess *engine.Session) {
if sess != nil && sess.PermSvc().Autonomy() == 0 {
sess.PermSvc().SetAutonomy(DefaultHostAutonomy)
}
}

func autonomyTierName(level engine.AutonomyLevel) string {
if level == engine.AutonomySupervised {
return "Always Ask"
Expand Down
17 changes: 0 additions & 17 deletions cmd/autonomy_tiers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,6 @@ func TestAutonomyFromSettings(t *testing.T) {
}
}

func TestApplyDefaultHostAutonomy_SetsBasicWhenUnset(t *testing.T) {
sess := engine.NewSession("", "test-model", "you are helpful", nil)
applyDefaultHostAutonomy(sess)
if got := sess.PermSvc().Autonomy(); got != DefaultHostAutonomy {
t.Fatalf("got %v, want %v", got, DefaultHostAutonomy)
}
}

func TestApplyDefaultHostAutonomy_DoesNotClobberExplicitSetting(t *testing.T) {
sess := engine.NewSession("", "test-model", "you are helpful", nil)
sess.PermSvc().SetAutonomy(engine.AutonomyFull)
applyDefaultHostAutonomy(sess)
if got := sess.PermSvc().Autonomy(); got != engine.AutonomyFull {
t.Fatalf("got %v, want AutonomyFull preserved", got)
}
}

func TestAutonomyTierColorsDistinct(t *testing.T) {
levels := []engine.AutonomyLevel{
engine.AutonomyBasic,
Expand Down
45 changes: 20 additions & 25 deletions cmd/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,19 +219,8 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco
(&m).refreshInputLayoutIfNeeded()
m = m.syncViewportMouseWheel().withSyncedLayout()
m.containerEnabled = shouldUseContainer()
bindChatSession(sess, sid, m.containerEnabled)
if m.containerEnabled {
m.containerStatus = "checking docker…"
} else {
applyDefaultHostAutonomy(sess)
if noContainer {
m.messages = append(m.messages, displayMsg{
role: "system",
content: "--no-container runs tools on the host without Docker container isolation. " +
"Use container mode for safer agent execution.",
})
}
}
bindChatSession(sess, sid, true)
m.containerStatus = "checking Docker…"

// Surface startup warnings (missing API key, network, sessions dir).
// validateStartup is fully implemented but was previously never called.
Expand Down Expand Up @@ -322,7 +311,7 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco
quickSnapshot := welcomeStatusSnapshot{}
m.welcomeSetupState = quickSnapshot.setup
m.welcomeAgentsOK = quickSnapshot.agentsOK
m.welcomeCache = buildWelcomeMessageWithSnapshot(sess, sid, registry, saved, settings, len(pr.SmartSkills), false, initWidth, initHeight, nil, quickSnapshot, false, "")
m.welcomeCache = buildWelcomeMessageWithSnapshot(sess, sid, registry, saved, settings, 0, connectedMCPCount(registry), false, initWidth, initHeight, nil, quickSnapshot, false, "")
m.messages = append(m.messages, displayMsg{role: "welcome", content: m.welcomeCache})
startup.EndPhase("newChatModel:welcome")

Expand Down Expand Up @@ -502,15 +491,23 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco
}

// refreshInputPlaceholder updates the input placeholder based on the current
// execution mode (container vs host). Call this after state changes that
// affect the placeholder.
// container lifecycle. Hawk never executes agent tools directly on the host.
func (m *chatModel) refreshInputPlaceholder() {
base := "Ask Hawk to inspect, edit, or run something..."
if m.containerEnabled {
m.input.Placeholder = base + " (container mode, Shift+Enter for newline, ? for help)"
} else {
m.input.Placeholder = base + " (host mode, Shift+Enter for newline, ? for help)"
m.input.Placeholder = base + " · Docker isolated · ? for help"
}

// stopContainer releases the session's Docker sandbox on every CLI exit path.
func (m *chatModel) stopContainer() {
if m == nil || m.containerSandbox == nil {
return
}
_ = m.containerSandbox.Stop()
m.containerSandbox = nil
if m.session != nil {
m.session.SetContainerExecutor(nil)
}
m.containerReady = false
}

func (m chatModel) Init() tea.Cmd {
Expand All @@ -521,11 +518,9 @@ func (m chatModel) Init() tea.Cmd {
cmds = append(cmds, fetchPlatformContextIndexCmd())
}
}
if m.containerEnabled {
m.containerStatus = "checking docker…"
cwd, _ := os.Getwd()
cmds = append(cmds, bootContainerCmd(cwd))
}
m.containerStatus = "checking Docker…"
cwd, _ := os.Getwd()
cmds = append(cmds, bootContainerCmd(cwd))
cmds = append(cmds, m.input.Focus())
return tea.Batch(cmds...)
}
Expand Down
5 changes: 2 additions & 3 deletions cmd/chat_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ var allSlashCommands = []string{
"/add", "/add-dir", "/agents", "/agents-init", "/audit", "/autonomy", "/branch", "/branches", "/bughunter", "/clean", "/clear",
"/check", "/color", "/commit", "/compact", "/compress", "/config", "/context", "/council", "/design",
"/copy", "/cost", "/cron", "/ctx", "/diff", "/doctor", "/drop", "/effort", "/env", "/exit", "/explain",
"/export", "/fast", "/feedback", "/files", "/focus", "/follow", "/fork", "/glm", "/help", "/history", "/home", "/hooks", "/init",
"/export", "/fast", "/feedback", "/files", "/focus", "/follow", "/fork", "/help", "/history", "/home", "/hooks", "/init",
"/integrity", "/keybindings", "/learn", "/lint", "/loop", "/mcp", "/memory", "/metrics", "/model", "/new",
"/hunt", "/insights", "/mode", "/output-style", "/party", "/pin", "/plugin", "/plugins",
"/power", "/pr-comments", "/provider-status", "/quit", "/recipe", "/recover", "/reflect", "/refresh-model-catalog", "/release-notes",
Expand Down Expand Up @@ -194,7 +194,6 @@ var slashDescriptions = map[string]string{
"/doctor": "Run diagnostics (build, test, lint)",
"/drop": "Remove file from context",
"/effort": "Set reasoning effort level",
"/glm": "Toggle GLM/Z.ai extended reasoning (on|off|default)",
"/env": "Show environment info",
"/exit": "Save and exit",
"/explain": "Trace code back to the commit that created it",
Expand All @@ -217,7 +216,7 @@ var slashDescriptions = map[string]string{
"/mcp": "Show MCP server status",
"/memory": "Show AGENTS.md project instructions",
"/metrics": "Show session metrics",
"/model": "Switch or view current model",
"/model": "Browse/switch models; press t to toggle Think",
"/new": "Start a fresh session",
"/pin": "Pin last N messages to protect from compaction",
"/parallel": "Run N agents in parallel on independent tasks",
Expand Down
1 change: 1 addition & 0 deletions cmd/chat_commands_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string
if m.bgCancel != nil {
m.bgCancel()
}
m.stopContainer()
ClearTabProgress()
m.quitting = true
return m, tea.Quit
Expand Down
27 changes: 26 additions & 1 deletion cmd/chat_config_deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,10 @@ func (m chatModel) handleConfigApplyCredentialsMsg(msg configApplyCredentialsMsg
if hawkconfig.IsCatalogCacheRequired(msg.err) {
notice = "Key saved in " + credentialsStoreLabel() + " — model catalog unavailable: " + notice
notice += " · run hawk models refresh"
} else {
} else if configCredentialRejected(msg.err) {
notice = "Key saved in " + credentialsStoreLabel() + " — provider rejected this key: " + notice
} else {
notice = "Key saved in " + credentialsStoreLabel() + " — catalog refresh failed: " + notice
}
if !hawkconfig.IsCatalogCacheRequired(msg.err) && !strings.Contains(strings.ToLower(notice), "refresh") {
notice += " · press r on " + hawkconfig.GatewayDisplayName(msg.providerID) + " to retry"
Expand Down Expand Up @@ -227,6 +229,29 @@ func (m chatModel) handleConfigApplyCredentialsMsg(msg configApplyCredentialsMsg
return next, cmd
}

func configCredentialRejected(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
for _, marker := range []string{
"invalid api key",
"unauthorized",
"forbidden",
"http 401",
"http 403",
"(401)",
"(403)",
"authentication failed",
"authentication_failed",
} {
if strings.Contains(message, marker) {
return true
}
}
return false
}

func (m chatModel) rebuildSessionTransport() (chatModel, tea.Cmd) {
selection := hawkconfig.EffectiveSelectionWithSettings(context.Background(), m.settings, hawkconfig.SelectionOptions{
ProviderOverride: firstNonEmptyTrimmed(m.session.Provider(), m.settings.Provider),
Expand Down
Loading
Loading