diff --git a/docs/architecture.md b/docs/architecture.md index 5344b391..384f7cf6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1416,8 +1416,10 @@ The separate Debug path starts with a user-owned terminal. Desktop can request a 15-second-to-15-minute process-local input lease, but the bearer never crosses the Go boundary. Only a root Code/Deliver Supervisor with the current Debug permission and installed runtime adapter sees `debug_terminal`. Schema v113 admits that tool to -the durable Supervisor call ledger with a data-preserving table migration. Each write rechecks all durable -bindings and Shell Policy; each read is cursor-addressed, bounded, and clamped to the +the durable Supervisor call ledger with a data-preserving table migration. Each input rechecks all durable +bindings and host Policy; command writes also pass Shell Policy. The fixed `interrupt` +action sends only Ctrl+C through the same lease, audit, deduplication and native authorization. +Each read is cursor-addressed, bounded, and clamped to the output watermark captured when the lease was granted. Canonical model commands and sanitized, explicitly untrusted post-grant results are durable Supervisor evidence, while user input, raw PTY bytes, process environment, root paths, and bearer tokens stay in memory. A diff --git a/docs/technical-reference.en.md b/docs/technical-reference.en.md index d902c29f..f0c6cc64 100644 --- a/docs/technical-reference.en.md +++ b/docs/technical-reference.en.md @@ -167,7 +167,7 @@ Traverse Board invokes real Git and operating-system shells; it is not a command `command_runtime` does not share a session or ownership with the persistent user terminal. Its installed host and sandbox adapters share the same Job protocol. Schema v116 writes an immutable launch intent under the current Supervisor generation lease, then keeps a background Job alive under a separate expiring process-owner heartbeat. A later turn can continue reading or writing stdin, while a second process cannot adopt the Job from SQLite. On crash, a creation-time Windows Job Object or POSIX guardian/process group reaps the owned process tree; restart only converges an expired owner record to `interrupted` and never re-executes a persisted PID. Deliberate POSIX daemonization into a new session outside the inherited process group remains an unsandboxed host-execution residual risk. stdout/stderr retain channel and time under a monotonic cursor, and bounded overflow becomes a SHA-256-addressed Artifact. Model-facing bytes are UTF-8 repaired, secret-redacted, and stripped of ANSI/C1/Unicode controls. -Every `debug_terminal` write still passes Shell Policy; commands that require separate per-command approval cannot bypass it through a Debug lease. Grant establishes an output watermark, so the model cannot read terminal scrollback from before the lease. For resumability, the canonical model command and sanitized bounded results after that watermark enter the Supervisor tool transcript; schema v113 admits this tool to the same durable call ledger without losing existing calls. A process-local Workspace-root digest and exact mode revision prevent an old lease from reviving after root or phase drift. User keystrokes, raw PTY bytes, the root path, and the lease bearer are not persisted. Restart terminates the session and invalidates every lease. These host-shell paths are not exposed on the Cyber Surface. See the [Usage Guide](usage.md), [ADR 0114](adr/0114-real-shell-transports-and-supervised-debug-terminal.md), and [ADR 0117](adr/0117-run-owned-command-runtime.md). +Every `debug_terminal` command write still passes Shell Policy; commands that require separate per-command approval cannot bypass it through a Debug lease. The fixed `interrupt` action sends only Ctrl+C under the same lease, host Policy, audit, operation deduplication and native authorization. Grant establishes an output watermark, so the model cannot read terminal scrollback from before the lease. For resumability, the canonical model call and sanitized bounded results after that watermark enter the Supervisor tool transcript; schema v113 admits this tool to the same durable call ledger without losing existing calls. A process-local Workspace-root digest and exact mode revision prevent an old lease from reviving after root or phase drift. User keystrokes, raw PTY bytes, the root path, and the lease bearer are not persisted. Restart terminates the session and invalidates every lease. These host-shell paths are not exposed on the Cyber Surface. See the [Usage Guide](usage.md), [ADR 0114](adr/0114-real-shell-transports-and-supervised-debug-terminal.md), and [ADR 0117](adr/0117-run-owned-command-runtime.md). ### Security boundaries diff --git a/docs/technical-reference.md b/docs/technical-reference.md index 9331c30b..d99faa23 100644 --- a/docs/technical-reference.md +++ b/docs/technical-reference.md @@ -169,7 +169,7 @@ Desktop、认证 OpenAPI 与只读/导出 CLI 共用同一份不可变 Attempt `command_runtime` 与用户持久终端不共享 session 或所有权。Schema v116 先以当前 Supervisor generation lease 写入不可变启动意图;schema v131 再把 exact adapter kind/backend/identity/generation/isolation/network/credential policy 固定到广告、调用、Job 与回执。独立、可过期的进程所有者心跳维持后台 Job,另一进程不能凭数据库记录收养它;旧 v116 行只读投影为不可执行的 `legacy_unbound`。宿主、Windows Local 与固定 Docker adapter 都复用同一套有界 stdin 生命周期;Local 把 manager-owned pipe 复制到 AppContainer 的唯一 stdin handle,Docker 则以 runner v2 + schema v132 生命周期 WAL 把进程内输入绑定到精确 owned container。输入字节不持久化,重启也不会收养旧管道。崩溃时 backend 必须回收 owned 进程树,重启只把所有者已过期的记录收敛为 `interrupted`,绝不按持久 PID 重新执行。stdout/stderr 以单调 cursor 保留通道与时间,内联窗口溢出后仍可生成有 SHA-256 的有界 Artifact;所有返回模型的内容统一去除 ANSI/C1/Unicode 控制序列、修复 UTF-8 并脱敏。 -`debug_terminal` 每次写入仍经过 Shell Policy;需要另行逐条审批的命令不会借 Debug 租约绕过审批。授权瞬间会固定输出水位,模型不能读取租约授予前的终端滚动内容。为支持 Run 恢复,模型提交的规范化命令和水位之后脱敏、有界的结果会进入 Supervisor 工具记录;schema v113 让该工具进入同一持久调用账本并保留既有记录。进程内 Workspace 根目录摘要和 mode revision 会阻止目录或阶段漂移后旧租约复活;用户键盘输入、原始 PTY 字节、根目录路径和租约 bearer 均不持久化。应用重启会终止会话并使租约失效。Cyber Surface 不公开这些宿主 Shell 路径。完整边界见[使用手册](usage.md)、[ADR 0114](adr/0114-real-shell-transports-and-supervised-debug-terminal.md)和 [ADR 0117](adr/0117-run-owned-command-runtime.md)。 +`debug_terminal` 每次命令写入仍经过 Shell Policy;需要另行逐条审批的命令不会借 Debug 租约绕过审批。固定的 `interrupt` 动作只发送 Ctrl+C,沿用相同租约、宿主 Policy、审计、操作去重和原生授权。授权瞬间会固定输出水位,模型不能读取租约授予前的终端滚动内容。为支持 Run 恢复,模型提交的规范化调用和水位之后脱敏、有界的结果会进入 Supervisor 工具记录;schema v113 让该工具进入同一持久调用账本并保留既有记录。进程内 Workspace 根目录摘要和 mode revision 会阻止目录或阶段漂移后旧租约复活;用户键盘输入、原始 PTY 字节、根目录路径和租约 bearer 均不持久化。应用重启会终止会话并使租约失效。Cyber Surface 不公开这些宿主 Shell 路径。完整边界见[使用手册](usage.md)、[ADR 0114](adr/0114-real-shell-transports-and-supervised-debug-terminal.md)和 [ADR 0117](adr/0117-run-owned-command-runtime.md)。 ### 安全边界 diff --git a/docs/usage.md b/docs/usage.md index d420e70a..81298a5c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -2132,7 +2132,9 @@ The persistent Debug terminal is user-owned by default and default-off at proces --enable-user-terminal ``` -Windows uses PowerShell in a ConPTY assigned to a creation-time kill-on-close Job Object. macOS uses Bash with `--noprofile --norc` in a PTY and an owned process group. Ordinary POSIX background jobs share that group, but a command that deliberately creates a new session/daemon can escape group cleanup; The Debug interaction is a host terminal, not a permission tier or containment boundary. Each Run may have one active process-local terminal and one active Agent-input binding; the binding lasts 15 seconds to 15 minutes, is capped and immediately revocable, and is invalid after restart. The model tool is advertised only to the root Supervisor in Code/Deliver with modern Full, current process activation, the Debug interaction and a live terminal-input lease. It supports one complete policy-checked command line or one bounded cursor read; commands that Policy classifies as denied or requiring separate per-command approval do not reach the PTY. Grant records the current output watermark, and every later read is clamped to it, so pre-grant user scrollback cannot enter model context. Output pages are at most 64 KiB, explicitly marked untrusted, stripped of terminal controls, repaired to UTF-8, and secret-redacted. +Windows uses PowerShell in a ConPTY assigned to a creation-time kill-on-close Job Object. macOS uses Bash with `--noprofile --norc` in a PTY and an owned process group. Ordinary POSIX background jobs share that group, but a command that deliberately creates a new session/daemon can escape group cleanup; The Debug interaction is a host terminal, not a permission tier or containment boundary. Each Run may have one active process-local terminal and one active Agent-input binding; the binding lasts 15 seconds to 15 minutes, is capped and immediately revocable, and is invalid after restart. The model tool is advertised only to the root Supervisor in Code/Deliver with modern Full, current process activation, the Debug interaction and a live terminal-input lease. It supports one complete policy-checked command line, one bounded cursor read, or `action: "interrupt"` to send Ctrl+C to the foreground program. Commands that Policy classifies as denied or requiring separate per-command approval do not reach the PTY. Grant records the current output watermark, and every later read is clamped to it, so pre-grant user scrollback cannot enter model context. Output pages are at most 64 KiB, explicitly marked untrusted, stripped of terminal controls, repaired to UTF-8, and secret-redacted. + +Interrupt accepts no command or cursor and sends exactly one `0x03` byte without a newline. It uses the same live input lease, Run/Workspace binding, host Policy checks, operation-key deduplication, audit, and native authorization as command input. It leaves the persistent shell available when the foreground program handles Ctrl+C; a successful tool result confirms the byte was submitted, so read subsequent output to verify the program stopped. Ambiguous writes are never automatically retried. The canonical model command and sanitized bounded post-grant result are stored in the resumable Supervisor tool transcript. Schema v113 transactionally widens that durable call ledger for `debug_terminal` while preserving earlier calls. The process-local binding also pins the exact mode revision and a canonical Workspace-root digest; a Plan round trip or Workspace re-registration invalidates it instead of reviving authority. The bearer, user keystrokes, raw PTY stream, pre-grant scrollback, root path, environment, and process identity are not persisted. Schema v108 defines a `terminal_sessions` metadata ledger contract, but the current Desktop lifecycle remains process-local and does not use that table to restore a process or authority; no database row can revive a terminal or lease. Agent-input grant/write/revoke events store only bounded identities, sizes, and digests. diff --git a/internal/application/debug_terminal_agent_input.go b/internal/application/debug_terminal_agent_input.go index fdb149a3..8b65f35f 100644 --- a/internal/application/debug_terminal_agent_input.go +++ b/internal/application/debug_terminal_agent_input.go @@ -110,6 +110,8 @@ type WriteDebugTerminalAgentInputRequest struct { BindingID string OperationKey string Data []byte + // Interrupt selects a fixed Ctrl+C frame and requires Data to be empty. + Interrupt bool } type ReadDebugTerminalAgentOutputRequest struct { @@ -433,9 +435,15 @@ func (s *DebugTerminalAgentInputService) Write( if err := s.revalidateBinding(ctx, entry); err != nil { return DebugTerminalAgentInputWriteResult{}, err } - policyDecision := s.checker.CheckToolCall(tools.Call{ + policyCall := tools.Call{ Name: "shell", Args: map[string]string{"command": command}, - }) + } + if normalized.Interrupt { + policyCall = tools.Call{ + Name: "debug_terminal", Args: map[string]string{"action": "interrupt"}, + } + } + policyDecision := s.checker.CheckToolCall(policyCall) if !policyDecision.Allowed || policyDecision.NeedsApproval { return DebugTerminalAgentInputWriteResult{}, apperror.New( apperror.CodePolicyDenied, policyDecision.Reason) @@ -939,6 +947,14 @@ func normalizeDebugTerminalWrite( } } request.OperationKey = operationKey + if request.Interrupt { + if len(request.Data) != 0 { + return WriteDebugTerminalAgentInputRequest{}, "", errors.New( + "debug terminal interrupt cannot include input data") + } + request.Data = []byte{0x03} + return request, "", nil + } if len(request.Data) == 0 || len(request.Data) > terminalruntime.MaxTerminalInputBytes || !utf8.Valid(request.Data) { diff --git a/internal/application/debug_terminal_agent_input_test.go b/internal/application/debug_terminal_agent_input_test.go index a2312845..7ab4fa1b 100644 --- a/internal/application/debug_terminal_agent_input_test.go +++ b/internal/application/debug_terminal_agent_input_test.go @@ -648,6 +648,12 @@ func TestDebugTerminalAgentInputRevokesOnWorkspaceRootDrift(t *testing.T) { func newDebugTerminalAgentFixture(t *testing.T, failWrite bool, +) debugTerminalAgentFixture { + return newDebugTerminalAgentFixtureWithManager(t, failWrite, false) +} + +func newDebugTerminalAgentFixtureWithManager(t *testing.T, + failWrite, native bool, ) debugTerminalAgentFixture { t.Helper() ctx := context.Background() @@ -715,7 +721,12 @@ func newDebugTerminalAgentFixture(t *testing.T, } broker := executionauth.NewTerminalInputBroker() backend := &debugTerminalBackendStub{} - manager, err := terminalruntime.NewManager(backend, broker) + var manager *terminalruntime.Manager + if native { + manager, err = terminalruntime.NewPlatformManager(broker) + } else { + manager, err = terminalruntime.NewManager(backend, broker) + } if err != nil { t.Fatal(err) } @@ -763,7 +774,9 @@ func newDebugTerminalAgentFixture(t *testing.T, }); err != nil { t.Fatal(err) } - backend.process.failWrite = failWrite + if backend.process != nil { + backend.process.failWrite = failWrite + } bridge, err := terminalruntime.NewAgentInputBridge(manager, broker) if err != nil { t.Fatal(err) diff --git a/internal/application/debug_terminal_interrupt_native_test.go b/internal/application/debug_terminal_interrupt_native_test.go new file mode 100644 index 00000000..4da6d059 --- /dev/null +++ b/internal/application/debug_terminal_interrupt_native_test.go @@ -0,0 +1,139 @@ +package application + +import ( + "fmt" + "os" + "os/signal" + "runtime" + "strings" + "testing" + "time" + + "cyberagent-workbench/internal/outputsafe" + terminalruntime "cyberagent-workbench/internal/terminal" + "cyberagent-workbench/internal/toolgateway" +) + +func TestDebugTerminalInterruptForegroundHelper(t *testing.T) { + if os.Args[len(os.Args)-1] != "uc-terminal-interrupt-helper" { + t.Skip("foreground subprocess helper") + } + interrupts := make(chan os.Signal, 1) + signal.Notify(interrupts, os.Interrupt) + defer signal.Stop(interrupts) + fmt.Println("UC_INTERRUPT_READY") + select { + case <-interrupts: + fmt.Println("UC_INTERRUPT_RECEIVED") + case <-time.After(30 * time.Second): + t.Fatal("foreground subprocess received no interrupt") + } +} + +func TestDebugTerminalInterruptNativeForegroundPreservesShell(t *testing.T) { + switch runtime.GOOS { + case "windows": + if os.Getenv("CYBERAGENT_TEST_WINDOWS_CONPTY") != "1" { + t.Skip("set CYBERAGENT_TEST_WINDOWS_CONPTY=1 to run the real ConPTY interrupt test") + } + case "darwin", "linux": + default: + t.Skip("native terminal is unavailable on this platform") + } + f := newDebugTerminalAgentFixtureWithManager(t, false, true) + binding := grantDebugTerminalInterruptFixture(t, f) + executor, err := NewDebugTerminalToolExecutor(f.service) + if err != nil { + t.Fatal(err) + } + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + quotedExecutable := "'" + strings.ReplaceAll(executable, "'", "'\"'\"'") + "'" + command := "PS1='UC_INTERRUPT_'\"PROMPT> \"; " + quotedExecutable + " '-test.run=^TestDebugTerminalInterruptForegroundHelper$' '--' 'uc-terminal-interrupt-helper'" + shellCheck := "printf '%s%s\\n' 'UC_INTERRUPT_' 'SHELL_USABLE'" + if runtime.GOOS == "windows" { + command = "function prompt { 'UC_INTERRUPT_' + 'PROMPT> ' }; & '" + strings.ReplaceAll(executable, "'", "''") + "' '-test.run=^TestDebugTerminalInterruptForegroundHelper$' '--' 'uc-terminal-interrupt-helper'" + shellCheck = "Write-Output ('UC_INTERRUPT_' + 'SHELL_USABLE')" + } + runInput := func(action, command, operation string) toolgateway.DebugTerminalExecutionResult { + t.Helper() + result, err := executor.ExecuteDebugTerminal(t.Context(), toolgateway.DebugTerminalContext{ + RunID: f.run.ID, OperationKey: operation, + }, toolgateway.DebugTerminalInput{ + Version: toolgateway.DebugTerminalProtocolVersion, Action: action, + Command: command, MaxBytes: 4096, + }) + if err != nil { + t.Fatal(err) + } + return result + } + waitForMarker := func(result toolgateway.DebugTerminalExecutionResult, marker string) { + t.Helper() + output, cursor := string(result.Output), result.NextCursor + deadline := time.Now().Add(10 * time.Second) + for !strings.Contains(output, marker) && time.Now().Before(deadline) { + page, err := f.service.Read(t.Context(), ReadDebugTerminalAgentOutputRequest{ + ProtocolVersion: DebugTerminalAgentInputProtocolVersion, + BindingID: binding.ID, Cursor: cursor, MaxBytes: 4096, + }) + if err != nil { + t.Fatal(err) + } + output += string(page.Data) + cursor = page.NextCursor + time.Sleep(10 * time.Millisecond) + } + if !strings.Contains(output, marker) { + t.Fatalf("terminal omitted %s: %q", marker, output) + } + } + waitForMarker(runInput(toolgateway.DebugTerminalActionWrite, command, "native-start-foreground"), "UC_INTERRUPT_READY") + interrupted := runInput(toolgateway.DebugTerminalActionInterrupt, "", "native-interrupt-foreground") + if !interrupted.InputSubmitted || interrupted.BytesWritten != 1 || interrupted.TerminalSessionID != f.sessionID { + t.Fatalf("unexpected native interrupt result: %+v", interrupted) + } + waitForMarker(interrupted, "UC_INTERRUPT_RECEIVED") + // Shell prompts have no newline and intentionally stay out of the model's + // line-buffered stream. Wait for the real prompt before sending another command. + var promptOutput []byte + var promptCursor uint64 + deadline := time.Now().Add(10 * time.Second) + for !strings.Contains(outputsafe.Sanitize(promptOutput), "UC_INTERRUPT_PROMPT> ") && time.Now().Before(deadline) { + page, err := f.manager.Read(f.sessionID, promptCursor, 4096) + if err != nil { + t.Fatal(err) + } + promptOutput = append(promptOutput, page.Data...) + promptCursor = page.NextCursor + time.Sleep(10 * time.Millisecond) + } + if !strings.Contains(outputsafe.Sanitize(promptOutput), "UC_INTERRUPT_PROMPT> ") { + t.Fatalf("shell prompt did not return after interrupt: %q", outputsafe.Sanitize(promptOutput)) + } + // Both pieces are separate in the command, so terminal echo cannot satisfy this check. + waitForMarker(runInput(toolgateway.DebugTerminalActionWrite, shellCheck, "native-check-shell"), "UC_INTERRUPT_SHELL_USABLE") + current, err := f.manager.Get(f.sessionID) + if err != nil || current.State != terminalruntime.SessionRunning { + t.Fatalf("shell did not survive foreground interrupt: %+v err=%v", current, err) + } + if _, err := f.manager.WriteUser(t.Context(), terminalruntime.UserInputRequest{ + SessionID: f.sessionID, Data: []byte("exit\r"), RequestedBy: "test_operator", UserConfirmed: true, + }); err != nil { + t.Fatal(err) + } + deadline = time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + current, err = f.manager.Get(f.sessionID) + if err != nil { + t.Fatal(err) + } + if current.State == terminalruntime.SessionExited { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("test shell did not exit") +} diff --git a/internal/application/debug_terminal_interrupt_test.go b/internal/application/debug_terminal_interrupt_test.go new file mode 100644 index 00000000..686d0de5 --- /dev/null +++ b/internal/application/debug_terminal_interrupt_test.go @@ -0,0 +1,176 @@ +package application + +import ( + "context" + "encoding/json" + "testing" + "time" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/events" + "cyberagent-workbench/internal/policy" + terminalruntime "cyberagent-workbench/internal/terminal" + "cyberagent-workbench/internal/tools" +) + +func grantDebugTerminalInterruptFixture(t *testing.T, f debugTerminalAgentFixture) DebugTerminalAgentInputBinding { + t.Helper() + binding, err := f.service.Grant(t.Context(), GrantDebugTerminalAgentInputRequest{ + ProtocolVersion: DebugTerminalAgentInputProtocolVersion, + RunID: f.run.ID, TerminalSessionID: f.sessionID, RequestedBy: "test_operator", + ConfirmFullAccess: true, ConfirmAgentTerminalInput: true, TTL: time.Minute, + }) + if err != nil { + t.Fatal(err) + } + return binding +} + +func TestDebugTerminalInterruptIsFixedAuditedAndExactlyOnce(t *testing.T) { + f := newDebugTerminalAgentFixture(t, false) + binding := grantDebugTerminalInterruptFixture(t, f) + request := WriteDebugTerminalAgentInputRequest{ + ProtocolVersion: DebugTerminalAgentInputProtocolVersion, + BindingID: binding.ID, OperationKey: "debug-interrupt-once", Interrupt: true, + } + result, err := f.service.Write(t.Context(), request) + if err != nil || result.BytesWritten != 1 || result.DataBytes != 1 || result.Replayed || + result.DataSHA256 != terminalruntime.AgentInputDataDigest([]byte{0x03}) { + t.Fatalf("interrupt=%+v err=%v", result, err) + } + replayed, err := f.service.Write(t.Context(), request) + if err != nil || !replayed.Replayed || replayed.OperationDigest != result.OperationDigest || replayed.OutputCursor != result.OutputCursor { + t.Fatalf("replay=%+v err=%v", replayed, err) + } + conflict := request + conflict.Interrupt, conflict.Data = false, []byte("go version\r") + if _, err := f.service.Write(t.Context(), conflict); apperror.CodeOf(err) != apperror.CodeConflict { + t.Fatalf("command reused interrupt operation key: %v", err) + } + for _, data := range [][]byte{{0x03}, {0x03, '\r'}, {0x04}, []byte("whoami\r")} { + invalid := request + invalid.Data = data + if _, err := f.service.Write(t.Context(), invalid); apperror.CodeOf(err) != apperror.CodeInvalidArgument { + t.Fatalf("interrupt accepted custom bytes %q: %v", data, err) + } + } + input, writes := f.process.snapshot() + if input != "\x03" || writes != 1 { + t.Fatalf("input=%q writes=%d", input, writes) + } + records, err := f.state.ListRunEvents(t.Context(), f.run.ID) + if err != nil { + t.Fatal(err) + } + counts := map[string]int{} + for _, record := range records { + if record.Type != events.DebugTerminalAgentInputPreparedEvent && record.Type != events.DebugTerminalAgentInputCompletedEvent { + continue + } + counts[record.Type]++ + var payload struct { + DataSHA256 string `json:"data_sha256"` + DataBytes int `json:"data_bytes"` + } + if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil || payload.DataBytes != 1 || payload.DataSHA256 != result.DataSHA256 { + t.Fatalf("unexpected interrupt audit: %s err=%v", record.PayloadJSON, err) + } + } + if counts[events.DebugTerminalAgentInputPreparedEvent] != 1 || counts[events.DebugTerminalAgentInputCompletedEvent] != 1 { + t.Fatalf("audit counts=%v", counts) + } +} + +type debugTerminalInterruptPolicy struct { + policy.Checker + decision policy.Decision +} + +func (p debugTerminalInterruptPolicy) CheckToolCall(call tools.Call) policy.Decision { + if call.Name == "debug_terminal" && call.Args["action"] == "interrupt" { + return p.decision + } + return p.Checker.CheckToolCall(call) +} + +// Revoke after application revalidation, at the entry to the native bridge. +type debugTerminalInterruptRevocationBridge struct { + DebugTerminalAgentInputBridge + revoke func() +} + +func (b debugTerminalInterruptRevocationBridge) Write(ctx context.Context, request terminalruntime.AgentWriteRequest) (terminalruntime.AgentWriteResult, error) { + b.revoke() + return b.DebugTerminalAgentInputBridge.Write(ctx, request) +} + +func TestDebugTerminalInterruptRequiresCurrentAuthority(t *testing.T) { + for _, scenario := range []string{"missing", "expired", "revoked", "permission", "workspace", "policy_denied", "policy_review", "native_revocation"} { + t.Run(scenario, func(t *testing.T) { + f := newDebugTerminalAgentFixture(t, false) + binding := grantDebugTerminalInterruptFixture(t, f) + switch scenario { + case "missing": + binding.ID = "missing-terminal-binding" + case "expired": + f.service.now = func() time.Time { return binding.ExpiresAt.Add(time.Second) } + case "revoked": + if err := f.service.Revoke(t.Context(), RevokeDebugTerminalAgentInputRequest{ + ProtocolVersion: DebugTerminalAgentInputProtocolVersion, BindingID: binding.ID, + RequestedBy: "test_operator", OperatorConfirmed: true, + }); err != nil { + t.Fatal(err) + } + case "permission": + if _, err := f.permission.Change(t.Context(), ChangeRunExecutionPermissionRequest{ + RunID: f.run.ID, Mode: "ask", OperationKey: "interrupt-permission-change", + RequestedBy: "test_operator", Reason: "end terminal input", + }); err != nil { + t.Fatal(err) + } + case "workspace": + workspace, err := f.state.GetWorkspaceByID(t.Context(), binding.WorkspaceID) + if err != nil { + t.Fatal(err) + } + workspace.RootPath = t.TempDir() + if err := f.state.SaveWorkspace(t.Context(), workspace); err != nil { + t.Fatal(err) + } + case "policy_denied", "policy_review": + f.service.checker = debugTerminalInterruptPolicy{Checker: policy.NewDefaultChecker(), decision: policy.Decision{ + Allowed: scenario == "policy_review", NeedsApproval: scenario == "policy_review", Reason: "interrupt host rule", + }} + case "native_revocation": + f.service.bridge = debugTerminalInterruptRevocationBridge{DebugTerminalAgentInputBridge: f.service.bridge, + revoke: func() { f.service.capabilities.RuntimeAuthority.RevokeRun(f.run.ID) }} + } + if _, err := f.service.Write(t.Context(), WriteDebugTerminalAgentInputRequest{ + ProtocolVersion: DebugTerminalAgentInputProtocolVersion, BindingID: binding.ID, + OperationKey: "interrupt-without-authority", Interrupt: true, + }); err == nil { + t.Fatal("interrupt accepted without current authority") + } + if input, writes := f.process.snapshot(); input != "" || writes != 0 { + t.Fatalf("unauthorized native input=%q writes=%d", input, writes) + } + }) + } +} + +func TestDebugTerminalInterruptDoesNotRetryAmbiguousWrite(t *testing.T) { + f := newDebugTerminalAgentFixture(t, true) + binding := grantDebugTerminalInterruptFixture(t, f) + request := WriteDebugTerminalAgentInputRequest{ + ProtocolVersion: DebugTerminalAgentInputProtocolVersion, BindingID: binding.ID, + OperationKey: "interrupt-ambiguous", Interrupt: true, + } + for range 2 { + if _, err := f.service.Write(t.Context(), request); apperror.CodeOf(err) != apperror.CodeFailedPrecondition { + t.Fatalf("ambiguous interrupt error=%v", err) + } + } + if _, writes := f.process.snapshot(); writes != 1 { + t.Fatalf("ambiguous interrupt retried %d times", writes) + } +} diff --git a/internal/application/debug_terminal_tool.go b/internal/application/debug_terminal_tool.go index a4cb0d01..5c002fcf 100644 --- a/internal/application/debug_terminal_tool.go +++ b/internal/application/debug_terminal_tool.go @@ -32,6 +32,10 @@ func (e *DebugTerminalToolExecutor) ExecuteDebugTerminal( apperror.CodeFailedPrecondition, "debug terminal Agent-input runtime is unavailable") } + if err := input.Validate(); err != nil { + return toolgateway.DebugTerminalExecutionResult{}, apperror.Wrap( + apperror.CodeInvalidArgument, "debug terminal input is invalid", err) + } binding, found, err := e.controller.Active(ctx, scope.RunID) if err != nil { return toolgateway.DebugTerminalExecutionResult{}, err @@ -45,13 +49,17 @@ func (e *DebugTerminalToolExecutor) ExecuteDebugTerminal( inputSubmitted := false bytesWritten := 0 replayed := false - if input.Action == toolgateway.DebugTerminalActionWrite { - written, err := e.controller.Write(ctx, - WriteDebugTerminalAgentInputRequest{ - ProtocolVersion: DebugTerminalAgentInputProtocolVersion, - BindingID: binding.ID, OperationKey: scope.OperationKey, - Data: []byte(input.Command + "\r"), - }) + if input.Action == toolgateway.DebugTerminalActionWrite || + input.Action == toolgateway.DebugTerminalActionInterrupt { + request := WriteDebugTerminalAgentInputRequest{ + ProtocolVersion: DebugTerminalAgentInputProtocolVersion, + BindingID: binding.ID, OperationKey: scope.OperationKey, + Interrupt: input.Action == toolgateway.DebugTerminalActionInterrupt, + } + if !request.Interrupt { + request.Data = []byte(input.Command + "\r") + } + written, err := e.controller.Write(ctx, request) if err != nil { return toolgateway.DebugTerminalExecutionResult{}, err } diff --git a/internal/application/debug_terminal_tool_test.go b/internal/application/debug_terminal_tool_test.go index 14888c37..c9427eee 100644 --- a/internal/application/debug_terminal_tool_test.go +++ b/internal/application/debug_terminal_tool_test.go @@ -26,11 +26,15 @@ func (s *debugTerminalToolControllerStub) Write(_ context.Context, request WriteDebugTerminalAgentInputRequest, ) (DebugTerminalAgentInputWriteResult, error) { s.writes = append(s.writes, request) + bytesWritten := len(request.Data) + if request.Interrupt { + bytesWritten = 1 + } return DebugTerminalAgentInputWriteResult{ ProtocolVersion: DebugTerminalAgentInputProtocolVersion, BindingID: request.BindingID, TerminalSessionID: s.binding.TerminalSessionID, - BytesWritten: len(request.Data), + BytesWritten: bytesWritten, OutputCursor: 5, }, nil } @@ -122,4 +126,40 @@ func TestDebugTerminalToolRequiresOperatorBinding(t *testing.T) { } } +func TestDebugTerminalToolInterruptUsesFixedInputAndReadsFromTail(t *testing.T) { + controller := &debugTerminalToolControllerStub{ + found: true, + binding: DebugTerminalAgentInputBinding{ + ID: "debug-terminal-binding-1", RunID: "run-1", TerminalSessionID: "terminal-1", + }, + } + executor, err := NewDebugTerminalToolExecutor(controller) + if err != nil { + t.Fatal(err) + } + input := toolgateway.DebugTerminalInput{ + Version: toolgateway.DebugTerminalProtocolVersion, + Action: toolgateway.DebugTerminalActionInterrupt, MaxBytes: 4096, + } + result, err := executor.ExecuteDebugTerminal(t.Context(), toolgateway.DebugTerminalContext{ + RunID: "run-1", OperationKey: "debug-terminal-interrupt-0001", + }, input) + if err != nil { + t.Fatal(err) + } + if len(controller.writes) != 1 || !controller.writes[0].Interrupt || + len(controller.writes[0].Data) != 0 || controller.writes[0].OperationKey != "debug-terminal-interrupt-0001" || + len(controller.reads) != 1 || controller.reads[0].Cursor != 5 || + !result.InputSubmitted || result.BytesWritten != 1 { + t.Fatalf("writes=%+v reads=%+v result=%+v", controller.writes, controller.reads, result) + } + input.Command = "whoami" + if _, err := executor.ExecuteDebugTerminal(t.Context(), toolgateway.DebugTerminalContext{RunID: "run-1"}, input); apperror.CodeOf(err) != apperror.CodeInvalidArgument { + t.Fatalf("interrupt with a command accepted: %v", err) + } + if len(controller.writes) != 1 { + t.Fatal("invalid interrupt reached controller") + } +} + var _ DebugTerminalAgentInputController = (*debugTerminalToolControllerStub)(nil) diff --git a/internal/toolgateway/debug_terminal.go b/internal/toolgateway/debug_terminal.go index 3cdd3700..38c4317c 100644 --- a/internal/toolgateway/debug_terminal.go +++ b/internal/toolgateway/debug_terminal.go @@ -22,6 +22,7 @@ const ( DebugTerminalProtocolVersion = "debug_terminal.v1" DebugTerminalActionWrite = "write" DebugTerminalActionRead = "read" + DebugTerminalActionInterrupt = "interrupt" MaxDebugTerminalWaitMillis = 5_000 MaxDebugTerminalOutputBytes = 64 * 1024 ) @@ -38,7 +39,8 @@ type DebugTerminalInput struct { func (i DebugTerminalInput) Validate() error { if i.Version != DebugTerminalProtocolVersion || (i.Action != DebugTerminalActionWrite && - i.Action != DebugTerminalActionRead) || + i.Action != DebugTerminalActionRead && + i.Action != DebugTerminalActionInterrupt) || i.MaxBytes < 1 || i.MaxBytes > MaxDebugTerminalOutputBytes || i.WaitMilliseconds < 0 || i.WaitMilliseconds > MaxDebugTerminalWaitMillis { @@ -50,6 +52,12 @@ func (i DebugTerminalInput) Validate() error { } return nil } + if i.Action == DebugTerminalActionInterrupt { + if i.Command != "" || i.Cursor != 0 { + return errors.New("debug terminal interrupt cannot include a command or cursor") + } + return nil + } if i.Cursor != 0 || i.Command == "" || strings.TrimSpace(i.Command) == "" || !utf8.ValidString(i.Command) || strings.ContainsRune(i.Command, 0) || @@ -143,8 +151,8 @@ type DebugTerminalExecutor interface { var debugTerminalDefinition = ToolDefinition{ Name: DebugTerminalTool, Class: ClassShell, Approval: ApprovalAutomatic, - Description: "Write one policy-checked command to, or read one bounded cursor page from, the current user-owned Debug terminal. Available only in Code/Deliver/Debug after the operator grants a short-lived Agent-input lease. Agent cursors address a separate sanitized stream; partial lines wait for a newline or EOF, and lines begun before the grant or command are omitted. A write submits the command to a persistent shell; the canonical command and sanitized bounded result become durable Supervisor evidence, output may be incomplete, and background processes may continue after this call. Never submit secrets.", - InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false,"required":["version","action","max_bytes","wait_milliseconds"],"properties":{"version":{"const":"debug_terminal.v1"},"action":{"enum":["write","read"]},"command":{"type":"string","minLength":1,"maxLength":16384},"cursor":{"type":"integer","minimum":0},"max_bytes":{"type":"integer","minimum":1,"maximum":65536},"wait_milliseconds":{"type":"integer","minimum":0,"maximum":5000}},"oneOf":[{"properties":{"action":{"const":"write"}},"required":["command"],"not":{"required":["cursor"]}},{"properties":{"action":{"const":"read"}},"not":{"required":["command"]}}]}`), + Description: "Write one policy-checked command to, read one bounded cursor page from, or send Ctrl+C to the current user-owned Debug terminal. Interrupt sends only Ctrl+C (no newline) to interrupt the foreground program while keeping the shell available; it does not guarantee the program exits. Available only in Code/Deliver/Debug after the operator grants a short-lived Agent-input lease. Agent cursors address a separate sanitized stream; partial lines wait for a newline or EOF, and lines begun before the grant or input are omitted. A write submits the command to a persistent shell; the canonical call and sanitized bounded result become durable Supervisor evidence, output may be incomplete, and background processes may continue after this call. Never submit secrets.", + InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false,"required":["version","action","max_bytes","wait_milliseconds"],"properties":{"version":{"const":"debug_terminal.v1"},"action":{"enum":["write","read","interrupt"]},"command":{"type":"string","minLength":1,"maxLength":16384},"cursor":{"type":"integer","minimum":0},"max_bytes":{"type":"integer","minimum":1,"maximum":65536},"wait_milliseconds":{"type":"integer","minimum":0,"maximum":5000}},"oneOf":[{"properties":{"action":{"const":"write"}},"required":["command"],"not":{"required":["cursor"]}},{"properties":{"action":{"const":"read"}},"not":{"required":["command"]}},{"properties":{"action":{"const":"interrupt"}},"not":{"anyOf":[{"required":["command"]},{"required":["cursor"]}]}}]}`), } func normalizeDebugTerminalPayload(payload json.RawMessage) ( @@ -179,6 +187,11 @@ func normalizeDebugTerminalPayload(payload json.RawMessage) ( return DebugTerminalInput{}, nil, errors.New("debug terminal read fields are invalid") } + case DebugTerminalActionInterrupt: + if commandPresent || cursorPresent { + return DebugTerminalInput{}, nil, + errors.New("debug terminal interrupt fields are invalid") + } } if err := input.Validate(); err != nil { return DebugTerminalInput{}, nil, err @@ -213,6 +226,10 @@ func (g *Gateway) invokeDebugTerminal(ctx context.Context, call ToolCall) ( policyDecision = g.checker.CheckToolCall(tools.Call{ Name: string(ShellTool), Args: map[string]string{"command": input.Command}, }) + } else if input.Action == DebugTerminalActionInterrupt { + policyDecision = g.checker.CheckToolCall(tools.Call{ + Name: string(DebugTerminalTool), Args: map[string]string{"action": input.Action}, + }) } if !policyDecision.Allowed || policyDecision.NeedsApproval { if policyDecision.NeedsApproval { @@ -266,7 +283,7 @@ func (g *Gateway) invokeDebugTerminal(ctx context.Context, call ToolCall) ( "output_complete": "false", "lease_token_exposed": "false", "raw_output_persisted": "false", - "command_persisted": "true", + "command_persisted": strconv.FormatBool(input.Action == DebugTerminalActionWrite), "sanitized_output_persisted": "true", "output_source": "untrusted_debug_terminal", "instruction_authorized": "false", diff --git a/internal/toolgateway/debug_terminal_test.go b/internal/toolgateway/debug_terminal_test.go index 3eea8094..147bd813 100644 --- a/internal/toolgateway/debug_terminal_test.go +++ b/internal/toolgateway/debug_terminal_test.go @@ -7,6 +7,7 @@ import ( "testing" "cyberagent-workbench/internal/policy" + "cyberagent-workbench/internal/tools" ) type debugTerminalExecutorStub struct { @@ -21,13 +22,19 @@ func (s *debugTerminalExecutorStub) ExecuteDebugTerminal(_ context.Context, s.calls++ s.scope = scope s.input = input + bytesWritten := 0 + if input.Action == DebugTerminalActionWrite { + bytesWritten = len([]byte(input.Command + "\r")) + } else if input.Action == DebugTerminalActionInterrupt { + bytesWritten = 1 + } return DebugTerminalExecutionResult{ BindingID: "terminal-input-binding-1", TerminalSessionID: "user-terminal-1", Backend: "windows-conpty-user-v1", BaseCursor: 4, NextCursor: 11, Output: []byte("\x1b[31mgo1.25\x1b[0m\r\n"), State: "running", - InputSubmitted: input.Action == DebugTerminalActionWrite, - BytesWritten: len([]byte(input.Command + "\r")), + InputSubmitted: input.Action != DebugTerminalActionRead, + BytesWritten: bytesWritten, }, nil } @@ -37,7 +44,17 @@ func TestDebugTerminalPayloadIsStrictAndCanonical(t *testing.T) { if err != nil || input.Command != "go version" || !json.Valid(canonical) { t.Fatalf("valid Debug terminal payload failed: %s err=%v", canonical, err) } + interrupt := json.RawMessage(`{"version":"debug_terminal.v1","action":"interrupt","max_bytes":4096,"wait_milliseconds":0}`) + input, canonical, err = normalizeDebugTerminalPayload(interrupt) + if err != nil || input.Action != DebugTerminalActionInterrupt || string(canonical) != string(interrupt) { + t.Fatalf("valid interrupt payload failed: %s err=%v", canonical, err) + } for _, invalid := range []json.RawMessage{ + json.RawMessage(`{"version":"debug_terminal.v1","action":"interrupt","command":"whoami","max_bytes":4096,"wait_milliseconds":0}`), + json.RawMessage(`{"version":"debug_terminal.v1","action":"interrupt","command":null,"max_bytes":4096,"wait_milliseconds":0}`), + json.RawMessage(`{"version":"debug_terminal.v1","action":"interrupt","cursor":0,"max_bytes":4096,"wait_milliseconds":0}`), + json.RawMessage(`{"version":"debug_terminal.v1","action":"interrupt","data":"\u0003","max_bytes":4096,"wait_milliseconds":0}`), + json.RawMessage(`{"version":"debug_terminal.v1","action":"write","command":"\u0003","max_bytes":4096,"wait_milliseconds":0}`), json.RawMessage(`{"version":"debug_terminal.v1","action":"write","command":"go version\nwhoami","max_bytes":4096,"wait_milliseconds":0}`), json.RawMessage(`{"version":"debug_terminal.v1","action":"write","command":"go version","cursor":0,"max_bytes":4096,"wait_milliseconds":0}`), json.RawMessage(`{"version":"debug_terminal.v1","action":"read","command":"whoami","max_bytes":4096,"wait_milliseconds":0}`), @@ -126,3 +143,49 @@ func TestDebugTerminalGatewayDeniesDangerousAndApprovalCommands(t *testing.T) { t.Fatalf("denied Debug terminal commands reached executor %d times", executor.calls) } } + +type debugTerminalInterruptChecker struct { + policy.Checker + decision policy.Decision +} + +func (p debugTerminalInterruptChecker) CheckToolCall(call tools.Call) policy.Decision { + if call.Name == string(DebugTerminalTool) && call.Args["action"] == DebugTerminalActionInterrupt { + return p.decision + } + return p.Checker.CheckToolCall(call) +} + +func TestDebugTerminalGatewayInterruptHonorsPolicy(t *testing.T) { + for _, scenario := range []string{"allow", "deny", "review"} { + t.Run(scenario, func(t *testing.T) { + executor := &debugTerminalExecutorStub{} + gateway := New(newTrackedStructuredStore(), debugTerminalInterruptChecker{ + Checker: policy.NewDefaultChecker(), decision: policy.Decision{ + Allowed: scenario != "deny", NeedsApproval: scenario == "review", Reason: "interrupt host rule", + }, + }).WithDebugTerminalExecutor(executor) + outcome, err := gateway.Invoke(t.Context(), ToolCall{ + Name: DebugTerminalTool, + Payload: json.RawMessage(`{"version":"debug_terminal.v1","action":"interrupt","max_bytes":4096,"wait_milliseconds":0}`), + OperationKey: "debug-terminal-interrupt", RunID: "run-1", AgentID: "agent-20260818123456-abcdef012345", + SessionID: "session-1", WorkspaceID: "workspace-1", LeaseID: "run-lease-1", LeaseGeneration: 1, + RequestedBy: "run_supervisor", + }) + if err != nil { + t.Fatal(err) + } + if scenario != "allow" { + if executor.calls != 0 || outcome.Decision.Allowed || outcome.Result.Status != StatusDenied { + t.Fatalf("denied interrupt reached executor: %+v", outcome) + } + return + } + if executor.calls != 1 || executor.input.Action != DebugTerminalActionInterrupt || + executor.scope.LeaseID != "run-lease-1" || outcome.Result.Metadata["bytes_written"] != "1" || + outcome.Result.Metadata["command_persisted"] != "false" { + t.Fatalf("unexpected interrupt outcome: %+v", outcome) + } + }) + } +}