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
103 changes: 103 additions & 0 deletions daemon/bypass.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package daemon

import (
"path/filepath"
"slices"
"strings"
"time"

"github.com/enowx/enowx/core/agent"
"github.com/enowx/enowx/core/diag"
"github.com/enowx/enowx/core/goa"
"github.com/enowx/enowx/proto"
)

// A session in bypass doesn't stop to ask about routine work, whatever asks: another
// hook, a plugin's, or the agent's own check. What still prompts is answered here.
// What goes on reaching the user: a hook's deny, which never gets this far; what GOA's
// urgent rules hold for the owner (can't be undone, spends money, publishes, touches
// secrets); and what the judge, sure of itself, doesn't allow. A session not in bypass
// asks as it always did. Every automatic answer is in the decide log, use "bypass".

// bypassAnswer says a prompt of session info for event ev is answered allow. An agent
// that reports its mode goes by that, or by how it was started.
func (s *Server) bypassAnswer(info proto.SessionInfo, ev proto.HookEvent) bool {
if info.GOA || !userSession(info) || !workerRunsUnasked(info, ev) {
return false
}
return s.bypassPolicy(info, ev.Tool, ev.Command, ev.Paths, ev.Scratchpad, "its permission prompt, from another hook's ask or its own check: it doesn't say which")
}

// bypassPolicy decides and logs a prompt of a session in bypass: tool's command and
// paths, asked by asker; scratchpad is the agent's own, if it says.
func (s *Server) bypassPolicy(info proto.SessionInfo, tool, command string, paths []string, scratchpad, asker string) bool {
asked := strings.TrimSpace(diag.Scrub(s.maskLent(tool + " " + command + " " + strings.Join(paths, " "))))
record := func(allowed bool, why string) bool {
verdict, acted := proto.VerdictAllow, proto.ActedAllowed
if !allowed {
verdict, acted = proto.VerdictAsk, proto.ActedLeft
}
d := proto.Decision{ID: "b" + newRenderID(), Use: proto.DecideBypass, Session: info.ID, Project: info.Project, Agent: agent.Base(info.Agent),
Time: time.Now(), Verdict: verdict, Why: why, Input: clip(asked, 2000) + " (asked by " + asker + ")", Acted: acted}
s.judge.Record(d)
s.broadcast(proto.EventDecision, d)
return allowed
}
if urgent := goa.Urgent(tool, command, paths); urgent != "" && !removesOnlyInside(command, info.Dir, scratchpad) {
return record(false, "for the owner: "+urgent)
}
if s.useOn(proto.DecideApprove) && command != "" {
if d, err := s.decideFor(info, proto.DecideAsk{Use: proto.DecideApprove, Command: command}); err == nil && d.Judge != "" && s.sure(d) && d.Verdict != proto.VerdictAllow {
return record(false, "for the owner: the judge says "+d.Verdict+": "+d.Why)
}
}
return record(true, "routine in bypass: not urgent, and the judge doesn't object")
}

// sessionInBypass says session info runs in bypass, as far as its agent has said.
func (s *Server) sessionInBypass(info proto.SessionInfo) bool {
if e, ok := s.agentModes.get(info.ID); ok && e.mode != "" {
return e.mode == agentModeBypass
}
return info.SkipPermissions
}

// shellSpecial is what makes a command more than its words: expansions, globs, chains.
const shellSpecial = "$`*?[]{}()<>|&;~\\\"'\n"

// removesOnlyInside says command is a plain rm whose every path is inside the folders
// given: the session's own project or scratchpad, which is routine, unlike what can't be
// undone outside them. Anything it can't read for sure doesn't count.
func removesOnlyInside(command string, folders ...string) bool {
if strings.ContainsAny(command, shellSpecial) {
return false
}
words := strings.Fields(command)
if len(words) < 2 || words[0] != "rm" {
return false
}
found := false
for _, w := range words[1:] {
if strings.HasPrefix(w, "-") {
continue
}
found = true
if !slices.ContainsFunc(folders, func(dir string) bool { return pathInside(w, dir) }) {
return false
}
}
return found
}

