diff --git a/.github/workflows/no-local-home-paths.yml b/.github/workflows/no-local-home-paths.yml new file mode 100644 index 000000000..fe13c30b8 --- /dev/null +++ b/.github/workflows/no-local-home-paths.yml @@ -0,0 +1,29 @@ +name: No absolute local home paths + +'on': + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + no-local-home-paths: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: '3.11.16' + - name: Negative and positive controls + run: | + python -B -S -m unittest discover -s tests -p 'test_check_no_local_home_paths.py' -v + python -B -O -S -m unittest discover -s tests -p 'test_check_no_local_home_paths.py' -v + - name: Tracked tree scan + run: python -B -S tools/check_no_local_home_paths.py . + - run: git diff --exit-code diff --git a/docs/custody/LOCAL_PATH_MAP.json b/docs/custody/LOCAL_PATH_MAP.json new file mode 100644 index 000000000..20cb16d69 --- /dev/null +++ b/docs/custody/LOCAL_PATH_MAP.json @@ -0,0 +1,143 @@ +{ + "schema": "local-path-map/v1", + "scientific_effect": "NONE", + "pinned_head": "6e502c1f8a5632eb90670fd9fc0d52e421c015a2", + "purpose": "Owner-side mapping for absolute local home paths retained in hash-pinned historical evidence. Additive only: no pinned file is edited, so no recorded hash changes.", + "prefixes": [ + { + "placeholder": "", + "description": "macOS per-user home directory of the repository owner (the prefix up to and including the user name, no trailing slash)", + "sha256_of_prefix_utf8": "f42cc646e946c6157467d73f3541203ab2746476c0b6dda88a5baa6358256ef5", + "bytes": 15 + } + ], + "container_paths": { + "pattern": "/root/", + "status": "N/A: agent-sandbox scratch directories, not a personal home path; not flagged" + }, + "ci_runner_paths": { + "pattern": "/home/runner", + "status": "N/A: GitHub Actions runner paths; not flagged" + }, + "files": [ + { + "path": "frontiers/lifetime_moment_boundary_20260930/IDENTITY_AND_CONTROL_OBSERVATIONS.json", + "lines": [ + 4, + 10, + 16, + 78, + 84, + 90 + ], + "hits": 6, + "git_blob": "a2aff201bda75aa74b8101d225182ca7731872e3", + "sha256": "84164b9ce29f7cc0d0b31a9cd53a870e0ad2c9bd7bb7de271cff877ba96754e5", + "bytes": 8495, + "disposition": "box-only path remap prepared (obs_paths.patch); not applied here — owner decision" + }, + { + "path": "reviews/radial_moment_corollaries_20260930/IDENTITY_AND_CONTROL_OBSERVATIONS.json", + "lines": [ + 4, + 10, + 16, + 78, + 84, + 90 + ], + "hits": 6, + "git_blob": "a2aff201bda75aa74b8101d225182ca7731872e3", + "sha256": "84164b9ce29f7cc0d0b31a9cd53a870e0ad2c9bd7bb7de271cff877ba96754e5", + "bytes": 8495, + "disposition": "box-only path remap prepared (obs_paths.patch); not applied here — owner decision" + }, + { + "path": "frontiers/moving_remote_collision_20261003/AUTHOR_CONTROL_RUNS.json", + "lines": [ + 6 + ], + "hits": 1, + "git_blob": "aeb927abcf637243228d5b3158d7f802763b1928", + "sha256": "ee15e53018b87d160a0824c684b0eac8b0b598bd24bd3bf49298a1d3edb79bfe", + "bytes": 3214, + "disposition": "needs-owner-mapping" + }, + { + "path": "frontiers/moving_remote_collision_20261003/review/ENGINEERING_RUNS.json", + "lines": [ + 14, + 17, + 25, + 32, + 43, + 47, + 55, + 63 + ], + "hits": 8, + "git_blob": "9bd98bc9df21d39c696335bf949ef3d945ca7d88", + "sha256": "ac99011d56f5fdd0674a798bace78de7560c160849c4bce9460b4f047ffbe5fe", + "bytes": 8801, + "disposition": "historical-evidence+mapping" + }, + { + "path": "frontiers/planar_soft_layer_chain_20261003/C102/CHECKER_COMMENT.md", + "lines": [ + 510, + 515, + 530 + ], + "hits": 3, + "git_blob": "7b81b8a5b09b8469441222b1fbf37ea28da60fa5", + "sha256": "68e847b5e74fa734923a31cc0fe6c452c7e183223dbc24d08f0872504402655e", + "bytes": 28098, + "disposition": "historical-evidence" + }, + { + "path": "frontiers/planar_soft_layer_chain_20261003/C124/AUTHOR_CONTROLS_COMMENT.md", + "lines": [ + 344 + ], + "hits": 1, + "git_blob": "447c317461e10d088672c04ad4fb412543e8c031", + "sha256": "c8387191a6b11b2e9a1f67e7f22a5ad58b667cc11d29b90fded1c0b33e459dc4", + "bytes": 23018, + "disposition": "historical-evidence" + }, + { + "path": "frontiers/planar_soft_layer_chain_20261003/C124/CONTROLS.md", + "lines": [ + 61 + ], + "hits": 1, + "git_blob": "7b5c77b4d359d93c023aeb4a4c8b77831084806f", + "sha256": "400a043d664f2ad894bbc669c38203d9809a14743522c031cc86bdbef923e399", + "bytes": 10113, + "disposition": "needs-owner-mapping" + }, + { + "path": "frontiers/planar_soft_layer_chain_20261003/C124/REVIEW.md", + "lines": [ + 139 + ], + "hits": 1, + "git_blob": "5cd4095e16bad897641254e6f2b2089f8d8e7db0", + "sha256": "19c405c68a93c3f5506629f0ebf1ffa78d5c25ad92f17fc4fd04ef0066deb6cf", + "bytes": 22542, + "disposition": "historical-evidence" + }, + { + "path": "frontiers/qs_d3_soft_layer_chain_20261005/A3_2/REVIEW_C135_Q4.md", + "lines": [ + 140 + ], + "hits": 1, + "git_blob": "d00fec057c73e1ba3a63efce7688832c40d4f647", + "sha256": "0724c5b9fd84c689bd52be84c67b0f0ad047065b5cf57b1c9ec8de19f92c5daa", + "bytes": 20565, + "disposition": "historical-evidence" + } + ], + "rule": "tools/check_no_local_home_paths.py fails on any absolute local home path in a tracked text file, except a file listed here whose git_blob is unchanged." +} diff --git a/tests/test_check_no_local_home_paths.py b/tests/test_check_no_local_home_paths.py new file mode 100644 index 000000000..c2b93d350 --- /dev/null +++ b/tests/test_check_no_local_home_paths.py @@ -0,0 +1,72 @@ +import json, pathlib, subprocess, sys, tempfile, unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +import check_no_local_home_paths as chk + +S = '/' +MAC_HOME = S + 'Us' + 'ers' + S + 'synthetic' + 'user' +LIN_HOME = S + 'ho' + 'me' + S + 'synthetic' + 'user' +WIN_HOME = 'C:' + '\\' + 'Us' + 'ers' + '\\' + 'synthetic' + 'user' +RUN_HOME = S + 'ho' + 'me' + S + 'run' + 'ner' + S + 'work' +ROOT_SCRATCH = S + 'root' + S + 'scratch_dir' + + +def repo(files, allow=None): + d = tempfile.TemporaryDirectory() + r = pathlib.Path(d.name) + subprocess.run(['git', 'init', '-q', str(r)], check=True) + for k, v in files.items(): + (r / k).parent.mkdir(parents=True, exist_ok=True) + (r / k).write_text(v, encoding='utf-8') + if allow is not None: + (r / 'docs/custody').mkdir(parents=True, exist_ok=True) + (r / chk.MAP).write_text(json.dumps({'schema': 'local-path-map/v1', 'files': allow})) + subprocess.run(['git', '-C', str(r), 'add', '-A'], check=True) + return d, r + + +class NegativeControls(unittest.TestCase): + def test_each_synthetic_home_fails(self): + for home in (MAC_HOME, LIN_HOME, WIN_HOME, WIN_HOME.replace('\\', '/')): + d, r = repo({'a.md': 'ran ' + home + '/venv/bin/python\n'}) + with d: + self.assertEqual(len(chk.scan(r)), 1, home) + + def test_runner_and_root_scratch_pass(self): + d, r = repo({'a.log': RUN_HOME + '\n' + ROOT_SCRATCH + '\n'}) + with d: + self.assertEqual(chk.scan(r), []) + + def test_allowlisted_unchanged_passes_and_changed_fails(self): + body = 'x ' + MAC_HOME + '/p\n' + d, r = repo({'e.json': body}) + with d: + b = chk.blob(body.encode()) + (r / 'docs/custody').mkdir(parents=True) + (r / chk.MAP).write_text(json.dumps({'schema': 'local-path-map/v1', 'files': [{'path': 'e.json', 'git_blob': b}]})) + subprocess.run(['git', '-C', str(r), 'add', '-A'], check=True) + self.assertEqual(chk.scan(r), []) + (r / 'e.json').write_text(body + 'y ' + LIN_HOME + '/q\n') + self.assertEqual(len(chk.scan(r)), 2) + + def test_cli_exit_code(self): + d, r = repo({'a.txt': MAC_HOME + '\n'}) + with d: + p = subprocess.run([sys.executable, str(ROOT / 'tools/check_no_local_home_paths.py'), str(r)], capture_output=True) + self.assertEqual(p.returncode, 1) + + def test_own_sources_have_no_literal_home_path(self): + for f in ('tools/check_no_local_home_paths.py', 'tests/test_check_no_local_home_paths.py', '.github/workflows/no-local-home-paths.yml', chk.MAP): + p = ROOT / f + if p.exists(): + self.assertFalse(chk.PATTERN.search(p.read_text(encoding='utf-8')), f) + + +class PositiveControl(unittest.TestCase): + def test_current_tree_passes(self): + self.assertEqual(chk.scan(ROOT), []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/check_no_local_home_paths.py b/tools/check_no_local_home_paths.py new file mode 100644 index 000000000..946d58e68 --- /dev/null +++ b/tools/check_no_local_home_paths.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Fail if a tracked text file contains an absolute local home path. + +Flagged: macOS per-user homes, Linux /home/ (except the GitHub Actions +runner), and Windows C:/Users or C:\\Users homes. Agent-container scratch +paths under /root are not personal homes and are not flagged. Files listed in +docs/custody/LOCAL_PATH_MAP.json are allowed only while their git blob is +unchanged, so any edit to such a file that keeps or adds a home path fails. + +Patterns are assembled from fragments so this file never contains a literal +home path itself. +""" +import hashlib, json, pathlib, re, subprocess, sys + +SEP = '/' +MAC = SEP + 'Us' + 'ers' + SEP +LINUX = SEP + 'ho' + 'me' + SEP +WIN = 'C:' + '[\\\\/]+' + 'Us' + 'ers' + '[\\\\/]+' +RUNNER = 'run' + 'ner' +NAME = r'[A-Za-z0-9._-]+' +PATTERN = re.compile( + '(?:' + re.escape(MAC) + NAME + ')' + + '|(?:' + re.escape(LINUX) + '(?!' + RUNNER + r'(?![A-Za-z0-9._-]))' + NAME + ')' + + '|(?:' + WIN + NAME + ')', + re.IGNORECASE) +MAP = 'docs/custody/LOCAL_PATH_MAP.json' + + +def blob(data): + return hashlib.sha1(b'blob %d\0' % len(data) + data).hexdigest() + + +def tracked(root): + out = subprocess.run(['git', '-C', str(root), 'ls-files', '-z'], + check=True, capture_output=True).stdout + return [p for p in out.decode('utf-8', 'surrogateescape').split('\0') if p] + + +def load_allow(root): + path = pathlib.Path(root) / MAP + if not path.is_file(): + return {} + doc = json.loads(path.read_text(encoding='utf-8')) + if doc.get('schema') != 'local-path-map/v1': + raise SystemExit('unexpected schema in ' + MAP) + return {e['path']: e['git_blob'] for e in doc['files']} + + +def scan(root, files=None): + root = pathlib.Path(root) + allow = load_allow(root) + findings = [] + for rel in (files if files is not None else tracked(root)): + p = root / rel + if p.is_symlink() or not p.is_file(): + continue + data = p.read_bytes() + if b'\0' in data[:8192]: + continue + if allow.get(rel) == blob(data): + continue + text = data.decode('utf-8', 'replace') + for n, line in enumerate(text.split('\n'), 1): + if PATTERN.search(line): + note = ' (allowlisted file changed)' if rel in allow else '' + findings.append(f'{rel}:{n}: absolute local home path{note}') + return findings + + +def main(argv): + root = argv[1] if len(argv) > 1 else '.' + findings = scan(root) + for f in findings: + print(f) + print(json.dumps({'findings': len(findings), 'scientific_effect': 'NONE'})) + return 1 if findings else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv))