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
13 changes: 13 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,19 @@ paths:
schema:
$ref: "#/components/schemas/Error"

/v1/status:
get:
operationId: statusSnapshot
tags: [system]
summary: Get a redacted daemon status snapshot
responses:
"200":
description: Current daemon status
content:
application/json:
schema:
type: object

/v1/ready:
get:
operationId: readinessProbe
Expand Down
4 changes: 2 additions & 2 deletions cmd/chat_commands_skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,13 @@ func (m *chatModel) handleSkillsCommand(parts []string, text string) (tea.Model,
m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()})
return m, nil
}
r := plugin.AuditResult{Findings: findings, Files: 1}
r := plugin.AuditResult{Findings: findings, Validation: plugin.ValidateSkillFile(target), Files: 1}
m.messages = append(m.messages, displayMsg{role: "system", content: plugin.FormatAuditResult(r)})
return m, nil
}
if _, path, ok := plugin.InstalledSkillInfo(target); ok {
findings, _ := plugin.AuditSkillFile(path)
r := plugin.AuditResult{Findings: findings, Files: 1}
r := plugin.AuditResult{Findings: findings, Validation: plugin.ValidateSkillFile(path), Files: 1}
m.messages = append(m.messages, displayMsg{role: "system", content: plugin.FormatAuditResult(r)})
return m, nil
}
Expand Down
3 changes: 2 additions & 1 deletion cmd/chat_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

hawkconfig "github.com/GrayCodeAI/hawk/internal/config"
"github.com/GrayCodeAI/hawk/internal/lsp"
"github.com/GrayCodeAI/hawk/internal/tool"
)

Expand Down Expand Up @@ -70,7 +71,6 @@ func essentialTools() []tool.Tool {
tool.WaitTasksTool{},
tool.KillTaskTool{},
tool.MonitorTool{},
tool.LSPTool{},
tool.MultiEditTool{},
tool.BrowserTool{},
tool.ScreenshotTool{},
Expand Down Expand Up @@ -297,6 +297,7 @@ func defaultRegistry(settings hawkconfig.Settings) (*tool.Registry, error) {
if err != nil {
return nil, err
}
filtered = append(filtered, tool.LSPTool{Manager: lsp.NewManagerFromProject(".")})
registry := tool.NewRegistry(filtered...)
// Lazy model surface: only essential tools are sent to the LLM.
// Optional tools register for Get/ToolSearch and promote via select:.
Expand Down
30 changes: 30 additions & 0 deletions cmd/chat_tools_lsp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package cmd

import (
"testing"

hawkconfig "github.com/GrayCodeAI/hawk/internal/config"
"github.com/GrayCodeAI/hawk/internal/tool"
)

func TestDefaultRegistryWiresLanguageServerManager(t *testing.T) {
registry, err := defaultRegistry(hawkconfig.Settings{})
if err != nil {
t.Fatal(err)
}
registered, ok := registry.Get("LSP")
if !ok {
t.Fatal("default registry must include LSP")
}
lspTool, ok := registered.(tool.LSPTool)
if !ok {
t.Fatalf("LSP tool type = %T, want tool.LSPTool", registered)
}
if lspTool.Manager == nil {
t.Fatal("LSP tool must have a language-server manager")
}
if len(lspTool.Manager.Status()) == 0 {
t.Fatal("language-server manager should expose built-in server configurations")
}
_ = lspTool.Manager.Close()
}
185 changes: 185 additions & 0 deletions cmd/permissions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package cmd

import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"

"github.com/GrayCodeAI/hawk/internal/permissions"
"github.com/GrayCodeAI/hawk/internal/permissions/stableid"
"github.com/spf13/cobra"
)

var (
permissionsJSON bool
permissionsScope string
)

type permissionRuleOutput struct {
ID uint64 `json:"id"`
Kind string `json:"kind"`
Identity string `json:"identity"`
Decision string `json:"decision"`
Generation uint64 `json:"generation"`
}

var permissionsCmd = &cobra.Command{
Use: "permissions",
Short: "List and manage exact permission rules",
}

var permissionsListCmd = &cobra.Command{
Use: "list",
Short: "List persisted exact permission rules",
RunE: func(cmd *cobra.Command, _ []string) error {
store := currentStableRuleStore()
if err := store.Load(); err != nil {
return err
}
rules := store.List()
out := make([]permissionRuleOutput, 0, len(rules))
for _, rule := range rules {
out = append(out, permissionRuleOutput{
ID: rule.ID, Kind: rule.Key.Kind.String(), Identity: rule.DisplayIdentity,
Decision: rule.Decision.String(), Generation: rule.Generation,
})
}
if permissionsJSON {
data, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
_, err = cmd.OutOrStdout().Write(append(data, '\n'))
return err
}
if len(out) == 0 {
cmd.Println("No persisted permission rules.")
return nil
}
for _, rule := range out {
cmd.Printf("%d\t%s\t%s\t%s\t%d\n", rule.ID, rule.Decision, rule.Kind, rule.Identity, rule.Generation)
}
return nil
},
}

var permissionsAddCmd = &cobra.Command{
Use: "add <allow|deny> <command|file|tool> <identity>",
Short: "Persist an exact permission rule",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
decision, err := parsePermissionDecision(args[0])
if err != nil {
return err
}
kind, err := parsePermissionKind(args[1])
if err != nil {
return err
}
identity := strings.TrimSpace(args[2])
if identity == "" {
return fmt.Errorf("permission identity cannot be empty")
}
store := currentStableRuleStore()
if err := store.Load(); err != nil {
return err
}
id, ok := store.Remember(kind, identity, identity, decision)
if !ok {
return fmt.Errorf("could not add permission rule")
}
if err := store.Save(); err != nil {
return err
}
cmd.Printf("Permission rule %d saved.\n", id)
return nil
},
}