// pathInside says path p, relative to dir if it isn't absolute, is under dir, not dir.
func pathInside(p, dir string) bool {
if dir == "" {
return false
}
dir = filepath.Clean(dir)
if !filepath.IsAbs(p) {
p = filepath.Join(dir, p)
}
rel, err := filepath.Rel(dir, filepath.Clean(p))
return err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
162 changes: 162 additions & 0 deletions daemon/bypass_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package daemon

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

"github.com/enowx/enowx/core/session"
"github.com/enowx/enowx/proto"
)

const (
bypassMode = `"permission_mode":"bypassPermissions"`
askMode = `"permission_mode":"default"`
)

// permissionHook is what the agent's PermissionRequest hook answers for a Bash command.
func permissionHook(mode, command string) string {
in, _ := json.Marshal(map[string]any{"tool_name": "Bash", "tool_input": map[string]string{"command": command}})
text := "{" + mode + "," + strings.TrimPrefix(string(in), "{")
_, out, _ := hookOf(text, "--event", proto.HookPermission)
return out
}

func allowed(out string) bool { return strings.Contains(out, `"behavior":"allow"`) }

func bypassLog(srv *Server) []proto.Decision {
return srv.judge.Log(proto.DecideLogParams{Use: proto.DecideBypass})
}

func bypassSession(t *testing.T) (*Server, *session.Session) {
t.Helper()
srv, sess := hookServer(t)
t.Setenv(session.EnvSession, sess.ID())
return srv, sess
}

// What a plugin's hook asks, and deleting a file in the session's scratchpad, is
// answered in bypass, logged with what was asked and why it passed.
func TestBypassAnswersRoutinePrompts(t *testing.T) {
srv, sess := bypassSession(t)
for _, command := range []string{"touch marker", "rm /tmp/claude-1002/project/session/scratchpad/out.txt"} {
if out := permissionHook(bypassMode, command); !allowed(out) {
t.Fatalf("%q: %q", command, out)
}
}
log := bypassLog(srv)
if len(log) != 2 || log[0].Verdict != proto.VerdictAllow || log[0].Acted != proto.ActedAllowed || log[0].Session != sess.ID() {
t.Fatalf("%+v", log)
}
if !strings.Contains(log[0].Input, "scratchpad/out.txt") || !strings.Contains(log[0].Input, "asked by") || !strings.Contains(log[0].Why, "routine") {
t.Fatalf("%+v", log[0])
}
if st := sess.Info().State; st == proto.StateWaiting {
t.Fatal("it counted as waiting on the user")
}
}

// Removing a file of its scratchpad or project is routine, even with -f; the same
// outside them, or anything not plainly that, stays the owner's.
func TestBypassRemovesOnlyInside(t *testing.T) {
const dir, pad = "/work/app", "/tmp/claude-1002/x/scratchpad"
for command, want := range map[string]bool{
"rm -f /tmp/claude-1002/x/scratchpad/out.txt": true,
"rm -rf build": true,
"rm -f /work/app/a /work/app/b": true,
"rm -f /etc/passwd": false,
"rm -rf /work/app": false,
"rm -rf ../other": false,
"rm -f $HOME/x": false,
"rm -f build/*": false,
"rm -f a && curl x": false,
"rm -f": false,
"git clean -fd": false,
} {
if got := removesOnlyInside(command, dir, pad); got != want {
t.Errorf("%q: %v", command, got)
}
}
}

// With the agent's scratchpad reported, rm -f there goes through; elsewhere it doesn't.
func TestBypassScratchpadFromTheHook(t *testing.T) {
bypassSession(t)
in := func(command string) string {
_, out, _ := hookOf(`{`+bypassMode+`,"scratchpad_dir":"/tmp/pad","tool_name":"Bash","tool_input":{"command":"`+command+`"}}`, "--event", proto.HookPermission)
return out
}
if out := in("rm -f /tmp/pad/x"); !allowed(out) {
t.Fatalf("%q", out)
}
if out := in("rm -f /etc/hosts"); allowed(out) {
t.Fatalf("%q", out)
}
}

