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
7 changes: 4 additions & 3 deletions forge-cli/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ var (
)

var runCmd = &cobra.Command{
Use: "run",
Short: "Run the agent locally with an A2A-compliant dev server",
RunE: runRun,
Use: "run",
Short: "Run the agent locally with an A2A-compliant dev server",
SilenceUsage: true,
RunE: runRun,
}

func init() {
Expand Down
10 changes: 6 additions & 4 deletions forge-cli/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,15 @@ Examples:
forge serve stop # Stop the daemon
forge serve status # Show running status
forge serve logs # View recent logs`,
RunE: serveStartRun, // bare "forge serve" acts as "forge serve start"
SilenceUsage: true,
RunE: serveStartRun, // bare "forge serve" acts as "forge serve start"
}

var serveStartCmd = &cobra.Command{
Use: "start",
Short: "Start the agent daemon",
RunE: serveStartRun,
Use: "start",
Short: "Start the agent daemon",
SilenceUsage: true,
RunE: serveStartRun,
}

var serveStopCmd = &cobra.Command{
Expand Down
1 change: 1 addition & 0 deletions forge-cli/runtime/guardrails_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func DefaultPolicyScaffold() *agentspec.PolicyScaffold {
"code_agent_write",
"code_agent_edit",
"cli_execute",
"web_search",
},
},
},
Expand Down
98 changes: 93 additions & 5 deletions forge-cli/tools/cli_execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,13 +276,27 @@ func (t *CLIExecuteTool) buildEnv(binary string) []string {
"HOME=" + homeVal,
"LANG=" + os.Getenv("LANG"),
}
// When HOME is overridden, preserve GH_CONFIG_DIR ONLY for the gh binary.
// Scoping to gh prevents other binaries from accessing GitHub credentials.
if binary == "gh" && t.workDir != "" && realHome != "" {
if _, ok := os.LookupEnv("GH_CONFIG_DIR"); !ok {
env = append(env, "GH_CONFIG_DIR="+filepath.Join(realHome, ".config", "gh"))

// Per-binary credential scoping: only the binary that needs credentials gets them.
if t.workDir != "" && realHome != "" {
switch binary {
case "gh":
// Preserve GH_CONFIG_DIR so gh CLI finds auth at real ~/.config/gh.
if _, ok := os.LookupEnv("GH_CONFIG_DIR"); !ok {
env = append(env, "GH_CONFIG_DIR="+filepath.Join(realHome, ".config", "gh"))
}
case "kubectl", "helm":
// Preserve KUBECONFIG so kubectl/helm find cluster credentials at
// the real ~/.kube/config when HOME has been overridden.
if _, ok := os.LookupEnv("KUBECONFIG"); !ok {
defaultKubeconfig := filepath.Join(realHome, ".kube", "config")
if _, err := os.Stat(defaultKubeconfig); err == nil {
env = append(env, "KUBECONFIG="+defaultKubeconfig)
}
}
}
}

for _, key := range t.config.EnvPassthrough {
if val, ok := os.LookupEnv(key); ok {
env = append(env, key+"="+val)
Expand All @@ -296,9 +310,83 @@ func (t *CLIExecuteTool) buildEnv(binary string) []string {
"https_proxy="+t.proxyURL,
)
}

// kubectl/helm manage their own TLS (mTLS client certs, bearer tokens).
// Routing through the egress proxy breaks K8s API auth. Set NO_PROXY to
// bypass the proxy for addresses kubectl commonly connects to.
if t.proxyURL != "" && (binary == "kubectl" || binary == "helm") {
noProxy := buildK8sNoProxy(env)
if noProxy != "" {
env = append(env,
"NO_PROXY="+noProxy,
"no_proxy="+noProxy,
)
}
}

return env
}

// buildK8sNoProxy extracts the K8s API server address from the KUBECONFIG
// file (if available) and returns a NO_PROXY value that includes it along
// with standard local addresses. This prevents the egress proxy from
// intercepting kubectl's mTLS/bearer-token auth to the API server.
func buildK8sNoProxy(env []string) string {
// Always bypass proxy for loopback and common K8s local addresses.
entries := []string{"localhost", "127.0.0.1", "::1", "*.local", "kubernetes.docker.internal", "host.docker.internal"}

// Try to extract the API server host from KUBECONFIG env we just set.
var kubeconfigPath string
for _, e := range env {
if strings.HasPrefix(e, "KUBECONFIG=") {
kubeconfigPath = strings.TrimPrefix(e, "KUBECONFIG=")
break
}
}
if kubeconfigPath == "" {
// Check the real env (user may have set KUBECONFIG explicitly).
kubeconfigPath = os.Getenv("KUBECONFIG")
}
if kubeconfigPath != "" {
if host := extractK8sAPIHost(kubeconfigPath); host != "" {
entries = append(entries, host)
}
}
return strings.Join(entries, ",")
}

// extractK8sAPIHost reads the kubeconfig and extracts the server hostname
// of the current context. Returns just the hostname (no port, no scheme).
func extractK8sAPIHost(kubeconfigPath string) string {
data, err := os.ReadFile(kubeconfigPath)
if err != nil {
return ""
}
// Lightweight extraction: find "server:" lines and parse the hostname.
// Full YAML parsing would require a dependency; a simple scan suffices.
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "server:") {
serverURL := strings.TrimSpace(strings.TrimPrefix(trimmed, "server:"))
// Strip scheme
serverURL = strings.TrimPrefix(serverURL, "https://")
serverURL = strings.TrimPrefix(serverURL, "http://")
// Strip port
if idx := strings.LastIndex(serverURL, ":"); idx > 0 {
serverURL = serverURL[:idx]
}
// Strip trailing path
if idx := strings.Index(serverURL, "/"); idx > 0 {
serverURL = serverURL[:idx]
}
if serverURL != "" {
return serverURL
}
}
}
return ""
}

