Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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: 3 additions & 2 deletions cmd/kontext/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/kontext-security/kontext-cli/internal/update"

_ "github.com/kontext-security/kontext-cli/internal/agent/claude"
_ "github.com/kontext-security/kontext-cli/internal/agent/hermes"
)

var version = "dev"
Expand Down Expand Up @@ -128,7 +129,7 @@ func startCmd() *cobra.Command {
},
}

cmd.Flags().StringVar(&agentName, "agent", "claude", "Agent to launch (currently: claude)")
cmd.Flags().StringVar(&agentName, "agent", "claude", "Agent to launch (supported: claude, hermes)")
cmd.Flags().StringVar(&templateFile, "env-template", ".env.kontext", "Path to env template file for --managed sessions")
cmd.Flags().BoolVar(&managed, "managed", false, "Launch with hosted managed credentials and shared traces")
cmd.Flags().BoolVar(&verbose, "verbose", false, "Show redacted diagnostic output")
Expand Down Expand Up @@ -214,7 +215,7 @@ func hookCmd() *cobra.Command {
if err != nil {
return err
}
adapter := guardhookruntime.AgentAdapter{Agent: a, AgentName: agentName}
adapter := guardhookruntime.AgentAdapter{Agent: a, AgentName: a.Name()}
processor := rootHookProcessor{socketPath: resolvedSocketPath, mode: hookMode}
return guardhookruntime.Run(context.Background(), adapter, processor, hookMode, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr())
}
Expand Down
34 changes: 34 additions & 0 deletions cmd/kontext/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -157,6 +159,38 @@ func TestHookCmdModeDoesNotDefaultFromEnv(t *testing.T) {
}
}

func TestHookCmdHermesAliasModeFailsClosedThroughRootHandler(t *testing.T) {
cmd := hookCmd()
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.SetIn(strings.NewReader(`{"hook_event_name":"pre_tool_call","tool_name":"terminal","tool_input":{"command":"cat .env"}}`))
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
if err := cmd.Flags().Set("agent", "hermes-agent"); err != nil {
t.Fatalf("Set agent error = %v", err)
}
if err := cmd.Flags().Set("mode", "enforce"); err != nil {
t.Fatalf("Set mode error = %v", err)
}
if err := cmd.Flags().Set("socket", filepath.Join(t.TempDir(), "missing.sock")); err != nil {
t.Fatalf("Set socket error = %v", err)
}

if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() error = %v, stderr = %q", err, stderr.String())
}
var output map[string]string
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
t.Fatalf("Unmarshal(stdout) error = %v, stdout = %q", err, stdout.String())
}
if output["action"] != "block" {
t.Fatalf("action = %q, want block; stdout = %q", output["action"], stdout.String())
}
if output["message"] != "sidecar unreachable" {
t.Fatalf("message = %q, want sidecar unreachable", output["message"])
}
}

func TestLogoutCmdAlreadyLoggedOut(t *testing.T) {
cmd := newLogoutCmd(func() error { return keyring.ErrNotFound })

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/zalando/go-keyring v0.2.8
golang.org/x/oauth2 v0.36.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.1
)

Expand Down
19 changes: 19 additions & 0 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,25 @@ type Agent interface {
EncodeHookResult(event hook.Event, result hook.Result) ([]byte, error)
}

type LocalLaunch struct {
Env []string
Args []string
}

type LocalLaunchOptions struct {
SessionDir string
KontextBinary string
AgentName string
SocketPath string
Mode string
BaseEnv []string
ExtraArgs []string
}

type LocalLauncher interface {
PrepareLocalLaunch(LocalLaunchOptions) (LocalLaunch, error)
}

type Aliaser interface {
Aliases() []string
}
Expand Down
259 changes: 259 additions & 0 deletions internal/agent/hermes/hermes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
package hermes

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

"github.com/kontext-security/kontext-cli/internal/agent"
"github.com/kontext-security/kontext-cli/internal/hook"
)

func init() {
agent.Register(&Hermes{})
}

type Hermes struct{}

func (h *Hermes) Name() string { return "hermes" }

func (h *Hermes) Aliases() []string { return []string{"hermes-agent"} }

func (h *Hermes) DecodeHookInput(input []byte) (hook.Event, error) {
return DecodeEvent(input)
}

func (h *Hermes) EncodeHookResult(event hook.Event, result hook.Result) ([]byte, error) {
return EncodeResult(event, result)
}

type HookInput struct {
HookEventName string `json:"hook_event_name"`
HookEventNameCamel string `json:"hookEventName"`
ToolName string `json:"tool_name"`
ToolNameCamel string `json:"toolName"`
ToolInput map[string]any `json:"tool_input"`
ToolInputCamel map[string]any `json:"toolInput"`
SessionID string `json:"session_id"`
SessionIDCamel string `json:"sessionId"`
CWD string `json:"cwd"`
Extra map[string]any `json:"extra"`
ToolCallID string `json:"tool_call_id"`
ToolUseID string `json:"tool_use_id"`
ToolUseIDCamel string `json:"toolUseID"`
DurationMs any `json:"duration_ms"`
DurationMsCamel any `json:"durationMs"`
Error any `json:"error"`
IsInterrupt any `json:"is_interrupt"`
IsInterruptCamel any `json:"isInterrupt"`
Result any `json:"result"`
}