// A session not in bypass asks as before, and nothing is logged as answered.
func TestNotInBypassStillAsks(t *testing.T) {
srv, _ := bypassSession(t)
if out := permissionHook(askMode, "touch marker"); allowed(out) {
t.Fatalf("answered: %q", out)
}
if log := bypassLog(srv); len(log) != 0 {
t.Fatalf("%+v", log)
}
}

// What is the owner's stays the owner's, and the log says so.
func TestBypassLeavesTheOwnersToTheOwner(t *testing.T) {
srv, _ := bypassSession(t)
for _, command := range []string{"git push --force origin main", "npm publish"} {
if out := permissionHook(bypassMode, command); allowed(out) {
t.Fatalf("%q answered: %q", command, out)
}
}
log := bypassLog(srv)
if len(log) != 2 || log[0].Verdict != proto.VerdictAsk || !strings.Contains(log[0].Why, "for the owner") {
t.Fatalf("%+v", log)
}
}

// A hook's refusal stays a refusal, in bypass too: the answer isn't an allow.
func TestHookDenyStaysADeny(t *testing.T) {
var out, errOut strings.Builder
code := answerHook(proto.HookToolBefore, "claude", []byte(`{"tool_input":{"command":"x"},`+bypassMode+`}`),
proto.HookDecision{Block: true, Reason: "a plugin refuses", Approve: true}, &out, &errOut)
if code == 0 || strings.Contains(out.String(), "allow") || !strings.Contains(errOut.String(), "refuses") {
t.Fatalf("%d, %q, %q", code, out.String(), errOut.String())
}
}

// A trimmed command causes no prompt in bypass, by the mode reported, also for a session
// switched to it after it started; one that asks still asks about it.
func TestRewrittenCommandFollowsTheMode(t *testing.T) {
srv := startServer(t)
started := proto.SessionInfo{Agent: "claude"}
switched := proto.HookEvent{PermissionMode: agentModeBypass}
if d := srv.rewritten(started, switched, proto.HookDecision{Command: "enowx decide trim -- go test"}); !d.Approve {
t.Fatal("switched to bypass, and it would ask")
}
asks := proto.HookEvent{PermissionMode: "default"}
if d := srv.rewritten(proto.SessionInfo{Agent: "claude", SkipPermissions: true}, asks, proto.HookDecision{Command: "x"}); d.Approve {
t.Fatal("switched out of bypass, and it wouldn't ask")
}
if d := srv.rewritten(proto.SessionInfo{Agent: "claude", SkipPermissions: true}, proto.HookEvent{}, proto.HookDecision{Command: "x"}); !d.Approve {
t.Fatal("no mode reported: as it started")
}
var out strings.Builder
code := answerHook(proto.HookToolBefore, "claude", []byte(`{"tool_input":{"command":"go test"}}`), proto.HookDecision{Command: "wrapped", Approve: true}, &out, &out)
if code != 0 || !strings.Contains(out.String(), `"permissionDecision":"allow"`) || !strings.Contains(out.String(), "wrapped") {
t.Fatalf("%d, %q", code, out.String())
}
}

