Skip to content

Latest commit

 

History

History
1848 lines (1431 loc) · 65.3 KB

File metadata and controls

1848 lines (1431 loc) · 65.3 KB

Building a darnit Implementation

New to darnit? Start with the Getting Started Guide instead. It provides:

This document is a deep technical reference covering the full implementation surface area in detail.

This guide walks through building a compliance implementation for the darnit framework. By the end, you'll have a working plugin that defines controls, runs automated checks through the sieve pipeline, and provides remediation actions.

We'll build a hypothetical implementation called "darnit-mystandard" that checks projects against a fictional "My Compliance Standard". Along the way, we'll reference how darnit-baseline (the OpenSSF Baseline implementation) solves the same problems.

Prerequisites

  • Python 3.11+
  • Familiarity with Python packaging (pyproject.toml, entry points)
  • A local clone of the darnit repository for reference

Starting point: packages/darnit-hello/ is the minimal plugin template: one control, the entry points, and a framework TOML inside the package. Copy it and grow it with this guide. For real plugins with custom step types, see packages/darnit-reproducibility/ and packages/darnit-gittuf/.

Architecture Overview

darnit uses a plugin architecture that separates the core framework from compliance implementations:

┌─────────────────────────────────────────────────────────┐
│                    darnit (framework)                    │
│                                                         │
│  ┌──────────┐  ┌──────────┐  ┌────────┐  ┌──────────┐  │
│  │ Discovery │  │  Sieve   │  │ Config │  │  Server  │  │
│  │  System   │  │ Pipeline │  │ Loader │  │  (MCP)   │  │
│  └──────────┘  └──────────┘  └────────┘  └──────────┘  │
│        │              │            │                     │
│        ▼              ▼            ▼                     │
│  ComplianceImplementation Protocol                      │
└────────────────┬────────────────────────────────────────┘
                 │
    ┌────────────┼────────────────┐
    ▼            ▼                ▼
┌─────────┐  ┌──────────┐  ┌──────────┐
│ darnit- │  │ darnit-  │  │  your    │
│baseline │  │testchecks│  │  plugin  │
└─────────┘  └──────────┘  └──────────┘

Key rule: The framework never imports implementation packages directly. All communication goes through the ComplianceImplementation protocol. Implementations may import from the framework.

The three layers

darnit operates at three distinct layers. Each layer has built-in primitives and plugin extensibility:

┌─────────────────────────────────────────────────────────┐
│  Layer 3: MCP Tools (what the AI assistant calls)       │
│                                                         │
│  Built-in: audit, remediate, list_controls              │
│  Plugin:   any Python function registered as a handler  │
│                                                         │
│  TOML:  [mcp.tools.audit]                               │
│         builtin = "audit"                               │
├─────────────────────────────────────────────────────────┤
│  Layer 2: Remediation (how to fix a failing control)    │
│                                                         │
│  Built-in: file_create, exec, platform_setting,         │
│            project_update, yaml_inject, manual          │
│  Plugin:   handler = "my_custom_fix"                    │
│                                                         │
│  TOML:  [[controls."X".remediation.handlers]]           │
│         handler = "file_create"                         │
│         path = "SECURITY.md"                            │
├─────────────────────────────────────────────────────────┤
│  Layer 1: Checking (how to verify a control)            │
│                                                         │
│  Built-in: file_exists, exec, pattern, manual           │
│  Plugin:   handler = "my_custom_check"                  │
│                                                         │
│  TOML:  [[controls."X".passes]]                         │
│         handler = "file_exists"                         │
│         files = ["README.md"]                           │
└─────────────────────────────────────────────────────────┘

Layer 1 (Checking) answers: "Does this control pass?" Using built-in pass types (file existence, command execution, regex patterns) or custom Python check functions.

Layer 2 (Remediation) answers: "How do I fix it?" Using built-in actions (create file from template, run a local fixing tool, require a platform setting, update project config, inject a YAML key, give manual steps) or custom Python remediation functions. Every remediation previews by default and applies only what it previewed (see "Remediation in TOML").

Layer 3 (MCP Tools) answers: "What can the AI assistant do?" Using built-in tools (audit all controls, remediate failures, list controls) or custom Python tool handlers.

For simple frameworks, all three layers can be TOML-only — no Python required. For complex frameworks, any layer can be extended with Python plugins.


Quick Start: TOML-Only Framework (No Python)

If your controls only need file checks and template-based remediation, you can create a working MCP server with just a TOML file:

# my-standard.toml
[metadata]
name = "my-standard"
version = "0.1.0"
schema_version = "0.1.0-alpha"
spec_version = "v1.0"
description = "My compliance standard"

[templates.readme]
content = "# $REPO\nA brief description.\n"

[controls."MS-01"]
name = "HasReadme"
description = "Project must have a README"
tags = { level = 1, domain = "DOC" }

[[controls."MS-01".passes]]
handler = "file_exists"
files = ["README.md", "README.rst"]

[[controls."MS-01".passes]]
handler = "manual"
steps = ["Check for README in project root"]

[[controls."MS-01".remediation.handlers]]
handler = "file_create"
path = "README.md"
template = "readme"
project_reference = "documentation.readme"

[mcp.tools.audit]
builtin = "audit"
description = "Run compliance audit"

[mcp.tools.remediate]
builtin = "remediate"
description = "Auto-fix failing controls"

[mcp.tools.list_controls]
builtin = "list_controls"
description = "List all controls"

Serve it directly:

darnit serve /path/to/my-standard.toml

Or configure in Claude Code:

{
  "mcpServers": {
    "my-standard": {
      "command": "uv",
      "args": ["run", "--directory", "/path/to/darnit", "darnit", "serve", "/path/to/my-standard.toml"]
    }
  }
}

The builtin = "audit" tools use the framework's generic implementations that work with any TOML-defined controls. For custom tools, use a Python plugin package instead (see sections below).


1. Package Setup

Directory structure

darnit-mystandard/
├── pyproject.toml
├── mystandard.toml              # Framework configuration
└── src/
    └── darnit_mystandard/
        ├── __init__.py           # register() function
        ├── implementation.py     # ComplianceImplementation class
        ├── controls/
        │   ├── __init__.py
        │   └── level1.py         # Python-defined controls
        └── remediation/
            ├── __init__.py
            └── registry.py       # Remediation action mappings

pyproject.toml

The critical piece is the entry point registration. darnit discovers implementations through the darnit.implementations entry point group:

[project]
name = "darnit-mystandard"
version = "0.1.0"
description = "My Compliance Standard checks for darnit"
requires-python = ">=3.11"
dependencies = [
    "darnit>=0.1.0",
]

[project.entry-points."darnit.implementations"]
mystandard = "darnit_mystandard:register"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/darnit_mystandard"]

The entry point format is name = "module:function". When darnit starts, it calls importlib.metadata.entry_points(group="darnit.implementations"), loads each entry point, and calls the returned function.

