Skip to content

Asset & attack graphs

Policies tell Norviq what to enforce; the graphs tell you what to enforce next. Every call the engine evaluates is also recorded as structure: who called what, and what that tool reaches. That structure is the Asset Graph. Walking it from every origin produces the Attack Graph — the kill chains an attacker could ride — which you can Simulate against live policy and Defend with a generated dry-run draft.

flowchart LR
    T["Evaluated<br/>tool calls"] --> A["Asset graph<br/>agents · tools · data · MCP servers"]
    A --> K["Attack graph<br/>kill chains, worst-first"]
    K --> S["Simulate<br/>preview vs live policy"]
    K --> D["Define intent / Defend<br/>dry-run draft"]
    D --> P["Policy Catalog<br/>review → apply"]
    P --> E["Enforced"]
    E -->|"paths re-render as blocked"| K

One principle runs through every surface here: evidence, not conjecture. The graphs are built from traffic Norviq actually evaluated; a hop carries its real allow/block counts; an unproven path says so; and every mutating action on these screens is a preview or a dry-run draft — nothing enforces until you take a draft through the Policy Catalog’s gated apply flow.

The graph is written on the decision path, in the background, after the decision has already been returned (_safe_record_graph in norviq/engine/evaluator.py) — a graph write can never delay or fail an evaluation. Each recorded call:

  • upserts an agent node keyed by SPIFFE ID, refreshing its trust score with the score the evaluator just computed for that call;
  • upserts a tool node and an agent→tool calls edge, incrementing its call count;
  • creates the tool’s data edges (see below);
  • when the call arrived through an MCP proxy, upserts an mcp_server node and a serves edge.

The whole namespace graph is then snapshotted: to Redis under graph:<namespace> with a 300s TTL, and as a row in the asset_graph table. On the first call after a process start the persisted snapshot is restored into the live builder, so a pod restart does not lose accumulated reach. Retention keeps the newest config.retention.graphSnapshotKeepPerNamespace snapshots per namespace (default 10); every reader uses only the latest.

The graph is append-only in normal operation. Nodes are evicted only when a namespace exceeds NRVQ_GRAPH_MAX_NODES (default 5000, norviq/config.py), least-recently-touched first. To remove a decommissioned workload or a probe artifact deliberately, use the housekeeping endpoint below.

Node type What it is
Agent agent one workload identity, keyed by SPIFFE ID, with agent class, trust score and category, namespace
Tool tool id tool:<name>; a tool name that identity has been observed calling, with a risk level and call count
Data data id data:<uri>; a store or sink a tool reaches (postgresql/users, elasticsearch/knowledge_base, …)
MCP server mcp_server id mcp:<server_id>; a server that served a tool definition, with transport and tool count
Edge type Meaning
agent → tool calls an observed, evaluated call. Carries call_count, last decision, and the range-windowed decision_history
tool → data accesses the store that tool reaches, with the resolved operation
mcp server → tool serves that server provided this tool’s definition. Structural — no decision was made about it
agent → agent belongs_to structural: a class sub-node links to its shared SPIFFE identity (read-model only)

A fifth edge type, delegates (agent → agent, with chain depth), exists in the graph model but nothing on the decision path writes one today, so you will not see it in a live graph.

Only calls edges can carry a verdict, because only they correspond to an evaluated decision. This is why the Asset Graph’s Blocked stat is labelled edges and not paths — a blocked accesses edge is not a thing that exists.

If one SPIFFE ID has been observed running several agent classes, the read model expands it into a shared identity node plus one sub-node per class, linked by belongs_to, so two distinct chatbots on one service account never silently collapse into one dot.

