diff --git a/docs/st3/instances.md b/docs/st3/instances.md index e1ecd3fea86..1ba79808bef 100644 --- a/docs/st3/instances.md +++ b/docs/st3/instances.md @@ -173,3 +173,40 @@ the work root and prints it once. Change it with `work_root = "/absolute/path"` must be absolute and cannot be the home folder or inside Documents, Desktop or Downloads. Existing agents keep their folders, and an agent or mission can still name any folder. `fractal uninstall` never removes what is in the work root: it prints `kept` with the path (and `--dry-run` prints `keep`). + +## Fixture seed tooling + +`scripts/client-control-fixture/seed.py` prepares the common native ask fixture. +Its `seed` subcommand is effectful and requires separately approved setup scope; +shipping or testing the script does not authorize running it. It never sets up an +instance or creates the fixture person or producer. The existing fixture actor +environment must authorize `agent/client-control-fixture/producer`; the tool does +not bypass the CLI's actor checks. + +Both subcommands require `--socket` to name exactly the reserved instance's own +privileged operator socket, `/run/fractal.sock`, independently +derived from `HOME` with the completed product-directory migration rule. They +refuse symlinks, other-instance sockets and the paired client socket, and require +an existing socket. No default endpoint discovery or fallback is used. + +`seed --idempotency-key KEY` requires an empty fixture attention inbox and invokes +the shipped `fractal work ask` once, for `person/client-control-fixture`, with a +structured feedback request. The fixed question is `client-control fixture 0001`; +the default title is `Client control fixture check`. Title and question must be +printable ASCII, 1–60 characters, one paragraph, without Markdown syntax, JSON, +or leading, trailing or repeated whitespace. + +Save the JSON seed receipt. Before each journey, run `check --ask ASK --baseline +SEED_RECEIPT` and save its output; afterwards run `check --ask ASK --baseline +BEFORE_RECEIPT`. The `no-read-mutation` check requires exactly one open +`person-step` attention item with request presentation, matching title/question +and a unique source `work.person-asked` claim. It scans all paginated durable +claims, refuses ask read/acknowledge/answer/close/termination claims and durable +read-position claims, and compares the evidence and claim IDs before/after. +The receipt includes the body SHA-256 and source claim IDs. + +Native asks expose no stored unread bit or mailbox read-position readback. +This check covers the current daemon attention projection and durable claims, +not browser-local storage, and explicitly reports that unread is unsupported +rather than inventing a value. Unit tests use only temporary paths and socket +test fixtures: `python3 -m unittest discover -s scripts/client-control-fixture`. diff --git a/release-notes/client-control-fixture-seed.json b/release-notes/client-control-fixture-seed.json new file mode 100644 index 00000000000..1e8239aa5bf --- /dev/null +++ b/release-notes/client-control-fixture-seed.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Add a common fixture setup and native ask packet with retained creation, no-read-mutation and client-local evidence.", + "replay": { "status": "none", "detail": "The tooling does not replay or initialize a store." }, + "schema": { "status": "none", "detail": "Use existing native work ask and attention/claim reads without new claims." }, + "rules": { "status": "none", "detail": "The existing CLI enforces actor authority; seed text and exact fixture operator socket are additionally validated." }, + "compatibility": { "status": "none", "detail": "No normal instance, client or protocol behavior changes." }, + "downtime": { "status": "none", "detail": "Shipping source does not start or restart a service." }, + "manual": { "status": "changed", "detail": "Setup, Fabric lifecycle, enrollment and grants require separate authorization. An immutable manifest and separate run record bind every journey to the same ask and enrolled device. E1 creation provenance, E2 no-read-mutation and E3 three-class local diffs establish initially unseen and unmutated evidence; native asks have no stored unread bit. Fixture service uninstall retains evidence; deleting state is not authorized." }, + "recovery": { "status": "none", "detail": "The check is read-only and refused socket/text inputs have no effects." } +} diff --git a/scripts/client-control-fixture/README.md b/scripts/client-control-fixture/README.md new file mode 100644 index 00000000000..b61a19b236b --- /dev/null +++ b/scripts/client-control-fixture/README.md @@ -0,0 +1,488 @@ +# Common client-control fixture packet + +This is source preparation for **one shared fixture and seed across iOS, web and +TUI**, not permission to set anything up, expose, sign, pair, enroll, grant, open, +or tear down. No runtime evidence is shipped here. Source publication establishes +none of the acceptance evidence below. + +## Inputs, identity and authorization + +`expected-input.json` is immutable expected INPUT, never an actual run record. +Its generated ID fields stay null permanently. `packet.py digests` emits the exact +INPUT SHA-256 and executable packet SHA-256. The latter is SHA-256 over canonical +ASCII JSON mapping `expected-input.json`, `packet.py`, `seed.py`, `acceptance.py`, +`observer.py`, `policy.py`, and `storage-inventory.json` to their byte SHA-256s. A changed executable or INPUT requires +new consent. Tests/docs are not execution authority. Retain the reviewed +executable bytes and digest receipt. The immutable INPUT byte SHA-256 is +`d50f3c8d9a7e460d1c84f68dc4373af43e0029f7c80102fe304676393405f017`. + +Every effect needs **standalone authenticated human consent bound to both exact +digests**, the intended scope and host, and the actual subject catalog when it +exists. Consent is not implied by this document, a source PR, a prior ask, saved +issuance state, or a successful binding check. This packet deliberately does not +invent a consent CLI or implement a signing substitute. Existing authenticated +human authorization and its durable receipt are prerequisites. + +The producer is `agent/client-control-fixture/producer`; the person is +`person/client-control-fixture`. The exact suggested title is +`Client control fixture check`; body is `client-control fixture 0001`, body +SHA-256 `c3e53495d6bafca1a5ac1023ec1943ca3eec7c78e0517ddd86f3389827e6636f`. +Text must be one printable ASCII paragraph of 1–60 characters, no Markdown +`*_#[]\`>|`, JSON, or leading/trailing/repeated whitespace. + +The root follows the shipped instance migration receipt: use +`$HOME/.fractal-instance/client-control-fixture` only when +`.fractal-migration-completed` contains exactly +`arn:lmig:smalltalk:2026-10-16-state-dirs-fractal\n` as bytes; otherwise use +`$HOME/.st-instance/client-control-fixture`. Derive this independently from the +operator's absolute HOME. No alternate root, socket fallback or symlink alias. +The privileged operator socket is exactly `ROOT/run/fractal.sock`; the client +socket is not an operator socket. `packet.py prepare` prints the derived paths +and human-only commands without executing them. + +## Required source admission + +Do not execute this packet until the reviewed source is landed and generated +contracts are admitted. The local-only fixture slice is #6281; grants and +item-binding resources are source dependencies #6289/#6296, canonical generation +#6274. A dedicated Fabric execution-context correction is also required: ambient +`Fabric::new()` is **not** acceptable fixture isolation. This packet does not +claim the prior source already enforces that correction. Do not hand-edit +code-generated clients, schemas or transport contracts to bypass admission. + +Grant creation/revocation and test enrollment are not shipped grant CLI commands. +The reviewed daemon source defines privileged-Unix APIs: + +- `POST /v1/client/control-grants`: `run_id`, `step_run`, `device`, + `actor_agent`, `verbs`, `screens`, optional `item_id`, `max_commands`, + `expires_at` (Unix milliseconds). For this packet require exact verbs + `phone.inspect` and `phone.navigate`, screens `NowRoot` and `Attention`, + actor above, and `item_id` equal to the actual **ask source subject**. +- Actual issuance resource: `grant_id`, `run_id`, `device`, `session`, + `actor_agent`, `verbs`, `screens`, `item_id`, `max_commands`, + `expires_at` (RFC3339), `state` plus normal resource metadata. +- `POST /v1/client/control-grants/revoke` with `{grant_id}`. +- `POST /v1/client/test-clients/revoke` with `{device}`. + +There is **no dedicated grant GET/list HTTP route or CLI**. Do not invent one. +An already authenticated paired client can read the existing WebSocket +`GET /v1/client/collections/stream`, send +`{"kind":"subscribe","id":"commands","collection":"commands"}`, and retain +its `snapshot`/`changes` lifecycle resources (`kind: client-control-grant`). The +daemon, not this packet, determines live active state from trusted provenance, +current step claim binding, enrollment, pairing, budget and expiry. Generic custom +claims alone are not grant authority. Packet binding checks are explicitly +`verifies_binding_only`, not live authorization or consent. + +## Human-only preparation and setup order + +The commands here are **prepared, not executed**. Run preparation from a clean +operator shell, not an inherited `ST_INSTANCE`/`FRACTAL_INSTANCE` environment. +Keep evidence outside source files; never overwrite INPUT with runtime IDs. +Before effects retain the normal service readback: + +```sh +fractal --json service status +python3 scripts/client-control-fixture/packet.py digests +python3 scripts/client-control-fixture/packet.py prepare +``` + +First obtain the required standalone setup/seed consent. Record proof that this +fixture root is fresh and no client has opened the new ask. Do not reuse an old +fixture and call it fresh. + +### Dedicated Fabric node, separately scoped + +The only permitted Fabric home is `ROOT/fabric`, control socket +`ROOT/fabric/run/control.sock`. Every invocation must pin both `FABRIC_HOME` and +global `--home` to that exact home; no independent control selector exists in +pinned Fabric `19e7f896`. `packet.py prepare` renders fully derived commands: + +```text +env FABRIC_HOME=ROOT/fabric fabric --home ROOT/fabric up --foreground +env FABRIC_HOME=ROOT/fabric fabric --home ROOT/fabric status --json +env FABRIC_HOME=ROOT/fabric fabric --home ROOT/fabric down +``` + +These are notation with `ROOT` replaced by the independently derived absolute +root, not shell commands to copy unchanged. Start **and stop** need the separately +reviewed authenticated Fabric-mesh scope. Never use production's Fabric daemon, +HOME, NodeID or control socket. `status --json` supplies the actual `node_id` +after start; do **not** use `fabric id` as a purported read-only query (it can +create identity). The dedicated Fabric NodeID and Fractal daemon node identity +are recorded separately and must not be substituted for each other. + +Retain a `fabric-evidence.json` object with `home`, `control_socket`, actual +`status` response, and nonempty `scope_receipt` referencing that authenticated +scope. This is an evidence wrapper, not a fabricated native API response. + +### Fixture setup, health, then seed + +The exact shipped setup invocation is: + +```sh +fractal --instance client-control-fixture setup --person client-control-fixture +``` + +Choose service-managed setup. Record installed/running exact daemon unit +`st3-instance-client-control-fixture.service` and fixture replication unit status; +normal units are `st3.service` and `st3-replication.service`. A daemon started +without a service does not satisfy this packet's teardown precondition. + +Save the outputs of these read-only commands externally; `SOCKET` below denotes +the exact independently derived `ROOT/run/fractal.sock` printed by preparation: + +```sh +python3 scripts/client-control-fixture/packet.py services +python3 scripts/client-control-fixture/packet.py --socket "$SOCKET" health +``` + +The `services` output is `service-evidence.json` (`fixture` and `normal`, each the +actual shipped JSON service status report). Compare normal-unit status against +the pre-setup readback. Health is actual `GET /v1/health` over that socket and must +report ready, network policy `local-only`, and false fleet join, replication and +peer relay. Retain path, local-only and unit evidence **before** seeding. Refuse +normal or client sockets, aliases, escaped roots and network-enabled health. + +Only after those gates and seed authorization, the prepared seed invocation is: + +```sh +python3 scripts/client-control-fixture/seed.py --socket "$SOCKET" seed \ + --idempotency-key client-control-fixture-0001 +``` + +Save its JSON as `seed-receipt.json`, record actual creation time and own seed +claim IDs, and retain the complete initial durable claim feed. Native asks have +**no unread bit**: output is always +`unread_bit=not-supported-for-native-asks`, never an unread PASS. + +## Separate actual run record before journeys + +After independently authorized pairing/enrollment/grant effects, retain each +client's complete actual issuance resource captured from the fixture operator +API. `grants.json` is an evidence object with exactly `ios`, `web`, `tui` keys, +each containing that client's **real** resource. Never invent generated IDs. +Clients require distinct device/session/grant identities for the same actual +run. Retain original issuance/enrollment request/response and authenticated +consent provenance alongside the record; the JSON wrapper is not provenance by +itself. + +```sh +python3 scripts/client-control-fixture/packet.py --socket "$SOCKET" capture \ + --seed-receipt seed-receipt.json --grants grants.json \ + --service-evidence service-evidence.json --fabric-evidence fabric-evidence.json \ + --observer-policy observer-policy.json +``` + +Save output once as `actual-run.json` **before any journey**. Its separate full +catalog records actual daemon and Fabric node IDs, person, actor, run, ask source, +attention projection, navigation item, all durable claim IDs/subjects, +grant/device/session IDs, and `catalog_sha256` over canonical JSON. Full raw +claims, attention, grant resources and service/Fabric evidence are retained. +The expected manifest remains unchanged. The navigation `item_id` is the native +ask source (`seed-receipt.ask_id`), **not** its `attention_id` projection key. + +## Independent before/after for every client + +Before opening each client, run its independent check, retain output, and confirm +its actual grant binds **both** `NowRoot` and `Attention` and the exact recorded +ask ID. The same check runs immediately after that journey; neither another +client's check nor a combined final check substitutes. For each of `ios`, `web`, +and `tui`, substituting the actual client name: + +```sh +python3 scripts/client-control-fixture/packet.py --socket "$SOCKET" check \ + --record actual-run.json --client ios --phase before +# Independently consented iOS journey, then: +python3 scripts/client-control-fixture/packet.py --socket "$SOCKET" check \ + --record actual-run.json --client ios --phase after +``` + +Each check reads current complete attention plus all durable claim pages, +requires one open native ask with exact title/body/person/producer, rejects +answer/read/acknowledge/close/cancel/done and read-position mutations, compares +seed receipts and own ask claim IDs to the frozen baseline, and verifies saved +grant item/screens/run/verbs/expiry binding. A saved resource is not proof of +live authority; the daemon enforces that on every actual command. Do not +request extra verbs or screens to work around refusal. + +### Acceptance: initially unseen and unmutated + +Three independent evidence sets are REQUIRED. Missing evidence keeps the client +row **OPEN**, and source preparation alone accepts none of them: + +- **E1 creation provenance:** fresh fixture creation evidence; exactly one ask + with actual recorded creation time; before the first journey, complete durable + person-relevant claims contain only the seed's own claim IDs. Unsupported unread + is explicit. Mere open attention is not proof that a person has never seen it. +- **E2 daemon state:** each client's separately retained before and after checks, + equal claim/receipt ID sets and no daemon mutations as above. +- **E3 client-local state:** each profile is created new **for the fixture before + pairing/enrollment**, never reset between journeys. Web starts as a new browser + context with empty storageState; iOS uses an isolated test-app data container; + TUI uses a dedicated fixture state directory. Baseline is captured immediately + **after enrollment**; every journey reuses that enrolled key, pairing and session, + without re-pairing. Use only declared isolated profile paths under + `ROOT/client-profiles/CLIENT`, never normal browser/app/TUI profiles. Retain fresh + creation and enrollment provenance separately from snapshots. The adjacent + `observer.py` captures that baseline and each after-journey state plus a diff. + Classify source-inventoried keys as identity, expected churn, or everything + else, using explicit concrete per-client selectors. Identity is preserved by + the primary daemon proof below; named expected churn is still diffed and scanned + for markers. Unexplained, identity, read/seen/acted/closed or opaque/unscanned + changes are FINDING, not normal churn. No blanket cache exclusions. + Web snapshots are actual Playwright storageState exports, iOS snapshots actual + copied test-app container files, TUI snapshots the dedicated state directory. + An offline copy does not itself prove a fresh original container. Do not reset + the profile, erase local markers, change identity, or re-pair to obtain a clean + comparison. Acceptance says **E2 claim and receipt comparison unchanged, and E3 + diff recorded**. + +A persisted read or seen marker is a **FINDING**, reported to Fractal Product +Lead, never a pass and never grounds for changing normal read policy. If source +proves the client has no local read state, retain exact `file:line` evidence as +an explicit declared limitation rather than manufacturing a clean observation. + +### Named inventory and concrete observation policy + +`storage-inventory.json` retains source-owner names and limitations, not a +fabricated runtime policy. Web's final source inventory supersedes its earlier +window-key guess: `wf.window-id` and `wf.clear@1` are **not** expected churn. +The named migration keys are conditional first-load migration writes, not blanket +allowances for user-action writes. Expand source descriptors such as gateway, +deployment or iOS member suffixes only to **actually observed** keys. Keep the +fixture layout free of restored Conversation panes: conversation read-position +keys are not allowed churn. Server-side glass writes are not covered by a browser +storage diff; any such effect needs separate scope and daemon evidence. + +`policy.py` converts reviewed actual selector bindings to the observer policy: + +```sh +python3 scripts/client-control-fixture/policy.py --bindings policy-bindings.json +``` + +Save its stdout as `observer-policy.json` before `capture`. The bindings wrapper +contains `inventory_sha256` (exact source inventory byte digest) and +`clients.CLIENT` with actual `profile`, `bindings`, `raw_omit`, `unobserved`, +and `substitutions`. For each source placeholder (`gw`, `deployment`, `node`, +`service-hex`), substitutions names the actual `value`, fixture/enrollment +`record_id`, source `field`, and `capture_provenance`. Read these values from the +fixture run and its enrollment records, not the normal client. Every descriptor +is resolved before baseline capture, including descriptors for unavailable keys. +Each binding and limitation names the **resolved** source name, never `` or +another template. The compiled policy freezes resolved names and provenance; +the run record freezes that full policy under `observer_policy_sha256`. +Snapshots refuse any remaining `<…>` anywhere in their policy. +Each binding contains a resolved source-inventoried `name`, `class` (`identity` or +`expected_churn`), an exact observed `selector`, and nonempty +`capture_provenance`. Selectors are relative captured filenames plus `#` and a +JSON pointer, or an explicitly named churn file. JSON pointer escaping is `~0` +for `~` and `~1` for `/`. No globs, guessed hashed filenames, broad cache +exclusions, nonexistent selectors, or whole-file identity exclusions. Operators +must review the mapping against the actual capture and source inventory; the +compiler validates bindings, it does not discover or attest their provenance. + +Every inventoried identity name must be bound, or appear under `unobserved` with +`name`, `reason`, `capture_provenance`, and `source_evidence`. This includes absent +pending cookies and inaccessible iOS Keychain records: do not manufacture their +contents or selectors. Missing concrete identity policy hard-refuses capture. + +Expected-churn stores must be **read-only JSON exports**, not raw binary +AsyncStorage/IndexedDB databases. Export through the client's existing read-only +path (web storageState plus read-only IndexedDB/sessionStorage exports; iOS +read-only test-app export) and retain capture provenance. Never call a +get-or-create API or alter a profile to obtain a snapshot. If no supported +read-only JSON export is available, that client's baseline remains OPEN: +compilation and capture refuse opaque churn rather than guaranteeing a FINDING +on every journey or silently approving unscanned state. Other changed opaque +files remain unscanned FINDINGs. TUI's named feed cache model snapshot, drafts, +glasses and prefs are expected churn only at their actual concrete JSON paths; +no whole cache subtree is exempted. + +For TUI, capture the **whole isolated config/state/cache trees**, including +feed cache, prefs, glasses and drafts. The private profile's actual relative +filename must be listed in `raw_omit`; Python never opens it. The TUI-owned +read-only helper, source #6294 `1f8c81a51bd06e8df914b07eb783bd16b792354d`, is: + +```text +fractal tui client-control identity --profile DEDICATED_PROFILE --json +``` + +`DEDICATED_PROFILE` denotes the actual isolated profile, never the normal +`stui-devices.json`. Save the helper's safe stdout export under an explicit +filename inside the captured tree. Its fields are `public_key`, `fingerprint`, +`device_id`, `session_actor`, `profile`; the embedded profile replaces only +`devices[].signing_key.pkcs8` and `devices[].session.credential` with +`{redacted:\"[REDACTED]\",sha256:}`. Never emit those original +secrets or parse the original private-key file in the packet. The binding and +identity artifacts require `sanitized_profile_evidence`; bind only the actual +safe export's signing-key and session JSON subtrees. No `devices complete` +invocation: that would pair. + +Observer invocation is source-prepared, with actual evidence paths and IDs: + +```sh +python3 scripts/client-control-fixture/observer.py snapshot --client tui \ + --profile \"$ISOLATED_PROFILE_TREE\" --item-id \"$ACTUAL_ASK_ID\" \ + --policy observer-policy.json --identity identity-evidence.json +python3 scripts/client-control-fixture/observer.py diff \ + --before enrolled-baseline.json --after after-journey.json +``` + +Snapshots/diffs print JSON to stdout; there is no `--output` flag. The first +snapshot is immediately after enrollment. Reuse that baseline, profile, key, +pairing and session for all subsequent journeys; never reset to obtain clean data. + +### Primary and secondary identity evidence + +Primary proof is daemon-side for every client: retain actual authoritative +`custom.client.test-enrolled` records before and after, with exactly unchanged +record and ID, and every journey's authoritative +`custom.client.command-receipt` containing `verifiedResultSignature` and +`signedResult`. Each verified signature's key must exactly match the single +enrolled `publicKey`; device/session/grant/run/command bindings must match. +Fingerprint is lowercase hex SHA-256 of the **65-byte uncompressed SEC1 point** +`0x04 || X || Y` decoded from the `p256:` public key. + +Generic user-authored custom claims do not establish this proof. Retain actual +read-only fixture-store extraction and trusted operation/receipt provenance. +There is no invented enrollment GET, receipt GET, or offline verification CLI. +`acceptance.py` consumes authoritative **daemon-verified** receipt artifacts and +checks their bindings; it does not independently verify ECDSA or promote a +signature-looking JSON value to cryptographic evidence. If independent offline +verification is required, the reviewed source APIs are +`fractal_schema::client_control::client_result_canonical_bytes` and +`smallclaims::principal::verify_key_signature`; use the admitted real library +implementation, not a substituted signature format. Until authoritative +provenance and admitted source are available, identity proof remains OPEN. + +`clients.CLIENT.e3.primary_identity` contains `store_readback_provenance`, +`trusted_receipt_provenance`, raw `enrollment_before`, raw `enrollment_after`, +actual `enrolled_fingerprint`, `journey_command_ids`, and complete raw +`verified_receipts`. Every actual journey command must be represented exactly +once. Before/after enrollment is the actual durable row, including `id`, `kind` +and `body.fields`, not a hand-made subset. + +Local readback is secondary. `secondary_identity` contains `mode`, `provenance`, +and exact `source_evidence` file:line references: + +- Web mode `readonly-local`: use `readExistingPublicKeyPoint()` from + #6278 `4619dc87`, or open the existing + `fractal-client-control-identity-v1` IndexedDB without upgrade (abort + `onupgradeneeded`), readonly `identity.get('p256')`, export public SPKI and take + the SEC1 point. Never call `getOrCreate`. +- TUI mode `readonly-local`: use the safe helper above. Map its actual + `fingerprint` to observer field `device_key_fingerprint` in the evidence + wrapper, retaining provenance; this mapping is not a new CLI output field. + Correlate public key/device/session/enrollment against the primary store row. +- iOS mode `declared-unavailable`: the local API is JS-only, not XCUITest-readable. + Do not add a fake screen/readback, invoke key creation, or claim local key + extraction ran. The authoritative daemon proof covers identity. + +Pairing ID is not persisted locally by **any** client. Every row must carry a +`pairing_limitation` with `reason` and exact `source_evidence` file:line list. +Observer `pairing_id` and enrollment ID come only from the actual read-only +fixture-store record correlated by fingerprint, never from a fabricated local +API. The identity `evidence` field records whether each value came from the +secondary local readback or primary store. + +### Retained artifact validation + +`acceptance.py` validates real retained artifacts offline. It does not create +observations, attest freshness, replace authenticated provenance, or authorize +a journey: + +```sh +python3 scripts/client-control-fixture/acceptance.py \ + --record actual-run.json --evidence acceptance-evidence.json +``` + +The evidence wrapper has `catalog_sha256`, `e1`, and `clients` with exactly the +three client rows. All timestamps below are actual Unix milliseconds: + +- `e1`: `root`, `fresh_fixture:true`, `creation_provenance`, + `fixture_created_at_unix_ms`, `ask_created_at_unix_ms`, + `before_first_journey_unix_ms`, `ask_count:1`, actual `ask_id`, + `complete_claim_feed:true`, `seed_claim_ids`, `person_relevant_claim_ids`, + `claim_feed_provenance`, and + `unread_bit:"not-supported-for-native-asks"`. Both claim ID lists must equal + the actual seed's own claim IDs; retain the original complete feed and exact + person-relevance query/capture provenance, not merely an asserted list. +- Each `clients.CLIENT.e2`: `before` and `after` are that client's complete + `packet.py check` receipts, not hand-authored replacements. The validator + compares their client/phase, actual catalog digest, stable receipt digest, + complete seed state/claim ID sets, exact item/screens/grant binding and time + ordering. +- Each `clients.CLIENT.e3`: actual isolated `profile`, `fresh_for_fixture:true`, + `never_reset:true`, `reused_enrolled_session:true`, `freshness_provenance`, + `enrollment_provenance`, `capture_provenance`, + `profile_created_at_unix_ms`, `enrolled_at_unix_ms`, + `baseline_at_unix_ms`, `after_at_unix_ms`, and raw observer artifacts under + `baseline`, `after`, `diff`. Baseline immediately follows enrollment and + precedes journeys; profile creation precedes enrollment. Those provenance + references must establish the actual original client/container, not only a + local copy or attestation flag. An optional `declared_limitation` has a + nonempty `reason` and `source_evidence` array of exact `file:line` references. + +Observer snapshots retain `version`, `client`, `profile`, `item_id`, +`identity:{device_key_fingerprint,pairing_id,evidence}`, exact named +`exclusions`, `expected_churn`, `raw_omit`, `policy_sha256`, all safe `entries` +hashes, `opaque_files`, and `read_seen_markers`. The actual run freezes the entire +concrete policy and `observer_policy_sha256` before journeys. Diff output is +recomputed from the raw artifacts during acceptance; a substituted diff is refused. +Diffs retain `identity_changes`, `expected_churn_changes`, `unexplained_changes`, +`unscanned_changes`, marker findings and identity invariants. Strings containing +JSON (such as storageState values) are scanned too. Opaque changes cannot be called +clean even if the filename is expected churn. + +Output rows are OPEN for missing/inconsistent evidence; FINDING for any marker, +identity, unexplained or opaque/unscanned changes (report to Fractal Product Lead); +or EVIDENCE_COMPLETE for a complete retained artifact set. Named expected-churn +changes alone can produce DIFF_RECORDED, but are never hidden. EVIDENCE_COMPLETE +validates the artifact contract only: it is not consent, live grant authority, +an unread PASS, or a claim that an observer was actually run. + + +## Human-only teardown, fresh consent required + +Retain evidence first. Generate (do not automatically execute) exact prepared +revocations and fixture stop commands: + +```sh +python3 scripts/client-control-fixture/packet.py --socket "$SOCKET" teardown-plan \ + --record actual-run.json +``` + +After fresh authenticated teardown consent bound to the packet/INPUT/catalog, +revalidate the exact fixture path and current unit evidence. Revoke every recorded +run grant via the prepared privileged-Unix request; revoke every recorded fixture +test enrollment by exact device; retain all receipts. Then stop/remove **only** +fixture services, preserving fixture state: + +```sh +fractal --instance client-control-fixture service uninstall +``` + +Stop the dedicated fixture Fabric daemon only with its exact prepared pinned-home +`down` command and the separately scoped Fabric-mesh authorization. Never stop +production Fabric. Compare normal-unit readbacks immediately before/after: + +```sh +python3 scripts/client-control-fixture/packet.py services --record actual-run.json +# Separately authorized fixture service uninstall and own Fabric stop only. +python3 scripts/client-control-fixture/packet.py services --record actual-run.json --expect-stopped +``` + +The after readback requires fixture services uninstalled/stopped and unchanged +normal units. There is no raw `systemctl` packet and no guessed unmanaged-daemon +stop. Normal roots, stores, sockets, sessions, grants/enrollments, profiles and +production Fabric are excluded. **State deletion is NOT AUTHORIZED**: +`uninstall --yes` is not a stop substitute and would require retained evidence +and separate later approval. + +## Source checks for the main agent + +No checks or effects were run while preparing this source. Main should run the +fixture Python unit suite once after all source owners finish, including +manifest/text, grant-binding, dedicated Fabric, normal-path/socket/symlink, +service-management, observer and acceptance-evidence refusal tests. diff --git a/scripts/client-control-fixture/acceptance.py b/scripts/client-control-fixture/acceptance.py new file mode 100755 index 00000000000..77eae88a752 --- /dev/null +++ b/scripts/client-control-fixture/acceptance.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Validate retained E1/E2/E3 artifacts offline; never collect, authorize or fabricate evidence.""" +import argparse +from pathlib import Path +import re + +import packet +import observer + + +def file_line(value): + return isinstance(value, str) and re.fullmatch(r'[^\n]+:[1-9][0-9]*(?:-[1-9][0-9]*)?', value) is not None + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +class Finding(ValueError): + pass + + +def preserved(condition, message): + if not condition: + raise Finding(message) + + +def references(value, subject): + if isinstance(value, dict): + return any(references(item, subject) for item in value.values()) + if isinstance(value, list): + return any(references(item, subject) for item in value) + return value == subject + + +def validate_e1(e1, record): + require(e1['fresh_fixture'] is True and e1['creation_provenance'], 'E1 fresh creation provenance missing') + require(e1['root'] == record['root'], 'E1 root mismatch') + require(0 < e1['fixture_created_at_unix_ms'] <= e1['ask_created_at_unix_ms'] + <= record['captured_at_unix_ms'] <= e1['before_first_journey_unix_ms'], 'E1 chronology missing') + require(e1['ask_count'] == 1 and e1['ask_id'] == record['catalog']['ask_id'], 'E1 unique ask missing') + require(e1['complete_claim_feed'] is True, 'E1 complete durable feed missing') + require(e1['unread_bit'] == 'not-supported-for-native-asks', 'E1 unsupported unread must be explicit') + own = sorted(record['baseline']['claim_ids']) + require(sorted(e1['seed_claim_ids']) == own and sorted(e1['person_relevant_claim_ids']) == own, + 'E1 durable person-relevant claim IDs differ from seed') + relevant = sorted(claim['id'] for claim in record['claims'] + if claim['subject'] == record['catalog']['ask_id'] + or references(claim, packet.seed.PERSON)) + require(relevant == own, 'E1 captured complete feed contains other person-relevant claims') + require(e1['claim_feed_provenance'], 'E1 claim feed capture provenance missing') + + +def validate_e2(e2, record, client): + before, after = e2['before'], e2['after'] + for phase, receipt in (('before', before), ('after', after)): + require(receipt['client'] == client and receipt['phase'] == phase, 'E2 wrong client/phase') + require(receipt['state_check'] == record['baseline'], 'E2 claim and receipt comparison changed') + require(receipt['catalog_sha256'] == record['catalog_sha256'], 'E2 wrong actual catalog') + require(receipt['receipt_sha256'] == packet.digest(packet.canonical(record['baseline'])), + 'E2 receipt digest mismatch') + binding = receipt['grant_binding'] + require(binding['grant_id'] == record['grants'][client]['grant_id'] + and binding['item_id'] == record['catalog']['ask_id'] + and sorted(binding['screens']) == sorted(packet.SCREENS), 'E2 grant binding mismatch') + require(0 < before['observed_at_unix_ms'] <= after['observed_at_unix_ms'], 'E2 chronology missing') + return before['observed_at_unix_ms'], after['observed_at_unix_ms'] + + +def primary_identity(proof, record, client): + """Consume authoritative daemon-verified receipt evidence, not generic custom claims.""" + import base64 + require(proof['store_readback_provenance'] and proof['trusted_receipt_provenance'], + 'E3 authoritative store/verified receipt provenance missing') + before, after = proof['enrollment_before'], proof['enrollment_after'] + preserved(before == after and before['id'] and before['kind'] == 'custom.client.test-enrolled', + 'E3 enrollment record or ID changed') + fields = before['body']['fields'] + grant = record['grants'][client] + require(fields['device'] == grant['device'] and fields['session'] == grant['session'] + and fields['instance'] == packet.seed.INSTANCE and fields['pairingSubject'], + 'E3 enrollment does not bind actual fixture device/session') + public = fields['publicKey'] + require(isinstance(public, str) and public.startswith('p256:'), 'E3 P-256 public key missing') + encoded = public.removeprefix('p256:') + point = base64.b64decode(encoded + '=' * (-len(encoded) % 4), altchars=b'-_', validate=True) + require(len(point) == 65 and point[0] == 4, 'E3 expected uncompressed 65-byte SEC1 point') + fingerprint = packet.digest(point) + preserved(proof['enrolled_fingerprint'] == fingerprint, 'E3 enrollment fingerprint mismatch') + commands = proof['journey_command_ids'] + require(commands and len(set(commands)) == len(commands), 'E3 actual journey command catalog missing') + observed = [] + for claim in proof['verified_receipts']: + require(claim['kind'] == 'custom.client.command-receipt' and claim['id'], + 'E3 expected actual daemon command receipt') + receipt = claim['body']['fields'] + signature, result = receipt['verifiedResultSignature'], receipt['signedResult'] + preserved(signature and result and signature['key'] == public and signature['signature'], + 'E3 result not verified with the one enrolled key') + for camel, snake, expected in ( + ('device', 'device', grant['device']), ('session', 'session', grant['session']), + ('grantId', 'grant_id', grant['grant_id']), ('runId', 'run_id', grant['run_id'])): + require(receipt[camel] == result[snake] == expected, 'E3 signed result identity/run binding mismatch') + require(receipt['commandId'] == result['command_id'] + and receipt['commandKind'] == result['command_kind'] + and receipt['commandKind'] in ('phone.inspect', 'phone.navigate'), + 'E3 receipt command binding mismatch') + observed.append(receipt['commandId']) + require(sorted(observed) == sorted(commands), 'E3 incomplete or duplicated signed journey receipts') + return fingerprint, fields['pairingSubject'] + + +def validate_e3(e3, record, client, journey_times): + profile = Path(record['root']) / 'client-profiles' / client + require(e3['profile'] == str(profile) and profile.resolve() == profile, + 'E3 normal/alternate/aliased client profile refused') + require(e3['fresh_for_fixture'] is True and e3['never_reset'] is True + and e3['reused_enrolled_session'] is True, 'E3 fresh fixture profile/key reuse evidence missing') + require(e3['freshness_provenance'] and e3['enrollment_provenance'] and e3['capture_provenance'], + 'E3 actual creation/enrollment/capture provenance missing') + require(0 < e3['profile_created_at_unix_ms'] < e3['enrolled_at_unix_ms'] + <= e3['baseline_at_unix_ms'] <= journey_times[0] + <= journey_times[1] <= e3['after_at_unix_ms'], 'E3 chronology invalid or reset profile') + baseline, after, diff = e3['baseline'], e3['after'], e3['diff'] + fingerprint, pairing = primary_identity(e3['primary_identity'], record, client) + secondary = e3['secondary_identity'] + require(secondary['mode'] == ('declared-unavailable' if client == 'ios' else 'readonly-local') + and secondary['provenance'] and secondary['source_evidence'] + and all(file_line(item) for item in secondary['source_evidence']), + 'E3 secondary identity readback or iOS declared limitation missing') + pairing_limitation = e3['pairing_limitation'] + require(pairing_limitation['reason'] and pairing_limitation['source_evidence'] + and all(file_line(item) for item in pairing_limitation['source_evidence']), + 'E3 pairing ID exists only in fixture store; declared local limitation required') + for capture in (baseline, after): + require(capture['version'] == 1 and capture['client'] == client + and capture['profile'] == str(profile) + and capture['item_id'] == record['catalog']['ask_id'], 'E3 observer capture mismatch') + require(capture['identity']['evidence'] and capture['identity']['device_key_fingerprint'] + and capture['identity']['pairing_id'], 'E3 identity capture provenance missing') + preserved(capture['identity']['device_key_fingerprint'] == fingerprint + and capture['identity']['pairing_id'] == pairing, + 'E3 local/store identity correlation mismatch') + if client == 'tui': + enrollment = e3['primary_identity']['enrollment_before'] + fields = enrollment['body']['fields'] + preserved(capture['identity']['public_key'] == fields['publicKey'] + and capture['identity']['device_id'] == fields['device'] + and capture['identity']['session_actor'] == fields['session'] + and capture['identity']['enrollment_id'] == enrollment['id'], + 'E3 TUI public identity differs from authoritative enrolled identity') + require(capture['identity']['sanitized_profile_evidence'], + 'E3 TUI safe helper export provenance missing') + require(capture['exclusions'] and all(isinstance(key, str) and key and '*' not in key + for key in capture['exclusions']), 'E3 named exclusion policy missing') + require(capture['policy_sha256'] == record['observer_policy_sha256'], + 'E3 policy digest differs from frozen actual run policy') + require(capture['exclusions'] == record['observer_policy'][client]['identity'] + and capture['expected_churn'] == record['observer_policy'][client]['expected_churn'] + and capture['raw_omit'] == record['observer_policy'][client]['raw_omit'], + 'E3 policy classes differ from frozen actual run policy') + preserved(baseline['identity']['device_key_fingerprint'] == after['identity']['device_key_fingerprint'] + and baseline['identity']['pairing_id'] == after['identity']['pairing_id'], + 'E3 identity changed or re-paired') + require(baseline['exclusions'] == after['exclusions'] + and baseline['policy_sha256'] == after['policy_sha256'], 'E3 exclusion policy changed') + require(diff['version'] == 1 and diff['client'] == client + and diff['item_id'] == record['catalog']['ask_id'] + and diff['device_key_fingerprint'] == baseline['identity']['device_key_fingerprint'] + and diff['pairing_id'] == baseline['identity']['pairing_id'] + and diff['exclusions'] == baseline['exclusions'], 'E3 diff identity/binding mismatch') + require(diff == observer.difference(baseline, after), 'E3 saved diff does not match raw captures') + if e3.get('declared_limitation'): + limitation = e3['declared_limitation'] + require(limitation['reason'] and limitation['source_evidence'] + and all(file_line(item) for item in limitation['source_evidence']), + 'E3 no-local-state limitation requires file:line evidence') + if diff['status'] == 'FINDING': + return 'FINDING' + require(diff['status'] == 'DIFF_RECORDED' and not diff['requires_review'], 'E3 diff result missing') + return 'EVIDENCE_COMPLETE' + + +def validate(evidence, record): + """Missing or inconsistent evidence keeps rows OPEN, never manufactures a pass.""" + output = {'acceptance': 'initially unseen and unmutated', + 'unread_bit': 'not-supported-for-native-asks', 'rows': {}, + 'scope': 'retained-artifact validation only; not consent or independent runtime observation'} + try: + require(evidence['catalog_sha256'] == record['catalog_sha256'], 'wrong catalog') + validate_e1(evidence['e1'], record) + initial = evidence['e1']['before_first_journey_unix_ms'] + e1_error = None + except (KeyError, TypeError, ValueError) as error: + e1_error = str(error) + for client in packet.CLIENTS: + try: + require(e1_error is None, f'E1: {e1_error}') + row = evidence['clients'][client] + times = validate_e2(row['e2'], record, client) + require(initial <= times[0], 'E1 first-journey evidence follows journey') + status = validate_e3(row['e3'], record, client, times) + result = {'status': status, 'summary': 'E2 claim and receipt comparison unchanged, and E3 diff recorded'} + if status == 'FINDING': + result['report_to'] = 'Fractal Product Lead' + output['rows'][client] = result + except Finding as error: + output['rows'][client] = {'status': 'FINDING', 'reason': str(error), + 'report_to': 'Fractal Product Lead'} + except (KeyError, TypeError, ValueError) as error: + output['rows'][client] = {'status': 'OPEN', 'reason': str(error)} + return output + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--record', type=Path, required=True) + parser.add_argument('--evidence', type=Path, required=True) + args = parser.parse_args() + record = packet.load(args.record) + import os + packet.validate_record(record, Path(record['operator_socket']), Path(os.environ['HOME'])) + import json + print(json.dumps(validate(packet.load(args.evidence), record), sort_keys=True, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/scripts/client-control-fixture/expected-input.json b/scripts/client-control-fixture/expected-input.json new file mode 100644 index 00000000000..ea75fd2ebb6 --- /dev/null +++ b/scripts/client-control-fixture/expected-input.json @@ -0,0 +1,71 @@ +{ + "schema": "client-control-fixture-input/v1", + "instance": "client-control-fixture", + "root_derivation": { + "home": "absolute operator HOME, never a supplied alternate root", + "legacy": "$HOME/.st-instance/client-control-fixture", + "migrated": "$HOME/.fractal-instance/client-control-fixture", + "migration_receipt": ".fractal-migration-completed", + "migration_receipt_exact": "arn:lmig:smalltalk:2026-10-16-state-dirs-fractal\n", + "selection": "migrated only when its receipt matches exactly; otherwise legacy", + "operator_socket_relative": "run/fractal.sock", + "symlinks": "refused" + }, + "fabric": { + "policy": "dedicated fixture Fabric node, never ambient or production", + "home_relative": "fabric", + "control_socket_relative": "fabric/run/control.sock", + "execution": "pin FABRIC_HOME and global --home to exact derived fixture Fabric home on every invocation", + "scope": "separate authenticated Fabric-mesh start/stop scope required", + "source_prerequisite": "dedicated Fabric context source must land before execution" + }, + "actor": "agent/client-control-fixture/producer", + "person": "person/client-control-fixture", + "title": "Client control fixture check", + "body": "client-control fixture 0001", + "body_sha256": "c3e53495d6bafca1a5ac1023ec1943ca3eec7c78e0517ddd86f3389827e6636f", + "text_policy": "printable ASCII, 1-60 characters, no Markdown *_#[]`>|, no JSON, no leading/trailing/repeated whitespace", + "screens": ["NowRoot", "Attention"], + "clients": ["ios", "web", "tui"], + "unread_bit": "not-supported-for-native-asks", + "generated": { + "node_id": null, + "fabric_node_id": null, + "run_id": null, + "ask_id": null, + "attention_item_id": null, + "claim_ids": null, + "grant_ids": null, + "device_ids": null, + "session_ids": null + }, + "generated_schema": { + "node_id": "nonempty string", + "fabric_node_id": "actual dedicated Fabric NodeID, separate from daemon node_id", + "run_id": "nonempty string from actual run issuance", + "ask_id": "nonempty native ask subject string from seed receipt", + "attention_item_id": "nonempty projection ID from seed receipt, not the navigation target", + "claim_ids": "array of actual durable claim IDs", + "grant_ids": "array of actual issued grant IDs", + "device_ids": "array of actual enrolled device IDs", + "session_ids": "array of actual session IDs", + "navigation_item_id": "exact ask_id, not attention_item_id" + }, + "checks": ["current-attention-projection", "complete-durable-claims", "no-read-mutation"], + "acceptance": { + "wording": "initially unseen and unmutated", + "e1": "fresh fixture creation provenance, one ask creation timestamp, only seed own person-relevant claim IDs before first journey", + "e2": "independent before/after every journey; E2 claim and receipt comparison unchanged", + "e3": "fresh profile before enrollment, never reset; baseline immediately after enrollment, same enrolled key/session; E3 diff recorded after every journey", + "profile_root_relative": "client-profiles", + "exclusions": "named storage-inventory.json keys bound to concrete actual selectors; no globs, normal profiles, broad cache exclusions or secret dumps", + "identity": "primary authoritative daemon-verified signed receipts use one enrolled public key, unchanged enrollment row/id; local readback secondary; pairing id only from fixture store", + "fingerprint": "lowercase hex SHA-256 of the 65-byte uncompressed P-256 SEC1 point 0x04||X||Y", + "state_classes": "identity preserved; named expected_churn diffed and marker-scanned; all unexplained, identity, marker or unscanned changes are FINDING", + "markers": "any persisted item read/seen/acted/closed marker is FINDING for Fractal Product Lead, never PASS", + "missing_evidence": "OPEN; source publication establishes none of E1-E3", + "no_local_state": "declared limitation requires file:line source evidence" + }, + "consent": "standalone authenticated consent bound to exact executable packet_sha256 and input_sha256; source preparation is not authorization", + "normal_instance": "excluded: root, store, socket, sessions, grants and enrollments" +} diff --git a/scripts/client-control-fixture/observer.py b/scripts/client-control-fixture/observer.py new file mode 100755 index 00000000000..f316120a6aa --- /dev/null +++ b/scripts/client-control-fixture/observer.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Read-only E3 snapshots of isolated client exports/copies; never reset a profile.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re + +from seed import instance_root + +CLIENTS = ('web', 'ios', 'tui') + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + + +def isolated_profile(profile, client, home): + root = instance_root(home) + allowed = root / 'client-profiles' / client + if client not in CLIENTS or not profile.is_absolute() or '..' in profile.parts: + raise ValueError('fixture-observer-path-refused') + if not profile.is_relative_to(allowed): + raise ValueError('fixture-observer-path-refused: not the declared fixture client profile') + for path in [root.parent, root, *profile.relative_to(root).parents]: + candidate = path if path.is_absolute() else root / path + if candidate.is_symlink(): + raise ValueError('fixture-observer-path-refused: symlink parent') + if profile.is_symlink() or not profile.is_dir(): + raise ValueError('fixture-observer-path-refused: profile must already exist without aliases') + return profile + + +def flatten(value, pointer=''): + # StorageState and AsyncStorage exports encode JSON payloads as strings. + if isinstance(value, str): + try: + decoded = json.loads(value) + except ValueError: + decoded = None + if isinstance(decoded, (dict, list)): + yield from flatten(decoded, pointer + '/$json') + return + if isinstance(value, dict) and value: + for key, child in sorted(value.items()): + escaped = key.replace('~', '~0').replace('/', '~1') + yield from flatten(child, f'{pointer}/{escaped}') + elif isinstance(value, list) and value: + for index, child in enumerate(value): + yield from flatten(child, f'{pointer}/{index}') + else: + yield pointer, value + + +def snapshot(profile, client, item_id, policy, identity, home): + isolated_profile(profile, client, home) + if re.search(r'<[^<>]*>', json.dumps(policy)): + raise ValueError('fixture-observer-policy-refused: unresolved placeholder before baseline') + classes = policy.get(client, {}) + exclusions = classes.get('identity', []) if isinstance(classes, dict) else [] + churn = classes.get('expected_churn', []) if isinstance(classes, dict) else [] + omitted = classes.get('raw_omit', []) if isinstance(classes, dict) else [] + if (not exclusions or any(not isinstance(keys, list) for keys in (exclusions, churn, omitted)) + or any(not isinstance(key, str) or not key or '*' in key + or '..' in Path(key).parts for key in exclusions + churn + omitted)): + raise ValueError('fixture-observer-policy-refused: explicit named client classes required') + if client == 'tui' and (not omitted or not identity.get('sanitized_profile_evidence')): + raise ValueError('fixture-observer-policy-refused: omit raw private profile; require helper export') + if (not isinstance(identity.get('device_key_fingerprint'), str) + or not identity['device_key_fingerprint'] or not identity.get('pairing_id') + or not identity.get('evidence')): + raise ValueError('fixture-observer-identity-refused: actual fingerprint, pairing and provenance required') + entries, markers, opaque = {}, [], [] + for path in sorted(profile.rglob('*')): + if path.is_symlink(): + raise ValueError('fixture-observer-path-refused: symlink in profile') + if not path.is_file(): + continue + relative = path.relative_to(profile).as_posix() + if relative in omitted: + # Never open the private profile. Its safe helper export is captured separately. + continue + raw = path.read_bytes() + try: + value = json.loads(raw) + except (ValueError, UnicodeDecodeError): + if any(name.split('#', 1)[0] == relative for name in churn): + raise ValueError('fixture-observer-policy-refused: opaque churn needs a read-only JSON export') + entries[relative] = hashlib.sha256(raw).hexdigest() + opaque.append(relative) + continue + contains_item = item_id in json.dumps(value) + for pointer, leaf in flatten(value): + selector = f'{relative}#{pointer}' + entries[selector] = digest(leaf) + words = re.sub(r'([a-z])([A-Z])', r'\1 \2', + pointer + (' ' + leaf if isinstance(leaf, str) else '')) + tokens = re.split(r'[^a-z]+', words.lower()) + if (contains_item and any(token in tokens for token in ('read', 'seen', 'acted', 'closed')) + and leaf not in (None, False, 0, '', 'unread', 'unseen')): + markers.append(selector) + return {'version': 1, 'client': client, 'profile': str(profile), 'item_id': item_id, + 'identity': identity, 'exclusions': sorted(exclusions), 'policy_sha256': digest(policy), + 'expected_churn': sorted(churn), 'raw_omit': sorted(omitted), + 'entries': entries, 'read_seen_markers': sorted(markers), 'opaque_files': opaque} + + +def difference(before, after): + for key in ('version', 'client', 'profile', 'item_id', 'exclusions', + 'policy_sha256', 'expected_churn', 'raw_omit'): + if before[key] != after[key]: + raise ValueError(f'fixture-observer-comparison-refused: changed {key}') + identity_keys = ['device_key_fingerprint', 'pairing_id'] + if before['client'] == 'tui': + identity_keys.extend(('public_key', 'device_id', 'session_actor', 'enrollment_id')) + identity_changes = [] + for key in identity_keys: + if not before['identity'].get(key) or not after['identity'].get(key): + raise ValueError(f'fixture-observer-identity-refused: missing {key}') + if before['identity'][key] != after['identity'][key]: + identity_changes.append('identity/' + key) + exclusions = set(before['exclusions']) + def classified(key, names): + return any(key == name or key.startswith(name + '/') or key.startswith(name + '#') + for name in names) + if not exclusions or any(not any(classified(key, [name]) + for key in before['entries'].keys() | after['entries'].keys()) + for name in exclusions): + raise ValueError('fixture-observer-policy-refused: exclusion did not name captured state') + changed = sorted(key for key in before['entries'].keys() | after['entries'].keys() + if not classified(key, exclusions) + and before['entries'].get(key) != after['entries'].get(key)) + identity_changes.extend(key for key in before['entries'].keys() | after['entries'].keys() + if classified(key, exclusions) + and before['entries'].get(key) != after['entries'].get(key)) + identity_changes.sort() + expected = [key for key in changed if classified(key, before['expected_churn'])] + unexplained = [key for key in changed if key not in expected] + unscanned = sorted(set(changed + identity_changes) + & (set(before['opaque_files']) | set(after['opaque_files']))) + # An already-present marker also defeats the initially-unseen assertion. + findings = sorted(set(after['read_seen_markers']) | set(before['read_seen_markers'])) + return {'version': 1, 'client': before['client'], 'item_id': before['item_id'], + 'identity_preserved': not identity_changes, + 'device_key_fingerprint': before['identity']['device_key_fingerprint'], + 'pairing_id': before['identity']['pairing_id'], 'exclusions': sorted(exclusions), + 'changed': changed, 'expected_churn_changes': expected, + 'identity_changes': identity_changes, + 'unexplained_changes': unexplained, 'read_seen_findings': findings, + 'unscanned_changes': unscanned, + 'status': 'FINDING' if findings or unexplained or identity_changes or unscanned else 'DIFF_RECORDED', + 'requires_review': bool(unexplained or findings or identity_changes or unscanned)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + capture = commands.add_parser('snapshot') + capture.add_argument('--client', choices=CLIENTS, required=True) + capture.add_argument('--profile', type=Path, required=True) + capture.add_argument('--item-id', required=True) + capture.add_argument('--policy', type=Path, required=True) + capture.add_argument('--identity', type=Path, required=True, + help='Actual client identity readback JSON with capture provenance') + compare = commands.add_parser('diff') + compare.add_argument('--before', type=Path, required=True) + compare.add_argument('--after', type=Path, required=True) + args = parser.parse_args() + if args.command == 'snapshot': + result = snapshot(args.profile, args.client, args.item_id, json.loads(args.policy.read_text()), + json.loads(args.identity.read_text()), Path(os.environ['HOME'])) + else: + result = difference(json.loads(args.before.read_text()), json.loads(args.after.read_text())) + print(json.dumps(result, sort_keys=True)) + + +if __name__ == '__main__': + main() diff --git a/scripts/client-control-fixture/packet.py b/scripts/client-control-fixture/packet.py new file mode 100755 index 00000000000..94f4854faef --- /dev/null +++ b/scripts/client-control-fixture/packet.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Source-only common packet: read-only evidence capture and human-only command preparation.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shlex +import subprocess + +import seed + +HERE = Path(__file__).resolve().parent +CLIENTS = ('ios', 'web', 'tui') +SCREENS = ['NowRoot', 'Attention'] + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode('ascii') + + +def load(path): + return json.loads(Path(path).read_text()) + + +def manifest(): + value = load(HERE / 'expected-input.json') + if (value['schema'] != 'client-control-fixture-input/v1' + or value['instance'] != seed.INSTANCE or value['actor'] != seed.ACTOR + or value['person'] != seed.PERSON or value['title'] != seed.TITLE + or value['body'] != seed.BODY or value['screens'] != SCREENS + or value['clients'] != list(CLIENTS) + or value['body_sha256'] != digest(seed.BODY.encode('ascii')) + or any(item is not None for item in value['generated'].values())): + raise ValueError('immutable-input-refused: manifest differs from fixed fixture') + seed.validate_text(value['title']) + seed.validate_text(value['body']) + return value + + +def digests(): + manifest() + files = {name: digest((HERE / name).read_bytes()) + for name in ('expected-input.json', 'packet.py', 'seed.py', 'acceptance.py', 'observer.py', + 'policy.py', 'storage-inventory.json')} + return {'input_sha256': files['expected-input.json'], + 'packet_sha256': digest(canonical(files)), 'packet_files': files} + + +def safe_root(home): + if not home.is_absolute() or home != Path(os.environ['HOME']): + raise ValueError('fixture-root-refused: only absolute operator HOME') + root = seed.instance_root(home) + # Check every existing ancestor, not only the leaf or run directory. + for path in (home, *home.parents, root.parent, root, + root / '.fractal-migration-completed', root / 'run', root / 'run/fractal.sock'): + if path.is_symlink(): + raise ValueError('fixture-root-refused: symlink alias') + if root.resolve() != root: + raise ValueError('fixture-root-refused: resolved root differs') + return root + + +def safe_socket(socket, home): + root = safe_root(home) + if socket != root / 'run/fractal.sock': + raise ValueError('fixture-socket-refused: normal or alternate socket') + return seed.operator_socket(socket, home) + + +def fabric_paths(home): + root = safe_root(home) + own = root / 'fabric' + control = own / 'run/control.sock' + for path in (own, own / 'run', control, own / 'identity.toml', own / 'peers.toml', own / 'config.toml'): + if path.is_symlink() or path.resolve() != path: + raise ValueError('fixture-fabric-refused: dedicated home/control symlink alias') + return own, control + + +def fabric_commands(home): + own, control = fabric_paths(home) + prefix = ('env', f'FABRIC_HOME={own}', 'fabric', '--home', own) + return {'home': str(own), 'control_socket': str(control), + 'start': command(*prefix, 'up', '--foreground'), + 'status': command(*prefix, 'status', '--json'), + 'stop': command(*prefix, 'down'), + 'scope': 'Separate authenticated Fabric-mesh start/stop scope required; no ambient Fabric.', + 'source_prerequisite': 'Dedicated fixture execution context must land before any invocation.'} + + +def fabric_evidence(value, home): + own, control = fabric_paths(home) + if (value['home'] != str(own) or value['control_socket'] != str(control) + or not isinstance(value['status']['node_id'], str) or not value['status']['node_id'] + or not value['scope_receipt']): + raise ValueError('fixture-fabric-refused: dedicated Fabric status and scope evidence required') + return value + + +def local_health(value): + value = value.get('value', value) + policy = value['network_policy'] + if (value['status'] != 'ready' or not value['node'] + or policy['mode'] != 'local-only' + or any(policy[name] is not False for name in ('fleet_join', 'replication', 'peer_relay'))): + raise ValueError('fixture-health-refused: expected ready local-only daemon') + return value + + +def validate_grant(grant, record, client, now_ms): + """Validate saved issuance binding, NOT live authority; see README execution blockers.""" + if client not in CLIENTS: + raise ValueError('fixture-grant-refused: unknown client') + from datetime import datetime + expires = datetime.fromisoformat(grant['expires_at'].replace('Z', '+00:00')) + if (grant['state'] != 'active' or grant['actor_agent'] != seed.ACTOR + or grant['run_id'] != record['catalog']['run_id'] + or grant['item_id'] != record['catalog']['ask_id'] + or sorted(grant['screens']) != sorted(SCREENS) + or len(grant['screens']) != len(SCREENS) + or sorted(grant['verbs']) != ['phone.inspect', 'phone.navigate'] + or expires.timestamp() * 1000 <= now_ms + or not all(isinstance(grant[key], str) and grant[key] + for key in ('grant_id', 'device', 'session'))): + raise ValueError('fixture-grant-refused: exact run/item/screens/verbs/expiry binding required') + saved = record.get('grants', {}).get(client) + if saved is not None and saved != grant: + raise ValueError('fixture-grant-refused: issuance differs from frozen run record') + return {'client': client, 'grant_id': grant['grant_id'], + 'item_id': grant['item_id'], 'screens': grant['screens'], + 'binding': 'saved-issuance-only', 'live_authority': 'not-verified'} + + +def service_evidence(value, stopped=False): + fixture = value['fixture'] + normal = value['normal'] + if fixture['manager'] != 'systemd-user' or normal['manager'] != fixture['manager']: + raise ValueError('fixture-service-refused: Linux systemd user evidence required') + expected = {f'st3-instance-{seed.INSTANCE}.service', + f'st3-instance-{seed.INSTANCE}-replication.service'} + if ({item['name'] for item in fixture['services']} != expected + or {item['name'] for item in normal['services']} != {'st3.service', 'st3-replication.service'}): + raise ValueError('fixture-service-refused: exact fixture and normal unit names required') + daemon = next(item for item in fixture['services'] + if item['name'] == f'st3-instance-{seed.INSTANCE}.service') + if stopped: + if any(item['installed'] or item['running'] for item in fixture['services']): + raise ValueError('fixture-service-refused: fixture services still installed or running') + elif daemon['installed'] is not True or daemon['running'] is not True: + raise ValueError('fixture-service-refused: fixture daemon must be service-managed and running') + return value + + +def read_services(program): + if os.environ.get('FRACTAL_INSTANCE') or os.environ.get('ST_INSTANCE'): + raise ValueError('fixture-service-refused: use clean operator shell for normal-unit evidence') + def status(*args): + return json.loads(subprocess.run([program, *args, '--json', 'service', 'status'], + text=True, capture_output=True, check=True).stdout) + return {'fixture': status('--instance', seed.INSTANCE), 'normal': status()} + + +def actual_record(health, receipt, claims, items, grants, services, fabric, policy, home, socket, now_ms): + health = local_health(health) + fabric = fabric_evidence(fabric, home) + if set(policy) != set(CLIENTS) or any(not policy[client].get('identity') for client in CLIENTS): + raise ValueError('fixture-policy-refused: concrete per-client identity policy required before journeys') + check = seed.no_read_mutation(items, claims, receipt['ask_id']) + if check != receipt: + raise ValueError('fixture-record-refused: seed receipt differs from current state') + if set(grants) != set(CLIENTS): + raise ValueError('fixture-record-refused: actual issuance resource for each client required') + run_ids = {grant['run_id'] for grant in grants.values()} + if len(run_ids) != 1 or not next(iter(run_ids)): + raise ValueError('fixture-record-refused: grants must bind one actual run') + catalog = { + 'node_id': health['node'], 'run_id': next(iter(run_ids)), + 'fabric_node_id': fabric['status']['node_id'], + 'person_id': seed.PERSON, 'actor_id': seed.ACTOR, + 'ask_id': check['ask_id'], 'attention_item_id': check['attention_id'], + 'navigation_item_id': check['ask_id'], + 'claim_ids': sorted(claim['id'] for claim in claims), + 'claim_subjects': sorted({claim['subject'] for claim in claims}), + 'grant_ids': sorted(grant['grant_id'] for grant in grants.values()), + 'device_ids': sorted(grant['device'] for grant in grants.values()), + 'session_ids': sorted(grant['session'] for grant in grants.values()), + } + for field in ('grant_ids', 'device_ids', 'session_ids'): + if len(set(catalog[field])) != len(CLIENTS): + raise ValueError('fixture-record-refused: each client requires distinct actual identity') + record = {'schema': 'client-control-fixture-actual/v1', **digests(), + 'captured_at_unix_ms': now_ms, 'instance': seed.INSTANCE, + 'root': str(safe_root(home)), 'operator_socket': str(socket), + 'health': health, 'catalog': catalog, 'catalog_sha256': digest(canonical(catalog)), + 'baseline': check, 'claims': claims, 'attention': items, 'grants': grants, + 'services': service_evidence(services), + 'fabric': fabric, + 'observer_policy': policy, 'observer_policy_sha256': digest(canonical(policy)), + 'unread_bit': 'not-supported-for-native-asks', + 'grant_check_scope': 'verifies_binding_only; daemon enforces live authority'} + for client in CLIENTS: + validate_grant(grants[client], record, client, now_ms) + return record + + +def validate_record(record, socket, home): + if (record['schema'] != 'client-control-fixture-actual/v1' + or record['instance'] != seed.INSTANCE + or record['root'] != str(safe_root(home)) + or record['operator_socket'] != str(socket) + or record['catalog_sha256'] != digest(canonical(record['catalog'])) + or any(record[key] != value for key, value in digests().items())): + raise ValueError('fixture-record-refused: changed input, packet, path or catalog') + expected = actual_record(record['health'], record['baseline'], record['claims'], + record['attention'], record['grants'], record['services'], + record['fabric'], record['observer_policy'], home, socket, record['captured_at_unix_ms']) + if record != expected: + raise ValueError('fixture-record-refused: evidence and catalog disagree') + return record + + +def command(*args): + return shlex.join(str(arg) for arg in args) + + +def preparation(home): + root = safe_root(home) + script = HERE / 'seed.py' + return {**digests(), 'root': str(root), 'operator_socket': str(root / 'run/fractal.sock'), + 'authorization': 'separate authenticated consent required before ANY effects', + 'human_only_order': [ + 'Review the packet, exclusions, prerequisites and authenticated consent in README.md.', + fabric_commands(home), + command('fractal', '--instance', seed.INSTANCE, 'setup', '--person', seed.INSTANCE), + 'Choose service-managed setup; save exact fixture and normal service status evidence.', + command('python3', HERE / 'packet.py', 'services'), + 'Save local-only GET /v1/health and exact path evidence BEFORE seed; refuse normal/socket aliases.', + command('python3', HERE / 'packet.py', '--socket', root / 'run/fractal.sock', 'health'), + 'Only after local-only evidence and separate seed authorization:', + command('python3', script, '--socket', root / 'run/fractal.sock', 'seed', + '--idempotency-key', 'client-control-fixture-0001'), + 'Save seed JSON externally; do not alter expected-input.json.', + 'Record actual issuance resources and enrollment evidence for all three clients.', + 'Capture actual run record before any journey; daemon retains live grant authorization authority.' + ]} + + +def teardown(record, socket, home): + validate_record(record, socket, home) + service_evidence(record['services']) + # This prepares commands only. It neither revokes nor stops anything. + commands = [] + for grant in record['grants'].values(): + commands.append(command('curl', '--fail-with-body', '--silent', '--show-error', + '--unix-socket', socket, '-H', 'Content-Type: application/json', + '-X', 'POST', '--data', json.dumps({'grant_id': grant['grant_id']}), + 'http://localhost/v1/client/control-grants/revoke')) + for device in record['catalog']['device_ids']: + commands.append(command('curl', '--fail-with-body', '--silent', '--show-error', + '--unix-socket', socket, '-H', 'Content-Type: application/json', + '-X', 'POST', '--data', json.dumps({'device': device}), + 'http://localhost/v1/client/test-clients/revoke')) + return {'human_only': True, 'requires_new_authenticated_teardown_consent': True, + 'commands': commands, + 'managed_service_stop_only': command('fractal', '--instance', seed.INSTANCE, 'service', 'uninstall'), + 'dedicated_fabric_stop_after_fixture_service': fabric_commands(home)['stop'], + 'service_readback_before_and_after': command('python3', HERE / 'packet.py', 'services'), + 'normal_units_must_remain_identical': record['services']['normal'], + 'stop_gate': 'Only if fixture service status proves daemon is service-managed; unmanaged daemon stop has no shipped standalone CLI here.', + 'excluded': ['normal root', 'normal store', 'normal socket', 'normal sessions', + 'normal grants', 'normal enrollments', 'fixture state deletion'], + 'api_gate': 'Grant/enrollment APIs depend on unlanded canonical source; do not execute until admitted.'} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--socket', type=Path) + parser.add_argument('--fractal', default='fractal') + sub = parser.add_subparsers(dest='action', required=True) + sub.add_parser('prepare') + sub.add_parser('digests') + sub.add_parser('health') + services = sub.add_parser('services') + services.add_argument('--record', type=Path, help='Compare normal units to frozen run record') + services.add_argument('--expect-stopped', action='store_true') + capture = sub.add_parser('capture') + capture.add_argument('--seed-receipt', type=Path, required=True) + capture.add_argument('--grants', type=Path, required=True, help='Object ios/web/tui: actual issuance resources') + capture.add_argument('--service-evidence', type=Path, required=True) + capture.add_argument('--fabric-evidence', type=Path, required=True) + capture.add_argument('--observer-policy', type=Path, required=True) + check = sub.add_parser('check') + check.add_argument('--record', type=Path, required=True) + check.add_argument('--client', choices=CLIENTS, required=True) + check.add_argument('--phase', choices=('before', 'after'), required=True) + stop = sub.add_parser('teardown-plan') + stop.add_argument('--record', type=Path, required=True) + args = parser.parse_args() + home = Path(os.environ['HOME']) + if args.action == 'digests': + result = digests() + elif args.action == 'prepare': + result = preparation(home) + elif args.action == 'services': + safe_root(home) + result = service_evidence(read_services(args.fractal), args.expect_stopped) + if args.record: + record = load(args.record) + validate_record(record, Path(record['operator_socket']), home) + if result['normal'] != record['services']['normal']: + raise ValueError('normal-service-refused: normal unit state changed') + else: + if args.socket is None: + parser.error('--socket is required for evidence and teardown commands') + socket = safe_socket(args.socket, home) + if args.action == 'teardown-plan': + result = teardown(load(args.record), socket, home) + else: + fixture = seed.Fixture(args.fractal, socket) + health = local_health(json.loads(subprocess.run( + ['curl', '--fail-with-body', '--silent', '--show-error', '--unix-socket', str(socket), + 'http://localhost/v1/health'], text=True, capture_output=True, check=True).stdout)) + if args.action == 'health': + result = {**digests(), 'root': str(safe_root(home)), 'operator_socket': str(socket), + 'health': health, 'effects': 'none; read-only evidence'} + else: + import time + now_ms = int(time.time() * 1000) + items, claims = fixture.attention(), fixture.claims() + if args.action == 'capture': + result = actual_record(health, load(args.seed_receipt), claims, items, + load(args.grants), load(args.service_evidence), + load(args.fabric_evidence), load(args.observer_policy), + home, socket, now_ms) + else: + record = validate_record(load(args.record), socket, home) + if health['node'] != record['catalog']['node_id']: + raise ValueError('fixture-record-refused: node identity changed') + current = seed.no_read_mutation(items, claims, record['catalog']['ask_id']) + if current != record['baseline']: + raise ValueError('fixture-no-read-mutation-refused: current state differs') + binding = validate_grant(record['grants'][args.client], record, args.client, now_ms) + result = {'client': args.client, 'phase': args.phase, 'state_check': current, + 'observed_at_unix_ms': now_ms, + 'catalog_sha256': record['catalog_sha256'], + 'receipt_sha256': digest(canonical(current)), + 'grant_binding': binding, 'verifies_binding_only': True, + 'authorization': 'Separate authenticated consent and daemon command authorization remain required.'} + print(json.dumps(result, sort_keys=True, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/scripts/client-control-fixture/policy.py b/scripts/client-control-fixture/policy.py new file mode 100755 index 00000000000..480d21fe5cb --- /dev/null +++ b/scripts/client-control-fixture/policy.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Bind source-inventoried names to actual captured selectors; never collect client secrets.""" +import argparse +import json +import re +from pathlib import Path + +import observer +import packet + + +INVENTORY = Path(__file__).with_name('storage-inventory.json') + +def resolve_names(source, substitutions): + resolved = {} + for classification in ('identity', 'expected_churn'): + resolved[classification] = [] + for template in source[classification]: + def replace(match): + row = substitutions.get(match.group(1), {}) + value = row.get('value') + if (not isinstance(value, str) or not value or '<' in value or '>' in value + or not row.get('record_id') or not row.get('field') + or not row.get('capture_provenance')): + raise ValueError('fixture-policy-refused: unresolved name needs actual fixture/enrollment record provenance') + return value + resolved[classification].append(re.sub(r'<([^<>]+)>', replace, template)) + return resolved + + +def compile_policy(bindings, home): + inventory = packet.load(INVENTORY) + if bindings['inventory_sha256'] != packet.digest(INVENTORY.read_bytes()): + raise ValueError('fixture-policy-refused: named source inventory digest mismatch') + output = {} + for client in packet.CLIENTS: + source, row = inventory[client], bindings['clients'][client] + resolved = resolve_names(source, row.get('substitutions', {})) + profile = Path(row['profile']) + observer.isolated_profile(profile, client, home) + omitted = row.get('raw_omit', []) + if client == 'tui' and (not omitted or not row.get('sanitized_profile_evidence')): + raise ValueError('fixture-policy-refused: actual private profile omission and safe helper export required') + if client != 'tui' and omitted: + raise ValueError('fixture-policy-refused: no inventoried raw omissions for this client') + for relative in omitted: + path = profile / relative + if (not relative or Path(relative).is_absolute() or '..' in Path(relative).parts + or path.is_symlink() or path.resolve() != path or not path.is_file()): + raise ValueError('fixture-policy-refused: raw omission must name actual isolated private profile') + # Enumerate only safe captured data. Never open an omitted private profile. + present, opaque = set(), set() + for path in sorted(profile.rglob('*')): + if path.is_symlink(): + raise ValueError('fixture-policy-refused: profile symlink') + if not path.is_file(): + continue + relative = path.relative_to(profile).as_posix() + if relative in omitted: + continue + try: + parsed = json.loads(path.read_bytes()) + except (ValueError, UnicodeDecodeError): + present.add(relative) + opaque.add(relative) + else: + present.update(f'{relative}#{pointer}' for pointer, _ in observer.flatten(parsed)) + classes = {'identity': [], 'expected_churn': [], 'raw_omit': omitted} + accounted = set() + for binding in row['bindings']: + classification, name, selector = binding['class'], binding['name'], binding['selector'] + if (classification not in ('identity', 'expected_churn') + or name not in resolved[classification] or not binding['capture_provenance'] + or not isinstance(selector, str) or not selector or '*' in selector + or '<' in selector or '>' in selector + or '..' in Path(selector.split('#', 1)[0]).parts + or Path(selector.split('#', 1)[0]).is_absolute()): + raise ValueError('fixture-policy-refused: unowned name or unsafe/unproven selector') + if classification == 'identity' and '#' not in selector: + raise ValueError('fixture-policy-refused: identity must name a captured JSON field, not a whole file') + if not any(key == selector or key.startswith(selector + '/') for key in present): + raise ValueError('fixture-policy-refused: selector absent from actual safe capture') + if classification == 'expected_churn' and selector.split('#', 1)[0] in opaque: + raise ValueError('fixture-policy-refused: expected churn requires a read-only JSON export, not opaque storage') + classes[classification].append(selector) + accounted.add(name) + for limitation in row.get('unobserved', []): + if (limitation['name'] not in resolved['identity'] or not limitation['reason'] + or not limitation['capture_provenance'] or not limitation['source_evidence']): + raise ValueError('fixture-policy-refused: absent identity key needs explicit source/capture limitation') + accounted.add(limitation['name']) + if not set(resolved['identity']).issubset(accounted) or not classes['identity']: + raise ValueError('fixture-policy-refused: every named identity key needs actual binding or declared limitation') + for classification in classes: + classes[classification] = sorted(set(classes[classification])) + if set(classes['identity']) & set(classes['expected_churn']): + raise ValueError('fixture-policy-refused: selector has conflicting classes') + classes['resolved_names'] = resolved + classes['substitutions'] = row.get('substitutions', {}) + output[client] = classes + return output + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--bindings', type=Path, required=True) + args = parser.parse_args() + import os + print(json.dumps(compile_policy(packet.load(args.bindings), Path(os.environ['HOME'])), + sort_keys=True, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/scripts/client-control-fixture/seed.py b/scripts/client-control-fixture/seed.py new file mode 100755 index 00000000000..d4e5f62bd6e --- /dev/null +++ b/scripts/client-control-fixture/seed.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Source-only preparation: invoke seed only under separately authorized fixture setup.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import subprocess + +INSTANCE = 'client-control-fixture' +PERSON = f'person/{INSTANCE}' +ACTOR = f'agent/{INSTANCE}/producer' +TITLE = 'Client control fixture check' +BODY = 'client-control fixture 0001' +MIGRATION = 'arn:lmig:smalltalk:2026-10-16-state-dirs-fractal\n' + + +def validate_text(text): + if (not 1 <= len(text) <= 60 or any(not 32 <= ord(c) <= 126 for c in text) + or text != text.strip() or ' ' in text + or any(c in text for c in '*_#[]`>|')): + raise ValueError('fixture-text-refused: use one plain ASCII paragraph of at most 60 characters') + try: + json.loads(text) + except json.JSONDecodeError: + return text + raise ValueError('fixture-text-refused: JSON text is not a fixture paragraph') + + +def instance_root(home): + # Match fractal_client::product_dirs::instance_root and its completed migration receipt. + migrated = home / '.fractal-instance' / INSTANCE + receipt = migrated / '.fractal-migration-completed' + if receipt.exists() and receipt.read_bytes() == MIGRATION.encode('ascii'): + return migrated + return home / '.st-instance' / INSTANCE + + +def operator_socket(socket, home): + root = instance_root(home) + expected = root / 'run/fractal.sock' + if not home.is_absolute() or socket != expected: + raise ValueError('local-only-fixture-path-refused: expected own fixture operator socket') + for path in [root.parent, root, root / 'run', expected]: + if path.is_symlink(): + raise ValueError('local-only-fixture-path-refused: symlink alias') + if socket.parent.resolve() / socket.name != root.resolve() / 'run/fractal.sock': + raise ValueError('local-only-fixture-path-refused: resolved socket escapes fixture root') + if not stat.S_ISSOCK(socket.stat().st_mode): + raise ValueError('local-only-fixture-path-refused: operator socket must already exist') + return socket + + +class Fixture: + def __init__(self, program, socket): + self.program, self.socket = program, socket + + def cli(self, *args, request=None): + # Revalidate before every invocation; never discover/fall back to a normal socket. + socket = operator_socket(self.socket, Path(os.environ['HOME'])) + result = subprocess.run([self.program, '--instance', INSTANCE, '--endpoint', + f'unix:{socket}', '--json', *args], + input=None if request is None else json.dumps(request), + text=True, capture_output=True, check=True) + return json.loads(result.stdout) + + def attention(self): + envelope = self.cli('alerts', 'ls', '--as', PERSON, '--limit', '200') + page = envelope['value'] + if page['page']['has_more'] or page['page'].get('omitted_items'): + raise ValueError('fixture-inbox-refused: incomplete attention projection') + return page['items'] + + def claims(self): + claims, cursor = [], None + while True: + args = ['claims', 'ls', '--limit', '500'] + if cursor is not None: + args += ['--after-index', str(cursor)] + page = self.cli(*args) + claims.extend(page['claims']) + next_cursor = page.get('next_cursor') + if next_cursor is None: + return claims + if cursor is not None and next_cursor <= cursor: + raise ValueError('fixture-claims-refused: nonadvancing cursor') + cursor = next_cursor + + +def assert_no_read_positions(claims): + # Native asks have no read-position model; refuse durable position records. + for claim in claims: + kind = claim['kind'].lower().replace('_', '-').replace('.', '-') + if 'read' in kind and 'position' in kind: + raise ValueError(f'fixture-read-position-refused: {claim["id"]}') + + +def no_read_mutation(items, claims, ask, title=TITLE, body=BODY): + validate_text(title) + validate_text(body) + if len(items) != 1: + raise ValueError('fixture-inbox-refused: expected exactly one attention item') + item = items[0] + if (item['source_id'] != ask or item['person_id'] != PERSON + or item['attention_kind'] != 'person-step' or item['state'] != 'open' + or item['presentation']['kind'] != 'request' or item['title'] != title + or item['request']['question'] != body): + raise ValueError('fixture-ask-refused: expected own open native request') + ask_claims = [claim for claim in claims if claim['subject'] == ask] + requested = [claim for claim in ask_claims if claim['kind'] == 'work.person-asked'] + if len(requested) != 1: + raise ValueError('fixture-ask-refused: missing unique ask claim') + fields = requested[0]['body']['fields'] + if (requested[0]['actor'] != ACTOR or fields['person'] != PERSON + or fields['title'] != title or fields['request']['question'] != body): + raise ValueError('fixture-ask-refused: source claim does not bind seed') + forbidden = re.compile(r'read|acknowledg|answer|clos|person-done|person-cancel|completed') + assert_no_read_positions(claims) + for claim in claims: + if claim['subject'] == ask and forbidden.search(claim['kind']): + raise ValueError(f'fixture-no-read-mutation-refused: {claim["id"]}') + return {'check': 'no-read-mutation', 'ask_id': ask, 'person': PERSON, + 'attention_id': item['id'], 'title': title, 'body': body, + 'body_sha256': hashlib.sha256(body.encode('ascii')).hexdigest(), + 'claim_ids': sorted(claim['id'] for claim in ask_claims), + 'read_position_claim_ids': [], 'state': 'open', 'answered': False, + 'surfaces': ['current-attention-projection', 'complete-durable-claims'], + 'unread_bit': 'not-supported-for-native-asks'} + + +def seed(fixture, key, title, body): + validate_text(title) + validate_text(body) + if body != BODY: + raise ValueError('fixture-text-refused: seed body must match the fixed fixture') + if fixture.attention(): + raise ValueError('fixture-inbox-refused: seed requires an empty fixture inbox') + assert_no_read_positions(fixture.claims()) + request = {'version': 1, 'type': 'feedback', 'question': body, + 'why_person': 'Isolated client rendering fixture'} + created = fixture.cli('work', 'ask', '--for', PERSON, '--as', ACTOR, + '--title', title, '--request', '-', '--new-run', 'client-control-fixture', + '--idempotency-key', key, request=request) + return no_read_mutation(fixture.attention(), fixture.claims(), created['subject'], title, body) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--fractal', default='fractal') + parser.add_argument('--socket', type=Path, required=True) + commands = parser.add_subparsers(dest='command', required=True) + create = commands.add_parser('seed', help='Requires separate setup authorization; creates one ask') + create.add_argument('--idempotency-key', required=True) + create.add_argument('--title', default=TITLE) + create.add_argument('--body', default=BODY) + check = commands.add_parser('check', help='Read-only before/after journey invariant') + check.add_argument('--ask', required=True) + check.add_argument('--baseline', type=Path, help='Compare with saved before-check JSON') + args = parser.parse_args() + fixture = Fixture(args.fractal, args.socket) + if args.command == 'seed': + receipt = seed(fixture, args.idempotency_key, args.title, args.body) + else: + baseline = json.loads(args.baseline.read_text()) if args.baseline else None + title, body = (baseline['title'], baseline['body']) if baseline else (TITLE, BODY) + receipt = no_read_mutation(fixture.attention(), fixture.claims(), args.ask, title, body) + if baseline is not None and receipt != baseline: + raise ValueError('fixture-no-read-mutation-refused: journey changed seed evidence') + print(json.dumps(receipt, sort_keys=True)) + + +if __name__ == '__main__': + main() diff --git a/scripts/client-control-fixture/storage-inventory.json b/scripts/client-control-fixture/storage-inventory.json new file mode 100644 index 00000000000..eaf83d5dc1b --- /dev/null +++ b/scripts/client-control-fixture/storage-inventory.json @@ -0,0 +1,95 @@ +{ + "schema": "client-control-fixture-storage-inventory/v1", + "selector_policy": "Bind named source keys to concrete captured relative-file#JSON-pointer selectors; no guessed paths, globs or whole cache exclusions. Uncapturable keys require declared limitations, not fake selectors.", + "web": { + "source": "apps/fractal-web #6278 88adf9cfc9; integration ff956ae72b; main d7aa0be1db; owner inventory message/e53fcf79424227e0", + "identity": [ + "IndexedDB/fractal-client-control-identity-v1/identity/p256", + "cookie/fractal_device", + "cookie/fractal_pair", + "localStorage/wf.c/session.person" + ], + "expected_churn": [ + "localStorage/wf.boot@1", + "localStorage/wf.probe", + "localStorage/wf.c/panels.leftSidebar", + "localStorage/wf.c/round2.scheme", + "localStorage/wf.c/round2.panelRatio", + "localStorage/wf.c/gw:/sidebar.tab", + "localStorage/wf.c/gw:/sidebar.collapsedFolders", + "localStorage/wf.c/sidebar.usageSection", + "localStorage/wf.c/sidebar.show.machines@2", + "localStorage/wf.c/devbar.visible", + "localStorage/wf.c/changelog.fractal-web.", + "localStorage/wf.c/gw:/sidebar.filters" + ], + "source_evidence": [ + "apps/fractal-web/src/client-control/identity.ts:3-5", + "apps/fractal-web/server/browserCookie.mts:3-5", + "apps/fractal-web/src/state/cells/drafts.ts:25-27", + "apps/fractal-web/src/state/window.ts:43-49", + "environment.ts:56-59" + ], + "limitations": [ + "Pairing id is not stored locally; use authoritative fixture-store record.", + "storageState does not by itself include sessionStorage; retain a separate actual read-only export when relevant.", + "Identity DB must be read without upgrade in a readonly transaction; never getOrCreate.", + "Live native ask UI persists no read/seen state per AlertsTab.tsx:67-88 and AgentAlerts.tsx:64-75; legacy persisted read paths still remain observable, not excluded.", + "Expected churn source message/e5ef7faed9606e1e supersedes earlier window-key guess. wf.window-id and wf.clear@1 are NOT churn. Migration keys require actual first-load migration provenance; user-action writes are not blanket-allowed. Expand gateway/deployment descriptors to actual observed concrete selectors only." + ] + }, + "ios": { + "source": "apps/ios #6287 85d4f44b; owner inventory message/6fbeae974930578f", + "identity": [ + "Keychain/com.compoundingtech.fractal.device-key", + "Keychain/com.compoundingtech.fractal.fabric-proof/iroh-secret-v1", + "SecureStore/st3.device.profile", + "SecureStore/st3.fabric.profile..", + "SecureStore/st3.device.credential", + "SecureStore/st3.device.signing", + "AsyncStorage/st3.gateway.url", + "AsyncStorage/st3.carrier", + "AsyncStorage/st3.fabric.target", + "AsyncStorage/st3.fabric.members", + "AsyncStorage/st3.fabric.fallback" + ], + "expected_churn": [ + "AsyncStorage/st3.observations", + "AsyncStorage/st3.quality.hours.v1", + "AsyncStorage/st3.quality.run.v1", + "AsyncStorage/st3.client-diagnostics.v1", + "AsyncStorage/st3.projection.v1", + "Application Support/st-diagnostics-v1/state.json" + ], + "source_evidence": [ + "FractalDeviceKeyModule.swift:21-24", + "store.tsx:57-75", + "projectionCache.ts:7-37" + ], + "limitations": [ + "Keychain and SecureStore are not readable from an ordinary copied app container; retain an explicit actual capture limitation, never fabricate their contents.", + "iOS local public-key API is JS-only, not reachable by XCUITest; primary identity proof is daemon-side.", + "Pairing id is not persisted locally; use authoritative fixture-store record.", + "Fabric identity() can create a key; never use it as a read-only observer.", + "st3.fabric.profile suffixes are actual generated member values; expand only from observed keys, never literal placeholder selectors." + ] + }, + "tui": { + "source": "TUI a4107410; SDK 62adcce8; owner e3-tui-inventory; safe helper export owned by TUI", + "identity": ["Profile/devices/signing_key", "Profile/devices/session"], + "expected_churn": ["feed cache model snapshot", "drafts", "glasses", "prefs"], + "raw_omit": "Exact actual private profile relative path; never read it in Python. Use TUI helper safe export with pkcs8 and credential sentinels plus SHA-256.", + "source_evidence": [ + "device.rs:165-177", + "device.rs:380-414", + "mod.rs:444-452" + ], + "limitations": [ + "Capture the whole isolated config, state and cache roots, not only the profile.", + "No packet-side private-key parsing. Use Profile::load public identity helper and sanitized export; never devices complete.", + "Pairing and enrollment IDs come only from authoritative fixture-store records.", + "Transient in-memory updates_read, acted, pending_answers and closed are a declared limitation, not a blanket claim of no local state.", + "Cache, drafts, glasses and preferences are diffed and scanned as named expected churn; concrete filenames come from actual capture, never guessed hashes." + ] + } +} diff --git a/scripts/client-control-fixture/test_acceptance.py b/scripts/client-control-fixture/test_acceptance.py new file mode 100644 index 00000000000..b1a65ae36c1 --- /dev/null +++ b/scripts/client-control-fixture/test_acceptance.py @@ -0,0 +1,202 @@ +"""Synthetic offline evidence tests, not real device observations or runtime IDs.""" +import copy +import base64 +import importlib.util +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).parent)) +spec = importlib.util.spec_from_file_location('fixture_acceptance', Path(__file__).with_name('acceptance.py')) +acceptance = importlib.util.module_from_spec(spec) +spec.loader.exec_module(acceptance) +packet = acceptance.packet + + +def synthetic(root): + record = {'root': str(root), 'captured_at_unix_ms': 4, 'catalog_sha256': 'synthetic-catalog', + 'catalog': {'ask_id': 'synthetic-ask'}, + 'baseline': {'claim_ids': ['synthetic-claim'], 'check': 'no-read-mutation'}, + 'claims': [{'id': 'synthetic-claim', 'subject': 'synthetic-ask'}], + 'grants': {client: {'grant_id': f'synthetic-{client}', 'device': f'synthetic-device-{client}', + 'session': f'synthetic-session-{client}', 'run_id': 'synthetic-run'} + for client in packet.CLIENTS}} + record['observer_policy'] = {client: {'identity': ['synthetic-test-identity-key'], + 'expected_churn': [], 'raw_omit': []} + for client in packet.CLIENTS} + record['observer_policy_sha256'] = packet.digest(packet.canonical(record['observer_policy'])) + evidence = {'catalog_sha256': record['catalog_sha256'], 'e1': { + 'fresh_fixture': True, 'creation_provenance': 'synthetic-test-only', 'root': str(root), + 'fixture_created_at_unix_ms': 1, 'ask_created_at_unix_ms': 2, + 'before_first_journey_unix_ms': 5, 'ask_count': 1, 'ask_id': 'synthetic-ask', + 'complete_claim_feed': True, 'unread_bit': 'not-supported-for-native-asks', + 'seed_claim_ids': ['synthetic-claim'], 'person_relevant_claim_ids': ['synthetic-claim'], + 'claim_feed_provenance': 'synthetic-test-only'}, 'clients': {}} + for client in packet.CLIENTS: + profile = str(root / 'client-profiles' / client) + # Synthetic encoding-shape data only; tests do not exercise cryptographic verification. + point = b'\x04' + b'\x01' * 64 + public = 'p256:' + base64.urlsafe_b64encode(point).decode().rstrip('=') + fingerprint = packet.digest(point) + grant = record['grants'][client] + enrollment = {'id': f'synthetic-enrollment-{client}', 'kind': 'custom.client.test-enrolled', + 'body': {'fields': {'device': grant['device'], 'session': grant['session'], + 'instance': packet.seed.INSTANCE, 'pairingSubject': f'synthetic-pair-{client}', + 'publicKey': public}}} + result = {'command_id': f'synthetic-command-{client}', 'command_kind': 'phone.inspect', + 'device': grant['device'], 'session': grant['session'], + 'grant_id': grant['grant_id'], 'run_id': grant['run_id']} + receipt = {'id': f'synthetic-receipt-{client}', 'kind': 'custom.client.command-receipt', + 'body': {'fields': {'commandId': result['command_id'], 'commandKind': 'phone.inspect', + 'device': grant['device'], 'session': grant['session'], + 'grantId': grant['grant_id'], 'runId': grant['run_id'], + 'verifiedResultSignature': {'key': public, 'signature': 'synthetic-not-a-signature'}, + 'signedResult': result}}} + primary = {'store_readback_provenance': 'synthetic-test-only', + 'trusted_receipt_provenance': 'synthetic-test-only', + 'enrollment_before': enrollment, 'enrollment_after': copy.deepcopy(enrollment), + 'enrolled_fingerprint': fingerprint, 'journey_command_ids': [result['command_id']], + 'verified_receipts': [receipt]} + capture = {'version': 1, 'client': client, 'profile': profile, 'item_id': 'synthetic-ask', + 'identity': {'device_key_fingerprint': fingerprint, + 'pairing_id': f'synthetic-pair-{client}', 'evidence': 'synthetic-test-only', + 'public_key': public, 'device_id': grant['device'], + 'session_actor': grant['session'], 'enrollment_id': enrollment['id'], + 'sanitized_profile_evidence': 'synthetic-safe-export-provenance'}, + 'exclusions': ['synthetic-test-identity-key'], + 'policy_sha256': record['observer_policy_sha256'], + 'expected_churn': [], 'raw_omit': [], 'opaque_files': [], + 'entries': {'synthetic-test-identity-key': 'synthetic-hash'}, 'read_seen_markers': []} + diff = acceptance.observer.difference(capture, capture) + e2 = {} + for phase, observed in [('before', 10), ('after', 20)]: + e2[phase] = {'client': client, 'phase': phase, 'state_check': record['baseline'], + 'catalog_sha256': record['catalog_sha256'], + 'receipt_sha256': packet.digest(packet.canonical(record['baseline'])), + 'observed_at_unix_ms': observed, + 'grant_binding': {'grant_id': f'synthetic-{client}', 'item_id': 'synthetic-ask', + 'screens': ['NowRoot', 'Attention']}} + evidence['clients'][client] = {'e2': e2, 'e3': { + 'profile': profile, 'fresh_for_fixture': True, 'never_reset': True, + 'primary_identity': primary, + 'secondary_identity': {'mode': 'declared-unavailable' if client == 'ios' else 'readonly-local', + 'provenance': 'synthetic-test-only', 'source_evidence': ['synthetic.rs:1']}, + 'pairing_limitation': {'reason': 'synthetic-test-only no-local-pairing-id', + 'source_evidence': ['synthetic.rs:2']}, + 'reused_enrolled_session': True, 'freshness_provenance': 'synthetic-test-only', + 'enrollment_provenance': 'synthetic-test-only', 'capture_provenance': 'synthetic-test-only', + 'profile_created_at_unix_ms': 1, 'enrolled_at_unix_ms': 3, 'baseline_at_unix_ms': 4, + 'after_at_unix_ms': 21, 'baseline': copy.deepcopy(capture), 'after': copy.deepcopy(capture), + 'diff': copy.deepcopy(diff)}} + return record, evidence + + +class AcceptanceTests(unittest.TestCase): + def test_missing_artifacts_keep_every_row_open(self): + record, _ = synthetic(Path('/synthetic-fixture')) + result = acceptance.validate({}, record) + self.assertEqual({row['status'] for row in result['rows'].values()}, {'OPEN'}) + self.assertEqual(result['unread_bit'], 'not-supported-for-native-asks') + + def test_complete_synthetic_artifacts_are_not_runtime_authorization(self): + with tempfile.TemporaryDirectory(dir=Path('/tmp').resolve()) as directory: + record, evidence = synthetic(Path(directory)) + result = acceptance.validate(evidence, record) + self.assertEqual({row['status'] for row in result['rows'].values()}, {'EVIDENCE_COMPLETE'}) + self.assertIn('not consent', result['scope']) + self.assertNotIn('PASS', str(result)) + + def test_creation_provenance_claim_scope_and_chronology_required(self): + record, evidence = synthetic(Path('/synthetic-fixture')) + for field, value in [('fresh_fixture', False), ('person_relevant_claim_ids', ['other-claim']), + ('ask_count', 2), ('unread_bit', 'PASS'), ('complete_claim_feed', False), + ('ask_created_at_unix_ms', 9), ('creation_provenance', '')]: + changed = copy.deepcopy(evidence) + changed['e1'][field] = value + with self.subTest(field=field): + self.assertEqual({row['status'] for row in acceptance.validate(changed, record)['rows'].values()}, + {'OPEN'}) + + def test_each_client_needs_own_before_after_receipts(self): + record, evidence = synthetic(Path('/synthetic-fixture')) + for client in packet.CLIENTS: + changed = copy.deepcopy(evidence) + changed['clients'][client]['e2']['after']['state_check'] = {'changed': True} + result = acceptance.validate(changed, record) + self.assertEqual(result['rows'][client]['status'], 'OPEN') + self.assertEqual(sum(row['status'] == 'EVIDENCE_COMPLETE' for row in result['rows'].values()), 2) + + def test_marker_is_finding_and_unknown_change_requires_review(self): + record, evidence = synthetic(Path('/synthetic-fixture')) + for source in ('baseline', 'after'): + changed = copy.deepcopy(evidence) + e3 = changed['clients']['web']['e3'] + e3[source]['read_seen_markers'] = ['synthetic-item-seen-marker'] + e3['diff'] = acceptance.observer.difference(e3['baseline'], e3['after']) + row = acceptance.validate(changed, record)['rows']['web'] + self.assertEqual(row['status'], 'FINDING') + self.assertEqual(row['report_to'], 'Fractal Product Lead') + e3 = evidence['clients']['ios']['e3'] + e3['after']['entries']['synthetic-other-file'] = 'synthetic-changed-hash' + e3['diff'] = acceptance.observer.difference(e3['baseline'], e3['after']) + self.assertEqual(acceptance.validate(evidence, record)['rows']['ios']['status'], 'FINDING') + + def test_profile_reset_identity_changes_unsafe_profiles_and_missing_policy_refused(self): + record, evidence = synthetic(Path('/synthetic-fixture')) + for field, value in [('never_reset', False), ('reused_enrolled_session', False), + ('profile', '/normal/profile'), ('fresh_for_fixture', False), + ('enrolled_at_unix_ms', 0)]: + changed = copy.deepcopy(evidence) + changed['clients']['tui']['e3'][field] = value + with self.subTest(field=field): + self.assertEqual(acceptance.validate(changed, record)['rows']['tui']['status'], 'OPEN') + for field, value in [('pairing_id', 'changed'), ('device_key_fingerprint', 'changed')]: + changed = copy.deepcopy(evidence) + changed['clients']['ios']['e3']['after']['identity'][field] = value + self.assertEqual(acceptance.validate(changed, record)['rows']['ios']['status'], 'FINDING') + for exclusions in ([], ['*'], ['identity/*']): + changed = copy.deepcopy(evidence) + changed['clients']['web']['e3']['baseline']['exclusions'] = exclusions + self.assertEqual(acceptance.validate(changed, record)['rows']['web']['status'], 'OPEN') + + def test_declared_limitation_requires_file_line_evidence(self): + record, evidence = synthetic(Path('/synthetic-fixture')) + limitation = {'reason': 'synthetic test-only declared limitation', 'source_evidence': ['missing-line']} + evidence['clients']['tui']['e3']['declared_limitation'] = limitation + self.assertEqual(acceptance.validate(evidence, record)['rows']['tui']['status'], 'OPEN') + limitation['source_evidence'] = ['synthetic-test.rs:42'] + self.assertEqual(acceptance.validate(evidence, record)['rows']['tui']['status'], 'EVIDENCE_COMPLETE') + + def test_primary_identity_requires_authoritative_receipts_and_stable_enrollment(self): + record, original = synthetic(Path('/synthetic-fixture')) + for field in ('store_readback_provenance', 'trusted_receipt_provenance'): + evidence = copy.deepcopy(original) + evidence['clients']['web']['e3']['primary_identity'][field] = '' + self.assertEqual(acceptance.validate(evidence, record)['rows']['web']['status'], 'OPEN') + evidence = copy.deepcopy(original) + evidence['clients']['web']['e3']['primary_identity']['enrollment_after']['id'] = 'changed' + self.assertEqual(acceptance.validate(evidence, record)['rows']['web']['status'], 'FINDING') + evidence = copy.deepcopy(original) + proof = evidence['clients']['web']['e3']['primary_identity'] + proof['verified_receipts'][0]['body']['fields']['verifiedResultSignature']['key'] = 'another-key' + self.assertEqual(acceptance.validate(evidence, record)['rows']['web']['status'], 'FINDING') + evidence = copy.deepcopy(original) + evidence['clients']['web']['e3']['primary_identity']['verified_receipts'] = [] + self.assertEqual(acceptance.validate(evidence, record)['rows']['web']['status'], 'OPEN') + + def test_policy_digest_and_secondary_identity_limitations_are_required(self): + record, original = synthetic(Path('/synthetic-fixture')) + evidence = copy.deepcopy(original) + evidence['clients']['web']['e3']['after']['policy_sha256'] = 'changed' + self.assertEqual(acceptance.validate(evidence, record)['rows']['web']['status'], 'OPEN') + evidence = copy.deepcopy(original) + evidence['clients']['ios']['e3']['secondary_identity']['mode'] = 'readonly-local' + self.assertEqual(acceptance.validate(evidence, record)['rows']['ios']['status'], 'OPEN') + evidence = copy.deepcopy(original) + evidence['clients']['tui']['e3']['pairing_limitation']['source_evidence'] = [] + self.assertEqual(acceptance.validate(evidence, record)['rows']['tui']['status'], 'OPEN') + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/client-control-fixture/test_observer.py b/scripts/client-control-fixture/test_observer.py new file mode 100644 index 00000000000..a7c703203c5 --- /dev/null +++ b/scripts/client-control-fixture/test_observer.py @@ -0,0 +1,140 @@ +import copy +import json +from pathlib import Path +import tempfile +import unittest + +import observer + + +class ObserverTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(dir=Path('/tmp').resolve()) + self.addCleanup(self.temporary.cleanup) + self.home = Path(self.temporary.name) + self.profile = self.home / '.st-instance/client-control-fixture/client-profiles/tui' + self.profile.mkdir(parents=True) + self.identity = {'device_key_fingerprint': 'fixture-key-fingerprint', + 'pairing_id': 'fixture-pairing', 'evidence': 'test-only-identity-readback', + 'public_key': 'test-public-key', 'device_id': 'test-device', + 'session_actor': 'test-session-actor', 'enrollment_id': 'test-enrollment', + 'sanitized_profile_evidence': 'test-only-helper-export'} + self.policy = {'web': {}, 'ios': {}, 'tui': { + 'identity': ['identity.json#/session'], 'expected_churn': [], + 'raw_omit': ['config/private-profile.json']}} + (self.profile / 'identity.json').write_text(json.dumps({'session': 'fixture-session'})) + + def capture(self): + return observer.snapshot(self.profile, 'tui', 'attention/fixture', + self.policy, self.identity, self.home) + + def test_empty_or_wildcard_exclusions_fail_closed(self): + for exclusions in [[], ['*'], ['identity.json#/*']]: + self.policy['tui']['identity'] = exclusions + with self.assertRaisesRegex(ValueError, 'policy-refused'): + self.capture() + + def test_other_normal_and_symlink_profiles_are_refused(self): + for profile in [self.home / 'normal', self.home / '.st-instance/other/client-profiles/tui']: + with self.assertRaisesRegex(ValueError, 'path-refused'): + observer.isolated_profile(profile, 'tui', self.home) + target = self.home / 'normal' + target.mkdir() + alias = self.profile / 'normal-alias' + alias.symlink_to(target) + with self.assertRaisesRegex(ValueError, 'path-refused'): + self.capture() + self.assertEqual(list(target.iterdir()), []) + + def test_exact_exclusion_does_not_hide_sibling_read_marker(self): + before = self.capture() + (self.profile / 'identity.json').write_text(json.dumps({ + 'session': 'fixture-session', 'item': 'attention/fixture', 'seen': True})) + after = self.capture() + diff = observer.difference(before, after) + self.assertNotIn('identity.json#/session', diff['changed']) + self.assertIn('identity.json#/seen', diff['changed']) + self.assertEqual(diff['status'], 'FINDING') + self.assertEqual(diff['read_seen_findings'], ['identity.json#/seen']) + self.assertTrue(diff['identity_preserved']) + + def test_changed_device_key_or_pairing_never_passes(self): + before = self.capture() + for key in ['device_key_fingerprint', 'pairing_id', 'public_key', + 'device_id', 'session_actor', 'enrollment_id']: + after = copy.deepcopy(before) + after['identity'][key] = 'replacement' + with self.subTest(key=key): + diff = observer.difference(before, after) + self.assertEqual(diff['status'], 'FINDING') + self.assertFalse(diff['identity_preserved']) + self.assertIn('identity/' + key, diff['identity_changes']) + + def test_unknown_binary_changes_require_review_not_pass(self): + path = self.profile / 'state.bin' + path.write_bytes(b'\xffbaseline') + before = self.capture() + path.write_bytes(b'\xffchanged') + diff = observer.difference(before, self.capture()) + self.assertEqual(diff['status'], 'FINDING') + self.assertEqual(diff['changed'], ['state.bin']) + self.assertTrue(diff['requires_review']) + + def test_policy_and_profile_reset_refused(self): + before = self.capture() + for key, value in [('policy_sha256', 'replacement'), ('profile', '/new-profile')]: + after = copy.deepcopy(before) + after[key] = value + with self.assertRaisesRegex(ValueError, 'comparison-refused'): + observer.difference(before, after) + + def test_private_profile_is_never_opened_and_safe_export_is_compared(self): + raw_profile = self.profile / 'config/private-profile.json' + raw_profile.parent.mkdir() + raw_profile.write_text('INVALID JSON PRIVATE PROFILE NEVER PARSED') + (self.profile / 'identity.json').write_text(json.dumps({ + 'session': {'credential': {'redacted': True, 'sha256': 'test-only-hash'}}, + 'cache': {'item': 'attention/fixture', 'closed': True}})) + before = self.capture() + self.assertNotIn('config/private-profile.json', before['entries']) + self.assertEqual(observer.difference(before, before)['status'], 'FINDING') + + def test_expected_churn_is_recorded_not_failure_but_scanned(self): + self.policy['tui']['expected_churn'] = ['cache.json'] + before = self.capture() + (self.profile / 'cache.json').write_text(json.dumps({'layout': 'changed'})) + diff = observer.difference(before, self.capture()) + self.assertEqual(diff['status'], 'DIFF_RECORDED') + self.assertEqual(diff['expected_churn_changes'], ['cache.json#/layout']) + self.assertFalse(diff['requires_review']) + (self.profile / 'cache.json').write_text(json.dumps({ + 'item': 'attention/fixture', 'acted': True})) + self.assertEqual(observer.difference(before, self.capture())['status'], 'FINDING') + + def test_string_encoded_storage_marker_and_identity_changes_are_findings(self): + before = self.capture() + (self.profile / 'storage.json').write_text(json.dumps({ + 'st3.projection.v1': json.dumps({'item': 'attention/fixture', 'seenAt': 123})})) + self.assertEqual(observer.difference(before, self.capture())['status'], 'FINDING') + (self.profile / 'identity.json').write_text(json.dumps({'session': 'replacement'})) + diff = observer.difference(before, self.capture()) + self.assertFalse(diff['identity_preserved']) + self.assertEqual(diff['identity_changes'], ['identity.json#/session']) + + def test_unresolved_policy_is_refused_before_baseline(self): + self.policy['web'] = {'resolved_names': ['gateway/']} + with self.assertRaisesRegex(ValueError, 'unresolved placeholder'): + self.capture() + + def test_expected_churn_must_be_json_at_capture(self): + self.policy['tui']['expected_churn'] = ['readonly-store.json'] + path = self.profile / 'readonly-store.json' + path.write_bytes(b'\xffopaque') + with self.assertRaisesRegex(ValueError, 'read-only JSON export'): + self.capture() + path.write_text(json.dumps({'item': 'attention/fixture', 'seen': True})) + self.assertEqual(observer.difference(self.capture(), self.capture())['status'], 'FINDING') + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/client-control-fixture/test_packet.py b/scripts/client-control-fixture/test_packet.py new file mode 100644 index 00000000000..a5c2872a90f --- /dev/null +++ b/scripts/client-control-fixture/test_packet.py @@ -0,0 +1,203 @@ +"""Source-only tests. All identifiers below are synthetic test values, never runtime records.""" +import copy +import importlib.util +import json +import os +from pathlib import Path +import socket +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent)) +spec = importlib.util.spec_from_file_location('fixture_packet', Path(__file__).with_name('packet.py')) +packet = importlib.util.module_from_spec(spec) +spec.loader.exec_module(packet) +seed = packet.seed + + +def evidence(): + ask = 'step-run/synthetic-test/ask' + items = [{'id': 'attention/synthetic-test', 'source_id': ask, 'person_id': seed.PERSON, + 'attention_kind': 'person-step', 'state': 'open', 'title': seed.TITLE, + 'presentation': {'kind': 'request'}, 'request': {'question': seed.BODY}}] + claims = [{'subject': ask, 'id': 'claim/synthetic-request', 'kind': 'work.person-asked', + 'actor': seed.ACTOR, 'body': {'fields': {'person': seed.PERSON, + 'title': seed.TITLE, 'request': {'question': seed.BODY}}}}] + grants = {client: {'grant_id': f'synthetic-grant-{client}', 'run_id': 'run/synthetic', + 'device': f'synthetic-device-{client}', 'session': f'synthetic-session-{client}', + 'actor_agent': seed.ACTOR, 'verbs': ['phone.inspect', 'phone.navigate'], + 'screens': ['NowRoot', 'Attention'], 'item_id': ask, 'state': 'active', + 'expires_at': '2099-01-01T00:00:00Z'} for client in packet.CLIENTS} + health = {'node': 'synthetic-node', 'status': 'ready', 'network_policy': { + 'mode': 'local-only', 'fleet_join': False, 'replication': False, 'peer_relay': False}} + services = {'fixture': {'manager': 'systemd-user', 'services': [ + {'name': f'st3-instance-{seed.INSTANCE}.service', 'installed': True, + 'running': True, 'state': 'active'}, + {'name': f'st3-instance-{seed.INSTANCE}-replication.service', 'installed': False, + 'running': False, 'state': 'not-found'}]}, + 'normal': {'manager': 'systemd-user', 'services': [ + {'name': 'st3.service', 'installed': True, 'running': True, 'state': 'active'}, + {'name': 'st3-replication.service', 'installed': True, 'running': True, 'state': 'active'}]}} + return ask, items, claims, grants, health, services + + +class PacketTests(unittest.TestCase): + def test_manifest_is_fixed_input_not_actual_ids(self): + raw = (packet.HERE / 'expected-input.json').read_text() + pairs = [] + value = json.loads(raw, object_pairs_hook=lambda items: pairs.extend(items) or dict(items)) + self.assertEqual(value, packet.manifest()) + # A separate duplicate-key decoder guards the immutable JSON contract. + def unique(items): + self.assertEqual(len(items), len(dict(items))) + return dict(items) + json.loads(raw, object_pairs_hook=unique) + self.assertTrue(all(value is None for value in value['generated'].values())) + self.assertEqual(set(value['generated']), set(value['generated_schema']) - {'navigation_item_id'}) + self.assertEqual(value['body_sha256'], packet.digest(seed.BODY.encode('ascii'))) + self.assertEqual(value['screens'], ['NowRoot', 'Attention']) + self.assertEqual(value['unread_bit'], 'not-supported-for-native-asks') + self.assertEqual(len(packet.digests()['input_sha256']), 64) + + def test_text_restrictions(self): + for value in (seed.TITLE, seed.BODY, 'x' * 60): + self.assertEqual(seed.validate_text(value), value) + for value in ('', 'x' * 61, ' leading', 'trailing ', 'two spaces', '\n', '{}', + '123', 'true', 'nonascii é', *('a' + char for char in '*_#[]`>|')): + with self.subTest(value=value), self.assertRaises(ValueError): + seed.validate_text(value) + + def test_health_refuses_network_enabled_or_unready(self): + health = evidence()[4] + self.assertEqual(packet.local_health(health), health) + for field in ('fleet_join', 'replication', 'peer_relay', 'mode'): + bad = copy.deepcopy(health) + bad['network_policy'][field] = 'fleet-capable' if field == 'mode' else True + with self.subTest(field=field), self.assertRaises(ValueError): + packet.local_health(bad) + bad = copy.deepcopy(health) + bad['status'] = 'starting' + with self.assertRaises(ValueError): + packet.local_health(bad) + + def test_every_client_binding_requires_exact_item_and_both_screens(self): + ask, _, _, grants, _, _ = evidence() + record = {'catalog': {'run_id': 'run/synthetic', 'ask_id': ask}} + for client in packet.CLIENTS: + valid = packet.validate_grant(grants[client], record, client, 0) + self.assertEqual(valid['live_authority'], 'not-verified') + for field, values in { + 'screens': [['NowRoot'], ['Attention'], ['NowRoot', 'Attention', 'Settings'], + ['NowRoot', 'NowRoot']], + 'item_id': ['attention/synthetic-test', 'another-ask', None], + 'run_id': ['another-run'], 'actor_agent': ['agent/normal'], + 'state': ['revoked', 'expired'], 'verbs': [['phone.inspect'], ['phone.navigate', 'phone.answer']], + 'expires_at': ['1970-01-01T00:00:00Z'], 'grant_id': [''], 'device': [''], 'session': [''] + }.items(): + for value in values: + bad = copy.deepcopy(grants[client]) + bad[field] = value + with self.subTest(client=client, field=field, value=value), self.assertRaises(ValueError): + packet.validate_grant(bad, record, client, 0) + + def test_service_management_refuses_wrong_or_unmanaged_units(self): + services = evidence()[5] + self.assertEqual(packet.service_evidence(services), services) + for field, value in [('installed', False), ('running', False), ('name', 'st3.service')]: + bad = copy.deepcopy(services) + bad['fixture']['services'][0][field] = value + with self.subTest(field=field), self.assertRaises(ValueError): + packet.service_evidence(bad) + with self.assertRaises(ValueError): + packet.service_evidence(services, stopped=True) + stopped = copy.deepcopy(services) + for item in stopped['fixture']['services']: + item.update(installed=False, running=False, state='not-found') + self.assertEqual(packet.service_evidence(stopped, stopped=True), stopped) + + def test_catalog_is_actual_separate_and_tamper_evident(self): + ask, items, claims, grants, health, services = evidence() + with tempfile.TemporaryDirectory(dir=Path('/tmp').resolve()) as directory, patch.dict(os.environ, {'HOME': directory}): + home = Path(directory) + path = seed.instance_root(home) / 'run/fractal.sock' + fabric_home, control = packet.fabric_paths(home) + fabric = {'home': str(fabric_home), 'control_socket': str(control), + 'status': {'node_id': 'synthetic-fabric-node'}, 'scope_receipt': 'synthetic-test-scope'} + raw_input = (packet.HERE / 'expected-input.json').read_bytes() + policy = {client: {'identity': ['synthetic.json#/identity'], 'expected_churn': [], 'raw_omit': []} + for client in packet.CLIENTS} + record = packet.actual_record(health, seed.no_read_mutation(items, claims, ask), + claims, items, grants, services, fabric, policy, home, path, 0) + self.assertEqual(packet.validate_record(record, path, home), record) + self.assertEqual(record['catalog']['navigation_item_id'], ask) + self.assertNotEqual(record['catalog']['attention_item_id'], ask) + self.assertEqual(record['catalog']['claim_subjects'], [ask]) + self.assertEqual(record['catalog_sha256'], packet.digest(packet.canonical(record['catalog']))) + self.assertEqual((packet.HERE / 'expected-input.json').read_bytes(), raw_input) + for field in ('ask_id', 'attention_item_id', 'run_id', 'node_id'): + changed = copy.deepcopy(record) + changed['catalog'][field] = 'synthetic-tampered' + with self.subTest(field=field), self.assertRaises(ValueError): + packet.validate_record(changed, path, home) + changed = copy.deepcopy(record) + changed['grants']['ios']['item_id'] = 'synthetic-other-ask' + with self.assertRaises(ValueError): + packet.validate_record(changed, path, home) + plan = packet.teardown(record, path, home) + self.assertEqual(len(plan['commands']), 6) + self.assertEqual(plan['managed_service_stop_only'], + 'fractal --instance client-control-fixture service uninstall') + self.assertTrue(plan['requires_new_authenticated_teardown_consent']) + self.assertNotIn('uninstall --yes', json.dumps(plan)) + with self.assertRaises(ValueError): + packet.teardown(record, home / 'run/fractal.sock', home) + + def test_dedicated_fabric_paths_commands_and_refusals(self): + with tempfile.TemporaryDirectory(dir=Path('/tmp').resolve()) as directory, patch.dict(os.environ, {'HOME': directory}): + home = Path(directory) + own, control = packet.fabric_paths(home) + plan = packet.fabric_commands(home) + self.assertIn(f'FABRIC_HOME={own}', plan['start']) + self.assertIn(f'--home {own} up --foreground', plan['start']) + self.assertTrue(plan['stop'].endswith(f'--home {own} down')) + valid = {'home': str(own), 'control_socket': str(control), + 'status': {'node_id': 'synthetic-fabric-node'}, 'scope_receipt': 'synthetic-test-scope'} + self.assertEqual(packet.fabric_evidence(valid, home), valid) + for key, value in [('home', str(home / '.fabric')), + ('control_socket', str(home / '.fabric/run/control.sock'))]: + invalid = copy.deepcopy(valid) + invalid[key] = value + with self.subTest(key=key), self.assertRaises(ValueError): + packet.fabric_evidence(invalid, home) + own.parent.mkdir(parents=True) + own.symlink_to(home) + with self.assertRaises(ValueError): + packet.fabric_paths(home) + + def test_socket_normal_client_alternate_and_symlink_refusals(self): + with tempfile.TemporaryDirectory(dir=Path('/tmp').resolve()) as directory, patch.dict(os.environ, {'HOME': directory}): + home = Path(directory) + own = seed.instance_root(home) / 'run/fractal.sock' + own.parent.mkdir(parents=True) + with socket.socket(socket.AF_UNIX) as listener: + listener.bind(str(own)) + self.assertEqual(packet.safe_socket(own, home), own) + for candidate in (home / 'run/fractal.sock', own.with_name('fractal-client.sock'), + home / '.st-instance/other/run/fractal.sock', home / 'outside.sock'): + with self.subTest(path=candidate), self.assertRaises(ValueError): + packet.safe_socket(candidate, home) + own.unlink() + own.symlink_to(home / 'normal.sock') + with self.assertRaises(ValueError): + packet.safe_socket(own, home) + own.unlink() + own.parent.rmdir() + (own.parent).symlink_to(home) + with self.assertRaises(ValueError): + packet.safe_root(home) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/client-control-fixture/test_policy.py b/scripts/client-control-fixture/test_policy.py new file mode 100644 index 00000000000..1660ae31118 --- /dev/null +++ b/scripts/client-control-fixture/test_policy.py @@ -0,0 +1,119 @@ +"""Synthetic copied-profile policy tests; never read a real client profile or key.""" +import copy +import importlib.util +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent)) +spec = importlib.util.spec_from_file_location('fixture_policy', Path(__file__).with_name('policy.py')) +policy = importlib.util.module_from_spec(spec) +spec.loader.exec_module(policy) + + +def bindings_for(home): + inventory = policy.packet.load(policy.INVENTORY) + root = policy.packet.safe_root(home) + result = {'inventory_sha256': policy.packet.digest(policy.INVENTORY.read_bytes()), 'clients': {}} + for client in policy.packet.CLIENTS: + profile = root / 'client-profiles' / client + profile.mkdir(parents=True) + (profile / 'synthetic-safe-export.json').write_text(json.dumps({'identity': {'value': 'synthetic-redacted'}})) + source = inventory[client] + substitutions = {key: {'value': 'synthetic-' + key, + 'record_id': 'synthetic-fixture-record', 'field': key, + 'capture_provenance': 'synthetic-only'} + for key in ('gw', 'deployment', 'node', 'service-hex')} + source = policy.resolve_names(source, substitutions) + row = {'profile': str(profile), 'raw_omit': [], 'substitutions': substitutions, 'bindings': [ + {'class': 'identity', 'name': source['identity'][0], + 'selector': 'synthetic-safe-export.json#/identity', 'capture_provenance': 'synthetic-test-only'}], + 'unobserved': [{'name': name, 'reason': 'synthetic-test-only absence', + 'capture_provenance': 'synthetic-test-only', 'source_evidence': ['synthetic.rs:1']} + for name in source['identity'][1:]]} + if client == 'tui': + (profile / 'synthetic-private-profile').write_bytes(b'not JSON and MUST NOT be opened by policy') + row.update(raw_omit=['synthetic-private-profile'], sanitized_profile_evidence='synthetic-test-only') + result['clients'][client] = row + return result + + +class PolicyTests(unittest.TestCase): + def test_bindings_use_existing_concrete_safe_selectors(self): + with tempfile.TemporaryDirectory(dir=Path('/tmp').resolve()) as directory, patch.dict(os.environ, {'HOME': directory}): + home = Path(directory) + bindings = bindings_for(home) + result = policy.compile_policy(bindings, home) + self.assertEqual(result['web']['identity'], ['synthetic-safe-export.json#/identity']) + self.assertEqual(result['tui']['raw_omit'], ['synthetic-private-profile']) + self.assertEqual(result['ios']['expected_churn'], []) + + def test_unowned_glob_missing_and_whole_file_identity_selectors_refused(self): + with tempfile.TemporaryDirectory(dir=Path('/tmp').resolve()) as directory, patch.dict(os.environ, {'HOME': directory}): + home = Path(directory) + original = bindings_for(home) + for field, value in [('name', 'not-in-source-inventory'), + ('selector', 'synthetic-safe-export.json#/*'), + ('selector', 'missing.json#/identity'), + ('selector', 'synthetic-safe-export.json'), + ('selector', '../normal.json#/identity'), ('capture_provenance', '')]: + bindings = copy.deepcopy(original) + bindings['clients']['web']['bindings'][0][field] = value + with self.subTest(field=field, value=value), self.assertRaises(ValueError): + policy.compile_policy(bindings, home) + + def test_private_profile_must_be_omitted_and_source_inventory_pinned(self): + with tempfile.TemporaryDirectory(dir=Path('/tmp').resolve()) as directory, patch.dict(os.environ, {'HOME': directory}): + home = Path(directory) + original = bindings_for(home) + for change in ('inventory', 'omission', 'safe-export', 'unobserved'): + bindings = copy.deepcopy(original) + if change == 'inventory': + bindings['inventory_sha256'] = 'changed' + elif change == 'omission': + bindings['clients']['tui']['raw_omit'] = [] + elif change == 'safe-export': + bindings['clients']['tui']['sanitized_profile_evidence'] = '' + else: + bindings['clients']['ios']['unobserved'] = [] + with self.subTest(change=change), self.assertRaises(ValueError): + policy.compile_policy(bindings, home) + + def test_placeholders_resolve_from_named_record_provenance_and_freeze(self): + with tempfile.TemporaryDirectory(dir=Path('/tmp').resolve()) as directory, patch.dict(os.environ, {'HOME': directory}): + home = Path(directory) + bindings = bindings_for(home) + result = policy.compile_policy(bindings, home) + names = result['web']['resolved_names']['expected_churn'] + self.assertIn('localStorage/wf.c/gw:synthetic-gw/sidebar.filters', names) + self.assertNotIn('<', json.dumps(result)) + self.assertEqual(result['ios']['substitutions']['node']['record_id'], + 'synthetic-fixture-record') + del bindings['clients']['web']['substitutions']['gw'] + with self.assertRaisesRegex(ValueError, 'unresolved'): + policy.compile_policy(bindings, home) + + def test_opaque_churn_requires_read_only_json_export(self): + with tempfile.TemporaryDirectory(dir=Path('/tmp').resolve()) as directory, patch.dict(os.environ, {'HOME': directory}): + home = Path(directory) + bindings = bindings_for(home) + row = bindings['clients']['ios'] + path = Path(row['profile']) / 'asyncstorage-export' + path.write_bytes(b'\xffopaque-store') + row['bindings'].append({ + 'class': 'expected_churn', 'name': 'AsyncStorage/st3.projection.v1', + 'selector': path.name, 'capture_provenance': 'synthetic-readonly-export'}) + with self.assertRaisesRegex(ValueError, 'read-only JSON export'): + policy.compile_policy(bindings, home) + path.write_text(json.dumps({'st3.projection.v1': {'items': []}})) + row['bindings'][-1]['selector'] = path.name + '#/st3.projection.v1' + self.assertEqual(policy.compile_policy(bindings, home)['ios']['expected_churn'], + [path.name + '#/st3.projection.v1']) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/client-control-fixture/test_seed.py b/scripts/client-control-fixture/test_seed.py new file mode 100644 index 00000000000..2154a97a434 --- /dev/null +++ b/scripts/client-control-fixture/test_seed.py @@ -0,0 +1,120 @@ +import copy +import importlib.util +from pathlib import Path +import socket +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('fixture_seed', Path(__file__).with_name('seed.py')) +seed = importlib.util.module_from_spec(spec) +spec.loader.exec_module(seed) + + +class FixtureSeedTests(unittest.TestCase): + def test_plain_text_rules(self): + for text in [seed.TITLE, seed.BODY, 'a' * 60, 'One plain paragraph']: + self.assertEqual(seed.validate_text(text), text) + for text in ['', 'a' * 61, ' leading', 'trailing ', 'two spaces', 'two\nlines', + 'tab\there', 'nonascii é', '\x7f', '{}', '123', 'true', '"JSON"']: + with self.subTest(text=text), self.assertRaisesRegex(ValueError, 'fixture-text-refused'): + seed.validate_text(text) + for char in '*_#[]`>|': + with self.subTest(char=char), self.assertRaises(ValueError): + seed.validate_text(f'No {char} syntax') + + def test_exact_own_operator_socket_only(self): + with tempfile.TemporaryDirectory(dir=Path('/tmp').resolve()) as temporary: + home = Path(temporary) + own = home / '.st-instance' / seed.INSTANCE / 'run/fractal.sock' + own.parent.mkdir(parents=True) + with socket.socket(socket.AF_UNIX) as listener: + listener.bind(str(own)) + self.assertEqual(seed.operator_socket(own, home), own) + for candidate in [home / 'run/fractal.sock', home / 'run/fractal-client.sock', + home / '.st-instance/other/run/fractal.sock', + own.with_name('fractal-client.sock'), home / 'outside.sock']: + with self.subTest(path=candidate), self.assertRaisesRegex(ValueError, 'fixture-path-refused'): + seed.operator_socket(candidate, home) + alias = own.with_name('alias.sock') + alias.symlink_to(own) + with self.assertRaises(ValueError): + seed.operator_socket(alias, home) + own.unlink() + alias.unlink() + other = home / 'outside' + other.mkdir() + own.symlink_to(other / 'normal.sock') + with self.assertRaises(ValueError): + seed.operator_socket(own, home) + own.unlink() + own.parent.rmdir() + own.parent.symlink_to(other) + with self.assertRaises(ValueError): + seed.operator_socket(own, home) + + def test_missing_socket_never_creates_or_launches_anything(self): + with tempfile.TemporaryDirectory(dir=Path('/tmp').resolve()) as temporary, patch.object(seed.subprocess, 'run') as run: + home = Path(temporary) + own = home / '.st-instance' / seed.INSTANCE / 'run/fractal.sock' + with patch.dict(seed.os.environ, {'HOME': str(home)}), self.assertRaises(FileNotFoundError): + seed.Fixture('fractal', own).attention() + run.assert_not_called() + self.assertFalse(own.parent.exists()) + + def test_root_receipt_matches_rust_bytes_without_newline_normalization(self): + with tempfile.TemporaryDirectory(dir=Path('/tmp').resolve()) as temporary: + home = Path(temporary) + migrated = home / '.fractal-instance' / seed.INSTANCE + migrated.mkdir(parents=True) + receipt = migrated / '.fractal-migration-completed' + receipt.write_bytes(seed.MIGRATION.replace('\n', '\r\n').encode('ascii')) + self.assertEqual(seed.instance_root(home), home / '.st-instance' / seed.INSTANCE) + receipt.write_bytes(seed.MIGRATION.encode('ascii')) + self.assertEqual(seed.instance_root(home), migrated) + + def test_seed_refuses_existing_read_position_before_ask_creation(self): + with patch.object(seed, 'Fixture') as fixture_type: + fixture = fixture_type.return_value + fixture.attention.return_value = [] + fixture.claims.return_value = [{'id': 'claim/position', + 'kind': 'custom.mailbox.read-position'}] + with self.assertRaisesRegex(ValueError, 'fixture-read-position-refused'): + seed.seed(fixture, 'fixture-key', seed.TITLE, seed.BODY) + fixture.cli.assert_not_called() + + def evidence(self): + ask = 'step-run/fixture/ask' + items = [{'id': 'attention/fixture', 'source_id': ask, 'person_id': seed.PERSON, + 'attention_kind': 'person-step', 'state': 'open', 'title': seed.TITLE, + 'presentation': {'kind': 'request'}, 'request': {'question': seed.BODY}}] + claims = [{'subject': ask, 'id': 'claim/request', 'kind': 'work.person-asked', + 'actor': seed.ACTOR, 'body': {'fields': {'person': seed.PERSON, + 'title': seed.TITLE, 'request': {'question': seed.BODY}}}}] + return ask, items, claims + + def test_open_request_receipt_names_claims_without_inventing_unread(self): + ask, items, claims = self.evidence() + receipt = seed.no_read_mutation(items, claims, ask) + self.assertEqual(receipt['check'], 'no-read-mutation') + self.assertEqual(receipt['claim_ids'], ['claim/request']) + self.assertEqual(receipt['unread_bit'], 'not-supported-for-native-asks') + self.assertEqual(receipt['body_sha256'], seed.hashlib.sha256(seed.BODY.encode()).hexdigest()) + + def test_answer_read_ack_close_and_position_records_are_refused(self): + ask, items, claims = self.evidence() + for kind in ['message.read', 'work.acknowledged', 'work.person-done', + 'work.person-cancelled', 'message.closed', 'custom.mailbox.read-position']: + mutated = claims + [{'subject': ask, 'id': 'claim/mutation', 'kind': kind}] + with self.subTest(kind=kind), self.assertRaises(ValueError): + seed.no_read_mutation(items, mutated, ask) + closed = copy.deepcopy(items) + closed[0]['state'] = 'resolved' + with self.assertRaises(ValueError): + seed.no_read_mutation(closed, claims, ask) + with self.assertRaises(ValueError): + seed.no_read_mutation(items * 2, claims, ask) + + +if __name__ == '__main__': + unittest.main()