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
159 changes: 159 additions & 0 deletions docs/plans/goose-adoption-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Goose Adoption Plan

Status: Proposed

Source: `https://github.com/aaif-goose/goose` (Apache-2.0, Rust workspace,
Linux Foundation / Agentic AI Foundation)

## Executive Decision

Goose's audit against hawk found that most of its runtime concepts already have
a native hawk implementation (providers via eyrie, sessions, MCP, ACP, skills,
native sandboxing, permissions). The genuinely novel, hawk-relevant ideas are
adopted here in Go, without copying Rust code or weakening hawk's native
sandboxing model.

## Existing Hawk Capabilities

| Goose package/concept | Hawk implementation | Decision |
|---|---|---|
| Provider abstraction (~36) | `external/eyrie` (28 built-in + 75+ live) | Keep hawk |
| Sessions (SQLite WAL) | `internal/session` JSONL+WAL+zstd + `external/trace` | Keep hawk |
| MCP (client+server) | `internal/mcp` + `external/hawk-mcpkit` | Keep hawk |
| ACP | `internal/acp` | Keep hawk |
| Extensions/skills | `internal/plugin`, skills registry | Keep hawk |
| OS sandboxing | `internal/sandbox` seatbelt/landlock/seccomp/ACL | **hawk ahead** (goose has none) |
| OSV malware gate | `internal/permissions/osv_checker.go` (`CheckCommand`/`CheckPackage`) | Keep hawk |
| Hints / AGENTS.md | `internal/config` AGENTS.md loader | **Adopt** @file references + subdir hints |
| Context compaction | `external/tok` + `internal/engine/compaction` | **Adopt** structured-summary retry ladder |
| Extension env safety | none (only OSV gate) | **Adopt** disallowed-env-var filter |
| Download manager | `internal/container`, `tool` | Out of scope |

## Priority Model

- **P0:** Security-relevant, bounded, hawk-native adoptions.
- **P1:** High-value product improvements.
- **Defer:** Larger or cross-cutting changes needing an RFC.

## P0: Disallowed Env-Var Filter for Package/Extension Launch

### Goal

Prevent command/library hijacking when spawning `uvx`/`npx`/CLI-based extension
or MCP stdio processes by filtering dangerous environment overrides
(`PATH`, `LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_*`, `PYTHONPATH`,
`NODE_OPTIONS`, `GOROOT`, etc.) from the child environment.

### Scope and ownership

- Primary: `internal/sandbox` (or `internal/permissions` next to the OSV
checker) — a `sanitizeEnv` / `SafeEnv` helper.
- Consumers: `internal/mcp` stdio launch path and `internal/plugin` package
execution.
- No changes to `external/eyrie`.

### Required behavior

1. Define the disallowed env-var set (path/library/python/node/go hijacking
vectors), mirroring goose `extension.rs::Envs`.
2. Provide a helper that, given a proposed env map, drops disallowed keys and
returns the sanitized map plus the list of removed keys.
3. Apply it before spawning extension/MCP stdio subprocesses.
4. Log removed keys at debug/warn (not their values).

### Acceptance criteria

- A config that sets `PATH`/`LD_PRELOAD`/`PYTHONPATH` cannot influence the child
process.
- Sanitization is unit-tested for the full disallowed set and for allow-listed
benign keys.
- Existing extension launch behavior is unchanged when no disallowed keys are
present.

> Adopted: `internal/sandbox/env_sanitize.go` (`SanitizeEnv`) wired into
> `internal/plugin/bridge.go`. Unit-tested.

## P0: AGENTS.md `@file` References with Boundary + Budgets

### Goal

Let `AGENTS.md` (and other context files) reference additional files via
`@path` that get inlined into the loaded context, bounded by the git root and
strict size/depth budgets — matching goose `hints/import_files.rs`.

### Scope and ownership

- Primary: `internal/config` (the AGENTS.md loader).
- Boundary: stop imports at the git root so a context file cannot pull in files
from outside the repository.
- Budgets: max import depth, max operations, max expanded bytes, content parse
limit.