Two sources, and the difference matters when you read the graph:

  1. A seed map of eight known tools (TOOL_DATA_MAP in norviq/engine/graph/asset_graph.py) — execute_sql, get_customer, search_kb, send_email, read_file, get_order, update_record, delete_record — which produce precise URIs like postgresql/payments.

  2. Everything else falls back to the tool’s verb. The same classifier enforcement uses (classify_tool) resolves the tool name, and the verb implies a coarse asset:

    Verb Derived data node Sensitivity
    delete compute/destructive critical
    write compute/mutating high
    send network/egress high
    read data/read medium

    These nodes are tagged data_type: derived so the console can distinguish “this is the orders table” from “this tool destroys something we could not name”. A tool the classifier cannot resolve at all gets no data edge — an edge to “unknown” would be noise, not evidence.

The fallback exists because blast radius is computed by walking these edges. Before it, a busy agent making hundreds of exec_shell calls produced an empty blast radius, and an empty blast radius does not read as “we cannot see this” — it reads as “nothing is reachable”.

An report-gen agent in the analytics namespace calling execute_sql and search_kb. A stadium is an agent, a rectangle a tool with its risk, a cylinder a data node; solid edges are observed calls with their decision, dashed edges are tool→data reach:

flowchart LR
    RG(["report-gen<br/>agent · trust 0.7"])
    RG -->|"12 calls · block"| ES["execute_sql<br/>tool · critical"]
    RG -->|"40 calls · allow"| SK["search_kb<br/>tool · low"]
    ES -.-> PU[("postgresql/users")]
    ES -.-> PO[("postgresql/orders")]
    ES -.-> PP[("postgresql/payments")]
    SK -.->|read| EK[("elasticsearch/<br/>knowledge_base")]

The operation label on a dashed edge is resolved against the source’s verb fragments, so name-encoded tools label cleanly (search_kb → read, delete_record → delete). An opaque wrapper like execute_sql matches no fragment for PostgreSQL and carries no operation label here — its destructive nature is resolved on the Attack Graph by the dynamic classifier, and you can make it explicit by promoting the tool.

  • Synthetic identities — red-team, probe, e2e and policy-tester classes, matched by an explicit norviq.io/synthetic=true marker or an anchored class-name convention (e2e-, probe-, evtrace-, canary-, policy-tester, …). Hidden by default with an “N test/probe agents hidden — Show” chip. Real product classes cannot collide with the patterns. API: ?include_synthetic=true.
  • Awaiting agents — classes an operator has deployed (a policy row or an agent-registry row exists) that have made no call yet. Rendered dimmed with a dashed ring; hidden by default with its own chip. API: ?include_awaiting=true. Managed scopes (__baseline__, __pack__, namespace:*) are never rendered as phantom awaiting agents.
  • Lookalike names — a node whose observed name contains confusable characters gets a ⚠ lookalike · <masked> · <codepoints> caption on both canvases. Tool and server names come from traffic, so they are attacker-influenced; the graph marks that rather than rendering the impersonation silently.

Click any node:

  • Blast radius (agent, tool, MCP server: everything reachable downstream) or Exposure (data nodes trace upstream: who can reach this store). The selected node is excluded from its own count, and belongs_to edges are not traversed — the number is reach, not padding. serves is traversed: “what would a compromised MCP server put in reach” is exactly the question.
  • Connections — every incident edge with its per-decision counts.
  • Trust score and category (the server’s >=0.7 high / >=0.4 medium ladder, not a second one computed in the browser), risk, ns/class/cluster chips, the full SPIFFE ID, last seen, and a View in Audit Log deep link carrying the SPIFFE ID and namespace.

The stat strip across the top is Namespaces · Nodes · Tools · Data · High risk · Blocked. The Data cell reads “N write/delete-open” — data nodes whose worst open verb is mutating. A dormant read grant is a lesser least-privilege note and deliberately does not light it red.

DELETE /api/v1/asset-graph/node is admin-only. Mint a token with norviq login (or the console API keys page) and export it as NRVQ_API_TOKEN, the same variable the CLI and the other API examples use:

Terminal window
export NRVQ_API_URL="https://norviq.internal" # your API base URL
export NRVQ_API_TOKEN="<admin bearer token>" # from `norviq login`
curl -s -X DELETE "$NRVQ_API_URL/api/v1/asset-graph/node?namespace=chatbot-prod&node_id=tool:legacy_export" \
-H "Authorization: Bearer $NRVQ_API_TOKEN"
{"removed":true,"namespace":"chatbot-prod","node_id":"tool:legacy_export","nodes":41,"edges":78}

