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
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"feature_directory": "specs/035-audit-cache-store-migration"}
{"feature_directory": "specs/036-tier2-error-class"}
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,5 +381,5 @@ else:
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan:
[`specs/035-audit-cache-store-migration/plan.md`](specs/035-audit-cache-store-migration/plan.md)
[`specs/036-tier2-error-class/plan.md`](specs/036-tier2-error-class/plan.md)
<!-- SPECKIT END -->
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ def build_assessment_predicate(
# producer emits authority for every result it generates.
if r.get("authority") is not None:
control["authority"] = r["authority"]
# Feature 036: additive `error_class` field, present only when the
# resolving pass could not run to completion. Additive within the v1
# predicate schema -- no version bump, same treatment `authority`
# got above. A signed attestation carrying a bare verdict when the
# underlying check never reached the network is exactly the
# misleading claim Constitution Principle II forbids.
if r.get("error_class") is not None:
control["error_class"] = r["error_class"]
controls.append(control)

# Build configuration section
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,13 @@ def result_to_sarif_result(
if pass_history:
sarif_result["properties"]["passHistory"] = pass_history

# Feature 036: environmental failure cause, when the resolving pass could
# not run to completion. Lets a code-scanning consumer separate "fix the
# runner" from "fix the repo".
error_class = result.get("error_class")
if error_class is not None:
sarif_result["properties"]["errorClass"] = error_class

return sarif_result


Expand Down
30 changes: 21 additions & 9 deletions packages/darnit-baseline/src/darnit_baseline/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import json
from pathlib import Path
from typing import Any

# OSPS control-ID-to-tool mapping for audit report remediation suggestions.
# This keeps all OSPS-specific knowledge in the implementation package.
Expand All @@ -35,6 +36,25 @@
}


def _compact_result(r: dict[str, Any]) -> dict[str, Any]:
"""Reduce one check result to the `summary` output shape.

Drops evidence and pass_history (~5-8K vs ~164K for 62 controls) but
keeps ``error_class`` when present. Feature 036: a summary that hides
"we could not verify" is worse than no summary -- a consumer would
read an unreachable network as a real compliance failure.
"""
compact: dict[str, Any] = {
"id": r.get("id"),
"status": r.get("status"),
"level": r.get("level"),
"details": r.get("details", ""),
}
if r.get("error_class") is not None:
compact["error_class"] = r["error_class"]
return compact


def _build_audit_result(
owner: str,
repo: str,
Expand Down Expand Up @@ -208,15 +228,7 @@ def audit_openssf_baseline(
# ~5-8K vs ~164K for full JSON with 62 controls.
from darnit.tools.audit import framework_metadata

compact_results = [
{
"id": r.get("id"),
"status": r.get("status"),
"level": r.get("level"),
"details": r.get("details", ""),
}
for r in results
]
compact_results = [_compact_result(r) for r in results]
output = json.dumps({
"metadata": framework_metadata("openssf-baseline"),
"owner": owner,
Expand Down
8 changes: 7 additions & 1 deletion packages/darnit/src/darnit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,13 @@ def format_result_text(result: dict) -> str:
}
icon = status_icons.get(status, "?")

return f" {icon} {control_id}: {status} - {details}"
# Feature 036: annotate environmental failures so triage from this
# output alone is possible -- "[auth]" means fix the token, an
# unannotated FAIL means fix the repo.
error_class = result.get("error_class")
ec_tag = f" [{error_class}]" if error_class else ""

return f" {icon} {control_id}: {status}{ec_tag} - {details}"


def format_results_text(results: list[CheckResult], framework_name: str, show_all: bool = False) -> str:
Expand Down
31 changes: 28 additions & 3 deletions packages/darnit/src/darnit/context/auto_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,15 @@ def collect_auto_context_with_confidence(


def _get_remote_url(remote_name: str, cwd: str) -> str | None:
"""Get the URL of a named git remote."""
"""Get the URL of a named git remote.

Returns None both when the remote does not exist and when git could not
be consulted at all -- but only the second case logs. Feature 036: a
non-zero exit here is git's legitimate answer ("no such remote"), which
is different in kind from git timing out or not being installed. Warning
on the former would make every single-remote repo noisy; staying silent
on the latter is what made a broken git install invisible.
"""
try:
result = subprocess.run(
["git", "remote", "get-url", remote_name],
Expand All @@ -530,8 +538,25 @@ def _get_remote_url(remote_name: str, cwd: str) -> str | None:
)
if result.returncode == 0:
return result.stdout.strip()
except (subprocess.SubprocessError, FileNotFoundError, OSError):
pass
except subprocess.TimeoutExpired:
logger.warning(
"context.platform: git remote lookup for %r timed out "
"(error_class=timeout); platform detection degraded",
remote_name,
)
except FileNotFoundError:
logger.warning(
"context.platform: git binary not found on PATH "
"(error_class=not_found); platform detection degraded",
)
except (subprocess.SubprocessError, OSError) as err:
logger.warning(
"context.platform: git remote lookup for %r failed "
"(error_class=network): %s: %s; platform detection degraded",
remote_name,
type(err).__name__,
err,
)
return None


Expand Down
83 changes: 83 additions & 0 deletions packages/darnit/src/darnit/core/error_class.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Environmental failure classification.

Feature 036. See specs/036-tier2-error-class/contracts/error-class.md.

When a sieve pass cannot run to completion -- the network is unreachable,
the auth token expired, a subprocess timed out, a required binary is
missing -- the resulting verdict is not the same kind of thing as a
verdict produced by a check that ran cleanly and found the repository
non-compliant. Both gate the audit identically (WARN counts as FAIL for
compliance math, per Constitution Principle II), but they demand
different operator responses: fix the environment vs fix the repo.

``error_class`` names the environmental cause so the two are
distinguishable in reports, logs, and attestations. Every value below
means "the check could not run to completion." None of them means "the
check ran and the repository does not comply" -- that stays a bare
FAIL/WARN with no ``error_class``.

============= ===============================================================
Value Meaning
============= ===============================================================
network Host unreachable, DNS failure, TLS error, MCP server unusable,
or any non-zero subprocess exit whose stderr matched no
more-specific pattern.
auth HTTP 401, bad credentials, expired token, "requires
authentication", or MCP plugin signature-verification failure.
timeout Subprocess exceeded its ``timeout`` budget, or an MCP tool
call exceeded ``MCP_DEFAULT_TIMEOUT_SECONDS``.
rate_limit GitHub primary or secondary rate limit, or abuse-detection
throttle.
not_found A required binary or MCP server executable is absent from
PATH.
crashed A handler raised an unexpected exception, or an MCP tool
returned unparseable output. The handler did not complete
cleanly.
============= ===============================================================

Two names are exported because ``typing.Literal`` is erased at runtime
and enforces nothing on its own. ``ErrorClass`` gives static-analysis
coverage; ``ERROR_CLASSES`` is what ``HandlerResult.__post_init__``
checks membership against, and is therefore the mechanism that actually
delivers the rejection guarantee (FR-002a). Adding a value means editing
both.

Deliberate divergence from :mod:`darnit.core.authority`: that module
pairs its ``Authority`` Literal with ``_TERMINAL_AUTHORITIES`` for a
*fail-safe* -- ``is_terminal_authority()`` returns False for unknown
strings, so an unknown authority can never conclude a control. That
works because authority has a conservative default ("cannot conclude").
An unknown ``error_class`` has no equivalent safe default: it is neither
"the check failed" nor "the check could not run." Rejection is the only
conservative option, so this module's frozenset backs a raise rather
than a degrade.
"""

from __future__ import annotations

from typing import Literal

ErrorClass = Literal[
"network",
"auth",
"timeout",
"rate_limit",
"not_found",
"crashed",
]

# Runtime-checkable companion to ``ErrorClass``. See module docstring for
# why both exist.
ERROR_CLASSES: frozenset[ErrorClass] = frozenset(
(
"network",
"auth",
"timeout",
"rate_limit",
"not_found",
"crashed",
)
)


__all__ = ["ERROR_CLASSES", "ErrorClass"]
Loading
Loading