Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Nixis - AI Agent Firewall
# Nixis

[![CI](https://github.com/mayankjain0141/nixis/actions/workflows/ci.yml/badge.svg)](https://github.com/mayankjain0141/nixis/actions/workflows/ci.yml)
[![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white)](https://go.dev)
Expand Down
97 changes: 97 additions & 0 deletions cmd/nixis-hook/adapter_hermes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: MIT
package main

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

"github.com/mayankjain0141/nixis/pkg/nixis"
)

// HermesAdapter handles the hermes-agent shell hook protocol.
// Detection: presence of both "hook_event_name" and "cwd" fields.
// Hermes always receives exit code 0; the decision is conveyed via the JSON body.
type HermesAdapter struct{}

// hermesInput is the JSON shape sent by hermes-agent shell hooks.
type hermesInput struct {
HookEventName string `json:"hook_event_name"`
ToolName string `json:"tool_name"`
ToolInput json.RawMessage `json:"tool_input"`
SessionID string `json:"session_id"`
TaskID string `json:"task_id"`
ToolCallID string `json:"tool_call_id"`
Cwd string `json:"cwd"`
}

// hermesBlockOutput is the body written when nixis blocks a tool call.
type hermesBlockOutput struct {
Decision string `json:"decision"`
Reason string `json:"reason"`
}

func init() {
// Prepend HermesAdapter so it is evaluated before ClaudeCodeAdapter.
// Both formats carry "hook_event_name"; hermes is distinguished by also
// carrying "cwd". First-match-wins requires hermes to come first.
adapters = append([]IDEAdapter{&HermesAdapter{}}, adapters...)
}

func (a *HermesAdapter) Name() string { return "hermes" }

func (a *HermesAdapter) Detect(raw json.RawMessage) bool {
var probe struct {
HookEventName string `json:"hook_event_name"`
Cwd string `json:"cwd"`
}
if err := json.Unmarshal(raw, &probe); err != nil {
return false
}
// Hermes payloads carry both hook_event_name and cwd.
// Claude Code payloads carry hook_event_name but not cwd.
return probe.HookEventName != "" && probe.Cwd != ""
}

func (a *HermesAdapter) ParseInput(raw json.RawMessage) (nixis.CheckRequest, error) {
var inp hermesInput
if err := json.Unmarshal(raw, &inp); err != nil {
return nixis.CheckRequest{}, fmt.Errorf("parse hermes input: %w", err)
}
args := inp.ToolInput
if len(args) == 0 || string(args) == "null" {
args = json.RawMessage("{}")
}
return nixis.CheckRequest{
Tool: inp.ToolName,
Args: args,
SessionID: inp.SessionID,
SpawnToken: os.Getenv("NIXIS_SPAWN_TOKEN"),
ParentSessionID: os.Getenv("NIXIS_PARENT_SESSION_ID"),
ProjectRoot: os.Getenv("NIXIS_PROJECT_ROOT"),
}, nil
}

func (a *HermesAdapter) FormatOutput(resp nixis.CheckResponse, _ json.RawMessage) ([]byte, int) {
switch resp.Decision.Action {

Check failure on line 76 in cmd/nixis-hook/adapter_hermes.go

View workflow job for this annotation

GitHub Actions / Backend (Go)

missing cases in switch of type nixis.Action: nixis.ActionAllow, nixis.ActionRequireApproval, nixis.ActionAudit (exhaustive)
case nixis.ActionDeny:
out := hermesBlockOutput{
Decision: "block",
Reason: resp.Decision.Reason,
}
b, err := json.Marshal(out)
if err != nil {
return []byte(`{"decision":"block","reason":"policy violation"}` + "\n"), 0
}
return append(b, '\n'), 0
default:
// ActionAllow, ActionLog/ActionAudit, ActionRequireApproval — all allow; hermes
// reads a non-empty "decision" field to block. Empty object = allow.
return []byte("{}\n"), 0
}
}

func (a *HermesAdapter) FormatFailOpen(_ string, _ json.RawMessage) ([]byte, int) {
// Fail-open: daemon unreachable → do not block.
return []byte("{}\n"), 0
}
189 changes: 189 additions & 0 deletions cmd/nixis-hook/adapter_hermes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// SPDX-License-Identifier: MIT
package main

import (
"bytes"
"encoding/json"
"testing"

"github.com/mayankjain0141/nixis/pkg/nixis"
)

func TestHermesAdapter_Detect_True(t *testing.T) {
raw := json.RawMessage(`{
"hook_event_name": "pre_tool_call",
"tool_name": "terminal",
"tool_input": {"command": "ls"},
"session_id": "sess-123",
"cwd": "/home/user"
}`)

a := &HermesAdapter{}
if !a.Detect(raw) {
t.Error("Detect() = false, want true for payload with hook_event_name and cwd")
}
}

func TestHermesAdapter_Detect_False_NoCwd(t *testing.T) {
// Payload has hook_event_name but no cwd — this is Claude Code, not Hermes.
raw := json.RawMessage(`{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "ls"},
"session_id": "sess-cc-001"
}`)

a := &HermesAdapter{}
if a.Detect(raw) {
t.Error("Detect() = true, want false for payload with hook_event_name but no cwd")
}
}

func TestHermesAdapter_Detect_False_NoHookEvent(t *testing.T) {
// Payload has cwd but no hook_event_name — generic/unknown, not hermes.
raw := json.RawMessage(`{
"tool_name": "terminal",
"cwd": "/home/user"
}`)

a := &HermesAdapter{}
if a.Detect(raw) {
t.Error("Detect() = true, want false for payload missing hook_event_name")
}
}

func TestHermesAdapter_ParseInput(t *testing.T) {
raw := json.RawMessage(`{
"hook_event_name": "pre_tool_call",
"tool_name": "terminal",
"tool_input": {"command": "rm -rf /"},
"session_id": "sess-123",
"task_id": "task-456",
"tool_call_id": "call-789",
"cwd": "/home/user"
}`)

a := &HermesAdapter{}
req, err := a.ParseInput(raw)
if err != nil {
t.Fatalf("ParseInput() error = %v", err)
}

if req.Tool != "terminal" {
t.Errorf("Tool = %q, want %q", req.Tool, "terminal")
}
if req.SessionID != "sess-123" {
t.Errorf("SessionID = %q, want %q", req.SessionID, "sess-123")
}

// Args must be a JSON object containing "command".
var args map[string]string
if err := json.Unmarshal(req.Args, &args); err != nil {
t.Fatalf("Args is not valid JSON object: %v", err)
}
if args["command"] != "rm -rf /" {
t.Errorf("Args.command = %q, want %q", args["command"], "rm -rf /")
}
}

func TestHermesAdapter_ParseInput_NullToolInput(t *testing.T) {
// When tool_input is absent or null, Args should fall back to "{}".
raw := json.RawMessage(`{
"hook_event_name": "pre_tool_call",
"tool_name": "terminal",
"session_id": "sess-999",
"cwd": "/tmp"
}`)

a := &HermesAdapter{}
req, err := a.ParseInput(raw)
if err != nil {
t.Fatalf("ParseInput() error = %v", err)
}
if string(req.Args) != "{}" {
t.Errorf("Args = %s, want {}", req.Args)
}
}

func TestHermesAdapter_FormatOutput_Allow(t *testing.T) {
resp := nixis.CheckResponse{}
resp.Decision.Action = nixis.ActionAllow

a := &HermesAdapter{}
out, exitCode := a.FormatOutput(resp, nil)

if exitCode != 0 {
t.Errorf("exitCode = %d, want 0", exitCode)
}
// Allow response must be empty JSON object (possibly with trailing newline).
trimmed := bytes.TrimSpace(out)
if string(trimmed) != "{}" {
t.Errorf("FormatOutput allow = %q, want {}", string(trimmed))
}
}

func TestHermesAdapter_FormatOutput_Deny(t *testing.T) {
resp := nixis.CheckResponse{}
resp.Decision.Action = nixis.ActionDeny
resp.Decision.Reason = "Policy violation: destructive command"

a := &HermesAdapter{}
out, exitCode := a.FormatOutput(resp, nil)

if exitCode != 0 {
t.Errorf("exitCode = %d, want 0 (hermes reads JSON, not exit code)", exitCode)
}

var m map[string]string
if err := json.Unmarshal(bytes.TrimSpace(out), &m); err != nil {
t.Fatalf("output is not valid JSON: %v (got: %s)", err, out)
}
if m["decision"] != "block" {
t.Errorf("decision = %q, want %q", m["decision"], "block")
}
if m["reason"] != "Policy violation: destructive command" {
t.Errorf("reason = %q, want %q", m["reason"], "Policy violation: destructive command")
}
}

func TestHermesAdapter_FormatOutput_Audit(t *testing.T) {
// ActionAudit (ActionLog) should not block — returns "{}".
resp := nixis.CheckResponse{}
resp.Decision.Action = nixis.ActionAudit

a := &HermesAdapter{}
out, exitCode := a.FormatOutput(resp, nil)

if exitCode != 0 {
t.Errorf("exitCode = %d, want 0", exitCode)
}
trimmed := bytes.TrimSpace(out)
if string(trimmed) != "{}" {
t.Errorf("FormatOutput audit = %q, want {}", string(trimmed))
}
}

func TestHermesAdapter_FormatFailOpen(t *testing.T) {
a := &HermesAdapter{}
out, exitCode := a.FormatFailOpen("daemon_unreachable", nil)

if exitCode != 0 {
t.Errorf("exitCode = %d, want 0", exitCode)
}
trimmed := bytes.TrimSpace(out)
if string(trimmed) != "{}" {
t.Errorf("FormatFailOpen = %q, want {}", string(trimmed))
}
}

func TestHermesAdapter_InitRegistered(t *testing.T) {
// Verify that the init() function registered HermesAdapter as the first entry
// in the global adapters slice, before ClaudeCodeAdapter.
if len(adapters) == 0 {
t.Fatal("adapters slice is empty")
}
first, ok := adapters[0].(*HermesAdapter)
if !ok || first == nil {
t.Errorf("adapters[0] = %T, want *HermesAdapter", adapters[0])
}
}
34 changes: 34 additions & 0 deletions cmd/nixis/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,26 @@ func runInstall(cmd *cobra.Command, homeDir, nixisDir string) error {
return fmt.Errorf("patch settings.json: %w", err)
}

// Step 6b: Configure hermes integration (optional)
fmt.Fprintln(w)
fmt.Fprintln(w, "[6b] Configuring hermes-agent integration (optional)...")
if detectHermes(homeDir) != "" {
if err := patchHermesConfig(w, homeDir, hookPath); err != nil {
fmt.Fprintf(w, " Warning: could not configure hermes: %v\n", err)
}
} else {
fmt.Fprintln(w, " hermes-agent not detected, skipping")
}

// Step 6c: Configure opencode integration (optional)
fmt.Fprintln(w)
fmt.Fprintln(w, "[6c] Configuring opencode integration (optional)...")
if err := patchOpenCodeConfig(w, homeDir, nixisDir); err != nil {
fmt.Fprintf(w, " Warning: could not configure opencode: %v\n", err)
} else {
fmt.Fprintln(w, " opencode instructions configured")
}

// Step 7: Smoke test
fmt.Fprintln(w)
fmt.Fprintln(w, "[7/8] Running smoke test...")
Expand Down Expand Up @@ -252,6 +272,20 @@ func runUninstall(cmd *cobra.Command, homeDir, nixisDir string) error {
fmt.Fprintf(w, " Warning: %v\n", err)
}

// Step 2b: Remove hermes integration
fmt.Fprintln(w)
fmt.Fprintln(w, "[2b] Removing hermes-agent integration...")
if err := unpatchHermesConfig(w, homeDir); err != nil {
fmt.Fprintf(w, " Warning: %v\n", err)
}

// Step 2c: Remove opencode integration
fmt.Fprintln(w)
fmt.Fprintln(w, "[2c] Removing opencode integration...")
if err := unpatchOpenCodeConfig(w, homeDir); err != nil {
fmt.Fprintf(w, " Warning: %v\n", err)
}

// Step 3: Remove ~/.nixis directory
fmt.Fprintln(w)
fmt.Fprintln(w, "[3/4] Removing", nixisDir)
Expand Down
Loading
Loading