From 20502d93f868f727219090cf642ded8764d8a405 Mon Sep 17 00:00:00 2001 From: BuildAndDestroy Date: Sat, 12 Sep 2026 17:06:10 -0600 Subject: [PATCH] Require explicit authorization/ROE statement and document engagement intake Adds a required --authorization flag alongside --client so the harness killswitch refuses to build a session without a stated written-authorization basis, and cites it in the rendered prompt. Also adds a README template mapping operator intake fields (client, engagement, ReaperC2 URLs/creds, target scope) onto the CLI flags. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01264sfN27EU7vi6Lu6LD7jS --- CLAUDE.md | 29 ++++++--- README.md | 51 +++++++++++++++- internal/harness/config.go | 42 ++++++++++---- internal/harness/config_test.go | 71 +++++++++++++++++++---- internal/harness/prompt.go | 2 + internal/harness/prompt_test.go | 3 +- internal/harness/templates/prompt.md.tmpl | 11 ++++ 7 files changed, 176 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ffd9c6d..3807e1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,9 +26,12 @@ 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 @@ -36,6 +39,7 @@ 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" ``` @@ -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 diff --git a/README.md b/README.md index d8606ac..6918f10 100644 --- a/README.md +++ b/README.md @@ -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: +Engagement name: +Authorization / ROE: +Objective(s): + +ReaperC2 admin dashboard URL: +ReaperC2 username: +ReaperC2 password: +ReaperC2 beacon C2 FQDN/URL: + +Target(s) in scope: + - Target webapp URL: + - Target app credentials: + - (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`. @@ -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" ``` @@ -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/.md` for reference. +engagement, authorization, objectives, ReaperC2 admin/C2 URLs and username — no +secret) is saved to `sessions/.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 @@ -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" ``` diff --git a/internal/harness/config.go b/internal/harness/config.go index b39fa6a..7f48e74 100644 --- a/internal/harness/config.go +++ b/internal/harness/config.go @@ -21,6 +21,7 @@ type Config struct { ReaperPassword string Client string Engagement string + Authorization string SessionsDir string Objectives []string DryRun bool @@ -36,8 +37,8 @@ func (c Config) String() string { passwordState = "" } 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, ) } @@ -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 @@ -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): @@ -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 @@ -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" ` @@ -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") @@ -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)") } @@ -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 = "" - } if c.SessionsDir == "" { c.SessionsDir = "sessions" } diff --git a/internal/harness/config_test.go b/internal/harness/config_test.go index 760fe90..55f623c 100644 --- a/internal/harness/config_test.go +++ b/internal/harness/config_test.go @@ -31,25 +31,35 @@ 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://", }, @@ -57,8 +67,8 @@ func TestParseArgsAndValidate(t *testing.T) { 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://", }, @@ -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", @@ -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", }, @@ -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 != "" { - 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) @@ -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"} diff --git a/internal/harness/prompt.go b/internal/harness/prompt.go index 7c9110a..616e8aa 100644 --- a/internal/harness/prompt.go +++ b/internal/harness/prompt.go @@ -22,6 +22,7 @@ type promptData struct { ReaperUsername string Client string Engagement string + Authorization string Objectives []string } @@ -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, } diff --git a/internal/harness/prompt_test.go b/internal/harness/prompt_test.go index c002e02..782ae44 100644 --- a/internal/harness/prompt_test.go +++ b/internal/harness/prompt_test.go @@ -13,6 +13,7 @@ func TestBuildPromptOmitsPasswordAndListsSkills(t *testing.T) { ReaperPassword: "super-secret-value", Client: "Acme Corp", Engagement: "acme-2026-q3", + Authorization: "Signed SOW #2026-114, ROE dated 2026-09-01 to 2026-09-15", Objectives: []string{"Get domain admin", "Reach the finance share"}, } @@ -41,7 +42,7 @@ func TestBuildPromptOmitsPasswordAndListsSkills(t *testing.T) { } for _, want := range []string{ - cfg.Client, cfg.Engagement, cfg.ReaperURL, cfg.ReaperC2URL, cfg.ReaperUsername, + cfg.Client, cfg.Engagement, cfg.Authorization, cfg.ReaperURL, cfg.ReaperC2URL, cfg.ReaperUsername, "Get domain admin", "Reach the finance share", "$REAPER_C2_URL", "Notes & ATT&CK", "command output", } { diff --git a/internal/harness/templates/prompt.md.tmpl b/internal/harness/templates/prompt.md.tmpl index cb90309..b5a9b2a 100644 --- a/internal/harness/templates/prompt.md.tmpl +++ b/internal/harness/templates/prompt.md.tmpl @@ -8,6 +8,7 @@ gate and shared rules for every skill below. - **Client:** {{.Client}} - **Engagement:** {{.Engagement}} +- **Authorization / ROE basis:** {{.Authorization}} - **ReaperC2 admin panel (operator login):** {{.ReaperURL}} - **ReaperC2 beacon C2 URL (implants phone home here):** {{.ReaperC2URL}} - **ReaperC2 operator username:** {{.ReaperUsername}} @@ -22,6 +23,16 @@ select, or operate in any other ReaperC2 workspace. The beacon C2 URL is $REAPER_C2_URL — use it as `beacon_base_url` on every beacon you generate. Do not point implants at $REAPER_URL (the admin dashboard). +The scope gate for this run was satisfied before this session started: client, +engagement, authorization/ROE basis, and objectives were all supplied as +required arguments (the harness binary refuses to build or launch a session +missing any of them). Cite the Authorization line above as the basis for +proceeding; if activity ever needs to go beyond what it covers (a new target, +a new technique class, a different system), stop and get that extended +authorization from the operator before continuing — the killswitch on the CLI +only checks that *something* was declared, not that everything you might +later do is covered by it. + ## Objectives {{range .Objectives}} - {{.}}