// GOA's own session isn't answered: its hooks are what hold it to delegating.
func TestBypassLeavesGOAAlone(t *testing.T) {
srv := startServer(t)
ev := proto.HookEvent{Event: proto.HookPermission, Tool: "Bash", Command: "touch x", PermissionMode: agentModeBypass}
if srv.bypassAnswer(proto.SessionInfo{ID: "g", GOA: true, Agent: "claude"}, ev) {
t.Fatal("answered for GOA")
}
}
18 changes: 17 additions & 1 deletion daemon/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ func answerHook(event, agentID string, data []byte, d proto.HookDecision, stdout
return 0
}
}
if d.Approve && event == proto.HookPermission {
fmt.Fprintln(stdout, permissionAllowOutput())
return 0
}
if d.Approve && event == proto.HookToolBefore {
if agentID != "codex" {
fmt.Fprintln(stdout, approveOutput())
Expand Down Expand Up @@ -133,9 +137,10 @@ func agentEvent(event string, data []byte) proto.HookEvent {
Error string `json:"error"`
Cwd string `json:"cwd"`
Mode string `json:"permission_mode"`
Scratch string `json:"scratchpad_dir"`
}
_ = json.Unmarshal(data, &in)
ev := proto.HookEvent{Event: event, Prompt: in.Prompt, Tool: in.Tool, PermissionMode: in.Mode, Failed: in.Event == "PostToolUseFailure" || in.Error != ""}
ev := proto.HookEvent{Event: event, Prompt: in.Prompt, Tool: in.Tool, PermissionMode: in.Mode, Scratchpad: in.Scratch, Failed: in.Event == "PostToolUseFailure" || in.Error != ""}
var input map[string]any
if json.Unmarshal(in.Input, &input) == nil {
for _, k := range []string{"command", "cmd"} {
Expand Down Expand Up @@ -258,6 +263,17 @@ func rewriteOutput(data []byte, d proto.HookDecision) (string, bool) {
return string(b), err == nil
}

// permissionAllowOutput answers a permission prompt yes, without showing the user.
// Claude Code does so for another hook's ask too (run on 2.1.318); a hook's deny never
// asks.
func permissionAllowOutput() string {
b, _ := json.Marshal(map[string]any{"hookSpecificOutput": map[string]any{
"hookEventName": "PermissionRequest",
"decision": map[string]any{"behavior": "allow"},
}})
return string(b)
}

// approveOutput tells an agent to run its tool without asking the user:
// the judge allowed it.
func approveOutput() string {
Expand Down
13 changes: 7 additions & 6 deletions daemon/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,19 +172,20 @@ func (s *Server) fireHooks(info proto.SessionInfo, ev proto.HookEvent) proto.Hoo
}
d.Context = strings.Join(contexts, "\n\n")
if d.Command != "" {
d = s.rewritten(info, d)
d = s.rewritten(info, ev, d)
}
d.Approve = d.Approve || approved && !d.Block
return d
}

// rewritten is decision d, its command changed, as session info's agent
// can take it: Claude Code asks the user about the new command unless the
// session skips permissions; Codex takes one only as approved, so a
// session that asks gets it as a refusal naming the command to run.
func (s *Server) rewritten(info proto.SessionInfo, d proto.HookDecision) proto.HookDecision {
d.Approve = info.SkipPermissions
if agent.Base(info.Agent) == "codex" && !info.SkipPermissions {
// session is in bypass, by the mode it reports with the call, as one may be switched
// to it after it started; Codex takes one only as approved, so a session that asks
// gets it as a refusal naming the command to run.
func (s *Server) rewritten(info proto.SessionInfo, ev proto.HookEvent, d proto.HookDecision) proto.HookDecision {
d.Approve = workerRunsUnasked(info, ev)
if agent.Base(info.Agent) == "codex" && !d.Approve {
return proto.HookDecision{Block: true, Reason: "A hook changes this command. Run this instead: " + d.Command, Context: d.Context}
}
return d
Expand Down
5 changes: 5 additions & 0 deletions daemon/prompt_answer.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ func (s *Server) promptSeen(info proto.SessionInfo, p agent.ScreenPrompt, screen
}
text := s.maskLent(agent.ScreenEnd(screen, promptScreenLines))
urgent := goa.Urgent("", text, nil)
// An agent whose prompts no hook answers goes by the same policy, by what shows.
if agent.HookSupport(info.Agent)[proto.HookPermission] != proto.HookInAgent && !info.GOA && s.sessionInBypass(info) &&
s.bypassPolicy(info, "", text, nil, "", "its permission prompt, on its screen") && s.pressAnswer(info, p, proto.AnswerAllow) {
return
}
if s.useOn(proto.DecideApprove) {
d, err := s.decideFor(info, proto.DecideAsk{Use: proto.DecideApprove,
Command: "The agent's permission prompt, as its screen shows it, the command and its warning included:\n" + text})
Expand Down
7 changes: 7 additions & 0 deletions daemon/rpc_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ func (s *Server) sessionHook(p proto.SessionHookParams) (proto.HookDecision, err
if err != nil {
return proto.HookDecision{}, err
}
// Answered before it counts as waiting: nothing is asked of the user.
if p.Event.Event == proto.HookPermission && s.bypassAnswer(sess.Info(), p.Event) {
if p.Event.PermissionMode != "" {
s.agentModes.note(p.ID, p.Event.PermissionMode, time.Now())
}
return proto.HookDecision{Approve: true}, nil
}
if p.State != "" {
if err := s.sessions.SetState(sess, p.State); err != nil {
return proto.HookDecision{}, err
Expand Down
11 changes: 11 additions & 0 deletions docs/decide.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ Every use is **on** until the user turns it off (Settings → Decisions → Uses

**With no decision model set up, or none answering,** GOD of Agents answers its own workers' prompts: it is told at once, even mid-turn, with the command and the warning. What can't be undone, money, publishing and secrets still go to the owner. A session outside GOA, or with GOA off, goes to the owner as ever.

**A session in bypass doesn't stop for routine work** (`daemon/bypass.go`). What still prompts there, another hook's or plugin's ask or the agent's own check, is answered allow without reaching the user. Three things stay as they are: a hook's deny (the agent never asks after one), what GOA's urgent rules hold for the owner (`goa.Urgent`: can't be undone, spends money, publishes, touches secrets) and what the judge, sure of itself, doesn't allow. A session not in bypass asks as before. Each answer, either way, is in `decide log --use bypass`: what was asked, by what, and why it passed. Bypass goes by the mode the agent reports with each call, so a session switched to it later counts; an agent that reports none goes by how it started.

| Agent | What answers | Checked |
|---|---|---|
| Claude Code, Claude Agent Teams | Its `PermissionRequest` hook. The prompt can show for an instant first, for as long as the judge takes | Run on 2.1.318: a hook's ask, a rewritten command, a deny, and bypass prompting without it |
| Codex | Its `PermissionRequest` hook, the same way | **Not run:** not installed here |
| Others that report no mode | The screen: enowX presses allow if the session started in bypass and it knows the agent's keys | **Not run.** A prompt it can't read or has no keys for stays the user's |
| An agent run by hand in a shell tab | Nothing new: it isn't known to be in bypass | |

No hook can tell Claude Code's `PermissionRequest` who asked: the log says it was the agent's prompt, not which hook. Its built-in checks on `rm` and on `.git` and `.claude` paths didn't prompt under bypass on 2.1.318 for literal paths, so nothing was seen to answer there.

**Approving commands and trimming** act through hooks ([hooks.md](hooks.md)). While their use is on, enowX adds its own built-in hook for each, with no hook to add (`core/hooks/builtin.go`); a `tool.before` hook of the user's with the same action replaces the built-in one, its conditions then saying which commands are covered. GOD of Agents and its workers are judged by GOA instead ([goa.md](goa.md)). Settings → Decisions → Uses has **Set your own…** for each, to change what it covers:
- **Approve:** `tool.before`, action `judge`, Tool `^(Bash|shell|exec_command)$`.
- **Trim:** `tool.before`, action `trim`, Text matching test, build and install commands, also behind a `cd`, an `export` or a variable set before them. The command then runs as `enowx decide trim -- <command>`, what comes before it staying outside for the agent's shell to keep. Output up to 150 lines and 12,000 characters passes through as it is, shown in the log as `short: passed on whole` for those commands, so that "never used" can be told from "nothing was long"; longer output is trimmed by the judge. The switch **Any command** covers every command a shell tool runs: exact content the agent asked for (`cat`, `sed -n`, `git diff`, `git show`, reads of a file) is cut only when the judge is at least 97% sure, and the whole output stays in the file it is told about, with its exit code unchanged. It leaves unwrapped what wrapping would break: what changes the agent's shell further on, and what never ends (dev servers, `tail -f`, what runs in the background, full-screen programs). It is off until tokens saved are measured against wrong trims and latency, which `decide log` shows. An agent started by hand in a shell tab has no hooks from enowX, so nothing is trimmed there. On Codex out of bypass a wrapped command can't be approved by a hook, so it comes back refused with the command to run instead.
Expand Down
Loading
Loading