Skip to content
Merged
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
47 changes: 41 additions & 6 deletions cmd/sin-code/chat_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@
// and network calls. Production defaults point to the real implementations.
var (
toolReadFn = toolRead
toolWriteFn = toolWrite
toolEditFn = toolEdit
toolBashFn = toolBash
toolSearchFn = toolSearch
toolBootstrapSkillFn = toolBootstrapSkill
toolWriteFn = toolWrite
toolEditFn = toolEdit
toolApplyDiffFn = toolApplyDiff
toolGenerateDiffFn = toolGenerateDiff
toolBashFn = toolBash
toolSearchFn = toolSearch
toolBootstrapSkillFn = toolBootstrapSkill
toolSearchWalkErrFn = func(_ string, err error) error { return nil }
metaBootstrapSkillFn = meta.BootstrapSkill
)
Expand All @@ -57,7 +59,11 @@
InputSchema: obj(map[string]any{"path": str("file path"), "content": str("full file content")}, "path", "content")},
{Name: "sin_edit", Description: "Replace the first exact occurrence of old with new in a file.",
InputSchema: obj(map[string]any{"path": str("file path"), "old": str("exact text to replace"), "new": str("replacement text")}, "path", "old", "new")},
{Name: "sin_bash", Description: "Run a shell command in the workspace (120s timeout).",
{Name: "sin_apply_diff", Description: "Apply a unified diff to a file. Validates each hunk before applying and reports applied/rejected hunks. (issue #365)",
InputSchema: obj(map[string]any{"path": str("file path"), "diff": str("unified diff string")}, "path", "diff")},
{Name: "sin_generate_diff", Description: "Generate a unified diff from old and new content. (issue #365)",
InputSchema: obj(map[string]any{"old_content": str("original content"), "new_content": str("updated content")}, "old_content", "new_content")},
{Name: "sin_bash", Description: "Run a shell command in the workspace (120s timeout).",
InputSchema: obj(map[string]any{"command": str("shell command")}, "command")},
{Name: "sin_search", Description: "Search files for a substring; returns file:line matches.",
InputSchema: obj(map[string]any{"pattern": str("substring to search"), "dir": str("directory (default .)")}, "pattern")},
Expand All @@ -78,6 +84,10 @@
return toolWriteFn(argStr(args, "path"), argStr(args, "content"))
case "sin_edit":
return toolEditFn(argStr(args, "path"), argStr(args, "old"), argStr(args, "new"))
case "sin_apply_diff":
return toolApplyDiffFn(argStr(args, "path"), argStr(args, "diff"))
case "sin_generate_diff":
return toolGenerateDiffFn(argStr(args, "old_content"), argStr(args, "new_content"))
case "sin_bash":
return toolBashFn(ctx, argStr(args, "command"))
case "sin_search":
Expand Down Expand Up @@ -192,6 +202,31 @@
return result, nil
}

func toolApplyDiff(path, diff string) (string, error) {
if path == "" || diff == "" {
return "", fmt.Errorf("sin_apply_diff: path and diff required")
}
data, err := os.ReadFile(path)

Check failure

Code scanning / gosec

Potential file inclusion via variable Error

Potential file inclusion via variable
if err != nil {
return "", err
}
applier := agentloop.NewDiffApplier()
updated, err := applier.Apply(string(data), diff)
if err != nil {
return "", fmt.Errorf("sin_apply_diff: %w", err)
}
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {

Check failure

Code scanning / gosec

Expect WriteFile permissions to be 0600 or less Error

Expect WriteFile permissions to be 0600 or less

Check failure

Code scanning / gosec

Path traversal via taint analysis Error

Path traversal via taint analysis
return "", err
}
result := "applied diff to " + path
result += maybeGenerateTest(path)
return result, nil
}

func toolGenerateDiff(oldContent, newContent string) (string, error) {
return agentloop.GenerateDiff(oldContent, newContent), nil
}

// sandboxConfig controls OS-level isolation for sin_bash (issue #367).
var sandboxConfig struct {
enabled bool
Expand Down
60 changes: 60 additions & 0 deletions cmd/sin-code/internal/agentloop/apply_diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,3 +325,63 @@ func splitLines(text string) []string {
}
return lines
}

