arcgraph 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- arcgraph/__init__.py +41 -0
- arcgraph/_build_provenance.json +10 -0
- arcgraph/adapters/__init__.py +6 -0
- arcgraph/adapters/arq_adapter.py +369 -0
- arcgraph/adapters/celery_adapter.py +440 -0
- arcgraph/adapters/common.py +436 -0
- arcgraph/adapters/django_adapter.py +765 -0
- arcgraph/adapters/fastapi_adapter.py +415 -0
- arcgraph/adapters/logging_adapter.py +335 -0
- arcgraph/adapters/mcp_adapter.py +100 -0
- arcgraph/adapters/pydantic_adapter.py +290 -0
- arcgraph/adapters/pytest_adapter.py +221 -0
- arcgraph/adapters/pytest_collection.py +96 -0
- arcgraph/adapters/pytest_fixtures.py +371 -0
- arcgraph/adapters/pytest_types.py +251 -0
- arcgraph/adapters/registry.py +34 -0
- arcgraph/adapters/service_container_adapter.py +548 -0
- arcgraph/adapters/sqlalchemy_adapter.py +446 -0
- arcgraph/adapters/typer_click_adapter.py +292 -0
- arcgraph/analyzers/__init__.py +1 -0
- arcgraph/analyzers/bindings.py +782 -0
- arcgraph/analyzers/calls/__init__.py +6 -0
- arcgraph/analyzers/calls/analyzer.py +2921 -0
- arcgraph/analyzers/calls/constants.py +702 -0
- arcgraph/analyzers/calls/context.py +298 -0
- arcgraph/analyzers/calls/lexical.py +237 -0
- arcgraph/analyzers/coverage.py +826 -0
- arcgraph/analyzers/exports.py +46 -0
- arcgraph/analyzers/har_trace.py +323 -0
- arcgraph/analyzers/imports.py +190 -0
- arcgraph/analyzers/otel_trace.py +352 -0
- arcgraph/analyzers/precise_references.py +1199 -0
- arcgraph/analyzers/python_scopes.py +56 -0
- arcgraph/analyzers/runtime_trace.py +516 -0
- arcgraph/analyzers/similarity.py +430 -0
- arcgraph/analyzers/symbols.py +363 -0
- arcgraph/analyzers/type_unions.py +89 -0
- arcgraph/analyzers/types.py +2293 -0
- arcgraph/assets/__init__.py +1 -0
- arcgraph/assets/workbench/__init__.py +1 -0
- arcgraph/assets/workbench/index.html +2398 -0
- arcgraph/assets/workbench/vendor/LICENSE.d3.txt +15 -0
- arcgraph/assets/workbench/vendor/d3.v7.min.js +2 -0
- arcgraph/change/__init__.py +10 -0
- arcgraph/change/baseline.py +145 -0
- arcgraph/change/changed_regions.py +728 -0
- arcgraph/change/contracts.py +927 -0
- arcgraph/change/errors.py +88 -0
- arcgraph/change/evidence.py +289 -0
- arcgraph/change/graph_delta.py +1422 -0
- arcgraph/change/identities.py +1277 -0
- arcgraph/change/paths.py +234 -0
- arcgraph/change/pins.py +426 -0
- arcgraph/change/planner.py +504 -0
- arcgraph/change/protected_surfaces.py +328 -0
- arcgraph/change/provider.py +46 -0
- arcgraph/change/service.py +1030 -0
- arcgraph/change/source_provider.py +311 -0
- arcgraph/change/store.py +1138 -0
- arcgraph/change/trusted_runners.py +154 -0
- arcgraph/change/verdicts.py +141 -0
- arcgraph/change/verification_plan.py +113 -0
- arcgraph/change/verifier.py +579 -0
- arcgraph/core/__init__.py +1 -0
- arcgraph/core/assurance.py +343 -0
- arcgraph/core/binding_metrics.py +75 -0
- arcgraph/core/cleanup.py +701 -0
- arcgraph/core/confidence_profiles.py +108 -0
- arcgraph/core/evidence_manifest.py +862 -0
- arcgraph/core/expression_kinds.py +11 -0
- arcgraph/core/force_graph_export.py +1125 -0
- arcgraph/core/freshness.py +94 -0
- arcgraph/core/graph_store.py +1099 -0
- arcgraph/core/ids.py +206 -0
- arcgraph/core/language_tiers.py +150 -0
- arcgraph/core/merge.py +649 -0
- arcgraph/core/metadata.py +30 -0
- arcgraph/core/operation_lock.py +218 -0
- arcgraph/core/passes.py +142 -0
- arcgraph/core/payload_policy.py +708 -0
- arcgraph/core/precise_references.py +124 -0
- arcgraph/core/query_engine.py +3078 -0
- arcgraph/core/recovery.py +18 -0
- arcgraph/core/scanner.py +1454 -0
- arcgraph/core/schemas.py +700 -0
- arcgraph/core/semantic.py +263 -0
- arcgraph/core/semantic_metrics.py +619 -0
- arcgraph/core/sharing_retry.py +84 -0
- arcgraph/core/structural.py +260 -0
- arcgraph/core/target_resolver.py +486 -0
- arcgraph/core/type_metrics.py +67 -0
- arcgraph/core/unresolved_classification.py +471 -0
- arcgraph/core/utils.py +100 -0
- arcgraph/core/visual_contract.py +240 -0
- arcgraph/core/visual_slice_engine.py +933 -0
- arcgraph/interfaces/__init__.py +1 -0
- arcgraph/interfaces/agent_capabilities.py +493 -0
- arcgraph/interfaces/agent_help.py +313 -0
- arcgraph/interfaces/benchmark.py +548 -0
- arcgraph/interfaces/ci.py +1992 -0
- arcgraph/interfaces/cli.py +2799 -0
- arcgraph/interfaces/cli_change.py +690 -0
- arcgraph/interfaces/cli_feedback.py +176 -0
- arcgraph/interfaces/cli_ops.py +69 -0
- arcgraph/interfaces/cli_support.py +113 -0
- arcgraph/interfaces/cli_trial.py +42 -0
- arcgraph/interfaces/cli_visual.py +601 -0
- arcgraph/interfaces/cli_workspace.py +300 -0
- arcgraph/interfaces/client_setup.py +481 -0
- arcgraph/interfaces/docs.py +1255 -0
- arcgraph/interfaces/human_output.py +409 -0
- arcgraph/interfaces/local_state.py +577 -0
- arcgraph/interfaces/mcp_server.py +457 -0
- arcgraph/interfaces/mcp_tools.py +1209 -0
- arcgraph/interfaces/metrics.py +571 -0
- arcgraph/interfaces/ops.py +330 -0
- arcgraph/interfaces/precision.py +1670 -0
- arcgraph/interfaces/project_probe.py +75 -0
- arcgraph/interfaces/reports.py +1121 -0
- arcgraph/interfaces/stdio_encoding.py +29 -0
- arcgraph/interfaces/trace.py +592 -0
- arcgraph/interfaces/trial_feedback.py +519 -0
- arcgraph/interfaces/trial_setup.py +535 -0
- arcgraph/interfaces/visual_server.py +354 -0
- arcgraph/interfaces/visual_slice.py +168 -0
- arcgraph/interfaces/visual_smoke.py +666 -0
- arcgraph/interfaces/workbench.py +576 -0
- arcgraph/interfaces/workspace.py +1513 -0
- arcgraph/pipeline/__init__.py +1 -0
- arcgraph/pipeline/contracts.py +122 -0
- arcgraph/pipeline/external_frontend.py +451 -0
- arcgraph/pipeline/external_l3_frontends.py +368 -0
- arcgraph/pipeline/external_protocol.py +358 -0
- arcgraph/pipeline/frontends.py +437 -0
- arcgraph/pipeline/indexer.py +661 -0
- arcgraph/pipeline/openapi_frontend.py +708 -0
- arcgraph/pipeline/python_frontend.py +298 -0
- arcgraph/pipeline/reindexer.py +1333 -0
- arcgraph/pipeline/scip_frontend.py +726 -0
- arcgraph/pipeline/typescript_extractor/declarations.mjs +183 -0
- arcgraph/pipeline/typescript_extractor/edges.mjs +2794 -0
- arcgraph/pipeline/typescript_extractor/graph_primitives.mjs +189 -0
- arcgraph/pipeline/typescript_extractor/module_resolution.mjs +877 -0
- arcgraph/pipeline/typescript_extractor/runtime.mjs +56 -0
- arcgraph/pipeline/typescript_extractor/similarity.mjs +619 -0
- arcgraph/pipeline/typescript_extractor/similarity_runner.mjs +19 -0
- arcgraph/pipeline/typescript_extractor/sources.mjs +23 -0
- arcgraph/pipeline/typescript_extractor.mjs +177 -0
- arcgraph/pipeline/typescript_frameworks.py +947 -0
- arcgraph/pipeline/typescript_frontend.py +656 -0
- arcgraph/pipeline/typescript_references.mjs +167 -0
- arcgraph/providers/__init__.py +27 -0
- arcgraph/providers/context_provider.py +1258 -0
- arcgraph/providers/flow_payload.py +117 -0
- arcgraph/providers/memory_connector.py +216 -0
- arcgraph/providers/orchestrator.py +475 -0
- arcgraph/providers/relationship_payload.py +102 -0
- arcgraph/providers/risk_provider.py +843 -0
- arcgraph/providers/why_provider.py +349 -0
- arcgraph/version_info.py +246 -0
- arcgraph-0.1.0.dist-info/METADATA +676 -0
- arcgraph-0.1.0.dist-info/RECORD +165 -0
- arcgraph-0.1.0.dist-info/WHEEL +4 -0
- arcgraph-0.1.0.dist-info/entry_points.txt +2 -0
- arcgraph-0.1.0.dist-info/licenses/LICENSE +203 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Typed, fail-closed errors for Surgical Change Safety."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ChangeSafetyError(RuntimeError):
|
|
7
|
+
"""Base error with a stable machine-readable failure code."""
|
|
8
|
+
|
|
9
|
+
code = "CHANGE_SAFETY_ERROR"
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, *, code: str | None = None) -> None:
|
|
12
|
+
self.code = code or self.code
|
|
13
|
+
super().__init__(f"{self.code}: {message}")
|
|
14
|
+
|
|
15
|
+
def to_payload(self) -> dict[str, str]:
|
|
16
|
+
return {"code": self.code, "message": str(self).split(": ", 1)[-1]}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ChangeContractVersionUnsupported(ChangeSafetyError):
|
|
20
|
+
code = "CHANGE_CONTRACT_VERSION_UNSUPPORTED"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ChangeContractSchemaUnsupported(ChangeSafetyError):
|
|
24
|
+
code = "CHANGE_SCHEMA_VERSION_UNSUPPORTED"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ChangeContractDigestMismatch(ChangeSafetyError):
|
|
28
|
+
code = "PLAN_CONTENT_DIGEST_MISMATCH"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RepositoryPathError(ChangeSafetyError):
|
|
32
|
+
code = "REPOSITORY_PATH_INVALID"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OutputContainmentError(ChangeSafetyError):
|
|
36
|
+
code = "OUTPUT_PATH_OUTSIDE_CONTAINMENT"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ChangeStoreCorrupt(ChangeSafetyError):
|
|
40
|
+
code = "CHANGE_STORE_CORRUPT"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ChangeStoreNotFound(ChangeSafetyError):
|
|
44
|
+
code = "CHANGE_STORE_RECORD_NOT_FOUND"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BaselineIntegrityMismatch(ChangeSafetyError):
|
|
48
|
+
code = "BASELINE_INDEX_INTEGRITY_MISMATCH"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BaselineSourceUnavailable(ChangeSafetyError):
|
|
52
|
+
code = "BASELINE_SOURCE_UNAVAILABLE"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BaselineGitObjectMissing(ChangeSafetyError):
|
|
56
|
+
code = "BASELINE_GIT_OBJECT_MISSING"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class BaselineSourceIdentityMismatch(ChangeSafetyError):
|
|
60
|
+
code = "BASELINE_SOURCE_IDENTITY_MISMATCH"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CurrentCodeIdentityMismatch(ChangeSafetyError):
|
|
64
|
+
code = "CURRENT_CODE_IDENTITY_MISMATCH"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CurrentBuildCodeIdentityMismatch(ChangeSafetyError):
|
|
68
|
+
code = "CURRENT_BUILD_CODE_IDENTITY_MISMATCH"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ChangePlanStateError(ChangeSafetyError):
|
|
72
|
+
code = "CHANGE_PLAN_STATE_INVALID"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ChangeEvidenceError(ChangeSafetyError):
|
|
76
|
+
code = "CHANGE_EVIDENCE_INVALID"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TrustedRunnerError(ChangeSafetyError):
|
|
80
|
+
code = "TRUSTED_RUNNER_UNAVAILABLE"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class GraphDeltaIncomplete(ChangeSafetyError):
|
|
84
|
+
code = "GRAPH_DELTA_INCOMPLETE"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class StableIdentityCollision(ChangeSafetyError):
|
|
88
|
+
code = "STABLE_IDENTITY_COLLISION"
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Exact evidence ingress validation and dynamic material availability summaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
from typing import Iterable
|
|
11
|
+
|
|
12
|
+
from arcgraph.change.contracts import (
|
|
13
|
+
ChangePlanRevision,
|
|
14
|
+
VerificationEvidence,
|
|
15
|
+
VerificationRequirement,
|
|
16
|
+
canonical_digest,
|
|
17
|
+
)
|
|
18
|
+
from arcgraph.change.errors import ChangeEvidenceError
|
|
19
|
+
from arcgraph.change.identities import (
|
|
20
|
+
assert_code_identity_matches,
|
|
21
|
+
assert_current_build_matches_code,
|
|
22
|
+
capture_build_identity,
|
|
23
|
+
capture_code_identity,
|
|
24
|
+
)
|
|
25
|
+
from arcgraph.change.trusted_runners import (
|
|
26
|
+
TrustedRunnerRegistry,
|
|
27
|
+
attestation_meets_requirement,
|
|
28
|
+
)
|
|
29
|
+
from arcgraph.core.graph_store import GraphStoreReader
|
|
30
|
+
|
|
31
|
+
_SENSITIVE_METADATA_KEY = re.compile(
|
|
32
|
+
r"(?:authorization|cookie|credential|password|secret|token|api[_-]?key|"
|
|
33
|
+
r"private[_-]?key|access[_-]?key|connection[_-]?(?:string|uri))",
|
|
34
|
+
re.IGNORECASE,
|
|
35
|
+
)
|
|
36
|
+
_SENSITIVE_TEXT_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
37
|
+
re.compile(
|
|
38
|
+
r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----",
|
|
39
|
+
re.IGNORECASE | re.DOTALL,
|
|
40
|
+
),
|
|
41
|
+
re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE),
|
|
42
|
+
re.compile(r"\bsk-[A-Za-z0-9_-]{12,}\b", re.IGNORECASE),
|
|
43
|
+
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b", re.IGNORECASE),
|
|
44
|
+
re.compile(r"\bAKIA[A-Z0-9]{16}\b", re.IGNORECASE),
|
|
45
|
+
re.compile(
|
|
46
|
+
r"\b[A-Za-z0-9_-]*(?:password|passwd|secret|token|api[_-]?key|"
|
|
47
|
+
r"private[_-]?key)[A-Za-z0-9_-]*"
|
|
48
|
+
r"\s*(?::|=|\bis\b|\bwas\b)\s*[^\s,;]+",
|
|
49
|
+
re.IGNORECASE,
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
# Dotted identifiers are common graph identities, not evidence of a secret.
|
|
53
|
+
# Recognize compact tokens by their encoded JSON object header instead of length.
|
|
54
|
+
_COMPACT_TOKEN = re.compile(
|
|
55
|
+
r"(?<![A-Za-z0-9_-])(?=(?P<token>(?P<header>[A-Za-z0-9_-]+)\."
|
|
56
|
+
r"[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*(?![A-Za-z0-9_-])))"
|
|
57
|
+
)
|
|
58
|
+
# Consume leading nonletters so punctuation/digit-prefixed URI text retains
|
|
59
|
+
# its prior redaction, without retrying every suffix of a long scheme token.
|
|
60
|
+
_URI_CREDENTIALS = re.compile(
|
|
61
|
+
r"(?<![A-Za-z0-9+.-])(?P<prefix>[0-9+.-]*[A-Za-z][A-Za-z0-9+.-]*://)"
|
|
62
|
+
r"[^/\s:@]+:[^/\s@]+@"
|
|
63
|
+
)
|
|
64
|
+
_WINDOWS_USER_PATH = re.compile(r"(?i)\b[A-Z]:\\Users\\[^\\\s\"']+(?:\\[^\s\"'<>]*)?")
|
|
65
|
+
_POSIX_USER_PATH = re.compile(r"/(?:home|Users)/[^/\s\"']+(?:/[^\s\"'<>]*)?")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class EvidenceIngressValidator:
|
|
69
|
+
"""Validate evidence against the exact revision and current code/build state."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
repo_root: Path,
|
|
74
|
+
output_dir: Path,
|
|
75
|
+
*,
|
|
76
|
+
repo_id: str,
|
|
77
|
+
trusted_runner_registry: TrustedRunnerRegistry | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.repo_root = repo_root.resolve()
|
|
80
|
+
self.output_dir = output_dir.resolve()
|
|
81
|
+
self.repo_id = repo_id
|
|
82
|
+
self.trusted_runner_registry = trusted_runner_registry or TrustedRunnerRegistry(
|
|
83
|
+
None,
|
|
84
|
+
repo_root=self.repo_root,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def validate(
|
|
88
|
+
self,
|
|
89
|
+
evidence: VerificationEvidence,
|
|
90
|
+
revision: ChangePlanRevision,
|
|
91
|
+
*,
|
|
92
|
+
material: dict[str, object] | None = None,
|
|
93
|
+
) -> VerificationEvidence:
|
|
94
|
+
if evidence.repo_id != self.repo_id or revision.repo_id != self.repo_id:
|
|
95
|
+
raise ChangeEvidenceError(
|
|
96
|
+
"evidence and revision must use the configured repo_id",
|
|
97
|
+
code="EVIDENCE_REPOSITORY_MISMATCH",
|
|
98
|
+
)
|
|
99
|
+
if (
|
|
100
|
+
evidence.plan_id != revision.plan_id
|
|
101
|
+
or evidence.plan_revision != revision.revision
|
|
102
|
+
or evidence.plan_content_digest != revision.plan_content_digest
|
|
103
|
+
):
|
|
104
|
+
raise ChangeEvidenceError(
|
|
105
|
+
"evidence does not bind the exact plan revision digest",
|
|
106
|
+
code="EVIDENCE_BINDING_MISMATCH",
|
|
107
|
+
)
|
|
108
|
+
requirement = _requirement_for(revision, evidence.verification_requirement_id)
|
|
109
|
+
current_build = capture_build_identity(
|
|
110
|
+
self.repo_root,
|
|
111
|
+
self.output_dir,
|
|
112
|
+
repo_id=self.repo_id,
|
|
113
|
+
)
|
|
114
|
+
source_paths = [
|
|
115
|
+
record.path
|
|
116
|
+
for record in GraphStoreReader.from_current(self.output_dir).iter_files()
|
|
117
|
+
]
|
|
118
|
+
actual_code = capture_code_identity(
|
|
119
|
+
self.repo_root,
|
|
120
|
+
repo_id=self.repo_id,
|
|
121
|
+
build_identity=current_build,
|
|
122
|
+
source_paths=source_paths,
|
|
123
|
+
)
|
|
124
|
+
assert_current_build_matches_code(current_build, actual_code)
|
|
125
|
+
assert_code_identity_matches(evidence.current_code_identity, actual_code)
|
|
126
|
+
if not attestation_meets_requirement(evidence, requirement):
|
|
127
|
+
raise ChangeEvidenceError(
|
|
128
|
+
"evidence attestation level is below the requirement",
|
|
129
|
+
code="EVIDENCE_ATTESTATION_INSUFFICIENT",
|
|
130
|
+
)
|
|
131
|
+
self.trusted_runner_registry.validate(evidence, requirement)
|
|
132
|
+
if (
|
|
133
|
+
not evidence.producer_identity.strip()
|
|
134
|
+
or not evidence.artifact_digest.strip()
|
|
135
|
+
):
|
|
136
|
+
raise ChangeEvidenceError(
|
|
137
|
+
"evidence producer identity and artifact digest are required",
|
|
138
|
+
code="EVIDENCE_IDENTITY_INCOMPLETE",
|
|
139
|
+
)
|
|
140
|
+
if evidence.attestation_level in {
|
|
141
|
+
"artifact_backed",
|
|
142
|
+
"trusted_runner",
|
|
143
|
+
} and evidence.artifact_digest != canonical_digest(material or {}):
|
|
144
|
+
raise ChangeEvidenceError(
|
|
145
|
+
"artifact digest does not bind the submitted evidence material",
|
|
146
|
+
code="EVIDENCE_ARTIFACT_DIGEST_MISMATCH",
|
|
147
|
+
)
|
|
148
|
+
return redact_evidence_metadata(evidence)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def evidence_summary(
|
|
152
|
+
evidence: Iterable[VerificationEvidence],
|
|
153
|
+
availability: dict[str, str],
|
|
154
|
+
) -> dict[str, object]:
|
|
155
|
+
"""Return a dynamic availability projection without mutating evidence records."""
|
|
156
|
+
|
|
157
|
+
records = list(evidence)
|
|
158
|
+
by_result: dict[str, int] = {}
|
|
159
|
+
by_availability: dict[str, int] = {}
|
|
160
|
+
for record in records:
|
|
161
|
+
by_result[record.result] = by_result.get(record.result, 0) + 1
|
|
162
|
+
state = availability.get(record.evidence_id, "missing")
|
|
163
|
+
by_availability[state] = by_availability.get(state, 0) + 1
|
|
164
|
+
return {
|
|
165
|
+
"total": len(records),
|
|
166
|
+
"by_result": dict(sorted(by_result.items())),
|
|
167
|
+
"by_availability": dict(sorted(by_availability.items())),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def redact_evidence_metadata(evidence: VerificationEvidence) -> VerificationEvidence:
|
|
172
|
+
"""Redact sensitive runner metadata before an immutable record is written."""
|
|
173
|
+
|
|
174
|
+
metadata, changed = _redact_mapping(evidence.runner_metadata)
|
|
175
|
+
if not changed:
|
|
176
|
+
return evidence
|
|
177
|
+
payload = evidence.model_dump(mode="python")
|
|
178
|
+
payload["runner_metadata"] = metadata
|
|
179
|
+
payload["redaction_status"] = "redacted"
|
|
180
|
+
payload["payload_digest"] = ""
|
|
181
|
+
return VerificationEvidence.model_validate(payload)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def redact_evidence_material(material: dict[str, object] | None) -> dict[str, object]:
|
|
185
|
+
"""Return an evidence-material projection safe for the local state store."""
|
|
186
|
+
|
|
187
|
+
if material is None:
|
|
188
|
+
return {}
|
|
189
|
+
redacted, _changed = _redact_mapping(material)
|
|
190
|
+
return redacted
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def redact_sensitive_text(
|
|
194
|
+
value: str,
|
|
195
|
+
*,
|
|
196
|
+
redact_paths: bool = True,
|
|
197
|
+
) -> tuple[str, bool]:
|
|
198
|
+
"""Redact well-known secret values and host-user paths in free text."""
|
|
199
|
+
|
|
200
|
+
redacted = _redact_compact_tokens(value)
|
|
201
|
+
for pattern in _SENSITIVE_TEXT_PATTERNS:
|
|
202
|
+
redacted = pattern.sub("<redacted>", redacted)
|
|
203
|
+
redacted = _URI_CREDENTIALS.sub(r"\g<prefix><redacted>@", redacted)
|
|
204
|
+
if redact_paths:
|
|
205
|
+
redacted = _WINDOWS_USER_PATH.sub("<redacted-user-path>", redacted)
|
|
206
|
+
redacted = _POSIX_USER_PATH.sub("<redacted-user-path>", redacted)
|
|
207
|
+
return redacted, redacted != value
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _redact_compact_tokens(value: str) -> str:
|
|
211
|
+
# Lookahead permits overlapping candidates: an invalid `session.header.payload`
|
|
212
|
+
# must not consume the start of the valid `header.payload.signature` token.
|
|
213
|
+
spans: list[tuple[int, int]] = []
|
|
214
|
+
for match in _COMPACT_TOKEN.finditer(value):
|
|
215
|
+
if not _is_compact_header(match.group("header")):
|
|
216
|
+
continue
|
|
217
|
+
start, end = match.span("token")
|
|
218
|
+
if spans and start <= spans[-1][1]:
|
|
219
|
+
spans[-1] = (spans[-1][0], max(end, spans[-1][1]))
|
|
220
|
+
else:
|
|
221
|
+
spans.append((start, end))
|
|
222
|
+
parts: list[str] = []
|
|
223
|
+
cursor = 0
|
|
224
|
+
for start, end in spans:
|
|
225
|
+
parts.extend((value[cursor:start], "<redacted>"))
|
|
226
|
+
cursor = end
|
|
227
|
+
parts.append(value[cursor:])
|
|
228
|
+
return "".join(parts)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _is_compact_header(header: str) -> bool:
|
|
232
|
+
try:
|
|
233
|
+
decoded = base64.b64decode(
|
|
234
|
+
header + "=" * (-len(header) % 4), altchars=b"-_", validate=True
|
|
235
|
+
).decode("utf-8")
|
|
236
|
+
parsed = json.loads(decoded)
|
|
237
|
+
except (ValueError, binascii.Error):
|
|
238
|
+
return False
|
|
239
|
+
# Redaction is conservative recognition, not signature or claim validation.
|
|
240
|
+
return isinstance(parsed, dict)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _requirement_for(
|
|
244
|
+
revision: ChangePlanRevision,
|
|
245
|
+
requirement_id: str,
|
|
246
|
+
) -> VerificationRequirement:
|
|
247
|
+
for requirement in revision.verification_plan.requirements:
|
|
248
|
+
if requirement.requirement_id == requirement_id:
|
|
249
|
+
return requirement
|
|
250
|
+
raise ChangeEvidenceError(
|
|
251
|
+
"evidence requirement does not exist in the revision",
|
|
252
|
+
code="EVIDENCE_REQUIREMENT_INVALID",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _redact_mapping(value: object) -> tuple[object, bool]:
|
|
257
|
+
if isinstance(value, dict):
|
|
258
|
+
result: dict[str, object] = {}
|
|
259
|
+
changed = False
|
|
260
|
+
for key, child in value.items():
|
|
261
|
+
if not isinstance(key, str):
|
|
262
|
+
raise ChangeEvidenceError(
|
|
263
|
+
"runner metadata and material keys must be strings",
|
|
264
|
+
code="EVIDENCE_REDACTION_INVALID",
|
|
265
|
+
)
|
|
266
|
+
if _SENSITIVE_METADATA_KEY.search(key):
|
|
267
|
+
result[key] = "<redacted>"
|
|
268
|
+
changed = True
|
|
269
|
+
continue
|
|
270
|
+
redacted, child_changed = _redact_mapping(child)
|
|
271
|
+
result[key] = redacted
|
|
272
|
+
changed = changed or child_changed
|
|
273
|
+
return result, changed
|
|
274
|
+
if isinstance(value, list):
|
|
275
|
+
result: list[object] = []
|
|
276
|
+
changed = False
|
|
277
|
+
for child in value:
|
|
278
|
+
redacted, child_changed = _redact_mapping(child)
|
|
279
|
+
result.append(redacted)
|
|
280
|
+
changed = changed or child_changed
|
|
281
|
+
return result, changed
|
|
282
|
+
if isinstance(value, str):
|
|
283
|
+
return redact_sensitive_text(value)
|
|
284
|
+
if isinstance(value, (int, float, bool)) or value is None:
|
|
285
|
+
return value, False
|
|
286
|
+
raise ChangeEvidenceError(
|
|
287
|
+
"runner metadata and material must contain JSON-compatible values",
|
|
288
|
+
code="EVIDENCE_REDACTION_INVALID",
|
|
289
|
+
)
|