Skip to content

feat(roe): generate the enforced engagement RoE from creation data (phase 1) - #1057

Merged
PurpleCHOIms merged 13 commits into
mainfrom
feat/roe-unification
Oct 22, 2026
Merged

PurpleCHOIms merged 13 commits into
mainfrom
feat/roe-unification

Conversation

@PurpleCHOIms

Copy link
Copy Markdown
Member

Summary

Phase 1 of the RoE unification design (docs/design/2026-10-22-roe-unification.md, included in this PR): engagement scope collapses to one machine-enforceable RoE generated mechanically from the creation data — always enforced, fail-closed. Today an engagement started without a provisioned plan/roe.json silently runs audit-only — an authorization gate that defaults to off. Creation now is the scope declaration.

Changes

  • build_engagement_roe(engagement_name, targets, out_of_scope=(), *, require_scope=True) (decepticon_core, pure): each target becomes an in-scope ScopeRule (host / CIDR / glob auto-detected). Targets are normalized mechanically — embedded lists split, URLs and scheme-relative //host collapse to the hostname, CIDRs canonicalize, host:port authorizes the host on all ports (documented), hostnames lowercase / lose the DNS trailing dot / IDNA-encode, bracketed bare IPv6 drops the brackets, git-scp remotes reduce to the host, a *.x glob adds its apex, and patterns no real DNS/IP target can match are dropped with a warning. No derivable in-scope target raises.
  • MCP start_engagement and the decepticon-cli scan path share one materializer (decepticon.roe_materialization, free of the mcp extra): writes <workspace>/plan/roe.json before dispatch and refuses to start an engagement with no scope; new optional out_of_scope creation parameter (CLI flag added); never overwrites an existing (reviewed) plan/roe.json.
  • Passive scope enrichment (decepticon.passive_discovery): plain-hostname, URL, git-remote and glob-base targets are enriched with Certificate-Transparency subdomains (crt.sh, injectable fetcher, bounded at 500, filtered to the authorized root, best-effort — discovery failure degrades to the operator targets and never blocks creation).
  • Filesystem-path targets carry no network scope: an all-paths engagement materializes an empty in-scope allowlist (denylist and forbidden destinations still enforce), while network-shaped inputs that all die as unmatched patterns are refused even alongside valid paths.

Intent

  • Issue / ADR this satisfies: docs/design/2026-10-22-roe-unification.md phase 1 (this PR). Supersedes fix(mcp): restrict start_engagement to orchestration graphs (RoE enforcement) #1055 (closed) — generating the RoE at creation covers every graph, so restricting start_engagement to orchestration graphs is unnecessary.
  • Anti-goal: no mode removal, authorization retirement, or HITL scope expansion here — phases 2–5 are separate PRs per the design doc (the launcher companion PR already pulls parts of 2 and 4 forward).

Blast radius

  • Tier-delegate — middleware-adjacent security behavior + start-path gate (strengthens the RoE gate: audit-only fail-open → enforced fail-closed). No CI, manifests, compose, or plugin contracts.

