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
16 changes: 11 additions & 5 deletions packages/darnit/src/darnit/context/auto_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ def detect_ci_provider(local_path: str) -> str | None:
# For directories (e.g. .github/workflows), check it has files
if os.path.isdir(full_path):
try:
entries = os.listdir(full_path)
# sorted() so a repo with multiple workflow files always
# scans them in the same order across runs. Determinism
# Tier 1 (#418).
entries = sorted(os.listdir(full_path))
if any(
e.endswith((".yml", ".yaml")) for e in entries
):
Expand Down Expand Up @@ -305,10 +308,13 @@ def detect_has_subprojects(local_path: str) -> bool | None:
d = p / dirname
if d.is_dir():
try:
children = [
c for c in d.iterdir()
if c.is_dir() and not c.name.startswith(".")
]
# sorted() so iteration order is stable across runs even if
# a future change replaces the len() check with slice /
# first-match semantics. Determinism Tier 1 (#418).
children = sorted(
(c for c in d.iterdir() if c.is_dir() and not c.name.startswith(".")),
key=lambda c: c.name,
)
if len(children) >= 2:
return True
except OSError:
Expand Down
27 changes: 21 additions & 6 deletions packages/darnit/src/darnit/remediation/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from collections.abc import Callable

from jinja2 import Environment

