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
16 changes: 16 additions & 0 deletions CHAGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@ Keep proposed and unmerged changes out of released sections.

## [Unreleased]

- Proposed [PR #1058](https://github.com/BitterSecurity/Decepticon/pull/1058): mechanical engagement creation across the Go launcher. The
launcher now generates the same machine-enforceable `plan/roe.json` the MCP
bridge and CLI scan produce, from a broadened creation grammar — multiple
targets (URLs, hostnames, CIDRs, `*.domain` globs) plus an optional
out-of-scope denylist — pinned to the core builder by shared golden test
vectors. The `.red-approved` approval marker and the Red-approval ceremony
(`/mode red approve`, the web plan-approval POST, `provision_roe
--approve-red`) are retired: creating an engagement with a declared scope is
the authorization (the operator owns it), the RoE middleware enforces the
scope on every tool call, and readiness across the launcher picker, TUI Red
gate and web dashboard is "an enforced machine scope exists". Legacy
dual-form RoE documents remain readable. The creation flow reviews the
generated scope before it is committed — a normalized preview (in-scope
entries, denylist, and dropped patterns) requires an explicit Create
confirmation — and the engagement picker shows each engagement's scope
summary (in-scope/denied counts and first target). Source only, unreleased.
- Proposed [PR #1051](https://github.com/BitterSecurity/Decepticon/pull/1051):
structured tool errors — the ops tools report failures as
`{is_error, tool, cause, recovery}` envelopes built by
Expand Down
2 changes: 1 addition & 1 deletion clients/cli/src/commands/convenience.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ it("uses the guarded mode transition when /plan is selected", async () => {
// When the convenience command runs
await findCommand("plan")?.execute("", ctx);
// Then it selects planning without sending a message or approving scope
expect(ctx.selectAssistant).toHaveBeenCalledWith("soundwave", undefined);
expect(ctx.selectAssistant).toHaveBeenCalledWith("soundwave");
expect(ctx.submit).not.toHaveBeenCalled();
});

Expand Down
78 changes: 15 additions & 63 deletions clients/cli/src/commands/mode.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { validRoe, validationServer } from "./__fixtures__/validationServer.js";
import mode from "./mode.js";
import { planningBundleDigest } from "./planMode.js";
import { getAssistantOverride, setAssistantOverride } from "./assistantOverride.js";
import type { CommandContext } from "./types.js";

Expand All @@ -16,24 +13,24 @@ function context() {
addSystemEvent: (message) => messages.push(message),
clearEvents: () => {},
submit: () => {},
selectAssistant: async (assistant, commit) => { if (commit) await commit(); selected.push(assistant); return true; },
selectAssistant: async (assistant) => { selected.push(assistant); return true; },
resume: () => {},
exit: () => {},
};
return { ctx, messages, selected };
}

describe("CLI run modes", () => {
let server: Awaited<ReturnType<typeof validationServer>>;
beforeEach(async () => { server = await validationServer(); vi.stubEnv("DECEPTICON_API_URL", server.url); });
afterEach(async () => { await server.close(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); setAssistantOverride(""); });
beforeEach(() => { vi.unstubAllEnvs(); });
afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); setAssistantOverride(""); });

it("keeps Plan explicit and accepts a pinned direct Red scope", async () => {
it("keeps Plan explicit and switches to Red with an enforced scope", async () => {
const root = await mkdtemp(path.join(tmpdir(), "decepticon-mode-command-"));
await mkdir(path.join(root, "plan"));
const roe = validRoe;
await writeFile(path.join(root, "plan", "roe.json"), roe);
await writeFile(path.join(root, ".red-approved"), `roe:${createHash("sha256").update(roe).digest("hex")}`);
await writeFile(path.join(root, "plan", "roe.json"), JSON.stringify({
engagement_name: "direct",
machine_enforcement: { mode: "enforce", in_scope: ["example.com"], out_of_scope: [] },
}));
vi.stubEnv("DECEPTICON_ENGAGEMENT", "direct");
vi.stubEnv("DECEPTICON_WORKSPACE_PATH", root);
const { ctx, selected } = context();
Expand All @@ -46,33 +43,22 @@ describe("CLI run modes", () => {
});

it("selects Red directly without a prior Plan conversation", async () => {
const root = await mkdtemp(path.join(tmpdir(), "decepticon-direct-red-command-"));
await mkdir(path.join(root, "plan"));
const roe = validRoe;
await writeFile(path.join(root, "plan", "roe.json"), roe);
const approval = `roe:${createHash("sha256").update(roe).digest("hex")}`;
await writeFile(path.join(root, ".red-approved"), approval);
vi.stubEnv("DECEPTICON_ENGAGEMENT", "direct");
vi.stubEnv("DECEPTICON_WORKSPACE_PATH", root);
const { ctx, selected } = context();

await mode.execute("red approve", ctx);

const { ctx, messages, selected } = context();
await mode.execute("red", ctx);
expect(getAssistantOverride()).toBe("decepticon");
expect(selected).toEqual(["decepticon"]);
expect(await readFile(path.join(root, ".red-approved"), "utf-8")).toBe(approval);
await expect(readFile(path.join(root, ".planning-draft-ready"))).rejects.toMatchObject({ code: "ENOENT" });
expect(messages.at(-2)).toContain("enforces the engagement scope");
});

it("selects Blue without its sensor", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
const { ctx, messages, selected } = context();
const { ctx, selected } = context();
await mode.execute("blue", ctx);
expect(selected).toEqual(["blue_cell"]);
expect(getAssistantOverride()).toBe("blue_cell");
});

it("selects Red without approving absent scope", async () => {
it("selects Red even without a scope file", async () => {
const root = await mkdtemp(path.join(tmpdir(), "decepticon-unapproved-mode-"));
vi.stubEnv("DECEPTICON_ENGAGEMENT", "unapproved");
vi.stubEnv("DECEPTICON_WORKSPACE_PATH", root);
Expand All @@ -82,45 +68,11 @@ describe("CLI run modes", () => {
expect(getAssistantOverride()).toBe("decepticon");
});

it("does not authorize a validated draft when the active run cannot stop", async () => {
const root = await mkdtemp(path.join(tmpdir(), "decepticon-mode-stop-"));
await mkdir(path.join(root, "plan"));
for (const filename of ["roe.json", "threat-profile.json", "conops.json", "deconfliction.json", "contact.json", "data-handling.json", "abort.json", "cleanup.json"]) {
await writeFile(path.join(root, "plan", filename), filename === "roe.json" ? validRoe : "{}");
}
await writeFile(path.join(root, ".planning-draft-ready"), await planningBundleDigest(root));
vi.stubEnv("DECEPTICON_ENGAGEMENT", "stop-failed");
vi.stubEnv("DECEPTICON_WORKSPACE_PATH", root);
const { ctx } = context();
ctx.selectAssistant = vi.fn(async () => false);
await mode.execute("red approve", ctx);
expect(ctx.selectAssistant).toHaveBeenCalledWith("decepticon", expect.any(Function));
await expect(readFile(path.join(root, ".red-approved"))).rejects.toMatchObject({ code: "ENOENT" });
expect(getAssistantOverride()).toBe("");
});

it("preserves the selected conversation when canonical approval fails", async () => {
// Given a selected Plan conversation and invalid scope.
const root = await mkdtemp(path.join(tmpdir(), "mode-invalid-"));
await mkdir(path.join(root, "plan"));
await writeFile(path.join(root, "plan", "roe.json"), "{}");
vi.stubEnv("DECEPTICON_ENGAGEMENT", "invalid");
vi.stubEnv("DECEPTICON_WORKSPACE_PATH", root);
setAssistantOverride("soundwave");
const { ctx, selected } = context();
// When approval is requested.
await mode.execute("red approve", ctx);
// Then no transition or authorization occurs.
expect(selected).toEqual([]);
expect(getAssistantOverride()).toBe("soundwave");
await expect(readFile(path.join(root, ".red-approved"))).rejects.toMatchObject({ code: "ENOENT" });
});

it("keeps web terminal mode routing with the web selector", async () => {
vi.stubEnv("DECEPTICON_WEB_TERMINAL", "true");
const { ctx, messages, selected } = context();
await mode.execute("plan", ctx);
expect(selected).toEqual([]);
expect(messages.at(-1)).toContain("controls above the web terminal");
});
});
});
19 changes: 6 additions & 13 deletions clients/cli/src/commands/mode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { prepareRedApproval } from "./planMode.js";
import type { Command } from "./types.js";
import { getAssistantOverride, setAssistantOverride } from "./assistantOverride.js";

Expand All @@ -20,22 +19,21 @@ function currentMode(): RunMode {
const mode: Command = {
name: "mode",
description: "Switch Plan, Red, or Blue conversation",
argumentHint: "[plan | red [approve] | blue]",
complete: (args) => ["plan", "red", "red approve", "blue"].filter((value) => value.startsWith(args)),
argumentHint: "[plan | red | blue]",
complete: (args) => ["plan", "red", "blue"].filter((value) => value.startsWith(args)),
async execute(args, ctx) {
if (process.env.DECEPTICON_WEB_TERMINAL === "true") {
ctx.addSystemEvent("Use the Plan, Red, or Blue controls above the web terminal to switch modes.");
return;
}
const input = args.trim().toLowerCase();
const selected = input === "red approve" ? "red" : input;
const selected = args.trim().toLowerCase();
if (!selected) {
if (ctx.openPicker) {
ctx.openPicker({
title: `Mode (current: ${currentMode()})`,
options: [
{ label: "Plan", value: "plan", description: "Draft scope with Soundwave" },
{ label: "Red", value: "red", description: "Decepticon; scope approval remains required" },
{ label: "Red", value: "red", description: "Decepticon; enforces the engagement scope" },
{ label: "Blue", value: "blue", description: "Blue Cell; connect a local sensor for telemetry" },
],
onSelect: async (value) => { await mode.execute(value, ctx); },
Expand All @@ -52,16 +50,11 @@ const mode: Command = {
const next = selected;
try {
const assistant = ASSISTANTS[next];
const commit = input === "red approve" ? await prepareRedApproval() : undefined;
if (ctx.selectAssistant && !(await ctx.selectAssistant(assistant, commit))) {
if (ctx.selectAssistant && !(await ctx.selectAssistant(assistant))) {
return;
}
if (!ctx.selectAssistant && commit) throw new Error("Red approval requires a safe paused transition.");
setAssistantOverride(assistant);
if (input === "red approve") {
ctx.addSystemEvent("Current scope approved for Red operations.");
}
if (next === "red" && input !== "red approve") ctx.addSystemEvent("Red operations require approved scope. Use /scope import <file> --confirm, review it, then /mode red approve. /mode plan is optional.");
if (next === "red") ctx.addSystemEvent("Red enforces the engagement scope in plan/roe.json. Create the engagement with targets, or import a scope with /scope import <file> --confirm.");
if (next === "blue") ctx.addSystemEvent("Blue conversation selected. Connect a local sensor with /blue up 127.0.0.1:3000 before investigating telemetry.");
ctx.addSystemEvent(`Mode: ${next}. The next message goes to ${assistant}.`);
} catch (error) {
Expand Down
Loading
Loading