From 542f6efaffce6792c18cbc405c67f25975ddd316 Mon Sep 17 00:00:00 2001 From: Conor Mongan <60523371+c-mongan@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:54:23 +0100 Subject: [PATCH] fix: reject symlinked originals in collection exports --- skills/sourcepack/scripts/sourcelens/pack.py | 1 + tests/test_pack.py | 32 ++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/skills/sourcepack/scripts/sourcelens/pack.py b/skills/sourcepack/scripts/sourcelens/pack.py index 0ae57b6..1d7b0c5 100644 --- a/skills/sourcepack/scripts/sourcelens/pack.py +++ b/skills/sourcepack/scripts/sourcelens/pack.py @@ -54,6 +54,7 @@ def build_pack(run,destination,include_originals=True): gaps.extend(cm.get('gaps',[]));scope='acquired' if root==run else f'companion-{len(register)+1}-acquired' if include_originals: for p in (root/'acquired').rglob('*'): + if p.is_symlink():raise ContractError('Acquisition originals cannot contain symlinks') if not p.is_file():continue if p.name=='request.json' or p.name.startswith('worker.'): omissions.append({'path':str(p.relative_to(root)),'reason':'runtime diagnostics; retained privately in run'});continue diff --git a/tests/test_pack.py b/tests/test_pack.py index 42e49e4..b49a878 100644 --- a/tests/test_pack.py +++ b/tests/test_pack.py @@ -23,3 +23,35 @@ def test_supporting_sources_are_bounded_and_uninspected(self): child=root/'child4';w.prepare(str(p),child) with self.assertRaises(ContractError):register_supporting_source(run,child,'extra') self.assertEqual(len(w.load(run)[1]['supporting_sources']),3) + def test_collection_export_rejects_unregistered_acquisition_symlinks(self): + for kind in ('file', 'directory', 'broken'): + with self.subTest(kind=kind), tempfile.TemporaryDirectory() as td: + root=Path(td);source=root/'source.md';source.write_text('Synthetic source.') + run=root/'run';w.prepare(str(source),run) + outside=root/'outside';outside.mkdir();private=outside/'private.txt';private.write_text('Synthetic private data.') + target={'file':private,'directory':outside,'broken':outside/'missing'}[kind] + (run/'acquired'/'unregistered').symlink_to(target,target_is_directory=kind=='directory') + destination=root/'export' + with self.assertRaisesRegex(ContractError,'symlink'): + build_pack(run,destination) + self.assertFalse(destination.exists()) + self.assertEqual(private.read_text(),'Synthetic private data.') + def test_collection_export_rejects_supporting_source_acquisition_symlink(self): + with tempfile.TemporaryDirectory() as td: + root=Path(td);source=root/'source.md';source.write_text('Synthetic source.') + run=root/'run';child=root/'child';w.prepare(str(source),run);w.prepare(str(source),child) + register_supporting_source(run,child,'official docs') + private=root/'private.txt';private.write_text('Synthetic private data.') + (child/'acquired'/'unregistered.txt').symlink_to(private) + with self.assertRaisesRegex(ContractError,'symlink'): + build_pack(run,root/'export') + self.assertFalse((root/'export').exists()) + def test_lightweight_collection_export_does_not_read_unregistered_symlink(self): + with tempfile.TemporaryDirectory() as td: + root=Path(td);source=root/'source.md';source.write_text('Synthetic source.') + run=root/'run';w.prepare(str(source),run) + private=root/'private.txt';private.write_text('Synthetic private data.') + (run/'acquired'/'unregistered.txt').symlink_to(private) + build_pack(run,root/'export',include_originals=False) + self.assertFalse((root/'export'/'acquired').exists()) + self.assertFalse(json.loads((root/'export'/'manifest.json').read_text())['self_contained_originals'])