The register() function

# src/darnit_mystandard/__init__.py

def register():
    """Register the My Standard implementation with darnit.

    Called by darnit's plugin discovery via entry points.
    """
    from .implementation import MyStandardImplementation
    return MyStandardImplementation()

The lazy import inside register() is intentional — it avoids loading the implementation class until darnit actually needs it.

Reference: See packages/darnit-baseline/src/darnit_baseline/__init__.py and packages/darnit-baseline/pyproject.toml for the real implementation.


2. The Implementation Class

Your implementation must satisfy the ComplianceImplementation protocol defined in packages/darnit/src/darnit/core/plugin.py. This is a typing.Protocol (structural typing), so you don't need to inherit from it — just implement the required interface.

Protocol definition

@runtime_checkable
class ComplianceImplementation(Protocol):
    @property
    def name(self) -> str: ...

    @property
    def display_name(self) -> str: ...

    @property
    def version(self) -> str: ...

    @property
    def spec_version(self) -> str: ...

    def get_framework_config_path(self) -> Path | None: ...

Required properties (4)

Property Type Purpose
name str Unique identifier (e.g., "mystandard")
display_name str Human-readable name (e.g., "My Compliance Standard")
version str Implementation version (e.g., "0.1.0")
spec_version str Spec version implemented (e.g., "MySpec v1.0")

Required method (1)

Method Purpose
get_framework_config_path() Return path to the TOML configuration file

