Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/HANDLER_AUTHORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,39 @@ These files are the best companions while authoring handlers:
- `packages/darnit-example/src/darnit_example/implementation.py`
- `packages/darnit/src/darnit/sieve/builtin_handlers.py`
- `packages/darnit/src/darnit/sieve/handler_registry.py`

## Shared Execution Context

For large tools or API calls that provide data for multiple controls (like OpenSSF Scorecard or an external API), you can use the `ExecutionContext` to ensure the tool is only executed once per audit run.

The `ExecutionContext` is exposed via `handler_ctx.execution_context` natively on custom Python handlers and provides thread-safe caching.

### Using `get_or_run_tool`

Inside a custom Python handler:

```python
from darnit.sieve.handler_registry import HandlerContext, HandlerResult, HandlerResultStatus

def scorecard_handler(config: dict[str, Any], handler_ctx: HandlerContext) -> HandlerResult:
ctx = handler_ctx.execution_context
if not ctx:
return HandlerResult(
status=HandlerResultStatus.ERROR,
message="No execution context available"
)

def run_scorecard():
# Expensive blocking call
return {"data": "some_expensive_computation"}

# get_or_run_tool is thread-safe and will only execute run_scorecard once per `tool_key`
scorecard_data = ctx.get_or_run_tool("scorecard", run_scorecard)

# Process the scorecard data for this specific control
return HandlerResult(
status=HandlerResultStatus.PASS,
message="Scorecard passed",
evidence=scorecard_data
)
```
133 changes: 82 additions & 51 deletions packages/darnit/src/darnit/core/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Core data models for the baseline MCP server."""

import threading
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING, Any
Expand All @@ -10,6 +12,7 @@

class CheckStatus(Enum):
"""Status of a control check."""

PASS = "pass"
FAIL = "fail"
WARN = "warn"
Expand All @@ -20,6 +23,7 @@ class CheckStatus(Enum):
@dataclass
class CheckResult:
"""Result of a single control check."""

control_id: str
status: CheckStatus
message: str
Expand All @@ -42,6 +46,7 @@ def to_dict(self) -> dict[str, Any]:
@dataclass
class RemediationResult:
"""Result of a remediation action."""

control_id: str
success: bool
message: str
Expand All @@ -54,65 +59,91 @@ class RemediationResult:
@dataclass
class AdapterCapability:
"""Describes what controls an adapter can handle."""

control_ids: set[str] # Specific control IDs, or {"*"} for all
supports_batch: bool = False # Can handle multiple controls in one call
batch_command: str | None = None # Command for batch mode
# TODO: Add cache_key for shared execution context
# cache_key: Optional[str] = None # Key for caching tool output (e.g., "scorecard")


# TODO: Shared Execution Context (Future Enhancement)
# Add ExecutionContext class for sharing tool outputs across controls.
# This enables tools like OpenSSF Scorecard to run once and provide
# results for multiple controls.
#
# @dataclass
# class ExecutionContext:
# """Shared context for an audit run, enabling result caching across controls.
#
# Example usage:
# context = ExecutionContext(owner="org", repo="repo", local_path="/path")
#
# # Adapter caches its output
# scorecard_data = context.get_or_run_tool(
# "scorecard",
# lambda: run_scorecard(context.local_path)
# )
#
# # Extract specific control result
# return extract_branch_protection_result(scorecard_data)
# """
# owner: str
# repo: str
# local_path: str
#
# # Cached tool outputs (scorecard JSON, trivy results, etc.)
# tool_outputs: Dict[str, Any] = field(default_factory=dict)
#
# # Cached GitHub API responses
# api_responses: Dict[str, Any] = field(default_factory=dict)
#
# # Already-computed check results
# cached_results: Dict[str, CheckResult] = field(default_factory=dict)
#
# def get_or_run_tool(self, tool_key: str, run_func: Callable) -> Any:
# """Get cached tool output or run the tool and cache result."""
# if tool_key not in self.tool_outputs:
# self.tool_outputs[tool_key] = run_func()
# return self.tool_outputs[tool_key]
#
# def get_cached_result(self, control_id: str) -> Optional[CheckResult]:
# """Get a previously cached check result."""
# return self.cached_results.get(control_id)
#
# def cache_result(self, result: CheckResult) -> None:
# """Cache a check result for later retrieval."""
# self.cached_results[result.control_id] = result
cache_key: str | None = None # Key for caching tool output (e.g., "scorecard")