from darnit.config.framework_schema import (
Expand Down Expand Up @@ -107,8 +109,9 @@ class RemediationExecutor:
- << REPO >> - Repository name
- << BRANCH >> - Default branch
- << PATH >> - Local repository path
- << YEAR >> - Current year
- << DATE >> - Current date (ISO format)
- << YEAR >> - Current year (used by LICENSE templates; year-per-year
cadence is slow enough that PR diffs stay stable within a calendar
year -- see Determinism Tier 1 note in _get_template_context)
- << CONTROL >> - Control ID being remediated
- << context.KEY >> - Confirmed project context values
- << project.KEY >> - Values from .project/project.yaml
Expand All @@ -127,6 +130,7 @@ def __init__(
project_values: dict[str, Any] | None = None,
scan_values: dict[str, Any] | None = None,
framework_path: str | None = None,
now_provider: Callable[[], datetime] | None = None,
):
"""Initialize the executor.

Expand All @@ -144,6 +148,10 @@ def __init__(
framework_path: Absolute path to the framework TOML file.
Template ``file`` references are resolved relative to this
file's directory. Falls back to ``local_path`` when None.
now_provider: Optional callable returning the "current" datetime,
used to derive ``<< YEAR >>`` in template output. Defaults
to :func:`datetime.now`. Parameterized so tests can inject
a fixed clock (Determinism Tier 1, #418).
"""
self.local_path = os.path.abspath(local_path)
self.templates = templates or {}
Expand All @@ -152,6 +160,7 @@ def __init__(
self._context_values = context_values or {}
self._project_values = project_values or {}
self._scan_values = scan_values or {}
self._now_provider = now_provider or datetime.now

# Auto-detect owner/repo if not provided
if not owner or not repo:
Expand All @@ -175,25 +184,31 @@ def _get_template_context(self, control_id: str) -> dict[str, Any]:
Jinja2 templates access these as e.g. ``<< REPO >>`` or
``<< context.maintainers >>``.
"""
now = datetime.now()
# YEAR is derived from now_provider (test-injectable). DATE was
# dropped: no template referenced it, and its day-per-day drift
# was cluttering PR diffs for identical inputs run on different
# days. Determinism Tier 1 (#418).
now = self._now_provider()
ctx: dict[str, Any] = {
"OWNER": self.owner or "",
"REPO": self.repo or "",
"BRANCH": self.default_branch,
"PATH": self.local_path,
"YEAR": str(now.year),
"DATE": now.strftime("%Y-%m-%d"),
"CONTROL": control_id,
}

# Build nested context/project/scan namespaces
# Build nested context/project/scan namespaces. List values are
# sorted before joining so upstream ordering (dict iteration,
# API-response order) does not drift the rendered output across
# runs. Determinism Tier 1 (#418).
context_ns: dict[str, str] = {}
if self._context_values:
for key, value in self._context_values.items():
if isinstance(value, str):
context_ns[key] = value
elif isinstance(value, list):
context_ns[key] = " ".join(str(v) for v in value)
context_ns[key] = " ".join(sorted(str(v) for v in value))
elif value is not None:
context_ns[key] = str(value)
ctx["context"] = context_ns
Expand Down
50 changes: 40 additions & 10 deletions packages/darnit/src/darnit/sieve/builtin_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import os
import re
import subprocess
import tempfile
from typing import Any

from .handler_registry import (
Expand All @@ -38,6 +39,29 @@
# =============================================================================

MCP_DEFAULT_TIMEOUT_SECONDS: float = 60.0


def _atomic_write_text(path: str, content: str) -> None:
"""Write ``content`` to ``path`` atomically via tempfile-then-rename.

Determinism Tier 1 (#418): direct ``open(path, "w").write(content)``
leaves a partial file behind if the process crashes or the disk fills
mid-write. Tempfile in the same directory + ``os.replace`` gives us
the same "either fully written or absent" invariant that
:class:`FilesystemAuditCacheStore` uses (feature 033).
"""
directory = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".darnit-write-", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
os.replace(tmp, path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
"""Per-call timeout for `handler = "mcp"` passes when the pass omits `timeout`.

Spec FR-002 (clarified 2026-08-16). Individual passes MAY override via
Expand Down Expand Up @@ -95,8 +119,10 @@ def _walk_depth_limited(root: str, max_depth: int):
return
for dirpath, dirnames, _files in os.walk(root_abs):
depth = dirpath[len(root_abs) :].count(os.sep)
# Prune in-place so os.walk skips them (matches os.walk's contract)
dirnames[:] = [d for d in dirnames if d not in _FILE_DISCOVERY_PRUNE_DIRS]
# Prune in-place so os.walk skips them (matches os.walk's contract).
# Sort so os.walk visits subdirs deterministically -- "first match
# wins" semantics downstream depend on this. Determinism Tier 1 (#418).
dirnames[:] = sorted(d for d in dirnames if d not in _FILE_DISCOVERY_PRUNE_DIRS)
if depth >= max_depth:
# Don't descend further; stop yielding deeper dirs
dirnames.clear()
Expand Down Expand Up @@ -132,7 +158,9 @@ def file_exists_handler(config: dict[str, Any], context: HandlerContext) -> Hand
if "*" in pattern:
import glob

matches = glob.glob(os.path.join(context.local_path, pattern))
# sorted() so "first match wins" is stable across filesystems.
# Determinism Tier 1 (#418).
matches = sorted(glob.glob(os.path.join(context.local_path, pattern)))
if matches:
found = matches[0]
rel_path = os.path.relpath(found, context.local_path)
Expand Down Expand Up @@ -449,9 +477,13 @@ def _resolve_regex_files(
for file_pattern in files_list:
if "*" in file_pattern or "?" in file_pattern:
# Glob patterns: always use glob.glob; max_depth does not apply.
matches = globmod.glob(
os.path.join(context.local_path, file_pattern),
recursive=True,
# sorted() so downstream ordering is stable across filesystems.
# Determinism Tier 1 (#418).
matches = sorted(
globmod.glob(
os.path.join(context.local_path, file_pattern),
recursive=True,
)
)
resolved.extend(m for m in matches if os.path.isfile(m))
elif max_depth > 0:
Expand Down Expand Up @@ -834,8 +866,7 @@ def file_create_handler(config: dict[str, Any], context: HandlerContext) -> Hand

try:
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w", encoding="utf-8") as f:
f.write(content)
_atomic_write_text(full_path, content)
except OSError as e:
return HandlerResult(
status=HandlerResultStatus.ERROR,
Expand Down Expand Up @@ -980,8 +1011,7 @@ def yaml_inject_handler(config: dict[str, Any], context: HandlerContext) -> Hand
lines.insert(insert_idx, injection.rstrip())

try:
with open(filepath, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
_atomic_write_text(filepath, "\n".join(lines))
modified.append(os.path.relpath(filepath, context.local_path))
except OSError:
continue
Expand Down
Loading
Loading