Engagement profiles

An engagement profile tells rupu what kind of thing an engagement is about — source code, a binary, a network, a web application, a cloud account, a threat model — and therefore what a complete finding about it looks like.

Concepts

A profile is a small TOML file. It declares the asset kinds that exist in its domain, the coordinates that pin each one down, a completeness checklist that every finding must pass, and a depth ladder that records how far each asset has been examined. rupu ships 15 profiles. You can override any of them, or add your own by writing a TOML file — no Rust needed.

Not a sandbox. A profile governs how findings are typed, routed and validated. It doesn’t run, gate or scope tools, and it doesn’t limit what an agent can reach. Agents examine binaries, hosts and services with bash and whatever tooling they like; rupu records and validates the findings that result, not the traffic.

Coordinates

A locator is the set of typed coordinates that pins an asset down. The core knows exactly 13 coordinate types, and every profile builds on them. Each is written as {"t": <tag>, "v": <value>}. A locator can be empty — a threat-model component has no coordinates at all.

TagValueTypical use
pathstringfile or archive path
line_range{"start": 10, "end": 24}lines in a file
symbolstringfunction or symbol name
commitstringVCS commit
sha256stringhash of a binary, image or package
offsetintegerbyte offset
addressintegervirtual address
hoststringhostname or IP
port{"number": 443, "proto": "tcp"} (tcp | udp | other)network port
urlstringURL
http_route{"method": "GET", "path": "/api/items"}HTTP method + path
paramstringrequest parameter
resource_id{"scheme": "arn", "id": "…"}cloud resource, package URL, objective id

Selecting profiles

rupu run --engagement-profile binary  firmware-re "reverse ./dist/updater.bin"
rupu run --engagement-profile network recon       "map 10.20.0.0/24"
rupu run --engagement-profile network,web assessor "..."   # two profiles
rupu run --engagement-profiles pentest assessor    "..."   # a composite
rupu workflow run edge-sweep --engagement-profile network
SurfaceHowNotes
rupu run--engagement-profile <id>Alias --engagement-profiles; repeat the flag or pass a comma-separated list. Sub-agents started with dispatch_agent inherit the selection.
rupu workflow run--engagement-profile <id>Applies to every agent step and to action: findings.record steps. The selection is recorded on the run (engagement_profiles in run.json), so rupu workflow resume, workflow approve and a gate approved in the control plane continue under it. A recorded profile that no longer resolves fails the resume.
Agentiflowsengagement_profiles: (required)rupu validates the definition against the profiles and passes the set to the lead and every unit. See Agentiflows.
CP agent launch APIengagement_profiles: [..] in the POST /api/agents/:name/run bodyPassed to rupu run --engagement-profile, locally or on the chosen host (see the remote row). The web launcher doesn’t offer it.
Sessions, autoflows—These don’t accept a selection and always run on the code path.
Remote workflow unitsinherited from rupu workflow runA host: / distribute: unit gets the ids as rupu run --engagement-profile, on every transport. A host must advertise agent.engagement_profile (see Multi-host); otherwise the launch is refused, never run on the code path. An SSH remote too old for the flag rejects it and the launch fails. The host resolves the ids against its own profiles, so install a custom profile there too.
rupu coverage rerun—Replays on the code path.

The built-in catalog

code is the default. Each asset-kind entry gives its locator coordinates, with its parent kind in parentheses where it has one.