@dataclass
class ExecutionContext:
"""Shared context for an audit run, enabling result caching across controls.

This context is thread-safe to support concurrent tool evaluations.

Example usage:
context = ExecutionContext(owner="org", repo="repo", local_path="/path")

# Adapter caches its output
scorecard_data = context.get_or_run_tool(
"scorecard",
lambda: run_scorecard(context.local_path)
)

# Extract specific control result
return extract_branch_protection_result(scorecard_data)
"""

owner: str
repo: str
local_path: str

# Cached tool outputs (scorecard JSON, trivy results, etc.)
tool_outputs: dict[str, Any] = field(default_factory=dict)

# Cached GitHub API responses
api_responses: dict[str, Any] = field(default_factory=dict)

# Already-computed check results
cached_results: dict[str, CheckResult] = field(default_factory=dict)

# Threading locks
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
_tool_locks: dict[str, threading.Lock] = field(default_factory=dict, init=False, repr=False)

def get_or_run_tool(self, tool_key: str, run_func: Callable[[], Any]) -> Any:
"""Get cached tool output or run the tool and cache result.

Uses fine-grained locking to prevent redundant concurrent executions
of the same tool while allowing different tools to run in parallel.
"""
# Fast path check
with self._lock:
if tool_key in self.tool_outputs:
return self.tool_outputs[tool_key]

# Get or create a lock specific to this tool
tool_lock = self._tool_locks.setdefault(tool_key, threading.Lock())

with tool_lock:
# Check again while inside the tool lock
if tool_key in self.tool_outputs:
return self.tool_outputs[tool_key]

# Actually run the tool
result = run_func()

with self._lock:
self.tool_outputs[tool_key] = result

return result

def get_cached_result(self, control_id: str) -> CheckResult | None:
"""Get a previously cached check result."""
with self._lock:
return self.cached_results.get(control_id)

def cache_result(self, result: CheckResult) -> None:
"""Cache a check result for later retrieval."""
with self._lock:
self.cached_results[result.control_id] = result


@dataclass
class AuditResult:
"""Complete result structure for baseline audit."""

owner: str
repo: str
local_path: str
Expand Down
3 changes: 3 additions & 0 deletions packages/darnit/src/darnit/sieve/handler_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ class HandlerContext:
gathered_evidence: Evidence accumulated from previous handlers in this control.
shared_cache: Cache for shared handler results (keyed by shared handler name).
dependency_results: Results from dependency controls (keyed by control ID).
execution_context: Shared context instance across the entire audit run.
"""

local_path: str
Expand All @@ -100,6 +101,7 @@ class HandlerContext:
gathered_evidence: dict[str, Any] = field(default_factory=dict)
shared_cache: dict[str, HandlerResult] = field(default_factory=dict)
dependency_results: dict[str, Any] = field(default_factory=dict)
execution_context: Any | None = None


# Handler callable signature: (config, context) -> HandlerResult
Expand Down Expand Up @@ -254,6 +256,7 @@ def get_sieve_handler_registry() -> SieveHandlerRegistry:
_sieve_handler_registry = SieveHandlerRegistry()
# Auto-register builtin handlers
from darnit.sieve.builtin_handlers import register_builtin_handlers

register_builtin_handlers()
return _sieve_handler_registry

Expand Down
4 changes: 4 additions & 0 deletions packages/darnit/src/darnit/sieve/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

if TYPE_CHECKING:
from darnit.config.framework_schema import LocatorConfig
from darnit.core.models import ExecutionContext
from darnit.locate import UnifiedLocator


Expand Down Expand Up @@ -50,6 +51,9 @@ class CheckContext:
# Contains flattened project metadata like project.security.policy_path, project.maintainers
project_context: dict[str, Any] = field(default_factory=dict)

# Shared execution state across all controls
execution_context: Optional["ExecutionContext"] = None


@dataclass
class PassResult:
Expand Down
54 changes: 18 additions & 36 deletions packages/darnit/src/darnit/sieve/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,16 @@ def evaluate_when_clause(when: dict[str, Any], context: dict[str, Any]) -> bool:
if actual is None:
# Missing context key → run normally (conservative)
logger.debug(
"when key '%s' missing from context, running normally", key,
"when key '%s' missing from context, running normally",
key,
)
continue
if actual != expected:
logger.debug(
"when condition failed (%s=%r, expected %r)", key, actual, expected,
"when condition failed (%s=%r, expected %r)",
key,
actual,
expected,
)
return False
return True
Expand Down Expand Up @@ -156,9 +160,7 @@ def reset_caches(self) -> None:
self._shared_cache.clear()
self._dependency_results.clear()

def _evaluate_when(
self, control_spec: ControlSpec, context: CheckContext
) -> bool:
def _evaluate_when(self, control_spec: ControlSpec, context: CheckContext) -> bool:
"""Evaluate when clause for conditional applicability.

