Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,20 @@ engagement ("harness run"), launched via the `harness` Go binary (`cmd/harness`)
## Starting an engagement

Run the `harness` binary rather than starting a bare Claude Code session by
hand — it requires a ReaperC2 admin URL, a separate beacon C2 URL, username,
password, a named engagement, and at least one clear objective, and refuses to
proceed without them:
hand. It is a scope-gate **killswitch**: it requires a ReaperC2 admin URL, a
separate beacon C2 URL, username, password, a named client, a named
engagement, an explicit written-authorization/ROE statement, and at least one
clear objective — and refuses to build a session prompt or launch `claude` if
any of them is missing. None of these have silent defaults; gather them from
the operator before running this, not after:

```
go build -o bin/harness ./cmd/harness

bin/harness --reaper-url https://c2.example.com:8443 \
--reaper-c2-url https://c2.example.com:8080 --reaper-username op1 \
--client "Acme Corp" --engagement "acme-2026-q3" \
--authorization "Signed SOW #2026-114, ROE dated 2026-09-01 to 2026-09-15" \
--objective "Obtain domain admin from an external foothold" \
--objective "Demonstrate access to the finance file share"
```
Expand All @@ -45,15 +49,22 @@ or via Docker (see `README.md` for the full container workflow):
```
docker compose run --rm harness --reaper-url https://c2.example.com:8443 \
--reaper-c2-url https://c2.example.com:8080 --reaper-username op1 \
--engagement "acme-2026-q3" \
--client "Acme Corp" --engagement "acme-2026-q3" \
--authorization "Signed SOW #2026-114, ROE dated 2026-09-01 to 2026-09-15" \
--objective "Obtain domain admin from an external foothold"
```

