diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b0f5089..0f526f0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,3 +11,7 @@ updates: schedule: interval: weekly open-pull-requests-limit: 5 + groups: + codeql-action: + patterns: + - "github/codeql-action/*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b0e9dba..342882b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,13 +29,13 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: languages: python build-mode: none queries: security-extended - name: Analyze - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: category: /language:python diff --git a/.gitignore b/.gitignore index 0e5d72e..51ec232 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,9 @@ __pycache__/ build/ dist/ .limitless/ +my-submissions/ +BUGGER.json +BUGGER.md +BUGGER.lock +BUGGER.json.bak +.bugger.toml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d60c01..f427ab2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Limitless Library is pre-alpha. Small changes that preserve the fail-closed +Limitless Library is alpha. Small changes that preserve the fail-closed trust model are easiest to review. 1. Open an issue describing the receiver problem and the invariant affected. @@ -15,5 +15,13 @@ trust model are easiest to review. 6. Never commit real receiver repositories, credentials, private catalog data, internal experiment evidence, or generated adoption receipts. +For source-free method authoring, begin with `examples/publication/method.json`. +Readable drafts can be converted locally with +`limitless seal-method --draft INPUT --output NEW_FILE`. The command validates +the method and writes canonical UTF-8 JSON without overwriting or publishing. +Point the publication draft at the sealed file. Method object bytes, rather than +only their parsed JSON meaning, are bound by the signed publication intent; +changing them after preparation requires a new publication operation. + Contributions are accepted under Apache-2.0. By submitting a contribution, you represent that you have the right to license it under those terms. diff --git a/README.md b/README.md index 04dfbc5..b1497ee 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,12 @@ scanner for secrets embedded in free text. See the [local demo evidence map](docs/LOCAL-DEMO.md) for the generated artifacts and manual inspection commands. +The terminal shows `USEFUL LOCAL RESULT`: the sample authorization and token +fields are redacted while its agent, action, and repository fields survive. +The same run reports a verified exact component, source-free guidance, and an +abstention. These are deterministic examples from the bundled local catalog. +Live service queries use the current public catalog and may correctly abstain. + ## How verified reuse works For exact reuse, the receiver—not the capsule—controls the trust boundary: @@ -196,12 +202,30 @@ Start the bounded local stdio server: limitless-mcp --catalog examples/catalog ``` -It exposes `limitless_query_before_work` and supports stateless MCP +It exposes `limitless_query_before_work` and `limitless_register_method` and supports stateless MCP `2026-07-28` requests plus the `2025-06-18` and legacy `2025-03-26` initialization flows. Initialization-era clients complete `initialize` and -`notifications/initialized` before requesting a tool. MCP is a decision -channel, not an artifact transport; installation remains an explicit -receiver-local operation. +`notifications/initialized` before requesting a tool. Agents query before +material work. After checking a useful original result, they can register a +concise source-free method with steps, checks, applicability, and limits. The +method object follows the +[canonical method example](examples/publication/method.json). The default stores +it privately under `$XDG_DATA_HOME/limitless-library/methods` +(or `~/.local/share/limitless-library/methods`). Repeating the same registration +returns the same method reference. A registration does not assert that the +service has verified the method or accepted it into its catalog. Query results +remain a decision channel, not an artifact transport; installation remains an +explicit receiver-local operation. + +An owner who wants new methods submitted to the public service can activate +the service, inspect and review its publication policy, then configure the MCP +command with **both** `--submit-methods-publicly` and +`--public-method-policy-digest sha256:`. This is standing +authorization to publish independently authored CC0 methods. The tool queues +submission in a separate process so normal task completion does not wait for +network or admission; the service still decides whether to admit each method. +Local-only is the default. The [managed-service guide](docs/MANAGED-SERVICE.md) +explains status, retries, and revocation. Python callers can use `query_local(...)` or `McpStdioConnector`. See [Protocol](docs/PROTOCOL.md). @@ -222,8 +246,8 @@ limitless agent-connect antigravity --catalog /absolute/path/to/catalog The command adds one named `limitless-library` stdio server to Antigravity's documented MCP profile. It uses the exact Python environment that ran `limitless`, so there is no separate executable to find or PATH assumption to -maintain. Restart Antigravity CLI, then its MCP instructions and the -`limitless_query_before_work` tool are available before material work. +maintain. Restart Antigravity CLI, then its MCP instructions and the query and +method registration tools are available. ```bash limitless agent-status antigravity @@ -271,14 +295,29 @@ command; from a source checkout, use `./scripts/limitless` instead. ```bash limitless service-activate limitless service-inspect -limitless service-query --request ./service-query.json +limitless service-query \ + --request-id request:example-001 \ + --objective "Verify prior work with receiver-owned checks and observed adoption" \ + --receiver ./examples/receiver-context.json # When the verified result selects an exact artifact: limitless service-query \ - --request ./service-query.json \ + --request-id request:example-002 \ + --objective "Find an exact component compatible with this receiver" \ + --receiver ./examples/receiver-context.json \ --artifact-output ./selected.bin ``` +Inspect and adapt [the service receiver example](examples/receiver-context.json) +to the actual target before querying. It declares an agent on Linux/x86_64; +it is not automatic host detection. `execution` describes the agent host, +`targets` describes where the work must fit, and `allowedUse` states its intended +use. For multiple targets, choose `all-targets` with `selectedTarget: null`, or +`one-target` with a declared target ID. The `{constraints, toolchain}` receiver +inside `examples/requests/` belongs to local queries and cannot be passed as a +service receiver. The client builds fresh timestamps and a query digest; advanced +callers can still submit a complete current query with `--request PATH`. + The service accepts anonymous activation, queries, outcome evidence, and public contributions. A contribution can contain an independently authored source-free method or a canonical exact file bundle that keeps its declared @@ -317,7 +356,20 @@ contribution is reported honestly rather than mistaken for a network failure. The client does not scan or upload a workspace. Artifact sources must already be canonical `limitless.exact-file-bundle/1.0` payloads; the client verifies that shape -locally and binds it into the current signed publication intent. See the +locally and binds it into the current signed publication intent. Method files +also require validated canonical JSON: sorted object keys, compact UTF-8 JSON, +and at most one trailing newline. The bundled method already meets that rule. +For a hand-formatted method, create a new canonical file locally: + +```bash +limitless seal-method --draft ./my-method.json --output ./my-method.canonical.json +``` + +Name that new file in the publication draft's `objects` list. `seal-method` +does not connect or publish, and refuses to overwrite a file. Publication +rejects malformed or noncanonical methods before signing new state or uploading; +it never rewrites bytes already named by a resumable operation. Auxiliary +manifest and verification files retain their own byte contracts. See the [managed-service connector](docs/MANAGED-SERVICE.md). ## Authoring diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 9b80bb2..22c1a47 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -13,8 +13,9 @@ by `tests/`: - exact installed byte equality and rollback after failed verification; - receiver-runtime invocation plus receiver-owned functional obligations; - immutable, content-bound adoption evidence; -- MCP 2026 discovery, read-only tool metadata, structured decisions, request - binding, protocol rejection, response size limits, and deadlines; +- MCP 2026 discovery, query read-only and method-registration tool metadata, + structured decisions, request binding, protocol rejection, response size + limits, and deadlines; - MCP 2025-06-18 and 2025-03-26 initialization, notification admission, and stdio lifecycle coverage. diff --git a/docs/MANAGED-SERVICE.md b/docs/MANAGED-SERVICE.md index 68c342c..f9b5b5e 100644 --- a/docs/MANAGED-SERVICE.md +++ b/docs/MANAGED-SERVICE.md @@ -55,22 +55,28 @@ limitless service-status limitless service-inspect ``` -An agent or integration can submit a complete bounded service-query record: - -```bash -limitless service-query --request ./service-query.json -``` - -Or let the client bind the query envelope around an explicit objective and -receiver context: +Let the client bind a fresh query envelope around an explicit objective and +the bundled receiver context, after adapting its facts to your actual receiver: ```bash limitless service-query \ --request-id request:example-001 \ - --objective "Add a reviewed clipboard history extension" \ - --receiver ./receiver-context.json + --objective "Verify prior work with receiver-owned checks and observed adoption" \ + --receiver ./examples/receiver-context.json ``` +The example describes an agent on Linux/x86_64. It declares `receiverId`, +`allowedUse`, `interfaces`, `execution`, `targets`, `compatibilityMode`, and +`selectedTarget`. It differs from the local query receiver's `constraints` and +`toolchain`. Execution facts describe the host; target facts describe where the +result must work. Keep those facts distinct when they differ. A malformed +receiver fails locally with exit status 2 and an actionable message. + +The client fills the current timestamps, policy vocabulary, supported result +version, and query digest. Advanced callers with an already-built current query +can use `limitless service-query --request PATH`; no static, expiring query file +is needed for the walkthrough. Live catalog results may include abstention. + Baseline public access requires no user credential: the client automatically uses its pseudonymous installation session. A caller may still supply an explicit advanced bearer through `LIMITLESS_SERVICE_TOKEN`; bearer material is @@ -130,7 +136,9 @@ same invocation: ```bash limitless service-query \ - --request ./service-query.json \ + --request-id request:example-artifact-001 \ + --objective "Find an exact component compatible with this receiver" \ + --receiver ./examples/receiver-context.json \ --artifact-output ./selected.bin ``` @@ -206,6 +214,20 @@ an immutable mode-0600 state file beside the draft. A retry reuses that intent and request identity, so an interrupted transfer cannot silently become another release. +Method objects are strict JSON with `summary`, ordered `steps`, `constraints`, +`evaluation`, and `limitations`. The client validates structure and canonical +bytes before it signs a new intent or uploads. Object keys must be sorted, +whitespace must be compact, and one final newline is allowed. Constraints and +limitations may be empty; evaluation must contain at least one check. Nonempty +text lists must be sorted and unique. Duplicate JSON keys are rejected. + +For a readable draft, run +`limitless seal-method --draft ./my-method.json --output ./my-method.canonical.json` +and name the new file in the publication draft. Sealing is local, creates an +owner-only file, and refuses overwrite. Automatic method admission is bounded +to 64 KiB. Auxiliary manifest and verification objects retain their own byte +contracts. Existing signed state always binds its original bytes. + The client sends the signed policy acceptance and intent as bounded JSON, receives a signed plan and short-lived content authorization, and streams only objects the plan says are missing. @@ -231,9 +253,9 @@ generation. Older releases stay opaque rather than being retroactively classified from their bytes or provenance. The bundled example is directly publishable from a supported release after the -publisher reviews and accepts the currently advertised policy digest. The open -client validates the public wire lifecycle but does not -contain the private admission engine, ranking service, or managed storage. +publisher reviews and accepts the currently advertised policy digest. The client +validates the public wire lifecycle. Admission, ranking, and managed storage are +provided by the service. The returned owner-only state is also the durable handle for follow-up: @@ -249,6 +271,42 @@ An already revoked release returns its existing state without creating another withdrawal request. Pending, quarantined, rejected, or retired work cannot be misrepresented as an active release eligible for withdrawal. +Publication state prepared by an older client for multiple targets retains its +original authenticated target order, digest, signature, and submission reference. +It can resume without preparing a replacement operation. New builders choose +canonical UTF-8 target order before signing; readers never reorder an existing +signed record. The service may still require manual admission review for multiple +targets. Compatibility does not turn pending intake into automatic admission. + +### MCP method contribution + +The generic Library MCP also offers `limitless_register_method` after an agent +has checked useful original work. By default it records a canonical, +source-free method in an owner-private local store and makes no service +request. The registered method must describe the observed outcome and its +applicability; this note is self-reported evidence, not verified receiver +adoption. The server exposes no exact-source contribution tool. + +For standing public-method authorization, activate the service, inspect its +signed `publicationPolicy`, review its policy URL, and configure the MCP server +with both `--submit-methods-publicly` and +`--public-method-policy-digest sha256:`. A registration then +queues the existing signed `service-publish` lifecycle in a background process. +The agent receives a stable `method:` reference immediately. Public +admission can remain pending, be rejected, or require owner attention; the tool +does not report those as verified success. + +The default store is `$XDG_DATA_HOME/limitless-library/methods`, falling back to +`~/.local/share/limitless-library/methods`. Each method folder is named by the +64 hex characters after `method:`. It contains `capture.json`, `method.json`, +and, when public sharing was authorized, `publication.json` plus +`submission-status.json`. The signed `publication.json.state.json` appears +after the first successful service preflight. The owner can use that state file +with `service-publication-status` or `service-publication-revoke` as above. +Changing the advertised policy requires a new owner review and MCP policy +digest. Retry pending or failed submissions without another agent turn using +`python -m limitless_library.method_capture --sync --policy-digest sha256:`. + ## Advanced alternate profiles Operators building another compatible service can bypass the official @@ -276,9 +334,10 @@ implementations. ## Deliberate exclusions -Connecting does not publish work. Only `service-publish` transfers the exact -objects named in its reviewed draft; it does not enumerate a workspace or -upload a local catalog. The client also does not install a staged component, +Connecting alone does not publish work. `service-publish` transfers the exact +objects named in its reviewed draft; owner-authorized MCP method contribution +uses that same bounded lifecycle. Neither path enumerates a workspace or +uploads a local catalog. The client also does not install a staged component, hand off to a native provider, or submit local outcome evidence. Those remain separate owner-authorized continuations. The service-side identity authority, managed admission implementation, ranking, persistence, analytics, and diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 85c2efd..fc767fc 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -26,10 +26,13 @@ An offer is eligible only when all of the following hold: 4. Every offer constraint is present in the receiver constraints. 5. Every required toolchain value is in the offer's allowed value list. -The unique highest-priority eligible offer wins. When the optional local -`objective` is present, a unique positive lexical match may break a tie among -only the highest-priority eligible offers; an absent, unmatched, or still-tied -objective abstains. Objective relevance never overrides rights, compatibility, +Without an objective, the unique highest-priority eligible offer wins and a tie +abstains. When the optional local `objective` is present, selection requires a +unique positive lexical match among the highest-priority eligible offers, even +when there is only one. An unmatched or still-tied objective abstains. This is a +conservative lexical check, not proof of semantic suitability; synonym-only +queries may abstain and shared words may still require receiver review. +Objective relevance never overrides rights, compatibility, scope, state, or explicit priority. Exact offers produce `reuse`/`exact-adoption`; method offers produce `instantiate`/`method-guided`. Abstention contains no candidate details. @@ -42,11 +45,23 @@ implements `server/discover`, `tools/list`, and `tools/call` independently. The `2025-06-18` and legacy `2025-03-26` modes require a successful `initialize` followed by `notifications/initialized` before tool requests, and -the server echoes the admitted revision. Client or server identity metadata is +the server echoes a supported requested revision. For another nonempty version +string it offers `2025-06-18`, which the client must support to continue. +Malformed version values are rejected. Client or server identity metadata is diagnostic and never an authorization input. -The MCP tool returns the decision as `structuredContent` and as canonical JSON -text for clients that consume text content. Artifact bytes never cross MCP. +`limitless_query_before_work` returns the decision as `structuredContent` and +canonical JSON text for clients that consume text content. Artifact bytes never +cross MCP. `limitless_register_method` accepts one original, checked, source-free +method after useful work. It records title, task kind, observed outcome, target +platform, and the canonical method fields (`summary`, ordered `steps` with +`instruction`/`check`/`expected`, `constraints`, `evaluation`, `limitations`). +It returns a stable method reference and local/public submission status. The +default is owner-private local capture. Public submission requires both MCP +startup flags for standing authorization and the exact reviewed policy digest; +registration queues the background publisher, while service admission remains +independent. `verifiedTargets` stays empty until separate receiver evidence +exists. Method capture does not install artifact bytes. ## Managed-service façade @@ -118,3 +133,13 @@ the exact bytes. See [Managed-service connector](MANAGED-SERVICE.md) for one-action official activation, advanced profiles, and the HTTPS boundary. + +New submission builders order compatibility targets by ascending canonical +UTF-8 JSON bytes of each target; verified targets use their nested target. +Readers preserve the received array order when validating digests and +signatures. Legacy hash-ordered or locale-ordered submissions, resumable state, +and releases therefore retain their authenticated bytes and identifiers. +Releases copy the submitted order. Reordering a signed array without a new +valid signature is rejected. Target bounds, uniqueness, and exact evidence +membership checks apply equally to every order; no client-version guess or +unsigned normalization can authorize a change. diff --git a/docs/TRUST-MODEL.md b/docs/TRUST-MODEL.md index 242952f..ef18caa 100644 --- a/docs/TRUST-MODEL.md +++ b/docs/TRUST-MODEL.md @@ -35,6 +35,8 @@ workspace discovery or connection as publication consent. - Equal-priority ambiguity and ineligibility produce the same non-disclosing abstention shape unless an optional local objective supplies one unique positive lexical tie-break among otherwise-equal eligible offers. +- An explicit objective with no positive lexical match also abstains when + only one top-priority offer is eligible. Word overlap is not proof of task fit. - Exact installation preflights all source and target paths, rejects symlinks, refuses overwrite, and removes files created by a failed attempt. - Receiver verifiers are digest-bound and run with no network, no inherited diff --git a/examples/README.md b/examples/README.md index 711a7b7..a28ab7f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,9 @@ # Conformance example +For a first demonstration, run `./scripts/limitless` from the repository root. +It uses its own bundled redaction catalog and shows useful output plus all three +outcomes. The examples below expose the smaller primitives for inspection. + This neutral fixture demonstrates all three query outcomes: - `exact-python.json` selects immutable Python bytes; @@ -48,3 +52,19 @@ The installed `_vendor/greeting.py` and `adoption-receipt.json` can then be inspected independently. The receipt binds the decision, recipe, exact bytes, receiver state, verifier bytes and results, containment profile, and explicit operator authorization. + +## Service receiver and publication examples + +`receiver-context.json` is a complete service receiver context for an agent on +Linux/x86_64. Review its host, target, interfaces, and allowed use before using +it with `service-query --receiver`. The files in `requests/` use the separate +local query contract. The service client constructs fresh timestamps and a +digest, so the service example contains receiver facts rather than a stale +signed query. Live discovery can abstain. + +`publication/method.json` contains canonical UTF-8 method bytes, with sorted +keys and one trailing newline. To author a method in readable JSON, write a +draft and run `limitless seal-method --draft INPUT --output NEW_FILE`, then +reference the new file from a publication draft. Sealing is local and does not +publish. Object keys are canonicalized; ordered steps and sorted, unique lists +must already satisfy the method contract. diff --git a/examples/receiver-context.json b/examples/receiver-context.json new file mode 100644 index 0000000..308fd6f --- /dev/null +++ b/examples/receiver-context.json @@ -0,0 +1,23 @@ +{ + "receiverId": "receiver:example-linux-agent", + "allowedUse": "general-reuse", + "interfaces": ["limitless.mcp/v1"], + "execution": { + "platform": "linux", + "architecture": "x86_64", + "runtime": "agent", + "version": "1.0.0" + }, + "targets": [ + { + "id": "target:example-linux-agent", + "platform": "linux", + "architecture": "x86_64", + "runtime": "agent", + "versionRange": "*", + "interfaces": ["limitless.mcp/v1"] + } + ], + "compatibilityMode": "all-targets", + "selectedTarget": null +} diff --git a/pyproject.toml b/pyproject.toml index e0eb063..136190c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "limitless-library" -version = "0.1.0a0" +version = "0.1.0a1" description = "Local, fail-closed verified reuse for AI agents." readme = "README.md" requires-python = ">=3.11" @@ -17,7 +17,7 @@ dependencies = [ ] keywords = ["agents", "mcp", "provenance", "reuse", "verification"] classifiers = [ - "Development Status :: 2 - Pre-Alpha", + "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", diff --git a/src/limitless_library/__init__.py b/src/limitless_library/__init__.py index 0da0e88..55bc017 100644 --- a/src/limitless_library/__init__.py +++ b/src/limitless_library/__init__.py @@ -1,6 +1,6 @@ """Public API for the Limitless Library local alpha.""" -__version__ = "0.1.0a0" +__version__ = "0.1.0a1" from .agent_integration import ( AGENT_IDS, diff --git a/src/limitless_library/catalog.py b/src/limitless_library/catalog.py index 4431e1a..add0309 100644 --- a/src/limitless_library/catalog.py +++ b/src/limitless_library/catalog.py @@ -150,11 +150,12 @@ def _search_terms(value: str) -> set[str]: def _objective_score(capsule: dict[str, Any], offer: dict[str, Any], objective: str) -> int: - """Return a conservative lexical tie-break score for one eligible offer. + """Return a conservative lexical relevance score for one eligible offer. Eligibility, rights, compatibility, and explicit priority remain controlling. - Objective text is used only when otherwise-equal eligible offers would force - an abstention, and only a unique positive match can break that tie. + An explicit objective requires a positive match, even for a sole candidate. + Only a unique positive match can break an otherwise-equal priority tie. + This lexical check does not establish semantic suitability for the task. """ query = _search_terms(objective) @@ -234,14 +235,14 @@ def query(self, request: dict[str, Any]) -> dict[str, Any]: if candidates: top_priority = candidates[0][0] top = [candidate for candidate in candidates if candidate[0] == top_priority] - if len(top) == 1: + if len(top) == 1 and not isinstance(request.get("objective"), str): selected_candidate = top[0] elif isinstance(request.get("objective"), str): ranked = sorted( ((_objective_score(item[3].record, item[4], request["objective"]), item) for item in top), key=lambda item: (-item[0], item[1][1], item[1][2]), ) - if ranked[0][0] > 0 and ranked[0][0] > ranked[1][0]: + if ranked[0][0] > 0 and (len(ranked) == 1 or ranked[0][0] > ranked[1][0]): selected_candidate = ranked[0][1] selected: dict[str, Any] | None = None if selected_candidate is not None: diff --git a/src/limitless_library/cli.py b/src/limitless_library/cli.py index 009d0db..53347d6 100644 --- a/src/limitless_library/cli.py +++ b/src/limitless_library/cli.py @@ -29,6 +29,7 @@ publication_status, publish_draft, revoke_publication, + seal_publication_method, ) from .sandbox import containment_readiness from .service_connector import ( @@ -36,6 +37,7 @@ ServiceConnectorError, ServiceProfile, ) +from .service_contracts import PublicServiceContractError, validate_service_receiver_context from .service_identity import installation_publisher_authority @@ -188,7 +190,7 @@ def _parser() -> argparse.ArgumentParser: ) service_query.add_argument("--request", type=Path) service_query.add_argument("--objective") - service_query.add_argument("--receiver", type=Path) + service_query.add_argument("--receiver", type=Path, help="service receiver context JSON; see examples/receiver-context.json") service_query.add_argument("--request-id") service_query.add_argument("--output", type=Path) service_query.add_argument( @@ -197,6 +199,10 @@ def _parser() -> argparse.ArgumentParser: help="fetch a selected exact artifact into this new file", ) + method = subparsers.add_parser("seal-method", help="validate and write canonical method JSON locally without publishing") + method.add_argument("--draft", type=Path, required=True) + method.add_argument("--output", type=Path, required=True) + capsule = subparsers.add_parser("seal-capsule", help="bind a capsule draft to exact payload bytes") capsule.add_argument("--draft", type=Path, required=True) capsule.add_argument("--root", type=Path, required=True) @@ -272,18 +278,25 @@ def main() -> None: } ) elif args.command == "service-query": - connector = _service_connector(args.profile) if args.request is not None: if any(item is not None for item in (args.objective, args.receiver, args.request_id)): raise ServiceConnectorError("--request cannot be combined with query-building arguments") request = load_json(args.request) + connector = _service_connector(args.profile) else: if not args.objective or args.receiver is None or not args.request_id: raise ServiceConnectorError("use --request, or provide --objective, --receiver, and --request-id") + try: + receiver = validate_service_receiver_context(load_json(args.receiver)) + except PublicServiceContractError as error: + raise ServiceConnectorError( + f"{error}; use a service receiver context (see examples/receiver-context.json)" + ) from error + connector = _service_connector(args.profile) request = connector.build_query( request_id=args.request_id, objective=args.objective, - receiver_context=load_json(args.receiver), + receiver_context=receiver, ) result = connector.query(request) staged = None @@ -334,6 +347,8 @@ def main() -> None: reason_code=args.reason_code, ) _print(result) + elif args.command == "seal-method": + _print(seal_publication_method(args.draft, args.output)) elif args.command == "seal-capsule": write_new_json(args.output, seal_capsule(load_json(args.draft), args.root)) elif args.command == "seal-recipe": diff --git a/src/limitless_library/conformance/multi-target-submission-1.2.json b/src/limitless_library/conformance/multi-target-submission-1.2.json new file mode 100644 index 0000000..8ad013b --- /dev/null +++ b/src/limitless_library/conformance/multi-target-submission-1.2.json @@ -0,0 +1,515 @@ +{ + "expectedInterfaces": [ + "repo.Pull-request/v1", + "repo.pull-request/v1", + "repo.pull_request/v1", + "repo.reviewed-change/v1" + ], + "intent": { + "buildContext": { + "architecture": "x86_64", + "interfaces": [ + "limitless.mcp/v1" + ], + "platform": "linux", + "runtime": "python", + "version": "3.13" + }, + "candidate": { + "capabilities": [ + "limitless.mcp/v1" + ], + "summary": "Public synthetic interoperability fixture.", + "title": "Canonical target order fixture", + "treatment": "source-free-method" + }, + "compatibility": { + "supportedTargets": [ + { + "architecture": "any", + "interfaces": [ + "repo.Pull-request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + }, + { + "architecture": "any", + "interfaces": [ + "repo.pull-request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + }, + { + "architecture": "any", + "interfaces": [ + "repo.pull_request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + }, + { + "architecture": "any", + "interfaces": [ + "repo.reviewed-change/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + } + ], + "verifiedTargets": [ + { + "evidenceDigests": [ + "sha256:2222222222222222222222222222222222222222222222222222222222222222" + ], + "target": { + "architecture": "any", + "interfaces": [ + "repo.Pull-request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + } + }, + { + "evidenceDigests": [ + "sha256:2222222222222222222222222222222222222222222222222222222222222222" + ], + "target": { + "architecture": "any", + "interfaces": [ + "repo.pull-request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + } + }, + { + "evidenceDigests": [ + "sha256:2222222222222222222222222222222222222222222222222222222222222222" + ], + "target": { + "architecture": "any", + "interfaces": [ + "repo.pull_request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + } + }, + { + "evidenceDigests": [ + "sha256:2222222222222222222222222222222222222222222222222222222222222222" + ], + "target": { + "architecture": "any", + "interfaces": [ + "repo.reviewed-change/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + } + } + ] + }, + "contentObjects": [ + { + "byteLength": 754, + "digest": "sha256:a931dda743363d20cc8399a8a7a5702e5209200321a4cbf8f1a6001d152f8d3d", + "role": "method" + } + ], + "destination": { + "audience": "public", + "collectionId": "collection:public" + }, + "evidenceDigests": [ + "sha256:b653eeebd891e707a97a0386b1c453516abb571f9a81213483fe59ecab79113c" + ], + "intentDigest": "sha256:eaf88fcc439ab2e131017ea9f9285c18e2e536a67f0a6019765f6780e76adb49", + "lineage": { + "lineageId": "lineage:clipboard-release", + "parents": [], + "releaseClass": "initial", + "supersedes": null, + "version": "1.0.0" + }, + "publisher": { + "authorityId": "installation-space:88888888888888888888888888888888", + "keyId": "key:installation:15a874f40b0a5b68de06dda961e134c7", + "publisherId": "installation:88888888888888888888888888888888" + }, + "requestId": "request:multitarget-order-vector", + "rights": { + "allowedUses": [ + "derive-method" + ], + "grantedBy": "installation:88888888888888888888888888888888", + "hasAuthority": true, + "license": "CC0-1.0", + "policyDigest": "sha256:4444444444444444444444444444444444444444444444444444444444444444" + }, + "schemaVersion": "limitless.service-submission-intent/1.2", + "signature": { + "algorithm": "ed25519", + "keyId": "key:installation:15a874f40b0a5b68de06dda961e134c7", + "value": "3o_5GIYb41iYBw9RJ54GcEPGaeb8H8MGSnSVYnshWx9O0JEaktMl3U9HLbnGjWuUMoM3JLvOAWVmYx9hLLM5Bw" + }, + "submittedAt": "2026-08-20T23:00:00Z" + }, + "legacyAcceptedPlan": { + "expiresAt": "2026-08-20T23:05:00Z", + "intentDigest": "sha256:160c637a4d704e134b1a2ca087817245b32d2e4834473bd7878814d94024f986", + "issuedAt": "2026-08-20T23:00:00Z", + "planDigest": "sha256:08c2284cc6079c8028061a418db56bb6ad8c09d9b1c2936009c0cd4490e2c08b", + "reasonCodes": [], + "requestId": "request:multitarget-order-vector", + "requiredObjects": [], + "reviewStages": [ + "compatibility", + "rights" + ], + "schemaVersion": "limitless.service-submission-plan/1.0", + "signature": { + "algorithm": "ed25519", + "keyId": "key:installation:31677f5fa88aff8f53a61b6ae131889b", + "value": "4Z46CMjJYN9nzbK0F68OYcMVavmwJYgHcSqIeqWzJz5y97oJe0I6LDjW3ll3aGdSv6xvHEGunh_wyNKyFP3uDg" + }, + "state": "accepted", + "submissionRef": "submission:160c637a4d704e134b1a2ca087817245" + }, + "legacyRelease": { + "buildContext": { + "architecture": "x86_64", + "interfaces": [ + "limitless.mcp/v1" + ], + "platform": "linux", + "runtime": "python", + "version": "3.13" + }, + "candidate": { + "capabilities": [ + "limitless.mcp/v1" + ], + "summary": "Public synthetic interoperability fixture.", + "title": "Canonical target order fixture", + "treatment": "source-free-method" + }, + "compatibility": { + "supportedTargets": [ + { + "architecture": "any", + "interfaces": [ + "repo.reviewed-change/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + }, + { + "architecture": "any", + "interfaces": [ + "repo.pull_request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + }, + { + "architecture": "any", + "interfaces": [ + "repo.pull-request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + }, + { + "architecture": "any", + "interfaces": [ + "repo.Pull-request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + } + ], + "verifiedTargets": [ + { + "evidenceDigests": [ + "sha256:2222222222222222222222222222222222222222222222222222222222222222" + ], + "target": { + "architecture": "any", + "interfaces": [ + "repo.reviewed-change/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + } + }, + { + "evidenceDigests": [ + "sha256:2222222222222222222222222222222222222222222222222222222222222222" + ], + "target": { + "architecture": "any", + "interfaces": [ + "repo.pull_request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + } + }, + { + "evidenceDigests": [ + "sha256:2222222222222222222222222222222222222222222222222222222222222222" + ], + "target": { + "architecture": "any", + "interfaces": [ + "repo.pull-request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + } + }, + { + "evidenceDigests": [ + "sha256:2222222222222222222222222222222222222222222222222222222222222222" + ], + "target": { + "architecture": "any", + "interfaces": [ + "repo.Pull-request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + } + } + ] + }, + "contentObjects": [ + { + "byteLength": 754, + "digest": "sha256:a931dda743363d20cc8399a8a7a5702e5209200321a4cbf8f1a6001d152f8d3d", + "role": "method" + } + ], + "createdAt": "2026-08-20T23:00:00Z", + "evidenceDigests": [ + "sha256:b653eeebd891e707a97a0386b1c453516abb571f9a81213483fe59ecab79113c" + ], + "lineage": { + "lineageId": "lineage:clipboard-release", + "parents": [], + "releaseClass": "initial", + "supersedes": null, + "version": "1.0.0" + }, + "publisherId": "installation:88888888888888888888888888888888", + "releaseDigest": "sha256:ff482017b0cca3a81386d3106badd62d78ade656f8ad42f6a09b07d1889ea121", + "releaseId": "release:ff482017b0cca3a81386d3106badd62d", + "reviewEvidenceDigests": [ + "sha256:6666666666666666666666666666666666666666666666666666666666666666" + ], + "reviewerId": "service:legacy-conformance-review", + "rights": { + "allowedUses": [ + "derive-method" + ], + "grantedBy": "installation:88888888888888888888888888888888", + "hasAuthority": true, + "license": "CC0-1.0", + "policyDigest": "sha256:4444444444444444444444444444444444444444444444444444444444444444" + }, + "schemaVersion": "limitless.service-release/1.2", + "signature": { + "algorithm": "ed25519", + "keyId": "key:installation:31677f5fa88aff8f53a61b6ae131889b", + "value": "N4Tx8vq2zzVn__wG_IaM4N8QpmB_Ltokhk4BBpRNt5dsEuStVq1BwXOPZ_ji6JimfiJoND1lo8zhlDlxSOKRCw" + }, + "submissionIntentDigest": "sha256:160c637a4d704e134b1a2ca087817245b32d2e4834473bd7878814d94024f986", + "submissionPlanDigest": "sha256:08c2284cc6079c8028061a418db56bb6ad8c09d9b1c2936009c0cd4490e2c08b" + }, + "oldDigestOrderedIntent": { + "buildContext": { + "architecture": "x86_64", + "interfaces": [ + "limitless.mcp/v1" + ], + "platform": "linux", + "runtime": "python", + "version": "3.13" + }, + "candidate": { + "capabilities": [ + "limitless.mcp/v1" + ], + "summary": "Public synthetic interoperability fixture.", + "title": "Canonical target order fixture", + "treatment": "source-free-method" + }, + "compatibility": { + "supportedTargets": [ + { + "architecture": "any", + "interfaces": [ + "repo.reviewed-change/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + }, + { + "architecture": "any", + "interfaces": [ + "repo.pull_request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + }, + { + "architecture": "any", + "interfaces": [ + "repo.pull-request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + }, + { + "architecture": "any", + "interfaces": [ + "repo.Pull-request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + } + ], + "verifiedTargets": [ + { + "evidenceDigests": [ + "sha256:2222222222222222222222222222222222222222222222222222222222222222" + ], + "target": { + "architecture": "any", + "interfaces": [ + "repo.reviewed-change/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + } + }, + { + "evidenceDigests": [ + "sha256:2222222222222222222222222222222222222222222222222222222222222222" + ], + "target": { + "architecture": "any", + "interfaces": [ + "repo.pull_request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + } + }, + { + "evidenceDigests": [ + "sha256:2222222222222222222222222222222222222222222222222222222222222222" + ], + "target": { + "architecture": "any", + "interfaces": [ + "repo.pull-request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + } + }, + { + "evidenceDigests": [ + "sha256:2222222222222222222222222222222222222222222222222222222222222222" + ], + "target": { + "architecture": "any", + "interfaces": [ + "repo.Pull-request/v1" + ], + "platform": "any", + "runtime": "any", + "versionRange": "any" + } + } + ] + }, + "contentObjects": [ + { + "byteLength": 754, + "digest": "sha256:a931dda743363d20cc8399a8a7a5702e5209200321a4cbf8f1a6001d152f8d3d", + "role": "method" + } + ], + "destination": { + "audience": "public", + "collectionId": "collection:public" + }, + "evidenceDigests": [ + "sha256:b653eeebd891e707a97a0386b1c453516abb571f9a81213483fe59ecab79113c" + ], + "intentDigest": "sha256:160c637a4d704e134b1a2ca087817245b32d2e4834473bd7878814d94024f986", + "lineage": { + "lineageId": "lineage:clipboard-release", + "parents": [], + "releaseClass": "initial", + "supersedes": null, + "version": "1.0.0" + }, + "publisher": { + "authorityId": "installation-space:88888888888888888888888888888888", + "keyId": "key:installation:15a874f40b0a5b68de06dda961e134c7", + "publisherId": "installation:88888888888888888888888888888888" + }, + "requestId": "request:multitarget-order-vector", + "rights": { + "allowedUses": [ + "derive-method" + ], + "grantedBy": "installation:88888888888888888888888888888888", + "hasAuthority": true, + "license": "CC0-1.0", + "policyDigest": "sha256:4444444444444444444444444444444444444444444444444444444444444444" + }, + "schemaVersion": "limitless.service-submission-intent/1.2", + "signature": { + "algorithm": "ed25519", + "keyId": "key:installation:15a874f40b0a5b68de06dda961e134c7", + "value": "R0mZZ1nL7lh5noH4dV4WiQAmnzI6tUDXO1QxNYkVwdNZioLSrZrdMwMW5it8YpzKbFpHgmhuLOCt_dbBWIWGBw" + }, + "submittedAt": "2026-08-20T23:00:00Z" + }, + "publicKey": "u-ZkeGREbF_hCwIs3v15udaF23BwLhBnkIUTKvi1GTo", + "releasePublicKey": { + "keyId": "key:installation:31677f5fa88aff8f53a61b6ae131889b", + "publicKey": "fWqt4-T7KCBS6ljDK6hXZnfqk_zEDZvBf9vhrC5XzBs" + } +} diff --git a/src/limitless_library/connector.py b/src/limitless_library/connector.py index 3467fc0..8792c18 100644 --- a/src/limitless_library/connector.py +++ b/src/limitless_library/connector.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import Any, Self +from . import __version__ from .catalog import LocalCatalog from .contracts import sha256_json, strict_json_loads, without from .mcp_protocol import modern_metadata @@ -109,7 +110,7 @@ def _request(self, method: str, params: dict[str, Any]) -> dict[str, Any]: raise ConnectorError("MCP connector is not open") request_id = self._next_id self._next_id += 1 - params = {**params, "_meta": modern_metadata(client_name="limitless-library-python", client_version="0.1.0a0")} + params = {**params, "_meta": modern_metadata(client_name="limitless-library-python", client_version=__version__)} message = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} encoded = json.dumps(message, separators=(",", ":"), sort_keys=True).encode() + b"\n" if len(encoded) > 1024 * 1024: diff --git a/src/limitless_library/mcp_protocol.py b/src/limitless_library/mcp_protocol.py index 1f33d67..fe290ee 100644 --- a/src/limitless_library/mcp_protocol.py +++ b/src/limitless_library/mcp_protocol.py @@ -152,10 +152,13 @@ def _legacy(self, message: dict[str, Any]) -> dict[str, Any] | None: if not isinstance(params, dict): return jsonrpc_error(message_id, -32602, "initialize requires object params") requested = params["protocolVersion"] + # Initialization negotiates a supported era; a newer client can + # accept our stable version without us claiming newer semantics. + negotiated = requested if requested in INITIALIZATION_PROTOCOL_VERSIONS else STABLE_PROTOCOL_VERSION return jsonrpc_result( message_id, { - "protocolVersion": requested, + "protocolVersion": negotiated, "capabilities": {"tools": {"listChanged": False}}, "serverInfo": {"name": self.server_name, "version": self.server_version}, "instructions": self.instructions, @@ -366,11 +369,11 @@ def _legacy_initialize_request_error(message: dict[str, Any]) -> dict[str, Any] if not isinstance(params, dict): return jsonrpc_error(message_id, -32602, "initialize requires object params") requested = params.get("protocolVersion") - if requested not in INITIALIZATION_PROTOCOL_VERSIONS: + if not isinstance(requested, str) or not requested.strip(): return jsonrpc_error( message_id, -32602, - "unsupported protocolVersion; expected one of " + ", ".join(INITIALIZATION_PROTOCOL_VERSIONS), + "initialize requires a non-empty protocolVersion string", ) if not isinstance(params.get("capabilities"), dict): return jsonrpc_error(message_id, -32602, "initialize requires object client capabilities") diff --git a/src/limitless_library/mcp_server.py b/src/limitless_library/mcp_server.py index bb7afbb..47d8c74 100644 --- a/src/limitless_library/mcp_server.py +++ b/src/limitless_library/mcp_server.py @@ -1,4 +1,4 @@ -"""Bounded stdio MCP adapter for local query-before-work.""" +"""Bounded stdio MCP adapter for local query and reusable method capture.""" from __future__ import annotations @@ -18,12 +18,20 @@ McpToolSession, jsonrpc_error, ) +from .method_capture import MethodCaptureError, default_method_store, register_method, schedule_submission from .schemas import load_schema SERVER_NAME = "limitless-library" TOOL_NAME = "limitless_query_before_work" +REGISTER_METHOD_TOOL_NAME = "limitless_register_method" MAX_REQUEST_BYTES = 1024 * 1024 SERVER_INSTRUCTIONS = "Query before material work; locally verify any selected bytes before adoption." +METHOD_INSTRUCTIONS = ( + " After completing and checking useful original work, register one concise source-free method with " + "limitless_register_method when its steps and limits could save future work. State the observed outcome, " + "keep private paths and secrets out, and do not turn copied source into a method. Registration is local " + "unless this MCP installation has explicit standing public-method authorization." +) def _tool() -> dict[str, Any]: @@ -40,24 +48,116 @@ def _tool() -> dict[str, Any]: } +def _register_method_tool(*, public_submission: bool = False) -> dict[str, Any]: + text = {"type": "string", "minLength": 1} + return { + "name": REGISTER_METHOD_TOOL_NAME, + "title": "Register a reusable method", + "description": "After verified original work, save one concise source-free method locally; authorized public sharing queues separately.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "required": ["title", "taskKind", "method", "observedOutcome", "platform"], + "properties": { + "title": {**text, "maxLength": 120}, + "taskKind": {"type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 100}, + "observedOutcome": {**text, "maxLength": 400}, + "platform": {"type": "string", "pattern": "^(?:any|[a-z][a-z0-9._-]{0,63})$"}, + "method": { + "type": "object", + "additionalProperties": False, + "required": ["summary", "steps", "constraints", "evaluation", "limitations"], + "properties": { + "summary": {**text, "maxLength": 400}, + "steps": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { + "type": "object", + "additionalProperties": False, + "required": ["index", "instruction", "check", "expected"], + "properties": { + "index": {"type": "integer", "minimum": 1, "maximum": 16}, + "instruction": {**text, "maxLength": 400}, + "check": {**text, "maxLength": 80}, + "expected": {**text, "maxLength": 240}, + }, + }, + }, + "constraints": { + "type": "array", + "maxItems": 16, + "uniqueItems": True, + "items": {**text, "maxLength": 240}, + }, + "evaluation": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "uniqueItems": True, + "items": {**text, "maxLength": 240}, + }, + "limitations": { + "type": "array", + "maxItems": 16, + "uniqueItems": True, + "items": {**text, "maxLength": 240}, + }, + }, + }, + }, + }, + "outputSchema": { + "type": "object", + "additionalProperties": False, + "required": ["schemaVersion", "status", "methodRef", "destination", "submission"], + "properties": { + "schemaVersion": {"const": "limitless.method-registration-result/0.1"}, + "status": {"enum": ["registered", "duplicate"]}, + "methodRef": {"type": "string", "pattern": "^method:[0-9a-f]{64}$"}, + "destination": {"enum": ["local", "public"]}, + "submission": { + "enum": ["local", "queued", "retryable", "submitted", "active", "rejected", "policy-attention"] + }, + }, + }, + "annotations": { + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": public_submission, + }, + } + + def handle_message(registry: LocalCatalog, message: dict[str, Any]) -> dict[str, Any] | None: return _dispatcher(registry).handle(message) -def _dispatcher(registry: LocalCatalog) -> McpToolDispatcher: +def _dispatcher( + registry: LocalCatalog, *, method_store: Path | None = None, public_method_policy_digest: str | None = None +) -> McpToolDispatcher: def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: - if name != TOOL_NAME: - raise McpToolCallError("tool is not available") - try: - return registry.query(arguments) - except CatalogError as error: - raise McpToolCallError(str(error)) from error + if name == TOOL_NAME: + try: + return registry.query(arguments) + except CatalogError as error: + raise McpToolCallError(str(error)) from error + if name == REGISTER_METHOD_TOOL_NAME and method_store is not None: + try: + return register_method(method_store, arguments, public_policy_digest=public_method_policy_digest) + except (MethodCaptureError, OSError) as error: + raise McpToolCallError(str(error)) from error + raise McpToolCallError("tool is not available") return McpToolDispatcher( server_name=SERVER_NAME, server_version=__version__, - instructions=SERVER_INSTRUCTIONS, - tools=[_tool()], + instructions=SERVER_INSTRUCTIONS + (METHOD_INSTRUCTIONS if method_store is not None else ""), + tools=[_tool(), _register_method_tool(public_submission=public_method_policy_digest is not None)] + if method_store is not None + else [_tool()], call_tool=call_tool, ) @@ -81,13 +181,48 @@ def _bounded_lines(stream: Any) -> Iterator[tuple[str | None, str | None]]: def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--catalog", required=True, type=Path) + parser.add_argument("--method-store", type=Path, help="private local method store (defaults to XDG data home)") + parser.add_argument( + "--submit-methods-publicly", + action="store_true", + help="standing owner authorization to submit independently authored CC0 methods after registration", + ) + parser.add_argument( + "--public-method-policy-digest", + help="exact reviewed service publication policy digest for authorized public methods", + ) args = parser.parse_args() + if args.submit_methods_publicly != bool(args.public_method_policy_digest): + parser.error( + "public method submission requires both --submit-methods-publicly and --public-method-policy-digest" + ) + if args.public_method_policy_digest is not None and ( + len(args.public_method_policy_digest) != 71 + or not args.public_method_policy_digest.startswith("sha256:") + or any(character not in "0123456789abcdef" for character in args.public_method_policy_digest[7:]) + ): + parser.error("public method policy digest must be sha256: followed by 64 lowercase hex characters") try: catalog = LocalCatalog(args.catalog) except CatalogError as error: print(f"cannot load catalog: {error}", file=sys.stderr) raise SystemExit(2) from error - session = McpToolSession(_dispatcher(catalog)) + try: + method_store = args.method_store or default_method_store() + except MethodCaptureError as error: + if args.submit_methods_publicly: + print(f"method capture is unavailable: {error}", file=sys.stderr) + raise SystemExit(2) from error + method_store = None + session = McpToolSession( + _dispatcher( + catalog, + method_store=method_store, + public_method_policy_digest=args.public_method_policy_digest, + ) + ) + if args.submit_methods_publicly and method_store.is_dir(): + schedule_submission(method_store, args.public_method_policy_digest) for line, framing_error in _bounded_lines(sys.stdin.buffer): if framing_error: response = jsonrpc_error(None, -32700, framing_error) diff --git a/src/limitless_library/method_capture.py b/src/limitless_library/method_capture.py new file mode 100644 index 0000000..62ccc2e --- /dev/null +++ b/src/limitless_library/method_capture.py @@ -0,0 +1,358 @@ +"""Capture small, independently authored methods and queue opted-in public sharing.""" + +from __future__ import annotations + +import argparse +import os +import platform +import re +import secrets +import stat +import subprocess # nosec B404 +import sys +from pathlib import Path +from typing import Any + +from . import __version__ +from .contracts import canonical_json_bytes, sha256_json, strict_json_loads +from .official_service import activated_service_connector +from .publication import PublicationError, publish_draft +from .service_connector import ServiceConnectorError +from .service_contracts import PublicServiceContractError, validate_source_free_method +from .service_identity import ServiceIdentityError, installation_publisher_authority + +_TASK_KIND = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +_METHOD_REF = re.compile(r"^method:([0-9a-f]{64})$") +_PLATFORM = re.compile(r"^(?:any|[a-z][a-z0-9._-]{0,63})$") +_MAX_RECORDS = 2000 + + +class MethodCaptureError(ValueError): + """The method or local capture store cannot be used safely.""" + + +def default_method_store(*, environ: dict[str, str] | None = None) -> Path: + environment = os.environ if environ is None else environ + configured = environment.get("XDG_DATA_HOME") + home = environment.get("HOME") + if configured: + root = Path(configured) + elif home: + root = Path(home) / ".local" / "share" + else: + raise MethodCaptureError("a per-user method store is unavailable") + if not root.is_absolute() or ".." in root.parts: + raise MethodCaptureError("the per-user method store is invalid") + return root / "limitless-library" / "methods" + + +def _directory(path: Path) -> Path: + if os.name != "posix": + raise MethodCaptureError("private method capture currently requires a POSIX host") + selected = Path(path) + if not selected.is_absolute() or ".." in selected.parts or selected.is_symlink(): + raise MethodCaptureError("method store must be an absolute, non-symlink directory") + selected.mkdir(parents=True, exist_ok=True, mode=0o700) + info = selected.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077: + raise MethodCaptureError("method store must be owned and private") + return selected + + +def _write_new(path: Path, payload: bytes) -> None: + descriptor = os.open( + path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0), 0o600 + ) + try: + written = 0 + while written < len(payload): + count = os.write(descriptor, payload[written:]) + if count <= 0: + raise MethodCaptureError("method record write was incomplete") + written += count + os.fsync(descriptor) + except BaseException: + path.unlink(missing_ok=True) + raise + finally: + os.close(descriptor) + + +def _read(path: Path, maximum: int = 64 * 1024) -> bytes: + before = path.lstat() + if ( + not stat.S_ISREG(before.st_mode) + or before.st_size > maximum + or before.st_mode & 0o077 + or before.st_uid != os.geteuid() + ): + raise MethodCaptureError("stored method material is unsafe") + descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)) + try: + current = os.fstat(descriptor) + payload = os.read(descriptor, maximum + 1) + if current.st_ino != before.st_ino or current.st_dev != before.st_dev or len(payload) != before.st_size: + raise MethodCaptureError("stored method material changed while reading") + return payload + finally: + os.close(descriptor) + + +def _input(value: Any) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != {"title", "taskKind", "method", "observedOutcome", "platform"}: + raise MethodCaptureError("method registration has unsupported or missing fields") + title = value["title"] + task = value["taskKind"] + outcome = value["observedOutcome"] + target_platform = value["platform"] + if not isinstance(title, str) or not title.strip() or len(title) > 120 or "\x00" in title: + raise MethodCaptureError("method title is invalid") + if not isinstance(task, str) or len(task) > 100 or _TASK_KIND.fullmatch(task) is None: + raise MethodCaptureError("method task kind is invalid") + if not isinstance(outcome, str) or not outcome.strip() or len(outcome) > 400 or "\x00" in outcome: + raise MethodCaptureError("observed outcome must briefly state what the receiver checked") + if not isinstance(target_platform, str) or _PLATFORM.fullmatch(target_platform) is None: + raise MethodCaptureError("method platform is invalid") + try: + method = validate_source_free_method(value["method"]) + except PublicServiceContractError as error: + raise MethodCaptureError(f"method does not meet the source-free method contract: {error}") from error + return { + "title": title.strip(), + "taskKind": task, + "method": method, + "observedOutcome": outcome.strip(), + "platform": target_platform, + } + + +def _publication(value: dict[str, Any], capture_digest: str) -> dict[str, Any]: + interface = "limitless.mcp/v1" + return { + "schemaVersion": "limitless.publication-draft/1.0", + "candidate": { + "title": value["title"], + "summary": value["method"]["summary"], + "treatment": "source-free-method", + "capabilities": sorted({interface, value["taskKind"]}), + }, + "lineage": { + "lineageId": "lineage:method-" + capture_digest[7:39], + "version": "1.0.0", + "releaseClass": "initial", + "parents": [], + "supersedes": None, + }, + "objects": [{"role": "method", "path": "method.json"}], + "compatibility": { + "supportedTargets": [ + { + "platform": value["platform"], + "architecture": "any", + "runtime": "any", + "versionRange": "any", + "interfaces": [interface], + } + ], + "verifiedTargets": [], + }, + "buildContext": { + "platform": platform.system().lower() or "unknown", + "architecture": platform.machine().lower() or "unknown", + "runtime": "limitless-library", + "version": __version__, + "interfaces": [interface], + }, + "evidenceDigests": [ + sha256_json( + {"schemaVersion": "limitless.method-outcome-note/0.1", "observedOutcome": value["observedOutcome"]} + ) + ], + "rights": {"license": "CC0-1.0", "allowedUses": ["derive-method"], "hasAuthority": True}, + } + + +def _status(folder: Path) -> str: + status = folder / "submission-status.json" + if not status.exists(): + return "queued" if (folder / "publication.json").exists() else "local" + try: + value = strict_json_loads(_read(status, 4096).decode("utf-8")) + except (UnicodeError, ValueError) as error: + raise MethodCaptureError("method submission status is invalid") from error + if ( + not isinstance(value, dict) + or set(value) != {"status", "reason"} + or value["status"] not in {"queued", "retryable", "submitted", "active", "rejected", "policy-attention"} + ): + raise MethodCaptureError("method submission status is invalid") + return value["status"] + + +def _write_status(folder: Path, status: str, reason: str | None) -> None: + target = folder / "submission-status.json" + temporary = folder / f".submission-status.{os.getpid()}.{secrets.token_hex(6)}.tmp" + _write_new(temporary, canonical_json_bytes({"status": status, "reason": reason}) + b"\n") + os.replace(temporary, target) + + +def register_method( + store_path: Path, value: Any, *, public_policy_digest: str | None = None, schedule: bool = True +) -> dict[str, Any]: + """One local capture; queue public submission only under standing owner consent.""" + if public_policy_digest is not None and ( + not isinstance(public_policy_digest, str) or _DIGEST.fullmatch(public_policy_digest) is None + ): + raise MethodCaptureError("public method sharing requires an exact reviewed policy digest") + checked = _input(value) + store = _directory(store_path) + import fcntl + + lock = os.open( + store / ".register.lock", os.O_RDWR | os.O_CREAT | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0), 0o600 + ) + try: + fcntl.flock(lock, fcntl.LOCK_EX) + return _register_locked(store, checked, public_policy_digest, schedule) + finally: + os.close(lock) + + +def _register_locked( + store: Path, checked: dict[str, Any], public_policy_digest: str | None, schedule: bool +) -> dict[str, Any]: + capture_digest = sha256_json(checked) + reference = "method:" + capture_digest[7:] + folder = store / capture_digest[7:] + new = not folder.exists() + if new and len([item for item in store.iterdir() if re.fullmatch(r"[0-9a-f]{64}", item.name)]) >= _MAX_RECORDS: + raise MethodCaptureError("method store exceeds its bounded capacity") + folder.mkdir(mode=0o700, exist_ok=True) + if folder.is_symlink() or folder.stat().st_mode & 0o077 or folder.stat().st_uid != os.geteuid(): + raise MethodCaptureError("method record directory is unsafe") + method_payload = canonical_json_bytes(checked["method"]) + b"\n" + capture_payload = ( + canonical_json_bytes({"schemaVersion": "limitless.method-capture/0.1", "methodRef": reference, **checked}) + + b"\n" + ) + for name, payload in (("capture.json", capture_payload), ("method.json", method_payload)): + path = folder / name + if path.exists(): + if _read(path) != payload: + raise MethodCaptureError("stored method differs from its digest-bound record") + else: + _write_new(path, payload) + if public_policy_digest is not None: + draft = canonical_json_bytes(_publication(checked, capture_digest)) + b"\n" + path = folder / "publication.json" + if path.exists(): + if _read(path) != draft: + raise MethodCaptureError("method publication draft differs from its captured record") + else: + _write_new(path, draft) + if ( + schedule + and _status(folder) not in {"active", "rejected"} + and not schedule_submission(store, public_policy_digest) + ): + _write_status(folder, "retryable", "worker-unavailable") + return { + "schemaVersion": "limitless.method-registration-result/0.1", + "status": "registered" if new else "duplicate", + "methodRef": reference, + "destination": "public" if (folder / "publication.json").exists() else "local", + "submission": _status(folder), + } + + +def schedule_submission(store: Path, public_policy_digest: str) -> bool: + """Keep the agent's post-task registration call independent of service latency.""" + try: + subprocess.Popen( # nosec B603 + [ + sys.executable, + "-m", + "limitless_library.method_capture", + "--sync", + str(store), + "--policy-digest", + public_policy_digest, + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + start_new_session=True, + ) + except OSError: + return False + return True + + +def sync_methods(store_path: Path, public_policy_digest: str, *, limit: int = 8) -> dict[str, int]: + """Retry a bounded batch; existing publication state supplies idempotency.""" + if ( + not isinstance(public_policy_digest, str) + or _DIGEST.fullmatch(public_policy_digest) is None + or type(limit) is not int + or not 1 <= limit <= 32 + ): + raise MethodCaptureError("method sync arguments are invalid") + import fcntl + + store = _directory(store_path) + lock = os.open(store / ".sync.lock", os.O_RDWR | os.O_CREAT | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0), 0o600) + try: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return {"processed": 0, "submitted": 0, "attention": 0} + folders = sorted(path for path in store.iterdir() if re.fullmatch(r"[0-9a-f]{64}", path.name)) + if len(folders) > _MAX_RECORDS: + raise MethodCaptureError("method store exceeds its bounded capacity") + processed = submitted = attention = 0 + for folder in folders: + if processed >= limit: + break + if folder.is_symlink() or not folder.is_dir() or folder.stat().st_mode & 0o077: + raise MethodCaptureError("method record directory is unsafe") + draft = folder / "publication.json" + if not draft.exists() or _status(folder) in {"active", "rejected"}: + continue + processed += 1 + try: + connector = activated_service_connector() + signer, publisher = installation_publisher_authority(service_id=connector.profile.service_id) + result = publish_draft( + connector, + draft_path=draft, + state_path=None, + signer=signer, + publisher=publisher, + accepted_publication_policy_digest=public_policy_digest, + ) + admission = result.get("admissionState") + state = "active" if admission == "active" else "rejected" if admission == "rejected" else "submitted" + _write_status(folder, state, None) + submitted += 1 + except (PublicationError, ServiceConnectorError, ServiceIdentityError, OSError, ValueError) as error: + # Policy drift needs owner review; transient service failure can retry. + reason = "policy-review-required" if "policy" in str(error).lower() else "service-unavailable" + _write_status(folder, "policy-attention" if reason == "policy-review-required" else "retryable", reason) + attention += 1 + return {"processed": processed, "submitted": submitted, "attention": attention} + finally: + os.close(lock) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sync", type=Path, required=True) + parser.add_argument("--policy-digest", required=True) + args = parser.parse_args() + sync_methods(args.sync, args.policy_digest) + + +if __name__ == "__main__": + main() diff --git a/src/limitless_library/public_submission_contracts.py b/src/limitless_library/public_submission_contracts.py index 5abf2aa..5cd838f 100644 --- a/src/limitless_library/public_submission_contracts.py +++ b/src/limitless_library/public_submission_contracts.py @@ -160,12 +160,13 @@ def _environment(value: Any, field: str, *, version_field: str) -> dict[str, Any def _compatibility(value: Any) -> dict[str, Any]: + """Validate signed target arrays without changing their authenticated order.""" + item = _exact(value, {"supportedTargets", "verifiedTargets"}, "compatibility") raw_supported = item["supportedTargets"] if not isinstance(raw_supported, list) or not 1 <= len(raw_supported) <= 16: raise PublicSubmissionContractError("compatibility supportedTargets are invalid") supported = [_environment(target, "supported target", version_field="versionRange") for target in raw_supported] - supported = sorted(supported, key=sha256_json) if len({sha256_json(target) for target in supported}) != len(supported): raise PublicSubmissionContractError("compatibility supportedTargets must be unique") raw_verified = item["verifiedTargets"] @@ -190,12 +191,20 @@ def _compatibility(value: Any) -> dict[str, Any]: ), } ) - verified = sorted(verified, key=lambda proof: sha256_json(proof["target"])) if len({sha256_json(proof["target"]) for proof in verified}) != len(verified): raise PublicSubmissionContractError("compatibility verifiedTargets must be unique") return {"supportedTargets": supported, "verifiedTargets": verified} +def _canonical_compatibility(value: Any) -> dict[str, Any]: + """Choose deterministic target order only when preparing a new signature.""" + + checked = _compatibility(value) + checked["supportedTargets"].sort(key=canonical_json_bytes) + checked["verifiedTargets"].sort(key=lambda proof: canonical_json_bytes(proof["target"])) + return checked + + def _release_reference(value: Any, field: str) -> dict[str, str]: item = _exact(value, {"releaseId", "releaseDigest", "lineageId", "version"}, field) return { @@ -506,6 +515,8 @@ def build_submission_intent( """Build one current, publisher-signed submission intent.""" body = {"schemaVersion": schema_version, **fields} + if "compatibility" in body: + body["compatibility"] = _canonical_compatibility(body["compatibility"]) submitted_at = body.get("submittedAt") if isinstance(submitted_at, datetime) and submitted_at.tzinfo is not None: body["submittedAt"] = isoformat_utc(submitted_at.astimezone(UTC).replace(microsecond=0)) @@ -534,6 +545,8 @@ def build_legacy_submission_intent(**fields: Any) -> dict[str, Any]: """Build an unsigned 1.0 intent solely for compatibility fixtures.""" body = {"schemaVersion": SUBMISSION_INTENT_SCHEMA_VERSION_1_0, **fields} + if "compatibility" in body: + body["compatibility"] = _canonical_compatibility(body["compatibility"]) submitted_at = body.get("submittedAt") if isinstance(submitted_at, datetime) and submitted_at.tzinfo is not None: body["submittedAt"] = isoformat_utc(submitted_at.astimezone(UTC).replace(microsecond=0)) diff --git a/src/limitless_library/publication.py b/src/limitless_library/publication.py index 1eb915b..de10628 100644 --- a/src/limitless_library/publication.py +++ b/src/limitless_library/publication.py @@ -13,9 +13,11 @@ from .contracts import ( ContractError, + canonical_json_bytes, load_json, sha256_json, strict_json_loads, + write_new_bytes, write_new_json, ) from .exact_file_bundle import ( @@ -36,12 +38,14 @@ validate_submission_intent, ) from .service_connector import ServiceConnector, ServiceConnectorError +from .service_contracts import validate_source_free_method from .service_identity import InstallationSigner PUBLICATION_DRAFT_SCHEMA_VERSION = "limitless.publication-draft/1.0" PUBLICATION_STATE_SCHEMA_VERSION = "limitless.publication-state/1.0" MAX_PUBLICATION_DRAFT_BYTES = 64 * 1024 MAX_PUBLICATION_STATE_BYTES = 128 * 1024 +MAX_PUBLICATION_METHOD_BYTES = 64 * 1024 _DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") _POLICY_REVISION = re.compile(r"^[A-Za-z][A-Za-z0-9._:-]{0,119}$") _EXACT_FILE_BUNDLE_MEDIA_TYPE = "application/vnd.limitless.exact-file-bundle+json" @@ -51,6 +55,38 @@ class PublicationError(ServiceConnectorError): """A local publication draft or resumable operation is unsafe.""" +def _method_payload(payload: bytes, *, require_canonical: bool = True) -> dict[str, Any]: + if not 1 <= len(payload) <= MAX_PUBLICATION_METHOD_BYTES: + raise PublicationError("publication method must be at most 64 KiB for automatic admission") + try: + value = strict_json_loads(payload.decode("utf-8")) + method = validate_source_free_method(value) + canonical = canonical_json_bytes(method) + except ValueError as error: + raise PublicationError(f"invalid publication method: {error}") from error + if require_canonical and payload not in (canonical, canonical + b"\n"): + raise PublicationError( + "publication method must be canonical JSON; run limitless seal-method --draft INPUT --output NEW_FILE " + "and name NEW_FILE in the publication draft" + ) + return method + + +def seal_publication_method(draft_path: Path, output_path: Path) -> dict[str, Any]: + """Write a validated, canonical method to a new owner-only file locally.""" + + try: + with Path(draft_path).open("rb") as source: + method = _method_payload(source.read(MAX_PUBLICATION_METHOD_BYTES + 1), require_canonical=False) + payload = canonical_json_bytes(method) + b"\n" + # The newline counts toward the service's byte limit too. + _method_payload(payload) + write_new_bytes(output_path, payload) + except (ContractError, OSError) as error: + raise PublicationError(f"cannot seal publication method: {error}") from error + return {"status": "sealed", "path": str(output_path), "digest": "sha256:" + sha256(payload).hexdigest(), "byteLength": len(payload)} + + def default_publication_state_path(draft_path: Path) -> Path: draft = Path(draft_path) return draft.with_name(draft.name + ".state.json") @@ -118,23 +154,26 @@ def _source_descriptor(role: str, configured: str, *, base: Path) -> dict[str, A ): raise PublicationError("publication source changed or is invalid") hasher = sha256() - artifact_payload = bytearray() + content_payload = bytearray() while True: chunk = os.read(descriptor, 128 * 1024) if not chunk: break hasher.update(chunk) - if role == "artifact": - artifact_payload.extend(chunk) - if len(artifact_payload) > MAX_EXACT_FILE_BUNDLE_BYTES: - raise PublicationError("publication artifact exceeds the exact bundle limit") + if role in {"artifact", "method"}: + content_payload.extend(chunk) + maximum = MAX_EXACT_FILE_BUNDLE_BYTES if role == "artifact" else MAX_PUBLICATION_METHOD_BYTES + if len(content_payload) > maximum: + raise PublicationError(f"publication {role} exceeds its automatic admission byte limit") if role == "artifact": try: - parse_exact_file_bundle(bytes(artifact_payload)) + parse_exact_file_bundle(bytes(content_payload)) except ExactFileBundleError as error: raise PublicationError( "publication artifact is not a canonical exact file bundle" ) from error + elif role == "method": + _method_payload(bytes(content_payload)) finally: os.close(descriptor) except PublicationError: diff --git a/src/limitless_library/service_connector.py b/src/limitless_library/service_connector.py index 5c04132..40ff3c1 100644 --- a/src/limitless_library/service_connector.py +++ b/src/limitless_library/service_connector.py @@ -35,6 +35,7 @@ build_opener, ) +from . import __version__ from ._service_support import decode_root_keys from .contracts import ( ContractError, @@ -81,6 +82,7 @@ SERVICE_QUERY_RESULT_SCHEMA_VERSION_1_5, SERVICE_QUERY_RESULT_SCHEMA_VERSIONS, SERVICE_QUERY_SCHEMA_VERSION, + PublicServiceContractError, active_result_keys, build_service_query, service_query_audiences, @@ -90,6 +92,7 @@ validate_service_profile, validate_service_query, validate_service_query_result, + validate_service_receiver_context, validate_service_root_key_transition_set, ) @@ -657,7 +660,7 @@ def _headers( ) -> dict[str, str]: headers = { "accept": "application/json", - "user-agent": "limitless-library/0.1.0a0", + "user-agent": f"limitless-library/{__version__}", } if content: headers["content-type"] = "application/json" @@ -966,7 +969,7 @@ def _stream_checked_artifact( descriptor = -1 headers = { "accept": immutable["mediaType"], - "user-agent": "limitless-library/0.1.0a0", + "user-agent": f"limitless-library/{__version__}", } if authorization is not None: headers.update( @@ -1088,7 +1091,7 @@ def _fetch_checked_artifact( headers={ "accept": _ARTIFACT_CONTENT_TYPE, "authorization": f"Bearer {self.profile.access_token}", - "user-agent": "limitless-library/0.1.0a0", + "user-agent": f"limitless-library/{__version__}", authorization["header"]: authorization["value"], }, body=None, @@ -1428,7 +1431,7 @@ def upload_submission_object( "content-type": _ARTIFACT_CONTENT_TYPE, "content-length": str(current.st_size), "x-limitless-content-digest": digest, - "user-agent": "limitless-library/0.1.0a0", + "user-agent": f"limitless-library/{__version__}", }, source=opened, byte_length=current.st_size, @@ -1560,6 +1563,12 @@ def build_query( issued_at: datetime | None = None, ttl_seconds: int = 60, ) -> dict[str, Any]: + try: + receiver_context = validate_service_receiver_context(receiver_context) + except PublicServiceContractError as error: + raise ServiceConnectorError( + f"{error}; use a service receiver context (see examples/receiver-context.json)" + ) from error verified = self.inspect() compatible_results = [ version @@ -1569,17 +1578,20 @@ def build_query( ] if not compatible_results: raise ServiceConnectorError("service does not advertise a policy-bound result generation") - return build_service_query( - request_id=request_id, - objective=objective, - receiver_context=receiver_context, - requested_audiences=self.profile.requested_audiences, - requested_treatments=requested_treatments, - execution_mode=self.profile.execution_mode, - history_mode=self.profile.history_mode, - client_name="limitless-library-python", - client_version="0.1.0a0", - issued_at=issued_at or self._now(), - ttl_seconds=ttl_seconds, - supported_result_version=compatible_results[-1], - ) + try: + return build_service_query( + request_id=request_id, + objective=objective, + receiver_context=receiver_context, + requested_audiences=self.profile.requested_audiences, + requested_treatments=requested_treatments, + execution_mode=self.profile.execution_mode, + history_mode=self.profile.history_mode, + client_name="limitless-library-python", + client_version=__version__, + issued_at=issued_at or self._now(), + ttl_seconds=ttl_seconds, + supported_result_version=compatible_results[-1], + ) + except PublicServiceContractError as error: + raise ServiceConnectorError(f"invalid service query: {error}") from error diff --git a/src/limitless_library/service_contracts.py b/src/limitless_library/service_contracts.py index d366398..338c13f 100644 --- a/src/limitless_library/service_contracts.py +++ b/src/limitless_library/service_contracts.py @@ -225,8 +225,9 @@ def _sorted_texts( maximum_items: int, maximum_length: int, allowed: Iterable[str] | None = None, + allow_empty: bool = False, ) -> list[str]: - if not isinstance(value, list) or not value or len(value) > maximum_items: + if not isinstance(value, list) or (not value and not allow_empty) or len(value) > maximum_items: raise PublicServiceContractError(f"{field} is invalid") result = [_text(item, field, maximum=maximum_length) for item in value] if result != sorted(set(result)): @@ -970,7 +971,7 @@ def _method(value: Any) -> dict[str, Any]: checked_steps: list[dict[str, str]] = [] for index, step in enumerate(steps, start=1): current = _exact(step, {"index", "instruction", "check", "expected"}, "source-free method step") - if current["index"] != index: + if type(current["index"]) is not int or current["index"] != index: raise PublicServiceContractError("source-free method step indexes are invalid") checked_steps.append( { @@ -983,12 +984,18 @@ def _method(value: Any) -> dict[str, Any]: return { "summary": _text(item["summary"], "source-free method summary", maximum=400), "steps": checked_steps, - "constraints": _sorted_texts(item["constraints"], "source-free method constraints", maximum_items=16, maximum_length=240), + "constraints": _sorted_texts(item["constraints"], "source-free method constraints", maximum_items=16, maximum_length=240, allow_empty=True), "evaluation": _sorted_texts(item["evaluation"], "source-free method evaluation", maximum_items=16, maximum_length=240), - "limitations": _sorted_texts(item["limitations"], "source-free method limitations", maximum_items=16, maximum_length=240), + "limitations": _sorted_texts(item["limitations"], "source-free method limitations", maximum_items=16, maximum_length=240, allow_empty=True), } +def validate_source_free_method(value: Any) -> dict[str, Any]: + """Validate method authoring and returned methods against the same contract.""" + + return _method(value) + + def _artifact_delivery_1_5( value: Any, *, diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 4898353..3b64c69 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -131,3 +131,44 @@ def test_revoked_and_unauthorized_offers_are_ineligible(tmp_path: Path) -> None: manifest.write_text(json.dumps(capsule)) decision = LocalCatalog(copied).query(load_json(REQUESTS / "exact-python.json")) assert decision["decision"] == "abstain" + + +@pytest.mark.parametrize("request_file", ["exact-python.json", "method-portable.json"]) +def test_explicit_unmatched_objective_abstains_for_a_sole_candidate(request_file: str) -> None: + catalog = LocalCatalog(CATALOG) + request = load_json(REQUESTS / request_file) + assert catalog.query(request)["selected"] is not None + request["objective"] = "Replace a Windows printer driver with a macOS kernel extension" + decision = catalog.query(request) + assert decision["decision"] == "abstain" + assert decision["selected"] is None + assert decision["reason"] == "no-safe-selection" + + +def test_matching_objective_keeps_a_sole_candidate() -> None: + request = load_json(REQUESTS / "method-portable.json") + request["objective"] = "Normalize a name and render a deterministic greeting" + assert LocalCatalog(CATALOG).query(request)["decision"] == "instantiate" + + +def test_objective_does_not_fall_through_to_lower_priority(tmp_path: Path) -> None: + copied = tmp_path / "catalog" + shutil.copytree(CATALOG, copied) + manifest = copied / "hello-component" / "capsule.json" + capsule = load_json(manifest) + lower = copy.deepcopy(capsule["offers"][0]) + lower["id"] = "offer:lower-priority" + lower["priority"] -= 1 + lower["kind"] = "method" + lower["files"] = None + lower["method"] = { + "summary": "Tune a database connection pool", + "steps": ["Measure database connection demand"], + "verification": ["Verify connection latency"], + } + capsule["offers"].append(lower) + capsule["capsuleDigest"] = sha256_json(without(capsule, "capsuleDigest")) + manifest.write_text(json.dumps(capsule)) + request = load_json(REQUESTS / "exact-python.json") + request["objective"] = "Tune a database connection pool" + assert LocalCatalog(copied).query(request)["selected"] is None diff --git a/tests/test_cli.py b/tests/test_cli.py index cb679c4..7ac9d11 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,6 +7,8 @@ import pytest from limitless_library import cli +from limitless_library.contracts import load_json +from limitless_library.service_contracts import build_service_query, validate_service_query class _Profile: @@ -246,6 +248,60 @@ def test_service_query_accepts_an_exact_request_and_writes_no_implicit_state( assert json.loads(capsys.readouterr().out) == {"verifiedRequest": {"query": "bounded"}} +def test_service_receiver_error_is_clean_before_connector_activation(monkeypatch, capsys, tmp_path) -> None: + receiver = tmp_path / "receiver.json" + receiver.write_text('{"constraints": [], "toolchain": {}}', encoding="utf-8") + monkeypatch.setattr(sys, "argv", ["limitless", "service-query", "--request-id", "request:test", + "--objective", "Verify prior work", "--receiver", str(receiver)]) + monkeypatch.setattr(cli, "_service_connector", lambda _path: pytest.fail("invalid receiver reached activation")) + with pytest.raises(SystemExit) as error: + cli.main() + assert error.value.code == 2 + output = capsys.readouterr() + assert output.out == "" + assert "receiverContext" in output.err + assert "examples/receiver-context.json" in output.err + assert "Traceback" not in output.err + + +def test_documented_service_receiver_builds_a_valid_query(monkeypatch, capsys) -> None: + from datetime import UTC, datetime + + receiver = Path(__file__).resolve().parents[1] / "examples" / "receiver-context.json" + + class ExampleConnector(_Connector): + def build_query(self, **arguments): + return build_service_query(**arguments, requested_audiences=["public"], + requested_treatments=["source-free-method"], execution_mode="service", + history_mode="local-only", client_name="example", client_version="1.0.0", + issued_at=datetime.now(UTC)) + + monkeypatch.setattr(sys, "argv", ["limitless", "service-query", "--request-id", "request:example-001", + "--objective", "Verify prior work", "--receiver", str(receiver)]) + monkeypatch.setattr(cli, "_service_connector", lambda _path: ExampleConnector()) + cli.main() + query = validate_service_query(json.loads(capsys.readouterr().out)["verifiedRequest"]) + assert query["receiverContext"] == load_json(receiver) + + +def test_seal_method_is_local_and_refuses_overwrite(monkeypatch, capsys, tmp_path) -> None: + method = Path(__file__).resolve().parents[1] / "examples" / "publication" / "method.json" + source = tmp_path / "method.json" + original = json.dumps(load_json(method), indent=2) + source.write_text(original, encoding="utf-8") + output = tmp_path / "sealed.json" + monkeypatch.setattr(sys, "argv", ["limitless", "seal-method", "--draft", str(source), "--output", str(output)]) + monkeypatch.setattr(cli, "activated_service_connector", lambda: pytest.fail("sealing must remain local")) + cli.main() + assert json.loads(capsys.readouterr().out)["status"] == "sealed" + assert load_json(output) == load_json(source) + assert source.read_text(encoding="utf-8") == original + with pytest.raises(SystemExit) as error: + cli.main() + assert error.value.code == 2 + assert "cannot seal" in capsys.readouterr().err + + def test_agent_connect_uses_the_general_antigravity_adapter( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], diff --git a/tests/test_mcp.py b/tests/test_mcp.py index ea64fc7..2abf301 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -66,17 +66,35 @@ def test_modern_mcp_discovers_and_queries_one_structured_tool() -> None: assert response["result"]["structuredContent"]["decision"] == "reuse" -@pytest.mark.parametrize("protocol_version", [STABLE_PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION]) -def test_initialize_eras_preserve_query_first_instructions(protocol_version: str) -> None: +@pytest.mark.parametrize( + ("protocol_version", "expected"), + [ + (STABLE_PROTOCOL_VERSION, STABLE_PROTOCOL_VERSION), + (LEGACY_PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION), + ("2025-11-25", STABLE_PROTOCOL_VERSION), + ("2099-01-01", STABLE_PROTOCOL_VERSION), + ], +) +def test_initialize_eras_preserve_query_first_instructions(protocol_version: str, expected: str) -> None: response = handle_message( LocalCatalog(CATALOG_PATH), _initialize_message(protocol_version), ) - assert response["result"]["protocolVersion"] == protocol_version + assert response["result"]["protocolVersion"] == expected assert response["result"]["instructions"] == SERVER_INSTRUCTIONS +@pytest.mark.parametrize("protocol_version", [None, "", " \t", 42, True, [], {}]) +def test_initialize_rejects_malformed_protocol_version(protocol_version: object) -> None: + message = _initialize_message(STABLE_PROTOCOL_VERSION) + message["params"]["protocolVersion"] = protocol_version + + response = handle_message(LocalCatalog(CATALOG_PATH), message) + + assert response["error"]["code"] == -32602 + + def test_initialize_rejects_missing_required_client_fields() -> None: response = handle_message( LocalCatalog(CATALOG_PATH), @@ -162,9 +180,16 @@ def fail(_name: str, _arguments: dict[str, object]) -> dict[str, object]: assert invalid["error"]["code"] == -32602 -@pytest.mark.parametrize("protocol_version", [STABLE_PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION]) +@pytest.mark.parametrize( + ("protocol_version", "expected"), + [ + (STABLE_PROTOCOL_VERSION, STABLE_PROTOCOL_VERSION), + (LEGACY_PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION), + ("2025-11-25", STABLE_PROTOCOL_VERSION), + ], +) def test_generic_session_requires_initialize_but_keeps_modern_stateless( - protocol_version: str, + protocol_version: str, expected: str, ) -> None: dispatcher = McpToolDispatcher( server_name="fixture", @@ -206,7 +231,7 @@ def test_generic_session_requires_initialize_but_keeps_modern_stateless( assert held["error"]["code"] == -32600 assert modern["result"]["structuredContent"] == {"era": "modern"} - assert initialized["result"]["protocolVersion"] == protocol_version + assert initialized["result"]["protocolVersion"] == expected assert still_held["error"]["code"] == -32600 assert initialized_notification is None assert admitted["result"]["structuredContent"] == {"era": "initialized"} @@ -359,10 +384,11 @@ def test_bounded_stdio_connector_validates_request_binding() -> None: assert decision["selected"]["offer"]["id"] == "offer:hello-python-exact" -def test_stdio_server_completes_initialization_era_lifecycle() -> None: +@pytest.mark.parametrize("protocol_version", [STABLE_PROTOCOL_VERSION, "2025-11-25"]) +def test_stdio_server_completes_initialization_era_lifecycle(protocol_version: str) -> None: request = load_json(REQUEST) messages = [ - _initialize_message(STABLE_PROTOCOL_VERSION), + _initialize_message(protocol_version), {"jsonrpc": "2.0", "method": "notifications/initialized"}, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, { diff --git a/tests/test_method_capture.py b/tests/test_method_capture.py new file mode 100644 index 0000000..cee65ae --- /dev/null +++ b/tests/test_method_capture.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from limitless_library.catalog import LocalCatalog +from limitless_library.contracts import load_json +from limitless_library.mcp_protocol import modern_metadata +from limitless_library.mcp_server import REGISTER_METHOD_TOOL_NAME, TOOL_NAME, _dispatcher +from limitless_library.method_capture import MethodCaptureError, register_method, sync_methods +from limitless_library.publication import PublicationError, _new_state, _source_descriptor +from limitless_library.service_contracts import validate_source_free_method +from limitless_library.service_identity import InstallationSigner + +ROOT = Path(__file__).parents[1] +CATALOG = ROOT / "examples" / "catalog" +POLICY_DIGEST = "sha256:" + "5" * 64 + + +def _method() -> dict: + return { + "title": "Verify observed adoption", + "taskKind": "receiver-verification", + "method": load_json(ROOT / "examples" / "publication" / "method.json"), + "observedOutcome": "Receiver checks passed and the supplied component was invoked.", + "platform": "linux", + } + + +def _call(name: str, arguments: dict) -> dict: + return { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": name, + "arguments": arguments, + "_meta": modern_metadata(client_name="method-test", client_version="1"), + }, + } + + +def test_generic_mcp_keeps_query_first_and_registers_one_local_method(tmp_path: Path) -> None: + store = tmp_path / "methods" + dispatcher = _dispatcher(LocalCatalog(CATALOG), method_store=store) + names = [ + tool["name"] + for tool in dispatcher.handle( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {"_meta": modern_metadata(client_name="method-test", client_version="1")}, + } + )["result"]["tools"] + ] + assert names == [TOOL_NAME, REGISTER_METHOD_TOOL_NAME] + assert "After completing and checking" in dispatcher.instructions + + request = load_json(ROOT / "examples" / "requests" / "exact-python.json") + assert dispatcher.handle(_call(TOOL_NAME, request))["result"]["structuredContent"]["decision"] == "reuse" + first = dispatcher.handle(_call(REGISTER_METHOD_TOOL_NAME, _method()))["result"]["structuredContent"] + second = dispatcher.handle(_call(REGISTER_METHOD_TOOL_NAME, _method()))["result"]["structuredContent"] + assert first["status"] == "registered" + assert first["destination"] == first["submission"] == "local" + assert second == {**first, "status": "duplicate"} + folder = store / first["methodRef"].split(":", 1)[1] + assert len(list(store.glob("[0-9a-f]*"))) == 1 + assert not (folder / "publication.json").exists() + assert json.loads((folder / "capture.json").read_text())["observedOutcome"] == _method()["observedOutcome"] + assert folder.stat().st_mode & 0o077 == 0 + assert (folder / "method.json").stat().st_mode & 0o077 == 0 + assert dispatcher.handle(_call(TOOL_NAME, request))["result"]["structuredContent"]["decision"] == "reuse" + + +def test_public_mode_prepares_canonical_method_and_retries_without_duplicate_submission( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = tmp_path / "methods" + captured = register_method(store, _method(), public_policy_digest=POLICY_DIGEST, schedule=False) + folder = store / captured["methodRef"].split(":", 1)[1] + method = validate_source_free_method(json.loads((folder / "method.json").read_text())) + assert method["summary"] == _method()["method"]["summary"] + assert _source_descriptor("method", "method.json", base=folder)["role"] == "method" + draft = json.loads((folder / "publication.json").read_text()) + assert draft["rights"] == {"license": "CC0-1.0", "allowedUses": ["derive-method"], "hasAuthority": True} + assert draft["compatibility"]["verifiedTargets"] == [] + assert draft["evidenceDigests"] != [] + signer = InstallationSigner.generate() + state = _new_state( + draft=draft, + draft_path=folder / "publication.json", + service_id="service:fixture", + policy={"revision": "policy:1", "digest": POLICY_DIGEST}, + signer=signer, + publisher={ + "publisherId": "installation:" + "8" * 32, + "authorityId": "installation:" + "8" * 32, + "keyId": signer.key_id, + "generation": 1, + }, + now=datetime(2026, 9, 23, tzinfo=UTC), + ) + assert state["intent"]["candidate"]["treatment"] == "source-free-method" + calls: list[Path] = [] + monkeypatch.setattr( + "limitless_library.method_capture.activated_service_connector", + lambda: SimpleNamespace(profile=SimpleNamespace(service_id="service:fixture")), + ) + monkeypatch.setattr( + "limitless_library.method_capture.installation_publisher_authority", + lambda **_: (object(), {"publisherId": "publisher:fixture"}), + ) + + def publish(_connector: object, **kwargs: object) -> dict: + calls.append(Path(kwargs["draft_path"])) + assert kwargs["accepted_publication_policy_digest"] == POLICY_DIGEST + return {"admissionState": "active"} + + monkeypatch.setattr("limitless_library.method_capture.publish_draft", publish) + assert sync_methods(store, POLICY_DIGEST) == {"processed": 1, "submitted": 1, "attention": 0} + assert sync_methods(store, POLICY_DIGEST) == {"processed": 0, "submitted": 0, "attention": 0} + assert calls == [folder / "publication.json"] + assert ( + register_method(store, _method(), public_policy_digest=POLICY_DIGEST, schedule=False)["submission"] == "active" + ) + + +def test_public_policy_drift_preserves_capture_for_later_retry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + store = tmp_path / "methods" + captured = register_method(store, _method(), public_policy_digest=POLICY_DIGEST, schedule=False) + monkeypatch.setattr( + "limitless_library.method_capture.activated_service_connector", + lambda: SimpleNamespace(profile=SimpleNamespace(service_id="service:fixture")), + ) + monkeypatch.setattr("limitless_library.method_capture.installation_publisher_authority", lambda **_: (object(), {})) + + def changed(_connector: object, **_kwargs: object) -> dict: + raise PublicationError("publication policy changed") + + monkeypatch.setattr("limitless_library.method_capture.publish_draft", changed) + assert sync_methods(store, POLICY_DIGEST) == {"processed": 1, "submitted": 0, "attention": 1} + assert ( + register_method(store, _method(), public_policy_digest=POLICY_DIGEST, schedule=False)["submission"] + == "policy-attention" + ) + monkeypatch.setattr( + "limitless_library.method_capture.publish_draft", lambda *_args, **_kwargs: {"admissionState": "active"} + ) + assert sync_methods(store, "sha256:" + "6" * 64)["submitted"] == 1 + assert ( + register_method(store, _method(), public_policy_digest=POLICY_DIGEST, schedule=False)["methodRef"] + == captured["methodRef"] + ) + + +def test_invalid_method_and_bad_public_authority_never_create_records(tmp_path: Path) -> None: + store = tmp_path / "methods" + invalid = _method() + invalid["method"]["steps"][0]["index"] = 2 + with pytest.raises(MethodCaptureError, match="source-free method contract"): + register_method(store, invalid) + with pytest.raises(MethodCaptureError, match="reviewed policy digest"): + register_method(store, _method(), public_policy_digest="not-a-digest") + assert not store.exists() + + +def test_unavailable_background_worker_is_visible_and_preserves_retry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("limitless_library.method_capture.schedule_submission", lambda *_args: False) + store = tmp_path / "methods" + result = register_method(store, _method(), public_policy_digest=POLICY_DIGEST) + assert result["destination"] == "public" + assert result["submission"] == "retryable" + assert (store / result["methodRef"].split(":", 1)[1] / "publication.json").exists() + + +def test_stdio_mcp_lists_capture_and_requires_explicit_public_settings(tmp_path: Path) -> None: + environment = os.environ.copy() + environment["PYTHONPATH"] = str(ROOT / "src") + command = [ + sys.executable, + "-m", + "limitless_library.mcp_server", + "--catalog", + str(CATALOG), + "--method-store", + str(tmp_path / "methods"), + ] + bad = subprocess.run( + command + ["--submit-methods-publicly"], + input="", + text=True, + capture_output=True, + env=environment, + check=False, + ) + assert bad.returncode == 2 + assert not (tmp_path / "methods").exists() + messages = [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {"_meta": modern_metadata(client_name="method-test", client_version="1")}, + }, + _call(REGISTER_METHOD_TOOL_NAME, _method()), + ] + good = subprocess.run( + command, + input="".join(json.dumps(item) + "\n" for item in messages), + text=True, + capture_output=True, + env=environment, + check=True, + ) + responses = [json.loads(line) for line in good.stdout.splitlines()] + assert [tool["name"] for tool in responses[0]["result"]["tools"]] == [TOOL_NAME, REGISTER_METHOD_TOOL_NAME] + assert responses[1]["result"]["structuredContent"]["destination"] == "local" diff --git a/tests/test_publication.py b/tests/test_publication.py index 0a9009e..f3c7787 100644 --- a/tests/test_publication.py +++ b/tests/test_publication.py @@ -1,12 +1,13 @@ from __future__ import annotations +from copy import deepcopy from datetime import UTC, datetime, timedelta from hashlib import sha256 from pathlib import Path import pytest -from limitless_library.contracts import canonical_json_bytes, sha256_json, strict_json_loads +from limitless_library.contracts import canonical_json_bytes, load_json, sha256_json, strict_json_loads from limitless_library.exact_file_bundle import build_exact_file_bundle from limitless_library.public_submission_contracts import ( build_content_transfer_grant, @@ -15,9 +16,12 @@ ) from limitless_library.publication import ( PublicationError, + _new_state, + _source_descriptor, publication_status, publish_draft, revoke_publication, + seal_publication_method, ) from limitless_library.service_connector import ( ServiceConnector, @@ -243,7 +247,8 @@ def _fixture() -> tuple[ def _write_draft(tmp_path: Path) -> Path: - (tmp_path / "method.md").write_text("Verify locally, then adopt.\n", encoding="utf-8") + method = Path(__file__).resolve().parents[1] / "examples" / "publication" / "method.json" + (tmp_path / "method.json").write_bytes(method.read_bytes()) draft = { "schemaVersion": "limitless.publication-draft/1.0", "candidate": { @@ -259,7 +264,7 @@ def _write_draft(tmp_path: Path) -> Path: "parents": [], "supersedes": None, }, - "objects": [{"role": "method", "path": "method.md"}], + "objects": [{"role": "method", "path": "method.json"}], "compatibility": { "supportedTargets": [ { @@ -291,6 +296,88 @@ def _write_draft(tmp_path: Path) -> Path: return path +@pytest.mark.parametrize( + "content", [b"{}", b'{"summary":"a","summary":"b"}', b"not json", b'{ "summary": "pretty JSON" }'] +) +def test_invalid_method_never_creates_signed_state_or_uploads(tmp_path: Path, content: bytes) -> None: + connector, transport, signer, publisher = _fixture() + draft = _write_draft(tmp_path) + (tmp_path / "method.json").write_bytes(content) + with pytest.raises(PublicationError, match="publication method"): + publish_draft( + connector, + draft_path=draft, + state_path=None, + signer=signer, + publisher=publisher, + accepted_publication_policy_digest=transport.policy["digest"], + now=NOW, + ) + assert not draft.with_name(draft.name + ".state.json").exists() + assert transport.intent is None + assert transport.upload_calls == 0 + assert transport.authorization_calls == 0 + + +def test_pretty_method_requires_explicit_sealing_without_rewriting_source(tmp_path: Path) -> None: + import json + + draft = _write_draft(tmp_path) + source = tmp_path / "method.json" + method = load_json(source) + original = json.dumps(method, indent=2).encode() + source.write_bytes(original) + with pytest.raises(PublicationError, match="seal-method"): + _source_descriptor("method", source.name, base=draft.parent) + sealed = tmp_path / "method.canonical.json" + result = seal_publication_method(source, sealed) + assert source.read_bytes() == original + assert sealed.read_bytes() == canonical_json_bytes(method) + b"\n" + assert sealed.stat().st_mode & 0o777 == 0o600 + descriptor = _source_descriptor("method", sealed.name, base=tmp_path) + assert descriptor["digest"] == result["digest"] + with pytest.raises(PublicationError, match="cannot seal"): + seal_publication_method(source, sealed) + assert sealed.read_bytes() == canonical_json_bytes(method) + b"\n" + + +@pytest.mark.parametrize("newline", [b"", b"\n"]) +def test_method_preflight_preserves_accepted_exact_bytes(tmp_path: Path, newline: bytes) -> None: + _write_draft(tmp_path) + source = tmp_path / "method.json" + method = load_json(source) + method["constraints"] = [] + method["limitations"] = [] + payload = canonical_json_bytes(method) + newline + source.write_bytes(payload) + descriptor = _source_descriptor("method", source.name, base=tmp_path) + assert descriptor["digest"] == "sha256:" + sha256(payload).hexdigest() + assert source.read_bytes() == payload + + +@pytest.mark.parametrize( + "change", + [ + {"evaluation": []}, + {"steps": [{"index": True, "instruction": "Check", "check": "fact-present", "expected": "Pass"}]}, + ], +) +def test_method_preflight_matches_service_structural_rejections(tmp_path: Path, change: dict) -> None: + _write_draft(tmp_path) + source = tmp_path / "method.json" + source.write_bytes(canonical_json_bytes({**load_json(source), **change})) + with pytest.raises(PublicationError, match="invalid publication method"): + _source_descriptor("method", source.name, base=tmp_path) + + +def test_auxiliary_objects_keep_their_own_byte_contract(tmp_path: Path) -> None: + source = tmp_path / "evidence.txt" + payload = b"Receiver verification notes.\n" + source.write_bytes(payload) + for role in ("manifest", "verification"): + assert _source_descriptor(role, source.name, base=tmp_path)["digest"] == "sha256:" + sha256(payload).hexdigest() + + def test_one_command_publication_is_resumable_and_cwd_independent(tmp_path: Path) -> None: connector, transport, signer, publisher = _fixture() draft = _write_draft(tmp_path) @@ -327,13 +414,73 @@ def test_one_command_publication_is_resumable_and_cwd_independent(tmp_path: Path assert state.stat().st_mode & 0o777 == 0o600 +def test_legacy_signed_state_resumes_without_rewriting_or_reuploading(tmp_path: Path) -> None: + connector, transport, signer, publisher = _fixture() + draft_path = _write_draft(tmp_path) + fixture = load_json( + Path(__file__).parents[1] / "src/limitless_library/conformance/multi-target-submission-1.2.json" + ) + draft = load_json(draft_path) + draft["compatibility"] = deepcopy(fixture["oldDigestOrderedIntent"]["compatibility"]) + draft_path.write_bytes(canonical_json_bytes(draft) + b"\n") + saved = _new_state( + draft=draft, + draft_path=draft_path, + service_id=connector.profile.service_id, + policy=transport.policy, + signer=signer, + publisher=publisher, + now=NOW, + ) + saved["intent"]["compatibility"] = draft["compatibility"] + unsigned = {k: v for k, v in saved["intent"].items() if k not in {"intentDigest", "signature"}} + saved["intent"]["intentDigest"] = sha256_json(unsigned) + signed = {**unsigned, "intentDigest": saved["intent"]["intentDigest"]} + saved["intent"]["signature"]["value"] = signer.sign(canonical_json_bytes(signed)) + state_path = tmp_path / "legacy-state.json" + original = canonical_json_bytes(saved) + b"\n" + state_path.write_bytes(original) + state_path.chmod(0o600) + first = publish_draft( + connector, + draft_path=draft_path, + state_path=state_path, + signer=signer, + publisher=publisher, + accepted_publication_policy_digest=transport.policy["digest"], + now=NOW, + ) + second = publish_draft( + connector, + draft_path=draft_path, + state_path=state_path, + signer=signer, + publisher=publisher, + accepted_publication_policy_digest=transport.policy["digest"], + now=NOW, + ) + assert transport.intent == saved["intent"] + assert ( + first["submissionRef"] + == second["submissionRef"] + == public_submission_ref(intent_digest=saved["intent"]["intentDigest"]) + ) + assert state_path.read_bytes() == original + assert transport.upload_calls == 1 + assert second["uploadedObjects"] == [] + transport.admission_state = "active" + assert ( + publication_status(connector, state_path=state_path, signer=signer, publisher=publisher)["admissionState"] + == "active" + ) + assert state_path.read_bytes() == original + + def test_artifact_publication_uses_current_format_aware_intent(tmp_path: Path) -> None: connector, transport, signer, publisher = _fixture() draft_path = _write_draft(tmp_path) draft = strict_json_loads(draft_path.read_text(encoding="utf-8")) - bundle = build_exact_file_bundle( - {"manifest.json": b'{"schemaVersion":1,"id":"example.plugin"}\n'} - ) + bundle = build_exact_file_bundle({"manifest.json": b'{"schemaVersion":1,"id":"example.plugin"}\n'}) (tmp_path / "plugin.bundle").write_bytes(bundle) draft["candidate"] = { **draft["candidate"], diff --git a/tests/test_publication_contracts.py b/tests/test_publication_contracts.py index bfa3231..c382499 100644 --- a/tests/test_publication_contracts.py +++ b/tests/test_publication_contracts.py @@ -10,7 +10,7 @@ import pytest -from limitless_library.contracts import load_json, sha256_json +from limitless_library.contracts import canonical_json_bytes, load_json, sha256_json from limitless_library.public_admission_contracts import ( PublicAdmissionContractError, assessment_state, @@ -20,12 +20,88 @@ ) from limitless_library.public_submission_contracts import ( PublicSubmissionContractError, + build_submission_intent, validate_content_transfer_grant, validate_content_transfer_result, validate_immutable_release, validate_submission_intent, validate_submission_plan, ) +from limitless_library.service_identity import InstallationSigner + + +def test_new_target_order_is_canonical_and_legacy_signatures_are_preserved() -> None: + fixture = load_json(Path(str(files("limitless_library.conformance").joinpath("multi-target-submission-1.2.json")))) + key = _decode(fixture["publicKey"]) + intent = fixture["intent"] + keys = {intent["publisher"]["keyId"]: key} + assert validate_submission_intent(intent, public_keys=keys) == intent + targets = intent["compatibility"]["supportedTargets"] + assert targets == sorted(targets, key=canonical_json_bytes) + assert [target["interfaces"][0] for target in targets] == fixture["expectedInterfaces"] + assert [proof["target"] for proof in intent["compatibility"]["verifiedTargets"]] == targets + legacy = fixture["oldDigestOrderedIntent"] + assert validate_submission_intent(legacy, public_keys=keys) == legacy + + signer = InstallationSigner.generate() + fields = deepcopy({k: v for k, v in intent.items() if k not in {"schemaVersion", "signature", "intentDigest"}}) + fields["publisher"]["keyId"] = signer.key_id + fields["compatibility"]["supportedTargets"].reverse() + fields["compatibility"]["verifiedTargets"].reverse() + built = build_submission_intent(signer=signer, **fields) + assert built["compatibility"] == intent["compatibility"] + + +@pytest.mark.parametrize("field", ["supportedTargets", "verifiedTargets"]) +def test_signed_target_order_cannot_change_without_a_new_signature(field: str) -> None: + fixture = load_json(Path(str(files("limitless_library.conformance").joinpath("multi-target-submission-1.2.json")))) + changed = deepcopy(fixture["oldDigestOrderedIntent"]) + keys = {changed["publisher"]["keyId"]: _decode(fixture["publicKey"])} + changed["compatibility"][field].reverse() + with pytest.raises(PublicSubmissionContractError, match="digest"): + validate_submission_intent(changed, public_keys=keys) + changed["intentDigest"] = sha256_json({k: v for k, v in changed.items() if k not in {"intentDigest", "signature"}}) + with pytest.raises(PublicSubmissionContractError, match="signature"): + validate_submission_intent(changed, public_keys=keys) + + +@pytest.mark.parametrize("mutation", ["duplicate-target", "duplicate-proof", "unclaimed-proof", "over-limit"]) +def test_legacy_order_does_not_bypass_target_validation(mutation: str) -> None: + fixture = load_json(Path(str(files("limitless_library.conformance").joinpath("multi-target-submission-1.2.json")))) + changed = deepcopy(fixture["oldDigestOrderedIntent"]) + compatibility = changed["compatibility"] + if mutation == "duplicate-target": + compatibility["supportedTargets"].append(compatibility["supportedTargets"][0]) + elif mutation == "duplicate-proof": + compatibility["verifiedTargets"].append(compatibility["verifiedTargets"][0]) + elif mutation == "unclaimed-proof": + compatibility["verifiedTargets"][0]["target"]["interfaces"] = ["repo.unclaimed/v1"] + else: + compatibility["supportedTargets"] *= 5 + with pytest.raises(PublicSubmissionContractError, match="compatibility|unique|exact supported"): + validate_submission_intent(changed, public_keys={changed["publisher"]["keyId"]: _decode(fixture["publicKey"])}) + + +def test_legacy_release_preserves_signed_intent_and_plan_identity() -> None: + fixture = load_json(Path(str(files("limitless_library.conformance").joinpath("multi-target-submission-1.2.json")))) + legacy = fixture["oldDigestOrderedIntent"] + key = fixture["releasePublicKey"] + keys = {key["keyId"]: _decode(key["publicKey"])} + plan = validate_submission_plan(fixture["legacyAcceptedPlan"], public_keys=keys, expected_intent=legacy) + release = validate_immutable_release( + fixture["legacyRelease"], public_keys=keys, expected_intent=legacy, expected_plan=plan + ) + assert release == fixture["legacyRelease"] + assert release["compatibility"] == legacy["compatibility"] + changed = deepcopy(release) + changed["compatibility"]["verifiedTargets"].reverse() + with pytest.raises(PublicSubmissionContractError, match="identity"): + validate_immutable_release(changed, public_keys=keys) + body = {k: v for k, v in changed.items() if k not in {"releaseId", "releaseDigest", "signature"}} + changed["releaseDigest"] = sha256_json(body) + changed["releaseId"] = "release:" + changed["releaseDigest"][7:39] + with pytest.raises(PublicSubmissionContractError, match="signature"): + validate_immutable_release(changed, public_keys=keys) def _corpus(name: str) -> dict[str, Any]: diff --git a/tests/test_service_connector.py b/tests/test_service_connector.py index a1d3be4..c11f49c 100644 --- a/tests/test_service_connector.py +++ b/tests/test_service_connector.py @@ -12,6 +12,7 @@ import pytest +from limitless_library import __version__ from limitless_library.contracts import ( canonical_json_bytes, load_json, @@ -857,6 +858,24 @@ def test_query_builder_emits_only_the_current_public_policy_vocabulary() -> None assert "exchange" not in canonical_json_bytes(query).decode("utf-8") +def test_query_builder_rejects_malformed_receiver_before_discovery() -> None: + corpus = load_json(CORPUS) + transport = MemoryTransport(corpus) + connector = ServiceConnector(_profile(corpus), transport=transport, clock=lambda: AT) + with pytest.raises(ServiceConnectorError, match="receiverContext"): + connector.build_query(request_id="request:invalid-receiver", objective="Verify prior work", + receiver_context={"constraints": [], "toolchain": {}}) + assert transport.calls == [] + + +def test_query_builder_normalizes_other_contract_errors() -> None: + corpus = load_json(CORPUS) + connector = ServiceConnector(_profile(corpus), transport=MemoryTransport(corpus), clock=lambda: AT) + with pytest.raises(ServiceConnectorError, match="invalid service query: service query ttl"): + connector.build_query(request_id="request:invalid-ttl", objective="Verify prior work", + receiver_context=corpus["query"]["receiverContext"], ttl_seconds=0) + + def test_query_builder_negotiates_current_result_from_current_discovery() -> None: corpus = load_json(CORPUS) root_signer = InstallationSigner.generate() @@ -1139,7 +1158,7 @@ def test_signed_artifact_is_fetched_with_header_authority_and_staged_without_sec "headers": { "accept": "application/octet-stream", "authorization": "Bearer test-access-token-value", - "user-agent": "limitless-library/0.1.0a0", + "user-agent": f"limitless-library/{__version__}", "Limitless-Capability": result["selection"]["immutable"]["authorization"]["value"], }, "body": None,