Skip to content

Configure risk context: risk-context.yaml

The same vulnerability is urgent in one deployment and irrelevant in another, and the facts that decide which are rarely in the code. Whether a service is reachable from the internet, which users you defend against, which risks you have already accepted deliberately: those live in your team's heads.

.k9security/risk-context.yaml is where you write them down once, in your repository, so every later scoring run reads the same answers instead of re-deriving them from whatever happened to come up in a chat session.

We measured what that is worth on a single advisory in one popular OSS project in our evals, running each cell three times. Naming a feature flag that ships enabled by default gave an ordinary user a capability the analysis needed to know about, and the analysis then judged the vulnerable path attacker-reachable, three runs out of three. Recording the risk that feature carries as one the team had deliberately accepted, with the flag still on, reversed the conclusion, three out of three. Nothing about the code changed between them.

Read both directions. Stating a fact can raise a verdict as readily as lower one, and the lowering came from the recorded acceptance rather than from the config fact, because that acceptance was scoped to bound the attacker model. That scoping is the one setting in this file that may affect reachability at all.

The file is optional. Scoring works without it and falls back to a conservative default of production / tier_2 / internal. The verdicts are simply lower-resolution than they could be.

Create it

Ask your agent:

Gather this project's risk context.

That runs gather_risk_context, which reads your project first: manifests, frameworks, where dependencies are installed, security-relevant configuration defaults, and how and where the code is deployed. Then your agent asks you only what it cannot work out for itself, a few questions at a time. It proposes the file. Then you review and commit it.

The guidance is published on three MCP surfaces, so use whichever your client supports:

  • Tool: gather_risk_context, callable mid-task with no user action.
  • Resource: k9://workflow/gather-risk-context, discoverable via listResources.
  • Prompt: gather_risk_context, discoverable via listPrompts.

The tool surface exists because some clients, VS Code among them, do not bring MCP prompts and resources into an agent session. If yours is one, call the tool.

Re-run it any time. With the file already present it switches to validating and repairing what is there rather than authoring from scratch: it fixes unambiguous drift in place and tells you, and asks about anything that needs a judgment. Two things it will never do on its own are widen an accepted risk's bounds or sign off the reviewed block for you.

Re-run it after a material security or architecture change, or whenever you learn a security-relevant fact the file does not capture.

What it looks like

Most first files are small. This is a complete, valid one for an internal service with no risks accepted:

schema_version: "1.0"
project:
  name: status-page
  summary: Internal service-status page for one engineering team.
  open_source: false
reviewed:
  date: 2026-07-28
  by: someone@example.com
execution_contexts:
  - id: runtime
    default: true
    asset_context:
      asset_criticality: tier_3
      network_exposure: internal
      data_classification: internal
      lifecycle: production
    deployment: |
      Runs on the corporate network only, no internet exposure. No customer
      data. An outage inconveniences one team.
trust_model:
  untrusted_actors: []
  trusted_actors:
    - id: employees
      description: All viewers are authenticated employees; read-only.
  out_of_scope_actors:
    - id: unauthenticated-network
      description: Not routable from the internet.
accepted_risks: []

An empty accepted_risks is a good outcome rather than a gap. An empty untrusted_actors is legitimate too, but it is a strong claim, so confirm it explicitly rather than letting it stand by default.

How scoring uses it

Your agent reads the file and lifts values out of it into score_risk:

In the file Reaches score_risk as
execution_contexts[].asset_context the matching binding's asset_context, verbatim
execution_contexts[].id that binding's execution_context_id
execution_contexts[].paths which context each dependency alert belongs to
execution_contexts[].frameworks which entry surfaces exist, before reachability tracing starts
execution_contexts[].dependency_sources where to read a package's installed source
reviewed.date + reviewed.commit risk_context_version, recording which revision produced the verdicts

One vulnerable dependency can run in several of your declared contexts, and score_risk scores the dependency's alert once per context and returns a verdict for each. That mechanism is context_bindings, and this file is what gives those bindings their names and values.

Scoring one alert in several contexts costs no more than scoring it in one. Metering counts alerts rather than verdicts, so declaring your contexts honestly never costs you anything.

Field reference

schema_version (required)

"1.0". Within 1.x we only add optional fields, so a file you commit today keeps working.

project (required)

Field Required Notes
name yes
summary yes One line someone unfamiliar would recognize.
open_source yes Descriptive. An open-source project's CI builds run against pull requests from outside contributors, which changes how build-time dependencies score.

reviewed (required)

Field Required Notes
date yes ISO date of the last human review.
by yes Who signed off.
commit no The commit the review was against.

Required because the file is authored once and then ages, and scoring is only as good as the day these facts were true. This block is yours. Your agent proposes it and asks you to confirm it names the right person, but it will not stamp it for you, and it will not re-stamp the date after its own edits. That would turn a freshness signal into a record of when an agent last touched the file.

execution_contexts (required, at least one)

One entry per place your code runs. Exactly one entry carries default: true.

A dependency is scored against the context where its own vulnerable code executes, rather than where the product as a whole lives. A linter that never ships to production is not a production risk, and a library in the serving path is not a build-time concern.