### Required behavior

1. Parse `@path` references in the loaded context file.
2. Resolve them relative to the context file, refusing paths outside the git
root.
3. Inline referenced file content recursively, applying depth/operation/byte
budgets.
4. On any budget/parse violation, fail that reference gracefully (skip) without
failing the whole load.

### Acceptance criteria

- A context file can pull in an in-repo file and its content appears in the
loaded context.
- A reference outside the git root is refused.
- Depth/byte/operation budgets are enforced and unit-tested.
- Existing single-file AGENTS.md behavior is unchanged.

> Adopted: `internal/config/context_refs.go` (`expandContextReferences`) wired
> into `LoadAgentsMDFrom`. Unit-tested.

## P1: Structured Compaction Overflow Retry Ladder

### Goal

Upgrade hawk's context compaction to a structured summary with a progressive
tool-response-dropping retry ladder on overflow, and token-estimator-backed
accounting, matching `goose-context-management`.

### Scope and ownership

- Primary: `internal/engine/compaction` (and `external/tok` for any
compression primitive).
- Behavior: on compaction context-overflow, drop tool responses from the middle
outwards and retry; parse structured summaries leniently with a lossless raw
fallback.

### Required behavior

1. Detect compaction overflow (`ContextLengthExceeded`).
2. Retry with progressive tool-response removal (`[0,10,20,50,100]%`).
3. Produce a structured summary (intent, files, errors/fixes, next step) when
possible, falling back to raw text losslessly.
4. Estimate tokens when the provider does not report usage.

### Acceptance criteria

- Compaction succeeds where it previously overflowed by dropping tool responses.
- Structured-summary parsing is lenient and never loses content to a hard error.
- Unit tests cover the retry ladder and fallback.

## Deliberately Deferred

- Goose's SQLite session store (`usage_ledger`, token/cost schema): hawk's
JSONL/WAL + trace + cost tracker cover it; a schema migration is a larger
change and is tracked separately.
- MCP Apps / agent-provided HTML UIs: novel but requires UI-layer design.
- ACP-as-provider wrapping other CLIs: larger provider abstraction change.
- Local-inference tool emulation / toolshim: depends on eyrie's local-model
path.
- Recipe security scanner / cron recipes: hawk already has schedule/cron.

## Verification