Diff budget

  • Fits the budget: 3 runtime files (~220 runtime lines); docs/**, tests/** excluded per QUALITY_BAR.

End-to-end verification

Ran on my machine (worktree feat/roe-unification, uv venv, real MCP server build path):

  1. Fail-closed gate over the real MCP server (uv run python /tmp/roe-e2e.py, production build_server with a real EngagementClient): start_engagement with empty targets → tool error cannot start engagement 'mcp-…': engagement RoE requires at least one in-scope target…engagements run only against an authorized scope, no thread/run created.
  2. Materialization with live crt.sh: materialize_engagement_roe("roe-e2e-check", ["https://elice.io/path", "10.0.0.5/24", "*.elice.io"], out_of_scope=["admin.elice.io"]) wrote plan/roe.json with mode: enforce and 179 in-scope entries — the operator targets (normalized: elice.io, 10.0.0.0/24, *.elice.io) plus real CT-discovered subdomains, denylist preserved.
  3. CLI, three real invocations (uv run decepticon-cli scan --target … --langgraph-url http://127.0.0.1:1): --target /abs/path → path-scoped in_scope: []; --target ./ (the GitHub Actions composite default) → path-scoped RoE written, then dispatch fails on the dead URL as designed; --target https://elice.io → 176-entry enforced scope.
  4. Graceful degrade observed live: a crt.sh outage during one probe returned 0 hosts and creation proceeded with the operator targets — best-effort contract held.

Testing

  • uv run --extra mcp pytest -n auto -q -m "not slow" → 5988 passed, 43 skipped, 0 failed (includes the 12 shared golden normalization vectors in packages/decepticon-core/tests/testdata/roe-target-vectors.json, also consumed by the companion launcher PR's Go tests)
  • Every new test imports symbols that did not exist before this branch (fails at collection on base); the two MCP workflow-selection tests were observed failing with empty targets before the hermetic fixture + target fix
  • New code paths executed on my machine (verification items 1–4)
  • ruff clean on all changed files
  • make quality / make smoke — not run locally (CI covers)

Quality Bar self-check

  • No banned pattern in the diff (no bare except, no bare # noqa, no print( outside the CLI's stderr contract, no mutable defaults, no TODO without issue, no mocked system-under-test)
  • No AI-slop signature survives; every changed line traces to the stated intent
  • Every public function I added/changed has explicit type annotations including return type; every raised exception is a named class
  • I would merge this PR if a stranger opened it

Related Issues

Supersedes #1055. Companion launcher PR: mechanical engagement creation, scope review preview, marker retirement.

mschoi added 5 commits October 22, 2026 13:00
…hase 1)

Engagement scope collapses to one machine-enforceable RoE generated
mechanically from the creation inputs: MCP start_engagement materializes
plan/roe.json from targets + optional out_of_scope and refuses to start
without a derivable in-scope target (fail-closed, replacing the audit-only
fallback). URL targets reduce to their host, CIDRs normalize to the
canonical network, subdomain globs add their apex. Plain-hostname and glob
targets are passively enriched with crt.sh CT subdomains (bounded,
root-filtered, best-effort). Design: docs/design/2026-10-22-roe-unification.md
Embedded target lists split, scheme-relative //host URLs collapse to the
hostname, hostnames lowercase / lose the DNS trailing dot / IDNA-encode,
bracketed bare IPv6 drops its brackets, host:port authorizes the host on all
ports (documented), and patterns no real DNS/IP target can match (empty
labels, a bare *.) are dropped with a warning instead of sitting as dead
rules. Passive discovery splits the same way before querying.
…surface

The CLI scan path (decepticon-cli scan) now materializes the same enforced
plan/roe.json the MCP bridge does, from a shared module
(decepticon.roe_materialization, free of the mcp extra) that also owns
engagement_workspace. Filesystem-path targets carry no network scope: an
all-paths engagement gets an empty in-scope allowlist (denylist and
forbidden destinations still enforce), while network-shaped inputs that all
die as unmatched patterns are refused even alongside valid paths. Passive
CT discovery now covers URL and git-remote hosts too (git@host:repo reduces
to the host). The Go launcher's direct-engagement flow already wrote its own
enforce-mode RoE before the TUI enters; its dual-form document unifies in
phase 2.
…stem targets

The GitHub Actions composite scans its checkout root with --target ./, but
the path classification excluded dot-prefixed fragments, so the default
Actions configuration was refused instead of materializing a path-scoped
RoE. Classify by disk existence with path markers (separator, leading . or
~); bare existing names stay paths with a warning pointing at the URL form
for unambiguous host scoping.
Consume packages/decepticon-core/tests/testdata/roe-target-vectors.json —
the same vectors the companion launcher PR's Go tests read — so the Python
builder and the Go launcher cannot drift. Also record the companion
launcher PR (machine-form generation, marker retirement) in the design doc.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-22T05:55:38.904667Z
We couldn’t complete this review. Comment @codex review to try again.
98f92b8 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

mschoi added 4 commits October 22, 2026 14:55
Policy now matches the operator decision:
- In-scope observation runs without an approval gate.
- Out-of-scope calls are always rejected with ROE_REFUSED and audited.
- Egress enforcement is always active when an in-scope allowlist exists.
- Missing or malformed machine RoE is fail-closed for gated tools with
  ROE_CONFIG_MISSING.
- Legacy EnforcementMode and the mode field are removed from the core
  schema and generated engagement RoE.
- Legacy mode-specific tests and security documentation are removed or
  updated; audit/warn-only behaviors are no longer tested.

ATT&CK remains metadata/reporting; impact-based HITL is separate follow-up.

All remaining RoE mode references in runtime code are removed.
@PurpleCHOIms

Copy link
Copy Markdown
Member Author

Follow-up: legacy RoE modes removed and fail-closed policy verified

This follow-up commit (a047e71) implements the operator decision from the discussion:

Changes

  • Removed EnforcementMode and MachineEnforcement.mode from the core schema.
  • Removed audit/warn/enforce branches from the parser guardrail and egress compiler; egress is always active when an in-scope allowlist exists.
  • Generated engagement RoE no longer writes a legacy mode field.
  • Missing/malformed machine RoE is fail-closed for gated tools with ROE_CONFIG_MISSING.
  • Removed/updated tests and security docs that only asserted legacy audit/warn semantics.
  • ATT&CK remains metadata/reporting; no ATT&CK-wide approval gate was introduced.

Direct runtime verification (not tests-only)

Executed against the real Python runtime in the uv workspace:

  • In-scope observation (nmap 10.0.0.10) → handler called, result ok-in-scope.
  • Out-of-scope (nmap 8.8.8.8) → handler not called, [ROE_REFUSED] returned.
  • Legacy input {"mode": "audit"} → mode ignored; out-of-scope still refused.
  • Malformed roe.json → handler not called, [ROE_REFUSED] code=ROE_CONFIG_MISSING.
  • Egress compiler: empty, legacy-audit, and no-mode configurations all produce enforce=True; an in-scope allowlist sets default_drop=True.

Test verification: focused RoE/egress/technology tests 156 passed. No mode references remain in runtime RoE/egress code or security docs.

@PurpleCHOIms

Copy link
Copy Markdown
Member Author

Policy decision represented by this follow-up

in-scope observation/read-only          -> execute without HITL
in-scope destructive/production-impact  -> separate impact-based HITL follow-up
out-of-scope                            -> always ROE_REFUSED + audit

The legacy audit / warn / enforce mode distinction is intentionally removed rather than preserved for compatibility. A materialized engagement RoE is enforced; missing or malformed machine RoE fails closed for gated tools.

mschoi and others added 2 commits October 22, 2026 16:21
Resolve CHAGELOG.md conflict (keep both #1057 and #1052 Unreleased
entries) and repair the tests the legacy-mode removal (a047e71) left
stale:

- test_roe_materialization: drop the two `machine_enforcement["mode"]
  == "enforce"` assertions (the key no longer exists; always enforce)
- test_public_api_stability: CORE_PUBLIC_API 75 -> 74 after
  EnforcementMode left the public API
- CHAGELOG: record the EnforcementMode removal under the #1057 entry

Verified: decepticon-core 136 passed; decepticon unit suite
5638 passed, 5 skipped (mcp_server excluded — optional `mcp` extra
not installed locally); ruff check/format clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread packages/decepticon-core/tests/test_roe_builder.py Fixed
mschoi added 2 commits October 22, 2026 17:12
- drop unused StrEnum import left by the EnforcementMode removal
- pin mode absence in the builder test instead of the empty tautology
- give from_dict(None) real assertions and a post-removal name
The lint job never reached the format stage before (check failed first),
so four files from the mode-removal commits carried formatting drift.
@PurpleCHOIms
PurpleCHOIms merged commit f078ada into main Oct 22, 2026
28 checks passed
PurpleCHOIms pushed a commit that referenced this pull request Oct 22, 2026
Post-unification (#1057) enforcement is unconditional and the runtime
ignores the mode key, so the generated roe.json no longer writes
"mode":"enforce" and the picker's readiness gate keys on the presence
of a machine block with in-scope entries — matching the runtime for
legacy documents too (a legacy audit-mode block is read as ready, since
that is what the runtime does with it).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants