-
Notifications
You must be signed in to change notification settings - Fork 0
Answer two independent reviews: three regressions and a fallback #186
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a3360d4
221fb57
212d1bf
333c844
c0268fb
612dc7f
7442f8f
79cba95
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1592,9 +1592,62 @@ def _parse_upstream(value: object, where: str) -> UpstreamPin: | |
| raise PolicyError( | ||
| f"{where}: 'upstream.tool_schema_sha256' must be 'sha256:' followed by 64 hex chars" | ||
| ) | ||
| return UpstreamPin( | ||
| pin = UpstreamPin( | ||
| cert_sha256=tuple(digests), certs=tuple(files), tool_schema_sha256=schema_hash | ||
| ) | ||
| _check_pin_correspondence(pin, where) | ||
| return pin | ||
|
|
||
|
|
||
| def _check_pin_correspondence(pin: UpstreamPin, where: str) -> None: | ||
| """The two TLS halves must agree, checked at load (SPEC-v0.10 §4.2). | ||
|
|
||
| §4.2 measured what a half-moved rotation costs and then this check was not written, which an | ||
| independent review found: a document whose `tls_cert_file` still held only the old certificate | ||
| while `tls_cert_sha256` had both loaded cleanly and failed at the **handshake**, on the day an | ||
| operator believed they had prepared for. That is the outage §4.2 says the list prevents, | ||
| arriving one layer down. | ||
|
|
||
| So: every certificate `tls_cert_file` holds hashes to a digest `tls_cert_sha256` names, and a | ||
| path that does not exist is a load error rather than an empty trust store discovered at the | ||
| first connection. | ||
| """ | ||
| if not pin.certs: | ||
| return | ||
| import hashlib | ||
| import ssl | ||
|
|
||
| for path in pin.certs: | ||
| try: | ||
| der_list = [ | ||
| ssl.PEM_cert_to_DER_cert(block + "-----END CERTIFICATE-----") | ||
| for block in Path(path).read_text().split("-----END CERTIFICATE-----") | ||
| if "BEGIN CERTIFICATE" in block | ||
| ] | ||
| except OSError as unreadable: | ||
| raise PolicyError( | ||
| f"{where}: 'upstream.tls_cert_file' names {path!r}, which could not be read " | ||
| f"({unreadable.strerror}); a pin whose certificate is missing builds an empty " | ||
| "trust store and refuses every connection (SPEC-v0.10 §4.2)" | ||
| ) from unreadable | ||
| except ValueError as malformed: | ||
| raise PolicyError( | ||
| f"{where}: 'upstream.tls_cert_file' names {path!r}, which is not PEM: {malformed}" | ||
| ) from malformed | ||
| if not der_list: | ||
| raise PolicyError( | ||
| f"{where}: 'upstream.tls_cert_file' names {path!r}, which holds no certificate" | ||
| ) | ||
| if not pin.cert_sha256: | ||
| continue | ||
| digests = {"sha256:" + hashlib.sha256(der).hexdigest() for der in der_list} | ||
| if not digests & set(pin.cert_sha256): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Require every leaf digest pin to appear in the combined certificate-file digest set.
🤖 Prompt for AI Agents |
||
| raise PolicyError( | ||
| f"{where}: 'upstream.tls_cert_file' {path!r} hashes to " | ||
| f"{sorted(digests)[0]}, which 'upstream.tls_cert_sha256' does not name. The two " | ||
| "halves pin the same certificates or a rotation that moves one fails at the " | ||
| "handshake (SPEC-v0.10 §4.2)" | ||
| ) | ||
|
|
||
|
|
||
| def _parse_entry( | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -190,6 +190,17 @@ class ScanReport: | |||||||||
| vocabulary: tuple[str, ...] | ||||||||||
| policy_path: str | None | ||||||||||
| policy_read: bool | ||||||||||
| #: SPEC-v0.10 §6.4 — the principals holding a grant no hop bounds, in codepoint order. | ||||||||||
| #: | ||||||||||
| #: §2.3.2's residual is that CTRLRun cannot make a receiving agent present the hop it was | ||||||||||
| #: given: one holding a grant of its own can decline and act on that instead. The deployment | ||||||||||
| #: rule that collapses it is *an agent that only ever acts on handed-over work holds no root | ||||||||||
| #: grant of its own*, and without a surface that rule is advice. This is the surface. | ||||||||||
| #: | ||||||||||
| #: **It reports and does not score.** `v0.4 §3.9`'s rule that CTRLRun never grades an | ||||||||||
| #: operator's document holds here: a principal on this line is a fact, not a finding, and it | ||||||||||
| #: does not move `exit_code`. | ||||||||||
| root_grant_holders: tuple[str, ...] = () | ||||||||||
|
|
||||||||||
| @property | ||||||||||
| def exit_code(self) -> int: | ||||||||||
|
|
@@ -570,6 +581,7 @@ def scan( | |||||||||
| vocabulary=words, | ||||||||||
| policy_path=str(policy_path) if policy_path else None, | ||||||||||
| policy_read=policy_path is not None, | ||||||||||
| root_grant_holders=_root_grant_holders(policy_path), | ||||||||||
| ) | ||||||||||
|
|
||||||||||
|
|
||||||||||
|
|
@@ -587,6 +599,26 @@ def _finding_line(finding: Finding) -> str: | |||||||||
| return f" {where} {subject} [{finding.kind}: {finding.rule}]{detail}" | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def _root_grant_holders(policy_path: Path | None) -> tuple[str, ...]: | ||||||||||
| """Which principals the document grants authority no hop bounds (SPEC-v0.10 §6.4). | ||||||||||
|
|
||||||||||
| A **root** grant, meaning one written in the document rather than delegated at runtime: those | ||||||||||
| are the ones an agent holds whether or not anybody handed it work. A document with no | ||||||||||
| `authority:` section grants nothing and answers with nothing. | ||||||||||
| """ | ||||||||||
| if policy_path is None: | ||||||||||
| return () | ||||||||||
| from .authority import _optional_from_yaml | ||||||||||
|
|
||||||||||
| try: | ||||||||||
| authority = _optional_from_yaml(policy_path.read_text(), source=str(policy_path)) | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Read the policy with UTF-8 encoding.
Proposed fix- authority = _optional_from_yaml(policy_path.read_text(), source=str(policy_path))
+ authority = _optional_from_yaml(
+ policy_path.read_text(encoding="utf-8"), source=str(policy_path)
+ )📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| except Exception: | ||||||||||
| return () | ||||||||||
| if authority is None: | ||||||||||
| return () | ||||||||||
| return tuple(sorted({grant.subject.agent or "*" for grant in authority.grants.values()})) | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def report_lines(report: ScanReport) -> list[str]: | ||||||||||
| """The human rendering. Every finding in the document has a line here (§5.3, T204).""" | ||||||||||
| lines = [f"ctrlrun scan — {report.root}", ""] | ||||||||||
|
|
@@ -597,6 +629,15 @@ def report_lines(report: ScanReport) -> list[str]: | |||||||||
| lines.append(f"{kind} ({len(found)})") | ||||||||||
| lines.extend(_finding_line(finding) for finding in found) | ||||||||||
| lines.append("") | ||||||||||
| if report.root_grant_holders: | ||||||||||
| # SPEC-v0.10 §6.4. A fact about the document, not a finding: it does not move the exit | ||||||||||
| # code, and `v0.4 §3.9` is why there is no verdict attached to it. | ||||||||||
| lines.append(f"holds a root grant ({len(report.root_grant_holders)})") | ||||||||||
| lines.extend(f" {agent}" for agent in report.root_grant_holders) | ||||||||||
| lines.append( | ||||||||||
| " an agent that only ever acts on handed-over work holds none (SPEC-v0.10 §2.3.2)" | ||||||||||
| ) | ||||||||||
| lines.append("") | ||||||||||
| if report.undetermined: | ||||||||||
| lines.append(f"undetermined ({len(report.undetermined)})") | ||||||||||
| lines.extend( | ||||||||||
|
|
@@ -652,6 +693,9 @@ def report_document(report: ScanReport) -> dict[str, Any]: | |||||||||
| {"file": call.file, "line": call.line, "expression": call.expression} | ||||||||||
| for call in report.undetermined | ||||||||||
| ], | ||||||||||
| # SPEC-v0.10 §6.4 — a fact about the document, additive, and outside `findings` because | ||||||||||
| # it is not one: `v0.4 §3.9` keeps `scan` from grading an operator's choices. | ||||||||||
| "root_grant_holders": list(report.root_grant_holders), | ||||||||||
| "totals": { | ||||||||||
| "files_read": report.files_read, | ||||||||||
| "files_excluded": report.files_excluded, | ||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: CTRLRun/ctrlrun
Length of output: 34491
🏁 Script executed:
Repository: CTRLRun/ctrlrun
Length of output: 11353
Authorization Bypass
Reachability: External
Exploitability: Moderate
CWE: CWE-863 — Incorrect Authorization
Replace tool observations only after a complete
tools/listsequence. MCP permits cursor pagination. The current per-tool writes retain entries omitted from a later completed listing.Controlcan then accept a call using the stale schema digest. Accumulate valid pages and atomically replace the upstream entries only after the final page has nonextCursor. Keep the register unchanged for malformed responses and incomplete sequences.🤖 Prompt for AI Agents