It removes the node and its incident edges from the live builder and re-snapshots (which also invalidates the namespace’s cached analyses, so every graph surface reflects it immediately). Audit rows, policies and decisions are never touched. The same code path backs DELETE /api/v1/agents/{spiffe_id}, so deregistering an agent and pruning a node cannot drift.

Every data node whose source type Norviq recognizes gets a Source capability section in the inspector: what operations that kind of store exposes, and where you stand on each.

Source type Aliases Class Verbs exposed
postgresql postgres, psql, pg datastore read (low) · write (high) · delete (critical)
elasticsearch es, opensearch datastore read (low) · write (high — knowledge poisoning) · delete (critical)
smtp mail, email, ses egress send (high)
webhook http, https egress send (high)
s3 gcs, blob, minio object store read (low) · write (high) · delete (critical)
filesystem fs, file object store read (low) · write (high) · delete (critical)

PostgreSQL and Elasticsearch are the validated wave-1 sources; the other four are modelled so the classifier already works when they appear. A source type outside this table renders unannotated — unknown is left visibly unknown, not guessed.

For each verb, three real signals combine into one status:

  • granted — a tool reaching that source with that verb has an accesses edge;
  • observed — that tool’s calls history is non-zero in the window;
  • defended — that tool’s history contains a block, an escalate, or a Monitor-mode would-block. Monitor counts: a rule that stopped a call 412 times in an audit-mode namespace is a rule, and reporting it as undefended was wrong in the dangerous direction.
Status Meaning
UNDEFENDED observed and nothing has ever acted on it. The live gap — what Defend targets
DORMANT GRANT granted but never exercised. Least-privilege cleanup candidate
DEFENDED observed and a policy already answers it
LATENT the source exposes the verb, but nothing in your cluster grants or uses it

The worst open verb (undefended or dormant, highest risk) drives the node’s severity and the Data KPI. Where a verb maps to a real MITRE ATLAS technique — deletion → AML.T0048, index writes → AML.T0018, egress → AML.T0040 — the finding says so. Plain reads carry a tactic-level label and no technique id; Norviq does not fabricate ATLAS codes.

Console → Attack Graph reads GET /api/v1/threats/attack-paths, which derives kill chains live at request time from the latest asset-graph snapshot plus real audit decision history. It does not run any evaluation, so opening the page has no side effects.

  1. Origins. Two node kinds may start a chain: an agent sub-node (not an identity super-node, not awaiting, and it must carry an agent class) and an MCP server. Nothing else.
  2. Walk. Depth-first from the origin, simple paths only, max 4 hops. A chain terminates at a data node or at a tool with no outgoing edges.
  3. Fan-out budgets. Each distinct first-hop tool (chokepoint) gets its own budget of 3 chains, so a high-fan-out tool such as execute_sql reaching many tables cannot crowd out a sibling delete_record. An origin keeps every high/critical-risk chokepoint and caps only the low-risk tail (16 chokepoints total).
  4. Per hop, the real decision history for that (agent, tool) pair over the requested range is attached: allow / block / would-block counts, plus the resolved data operation on a tool→data hop.
  5. Dedupe by (namespace, origin, target, length), keeping the worst status.
  6. Rank worst-first: status (exploitable → unsimulated → blocked), then severity, then larger blast radius.
  7. Cap after ranking at 200 paths, of which non-agent origins may take at most 40 (see below). The response reports total_paths, and the console prints “Showing the N most severe of M” rather than truncating silently.

Severity, source → target, hop count, blast radius, the chokepoint (the last tool on the chain — block it and the chain dies), a MITRE chip, and a recommended fix. For an agent origin the inspector also shows min trust; for a non-agent origin that field reads “not an agent” rather than a fabricated number.

Blast radius on a card is every non-agent node reachable from the origin, excluding the path’s own target (the target is the compromise premise, not part of its own blast). The canvas draws the top eight, sensitive first.