Returns True if the control should run, False if N/A.
Expand All @@ -172,9 +174,7 @@ def _evaluate_when(
merged = {**context.control_metadata, **context.project_context}
return evaluate_when_clause(when, merged)

def _check_inferred_from(
self, control_spec: ControlSpec
) -> SieveResult | None:
def _check_inferred_from(self, control_spec: ControlSpec) -> SieveResult | None:
"""Check if this control can be auto-passed via inferred_from.

If the referenced control PASSED, return an auto-PASS result.
Expand Down Expand Up @@ -228,19 +228,16 @@ def _dispatch_handler_invocations(
project_context=dict(context.project_context),
gathered_evidence=dict(context.gathered_evidence),
shared_cache=self._shared_cache,
dependency_results={
cid: r.status for cid, r in self._dependency_results.items()
},
dependency_results={cid: r.status for cid, r in self._dependency_results.items()},
execution_context=context.execution_context,
)

# Assemble flat context for when-clause evaluation
when_context = dict(handler_ctx.project_context)

for pass_index, invocation in enumerate(handler_invocations):
# Evaluate when clause — skip handler if condition not met
if invocation.when and not evaluate_when(
invocation.when, when_context
):
if invocation.when and not evaluate_when(invocation.when, when_context):
logger.debug(
"Control %s: handler '%s' skipped (when clause not met)",
control_spec.control_id,
Expand Down Expand Up @@ -315,9 +312,7 @@ def _dispatch_handler_invocations(
pass_history.append(
PassAttempt(
phase=phase,
checks_performed=[
f"handler:{invocation.handler}"
],
checks_performed=[f"handler:{invocation.handler}"],
result=pass_result,
duration_ms=duration_ms,
)
Expand Down Expand Up @@ -391,9 +386,7 @@ def _dispatch_handler_invocations(
pass_history=pass_history,
evidence={
**accumulated_evidence,
"llm_consultation": handler_result.details[
"consultation_request"
],
"llm_consultation": handler_result.details["consultation_request"],
},
source="sieve",
)
Expand Down Expand Up @@ -498,9 +491,7 @@ def verify_with_llm_response(
for inv in handler_invocations:
if inv.handler == "llm_eval":
extra = inv.model_extra or {}
confidence_threshold = extra.get(
"confidence_threshold", 0.8
)
confidence_threshold = extra.get("confidence_threshold", 0.8)
break

# Determine outcome based on confidence
Expand Down Expand Up @@ -586,11 +577,7 @@ def verify_batch(
result_map[spec.control_id] = result

# Return in original order
return [
result_map[spec.control_id]
for spec in control_specs
if spec.control_id in result_map
]
return [result_map[spec.control_id] for spec in control_specs if spec.control_id in result_map]

def _apply_on_pass(
self,
Expand Down Expand Up @@ -629,7 +616,7 @@ def _apply_on_pass(
resolved: dict[str, Any] = {}
for key, value in updates.items():
if isinstance(value, str) and value.startswith("$EVIDENCE."):
evidence_key = value[len("$EVIDENCE."):]
evidence_key = value[len("$EVIDENCE.") :]
resolved[key] = evidence.get(evidence_key, value)
else:
resolved[key] = value
Expand All @@ -647,16 +634,11 @@ def _apply_on_pass(

config = ProjectUpdateRemediationConfig(set=resolved)
apply_project_update(local_path, config, control_spec.control_id)
logger.debug(
f"Applied on_pass for {control_spec.control_id}: "
f"set {len(resolved)} values"
)
logger.debug(f"Applied on_pass for {control_spec.control_id}: set {len(resolved)} values")
except ImportError:
logger.debug("Remediation executor not available for on_pass")
except Exception as e:
logger.warning(
f"Failed to apply on_pass for {control_spec.control_id}: {e}"
)
logger.warning(f"Failed to apply on_pass for {control_spec.control_id}: {e}")


# =============================================================================
Expand Down
Loading
Loading