diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 724a47f..e0e573b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,3 +50,19 @@ jobs: # so a change to it fails in CI rather than in an unattended deploy. test "$(printf '%s' "$line" | awk '{print $1}')" = "shuttle" test -n "$(printf '%s' "$line" | awk '{print $2}')" + + repository-pendant-source: + name: Repository pendant source controls + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.11" + - run: python -m pip install 'rdflib==7.6.0' + - run: | + python scripts/test-repository-knowledge.py + python scripts/test-repository-share-publication.py + python scripts/test-repository-share-query.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40d789e..8075737 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,3 +113,41 @@ jobs: --notes-file "$RUNNER_TEMP/notes.md" \ dist/shuttle-*.whl dist/shuttle-*.tar.gz dist/checksums.txt fi + + repository-pendant: + name: Verify repository pendant + if: vars.SHUTTLE_PENDANT_PUBLICATION == 'enabled' + needs: release + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: refs/tags/${{ inputs.tag || github.ref_name }} + fetch-depth: 0 + - name: Verify the bounded public pointer corpus + run: python3 scripts/verify-repository-share.py "$RELEASE_TAG" "$(git rev-parse HEAD)" + + repository-pendant-admission: + name: Record repository pendant admission + needs: release + runs-on: ubuntu-latest + permissions: + contents: read + env: + PENDANT_PUBLICATION: ${{ vars.SHUTTLE_PENDANT_PUBLICATION }} + steps: + - name: Record the publication decision + run: | + set -euo pipefail + if [ "$PENDANT_PUBLICATION" = enabled ]; then + state="ADMITTED: pendant verification remains required" + else + state="NOT ADMITTED: no trusted Shuttle pendant publisher is enabled" + fi + printf '%s\n' "$state" | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/docs/knowledge/README.md b/docs/knowledge/README.md new file mode 100644 index 0000000..290b620 --- /dev/null +++ b/docs/knowledge/README.md @@ -0,0 +1,40 @@ +# Repository pendant source + +The repository-owned release job exports code and document **pointers**, plus +reviewed summaries of ideas, interfaces, decisions, freshness and starting points. +It does not export source chunk text or line spans. Source witnesses bind each +summary to its defining tracked file. The native index must resolve every +curated pointer before the build can proceed. Stored queries and validating +shapes travel with the share. + +`alignments.ttl` is currently a valid empty SSSOM/RDF carrier. Its accompanying +status file explicitly records zero approved mappings and seals those bytes. +An engine advertising `share --alignments` carries this reviewed file physically, +with its exact manifest filename and SHA-256 binding. Older engines retain the +inline empty carrier without adding an undeclared archive member. +This is not evidence of global absence. Missing, changed or malformed input +refuses; an explicit empty carrier does not. Dependencies and glossary references +are not identity alignments. Code/commit deduplication and unrelated exactMatch +records are not cross-repository mapping approval. + +Candidate mappings must go through Quipu's existing `align propose` and +`align decide` curation path. Keep their source graphs and source witnesses; +do not fill an approval author from a lexical score. Only the reviewed mapping +record may produce derived identity through the engine's existing apply path. +The current source profile deliberately does not import arbitrary nonempty +mapping data until an actual curated artifact and its transport are reviewed. + +The copied release workflow verifies the three public assets after the ordinary +release finishes only when `SHUTTLE_PENDANT_PUBLICATION` is exactly `enabled`. +A separate job records the admission state; `NOT ADMITTED` does not claim +verified assets or enable a producer. The managed trusted host runs this repository's own +publisher, keeps its publication policy private, pins both native engine +executables and the indexer, and refuses retained uncertain upload outcomes. +Default configuration still publishes only the existing Quipu corpus. Enabling +another repository requires its actual trusted host configuration and a clean +release containing this job; committing this source does not publish an asset. + +The publisher requires the engine's model/digest/dimension manifest recipe. +Loading and vector rebuilding belong to the native engine. No hosted job gets +private publication policy or a new credential route. These source controls do +not establish public asset availability, live loading or service acceptance. diff --git a/docs/knowledge/alignment-status.json b/docs/knowledge/alignment-status.json new file mode 100644 index 0000000..88aca2f --- /dev/null +++ b/docs/knowledge/alignment-status.json @@ -0,0 +1,8 @@ +{ + "status": "no-approved-mappings", + "approved_record_count": 0, + "carrier_sha256": "a8f3d420900dce866437e3ecfaba792b4ac4c301ae4702966f9147d4b7d49e3b", + "scope": "scbrown/shuttle repository pendant source", + "basis": "No verified approved artifact or pairs supplied at source review; empty carrier is explicit. This is not a global absence claim.", + "curation_contract": "https://github.com/scbrown/quipu/blob/main/docs/design/cross-graph-alignment.md" +} diff --git a/docs/knowledge/alignments.ttl b/docs/knowledge/alignments.ttl new file mode 100644 index 0000000..b0f73f1 --- /dev/null +++ b/docs/knowledge/alignments.ttl @@ -0,0 +1,6 @@ +# SSSOM/RDF carrier: explicitly no approved Shuttle cross-stack mappings. +# This is an empty mapping graph, not an absent/corrupt input or a claim that +# no mappings exist anywhere. Candidate records require the existing curator +# path before publication as accepted mappings. Dependencies remain dependencies. +@prefix sssom: . +@prefix owl: . diff --git a/docs/knowledge/repository-knowledge.json b/docs/knowledge/repository-knowledge.json new file mode 100644 index 0000000..c98ac7b --- /dev/null +++ b/docs/knowledge/repository-knowledge.json @@ -0,0 +1,130 @@ +{ + "source_revision": "33001f173986a0496b3fc9f0afe4ebc658e2618f", + "reviewed_at": "2026-10-21T23:38:05.033622Z", + "max_age_seconds": 2592000, + "records": [ + { + "kind": "idea", + "source": "README.md", + "subject": "http://aegis.gastown.local/ontology/doc/shuttle/README.md", + "summary": "Shuttle records workflow transitions in a local append-only JSONL log and exports signed history into governed, time-windowed Quipu graphs.", + "source_sha256": "af53641f5f1d6b5a191f477cd40742cfb5ef5e8d2a37ffe4dc2af34563d3d82f", + "type": "Concept" + }, + { + "kind": "interface", + "source": "shuttle/cli.py", + "subject": "http://aegis.gastown.local/ontology/code/shuttle/shuttle%2Fcli.py", + "summary": "The CLI defines and advances workflows, exports history, verifies signatures and freezes windows. It has no HTTP or MCP server.", + "source_sha256": "c4255c23dc66050d2a8f8e928123db08ae9b7d1dddcb27018db464b8efbc6931", + "properties": { + "interfaceKind": "CLI", + "publicName": "shuttle" + } + }, + { + "kind": "interface", + "source": "shuttle/state.py", + "subject": "http://aegis.gastown.local/ontology/code/shuttle/shuttle%2Fstate.py", + "summary": "SHUTTLE_STATE_DIR selects local append-only workflow state; otherwise the user state directory is used.", + "source_sha256": "d1f0b8428753c569725b925d22e08d25d8590dc1d2a9f2106d172c7d44b74a5b", + "properties": { + "interfaceKind": "config", + "publicName": "SHUTTLE_STATE_DIR" + } + }, + { + "kind": "interface", + "source": "shuttle/signing.py", + "subject": "http://aegis.gastown.local/ontology/code/shuttle/shuttle%2Fsigning.py", + "summary": "SHUTTLE_KEY_DIR selects agent key storage; require-existing-key mode refuses missing or unsafe key files and does not create replacements.", + "source_sha256": "4e9a268d068030989e5131bb8d4d9e83e671b3c9e497aaa8fbd10449acc246e3", + "properties": { + "interfaceKind": "config", + "publicName": "SHUTTLE_KEY_DIR / existing_keys_only" + } + }, + { + "kind": "interface", + "source": "shuttle/model.py", + "subject": "http://aegis.gastown.local/ontology/code/shuttle/shuttle%2Fmodel.py", + "summary": "Workflow definitions and transition events are pure data models; replay folds validated transitions to reconstruct state.", + "source_sha256": "b6be4c052d4abda4965815da0e9a44d85dc19df2224e8cc29e1bb117f5126198", + "properties": { + "interfaceKind": "file-format", + "publicName": "Workflow definition JSON and transition records" + } + }, + { + "kind": "interface", + "source": "shuttle/quipu_client.py", + "subject": "http://aegis.gastown.local/ontology/code/shuttle/shuttle%2Fquipu_client.py", + "summary": "The client uses Quipu HTTP graph, knot and episode APIs. Capability and write refusals are distinct from empty results; it does not serve REST itself.", + "source_sha256": "9037e275caf804b0e9a5a0234631d92f85558b1c5cb7c9f09e2a5b541f622df7", + "properties": { + "interfaceKind": "library", + "publicName": "Quipu client" + }, + "depends_on": [ + "http://aegis.gastown.local/ontology/repo_quipu" + ] + }, + { + "kind": "interface", + "source": "shuttle/seeds.py", + "subject": "http://aegis.gastown.local/ontology/code/shuttle/shuttle%2Fseeds.py", + "summary": "Seed steps create and reconcile linked work through the configured sd executable; backend routing belongs to that client.", + "source_sha256": "b40c0c128697d92bcfcf326768fab2c9e545b8a291618f2362d068e2c9bbc7f6", + "properties": { + "interfaceKind": "library", + "publicName": "Seed-step reconciliation" + }, + "depends_on": [ + "http://aegis.gastown.local/ontology/repo_seeds" + ] + }, + { + "kind": "decision", + "source": "docs/design/shuttle.md", + "subject": "http://aegis.gastown.local/ontology/doc/shuttle/docs%2Fdesign%2Fshuttle.md", + "summary": "The v1 design deliberately exposes a CLI and library rather than a daemon, HTTP or MCP server. Consumers read the governed graph instead.", + "source_sha256": "959dd5719f22d0997e7438520eccacc3907fa260fa904f14903d2954a85ad166", + "type": "DecisionRecord", + "properties": { + "status": "active", + "supersessionStatus": "not-recorded-in-source" + } + }, + { + "kind": "start-here", + "source": "README.md", + "subject": "http://aegis.gastown.local/ontology/doc/shuttle/README.md", + "summary": "Begin with the workflow quick start and role in the stack; registration and production writes remain explicit operator actions.", + "source_sha256": "af53641f5f1d6b5a191f477cd40742cfb5ef5e8d2a37ffe4dc2af34563d3d82f", + "properties": { + "startHereOrder": 1, + "glossaryTerm": "https://scbrown.github.io/quechua/ns#Decision" + } + }, + { + "kind": "start-here", + "source": "docs/operating.md", + "subject": "http://aegis.gastown.local/ontology/doc/shuttle/docs%2Foperating.md", + "summary": "Read the operational command and environment contract before exporting or freezing workflow history.", + "source_sha256": "d5634302171e1787507a4e065b46126fd4e3daa328954209981719817382bc64", + "properties": { + "startHereOrder": 2 + } + }, + { + "kind": "start-here", + "source": "docs/seed-steps.md", + "subject": "http://aegis.gastown.local/ontology/doc/shuttle/docs%2Fseed-steps.md", + "summary": "Read seed-step visit, replay and outcome semantics before connecting work-item completion to workflow transitions.", + "source_sha256": "af2b562361814de135058f64047c0a36d0e8b40ec490d2bc26fd1b6374071ed9", + "properties": { + "startHereOrder": 3 + } + } + ] +} diff --git a/docs/knowledge/repository-knowledge.shapes.ttl b/docs/knowledge/repository-knowledge.shapes.ttl new file mode 100644 index 0000000..e0fd2bd --- /dev/null +++ b/docs/knowledge/repository-knowledge.shapes.ttl @@ -0,0 +1,62 @@ +@prefix a: . +@prefix sh: . +@prefix rdfs: . +@prefix xsd: . +@prefix sssom: . +@prefix owl: . +@prefix prov: . + +# This profile constrains only explicitly curated repository knowledge. It +# does not turn every imported Concept or CodeModule into a Bobbin record. +a:ShuttleKnowledgeProfile a sh:NodeShape ; + sh:targetSubjectsOf a:knowledgeKind ; + sh:property [ sh:path rdfs:label ; sh:minCount 1 ; ] ; + sh:property [ sh:path rdfs:comment ; sh:minCount 1 ; sh:datatype xsd:string ; ] ; + sh:property [ sh:path a:knowledgeKind ; sh:minCount 1 ; + sh:in ( "idea" "interface" "decision" "start-here" ) ; ] ; + sh:property [ sh:path a:filePath ; sh:minCount 1 ; sh:datatype xsd:string ; ] ; + sh:property [ sh:path a:reviewAfter ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:dateTime ; ] ; + sh:property [ sh:path a:sourceHash ; sh:minCount 1 ; sh:maxCount 1 ; sh:pattern "^[0-9a-f]{64}$" ; ] ; + # maxAge is seconds since the explicit source review, not an eviction rule. + sh:property [ sh:path a:maxAge ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ; sh:minExclusive 0 ; ] ; + sh:property [ sh:path prov:wasDerivedFrom ; sh:minCount 1 ; sh:nodeKind sh:IRI ; ] . + +# Select on the stable record discriminator, never on a field whose absence +# this profile must detect. The first branch admits the other known kinds; +# the second enforces this kind's metadata, even when its selector is deleted. +a:ShuttleInterfaceProfile a sh:NodeShape ; + sh:targetSubjectsOf a:knowledgeKind ; + sh:or ( + [ sh:property [ sh:path a:knowledgeKind ; sh:minCount 1 ; + sh:in ( "idea" "decision" "start-here" ) ; ] ] + [ sh:property [ sh:path a:interfaceKind ; sh:minCount 1 ; sh:maxCount 1 ; + sh:in ( "CLI" "MCP" "REST" "config" "file-format" "library" ) ; ] ; + sh:property [ sh:path a:publicName ; sh:minCount 1 ; sh:datatype xsd:string ; ] ; + sh:property [ sh:path a:depends_on ; sh:nodeKind sh:IRI ; ] ] + ) . + +a:ShuttleDecisionProfile a sh:NodeShape ; + sh:targetSubjectsOf a:knowledgeKind ; + sh:or ( + [ sh:property [ sh:path a:knowledgeKind ; sh:minCount 1 ; + sh:in ( "idea" "interface" "start-here" ) ; ] ] + [ sh:property [ sh:path a:status ; sh:minCount 1 ; sh:maxCount 1 ; + sh:in ( "active" "superseded" "proposed" ) ; ] ; + sh:property [ sh:path a:supersessionStatus ; sh:minCount 1 ; sh:maxCount 1 ; + sh:in ( "not-recorded-in-source" "recorded" ) ; ] ] + ) . + +# Existing SSSOM/RDF is a record, not a second identity-assertion format. The +# native align decide/apply path remains the only way to derive identity knots. +a:ShuttleMappingRecordProfile a sh:NodeShape ; + sh:targetSubjectsOf sssom:subject_id ; + sh:property [ sh:path rdfs:label ; sh:minCount 1 ; ] ; + sh:property [ sh:path sssom:subject_id ; sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; ] ; + sh:property [ sh:path sssom:object_id ; sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; ] ; + sh:property [ sh:path sssom:predicate_id ; sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; ] ; + sh:property [ sh:path sssom:mapping_justification ; sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; ] . + +a:ShuttleNoHandAuthoredIdentity a sh:NodeShape ; + sh:targetSubjectsOf owl:sameAs ; + sh:property [ sh:path rdfs:label ; sh:minCount 1 ; ] ; + sh:property [ sh:path owl:sameAs ; sh:maxCount 0 ; ] . diff --git a/docs/knowledge/stored-queries.json b/docs/knowledge/stored-queries.json new file mode 100644 index 0000000..2757a4c --- /dev/null +++ b/docs/knowledge/stored-queries.json @@ -0,0 +1,32 @@ +[ + { + "name": "repository_ideas", + "description": "Repository-owned repository ideas", + "template": "PREFIX a: PREFIX rdfs: SELECT * WHERE { ?s a a:Concept; rdfs:comment ?summary . FILTER(STRSTARTS(STR(?s), \"http://aegis.gastown.local/ontology/doc/shuttle/\") || STRSTARTS(STR(?s), \"http://aegis.gastown.local/ontology/code/shuttle/\")) } ORDER BY ?s LIMIT 100", + "params": [] + }, + { + "name": "repository_interfaces", + "description": "Repository-owned repository interfaces", + "template": "PREFIX a: PREFIX rdfs: SELECT * WHERE { ?s a:interfaceKind ?kind; a:publicName ?name . FILTER(STRSTARTS(STR(?s), \"http://aegis.gastown.local/ontology/doc/shuttle/\") || STRSTARTS(STR(?s), \"http://aegis.gastown.local/ontology/code/shuttle/\")) } ORDER BY ?s LIMIT 100", + "params": [] + }, + { + "name": "repository_decisions", + "description": "Repository-owned repository decisions", + "template": "PREFIX a: PREFIX rdfs: SELECT * WHERE { ?s a a:DecisionRecord; a:status ?status . OPTIONAL { ?s ?successor } FILTER(STRSTARTS(STR(?s), \"http://aegis.gastown.local/ontology/doc/shuttle/\") || STRSTARTS(STR(?s), \"http://aegis.gastown.local/ontology/code/shuttle/\")) } ORDER BY ?s LIMIT 100", + "params": [] + }, + { + "name": "repository_start_here", + "description": "Repository-owned repository start here", + "template": "PREFIX a: PREFIX rdfs: SELECT * WHERE { ?s a:startHereOrder ?order . FILTER(STRSTARTS(STR(?s), \"http://aegis.gastown.local/ontology/doc/shuttle/\") || STRSTARTS(STR(?s), \"http://aegis.gastown.local/ontology/code/shuttle/\")) } ORDER BY ?s LIMIT 100", + "params": [] + }, + { + "name": "repository_freshness", + "description": "Repository-owned repository freshness", + "template": "PREFIX a: PREFIX rdfs: SELECT * WHERE { ?s a:reviewAfter ?reviewAfter; a:maxAge ?maxAgeSeconds . FILTER(STRSTARTS(STR(?s), \"http://aegis.gastown.local/ontology/doc/shuttle/\") || STRSTARTS(STR(?s), \"http://aegis.gastown.local/ontology/code/shuttle/\")) } ORDER BY ?s LIMIT 100", + "params": [] + } +] diff --git a/queries/repository-share-shuttle.rq b/queries/repository-share-shuttle.rq new file mode 100644 index 0000000..cd36df7 --- /dev/null +++ b/queries/repository-share-shuttle.rq @@ -0,0 +1,68 @@ +PREFIX aegis: +PREFIX rdf: +PREFIX prov: +PREFIX dct: +PREFIX sssom: +PREFIX owl: +PREFIX rdfs: + +# Public repository knowledge only. Bobbin publishes its replaceable snapshot +# into ROOT, so graph/group selectors cannot see it. Keep this predicate list +# aligned with Bobbin's generate_chunk_turtle output: selecting every outgoing +# edge also pulls private operational literals into a public release artifact. +CONSTRUCT { ?s ?p ?o } +WHERE { + { + ?s ?p ?o . + FILTER( + STRSTARTS(STR(?s), "http://aegis.gastown.local/ontology/code/shuttle/") || + STRSTARTS(STR(?s), "http://aegis.gastown.local/ontology/doc/shuttle/") || + STRSTARTS(STR(?s), "http://aegis.gastown.local/ontology/chunk/shuttle/") + ) + VALUES ?p { + rdf:type rdfs:label + aegis:filePath aegis:repo aegis:language + aegis:inDocument aegis:chunkOrder aegis:nextChunk aegis:mentions + aegis:name aegis:definedIn aegis:symbolKind + aegis:heading aegis:headingDepth + rdfs:comment rdfs:seeAlso prov:wasDerivedFrom dct:modified dct:isReplacedBy + aegis:knowledgeKind aegis:interfaceKind aegis:publicName aegis:status + aegis:supersessionStatus aegis:startHereOrder aegis:glossaryTerm + aegis:reviewAfter aegis:maxAge aegis:depends_on aegis:sourceHash + } + # Bobbin emits weak mention names as strings. Quipu may additionally + # resolve those names to IRIs under the same predicate in its local store; + # those derived edges do not satisfy the shipped string-valued shape. + FILTER(?p != aegis:mentions || isLiteral(?o)) + } + UNION + { + ?s ?p ?o . + { ?s a sssom:MappingSet } UNION { ?s a owl:Axiom } + VALUES ?p { + rdf:type rdfs:label prov:wasDerivedFrom + sssom:mappings sssom:subject_id sssom:object_id sssom:predicate_id + sssom:subject_label sssom:object_label sssom:mapping_justification + sssom:predicate_modifier sssom:confidence sssom:author_id + } + } + # Versioned crew memory is internal source, including references attached to + # otherwise public context nodes. Internal backups use a separate explicit scope. + FILTER(!STRSTARTS(STR(?s), "urn:quipu:memory:")) + FILTER(!STRSTARTS(STR(?p), "https://quipu.dev/memory#")) + FILTER(!STRSTARTS(STR(?o), "urn:quipu:memory:")) + # Work tracking identifiers are not public repository knowledge. Filter + # both endpoints and literal mentions instead of renaming entity identity. + FILTER(!REGEX(STR(?s), "(^|[^A-Za-z0-9_])aegis-[A-Za-z0-9]")) + FILTER(!REGEX(STR(?o), "(^|[^A-Za-z0-9_])aegis-[A-Za-z0-9]")) + # Exclude the whole node when a required label contains an internal ID. + # Dropping only its label would leave a typed, SHACL-invalid fragment. + MINUS { + ?s ?internalPredicate ?internalValue . + FILTER(REGEX(STR(?internalValue), "(^|[^A-Za-z0-9_])aegis-[A-Za-z0-9]")) + } + MINUS { + ?o ?targetPredicate ?targetValue . + FILTER(REGEX(STR(?targetValue), "(^|[^A-Za-z0-9_])aegis-[A-Za-z0-9]")) + } +} diff --git a/scripts/build-repository-knowledge.py b/scripts/build-repository-knowledge.py new file mode 100644 index 0000000..4dd72cd --- /dev/null +++ b/scripts/build-repository-knowledge.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Render reviewed summaries onto existing indexed pointers, never new identities.""" + +import datetime +import hashlib +import json +from pathlib import Path +import re +from urllib.parse import quote + +A = "http://aegis.gastown.local/ontology/" +RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" +RDFS = "http://www.w3.org/2000/01/rdf-schema#" +PROV = "http://www.w3.org/ns/prov#" +XSD = "http://www.w3.org/2001/XMLSchema#" +KINDS = {"idea", "interface", "decision", "start-here"} +PROPERTIES = {"interfaceKind", "publicName", "status", "supersessionStatus", "startHereOrder", "glossaryTerm"} + + +def render(source, revision, lookup): + source = Path(source) + if not re.fullmatch("[0-9a-f]{40}", revision): + raise ValueError("release revision must be explicit") + registry = json.loads((source / "docs/knowledge/repository-knowledge.json").read_text()) + reviewed = datetime.datetime.fromisoformat(registry["reviewed_at"].replace("Z", "+00:00")) + age = registry["max_age_seconds"] + if reviewed.tzinfo is None or type(age) is not int or age <= 0: + raise ValueError("freshness requires an explicit UTC review time and positive seconds") + after = (reviewed + datetime.timedelta(seconds=age)).isoformat().replace("+00:00", "Z") + records = registry["records"] + if {record["kind"] for record in records} != KINDS: + raise ValueError("ideas/interfaces/decisions/start-here inventory incomplete") + lines, seen = [], set() + + def fact(subject, predicate, obj, iri=False, datatype=None): + if iri: + if not re.fullmatch(r'[^\s<>"{}|\\^`]+:[^\s<>"{}|\\^`]*', obj): + raise ValueError("invalid referenced IRI") + value = "<" + obj + ">" + else: + value = json.dumps(str(obj), ensure_ascii=False) + if datatype: + value += "^^<" + datatype + ">" + lines.append("<" + subject + "> <" + predicate + "> " + value + " .") + + for record in records: + path = record["source"] + witness = source / path + if ( + Path(path).is_absolute() + or ".." in Path(path).parts + or not witness.resolve().is_relative_to(source.resolve()) + or hashlib.sha256(witness.read_bytes()).hexdigest() != record["source_sha256"] + ): + raise ValueError("source witness changed: " + path) + subject = record["subject"] + bases = (A + "doc/shuttle/" + quote(path, safe=""), A + "code/shuttle/" + quote(path, safe="")) + if not any(subject == base or subject.startswith(base + "#") for base in bases): + raise ValueError("subject is not the source-backed indexed pointer") + # lookup must use the actual index: a deterministic-looking guessed IRI + # is not an identity proof. Never mint another node when it is absent. + label = lookup(subject) + if not isinstance(label, str) or not label: + raise ValueError("source pointer absent or ambiguous in actual index: " + subject) + if (subject, record["kind"]) in seen: + raise ValueError("duplicate knowledge record") + seen.add((subject, record["kind"])) + if record.get("type") not in ("Concept", "DecisionRecord", None): + raise ValueError("unsupported knowledge type") + fact(subject, RDFS + "label", label) + fact(subject, A + "filePath", path) + if record.get("type"): + fact(subject, RDF + "type", A + record["type"], iri=True) + summary = record["summary"] + if ( + not summary.strip() + or len(summary) > 1000 + or re.search(r"\b[\w-]+\.(?:svc|lan)\b|/home/|\baegis-[A-Za-z0-9]", summary) + ): + raise ValueError("unreviewed or private summary") + fact(subject, RDFS + "comment", summary) + fact(subject, A + "knowledgeKind", record["kind"]) + fact(subject, A + "sourceHash", record["source_sha256"]) + fact(subject, A + "reviewAfter", after, datatype=XSD + "dateTime") + fact(subject, A + "maxAge", age, datatype=XSD + "integer") + fact(subject, "http://purl.org/dc/terms/modified", registry["reviewed_at"], datatype=XSD + "dateTime") + fact( + subject, + PROV + "wasDerivedFrom", + "https://github.com/scbrown/shuttle/blob/" + revision + "/" + path, + iri=True, + ) + properties = record.get("properties", {}) + required = { + "interface": {"interfaceKind", "publicName"}, + "decision": {"status", "supersessionStatus"}, + }.get(record["kind"], set()) + if not isinstance(properties, dict) or any( + not isinstance(properties.get(key), str) or not properties[key].strip() + for key in required + ): + raise ValueError("required metadata missing for " + record["kind"]) + for key, value in properties.items(): + if key not in PROPERTIES: + raise ValueError("unknown content property") + if key == "startHereOrder": + if not str(value).isdigit() or int(value) <= 0: + raise ValueError("start-here order must be a positive integer") + fact(subject, A + key, int(value), datatype=XSD + "integer") + else: + fact(subject, A + key, value) + for dependency in record.get("depends_on", []): + fact(subject, A + "depends_on", dependency, iri=True) + for term in record.get("see_also", []): + fact(subject, RDFS + "seeAlso", term, iri=True) + return "\n".join(sorted(set(lines))) + "\n" diff --git a/scripts/build-repository-share.py b/scripts/build-repository-share.py new file mode 100644 index 0000000..e9e8794 --- /dev/null +++ b/scripts/build-repository-share.py @@ -0,0 +1,396 @@ +"""Build one outward stack share using the existing Quipu/Bobbin boundary. + +Source and tooling are separate fresh, tag-verified checkouts. Index configuration, +live policy, databases and receiver probes stay in the private working directory. +Only the verified share directory leaves this function. +""" + +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import sqlite3 +import io +import tarfile +import subprocess +import sys + + +def isolated_environment(private): + """Native workers must not inherit a host endpoint or credential catalogue.""" + private = Path(private) + env = dict(os.environ) + for key in list(env): + if key in { + "BOBBIN_SERVER", + "QUIPU_SERVER", + "QUIPU_DB", + "QUIPU_AUTH_TOKEN", + "QUIPU_AUTH_TOKEN_FILE", + "GH_TOKEN", + "GITHUB_TOKEN", + } or key.startswith("BOBBIN_QUIPU_"): + env.pop(key) + for key, name in ( + ("HOME", "native-home"), + ("XDG_CONFIG_HOME", "native-config"), + ("XDG_DATA_HOME", "native-data"), + ("XDG_CACHE_HOME", "native-cache"), + ): + directory = private / name + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + env[key] = str(directory) + return env + +def command(args, *, env=None, cwd=None): + if env is None and cwd is not None: + env = isolated_environment(cwd) + return subprocess.run( + [str(v) for v in args], + check=True, + capture_output=True, + text=True, + env=env, + cwd=cwd, + ).stdout + + +def local_mcp(binary, database, calls): + """Short-lived native process over a private DB; no HTTP or live graph.""" + import selectors + + requests = [ + { + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "repository-pendant-build", "version": "1"}, + }, + }, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + ] + requests.extend( + {"jsonrpc": "2.0", "id": number, "method": "tools/call", "params": {"name": name, "arguments": arguments}} + for number, (name, arguments) in enumerate(calls, 1) + ) + values = [] + with subprocess.Popen( + [str(binary), "mcp", "--db", str(database)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=Path(database).parent, + env=isolated_environment(Path(database).parent), + ) as process: + selector = selectors.DefaultSelector() + selector.register(process.stdout, selectors.EVENT_READ) + try: + for request in requests: + process.stdin.write(json.dumps(request) + "\n") + process.stdin.flush() + if "id" not in request: + continue + if not selector.select(timeout=30): + raise ValueError("native MCP response timeout") + response = json.loads(process.stdout.readline()) + if ( + response.get("id") != request["id"] + or "result" not in response + or "error" in response + or response["result"].get("isError", False) + ): + raise ValueError("native MCP refused operation: " + json.dumps(response)) + if request["id"]: + content = response["result"].get("content", []) + if len(content) != 1 or content[0].get("type") != "text": + raise ValueError("unexpected native MCP result envelope") + values.append(json.loads(content[0]["text"])) + finally: + selector.close() + process.stdin.close() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.terminate() + process.wait(timeout=10) + if process.returncode: + raise ValueError("native MCP process failed") + return values + + +def register_queries(binary, database, definitions): + if not isinstance(definitions, list) or not definitions: + raise ValueError("explicit repository stored query definitions required") + for definition in definitions: + if not isinstance(definition, dict) or definition.get("action", "load") != "load": + raise ValueError("stored query must be an explicit load definition") + results = local_mcp(binary, database, [("quipu_queries", {**d, "action": "load"}) for d in definitions]) + if [result.get("loaded") for result in results] != [d["name"] for d in definitions]: + raise ValueError("native registry did not return the exact loaded query names") + + +def indexed_labels(binary, database, subjects): + if any(not re.fullmatch(r'https?://[^\s<>"{}|\\^`]+', subject) for subject in subjects): + raise ValueError("invalid indexed subject IRI") + query = ( + "SELECT ?s ?label WHERE { VALUES ?s { " + + " ".join("<" + s + ">" for s in subjects) + + "} ?s ?label }" + ) + (result,) = local_mcp(binary, database, [("quipu_query", {"query": query, "verbose": True})]) + if result.get("truncated") is not False: + raise ValueError("indexed pointer lookup was incomplete") + labels = {} + for row in result["rows"]: + subject, label = row["s"], row["label"] + if isinstance(subject, dict): + subject = subject["value"] + if isinstance(label, dict): + label = label["value"] + if subject in labels and labels[subject] != label: + raise ValueError("ambiguous indexed pointer label") + labels[subject] = label + return labels + + +def materialize_release(source, revision, destination): + """Index only exact tracked release bytes in a new private source tree.""" + destination = Path(destination) + destination.mkdir(mode=0o700) + archive = subprocess.run( + ["git", "-C", str(source), "archive", "--format=tar", revision], check=True, capture_output=True + ).stdout + with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as bundle: + for member in bundle.getmembers(): + relative = Path(member.name) + if relative.is_absolute() or ".." in relative.parts or not (member.isfile() or member.isdir()): + raise ValueError("unsupported tracked release member") + target = destination / relative + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + with bundle.extractfile(member) as original, target.open("xb") as copied: + shutil.copyfileobj(original, copied) + return destination + + +def alignment_carrier(source): + """An explicit empty reviewed carrier differs from missing or corrupt data. + + Accepted nonempty mapping transport awaits an actual curated artifact. Do + not interpret a dependency, lexical match, or candidate as an identity. + """ + source = Path(source) + data = (source / "docs/knowledge/alignments.ttl").read_bytes() + status = json.loads((source / "docs/knowledge/alignment-status.json").read_text()) + if ( + status.get("status") != "no-approved-mappings" + or type(status.get("approved_record_count")) is not int + or status["approved_record_count"] != 0 + or status.get("carrier_sha256") != hashlib.sha256(data).hexdigest() + ): + raise ValueError("unreviewed or corrupt alignment carrier") + # Only declarations/comments are permitted in the explicit-empty profile. + # The native data validator below still parses the complete Turtle input. + for line in data.decode().splitlines(): + if ( + line.strip() + and not line.lstrip().startswith("#") + and line.strip() + not in {"@prefix sssom: .", "@prefix owl: ."} + ): + raise ValueError("empty alignment profile contains mapping data") + return data.decode() + + +def native_alignments(binary, private): + """Engines that advertise --alignments carry the reviewed physical file. + + Older engines keep the inline empty carrier and never emit an undeclared + archive member that an older receiver would refuse. + """ + usage = subprocess.run( + [str(binary), "share", "--help"], + capture_output=True, + text=True, + env=isolated_environment(private), + cwd=private, + ) + return "--alignments " in usage.stdout + usage.stderr + +def verify_carried_alignments(stage, alignments): + data = Path(alignments).read_bytes() + carried = json.loads((Path(stage) / "manifest.json").read_text()) + copied = Path(stage) / "alignments.ttl" + if ( + carried.get("alignments_hash") != "sha256:" + hashlib.sha256(data).hexdigest() + or carried.get("files", {}).get("alignments") != "alignments.ttl" + or not copied.is_file() + or copied.read_bytes() != data + ): + raise ValueError("native share did not carry the reviewed alignments") + + +def build(revision, source, tooling, binary, bobbin, output): + repository = "scbrown/shuttle" + source, tooling, output = map(Path, (source, tooling, output)) + if output.exists(): + raise ValueError("repository share output already exists") + if not re.fullmatch(r"scbrown/[a-z][a-z0-9-]*", repository): + raise ValueError("invalid repository identity") + slug = repository.split("/")[1] + if ( + not re.fullmatch(r"[0-9a-f]{40}", revision) + or command(["git", "-C", source, "rev-parse", "HEAD"]).strip() != revision + or command(["git", "-C", source, "status", "--porcelain"]).strip() + ): + raise ValueError("repository source is not the exact clean release") + paths = command(["git", "-C", source, "ls-files", "-z"]).split("\0") + control = next((p for p in ("README.md", "README.rst") if p in paths), None) + if control is None: + raise ValueError("release lacks a supported public corpus positive control") + private = output.parent / "build-private" + private.mkdir(mode=0o700) + + def run(args, *, env=None): + return command(args, env=env, cwd=private) + + policy = private / "policy.ttl" + run(["python3", tooling / "scripts/private-share-policy.py", policy], env=dict(os.environ)) + corpus = materialize_release(source, revision, private / "source") + (corpus / ".bobbin").mkdir(exist_ok=True) + index_database = private / "indexed-graph.db" + # Bobbin resolves embedded graph state at --source, not at the vector + # index root. Explicit private configuration avoids global-store fallback. + (corpus / ".bobbin/config.toml").write_text("[quipu]\nstore_path = " + json.dumps(str(index_database)) + "\n") + index = private / "index" + (index / ".bobbin").mkdir(parents=True) + (index / ".bobbin/config.toml").write_text("""quipu_push_chunks = true + +[index] +include = [ + "**/*.rs", "**/*.md", "**/*.rst", "**/*.toml", "**/*.yml", "**/*.yaml", "**/*.py", + "**/*.sh", "**/*.js", "**/*.mjs", "**/*.ts", "**/*.tsx", "**/*.json", "**/*.ttl", "**/*.go", +] +exclude = [ + "**/target/**", "**/.git/**", "**/.bobbin/**", "**/.agents/**", "**/.claude/**", "**/.codex/**", + "**/.mcp.json", "**/CLAUDE.local.md", "**/node_modules/**", "**/book/book/**", "**/vendor/**", +] +use_gitignore = true +entities = false + +[embedding] +model = "all-MiniLM-L6-v2" +batch_size = 64 +""") + env = isolated_environment(private) + run([bobbin, "index", index, "--source", corpus, "--repo", slug, "--force", "--skip-calibrate", "--json"], env=env) + database = private / "producer.db" + if not index_database.is_file(): + raise ValueError("indexer did not produce the repository graph") + with sqlite3.connect("file:" + str(index_database) + "?mode=ro", uri=True) as original: + with sqlite3.connect(database) as target: + original.backup(target) + shapes = sorted((tooling / "shapes").glob("*.ttl")) + if not shapes: + raise ValueError("verified engine source has no receiver shapes") + for shape in shapes: + run([binary, "shapes", "load", shape.stem, shape, "--db", database]) + run( + [ + binary, + "shapes", + "load", + "publication-policy", + tooling / "docs/knowledge/publication-policy.shapes.ttl", + "--db", + database, + ] + ) + run([binary, "knot", tooling / "docs/knowledge/publication-policy.ttl", "--db", database]) + run([binary, "knot", policy, "--graph", "urn:quipu:private-release-policy", "--db", database]) + # Actual indexed pointers and sealed repository/source provenance identify this corpus. + # Curated repository content and validating shapes are repo-owned. Refuse + # an incomplete source proposal rather than minting a pointer-only stub. + registry_path = source / "docs/knowledge/repository-knowledge.json" + knowledge = private / "repository-knowledge.ttl" + constraints = source / "docs/knowledge/repository-knowledge.shapes.ttl" + queries = source / "docs/knowledge/stored-queries.json" + alignments = source / "docs/knowledge/alignments.ttl" + for required in (registry_path, constraints, queries, alignments): + if not required.is_file() or not required.stat().st_size: + raise ValueError("required repository knowledge member missing: " + required.name) + import importlib.util + + spec = importlib.util.spec_from_file_location( + "repository_knowledge", source / "scripts/build-repository-knowledge.py" + ) + generator = importlib.util.module_from_spec(spec) + _previous_bytecode = sys.dont_write_bytecode + try: + sys.dont_write_bytecode = True + spec.loader.exec_module(generator) + finally: + sys.dont_write_bytecode = _previous_bytecode + records = json.loads(registry_path.read_text())["records"] + labels = indexed_labels(binary, database, sorted({record["subject"] for record in records})) + knowledge.write_text( + generator.render(source, revision, lambda subject: labels.get(subject)) + "\n" + alignment_carrier(source) + ) + run([binary, "validate", "--shapes", constraints, "--data", knowledge]) + run([binary, "shapes", "load", "repository-knowledge", constraints, "--db", database]) + run([binary, "knot", knowledge, "--db", database]) + register_queries(binary, database, json.loads(queries.read_text())) + query = (source / "queries/repository-share-shuttle.rq").read_text() + stage = private / "share" + native = native_alignments(binary, private) + run([binary, "share", "--construct", query, "--turtle", "--output", stage] + + (["--alignments", alignments] if native else []) + ["--db", database]) + if native: + verify_carried_alignments(stage, alignments) + if not (stage / "shapes.ttl").is_file() or (stage / "shapes.ttl").stat().st_size == 0: + raise ValueError("repository share omitted receiver shapes") + receiver = private / "receiver.db" + run([binary, "shapes", "load", "repository-share", stage / "shapes.ttl", "--db", receiver]) + imported = json.loads(run([binary, "import", stage, "--db", receiver])) + if ( + imported.get("outcome") != "staged" + or imported.get("promotion", {}).get("eligible") is not True + or type(imported.get("triples", {}).get("accepted")) is not int + or imported["triples"]["accepted"] <= 3 + ): + raise ValueError("repository receiver did not admit a non-stub corpus") + run([binary, "import", "promote", imported["share_id"], "--db", receiver]) + positive = run( + [ + binary, + "query", + "SELECT ?s WHERE { ?s " + json.dumps(control) + " } LIMIT 1", + "--db", + receiver, + ] + ) + if re.search(r"\n1 results\s*$", positive) is None: + raise ValueError("repository corpus positive control missing after import") + run(["python3", tooling / "scripts/verify-repository-share-receiver.py", binary, stage, receiver, private]) + if run(["git", "-C", source, "status", "--porcelain"]).strip(): + raise ValueError("indexing changed the exact repository source") + shutil.move(stage, output) + return {"triples": imported["triples"]["accepted"], "positive_file": control} + + +if __name__ == "__main__": + import sys + + if len(sys.argv) != 7: + raise SystemExit( + "usage: build-repository-share.py " + ) + build(*sys.argv[1:]) diff --git a/scripts/publish-repository-share.sh b/scripts/publish-repository-share.sh new file mode 100755 index 0000000..3b0f0f9 --- /dev/null +++ b/scripts/publish-repository-share.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Repo-owned job, invoked only by the managed trusted-host release dispatcher. +set -euo pipefail +umask 077 +exec python3 "$(dirname -- "${BASH_SOURCE[0]}")/publish_repository_share.py" "$@" diff --git a/scripts/publish_repository_share.py b/scripts/publish_repository_share.py new file mode 100644 index 0000000..e55ed26 --- /dev/null +++ b/scripts/publish_repository_share.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Shuttle's owned trusted release job; hosted workflows only verify its output.""" + +import gzip +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile + +REPOSITORY = "scbrown/shuttle" +MEMBERS = {"export.nt", "export.ttl", "payload.nq", "manifest.json", "manifest.ttl", "shapes.ttl", "queries.ttl", "alignments.ttl"} + + +def command(args): + return subprocess.run([str(v) for v in args], check=True, capture_output=True, text=True).stdout + + +def digest(path): + result = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1 << 20), b""): + result.update(block) + return result.hexdigest() + + +def check_alignment_member(stage, manifest): + """Accept a physical optional carrier only with its exact manifest binding.""" + files = manifest.get("files", {}) + if not isinstance(files, dict): + raise ValueError("invalid alignment member declaration") + member = stage / "alignments.ttl" + claimed = manifest.get("alignments_hash") + if member.exists() or claimed is not None or "alignments" in files: + if ( + files.get("alignments") != "alignments.ttl" + or not isinstance(claimed, str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", claimed) is None + or not member.is_file() + or claimed != "sha256:" + digest(member) + ): + raise ValueError("missing, undeclared or corrupt alignment member") + + +def check_share(stage): + files = list(stage.iterdir()) + if any(not p.is_file() or p.is_symlink() or p.name not in MEMBERS for p in files): + raise ValueError("unexpected or non-regular share member") + for name in ("export.nt", "manifest.json", "manifest.ttl", "shapes.ttl", "queries.ttl"): + if not (stage / name).is_file() or not (stage / name).stat().st_size: + raise ValueError("required nonempty share member missing: " + name) + manifest = json.loads((stage / "manifest.json").read_text()) + check_alignment_member(stage, manifest) + recipe = manifest.get("embedding") + if ( + not isinstance(recipe, dict) + or not isinstance(recipe.get("model"), str) + or not recipe["model"] + or Path(recipe["model"]).name != recipe["model"] + or type(recipe.get("dimension")) is not int + or recipe["dimension"] <= 0 + or any(not re.fullmatch("[0-9a-f]{64}", recipe.get(k, "")) for k in ("model_sha256", "tokenizer_sha256")) + ): + raise ValueError("share omitted a provider-byte embedding recipe") + # This repository pack is pointer-only. Curated idea summaries use + # rdfs:comment; source bodies and line spans are never release predicates. + export = (stage / "export.nt").read_text() + for predicate in ("content", "startLine", "endLine"): + if "" in export: + raise ValueError("share duplicates source text or line-span data") + return recipe + + +def package(stage, archive): + check_share(stage) + with archive.open("xb") as raw: + with gzip.GzipFile(filename="", fileobj=raw, mode="wb", mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w") as tar: + for path in sorted(stage.iterdir()): + info = tar.gettarinfo(str(path), arcname=path.name) + info.uid = info.gid = 0 + info.uname = info.gname = "" + info.mtime = 0 + info.mode = 0o600 + with path.open("rb") as handle: + tar.addfile(info, handle) + + +def held(): + path = Path(os.environ.get("QUIPU_SHARE_HOLD_FILE", str(Path.home() / ".quipu-share-hold"))) + try: + path.lstat() + except FileNotFoundError: + return False + except OSError as error: + raise ValueError("cannot inspect publication hold") from error + return True + + +def publish(tag, revision, output): + if held(): + raise ValueError("publication held before any lookup/build") + if not re.fullmatch(r"v\d+\.\d+\.\d+(?:[.-][A-Za-z0-9.-]+)?", tag): + raise ValueError("unexpected Bobbin release tag") + if not re.fullmatch("[0-9a-f]{40}", revision): + raise ValueError("invalid release revision") + source = Path(__file__).resolve().parents[1] + if ( + command(["git", "-C", source, "rev-parse", "HEAD"]).strip() != revision + or command(["git", "-C", source, "rev-parse", "refs/tags/" + tag + "^{commit}"]).strip() != revision + or command(["git", "-C", source, "status", "--porcelain"]).strip() + ): + raise ValueError("publisher source is not the exact clean release") + output = Path(output) + if output.exists(): + raise ValueError("retained output exists; reconcile before retrying") + binary = Path(os.environ["QUIPU_SHARE_ENGINE_BIN"]).resolve(strict=True) + tooling = Path(os.environ["QUIPU_SHARE_ENGINE_SOURCE"]).resolve(strict=True) + bobbin = Path(os.environ["QUIPU_SHARE_BOBBIN"]).resolve(strict=True) + engine_revision = os.environ["QUIPU_SHARE_ENGINE_REVISION"] + if ( + not re.fullmatch("[0-9a-f]{40}", engine_revision) + or command(["git", "-C", tooling, "rev-parse", "HEAD"]).strip() != engine_revision + or command(["git", "-C", tooling, "status", "--porcelain"]).strip() + or digest(binary) != os.environ["QUIPU_SHARE_ENGINE_BIN_SHA256"] + or digest(binary.with_name("quipu-server")) != os.environ["QUIPU_SHARE_ENGINE_SERVER_SHA256"] + or digest(bobbin) != os.environ["QUIPU_SHARE_BOBBIN_SHA256"] + ): + raise ValueError("trusted engine/source/indexer pin mismatch") + output.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + with tempfile.TemporaryDirectory(prefix="prepare-", dir=output.parent) as temp: + private = Path(temp) + stage = private / "share" + spec = importlib.util.spec_from_file_location( + "bobbin_share_build", source / "scripts/build-repository-share.py" + ) + builder = importlib.util.module_from_spec(spec) + _previous_bytecode = sys.dont_write_bytecode + try: + sys.dont_write_bytecode = True + spec.loader.exec_module(builder) + finally: + sys.dont_write_bytecode = _previous_bytecode + builder.build(revision, source, tooling, binary, bobbin, stage) + archive_name = "shuttle-" + tag + "-repository.pendant.tar.gz" + prepared = private / "assets" + prepared.mkdir(mode=0o700) + archive = prepared / archive_name + package(stage, archive) + (prepared / (archive_name + ".sha256")).write_text(digest(archive) + " " + archive_name + "\n") + proof = { + "schema": "quipu.repository-share-producer/v1", + "source_repository": REPOSITORY, + "source_revision": revision, + "release_tag": tag, + "archive_sha256": digest(archive), + "quipu_sha256": digest(binary), + "engine_source_revision": engine_revision, + "bobbin_sha256": digest(bobbin), + "quipu_server_sha256": digest(binary.with_name("quipu-server")), + "destination": "outward", + } + (prepared / (archive_name + ".provenance.json")).write_text(json.dumps(proof, sort_keys=True, indent=2) + "\n") + names = sorted(p.name for p in prepared.iterdir()) + if held(): + raise ValueError("publication held after preparation") + available = json.loads(command(["gh", "release", "view", tag, "--repo", REPOSITORY, "--json", "assets"])) + present = {a["name"] for a in available["assets"]} & set(names) + if present and present != set(names): + raise ValueError("partial publication; explicit reconciliation required") + # Retain before the one-way upload. No --clobber and no automatic retry + # from an uncertain write. The managed dispatcher also retains STARTED. + shutil.copytree(prepared, output) + if not present: + if held(): + raise ValueError("publication held immediately before upload") + command(["gh", "release", "upload", tag, "--repo", REPOSITORY, *(output / name for name in names)]) + downloaded = private / "downloaded" + downloaded.mkdir() + command( + [ + "gh", + "release", + "download", + tag, + "--repo", + REPOSITORY, + "--dir", + downloaded, + *(arg for name in names for arg in ("--pattern", name)), + ] + ) + for name in names: + if (downloaded / name).read_bytes() != (output / name).read_bytes(): + raise ValueError("existing or newly published asset differs; never overwrite") + receipt = { + **proof, + "published_and_read_back": True, + "assets": names, + "asset_sha256": {name: digest(downloaded / name) for name in names}, + } + (output / "delivery.json").write_text(json.dumps(receipt, indent=2) + "\n") + return receipt + + +if __name__ == "__main__": + if len(sys.argv) != 5 or sys.argv[1] != "--trusted-release": + raise SystemExit("usage: publish-repository-share.sh --trusted-release ") + print(json.dumps(publish(sys.argv[2], sys.argv[3], sys.argv[4]))) diff --git a/scripts/test-repository-knowledge.py b/scripts/test-repository-knowledge.py new file mode 100644 index 0000000..1b27c7e --- /dev/null +++ b/scripts/test-repository-knowledge.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Curated-source refusal controls. Lookup fixtures are not index coverage proof. + +# arming: ci; release contract invokes this script. +""" + +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("knowledge", ROOT / "scripts/build-repository-knowledge.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +builder_spec = importlib.util.spec_from_file_location("share_builder", ROOT / "scripts/build-repository-share.py") +builder = importlib.util.module_from_spec(builder_spec) +builder_spec.loader.exec_module(builder) + + +class ReleaseSnapshot(unittest.TestCase): + def test_real_git_archive_excludes_untracked_key_and_preserves_source(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + source = root / "source" + source.mkdir() + subprocess.run(["git", "init", "-q", str(source)], check=True) + (source / "README.md").write_text("tracked pointer source\n") + subprocess.run(["git", "-C", str(source), "add", "README.md"], check=True) + subprocess.run( + [ + "git", + "-C", + str(source), + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.org", + "commit", + "-qm", + "fixture", + ], + check=True, + ) + (source / "private.key").write_text("synthetic untracked sentinel") + revision = subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip() + copied = builder.materialize_release(source, revision, root / "copied") + self.assertEqual((copied / "README.md").read_bytes(), (source / "README.md").read_bytes()) + self.assertFalse((copied / "private.key").exists()) + self.assertEqual((source / "private.key").read_text(), "synthetic untracked sentinel") + self.assertFalse((source / ".bobbin").exists()) + + +class IndexIsolation(unittest.TestCase): + def test_private_configuration_and_credentials_do_not_follow_parent(self): + from unittest.mock import patch + with tempfile.TemporaryDirectory() as folder: + with patch.dict(os.environ, {"HOME": "/synthetic-parent", "XDG_CONFIG_HOME": "/synthetic-global", + "BOBBIN_SERVER": "https://invalid.example", "QUIPU_AUTH_TOKEN": "synthetic-only"}): + env = builder.isolated_environment(Path(folder)) + self.assertEqual(env["HOME"], str(Path(folder) / "native-home")) + self.assertEqual(env["XDG_CONFIG_HOME"], str(Path(folder) / "native-config")) + self.assertNotIn("BOBBIN_SERVER", env) + self.assertNotIn("QUIPU_AUTH_TOKEN", env) + self.assertEqual(list((Path(folder) / "native-config").iterdir()), []) + + +class AlignmentCarrier(unittest.TestCase): + def test_explicit_empty_positive_missing_corrupt_and_nonempty_negative(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + target = root / "docs/knowledge" + target.mkdir(parents=True) + for name in ("alignments.ttl", "alignment-status.json"): + shutil.copyfile(ROOT / "docs/knowledge" / name, target / name) + self.assertIn("@prefix sssom:", builder.alignment_carrier(root)) + data = target / "alignments.ttl" + original = data.read_text() + data.unlink() + with self.assertRaises(FileNotFoundError): + builder.alignment_carrier(root) + data.write_text(original + " .\n") + with self.assertRaisesRegex(ValueError, "corrupt"): + builder.alignment_carrier(root) + import hashlib + + status = json.loads((target / "alignment-status.json").read_text()) + status["carrier_sha256"] = hashlib.sha256(data.read_bytes()).hexdigest() + (target / "alignment-status.json").write_text(json.dumps(status)) + with self.assertRaisesRegex(ValueError, "mapping data"): + builder.alignment_carrier(root) + data.write_text("@prefix owl: . .\n") + status["carrier_sha256"] = hashlib.sha256(data.read_bytes()).hexdigest() + (target / "alignment-status.json").write_text(json.dumps(status)) + with self.assertRaisesRegex(ValueError, "mapping data"): + builder.alignment_carrier(root) + + + def test_native_alignment_transport_is_advertised_and_hash_bound(self): + import hashlib + + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + private = root / "private" + private.mkdir() + for name, usage in ( + ("current", "[--queries ]... [--alignments ] [--turtle]"), + ("older", "[--queries ]... [--turtle]"), + ): + engine = root / name + # Real engines print share usage to stderr and exit nonzero. + engine.write_text(f"#!/bin/sh\necho 'usage: quipu share {usage}' >&2\nexit 1\n") + engine.chmod(0o755) + self.assertTrue(builder.native_alignments(root / "current", private)) + self.assertFalse(builder.native_alignments(root / "older", private)) + reviewed = ROOT / "docs/knowledge/alignments.ttl" + stage = root / "share" + stage.mkdir() + shutil.copyfile(reviewed, stage / "alignments.ttl") + manifest = { + "alignments_hash": "sha256:" + + hashlib.sha256(reviewed.read_bytes()).hexdigest(), + "files": {"alignments": "alignments.ttl"}, + } + (stage / "manifest.json").write_text(json.dumps(manifest)) + builder.verify_carried_alignments(stage, reviewed) + for corrupt in ( + {**manifest, "alignments_hash": manifest["alignments_hash"][7:]}, + {**manifest, "files": {}}, + ): + (stage / "manifest.json").write_text(json.dumps(corrupt)) + with self.assertRaisesRegex(ValueError, "reviewed alignments"): + builder.verify_carried_alignments(stage, reviewed) + (stage / "manifest.json").write_text(json.dumps(manifest)) + with (stage / "alignments.ttl").open("a") as copied: + copied.write(" .\n") + with self.assertRaisesRegex(ValueError, "reviewed alignments"): + builder.verify_carried_alignments(stage, reviewed) + (stage / "alignments.ttl").unlink() + with self.assertRaisesRegex(ValueError, "reviewed alignments"): + builder.verify_carried_alignments(stage, reviewed) + + +class NativeIsolation(unittest.TestCase): + def test_real_subprocess_cannot_see_synthetic_global_config_or_endpoints(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + host, private = root / "host", root / "private" + private.mkdir() + config = host / ".config/bobbin" + config.mkdir(parents=True) + (config / "config.toml").write_text('[server]\nurl="http://127.0.0.1:9"\n') + self.assertTrue((config / "config.toml").exists()) + code = ( + "import os,json;from pathlib import Path;" + 'visible=(Path(os.environ["HOME"])/".config/bobbin/config.toml").exists();' + "remote=any(k in os.environ for k in " + '("BOBBIN_SERVER","BOBBIN_QUIPU_REMOTE","QUIPU_SERVER","QUIPU_AUTH_TOKEN","GH_TOKEN"));' + 'print(json.dumps({"global_config_visible":visible,"remote_or_upload_env_visible":remote}));' + "raise SystemExit(3 if visible or remote else 0)" + ) + environment = { + "HOME": str(host), + "XDG_CONFIG_HOME": str(host / ".config"), + "BOBBIN_SERVER": "http://127.0.0.1:9", + "BOBBIN_QUIPU_REMOTE": "http://127.0.0.1:9", + "QUIPU_SERVER": "http://127.0.0.1:9", + "QUIPU_AUTH_TOKEN": "synthetic-only", + "GH_TOKEN": "synthetic-only", + } + with patch.dict(os.environ, environment): + output = builder.command([sys.executable, "-c", code], cwd=private) + policy_code = code.replace( + "3 if visible or remote else 0", "0 if visible and remote else 3" + ) + policy = builder.command( + [sys.executable, "-c", policy_code], + env=dict(os.environ), + cwd=private, + ) + self.assertIn('"global_config_visible": false', output) + self.assertIn('"remote_or_upload_env_visible": false', output) + self.assertIn('"global_config_visible": true', policy) + self.assertIn('"remote_or_upload_env_visible": true', policy) + + +class Knowledge(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.registry = json.loads((ROOT / "docs/knowledge/repository-knowledge.json").read_text()) + for source in {r["source"] for r in self.registry["records"]}: + target = self.root / source + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / source, target) + (self.root / "docs/knowledge").mkdir(parents=True) + self.save() + + def save(self): + (self.root / "docs/knowledge/repository-knowledge.json").write_text(json.dumps(self.registry)) + + def render(self, lookup=lambda _: "actual-index-label-fixture"): + return module.render(self.root, "1" * 40, lookup) + + def test_source_bound_nonempty_content(self): + text = self.render() + self.assertIn("2592000", text) + self.assertIn("reviewAfter", text) + self.assertIn('knowledgeKind> "idea"', text) + self.assertIn('interfaceKind> "file-format"', text) + self.assertIn('supersessionStatus> "not-recorded-in-source"', text) + self.assertIn('startHereOrder> "3"^^', text) + self.assertNotIn("/content>", text) + self.assertNotIn("owl#sameAs", text) + + def test_absent_or_ambiguous_index_pointer_refuses(self): + for value in (None, False, ""): + with self.subTest(value=value), self.assertRaisesRegex(ValueError, "absent or ambiguous"): + self.render(lambda _: value) + + def test_changed_source_refuses(self): + path = self.root / self.registry["records"][0]["source"] + path.write_text(path.read_text() + "\nchanged\n") + with self.assertRaisesRegex(ValueError, "witness changed"): + self.render() + + def test_guessed_identity_refuses(self): + self.registry["records"][0]["subject"] = "https://example.org/guessed" + self.save() + with self.assertRaisesRegex(ValueError, "source-backed"): + self.render() + + def test_missing_required_content_refuses(self): + self.registry["records"] = [r for r in self.registry["records"] if r["kind"] != "decision"] + self.save() + with self.assertRaisesRegex(ValueError, "inventory incomplete"): + self.render() + + def test_kind_metadata_deletions_refuse_in_renderer(self): + for kind, fields in (("interface", ("interfaceKind", "publicName")), + ("decision", ("status", "supersessionStatus"))): + for deleted in ((fields[0],), (fields[1],), fields): + with self.subTest(kind=kind, deleted=deleted): + self.registry = json.loads((ROOT / "docs/knowledge/repository-knowledge.json").read_text()) + for record in self.registry["records"]: + if record["kind"] == kind: + for key in deleted: + record["properties"].pop(key) + self.save() + with self.assertRaisesRegex(ValueError, "required metadata"): + self.render() + + @unittest.skipUnless(os.environ.get("QUIPU_TEST_BIN"), "native schema control requires explicit test artifact") + def test_native_schema_kind_metadata_deletions_refuse(self): + import rdflib + a = rdflib.Namespace(module.A) + valid = self.render() + data = self.root / "kind-metadata.ttl" + args = [os.environ["QUIPU_TEST_BIN"], "validate", "--shapes", + str(ROOT / "docs/knowledge/repository-knowledge.shapes.ttl"), + "--data", str(data), "--format", "json"] + data.write_text(valid) + positive = subprocess.run(args, capture_output=True, text=True, cwd=self.root) + self.assertEqual(positive.returncode, 0, positive.stderr + positive.stdout) + for kind, fields in (("interface", ("interfaceKind", "publicName")), + ("decision", ("status", "supersessionStatus"))): + for deleted in ((fields[0],), (fields[1],), fields): + with self.subTest(kind=kind, deleted=deleted): + graph = rdflib.Graph().parse(data=valid, format="turtle") + subjects = set(graph.subjects(a.knowledgeKind, rdflib.Literal(kind))) + self.assertTrue(subjects) + for subject in subjects: + for key in deleted: + graph.remove((subject, a[key], None)) + data.write_text(graph.serialize(format="turtle")) + result = subprocess.run(args, capture_output=True, text=True, cwd=self.root) + self.assertNotEqual(result.returncode, 0, result.stderr + result.stdout) + + def test_bad_freshness_and_private_summary_refuse(self): + self.registry["max_age_seconds"] = 0 + self.save() + with self.assertRaisesRegex(ValueError, "freshness"): + self.render() + self.registry["max_age_seconds"] = 30 + self.registry["records"][0]["summary"] = "read /home/private/file" + self.save() + with self.assertRaisesRegex(ValueError, "private summary"): + self.render() + + @unittest.skipUnless(os.environ.get("QUIPU_TEST_BIN"), "native schema control requires explicit test artifact") + def test_actual_native_schema_positive_and_missing_freshness_negative(self): + data = self.root / "curated.ttl" + data.write_text(self.render()) + args = [ + os.environ["QUIPU_TEST_BIN"], + "validate", + "--shapes", + str(ROOT / "docs/knowledge/repository-knowledge.shapes.ttl"), + "--data", + str(data), + "--format", + "json", + ] + positive = subprocess.run(args, capture_output=True, text=True, cwd=self.root) + self.assertEqual(positive.returncode, 0, positive.stderr + positive.stdout) + data.write_text("\n".join(line for line in data.read_text().splitlines() if "/reviewAfter>" not in line)) + negative = subprocess.run(args, capture_output=True, text=True, cwd=self.root) + self.assertNotEqual(negative.returncode, 0, negative.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-repository-share-publication.py b/scripts/test-repository-share-publication.py new file mode 100644 index 0000000..c02acda --- /dev/null +++ b/scripts/test-repository-share-publication.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Packaging/readback refusal controls; synthetic data is not native acceptance. + +# arming: ci; release asset publication contract invokes this script. +""" + +import importlib.util +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parent + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +publisher = load("publisher_test", ROOT / "publish_repository_share.py") +verifier = load("verifier_test", ROOT / "verify-repository-share.py") + + +class Publication(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.stage = self.root / "stage" + self.stage.mkdir() + recipe = {"model": "model.onnx", "model_sha256": "a" * 64, "tokenizer_sha256": "b" * 64, "dimension": 384} + (self.stage / "manifest.json").write_text(json.dumps({"embedding": recipe})) + for name in ("export.nt", "manifest.ttl", "shapes.ttl", "queries.ttl"): + (self.stage / name).write_text("# synthetic fixture only\n") + self.archive = self.root / "shuttle-v1.2.3-repository.pendant.tar.gz" + self.revision = "1" * 40 + + def prepare(self): + publisher.package(self.stage, self.archive) + sha = publisher.digest(self.archive) + self.archive.with_name(self.archive.name + ".sha256").write_text(sha + " " + self.archive.name + "\n") + self.proof = { + "schema": "quipu.repository-share-producer/v1", + "source_repository": "scbrown/shuttle", + "source_revision": self.revision, + "release_tag": "v1.2.3", + "archive_sha256": sha, + "destination": "outward", + "quipu_sha256": "2" * 64, + "quipu_server_sha256": "5" * 64, + "bobbin_sha256": "3" * 64, + "engine_source_revision": "4" * 40, + } + self.proof_path = self.archive.with_name(self.archive.name + ".provenance.json") + self.proof_path.write_text(json.dumps(self.proof)) + + def test_deterministic_archive_and_exact_readback(self): + self.prepare() + other = self.root / "second.tar.gz" + publisher.package(self.stage, other) + self.assertEqual(self.archive.read_bytes(), other.read_bytes()) + self.assertEqual(verifier.verify(self.root, "v1.2.3", self.revision)["source_revision"], self.revision) + + def test_required_members_and_recipe(self): + (self.stage / "queries.ttl").unlink() + with self.assertRaisesRegex(ValueError, "required nonempty"): + publisher.check_share(self.stage) + (self.stage / "queries.ttl").write_text("# query") + (self.stage / "manifest.json").write_text("{}") + with self.assertRaisesRegex(ValueError, "provider-byte"): + publisher.check_share(self.stage) + + def test_text_line_span_and_private_members_refuse(self): + for predicate in ("content", "startLine", "endLine"): + (self.stage / "export.nt").write_text( + " "body" .\n' + ) + with self.subTest(predicate=predicate), self.assertRaisesRegex(ValueError, "duplicates source"): + publisher.check_share(self.stage) + (self.stage / "export.nt").write_text("# empty synthetic") + (self.stage / "policy-token").write_text("not a real token") + with self.assertRaisesRegex(ValueError, "unexpected"): + publisher.check_share(self.stage) + + def test_foreign_repository_and_wrong_source_refuse(self): + self.prepare() + for field, value in [ + ("source_repository", "scbrown/quipu"), + ("source_revision", "f" * 40), + ("release_tag", "v9.9.9"), + ("archive_sha256", "f" * 64), + ]: + self.proof_path.write_text(json.dumps({**self.proof, field: value})) + with self.subTest(field=field), self.assertRaisesRegex(ValueError, "provenance mismatch"): + verifier.verify(self.root, "v1.2.3", self.revision) + + def test_checksum_refuses(self): + self.prepare() + self.archive.with_name(self.archive.name + ".sha256").write_text("wrong") + with self.assertRaisesRegex(ValueError, "checksum"): + verifier.verify(self.root, "v1.2.3", self.revision) + + def test_hold_absent_and_dangling_symlink_before_any_build(self): + hold = self.root / "hold" + with patch.dict(publisher.os.environ, {"QUIPU_SHARE_HOLD_FILE": str(hold)}): + self.assertFalse(publisher.held()) + hold.symlink_to(self.root / "missing-target") + self.assertTrue(publisher.held()) + with self.assertRaisesRegex(ValueError, "held before"): + publisher.publish("invalid-tag", "invalid-revision", self.root / "output") + self.assertFalse((self.root / "output").exists()) + + def test_unreadable_hold_fails_closed(self): + with patch.object(Path, "lstat", side_effect=PermissionError("synthetic denied")): + with self.assertRaisesRegex(ValueError, "cannot inspect publication hold"): + publisher.held() + + def test_optional_alignment_member_is_declared_and_hash_bound(self): + import hashlib + data = b"@prefix sssom: .\n" + member = self.stage / "alignments.ttl" + member.write_bytes(data) + manifest = json.loads((self.stage / "manifest.json").read_text()) + bound = {**manifest, "alignments_hash": "sha256:" + hashlib.sha256(data).hexdigest(), + "files": {"alignments": "alignments.ttl"}} + (self.stage / "manifest.json").write_text(json.dumps(bound)) + self.prepare() + tag = self.proof["release_tag"] + self.assertEqual(verifier.verify(self.root, tag, self.revision)["source_revision"], self.revision) + for invalid in ( + manifest, + {**bound, "files": {}}, + {**bound, "alignments_hash": bound["alignments_hash"][7:]}, + {**bound, "files": {"alignments": "other.ttl"}}, + ): + (self.stage / "manifest.json").write_text(json.dumps(invalid)) + with self.assertRaisesRegex(ValueError, "alignment member"): + publisher.check_share(self.stage) + (self.stage / "manifest.json").write_text(json.dumps(bound)) + member.write_bytes(data + b"# tampered\n") + with self.assertRaisesRegex(ValueError, "alignment member"): + publisher.check_share(self.stage) + member.unlink() + with self.assertRaisesRegex(ValueError, "alignment member"): + publisher.check_share(self.stage) + (self.stage / "manifest.json").write_text(json.dumps(manifest)) + publisher.check_share(self.stage) + + def test_readonly_verifier_keeps_nonpython_checkout_clean(self): + source = self.root / "clean-source" + (source / "scripts").mkdir(parents=True) + for name in ( + "verify-repository-share.py", + "publish_repository_share.py", + "build-repository-share.py", + "build-repository-knowledge.py", + ): + shutil.copyfile(ROOT / name, source / "scripts" / name) + subprocess.run(["git", "init", "-q", str(source)], check=True) + subprocess.run(["git", "-C", str(source), "add", "."], check=True) + subprocess.run( + [ + "git", + "-C", + str(source), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.org", + "commit", + "-qm", + "fixture", + ], + check=True, + ) + environment = dict(os.environ) + environment.pop("PYTHONDONTWRITEBYTECODE", None) + result = subprocess.run( + [ + sys.executable, + str(source / "scripts/verify-repository-share.py"), + "not-a-release", + "a" * 40, + ], + cwd=source, + env=environment, + capture_output=True, + text=True, + timeout=10, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("invalid release identity", result.stderr) + status = subprocess.check_output( + ["git", "-C", str(source), "status", "--porcelain"], text=True + ) + self.assertEqual(status, "") + + def test_wait_is_bounded(self): + calls, pauses = [], [] + + class Result: + returncode = 1 + + def run(args, **kwargs): + calls.append(args) + return Result() + + with self.assertRaisesRegex(ValueError, "bounded"): + verifier.wait("v1.2.3", self.revision, attempts=2, pause=pauses.append, run=run) + self.assertEqual(len(calls), 2) + self.assertEqual(pauses, [30]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-repository-share-query.py b/scripts/test-repository-share-query.py new file mode 100644 index 0000000..e1da5c4 --- /dev/null +++ b/scripts/test-repository-share-query.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Private corpus controls for Shuttle's copied pointer-only release query. + +# arming: agent-invoked; run before publishing the repository-pendant source. +""" + +from pathlib import Path +import unittest +from rdflib import Graph, Literal, Namespace, RDF, RDFS, URIRef + +A = Namespace("http://aegis.gastown.local/ontology/") +CODE = Namespace(str(A) + "code/shuttle/") +DOC = Namespace(str(A) + "doc/shuttle/") +QUERY = (Path(__file__).resolve().parents[1] / "queries/repository-share-shuttle.rq").read_text() + + +class RepositoryQuery(unittest.TestCase): + def test_pointer_corpus_and_private_boundaries(self): + source = Graph() + doc, chunk = DOC["README.md"], URIRef(str(A) + "chunk/shuttle/README.md#C1") + expected = { + (doc, RDF.type, A.Document), + (doc, A.filePath, Literal("README.md")), + (chunk, RDF.type, A.Chunk), + (chunk, A.inDocument, doc), + (chunk, A.chunkOrder, Literal(0)), + (chunk, A.mentions, Literal("Store")), + (CODE["store.rs"], A.filePath, Literal("src/store.rs")), + } + for triple in expected: + source.add(triple) + blocked = { + (chunk, A.content, Literal("body must not be exported")), + (chunk, A.startLine, Literal(1)), + (chunk, A.endLine, Literal(30)), + (chunk, A.mentions, URIRef("https://example.org/resolved")), + (DOC["private"], RDFS.label, Literal("aegis-private123")), + (DOC["private"], RDF.type, A.Document), + (URIRef(str(A) + "doc/other/README.md"), A.filePath, Literal("foreign")), + (URIRef("urn:quipu:memory:private"), RDFS.label, Literal("memory")), + } + for triple in blocked: + source.add(triple) + result = set(source.query(QUERY).graph) + self.assertEqual(result, expected) + + def test_ideas_and_sssom_records_survive_without_identity_knots(self): + source = Graph() + S = Namespace("https://w3id.org/sssom/") + OWL = Namespace("http://www.w3.org/2002/07/owl#") + record = URIRef("https://example.org/fixture/mapping") + pointer = DOC["guide.md#idea"] + other = URIRef("https://example.org/other/idea") + expected = { + (pointer, RDF.type, A.Concept), + (pointer, RDFS.comment, Literal("Curated high-level idea")), + (pointer, A.reviewAfter, Literal("2026-11-01T00:00:00Z")), + (record, RDF.type, OWL.Axiom), + (record, S.subject_id, pointer), + (record, S.object_id, other), + (record, S.predicate_id, URIRef("http://www.w3.org/2004/02/skos/core#closeMatch")), + (record, S.mapping_justification, URIRef("https://w3id.org/semapv/vocab/LexicalMatching")), + } + for triple in expected: + source.add(triple) + source.add((pointer, OWL.sameAs, other)) + self.assertEqual(set(source.query(QUERY).graph), expected) + + def test_empty_is_empty_with_populated_positive(self): + self.assertEqual(len(Graph().query(QUERY).graph), 0) + source = Graph() + triple = (DOC["README.md"], A.filePath, Literal("README.md")) + source.add(triple) + self.assertEqual(set(source.query(QUERY).graph), {triple}) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_release_workflow.py b/scripts/test_release_workflow.py index ae2afe3..bd6525e 100644 --- a/scripts/test_release_workflow.py +++ b/scripts/test_release_workflow.py @@ -120,5 +120,33 @@ def test_draft_publication_and_failures(self): self.assertTrue(all("--clobber" not in call for call in calls)) +class PendantAdmissionTests(unittest.TestCase): + def test_verification_requires_explicit_publisher_admission(self): + workflow = yaml.load((ROOT / ".github/workflows/release.yml").read_text(), Loader=yaml.BaseLoader) + verify = workflow["jobs"]["repository-pendant"] + self.assertEqual(verify["if"], "vars.SHUTTLE_PENDANT_PUBLICATION == 'enabled'") + self.assertEqual(verify["needs"], "release") + self.assertEqual(verify["permissions"], {"contents": "read"}) + self.assertIn("scripts/verify-repository-share.py", verify["steps"][-1]["run"]) + self.assertEqual(verify["timeout-minutes"], "45") + + def test_actual_recorded_state_is_truthful_without_publishing(self): + workflow = yaml.load((ROOT / ".github/workflows/release.yml").read_text(), Loader=yaml.BaseLoader) + job = workflow["jobs"]["repository-pendant-admission"] + self.assertEqual(job["env"]["PENDANT_PUBLICATION"], "${{ vars.SHUTTLE_PENDANT_PUBLICATION }}") + self.assertEqual(job["needs"], "release") + for value in ("", "disabled", "enabled", "true", "ENABLED", " enabled"): + with self.subTest(value=value), tempfile.TemporaryDirectory() as scratch: + summary = Path(scratch) / "summary" + result = subprocess.run(["/bin/bash", "-c", job["steps"][0]["run"]], + env={"PATH": "/usr/bin:/bin", "PENDANT_PUBLICATION": value, + "GITHUB_STEP_SUMMARY": str(summary)}, + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + expected = "ADMITTED:" if value == "enabled" else "NOT ADMITTED:" + self.assertTrue(result.stdout.startswith(expected), result.stdout) + self.assertEqual(summary.read_text(), result.stdout) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/verify-repository-share.py b/scripts/verify-repository-share.py new file mode 100644 index 0000000..41bcc4b --- /dev/null +++ b/scripts/verify-repository-share.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Bounded hosted readback; build policy and credentials stay on trusted host.""" + +import hashlib +import importlib.util +import json +from pathlib import Path +import re +import subprocess +import sys +import tarfile +import tempfile +import time + +spec = importlib.util.spec_from_file_location("publisher", Path(__file__).with_name("publish_repository_share.py")) +publisher = importlib.util.module_from_spec(spec) +_previous_bytecode = sys.dont_write_bytecode +try: + sys.dont_write_bytecode = True + spec.loader.exec_module(publisher) +finally: + sys.dont_write_bytecode = _previous_bytecode + + +def verify(folder, tag, revision): + if not re.fullmatch(r"v\d+\.\d+\.\d+(?:[.-][A-Za-z0-9.-]+)?", tag) or not re.fullmatch("[0-9a-f]{40}", revision): + raise ValueError("invalid release identity") + archive = folder / ("shuttle-" + tag + "-repository.pendant.tar.gz") + sha = hashlib.sha256(archive.read_bytes()).hexdigest() + if archive.with_name(archive.name + ".sha256").read_text() != sha + " " + archive.name + "\n": + raise ValueError("archive checksum mismatch") + proof = json.loads(archive.with_name(archive.name + ".provenance.json").read_text()) + expected = { + "schema": "quipu.repository-share-producer/v1", + "source_repository": "scbrown/shuttle", + "source_revision": revision, + "release_tag": tag, + "archive_sha256": sha, + "destination": "outward", + } + if any(proof.get(k) != v for k, v in expected.items()): + raise ValueError("repository/source/tag/provenance mismatch") + for key in ("quipu_sha256", "quipu_server_sha256", "bobbin_sha256"): + if not re.fullmatch("[0-9a-f]{64}", proof.get(key, "")): + raise ValueError("missing engine/indexer digest") + if not re.fullmatch("[0-9a-f]{40}", proof.get("engine_source_revision", "")): + raise ValueError("missing engine source pin") + with tempfile.TemporaryDirectory(prefix="verify-pendant-") as temp: + stage = Path(temp) + with tarfile.open(archive) as tar: + members = tar.getmembers() + if len(members) != len({m.name for m in members}) or any( + not m.isfile() or m.name not in publisher.MEMBERS for m in members + ): + raise ValueError("unexpected, duplicate, or unsafe archive member") + # Every member is a flat allowlisted regular file; no path, link or device extraction. + for member in members: + (stage / member.name).write_bytes(tar.extractfile(member).read()) + publisher.check_share(stage) + return expected + + +def wait(tag, revision, attempts=80, pause=time.sleep, run=subprocess.run): + if not re.fullmatch(r"v\d+\.\d+\.\d+(?:[.-][A-Za-z0-9.-]+)?", tag) or not re.fullmatch("[0-9a-f]{40}", revision): + raise ValueError("invalid release identity before lookup") + stem = "shuttle-" + tag + "-repository.pendant.tar.gz" + last_read = "not attempted" + for number in range(attempts): + with tempfile.TemporaryDirectory(prefix="repository-pendant-readback-") as temp: + folder = Path(temp) + try: + result = run( + [ + "gh", + "release", + "download", + tag, + "--repo", + "scbrown/shuttle", + "--dir", + temp, + "--pattern", + stem, + "--pattern", + stem + ".sha256", + "--pattern", + stem + ".provenance.json", + ], + capture_output=True, + text=True, + timeout=45, + ) + last_read = "exit=" + str(result.returncode) + if result.returncode == 0: + return verify(folder, tag, revision) + except subprocess.TimeoutExpired: + last_read = "read timeout" + if number + 1 < attempts: + pause(30) + raise ValueError("repository pendant unverified after bounded trusted-producer wait; " + last_read) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + raise SystemExit("usage: verify-repository-share.py ") + print(json.dumps(wait(sys.argv[1], sys.argv[2])))