From e83c1fe7dda4af7ff17e7672e030cbdcd2e5b6a6 Mon Sep 17 00:00:00 2001 From: enowdev Date: Wed, 4 Nov 2026 22:00:03 +0700 Subject: [PATCH] Don't stop a session in bypass for routine work Bypass didn't mean what it says: a plugin hook's ask still prompted, and enowX's own trim asked in a session switched to bypass after it started, as it went by how the session started rather than the mode the agent reports. The rewrite now goes by the mode. What still prompts is answered by one policy, through the agent's PermissionRequest hook, or by the screen for an agent without one: routine work passes, and what the urgent rules or a sure judge hold for the owner doesn't. A hook's deny never gets that far, and a session not in bypass asks as before. Each answer is in decide log. Checked on Claude Code 2.1.318 in a real terminal. Co-Authored-By: Claude Opus 5.5 --- daemon/bypass.go | 103 +++++++++++++++++++++++++ daemon/bypass_test.go | 162 ++++++++++++++++++++++++++++++++++++++++ daemon/hook.go | 18 ++++- daemon/hooks.go | 13 ++-- daemon/prompt_answer.go | 5 ++ daemon/rpc_hooks.go | 7 ++ docs/decide.md | 11 +++ docs/hooks.md | 2 +- proto/decide.go | 1 + proto/hooks.go | 2 + proto/testdata/api.txt | 2 + 11 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 daemon/bypass.go create mode 100644 daemon/bypass_test.go 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