It builds the initial prompt referencing all five skills and the objectives, keeps
the password out of that prompt/session file (it's exported only into the launched
process's environment as `$REAPER_PASSWORD`), and hands off to `claude`. See
`bin/harness --help` for all options, including `--dry-run` to review the prompt
before it's used, and `--objectives-file` for longer objective lists.
It builds the initial prompt referencing all five skills, the authorization
statement, and the objectives, keeps the password out of that prompt/session
file (it's exported only into the launched process's environment as
`$REAPER_PASSWORD`), and hands off to `claude`. See `bin/harness --help` for
all options, including `--dry-run` to review the prompt before it's used, and
`--objectives-file` for longer objective lists.

If you're ever driving one of the five skills directly in a bare Claude Code
session (not launched via this binary — its own required-input gate doesn't
run in that case), the scope gate below still applies: don't proceed until the
operator has stated it in the conversation.

## Standing rules for all five skills

Expand Down
51 changes: 49 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,44 @@ standing rules; this file covers building and running the `harness` launcher.
- `Dockerfile`, `docker-compose.yml` — containerized build/run, multi-arch
(`linux/amd64`, `linux/arm64`).

## Engagement intake template

Gather this from the operator/client before running a build — nothing here has
a silent default, and the launcher refuses to start without the required
fields. Fill in a copy of this and hand it back:

```
Client: <named client, or "Internal Lab" if self-authorized>
Engagement name: <short slug, e.g. acme-2026-q3>
Authorization / ROE: <SOW #, ROE dates, or an explicit self-authorization
statement that you own/control every in-scope system>
Objective(s): <one or more concrete goals>

ReaperC2 admin dashboard URL: <e.g. https://127.0.0.1:8443/login>
ReaperC2 username: <e.g. aiuser>
ReaperC2 password: <supply via --reaper-password prompt or
REAPER_PASSWORD env var — do not paste it into
chat, a session file, or a committed file>
ReaperC2 beacon C2 FQDN/URL: <e.g. https://metrics.example.com — must differ
from the admin dashboard URL above>

Target(s) in scope:
- Target webapp URL: <e.g. http://10.0.20.75:30280/>
- Target app credentials: <e.g. admin / password, if authorized to use>
- (any other in-scope hosts/creds)
```

Maps to the flags below as: Client → `--client`, Engagement name →
`--engagement`, Authorization/ROE → `--authorization`, ReaperC2 admin dashboard
→ `--reaper-url`, ReaperC2 beacon C2 FQDN → `--reaper-c2-url`, ReaperC2
username → `--reaper-username`, ReaperC2 password → `--reaper-password` /
`REAPER_PASSWORD` (never a bare CLI arg in a shared shell). There's no
dedicated flag for target webapp URL/credentials — fold them into
`--objective` text (e.g. `--objective "Obtain password or flag.txt from
http://10.0.20.75:30280/, credentials admin:password"`) so the session has
that context; the `red-team-operator` skill treats it as part of the
authorized scope, not a separate secret channel.

## Running locally (Go toolchain)

Requires Go 1.26+ and the `claude` CLI on `PATH`.
Expand All @@ -30,6 +68,7 @@ go build -o bin/harness ./cmd/harness
--reaper-username op1 \
--client "Acme Corp" \
--engagement "acme-2026-q3" \
--authorization "Signed SOW #2026-114, ROE dated 2026-09-01 to 2026-09-15" \
--objective "Obtain domain admin from an external foothold" \
--objective "Demonstrate access to the finance file share"
```
Expand All @@ -38,12 +77,19 @@ go build -o bin/harness ./cmd/harness
listener implants phone home to — they must differ. `--engagement` scopes the
run to that ReaperC2 workspace and is required.

`--client`, `--authorization`, and `--objective` are a scope-gate killswitch:
the binary refuses to build a session prompt or launch `claude` unless a named
client, a written authorization/ROE statement (a SOW reference, or an explicit
self-authorization statement for a lab you own), and at least one objective are
all given explicitly — none of them default to a placeholder. Gather them
before running this, not after.

You'll be prompted for the ReaperC2 password (hidden input) unless you pass
`--reaper-password` or set `REAPER_PASSWORD`. The password is never written to
disk — it lives only in the launcher's process environment and is exported into
`claude`'s environment as `$REAPER_PASSWORD`. The rendered session prompt (client,
engagement, objectives, ReaperC2 admin/C2 URLs and username — no secret) is saved
to `sessions/<engagement>.md` for reference.
engagement, authorization, objectives, ReaperC2 admin/C2 URLs and username — no
secret) is saved to `sessions/<engagement>.md` for reference.

Use `--dry-run` to see the prompt without launching `claude`, and
`harness --help` for the full flag list (including `--objectives-file` for a
Expand All @@ -55,6 +101,7 @@ longer objectives list and `--sessions-dir` to change where prompts are saved).
cp .env.example .env # fill in REAPER_URL / REAPER_C2_URL / REAPER_USERNAME / REAPER_PASSWORD / REAPER_ENGAGEMENT / ANTHROPIC_API_KEY
docker compose run --rm harness \
--client "Acme Corp" --engagement "acme-2026-q3" \
--authorization "Signed SOW #2026-114, ROE dated 2026-09-01 to 2026-09-15" \
--objective "Obtain domain admin from an external foothold"
```

Expand Down
42 changes: 31 additions & 11 deletions internal/harness/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type Config struct {
ReaperPassword string
Client string
Engagement string
Authorization string
SessionsDir string
Objectives []string
DryRun bool
Expand All @@ -36,8 +37,8 @@ func (c Config) String() string {
passwordState = "<redacted>"
}
return fmt.Sprintf(
"Config{ReaperURL:%q ReaperC2URL:%q ReaperUsername:%q ReaperPassword:%s Client:%q Engagement:%q SessionsDir:%q Objectives:%v DryRun:%v}",
c.ReaperURL, c.ReaperC2URL, c.ReaperUsername, passwordState, c.Client, c.Engagement, c.SessionsDir, c.Objectives, c.DryRun,
"Config{ReaperURL:%q ReaperC2URL:%q ReaperUsername:%q ReaperPassword:%s Client:%q Engagement:%q Authorization:%q SessionsDir:%q Objectives:%v DryRun:%v}",
c.ReaperURL, c.ReaperC2URL, c.ReaperUsername, passwordState, c.Client, c.Engagement, c.Authorization, c.SessionsDir, c.Objectives, c.DryRun,
)
}

Expand All @@ -53,7 +54,13 @@ func (s *stringSlice) Set(v string) error {

// UsageText is the full CLI help text, shared by -h/--help and validation errors.
const UsageText = `Usage: harness --reaper-url URL --reaper-c2-url URL --reaper-username USER \
--engagement NAME --objective "text" [--objective "text" ...] [options]
--client NAME --engagement NAME --authorization TEXT \
--objective "text" [--objective "text" ...] [options]

This is a scope-gate killswitch: the run refuses to build a session or launch
claude unless every required input below is explicitly provided. There are no
silent defaults for client, authorization, or objectives — if you haven't
gathered them yet, gather them before running this.

Required:
--reaper-url URL ReaperC2 admin panel / operator dashboard base URL
Expand All @@ -62,7 +69,15 @@ Required:
(e.g. https://c2.example.com:8080). Must differ from
--reaper-url; beacons phone home here, not the dashboard.
--reaper-username USER ReaperC2 operator username
--client NAME Named client/customer this engagement is for (use a
self-owned label like "Internal Lab" if there is no
external client — never left blank or guessed).
--engagement NAME Engagement / workspace name this run is scoped to
--authorization TEXT Written authorization / rules-of-engagement basis for
this run (e.g. a signed SOW reference and dates, or
an explicit self-authorization statement that you own
or control every in-scope system). At least one
required.
--objective TEXT One engagement objective. Repeat for multiple. At least one required.

One of these is required for the password (never pass it as a bare CLI arg in shared shells):
Expand All @@ -71,7 +86,6 @@ One of these is required for the password (never pass it as a bare CLI arg in sh
(omit both to be prompted interactively, input hidden — requires a TTY)

Optional:
--client NAME Client / customer name for the report
--objectives-file PATH Read additional objectives, one per line
--sessions-dir PATH Where to write the session prompt file (default: sessions)
--dry-run Build and print the prompt, don't launch claude
Expand All @@ -80,6 +94,7 @@ Optional:
Examples:
harness --reaper-url https://c2.internal:8443 --reaper-c2-url https://c2.internal:8080 \
--reaper-username op1 --client "Acme Corp" --engagement "acme-2026-q3" \
--authorization "Signed SOW #2026-114, ROE dated 2026-09-01 to 2026-09-15" \
--objective "Obtain domain admin from an external foothold" \
--objective "Demonstrate access to the finance file share"
`
Expand Down Expand Up @@ -108,8 +123,9 @@ func ParseArgs(args []string, stderr io.Writer) (*Config, error) {
fs.StringVar(&cfg.ReaperC2URL, "reaper-c2-url", cfg.ReaperC2URL, "ReaperC2 beacon listener / implant C2 base URL")
fs.StringVar(&cfg.ReaperUsername, "reaper-username", cfg.ReaperUsername, "ReaperC2 operator username")
fs.StringVar(&cfg.ReaperPassword, "reaper-password", cfg.ReaperPassword, "ReaperC2 operator password")
fs.StringVar(&cfg.Client, "client", "", "Client / customer name")
fs.StringVar(&cfg.Client, "client", "", "Named client / customer this engagement is for (required)")
fs.StringVar(&cfg.Engagement, "engagement", cfg.Engagement, "Engagement name this run is scoped to")
fs.StringVar(&cfg.Authorization, "authorization", "", "Written authorization / rules-of-engagement basis for this run (required)")
fs.StringVar(&cfg.SessionsDir, "sessions-dir", "sessions", "Where to write the session prompt file")
fs.Var(&objectives, "objective", "Engagement objective (repeatable)")
fs.StringVar(&objectivesFile, "objectives-file", "", "Path to a file of objectives, one per line")
Expand Down Expand Up @@ -162,9 +178,15 @@ func (c *Config) Validate() error {
if c.ReaperUsername == "" {
missing = append(missing, "--reaper-username")
}
if c.Client == "" {
missing = append(missing, "--client")
}
if c.Engagement == "" {
missing = append(missing, "--engagement")
}
if c.Authorization == "" {
missing = append(missing, "--authorization")
}
if len(c.Objectives) == 0 {
missing = append(missing, "--objective (at least one)")
}
Expand Down Expand Up @@ -197,13 +219,11 @@ func normalizeURL(raw string) string {
return strings.TrimRight(strings.TrimSpace(raw), "/")
}

// Finalize fills in defaults that depend on runtime state (a placeholder
// client name). Call after Validate succeeds. Engagement is required and is
// never generated here — the run stays scoped to the name the operator gave.
// Finalize fills in defaults that depend on runtime state. Call after Validate
// succeeds. Client, Engagement, and Authorization are all required by Validate
// and are never invented here — the run stays scoped to exactly what the
// operator gave.
func (c *Config) Finalize() {
if c.Client == "" {
c.Client = "<UPDATE ME>"
}
if c.SessionsDir == "" {
c.SessionsDir = "sessions"
}
Expand Down
71 changes: 61 additions & 10 deletions internal/harness/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,34 +31,44 @@ func TestParseArgsAndValidate(t *testing.T) {
},
{
name: "missing objective",
args: []string{"--reaper-url", "https://c2:8443", "--reaper-c2-url", "https://c2:8080", "--reaper-username", "op1", "--engagement", "acme"},
args: []string{"--reaper-url", "https://c2:8443", "--reaper-c2-url", "https://c2:8080", "--reaper-username", "op1", "--client", "Acme", "--engagement", "acme", "--authorization", "Signed SOW #1"},
wantErr: "--objective",
},
{
name: "missing client",
args: []string{"--reaper-url", "https://c2:8443", "--reaper-c2-url", "https://c2:8080", "--reaper-username", "op1", "--engagement", "acme", "--authorization", "Signed SOW #1", "--objective", "test"},
wantErr: "--client",
},
{
name: "missing authorization",
args: []string{"--reaper-url", "https://c2:8443", "--reaper-c2-url", "https://c2:8080", "--reaper-username", "op1", "--client", "Acme", "--engagement", "acme", "--objective", "test"},
wantErr: "--authorization",
},
{
name: "missing engagement",
args: []string{"--reaper-url", "https://c2:8443", "--reaper-c2-url", "https://c2:8080", "--reaper-username", "op1", "--objective", "test"},
args: []string{"--reaper-url", "https://c2:8443", "--reaper-c2-url", "https://c2:8080", "--reaper-username", "op1", "--client", "Acme", "--authorization", "Signed SOW #1", "--objective", "test"},
wantErr: "--engagement",
},
{
name: "missing c2 url",
args: []string{"--reaper-url", "https://c2:8443", "--reaper-username", "op1", "--engagement", "acme", "--objective", "test"},
args: []string{"--reaper-url", "https://c2:8443", "--reaper-username", "op1", "--client", "Acme", "--engagement", "acme", "--authorization", "Signed SOW #1", "--objective", "test"},
wantErr: "--reaper-c2-url",
},
{
name: "bad scheme",
args: []string{
"--reaper-url", "ftp://c2", "--reaper-c2-url", "https://c2:8080",
"--reaper-username", "op1", "--engagement", "acme",
"--objective", "test",
"--reaper-username", "op1", "--client", "Acme", "--engagement", "acme",
"--authorization", "Signed SOW #1", "--objective", "test",
},
wantErr: "http:// or https://",
},
{
name: "bad c2 scheme",
args: []string{
"--reaper-url", "https://c2:8443", "--reaper-c2-url", "ftp://c2:8080",
"--reaper-username", "op1", "--engagement", "acme",
"--objective", "test",
"--reaper-username", "op1", "--client", "Acme", "--engagement", "acme",
"--authorization", "Signed SOW #1", "--objective", "test",
},
wantErr: "--reaper-c2-url must start with http:// or https://",
},
Expand All @@ -68,7 +78,9 @@ func TestParseArgsAndValidate(t *testing.T) {
"--reaper-url", "https://c2.example.com:8443",
"--reaper-c2-url", "https://c2.example.com:8443/",
"--reaper-username", "op1",
"--client", "Acme",
"--engagement", "acme",
"--authorization", "Signed SOW #1",
"--objective", "test",
},
wantErr: "must differ",
Expand All @@ -79,7 +91,9 @@ func TestParseArgsAndValidate(t *testing.T) {
"--reaper-url", "https://c2.example.com:8443",
"--reaper-c2-url", "https://c2.example.com:8080",
"--reaper-username", "op1",
"--client", "Acme Corp",
"--engagement", "acme-2026-q3",
"--authorization", "Signed SOW #2026-114, ROE dated 2026-09-01 to 2026-09-15",
"--objective", "Get domain admin",
"--objective", "Reach the finance share",
},
Expand Down Expand Up @@ -169,13 +183,16 @@ func TestParseArgsEnvFallback(t *testing.T) {
}

func TestFinalizeDefaults(t *testing.T) {
cfg := &Config{Engagement: "acme-2026-q3"}
cfg := &Config{Engagement: "acme-2026-q3", Client: "Acme Corp", Authorization: "Signed SOW #1"}
cfg.Finalize()
if cfg.Engagement != "acme-2026-q3" {
t.Errorf("Engagement = %q, Finalize must not invent an engagement name", cfg.Engagement)
}
if cfg.Client != "<UPDATE ME>" {
t.Errorf("Client = %q", cfg.Client)
if cfg.Client != "Acme Corp" {
t.Errorf("Finalize must not invent or alter Client, got %q", cfg.Client)
}
if cfg.Authorization != "Signed SOW #1" {
t.Errorf("Finalize must not invent or alter Authorization, got %q", cfg.Authorization)
}
if cfg.SessionsDir != "sessions" {
t.Errorf("SessionsDir = %q", cfg.SessionsDir)
Expand All @@ -189,6 +206,40 @@ func TestFinalizeDefaults(t *testing.T) {
}
}

// TestValidateKillswitchRequiresClientAndAuthorization is the regression test
// for the scope-gate killswitch: even with every ReaperC2 connection detail
// and an objective present, Validate must still refuse to proceed without an
// explicit named client and an explicit authorization/ROE statement. Neither
// field may be silently defaulted anywhere in this package.
func TestValidateKillswitchRequiresClientAndAuthorization(t *testing.T) {
base := Config{
ReaperURL: "https://c2.example.com:8443",
ReaperC2URL: "https://c2.example.com:8080",
ReaperUsername: "op1",
Engagement: "acme-2026-q3",
Objectives: []string{"Get domain admin"},
}

withoutClient := base
withoutClient.Authorization = "Signed SOW #1"
if err := withoutClient.Validate(); err == nil || !strings.Contains(err.Error(), "--client") {
t.Fatalf("Validate() with no Client = %v, want an error naming --client", err)
}

withoutAuthorization := base
withoutAuthorization.Client = "Acme Corp"
if err := withoutAuthorization.Validate(); err == nil || !strings.Contains(err.Error(), "--authorization") {
t.Fatalf("Validate() with no Authorization = %v, want an error naming --authorization", err)
}

complete := base
complete.Client = "Acme Corp"
complete.Authorization = "Signed SOW #1"
if err := complete.Validate(); err != nil {
t.Fatalf("Validate() with client+authorization set = %v, want no error", err)
}
}

func TestConfigStringRedactsPassword(t *testing.T) {
cfg := Config{ReaperUsername: "op1", ReaperPassword: "super-secret-value"}

Expand Down
2 changes: 2 additions & 0 deletions internal/harness/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type promptData struct {
ReaperUsername string
Client string
Engagement string
Authorization string
Objectives []string
}

Expand All @@ -35,6 +36,7 @@ func BuildPrompt(cfg Config) (string, error) {
ReaperUsername: cfg.ReaperUsername,
Client: cfg.Client,
Engagement: cfg.Engagement,
Authorization: cfg.Authorization,
Objectives: cfg.Objectives,
}

Expand Down
Loading
Loading