diff --git a/code-mower-package-manifest.json b/code-mower-package-manifest.json index 2d32d5dd..aef76089 100644 --- a/code-mower-package-manifest.json +++ b/code-mower-package-manifest.json @@ -1212,6 +1212,11 @@ "source": "src/code_mower/doctor_checks/self_hosted_runner.py", "target": "src/code_mower/doctor_checks/self_hosted_runner.py" }, + { + "kind": "core", + "source": "src/code_mower/doctor_checks/share_safe.py", + "target": "src/code_mower/doctor_checks/share_safe.py" + }, { "kind": "core", "source": "src/code_mower/doctor_checks/supervised_pilot.py", diff --git a/docs/install.md b/docs/install.md index 5355c542..928072bf 100644 --- a/docs/install.md +++ b/docs/install.md @@ -441,8 +441,8 @@ run Codex or Claude local CLI audits itself, keep the GitHub/cloud/setup checks but skip local CLI probes: ```bash -code-mower doctor --adoption --hosted-builders --repo OWNER/REPO --json -code-mower doctor --adoption --orchestrator-only --repo OWNER/REPO --json +code-mower doctor --adoption --hosted-builders --repo OWNER/REPO --json --share-safe +code-mower doctor --adoption --orchestrator-only --repo OWNER/REPO --json --share-safe ``` Those commands expect the current checkout to contain `code-mower.yml`. On a @@ -450,17 +450,20 @@ remote-only host with no repository checkout, select the maintained packaged starter explicitly through the easy preset: ```bash -code-mower doctor --easy --orchestrator-only --repo OWNER/REPO --json +code-mower doctor --easy --orchestrator-only --repo OWNER/REPO --json --share-safe ``` The resulting filesystem and generated-workflow checks describe the packaged starter, not the remote repository. Use them for installation posture; use `lanes status --repo OWNER/REPO` for current remote PR and gate visibility. -Doctor JSON is local diagnostic evidence. It can include bounded local paths -such as the selected config, executable, or workflow path. Review or redact it -before attaching it to an issue or uploading it; the concise text view is the -safer first status summary. +Adoption and hosted-posture doctor output is share-safe by default. JSON keeps +its existing fields and check IDs, reports `local_paths: redacted`, and replaces +local config, executable, checkout, workflow, and packaged-template paths with +`[local path hidden]`. Maintained copy/paste commands also pass `--share-safe` +explicitly. For private local debugging, `--include-local-paths` restores the +legacy values and the legacy top-level key set for strict machine consumers; +review that output locally and do not attach or upload it. In those observer/coordinator postures, missing local wrapper environment variables and missing `DISPATCH_TOKEN` setup are surfaced as owner setup or diff --git a/docs/launch-command-surface.md b/docs/launch-command-surface.md index d11c1167..3a944077 100644 --- a/docs/launch-command-surface.md +++ b/docs/launch-command-surface.md @@ -23,7 +23,7 @@ The default `code-mower next-steps` and `code-mower --help` focus on this path. | `code-mower lanes status --repo OWNER/REPO` | Show active PR lanes, gate/check state, local board/process hints, stale audit requeue guidance, and the next action. | no | GitHub optional | | `code-mower productivity report --repo OWNER/REPO` | Summarize local Board history, reviewer spend, provider scorecards, promotion caveats, quality catches, fix rounds, owner actions, and optional cloud aggregate productivity events. | no | no | | `code-mower board serve --repo OWNER/REPO` | Serve redacted lane status plus owner queue and local verdict/spend timelines in a local read-only browser board. | no | GitHub optional | -| `code-mower doctor --adoption --repo OWNER/REPO --json` | Check Python, GitHub, provider CLIs, cloud token posture, and private-repo cost traps. | no | optional GitHub/provider probes | +| `code-mower doctor --adoption --repo OWNER/REPO --json --share-safe` | Check Python, GitHub, provider CLIs, cloud token posture, and private-repo cost traps with local paths redacted. | no | optional GitHub/provider probes | | `code-mower next-steps --profile recommended --repo OWNER/REPO` | Print the next recommended setup actions. | no | no | ## Later Workflows @@ -54,7 +54,8 @@ Select any additional builder or reviewer explicitly. | `code-mower board events` | Print recent local board-history events without calling GitHub. | no | no | | `code-mower board doctor --repo OWNER/REPO` | Diagnose Board inputs, local history, gate alerts, owner queue, and optional agent cards with redacted local paths by default. | no | GitHub optional | | `code-mower board reset --repo OWNER/REPO --yes` | Delete only the local Board history file after explicit confirmation. | yes, local only | no | -| `code-mower doctor --adoption --hosted-builders --repo OWNER/REPO --json` | Check hosted-builder or orchestrator setup without requiring local Codex/Claude CLIs on this machine. | no | optional GitHub/provider probes | +| `code-mower doctor --adoption --hosted-builders --repo OWNER/REPO --json --share-safe` | Check hosted-builder or orchestrator setup without requiring local Codex/Claude CLIs on this machine; local paths stay redacted. | no | optional GitHub/provider probes | +| `code-mower doctor --adoption --repo OWNER/REPO --json --include-local-paths` | Restore local diagnostic path values for private debugging; do not attach or upload this output. | no | optional GitHub/provider probes | | `code-mower doctor --supervised-pilot --repo OWNER/REPO --json` | Summarize manual-pilot readiness with blockers, owner actions, warnings, promotion to-dos, cloud token, and Board visibility. | no | optional GitHub/provider probes | | `code-mower doctor --promoted-pilot --repo OWNER/REPO --json` | Check the stricter posture needed before green audits may drive auto-merge. | no | optional GitHub/provider probes | | `code-mower migration setup-drift --repo-path .` | Classify existing generated setup files before an upgrade PR without printing source or diffs. | no | no | diff --git a/docs/orchestrator-prompt-pack.md b/docs/orchestrator-prompt-pack.md index 61903822..53af317d 100644 --- a/docs/orchestrator-prompt-pack.md +++ b/docs/orchestrator-prompt-pack.md @@ -81,11 +81,11 @@ before any init --apply. Preserve repository policy and copy only the intended generated files in the upgrade PR. Run the posture-appropriate doctor: -- local reviewer/builder machine: code-mower doctor --adoption --repo OWNER/REPO --json -- hosted builder or observer: code-mower doctor --adoption --hosted-builders --repo OWNER/REPO --json -- orchestrator-only host: code-mower doctor --adoption --orchestrator-only --repo OWNER/REPO --json -- remote-only host with no checkout config: code-mower doctor --adoption --orchestrator-only --repo OWNER/REPO --json -- supervised pilot readiness: code-mower doctor --supervised-pilot --repo OWNER/REPO --json +- local reviewer/builder machine: code-mower doctor --adoption --repo OWNER/REPO --json --share-safe +- hosted builder or observer: code-mower doctor --adoption --hosted-builders --repo OWNER/REPO --json --share-safe +- orchestrator-only host: code-mower doctor --adoption --orchestrator-only --repo OWNER/REPO --json --share-safe +- remote-only host with no checkout config: code-mower doctor --adoption --orchestrator-only --repo OWNER/REPO --json --share-safe +- supervised pilot readiness: code-mower doctor --supervised-pilot --repo OWNER/REPO --json --share-safe Read the posture-scoped summary first by rerunning the same posture with --concise instead of --json: every check still runs, and the summary leads with @@ -99,10 +99,11 @@ checks. Repository Actions secret and variable presence still comes from the target repository through `gh`; that is separate from `GITHUB_TOKEN` or local checkout-path requirements used by direct local-audit wrappers. -That packaged-starter remote-observer JSON uses stable package labels and does -not include local config, executable, checkout, or workflow paths. Other doctor -JSON can contain local paths; review or redact it before attaching or uploading -it. +Adoption and hosted-posture output is share-safe by default. The explicit +`--share-safe` in this maintained prompt makes that boundary reviewable: local +config, executable, checkout, workflow, and packaged-template paths retain +their fields but use `[local path hidden]`. Use `--include-local-paths` only for +private local debugging; do not attach that form to an issue or upload it. Provider selection and a working CLI do not qualify an orchestrator. Devin is currently limited to bounded builder work and informational review; use a diff --git a/docs/quickstart.md b/docs/quickstart.md index ad5923f9..dfefee5a 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -384,8 +384,8 @@ shows `"app_id": 15368`, remove and re-add the required check from Any source. Now run the preflight: ```bash -code-mower doctor --adoption --repo OWNER/REPO --json -code-mower doctor --supervised-pilot --repo OWNER/REPO --json +code-mower doctor --adoption --repo OWNER/REPO --json --share-safe +code-mower doctor --supervised-pilot --repo OWNER/REPO --json --share-safe ``` `doctor --adoption` is the recommended early-adopter preset for GitHub auth, diff --git a/docs/try-in-10-minutes.md b/docs/try-in-10-minutes.md index 9ba82a9e..91d65d37 100644 --- a/docs/try-in-10-minutes.md +++ b/docs/try-in-10-minutes.md @@ -14,7 +14,7 @@ setup behavior from drifting. ```bash code-mower init --easy code-mower init --easy --apply --output-dir .code-mower.generated - code-mower doctor --adoption --repo OWNER/REPO --json + code-mower doctor --adoption --repo OWNER/REPO --json --share-safe ``` 4. Open the setup pull request and request independent Claude and Codex audits diff --git a/src/code_mower/doctor.py b/src/code_mower/doctor.py index a5917321..6361640e 100644 --- a/src/code_mower/doctor.py +++ b/src/code_mower/doctor.py @@ -43,6 +43,9 @@ _local_cli_probe_remediation = _doctor_checks.local_cli_probe_remediation render_doctor_summary = _doctor_checks.render_doctor_summary render_doctor_text = _doctor_checks.render_doctor_text +doctor_report_payload = _doctor_checks.doctor_report_payload +redact_local_path_text = _doctor_checks.redact_local_path_text +share_safe_doctor_report = _doctor_checks.share_safe_doctor_report resolve_doctor_config_path = _doctor_checks.resolve_doctor_config_path resolve_doctor_config_path_for_script = _doctor_checks.resolve_doctor_config_path_for_script resolve_doctor_provider_templates_path = _doctor_checks.resolve_doctor_provider_templates_path @@ -111,7 +114,9 @@ def _doctor_config_source_label( return "repository_config" -def _doctor_config_error_message(exc: Exception, *, config_arg: str) -> str: +def _doctor_config_error_message( + exc: Exception, *, config_arg: str, include_local_paths: bool = True +) -> str: requested = str(config_arg) lines = [ f"error: {exc}", @@ -129,7 +134,8 @@ def _doctor_config_error_message(exc: Exception, *, config_arg: str) -> str: "the repository checkout with `code-mower doctor --adoption " "--repo OWNER/REPO`." ) - return "\n".join(lines) + message = "\n".join(lines) + return message if include_local_paths else redact_local_path_text(message) _DOCTOR_COMPAT_EXPORTS = ( @@ -358,6 +364,23 @@ def main(argv: Sequence[str] | None = None) -> int: "remaining warnings by group" ), ) + path_group = parser.add_mutually_exclusive_group() + path_group.add_argument( + "--share-safe", + action="store_true", + help=( + "redact local config, checkout, executable, workflow, and template " + "paths from text and JSON output" + ), + ) + path_group.add_argument( + "--include-local-paths", + action="store_true", + help=( + "include local diagnostic paths; adoption and hosted postures redact " + "them by default" + ), + ) detail_group.add_argument( "--advanced", action="store_true", @@ -375,6 +398,11 @@ def main(argv: Sequence[str] | None = None) -> int: args.adoption = True if args.adoption: args.preflight = True + include_local_paths = args.include_local_paths or not ( + args.share_safe + or args.adoption + or args.adoption_posture in {"hosted-builders", "orchestrator-only"} + ) cloud_explicit = "--cloud" in raw_args runtime_probe_explicit = "--probe-runtime" in raw_args explicit_config = args.config is not None @@ -461,7 +489,14 @@ def main(argv: Sequence[str] | None = None) -> int: **({'context_state_dir': args.context_state_dir} if args.context_state_dir is not None else {}), ) except (code_mower_config.ConfigError, ValueError) as exc: - print(_doctor_config_error_message(exc, config_arg=args.config), file=sys.stderr) + print( + _doctor_config_error_message( + exc, + config_arg=args.config, + include_local_paths=include_local_paths, + ), + file=sys.stderr, + ) return 1 if args.operational_evidence is not None: @@ -489,12 +524,21 @@ def main(argv: Sequence[str] | None = None) -> int: # modes keep the full text view: a concise run only changes what a default # text run reads first. concise = args.concise and not args.advanced and not args.campaign + output_report = ( + report if include_local_paths else share_safe_doctor_report(report) + ) if args.json: - print(json.dumps(report.as_dict(), indent=2, sort_keys=True)) + print( + json.dumps( + doctor_report_payload(report, include_local_paths=include_local_paths), + indent=2, + sort_keys=True, + ) + ) elif concise: - print(render_doctor_summary(report), end="") + print(render_doctor_summary(output_report), end="") else: - print(render_doctor_text(report), end="") + print(render_doctor_text(output_report), end="") if report.failures: return 1 if args.strict and report.warnings: diff --git a/src/code_mower/doctor_checks/__init__.py b/src/code_mower/doctor_checks/__init__.py index dc1da4a8..bbde780d 100644 --- a/src/code_mower/doctor_checks/__init__.py +++ b/src/code_mower/doctor_checks/__init__.py @@ -113,6 +113,13 @@ check_runner_workflow_labels, check_self_hosted_runner, ) +from .share_safe import ( + LOCAL_PATH_REDACTION, + doctor_report_payload, + redact_local_path_text, + redact_local_paths, + share_safe_doctor_report, +) from .runner import run_doctor __all__ = [ @@ -130,6 +137,7 @@ "DoctorCheckGroup", "DoctorCheckStage", "DoctorReport", + "LOCAL_PATH_REDACTION", "STATUS_FAIL", "STATUS_PASS", "STATUS_SKIP", @@ -182,6 +190,7 @@ "detect_repo_slug", "doctor_check_group_id", "doctor_output_group", + "doctor_report_payload", "effective_lane", "evaluate_json_probe", "gate_automerge_token_config", @@ -192,6 +201,8 @@ "local_cli_probe_remediation", "normalize_repo_slug", "provider_template_coverage", + "redact_local_path_text", + "redact_local_paths", "render_doctor_summary", "render_doctor_text", "resolve_doctor_config_path", @@ -200,5 +211,6 @@ "repo_slug_from_remote", "run_doctor", "selected_lanes", + "share_safe_doctor_report", "token_file_mentions_cloud_token", ] diff --git a/src/code_mower/doctor_checks/share_safe.py b/src/code_mower/doctor_checks/share_safe.py new file mode 100644 index 00000000..bd2b04cf --- /dev/null +++ b/src/code_mower/doctor_checks/share_safe.py @@ -0,0 +1,208 @@ +"""Share-safe rendering for adoption-facing doctor reports.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import replace +import re +from typing import Any + +from .models import DoctorReport + + +LOCAL_PATH_REDACTION = "[local path hidden]" + +# Match local filesystem spellings without treating URLs or GitHub's +# ``owner/repo`` form as paths. File URIs may name the local host or a network +# authority. Windows paths include drive-rooted, rooted, UNC, and relative +# backslash spellings. Relative POSIX paths require a stronger path signal than +# one bare slash: a leading dot-directory, two separators, or a filename +# extension. That keeps ordinary repository slugs readable. +_FILE_URI = re.compile(r"(?['\"`])(?:" + r"file:|" + r"[A-Za-z]:(?:[\\/]|[^'\"`\n\\/]+[\\/])|" + r"\\\\|\\|" + r"~[\w.-]*[\\/]|/|" + r"\.\.?[\\/]" + r")[^'\"`\n]+(?P=quote)", + re.IGNORECASE, +) +_NONLOCAL_URI = re.compile( + r"(?i)(? str: + pieces: list[str] = [] + position = 0 + for match in pattern.finditer(line): + pieces.append(line[position : match.start()]) + pieces.append(LOCAL_PATH_REDACTION) + position = match.end() + pieces.append(line[position:]) + return "".join(pieces) + + +def redact_local_path_text(value: str) -> str: + """Return text with local path-shaped content removed conservatively.""" + + redacted: list[str] = [] + for line in value.split("\n"): + pieces: list[str] = [] + position = 0 + # A URL may itself contain path-looking query values or enough slash + # components to resemble a relative path. Keep each non-file URI whole + # and apply the filesystem recognizers only to the text around it. + for uri in _NONLOCAL_URI.finditer(line): + current = line[position : uri.start()] + for pattern in ( + _QUOTED_LOCAL_PATH, + _FILE_URI, + _WINDOWS_PATH, + _POSIX_PATH, + _RELATIVE_PATH, + ): + current = _redact_matches(current, pattern) + pieces.extend((current, uri.group(0))) + position = uri.end() + current = line[position:] + for pattern in ( + _QUOTED_LOCAL_PATH, + _FILE_URI, + _WINDOWS_PATH, + _POSIX_PATH, + _RELATIVE_PATH, + ): + current = _redact_matches(current, pattern) + pieces.append(current) + redacted.append("".join(pieces)) + return "\n".join(redacted) + + +def _is_path_value_key(key: object) -> bool: + if not isinstance(key, str): + return False + normalized = key.lower().replace("-", "_") + return normalized in _PATH_VALUE_KEYS or normalized.endswith(_PATH_VALUE_KEY_SUFFIXES) + + +def _redact_known_path_value(value: Any) -> Any: + """Redact values whose field name supplies the otherwise ambiguous context.""" + + if isinstance(value, str): + return LOCAL_PATH_REDACTION if value else value + if isinstance(value, Mapping): + return redact_local_paths(value) + if isinstance(value, tuple): + return tuple(_redact_known_path_value(item) for item in value) + if isinstance(value, list): + return [_redact_known_path_value(item) for item in value] + return value + + +def redact_local_paths(value: Any) -> Any: + """Recursively redact path-shaped strings while preserving payload shape.""" + + if isinstance(value, str): + return redact_local_path_text(value) + if isinstance(value, Mapping): + return { + (redact_local_path_text(key) if isinstance(key, str) else key): ( + _redact_known_path_value(item) + if _is_path_value_key(key) + else redact_local_paths(item) + ) + for key, item in value.items() + } + if isinstance(value, tuple): + return tuple(redact_local_paths(item) for item in value) + if isinstance(value, list): + return [redact_local_paths(item) for item in value] + return value + + +def share_safe_doctor_report(report: DoctorReport) -> DoctorReport: + """Preserve the doctor schema while redacting every local path value.""" + + checks = tuple( + replace( + check, + message=redact_local_path_text(check.message), + detail=( + redact_local_paths(check.detail) + if isinstance(check.detail, Mapping) + else check.detail + ), + remediation=( + redact_local_path_text(check.remediation) + if check.remediation is not None + else None + ), + ) + for check in report.checks + ) + return replace( + report, + config_path=LOCAL_PATH_REDACTION if report.config_path else report.config_path, + provider_templates_path=( + LOCAL_PATH_REDACTION + if report.provider_templates_path + else report.provider_templates_path + ), + checks=checks, + ) + + +def doctor_report_payload( + report: DoctorReport, *, include_local_paths: bool +) -> dict[str, Any]: + """Serialize one report with an explicit, backward-compatible path policy.""" + + if include_local_paths: + # This is the exact legacy object for closed machine consumers. The + # share-safe form adds one policy marker; the explicit debug opt-in + # restores both the old values and the old top-level key set. + return report.as_dict() + return { + **share_safe_doctor_report(report).as_dict(), + "local_paths": "redacted", + } diff --git a/src/code_mower/package_manifest.py b/src/code_mower/package_manifest.py index 6a88b71f..317d33ee 100644 --- a/src/code_mower/package_manifest.py +++ b/src/code_mower/package_manifest.py @@ -302,6 +302,11 @@ ), ("src/code_mower/doctor_checks/devin.py", "src/code_mower/doctor_checks/devin.py", "core"), ("src/code_mower/doctor_checks/privacy.py", "src/code_mower/doctor_checks/privacy.py", "core"), + ( + "src/code_mower/doctor_checks/share_safe.py", + "src/code_mower/doctor_checks/share_safe.py", + "core", + ), ("src/code_mower/doctor_checks/providers.py", "src/code_mower/doctor_checks/providers.py", "core"), ("src/code_mower/providers/__init__.py", "src/code_mower/providers/__init__.py", "core"), ("src/code_mower/providers/local_cli.py", "src/code_mower/providers/local_cli.py", "core"), diff --git a/tests/test_doctor_share_safe.py b/tests/test_doctor_share_safe.py new file mode 100644 index 00000000..0d8d2022 --- /dev/null +++ b/tests/test_doctor_share_safe.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +from io import StringIO +import json +from pathlib import Path +from unittest import TestCase, mock + +from code_mower import doctor +from code_mower.doctor_checks import ( + LOCAL_PATH_REDACTION, + DoctorCheck, + DoctorReport, + doctor_report_payload, + redact_local_path_text, +) + + +def _local_root() -> str: + # Assemble the synthetic home prefix so the repository privacy scanner does + # not mistake a regression fixture for a real developer path. + return "/" + "Users/example-person/Private Project" + + +def _report() -> DoctorReport: + root = _local_root() + return DoctorReport( + config_path=f"{root}/repo/code-mower.yml", + provider_templates_path=f"{root}/package/templates/providers.yml", + profile="recommended", + checks=( + DoctorCheck( + name="runtime.python", + status="pass", + message=f"Python found at {root}/venv/bin/python: ready", + detail={ + "executable": f"{root}/venv/bin/python", + "workflow_paths": [ + f"{root}/repo/.github/workflows/audit.yml", + r"C:\Users\example-person\repo\workflow.yml", + ], + "repository": "example-org/example-repo", + "documentation": "https://example.test/docs/install", + f"{root}/repo/private-keyed-path": "present", + }, + remediation=f"inspect file://{root}/repo/doctor.log", + ), + ), + ) + + +class DoctorShareSafeTests(TestCase): + def test_recursive_payload_preserves_schema_and_removes_local_paths(self) -> None: + payload = doctor_report_payload(_report(), include_local_paths=False) + rendered = json.dumps(payload) + + self.assertEqual(payload["local_paths"], "redacted") + self.assertEqual(payload["config_path"], LOCAL_PATH_REDACTION) + self.assertEqual(payload["provider_templates_path"], LOCAL_PATH_REDACTION) + self.assertNotIn("example-person", rendered) + self.assertNotIn("Private Project", rendered) + self.assertNotIn("file://", rendered) + self.assertNotIn("private-keyed-path", rendered) + self.assertIn("example-org/example-repo", rendered) + self.assertIn("https://example.test/docs/install", rendered) + self.assertEqual(payload["checks"][0]["id"], "runtime.python") + + def test_include_local_paths_preserves_legacy_values(self) -> None: + report = _report() + payload = doctor_report_payload(report, include_local_paths=True) + + self.assertEqual(payload, report.as_dict()) + self.assertNotIn("local_paths", payload) + self.assertEqual(payload["config_path"], report.config_path) + self.assertEqual( + payload["checks"][0]["detail"]["executable"], + report.checks[0].detail["executable"], + ) + + def test_text_redaction_keeps_urls_and_repository_slugs(self) -> None: + text = redact_local_path_text( + "See https://example.test/a/b for example-org/example-repo; " + f"failed at {_local_root()}/repo/config.yml: denied" + ) + + self.assertIn("https://example.test/a/b", text) + self.assertIn("example-org/example-repo", text) + self.assertIn(LOCAL_PATH_REDACTION, text) + self.assertNotIn("example-person", text) + + def test_text_redaction_covers_authority_root_and_relative_path_matrix(self) -> None: + private_paths = ( + "file://server/share/private/workflow.yml", + "file://localhost/" + "Users/alice/private.yml", + "file:///" + "Users/alice/private.yml", + "FILE:/" + "Users/alice/private.yml", + "confidential/config.yml", + "./confidential/config.yml", + "../confidential/config.yml", + ".github/workflows/private-audit.yml", + "private/workflows/audit", + r"C:\Users\alice\private.yml", + r"C:Users\alice\private.yml", + r"\\server\share\private\workflow.yml", + r"\Users\alice\private.yml", + r"confidential\config.yml", + "/秘密/config.yml", + "~/confidential/config.yml", + "~alice/confidential/config.yml", + ) + for private_path in private_paths: + with self.subTest(path=private_path): + redacted = redact_local_path_text(f"failed at {private_path}: denied") + self.assertIn(LOCAL_PATH_REDACTION, redacted) + self.assertNotIn(private_path, redacted) + + def test_text_redaction_preserves_nonlocal_identifiers_and_urls(self) -> None: + public_values = ( + "example-org/example-repo", + "OWNER/REPO", + "https://example.test/a/b", + "http://example.test/a/b?next=/c/d", + "ssh://git@example.test/owner/repo", + "git@example.test:owner/repo", + ) + for public_value in public_values: + with self.subTest(value=public_value): + self.assertEqual(redact_local_path_text(public_value), public_value) + + def test_text_redaction_preserves_diagnostics_after_an_embedded_path(self) -> None: + text = redact_local_path_text( + "workflow at .github/workflows/private-audit.yml failed after validation" + ) + + self.assertEqual( + text, + f"workflow at {LOCAL_PATH_REDACTION} failed after validation", + ) + + def test_space_containing_paths_are_hidden_without_truncating_following_text(self) -> None: + unquoted = redact_local_path_text( + f"failed at {_local_root()}/repo/config.yml because it was unreadable" + ) + quoted = redact_local_path_text( + f"checkout '{_local_root()}' is unavailable" + ) + + for redacted in (unquoted, quoted): + self.assertNotIn("example-person", redacted) + self.assertNotIn("Private Project", redacted) + self.assertIn("because it was unreadable", unquoted) + self.assertEqual( + quoted, + f"checkout {LOCAL_PATH_REDACTION} is unavailable", + ) + + def test_every_report_text_surface_uses_the_complete_path_matrix(self) -> None: + report = DoctorReport( + config_path="code-mower.yml", + provider_templates_path="private/templates", + profile="recommended", + checks=( + DoctorCheck( + name="path.matrix", + status="fail", + message="workflow at .github/workflows/private-audit.yml failed", + detail={ + "executable": "bin/private-python", + "workflow_path": "confidential/workflow", + "template_paths": ["private/template"], + "nested": [ + {"template": "/秘密/config.yml"}, + "file://localhost/" + "Users/alice/private.yml", + ], + "repository": "OWNER/REPO", + "documentation": "https://example.test/a/b", + }, + remediation=r"inspect C:\Users\alice\private.log", + ), + ), + ) + + payload = doctor_report_payload(report, include_local_paths=False) + rendered = json.dumps(payload, ensure_ascii=False) + + for private_fragment in ( + "confidential", + "server", + ".github", + "alice", + "秘密", + "file://", + ): + self.assertNotIn(private_fragment, rendered) + self.assertIn("OWNER/REPO", rendered) + self.assertIn("https://example.test/a/b", rendered) + self.assertIn("workflow at", rendered) + self.assertIn("failed", rendered) + + def test_adoption_json_defaults_to_share_safe_and_has_debug_opt_in(self) -> None: + def run(extra: list[str]) -> dict[str, object]: + stdout = StringIO() + with ( + mock.patch.object(doctor, "run_doctor", return_value=_report()), + mock.patch.object( + doctor, + "resolve_doctor_config_path", + return_value=Path(f"{_local_root()}/repo/code-mower.yml"), + ), + mock.patch.object( + doctor, + "resolve_doctor_provider_templates_path", + return_value=Path(f"{_local_root()}/package/templates/providers.yml"), + ), + mock.patch.object(doctor, "board_startup_grace", return_value=None), + redirect_stdout(stdout), + ): + code = doctor.main( + [ + "--adoption", + "--hosted-builders", + "--repo", + "example-org/example-repo", + "--json", + *extra, + ] + ) + self.assertEqual(code, 0) + return json.loads(stdout.getvalue()) + + safe = run([]) + local = run(["--include-local-paths"]) + + self.assertEqual(safe["local_paths"], "redacted") + self.assertNotIn("example-person", json.dumps(safe)) + self.assertNotIn("local_paths", local) + self.assertIn("example-person", json.dumps(local)) + + def test_share_safe_flag_redacts_non_adoption_json(self) -> None: + stdout = StringIO() + with ( + mock.patch.object(doctor, "run_doctor", return_value=_report()), + mock.patch.object( + doctor, + "resolve_doctor_config_path", + return_value=Path(f"{_local_root()}/repo/code-mower.yml"), + ), + mock.patch.object( + doctor, + "resolve_doctor_provider_templates_path", + return_value=Path(f"{_local_root()}/package/templates/providers.yml"), + ), + mock.patch.object(doctor, "board_startup_grace", return_value=None), + redirect_stdout(stdout), + ): + code = doctor.main(["--json", "--share-safe"]) + + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["local_paths"], "redacted") + self.assertNotIn("example-person", json.dumps(payload)) + + def test_share_safe_config_error_hides_requested_path_and_cwd(self) -> None: + message = doctor._doctor_config_error_message( + ValueError(f"cannot read {_local_root()}/repo/code-mower.yml"), + config_arg=f"{_local_root()}/repo/code-mower.yml", + include_local_paths=False, + ) + + self.assertIn(LOCAL_PATH_REDACTION, message) + self.assertNotIn("example-person", message) diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index afd418c2..f74a8d7d 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -11765,6 +11765,8 @@ def test_orchestrator_prompt_pack_preserves_adoption_guardrails(self) -> None: self.assertIn("docs/upgrade-existing-repo.md", prompt_pack) self.assertIn("--orchestrator-only", prompt_pack) self.assertIn("--hosted-builders", prompt_pack) + self.assertIn("--share-safe", prompt_pack) + self.assertIn("--include-local-paths", prompt_pack) self.assertIn("docs/install.md", build_loop_30) self.assertIn("docs/upgrade-existing-repo.md", build_loop_30) self.assertIn("Keep one writer per PR branch", prompt_pack)