var permissionsRevokeCmd = &cobra.Command{
Use: "revoke <rule-id>",
Short: "Revoke a persisted exact permission rule",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := strconv.ParseUint(args[0], 10, 64)
if err != nil || id == 0 {
return fmt.Errorf("invalid permission rule ID %q", args[0])
}
store := currentStableRuleStore()
if err := store.Load(); err != nil {
return err
}
if !store.Revoke(id) {
return fmt.Errorf("permission rule %d not found", id)
}
if err := store.Save(); err != nil {
return err
}
cmd.Printf("Permission rule %d revoked.\n", id)
return nil
},
}

var permissionsResetCmd = &cobra.Command{
Use: "reset",
Short: "Remove all persisted exact permission rules",
RunE: func(cmd *cobra.Command, _ []string) error {
if permissionsScope != "" && permissionsScope != "project" {
return fmt.Errorf("unsupported permission scope %q", permissionsScope)
}
store := currentStableRuleStore()
if err := store.Load(); err != nil {
return err
}
if !store.Reset() {
cmd.Println("No persisted permission rules.")
return nil
}
if err := store.Save(); err != nil {
return err
}
cmd.Println("Persisted permission rules reset.")
return nil
},
}

func currentStableRuleStore() *permissions.StableRuleStore {
projectDir, err := os.Getwd()
if err != nil {
projectDir = "."
}
return permissions.NewStableRuleStore(permissions.DefaultStableRulesPath(projectDir))
}

func parsePermissionDecision(raw string) (stableid.Decision, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "allow":
return stableid.Allow, nil
case "deny":
return stableid.Deny, nil
default:
return stableid.Deny, fmt.Errorf("permission decision must be allow or deny")
}
}

func parsePermissionKind(raw string) (stableid.Kind, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "command", "bash":
return stableid.KindCommand, nil
case "file", "file_mutation", "edit", "write":
return stableid.KindFileMutation, nil
case "tool", "structured_tool":
return stableid.KindStructuredTool, nil
default:
return stableid.KindCommand, fmt.Errorf("permission kind must be command, file, or tool")
}
}

