diff --git a/daemon/bypass.go b/daemon/bypass.go new file mode 100644 index 00000000..435297d3 --- /dev/null +++ b/daemon/bypass.go @@ -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)) +} diff --git a/daemon/bypass_test.go b/daemon/bypass_test.go new file mode 100644 index 00000000..572619d2 --- /dev/null +++ b/daemon/bypass_test.go @@ -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") + } +} diff --git a/daemon/hook.go b/daemon/hook.go index 1d03d495..4b534b28 100644 --- a/daemon/hook.go +++ b/daemon/hook.go @@ -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()) @@ -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"} { @@ -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 { diff --git a/daemon/hooks.go b/daemon/hooks.go index a4081a14..d05e4f70 100644 --- a/daemon/hooks.go +++ b/daemon/hooks.go @@ -172,7 +172,7 @@ 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 @@ -180,11 +180,12 @@ func (s *Server) fireHooks(info proto.SessionInfo, ev proto.HookEvent) proto.Hoo // 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 diff --git a/daemon/prompt_answer.go b/daemon/prompt_answer.go index fd04127c..941d42e7 100644 --- a/daemon/prompt_answer.go +++ b/daemon/prompt_answer.go @@ -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}) diff --git a/daemon/rpc_hooks.go b/daemon/rpc_hooks.go index 6c235432..768113d6 100644 --- a/daemon/rpc_hooks.go +++ b/daemon/rpc_hooks.go @@ -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 diff --git a/docs/decide.md b/docs/decide.md index be347e2f..a53f4134 100644 --- a/docs/decide.md +++ b/docs/decide.md @@ -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 -- `, 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. diff --git a/docs/hooks.md b/docs/hooks.md index 80b2aba0..c3477242 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -69,7 +69,7 @@ Limits: **once a session**, **at most N times a session**, **apart at least** so - **Add context:** text the agent reads before a prompt or at a session's start. - **Refuse:** a tool about to run, a prompt, or a commit, push or merge is refused, and Reason is told to the agent, or shown in the Git panel. On other events, the refusal is only logged. - **Change the command:** Find (a regular expression) in a tool's command is replaced by Replace (`$1` for a group). - - Claude Code asks the user about the new command, unless the session skips permissions. + - Claude Code asks the user about the new command, unless the session is in bypass, by the mode it reports with the call. - Codex takes a changed command only as approved, so a Codex session that asks gets it as a refusal naming the command to run instead. - **Ask the judge** (before a tool runs): enowX's judge allows the command without asking the user, leaves it to them, or refuses it ([decide.md](decide.md)). On Codex an allow doesn't spare the prompt: its own permission checks stay, and the decision log says `left to the agent's prompt`; the judge can still refuse a command. - **Trim its long output** (before a tool runs): the command runs through `enowx decide trim`, and a long output reaches the agent cut by the judge to what matters ([decide.md](decide.md)). diff --git a/proto/decide.go b/proto/decide.go index c6452922..a1f000c9 100644 --- a/proto/decide.go +++ b/proto/decide.go @@ -32,6 +32,7 @@ const ( DecideContext = "context" // a project's search results: which bear on a prompt DecideMemory = "memory" // what an agent said last: a decision worth keeping DecideChat = "chat" // a team chat message: urgent, normal or quiet + DecideBypass = "bypass" // a prompt of a session in bypass, answered by policy (docs/decide.md); no judge's use DecideFlaky = "flaky" // a failed check's output: flaky or a real failure ) diff --git a/proto/hooks.go b/proto/hooks.go index 080e8d03..bd93f8c4 100644 --- a/proto/hooks.go +++ b/proto/hooks.go @@ -190,6 +190,8 @@ type HookEvent struct { // PermissionMode is the permission mode the agent's hook reports it in // (permission_mode: Claude Code's, Codex's), "" when it says none. PermissionMode string `json:"permissionMode,omitempty"` + // Scratchpad is the agent's own folder for scratch files, if it says. + Scratchpad string `json:"scratchpad,omitempty"` // Via is how it was seen (HookInAgent, HookOutside). Via string `json:"via,omitempty"` Time time.Time `json:"time"` diff --git a/proto/testdata/api.txt b/proto/testdata/api.txt index 1393d616..4be9268c 100644 --- a/proto/testdata/api.txt +++ b/proto/testdata/api.txt @@ -1322,6 +1322,7 @@ field HookEvent.permissionMode string field HookEvent.port int field HookEvent.project string field HookEvent.prompt string +field HookEvent.scratchpad string field HookEvent.session string field HookEvent.text string field HookEvent.time time.Time @@ -3272,6 +3273,7 @@ value CredUnchecked=unchecked value CredValid=valid value DecideAnyProvider=any value DecideApprove=approve +value DecideBypass=bypass value DecideChat=chat value DecideContext=context value DecideFlaky=flaky