Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 57 additions & 22 deletions cmd/entire/cli/agent/claudecode/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
package claudecode

import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
Expand Down Expand Up @@ -257,7 +257,7 @@ func SanitizePathForClaude(path string) string {
// GetTranscriptPosition returns the current line count of a Claude Code transcript.
// Claude Code uses JSONL format, so position is the number of lines.
// This is a lightweight operation that only counts lines without parsing JSON.
// Uses bufio.Reader to handle arbitrarily long lines (no size limit).
// Uses a fixed-size buffer to handle arbitrarily long lines safely.
// Returns 0 if the file doesn't exist or is empty.
func (c *ClaudeCodeAgent) GetTranscriptPosition(path string) (int, error) {
if path == "" {
Expand All @@ -273,29 +273,33 @@ func (c *ClaudeCodeAgent) GetTranscriptPosition(path string) (int, error) {
}
defer file.Close()

reader := bufio.NewReader(file)
buf := make([]byte, 32*1024)
lineCount := 0
var lastByte byte = '\n'

for {
line, err := reader.ReadBytes('\n')
n, err := file.Read(buf)
if n > 0 {
lineCount += bytes.Count(buf[:n], []byte{'\n'})
lastByte = buf[n-1]
}
if err != nil {
if err == io.EOF {
if len(line) > 0 {
lineCount++ // Count final line without trailing newline
if lastByte != '\n' {
lineCount++
}
break
}
return 0, fmt.Errorf("failed to read transcript: %w", err)
}
lineCount++
}

return lineCount, nil
}

// ExtractModifiedFilesFromOffset extracts files modified since a given line number.
// For Claude Code (JSONL format), offset is the starting line number.
// Uses bufio.Reader to handle arbitrarily long lines (no size limit).
// Uses a fixed-size buffer to handle arbitrarily long lines safely.
// Returns:
// - files: list of file paths modified by Claude (from Write/Edit tools)
// - currentPosition: total number of lines in the file
Expand All @@ -307,33 +311,64 @@ func (c *ClaudeCodeAgent) ExtractModifiedFilesFromOffset(path string, startOffse

file, openErr := os.Open(path) //nolint:gosec // Path comes from Claude Code transcript location
if openErr != nil {
if os.IsNotExist(openErr) {
return nil, 0, nil
}
return nil, 0, fmt.Errorf("failed to open transcript file: %w", openErr)
}
defer file.Close()

reader := bufio.NewReader(file)
var lines []TranscriptLine
lineNum := 0
var lastByte byte = '\n'
buf := make([]byte, 32*1024)
var lineBuf []byte
const maxLineSize = 10 * 1024 * 1024 // 10MB limit

for {
lineData, readErr := reader.ReadBytes('\n')
if readErr != nil && readErr != io.EOF {
return nil, 0, fmt.Errorf("failed to read transcript: %w", readErr)
}
n, readErr := file.Read(buf)
if n > 0 {
chunk := buf[:n]
for len(chunk) > 0 {
idx := bytes.IndexByte(chunk, '\n')
if idx == -1 {
if lineNum >= startOffset && len(lineBuf) < maxLineSize {
lineBuf = append(lineBuf, chunk...)
}
lastByte = chunk[len(chunk)-1]
break
}

if len(lineData) > 0 {
lineNum++
if lineNum > startOffset {
var line TranscriptLine
if parseErr := json.Unmarshal(lineData, &line); parseErr == nil {
lines = append(lines, line)
if lineNum >= startOffset {
if len(lineBuf) < maxLineSize {
lineBuf = append(lineBuf, chunk[:idx]...)
var line TranscriptLine
if parseErr := json.Unmarshal(lineBuf, &line); parseErr == nil {
Comment on lines +334 to +346
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maxLineSize isn’t enforced strictly: the guards only check len(lineBuf) < maxLineSize before append, so a final append can push lineBuf past the intended limit (and it will still be unmarshaled). Consider checking len(lineBuf)+len(toAppend) <= maxLineSize (or truncating/discarding the remainder of the line once the limit is hit) to keep the memory bound predictable.

Copilot uses AI. Check for mistakes.
lines = append(lines, line)
}
}
lineBuf = lineBuf[:0]
}
// Skip malformed lines silently
lineNum++
lastByte = '\n'
chunk = chunk[idx+1:]
}
}

if readErr == io.EOF {
break
if readErr != nil {
if readErr == io.EOF {
if lastByte != '\n' {
if lineNum >= startOffset && len(lineBuf) > 0 && len(lineBuf) < maxLineSize {
var line TranscriptLine
if parseErr := json.Unmarshal(lineBuf, &line); parseErr == nil {
lines = append(lines, line)
}
}
lineNum++
}
break
}
return nil, 0, fmt.Errorf("failed to read transcript: %w", readErr)
}
}

Expand Down
7 changes: 6 additions & 1 deletion cmd/entire/cli/agent/geminicli/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,8 @@ func (g *GeminiCLIAgent) ChunkTranscript(ctx context.Context, content []byte, ma

var chunks [][]byte
var currentMessages []GeminiMessage
currentSize := len(`{"messages":[]}`) // Base JSON structure size
baseSize := len(`{"messages":[]}`)
currentSize := baseSize // Base JSON structure size

for i, msg := range transcript.Messages {
// Marshal message to get its size
Expand All @@ -330,6 +331,10 @@ func (g *GeminiCLIAgent) ChunkTranscript(ctx context.Context, content []byte, ma
continue
}
msgSize := len(msgBytes) + 1 // +1 for comma separator

Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s trailing whitespace on the blank line after msgSize := .... This file won’t be gofmt-clean (CI will fail the formatting check). Please remove the whitespace / run gofmt so the blank line is truly empty.

Suggested change

Copilot uses AI. Check for mistakes.
if msgSize+baseSize > maxSize {
return nil, fmt.Errorf("single message size (%d) exceeds chunk maxSize (%d)", msgSize+baseSize, maxSize)
}

if currentSize+msgSize > maxSize && len(currentMessages) > 0 {
// Save current chunk
Expand Down
12 changes: 10 additions & 2 deletions cmd/entire/cli/agent/opencode/opencode.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ func (a *OpenCodeAgent) ChunkTranscript(_ context.Context, content []byte, maxSi
}
msgSize := len(msgBytes) + 1 // +1 for comma separator

if msgSize+baseSize > maxSize {
return nil, fmt.Errorf("single message size (%d) exceeds chunk maxSize (%d)", msgSize+baseSize, maxSize)
}

if currentSize+msgSize > maxSize && len(currentMessages) > 0 {
// Save current chunk
chunkData, err := json.Marshal(ExportSession{Info: session.Info, Messages: currentMessages})
Expand Down Expand Up @@ -167,7 +171,7 @@ func (a *OpenCodeAgent) GetSessionID(input *agent.HookInput) string {
// GetSessionDir returns the directory where Entire stores OpenCode session transcripts.
// Transcripts are ephemeral handoff files between the TS plugin and the Go hook handler.
// Once checkpointed, the data lives on git refs and the file is disposable.
// Stored in os.TempDir()/entire-opencode/<sanitized-path>/ to avoid squatting on
// Stored in repoPath/.git/entire-opencode/<sanitized-path>/ to avoid squatting on
// OpenCode's own directories (~/.opencode/ is project-level, not home-level).
func (a *OpenCodeAgent) GetSessionDir(repoPath string) (string, error) {
// Check for test environment override
Expand All @@ -176,7 +180,11 @@ func (a *OpenCodeAgent) GetSessionDir(repoPath string) (string, error) {
}

projectDir := SanitizePathForOpenCode(repoPath)
return filepath.Join(os.TempDir(), "entire-opencode", projectDir), nil
dir := filepath.Join(repoPath, ".git", "entire-opencode", projectDir)
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("failed to create secure opencode session dir: %w", err)
}
Comment on lines 182 to +186
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetSessionDir constructs repoPath/.git/… directly. In Git worktrees, .git is a file (gitfile) rather than a directory, so MkdirAll on repoPath/.git/... will fail and OpenCode session handoff will break for worktrees/submodules. Consider resolving the actual git dir via git rev-parse --git-common-dir / --git-dir (or parsing the .git gitfile) and placing entire-opencode under that resolved directory instead of assuming .git is a directory.

Copilot uses AI. Check for mistakes.
return dir, nil
}

func (a *OpenCodeAgent) ResolveSessionFile(sessionDir, agentSessionID string) string {
Expand Down
46 changes: 29 additions & 17 deletions cmd/entire/cli/git_operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,23 +467,35 @@ func FetchBlobsByHash(ctx context.Context, hashes []plumbing.Hash) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()

// Build fetch args: "git fetch origin <hash1> <hash2> ..."
// This uses the normal transport + credential helpers, unlike fetch-pack.
args := []string{"fetch", "--no-write-fetch-head", "origin"}
for _, h := range hashes {
args = append(args, h.String())
}

fetchCmd := exec.CommandContext(ctx, "git", args...)
if _, fetchErr := fetchCmd.CombinedOutput(); fetchErr != nil {
logging.Debug(ctx, "fetch-by-hash failed, falling back to full metadata fetch",
slog.Int("blob_count", len(hashes)),
slog.String("error", fetchErr.Error()),
)
// Fallback: full metadata branch fetch (pack negotiation skips already-local objects)
if fallbackErr := FetchMetadataBranch(ctx); fallbackErr != nil {
return fmt.Errorf("fetch-by-hash failed: %w; fallback fetch also failed: %w",
fetchErr, fallbackErr)
const batchSize = 500
for i := 0; i < len(hashes); i += batchSize {
end := i + batchSize
if end > len(hashes) {
end = len(hashes)
}

batchHashes := hashes[i:end]
// Build fetch args: "git fetch origin <hash1> <hash2> ..."
// This uses the normal transport + credential helpers, unlike fetch-pack.
args := []string{"fetch", "--no-write-fetch-head", "origin"}
for _, h := range batchHashes {
args = append(args, h.String())
}

fetchCmd := exec.CommandContext(ctx, "git", args...)
if _, fetchErr := fetchCmd.CombinedOutput(); fetchErr != nil {
logging.Debug(ctx, "fetch-by-hash failed for batch, falling back to full metadata fetch",
slog.Int("total_blobs", len(hashes)),
slog.Int("batch_start", i),
slog.Int("batch_size", len(batchHashes)),
slog.String("error", fetchErr.Error()),
)
// Fallback: full metadata branch fetch (pack negotiation skips already-local objects)
if fallbackErr := FetchMetadataBranch(ctx); fallbackErr != nil {
return fmt.Errorf("fetch-by-hash failed: %w; fallback fetch also failed: %w",
fetchErr, fallbackErr)
}
return nil // Fallback fetched everything, no need to process remaining batches
}
}

Expand Down
10 changes: 10 additions & 0 deletions cmd/entire/cli/session/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package session
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
Expand Down Expand Up @@ -364,6 +365,15 @@ func (s *StateStore) Save(ctx context.Context, state *State) error {
if err := os.Rename(tmpFile, stateFile); err != nil {
return fmt.Errorf("failed to rename session state file: %w", err)
}

info, err := os.Lstat(stateFile)
if err != nil {
return fmt.Errorf("failed to stat session state file after rename: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
_ = os.Remove(stateFile)
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the post-rename Lstat indicates the state file is a symlink / non-regular file, the code ignores any os.Remove failure. For a security hardening path, it’d be better to surface removal failures (or at least wrap/return them) so callers can distinguish “detected and removed” from “detected but could not clean up”.

Suggested change
_ = os.Remove(stateFile)
if removeErr := os.Remove(stateFile); removeErr != nil {
return fmt.Errorf("security error: state file is not a regular file (symlink / path traversal detected); additionally failed to remove suspicious file: %w", removeErr)
}

Copilot uses AI. Check for mistakes.
return errors.New("security error: state file is not a regular file (symlink / path traversal detected)")
}
return nil
}

Expand Down
12 changes: 9 additions & 3 deletions cmd/entire/cli/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (

"github.com/charmbracelet/huh"

"github.com/entireio/cli/cmd/entire/cli/osroot"
"github.com/entireio/cli/cmd/entire/cli/paths"
)

Expand Down Expand Up @@ -87,18 +86,25 @@ func copyFile(src, dst string) error {
return fmt.Errorf("copyFile: dst must be absolute, got %q", dst)
}

input, err := os.ReadFile(src)
srcFile, err := os.Open(src)
if err != nil {
return err //nolint:wrapcheck // already present in codebase
}
defer srcFile.Close()

root, relPath, err := openAllowedRoot(dst)
if err != nil {
return err
}
defer root.Close()

if err := osroot.WriteFile(root, relPath, input, 0o600); err != nil {
dstFile, err := root.OpenFile(relPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
defer dstFile.Close()

if _, err := io.Copy(dstFile, srcFile); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
Expand Down
26 changes: 25 additions & 1 deletion redact/pii.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,31 @@ func ConfigurePII(cfg PIIConfig) {
func getPIIConfig() *PIIConfig {
piiConfigMu.RLock()
defer piiConfigMu.RUnlock()
return piiConfig

if piiConfig == nil {
return nil
}

cfgCopy := &PIIConfig{
Enabled: piiConfig.Enabled,
patterns: piiConfig.patterns,
}

if piiConfig.Categories != nil {
cfgCopy.Categories = make(map[PIICategory]bool, len(piiConfig.Categories))
for k, v := range piiConfig.Categories {
cfgCopy.Categories[k] = v
}
}

if piiConfig.CustomPatterns != nil {
cfgCopy.CustomPatterns = make(map[string]string, len(piiConfig.CustomPatterns))
for k, v := range piiConfig.CustomPatterns {
cfgCopy.CustomPatterns[k] = v
}
}

return cfgCopy
}

// Pre-compiled builtin PII regexes.
Expand Down
Loading