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
1 change: 1 addition & 0 deletions cmd/kontext/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
_ "github.com/kontext-security/kontext-cli/internal/agent/claude"
_ "github.com/kontext-security/kontext-cli/internal/agent/codex"
_ "github.com/kontext-security/kontext-cli/internal/agent/cowork"
_ "github.com/kontext-security/kontext-cli/internal/agent/devin"
)

var version = "dev"
Expand Down
24 changes: 24 additions & 0 deletions internal/agent/devin/devin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Package devin registers the Devin agent adapter.
package devin

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

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

type Devin struct{}

func (d *Devin) Name() string { return "devin" }

func (d *Devin) DecodeHookInput(input []byte) (hook.Event, error) {
return hookruntime.DecodeDevinEvent(input, d.Name())
}

func (d *Devin) EncodeHookResult(event hook.Event, result hook.Result) ([]byte, error) {
return hookruntime.EncodeDevinResult(event.HookName.String(), result)
}
59 changes: 59 additions & 0 deletions internal/agent/devin/devin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package devin

import (
"errors"
"testing"

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

func TestDevinRegisteredUnderItsName(t *testing.T) {
t.Parallel()

registered, ok := agent.Get("devin")
if !ok {
t.Fatal("agent.Get(\"devin\") not found, want the adapter registered")
}
if registered.Name() != "devin" {
t.Fatalf("Name() = %q, want devin", registered.Name())
}
}

func TestDecodeHookInputStampsAgentName(t *testing.T) {
t.Parallel()

event, err := (&Devin{}).DecodeHookInput([]byte(
`{"hook_event_name":"PreToolUse","tool_name":"exec","tool_input":{"command":"ls"}}`,
))
if err != nil {
t.Fatalf("DecodeHookInput() error = %v", err)
}
if event.Agent != "devin" {
t.Fatalf("Agent = %q, want devin", event.Agent)
}
}

func TestDecodeHookInputReportsSkipForEventsWithoutDecisions(t *testing.T) {
t.Parallel()

_, err := (&Devin{}).DecodeHookInput([]byte(`{"hook_event_name":"PostCompaction"}`))
if !errors.Is(err, hook.ErrSkipEvent) {
t.Fatalf("DecodeHookInput() error = %v, want ErrSkipEvent", err)
}
}

func TestEncodeHookResultUsesTheEventOnTheEvent(t *testing.T) {
t.Parallel()

out, err := (&Devin{}).EncodeHookResult(
hook.Event{HookName: hook.HookPreToolUse},
hook.Result{Decision: hook.DecisionDeny, Reason: "denied by policy"},
)
if err != nil {
t.Fatalf("EncodeHookResult() error = %v", err)
}
if want := `{"decision":"block","reason":"denied by policy"}`; string(out) != want {
t.Fatalf("EncodeHookResult() = %s, want %s", out, want)
}
}
7 changes: 7 additions & 0 deletions internal/guard/hookruntime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package hookruntime

import (
"context"
"errors"
"fmt"
"io"
"strings"
Expand Down Expand Up @@ -34,6 +35,12 @@ type Processor interface {
func Run(ctx context.Context, adapter Adapter, processor Processor, mode Mode, stdin io.Reader, stdout, stderr io.Writer) error {
event, err := adapter.Decode(stdin)
if err != nil {
// An event the adapter recognizes but does not translate carries no
// decision: emit nothing and succeed, rather than encoding a deny for
// what is only a lifecycle signal.
if errors.Is(err, hook.ErrSkipEvent) {
return nil
}
fmt.Fprintf(stderr, "kontext: malformed hook input: %v\n", err)
malformed := hook.Event{HookName: adapter.MalformedHookName()}
return adapter.Encode(stdout, malformed, effectiveResult(malformed, hook.Result{
Expand Down
39 changes: 39 additions & 0 deletions internal/guard/hookruntime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"errors"
"fmt"
"io"
"testing"

Expand Down Expand Up @@ -183,3 +184,41 @@ func TestEffectiveResultRemoteDowngradesUnstampedToObserve(t *testing.T) {
t.Fatalf("reason = %q, want observe would-note", result.Reason)
}
}

// An event the adapter recognizes but does not translate carries no decision.
// It must not be encoded as a deny the way malformed input is, and it must not
// reach the processor.
func TestRunSkippedEventEncodesNothing(t *testing.T) {
t.Parallel()

adapter := &stubAdapter{
decodeErr: fmt.Errorf("devin: event PostCompaction: %w", hook.ErrSkipEvent),
}
stdout := &bytes.Buffer{}

err := Run(context.Background(), adapter, stubProcessor{err: errors.New("must not be called")}, ModeEnforce, bytes.NewReader(nil), stdout, io.Discard)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if adapter.result.Decision != "" {
t.Fatalf("encoded decision = %q, want nothing encoded", adapter.result.Decision)
}
if stdout.String() != "" {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
}

// Malformed input keeps its existing behavior: a policy-controlled result is
// still encoded, so this must not be swept into the skip path.
func TestRunMalformedInputStillEncodesDenyInEnforce(t *testing.T) {
t.Parallel()

adapter := &stubAdapter{decodeErr: errors.New("malformed")}
err := Run(context.Background(), adapter, stubProcessor{}, ModeEnforce, bytes.NewReader(nil), io.Discard, io.Discard)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if adapter.result.Decision != hook.DecisionDeny {
t.Fatalf("decision = %q, want deny", adapter.result.Decision)
}
}
6 changes: 6 additions & 0 deletions internal/guard/store/sqlite/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -1755,12 +1755,18 @@ func scanSession(scanner interface{ Scan(...any) error }) (SessionRecord, error)
return record, nil
}

// hostedAgentIdentity maps a local agent name to the (provider, agent) pair the
// hosted ledger records. An unmapped agent still exports its own name, but with
// an empty provider, which reads downstream as "provider unknown" rather than as
// the vendor it came from.
func hostedAgentIdentity(agent string) (string, string) {
switch strings.ToLower(strings.TrimSpace(agent)) {
case "claude", "claude-code", "claude_code":
return "anthropic", "claude_code"
case "cowork", "claude-cowork", "claude_cowork":
return "anthropic", "claude_cowork"
case "devin":
return "cognition", "devin"
default:
return "", strings.TrimSpace(agent)
}
Expand Down
25 changes: 25 additions & 0 deletions internal/guard/store/sqlite/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1862,3 +1862,28 @@ func assertRecordIDs(t *testing.T, records []LedgerRecord, want map[string]bool)
}
}
}

// The hosted ledger records a (provider, agent) pair per session. An unmapped
// agent exports an empty provider, which reads downstream as "provider unknown"
// rather than as the vendor it came from.
func TestHostedAgentIdentityMapsKnownAgents(t *testing.T) {
t.Parallel()

for name, want := range map[string]struct{ provider, agent string }{
"claude": {"anthropic", "claude_code"},
"claude-code": {"anthropic", "claude_code"},
"cowork": {"anthropic", "claude_cowork"},
"devin": {"cognition", "devin"},
" DeVin ": {"cognition", "devin"},
"someoneelse": {"", "someoneelse"},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()

provider, agent := hostedAgentIdentity(name)
if provider != want.provider || agent != want.agent {
t.Fatalf("hostedAgentIdentity(%q) = (%q, %q), want (%q, %q)", name, provider, agent, want.provider, want.agent)
}
})
}
}
16 changes: 15 additions & 1 deletion internal/hook/domain.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
package hook

import "strings"
import (
"errors"
"strings"
)

// ErrSkipEvent reports a hook event an adapter recognizes but deliberately does
// not translate — a lifecycle signal that carries no authorization decision and
// must not be recorded as one.
//
// Adapters wrap it; hook runners test for it with errors.Is and exit
// successfully without writing a decision. It exists because some agent
// runtimes read a non-zero hook exit as "block the tool call", so reporting an
// unhandled event as a decode failure would stall the agent on an event we
// simply chose not to handle.
var ErrSkipEvent = errors.New("hook event skipped by adapter")

type HookName string

Expand Down
157 changes: 157 additions & 0 deletions internal/hookruntime/devin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package hookruntime

import (
"bytes"
"encoding/json"
"fmt"

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

// devinDecisionBlock is the decision verb the Devin runtime understands on a
// pre-tool-use hook's stdout. Devin surfaces the accompanying reason to the
// agent verbatim, so reasons double as policy messaging.
const devinDecisionBlock = "block"

// devinHookInput mirrors the payload the Devin runtime writes to a plugin
// hook's stdin. Devin emits snake_case only, so unlike the Claude Code adapter
// there are no camelCase aliases to tolerate.
//
// Fields Devin sends that we deliberately do not decode:
//
// - prompt_id: a per-turn correlator with no home on hook.Event yet. It is
// deliberately NOT folded into ToolInput, because ToolInput becomes the
// Cedar evaluation context; injecting a synthetic attribute there would
// make it policy-visible and would change the parameters hash recorded on
// every decision.
// - summary: only present on the compaction event, which is skipped outright.
// It carries verbatim conversation content and must not reach the ledger
// without going through payload capture.
type devinHookInput struct {
HookEventName string `json:"hook_event_name"`
SessionID string `json:"session_id"`
ToolName string `json:"tool_name"`
ToolInput json.RawMessage `json:"tool_input"`
ToolResponse json.RawMessage `json:"tool_response"`
ToolUseID string `json:"tool_use_id"`
}

// devinHookOutput is the decision object Devin parses from hook stdout. An
// empty object is the "no directive, continue" form.
type devinHookOutput struct {
Decision string `json:"decision,omitempty"`
Reason string `json:"reason,omitempty"`
}

// DecodeDevinEvent translates a Devin hook payload into a hook.Event.
//
// Events Devin emits that carry no authorization decision — and any event name
// this adapter does not recognize — are reported as hook.ErrSkipEvent rather
// than as a decode failure. That distinction matters: the Devin runtime treats
// a non-zero hook exit as "block the tool call", so failing on an event we
// simply do not handle would stall the agent on a legitimate lifecycle signal.
//
// A payload that is malformed or missing its event name is still an error. That
// indicates a bug or a contract change rather than an event we chose not to
// handle, and failing closed on it is the safer default.
func DecodeDevinEvent(input []byte, agentName string) (hook.Event, error) {
var h devinHookInput
if err := decodeUseNumber(input, &h); err != nil {
return hook.Event{}, fmt.Errorf("devin: decode hook input: %w", err)
}
if h.HookEventName == "" {
return hook.Event{}, fmt.Errorf("devin: hook event name missing")
}

hookName := hook.HookName(h.HookEventName)
if !devinSupportedHook(hookName) {
return hook.Event{}, fmt.Errorf("devin: event %s: %w", h.HookEventName, hook.ErrSkipEvent)
}

toolInput, err := normalizeDevinToolInput(h.ToolInput)
if err != nil {
return hook.Event{}, err
}
return hook.Event{
// Devin session IDs are already namespaced (devin-<uuid>), so unlike
// the Codex adapter there is no prefix to add and no synthetic ID to
// mint — session_id and tool_use_id map straight through.
SessionID: h.SessionID,
Agent: agentName,
HookName: hookName,
ToolName: h.ToolName,
ToolInput: toolInput,
ToolResponse: normalizeToolResponse(h.ToolResponse),
ToolUseID: h.ToolUseID,
}, nil
}

// EncodeDevinResult renders a decision in the form Devin parses from stdout.
//
// Only pre-tool-use carries a decision; every other event is observation and
// must emit no directive. Anything other than an explicit allow blocks, so an
// unrecognized decision value fails closed rather than letting a tool call
// through.
func EncodeDevinResult(hookEventName string, result hook.Result) ([]byte, error) {
if hook.HookName(hookEventName) != hook.HookPreToolUse {
return json.Marshal(devinHookOutput{})
}

// Devin applies an updated tool input by REPLACING the original wholesale
// rather than merging it. Emitting a partial rewrite would silently drop
// every field the policy did not restate — turning a redaction into an
// arbitrary change to the agent's command — and the post-tool-use hook
// would then observe only the rewritten input, so the original would never
// reach the ledger. Until input rewriting is modelled end to end, refuse
// visibly instead of rewriting wrongly.
if result.UpdatedInput != nil {
return json.Marshal(devinHookOutput{
Decision: devinDecisionBlock,
Reason: "Kontext: policy requested an input rewrite, which this adapter does not support; refusing instead of altering the request.",
})
}

if result.Decision != hook.DecisionAllow {
return json.Marshal(devinHookOutput{
Decision: devinDecisionBlock,
Reason: result.ClaudeReason(),
})
}
return json.Marshal(devinHookOutput{})
}

// devinSupportedHook reports the Devin events this adapter translates. Devin
// also emits compaction and permission-request events; both are skipped, as is
// any event name added in future, so an unhandled event never blocks a tool
// call.
func devinSupportedHook(hookName hook.HookName) bool {
switch hookName {
case hook.HookSessionStart,
hook.HookPreToolUse,
hook.HookPostToolUse,
hook.HookSessionEnd:
return true
default:
return false
}
}

// normalizeDevinToolInput decodes tool_input into the map the rest of the
// pipeline expects. An absent or null input yields nil. A non-object input is
// not expected from Devin; it is wrapped rather than rejected so an unforeseen
// shape degrades to a recordable event instead of blocking the agent.
func normalizeDevinToolInput(raw json.RawMessage) (map[string]any, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || string(trimmed) == "null" {
return nil, nil
}
var obj map[string]any
if err := decodeUseNumber(trimmed, &obj); err == nil {
return obj, nil
}
var value any
if err := decodeUseNumber(trimmed, &value); err != nil {
return nil, fmt.Errorf("devin: decode tool input: %w", err)
}
return map[string]any{"value": value}, nil
}
Loading
Loading