// GenerateDiff produces a unified diff string from old and new content.
// It uses a simple line-based algorithm that groups consecutive changes into
// one hunk. The output is valid for ApplyDiff and human-readable.
func GenerateDiff(oldContent, newContent string) string {
oldLines := splitLines(oldContent)
newLines := splitLines(newContent)

// Find common prefix.
prefix := 0
for prefix < len(oldLines) && prefix < len(newLines) && oldLines[prefix] == newLines[prefix] {
prefix++
}

// Find common suffix after the prefix.
suffix := 0
for suffix < len(oldLines)-prefix && suffix < len(newLines)-prefix &&
oldLines[len(oldLines)-1-suffix] == newLines[len(newLines)-1-suffix] {
suffix++
}

oldEnd := len(oldLines) - suffix
newEnd := len(newLines) - suffix

ctxBefore := 3
if prefix < ctxBefore {
ctxBefore = prefix
}
ctxAfter := 3
if oldEnd+ctxAfter > len(oldLines) {
ctxAfter = len(oldLines) - oldEnd
if ctxAfter < 0 {
ctxAfter = 0
}
}

oldStart := prefix - ctxBefore + 1
newStart := prefix - ctxBefore + 1
oldLinesCount := ctxBefore + (oldEnd - prefix) + ctxAfter
newLinesCount := ctxBefore + (newEnd - prefix) + ctxAfter

var b strings.Builder
fmt.Fprintf(&b, "--- a/file\n+++ b/file\n")
fmt.Fprintf(&b, "@@ -%d,%d +%d,%d @@\n", oldStart, oldLinesCount, newStart, newLinesCount)

for i := prefix - ctxBefore; i < prefix; i++ {
fmt.Fprintf(&b, " %s\n", oldLines[i])
}
for i := prefix; i < oldEnd; i++ {
fmt.Fprintf(&b, "-%s\n", oldLines[i])
}
for i := prefix; i < newEnd; i++ {
fmt.Fprintf(&b, "+%s\n", newLines[i])
}
for i := oldEnd; i < oldEnd+ctxAfter && i < len(oldLines); i++ {
fmt.Fprintf(&b, " %s\n", oldLines[i])
}

return b.String()
}
35 changes: 35 additions & 0 deletions cmd/sin-code/internal/agentloop/apply_diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,41 @@ func TestApplyDiff_FileHeadersSkipped(t *testing.T) {
}
}

func TestGenerateDiff_RoundTrip(t *testing.T) {
oldContent := "alpha\nbeta\ngamma\n"
newContent := "alpha\nBETA\ngamma\n"
diff := GenerateDiff(oldContent, newContent)
hunks, err := ParseUnifiedDiff(diff)
if err != nil {
t.Fatalf("ParseUnifiedDiff: %v", err)
}
result, err := ApplyDiff(oldContent, hunks)
if err != nil {
t.Fatalf("ApplyDiff: %v", err)
}
if result != newContent {
t.Errorf("round-trip = %q, want %q", result, newContent)
}
}

func TestGenerateDiff_Insertion(t *testing.T) {
oldContent := "a\nc\n"
newContent := "a\nb\nc\n"
diff := GenerateDiff(oldContent, newContent)
if !strings.Contains(diff, "+b") {
t.Errorf("diff missing inserted line: %s", diff)
}
}

func TestGenerateDiff_Deletion(t *testing.T) {
oldContent := "a\nb\nc\n"
newContent := "a\nc\n"
diff := GenerateDiff(oldContent, newContent)
if !strings.Contains(diff, "-b") {
t.Errorf("diff missing removed line: %s", diff)
}
}

// isBinary returns true if the content contains NUL bytes, which is the
// standard heuristic for binary file detection.
func isBinary(content string) bool {
Expand Down
2 changes: 2 additions & 0 deletions cmd/sin-code/internal/permission_defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ func DefaultPermissionRules() []permission.Rule {
{Tool: "sin_read", Policy: "allow"},
{Tool: "sin_write", Policy: "allow"},
{Tool: "sin_edit", Policy: "allow"},
{Tool: "sin_apply_diff", Policy: "allow"}, // v3.23.0: unified diff editor (issue #365)
{Tool: "sin_generate_diff", Policy: "allow"}, // v3.23.0: diff generator (issue #365)
{Tool: "sin_test", Policy: "allow"},
{Tool: "sin_quality_gate", Policy: "allow"}, // v3.21.0: Test-First Verify-Loop (RFC-test-automation)
{Tool: "sin_mutation", Policy: "allow"},
Expand Down
Loading