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
23 changes: 19 additions & 4 deletions cli/internal/agents/opencode.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,24 @@ const (
const pluginContent = `// Cordon enforcement plugin for OpenCode — do not edit.
// Managed by cordon (https://cordon.sh).
export const CordonEnforcement = async ({ $, directory }) => {
const extractToolInput = (input, output) => {
// OpenCode tool hook payloads can surface args under different keys.
const candidate =
output?.args ??
output?.arguments ??
input?.args ??
input?.arguments ??
input?.input ??
{};
return candidate && typeof candidate === "object" ? candidate : {};
};

return {
"tool.execute.before": async (input, output) => {
const toolInput = extractToolInput(input, output);
const payload = JSON.stringify({
tool_name: input.tool,
tool_input: output.args || {},
tool_input: toolInput,
cwd: directory,
});
try {
Expand All @@ -45,10 +58,12 @@ export const CordonEnforcement = async ({ $, directory }) => {
const parsed = JSON.parse(stdout);
if (parsed.reason) reason = parsed.reason;
} catch {}
throw new Error(reason);
const denyError = new Error(reason);
denyError.name = "CordonPolicyError";
throw denyError;
}
} catch (e) {
if (e.message && e.message.includes("Cordon")) throw e;
if (e?.name === "CordonPolicyError") throw e;
// Fail-open on infrastructure errors
}
},
Expand All @@ -58,7 +73,7 @@ export const CordonEnforcement = async ({ $, directory }) => {

// OpenCode configures the OpenCode agent via a JS plugin at
// .opencode/plugins/cordon-interface.js. The plugin hooks tool.execute.before
// to enforce Cordon file rules.
// to enforce Cordon file and command rules.
type OpenCode struct{}

func (o *OpenCode) ID() string { return "opencode" }
Expand Down
22 changes: 22 additions & 0 deletions cli/internal/agents/opencode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package agents

import (
"encoding/json"
"strings"
"testing"
)

Expand Down Expand Up @@ -48,3 +49,24 @@ func TestStripJSONCHandlesBlockComments(t *testing.T) {
t.Fatalf("expected valid JSON after stripping JSONC with block comments, got error: %v\ncleaned:\n%s", err, string(clean))
}
}

func TestPluginContentSupportsMultipleArgShapes(t *testing.T) {
if !strings.Contains(pluginContent, "output?.args") {
t.Fatal("plugin should read output?.args")
}
if !strings.Contains(pluginContent, "input?.args") {
t.Fatal("plugin should fall back to input?.args")
}
if !strings.Contains(pluginContent, "input?.arguments") {
t.Fatal("plugin should fall back to input?.arguments")
}
if !strings.Contains(pluginContent, "input?.input") {
t.Fatal("plugin should fall back to input?.input")
}
}

func TestPluginContentPropagatesPolicyDenials(t *testing.T) {
if !strings.Contains(pluginContent, "CordonPolicyError") {
t.Fatal("plugin should throw and propagate CordonPolicyError on deny")
}
}
6 changes: 5 additions & 1 deletion cli/internal/hook/bash.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

type bashToolInput struct {
Command string `json:"command"`
Cmd string `json:"cmd"`
}

var (
Expand Down Expand Up @@ -207,5 +208,8 @@ func parseBashToolInput(raw json.RawMessage) string {
if err := json.Unmarshal([]byte(raw), &inp); err != nil {
return ""
}
return inp.Command
if inp.Command != "" {
return inp.Command
}
return inp.Cmd
}
20 changes: 14 additions & 6 deletions cli/internal/hook/hook.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Package hook implements PreToolUse hook evaluation for cordon.
// It parses the JSON payload sent by Claude Code and VS Code agents and
// writes an allow or deny decision.
// It parses the JSON payload sent by supported agents and writes an allow or
// deny decision.
package hook

import (
Expand Down Expand Up @@ -248,9 +248,10 @@ func Evaluate(r io.Reader, w io.Writer, errW io.Writer, checker PolicyChecker, r
payload.SessionID = payload.ConversationID
}

agent := payload.inferAgent(agentOverride)

// Shell command tools: check command rules and shell read/write targets.
if isShellCommandTool(payload.ToolName) {
agent := payload.inferAgent(agentOverride)
if isShellCommandInvocation(payload.ToolName, payload.ToolInput, agent) {
event, err := evaluateBash(payload, w, errW, checker, rdChecker, cmdChecker, agent)
if event != nil {
payload.setSession(event, agentOverride)
Expand All @@ -275,8 +276,6 @@ func Evaluate(r io.Reader, w io.Writer, errW io.Writer, checker PolicyChecker, r
}
filePath := inp.effectivePath()

agent := payload.inferAgent(agentOverride)

// Reading tools: check against prevent-read file rules.
if readingTools[payload.ToolName] {
allowed, readPassID, notify := checkRead(rdChecker, filePath, payload.Cwd)
Expand Down Expand Up @@ -558,6 +557,15 @@ func isShellCommandTool(toolName string) bool {
}
}

func isShellCommandInvocation(toolName string, toolInput json.RawMessage, agent string) bool {
if isShellCommandTool(toolName) {
return true
}
// OpenCode tool names are not stable across versions; if a command-shaped
// payload is present, treat it as a shell invocation for command-rule checks.
return agent == "opencode" && strings.TrimSpace(parseBashToolInput(toolInput)) != ""
}

// evaluateApplyPatch handles VS Code Copilot's apply_patch tool.
// The patch body is in the "input" field and contains one or more file paths
// as "*** Update File: <path>" or "*** Add File: <path>" directives.
Expand Down
56 changes: 56 additions & 0 deletions cli/internal/hook/hook_shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,59 @@ func TestEvaluate_RunInTerminalRecordsCommandPass(t *testing.T) {
t.Fatal("event.Notify = false, want true")
}
}

func TestEvaluate_BashAcceptsCmdFieldAlias(t *testing.T) {
payload := `{
"tool_name": "bash",
"tool_input": {"cmd":"git status"},
"cwd": "/repo"
}`

cmdChecker := func(command, cwd string) (bool, string, *MatchedRule, bool) {
if strings.TrimSpace(command) == "git status" {
return false, "", &MatchedRule{Pattern: "git status", RuleType: "deny", RuleAuthority: "standard"}, false
}
return true, "", nil, false
}

var out bytes.Buffer
var errOut bytes.Buffer
event, err := Evaluate(strings.NewReader(payload), &out, &errOut, nil, nil, cmdChecker, "")
if err != ErrDenied {
t.Fatalf("Evaluate error = %v, want ErrDenied", err)
}
if event == nil {
t.Fatal("event = nil, want non-nil deny event")
}
if event.DeniedOpReason != "prevent-command rule violation" {
t.Fatalf("event.DeniedOpReason = %q, want prevent-command rule violation", event.DeniedOpReason)
}
}

func TestEvaluate_OpenCodeCommandToolNameStillChecksCommandRules(t *testing.T) {
payload := `{
"tool_name": "terminal.exec",
"tool_input": {"command":"git status"},
"cwd": "/repo"
}`

cmdChecker := func(command, cwd string) (bool, string, *MatchedRule, bool) {
if strings.TrimSpace(command) == "git status" {
return false, "", &MatchedRule{Pattern: "git status", RuleType: "deny", RuleAuthority: "standard"}, false
}
return true, "", nil, false
}

var out bytes.Buffer
var errOut bytes.Buffer
event, err := Evaluate(strings.NewReader(payload), &out, &errOut, nil, nil, cmdChecker, "opencode")
if err != ErrDenied {
t.Fatalf("Evaluate error = %v, want ErrDenied", err)
}
if event == nil {
t.Fatal("event = nil, want non-nil deny event")
}
if event.DeniedOpReason != "prevent-command rule violation" {
t.Fatalf("event.DeniedOpReason = %q, want prevent-command rule violation", event.DeniedOpReason)
}
}
Loading