mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
fix: validate Pi fault fixture against canonical closure pin
This commit is contained in:
1 parent
029795bcff
commit
585b43d3ab
3 files changed
+90
-9
No files matched your search
@@ -1461,6 +1461,10 @@ death is excluded because this exact ownership mechanism requires Linux pidfd.
|
||||
The Python helper calibration uses a synthetic transport and a real title-changing
|
||||
Node child; it proves fault ownership only, never paid Pi lifecycle behavior. Run
|
||||
it on native Linux with pinned Node on PATH: `python3 tests/runner-e2e/pi-provider-fault.test.py`.
|
||||
The fault helper validates the profile pin against canonical normalized closure
|
||||
entries, matching production admission. JSON formatting is not part of that pin.
|
||||
The same free suite checks canonical hash admission, malformed metadata and
|
||||
changed entries before any signal is sent.
|
||||
This adds one cell to the pending Pi Product matrix (26: 13 local, 13 Daytona);
|
||||
no qualification or live success is implied by discovery or oracle tests.
|
||||
|
||||
|
||||
@@ -72,6 +72,31 @@ def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def parse_closure_metadata(content, expected_pin):
|
||||
# Match production parseNativeAcpxDistributionEntries: the profile pins
|
||||
# canonical entries, not the formatting or outer JSON file bytes.
|
||||
manifest = json.loads(content)
|
||||
entries = manifest.get('entries') if isinstance(manifest, dict) else None
|
||||
require(isinstance(entries, list) and 0 < len(entries) <= 30000, 'closure_inventory')
|
||||
normalized, previous, total = [], '', 0
|
||||
for entry in entries:
|
||||
require(isinstance(entry, dict) and set(entry) == {'path', 'sha256', 'size', 'executable'}, 'closure_entry_shape')
|
||||
path = entry['path']
|
||||
require(isinstance(path, str) and 0 < len(path) <= 4096 and not os.path.isabs(path)
|
||||
and not re.search(r'[\x00-\x1f\x7f\\]', path)
|
||||
and all(part not in ('', '.', '..') for part in path.split('/'))
|
||||
and path > previous and path != 'manifest.json' and not path.startswith('.paperclip-'), 'closure_entry_path')
|
||||
require(isinstance(entry['sha256'], str) and re.fullmatch(r'[a-f0-9]{64}', entry['sha256'])
|
||||
and type(entry['size']) is int and 0 <= entry['size'] <= 384 * 1024 * 1024
|
||||
and type(entry['executable']) is bool, 'closure_entry_metadata')
|
||||
total += entry['size']; require(total <= 1024 * 1024 * 1024, 'closure_tree_bound')
|
||||
previous = path
|
||||
normalized.append({key: entry[key] for key in ('path', 'sha256', 'size', 'executable')})
|
||||
canonical = json.dumps(normalized, separators=(',', ':'), ensure_ascii=False).encode('utf8')
|
||||
require(re.fullmatch(r'[a-f0-9]{64}', expected_pin) and digest(canonical) == expected_pin, 'closure_pin')
|
||||
return normalized
|
||||
|
||||
|
||||
def inspect(config):
|
||||
require(set(config) == {'root', 'binding', 'runtimeEnvironmentLeaseId', 'runnerdSha256', 'closureSha256'}, 'config_keys')
|
||||
binding, expected = config['binding'], config['root']
|
||||
@@ -90,10 +115,8 @@ def inspect(config):
|
||||
executable = os.stat(f'/proc/{root["pid"]}/exe')
|
||||
require((executable.st_dev, executable.st_ino) == runner_inode and 'sha256:' + digest(runner_bytes) == config['runnerdSha256'], 'runner_executable')
|
||||
closure_bytes, _ = checked_file(PACK + '/provider-assets/pi/linux-x64/native-closure.json', 4 * 1024 * 1024)
|
||||
require(digest(closure_bytes) == config['closureSha256'], 'closure_pin')
|
||||
manifest = json.loads(closure_bytes)
|
||||
entries = {e['path']: e for e in manifest['entries']}
|
||||
require(len(entries) == len(manifest['entries']), 'closure_duplicate')
|
||||
admitted_entries = parse_closure_metadata(closure_bytes, config['closureSha256'])
|
||||
entries = {e['path']: e for e in admitted_entries}
|
||||
for name in (NODE, ENTRY, EXTENSION, 'pi-entry.cjs', 'node_modules/pi-acp/dist/index.js', 'node_modules/pi-acp/dist/paperclip-runtime.js'):
|
||||
require(name in entries and re.fullmatch(r'[a-f0-9]{64}', entries[name]['sha256']), 'closure_entry')
|
||||
pids = [int(p) for p in os.listdir('/proc') if p.isdigit() and int(p) > 1]
|
||||
|
||||
@@ -32,14 +32,68 @@ def check_cancelled():
|
||||
NAMES = [fault.NODE, fault.ENTRY, fault.EXTENSION, 'pi-entry.cjs', 'node_modules/pi-acp/dist/index.js', 'node_modules/pi-acp/dist/paperclip-runtime.js']
|
||||
|
||||
|
||||
class ClosureMetadataTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.entries = [
|
||||
{'path': name, 'sha256': fault.digest(name.encode()), 'size': len(name), 'executable': name == fault.NODE}
|
||||
for name in sorted(NAMES)
|
||||
]
|
||||
self.canonical = json.dumps(self.entries, separators=(',', ':'), ensure_ascii=False).encode()
|
||||
self.pin = fault.digest(self.canonical)
|
||||
self.manifest = {'entries': self.entries}
|
||||
|
||||
def test_canonical_entries_pin_admits_metadata(self):
|
||||
raw = json.dumps(self.manifest, indent=2).encode()
|
||||
self.assertNotEqual(fault.digest(raw), self.pin)
|
||||
self.assertEqual(fault.parse_closure_metadata(raw, self.pin), self.entries)
|
||||
|
||||
def test_metadata_formatting_and_property_order_preserve_pin(self):
|
||||
reordered = {'entries': [dict(reversed(list(entry.items()))) for entry in self.entries]}
|
||||
for manifest in [self.manifest, reordered]:
|
||||
for indent in [None, 2, 4]:
|
||||
with self.subTest(indent=indent):
|
||||
self.assertEqual(fault.parse_closure_metadata(json.dumps(manifest, indent=indent).encode(), self.pin), self.entries)
|
||||
|
||||
def test_raw_metadata_hash_is_not_a_closure_pin(self):
|
||||
raw = json.dumps(self.manifest, indent=2).encode()
|
||||
with self.assertRaisesRegex(RuntimeError, 'closure_pin'):
|
||||
fault.parse_closure_metadata(raw, fault.digest(raw))
|
||||
|
||||
def test_changed_entry_is_rejected_by_the_pinned_digest(self):
|
||||
changed = json.loads(json.dumps(self.manifest))
|
||||
changed['entries'][0]['sha256'] = '0' * 64
|
||||
with self.assertRaisesRegex(RuntimeError, 'closure_pin'):
|
||||
fault.parse_closure_metadata(json.dumps(changed).encode(), self.pin)
|
||||
|
||||
def test_unsafe_duplicate_unsorted_and_invalid_metadata_fail_closed(self):
|
||||
mutations = [
|
||||
lambda m: m['entries'][0].update(path='../escape'),
|
||||
lambda m: m['entries'][0].update(path='.paperclip-native-entry.cjs'),
|
||||
lambda m: m['entries'].insert(1, m['entries'][0].copy()),
|
||||
lambda m: m['entries'].reverse(),
|
||||
lambda m: m['entries'][0].update(size=True),
|
||||
lambda m: m['entries'][0].update(executable=1),
|
||||
lambda m: m['entries'][0].update(extra='foreign'),
|
||||
]
|
||||
for index, mutate in enumerate(mutations):
|
||||
changed = json.loads(json.dumps(self.manifest))
|
||||
mutate(changed)
|
||||
with self.subTest(index=index), self.assertRaises(RuntimeError):
|
||||
fault.parse_closure_metadata(json.dumps(changed).encode(), self.pin)
|
||||
for invalid in [None, [], {}, {'entries': []}]:
|
||||
with self.subTest(invalid=invalid), self.assertRaises(RuntimeError):
|
||||
fault.parse_closure_metadata(json.dumps(invalid).encode(), self.pin)
|
||||
|
||||
|
||||
class IdentityTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.files = {DIST + '/' + n: (n.encode(), (1, 100 + i)) for i, n in enumerate(NAMES)}
|
||||
self.files[RUNTIME + '/bin/paperclip-runnerd'] = (b'runner', (1, 90))
|
||||
closure = json.dumps({'entries': [{'path': n, 'sha256': fault.digest(n.encode()), 'size': len(n)} for n in NAMES]}).encode()
|
||||
entries = [{'path': n, 'sha256': fault.digest(n.encode()), 'size': len(n), 'executable': n == fault.NODE} for n in sorted(NAMES)]
|
||||
closure = json.dumps({'entries': entries}, indent=2).encode()
|
||||
self.files[fault.PACK + '/provider-assets/pi/linux-x64/native-closure.json'] = (closure, (1, 80))
|
||||
self.config = {'root': ROOT, 'binding': {'remoteCwd': '/workspace', 'runId': 'run'}, 'runtimeEnvironmentLeaseId': 'workspace-id',
|
||||
'runnerdSha256': 'sha256:' + fault.digest(b'runner'), 'closureSha256': fault.digest(closure)}
|
||||
'runnerdSha256': 'sha256:' + fault.digest(b'runner'), 'closureSha256': fault.digest(json.dumps(entries, separators=(',', ':'), ensure_ascii=False).encode())}
|
||||
self.table = {21: ROOT, 22: PARENT, 23: TARGET}
|
||||
self.args = {21: [RUNTIME + '/bin/paperclip-runnerd', '--run-id', 'run', '--environment-lease-id', 'workspace-id', '--lifecycle-mode', 'per_turn', '--state-dir', RUNTIME + '/sessions/' + 'a' * 64 + '/runner'],
|
||||
22: [DIST + '/' + fault.NODE, '--require', DIST + '/' + fault.GUARD, DIST + '/pi-entry.cjs'], 23: ['pi']}
|
||||
@@ -120,8 +174,8 @@ class PidfdTests(unittest.TestCase):
|
||||
(distribution / fault.ENTRY).write_text('process.title="pi"; require("node:fs").writeFileSync(' + json.dumps(str(ready)) + ',JSON.stringify({pid:process.pid}));setInterval(()=>{},1000);')
|
||||
(distribution / 'pi-entry.cjs').write_text('const p=require("node:child_process").spawn(process.execPath,["--require",' + json.dumps(str(distribution / fault.GUARD)) + ',' + json.dumps(str(distribution / fault.ENTRY)) + '],{stdio:"ignore"});p.on("exit",(code,signal)=>require("node:fs").writeFileSync(' + json.dumps(str(exited)) + ',JSON.stringify({code,signal})));process.on("SIGTERM",()=>{if(p.exitCode!==null||p.signalCode!==null)process.exit(0);p.once("exit",()=>process.exit(0));p.kill("SIGTERM")});setInterval(()=>{},1000);')
|
||||
entries = []
|
||||
for name in NAMES:
|
||||
p = distribution / name; content = p.read_bytes(); entries.append({'path': name, 'sha256': fault.digest(content), 'size': len(content)})
|
||||
for name in sorted(NAMES):
|
||||
p = distribution / name; content = p.read_bytes(); entries.append({'path': name, 'sha256': fault.digest(content), 'size': len(content), 'executable': name == fault.NODE})
|
||||
closure = json.dumps({'entries': entries}).encode(); (pack / 'provider-assets/pi/linux-x64/native-closure.json').write_bytes(closure)
|
||||
for directory, dirs, files in os.walk(snapshot):
|
||||
for f in files:
|
||||
@@ -143,7 +197,7 @@ class PidfdTests(unittest.TestCase):
|
||||
self.assertTrue(ready.exists()); target = json.loads(ready.read_text())['pid']
|
||||
self.assertEqual(fault.argv(target), ['pi'], 'real Node process.title must overwrite argv')
|
||||
boot = Path('/proc/sys/kernel/random/boot_id').read_text().strip()
|
||||
config = {'root': fault.proc(root_child.pid, boot), 'binding': {'remoteCwd': str(workspace), 'runId': 'fixture'}, 'runtimeEnvironmentLeaseId': 'workspace-id', 'runnerdSha256': 'sha256:' + fault.digest((runtime / 'bin/paperclip-runnerd').read_bytes()), 'closureSha256': fault.digest(closure)}
|
||||
config = {'root': fault.proc(root_child.pid, boot), 'binding': {'remoteCwd': str(workspace), 'runId': 'fixture'}, 'runtimeEnvironmentLeaseId': 'workspace-id', 'runnerdSha256': 'sha256:' + fault.digest((runtime / 'bin/paperclip-runnerd').read_bytes()), 'closureSha256': fault.digest(json.dumps(entries, separators=(',', ':'), ensure_ascii=False).encode())}
|
||||
with patch.object(fault, 'PACK', str(pack)):
|
||||
admitted = fault.inspect(config)
|
||||
self.assertEqual(admitted['target']['pid'], target)
|
||||
|
||||
Reference in new issue
Block a user