Inventory retained source access fields without inferring grants
Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
This commit is contained in:
tegwick 2026-10-31 15:07:15 +01:00
parent 5c8f78ce43
commit 52168c3bd4
6 changed files with 476 additions and 0 deletions

View file

@ -177,3 +177,22 @@ records remain private. The new bundle retains all71spaces and the existing
graph/grant/fidelity gates.18body links and one attachment flag are still
unreconciled. Captured Visual bytes do not authorize copying memberships or
productive import. Public proof: docs/release/bubble-visual-preparation-2026-10-31.json.
## Private access reconciliation
`scripts/audit-bubble-access.py ORIGINAL NEW_PRIVATE_DIRECTORY` inventories the
retained source ownership, membership and visibility fields without proposing
target grants. It requires private, hash-bound source snapshots and refuses
source drift, symlinks, public files, hard links, existing output and output
inside the original bundle or this repository. The JSON and escaped, network-free
HTML review are private; stdout contains aggregate counts and hashes only.
The October 31 review is at
`~/.config/coulomb-social/auth/exports/access-review-20261031/index.html`.
It covers 70 fresh spaces, 245 article records and one inventory-only space.
Three distinct source account references remain to be linked through authoritative
identity evidence. Nine creator references are unresolved. A missing source
membership field is unknown, not an empty list. Source role names and visibility
values are preserved for review; no equivalence with target owner/member/viewer
permissions is assumed. The accepted ReichelAG owner grant remains evidence for
that cohort alone. See `docs/release/bubble-access-audit-2026-10-31.json`.

View file

@ -0,0 +1,131 @@
{
"schema": "coulomb.bubble-access-audit-result.v1",
"spaces": {
"records": 70,
"field_states": {
"Created By": {
"unresolved_reference": 5,
"observed": 64,
"missing": 1
},
"crew_list_user": {
"observed": 69,
"missing": 1
},
"members_list_user": {
"observed": 47,
"missing": 23
},
"guests_list_user": {
"missing": 69,
"observed": 1
}
},
"option_fields": {
"isprivate_boolean": {
"present": 69,
"missing": 1,
"distinct_observed_values": 2
},
"addnewusersasmember_boolean": {
"present": 20,
"missing": 50,
"distinct_observed_values": 2
},
"addnewusersasguest_boolean": {
"present": 21,
"missing": 49,
"distinct_observed_values": 2
}
}
},
"articles": {
"records": 245,
"field_states": {
"Created By": {
"observed": 239,
"unresolved_reference": 4,
"missing": 2
},
"_creator_user": {
"missing": 10,
"observed": 235
},
"_owner_user": {
"observed": 243,
"missing": 2
},
"moders_list_user": {
"missing": 71,
"observed": 174
}
},
"option_fields": {
"access_option_visibility": {
"present": 243,
"missing": 2,
"distinct_observed_values": 2
}
}
},
"known_spaces": 71,
"fresh_space_records": 70,
"inventory_only_spaces": 1,
"distinct_source_principals": 3,
"target_identity_mappings_established": 0,
"target_grants_proposed": 0,
"production_changed": false,
"source_visibility_semantics_verified": false,
"source_sha256": {
"spaces": "ae20b0cfd1705f486c0b67ef48ac84b396f953cbcef09f9a8f07d07a66f1bf71",
"articles": "f33967c79c0f8a60a7d504f5d5e4082f8bc9e3c34f6a3160cb200fd380831d62",
"retained_spaces": "92038249ec2e4eed4c8d61ffcdbe030232cebc52e59ba005baee506a16930550"
},
"source_report_sha256": "75fcd87d17ea0c5042e903efd60e65839da7716e5399ff431a345486ddd10f01",
"recorded_at": "2026-10-31T14:05:30.738728+00:00",
"workplan_task": "CSOC-WP-0014-T01",
"task_status": "progress",
"accepted_mapping_scope_evidence": "docs/release/reichelag-imported-2026-10-06.md",
"validation": {
"focused_tests_passed": 13,
"full_gate": "make check passed",
"junit": {
"tests": 583,
"errors": 0,
"failures": 0,
"skipped": 0
},
"source_snapshots_unchanged": true,
"private_output_hashes_verified": true,
"output_modes_verified": true
},
"statehub_readback": {
"task_id": "0002882d-f156-5a5d-b524-d04793f4684d",
"status": "progress",
"access_audit_description_synced": true,
"fix_consistency_exit_code": 0,
"automation_errors": 0,
"assessment_failures": 0,
"warnings": 6
},
"output_files": [
{
"path": "access-inventory.json",
"sha256": "d4f97ccc55867361048325f7227721a50a59bcf70ea1af4dfa3db48e8e703cb2"
},
{
"path": "index.html",
"sha256": "6c175577f8a34c7bda78f86461d68d2c2167708970517f417bc89d1fde37436b"
}
],
"implementation_files": [
{
"path": "scripts/audit-bubble-access.py",
"sha256": "6590a19e54b77521e31455944b085f92e784e2b85737579cb14caaeb8538807b"
},
{
"path": "tests/test_bubble_access_audit.py",
"sha256": "392a799d62828c7508a1b25133d9c1c8de01959b7f1fec90ae737a7cefce7883"
}
]
}