Field Required Notes
id yes Short, stable identifier. runtime and build are the common pair. See the note below.
default one entry The context for anything not matched more specifically.
applies_to recommended Which dependencies belong here, in your own words.
frameworks recommended name plus roles: what this code is built on, and what each framework is used for. Suggested roles: web, api, background-tasks, scheduled-jobs, cli, browser-ui, build, test.
paths no Which files this context governs. See paths below.
dependency_sources no Where this context's dependency source is installed or vendored (node_modules, vendor, a virtualenv's site-packages).
asset_context yes The five values scoring consumes. See asset_context below.
deployment yes Prose: how and where this runs, and what data it touches.

Keep id stable. It is recorded alongside every verdict scored in that context, so it is how one context's history is followed over time, and renaming a context splits that history in two. Name the role the code plays (runtime, build, admin-ui) rather than something that will churn, like a framework version or a server name. Use letters, digits, and . _ -, but not a colon, which belongs to finding_key. Because it is stored with your verdicts, keep it a bare identifier: no filesystem paths, hostnames, internal service names, URLs, or ticket references.

Why frameworks and not entry points? Frameworks change rarely, and when one does, that is exactly a reason to revisit this file. Specific entry points change every release, and a stale inventory is worse than none because a reader trusts it. Your agent derives entry points per run from the frameworks you name.

dependency_sources is worth filling in because these directories are usually excluded from version control, so nobody can infer them later, and analysis that cannot read a package's source cannot conclude anything about it. On one project we measured, a vendored dependency directory missing from a fresh clone left 17 of 20 findings analyzed with no source at all. Nothing in the run reported a problem.

Record only your own dependencies here. If your repository vendors or checks out other projects' source, whether test fixtures, sample applications, or code you analyze, those dependency trees are not yours and should not be bound to a context.

asset_context

Five values, consumed verbatim by scoring. Use these spellings and no others: an unrecognized field name is rejected outright, but an unrecognized value is read as "not stated", which quietly lowers a verdict instead of failing.

asset_criticality:    tier_1 | tier_2 | tier_3
network_exposure:     public_unauth | public_auth | internal | isolated
data_classification:  public | internal | confidential | regulated
lifecycle:            production | staging | dev
regulatory_scope:     a list, e.g. [pci, hipaa]  (omit or [] if none)
Value Means
tier_1 A breach or outage is a business-level event: customer-facing, revenue-critical, or handling identity and credentials.
tier_2 Supports the business; a failure hurts but is survivable.
tier_3 Limited blast radius; inconvenience.
public_unauth Reachable from the internet with no login.
public_auth Reachable from the internet, but only after authenticating.
internal Reachable only from a private network.
isolated Not routable from a general-purpose network at all.

Every field is optional in the sense that scoring tolerates its absence, but an absent field is treated as unknown rather than as safe, so fill in what you know.

Build, dev, and test tooling carries the build environment's context, not your deployed service's. Cloud CI runners are typically internal / tier_2 / dev.

paths

Optional. A single-product repository omits it and the default: true context covers everything. Add it when one repository holds parts with genuinely different risk, such as a monorepo, or product code alongside the infrastructure code that deploys it.

paths:
  include: ["/services/billing/**", "/services/billing/requirements.txt", "/services/billing/uv.lock"]
  exclude: ["/services/billing/**/*_test.py"]
  • include lists the files a context governs and exclude carves some back out. exclude wins when both match.
  • Globs use .gitignore syntax, relative to your repository root. * does not cross / and ** does. A trailing / means a directory. ! negation is not supported, so use exclude instead.
  • A pattern with no / matches at any depth. That is the trap, and it bites the most common case: pyproject.toml also matches a pyproject.toml in a sub-project. Anchor root manifests with a leading slash, as /pyproject.toml or /package.json.
  • Match the lock file as well as the manifest. An alert names the file that pins the resolved version, and in several ecosystems that is the lock file. Every npm advisory is reported against package-lock.json, so a context listing only /package.json matches none of them. List both, or use a glob covering both, as /package*.json catches package.json and package-lock.json together. The pairs worth knowing: package.json with package-lock.json / yarn.lock / pnpm-lock.yaml; pyproject.toml with uv.lock / poetry.lock; Gemfile with Gemfile.lock; go.mod with go.sum; Cargo.toml with Cargo.lock; composer.json with composer.lock.
  • Check your patterns against a real alert before you finish. Take a handful of your open dependency alerts, read the path each one reports, and confirm some context's include matches it. A pattern that matches no alert path fails silently: those alerts fall to the default: true context, which for most projects is the production one, so build-time dependencies get scored as production risk.
  • exclude any vendored or checked-out third-party tree. Another project's manifests are not your dependencies, and binding them here scores somebody else's code against your asset.
  • Files matched by no context fall to the default: true context. A file matched by two contexts is an ambiguity worth resolving deliberately rather than leaving to whichever context happens to win.
  • Every entry in paths and dependency_sources must resolve inside the project. An absolute path, a .. that climbs out of the project root, or a symlink leaving the tree is rejected.

