diff --git a/README.md b/README.md index 54535e9..2f052d9 100644 --- a/README.md +++ b/README.md @@ -382,3 +382,35 @@ python3 -m pytest tests/ -v | `test_mirror_refs.py` | Flags, safety defaults, local integration tests | All tests mock network and keychain access — no real API calls are made. + +## Troubleshooting + +### macOS: `com.apple.provenance` blocks Python execution (#3) + +On macOS Sequoia and later, files written by an agent/IDE terminal receive the +`com.apple.provenance` extended attribute, and macOS blocks `Python.app` from +**executing** such files. Symptoms: newly created/restored `.py` files fail to +run (e.g. `create_issue.py` "vanishing" or refusing to execute), while shell +scripts and files created before the session are unaffected. This is a macOS +security feature, not a bug in this project's code. + +Workarounds (run from a terminal with **Full Disk Access**, e.g. `Terminal.app` +— not the IDE terminal, or the removal itself may be blocked): + +```bash +# Preferred: strip only com.apple.provenance under the repo (dry-run first) +./scripts/clear-provenance --dry-run +./scripts/clear-provenance + +# Or a single file +./scripts/clear-provenance /path/to/file.py + +# Manual equivalents +xattr -r -d com.apple.provenance /Users/jasonwalker/Development/Gitea-Tools/ +xattr -cr /Users/jasonwalker/Development/Gitea-Tools/ # clears ALL xattrs +``` + +Alternatively, grant Full Disk Access to the terminal app in +**System Settings → Privacy & Security**. `scripts/clear-provenance` removes only +`com.apple.provenance` (leaving other extended attributes intact) and supports +`--dry-run`. diff --git a/scripts/clear-provenance b/scripts/clear-provenance new file mode 100755 index 0000000..0087e44 --- /dev/null +++ b/scripts/clear-provenance @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +# clear-provenance — strip the macOS com.apple.provenance extended attribute so +# Python.app can execute .py files created by agent/IDE terminals (issue #3). +# +# macOS Sequoia+ blocks Python.app from executing files carrying +# com.apple.provenance. Files written by the agent terminal get it; shell +# scripts are unaffected. This is a macOS security feature, not a bug in our +# code — see the Troubleshooting section of the README. +# +# Run from a terminal with Full Disk Access (e.g. Terminal.app), not the IDE +# terminal, or the removal itself may be blocked. + +usage() { + cat <<'EOF' +usage: scripts/clear-provenance [--dry-run] [path] + +Recursively remove the com.apple.provenance extended attribute under +(default: the repository root). macOS only. Only that attribute is removed; +other extended attributes are left intact. + +Examples: + scripts/clear-provenance --dry-run + scripts/clear-provenance + scripts/clear-provenance /path/to/file.py +EOF +} + +dry_run=0 +while [[ "${1:-}" == --* ]]; do + case "$1" in + --dry-run) dry_run=1 ;; + --help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac + shift +done + +if [[ $# -gt 1 ]]; then + usage >&2 + exit 2 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +target="${1:-$repo_root}" + +if [[ ! -e "$target" ]]; then + printf 'clear-provenance: no such path: %s\n' "$target" >&2 + exit 1 +fi + +# Remove only com.apple.provenance; tolerate files that do not carry it. +if [[ "$dry_run" -eq 1 ]]; then + printf 'clear-provenance: [dry-run] would run: xattr -r -d com.apple.provenance %q\n' "$target" + exit 0 +fi + +xattr -r -d com.apple.provenance "$target" 2>/dev/null || true +printf 'clear-provenance: removed com.apple.provenance recursively under: %s\n' "$target" diff --git a/tests/test_clear_provenance.py b/tests/test_clear_provenance.py new file mode 100644 index 0000000..707794d --- /dev/null +++ b/tests/test_clear_provenance.py @@ -0,0 +1,60 @@ +"""Tests for scripts/clear-provenance (#3). + +Exercises argument handling and the inert --dry-run path only — no real xattr +mutation, no network. (Actually removing com.apple.provenance is macOS-only and +has real side effects, so it is not exercised here.) +""" +import subprocess +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SCRIPT = REPO / "scripts" / "clear-provenance" + + +def run(*args): + proc = subprocess.run(["bash", str(SCRIPT), *args], + capture_output=True, text=True, cwd=str(REPO)) + return proc.returncode, proc.stdout, proc.stderr + + +class TestClearProvenance(unittest.TestCase): + + def test_dry_run_defaults_to_repo_root(self): + rc, out, _ = run("--dry-run") + self.assertEqual(rc, 0) + self.assertIn("would run: xattr -r -d com.apple.provenance", out) + self.assertIn(str(REPO), out) + + def test_dry_run_explicit_path(self): + with tempfile.TemporaryDirectory() as d: + f = Path(d) / "x.py" + f.write_text("print('hi')\n") + rc, out, _ = run("--dry-run", str(f)) + self.assertEqual(rc, 0) + self.assertIn(str(f), out) + + def test_missing_path_errors(self): + rc, _, err = run("--dry-run", "/no/such/path-xyz") + self.assertEqual(rc, 1) + self.assertIn("no such path", err) + + def test_bad_flag_exit_2(self): + rc, _, _ = run("--bogus") + self.assertEqual(rc, 2) + + def test_too_many_args_exit_2(self): + rc, _, _ = run("a", "b") + self.assertEqual(rc, 2) + + def test_only_targets_provenance_attribute(self): + # The command removes only com.apple.provenance, not all xattrs. + rc, out, _ = run("--dry-run") + self.assertIn("com.apple.provenance", out) + self.assertNotIn("xattr -rc", out) # not a blanket "clear all" + self.assertNotIn("-c ", out) + + +if __name__ == "__main__": + unittest.main()