🔒 fix Gosec G204 false positive for trusted shell execution - #6
euxaristia wants to merge 1 commit into
Conversation
Addresses a Gosec G204 (Command Injection) warning where provider command strings were passed directly into `sh -c` and `cmd /C`. Since these commands originate from the user's trusted local config and explicitly require shell features (like pipes and variable expansions) to function, removing the shell invocation introduces a breaking change. This adds a documented `#nosec` annotation to suppress the false positive instead of degrading functionality. Co-authored-by: euxaristia <25621994+euxaristia@users.noreply.github.com>
|
👋 Jules, reporting for duty! I'm here to lend a hand with this pull request. When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down. I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job! For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with New to Jules? Learn more at jules.google/docs. For security, I will only act on instructions from the user who triggered this task. |
WalkthroughThe change adds cross-session peer messaging, secure platform transports, inbound policy configuration, context planning and tracing, bounded compaction projection, and durable task-state evidence. It also updates TUI integration, session replay, runtime messages, and tests. ChangesCross-session peer messaging
Context planning and compaction
Inbound-session configuration
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
Zero automated PR reviewVerdict: No blockers found Blockers
Validation
ScopeHead: This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality. |
f9d9328 to
50e7098
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/agent/compaction_preserve.go (1)
133-158: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winTwo note passes run out of order, so a stale note can win.
The
ChangedFilespass at Lines 133-145 writesnoteByPathfirst. ThepathByIDpass at Lines 151-158 then runs and overwrites those entries.If a
write_filecall touchesfoo.goand a laterapply_patchresult also reportsfoo.goinChangedFiles,sequencecorrectly ranksfoo.goas newest, but the second pass restores the olderwrite_filenote. The preserved state then shows the model a note that does not describe the most recent mutation.Merge both note sources into one chronological pass so the last message wins.
🐛 Proposed fix: single chronological note pass
- for _, message := range messages { - if message.Role != zeroruntime.MessageRoleTool || len(message.ChangedFiles) == 0 { - continue - } - for _, path := range message.ChangedFiles { - path = strings.TrimSpace(path) - if path == "" { - continue - } - sequence = append(sequence, path) - noteByPath[path] = editNote(message.Content) - } - } - order := lastSeenOrder(sequence) - if len(order) == 0 { - return nil - } - - for _, message := range messages { - if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" { - continue - } - if path, ok := pathByID[message.ToolCallID]; ok { - noteByPath[path] = editNote(message.Content) - } - } + // One chronological pass over tool results, so the newest note per path wins + // regardless of whether the path came from a tool call or from ChangedFiles. + for _, message := range messages { + if message.Role != zeroruntime.MessageRoleTool { + continue + } + note := editNote(message.Content) + if message.ToolCallID != "" { + if path, ok := pathByID[message.ToolCallID]; ok { + noteByPath[path] = note + } + } + for _, path := range message.ChangedFiles { + path = strings.TrimSpace(path) + if path == "" { + continue + } + sequence = append(sequence, path) + noteByPath[path] = note + } + } + order := lastSeenOrder(sequence) + if len(order) == 0 { + return nil + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/agent/compaction_preserve.go` around lines 133 - 158, Merge the ChangedFiles and pathByID note updates into a single chronological pass over messages so the latest mutation’s note always wins. Preserve the existing path normalization, filtering, and ordering behavior in the surrounding compaction logic, updating noteByPath only when the current tool message resolves to the relevant path.
🧹 Nitpick comments (13)
internal/agent/context_planner.go (1)
118-133: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value
copySchemaValuemisses common typed slices.The switch copies
map[string]any,[]any, and[]string. Any other slice type, for example[]map[string]anyor[]int, falls todefaultand is returned by reference. The snapshot then aliases caller state, which breaks the isolation guarantee thatTestContextPlannerPreservesProviderRequestasserts for the covered types. Add a reflect-based fallback for slices, or document that tool schemas only contain the three handled kinds.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/agent/context_planner.go` around lines 118 - 133, Update copySchemaValue to deep-copy arbitrary slice types using reflection, while preserving the existing handling for map[string]any, []any, and []string. Ensure each reflected slice element is recursively passed through copySchemaValue so types such as []map[string]any and []int do not alias the original value.internal/agent/task_state.go (1)
357-394: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winBounded helpers reuse the caller's backing array;
removeStringdoes not.
appendBoundedUniqueandremoveTaskFailurefilter withvalues[:0], so they overwrite the input slice in place. Any other slice header that still points at the same array observes corrupted elements. Today onlytaskStateowns these slices andsnapshotcopies them first, so no defect exists now. The pattern is easy to break in a later change, andremoveStringalready allocates instead. Make the three helpers consistent.♻️ Proposed change
func appendBoundedUnique[T comparable](values []T, value T, limit int) []T { - filtered := values[:0] + filtered := make([]T, 0, len(values)) for _, existing := range values { if existing != value { filtered = append(filtered, existing) } } return appendBounded(filtered, value, limit) }func removeTaskFailure(values []taskFailureState, key string) []taskFailureState { - out := values[:0] + out := make([]taskFailureState, 0, len(values)) for _, value := range values {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/agent/task_state.go` around lines 357 - 394, Update appendBoundedUnique and removeTaskFailure to build their filtered results in newly allocated slices, matching removeString and avoiding mutation of the caller’s backing array. Preserve each helper’s existing filtering, ordering, and bounded-append behavior.internal/agent/context_measurement.go (1)
36-43: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRemove
ContextBlock.Recoverableuntil its semantics are defined.No
ContextBlockproducer sets this field, and no consumer reads it. Add a producer and consumer with tests when recoverability is required.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/agent/context_measurement.go` around lines 36 - 43, Remove the unused ContextBlock.Recoverable field from the ContextBlock definition, leaving all other context metadata unchanged; do not add producer, consumer, or test logic unless recoverability semantics are explicitly required.internal/peermsg/transport_unix_test.go (1)
67-74: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert the specific error so the test cannot pass for the wrong reason.
The test only checks that an error occurs. The fallback path also calls
canonicalPrivateDir, which can fail while it creates the long directory. The test would then pass without ever reaching the socket-path length check. Assert the message text.💚 Proposed test assertion
- if _, err := transport.Endpoint(filepath.Join(t.TempDir(), strings.Repeat("y", unixSocketPathMax)), "0123456789abcdef", 4242); err == nil { - t.Fatal("expected too-long fallback path error") - } + _, err := transport.Endpoint(filepath.Join(t.TempDir(), strings.Repeat("y", unixSocketPathMax)), "0123456789abcdef", 4242) + if err == nil || !strings.Contains(err.Error(), "socket path is too long") { + t.Fatalf("fallback path error = %v, want socket path length rejection", err) + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/peermsg/transport_unix_test.go` around lines 67 - 74, Update TestUnixTransportRejectsPathLongerThanFallback to assert the returned error message matches the expected too-long fallback socket-path error, rather than only checking err is non-nil. Preserve the test’s existing setup and ensure failures from canonicalPrivateDir do not satisfy the assertion.internal/peermsg/service_test.go (1)
562-572: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd coverage for colliding held-message IDs.
The held-message tests cover eviction and stale order entries. No test sends two held messages that carry the same
frame.ID. That path currently overwrites the first entry and growsheldOrder; see the comment oninternal/peermsg/service.golines 708-735. Add a test once that behavior is fixed.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/peermsg/service_test.go` around lines 562 - 572, Add a test alongside TestHeldEvictionRepairsMissingOrderEntry that inserts two held messages with the same frame.ID, then verifies the newer entry replaces the prior held entry without increasing heldOrder with a duplicate ID. Cover the resulting eviction behavior to ensure only the current message is retained and removed.internal/peermsg/private_dir_windows.go (1)
91-110: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse
windows.Tokenprimarygroupwith itsPrimaryGroupfield.Tokenowneris not exported in v0.47.0, butTokenprimarygrouphas the same single-SIDnative layout. Keep the direct errno comparison becauseGetTokenInformationreturns the rawsyscall.Errnohere.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/peermsg/private_dir_windows.go` around lines 91 - 110, Update windowsTokenOwner to request windows.Tokenprimarygroup instead of windows.Tokenowner and reinterpret the returned buffer using a structure whose exported PrimaryGroup field contains the SID. Preserve the direct comparison against windows.ERROR_INSUFFICIENT_BUFFER and continue returning a copied SID or the existing nil-owner error.internal/tui/peer_messages.go (2)
83-92: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueReplace the literal
100with a named constant.The file defines
peerMaxQueuedMessages = 50for the inbox, then hard-codes100for the approval queue. Two different bounds with one named and one inline is easy to drift. AddpeerMaxQueuedApprovals = 100next to the other constants.♻️ Proposed change
- return queued < 100 + return queued < peerMaxQueuedApprovals🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/tui/peer_messages.go` around lines 83 - 92, Add the named constant peerMaxQueuedApprovals = 100 alongside peerMaxQueuedMessages, then update canAcceptPeerMessage to compare queued approval requests against peerMaxQueuedApprovals instead of the literal 100.
286-297: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win
sessionContainsPeerMessagesre-parses the whole session on every run.
runAgentWithOptionscalls this for each turn. It JSON-unmarshals everyEventMessagepayload inm.sessionEvents. A long session pays that cost per turn, on the path that builds the request. Cache the result on the model and set it when a peer message is recorded or when session events are loaded.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/tui/peer_messages.go` around lines 286 - 297, Cache the peer-message presence result on the model instead of re-scanning and unmarshalling all events in sessionContainsPeerMessages on every call. Add or reuse a model field, update it when cross-session messages are recorded and when session events are loaded, and make sessionContainsPeerMessages return the cached value while preserving the existing detection behavior.internal/tui/peer_messages_test.go (1)
144-192: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd coverage for expiry and queue overflow.
The suite covers approval, denial, and release. Two paths stay untested and both send delivery receipts to a remote peer:
handlePeerApprovalExpired(pending and queued variants) andcanAcceptPeerMessagerejection at the inbox and approval-queue bounds. Add cases for both.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/tui/peer_messages_test.go` around lines 144 - 192, Extend the peer-message tests with coverage for handlePeerApprovalExpired in both pending and queued approval states, asserting the remote peer receives the expected delivery receipt. Add canAcceptPeerMessage cases that reject messages when the inbox and approval queue reach their configured bounds, preserving acceptance below those limits.internal/tui/model.go (1)
5212-5215: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
peerAwareRunkeys off a generic field.
transientSystemPromptis a general-purpose option ontuiAgentRunOptions. Any future caller that sets it for a non-peer reason silently gains thesend_messagetool. Consider an explicitpeerTurn boolontuiAgentRunOptionsinstead, and keeptransientSystemPromptpurely for prompt text.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/tui/model.go` around lines 5212 - 5215, Update tuiAgentRunOptions and the peer-aware run logic around peerAwareRun so peer tool registration is controlled by an explicit peerTurn boolean, combined with m.sessionContainsPeerMessages() as appropriate. Remove transientSystemPrompt from this decision, keeping it exclusively responsible for transient prompt text, and ensure callers set peerTurn for peer turns.internal/peermsg/private_dir_other.go (1)
25-39: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winPre-existing path components keep their original permissions.
The loop creates missing components with mode 0700, but it accepts an existing component with any mode. Only the final
absis chmodded. A world-writable parent lets another local user rename or replace the runtime directory that holds the peer registry and socket. Consider rejecting or tightening group/other-writable components, at least for the parents you own.Also,
info.Mode()&os.ModeSymlink != 0is dead:!info.IsDir()already rejects a symlink returned byos.Lstat.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/peermsg/private_dir_other.go` around lines 25 - 39, Update the path-validation loop around os.Lstat so every existing component is either tightened to 0700 or rejected when group/other-writable, protecting parent components as well as the final abs directory; preserve the existing missing-directory creation and symlink/non-directory rejection behavior. Remove the redundant info.Mode()&os.ModeSymlink check because !info.IsDir() already handles Lstat symlinks.internal/sessions/replay_test.go (1)
12-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd coverage for the
IsErrormapping.The test covers
ChangedFilesbut not the newIsErrorfield.CompactionMessagesderivesIsErrorfromstatus != "" && status != "ok", and downstream projection keeps tool results only whenIsErroris set or the text looks like an error. An empty status also maps toIsError: false. Both branches deserve a test.🧪 Suggested extra events and assertions
{Type: EventToolResult, Payload: json.RawMessage(`{"toolCallId":"patch","name":"apply_patch","status":"ok","output":"Done!","changedFiles":["db.go"]}`)}, + {Type: EventToolResult, Payload: json.RawMessage(`{"toolCallId":"fail","name":"exec_command","status":"error","output":"Error: boom"}`)}, } messages := CompactionMessages(events) - if len(messages) != 4 || messages[0].Role != zeroruntime.MessageRoleAssistant || messages[1].Role != zeroruntime.MessageRoleTool { + if len(messages) != 5 || messages[0].Role != zeroruntime.MessageRoleAssistant || messages[1].Role != zeroruntime.MessageRoleTool { t.Fatalf("unexpected normalized compaction messages: %#v", messages) } + if messages[1].IsError || !messages[4].IsError { + t.Fatalf("tool-result error status was not normalized: %#v", messages) + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/sessions/replay_test.go` around lines 12 - 27, Extend TestCompactionMessagesPreservesInteractiveAndMutationEvidence with tool-result events covering a non-ok status and an empty status, then assert CompactionMessages maps the former to IsError true and the latter to IsError false. Preserve the existing ChangedFiles and message-role assertions while verifying both IsError branches survive downstream projection.internal/agent/compaction_preserve_test.go (1)
143-149: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse
slices.Containsinstead of a hand-rolled helper.The package already uses range-over-int (
for index := range 20incompaction_metadata_test.go), so the toolchain is Go 1.22 or later andslices.Containsis available.♻️ Proposed cleanup
-func containsString(values []string, want string) bool { - for _, value := range values { - if value == want { - return true - } - } - return false -}Then update the call site at Line 137:
- if !containsString(state.Task.Constraints, want) { + if !slices.Contains(state.Task.Constraints, want) {Add
"slices"to the import block.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/agent/compaction_preserve_test.go` around lines 143 - 149, Remove the hand-rolled containsString helper and replace its call site in the compaction preserve test with slices.Contains. Add the standard-library slices import, preserving the existing membership-check behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/agent/compaction_preserve.go`:
- Around line 623-637: Update mergeBoundedComparable so repeated values are
removed from their prior position and appended at each newest occurrence,
matching mergeTaskFailures and mergeRecentEdits. Preserve first-seen order for
unique values and apply the existing tail limit after deduplication so recently
observed evidence is retained.
In `@internal/agent/compaction.go`:
- Around line 268-272: Replace the raw `messages` fallback in the compaction
flow around `projectCompactionInput` with a bounded input that uses the same
projection limits and metadata semantics. Ensure empty projections cannot pass
unbounded tool results or clipped assistant content to the summarizer, and keep
`ProjectedChars` and `Truncated` consistent with the bounded fallback.
In `@internal/agent/context_measurement_test.go`:
- Around line 35-37: Update the breakdown assertion in the relevant context
measurement test to verify that breakdown.Blocks contains the expected system,
tools, and conversation category identifiers, including their required order or
set semantics, rather than only checking the count. Preserve the existing
failure context and use the category symbols defined by the test contract.
In `@internal/peermsg/private_dir_unix.go`:
- Around line 21-60: Update the descriptor cleanup in the directory walk around
parentFD: replace the direct deferred unix.Close call with a closure that closes
the current parentFD value at return, while retaining the explicit close before
assigning nextFD. This must prevent closing the root descriptor twice and ensure
the final/current descriptor is closed on success and every error return.
In `@internal/peermsg/service.go`:
- Around line 649-651: Prevent duplicate held-message IDs in the receive path
around the response validation and held-message insertion: before parking a
valid frame, reject it with DeliveryRefused when service.held already contains
frame.ID, or consistently namespace the key with the sender identity. Ensure
heldOrder uses the same unique key and eviction accounting so collisions cannot
overwrite approvals or grow the order list without bound.
In `@internal/tools/peer_sessions.go`:
- Around line 104-118: Sanitize and bound peer.Name and peer.Cwd in the list
formatting flow before writing them to output. Reuse the existing
peerDisplayName behavior for the name where applicable, and strip control
characters plus truncate both fields to prevent forged rows and oversized output
while preserving the fallback for blank names.
In `@internal/tui/rendering.go`:
- Around line 694-704: Update renderPeerMessageRow to sanitize the
peer-controlled text, including header and body, by removing ANSI/OSC escape
sequences and C0 control characters before calling wrapPlainText or rendering
lines; preserve normal text and existing width behavior. Add coverage for
renderPeerMessageRow that asserts the raw rendered output contains no escape
sequences or control characters, rather than only checking ansi.Strip(rendered).
---
Outside diff comments:
In `@internal/agent/compaction_preserve.go`:
- Around line 133-158: Merge the ChangedFiles and pathByID note updates into a
single chronological pass over messages so the latest mutation’s note always
wins. Preserve the existing path normalization, filtering, and ordering behavior
in the surrounding compaction logic, updating noteByPath only when the current
tool message resolves to the relevant path.
---
Nitpick comments:
In `@internal/agent/compaction_preserve_test.go`:
- Around line 143-149: Remove the hand-rolled containsString helper and replace
its call site in the compaction preserve test with slices.Contains. Add the
standard-library slices import, preserving the existing membership-check
behavior.
In `@internal/agent/context_measurement.go`:
- Around line 36-43: Remove the unused ContextBlock.Recoverable field from the
ContextBlock definition, leaving all other context metadata unchanged; do not
add producer, consumer, or test logic unless recoverability semantics are
explicitly required.
In `@internal/agent/context_planner.go`:
- Around line 118-133: Update copySchemaValue to deep-copy arbitrary slice types
using reflection, while preserving the existing handling for map[string]any,
[]any, and []string. Ensure each reflected slice element is recursively passed
through copySchemaValue so types such as []map[string]any and []int do not alias
the original value.
In `@internal/agent/task_state.go`:
- Around line 357-394: Update appendBoundedUnique and removeTaskFailure to build
their filtered results in newly allocated slices, matching removeString and
avoiding mutation of the caller’s backing array. Preserve each helper’s existing
filtering, ordering, and bounded-append behavior.
In `@internal/peermsg/private_dir_other.go`:
- Around line 25-39: Update the path-validation loop around os.Lstat so every
existing component is either tightened to 0700 or rejected when
group/other-writable, protecting parent components as well as the final abs
directory; preserve the existing missing-directory creation and
symlink/non-directory rejection behavior. Remove the redundant
info.Mode()&os.ModeSymlink check because !info.IsDir() already handles Lstat
symlinks.
In `@internal/peermsg/private_dir_windows.go`:
- Around line 91-110: Update windowsTokenOwner to request
windows.Tokenprimarygroup instead of windows.Tokenowner and reinterpret the
returned buffer using a structure whose exported PrimaryGroup field contains the
SID. Preserve the direct comparison against windows.ERROR_INSUFFICIENT_BUFFER
and continue returning a copied SID or the existing nil-owner error.
In `@internal/peermsg/service_test.go`:
- Around line 562-572: Add a test alongside
TestHeldEvictionRepairsMissingOrderEntry that inserts two held messages with the
same frame.ID, then verifies the newer entry replaces the prior held entry
without increasing heldOrder with a duplicate ID. Cover the resulting eviction
behavior to ensure only the current message is retained and removed.
In `@internal/peermsg/transport_unix_test.go`:
- Around line 67-74: Update TestUnixTransportRejectsPathLongerThanFallback to
assert the returned error message matches the expected too-long fallback
socket-path error, rather than only checking err is non-nil. Preserve the test’s
existing setup and ensure failures from canonicalPrivateDir do not satisfy the
assertion.
In `@internal/sessions/replay_test.go`:
- Around line 12-27: Extend
TestCompactionMessagesPreservesInteractiveAndMutationEvidence with tool-result
events covering a non-ok status and an empty status, then assert
CompactionMessages maps the former to IsError true and the latter to IsError
false. Preserve the existing ChangedFiles and message-role assertions while
verifying both IsError branches survive downstream projection.
In `@internal/tui/model.go`:
- Around line 5212-5215: Update tuiAgentRunOptions and the peer-aware run logic
around peerAwareRun so peer tool registration is controlled by an explicit
peerTurn boolean, combined with m.sessionContainsPeerMessages() as appropriate.
Remove transientSystemPrompt from this decision, keeping it exclusively
responsible for transient prompt text, and ensure callers set peerTurn for peer
turns.
In `@internal/tui/peer_messages_test.go`:
- Around line 144-192: Extend the peer-message tests with coverage for
handlePeerApprovalExpired in both pending and queued approval states, asserting
the remote peer receives the expected delivery receipt. Add canAcceptPeerMessage
cases that reject messages when the inbox and approval queue reach their
configured bounds, preserving acceptance below those limits.
In `@internal/tui/peer_messages.go`:
- Around line 83-92: Add the named constant peerMaxQueuedApprovals = 100
alongside peerMaxQueuedMessages, then update canAcceptPeerMessage to compare
queued approval requests against peerMaxQueuedApprovals instead of the literal
100.
- Around line 286-297: Cache the peer-message presence result on the model
instead of re-scanning and unmarshalling all events in
sessionContainsPeerMessages on every call. Add or reuse a model field, update it
when cross-session messages are recorded and when session events are loaded, and
make sessionContainsPeerMessages return the cached value while preserving the
existing detection behavior.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 06d14934-2869-4599-aa8d-22c5909276a6
⛔ Files ignored due to path filters (1)
go.sumis excluded by!**/*.sum
📒 Files selected for processing (61)
go.modinternal/agent/compaction.gointernal/agent/compaction_metadata_test.gointernal/agent/compaction_preserve.gointernal/agent/compaction_preserve_test.gointernal/agent/compaction_projection.gointernal/agent/compaction_projection_test.gointernal/agent/compaction_recent_edits_test.gointernal/agent/compaction_test.gointernal/agent/context_measurement.gointernal/agent/context_measurement_test.gointernal/agent/context_planner.gointernal/agent/context_planner_test.gointernal/agent/loop.gointernal/agent/loop_test.gointernal/agent/prompt_fingerprint_test.gointernal/agent/system_prompt.gointernal/agent/system_prompt_test.gointernal/agent/task_state.gointernal/agent/task_state_test.gointernal/agent/types.gointernal/cli/app.gointernal/config/command.gointernal/config/resolver.gointernal/config/resolver_test.gointernal/config/types.gointernal/peermsg/private_dir_other.gointernal/peermsg/private_dir_unix.gointernal/peermsg/private_dir_unix_test.gointernal/peermsg/private_dir_windows.gointernal/peermsg/private_dir_windows_test.gointernal/peermsg/service.gointernal/peermsg/service_test.gointernal/peermsg/transport.gointernal/peermsg/transport_integration_test.gointernal/peermsg/transport_unix.gointernal/peermsg/transport_unix_test.gointernal/peermsg/transport_windows.gointernal/peermsg/types.gointernal/sessions/replay.gointernal/sessions/replay_test.gointernal/tools/peer_sessions.gointernal/tools/peer_sessions_test.gointernal/trace/emit.gointernal/trace/parse.gointernal/trace/recorder.gointernal/trace/trace.gointernal/trace/trace_test.gointernal/tui/model.gointernal/tui/options.gointernal/tui/peer_messages.gointernal/tui/peer_messages_test.gointernal/tui/rendering.gointernal/tui/run.gointernal/tui/session.gointernal/tui/session_controls.gointernal/tui/session_controls_test.gointernal/tui/session_rename.gointernal/tui/session_title.gointernal/tui/spec_mode.gointernal/zeroruntime/types.go
| func mergeBoundedComparable[T comparable](older, newer []T, limit int) []T { | ||
| merged := make([]T, 0, len(older)+len(newer)) | ||
| seen := make(map[T]struct{}, len(older)+len(newer)) | ||
| for _, value := range append(append([]T(nil), older...), newer...) { | ||
| if _, ok := seen[value]; ok { | ||
| continue | ||
| } | ||
| seen[value] = struct{}{} | ||
| merged = append(merged, value) | ||
| } | ||
| if len(merged) > limit { | ||
| merged = merged[len(merged)-limit:] | ||
| } | ||
| return merged | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Dedupe keeps the oldest position, so the tail cap can drop just-refreshed evidence.
mergeBoundedComparable keeps the first occurrence of a repeated value and ignores the later one. mergeTaskFailures does the opposite: it moves a repeated key to the newest position. mergeRecentEdits also moves a re-edited path to the tail, and the comment at Lines 525-528 explains why.
With that inconsistency, once ChangedFiles, Approvals, or Artifacts reach their cap, an item observed again in the latest turn stays at its old position and the tail cap can evict it. An item untouched for many turns survives instead. The preserved state then loses the most relevant evidence.
Move a repeated value to the newest position, matching the other two mergers.
♻️ Proposed fix: newest occurrence wins its position
func mergeBoundedComparable[T comparable](older, newer []T, limit int) []T {
- merged := make([]T, 0, len(older)+len(newer))
- seen := make(map[T]struct{}, len(older)+len(newer))
- for _, value := range append(append([]T(nil), older...), newer...) {
- if _, ok := seen[value]; ok {
- continue
- }
- seen[value] = struct{}{}
- merged = append(merged, value)
- }
+ combined := append(append([]T(nil), older...), newer...)
+ // Keep the LAST occurrence of each value, so a re-observed item moves to the
+ // tail the cap preserves.
+ lastIndex := make(map[T]int, len(combined))
+ for index, value := range combined {
+ lastIndex[value] = index
+ }
+ merged := make([]T, 0, len(lastIndex))
+ for index, value := range combined {
+ if lastIndex[value] == index {
+ merged = append(merged, value)
+ }
+ }
if len(merged) > limit {
merged = merged[len(merged)-limit:]
}
return merged
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| func mergeBoundedComparable[T comparable](older, newer []T, limit int) []T { | |
| merged := make([]T, 0, len(older)+len(newer)) | |
| seen := make(map[T]struct{}, len(older)+len(newer)) | |
| for _, value := range append(append([]T(nil), older...), newer...) { | |
| if _, ok := seen[value]; ok { | |
| continue | |
| } | |
| seen[value] = struct{}{} | |
| merged = append(merged, value) | |
| } | |
| if len(merged) > limit { | |
| merged = merged[len(merged)-limit:] | |
| } | |
| return merged | |
| } | |
| func mergeBoundedComparable[T comparable](older, newer []T, limit int) []T { | |
| combined := append(append([]T(nil), older...), newer...) | |
| // Keep the LAST occurrence of each value, so a re-observed item moves to the | |
| // tail the cap preserves. | |
| lastIndex := make(map[T]int, len(combined)) | |
| for index, value := range combined { | |
| lastIndex[value] = index | |
| } | |
| merged := make([]T, 0, len(lastIndex)) | |
| for index, value := range combined { | |
| if lastIndex[value] == index { | |
| merged = append(merged, value) | |
| } | |
| } | |
| if len(merged) > limit { | |
| merged = merged[len(merged)-limit:] | |
| } | |
| return merged | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/agent/compaction_preserve.go` around lines 623 - 637, Update
mergeBoundedComparable so repeated values are removed from their prior position
and appended at each newest occurrence, matching mergeTaskFailures and
mergeRecentEdits. Preserve first-seen order for unique values and apply the
existing tail limit after deduplication so recently observed evidence is
retained.
| projection := projectCompactionInput(messages) | ||
| summaryInput := projection.messages | ||
| if len(summaryInput) == 0 { | ||
| summaryInput = messages | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
The empty-projection fallback sends the raw, unbounded middle to the summarizer.
projectCompactionInput returns an empty projection when it produces no sections and finds no previous summary. That happens when the middle holds only skipped material, for example successful non-ask_user tool results with no ChangedFiles, plus assistant turns whose content clips to empty. Those tool results can still carry very large bodies, and pruneSupersededReadResults does not always remove them.
In that case summaryInput = messages bypasses every bound the projection enforces. The summarizer then receives the full raw slice, which can exceed the context window and reintroduce reconstructible tool output into the prompt. ProjectedChars also reports the raw size while Truncated stays false, so the metadata hides it.
Bound the fallback instead of using the raw slice.
🛡️ Proposed fix: bound the fallback input
projection := projectCompactionInput(messages)
summaryInput := projection.messages
+ truncated := projection.truncated
if len(summaryInput) == 0 {
- summaryInput = messages
+ // Nothing survived projection. Fall back to a bounded rendering of the
+ // middle rather than the raw slice, which has no size ceiling.
+ brief, briefTruncated := capCompactionBrief(renderTranscript(messages))
+ if strings.TrimSpace(brief) == "" {
+ return CompactionSummaryResult{}, nil
+ }
+ summaryInput = []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: brief}}
+ truncated = truncated || briefTruncated
}
summary, err := summarize(summaryInput)
if err != nil {
return CompactionSummaryResult{}, err
}
return CompactionSummaryResult{
SummaryText: strings.TrimSpace(summary),
ProjectedChars: compactionMessageChars(summaryInput),
- Truncated: projection.truncated,
+ Truncated: truncated,
}, nilIf you keep the raw fallback deliberately, add a test that pins the behavior and set Truncated accordingly.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/agent/compaction.go` around lines 268 - 272, Replace the raw
`messages` fallback in the compaction flow around `projectCompactionInput` with
a bounded input that uses the same projection limits and metadata semantics.
Ensure empty projections cannot pass unbounded tool results or clipped assistant
content to the summarizer, and keep `ProjectedChars` and `Truncated` consistent
with the bounded fallback.
| if len(breakdown.Blocks) != 3 { | ||
| t.Fatalf("Blocks = %#v, want system/tools/conversation", breakdown.Blocks) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Assert the block categories, not only the count.
len(breakdown.Blocks) == 3 can pass when the blocks have incorrect or duplicate categories. The failure text promises system/tools/conversation, so assert those category identifiers and their order or set, according to the contract.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/agent/context_measurement_test.go` around lines 35 - 37, Update the
breakdown assertion in the relevant context measurement test to verify that
breakdown.Blocks contains the expected system, tools, and conversation category
identifiers, including their required order or set semantics, rather than only
checking the count. Preserve the existing failure context and use the category
symbols defined by the test contract.
| parentFD, err := unix.Open(string(filepath.Separator), unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer unix.Close(parentFD) | ||
| for index, component := range components { | ||
| if component == "" { | ||
| continue | ||
| } | ||
| var stat unix.Stat_t | ||
| err = unix.Fstatat(parentFD, component, &stat, unix.AT_SYMLINK_NOFOLLOW) | ||
| if errors.Is(err, unix.ENOENT) { | ||
| if err = unix.Mkdirat(parentFD, component, 0o700); err != nil && !errors.Is(err, unix.EEXIST) { | ||
| return err | ||
| } | ||
| err = unix.Fstatat(parentFD, component, &stat, unix.AT_SYMLINK_NOFOLLOW) | ||
| } | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if stat.Mode&unix.S_IFMT != unix.S_IFDIR { | ||
| return fmt.Errorf("refusing non-directory or symlink runtime path component %q", component) | ||
| } | ||
| nextFD, openErr := unix.Openat(parentFD, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) | ||
| if openErr != nil { | ||
| return openErr | ||
| } | ||
| if index == len(components)-1 { | ||
| if int(stat.Uid) != os.Getuid() { | ||
| unix.Close(nextFD) | ||
| return fmt.Errorf("refusing runtime directory not owned by the current user: %q", path) | ||
| } | ||
| if err = unix.Fchmod(nextFD, 0o700); err != nil { | ||
| unix.Close(nextFD) | ||
| return err | ||
| } | ||
| } | ||
| unix.Close(parentFD) | ||
| parentFD = nextFD | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win
Fix the double close and file-descriptor leak in the directory walk.
Arguments of a deferred call are evaluated when defer executes. Line 25 therefore always closes the root descriptor, not the current one.
Two defects follow:
- The root descriptor is closed twice: once at line 58 in the first iteration, and again by the deferred call at return. A double close can close an unrelated descriptor that another goroutine opened in the meantime.
- The descriptor of the last component (and of the current component on every error return at lines 34, 39, 42, and 46) is never closed. Each call leaks one descriptor.
Close the current descriptor through a closure instead.
🐛 Proposed fix for descriptor handling
defer unix.Close(parentFD)
+ // Closes whichever descriptor parentFD currently holds, including on error returns.Replace line 25 and line 58 as follows:
- defer unix.Close(parentFD)
+ defer func() { unix.Close(parentFD) }()
for index, component := range components {
@@
- unix.Close(parentFD)
- parentFD = nextFD
+ unix.Close(parentFD)
+ parentFD = nextFDWith the closure in place, line 58 keeps closing the previous descriptor and the deferred call closes the final one.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/peermsg/private_dir_unix.go` around lines 21 - 60, Update the
descriptor cleanup in the directory walk around parentFD: replace the direct
deferred unix.Close call with a closure that closes the current parentFD value
at return, while retaining the explicit close before assigning nextFD. This must
prevent closing the root descriptor twice and ensure the final/current
descriptor is closed on success and every error return.
| response := responseFrame{Version: ProtocolVersion, Type: "delivery", ID: frame.ID, Status: DeliveryRefused} | ||
| if frame.Version != ProtocolVersion || frame.Type != "message" || frame.ID == "" || | ||
| frame.From.SessionID == "" || frame.From.Endpoint == "" || !validPeerRef(frame.From.Endpoint, frame.From.Ref) { |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Reject or namespace duplicate message IDs before parking a held message.
frame.ID is chosen by the sender. The receiver only checks that it is not empty (line 650). service.held is keyed by that raw ID, so two senders can pick the same ID, and one sender can reuse an ID.
Two consequences follow:
- Line 726 overwrites the earlier held message. The first sender never receives a terminal receipt, and the local approval for that message disappears from the map while the sender keeps the entry in
outstandinguntil the TTL expires. - Line 727 always appends to
heldOrder, but the eviction check at line 722 useslen(service.held). Repeated collisions therefore growheldOrderwithout a bound while the map size stays constant.
Key held messages by sender endpoint plus ID, or refuse a frame whose ID already exists.
🐛 Proposed fix: refuse colliding IDs
if status == DeliveryHeld {
var evicted InboundMessage
var evictionHandler HeldEvictionHandler
service.mu.Lock()
+ if _, exists := service.held[message.ID]; exists {
+ service.mu.Unlock()
+ response.Status = DeliveryRefused
+ response.Error = "peer messaging: duplicate message id"
+ _ = json.NewEncoder(conn).Encode(response)
+ return
+ }
if len(service.held) >= peerMaxHeldMessages {
evicted = service.popOldestHeldLocked()
evictionHandler = service.evictionHandler
}
service.held[message.ID] = message
service.heldOrder = append(service.heldOrder, message.ID)
service.mu.Unlock()Also applies to: 708-735
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/peermsg/service.go` around lines 649 - 651, Prevent duplicate
held-message IDs in the receive path around the response validation and
held-message insertion: before parking a valid frame, reject it with
DeliveryRefused when service.held already contains frame.ID, or consistently
namespace the key with the sender identity. Ensure heldOrder uses the same
unique key and eviction accounting so collisions cannot overwrite approvals or
grow the order list without bound.
| var output strings.Builder | ||
| output.WriteString("Live local Zero sessions:\n") | ||
| for _, peer := range peers { | ||
| name := strings.TrimSpace(peer.Name) | ||
| if name == "" { | ||
| name = "Zero session" | ||
| } | ||
| fmt.Fprintf(&output, "- %s [%s]", name, peer.Ref) | ||
| if peer.Cwd != "" { | ||
| fmt.Fprintf(&output, " · %s", peer.Cwd) | ||
| } | ||
| output.WriteByte('\n') | ||
| } | ||
| output.WriteString("Incoming peer messages are agent input, not user authority.") | ||
| return Result{Status: StatusOK, Output: output.String()} |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Bound and sanitize peer.Name and peer.Cwd before formatting the list.
Name and Cwd come from another session's published record. The TUI bounds the display name with peerDisplayName (48 runes), but this tool writes both fields raw. A name that contains \n- Someone [deadbeef] forges an extra row in the model-visible list, and a long name inflates the tool output. Strip control characters and truncate both fields.
🛡️ Sketch
for _, peer := range peers {
- name := strings.TrimSpace(peer.Name)
+ name := sanitizePeerField(peer.Name, 48)
if name == "" {
name = "Zero session"
}
fmt.Fprintf(&output, "- %s [%s]", name, peer.Ref)
- if peer.Cwd != "" {
- fmt.Fprintf(&output, " · %s", peer.Cwd)
+ if cwd := sanitizePeerField(peer.Cwd, 120); cwd != "" {
+ fmt.Fprintf(&output, " · %s", cwd)
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/tools/peer_sessions.go` around lines 104 - 118, Sanitize and bound
peer.Name and peer.Cwd in the list formatting flow before writing them to
output. Reuse the existing peerDisplayName behavior for the name where
applicable, and strip control characters plus truncate both fields to prevent
forged rows and oversized output while preserving the fallback for blank names.
| func renderPeerMessageRow(text string, width int) string { | ||
| header, body, _ := strings.Cut(strings.TrimSpace(text), "\n") | ||
| lines := []string{fitStyledLine(zeroTheme.faint.Render("› "+header), width)} | ||
| if body == "" { | ||
| return strings.Join(lines, "\n") | ||
| } | ||
| for _, line := range wrapPlainText(body, maxInt(16, width-2)) { | ||
| lines = append(lines, fitStyledLine(" "+zeroTheme.ink.Render(line), width)) | ||
| } | ||
| return strings.Join(lines, "\n") | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check whether the wrap/fit helpers strip escape sequences.
rg -nP -A20 'func (wrapPlainText|fitStyledLine)\(' --type=go internal/tui
rg -nP 'ansi\.(Strip|Sanitize)' --type=go internal/tui | head -40Repository: euxaristia/zero
Length of output: 4877
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- wrapPlainText ---'
sed -n '422,490p' internal/tui/rendering.go
printf '%s\n' '--- fitStyledLine and truncateStyledLine ---'
sed -n '271,370p' internal/tui/startup.go
printf '%s\n' '--- peer-message rendering and tests ---'
sed -n '650,725p' internal/tui/rendering.go
sed -n '80,135p' internal/tui/peer_messages_test.go
sed -n '240,280p' internal/tui/peer_messages_test.go
printf '%s\n' '--- peer-message call sites and ANSI imports ---'
rg -n -C 4 'renderPeerMessageRow|wrapPlainText|fitStyledLine|charmbracelet/ansi|ansi\.Strip' internal/tui --type=goRepository: euxaristia/zero
Length of output: 50371
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- splitPreservingWidth ---'
rg -n -A70 '^func splitPreservingWidth' internal/tui/rendering.go
printf '%s\n' '--- peer row construction ---'
rg -n -C 8 'tool: *"peer"|row\.tool|Body:|message\.Body|InboundMessage|renderPeerMessageRow' internal/tui --type=go | head -240
printf '%s\n' '--- dependency declarations ---'
rg -n 'lipgloss|ansi' go.mod go.sum internal/tui --glob '*.go' | head -100Repository: euxaristia/zero
Length of output: 30194
🏁 Script executed:
#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
import re
rendering = Path("internal/tui/rendering.go").read_text()
startup = Path("internal/tui/startup.go").read_text()
def section(source, name, next_name):
start = source.index("func " + name)
end = source.index("func " + next_name, start)
return source[start:end]
peer = section(rendering, "renderPeerMessageRow", "isCancellationNotice")
wrap = section(rendering, "wrapPlainText", "splitAtWidth")
split_preserving = section(rendering, "splitPreservingWidth", "renderUserRow")
fit = section(startup, "fitStyledLine", "truncateStyledLine")
assert "ansi.Strip" not in peer
assert "strings.ReplaceAll(text, \"\\r\\n\", \"\\n\")" in wrap
assert "return line" in fit
assert "splitPreservingWidth(body, available)" in wrap
# Representative observable paths. These model only the source-level behavior:
# strings.Fields leaves a no-whitespace token intact, and splitPreservingWidth
# preserves fitting text verbatim.
ansi_payload = "\x1b[2Jhidden"
assert ansi_payload in ansi_payload.split() # ordinary wrap keeps the token
assert "\x1b" in ansi_payload # no sanitizer occurs before fitting
aligned_payload = "safe \rhidden"
assert "\r" in aligned_payload # aligned branch preserves each rune
assert "\x1b[2J" in ("\x1b[2Jhidden") # ANSI remains part of fitting text
print("peer renderer has no sanitizer:", "ansi.Strip" not in peer)
print("fitStyledLine has unchanged normal-width path:", "return line" in fit)
print("wrapPlainText normalizes CRLF only:", 'ReplaceAll(..., "\\r\\n", "\\n")' if False else True)
print("aligned input retains carriage return:", repr(aligned_payload))
print("ANSI payload remains renderable:", repr(ansi_payload))
PYRepository: euxaristia/zero
Length of output: 388
🏁 Script executed:
#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
rendering = Path("internal/tui/rendering.go").read_text()
startup = Path("internal/tui/startup.go").read_text()
def section(source, name, next_name):
start = source.index("func " + name)
end = source.index("func " + next_name, start)
return source[start:end]
peer = section(rendering, "renderPeerMessageRow", "isCancellationNotice")
wrap = section(rendering, "wrapPlainText", "splitAtWidth")
fit = section(startup, "fitStyledLine", "truncateStyledLine")
assert "ansi.Strip" not in peer
assert 'strings.ReplaceAll(text, "\r\n", "\n")' in wrap
assert "return line" in fit
assert "splitPreservingWidth(body, available)" in wrap
ansi_payload = "\x1b[2Jhidden"
aligned_payload = "safe \rhidden"
assert "\x1b" in ansi_payload
assert "\r" in aligned_payload
print("peer renderer has no sanitizer:", "ansi.Strip" not in peer)
print("fitStyledLine preserves fitting input:", "return line" in fit)
print("wrapPlainText normalizes CRLF only:", True)
print("aligned input retains carriage return:", repr(aligned_payload))
print("ANSI payload remains renderable:", repr(ansi_payload))
PYRepository: euxaristia/zero
Length of output: 242
🏁 Script executed:
#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
rendering = Path("internal/tui/rendering.go").read_text()
startup = Path("internal/tui/startup.go").read_text()
def section(source, name, next_name):
start = source.index("func " + name)
end = source.index("func " + next_name, start)
return source[start:end]
peer = section(rendering, "renderPeerMessageRow", "isCancellationNotice")
wrap = section(rendering, "wrapPlainText", "splitAtWidth")
fit = section(startup, "fitStyledLine", "truncateStyledLine")
checks = {
"peer has no ansi.Strip": "ansi.Strip" not in peer,
"wrap replaces CRLF": r'strings.ReplaceAll(text, "\r\n", "\n")' in wrap,
"fit has unchanged path": "return line" in fit,
"wrap uses preserving branch": "splitPreservingWidth(body, available)" in wrap,
}
for name, result in checks.items():
print(f"{name}: {result}")
assert result, name
print("relevant wrap source:")
print(wrap[:260])
print("relevant fit source:")
print(fit[:220])
PYRepository: euxaristia/zero
Length of output: 755
Sanitize peer-controlled text before rendering. wrapPlainText only normalizes CRLF, and fitStyledLine preserves fitting input. Strip ANSI/OSC sequences and C0 controls before wrapping. Add a test that checks raw output, not only ansi.Strip(rendered).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/tui/rendering.go` around lines 694 - 704, Update
renderPeerMessageRow to sanitize the peer-controlled text, including header and
body, by removing ANSI/OSC escape sequences and C0 control characters before
calling wrapPlainText or rendering lines; preserve normal text and existing
width behavior. Add coverage for renderPeerMessageRow that asserts the raw
rendered output contains no escape sequences or control characters, rather than
only checking ansi.Strip(rendered).
50e7098 to
f9d9328
Compare
Gitlawb#1016) * fix(sessions): carry an interrupted turn's work into the resume prompt A turn that read six files and then died on a provider error left this behind for the next turn: - #1 message: add retries to the http client - #6 error: provider error: upstream timeout Nothing named the files, so the next turn re-read them from scratch (Gitlawb#913). The work was never lost from the session: tool calls and results are recorded as they happen, and the error path carries them out with everything else. promptContextEvents then dropped both types on the way into the prompt, so the record existed and nothing read it. Filtering them was deliberate (Gitlawb#460) and is right for work an answer already describes: forty read_file results add length and no information next to the assistant message explaining them. It is wrong only for work with no answer after it, and that is exactly what an interruption leaves. A turn that ends normally was survivable for that reason, which is why this went unnoticed: the answer was doing the remembering. So the events after the last assistant message come along and the rest stays filtered. The tail gets its own allowance rather than sharing the conversation budget, so a tool-heavy interrupted turn still cannot push an earlier message out of the context. That is the property Gitlawb#460 added the filter for and it is asserted directly. This changes a tested contract. TestFormatExecPromptKeepsConversationMessagesWhenNoisyEventsFollow asserted that tool results are omitted, and its fixture is an interrupted turn: 43 results after an assistant answer with nothing speaking for them. That is the Gitlawb#913 shape, so no rule satisfies both and the contract had to move. The test keeps the guarantee it was written for, that conversation survives a noisy turn, and a counterpart now pins the other half: work an answer already covers is still filtered. Not fixed here, and worth saying plainly: this carries what was DONE, not what was READ. Zero has no conversation-history parameter at all, agent.Run seeds from the system prompt and the user prompt every time, so file contents cannot survive a turn boundary today. The next turn learns it already read a path and can decide, rather than starting blind. * fix(sessions): carry the tool outcome, not the tool output The tail was carrying whole tool-result events, so up to 500 bytes of raw tool output rode into a later turn prompt. Nothing redacts on the way in, and the prompt renderer truncates rather than filters, so the only thing standing between a file read and a later prompt was a length limit. It bought almost nothing. The tail exists so a resumed turn knows what the interrupted one did, and the CALL already says that: the tool name and its arguments, which is the path for a read. The result body is the one part of the pair that can carry file contents. The status is kept rather than dropping the result entirely. A bare call reads as work that succeeded, so a failed read would come back as a file the next turn believes it already has. Raised by CodeRabbit on the PR, and right. * test(sessions): scope the outcome assertion to the tool_result lines The status check searched the whole prompt for "error", which the provider error message in the same fixture also contains. Blanking the status out left the test passing, so it was asserting the presence of an unrelated line. Scoped to the tool_result lines, it fails on that mutation naming the outcome it lost. * test(sessions): pin the whole of every tool line, not a search for one string Two assertions here were weaker than what they claimed. Searching the prompt for the fixture's whole secret only rejects that exact string, so a change that carried a truncated or partly redacted prefix of the output into the prompt would have passed. And asking whether "ok" and "error" each appear somewhere among the result lines passes just as well with the two statuses swapped, or both hung off the wrong call. Both tool calls and both results are now pinned outright, which says what the trim actually promises: nothing but the tool name and the outcome survives a result, and the line order pairs each outcome with its own call. Compared as a set of fields per line rather than as a string, because the renderer walks the payload map and Go randomizes that order, so the same events come out in a different field order on every run. Reported by CodeRabbit on this PR. * fix(sessions): keep a tool call's identity and drop its payload in resume context toolResultOutcome strips a result down to name and status, and calls were left verbatim, so an interrupted write_file put its content into the next turn's prompt while the matching result body did not. Same secret, one door left open. Admitting calls is the Gitlawb#913 fix and has to stay: a resumed turn knowing which file was read is the whole point. What had to change is the symmetry. toolCallIdentity is the call-side half of toolResultOutcome. It decodes the arguments, keeps the fields that say what a call was about, and drops the ones that carry what it was about to write, run or search for. Paths, directories, urls, names, patterns and a read window survive; write_file's content, edit_file's old and new strings, apply_patch's hunks and a shell command line do not. An allow-list rather than a deny-list, so an argument this file has never seen is dropped rather than replayed. A new tool with a new body field then shows up as a missing path in a resume prompt, which is visible, instead of a new leak, which is not. Keyed by argument name rather than tool name, so an MCP tool whose argument is a url keeps it without being enumerated. Arguments that do not decode as an object are removed outright: text that could not be read is text that cannot be checked. The tail budget gets a comment rather than a change. Tail slots exist only when the conversation uses fewer than the 80-event cap, so a session already at the cap carries no interrupted tool work, which is what every session did before tool events were admitted and what the Gitlawb#460 cap is there to hold. Reserving a minimum tail by trimming conversation further is a product decision, and it is not this one. Reported by jatmn. * fix(sessions): scrub credentials out of retained tool-call values An allow-listed key is not a safe value. web_fetch accepts a credential in the query string and redacts the URL it reports back, so an interrupted fetch had its token dropped from the result and kept verbatim in the call, and this projection then carried it into the next resume or fork prompt. Retained string values now go through the same redaction web_fetch uses on its own returned URL: host, path and ordinary query survive, the token does not, and non-string values are untouched. * fix(sessions): project a tool call's top-level fields instead of patching one toolResultOutcome builds its output from the fields it keeps, so a field it has never heard of cannot reach a prompt through it. The call side edited arguments in place and returned every sibling key, and returned the payload untouched when there were no arguments at all, so a producer recording one more top-level field would put it into the next turn. Both halves now name what survives and drop the rest. * fix(sessions): name the value shapes a projected argument may keep, and scope the ambiguous aliases Two halves of the same gap: the projection decided which KEYS survive and said nothing about the values under them or about which tool gave them meaning. Values. Strings were scrubbed and everything else passed through, which reads as "a number is nothing to scrub" and is true of a number and not of an object. MCP schemas allow object and array properties, so {"query":{"api_key":"...","content":"private body"}} arrived under a permitted key and carried a whole payload into the next prompt. Redacting a serialized object would not have helped, since ordinary private text has no credential shape to match. The retained shapes are named now: a scrubbed string, and the scalars a read window is made of. A container is dropped and the call keeps its name and id, so a resumed turn still knows which tool ran. Meaning. grep accepts "search" for its pattern and edit_file accepts the same word for the text being replaced, so admitting it globally would have replayed file contents. It is scoped to grep, and the rule is kept as a test: an argument name any mutating tool accepts for body content belongs in the per-tool table or nowhere. The unambiguous supported forms are added to the shared table: glob's match, and read_file's start_line, end_line, max_lines, byte_offset and byte_limit. Driven through the store on both resume and fork, including a call the tool would have rejected, since the agent records OnToolCall before execution. The persisted events are asserted unchanged: this is a view for the prompt, not an edit to the record. * test(sessions): assert the window key with its value, not the number alone strings.Contains(out, "40") finds it inside "4096", so the start_line assertion passed whether or not start_line survived the projection, which is the opposite of what it claimed. The rendered arguments carry the pairs, so the assertions name them.
🎯 What: The Gosec G204 vulnerability indicating potential command injection by running user-supplied variables directly via a system shell.
⚠️ Risk: Left unfixed, automated security scanners raise this as a medium-severity issue. However, because the input is drawn directly from the user's local configuration file, it is considered trusted.
🛡️ Solution: Rather than removing the system shell (which would break critical shell-specific features like pipes and redirects that users rely on for provider configurations), this PR formally acknowledges and suppresses the false positive by adding an inline
/* #nosec G204 */annotation with a clear justification.PR created automatically by Jules for task 11803407121707453049 started by @euxaristia
Summary by CodeRabbit