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
4 changes: 1 addition & 3 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@ builds:
goarch: arm64
ldflags:
- -s -w
- -X github.com/omarluq/librecode/internal/vinfo.Version={{.Version}}
- -X github.com/omarluq/librecode/internal/vinfo.Commit={{.Commit}}
- -X github.com/omarluq/librecode/internal/vinfo.BuildDate={{.Date}}
- -X github.com/omarluq/librecode/internal/vinfo.version={{.Version}}|{{.Commit}}|{{.Date}}

archives:
- id: default
Expand Down
5 changes: 2 additions & 3 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,9 @@ tasks:
sh: git rev-parse --short HEAD 2>/dev/null || printf none
BUILD_DATE:
sh: date -u +"%Y-%m-%dT%H:%M:%SZ"
BUILD_METADATA: '{{.VERSION}}|{{.COMMIT}}|{{.BUILD_DATE}}'
LDFLAGS: >-
-X github.com/omarluq/librecode/internal/vinfo.version={{.VERSION}}


-X github.com/omarluq/librecode/internal/vinfo.version={{.BUILD_METADATA}}
cmds:
- go build -ldflags "{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.BINARY_NAME}} {{.MAIN_PACKAGE}}

Expand Down
37 changes: 36 additions & 1 deletion internal/core/messages.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package core

import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)

const (
legacyCanceledJSONKey = "cancel" + "led"

// CompactionSummaryPrefix wraps compacted conversation history.
CompactionSummaryPrefix = "The conversation history before this point was compacted into the following summary:" +
"\n\n<summary>\n"
Expand Down Expand Up @@ -40,11 +44,42 @@ type BashExecutionMessage struct {
Output string `json:"output"`
FullOutputPath string `json:"full_output_path,omitempty"`
Timestamp int64 `json:"timestamp"`
Canceled bool "json:\"cancel\u006ced\""
Canceled bool `json:"canceled"`
Truncated bool `json:"truncated"`
ExcludeFromContext bool `json:"exclude_from_context,omitempty"`
}

// UnmarshalJSON preserves compatibility with sessions written before the
// canonical American-English canceled key was fixed.
func (message *BashExecutionMessage) UnmarshalJSON(data []byte) error {
type bashExecutionMessageAlias BashExecutionMessage

var decoded bashExecutionMessageAlias
if err := json.Unmarshal(data, &decoded); err != nil {
return fmt.Errorf("decode bash execution message: %w", err)
}

var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("decode bash execution message keys: %w", err)
}

if _, ok := raw["canceled"]; !ok {
if legacy, ok := raw[legacyCanceledJSONKey]; ok {
var legacyCanceled bool
if err := json.Unmarshal(legacy, &legacyCanceled); err != nil {
return fmt.Errorf("decode legacy bash execution canceled key: %w", err)
}

decoded.Canceled = legacyCanceled
}
}

*message = BashExecutionMessage(decoded)

return nil
}

// CustomMessage is extension-injected context.
type CustomMessage struct {
Details any `json:"details,omitempty"`
Expand Down
52 changes: 52 additions & 0 deletions internal/core/messages_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package core_test

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -108,6 +109,57 @@ func TestMessageConstructorsAndLLMConversions(t *testing.T) {
assert.Contains(t, compactionLLM.Content[0].Text, "compact summary")
}

func TestBashExecutionMessageJSON(t *testing.T) {
t.Parallel()

encoded, err := json.Marshal(core.BashExecutionMessage{
ExitCode: nil,
Command: "sleep 10",
Output: "",
FullOutputPath: "",
Timestamp: 123,
Canceled: true,
Truncated: false,
ExcludeFromContext: false,
})
require.NoError(t, err)
assert.Contains(t, string(encoded), `"canceled":true`)
assert.NotContains(t, string(encoded), "cancel"+"led")

tests := []struct {
name string
raw string
want bool
}{
{
name: "canonical canceled key",
raw: `{"command":"sleep 10","canceled":true}`,
want: true,
},
{
name: "legacy British-English key",
raw: `{"command":"sleep 10","cancel` + `led":true}`,
want: true,
},
{
name: "canonical key wins over legacy key",
raw: `{"command":"sleep 10","canceled":false,"cancel` + `led":true}`,
want: false,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()

var decoded core.BashExecutionMessage
require.NoError(t, json.Unmarshal([]byte(testCase.raw), &decoded))

assert.Equal(t, testCase.want, decoded.Canceled)
assert.Equal(t, "sleep 10", decoded.Command)
})
}
}

func TestBashExecutionToLLM(t *testing.T) {
t.Parallel()

Expand Down
27 changes: 14 additions & 13 deletions internal/core/skills_activation.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,20 +298,21 @@ func normalizeSkillToken(token string) string {
}

func isSkillStopWord(token string) bool {
stopWords := map[string]bool{
"about": true, "after": true, "agent": true, "also": true, "and": true,
"any": true, "apply": true, "are": true, "build": true, "can": true,
"code": true, "coding": true, "cover": true, "covers": true, "debug": true,
"designed": true, "especially": true, "for": true, "from": true, "guide": true,
"helps": true, "implement": true, "into": true, "not": true, "only": true,
"project": true, "provides": true, "review": true, "similar": true, "skill": true,
"task": true, "tasks": true, "that": true, "the": true, "their": true, "these": true,
"this": true, "tool": true, "tools": true, "trigger": true, "use": true,
"when": true, "whenever": true, "with": true, "work": true, "working": true,
"write": true, "you": true,
switch token {
case "about", "after", "agent", "also", "and",
"any", "apply", "are", "build", "can",
"code", "coding", "cover", "covers", "debug",
"designed", "especially", "for", "from", "guide",
"helps", "implement", "into", "not", "only",
"project", "provides", "review", "similar", "skill",
"task", "tasks", "that", "the", "their", "these",
"this", "tool", "tools", "trigger", "use",
"when", "whenever", "with", "work", "working",
"write", "you":
return true
default:
return false
}

return stopWords[token]
}

func truncateSkillContent(content string) (string, bool) {
Expand Down
19 changes: 19 additions & 0 deletions internal/core/skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,25 @@ func TestAutoActivateSkillsSelectsMatchingSkill(t *testing.T) {
assert.Contains(t, activated[0].Content, "Run tests")
}

func TestAutoActivateSkillsIgnoresActivationStopWords(t *testing.T) {
cwd := t.TempDir()
home := t.TempDir()
t.Setenv("HOME", home)
writeTestFile(t, filepath.Join(cwd, core.ConfigDirName, "skills", "loud", "SKILL.md"), strings.Join([]string{
frontmatterDelimiter,
"name: loud",
"description: Use when the agent and any task",
frontmatterDelimiter,
"Loud instructions.",
}, "\n"))

result := core.LoadSkills(cwd, nil, true)
detail := core.AutoActivateSkillsDetailed("the agent and any task", result.Skills)

require.Empty(t, detail.Diagnostics)
assert.Empty(t, detail.Activated)
}

func TestAutoActivateSkillsRequiresStrongIntent(t *testing.T) {
cwd := t.TempDir()
home := t.TempDir()
Expand Down
130 changes: 125 additions & 5 deletions internal/tool/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
Expand Down Expand Up @@ -248,7 +249,10 @@

setFetchHeaders(request)

response, err := fetchTool.httpClientWithRedirectValidation(requestCtx).Do(request)
client, closeIdleConnections := fetchTool.httpClientWithRedirectValidation(requestCtx)
defer closeIdleConnections()

response, err := client.Do(request)
if err != nil {
return nil, fetchResponseInfo{}, oops.In("tool").Code("fetch_request").Wrapf(err, "fetch url")
}
Expand Down Expand Up @@ -298,11 +302,17 @@
return http.DefaultClient
}

func (fetchTool *FetchTool) httpClientWithRedirectValidation(ctx context.Context) *http.Client {
func (fetchTool *FetchTool) httpClientWithRedirectValidation(
ctx context.Context,
) (client *http.Client, closeIdleConnections func()) {
baseClient := fetchTool.httpClient()
client := *baseClient
clonedClient := *baseClient

transport, closeIdleConnections := fetchTool.transportWithNetworkValidation(baseClient.Transport)
clonedClient.Transport = transport

baseCheckRedirect := baseClient.CheckRedirect
client.CheckRedirect = func(request *http.Request, via []*http.Request) error {
clonedClient.CheckRedirect = func(request *http.Request, via []*http.Request) error {
if baseCheckRedirect != nil {
if err := baseCheckRedirect(request, via); err != nil {
return err
Expand All @@ -314,7 +324,86 @@
return fetchTool.validatePublicFetchURL(ctx, request.URL)
}

return &client
return &clonedClient, closeIdleConnections
}

func (fetchTool *FetchTool) transportWithNetworkValidation(
baseTransport http.RoundTripper,
) (roundTripper http.RoundTripper, closeIdleConnections func()) {
if fetchTool.allowPrivateNetworks {
return baseTransport, func() {}

Check failure on line 334 in internal/tool/fetch.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this function is empty or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=omarluq_librecode&issues=AZ78qYofaN4rNUSQH_vU&open=AZ78qYofaN4rNUSQH_vU&pullRequest=173
}

transport, ok := cloneFetchHTTPTransport(baseTransport)
if !ok {
return baseTransport, func() {}

Check failure on line 339 in internal/tool/fetch.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this function is empty or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=omarluq_librecode&issues=AZ78qYofaN4rNUSQH_vV&open=AZ78qYofaN4rNUSQH_vV&pullRequest=173
}
Comment on lines +333 to +340

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the intentional no-op closers.

CI is flagging both empty closures. Add a short comment inside each no-op so the fallback is explicit and Sonar passes.

Proposed fix
 	if fetchTool.allowPrivateNetworks {
-		return baseTransport, func() {}
+		return baseTransport, func() {
+			// No cloned transport is owned here, so there are no idle connections to close.
+		}
 	}
 
 	transport, ok := cloneFetchHTTPTransport(baseTransport)
 	if !ok {
-		return baseTransport, func() {}
+		return baseTransport, func() {
+			// Non-http transports are not cloned here, so cleanup remains with the owner.
+		}
 	}
📝 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.

Suggested change
if fetchTool.allowPrivateNetworks {
return baseTransport, func() {}
}
transport, ok := cloneFetchHTTPTransport(baseTransport)
if !ok {
return baseTransport, func() {}
}
if fetchTool.allowPrivateNetworks {
return baseTransport, func() {
// No cloned transport is owned here, so there are no idle connections to close.
}
}
transport, ok := cloneFetchHTTPTransport(baseTransport)
if !ok {
return baseTransport, func() {
// Non-http transports are not cloned here, so cleanup remains with the owner.
}
}
🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[failure] 334-334: Add a nested comment explaining why this function is empty or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=omarluq_librecode&issues=AZ78qYofaN4rNUSQH_vU&open=AZ78qYofaN4rNUSQH_vU&pullRequest=173


[failure] 339-339: Add a nested comment explaining why this function is empty or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=omarluq_librecode&issues=AZ78qYofaN4rNUSQH_vV&open=AZ78qYofaN4rNUSQH_vV&pullRequest=173

🤖 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/tool/fetch.go` around lines 333 - 340, The fallback paths in
fetchTransport creation currently return intentionally empty cleanup closures,
which Sonar flags as empty blocks. In the fetch tool logic around
allowPrivateNetworks and cloneFetchHTTPTransport, add a brief inline comment
inside each no-op func() {} to make it explicit that no cleanup is needed for
the baseTransport fallback, while keeping the existing return behavior
unchanged.

Source: Linters/SAST tools


transport.Proxy = nil
transport.DialContext = validatingFetchDialContext(fetchDialContext(transport))

if transport.DialTLSContext != nil {
transport.DialTLSContext = validatingFetchDialContext(transport.DialTLSContext)
}
Comment on lines +342 to +347

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find custom http.Transport dial hooks that need preservation/validation.
# Expectation: Any Dial/DialTLS usage should remain behaviorally equivalent after wrapping.
rg -nP --type=go -C3 '\bDial(TLS|TLSContext|Context)?\s*:' .

Repository: omarluq/librecode

Length of output: 748


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the implementation around the cited lines and related helpers/tests.
git ls-files 'internal/tool/fetch.go' 'internal/tool/fetch_internal_test.go'
wc -l internal/tool/fetch.go internal/tool/fetch_internal_test.go
sed -n '320,390p' internal/tool/fetch.go
printf '\n--- tests around transport setup ---\n'
sed -n '480,560p' internal/tool/fetch_internal_test.go
printf '\n--- search for legacy dial hooks in repo ---\n'
rg -n --type=go '\bDialTLSContext\b|\bDialTLS\b|\bDial\b' internal/tool

Repository: omarluq/librecode

Length of output: 4752


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for other cloning/sanitizing logic for http.Transport in the codebase.
rg -n --type=go 'Clone\(\)|\bProxy\s*=\s*nil|\bDialContext\s*=\s*validatingFetchDialContext|\bvalidateFetchDialedConnection\b' internal

Repository: omarluq/librecode

Length of output: 1141


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the implementation and helper definitions that matter for the transport wrapping logic.
sed -n '300,420p' internal/tool/fetch.go
printf '\n---\n'
sed -n '1,140p' internal/tool/fetch.go

Repository: omarluq/librecode

Length of output: 7693


🌐 Web query:

Go net/http Transport Clone copies Dial and DialTLS legacy hooks DialTLSContext validation behavior

💡 Result:

The net/http.Transport.Clone method performs a shallow copy of the Transport's exported fields, which includes the legacy Dial, DialTLS, and modern DialTLSContext hooks [1][2]. Specifically regarding these hooks: - The fields Dial, DialTLS, and DialTLSContext are copied directly from the original Transport to the new one [2]. - Because they are function types, the cloned Transport will point to the exact same dialer functions as the original Transport [2]. - There is no special validation logic for these hooks during the cloning process [2]. Regarding DialTLSContext behavior specifically: - The Transport prioritizes DialTLSContext over the deprecated DialTLS if both are configured [3][2]. - When you clone a Transport, this priority logic remains preserved in the new instance because the fields themselves are copied as-is [2]. It is important to note that while Clone is intended to create a usable copy, it has historically been subject to various edge-case issues—particularly concerning internal state like HTTP/2 configuration (TLSNextProto and ForceAttemptHTTP2) [4][5]. Users should be aware that if the original Transport has not yet been initialized (via a request), the cloned Transport might behave differently than expected due to lazy initialization of default values [5]. Always ensure the Transport state is fully configured if you intend to rely on specific protocol or dialer behaviors after cloning [4][5].

Citations:


🌐 Web query:

Go net/http Transport DialTLS DialTLSContext precedence and Clone documentation

💡 Result:

In the Go net/http package, the DialTLSContext and DialTLS fields on the http.Transport struct manage TLS connection dialing for HTTPS requests, with specific precedence rules [1][2]. Precedence and Behavior: 1. If DialTLSContext is provided, it takes precedence [1][2]. 2. If DialTLSContext is nil, the transport falls back to the deprecated DialTLS [1][2]. 3. If both DialTLSContext and DialTLS are nil, the transport uses the configured DialContext (or the deprecated Dial) in combination with the Transport's TLSClientConfig to establish the connection [1][2]. 4. When DialTLSContext is set, the Dial and DialContext hooks are ignored for HTTPS requests, as are the TLSClientConfig and TLSHandshakeTimeout fields [1][2]. The DialTLS and Dial hooks are deprecated in favor of DialTLSContext and DialContext, respectively, because the context-aware versions allow the transport to cancel dials immediately when they are no longer needed [1][2]. Clone Documentation: The Clone method on http.Transport returns a deep copy of the Transport's exported fields [3]. This is useful for creating a modified version of an existing transport without affecting the original [4][3]. The clone process performs a shallow copy of most configuration fields (such as dialers and timeout durations) while creating new, independent instances for fields that require deep copying to prevent shared-state side effects, such as the TLSClientConfig [3]. Note that the internal state—such as connection pools or synchronization primitives—is managed appropriately to ensure the cloned transport is safe for use independently [3].

Citations:


🌐 Web query:

Go net/http Transport Clone copies Dial and DialTLS legacy hooks DialTLSContext validation behavior

💡 Result:

In Go's net/http package, the Transport.Clone method creates a shallow copy of the Transport structure, specifically copying exported fields, including the dial hooks [1][2][3]. When Clone is called, both the deprecated DialTLS and the modern DialTLSContext hooks are copied from the original Transport to the new instance [1][2]. Regarding their behavior and validation: 1. Prioritization: When both DialTLSContext and the deprecated DialTLS are set on a Transport (whether original or cloned), DialTLSContext takes precedence [1][4][2]. 2. Legacy Hook Handling: The Transport continues to support the legacy DialTLS hook for backward compatibility [1][2]. However, the documentation explicitly recommends using DialTLSContext instead, as it allows for better control, such as canceling dials when they are no longer needed [1][4]. 3. Validation and Fallback: If DialTLSContext is nil and DialTLS is also nil, the Transport falls back to using the DialContext (or Dial) hook combined with the TLSClientConfig to initiate TLS connections [1][4]. 4. Connection Behavior: If DialTLSContext is provided, the Transport ignores the standard Dial/DialContext hooks for HTTPS requests, as well as the TLSClientConfig and TLSHandshakeTimeout [1][4][2]. The returned net.Conn is expected to be already established past the TLS handshake [1][4]. Users should be aware that while Clone copies these hooks, it does not perform validation to ensure they are mutually compatible or optimal; it simply replicates the configuration [1][3]. Furthermore, while Clone is useful for creating variations of a Transport, users have historically reported issues regarding the cloning of internal state (such as HTTP/2 configuration) when the original transport was auto-configured [5][6], so it is generally best practice to ensure the cloned Transport is explicitly configured if specific behavior is required.

Citations:


Wrap legacy transport dial hooks too. http.Transport.Clone() preserves Dial/DialTLS, but this path only wraps DialContext and DialTLSContext. A transport that relies on legacy Dial loses its custom dialer here, and legacy DialTLS still bypasses the remote-address check for HTTPS. Wrap those hooks as well, or reject transports that use them.

🤖 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/tool/fetch.go` around lines 342 - 347, The transport hardening in
fetch.go only wraps http.Transport DialContext and DialTLSContext, so legacy
Dial and DialTLS settings can still bypass the validation path. Update the
transport setup in the fetch logic that uses validatingFetchDialContext to
either wrap the legacy Dial/DialTLS hooks too or explicitly reject transports
that set them, and keep the remote-address check enforced consistently for both
HTTP and HTTPS.


return transport, transport.CloseIdleConnections
}

func cloneFetchHTTPTransport(baseTransport http.RoundTripper) (*http.Transport, bool) {
if baseTransport == nil {
baseTransport = http.DefaultTransport
}

transport, ok := baseTransport.(*http.Transport)
if !ok {
return nil, false
}

return transport.Clone(), true
}

func fetchDialContext(transport *http.Transport) func(context.Context, string, string) (net.Conn, error) {
if transport.DialContext != nil {
return transport.DialContext
}

dialer := &net.Dialer{}

return dialer.DialContext
}

func validatingFetchDialContext(
dialContext func(context.Context, string, string) (net.Conn, error),
) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
return validateFetchDialedConnection(dialContext(ctx, network, address))
}
}

func validateFetchDialedConnection(conn net.Conn, dialErr error) (net.Conn, error) {
if dialErr != nil {
return nil, dialErr
}

if conn == nil {
return nil, oops.In("tool").Code("fetch_nil_connection").Errorf("fetch dial returned nil connection")
}

if err := validatePublicFetchRemoteAddr(conn.RemoteAddr()); err != nil {
if closeErr := conn.Close(); closeErr != nil {
return nil, errors.Join(
err,
oops.In("tool").Code("fetch_close_rejected_connection").Wrapf(
closeErr,
"close rejected fetch connection",
),
)
}

return nil, err
}

return conn, nil
}

func (fetchTool *FetchTool) validatePublicFetchURL(ctx context.Context, requestURL *url.URL) error {
Expand Down Expand Up @@ -390,6 +479,37 @@
return nil
}

func validatePublicFetchRemoteAddr(remoteAddr net.Addr) error {
ipAddress := fetchRemoteAddrIP(remoteAddr)
if ipAddress == nil {
return oops.In("tool").Code("fetch_invalid_remote_address").Errorf("fetch remote address is not an IP")
}

return validatePublicFetchIP(ipAddress)
}

func fetchRemoteAddrIP(remoteAddr net.Addr) net.IP {
if remoteAddr == nil {
return nil
}

switch addr := remoteAddr.(type) {
case *net.TCPAddr:
return addr.IP
case *net.UDPAddr:
return addr.IP
case *net.IPAddr:
return addr.IP
}

host, _, err := net.SplitHostPort(remoteAddr.String())
if err != nil {
host = remoteAddr.String()
}

return parseFetchHostIP(normalizedFetchHost(host))
}

func isPrivateFetchIP(ipAddress net.IP) bool {
return ipAddress.IsLoopback() ||
ipAddress.IsPrivate() ||
Expand Down
Loading
Loading