Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3f7eb7f
feat(gitagent): verified substrate harness and protocol ref/envelope …
claude Aug 4, 2026
d4a70a0
feat(gitagent): dispatch snapshot from an explicit path set, audited …
claude Aug 4, 2026
b70ba84
feat(gitagent): receivers, task state and the pure-data admission tier
claude Aug 4, 2026
7b99c37
feat(gitagent): materialization, sandboxed hook sets, verdicts and fe…
claude Aug 4, 2026
1e50aed
feat(gitagent): SSH receive endpoint, enrollment and the CLI group
claude Aug 4, 2026
2f2ee7d
feat(gitagent): the full dispatch→vet→relay→integrate loop, wired to …
claude Aug 4, 2026
5892378
feat(gitagent): egress credential proxy — placeholders out, values ne…
claude Aug 4, 2026
0a69373
fix(gitagent): make the advertised cycle work through the public CLI
claude Aug 5, 2026
d9c2605
fix(gitagent): size a relocated run's deadline for the remote agent
claude Aug 5, 2026
4e771d1
fix(gitagent): dispatch a task that something actually works on
claude Aug 5, 2026
615fed0
fix(gitagent): let remote agents edit and detach
adityathebe Aug 5, 2026
80c8f4f
fix(gitagent): harden remote task protocol
adityathebe Aug 5, 2026
deaca74
fix(gitagent): complete portable remote task cycle
ampagent Aug 5, 2026
4013f6d
fix(gitagent): enforce hooks and reject merge conflicts
adityathebe Aug 6, 2026
bf68f55
fix(gitagent): preserve supervisor relay verdict
ampagent Aug 6, 2026
abe567a
fix(gitagent): confine exec hooks to their materialized workspace
claude Aug 6, 2026
cf0d9ca
feat(gitagent): stream agent task logs from serve
adityathebe Aug 6, 2026
fa11050
feat(gitagent): route tasks through repository mailboxes
adityathebe Aug 6, 2026
32a65d5
fix(gitagent): address remote protocol review findings
adityathebe Aug 7, 2026
a11fc54
fix(gitagent): refuse required filters only when a path resolves them
claude Aug 10, 2026
7142e64
fix(cli): order disabled-selections persist+install under one mutex
claude Aug 10, 2026
679fa0b
fix(aichat): deflake tool-approval resume against in-flight suspension
claude Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
36 changes: 36 additions & 0 deletions cmd/captain/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ 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
)

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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
256 changes: 256 additions & 0 deletions hack/gitagent_empirical.sh
Original file line number Diff line number Diff line change
@@ -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 <ok-if-zero> <description>
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 <dir> — 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" <<EOF
#!/bin/sh
cat >/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" <<EOF
#!/bin/sh
cat >/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" <<EOF
#!/bin/sh
read old new ref
expected=\$(git ls-tree -r "\$new" | wc -l)
printf '%s' "\$expected" >"$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" <<EOF
#!/bin/sh
read old new ref
git push "$upstream" "\$new:refs/heads/relay-quarantined" \
2>"$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"
24 changes: 24 additions & 0 deletions pkg/ai/agent/verify/cmd_hardening_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package verify

import (
"context"
"os"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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 <Timeout>".
Expand Down
Loading
Loading