// deniedShells is a hardcoded set of shell interpreters that are never allowed
// regardless of the allowlist. Shells defeat the security model by
// reintroducing shell interpretation, bypassing path validation and the
Expand Down
123 changes: 123 additions & 0 deletions forge-cli/tools/cli_execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -572,3 +572,126 @@ func TestCLIExecute_HomeOverriddenToWorkDir(t *testing.T) {
t.Errorf("expected %q in env output, got:\n%s", expected, res.Stdout)
}
}

func TestBuildEnv_GHConfigDirScopedToGh(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", "/Users/testuser")

tool := NewCLIExecuteTool(CLIExecuteConfig{
AllowedBinaries: []string{"env", "gh", "curl"},
WorkDir: tmpDir,
})

// gh binary should get GH_CONFIG_DIR
ghEnv := tool.buildEnv("gh")
found := false
for _, e := range ghEnv {
if strings.HasPrefix(e, "GH_CONFIG_DIR=") {
found = true
break
}
}
if !found {
t.Error("expected GH_CONFIG_DIR for gh binary")
}

// curl binary should NOT get GH_CONFIG_DIR
curlEnv := tool.buildEnv("curl")
for _, e := range curlEnv {
if strings.HasPrefix(e, "GH_CONFIG_DIR=") {
t.Error("GH_CONFIG_DIR should not be set for curl binary")
}
}
}

func TestBuildEnv_KubeconfigScopedToKubectl(t *testing.T) {
tmpDir := t.TempDir()
realHome := t.TempDir()
t.Setenv("HOME", realHome)

// Create a fake kubeconfig
kubeDir := filepath.Join(realHome, ".kube")
if err := os.MkdirAll(kubeDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(kubeDir, "config"), []byte("apiVersion: v1\nclusters:\n- cluster:\n server: https://192.168.1.100:6443\n"), 0o600); err != nil {
t.Fatal(err)
}

tool := NewCLIExecuteTool(CLIExecuteConfig{
AllowedBinaries: []string{"env", "kubectl", "curl"},
WorkDir: tmpDir,
})

// kubectl should get KUBECONFIG
kubectlEnv := tool.buildEnv("kubectl")
found := false
for _, e := range kubectlEnv {
if strings.HasPrefix(e, "KUBECONFIG=") {
found = true
if !strings.Contains(e, filepath.Join(realHome, ".kube", "config")) {
t.Errorf("expected KUBECONFIG to point to real home, got: %s", e)
}
break
}
}
if !found {
t.Error("expected KUBECONFIG for kubectl binary")
}

// curl should NOT get KUBECONFIG
curlEnv := tool.buildEnv("curl")
for _, e := range curlEnv {
if strings.HasPrefix(e, "KUBECONFIG=") {
t.Error("KUBECONFIG should not be set for curl binary")
}
}
}