type hookOutput struct {
Action string `json:"action,omitempty"`
Message string `json:"message,omitempty"`
}

func DecodeEvent(input []byte) (hook.Event, error) {
var h HookInput
if err := json.Unmarshal(input, &h); err != nil {
return hook.Event{}, fmt.Errorf("hermes: decode hook input: %w", err)
}
hookEventName := firstNonEmpty(h.HookEventName, h.HookEventNameCamel)
hookName, ok := mapHookName(hookEventName)
if !ok {
if strings.TrimSpace(hookEventName) == "" {
return hook.Event{}, fmt.Errorf("hermes: hook event name missing")
}
return hook.Event{}, fmt.Errorf("hermes: unsupported hook event %q", hookEventName)
}

metadata := h.metadata()
event := hook.Event{
SessionID: firstNonEmpty(h.SessionID, h.SessionIDCamel),
Agent: "hermes",
HookName: hookName,
ToolName: normalizeToolName(firstNonEmpty(h.ToolName, h.ToolNameCamel)),
ToolInput: firstMap(h.ToolInput, h.ToolInputCamel),
ToolUseID: stringFromExtra(metadata, "tool_call_id", "tool_use_id", "toolUseID"),
CWD: h.CWD,
ToolResponse: toolResponseFromExtra(metadata),
}
if duration, ok := int64FromExtra(metadata, "duration_ms", "durationMs"); ok {
event.DurationMs = &duration
}
if errText := stringFromExtra(metadata, "error"); errText != "" {
event.Error = errText
}
if interrupted, ok := boolFromExtra(metadata, "is_interrupt", "isInterrupt"); ok {
event.IsInterrupt = &interrupted
}
return event, nil
}

func (h HookInput) metadata() map[string]any {
metadata := map[string]any{}
for key, value := range h.Extra {
metadata[key] = value
}
for key, value := range map[string]any{
"tool_call_id": h.ToolCallID,
"tool_use_id": h.ToolUseID,
"toolUseID": h.ToolUseIDCamel,
"duration_ms": h.DurationMs,
"durationMs": h.DurationMsCamel,
"error": h.Error,
"is_interrupt": h.IsInterrupt,
"isInterrupt": h.IsInterruptCamel,
"result": h.Result,
} {
if isZeroMetadataValue(value) {
continue
}
metadata[key] = value
}
return metadata
}

func EncodeResult(event hook.Event, result hook.Result) ([]byte, error) {
if !event.HookName.CanBlock() || !result.Blocking() {
return []byte("{}"), nil
}

return json.Marshal(hookOutput{
Action: "block",
Message: blockMessage(result),
})
}

func mapHookName(value string) (hook.HookName, bool) {
switch strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), "_", "")) {
case "pretoolcall":
return hook.HookPreToolUse, true
case "posttoolcall":
return hook.HookPostToolUse, true
default:
return "", false
}
}

func normalizeToolName(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "terminal", "shell", "bash":
return "Bash"
case "read_file", "read":
return "Read"
case "write_file", "write":
return "Write"
case "patch", "edit", "edit_file":
return "Edit"
default:
return value
}
}

func toolResponseFromExtra(extra map[string]any) map[string]any {
if extra == nil {
return nil
}
if result, ok := extra["result"]; ok {
return map[string]any{"result": result}
}
return nil
}

func blockMessage(result hook.Result) string {
if reason := result.ClaudeReason(); strings.TrimSpace(reason) != "" {
return reason
}
return "Blocked by Kontext access policy."
}

func stringFromExtra(extra map[string]any, keys ...string) string {
if extra == nil {
return ""
}
for _, key := range keys {
value, ok := extra[key]
if !ok {
continue
}
switch typed := value.(type) {
case string:
return typed
case json.Number:
return typed.String()
}
}
return ""
}

func int64FromExtra(extra map[string]any, keys ...string) (int64, bool) {
if extra == nil {
return 0, false
}
for _, key := range keys {
value, ok := extra[key]
if !ok {
continue
}
switch typed := value.(type) {
case float64:
return int64(typed), true
case json.Number:
parsed, err := typed.Int64()
return parsed, err == nil
case int64:
return typed, true
case int:
return int64(typed), true
default:
return 0, false
}
}
return 0, false
}

func boolFromExtra(extra map[string]any, keys ...string) (bool, bool) {
if extra == nil {
return false, false
}
for _, key := range keys {
value, ok := extra[key]
if !ok {
continue
}
typed, ok := value.(bool)
return typed, ok
}
return false, false
}

func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

func firstMap(values ...map[string]any) map[string]any {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}

func isZeroMetadataValue(value any) bool {
switch typed := value.(type) {
case nil:
return true
case string:
return typed == ""
default:
return false
}
}
Loading
Loading