Controls, SARIF rule metadata, and remediations all come from that TOML file; the implementation class does not return them. get_all_controls, get_controls_by_level, get_rules_catalog, get_remediation_registry, and register_controls were removed from the protocol in 0.2.0 (#487). A plugin that still defines them is discovered as before, and they are not called.

Optional methods

Method Purpose
register_handlers() The handler hook: register custom sieve step types and MCP tool handlers (checked via hasattr; see Section 5)

register_handlers() is the one supported name. The framework also calls a register_sieve_handlers() method, for compatibility with plugins written before 0.2.0 only (#451). Registering handlers as a side effect of importing your module works, but the framework cannot see it, and a registry reset loses the handlers.

Minimal implementation

# src/darnit_mystandard/implementation.py
from pathlib import Path


class MyStandardImplementation:
    """My Compliance Standard implementation for darnit."""

    @property
    def name(self) -> str:
        return "mystandard"

    @property
    def display_name(self) -> str:
        return "My Compliance Standard"

    @property
    def version(self) -> str:
        return "0.1.0"

    @property
    def spec_version(self) -> str:
        return "MySpec v1.0"

    def get_framework_config_path(self) -> Path | None:
        # Navigate from implementation.py to the TOML file
        return Path(__file__).parent.parent.parent / "mystandard.toml"

    def register_handlers(self) -> None:
        """Optional. Register custom sieve step types here (see Section 5)."""

The get_framework_config_path() method deserves attention: it must return the correct path relative to implementation.py. The path traversal depends on your package structure. For src/darnit_mystandard/implementation.py reaching mystandard.toml at the package root:

implementation.py  →  parent (darnit_mystandard/)
                   →  parent (src/)
                   →  parent (darnit-mystandard/)
                   →  / mystandard.toml

Reference: See packages/darnit-baseline/src/darnit_baseline/implementation.py for the full OpenSSF Baseline implementation class.


3. TOML Framework Configuration

The TOML file is the declarative heart of your implementation. It defines metadata, templates, context collection prompts, and control definitions. The framework loads this file via get_framework_config_path().

Metadata section

[metadata]
name = "mystandard"
display_name = "My Compliance Standard"
version = "0.1.0"
schema_version = "0.1.0-alpha"
spec_version = "MySpec v1.0"
description = "Compliance controls for My Standard"
url = "https://example.com/mystandard"

Control definitions

Each control is defined under [controls."CONTROL-ID"]. Controls can have declarative passes that the framework executes without Python code:

[controls."MS-DOC-01"]
name = "ReadmeExists"
description = "Project must have a README file"
tags = { level = 1, domain = "DOC", documentation = true }
docs_url = "https://example.com/mystandard#MS-DOC-01"
help_md = """Create a README.md file in the repository root.

**Remediation:**
1. Create README.md with project description
2. Include usage instructions
"""

# Deterministic pass: check if file exists
[[controls."MS-DOC-01".passes]]
handler = "file_exists"
files = [
    "README.md",
    "README.rst",
    "README.txt",
    "README",
]

# Manual pass: fallback verification steps
[[controls."MS-DOC-01".passes]]
handler = "manual"
steps = [
    "Check repository root for README file",
    "Verify README contains project description",
]

Handler when clauses

Controls can declare preconditions using a when clause. If the condition is not met, the control is skipped (not failed) during the audit. This is useful for controls that only apply to certain platforms, CI providers, or project types:

# Only check on GitHub-hosted repositories
[controls."MS-AC-01"]
name = "RequireMFA"
description = "Require multi-factor authentication"
when = { platform = "github" }

# Only check if the project uses GitHub Actions
[controls."MS-CI-01"]
name = "SecureWorkflowInputs"
description = "Workflows handle untrusted inputs safely"
when = { ci_provider = "github" }

# Only check if the project makes official releases
[controls."MS-REL-01"]
name = "SignReleases"
description = "Sign releases or provide signed manifest"
when = { has_releases = true }

# Multiple conditions (all must be true)
[controls."MS-REL-02"]
name = "UniqueVersionIdentifiers"
description = "Assign unique version identifiers to each release"
when = { has_releases = true, platform = "github" }

The when keys are matched against project context values from .project/project.yaml. If a key is missing from the project context, the condition is treated as not met and the control is skipped. This avoids false failures for controls that are inapplicable to the project.

Reference: See packages/darnit-baseline/src/darnit_baseline/openssf-baseline.toml for real-world when clauses used across 30+ controls.

Built-in pass types

The framework provides these built-in handler names for use in TOML [[passes]] entries, implemented in packages/darnit/src/darnit/sieve/builtin_handlers.py:

Handler Name Purpose
file_exists File existence checks
exec Run external commands, evaluate with CEL
regex / pattern Regex matching in file contents
manual / manual_steps Human verification steps

file_exists

The simplest check. It fails when none of the listed files exists; a file that exists is evidence, and concludes PASS only with existence = true (for a control whose requirement is literally that the file exists):

[[controls."MS-SEC-01".passes]]
handler = "file_exists"
files = [
    "SECURITY.md",
    ".github/SECURITY.md",
    "docs/SECURITY.md",
]

exec (external command)

Run a CLI tool and evaluate the result with a CEL expression:

[[controls."MS-AC-01".passes]]
handler = "exec"
command = ["gh", "api", "/orgs/$OWNER"]
pass_exit_codes = [0]
fail_exit_codes = [1]
output_format = "json"
expr = 'output.json.two_factor_requirement_enabled == true'
timeout = 30

Variable substitution in commands: $PATH (local repo path), $OWNER, $REPO, $BRANCH, $CONTROL.

Pattern matching

Search file contents with regex:

[[controls."MS-DOC-02".passes]]
handler = "pattern"
files = ["SECURITY.md", ".github/SECURITY.md"]
pattern = { patterns = { security_contact = '([\w.-]+@[\w.-]+\.\w+|security\s*contact)' } }
expr = 'output.any_match'

Manual verification

Always returns INCONCLUSIVE with human-readable steps:

[[controls."MS-DOC-02".passes]]
handler = "manual"
steps = [
    "Open SECURITY.md",
    "Verify it contains a clear contact method",
    "Confirm the contact method is monitored",
]

Remediation in TOML

Controls can define declarative remediation steps that the framework executes without Python code. A remediation is an ordered list of handler invocations under [[controls."ID".remediation.handlers]]; each step may have a when clause, and strategy = "first_match" stops after the first step whose when matches. The authoritative reference is docs/architecture/framework-design.md sections 4 and 15.

Every remediation runs in two modes. A preview (the default for every tool) runs the same handler logic as the apply against the current state and writes nothing; it lists each file to be created or changed, each platform field before and after, each command, and a digest per step. An apply writes exactly the previewed changes. The executor is the only writer: handlers return the changes they would make, and the executor writes them, skips any file with uncommitted user changes, and records every write in an operator-side run manifest, so the git tools commit only those files.

file_create

Create a file from a template:

[[controls."MS-SEC-01".remediation.handlers]]
handler = "file_create"
path = "SECURITY.md"
template = "security_policy"
overwrite = false
project_reference = "security.policy"    # Record the file in .project/ after creating it

Parent directories of path are always created.

A file that already exists is not written (overwrite = false, the default) and the outcome is unchanged, reason already_exists. project_reference names the .project/ field that describes the created file; it is recorded only for a file created in this run and only into an empty field.

exec (run a local fixing tool)

[[controls."MS-BR-01".remediation.handlers]]
handler = "exec"
command = ["zizmor", "--fix=all", "--offline", "$PATH"]
pass_exit_codes = [0]
timeout = 60
effects = "working_tree"   # Changes only files in the working tree
offline = true             # Needs no network

With both effects = "working_tree" and offline = true the preview runs the command in a scratch copy and shows the resulting file changes. Without them the step "cannot be previewed exactly" and runs only when the person approves its plan item digest. An exec remediation never changes platform state; a shipped exec remediation whose command is gh, curl, wget, or git push fails validate_sync.

platform_setting

Declare a requirement on a platform target, not a payload:

[[controls."MS-AC-01".remediation.handlers]]
handler = "platform_setting"
target = "branch_protection"   # branch_protection | repository | vulnerability_reporting
require = { require_pull_request = true, require_approvals = 1 }
# branch = "release"           # optional; default = the repository's default branch

The platform engine reads the current settings, plans the smallest change that satisfies the requirement without weakening anything already configured, writes it under the operator's [remediation] policy (by default only when the person approves the change set's digest), and reads the result back. A requirement already met, including by an active ruleset, writes nothing. GitHub is the only supported platform; other forges get manual steps.

manual

[[controls."MS-AC-02".remediation.handlers]]
handler = "manual"
steps = ["Go to Organization Settings -> Authentication security", "Enable two-factor authentication"]
docs_url = "https://docs.github.com/..."

Use manual steps when the platform has no API for a change or when the choice needs a person.

project_update

Set .project/ fields after a successful remediation:

[controls."MS-SEC-01".remediation.project_update]
set = { "security.policy.path" = "SECURITY.md" }

The set dict maps dotted paths to values. security.policy.path becomes:

# .project/project.yaml
security:
  policy:
    path: SECURITY.md

project_update is written only when every step of the remediation succeeded, by the executor, through the same preview and run manifest. To record where a created file lives, prefer project_reference on the file_create step.

Safety fields

Field Effect
safe = false Every step of the remediation that may change something needs its own approval (its plan item digest), under every policy
requires_api Descriptive only

api_call, requires_confirmation, dry_run_supported, and dry_run_command were removed in feature 043; a TOML that declares one fails to load with the replacement named.

on_pass (post-check context updates)

When a control passes, the sieve may discover useful evidence (e.g., "SECURITY.md exists at this path"). The on_pass section feeds that evidence back into .project/project.yaml so subsequent checks can use it:

[controls."MS-SEC-01".on_pass]
project_update = { "security.policy.path" = "SECURITY.md" }

Values can reference evidence gathered during the check using $EVIDENCE.<key>:

[controls."MS-SEC-01".on_pass]
project_update = { "security.policy.path" = "$EVIDENCE.file" }

This creates a lifecycle:

  • Control passes → on_pass records what was found
  • Control fails → remediation fixes it → project_update records what was created
  • Next audit run → project config has the context for downstream controls

Templates

Templates are string content blocks used by remediation actions:

[templates.security_policy]
description = "Standard SECURITY.md template"
content = """# Security Policy

## Reporting a Vulnerability

Report vulnerabilities by emailing security@$OWNER.example.com.
We will respond within 48 hours.
"""

Templates support $OWNER and $REPO variable substitution.

Context definitions

Context prompts collect project-specific information to improve audit accuracy:

[context.ci_provider]
type = "enum"
prompt = "What CI/CD system does this project use?"
hint = "Select your primary CI/CD provider"
values = ["github", "gitlab", "jenkins", "none", "other"]
affects = ["MS-CI-01", "MS-CI-02"]
store_as = "ci.provider"
auto_detect = true
required = false
presentation_hint = "[github/gitlab/jenkins/circleci/azure/travis/none/other]"

Context values are stored in .project/project.yaml and injected into checks via CheckContext.project_context.

presentation_hint and allowed_values

Context definitions support two additional fields for controlling how the LLM presents questions to users:

  • presentation_hint — A short string shown alongside the prompt to indicate the expected format (e.g., "[y/N]" for booleans, "[github/gitlab/other]" for enums). If not set, the framework auto-generates a hint from allowed_values or values.

  • allowed_values — An explicit list of display values, distinct from values (which is used for validation). Useful when the display options differ from the validation set.

auto_detect = false marks a user-judgment key: the framework may still propose a candidate for it (see allow_sieve_hints and hint_sources), but may never conclude the value on its own. The key stays unverified until a person confirms it.

[context.governance_model]
type = "enum"
prompt = "What governance model does this project use?"
store_as = "governance.model"
auto_detect = false
presentation_hint = "[bdfl/meritocracy/democracy/corporate/foundation/committee/other]"

[context.has_releases]
type = "boolean"
prompt = "Does this project make official releases?"
store_as = "releases.enabled"
auto_detect = true
presentation_hint = "[y/N]"

Reference: See packages/darnit-baseline/src/darnit_baseline/openssf-baseline.toml for a full TOML configuration with 62 controls, templates, and context definitions.


4. The Sieve Pipeline

The sieve is a 4-phase verification pipeline. Each control defines one or more "passes" that execute in order. The orchestrator stops at the first conclusive result.

DETERMINISTIC  →  PATTERN  →  LLM  →  MANUAL
     ↓               ↓          ↓        ↓
  Exact checks    Heuristics   AI     Human
  (high conf)     (med conf)   eval   review

Phase execution rules

  1. DETERMINISTIC: File existence, API calls, config lookups. Returns PASS, FAIL, or INCONCLUSIVE.
  2. PATTERN: Regex matching, content analysis. Only runs if DETERMINISTIC was INCONCLUSIVE.
  3. LLM: Asks the calling LLM to analyze evidence. Only runs if earlier phases were INCONCLUSIVE.
  4. MANUAL: Always returns INCONCLUSIVE (rendered as WARN) with verification steps. This is the fallback when no automated check can determine the result.

Key data types

All defined in packages/darnit/src/darnit/sieve/models.py:

class VerificationPhase(Enum):
    DETERMINISTIC = "deterministic"
    PATTERN = "pattern"
    LLM = "llm"
    MANUAL = "manual"

class PassOutcome(Enum):
    PASS = "pass"           # Control satisfied
    FAIL = "fail"           # Control NOT satisfied
    INCONCLUSIVE = "inconclusive"  # Cannot determine, try next pass
    ERROR = "error"         # Pass failed to execute

PassResult is what every pass returns:

@dataclass
class PassResult:
    phase: VerificationPhase
    outcome: PassOutcome
    message: str
    evidence: dict[str, Any] | None = None
    confidence: float | None = None  # 0.0-1.0, primarily for LLM pass
    details: dict[str, Any] | None = None

CheckContext is what every pass receives:

@dataclass
class CheckContext:
    owner: str                  # GitHub org/user
    repo: str                   # Repository name
    local_path: str             # Path to cloned repo
    default_branch: str         # e.g., "main"
    control_id: str             # e.g., "MS-DOC-01"
    control_metadata: dict      # From TOML definition
    gathered_evidence: dict     # Accumulated from previous passes
    project_context: dict       # From .project/project.yaml

ControlSpec (sieve version)

Controls registered with the sieve use the sieve-specific ControlSpec from darnit.sieve.models, not the one from darnit.core.plugin:

from darnit.sieve.models import ControlSpec

ControlSpec(
    control_id="MS-DOC-01",
    level=1,
    domain="DOC",
    name="ReadmeExists",
    description="Project must have a README file",
    passes=[
        DeterministicPass(file_must_exist=["README.md", "README.rst"]),
        ManualPass(verification_steps=["Check for README"]),
    ],
)

The sieve ControlSpec includes a passes field (list of pass objects) and validates that passes are in the correct phase order (DETERMINISTIC → PATTERN → LLM → MANUAL).

Reference: See packages/darnit/src/darnit/sieve/models.py for all data types and docs/architecture/framework-design.md for the authoritative specification.


5. Custom Sieve Handlers

When the built-in pass types (file_exists, exec, regex, manual) aren't enough, you can write custom sieve handlers — Python functions that plug into the confidence gradient pipeline for checking or remediation.

Authoritative API: See packages/darnit/src/darnit/sieve/handler_registry.py for the full registry, context, and result types.

Two registries — don't confuse them!

Registry Layer Purpose Access
SieveHandlerRegistry 1 & 2 Checking + remediation handlers in the sieve pipeline get_sieve_handler_registry() from darnit.sieve.handler_registry
HandlerRegistry 3 MCP tool handlers exposed to the LLM get_handler_registry() from darnit.core.handlers

This section covers SieveHandlerRegistry (Layer 1 & 2). For MCP tool handlers, see Section 8.

Handler function signature

Every sieve handler is a plain function with this signature:

from typing import Any
from darnit.sieve.handler_registry import HandlerContext, HandlerResult, HandlerResultStatus

def my_handler(config: dict[str, Any], context: HandlerContext) -> HandlerResult:
    """Check something and return a result."""
    # Your logic here
    return HandlerResult(
        status=HandlerResultStatus.PASS,
        message="Check passed",
        confidence=1.0,
        evidence={"key": "value"},
    )
  • config — a dict containing all pass-through fields from the TOML [[passes]] entry. The framework strips handler, shared, and phase before calling your function; everything else arrives as-is.
  • context — an immutable dataclass provided by the framework with everything your handler needs to do its work.

HandlerContext fields

Field Type Description
local_path str Absolute path to the repository being audited. Always populated.
owner str Repository owner (GitHub org or user). Empty string if unknown.
repo str Repository name. Empty string if unknown.
default_branch str Default branch name (e.g., "main"). Defaults to "main".
control_id str ID of the control being verified (e.g., "OSPS-AC-01.01"). Empty for data gathering.
project_context dict[str, Any] This run's detections of keys that are not user-judgment keys, .project/project.yaml mapper values, and usable context values (confirmed, or concluded in this run for auto_detect = true keys). A stored value without a matching confirmation record is not included.
gathered_evidence dict[str, Any] Evidence accumulated from earlier handlers in this control's pipeline. See Evidence propagation.
shared_cache dict[str, HandlerResult] Cache of shared handler results, keyed by the shared handler name. Shared handlers run once and cache their result for all controls that reference them.
dependency_results dict[str, Any] Results from dependency controls (keyed by control ID), available when control dependencies are declared in TOML.

HandlerResult construction

Return a HandlerResult with the appropriate status:

Status Meaning When to use Confidence
PASS Control satisfied Your handler positively verified compliance 1.0 for deterministic, 0.0–1.0 for heuristic
FAIL Control NOT satisfied Your handler positively verified non-compliance 1.0 for deterministic, 0.0–1.0 for heuristic
INCONCLUSIVE Cannot determine Your handler can't tell — let the next handler in the pipeline try None
ERROR Handler malfunction Your handler itself broke (not the control) — e.g., command not found, file unreadable None
# PASS example — deterministic check found what it needed
HandlerResult(
    status=HandlerResultStatus.PASS,
    message="License header found in 42/42 source files",
    confidence=1.0,
    evidence={"files_checked": 42, "files_passing": 42},
)

# FAIL example — deterministic check found a violation
HandlerResult(
    status=HandlerResultStatus.FAIL,
    message="License header missing in 3 source files",
    confidence=1.0,
    evidence={"files_checked": 42, "files_failing": 3, "failing_files": ["a.py", "b.py", "c.py"]},
)

# INCONCLUSIVE — can't determine, let the next handler try
HandlerResult(
    status=HandlerResultStatus.INCONCLUSIVE,
    message="No source files found to check",
    evidence={"files_checked": 0},
)

# ERROR — the handler itself failed
HandlerResult(
    status=HandlerResultStatus.ERROR,
    message="Failed to read source directory: Permission denied",
    evidence={"error": "PermissionError: /src/"},
)

The evidence dict is free-form — put whatever is useful for debugging and for downstream handlers. The details dict is for metadata that doesn't belong in evidence (e.g., consultation_request for LLM handlers).

Registering a handler

Register your handler with the SieveHandlerRegistry using a short name and phase affinity:

from darnit.sieve.handler_registry import get_sieve_handler_registry

registry = get_sieve_handler_registry()
registry.register(
    "license_header",              # Short name used in TOML
    phase="deterministic",         # Phase affinity (deterministic, pattern, llm, manual)
    handler_fn=license_header_handler,
    description="Check source files for license headers",
)

Phase affinity is advisory — the framework logs a warning if a handler is used in a different phase than its registered affinity, but still executes it. Choose the phase that best matches your handler's confidence level:

Phase Confidence Typical handlers
deterministic High (1.0) File checks, API calls, config lookups
pattern Medium (0.7–0.9) Regex matching, heuristic analysis
llm Variable (0.5–0.9) AI-assisted evaluation
manual N/A Human verification steps

Registering from a plugin

When registering handlers from within an implementation, set plugin context so the framework knows which plugin owns each handler:

from darnit.sieve.handler_registry import get_sieve_handler_registry

def register_handlers(self):
    registry = get_sieve_handler_registry()
    registry.set_plugin_context(self.name)

    registry.register("license_header", "deterministic", license_header_handler,
                       description="Check source files for license headers",
                       settings={"file_extensions", "header_pattern", "min_files"})
    registry.register("scorecard", "deterministic", scorecard_handler,
                       description="Run OpenSSF Scorecard checks")

    registry.set_plugin_context(None)  # Always clear when done

A plugin cannot replace a step type core or another plugin already registered. If you register a handler named "exec", the registration is refused, logged at WARNING, and shown by darnit list; the built-in exec is unchanged (framework-design 3.0.3).

Wiring into TOML

Reference your registered handler from a control's [[passes]] block. Every key other than the common step fields (handler, when, concludes, and the others in docs/architecture/framework-design.md section 3.0.3) is passed through to your handler's config dict. Declare those keys with settings= when you register the handler, so a misspelled key fails loading instead of being ignored:

[controls."MS-LIC-01"]
name = "LicenseHeaders"
description = "Source files must contain license headers"
tags = { level = 1, domain = "LE" }

[[controls."MS-LIC-01".passes]]
handler = "license_header"           # Matches the registered name
file_extensions = [".py", ".js"]     # → config["file_extensions"]
header_pattern = "Copyright.*2024"   # → config["header_pattern"]
min_files = 1                        # → config["min_files"]

[[controls."MS-LIC-01".passes]]
handler = "manual"
steps = ["Review source files for license headers"]

Evidence propagation

When a handler returns evidence in its HandlerResult, those key-value pairs are merged into HandlerContext.gathered_evidence for subsequent handlers in the same control's pipeline. This enables multi-pass pipelines:

# Pass 1: Find the file
[[controls."MS-SEC-01".passes]]
handler = "file_exists"
files = ["SECURITY.md", ".github/SECURITY.md"]

# Pass 2: Check the file's content (uses evidence from pass 1)
[[controls."MS-SEC-01".passes]]
handler = "security_content_checker"
file = "$FOUND_FILE"                 # Reads from gathered_evidence["found_file"]
required_sections = ["Reporting", "Contact"]

In pass 2, the handler can access the found file path via:

def security_content_checker(config, context):
    file_path = config.get("file", "")
    if file_path == "$FOUND_FILE":
        file_path = context.gathered_evidence.get("found_file", "")
    # ... check the file content

Remediation handlers

Remediation handlers use the same signature and result type as checking handlers. Feature 043 (framework-design.md 4.2) adds a plan/apply contract:

  1. Plan, do not write: the executor calls the handler with context.mode set to "plan" for a preview and "apply" for an apply. In both modes the handler returns the files it would change as FileChange entries in evidence["file_changes"] and writes nothing; the executor writes them in apply mode, skips files with uncommitted user changes, and records each write in the run manifest.
  2. PASS means "the plan is valid": return PASS with the planned changes (an action = "none" change with a reason when nothing needs changing), ERROR when the fix cannot be computed. A step that changes nothing never counts as a fix.
  3. Register plan support: register with supports_plan=True. A handler without it is not run in a preview, is labelled "cannot be previewed exactly", and runs in an apply only when the person approves its plan item digest. Files it writes itself are outside the run manifest and are never committed by the git tools.
import os

from darnit.remediation.plan import FileChange


def create_notice_file(config: dict[str, Any], context: HandlerContext) -> HandlerResult:
    """Plan a NOTICE file from the step's content; the executor writes it."""
    path = config.get("path", "NOTICE")
    if os.path.exists(os.path.join(context.local_path, path)):
        change = FileChange(path=path, action="none", reason="already_exists")
    else:
        change = FileChange(path=path, action="create", content=config.get("content", ""))
    return HandlerResult(
        status=HandlerResultStatus.PASS,
        message=f"{path}: {change.action}",
        evidence={"file_changes": [change.model_dump(mode="json")]},
    )


registry.register("create_notice_file", "deterministic", create_notice_file, supports_plan=True)

project_update integration: When a control's TOML defines on_pass.project_update, the framework automatically updates .project/project.yaml after the control passes during audit. For remediation, define [controls."X".remediation.project_update] to update the config after a successful fix. See Section 3 for TOML syntax.

Complete example: license_header handler

Here's a complete custom handler from Python function to TOML usage:

1. The handler function (src/darnit_mystandard/handlers/license_check.py):

"""Custom sieve handler: check source files for license headers."""

import os
import re
from typing import Any

from darnit.sieve.handler_registry import (
    HandlerContext,
    HandlerResult,
    HandlerResultStatus,
)


def license_header_handler(config: dict[str, Any], context: HandlerContext) -> HandlerResult:
    """Check that source files contain a license header.

    Config fields:
        file_extensions: list[str] - Extensions to check (e.g., [".py", ".js"])
        header_pattern: str - Regex pattern for the license header
        min_files: int - Minimum files that must have headers (default: 1)
    """
    extensions = config.get("file_extensions", [".py"])
    pattern = config.get("header_pattern", r"Copyright")
    min_files = config.get("min_files", 1)

    source_files = []
    for root, _dirs, files in os.walk(context.local_path):
        # Skip hidden directories and common non-source dirs
        if any(part.startswith(".") for part in root.split(os.sep)):
            continue
        for f in files:
            if any(f.endswith(ext) for ext in extensions):
                source_files.append(os.path.join(root, f))

    if not source_files:
        return HandlerResult(
            status=HandlerResultStatus.INCONCLUSIVE,
            message=f"No source files found with extensions {extensions}",
            evidence={"extensions": extensions, "files_found": 0},
        )

    passing = []
    failing = []
    for filepath in source_files:
        try:
            with open(filepath, encoding="utf-8", errors="ignore") as fh:
                # Only check first 20 lines for license header
                head = "".join(fh.readline() for _ in range(20))
            if re.search(pattern, head):
                passing.append(filepath)
            else:
                failing.append(filepath)
        except OSError:
            failing.append(filepath)

    evidence = {
        "files_checked": len(source_files),
        "files_passing": len(passing),
        "files_failing": len(failing),
        "failing_files": [os.path.relpath(f, context.local_path) for f in failing[:10]],
    }

    if len(passing) >= min_files and not failing:
        return HandlerResult(
            status=HandlerResultStatus.PASS,
            message=f"License header found in {len(passing)}/{len(source_files)} files",
            confidence=1.0,
            evidence=evidence,
        )
    elif failing:
        return HandlerResult(
            status=HandlerResultStatus.FAIL,
            message=f"License header missing in {len(failing)}/{len(source_files)} files",
            confidence=1.0,
            evidence=evidence,
        )
    else:
        return HandlerResult(
            status=HandlerResultStatus.INCONCLUSIVE,
            message=f"Only {len(passing)} files have headers (need {min_files})",
            evidence=evidence,
        )

2. Registration (in your implementation's initialization):

from darnit.sieve.handler_registry import get_sieve_handler_registry
from .handlers.license_check import license_header_handler

registry = get_sieve_handler_registry()
registry.set_plugin_context("mystandard")
registry.register("license_header", "deterministic", license_header_handler,
                   description="Check source files for license headers")
registry.set_plugin_context(None)

3. TOML wiring (in mystandard.toml):

[controls."MS-LE-01"]
name = "LicenseHeaders"
description = "All source files must contain a license header"
tags = { level = 2, domain = "LE" }

[[controls."MS-LE-01".passes]]
handler = "license_header"
file_extensions = [".py", ".js", ".ts"]
header_pattern = 'Copyright\s+\d{4}'
min_files = 1

[[controls."MS-LE-01".passes]]
handler = "manual"
steps = ["Review source files for license headers", "Verify header matches project license"]

4. Expected audit output:

Pass case:

MS-LE-01  PASS  License header found in 15/15 files  (deterministic, confidence=1.0)

Fail case:

MS-LE-01  FAIL  License header missing in 3/15 files  (deterministic, confidence=1.0)

Reference: See packages/darnit/src/darnit/sieve/builtin_handlers.py for the framework's built-in handler implementations (file_exists, exec, regex, etc.).


6. Custom Sieve Handlers

When the built-in TOML handler types (file_exists, exec, regex, manual) aren't expressive enough — for example, when you need custom API calls, complex multi-step logic, or content analysis — write a custom sieve handler in Python and reference it from TOML.

Writing a custom handler

A sieve handler is a function with signature (config: dict, context: HandlerContext) -> HandlerResult:

# src/darnit_mystandard/handlers.py
from darnit.sieve.handler_registry import (
    HandlerContext,
    HandlerResult,
    HandlerResultStatus,
)


def api_check_handler(config: dict, context: HandlerContext) -> HandlerResult:
    """Check repository visibility via GitHub API."""
    import json
    import subprocess

    result = subprocess.run(
        ["gh", "api", f"/repos/{context.owner}/{context.repo}"],
        capture_output=True, text=True, timeout=30,
    )

    if result.returncode != 0:
        return HandlerResult(
            status=HandlerResultStatus.INCONCLUSIVE,
            message="Could not reach API",
        )

    data = json.loads(result.stdout)
    if data.get("private") is False:
        return HandlerResult(
            status=HandlerResultStatus.PASS,
            message="Repository is public",
            confidence=1.0,
            evidence={"private": False},
        )
    return HandlerResult(
        status=HandlerResultStatus.FAIL,
        message="Repository is private",
        evidence={"private": True},
    )

Registering the handler

Register handlers in your implementation's register_handlers() method, the protocol hook the framework calls before it loads or audits your framework (framework-design.md 6.4):

def register_handlers(self) -> None:
    from darnit.sieve.handler_registry import get_sieve_handler_registry
    from . import handlers

    registry = get_sieve_handler_registry()
    registry.set_plugin_context(self.name)
    registry.register("api_check", "deterministic", handlers.api_check_handler,
                       "Check repository via API")
    registry.set_plugin_context(None)

Referencing from TOML

Once registered, reference the handler by short name in your framework TOML:

[[controls."MS-AC-01".passes]]
handler = "api_check"

[[controls."MS-AC-01".passes]]
handler = "manual"
steps = [
    "Check repository visibility in Settings",
    "Verify repository is not private",
]

Reference: See packages/darnit-reproducibility/src/darnit_reproducibility/handlers.py and packages/darnit-gittuf/src/darnit_gittuf/handlers.py for real custom handlers, registered in each package's implementation.py.


7. Remediation

Remediation is declared in the framework TOML, under each control's remediation table (Section 3, "Remediation in TOML"; framework-design.md section 4). The framework's remediation executor reads it from there; nothing in your implementation class returns it. The Python REMEDIATION_REGISTRY dict that earlier versions of this guide described was read only by get_remediation_registry(), which was removed in 0.2.0 (#487).

Context requirements

Some remediations need user-confirmed context before they can run. Declare them with [[controls."ID".remediation.requires_context]] (framework-design.md 7.3). A requirement is met by the key's standing (framework-design.md 7.3, 7.4): a candidate or unknown key is never ready. Independently of requires_context, a template or when clause that reads a key without a usable value stops the control with confirmation required: <key> and writes nothing (framework-design.md 7.11).

A remediation that needs logic the built-in remediation handlers cannot express is a plugin remediation handler, registered in register_handlers() like a sieve step type (Section 5). It returns planned FileChanges and never writes itself (framework-design.md 4.2).


8. MCP Tools

MCP tools are functions exposed to AI assistants (Claude, etc.) via the Model Context Protocol. There are two ways to provide them: built-in tools (TOML-only) and custom handler tools (requires Python).

Built-in MCP tools

The framework provides generic built-in tools that work with any TOML-defined framework. Reference them with builtin = "..." instead of handler = "...":

[mcp.tools.audit]
builtin = "audit"
description = "Run compliance audit"

[mcp.tools.remediate]
builtin = "remediate"
description = "Auto-fix failing controls"

[mcp.tools.list_controls]
builtin = "list_controls"
description = "List all controls by level"

Available built-in tools:

Name What it does
audit Load controls from this framework's TOML, run sieve on each, return formatted report
remediate Run audit, then preview (default) or apply the declarative remediations for failures
list_controls Return JSON list of all controls grouped by level

Built-in tools automatically receive the framework name from the [metadata] section, so they know which TOML to load. They support local_path, level, and dry_run parameters.

Use built-in tools when your framework only needs standard audit/remediate behavior. Use custom handler tools (below) when you need additional parameters, custom output formats, or specialized logic.

Custom handler tools

If your implementation provides custom MCP tools, register them through the handler system.

The register_handlers() method

This is an optional method on your implementation class. The framework checks for it with hasattr() before calling:

# In implementation.py

def register_handlers(self) -> None:
    """Register MCP tool handlers."""
    from darnit.core.handlers import get_handler_registry
    from . import tools

    registry = get_handler_registry()
    registry.set_plugin_context(self.name)

    # Register each handler by short name
    registry.register_handler("audit_mystandard", tools.audit_mystandard)
    registry.register_handler("list_checks", tools.list_checks)

    # Clear plugin context when done
    registry.set_plugin_context(None)

How it works

  1. set_plugin_context(self.name) — tells the registry which plugin is registering handlers (for audit trails)
  2. register_handler("name", func) — registers the function under a short name
  3. set_plugin_context(None) — clears the context

Handlers can then be referenced in TOML by short name:

[mcp.tools.audit_mystandard]
handler = "audit_mystandard"

Or by full module path:

[mcp.tools.audit_mystandard]
handler = "darnit_mystandard.tools:audit_mystandard"

Module path policy

When a handler is referenced by module:function path in TOML, darnit imports it only if the module's top-level package is darnit or the package of an installed implementation, read from the darnit.implementations entry points. If your entry point is mystandard = "darnit_mystandard:register", any darnit_mystandard.* module may be named; nothing needs to be added to darnit. Any other path is refused with HandlerImportRefused, and an MCP tool that names one does not load (framework-design.md 6.5). Short names (via register_handler()) need no import at all and remain the recommended form.

Reference: See packages/darnit-baseline/src/darnit_baseline/implementation.py:92 for the OpenSSF Baseline's register_handlers() method and packages/darnit/src/darnit/core/handlers.py for the full handler registry.


9. Testing

Protocol compliance tests

Verify your implementation satisfies the ComplianceImplementation protocol:

# tests/test_mystandard/test_implementation.py
from darnit.core.plugin import ComplianceImplementation
from darnit_mystandard.implementation import MyStandardImplementation


def test_implements_protocol():
    impl = MyStandardImplementation()
    assert isinstance(impl, ComplianceImplementation)


def test_properties():
    impl = MyStandardImplementation()
    assert impl.name == "mystandard"
    assert impl.display_name == "My Compliance Standard"
    assert impl.version == "0.1.0"
    assert impl.spec_version == "MySpec v1.0"


def test_get_framework_config_path():
    impl = MyStandardImplementation()
    path = impl.get_framework_config_path()
    assert path is not None
    assert path.name == "mystandard.toml"
    # Verify the file actually exists at this path
    assert path.exists(), f"TOML not found at {path}"

Control unit tests

Test individual controls by mocking external dependencies:

# tests/test_mystandard/test_controls.py
from darnit.sieve.models import CheckContext, PassOutcome


def _make_context(tmp_path, **kwargs):
    """Create a CheckContext for testing."""
    return CheckContext(
        owner="test-owner",
        repo="test-repo",
        local_path=str(tmp_path),
        default_branch="main",
        control_id="MS-DOC-01",
        **kwargs,
    )


def test_readme_exists_pass(tmp_path):
    """README check passes when README.md exists."""
    (tmp_path / "README.md").write_text("# My Project")

    from darnit.sieve.handler_registry import HandlerContext, HandlerResultStatus
    from darnit_mystandard.handlers import readme_check_handler

    ctx = HandlerContext(local_path=str(tmp_path))
    result = readme_check_handler({}, ctx)

    assert result.status == HandlerResultStatus.PASS


def test_readme_exists_fail(tmp_path):
    """README check fails when no README exists."""
    from darnit.sieve.handler_registry import HandlerContext, HandlerResultStatus
    from darnit_mystandard.handlers import readme_check_handler

    ctx = HandlerContext(local_path=str(tmp_path))
    result = readme_check_handler({}, ctx)

    assert result.status == HandlerResultStatus.FAIL

Testing API-based handlers

Mock the subprocess calls to avoid hitting real APIs:

from unittest.mock import patch
import json


def test_api_check_pass(tmp_path):
    """API handler passes when API returns expected data."""
    mock_response = json.dumps({"private": False})

    with patch("subprocess.run") as mock_run:
        mock_run.return_value.returncode = 0
        mock_run.return_value.stdout = mock_response

        from darnit.sieve.handler_registry import HandlerContext, HandlerResultStatus
        from darnit_mystandard.handlers import api_check_handler

        ctx = HandlerContext(local_path=str(tmp_path))
        result = api_check_handler({}, ctx)

        assert result.status == HandlerResultStatus.PASS

Integration tests

Test the full audit pipeline end-to-end:

def test_full_audit(tmp_path):
    """Run a complete audit against a test repository."""
    # Set up minimal repo structure
    (tmp_path / "README.md").write_text("# Test")
    (tmp_path / "LICENSE").write_text("MIT License")
    (tmp_path / "SECURITY.md").write_text("Report to security@test.com")

    from darnit.config import load_controls_from_framework
    from darnit.config.merger import load_framework_config
    from darnit_mystandard.implementation import MyStandardImplementation
    impl = MyStandardImplementation()

    # Load the controls from the framework TOML and verify they loaded
    config = load_framework_config(impl.get_framework_config_path())
    controls = load_controls_from_framework(config)
    assert len(controls) > 0

Test organization

tests/
├── test_mystandard/
│   ├── __init__.py
│   ├── test_implementation.py   # Protocol compliance
│   ├── test_controls.py         # Individual control logic
│   ├── test_remediation.py      # Remediation actions
│   └── conftest.py              # Shared fixtures
└── integration/
    └── test_full_audit.py       # End-to-end (may need network)

Reference: See tests/darnit_baseline/ for the OpenSSF Baseline test suite.


10. Common Pitfalls

Handler function signature

Problem: Writing a handler that doesn't match the expected signature (config: dict, context: HandlerContext) -> HandlerResult.

# WRONG: Missing config parameter
def my_handler(context):
    ...

# WRONG: Returning a plain dict instead of HandlerResult
def my_handler(config, context):
    return {"status": "pass"}

# CORRECT: Full signature with HandlerResult return type
def my_handler(config: dict, context: HandlerContext) -> HandlerResult:
    return HandlerResult(
        status=HandlerResultStatus.PASS,
        message="Check passed",
        confidence=1.0,
    )

Module path policy for dynamic loading

A module:function handler path must name a module in darnit or in your own implementation's package (the module of your darnit.implementations entry point). A path into any other package is refused at load time. Prefer short names via register_handler().

TOML path resolution

get_framework_config_path() returns a path relative to implementation.py. Count the parent traversals carefully:

# From: src/darnit_mystandard/implementation.py
# To:   mystandard.toml (at package root)
Path(__file__).parent  # → src/darnit_mystandard/
             .parent   # → src/
             .parent   # → darnit-mystandard/
             / "mystandard.toml"

If this path is wrong, the framework silently falls back to an empty config. Add an assertion in your tests to catch this early.

Entry point naming conventions

The entry point name in pyproject.toml becomes the key used to look up your implementation:

[project.entry-points."darnit.implementations"]
mystandard = "darnit_mystandard:register"
#  ↑ This name must match impl.name

If the entry point name doesn't match impl.name, discovery will still work but the implementation will be stored under impl.name, not the entry point name. Keep them consistent.

Control registration is global

register_control() writes to a global registry. If multiple implementations register a control with the same ID, only the first one wins (duplicates are skipped). If you need to override, use the overwrite=True parameter:

from darnit.sieve.registry import get_control_registry
registry = get_control_registry()
registry.register(my_spec, overwrite=True)

Pass ordering validation

The sieve ControlSpec validates that passes are in phase order (DETERMINISTIC → PATTERN → LLM → MANUAL). If passes are out of order, a warning is emitted. While not an error, out-of-order passes may produce unexpected results since the orchestrator assumes the order.


11. Quick Reference

ComplianceImplementation protocol

Properties:  name, display_name, version, spec_version
Methods:     get_framework_config_path()
Optional:    register_handlers()

Handler type cheat sheet

Need TOML Handler Notes
File exists? handler = "file_exists" + files = [...] PASS only with existence = true
CLI/API check? handler = "exec" + command = [...] + expr = "..." CEL expression on output
Regex in file? handler = "pattern" + files = [...] + pattern = "..." Also accepts regex alias
AI analysis? handler = "llm_eval" + confidence_threshold = 0.8 Requires LLM consultation
Human steps? handler = "manual" + steps = [...] Also accepts manual_steps alias
Custom logic? handler = "my_handler" Register via SieveHandlerRegistry (Section 6)

Key imports

# Sieve handler authoring (Section 5-6)
from darnit.sieve.handler_registry import (
    HandlerContext, HandlerResult, HandlerResultStatus,
    get_sieve_handler_registry,
)

# Models
from darnit.sieve.models import (
    CheckContext, ControlSpec, PassOutcome, PassResult, VerificationPhase,
)

# Control registration
from darnit.sieve.registry import register_control

# Protocol (for isinstance checks)
from darnit.core.plugin import ComplianceImplementation, ControlSpec as PluginControlSpec

# MCP tool handler registration (Section 8)
from darnit.core.handlers import get_handler_registry

Key file paths

What Path
Protocol definition packages/darnit/src/darnit/core/plugin.py
Plugin discovery packages/darnit/src/darnit/core/discovery.py
Sieve models packages/darnit/src/darnit/sieve/models.py
Control registry packages/darnit/src/darnit/sieve/registry.py
Sieve handler registry packages/darnit/src/darnit/sieve/handler_registry.py
Built-in sieve handlers packages/darnit/src/darnit/sieve/builtin_handlers.py
MCP tool handler registry packages/darnit/src/darnit/core/handlers.py
Reference implementation packages/darnit-baseline/src/darnit_baseline/implementation.py
Reference TOML packages/darnit-baseline/src/darnit_baseline/openssf-baseline.toml
Plugin template packages/darnit-hello/
Plugin with custom step types packages/darnit-reproducibility/src/darnit_reproducibility/implementation.py
Custom step type tests tests/darnit_reproducibility/test_handlers.py
Framework spec docs/architecture/framework-design.md
Composition resolver packages/darnit/src/darnit/core/composition.py
Composition spec specs/013-plugin-composition/spec.md
Composition TOML schema contract specs/013-plugin-composition/contracts/toml-schema.md

12. Composition (assembling implementations from other implementations)

darnit supports composing a new implementation as a curated mix of slices from already-installed implementations, all expressed in TOML. Think "OpenSSF Baseline levels 1+2, plus three named SLSA controls, plus four internal controls of our own" — without forking the upstream sources and without writing any composition-aware Python code.

See specs/013-plugin-composition/quickstart.md for a full walkthrough. The short version:

When to compose vs fork

  • Use composition when your posture is "the upstream framework plus a few named slices and overrides." Composition keeps you upgrade-current on the upstream's pass logic and remediation — upstream changes flow through automatically on next install.
  • Use a fork only if you need to fundamentally re-author a source's pass logic, restructure its control IDs, or maintain semantically divergent behavior. Forking is heavyweight; composition is the additive default.

The three opt-out / escape mechanisms

When two [[compose]] blocks contribute a control with the same ID, the framework's default behavior is to refuse registration with a clear CompositionConflictError. The composite author can resolve the conflict in one of three ways:

Mechanism Effect When to reach for it
Strict (default) Registration fails; the error names both sources and the conflicting ID The conservative default. If you don't know which source you want, you don't have a posture yet — fix that first.
allow_conflicts = true at the composition root Later [[compose]] block in TOML file order wins; INFO log emitted naming both sources Use sparingly. The log line is your audit trail.
[overrides."ID"] block Override fields layer onto the earliest compose block's contribution; mode-independent (works in strict AND under allow_conflicts) When you've made an explicit decision about which source's behavior wins. The override block IS your per-control acknowledgement.

Provenance traces to the originating implementation

Every control in a composite's resolved set carries two framework-stamped tags inside the existing ControlConfig.tags:

Tag key Meaning
_composed_from The slug of the ultimate non-composite source the control originated in. For recursive composition (composite-of-composite), this is the original leaf source, not the intermediate composite.
_original_control_id The control's ID in the originating non-composite source

Audit results, list-controls output, SARIF formatters — anything that already serializes tags — inherits provenance for free. No consumer-side schema change required.

What composition is NOT

  • Not a registry / network fetch primitive. Composites pull from locally-installed implementations only. Cross-host composition is explicitly out of scope.
  • Not a partial-pass-override primitive. The sieve passes array can be wholesale-replaced via an [overrides."ID"] block, but you cannot edit individual passes inside it — that's a fork case.
  • Not a hot-reload primitive. Resolution happens once at framework registration. Editing a composite's TOML at runtime requires restarting the host process.

Critical TOML scoping caveat

allow_conflicts is a root-level scalar. In TOML, bare key/value pairs are scoped to the most recently opened [table] header — so placing allow_conflicts = true after [metadata] would silently land it inside [metadata] and have no effect. Put it BEFORE any [table] header. This is the most common composition footgun.