algo-cli-runtime 0.14.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.
- algo_cli/__init__.py +3 -0
- algo_cli/__main__.py +7 -0
- algo_cli/_internal/__init__.py +12 -0
- algo_cli/_internal/policy_chain.py +259 -0
- algo_cli/action_registry.py +1047 -0
- algo_cli/agent_blocks.py +550 -0
- algo_cli/agent_pipeline.py +1457 -0
- algo_cli/agent_threads.py +308 -0
- algo_cli/animations.py +316 -0
- algo_cli/cache_admission.py +209 -0
- algo_cli/capability_mask.py +66 -0
- algo_cli/chat_protocol.py +116 -0
- algo_cli/chatgpt_auth.py +510 -0
- algo_cli/chatgpt_client.py +657 -0
- algo_cli/code_rag.py +479 -0
- algo_cli/config.py +651 -0
- algo_cli/context_budget.py +679 -0
- algo_cli/credential_helpers.py +315 -0
- algo_cli/deliberation.py +29 -0
- algo_cli/display.py +1470 -0
- algo_cli/evals/__init__.py +21 -0
- algo_cli/evals/algorithm_effectiveness.py +560 -0
- algo_cli/evals/competitive_harness_rating.py +702 -0
- algo_cli/evals/cot_quality.py +220 -0
- algo_cli/evals/harness_retrieval_benchmark.py +401 -0
- algo_cli/evals/performance_regression.py +136 -0
- algo_cli/evals/scorecard_grading.py +308 -0
- algo_cli/evals/session_distribution.py +84 -0
- algo_cli/execution_guardrails.py +806 -0
- algo_cli/extensions_manifest.py +84 -0
- algo_cli/git_evidence.py +227 -0
- algo_cli/google_workspace.py +407 -0
- algo_cli/google_workspace_auth.py +523 -0
- algo_cli/harness.py +2587 -0
- algo_cli/identity.py +557 -0
- algo_cli/index_compute_lab.py +228 -0
- algo_cli/inference_harness.py +70 -0
- algo_cli/intelligence/__init__.py +1103 -0
- algo_cli/intelligence/acrobat_config.py +307 -0
- algo_cli/intelligence/acrobat_manifests.py +338 -0
- algo_cli/intelligence/acrobat_models.py +195 -0
- algo_cli/intelligence/acrobat_pipeline.py +295 -0
- algo_cli/intelligence/acrobat_runtime.py +302 -0
- algo_cli/intelligence/acrobat_security.py +261 -0
- algo_cli/intelligence/acrobat_workflows.py +226 -0
- algo_cli/intelligence/actionability.py +165 -0
- algo_cli/intelligence/adversarial_audit.py +136 -0
- algo_cli/intelligence/agent_arena.py +92 -0
- algo_cli/intelligence/agent_benchmark.py +236 -0
- algo_cli/intelligence/agent_runtime.py +171 -0
- algo_cli/intelligence/agents_as_tools.py +70 -0
- algo_cli/intelligence/artifact_binding.py +80 -0
- algo_cli/intelligence/autonomous_engineer.py +1976 -0
- algo_cli/intelligence/backpressure.py +99 -0
- algo_cli/intelligence/bloom_filter.py +186 -0
- algo_cli/intelligence/bonferroni.py +66 -0
- algo_cli/intelligence/boundary_compaction.py +98 -0
- algo_cli/intelligence/catalog_verifier.py +172 -0
- algo_cli/intelligence/cavecrew.py +118 -0
- algo_cli/intelligence/changelog.py +176 -0
- algo_cli/intelligence/checkpoint_resume.py +92 -0
- algo_cli/intelligence/circuit_breaker.py +88 -0
- algo_cli/intelligence/clarification_gate.py +101 -0
- algo_cli/intelligence/code_graph.py +180 -0
- algo_cli/intelligence/coderank.py +97 -0
- algo_cli/intelligence/consistent_hash.py +150 -0
- algo_cli/intelligence/consortium_synthesis.py +139 -0
- algo_cli/intelligence/construction/__init__.py +241 -0
- algo_cli/intelligence/construction/common.py +273 -0
- algo_cli/intelligence/construction/documents.py +496 -0
- algo_cli/intelligence/construction/labor_units.py +1395 -0
- algo_cli/intelligence/construction/payments.py +470 -0
- algo_cli/intelligence/construction/risk.py +784 -0
- algo_cli/intelligence/content_extractor.py +132 -0
- algo_cli/intelligence/context_adaptive.py +102 -0
- algo_cli/intelligence/context_ops.py +95 -0
- algo_cli/intelligence/count_min.py +145 -0
- algo_cli/intelligence/cow_state.py +103 -0
- algo_cli/intelligence/critic_loop.py +119 -0
- algo_cli/intelligence/cross_source.py +113 -0
- algo_cli/intelligence/daemon_mode.py +99 -0
- algo_cli/intelligence/dag_orchestration.py +151 -0
- algo_cli/intelligence/deep_research.py +155 -0
- algo_cli/intelligence/degenerate_detector.py +78 -0
- algo_cli/intelligence/delta_report.py +92 -0
- algo_cli/intelligence/discovery_event_log.py +92 -0
- algo_cli/intelligence/document_ingest.py +298 -0
- algo_cli/intelligence/dual_layer_validate.py +151 -0
- algo_cli/intelligence/echo_fidelity.py +73 -0
- algo_cli/intelligence/ema_tuning.py +104 -0
- algo_cli/intelligence/event_log.py +92 -0
- algo_cli/intelligence/evidence_graph.py +114 -0
- algo_cli/intelligence/extension_host.py +162 -0
- algo_cli/intelligence/extension_manifest.py +115 -0
- algo_cli/intelligence/falsification_suite.py +178 -0
- algo_cli/intelligence/finance/__init__.py +169 -0
- algo_cli/intelligence/finance/anomalies.py +135 -0
- algo_cli/intelligence/finance/ap_ar.py +351 -0
- algo_cli/intelligence/finance/cash.py +162 -0
- algo_cli/intelligence/finance/close.py +332 -0
- algo_cli/intelligence/finance/common.py +244 -0
- algo_cli/intelligence/finance/construction.py +135 -0
- algo_cli/intelligence/finance/controls.py +172 -0
- algo_cli/intelligence/finance/evidence.py +119 -0
- algo_cli/intelligence/finance/exceptions.py +157 -0
- algo_cli/intelligence/finance/reconciliations.py +254 -0
- algo_cli/intelligence/finance/revenue.py +109 -0
- algo_cli/intelligence/finance/tax.py +74 -0
- algo_cli/intelligence/finance/workpapers.py +111 -0
- algo_cli/intelligence/finding_record.py +120 -0
- algo_cli/intelligence/flow_dag.py +267 -0
- algo_cli/intelligence/gatherer.py +223 -0
- algo_cli/intelligence/golden_master.py +98 -0
- algo_cli/intelligence/graph_rag.py +195 -0
- algo_cli/intelligence/group_chat.py +143 -0
- algo_cli/intelligence/hash_dedup.py +145 -0
- algo_cli/intelligence/hyperloglog.py +128 -0
- algo_cli/intelligence/incremental_index.py +316 -0
- algo_cli/intelligence/index_store.py +16 -0
- algo_cli/intelligence/iteration_plan.py +133 -0
- algo_cli/intelligence/kernel_plugins.py +167 -0
- algo_cli/intelligence/lesson_catalog.py +135 -0
- algo_cli/intelligence/llm_fallback.py +169 -0
- algo_cli/intelligence/log2_histogram.py +267 -0
- algo_cli/intelligence/lsp_integration.py +147 -0
- algo_cli/intelligence/memory_evolution.py +117 -0
- algo_cli/intelligence/minhash_lsh.py +182 -0
- algo_cli/intelligence/multi_model_score.py +174 -0
- algo_cli/intelligence/multi_tier_grade.py +211 -0
- algo_cli/intelligence/negative_controls.py +113 -0
- algo_cli/intelligence/numeric_clamp.py +63 -0
- algo_cli/intelligence/occ_editor.py +66 -0
- algo_cli/intelligence/output_normalize.py +112 -0
- algo_cli/intelligence/parallel_delegation.py +98 -0
- algo_cli/intelligence/parallel_fanout.py +104 -0
- algo_cli/intelligence/permission_modes.py +105 -0
- algo_cli/intelligence/pre_push_gate.py +68 -0
- algo_cli/intelligence/prefetch.py +171 -0
- algo_cli/intelligence/process_framework.py +217 -0
- algo_cli/intelligence/project_graph.py +387 -0
- algo_cli/intelligence/query_expansion.py +146 -0
- algo_cli/intelligence/ralph_loop.py +117 -0
- algo_cli/intelligence/rate_limiter.py +153 -0
- algo_cli/intelligence/refactor_transaction.py +94 -0
- algo_cli/intelligence/research_workspace.py +108 -0
- algo_cli/intelligence/retraction_ledger.py +72 -0
- algo_cli/intelligence/saga_pattern.py +88 -0
- algo_cli/intelligence/session_fork.py +100 -0
- algo_cli/intelligence/shadow_editor.py +67 -0
- algo_cli/intelligence/shell_session.py +213 -0
- algo_cli/intelligence/source_registry.py +143 -0
- algo_cli/intelligence/spawn_scales.py +99 -0
- algo_cli/intelligence/stat_stability.py +104 -0
- algo_cli/intelligence/structural_validator.py +148 -0
- algo_cli/intelligence/subagent_spawner.py +111 -0
- algo_cli/intelligence/symmetric_verify.py +70 -0
- algo_cli/intelligence/task_classifier.py +129 -0
- algo_cli/intelligence/team_execution.py +122 -0
- algo_cli/intelligence/tiered_access.py +121 -0
- algo_cli/intelligence/utility_registry.py +159 -0
- algo_cli/intuition_engine.py +560 -0
- algo_cli/intuition_injector.py +82 -0
- algo_cli/kernels/__init__.py +5 -0
- algo_cli/kernels/manifest.py +763 -0
- algo_cli/main.py +3903 -0
- algo_cli/memory_candidates.py +541 -0
- algo_cli/memory_echo_veil.py +394 -0
- algo_cli/memory_runtime.py +112 -0
- algo_cli/model_info.py +548 -0
- algo_cli/model_profile.py +160 -0
- algo_cli/model_routing.py +74 -0
- algo_cli/oneshot.py +331 -0
- algo_cli/perf_telemetry.py +389 -0
- algo_cli/plugins.py +245 -0
- algo_cli/private_event_store.py +654 -0
- algo_cli/quantization/__init__.py +24 -0
- algo_cli/quantization/lloyd_max.py +98 -0
- algo_cli/quantization/turbo_quant.py +308 -0
- algo_cli/reasoning/__init__.py +46 -0
- algo_cli/reasoning/combinatorial.py +356 -0
- algo_cli/reasoning/graph_of_thought.py +297 -0
- algo_cli/reasoning/mcts.py +220 -0
- algo_cli/reasoning/neuro_symbolic.py +250 -0
- algo_cli/reasoning/react.py +246 -0
- algo_cli/reasoning/reflexion.py +225 -0
- algo_cli/reasoning/tree_of_thought.py +241 -0
- algo_cli/reasoning_bridge.py +150 -0
- algo_cli/reconciliation.py +284 -0
- algo_cli/reflex.py +385 -0
- algo_cli/resources/docs/ALGO.md +13958 -0
- algo_cli/resources/docs/algo-cli-algorithm-evidence-contract.md +60 -0
- algo_cli/resources/docs/algo-cli-execution-verification-contract.md +59 -0
- algo_cli/resources/docs/algo-cli-memory-lifecycle-contract.md +72 -0
- algo_cli/resources/docs/harness-extension-cleanup-recommendation.md +41 -0
- algo_cli/resources/docs/index-compute-lab-integration.md +32 -0
- algo_cli/resources/docs/inference-harness-loop-blueprint-2026-06.md +55 -0
- algo_cli/resources/docs/main-split-map.md +35 -0
- algo_cli/resources/docs/privacy-and-context.md +48 -0
- algo_cli/resources/docs/reflex-loop-v0.2.md +354 -0
- algo_cli/resources/skills/README.md +26 -0
- algo_cli/resources/skills/algo-cli.md +59 -0
- algo_cli/resources/skills/edit-file-precision.md +49 -0
- algo_cli/resources/skills/harness-search-first.md +47 -0
- algo_cli/resources/skills/memory-recall-ritual.md +51 -0
- algo_cli/resources/skills/qol-algorithms.md +224 -0
- algo_cli/resources/skills/smart-error-recovery.md +56 -0
- algo_cli/resources/skills/tool-selection-cheatsheet.md +65 -0
- algo_cli/retrieval_algorithms.py +127 -0
- algo_cli/runtime_qos.py +236 -0
- algo_cli/runtime_services.py +320 -0
- algo_cli/session_commands.py +95 -0
- algo_cli/session_mode.py +113 -0
- algo_cli/skills.py +430 -0
- algo_cli/slash_dispatch.py +1265 -0
- algo_cli/small_context.py +206 -0
- algo_cli/spawn_budget.py +89 -0
- algo_cli/task_ledger.py +84 -0
- algo_cli/task_router.py +197 -0
- algo_cli/tool_context.py +94 -0
- algo_cli/tool_contract.py +99 -0
- algo_cli/tool_policy.py +357 -0
- algo_cli/tool_runtime.py +647 -0
- algo_cli/tools.py +3056 -0
- algo_cli/url_scheme.py +174 -0
- algo_cli/verify.py +154 -0
- algo_cli/version_manifest.py +178 -0
- algo_cli/vision_screenshot_verify.py +76 -0
- algo_cli/workspace_resolver.py +68 -0
- algo_cli/x_account.py +209 -0
- algo_cli/xai_auth.py +374 -0
- algo_cli/xai_client.py +600 -0
- algo_cli_runtime-0.14.0.dist-info/METADATA +369 -0
- algo_cli_runtime-0.14.0.dist-info/RECORD +237 -0
- algo_cli_runtime-0.14.0.dist-info/WHEEL +4 -0
- algo_cli_runtime-0.14.0.dist-info/entry_points.txt +3 -0
- algo_cli_runtime-0.14.0.dist-info/licenses/LICENSE +21 -0
- ollama_cli/__init__.py +67 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
"""Run-scoped execution evidence and fail-closed file-write guardrails.
|
|
2
|
+
|
|
3
|
+
The ledger deliberately stores only structural metadata. File contents, shell
|
|
4
|
+
commands, and command output are never retained here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
from contextvars import ContextVar, Token
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable, Iterable, Literal, Sequence
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
EvidenceKind = Literal["read", "mutation", "verification"]
|
|
20
|
+
VerificationKind = Literal["same_path_reread", "git_diff", "test", "lint"]
|
|
21
|
+
|
|
22
|
+
_PATH_MUTATION_OPERATIONS = frozenset({"write_file", "edit_file", "batch_edit"})
|
|
23
|
+
_MUTATION_OPERATIONS = frozenset({*_PATH_MUTATION_OPERATIONS, "run_shell"})
|
|
24
|
+
_READ_OPERATIONS = frozenset({"read_file"})
|
|
25
|
+
_VERIFICATION_KINDS = frozenset({"git_diff", "test", "lint"})
|
|
26
|
+
|
|
27
|
+
_SENSITIVE_COMPONENTS = frozenset(
|
|
28
|
+
{
|
|
29
|
+
".git",
|
|
30
|
+
".hg",
|
|
31
|
+
".svn",
|
|
32
|
+
".ssh",
|
|
33
|
+
".gnupg",
|
|
34
|
+
".aws",
|
|
35
|
+
".azure",
|
|
36
|
+
".kube",
|
|
37
|
+
".docker",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
_SENSITIVE_FILENAMES = frozenset(
|
|
41
|
+
{
|
|
42
|
+
".env",
|
|
43
|
+
".netrc",
|
|
44
|
+
"_netrc",
|
|
45
|
+
"credentials",
|
|
46
|
+
"credentials.json",
|
|
47
|
+
"secrets.json",
|
|
48
|
+
"service-account.json",
|
|
49
|
+
"service_account.json",
|
|
50
|
+
"token.json",
|
|
51
|
+
"id_rsa",
|
|
52
|
+
"id_dsa",
|
|
53
|
+
"id_ecdsa",
|
|
54
|
+
"id_ed25519",
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
_SENSITIVE_SUFFIXES = frozenset(
|
|
58
|
+
{".pem", ".key", ".p12", ".pfx", ".jks", ".keystore", ".kdbx"}
|
|
59
|
+
)
|
|
60
|
+
_CONTROL_OPERATOR_RE = re.compile(
|
|
61
|
+
r"(?:\r|\n|;|&&|\|\||(?<!\|)\|(?!\|)|(?<!&)\&(?!&)|`|\$\(|[<>])"
|
|
62
|
+
)
|
|
63
|
+
_ENV_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$", re.DOTALL)
|
|
64
|
+
_RUNNER_OPTIONS_WITH_VALUE = frozenset(
|
|
65
|
+
{
|
|
66
|
+
"--directory",
|
|
67
|
+
"--env-file",
|
|
68
|
+
"--extra",
|
|
69
|
+
"--group",
|
|
70
|
+
"--index",
|
|
71
|
+
"--project",
|
|
72
|
+
"--python",
|
|
73
|
+
"--with",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
_VERIFIER_SCRIPT_RE = re.compile(
|
|
77
|
+
r"^(?:(?:health|smoke)[_-]?check|check(?:[_-].*)?|verify(?:[_-].*)?|"
|
|
78
|
+
r"test(?:[_-].*)?|.*[_-]test)\.(?:py|sh|ps1|js|ts)$",
|
|
79
|
+
re.IGNORECASE,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ExecutionGuardrailError(RuntimeError):
|
|
84
|
+
"""Raised when execution evidence cannot be handled safely."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class EvidenceEvent:
|
|
89
|
+
"""One successful, content-free event in execution order."""
|
|
90
|
+
|
|
91
|
+
sequence: int
|
|
92
|
+
kind: EvidenceKind
|
|
93
|
+
operation: str
|
|
94
|
+
relative_path: str | None = None
|
|
95
|
+
verification_kind: str | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class PathDecision:
|
|
100
|
+
"""Decision for a prospective model-controlled file write."""
|
|
101
|
+
|
|
102
|
+
allowed: bool
|
|
103
|
+
reason: str
|
|
104
|
+
resolved_path: Path | None = None
|
|
105
|
+
relative_path: str | None = None
|
|
106
|
+
sensitive: bool = False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class ReadBeforeEditDecision:
|
|
111
|
+
"""Whether an edit has a fresh successful read in the current run."""
|
|
112
|
+
|
|
113
|
+
allowed: bool
|
|
114
|
+
reason: str
|
|
115
|
+
read_sequence: int | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class VerificationCommand:
|
|
120
|
+
"""Content-free classification of a shell verification command."""
|
|
121
|
+
|
|
122
|
+
qualifies: bool
|
|
123
|
+
kind: str | None
|
|
124
|
+
reason: str
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class CompletionDecision:
|
|
129
|
+
"""Whether execution evidence supports a successful completion claim."""
|
|
130
|
+
|
|
131
|
+
allowed: bool
|
|
132
|
+
reason: str
|
|
133
|
+
last_mutation_sequence: int | None = None
|
|
134
|
+
verifier_sequence: int | None = None
|
|
135
|
+
verifier_kind: str | None = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class _ExecutionLedger:
|
|
140
|
+
workspace: Path
|
|
141
|
+
events: list[EvidenceEvent] = field(default_factory=list)
|
|
142
|
+
closed: bool = False
|
|
143
|
+
|
|
144
|
+
def append(
|
|
145
|
+
self,
|
|
146
|
+
kind: EvidenceKind,
|
|
147
|
+
operation: str,
|
|
148
|
+
*,
|
|
149
|
+
relative_path: str | None = None,
|
|
150
|
+
verification_kind: str | None = None,
|
|
151
|
+
) -> EvidenceEvent:
|
|
152
|
+
if self.closed:
|
|
153
|
+
raise ExecutionGuardrailError("execution scope is closed")
|
|
154
|
+
event = EvidenceEvent(
|
|
155
|
+
sequence=len(self.events) + 1,
|
|
156
|
+
kind=kind,
|
|
157
|
+
operation=operation,
|
|
158
|
+
relative_path=relative_path,
|
|
159
|
+
verification_kind=verification_kind,
|
|
160
|
+
)
|
|
161
|
+
self.events.append(event)
|
|
162
|
+
return event
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class ExecutionScope:
|
|
167
|
+
"""Opaque handle returned by :func:`begin_execution_scope`."""
|
|
168
|
+
|
|
169
|
+
workspace: Path
|
|
170
|
+
_ledger: _ExecutionLedger
|
|
171
|
+
_token: Token[_ExecutionLedger | None]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
_ACTIVE_LEDGER: ContextVar[_ExecutionLedger | None] = ContextVar(
|
|
175
|
+
"algo_cli_execution_evidence_ledger",
|
|
176
|
+
default=None,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def begin_execution_scope(workspace: str | Path) -> ExecutionScope:
|
|
181
|
+
"""Begin a run-local evidence scope rooted at an existing directory."""
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
resolved_workspace = Path(workspace).expanduser().resolve(strict=True)
|
|
185
|
+
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
|
186
|
+
raise ExecutionGuardrailError("workspace cannot be resolved safely") from exc
|
|
187
|
+
if not resolved_workspace.is_dir():
|
|
188
|
+
raise ExecutionGuardrailError("workspace must be an existing directory")
|
|
189
|
+
if resolved_workspace.parent == resolved_workspace:
|
|
190
|
+
raise ExecutionGuardrailError("filesystem root cannot be used as a workspace")
|
|
191
|
+
ledger = _ExecutionLedger(workspace=resolved_workspace)
|
|
192
|
+
token = _ACTIVE_LEDGER.set(ledger)
|
|
193
|
+
return ExecutionScope(resolved_workspace, ledger, token)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def end_execution_scope(scope: ExecutionScope) -> tuple[EvidenceEvent, ...]:
|
|
197
|
+
"""Close *scope*, restore any parent scope, and return immutable evidence."""
|
|
198
|
+
|
|
199
|
+
active = _ACTIVE_LEDGER.get()
|
|
200
|
+
if active is not scope._ledger or active.closed:
|
|
201
|
+
raise ExecutionGuardrailError("execution scopes must be ended once and in order")
|
|
202
|
+
snapshot = tuple(active.events)
|
|
203
|
+
try:
|
|
204
|
+
_ACTIVE_LEDGER.reset(scope._token)
|
|
205
|
+
except (RuntimeError, ValueError) as exc:
|
|
206
|
+
raise ExecutionGuardrailError("execution scope belongs to a different context") from exc
|
|
207
|
+
active.closed = True
|
|
208
|
+
return snapshot
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def evidence_snapshot() -> tuple[EvidenceEvent, ...]:
|
|
212
|
+
"""Return an immutable snapshot of the active scope's evidence."""
|
|
213
|
+
|
|
214
|
+
ledger = _ACTIVE_LEDGER.get()
|
|
215
|
+
return tuple(ledger.events) if ledger is not None and not ledger.closed else ()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def active_workspace() -> Path | None:
|
|
219
|
+
"""Return the active canonical workspace, if a scope is open."""
|
|
220
|
+
|
|
221
|
+
ledger = _ACTIVE_LEDGER.get()
|
|
222
|
+
return ledger.workspace if ledger is not None and not ledger.closed else None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def is_sensitive_path(path: str | Path) -> bool:
|
|
226
|
+
"""Return whether a path names credential, VCS, or private key material."""
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
normalized = str(path).replace("\\", "/")
|
|
230
|
+
parts = tuple(part.casefold() for part in Path(normalized).parts if part not in {"", "."})
|
|
231
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
232
|
+
return True
|
|
233
|
+
if not parts:
|
|
234
|
+
return True
|
|
235
|
+
if any(part in _SENSITIVE_COMPONENTS for part in parts):
|
|
236
|
+
return True
|
|
237
|
+
if any(part in _SENSITIVE_FILENAMES or part.startswith(".env.") for part in parts):
|
|
238
|
+
return True
|
|
239
|
+
name = parts[-1]
|
|
240
|
+
return any(name.endswith(suffix) for suffix in _SENSITIVE_SUFFIXES)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def assess_resolved_write_path(
|
|
244
|
+
resolved_workspace: Path,
|
|
245
|
+
resolved_target: Path,
|
|
246
|
+
*,
|
|
247
|
+
target_is_directory: bool = False,
|
|
248
|
+
) -> PathDecision:
|
|
249
|
+
"""Pure containment decision for paths already resolved by a caller/probe."""
|
|
250
|
+
|
|
251
|
+
if not isinstance(resolved_workspace, Path) or not isinstance(resolved_target, Path):
|
|
252
|
+
return PathDecision(False, "resolved paths are invalid")
|
|
253
|
+
if not resolved_workspace.is_absolute() or not resolved_target.is_absolute():
|
|
254
|
+
return PathDecision(False, "resolved paths must be absolute")
|
|
255
|
+
if resolved_workspace.parent == resolved_workspace:
|
|
256
|
+
return PathDecision(False, "filesystem root cannot be used as a workspace")
|
|
257
|
+
try:
|
|
258
|
+
relative = resolved_target.relative_to(resolved_workspace)
|
|
259
|
+
except (TypeError, ValueError):
|
|
260
|
+
return PathDecision(False, "target escapes the workspace")
|
|
261
|
+
relative_text = relative.as_posix()
|
|
262
|
+
if relative_text in {"", "."}:
|
|
263
|
+
return PathDecision(False, "target must name a file")
|
|
264
|
+
if ".." in relative.parts:
|
|
265
|
+
return PathDecision(False, "target escapes the workspace")
|
|
266
|
+
if target_is_directory:
|
|
267
|
+
return PathDecision(False, "target is a directory")
|
|
268
|
+
if is_sensitive_path(relative):
|
|
269
|
+
return PathDecision(
|
|
270
|
+
False,
|
|
271
|
+
"target is a sensitive path",
|
|
272
|
+
resolved_path=resolved_target,
|
|
273
|
+
relative_path=relative_text,
|
|
274
|
+
sensitive=True,
|
|
275
|
+
)
|
|
276
|
+
return PathDecision(
|
|
277
|
+
True,
|
|
278
|
+
"target is contained in the workspace",
|
|
279
|
+
resolved_path=resolved_target,
|
|
280
|
+
relative_path=relative_text,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def assess_write_path(
|
|
285
|
+
workspace: str | Path,
|
|
286
|
+
path: str | Path,
|
|
287
|
+
*,
|
|
288
|
+
resolve_probe: Callable[[Path, bool], Path] | None = None,
|
|
289
|
+
is_directory_probe: Callable[[Path], bool] | None = None,
|
|
290
|
+
) -> PathDecision:
|
|
291
|
+
"""Resolve and assess a prospective write without performing the write.
|
|
292
|
+
|
|
293
|
+
The optional probes make resolution and directory checks deterministic in
|
|
294
|
+
tests or alternate runtimes. Resolution failures always deny the write.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
def default_resolve(candidate: Path, strict: bool) -> Path:
|
|
298
|
+
return candidate.resolve(strict=strict)
|
|
299
|
+
|
|
300
|
+
resolver = resolve_probe or default_resolve
|
|
301
|
+
directory_probe = is_directory_probe or Path.is_dir
|
|
302
|
+
try:
|
|
303
|
+
workspace_input = Path(workspace).expanduser()
|
|
304
|
+
resolved_workspace = resolver(workspace_input, True)
|
|
305
|
+
if not directory_probe(resolved_workspace):
|
|
306
|
+
return PathDecision(False, "workspace is not a directory")
|
|
307
|
+
if resolved_workspace.parent == resolved_workspace:
|
|
308
|
+
return PathDecision(False, "filesystem root cannot be used as a workspace")
|
|
309
|
+
target_input = Path(path).expanduser()
|
|
310
|
+
if not target_input.is_absolute():
|
|
311
|
+
target_input = resolved_workspace / target_input
|
|
312
|
+
resolved_target = resolver(target_input, False)
|
|
313
|
+
target_is_directory = directory_probe(resolved_target)
|
|
314
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
315
|
+
return PathDecision(False, "path cannot be resolved safely")
|
|
316
|
+
return assess_resolved_write_path(
|
|
317
|
+
resolved_workspace,
|
|
318
|
+
resolved_target,
|
|
319
|
+
target_is_directory=target_is_directory,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def assess_active_write_path(path: str | Path) -> PathDecision:
|
|
324
|
+
"""Assess a path against the active run workspace, failing closed."""
|
|
325
|
+
|
|
326
|
+
workspace = active_workspace()
|
|
327
|
+
if workspace is None:
|
|
328
|
+
return PathDecision(False, "no active execution scope")
|
|
329
|
+
return assess_write_path(workspace, path)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _valid_evidence(events: Sequence[EvidenceEvent]) -> bool:
|
|
333
|
+
try:
|
|
334
|
+
expected = 1
|
|
335
|
+
for event in events:
|
|
336
|
+
if event.sequence != expected:
|
|
337
|
+
return False
|
|
338
|
+
expected += 1
|
|
339
|
+
if event.kind == "read":
|
|
340
|
+
if event.operation not in _READ_OPERATIONS or not event.relative_path or event.verification_kind:
|
|
341
|
+
return False
|
|
342
|
+
elif event.kind == "mutation":
|
|
343
|
+
if event.operation not in _MUTATION_OPERATIONS or not event.relative_path or event.verification_kind:
|
|
344
|
+
return False
|
|
345
|
+
elif event.kind == "verification":
|
|
346
|
+
if (
|
|
347
|
+
event.operation != "verification"
|
|
348
|
+
or event.relative_path
|
|
349
|
+
or event.verification_kind not in _VERIFICATION_KINDS
|
|
350
|
+
):
|
|
351
|
+
return False
|
|
352
|
+
else:
|
|
353
|
+
return False
|
|
354
|
+
except (AttributeError, TypeError):
|
|
355
|
+
return False
|
|
356
|
+
return True
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def evaluate_read_before_edit(
|
|
360
|
+
relative_path: str,
|
|
361
|
+
events: Sequence[EvidenceEvent],
|
|
362
|
+
) -> ReadBeforeEditDecision:
|
|
363
|
+
"""Pure decision requiring a read newer than any mutation of the file."""
|
|
364
|
+
|
|
365
|
+
if not relative_path or not _valid_evidence(events):
|
|
366
|
+
return ReadBeforeEditDecision(False, "execution evidence is invalid")
|
|
367
|
+
reads = [event for event in events if event.kind == "read" and event.relative_path == relative_path]
|
|
368
|
+
if not reads:
|
|
369
|
+
return ReadBeforeEditDecision(False, "edit requires a successful same-file read")
|
|
370
|
+
last_read = reads[-1]
|
|
371
|
+
mutations = [
|
|
372
|
+
event
|
|
373
|
+
for event in events
|
|
374
|
+
if event.kind == "mutation" and event.relative_path in {relative_path, "."}
|
|
375
|
+
]
|
|
376
|
+
if mutations and mutations[-1].sequence > last_read.sequence:
|
|
377
|
+
return ReadBeforeEditDecision(False, "edit requires a fresh read after the previous mutation")
|
|
378
|
+
return ReadBeforeEditDecision(True, "same-file read evidence is fresh", last_read.sequence)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def read_before_edit_decision(path: str | Path) -> ReadBeforeEditDecision:
|
|
382
|
+
"""Assess active-scope read evidence for a prospective edit."""
|
|
383
|
+
|
|
384
|
+
path_decision = assess_active_write_path(path)
|
|
385
|
+
if not path_decision.allowed or not path_decision.relative_path:
|
|
386
|
+
return ReadBeforeEditDecision(False, path_decision.reason)
|
|
387
|
+
return evaluate_read_before_edit(path_decision.relative_path, evidence_snapshot())
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _append_path_event(
|
|
391
|
+
kind: Literal["read", "mutation"],
|
|
392
|
+
path: str | Path,
|
|
393
|
+
*,
|
|
394
|
+
operation: str,
|
|
395
|
+
success: bool,
|
|
396
|
+
) -> EvidenceEvent | None:
|
|
397
|
+
if not success:
|
|
398
|
+
return None
|
|
399
|
+
ledger = _ACTIVE_LEDGER.get()
|
|
400
|
+
if ledger is None or ledger.closed:
|
|
401
|
+
return None
|
|
402
|
+
allowed_operations = _READ_OPERATIONS if kind == "read" else _PATH_MUTATION_OPERATIONS
|
|
403
|
+
if operation not in allowed_operations:
|
|
404
|
+
return None
|
|
405
|
+
decision = assess_write_path(ledger.workspace, path)
|
|
406
|
+
if not decision.allowed or not decision.relative_path:
|
|
407
|
+
return None
|
|
408
|
+
return ledger.append(kind, operation, relative_path=decision.relative_path)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def record_read(
|
|
412
|
+
path: str | Path,
|
|
413
|
+
*,
|
|
414
|
+
success: bool,
|
|
415
|
+
operation: str = "read_file",
|
|
416
|
+
) -> EvidenceEvent | None:
|
|
417
|
+
"""Record only a successful, contained, non-sensitive file read."""
|
|
418
|
+
|
|
419
|
+
return _append_path_event("read", path, operation=operation, success=success)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def record_mutation(
|
|
423
|
+
path: str | Path,
|
|
424
|
+
*,
|
|
425
|
+
success: bool,
|
|
426
|
+
operation: str,
|
|
427
|
+
) -> EvidenceEvent | None:
|
|
428
|
+
"""Record only a successful allowed model file mutation."""
|
|
429
|
+
|
|
430
|
+
return _append_path_event("mutation", path, operation=operation, success=success)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def record_workspace_mutation(
|
|
434
|
+
*,
|
|
435
|
+
success: bool,
|
|
436
|
+
operation: str = "run_shell",
|
|
437
|
+
) -> EvidenceEvent | None:
|
|
438
|
+
"""Record a successful workspace-wide mutation without retaining its command."""
|
|
439
|
+
|
|
440
|
+
if not success or operation != "run_shell":
|
|
441
|
+
return None
|
|
442
|
+
ledger = _ACTIVE_LEDGER.get()
|
|
443
|
+
if ledger is None or ledger.closed:
|
|
444
|
+
return None
|
|
445
|
+
return ledger.append("mutation", operation, relative_path=".")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _shell_split(command: str, *, platform_name: str | None = None) -> list[str]:
|
|
449
|
+
"""Split one shell command using the quoting rules of the target platform."""
|
|
450
|
+
|
|
451
|
+
platform = os.name if platform_name is None else platform_name
|
|
452
|
+
tokens = shlex.split(command, posix=platform != "nt")
|
|
453
|
+
if platform == "nt":
|
|
454
|
+
# ``cmd.exe`` uses double quotes for grouping and removes the outer
|
|
455
|
+
# pair before invoking Python. Single quotes are literal characters.
|
|
456
|
+
tokens = [
|
|
457
|
+
token[1:-1]
|
|
458
|
+
if len(token) >= 2 and token.startswith('"') and token.endswith('"')
|
|
459
|
+
else token
|
|
460
|
+
for token in tokens
|
|
461
|
+
]
|
|
462
|
+
return tokens
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _command_tokens(command: str) -> list[str] | None:
|
|
466
|
+
if not command.strip() or _CONTROL_OPERATOR_RE.search(command):
|
|
467
|
+
return None
|
|
468
|
+
try:
|
|
469
|
+
tokens = _shell_split(command)
|
|
470
|
+
except ValueError:
|
|
471
|
+
return None
|
|
472
|
+
while os.name != "nt" and tokens and _ENV_ASSIGNMENT_RE.match(tokens[0]):
|
|
473
|
+
tokens.pop(0)
|
|
474
|
+
return tokens or None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _unwrap_verification_shell(command: str) -> str:
|
|
478
|
+
"""Remove narrowly safe shell framing around a verifier command.
|
|
479
|
+
|
|
480
|
+
Models commonly express a working directory in-band even though
|
|
481
|
+
``run_shell`` also has a structured ``cwd`` argument. Accept one leading
|
|
482
|
+
``cd PATH &&`` and one trailing ``2>&1`` while leaving every other shell
|
|
483
|
+
operator for the fail-closed parser to reject.
|
|
484
|
+
"""
|
|
485
|
+
|
|
486
|
+
candidate = command.strip()
|
|
487
|
+
candidate = re.sub(r"\s+2\s*>\s*&\s*1\s*$", "", candidate).strip()
|
|
488
|
+
if "&&" not in candidate:
|
|
489
|
+
return candidate
|
|
490
|
+
parts = candidate.split("&&")
|
|
491
|
+
if len(parts) != 2:
|
|
492
|
+
return candidate
|
|
493
|
+
try:
|
|
494
|
+
prefix = _shell_split(parts[0].strip())
|
|
495
|
+
except ValueError:
|
|
496
|
+
return candidate
|
|
497
|
+
if len(prefix) == 2 and _executable_name(prefix[0]) == "cd" and prefix[1]:
|
|
498
|
+
return parts[1].strip()
|
|
499
|
+
if len(prefix) == 3 and _executable_name(prefix[0]) == "cd" and prefix[1] == "--" and prefix[2]:
|
|
500
|
+
return parts[1].strip()
|
|
501
|
+
return candidate
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _executable_name(token: str) -> str:
|
|
505
|
+
name = token.strip("\"'").replace("\\", "/").rsplit("/", 1)[-1].casefold()
|
|
506
|
+
for suffix in (".exe", ".cmd", ".bat", ".ps1"):
|
|
507
|
+
if name.endswith(suffix):
|
|
508
|
+
return name[: -len(suffix)]
|
|
509
|
+
return name
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _strip_runner(tokens: list[str]) -> list[str]:
|
|
513
|
+
first = _executable_name(tokens[0])
|
|
514
|
+
if first == "env":
|
|
515
|
+
tokens = tokens[1:]
|
|
516
|
+
while tokens and (_ENV_ASSIGNMENT_RE.match(tokens[0]) or tokens[0].startswith("-")):
|
|
517
|
+
tokens = tokens[1:]
|
|
518
|
+
return _strip_runner(tokens) if tokens else []
|
|
519
|
+
if first in {"uv", "poetry", "pipenv"}:
|
|
520
|
+
try:
|
|
521
|
+
run_index = tokens.index("run")
|
|
522
|
+
except ValueError:
|
|
523
|
+
return []
|
|
524
|
+
nested = tokens[run_index + 1 :]
|
|
525
|
+
while nested and nested[0].startswith("-"):
|
|
526
|
+
raw_option = nested.pop(0)
|
|
527
|
+
option = raw_option.split("=", 1)[0]
|
|
528
|
+
if "=" not in raw_option and option in _RUNNER_OPTIONS_WITH_VALUE:
|
|
529
|
+
if not nested:
|
|
530
|
+
return []
|
|
531
|
+
nested.pop(0)
|
|
532
|
+
elif option not in _RUNNER_OPTIONS_WITH_VALUE and option not in {
|
|
533
|
+
"--all-extras",
|
|
534
|
+
"--exact",
|
|
535
|
+
"--frozen",
|
|
536
|
+
"--isolated",
|
|
537
|
+
"--locked",
|
|
538
|
+
"--no-project",
|
|
539
|
+
"--no-sync",
|
|
540
|
+
"--offline",
|
|
541
|
+
}:
|
|
542
|
+
return []
|
|
543
|
+
return _strip_runner(nested) if nested else []
|
|
544
|
+
return tokens
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _assertion_script_qualifies(source: str) -> bool:
|
|
548
|
+
"""Return whether inline Python contains a fail-on-error check."""
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
tree = ast.parse(source)
|
|
552
|
+
except SyntaxError:
|
|
553
|
+
return False
|
|
554
|
+
for node in ast.walk(tree):
|
|
555
|
+
if isinstance(node, ast.Assert):
|
|
556
|
+
return True
|
|
557
|
+
if (
|
|
558
|
+
isinstance(node, ast.Call)
|
|
559
|
+
and isinstance(node.func, ast.Attribute)
|
|
560
|
+
and isinstance(node.func.value, ast.Name)
|
|
561
|
+
and node.func.value.id == "sys"
|
|
562
|
+
and node.func.attr == "exit"
|
|
563
|
+
and node.args
|
|
564
|
+
and not (
|
|
565
|
+
isinstance(node.args[0], ast.Constant)
|
|
566
|
+
and node.args[0].value in {0, None}
|
|
567
|
+
)
|
|
568
|
+
):
|
|
569
|
+
return True
|
|
570
|
+
return False
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _looks_like_verifier_script(token: str) -> bool:
|
|
574
|
+
return bool(_VERIFIER_SCRIPT_RE.fullmatch(_executable_name(token)))
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _inline_python_verification(
|
|
578
|
+
command: str,
|
|
579
|
+
*,
|
|
580
|
+
platform_name: str | None = None,
|
|
581
|
+
) -> VerificationCommand | None:
|
|
582
|
+
"""Classify a standalone Python ``-c`` check before newline rejection.
|
|
583
|
+
|
|
584
|
+
``shlex`` preserves newlines inside the quoted source as one argument, so
|
|
585
|
+
multiline assertion scripts remain unambiguous while extra shell segments
|
|
586
|
+
produce additional tokens and are rejected.
|
|
587
|
+
"""
|
|
588
|
+
|
|
589
|
+
platform = os.name if platform_name is None else platform_name
|
|
590
|
+
try:
|
|
591
|
+
tokens = _shell_split(command, platform_name=platform)
|
|
592
|
+
except ValueError:
|
|
593
|
+
return None
|
|
594
|
+
while platform != "nt" and tokens and _ENV_ASSIGNMENT_RE.match(tokens[0]):
|
|
595
|
+
tokens.pop(0)
|
|
596
|
+
if len(tokens) != 3 or not re.fullmatch(
|
|
597
|
+
r"python(?:\d+(?:\.\d+)*)?|py", _executable_name(tokens[0])
|
|
598
|
+
) or tokens[1].casefold() != "-c":
|
|
599
|
+
return None
|
|
600
|
+
if _assertion_script_qualifies(tokens[2]):
|
|
601
|
+
return VerificationCommand(True, "test", "inline assertions execute a fail-on-error check")
|
|
602
|
+
return VerificationCommand(False, None, "inline Python lacks fail-on-error assertions")
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def classify_verification_command(command: str) -> VerificationCommand:
|
|
606
|
+
"""Classify one shell command without retaining its text.
|
|
607
|
+
|
|
608
|
+
Compound/piped commands and discovery-only flags are rejected because a
|
|
609
|
+
zero shell status would not reliably prove the verifier itself passed. A
|
|
610
|
+
single working-directory wrapper and stderr-to-stdout redirect are allowed
|
|
611
|
+
because they preserve the verifier's exit status.
|
|
612
|
+
"""
|
|
613
|
+
|
|
614
|
+
unwrapped = _unwrap_verification_shell(command)
|
|
615
|
+
inline_python = _inline_python_verification(unwrapped)
|
|
616
|
+
if inline_python is not None:
|
|
617
|
+
return inline_python
|
|
618
|
+
tokens = _command_tokens(unwrapped)
|
|
619
|
+
if tokens is None:
|
|
620
|
+
return VerificationCommand(False, None, "command is empty, compound, or ambiguous")
|
|
621
|
+
tokens = _strip_runner(tokens)
|
|
622
|
+
if not tokens:
|
|
623
|
+
return VerificationCommand(False, None, "command runner does not invoke a verifier")
|
|
624
|
+
lowered = [token.casefold() for token in tokens]
|
|
625
|
+
if any(
|
|
626
|
+
flag in lowered
|
|
627
|
+
for flag in {
|
|
628
|
+
"--help",
|
|
629
|
+
"-h",
|
|
630
|
+
"--version",
|
|
631
|
+
"--collect-only",
|
|
632
|
+
"--co",
|
|
633
|
+
"--list-tests",
|
|
634
|
+
"--list-sessions",
|
|
635
|
+
"--notest",
|
|
636
|
+
"--showconfig",
|
|
637
|
+
"--dry-run",
|
|
638
|
+
"--print-config",
|
|
639
|
+
}
|
|
640
|
+
):
|
|
641
|
+
return VerificationCommand(False, None, "command does not execute verification")
|
|
642
|
+
if any(
|
|
643
|
+
flag in lowered
|
|
644
|
+
for flag in {
|
|
645
|
+
"--exit-zero",
|
|
646
|
+
"--no-error-on-unmatched-pattern",
|
|
647
|
+
"--pass-with-no-tests",
|
|
648
|
+
}
|
|
649
|
+
):
|
|
650
|
+
return VerificationCommand(False, None, "command disables verifier failure status")
|
|
651
|
+
|
|
652
|
+
executable = _executable_name(tokens[0])
|
|
653
|
+
if re.fullmatch(r"python(?:\d+(?:\.\d+)*)?|py", executable):
|
|
654
|
+
if len(tokens) >= 2 and _looks_like_verifier_script(tokens[1]):
|
|
655
|
+
return VerificationCommand(True, "test", "command executes a verifier script")
|
|
656
|
+
if len(lowered) < 3 or lowered[1] != "-m":
|
|
657
|
+
return VerificationCommand(False, None, "python command does not invoke a verifier module")
|
|
658
|
+
tokens = tokens[2:]
|
|
659
|
+
lowered = lowered[2:]
|
|
660
|
+
executable = _executable_name(tokens[0])
|
|
661
|
+
elif executable == "git":
|
|
662
|
+
if len(lowered) >= 3 and lowered[1] == "diff" and "--check" in lowered[2:]:
|
|
663
|
+
return VerificationCommand(True, "git_diff", "git diff --check is structural verification")
|
|
664
|
+
return VerificationCommand(False, None, "git command is not a fail-on-error git diff check")
|
|
665
|
+
|
|
666
|
+
if _looks_like_verifier_script(tokens[0]):
|
|
667
|
+
return VerificationCommand(True, "test", "command executes a verifier script")
|
|
668
|
+
|
|
669
|
+
if executable in {"pytest", "py.test", "unittest", "tox", "nox"}:
|
|
670
|
+
return VerificationCommand(True, "test", "command executes a test runner")
|
|
671
|
+
if executable in {"go", "cargo", "dotnet"}:
|
|
672
|
+
if executable == "go" and (len(lowered) < 2 or lowered[1] != "test"):
|
|
673
|
+
return VerificationCommand(False, None, "go command is not a test")
|
|
674
|
+
if executable == "cargo" and len(lowered) >= 2 and lowered[1] == "clippy":
|
|
675
|
+
return VerificationCommand(True, "lint", "command executes a lint check")
|
|
676
|
+
if executable == "cargo" and (len(lowered) < 2 or lowered[1] != "test"):
|
|
677
|
+
return VerificationCommand(False, None, "cargo command is not a test or lint check")
|
|
678
|
+
if executable == "dotnet" and (len(lowered) < 2 or lowered[1] != "test"):
|
|
679
|
+
return VerificationCommand(False, None, "dotnet command is not a test")
|
|
680
|
+
return VerificationCommand(True, "test", "command executes a test runner")
|
|
681
|
+
if executable in {"mvn", "mvnw"}:
|
|
682
|
+
goals = {token for token in lowered[1:] if not token.startswith("-")}
|
|
683
|
+
if goals & {"test", "verify"}:
|
|
684
|
+
return VerificationCommand(True, "test", "build goal executes tests")
|
|
685
|
+
return VerificationCommand(False, None, "build command lacks a test goal")
|
|
686
|
+
if executable in {"gradle", "gradlew"}:
|
|
687
|
+
tasks = {token for token in lowered[1:] if not token.startswith("-")}
|
|
688
|
+
if tasks & {"test", "check"}:
|
|
689
|
+
return VerificationCommand(True, "test", "build task executes tests")
|
|
690
|
+
return VerificationCommand(False, None, "build command lacks a test task")
|
|
691
|
+
|
|
692
|
+
if executable in {"npm", "pnpm", "yarn", "bun"}:
|
|
693
|
+
scripts = {"test", "lint", "check", "typecheck", "type-check"}
|
|
694
|
+
requested = next((token for token in lowered[1:] if not token.startswith("-")), "")
|
|
695
|
+
if requested == "run" and len(lowered) >= 3:
|
|
696
|
+
requested = lowered[2]
|
|
697
|
+
if requested in scripts:
|
|
698
|
+
kind = "test" if requested == "test" else "lint"
|
|
699
|
+
return VerificationCommand(True, kind, "package script executes verification")
|
|
700
|
+
return VerificationCommand(False, None, "package command is not a test or lint script")
|
|
701
|
+
|
|
702
|
+
if executable in {"make", "just"}:
|
|
703
|
+
targets = {token for token in lowered[1:] if not token.startswith("-")}
|
|
704
|
+
if "test" in targets:
|
|
705
|
+
return VerificationCommand(True, "test", "build target executes tests")
|
|
706
|
+
if targets & {"lint", "check", "typecheck", "type-check"}:
|
|
707
|
+
return VerificationCommand(True, "lint", "build target executes lint checks")
|
|
708
|
+
return VerificationCommand(False, None, "build command lacks a verification target")
|
|
709
|
+
|
|
710
|
+
if executable in {
|
|
711
|
+
"ruff",
|
|
712
|
+
"mypy",
|
|
713
|
+
"pyright",
|
|
714
|
+
"pylint",
|
|
715
|
+
"flake8",
|
|
716
|
+
"eslint",
|
|
717
|
+
"biome",
|
|
718
|
+
"shellcheck",
|
|
719
|
+
"golangci-lint",
|
|
720
|
+
}:
|
|
721
|
+
if executable in {"ruff", "biome"} and (len(lowered) < 2 or lowered[1] != "check"):
|
|
722
|
+
return VerificationCommand(False, None, "command does not execute a lint check")
|
|
723
|
+
if any(flag in lowered for flag in {"--fix", "--write"}):
|
|
724
|
+
return VerificationCommand(False, None, "mutating lint commands are not verification")
|
|
725
|
+
return VerificationCommand(True, "lint", "command executes a lint or type check")
|
|
726
|
+
if executable == "tsc" and "--noemit" in lowered:
|
|
727
|
+
return VerificationCommand(True, "lint", "command executes a no-emit type check")
|
|
728
|
+
return VerificationCommand(False, None, "command is not a recognized verifier")
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
_STATUS_MASKING_ECHO_RE = re.compile(
|
|
732
|
+
r"(?is)^(?P<base>.+?);\s*echo\s+(?:[\"'])?[^\n;]*\$\?[^\n;]*(?:[\"'])?\s*$"
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def masks_verification_exit_status(command: str) -> bool:
|
|
737
|
+
"""Return whether a verifier is followed by an echo that masks its status."""
|
|
738
|
+
|
|
739
|
+
match = _STATUS_MASKING_ECHO_RE.match(str(command or "").strip())
|
|
740
|
+
if match is None:
|
|
741
|
+
return False
|
|
742
|
+
return classify_verification_command(match.group("base").strip()).qualifies
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def record_verification(
|
|
746
|
+
verification_kind: str,
|
|
747
|
+
*,
|
|
748
|
+
success: bool,
|
|
749
|
+
) -> EvidenceEvent | None:
|
|
750
|
+
"""Record a successful recognized verifier without command/output data."""
|
|
751
|
+
|
|
752
|
+
if not success or verification_kind not in _VERIFICATION_KINDS:
|
|
753
|
+
return None
|
|
754
|
+
ledger = _ACTIVE_LEDGER.get()
|
|
755
|
+
if ledger is None or ledger.closed:
|
|
756
|
+
return None
|
|
757
|
+
return ledger.append("verification", "verification", verification_kind=verification_kind)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def record_shell_verification(command: str, *, returncode: int) -> EvidenceEvent | None:
|
|
761
|
+
"""Classify a shell invocation and record it only when it passed."""
|
|
762
|
+
|
|
763
|
+
if returncode != 0:
|
|
764
|
+
return None
|
|
765
|
+
classification = classify_verification_command(command)
|
|
766
|
+
if not classification.qualifies or classification.kind is None:
|
|
767
|
+
return None
|
|
768
|
+
return record_verification(classification.kind, success=True)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def evaluate_completion(events: Sequence[EvidenceEvent]) -> CompletionDecision:
|
|
772
|
+
"""Pure completion decision over an immutable or probe-supplied ledger."""
|
|
773
|
+
|
|
774
|
+
if not _valid_evidence(events):
|
|
775
|
+
return CompletionDecision(False, "execution evidence is invalid")
|
|
776
|
+
mutations = [event for event in events if event.kind == "mutation"]
|
|
777
|
+
if not mutations:
|
|
778
|
+
return CompletionDecision(True, "no successful file mutation requires verification")
|
|
779
|
+
last_mutation = mutations[-1]
|
|
780
|
+
for event in events:
|
|
781
|
+
if event.sequence <= last_mutation.sequence:
|
|
782
|
+
continue
|
|
783
|
+
if event.kind == "verification" and event.verification_kind in _VERIFICATION_KINDS:
|
|
784
|
+
return CompletionDecision(
|
|
785
|
+
True,
|
|
786
|
+
"last mutation was followed by successful verification",
|
|
787
|
+
last_mutation.sequence,
|
|
788
|
+
event.sequence,
|
|
789
|
+
event.verification_kind,
|
|
790
|
+
)
|
|
791
|
+
return CompletionDecision(
|
|
792
|
+
False,
|
|
793
|
+
"successful verification is required after the last mutation",
|
|
794
|
+
last_mutation.sequence,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def completion_decision(events: Iterable[EvidenceEvent] | None = None) -> CompletionDecision:
|
|
799
|
+
"""Evaluate completion for supplied evidence or the active run scope."""
|
|
800
|
+
|
|
801
|
+
if events is not None:
|
|
802
|
+
return evaluate_completion(tuple(events))
|
|
803
|
+
ledger = _ACTIVE_LEDGER.get()
|
|
804
|
+
if ledger is None or ledger.closed:
|
|
805
|
+
return CompletionDecision(False, "no active execution scope")
|
|
806
|
+
return evaluate_completion(tuple(ledger.events))
|