mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-31 12:20:48 +00:00
922 lines
32 KiB
Go
922 lines
32 KiB
Go
package tools
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"maps"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
shellwords "github.com/mattn/go-shellwords"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
|
|
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// maxWrapperDepth is the hard cap on shell-wrapper unwrapping. Commands nested
|
|
// deeper than this are denied unconditionally as adversarial — real commands
|
|
// never wrap beyond depth 3.
|
|
const maxWrapperDepth = 3
|
|
|
|
// wrapperBinaries identifies shell/exec wrappers whose first arg after -c is
|
|
// the real command to gate. Key is the normalized base name.
|
|
var wrapperBinaries = map[string]bool{
|
|
"sh": true, "bash": true, "zsh": true, "dash": true,
|
|
"env": true, "nohup": true, "stdbuf": true, "timeout": true,
|
|
}
|
|
|
|
// normalizeBinaryName returns the lowercased file base of a binary reference.
|
|
// Examples: "/usr/bin/gh" → "gh", "./GH" → "gh", " Gh " → "gh".
|
|
// Applied at both the gate lookup and lookupCredentialedBinary so the two
|
|
// layers agree on identity.
|
|
func normalizeBinaryName(s string) string {
|
|
return filepath.Base(strings.TrimSpace(strings.ToLower(s)))
|
|
}
|
|
|
|
// detectWrapper recognises shell-wrapper invocations and returns the inner
|
|
// command string. Supported shapes:
|
|
//
|
|
// sh -c "<inner>" (also bash / zsh / dash / /bin/sh / /usr/bin/env sh ...)
|
|
// env [K=V ...] <cmd> ... (no -c; the real binary is <cmd>)
|
|
// nohup <cmd> ...
|
|
// stdbuf -oL <cmd> ...
|
|
// timeout 10 <cmd> ...
|
|
//
|
|
// Returns wrapper=normalized wrapper name, innerCmd=remaining command string.
|
|
// ok=false when cmd is not a recognised wrapper or parsing fails.
|
|
func detectWrapper(cmd string) (wrapper string, innerCmd string, ok bool) {
|
|
parser := shellwords.NewParser()
|
|
parser.ParseBacktick = false
|
|
parser.ParseEnv = false
|
|
words, err := parser.Parse(cmd)
|
|
if err != nil || len(words) == 0 {
|
|
return "", "", false
|
|
}
|
|
head := normalizeBinaryName(words[0])
|
|
if !wrapperBinaries[head] {
|
|
return "", "", false
|
|
}
|
|
|
|
switch head {
|
|
case "sh", "bash", "zsh", "dash":
|
|
// sh -c "<inner>" → inner is word[2].
|
|
for i := 1; i < len(words); i++ {
|
|
if words[i] == "-c" && i+1 < len(words) {
|
|
return head, words[i+1], true
|
|
}
|
|
}
|
|
return "", "", false
|
|
case "env":
|
|
// env [K=V ...] <cmd> [args...] OR env -S "<cmd args>"
|
|
for i := 1; i < len(words); i++ {
|
|
w := words[i]
|
|
if strings.Contains(w, "=") && !strings.HasPrefix(w, "-") {
|
|
continue // env var assignment, skip
|
|
}
|
|
if w == "-i" || w == "-u" || w == "-" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(w, "-") {
|
|
// Unknown env flag — bail out of wrapper detection.
|
|
return "", "", false
|
|
}
|
|
// First non-assignment, non-flag token is the real command.
|
|
return "env", strings.Join(append([]string{w}, words[i+1:]...), " "), true
|
|
}
|
|
return "", "", false
|
|
case "nohup":
|
|
if len(words) < 2 {
|
|
return "", "", false
|
|
}
|
|
return "nohup", strings.Join(words[1:], " "), true
|
|
case "stdbuf":
|
|
// stdbuf [-oL -eL -iL ...] <cmd> ...
|
|
for i := 1; i < len(words); i++ {
|
|
if strings.HasPrefix(words[i], "-") {
|
|
continue
|
|
}
|
|
return "stdbuf", strings.Join(words[i:], " "), true
|
|
}
|
|
return "", "", false
|
|
case "timeout":
|
|
// timeout [--foreground] [-k DUR] <DURATION> <cmd> ...
|
|
for i := 1; i < len(words); i++ {
|
|
w := words[i]
|
|
if strings.HasPrefix(w, "-") {
|
|
continue
|
|
}
|
|
// First non-flag token is the DURATION — skip it.
|
|
if i+1 < len(words) {
|
|
return "timeout", strings.Join(words[i+1:], " "), true
|
|
}
|
|
return "", "", false
|
|
}
|
|
return "", "", false
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
// gateCandidate is one step of wrapper unwrapping; it captures the binary
|
|
// name to gate and (optionally) the wrapper token that introduced it.
|
|
type gateCandidate struct {
|
|
binary string // normalized (lowercase, file base)
|
|
wrapper string // "" for outermost direct invocation; else wrapper name
|
|
}
|
|
|
|
// collectGateCandidates returns the ordered list of binaries extracted from
|
|
// a command via recursive wrapper unwrapping (outermost → innermost).
|
|
// tooDeep=true when unwrapping exceeds maxWrapperDepth — callers must deny.
|
|
func collectGateCandidates(cmd string) (candidates []gateCandidate, tooDeep bool) {
|
|
current := cmd
|
|
wrapper := ""
|
|
for depth := 0; ; depth++ {
|
|
bin, _, err := parseCommandBinary(current)
|
|
if err != nil || bin == "" {
|
|
return candidates, false
|
|
}
|
|
norm := normalizeBinaryName(bin)
|
|
candidates = append(candidates, gateCandidate{binary: norm, wrapper: wrapper})
|
|
w, inner, ok := detectWrapper(current)
|
|
if !ok || strings.TrimSpace(inner) == "" {
|
|
return candidates, false
|
|
}
|
|
if depth+1 > maxWrapperDepth {
|
|
return candidates, true
|
|
}
|
|
wrapper = w
|
|
current = inner
|
|
}
|
|
}
|
|
|
|
// shellOperatorPattern detects shell metacharacters that indicate command chaining.
|
|
// These are unsafe in credentialed mode because they allow reading injected env vars.
|
|
var shellOperatorPattern = regexp.MustCompile(`[;|&<>\n\r` + "`" + `]|\$\(|\$\{`)
|
|
|
|
// parseCommandBinary splits a command string into binary name and arguments.
|
|
// Uses shell-word parsing to correctly handle quoted arguments with spaces.
|
|
func parseCommandBinary(command string) (binary string, args []string, err error) {
|
|
parser := shellwords.NewParser()
|
|
parser.ParseBacktick = false
|
|
parser.ParseEnv = false
|
|
|
|
words, err := parser.Parse(command)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("parse command: %w", err)
|
|
}
|
|
if len(words) == 0 {
|
|
return "", nil, fmt.Errorf("empty command")
|
|
}
|
|
return words[0], words[1:], nil
|
|
}
|
|
|
|
// detectUnquotedShellOperators scans a command string for shell metacharacters
|
|
// that appear OUTSIDE of single or double quotes. This prevents false positives
|
|
// when argument values contain characters like | (e.g. --jq '.[0] | .name').
|
|
// Returns the list of detected operators, or nil if the command is clean.
|
|
func detectUnquotedShellOperators(command string) []string {
|
|
unquoted := extractUnquotedSegments(command)
|
|
if unquoted == "" {
|
|
return nil
|
|
}
|
|
return detectShellOperators(unquoted)
|
|
}
|
|
|
|
// extractUnquotedSegments returns a string containing only the characters
|
|
// from command that are outside of single-quoted and double-quoted segments.
|
|
// Backslash escaping is handled both inside double quotes (\") and outside
|
|
// quotes (\' \" \\) to match go-shellwords parsing behavior — without this,
|
|
// \" outside quotes would incorrectly enter double-quote mode and hide
|
|
// subsequent shell operators from detection.
|
|
func extractUnquotedSegments(command string) string {
|
|
var buf strings.Builder
|
|
buf.Grow(len(command))
|
|
|
|
inSingle := false
|
|
inDouble := false
|
|
for i := 0; i < len(command); i++ {
|
|
ch := command[i]
|
|
switch {
|
|
case inSingle:
|
|
if ch == '\'' {
|
|
inSingle = false
|
|
}
|
|
case inDouble:
|
|
if ch == '\\' && i+1 < len(command) {
|
|
i++ // skip escaped character inside double quotes
|
|
} else if ch == '"' {
|
|
inDouble = false
|
|
}
|
|
default:
|
|
switch ch {
|
|
case '\\':
|
|
// Backslash outside quotes escapes the next character, preventing
|
|
// it from being treated as a quote delimiter. Both the backslash
|
|
// and the escaped character are emitted as unquoted content so
|
|
// that operator detection still sees them (e.g. \; remains visible).
|
|
buf.WriteByte(ch)
|
|
if i+1 < len(command) {
|
|
i++
|
|
buf.WriteByte(command[i])
|
|
}
|
|
case '\'':
|
|
inSingle = true
|
|
case '"':
|
|
inDouble = true
|
|
default:
|
|
buf.WriteByte(ch)
|
|
}
|
|
}
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// detectShellOperators scans a raw command string for shell metacharacters.
|
|
// Returns the list of detected operators, or nil if the command is clean.
|
|
// NOTE: This function does not respect quoting — use detectUnquotedShellOperators
|
|
// for credentialed exec where argument values may contain metacharacters.
|
|
func detectShellOperators(command string) []string {
|
|
matches := shellOperatorPattern.FindAllString(command, -1)
|
|
if len(matches) == 0 {
|
|
return nil
|
|
}
|
|
// Deduplicate
|
|
seen := make(map[string]bool, len(matches))
|
|
var unique []string
|
|
for _, m := range matches {
|
|
if !seen[m] {
|
|
seen[m] = true
|
|
unique = append(unique, m)
|
|
}
|
|
}
|
|
return unique
|
|
}
|
|
|
|
// resolveAndMatchBinary resolves a binary name to an absolute path and
|
|
// verifies any stored path matches either the command binary or a known runtime
|
|
// package alias (for example openrouter-cli -> orc).
|
|
func resolveAndMatchBinary(binaryName string, configPath *string) (string, error) {
|
|
if configPath != nil && strings.TrimSpace(*configPath) != "" {
|
|
expectedPath := strings.TrimSpace(*configPath)
|
|
if !filepath.IsAbs(expectedPath) {
|
|
return "", fmt.Errorf("configured binary path must be absolute: %q", expectedPath)
|
|
}
|
|
if !skills.IsExecutableFile(expectedPath) {
|
|
return "", fmt.Errorf("configured binary path %q is not executable", expectedPath)
|
|
}
|
|
if normalizeBinaryName(expectedPath) == normalizeBinaryName(binaryName) {
|
|
return expectedPath, nil
|
|
}
|
|
if runtimePath, ok := skills.FindRuntimeExecutable(binaryName); ok && runtimePath == expectedPath {
|
|
return expectedPath, nil
|
|
}
|
|
return "", fmt.Errorf("binary path mismatch: command uses %q but config expects %q", binaryName, expectedPath)
|
|
}
|
|
|
|
absPath, err := exec.LookPath(binaryName)
|
|
if err != nil {
|
|
if runtimePath, ok := skills.FindRuntimeExecutable(binaryName); ok {
|
|
return runtimePath, nil
|
|
}
|
|
return "", fmt.Errorf("binary %q not found in PATH: %w", binaryName, err)
|
|
}
|
|
return absPath, nil
|
|
}
|
|
|
|
// matchesBinaryDeny checks if the joined args string matches any per-binary deny pattern.
|
|
// Used for deny_args where patterns span multiple args (e.g. `auth\s+login`, `repo\s+delete`).
|
|
// Returns the matched pattern string, or empty if allowed.
|
|
func matchesBinaryDeny(args []string, denyPatternsJSON json.RawMessage) string {
|
|
if len(denyPatternsJSON) == 0 {
|
|
return ""
|
|
}
|
|
var patterns []string
|
|
if err := json.Unmarshal(denyPatternsJSON, &patterns); err != nil || len(patterns) == 0 {
|
|
return ""
|
|
}
|
|
argsStr := strings.Join(args, " ")
|
|
for _, p := range patterns {
|
|
re, err := regexp.Compile(p)
|
|
if err != nil {
|
|
slog.Warn("secure_cli.invalid_deny_pattern", "pattern", p, "error", err)
|
|
continue
|
|
}
|
|
if re.MatchString(argsStr) {
|
|
return p
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// matchesBinaryVerbose checks each arg token against verbose/debug flag patterns.
|
|
// Patterns are anchored at the START of each arg (but not the end), which allows:
|
|
// - `-v` to match `-v`, `-vv`, `-vvv` (verbosity escalation), `-v=1`, `-vq` (combined flags)
|
|
// - `--verbose` to match `--verbose`, `--verbose=true`
|
|
// - `-v` to NOT match `--version` (char 1 is `-`, not `v`)
|
|
// - `--verbose` to NOT match `--version` (diverges at char 5)
|
|
//
|
|
// This is intentional: verbose flags leak sensitive output (tokens in HTTP headers,
|
|
// API response bodies, OAuth flows). Start-anchored per-arg matching catches the
|
|
// real verbose family without false-positive on safe flags like `--version`.
|
|
// Returns the matched pattern string, or empty if allowed.
|
|
func matchesBinaryVerbose(args []string, denyPatternsJSON json.RawMessage) string {
|
|
if len(denyPatternsJSON) == 0 {
|
|
return ""
|
|
}
|
|
var patterns []string
|
|
if err := json.Unmarshal(denyPatternsJSON, &patterns); err != nil || len(patterns) == 0 {
|
|
return ""
|
|
}
|
|
for _, p := range patterns {
|
|
re, err := regexp.Compile("^(?:" + p + ")")
|
|
if err != nil {
|
|
slog.Warn("secure_cli.invalid_deny_pattern", "pattern", p, "error", err)
|
|
continue
|
|
}
|
|
if slices.ContainsFunc(args, re.MatchString) {
|
|
return p
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// executeCredentialed runs a CLI command in Direct Exec Mode (no shell).
|
|
// Credentials are injected as env vars into the child process only.
|
|
// rawCommand is the original command string before shell-word parsing (preserves quoting).
|
|
func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCLIBinary,
|
|
binary string, args []string, cwd string, sandboxKey string, rawCommand string) *Result {
|
|
|
|
// Attach a per-request scrub bag so adapter-derived secrets stay isolated
|
|
// from other tenants/goroutines (see scrub.go WithScrubBag).
|
|
ctx = WithScrubBag(ctx)
|
|
|
|
// Step 0: Reject NUL bytes (defense-in-depth — also checked in Execute()).
|
|
if strings.ContainsRune(rawCommand, '\x00') {
|
|
return ErrorResult("command contains invalid NUL byte")
|
|
}
|
|
|
|
// Step 1: Check for shell operators in the ORIGINAL command (preserves quoting).
|
|
// We check the raw command string (before shell-word parsing) so that characters
|
|
// inside quoted argument values (e.g. | in --jq '.[0] | ...') are not falsely flagged.
|
|
// Only top-level (unquoted) shell operators indicate actual command chaining attempts.
|
|
if ops := detectUnquotedShellOperators(rawCommand); len(ops) > 0 {
|
|
return credentialedShellOperatorError(rawCommand, ops)
|
|
}
|
|
|
|
// Step 2: Per-binary deny check (deny_args)
|
|
denyArgs, allowAudits := applyCommandKeywordAllowlist(binary, args, t.commandKeywordAllowlistSnapshot())
|
|
if p := matchesBinaryDeny(denyArgs, cred.DenyArgs); p != "" {
|
|
return credentialedDenyError(binary, args, p)
|
|
}
|
|
// Per-binary verbose deny check (deny_verbose) — per-arg start-anchored match
|
|
// so `-v` blocks `-v`/`-vv`/`-v=1` but not `--version`.
|
|
if p := matchesBinaryVerbose(args, cred.DenyVerbose); p != "" {
|
|
return credentialedDenyError(binary, args, p)
|
|
}
|
|
for _, audit := range allowAudits {
|
|
slog.Info("security.command_keyword_allowlist",
|
|
"binary", audit.Command,
|
|
"subcommand", audit.Subcommand,
|
|
"arg", audit.Arg,
|
|
"keyword", audit.Keyword,
|
|
"rule_id", audit.RuleID,
|
|
"reason", audit.Reason,
|
|
"agent_id", store.AgentIDFromContext(ctx),
|
|
"user_id", store.UserIDFromContext(ctx),
|
|
"credential_user_id", store.CredentialUserIDFromContext(ctx),
|
|
"tenant_id", store.TenantIDFromContext(ctx),
|
|
)
|
|
}
|
|
|
|
// Step 3: Decrypt env vars from store (already decrypted by store layer).
|
|
// Per-user env overrides take priority over binary/grant env.
|
|
envMap, err := mergeCredentialedEnv(cred)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("credentialed exec: invalid env JSON for %q: %v", binary, err))
|
|
}
|
|
slog.Debug("secure_cli.env_merged",
|
|
"binary", binary,
|
|
"agent_id", store.AgentIDFromContext(ctx),
|
|
"tenant_id", store.TenantIDFromContext(ctx),
|
|
"credential_user_id_present", store.CredentialUserIDFromContext(ctx) != "",
|
|
"env_keys", sortedKeys(envMap),
|
|
)
|
|
if missing := missingRequiredCredentialEnv(binary, envMap); len(missing) > 0 {
|
|
slog.Warn("secure_cli.missing_required_env",
|
|
"binary", binary,
|
|
"agent_id", store.AgentIDFromContext(ctx),
|
|
"tenant_id", store.TenantIDFromContext(ctx),
|
|
"credential_user_id_present", store.CredentialUserIDFromContext(ctx) != "",
|
|
"missing_env_keys", missing,
|
|
"env_keys", sortedKeys(envMap),
|
|
)
|
|
return credentialedMissingEnvError(binary, missing)
|
|
}
|
|
|
|
// Step 4: Register credential values for output scrubbing
|
|
for _, v := range envMap {
|
|
AddCredentialScrubValues(v)
|
|
}
|
|
|
|
// Step 5: Resolve binary to absolute path and verify against config
|
|
absPath, err := resolveAndMatchBinary(binary, cred.BinaryPath)
|
|
if err != nil {
|
|
r := credentialedPathError(binary, err)
|
|
if t.sandboxMgr != nil && sandboxKey != "" {
|
|
r.ForLLM += hintBinaryNotFound
|
|
}
|
|
return r
|
|
}
|
|
|
|
// Step 6: Determine timeout
|
|
timeout := time.Duration(cred.TimeoutSeconds) * time.Second
|
|
if timeout <= 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
|
|
// Step 6b: Resolve adapter from DB row (source of truth, NOT CLIPresets map).
|
|
// Passthrough is the default and a no-op — preserves bit-for-bit behavior
|
|
// for every legacy preset.
|
|
adapterName := ""
|
|
if cred.AdapterName != nil {
|
|
adapterName = *cred.AdapterName
|
|
}
|
|
adapter := AdapterFor(adapterName)
|
|
|
|
// Sandbox is incompatible with non-passthrough adapters in v1 — they need
|
|
// to materialize ephemeral files (SSH key, PAT helper) on the host fs that
|
|
// the sandboxed process can't read. Reject early before any ephemerals
|
|
// would be created.
|
|
inSandbox := t.sandboxMgr != nil && sandboxKey != ""
|
|
if inSandbox && adapter.Name() != "passthrough" {
|
|
return ErrorResult(fmt.Sprintf("credentialed exec: %q adapter not supported in sandbox mode yet", adapter.Name()))
|
|
}
|
|
|
|
if adapter.ShouldInject(args) {
|
|
userCred := userCredFromBinary(ctx, cred)
|
|
// Plant the resolved exec cwd so adapters (e.g. git) can run any
|
|
// pre-flight sub-exec from the right repo, not goclaw's daemon CWD.
|
|
prepareCtx := WithExecCwd(ctx, cwd)
|
|
inj, err := adapter.Prepare(prepareCtx, cred, userCred, args)
|
|
if err != nil {
|
|
return ErrorResult(ScrubCredentialsCtx(ctx, fmt.Sprintf("credentialed exec: %s adapter prepare failed: %v", adapter.Name(), err)))
|
|
}
|
|
if inj != nil {
|
|
if inj.Cleanup != nil {
|
|
defer func() {
|
|
if cerr := inj.Cleanup(); cerr != nil {
|
|
// Scrub cerr — os.Remove errors embed the full tmpfile
|
|
// path, which is in inj.ScrubValues precisely because
|
|
// it's adapter-sensitive (e.g. SSH key keypath).
|
|
slog.Warn("security.adapter_cleanup_failed",
|
|
"adapter", adapter.Name(),
|
|
"binary", binary,
|
|
"error", ScrubCredentialsCtx(ctx, cerr.Error()),
|
|
)
|
|
}
|
|
}()
|
|
}
|
|
if len(inj.ArgvPrefix) > 0 {
|
|
args = append(append([]string{}, inj.ArgvPrefix...), args...)
|
|
}
|
|
maps.Copy(envMap, inj.Env)
|
|
if len(inj.ScrubValues) > 0 {
|
|
AddScrubValuesCtx(ctx, inj.ScrubValues...)
|
|
}
|
|
emitSystemEnvInjectionAudit(adapter.Name(), binary,
|
|
store.CredentialUserIDFromContext(ctx), cred.CredentialSource, inj, effectiveHostScope(cred))
|
|
}
|
|
}
|
|
|
|
// Step 7: Execute — sandbox or host
|
|
if inSandbox {
|
|
return t.executeCredentialedSandbox(ctx, absPath, args, cwd, sandboxKey, envMap, timeout)
|
|
}
|
|
return t.executeCredentialedHost(ctx, absPath, args, cwd, envMap, timeout)
|
|
}
|
|
|
|
// userCredFromBinary synthesizes adapter credential input from LookupByBinary's
|
|
// effective credential fields. The adapter contract still uses
|
|
// SecureCLIUserCredential, but the material can come from user, context, or
|
|
// agent scoped rows.
|
|
func userCredFromBinary(ctx context.Context, bin *store.SecureCLIBinary) *store.SecureCLIUserCredential {
|
|
if bin == nil {
|
|
return nil
|
|
}
|
|
if len(bin.CredentialEnv) > 0 || bin.CredentialType != nil || bin.CredentialHostScope != nil {
|
|
return &store.SecureCLIUserCredential{
|
|
BinaryID: bin.ID,
|
|
UserID: credentialSubjectForAdapter(ctx, bin),
|
|
EncryptedEnv: bin.CredentialEnv,
|
|
CredentialType: bin.CredentialType,
|
|
HostScope: bin.CredentialHostScope,
|
|
}
|
|
}
|
|
if len(bin.UserEnv) == 0 && bin.UserCredentialType == nil && bin.UserHostScope == nil {
|
|
return nil
|
|
}
|
|
return &store.SecureCLIUserCredential{
|
|
BinaryID: bin.ID,
|
|
UserID: store.CredentialUserIDFromContext(ctx),
|
|
EncryptedEnv: bin.UserEnv,
|
|
CredentialType: bin.UserCredentialType,
|
|
HostScope: bin.UserHostScope,
|
|
}
|
|
}
|
|
|
|
func credentialSubjectForAdapter(ctx context.Context, bin *store.SecureCLIBinary) string {
|
|
if bin != nil && bin.CredentialSubjectID != "" {
|
|
return bin.CredentialSubjectID
|
|
}
|
|
return store.CredentialUserIDFromContext(ctx)
|
|
}
|
|
|
|
func effectiveHostScope(bin *store.SecureCLIBinary) *string {
|
|
if bin == nil {
|
|
return nil
|
|
}
|
|
if bin.CredentialHostScope != nil {
|
|
return bin.CredentialHostScope
|
|
}
|
|
return bin.UserHostScope
|
|
}
|
|
|
|
func mergeCredentialedEnv(cred *store.SecureCLIBinary) (map[string]string, error) {
|
|
envMap := make(map[string]string)
|
|
if cred == nil {
|
|
return envMap, nil
|
|
}
|
|
if len(cred.EncryptedEnv) > 0 {
|
|
baseEnv, err := store.FlattenSecureCLIEnv(cred.EncryptedEnv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
maps.Copy(envMap, baseEnv)
|
|
}
|
|
if len(cred.CredentialEnv) > 0 && isEnvCredentialType(cred.CredentialType) {
|
|
scopedEnv, err := store.FlattenSecureCLIEnv(cred.CredentialEnv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
maps.Copy(envMap, scopedEnv)
|
|
return envMap, nil
|
|
}
|
|
if len(cred.UserEnv) > 0 {
|
|
if !isEnvCredentialType(cred.UserCredentialType) {
|
|
return envMap, nil
|
|
}
|
|
userEnvMap, err := store.FlattenSecureCLIEnv(cred.UserEnv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
maps.Copy(envMap, userEnvMap)
|
|
}
|
|
return envMap, nil
|
|
}
|
|
|
|
func isEnvCredentialType(typ *string) bool {
|
|
return typ == nil || *typ == "" || *typ == "env"
|
|
}
|
|
|
|
func missingRequiredCredentialEnv(binary string, envMap map[string]string) []string {
|
|
required := requiredCredentialEnvVars(binary)
|
|
if len(required) == 0 {
|
|
return nil
|
|
}
|
|
missing := make([]string, 0, len(required))
|
|
for _, key := range required {
|
|
if strings.TrimSpace(envMap[key]) == "" {
|
|
missing = append(missing, key)
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
// validateExecCwd checks that cmd.Dir exists and is a directory before
|
|
// cmd.Start(). Empty cwd is allowed (Go interprets as "inherit parent's dir").
|
|
//
|
|
// Why: on Linux, Go's syscall.forkAndExecInChild reports any child-side error
|
|
// (chdir, execve, missing PT_INTERP, etc.) as `&PathError{Op: "fork/exec",
|
|
// Path: argv0, Err: errno}`. The label `fork/exec PATH:` always names the
|
|
// binary even when chdir was the actual failure. A stale stored workspace
|
|
// (e.g. /app/workspace/clax persisted from a Docker-era deployment but used
|
|
// on a bare-metal host) then surfaces as `fork/exec /usr/bin/gh: no such file
|
|
// or directory` — sending operators down the wrong investigation path.
|
|
//
|
|
// Returns nil when cwd is empty, exists and is a directory; otherwise an
|
|
// error naming the actual problem with the working directory.
|
|
func validateExecCwd(cwd string) error {
|
|
if cwd == "" {
|
|
return nil
|
|
}
|
|
info, err := os.Stat(cwd)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("working directory does not exist: %q", cwd)
|
|
}
|
|
return fmt.Errorf("working directory inaccessible: %q: %w", cwd, err)
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("working directory is not a directory: %q", cwd)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// executeCredentialedHost runs a credentialed command directly on the host.
|
|
// Uses exec.Command (no shell) with credentials as env vars.
|
|
// ctx cancellation triggers SIGTERM → 3s grace → SIGKILL via process-group helpers.
|
|
func (t *ExecTool) executeCredentialedHost(ctx context.Context, absPath string, args []string,
|
|
cwd string, envMap map[string]string, timeout time.Duration) *Result {
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
// Pre-flight cmd.Dir validation. On Linux, Go's clone+chdir+execve failure
|
|
// path collapses every child-side error into "fork/exec PATH: <errno-string>"
|
|
// — so a missing cmd.Dir surfaces as if absPath itself were missing. Catch
|
|
// this case explicitly so the error names the real culprit.
|
|
if err := validateExecCwd(cwd); err != nil {
|
|
return ErrorResult(fmt.Sprintf("credentialed exec: %v (binary %s does exist)", err, absPath))
|
|
}
|
|
|
|
// Plain exec.Command (not CommandContext) so we own the kill sequence.
|
|
cmd := exec.Command(absPath, args...)
|
|
cmd.Dir = cwd
|
|
|
|
// Process group so abort reaches the whole tree (mirrors executeOnHost).
|
|
setProcessGroup(cmd)
|
|
|
|
// Build env: inherit minimal PATH + HOME, add credentials
|
|
cmd.Env = buildCredentialedEnv(envMap)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return ErrorResult(fmt.Sprintf("credentialed exec: failed to start %s: %v", absPath, err))
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- cmd.Wait() }()
|
|
|
|
select {
|
|
case err := <-done:
|
|
return formatCredentialedResult(absPath, args, stdout.String(), stderr.String(), err, ctx, timeout)
|
|
|
|
case <-ctx.Done():
|
|
_ = killProcessGroup(cmd, syscallSIGTERM)
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
_ = killProcessGroup(cmd, syscallSIGKILL)
|
|
<-done
|
|
}
|
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
|
return ErrorResult(fmt.Sprintf("[CREDENTIALED EXEC] Command timed out after %s.\nBinary: %s", timeout, absPath))
|
|
}
|
|
return ErrorResult(fmt.Sprintf("[CREDENTIALED EXEC] Command aborted.\nBinary: %s", absPath))
|
|
}
|
|
}
|
|
|
|
// executeCredentialedSandbox runs a credentialed command inside a Docker sandbox.
|
|
// Uses sandbox.WithEnv to inject credentials via docker exec -e (no shell).
|
|
func (t *ExecTool) executeCredentialedSandbox(ctx context.Context, absPath string, args []string,
|
|
cwd string, sandboxKey string, envMap map[string]string, timeout time.Duration) *Result {
|
|
|
|
mountWorkspace, err := effectiveSandboxWorkspace(ctx, t.workspace)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
containerCwd, cwdErr := sandboxCwdForHostPath(cwd, mountWorkspace, sandbox.DefaultContainerWorkdir)
|
|
if cwdErr != nil {
|
|
return ErrorResult(fmt.Sprintf("credentialed sandbox path mapping: %v", cwdErr))
|
|
}
|
|
|
|
sb, err := t.sandboxMgr.Get(ctx, sandboxKey, mountWorkspace, SandboxConfigFromCtx(ctx))
|
|
if err != nil {
|
|
slog.Warn("security.credentialed_exec_sandbox_unavailable",
|
|
"binary", absPath, "error", err)
|
|
return ErrorResult("credentialed exec requires sandbox but sandbox is unavailable: " + err.Error())
|
|
}
|
|
|
|
// Direct exec inside sandbox: [absPath, args...] with env injection
|
|
command := append([]string{absPath}, args...)
|
|
result, err := sb.Exec(ctx, command, containerCwd, sandbox.WithEnv(envMap))
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("credentialed sandbox exec: %v", err))
|
|
}
|
|
|
|
output := result.Stdout
|
|
if result.Stderr != "" {
|
|
if output != "" {
|
|
output += "\n"
|
|
}
|
|
output += "STDERR:\n" + result.Stderr
|
|
}
|
|
if result.ExitCode != 0 {
|
|
scrubbed := ScrubCredentialsCtx(ctx, output)
|
|
return credentialedExecFailError(absPath, args, result.ExitCode, scrubbed+MaybeSandboxHint(result.ExitCode, scrubbed))
|
|
}
|
|
if output == "" {
|
|
output = "(command completed with no output)"
|
|
}
|
|
output = ScrubCredentialsCtx(ctx, output)
|
|
output = capExecOutput(output, execMaxOutputChars)
|
|
return SilentResult(output)
|
|
}
|
|
|
|
// buildCredentialedEnv creates a minimal environment with injected credentials.
|
|
// Inherits PATH and HOME from parent process, adds credential env vars.
|
|
// On Windows, also passes through SYSTEMROOT / TEMP / APPDATA / etc. — these
|
|
// are not secrets and many native CLIs (gh, az, aws, npm) require them to run.
|
|
func buildCredentialedEnv(envMap map[string]string) []string {
|
|
var env []string
|
|
if runtime.GOOS == "windows" {
|
|
pathDefault := "C:\\Windows\\system32;C:\\Windows;C:\\Windows\\System32\\Wbem"
|
|
homeDefault := os.Getenv("USERPROFILE")
|
|
if homeDefault == "" {
|
|
homeDefault = "C:\\Users\\Default"
|
|
}
|
|
env = []string{
|
|
"PATH=" + getenvDefault("PATH", pathDefault),
|
|
"HOME=" + getenvDefault("HOME", homeDefault),
|
|
"LANG=" + getenvDefault("LANG", "en_US.UTF-8"),
|
|
"USERNAME=" + getenvDefault("USERNAME", "goclaw"),
|
|
}
|
|
// Pass through Windows runtime vars that native tools expect.
|
|
// Missing SYSTEMROOT breaks networking/registry in most Win32 programs.
|
|
for _, k := range []string{
|
|
"SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "PATHEXT",
|
|
"TEMP", "TMP", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
|
|
"PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMDATA",
|
|
"HOMEDRIVE", "HOMEPATH", "COMPUTERNAME",
|
|
} {
|
|
if v := os.Getenv(k); v != "" {
|
|
env = append(env, k+"="+v)
|
|
}
|
|
}
|
|
} else {
|
|
env = []string{
|
|
"PATH=" + getenvDefault("PATH", "/usr/local/bin:/usr/bin:/bin"),
|
|
"HOME=" + getenvDefault("HOME", "/tmp"),
|
|
"LANG=" + getenvDefault("LANG", "en_US.UTF-8"),
|
|
"USER=" + getenvDefault("USER", "goclaw"),
|
|
}
|
|
}
|
|
for k, v := range envMap {
|
|
env = append(env, k+"="+v)
|
|
}
|
|
return env
|
|
}
|
|
|
|
// formatCredentialedResult formats the output of a credentialed exec call.
|
|
func formatCredentialedResult(binary string, args []string,
|
|
stdout, stderr string, err error, ctx context.Context, timeout time.Duration) *Result {
|
|
|
|
var output string
|
|
if stdout != "" {
|
|
output = stdout
|
|
}
|
|
if stderr != "" {
|
|
if output != "" {
|
|
output += "\n"
|
|
}
|
|
output += "STDERR:\n" + stderr
|
|
}
|
|
|
|
if err != nil {
|
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
|
return ErrorResult(fmt.Sprintf("[CREDENTIALED EXEC] Command timed out after %s.\nBinary: %s", timeout, binary))
|
|
}
|
|
exitCode := -1
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
exitCode = exitErr.ExitCode()
|
|
}
|
|
return credentialedExecFailError(binary, args, exitCode, ScrubCredentialsCtx(ctx, output))
|
|
}
|
|
|
|
if output == "" {
|
|
output = "(command completed with no output)"
|
|
}
|
|
output = ScrubCredentialsCtx(ctx, output)
|
|
output = capExecOutput(output, execMaxOutputChars)
|
|
return SilentResult(output)
|
|
}
|
|
|
|
// lookupCredentialedBinary checks if a command's binary has credential config.
|
|
// Returns the credential config and parsed args, or nil if not credentialed.
|
|
func (t *ExecTool) lookupCredentialedBinary(ctx context.Context, command string) (*store.SecureCLIBinary, string, []string) {
|
|
if t.secureCLIStore == nil {
|
|
slog.Warn("secure_cli.lookup: store is nil, skipping credentialed exec", "command", command)
|
|
return nil, "", nil
|
|
}
|
|
binary, args, err := parseCommandBinary(command)
|
|
if err != nil {
|
|
return nil, "", nil
|
|
}
|
|
// Normalize lookup key so path/case variants (/usr/bin/gh, ./gh, GH) all
|
|
// resolve to the same registry row. Same helper is used by the gate
|
|
// branch in Execute because identity must agree at both layers.
|
|
normBinary := normalizeBinaryName(binary)
|
|
// Get agent ID from context for scoped lookup
|
|
agentID := store.AgentIDFromContext(ctx)
|
|
var agentIDPtr *uuid.UUID
|
|
if agentID != uuid.Nil {
|
|
agentIDPtr = &agentID
|
|
}
|
|
// Pass userID for per-user credential resolution (LEFT JOIN, zero extra queries).
|
|
// Uses CredentialUserIDFromContext to pick up merged tenant user identity
|
|
// (falls back to UserIDFromContext when not set).
|
|
userID := store.CredentialUserIDFromContext(ctx)
|
|
cred, err := t.secureCLIStore.LookupByBinary(ctx, normBinary, agentIDPtr, userID)
|
|
if err != nil {
|
|
slog.Warn("secure_cli.lookup: query failed", "binary", binary, "agent_id", agentID, "error", err)
|
|
return nil, "", nil
|
|
}
|
|
if cred == nil {
|
|
slog.Debug("secure_cli.lookup: no credential found", "binary", binary, "agent_id", agentID)
|
|
return nil, "", nil
|
|
}
|
|
slog.Debug("secure_cli.lookup: found credential", "binary", binary, "cred_id", cred.ID, "env_size", len(cred.EncryptedEnv))
|
|
return cred, binary, args
|
|
}
|
|
|
|
// getenvDefault returns the value of an env var, or a default if not set.
|
|
func getenvDefault(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// --- Structured error helpers ---
|
|
|
|
func credentialedShellOperatorError(command string, ops []string) *Result {
|
|
return &Result{
|
|
ForLLM: fmt.Sprintf("[CREDENTIALED EXEC] Shell operators not supported.\n"+
|
|
"Detected: %s\n"+
|
|
"This CLI runs in Direct Exec Mode — no shell operators (; && || | > < $() ``).\n"+
|
|
"Run the command without operators. Use --json or --format=json for structured output.",
|
|
strings.Join(ops, ", ")),
|
|
ForUser: "Command contains shell operators not supported in credentialed mode.",
|
|
IsError: true,
|
|
}
|
|
}
|
|
|
|
func credentialedPathError(binary string, err error) *Result {
|
|
return &Result{
|
|
ForLLM: fmt.Sprintf("[CREDENTIALED EXEC] Binary resolution failed.\n"+
|
|
"Binary: %s\nError: %v\n"+
|
|
"The binary may not be installed or the path doesn't match the configured path.",
|
|
binary, err),
|
|
ForUser: fmt.Sprintf("CLI binary %q not found or path mismatch.", binary),
|
|
IsError: true,
|
|
}
|
|
}
|
|
|
|
func credentialedDenyError(binary string, args []string, pattern string) *Result {
|
|
return &Result{
|
|
ForLLM: fmt.Sprintf("[CREDENTIALED EXEC] Command blocked by security policy.\n"+
|
|
"Binary: %s\nArgs: %s\nMatched deny pattern: %s\n"+
|
|
"This operation requires admin approval and cannot be performed automatically.",
|
|
binary, strings.Join(args, " "), pattern),
|
|
ForUser: fmt.Sprintf("Operation '%s %s' is blocked by security policy.", binary, strings.Join(args, " ")),
|
|
IsError: true,
|
|
}
|
|
}
|
|
|
|
func credentialedMissingEnvError(binary string, keys []string) *Result {
|
|
return &Result{
|
|
ForLLM: fmt.Sprintf("[CREDENTIALED EXEC] Credential config missing required credential env.\n"+
|
|
"Binary: %s\nMissing env keys: %s\n"+
|
|
"Configure the missing key as a SecureCLI user credential for the same user context, then grant this binary to the target agent.",
|
|
binary, strings.Join(keys, ", ")),
|
|
ForUser: fmt.Sprintf("CLI credential for %q is missing required env: %s.", binary, strings.Join(keys, ", ")),
|
|
IsError: true,
|
|
}
|
|
}
|
|
|
|
func credentialedExecFailError(binary string, args []string, exitCode int, output string) *Result {
|
|
return &Result{
|
|
ForLLM: fmt.Sprintf("[CREDENTIALED EXEC] Command failed (exit code %d).\n"+
|
|
"Binary: %s\nArgs: %s\n"+
|
|
"Note: This runs in Direct Exec Mode — shell operators are NOT supported.\n"+
|
|
"If you used shell operators, remove them and try again.\n\n%s",
|
|
exitCode, binary, strings.Join(args, " "), output),
|
|
ForUser: fmt.Sprintf("Command failed with exit code %d.", exitCode),
|
|
IsError: true,
|
|
}
|
|
}
|