View file

@ -161,3 +161,14 @@ checks pass. This advances the source checklist to14of33done, not an effort
percentage. Source exceptions, identity/grant mapping, links/attachments and
productive transfer/verdicts remain open; the rough remaining-effort estimate
has not been recalculated from these counts.
## Access reconciliation checkpoint
WP-0014-T01 now has a private, source-hash-bound access inventory covering the
70 fresh spaces and 245 articles, while retaining the inventory-only 71st space.
It identifies three distinct source principals and nine unresolved creator
references, preserving missing access fields as unknown. Existing ReichelAG
admission does not establish all-space identity or role mappings. The task remains
progress: this closes an evidence gap inside the task, not productive permission
reconciliation. The checklist stays 14 of 33 done, and the remaining-effort
estimate is unchanged. Evidence: `bubble-access-audit-2026-10-31.json`.

View file

@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Prepare an offline private access inventory; never infer target identities or grants."""
import argparse
import hashlib
import html
import json
import os
import re
import stat
import sys
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from coulomb_social.apps.spaces.bubble_transfer import normalize_ref # noqa: E402
FIELDS = {
'spaces': {'Created By': False, 'crew_list_user': True,
'members_list_user': True, 'guests_list_user': True},
'articles': {'Created By': False, '_creator_user': False,
'_owner_user': False, 'moders_list_user': True},
}
OPTIONS = {'spaces': ['isprivate_boolean', 'addnewusersasmember_boolean',
'addnewusersasguest_boolean'],
'articles': ['access_option_visibility']}
def sha(raw):
return hashlib.sha256(raw).hexdigest()
def private_read(path):
if any(p.is_symlink() for p in (path, *path.parents)):
raise ValueError('source symlink')
info = path.stat()
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
or info.st_mode & 0o077 or info.st_nlink != 1 or info.st_size > 16 * 1024**2):
raise ValueError('private bounded regular source required')
return path.read_bytes()
def reference(value):
try:
result = normalize_ref(value)
except (ValueError, TypeError):
return None
return result if re.fullmatch(r'[0-9]+x[0-9]+', result) else None
def field_state(row, field, many):
if field not in row:
return {'state': 'missing', 'principals': []}
raw = row[field]
if raw is None:
return {'state': 'null', 'raw': raw, 'principals': []}
if many and not isinstance(raw, list):
return {'state': 'invalid_container', 'raw': raw, 'principals': []}
if not many and not isinstance(raw, str):
return {'state': 'invalid_container', 'raw': raw, 'principals': []}
values = raw if many else [raw]
refs = [reference(v) for v in values]
return {'state': ('empty' if not values else 'unresolved_reference'
if None in refs else 'observed'),
'raw': raw, 'principals': sorted(set(r for r in refs if r is not None)),
'unresolved_count': sum(r is None for r in refs)}
def audit(inputs):
"""Source values remain private; the separate summary contains aggregate counts only."""
records = {}
principals = set()
summary = {}
for kind in FIELDS:
source = inputs[kind]
if not isinstance(source, dict) or any(not isinstance(v, dict) for v in source.values()):
raise ValueError('record dictionaries required')
records[kind] = []
counters = {f: Counter() for f in FIELDS[kind]}
for key, row in sorted(source.items()):
fields = {f: field_state(row, f, many) for f, many in FIELDS[kind].items()}
for f, value in fields.items():
counters[f][value['state']] += 1
principals.update(value['principals'])
records[kind].append({'source_id': key, 'fields': fields,
'options': {f: {'present': f in row, 'value': row.get(f)}
for f in OPTIONS[kind]}})
summary[kind] = {
'records': len(source), 'field_states': counters,
'option_fields': {f: {
'present': sum(f in row for row in source.values()),
'missing': sum(f not in row for row in source.values()),
'distinct_observed_values': len({json.dumps(row[f], sort_keys=True)
for row in source.values() if f in row}),
} for f in OPTIONS[kind]},
}
retained = inputs['retained_spaces']
if not isinstance(retained, dict):
raise ValueError('retained inventory required')
known = set(retained) | set(inputs['spaces'])
summary.update({'known_spaces': len(known), 'fresh_space_records': len(inputs['spaces']),
'inventory_only_spaces': len(set(retained) - set(inputs['spaces'])),
'distinct_source_principals': len(principals),
'target_identity_mappings_established': 0,
'target_grants_proposed': 0, 'production_changed': False,
'source_visibility_semantics_verified': False})
return {'schema': 'coulomb.bubble-access-inventory.v1', 'summary': summary,
'source_principals': sorted(principals), 'records': records,
'inventory_only_space_ids': sorted(set(retained) - set(inputs['spaces'])),
'limitations': [
'Missing fields are unknown, not empty grants.',
'Source roles and visibility are observed values, not target permissions.',
'Existing one-space owner admission does not map every source identity.',
'This capture does not prove complete or current provider membership.',
'No target identity, role mapping or import manifest is created.']}
def review_html(report):
rows = []
for kind, records in report['records'].items():
for row in records:
data = json.dumps(row, ensure_ascii=False, indent=2)
rows.append('<details><summary>' + html.escape(kind + ': ' + row['source_id'])
+ '</summary><pre>' + html.escape(data) + '</pre></details>')
return ('<!doctype html><html><head><meta charset="utf-8">'
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; '
'base-uri \'none\'; form-action \'none\'">'
'<title>Private source access inventory</title></head><body>'
'<h1>Private source access inventory</h1><p>Observed source metadata only. '
'No target identities or grants have been inferred.</p><pre>'
+ html.escape(json.dumps(report['summary'], indent=2)) + '</pre><ul>'
+ ''.join('<li>' + html.escape(x) + '</li>' for x in report['limitations'])
+ '</ul>' + ''.join(rows) + '</body></html>').encode()
def prepare(source, out):
raw = {kind: private_read(source / 'source-capture' / (kind + '.json'))
for kind in [*FIELDS, 'retained_spaces']}
receipt_raw = private_read(source / 'reconciliation.json')
receipt = json.loads(receipt_raw)
if (receipt['schema'] != 'coulomb.bubble-review-bundle.v1'
or receipt['status'] != 'completed_offline_preparation'):
raise ValueError('completed review bundle required')
hashes = {k: sha(v) for k, v in raw.items()}
if any(receipt['source_sha256'].get(k) != v for k, v in hashes.items()):
raise ValueError('source digest mismatch')
report = audit({k: json.loads(v) for k, v in raw.items()})
report['source_sha256'] = hashes
report['source_report_sha256'] = sha(receipt_raw)
if out.exists() or any(p.is_symlink() for p in (out, *out.parents)):
raise ValueError('new private output required')
parent = out.parent.stat()
if (parent.st_uid != os.getuid() or parent.st_mode & 0o077
or out.resolve().is_relative_to(Path(__file__).resolve().parents[1])
or out.resolve().is_relative_to(source.resolve())):
raise ValueError('private output outside repository and source required')
for kind, data in raw.items():
if private_read(source / 'source-capture' / (kind + '.json')) != data:
raise ValueError('source changed during audit')
if private_read(source / 'reconciliation.json') != receipt_raw:
raise ValueError('source report changed during audit')
out.mkdir(mode=0o700)
outputs = {'access-inventory.json': json.dumps(report, indent=2).encode(),
'index.html': review_html(report)}
for name, data in outputs.items():
with os.fdopen(os.open(out / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'wb') as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
return {'schema': 'coulomb.bubble-access-audit-result.v1', **report['summary'],
'source_sha256': hashes, 'source_report_sha256': sha(receipt_raw),
'output_sha256': {k: sha(v) for k, v in outputs.items()}}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('source', type=Path)
parser.add_argument('output', type=Path)
args = parser.parse_args()
try:
result = prepare(args.source, args.output)
except Exception:
print('Access audit refused; no identity or grant inferred.', file=sys.stderr)
return 1
print(json.dumps(result))
return 0
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -0,0 +1,110 @@
"""Missing access metadata must never turn into empty or approved permissions."""
import hashlib
import importlib.util
import json
from pathlib import Path
import pytest
SPEC = importlib.util.spec_from_file_location(
'bubble_access_audit', Path(__file__).resolve().parents[1] / 'scripts/audit-bubble-access.py')
audit = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(audit)
def inputs():
return {'spaces': {'1x1': {'crew_list_user': ['LOOKUP__User__9x9'],
'members_list_user': [], 'isprivate_boolean': False}},
'articles': {'2x2': {'_owner_user': '9x9', 'Created By': 'unresolved'}},
'retained_spaces': {'1x1': {}, '3x3': {}}}
def test_aliases_collapse_but_no_grants_or_empty_missing_inference():
report = audit.audit(inputs())
assert report['source_principals'] == ['9x9']
fields = report['records']['spaces'][0]['fields']
assert fields['members_list_user']['state'] == 'empty'
assert fields['guests_list_user']['state'] == 'missing'
created_by = report['records']['articles'][0]['fields']['Created By']
assert created_by['state'] == 'unresolved_reference'
assert report['inventory_only_space_ids'] == ['3x3']
assert report['summary']['known_spaces'] == 2
assert report['summary']['target_grants_proposed'] == 0
assert report['summary']['target_identity_mappings_established'] == 0
assert '9x9' not in json.dumps(report['summary'])
@pytest.mark.parametrize('raw,expected', [(None, 'null'), ('9x9', 'invalid_container'),
([], 'empty'), ([{}], 'unresolved_reference'),
(['9x9', None], 'unresolved_reference')])
def test_field_states(raw, expected):
result = audit.field_state({'members_list_user': raw}, 'members_list_user', True)
assert result['state'] == expected
assert result['raw'] == raw
def test_private_html_escapes_source_and_disables_network():
data = inputs()
data['articles']['2x2']['Created By'] = '<script>alert(1)</script>'
result = audit.review_html(audit.audit(data))
assert b'<script>' not in result
assert b'&lt;script&gt;' in result
assert b"default-src 'none'" in result
assert b"form-action 'none'" in result
def source_bundle(tmp_path):
tmp_path.chmod(0o700)
source = tmp_path / 'source'
source.mkdir(mode=0o700)
capture = source / 'source-capture'
capture.mkdir(mode=0o700)
hashes = {}
for kind, values in inputs().items():
raw = json.dumps(values).encode()
path = capture / (kind + '.json')
path.write_bytes(raw)
path.chmod(0o600)
hashes[kind] = hashlib.sha256(raw).hexdigest()
path = source / 'reconciliation.json'
path.write_text(json.dumps({'schema': 'coulomb.bubble-review-bundle.v1',
'status': 'completed_offline_preparation', 'source_sha256': hashes}))
path.chmod(0o600)
return source
def test_source_binding_private_modes_and_no_overwrite(tmp_path):
source = source_bundle(tmp_path)
before = {p: p.read_bytes() for p in source.rglob('*') if p.is_file()}
out = tmp_path / 'review'
result = audit.prepare(source, out)
assert all(p.read_bytes() == raw for p, raw in before.items())
assert not out.stat().st_mode & 0o077
for name, digest in result['output_sha256'].items():
assert hashlib.sha256((out / name).read_bytes()).hexdigest() == digest
assert not (out / name).stat().st_mode & 0o077
with pytest.raises(ValueError):
audit.prepare(source, out)
@pytest.mark.parametrize('defect', ['drift', 'public', 'symlink', 'hardlink', 'nested-output'])
def test_unsafe_custody_or_drift_refused(tmp_path, defect):
source = source_bundle(tmp_path)
path = source / 'source-capture/articles.json'
out = tmp_path / 'review'
if defect == 'drift':
path.write_text('{}')
elif defect == 'public':
path.chmod(0o644)
elif defect == 'symlink':
saved = path.with_suffix('.saved')
path.rename(saved)
path.symlink_to(saved)
elif defect == 'hardlink':
path.with_suffix('.linked').hardlink_to(path)
else:
out = source / 'review'
with pytest.raises(ValueError):
audit.prepare(source, out)
assert not out.exists()

View file

@ -116,6 +116,21 @@ body and new grants have not been inferred. No productive import occurred.
Evidence: docs/release/bubble-source-continuation-2026-10-31.json and its source
manifest. T01 remains progress; the login-renewal gate is cleared.
2026-10-31 access reconciliation: a hash-bound offline inventory now separates
observed, missing, null, empty and unresolved access fields across all 70 fresh
space records and 245 article records; the retained 71st space remains explicitly
inventory-only. Source references normalize to three distinct principals. Nine
Created By references cannot be normalized; absent membership fields remain
unknown rather than empty grants. Space privacy and article visibility each have
two observed values, whose authorization semantics are not established by this
capture. The October 6 accepted import admits tegwick as owner of ReichelAG only;
it does not establish a general source-account or role mapping for other spaces.
Private per-record review: `~/.config/coulomb-social/auth/exports/access-review-20261031/index.html`.
Aggregate proof: `docs/release/bubble-access-audit-2026-10-31.json`. No target
identity mapping, grant proposal or import manifest was inferred. T01 remains
progress until source exceptions and the actual identity/grant/assets/link
reconciliation are resolved; no extra completion task was added for this audit.
## T02 — Review remaining journeys and record transfer decisions
```task