ProfilePurposeAsset kinds → coordinatesDepth ladder
codeSource code reviewrepo → path; file (repo) → path, line_range; function (file) → path, symbol, line_rangeunreviewed → reviewed
binaryBinary reverse engineeringbinary → sha256; function (binary) → sha256, address, symbollocated → disassembled → analyzed
firmwareFirmware assessmentimage → sha256; partition (image) → sha256, path; file (partition) → path, sha256; function (file) → path, address, symbolacquired → extracted → unpacked → analyzed
networkNetwork security assessmenthost → host; service (host) → host, portdiscovered → enumerated → tested → exploited
webWeb application assessmentsite → url; route (site) → url, http_route, parammapped → crawled → tested → exploited
apiAPI security assessmentservice → url; endpoint (service) → url, http_route, parammapped → tested → exploited
cloudCloud configuration reviewaccount → resource_id; resource (account) → resource_idinventoried → policy-evaluated → validated
scaSoftware composition analysisrepo → path; dependency (repo) → path, resource_idinventoried → resolved → triaged
iacInfrastructure-as-code reviewrepo → path; resource (repo) → path, line_rangemapped → reviewed
secretsSecret scanningrepo → path; secret (repo) → path, line_range, commitscanned → triaged → confirmed
containerContainer image assessmentimage → sha256; layer (image) → sha256, path; package (image) → resource_idinventoried → scanned → triaged
redteamRed team operationhost → host; objective (host) → host, resource_idplanned → executed → validated
threat-modelThreat modelsystem → none; component (system) → nonemodeled → reviewed
mobileMobile application assessment (composite: binary + web)package → sha256unpacked → static → dynamic
pentestPenetration test (composite: network + web)none of its ownnone of its own

Completeness, evidence and taxonomies

Every built-in check is required, and report_finding enforces them. Evidence blocks and taxonomies are advisory: rupu enforces them only when a check names one. Taxonomy matching ignores case, and a report’s legacy cwe list counts as CWE.

ProfileRequired checksEvidence blocksTaxonomies
codelocated: has path · has_root_causetext, code_slice, diffCWE, OWASP
binaryevidence_has_listing: a disasm or hexdump block · has_root_cause · classified: CWEtext, code_slice, diff, hexdump, disasmCWE, CVE
firmwareevidence_has_listing: disasm, decompile or hexdump · classified: CWEtext, hexdump, disasm, decompile, tableCWE, CVE
networkservice_identified: host and port · has_root_cause · classified: CVE or CWEtext, table, scan_output, http_exchange, pcap_refCVE, CWE, CAPEC, ATT&CK
webroute_identified: http_route or url · evidence_has_exchange: http_exchange · classified: OWASP or CWEtext, http_exchange, image, tableOWASP, CWE, CVE
apiendpoint_identified: http_route · evidence_has_exchange: http_exchangetext, http_exchange, tableOWASP-API, CWE
cloudresource_identified: resource_id · classified: CIS or CWEtext, table, code_sliceCIS, CWE, ATT&CK
scadependency_identified: resource_id (a purl) · classified: CVE or GHSAtable, textCVE, GHSA
iaclocated: path and line_range · classified: CIS or CWEcode_slice, diff, tableCIS, CWE
secretslocated: path and line_range · classified: CWEcode_slice, textCWE
containercomponent_identified: resource_id or path · classified: CVEtable, textCVE
redteammapped_to_attack: ATT&CK · has_narrative: descriptiontext, scan_output, imageATT&CK
threat-modelhas_threat: description · classified: STRIDEtext, image, tableSTRIDE, CAPEC, ATT&CK
mobilepackage_identified: sha256, address or http_route (its own mobile:package kind only)disasm, http_exchange, imageMASVS, OWASP, CWE
pentestnone of its own——

Every built-in profile also declares a [bundle]: the agents and workflows an engagement of that kind typically uses. A bundle never grants tools. The stock security fleet is built from these bundles, and rupu’s own tests keep the two in lockstep.

Composites and routing

A composite is a profile with an includes list. Selecting one is shorthand for selecting its members, and each member stays its own routing target — the members are not merged. During a pentest run, a network:service finding is judged by network’s checks and a web:route finding by web’s. mobile adds its own root kind, mobile:package, alongside binary and web. includes is transitive; an include cycle, or an include of a profile that doesn’t exist, is a load error.

The stock security fleet

A profile says what a complete finding looks like; it doesn’t ship anyone to find them. The stock fleet does: 18 generic security-assessment agents and 3 workflows, one set for every built-in bundle. Install it once and every engagement, in any project, can draw on it.

rupu fleet list                    # what the fleet ships
rupu fleet install                 # into ~/.rupu (or $RUPU_HOME)
rupu fleet install --project       # into this project's .rupu/ instead
rupu fleet install --force         # re-seed files you've edited

