diff --git a/internal/chat/command_output.go b/internal/chat/command_output.go index 5669af5..dd22e2c 100644 --- a/internal/chat/command_output.go +++ b/internal/chat/command_output.go @@ -12,7 +12,9 @@ import ( // Each stream holds at most one bounded lexical token. Tokens spanning writes // are scrubbed together; PEM bodies and oversized secret tokens are discarded before -// reaching storage. No raw output is spooled to disk. +// reaching storage or the agent. No raw output is spooled to disk. The agent +// always gets the scrubbed head and tail of the command it asked to run; only +// snippets capture stores the full scrubbed output. type outputStream struct { parent *commandOutput name, token string @@ -50,10 +52,12 @@ func (o *commandOutput) emit(ctx context.Context, stream, data string) { if data == "" || o.err != nil { return } - if o.err = o.st.AppendCommandOutput(ctx, o.action, stream, o.seq, []byte(data)); o.err != nil { - return + if o.snippets { + if o.err = o.st.AppendCommandOutput(ctx, o.action, stream, o.seq, []byte(data)); o.err != nil { + return + } + o.seq++ } - o.seq++ o.total += len(data) if len(o.head) < 2048 { n := min(2048-len(o.head), len(data)) @@ -97,9 +101,6 @@ func (s *outputStream) Write(p []byte) (int, error) { if o.finished { return 0, errors.New("command output closed") } - if !o.snippets { - return len(p), nil - } var safe strings.Builder for _, b := range p { if b == ' ' || b == '\n' || b == '\r' || b == '\t' { @@ -140,9 +141,7 @@ func (o *commandOutput) Finish(ctx context.Context) (string, error) { defer o.mu.Unlock() if !o.finished { for _, s := range o.streams { - if o.snippets { - o.emit(ctx, s.name, s.flush()) - } + o.emit(ctx, s.name, s.flush()) } o.finished = true } diff --git a/internal/chat/command_output_test.go b/internal/chat/command_output_test.go index dc202df..a0ce66b 100644 --- a/internal/chat/command_output_test.go +++ b/internal/chat/command_output_test.go @@ -49,8 +49,12 @@ func TestCommandOutputCapture(t *testing.T) { if err = st.ReadCommandOutput(context.Background(), "a", &full); err != nil { t.Fatal(err) } + // The agent gets the scrubbed head and tail under every policy. + if strings.Contains(summary, "ghp_") || !strings.Contains(summary, "HEAD") || !strings.Contains(summary, "[redacted]") || !strings.Contains(summary, "TAIL") || len(summary) > 5000 { + t.Fatal("agent summary", summary) + } if !snippets { - if full.Len() != 0 || summary != "" { + if full.Len() != 0 { t.Fatal("titles retained output") } return diff --git a/site/src/content/docs/guides/chat.mdx b/site/src/content/docs/guides/chat.mdx index c1cc0b7..33c9534 100644 --- a/site/src/content/docs/guides/chat.mdx +++ b/site/src/content/docs/guides/chat.mdx @@ -180,15 +180,17 @@ interrupted work is marked interrupted after a restart and never replayed to the agent. Your question is scrubbed and sent to Claude. Context and tool results pass the -capture policy first. Under the default `capture_text = "titles"` Driga keeps +capture policy first, except the output of commands you approve, described +below. Under the default `capture_text = "titles"` Driga keeps no chat message bodies; `snippets` keeps scrubbed 200-character snippets. Claude's own session history is managed by Claude and is not controlled by Driga's capture policy. Command results join that action log under the same capture policy and -30-day retention. Long command output keeps its head and tail in the -conversation; retained full output is scrubbed and follows the capture -policy (`titles` does not retain command output). Process result counts and +30-day retention. Claude gets the scrubbed head and tail of each command you +approve, up to 2 KB of each, under every capture policy, since reading that +output is why it asked to run the command. Retained full output is scrubbed +and follows the capture policy (`titles` does not retain command output). Process result counts and per-target outcomes appear on the card. External `driga mcp` clients and `driga ask` have no command actions.