- `go test ./...` full suite.
- `make vet`, `make lint`, `hawk verify`.
- Focused tests for the env filter, AGENTS.md references, and compaction retry.
- markdownlint on this document.
10 changes: 7 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,16 @@ func LoadAgentsMDFrom(start string) string {
}
for {
for _, name := range agentFiles {
data, err := os.ReadFile(filepath.Join(dir, name)) // #nosec G304 -- dir is the working directory or an ancestor of it; name is a fixed constant
path := filepath.Join(dir, name)
data, err := os.ReadFile(path) // #nosec G304 -- dir is the working directory or an ancestor of it; name is a fixed constant
if err == nil {
content := string(data)
if len(data) > maxAgentsMDSize {
return string(data[:maxAgentsMDSize]) + "\n\n[WARNING: AGENTS.md truncated to 10KB]"
content = content[:maxAgentsMDSize] + "\n\n[WARNING: AGENTS.md truncated to 10KB]"
}
return string(data)
// Expand `@path` references (bounded by the git root and strict
// size/depth budgets) so AGENTS.md can pull in in-repo files.
return expandContextReferences(content, dir, gitRoot(dir))
}
}
parent := filepath.Dir(dir)
Expand Down
156 changes: 156 additions & 0 deletions internal/config/context_refs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package config

import (
"fmt"
"os"
"path/filepath"
"strings"
)

// Context-file reference budgets, mirroring goose `hints/import_files.rs`.
const (
// maxRefDepth bounds recursive inlining depth.
maxRefDepth = 3
// maxRefOps bounds the total number of reference operations per root file.
maxRefOps = 64
// maxRefBytes bounds the total expanded output per root file.
maxRefBytes = 1 << 20 // 1 MiB
// maxRefFileSize bounds a single referenced file's parse size (ReDoS guard).
maxRefFileSize = 128 << 10 // 128 KiB
)

// gitRoot finds the repository root for start by walking up for a `.git`
// entry (directory or file). Returns "" when no repo is found.
func gitRoot(start string) string {
dir := start
if abs, err := filepath.Abs(dir); err == nil {
dir = abs
}
for {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}

// expandContextReferences inlines `@path` reference lines found in a context
// file (e.g. AGENTS.md) with recursive resolution bounded by the git root and
// strict size/depth budgets. Reference lines are a path prefixed by `@` on its
// own line (trimmed). Referenced file content is inserted in place of the
// reference line. Any violation (out-of-root path, budget, size, parse failure)
// skips that reference gracefully without failing the whole load.
func expandContextReferences(content, baseDir, root string) string {
state := &refState{ops: 0}
out := expandRecursive(content, baseDir, root, 0, state)
if state.bytes > maxRefBytes {
// The caller already truncated to maxAgentsMDSize; keep the budget
// enforcement explicit so the referenced content never dominates.
if len(out) > maxRefBytes {
out = out[:maxRefBytes]
}
}
return out
}

// refState tracks the cumulative budget across recursive reference expansion.
type refState struct {
ops int
bytes int
}

func expandRecursive(content, baseDir, root string, depth int, state *refState) string {
if depth > maxRefDepth {
return content
}
lines := strings.Split(content, "\n")
var out []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
ref, ok := parseReferenceLine(trimmed)
if !ok {
out = append(out, line)
continue
}
if state.ops >= maxRefOps {
out = append(out, "# [context] reference skipped: operation budget exhausted")
continue
}
resolved, err := resolveReference(baseDir, root, ref)
if err != nil {
out = append(out, "# [context] reference skipped: "+err.Error())
continue
}
data, err := os.ReadFile(resolved) // #nosec G304 -- resolved is constrained to the git root by resolveReference
if err != nil {
out = append(out, "# [context] reference skipped: cannot read "+ref)
continue
}
if len(data) > maxRefFileSize {
out = append(out, "# [context] reference skipped: file too large "+ref)
continue
}
if state.bytes+len(data) > maxRefBytes {
out = append(out, "# [context] reference skipped: byte budget exhausted")
continue
}
state.ops++
state.bytes += len(data)
refContent := string(data)
refContent = expandRecursive(refContent, filepath.Dir(resolved), root, depth+1, state)
out = append(out, refContent)
}
return strings.Join(out, "\n")
}

// parseReferenceLine returns (path, true) when the line is a reference
// (`@` + a non-empty path without spaces), else (_, false).
func parseReferenceLine(trimmed string) (string, bool) {
if !strings.HasPrefix(trimmed, "@") {
return "", false
}
ref := strings.TrimSpace(strings.TrimPrefix(trimmed, "@"))
if ref == "" || strings.ContainsAny(ref, " \t") {
return "", false
}
return ref, true
}

// resolveReference resolves a reference path relative to baseDir and ensures it
// stays within root (the git root boundary).
func resolveReference(baseDir, root, ref string) (string, error) {
if baseDir == "" {
return "", fmt.Errorf("no base directory")
}
abs, err := filepath.Abs(filepath.Join(baseDir, ref))
if err != nil {
return "", fmt.Errorf("bad reference %q: %w", ref, err)
}
if root == "" {
// No repo boundary; refuse absolute/escaping references for safety.
if strings.HasPrefix(ref, "/") {
return "", fmt.Errorf("absolute reference %q refused (no repo boundary)", ref)
}
return abs, nil
}
rootAbs, err := filepath.Abs(root)
if err != nil {
return "", fmt.Errorf("bad root %q: %w", root, err)
}
if !within(rootAbs, abs) {
return "", fmt.Errorf("reference %q escapes the git root", ref)
}
return abs, nil
}

func within(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
Loading
Loading