One manifest can serve several contexts, and paths alone cannot separate them, because a dependency alert names a manifest. A single manifest might declare both a production service's dependencies and a batch job's, or declare dependency groups where only some ship to production. Say so in each sharing context's applies_to, so whoever scores knows the split is real and cannot be read off the path.

trust_model (required)

Three lists, untrusted_actors, trusted_actors, and out_of_scope_actors, each entry taking id, description, and optionally rationale.

This is the most decisive thing in the file. "Reachable" means reachable by someone: a bug only an administrator can trigger, in a product where administrators are trusted, is a different risk from one any logged-in user can trigger.

untrusted_actors[].controls is the field that pays off. List what that actor controls, such as the text of a query, an uploaded file, a configuration value, or a URL your server will fetch. Everything listed is treated as attacker-controlled input during reachability analysis, which is the question most reachability conclusions turn on.

trust_model:
  untrusted_actors:
    - id: authenticated-non-admin
      description: >-
        Any logged-in user. By design they author queries, parameters, and
        dashboard configuration.
      controls:
        - query text and query parameters
        - dashboard and visualization configuration
  trusted_actors:
    - id: admin
      description: Administrators. Data-source credentials and server settings are admin-only.
      rationale: >-
        A vulnerability reachable only by an admin acting within a capability we
        granted them is not attacker-controlled.
  out_of_scope_actors:
    - id: unauthenticated-network
      description: Anonymous parties on the network.
      rationale: Authentication is enforced at the edge; no anonymous access to any route.

Note what the example does not do: a low network_exposure and a populated untrusted_actors are not in tension. A service nobody can call directly can still process attacker-shaped input that arrives through a trusted channel. Record both, the exposure in asset_context and the input in controls.

accepted_risks (required, may be empty)

Risks you have decided to live with. Stating them stops scoring from re-reporting a decision you already made, and stops that decision from being applied to unrelated problems.

Field Required Notes
id yes Short identifier.
cwe no The weakness class.
capability yes What the accepted feature lets someone do.
actor no Who gets that capability.
scope yes The boundary. State what this does not cover.
evidence recommended A checkable config key or code fact.
rationale no Why you carry it.
bounds yes impact_only or attacker_model. See below.
paths no Where the acceptance applies, same glob semantics as above. Omit to apply repository-wide.

The boundary matters more than the acceptance. "We accept cross-site scripting" would silence every such finding in the product. What you actually accept is one capability of one feature, so scope should say what it does not reach.

bounds is the most consequential choice in this file.

Value Effect
impact_only The default. May lower how much a finding matters, and never changes whether code is reachable. Start here.
attacker_model The capability may be treated as unavailable to an attacker, which can flip a reachability conclusion from reachable to unreachable.

The test: if you turned this feature off tomorrow, would the vulnerability become unreachable, or would it merely matter less? If it would merely matter less, it is impact_only.

evidence carries real weight when it names something checkable. A configuration key someone can verify in your repository is worth far more than a paragraph of prose.

accepted_risks:
  - id: custom-js-visualizations
    cwe: CWE-79
    capability: >-
      authenticated users execute arbitrary JavaScript in other users' browsers
      via saved custom visualization code
    actor: authenticated non-admin user
    scope: >-
      Custom visualization rendering only. Does NOT extend to other XSS paths,
      sanitizer bypasses, or HTML rendering elsewhere in the product.
    evidence: ALLOW_CUSTOM_JS_VISUALIZATIONS=true
    rationale: >-
      Sharing custom visualization code is a core product capability and is on
      by default; we carry this deliberately.
    bounds: attacker_model

Your agent will propose acceptances it can see evidence for, including ones your own documentation already records in a decision log or a triage runbook, but it writes one only after you confirm it. An acceptance nobody agreed to hands out permission to discount real findings.

Protect the file

This file steers verdicts, and anyone who can land a commit can change it. Treat it as control-plane configuration:

  • Require review on everything under .k9security/, with a CODEOWNERS entry or a repository ruleset. That is what makes an acceptance a decision rather than an edit.
  • Resolve from the default branch in CI, never from a pull request's head. Otherwise, in a repository that accepts outside contributions, a pull request can add the context that scores it, declaring a low criticality or an acceptance that covers exactly what it introduces. A pull request that changes the risk context should be reviewed and merged on its own merits first.
  • No secrets. The file describes architecture and posture, and it gets committed. Someone documenting how authentication works can paste a credential without meaning to.

What k9 stores

k9 never reads your repository. Everything about this file happens on your side, and the server sees only the values your agent sends with a scoring call.

Of what your agent draws from this file, that is the asset_context values, the execution_context_id names, and the risk_context_version: short labels and enumerated values. Stored alongside them is the rest of what a scoring call carries, including the finding_key and project_id you construct and the vuln_id being scored, which is why this page and score_risk both ask you to keep those identifiers bare.

The file's prose stays on your machine. Your deployment descriptions, your trust model's description and controls text, and your accepted risks' rationale and evidence are never sent.


Last update: July 30, 2026