Severity is computed, not assigned:

  • agent origin — (1 − trust) × 0.5 + target_sensitivity × 0.35 + 0.15 if a chokepoint exists
  • MCP-server origin — origin_risk × 0.6 + target_sensitivity × 0.25 + 0.1 if a chokepoint exists

where target sensitivity is 1.0 for a data node or a high/critical-risk node and 0.4 otherwise, and the score maps to critical ≥ 0.75, high ≥ 0.5, medium ≥ 0.25, else low. An agent origin with no recorded trust score defaults to 0.8; a frozen agent’s real 0.0 is used as-is.

The MITRE chip is name-derived and best-effort. It resolves the chokepoint tool name against a mapping table, then against its verb; a read-shaped name renders “Reconnaissance · read/search — no specific ATLAS technique”, and anything unmatched falls back to AML.T0051 · Impact · Excessive Agency. Treat it as a label for triage, not as an attested control mapping — for that use Compliance & coverage.

Two chains derived from the asset graph above. The first is exploitable (every hop has allowed traffic end to end); the second is blocked (policy denies the chokepoint):

flowchart LR
    A(["report-gen<br/>agent · trust 0.7"])
    A -->|"calls · 12 allowed"| S["execute_sql<br/>chokepoint · critical"]
    S -->|"reaches · delete · 8 allowed"| P[("postgresql/payments<br/>crown jewel · sensitive")]
    P --> EX{{"EXPLOITABLE — allowed<br/>end to end"}}
    A -->|"calls · 5 blocked"| DR["delete_record<br/>chokepoint · critical"]
    DR -.->|"reaches · delete"| U[("postgresql/users")]
    DR --> BL{{"BLOCKED — policy denies<br/>the chokepoint"}}
  1. Exploitable — every hop has real allowed traffic end to end. Proven, not theoretical.
  2. Unsimulated — the shape exists but there is no end-to-end traffic. Simulate to confirm.
  3. Blocked — a hop has blocks and no allows, or a Monitor-mode would-block covers it.

Fresh policies bridge the audit lag. Status comes from recorded traffic, so it lags a just-applied policy. When an applied intent or capability policy already denies a path’s chokepoint, the card gets a teal defended chip — “this status reflects past traffic — Simulate to confirm.” The check is precise: an intent policy counts only when it actually denies that chokepoint (not allowlisted, or allowlisted but refined out by Read-only), and an intent that does not scope by tool name is never claimed as the defense.

The stat strip — Critical paths · High · Chokepoints · Max blast radius · Exploitable · Blocked — filters the list on click. Chokepoints is the number of distinct chokepoint tools among the visible paths, using the server’s definition of chokepoint (the same value the inspector chips).

A server node is an origin because “if this integration were poisoned, what does it reach” is not answerable from any agent’s row: a hostile tool definition steers whichever agent happens to be connected. Its severity term is the registry decision you made on the MCP Servers page:

Registry status Origin risk
discovered (or no decision recorded) 1.0 — nobody has reviewed it
registered 0.5 — an operator vouched for it; it can still be compromised
blocked 0.1 — refused at discovery, so the topology exists but the reach does not

