Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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