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
28 changes: 23 additions & 5 deletions cmd/shellforge/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ cmdReport(repo)
case "run":
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: shellforge run <driver> \"prompt\"")
fmt.Fprintln(os.Stderr, "Drivers: goose, claude, copilot, codex, gemini")
fmt.Fprintln(os.Stderr, "Drivers: goose, claude, copilot, codex, gemini, openclaw, nemoclaw")
os.Exit(1)
}
driver := os.Args[2]
Expand Down Expand Up @@ -91,7 +91,7 @@ func printUsage() {
fmt.Printf(`ShellForge %s — local governed agent runtime

Usage:
shellforge run <driver> "prompt" Run a governed agent (claude, copilot, codex, gemini, crush)
shellforge run <driver> "prompt" Run a governed agent (claude, copilot, codex, gemini, openclaw, nemoclaw)
shellforge setup Install Ollama, pull model, verify stack
shellforge qa [target] QA analysis with tool use + governance
shellforge report [repo] Weekly status report from git + logs
Expand Down Expand Up @@ -510,13 +510,31 @@ var drivers = map[string]driverConfig{
hasHooks: false,
initHint: "",
},
"openclaw": {
binary: "openclaw",
buildCmd: func(p string) []string {
return []string{"--non-interactive", "-p", p}
},
interactive: []string{},
hasHooks: false,
initHint: "agentguard openclaw-init",
},
"nemoclaw": {
binary: "openclaw",
buildCmd: func(p string) []string {
return []string{"--non-interactive", "--model", "nemotron", "--sandbox", "openshell", "-p", p}
},
interactive: []string{},
hasHooks: false,
initHint: "agentguard nemoclaw-init",
},
}

func cmdRun(driver, prompt string) {
dc, ok := drivers[driver]
if !ok {
fmt.Fprintf(os.Stderr, "Unknown driver: %s\n", driver)
fmt.Fprintln(os.Stderr, "Available drivers: claude, copilot, codex, gemini, crush")
fmt.Fprintln(os.Stderr, "Available drivers: claude, copilot, codex, gemini, openclaw, nemoclaw")
os.Exit(1)
}

Expand Down Expand Up @@ -559,8 +577,8 @@ func cmdRun(driver, prompt string) {
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()

// For Goose: set governed shell so ALL commands go through AgentGuard
if driver == "goose" {
// For Goose/OpenClaw/NemoClaw: set governed shell so ALL commands go through AgentGuard
if driver == "goose" || driver == "openclaw" || driver == "nemoclaw" {
sfBin, _ := exec.LookPath("shellforge")
if sfBin != "" {
// Find govern-shell.sh next to the shellforge binary or in known locations
Expand Down
2 changes: 2 additions & 0 deletions cmd/shellforge/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ func cmdStatusFull() {
{"copilot", "github-copilot-cli", "GitHub Copilot CLI", "gh extension install github/gh-copilot"},
{"codex", "codex", "OpenAI Codex CLI", "npm i -g @openai/codex"},
{"gemini", "gemini", "Google Gemini CLI", "npm i -g @anthropic-ai/gemini-cli"},
{"openclaw", "openclaw", "OpenClaw browser automation (Anthropic)", "npm i -g @anthropic-ai/openclaw"},
{"nemoclaw", "openclaw", "NemoClaw (OpenClaw + Nemotron sandbox)", "npm i -g @anthropic-ai/openclaw"},
}
driverCount := 0
for _, d := range drivers {
Expand Down
130 changes: 130 additions & 0 deletions internal/engine/nemoclaw.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package engine

import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"time"
)

// NemoClawEngine is a thin wrapper around OpenClaw that additionally:
// - Defaults to Nemotron as the model (instead of Claude)
// - Enables OpenShell sandbox (if available)
// - Sets additional security flags for hardened execution
//
// NemoClaw is an optional adapter — it never introduces a hard dependency.
type NemoClawEngine struct{}

func (e *NemoClawEngine) Name() string { return "nemoclaw" }

func (e *NemoClawEngine) Available() bool {
// NemoClaw requires OpenClaw to be installed
oc := &OpenClawEngine{}
return oc.Available()
}

func (e *NemoClawEngine) Run(task Task) (*Result, error) {
start := time.Now()

if !e.Available() {
return nil, fmt.Errorf("nemoclaw requires openclaw. Install: npm i -g @anthropic-ai/openclaw")
}

// Determine binary: standalone or npx (reuse OpenClaw resolution)
binary, args := openclawCommand()

// NemoClaw defaults to Nemotron model
model := "nemotron"
if task.Model != "" {
model = task.Model
}
if v := os.Getenv("NEMOCLAW_MODEL"); v != "" {
model = v
}
args = append(args, "--model", model)

// Always headless for NemoClaw (security-oriented, no browser UI)
args = append(args, "--headless")

// Enable sandbox mode if OpenShell is available
if _, err := exec.LookPath("openshell"); err == nil {
args = append(args, "--sandbox", "openshell")
}

// Additional security flags
args = append(args, "--no-network-write")
args = append(args, "--read-only-fs")

// Timeout
if task.Timeout > 0 {
args = append(args, "--timeout", fmt.Sprintf("%d", task.Timeout))
}

// Max turns
if task.MaxTurns > 0 {
args = append(args, "--max-turns", fmt.Sprintf("%d", task.MaxTurns))
}

// Non-interactive for headless execution
args = append(args, "--non-interactive")

// Set up AgentGuard governance + NemoClaw-specific env
env := append(os.Environ(),
"AGENTGUARD_POLICY=agentguard.yaml",
"AGENTGUARD_MODE=enforce",
"NEMOCLAW_SANDBOX=1",
)

// Wrap shell commands through govern-shell.sh
governShell := findGovernShell()
if governShell != "" {
env = append(env,
"SHELL="+governShell,
"SHELLFORGE_REAL_SHELL=/bin/bash",
)
}

cmd := exec.Command(binary, args...)
cmd.Dir = task.WorkDir
cmd.Env = env
cmd.Stdin = strings.NewReader(task.Prompt)

out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out))

duration := time.Since(start).Milliseconds()

if err != nil && output == "" {
return nil, fmt.Errorf("nemoclaw failed: %w", err)
}

// Parse structured output if available
result := &Result{
Success: err == nil,
Output: output,
DurationMs: duration,
}

// Try to extract metrics from OpenClaw's JSON output
if idx := strings.LastIndex(output, "\n{"); idx >= 0 {
var metrics struct {
Turns int `json:"turns"`
ToolCalls int `json:"tool_calls"`
Tokens struct {
Prompt int `json:"prompt"`
Response int `json:"response"`
} `json:"tokens"`
}
if json.Unmarshal([]byte(output[idx+1:]), &metrics) == nil {
result.Turns = metrics.Turns
result.ToolCalls = metrics.ToolCalls
result.PromptTok = metrics.Tokens.Prompt
result.ResponseTok = metrics.Tokens.Response
result.Output = strings.TrimSpace(output[:idx])
}
}

return result, nil
}
166 changes: 166 additions & 0 deletions internal/engine/openclaw.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package engine

import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)

// OpenClawEngine wraps the OpenClaw CLI with AgentGuard governance.
// OpenClaw is a browser automation + integrations runtime by Anthropic.
// Installed via npm: npx @anthropic-ai/openclaw, or as a standalone binary.
// Supports headless mode (for server/RunPod) and Extension Relay mode (local Mac with browser).
type OpenClawEngine struct{}

func (e *OpenClawEngine) Name() string { return "openclaw" }

func (e *OpenClawEngine) Available() bool {
// Check for standalone binary first
if _, err := exec.LookPath("openclaw"); err == nil {
return true
}
// Check for npx availability (npm-installed)
cmd := exec.Command("npx", "@anthropic-ai/openclaw", "--version")
return cmd.Run() == nil
}

func (e *OpenClawEngine) Run(task Task) (*Result, error) {
start := time.Now()

if !e.Available() {
return nil, fmt.Errorf("openclaw not installed. Install: npm i -g @anthropic-ai/openclaw")
}

// Determine binary: standalone or npx
binary, args := openclawCommand()

// Headless mode: default on for server (no DISPLAY), override via env
headless := os.Getenv("DISPLAY") == ""
if v := os.Getenv("OPENCLAW_HEADLESS"); v != "" {
headless = v == "1" || v == "true"
}

if headless {
args = append(args, "--headless")
}

// Model override
model := task.Model
if v := os.Getenv("OPENCLAW_MODEL"); v != "" {
model = v
}
if model != "" {
args = append(args, "--model", model)
}

// Timeout
if task.Timeout > 0 {
args = append(args, "--timeout", fmt.Sprintf("%d", task.Timeout))
}

// Max turns
if task.MaxTurns > 0 {
args = append(args, "--max-turns", fmt.Sprintf("%d", task.MaxTurns))
}

// Non-interactive mode for headless execution
args = append(args, "--non-interactive")

// Set up AgentGuard governance via env
env := append(os.Environ(),
"AGENTGUARD_POLICY=agentguard.yaml",
"AGENTGUARD_MODE=enforce",
)

// Wrap shell commands through govern-shell.sh
governShell := findGovernShell()
if governShell != "" {
env = append(env,
"SHELL="+governShell,
"SHELLFORGE_REAL_SHELL=/bin/bash",
)
}

cmd := exec.Command(binary, args...)
cmd.Dir = task.WorkDir
cmd.Env = env
cmd.Stdin = strings.NewReader(task.Prompt)

out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out))

duration := time.Since(start).Milliseconds()

if err != nil && output == "" {
return nil, fmt.Errorf("openclaw failed: %w", err)
}

// Parse structured output if available
result := &Result{
Success: err == nil,
Output: output,
DurationMs: duration,
}

// Try to extract metrics from OpenClaw's JSON output
if idx := strings.LastIndex(output, "\n{"); idx >= 0 {
var metrics struct {
Turns int `json:"turns"`
ToolCalls int `json:"tool_calls"`
Tokens struct {
Prompt int `json:"prompt"`
Response int `json:"response"`
} `json:"tokens"`
}
if json.Unmarshal([]byte(output[idx+1:]), &metrics) == nil {
result.Turns = metrics.Turns
result.ToolCalls = metrics.ToolCalls
result.PromptTok = metrics.Tokens.Prompt
result.ResponseTok = metrics.Tokens.Response
result.Output = strings.TrimSpace(output[:idx])
}
}

return result, nil
}

// openclawCommand returns the binary and base args for invoking OpenClaw.
// Prefers the standalone binary; falls back to npx.
func openclawCommand() (string, []string) {
if _, err := exec.LookPath("openclaw"); err == nil {
return "openclaw", nil
}
return "npx", []string{"@anthropic-ai/openclaw"}
}

// findGovernShell locates the govern-shell.sh wrapper for governance integration.
func findGovernShell() string {
sfBin, _ := exec.LookPath("shellforge")
if sfBin == "" {
// Try common locations even without shellforge on PATH
for _, path := range []string{
"scripts/govern-shell.sh",
"/usr/local/share/shellforge/govern-shell.sh",
} {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
for _, path := range []string{
filepath.Join(filepath.Dir(sfBin), "..", "share", "shellforge", "govern-shell.sh"),
filepath.Join(filepath.Dir(sfBin), "govern-shell.sh"),
"scripts/govern-shell.sh",
"/usr/local/share/shellforge/govern-shell.sh",
} {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
Loading