diff --git a/.gitignore b/.gitignore index 8389c11e..5d6c32b9 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,12 @@ dist/ pkg/cli/webapp/dist/* !pkg/cli/webapp/dist/.gitkeep .grite/ +# hack/ is local scratch space, except the git-agent substrate harness the +# protocol tests rerun on every git version (SPEC-git-agent-protocol §1). hack/ +!hack/ +hack/* +!hack/gitagent_empirical.sh .ok/ .okignore .ginkgo/ diff --git a/cmd/captain/main.go b/cmd/captain/main.go index 171734c7..892deaf5 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -6,6 +6,7 @@ import ( "reflect" "github.com/flanksource/captain/pkg/cli" + "github.com/flanksource/captain/pkg/gitagent" "github.com/flanksource/clicky" "github.com/flanksource/clicky/flags" "github.com/flanksource/clicky/mcp" @@ -123,6 +124,41 @@ func main() { clicky.AddNamedCommand("generate", sandboxCmd, cli.SRTGenerateOptions{}, cli.RunSRTGenerate).Short = "Generate sandbox-runtime config" clicky.AddNamedCommand("presets", sandboxCmd, cli.SandboxPresetsOptions{}, cli.RunSandboxPresets).Short = "List available sandbox-runtime presets" + gitAgentCmd := &cobra.Command{ + Use: "git-agent", + Short: "Enroll and serve remote git-agent sandboxes (SPEC-git-agent-protocol)", + } + // Without its own help func this inherits the parent's, which prints the + // sandbox generate/presets help and makes this group undiscoverable. + gitAgentCmd.SetHelpFunc(func(c *cobra.Command, _ []string) { + if c == gitAgentCmd { + fmt.Fprint(os.Stderr, cli.GitAgentHelp().ANSI()) + return + } + fmt.Fprint(os.Stderr, c.UsageString()) + }) + sandboxCmd.AddCommand(gitAgentCmd) + clicky.AddNamedCommand("add", gitAgentCmd, cli.GitAgentAddOptions{}, cli.RunGitAgentAdd).Short = "Enroll a new agent: mint a join token and print the join command" + clicky.AddNamedCommand("list", gitAgentCmd, cli.GitAgentListOptions{}, cli.RunGitAgentList).Short = "List enrolled agents and pending enrollments" + clicky.AddNamedCommand("revoke", gitAgentCmd, cli.GitAgentRevokeOptions{}, cli.RunGitAgentRevoke).Short = "Revoke an enrolled agent's key" + clicky.AddNamedCommandWithContext("serve", gitAgentCmd, cli.GitAgentServeOptions{}, cli.RunGitAgentServe).Short = "Run the receive endpoint on this host (agent sidecar or supervisor mailbox)" + hookLeaf := clicky.AddNamedCommandWithContext("hook", gitAgentCmd, cli.GitAgentHookOptions{}, cli.RunGitAgentHook) + hookLeaf.Short = "Internal: receive-hook entrypoint invoked by the installed shims" + hookLeaf.Hidden = true + runTaskLeaf := clicky.AddNamedCommandWithContext("run-task", gitAgentCmd, cli.GitAgentRunTaskOptions{}, cli.RunGitAgentRunTask) + runTaskLeaf.Short = "Internal: work a dispatched task in its worktree, then commit and push" + runTaskLeaf.Hidden = true + gitAgentCmd.AddCommand(&cobra.Command{ + Use: "ssh", + Hidden: true, + Short: "Internal: GIT_SSH_COMMAND transport for dispatch and relay pushes", + DisableFlagParsing: true, + RunE: func(_ *cobra.Command, args []string) error { + os.Exit(gitagent.SSHClientMain(args)) + return nil + }, + }) + aiCmd := &cobra.Command{ Use: "ai", Short: "AI provider commands", diff --git a/go.mod b/go.mod index 50d3bfb0..82bff7e2 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( require ( github.com/flanksource/commons-db v0.1.26 + github.com/gliderlabs/ssh v0.3.8 github.com/pelletier/go-toml/v2 v2.4.3 ) @@ -48,6 +49,7 @@ require ( ariga.io/atlas v0.38.0 // indirect cloud.google.com/go/cloudsqlconn v1.22.1 // indirect github.com/agext/levenshtein v1.2.1 // indirect + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect @@ -127,7 +129,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/buger/jsonparser v1.1.2 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect @@ -344,7 +346,7 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect gocloud.dev v0.43.0 // indirect - golang.org/x/crypto v0.53.0 // indirect + golang.org/x/crypto v0.53.0 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.56.0 // indirect diff --git a/hack/gitagent_empirical.sh b/hack/gitagent_empirical.sh new file mode 100755 index 00000000..ed650fad --- /dev/null +++ b/hack/gitagent_empirical.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# gitagent_empirical.sh — verifies the git behaviours the git-agent protocol +# (SPEC-git-agent-protocol §1) relies on, against the git on PATH. +# +# The four properties: +# 1.1 receive-pack quarantine env leaks into hook descendants, and +# `rev-parse --local-env-vars` does NOT list GIT_QUARANTINE_PATH, +# so the githooks(5) scrub idiom leaves it set (R1.1). +# 1.2 push options survive byte-identical under receive.advertisePushOptions, +# and a push with options fails against a receiver that has not +# advertised them (R1.2). +# 1.3 read-tree + checkout-index materializes a quarantined tree when the +# work tree is absolute; the relative form is a trap (R1.3, H18). +# 1.4 a relay push from inside pre-receive succeeds once GIT_QUARANTINE_PATH +# alone is unset, with the inherited object directories retained (R1.4). +# +# Output is TAP-like; exit status is non-zero if any check fails. +# Rerun this before trusting the protocol on a new git version. + +set -u + +# The harness installs repository-local hooks in disposable repositories. +# Ignore host-level hook redirection so a user's core.hooksPath cannot make +# the probes silently skip those hooks and report false substrate failures. +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_CONFIG_NOSYSTEM=1 + +ROOT="$(mktemp -d "${TMPDIR:-/tmp}/gitagent-empirical.XXXXXX")" +trap 'rm -rf "$ROOT"' EXIT + +N=0 +FAIL=0 + +ok() { + N=$((N + 1)) + echo "ok $N - $1" +} + +not_ok() { + N=$((N + 1)) + FAIL=$((FAIL + 1)) + echo "not ok $N - $1" +} + +# assert +assert() { + if [ "$1" -eq 0 ]; then ok "$2"; else not_ok "$2"; fi +} + +g() { + git -c user.name=captain -c user.email=captain@localhost \ + -c init.defaultBranch=main -c protocol.file.allow=always "$@" +} + +# mkclient — working repo with a three-file commit (one nested path). +mkclient() { + g init -q "$1" + mkdir -p "$1/pkg/deep" + echo alpha >"$1/alpha.txt" + echo beta >"$1/pkg/beta.txt" + echo gamma >"$1/pkg/deep/gamma.txt" + g -C "$1" add -A + g -C "$1" commit -q -m seed +} + +echo "# git version: $(git version)" + +# --- 1.1 quarantine leak + --local-env-vars omission -------------------------- + +r11="$ROOT/r11" +client11="$ROOT/client11" +unrelated="$ROOT/unrelated" +out11="$ROOT/out11" +mkdir -p "$out11" +g init -q --bare "$r11" +mkclient "$client11" +mkclient "$unrelated" +unrelated_head="$(g -C "$unrelated" rev-parse HEAD)" + +cat >"$r11/hooks/pre-receive" </dev/null +printf '%s' "\${GIT_QUARANTINE_PATH:-}" >"$out11/quarantine_path" +sh -c "cd '$unrelated' && git update-ref refs/heads/probe-leak $unrelated_head" \ + 2>"$out11/leak_err" +echo \$? >"$out11/leak_rc" +sh -c "unset GIT_QUARANTINE_PATH GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE \ + GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES; \ + cd '$unrelated' && git update-ref refs/heads/probe-scrubbed $unrelated_head" \ + 2>"$out11/scrub_err" +echo \$? >"$out11/scrub_rc" +git rev-parse --local-env-vars >"$out11/local_env_vars" +exit 0 +EOF +chmod +x "$r11/hooks/pre-receive" + +g -C "$client11" push -q "$r11" HEAD:refs/heads/main 2>"$out11/push_err" +assert $? "1.1 push driving the quarantine probe succeeds" + +test -s "$out11/quarantine_path" +assert $? "1.1 GIT_QUARANTINE_PATH is set in the pre-receive environment" + +test "$(cat "$out11/leak_rc" 2>/dev/null)" = "128" +assert $? "1.1 inherited env breaks a descendant git in an unrelated repo (rc=128)" + +test "$(cat "$out11/scrub_rc" 2>/dev/null)" = "0" && + g -C "$unrelated" rev-parse -q --verify refs/heads/probe-scrubbed >/dev/null +assert $? "1.1 scrubbing the R1.1 variable list makes the same command succeed" + +! grep -q GIT_QUARANTINE_PATH "$out11/local_env_vars" +assert $? "1.1 rev-parse --local-env-vars omits GIT_QUARANTINE_PATH" + +# --- 1.2 push options --------------------------------------------------------- + +r12="$ROOT/r12" +client12="$ROOT/client12" +out12="$ROOT/out12" +mkdir -p "$out12" +g init -q --bare "$r12" +g -C "$r12" config receive.advertisePushOptions true +mkclient "$client12" + +cat >"$r12/hooks/pre-receive" </dev/null +printf '%s' "\${GIT_PUSH_OPTION_COUNT:-}" >"$out12/count" +printf '%s' "\${GIT_PUSH_OPTION_0:-}" >"$out12/opt0" +printf '%s' "\${GIT_PUSH_OPTION_1:-}" >"$out12/opt1" +exit 0 +EOF +chmod +x "$r12/hooks/pre-receive" + +g -C "$client12" push -q \ + --push-option=captain-envelope-v1 --push-option=attempt=2 \ + "$r12" HEAD:refs/heads/main 2>"$out12/push_err" +assert $? "1.2 push with options succeeds when advertised" + +test "$(cat "$out12/count")" = "2" && + test "$(cat "$out12/opt0")" = "captain-envelope-v1" && + test "$(cat "$out12/opt1")" = "attempt=2" +assert $? "1.2 both options arrive byte-identical in pre-receive" + +r12b="$ROOT/r12b" +g init -q --bare "$r12b" # receive.advertisePushOptions left at the false default +if g -C "$client12" push -q --push-option=x "$r12b" HEAD:refs/heads/main \ + 2>"$out12/noadv_err"; then + not_ok "1.2 push with options fails against a non-advertising receiver" +else + if g -C "$r12b" rev-parse -q --verify refs/heads/main >/dev/null; then + not_ok "1.2 rejected options push leaves no ref behind" + else + ok "1.2 push with options fails outright when not advertised" + fi +fi + +# --- 1.3 materialization ------------------------------------------------------ + +r13="$ROOT/r13" +client13="$ROOT/client13" +out13="$ROOT/out13" +abswt="$ROOT/abswt" +mkdir -p "$out13" "$abswt" +g init -q --bare "$r13" +mkclient "$client13" + +cat >"$r13/hooks/pre-receive" <"$out13/expected" + +idx="$out13/idx-abs" +GIT_INDEX_FILE="\$idx" git read-tree "\$new" && + GIT_INDEX_FILE="\$idx" GIT_WORK_TREE="$abswt" git checkout-index -a -f +echo \$? >"$out13/abs_rc" +find "$abswt" -type f | wc -l | tr -d ' ' >"$out13/abs_count" + +mkdir -p relwt +idxrel="$out13/idx-rel" +GIT_INDEX_FILE="\$idxrel" git read-tree "\$new" && + GIT_INDEX_FILE="\$idxrel" GIT_WORK_TREE=relwt git checkout-index -a -f +echo \$? >"$out13/rel_rc" +find relwt -type f | wc -l | tr -d ' ' >"$out13/rel_count" +exit 0 +EOF +chmod +x "$r13/hooks/pre-receive" + +g -C "$client13" push -q "$r13" HEAD:refs/heads/main 2>"$out13/push_err" +assert $? "1.3 push driving the materialization probe succeeds" + +expected="$(tr -d ' ' <"$out13/expected")" +test "$(cat "$out13/abs_rc")" = "0" && + test -n "$expected" && test "$expected" -gt 0 && + test "$(cat "$out13/abs_count")" = "$expected" +assert $? "1.3 absolute work-tree materializes the full quarantined tree ($expected files)" + +rel_rc="$(cat "$out13/rel_rc" 2>/dev/null)" +rel_count="$(cat "$out13/rel_count" 2>/dev/null)" +if [ "$rel_rc" = "0" ] && [ "$rel_count" = "0" ]; then + ok "1.3 relative work-tree writes nothing while exiting 0 (H18 confirmed)" +elif [ "$rel_rc" != "0" ]; then + ok "1.3 relative work-tree fails visibly on this git (rc=$rel_rc; safe, H18 moot)" +else + # It materialized where the naive reading expects. Still not a failure for + # the protocol (which always absolutizes) but flag the substrate change. + ok "1.3 relative work-tree materialized $rel_count files on this git # H18 behaviour differs; absolutizing remains correct" +fi + +# --- 1.4 relay from inside pre-receive --------------------------------------- + +sidecar="$ROOT/sidecar" +upstream="$ROOT/upstream" +client14="$ROOT/client14" +out14="$ROOT/out14" +mkdir -p "$out14" +g init -q --bare "$sidecar" +g init -q --bare "$upstream" +mkclient "$client14" + +cat >"$sidecar/hooks/pre-receive" <"$out14/naive_err" +echo \$? >"$out14/naive_rc" +env -u GIT_QUARANTINE_PATH \ + git push "$upstream" "\$new:refs/heads/relay-scrubbed" \ + 2>"$out14/scrubbed_err" +echo \$? >"$out14/scrubbed_rc" +exit 0 +EOF +chmod +x "$sidecar/hooks/pre-receive" + +g -C "$client14" push -q "$sidecar" HEAD:refs/heads/main 2>"$out14/push_err" +assert $? "1.4 push driving the relay probe succeeds" + +test "$(cat "$out14/naive_rc")" != "0" && + grep -qi quarantine "$out14/naive_err" +assert $? "1.4 naive relay is refused by the upstream quarantine guard" + +! g -C "$upstream" rev-parse -q --verify refs/heads/relay-quarantined >/dev/null +assert $? "1.4 refused relay left no ref upstream" + +pushed="$(g -C "$client14" rev-parse HEAD)" +test "$(cat "$out14/scrubbed_rc")" = "0" && + test "$(g -C "$upstream" rev-parse refs/heads/relay-scrubbed 2>/dev/null)" = "$pushed" +assert $? "1.4 unsetting GIT_QUARANTINE_PATH alone lets the relay through" + +# ------------------------------------------------------------------------------ + +echo "1..$N" +if [ "$FAIL" -ne 0 ]; then + echo "# $FAIL of $N checks failed" + exit 1 +fi +echo "# all $N checks passed" diff --git a/pkg/ai/agent/verify/cmd_hardening_test.go b/pkg/ai/agent/verify/cmd_hardening_test.go index 6c7c108e..154d11c0 100644 --- a/pkg/ai/agent/verify/cmd_hardening_test.go +++ b/pkg/ai/agent/verify/cmd_hardening_test.go @@ -2,6 +2,7 @@ package verify import ( "context" + "os" "strings" "testing" "time" @@ -62,6 +63,29 @@ func TestCmdVerifier_StartFailureFeedsBackTheError(t *testing.T) { assert.Contains(t, verdict.Feedback, "captain-no-such-binary") } +// A wrapper that returns no environment must leave the pre-wrap boundary in +// place, not fall through to full process inheritance: the git-agent hook path +// hands a deliberately reduced Env to Wrap, and inheriting the process +// environment would silently expose every ambient credential to an +// agent-authored command (issue #40). +func TestCmdVerifier_WrapWithNilEnvKeepsTheDeclaredBoundary(t *testing.T) { + t.Setenv("CAPTAIN_TEST_AMBIENT_SECRET", "leaked") + + v := &CmdVerifier{ + Cmd: "sh", + Args: []string{"-c", `test -z "$CAPTAIN_TEST_AMBIENT_SECRET" && test "$MARKER" = ok`}, + Env: []string{"PATH=" + os.Getenv("PATH"), "MARKER=ok"}, + Wrap: func(_ context.Context, cmd string, args, _ []string) (string, []string, []string, error) { + return cmd, args, nil, nil // a wrapper that supplies no environment + }, + } + + verdict, err := v.Verify(context.Background(), t.TempDir(), nil) + + require.NoError(t, err) + assert.True(t, verdict.OK, "declared env must reach the command and the ambient secret must not: %s", verdict.Feedback) +} + // A parent deadline shorter than the verifier's own Timeout is the RUN's // cancellation, not the command's: it must come back as an error, never as a // verdict blaming the command for "timing out after ". diff --git a/pkg/ai/agent/verify/verify.go b/pkg/ai/agent/verify/verify.go index cf1081b0..d33039bb 100644 --- a/pkg/ai/agent/verify/verify.go +++ b/pkg/ai/agent/verify/verify.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "os" "os/exec" "strings" "sync" @@ -46,6 +47,11 @@ func New(name string, v Verifier) *Plugin { return &Plugin{name: name, v: v} } func (p *Plugin) Name() string { return p.name } +// Verifier exposes the wrapped Verifier so a caller that runs checks outside +// an agent loop — the git-agent receive path — can drive it directly with its +// own cwd and changed set instead of an agent.HookContext. +func (p *Plugin) Verifier() Verifier { return p.v } + func (p *Plugin) Verify(hc *agent.HookContext) (agent.VerifyResult, error) { ws := hc.Workspace() var changed []string @@ -95,10 +101,18 @@ type CmdVerifier struct { Cmd string Args []string PerFile bool - FeedbackTail int // max bytes of output fed back; 0 ⇒ 4096 - Timeout time.Duration // wall-clock bound; 0 ⇒ DefaultCmdTimeout + FeedbackTail int // max bytes of output fed back; 0 ⇒ 4096 + Timeout time.Duration // wall-clock bound; 0 ⇒ DefaultCmdTimeout + Env []string // command environment; nil ⇒ inherit the process's + Wrap CommandWrapFunc // optional confinement seam; see CommandWrapFunc } +// CommandWrapFunc rewrites a command for confined execution. It mirrors +// api.CommandWrapper's Wrap signature so a resolved sandbox adapter plugs in +// directly — hook inputs are untrusted, so a receive path must never exec +// them bare on the host (issue #40 R5.2). +type CommandWrapFunc func(ctx context.Context, cmd string, args, env []string) (string, []string, []string, error) + func (c *CmdVerifier) Verify(ctx context.Context, cwd string, changed []string) (Verdict, error) { args := append([]string(nil), c.Args...) if c.PerFile { @@ -120,8 +134,31 @@ func (c *CmdVerifier) Verify(ctx context.Context, cwd string, changed []string) } output := &tailBuffer{max: tail} - cmd := exec.CommandContext(runCtx, c.Cmd, args...) + command, cmdArgs, env := c.Cmd, args, c.Env + if c.Wrap != nil { + wrapEnv := env + if wrapEnv == nil { + wrapEnv = os.Environ() + } + var err error + command, cmdArgs, env, err = c.Wrap(ctx, command, cmdArgs, wrapEnv) + if err != nil { + return Verdict{}, fmt.Errorf("wrapping %s for sandboxed execution: %w", c.Cmd, err) + } + if env == nil { + // A wrapper that supplies no environment keeps the pre-wrap + // boundary. Leaving env nil here would hand the wrapped process + // the full inherited environment — silently widening a caller's + // deliberately reduced Env (the git-agent hook path, issue #40). + env = wrapEnv + } + } + + cmd := exec.CommandContext(runCtx, command, cmdArgs...) cmd.Dir = cwd + if env != nil { + cmd.Env = env + } cmd.Stdout, cmd.Stderr = output, output // Own process group, and cancellation kills the group: signalling only the // pid leaves a hook's children running after their parent is dead. diff --git a/pkg/aichat/aimock_lifecycle_integration_test.go b/pkg/aichat/aimock_lifecycle_integration_test.go index 94f1b7f7..a95ad0d1 100644 --- a/pkg/aichat/aimock_lifecycle_integration_test.go +++ b/pkg/aichat/aimock_lifecycle_integration_test.go @@ -330,15 +330,26 @@ func runApprovalFlow( "user-"+strings.ToLower(verb), verb+" the account update") }() var pending session.Session + // The chat result is consumed at most once across poll retries: a value + // drained inside a failed Eventually iteration would otherwise be lost and + // the final receive below would wait on an already-empty channel. + var chat httpResult + chatReceived := false Eventually(func(g Gomega) { pending = getLifecycleSession(ctx, client, baseURL, thread.ID) if len(pending.Requests) == 0 { - select { - case chat := <-chatResult: + if !chatReceived { + select { + case chat = <-chatResult: + chatReceived = true + default: + } + } + if chatReceived { g.Expect(chat.err).NotTo(HaveOccurred()) g.Expect(chat.status).To(Equal(http.StatusOK), string(chat.body)) g.Expect(pending.Requests).To(HaveLen(1), string(chat.body)) - default: + } else { g.Expect(pending.Requests).To(HaveLen(1), lifecycleRequestsJSON(mockServer.Requests())) } return @@ -361,8 +372,9 @@ func runApprovalFlow( g.Expect(completed.Turns[0].Status).To(Equal(string(database.TurnStatusEnded)), lifecycleRequestsJSON(mockServer.Requests())) }).WithTimeout(30 * time.Second).Should(Succeed()) - var chat httpResult - Eventually(chatResult).WithTimeout(30 * time.Second).Should(Receive(&chat)) + if !chatReceived { + Eventually(chatResult).WithTimeout(30 * time.Second).Should(Receive(&chat)) + } Expect(chat.err).NotTo(HaveOccurred()) Expect(chat.status).To(Equal(http.StatusOK), string(chat.body)) conflict := postLifecycleJSON(ctx, client, http.MethodPost, diff --git a/pkg/aichat/approval_execution.go b/pkg/aichat/approval_execution.go index fdc50326..578b29e2 100644 --- a/pkg/aichat/approval_execution.go +++ b/pkg/aichat/approval_execution.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" aitools "github.com/flanksource/captain/pkg/ai/tools" "github.com/flanksource/captain/pkg/api" @@ -53,24 +54,13 @@ func (s *Service) resumeToolApproval(ctx context.Context, threadID string, conti return fmt.Errorf("backend %q does not support caller tools", provider.GetBackend()) } } - store, err := s.threads(ctx) + seed, err := s.awaitSuspendedSeed(ctx, threadID, execution.TurnID()) if err != nil { return err } - thread, err := store.Get(ctx, threadID) - if err != nil { - return err - } - if len(thread.Messages) == 0 { - return fmt.Errorf("captain chat session %s has no suspended assistant message", threadID) - } - seed := thread.Messages[len(thread.Messages)-1] - if !strings.EqualFold(seed.Role, string(api.RoleAssistant)) || seed.TurnID != execution.TurnID() { - return fmt.Errorf("captain chat session %s does not end with the suspended turn %s", threadID, execution.TurnID()) - } request := ChatRequest{ ID: threadID, ThreadID: threadID, Trigger: "submit-message", MessageID: seed.ID, - Messages: []UIMessage{seed}, ToolApproval: continuation.Spec.ToolApproval, + Messages: []UIMessage{*seed}, ToolApproval: continuation.Spec.ToolApproval, } streamContext, cancel := context.WithCancel(ctx) defer cancel() @@ -97,3 +87,45 @@ func (s *Service) resumeToolApproval(ctx context.Context, threadID string, conti } return nil } + +// suspendedSeedWait bounds how long an approval resolution waits for the +// suspending turn's assistant message to land in the thread store. +const suspendedSeedWait = 5 * time.Second + +// awaitSuspendedSeed returns the thread's trailing assistant message for the +// suspended turn. The prompt run reaches its waiting state from the event +// pipeline before the suspending stream persists that message on its final +// unwind, so an approval resolved from a session poll can arrive while the +// write is still in flight. The run's durable approval state guarantees the +// message is committed or imminent — wait it out instead of failing a +// resolution that has already consumed the approval. +func (s *Service) awaitSuspendedSeed(ctx context.Context, threadID, turnID string) (*UIMessage, error) { + store, err := s.threads(ctx) + if err != nil { + return nil, err + } + deadline := time.Now().Add(suspendedSeedWait) + for { + thread, err := store.Get(ctx, threadID) + if err != nil { + return nil, err + } + if count := len(thread.Messages); count > 0 { + seed := thread.Messages[count-1] + if strings.EqualFold(seed.Role, string(api.RoleAssistant)) && seed.TurnID == turnID { + return &seed, nil + } + } + if time.Now().After(deadline) { + if len(thread.Messages) == 0 { + return nil, fmt.Errorf("captain chat session %s has no suspended assistant message", threadID) + } + return nil, fmt.Errorf("captain chat session %s does not end with the suspended turn %s", threadID, turnID) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } +} diff --git a/pkg/api/cli_options.go b/pkg/api/cli_options.go index cc036b36..060ade05 100644 --- a/pkg/api/cli_options.go +++ b/pkg/api/cli_options.go @@ -111,6 +111,8 @@ func CodexSafety(p Permissions) (CodexSandbox, CodexApprovalPolicy) { switch { case p.Mode == PermissionBypass: return CodexSandboxDangerFull, CodexApprovalNever + case p.Mode == PermissionAcceptEdits, p.Mode == PermissionAuto: + return CodexSandboxWorkspaceWrite, CodexApprovalOnRequest case p.HasPreset(PresetEdit) && p.Mode == "": return CodexSandboxWorkspaceWrite, CodexApprovalOnRequest default: diff --git a/pkg/api/cli_options_test.go b/pkg/api/cli_options_test.go index 5c7e043c..6f41b1ba 100644 --- a/pkg/api/cli_options_test.go +++ b/pkg/api/cli_options_test.go @@ -63,6 +63,8 @@ func TestCodexSafety(t *testing.T) { wantApproval CodexApprovalPolicy }{ {"bypass is full access", Permissions{Mode: PermissionBypass}, CodexSandboxDangerFull, CodexApprovalNever}, + {"accept edits is workspace write", Permissions{Mode: PermissionAcceptEdits}, CodexSandboxWorkspaceWrite, CodexApprovalOnRequest}, + {"auto is workspace write", Permissions{Mode: PermissionAuto}, CodexSandboxWorkspaceWrite, CodexApprovalOnRequest}, {"edit preset is workspace write", Permissions{Presets: []Preset{PresetEdit}}, CodexSandboxWorkspaceWrite, CodexApprovalOnRequest}, {"default is read-only", Permissions{}, CodexSandboxReadOnly, CodexApprovalOnRequest}, {"edit preset with explicit mode stays read-only", Permissions{Mode: PermissionDefault, Presets: []Preset{PresetEdit}}, CodexSandboxReadOnly, CodexApprovalOnRequest}, diff --git a/pkg/api/sandbox.go b/pkg/api/sandbox.go index a17dfd9e..315e19ec 100644 --- a/pkg/api/sandbox.go +++ b/pkg/api/sandbox.go @@ -90,6 +90,19 @@ type RemoteExecutor interface { Execute(ctx context.Context, spec Spec) (*Response, error) } +// WorkspaceIsolating marks an adapter whose run happens in its own working +// tree, so selecting it must not be combined with another workspace isolator +// (a --worktree run or a setup checkout). +type WorkspaceIsolating interface { + IsolatesWorkspace() bool +} + +// EgressProxied marks an adapter whose sandbox never holds a real credential: +// placeholders are substituted outside it by an egress proxy. +type EgressProxied interface { + ProvidesEgressProxy() bool +} + // SandboxAs resolves a capability from a sandbox, walking SandboxUnwrapper // chains so a capability is found behind decorators. Mirrors ProviderAs. func SandboxAs[T any](sandbox Sandbox) (T, bool) { diff --git a/pkg/api/sandbox_ginkgo_test.go b/pkg/api/sandbox_ginkgo_test.go index c1d0791b..a3819106 100644 --- a/pkg/api/sandbox_ginkgo_test.go +++ b/pkg/api/sandbox_ginkgo_test.go @@ -103,7 +103,7 @@ var _ = Describe("NewSandbox", func() { descriptor, ok := api.SandboxFor(api.SandboxSRT) Expect(ok).To(BeTrue()) original := append([]api.SandboxCapability(nil), descriptor.Capabilities...) - descriptor.Capabilities = append(descriptor.Capabilities, api.CapabilityEgressProxy) + descriptor.Capabilities = append(descriptor.Capabilities, api.SandboxCapability("future-capability")) DeferCleanup(func() { descriptor.Capabilities = original }) api.RegisterSandbox(api.SandboxSRT, func(cfg api.SandboxConfig) (api.Sandbox, error) { return wrappingSandboxStub{sandboxStub{kind: api.SandboxSRT}}, nil @@ -111,7 +111,22 @@ var _ = Describe("NewSandbox", func() { _, err := api.NewSandbox(api.SandboxConfig{Kind: api.SandboxSRT}) - Expect(err).To(MatchError(ContainSubstring(`capability "egress-proxy" but no construction-time verifier`))) + Expect(err).To(MatchError(ContainSubstring(`capability "future-capability" but no construction-time verifier`))) + }) + + It("rejects an adapter that fails a declared capability's verifier", func() { + descriptor, ok := api.SandboxFor(api.SandboxSRT) + Expect(ok).To(BeTrue()) + original := append([]api.SandboxCapability(nil), descriptor.Capabilities...) + descriptor.Capabilities = append(descriptor.Capabilities, api.CapabilityEgressProxy) + DeferCleanup(func() { descriptor.Capabilities = original }) + api.RegisterSandbox(api.SandboxSRT, func(cfg api.SandboxConfig) (api.Sandbox, error) { + return wrappingSandboxStub{sandboxStub{kind: api.SandboxSRT}}, nil // no EgressProxied + }) + + _, err := api.NewSandbox(api.SandboxConfig{Kind: api.SandboxSRT}) + + Expect(err).To(MatchError(ContainSubstring(`declares capability "egress-proxy" but its adapter does not implement it`))) }) It("accepts an adapter that implements its declared capabilities", func() { diff --git a/pkg/api/sandbox_registry.go b/pkg/api/sandbox_registry.go index 077a341b..53bd4364 100644 --- a/pkg/api/sandbox_registry.go +++ b/pkg/api/sandbox_registry.go @@ -19,8 +19,29 @@ type SandboxConfig struct { // Options carries the kind-specific settings verbatim. Each adapter decodes // its own; an unknown key is the adapter's error to raise, not this layer's. Options map[string]any `json:"options,omitempty" yaml:"options,omitempty"` + // Agent pins one enrolled agent of a git-agent backend, from + // SandboxRef.Agent. Empty lets the adapter choose. + Agent string `json:"agent,omitempty" yaml:"agent,omitempty"` + // Policy is the per-run override from SandboxRef.Policy. + Policy *SandboxPolicy `json:"policy,omitempty" yaml:"policy,omitempty"` } +// Options keys shared between the git-agent hook resolver and the adapters it +// constructs. They live here because the resolver (pkg/gitagent) cannot import +// the adapters (pkg/sandbox/adapter imports pkg/gitagent). +const ( + // SandboxOptionProfile selects a policy profile within one adapter kind. + SandboxOptionProfile = "profile" + // SandboxProfileHook is the generic exec-hook profile: the wrapped command + // is untrusted, agent-authored repository code, so it gets its prepared + // workspace and nothing else — no network, no provider credentials or + // state, no host credentials (issue #40 R5.2). + SandboxProfileHook = "hook" + // SandboxOptionDenyRead carries extra deny-read paths ([]string) the + // profile must hide — for hooks, the receiving repository itself. + SandboxOptionDenyRead = "denyRead" +) + // SandboxFactory constructs a Sandbox from a SandboxConfig. type SandboxFactory func(cfg SandboxConfig) (Sandbox, error) @@ -87,6 +108,14 @@ func NewSandbox(cfg SandboxConfig) (Sandbox, error) { var sandboxCapabilityChecks = map[SandboxCapability]func(Sandbox) bool{ CapabilityWrapCommand: func(s Sandbox) bool { _, ok := SandboxAs[CommandWrapper](s); return ok }, CapabilityRemoteExec: func(s Sandbox) bool { _, ok := SandboxAs[RemoteExecutor](s); return ok }, + CapabilityIsolateWorkspace: func(s Sandbox) bool { + iso, ok := SandboxAs[WorkspaceIsolating](s) + return ok && iso.IsolatesWorkspace() + }, + CapabilityEgressProxy: func(s Sandbox) bool { + proxy, ok := SandboxAs[EgressProxied](s) + return ok && proxy.ProvidesEgressProxy() + }, } func verifySandboxCapabilities(descriptor *SandboxDescriptor, sandbox Sandbox) error { diff --git a/pkg/captainconfig/config.go b/pkg/captainconfig/config.go index e9eedfd1..7d0c9f2f 100644 --- a/pkg/captainconfig/config.go +++ b/pkg/captainconfig/config.go @@ -236,13 +236,22 @@ type SchemaRepairDefaults struct { Prompt string `yaml:"prompt,omitempty"` } -// pathOverride lets tests redirect Path() to a temp directory without touching -// $HOME. Empty string means "use os.UserHomeDir". +// pathOverride redirects Path() away from $HOME. Empty string means +// "use os.UserHomeDir". var pathOverride string +// SetPath redirects Path() to an explicit config file. It exists for +// processes that cannot rely on $HOME: a git receive hook runs as a child of +// whoever pushed, so its ambient home is the pusher's, not the one the +// receiver was configured under. +// +// Call SetPath during process startup, before concurrent calls to Path. The +// override is process-global and intentionally unsynchronized. +func SetPath(p string) { pathOverride = p } + // SetPathForTesting redirects Path() to the given absolute file path. Tests // must call it with t.Cleanup(func() { SetPathForTesting("") }). -func SetPathForTesting(p string) { pathOverride = p } +func SetPathForTesting(p string) { SetPath(p) } // Path returns the absolute path to the captain config file. Currently fixed // at ~/.captain.yaml; the location is intentionally not configurable via env diff --git a/pkg/captainconfig/sandbox_test.go b/pkg/captainconfig/sandbox_test.go index fc6e493f..4789f2d0 100644 --- a/pkg/captainconfig/sandbox_test.go +++ b/pkg/captainconfig/sandbox_test.go @@ -80,7 +80,7 @@ backends: prod-pool: kind: git-agent relay: sync - mailbox: ~/.captain/sandbox/mailbox.git + mailboxRoot: ~/.captain/sandbox/repos local-docker: kind: container presets: [golang, git] diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index de6c8ed8..2a9a0717 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -129,7 +129,7 @@ func (o AIProviderOptions) ToConfig() (ai.Config, error) { APIKey: o.APIKey, APIURL: strings.TrimSpace(o.APIURL), Sandbox: sandbox.Kind == registry.SandboxSRT, - SandboxSelection: sandboxSelectionConfig(sandbox), + SandboxSelection: sandboxSelectionConfig(sandbox, nil), NoCache: o.NoCache || saved.NoCache, SchemaRepair: schemaRepairConfig(savedCfg.Prompts.SchemaRepair), }, nil @@ -214,7 +214,7 @@ type AIPromptOptions struct { Var []string `flag:"var" help:"Template variable key=value (repeatable)" short:"V"` Attach []string `flag:"attach" help:"Attach a local path or URL (repeatable; RFC 4180 comma-separated values allowed)" short:"A"` MultiModels []string `flag:"multi-models" help:"Run prompt once per runtime selector in parallel, e.g. cli:sonnet-5,cmux:opus (repeatable; comma-separated allowed)" short:"M"` - Timeout string `flag:"timeout" help:"Request timeout" default:"120s"` + Timeout string `flag:"timeout" help:"Request timeout (default 120s; a relocating sandbox waits for the remote agent instead)"` NoStream bool `flag:"no-stream" help:"Disable streaming; print only the final text to stdout"` } @@ -335,7 +335,7 @@ func (o AIPromptOptions) ToRequest() (ai.Request, error) { } func executePromptRequest(parent context.Context, req ai.Request, cfg ai.Config, timeout time.Duration, noStream bool) (any, error) { - ctx, cancel := runContext(parent, req, timeout) + ctx, cancel := runContext(parent, req, remoteAwareTimeout(req, cfg, timeout)) defer cancel() if err := preparePromptAttachments(ctx, &req, cfg); err != nil { return nil, err @@ -346,6 +346,7 @@ func executePromptRequest(parent context.Context, req ai.Request, cfg ai.Config, return nil, err } defer cleanup() + defer closeProvider(p) if streamer, ok := p.(ai.StreamingProvider); ok && !noStream && !req.Prompt.HasSchema() { return runStreaming(ctx, streamer, req) @@ -455,6 +456,20 @@ func buildProvider(ctx context.Context, req *ai.Request, cfg ai.Config) (ai.Prov if req.NoCache { cfg.NoCache = true } + // A remote-executing sandbox replaces provider execution wholesale: the + // run happens on another machine, so no local setup checkout, no CLI + // process, no streaming. Resolved before setup.Apply because the sandbox + // is itself a workspace isolator — a local checkout would double-isolate. + if remote, err := remoteExecProviderFor(req, cfg); err != nil { + return nil, cleanup, err + } else if remote != nil { + wrapped, err := middleware.Wrap(remote, middleware.WithLogging(), middleware.WithSchemaValidation(cfg)) + if err != nil { + closeProvider(remote) + return nil, cleanup, err + } + return bufferedOnlyProvider{Provider: wrapped}, cleanup, nil + } prepared, err := setup.Apply(ctx, req, "") if err != nil { return nil, cleanup, err diff --git a/pkg/cli/ai_prompt_file.go b/pkg/cli/ai_prompt_file.go index 541328de..71d3787e 100644 --- a/pkg/cli/ai_prompt_file.go +++ b/pkg/cli/ai_prompt_file.go @@ -187,9 +187,16 @@ func overlayCLI(base ai.Request, baseCfg ai.Config, o AIPromptOptions) (ai.Reque } // Record the winning sandbox on the request when the flag overrode it, so the - // serialized spec carries the choice the run was actually made with. + // serialized spec carries the choice the run was actually made with. The + // selector only replaces the backend; an independently declared agent and + // policy remain part of the request. if selector := o.SandboxSelector(); selector != "" { - req.Sandbox = &api.SandboxRef{Backend: selector} + ref := api.SandboxRef{Backend: selector} + if base.Sandbox != nil { + ref.Agent = base.Sandbox.Agent + ref.Policy = base.Sandbox.Policy + } + req.Sandbox = &ref } // Config mirrors the resolved model + budget; runtime-only knobs from CLI+saved. @@ -202,7 +209,9 @@ func overlayCLI(base ai.Request, baseCfg ai.Config, o AIPromptOptions) (ai.Reque // so it overwrites rather than ORs with baseCfg: an explicit "none" must be // able to turn an inherited srt selection OFF. cfg.Sandbox = sandbox.Kind == registry.SandboxSRT - cfg.SandboxSelection = sandboxSelectionConfig(sandbox) + // req.Sandbox is the winning ref (frontmatter, or the flag's override + // recorded above), so its agent pin and policy ride along. + cfg.SandboxSelection = sandboxSelectionConfig(sandbox, req.Sandbox) cfg.NoCache = req.NoCache return req, cfg, nil } diff --git a/pkg/cli/ai_prompt_file_test.go b/pkg/cli/ai_prompt_file_test.go index 1d03de4e..89e8091b 100644 --- a/pkg/cli/ai_prompt_file_test.go +++ b/pkg/cli/ai_prompt_file_test.go @@ -196,6 +196,31 @@ func TestOverlayCLI_SandboxNoneClearsInherited(t *testing.T) { } } +func TestOverlayCLI_SandboxFlagPreservesFrontmatterAgentAndPolicy(t *testing.T) { + isolateSavedAI(t) + base := baseFileReq() + base.Sandbox = &api.SandboxRef{ + Backend: "old-pool", + Agent: "worker-01", + Policy: &api.SandboxPolicy{Paths: []string{"pkg/**"}, MaxAttempts: 3}, + } + opts := AIPromptOptions{AIRuntimeOptions: AIRuntimeOptions{AIProviderOptions: AIProviderOptions{Sandbox: "git-agent"}}} + + req, cfg, err := overlayCLI(base, ai.Config{}, opts) + if err != nil { + t.Fatal(err) + } + if req.Sandbox == nil || req.Sandbox.Backend != "git-agent" || req.Sandbox.Agent != "worker-01" { + t.Fatalf("request sandbox = %#v", req.Sandbox) + } + if req.Sandbox.Policy == nil || req.Sandbox.Policy.MaxAttempts != 3 { + t.Fatalf("request policy = %#v", req.Sandbox.Policy) + } + if cfg.SandboxSelection == nil || cfg.SandboxSelection.Agent != "worker-01" || cfg.SandboxSelection.Policy != req.Sandbox.Policy { + t.Fatalf("config sandbox = %#v", cfg.SandboxSelection) + } +} + // --api-url is what points a run at a `captain ai mock` endpoint, so it has to // survive the overlay; a prompt file may pin its own endpoint, and the flag wins. func TestOverlayCLI_APIURLFlagBeatsFrontmatter(t *testing.T) { diff --git a/pkg/cli/ai_sandbox.go b/pkg/cli/ai_sandbox.go index c9716b39..f76d3492 100644 --- a/pkg/cli/ai_sandbox.go +++ b/pkg/cli/ai_sandbox.go @@ -26,12 +26,12 @@ func resolveSandboxSelection(flagSelector string, ref *api.SandboxRef, defaults return captainconfig.SandboxSelection{}, err } switch selection.Kind { - case registry.SandboxNone, registry.SandboxSRT, registry.SandboxContainer: + case registry.SandboxNone, registry.SandboxSRT, registry.SandboxContainer, registry.SandboxGitAgent: return selection, nil default: return captainconfig.SandboxSelection{}, fmt.Errorf( - "sandbox kind %q is not wired to execution yet (supported today: %s, %s, %s)", - selection.Kind, registry.SandboxNone, registry.SandboxSRT, registry.SandboxContainer) + "sandbox kind %q is not wired to execution yet (supported today: %s, %s, %s, %s)", + selection.Kind, registry.SandboxNone, registry.SandboxSRT, registry.SandboxContainer, registry.SandboxGitAgent) } } @@ -51,10 +51,16 @@ func sandboxForcedMode(kind registry.SandboxKind) registry.RuntimeMode { // sandboxSelectionConfig projects a resolved selection onto the runtime // config. "none" stays nil, so an unsandboxed run carries no selection at all -// and the exec seam's nil check keeps its meaning. -func sandboxSelectionConfig(selection captainconfig.SandboxSelection) *api.SandboxConfig { +// and the exec seam's nil check keeps its meaning. ref, when present, carries +// the per-prompt agent pin and policy override (git-agent). +func sandboxSelectionConfig(selection captainconfig.SandboxSelection, ref *api.SandboxRef) *api.SandboxConfig { if selection.Kind == registry.SandboxNone { return nil } - return &api.SandboxConfig{Kind: selection.Kind, Name: selection.Name, Options: selection.Options} + cfg := &api.SandboxConfig{Kind: selection.Kind, Name: selection.Name, Options: selection.Options} + if ref != nil { + cfg.Agent = ref.Agent + cfg.Policy = ref.Policy + } + return cfg } diff --git a/pkg/cli/ai_sandbox_remote.go b/pkg/cli/ai_sandbox_remote.go new file mode 100644 index 00000000..320ee947 --- /dev/null +++ b/pkg/cli/ai_sandbox_remote.go @@ -0,0 +1,104 @@ +package cli + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/api/registry" + "github.com/flanksource/captain/pkg/sandbox/adapter" +) + +// relocatesRun reports whether the selection replaces provider execution with +// a run on another machine. +func relocatesRun(cfg ai.Config) bool { + if cfg.SandboxSelection == nil { + return false + } + descriptor, ok := registry.SandboxFor(cfg.SandboxSelection.Kind) + return ok && descriptor.Has(registry.CapabilityRemoteExec) +} + +// remoteAwareTimeout sizes the run's deadline for where the work happens. A +// relocated run blocks on a remote agent for as long as that agent takes, so +// the local request default — sized for a model call — would kill a dispatch +// that is still progressing and report it as a failure. An explicitly declared +// timeout (--timeout or budget.timeout) always wins. +func remoteAwareTimeout(req ai.Request, cfg ai.Config, timeout time.Duration) time.Duration { + if strings.TrimSpace(req.Budget.Timeout) != "" || !relocatesRun(cfg) { + return timeout + } + return adapter.WaitTimeout(cfg.SandboxSelection.Options) +} + +// renderedTimeout is remoteAwareTimeout for an already-rendered prompt, used +// by the stream and batch paths that size their own deadline. +func renderedTimeout(rendered PromptRenderResult) time.Duration { + return remoteAwareTimeout(rendered.Input, rendered.Config, runtimeTimeout(rendered.Input.Budget.Timeout)) +} + +// remoteExecProviderFor returns a provider backed by the resolved sandbox's +// RemoteExecutor when the selection has that capability, nil otherwise. This +// is the run-path branch for whole-run relocation (git-agent): it sits above +// provider construction because the adapter replaces execution, not the argv. +func remoteExecProviderFor(req *ai.Request, cfg ai.Config) (ai.Provider, error) { + if !relocatesRun(cfg) { + return nil, nil + } + selection := cfg.SandboxSelection + sandbox, err := api.NewSandbox(*selection) + if err != nil { + return nil, err + } + executor, ok := api.SandboxAs[api.RemoteExecutor](sandbox) + if !ok { + _ = sandbox.Close() + return nil, fmt.Errorf("sandbox %q declares remote execution but provides none", selection.Kind) + } + if _, err := sandbox.Prepare(context.Background(), req); err != nil { + _ = sandbox.Close() + return nil, err + } + return &remoteExecProvider{executor: executor, sandbox: sandbox, model: cfg.Model.Name, backend: cfg.Model.Backend}, nil +} + +// remoteExecProvider adapts a RemoteExecutor to ai.Provider. Streaming is +// deliberately absent — the run happens elsewhere and comes back whole; the +// buffered workflow path synthesizes events from Execute. +type remoteExecProvider struct { + executor api.RemoteExecutor + sandbox api.Sandbox + model string + backend api.Backend + close sync.Once + closeErr error +} + +// bufferedOnlyProvider preserves a provider's buffered capability after +// middleware wrapping. Middleware supports streaming when its inner provider +// does, but its wrapper methods must not advertise streaming for a remote run. +type bufferedOnlyProvider struct{ ai.Provider } + +func (p bufferedOnlyProvider) Unwrap() ai.Provider { return p.Provider } + +func (p *remoteExecProvider) Execute(ctx context.Context, req ai.Request) (*ai.Response, error) { + return p.executor.Execute(ctx, req) +} + +func (p *remoteExecProvider) GetModel() string { return p.model } +func (p *remoteExecProvider) GetBackend() api.Backend { return p.backend } + +// Close releases the prepared remote sandbox. It is idempotent because both +// provider setup failures and execution completion can reach this boundary. +func (p *remoteExecProvider) Close() error { + p.close.Do(func() { + if p.sandbox != nil { + p.closeErr = p.sandbox.Close() + } + }) + return p.closeErr +} diff --git a/pkg/cli/ai_sandbox_remote_test.go b/pkg/cli/ai_sandbox_remote_test.go new file mode 100644 index 00000000..88f42f1a --- /dev/null +++ b/pkg/cli/ai_sandbox_remote_test.go @@ -0,0 +1,154 @@ +package cli + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/api/registry" + "github.com/flanksource/captain/pkg/sandbox/adapter" +) + +type remoteSandboxStub struct{ closes int } + +func (s *remoteSandboxStub) Kind() api.SandboxKind { return registry.SandboxGitAgent } +func (s *remoteSandboxStub) Prepare(context.Context, *api.Spec) (*api.SandboxSession, error) { + return &api.SandboxSession{}, nil +} +func (s *remoteSandboxStub) Close() error { s.closes++; return nil } + +type remoteExecutorStub struct{ err error } + +func (s remoteExecutorStub) Execute(context.Context, api.Spec) (*api.Response, error) { + return nil, s.err +} + +// A remote-executing selection must replace provider execution. Without this +// the run silently falls through to the model provider and the sandbox is a +// no-op — which is indistinguishable from success until a dispatch never +// arrives. +func TestRemoteExecProviderForRoutesGitAgent(t *testing.T) { + req := &ai.Request{} + cfg := ai.Config{ + SandboxSelection: &api.SandboxConfig{ + Kind: registry.SandboxGitAgent, + Name: "git-agent", + Options: map[string]any{ + "agents": map[string]any{ + "worker-01": map[string]any{ + "fingerprint": "SHA256:key", + "url": "ssh://captain@127.0.0.1:7502/repo.git", + "hostFingerprint": "SHA256:host", + }, + }, + }, + }, + } + provider, err := remoteExecProviderFor(req, cfg) + if err != nil { + t.Fatalf("remoteExecProviderFor: %v", err) + } + if provider == nil { + t.Fatal("a git-agent selection must produce a remote-executing provider, not fall through to the model") + } + if _, isRemote := provider.(*remoteExecProvider); !isRemote { + t.Fatalf("provider = %T, want *remoteExecProvider", provider) + } +} + +func TestBuildProviderWrapsRemoteWithoutAdvertisingStreaming(t *testing.T) { + req := &ai.Request{} + cfg := ai.Config{SandboxSelection: &api.SandboxConfig{Kind: registry.SandboxGitAgent}} + provider, cleanup, err := buildProvider(context.Background(), req, cfg) + if err != nil { + t.Fatal(err) + } + defer cleanup() + defer closeProvider(provider) + if _, ok := provider.(ai.StreamingProvider); ok { + t.Fatalf("wrapped remote provider %T advertises unsupported streaming", provider) + } + if _, ok := api.ProviderAs[*remoteExecProvider](provider); !ok { + t.Fatalf("wrapped provider %T hides the remote provider", provider) + } +} + +// A relocated run waits on a remote agent doing real work. Inheriting the +// local request default kills a dispatch that is still progressing and reports +// it as a failure ("dispatched but not concluded: context deadline exceeded"). +func TestRemoteAwareTimeout(t *testing.T) { + remote := ai.Config{SandboxSelection: &api.SandboxConfig{Kind: registry.SandboxGitAgent}} + local := ai.Config{SandboxSelection: &api.SandboxConfig{Kind: registry.SandboxSRT}} + const requestDefault = 120 * time.Second + + t.Run("a relocated run waits for the agent, not the model", func(t *testing.T) { + got := remoteAwareTimeout(ai.Request{}, remote, requestDefault) + if got != adapter.DefaultWaitTimeout { + t.Fatalf("timeout = %s, want the remote wait budget %s", got, adapter.DefaultWaitTimeout) + } + }) + + t.Run("the backend's waitTimeout is honoured", func(t *testing.T) { + scoped := ai.Config{SandboxSelection: &api.SandboxConfig{ + Kind: registry.SandboxGitAgent, + Options: map[string]any{"waitTimeout": "15m"}, + }} + if got := remoteAwareTimeout(ai.Request{}, scoped, requestDefault); got != 15*time.Minute { + t.Fatalf("timeout = %s, want 15m", got) + } + }) + + t.Run("an explicit timeout always wins", func(t *testing.T) { + req := ai.Request{} + req.Budget.Timeout = "45s" + // runContext applies the declared budget itself; the point here is that + // the remote default does not override an explicit choice. + if got := remoteAwareTimeout(req, remote, requestDefault); got != requestDefault { + t.Fatalf("timeout = %s, want the caller's %s left untouched", got, requestDefault) + } + }) + + t.Run("local sandboxes keep the request default", func(t *testing.T) { + for name, cfg := range map[string]ai.Config{"srt": local, "none": {}} { + if got := remoteAwareTimeout(ai.Request{}, cfg, requestDefault); got != requestDefault { + t.Fatalf("%s: timeout = %s, want %s", name, got, requestDefault) + } + } + }) +} + +func TestRemoteExecProviderForLeavesLocalSandboxesAlone(t *testing.T) { + for _, selection := range []*api.SandboxConfig{ + nil, + {Kind: registry.SandboxSRT}, + {Kind: registry.SandboxContainer}, + } { + provider, err := remoteExecProviderFor(&ai.Request{}, ai.Config{SandboxSelection: selection}) + if err != nil { + t.Fatalf("selection %v: %v", selection, err) + } + if provider != nil { + t.Fatalf("selection %v produced %T; only remote-exec kinds replace the provider", selection, provider) + } + } +} + +func TestRemoteExecProviderCloseIsIdempotentAfterFailure(t *testing.T) { + sandbox := &remoteSandboxStub{} + provider := &remoteExecProvider{executor: remoteExecutorStub{err: errors.New("failed")}, sandbox: sandbox} + if _, err := provider.Execute(context.Background(), ai.Request{}); err == nil { + t.Fatal("Execute returned nil error") + } + if err := provider.Close(); err != nil { + t.Fatal(err) + } + if err := provider.Close(); err != nil { + t.Fatal(err) + } + if sandbox.closes != 1 { + t.Fatalf("sandbox closed %d times, want once", sandbox.closes) + } +} diff --git a/pkg/cli/ai_sandbox_test.go b/pkg/cli/ai_sandbox_test.go index 65e60cb2..77707587 100644 --- a/pkg/cli/ai_sandbox_test.go +++ b/pkg/cli/ai_sandbox_test.go @@ -58,10 +58,13 @@ func TestResolveSandboxSelection_Precedence(t *testing.T) { } }) - t.Run("an unwired kind fails loud instead of running unsandboxed", func(t *testing.T) { - _, err := resolveSandboxSelection("git-agent", nil, defaults) - if err == nil || !strings.Contains(err.Error(), "not wired to execution yet") { - t.Fatalf("err = %v", err) + t.Run("git-agent resolves now that remote execution is wired", func(t *testing.T) { + got, err := resolveSandboxSelection("git-agent", nil, defaults) + if err != nil { + t.Fatal(err) + } + if got.Kind != registry.SandboxGitAgent { + t.Fatalf("kind = %q, want git-agent", got.Kind) } }) diff --git a/pkg/cli/gitagent.go b/pkg/cli/gitagent.go new file mode 100644 index 00000000..b8274f28 --- /dev/null +++ b/pkg/cli/gitagent.go @@ -0,0 +1,302 @@ +// The captain sandbox git-agent command group (issue #39 §7): enrollment and +// the receive endpoint. `add` and `revoke` stay excluded from MCP exposure +// via the existing ^sandbox pattern (A7.3). +package cli + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" +) + +// gitAgentKeysDir anchors key material beside the config file: with the +// default ~/.captain.yaml this is ~/.captain/sandbox, and tests that redirect +// the config path get an isolated keys dir for free. +func gitAgentKeysDir() (string, error) { + path, err := captainconfig.Path() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(path), ".captain", "sandbox"), nil +} + +// The fixed layout every git-agent host uses, so enrollment and dispatch agree +// on where key material and repositories live without configuration. +const ( + hostKeyName = "host_ed25519" // this endpoint's SSH host key + dispatchKeyName = "supervisor_ed25519" // the supervisor's client key + agentKeyName = "agent_ed25519" // the agent's client key + servedReposDir = "repos" // served root, under the keys dir + SidecarRepoName = "repo.git" // the agent's sidecar repo, under the root + supervisorAgentID = "supervisor" // the supervisor's identity on a sidecar +) + +func gitAgentServedRoot() (string, error) { + keysDir, err := gitAgentKeysDir() + if err != nil { + return "", err + } + return filepath.Join(keysDir, servedReposDir), nil +} + +// GitAgentHelp documents the group and the two-host setup, because the order +// of the steps is the part that is not guessable from the flags. +func GitAgentHelp() api.Textable { + return clicky.Text("Remote git-agent sandboxes", "font-bold text-blue-400").NewLine().NewLine(). + AddText("A supervisor dispatches work to a coding agent on another machine; the agent", "text-gray-400").NewLine(). + AddText("runs only `git commit` and `git push`. Work is vetted at both ends before it", "text-gray-400").NewLine(). + AddText("is integrated.", "text-gray-400").NewLine().NewLine(). + AddText("Commands:", "font-bold text-blue-400").NewLine(). + AddText(" captain sandbox git-agent serve", "text-green-400"). + AddText(" — run a receive endpoint (this host)", "text-gray-500").NewLine(). + AddText(" captain sandbox git-agent add", "text-green-400"). + AddText(" — enroll an agent, print its join command", "text-gray-500").NewLine(). + AddText(" captain sandbox git-agent list", "text-green-400"). + AddText(" — enrolled agents and pending enrollments", "text-gray-500").NewLine(). + AddText(" captain sandbox git-agent revoke", "text-green-400"). + AddText(" — refuse an agent's key from now on", "text-gray-500").NewLine().NewLine(). + AddText("Setting up (supervisor first, then the agent host):", "font-bold text-blue-400").NewLine(). + AddText(" 1. supervisor: captain sandbox git-agent serve --role mailbox", "text-green-400").NewLine(). + AddText(" 2. supervisor: captain sandbox git-agent add worker-01 --endpoint ssh://:7422", "text-green-400").NewLine(). + AddText(" 3. agent host: run the printed join command (it enrolls, then serves)", "text-green-400").NewLine(). + AddText(" 4. supervisor: captain ai prompt ./task.prompt --sandbox git-agent", "text-green-400").NewLine().NewLine(). + AddText("Step 3 establishes trust in both directions: the supervisor learns the agent's", "text-gray-400").NewLine(). + AddText("endpoint and host key, and the agent authorizes the supervisor's dispatch key.", "text-gray-400").NewLine().NewLine(). + AddText("The agent:", "font-bold text-blue-400").NewLine(). + AddText(" By default the sidecar runs captain itself on the dispatched prompt, then", "text-gray-400").NewLine(). + AddText(" commits and pushes. Set sandbox.backends..agentCommand to run your own", "text-gray-400").NewLine(). + AddText(" agent instead, or ", "text-gray-400"). + AddText("agentCommand: none", "text-green-400"). + AddText(" to prepare the worktree and stop.", "text-gray-400").NewLine() +} + +type GitAgentAddOptions struct { + Name string `args:"true" help:"Name for the agent being enrolled"` + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` + Endpoint string `flag:"endpoint" help:"ssh://host:port the new agent will join through (defaults to the backend's url)"` + DryRun bool `flag:"dry-run" help:"Print every intended mutation without touching anything" short:"n"` +} + +// GitAgentAddResult is the enrollment hand-off. The token is single-use and +// short-TTL; a private key is never printed (R8.2/A7.1). +type GitAgentAddResult struct { + Backend string `json:"backend" pretty:"label=Backend"` + Agent string `json:"agent" pretty:"label=Agent"` + Expires time.Time `json:"expires" pretty:"label=Token expires"` + HostFingerprint string `json:"hostFingerprint" pretty:"label=Host key"` + DispatchKey string `json:"dispatchKey" pretty:"label=Dispatch key"` + JoinCommand string `json:"joinCommand" pretty:"label=Join command"` + DryRun bool `json:"dryRun,omitempty" pretty:"label=Dry Run"` +} + +func RunGitAgentAdd(opts GitAgentAddOptions) (any, error) { + if err := gitagent.ValidateTaskID(opts.Name); err != nil { + return nil, fmt.Errorf("agent name: %w", err) + } + keysDir, err := gitAgentKeysDir() + if err != nil { + return nil, err + } + hostKeyPath := filepath.Join(keysDir, hostKeyName) + dispatchKeyPath := filepath.Join(keysDir, dispatchKeyName) + endpoint := opts.Endpoint + if endpoint == "" { + endpoint = gitAgentBackendEndpoint(opts.Backend) + } + if opts.DryRun { + clicky.Printf("[dry-run] would ensure host key at %s\n", hostKeyPath) + clicky.Printf("[dry-run] would ensure dispatch key at %s\n", dispatchKeyPath) + clicky.Printf("[dry-run] would mint a single-use join token (TTL %s) for agent %q\n", gitagent.JoinTokenTTL, opts.Name) + clicky.Printf("[dry-run] would record the pending enrollment under sandbox.backends.%s in %s\n", opts.Backend, configPathForDisplay()) + clicky.Printf("[dry-run] would print the join command for endpoint %s\n", endpoint) + return GitAgentAddResult{Backend: opts.Backend, Agent: opts.Name, DryRun: true}, nil + } + _, hostFP, err := gitagent.EnsureKeyPair(hostKeyPath) + if err != nil { + return nil, err + } + // The dispatch key is what the agent must authorize for the supervisor's + // push to be accepted, so it has to exist before the join is offered. + _, dispatchFP, err := gitagent.EnsureKeyPair(dispatchKeyPath) + if err != nil { + return nil, err + } + token, hash, err := gitagent.MintJoinToken() + if err != nil { + return nil, err + } + expires := time.Now().UTC().Add(gitagent.JoinTokenTTL) + err = captainconfig.Update(func(cfg *captainconfig.Config) error { + backend, err := ensureGitAgentBackend(cfg, opts.Backend) + if err != nil { + return err + } + pending, _ := backend.Options["pending"].(map[string]any) + if pending == nil { + pending = map[string]any{} + } + pending[hash] = map[string]any{ + "agent": opts.Name, + "expires": expires.Format(time.RFC3339), + } + backend.Options["pending"] = pending + backend.Options["dispatchKey"] = dispatchFP + cfg.Sandbox.Backends[opts.Backend] = backend + return nil + }) + if err != nil { + return nil, err + } + join := fmt.Sprintf( + "captain sandbox git-agent serve --join %s --supervisor %s --host-fingerprint %s", + token, endpoint, hostFP) + return GitAgentAddResult{ + Backend: opts.Backend, + Agent: opts.Name, + Expires: expires, + HostFingerprint: hostFP, + DispatchKey: dispatchFP, + JoinCommand: join, + }, nil +} + +type GitAgentListOptions struct { + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` +} + +type GitAgentListEntry struct { + Name string `json:"name" pretty:"label=Name"` + Fingerprint string `json:"fingerprint,omitempty" pretty:"label=Fingerprint"` + URL string `json:"url,omitempty" pretty:"label=Endpoint"` + AddedAt string `json:"addedAt,omitempty" pretty:"label=Added"` + Status string `json:"status" pretty:"label=Status"` +} + +// RunGitAgentList always returns a slice — an empty roster renders as [] in +// JSON rather than null, so a consumer can iterate it unconditionally. +func RunGitAgentList(opts GitAgentListOptions) (any, error) { + entries := []GitAgentListEntry{} + cfg, _, err := captainconfig.Load() + if err != nil { + return nil, err + } + backend, ok := cfg.Sandbox.Backends[opts.Backend] + if !ok { + return entries, nil + } + agents, _ := backend.Options["agents"].(map[string]any) + for _, name := range sortedKeys(agents) { + entry := GitAgentListEntry{Name: name, Status: "enrolled"} + if m, ok := agents[name].(map[string]any); ok { + entry.Fingerprint, _ = m["fingerprint"].(string) + entry.URL, _ = m["url"].(string) + entry.AddedAt, _ = m["addedAt"].(string) + } + entries = append(entries, entry) + } + pending, _ := backend.Options["pending"].(map[string]any) + for _, hash := range sortedKeys(pending) { + if m, ok := pending[hash].(map[string]any); ok { + name, _ := m["agent"].(string) + expires, _ := m["expires"].(string) + entries = append(entries, GitAgentListEntry{Name: name, Status: "pending until " + expires}) + } + } + return entries, nil +} + +type GitAgentRevokeOptions struct { + Name string `args:"true" help:"Enrolled agent name to revoke"` + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` + DryRun bool `flag:"dry-run" help:"Print the intended mutation without touching anything" short:"n"` +} + +// GitAgentRevokeResult reports the revocation, so --format json emits a +// single well-formed document rather than prose. +type GitAgentRevokeResult struct { + Backend string `json:"backend" pretty:"label=Backend"` + Agent string `json:"agent" pretty:"label=Agent"` + Fingerprint string `json:"fingerprint,omitempty" pretty:"label=Fingerprint"` + Revoked bool `json:"revoked" pretty:"label=Revoked"` + DryRun bool `json:"dryRun,omitempty" pretty:"label=Dry Run"` +} + +func RunGitAgentRevoke(opts GitAgentRevokeOptions) (any, error) { + if opts.DryRun { + cfg, _, err := captainconfig.Load() + if err != nil { + return nil, err + } + if _, err := enrolledAgent(cfg, opts.Backend, opts.Name); err != nil { + return nil, err + } + clicky.Printf("[dry-run] would remove agent %q from sandbox.backends.%s.agents in %s\n", + opts.Name, opts.Backend, configPathForDisplay()) + return GitAgentRevokeResult{Backend: opts.Backend, Agent: opts.Name, DryRun: true}, nil + } + var fingerprint string + err := captainconfig.Update(func(cfg *captainconfig.Config) error { + entry, err := enrolledAgent(*cfg, opts.Backend, opts.Name) + if err != nil { + return err + } + backend := cfg.Sandbox.Backends[opts.Backend] + agents, _ := backend.Options["agents"].(map[string]any) + fingerprint, _ = entry["fingerprint"].(string) + delete(agents, opts.Name) + if len(agents) == 0 { + delete(backend.Options, "agents") + } + cfg.Sandbox.Backends[opts.Backend] = backend + return nil + }) + if err != nil { + return nil, err + } + // Effective for connections established after now (R8.5): the server + // consults the config per handshake. + return GitAgentRevokeResult{ + Backend: opts.Backend, + Agent: opts.Name, + Fingerprint: fingerprint, + Revoked: true, + }, nil +} + +func enrolledAgent(cfg captainconfig.Config, backendName, agentName string) (map[string]any, error) { + backend, ok := cfg.Sandbox.Backends[backendName] + if !ok { + return nil, fmt.Errorf("backend %q has no enrolled agents", backendName) + } + agents, _ := backend.Options["agents"].(map[string]any) + entry, ok := agents[agentName].(map[string]any) + if !ok { + return nil, fmt.Errorf("agent %q is not enrolled in backend %q", agentName, backendName) + } + return entry, nil +} + +func gitAgentBackendEndpoint(backend string) string { + cfg, _, err := captainconfig.Load() + if err == nil { + if b, ok := cfg.Sandbox.Backends[backend]; ok { + if url, _ := b.Options["url"].(string); url != "" { + return url + } + } + } + return "ssh://:7422" +} + +func configPathForDisplay() string { + path, err := captainconfig.Path() + if err != nil { + return "~/.captain.yaml" + } + return path +} diff --git a/pkg/cli/gitagent_directory.go b/pkg/cli/gitagent_directory.go new file mode 100644 index 00000000..47b9d246 --- /dev/null +++ b/pkg/cli/gitagent_directory.go @@ -0,0 +1,138 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/flanksource/captain/pkg/api/registry" + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" +) + +// gitAgentDirectory satisfies the server's authorization source. +var _ gitagent.AgentDirectory = gitAgentDirectory{} + +// gitAgentDirectory implements gitagent.AgentDirectory over the sandbox +// backend's options block in ~/.captain.yaml. Every read loads the file fresh +// so a revocation takes effect for the next connection (R8.5), and every +// mutation goes through the flocked captainconfig.Update (A3.4) so a token +// burn is atomic. +type gitAgentDirectory struct { + backend string +} + +func (d gitAgentDirectory) AgentByFingerprint(fingerprint string) (string, bool) { + cfg, _, err := captainconfig.Load() + if err != nil { + return "", false + } + backend, ok := cfg.Sandbox.Backends[d.backend] + if !ok { + return "", false + } + agents, _ := backend.Options["agents"].(map[string]any) + for name, v := range agents { + if entry, ok := v.(map[string]any); ok && entry["fingerprint"] == fingerprint { + return name, true + } + } + return "", false +} + +func (d gitAgentDirectory) ConsumeJoinToken(token string) (string, error) { + hash := gitagent.HashJoinToken(token) + var agentName string + var refusal error + // The refusal travels outside the Update callback: an error returned from + // the callback aborts the write, and burning an expired or malformed + // token must persist. + err := captainconfig.Update(func(cfg *captainconfig.Config) error { + refusal = fmt.Errorf("join token is unknown or already used") + backend, ok := cfg.Sandbox.Backends[d.backend] + if !ok { + return nil + } + pending, _ := backend.Options["pending"].(map[string]any) + entry, ok := pending[hash].(map[string]any) + if !ok { + return nil + } + // Burn before inspecting: a malformed entry must not stay redeemable. + delete(pending, hash) + if len(pending) == 0 { + delete(backend.Options, "pending") + } + cfg.Sandbox.Backends[d.backend] = backend + expires, _ := entry["expires"].(string) + if t, err := time.Parse(time.RFC3339, expires); err != nil || time.Now().After(t) { + refusal = fmt.Errorf("join token has expired; mint a new one with `captain sandbox git-agent add`") + return nil + } + name, _ := entry["agent"].(string) + if name == "" { + refusal = fmt.Errorf("join token has no agent recorded") + return nil + } + agentName = name + refusal = nil + return nil + }) + if err != nil { + return "", err + } + return agentName, refusal +} + +// RecordAgent stores everything a dispatch to this agent needs: its client +// key, its endpoint, and the host key to pin when pushing there. Recording +// only the key would leave an enrollment that looks complete but cannot be +// dispatched to. +func (d gitAgentDirectory) RecordAgent(e gitagent.AgentEnrollment) error { + if e.URL == "" { + return fmt.Errorf("agent %q advertised no endpoint; rerun its serve with --advertise ssh://host:port", e.Name) + } + if e.HostFingerprint == "" { + return fmt.Errorf("agent %q advertised no host key fingerprint; its dispatch could not be verified", e.Name) + } + return captainconfig.Update(func(cfg *captainconfig.Config) error { + backend, err := ensureGitAgentBackend(cfg, d.backend) + if err != nil { + return err + } + agents, _ := backend.Options["agents"].(map[string]any) + if agents == nil { + agents = map[string]any{} + } + agents[e.Name] = map[string]any{ + "fingerprint": e.Fingerprint, + "url": e.URL, + "hostFingerprint": e.HostFingerprint, + "addedAt": time.Now().UTC().Format(time.RFC3339), + } + backend.Options["agents"] = agents + cfg.Sandbox.Backends[d.backend] = backend + return nil + }) +} + +// ensureGitAgentBackend returns the named backend, creating a git-agent one +// (with an initialized Options map) when absent so `add` works on a fresh +// config. +func ensureGitAgentBackend(cfg *captainconfig.Config, name string) (captainconfig.SandboxBackend, error) { + if cfg.Sandbox.Backends == nil { + cfg.Sandbox.Backends = map[string]captainconfig.SandboxBackend{} + } + backend, ok := cfg.Sandbox.Backends[name] + if !ok { + backend = captainconfig.SandboxBackend{Kind: string(registry.SandboxGitAgent)} + } else if backend.Kind != "" && backend.Kind != string(registry.SandboxGitAgent) { + return backend, fmt.Errorf("backend %q is kind %q, not %s", name, backend.Kind, registry.SandboxGitAgent) + } else if backend.Kind == "" { + backend.Kind = string(registry.SandboxGitAgent) + } + if backend.Options == nil { + backend.Options = map[string]any{} + } + cfg.Sandbox.Backends[name] = backend + return backend, nil +} diff --git a/pkg/cli/gitagent_e2e_test.go b/pkg/cli/gitagent_e2e_test.go new file mode 100644 index 00000000..d1c0769d --- /dev/null +++ b/pkg/cli/gitagent_e2e_test.go @@ -0,0 +1,827 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/flanksource/captain/pkg/gitagent" + "gopkg.in/yaml.v3" +) + +// lockedBuffer collects a background process's output safely across the +// goroutine that waits on it. +type lockedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func netDial(addr string) (net.Conn, error) { + return net.DialTimeout("tcp", addr, 200*time.Millisecond) +} + +// freeLocalPort reserves a loopback port and releases it for immediate reuse. +func freeLocalPort(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + _, port, err := net.SplitHostPort(l.Addr().String()) + if err != nil { + t.Fatal(err) + } + return port +} + +// End-to-end coverage through the compiled binary, with each host in its own +// process and its own HOME. It exists because the protocol conformance suite +// wires its topology programmatically, and because captainconfig's path is +// process-global: neither can see a broken enrollment, a missing endpoint +// record, or a server exposing the wrong role — each of which leaves a working +// protocol attached to a product that cannot complete a cycle. + +var ( + captainBuildOnce sync.Once + captainBinPath string + captainBuildErr error +) + +// captainBinary builds cmd/captain once per test binary. +func captainBinary(t *testing.T) string { + t.Helper() + captainBuildOnce.Do(func() { + dir, err := os.MkdirTemp("", "captain-e2e-bin-") + if err != nil { + captainBuildErr = err + return + } + out := filepath.Join(dir, "captain") + cmd := exec.Command("go", "build", "-o", out, "./cmd/captain") + cmd.Dir = moduleRoot(t) + if combined, err := cmd.CombinedOutput(); err != nil { + captainBuildErr = fmt.Errorf("go build: %w\n%s", err, combined) + return + } + captainBinPath = out + }) + if captainBuildErr != nil { + t.Fatal(captainBuildErr) + } + return captainBinPath +} + +// moduleRoot is the repository root relative to this package. +func moduleRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + return root +} + +// host is one machine in the topology: an isolated HOME and the binary. +type host struct { + t *testing.T + home string + bin string + serveOut *lockedBuffer +} + +func newHost(t *testing.T) *host { + t.Helper() + return &host{t: t, home: t.TempDir(), bin: captainBinary(t)} +} + +func (h *host) env() []string { + return append(os.Environ(), "HOME="+h.home, "CAPTAIN_SESSION_DB_URL=off") +} + +// run executes a captain command to completion. +func (h *host) run(args ...string) (string, error) { + h.t.Helper() + cmd := exec.Command(h.bin, args...) + cmd.Env = h.env() + out, err := cmd.CombinedOutput() + return string(out), err +} + +func (h *host) mustRun(args ...string) string { + h.t.Helper() + out, err := h.run(args...) + if err != nil { + h.t.Fatalf("captain %s failed: %v\n%s", strings.Join(args, " "), err, out) + } + return out +} + +// serve starts a receive endpoint in the background and waits for it to +// accept, failing with the process's own output if it exits first. +func (h *host) serve(port string, args ...string) { + h.t.Helper() + full := append([]string{"sandbox", "git-agent", "serve", "--listen", "127.0.0.1:" + port}, args...) + cmd := exec.Command(h.bin, full...) + cmd.Env = h.env() + var out lockedBuffer + h.serveOut = &out + cmd.Stdout, cmd.Stderr = &out, &out + if err := cmd.Start(); err != nil { + h.t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + h.t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + <-done + }) + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + if conn, err := netDial("127.0.0.1:" + port); err == nil { + conn.Close() + return + } + select { + case err := <-done: + h.t.Fatalf("serve exited before listening (%v):\n%s", err, out.String()) + case <-time.After(100 * time.Millisecond): + } + } + h.t.Fatalf("serve never began listening:\n%s", out.String()) +} + +func (h *host) serveLogs() string { + if h.serveOut == nil { + return "" + } + return h.serveOut.String() +} + +func (h *host) configBytes() string { + h.t.Helper() + data, err := os.ReadFile(filepath.Join(h.home, ".captain.yaml")) + if err != nil { + h.t.Fatalf("reading %s: %v", filepath.Join(h.home, ".captain.yaml"), err) + } + return string(data) +} + +// setBackendOption edits one option under sandbox.backends.git-agent in this +// host's config, the way an operator would. +func (h *host) setBackendOption(t *testing.T, key, value string) { + t.Helper() + path := filepath.Join(h.home, ".captain.yaml") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + sandbox, _ := cfg["sandbox"].(map[string]any) + backends, _ := sandbox["backends"].(map[string]any) + backend, _ := backends["git-agent"].(map[string]any) + if backend == nil { + t.Fatalf("no git-agent backend in %s:\n%s", path, data) + } + backend[key] = value + out, err := yaml.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, out, 0o644); err != nil { + t.Fatal(err) + } +} + +// gitIn runs git in dir with a pinned identity. +func gitIn(t *testing.T, dir string, args ...string) string { + t.Helper() + full := append([]string{ + "-c", "user.name=test", "-c", "user.email=test@localhost", + "-c", "init.defaultBranch=main", + }, args...) + cmd := exec.Command("git", full...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v:\n%s", args, out) + } + return strings.TrimSpace(string(out)) +} + +func writeAt(t *testing.T, dir, path, content string) { + t.Helper() + full := filepath.Join(dir, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// newRepo creates a seeded git repository. +func newRepo(t *testing.T) string { + t.Helper() + repo := filepath.Join(t.TempDir(), "project") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatal(err) + } + gitIn(t, repo, "init", "-q") + writeAt(t, repo, "pkg/main.go", "package main\n") + gitIn(t, repo, "add", "-A") + gitIn(t, repo, "commit", "-q", "-m", "base") + return repo +} + +func mailboxPathForRepo(t *testing.T, supervisor *host, repo string) string { + t.Helper() + root := filepath.Join(supervisor.home, ".captain", "sandbox", servedReposDir) + mailbox, err := gitagent.MailboxForRepository(context.Background(), root, repo) + if err != nil { + t.Fatal(err) + } + return mailbox.Path +} + +// addResult is the JSON `add --format json` emits. +type addResult struct { + Agent string `json:"agent"` + HostFingerprint string `json:"hostFingerprint"` + DispatchKey string `json:"dispatchKey"` + JoinCommand string `json:"joinCommand"` +} + +// enrollPair brings up a supervisor and an agent using only public commands, +// exactly as the setup documentation instructs, and returns both hosts. +func enrollPair(t *testing.T) (supervisor, agent *host, repo, agentPort string, add addResult) { + t.Helper() + supervisor, agent = newHost(t), newHost(t) + repo = newRepo(t) + + supPort := freeLocalPort(t) + supervisor.serve(supPort, "--role", "mailbox") + + out := supervisor.mustRun("sandbox", "git-agent", "add", "worker-01", + "--endpoint", "ssh://127.0.0.1:"+supPort, "--format", "json") + if err := json.Unmarshal([]byte(firstJSONDocument(out)), &add); err != nil { + t.Fatalf("add --format json is not a JSON document: %v\n%s", err, out) + } + if add.JoinCommand == "" || add.DispatchKey == "" { + t.Fatalf("add did not report a join command and dispatch key: %+v", add) + } + + // Run the printed join command verbatim, only redirecting the listen + // address — an operator changes nothing else. + joinArgs := parseJoin(t, add.JoinCommand) + agentPort = freeLocalPort(t) + agent.serve(agentPort, joinArgs...) + return supervisor, agent, repo, agentPort, add +} + +// parseJoin turns the printed join command into serve arguments. +func parseJoin(t *testing.T, join string) []string { + t.Helper() + fields := strings.Fields(join) + idx := -1 + for i, f := range fields { + if f == "serve" { + idx = i + 1 + break + } + } + if idx < 0 { + t.Fatalf("join command does not invoke serve: %q", join) + } + args := fields[idx:] + for _, required := range []string{"--join", "--supervisor", "--host-fingerprint"} { + if !containsArg(args, required) { + t.Fatalf("join command lacks %s, so an operator cannot complete enrollment: %q", required, join) + } + } + return args +} + +func containsArg(args []string, name string) bool { + for _, a := range args { + if a == name { + return true + } + } + return false +} + +// TestEnrollmentProducesADispatchableTopology is the regression test for the +// setup blockers: after only `add` on the supervisor and the printed join +// command on the agent, dispatch must be possible with no hand-edited config. +func TestEnrollmentProducesADispatchableTopology(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary") + } + supervisor, agent, repo, agentPort, add := enrollPair(t) + + // The supervisor must hold a dispatchable record: key, endpoint and host + // key. Recording only the key is what made dispatch impossible. + listed := supervisor.mustRun("sandbox", "git-agent", "list", "--format", "json") + var entries []struct { + Name string `json:"name"` + Fingerprint string `json:"fingerprint"` + URL string `json:"url"` + Status string `json:"status"` + } + if err := json.Unmarshal([]byte(firstJSONDocument(listed)), &entries); err != nil { + t.Fatalf("list --format json is not a JSON document: %v\n%s", err, listed) + } + var worker *struct { + Name string `json:"name"` + Fingerprint string `json:"fingerprint"` + URL string `json:"url"` + Status string `json:"status"` + } + for i := range entries { + if entries[i].Name == "worker-01" && entries[i].Status == "enrolled" { + worker = &entries[i] + } + } + if worker == nil { + t.Fatalf("worker-01 is not enrolled: %s", listed) + } + if worker.Fingerprint == "" || worker.URL == "" { + t.Fatalf("enrollment recorded no key or endpoint: %+v", worker) + } + if !strings.Contains(worker.URL, agentPort) || !strings.HasSuffix(worker.URL, SidecarRepoName) { + t.Fatalf("recorded endpoint %q must address the agent's port and repository", worker.URL) + } + if !strings.Contains(supervisor.configBytes(), "hostFingerprint") { + t.Fatalf("the supervisor recorded no host key to pin when dispatching:\n%s", supervisor.configBytes()) + } + if !strings.Contains(supervisor.configBytes(), "mailboxRoot") { + t.Fatalf("the supervisor endpoint recorded no root for lazy mailboxes:\n%s", supervisor.configBytes()) + } + + // The agent authorizes the supervisor once and retains only its stable + // endpoint. A mailbox path is selected later for each dispatched repository. + agentCfg := agent.configBytes() + if !strings.Contains(agentCfg, add.DispatchKey) { + t.Fatalf("the agent did not authorize the supervisor's dispatch key %s:\n%s", add.DispatchKey, agentCfg) + } + if !strings.Contains(agentCfg, "ssh://127.0.0.1:") { + t.Fatalf("the agent recorded no supervisor endpoint:\n%s", agentCfg) + } + mailbox := mailboxPathForRepo(t, supervisor, repo) + if _, err := os.Stat(mailbox); !os.IsNotExist(err) { + t.Fatalf("repository mailbox must be created lazily at dispatch, stat error = %v", err) + } +} + +// TestFullCycleWithAManualAgent covers the protocol half of the cycle: a +// dispatch reaches the agent, work pushed with ordinary git relays to the +// supervisor, and accepted work is integrated. +// +// It drives the agent by hand deliberately, which is exactly what it does NOT +// prove: that a dispatch launches anything. TestDispatchLaunchesAnAgent covers +// that, and the two must stay separate — a manual push in this test once +// masked a launch path that never ran. +func TestFullCycleWithAManualAgent(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary and runs two endpoints") + } + supervisor, agent, repo, _, _ := enrollPair(t) + // Opt out of the launcher: this test drives the agent itself. + agent.setBackendOption(t, "agentCommand", gitagent.NoAgentCommand) + + // A dirty supervisor worktree must travel with the dispatch. + writeAt(t, repo, "task.prompt", "---\nsandbox: git-agent\n---\n{{role \"user\"}}\nAdd a greeting.\n") + writeAt(t, repo, "pkg/main.go", "package main\n\n// dirty\n") + + // Dispatch blocks until the verdict (relay=sync), so it runs alongside the + // agent's work. + dispatch := exec.Command(supervisor.bin, "ai", "prompt", "./task.prompt", "--sandbox", "git-agent") + dispatch.Dir = repo + dispatch.Env = supervisor.env() + var dispatchOut lockedBuffer + dispatch.Stdout, dispatch.Stderr = &dispatchOut, &dispatchOut + if err := dispatch.Start(); err != nil { + t.Fatal(err) + } + dispatchDone := make(chan error, 1) + go func() { dispatchDone <- dispatch.Wait() }() + t.Cleanup(func() { + if dispatch.Process != nil { + _ = dispatch.Process.Kill() + } + }) + + // The agent's workspace is an ordinary worktree with a branch and upstream. + tasksDir := filepath.Join(agent.home, ".captain", "sandbox", servedReposDir, SidecarRepoName, "captain", "tasks") + var worktree, taskID string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) && worktree == "" { + entries, _ := os.ReadDir(tasksDir) + for _, e := range entries { + candidate := filepath.Join(tasksDir, e.Name(), "worktree") + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + worktree, taskID = candidate, e.Name() + } + } + if worktree != "" { + break + } + select { + case err := <-dispatchDone: + t.Fatalf("dispatch exited before creating a workspace (%v):\n%s", err, dispatchOut.String()) + case <-time.After(250 * time.Millisecond): + } + } + if worktree == "" { + t.Fatalf("no agent workspace appeared:\n%s", dispatchOut.String()) + } + if got := gitIn(t, worktree, "rev-parse", "--abbrev-ref", "@{u}"); !strings.HasSuffix(got, taskID) { + t.Fatalf("upstream = %q; a bare `git push` needs one (H17)", got) + } + // The supervisor's uncommitted edit travelled. + body, err := os.ReadFile(filepath.Join(worktree, "pkg", "main.go")) + if err != nil || !strings.Contains(string(body), "// dirty") { + t.Fatalf("the dispatch did not carry the dirty worktree: %q (%v)", body, err) + } + + // The agent uses ordinary git and nothing else. + writeAt(t, worktree, "pkg/main.go", string(body)+"\nfunc Greet() string { return \"hi\" }\n") + gitIn(t, worktree, "add", "-A") + gitIn(t, worktree, "commit", "-q", "-m", "add greeting") + push := exec.Command("git", "push") + push.Dir = worktree + pushOut, err := push.CombinedOutput() + if err != nil { + t.Fatalf("the agent's bare push failed: %v\n%s", err, pushOut) + } + if !strings.Contains(string(pushOut), "ACCEPTED") { + t.Fatalf("push was not accepted:\n%s", pushOut) + } + + select { + case err := <-dispatchDone: + if err != nil { + t.Fatalf("dispatch failed: %v\n%s", err, dispatchOut.String()) + } + case <-time.After(90 * time.Second): + t.Fatalf("dispatch never concluded after the push:\n%s", dispatchOut.String()) + } + if !strings.Contains(dispatchOut.String(), "accepted") { + t.Fatalf("dispatch did not report acceptance:\n%s", dispatchOut.String()) + } + + // The supervisor holds the result and verdict refs, and integrated the work + // onto a branch without touching the user's checkout. + mailbox := mailboxPathForRepo(t, supervisor, repo) + refs := gitIn(t, mailbox, "for-each-ref", "--format=%(refname)") + for _, want := range []string{"/result/1", "/verdict/1"} { + if !strings.Contains(refs, taskID+want) { + t.Fatalf("mailbox is missing %s%s:\n%s", taskID, want, refs) + } + } + integrated := gitIn(t, repo, "show", "captain/"+taskID+":pkg/main.go") + if !strings.Contains(integrated, "func Greet()") || !strings.Contains(integrated, "// dirty") { + t.Fatalf("integration lost the agent's work or the dispatched state:\n%s", integrated) + } + if branch := gitIn(t, repo, "rev-parse", "--abbrev-ref", "HEAD"); strings.HasPrefix(branch, "captain/") { + t.Fatalf("integration switched the user's checkout to %s", branch) + } +} + +// TestJoinTokenIsSingleUse pins that the bidirectional exchange keeps the +// single-use property (R8.2). +func TestJoinTokenIsSingleUse(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary") + } + _, _, _, _, add := enrollPair(t) + + replay := newHost(t) + args := append([]string{"sandbox", "git-agent", "serve", "--listen", "127.0.0.1:" + freeLocalPort(t)}, + parseJoin(t, add.JoinCommand)...) + out, err := replay.run(args...) + if err == nil { + t.Fatalf("token replay must be refused:\n%s", out) + } + if !strings.Contains(out, "already used") { + t.Fatalf("replay refusal must name the cause:\n%s", out) + } +} + +// TestGitAgentHelpDocumentsItsOwnCommands covers the reported discoverability +// defect: the group inherited the parent's help and advertised the wrong +// commands. +func TestGitAgentHelpDocumentsItsOwnCommands(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary") + } + h := newHost(t) + parent, _ := h.run("sandbox", "--help") + if !strings.Contains(parent, "git-agent") { + t.Fatalf("sandbox help does not advertise git-agent:\n%s", parent) + } + out, _ := h.run("sandbox", "git-agent", "--help") + for _, want := range []string{"serve", "add", "list", "revoke"} { + if !strings.Contains(out, want) { + t.Fatalf("git-agent help does not mention %q:\n%s", want, out) + } + } + if strings.Contains(out, "sandbox-runtime presets") { + t.Fatalf("git-agent help is the parent's:\n%s", out) + } + for _, sub := range []string{"add", "serve", "list", "revoke"} { + out, _ := h.run("sandbox", "git-agent", sub, "--help") + if strings.Contains(out, "sandbox-runtime presets") { + t.Fatalf("git-agent %s --help is the parent's:\n%s", sub, out) + } + if sub == "serve" && strings.Contains(out, "--repo string") { + t.Fatalf("the long-running endpoint must not be repository-bound:\n%s", out) + } + } +} + +// TestListEmitsAnArrayWhenEmpty covers the reported JSON defect. +func TestListEmitsAnArrayWhenEmpty(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary") + } + h := newHost(t) + out := h.mustRun("sandbox", "git-agent", "list", "--format", "json") + doc := firstJSONDocument(out) + if strings.TrimSpace(doc) != "[]" { + t.Fatalf("an empty roster must encode as [], got %q\n%s", doc, out) + } +} + +// firstJSONDocument extracts the JSON value from command output that may also +// carry log lines. +func firstJSONDocument(out string) string { + trimmed := strings.TrimSpace(out) + for _, open := range []string{"[", "{"} { + if i := strings.Index(trimmed, open); i >= 0 { + candidate := trimmed[i:] + var probe any + if json.Unmarshal([]byte(candidate), &probe) == nil { + return candidate + } + } + } + return trimmed +} + +// TestDispatchLaunchesAnAgent is the test the manual-push cycle test cannot +// be: it proves a dispatch starts work that completes on its own, with no +// human touching the worktree at any point. +// +// The agent is a scripted stand-in rather than a real model, so the +// launch → work → push → relay → integrate chain is exercised without needing +// credentials. What it does not cover is the UNCONFIGURED default — that is +// TestUnconfiguredDispatchLaunchesTheDefaultAgent below, plus +// TestDefaultAgentCommandRunsCaptain for the command itself. +func TestDispatchLaunchesAnAgent(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary and runs two endpoints") + } + supervisor, agent, repo, _, _ := enrollPair(t) + + // A scripted agent: edit, commit, push — the same three steps run-task + // performs after the model call. + agent.setBackendOption(t, "agentCommand", + `echo 'func Greet() string { return "hi" }' >> pkg/main.go `+ + `&& git add -A && git commit -q -m "captain: $CAPTAIN_TASK" && git push`) + + writeAt(t, repo, "task.prompt", "---\nsandbox: git-agent\n---\n{{role \"user\"}}\nAdd a greeting.\n") + writeAt(t, repo, "pkg/main.go", "package main\n\n// dirty\n") + + // No manual step anywhere below: dispatch, and wait for it to conclude. + dispatch := exec.Command(supervisor.bin, "ai", "prompt", "./task.prompt", + "--sandbox", "git-agent", "--timeout", "3m") + dispatch.Dir = repo + dispatch.Env = supervisor.env() + out, err := dispatch.CombinedOutput() + if err != nil { + t.Fatalf("the dispatch did not conclude on its own: %v\n%s\n%s", err, out, agentLogs(t, agent)) + } + if !strings.Contains(string(out), "accepted") { + t.Fatalf("dispatch did not report acceptance:\n%s\n%s", out, agentLogs(t, agent)) + } + + // The agent's work reached the supervisor and was integrated, with no + // human touching the worktree. + mailbox := mailboxPathForRepo(t, supervisor, repo) + refs := gitIn(t, mailbox, "for-each-ref", "--format=%(refname)") + if !strings.Contains(refs, "/result/1") || !strings.Contains(refs, "/verdict/1") { + t.Fatalf("no result/verdict refs; the launched agent never submitted:\n%s", refs) + } + branch := "" + for _, line := range strings.Split(gitIn(t, repo, "branch", "--format=%(refname:short)"), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "captain/") { + branch = strings.TrimSpace(line) + } + } + if branch == "" { + t.Fatalf("accepted work was not integrated:\n%s", gitIn(t, repo, "branch")) + } + integrated := gitIn(t, repo, "show", branch+":pkg/main.go") + if !strings.Contains(integrated, "func Greet()") || !strings.Contains(integrated, "// dirty") { + t.Fatalf("integration lost the agent's work or the dispatched state:\n%s", integrated) + } + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + logs := agent.serveLogs() + if strings.Contains(logs, "git-agent task") && strings.Contains(logs, "received") && strings.Contains(logs, "accepted at sidecar") { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("agent serve did not report the task lifecycle:\n%s", agent.serveLogs()) +} + +// TestOneEndpointRoutesTwoRepositories proves service lifetime is independent +// of repository lifetime: one enrollment and one listener own isolated +// mailboxes while the shared sidecar executes both tasks. +func TestOneEndpointRoutesTwoRepositories(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary and runs two endpoints") + } + supervisor, agent, repoA, _, _ := enrollPair(t) + repoB := newRepo(t) // same basename, different canonical path + agent.setBackendOption(t, "agentCommand", + `echo "// completed $CAPTAIN_TASK" >> pkg/main.go `+ + `&& git add -A && git commit -q -m "captain: $CAPTAIN_TASK" && git push`) + + run := func(repo string) string { + t.Helper() + writeAt(t, repo, "task.prompt", "---\nsandbox: git-agent\n---\n{{role \"user\"}}\nComplete this task.\n") + cmd := exec.Command(supervisor.bin, "ai", "prompt", "./task.prompt", + "--sandbox", "git-agent", "--timeout", "3m") + cmd.Dir, cmd.Env = repo, supervisor.env() + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("dispatch from %s failed: %v\n%s\n%s", repo, err, out, agentLogs(t, agent)) + } + branches := strings.Fields(gitIn(t, repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/captain")) + if len(branches) != 1 { + t.Fatalf("integration branches in %s = %v", repo, branches) + } + return strings.TrimPrefix(branches[0], "captain/") + } + + taskA := run(repoA) + taskB := run(repoB) + if taskA == taskB { + t.Fatalf("two dispatches reused task id %s", taskA) + } + mailboxA := mailboxPathForRepo(t, supervisor, repoA) + mailboxB := mailboxPathForRepo(t, supervisor, repoB) + if mailboxA == mailboxB { + t.Fatalf("same-named repositories share mailbox %s", mailboxA) + } + for _, tc := range []struct { + mailbox, ownTask, otherTask, repo string + }{{mailboxA, taskA, taskB, repoA}, {mailboxB, taskB, taskA, repoB}} { + refs := gitIn(t, tc.mailbox, "for-each-ref", "--format=%(refname)") + if !strings.Contains(refs, tc.ownTask+"/result/1") || strings.Contains(refs, tc.otherTask) { + t.Fatalf("mailbox %s has crossed task namespaces:\n%s", tc.mailbox, refs) + } + binding, err := gitagent.LoadMailboxBinding(tc.mailbox) + if err != nil || binding.Repository != tc.repo { + t.Fatalf("mailbox %s binding = %+v, %v; want %s", tc.mailbox, binding, err, tc.repo) + } + shim, err := os.ReadFile(filepath.Join(tc.mailbox, "hooks", "pre-receive")) + if err != nil || !strings.Contains(string(shim), `--role "mailbox"`) { + t.Fatalf("mailbox %s has no mailbox hook: %v\n%s", tc.mailbox, err, shim) + } + } +} + +// agentLogs returns whatever the detached agent wrote, which is the only +// diagnosis available when a dispatch fails to conclude. +func agentLogs(t *testing.T, agent *host) string { + t.Helper() + tasks := filepath.Join(agent.home, ".captain", "sandbox", servedReposDir, SidecarRepoName, "captain", "tasks") + entries, err := os.ReadDir(tasks) + if err != nil { + return "no task directory: " + err.Error() + } + var b strings.Builder + for _, e := range entries { + for _, name := range []string{"agent.stdout.log", "agent.stderr.log"} { + if data, err := os.ReadFile(filepath.Join(tasks, e.Name(), name)); err == nil { + fmt.Fprintf(&b, "--- %s/%s ---\n%s\n", e.Name(), name, data) + } + } + } + if b.Len() == 0 { + return "the agent wrote no logs (was anything launched?)" + } + return b.String() +} + +// TestDefaultAgentCommandRunsCaptain pins what an unconfigured sidecar +// launches: this binary, working the task in place. An empty default is the +// defect this whole pair of tests exists to prevent. +func TestDefaultAgentCommandRunsCaptain(t *testing.T) { + command := DefaultAgentCommand("/usr/local/bin/captain", "/srv/repo.git", "t-1", "/home/agent/.captain.yaml") + for _, want := range []string{ + `"/usr/local/bin/captain"`, "sandbox git-agent run-task", + `--repo "/srv/repo.git"`, `--task "t-1"`, `--config "/home/agent/.captain.yaml"`, + } { + if !strings.Contains(command, want) { + t.Fatalf("default agent command %q lacks %q", command, want) + } + } + if strings.TrimSpace(DefaultAgentCommand("/bin/captain", "/r.git", "t-1", "")) == "" { + t.Fatal("the default must never be empty; LaunchAgent would refuse and the dispatch would hang") + } +} + +// TestUnconfiguredDispatchLaunchesTheDefaultAgent closes the gap the other +// tests leave: a backend that configures no agentCommand at all must still +// launch a real agent. It cannot assert the task completes — captain's default +// agent calls a model, which needs credentials this suite does not have — so +// it asserts the launch itself, which is precisely what used to be missing. +func TestUnconfiguredDispatchLaunchesTheDefaultAgent(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary and runs two endpoints") + } + supervisor, agent, repo, _, _ := enrollPair(t) + if strings.Contains(agent.configBytes(), "agentCommand") { + t.Fatalf("this test requires an unconfigured backend:\n%s", agent.configBytes()) + } + + writeAt(t, repo, "task.prompt", "---\nsandbox: git-agent\n---\n{{role \"user\"}}\nAdd a greeting.\n") + + // A short budget: the point is what got launched, not what it produced. + dispatch := exec.Command(supervisor.bin, "ai", "prompt", "./task.prompt", + "--sandbox", "git-agent", "--timeout", "45s") + dispatch.Dir = repo + dispatch.Env = supervisor.env() + var out lockedBuffer + dispatch.Stdout, dispatch.Stderr = &out, &out + if err := dispatch.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- dispatch.Wait() }() + t.Cleanup(func() { + if dispatch.Process != nil { + _ = dispatch.Process.Kill() + } + }) + + // An agent log appearing at all is the assertion: the sidecar launched + // something rather than preparing a workspace and going quiet. + tasks := filepath.Join(agent.home, ".captain", "sandbox", servedReposDir, SidecarRepoName, "captain", "tasks") + deadline := time.Now().Add(60 * time.Second) + launched := "" + for time.Now().Before(deadline) && launched == "" { + entries, _ := os.ReadDir(tasks) + for _, e := range entries { + for _, name := range []string{"agent.stdout.log", "agent.stderr.log"} { + if data, err := os.ReadFile(filepath.Join(tasks, e.Name(), name)); err == nil && len(data) > 0 { + launched = string(data) + } + } + } + time.Sleep(250 * time.Millisecond) + } + if launched == "" { + t.Fatalf("an unconfigured backend launched nothing; the dispatch would wait out its whole budget in silence\ndispatch output:\n%s", out.String()) + } + t.Logf("default agent produced:\n%s", launched) + deadline = time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) && !strings.Contains(agent.serveLogs(), "starting") { + time.Sleep(50 * time.Millisecond) + } + if logs := agent.serveLogs(); !strings.Contains(logs, "git-agent task") || !strings.Contains(logs, "starting") { + t.Fatalf("agent serve did not stream the default Captain run log:\n%s", logs) + } +} diff --git a/pkg/cli/gitagent_hook.go b/pkg/cli/gitagent_hook.go new file mode 100644 index 00000000..33a993c6 --- /dev/null +++ b/pkg/cli/gitagent_hook.go @@ -0,0 +1,191 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/middleware" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" + "gopkg.in/yaml.v3" +) + +type GitAgentHookOptions struct { + Hook string `args:"true" help:"pre-receive or post-receive"` + Repo string `flag:"repo" help:"Receiving bare repository"` + Role string `flag:"role" help:"Receiver role: sidecar or mailbox"` + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` + Config string `flag:"config" help:"Config file to read; hooks cannot rely on $HOME, which belongs to whoever pushed"` +} + +// RunGitAgentHook is the shim entrypoint: admission, hook sets, relay and +// integration, with the runtime assembled from the backend's config block. +func RunGitAgentHook(ctx context.Context, opts GitAgentHookOptions) (any, error) { + if strings.TrimSpace(opts.Config) != "" { + captainconfig.SetPath(opts.Config) + } + role := gitagent.ReceiverRole(opts.Role) + if role != gitagent.RoleSidecar && role != gitagent.RoleMailbox { + return nil, fmt.Errorf("unknown receiver role %q", opts.Role) + } + runtime, err := hookRuntimeFromConfig(opts.Backend) + if err != nil { + fmt.Fprintf(os.Stderr, "captain: %v\n", err) + return nil, err + } + if role == gitagent.RoleMailbox { + binding, err := gitagent.LoadMailboxBinding(opts.Repo) + if err != nil { + fmt.Fprintf(os.Stderr, "captain: %v\n", err) + return nil, err + } + runtime.RealRepo = binding.Repository + } + wrapFor, err := gitagent.ResolveHookWrap(runtime.HookSandbox, opts.Repo) + if err != nil { + fmt.Fprintf(os.Stderr, "captain: %v\n", err) + return nil, err + } + var judge ai.Provider + if opts.Hook == "pre-receive" { + judge, err = hookJudgeProvider(runtime) + if err != nil { + fmt.Fprintf(os.Stderr, "captain: %v\n", err) + return nil, err + } + } + defer closeProvider(judge) + exe, err := os.Executable() + if err != nil { + return nil, err + } + configPath := strings.TrimSpace(opts.Config) + host := gitagent.HookHost{ + Runtime: runtime, + Judge: judge, + WrapFor: wrapFor, + // With no agentCommand configured, the sidecar still launches a real + // agent: this binary, working the task in the prepared worktree. The + // alternative — launching nothing — leaves the supervisor waiting out + // its whole budget on work that never started. + DefaultAgentCommand: func(repo, task string) string { + return DefaultAgentCommand(exe, repo, task, configPath) + }, + } + switch opts.Hook { + case "pre-receive": + return nil, gitagent.RunPreReceive(ctx, opts.Repo, role, host, os.Stdin, os.Stderr) + case "post-receive": + return nil, gitagent.RunPostReceive(ctx, opts.Repo, role, host, os.Stdin) + default: + return nil, fmt.Errorf("unknown hook %q", opts.Hook) + } +} + +// hookJudgeProvider builds the local provider used by receiver-side prompt +// checks. It pins sandboxing to none because the hook is already executing at +// the remote receiver; selecting git-agent here would recursively dispatch. +func hookJudgeProvider(runtime gitagent.HookRuntime) (ai.Provider, error) { + if !runtime.RequiresJudge() { + return nil, nil + } + cfg, err := (AIProviderOptions{Sandbox: "none"}).ToConfig() + if err != nil { + return nil, fmt.Errorf("configure hook judge: %w", err) + } + if strings.TrimSpace(cfg.Model.Name) == "" { + return nil, fmt.Errorf("verify prompts declared but no model is configured for the hook judge") + } + provider, err := ai.NewProvider(cfg) + if err != nil { + return nil, fmt.Errorf("create hook judge: %w", err) + } + wrapped, err := middleware.Wrap(provider, middleware.WithLogging(), middleware.WithSchemaValidation(cfg)) + if err != nil { + closeProvider(provider) + return nil, fmt.Errorf("wrap hook judge: %w", err) + } + return wrapped, nil +} + +// hookRuntimeFromConfig assembles host-wide receiver settings from the +// backend. Repository integration is mailbox-local and is resolved separately +// from the binding beside that mailbox's task state. +func hookRuntimeFromConfig(backendName string) (gitagent.HookRuntime, error) { + var rt gitagent.HookRuntime + cfg, _, err := captainconfig.Load() + if err != nil { + return rt, err + } + backend, ok := cfg.Sandbox.Backends[backendName] + if !ok { + return rt, nil // no config: admission still runs, hook sets are empty + } + if hooks, ok := backend.Options["hooks"].(map[string]any); ok { + if rt.SidecarWorkflow, err = decodeWorkflow(hooks["sidecar"]); err != nil { + return rt, fmt.Errorf("hooks.sidecar: %w", err) + } + if rt.SupervisorWorkflow, err = decodeWorkflow(hooks["supervisor"]); err != nil { + return rt, fmt.Errorf("hooks.supervisor: %w", err) + } + } + rt.HookSandbox, _ = backend.Options["hookSandbox"].(string) + rt.AgentCommand, _ = backend.Options["agentCommand"].(string) + if supervisor, ok := backend.Options["supervisor"].(map[string]any); ok { + url, _ := supervisor["url"].(string) + hostFP, _ := supervisor["hostFingerprint"].(string) + keysDir, err := gitAgentKeysDir() + if err != nil { + return rt, err + } + exe, err := os.Executable() + if err != nil { + return rt, err + } + rt.Relay = gitagent.RelayTarget{ + URL: url, + HostFingerprint: hostFP, + KeyPath: filepath.Join(keysDir, agentKeyName), + SSHCommand: gitagent.SSHTransportCommand(exe), + } + } + return rt, nil +} + +// decodeWorkflow converts a YAML-decoded options value into an api.Workflow +// via a JSON round-trip, so the receiver runs the exact schema the local run +// path declares (A5.1). +func decodeWorkflow(v any) (*api.Workflow, error) { + if v == nil { + return nil, nil + } + raw, err := yaml.Marshal(v) + if err != nil { + return nil, err + } + var normalized any + if err := yaml.Unmarshal(raw, &normalized); err != nil { + return nil, err + } + data, err := json.Marshal(normalized) + if err != nil { + return nil, err + } + var wf api.Workflow + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&wf); err != nil { + return nil, err + } + if err := wf.Validate(); err != nil { + return nil, err + } + return &wf, nil +} diff --git a/pkg/cli/gitagent_runtask.go b/pkg/cli/gitagent_runtask.go new file mode 100644 index 00000000..1e51c4e5 --- /dev/null +++ b/pkg/cli/gitagent_runtask.go @@ -0,0 +1,151 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/aiflags" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" +) + +// GitAgentRunTaskOptions configures the detached sidecar task runner. +type GitAgentRunTaskOptions struct { + Repo string `flag:"repo" help:"The sidecar's bare repository"` + Task string `flag:"task" help:"Task id to work on"` + Config string `flag:"config" help:"Config file to read; a detached agent cannot rely on $HOME"` + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` +} + +// RunGitAgentRunTask performs one task end to end on the agent host: read the +// dispatched prompt, run it in the worktree, then commit and push. Its output +// is the agent log, so every failure is reported there rather than to a +// terminal nobody is watching. +func RunGitAgentRunTask(ctx context.Context, opts GitAgentRunTaskOptions) (_ any, runErr error) { + started := time.Now() + defer func() { + if runErr != nil { + log.Errorf("git-agent task %s failed after %s: %v", opts.Task, time.Since(started).Round(time.Millisecond), runErr) + } + }() + if strings.TrimSpace(opts.Config) != "" { + captainconfig.SetPath(opts.Config) + } + worktree, taskFile, err := gitagent.TaskPaths(opts.Repo, opts.Task) + if err != nil { + return nil, err + } + payload, err := gitagent.LoadTaskPayload(taskFile) + if err != nil { + return nil, err + } + identity := ai.LogIdentity(api.Backend(payload.Backend), payload.Model, payload.Effort) + log.Infof("git-agent task %s starting %s in %s", opts.Task, identity, worktree) + if err := runTaskPrompt(ctx, worktree, payload); err != nil { + return nil, fmt.Errorf("running the dispatched prompt: %w", err) + } + log.Infof("git-agent task %s agent finished after %s; preparing submission", opts.Task, time.Since(started).Round(time.Millisecond)) + if err := submitWork(ctx, worktree, opts.Task); err != nil { + return nil, err + } + log.Infof("git-agent task %s accepted after %s", opts.Task, time.Since(started).Round(time.Millisecond)) + return nil, nil +} + +// runTaskPrompt executes the dispatched prompt in the worktree. The sandbox is +// pinned to none: this process IS the relocated run, so resolving a relocating +// sandbox here would dispatch the task to another agent, and so on (H15). +func runTaskPrompt(ctx context.Context, worktree string, payload gitagent.TaskPayload) error { + providerOpts := AIProviderOptions{ + ModelFlags: aiflags.ModelFlags{Model: payload.Model, Backend: payload.Backend, Effort: string(payload.Effort)}, + Sandbox: "none", + } + cfg, err := providerOpts.ToConfig() + if err != nil { + return err + } + var req ai.Request + req.Prompt.User = payload.Prompt + req.Prompt.System = payload.System + req.Model = cfg.Model + req.Budget.Timeout = payload.Timeout + req.SetCwd(worktree) + // Editing is the point: a coding agent that cannot write files produces an + // empty result and an unexplained silence on the supervisor. + req.Permissions.Mode = api.PermissionAcceptEdits + + if _, err := executePromptRequestFunc(ctx, req, cfg, renderedTimeout(PromptRenderResult{Input: req, Config: cfg}), true); err != nil { + return err + } + return nil +} + +// submitWork performs the agent's half of the protocol: stage everything the +// run produced, commit, and push. A run that changed nothing is reported as +// such rather than pushed as an empty success. +func submitWork(ctx context.Context, worktree, task string) error { + log.Infof("git-agent task %s staging workspace changes", task) + if err := git(ctx, worktree, "add", "-A"); err != nil { + return err + } + staged, err := hasStagedChanges(ctx, worktree) + if err != nil { + return err + } + if !staged { + return fmt.Errorf("the agent produced no changes for task %s; nothing to submit", task) + } + log.Infof("git-agent task %s committing workspace changes", task) + if err := git(ctx, worktree, "commit", "-m", "captain: "+task); err != nil { + return err + } + // The push carries the work through both hook tiers and blocks until the + // verdict, so its output is the agent's most important log line. + log.Infof("git-agent task %s pushing for sidecar and supervisor verification", task) + return git(ctx, worktree, "push") +} + +func git(ctx context.Context, dir string, args ...string) error { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + return nil +} + +// hasStagedChanges reports whether the index differs from HEAD. `diff --cached +// --quiet` exits 1 when it does, which is the answer rather than a failure. +func hasStagedChanges(ctx context.Context, dir string) (bool, error) { + cmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--quiet") + cmd.Dir = dir + err := cmd.Run() + if err == nil { + return false, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return true, nil + } + return false, fmt.Errorf("git diff --cached: %w", err) +} + +// DefaultAgentCommand is the coding agent a sidecar launches when the backend +// declares none: Captain drives the supervisor-selected CLI runtime in the +// prepared worktree, then commits and pushes the result. A concrete default +// keeps an unconfigured sidecar from silently leaving dispatched work idle. +func DefaultAgentCommand(captainBin, repo, task, configPath string) string { + command := fmt.Sprintf("%q sandbox git-agent run-task --repo %q --task %q", captainBin, repo, task) + if strings.TrimSpace(configPath) != "" { + command += fmt.Sprintf(" --config %q", configPath) + } + return command +} diff --git a/pkg/cli/gitagent_runtask_test.go b/pkg/cli/gitagent_runtask_test.go new file mode 100644 index 00000000..c94b9f00 --- /dev/null +++ b/pkg/cli/gitagent_runtask_test.go @@ -0,0 +1,36 @@ +package cli + +import ( + "context" + "testing" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/gitagent" +) + +func TestRunTaskPromptCarriesSupervisorRuntime(t *testing.T) { + isolateSavedAI(t) + original := executePromptRequestFunc + t.Cleanup(func() { executePromptRequestFunc = original }) + + var captured ai.Request + executePromptRequestFunc = func(_ context.Context, req ai.Request, _ ai.Config, _ time.Duration, _ bool) (any, error) { + captured = req + return nil, nil + } + payload := gitagent.TaskPayload{ + Prompt: "make a change", Model: "gpt-5.6-sol", + Backend: string(api.BackendCodexCLI), Effort: api.EffortHigh, Timeout: "17m", + } + if err := runTaskPrompt(context.Background(), t.TempDir(), payload); err != nil { + t.Fatal(err) + } + if captured.Budget.Timeout != "17m" { + t.Fatalf("timeout = %q, want 17m", captured.Budget.Timeout) + } + if captured.Effort != api.EffortHigh { + t.Fatalf("effort = %q, want high", captured.Effort) + } +} diff --git a/pkg/cli/gitagent_serve.go b/pkg/cli/gitagent_serve.go new file mode 100644 index 00000000..7f0cb1a5 --- /dev/null +++ b/pkg/cli/gitagent_serve.go @@ -0,0 +1,268 @@ +package cli + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky" +) + +type GitAgentServeOptions struct { + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` + Listen string `flag:"listen" help:"Address to serve git-receive-pack on" default:":7422"` + Root string `flag:"root" help:"Directory of receivable repos (default /repos)"` + Role string `flag:"role" help:"Receiver role: sidecar (runs beside a coding agent) or mailbox (the supervisor's receiver)" default:"sidecar"` + Advertise string `flag:"advertise" help:"sidecar role: ssh://host:port the supervisor should dispatch to (default: the address the supervisor sees)"` + Join string `flag:"join" help:"Single-use join token printed by 'captain sandbox git-agent add'"` + Supervisor string `flag:"supervisor" help:"ssh://host:port of the supervisor to enroll with"` + HostFingerprint string `flag:"host-fingerprint" help:"Pinned SHA256 fingerprint of the supervisor's host key"` +} + +// RunGitAgentServe runs the receive endpoint on this host, optionally +// enrolling with a supervisor first. The agent keypair is generated locally +// on first start and its private half never leaves this machine (R8.2). +func RunGitAgentServe(ctx context.Context, opts GitAgentServeOptions) (any, error) { + role := gitagent.ReceiverRole(opts.Role) + if role != gitagent.RoleSidecar && role != gitagent.RoleMailbox { + return nil, fmt.Errorf("--role must be %q or %q", gitagent.RoleSidecar, gitagent.RoleMailbox) + } + keysDir, err := gitAgentKeysDir() + if err != nil { + return nil, err + } + root := opts.Root + if root == "" { + if root, err = gitAgentServedRoot(); err != nil { + return nil, err + } + } + root, err = filepath.Abs(root) + if err != nil { + return nil, err + } + if opts.Join != "" { + if err := joinSupervisor(ctx, opts, keysDir); err != nil { + return nil, err + } + } + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, err + } + // Reclaim worktrees orphaned by a crashed hook (R10.3). + gitagent.PruneWorktrees(ctx, root) + if err := ensureServedRepos(ctx, root, role, opts); err != nil { + return nil, err + } + hostKey, hostFP, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, hostKeyName)) + if err != nil { + return nil, err + } + offer, err := enrollmentOffer(role, keysDir) + if err != nil { + return nil, err + } + server, err := gitagent.NewServer(gitagent.ServerConfig{ + Listen: opts.Listen, + Root: root, + Role: role, + HostKey: hostKey, + Directory: gitAgentDirectory{backend: opts.Backend}, + Offer: offer, + AgentRepoPath: SidecarRepoName, + }) + if err != nil { + return nil, err + } + clicky.Printf("captain git-agent %s serving %s on %s\n", role, root, opts.Listen) + clicky.Printf(" host key: %s\n", hostFP) + if role == gitagent.RoleMailbox { + clicky.Printf(" enroll an agent with: captain sandbox git-agent add --endpoint ssh://:\n") + } else { + monitor := newAgentTaskLogMonitor(filepath.Join(root, SidecarRepoName), os.Stdout, os.Stderr, log.Infof) + if err := monitor.prime(); err != nil { + log.Warnf("git-agent task log monitor: %v", err) + } + go monitor.run(ctx) + } + go func() { + <-ctx.Done() + _ = server.Close() + }() + if err := server.ListenAndServe(); err != nil && ctx.Err() == nil { + return nil, err + } + return nil, nil +} + +// enrollmentOffer is what this endpoint hands a joining agent. Only a mailbox +// has a dispatch key for the agent to authorize; task-specific mailbox routes +// arrive later in authenticated dispatch envelopes. +func enrollmentOffer(role gitagent.ReceiverRole, keysDir string) (gitagent.EnrollmentOffer, error) { + if role != gitagent.RoleMailbox { + return gitagent.EnrollmentOffer{}, nil + } + _, dispatchFP, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, dispatchKeyName)) + if err != nil { + return gitagent.EnrollmentOffer{}, err + } + return gitagent.EnrollmentOffer{DispatchKey: dispatchFP}, nil +} + +// joinSupervisor performs the enrollment exchange and records both directions +// of trust: the supervisor's dispatch key is authorized locally, and its base +// endpoint is retained for task-specific result relays. +func joinSupervisor(ctx context.Context, opts GitAgentServeOptions, keysDir string) error { + if opts.Supervisor == "" { + return fmt.Errorf("--join requires --supervisor ssh://host:port") + } + signer, fp, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, agentKeyName)) + if err != nil { + return err + } + // The supervisor must be able to verify this endpoint's host key when it + // dispatches, so the key has to exist before we advertise its fingerprint. + _, hostFP, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, hostKeyName)) + if err != nil { + return err + } + _, port, err := net.SplitHostPort(opts.Listen) + if err != nil { + return fmt.Errorf("--listen %q must be [host]:port: %w", opts.Listen, err) + } + // Verify the local half can be persisted before consuming the supervisor's + // single-use token. The exchange cannot be transactional across hosts, but + // this catches path, permission, and backend-kind failures up front. + if err := captainconfig.Update(func(cfg *captainconfig.Config) error { + _, err := ensureGitAgentBackend(cfg, opts.Backend) + return err + }); err != nil { + return fmt.Errorf("prepare local enrollment config: %w", err) + } + resp, err := gitagent.Enroll(ctx, opts.Supervisor, opts.Join, opts.HostFingerprint, signer, gitagent.EnrollRequest{ + AdvertiseURL: advertiseURL(opts.Advertise), + ListenPort: port, + HostFingerprint: hostFP, + }) + if err != nil { + return err + } + err = captainconfig.Update(func(cfg *captainconfig.Config) error { + backend, err := ensureGitAgentBackend(cfg, opts.Backend) + if err != nil { + return err + } + // The task supplies its repository-specific mailbox route; enrollment + // records only the stable supervisor endpoint and host identity. + backend.Options["supervisor"] = map[string]any{ + "url": strings.TrimSuffix(opts.Supervisor, "/"), + "hostFingerprint": strings.TrimSpace(opts.HostFingerprint), + } + // Authorize the supervisor's dispatch key so its push is accepted + // here — the direction a one-way enrollment leaves broken. + agents, _ := backend.Options["agents"].(map[string]any) + if agents == nil { + agents = map[string]any{} + } + agents[supervisorAgentID] = map[string]any{"fingerprint": resp.DispatchKey} + backend.Options["agents"] = agents + cfg.Sandbox.Backends[opts.Backend] = backend + return nil + }) + if err != nil { + return fmt.Errorf("supervisor enrolled agent %q, but the local relay config could not be saved; mint a new join token and retry after fixing the config: %w", resp.Agent, err) + } + clicky.Printf("enrolled as %s\n", resp.Agent) + clicky.Printf(" this agent's key: %s\n", fp) + clicky.Printf(" this endpoint's host key: %s\n", hostFP) + clicky.Printf(" relays to: %s/\n", strings.TrimSuffix(opts.Supervisor, "/")) + clicky.Printf(" authorized supervisor key: %s\n", resp.DispatchKey) + return nil +} + +// advertiseURL normalizes an operator-supplied endpoint, appending the sidecar +// repository path when only a host:port was given. +func advertiseURL(raw string) string { + advertise := strings.TrimSpace(raw) + if advertise == "" { + return "" + } + if !strings.Contains(advertise, "://") { + advertise = "ssh://" + advertise + } + if trimmed := strings.TrimSuffix(advertise, "/"); !strings.Contains(strings.TrimPrefix(trimmed, "ssh://"), "/") { + advertise = trimmed + "/" + SidecarRepoName + } + return advertise +} + +// ensureServedRepos creates the role's repository and (re-)installs the hook +// shims on every repo under root, so an upgraded captain binary repoints the +// shims at itself. +func ensureServedRepos(ctx context.Context, root string, role gitagent.ReceiverRole, opts GitAgentServeOptions) error { + switch role { + case gitagent.RoleSidecar: + if err := gitagent.InitSidecar(ctx, filepath.Join(root, SidecarRepoName)); err != nil { + return err + } + case gitagent.RoleMailbox: + if err := os.MkdirAll(filepath.Join(root, gitagent.MailboxesDir), 0o755); err != nil { + return err + } + if err := recordMailboxRoot(opts.Backend, root); err != nil { + return err + } + } + exe, err := os.Executable() + if err != nil { + return err + } + // Bake the config path into the shims: a hook inherits the pusher's + // environment, and a co-located agent pushes from its own shell. + configPath, err := captainconfig.Path() + if err != nil { + return err + } + var repos []string + if role == gitagent.RoleSidecar { + repos = append(repos, filepath.Join(root, SidecarRepoName)) + } else { + entries, err := os.ReadDir(filepath.Join(root, gitagent.MailboxesDir)) + if err != nil { + return err + } + for _, e := range entries { + if e.IsDir() { + repos = append(repos, filepath.Join(root, gitagent.MailboxesDir, e.Name())) + } + } + } + for _, repo := range repos { + if _, err := os.Stat(filepath.Join(repo, "HEAD")); err != nil { + continue + } + if err := gitagent.InstallHookShims(repo, exe, configPath, role, opts.Backend); err != nil { + return err + } + } + return nil +} + +// recordMailboxRoot lets dispatch processes create repository-specific +// mailboxes under the same root served by this long-running endpoint. +func recordMailboxRoot(backendName, root string) error { + return captainconfig.Update(func(cfg *captainconfig.Config) error { + backend, err := ensureGitAgentBackend(cfg, backendName) + if err != nil { + return err + } + backend.Options["mailboxRoot"] = root + cfg.Sandbox.Backends[backendName] = backend + return nil + }) +} diff --git a/pkg/cli/gitagent_task_logs.go b/pkg/cli/gitagent_task_logs.go new file mode 100644 index 00000000..dd6ae5ec --- /dev/null +++ b/pkg/cli/gitagent_task_logs.go @@ -0,0 +1,208 @@ +package cli + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/flanksource/captain/pkg/gitagent" +) + +const agentTaskLogPollInterval = 200 * time.Millisecond + +// agentTaskLogMonitor bridges detached task output back to the long-running +// sidecar terminal. It tails files rather than inheriting receive-pack's file +// descriptors, which would keep the dispatch push open for the agent's life. +type agentTaskLogMonitor struct { + repo string + stdout io.Writer + stderr io.Writer + notify func(string, ...any) + interval time.Duration + tasks map[string]*observedAgentTask +} + +type observedAgentTask struct { + offsets map[string]int64 + attempts int + verdicts map[int]bool +} + +func newAgentTaskLogMonitor(repo string, stdout, stderr io.Writer, notify func(string, ...any)) *agentTaskLogMonitor { + return &agentTaskLogMonitor{ + repo: repo, stdout: stdout, stderr: stderr, notify: notify, + interval: agentTaskLogPollInterval, + tasks: map[string]*observedAgentTask{}, + } +} + +func (m *agentTaskLogMonitor) prime() error { + return m.scan(false) +} + +func (m *agentTaskLogMonitor) run(ctx context.Context) { + ticker := time.NewTicker(m.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := m.scan(true); err != nil { + log.Warnf("git-agent task log monitor: %v", err) + } + } + } +} + +func (m *agentTaskLogMonitor) scan(reportNew bool) error { + root := filepath.Join(m.repo, "captain", "tasks") + entries, err := os.ReadDir(root) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if err := m.scanTask(entry.Name(), reportNew); err != nil { + return fmt.Errorf("task %s: %w", entry.Name(), err) + } + } + return nil +} + +func (m *agentTaskLogMonitor) scanTask(task string, reportNew bool) error { + observed, exists := m.tasks[task] + if !exists { + observed = &observedAgentTask{offsets: map[string]int64{}, verdicts: map[int]bool{}} + m.tasks[task] = observed + if !reportNew { + return m.seedTask(task, observed) + } + m.notify("git-agent task %s received; workspace=%s", task, filepath.Join(m.repo, "captain", "tasks", task, "worktree")) + } + dir := filepath.Join(m.repo, "captain", "tasks", task) + for _, stream := range []struct { + name string + writer io.Writer + }{{"agent.stdout.log", m.stdout}, {"agent.stderr.log", m.stderr}} { + offset, err := copyAppended(filepath.Join(dir, stream.name), observed.offsets[stream.name], stream.writer) + if err != nil { + return err + } + observed.offsets[stream.name] = offset + } + state, found, err := gitagent.LoadTaskState(m.repo, task) + if err != nil { + return err + } + if found && state.Attempts > observed.attempts { + for attempt := observed.attempts + 1; attempt <= state.Attempts; attempt++ { + m.notify("git-agent task %s submit attempt %d", task, attempt) + } + observed.attempts = state.Attempts + } + return m.scanVerdicts(task, observed) +} + +func (m *agentTaskLogMonitor) seedTask(task string, observed *observedAgentTask) error { + dir := filepath.Join(m.repo, "captain", "tasks", task) + for _, name := range []string{"agent.stdout.log", "agent.stderr.log"} { + if info, err := os.Stat(filepath.Join(dir, name)); err == nil { + observed.offsets[name] = info.Size() + } else if !os.IsNotExist(err) { + return err + } + } + if state, found, err := gitagent.LoadTaskState(m.repo, task); err != nil { + return err + } else if found { + observed.attempts = state.Attempts + } + entries, err := os.ReadDir(filepath.Join(dir, "verdicts")) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + for _, entry := range entries { + if attempt, ok := verdictAttempt(entry.Name()); ok { + observed.verdicts[attempt] = true + } + } + return nil +} + +func (m *agentTaskLogMonitor) scanVerdicts(task string, observed *observedAgentTask) error { + dir := filepath.Join(m.repo, "captain", "tasks", task, "verdicts") + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + for _, entry := range entries { + attempt, ok := verdictAttempt(entry.Name()) + if !ok || observed.verdicts[attempt] { + continue + } + verdict, found, err := gitagent.LoadVerdict(m.repo, task, attempt) + if err != nil { + return err + } + if !found { + continue + } + message := "" + if len(verdict.Findings) > 0 && verdict.Findings[0].Message != "" { + message = ": " + verdict.Findings[0].Message + } + m.notify("git-agent task %s attempt %d %s at %s%s", task, attempt, verdict.Status, verdict.Tier, message) + observed.verdicts[attempt] = true + } + return nil +} + +func verdictAttempt(name string) (int, bool) { + if filepath.Ext(name) != ".json" { + return 0, false + } + attempt, err := strconv.Atoi(strings.TrimSuffix(name, ".json")) + return attempt, err == nil && attempt > 0 +} + +func copyAppended(path string, offset int64, dst io.Writer) (int64, error) { + file, err := os.Open(path) + if os.IsNotExist(err) { + return offset, nil + } + if err != nil { + return offset, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return offset, err + } + if info.Size() < offset { + offset = 0 + } + if _, err := file.Seek(offset, io.SeekStart); err != nil { + return offset, err + } + written, err := io.Copy(dst, file) + return offset + written, err +} diff --git a/pkg/cli/gitagent_task_logs_test.go b/pkg/cli/gitagent_task_logs_test.go new file mode 100644 index 00000000..973d1490 --- /dev/null +++ b/pkg/cli/gitagent_task_logs_test.go @@ -0,0 +1,99 @@ +package cli + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/gitagent" +) + +func TestAgentTaskLogMonitorStreamsOnlyNewTaskActivity(t *testing.T) { + repo := t.TempDir() + oldTask := "t-old" + if err := gitagent.SaveTaskState(repo, &gitagent.TaskState{Task: oldTask}); err != nil { + t.Fatal(err) + } + writeAgentLog(t, repo, oldTask, "agent.stderr.log", "old output\n") + + var stdout, stderr bytes.Buffer + var notices []string + monitor := newAgentTaskLogMonitor(repo, &stdout, &stderr, func(format string, args ...any) { + notices = append(notices, formatMessage(format, args...)) + }) + if err := monitor.prime(); err != nil { + t.Fatal(err) + } + if stdout.Len() != 0 || stderr.Len() != 0 || len(notices) != 0 { + t.Fatalf("prime replayed old activity: stdout=%q stderr=%q notices=%v", stdout.String(), stderr.String(), notices) + } + + task := "t-new" + if err := gitagent.SaveTaskState(repo, &gitagent.TaskState{Task: task}); err != nil { + t.Fatal(err) + } + writeAgentLog(t, repo, task, "agent.stdout.log", "model output\n") + writeAgentLog(t, repo, task, "agent.stderr.log", "normal captain log\n") + if err := monitor.scan(true); err != nil { + t.Fatal(err) + } + if stdout.String() != "model output\n" || stderr.String() != "normal captain log\n" { + t.Fatalf("new task output was not streamed: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + + writeAgentLog(t, repo, task, "agent.stderr.log", "next line\n") + if _, err := gitagent.UpdateTaskState(repo, task, func(state *gitagent.TaskState) (bool, error) { + state.Attempts = 1 + return true, nil + }); err != nil { + t.Fatal(err) + } + if err := gitagent.SaveVerdict(repo, gitagent.TierVerdict{ + V: 1, Task: task, Attempt: 1, Tier: "sidecar", Status: gitagent.StatusAccepted, + }); err != nil { + t.Fatal(err) + } + if err := monitor.scan(true); err != nil { + t.Fatal(err) + } + if err := monitor.scan(true); err != nil { + t.Fatal(err) + } + + if stderr.String() != "normal captain log\nnext line\n" { + t.Fatalf("tail replayed or lost bytes: %q", stderr.String()) + } + joined := strings.Join(notices, "\n") + for _, want := range []string{ + "git-agent task t-new received", + "git-agent task t-new submit attempt 1", + "git-agent task t-new attempt 1 accepted at sidecar", + } { + if strings.Count(joined, want) != 1 { + t.Fatalf("notices %q contain %q %d times, want once", joined, want, strings.Count(joined, want)) + } + } +} + +func writeAgentLog(t *testing.T, repo, task, name, text string) { + t.Helper() + dir := filepath.Join(repo, "captain", "tasks", task) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + file, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + t.Fatal(err) + } + defer file.Close() + if _, err := file.WriteString(text); err != nil { + t.Fatal(err) + } +} + +func formatMessage(format string, args ...any) string { + return fmt.Sprintf(format, args...) +} diff --git a/pkg/cli/gitagent_test.go b/pkg/cli/gitagent_test.go new file mode 100644 index 00000000..d2ec0410 --- /dev/null +++ b/pkg/cli/gitagent_test.go @@ -0,0 +1,173 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" +) + +func isolatedConfig(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), ".captain.yaml") + captainconfig.SetPathForTesting(path) + t.Cleanup(func() { captainconfig.SetPathForTesting("") }) + return path +} + +func TestGitAgentAddMintsSingleUseToken(t *testing.T) { + path := isolatedConfig(t) + + res, err := RunGitAgentAdd(GitAgentAddOptions{Name: "worker-1", Backend: "git-agent"}) + if err != nil { + t.Fatal(err) + } + add, ok := res.(GitAgentAddResult) + if !ok { + t.Fatalf("result = %T", res) + } + if add.HostFingerprint == "" || !strings.Contains(add.JoinCommand, "--join ") { + t.Fatalf("join hand-off incomplete: %+v", add) + } + if !strings.Contains(add.JoinCommand, "--host-fingerprint "+add.HostFingerprint) { + t.Fatalf("join command must pin the host key: %s", add.JoinCommand) + } + if time.Until(add.Expires) > gitagent.JoinTokenTTL { + t.Fatalf("token TTL too long: %s", add.Expires) + } + + // The raw token never lands in the config file — only its hash (R8.2). + token := strings.Fields(strings.SplitAfter(add.JoinCommand, "--join ")[1])[0] + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), token) { + t.Fatal("the raw join token must not be persisted") + } + if !strings.Contains(string(raw), gitagent.HashJoinToken(token)) { + t.Fatal("the token hash must be persisted as pending") + } + + // Consume: valid once, burned after (R8.2). + dir := gitAgentDirectory{backend: "git-agent"} + name, err := dir.ConsumeJoinToken(token) + if err != nil || name != "worker-1" { + t.Fatalf("consume = %q, %v", name, err) + } + if _, err := dir.ConsumeJoinToken(token); err == nil || !strings.Contains(err.Error(), "already used") { + t.Fatalf("replay must fail, got %v", err) + } +} + +func TestGitAgentExpiredTokenRefused(t *testing.T) { + isolatedConfig(t) + token, hash, err := gitagent.MintJoinToken() + if err != nil { + t.Fatal(err) + } + err = captainconfig.Update(func(cfg *captainconfig.Config) error { + backend, err := ensureGitAgentBackend(cfg, "git-agent") + if err != nil { + return err + } + backend.Options["pending"] = map[string]any{ + hash: map[string]any{ + "agent": "worker-1", + "expires": time.Now().Add(-time.Minute).UTC().Format(time.RFC3339), + }, + } + cfg.Sandbox.Backends["git-agent"] = backend + return nil + }) + if err != nil { + t.Fatal(err) + } + dir := gitAgentDirectory{backend: "git-agent"} + if _, err := dir.ConsumeJoinToken(token); err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("expired token must fail, got %v", err) + } + // And expiry burns it: a retry is unknown, not expired. + if _, err := dir.ConsumeJoinToken(token); err == nil || !strings.Contains(err.Error(), "already used") { + t.Fatalf("expired token must burn, got %v", err) + } +} + +func TestGitAgentEnrollListRevoke(t *testing.T) { + isolatedConfig(t) + dir := gitAgentDirectory{backend: "git-agent"} + if err := dir.RecordAgent(gitagent.AgentEnrollment{ + Name: "worker-1", Fingerprint: "SHA256:abc", + URL: "ssh://127.0.0.1:7422/repo.git", HostFingerprint: "SHA256:host", + }); err != nil { + t.Fatal(err) + } + if name, ok := dir.AgentByFingerprint("SHA256:abc"); !ok || name != "worker-1" { + t.Fatalf("lookup = %q, %v", name, ok) + } + + res, err := RunGitAgentList(GitAgentListOptions{Backend: "git-agent"}) + if err != nil { + t.Fatal(err) + } + entries := res.([]GitAgentListEntry) + if len(entries) != 1 || entries[0].Name != "worker-1" || entries[0].Status != "enrolled" { + t.Fatalf("entries = %+v", entries) + } + if _, err := RunGitAgentRevoke(GitAgentRevokeOptions{Name: "worker-1", Backend: "git-agent", DryRun: true}); err != nil { + t.Fatalf("dry-run existing agent: %v", err) + } + + if _, err := RunGitAgentRevoke(GitAgentRevokeOptions{Name: "worker-1", Backend: "git-agent"}); err != nil { + t.Fatal(err) + } + // Revocation is effective for lookups after it (R8.5). + if _, ok := dir.AgentByFingerprint("SHA256:abc"); ok { + t.Fatal("revoked fingerprint must be refused") + } + if _, err := RunGitAgentRevoke(GitAgentRevokeOptions{Name: "worker-1", Backend: "git-agent"}); err == nil { + t.Fatal("revoking an unknown agent must error") + } + if _, err := RunGitAgentRevoke(GitAgentRevokeOptions{Name: "worker-1", Backend: "git-agent", DryRun: true}); err == nil { + t.Fatal("dry-run revoking an unknown agent must error") + } +} + +func TestGitAgentAddDryRunTouchesNothing(t *testing.T) { + path := isolatedConfig(t) + result, err := RunGitAgentAdd(GitAgentAddOptions{Name: "worker-1", Backend: "git-agent", DryRun: true}) + if err != nil { + t.Fatal(err) + } + if add, ok := result.(GitAgentAddResult); !ok || !add.DryRun { + t.Fatalf("dry-run result = %#v", result) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("dry-run must not write the config, stat err = %v", err) + } + keysDir := filepath.Join(filepath.Dir(path), ".captain", "sandbox") + if _, err := os.Stat(keysDir); !os.IsNotExist(err) { + t.Fatalf("dry-run must not create key material, stat err = %v", err) + } +} + +func TestEnsureGitAgentBackendRejectsDifferentKind(t *testing.T) { + cfg := captainconfig.Config{} + cfg.Sandbox.Backends = map[string]captainconfig.SandboxBackend{ + "shared": {Kind: "srt"}, + } + if _, err := ensureGitAgentBackend(&cfg, "shared"); err == nil || !strings.Contains(err.Error(), "not git-agent") { + t.Fatalf("error = %v", err) + } +} + +func TestDecodeWorkflowRejectsUnknownFields(t *testing.T) { + _, err := decodeWorkflow(map[string]any{"verify": map[string]any{"promtps": []string{"judge.prompt"}}}) + if err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("error = %v", err) + } +} diff --git a/pkg/cli/prompt_batch_run.go b/pkg/cli/prompt_batch_run.go index 6fedab8b..28e315f3 100644 --- a/pkg/cli/prompt_batch_run.go +++ b/pkg/cli/prompt_batch_run.go @@ -52,11 +52,11 @@ func launchAsyncBatch(ctx context.Context, id string, rendered PromptRenderResul } handles[i] = group.Add(runtimeSelector(run.Runtime), func(_ flanksourceContext.Context, t *task.Task) (PromptRunSummary, error) { if chat { - chatSession := newChatSession(runID, variant, runtimeTimeout(variant.Input.Budget.Timeout), stream, binding) + chatSession := newChatSession(runID, variant, renderedTimeout(variant), stream, binding) promptChats.register(chatSession) return chatSession.run(t) } - summary, runErr := runPromptStream(t, variant, runtimeTimeout(variant.Input.Budget.Timeout), runID, stream, binding) + summary, runErr := runPromptStream(t, variant, renderedTimeout(variant), runID, stream, binding) if runErr != nil { persistPromptRun(context.WithoutCancel(t.Context()), promptRunRecordInput{ Rendered: variant, RunID: runID, Binding: binding, diff --git a/pkg/cli/prompt_entity.go b/pkg/cli/prompt_entity.go index 154d32f4..41ed6db5 100644 --- a/pkg/cli/prompt_entity.go +++ b/pkg/cli/prompt_entity.go @@ -131,7 +131,7 @@ type PromptActionFlags struct { Attach []string `flag:"attach" help:"Attach a local path or URL (repeatable; RFC 4180 comma-separated values allowed)" short:"A"` Vars string `flag:"vars" help:"JSON object of template variables (HTTP callers)"` MultiModels []string `flag:"multi-models" help:"Run prompt once per runtime selector in parallel, e.g. cli:sonnet-5,cmux:opus (repeatable; comma-separated allowed)" short:"M"` - Timeout string `flag:"timeout" help:"Request timeout" default:"120s"` + Timeout string `flag:"timeout" help:"Request timeout (default 120s; a relocating sandbox waits for the remote agent instead)"` NoStream bool `flag:"no-stream" help:"Disable streaming; print only the final text (CLI)"` } diff --git a/pkg/cli/prompt_run.go b/pkg/cli/prompt_run.go index 5c87edfd..5ddd9d45 100644 --- a/pkg/cli/prompt_run.go +++ b/pkg/cli/prompt_run.go @@ -93,7 +93,7 @@ var executePromptRequestFunc = executePromptRequest func launchAsyncRun(id string, rendered PromptRenderResult, chat bool) PromptRunResult { runID := uuid.NewString() stream := promptRuns.create(runID) - timeout := runtimeTimeout(rendered.Input.Budget.Timeout) + timeout := renderedTimeout(rendered) capabilities := chatCapabilitiesForBackend(rendered.Backend) stream.setRun(PromptRunFrame{ RunID: runID, Status: "running", Chat: chat, Model: rendered.Model, @@ -156,7 +156,7 @@ func executeSyncRunSingleDirect(ctx context.Context, t *task.Task, rendered Prom if workflowConfigured(rendered.Input.Workflow) { return executeSyncWorkflowRun(t, rendered, opts.NoStream, binding) } - out, err := executePromptRequestFunc(ctx, rendered.Input, rendered.Config, runtimeTimeout(rendered.Input.Budget.Timeout), opts.NoStream) + out, err := executePromptRequestFunc(ctx, rendered.Input, rendered.Config, renderedTimeout(rendered), opts.NoStream) if err != nil { return PromptRunResult{}, err } @@ -192,7 +192,7 @@ func executeSyncWorkflowRun(t *task.Task, rendered PromptRenderResult, noStream // loop only runs under `captain serve` — deregister so embedders don't // accumulate finished runs. defer promptRuns.remove(runID) - timeout := runtimeTimeout(rendered.Input.Budget.Timeout) + timeout := renderedTimeout(rendered) var summary PromptRunSummary var err error if noStream { diff --git a/pkg/cli/sandbox.go b/pkg/cli/sandbox.go index f9710545..4eafb04e 100644 --- a/pkg/cli/sandbox.go +++ b/pkg/cli/sandbox.go @@ -21,7 +21,9 @@ func SandboxHelp() api.Text { AddText(" captain sandbox generate", "text-green-400"). AddText(" — generate sandbox-runtime config", "text-gray-500").NewLine(). AddText(" captain sandbox presets", "text-green-400"). - AddText(" — list available presets with details", "text-gray-500").NewLine().NewLine(). + AddText(" — list available presets with details", "text-gray-500").NewLine(). + AddText(" captain sandbox git-agent", "text-green-400"). + AddText(" — enroll and serve remote coding agents", "text-gray-500").NewLine().NewLine(). AddText("See also:", "font-bold text-blue-400").NewLine(). AddText(" captain container", "text-green-400"). AddText(" — build container images with preset support", "text-gray-500").NewLine() diff --git a/pkg/cli/serve_disabled.go b/pkg/cli/serve_disabled.go index 133a31cd..a05c236a 100644 --- a/pkg/cli/serve_disabled.go +++ b/pkg/cli/serve_disabled.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "strings" + "sync" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/captainconfig" @@ -64,6 +65,11 @@ func handleDisabledSelections(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusUnprocessableEntity) return } + // The persist+install pair must commit in the same order for every writer: + // unlocked, two concurrent PUTs can reach disk in one order and install + // process-wide in the other, leaving the runtime set diverged from the file. + disabledWriteMu.Lock() + defer disabledWriteMu.Unlock() if err := captainconfig.Update(func(cfg *captainconfig.Config) error { cfg.AI.Disabled = selections return nil @@ -77,6 +83,8 @@ func handleDisabledSelections(w http.ResponseWriter, r *http.Request) { writeConfigurationJSON(w, disabledSelectionsRequest(selections)) } +var disabledWriteMu sync.Mutex + // normalizeTokens trims, drops blanks and de-duplicates case-insensitively. // Enum axes are canonicalized to lower case; model ids keep the case they were // written in, since they are only ever displayed back and matched insensitively. diff --git a/pkg/cli/srt.go b/pkg/cli/srt.go index 498361a2..06ac4dc6 100644 --- a/pkg/cli/srt.go +++ b/pkg/cli/srt.go @@ -490,7 +490,9 @@ func mergeStringSlices(a, b []string) []string { return sortedKeys(seen) } -func sortedKeys(m map[string]bool) []string { +// sortedKeys is generic over the value type so set-shaped maps and decoded +// config maps share one helper. +func sortedKeys[V any](m map[string]V) []string { result := make([]string, 0, len(m)) for k := range m { result = append(result, k) diff --git a/pkg/gitagent/admit.go b/pkg/gitagent/admit.go new file mode 100644 index 00000000..082b719d --- /dev/null +++ b/pkg/gitagent/admit.go @@ -0,0 +1,356 @@ +// Admission (§6.2 step 7): the sub-second, pure-data tier of pre-receive. +// Everything here is checkable from ref names, the envelope, task state and +// object metadata — no tree is materialized and no hook is run. +package gitagent + +import ( + "bufio" + "context" + "fmt" + "io" + "strconv" + "strings" + + "github.com/flanksource/captain/pkg/ai/agent/commit" +) + +// RefUpdate is one "old new ref" line of the pre-receive stdin. +type RefUpdate struct { + Old, New, Ref string +} + +// IsCreate reports whether the update creates the ref. +func (u RefUpdate) IsCreate() bool { return isZeroOID(u.Old) } + +// IsDelete reports whether the update deletes the ref. +func (u RefUpdate) IsDelete() bool { return isZeroOID(u.New) } + +// ParseRefUpdates reads pre-receive stdin lines. +func ParseRefUpdates(r io.Reader) ([]RefUpdate, error) { + var updates []RefUpdate + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) != 3 { + return nil, fmt.Errorf("unparseable ref update line %q", line) + } + if err := ValidateOID(fields[0]); err != nil { + return nil, fmt.Errorf("ref update %q: old: %w", line, err) + } + if err := ValidateOID(fields[1]); err != nil { + return nil, fmt.Errorf("ref update %q: new: %w", line, err) + } + updates = append(updates, RefUpdate{Old: fields[0], New: fields[1], Ref: fields[2]}) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return updates, nil +} + +// AdmitRequest carries everything the admission tier may consult. +type AdmitRequest struct { + Repo string + Role ReceiverRole + Agent string // authenticated agent identity; "" for an unauthenticated local push + Updates []RefUpdate + Envelope *Envelope // nil when the push carried none (an agent's bare push) + Env []string // hook environment — keeps quarantine object dirs readable +} + +// Admit accepts or rejects a push. The error message is the rejection reason +// shown to the pusher. +func Admit(ctx context.Context, req AdmitRequest) error { + if len(req.Updates) == 0 { + return fmt.Errorf("push updates no refs") + } + protocol := map[string]RefInfo{} + for _, u := range req.Updates { + switch { + case IsProtocolRef(u.Ref): + info, err := admitProtocolRef(req, u) + if err != nil { + return err + } + protocol[u.Ref] = info + case req.Role == RoleSidecar && strings.HasPrefix(u.Ref, agentBranchPrefix): + if err := admitAgentBranch(ctx, req, u); err != nil { + return err + } + default: + return fmt.Errorf("ref %s is outside the protocol namespaces this receiver accepts", u.Ref) + } + } + if err := requireAtomicPairs(ctx, req, protocol); err != nil { + return err + } + for ref, info := range protocol { + if err := admitCodeContent(ctx, req, refUpdateFor(req.Updates, ref), info); err != nil { + return err + } + } + return nil +} + +// admitProtocolRef checks the pure-name properties of one protocol ref +// update: shape, create-only (R3.2), role allowlist, envelope agreement +// (R4.1), and namespace ownership (R8.3). +func admitProtocolRef(req AdmitRequest, u RefUpdate) (RefInfo, error) { + info, err := ParseTaskRef(u.Ref) + if err != nil { + return RefInfo{}, err + } + if u.IsDelete() { + return RefInfo{}, fmt.Errorf("ref %s: protocol refs cannot be deleted (R3.2)", u.Ref) + } + if !u.IsCreate() { + return RefInfo{}, fmt.Errorf("ref %s already exists; every protocol ref push is a create (R3.2)", u.Ref) + } + allowed := map[ReceiverRole][]RefKind{ + RoleSidecar: {RefDispatch, RefControl}, + RoleMailbox: {RefResult, RefControl}, + }[req.Role] + if !containsKind(allowed, info.Kind) { + return RefInfo{}, fmt.Errorf("ref %s: a %s does not accept %s refs over the wire", u.Ref, req.Role, info.Kind) + } + if req.Envelope == nil { + return RefInfo{}, fmt.Errorf("ref %s: protocol ref pushes require the control envelope in push options (R4.1)", u.Ref) + } + if err := req.Envelope.MatchesRef(info); err != nil { + return RefInfo{}, err + } + if req.Role == RoleSidecar && info.Kind == RefDispatch && req.Envelope.Mailbox == "" { + return RefInfo{}, fmt.Errorf("ref %s: dispatch envelope carries no mailbox route", u.Ref) + } + if !NamespaceContains(TaskNamespace(info.Task), u.Ref) { + return RefInfo{}, fmt.Errorf("ref %s escapes its task namespace", u.Ref) + } + // Task ownership binds on the mailbox, where results arrive from enrolled + // agents; a sidecar's dispatch refs are what CREATE the task there. + if req.Role == RoleMailbox && req.Agent != "" { + st, ok, err := LoadTaskState(req.Repo, info.Task) + if err != nil { + return RefInfo{}, err + } + if !ok { + return RefInfo{}, fmt.Errorf("task %s was never dispatched here", info.Task) + } + if st.Agent != req.Agent { + return RefInfo{}, fmt.Errorf("agent %q cannot write task %s, which belongs to agent %q (R8.3)", req.Agent, info.Task, st.Agent) + } + if st.Policy.MaxAttempts > 0 && info.Attempt > st.Policy.MaxAttempts { + return RefInfo{}, fmt.Errorf("attempt %d exceeds the task's maxAttempts %d", info.Attempt, st.Policy.MaxAttempts) + } + } + return info, nil +} + +// admitAgentBranch checks an agent's bare push to refs/heads/captain/: +// the task must have been dispatched here, deletes are refused, and updates +// must be fast-forward. +func admitAgentBranch(ctx context.Context, req AdmitRequest, u RefUpdate) error { + task := strings.TrimPrefix(u.Ref, agentBranchPrefix) + if err := ValidateTaskID(task); err != nil { + return fmt.Errorf("ref %s: %w", u.Ref, err) + } + if u.IsDelete() { + return fmt.Errorf("ref %s: the task branch cannot be deleted", u.Ref) + } + st, ok, err := LoadTaskState(req.Repo, task) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("task %s was never dispatched to this sidecar", task) + } + if !u.IsCreate() { + code, _, err := gitExitCode(ctx, req.Repo, req.Env, "merge-base", "--is-ancestor", u.Old, u.New) + if err != nil { + return err + } + if code != 0 { + return fmt.Errorf("ref %s: non-fast-forward push rejected; fetch and rebase onto your branch tip", u.Ref) + } + } + return admitContent(ctx, req, st, st.DispatchCommit, u.New) +} + +// requireAtomicPairs enforces R3.4: a code ref is unprocessable without its +// control ref. The control may travel in the same atomic push or already be +// present at the receiver — git filters an up-to-date control out of a push, +// so attempt 1's relay legitimately arrives result-only when the supervisor +// audit-wrote the identical control commit at dispatch. +func requireAtomicPairs(ctx context.Context, req AdmitRequest, protocol map[string]RefInfo) error { + if len(protocol) == 0 { + return nil + } + type key struct { + task string + attempt int + } + kinds := map[key]map[RefKind]bool{} + for _, info := range protocol { + k := key{info.Task, info.Attempt} + if kinds[k] == nil { + kinds[k] = map[RefKind]bool{} + } + kinds[k][info.Kind] = true + } + for k, present := range kinds { + code := present[RefDispatch] || present[RefResult] + if code && !present[RefControl] && !controlRefExists(ctx, req, k.task, k.attempt) { + return fmt.Errorf("task %s attempt %d: a code ref without its control ref is unprocessable (R3.4)", k.task, k.attempt) + } + if present[RefControl] && !code { + return fmt.Errorf("task %s attempt %d: a control ref must travel with its code ref (R3.4)", k.task, k.attempt) + } + } + return nil +} + +func controlRefExists(ctx context.Context, req AdmitRequest, task string, attempt int) bool { + ref, err := ControlRef(task, attempt) + if err != nil { + return false + } + code, _, err := gitExitCode(ctx, req.Repo, req.Env, "rev-parse", "--verify", "--quiet", ref) + return err == nil && code == 0 +} + +// admitCodeContent runs the content checks that need object access: result +// parentage, blob caps and name gates. Control refs carry no worktree code. +func admitCodeContent(ctx context.Context, req AdmitRequest, u RefUpdate, info RefInfo) error { + if info.Kind != RefResult { + return nil + } + st, ok, err := LoadTaskState(req.Repo, info.Task) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("task %s was never dispatched here", info.Task) + } + if req.Envelope == nil || req.Envelope.Base != st.Base { + got := "" + if req.Envelope != nil { + got = req.Envelope.Base + } + return fmt.Errorf("task %s: envelope base %s does not match dispatched base %s", info.Task, got, st.Base) + } + parents, err := runGit(ctx, req.Repo, req.Env, "rev-list", "--parents", "-n", "1", u.New) + if err != nil { + return err + } + fields := strings.Fields(parents) + if len(fields) != 2 || fields[1] != st.DispatchCommit { + return fmt.Errorf("result %s must be parented on its dispatch %s", u.New, st.DispatchCommit) + } + return admitContent(ctx, req, st, st.DispatchCommit, u.New) +} + +// admitContent applies the pure-data content gates over old..new: path +// policy, secret-shaped names, and blob size caps. It stats nothing on disk — +// the tree is not materialized at this tier. +func admitContent(ctx context.Context, req AdmitRequest, st *TaskState, from, to string) error { + out, err := runGitRaw(ctx, req.Repo, req.Env, nil, + "diff-tree", "-r", "-z", "--name-only", "--no-commit-id", "--no-renames", from, to) + if err != nil { + return err + } + var paths []string + for _, p := range strings.Split(out, "\x00") { + if p != "" { + paths = append(paths, p) + } + } + kept, err := filterPolicyPaths(paths, st.Policy.Paths) + if err != nil { + return err + } + if len(kept) != len(paths) { + denied := diffPaths(paths, kept) + return fmt.Errorf("gate:path-denied %s", strings.Join(denied, " ")) + } + for _, p := range paths { + if commit.LooksSecret(p) { + return fmt.Errorf("gate:secret-name %s looks like a credential; the push is rejected (A5.4)", p) + } + } + return admitBlobCaps(ctx, req, st, from, to) +} + +func admitBlobCaps(ctx context.Context, req AdmitRequest, st *TaskState, from, tip string) error { + maxBlob := st.Policy.MaxBlobSize + if maxBlob == 0 { + maxBlob = DefaultSnapshotMaxFileSize + } + objects, err := runGitRaw(ctx, req.Repo, req.Env, nil, + "rev-list", "--objects", tip, "--not", from) + if err != nil { + return err + } + var oids strings.Builder + for _, line := range strings.Split(objects, "\n") { + if fields := strings.Fields(line); len(fields) > 0 { + oids.WriteString(fields[0]) + oids.WriteByte('\n') + } + } + if oids.Len() == 0 { + return nil + } + sizes, err := runGitIn(ctx, req.Repo, req.Env, strings.NewReader(oids.String()), + "cat-file", "--batch-check=%(objecttype) %(objectsize) %(objectname)") + if err != nil { + return err + } + for _, line := range strings.Split(sizes, "\n") { + fields := strings.Fields(line) + if len(fields) == 3 && fields[0] == "blob" { + size, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + return fmt.Errorf("gate:blob-size cannot read the size of object %s: %w", fields[2], err) + } + if size > maxBlob { + return fmt.Errorf("gate:blob-size object %s is %d bytes, over the %d-byte cap", fields[2], size, maxBlob) + } + } + } + return nil +} + +func containsKind(kinds []RefKind, k RefKind) bool { + for _, kind := range kinds { + if kind == k { + return true + } + } + return false +} + +func refUpdateFor(updates []RefUpdate, ref string) RefUpdate { + for _, u := range updates { + if u.Ref == ref { + return u + } + } + return RefUpdate{} +} + +func diffPaths(all, kept []string) []string { + keep := map[string]bool{} + for _, p := range kept { + keep[p] = true + } + var out []string + for _, p := range all { + if !keep[p] { + out = append(out, p) + } + } + return out +} diff --git a/pkg/gitagent/admit_ginkgo_test.go b/pkg/gitagent/admit_ginkgo_test.go new file mode 100644 index 00000000..05430782 --- /dev/null +++ b/pkg/gitagent/admit_ginkgo_test.go @@ -0,0 +1,295 @@ +package gitagent_test + +import ( + "context" + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent" +) + +const zeroOID40 = "0000000000000000000000000000000000000000" + +// admitFixture builds a supervisor repo with a snapshot, a control commit, +// and an initialized sidecar holding both (pushed without admission, so the +// tests can drive Admit directly against real objects). +type admitFixture struct { + super, sidecar string + snap *gitagent.Snapshot + control string + env []string +} + +func newAdmitFixture(ctx context.Context) *admitFixture { + super := GinkgoT().TempDir() + gitT(super, "init", "-q") + writeFileT(super, "src/main.go", "package main\n") + gitT(super, "add", "-A") + gitT(super, "commit", "-q", "-m", "base") + writeFileT(super, "src/dirty.go", "package main // dirty\n") + + snap, err := gitagent.TakeSnapshot(ctx, super, gitagent.SnapshotPolicy{}) + Expect(err).NotTo(HaveOccurred()) + control, err := gitagent.BuildControlCommit(ctx, super, nil, map[string][]byte{ + gitagent.ControlTaskFile: []byte(`{"prompt":"do the thing"}`), + gitagent.ControlHooksFile: []byte(`{}`), + gitagent.ControlPolicyFile: []byte(`{}`), + }) + Expect(err).NotTo(HaveOccurred()) + + sidecar := filepath.Join(GinkgoT().TempDir(), "repo.git") + Expect(gitagent.InitSidecar(ctx, sidecar)).To(Succeed()) + gitT(super, "push", "-q", sidecar, + snap.Commit+":refs/captain/tasks/t-1/dispatch/1", + control+":refs/captain/tasks/t-1/control/1") + return &admitFixture{super: super, sidecar: sidecar, snap: snap, control: control, env: os.Environ()} +} + +func (f *admitFixture) envelope() *gitagent.Envelope { + return &gitagent.Envelope{ + Version: gitagent.ProtocolVersion, + Task: "t-1", + Attempt: 1, + Base: f.snap.Base, + Depth: 0, + Agent: "worker-1", + Relay: gitagent.RelaySync, + Mailbox: "mailboxes/" + strings.Repeat("a", 64) + ".git", + } +} + +func (f *admitFixture) dispatchUpdates() []gitagent.RefUpdate { + return []gitagent.RefUpdate{ + {Old: zeroOID40, New: f.snap.Commit, Ref: "refs/captain/tasks/t-1/dispatch/1"}, + {Old: zeroOID40, New: f.control, Ref: "refs/captain/tasks/t-1/control/1"}, + } +} + +// newCommitOn writes files on top of parent in repo and returns the commit, +// leaving the objects in the repo with no ref pointing at them (as quarantine +// would). +func newCommitOn(ctx context.Context, repo, parent string, files map[string]string) string { + GinkgoHelper() + work := GinkgoT().TempDir() + gitT(repo, "worktree", "add", "-q", "--detach", work, parent) + defer gitT(repo, "worktree", "remove", "--force", work) + for path, content := range files { + writeFileT(work, path, content) + } + gitT(work, "add", "-A") + gitT(work, "commit", "-q", "-m", "agent work") + return gitT(work, "rev-parse", "HEAD") +} + +var _ = Describe("admission", func() { + ctx := context.Background() + + It("parses pre-receive stdin", func() { + updates, err := gitagent.ParseRefUpdates(strings.NewReader( + zeroOID40 + " " + strings.Repeat("a", 40) + " refs/captain/tasks/t-1/dispatch/1\n")) + Expect(err).NotTo(HaveOccurred()) + Expect(updates).To(HaveLen(1)) + Expect(updates[0].IsCreate()).To(BeTrue()) + Expect(updates[0].IsDelete()).To(BeFalse()) + + _, err = gitagent.ParseRefUpdates(strings.NewReader("garbage\n")) + Expect(err).To(HaveOccurred()) + }) + + It("admits a well-formed dispatch pair on the sidecar", func() { + f := newAdmitFixture(ctx) + Expect(gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: f.dispatchUpdates(), Envelope: f.envelope(), Env: f.env, + })).To(Succeed()) + }) + + It("rejects deletes, updates to existing refs, and unpaired refs (R3.2/R3.4)", func() { + f := newAdmitFixture(ctx) + + del := f.dispatchUpdates() + del[0].New = zeroOID40 + err := gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: del, Envelope: f.envelope(), Env: f.env}) + Expect(err).To(MatchError(ContainSubstring("cannot be deleted"))) + + upd := f.dispatchUpdates() + upd[0].Old = f.snap.Base + err = gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: upd, Envelope: f.envelope(), Env: f.env}) + Expect(err).To(MatchError(ContainSubstring("every protocol ref push is a create"))) + + // A code ref alone is unprocessable — unless the receiver already + // holds that attempt's control ref, which the fixture's task does. + Expect(gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: f.dispatchUpdates()[:1], Envelope: f.envelope(), Env: f.env})). + To(Succeed(), "an existing control ref satisfies the pairing") + + fresh := f.envelope() + fresh.Task = "t-2" + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: zeroOID40, New: f.snap.Commit, Ref: "refs/captain/tasks/t-2/dispatch/1"}}, + Envelope: fresh, Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("R3.4"))) + }) + + It("rejects role mismatches, missing envelopes and envelope disagreement (R4.1)", func() { + f := newAdmitFixture(ctx) + + err := gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleMailbox, Updates: f.dispatchUpdates(), Envelope: f.envelope(), Env: f.env}) + Expect(err).To(MatchError(ContainSubstring("does not accept dispatch"))) + + err = gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: f.dispatchUpdates(), Env: f.env}) + Expect(err).To(MatchError(ContainSubstring("require the control envelope"))) + + unroutable := f.envelope() + unroutable.Mailbox = "" + err = gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: f.dispatchUpdates(), Envelope: unroutable, Env: f.env}) + Expect(err).To(MatchError(ContainSubstring("no mailbox route"))) + + wrong := f.envelope() + wrong.Attempt = 2 + err = gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: f.dispatchUpdates(), Envelope: wrong, Env: f.env}) + Expect(err).To(MatchError(ContainSubstring("disagrees"))) + + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: zeroOID40, New: f.snap.Commit, Ref: "refs/heads/main"}}, + Envelope: f.envelope(), Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("outside the protocol namespaces"))) + }) + + Describe("agent branch pushes on the sidecar", func() { + It("requires a dispatched task and fast-forward updates", func() { + f := newAdmitFixture(ctx) + work := newCommitOn(ctx, f.sidecar, f.snap.Commit, map[string]string{"src/fix.go": "package main // fix\n"}) + + err := gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: zeroOID40, New: work, Ref: "refs/heads/captain/t-1"}}, + Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("never dispatched"))) + + Expect(gitagent.SaveTaskState(f.sidecar, &gitagent.TaskState{ + Task: "t-1", Agent: "worker-1", Base: f.snap.Base, DispatchCommit: f.snap.Commit, + })).To(Succeed()) + + Expect(gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: zeroOID40, New: work, Ref: "refs/heads/captain/t-1"}}, + Env: f.env, + })).To(Succeed()) + + // A push rewinding to the dispatch tip is not a fast-forward. + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: work, New: f.snap.Commit, Ref: "refs/heads/captain/t-1"}}, + Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("non-fast-forward"))) + }) + + It("applies the content gates to agent work", func() { + f := newAdmitFixture(ctx) + Expect(gitagent.SaveTaskState(f.sidecar, &gitagent.TaskState{ + Task: "t-1", Agent: "worker-1", Base: f.snap.Base, DispatchCommit: f.snap.Commit, + Policy: gitagent.Policy{Paths: []string{"src/**"}, MaxBlobSize: 64}, + })).To(Succeed()) + + secret := newCommitOn(ctx, f.sidecar, f.snap.Commit, map[string]string{"src/.env": "TOKEN=x\n"}) + err := gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: zeroOID40, New: secret, Ref: "refs/heads/captain/t-1"}}, + Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("gate:secret-name"))) + + outside := newCommitOn(ctx, f.sidecar, f.snap.Commit, map[string]string{"docs/notes.md": "notes\n"}) + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: zeroOID40, New: outside, Ref: "refs/heads/captain/t-1"}}, + Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("gate:path-denied"))) + + big := newCommitOn(ctx, f.sidecar, f.snap.Commit, map[string]string{"src/big.go": strings.Repeat("x", 128) + "\n"}) + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: zeroOID40, New: big, Ref: "refs/heads/captain/t-1"}}, + Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("gate:blob-size"))) + }) + }) + + Describe("result admission on the mailbox", func() { + It("enforces parentage, agent namespace and attempt caps", func() { + f := newAdmitFixture(ctx) + mailbox := filepath.Join(GinkgoT().TempDir(), "mailbox.git") + Expect(gitagent.InitMailbox(ctx, mailbox, f.super)).To(Succeed()) + Expect(gitagent.SaveTaskState(mailbox, &gitagent.TaskState{ + Task: "t-1", Agent: "worker-1", Base: f.snap.Base, DispatchCommit: f.snap.Commit, + Policy: gitagent.Policy{MaxAttempts: 2}, + })).To(Succeed()) + + result := newCommitOn(ctx, f.super, f.snap.Commit, map[string]string{"src/fix.go": "package main // fix\n"}) + resultUpdates := []gitagent.RefUpdate{ + {Old: zeroOID40, New: result, Ref: "refs/captain/tasks/t-1/result/1"}, + {Old: zeroOID40, New: f.control, Ref: "refs/captain/tasks/t-1/control/1"}, + } + + // Admission must stay inside this task's dispatch..result range. A + // damaged historical namespace cannot reject an unrelated result. + broken := filepath.Join(mailbox, "refs", "captain", "tasks", "t-stale", "control", "1") + Expect(os.MkdirAll(filepath.Dir(broken), 0o755)).To(Succeed()) + Expect(os.WriteFile(broken, []byte(strings.Repeat("f", 40)+"\n"), 0o644)).To(Succeed()) + + Expect(gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: mailbox, Role: gitagent.RoleMailbox, Agent: "worker-1", + Updates: resultUpdates, Envelope: f.envelope(), Env: f.env, + })).To(Succeed()) + + forgedBase := f.envelope() + forgedBase.Base = result + err := gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: mailbox, Role: gitagent.RoleMailbox, Agent: "worker-1", + Updates: resultUpdates, Envelope: forgedBase, Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("does not match dispatched base"))) + + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: mailbox, Role: gitagent.RoleMailbox, Agent: "worker-2", + Updates: resultUpdates, Envelope: f.envelope(), Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("belongs to agent"))) + + orphan := newCommitOn(ctx, f.super, f.snap.Base, map[string]string{"src/fix.go": "package main // fix\n"}) + orphanUpdates := []gitagent.RefUpdate{ + {Old: zeroOID40, New: orphan, Ref: "refs/captain/tasks/t-1/result/1"}, + {Old: zeroOID40, New: f.control, Ref: "refs/captain/tasks/t-1/control/1"}, + } + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: mailbox, Role: gitagent.RoleMailbox, Agent: "worker-1", + Updates: orphanUpdates, Envelope: f.envelope(), Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("parented on its dispatch"))) + + over := f.envelope() + over.Attempt = 3 + overUpdates := []gitagent.RefUpdate{ + {Old: zeroOID40, New: result, Ref: "refs/captain/tasks/t-1/result/3"}, + {Old: zeroOID40, New: f.control, Ref: "refs/captain/tasks/t-1/control/3"}, + } + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: mailbox, Role: gitagent.RoleMailbox, Agent: "worker-1", + Updates: overUpdates, Envelope: over, Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("maxAttempts"))) + }) + }) +}) diff --git a/pkg/gitagent/conformance_ginkgo_test.go b/pkg/gitagent/conformance_ginkgo_test.go new file mode 100644 index 00000000..356871ec --- /dev/null +++ b/pkg/gitagent/conformance_ginkgo_test.go @@ -0,0 +1,335 @@ +package gitagent_test + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/gitagent" +) + +// conformanceWorld is the §12 topology built from local repos and loopback +// SSH: a supervisor host (real repo + mailbox + serve) and an agent host +// (sidecar repo + serve), with the test binary standing in for the captain +// binary in both the shims and the ssh transport. +type conformanceWorld struct { + superRepo string + mailbox string + mailboxRoute string + sidecar string + sidecarURL string + dispatch gitagent.DispatchRequest + workdir string // the agent's clone, present after dispatch +} + +// testShim writes a hook shim exec'ing the test binary with the hook env +// armed and the ssh env disarmed (each invocation re-arms its own). +func installTestShims(repo, role, runtimeCfg string) { + GinkgoHelper() + exe, err := os.Executable() + Expect(err).NotTo(HaveOccurred()) + for _, hook := range []string{"pre-receive", "post-receive"} { + shim := fmt.Sprintf("#!/bin/sh\nCAPTAIN_TEST_HOOK=1 CAPTAIN_TEST_SSH_CLIENT= exec %q %s %q %q %q\n", + exe, hook, repo, role, runtimeCfg) + path := filepath.Join(repo, "hooks", hook) + Expect(os.MkdirAll(filepath.Dir(path), 0o755)).To(Succeed()) + Expect(os.WriteFile(path, []byte(shim), 0o755)).To(Succeed()) + } +} + +func writeRuntime(dir string, rt gitagent.HookRuntime) string { + GinkgoHelper() + data, err := json.Marshal(rt) + Expect(err).NotTo(HaveOccurred()) + path := filepath.Join(dir, "runtime.json") + Expect(os.WriteFile(path, data, 0o644)).To(Succeed()) + return path +} + +// testSSHCommand is the GIT_SSH_COMMAND for pushes in the conformance world: +// the test binary with its ssh persona armed and the hook persona disarmed. +func testSSHCommand() string { + GinkgoHelper() + exe, err := os.Executable() + Expect(err).NotTo(HaveOccurred()) + return fmt.Sprintf("env CAPTAIN_TEST_SSH_CLIENT=1 CAPTAIN_TEST_HOOK= %q", exe) +} + +// newConformanceWorld wires the full topology. sidecarWF and supervisorWF are +// the two tier hook sets; agentCommand is launched detached on dispatch. +func newConformanceWorld(ctx context.Context, sidecarWF, supervisorWF *api.Workflow, agentCommand string) *conformanceWorld { + GinkgoHelper() + w := &conformanceWorld{} + + // Supervisor host: real repo with dirty state, mailbox beside it. + supRoot := GinkgoT().TempDir() + w.superRepo = filepath.Join(supRoot, "project") + Expect(os.MkdirAll(w.superRepo, 0o755)).To(Succeed()) + gitT(w.superRepo, "init", "-q") + writeFileT(w.superRepo, "pkg/main.go", "package main\n") + writeFileT(w.superRepo, "docs/readme.md", "readme\n") + gitT(w.superRepo, "add", "-A") + gitT(w.superRepo, "commit", "-q", "-m", "base") + writeFileT(w.superRepo, "pkg/dirty.go", "package main // dirty\n") + mailbox, err := gitagent.EnsureMailbox(ctx, supRoot, w.superRepo) + Expect(err).NotTo(HaveOccurred()) + w.mailbox, w.mailboxRoute = mailbox.Path, mailbox.Route + + // Keys: one per party. + supKey := filepath.Join(GinkgoT().TempDir(), "supervisor_ed25519") + _, supFP, err := gitagent.EnsureKeyPair(supKey) + Expect(err).NotTo(HaveOccurred()) + agentKey := filepath.Join(GinkgoT().TempDir(), "agent_ed25519") + _, agentFP, err := gitagent.EnsureKeyPair(agentKey) + Expect(err).NotTo(HaveOccurred()) + + // Supervisor mailbox endpoint, accepting the enrolled agent's key. + supDir := &memoryDirectory{agents: map[string]string{agentFP: "worker-1"}, pending: map[string]string{}} + supAddr, supHostFP := startTestServer(supDir, supRoot, gitagent.RoleMailbox) + supHost, supPort, err := net.SplitHostPort(supAddr) + Expect(err).NotTo(HaveOccurred()) + + // Agent host: sidecar repo + endpoint accepting the supervisor's key. + agentRoot := GinkgoT().TempDir() + w.sidecar = filepath.Join(agentRoot, "repo.git") + Expect(gitagent.InitSidecar(ctx, w.sidecar)).To(Succeed()) + sideDir := &memoryDirectory{agents: map[string]string{supFP: "supervisor"}, pending: map[string]string{}} + sideAddr, sideHostFP := startTestServer(sideDir, agentRoot, gitagent.RoleSidecar) + sideHost, sidePort, err := net.SplitHostPort(sideAddr) + Expect(err).NotTo(HaveOccurred()) + w.sidecarURL = fmt.Sprintf("ssh://captain@%s:%s/repo.git", sideHost, sidePort) + + // Hook runtimes and shims on both receivers. + sidecarRT := writeRuntime(GinkgoT().TempDir(), gitagent.HookRuntime{ + SidecarWorkflow: sidecarWF, + HookSandbox: "test-identity", + AgentCommand: agentCommand, + Relay: gitagent.RelayTarget{ + URL: fmt.Sprintf("ssh://captain@%s:%s", supHost, supPort), + HostFingerprint: supHostFP, + KeyPath: agentKey, + SSHCommand: testSSHCommand(), + }, + }) + installTestShims(w.sidecar, "sidecar", sidecarRT) + mailboxRT := writeRuntime(GinkgoT().TempDir(), gitagent.HookRuntime{ + SupervisorWorkflow: supervisorWF, + HookSandbox: "test-identity", + RealRepo: w.superRepo, + }) + installTestShims(w.mailbox, "mailbox", mailboxRT) + + w.dispatch = gitagent.DispatchRequest{ + RepoDir: w.superRepo, + MailboxPath: w.mailbox, + MailboxRoute: w.mailboxRoute, + Agent: "worker-1", + SidecarURL: w.sidecarURL, + SidecarHostFP: sideHostFP, + KeyPath: supKey, + SSHCommand: testSSHCommand(), + Policy: gitagent.Policy{MaxAttempts: 5}, + } + return w +} + +// agentPush commits the given files in the agent's clone and pushes bare, +// returning the push's combined output and error. +func (w *conformanceWorld) agentPush(files map[string]string) (string, error) { + GinkgoHelper() + for path, content := range files { + writeFileT(w.workdir, path, content) + } + gitT(w.workdir, "add", "-A") + gitT(w.workdir, "commit", "-q", "-m", "agent work", "--allow-empty") + push := exec.Command("git", "push") + push.Dir = w.workdir + out, err := push.CombinedOutput() + return string(out), err +} + +func (w *conformanceWorld) dispatchTask(ctx context.Context) *gitagent.DispatchResult { + GinkgoHelper() + result, err := gitagent.Dispatch(ctx, w.dispatch) + Expect(err).NotTo(HaveOccurred()) + w.workdir = filepath.Join(w.sidecar, "captain", "tasks", result.Task, "worktree") + return result +} + +var _ = Describe("protocol conformance (§12)", Serial, func() { + ctx := context.Background() + + It("completes a full cycle from a vanilla clone with bare git commands (H17)", func() { + w := newConformanceWorld(ctx, + &api.Workflow{Verify: &api.Verify{Commands: []string{"test -f pkg/ok.txt"}}}, + &api.Workflow{Verify: &api.Verify{Commands: []string{"grep -q good pkg/ok.txt"}}}, + "") + result := w.dispatchTask(ctx) + Expect(w.workdir).To(BeADirectory(), "post-receive must set up the agent workspace") + Expect(os.ReadFile(filepath.Join(w.workdir, "pkg", "dirty.go"))). + To(Equal([]byte("package main // dirty\n")), "the dirty worktree travelled") + Expect(filepath.Join(w.sidecar, "captain", "tasks", result.Task, "task.json")). + To(BeAnExistingFile(), "task.json lands outside the worktree") + Expect(filepath.Join(w.workdir, "task.json")).NotTo(BeAnExistingFile()) + + // HEAD moves on the supervisor mid-task: integration must use the + // envelope's base, not the moved HEAD (R10.1). + writeFileT(w.superRepo, "docs/readme.md", "readme v2\n") + gitT(w.superRepo, "commit", "-q", "-am", "moved after dispatch") + + out, err := w.agentPush(map[string]string{"pkg/ok.txt": "good\n"}) + Expect(err).NotTo(HaveOccurred(), "push output:\n%s", out) + Expect(out).To(ContainSubstring("captain: ACCEPTED")) + + verdict, err := gitagent.AwaitOutcome(ctx, w.mailbox, result.Task, 30*time.Second) + Expect(err).NotTo(HaveOccurred()) + Expect(verdict.Status).To(Equal(gitagent.StatusAccepted)) + + // Result + control landed in the mailbox; the verdict ref exists. + Expect(gitT(w.mailbox, "rev-parse", "refs/captain/tasks/"+result.Task+"/result/1")).NotTo(BeEmpty()) + Expect(gitT(w.mailbox, "rev-parse", "refs/captain/tasks/"+result.Task+"/verdict/1")).NotTo(BeEmpty()) + + // Integration: the captain/ branch carries BOTH the agent's file + // and the supervisor's post-dispatch commit. + branch := "captain/" + result.Task + Expect(blob(w.superRepo, branch, "pkg/ok.txt")).To(Equal("good\n")) + Expect(blob(w.superRepo, branch, "docs/readme.md")).To(Equal("readme v2\n")) + Expect(blob(w.superRepo, branch, "pkg/dirty.go")).To(Equal("package main // dirty\n")) + }) + + It("rejects at tier 1 with feedback, never contacting the supervisor, and recovers on retry (§6.3)", func() { + w := newConformanceWorld(ctx, + &api.Workflow{Verify: &api.Verify{Commands: []string{"test -f pkg/ok.txt"}}}, + nil, "") + result := w.dispatchTask(ctx) + + refsBefore := gitT(w.sidecar, "for-each-ref") + out, err := w.agentPush(map[string]string{"pkg/other.txt": "not the file\n"}) + Expect(err).To(HaveOccurred(), "push must be rejected:\n%s", out) + Expect(out).To(ContainSubstring("captain: REJECTED"), "tier-1 feedback reaches the pusher") + Expect(out).To(ContainSubstring("verify:test -f pkg/ok.txt")) + Expect(out).To(ContainSubstring("captain-json: ")) + + // The supervisor was never contacted: no result refs, no verdicts. + Expect(gitT(w.mailbox, "for-each-ref", "refs/captain/tasks/"+result.Task+"/result")).To(BeEmpty()) + _, found, err := gitagent.LoadVerdict(w.mailbox, result.Task, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeFalse()) + + // Zero new refs at the sidecar; the remote task branch did not advance. + Expect(gitT(w.sidecar, "for-each-ref")).To(Equal(refsBefore)) + + // The rejection persisted out-of-band at the rejecting tier (R6.9). + verdict, found, err := gitagent.LoadVerdict(w.sidecar, result.Task, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(verdict.Status).To(Equal(gitagent.StatusRejected)) + + // Rejection is not termination: fix and push again as attempt 2. + out, err = w.agentPush(map[string]string{"pkg/ok.txt": "fixed\n"}) + Expect(err).NotTo(HaveOccurred(), "retry output:\n%s", out) + Expect(out).To(ContainSubstring("captain: ACCEPTED")) + Expect(gitT(w.mailbox, "rev-parse", "refs/captain/tasks/"+result.Task+"/result/2")).NotTo(BeEmpty()) + }) + + It("enforces the sidecar hooks carried by the dispatch control payload", func() { + w := newConformanceWorld(ctx, nil, nil, "") + hooks, err := json.Marshal(gitagent.HookSets{ + Sidecar: &api.Workflow{Verify: &api.Verify{Commands: []string{"false"}}}, + }) + Expect(err).NotTo(HaveOccurred()) + w.dispatch.HooksJSON = hooks + result := w.dispatchTask(ctx) + + state, found, err := gitagent.LoadTaskState(w.sidecar, result.Task) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(state.Hooks).NotTo(BeNil()) + Expect(state.Hooks.Sidecar.Verify.Commands).To(Equal([]string{"false"})) + + out, err := w.agentPush(map[string]string{"pkg/result.txt": "must not land\n"}) + Expect(err).To(HaveOccurred(), "task-carried false hook must reject:\n%s", out) + Expect(out).To(ContainSubstring("verify:false")) + Expect(out).To(ContainSubstring("captain: REJECTED")) + Expect(gitT(w.mailbox, "for-each-ref", "refs/captain/tasks/"+result.Task+"/result")).To(BeEmpty()) + }) + + It("relays tier-2 rejection text down through the sidecar (H16)", func() { + w := newConformanceWorld(ctx, + nil, + &api.Workflow{Verify: &api.Verify{Commands: []string{"grep -q good pkg/ok.txt"}}}, + "") + result := w.dispatchTask(ctx) + + out, err := w.agentPush(map[string]string{"pkg/ok.txt": "bad\n"}) + Expect(err).To(HaveOccurred(), "push must be rejected:\n%s", out) + Expect(out).To(ContainSubstring("verify:grep -q good pkg/ok.txt"), "the supervisor's feedback travelled the sideband chain") + Expect(out).NotTo(ContainSubstring("remote: remote:"), "the relay removes the inner git transport prefix") + Expect(strings.Count(out, "captain-json: ")).To(Equal(1), "the supervisor verdict remains the only machine summary") + Expect(out).To(ContainSubstring(`"status":"rejected","tier":"supervisor"`)) + Expect(out).NotTo(ContainSubstring(`"hook":"relay"`), "a supervisor rejection is not a sidecar transport error") + + // The supervisor's rejection persisted at its tier (R6.9); quarantine + // left no result ref behind. + verdict, found, err := gitagent.LoadVerdict(w.mailbox, result.Task, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(verdict.Status).To(Equal(gitagent.StatusRejected)) + Expect(verdict.Tier).To(Equal("supervisor")) + Expect(gitT(w.mailbox, "for-each-ref", "refs/captain/tasks/"+result.Task+"/result")).To(BeEmpty()) + + // The agent's local branch stays intact and ahead. + Expect(gitT(w.workdir, "rev-list", "--count", "origin/captain/"+result.Task+"..HEAD")).To(Equal("1")) + }) + + It("rejects a moved-HEAD integration conflict before accepting the agent push (R10.2)", func() { + w := newConformanceWorld(ctx, nil, nil, "") + result := w.dispatchTask(ctx) + + writeFileT(w.superRepo, "docs/readme.md", "supervisor version\n") + gitT(w.superRepo, "commit", "-q", "-am", "move supervisor head") + + out, err := w.agentPush(map[string]string{"docs/readme.md": "agent version\n"}) + Expect(err).To(HaveOccurred(), "conflicting push must be rejected:\n%s", out) + Expect(out).To(ContainSubstring("merge conflict")) + Expect(out).To(ContainSubstring("captain: REJECTED")) + + verdict, found, err := gitagent.LoadVerdict(w.mailbox, result.Task, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(verdict.Status).To(Equal(gitagent.StatusRejected)) + Expect(gitT(w.mailbox, "for-each-ref", "refs/captain/tasks/"+result.Task+"/result")).To(BeEmpty()) + Expect(gitT(w.superRepo, "for-each-ref", "refs/heads/captain/"+result.Task)).To(BeEmpty()) + Expect(gitT(w.workdir, "rev-list", "--count", "origin/captain/"+result.Task+"..HEAD")).To(Equal("1")) + }) + + It("returns from dispatch promptly while the agent keeps running (H12)", func() { + w := newConformanceWorld(ctx, nil, nil, "echo started > agent-marker.txt && sleep 30") + start := time.Now() + result := w.dispatchTask(ctx) + Expect(time.Since(start)).To(BeNumerically("<", 15*time.Second), + "the dispatch push must not wait for the agent") + marker := filepath.Join(w.workdir, "agent-marker.txt") + Eventually(func() error { _, err := os.Stat(marker); return err }, "10s", "200ms").Should(Succeed(), + "the detached agent runs on after the push returned") + Expect(strings.TrimSpace(string(mustRead(marker)))).To(Equal("started")) + _ = result + }) +}) + +func mustRead(path string) []byte { + GinkgoHelper() + data, err := os.ReadFile(path) + Expect(err).NotTo(HaveOccurred()) + return data +} diff --git a/pkg/gitagent/control.go b/pkg/gitagent/control.go new file mode 100644 index 00000000..d69d2de1 --- /dev/null +++ b/pkg/gitagent/control.go @@ -0,0 +1,110 @@ +package gitagent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/flanksource/captain/pkg/api" +) + +// Control payload file names (§4). +const ( + ControlTaskFile = "task.json" + ControlHooksFile = "hooks.json" + ControlPolicyFile = "policy.json" +) + +// HookSets is the hooks.json wire payload. The supervisor chooses both tiers; +// receivers persist this value with task state so retries vet the same policy. +type HookSets struct { + Sidecar *api.Workflow `json:"sidecar,omitempty"` + Supervisor *api.Workflow `json:"supervisor,omitempty"` +} + +// DecodeHookSets validates a hooks.json payload before it becomes durable task +// policy. Unknown fields fail closed instead of silently disabling a tier. +func DecodeHookSets(data []byte) (*HookSets, error) { + if len(bytes.TrimSpace(data)) == 0 { + data = []byte("{}") + } + var hooks HookSets + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&hooks); err != nil { + return nil, fmt.Errorf("decode hooks.json: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + err = fmt.Errorf("multiple JSON values") + } + return nil, fmt.Errorf("decode hooks.json: %w", err) + } + for tier, workflow := range map[string]*api.Workflow{ + "sidecar": hooks.Sidecar, "supervisor": hooks.Supervisor, + } { + if err := workflow.Validate(); err != nil { + return nil, fmt.Errorf("hooks.%s: %w", tier, err) + } + } + return &hooks, nil +} + +// BuildControlCommit writes payloads as a flat tree and wraps it in a +// parentless commit. Control refs point at commits, never bare trees — a +// tree-tipped ref trips gc, bitmap and fsck paths (R3.3). env matters: built +// with a hook environment the objects land in quarantine and are discarded +// with a rejected push; nil means the process environment, scrubbed. +func BuildControlCommit(ctx context.Context, repoDir string, env []string, payloads map[string][]byte) (string, error) { + if len(payloads) == 0 { + return "", fmt.Errorf("a control commit needs at least one payload") + } + if env == nil { + env = ScrubGitEnv(os.Environ()) + } + names := make([]string, 0, len(payloads)) + for name := range payloads { + if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\x00") { + return "", fmt.Errorf("control payload name %q must be a bare file name", name) + } + names = append(names, name) + } + sort.Strings(names) + var tree strings.Builder + for _, name := range names { + oid, err := runGitIn(ctx, repoDir, env, strings.NewReader(string(payloads[name])), + "hash-object", "-w", "--no-filters", "--stdin") + if err != nil { + return "", err + } + fmt.Fprintf(&tree, "100644 blob %s\t%s\n", oid, name) + } + treeOID, err := runGitIn(ctx, repoDir, env, strings.NewReader(tree.String()), "mktree") + if err != nil { + return "", err + } + cenv := envWith(env, + "GIT_AUTHOR_NAME=captain", + "GIT_AUTHOR_EMAIL=captain@localhost", + "GIT_COMMITTER_NAME=captain", + "GIT_COMMITTER_EMAIL=captain@localhost", + ) + return runGitIn(ctx, repoDir, cenv, + strings.NewReader("captain control envelope payloads\n"), + "commit-tree", treeOID) +} + +// ReadControlPayload reads one payload file from a control commit's tree, +// readable in pre-receive through the quarantine object directories. +func ReadControlPayload(ctx context.Context, repoDir string, env []string, controlCommit, name string) ([]byte, error) { + out, err := runGitRaw(ctx, repoDir, env, nil, "cat-file", "blob", controlCommit+":"+name) + if err != nil { + return nil, err + } + return []byte(out), nil +} diff --git a/pkg/gitagent/dispatch.go b/pkg/gitagent/dispatch.go new file mode 100644 index 00000000..91c8cf9b --- /dev/null +++ b/pkg/gitagent/dispatch.go @@ -0,0 +1,320 @@ +// Dispatch (§6.1): snapshot the supervisor's dirty worktree, record the task +// in the local mailbox, and push dispatch+control atomically to the agent's +// sidecar with the envelope riding on push options. +package gitagent + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/flanksource/captain/pkg/api" +) + +// TaskPayload is task.json: what the agent is asked to do. It is materialized +// outside the agent's worktree so it is not itself submittable (§4). +type TaskPayload struct { + Prompt string `json:"prompt"` + System string `json:"system,omitempty"` + Model string `json:"model,omitempty"` + // Backend and Effort record the runtime the supervisor resolved, so the + // agent does not re-resolve the model against its own defaults. + Backend string `json:"backend,omitempty"` + Effort api.Effort `json:"effort,omitempty"` + // Timeout is the supervisor's effective deadline. The relocated runner + // must not fall back to the shorter local model-call default. + Timeout string `json:"timeout,omitempty"` +} + +// LoadTaskPayload reads a materialized task.json. +func LoadTaskPayload(path string) (TaskPayload, error) { + var payload TaskPayload + data, err := os.ReadFile(path) + if err != nil { + return payload, err + } + if err := json.Unmarshal(data, &payload); err != nil { + return payload, fmt.Errorf("task file %s: %w", path, err) + } + if strings.TrimSpace(payload.Prompt) == "" { + return payload, fmt.Errorf("task file %s carries no prompt", path) + } + return payload, nil +} + +// TaskPaths locates a task's materialized inputs on a sidecar. +func TaskPaths(sidecarRepo, task string) (worktree, taskFile string, err error) { + if err := ValidateTaskID(task); err != nil { + return "", "", err + } + dir := taskStateDir(sidecarRepo, task) + return filepath.Join(dir, "worktree"), filepath.Join(dir, ControlTaskFile), nil +} + +// DispatchRequest carries one task hand-off. +type DispatchRequest struct { + RepoDir string + MailboxPath string + MailboxRoute string // opaque path under the supervisor's served root + Task string // generated when empty + Agent string + SidecarURL string // ssh://host:port/repo.git + SidecarHostFP string + KeyPath string + SSHCommand string // GIT_SSH_COMMAND; "" ⇒ this binary's git-agent ssh transport + Relay RelayMode + Policy Policy + TaskPayload TaskPayload + HooksJSON []byte // pre-serialized hooks.json (may be nil) +} + +// DispatchResult reports the pushed hand-off. +type DispatchResult struct { + Task string + Attempt int + Snapshot *Snapshot + Control string +} + +// Dispatch performs §6.1 steps 1–3. The sidecar's admission and agent launch +// happen inside the push; the push returning is the proof of hand-off (H12). +func Dispatch(ctx context.Context, req DispatchRequest) (*DispatchResult, error) { + task := req.Task + if task == "" { + var err error + if task, err = NewTaskID(); err != nil { + return nil, err + } + } + if err := ValidateTaskID(task); err != nil { + return nil, err + } + if err := ValidateMailboxRoute(req.MailboxRoute); err != nil { + return nil, err + } + if req.Relay == "" { + req.Relay = RelaySync + } + hooks, err := DecodeHookSets(req.HooksJSON) + if err != nil { + return nil, err + } + snapshot, err := TakeSnapshot(ctx, req.RepoDir, SnapshotPolicy{ + Paths: req.Policy.Paths, MaxFileSize: req.Policy.MaxBlobSize, + }) + if err != nil { + return nil, err + } + control, err := buildDispatchControl(ctx, req, snapshot) + if err != nil { + return nil, err + } + if err := recordDispatch(ctx, req, task, snapshot, control, hooks); err != nil { + return nil, err + } + if err := pushDispatch(ctx, req, task, snapshot, control); err != nil { + return nil, err + } + return &DispatchResult{Task: task, Attempt: 1, Snapshot: snapshot, Control: control}, nil +} + +// NewTaskID mints a task id inside the §3.2 shape. +func NewTaskID() (string, error) { + raw := make([]byte, 6) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return "t-" + hex.EncodeToString(raw), nil +} + +func buildDispatchControl(ctx context.Context, req DispatchRequest, snapshot *Snapshot) (string, error) { + taskJSON, err := json.Marshal(req.TaskPayload) + if err != nil { + return "", err + } + policyJSON, err := json.Marshal(req.Policy) + if err != nil { + return "", err + } + hooksJSON := req.HooksJSON + if len(hooksJSON) == 0 { + hooksJSON = []byte("{}") + } + return BuildControlCommit(ctx, req.RepoDir, nil, map[string][]byte{ + ControlTaskFile: taskJSON, + ControlHooksFile: hooksJSON, + ControlPolicyFile: policyJSON, + }) +} + +// recordDispatch writes the audit refs and task state into the local mailbox +// (R2.1): local update-refs, since the mailbox shares the real repository's +// objects through alternates. +func recordDispatch(ctx context.Context, req DispatchRequest, task string, snapshot *Snapshot, control string, hooks *HookSets) error { + if err := InitMailbox(ctx, req.MailboxPath, req.RepoDir); err != nil { + return err + } + if err := copyDispatchObjects(ctx, req.RepoDir, req.MailboxPath, snapshot, control); err != nil { + return err + } + env := ScrubGitEnv(os.Environ()) + dispatchRef, err := DispatchRef(task, 1) + if err != nil { + return err + } + controlRef, err := ControlRef(task, 1) + if err != nil { + return err + } + updates := fmt.Sprintf("start\ncreate %s %s\ncreate %s %s\nprepare\ncommit\n", + dispatchRef, snapshot.Commit, controlRef, control) + if _, err := runGitIn(ctx, req.MailboxPath, env, strings.NewReader(updates), "update-ref", "--stdin"); err != nil { + return err + } + return SaveTaskState(req.MailboxPath, &TaskState{ + Task: task, + Agent: req.Agent, + Base: snapshot.Base, + DispatchCommit: snapshot.Commit, + ControlCommit: control, + Relay: req.Relay, + Mailbox: req.MailboxRoute, + Policy: req.Policy, + Hooks: hooks, + }) +} + +// copyDispatchObjects gives the mailbox ownership of synthetic objects that +// have no ref in the real repository. The base history remains borrowed via +// alternates, while source-repository GC can no longer prune dispatch/control. +func copyDispatchObjects(ctx context.Context, source, mailbox string, snapshot *Snapshot, control string) error { + packDir := filepath.Join(mailbox, "objects", "pack") + if err := os.MkdirAll(packDir, 0o755); err != nil { + return err + } + revisions := strings.NewReader(snapshot.Commit + "\n" + control + "\n^" + snapshot.Base + "\n") + if _, err := runGitIn(ctx, source, ScrubGitEnv(os.Environ()), revisions, + "pack-objects", "--revs", filepath.Join(packDir, "pack")); err != nil { + return fmt.Errorf("storing dispatch objects in mailbox: %w", err) + } + return nil +} + +func pushDispatch(ctx context.Context, req DispatchRequest, task string, snapshot *Snapshot, control string) error { + dispatchRef, err := DispatchRef(task, 1) + if err != nil { + return err + } + controlRef, err := ControlRef(task, 1) + if err != nil { + return err + } + envelope := Envelope{ + Version: ProtocolVersion, + Task: task, + Attempt: 1, + Base: snapshot.Base, + Depth: 0, + Agent: req.Agent, + Relay: req.Relay, + Mailbox: req.MailboxRoute, + } + opts, err := envelope.Encode() + if err != nil { + return err + } + args := []string{"push", "--atomic"} + for _, o := range opts { + args = append(args, "--push-option="+o) + } + args = append(args, req.SidecarURL, + snapshot.Commit+":"+dispatchRef, + control+":"+controlRef, + ) + pairs, err := transportPairs(req.SSHCommand, req.KeyPath, req.SidecarHostFP) + if err != nil { + return err + } + env := envWith(ScrubGitEnv(os.Environ()), pairs...) + if _, err := runGit(ctx, req.RepoDir, env, args...); err != nil { + return fmt.Errorf("dispatch push: %w", err) + } + return nil +} + +// transportPairs builds the env for a push riding captain's GIT_SSH_COMMAND +// transport: no system ssh, key from a captain-managed path, host key pinned +// by fingerprint. An empty sshCommand means this binary's own transport. +func transportPairs(sshCommand, keyPath, hostFingerprint string) ([]string, error) { + if sshCommand == "" { + exe, err := os.Executable() + if err != nil { + return nil, err + } + sshCommand = SSHTransportCommand(exe) + } + return []string{ + "GIT_SSH_COMMAND=" + sshCommand, + "GIT_SSH_VARIANT=ssh", // an unrecognized command defaults to "simple", which cannot pass -p + EnvSSHKey + "=" + keyPath, + EnvSSHHostFingerprint + "=" + hostFingerprint, + }, nil +} + +// SSHTransportCommand renders this binary's SSH transport as a shell-safe +// GIT_SSH_COMMAND value. Git evaluates the value through a shell. +func SSHTransportCommand(executable string) string { + return "'" + strings.ReplaceAll(executable, "'", "'\"'\"'") + "' sandbox git-agent ssh" +} + +// AwaitOutcome polls the mailbox for the task's final verdict: the first +// accepted one, or a rejection on the task's last permitted attempt, or a +// timeout. Rejected non-final attempts keep waiting — rejection is not +// termination (§6.3). +func AwaitOutcome(ctx context.Context, mailbox, task string, timeout time.Duration) (*TierVerdict, error) { + if timeout <= 0 { + timeout = time.Hour + } + deadline := time.NewTimer(timeout) + defer deadline.Stop() + tick := time.NewTicker(500 * time.Millisecond) + defer tick.Stop() + for { + st, ok, err := LoadTaskState(mailbox, task) + if err != nil { + return nil, err + } + maxAttempts := 0 + if ok { + maxAttempts = st.Policy.MaxAttempts + } + for attempt := 1; attempt <= MaxAttempt; attempt++ { + v, found, err := LoadVerdict(mailbox, task, attempt) + if err != nil { + return nil, err + } + if !found { + break + } + if v.Status == StatusAccepted { + return v, nil + } + if maxAttempts > 0 && attempt >= maxAttempts { + return v, nil + } + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-deadline.C: + return nil, fmt.Errorf("task %s: no final verdict within %s", task, timeout) + case <-tick.C: + } + } +} diff --git a/pkg/gitagent/enroll.go b/pkg/gitagent/enroll.go new file mode 100644 index 00000000..2362da4e --- /dev/null +++ b/pkg/gitagent/enroll.go @@ -0,0 +1,198 @@ +// Enrollment (§8): a single-use, short-TTL join token authorizes exactly one +// key registration and is then burned. The private key never leaves the agent +// host (R8.2). +// +// The exchange is bidirectional because trust is: the supervisor dispatches TO +// the sidecar and the sidecar relays TO the mailbox, so each side must learn +// the other's endpoint and authorize the other's key. A one-way enrollment +// leaves a topology that looks configured but cannot complete a cycle. +package gitagent + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net" + "net/url" + "strings" + "time" + + gossh "golang.org/x/crypto/ssh" +) + +// JoinTokenTTL bounds how long a minted token stays redeemable. +const JoinTokenTTL = 15 * time.Minute + +// EnrollRequest is what a joining agent tells the supervisor about itself. +type EnrollRequest struct { + // AdvertiseURL is the sidecar endpoint the supervisor should dispatch to. + // When empty the supervisor derives it from the connection's source + // address and ListenPort, which is right on a flat network and wrong + // behind NAT — hence the explicit override. + AdvertiseURL string `json:"advertiseUrl,omitempty"` + // ListenPort is the port the agent's own receive endpoint listens on. + ListenPort string `json:"listenPort,omitempty"` + // HostFingerprint is the agent endpoint's host key, for the supervisor to + // pin when it dispatches. + HostFingerprint string `json:"hostFingerprint"` +} + +// EnrollResponse is what the supervisor hands back so the agent can complete +// the reverse direction without manual configuration. +type EnrollResponse struct { + Agent string `json:"agent"` + // DispatchKey is the supervisor's client-key fingerprint. The agent + // authorizes it locally so the supervisor's dispatch push is accepted. + DispatchKey string `json:"dispatchKey"` +} + +// EnrollmentOffer is the supervisor-side half of the exchange, supplied to +// the server by whatever runs it. +type EnrollmentOffer struct { + DispatchKey string +} + +// AgentEnrollment is one recorded agent: its key, its endpoint, and the host +// key to pin when dispatching there. +type AgentEnrollment struct { + Name string + Fingerprint string + URL string + HostFingerprint string +} + +// MintJoinToken returns a fresh token and its storage hash. Only the hash is +// persisted, so a leaked config file does not leak redeemable tokens. +func MintJoinToken() (token, hash string, err error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", "", err + } + token = base64.RawURLEncoding.EncodeToString(raw) + return token, HashJoinToken(token), nil +} + +// HashJoinToken maps a presented token onto its storage hash. +func HashJoinToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +// Enroll dials the supervisor endpoint, presents the join token along with +// this agent's endpoint details, and returns what the supervisor offered +// back. The host key is verified against the fingerprint printed by +// `git-agent add` — never trusted on first use. +func Enroll(ctx context.Context, endpoint, token, hostFingerprint string, signer gossh.Signer, req EnrollRequest) (*EnrollResponse, error) { + hostFingerprint = strings.TrimSpace(hostFingerprint) + if hostFingerprint == "" { + return nil, fmt.Errorf("enrollment requires the supervisor's host-key fingerprint (printed by `captain sandbox git-agent add`)") + } + addr, user, err := splitSSHEndpoint(endpoint) + if err != nil { + return nil, err + } + config := &gossh.ClientConfig{ + User: user, + Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)}, + HostKeyCallback: func(_ string, _ net.Addr, key gossh.PublicKey) error { + if got := gossh.FingerprintSHA256(key); got != hostFingerprint { + return fmt.Errorf("supervisor host key %s does not match the pinned %s", got, hostFingerprint) + } + return nil + }, + Timeout: 30 * time.Second, + } + dialer := net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, err + } + c, chans, reqs, err := gossh.NewClientConn(conn, addr, config) + if err != nil { + conn.Close() + return nil, err + } + client := gossh.NewClient(c, chans, reqs) + defer client.Close() + session, err := client.NewSession() + if err != nil { + return nil, err + } + defer session.Close() + payload, err := json.Marshal(req) + if err != nil { + return nil, err + } + command := EnrollCommand + " " + token + " " + base64.RawURLEncoding.EncodeToString(payload) + // stderr is captured separately: the server explains a refusal there, and + // folding it into stdout would corrupt the JSON response. + var refusal strings.Builder + session.Stderr = &refusal + out, err := session.Output(command) + if err != nil { + return nil, fmt.Errorf("enrollment refused: %s", enrollFailureDetail(err, []byte(refusal.String()))) + } + var resp EnrollResponse + if err := json.Unmarshal(out, &resp); err != nil { + return nil, fmt.Errorf("unparseable enrollment response %q: %w", strings.TrimSpace(string(out)), err) + } + if resp.Agent == "" || resp.DispatchKey == "" { + return nil, fmt.Errorf("enrollment response is missing the agent name or the supervisor's dispatch key") + } + return &resp, nil +} + +// enrollFailureDetail prefers the server's own explanation over the bare exit +// status, so a refusal reads as its reason. +func enrollFailureDetail(err error, out []byte) string { + if detail := strings.TrimSpace(string(out)); detail != "" { + return detail + } + var exitErr *gossh.ExitError + if errors.As(err, &exitErr) && strings.TrimSpace(exitErr.Msg()) != "" { + return strings.TrimSpace(exitErr.Msg()) + } + return err.Error() +} + +// MailboxURL joins the enrolled supervisor endpoint with the opaque route +// carried by a dispatch. +func MailboxURL(endpoint, mailboxPath string) (string, error) { + if err := ValidateMailboxRoute(mailboxPath); err != nil { + return "", err + } + addr, user, err := splitSSHEndpoint(endpoint) + if err != nil { + return "", err + } + return "ssh://" + user + "@" + addr + "/" + strings.TrimPrefix(mailboxPath, "/"), nil +} + +// splitSSHEndpoint accepts ssh://[user@]host[:port] or host[:port]. +func splitSSHEndpoint(endpoint string) (addr, user string, err error) { + user = "captain" + target := endpoint + if strings.Contains(endpoint, "://") { + u, err := url.Parse(endpoint) + if err != nil || u.Scheme != "ssh" || u.Host == "" { + return "", "", fmt.Errorf("endpoint %q must be ssh://[user@]host[:port]", endpoint) + } + if u.User != nil && u.User.Username() != "" { + user = u.User.Username() + } + target = u.Host + } + if at := strings.LastIndex(target, "@"); at >= 0 { + user, target = target[:at], target[at+1:] + } + if _, _, splitErr := net.SplitHostPort(target); splitErr != nil { + host := strings.TrimSuffix(strings.TrimPrefix(target, "["), "]") + target = net.JoinHostPort(host, "22") + } + return target, user, nil +} diff --git a/pkg/gitagent/envelope.go b/pkg/gitagent/envelope.go new file mode 100644 index 00000000..6a93f0ef --- /dev/null +++ b/pkg/gitagent/envelope.go @@ -0,0 +1,231 @@ +// The control envelope rides on push options (§4): option 0 is the version +// tag, the remainder are key=value pairs. Envelope values are never read from +// commit trailers — trailers are heuristically extracted and forgeable (R4.2). + +package gitagent + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +const ( + // EnvelopeVersionTag is push option 0 for protocol v1. + EnvelopeVersionTag = "captain-envelope-v1" + + // ProtocolVersion is the version this implementation speaks. + ProtocolVersion = 1 + + // MaxHookDepth bounds hook-recursion depth (R5.4/H15). Depth 0 is the top + // level; a prompt hook increments it per nesting level. + MaxHookDepth = 4 + + // maxPushOptions bounds how many options a decoder will look at. + maxPushOptions = 16 +) + +// RelayMode selects how the sidecar reports the supervisor's verdict (§6.4). +type RelayMode string + +const ( + // RelaySync blocks the agent's push until the supervisor has decided. It + // is the default and MUST be supported (R6.8). + RelaySync RelayMode = "sync" + // RelayAsync accepts after hook set #1 and reports out-of-band. It MUST + // NOT be the default (R6.8). + RelayAsync RelayMode = "async" +) + +// oidRe accepts SHA-1 or SHA-256 object names. +var oidRe = regexp.MustCompile(`^[0-9a-f]{40}([0-9a-f]{24})?$`) + +// ValidateOID checks that s is a full hex object id. +func ValidateOID(s string) error { + if !oidRe.MatchString(s) { + return fmt.Errorf("%q is not a full object id", s) + } + return nil +} + +func isZeroOID(oid string) bool { + return (len(oid) == 40 || len(oid) == 64) && strings.Trim(oid, "0") == "" +} + +// Envelope is the §4 control envelope. +type Envelope struct { + Version int `json:"v"` + Task string `json:"task"` + Attempt int `json:"attempt"` + Base string `json:"base"` // supervisor HEAD OID at dispatch (R10.1) + Depth int `json:"depth"` // hook-recursion depth, 0 at top level + Agent string `json:"agent,omitempty"` // dispatch only: target agent + Relay RelayMode `json:"relay,omitempty"` // dispatch only + Mailbox string `json:"mailbox,omitempty"` // dispatch only: opaque supervisor route +} + +// Validate checks every field an envelope always carries. Agent, Relay, and +// Mailbox are dispatch-only and validated when present. +func (e Envelope) Validate() error { + if e.Version != ProtocolVersion { + return fmt.Errorf("unsupported envelope version %d (implementation speaks %d)", e.Version, ProtocolVersion) + } + if err := ValidateTaskID(e.Task); err != nil { + return err + } + if e.Attempt < 1 || e.Attempt > MaxAttempt { + return fmt.Errorf("attempt %d out of range [1,%d]", e.Attempt, MaxAttempt) + } + if err := ValidateOID(e.Base); err != nil { + return fmt.Errorf("base: %w", err) + } + if e.Depth < 0 || e.Depth > MaxHookDepth { + return fmt.Errorf("depth %d out of range [0,%d]", e.Depth, MaxHookDepth) + } + if e.Agent != "" { + if err := ValidateTaskID(e.Agent); err != nil { + return fmt.Errorf("agent: %w", err) + } + } + switch e.Relay { + case "", RelaySync, RelayAsync: + default: + return fmt.Errorf("relay %q must be %q or %q", e.Relay, RelaySync, RelayAsync) + } + if e.Mailbox != "" { + if err := ValidateMailboxRoute(e.Mailbox); err != nil { + return err + } + } + return nil +} + +// Encode renders the envelope as push options: the version tag followed by +// key=value pairs. It validates first so a malformed envelope never leaves +// the process. +func (e Envelope) Encode() ([]string, error) { + if e.Version == 0 { + e.Version = ProtocolVersion + } + if err := e.Validate(); err != nil { + return nil, err + } + opts := []string{ + EnvelopeVersionTag, + "task=" + e.Task, + "attempt=" + strconv.Itoa(e.Attempt), + "base=" + e.Base, + "depth=" + strconv.Itoa(e.Depth), + } + if e.Agent != "" { + opts = append(opts, "agent="+e.Agent) + } + if e.Relay != "" { + opts = append(opts, "relay="+string(e.Relay)) + } + if e.Mailbox != "" { + opts = append(opts, "mailbox="+e.Mailbox) + } + return opts, nil +} + +// DecodeEnvelope parses push options into an Envelope. Absent envelope, an +// unknown version tag, and unknown keys are all errors: a receiver rejects +// what it does not understand (R4.1). +func DecodeEnvelope(opts []string) (Envelope, error) { + if len(opts) == 0 { + return Envelope{}, fmt.Errorf("push carries no envelope (no push options)") + } + if len(opts) > maxPushOptions { + return Envelope{}, fmt.Errorf("push carries %d options, more than the %d the protocol allows", len(opts), maxPushOptions) + } + if opts[0] != EnvelopeVersionTag { + return Envelope{}, fmt.Errorf("push option 0 is %q, not the version tag %q", opts[0], EnvelopeVersionTag) + } + e := Envelope{Version: ProtocolVersion} + seen := map[string]bool{} + for _, opt := range opts[1:] { + key, value, found := strings.Cut(opt, "=") + if !found || value == "" { + return Envelope{}, fmt.Errorf("push option %q is not key=value", opt) + } + if seen[key] { + return Envelope{}, fmt.Errorf("push option key %q repeated", key) + } + seen[key] = true + if err := e.setField(key, value); err != nil { + return Envelope{}, err + } + } + for _, required := range []string{"task", "attempt", "base", "depth"} { + if !seen[required] { + return Envelope{}, fmt.Errorf("envelope is missing required key %q", required) + } + } + if err := e.Validate(); err != nil { + return Envelope{}, err + } + return e, nil +} + +func (e *Envelope) setField(key, value string) error { + switch key { + case "task": + e.Task = value + case "attempt": + n, err := ParseAttempt(value) + if err != nil { + return err + } + e.Attempt = n + case "base": + e.Base = value + case "depth": + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("depth %q is not an integer", value) + } + e.Depth = n + case "agent": + e.Agent = value + case "relay": + e.Relay = RelayMode(value) + case "mailbox": + e.Mailbox = value + default: + return fmt.Errorf("unknown envelope key %q", key) + } + return nil +} + +// EnvelopeFromEnv decodes the envelope receive-pack exposes to hooks via +// GIT_PUSH_OPTION_COUNT / GIT_PUSH_OPTION_ (§1.2). getenv is injectable +// for tests; pass os.Getenv in hooks. +func EnvelopeFromEnv(getenv func(string) string) (Envelope, error) { + countStr := getenv("GIT_PUSH_OPTION_COUNT") + if countStr == "" { + return Envelope{}, fmt.Errorf("push carries no envelope (GIT_PUSH_OPTION_COUNT unset; is receive.advertisePushOptions on?)") + } + count, err := strconv.Atoi(countStr) + if err != nil || count < 0 || count > maxPushOptions { + return Envelope{}, fmt.Errorf("GIT_PUSH_OPTION_COUNT %q out of range [0,%d]", countStr, maxPushOptions) + } + opts := make([]string, 0, count) + for i := 0; i < count; i++ { + opts = append(opts, getenv("GIT_PUSH_OPTION_"+strconv.Itoa(i))) + } + return DecodeEnvelope(opts) +} + +// MatchesRef enforces envelope↔ref agreement (R4.1): a receiver rejects a +// push whose task or attempt disagree with the ref being written. +func (e Envelope) MatchesRef(info RefInfo) error { + if e.Task != info.Task { + return fmt.Errorf("envelope task %q disagrees with ref task %q", e.Task, info.Task) + } + if e.Attempt != info.Attempt { + return fmt.Errorf("envelope attempt %d disagrees with ref attempt %d", e.Attempt, info.Attempt) + } + return nil +} diff --git a/pkg/gitagent/envelope_ginkgo_test.go b/pkg/gitagent/envelope_ginkgo_test.go new file mode 100644 index 00000000..3acbf4aa --- /dev/null +++ b/pkg/gitagent/envelope_ginkgo_test.go @@ -0,0 +1,139 @@ +package gitagent_test + +import ( + "strconv" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent" +) + +const testOID = "0123456789abcdef0123456789abcdef01234567" + +func validEnvelope() gitagent.Envelope { + return gitagent.Envelope{ + Version: gitagent.ProtocolVersion, + Task: "my-task", + Attempt: 2, + Base: testOID, + Depth: 0, + } +} + +var _ = Describe("envelope encode/decode", func() { + It("round-trips a dispatch envelope", func() { + e := validEnvelope() + e.Agent = "worker-01" + e.Relay = gitagent.RelaySync + e.Mailbox = "mailboxes/" + strings.Repeat("a", 64) + ".git" + opts, err := e.Encode() + Expect(err).NotTo(HaveOccurred()) + Expect(opts[0]).To(Equal(gitagent.EnvelopeVersionTag)) + decoded, err := gitagent.DecodeEnvelope(opts) + Expect(err).NotTo(HaveOccurred()) + Expect(decoded).To(Equal(e)) + }) + + It("round-trips a minimal envelope", func() { + e := validEnvelope() + opts, err := e.Encode() + Expect(err).NotTo(HaveOccurred()) + decoded, err := gitagent.DecodeEnvelope(opts) + Expect(err).NotTo(HaveOccurred()) + Expect(decoded).To(Equal(e)) + }) + + It("rejects a missing envelope", func() { + _, err := gitagent.DecodeEnvelope(nil) + Expect(err).To(HaveOccurred()) + }) + + It("rejects an unknown version tag (R4.1)", func() { + opts, _ := validEnvelope().Encode() + opts[0] = "captain-envelope-v2" + _, err := gitagent.DecodeEnvelope(opts) + Expect(err).To(MatchError(ContainSubstring("version tag"))) + }) + + It("rejects unknown keys, repeats, and non key=value options", func() { + base, _ := validEnvelope().Encode() + for _, extra := range []string{"evil=1", "task=my-task", "notkeyvalue", "task="} { + opts := append(append([]string{}, base...), extra) + _, err := gitagent.DecodeEnvelope(opts) + Expect(err).To(HaveOccurred(), "option %q", extra) + } + }) + + It("rejects a missing required key", func() { + for _, drop := range []string{"task=", "attempt=", "base=", "depth="} { + opts, _ := validEnvelope().Encode() + kept := opts[:0:0] + for _, o := range opts { + if !strings.HasPrefix(o, drop) { + kept = append(kept, o) + } + } + _, err := gitagent.DecodeEnvelope(kept) + Expect(err).To(HaveOccurred(), "dropped %q", drop) + } + }) + + It("rejects invalid field values", func() { + bad := []gitagent.Envelope{} + e := validEnvelope() + e.Task = "Bad Task" + bad = append(bad, e) + e = validEnvelope() + e.Attempt = 0 + bad = append(bad, e) + e = validEnvelope() + e.Base = "not-an-oid" + bad = append(bad, e) + e = validEnvelope() + e.Depth = gitagent.MaxHookDepth + 1 + bad = append(bad, e) + e = validEnvelope() + e.Relay = "eventually" + bad = append(bad, e) + e = validEnvelope() + e.Mailbox = "../other.git" + bad = append(bad, e) + for i, envelope := range bad { + _, err := envelope.Encode() + Expect(err).To(HaveOccurred(), "case %d", i) + } + }) +}) + +var _ = Describe("envelope from hook environment", func() { + It("reads GIT_PUSH_OPTION_COUNT and the numbered options", func() { + opts, err := validEnvelope().Encode() + Expect(err).NotTo(HaveOccurred()) + env := map[string]string{"GIT_PUSH_OPTION_COUNT": strconv.Itoa(len(opts))} + for i, o := range opts { + env["GIT_PUSH_OPTION_"+strconv.Itoa(i)] = o + } + decoded, err := gitagent.EnvelopeFromEnv(func(k string) string { return env[k] }) + Expect(err).NotTo(HaveOccurred()) + Expect(decoded).To(Equal(validEnvelope())) + }) + + It("names advertisePushOptions when the count is unset", func() { + _, err := gitagent.EnvelopeFromEnv(func(string) string { return "" }) + Expect(err).To(MatchError(ContainSubstring("advertisePushOptions"))) + }) +}) + +var _ = Describe("envelope↔ref agreement (R4.1)", func() { + It("accepts matching task and attempt", func() { + info := gitagent.RefInfo{Task: "my-task", Kind: gitagent.RefResult, Attempt: 2} + Expect(validEnvelope().MatchesRef(info)).To(Succeed()) + }) + + It("rejects disagreement on either field", func() { + Expect(validEnvelope().MatchesRef(gitagent.RefInfo{Task: "other", Kind: gitagent.RefResult, Attempt: 2})).NotTo(Succeed()) + Expect(validEnvelope().MatchesRef(gitagent.RefInfo{Task: "my-task", Kind: gitagent.RefResult, Attempt: 3})).NotTo(Succeed()) + }) +}) diff --git a/pkg/gitagent/feedback.go b/pkg/gitagent/feedback.go new file mode 100644 index 00000000..586569e1 --- /dev/null +++ b/pkg/gitagent/feedback.go @@ -0,0 +1,152 @@ +// The feedback wire format (§7): everything the agent sees arrives through +// the sideband, prefixed `remote:` by its client. CR would be eaten as a +// progress-line terminator (R7.1); the JSON summary is one greppable line +// (R7.2); the block is capped with an explicit truncation marker (R7.3); and +// long hooks emit keepalive traffic so intermediaries don't drop the +// connection (R7.4). +package gitagent + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "time" +) + +// MaxFeedbackBytes caps the feedback block (R7.3). +const MaxFeedbackBytes = 64 << 10 + +// maxFindingFeedback bounds one finding's feedback in both the human block +// and the JSON line, so the line stays a line and the block stays cappable. +const maxFindingFeedback = 8 << 10 + +// FormatFeedback renders the §7 block. fullLogPath, when non-empty, is named +// in the truncation marker so a capped block still points at the whole story. +func FormatFeedback(v TierVerdict, fullLogPath string) (string, error) { + v = boundFindings(v) + jsonLine, err := feedbackJSONLine(v) + if err != nil { + return "", err + } + var b strings.Builder + verb := strings.ToUpper(string(v.Status)) + fmt.Fprintf(&b, "captain: %s task %s attempt %d (%s)\n\n", verb, v.Task, v.Attempt, v.Tier) + for _, f := range v.Findings { + mark := "✗" + fmt.Fprintf(&b, "%s %s", mark, f.Hook) + if f.Path != "" { + fmt.Fprintf(&b, " %s", f.Path) + } + if f.Message != "" { + fmt.Fprintf(&b, " %s", f.Message) + } + b.WriteByte('\n') + for _, line := range strings.Split(strings.TrimSpace(f.Feedback), "\n") { + if line != "" { + fmt.Fprintf(&b, " %s\n", line) + } + } + } + human := sanitizeCR(b.String()) + budget := MaxFeedbackBytes - len(jsonLine) - 256 // headroom for the marker + if budget < 0 { + budget = 0 + } + if len(human) > budget { + marker := "\n[feedback truncated" + if fullLogPath != "" { + marker += "; full log: " + fullLogPath + } + marker += "]\n" + human = human[:budget] + marker + } + return human + "\n" + jsonLine + "\n", nil +} + +// boundFindings truncates each finding's feedback to maxFindingFeedback, +// copying so the caller's verdict (and the persisted verdict.json) keeps the +// full text. +func boundFindings(v TierVerdict) TierVerdict { + if len(v.Findings) == 0 { + return v + } + findings := make([]Finding, len(v.Findings)) + copy(findings, v.Findings) + for i := range findings { + if len(findings[i].Feedback) > maxFindingFeedback { + findings[i].Feedback = findings[i].Feedback[:maxFindingFeedback] + "\n[finding feedback truncated]" + } + } + v.Findings = findings + return v +} + +// feedbackJSONLine renders the single-line machine summary (R7.2). When the +// full form would eat the 64 KiB budget, the summary degrades — feedback +// bodies drop out and the findings list is capped — because the human block +// and the retained log carry the detail; the line must stay recoverable. +func feedbackJSONLine(v TierVerdict) (string, error) { + data, err := json.Marshal(v) + if err != nil { + return "", err + } + if len(data) > MaxFeedbackBytes/2 { + slim := v + findings := slim.Findings + if len(findings) > 32 { + findings = findings[:32] + } + slimFindings := make([]Finding, len(findings)) + copy(slimFindings, findings) + for i := range slimFindings { + slimFindings[i].Feedback = "" + } + slim.Findings = slimFindings + if data, err = json.Marshal(slim); err != nil { + return "", err + } + } + return "captain-json: " + sanitizeCR(string(data)), nil +} + +// sanitizeCR strips carriage returns: the sideband demuxer consumes CR as a +// progress-line terminator and the text after it is lost (R7.1). +func sanitizeCR(s string) string { + return strings.ReplaceAll(s, "\r", "") +} + +// WriteFeedback renders v onto w (pre-receive stderr → the pusher's sideband). +func WriteFeedback(w io.Writer, v TierVerdict, fullLogPath string) error { + block, err := FormatFeedback(v, fullLogPath) + if err != nil { + return err + } + _, err = io.WriteString(w, block) + return err +} + +// StartProgress emits a keepalive line to w every interval until the returned +// stop function is called (R7.4). Without sideband traffic during a long +// hook, intermediaries drop the connection and the agent sees a transport +// error instead of a verdict. +func StartProgress(w io.Writer, label string, interval time.Duration) (stop func()) { + if interval <= 0 { + interval = 30 * time.Second + } + done := make(chan struct{}) + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + start := time.Now() + for { + select { + case <-done: + return + case <-ticker.C: + fmt.Fprintf(w, "captain: %s still running (%ds)\n", label, int(time.Since(start).Seconds())) + } + } + }() + return func() { close(done) } +} diff --git a/pkg/gitagent/git.go b/pkg/gitagent/git.go new file mode 100644 index 00000000..f43dc77d --- /dev/null +++ b/pkg/gitagent/git.go @@ -0,0 +1,90 @@ +package gitagent + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os/exec" + "strings" +) + +// normalizationArgs pin every git invocation this package makes to byte +// fidelity (R6.2): no CRLF conversion and no user/global attribute file, so +// the bytes committed are the bytes on disk regardless of host config. +var normalizationArgs = []string{ + "-c", "core.autocrlf=false", + "-c", "core.eol=lf", + "-c", "core.attributesFile=/dev/null", +} + +// runGit executes `git ` in dir with the given environment and +// returns trimmed stdout, failing loud with stderr context on any error. +func runGit(ctx context.Context, dir string, env []string, args ...string) (string, error) { + return runGitIn(ctx, dir, env, nil, args...) +} + +// runGitIn is runGit with a stdin stream, for plumbing commands that read +// their payload from stdin (hash-object --stdin, update-index --index-info). +func runGitIn(ctx context.Context, dir string, env []string, stdin io.Reader, args ...string) (string, error) { + out, err := runGitRaw(ctx, dir, env, stdin, args...) + return strings.TrimSpace(out), err +} + +// runGitRaw returns stdout verbatim — status/ls-files -z records start with +// significant bytes (a leading space is a status code) that trimming would +// corrupt. +func runGitRaw(ctx context.Context, dir string, env []string, stdin io.Reader, args ...string) (string, error) { + full := append(append([]string{}, normalizationArgs...), args...) + cmd := exec.CommandContext(ctx, "git", full...) + cmd.Dir = dir + cmd.Env = env + cmd.Stdin = stdin + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + } + return stdout.String(), nil +} + +// gitExitCode runs git and returns its exit code, for commands whose non-zero +// exit is an answer rather than a failure. +func gitExitCode(ctx context.Context, dir string, env []string, args ...string) (int, string, error) { + var stderr bytes.Buffer + code, out, err := gitExitCodeStderr(ctx, dir, env, &stderr, args...) + if err != nil { + return code, out, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) + } + return code, out, nil +} + +// gitExitCodeStderr is gitExitCode with stderr streamed live to the given +// writer — the relay uses it to pass the upstream's sideband straight through +// to the blocked pusher (R6.6). +func gitExitCodeStderr(ctx context.Context, dir string, env []string, stderr io.Writer, args ...string) (int, string, error) { + full := append(append([]string{}, normalizationArgs...), args...) + cmd := exec.CommandContext(ctx, "git", full...) + cmd.Dir = dir + cmd.Env = env + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = stderr + err := cmd.Run() + if err == nil { + return 0, stdout.String(), nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if code := exitErr.ExitCode(); code >= 0 { + return code, stdout.String(), nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return -1, stdout.String(), ctxErr + } + return -1, stdout.String(), fmt.Errorf("git %s terminated by signal: %w", strings.Join(args, " "), err) + } + return -1, "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err) +} diff --git a/pkg/gitagent/gitagent_suite_test.go b/pkg/gitagent/gitagent_suite_test.go new file mode 100644 index 00000000..98daa4fa --- /dev/null +++ b/pkg/gitagent/gitagent_suite_test.go @@ -0,0 +1,31 @@ +package gitagent_test + +import ( + "os" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent" +) + +// TestMain triples the test binary as the GIT_SSH_COMMAND client and as the +// receive-hook shim, so the conformance suite exercises the production +// transport and hook entrypoints with no captain binary installed. The ssh +// branch is checked first: a relay push inside a hook process sets the ssh +// variable explicitly while the hook variable is still inherited. +func TestMain(m *testing.M) { + if os.Getenv("CAPTAIN_TEST_SSH_CLIENT") == "1" { + os.Exit(gitagent.SSHClientMain(os.Args[1:])) + } + if os.Getenv("CAPTAIN_TEST_HOOK") == "1" { + os.Exit(gitagent.HookMain(os.Args[1:])) + } + os.Exit(m.Run()) +} + +func TestGitAgent(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "GitAgent Suite") +} diff --git a/pkg/gitagent/hookmain.go b/pkg/gitagent/hookmain.go new file mode 100644 index 00000000..95513129 --- /dev/null +++ b/pkg/gitagent/hookmain.go @@ -0,0 +1,650 @@ +// The receive-hook entrypoints the shims exec (§6.2). pre-receive is the only +// place a push can be rejected, so admission, hook set execution and the +// nested relay all live there (R6.6); post-receive owns everything that needs +// quarantine to have ended — workspace setup, agent launch, integration +// (R6.4). +package gitagent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/agent/verify" + "github.com/flanksource/captain/pkg/api" +) + +// HookRuntime is the serializable part of a receiver's hook configuration. +type HookRuntime struct { + SidecarWorkflow *api.Workflow `json:"sidecarWorkflow,omitempty"` + SupervisorWorkflow *api.Workflow `json:"supervisorWorkflow,omitempty"` + // HookSandbox names the wrap-command sandbox confining exec hooks (R5.2): + // srt or container. The value test-identity is accepted only inside a Go + // test binary and cannot activate in production. + HookSandbox string `json:"hookSandbox,omitempty"` + AgentCommand string `json:"agentCommand,omitempty"` + RealRepo string `json:"realRepo,omitempty"` // mailbox-local integration target + Relay RelayTarget `json:"relay,omitempty"` // sidecar: supervisor base endpoint +} + +// RequiresJudge reports whether either receiver tier declares prompt checks. +func (r HookRuntime) RequiresJudge() bool { + for _, workflow := range []*api.Workflow{r.SidecarWorkflow, r.SupervisorWorkflow} { + if workflow != nil && workflow.Verify != nil && len(workflow.Verify.Prompts) > 0 { + return true + } + } + return false +} + +// HookHost is a runtime plus the process-local collaborators a hook set needs. +type HookHost struct { + Runtime HookRuntime + Judge ai.Provider + WrapFor HookWrapFactory + Timeout time.Duration + // DefaultAgentCommand supplies the command to launch when the backend + // declares no agentCommand. Without it a dispatch would prepare a + // workspace nothing ever works on, and the supervisor would wait out its + // whole budget in silence. The task id is only known here, at + // post-receive, which is why this is a builder rather than a string. + DefaultAgentCommand func(repo, task string) string +} + +// agentCommandFor resolves what to launch for a task: the configured command, +// else the host's default. +func (h HookHost) agentCommandFor(repo, task string) string { + if command := strings.TrimSpace(h.Runtime.AgentCommand); command != "" { + return command + } + if h.DefaultAgentCommand != nil { + return h.DefaultAgentCommand(repo, task) + } + return "" +} + +// LoadHookRuntime reads a HookRuntime JSON file; an empty path is an empty +// runtime. +func LoadHookRuntime(path string) (HookRuntime, error) { + var rt HookRuntime + if path == "" { + return rt, nil + } + data, err := os.ReadFile(path) + if err != nil { + return rt, err + } + if err := json.Unmarshal(data, &rt); err != nil { + return rt, fmt.Errorf("hook runtime %s: %w", path, err) + } + return rt, nil +} + +// HookWrapFactory builds a confinement wrapper scoped to one materialized +// workspace. The sandbox's filesystem policy depends on the tree it confines, +// and that tree only exists once a push is being vetted — so hooks resolve a +// factory up front and construct the wrapper per vet, after materialization. +// The returned close releases the sandbox; callers must invoke it once the +// hook set has run. +type HookWrapFactory func(ctx context.Context, dir string) (verify.CommandWrapFunc, func() error, error) + +// ResolveHookWrap maps HookRuntime.HookSandbox onto a per-workspace +// confinement factory via the sandbox registry. Empty means none — RunHookSet +// then refuses exec hooks rather than running them bare (R5.2). repo is the +// receiving repository, which the hook policy must hide from the confined +// command. Unknown kinds and adapters without a command wrapper fail here, at +// hook startup, not mid-vet. +func ResolveHookWrap(name, repo string) (HookWrapFactory, error) { + switch name { + case "": + return nil, nil + case "test-identity": + if !testing.Testing() { + return nil, fmt.Errorf("hookSandbox test-identity is only available inside a test binary (R5.2)") + } + return func(_ context.Context, _ string) (verify.CommandWrapFunc, func() error, error) { + wrap := func(_ context.Context, cmd string, args, env []string) (string, []string, []string, error) { + return cmd, args, env, nil + } + return wrap, func() error { return nil }, nil + }, nil + } + kind, ok := api.ParseSandboxKind(name) + if !ok { + return nil, fmt.Errorf("unknown hook sandbox kind %q", name) + } + cfg := api.SandboxConfig{Kind: kind, Options: map[string]any{ + api.SandboxOptionProfile: api.SandboxProfileHook, + }} + if abs, err := filepath.Abs(repo); err == nil && repo != "" { + cfg.Options[api.SandboxOptionDenyRead] = []string{abs} + } + // Probe once at resolve time so a kind that cannot confine commands is a + // startup error rather than a per-push surprise. + probe, err := api.NewSandbox(cfg) + if err != nil { + return nil, err + } + _, canWrap := api.SandboxAs[api.CommandWrapper](probe) + _ = probe.Close() + if !canWrap { + return nil, fmt.Errorf("hook sandbox %q provides no command wrapper; exec hooks cannot run confined (R5.2)", name) + } + return func(ctx context.Context, dir string) (verify.CommandWrapFunc, func() error, error) { + sandbox, err := api.NewSandbox(cfg) + if err != nil { + return nil, nil, err + } + spec := &api.Spec{} + spec.SetCwd(dir) + if _, err := sandbox.Prepare(ctx, spec); err != nil { + _ = sandbox.Close() + return nil, nil, err + } + wrapper, ok := api.SandboxAs[api.CommandWrapper](sandbox) + if !ok { + _ = sandbox.Close() + return nil, nil, fmt.Errorf("hook sandbox %q provides no command wrapper; exec hooks cannot run confined (R5.2)", name) + } + return wrapper.Wrap, sandbox.Close, nil + }, nil +} + +// HookMain is the shim entrypoint: args are +// [runtime-config.json]. +func HookMain(args []string) int { + if len(args) < 3 || len(args) > 4 { + fmt.Fprintln(os.Stderr, "captain: usage: hook [runtime.json]") + return 1 + } + hook, repo, role := args[0], args[1], args[2] + cfgPath := "" + if len(args) == 4 { + cfgPath = args[3] + } + runtime, err := LoadHookRuntime(cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "captain: %v\n", err) + return 1 + } + if runtime.RequiresJudge() { + fmt.Fprintln(os.Stderr, "captain: verify prompts declared but the standalone hook shim has no provider; use `captain sandbox git-agent hook`") + return 1 + } + wrapFor, err := ResolveHookWrap(runtime.HookSandbox, repo) + if err != nil { + fmt.Fprintf(os.Stderr, "captain: %v\n", err) + return 1 + } + host := HookHost{Runtime: runtime, WrapFor: wrapFor} + ctx := context.Background() + switch hook { + case "pre-receive": + if err := RunPreReceive(ctx, repo, ReceiverRole(role), host, os.Stdin, os.Stderr); err != nil { + return 1 + } + return 0 + case "post-receive": + if err := RunPostReceive(ctx, repo, ReceiverRole(role), host, os.Stdin); err != nil { + // post-receive exit status cannot reject; still surface the problem. + fmt.Fprintf(os.Stderr, "captain: post-receive: %v\n", err) + return 1 + } + return 0 + default: + fmt.Fprintf(os.Stderr, "captain: unknown hook %q\n", hook) + return 1 + } +} + +// RunPreReceive is the admission + vetting + relay tier. A non-nil error +// means the push is rejected; the reason has already been written to +// sideband. +func RunPreReceive(ctx context.Context, repo string, role ReceiverRole, host HookHost, stdin io.Reader, sideband io.Writer) error { + updates, err := ParseRefUpdates(stdin) + if err != nil { + fmt.Fprintf(sideband, "captain: %v\n", err) + return err + } + envelope := envelopeFromHookEnv() + req := AdmitRequest{Repo: repo, Role: role, Agent: os.Getenv(EnvAgentName), Updates: updates, Envelope: envelope, Env: os.Environ()} + if err := Admit(ctx, req); err != nil { + fmt.Fprintf(sideband, "captain: REJECTED (admission)\ncaptain: %v\n", err) + return err + } + if role == RoleSidecar { + return sidecarPreReceive(ctx, repo, host, updates, sideband) + } + return mailboxPreReceive(ctx, repo, host, updates, envelope, sideband) +} + +func envelopeFromHookEnv() *Envelope { + envelope, err := EnvelopeFromEnv(os.Getenv) + if err != nil { + return nil // an agent's bare push carries none; Admit enforces per-ref + } + return &envelope +} + +// sidecarPreReceive vets an agent's submit. Dispatch pushes (protocol refs +// only) were fully admitted above and need no hook set — theirs runs on +// submit. +func sidecarPreReceive(ctx context.Context, repo string, host HookHost, updates []RefUpdate, sideband io.Writer) error { + branchUpdate, task, ok := singleAgentBranchUpdate(updates) + if !ok { + return nil + } + var attempt int + st, err := UpdateTaskState(repo, task, func(current *TaskState) (bool, error) { + attempt = current.Attempts + 1 + current.Attempts = attempt + return true, nil + }) + if err != nil { + fmt.Fprintf(sideband, "captain: %v\n", err) + return err + } + if st == nil { + fmt.Fprintf(sideband, "captain: task state missing for %s\n", task) + return fmt.Errorf("task state missing for %s", task) + } + // An attempt is consumed per submit, rejected or not (§6.3): the retry + // after a rejection is attempt n+1. + if st.Policy.MaxAttempts > 0 && attempt > st.Policy.MaxAttempts { + verdict := TierVerdict{ + V: ProtocolVersion, Task: task, Attempt: attempt, Tier: string(RoleSidecar), Status: StatusRejected, + Findings: []Finding{{Hook: "gate:max-attempts", Kind: "commit", + Message: fmt.Sprintf("attempt %d exceeds the task's maxAttempts %d", attempt, st.Policy.MaxAttempts)}}, + } + return rejectWithVerdict(repo, verdict, sideband) + } + workflow := host.Runtime.SidecarWorkflow + if st.Hooks != nil && st.Hooks.Sidecar != nil { + workflow = st.Hooks.Sidecar + } + verdict := vetTree(ctx, repo, vetRequest{ + host: host, workflow: workflow, tier: string(RoleSidecar), + task: task, attempt: attempt, depth: 0, + from: st.DispatchCommit, to: branchUpdate.New, + }) + if verdict.Rejects() { + return rejectWithVerdict(repo, verdict, sideband) + } + if host.Runtime.Relay.URL != "" { + if err := relayUpward(ctx, repo, host, st, attempt, branchUpdate.New, sideband); err != nil { + var rejected *upstreamRejectedError + if errors.As(err, &rejected) { + // The supervisor already persisted and rendered the authoritative + // verdict. Return non-zero to reject this push without replacing it + // with a second, generic sidecar relay-error verdict. + return err + } + verdict.Status = StatusError + verdict.Findings = append(verdict.Findings, Finding{ + Hook: "relay", Kind: "exec", Message: err.Error(), + }) + return rejectWithVerdict(repo, verdict, sideband) + } + } + if err := SaveVerdict(repo, verdict); err != nil { + fmt.Fprintf(sideband, "captain: %v\n", err) + return err + } + return WriteFeedback(sideband, verdict, "") +} + +// relayUpward pushes the squashed result plus the ORIGINAL dispatch control +// commit at the attempt's control ref. On attempt 1 the mailbox already holds +// that exact commit from the supervisor's audit write, so git filters the +// control update as up-to-date; on a retry it is a fresh create — either way +// R3.2's create-only holds and the R3.4 pairing invariant is preserved. +func relayUpward(ctx context.Context, repo string, host HookHost, st *TaskState, attempt int, tip string, sideband io.Writer) error { + hookEnv := os.Environ() + result, err := BuildResultCommit(ctx, repo, hookEnv, tip, st.DispatchCommit) + if err != nil { + return err + } + control := st.ControlCommit + if control == "" { + return fmt.Errorf("task %s has no recorded control commit; cannot relay", st.Task) + } + if err := ValidateMailboxRoute(st.Mailbox); err != nil { + return fmt.Errorf("task %s has no usable mailbox route: %w", st.Task, err) + } + envelope := Envelope{ + Version: ProtocolVersion, Task: st.Task, Attempt: attempt, + Base: st.Base, Depth: 0, Agent: st.Agent, Relay: st.Relay, + } + return Relay(ctx, repo, hookEnv, host.Runtime.Relay, st.Mailbox, envelope, result, control, sideband) +} + +// mailboxPreReceive runs hook set #2 over an arriving result (§6.2 step 10). +// The accept-path verdict is written by post-receive after integration; only +// a rejection must persist here, before the non-zero exit (R6.9). +func mailboxPreReceive(ctx context.Context, repo string, host HookHost, updates []RefUpdate, envelope *Envelope, sideband io.Writer) error { + resultUpdate, info, ok := singleResultUpdate(updates) + if !ok { + return nil + } + st, found, err := LoadTaskState(repo, info.Task) + if err != nil || !found { + fmt.Fprintf(sideband, "captain: task state missing for %s\n", info.Task) + return fmt.Errorf("task state missing for %s", info.Task) + } + depth := 0 + if envelope != nil { + depth = envelope.Depth + } + workflow := host.Runtime.SupervisorWorkflow + if st.Hooks != nil && st.Hooks.Supervisor != nil { + workflow = st.Hooks.Supervisor + } + verdict := vetTree(ctx, repo, vetRequest{ + host: host, workflow: workflow, tier: "supervisor", + task: info.Task, attempt: info.Attempt, depth: depth, + from: st.DispatchCommit, to: resultUpdate.New, + }) + if verdict.Rejects() { + return rejectWithVerdict(repo, verdict, sideband) + } + if host.Runtime.RealRepo != "" { + conflict, err := checkIntegration(ctx, host.Runtime.RealRepo, repo, st.Base, resultUpdate.New, os.Environ()) + if err != nil { + verdict.Status = StatusError + verdict.Findings = append(verdict.Findings, Finding{ + Hook: "integrate", Kind: "commit", Message: "could not evaluate integration: " + err.Error(), + }) + return rejectWithVerdict(repo, verdict, sideband) + } + if conflict != "" { + verdict.Status = StatusRejected + verdict.Findings = append(verdict.Findings, Finding{ + Hook: "integrate", Kind: "commit", Message: conflict, + }) + return rejectWithVerdict(repo, verdict, sideband) + } + } + return nil +} + +type vetRequest struct { + host HookHost + workflow *api.Workflow + tier string + task string + attempt int + depth int + from, to string +} + +// vetTree materializes the pushed tree and runs one tier's hook set over it. +// Every failure mode folds into the verdict (R7.5). +func vetTree(ctx context.Context, repo string, req vetRequest) TierVerdict { + verdict := TierVerdict{V: ProtocolVersion, Task: req.task, Attempt: req.attempt, Tier: req.tier, Status: StatusError} + dir, err := os.MkdirTemp("", "captain-vet-") + if err != nil { + verdict.Findings = append(verdict.Findings, Finding{Hook: "materialize", Kind: "exec", Message: err.Error()}) + return verdict + } + defer os.RemoveAll(dir) + hookEnv := os.Environ() + if _, err := Materialize(ctx, repo, hookEnv, req.to, dir); err != nil { + verdict.Findings = append(verdict.Findings, Finding{Hook: "materialize", Kind: "exec", Message: err.Error()}) + return verdict + } + changed, err := changedPathsBetween(ctx, repo, hookEnv, req.from, req.to) + if err != nil { + verdict.Findings = append(verdict.Findings, Finding{Hook: "materialize", Kind: "exec", Message: err.Error()}) + return verdict + } + // The confinement wrapper is built here, against the tree it will confine: + // only now does the workspace exist, and a sandbox whose filesystem policy + // was computed for any other directory would confine the wrong thing. A + // factory failure is fail-closed (R5.2): status error, and error rejects. + var wrap verify.CommandWrapFunc + if req.host.WrapFor != nil && len(verify.HooksForWorkflow(req.workflow)) > 0 { + wrapped, closeWrap, err := req.host.WrapFor(ctx, dir) + if err != nil { + verdict.Findings = append(verdict.Findings, Finding{Hook: "hookset", Kind: "exec", + Message: "hook sandbox could not confine the workspace: " + err.Error()}) + return verdict + } + defer func() { _ = closeWrap() }() + wrap = wrapped + } + stop := StartProgress(os.Stderr, req.tier+" hooks", 30*time.Second) + defer stop() + return RunHookSet(ctx, HookWorkspace{Dir: dir, Changed: changed}, HookSetOptions{ + Workflow: req.workflow, + Tier: req.tier, + Task: req.task, + Attempt: req.attempt, + Depth: req.depth, + Judge: req.host.Judge, + Wrap: wrap, + Env: HookExecEnv(hookEnv), + Timeout: req.host.Timeout, + }) +} + +// rejectWithVerdict persists the verdict out-of-band before the non-zero exit +// (R6.9), then writes the sideband feedback block (§7). +func rejectWithVerdict(repo string, verdict TierVerdict, sideband io.Writer) error { + logPath := verdictPath(repo, verdict.Task, verdict.Attempt) + if err := SaveVerdict(repo, verdict); err != nil { + fmt.Fprintf(sideband, "captain: persisting verdict: %v\n", err) + } + _ = WriteFeedback(sideband, verdict, logPath) + return fmt.Errorf("push rejected: task %s attempt %d (%s)", verdict.Task, verdict.Attempt, verdict.Tier) +} + +// RunPostReceive owns the work that is only legal once quarantine has ended +// (R6.4): sidecar workspace setup and agent launch on dispatch, supervisor +// integration and the verdict ref on an accepted result. +func RunPostReceive(ctx context.Context, repo string, role ReceiverRole, host HookHost, stdin io.Reader) error { + updates, err := ParseRefUpdates(stdin) + if err != nil { + return err + } + envelope := envelopeFromHookEnv() + if role == RoleSidecar { + return sidecarPostReceive(ctx, repo, host, updates, envelope) + } + return mailboxPostReceive(ctx, repo, host, updates) +} + +func sidecarPostReceive(ctx context.Context, repo string, host HookHost, updates []RefUpdate, envelope *Envelope) error { + for _, u := range updates { + if !IsProtocolRef(u.Ref) { + continue + } + info, err := ParseTaskRef(u.Ref) + if err != nil || info.Kind != RefDispatch { + continue + } + if envelope == nil { + return fmt.Errorf("dispatch ref %s arrived without an envelope", u.Ref) + } + payloads, err := loadDispatchPayloads(ctx, repo, updates, info) + if err != nil { + return err + } + if err := SaveTaskState(repo, &TaskState{ + Task: info.Task, Agent: envelope.Agent, Base: envelope.Base, + DispatchCommit: u.New, ControlCommit: payloads.controlCommit, + Relay: envelope.Relay, Mailbox: envelope.Mailbox, + Policy: payloads.policy, Hooks: payloads.hooks, + }); err != nil { + return err + } + workdir, err := SetupAgentWorkspace(ctx, repo, info.Task, u.New, taskRuntimeIdentity(payloads.task)) + if err != nil { + return err + } + taskFile, err := WriteTaskFile(repo, info.Task, payloads.task) + if err != nil { + return err + } + if err := LaunchAgent(repo, info.Task, workdir, taskFile, host.agentCommandFor(repo, info.Task)); err != nil { + return err + } + } + return nil +} + +// taskRuntimeIdentity derives commit provenance from task.json while keeping +// legacy or human-driven payloads usable with the generic agent name. +func taskRuntimeIdentity(data []byte) string { + var payload TaskPayload + if err := json.Unmarshal(data, &payload); err != nil || payload.Backend == "" || payload.Model == "" { + return "captain-agent" + } + return ai.LogIdentity(api.Backend(payload.Backend), payload.Model, payload.Effort) +} + +type dispatchPayloads struct { + policy Policy + task []byte + hooks *HookSets + controlCommit string +} + +// loadDispatchPayloads reads the task policy carried by the paired control +// commit. Hooks become durable receiver state; consulting only local receiver +// config would let the dispatched vetting policy disappear at the sidecar. +func loadDispatchPayloads(ctx context.Context, repo string, updates []RefUpdate, dispatch RefInfo) (dispatchPayloads, error) { + payloads := dispatchPayloads{task: []byte("{}")} + controlRef, err := ControlRef(dispatch.Task, dispatch.Attempt) + if err != nil { + return payloads, err + } + control := refUpdateFor(updates, controlRef) + if control.New == "" || isZeroOID(control.New) { + return payloads, fmt.Errorf("dispatch %s has no paired control commit", dispatch.Task) + } + payloads.controlCommit = control.New + env := os.Environ() + if raw, err := ReadControlPayload(ctx, repo, env, control.New, ControlPolicyFile); err == nil { + if err := json.Unmarshal(raw, &payloads.policy); err != nil { + return payloads, fmt.Errorf("decode %s: %w", ControlPolicyFile, err) + } + } + if raw, err := ReadControlPayload(ctx, repo, env, control.New, ControlTaskFile); err == nil && len(raw) > 0 { + payloads.task = raw + } + if raw, err := ReadControlPayload(ctx, repo, env, control.New, ControlHooksFile); err == nil { + payloads.hooks, err = DecodeHookSets(raw) + if err != nil { + return payloads, err + } + } + return payloads, nil +} + +func mailboxPostReceive(ctx context.Context, repo string, host HookHost, updates []RefUpdate) error { + resultUpdate, info, ok := singleResultUpdate(updates) + if !ok { + return nil + } + st, found, err := LoadTaskState(repo, info.Task) + if err != nil || !found { + return fmt.Errorf("task state missing for %s", info.Task) + } + verdict := TierVerdict{V: ProtocolVersion, Task: info.Task, Attempt: info.Attempt, Tier: "supervisor", Status: StatusAccepted} + if host.Runtime.RealRepo != "" { + // Admission already proved that the envelope agrees with this durable + // state. Keep the authoritative dispatch record as the sole source for + // the three-way merge base (R10.1). + integration, err := Integrate(ctx, host.Runtime.RealRepo, repo, info.Task, info.Attempt, st.Base, resultUpdate.New) + if err != nil { + return err + } + if integration.Conflict != "" { + // Pre-receive normally rejects this. If HEAD changed in the narrow + // gap before post-receive, fail the verdict closed instead of + // claiming that unintegrated work was accepted. + verdict.Status = StatusError + verdict.Findings = append(verdict.Findings, Finding{ + Hook: "integrate", Kind: "commit", Message: integration.Conflict, + }) + } else { + verdict.Findings = append(verdict.Findings, Finding{ + Hook: "integrate", Kind: "commit", + Message: "merged onto " + integration.Branch, Path: integration.Branch, + }) + } + } + if _, err := UpdateTaskState(repo, info.Task, func(current *TaskState) (bool, error) { + if info.Attempt > current.Attempts { + current.Attempts = info.Attempt + } + return true, nil + }); err != nil { + return err + } + if err := SaveVerdict(repo, verdict); err != nil { + return err + } + return writeVerdictRef(ctx, repo, verdict) +} + +// writeVerdictRef records the verdict as a control commit on +// refs/captain/tasks//verdict/ (§3.2). +func writeVerdictRef(ctx context.Context, repo string, verdict TierVerdict) error { + ref, err := VerdictRef(verdict.Task, verdict.Attempt) + if err != nil { + return err + } + payload, err := json.MarshalIndent(verdict, "", " ") + if err != nil { + return err + } + commit, err := BuildControlCommit(ctx, repo, nil, map[string][]byte{"verdict.json": payload}) + if err != nil { + return err + } + _, err = runGit(ctx, repo, os.Environ(), "update-ref", ref, commit) + return err +} + +func singleAgentBranchUpdate(updates []RefUpdate) (RefUpdate, string, bool) { + for _, u := range updates { + if strings.HasPrefix(u.Ref, agentBranchPrefix) { + return u, strings.TrimPrefix(u.Ref, agentBranchPrefix), true + } + } + return RefUpdate{}, "", false +} + +func singleResultUpdate(updates []RefUpdate) (RefUpdate, RefInfo, bool) { + for _, u := range updates { + if info, err := ParseTaskRef(u.Ref); err == nil && info.Kind == RefResult { + return u, info, true + } + } + return RefUpdate{}, RefInfo{}, false +} + +// changedPathsBetween lists the paths differing between two commits. +func changedPathsBetween(ctx context.Context, repo string, env []string, from, to string) ([]string, error) { + out, err := runGitRaw(ctx, repo, env, nil, + "diff-tree", "-r", "-z", "--name-only", "--no-commit-id", "--no-renames", from, to) + if err != nil { + return nil, err + } + var paths []string + for _, p := range strings.Split(out, "\x00") { + if p != "" { + paths = append(paths, p) + } + } + return paths, nil +} diff --git a/pkg/gitagent/hookset.go b/pkg/gitagent/hookset.go new file mode 100644 index 00000000..9d1d4ca4 --- /dev/null +++ b/pkg/gitagent/hookset.go @@ -0,0 +1,166 @@ +// Hook-set execution (§5): an api.Workflow run as a single-verdict check +// chain over a materialized tree. Order is commit gates, then exec verifiers, +// then prompt judges; the chain stops at the first failure and that hook's +// output is the verdict's feedback (R5.1). Exec hooks are agent-authored +// input and MUST run confined — a wrap-command sandbox is required, not +// optional (R5.2/H1). Nothing here returns a Go error: every failure mode +// folds into the verdict, and an indeterminate verdict rejects (R7.5). +package gitagent + +import ( + "context" + "fmt" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/agent" + agentcommit "github.com/flanksource/captain/pkg/ai/agent/commit" + "github.com/flanksource/captain/pkg/ai/agent/verify" + "github.com/flanksource/captain/pkg/api" +) + +// DefaultHookTimeout bounds one hook's wall clock inside a blocked push +// (R5.5); tighter than verify.DefaultCmdTimeout because a push is waiting. +const DefaultHookTimeout = 5 * time.Minute + +// HookWorkspace is the materialized tree a hook set runs against. +type HookWorkspace struct { + Dir string // absolute path of the materialized tree + Changed []string // repo-relative paths the push changed +} + +// HookSetOptions configures one tier's hook-set run. +type HookSetOptions struct { + Workflow *api.Workflow + Tier string // "sidecar" | "supervisor" + Task string + Attempt int + Depth int // envelope depth of the push being vetted (R5.4/H15) + Judge ai.Provider // provider for prompt hooks; nil forbids them + Wrap verify.CommandWrapFunc + Env []string // allowlisted environment for exec hooks (R1.1, HookExecEnv) + Timeout time.Duration +} + +// RunHookSet executes the workflow's checks and renders the tier's verdict. +func RunHookSet(ctx context.Context, ws HookWorkspace, opts HookSetOptions) TierVerdict { + verdict := TierVerdict{ + V: ProtocolVersion, + Task: opts.Task, + Attempt: opts.Attempt, + Status: StatusAccepted, + Tier: opts.Tier, + } + wf := opts.Workflow + if wf == nil { + return verdict + } + if finding, failed := runCommitGates(ws, wf); failed { + verdict.Status = StatusRejected + verdict.Findings = append(verdict.Findings, finding) + return verdict + } + plugins, errFinding := buildHookPlugins(wf, opts) + if errFinding != nil { + verdict.Status = StatusError + verdict.Findings = append(verdict.Findings, *errFinding) + return verdict + } + changed := ws.Changed + if verify.ScopeForWorkflow(wf) != agent.ScopeChanged { + changed = nil // whole-tree semantics, matching verify.Plugin + } + for _, p := range plugins { + vd, err := p.Verifier().Verify(ctx, ws.Dir, changed) + if err != nil { + verdict.Status = StatusError + verdict.Findings = append(verdict.Findings, Finding{ + Hook: p.Name(), Kind: hookKind(p.Name()), + Message: fmt.Sprintf("hook could not reach a verdict: %v", err), + }) + return verdict + } + if !vd.OK { + verdict.Status = StatusRejected + verdict.Findings = append(verdict.Findings, Finding{ + Hook: p.Name(), Kind: hookKind(p.Name()), + Message: vd.Reason, Feedback: vd.Feedback, + }) + return verdict + } + } + return verdict +} + +// runCommitGates applies each declared commit policy's content gates over the +// changed paths (the receive-side analogue of commit.Hook's pre-commit check). +func runCommitGates(ws HookWorkspace, wf *api.Workflow) (Finding, bool) { + for _, c := range wf.Commits { + if err := agentcommit.CheckGates(ws.Dir, c.EffectiveGates(), 0, ws.Changed); err != nil { + return Finding{ + Hook: "gate:commit", Kind: "commit", + Message: err.Error(), + }, true + } + } + return Finding{}, false +} + +// buildHookPlugins assembles exec and prompt verifiers via the same builders +// the local run path uses (A5.1: one hook machinery), confining every exec +// hook and bounding prompt-hook recursion. +func buildHookPlugins(wf *api.Workflow, opts HookSetOptions) ([]*verify.Plugin, *Finding) { + var plugins []*verify.Plugin + execHooks := verify.HooksForWorkflow(wf) + if len(execHooks) > 0 && opts.Wrap == nil { + return nil, &Finding{ + Hook: "hookset", Kind: "exec", + Message: "exec hooks require a wrap-command sandbox; refusing to run agent-authored commands on the host (R5.2)", + } + } + timeout := opts.Timeout + if timeout <= 0 { + timeout = DefaultHookTimeout + } + for _, h := range execHooks { + p, ok := h.(*verify.Plugin) + if !ok { + return nil, &Finding{Hook: "hookset", Kind: "exec", Message: fmt.Sprintf("unexpected hook type %T", h)} + } + if cv, ok := p.Verifier().(*verify.CmdVerifier); ok { + cv.Env = opts.Env + cv.Wrap = opts.Wrap + cv.Timeout = timeout + } + plugins = append(plugins, p) + } + if wf.Verify != nil && len(wf.Verify.Prompts) > 0 && opts.Depth+1 > MaxHookDepth { + return nil, &Finding{ + Hook: "hookset", Kind: "prompt", + Message: fmt.Sprintf("prompt hooks at depth %d exceed the recursion bound %d (R5.4/H15)", opts.Depth+1, MaxHookDepth), + } + } + judgeHooks, err := verify.PromptHooksForWorkflow(wf, opts.Judge) + if err != nil { + return nil, &Finding{Hook: "hookset", Kind: "prompt", Message: err.Error()} + } + for _, h := range judgeHooks { + p, ok := h.(*verify.Plugin) + if !ok { + return nil, &Finding{Hook: "hookset", Kind: "prompt", Message: fmt.Sprintf("unexpected hook type %T", h)} + } + plugins = append(plugins, p) + } + return plugins, nil +} + +func hookKind(name string) string { + switch { + case len(name) > 6 && name[:6] == "judge:": + return "prompt" + case len(name) > 7 && name[:7] == "verify:": + return "exec" + default: + return "exec" + } +} diff --git a/pkg/gitagent/hookset_ginkgo_test.go b/pkg/gitagent/hookset_ginkgo_test.go new file mode 100644 index 00000000..fc12be74 --- /dev/null +++ b/pkg/gitagent/hookset_ginkgo_test.go @@ -0,0 +1,261 @@ +package gitagent_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/gitagent" +) + +// identityWrap satisfies the mandatory confinement seam for tests without a +// real sandbox runtime. +func identityWrap(_ context.Context, cmd string, args, env []string) (string, []string, []string, error) { + return cmd, args, env, nil +} + +// judgeStub is an ai.Provider that returns a fixed judge verdict with no live +// model call. +type judgeStub struct { + verdict string + judged int +} + +func (p *judgeStub) Execute(_ context.Context, req ai.Request) (*ai.Response, error) { + p.judged++ + if req.Prompt.Schema != nil { + // A real provider unmarshals the model's JSON into the structured + // output target; the stub does the same with its canned verdict. + if err := json.Unmarshal([]byte(p.verdict), req.Prompt.Schema); err != nil { + return nil, err + } + } + return &ai.Response{Text: p.verdict}, nil +} +func (p *judgeStub) GetModel() string { return "stub" } +func (p *judgeStub) GetBackend() api.Backend { return api.BackendAnthropic } + +var _ = Describe("materialization", func() { + ctx := context.Background() + + It("materializes the full tree into an absolute destination and counts it (R1.3)", func() { + f := newAdmitFixture(ctx) + dst := filepath.Join(GinkgoT().TempDir(), "tree") + count, err := gitagent.Materialize(ctx, f.super, os.Environ(), f.snap.Commit, dst) + Expect(err).NotTo(HaveOccurred()) + Expect(count).To(Equal(2)) // src/main.go + src/dirty.go + Expect(os.ReadFile(filepath.Join(dst, "src", "dirty.go"))).To(Equal([]byte("package main // dirty\n"))) + }) + + It("ignores unrelated files already present in the destination", func() { + f := newAdmitFixture(ctx) + dst := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(dst, "stale.txt"), []byte("stale"), 0o644)).To(Succeed()) + count, err := gitagent.Materialize(ctx, f.super, os.Environ(), f.snap.Commit, dst) + Expect(err).NotTo(HaveOccurred()) + Expect(count).To(Equal(2)) + }) + + It("detects an empty or short materialization instead of passing (H18)", func() { + Expect(gitagent.AssertMaterialized(0, 3)).To(MatchError(ContainSubstring("H18"))) + Expect(gitagent.AssertMaterialized(2, 3)).To(MatchError(ContainSubstring("H18"))) + Expect(gitagent.AssertMaterialized(3, 0)).To(MatchError(ContainSubstring("H18"))) + Expect(gitagent.AssertMaterialized(3, 3)).To(Succeed()) + }) + + It("refuses a tree containing a .git path component (H9)", func() { + Expect(gitagent.RejectDotGitComponents([]string{"a/b.txt", ".git/config"})).To(MatchError(ContainSubstring("H9"))) + Expect(gitagent.RejectDotGitComponents([]string{"a/.GIT/hook"})).To(MatchError(ContainSubstring("H9"))) + Expect(gitagent.RejectDotGitComponents([]string{"a/gitty/.gitignore"})).To(Succeed()) + }) +}) + +var _ = Describe("hook sets", func() { + ctx := context.Background() + + ws := func() gitagent.HookWorkspace { + dir := GinkgoT().TempDir() + writeFileT(dir, "main.go", "package main\n") + return gitagent.HookWorkspace{Dir: dir, Changed: []string{"main.go"}} + } + + It("accepts an empty workflow and a passing chain", func() { + v := gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{Task: "t-1", Attempt: 1, Tier: "sidecar"}) + Expect(v.Status).To(Equal(gitagent.StatusAccepted)) + + v = gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Tier: "sidecar", + Workflow: &api.Workflow{Verify: &api.Verify{Commands: []string{"true"}}}, + Wrap: identityWrap, + }) + Expect(v.Status).To(Equal(gitagent.StatusAccepted)) + Expect(v.Rejects()).To(BeFalse()) + }) + + It("stops at the first failing hook and carries its feedback (R5.1)", func() { + w := ws() + marker := filepath.Join(w.Dir, "second-ran") + v := gitagent.RunHookSet(ctx, w, gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Tier: "sidecar", + Workflow: &api.Workflow{Verify: &api.Verify{Commands: []string{ + "echo boom-detail && false", + "touch " + marker, + }}}, + Wrap: identityWrap, + }) + Expect(v.Status).To(Equal(gitagent.StatusRejected)) + Expect(v.Findings).To(HaveLen(1)) + Expect(v.Findings[0].Feedback).To(ContainSubstring("boom-detail")) + Expect(marker).NotTo(BeAnExistingFile(), "the chain must stop at the first failure") + }) + + It("refuses to run exec hooks without a confinement wrap (R5.2)", func() { + v := gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Tier: "sidecar", + Workflow: &api.Workflow{Verify: &api.Verify{Commands: []string{"true"}}}, + }) + Expect(v.Status).To(Equal(gitagent.StatusError)) + Expect(v.Rejects()).To(BeTrue(), "an indeterminate verdict rejects (R7.5)") + Expect(v.Findings[0].Message).To(ContainSubstring("R5.2")) + }) + + It("kills a hook that overruns its timeout and reports error status", func() { + v := gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Tier: "sidecar", + Workflow: &api.Workflow{Verify: &api.Verify{Commands: []string{"sleep 30"}}}, + Wrap: identityWrap, + Timeout: 200 * time.Millisecond, + }) + Expect(v.Status).To(Equal(gitagent.StatusRejected)) + Expect(v.Findings[0].Message).To(ContainSubstring("timed out")) + }) + + It("runs prompt hooks through a mock provider with no live model call", func() { + promptPath := filepath.Join(GinkgoT().TempDir(), "review.prompt") + Expect(os.WriteFile(promptPath, []byte("{{role \"user\"}}\nJudge {{cwd}}."), 0o644)).To(Succeed()) + + reject := &judgeStub{verdict: `{"ok":false,"reason":"style","feedback":"rename Baz"}`} + v := gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Tier: "supervisor", + Workflow: &api.Workflow{Verify: &api.Verify{Prompts: []string{promptPath}}}, + Judge: reject, + }) + Expect(v.Status).To(Equal(gitagent.StatusRejected)) + Expect(reject.judged).To(Equal(1)) + Expect(v.Findings[0].Kind).To(Equal("prompt")) + Expect(v.Findings[0].Feedback).To(ContainSubstring("rename Baz")) + + accept := &judgeStub{verdict: `{"ok":true,"reason":"fine","feedback":""}`} + v = gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Tier: "supervisor", + Workflow: &api.Workflow{Verify: &api.Verify{Prompts: []string{promptPath}}}, + Judge: accept, + }) + Expect(v.Status).To(Equal(gitagent.StatusAccepted)) + }) + + It("errors on prompts with no judge, and bounds recursion depth (R5.4)", func() { + promptPath := filepath.Join(GinkgoT().TempDir(), "review.prompt") + Expect(os.WriteFile(promptPath, []byte("{{role \"user\"}}\nJudge {{cwd}}."), 0o644)).To(Succeed()) + wf := &api.Workflow{Verify: &api.Verify{Prompts: []string{promptPath}}} + + v := gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{Task: "t-1", Attempt: 1, Workflow: wf}) + Expect(v.Status).To(Equal(gitagent.StatusError)) + Expect(v.Findings[0].Message).To(ContainSubstring("no provider")) + + v = gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Workflow: wf, + Judge: &judgeStub{verdict: `{"ok":true}`}, Depth: gitagent.MaxHookDepth, + }) + Expect(v.Status).To(Equal(gitagent.StatusError)) + Expect(v.Findings[0].Message).To(ContainSubstring("H15")) + }) + + It("applies commit gates to changed paths", func() { + w := ws() + writeFileT(w.Dir, ".env", "TOKEN=x\n") + w.Changed = append(w.Changed, ".env") + v := gitagent.RunHookSet(ctx, w, gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Tier: "sidecar", + Workflow: &api.Workflow{Commits: []api.Commit{{}}}, + }) + Expect(v.Status).To(Equal(gitagent.StatusRejected)) + Expect(v.Findings[0].Kind).To(Equal("commit")) + Expect(v.Findings[0].Message).To(ContainSubstring(".env")) + }) +}) + +var _ = Describe("verdict persistence (R6.9)", func() { + It("round-trips a verdict keyed by task and attempt", func() { + repo := GinkgoT().TempDir() + v := gitagent.TierVerdict{ + V: 1, Task: "t-1", Attempt: 2, Status: gitagent.StatusRejected, Tier: "sidecar", + } + Expect(gitagent.SaveVerdict(repo, v)).To(Succeed()) + loaded, ok, err := gitagent.LoadVerdict(repo, "t-1", 2) + Expect(err).NotTo(HaveOccurred()) + Expect(ok).To(BeTrue()) + Expect(loaded.Status).To(Equal(gitagent.StatusRejected)) + + _, ok, err = gitagent.LoadVerdict(repo, "t-1", 3) + Expect(err).NotTo(HaveOccurred()) + Expect(ok).To(BeFalse()) + }) +}) + +var _ = Describe("feedback wire format (§7)", func() { + verdict := gitagent.TierVerdict{ + V: 1, Task: "t-1", Attempt: 2, Status: gitagent.StatusRejected, Tier: "sidecar", + Findings: []gitagent.Finding{ + {Hook: "gate:path-denied", Kind: "commit", Path: ".env", Message: "path denied by policy"}, + {Hook: "verify:make lint", Kind: "exec", Feedback: "pkg/foo/bar.go:12:2: undefined: Baz\r\nsecond line"}, + }, + } + + It("renders the header, findings and a single captain-json line without CR", func() { + block, err := gitagent.FormatFeedback(verdict, "") + Expect(err).NotTo(HaveOccurred()) + Expect(block).To(ContainSubstring("captain: REJECTED task t-1 attempt 2 (sidecar)")) + Expect(block).To(ContainSubstring("✗ gate:path-denied")) + Expect(block).To(ContainSubstring("undefined: Baz")) + Expect(block).NotTo(ContainSubstring("\r"), "CR is eaten by the sideband demuxer (R7.1)") + + var jsonLine string + for _, line := range strings.Split(block, "\n") { + if strings.HasPrefix(line, "captain-json: ") { + Expect(jsonLine).To(BeEmpty(), "exactly one captain-json line (R7.2)") + jsonLine = strings.TrimPrefix(line, "captain-json: ") + } + } + Expect(jsonLine).NotTo(BeEmpty()) + var decoded gitagent.TierVerdict + Expect(json.Unmarshal([]byte(jsonLine), &decoded)).To(Succeed()) + Expect(decoded.Status).To(Equal(gitagent.StatusRejected)) + Expect(decoded.Findings).To(HaveLen(2)) + }) + + It("caps the block at 64 KiB with a marker and log pointer (R7.3)", func() { + big := verdict + big.Findings = nil + for range 24 { + // Each finding's feedback is individually bounded, so overflow the + // block with many findings rather than one enormous one. + big.Findings = append(big.Findings, gitagent.Finding{ + Hook: "verify:test", Kind: "exec", Feedback: strings.Repeat("x", 9<<10), + }) + } + block, err := gitagent.FormatFeedback(big, "/var/log/captain/t-1.log") + Expect(err).NotTo(HaveOccurred()) + Expect(len(block)).To(BeNumerically("<=", gitagent.MaxFeedbackBytes)) + Expect(block).To(ContainSubstring("[feedback truncated; full log: /var/log/captain/t-1.log]")) + Expect(block).To(ContainSubstring("captain-json: "), "the JSON summary survives truncation") + }) +}) diff --git a/pkg/gitagent/hookshim.go b/pkg/gitagent/hookshim.go new file mode 100644 index 00000000..ac47582a --- /dev/null +++ b/pkg/gitagent/hookshim.go @@ -0,0 +1,69 @@ +package gitagent + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const hookShimMarker = "# installed by captain sandbox git-agent" + +// InstallHookShims writes pre-receive and post-receive shims into the repo's +// hooks directory, each exec'ing the captain binary's hook subcommand with +// stdin and environment flowing through untouched. Re-running is idempotent: +// an identical shim is left alone, a stale one is rewritten, and a foreign +// hook is refused rather than silently replaced. +// +// configPath and backend are baked in rather than left to process defaults: a +// hook runs as a child of whoever pushed, whose ambient HOME and selected +// backend may differ from the receiver that installed it. +func InstallHookShims(repoPath, captainBin, configPath string, role ReceiverRole, backend string) error { + bin, err := filepath.Abs(captainBin) + if err != nil { + return err + } + repo, err := filepath.Abs(repoPath) + if err != nil { + return err + } + config := "" + if strings.TrimSpace(configPath) != "" { + abs, err := filepath.Abs(configPath) + if err != nil { + return err + } + config = fmt.Sprintf(" --config %q", abs) + } + backendArg := "" + if strings.TrimSpace(backend) != "" { + backendArg = fmt.Sprintf(" --backend %q", strings.TrimSpace(backend)) + } + for _, hook := range []string{"pre-receive", "post-receive"} { + shim := fmt.Sprintf(`#!/bin/sh +%s +exec %q sandbox git-agent hook %s --repo %q --role %q%s%s +`, hookShimMarker, bin, hook, repo, string(role), backendArg, config) + target := filepath.Join(repo, "hooks", hook) + existing, err := os.ReadFile(target) + switch { + case err == nil && string(existing) == shim: + continue + case err == nil && !containsMarker(existing): + return fmt.Errorf("%s already has a %s hook not installed by captain; remove it first", repo, hook) + case err != nil && !os.IsNotExist(err): + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if err := writeFileAtomic(target, []byte(shim), 0o755); err != nil { + return err + } + } + return nil +} + +func containsMarker(content []byte) bool { + return strings.Contains(string(content), hookShimMarker) +} diff --git a/pkg/gitagent/hookwrap_test.go b/pkg/gitagent/hookwrap_test.go new file mode 100644 index 00000000..fec2a893 --- /dev/null +++ b/pkg/gitagent/hookwrap_test.go @@ -0,0 +1,154 @@ +package gitagent + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/ai/agent/verify" + "github.com/flanksource/captain/pkg/api" +) + +func TestHookExecEnv(t *testing.T) { + got := HookExecEnv([]string{ + "PATH=/usr/bin", + "HOME=/home/u", + "LANG=C.UTF-8", + "LC_ALL=C", + "GIT_DIR=.", + "GIT_QUARANTINE_PATH=/q", + "ANTHROPIC_API_KEY=sk-secret", + "OPENAI_API_KEY=sk-secret", + "AWS_SECRET_ACCESS_KEY=secret", + "CAPTAIN_TASK=t-1", + }) + want := []string{"PATH=/usr/bin", "HOME=/home/u", "LANG=C.UTF-8", "LC_ALL=C"} + if len(got) != len(want) { + t.Fatalf("HookExecEnv = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("HookExecEnv = %v, want %v", got, want) + } + } +} + +func TestResolveHookWrap_EmptyMeansNone(t *testing.T) { + factory, err := ResolveHookWrap("", t.TempDir()) + if err != nil || factory != nil { + t.Fatalf("ResolveHookWrap(\"\") = %v, %v; want nil factory, nil error", factory, err) + } +} + +func TestResolveHookWrap_UnknownKindFailsAtResolveTime(t *testing.T) { + if _, err := ResolveHookWrap("no-such-sandbox", t.TempDir()); err == nil { + t.Fatal("unknown hook sandbox kind must fail at resolve time") + } +} + +// vetTreeFixture builds a repo with two commits and returns their OIDs, so +// vetTree has a real from→to range to materialize. +func vetTreeFixture(t *testing.T) (repo, from, to string) { + t.Helper() + ctx := context.Background() + repo = t.TempDir() + env := os.Environ() + mustGit := func(args ...string) string { + out, err := runGit(ctx, repo, env, args...) + if err != nil { + t.Fatalf("git %v: %v", args, err) + } + return strings.TrimSpace(out) + } + mustGit("init", "-q") + mustGit("config", "user.name", "t") + mustGit("config", "user.email", "t@example.com") + if err := os.WriteFile(filepath.Join(repo, "a.txt"), []byte("one\n"), 0o644); err != nil { + t.Fatal(err) + } + mustGit("add", "-A") + mustGit("commit", "-q", "-m", "one") + from = mustGit("rev-parse", "HEAD") + if err := os.WriteFile(filepath.Join(repo, "b.txt"), []byte("two\n"), 0o644); err != nil { + t.Fatal(err) + } + mustGit("add", "-A") + mustGit("commit", "-q", "-m", "two") + to = mustGit("rev-parse", "HEAD") + return repo, from, to +} + +// A hook-sandbox factory that cannot confine the workspace must fail the vet +// closed: status error, and error rejects (R5.2/R7.5). +func TestVetTreeFailsClosedWhenHookSandboxFails(t *testing.T) { + repo, from, to := vetTreeFixture(t) + host := HookHost{WrapFor: func(context.Context, string) (verify.CommandWrapFunc, func() error, error) { + return nil, nil, errors.New("no confinement available") + }} + verdict := vetTree(context.Background(), repo, vetRequest{ + host: host, + workflow: &api.Workflow{Verify: &api.Verify{Commands: []string{"true"}}}, + tier: "sidecar", task: "t-1", attempt: 1, + from: from, to: to, + }) + if verdict.Status != StatusError || !verdict.Rejects() { + t.Fatalf("verdict = %+v, want a rejecting error status", verdict) + } + if len(verdict.Findings) == 0 || !strings.Contains(verdict.Findings[0].Message, "no confinement available") { + t.Fatalf("findings = %+v, want the sandbox failure surfaced", verdict.Findings) + } +} + +// The wrapper must be constructed against the materialized tree — the hook +// command observing the pushed files proves the confinement target and the +// execution directory agree — and the factory's close must run. +func TestVetTreeBuildsTheWrapperForTheMaterializedTree(t *testing.T) { + repo, from, to := vetTreeFixture(t) + var confined string + closed := false + host := HookHost{WrapFor: func(_ context.Context, dir string) (verify.CommandWrapFunc, func() error, error) { + confined = dir + wrap := func(_ context.Context, cmd string, args, env []string) (string, []string, []string, error) { + return cmd, args, env, nil + } + return wrap, func() error { closed = true; return nil }, nil + }} + verdict := vetTree(context.Background(), repo, vetRequest{ + host: host, + workflow: &api.Workflow{Verify: &api.Verify{Commands: []string{"test -f a.txt && test -f b.txt"}}}, + tier: "sidecar", task: "t-1", attempt: 1, + from: from, to: to, + }) + if verdict.Status != StatusAccepted { + t.Fatalf("verdict = %+v, want accepted", verdict) + } + if confined == "" { + t.Fatal("the factory never saw a workspace directory") + } + if !closed { + t.Fatal("the factory's close must run after the hook set") + } +} + +// Prompt-only workflows must not construct a sandbox at all: there is no exec +// hook to confine, and a hook sandbox failure would reject pushes that run no +// commands. +func TestVetTreeSkipsTheSandboxWithoutExecHooks(t *testing.T) { + repo, from, to := vetTreeFixture(t) + host := HookHost{WrapFor: func(context.Context, string) (verify.CommandWrapFunc, func() error, error) { + t.Fatal("no sandbox may be constructed for a workflow without exec hooks") + return nil, nil, nil + }} + verdict := vetTree(context.Background(), repo, vetRequest{ + host: host, + workflow: &api.Workflow{}, + tier: "sidecar", task: "t-1", attempt: 1, + from: from, to: to, + }) + if verdict.Status != StatusAccepted { + t.Fatalf("verdict = %+v, want accepted", verdict) + } +} diff --git a/pkg/gitagent/integrate.go b/pkg/gitagent/integrate.go new file mode 100644 index 00000000..530d6de2 --- /dev/null +++ b/pkg/gitagent/integrate.go @@ -0,0 +1,130 @@ +// Integration (§10): an accepted result merges three-way against the base +// recorded in the envelope — never against current HEAD, which may have moved +// during the task (R10.1). Conflicts become structured feedback; nothing is +// auto-resolved (R10.2). +package gitagent + +import ( + "context" + "fmt" + "os" + "strings" +) + +// IntegrationResult reports where an accepted result landed. +type IntegrationResult struct { + Branch string // captain/ in the real repository + Commit string // the merge commit the branch points at + Conflict string // non-empty when the merge conflicted; Branch is then unset +} + +type integrationPlan struct { + head, tree, conflict string +} + +// checkIntegration evaluates the merge while the result is still quarantined. +// The mailbox can see both the quarantine and the real repository's objects, +// so pre-receive can reject a conflict before git reports push success. +func checkIntegration(ctx context.Context, realRepo, mailbox, base, result string, env []string) (string, error) { + head, err := runGit(ctx, realRepo, ScrubGitEnv(env), "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return "", err + } + plan, err := prepareIntegration(ctx, mailbox, env, head, base, result) + if err != nil { + return "", err + } + return plan.conflict, nil +} + +// Integrate merges result into the real repository's HEAD using base as the +// merge base and parks the outcome on refs/heads/captain/, leaving the +// user's worktree and current branch untouched. mailbox is fetched first: the +// result's objects arrived there, and the real repository has no alternates +// pointing back (R2.1 keeps that arrow one-way). +func Integrate(ctx context.Context, realRepo, mailbox, task string, attempt int, base, result string) (*IntegrationResult, error) { + branch, err := AgentBranch(task) + if err != nil { + return nil, err + } + env := ScrubGitEnv(os.Environ()) + resultRef, err := ResultRef(task, attempt) + if err != nil { + return nil, err + } + if _, err := runGit(ctx, realRepo, env, "fetch", "--quiet", "--no-write-fetch-head", mailbox, resultRef); err != nil { + return nil, err + } + head, err := runGit(ctx, realRepo, env, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return nil, err + } + plan, err := prepareIntegration(ctx, realRepo, env, head, base, result) + if err != nil { + return nil, err + } + if plan.conflict != "" { + return &IntegrationResult{Conflict: plan.conflict}, nil + } + cenv := envWith(env, + "GIT_AUTHOR_NAME=captain", + "GIT_AUTHOR_EMAIL=captain@localhost", + "GIT_COMMITTER_NAME=captain", + "GIT_COMMITTER_EMAIL=captain@localhost", + ) + merge, err := runGitIn(ctx, realRepo, cenv, + strings.NewReader(fmt.Sprintf("captain: integrate task %s\n", task)), + "commit-tree", plan.tree, "-p", plan.head, "-p", result) + if err != nil { + return nil, err + } + if _, err := runGit(ctx, realRepo, env, "update-ref", branch, merge); err != nil { + return nil, err + } + return &IntegrationResult{Branch: branch, Commit: merge}, nil +} + +// prepareIntegration computes a three-way merge tree without updating refs. +// objectRepo must be able to read head, base, and result; during pre-receive +// that is the mailbox with its quarantine environment intact. +func prepareIntegration(ctx context.Context, objectRepo string, env []string, head, base, result string) (*integrationPlan, error) { + // Older supported Git releases do not have merge-tree's --merge-base + // option. Give the current HEAD tree a synthetic parent at the envelope's + // recorded base instead: merge-tree then computes that exact base from the + // graph while leaving the user's real history and checkout untouched. + headTree, err := runGit(ctx, objectRepo, env, "rev-parse", "--verify", head+"^{tree}") + if err != nil { + return nil, err + } + cenv := envWith(env, + "GIT_AUTHOR_NAME=captain", + "GIT_AUTHOR_EMAIL=captain@localhost", + "GIT_COMMITTER_NAME=captain", + "GIT_COMMITTER_EMAIL=captain@localhost", + ) + mergeHead, err := runGitIn(ctx, objectRepo, cenv, + strings.NewReader("captain: integration merge input\n"), + "commit-tree", headTree, "-p", base) + if err != nil { + return nil, err + } + code, out, err := gitExitCode(ctx, objectRepo, env, + "merge-tree", "--write-tree", mergeHead, result) + if err != nil { + return nil, err + } + lines := strings.Split(strings.TrimSpace(out), "\n") + switch { + case code == 1: + // Content conflicts: the first line is the conflict-markered tree, + // the rest describes the conflicted paths. + detail := "merge conflict" + if len(lines) > 1 { + detail = "merge conflict in: " + strings.Join(lines[1:], ", ") + } + return &integrationPlan{head: head, conflict: detail}, nil + case code != 0 || len(lines) == 0: + return nil, fmt.Errorf("merge-tree failed (exit %d): %s", code, strings.TrimSpace(out)) + } + return &integrationPlan{head: head, tree: strings.TrimSpace(lines[0])}, nil +} diff --git a/pkg/gitagent/keys.go b/pkg/gitagent/keys.go new file mode 100644 index 00000000..d5866775 --- /dev/null +++ b/pkg/gitagent/keys.go @@ -0,0 +1,80 @@ +// Key material (§8): each agent authenticates with its own keypair, generated +// where it will live and never transiting a terminal, clipboard or log (R8.2). +package gitagent + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "os" + "path/filepath" + + gossh "golang.org/x/crypto/ssh" +) + +// EnsureKeyPair loads the OpenSSH private key at path, generating an ed25519 +// pair (0600, parent 0700) on first use. It returns the signer and the +// SHA256: fingerprint of the public half. +func EnsureKeyPair(path string) (gossh.Signer, string, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, "", err + } + var signer gossh.Signer + var fingerprint string + err := withFileLock(path+".lock", 0o600, func() error { + data, readErr := os.ReadFile(path) + if readErr == nil { + var parseErr error + signer, parseErr = gossh.ParsePrivateKey(data) + if parseErr != nil { + return fmt.Errorf("parse key %s: %w", path, parseErr) + } + fingerprint = gossh.FingerprintSHA256(signer.PublicKey()) + return nil + } + if !os.IsNotExist(readErr) { + return readErr + } + signer, fingerprint, readErr = generateKeyPair(path) + return readErr + }) + return signer, fingerprint, err + } + if err != nil { + return nil, "", err + } + signer, err := gossh.ParsePrivateKey(data) + if err != nil { + return nil, "", fmt.Errorf("parse key %s: %w", path, err) + } + return signer, gossh.FingerprintSHA256(signer.PublicKey()), nil +} + +func generateKeyPair(path string) (gossh.Signer, string, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, "", err + } + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, "", err + } + block, err := gossh.MarshalPrivateKey(priv, "captain-gitagent") + if err != nil { + return nil, "", err + } + if err := writeFileAtomic(path, pem.EncodeToMemory(block), 0o600); err != nil { + return nil, "", err + } + signer, err := gossh.NewSignerFromKey(priv) + if err != nil { + return nil, "", err + } + pub := gossh.MarshalAuthorizedKey(signer.PublicKey()) + if err := writeFileAtomic(path+".pub", pub, 0o644); err != nil { + return nil, "", err + } + return signer, gossh.FingerprintSHA256(signer.PublicKey()), nil +} diff --git a/pkg/gitagent/mailbox.go b/pkg/gitagent/mailbox.go new file mode 100644 index 00000000..99fabd2a --- /dev/null +++ b/pkg/gitagent/mailbox.go @@ -0,0 +1,176 @@ +// Repository-scoped supervisor mailboxes. One receive endpoint hosts many +// bare repositories, while each mailbox has one immutable local integration +// target and one opaque route safe to send to a sidecar. +package gitagent + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "strings" +) + +const ( + // MailboxesDir is the repository namespace served by a supervisor endpoint. + MailboxesDir = "mailboxes" + + mailboxBindingVersion = 1 + mailboxBindingFile = "repository.json" +) + +var mailboxRouteRe = regexp.MustCompile(`^mailboxes/[0-9a-f]{64}\.git$`) + +// Mailbox identifies the repository-specific receiver used by one supervisor +// worktree. Route is safe to send over the wire; Repository remains local. +type Mailbox struct { + Path string + Route string + Repository string +} + +// MailboxBinding is the durable, supervisor-local association between a bare +// mailbox and the worktree into which accepted results are integrated. +type MailboxBinding struct { + Version int `json:"version"` + Repository string `json:"repository"` +} + +// ValidateMailboxRoute limits a relayed repository path to Captain's opaque +// mailbox namespace. The route is still containment-checked by the SSH server. +func ValidateMailboxRoute(route string) error { + if !mailboxRouteRe.MatchString(route) { + return fmt.Errorf("mailbox route %q must match %s", route, mailboxRouteRe) + } + return nil +} + +// MailboxForRepository derives a stable receiver path from the canonical +// worktree root. Repositories with the same basename remain distinct. +func MailboxForRepository(ctx context.Context, servedRoot, repoDir string) (Mailbox, error) { + repository, err := canonicalRepository(ctx, repoDir) + if err != nil { + return Mailbox{}, err + } + root, err := filepath.Abs(servedRoot) + if err != nil { + return Mailbox{}, err + } + sum := sha256.Sum256([]byte(repository)) + route := path.Join(MailboxesDir, hex.EncodeToString(sum[:])+".git") + return Mailbox{ + Path: filepath.Join(root, filepath.FromSlash(route)), + Route: route, + Repository: repository, + }, nil +} + +// EnsureMailbox creates or verifies the repository-specific mailbox. A +// mailbox binding is immutable: changing it would leave existing refs backed +// by a different object store and make unrelated future pushes fail. +func EnsureMailbox(ctx context.Context, servedRoot, repoDir string) (Mailbox, error) { + mailbox, err := MailboxForRepository(ctx, servedRoot, repoDir) + if err != nil { + return Mailbox{}, err + } + if err := os.MkdirAll(filepath.Dir(mailbox.Path), 0o755); err != nil { + return Mailbox{}, err + } + if err := InitMailbox(ctx, mailbox.Path, mailbox.Repository); err != nil { + return Mailbox{}, err + } + return mailbox, nil +} + +// LoadMailboxBinding reads the integration target owned by a mailbox hook. +func LoadMailboxBinding(mailboxPath string) (MailboxBinding, error) { + data, err := os.ReadFile(filepath.Join(mailboxPath, "captain", mailboxBindingFile)) + if err != nil { + return MailboxBinding{}, fmt.Errorf("mailbox binding: %w", err) + } + var binding MailboxBinding + if err := json.Unmarshal(data, &binding); err != nil { + return MailboxBinding{}, fmt.Errorf("mailbox binding: %w", err) + } + if binding.Version != mailboxBindingVersion || !filepath.IsAbs(binding.Repository) { + return MailboxBinding{}, fmt.Errorf("mailbox binding has version %d and repository %q", binding.Version, binding.Repository) + } + return binding, nil +} + +// bindMailbox commits the repository association and alternates path under one +// lock. A partial first initialization can be completed, never redirected. +func bindMailbox(mailboxPath, repository, objects string) error { + return withFileLock(filepath.Join(mailboxPath, "captain", "mailbox.lock"), 0o600, func() error { + bindingPath := filepath.Join(mailboxPath, "captain", mailboxBindingFile) + binding, err := LoadMailboxBinding(mailboxPath) + bindingExists := err == nil + switch { + case bindingExists && binding.Repository != repository: + return fmt.Errorf("mailbox %s is bound to %s; cannot rebind it to %s", mailboxPath, binding.Repository, repository) + case err != nil && !errors.Is(err, os.ErrNotExist): + return err + } + + alternates := filepath.Join(mailboxPath, "objects", "info", "alternates") + if data, readErr := os.ReadFile(alternates); readErr == nil { + current := strings.TrimSpace(string(data)) + if current != objects { + return fmt.Errorf("mailbox %s uses object store %s; cannot rebind it to %s", mailboxPath, current, objects) + } + } else if !os.IsNotExist(readErr) { + return readErr + } + if err := os.MkdirAll(filepath.Dir(alternates), 0o755); err != nil { + return err + } + if err := writeFileAtomic(alternates, []byte(objects+"\n"), 0o644); err != nil { + return err + } + if bindingExists { + return nil + } + data, marshalErr := json.MarshalIndent(MailboxBinding{ + Version: mailboxBindingVersion, Repository: repository, + }, "", " ") + if marshalErr != nil { + return marshalErr + } + return writeFileAtomic(bindingPath, append(data, '\n'), 0o644) + }) +} + +func canonicalRepository(ctx context.Context, repoDir string) (string, error) { + root, err := runGit(ctx, repoDir, ScrubGitEnv(os.Environ()), "rev-parse", "--show-toplevel") + if err != nil { + return "", fmt.Errorf("mailbox: resolving repository root: %w", err) + } + root, err = filepath.Abs(root) + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(root) + if err != nil { + return "", fmt.Errorf("mailbox: resolving repository path %s: %w", root, err) + } + return filepath.Clean(resolved), nil +} + +func repositoryObjects(ctx context.Context, repository string) (string, error) { + gitDir, err := runGit(ctx, repository, ScrubGitEnv(os.Environ()), + "rev-parse", "--path-format=absolute", "--git-common-dir") + if err != nil { + return "", fmt.Errorf("mailbox: resolving the real repository: %w", err) + } + objects := filepath.Join(gitDir, "objects") + if _, err := os.Stat(objects); err != nil { + return "", fmt.Errorf("mailbox: real repository object store %s: %w", objects, err) + } + return objects, nil +} diff --git a/pkg/gitagent/materialize.go b/pkg/gitagent/materialize.go new file mode 100644 index 00000000..6bfcb0a5 --- /dev/null +++ b/pkg/gitagent/materialize.go @@ -0,0 +1,112 @@ +// Materialization (§1.3, R1.3/H18): read-tree + checkout-index against an +// absolute destination, never git archive (R6.5/H9), with the non-empty and +// file-count assertions that turn the silent-false-accept trap into a loud +// failure. Exit status 0 from checkout-index is not proof of materialization. +package gitagent + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Materialize checks out commitOID's full tree into dst and returns the file +// count. env should be the hook environment so quarantined objects stay +// readable; every path is absolutized before use (R1.3). +func Materialize(ctx context.Context, repoDir string, env []string, commitOID, dst string) (int, error) { + dstAbs, err := filepath.Abs(dst) + if err != nil { + return 0, err + } + if err := os.MkdirAll(dstAbs, 0o755); err != nil { + return 0, err + } + names, err := treeEntryNames(ctx, repoDir, env, commitOID) + if err != nil { + return 0, err + } + if err := RejectDotGitComponents(names); err != nil { + return 0, err + } + idxDir, err := os.MkdirTemp("", "captain-gitagent-") + if err != nil { + return 0, err + } + defer os.RemoveAll(idxDir) + ienv := envWith(env, "GIT_INDEX_FILE="+filepath.Join(idxDir, "index")) + if _, err := runGit(ctx, repoDir, ienv, "read-tree", commitOID); err != nil { + return 0, err + } + // The --work-tree + core.bare=false form is the one verified to write a + // full tree from a bare receiver during pre-receive (§1.3); --prefix + // refuses to run without a work tree there. + if _, err := runGit(ctx, repoDir, ienv, + "-c", "core.bare=false", "--work-tree="+dstAbs, "checkout-index", "-a", "-f"); err != nil { + return 0, err + } + count, err := countMaterialized(dstAbs, names) + if err != nil { + return 0, err + } + if err := AssertMaterialized(count, len(names)); err != nil { + return 0, fmt.Errorf("%w (destination %s)", err, dstAbs) + } + return count, nil +} + +func treeEntryNames(ctx context.Context, repoDir string, env []string, commitOID string) ([]string, error) { + out, err := runGitRaw(ctx, repoDir, env, nil, "ls-tree", "-r", "-z", "--name-only", commitOID) + if err != nil { + return nil, err + } + var names []string + for _, name := range strings.Split(out, "\x00") { + if name != "" { + names = append(names, name) + } + } + return names, nil +} + +// RejectDotGitComponents refuses any path with a .git component (H9 defense +// in depth — receive.fsckObjects already rejects such trees on the wire). +func RejectDotGitComponents(paths []string) error { + for _, p := range paths { + for _, component := range strings.Split(p, "/") { + if strings.EqualFold(component, ".git") { + return fmt.Errorf("tree contains a .git path component (%q); refusing to materialize (H9)", p) + } + } + } + return nil +} + +// AssertMaterialized is the H18 guard: an empty or short materialization is a +// silent false-accept, not a success. +func AssertMaterialized(got, expected int) error { + if expected == 0 { + return fmt.Errorf("refusing to verify an empty tree (H18)") + } + if got == 0 { + return fmt.Errorf("materialization wrote nothing despite checkout-index exiting 0 (H18)") + } + if got != expected { + return fmt.Errorf("materialized %d files but the tree holds %d (H18)", got, expected) + } + return nil +} + +func countMaterialized(dir string, expected []string) (int, error) { + for _, name := range expected { + info, err := os.Lstat(filepath.Join(dir, filepath.FromSlash(name))) + if err != nil { + return 0, fmt.Errorf("materialized path %q: %w", name, err) + } + if info.IsDir() { + return 0, fmt.Errorf("materialized path %q is a directory, not a tree entry", name) + } + } + return len(expected), nil +} diff --git a/pkg/gitagent/proxy/grants.go b/pkg/gitagent/proxy/grants.go new file mode 100644 index 00000000..b7cb9db8 --- /dev/null +++ b/pkg/gitagent/proxy/grants.go @@ -0,0 +1,166 @@ +// Package proxy is the egress credential proxy (SPEC-git-agent-protocol §9): +// the sandbox never holds a real credential, only a placeholder; the proxy +// substitutes the real value on the way out, and only when the placeholder +// appears in exactly the granted header for the granted destination. A +// placeholder anywhere else is an exfiltration attempt and rejects loudly +// (R9.2). The proxy protects the credential's value, not its capability — +// grants are scoped by method and path prefix because a host-level allowlist +// still permits POST /gists (R9.6/H7). +package proxy + +import ( + "fmt" + "net/url" + "path" + "strings" + + "github.com/flanksource/commons-db/types" +) + +// PlaceholderPrefix starts every placeholder the sandbox sees. +const PlaceholderPrefix = "captain-placeholder-" + +// HeaderGrant names one header that may carry a credential, and where its +// real value comes from. The value is a types.EnvVar (A4.1): static, or +// resolved from a secret store at request time — the proxy never learns +// anything about storage. +type HeaderGrant struct { + Name string `json:"name" yaml:"name"` + Value types.EnvVar `json:"valueFrom,omitempty" yaml:"valueFrom,omitempty"` +} + +// Grant is one destination the sandbox may reach: host, methods, path +// prefixes, and the headers that may carry credentials to it. Destinations +// are deny-by-default — no grant, no request (R9.3; the network layer must +// additionally block non-HTTP egress, which no proxy can see). +type Grant struct { + Name string `json:"name" yaml:"name"` // stable id; part of the placeholder + URL string `json:"url" yaml:"url"` // scheme://host[:port] + Methods []string `json:"methods,omitempty" yaml:"methods,omitempty"` + Paths []string `json:"paths,omitempty" yaml:"paths,omitempty"` + Headers []HeaderGrant `json:"headers,omitempty" yaml:"headers,omitempty"` +} + +// Placeholder returns the sandbox-visible stand-in for one granted header. +func (g Grant) Placeholder(header string) string { + return PlaceholderPrefix + g.Name + "-" + strings.ToLower(header) +} + +// Validate checks the grant's shape. +func (g Grant) Validate() error { + if g.Name == "" || strings.ContainsAny(g.Name, " /") { + return fmt.Errorf("grant name %q must be a bare identifier", g.Name) + } + u, err := url.Parse(g.URL) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("grant %s: url %q must be http(s)://host[:port]", g.Name, g.URL) + } + if u.Path != "" && u.Path != "/" { + return fmt.Errorf("grant %s: scope paths belong in paths, not the url", g.Name) + } + for _, h := range g.Headers { + if strings.TrimSpace(h.Name) == "" { + return fmt.Errorf("grant %s: header grant with no name", g.Name) + } + } + return nil +} + +func (g Grant) host() string { + u, err := url.Parse(g.URL) + if err != nil { + return "" + } + host := u.Host + if u.Port() == "" { + if u.Scheme == "https" { + host += ":443" + } else { + host += ":80" + } + } + return host +} + +func (g Grant) rawHost() string { + u, err := url.Parse(g.URL) + if err != nil { + return "" + } + return u.Host +} + +func (g Grant) scheme() string { + u, err := url.Parse(g.URL) + if err != nil { + return "https" + } + return u.Scheme +} + +// AllowsMethod reports whether the grant permits the method; an empty list +// permits none — scope is mandatory, not advisory (R9.6). +func (g Grant) AllowsMethod(method string) bool { + for _, m := range g.Methods { + if strings.EqualFold(m, method) { + return true + } + } + return false +} + +// AllowsPath reports whether the path lies under a granted prefix. +func (g Grant) AllowsPath(path string) bool { + for _, p := range g.Paths { + prefix := pathpkgClean(p) + request := pathpkgClean(path) + if prefix == "/" || request == prefix || strings.HasPrefix(request, prefix+"/") { + return true + } + } + return false +} + +func pathpkgClean(value string) string { + if !strings.HasPrefix(value, "/") { + value = "/" + value + } + return path.Clean(value) +} + +// grantedHeader returns the header grant for name, if any. +func (g Grant) grantedHeader(name string) (HeaderGrant, bool) { + for _, h := range g.Headers { + if strings.EqualFold(h.Name, name) { + return h, true + } + } + return HeaderGrant{}, false +} + +// SandboxEnv renders the placeholder environment for a set of grants: one +// _
entry per granted header, all placeholders, no values. +func SandboxEnv(grants []Grant) []string { + var env []string + for _, g := range grants { + for _, h := range g.Headers { + key := strings.ToUpper(strings.ReplaceAll(g.Name+"_"+h.Name, "-", "_")) + env = append(env, key+"="+g.Placeholder(h.Name)) + } + } + return env +} + +// Resolver turns a header grant's EnvVar into the real value at request +// time. StaticResolver handles inline values; richer resolvers plug in +// secret stores. +type Resolver func(types.EnvVar) (string, error) + +// StaticResolver resolves only inline `value:` credentials, failing loudly on +// anything it cannot resolve — the placeholder is never forwarded (R9.4). +func StaticResolver(v types.EnvVar) (string, error) { + if v.ValueStatic != "" { + return v.ValueStatic, nil + } + return "", fmt.Errorf("credential is not statically resolvable (valueFrom requires a store-backed resolver)") +} diff --git a/pkg/gitagent/proxy/proxy.go b/pkg/gitagent/proxy/proxy.go new file mode 100644 index 00000000..74823d86 --- /dev/null +++ b/pkg/gitagent/proxy/proxy.go @@ -0,0 +1,285 @@ +package proxy + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "net/http" + "path" + "strings" + "sync" + "time" +) + +// maxScannedBody bounds how much request body is scanned for misplaced +// placeholders. Response bodies are deliberately not scanned: response-body +// redaction is telemetry, not a control (§9 non-goal). +const maxScannedBody = 1 << 20 + +// Decision is one audit record. Values are never logged (R9.5). +type Decision struct { + Time time.Time + Method string + Destination string + Header string // the substituted or offending header, when relevant + Verdict string // forwarded | rejected | error + Reason string +} + +// Proxy is the egress credential proxy: an HTTP forward proxy that +// substitutes placeholders in exactly the granted position and rejects them +// anywhere else. CONNECT is refused — tunneled TLS would blind the proxy, and +// the sandbox has no legitimate need to hide its requests from it. +type Proxy struct { + Grants []Grant + Resolve Resolver + // Audit receives every decision; nil discards. Never receives a value. + Audit func(Decision) + // Dialer resolves and dials upstream. The proxy resolves DNS itself and + // the TLS layer validates the certificate against the granted host name — + // never the sandbox-controlled Host header or SNI (R9.1). + Dialer *net.Dialer + once sync.Once + rt *http.Transport +} + +func (p *Proxy) audit(d Decision) { + d.Time = time.Now() + if p.Audit != nil { + p.Audit(d) + } +} + +func (p *Proxy) resolve(v HeaderGrant) (string, error) { + resolver := p.Resolve + if resolver == nil { + resolver = StaticResolver + } + return resolver(v.Value) +} + +func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + p.audit(Decision{Method: r.Method, Destination: r.Host, Verdict: "rejected", Reason: "CONNECT is not served; requests must be inspectable"}) + http.Error(w, "captain-proxy: CONNECT is not served", http.StatusForbidden) + return + } + if !r.URL.IsAbs() { + p.audit(Decision{Method: r.Method, Destination: r.Host, Verdict: "rejected", Reason: "not an absolute-form proxy request"}) + http.Error(w, "captain-proxy: absolute-form proxy requests only", http.StatusBadRequest) + return + } + canonicalPath, canonical := canonicalRequestPath(r.URL.Path) + if !canonical { + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "rejected", Reason: "request path is not canonical (R9.6)"}) + http.Error(w, "captain-proxy: request path is not canonical", http.StatusForbidden) + return + } + grants := p.grantsFor(r.URL.Hostname(), r.URL.Port(), r.URL.Scheme) + if len(grants) == 0 { + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "rejected", Reason: "destination not granted (deny by default)"}) + http.Error(w, "captain-proxy: destination not granted", http.StatusForbidden) + return + } + grant, ok := scopedGrantFor(grants, r, canonicalPath) + if !ok { + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "rejected", Reason: "method or path outside the grant's scope (R9.6)"}) + http.Error(w, "captain-proxy: method or path outside the grant's scope", http.StatusForbidden) + return + } + if reason := p.findMisplacedPlaceholder(r, grant); reason != "" { + // Rejected and logged, never stripped-and-forwarded: the appearance + // is an exfiltration attempt and silently continuing hides it (R9.2). + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "rejected", Reason: reason}) + http.Error(w, "captain-proxy: "+reason, http.StatusForbidden) + return + } + if len(grant.Headers) > 0 && grant.scheme() != "https" { + reason := "credential-bearing grants require HTTPS" + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "rejected", Reason: reason}) + http.Error(w, "captain-proxy: "+reason, http.StatusForbidden) + return + } + substituted, err := p.substitute(r, grant) + if err != nil { + // A credential that fails to resolve fails the request loudly; the + // placeholder is never forwarded upstream (R9.4). + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "error", Reason: err.Error()}) + http.Error(w, "captain-proxy: credential resolution failed", http.StatusBadGateway) + return + } + p.forward(w, r, grant, substituted) +} + +// grantsFor returns every grant for a destination. Scope selection happens +// separately so one destination can carry independent capabilities. +func (p *Proxy) grantsFor(host, port, scheme string) []Grant { + if port == "" { + if scheme == "https" { + port = "443" + } else { + port = "80" + } + } + var matches []Grant + for _, g := range p.Grants { + if g.host() == net.JoinHostPort(host, port) && g.scheme() == scheme { + matches = append(matches, g) + } + } + return matches +} + +func scopedGrantFor(grants []Grant, r *http.Request, requestPath string) (Grant, bool) { + var scoped []Grant + for _, grant := range grants { + if grant.AllowsMethod(r.Method) && grant.AllowsPath(requestPath) { + scoped = append(scoped, grant) + } + } + for _, grant := range scoped { + for _, header := range grant.Headers { + if r.Header.Get(header.Name) == grant.Placeholder(header.Name) { + return grant, true + } + } + } + if len(scoped) > 0 { + return scoped[0], true + } + return Grant{}, false +} + +func canonicalRequestPath(requestPath string) (string, bool) { + if requestPath == "" { + requestPath = "/" + } + cleaned := path.Clean(requestPath) + if strings.HasSuffix(requestPath, "/") && cleaned != "/" { + cleaned += "/" + } + return cleaned, cleaned == requestPath +} + +// findMisplacedPlaceholder scans headers, URL and a bounded body prefix for +// any placeholder that is not this grant's placeholder in this grant's +// header. It returns the rejection reason, or "". +func (p *Proxy) findMisplacedPlaceholder(r *http.Request, grant Grant) string { + if strings.Contains(r.URL.String(), PlaceholderPrefix) { + return "credential placeholder in the URL is an exfiltration attempt (R9.2)" + } + for name, values := range r.Header { + for _, value := range values { + if !strings.Contains(value, PlaceholderPrefix) { + continue + } + granted, ok := grant.grantedHeader(name) + if !ok || value != grant.Placeholder(granted.Name) { + return fmt.Sprintf("credential placeholder in header %s is not granted for this destination (R9.2)", name) + } + } + } + if r.Body != nil { + var scanned bytes.Buffer + _, _ = scanned.ReadFrom(io.LimitReader(r.Body, maxScannedBody)) + body := scanned.Bytes() + rest := r.Body + r.Body = readCloser{io.MultiReader(bytes.NewReader(body), rest), rest} + if bytes.Contains(body, []byte(PlaceholderPrefix)) { + return "credential placeholder in the request body is an exfiltration attempt (R9.2)" + } + } + return "" +} + +// substitute swaps each granted header's placeholder for its resolved value, +// returning the headers it substituted. +func (p *Proxy) substitute(r *http.Request, grant Grant) ([]string, error) { + var substituted []string + for _, h := range grant.Headers { + value := r.Header.Get(h.Name) + if value != grant.Placeholder(h.Name) { + continue // header absent or carrying the caller's own value + } + real, err := p.resolve(h) + if err != nil { + return nil, fmt.Errorf("header %s: %w", h.Name, err) + } + r.Header.Set(h.Name, real) + substituted = append(substituted, h.Name) + } + return substituted, nil +} + +// forward relays the request upstream, resolving DNS itself and letting TLS +// validate against the granted host name (R9.1). +func (p *Proxy) forward(w http.ResponseWriter, r *http.Request, grant Grant, substituted []string) { + transport := p.transport() + + out := r.Clone(r.Context()) + out.RequestURI = "" + out.URL.Scheme = grant.scheme() + out.URL.Host = grant.rawHost() + out.Host = out.URL.Host + + resp, err := transport.RoundTrip(out) + if err != nil { + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "error", Reason: "upstream: " + err.Error()}) + http.Error(w, "captain-proxy: upstream unreachable", http.StatusBadGateway) + return + } + defer resp.Body.Close() + for _, name := range substituted { + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Header: name, Verdict: "forwarded", Reason: "placeholder substituted"}) + } + if len(substituted) == 0 { + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "forwarded", Reason: "no credential involved"}) + } + for name, values := range resp.Header { + for _, v := range values { + w.Header().Add(name, v) + } + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + +func (p *Proxy) transport() *http.Transport { + p.once.Do(func() { + dialer := p.Dialer + if dialer == nil { + dialer = &net.Dialer{Timeout: 30 * time.Second} + } + p.rt = &http.Transport{ + Proxy: nil, // never chain through environment proxies + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 60 * time.Second, + IdleConnTimeout: 90 * time.Second, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + // addr is the granted host:port (the URL was matched against the + // grant). Resolve it ourselves rather than trusting anything the + // sandbox controls as identity. + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupHost(ctx, host) + if err != nil { + return nil, fmt.Errorf("resolving %s: %w", host, err) + } + if len(ips) == 0 { + return nil, fmt.Errorf("resolving %s: no addresses", host) + } + return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0], port)) + }, + } + }) + return p.rt +} + +type readCloser struct { + io.Reader + io.Closer +} diff --git a/pkg/gitagent/proxy/proxy_test.go b/pkg/gitagent/proxy/proxy_test.go new file mode 100644 index 00000000..6f8813a5 --- /dev/null +++ b/pkg/gitagent/proxy/proxy_test.go @@ -0,0 +1,325 @@ +package proxy + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/flanksource/commons-db/types" +) + +// world spins a TLS upstream and a proxy granting it. Requests invoke the +// handler in absolute form because ordinary HTTP clients tunnel TLS with +// CONNECT, which this inspectable proxy deliberately refuses. +type world struct { + upstream *httptest.Server + proxy *httptest.Server + handler *Proxy + grant Grant + mu sync.Mutex + requests []*http.Request + headers []http.Header + decisions []Decision +} + +func newWorld(t *testing.T, secret string) *world { + t.Helper() + w := &world{} + w.upstream = httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + w.mu.Lock() + w.requests = append(w.requests, r.Clone(r.Context())) + w.headers = append(w.headers, r.Header.Clone()) + w.mu.Unlock() + rw.Header().Set("X-Upstream", "yes") + rw.WriteHeader(200) + _, _ = rw.Write([]byte("upstream says hi")) + })) + t.Cleanup(w.upstream.Close) + + w.grant = Grant{ + Name: "gh", + URL: w.upstream.URL, + Methods: []string{"GET", "POST"}, + Paths: []string{"/repos/acme/"}, + Headers: []HeaderGrant{{Name: "Authorization", Value: types.EnvVar{ValueStatic: secret}}}, + } + if err := w.grant.Validate(); err != nil { + t.Fatal(err) + } + p := &Proxy{ + Grants: []Grant{w.grant}, + Audit: func(d Decision) { + w.mu.Lock() + w.decisions = append(w.decisions, d) + w.mu.Unlock() + }, + } + trusted := w.upstream.Client().Transport.(*http.Transport).Clone() + trusted.Proxy = nil + p.once.Do(func() { p.rt = trusted }) + w.handler = p + w.proxy = httptest.NewServer(p) + t.Cleanup(w.proxy.Close) + t.Cleanup(trusted.CloseIdleConnections) + return w +} + +func (w *world) do(t *testing.T, method, target string, headers map[string]string, body string) *http.Response { + t.Helper() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + req, err := http.NewRequest(method, target, reader) + if err != nil { + t.Fatal(err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + recorder := httptest.NewRecorder() + w.handler.ServeHTTP(recorder, req) + resp := recorder.Result() + t.Cleanup(func() { resp.Body.Close() }) + return resp +} + +func (w *world) upstreamHeaderValues(name string) []string { + w.mu.Lock() + defer w.mu.Unlock() + var values []string + for _, h := range w.headers { + values = append(values, h.Get(name)) + } + return values +} + +func (w *world) auditDecisions() []Decision { + w.mu.Lock() + defer w.mu.Unlock() + return append([]Decision(nil), w.decisions...) +} + +func (w *world) upstreamRequestCount() int { + w.mu.Lock() + defer w.mu.Unlock() + return len(w.requests) +} + +const secret = "ghp_real_secret_value" + +func TestSubstitutesOnlyInTheGrantedPosition(t *testing.T) { + w := newWorld(t, secret) + placeholder := w.grant.Placeholder("Authorization") + + resp := w.do(t, "GET", w.upstream.URL+"/repos/acme/captain", map[string]string{"Authorization": placeholder}, "") + if resp.StatusCode != 200 { + t.Fatalf("status = %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if string(body) != "upstream says hi" { + t.Fatalf("body = %q", body) + } + values := w.upstreamHeaderValues("Authorization") + if len(values) != 1 || values[0] != secret { + t.Fatalf("upstream saw Authorization = %v, want the real value", values) + } +} + +func TestCredentialBearingHTTPGrantIsRejectedBeforeResolution(t *testing.T) { + hits := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + hits++ + rw.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + grant := Grant{ + Name: "cleartext", URL: upstream.URL, + Methods: []string{"GET"}, Paths: []string{"/repos/"}, + Headers: []HeaderGrant{{Name: "Authorization", Value: types.EnvVar{ValueStatic: secret}}}, + } + resolved := false + var decisions []Decision + p := &Proxy{ + Grants: []Grant{grant}, + Resolve: func(types.EnvVar) (string, error) { + resolved = true + return secret, nil + }, + Audit: func(d Decision) { decisions = append(decisions, d) }, + } + req, _ := http.NewRequest("GET", upstream.URL+"/repos/acme", nil) + req.Header.Set("Authorization", grant.Placeholder("Authorization")) + recorder := httptest.NewRecorder() + p.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", recorder.Code) + } + if hits != 0 || resolved { + t.Fatalf("upstream hits = %d, resolved = %v; cleartext credentials must not leave the proxy", hits, resolved) + } + if len(decisions) != 1 || decisions[0].Verdict != "rejected" || !strings.Contains(decisions[0].Reason, "HTTPS") { + t.Fatalf("decisions = %+v", decisions) + } +} + +func TestPlaceholderInNonGrantedHeaderIsRejectedNotStripped(t *testing.T) { + w := newWorld(t, secret) + placeholder := w.grant.Placeholder("Authorization") + + resp := w.do(t, "GET", w.upstream.URL+"/repos/acme/captain", + map[string]string{"X-Exfil": placeholder}, "") + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d, want 403", resp.StatusCode) + } + if n := len(w.upstreamHeaderValues("X-Exfil")); n != 0 { + t.Fatalf("upstream was contacted %d times; a rejected request must never be forwarded", n) + } + found := false + for _, d := range w.auditDecisions() { + if d.Verdict == "rejected" && strings.Contains(d.Reason, "R9.2") { + found = true + if strings.Contains(d.Reason, secret) || strings.Contains(d.Reason, placeholder) { + t.Fatal("the audit log must not carry values") + } + } + } + if !found { + t.Fatal("the rejection must be logged (R9.5)") + } +} + +func TestPlaceholderInBodyOrURLIsRejected(t *testing.T) { + w := newWorld(t, secret) + placeholder := w.grant.Placeholder("Authorization") + + resp := w.do(t, "POST", w.upstream.URL+"/repos/acme/captain", nil, `{"note":"`+placeholder+`"}`) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("body placeholder: status = %d, want 403", resp.StatusCode) + } + resp = w.do(t, "GET", w.upstream.URL+"/repos/acme/captain?x="+placeholder, nil, "") + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("url placeholder: status = %d, want 403", resp.StatusCode) + } + if w.upstreamRequestCount() != 0 { + t.Fatal("nothing may reach upstream") + } +} + +func TestNonGrantedDestinationIsDenied(t *testing.T) { + w := newWorld(t, secret) + other := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(200) + })) + defer other.Close() + + // Including with the placeholder: granted for host A, sent to host B. + resp := w.do(t, "GET", other.URL+"/anything", + map[string]string{"Authorization": w.grant.Placeholder("Authorization")}, "") + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d, want 403 (deny by default)", resp.StatusCode) + } +} + +func TestScopeByMethodAndPath(t *testing.T) { + w := newWorld(t, secret) + resp := w.do(t, "DELETE", w.upstream.URL+"/repos/acme/captain", nil, "") + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("method outside scope: status = %d, want 403 (R9.6)", resp.StatusCode) + } + resp = w.do(t, "POST", w.upstream.URL+"/gists", nil, "") + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("path outside scope: status = %d, want 403 (R9.6/H7)", resp.StatusCode) + } + req := httptest.NewRequest("POST", w.upstream.URL+"/repos/acme/../gists", nil) + recorder := httptest.NewRecorder() + (&Proxy{Grants: []Grant{w.grant}}).ServeHTTP(recorder, req) + if recorder.Code != http.StatusForbidden { + t.Fatalf("dot-segment escape: status = %d, want 403", recorder.Code) + } +} + +func TestUnresolvableCredentialFailsTheRequest(t *testing.T) { + w := newWorld(t, secret) + unresolvable := w.grant + unresolvable.Headers = []HeaderGrant{{Name: "Authorization"}} + w.handler.Grants = []Grant{unresolvable} + + resp := w.do(t, "GET", w.upstream.URL+"/repos/acme/captain", + map[string]string{"Authorization": w.grant.Placeholder("Authorization")}, "") + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("status = %d, want 502 (R9.4)", resp.StatusCode) + } + for _, v := range w.upstreamHeaderValues("Authorization") { + if strings.Contains(v, PlaceholderPrefix) { + t.Fatal("the placeholder must never be forwarded upstream (R9.4)") + } + } +} + +func TestLaterGrantForSameDestinationCanAuthorize(t *testing.T) { + w := newWorld(t, secret) + otherScope := w.grant + otherScope.Name = "other" + otherScope.Paths = []string{"/other/"} + w.handler.Grants = []Grant{otherScope, w.grant} + resp := w.do(t, "GET", w.upstream.URL+"/repos/acme/captain", nil, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } +} + +func TestGrantRawHostPreservesExplicitCrossSchemePort(t *testing.T) { + for _, test := range []struct { + url string + host string + }{ + {"http://example.com:443", "example.com:443"}, + {"https://example.com:80", "example.com:80"}, + } { + if got := (Grant{URL: test.url}).rawHost(); got != test.host { + t.Fatalf("rawHost(%q) = %q, want %q", test.url, got, test.host) + } + } +} + +func TestProxyReusesTransport(t *testing.T) { + p := &Proxy{} + if first, second := p.transport(), p.transport(); first != second { + t.Fatal("transport was rebuilt") + } +} + +func TestConnectIsRefused(t *testing.T) { + w := newWorld(t, secret) + req, _ := http.NewRequest(http.MethodConnect, w.proxy.URL, nil) + req.URL.Opaque = "example.com:443" + resp, err := http.DefaultTransport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("CONNECT: status = %d, want 403", resp.StatusCode) + } +} + +func TestSandboxEnvHoldsOnlyPlaceholders(t *testing.T) { + w := newWorld(t, secret) + env := SandboxEnv([]Grant{w.grant}) + if len(env) != 1 { + t.Fatalf("env = %v", env) + } + if env[0] != "GH_AUTHORIZATION="+w.grant.Placeholder("Authorization") { + t.Fatalf("env = %v", env) + } + for _, kv := range env { + if strings.Contains(kv, secret) { + t.Fatal("the real secret must be absent from the sandbox environment") + } + } +} diff --git a/pkg/gitagent/receiver.go b/pkg/gitagent/receiver.go new file mode 100644 index 00000000..4e20e1f6 --- /dev/null +++ b/pkg/gitagent/receiver.go @@ -0,0 +1,169 @@ +// Receiver repositories: the supervisor-side mailbox and the agent-side +// sidecar bare repo. Both carry the mandated config (R2.2); the mailbox +// additionally shares the real repository's object store via alternates so +// protocol refs never pollute the user's working repo (R2.1/H8). + +package gitagent + +import ( + "context" + "os" + "path/filepath" + "strconv" + + "golang.org/x/sys/unix" +) + +// ReceiverRole distinguishes the two admission tiers. +type ReceiverRole string + +const ( + RoleMailbox ReceiverRole = "mailbox" + RoleSidecar ReceiverRole = "sidecar" +) + +// DefaultMaxInputSize caps a single incoming pack (R2.2/H10). +const DefaultMaxInputSize = int64(512 << 20) + +// receiverConfig is the R2.2 block every receiving repo MUST carry. +func receiverConfig(maxInputSize int64) [][2]string { + return [][2]string{ + {"receive.advertisePushOptions", "true"}, + {"receive.fsckObjects", "true"}, + {"receive.denyDeletes", "true"}, + {"receive.autogc", "false"}, + {"core.logAllRefUpdates", "always"}, + {"receive.maxInputSize", strconv.FormatInt(maxInputSize, 10)}, + } +} + +// InitMailbox creates a bare mailbox bound to one canonical worktree. Repeated +// initialization is safe only for that same worktree; rebinding is refused so +// existing refs never lose the object store that backs them. +func InitMailbox(ctx context.Context, path, realRepo string) error { + repository, err := canonicalRepository(ctx, realRepo) + if err != nil { + return err + } + objects, err := repositoryObjects(ctx, repository) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(path, "captain"), 0o755); err != nil { + return err + } + return withFileLock(filepath.Join(path, "captain", "init.lock"), 0o600, func() error { + if err := initReceiver(ctx, path); err != nil { + return err + } + return bindMailbox(path, repository, objects) + }) +} + +// InitSidecar creates the bare sidecar repo at path. +func InitSidecar(ctx context.Context, path string) error { + return initReceiver(ctx, path) +} + +func initReceiver(ctx context.Context, path string) error { + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + env := ScrubGitEnv(os.Environ()) + if _, err := runGit(ctx, path, env, "init", "--quiet", "--bare"); err != nil { + return err + } + // A receiver must always run the shims installed in its own hooks + // directory. Without a repository-local override, the pusher's global + // core.hooksPath can silently bypass admission, vetting, and relay. + hooksPath, err := filepath.Abs(filepath.Join(path, "hooks")) + if err != nil { + return err + } + if _, err := runGit(ctx, path, env, "config", "core.hooksPath", hooksPath); err != nil { + return err + } + for _, kv := range receiverConfig(DefaultMaxInputSize) { + if _, err := runGit(ctx, path, env, "config", kv[0], kv[1]); err != nil { + return err + } + } + return os.MkdirAll(filepath.Join(path, "captain"), 0o755) +} + +// PruneWorktrees runs `git worktree prune` in every bare repo directly under +// root, reclaiming worktrees orphaned by a crashed hook (R10.3). Failures are +// deliberately non-fatal: pruning is hygiene, not a serving precondition. +func PruneWorktrees(ctx context.Context, root string) { + entries, err := os.ReadDir(root) + if err != nil { + return + } + env := ScrubGitEnv(os.Environ()) + for _, e := range entries { + if !e.IsDir() { + continue + } + repo := filepath.Join(root, e.Name()) + if _, err := os.Stat(filepath.Join(repo, "HEAD")); err != nil { + continue + } + _, _ = runGit(ctx, repo, env, "worktree", "prune") + } +} + +// writeFileAtomic writes via a sibling temp file and rename, so a reader +// never observes a partial file. +func writeFileAtomic(path string, data []byte, mode os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*") + if err != nil { + return err + } + name := tmp.Name() + committed := false + defer func() { + if !committed { + _ = os.Remove(name) + } + }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(mode); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(name, path); err != nil { + return err + } + committed = true + dir, err := os.Open(filepath.Dir(path)) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} + +// withFileLock serializes a durable state transition across hook and CLI +// processes. The caller creates the parent directory before entering. +func withFileLock(path string, mode os.FileMode, fn func() error) error { + lock, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, mode) + if err != nil { + return err + } + defer lock.Close() + if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX); err != nil { + return err + } + defer func() { _ = unix.Flock(int(lock.Fd()), unix.LOCK_UN) }() + return fn() +} diff --git a/pkg/gitagent/receiver_ginkgo_test.go b/pkg/gitagent/receiver_ginkgo_test.go new file mode 100644 index 00000000..d1865161 --- /dev/null +++ b/pkg/gitagent/receiver_ginkgo_test.go @@ -0,0 +1,120 @@ +package gitagent_test + +import ( + "context" + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent" +) + +var _ = Describe("receiver repositories", func() { + ctx := context.Background() + + It("configures a sidecar with the R2.2 block, idempotently", func() { + path := filepath.Join(GinkgoT().TempDir(), "repo.git") + Expect(gitagent.InitSidecar(ctx, path)).To(Succeed()) + Expect(gitagent.InitSidecar(ctx, path)).To(Succeed(), "re-running must be safe") + + for key, want := range map[string]string{ + "receive.advertisepushoptions": "true", + "receive.fsckobjects": "true", + "receive.denydeletes": "true", + "receive.autogc": "false", + "core.logallrefupdates": "always", + } { + Expect(gitT(path, "config", key)).To(Equal(want), key) + } + Expect(gitT(path, "config", "core.hookspath")).To(Equal(filepath.Join(path, "hooks")), + "a pusher's global core.hooksPath must not bypass receiver hooks") + Expect(gitT(path, "config", "receive.maxinputsize")).NotTo(Equal("0"), "maxInputSize must be finite") + }) + + It("namespaces mailboxes by canonical repository and refuses rebinding", func() { + servedRoot := GinkgoT().TempDir() + makeRepo := func(parent string) string { + repo := filepath.Join(parent, "project") + Expect(os.MkdirAll(repo, 0o755)).To(Succeed()) + gitT(repo, "init", "-q") + writeFileT(repo, "a.txt", repo+"\n") + gitT(repo, "add", "-A") + gitT(repo, "commit", "-q", "-m", "base") + return repo + } + repoA := makeRepo(GinkgoT().TempDir()) + repoB := makeRepo(GinkgoT().TempDir()) + mailboxA, err := gitagent.EnsureMailbox(ctx, servedRoot, repoA) + Expect(err).NotTo(HaveOccurred()) + mailboxB, err := gitagent.EnsureMailbox(ctx, servedRoot, repoB) + Expect(err).NotTo(HaveOccurred()) + Expect(mailboxA.Route).NotTo(Equal(mailboxB.Route), "same basenames must not collide") + Expect(mailboxA.Route).To(HavePrefix(gitagent.MailboxesDir + "/")) + + binding, err := gitagent.LoadMailboxBinding(mailboxA.Path) + Expect(err).NotTo(HaveOccurred()) + Expect(binding.Repository).To(Equal(repoA)) + Expect(gitagent.InitMailbox(ctx, mailboxA.Path, repoB)). + To(MatchError(ContainSubstring("cannot rebind"))) + }) + + It("shares the real repository's objects with the mailbox via alternates", func() { + real := GinkgoT().TempDir() + gitT(real, "init", "-q") + writeFileT(real, "a.txt", "a\n") + gitT(real, "add", "-A") + gitT(real, "commit", "-q", "-m", "base") + head := gitT(real, "rev-parse", "HEAD") + + mailbox := filepath.Join(GinkgoT().TempDir(), "mailbox.git") + Expect(gitagent.InitMailbox(ctx, mailbox, real)).To(Succeed()) + Expect(gitagent.InitMailbox(ctx, mailbox, real)).To(Succeed()) + + alternates, err := os.ReadFile(filepath.Join(mailbox, "objects", "info", "alternates")) + Expect(err).NotTo(HaveOccurred()) + Expect(strings.TrimSpace(string(alternates))).To(HaveSuffix(filepath.Join(".git", "objects"))) + // The real repo's commits are readable without any copy. + Expect(gitT(mailbox, "cat-file", "-t", head)).To(Equal("commit")) + }) +}) + +var _ = Describe("hook shims", func() { + It("installs idempotent pre/post-receive shims and refuses to clobber foreign hooks", func() { + ctx := context.Background() + repo := filepath.Join(GinkgoT().TempDir(), "repo.git") + Expect(gitagent.InitSidecar(ctx, repo)).To(Succeed()) + + Expect(gitagent.InstallHookShims(repo, "/usr/local/bin/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar, "worker-pool")).To(Succeed()) + Expect(gitagent.InstallHookShims(repo, "/usr/local/bin/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar, "worker-pool")).To(Succeed()) + + for _, hook := range []string{"pre-receive", "post-receive"} { + path := filepath.Join(repo, "hooks", hook) + info, err := os.Stat(path) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode() & 0o111).NotTo(BeZero()) + content, err := os.ReadFile(path) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("/usr/local/bin/captain")) + Expect(string(content)).To(ContainSubstring("--role \"sidecar\"")) + Expect(string(content)).To(ContainSubstring("--backend \"worker-pool\"")) + // A hook runs as a child of whoever pushed, so its config path is + // baked in rather than resolved from an ambient $HOME. + Expect(string(content)).To(ContainSubstring("--config \"/home/agent/.captain.yaml\"")) + } + + // A rebinned captain updates the shim in place. + Expect(gitagent.InstallHookShims(repo, "/opt/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar, "worker-pool")).To(Succeed()) + content, err := os.ReadFile(filepath.Join(repo, "hooks", "pre-receive")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("/opt/captain")) + + // A hook captain did not install is never overwritten. + foreign := filepath.Join(repo, "hooks", "pre-receive") + Expect(os.WriteFile(foreign, []byte("#!/bin/sh\nexit 0\n"), 0o755)).To(Succeed()) + err = gitagent.InstallHookShims(repo, "/opt/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar, "worker-pool") + Expect(err).To(MatchError(ContainSubstring("not installed by captain"))) + }) +}) diff --git a/pkg/gitagent/refs.go b/pkg/gitagent/refs.go new file mode 100644 index 00000000..ead682c4 --- /dev/null +++ b/pkg/gitagent/refs.go @@ -0,0 +1,167 @@ +// Package gitagent implements the git-agent protocol v1: dispatching a unit +// of work to a coding agent on another machine and vetting the result using +// nothing but git (issue #40, SPEC-git-agent-protocol). +// +// This file is the ref layer — pure data, no git invocation. The two hops are +// deliberately asymmetric: the agent pushes an ordinary branch +// (refs/heads/captain/), while the sidecar↔supervisor hop is an +// append-only audit trail under refs/captain/tasks/. +package gitagent + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +const ( + // taskRefPrefix roots the machine-to-machine audit refs. These land in a + // mailbox repo, never the user's working repository (R2.1). + taskRefPrefix = "refs/captain/tasks/" + + // agentBranchPrefix roots the agent↔sidecar hop: a plain branch the agent + // pushes with no protocol awareness (R3.1). + agentBranchPrefix = "refs/heads/captain/" + + // MaxAttempt bounds attempt numbers well above any real retry budget so a + // hostile ref name cannot drive integer growth. + MaxAttempt = 999999 +) + +// RefKind names the four protocol ref roles under a task namespace. +type RefKind string + +const ( + RefDispatch RefKind = "dispatch" // code: supervisor's snapshot, parent = base + RefControl RefKind = "control" // control: task.json / hooks.json / policy.json + RefResult RefKind = "result" // code: the agent's work, parent = dispatch + RefVerdict RefKind = "verdict" // control: verdict.json + log +) + +var refKinds = map[RefKind]bool{ + RefDispatch: true, + RefControl: true, + RefResult: true, + RefVerdict: true, +} + +var ( + taskIDRe = regexp.MustCompile(`^[a-z0-9-]{1,64}$`) + // Attempts are positive decimals with no leading zeros (§3.2). + attemptRe = regexp.MustCompile(`^[1-9][0-9]{0,5}$`) +) + +// ValidateTaskID enforces the §3.2 task-id shape: ^[a-z0-9-]{1,64}$. +func ValidateTaskID(task string) error { + if !taskIDRe.MatchString(task) { + return fmt.Errorf("task id %q must match %s", task, taskIDRe) + } + return nil +} + +// ParseAttempt parses an attempt number: a positive decimal integer with no +// leading zeros, bounded by MaxAttempt. +func ParseAttempt(s string) (int, error) { + if !attemptRe.MatchString(s) { + return 0, fmt.Errorf("attempt %q must be a positive integer with no leading zeros", s) + } + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > MaxAttempt { + return 0, fmt.Errorf("attempt %q out of range [1,%d]", s, MaxAttempt) + } + return n, nil +} + +// RefInfo is a parsed protocol ref. +type RefInfo struct { + Task string + Kind RefKind + Attempt int +} + +// TaskRef builds refs/captain/tasks///, validating each +// component. +func TaskRef(task string, kind RefKind, attempt int) (string, error) { + if err := ValidateTaskID(task); err != nil { + return "", err + } + if !refKinds[kind] { + return "", fmt.Errorf("unknown protocol ref kind %q", kind) + } + if attempt < 1 || attempt > MaxAttempt { + return "", fmt.Errorf("attempt %d out of range [1,%d]", attempt, MaxAttempt) + } + return taskRefPrefix + task + "/" + string(kind) + "/" + strconv.Itoa(attempt), nil +} + +// DispatchRef, ControlRef, ResultRef and VerdictRef are the four TaskRef +// specializations. +func DispatchRef(task string, attempt int) (string, error) { + return TaskRef(task, RefDispatch, attempt) +} + +// ControlRef builds the control ref for an attempt. +func ControlRef(task string, attempt int) (string, error) { return TaskRef(task, RefControl, attempt) } + +// ResultRef builds the result ref for an attempt. +func ResultRef(task string, attempt int) (string, error) { return TaskRef(task, RefResult, attempt) } + +// VerdictRef builds the verdict ref for an attempt. +func VerdictRef(task string, attempt int) (string, error) { return TaskRef(task, RefVerdict, attempt) } + +// AgentBranch builds refs/heads/captain/, the ordinary branch the agent +// pushes (R3.1). +func AgentBranch(task string) (string, error) { + if err := ValidateTaskID(task); err != nil { + return "", err + } + return agentBranchPrefix + task, nil +} + +// IsProtocolRef reports whether ref lies under refs/captain/tasks/. +func IsProtocolRef(ref string) bool { + return strings.HasPrefix(ref, taskRefPrefix) +} + +// ParseTaskRef parses a refs/captain/tasks/// name, +// rejecting anything malformed. +func ParseTaskRef(ref string) (RefInfo, error) { + rest, found := strings.CutPrefix(ref, taskRefPrefix) + if !found { + return RefInfo{}, fmt.Errorf("ref %q is not a protocol ref (missing %s)", ref, taskRefPrefix) + } + parts := strings.Split(rest, "/") + if len(parts) != 3 { + return RefInfo{}, fmt.Errorf("ref %q must be %s//", ref, taskRefPrefix) + } + if err := ValidateTaskID(parts[0]); err != nil { + return RefInfo{}, fmt.Errorf("ref %q: %w", ref, err) + } + kind := RefKind(parts[1]) + if !refKinds[kind] { + return RefInfo{}, fmt.Errorf("ref %q: unknown protocol ref kind %q", ref, parts[1]) + } + attempt, err := ParseAttempt(parts[2]) + if err != nil { + return RefInfo{}, fmt.Errorf("ref %q: %w", ref, err) + } + return RefInfo{Task: parts[0], Kind: kind, Attempt: attempt}, nil +} + +// TaskNamespace returns the ref namespace owned by a task: +// refs/captain/tasks/ (no trailing separator). +func TaskNamespace(task string) string { + return taskRefPrefix + task +} + +// NamespaceContains reports whether ref lies inside namespace. The comparison +// appends the separator before matching (R8.3): bare prefix matching would let +// agent "a" write agent "ab"'s namespace (H11). +func NamespaceContains(namespace, ref string) bool { + namespace = strings.TrimSuffix(namespace, "/") + if namespace == "" { + return false + } + return strings.HasPrefix(ref, namespace+"/") +} diff --git a/pkg/gitagent/refs_ginkgo_test.go b/pkg/gitagent/refs_ginkgo_test.go new file mode 100644 index 00000000..ef742a04 --- /dev/null +++ b/pkg/gitagent/refs_ginkgo_test.go @@ -0,0 +1,95 @@ +package gitagent_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent" +) + +var _ = Describe("task ids", func() { + It("accepts the documented shape", func() { + Expect(gitagent.ValidateTaskID("01jb-refactor-store")).To(Succeed()) + Expect(gitagent.ValidateTaskID("a")).To(Succeed()) + }) + + It("rejects everything outside ^[a-z0-9-]{1,64}$", func() { + for _, bad := range []string{"", "UPPER", "has space", "dot.dot", "a/b", "..", "-" + string(make([]byte, 64))} { + Expect(gitagent.ValidateTaskID(bad)).NotTo(Succeed(), "task id %q", bad) + } + long := "" + for range 65 { + long += "a" + } + Expect(gitagent.ValidateTaskID(long)).NotTo(Succeed()) + }) +}) + +var _ = Describe("attempts", func() { + It("parses positive decimals without leading zeros", func() { + n, err := gitagent.ParseAttempt("1") + Expect(err).NotTo(HaveOccurred()) + Expect(n).To(Equal(1)) + n, err = gitagent.ParseAttempt("42") + Expect(err).NotTo(HaveOccurred()) + Expect(n).To(Equal(42)) + }) + + It("rejects zero, leading zeros, negatives and junk", func() { + for _, bad := range []string{"0", "01", "-1", "", "1x", "0x1", "1.0", "9999999"} { + _, err := gitagent.ParseAttempt(bad) + Expect(err).To(HaveOccurred(), "attempt %q", bad) + } + }) +}) + +var _ = Describe("protocol refs", func() { + It("round-trips all four kinds", func() { + for _, kind := range []gitagent.RefKind{gitagent.RefDispatch, gitagent.RefControl, gitagent.RefResult, gitagent.RefVerdict} { + ref, err := gitagent.TaskRef("my-task", kind, 3) + Expect(err).NotTo(HaveOccurred()) + Expect(ref).To(Equal("refs/captain/tasks/my-task/" + string(kind) + "/3")) + info, err := gitagent.ParseTaskRef(ref) + Expect(err).NotTo(HaveOccurred()) + Expect(info).To(Equal(gitagent.RefInfo{Task: "my-task", Kind: kind, Attempt: 3})) + } + }) + + It("rejects malformed refs", func() { + for _, bad := range []string{ + "refs/heads/main", + "refs/captain/tasks/my-task", + "refs/captain/tasks/my-task/dispatch", + "refs/captain/tasks/my-task/dispatch/0", + "refs/captain/tasks/my-task/dispatch/01", + "refs/captain/tasks/my-task/unknown/1", + "refs/captain/tasks/My-Task/dispatch/1", + "refs/captain/tasks/my-task/dispatch/1/extra", + } { + _, err := gitagent.ParseTaskRef(bad) + Expect(err).To(HaveOccurred(), "ref %q", bad) + } + }) + + It("builds the agent branch", func() { + ref, err := gitagent.AgentBranch("my-task") + Expect(err).NotTo(HaveOccurred()) + Expect(ref).To(Equal("refs/heads/captain/my-task")) + _, err = gitagent.AgentBranch("Bad Task") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("namespace containment (R8.3/H11)", func() { + It("appends the separator before matching", func() { + ns := gitagent.TaskNamespace("a") + Expect(gitagent.NamespaceContains(ns, "refs/captain/tasks/a/result/1")).To(BeTrue()) + // agent "a" must not reach agent "ab"'s namespace + Expect(gitagent.NamespaceContains(ns, "refs/captain/tasks/ab/result/1")).To(BeFalse()) + Expect(gitagent.NamespaceContains(ns, "refs/captain/tasks/a")).To(BeFalse()) + }) + + It("rejects an empty namespace", func() { + Expect(gitagent.NamespaceContains("", "refs/captain/tasks/a/result/1")).To(BeFalse()) + }) +}) diff --git a/pkg/gitagent/regression_test.go b/pkg/gitagent/regression_test.go new file mode 100644 index 00000000..e775e6bd --- /dev/null +++ b/pkg/gitagent/regression_test.go @@ -0,0 +1,239 @@ +package gitagent + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + gossh "golang.org/x/crypto/ssh" +) + +func TestSplitSSHEndpointBracketedIPv6DefaultsPort(t *testing.T) { + addr, user, err := splitSSHEndpoint("ssh://operator@[::1]") + if err != nil { + t.Fatal(err) + } + if addr != "[::1]:22" || user != "operator" { + t.Fatalf("endpoint = %q, %q", addr, user) + } +} + +func TestOverLimitSubmissionsAdvanceAttempt(t *testing.T) { + repo := t.TempDir() + if err := SaveTaskState(repo, &TaskState{ + Task: "t-limit", Attempts: 1, Policy: Policy{MaxAttempts: 1}, + }); err != nil { + t.Fatal(err) + } + updates := []RefUpdate{{Ref: agentBranchPrefix + "t-limit"}} + for want := 2; want <= 3; want++ { + var sideband strings.Builder + if err := sidecarPreReceive(context.Background(), repo, HookHost{}, updates, &sideband); err == nil { + t.Fatalf("attempt %d was accepted", want) + } + verdict, found, err := LoadVerdict(repo, "t-limit", want) + if err != nil || !found || verdict.Attempt != want { + t.Fatalf("attempt %d verdict = %+v, found=%v, err=%v", want, verdict, found, err) + } + } + state, found, err := LoadTaskState(repo, "t-limit") + if err != nil || !found { + t.Fatalf("load state: found=%v err=%v", found, err) + } + if state.Attempts != 3 { + t.Fatalf("attempts = %d, want 3", state.Attempts) + } +} + +func TestUpdateTaskStateSerializesConcurrentWriters(t *testing.T) { + repo := t.TempDir() + if err := SaveTaskState(repo, &TaskState{Task: "t-lock"}); err != nil { + t.Fatal(err) + } + const writers = 24 + var wg sync.WaitGroup + errs := make(chan error, writers) + for range writers { + wg.Add(1) + go func() { + defer wg.Done() + _, err := UpdateTaskState(repo, "t-lock", func(st *TaskState) (bool, error) { + st.Attempts++ + return true, nil + }) + errs <- err + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + state, ok, err := LoadTaskState(repo, "t-lock") + if err != nil || !ok { + t.Fatalf("load state: ok=%v err=%v", ok, err) + } + if state.Attempts != writers { + t.Fatalf("attempts = %d, want %d", state.Attempts, writers) + } +} + +func TestEnsureKeyPairConcurrentCreationReturnsStoredKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent_ed25519") + const callers = 8 + type result struct { + fingerprint string + err error + } + results := make(chan result, callers) + var wg sync.WaitGroup + for range callers { + wg.Add(1) + go func() { + defer wg.Done() + _, fingerprint, err := EnsureKeyPair(path) + results <- result{fingerprint: fingerprint, err: err} + }() + } + wg.Wait() + close(results) + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + signer, err := gossh.ParsePrivateKey(data) + if err != nil { + t.Fatal(err) + } + want := gossh.FingerprintSHA256(signer.PublicKey()) + for result := range results { + if result.err != nil || result.fingerprint != want { + t.Fatalf("result = %#v, stored fingerprint = %q", result, want) + } + } +} + +func TestRefUpdateRecognizesSHA256NullOID(t *testing.T) { + null := "0000000000000000000000000000000000000000000000000000000000000000" + if !(RefUpdate{Old: null}).IsCreate() || !(RefUpdate{New: null}).IsDelete() { + t.Fatal("64-character null OID was not recognized") + } +} + +func TestSSHTransportCommandQuotesExecutable(t *testing.T) { + got := SSHTransportCommand("/tmp/captain build/captain's") + want := "'/tmp/captain build/captain'\"'\"'s' sandbox git-agent ssh" + if got != want { + t.Fatalf("command = %q, want %q", got, want) + } +} + +func TestWriteFileAtomicCleansTempAfterRenameFailure(t *testing.T) { + parent := t.TempDir() + target := filepath.Join(parent, "state.json") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + if err := writeFileAtomic(target, []byte("state"), 0o644); err == nil { + t.Fatal("writeFileAtomic succeeded with a directory target") + } + matches, err := filepath.Glob(filepath.Join(parent, ".state.json-*")) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("temporary files remain: %v", matches) + } +} + +func TestMailboxOwnsSyntheticDispatchObjects(t *testing.T) { + ctx := context.Background() + repo := t.TempDir() + env := ScrubGitEnv(os.Environ()) + if _, err := runGit(ctx, repo, env, "init", "--quiet"); err != nil { + t.Fatal(err) + } + if _, err := runGit(ctx, repo, env, + "-c", "user.name=test", "-c", "user.email=test@localhost", + "commit", "--allow-empty", "-m", "base"); err != nil { + t.Fatal(err) + } + snapshot, err := TakeSnapshot(ctx, repo, SnapshotPolicy{}) + if err != nil { + t.Fatal(err) + } + control, err := BuildControlCommit(ctx, repo, nil, map[string][]byte{ + ControlTaskFile: []byte(`{"prompt":"test"}`), + }) + if err != nil { + t.Fatal(err) + } + mailbox := filepath.Join(t.TempDir(), "mailbox.git") + req := DispatchRequest{ + RepoDir: repo, MailboxPath: mailbox, + MailboxRoute: "mailboxes/" + strings.Repeat("a", 64) + ".git", + } + if err := recordDispatch(ctx, req, "t-durable", snapshot, control, &HookSets{}); err != nil { + t.Fatal(err) + } + if _, err := runGit(ctx, repo, env, "prune", "--expire", "now"); err != nil { + t.Fatal(err) + } + for oid, want := range map[string]string{snapshot.Commit: "commit", control: "commit"} { + if _, err := runGit(ctx, repo, env, "cat-file", "-e", oid); err == nil { + t.Fatalf("synthetic object %s unexpectedly survived source prune", oid) + } + got, err := runGit(ctx, mailbox, env, "cat-file", "-t", oid) + if err != nil || got != want { + t.Fatalf("mailbox object %s = %q, %v; want %s", oid, got, err, want) + } + } +} + +func TestRecordDispatchCreatesAuditRefsAtomically(t *testing.T) { + ctx := context.Background() + repo := t.TempDir() + env := ScrubGitEnv(os.Environ()) + for _, args := range [][]string{ + {"init", "--quiet"}, + {"-c", "user.name=test", "-c", "user.email=test@localhost", "commit", "--allow-empty", "-m", "base"}, + } { + if _, err := runGit(ctx, repo, env, args...); err != nil { + t.Fatal(err) + } + } + snapshot, err := TakeSnapshot(ctx, repo, SnapshotPolicy{}) + if err != nil { + t.Fatal(err) + } + control, err := BuildControlCommit(ctx, repo, nil, map[string][]byte{ControlTaskFile: []byte(`{"prompt":"test"}`)}) + if err != nil { + t.Fatal(err) + } + mailbox := filepath.Join(t.TempDir(), "mailbox.git") + if err := InitMailbox(ctx, mailbox, repo); err != nil { + t.Fatal(err) + } + controlRef, _ := ControlRef("t-atomic", 1) + if _, err := runGit(ctx, mailbox, env, "update-ref", controlRef, control); err != nil { + t.Fatal(err) + } + req := DispatchRequest{RepoDir: repo, MailboxPath: mailbox, Task: "t-atomic"} + if err := recordDispatch(ctx, req, "t-atomic", snapshot, control, &HookSets{}); err == nil { + t.Fatal("recordDispatch succeeded despite an existing control ref") + } + dispatchRef, _ := DispatchRef("t-atomic", 1) + code, _, err := gitExitCode(ctx, mailbox, env, "show-ref", "--verify", "--quiet", dispatchRef) + if err != nil { + t.Fatal(err) + } + if code == 0 { + t.Fatalf("dispatch ref %s was left behind after transaction failure", dispatchRef) + } +} diff --git a/pkg/gitagent/relay.go b/pkg/gitagent/relay.go new file mode 100644 index 00000000..77e244dc --- /dev/null +++ b/pkg/gitagent/relay.go @@ -0,0 +1,198 @@ +// The nested relay (§6.2 step 9): still inside the sidecar's pre-receive, the +// vetted work is pushed onward to the supervisor's mailbox and the upstream +// sideband streams back out through the sidecar's own stderr. The relay lives +// in pre-receive because post-receive can no longer reject (R6.6/H16); it +// unsets GIT_QUARANTINE_PATH and keeps the inherited object directories, so +// no object is ever copied out of quarantine (R1.4). +package gitagent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" +) + +// RelayTarget is where and how the sidecar reaches the supervisor endpoint. +// The repository-specific mailbox route comes from trusted dispatch state. +type RelayTarget struct { + URL string `json:"url"` + HostFingerprint string `json:"hostFingerprint"` + KeyPath string `json:"keyPath"` + SSHCommand string `json:"sshCommand,omitempty"` // "" ⇒ this binary's transport +} + +// upstreamRejectedError distinguishes a supervisor verdict from a failure to +// obtain one. The supervisor has already persisted and rendered this verdict; +// the sidecar only needs the error to reject its own still-blocked push. +type upstreamRejectedError struct { + verdict TierVerdict +} + +func (e *upstreamRejectedError) Error() string { + return fmt.Sprintf("supervisor rejected task %s attempt %d (%s)", + e.verdict.Task, e.verdict.Attempt, e.verdict.Status) +} + +const relayFeedbackTruncation = "captain: relay feedback truncated\n" + +// relayFeedbackWriter removes the inner git client's "remote: " transport +// prefix before forwarding through the outer receive-pack. It also retains +// the structured supervisor verdict so an ordinary rejection is not +// misclassified as a sidecar transport error. +type relayFeedbackWriter struct { + dst io.Writer + pending string + verdict *TierVerdict + dropping bool + truncated bool +} + +func (w *relayFeedbackWriter) Write(p []byte) (int, error) { + n := len(p) + for len(p) > 0 { + if w.dropping { + i := bytes.IndexByte(p, '\n') + if i < 0 { + return n, nil + } + p = p[i+1:] + w.dropping = false + continue + } + + i := bytes.IndexByte(p, '\n') + if i < 0 { + if len(p) > MaxFeedbackBytes-len(w.pending) { + w.pending = "" + w.dropping = true + if err := w.writeTruncation(); err != nil { + return 0, err + } + return n, nil + } + w.pending += string(p) + return n, nil + } + + fragment := p[:i+1] + p = p[i+1:] + if len(fragment) > MaxFeedbackBytes-len(w.pending) { + w.pending = "" + if err := w.writeTruncation(); err != nil { + return 0, err + } + continue + } + w.pending += string(fragment) + line := w.pending + w.pending = "" + if err := w.writeLine(line); err != nil { + return 0, err + } + } + return n, nil +} + +func (w *relayFeedbackWriter) writeTruncation() error { + if w.truncated { + return nil + } + w.truncated = true + _, err := io.WriteString(w.dst, relayFeedbackTruncation) + return err +} + +func (w *relayFeedbackWriter) flush() error { + if w.pending == "" { + return nil + } + line := w.pending + w.pending = "" + return w.writeLine(line) +} + +func (w *relayFeedbackWriter) writeLine(line string) error { + line = strings.TrimPrefix(line, "remote: ") + candidate := strings.TrimSpace(line) + if raw, ok := strings.CutPrefix(candidate, "captain-json: "); ok { + var verdict TierVerdict + if json.Unmarshal([]byte(raw), &verdict) == nil && verdict.Tier == "supervisor" { + w.verdict = &verdict + } + } + _, err := io.WriteString(w.dst, line) + return err +} + +// BuildResultCommit squashes the agent's branch tip into the single result +// commit the protocol requires: tree = the tip's tree, parent = the dispatch +// commit (§3.2). Written through the hook environment, so in pre-receive the +// new object lands in quarantine and is discarded with a rejected push. +func BuildResultCommit(ctx context.Context, repo string, env []string, tip, dispatchCommit string) (string, error) { + tree, err := runGit(ctx, repo, env, "rev-parse", tip+"^{tree}") + if err != nil { + return "", err + } + cenv := envWith(env, + "GIT_AUTHOR_NAME=captain", + "GIT_AUTHOR_EMAIL=captain@localhost", + "GIT_COMMITTER_NAME=captain", + "GIT_COMMITTER_EMAIL=captain@localhost", + ) + return runGitIn(ctx, repo, cenv, + strings.NewReader("captain result\n"), + "commit-tree", tree, "-p", dispatchCommit) +} + +// Relay pushes result+control atomically to the mailbox, streaming the +// upstream's stderr through sideband. A non-zero upstream exit is the +// caller's signal to reject the agent's push (R6.7). +func Relay(ctx context.Context, repo string, hookEnv []string, target RelayTarget, mailboxRoute string, envelope Envelope, result, control string, sideband io.Writer) error { + mailboxURL, err := MailboxURL(target.URL, mailboxRoute) + if err != nil { + return err + } + resultRef, err := ResultRef(envelope.Task, envelope.Attempt) + if err != nil { + return err + } + controlRef, err := ControlRef(envelope.Task, envelope.Attempt) + if err != nil { + return err + } + opts, err := envelope.Encode() + if err != nil { + return err + } + args := []string{"push", "--atomic"} + for _, o := range opts { + args = append(args, "--push-option="+o) + } + args = append(args, mailboxURL, result+":"+resultRef, control+":"+controlRef) + + pairs, err := transportPairs(target.SSHCommand, target.KeyPath, target.HostFingerprint) + if err != nil { + return err + } + // R1.4: unset only GIT_QUARANTINE_PATH; the object-directory variables + // stay so the quarantined objects remain readable for the outbound pack. + env := envWith(RelayEnv(hookEnv), pairs...) + feedback := &relayFeedbackWriter{dst: sideband} + code, out, err := gitExitCodeStderr(ctx, repo, env, feedback, args...) + if flushErr := feedback.flush(); err == nil && flushErr != nil { + err = flushErr + } + if err != nil { + return err + } + if code != 0 { + if feedback.verdict != nil && feedback.verdict.Rejects() { + return &upstreamRejectedError{verdict: *feedback.verdict} + } + return fmt.Errorf("supervisor rejected attempt %d (exit %d)%s", envelope.Attempt, code, strings.TrimSpace(out)) + } + return nil +} diff --git a/pkg/gitagent/relay_test.go b/pkg/gitagent/relay_test.go new file mode 100644 index 00000000..eb174082 --- /dev/null +++ b/pkg/gitagent/relay_test.go @@ -0,0 +1,59 @@ +package gitagent + +import ( + "strings" + "testing" +) + +func TestRelayFeedbackWriterPreservesLinesAndSupervisorVerdict(t *testing.T) { + var dst strings.Builder + writer := &relayFeedbackWriter{dst: &dst} + if _, err := writer.Write([]byte("remote: first")); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(" line\nremote: captain-json: {\"v\":1,\"task\":\"t-1\",\"attempt\":2,\"status\":\"rejected\",\"tier\":\"supervisor\"}\nremote: tail")); err != nil { + t.Fatal(err) + } + if err := writer.flush(); err != nil { + t.Fatal(err) + } + if got := dst.String(); !strings.Contains(got, "first line\n") || !strings.HasSuffix(got, "tail") { + t.Fatalf("feedback = %q", got) + } + if writer.verdict == nil || writer.verdict.Task != "t-1" || writer.verdict.Attempt != 2 { + t.Fatalf("verdict = %+v", writer.verdict) + } +} + +func TestRelayFeedbackWriterBoundsUnterminatedLines(t *testing.T) { + var dst strings.Builder + writer := &relayFeedbackWriter{dst: &dst} + if _, err := writer.Write([]byte("remote: " + strings.Repeat("x", MaxFeedbackBytes/2))); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(strings.Repeat("x", MaxFeedbackBytes))); err != nil { + t.Fatal(err) + } + if len(writer.pending) > MaxFeedbackBytes { + t.Fatalf("pending grew to %d bytes", len(writer.pending)) + } + if _, err := writer.Write([]byte("discarded\nremote: after\n")); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(strings.Repeat("y", MaxFeedbackBytes+1) + "\n")); err != nil { + t.Fatal(err) + } + if err := writer.flush(); err != nil { + t.Fatal(err) + } + got := dst.String() + if strings.Count(got, relayFeedbackTruncation) != 1 { + t.Fatalf("truncation count = %d, feedback = %q", strings.Count(got, relayFeedbackTruncation), got) + } + if !strings.Contains(got, "after\n") { + t.Fatalf("normal line after truncation was lost: %q", got) + } + if strings.Contains(got, strings.Repeat("x", 32)) || strings.Contains(got, strings.Repeat("y", 32)) { + t.Fatalf("oversized line content was forwarded: %q", got) + } +} diff --git a/pkg/gitagent/scrub.go b/pkg/gitagent/scrub.go new file mode 100644 index 00000000..27c960e9 --- /dev/null +++ b/pkg/gitagent/scrub.go @@ -0,0 +1,104 @@ +package gitagent + +import "strings" + +// receivePackEnv are the variables receive-pack injects that redirect any +// descendant git at another repository. GIT_QUARANTINE_PATH is listed by name +// because `git rev-parse --local-env-vars` omits it (verified, §1.1), so the +// githooks(5) scrub idiom leaves it set — the exact trap R1.1 exists for. +var receivePackEnv = []string{ + "GIT_QUARANTINE_PATH", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", +} + +// ScrubGitEnv returns env without any receive-pack repository redirection and +// without the push-option variables, for hook subprocesses (R1.1). +func ScrubGitEnv(env []string) []string { + out := make([]string, 0, len(env)) + for _, kv := range env { + name, _, _ := strings.Cut(kv, "=") + if scrubbedGitVar(name) { + continue + } + out = append(out, kv) + } + return out +} + +func scrubbedGitVar(name string) bool { + if strings.HasPrefix(name, "GIT_PUSH_OPTION_") { + return true + } + for _, v := range receivePackEnv { + if name == v { + return true + } + } + return false +} + +// hookExecEnvAllowed are the variables an exec hook may inherit by exact name. +// PATH so declared commands resolve; HOME and TMPDIR because tools need them +// (a sandboxing wrapper may re-point both at a private scratch directory); the +// rest is locale and terminal plumbing. Everything else — provider +// credentials, host tokens, the receive-pack git context — stays out. +var hookExecEnvAllowed = map[string]struct{}{ + "PATH": {}, "HOME": {}, "TMPDIR": {}, "LANG": {}, "TZ": {}, + "TERM": {}, "USER": {}, "LOGNAME": {}, "SHELL": {}, +} + +// HookExecEnv reduces env to the allowlist an exec hook may see. Hooks run +// agent-authored commands, so their environment is a boundary, not an +// inheritance: R1.1's git scrub falls out of the allowlist (no GIT_* survives) +// and no ambient credential — provider API keys included — can reach the hook +// (issue #40). +func HookExecEnv(env []string) []string { + out := make([]string, 0, len(env)) + for _, kv := range env { + name, _, _ := strings.Cut(kv, "=") + if _, ok := hookExecEnvAllowed[name]; ok || strings.HasPrefix(name, "LC_") { + out = append(out, kv) + } + } + return out +} + +// RelayEnv returns env with only GIT_QUARANTINE_PATH removed. The relay push +// from inside pre-receive must keep the inherited object directories so the +// quarantined objects stay readable, and must not copy them (R1.4, verified +// §1.4). +func RelayEnv(env []string) []string { + out := make([]string, 0, len(env)) + for _, kv := range env { + if name, _, _ := strings.Cut(kv, "="); name == "GIT_QUARANTINE_PATH" { + continue + } + out = append(out, kv) + } + return out +} + +// envWith returns env with the given KEY=VALUE pairs appended, each replacing +// any existing entry for its key. +func envWith(env []string, pairs ...string) []string { + out := make([]string, 0, len(env)+len(pairs)) + for _, kv := range env { + name, _, _ := strings.Cut(kv, "=") + replaced := false + for _, p := range pairs { + if pname, _, _ := strings.Cut(p, "="); pname == name { + replaced = true + break + } + } + if !replaced { + out = append(out, kv) + } + } + return append(out, pairs...) +} diff --git a/pkg/gitagent/server.go b/pkg/gitagent/server.go new file mode 100644 index 00000000..de08bb59 --- /dev/null +++ b/pkg/gitagent/server.go @@ -0,0 +1,271 @@ +// The embedded SSH endpoint (§2/§8): it speaks git-receive-pack and the +// enrollment exchange, nothing else. upload-pack is never served — a shared +// upload-pack leaks every task namespace to every enrolled agent (R2.3/H11). +// +// The three gavel-serve defects issue #39 §9 records are corrected here: keys +// are authorized against an enrollment directory instead of accept-all, the +// vetting hooks live in pre-receive where rejection is possible, and the repo +// path is containment-checked rather than merely unquoted. +package gitagent + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/gliderlabs/ssh" + gossh "golang.org/x/crypto/ssh" +) + +// Env vars the server injects into receive-pack so the hook shims know who is +// pushing and which admission tier they are. +const ( + EnvAgentName = "CAPTAIN_GITAGENT_AGENT" + EnvRole = "CAPTAIN_GITAGENT_ROLE" +) + +// EnrollCommand is the single non-git exec verb the server accepts. +const EnrollCommand = "captain-enroll" + +// AgentDirectory is the server's authorization source. Implementations read +// live state on every call so revocation takes effect for new connections +// (R8.5) and a join token can be burned atomically (R8.2). +type AgentDirectory interface { + // AgentByFingerprint maps an SSH public-key SHA256 fingerprint to an + // enrolled agent name. + AgentByFingerprint(fingerprint string) (string, bool) + // ConsumeJoinToken validates and burns a single-use join token, returning + // the agent name it enrolls. + ConsumeJoinToken(token string) (string, error) + // RecordAgent binds a key, an endpoint and a host key to an enrolled + // agent — everything a dispatch to it needs. + RecordAgent(AgentEnrollment) error +} + +// ServerConfig configures one receive endpoint. +type ServerConfig struct { + Listen string + Root string // directory whose repos may be pushed to + Role ReceiverRole + HostKey gossh.Signer + Directory AgentDirectory + // Offer is what this endpoint hands back to a joining agent so the agent + // can complete the reverse direction of trust. + Offer EnrollmentOffer + // AgentRepoPath is the repository path an enrolled agent serves, used to + // derive its dispatch URL when the agent advertises none. + AgentRepoPath string +} + +// NewServer builds the SSH server. The caller owns ListenAndServe/Serve and +// Close. +func NewServer(cfg ServerConfig) (*ssh.Server, error) { + if cfg.HostKey == nil || cfg.Directory == nil || cfg.Root == "" { + return nil, fmt.Errorf("git-agent server needs a host key, an agent directory and a repo root") + } + root, err := filepath.Abs(cfg.Root) + if err != nil { + return nil, err + } + server := &ssh.Server{ + Addr: cfg.Listen, + // The key handler only proves possession and records the fingerprint; + // authorization is per-command below, because an unenrolled key must + // still be able to present a join token (R8.2). + PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { + ctx.SetValue("fingerprint", gossh.FingerprintSHA256(key)) + return true + }, + Handler: func(s ssh.Session) { + handleSession(s, root, cfg) + }, + } + server.AddHostKey(cfg.HostKey) + return server, nil +} + +func handleSession(s ssh.Session, root string, cfg ServerConfig) { + fingerprint, _ := s.Context().Value("fingerprint").(string) + cmd := s.Command() + if len(cmd) == 0 { + fmt.Fprintln(s.Stderr(), "captain: interactive sessions are not served") + _ = s.Exit(1) + return + } + switch cmd[0] { + case EnrollCommand: + handleEnroll(s, cfg, fingerprint, cmd) + case "git-receive-pack", "git", "git-upload-pack", "git-upload-archive": + handleGit(s, root, cfg, fingerprint, cmd) + default: + fmt.Fprintf(s.Stderr(), "captain: command %q is not served\n", cmd[0]) + _ = s.Exit(1) + } +} + +// handleEnroll completes both directions of the exchange: it records the +// agent's key and endpoint, then returns the supervisor dispatch key needed to +// authorize later task-specific reverse pushes. +func handleEnroll(s ssh.Session, cfg ServerConfig, fingerprint string, cmd []string) { + if len(cmd) < 2 || strings.TrimSpace(cmd[1]) == "" { + fmt.Fprintln(s.Stderr(), "captain: usage: captain-enroll [request]") + _ = s.Exit(1) + return + } + req, err := decodeEnrollRequest(cmd) + if err != nil { + fmt.Fprintf(s.Stderr(), "captain: %v\n", err) + _ = s.Exit(1) + return + } + name, err := cfg.Directory.ConsumeJoinToken(strings.TrimSpace(cmd[1])) + if err != nil { + fmt.Fprintf(s.Stderr(), "captain: enrollment refused: %v\n", err) + _ = s.Exit(1) + return + } + enrollment := AgentEnrollment{ + Name: name, + Fingerprint: fingerprint, + URL: agentDispatchURL(req, s.RemoteAddr(), cfg.AgentRepoPath), + HostFingerprint: strings.TrimSpace(req.HostFingerprint), + } + if err := cfg.Directory.RecordAgent(enrollment); err != nil { + fmt.Fprintf(s.Stderr(), "captain: enrollment failed: %v\n", err) + _ = s.Exit(1) + return + } + resp, err := json.Marshal(EnrollResponse{ + Agent: name, + DispatchKey: cfg.Offer.DispatchKey, + }) + if err != nil { + fmt.Fprintf(s.Stderr(), "captain: %v\n", err) + _ = s.Exit(1) + return + } + fmt.Fprintln(s, string(resp)) + _ = s.Exit(0) +} + +func decodeEnrollRequest(cmd []string) (EnrollRequest, error) { + var req EnrollRequest + if len(cmd) < 3 || strings.TrimSpace(cmd[2]) == "" { + return req, nil // an older client sent no details; derive what we can + } + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(cmd[2])) + if err != nil { + return req, fmt.Errorf("unparseable enrollment request: %w", err) + } + if err := json.Unmarshal(raw, &req); err != nil { + return req, fmt.Errorf("unparseable enrollment request: %w", err) + } + return req, nil +} + +// agentDispatchURL resolves where the supervisor will dispatch to: the URL the +// agent advertised, or — on a flat network where the source address is the +// agent's real address — one derived from the connection plus its listen port. +func agentDispatchURL(req EnrollRequest, remote net.Addr, repoPath string) string { + if url := strings.TrimSpace(req.AdvertiseURL); url != "" { + return url + } + port := strings.TrimSpace(req.ListenPort) + if port == "" || remote == nil { + return "" + } + host, _, err := net.SplitHostPort(remote.String()) + if err != nil { + return "" + } + if repoPath == "" { + repoPath = "repo.git" + } + return "ssh://captain@" + net.JoinHostPort(host, port) + "/" + strings.TrimPrefix(repoPath, "/") +} + +func handleGit(s ssh.Session, root string, cfg ServerConfig, fingerprint string, cmd []string) { + repoArg, err := parseReceivePack(cmd) + if err != nil { + fmt.Fprintf(s.Stderr(), "captain: %v\n", err) + _ = s.Exit(1) + return + } + agent, ok := cfg.Directory.AgentByFingerprint(fingerprint) + if !ok { + fmt.Fprintf(s.Stderr(), "captain: key %s is not enrolled\n", fingerprint) + _ = s.Exit(1) + return + } + repo, err := ResolveRepoPath(root, repoArg) + if err != nil { + fmt.Fprintf(s.Stderr(), "captain: %v\n", err) + _ = s.Exit(1) + return + } + proc := exec.CommandContext(s.Context(), "git", "receive-pack", repo) + proc.Env = envWith(os.Environ(), EnvAgentName+"="+agent, EnvRole+"="+string(cfg.Role)) + proc.Stdin = s + proc.Stdout = s + proc.Stderr = s.Stderr() + if err := proc.Run(); err != nil { + if exit, ok := err.(*exec.ExitError); ok { + _ = s.Exit(exit.ExitCode()) + return + } + fmt.Fprintf(s.Stderr(), "captain: receive-pack: %v\n", err) + _ = s.Exit(1) + return + } + _ = s.Exit(0) +} + +// parseReceivePack accepts only the receive-pack command forms and returns +// the repo argument. upload-pack is refused by name (R2.3). +func parseReceivePack(cmd []string) (string, error) { + switch cmd[0] { + case "git-receive-pack": + if len(cmd) != 2 { + return "", fmt.Errorf("git-receive-pack takes exactly one repository argument") + } + return cmd[1], nil + case "git": + if len(cmd) != 3 || cmd[1] != "receive-pack" { + return "", fmt.Errorf("only `git receive-pack ` is served") + } + return cmd[2], nil + default: + return "", fmt.Errorf("%s is not served: this endpoint speaks git-receive-pack only (R2.3)", cmd[0]) + } +} + +// ResolveRepoPath maps a client-supplied repo path onto a repo under root, +// confirming containment after resolving — stripping quotes and leading +// slashes is insufficient because `..` traversal escapes (R8.4/H13). +func ResolveRepoPath(root, arg string) (string, error) { + cleaned := strings.TrimSpace(arg) + cleaned = strings.Trim(cleaned, "'\"") + cleaned = strings.TrimPrefix(cleaned, "/") + if cleaned == "" { + return "", fmt.Errorf("empty repository path") + } + joined := filepath.Join(root, filepath.FromSlash(cleaned)) + resolved, err := filepath.EvalSymlinks(joined) + if err != nil { + return "", fmt.Errorf("repository %q not found", cleaned) + } + rootResolved, err := filepath.EvalSymlinks(root) + if err != nil { + return "", err + } + rel, err := filepath.Rel(rootResolved, resolved) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("repository path %q escapes the served root (H13)", arg) + } + return resolved, nil +} diff --git a/pkg/gitagent/server_ginkgo_test.go b/pkg/gitagent/server_ginkgo_test.go new file mode 100644 index 00000000..0cef98e7 --- /dev/null +++ b/pkg/gitagent/server_ginkgo_test.go @@ -0,0 +1,237 @@ +package gitagent_test + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gossh "golang.org/x/crypto/ssh" + + "github.com/flanksource/captain/pkg/gitagent" +) + +// memoryDirectory is an in-process AgentDirectory for server tests. +type memoryDirectory struct { + mu sync.Mutex + agents map[string]string // fingerprint → name + pending map[string]string // token hash → name + enrollments map[string]gitagent.AgentEnrollment +} + +func (d *memoryDirectory) AgentByFingerprint(fp string) (string, bool) { + d.mu.Lock() + defer d.mu.Unlock() + name, ok := d.agents[fp] + return name, ok +} + +func (d *memoryDirectory) ConsumeJoinToken(token string) (string, error) { + d.mu.Lock() + defer d.mu.Unlock() + name, ok := d.pending[gitagent.HashJoinToken(token)] + if !ok { + return "", fmt.Errorf("join token is unknown or already used") + } + delete(d.pending, gitagent.HashJoinToken(token)) + return name, nil +} + +func (d *memoryDirectory) RecordAgent(e gitagent.AgentEnrollment) error { + d.mu.Lock() + defer d.mu.Unlock() + d.agents[e.Fingerprint] = e.Name + if d.enrollments == nil { + d.enrollments = map[string]gitagent.AgentEnrollment{} + } + d.enrollments[e.Name] = e + return nil +} + +// startTestServer serves root on a loopback port and returns its address and +// host fingerprint. +func startTestServer(dir *memoryDirectory, root string, role gitagent.ReceiverRole) (addr, hostFP string) { + GinkgoHelper() + return startTestServerWithOffer(dir, root, role, gitagent.EnrollmentOffer{}) +} + +func startTestServerWithOffer(dir *memoryDirectory, root string, role gitagent.ReceiverRole, offer gitagent.EnrollmentOffer) (addr, hostFP string) { + GinkgoHelper() + keys := GinkgoT().TempDir() + hostKey, fp, err := gitagent.EnsureKeyPair(filepath.Join(keys, "host_ed25519")) + Expect(err).NotTo(HaveOccurred()) + server, err := gitagent.NewServer(gitagent.ServerConfig{ + Root: root, Role: role, HostKey: hostKey, Directory: dir, + Offer: offer, AgentRepoPath: "repo.git", + }) + Expect(err).NotTo(HaveOccurred()) + listener, err := net.Listen("tcp", "127.0.0.1:0") + Expect(err).NotTo(HaveOccurred()) + go func() { _ = server.Serve(listener) }() + DeferCleanup(func() { _ = server.Close() }) + return listener.Addr().String(), fp +} + +func newClientKey() (gossh.Signer, string, string) { + GinkgoHelper() + keyPath := filepath.Join(GinkgoT().TempDir(), "agent_ed25519") + signer, fp, err := gitagent.EnsureKeyPair(keyPath) + Expect(err).NotTo(HaveOccurred()) + return signer, fp, keyPath +} + +func sshExec(addr string, signer gossh.Signer, command string) (string, error) { + config := &gossh.ClientConfig{ + User: "captain", + Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)}, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + } + client, err := gossh.Dial("tcp", addr, config) + if err != nil { + return "", err + } + defer client.Close() + session, err := client.NewSession() + if err != nil { + return "", err + } + defer session.Close() + out, err := session.CombinedOutput(command) + return string(out), err +} + +var _ = Describe("the git-agent SSH endpoint", func() { + ctx := context.Background() + + It("serves receive-pack to enrolled keys and completes a real push", func() { + root := GinkgoT().TempDir() + Expect(gitagent.InitSidecar(ctx, filepath.Join(root, "repo.git"))).To(Succeed()) + + dir := &memoryDirectory{agents: map[string]string{}, pending: map[string]string{}} + _, fp, keyPath := newClientKey() + dir.agents[fp] = "worker-1" + addr, hostFP := startTestServer(dir, root, gitagent.RoleSidecar) + + src := GinkgoT().TempDir() + gitT(src, "init", "-q") + writeFileT(src, "a.txt", "a\n") + gitT(src, "add", "-A") + gitT(src, "commit", "-q", "-m", "base") + + // The test binary doubles as GIT_SSH_COMMAND (see TestMain), so this + // is the production transport end to end with no system ssh. + exe, err := os.Executable() + Expect(err).NotTo(HaveOccurred()) + host, port, err := net.SplitHostPort(addr) + Expect(err).NotTo(HaveOccurred()) + push := exec.Command("git", "push", "-q", + fmt.Sprintf("ssh://captain@%s:%s/repo.git", host, port), "HEAD:refs/heads/pushed") + push.Dir = src + push.Env = append(os.Environ(), + "GIT_SSH_COMMAND="+exe, + "GIT_SSH_VARIANT=ssh", // an unknown command defaults to "simple", which cannot pass -p + "CAPTAIN_TEST_SSH_CLIENT=1", + gitagent.EnvSSHKey+"="+keyPath, + gitagent.EnvSSHHostFingerprint+"="+hostFP, + ) + out, err := push.CombinedOutput() + Expect(err).NotTo(HaveOccurred(), "push output:\n%s", out) + + Expect(gitT(filepath.Join(root, "repo.git"), "rev-parse", "refs/heads/pushed")). + To(Equal(gitT(src, "rev-parse", "HEAD"))) + }) + + It("refuses unknown keys, upload-pack, and path traversal", func() { + root := GinkgoT().TempDir() + Expect(gitagent.InitSidecar(ctx, filepath.Join(root, "repo.git"))).To(Succeed()) + dir := &memoryDirectory{agents: map[string]string{}, pending: map[string]string{}} + enrolled, fp, _ := newClientKey() + dir.agents[fp] = "worker-1" + stranger, _, _ := newClientKey() + addr, _ := startTestServer(dir, root, gitagent.RoleSidecar) + + out, err := sshExec(addr, stranger, "git-receive-pack 'repo.git'") + Expect(err).To(HaveOccurred()) + Expect(out).To(ContainSubstring("not enrolled")) + + out, err = sshExec(addr, enrolled, "git-upload-pack 'repo.git'") + Expect(err).To(HaveOccurred()) + Expect(out).To(ContainSubstring("git-receive-pack only")) + + out, err = sshExec(addr, enrolled, "git-receive-pack '../repo.git'") + Expect(err).To(HaveOccurred()) + Expect(out).NotTo(BeEmpty()) + + out, err = sshExec(addr, enrolled, "rm -rf /") + Expect(err).To(HaveOccurred()) + Expect(out).To(ContainSubstring("not served")) + }) + + It("enrolls both directions through a single-use join token and refuses replay (R8.2)", func() { + root := GinkgoT().TempDir() + dir := &memoryDirectory{agents: map[string]string{}, pending: map[string]string{}} + token, hash, err := gitagent.MintJoinToken() + Expect(err).NotTo(HaveOccurred()) + dir.pending[hash] = "worker-2" + addr, hostFP := startTestServerWithOffer(dir, root, gitagent.RoleMailbox, + gitagent.EnrollmentOffer{DispatchKey: "SHA256:dispatch"}) + + signer, fp, _ := newClientKey() + request := gitagent.EnrollRequest{ListenPort: "7502", HostFingerprint: "SHA256:agenthost"} + resp, err := gitagent.Enroll(context.Background(), "ssh://"+addr, token, hostFP, signer, request) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.Agent).To(Equal("worker-2")) + // The reverse direction: what the agent needs to accept a dispatch. + // Repository-specific mailbox routing arrives with each task. + Expect(resp.DispatchKey).To(Equal("SHA256:dispatch")) + + // The supervisor recorded an endpoint it can actually dispatch to. + enrolled, ok := dir.enrollments["worker-2"] + Expect(ok).To(BeTrue()) + Expect(enrolled.Fingerprint).To(Equal(fp)) + Expect(enrolled.HostFingerprint).To(Equal("SHA256:agenthost")) + Expect(enrolled.URL).To(ContainSubstring(":7502/repo.git")) + + route := "mailboxes/" + strings.Repeat("a", 64) + ".git" + mailboxURL, err := gitagent.MailboxURL("ssh://"+addr, route) + Expect(err).NotTo(HaveOccurred()) + Expect(mailboxURL).To(HaveSuffix("/" + route)) + _, err = gitagent.MailboxURL("ssh://"+addr, "../other.git") + Expect(err).To(MatchError(ContainSubstring("mailbox route"))) + + // Replay fails: the token burned. + _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, token, hostFP, signer, request) + Expect(err).To(MatchError(ContainSubstring("already used"))) + + // A wrong host fingerprint is refused before the token is offered. + _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, token, "SHA256:bogus", signer, request) + Expect(err).To(HaveOccurred()) + + // An empty fingerprint never trusts on first use. + _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, token, "", signer, request) + Expect(err).To(MatchError(ContainSubstring("host-key fingerprint"))) + }) + + It("resolves repo paths strictly within the root (R8.4/H13)", func() { + root := GinkgoT().TempDir() + Expect(gitagent.InitSidecar(ctx, filepath.Join(root, "repo.git"))).To(Succeed()) + + resolved, err := gitagent.ResolveRepoPath(root, "/repo.git") + Expect(err).NotTo(HaveOccurred()) + Expect(resolved).To(HaveSuffix("repo.git")) + + _, err = gitagent.ResolveRepoPath(root, "'/repo.git'") + Expect(err).NotTo(HaveOccurred(), "quoted forms are unquoted") + + for _, evil := range []string{"../outside.git", "/../outside.git", "a/../../outside.git", ""} { + _, err := gitagent.ResolveRepoPath(root, evil) + Expect(err).To(HaveOccurred(), "path %q must be rejected", evil) + } + }) +}) diff --git a/pkg/gitagent/snapshot.go b/pkg/gitagent/snapshot.go new file mode 100644 index 00000000..aea436ad --- /dev/null +++ b/pkg/gitagent/snapshot.go @@ -0,0 +1,345 @@ +// The dispatch snapshot (§6.1): the supervisor's dirty worktree captured as a +// commit parented on HEAD, built from an explicit path set against a +// temporary index — never `read-tree HEAD` + `add --all`, which stages mass +// deletions under sparse-checkout or skip-worktree (R6.1/H4). Blobs are +// hashed with --no-filters so no clean filter, CRLF rule or attribute can +// change the bytes (R6.2); anything that cannot round-trip byte-exact — LFS, +// required filters, dirty submodules — refuses loudly instead (H5). +package gitagent + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v4" +) + +// Default snapshot caps, applied when the policy leaves a cap zero. +const ( + DefaultSnapshotMaxFiles = 5000 + DefaultSnapshotMaxFileSize = int64(32 << 20) + DefaultSnapshotMaxTotalSize = int64(256 << 20) +) + +// SnapshotPolicy bounds what a dispatch snapshot may carry. +type SnapshotPolicy struct { + // Paths are doublestar globs; a leading ! denies. With any non-negated + // pattern present, a path must match one to be included. + Paths []string + MaxFiles int + MaxFileSize int64 + MaxTotalSize int64 +} + +// Snapshot is the result of TakeSnapshot: a commit whose tree is the dirty +// worktree state and whose parent is the base the supervisor dispatched from. +type Snapshot struct { + Commit string + Tree string + Base string + Paths []string // repo-relative dirty paths the snapshot applied +} + +// TakeSnapshot captures repoDir's dirty worktree as a commit parented on +// HEAD. The worktree, index and repo are left untouched. +func TakeSnapshot(ctx context.Context, repoDir string, policy SnapshotPolicy) (*Snapshot, error) { + dir, err := filepath.Abs(repoDir) + if err != nil { + return nil, err + } + env := ScrubGitEnv(os.Environ()) + base, err := runGit(ctx, dir, env, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return nil, fmt.Errorf("dispatch requires a repository with at least one commit: %w", err) + } + entries, err := statusZ(ctx, dir, env) + if err != nil { + return nil, err + } + for _, e := range entries { + if e.Unmerged() { + return nil, fmt.Errorf("snapshot refused: %q is unmerged; resolve the conflict before dispatching", e.Path) + } + } + all := make([]string, 0, len(entries)) + for _, e := range entries { + all = append(all, e.Path) + } + paths, err := filterPolicyPaths(all, policy.Paths) + if err != nil { + return nil, err + } + if err := refuseSnapshotHazards(ctx, dir, env, paths); err != nil { + return nil, err + } + if err := enforceSnapshotCaps(dir, paths, policy); err != nil { + return nil, err + } + tree, err := buildSnapshotTree(ctx, dir, env, base, paths) + if err != nil { + return nil, err + } + commit, err := commitSnapshotTree(ctx, dir, env, tree, base) + if err != nil { + return nil, err + } + return &Snapshot{Commit: commit, Tree: tree, Base: base, Paths: paths}, nil +} + +// filterPolicyPaths applies allow/deny globs. Exclusion is the point of a +// path policy — an excluded dirty path stays at its base version. +func filterPolicyPaths(paths, patterns []string) ([]string, error) { + if len(patterns) == 0 { + return paths, nil + } + var allows, denies []string + for _, p := range patterns { + pattern, negated := strings.CutPrefix(p, "!") + if !doublestar.ValidatePattern(pattern) { + return nil, fmt.Errorf("invalid policy path pattern %q", p) + } + if negated { + denies = append(denies, pattern) + } else { + allows = append(allows, pattern) + } + } + matchAny := func(patterns []string, path string) bool { + for _, p := range patterns { + if ok, _ := doublestar.Match(p, path); ok { + return true + } + } + return false + } + var out []string + for _, path := range paths { + if matchAny(denies, path) { + continue + } + if len(allows) > 0 && !matchAny(allows, path) { + continue + } + out = append(out, path) + } + return out, nil +} + +// refuseSnapshotHazards aborts on anything that round-trips incorrectly and +// silently (H5): LFS-filtered paths, required clean/smudge filters resolved +// by a snapshot path, and dirty submodules. +func refuseSnapshotHazards(ctx context.Context, dir string, env []string, paths []string) error { + if err := refuseFilters(ctx, dir, env, paths); err != nil { + return err + } + return refuseDirtySubmodules(ctx, dir, env, paths) +} + +// refuseFilters rejects a snapshot when a candidate path resolves the lfs +// filter or any filter declared required in config: materializing would +// smudge the byte-exact blob on the far side. The declaration alone is +// harmless — git-lfs installs filter.lfs.required=true machine-wide, so a +// path must actually resolve the filter through an attribute before the +// hazard exists. In-repo .gitattributes declaring filter=lfs refuses even +// with no dirty path — equivalent to "`git lfs ls-files` is non-empty" +// without requiring the lfs binary. +func refuseFilters(ctx context.Context, dir string, env []string, paths []string) error { + if len(paths) > 0 { + args := append([]string{"check-attr", "-z", "filter", "--"}, paths...) + out, err := runGitRaw(ctx, dir, env, nil, args...) + if err != nil { + return err + } + required := map[string]bool{} + fields := strings.Split(out, "\x00") + for i := 0; i+2 < len(fields); i += 3 { + name := fields[i+2] + switch name { + case "lfs": + return fmt.Errorf("snapshot refused: %q is LFS-tracked; LFS pointers do not round-trip (H5)", fields[i]) + case "unspecified", "unset", "set": + continue + } + must, seen := required[name] + if !seen { + code, val, err := gitExitCode(ctx, dir, env, "config", "--get", "--type=bool", "filter."+name+".required") + if err != nil { + return err + } + must = code == 0 && strings.TrimSpace(val) == "true" + required[name] = must + } + if must { + return fmt.Errorf("snapshot refused: %q resolves the required clean/smudge filter %q; it cannot round-trip byte-exact", fields[i], name) + } + } + } + out, err := runGitRaw(ctx, dir, env, nil, + "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ".gitattributes", ":(glob)**/.gitattributes") + if err != nil { + return err + } + for _, attrFile := range strings.Split(out, "\x00") { + if attrFile == "" { + continue + } + data, err := os.ReadFile(filepath.Join(dir, attrFile)) + if err != nil { + continue // deleted attributes file cannot declare anything + } + if strings.Contains(string(data), "filter=lfs") { + return fmt.Errorf("snapshot refused: %s declares filter=lfs; dispatch does not support LFS repositories (H5)", attrFile) + } + } + return nil +} + +// refuseDirtySubmodules rejects when a dirty path is a gitlink: a submodule's +// content cannot travel in the snapshot, so its dirt would be silently lost. +func refuseDirtySubmodules(ctx context.Context, dir string, env []string, paths []string) error { + out, err := runGitRaw(ctx, dir, env, nil, "ls-files", "-z", "--stage") + if err != nil { + return err + } + gitlinks := map[string]bool{} + for _, record := range strings.Split(out, "\x00") { + if !strings.HasPrefix(record, "160000 ") { + continue + } + if _, path, found := strings.Cut(record, "\t"); found { + gitlinks[path] = true + } + } + for _, p := range paths { + if gitlinks[p] { + return fmt.Errorf("snapshot refused: submodule %q is dirty; commit or clean it before dispatching (H5)", p) + } + } + return nil +} + +func enforceSnapshotCaps(dir string, paths []string, policy SnapshotPolicy) error { + maxFiles := policy.MaxFiles + if maxFiles == 0 { + maxFiles = DefaultSnapshotMaxFiles + } + maxFile := policy.MaxFileSize + if maxFile == 0 { + maxFile = DefaultSnapshotMaxFileSize + } + maxTotal := policy.MaxTotalSize + if maxTotal == 0 { + maxTotal = DefaultSnapshotMaxTotalSize + } + if len(paths) > maxFiles { + return fmt.Errorf("snapshot refused: %d dirty paths exceed the %d-file cap", len(paths), maxFiles) + } + var total int64 + for _, p := range paths { + fi, err := os.Lstat(filepath.Join(dir, p)) + if err != nil { + continue // a deletion has no size + } + if fi.Mode().IsRegular() && fi.Size() > maxFile { + return fmt.Errorf("snapshot refused: %q is %d bytes, over the %d-byte cap", p, fi.Size(), maxFile) + } + total += fi.Size() + if total > maxTotal { + return fmt.Errorf("snapshot refused: dirty state exceeds the %d-byte total cap", maxTotal) + } + } + return nil +} + +// buildSnapshotTree stages exactly paths over base in a throwaway index and +// returns the written tree. +func buildSnapshotTree(ctx context.Context, dir string, env []string, base string, paths []string) (string, error) { + idxDir, err := os.MkdirTemp("", "captain-gitagent-") + if err != nil { + return "", err + } + defer os.RemoveAll(idxDir) + ienv := envWith(env, "GIT_INDEX_FILE="+filepath.Join(idxDir, "index")) + if _, err := runGit(ctx, dir, ienv, "read-tree", base); err != nil { + return "", err + } + var info bytes.Buffer + for _, p := range paths { + line, err := indexEntry(ctx, dir, env, p, len(base)) + if err != nil { + return "", err + } + info.WriteString(line) + info.WriteByte(0) + } + if info.Len() > 0 { + if _, err := runGitIn(ctx, dir, ienv, &info, "update-index", "-z", "--index-info"); err != nil { + return "", err + } + } + return runGit(ctx, dir, ienv, "write-tree") +} + +// indexEntry renders one `update-index --index-info` record for the path's +// on-disk state: mode 0 removes a deleted path, symlinks hash their target, +// and regular files are hashed with --no-filters so the blob is byte-exact. +func indexEntry(ctx context.Context, dir string, env []string, path string, oidLength int) (string, error) { + full := filepath.Join(dir, path) + fi, err := os.Lstat(full) + if errors.Is(err, fs.ErrNotExist) { + if oidLength != 40 && oidLength != 64 { + return "", fmt.Errorf("snapshot refused: unsupported object id length %d", oidLength) + } + return "0 " + strings.Repeat("0", oidLength) + "\t" + path, nil + } + if err != nil { + return "", err + } + switch { + case fi.Mode()&fs.ModeSymlink != 0: + target, err := os.Readlink(full) + if err != nil { + return "", err + } + oid, err := runGitIn(ctx, dir, env, strings.NewReader(target), "hash-object", "-w", "--no-filters", "--stdin") + if err != nil { + return "", err + } + return "120000 " + oid + "\t" + path, nil + case fi.Mode().IsRegular(): + f, err := os.Open(full) + if err != nil { + return "", err + } + defer f.Close() + oid, err := runGitIn(ctx, dir, env, f, "hash-object", "-w", "--no-filters", "--stdin") + if err != nil { + return "", err + } + mode := "100644" + if fi.Mode()&0o111 != 0 { + mode = "100755" + } + return mode + " " + oid + "\t" + path, nil + default: + return "", fmt.Errorf("snapshot refused: %q is a %s, which git cannot carry", path, fi.Mode().Type()) + } +} + +func commitSnapshotTree(ctx context.Context, dir string, env []string, tree, base string) (string, error) { + cenv := envWith(env, + "GIT_AUTHOR_NAME=captain", + "GIT_AUTHOR_EMAIL=captain@localhost", + "GIT_COMMITTER_NAME=captain", + "GIT_COMMITTER_EMAIL=captain@localhost", + ) + return runGitIn(ctx, dir, cenv, + strings.NewReader("captain dispatch snapshot\n"), + "commit-tree", tree, "-p", base) +} diff --git a/pkg/gitagent/snapshot_audit_ginkgo_test.go b/pkg/gitagent/snapshot_audit_ginkgo_test.go new file mode 100644 index 00000000..237eedc7 --- /dev/null +++ b/pkg/gitagent/snapshot_audit_ginkgo_test.go @@ -0,0 +1,93 @@ +package gitagent_test + +import ( + "context" + "os" + "path/filepath" + + dbcontext "github.com/flanksource/commons-db/context" + "github.com/flanksource/commons-db/shell" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// A4.3 audit of commons-db's dirty-state mechanism (shell.Checkout.Dirty → +// applyDirtyState), which issue #39 §4 names as the dispatch-snapshot +// substrate. The dispatch snapshot does NOT use it — TakeSnapshot builds the +// commit from git plumbing directly — but Spec.Setup.Checkout.Dirty remains a +// user-facing surface, so this suite pins what round-trips and what does not. +// Failures here after a commons-db upgrade mean upstream behaviour changed: +// re-audit before trusting it. Known gaps (filed upstream rather than forked, +// A4.2): skip-worktree edits are silently dropped, CRLF normalization is not +// pinned, LFS pointers pass through silently, and dirtyFiles mangles unusual +// paths. +var _ = Describe("commons-db dirty-state audit (A4.3)", func() { + prepare := func(src string) (*shell.SetupResult, error) { + return shell.Prepare(dbcontext.NewContext(context.Background()), &shell.Setup{ + BaseDir: GinkgoT().TempDir(), + Checkout: &shell.Checkout{ + Path: src, + Dirty: &shell.Dirty{Stash: shell.StashAll}, + Worktree: &shell.Worktree{Mode: shell.WorktreeNew, Prefix: "captain-audit"}, + }, + }) + } + + It("round-trips staged deletions, renames, exec bits, symlinks and untracked files", func() { + src := newFidelityRepo() + // The fidelity fixture arms core.autocrlf=true + a text attribute; + // commons-db does not pin normalization (a known gap), so drop the + // CRLF tripwires to audit the rest in isolation. + gitT(src, "config", "core.autocrlf", "false") + Expect(os.Remove(filepath.Join(src, ".gitattributes"))).To(Succeed()) + + res, err := prepare(src) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { + if res.Cleanup != nil { + _ = res.Cleanup() + } + }) + wt := res.Cwd + Expect(wt).NotTo(Equal(src)) + + Expect(os.ReadFile(filepath.Join(wt, "mod.txt"))).To(Equal([]byte("v2\n"))) + Expect(filepath.Join(wt, "del-staged.txt")).NotTo(BeAnExistingFile()) + Expect(filepath.Join(wt, "ren-old.txt")).NotTo(BeAnExistingFile()) + Expect(os.ReadFile(filepath.Join(wt, "ren-new.txt"))).To(Equal([]byte("renamed v2\n"))) + info, err := os.Stat(filepath.Join(wt, "exec.sh")) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode()&0o111).NotTo(BeZero(), "exec bit lost in transit") + target, err := os.Readlink(filepath.Join(wt, "link.txt")) + Expect(err).NotTo(HaveOccurred()) + Expect(target).To(Equal("mod.txt")) + Expect(os.ReadFile(filepath.Join(wt, "newdir/new.txt"))).To(Equal([]byte("brand new\n"))) + Expect(os.ReadFile(filepath.Join(wt, "with space.txt"))).To(Equal([]byte("spaced\n"))) + }) + + It("silently drops skip-worktree edits — the pinned upstream gap", func() { + src := GinkgoT().TempDir() + gitT(src, "init", "-q") + writeFileT(src, "hidden.txt", "committed\n") + writeFileT(src, "visible.txt", "v1\n") + gitT(src, "add", "-A") + gitT(src, "commit", "-q", "-m", "base") + writeFileT(src, "hidden.txt", "local edit git cannot see\n") + gitT(src, "update-index", "--skip-worktree", "hidden.txt") + writeFileT(src, "visible.txt", "v2\n") + + res, err := prepare(src) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { + if res.Cleanup != nil { + _ = res.Cleanup() + } + }) + Expect(os.ReadFile(filepath.Join(res.Cwd, "visible.txt"))).To(Equal([]byte("v2\n"))) + // Documented loss: `git diff` does not report skip-worktree paths, so + // the local edit never reaches the worktree. When an upstream release + // starts carrying it, this assertion breaks — re-audit then. + Expect(os.ReadFile(filepath.Join(res.Cwd, "hidden.txt"))).To(Equal([]byte("committed\n")), + "commons-db now carries skip-worktree edits; update the audit and the upstream-gap list") + }) +}) diff --git a/pkg/gitagent/snapshot_ginkgo_test.go b/pkg/gitagent/snapshot_ginkgo_test.go new file mode 100644 index 00000000..ea44e012 --- /dev/null +++ b/pkg/gitagent/snapshot_ginkgo_test.go @@ -0,0 +1,265 @@ +package gitagent_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent" +) + +// gitT runs git in dir with a pinned identity, failing the spec on error. +func gitT(dir string, args ...string) string { + GinkgoHelper() + full := append([]string{ + "-c", "user.name=test", "-c", "user.email=test@localhost", + "-c", "init.defaultBranch=main", "-c", "protocol.file.allow=always", + }, args...) + cmd := exec.Command("git", full...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + Expect(err).NotTo(HaveOccurred(), "git %v:\n%s", args, out) + return strings.TrimSpace(string(out)) +} + +func writeFileT(dir, path, content string) { + GinkgoHelper() + full := filepath.Join(dir, path) + Expect(os.MkdirAll(filepath.Dir(full), 0o755)).To(Succeed()) + Expect(os.WriteFile(full, []byte(content), 0o644)).To(Succeed()) +} + +// blob returns the byte content of path in the given tree-ish, or "" if absent. +func blob(dir, treeish, path string) string { + cmd := exec.Command("git", "cat-file", "blob", treeish+":"+path) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "" + } + return string(out) +} + +// treeMode returns the mode of path in the tree-ish, or "" if absent. +func treeMode(dir, treeish, path string) string { + cmd := exec.Command("git", "ls-tree", treeish, "--", path) + cmd.Dir = dir + out, _ := cmd.Output() + fields := strings.Fields(string(out)) + if len(fields) < 1 { + return "" + } + return fields[0] +} + +// newFidelityRepo builds the A4.3 fixture: a repo whose dirty state covers +// modifications, staged and unstaged deletions, a rename, an exec-bit flip, a +// symlink, nested and odd-named untracked files, and CRLF bytes under a text +// attribute plus core.autocrlf=true. +func newFidelityRepo() string { + dir := GinkgoT().TempDir() + gitT(dir, "init", "-q") + writeFileT(dir, "keep.txt", "keep\n") + writeFileT(dir, "mod.txt", "v1\n") + writeFileT(dir, "del-staged.txt", "doomed\n") + writeFileT(dir, "del-unstaged.txt", "doomed too\n") + writeFileT(dir, "ren-old.txt", "rename me\n") + writeFileT(dir, "exec.sh", "#!/bin/sh\n") + writeFileT(dir, "sub/nested.txt", "nested\n") + writeFileT(dir, "crlf.txt", "a\nb\n") + gitT(dir, "add", "-A") + gitT(dir, "commit", "-q", "-m", "base") + + writeFileT(dir, "mod.txt", "v2\n") + gitT(dir, "rm", "-q", "del-staged.txt") + Expect(os.Remove(filepath.Join(dir, "del-unstaged.txt"))).To(Succeed()) + gitT(dir, "mv", "ren-old.txt", "ren-new.txt") + writeFileT(dir, "ren-new.txt", "renamed v2\n") + Expect(os.Chmod(filepath.Join(dir, "exec.sh"), 0o755)).To(Succeed()) + Expect(os.Symlink("mod.txt", filepath.Join(dir, "link.txt"))).To(Succeed()) + writeFileT(dir, "newdir/new.txt", "brand new\n") + writeFileT(dir, "with space.txt", "spaced\n") + // CRLF bytes on disk, with every conversion knob armed against them. + Expect(os.WriteFile(filepath.Join(dir, "crlf.txt"), []byte("a\r\nb\r\n"), 0o644)).To(Succeed()) + writeFileT(dir, ".gitattributes", "*.txt text\n") + gitT(dir, "config", "core.autocrlf", "true") + return dir +} + +var _ = Describe("dispatch snapshot", func() { + ctx := context.Background() + + It("captures the dirty worktree byte-exactly and leaves the repo untouched", func() { + dir := newFidelityRepo() + head := gitT(dir, "rev-parse", "HEAD") + statusBefore := gitT(dir, "status", "--porcelain") + + snap, err := gitagent.TakeSnapshot(ctx, dir, gitagent.SnapshotPolicy{}) + Expect(err).NotTo(HaveOccurred()) + Expect(snap.Base).To(Equal(head)) + Expect(gitT(dir, "rev-parse", snap.Commit+"^")).To(Equal(head)) + Expect(gitT(dir, "rev-parse", snap.Commit+"^{tree}")).To(Equal(snap.Tree)) + + Expect(blob(dir, snap.Commit, "mod.txt")).To(Equal("v2\n")) + Expect(treeMode(dir, snap.Commit, "del-staged.txt")).To(BeEmpty()) + Expect(treeMode(dir, snap.Commit, "del-unstaged.txt")).To(BeEmpty()) + Expect(treeMode(dir, snap.Commit, "ren-old.txt")).To(BeEmpty()) + Expect(blob(dir, snap.Commit, "ren-new.txt")).To(Equal("renamed v2\n")) + Expect(treeMode(dir, snap.Commit, "exec.sh")).To(Equal("100755")) + Expect(treeMode(dir, snap.Commit, "link.txt")).To(Equal("120000")) + Expect(blob(dir, snap.Commit, "link.txt")).To(Equal("mod.txt")) + Expect(blob(dir, snap.Commit, "newdir/new.txt")).To(Equal("brand new\n")) + Expect(blob(dir, snap.Commit, "with space.txt")).To(Equal("spaced\n")) + // R6.2: the CRLF bytes on disk are the bytes in the snapshot, despite + // core.autocrlf=true and the `*.txt text` attribute. + Expect(blob(dir, snap.Commit, "crlf.txt")).To(Equal("a\r\nb\r\n")) + // An untouched file keeps its base blob. + Expect(blob(dir, snap.Commit, "keep.txt")).To(Equal("keep\n")) + + // The snapshot must not disturb the repo: same HEAD, same dirt. + Expect(gitT(dir, "rev-parse", "HEAD")).To(Equal(head)) + Expect(gitT(dir, "status", "--porcelain")).To(Equal(statusBefore)) + }) + + It("does not stage deletions under sparse-checkout (R6.1/H4)", func() { + dir := GinkgoT().TempDir() + gitT(dir, "init", "-q") + writeFileT(dir, "top.txt", "top\n") + writeFileT(dir, "vendor/dep.txt", "vendored\n") + gitT(dir, "add", "-A") + gitT(dir, "commit", "-q", "-m", "base") + vendorBlob := gitT(dir, "rev-parse", "HEAD:vendor/dep.txt") + gitT(dir, "sparse-checkout", "set", "--no-cone", "/*", "!vendor/") + Expect(filepath.Join(dir, "vendor", "dep.txt")).NotTo(BeAnExistingFile()) + + writeFileT(dir, "top.txt", "top v2\n") + snap, err := gitagent.TakeSnapshot(ctx, dir, gitagent.SnapshotPolicy{}) + Expect(err).NotTo(HaveOccurred()) + Expect(blob(dir, snap.Commit, "top.txt")).To(Equal("top v2\n")) + Expect(gitT(dir, "rev-parse", snap.Commit+":vendor/dep.txt")).To(Equal(vendorBlob)) + }) + + It("snapshots a clean worktree as the base tree", func() { + dir := GinkgoT().TempDir() + gitT(dir, "init", "-q") + writeFileT(dir, "a.txt", "a\n") + gitT(dir, "add", "-A") + gitT(dir, "commit", "-q", "-m", "base") + snap, err := gitagent.TakeSnapshot(ctx, dir, gitagent.SnapshotPolicy{}) + Expect(err).NotTo(HaveOccurred()) + Expect(snap.Paths).To(BeEmpty()) + Expect(snap.Tree).To(Equal(gitT(dir, "rev-parse", "HEAD^{tree}"))) + }) + + It("applies policy path globs, denies winning over allows", func() { + dir := GinkgoT().TempDir() + gitT(dir, "init", "-q") + writeFileT(dir, "seed.txt", "seed\n") + gitT(dir, "add", "-A") + gitT(dir, "commit", "-q", "-m", "base") + writeFileT(dir, "pkg/a.go", "package a\n") + writeFileT(dir, "pkg/key.pem", "SECRET\n") + writeFileT(dir, "docs/readme.md", "docs\n") + + snap, err := gitagent.TakeSnapshot(ctx, dir, gitagent.SnapshotPolicy{ + Paths: []string{"pkg/**", "!**/*.pem"}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(snap.Paths).To(ConsistOf("pkg/a.go")) + Expect(blob(dir, snap.Commit, "pkg/a.go")).To(Equal("package a\n")) + Expect(treeMode(dir, snap.Commit, "pkg/key.pem")).To(BeEmpty()) + Expect(treeMode(dir, snap.Commit, "docs/readme.md")).To(BeEmpty()) + }) + + It("refuses LFS, required filters, dirty submodules and unmerged paths (H5)", func() { + lfs := GinkgoT().TempDir() + gitT(lfs, "init", "-q") + writeFileT(lfs, "seed.txt", "seed\n") + gitT(lfs, "add", "-A") + gitT(lfs, "commit", "-q", "-m", "base") + writeFileT(lfs, ".gitattributes", "*.bin filter=lfs diff=lfs merge=lfs -text\n") + writeFileT(lfs, "data.bin", "not really lfs\n") + _, err := gitagent.TakeSnapshot(ctx, lfs, gitagent.SnapshotPolicy{}) + Expect(err).To(MatchError(ContainSubstring("LFS"))) + + reqf := GinkgoT().TempDir() + gitT(reqf, "init", "-q") + writeFileT(reqf, "seed.txt", "seed\n") + gitT(reqf, "add", "-A") + gitT(reqf, "commit", "-q", "-m", "base") + gitT(reqf, "config", "filter.crypt.required", "true") + gitT(reqf, "config", "filter.crypt.clean", "cat") + gitT(reqf, "config", "filter.crypt.smudge", "cat") + writeFileT(reqf, "dirty.txt", "dirty\n") + // The bare declaration must not refuse: git-lfs installs one + // machine-wide, so refusing on it would refuse every repository. + _, err = gitagent.TakeSnapshot(ctx, reqf, gitagent.SnapshotPolicy{}) + Expect(err).NotTo(HaveOccurred()) + writeFileT(reqf, ".gitattributes", "*.txt filter=crypt\n") + _, err = gitagent.TakeSnapshot(ctx, reqf, gitagent.SnapshotPolicy{}) + Expect(err).To(MatchError(ContainSubstring("required clean/smudge filter"))) + + sub := GinkgoT().TempDir() + gitT(sub, "init", "-q") + writeFileT(sub, "inner.txt", "inner\n") + gitT(sub, "add", "-A") + gitT(sub, "commit", "-q", "-m", "sub base") + super := GinkgoT().TempDir() + gitT(super, "init", "-q") + writeFileT(super, "seed.txt", "seed\n") + gitT(super, "add", "-A") + gitT(super, "commit", "-q", "-m", "base") + gitT(super, "submodule", "add", sub, "submod") + gitT(super, "commit", "-q", "-m", "add submodule") + writeFileT(super, "submod/inner.txt", "modified inner\n") + _, err = gitagent.TakeSnapshot(ctx, super, gitagent.SnapshotPolicy{}) + Expect(err).To(MatchError(ContainSubstring("submodule"))) + + conflict := GinkgoT().TempDir() + gitT(conflict, "init", "-q") + writeFileT(conflict, "c.txt", "base\n") + gitT(conflict, "add", "-A") + gitT(conflict, "commit", "-q", "-m", "base") + gitT(conflict, "checkout", "-q", "-b", "side") + writeFileT(conflict, "c.txt", "side\n") + gitT(conflict, "commit", "-q", "-am", "side") + gitT(conflict, "checkout", "-q", "main") + writeFileT(conflict, "c.txt", "main\n") + gitT(conflict, "commit", "-q", "-am", "main") + cmd := exec.Command("git", + "-c", "user.name=test", "-c", "user.email=test@localhost", + "merge", "side") + cmd.Dir = conflict + _ = cmd.Run() // expected to conflict + _, err = gitagent.TakeSnapshot(ctx, conflict, gitagent.SnapshotPolicy{}) + Expect(err).To(MatchError(ContainSubstring("unmerged"))) + }) + + It("enforces snapshot caps", func() { + dir := GinkgoT().TempDir() + gitT(dir, "init", "-q") + writeFileT(dir, "seed.txt", "seed\n") + gitT(dir, "add", "-A") + gitT(dir, "commit", "-q", "-m", "base") + writeFileT(dir, "a.txt", "a\n") + writeFileT(dir, "b.txt", "b\n") + + _, err := gitagent.TakeSnapshot(ctx, dir, gitagent.SnapshotPolicy{MaxFiles: 1}) + Expect(err).To(MatchError(ContainSubstring("cap"))) + + _, err = gitagent.TakeSnapshot(ctx, dir, gitagent.SnapshotPolicy{MaxFileSize: 1}) + Expect(err).To(MatchError(ContainSubstring("cap"))) + }) + + It("refuses a repository with no commits", func() { + dir := GinkgoT().TempDir() + gitT(dir, "init", "-q") + _, err := gitagent.TakeSnapshot(ctx, dir, gitagent.SnapshotPolicy{}) + Expect(err).To(MatchError(ContainSubstring("at least one commit"))) + }) +}) diff --git a/pkg/gitagent/sshclient.go b/pkg/gitagent/sshclient.go new file mode 100644 index 00000000..bc769e15 --- /dev/null +++ b/pkg/gitagent/sshclient.go @@ -0,0 +1,150 @@ +// A minimal GIT_SSH_COMMAND implementation. Dispatch and relay pushes run +// git with GIT_SSH_COMMAND pointing at `captain sandbox git-agent ssh`, so no +// system ssh client is needed, the key never leaves captain-managed paths, +// and the server's host key is verified against a pinned fingerprint — the +// endpoint name is never trusted as identity. +package gitagent + +import ( + "fmt" + "io" + "net" + "os" + "strings" + "time" + + gossh "golang.org/x/crypto/ssh" +) + +// Environment variables the client reads; flags would be mangled by git's +// shell-splitting of GIT_SSH_COMMAND. +const ( + EnvSSHKey = "CAPTAIN_SSH_KEY" + EnvSSHHostFingerprint = "CAPTAIN_SSH_HOST_FINGERPRINT" + EnvSSHUser = "CAPTAIN_SSH_USER" +) + +// SSHClientMain speaks git's ssh-command contract: argv is +// `[-4|-6] [-p port] [--] [user@]host command...`. It returns the process +// exit code, propagating the remote command's. +func SSHClientMain(args []string) int { + code, err := runSSHClient(args, os.Stdin, os.Stdout, os.Stderr) + if err != nil { + fmt.Fprintf(os.Stderr, "captain-ssh: %v\n", err) + if code == 0 { + code = 255 + } + } + return code +} + +func runSSHClient(args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + host, port, command, err := parseSSHArgs(args) + if err != nil { + return 255, err + } + keyPath := os.Getenv(EnvSSHKey) + pinned := os.Getenv(EnvSSHHostFingerprint) + if keyPath == "" || pinned == "" { + return 255, fmt.Errorf("%s and %s must be set", EnvSSHKey, EnvSSHHostFingerprint) + } + user := os.Getenv(EnvSSHUser) + if user == "" { + user = "captain" + } + if at := strings.LastIndex(host, "@"); at >= 0 { + user, host = host[:at], host[at+1:] + } + signer, _, err := EnsureKeyPair(keyPath) + if err != nil { + return 255, err + } + config := &gossh.ClientConfig{ + User: user, + Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)}, + HostKeyCallback: func(_ string, _ net.Addr, key gossh.PublicKey) error { + if got := gossh.FingerprintSHA256(key); got != pinned { + return fmt.Errorf("host key %s does not match the pinned %s", got, pinned) + } + return nil + }, + Timeout: 30 * time.Second, + } + client, err := gossh.Dial("tcp", net.JoinHostPort(host, port), config) + if err != nil { + return 255, err + } + defer client.Close() + session, err := client.NewSession() + if err != nil { + return 255, err + } + defer session.Close() + session.Stdin = stdin + session.Stdout = stdout + session.Stderr = stderr + if err := session.Run(command); err != nil { + if exitErr, ok := err.(*gossh.ExitError); ok { + return exitErr.ExitStatus(), nil + } + return 255, err + } + return 0, nil +} + +// valueFlags are the ssh(1) options git may pass that consume the following +// argument. Anything else beginning with - is treated as a standalone switch, +// so an option we do not model cannot be mistaken for the hostname. +var valueFlags = map[string]bool{ + "-o": true, "-p": true, "-i": true, "-l": true, "-F": true, "-c": true, + "-m": true, "-b": true, "-e": true, "-w": true, "-J": true, "-S": true, + "-D": true, "-L": true, "-R": true, "-W": true, "-Q": true, "-B": true, + "-E": true, "-I": true, +} + +// parseSSHArgs implements git's ssh-command contract: +// `[options] [--] [user@]host command...`. git invokes it with options of its +// own — notably `-o SendEnv=GIT_PROTOCOL` — so options must be skipped +// generically rather than by an allowlist of the ones we care about. +func parseSSHArgs(args []string) (host, port, command string, err error) { + port = "22" + i := 0 + for i < len(args) { + arg := args[i] + if arg == "--" { + i++ + break + } + if !strings.HasPrefix(arg, "-") || arg == "-" { + break + } + // Joined forms (-p2222, -oFoo=bar) carry their value inline. + if len(arg) > 2 && valueFlags[arg[:2]] { + if arg[:2] == "-p" { + port = arg[2:] + } + i++ + continue + } + if valueFlags[arg] { + if i+1 >= len(args) { + return "", "", "", fmt.Errorf("%s needs an argument", arg) + } + if arg == "-p" { + port = args[i+1] + } + i += 2 + continue + } + i++ // a standalone switch such as -4, -6, -q, -T + } + if i >= len(args) { + return "", "", "", fmt.Errorf("usage: [options] [user@]host command") + } + host = args[i] + command = strings.Join(args[i+1:], " ") + if strings.TrimSpace(command) == "" { + return "", "", "", fmt.Errorf("no remote command given (interactive sessions are not supported)") + } + return host, port, command, nil +} diff --git a/pkg/gitagent/sshclient_test.go b/pkg/gitagent/sshclient_test.go new file mode 100644 index 00000000..8f031128 --- /dev/null +++ b/pkg/gitagent/sshclient_test.go @@ -0,0 +1,65 @@ +package gitagent + +import "testing" + +// git invokes GIT_SSH_COMMAND with options of its own — `-o SendEnv=…` in +// particular. Mistaking one for the hostname makes every dispatch and relay +// push fail with a DNS lookup of the option itself. +func TestParseSSHArgs(t *testing.T) { + cases := []struct { + name string + args []string + host string + port string + command string + wantFail bool + }{ + { + name: "git's own invocation", + args: []string{"-o", "SendEnv=GIT_PROTOCOL", "-p", "7502", "captain@127.0.0.1", "git-receive-pack", "'/repo.git'"}, + host: "captain@127.0.0.1", port: "7502", + command: "git-receive-pack '/repo.git'", + }, + { + name: "no options", + args: []string{"host", "git-receive-pack", "'r.git'"}, + host: "host", port: "22", command: "git-receive-pack 'r.git'", + }, + { + name: "joined option values", + args: []string{"-p2222", "-oBatchMode=yes", "host", "cmd"}, + host: "host", port: "2222", command: "cmd", + }, + { + name: "standalone switches and a separator", + args: []string{"-4", "-q", "--", "host", "cmd"}, + host: "host", port: "22", command: "cmd", + }, + { + name: "unmodelled option does not become the host", + args: []string{"-i", "/tmp/key", "-T", "host", "cmd"}, + host: "host", port: "22", command: "cmd", + }, + {name: "no command", args: []string{"host"}, wantFail: true}, + {name: "nothing", args: nil, wantFail: true}, + {name: "dangling option value", args: []string{"-o"}, wantFail: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + host, port, command, err := parseSSHArgs(tc.args) + if tc.wantFail { + if err == nil { + t.Fatalf("want an error, got host=%q port=%q command=%q", host, port, command) + } + return + } + if err != nil { + t.Fatal(err) + } + if host != tc.host || port != tc.port || command != tc.command { + t.Fatalf("host=%q port=%q command=%q, want %q %q %q", + host, port, command, tc.host, tc.port, tc.command) + } + }) + } +} diff --git a/pkg/gitagent/state.go b/pkg/gitagent/state.go new file mode 100644 index 00000000..51aaa400 --- /dev/null +++ b/pkg/gitagent/state.go @@ -0,0 +1,118 @@ +// Receiver-side task state, kept under /captain/ — outside the object +// store, because a rejected push must leave zero new objects and refs while +// the verdict still has to survive (R6.9). + +package gitagent + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// Policy is the policy.json payload referenced by a control ref (§4). +type Policy struct { + Paths []string `json:"paths,omitempty"` + MaxAttempts int `json:"maxAttempts,omitempty"` + MaxBlobSize int64 `json:"maxBlobSize,omitempty"` +} + +// TaskState records what a receiver knows about a dispatched task. +type TaskState struct { + Task string `json:"task"` + Agent string `json:"agent,omitempty"` + Base string `json:"base"` + DispatchCommit string `json:"dispatchCommit"` + ControlCommit string `json:"controlCommit,omitempty"` // the dispatched control payloads + Attempts int `json:"attempts"` // highest attempt seen + Relay RelayMode `json:"relay,omitempty"` + Mailbox string `json:"mailbox,omitempty"` // opaque route chosen by the supervisor + Policy Policy `json:"policy"` + Hooks *HookSets `json:"hooks,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func taskStateDir(repo, task string) string { + return filepath.Join(repo, "captain", "tasks", task) +} + +// LoadTaskState reads a task's state; ok is false when the task is unknown. +func LoadTaskState(repo, task string) (*TaskState, bool, error) { + if err := ValidateTaskID(task); err != nil { + return nil, false, err + } + return loadTaskStateUnlocked(repo, task) +} + +func loadTaskStateUnlocked(repo, task string) (*TaskState, bool, error) { + data, err := os.ReadFile(filepath.Join(taskStateDir(repo, task), "state.json")) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + var st TaskState + if err := json.Unmarshal(data, &st); err != nil { + return nil, false, fmt.Errorf("task %s state: %w", task, err) + } + return &st, true, nil +} + +// SaveTaskState persists st atomically. +func SaveTaskState(repo string, st *TaskState) error { + if err := ValidateTaskID(st.Task); err != nil { + return err + } + dir := taskStateDir(repo, st.Task) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + return withFileLock(filepath.Join(dir, "state.lock"), 0o600, func() error { + return saveTaskStateUnlocked(repo, st) + }) +} + +// UpdateTaskState holds the task lock across load, mutation, and save. The +// callback returns false when it inspected state but intentionally made no +// durable change. +func UpdateTaskState(repo, task string, update func(*TaskState) (bool, error)) (*TaskState, error) { + if err := ValidateTaskID(task); err != nil { + return nil, err + } + dir := taskStateDir(repo, task) + if _, err := os.Stat(dir); err != nil { + return nil, err + } + var result *TaskState + err := withFileLock(filepath.Join(dir, "state.lock"), 0o600, func() error { + st, ok, err := loadTaskStateUnlocked(repo, task) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("task %s state is missing", task) + } + save, err := update(st) + if err != nil { + return err + } + result = st + if !save { + return nil + } + return saveTaskStateUnlocked(repo, st) + }) + return result, err +} + +func saveTaskStateUnlocked(repo string, st *TaskState) error { + st.UpdatedAt = time.Now().UTC() + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + return writeFileAtomic(filepath.Join(taskStateDir(repo, st.Task), "state.json"), append(data, '\n'), 0o644) +} diff --git a/pkg/gitagent/status.go b/pkg/gitagent/status.go new file mode 100644 index 00000000..7fad69d4 --- /dev/null +++ b/pkg/gitagent/status.go @@ -0,0 +1,58 @@ +package gitagent + +import ( + "context" + "fmt" + "strings" +) + +// StatusEntry is one record of `git status --porcelain -z`. +type StatusEntry struct { + X, Y byte // staged / worktree status codes + Path string // repo-relative, verbatim (the -z format does not quote) +} + +// Unmerged reports whether the entry is in a conflict state. +func (e StatusEntry) Unmerged() bool { + return e.X == 'U' || e.Y == 'U' || + (e.X == 'D' && e.Y == 'D') || (e.X == 'A' && e.Y == 'A') +} + +// statusZ lists every dirty path in dir. --no-renames keeps records +// single-path (a staged rename arrives as its A and D halves, which is +// exactly the final state a tree-level snapshot needs), and -z emits paths +// verbatim where the human format would quote and escape them. +func statusZ(ctx context.Context, dir string, env []string) ([]StatusEntry, error) { + out, err := runGitRaw(ctx, dir, env, nil, + "status", "--porcelain", "-z", "--untracked-files=all", "--no-renames") + if err != nil { + return nil, err + } + return parseStatusZ(out) +} + +func parseStatusZ(out string) ([]StatusEntry, error) { + fields := strings.Split(out, "\x00") + var entries []StatusEntry + for i := 0; i < len(fields); i++ { + record := fields[i] + if record == "" { + continue + } + if len(record) < 4 || record[2] != ' ' { + return nil, fmt.Errorf("unparseable git status record %q", record) + } + entry := StatusEntry{X: record[0], Y: record[1], Path: record[3:]} + entries = append(entries, entry) + // Defensive: rename/copy records carry a second, source-path field. + // --no-renames should prevent them, but skipping one silently would + // misattribute the next record. + if entry.X == 'R' || entry.X == 'C' || entry.Y == 'R' || entry.Y == 'C' { + if i+1 < len(fields) && fields[i+1] != "" { + i++ + entries = append(entries, StatusEntry{X: entry.X, Y: entry.Y, Path: fields[i]}) + } + } + } + return entries, nil +} diff --git a/pkg/gitagent/substrate_ginkgo_test.go b/pkg/gitagent/substrate_ginkgo_test.go new file mode 100644 index 00000000..39d5c024 --- /dev/null +++ b/pkg/gitagent/substrate_ginkgo_test.go @@ -0,0 +1,28 @@ +package gitagent_test + +import ( + "os/exec" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The protocol's git assumptions (SPEC §1) are re-verified against the +// installed git on every run, so a git upgrade that changes quarantine, +// push-option or materialization behaviour fails loudly here rather than +// silently corrupting a dispatch. +var _ = Describe("verified substrate", func() { + It("passes the empirical harness against the installed git", func() { + if _, err := exec.LookPath("git"); err != nil { + Skip("git not installed") + } + if _, err := exec.LookPath("bash"); err != nil { + Skip("bash not installed") + } + script, err := filepath.Abs(filepath.Join("..", "..", "hack", "gitagent_empirical.sh")) + Expect(err).NotTo(HaveOccurred()) + out, err := exec.Command("bash", script).CombinedOutput() + Expect(err).NotTo(HaveOccurred(), "harness output:\n%s", out) + }) +}) diff --git a/pkg/gitagent/verdict.go b/pkg/gitagent/verdict.go new file mode 100644 index 00000000..a305bd49 --- /dev/null +++ b/pkg/gitagent/verdict.go @@ -0,0 +1,89 @@ +// Verdicts (§7.1): the machine record of one tier's decision. A verdict is +// persisted outside git before pre-receive exits non-zero, because quarantine +// discards every object of a rejected push (R6.9). +package gitagent + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" +) + +// VerdictStatus is accepted | rejected | error. error means the tier could +// not reach a verdict — and an indeterminate verdict rejects (R7.5). +type VerdictStatus string + +const ( + StatusAccepted VerdictStatus = "accepted" + StatusRejected VerdictStatus = "rejected" + StatusError VerdictStatus = "error" +) + +// Finding is one hook's contribution to a verdict. +type Finding struct { + Hook string `json:"hook"` + Kind string `json:"kind"` // exec | commit | prompt | fixture + Path string `json:"path,omitempty"` + Message string `json:"message,omitempty"` + Feedback string `json:"feedback,omitempty"` +} + +// TierVerdict is the verdict.json payload. +type TierVerdict struct { + V int `json:"v"` + Task string `json:"task"` + Attempt int `json:"attempt"` + Status VerdictStatus `json:"status"` + Tier string `json:"tier"` // "sidecar" | "supervisor" + Findings []Finding `json:"findings,omitempty"` +} + +// Rejects reports whether the push must be refused. Only an explicit accept +// passes: error is indeterminate and indeterminate rejects (R7.5). +func (v TierVerdict) Rejects() bool { return v.Status != StatusAccepted } + +func verdictPath(repo, task string, attempt int) string { + return filepath.Join(taskStateDir(repo, task), "verdicts", strconv.Itoa(attempt)+".json") +} + +// SaveVerdict persists v under /captain/tasks//verdicts/, keyed +// by attempt, atomically — the out-of-band record a rejected push leaves +// behind and the channel async relay reports through (R6.9). +func SaveVerdict(repo string, v TierVerdict) error { + if err := ValidateTaskID(v.Task); err != nil { + return err + } + if v.Attempt < 1 { + return fmt.Errorf("verdict attempt %d must be positive", v.Attempt) + } + path := verdictPath(repo, v.Task, v.Attempt) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return writeFileAtomic(path, append(data, '\n'), 0o644) +} + +// LoadVerdict reads a persisted verdict; ok is false when none exists. +func LoadVerdict(repo, task string, attempt int) (*TierVerdict, bool, error) { + if err := ValidateTaskID(task); err != nil { + return nil, false, err + } + data, err := os.ReadFile(verdictPath(repo, task, attempt)) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + var v TierVerdict + if err := json.Unmarshal(data, &v); err != nil { + return nil, false, fmt.Errorf("verdict %s/%d: %w", task, attempt, err) + } + return &v, true, nil +} diff --git a/pkg/gitagent/workspace.go b/pkg/gitagent/workspace.go new file mode 100644 index 00000000..a4b6197d --- /dev/null +++ b/pkg/gitagent/workspace.go @@ -0,0 +1,140 @@ +// Sidecar workspace setup (§6.1 steps 4–5, run from post-receive where +// quarantine has ended — R6.4/H2): the agent gets an ordinary clone with a +// branch and upstream so a bare `git commit` + `git push` is sufficient +// (R3.1/H17), task.json lands outside the worktree, and the agent process is +// launched fully detached so the dispatch push returns promptly (R6.3/H12). + +package gitagent + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + + "golang.org/x/sys/unix" +) + +// NoAgentCommand opts a sidecar out of launching anything, leaving the +// prepared worktree for a human. It is spelled explicitly so that "no agent +// ran" is always a choice on the record rather than an empty config field. +const NoAgentCommand = "none" + +// SetupAgentWorkspace creates the task branch in the sidecar repo and clones +// it (object store shared) into /captain/tasks//worktree. A clone +// rather than a linked worktree keeps the agent's unaccepted commits in the +// agent's own object store: a rejected push leaves the sidecar repo clean. +// runtimeIdentity becomes the author name on the agent's ordinary commits. +func SetupAgentWorkspace(ctx context.Context, sidecarRepo, task, dispatchCommit, runtimeIdentity string) (string, error) { + branch, err := AgentBranch(task) + if err != nil { + return "", err + } + env := ScrubGitEnv(os.Environ()) + if _, err := runGit(ctx, sidecarRepo, env, "update-ref", branch, dispatchCommit); err != nil { + return "", err + } + workdir := filepath.Join(taskStateDir(sidecarRepo, task), "worktree") + if _, err := os.Stat(workdir); err == nil { + return workdir, nil // re-dispatch onto an existing workspace is a no-op + } + branchName := "captain/" + task + if _, err := runGit(ctx, filepath.Dir(workdir), env, + "clone", "--quiet", "--shared", "--branch", branchName, sidecarRepo, workdir); err != nil { + return "", err + } + // Pin the selected runtime so a bare `git commit` needs no global config + // and still records which model and effort produced it. + for _, kv := range [][2]string{{"user.name", runtimeIdentity}, {"user.email", "agent@captain.local"}} { + if _, err := runGit(ctx, workdir, env, "config", kv[0], kv[1]); err != nil { + return "", err + } + } + return workdir, nil +} + +// WriteTaskFile materializes task.json outside the worktree (§4). +func WriteTaskFile(sidecarRepo, task string, payload []byte) (string, error) { + dir := taskStateDir(sidecarRepo, task) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + path := filepath.Join(dir, ControlTaskFile) + return path, writeFileAtomic(path, payload, 0o644) +} + +// LaunchAgent starts command (a shell line) fully detached: its own session, +// stdio redirected to files. A child inheriting the hook's stdout keeps the +// sideband pipe open and receive-pack waits for EOF — the dispatch push would +// hang for the agent's lifetime (R6.3/H12). +// +// An empty command is an error, not a no-op. Nothing would run, nothing would +// push, and the dispatch would wait out its whole budget for work that was +// never started — a silence indistinguishable from an agent still thinking. +// Callers that genuinely want a bare workspace pass NoAgentCommand. +func LaunchAgent(sidecarRepo, task, workdir, taskFile, command string) error { + if strings.TrimSpace(command) == "" { + return fmt.Errorf("task %s: no agent command configured; nothing would run and the dispatch would wait for work that never started", task) + } + dir := taskStateDir(sidecarRepo, task) + if command == NoAgentCommand { + // Explicitly hand the workspace to a human: record why nothing ran so + // a waiting dispatch is diagnosable from the task directory. + return writeFileAtomic(filepath.Join(dir, "agent.stdout.log"), + []byte("agentCommand is "+NoAgentCommand+": the worktree is prepared but no agent was launched.\n"+ + "Commit and push from "+workdir+" to complete this task.\n"), 0o644) + } + stdout, err := os.OpenFile(filepath.Join(dir, "agent.stdout.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + defer stdout.Close() + stderr, err := os.OpenFile(filepath.Join(dir, "agent.stderr.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + defer stderr.Close() + cmd := exec.Command("sh", "-c", command) + cmd.Dir = workdir + cmd.Env = envWith(ScrubGitEnv(os.Environ()), + "CAPTAIN_TASK="+task, + "CAPTAIN_TASK_FILE="+taskFile, + ) + cmd.Stdin = nil + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := markInheritedDescriptorsCloseOnExec(); err != nil { + return err + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("launching agent: %w", err) + } + // Detach: the hook must not wait. Release drops our handle; the process + // reparents to init when the hook exits. + return cmd.Process.Release() +} + +// markInheritedDescriptorsCloseOnExec prevents receive-pack's sideband pipes +// from surviving in the detached agent and keeping the dispatch push open. +func markInheritedDescriptorsCloseOnExec() error { + closeRangeErr := unix.CloseRange(3, ^uint(0), unix.CLOSE_RANGE_CLOEXEC) + if closeRangeErr == nil { + return nil + } + entries, readErr := os.ReadDir("/proc/self/fd") + if readErr != nil { + return fmt.Errorf("marking inherited descriptors close-on-exec: close_range: %v; /proc/self/fd: %w", closeRangeErr, readErr) + } + for _, entry := range entries { + fd, parseErr := strconv.Atoi(entry.Name()) + if parseErr == nil && fd >= 3 { + syscall.CloseOnExec(fd) + } + } + return nil +} diff --git a/pkg/gitagent/workspace_test.go b/pkg/gitagent/workspace_test.go new file mode 100644 index 00000000..fc17829b --- /dev/null +++ b/pkg/gitagent/workspace_test.go @@ -0,0 +1,103 @@ +package gitagent + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTaskRuntimeIdentityUsesModelAndEffort(t *testing.T) { + got := taskRuntimeIdentity([]byte(`{"model":"gpt-5.6-sol","backend":"codex-agent","effort":"high"}`)) + if got != "agent:gpt-5.6-sol:high" { + t.Fatalf("identity = %q", got) + } + if got := taskRuntimeIdentity([]byte(`{}`)); got != "captain-agent" { + t.Fatalf("legacy identity = %q", got) + } +} + +func TestSetupAgentWorkspacePinsRuntimeIdentity(t *testing.T) { + ctx := context.Background() + repo := filepath.Join(t.TempDir(), "sidecar.git") + if err := InitSidecar(ctx, repo); err != nil { + t.Fatal(err) + } + commit, err := BuildControlCommit(ctx, repo, nil, map[string][]byte{"seed.txt": []byte("seed\n")}) + if err != nil { + t.Fatal(err) + } + if err := SaveTaskState(repo, &TaskState{Task: "t-identity"}); err != nil { + t.Fatal(err) + } + workdir, err := SetupAgentWorkspace(ctx, repo, "t-identity", commit, "agent:gpt-5.6-sol:high") + if err != nil { + t.Fatal(err) + } + env := ScrubGitEnv(os.Environ()) + if got, err := runGit(ctx, workdir, env, "config", "user.name"); err != nil || got != "agent:gpt-5.6-sol:high" { + t.Fatalf("user.name = %q, %v", got, err) + } + if got, err := runGit(ctx, workdir, env, "config", "user.email"); err != nil || got != "agent@captain.local" { + t.Fatalf("user.email = %q, %v", got, err) + } +} + +// A dispatch that launches nothing leaves the supervisor waiting out its whole +// budget on work that never started — a silence indistinguishable from an +// agent still thinking. Empty must therefore be an error, and "no agent" must +// be spelled explicitly. +func TestLaunchAgentRefusesAnEmptyCommand(t *testing.T) { + repo := t.TempDir() + if err := os.MkdirAll(taskStateDir(repo, "t-1"), 0o755); err != nil { + t.Fatal(err) + } + for _, command := range []string{"", " "} { + err := LaunchAgent(repo, "t-1", filepath.Join(repo, "wt"), "task.json", command) + if err == nil || !strings.Contains(err.Error(), "no agent command") { + t.Fatalf("command %q: err = %v, want a refusal", command, err) + } + } +} + +func TestLaunchAgentRecordsAnExplicitOptOut(t *testing.T) { + repo := t.TempDir() + dir := taskStateDir(repo, "t-1") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + workdir := filepath.Join(repo, "wt") + if err := LaunchAgent(repo, "t-1", workdir, "task.json", NoAgentCommand); err != nil { + t.Fatal(err) + } + log, err := os.ReadFile(filepath.Join(dir, "agent.stdout.log")) + if err != nil { + t.Fatalf("an opt-out must leave a diagnosable record: %v", err) + } + if !strings.Contains(string(log), workdir) { + t.Fatalf("the record must name the worktree to push from: %s", log) + } +} + +// A backend that configures no agentCommand still gets a real agent, supplied +// by the host. Falling through to empty is what made the advertised flow wait +// forever. +func TestAgentCommandForFallsBackToTheHostDefault(t *testing.T) { + host := HookHost{DefaultAgentCommand: func(repo, task string) string { + return "captain run-task " + repo + " " + task + }} + if got := host.agentCommandFor("/repo.git", "t-1"); got != "captain run-task /repo.git t-1" { + t.Fatalf("agentCommandFor = %q", got) + } + + host.Runtime.AgentCommand = "my-own-agent" + if got := host.agentCommandFor("/repo.git", "t-1"); got != "my-own-agent" { + t.Fatalf("a configured command must win, got %q", got) + } + + bare := HookHost{} + if got := bare.agentCommandFor("/repo.git", "t-1"); got != "" { + t.Fatalf("with no default and no config the result is empty (LaunchAgent then refuses), got %q", got) + } +} diff --git a/pkg/sandbox/adapter/gitagent.go b/pkg/sandbox/adapter/gitagent.go new file mode 100644 index 00000000..c95aa5f9 --- /dev/null +++ b/pkg/sandbox/adapter/gitagent.go @@ -0,0 +1,274 @@ +// The git-agent adapter: whole-run relocation over the git-agent protocol. +// Execute snapshots the supervisor's dirty worktree, dispatches it to an +// enrolled agent's sidecar, and blocks until the two-tier vet loop produces a +// final verdict, which comes back as the run's response. +package adapter + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/flanksource/captain/pkg/ai/agent/setup" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" +) + +// GitAgent constructs the remote-execution adapter. Backend options carry the +// enrollment map and transport endpoints; nothing here is read from the spec +// except the work itself. +func GitAgent(cfg api.SandboxConfig) (api.Sandbox, error) { + if _, err := configuredWaitTimeout(cfg.Options); err != nil { + return nil, err + } + return &gitAgentSandbox{cfg: cfg}, nil +} + +func init() { + api.RegisterSandbox(api.SandboxGitAgent, GitAgent) +} + +type gitAgentSandbox struct { + cfg api.SandboxConfig +} + +func (g *gitAgentSandbox) Kind() api.SandboxKind { return api.SandboxGitAgent } + +func (g *gitAgentSandbox) Prepare(_ context.Context, _ *api.Spec) (*api.SandboxSession, error) { + return &api.SandboxSession{}, nil +} + +func (g *gitAgentSandbox) Close() error { return nil } + +// IsolatesWorkspace: the run happens in the agent's own worktree, so pairing +// with --worktree or a setup checkout must be refused, never doubled. +func (g *gitAgentSandbox) IsolatesWorkspace() bool { return true } + +// ProvidesEgressProxy: the dispatch sandbox holds placeholders, not +// credentials; the sidecar proxy substitutes on the way out. +func (g *gitAgentSandbox) ProvidesEgressProxy() bool { return true } + +func (g *gitAgentSandbox) Execute(ctx context.Context, spec api.Spec) (*api.Response, error) { + if spec.Setup != nil && setup.Relocates(spec.Setup.Checkout) { + return nil, fmt.Errorf("sandbox git-agent relocates the run; it cannot be combined with a setup checkout (register exactly one isolator)") + } + target, err := g.resolveTarget() + if err != nil { + return nil, err + } + repoDir := spec.Cwd() + mailbox, err := gitagent.EnsureMailbox(ctx, target.mailboxRoot, repoDir) + if err != nil { + return nil, err + } + exe, err := os.Executable() + if err != nil { + return nil, err + } + configPath, err := captainconfig.Path() + if err != nil { + return nil, err + } + if err := gitagent.InstallHookShims(mailbox.Path, exe, configPath, gitagent.RoleMailbox, g.cfg.Name); err != nil { + return nil, err + } + hooksJSON, err := hookSetsJSON(g.cfg.Options) + if err != nil { + return nil, err + } + prompt := "" + system := "" + if spec.Prompt.User != "" { + prompt = spec.Prompt.User + system = spec.Prompt.System + } + timeout := strings.TrimSpace(spec.Budget.Timeout) + if timeout == "" { + timeout = target.waitTimeout.String() + } + dispatch, err := gitagent.Dispatch(ctx, gitagent.DispatchRequest{ + RepoDir: repoDir, + MailboxPath: mailbox.Path, + MailboxRoute: mailbox.Route, + Agent: target.agent, + SidecarURL: target.url, + SidecarHostFP: target.hostFingerprint, + KeyPath: target.keyPath, + Relay: target.relay, + Policy: target.policy, + // The resolved backend travels with the model: the agent must run the + // runtime the supervisor selected, not re-resolve the name against its + // own defaults and quietly pick a different one. + TaskPayload: gitagent.TaskPayload{ + Prompt: prompt, System: system, + Model: spec.Name, Backend: string(spec.Backend), Effort: spec.Effort, Timeout: timeout, + }, + HooksJSON: hooksJSON, + }) + if err != nil { + return nil, err + } + verdict, err := gitagent.AwaitOutcome(ctx, mailbox.Path, dispatch.Task, target.waitTimeout) + if err != nil { + return nil, fmt.Errorf("task %s dispatched but not concluded: %w", dispatch.Task, err) + } + return gitAgentResponse(dispatch.Task, verdict), nil +} + +// hookSetsJSON preserves the sidecar/supervisor split declared by the backend +// and validates it against the protocol payload before dispatch. +func hookSetsJSON(options map[string]any) ([]byte, error) { + value := any(map[string]any{}) + if configured, ok := options["hooks"]; ok { + value = configured + } + data, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode git-agent hooks: %w", err) + } + if _, err := gitagent.DecodeHookSets(data); err != nil { + return nil, err + } + return data, nil +} + +func gitAgentResponse(task string, verdict *gitagent.TierVerdict) *api.Response { + text := fmt.Sprintf("git-agent task %s attempt %d: %s", task, verdict.Attempt, verdict.Status) + for _, f := range verdict.Findings { + text += "\n" + f.Hook + if f.Message != "" { + text += ": " + f.Message + } + } + resp := &api.Response{Text: text, StructuredData: verdict} + for _, f := range verdict.Findings { + if f.Hook == "integrate" && f.Path != "" { + resp.Workspace = &api.Workspace{Branch: f.Path} + } + } + return resp +} + +type gitAgentTarget struct { + agent string + url string + hostFingerprint string + keyPath string + mailboxRoot string + relay gitagent.RelayMode + policy gitagent.Policy + waitTimeout time.Duration +} + +// resolveTarget picks the enrolled agent — pinned by the spec's sandbox.agent, +// or the sole enrolled one — and assembles transport details from options. +func (g *gitAgentSandbox) resolveTarget() (*gitAgentTarget, error) { + opts := g.cfg.Options + agents, _ := opts["agents"].(map[string]any) + name := g.cfg.Agent + if name == "" { + if len(agents) == 1 { + for only := range agents { + name = only + } + } else { + return nil, fmt.Errorf("backend %q has %d enrolled agents; pin one with sandbox.agent", g.cfg.Name, len(agents)) + } + } + entry, _ := agents[name].(map[string]any) + if entry == nil { + return nil, fmt.Errorf("agent %q is not enrolled in backend %q", name, g.cfg.Name) + } + url, _ := entry["url"].(string) + hostFP, _ := entry["hostFingerprint"].(string) + if url == "" || hostFP == "" { + return nil, fmt.Errorf( + "agent %q has no endpoint recorded: it enrolled before advertising one. "+ + "Re-enroll it (captain sandbox git-agent add %s) so its serve reports its URL and host key", + name, name) + } + keysDir, err := gitAgentKeysDir() + if err != nil { + return nil, err + } + target := &gitAgentTarget{ + agent: name, + url: url, + hostFingerprint: hostFP, + keyPath: stringOption(opts, "key", filepath.Join(keysDir, dispatchKeyFile)), + // The long-running endpoint serves this root; each dispatch derives a + // repository-specific mailbox beneath it from the request working tree. + mailboxRoot: stringOption(opts, "mailboxRoot", filepath.Join(keysDir, servedReposDir)), + relay: gitagent.RelayMode(stringOption(opts, "relay", string(gitagent.RelaySync))), + waitTimeout: WaitTimeout(opts), + } + if g.cfg.Policy != nil { + target.policy = gitagent.Policy{Paths: g.cfg.Policy.Paths, MaxAttempts: g.cfg.Policy.MaxAttempts} + } + return target, nil +} + +func stringOption(opts map[string]any, key, fallback string) string { + if v, ok := opts[key].(string); ok && v != "" { + return v + } + return fallback +} + +// The on-disk layout shared with the CLI (pkg/cli/gitagent.go). Duplicated as +// constants rather than imported because pkg/cli imports this package. +const ( + dispatchKeyFile = "supervisor_ed25519" + servedReposDir = "repos" +) + +// DefaultWaitTimeout bounds how long a dispatch waits for its verdict. A +// relocating sandbox blocks on a remote agent doing real work, so this is +// sized for that rather than for a model call. +const DefaultWaitTimeout = time.Hour + +// WaitTimeout reads the backend's waitTimeout option, falling back to +// DefaultWaitTimeout. Exported so the run path can size its own deadline to +// match: a shorter outer timeout would kill the dispatch mid-flight and report +// a failure for work that is still running. +func WaitTimeout(options map[string]any) time.Duration { + timeout, err := configuredWaitTimeout(options) + if err != nil { + return DefaultWaitTimeout + } + return timeout +} + +func configuredWaitTimeout(options map[string]any) (time.Duration, error) { + value, exists := options["waitTimeout"] + if !exists { + return DefaultWaitTimeout, nil + } + raw, ok := value.(string) + if !ok { + return 0, fmt.Errorf("waitTimeout must be a duration string, got %T", value) + } + timeout, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("waitTimeout %q: %w", raw, err) + } + if timeout <= 0 { + return 0, fmt.Errorf("waitTimeout must be positive, got %q", raw) + } + return timeout, nil +} + +// gitAgentKeysDir anchors key material and the default mailbox beside the +// captain config file, matching the CLI's layout. +func gitAgentKeysDir() (string, error) { + path, err := captainconfig.Path() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(path), ".captain", "sandbox"), nil +} diff --git a/pkg/sandbox/adapter/gitagent_test.go b/pkg/sandbox/adapter/gitagent_test.go new file mode 100644 index 00000000..ecdd1a80 --- /dev/null +++ b/pkg/sandbox/adapter/gitagent_test.go @@ -0,0 +1,48 @@ +package adapter + +import ( + "testing" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/gitagent" +) + +func TestGitAgentRejectsInvalidWaitTimeout(t *testing.T) { + for _, value := range []any{"fifteen", "0s", 15} { + _, err := GitAgent(api.SandboxConfig{Options: map[string]any{"waitTimeout": value}}) + if err == nil { + t.Fatalf("waitTimeout %#v was accepted", value) + } + } +} + +func TestHookSetsJSONPreservesConfiguredTiers(t *testing.T) { + data, err := hookSetsJSON(map[string]any{ + "hooks": map[string]any{ + "sidecar": map[string]any{"verify": map[string]any{"commands": []any{"false"}}}, + "supervisor": map[string]any{"verify": map[string]any{"commands": []any{"make lint"}}}, + }, + }) + if err != nil { + t.Fatal(err) + } + hooks, err := gitagent.DecodeHookSets(data) + if err != nil { + t.Fatal(err) + } + if got := hooks.Sidecar.Verify.Commands; len(got) != 1 || got[0] != "false" { + t.Fatalf("sidecar commands = %#v", got) + } + if got := hooks.Supervisor.Verify.Commands; len(got) != 1 || got[0] != "make lint" { + t.Fatalf("supervisor commands = %#v", got) + } +} + +func TestHookSetsJSONRejectsAWorkflowAtTheWrongLevel(t *testing.T) { + _, err := hookSetsJSON(map[string]any{ + "hooks": map[string]any{"verify": map[string]any{"commands": []any{"false"}}}, + }) + if err == nil { + t.Fatal("hooks.verify was accepted; hooks must name sidecar or supervisor") + } +} diff --git a/pkg/sandbox/adapter/srt.go b/pkg/sandbox/adapter/srt.go index 0c011d08..a513a37b 100644 --- a/pkg/sandbox/adapter/srt.go +++ b/pkg/sandbox/adapter/srt.go @@ -11,6 +11,7 @@ import ( "time" "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" sandboxruntime "github.com/flanksource/sandbox-runtime/sandbox" ) @@ -27,18 +28,42 @@ var NewSRTRuntime = func(ctx context.Context, cfg sandboxruntime.Config) (Runtim return sandboxruntime.New(ctx, cfg) } -// srtSandbox confines the agent CLI with sandbox-runtime (filesystem and -// network policy). The confinement is constructed per wrapped command, because -// its policy depends on which CLI is being run. +// srtSandbox confines a process with sandbox-runtime (filesystem and network +// policy). It carries one of two policies, chosen at construction: the per-CLI +// provider policy (which API domains, credentials and state directory that CLI +// needs), or the generic exec-hook policy (untrusted repository code gets its +// prepared workspace and nothing else). The confinement is constructed per +// wrapped command, because its policy depends on what is being run. type srtSandbox struct { cwd string + // hook selects the generic exec-hook profile (api.SandboxProfileHook). + // Hook policy is a construction-time choice, never inferred from the + // wrapped argv: keying it off the binary name would hand agent-authored + // code a provider policy — credentials included — the day a hook invokes + // a supported CLI. + hook bool + // hookDenyRead are extra paths the hook profile must hide (the receiving + // repository), from api.SandboxOptionDenyRead. + hookDenyRead []string + // scratch is the hook run's private writable directory: its TMPDIR and + // HOME, created by Prepare and removed by Close. It exists so the policy + // need not allow host /tmp wholesale, where concurrent runs' trees live. + scratch string + mu sync.Mutex runtimes []Runtime } // SRT is the SandboxFactory for the sandbox-runtime adapter. -func SRT(api.SandboxConfig) (api.Sandbox, error) { return &srtSandbox{}, nil } +func SRT(cfg api.SandboxConfig) (api.Sandbox, error) { + s := &srtSandbox{} + if profile, _ := cfg.Options[api.SandboxOptionProfile].(string); profile == api.SandboxProfileHook { + s.hook = true + s.hookDenyRead = stringSliceOption(cfg.Options, api.SandboxOptionDenyRead) + } + return s, nil +} func init() { api.RegisterSandbox(api.SandboxSRT, SRT) } @@ -46,11 +71,30 @@ func (s *srtSandbox) Kind() api.SandboxKind { return api.SandboxSRT } func (s *srtSandbox) Prepare(_ context.Context, spec *api.Spec) (*api.SandboxSession, error) { s.cwd = spec.Cwd() + if s.hook { + // The hook profile confines to an explicit workspace or not at all: a + // cwd fallback here would build the policy for whatever directory the + // receive hook happens to run in — the bare receiving repository. + if s.cwd == "" { + return nil, fmt.Errorf("srt hook sandbox requires an explicit workspace; refusing to confine to the process working directory") + } + scratch, err := os.MkdirTemp("", "captain-hook-scratch-") + if err != nil { + return nil, fmt.Errorf("create hook scratch directory: %w", err) + } + s.scratch = scratch + } return &api.SandboxSession{}, nil } func (s *srtSandbox) Wrap(ctx context.Context, command string, args, env []string) (string, []string, []string, error) { - cfg, err := srtConfigFor(command, s.cwd) + var cfg sandboxruntime.Config + var err error + if s.hook { + cfg, err = srtHookConfigFor(s.cwd, s.scratch, s.hookDenyRead) + } else { + cfg, err = srtConfigFor(command, s.cwd) + } if err != nil { return "", nil, nil, err } @@ -68,12 +112,21 @@ func (s *srtSandbox) Wrap(ctx context.Context, command string, args, env []strin } // When the runtime supplies its own environment, the request-declared // variables are appended so they survive; when it supplies none, nil is - // returned and the exec seam falls back to the full resolved environment, - // which already contains them. + // returned and the exec seam falls back to its own environment boundary. wrappedEnv := cmd.Env if len(wrappedEnv) > 0 { wrappedEnv = append(wrappedEnv, env...) } + if s.hook { + // The hook environment is explicit, never inherited: the caller's env + // is the whole boundary, and the run's TMPDIR and HOME move into the + // scratch directory so tools have somewhere writable that is not the + // tree under verification and not host /tmp. + if wrappedEnv == nil { + wrappedEnv = append([]string(nil), env...) + } + wrappedEnv = append(wrappedEnv, "TMPDIR="+s.scratch, "HOME="+s.scratch) + } return cmd.Args[0], cmd.Args[1:], wrappedEnv, nil } @@ -90,6 +143,12 @@ func (s *srtSandbox) Close() error { } cancel() } + if s.scratch != "" { + if err := os.RemoveAll(s.scratch); err != nil { + errs = append(errs, err) + } + s.scratch = "" + } return errors.Join(errs...) } @@ -135,26 +194,104 @@ func srtConfigFor(command, cwd string) (sandboxruntime.Config, error) { }, Filesystem: sandboxruntime.FilesystemConfig{ AllowWrite: append([]string{absoluteCwd, "/tmp"}, statePaths...), - DenyRead: []string{ - filepath.Join(home, ".ssh"), - filepath.Join(home, ".aws"), - filepath.Join(home, ".azure"), - filepath.Join(home, ".config", "gcloud"), - filepath.Join(home, ".kube"), - filepath.Join(home, ".netrc"), - filepath.Join(home, ".git-credentials"), - filepath.Join(home, ".config", "gh"), - filepath.Join(home, ".docker", "config.json"), - filepath.Join(home, ".npmrc"), - filepath.Join(home, ".pypirc"), - filepath.Join(home, ".docker", "run", "docker.sock"), - "/var/run/docker.sock", - "/run/docker.sock", - "/run/containerd/containerd.sock", - "/run/podman/podman.sock", - }, - DenyWrite: []string{}, + DenyRead: hostCredentialDenyRead(home), + DenyWrite: []string{}, }, PassthroughEnv: passthroughEnv, }, nil } + +// srtHookConfigFor builds the generic exec-hook confinement. The wrapped +// command is agent-authored repository code (issue #40 R5.2), so the policy is +// the inverse of the CLI ones: write access to the materialized workspace and +// the run's scratch directory only (not host /tmp, where other runs' trees +// live), network denied entirely, no credential env passthrough, and reads of +// provider state, captain's own key material and the receiving repository +// hidden on top of the host-credential list. Both directories must already +// exist and be non-empty paths — sandbox-runtime silently skips missing +// AllowWrite entries, which here would mean an unwritable workspace, so this +// fails closed instead. +func srtHookConfigFor(workspace, scratch string, extraDenyRead []string) (sandboxruntime.Config, error) { + if workspace == "" || scratch == "" { + return sandboxruntime.Config{}, fmt.Errorf("srt hook sandbox has no prepared workspace; Prepare must run with the materialized tree before Wrap") + } + absoluteWorkspace, err := filepath.Abs(workspace) + if err != nil { + return sandboxruntime.Config{}, fmt.Errorf("resolve hook workspace %q: %w", workspace, err) + } + home, err := os.UserHomeDir() + if err != nil { + return sandboxruntime.Config{}, fmt.Errorf("resolve sandbox home directory: %w", err) + } + denyRead := append(hostCredentialDenyRead(home), + // Provider state and credentials the CLI policies deliberately allow. + filepath.Join(home, ".claude"), + filepath.Join(home, ".claude.json"), + filepath.Join(home, ".codex"), + filepath.Join(home, ".gemini"), + ) + // Captain's own configuration (which may embed provider keys) and its + // git-agent key material and served repositories. + if configPath, err := captainconfig.Path(); err == nil { + denyRead = append(denyRead, configPath, filepath.Join(filepath.Dir(configPath), ".captain")) + } + for _, path := range extraDenyRead { + if abs, err := filepath.Abs(path); err == nil { + denyRead = append(denyRead, abs) + } + } + + return sandboxruntime.Config{ + Network: sandboxruntime.NetworkConfig{ + // Non-nil and empty: network isolation is ON and every domain is + // denied. nil would mean "no network restriction" instead. + AllowedDomains: []string{}, + DeniedDomains: []string{}, + }, + Filesystem: sandboxruntime.FilesystemConfig{ + AllowWrite: []string{absoluteWorkspace, scratch}, + DenyRead: denyRead, + DenyWrite: []string{}, + }, + }, nil +} + +// hostCredentialDenyRead is the host credential material no sandboxed process +// may read, shared by every SRT policy: SSH, cloud, git, container and +// package-manager credentials, plus container runtime sockets. +func hostCredentialDenyRead(home string) []string { + return []string{ + filepath.Join(home, ".ssh"), + filepath.Join(home, ".aws"), + filepath.Join(home, ".azure"), + filepath.Join(home, ".config", "gcloud"), + filepath.Join(home, ".kube"), + filepath.Join(home, ".netrc"), + filepath.Join(home, ".git-credentials"), + filepath.Join(home, ".config", "gh"), + filepath.Join(home, ".docker", "config.json"), + filepath.Join(home, ".npmrc"), + filepath.Join(home, ".pypirc"), + filepath.Join(home, ".docker", "run", "docker.sock"), + "/var/run/docker.sock", + "/run/docker.sock", + "/run/containerd/containerd.sock", + "/run/podman/podman.sock", + } +} + +func stringSliceOption(options map[string]any, key string) []string { + switch value := options[key].(type) { + case []string: + return value + case []any: + var out []string + for _, item := range value { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + } + return nil +} diff --git a/pkg/sandbox/adapter/srt_test.go b/pkg/sandbox/adapter/srt_test.go index 95bff458..26ff9569 100644 --- a/pkg/sandbox/adapter/srt_test.go +++ b/pkg/sandbox/adapter/srt_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/gitagent" sandboxruntime "github.com/flanksource/sandbox-runtime/sandbox" ) @@ -130,3 +131,186 @@ func specWithCwd(cwd string) *api.Spec { spec.SetCwd(cwd) return spec } + +func hookSandboxConfig(denyRead ...string) api.SandboxConfig { + options := map[string]any{api.SandboxOptionProfile: api.SandboxProfileHook} + if len(denyRead) > 0 { + options[api.SandboxOptionDenyRead] = denyRead + } + return api.SandboxConfig{Kind: api.SandboxSRT, Options: options} +} + +// The hook profile is the boundary for agent-authored commands (issue #40 +// R5.2): writes confined to the prepared workspace plus a private scratch dir, +// network denied entirely, no credential passthrough, and provider state +// hidden on top of the host-credential list. +func TestSRTAdapter_HookProfile(t *testing.T) { + original := NewSRTRuntime + t.Cleanup(func() { NewSRTRuntime = original }) + fake := &fakeRuntime{} + NewSRTRuntime = func(_ context.Context, cfg sandboxruntime.Config) (Runtime, error) { + fake.gotConfig = cfg + return fake, nil + } + + repo := t.TempDir() + sandbox, err := api.NewSandbox(hookSandboxConfig(repo)) + if err != nil { + t.Fatal(err) + } + workspace := t.TempDir() + if _, err := sandbox.Prepare(context.Background(), specWithCwd(workspace)); err != nil { + t.Fatal(err) + } + wrapper, _ := api.SandboxAs[api.CommandWrapper](sandbox) + command, args, env, err := wrapper.Wrap(context.Background(), "sh", []string{"-c", "make lint"}, []string{"PATH=/usr/bin"}) + if err != nil { + t.Fatal(err) + } + if command != "srt-wrapper" || len(args) != 3 || args[0] != "sh" || args[1] != "-c" || args[2] != "make lint" { + t.Fatalf("wrapped argv = %q %v", command, args) + } + + cfg := fake.gotConfig + if cfg.Network.AllowedDomains == nil || len(cfg.Network.AllowedDomains) != 0 { + t.Fatalf("allowed domains = %#v, want non-nil empty (network isolation on, everything denied)", cfg.Network.AllowedDomains) + } + if len(cfg.Filesystem.AllowWrite) != 2 || cfg.Filesystem.AllowWrite[0] != workspace { + t.Fatalf("allowWrite = %v, want exactly [workspace, scratch]", cfg.Filesystem.AllowWrite) + } + scratch := cfg.Filesystem.AllowWrite[1] + if !strings.Contains(scratch, "captain-hook-scratch-") { + t.Fatalf("second allowWrite = %q, want the run's scratch directory", scratch) + } + if info, err := os.Stat(scratch); err != nil || !info.IsDir() { + t.Fatalf("scratch %q must exist before wrap (missing AllowWrite paths are silently skipped): %v", scratch, err) + } + for _, path := range cfg.Filesystem.AllowWrite { + if path == "/tmp" { + t.Fatal("hook policy must not allow host /tmp, where other runs' trees live") + } + } + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + filepath.Join(home, ".ssh"), + filepath.Join(home, ".claude"), + filepath.Join(home, ".claude.json"), + filepath.Join(home, ".codex"), + filepath.Join(home, ".gemini"), + repo, + } { + found := false + for _, got := range cfg.Filesystem.DenyRead { + if got == want { + found = true + break + } + } + if !found { + t.Fatalf("denyRead %v missing %q", cfg.Filesystem.DenyRead, want) + } + } + if len(cfg.PassthroughEnv) != 0 { + t.Fatalf("passthroughEnv = %v, want none for hooks", cfg.PassthroughEnv) + } + + // The environment is explicit: the runtime's env, the declared env, and + // TMPDIR/HOME re-pointed at the scratch directory. + want := []string{"SRT=1", "PATH=/usr/bin", "TMPDIR=" + scratch, "HOME=" + scratch} + if !reflect.DeepEqual(env, want) { + t.Fatalf("wrapped env = %v, want %v", env, want) + } + + if err := sandbox.Close(); err != nil { + t.Fatal(err) + } + if !fake.closed { + t.Fatal("Close must close the live runtime") + } + if _, err := os.Stat(scratch); !os.IsNotExist(err) { + t.Fatalf("Close must remove the scratch directory, stat err = %v", err) + } +} + +// Without Prepare there is no workspace to confine to, and falling back to the +// process working directory would build the policy for the bare receiving +// repository — the exact directory hooks must never touch. +func TestSRTAdapter_HookProfileFailsClosedWithoutWorkspace(t *testing.T) { + original := NewSRTRuntime + t.Cleanup(func() { NewSRTRuntime = original }) + NewSRTRuntime = func(_ context.Context, _ sandboxruntime.Config) (Runtime, error) { + t.Fatal("no runtime may be constructed without a prepared workspace") + return nil, nil + } + + sandbox, err := api.NewSandbox(hookSandboxConfig()) + if err != nil { + t.Fatal(err) + } + if _, err := sandbox.Prepare(context.Background(), &api.Spec{}); err == nil { + t.Fatal("Prepare with no cwd must fail closed for the hook profile") + } + + wrapper, _ := api.SandboxAs[api.CommandWrapper](sandbox) + if _, _, _, err := wrapper.Wrap(context.Background(), "sh", []string{"-c", "true"}, nil); err == nil { + t.Fatal("Wrap before a successful Prepare must fail closed") + } +} + +// The none adapter is registered here but confines nothing; resolving it for +// hooks must fail at resolve time rather than let exec hooks run bare (R5.2). +func TestResolveHookWrap_NoneIsRefused(t *testing.T) { + if _, err := gitagent.ResolveHookWrap("none", t.TempDir()); err == nil || + !strings.Contains(err.Error(), "no command wrapper") { + t.Fatalf("hookSandbox none must be refused loudly, got err = %v", err) + } +} + +// The regression this guards: ResolveHookWrap used to hand out a wrapper with +// no Prepare call at all, so the SRT policy was computed for the hook +// process's working directory instead of the materialized tree. +func TestResolveHookWrap_SRTConfinesTheMaterializedTree(t *testing.T) { + original := NewSRTRuntime + t.Cleanup(func() { NewSRTRuntime = original }) + fake := &fakeRuntime{} + NewSRTRuntime = func(_ context.Context, cfg sandboxruntime.Config) (Runtime, error) { + fake.gotConfig = cfg + return fake, nil + } + + repo := t.TempDir() + factory, err := gitagent.ResolveHookWrap("srt", repo) + if err != nil { + t.Fatal(err) + } + tree := t.TempDir() + wrap, closeWrap, err := factory(context.Background(), tree) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := wrap(context.Background(), "sh", []string{"-c", "true"}, []string{"PATH=/usr/bin"}); err != nil { + t.Fatal(err) + } + if got := fake.gotConfig.Filesystem.AllowWrite[0]; got != tree { + t.Fatalf("confinement workspace = %q, want the materialized tree %q", got, tree) + } + found := false + for _, path := range fake.gotConfig.Filesystem.DenyRead { + if path == repo { + found = true + break + } + } + if !found { + t.Fatalf("denyRead %v must hide the receiving repository %q", fake.gotConfig.Filesystem.DenyRead, repo) + } + if err := closeWrap(); err != nil { + t.Fatal(err) + } + if !fake.closed { + t.Fatal("the factory's close must close the live runtime") + } +} diff --git a/pkg/sandbox/tokens.go b/pkg/sandbox/tokens.go index 687a6ac2..b63e989d 100644 --- a/pkg/sandbox/tokens.go +++ b/pkg/sandbox/tokens.go @@ -51,6 +51,26 @@ type TokenResult struct { EnvVars map[string]string WritePaths []string Expiry time.Time + // Placeholder, when set, is what the sandbox environment carries instead + // of the real values: an egress proxy (pkg/gitagent/proxy) substitutes it + // at the granted header on the way out, so the credential never enters + // the sandbox (issue #39 §6.2, SPEC-git-agent-protocol §9). + Placeholder string +} + +// PlaceholderEnv returns EnvVars with every value replaced by the +// placeholder, for building a sandbox environment that holds no credential. +// It returns nil when no placeholder is set — the caller must not fall back +// to real values by accident. +func (t TokenResult) PlaceholderEnv() map[string]string { + if t.Placeholder == "" { + return nil + } + env := make(map[string]string, len(t.EnvVars)) + for k := range t.EnvVars { + env[k] = t.Placeholder + } + return env } type TokenManager struct { diff --git a/pkg/sandbox/tokens_test.go b/pkg/sandbox/tokens_test.go new file mode 100644 index 00000000..29d19e15 --- /dev/null +++ b/pkg/sandbox/tokens_test.go @@ -0,0 +1,25 @@ +package sandbox + +import "testing" + +func TestPlaceholderEnvNeverLeaksValues(t *testing.T) { + result := TokenResult{ + Provider: "github", + EnvVars: map[string]string{"GITHUB_TOKEN": "ghp_real", "GH_TOKEN": "ghp_real"}, + Placeholder: "captain-placeholder-gh-authorization", + } + env := result.PlaceholderEnv() + if len(env) != 2 { + t.Fatalf("env = %v", env) + } + for k, v := range env { + if v != result.Placeholder { + t.Fatalf("%s = %q, want the placeholder", k, v) + } + } + + // Without a placeholder there is no safe environment to hand out. + if env := (TokenResult{EnvVars: map[string]string{"X": "y"}}).PlaceholderEnv(); env != nil { + t.Fatalf("env = %v, want nil so callers cannot fall back to real values", env) + } +}