Three honest limits apply to these paths, and the console states each of them rather than papering over it:

  • They are never “exploitable”. Audit rows name the calling agent; no row will ever carry agent_id = mcp:<server>. So an MCP-origin path is permanently unsimulated, and its verdict says the reach is structural — the server serves the tool whether or not anyone has called it.
  • Simulate and Define-intent are hidden on them, not left to fail. Both are agent-class scoped (Simulate mints spiffe://…/sa/<class>; the draft creates norviq.intent.<class>), and a path with no class has nothing to scope to. Their governed_by is reported as n/a rather than as “no defense found”.
  • The recommended fix is different: register the server read-only, or block it, on the MCP Servers page — blocking withholds its tools at discovery.

Because input.mcp.server is PEP-reported and unvalidated, server names that become graph nodes are attacker-influenceable. That is why non-agent origins get their own 40-path sub-budget inside the 200-path cap: measured, 300 fabricated servers pushed every real agent kill chain out of the view. The reservation is dynamic — what MCP origins do not use goes to agents, so an estate with no MCP servers still gets the full 200.

Simulate answers “does my current live policy stop this path — right now?” by running each tool step of the selected path through the real POST /api/v1/evaluate endpoint under a synthesized identity for the path’s namespace and class. It changes no policy and blocks nothing for real, but each step is a genuine evaluation:

Result Meaning
Blocked by an authored policy a policy you wrote or applied denies a step — real coverage
Would be blocked — Monitor mode decision audit: policy covers it, the namespace only logs
Blocked by the no-policy setting rule_id = no_policy_loaded — no policy is loaded for this namespace and its no_policy_decision is set to deny. The shipped default is allow, so an unpoliced namespace normally shows as Policy gap below, not this row. Not authored coverage either way; write a policy
Policy gap every step allowed — the path is open

A simulate result updates that one path’s status locally; it never inflates the headline Blocked stat or reorders other paths.

  • Block this step (what-if) — a per-hop hypothetical: the canvas and verdict show “blocking <tool> at step N WOULD neutralize this path”. Purely visual, counted separately as “+N what-if” and never merged into the real Blocked stat.
  • Draft blocking policy — persists a real dry-run draft for that path (read-only intent, empty allowlist) and returns the deep link into Policies → Catalog.

Define intended behaviour (the intent allowlist)

Section titled “Define intended behaviour (the intent allowlist)”

The Attack Graph’s main defense flow — per agent class from a path, or from the toolbar:

  1. The modal lists the class’s observed tool surface from real traffic (GET /threats/intent-suggest), every tool unchecked by default — positive security means you assert what is intended. Chokepoint and egress tools are tagged, and each row shows the tool’s classification chip.
  2. Pick the intended tools plus up to four refinements: Read-only, Namespace-scoped, Rate-limit, No external egress. The rate refinement is advisory: a stateless policy cannot count calls per minute, so it compiles to a call-depth bound and the real limiter is the throttle layer.
  3. A live coverage preview dry-runs the generated policy against every path’s chokepoint through an isolated evaluator key — “covers N of M paths” plus the residual list. The denominator is the class’s uncapped path count, not the 200 shown on screen. Nothing is persisted.
  4. Apply intent policy stores one draft per (namespace, agent_class) in intent_drafts and deep-links to the Policy Catalog. The evaluator never reads that table; enforcement happens only through the gated Apply → Confirm flow, and the draft inherits the namespace baseline priority so an applied draft stays tighten-only.

Drafts expire after config.retention.draftTtlDays (default 14; 24 hours for synthetic classes) and a namespace keeps at most config.retention.draftCapPerNamespace (default 50), oldest evicted.

The generated policy is the default-deny allowlist described in Writing policies (norviq.intent.<class>, evasion-normalized matching).

From a data node’s capability findings, Defend turns the worst open verb into a draft in one click — “make <class> read-only” (all mutating verbs the source exposes) or “block <verb> for <class>. Reads are never a defense target, and a source exposing no blockable verb returns 422 rather than an empty policy.

Because policy evaluates tool calls and the OPA input carries no data-source field, the generated Rego resolves the source-level intent into tool-name terms, two ways at once:

  • the concrete tools observed reaching that source with the target verb, resolved from the live snapshot at generation time; and
  • a forward guard: the source’s verb fragments (delete → delete/drop/purge/truncate) matched at a word boundary on both the raw and evasion-normalized tool name — so a renamed or not-yet-seen destructive tool is caught too.

The draft lands in the same inbox with source_framework = capability, deduped per (namespace, class, source:verbs), and goes through the same gated review → dry-run → apply flow.

Tool classification & the promotion lifecycle

Section titled “Tool classification & the promotion lifecycle”

Norviq classifies every tool name it sees into a verb (read / write / delete / send) and a risk:

  • the name is tokenized on delimiters and camelCase, folded through the confusable skeleton, then matched whole-token against a verb lexicon — so aws_s3_DeleteObject, s3:DeleteObject and a homoglyph dеlete_records all resolve, and compute_input is not a write;
  • an actuation noun with a control verb (open_breaker, set_valve) is decided as critical control-plane actuation;
  • when the name is inconclusive, tool_params are inspected (a leading SQL verb, an egress destination key);
  • risk and verb are ranked separately. The risk is the worst any matched token carries. The reported verb demotes exec qualifiers (run, invoke, exec, restart) below the token they accompany — run_export is a send, not a delete — while keeping destruction the worst thing a name can say. invoke_send_pipeline is therefore send, still graded critical.

Unknown ≠ safe. A tool that cannot be classified surfaces as unclassified · review. That is where the lifecycle starts:

  1. Observe — when the name says nothing but the params reveal the operation, each call stamps that evidence onto the audit row (op, op_src=params). Run the namespace in Monitor mode and evidence accrues with zero enforcement risk.
  2. Infer — evidence from the last 7 days aggregates into a per-tool verb histogram (“observing · delete 2/14”). The headline is the most destructive verb observed, with frequency only as a tie-break: for an authorization decision the question is what the tool can do, so one observed delete makes it a delete tool. The full histogram travels alongside.
  3. Promote (admin) — accept the inference or override to any of the four verbs. Risk always follows the verb (delete ⇒ critical), so a promotion can never under-declare. The justifying evidence is stored with the override. Demote deletes it and returns the tool to observation.

The Tool verbs panel on the Attack Graph toolbar manages the whole lifecycle. Path cards carry a stage tag, and promoting from anywhere refreshes every surface. Across a multi-namespace view, a tool promoted differently in two namespaces displays the worse risk.

What a promotion changes:

  • Displays — hop operation chips, path severities and allowlist rows resolve promoted verb → name classifier → observed evidence, in that order.

  • Generated policies — learned verbs flow into the intent generator, so a misleadingly-named tool promoted to delete can no longer pass the Read-only toggle, and one promoted to send counts as an egress sink.

  • Enforcement, with limits. Promote and demote both re-seed the evaluator’s in-process override map, so input.derived.verb reports the promoted verb on the very next call — a verb-gated policy sees it without a restart. Two limits are deliberate and load-bearing:

    • the override is keyed (namespace, tool_name) — a promotion applies only in the namespace it was written to, which is why the console refuses to promote from an aggregate scope;
    • a promotion cannot contradict the name classifier. If the name resolves, the classifier wins outright in either direction (milvus_search promoted to send stays read). Promotion only speaks for names the classifier cannot resolve — which is exactly the candidate set the panel offers. If you believe the classifier is wrong, the classifier is what needs changing.

    Re-seeding is best-effort: a DB hiccup during a demote leaves the retracted verb enforcing until the next promote/demote or a restart.

  • The graphs are evidence of what was observed, never an inventory. A tool nobody has called and a store nobody has reached do not exist here. An empty Tools or Attack Graph on a quiet namespace is the expected result, not a failure.
  • Reach is cumulative and the range does not prune it — see the caution above.
  • Non-agent origins are limited to MCP servers. There is no way today to express “a compromised CI job”, “a human operator” or “an inbound webhook” as a path origin; only identities that call /evaluate (agents) and servers reported through input.mcp.server become nodes.
  • A SPIFFE identity running several agent classes produces no kill chains. Its calls edges stay on the shared identity node, which is excluded as a path origin, while the class sub-nodes carry only the structural belongs_to edge. The identity still renders in full on the Asset Graph. Give each agent class its own workload identity if you need attack paths for it.
  • Tool→data edges outside the eight seeded tools are verb-derived, not discovered. They are correct about what kind of thing is reachable and deliberately vague about which one.
  • Data-node sensitivity is name-based. The stored path scorer treats a target as sensitive when its name contains customers, users, payments, pii or secrets; the live derivation treats every data node as sensitive. Neither reads a data classification you own.
  • There are two path stores. The console list is derived live from the snapshot on every read. POST /attack-paths/compute writes a separate precomputed set into the attack_paths table, which GET /api/v1/attack-paths serves; no console page reads that table today. The Recompute button drives the precompute and reports what it computed — a 0 result (no stored asset graph, no agents in it) is stated rather than rendered as a clean bill of health.
  • GET /api/v1/graph/* is a separate, API-only analysis family over the same builder (summary, blast radius, chokepoints, critical paths). It is cached per graph version, and it has no console consumer — treat it as an integration surface, not as what the console is showing.

Bearer-token auth throughout, namespace-scoped like every other read route: namespace=all unions the caller’s namespaces, a scoped viewer naming someone else’s namespace gets 403, and a non-admin with no namespace claim gets no tenant data at all. Ranges accept 1h, 6h, 24h, 7d, 30d (anything else falls back to 24h). Routes marked +cluster are refused when a fleet hub aims them at a remote cluster.

Method & path Auth Purpose
GET /api/v1/asset-graph?namespace=&range=&include_synthetic=&include_awaiting= user the asset-graph read model: nodes, edges, synthetic_hidden, awaiting_hidden
DELETE /api/v1/asset-graph/node?namespace=&node_id= admin remove a node + its incident edges from the graph only
GET /api/v1/threats/attack-paths?ns=&cls=&range=&include_synthetic= user live kill chains, worst-first, with per-hop evidence, class_totals, total_paths, non_agent_paths
POST /api/v1/threats/intent-coverage user dry-run a candidate allowlist against every chokepoint. Nothing persisted
POST /api/v1/threats/intent-draft admin +cluster persist an intent draft; returns the Policy Catalog deep link
GET /api/v1/threats/intent-suggest?ns=&cls= user the class’s observed tool surface, tagged egress/chokepoint
GET /api/v1/threats/intent-drafts · GET/DELETE /threats/intent-drafts/{id} user / user / admin the drafts inbox (paginated)
POST /api/v1/threats/intent-drafts/gc?ns= admin clear expired drafts
POST /api/v1/capability/defend admin +cluster turn a source-capability finding into a dry-run draft
GET /api/v1/threats/tool-verbs?ns= user lifecycle state: promoted overrides + observing candidates with evidence
POST /api/v1/threats/tool-verbs/promote · DELETE /api/v1/threats/tool-verbs?ns=&tool_name= admin promote / demote a tool’s verb
POST /api/v1/attack-paths/compute?namespace= admin +cluster recompute the stored path set; omit namespace for every namespace
GET /api/v1/attack-paths?namespace=&severity= user the stored precomputed path set
GET /api/v1/graph/summary · /blast-radius/{agent_id} · /chokepoints · /critical-paths · /attack-paths?source=&target= · /analysis user API-only analysis over the same builder

POST /attack-paths/compute without namespace is the only way to reach the all-namespaces branch; passing the console’s all sentinel takes the single-namespace path and matches nothing.

A practical loop for a newly onboarded namespace:

  1. Put the namespace in Monitor mode and let real traffic build the asset graph (Getting started, Writing policies).
  2. Read the Attack Graph worst-first; Simulate the top paths to separate real, authored coverage from a path that only looks closed — a Monitor-mode would-block, or (only if you have set no_policy_decision: deny) an unauthored no-policy block. The shipped default is allow, so most unpoliced paths simulate as a Policy gap, not a block.
  3. Promote the tools stuck in observing so the intent generator has true verbs to work with.
  4. Define intended behaviour per agent class (or Defend the worst source finding), dry-run the draft, review it in the Policy Catalog, apply.
  5. Decide the MCP-origin paths on the MCP Servers page — register read-only or block. They will not resolve through an agent-class policy.
  6. Flip the namespace to Block, re-Simulate, and confirm every riding path renders blocked — then prove it adversarially with the red-team suite (run via norviq redteam run).

For mapping enforced policy to a recognized threat framework and closing the residual gaps, continue to Compliance & coverage.