From 9c72a4165e76dfbf52e15a0ff881b4697142f138 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Tue, 20 Oct 2026 19:52:24 -0400 Subject: [PATCH 1/2] feat: add verified one-line install and concise stack guide --- .github/workflows/install.yml | 19 +++ README.md | 285 +++++--------------------------- docs/installation-reference.md | 264 +++++++++++++++++++++++++++++ install.sh | 242 +++++++++++++++++++++++++++ scripts/test-install-release.py | 177 ++++++++++++++++++++ 5 files changed, 745 insertions(+), 242 deletions(-) create mode 100644 .github/workflows/install.yml create mode 100644 docs/installation-reference.md create mode 100755 install.sh create mode 100644 scripts/test-install-release.py diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml new file mode 100644 index 0000000..061eb67 --- /dev/null +++ b/.github/workflows/install.yml @@ -0,0 +1,19 @@ +name: Installer controls +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + installer: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Validate standalone entrypoint and refusal controls + run: | + sh -n install.sh + python3 scripts/test-install-release.py diff --git a/README.md b/README.md index 04c623c..ef9afb8 100644 --- a/README.md +++ b/README.md @@ -1,263 +1,64 @@ -# chaski +# Chaski -A post-commit and time-driven **reactor** for a [quipu](https://github.com/scbrown/quipu) -knowledge graph. +Reactions and notifications when your knowledge graph changes. Chaski keeps +notification delivery separate from the facts that triggered it. -In the Inca empire, chaski were the relay runners who carried quipus between -stations. This one carries what the graph learned to the people and systems who -need to act on it. +## Install -## What it does - -quipu records facts. chaski watches for facts that call for action, and acts: - -- **Event rules** fire when a write changes something that matters. For example, - "a credential was verified and is now dead". chaski follows quipu's durable - event feed (`GET /events` with a committed consumer offset), so a restart - resumes exactly where it stopped. -- **Schedule rules** fire when time makes something true. For example, "a - credential expires within 14 days". quipu has no timers of its own; chaski is - the scheduler. - -Rules are data. Each rule is a SPARQL `SELECT ?focus` whose result is the set of -nodes the rule is firing for **right now**. A node leaving the set resolves the -alert, so alerts resolve on their own. - -## Design constraints - -- **Public API only.** chaski never reads quipu's storage. A reactor that reads - a store's internals dies the day the store changes. -- **Liveness is measured by output, not process.** A canary rule must fire every - cycle. A chaski that is running but not evaluating alerts on itself. -- **Unknown is not "all clear".** A query that times out is counted as - `unknown`, never as "not firing". -- **Writes are transitions only.** Steady state writes nothing to the graph. -- **Emission reuses Prometheus and Alertmanager.** chaski exports metrics; it - does not invent notification plumbing. - -## Status - -Committed-change stream progress is exported as three scalar metrics: -`chaski_changes_stream_events_received_total` counts received SSE pages, -including refused or replayed deliveries; it does not prove processing. -`chaski_changes_cursor_offset` reports the last committed **transaction ID**, -not the legacy `/events` offset, and is `-1` until a commit is observed. -`chaski_changes_poll_fallback_events_total` counts records admitted through -polling, including startup catch-up for an existing cursor. The initial -tail bootstrap discards records and adds nothing to that counter. -Scrapes read in-memory values without touching SQLite or querying the server. -Counters reset on restart. Compare two scrapes from the same process: received -pages plus an advancing committed cursor and no additional poll records prove -stream progress. One INFO line per process names the first committed stream -transaction; later transactions use counters rather than per-page log traffic. - -Stage 1: a read-only skeleton, with the event tail, schedule rules, metrics and -the canary. No writes and no pages yet. - -Stage 2 (in progress): **emitters** (`emitter.py`). An emitter runs an external -verdict adapter on a schedule and turns its answers into transition events. A -first sighting is a baseline, "unknown" keeps the last verdict, and only a -known blocked-to-unblocked transition emits. Verdict and outbox commit in one -transaction. Delivery is at least once to a receiver that deduplicates on the -adapter's deterministic event id. Configure with `--emitters emitters.yaml`: - -Scheduled adapters normally return a JSON list representing the complete -inventory. A partial observation may instead return -`{"records": [...], "scope": ["item-a", "item-b"]}` (at most 10,000 records and -unique scope keys). The existing partial-scope observation path updates only -those keys; absence outside the scope is UNKNOWN. An empty partial scope never -clears an earlier verdict. Conclusive records can still emit, while the runner -counts the tick as UNKNOWN and does not advance whole-inventory success time. -Malformed envelopes and records outside their scope refuse atomically. Existing -JSON-list adapters retain their full-inventory behavior. - -```yaml -emitters: - - label: workitem-unblocked - command: [python3, /path/to/blocked_by.py] - schedule: PT15M - owner: someone - event: unblocked - sink: {jsonl: /var/lib/chaski/unblocked.jsonl} +```sh +curl -fsSL https://raw.githubusercontent.com/scbrown/chaski/main/install.sh | sh ``` -## License - -Apache-2.0. See [LICENSE](LICENSE). - -### Incremental verdict adapters - -An emitter can set `trigger: changes` and `schedule: PT24H`. The schedule then -means catalogue reconciliation, not a full verdict scan. The adapter must support -`--describe` (local subscription metadata) and `--changes` (JSON on stdin). Existing -scheduled adapters remain supported. +Python 3.11+ with venv/pip, curl, and a Linux or macOS shell. Downloads the latest checksummed wheel into its own virtual environment; +command links go in `~/.local/bin`. Add that directory to `PATH`. -The version-1 protocol describes `attributes`, `types` and trusted `graphs`. -A request contains `now` plus one of: +Set `STACK_INSTALL_ROOT` on the receiving shell for another prefix. +Pinned releases, alternatives, configuration and removal are in the +[installation and usage reference](docs/installation-reference.md). -- `discover: true`: return `items`, the complete catalogue of candidate keys. -- `route: [...]`: return `items` affected by the supplied fact changes. Retracted - links carry `old_value`; adapters must include their former owners. -- `items: [...]`: return `scope`, `records` and `next_checks` (one future Unix - deadline or null per record). Missing keys inside scope become untracked; - keys outside scope retain their verdict. UNKNOWN preserves the last verdict. +## Try it -Every response includes `version: 1`. The first discovery starts after a durable -feed-tail checkpoint; subsequent changes cannot fall through the bootstrap gap. -Chaski polls `/changes` with `old_and_new_values` at the existing 60-second floor, -committing the cursor and matching inbox records together. Routing atomically -moves inbox records into a persistent key queue. Two changed subjects and two -ready keys are processed per tick, with changed keys ahead of reconciliation. -Verdicts, future deadlines and transition outbox records commit together. A -failed operation leaves its work for retry, and restart does not repeat discovery. -The existing idempotent receiver and global write budget still govern delivery. - -`chaski_changes_lag_transactions`, `chaski_changes_errors_total` and per-emitter -`changes_pending`, `deadlines`, `next_due_timestamp_seconds` expose feed and queue -health. The existing event lag/error signals also include the change feed. Idle -adapters make no queries until a matching change, deadline or reconciliation. -Catch-up is bounded to ten pages of 100 transactions per poll; sustained overload -is visible as lag rather than silently dropping work. Latency includes queueing -and adapter runtime; a large dependency fan-out can take multiple ticks. - -Adapter state uses additive SQLite tables in the existing emitter database. -Rollback to a scheduled adapter preserves verdicts and delivered-event identities; -keep that database, and restore the previous code and emitter configuration. - -### Authenticated external event sources - -An authenticated webhook or local event source can queue an explicit event into -Chaski's existing emitter outbox with `external_event.py`. Authentication belongs -to that source; this local CLI exposes no network listener. Configure an external -emitter with `baseline_emits: true` so the first published event is delivered: - -```yaml -emitters: - - label: release-pin - trigger: external - schedule: PT24H # metadata only; no periodic verdict adapter is invoked - owner: kit - event: release.published - from: ABSENT - to: PUBLISHED - baseline_emits: true - sink: {jsonl: /var/lib/example/releases.jsonl} -``` +Inspect the installed receiver's options: ```sh -printf '%s\n' '{"event_id":"repo-kit-1","type":"release.published","payload":{"tag":"v1.2.3"}}' | - python external_event.py --emitters emitters.yaml --label release-pin --state emitter.db +chaski --help ``` -Use the same emitter database and configuration as the running Chaski instance. -The existing runner delivers pending events on its next tick without running a -verdict adapter or scanning remote data. Existing graph, command, alert, retry, -write-budget and receiver-deduplication contracts remain in force. A command -receiver must return nonzero while held or on failed proof: exit zero is its -receipt and acknowledges delivery. - -The receipt contains the original source ID only after a synchronous transaction -proves the exact outbox payload. A lost receipt may be retried with identical -bytes; restart and redelivery do not queue a second firing. Changed payload under -an existing source ID refuses. The outbox ID and graph focus use a hash scoped to -the emitter, so arbitrary source identifiers cannot become graph syntax and two -emitters can consume the same source event independently. Graph reaction metadata -records an event trigger for this mode. No webhook or installer is activated by -adding the source code. - -### Kind-specific review signals - -Verdict adapters may include a bounded `work_kind` token. The emitter freezes it -in the outbox with the event identity, so a retry cannot reclassify an event. -The Alertmanager sink accepts an optional `kind_overrides` map in deployment -configuration. Each exact kind may override only `severity` and `alertname`; -other labels and adapter-supplied severity are ignored. Missing and unknown kinds -retain the emitter's default policy. For example, a deployment can route -`DreamCycle` and `DreamLane` as informational signals while directives remain -warnings. Changing this map is a routing-policy change requiring review. - - -### Committed transaction stream (opt-in) - -`--changes-stream` moves incremental emitter change delivery to the read-only -`/changes/stream` SSE endpoint. Existing configurations keep the poll path. -Bootstrap retains the existing tail-before-discovery behavior. Transaction IDs -are local durable change cursors; they are not `/events/commit` event offsets. -The reader queues one complete frame and waits for the reactor thread to commit -both routed inbox records and the cursor before accepting another. The reactor drains up to64 pages in a50ms burst -(budget checked between commits), then gives adapters/deadlines their turn. -New delivery wakes the reactor instead of waiting for its five-second idle -sleep; only one frame stays queued. Disconnects, -invalid frames and failed local commits retain the last applied cursor and -reconnect with 1–60 second backoff. A frame is bounded to 1 MiB; heartbeat -comments perform no graph read. The network thread never accesses SQLite. - -Scheduled rules, incremental adapter deadlines and bounded catalogue -reconciliation retain their existing behavior. This flag does not move the -separate ROOT event-rule consumer to SSE. Closing a network reader can wait for -its bounded 45-second socket timeout. Source tests are not permission to enable -this flag, replay production history or claim natural scheduled acceptance. - -### Repeated command failures - -`failure_family_adapter.py` consumes the `dp failure-families` JSON report and a directly typed Quipu `ConfigSetting` containing a single JSON description. Policy fields are `default_threshold`, `window_seconds`, `default_owner`, optional `family_thresholds` (hashed family ID to threshold), and `tool_owners` (tool name to crew owner). Invalid/missing/ambiguous policy or failed aggregation exits nonzero, leaving the emitter UNKNOWN. Ownership comes from this graph policy, with an explicit crew fallback; human recipients are refused. +Then choose an explicit source and sink in the [configuration guide](docs/installation-reference.md). +Installation alone starts no receiver or scheduled job. -Configure the emitter with `from: BELOW`, `to: ABOVE`, `resolve_recovery: true`, `generation_event_ids: true`, and `missing_is_from: true`. Keep `baseline_emits: false`: first sighting above threshold is a baseline, not an observed crossing. A complete successful rolling-window inventory can resolve absent families; partial observations and failed runs cannot. Unsupported program shapes are a separate disclosed measurement population, not silently attributed to a known family. Each actual crossing gets a generation-bound identity, stable across retries and restarts. Recovery refers to the original frozen firing labels and cannot overtake its receipt. The existing Alertmanager sink retains its finite TTL; scheduled delivery and producer-liveness monitoring are still deployment requirements. This adapter is not scheduled or enabled by the repository itself. +## Next steps -### Exclusive consumer custody +- [Tambo](https://github.com/scbrown/tambo): guided stack installation and verification. +- [Camayoc](https://github.com/scbrown/camayoc): governed memory and vocabulary. +- [Detailed examples and agent setup](docs/installation-reference.md). -The `chaski.py` CLI takes non-blocking process-lifetime locks before loading -rules or reading its persistent cursor and emitter inbox. A second consumer -using those paths refuses loudly with exit code 2, including `--once`. Use -separate private state paths for an independent diagnostic invocation. +**Load this repository’s graph:** public repository-pendant assets have not yet been verified. When a release supplies the archive, checksum and provenance, follow the [verified import workflow](https://scbrown.github.io/quipu/sharing/index.html): extract the share directory and use `quipu import SHARE_DIR --db repository.db`. Import stages knowledge; ROOT stays unchanged. -The inbox lock follows the database inode, so symbolic-link and hard-link -aliases do not admit a second owner. The legacy cursor also has a stable -`.consumer-lease` sidecar beside its canonical path. Keep the sidecar in place; -unlinking or replacing a live lease or database can split ownership. Kernel -locks release on process exit, including crashes, and acquisition failures -release any earlier locks. These locks serialize cooperating CLI consumers; -they do not exclude arbitrary privileged tools or authorize stream activation. +## The stack -Deployments must include `consumer_lease.py` with the matching `chaski.py` pin. - -## Development - -Python 3.11 or newer and [uv](https://docs.astral.sh/uv/) are required. -The distribution is named `chaski-reactor`; its commands are `chaski`, -`chaski-event` and `chaski-once`. - -```sh -uv run --locked python -m pytest -uv run --locked ruff check . -uv build -``` - -An offline canary exercises the actual reaction engine with controlled query -answers. It fires, preserves its firing state on UNKNOWN, then resolves after a -successful empty answer. It does not connect to a graph or deliver notifications. -The receipt is separate from live consumer state: - -```sh -uv run --locked chaski demo status -uv run --locked chaski demo run --focus example-canary -uv run --locked chaski demo status -``` + -CI builds the wheel and installs it into an isolated environment on Linux and -macOS. Its smoke check verifies module bytes against the wheel RECORD, checks -all console entry points, and reads the measured canary receipt back. +The Quipu stack is built around a governed, bitemporal knowledge store: shared +facts, decisions and work history your agents can query. Quipu can check writes +against your rules, records where each fact came from, and answers what was true +when. -### Scheduled receiver admission (library) +| tool | what it gives your agents | +|---|---| +| [tambo](https://github.com/scbrown/tambo) | 🧰 Agent Platform Toolkit - installs and configures the quipu stack | +| [quipu](https://github.com/scbrown/quipu) | stores shared facts with provenance and history; can check writes against your rules | +| [camayoc](https://github.com/scbrown/camayoc) | prepares knowledge and vocabulary for Quipu, with checks before it is admitted | +| [quechua](https://github.com/scbrown/quechua) | shared vocabulary and shapes for interoperable knowledge | +| [chaski](https://github.com/scbrown/chaski) | reacts to graph changes and delivers governed notifications | +| [bobbin](https://github.com/scbrown/bobbin) | searches your code and can include related knowledge from Quipu | +| [yupana](https://github.com/scbrown/yupana) | shows code impact and can read policies from Quipu to check changes | +| [desire-path](https://github.com/scbrown/desire-path) | turns agent mistakes into feature requests; works on its own without Quipu | +| [seeds](https://github.com/scbrown/seeds) | a work item tracker backed by the Quipu knowledge base | +| [shantytown](https://github.com/scbrown/shantytown) | runs crews of AI agents on work items, triggered by hand, on a schedule or by events; can share work and memory through Quipu | +| [rikuy](https://github.com/scbrown/rikuy) | live brief: status, graphs, watches and signed decisions over Quipu | +| [shuttle](https://github.com/scbrown/shuttle) | runs signed agent workflows and keeps their records in Quipu | -`slot_receiver.SlotLedger` supplies durable, at-most-once admission for a -scheduled command receiver. `slot_id(job, revision, scheduled_at)` identifies a -UTC schedule slot independently of delivery retries. Bind each claim to the -admitted host and immutable manifest. A lost reply replays the stored command -outcome; a crash after claiming leaves `SlotUnknown` and requires reconciliation, -never automatic re-execution. Preserve the ledger across rollback. + -This library does not schedule jobs, authenticate senders, admit executable -manifests, sign transitions, or export runs. Those remain the integrating -receiver's responsibilities. A stored outcome is not signed workflow acceptance. -No runtime configuration enables this library automatically. +[License](LICENSE). diff --git a/docs/installation-reference.md b/docs/installation-reference.md new file mode 100644 index 0000000..6d07a32 --- /dev/null +++ b/docs/installation-reference.md @@ -0,0 +1,264 @@ +# chaski + +A post-commit and time-driven **reactor** for a [quipu](https://github.com/scbrown/quipu) +knowledge graph. + +In the Inca empire, chaski were the relay runners who carried quipus between +stations. This one carries what the graph learned to the people and systems who +need to act on it. + +## What it does + +quipu records facts. chaski watches for facts that call for action, and acts: + +- **Event rules** fire when a write changes something that matters. For example, + "a credential was verified and is now dead". chaski follows quipu's durable + event feed (`GET /events` with a committed consumer offset), so a restart + resumes exactly where it stopped. +- **Schedule rules** fire when time makes something true. For example, "a + credential expires within 14 days". quipu has no timers of its own; chaski is + the scheduler. + +Rules are data. Each rule is a SPARQL `SELECT ?focus` whose result is the set of +nodes the rule is firing for **right now**. A node leaving the set resolves the +alert, so alerts resolve on their own. + +## Design constraints + +- **Public API only.** chaski never reads quipu's storage. A reactor that reads + a store's internals dies the day the store changes. +- **Liveness is measured by output, not process.** A canary rule must fire every + cycle. A chaski that is running but not evaluating alerts on itself. +- **Unknown is not "all clear".** A query that times out is counted as + `unknown`, never as "not firing". +- **Writes are transitions only.** Steady state writes nothing to the graph. +- **Emission reuses Prometheus and Alertmanager.** chaski exports metrics; it + does not invent notification plumbing. + +## Status + +Committed-change stream progress is exported as three scalar metrics: +`chaski_changes_stream_events_received_total` counts received SSE pages, +including refused or replayed deliveries; it does not prove processing. +`chaski_changes_cursor_offset` reports the last committed **transaction ID**, +not the legacy `/events` offset, and is `-1` until a commit is observed. +`chaski_changes_poll_fallback_events_total` counts records admitted through +polling, including startup catch-up for an existing cursor. The initial +tail bootstrap discards records and adds nothing to that counter. +Scrapes read in-memory values without touching SQLite or querying the server. +Counters reset on restart. Compare two scrapes from the same process: received +pages plus an advancing committed cursor and no additional poll records prove +stream progress. One INFO line per process names the first committed stream +transaction; later transactions use counters rather than per-page log traffic. + +Stage 1: a read-only skeleton, with the event tail, schedule rules, metrics and +the canary. No writes and no pages yet. + +Stage 2 (in progress): **emitters** (`emitter.py`). An emitter runs an external +verdict adapter on a schedule and turns its answers into transition events. A +first sighting is a baseline, "unknown" keeps the last verdict, and only a +known blocked-to-unblocked transition emits. Verdict and outbox commit in one +transaction. Delivery is at least once to a receiver that deduplicates on the +adapter's deterministic event id. Configure with `--emitters emitters.yaml`: + +Scheduled adapters normally return a JSON list representing the complete +inventory. A partial observation may instead return +`{"records": [...], "scope": ["item-a", "item-b"]}` (at most 10,000 records and +unique scope keys). The existing partial-scope observation path updates only +those keys; absence outside the scope is UNKNOWN. An empty partial scope never +clears an earlier verdict. Conclusive records can still emit, while the runner +counts the tick as UNKNOWN and does not advance whole-inventory success time. +Malformed envelopes and records outside their scope refuse atomically. Existing +JSON-list adapters retain their full-inventory behavior. + +```yaml +emitters: + - label: workitem-unblocked + command: [python3, /path/to/blocked_by.py] + schedule: PT15M + owner: someone + event: unblocked + sink: {jsonl: /var/lib/chaski/unblocked.jsonl} +``` + +## License + +Apache-2.0. See [LICENSE](https://github.com/scbrown/chaski/blob/main/LICENSE). + +### Incremental verdict adapters + +An emitter can set `trigger: changes` and `schedule: PT24H`. The schedule then +means catalogue reconciliation, not a full verdict scan. The adapter must support +`--describe` (local subscription metadata) and `--changes` (JSON on stdin). Existing +scheduled adapters remain supported. + +The version-1 protocol describes `attributes`, `types` and trusted `graphs`. +A request contains `now` plus one of: + +- `discover: true`: return `items`, the complete catalogue of candidate keys. +- `route: [...]`: return `items` affected by the supplied fact changes. Retracted + links carry `old_value`; adapters must include their former owners. +- `items: [...]`: return `scope`, `records` and `next_checks` (one future Unix + deadline or null per record). Missing keys inside scope become untracked; + keys outside scope retain their verdict. UNKNOWN preserves the last verdict. + +Every response includes `version: 1`. The first discovery starts after a durable +feed-tail checkpoint; subsequent changes cannot fall through the bootstrap gap. +Chaski polls `/changes` with `old_and_new_values` at the existing 60-second floor, +committing the cursor and matching inbox records together. Routing atomically +moves inbox records into a persistent key queue. Two changed subjects and two +ready keys are processed per tick, with changed keys ahead of reconciliation. +Verdicts, future deadlines and transition outbox records commit together. A +failed operation leaves its work for retry, and restart does not repeat discovery. +The existing idempotent receiver and global write budget still govern delivery. + +`chaski_changes_lag_transactions`, `chaski_changes_errors_total` and per-emitter +`changes_pending`, `deadlines`, `next_due_timestamp_seconds` expose feed and queue +health. The existing event lag/error signals also include the change feed. Idle +adapters make no queries until a matching change, deadline or reconciliation. +Catch-up is bounded to ten pages of 100 transactions per poll; sustained overload +is visible as lag rather than silently dropping work. Latency includes queueing +and adapter runtime; a large dependency fan-out can take multiple ticks. + +Adapter state uses additive SQLite tables in the existing emitter database. +Rollback to a scheduled adapter preserves verdicts and delivered-event identities; +keep that database, and restore the previous code and emitter configuration. + +### Authenticated external event sources + +An authenticated webhook or local event source can queue an explicit event into +Chaski's existing emitter outbox with `external_event.py`. Authentication belongs +to that source; this local CLI exposes no network listener. Configure an external +emitter with `baseline_emits: true` so the first published event is delivered: + +```yaml +emitters: + - label: release-pin + trigger: external + schedule: PT24H # metadata only; no periodic verdict adapter is invoked + owner: kit + event: release.published + from: ABSENT + to: PUBLISHED + baseline_emits: true + sink: {jsonl: /var/lib/example/releases.jsonl} +``` + +```sh +printf '%s\n' '{"event_id":"repo-kit-1","type":"release.published","payload":{"tag":"v1.2.3"}}' | + python external_event.py --emitters emitters.yaml --label release-pin --state emitter.db +``` + +Use the same emitter database and configuration as the running Chaski instance. +The existing runner delivers pending events on its next tick without running a +verdict adapter or scanning remote data. Existing graph, command, alert, retry, +write-budget and receiver-deduplication contracts remain in force. A command +receiver must return nonzero while held or on failed proof: exit zero is its +receipt and acknowledges delivery. + +The receipt contains the original source ID only after a synchronous transaction +proves the exact outbox payload. A lost receipt may be retried with identical +bytes; restart and redelivery do not queue a second firing. Changed payload under +an existing source ID refuses. The outbox ID and graph focus use a hash scoped to +the emitter, so arbitrary source identifiers cannot become graph syntax and two +emitters can consume the same source event independently. Graph reaction metadata +records an event trigger for this mode. No webhook or installer is activated by +adding the source code. + +### Kind-specific review signals + +Verdict adapters may include a bounded `work_kind` token. The emitter freezes it +in the outbox with the event identity, so a retry cannot reclassify an event. +The Alertmanager sink accepts an optional `kind_overrides` map in deployment +configuration. Each exact kind may override only `severity` and `alertname`; +other labels and adapter-supplied severity are ignored. Missing and unknown kinds +retain the emitter's default policy. For example, a deployment can route +`DreamCycle` and `DreamLane` as informational signals while directives remain +warnings. Changing this map is a routing-policy change requiring review. + +### Committed transaction stream (opt-in) + +`--changes-stream` moves incremental emitter change delivery to the read-only +`/changes/stream` SSE endpoint. Existing configurations keep the poll path. +Bootstrap retains the existing tail-before-discovery behavior. Transaction IDs +are local durable change cursors; they are not `/events/commit` event offsets. +The reader queues one complete frame and waits for the reactor thread to commit +both routed inbox records and the cursor before accepting another. The reactor drains up to64 pages in a50ms burst +(budget checked between commits), then gives adapters/deadlines their turn. +New delivery wakes the reactor instead of waiting for its five-second idle +sleep; only one frame stays queued. Disconnects, +invalid frames and failed local commits retain the last applied cursor and +reconnect with 1–60 second backoff. A frame is bounded to 1 MiB; heartbeat +comments perform no graph read. The network thread never accesses SQLite. + +Scheduled rules, incremental adapter deadlines and bounded catalogue +reconciliation retain their existing behavior. This flag does not move the +separate ROOT event-rule consumer to SSE. Closing a network reader can wait for +its bounded 45-second socket timeout. Source tests are not permission to enable +this flag, replay production history or claim natural scheduled acceptance. + +### Repeated command failures + +`failure_family_adapter.py` consumes the `dp failure-families` JSON report and a directly typed Quipu `ConfigSetting` containing a single JSON description. Policy fields are `default_threshold`, `window_seconds`, `default_owner`, optional `family_thresholds` (hashed family ID to threshold), and `tool_owners` (tool name to crew owner). Invalid/missing/ambiguous policy or failed aggregation exits nonzero, leaving the emitter UNKNOWN. Ownership comes from this graph policy, with an explicit crew fallback; human recipients are refused. + +Configure the emitter with `from: BELOW`, `to: ABOVE`, `resolve_recovery: true`, `generation_event_ids: true`, and `missing_is_from: true`. Keep `baseline_emits: false`: first sighting above threshold is a baseline, not an observed crossing. A complete successful rolling-window inventory can resolve absent families; partial observations and failed runs cannot. Unsupported program shapes are a separate disclosed measurement population, not silently attributed to a known family. Each actual crossing gets a generation-bound identity, stable across retries and restarts. Recovery refers to the original frozen firing labels and cannot overtake its receipt. The existing Alertmanager sink retains its finite TTL; scheduled delivery and producer-liveness monitoring are still deployment requirements. This adapter is not scheduled or enabled by the repository itself. + +### Exclusive consumer custody + +The `chaski.py` CLI takes non-blocking process-lifetime locks before loading +rules or reading its persistent cursor and emitter inbox. A second consumer +using those paths refuses loudly with exit code 2, including `--once`. Use +separate private state paths for an independent diagnostic invocation. + +The inbox lock follows the database inode, so symbolic-link and hard-link +aliases do not admit a second owner. The legacy cursor also has a stable +`.consumer-lease` sidecar beside its canonical path. Keep the sidecar in place; +unlinking or replacing a live lease or database can split ownership. Kernel +locks release on process exit, including crashes, and acquisition failures +release any earlier locks. These locks serialize cooperating CLI consumers; +they do not exclude arbitrary privileged tools or authorize stream activation. + +Deployments must include `consumer_lease.py` with the matching `chaski.py` pin. + +## Development + +Python 3.11 or newer and [uv](https://docs.astral.sh/uv/) are required. +The distribution is named `chaski-reactor`; its commands are `chaski`, +`chaski-event` and `chaski-once`. + +```sh +uv run --locked python -m pytest +uv run --locked ruff check . +uv build +``` + +An offline canary exercises the actual reaction engine with controlled query +answers. It fires, preserves its firing state on UNKNOWN, then resolves after a +successful empty answer. It does not connect to a graph or deliver notifications. +The receipt is separate from live consumer state: + +```sh +uv run --locked chaski demo status +uv run --locked chaski demo run --focus example-canary +uv run --locked chaski demo status +``` + +CI builds the wheel and installs it into an isolated environment on Linux and +macOS. Its smoke check verifies module bytes against the wheel RECORD, checks +all console entry points, and reads the measured canary receipt back. + +### Scheduled receiver admission (library) + +`slot_receiver.SlotLedger` supplies durable, at-most-once admission for a +scheduled command receiver. `slot_id(job, revision, scheduled_at)` identifies a +UTC schedule slot independently of delivery retries. Bind each claim to the +admitted host and immutable manifest. A lost reply replays the stored command +outcome; a crash after claiming leaves `SlotUnknown` and requires reconciliation, +never automatic re-execution. Preserve the ledger across rollback. + +This library does not schedule jobs, authenticate senders, admit executable +manifests, sign transitions, or export runs. Those remain the integrating +receiver's responsibilities. A stored outcome is not signed workflow acceptance. +No runtime configuration enables this library automatically. + +## The stack diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e5afb44 --- /dev/null +++ b/install.sh @@ -0,0 +1,242 @@ +#!/bin/sh +# Verify the published package before installing; no service or enrollment action. +set -eu +exec python3 - "$@" <<'PYINSTALL' +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import platform +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import time +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + + + +def fetch(url, destination): + for attempt in range(3): + try: + with urlopen(Request(url, headers={'User-Agent': CONFIG['repo'] + '-installer'}), timeout=60) as response: + with destination.open('wb') as output: + shutil.copyfileobj(response, output) + return + except (HTTPError, URLError, TimeoutError) as error: + transient = not isinstance(error, HTTPError) or error.code in (403, 429, 500, 502, 503, 504) + if attempt == 2 or not transient: + raise + time.sleep(attempt + 1) + + +def extract(archive, destination): + with tarfile.open(archive, 'r:gz') as bundle: + seen, links = set(), [] + total = 0 + for member in bundle: + name = PurePosixPath(member.name) + if name.is_absolute() or '..' in name.parts or str(name) in seen: + raise ValueError('unsafe or duplicate archive path') + seen.add(str(name)) + target = destination.joinpath(*name.parts) + target.parent.mkdir(parents=True, exist_ok=True) + if member.isdir(): + target.mkdir(exist_ok=True) + elif member.isfile(): + total += member.size + if total > 1024 * 1024 * 1024: + raise ValueError('release exceeds 1 GiB unpacked limit') + source = bundle.extractfile(member) + if source is None: + raise ValueError('missing archive bytes') + with source, target.open('xb') as output: + shutil.copyfileobj(source, output) + target.chmod(0o755 if member.mode & 0o111 else 0o644) + elif member.issym(): + link = PurePosixPath(member.linkname) + if link.is_absolute() or '..' in link.parts: + raise ValueError('unsafe archive link') + links.append((target, member.linkname)) + else: + raise ValueError('unsupported archive member') + for target, link in links: + target.symlink_to(link) + target.resolve(strict=True).relative_to(destination.resolve()) + + +CONFIG = {'kind': 'wheel', 'package': 'chaski-reactor', 'assets': ['chaski_reactor-{version}-py3-none-any.whl'], 'checksum': 'checksums.txt', 'commands': ['chaski', 'chaski-event', 'chaski-once'], 'repo': 'chaski'} + + +def bundle_identity(root): + result = {} + for path in root.rglob('*'): + relative = str(path.relative_to(root)) + if relative == '.release.json': + continue + if path.is_symlink(): + result[relative] = ('link', os.readlink(path)) + elif path.is_file(): + result[relative] = ('file', path.stat().st_mode & 0o777, + hashlib.sha256(path.read_bytes()).hexdigest()) + elif path.is_dir(): + result[relative] = ('directory',) + else: + raise ValueError('unexpected installed bundle entry') + return result + + +def install(): + if len(sys.argv) > 1 or platform.system() not in ('Linux', 'Darwin'): + raise ValueError('use a Linux or macOS shell; configure STACK_INSTALL_ROOT through the environment') + if CONFIG['kind'] == 'wheel' and sys.version_info < (3, 11): + raise ValueError('Python 3.11 or newer is required') + with tempfile.TemporaryDirectory(prefix=CONFIG['repo'] + '-release-') as temporary: + work = Path(temporary) + tag = os.environ.get('STACK_VERSION') + if not tag: + metadata = work / 'latest.json' + fetch('https://api.github.com/repos/scbrown/' + CONFIG['repo'] + '/releases/latest', metadata) + release = json.loads(metadata.read_text()) + if release.get('draft') or release.get('prerelease'): + raise ValueError('latest release is not stable') + tag = release['tag_name'] + if not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', tag): + raise ValueError('unsupported release tag') + version = tag[1:] + base = os.environ.get('STACK_RELEASE_BASE_URL', 'https://github.com/scbrown/' + CONFIG['repo'] + '/releases/download/' + tag) + manifest = work / 'checksums' + fetch(base + '/' + CONFIG['checksum'], manifest) + names = [name.format(tag=tag, version=version) for name in CONFIG['assets']] + digests = {} + for name in names: + matches = [fields[0].lower() for line in manifest.read_text().splitlines() + if len(fields := line.split()) == 2 and fields[1].lstrip('*') == name] + if len(matches) != 1 or not re.fullmatch('[0-9a-f]{64}', matches[0]): + raise ValueError('missing or ambiguous checksum for ' + name) + fetch(base + '/' + name, work / name) + digest = hashlib.sha256((work / name).read_bytes()).hexdigest() + if digest != matches[0]: + raise ValueError('checksum mismatch: ' + name) + digests[name] = digest + prefix = Path(os.environ.get('STACK_INSTALL_ROOT', str(Path.home() / '.local'))).expanduser().absolute() + releases = prefix / 'share' / CONFIG['repo'] + releases.mkdir(parents=True, exist_ok=True) + lock = releases / '.install-lock' + lock.mkdir() + destination = releases / tag + identity = {'tag': tag, 'assets': digests} + created = False + try: + if destination.is_symlink(): + raise ValueError('release destination is a symlink') + if destination.exists(): + if json.loads((destination / '.release.json').read_text()) != identity: + raise ValueError('existing release identity differs; previous install preserved') + if CONFIG['kind'] == 'source': + check = work / 'source-check' + check.mkdir() + extract(work / names[0], check) + if bundle_identity(destination) != bundle_identity(check / ('camayoc-' + tag)): + raise ValueError('installed source bytes differ') + elif CONFIG['kind'] == 'data': + for name in names: + if hashlib.sha256((destination / name).read_bytes()).hexdigest() != digests[name]: + raise ValueError('installed data bytes differ') + else: + if CONFIG['kind'] == 'wheel': + # The venv is created at its final location so console-script + # shebangs remain valid. No command is published before smoke. + destination.mkdir() + created = True + subprocess.run([sys.executable, '-m', 'venv', str(destination)], check=True, timeout=120) + python = destination / 'bin/python' + subprocess.run([str(python), '-m', 'pip', '--isolated', '--disable-pip-version-check', + 'install', '--no-input', str(work / names[0])], check=True, timeout=300) + actual = subprocess.check_output([str(python), '-c', + 'from importlib.metadata import version; print(version(' + repr(CONFIG['package']) + '))'], text=True, timeout=20).strip() + if actual != version: + raise ValueError('installed package version mismatch') + for command in CONFIG['commands']: + if not os.access(destination / 'bin' / command, os.X_OK): + raise ValueError('missing console entrypoint: ' + command) + elif CONFIG['kind'] == 'source': + unpacked = work / 'source' + unpacked.mkdir() + extract(work / names[0], unpacked) + source = unpacked / ('camayoc-' + tag) + actual = json.loads((source / '.claude-plugin/plugin.json').read_text())['version'] + if actual != version: + raise ValueError('source package version mismatch') + shutil.copytree(source, destination, symlinks=True) + created = True + else: + destination.mkdir() + created = True + for name in names: + data = (work / name).read_bytes() + if not data.strip(): + raise ValueError('empty vocabulary asset') + shutil.copyfile(work / name, destination / name) + (destination / '.release.json').write_text(json.dumps(identity, sort_keys=True) + '\n') + if CONFIG['kind'] == 'wheel': + # Pip's RECORD binds installed package/entrypoint bytes. Validate + # it on both first install and reuse; a stale marker is not proof. + code = """from importlib.metadata import distribution +from pathlib import Path +import base64, hashlib, sys +root = Path(sys.prefix).resolve() +dist = distribution(sys.argv[1]) +assert dist.version == sys.argv[2], 'installed package version mismatch' +for entry in dist.files or []: + if entry.hash is None: + continue + path = Path(dist.locate_file(entry)).resolve() + path.relative_to(root) + assert path.is_file(), 'installed package entry is not a regular file' + actual = base64.urlsafe_b64encode(hashlib.new(entry.hash.mode, path.read_bytes()).digest()).decode().rstrip('=') + assert actual == entry.hash.value, 'installed package file hash mismatch' +""" + subprocess.run([str(destination / 'bin/python'), '-c', code, + CONFIG['package'], version], check=True, timeout=30) + if CONFIG['kind'] == 'wheel': + binaries = prefix / 'bin' + binaries.mkdir(parents=True, exist_ok=True) + for command in CONFIG['commands']: + if (binaries / command).is_dir(): + raise ValueError('command destination is a directory') + for command in CONFIG['commands']: + fd, staged = tempfile.mkstemp(prefix='.' + command + '-', dir=binaries) + os.close(fd) + os.unlink(staged) + try: + os.symlink(destination / 'bin' / command, staged) + os.replace(staged, binaries / command) + finally: + if os.path.lexists(staged): + os.unlink(staged) + print('Installed verified ' + CONFIG['repo'] + ' ' + version + ' at ' + str(destination)) + if CONFIG['kind'] == 'source': + print('Source package only. Bootstrap and agent enrollment are separate explicit actions.') + elif CONFIG['kind'] == 'data': + print('Vocabulary and shapes only. Nothing was loaded into a running graph.') + else: + print('Add ' + str(prefix / 'bin') + ' to PATH. Previous release directories are retained.') + except BaseException: + if created: + shutil.rmtree(destination) + raise + finally: + lock.rmdir() + + +try: + install() +except (OSError, ValueError, KeyError, subprocess.SubprocessError, tarfile.TarError) as error: + print('Install refused: ' + str(error), file=sys.stderr) + sys.exit(1) +PYINSTALL diff --git a/scripts/test-install-release.py b/scripts/test-install-release.py new file mode 100644 index 0000000..26f9d9c --- /dev/null +++ b/scripts/test-install-release.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Offline package fixtures exercise the real standalone installer.""" + +from contextlib import redirect_stderr, redirect_stdout +import hashlib +import io +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest +from urllib.request import urlopen as real_urlopen +from unittest.mock import patch +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +CONFIG = { + "kind": "wheel", + "package": "chaski-reactor", + "assets": ["chaski_reactor-{version}-py3-none-any.whl"], + "checksum": "checksums.txt", + "commands": ["chaski", "chaski-event", "chaski-once"], + "repo": "chaski", +} +TAG = "v1.2.3" +VERSION = "1.2.3" + + +class PackageInstall(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="package-install-test-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.release = self.root / "release" + self.release.mkdir() + self.prefix = self.root / "prefix" + (self.prefix / "bin").mkdir(parents=True) + self.previous = self.prefix / "bin" / CONFIG.get("commands", ["prior"])[0] + self.previous.write_text("previous bytes") + self.env = dict( + os.environ, + HOME=str(self.root / "home"), + STACK_VERSION=TAG, + STACK_INSTALL_ROOT=str(self.prefix), + STACK_RELEASE_BASE_URL=self.release.as_uri(), + ) + self.names = [name.format(tag=TAG, version=VERSION) for name in CONFIG["assets"]] + self.fixture() + self.latest = {"tag_name": TAG, "draft": False, "prerelease": False} + + def fixture(self): + for name in self.names: + path = self.release / name + if CONFIG["kind"] == "wheel": + distribution = CONFIG["package"].replace("-", "_") + metadata = distribution + "-" + VERSION + ".dist-info/" + with zipfile.ZipFile(path, "w") as wheel: + wheel.writestr("fixture_cli.py", 'def main():\n print("fixture ' + VERSION + '")\n') + wheel.writestr( + metadata + "METADATA", + "Metadata-Version: 2.1\nName: " + CONFIG["package"] + "\nVersion: " + VERSION + "\n", + ) + wheel.writestr( + metadata + "WHEEL", + "Wheel-Version: 1.0\nGenerator: fixture\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + ) + wheel.writestr( + metadata + "entry_points.txt", + "[console_scripts]\n" + + "\n".join(command + " = fixture_cli:main" for command in CONFIG["commands"]), + ) + wheel.writestr(metadata + "RECORD", "") + elif CONFIG["kind"] == "source": + data = json.dumps({"name": "camayoc", "version": VERSION}).encode() + with tarfile.open(path, "w:gz") as bundle: + member = tarfile.TarInfo("camayoc-" + TAG + "/.claude-plugin/plugin.json") + member.size, member.mode = len(data), 0o644 + bundle.addfile(member, io.BytesIO(data)) + else: + path.write_text("@prefix example: .\nexample:Thing a example:Class .\n") + manifest = "".join( + hashlib.sha256((self.release / name).read_bytes()).hexdigest() + " " + name + "\n" for name in self.names + ) + (self.release / CONFIG["checksum"]).write_text(manifest) + + def open_release(self, request, **kwargs): + if request.full_url.startswith("https://api.github.com/repos/scbrown/"): + return io.BytesIO(json.dumps(self.latest).encode()) + return real_urlopen(request, **kwargs) + + def run_installer(self, system="Linux"): + script = (ROOT / "install.sh").read_text().split("<<'PYINSTALL'\n", 1)[1].rsplit("PYINSTALL", 1)[0] + stdout, stderr = io.StringIO(), io.StringIO() + status = 0 + with ( + patch.dict(os.environ, self.env, clear=True), + patch("sys.argv", ["install.sh"]), + patch("urllib.request.urlopen", side_effect=self.open_release), + patch("platform.system", return_value=system), + redirect_stdout(stdout), + redirect_stderr(stderr), + ): + try: + exec(compile(script, str(ROOT / "install.sh"), "exec"), {"__name__": "__main__"}) + except SystemExit as error: + status = error.code + return status, stdout.getvalue(), stderr.getvalue() + + def assert_preserved(self, result): + self.assertNotEqual(result[0], 0, result) + self.assertEqual(self.previous.read_text(), "previous bytes") + self.assertFalse(self.previous.is_symlink()) + self.assertNotIn("Installed verified", result[1]) + + def test_actual_installer_installs_verified_package(self): + result = self.run_installer() + self.assertEqual(result[0], 0, result) + destination = self.prefix / "share" / CONFIG["repo"] / TAG + self.assertEqual(json.loads((destination / ".release.json").read_text())["tag"], TAG) + if CONFIG["kind"] == "wheel": + for command in CONFIG["commands"]: + self.assertIn(VERSION, subprocess.check_output([str(self.prefix / "bin" / command)], text=True)) + else: + self.assertEqual(self.previous.read_text(), "previous bytes") + + def test_latest_release_selection_installs_the_resolved_tag(self): + self.env.pop("STACK_VERSION") + result = self.run_installer() + self.assertEqual(result[0], 0, result) + self.assertIn(TAG, result[1]) + + def test_prerelease_cannot_become_latest_stable_install(self): + self.env.pop("STACK_VERSION") + self.latest["prerelease"] = True + self.assert_preserved(self.run_installer()) + + def test_reinstall_of_intact_release_succeeds(self): + self.assertEqual(self.run_installer()[0], 0) + result = self.run_installer() + self.assertEqual(result[0], 0, result) + + def test_tampered_existing_install_cannot_claim_verified_success(self): + self.assertEqual(self.run_installer()[0], 0) + destination = self.prefix / "share" / CONFIG["repo"] / TAG + if CONFIG["kind"] == "wheel": + target = self.previous.resolve() + elif CONFIG["kind"] == "source": + target = destination / ".claude-plugin/plugin.json" + else: + target = destination / self.names[0] + target.write_text("tampered installed bytes") + result = self.run_installer() + self.assertNotEqual(result[0], 0, result) + self.assertNotIn("Installed verified", result[1]) + self.assertEqual(target.read_text(), "tampered installed bytes") + + def test_corrupt_checksum_preserves_previous(self): + path = self.release / self.names[0] + path.write_bytes(path.read_bytes() + b"corrupt") + self.assert_preserved(self.run_installer()) + + def test_missing_asset_preserves_previous(self): + (self.release / self.names[0]).unlink() + self.assert_preserved(self.run_installer()) + + def test_unsupported_platform_preserves_previous(self): + self.assert_preserved(self.run_installer(system="UnsupportedOS")) + + def test_missing_checksum_entry_preserves_previous(self): + (self.release / CONFIG["checksum"]).write_text("") + self.assert_preserved(self.run_installer()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 23db92328e99c5d81919f9396b528b227fabb703 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Wed, 21 Oct 2026 00:45:29 -0400 Subject: [PATCH 2/2] fix: verify wheel payload and entrypoints on reinstall --- install.sh | 48 +++++++++++++++++++++++---------- scripts/test-install-release.py | 42 +++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/install.sh b/install.sh index e5afb44..1e44f0c 100755 --- a/install.sh +++ b/install.sh @@ -1,7 +1,7 @@ #!/bin/sh # Verify the published package before installing; no service or enrollment action. set -eu -exec python3 - "$@" <<'PYINSTALL' +exec python3 -I - "$@" <<'PYINSTALL' import hashlib import json import os @@ -153,17 +153,14 @@ def install(): # shebangs remain valid. No command is published before smoke. destination.mkdir() created = True - subprocess.run([sys.executable, '-m', 'venv', str(destination)], check=True, timeout=120) + subprocess.run([sys.executable, '-I', '-m', 'venv', str(destination)], check=True, timeout=120) python = destination / 'bin/python' - subprocess.run([str(python), '-m', 'pip', '--isolated', '--disable-pip-version-check', + subprocess.run([str(python), '-I', '-m', 'pip', '--isolated', '--disable-pip-version-check', 'install', '--no-input', str(work / names[0])], check=True, timeout=300) - actual = subprocess.check_output([str(python), '-c', + actual = subprocess.check_output([str(python), '-I', '-c', 'from importlib.metadata import version; print(version(' + repr(CONFIG['package']) + '))'], text=True, timeout=20).strip() if actual != version: raise ValueError('installed package version mismatch') - for command in CONFIG['commands']: - if not os.access(destination / 'bin' / command, os.X_OK): - raise ValueError('missing console entrypoint: ' + command) elif CONFIG['kind'] == 'source': unpacked = work / 'source' unpacked.mkdir() @@ -187,22 +184,45 @@ def install(): # Pip's RECORD binds installed package/entrypoint bytes. Validate # it on both first install and reuse; a stale marker is not proof. code = """from importlib.metadata import distribution -from pathlib import Path -import base64, hashlib, sys +from pathlib import Path, PurePosixPath +import base64, hashlib, json, os, sys, zipfile root = Path(sys.prefix).resolve() dist = distribution(sys.argv[1]) assert dist.version == sys.argv[2], 'installed package version mismatch' +# RECORD may legitimately omit hashes. Bind every wheel payload file directly +# to the freshly checksum-verified release instead of trusting mutable RECORD. +with zipfile.ZipFile(sys.argv[3]) as wheel: + seen = set() + payload_count = 0 + for member in wheel.infolist(): + name = PurePosixPath(member.filename) + assert not name.is_absolute() and '..' not in name.parts, 'unsafe wheel path' + assert member.filename not in seen, 'duplicate wheel path' + seen.add(member.filename) + assert not any(part.endswith('.data') for part in name.parts), 'unsupported wheel data layout' + if member.is_dir() or member.filename.endswith('.dist-info/RECORD'): + continue + path = Path(dist.locate_file(member.filename)) + assert not path.is_symlink() and path.is_file(), 'missing or linked wheel payload' + path.resolve().relative_to(root) + assert path.read_bytes() == wheel.read(member), 'installed wheel payload differs' + payload_count += 1 + assert payload_count > 0, 'wheel has no payload' for entry in dist.files or []: if entry.hash is None: continue - path = Path(dist.locate_file(entry)).resolve() - path.relative_to(root) - assert path.is_file(), 'installed package entry is not a regular file' + path = Path(dist.locate_file(entry)) + assert not path.is_symlink() and path.is_file(), 'installed package entry is not a regular file' + path.resolve().relative_to(root) actual = base64.urlsafe_b64encode(hashlib.new(entry.hash.mode, path.read_bytes()).digest()).decode().rstrip('=') assert actual == entry.hash.value, 'installed package file hash mismatch' +for command in json.loads(sys.argv[4]): + path = root / 'bin' / command + assert not path.is_symlink() and path.is_file() and os.access(path, os.X_OK), 'missing or nonexecutable console entrypoint' """ - subprocess.run([str(destination / 'bin/python'), '-c', code, - CONFIG['package'], version], check=True, timeout=30) + subprocess.run([str(destination / 'bin/python'), '-I', '-c', code, + CONFIG['package'], version, str(work / names[0]), + json.dumps(CONFIG['commands'])], check=True, timeout=30) if CONFIG['kind'] == 'wheel': binaries = prefix / 'bin' binaries.mkdir(parents=True, exist_ok=True) diff --git a/scripts/test-install-release.py b/scripts/test-install-release.py index 26f9d9c..f310ebc 100644 --- a/scripts/test-install-release.py +++ b/scripts/test-install-release.py @@ -156,6 +156,48 @@ def test_tampered_existing_install_cannot_claim_verified_success(self): self.assertNotIn("Installed verified", result[1]) self.assertEqual(target.read_text(), "tampered installed bytes") + def test_caller_metadata_cannot_shadow_installed_distribution(self): + shadow = self.root / "shadow" + metadata = shadow / (CONFIG["package"].replace("-", "_") + "-0.0.0.dist-info") + metadata.mkdir(parents=True) + (metadata / "METADATA").write_text("Metadata-Version: 2.1\nName: " + CONFIG["package"] + "\nVersion: 0.0.0\n") + previous = Path.cwd() + try: + os.chdir(shadow) + result = self.run_installer() + finally: + os.chdir(previous) + self.assertEqual(result[0], 0, result) + + def test_missing_console_entrypoint_refuses_reuse(self): + self.assertEqual(self.run_installer()[0], 0) + destination = self.prefix / "share" / CONFIG["repo"] / TAG + command = destination / "bin" / CONFIG["commands"][0] + command.unlink() + result = self.run_installer() + self.assertNotEqual(result[0], 0, result) + self.assertNotIn("Installed verified", result[1]) + self.assertFalse(command.exists()) + + def test_unhashed_wheel_payload_tamper_refuses_reuse(self): + self.assertEqual(self.run_installer()[0], 0) + destination = self.prefix / "share" / CONFIG["repo"] / TAG + target = next(destination.rglob("fixture_cli.py")) + target.write_text("def main(): print('tampered')\n") + result = self.run_installer() + self.assertNotEqual(result[0], 0, result) + self.assertNotIn("Installed verified", result[1]) + self.assertEqual(target.read_text(), "def main(): print('tampered')\n") + + def test_nonexecutable_console_entrypoint_refuses_reuse(self): + self.assertEqual(self.run_installer()[0], 0) + destination = self.prefix / "share" / CONFIG["repo"] / TAG + command = destination / "bin" / CONFIG["commands"][0] + command.chmod(0o644) + result = self.run_installer() + self.assertNotEqual(result[0], 0, result) + self.assertNotIn("Installed verified", result[1]) + def test_corrupt_checksum_preserves_previous(self): path = self.release / self.names[0] path.write_bytes(path.read_bytes() + b"corrupt")