The fleet is plain files: agents/<name>.md and workflows/<id>.yaml, written exactly where rupu’s loaders look. A file that already exists is kept, so your edits survive a reinstall; each file is reported as CREATED, SKIPPED or OVERWROTE. --force overwrites them with the shipped versions. --project needs a .rupu/ (run rupu init first), and a project copy shadows the global one, as for any agent or workflow.

ProfileBundle agentsBundle workflows
networkassessment-lead, recon, service-analyst, exploit-verifiernetwork-assessment
webassessment-lead, crawler, appsec-testerweb-assessment
apiassessment-lead, api-testerapi-assessment
pentestthe network and web agentsnetwork-assessment, web-assessment
redteamassessment-lead, redteam-operator—
codecode-auditor—
binarybinary-analyst—
firmwarefirmware-analyst—
cloudcloud-auditor—
scasca-auditor—
iaciac-reviewer—
secretssecret-scanner—
containercontainer-scanner—
threat-modelthreat-modeler—
mobilemobile-analyst—

Run a workflow under its profile so the asset and findings tools are active:

rupu fleet install
rupu workflow run web-assessment --engagement-profile web \
  --input scope="https://staging.example.test (exclude /admin/billing)"

Or hand the whole pool to the lead in an agentiflow:

# .rupu/agentiflows/lab-sweep.yaml (excerpt)
lead: assessment-lead
engagement_profiles: [network]
pool:
  agents: [assessment-lead, recon, service-analyst, exploit-verifier]
  workflows: [network-assessment]
Bundles and the fleet stay in step. A test fails rupu’s build if any built-in bundle names an agent or workflow the fleet doesn’t ship, or if the fleet ships one no bundle names. A bundle in your own profile isn’t checked against anything, and rupu doesn’t install or select a bundle’s members when you pick the profile — you still name the agent or workflow you run.

Recording assets

Two tools write assets, both to the target’s asset ledger, .rupu/coverage/<target>/assets.jsonl, next to findings.jsonl.

report_finding with an asset

When an engagement is active, report_finding takes an optional asset. So does the MCP findings.record tool a workflow action: step calls, with the same schema and handling.

{
  "scope": "edge-sweep",
  "report": { /* a complete finding report */ },
  "asset": {
    "kind": "network:service",
    "coordinates": [
      { "t": "host", "v": "198.51.100.20" },
      { "t": "port", "v": { "number": 8443, "proto": "tcp" } }
    ],
    "label": "198.51.100.20:8443 admin console"
  }
}

When an engagement is active, rupu:

  1. Routes the finding by its namespaced kind. If no active profile owns the kind, the call fails, and the error names the active set.
  2. Checks completeness. Every required check of the owning profile is evaluated under the full finding profile. If any check fails, the finding is rejected with the labels of the failing checks. Under summary there’s no report to evaluate, so this step is skipped.
  3. Stamps the asset into assets.jsonl and the run’s coverage stream, so a coordinator collecting a remote unit receives it. The new line keeps the asset’s existing depth, parent, attributes and label, unless the call supplies a new label.

The finding record itself does not store an asset id. When no engagement is active, asset is ignored. When an engagement is active but the finding has no asset, it records on the plain path without a completeness check. rupu findings import records no asset.

asset_mark

{ "kind": "web:route",
  "coordinates": [ { "t": "url", "v": "https://shop.example.test" },
                   { "t": "http_route", "v": { "method": "POST", "path": "/cart/apply" } } ],
  "depth": "tested" }

depth must be a rung of the owning profile’s ladder; any other value is an error that lists the ladder. Depth only moves forward: if an asset is already at exploited, marking it tested leaves it at exploited, and the tool returns the rung that ends up recorded (effective_depth). The engine offers asset_mark and report_finding to every agent in an engagement run, whatever the agent’s tools: list says. Without an active engagement, asset_mark doesn’t exist.

The ledger and its views

assets.jsonl only ever gets appended to, one full asset per line (id, kind, parent, locator, label, depth, attributes). When the file is read, the last line for each id wins. An asset’s id is <kind>:<16 hex>, derived from the kind plus the sorted locator, so the same host and port always map to the same asset, across reruns too. The profile’s label template is declarative and isn’t rendered: an asset’s label is whatever the caller passed, or else its kind.