func init() {
permissionsListCmd.Flags().BoolVar(&permissionsJSON, "json", false, "output rules as JSON")
permissionsResetCmd.Flags().StringVar(&permissionsScope, "scope", "project", "rule scope to reset")
permissionsCmd.AddCommand(permissionsListCmd, permissionsAddCmd, permissionsRevokeCmd, permissionsResetCmd)
rootCmd.AddCommand(permissionsCmd)
}
4 changes: 2 additions & 2 deletions cmd/skills_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ var skillsAuditCmd = &cobra.Command{
if err != nil {
return err
}
r := plugin.AuditResult{Findings: findings, Files: 1}
r := plugin.AuditResult{Findings: findings, Validation: plugin.ValidateSkillFile(target), Files: 1}
if jsonOut {
data, _ := json.MarshalIndent(r, "", " ")
fmt.Println(string(data))
Expand All @@ -172,7 +172,7 @@ var skillsAuditCmd = &cobra.Command{
}
if _, path, ok := plugin.InstalledSkillInfo(target); ok {
findings, _ := plugin.AuditSkillFile(path)
r := plugin.AuditResult{Findings: findings, Files: 1}
r := plugin.AuditResult{Findings: findings, Validation: plugin.ValidateSkillFile(path), Files: 1}
if jsonOut {
data, _ := json.MarshalIndent(r, "", " ")
fmt.Println(string(data))
Expand Down
87 changes: 87 additions & 0 deletions cmd/status_snapshot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package cmd

import (
"context"
"fmt"
"strings"

hawkconfig "github.com/GrayCodeAI/hawk/internal/config"
"github.com/GrayCodeAI/hawk/internal/engine"
"github.com/GrayCodeAI/hawk/internal/plugin"
"github.com/GrayCodeAI/hawk/internal/status"
"github.com/spf13/cobra"
)

var statusJSON bool

var statusCmd = &cobra.Command{
Use: "status",
Short: "Show a redacted runtime status snapshot",
RunE: func(cmd *cobra.Command, _ []string) error {
snapshot := buildStatusSnapshot()
if statusJSON {
data, err := snapshot.JSON()
if err != nil {
return err
}
_, err = cmd.OutOrStdout().Write(append(data, '\n'))
return err
}
cmd.Print(formatStatusSnapshot(snapshot))
return nil
},
}

func buildStatusSnapshot() status.Snapshot {
snapshot := status.New()
snapshot.HawkVersion = version
snapshot.Workspace = status.Workspace()
snapshot.GitBranch = engine.InspectGitBranch("").Branch
settings := hawkconfig.LoadGlobalSettings()
selection := hawkconfig.EffectiveSelection(context.Background(), hawkconfig.SelectionOptions{})
snapshot.Model = strings.TrimSpace(selection.Model)
snapshot.Provider = strings.TrimSpace(selection.Provider)
if snapshot.Model == "" {
snapshot.Model = strings.TrimSpace(settings.Model)
}
if snapshot.Provider == "" {
snapshot.Provider = strings.TrimSpace(settings.Provider)
}
snapshot.Permission.SandboxMode = settings.Sandbox
snapshot.Permission.EffectiveRules = len(settings.AllowedTools) + len(settings.DisallowedTools) + len(settings.AutoAllow)
if settings.AutonomyExplicit {
snapshot.Permission.AutonomyTier = fmt.Sprintf("%d", settings.Autonomy)
switch settings.Autonomy {
case 0:
snapshot.Permission.Mode = "ask"
case 4:
snapshot.Permission.Mode = "yolo"
default:
snapshot.Permission.Mode = "auto"
}
}
snapshot.MCP.Configured = len(settings.MCPServers)
snapshot.MCP.State = "not_loaded"
snapshot.Skills.State = "discovery_deferred"
if entries, err := plugin.DefaultRegistry.List(context.Background(), snapshot.Workspace); err == nil {
snapshot.Skills.Configured = len(entries)
snapshot.Skills.State = "available"
}
if engine.ProjectTrust(snapshot.Workspace).Blocked {
snapshot.Warnings = append(snapshot.Warnings, "project automation is blocked until this folder is trusted")
}
return snapshot
}

func formatStatusSnapshot(s status.Snapshot) string {
return fmt.Sprintf("Hawk status\nSchema: %s\nWorkspace: %s\nGit branch: %s\nProvider: %s\nModel: %s\nAutonomy tier: %s\nSandbox: %s\nPermission rules: %d\nMCP: %d configured (%s)\nSkills: %d (%s)\nSecrets redacted: %t\n",
s.SchemaVersion, s.Workspace, s.GitBranch, s.Provider, s.Model,
s.Permission.AutonomyTier, s.Permission.SandboxMode,
s.Permission.EffectiveRules, s.MCP.Configured, s.MCP.State,
s.Skills.Configured, s.Skills.State, s.Permission.SecretRedacted)
}

func init() {
statusCmd.Flags().BoolVar(&statusJSON, "json", false, "output the status snapshot as JSON")
rootCmd.AddCommand(statusCmd)
}
16 changes: 16 additions & 0 deletions cmd/status_snapshot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package cmd

import (
"strings"
"testing"
)

func TestFormatStatusSnapshot(t *testing.T) {
snapshot := buildStatusSnapshot()
formatted := formatStatusSnapshot(snapshot)
for _, expected := range []string{"Hawk status", "Schema: 1", "Secrets redacted: true"} {
if !strings.Contains(formatted, expected) {
t.Errorf("status output missing %q: %s", expected, formatted)
}
}
}
Loading
Loading