func TestBuildEnv_KubectlNoProxy(t *testing.T) {
tmpDir := t.TempDir()
realHome := t.TempDir()
t.Setenv("HOME", realHome)

// Create kubeconfig with a server address
kubeDir := filepath.Join(realHome, ".kube")
if err := os.MkdirAll(kubeDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(kubeDir, "config"), []byte("apiVersion: v1\nclusters:\n- cluster:\n server: https://my-k8s.example.com:6443\n"), 0o600); err != nil {
t.Fatal(err)
}

tool := NewCLIExecuteTool(CLIExecuteConfig{
AllowedBinaries: []string{"kubectl", "curl"},
WorkDir: tmpDir,
})
tool.proxyURL = "http://127.0.0.1:54321"

// kubectl should get NO_PROXY with the K8s API server host
kubectlEnv := tool.buildEnv("kubectl")
var noProxy string
for _, e := range kubectlEnv {
if strings.HasPrefix(e, "NO_PROXY=") {
noProxy = strings.TrimPrefix(e, "NO_PROXY=")
break
}
}
if noProxy == "" {
t.Fatal("expected NO_PROXY for kubectl binary")
}
if !strings.Contains(noProxy, "my-k8s.example.com") {
t.Errorf("expected NO_PROXY to contain K8s API host, got: %s", noProxy)
}
if !strings.Contains(noProxy, "localhost") {
t.Errorf("expected NO_PROXY to contain localhost, got: %s", noProxy)
}

// curl should NOT get NO_PROXY
curlEnv := tool.buildEnv("curl")
for _, e := range curlEnv {
if strings.HasPrefix(e, "NO_PROXY=") {
t.Error("NO_PROXY should not be set for curl binary")
}
}
}
30 changes: 8 additions & 22 deletions forge-core/forgecore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -794,15 +794,6 @@ func TestNewRuntime_WithToolCalling(t *testing.T) {
},
FinishReason: "stop",
},
// The agent loop sends a continuation nudge after the first stop.
// Without workflow phases configured, only 1 nudge fires.
{
Message: llm.ChatMessage{
Role: llm.RoleAssistant,
Content: "I fetched the URL and got: ok",
},
FinishReason: "stop",
},
},
}

Expand Down Expand Up @@ -834,9 +825,10 @@ func TestNewRuntime_WithToolCalling(t *testing.T) {
t.Errorf("response text = %q, want 'I fetched the URL and got: ok'", resp.Parts[0].Text)
}

// Should have made 3 LLM calls (tool call + stop + 1 continuation nudge)
if toolCallClient.callIdx != 3 {
t.Errorf("LLM was called %d times, want 3", toolCallClient.callIdx)
// Should have made 2 LLM calls (tool call + stop). No continuation
// nudge because no workflow phases and no edit/git tools were used.
if toolCallClient.callIdx != 2 {
t.Errorf("LLM was called %d times, want 2", toolCallClient.callIdx)
}
}

Expand Down Expand Up @@ -1334,14 +1326,6 @@ func TestIntegration_CompileWithToolCallLoop(t *testing.T) {
},
FinishReason: "stop",
},
// Continuation nudge: without workflow phases, only 1 nudge fires.
{
Message: llm.ChatMessage{
Role: llm.RoleAssistant,
Content: "Found and fetched the result",
},
FinishReason: "stop",
},
},
}

Expand Down Expand Up @@ -1371,8 +1355,10 @@ func TestIntegration_CompileWithToolCallLoop(t *testing.T) {
if resp.Parts[0].Text != "Found and fetched the result" {
t.Errorf("response text = %q", resp.Parts[0].Text)
}
if toolCallClient.callIdx != 4 {
t.Errorf("LLM was called %d times, want 4", toolCallClient.callIdx)
// 3 calls: web_search tool call + http_request tool call + stop.
// No continuation nudge because no workflow phases and no edit/git tools.
if toolCallClient.callIdx != 3 {
t.Errorf("LLM was called %d times, want 3", toolCallClient.callIdx)
}
}

Expand Down
12 changes: 11 additions & 1 deletion forge-core/runtime/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,12 @@ func (e *LLMExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Mess
maxNudges = 0 // workflow is complete — don't nudge
} else if workflowIncomplete && tracker.phaseHasError[phaseGitOps] {
maxNudges = 2
} else if !hasWorkflowRequirements && !tracker.phaseSeen[phaseEdit] && !tracker.phaseSeen[phaseGitOps] {
// Informational / Q&A conversation — agent only used
// explore-phase tools (web_search, file_read, etc.) and
// gave a text response. No code changes were attempted,
// so there's nothing to "continue" with.
maxNudges = 0
}

if stopNudgesSent < maxNudges {
Expand Down Expand Up @@ -434,6 +440,10 @@ func (e *LLMExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Mess

// Handle file_create tool: always create a file part.
// For other tools with large output, detect content type.
// Skip cli_execute: it's an intermediate tool — the LLM should
// analyze its output and produce a human-readable response, not
// forward raw JSON. Attaching cli_execute output as a file causes
// the LLM to say "see attached" instead of writing a report.
if tc.Function.Name == "file_create" {
var fc struct {
Filename string `json:"filename"`
Expand All @@ -450,7 +460,7 @@ func (e *LLMExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Mess
},
})
}
} else if len(result) > largeToolOutputThreshold {
} else if tc.Function.Name != "cli_execute" && len(result) > largeToolOutputThreshold {
name, mime := detectFileType(result, tc.Function.Name)
largeToolOutputs = append(largeToolOutputs, a2a.Part{
Kind: a2a.PartKindFile,
Expand Down
Loading
Loading