Remote units’ asset lines arrive through the coverage stream and are merged into the coordinator. The control plane’s Security → Assets page lists every asset from every registered workspace, and an agentiflow’s Assets tab shows the same data for one run (both use GET /api/assets). Agentiflow coverage goals measure this ledger.

Writing a profile

Where profiles live

SourceLocationPrecedence
Built-incompiled into the binarylowest
Global~/.rupu/profiles/*.tomloverrides built-in
Project.rupu/profiles/*.toml in the run’s workspace (for an agentiflow, the directory you launch from)highest

rupu identifies a profile by its id field; the file name doesn’t matter. A later source with the same id replaces the earlier profile completely — nothing is merged — so to change one check of a built-in, copy the whole built-in and edit the copy. Within one directory, files load in name order, and the last one wins. Only *.toml files are read, and a missing directory is fine.

Schema

All keys are strict: an unknown key in any table is a parse error. Put the top-level keys before the first [[asset_kinds]], or TOML assigns them to that kind and the file fails to parse.

KeyTypeMeaning
idstring, requiredProfile id, and the namespace of its kinds (<id>:<kind>)
namestring, requiredHuman-readable name
includesstring listProfiles this one includes (makes it a composite)
evidence_blocksstring listEvidence-block kinds the engagement expects (advisory)
classification_systemsstring listTaxonomies the engagement uses (advisory)
[[asset_kinds]] idstring, requiredBare kind id, namespaced as <profile>:<id>
[[asset_kinds]] parentstringParent kind id in the same profile; omit for a root
[[asset_kinds]] coordinatesstring listCoordinate tags an asset of this kind carries (descriptive)
[[asset_kinds]] labelstring, requiredLabel template such as "{host}:{port}" (descriptive, not rendered)
[[completeness]] id / labelstring, requiredCheck id; the label is shown in the rejection message when the check fails
[[completeness]] requiredbool, default truefalse makes the check informational; it isn’t evaluated
[[completeness]] satisfied_whenpredicate, requiredSee below
[coverage] enumeratesstring listBare kind ids that agentiflow coverage goals count by default
[coverage] depth_ladderstring listOrdered rungs, shallowest first; the only depths asset_mark accepts. The last rung is a coverage goal’s default target.
[bundle] agents / workflows / toolsstring listsThe agents, workflows and tools an engagement of this kind typically uses. Descriptive only: never a grant, and nothing installs or loads them for you

Predicates

PredicateTrue when
{ has_field = "…" }the report field is filled in: root_cause, remediation, impact, description, attack_vector, category (non-blank), replication_steps (non-empty), evidence (any claim or block)
{ has_block_kind = "…" }the report has at least one block of that kind: text, code_slice, diff, table, image, hexdump, disasm, decompile, http_exchange, scan_output, pcap_ref
{ has_classification_system = "…" }a classification uses that system (case-insensitive)
{ locator_has_coordinate = "…" }the asset’s locator has a coordinate with that tag
{ min_severity = "…" }rating.risk_rating is at least Low, Medium, High or Critical (capitalized)
{ all = [ … ] } / { any = [ … ] }every / at least one nested predicate holds

That is the whole predicate grammar. It isn’t meant to grow into a scripting language.

Failure modes

MistakeWhen it shows upWhat you see
Unknown key, wrong type, or a top-level key after [[asset_kinds]]launch, for any run with a selectionengagement profiles: unloadable profiles: <file>: profile parse: … — even if this profile wasn’t selected
Unreadable file or directorylaunchthe same message, with the IO error
Missing include, or an include cyclelaunchprofile `x` is included but not defined / include cycle at profile `x`
Selected id doesn’t existlaunchunknown engagement profile `x`
Two selections contribute the same kindlaunchasset kind `x:y` is contributed by more than one selected profile
Typo in a predicate’s field, block kind or coordinate tagthe first finding that routes to the profilethat finding is rejected with completeness predicate error for profile …
Test with one finding. Loading checks only the TOML structure. A predicate’s field names, block kinds and coordinate tags are first checked when a finding reaches it, so record one real finding against a new profile before you rely on it.

Worked example: an IoT fleet engagement

The profile below is invented for this guide. It covers fleets of embedded devices: a device is found by its address, it exposes services, and each firmware build is identified by its hash. Save it as .rupu/profiles/iot-fleet.toml:

id = "iot-fleet"
name = "IoT device fleet assessment"
evidence_blocks = ["text", "scan_output", "hexdump", "http_exchange", "table"]
classification_systems = ["CWE", "CVE"]

[[asset_kinds]]
id = "device"
coordinates = ["host"]
label = "{host}"

[[asset_kinds]]
id = "service"
parent = "device"
coordinates = ["host", "port"]
label = "{host}:{port}"

[[asset_kinds]]
id = "firmware"
parent = "device"
coordinates = ["host", "sha256"]
label = "{sha256}"

[[completeness]]
id = "pinned"
label = "Pinned to a device service or a firmware build"
satisfied_when = { all = [
  { locator_has_coordinate = "host" },
  { any = [ { locator_has_coordinate = "port" }, { locator_has_coordinate = "sha256" } ] },
] }

[[completeness]]
id = "evidence"
label = "Scan output, hexdump or HTTP exchange attached"
satisfied_when = { any = [
  { has_block_kind = "scan_output" },
  { has_block_kind = "hexdump" },
  { has_block_kind = "http_exchange" },
] }

[[completeness]]
id = "classified"
label = "Classified (CWE or CVE)"
satisfied_when = { any = [ { has_classification_system = "CWE" }, { has_classification_system = "CVE" } ] }

[[completeness]]
id = "has_fix"
label = "Remediation for high-severity issues"
required = false        # informational: not evaluated
satisfied_when = { has_field = "remediation" }

[coverage]
enumerates = ["device", "service", "firmware"]
depth_ladder = ["discovered", "fingerprinted", "tested", "exploited"]
rupu run --engagement-profile iot-fleet fleet-recon "assess the lab segment 192.0.2.0/28"

During the run, the agent might call asset_mark with kind iot-fleet:device, host 192.0.2.7 and depth fingerprinted, then call report_finding with an iot-fleet:service asset pinned by host and port 23/tcp, and a report that has a scan_output block and a CWE classification. If the agent omits the port and the hash, the finding is rejected with Pinned to a device service or a firmware build, and the agent can add the missing coordinates and try again.

Profiles, finding profiles and concern catalogs

Three separate rupu features have names that sound alike. They don’t depend on each other:

Engagement profileFinding profileConcern catalog
AnswersWhat is this engagement about, and when is a finding about it complete?How much does each finding record?What should the agent check for, file by file?
Valuescode, network, web, … or your own TOMLfull (default) or summaryowasp-top10-2021, cwe-top25-2023, … or your own YAML
Chosen by--engagement-profile, agentiflow engagement_profiles:agent findingsProfile, workflow / step findings_profile, --findings-profilethe agent’s concerns: block
Lives inbuilt-in, ~/.rupu/profiles/, .rupu/profiles/built into rupubuilt-in, ~/.rupu/concerns/, .rupu/concerns/
Recordsassets.jsonl + completeness-gated findingsthe shape of each finding recordfile × concern verdicts

Completeness checks run only on a full report (see Findings); under summary an asset is still routed and recorded, but its checks are skipped. Concern catalogs are lists of things to check (see Coverage). They’re independent of the engagement: a code engagement can use owasp-top10-2021, and so can a run with no engagement. The bundled catalogs (rupu coverage templates list, or show <name> to print one):

CatalogContents
owasp-top10-2021OWASP Top 10 web risks, 2021 (10 concerns)
owasp-api-top10-2023OWASP API Security Top 10, 2023 (10)
cwe-top25-20232023 CWE Top 25 most dangerous weaknesses (25)
cwe-software-developmentMITRE CWE Software Development view (~399)
cwe-researchMITRE CWE Research view (~944)
strideSTRIDE threat categories (6)
secrets-in-sourcehardcoded secrets in source (1)
code-smellsFowler-style code smells (12)
web-security-defaultbundle: owasp-top10-2021 + cwe-top25-2023 + secrets-in-source
api-security-defaultbundle: owasp-api-top10-2023 + cwe-top25-2023 + secrets-in-source