Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbff4ffa75 | ||
|
|
7056ff6973 | ||
|
|
12958610cb | ||
|
|
593b5a1f45 | ||
|
|
f4efc311ab | ||
|
|
b3a6959e46 | ||
|
|
b3d84662f9 | ||
|
|
9512adcec2 | ||
|
|
46e65d9ad4 | ||
|
|
e46e114d3b | ||
|
|
26203bd92c | ||
|
|
d18e49e8f4 | ||
|
|
791c57157a | ||
|
|
fa301afb8c | ||
|
|
d5ed9cfdf1 | ||
|
|
ce0a8ad7d1 | ||
|
|
85d2108e4e | ||
|
|
16f91157a1 | ||
|
|
5916aeefd6 | ||
|
|
58b971aba3 |
+3
-13
@@ -68,25 +68,15 @@ steps:
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
||||
|
||||
# Anti-inert-gate registry. Deliberately unconditional: no path filter and no
|
||||
# step-level `when`, because a gate can be disabled by changes outside its own path.
|
||||
gate-verify:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm gate:verify
|
||||
depends_on:
|
||||
- install
|
||||
- sanitization
|
||||
- upgrade-guard
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm typecheck
|
||||
depends_on:
|
||||
- gate-verify
|
||||
- install
|
||||
- sanitization
|
||||
- upgrade-guard
|
||||
|
||||
# lint, format, and test are independent — run in parallel after typecheck
|
||||
lint:
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Administrator Guide
|
||||
|
||||
- [Gate registry operations](quality-gate-registry.md)
|
||||
@@ -1,35 +0,0 @@
|
||||
# Gate Registry Operations
|
||||
|
||||
## Routine verification
|
||||
|
||||
Run `pnpm gate:verify` from a dependency-installed checkout. Exit zero means registry observations matched their declared `actual` values; it does **not** assert required-behavior conformance while deltas remain. Open `DEFECT` records are checked descriptions of current behavior with tracked owners, never successful gate outcomes.
|
||||
|
||||
Investigate any of these immediately:
|
||||
|
||||
- `GATE VERIFY FAILED` — registry structure, observed behavior, provenance, claim binding, source/deployment identity, or negative-control detection changed. The verifier aggregates independent phase failures, so repair the responsible stable-ID diagnostics as well as any accompanying stale-fixture error; do not treat the generic error as a substitute.
|
||||
- `unregistered gate` — an executable appeared under a declared gate root without a registry entry.
|
||||
- `no negative control` — a gate has no must-fail case.
|
||||
- `DEPLOYED IDENTITY UNAVAILABLE` — the runner cannot reach the installed enforcing copy. The pinned observation is checked, but live equality is not asserted.
|
||||
- `HISTORY_PROVENANCE_FORBIDDEN` — a history assertion path was reintroduced at the repository layer; remove it and keep RM-60 as the tracked external-boundary owner.
|
||||
|
||||
## Updating a gate
|
||||
|
||||
1. Add or change the criterion, its exact criterion-side `caseRefs`, and matching case-side `criterionIds`. Preserve recursive closed-schema validation for every nested object; new fields require explicit key and type handling.
|
||||
2. Observe the must-fail case fail for its own stated reason; moving the binding to any undeclared case must fail verification.
|
||||
3. Declare an exact inerting mutation and observe the verifier detect it.
|
||||
4. If required and actual behavior differ, add a tracked remediation owner and justification.
|
||||
5. If meaning changed, append provenance; never replace the original silently.
|
||||
6. For an installed counterpart, verify live byte identity and update the observed digest only from measured evidence.
|
||||
7. Run focused verifier tests, `pnpm gate:verify`, and the repository baseline gates.
|
||||
|
||||
Do not add an ownerless exception or describe an open delta as pass/green/OK.
|
||||
|
||||
## CI behavior
|
||||
|
||||
Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. The step needs no local history preparation because RM-02 asserts no history-provenance property.
|
||||
|
||||
**DOES:** PR CI executes current-tree verification unprivileged and fail-closed. It compares the manifest and verifier inventory separately with `gates/required-gates.baseline.json`, and consumed case evidence carries a subject checked against its gate definition. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.** The registered shrink-both, overclaim, and per-gate evidence-subject controls must remain red for their stated reasons.
|
||||
|
||||
**DOES NOT:** No local git state in the PR checkout is trustworthy as a history anchor because PR-controlled lifecycle code executes before the gate. The verifier has no history-verification path, and its closed current-tree observation renderer has no history/ancestry/lineage success class. Do not add a local ref, config, remote URL, source constant, or author-positioned path as a replacement anchor.
|
||||
|
||||
`scripts/gate-history-exclusion-control.mjs` enforces the output incapacity by testing alternate success wording and production renderer wiring; its registered fixture proves adding a prohibited success class goes red. RM-60 is the tracked owner of the provider-controlled/protected pre-execution boundary. If a bootstrap override is ever proposed before RM-60, it requires a separate explicit, loud, audited, retiring, negative-controlled design; RM-02 contains no override.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Developer Guide
|
||||
|
||||
- [Gate registry and negative controls](quality-gate-registry.md)
|
||||
@@ -1,43 +0,0 @@
|
||||
# Gate Registry and Negative Controls
|
||||
|
||||
`gates/gates.manifest.json` is the machine-readable registry for the initial RM-02 gate slice. Run:
|
||||
|
||||
```bash
|
||||
pnpm gate:verify
|
||||
```
|
||||
|
||||
## Registered slice
|
||||
|
||||
The registry covers root typecheck, lint, and format checks; RM-01 checkout preflight; the Mosaic CI queue guard; and root Husky pre-commit/pre-push hooks. It does not imply repository-wide coverage. Framework scripts, package-local build/test scripts, templates, and deployment/release scripts remain assigned to RM-54.
|
||||
|
||||
Every gate declares exact invocations, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every case result carries an evidence-side subject that is compared with the gate definition when the result is consumed; the population control mutates that evidence-side subject independently for every required gate. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. Every nested manifest object used by outcomes, mutations, fixtures, deployments, defects, compatibility, provenance, coverage, and merge assertions has closed keys and strict field types; a misspelling cannot silently turn a required comparison into an absent optional field. Independent validation phases collect labeled failures instead of letting one thrown fixture, claim, discovery, deployment, mutation, or compatibility error mask already-known stable-ID diagnostics. This proves detection of the **declared** inerting mutation, not every possible semantic weakening.
|
||||
|
||||
## Required versus actual
|
||||
|
||||
A case may record different `required` and `actual` outcomes only with a tracked owner. The verifier checks current reality against `actual`, prints each difference as `DEFECT (owner: ...)`, and fails when behavior changes without a matching registry update. A defect is never described as passing, green, or OK.
|
||||
|
||||
The queue guard currently has RM-03-owned deltas. In particular, its stdin/heredoc classifier does not consume piped status JSON, so terminal-success, no-status, and terminal-failure payloads become `unknown`; unknown and malformed states exit zero; push purpose defaults to `main`. RM-02 records these observations and does not edit the guard.
|
||||
|
||||
## Criteria, prose, and compatibility
|
||||
|
||||
Each criterion declares exact `caseRefs`; the verifier compares those semantic declarations bidirectionally with case-side `criterionIds` and requires at least one must-fail case. Moving a criterion ID to an unrelated case therefore fails as both a missing declared exercising case and an undeclared binding. Registered meta-negative controls misbind a criterion, remove meaning provenance, and misbind a prose claim, and each must make structure verification red for its stated reason.
|
||||
|
||||
Designated governing prose uses `GATE-CLAIM:<id>` markers. Each claim also names the exact must-fail `caseRef` that exercises its criterion; unknown, positive-only, unrelated, unbound, or registered-but-missing claims fail. Orchestrator-owned claims from `TASKS.md` are bound through `docs/remediation/GATE-CLAIMS.md`, which records source headings and anchored text without changing task tracking. Marker completeness still requires RM-54 review because arbitrary English claims cannot be inferred safely.
|
||||
|
||||
Compatibility checks detect direct contradictions in declared finite constructions. The verifier combines referenced case fixtures and environments in one isolated tree, rejects conflicting fixture/environment values, executes the construction's exact invocation, and checks its exact outcome. They do not prove semantic consistency of arbitrary natural language.
|
||||
|
||||
Restatements preserve original text, current text, reason, finding/task, and date.
|
||||
|
||||
## Source and deployed identity
|
||||
|
||||
A gate with an external installed counterpart declares it explicitly. When the installed queue guard is reachable, its bytes must equal repository source and an internal drift control is observed red. In CI the operator-home installation may be outside the container; the verifier checks the pinned observed source digest, reports `DEPLOYED IDENTITY UNAVAILABLE (owner: RM-04)`, and does not infer live equality.
|
||||
|
||||
## Current-tree and history-provenance boundary
|
||||
|
||||
**DOES:** Every PR evaluates the current checkout's registered gates and declared inerting mutations directly, unprivileged and fail-closed. The seven-gate population, verifier inventory, and `gates/required-gates.baseline.json` are compared inside the checkout. Evidence-side subjects are consumed and compared with gate definitions for every gate. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.**
|
||||
|
||||
**DOES NOT:** This repository layer establishes history provenance at all. `pnpm install` executes PR-controlled lifecycle code before `gate:verify`, so no local ref, git config, remote URL, constant, or author-positioned path in the checkout can anchor a history claim. An observation saying “unverifiable” while returning zero would be a green wearing a disclaimer, so the claim and history verifier path are removed.
|
||||
|
||||
The production output path uses a closed current-tree observation renderer; history, ancestry, and provider-lineage success are not representable observation classes. `scripts/gate-history-exclusion-control.mjs` exercises alternate success wording and production renderer wiring. Its registered must-fail case turns red if a prohibited success class is added or renderer consumption is bypassed. RM-60 owns the provider-controlled/protected pre-execution boundary needed before history provenance can be asserted. No bootstrap override exists in RM-02.
|
||||
|
||||
Universal checks first prove populations non-empty. The production profile reads the same-checkout baseline before comparing the verifier inventory and manifest separately, while `gateRefs` on the D-38/D-40 criteria must exactly span every registered gate. Population controls mutate evidence-side subjects and type-strict comparison inputs one gate at a time and require rejection across the complete inventory. A registered prose-claim control fails when the same-checkout mechanism is described as an adversarial protection.
|
||||
+53
-57
@@ -14,63 +14,6 @@
|
||||
|
||||
---
|
||||
|
||||
## RM-02 Gate Registry and Negative-Control Verifier (#1029)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
Existing deterministic gates can return success without enforcing their stated property. RM-02 introduces a machine-readable registry and an unconditional CI verifier that distinguishes required behavior from observed behavior, proves every registered negative control can detect its own failure reason, and makes source/deployment drift visible.
|
||||
|
||||
### Scope
|
||||
|
||||
**In scope:** root typecheck, lint, and format gates; RM-01 checkout preflight; the Mosaic CI queue guard; root Husky pre-commit and pre-push hooks; criterion bindings; modeled compatibility; meaning-change provenance; security/integrity prose claim markers; source-versus-deployed identity; unprivileged current-tree PR verification; independent required-inventory comparison; evidence-side subject consumption; and enforced structural exclusion of history provenance until RM-60 provides protected external authority.
|
||||
|
||||
**Out of scope:** fixing the queue guard (RM-03); exhaustive registration of every repository executable (RM-54); semantic proof that arbitrary English criteria are mutually satisfiable (RM-54/RM-55); history provenance before RM-60's provider-controlled/protected execution boundary; and a same-authority trust anchor for repository-authored evidence (RM-25/RM-59).
|
||||
|
||||
### Normative requirements
|
||||
|
||||
1. `RM02-REQ-01`: The JSON registry SHALL give every gate and criterion a stable ID and SHALL declare exact invocation, input classes, cases, exact observed and required exit codes, reason diagnostics, and criterion bindings.
|
||||
2. `RM02-REQ-02`: Every gate SHALL have at least one observed-red must-fail case. The verifier SHALL reject missing, stale, ambiguous, or ineffective declared inert mutations and SHALL name an externally inerted gate.
|
||||
3. `RM02-REQ-03`: Every acceptance criterion SHALL declare exact criterion-side `caseRefs` that match case-side `criterionIds` bidirectionally and include a case that can fail for that criterion's stated reason. Moving a binding to an unrelated case SHALL fail verification. Security/integrity prose claims in the designated governing documents SHALL carry bound `GATE-CLAIM:<id>` markers and declare the exact must-fail case exercising the claim criterion.
|
||||
4. `RM02-REQ-04`: Declared finite compatibility scenarios SHALL execute together and direct modeled contradictions SHALL fail. This does not claim semantic consistency of arbitrary English.
|
||||
5. `RM02-REQ-05`: Restated criteria SHALL retain original text, current text, reason, finding/task, and dated meaning-change history.
|
||||
6. `RM02-REQ-06`: An observed behavior differing from required behavior SHALL be reported as `DEFECT` with a tracked owner; an ownerless delta SHALL fail verification. Such a gate SHALL never be described as passing, green, or OK.
|
||||
7. `RM02-REQ-07`: Executables under declared gate roots SHALL fail with `unregistered gate` when absent from the registry. The initial coverage boundary SHALL explicitly list exclusions and bind the broader inventory to RM-54.
|
||||
8. `RM02-REQ-08`: Every gate with a deployed counterpart SHALL register source/deployed byte identity and a must-fail drift control. Gates without a deployed counterpart SHALL say so explicitly.
|
||||
9. `RM02-REQ-09`: CI SHALL run `pnpm gate:verify` on every pull request without path filtering and on protected-main pushes.
|
||||
10. `RM02-REQ-10` (restated): PR CI SHALL perform unprivileged, fail-closed current-tree verification only. The production observation renderer SHALL be a closed current-tree-only output type with no representable history-provenance success state. A registered must-fail control SHALL turn red if history/ancestry/lineage success is added to that renderer or if production output bypasses the renderer. RM-60 owns the provider-controlled/protected pre-execution boundary required to establish history provenance.
|
||||
11. `RM02-REQ-11` (`D-46`): The required seven-gate inventory SHALL be read from a same-checkout baseline and compared separately with both the verifier inventory and manifest. Shrinking the verifier inventory and manifest together while leaving the baseline intact SHALL fail for the removed gate. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.**
|
||||
12. `RM02-REQ-12` (`D-38`): Every consumed case result SHALL carry an evidence-side subject independently declared from the gate definition. The verifier SHALL compare that evidence subject with the gate definition when consuming the result. A population control SHALL mutate the evidence-side subject independently for every required gate and observe rejection for every gate.
|
||||
13. `RM02-REQ-13` (`D-40`): For every gate listed in the required inventory baseline, each discriminator and comparison input SHALL have a recursively closed, type-strict schema. Unknown, misspelled, wrong-type, or present-but-empty nested assertion fields SHALL fail rather than disabling an assertion.
|
||||
14. `RM02-REQ-14` (`D-46`): No universally quantified registry check SHALL run until its population is proven non-empty and compared with the same-checkout baseline. The required seven-gate inventory, criteria, prose claims, and compatibility scenarios SHALL reject empty populations before reporting that all registered cases ran.
|
||||
|
||||
#### RM02-REQ-10 meaning-change provenance
|
||||
|
||||
- **Original:** “assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.”
|
||||
- **Restatement:** The repository verifier performs current-tree verification and is structurally incapable of asserting history provenance. RM-60 supplies the provider-controlled/protected pre-execution boundary before any repository-controlled lifecycle code executes.
|
||||
- **Reason:** `pnpm install` executes PR-controlled lifecycle code before `gate:verify`; therefore no local ref, git config, remote URL, constant, or author-positioned path in the checkout is a trustworthy history anchor. A local disclaimer would be a green wearing a note, not a property. The history-provenance claim is removed rather than weakened.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `RM02-AC-01`: A healthy current tree returns zero while prominently reporting the queue guard's required-versus-actual `DEFECT (owner: RM-03)` delta.
|
||||
2. `RM02-AC-02`: Externally mutate any registered gate at its declared inerting point so its failure path succeeds; verification returns nonzero and names that gate. The verifier's internal meta-control is observed red before its healthy result is trusted.
|
||||
3. `RM02-AC-03`: An executable added under a declared gate root without an entry returns nonzero and includes `unregistered gate`.
|
||||
4. `RM02-AC-04`: A gate with zero must-fail cases returns nonzero and includes `no negative control`.
|
||||
5. `RM02-AC-05`: Unbound or semantically misbound criteria, prose claims bound to unrelated cases, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. A simultaneous stale fixture or independent phase error SHALL NOT mask responsible stable-ID diagnostics. Registered meta-negative controls move a criterion binding, remove meaning provenance, and redirect a prose claim to an unrelated case; each is observed red for its stated reason.
|
||||
6. `RM02-AC-06`: CI configuration invokes the verifier unconditionally on every pull request.
|
||||
7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: the repository layer verifies current-tree registered cases, same-checkout inventory drift, and evidence-side subject consumption; it does not establish history provenance at all. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.**
|
||||
8. `RM02-AC-08`: Registered must-fail controls reject an emptied registry; shrinking the verifier inventory and manifest together; overstating the same-checkout inventory boundary; adding history/ancestry/lineage success to the closed observation renderer or bypassing renderer consumption; a misspelled nested outcome field; a wrong outcome field type; and a present-but-empty outcome pattern.
|
||||
9. `RM02-AC-09`: Population controls iterate every gate listed in the baseline and prove consumed evidence-subject mismatch and wrong-type comparison input are rejected for each gate. `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-PROVENANCE-EXCLUDED`, and `RM02-NONEMPTY-ANCHORED-QUANTIFICATION` are bidirectionally bound to their must-fail controls.
|
||||
|
||||
### Risks, dependencies, and verification boundary
|
||||
|
||||
- **DOES:** The repository verifier proves declared current-tree controls, modeled scenarios, bidirectional criterion/case relationships, source/deployed equality at execution time, a same-checkout seven-gate baseline comparison, and evidence-side subject consumption.
|
||||
- **DOES NOT:** This layer establishes no history provenance. PR-controlled lifecycle code executes before the gate, so no local git state in the checkout is trustworthy as a history anchor. RM-60 owns the provider-controlled/protected pre-execution boundary. The production verifier has no history verifier path, and its closed current-tree observation renderer cannot represent a history-provenance success state.
|
||||
- **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.** A registered claim control fails if the checked artifacts overstate this boundary.
|
||||
- The verifier does **not** infer arbitrary-English semantics or defend against an actor able to rewrite the gate, registry, verifier, controls, and baseline consistently.
|
||||
- `ASSUMPTION:` RM-54 is the owner for expanding registration and prose-marker coverage beyond this approved seven-gate slice; rationale: the remediation task graph already assigns the fleet-wide inert-gate audit there.
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and Next.js frontend. It handles chat, projects, tasks, and LLM routing but lacks orchestration depth, agent coordination, shared memory, and remote access. The Mosaic framework (`~/.config/mosaic`) provides agent guides, shell-based orchestration tools, and quality rails — but these are loose scripts, not an integrated platform. The `@mosaicstack/*` packages in mosaic-mono-v0 began consolidating these into TypeScript packages (brain, queue, coord, cli, prdy, quality-rails) but have no UI, no auth, and no agent runtime integration.
|
||||
@@ -494,6 +437,59 @@ Canonical checkpoint/handoff payloads, exactly-once connector receipts, concrete
|
||||
|
||||
---
|
||||
|
||||
## Governed fleet credential lifecycle (`mosaic cred`, #1045)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
Fleet credentials are issued, wired, resolved, granted, validated, rotated, and revoked through unrelated scripts and manual provider actions. The split has produced silent fallback to a human/shared principal, missing runtime identity, cross-estate login resolution, incomplete permission checks, and non-auditable grants. The objective is one mechanical, durable, systemic `mosaic cred` path that decides both what a fleet seat may do and which provider identity it acts as.
|
||||
|
||||
### Scope
|
||||
|
||||
Phase 1 governs the existing per-identity Gitea token store and Tea login registration. VaultWarden is explicitly out for the agent tier and is not a backend option in this workstream. Certificate-backed identity and short-lived broker-issued credentials remain later phases behind the same caller contract.
|
||||
|
||||
### Normative requirements
|
||||
|
||||
1. `CRED-REQ-01`: The CLI SHALL expose `provision`, `wire`, `grant`, `get`, `validate`, `whoami`, `list`, `rotate`, `revoke`, and `audit`. Grant and validate SHALL conform to [`docs/credentials/GRANT-VALIDATE-CONTRACT.md`](./credentials/GRANT-VALIDATE-CONTRACT.md).
|
||||
2. `CRED-REQ-02`: Every provider operation SHALL carry an explicit identity, estate, and host. Estate-to-host mapping SHALL come from strict non-secret configuration. Missing, ambiguous, inferred, or mismatched values SHALL refuse before credential resolution. Machine location SHALL grant no estate authority.
|
||||
3. `CRED-REQ-03`: Token capability and Tea login identity are inseparable. Provisioning SHALL create/register both or neither. At mint time, delegated Basic authority SHALL read its provider principal back, the minted token object SHALL read back exact scopes, and both the token binding and exact host-bound Tea record SHALL contain that same minted credential. Rollback SHALL read provider, token-store, and Tea state back and may report complete only when the pre-operation state is established exactly; failed or unverified cleanup is `indeterminate`/`rollback-incomplete`. Provision, rotate, and revoke SHALL serialize the complete provider/token-store/Tea transaction between cooperating `mosaic cred` processes under one fixed estate/host/identity advisory lock that caller-selected state roots cannot bypass. That same-UID-replaceable filesystem lock and the file-store generation preconditions provide optimistic concurrency only for cooperating `mosaic cred` mutators; neither is an authorization boundary nor atomic CAS against a hostile same-UID filesystem writer. Provider authority is the authorization boundary. Hostile same-UID direct filesystem mutation is explicitly out of scope for this phase and deferred. Runtime `/user` identity remeasurement is required only when the seat token already carries `read:user`; least-privilege tokens SHALL NOT be widened to service the instrument. A wrong-host or absent Tea login SHALL never fall back to a host default.
|
||||
4. `CRED-REQ-04`: Under fleet context, unset or unresolvable identity SHALL fail closed identically in the git credential helper and API resolver. Interactive shared credentials remain available only through an explicit non-fleet/shared selection; absence SHALL never select them.
|
||||
5. `CRED-REQ-05`: Token scope, repository permission, and organization/team role are independent layers. Provision, grant, and validate SHALL report each separately from provider evidence. No layer substitutes for another, and a permission widening at one layer SHALL not be described as least privilege because another layer is narrow.
|
||||
6. `CRED-REQ-06`: Gitea token creation SHALL use an explicit delegated provisioning step because this provider requires Basic Auth. Password-equivalent provisioning material SHALL enter only through a protected control-plane runtime credential channel, never caller bearer storage, argv, ordinary environment, logs, or output.
|
||||
7. `CRED-REQ-07`: Permission grants SHALL be accepted only after provider read-back of the named direct collaborator permission or, for team grants, organization membership, team membership, team-repository attachment, and subject effective permission.
|
||||
8. `CRED-REQ-08`: `validate --repo` SHALL compute a side-effect-free write differential by result. One immutable credential resolution SHALL bind the declared subject's provider identity read-back, repository permission, and authenticated Git receive-pack advertisement. A distinct provider-confirmed read-only principal and an unauthenticated caller SHALL both be refused receive-pack in the same evaluation. Principal/handle disagreement SHALL be indeterminate, never refusal or success. The check SHALL create no ref or artifact and SHALL state that it does not prove a particular update will pass branch protection, hooks, races, or content policy.
|
||||
9. `CRED-REQ-09`: All provider HTTP calls SHALL share one transport implementation for URL/host binding, TLS, User-Agent, content-type, JSON-shape validation, redaction, and bounded responses. A 2xx status alone SHALL never establish identity, scope, permission, grant, or revocation.
|
||||
10. `CRED-REQ-10`: Operations SHALL return stable machine outcomes `ok`, `refused`, `error`, or `indeterminate`. Policy refusal, local operational failure, and incomplete/inconsistent evidence SHALL remain distinguishable. `provider-unavailable`, `identity-not-measured`, `identity-not-visible`, `identity-not-found`, and `credential-rejected` SHALL remain distinct diagnoses. Validation SHALL report capability from an in-scope probe separately from identity measurement. `/user` 401 is `credential-rejected`/refused; `/user` 403/404 plus successful in-scope capability is `identity-not-measured`, never a dead credential. A returned login mismatch is a binding refusal. No implemented operation may emit `identity-not-found`; that diagnosis requires a separately approved visibility-authorized inventory capability. Security callers SHALL fail closed on every outcome except `ok` without relabelling indeterminate evidence as a denial.
|
||||
11. `CRED-REQ-11`: No command SHALL print a token, password, authorization header, fingerprint, partial secret, or secret-bearing provider body, including error paths. Secrets SHALL not appear in process argv. Phase-1 file storage SHALL remain private, symlink-safe, regular-file-only, test-overridable, and compatible with existing managed token consumers.
|
||||
12. `CRED-REQ-12`: Every issue, provision, grant, rotate, revoke, and credential access SHALL be journaled with actor, subject, estate, host, repo/scope, operation, time, and non-secret provider evidence. The durable journal SHALL be opened and fsynced before the first mutation, append each mutation/read-back, and seal only after acceptance. Credential access SHALL durably record issuance start immediately before protected-fd disclosure; any partial write or post-write audit failure SHALL remain visibly `indeterminate` with mutation `unknown` or `applied`, never be relabelled as a pre-disclosure destination error. A journal seal SHALL remain staged and recovery-visible until its final rename and directory durability succeed; a final seal-commit fault SHALL revert to open classification and cannot leave an acceptance-bearing sealed-success object. Journal/audit write failure SHALL be fatal; an unsealed journal means incomplete/indeterminate work.
|
||||
13. `CRED-REQ-13`: `wire` SHALL be idempotent and SHALL update the exact roster-derived `<identity>.env.generated` fleet projection so both identity axes survive restart. It SHALL authenticate the same explicit seat through a protected delegated credential channel and provider identity read-back before mutation, refuse actor/identity/path/roster disagreements, and never authorize from the shared Unix account. It SHALL not write linked-worktree git configuration or silently infer identity from pane/session names.
|
||||
14. `CRED-REQ-14`: Rotate SHALL verify the new credential/provider identity before retiring the old credential. Revoke SHALL read back provider revocation/denial and preserve an auditable recovery record. A local file deletion or successful HTTP status is not revocation evidence.
|
||||
15. `CRED-REQ-15`: Before the #1044 fail-closed resolver change is eligible to land, `mosaic cred validate` SHALL resolve every live HOMELAB mosaic-lane seat from `git.mosaicstack.dev` by provider read-back. Any unresolved seat HOLDS the fail-closed change; the implementation may not widen or restore shared fallback.
|
||||
16. `CRED-REQ-16`: Provider claims SHALL record the estate, instance, endpoint, asserted content type, and decision-relevant object fields. Append-only provider status history SHALL be reduced to latest-per-context where current state is required.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `AC-CRED-01`: Red-first tests prove unset identity, missing token, wrong estate, wrong host, wrong Tea login, and out-of-estate identity produce the same structured refusal class/reason on git and API resolution, with no shared credential read and no provider mutation.
|
||||
2. `AC-CRED-02`: Provisioning against a provider fixture proves Basic Auth is required, bearer-only token minting is refused, both identity axes are established together or every completed provider/token-store/Tea change is compensated and read back to the exact pre-operation state, and exact token scopes are read back from the provider token object. Injected Tea cleanup failure returns `indeterminate`/`rollback-incomplete` and cannot claim mutation `none`; no cross-system atomic commit is claimed. A second cooperating same-identity transaction is refused across caller-selected state roots. A code-level security-model assertion pins that advisory flock as cooperative serialization, provider authority as the authorization boundary, generation preconditions as optimistic protection for cooperating mutators rather than atomic CAS, and hostile same-UID filesystem mutation as explicitly deferred.
|
||||
3. `AC-CRED-03`: Direct and team grant tests read all applicable permission layers back from provider objects. Deliberately divergent token scope and repo grant cases cannot return `ok`; organization/team membership and team-repository attachment are additionally acceptance-bearing for team grants. A direct collaborator grant reports organization membership but does not require it, because direct collaborator permission and organization membership are intentionally independent provider layers.
|
||||
4. `AC-CRED-04`: Validate proves provider identity and the write differential on the intended repository through one credential handle. The subject is accepted, a separately resolved provider-confirmed read-only principal is refused, and an unauthenticated caller is refused in the same invocation. A shared/wrong-principal fallback, independent subject lookups, invalid read-only control, evidence disagreement, unexpected content type/shape, or provider outage returns `indeterminate`, never success or policy refusal. Runtime exact scope is reported independently as `not-measured` when the current seat credential is not authorized to read its provider token object; NOT-MEASURED is neither pass nor failure and does not erase confirmed repository capability. Exact scope is acceptance-bearing at provision/rotate time, where delegated mint authority can read the token object.
|
||||
5. `AC-CRED-05`: Audit/journal fault injection before and after each mutation proves write failure is fatal, open journals remain visible/recoverable, and no operation can claim success without a sealed journal and provider read-back. Protected credential output fault injection covers partial write, post-write append, and seal failure and preserves possibly-issued/applied truth in both DTO and durable journal.
|
||||
6. `AC-CRED-06`: Adversarial output/argv tests seed distinct secret values through success, refusal, provider-error, parser-error, rollback, rotate, and revoke paths and find zero secret/partial/fingerprint occurrences in stdout, stderr, logs, audit, and child argv.
|
||||
7. `AC-CRED-07`: Storage tests reject symlinked roots/files, non-regular files, permissive modes, traversal, stale generations from cooperating concurrent mutators, and production-store leakage into fixture tests. They do not claim atomic exclusion against hostile same-UID direct filesystem mutation. Existing canonical per-seat token consumers continue through the governed adapter.
|
||||
8. `AC-CRED-08`: `wire` repeated twice is byte-idempotent, produces both required identity-axis values in the exact roster-derived generated environment, survives a fresh fleet projection/restart path, and leaves shared linked-worktree git config untouched. An unauthenticated caller, a caller authenticated as another seat, a caller-selected filename, or a file whose roster identity differs is refused before mutation.
|
||||
9. `AC-CRED-09`: Rotate validates new identity/capabilities before retiring old material; injected failure leaves the previously valid credential usable and the journal open. Revoke is accepted only when provider read-back proves the credential no longer authenticates/authorizes.
|
||||
10. `AC-CRED-10`: Every live HOMELAB mosaic-lane seat resolves from `git.mosaicstack.dev` before the #1044 fallback closes. The evidence names the complete seat population, provider endpoint/content type, and unresolved count; non-zero unresolved count blocks landing.
|
||||
11. `AC-CRED-11`: Baseline typecheck/lint/format/tests, focused auth/permission abuse cases, independent code review, independent security review, and terminal-green HOMELAB Woodpecker CI pass on the exact reviewed head.
|
||||
12. `AC-CRED-12`: Interim delivery to `next` is reported only as **believed-fixed, pending validation AND pending promotion to `main`**. Issues stay open until #1037 promotes the work and constitutional completion is independently verified.
|
||||
|
||||
### Constraints and dependencies
|
||||
|
||||
- C1 install-state-machine work merges first. This lane then re-takes base/head-bound measurements without redesigning or reworking code.
|
||||
- MB-BRAIN-01 (#1051) consumes the grant/validate contract and may proceed against the published interface before implementation merge.
|
||||
- The branch-model compatibility question for `next` remains escalated. No `done` claim, issue closure, or self-initiated promotion is permitted at the `next` checkpoint.
|
||||
- `ASSUMPTION:` Phase-1 Gitea support is the only provider implementation in this slice; provider-neutral types preserve later adapters without pretending unimplemented providers are supported.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### High-Level System Diagram
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Documentation Sitemap
|
||||
|
||||
## Gate verification
|
||||
|
||||
- [Developer gate registry guide](DEVELOPER-GUIDE/quality-gate-registry.md) — manifest schema, negative controls, evidence-side subjects, and the enforced RM-60 history-provenance exclusion. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.
|
||||
- [Gate registry operations](ADMIN-GUIDE/quality-gate-registry.md) — routine verification, failure interpretation, registry updates, and unconditional CI behavior.
|
||||
- [RM-02 governing claim index](remediation/GATE-CLAIMS.md) — marker bindings for orchestrator-owned remediation claims without modifying task tracking.
|
||||
|
||||
## Compaction refresh lease broker
|
||||
|
||||
- [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings.
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# `mosaic cred grant` / `validate` caller contract v1.5
|
||||
|
||||
**Status:** early binding contract for MC-CRED-01 and MB-BRAIN-01. v1.3's anonymous absence classifier was withdrawn as unsound for private users. v1.4 adopted subject-credential validation without admin visibility. v1.5 separates in-scope capability from identity measurement so correctly least-privileged tokens are not widened to service the instrument. This contract may evolve before implementation merge; incompatible changes require an explicit change notice.
|
||||
|
||||
## Security model
|
||||
|
||||
- Every call carries both `--estate` and `--host`. The configured estate-to-host mapping must match exactly. Host inference, host-adjacent fallback, and cross-estate resolution are forbidden.
|
||||
- `<identity>` is always explicit. The CLI never substitutes a pane, roster, login, Unix user, or other plausible ambient identity.
|
||||
- The identity token and the host-bound Tea login are one provisioning unit. Minting authority reads the principal back when the invariant is created and records that binding with the token registration. Runtime validation re-measures identity only when the token already holds `read:user`; it never widens scopes to make the instrument green.
|
||||
- Grant authority is broker/delegated-provisioner material. It is never supplied as a CLI value, environment value, or bearer token readable by the requesting agent. The broker obtains it from its protected runtime credential channel.
|
||||
- Commands never print token, password, authorization header, fingerprint, partial secret, or secret-bearing error text. Structured evidence contains provider object fields and endpoint metadata only.
|
||||
- Every operation opens and fsyncs a durable journal before the first mutation. Journal/audit write failure is fatal. A grant is successful only after provider read-back and a sealed journal.
|
||||
|
||||
## Commands
|
||||
|
||||
```text
|
||||
mosaic cred grant <identity> \
|
||||
--estate <estate> \
|
||||
--host <host> \
|
||||
--repo <owner/repo> \
|
||||
--permission <read|write|admin> \
|
||||
[--via <collaborator|team>] \
|
||||
[--team <team>] \
|
||||
[--read-only-control <identity>] \
|
||||
[--json]
|
||||
|
||||
mosaic cred validate <identity> \
|
||||
--estate <estate> \
|
||||
--host <host> \
|
||||
[--repo <owner/repo>] \
|
||||
[--require <read|write|admin>] \
|
||||
[--read-only-control <identity>] \
|
||||
[--json]
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `--via collaborator` is the default. It grants a direct repository permission and still reports the organization-membership layer.
|
||||
- `--via team` requires `--team`; `--team` with collaborator mode is invalid.
|
||||
- `validate --repo` reports two independent axes: capability from an in-scope repository probe, and identity binding from `/user` only when authorized. Capability may be `confirmed` while identity is `not-measured`; NOT-MEASURED is neither pass nor failure.
|
||||
- Write validation requires a distinct known-read-only control identity, supplied explicitly or configured in the declared estate. The control identity and its read-only permission are read back from the provider on every invocation; the configured name alone is not evidence.
|
||||
- `grant` invokes the same validation after mutation. HTTP 2xx and process exit status are never acceptance evidence.
|
||||
|
||||
## Machine result
|
||||
|
||||
`--json` writes exactly one non-secret JSON object to stdout. Human diagnostics go to stderr. Callers must decide from `outcome`, never by parsing prose.
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"operation": "grant",
|
||||
"outcome": "ok",
|
||||
"exitCode": 0,
|
||||
"retryable": false,
|
||||
"subject": {
|
||||
"identity": "seat-name",
|
||||
"estate": "estate-name",
|
||||
"host": "git.example.invalid",
|
||||
"repo": "owner/repo"
|
||||
},
|
||||
"mutation": "applied",
|
||||
"reason": {
|
||||
"code": "grant-verified",
|
||||
"message": "Grant matched all provider read-backs."
|
||||
},
|
||||
"evidence": {
|
||||
"providerIdentity": {
|
||||
"login": "seat-name",
|
||||
"endpoint": "GET /api/v1/user",
|
||||
"contentType": "application/json"
|
||||
},
|
||||
"tokenCapabilities": {
|
||||
"state": "not-measured",
|
||||
"scopes": [],
|
||||
"source": "runtime-not-authorized"
|
||||
},
|
||||
"repositoryPermission": {
|
||||
"requested": "write",
|
||||
"effective": "write",
|
||||
"endpoint": "GET /api/v1/repos/owner/repo",
|
||||
"contentType": "application/json"
|
||||
},
|
||||
"organizationMembership": {
|
||||
"state": "present"
|
||||
},
|
||||
"teamMembership": {
|
||||
"state": "not-applicable"
|
||||
},
|
||||
"writeDifferential": {
|
||||
"state": "can-write",
|
||||
"credentialBinding": "same-resolution",
|
||||
"transportPrincipal": "seat-name",
|
||||
"authenticatedReceivePack": "advertised",
|
||||
"readOnlyControl": {
|
||||
"identity": "read-only-control",
|
||||
"providerPermission": "read",
|
||||
"receivePack": "refused"
|
||||
},
|
||||
"unauthenticatedReceivePack": "refused",
|
||||
"artifactCreated": false,
|
||||
"proves": "One immutable credential resolution authenticated both the subject identity read-back and write transport; a provider-confirmed read-only principal and an unauthenticated caller were both refused.",
|
||||
"doesNotProve": "A particular ref update will pass branch protection, hooks, races, or content policy."
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"journalId": "opaque-id",
|
||||
"state": "sealed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fields may be `null` only when their enclosing evidence state explains why. Missing decision-relevant fields make the result `indeterminate`, never `ok`.
|
||||
|
||||
## Terminal classes
|
||||
|
||||
| Outcome | Exit | Meaning | Mutation guarantee | Caller action |
|
||||
| --------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `ok` | `0` | Requested property was established from provider objects and all required layers agree. | `validate`: `none`; `grant`: `applied` and read back. | Continue. |
|
||||
| `refused` | `10` | A complete, authoritative policy/access decision denied the request. Examples: estate-host mismatch, missing explicit identity, provider identity mismatch, explicit permission denial, or cross-estate subject. | `none`; refusal occurs before mutation. | Treat as a stable denial. Do not retry without changing authority/configuration. |
|
||||
| `error` | `20` | The command contract or local control failed before an access verdict. Examples: invalid arguments, malformed estate registry, insecure credential path, journal cannot be opened/fsynced, or internal invariant failure. | `none` unless `mutation` explicitly says `unknown`; `unknown` is never success. | Repair the tool/configuration. Do not reinterpret as access denial. |
|
||||
| `indeterminate` | `30` | The requested security property could not be evaluated completely or evidence disagreed. Examples: provider unavailable, wrong content type/shape, permission and receive-pack disagreement, missing post-grant read-back, or unknown mutation acknowledgement. Runtime scope `not-measured` remains a separately reported axis and is neither pass nor failure. | `none`, `applied`, or `unknown`, stated explicitly. Never infer. | Fail closed at the calling gate. Investigate/re-evaluate; do not label the subject refused. |
|
||||
|
||||
Parsing/usage errors emitted by Commander remain exit `2` and do not produce a broker verdict. Callers should treat them as integration defects, not access decisions.
|
||||
|
||||
## Refusal object
|
||||
|
||||
A refusal is intentionally recognizable without prose:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"operation": "validate",
|
||||
"outcome": "refused",
|
||||
"exitCode": 10,
|
||||
"retryable": false,
|
||||
"subject": {
|
||||
"identity": "external-seat",
|
||||
"estate": "homelab",
|
||||
"host": "git.example.invalid",
|
||||
"repo": "owner/repo"
|
||||
},
|
||||
"mutation": "none",
|
||||
"reason": {
|
||||
"code": "no-token-for-identity",
|
||||
"message": "The explicit identity has no credential in the declared estate."
|
||||
},
|
||||
"evidence": {
|
||||
"providerIdentity": null,
|
||||
"tokenCapabilities": {
|
||||
"state": "not-measured",
|
||||
"scopes": [],
|
||||
"source": "runtime-not-authorized"
|
||||
},
|
||||
"repositoryPermission": null,
|
||||
"organizationMembership": null,
|
||||
"teamMembership": null,
|
||||
"writeDifferential": null
|
||||
},
|
||||
"audit": {
|
||||
"journalId": "opaque-id",
|
||||
"state": "sealed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The git credential helper and API resolver must map the same subject/estate/host failure to the same `reason.code` and terminal class. MB-BRAIN-01 may assert this parity. A caller does not need to know which resolver path was used.
|
||||
|
||||
## Required reason codes
|
||||
|
||||
Stable v1 codes:
|
||||
|
||||
- refusal: `identity-required`, `estate-required`, `estate-host-mismatch`, `cross-estate-resolution`, `no-token-for-identity`, `tea-login-missing`, `tea-login-host-mismatch`, `provider-identity-mismatch`, `credential-rejected`, `permission-denied`, `organization-membership-required`, `team-membership-required`
|
||||
- error: `invalid-input`, `estate-registry-invalid`, `insecure-credential-source`, `journal-unavailable`, `internal-invariant`
|
||||
- indeterminate: `provider-unavailable`, `identity-not-visible`, `identity-not-measured`, `identity-not-found`, `unexpected-content-type`, `unexpected-provider-shape`, `scope-not-evaluable`, `permission-evidence-disagrees`, `transport-principal-mismatch`, `read-only-control-invalid`, `readback-missing`, `mutation-state-unknown`, `concurrent-mutation`, `mutation-lock-unavailable`, `mutation-lock-release-failed`, `team-scope-changed-during-grant`, `wire-audit-incomplete`
|
||||
|
||||
`provider-unavailable` means no usable provider answer was available. `identity-not-measured` means `/user` was scope-forbidden while an in-scope repository probe confirmed the credential capability; it is `indeterminate` only for the identity axis and must not be represented as a dead credential. `identity-not-visible` and `identity-not-found` are reserved for the unimplemented external inventory capability. `credential-rejected` means the provider rejected the credential itself (Gitea 401), which is a stable `refused` outcome. A 403 on `/user` is not credential rejection when an in-scope probe succeeds.
|
||||
|
||||
No anonymous or visibility-unprivileged 404 is admissible evidence of absence. `identity-not-found` requires, in the same invocation: (1) the visibility credential's own `/user` object read back as the configured authority with provider-admin visibility; (2) target lookup performed with that same authority; (3) a known-present PRIVATE control returning JSON 200 with matching login and `visibility=private`; and (4) a generated absent negative control returning JSON 404 under that same authority. Missing authority or any non-discriminating control yields `identity-not-visible`, never absence. A public positive control cannot certify private subjects.
|
||||
|
||||
No currently implemented operation may emit `identity-not-found`: the required governed inventory capability was deliberately declined and runtime validation must not acquire standing admin visibility. For `validate`, `/user` 401 means `credential-rejected`; `/user` 403/404 triggers the in-scope capability probe and, when that succeeds, identity is `identity-not-measured`; JSON 200 with a mismatched login is a binding refusal. A future inventory operation must meet every precondition above and receive an explicit privilege decision before making `identity-not-found` reachable.
|
||||
|
||||
Unknown future reason codes must still carry one of the four stable `outcome` values.
|
||||
|
||||
## Side-effect-free write differential
|
||||
|
||||
For Gitea v1, `validate --repo` resolves the subject credential exactly once into an immutable in-memory credential handle. The provider `/user` read-back, authenticated repository object, and Git smart-HTTP `git-receive-pack` advertisement all consume that same handle; callers may not perform independent lookups for those steps. The command also probes a separately resolved, provider-confirmed read-only control principal and repeats the request unauthenticated.
|
||||
|
||||
`can-write` requires all of the following:
|
||||
|
||||
1. provider `/user` login obtained with the subject credential handle equals `<identity>`;
|
||||
2. authenticated repository object obtained with that same handle reports write-capable permission;
|
||||
3. receive-pack obtained with that same handle returns the exact advertisement content type and protocol preamble;
|
||||
4. the transport evidence records the same declared principal as the identity read-back; any handle/principal seam disagreement is `transport-principal-mismatch` and therefore `indeterminate`, never refused;
|
||||
5. a distinct known-read-only credential resolves to its declared control identity, its provider repository object reports no write permission, and receive-pack is refused;
|
||||
6. the unauthenticated control is refused and does not return a receive-pack advertisement;
|
||||
7. estate, host, and repository in every request equal the declared subject.
|
||||
|
||||
The read-only control varies the mechanism under accusation: principal selection. The unauthenticated arm remains as a separate control proving authentication is required; it cannot establish which principal authenticated the subject probe. A missing, write-capable, identity-mismatched, or otherwise invalid read-only control makes the result `indeterminate`.
|
||||
|
||||
No ref is updated and no repository artifact is created. This proves that the declared subject credential—not merely some authenticated credential—can enter the write transport for that repository, while a provider-confirmed read-only principal and an unauthenticated caller cannot. It does not prove any specific branch update would survive branch protection, hooks, concurrent changes, or content policy.
|
||||
|
||||
## Grant read-back
|
||||
|
||||
A collaborator grant is accepted only when the provider returns the named collaborator permission and the subject credential independently reads the repository with matching effective permission. A team grant uses a same-UID-replaceable advisory lock to serialize cooperating `mosaic cred` mutations per provider team; this is optimistic coordination, not an authorization boundary or atomic CAS against direct filesystem mutation. Provider authority remains the authorization boundary. While holding that cooperative lock, the grant enumerates the team's complete repository attachment set both before and after mutation. It refuses before mutation when the team is already attached outside the one explicitly requested repository (`team-scope-exceeds-request`). If the post-mutation set is not exactly the requested repository, it returns `indeterminate` (`team-scope-changed-during-grant`) and compensates only state proven absent before the locked invocation: a newly introduced subject membership and/or requested repository attachment. Both compensations require provider absence read-back and are journaled; this is explicit all-or-verified-compensation behavior, not a cross-system atomic commit, and the operation never reports success from the stale pre-check. The grant then requires provider read-back of organization membership, team membership, team repository attachment, and effective subject permission. Token capability, repository permission, and organization/team role are reported as separate layers; no layer substitutes for another.
|
||||
@@ -1,95 +0,0 @@
|
||||
# RM-02 Gate Registry Implementation Plan
|
||||
|
||||
> **For Pi:** Use test-driven development and execute each task RED → GREEN → refactor.
|
||||
|
||||
**Goal:** Build a machine-readable seven-gate registry and an unconditional CI verifier that detects inert gates, binds criteria to observed negative controls, records defects honestly, and verifies the current PR tree unprivileged and fail-closed.
|
||||
|
||||
**Architecture:** A dependency-free Node CLI reads `gates/gates.manifest.json` and the same-checkout `gates/required-gates.baseline.json`, validates closed schemas and references, then runs typed cases in isolated main-disk fixtures. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary. Gate-specific fixture setup remains declarative; exact invocations, evidence-side subjects, and exact observed/required exits stay in JSON. The production verifier is structurally incapable of asserting history provenance; RM-60 owns the provider-controlled/protected pre-execution boundary.
|
||||
|
||||
**Tech Stack:** Node.js ESM, `node:test`, JSON, shell gates, pnpm, Woodpecker CI.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Meta-negative-control kernel
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `scripts/gate-verify.test.mjs`
|
||||
- Create: `scripts/gate-verify.mjs`
|
||||
|
||||
1. Write a black-box fixture whose gate failure branch has already been changed to success.
|
||||
2. Run `node --test scripts/gate-verify.test.mjs`; require failure because the absent verifier does not name the inert gate.
|
||||
3. Implement manifest loading, exact process execution, and named mismatch reporting only.
|
||||
4. Re-run and require the external inert mutation to produce verifier nonzero while the test passes.
|
||||
5. Add an internally applied declared mutation and require a healthy fixture to report its negative control armed.
|
||||
|
||||
### Task 2: Registry structural clauses
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `scripts/gate-verify.test.mjs`
|
||||
- Modify: `scripts/gate-verify.mjs`
|
||||
|
||||
Add failing tests, one behavior at a time, for: `unregistered gate`; `no negative control`; unbound criterion; unbound `GATE-CLAIM`; ownerless required/actual delta; stale/ambiguous/ineffective mutation; direct modeled conflict; missing meaning-change provenance. Implement the minimum validator after each observed RED.
|
||||
|
||||
### Task 3: Gate fixtures and seven-gate manifest
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `gates/gates.manifest.json`
|
||||
- Create: `gates/fixtures/quality-case.mjs`
|
||||
- Create: `gates/fixtures/preflight-case.mjs`
|
||||
- Create: `gates/fixtures/queue-case.sh`
|
||||
- Create: `gates/fixtures/hook-case.sh`
|
||||
- Modify: `scripts/gate-verify.test.mjs`
|
||||
|
||||
Register typecheck, lint, format, RM-01 preflight, queue guard, pre-commit, and pre-push. For each, first run its known-bad case against an intentionally inert fixture and observe verifier RED; then restore the real gate behavior and require its exact observed exit/reason. Record queue defects as required/actual deltas owned by RM-03, never as pass/green/OK.
|
||||
|
||||
### Task 4: Source-versus-deployed identity
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `gates/gates.manifest.json`
|
||||
- Modify: `scripts/gate-verify.test.mjs`
|
||||
- Modify: `scripts/gate-verify.mjs`
|
||||
|
||||
Write and observe a failing test with a byte-mutated deployed counterpart. Implement byte equality and internal drift mutation controls. Declare `none` explicitly for gates without deployed counterparts.
|
||||
|
||||
### Task 5: Prose claims and compatibility constructions
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/remediation/MISSION.md`
|
||||
- Modify: `docs/remediation/TASKS.md` only if coordinator authorization overrides the worker prohibition; otherwise place markers in an RM-02 claim index that references immutable source anchors.
|
||||
- Modify: `gates/gates.manifest.json`
|
||||
- Modify: verifier tests/implementation.
|
||||
|
||||
Enumerate current security/integrity claims, bind each marker/id to a negative case, and reject unbound markers. Execute finite compatibility scenarios and clearly document that arbitrary English consistency is outside the model.
|
||||
|
||||
### Task 6: Current-tree boundary and enforced history-provenance exclusion
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `gates/required-gates.baseline.json`
|
||||
- Create: `scripts/gate-history-exclusion-control.mjs`
|
||||
- Create: `scripts/gate-inventory-shrink-control.mjs`
|
||||
- Modify: `scripts/gate-verify.mjs`
|
||||
- Modify: `gates/gates.manifest.json`
|
||||
|
||||
Verify current-tree behavior only. Remove the anchor-dependent history verifier and provider-history consumption because PR-controlled lifecycle code executes before the gate and makes every local git anchor untrustworthy. Use a closed current-tree observation renderer and register a must-fail control that turns red if history/ancestry/lineage success becomes representable or production output bypasses renderer consumption. Compare the verifier inventory and manifest separately against the same-checkout baseline, register must-fail attacks that shrink either paired representation, and register an overclaim control. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary. State both directions: current-tree controls, inventory shape, and evidence subjects are enforced here; history provenance is not established until RM-60 provides a provider-controlled/protected pre-execution boundary.
|
||||
|
||||
### Task 7: CI and documentation
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `package.json`
|
||||
- Modify: `.woodpecker/ci.yml`
|
||||
- Create/update: `docs/DEVELOPER-GUIDE/quality-gate-registry.md`
|
||||
- Create/update: `docs/ADMIN-GUIDE/quality-gate-registry.md`
|
||||
- Modify: `docs/SITEMAP.md`
|
||||
|
||||
Add `gate:verify`; run it in an unconditional PR/main CI step. Document invocation, defect semantics, coverage/exclusions, marker syntax, replay/provider boundaries, and source/deployed identity.
|
||||
|
||||
### Task 8: Verification and delivery
|
||||
|
||||
Run focused tests, `pnpm gate:verify`, checkout tests, typecheck, lint, format, and applicable integration tests. Run Codex code and security review; remediate and re-review. Verify authorship, commit, execute queue guard before push, push, create PR via Mosaic wrapper, and send exact-head review request to rev-974 through the coordinator. Do not merge without coordinator authorization.
|
||||
@@ -1,40 +0,0 @@
|
||||
# RM-02 Governing Claim Index
|
||||
|
||||
This index binds remediation claims that live in orchestrator-owned `TASKS.md` without modifying that file. The source heading and quoted text are the reviewable anchor; `gate:verify` enforces each marker-to-criterion binding. Marker completeness beyond this approved slice remains RM-54.
|
||||
|
||||
## Prose is an enforceable claim
|
||||
|
||||
<!-- GATE-CLAIM:PROSE-IS-A-CLAIM -->
|
||||
|
||||
- Source: `docs/remediation/TASKS.md`, heading `D-20 — the orchestrator's own documentation overclaimed, and a reviewer disproved it empirically`.
|
||||
- Anchored text: “The defect was not in the code — it was in this file.”
|
||||
|
||||
## Generated-state verification scope
|
||||
|
||||
<!-- GATE-CLAIM:GENERATED-STATE-SCOPE -->
|
||||
|
||||
- Source: `docs/remediation/TASKS.md`, heading `D-19 — an integrity property that cannot exist at the layer it was specified`.
|
||||
- Anchored text: “a verifier that cannot detect the attack is not a verifier.”
|
||||
|
||||
## Execution trust boundary
|
||||
|
||||
<!-- GATE-CLAIM:EXECUTION-TRUST-BOUNDARY -->
|
||||
|
||||
- Source: RM-02 ruling recorded under `docs/remediation/TASKS.md`, RM-02 clause 4, D-25.
|
||||
- Anchored text: “SELF-VERIFICATION BY THE AUDITED PARTY IS NOT VERIFICATION.”
|
||||
- Dependency: RM-60/#1031, cross-referenced with RM-59.
|
||||
|
||||
## Same-checkout inventory drift boundary
|
||||
|
||||
<!-- GATE-CLAIM:INVENTORY-DRIFT-BOUNDARY -->
|
||||
|
||||
- Source: RM-02 ruling after D-48/CWE-353 reproduced against the round-4 baseline.
|
||||
- Boundary: Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.
|
||||
- Negative control: `checkout-preflight/inventory-claim-overstatement` rewrites the boundary as protection and must go red.
|
||||
|
||||
## Criterion restatement provenance
|
||||
|
||||
<!-- GATE-CLAIM:CRITERION-RESTATEMENT -->
|
||||
|
||||
- Source: `docs/remediation/TASKS.md`, heading `D-18 — two pre-registered criteria were mutually unsatisfiable, discoverable only at implementation`.
|
||||
- Anchored text: “Both criteria were pre-registered. They cannot both be satisfied.”
|
||||
@@ -12,8 +12,6 @@ gate/program; the LLM handles only genuine judgment.
|
||||
|
||||
### First-class principle — observe the property, not the exit code
|
||||
|
||||
<!-- GATE-CLAIM:OBSERVE-PROPERTY -->
|
||||
|
||||
> **No write is done until the requested PROPERTY is observed. A success exit code is not evidence.**
|
||||
>
|
||||
> **Success output is designed to be believed.** That is the whole reason the inert-gate class exists
|
||||
@@ -32,8 +30,6 @@ gate/program; the LLM handles only genuine judgment.
|
||||
|
||||
### First-class principle — pre-registration prevents retrofitting, and nothing else
|
||||
|
||||
<!-- GATE-CLAIM:PREREGISTRATION-BOUNDARY -->
|
||||
|
||||
> **A pre-registered check set can fail in three distinct ways:**
|
||||
>
|
||||
> | mode | the set is… | found as |
|
||||
@@ -60,8 +56,6 @@ gate/program; the LLM handles only genuine judgment.
|
||||
|
||||
### Corollary — never ship an integrity claim dressed as a property
|
||||
|
||||
<!-- GATE-CLAIM:ARTIFACT-INTEGRITY-BOUNDARY -->
|
||||
|
||||
> A verification artifact that can be forged by whoever it is meant to catch verifies nothing. If a
|
||||
> manifest, marker, ledger, or receipt is writable by the same actor whose behaviour it certifies, it
|
||||
> **certifies the attack.** Such an artifact must sit inside the integrity envelope it belongs to,
|
||||
@@ -74,8 +68,6 @@ gate/program; the LLM handles only genuine judgment.
|
||||
|
||||
### First-class principle — when a property cannot exist at the layer it was specified
|
||||
|
||||
<!-- GATE-CLAIM:IMPOSSIBLE-LAYER-BOUNDARY -->
|
||||
|
||||
> Some required properties are **impossible at the layer that asked for them** — not hard, impossible.
|
||||
> A local check cannot defend against an actor who can rewrite the check itself. When that happens,
|
||||
> there are exactly three honest moves, and all three are mandatory:
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# #1019 — Zero-timeout queue-guard harness race
|
||||
|
||||
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
|
||||
- **Branch:** `fix/1019-ci-queue-timeout-harness`
|
||||
- **Owner:** `be-coder-08`
|
||||
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
|
||||
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
|
||||
|
||||
## Objective
|
||||
|
||||
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
|
||||
|
||||
## Scope
|
||||
|
||||
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
|
||||
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. RED deterministically reproduces deadline pre-emption before the provider call.
|
||||
2. Every case that intends status classification positively proves provider observation.
|
||||
3. Pending observes `pending` before deterministic virtual-time expiration.
|
||||
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
|
||||
5. The exact CI-base image passes the final harness repeatedly with zero failures.
|
||||
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
|
||||
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
|
||||
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
|
||||
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
|
||||
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
|
||||
|
||||
## Budget
|
||||
|
||||
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
|
||||
|
||||
## Evidence
|
||||
|
||||
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
|
||||
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
|
||||
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
|
||||
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log` — **100 pass / 0 fail / 100 total**.
|
||||
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
|
||||
|
||||
## Review remediation — semantic timeout vs. liveness bound
|
||||
|
||||
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
|
||||
|
||||
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
|
||||
|
||||
Remediation:
|
||||
|
||||
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
|
||||
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
|
||||
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
|
||||
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
|
||||
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
|
||||
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
|
||||
|
||||
Post-review evidence:
|
||||
|
||||
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
|
||||
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
|
||||
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
|
||||
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
|
||||
|
||||
## 60% context hold
|
||||
|
||||
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
|
||||
@@ -1,107 +0,0 @@
|
||||
# RM-02 Gate Registry Scratchpad (#1029)
|
||||
|
||||
## Objective
|
||||
|
||||
Deliver the seven-gate registry and RED-first anti-inert verifier on `feat/rm-02-gate-registry`, preserving required-versus-actual defects without laundering them as success.
|
||||
|
||||
## Constraints and boundaries
|
||||
|
||||
- Do not modify `ci-queue-wait.sh`; RM-03 owns that fix.
|
||||
- Do not modify `docs/remediation/TASKS.md`; workers cannot edit orchestrator tracking.
|
||||
- Every declared behavior must be observed, not inferred from an exit code.
|
||||
- Repository-local evidence does not establish same-authority tamper resistance; RM-25/RM-59 own the external trust anchor.
|
||||
- Coverage is seven logical gates; broader inventory is RM-54.
|
||||
- Budget assumption: no explicit token ceiling was supplied. Keep implementation dependency-free (stock Node), avoid repeated full monorepo installs, and keep generated test artifacts under the worktree/main disk.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Update PRD and tracking references.
|
||||
2. Write black-box meta-negative-control first and observe it fail for the missing verifier behavior.
|
||||
3. Implement minimal manifest parser/case runner/mutation detector; observe meta-control succeed.
|
||||
4. Add schema, coverage, provenance, prose marker, compatibility, discovery, deployment identity, and defect-delta tests RED-first.
|
||||
5. Register seven gates with exact cases and run each case.
|
||||
6. Add prospective per-commit replay and bounded provider-evidence reporting.
|
||||
7. Wire unconditional Woodpecker CI and update developer/admin documentation.
|
||||
8. Run situational and baseline verification, independent Codex review, remediate, commit, queue guard, push, PR, coordinator/reviewer handoff.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-08-01: Design approved by `mos-remediation`; rulings A-E and source/deployed identity addition incorporated.
|
||||
- 2026-08-01: Isolated worktree created from `origin/main` f65e9ea6; RM-01 f58b3699 verified as ancestor.
|
||||
- 2026-08-01: Git author set to `f10-coder <[email protected]>`; provider issue #1029 created with `MOSAIC_GIT_IDENTITY=f10-coder`.
|
||||
- 2026-08-01: Source/deployed queue-guard SHA-256 observed equal (`19cda2f...`); this observation is not yet an enforced property.
|
||||
|
||||
## Tests and RED-first evidence
|
||||
|
||||
- Meta-negative RED first: `node --test scripts/gate-verify.test.mjs` failed because the absent verifier did not name externally inerted `meta-fixture`.
|
||||
- Structural RED: nine tests failed before implementation for internal mutation, unregistered executable, missing negative control, ownerless delta, unbound criterion/claim, modeled conflict, missing provenance, and deployment drift.
|
||||
- Clause hardening RED: positive-only security criterion and missing registered claim marker both passed incorrectly before validation was added.
|
||||
- CI wiring RED: package script and unconditional Woodpecker step tests both failed before wiring.
|
||||
- History RED: history test failed with missing module before own-tree manifest selection/provider classification was implemented.
|
||||
- `pnpm gate:verify`: exit 0; seven gates each reported `META-NEGATIVE-CONTROL ... observed red`; queue source/deployed drift control observed red; six queue behavior deltas printed as `DEFECT (owner: RM-03)`.
|
||||
- Focused Node tests: 54/54 pass after third-round hardening (36 verifier/wiring plus 18 history/provider tests).
|
||||
- `pnpm typecheck`: pass (45/45 Turbo tasks).
|
||||
- `pnpm lint`: pass (25/25 Turbo tasks).
|
||||
- `pnpm format:check`: pass.
|
||||
- `pnpm test`: repository suites reached 45/46 Turbo tasks; all application/package tests shown passed, then the known host-specific wake assertion aborted exit 97 (`BASH_LINENO convention violated`, #973/D-16). No test was edited or bypassed. CI remains the authoritative full-suite environment.
|
||||
|
||||
## Self-surfaced defect
|
||||
|
||||
The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while provider JSON is piped to the shell function. The heredoc owns stdin, so Python never reads provider JSON. Terminal success, no-status, terminal failure, and malformed payloads all classify as `unknown`; the associated fail-open outcomes are recorded under RM-03. No queue-guard source was modified.
|
||||
|
||||
## Independent review remediation
|
||||
|
||||
- Final pre-commit Codex code review found three blockers: a tautological deployment drift control, independently observed rather than combined compatibility cases, and unauthorized edits to orchestrator-owned `TASKS.md`.
|
||||
- Deployment identity now uses one shared file comparator for both live equality and a temporary drifted deployed copy; a test makes that comparator inert and proves the meta-control fails.
|
||||
- Compatibility scenarios now merge referenced fixtures/environments into one isolated construction and execute an exact scenario invocation; a test proves two independently valid fixtures coexist in the combined run.
|
||||
- `TASKS.md` changes were reverted. `docs/remediation/GATE-CLAIMS.md` binds source headings and anchored text without editing orchestrator tracking.
|
||||
- Codex security review found the Bubblewrap replay shared the runner PID namespace. Replay now unshares PID, IPC, and UTS namespaces, and an abuse-case test proves a sibling runner PID is invisible.
|
||||
- Second review found empty reason diagnostics, final-symlink fixture writes, and lifecycle-script mutation of authoritative history files. Must-fail cases now require a reason pattern; writes use no-follow semantics; and replay snapshots every archived file before install and rejects any changed, deleted, or type/mode-shifted source before executing the verifier. Dedicated negative tests cover all three.
|
||||
- Third code review found ambiguous duplicate provider steps and order-sensitive JSON outcome comparison. Provider evidence now requires exactly one `gate-verify` step in the authoritative rerun, and structural equality normalizes object keys. Both regressions have RED-first tests. Third security review reported no findings.
|
||||
- Initial PR pipeline #2177 exposed Woodpecker's shallow boundary: the activation parent object was present but marked shallow, so `merge-base --is-ancestor` correctly refused to infer ancestry. The unconditional gate step now unshallows before ancestry/provenance checks; its wiring test was observed RED before the CI fix.
|
||||
- Pipeline #2178 then proved the unprivileged Docker runner cannot establish Bubblewrap namespaces. A privileged experiment remained uncommitted and was rejected after Codex correctly rated it CRITICAL: PR-controlled code executes before an in-repository sandbox and could directly use the granted capability.
|
||||
- `mos-remediation` and `rev-974` independently ruled Option C. RM02-REQ-10 now retains its original text, restatement, and reason: PR CI verifies only the current tree, unprivileged and fail-closed; isolated own-tree replay is deferred to RM-60/#1031's external pre-execution authority, cross-referenced with RM-59. Future protected post-merge replay is detection with quarantine/revert, never pre-merge prevention.
|
||||
- RED-first boundary test proved the old path executed an inert intermediate verifier. The revised path states adjacent `DOES`/`DOES NOT` claims, validates historical manifest provenance without executing it, and infers no replay success. Direct sandbox tests remain hard-fail; unprivileged CI asserts terminal refusal instead of treating replay as success. Pipelines #2179/#2180/#2181 exposed two runner refusal forms: namespace denial as `spawnSync bwrap` with `error.code=EPERM`, and a test image without Bubblewrap as `error.code=ENOENT`. The replay diagnostic now preserves spawn errors. Parent-generated launcher/entry metadata distinguishes refusal before sandbox entry from child-controlled output; the detector recognizes exact `spawnSync bwrap` provenance for `EPERM`/`EACCES`/`ENOENT` and known namespace-refusal text only when the entry command provably did not run. Focused negative assertions reject unrelated `spawnSync git EPERM`, verifier output that merely says `bwrap ENOENT`, and exact namespace-denial impersonation without provenance or after sandbox entry.
|
||||
- Exact-head independent review at `38f1b249` found one valid diagnostic-masking blocker: canonical verification knew four stable-ID rebinding failures but a thrown stale fixture replacement reached the outer catch first and emitted only the generic error. RED-first reproduction confirmed the canonical path omitted both responsible criterion IDs. Verification now collects labeled failures independently across claims, discovery, deployment, case execution/outcome checks, mutation, and compatibility, preserving structural stable-ID failures alongside the stale-fixture signal. The canonical regression test requires both missing-binding IDs and the generic fixture error.
|
||||
- Exact-head independent review at `9b4d4beb` found two valid blockers. RED-first controls reproduced both: denial-looking child stderr was accepted as sandbox unavailability, and moving meaning/prose criterion IDs to an unrelated type-error case left `gate:verify` green. Bubblewrap execution now emits a parent-generated random entry marker and returns parent-owned launcher/entry metadata; unavailability requires Bubblewrap launcher provenance plus proof entry never ran, so exact denial impersonation from plain or entered-child results is rejected. Criterion objects now declare exact `caseRefs`, checked bidirectionally against case-side `criterionIds`; prose claims declare an exact must-fail `caseRef`. Registered must-fail cases move a criterion binding, remove meaning provenance, and redirect a prose claim, each producing its stable reason. The review freeze was deliberately lifted before remediation.
|
||||
- Option C security review reported no findings. Code review rejected an initial unrelated typecheck binding for the new security criterion. It was replaced with a dedicated registered `privileged-pr-gate` case: the fixture injects a privilege key into the gate step, the wiring control rejects it for that exact reason, and `gate:verify` observes the boundary negative control. Follow-up hardening uses a closed exact gate-step construction, rejects privilege across the entire pipeline, rejects non-canonical/merged YAML keys, and pins the unrestricted PR/main trigger block; quoted/escaped/alias/merge/duplicate/filter bypass tests pass. Final Codex code review approved with no findings.
|
||||
|
||||
- Exact-head review at `83d2ecb2` found four silent-defeat paths. Genuine RED-first tests on the pre-fix code proved: author-controlled HEAD/parent/introduction seams produced no boundary failure; cross-commit duplicate pipeline number 7 returned terminal-success; and misspelling `outputPattern` as `outputPatern` in required/actual left canonical verification green. Fixes derive the seam from Git, validate provider identity globally before subject filtering, and recursively close/type-check nested schemas. New registry criteria `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, and `RM02-HISTORY-BOUNDARY` are bidirectionally bound to eight registered must-fail controls. The broad nested-object typo table and exact derived-boundary test are regression guards added after implementation, not claimed as RED-first. Pre-commit Codex review then found that global evidence failure was only observed—not failed—when HEAD was the sole prospective commit, and that non-object collection entries threw before normalization. Both were reproduced RED-first, then fixed by one collection validator used before iteration and by per-commit assessment. Follow-up review found collection validation still omitted exactly-one-gate-step cardinality for unrelated subjects; a focused test reproduced terminal-success RED-first, and collection validation now rejects that ambiguity globally. Security review then found present-but-empty outcome patterns were truthy-optional assertion bypasses; a reason-specific test reproduced that they lacked the required schema diagnostic, and present pattern fields now require non-whitespace content.
|
||||
|
||||
- Exact-head review at `32b490a7` found three population-level blockers. Genuine RED-first tests proved a gate change before author-delayed registry introduction fell outside the range, and empty criteria/gates/prose/scenario populations returned zero. History now anchors at the provider target merge-base; delayed introduction is a registered must-fail control. Production verification requires non-empty populations and a hardcoded seven-gate ID/source inventory before quantified checks. D-38/D-40 criteria now quantify over `gateRefs` exactly spanning every registered gate; each gate declares its evidence subject, and population controls mutate evidence subject and comparison type for every gate. The history record states both bootstrap directions: sound against a branch author unable to rewrite main, not sound against compromised/rewritten main, with residual owned by Builds 1-2. Pre-commit review rejected an initial production CLI `--fixture-profile` test relaxation as a vacuity bypass. That flag was removed; synthetic fixtures now use a non-executable test-support runner, while regression tests prove the shipped CLI rejects the flag and production population checks remain mandatory. Follow-up review then proved deleting `gateRefs` skipped population validation; a RED-first loop reproduced all three deletions, and the three general criterion IDs now require the field before exact-span validation.
|
||||
|
||||
## Documentation checklist
|
||||
|
||||
- PRD, developer guide, admin guide, governing claim index, sitemap, plan, and scratchpad updated.
|
||||
- User/API documentation not applicable: no user workflow or API changed.
|
||||
- Independent review documentation check pending rev-974 at the revised exact head.
|
||||
- Canonical documentation remains in-repository; no external publication requested.
|
||||
|
||||
## Rebase onto RM-61
|
||||
|
||||
- Rebasing `f9746b23` onto main `f4fd5967` completed mechanically with no conflicts.
|
||||
- The first post-rebase `pnpm gate:verify` correctly failed because the old activation seam `f65e9ea6` made the newly merged pre-registry RM-61 commit part of prospective history even though that commit predates the registry. An initial manifest update to `f4fd5967` had the right value but retained an author-controlled mechanism. Independent mutation proved HEAD, HEAD's parent, and the introduction commit could each make history coverage vacuous or partial. The manifest field is now forbidden; the verifier derives the boundary as the parent of the first first-parent registry-introduction commit, and registered must-fail controls reject all three unsafe candidates.
|
||||
|
||||
## Risks/blockers
|
||||
|
||||
- Current queue guard intentionally has required-versus-actual deltas owned by RM-03.
|
||||
- Provider CI cannot report the currently executing pipeline as terminal success; current-commit evidence must be labeled pending and becomes historical current-tree evidence only after provider completion.
|
||||
- Isolated per-commit execution requires RM-60/#1031. Until that external authority exists, no replay success is claimed. A future protected post-merge failure requires quarantine/revert.
|
||||
- CI containers may not expose the operator-home deployed queue guard. In that layer the verifier checks the pinned observed digest and reports live identity unavailable under RM-04; it does not infer live equality.
|
||||
|
||||
## coder-mos2 remediation — fourth round
|
||||
|
||||
- Coordinator correction loaded: Blocker 2 requires a genuine shrink-both control against an independent inventory baseline; Blocker 3 requires evidence-side subjects consumed against gate definitions for every gate; anchor-dependent history provenance must be removed rather than relabelled because PR lifecycle code makes all local git state untrustworthy before verification.
|
||||
- Boundary to preserve in both directions: this repository layer DOES verify current-tree registered cases, independently anchored inventory shape, evidence subject consumption, and an enforced absence of history-provenance claims. It DOES NOT establish history provenance at all. RM-60 owns the provider-controlled/protected pre-execution boundary required for that property; no local ref, config, URL, constant, or author-positioned path is treated as an anchor.
|
||||
- TDD plan: add three genuine red-first controls before implementation: shrink verifier inventory plus manifest together; mutate evidence-side subject for every gate; and reject the currently enabled history verifier/import/report path. Existing controls retained as regression guards and labelled honestly.
|
||||
- Identity observation before first commit: `git var GIT_AUTHOR_IDENT` returned `coder-mos2 <[email protected]>` using process-scoped author/committer variables; shared repository config was not modified.
|
||||
- Genuine RED-first evidence: `node --test scripts/gate-remediation.test.mjs` produced 0/3 passing on frozen head `fbb61912`: source+manifest shrink exited zero, no evidence-side case subjects existed, and the production verifier still imported/invoked history verification. These were failures for the three stated blocker reasons, not regression guards.
|
||||
- Sixth mutation found before review: after the first baseline implementation, shrinking the new baseline and manifest together while leaving the verifier inventory unchanged still exited zero. A new test observed that attack RED first. Inventory equality diagnostics are now bidirectional, and the registered shrink control attacks both source+manifest and baseline+manifest pairs.
|
||||
- Regression guards retained and honestly labelled: manifest-only shrink, exact `gateRefs` span, production fixture-profile rejection, and broad nested schema checks already blocked before this round.
|
||||
- Current focused evidence before independent review: remediation controls 4/4; verifier/wiring/remediation suite 39/39; canonical `pnpm gate:verify` exits zero while reporting six RM-03-owned `DEFECT` deltas and all seven gate meta-negative controls observed red.
|
||||
- Independent Codex code review requested changes on two valid blockers. First, the evidence population control called the subject helper directly rather than traversing production result consumption. It now creates one lightweight executed fixture per required gate, invokes the real `verifyRegistry` path, and fails to observe rejection if the production consumer is removed; a regression mutation proves that coupling. Second, a lexical history blacklist overclaimed structural incapacity. Production output now passes through a closed current-tree observation renderer with no history/ancestry/lineage success class; the exclusion control tests three alternate success wordings plus exact production renderer wiring, and the registered must-fail fixture adds a prohibited class.
|
||||
- Codex review test attempts were unrunnable in its read-only sandbox (`EROFS`/`EPERM`); the reviewer disclosed this rather than substituting a passing variant. Local writable-worktree tests remain the runnable evidence.
|
||||
- Renderer remediation: every non-error production observation and final summary now routes through the closed current-tree output renderer. The exclusion control additionally requires one stdout sink, exactly two `GATE VERIFY FAILED` stderr sinks, no console sinks, and renderer use for both per-observation and final-summary paths. Regression attacks cover allowlisted history/ancestry/lineage wording, writer assertion removal, direct final-success stdout, and direct success stderr; focused suite 41/41, `pnpm gate:verify` green with six declared RM-03 deltas, and format check green.
|
||||
- Coordinator ruling after CWE-353: retain the same-checkout inventory comparison but narrow its claim. Canonical current boundary: “Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.” This supersedes the earlier “independent/anchored” wording in the historical entries above; those entries remain as append-only evidence, not current claims.
|
||||
- Genuine RED-first overclaim control: before claim remediation, `node scripts/gate-inventory-claim-control.mjs` exited 84 and named every artifact missing the narrowed boundary plus each current overclaim. The registered `inventory-claim-overstatement` case now rewrites the baseline purpose to a protection claim and must exit 84 with `INVENTORY_CLAIM_OVERSTATED`. Existing shrink-pair controls remain regression guards for the same-checkout drift property, not adversarial-integrity claims.
|
||||
- Follow-up code review found a genuine propagation/control gap: `docs/PRD.md` still said “independent seven-gate baseline,” and the first forbidden regex did not match the intervening qualifier. The reviewer observed the control green against that overclaim. The PRD now says same-checkout, the detector rejects `independent … baseline` across bounded same-line qualifiers, the registered mutation uses the exact qualified wording, and a focused behavioral test requires exit 84 for it. Follow-up security review passed with risk `none` and explicitly accepted the narrowed RM-60 boundary.
|
||||
@@ -0,0 +1,110 @@
|
||||
# MC-CRED-01 / stack #1045 scratchpad
|
||||
|
||||
Last updated: 2026-08-05
|
||||
|
||||
## Successor remediation (`be-coder-07`, PR #1059 review id 89)
|
||||
|
||||
Objective: close the four exact-head `rev-974` blockers without weakening any assertion, repair canonical-image test portability, and refresh the narrowed cooperative-concurrency claims and PR metadata. The provider-fetched starting head is `12958610cbafaa54a3db95327a7c3453d9111669` at merge-base `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`.
|
||||
|
||||
Plan:
|
||||
|
||||
1. Preserve the journal-lock release assertion while replacing Alpine-invalid `/usr/bin/true` with canonical `/bin/true`.
|
||||
2. Replace cross-system atomicity claims with exact all-or-verified-compensation semantics; retain atomic wording only for explicitly scoped single-file rename/replacement primitives and qualify team locking as cooperative advisory serialization.
|
||||
3. Add red-first controls for complete journal writes under one-byte progress, rejection of zero/invalid progress, Tea generation equivalence under recursive key reorder, and durable indeterminate classification when team-lock release cannot be verified.
|
||||
4. Implement bounded journal write-all, recursive canonical generation serialization, and release-before-seal team-lock cleanup classification; then run focused and baseline gates plus code/security advisories.
|
||||
5. Commit with explicit `be-coder-07` identity, rebase rather than merge onto current `origin/main`, prove stable patch identity, run the required queue/direct-CI guards, and make one force-with-lease push pinned to the measured starting head.
|
||||
|
||||
Budget: hard context ceiling is 60%. Reuse predecessor evidence and avoid re-deriving unrelated 41-file history. Non-blocking secret zeroization is included only after all blockers are green. Stop and write a seam/report before the ceiling.
|
||||
|
||||
## Objective
|
||||
|
||||
Deliver the governed `mosaic cred` identity boundary for issue, scope, validation, rotation, and revocation across explicitly declared estates. The trunk-only ruling superseded the original `next` checkpoint: the branch is rebased onto `origin/main` and its PR target is `main`. Linked issues remain **believed-fixed, pending jarvis validation** after merge.
|
||||
|
||||
## Requirements sources
|
||||
|
||||
- Charter: `/home/hermes/agent-work/tl-mosaic/CHARTER-MC-CRED-01-be-coder-06.md`
|
||||
- Stack issues: #1045, #1043, #1044, #1047, #1049, #1013, #1007; promotion #1037; consumer #1051
|
||||
- Remote spec: `jason.woltje/jarvis-brain` origin/main `b7687d51f4efe52e43dbcd6dc95b5554b3332957`
|
||||
- Greenfield PRD v3 addenda: INV-B durable journal, INV-C visible failure diagnostics, INV-D supported fixture
|
||||
- Binding doctrine: `/src/jarvis-brain/infra/fleet/FLEET-DOCTRINE.md`
|
||||
|
||||
## Plan
|
||||
|
||||
1. Publish grant/validate v1 caller contract for MB-BRAIN-01.
|
||||
2. Add repo PRD requirements and preregister acceptance tests.
|
||||
3. Implement explicit estate registry, secure current file-store adapter, durable operation journal/audit, provider transport, and terminal result types.
|
||||
4. Implement `grant` and side-effect-free `validate`; then provision/wire/get/whoami/list/rotate/revoke/audit.
|
||||
5. Make git and API resolver refusals identical and fail closed under fleet context.
|
||||
6. Reconcile live HOMELAB seats through each subject credential's own `/user`; #1044 hold is lifted, and its fail-closed change carries the pre-registered mechanism evidence (resolver refusal marker, same-run marker positive control, confirmed-lane negative arm).
|
||||
7. Run baseline/situational tests, independent code review and mandatory independent security review, CI on the exact head, then open the PR directly against `main` without closing issues or claiming Jarvis validation.
|
||||
8. C1 merges first. After any base/head move, re-derive merge-base, commit set, diff, CI, reviews, and provider measurements from the replacement SHA.
|
||||
|
||||
## Budget
|
||||
|
||||
No explicit token cap supplied. Working cap: keep implementation in one package plus shipped framework resolver changes and required docs/tests; avoid unrelated wrapper defect fixes and VaultWarden redesign. Escalate only if a charter requirement is technically unsatisfiable.
|
||||
|
||||
## Decisions
|
||||
|
||||
- VaultWarden is out for the agent tier per the charter verdict; phase 1 governs the existing per-identity file store.
|
||||
- Estate is explicit input and must match a configured host mapping; target host is never inferred from machine location.
|
||||
- Grant authority and basic-auth provisioning material are delegated control-plane credentials, never caller bearer material and never CLI argument/output.
|
||||
- `ok`, `refused`, `error`, and `indeterminate` are distinct machine outcomes. Security callers fail closed on all but `ok`, while retaining the semantic distinction.
|
||||
- Gitea write-differential resolves the subject once and binds provider identity, repository permission, and receive-pack to the same in-memory credential handle. It adds a distinct provider-confirmed read-only-principal control plus the unauthenticated control, with no ref update. The live HOMELAB negative-control subject is `tl-mosaic`, verified read-only on `mosaicstack/stack`; code and contract remain principal-agnostic.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Mode/intake/core guides/skills/doctrine loaded.
|
||||
- [x] Spec repository READ confirmed under be-coder-06 from provider object.
|
||||
- [x] Target-branch conflict resolved by the trunk-only ruling; the lane was rebased from `next` onto `origin/main`.
|
||||
- [x] Canonical remote PRD v3 addenda re-read at new head.
|
||||
- [x] Required issues read via Mosaic wrapper.
|
||||
- [x] Early grant/validate contract v1 published at `docs/credentials/GRANT-VALIDATE-CONTRACT.md`.
|
||||
- [x] Contract v1.1 binds transport to the same resolved principal and adds a provider-confirmed read-only-principal control.
|
||||
- [x] Contract v1.2 distinguishes provider outage, absent identity, and rejected credential.
|
||||
- [x] Contract v1.3 positive-controlled anonymous visibility; subsequently withdrawn as unsound for private identities.
|
||||
- [x] Contract v1.4 implements ruling (b): subject credential's own `/user`, no admin/inventory authority, no implemented `identity-not-found` path.
|
||||
- [x] PRD update.
|
||||
- [x] Red-first principal-bound validate, estate-registry, file-store, provider-transport, and journal tests.
|
||||
- [x] Implementation: validate, direct/team grant, protected delegated authority, provision/wire/get/whoami/list/rotate/revoke/audit, reverse registry, and fleet fail-closed resolver paths.
|
||||
- [x] Review hardening: rotation returns visible open journals; team evidence records absent objects accurately; team scope is checked before/after under a host-qualified OS advisory lock with verified compensation; `wire` authenticates the exact seat/path/roster binding and preserves post-rename mutation semantics.
|
||||
- [x] Author advisory remediation: provisioning rollback verifies provider/token-store/Tea state and cannot suppress Tea cleanup failure; Tea put/remove always unlink secret-bearing temporary files; unrelated hosts exit before Mosaic identity grammar; `cred get` persists issuance start before disclosure, writes through a short-write-safe loop, and preserves partial/applied indeterminate semantics under write/append/seal faults; provision/rotate/revoke share a fixed cross-state-root lifecycle lock for cooperating processes only (provider authority is the authorization boundary; generation preconditions are optimistic for cooperating mutators; hostile same-UID filesystem mutation is explicitly deferred); Tea pre-state is snapshotted/restored independently; final journal seal faults revert to open classification.
|
||||
- [ ] Legacy-token estate binding is explicitly deferred: the legacy format contains no estate and the installed shell path has no distributed strict registry. Enabling refusal before registry distribution plus token-envelope migration would strand active legacy-only seats; governed envelopes already bind estate exactly.
|
||||
- [ ] Hostile same-UID direct filesystem mutation is explicitly deferred to #1065. The finding is copied verbatim there; PR #1059 links it. Phase 1 claims only optimistic generation preconditions and advisory locking for cooperating mutators, with provider authority as the authorization boundary.
|
||||
- [ ] Independent code/security approvals on the final exact head (Codex advisory iterations are not independent approval).
|
||||
- [ ] Final exact-head CI and provider evidence.
|
||||
|
||||
## Tests and evidence
|
||||
|
||||
Baseline after workspace build: package typecheck passed; Vitest 81/81 files and 1,514/1,514 tests passed. The package shell suite reached a pre-existing tracked #973 Bash 5.2 BASH_LINENO incompatibility and exited 97 before wake tests; this is baseline, not introduced by MC-CRED.
|
||||
|
||||
Red-first evidence:
|
||||
- principal-bound validate module absent → focused suite red;
|
||||
- incremental v1.1 run: write-capable, identity-mismatched, and receive-pack-admitted read-only controls each returned `ok`, causing 3/13 tests to fail for the exact control defect; after the control checks, 13/13 passed;
|
||||
- read validation absent → 2 tests failed `evaluateGiteaReadValidation is not a function`; after implementation, 15/15 validate tests passed;
|
||||
- estate registry, secure file resolver, Gitea transport, and audit journal each failed first because the module did not exist, then passed focused behavior suites.
|
||||
|
||||
Pre-remediation focused evidence: 80/80 across 11 credential/command suites; package lint, typecheck, formatting, and build were green. The advisory remediation adds thirty-five focused regression cases for rollback cleanup, Tea temporary-file cleanup, unsupported-host passthrough, and credential issuance partial-write/post-write-audit truthfulness; focused reruns are green (latest credential/command set: 109/109). Final uncommitted author advisories report code `approve` with no findings and security `none` with no findings; they remain advisory rather than independent approval. Full package Vitest reached 1,614 passing tests and three unrelated CLI-smoke failures caused solely by the installed-version update banner writing to stderr. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and use atomic rename only for each explicitly scoped single-file replacement primitive; cross-system lifecycle completion uses verified compensation rather than an atomic-commit claim; grant mutation/read-back state is journaled.
|
||||
|
||||
Fail-closed resolver evidence: synthetic missing-token API and git paths each emitted stable `MOSAIC_CREDENTIAL_REFUSAL` with `reason=no-token-for-identity` and `shared_path_entered=false`; all 13 live token-bearing identities bypassed the shared path without over-fire in the same run. Evidence: `/home/hermes/agent-work/be-coder-06/review-evidence/failclosed-postcondition.jsonl`; independent verification remains tl-mosaic's obligation.
|
||||
|
||||
Live validation v1.4 (subject credential's own `/user`, no admin): population 13; CONFIRMED 8; CREDENTIAL-REJECTED 4 (`coder-mos1`, `coder-mos2`, `f10-coder`, `merge-gate`); MISMATCH 1 (`mos-admin` token authenticates as `Mos`); NOT-MEASURED 0. The four false v1.2 `identity-not-found` sealed journals remain immutable and are explicitly superseded by four sealed correction journals. Evidence: `/home/hermes/agent-work/be-coder-06/live-validation-v1.4/`.
|
||||
|
||||
Write differential for be-coder-06 passed with the configured read-only control and unauthenticated arm. Unit evidence proves the control arm invalidates validation when write-capable, identity-mismatched, or receive-pack-admitted.
|
||||
|
||||
## Successor review-remediation evidence
|
||||
|
||||
- Canonical-image CI portability: `/usr/bin/true` was changed only to `/bin/true`; the second-process `flock` acquisition assertion remains `status === 0` and passes in the focused lifecycle suite.
|
||||
- Required red phase: five exact finding controls failed before implementation (journal one-byte/zero/oversized progress, recursively reordered Tea metadata, and lock-release failure). Review remediation added a sixth red control proving final-seal failure could compensate a later cooperating team mutation after lock release; a prototype-named Tea metadata control also failed before canonical object construction was hardened.
|
||||
- Focused final: 120/120 passed across all 11 credential and credential-command files.
|
||||
- Package lint and typecheck passed; dependency-aware workspace build passed 13/13 packages; repository format check and `git diff --check` passed; both git credential resolver shell regressions passed.
|
||||
- Full local package Vitest: 1,624 passed / 3 failed. The failures are the same installed-version update banner emitted to stderr by three `cli-smoke.spec.ts` cases; prior canonical pipeline #2223 ran all 22 CLI-smoke cases green. Exact-head canonical CI remains required after push.
|
||||
- First uncommitted advisories found a real post-release compensation race. Its red control observed one removal after lock release; the fix forbids compensation after successful release, and the control then passed without weakening an assertion.
|
||||
- Final uncommitted advisories: code `approve` with no findings; security risk `none` with no findings. Codex could not execute tests in its read-only sandbox (EROFS), so these verdicts rely on review only; the writable author runs above are the test evidence and independent reviewers remain required.
|
||||
|
||||
## Risks/blockers
|
||||
|
||||
- Hostile same-UID direct filesystem mutation is outside phase-1 and requires a transactional service, broker/distinct identity, or equivalent non-bypassable primitive; lifecycle flock and generation preconditions cover cooperating `mosaic cred` mutators only. Track the explicit security deferral linked from PR #1059.
|
||||
- The full CLI surface is broad; protect scope by sharing one provider/registry/journal core rather than per-command scripts.
|
||||
- Gitea exact token-scope read-back may require delegated Basic Auth. If a bearer-only validation path cannot obtain an exact provider token object, return `indeterminate` rather than claim a scope.
|
||||
- #1044 hold is LIFTED. The four least-privilege credentials are capability-confirmed and identity-not-measured, not dead. Fleet fail-closed paths now refuse with stable reason markers and never enter shared fallback under `MOSAIC_AGENT_NAME`; interactive callers retain explicit shared behavior. Runtime mismatch coverage remains limited to tokens holding `read:user`; future mints close identity binding at creation without widening seat scopes.
|
||||
- C1 PR #1054 remains sequencing-prior and requires its own exact-head gates. MC-CRED PR #1059 must freeze only after a new advisory review, independent review, and exact-head CI.
|
||||
@@ -0,0 +1,120 @@
|
||||
# RM-03 — CI Queue Guard Repair
|
||||
|
||||
- **Task:** RM-03
|
||||
- **Issue:** #1019
|
||||
- **Branch:** `fix/rm-03-queue-guard`
|
||||
- **Owner:** coder-mos1
|
||||
- **Reviewer:** rev-974 (independent; author != reviewer)
|
||||
- **Started:** 2026-08-01
|
||||
|
||||
## Objective
|
||||
|
||||
Repair the mandatory CI queue guard so it reads provider payloads, blocks asserted non-green CI, distinguishes provider unavailability from a real non-green result, and inspects the branch actually being pushed or merged.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Worktree only: `/home/hermes/agent-work/rm-03`; never mutate `/src/mosaic-stack`.
|
||||
- JSON payload travels through stdin; never argv. Large payload must remain below no ARG_MAX dependency.
|
||||
- TDD is mandatory. Every behavior case must be observed red before implementation.
|
||||
- No bypass flags or hook suppression.
|
||||
- Do not cite the existing guard's green as evidence; D-23 establishes it is zero-information.
|
||||
- Gate-ready is a frozen exact head. Any push after a merge-gate verdict voids that verdict.
|
||||
- No merge: coordinator holds the merge hand pending Jason.
|
||||
|
||||
## Design
|
||||
|
||||
1. Feed JSON to `python3 -c` on stdin, including pending-context rendering.
|
||||
2. Classify valid green as `READY`; pending/failure/no-status/malformed/mixed as `ASSERTED_NOT_READY`; provider/credential/transport inability as `CANNOT_ASSERT`.
|
||||
3. `ASSERTED_NOT_READY` exits nonzero. `CANNOT_ASSERT` emits a loud diagnostic and appends a local JSONL audit record. Push degrades to exit 0; merge holds with distinct retryable exit 75 until provider recovery, then self-clears without manual reset. Inability to write the audit exits nonzero.
|
||||
4. Derive the current branch when `-B` is omitted. The merge wrapper passes the exact PR head branch, repository, and full commit SHA—not its `main` base—so fork PRs cannot resolve against an adjacent base-repository branch.
|
||||
|
||||
## Test matrix
|
||||
|
||||
| Case | Required outcome |
|
||||
| --- | --- |
|
||||
| success | exit 0; terminal-success |
|
||||
| pending | nonzero after bounded timeout |
|
||||
| failure | nonzero |
|
||||
| no-status | nonzero |
|
||||
| malformed | nonzero |
|
||||
| >=150 KiB payload | unchanged classification; never rc126 |
|
||||
| provider unreachable on push | loud audited CANNOT_ASSERT; degraded exit 0 |
|
||||
| provider unreachable on merge | loud audited CANNOT_ASSERT; retryable exit 75/HOLD |
|
||||
| audit unavailable | nonzero |
|
||||
| implicit push branch | provider URL uses checked-out feature branch |
|
||||
| merge wrapper | queue guard receives exact PR head branch/repository/full SHA |
|
||||
|
||||
## RED-first evidence
|
||||
|
||||
Observed against the unmodified `origin/main` implementation before source edits:
|
||||
|
||||
- `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` → rc 1 with 15 failed assertions.
|
||||
- Success payload was reported `state=unknown`.
|
||||
- Pending, failure, no-status, and malformed payloads each exited 0 and omitted `ASSERTED_NOT_READY`.
|
||||
- The 160 KiB payload produced rc 141 because Python never consumed the pipe; it did not classify success.
|
||||
- Provider-unreachable exited 7 with no `CANNOT_ASSERT` audit record.
|
||||
- Implicit push queried `/branches/main`, not `/branches/fix/rm-03-fixture`.
|
||||
- Audit-unavailable emitted no audit diagnostic.
|
||||
- A credential-resolution hard-block mutant was then run before trusting that added case: `credential-unresolvable` returned rc 1 and omitted `CANNOT_ASSERT`; the matrix returned rc 1 with two named assertion failures.
|
||||
- Review-blocker controls were observed red: structurally invalid `statuses` string and null-entry payloads each exited 0 as `terminal-success`; unsupported-platform discovery exited 1 without diagnostic or audit (seven named assertion failures total).
|
||||
- After the push/merge asymmetry ruling, merge-side provider unavailability was observed red at rc 0; its registered case required distinct retryable rc 75.
|
||||
- Aggregate `state=success` with zero contexts was observed red: it exited 0 as `terminal-success`; the registered case requires `no-status`/nonzero.
|
||||
- Fork/exact-head controls were observed red: `pr-merge.sh` omitted the fork repository and full SHA, and an ignored-arguments mutant re-resolved through `/branches/` instead of the exact fork commit (two named failures).
|
||||
- GitHub check-run-only success/pending/failure were each misclassified as `no-status`; the RED run had five named failures and proved the Checks API was never queried.
|
||||
- The first merge-pin control was unrunnable because one `local` declaration referenced a variable before assignment under `set -u`; this was disclosed and corrected rather than counted. The runnable RED then showed Gitea payload `{"Do":"squash"}` lacked `head_commit_id`; a separate GitHub run showed `gh pr merge 123 --squash` lacked `--match-head-commit`.
|
||||
- A stale-verdict mutant removed the `--expect-head` comparison and was observed red because a moved head reached the provider merge call.
|
||||
- `bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh` initially returned rc 1; captured call was `--purpose merge -B main -t 900 -i 15`.
|
||||
|
||||
Logs remain untracked under the worktree as `.mosaic-test-work-red-*.log` and will not be committed.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Mission, remediation charter, task evidence, board, issue #1019, and superseded PR #1023 read.
|
||||
- [x] Isolated worktree created and identity configured coherently.
|
||||
- [x] Mutant tests authored and observed red.
|
||||
- [x] Implementation green.
|
||||
- [x] Baseline and focused situational gates green; full package suite has an unrelated framework-shell environment abort recorded below.
|
||||
- [ ] Independent review clean (rev-974 requested changes at `44ffa99a`; bypass remediation committed and awaiting re-review).
|
||||
- [ ] PR CI terminal-green at exact head by full step scan.
|
||||
- [ ] Merge-gate verdict issued against frozen head.
|
||||
|
||||
## Scope disposition
|
||||
|
||||
- The five framework guides are consequential documentation: they define the purpose-aware tri-state contract, including audited push degradation and merge HOLD.
|
||||
- The agent templates are consequential because they ship the same queue-guard instructions into newly seeded agent contracts; leaving them binary/stale would contradict the repaired tool.
|
||||
- `pr-merge.sh` is consequential: it must inspect the PR's exact head branch/repository/SHA and enforce the exact-head merge pin.
|
||||
- `pr-metadata.sh` is consequential only as the normalized source of that head branch/repository/SHA. Its diff is limited to exposing those fields on GitHub and Gitea.
|
||||
- `test-pr-merge-gitea-empty-uid.sh` changes because exact-head Gitea merges now always use the API path (the only path that can send `head_commit_id`), superseding the prior tea-empty-identity fallback behavior.
|
||||
|
||||
## Review remediation
|
||||
|
||||
- rev-974 independently proved that the documented `--skip-queue-guard` merge option bypassed an exit-99 guard stub, reached the provider merge payload, printed success, and exited 0 at head `44ffa99a`.
|
||||
- RED-first reproduction was added to `test-pr-merge-head-pin.sh` before the production fix: `FAIL merge-bypass: --skip-queue-guard reached the provider merge path`, suite rc 1. The test-only commit is `241113e6`.
|
||||
- Production remediation `37aae650` removes the option from parsing, usage, help, and examples. Every merge-capable path now invokes the queue guard; `--dry-run` alone omits it and has a regression proving that it exits before provider dispatch and creates no merge payload.
|
||||
- Existing Gitea merge tests now exercise a successful guard response rather than bypassing the guard.
|
||||
|
||||
## Risks / boundaries
|
||||
|
||||
- The local JSONL audit is durable operational evidence but not tamper-resistant against the same UID. RM-03 does not claim otherwise.
|
||||
- Push-side audited exit 0 is an explicit owner ruling (Option B), accepted to avoid bricking recovery work; merge-side CANNOT_ASSERT remains retryable exit 75/HOLD. The automated security reviewer continues to flag the deliberate push availability tradeoff.
|
||||
- Source/deployed-copy equality is owned by RM-02/D-22; this branch changes repository source and its tests only.
|
||||
|
||||
## Test evidence
|
||||
|
||||
Fresh after rescue checkpoint `b7175012`:
|
||||
|
||||
- Focused situational matrix: tri-state, GitHub checks pagination, branch-absent, merge head branch/repository/SHA, exact-head pin, and Gitea exact-head API regressions all passed.
|
||||
- `bash -n` on the three production shell scripts passed.
|
||||
- `shellcheck -x -P packages/mosaic/framework/tools/git ...` on all changed shell scripts passed.
|
||||
- `pnpm typecheck` passed (45/45 Turbo tasks).
|
||||
- `pnpm lint` passed (25/25 Turbo tasks).
|
||||
- `pnpm format:check` passed.
|
||||
- `pnpm --filter @mosaicstack/mosaic test`: Vitest passed 1508/1508 on the confirmation run; framework-shell then aborted at the pre-existing wake coordinate assertion with exit 97: `BASH_LINENO ... probe reported [3 5], expected [3 4] ... (#973)`. This is outside the RM-03 diff and is disclosed rather than substituted or called green.
|
||||
- The prior package-suite attempt had one transient, out-of-diff `install-ordering-guard.spec.ts` failure (1/1508); its isolated rerun passed 19/19 and the confirmation full Vitest run passed 1508/1508.
|
||||
- After bypass remediation: all six focused RM-03 queue/merge regressions passed, including bypass refusal and dry-run non-dispatch; shell syntax and source-aware ShellCheck passed; `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` passed.
|
||||
- Fresh `test:framework-shell` reached and passed every RM-03 test, then again aborted at the unrelated wake coordinate assertion with exit 97; it remains explicitly non-green rather than substituted.
|
||||
- An ad hoc raw Prettier invocation over `.template` and `.sh` files was unrunnable because no parser is registered for those extensions; it was not used as a substitute for canonical `pnpm format:check`.
|
||||
|
||||
## Final evidence
|
||||
|
||||
Pending.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"purpose": "Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.",
|
||||
"gates": [
|
||||
{
|
||||
"id": "quality-typecheck",
|
||||
"source": "package.json"
|
||||
},
|
||||
{
|
||||
"id": "quality-lint",
|
||||
"source": "package.json"
|
||||
},
|
||||
{
|
||||
"id": "quality-format",
|
||||
"source": "package.json"
|
||||
},
|
||||
{
|
||||
"id": "checkout-preflight",
|
||||
"source": "scripts/preflight.mjs"
|
||||
},
|
||||
{
|
||||
"id": "ci-queue-wait",
|
||||
"source": "packages/mosaic/framework/tools/git/ci-queue-wait.sh"
|
||||
},
|
||||
{
|
||||
"id": "hook-pre-commit",
|
||||
"source": ".husky/pre-commit"
|
||||
},
|
||||
{
|
||||
"id": "hook-pre-push",
|
||||
"source": ".husky/pre-push"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"gate:verify": "node scripts/gate-verify.mjs",
|
||||
"preflight": "node scripts/preflight.mjs",
|
||||
"clean:generated": "node scripts/clean-generated.mjs",
|
||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||
|
||||
@@ -925,14 +925,16 @@ Woodpecker note:
|
||||
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- If pipeline state is running/queued/pending, wait until queue clears.
|
||||
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
|
||||
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
|
||||
- Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero.
|
||||
- Credential, transport, or provider unavailability is `CANNOT_ASSERT`: the guard emits a loud diagnostic and durable JSONL audit record. For push it exits 0 so recovery work is not bricked. For merge it returns distinct retryable exit 75 and holds until provider recovery; rerunning then self-clears without manual reset. This result is never evidence that CI was clear. If the audit cannot be written, the guard exits nonzero.
|
||||
- `pr-merge.sh` resolves and guards the exact PR head repository and full SHA automatically, including fork PRs.
|
||||
|
||||
## Gitea as Unified Platform
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE):
|
||||
- PR target for delivery is `main`.
|
||||
- Direct pushes to `main` are prohibited.
|
||||
- Merge to `main` MUST be squash-only.
|
||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent).
|
||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
|
||||
|
||||
## Review Checklist
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ For implementation work, you MUST run this cycle in order:
|
||||
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. `push` - push immediately after queue guard passes.
|
||||
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
|
||||
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`.
|
||||
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
|
||||
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
|
||||
14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes).
|
||||
@@ -93,8 +93,8 @@ For implementation work, you MUST run this cycle in order:
|
||||
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
|
||||
> without asking.
|
||||
|
||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
|
||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
|
||||
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
|
||||
4. `~/.config/mosaic/tools/git/issue-close.sh -i <ISSUE_NUMBER>` (or close internal `docs/TASKS.md` ref when no provider exists)
|
||||
5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop.
|
||||
|
||||
@@ -425,11 +425,11 @@ git push
|
||||
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
|
||||
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
||||
- Before merging, run queue guard:
|
||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
||||
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
||||
- Merge via wrapper:
|
||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||
- Wait for terminal CI status:
|
||||
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||
- Close linked issue after merge + green CI:
|
||||
@@ -630,7 +630,7 @@ Construct this from the task row and pass to worker via Task tool:
|
||||
|
||||
**MANDATORY:** This ALWAYS includes linting. If the project has a linter configured
|
||||
(ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched.
|
||||
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||
|
||||
## Git Scripts
|
||||
|
||||
@@ -638,8 +638,9 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
|
||||
|
||||
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
||||
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
|
||||
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`
|
||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
||||
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
|
||||
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
|
||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
|
||||
|
||||
|
||||
@@ -23,10 +23,12 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a
|
||||
# Milestones
|
||||
~/.config/mosaic/tools/git/milestone-create.sh
|
||||
|
||||
# CI queue guard (required before push/merge)
|
||||
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||
```
|
||||
|
||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||
|
||||
### Code Review (Codex)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -88,7 +88,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
@@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -97,7 +97,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
@@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -101,7 +101,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
@@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -87,7 +87,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
+2
-2
@@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -84,7 +84,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
+2
-2
@@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -85,7 +85,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
@@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -7,7 +7,9 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
BRANCH="main"
|
||||
BRANCH=""
|
||||
TARGET_REPO=""
|
||||
HEAD_SHA=""
|
||||
TIMEOUT_SEC=900
|
||||
INTERVAL_SEC=15
|
||||
PURPOSE="merge"
|
||||
@@ -15,10 +17,12 @@ REQUIRE_STATUS=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
|
||||
Options:
|
||||
-B, --branch BRANCH Branch head to inspect (default: main)
|
||||
-B, --branch BRANCH Branch head to inspect (default: current branch)
|
||||
-R, --repo OWNER/REPO Repository containing the branch (default: origin repo)
|
||||
--sha FULL_SHA Inspect this exact 40-character commit instead of resolving the branch
|
||||
-t, --timeout SECONDS Max wait time in seconds (default: 900)
|
||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||
--purpose VALUE Log context: push|merge (default: merge)
|
||||
@@ -27,63 +31,65 @@ Options:
|
||||
|
||||
Examples:
|
||||
$(basename "$0")
|
||||
$(basename "$0") --purpose push -B main -t 600 -i 10
|
||||
$(basename "$0") --purpose push -t 600 -i 10
|
||||
EOF
|
||||
}
|
||||
|
||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||
|
||||
get_state_from_status_json() {
|
||||
python3 - <<'PY'
|
||||
# Python source comes from -c so the provider payload remains on stdin.
|
||||
# Never move the payload to argv: commit-status responses can exceed ARG_MAX.
|
||||
python3 -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("status payload is not an object")
|
||||
except Exception:
|
||||
print("unknown")
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
|
||||
statuses = payload.get("statuses") or []
|
||||
state = (payload.get("state") or "").lower()
|
||||
raw_statuses = payload.get("statuses", [])
|
||||
raw_state = payload.get("state", "")
|
||||
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
statuses = raw_statuses
|
||||
state = raw_state.lower()
|
||||
|
||||
pending_values = {"pending", "queued", "running", "waiting"}
|
||||
failure_values = {"failure", "error", "failed"}
|
||||
success_values = {"success"}
|
||||
|
||||
if state in pending_values:
|
||||
print("pending")
|
||||
raise SystemExit(0)
|
||||
if state in failure_values:
|
||||
print("terminal-failure")
|
||||
raise SystemExit(0)
|
||||
if state in success_values:
|
||||
print("terminal-success")
|
||||
raise SystemExit(0)
|
||||
|
||||
values = []
|
||||
for item in statuses:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
value = (item.get("status") or item.get("state") or "").lower()
|
||||
if value:
|
||||
values.append(value)
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
raw_value = item.get("status") or item.get("state")
|
||||
if not isinstance(raw_value, str) or not raw_value:
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
values.append(raw_value.lower())
|
||||
|
||||
if not values and not state:
|
||||
print("no-status")
|
||||
elif any(v in pending_values for v in values):
|
||||
if any(value in pending_values for value in values) or state in pending_values:
|
||||
print("pending")
|
||||
elif any(v in failure_values for v in values):
|
||||
elif any(value in failure_values for value in values) or state in failure_values:
|
||||
print("terminal-failure")
|
||||
elif values and all(v in success_values for v in values):
|
||||
elif values and all(value in success_values for value in values) and state in {"", "success"}:
|
||||
print("terminal-success")
|
||||
elif not values:
|
||||
print("no-status")
|
||||
else:
|
||||
print("unknown")
|
||||
PY
|
||||
'
|
||||
}
|
||||
|
||||
print_pending_contexts() {
|
||||
python3 - <<'PY'
|
||||
python3 -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
@@ -104,17 +110,61 @@ for item in statuses:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("context") or item.get("name") or "unknown-context"
|
||||
value = (item.get("status") or item.get("state") or "unknown").lower()
|
||||
value = str(item.get("status") or item.get("state") or "unknown").lower()
|
||||
target = item.get("target_url") or item.get("url") or ""
|
||||
if value in pending_values:
|
||||
found = True
|
||||
if target:
|
||||
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
|
||||
else:
|
||||
print(f"[ci-queue-wait] pending: {name}={value}")
|
||||
suffix = f" ({target})" if target else ""
|
||||
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
|
||||
if not found:
|
||||
print("[ci-queue-wait] no pending contexts")
|
||||
'
|
||||
}
|
||||
|
||||
record_cannot_assert() {
|
||||
local reason="$1"
|
||||
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
|
||||
|
||||
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
|
||||
echo "Error: CANNOT_ASSERT and audit directory is unavailable; refusing degraded pass." >&2
|
||||
return 70
|
||||
fi
|
||||
|
||||
if ! python3 - "$audit_log" "$reason" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
path, reason, platform, purpose, branch, repo = sys.argv[1:]
|
||||
record = {
|
||||
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"outcome": "CANNOT_ASSERT",
|
||||
"reason": reason,
|
||||
"platform": platform,
|
||||
"purpose": purpose,
|
||||
"disposition": "hold" if purpose == "merge" else "degraded-pass",
|
||||
"branch": branch,
|
||||
"repo": repo,
|
||||
}
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
PY
|
||||
then
|
||||
echo "Error: CANNOT_ASSERT and audit write failed at ${audit_log}; refusing degraded pass." >&2
|
||||
return 70
|
||||
fi
|
||||
|
||||
if [[ "$PURPOSE" == "merge" ]]; then
|
||||
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=merge branch=${BRANCH:-unknown}; audited=${audit_log}; HOLD (exit 75). Retry after provider recovery; no manual reset is required." >&2
|
||||
return 75
|
||||
fi
|
||||
|
||||
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=push branch=${BRANCH:-unknown}; audited=${audit_log}; push may proceed in degraded mode." >&2
|
||||
return 0
|
||||
}
|
||||
|
||||
github_get_branch_head_sha() {
|
||||
@@ -128,7 +178,87 @@ github_get_commit_status_json() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
local sha="$3"
|
||||
gh api "repos/${owner}/${repo}/commits/${sha}/status"
|
||||
local work_root status_file checks_file
|
||||
work_root="${AGENT_WORK_ROOT:-${HOME:-}/.cache/mosaic/ci-queue-wait}"
|
||||
mkdir -p "$work_root" || return 1
|
||||
status_file=$(mktemp "$work_root/github-status.XXXXXX") || return 1
|
||||
checks_file=$(mktemp "$work_root/github-checks.XXXXXX") || {
|
||||
rm -f "$status_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/statuses?per_page=100" > "$status_file" ||
|
||||
! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100&filter=latest" > "$checks_file"; then
|
||||
rm -f "$status_file" "$checks_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
python3 - "$status_file" "$checks_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
status_pages = json.load(handle)
|
||||
with open(sys.argv[2], encoding="utf-8") as handle:
|
||||
check_pages = json.load(handle)
|
||||
|
||||
if not isinstance(status_pages, list) or not isinstance(check_pages, list):
|
||||
raise SystemExit(1)
|
||||
|
||||
# The statuses endpoint is newest-first and can contain retries for one context.
|
||||
# Keep only the newest entry per context after flattening every page.
|
||||
combined = []
|
||||
seen_contexts = set()
|
||||
for page in status_pages:
|
||||
if not isinstance(page, list):
|
||||
raise SystemExit(1)
|
||||
for status in page:
|
||||
if not isinstance(status, dict):
|
||||
raise SystemExit(1)
|
||||
context = status.get("context")
|
||||
if not isinstance(context, str) or not context or context in seen_contexts:
|
||||
continue
|
||||
seen_contexts.add(context)
|
||||
combined.append(status)
|
||||
|
||||
check_runs = []
|
||||
reported_total = 0
|
||||
for page in check_pages:
|
||||
if not isinstance(page, dict):
|
||||
raise SystemExit(1)
|
||||
page_runs = page.get("check_runs") or []
|
||||
total_count = page.get("total_count")
|
||||
if not isinstance(page_runs, list) or not isinstance(total_count, int):
|
||||
raise SystemExit(1)
|
||||
reported_total = max(reported_total, total_count)
|
||||
check_runs.extend(page_runs)
|
||||
if len(check_runs) < reported_total:
|
||||
raise SystemExit(1)
|
||||
|
||||
for run in check_runs:
|
||||
if not isinstance(run, dict):
|
||||
raise SystemExit(1)
|
||||
status = run.get("status")
|
||||
conclusion = run.get("conclusion")
|
||||
if status != "completed":
|
||||
value = "pending"
|
||||
elif conclusion == "success":
|
||||
value = "success"
|
||||
elif conclusion in {"failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale"}:
|
||||
value = "failure"
|
||||
else:
|
||||
value = "unknown"
|
||||
combined.append({
|
||||
"context": run.get("name") or "github-check",
|
||||
"status": value,
|
||||
"target_url": run.get("html_url") or run.get("details_url") or "",
|
||||
})
|
||||
|
||||
json.dump({"state": "", "statuses": combined}, sys.stdout)
|
||||
PY
|
||||
local status=$?
|
||||
rm -f "$status_file" "$checks_file"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
gitea_get_branch_head_sha() {
|
||||
@@ -174,6 +304,14 @@ while [[ $# -gt 0 ]]; do
|
||||
BRANCH="$2"
|
||||
shift 2
|
||||
;;
|
||||
-R|--repo)
|
||||
TARGET_REPO="$2"
|
||||
shift 2
|
||||
;;
|
||||
--sha)
|
||||
HEAD_SHA="$2"
|
||||
shift 2
|
||||
;;
|
||||
-t|--timeout)
|
||||
TIMEOUT_SEC="$2"
|
||||
shift 2
|
||||
@@ -206,45 +344,89 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
|
||||
echo "Error: timeout and interval must be integer seconds." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$HEAD_SHA" && ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --sha must be a full 40-character hexadecimal commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$TARGET_REPO" && ! "$TARGET_REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then
|
||||
echo "Error: --repo must be OWNER/REPO." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
detect_platform > /dev/null
|
||||
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
|
||||
echo "Error: --purpose must be push or merge." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OWNER="unknown"
|
||||
REPO="unknown"
|
||||
PLATFORM="unknown"
|
||||
if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then
|
||||
record_cannot_assert "repository-owner-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then
|
||||
record_cannot_assert "repository-name-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! detect_platform > /dev/null; then
|
||||
PLATFORM="${PLATFORM:-unknown}"
|
||||
record_cannot_assert "unsupported-platform"
|
||||
exit $?
|
||||
fi
|
||||
PLATFORM="${PLATFORM:-unknown}"
|
||||
|
||||
if [[ -n "$TARGET_REPO" ]]; then
|
||||
OWNER="${TARGET_REPO%%/*}"
|
||||
REPO="${TARGET_REPO##*/}"
|
||||
fi
|
||||
|
||||
if [[ -z "$BRANCH" ]]; then
|
||||
if ! BRANCH=$(git symbolic-ref --quiet --short HEAD) || [[ -z "$BRANCH" ]]; then
|
||||
record_cannot_assert "current-branch-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
|
||||
exit 1
|
||||
record_cannot_assert "github-cli-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||
exit 1
|
||||
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
HOST=$(get_remote_host) || {
|
||||
echo "Error: Could not determine remote host." >&2
|
||||
exit 1
|
||||
}
|
||||
TOKEN=$(get_gitea_token "$HOST") || {
|
||||
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
||||
exit 1
|
||||
}
|
||||
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
|
||||
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||
exit 0
|
||||
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
|
||||
record_cannot_assert "remote-host-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
|
||||
record_cannot_assert "credential-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||
exit 1
|
||||
if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||
else
|
||||
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
||||
exit 1
|
||||
record_cannot_assert "unsupported-platform"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
START_TS=$(date +%s)
|
||||
@@ -253,14 +435,20 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
|
||||
while true; do
|
||||
NOW_TS=$(date +%s)
|
||||
if (( NOW_TS > DEADLINE_TS )); then
|
||||
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||
echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||
exit 124
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
|
||||
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
|
||||
record_cannot_assert "status-provider-unreachable"
|
||||
exit $?
|
||||
fi
|
||||
else
|
||||
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
|
||||
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
|
||||
record_cannot_assert "status-provider-unreachable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
|
||||
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
||||
@@ -271,21 +459,24 @@ while true; do
|
||||
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
||||
sleep "$INTERVAL_SEC"
|
||||
;;
|
||||
terminal-success)
|
||||
exit 0
|
||||
;;
|
||||
no-status)
|
||||
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
|
||||
exit 1
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
||||
else
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
fi
|
||||
echo "[ci-queue-wait] no status contexts present; proceeding."
|
||||
exit 0
|
||||
exit 3
|
||||
;;
|
||||
terminal-success|terminal-failure|unknown)
|
||||
# Queue guard only blocks on pending/running/queued states.
|
||||
exit 0
|
||||
terminal-failure|malformed|unknown)
|
||||
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
;;
|
||||
*)
|
||||
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
|
||||
exit 0
|
||||
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -499,8 +499,17 @@ get_gitea_url_for_host() {
|
||||
|
||||
# Resolve a Gitea API token for the given host.
|
||||
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
||||
_trace_credential_resolution() {
|
||||
[[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]] || return 0
|
||||
local reason="$1" identity="$2" host="$3" source="$4"
|
||||
local shared_path_entered=false
|
||||
[[ "$_resolution_path" == shared ]] && shared_path_entered=true
|
||||
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=%s identity=%s host=%s resolution_path=%s shared_path_entered=%s source=%s\n' \
|
||||
"$reason" "$identity" "$host" "$_resolution_path" "$shared_path_entered" "$source" >&2
|
||||
}
|
||||
|
||||
get_gitea_token() {
|
||||
local host="$1"
|
||||
local host="$1" _resolution_path=unresolved
|
||||
local script_dir
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
local cred_loader="$script_dir/../_lib/credentials.sh"
|
||||
@@ -516,6 +525,16 @@ get_gitea_token() {
|
||||
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||
_ident_src="git config mosaic.gitIdentity"
|
||||
fi
|
||||
if [[ -n "$_ident" && ! "$_ident" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then
|
||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=invalid-identity identity=<invalid> host=%s shared_path_entered=false source=%s\n' \
|
||||
"$host" "$_ident_src" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -n "${MOSAIC_AGENT_NAME:-}" && -n "$_ident" && "$_ident" != "$MOSAIC_AGENT_NAME" ]]; then
|
||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=%s\n' \
|
||||
"$_ident" "$MOSAIC_AGENT_NAME" "$host" "$_ident_src" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -n "$_ident" ]]; then
|
||||
local _idpfx=""
|
||||
case "$host" in
|
||||
@@ -524,8 +543,23 @@ get_gitea_token() {
|
||||
esac
|
||||
if [[ -n "$_idpfx" ]]; then
|
||||
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
||||
if [[ -r "$_idtok" ]]; then
|
||||
cat "$_idtok"
|
||||
local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json"
|
||||
if [[ -e "$_idcred" || -L "$_idcred" ]]; then
|
||||
local _resolved_token
|
||||
_resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \
|
||||
"$HOME/.config/mosaic/secrets/gitea-tokens" "$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1
|
||||
_resolution_path=identity
|
||||
_trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src"
|
||||
printf '%s\n' "$_resolved_token"
|
||||
return 0
|
||||
fi
|
||||
if [[ -e "$_idtok" || -L "$_idtok" ]]; then
|
||||
local _resolved_token
|
||||
_resolved_token=$(python3 "$script_dir/resolve-legacy-token.py" \
|
||||
"$HOME/.config/mosaic/secrets/gitea-tokens" "$_idtok") || return 1
|
||||
_resolution_path=identity
|
||||
_trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src"
|
||||
printf '%s\n' "$_resolved_token"
|
||||
return 0
|
||||
fi
|
||||
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
|
||||
@@ -534,12 +568,21 @@ get_gitea_token() {
|
||||
# would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed
|
||||
# to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the
|
||||
# caller aborts loudly rather than acting as the wrong identity.
|
||||
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
|
||||
echo " Refusing to borrow another slot's token. Provision the per-slot token, or unset the identity to use shared credentials." >&2
|
||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=no-token-for-identity identity=%s host=%s shared_path_entered=false source=%s path=%s\n' \
|
||||
"$_ident" "$host" "$_ident_src" "$_idtok" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fleet automation never borrows a shared human/default credential. An
|
||||
# explicit interactive caller may still reach the shared paths below, but
|
||||
# a fleet process must name an identity and resolve that identity exactly.
|
||||
if [[ -n "${MOSAIC_AGENT_NAME:-}" ]]; then
|
||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=identity-required identity=<unset> host=%s shared_path_entered=false source=MOSAIC_AGENT_NAME\n' \
|
||||
"$host" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
|
||||
if [[ -f "$cred_loader" ]]; then
|
||||
local token
|
||||
@@ -571,6 +614,8 @@ get_gitea_token() {
|
||||
echo "${GITEA_TOKEN:-}"
|
||||
)
|
||||
if [[ -n "$token" ]]; then
|
||||
_resolution_path=shared
|
||||
_trace_credential_resolution shared-credential-resolved '<interactive-shared>' "$host" credentials-loader
|
||||
echo "$token"
|
||||
return 0
|
||||
fi
|
||||
@@ -579,6 +624,8 @@ get_gitea_token() {
|
||||
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then
|
||||
_resolution_path=shared
|
||||
_trace_credential_resolution shared-credential-resolved '<interactive-shared>' "$host" environment
|
||||
echo "$GITEA_TOKEN"
|
||||
return 0
|
||||
fi
|
||||
@@ -590,6 +637,8 @@ get_gitea_token() {
|
||||
local token
|
||||
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
|
||||
if [[ -n "$token" ]]; then
|
||||
_resolution_path=shared
|
||||
_trace_credential_resolution shared-credential-resolved '<interactive-shared>' "$host" git-credentials
|
||||
echo "$token"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -24,29 +24,79 @@ while IFS= read -r line; do
|
||||
username=*) username_in=${line#username=};;
|
||||
esac
|
||||
done
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
trace_resolution() {
|
||||
[ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ] || return 0
|
||||
reason="$1" trace_identity="$2" trace_host="$3" source="$4"
|
||||
shared_path_entered=false
|
||||
[ "$resolution_path" = shared ] && shared_path_entered=true
|
||||
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=%s identity=%s host=%s resolution_path=%s shared_path_entered=%s source=%s\n' \
|
||||
"$reason" "$trace_identity" "$trace_host" "$resolution_path" "$shared_path_entered" "$source" >&2
|
||||
}
|
||||
resolution_path=unresolved
|
||||
# Per-agent identity resolution (Gate-16 author≠reviewer separation).
|
||||
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
|
||||
# survives across non-persistent shells) > git-supplied username (credential.username
|
||||
# / URL). When the resolved identity has a matching per-agent token, use it instead of
|
||||
# the shared account. Backward-compatible: nothing resolvable → shared token.
|
||||
case "$host" in
|
||||
git.uscllc.com) idpfx=gitea-usc;;
|
||||
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||
*) exit 0;;
|
||||
esac
|
||||
ident="$MOSAIC_GIT_IDENTITY"
|
||||
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||
[ -z "$ident" ] && ident="$username_in"
|
||||
if [[ -n "$ident" && ! "$ident" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then
|
||||
echo "quit=true"
|
||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=invalid-identity identity=<invalid> host=%s shared_path_entered=false source=git-credential-mosaic\n' "$host" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$idpfx" ] && [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then
|
||||
echo "quit=true"
|
||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
|
||||
"$ident" "$MOSAIC_AGENT_NAME" "$host" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$ident" ]; then
|
||||
case "$host" in
|
||||
git.uscllc.com) idpfx=gitea-usc;;
|
||||
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||
*) idpfx="";;
|
||||
esac
|
||||
if [ -n "$idpfx" ]; then
|
||||
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
|
||||
if [ -r "$idtok" ]; then
|
||||
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
|
||||
if [ -e "$idcred" ] || [ -L "$idcred" ]; then
|
||||
token=$(python3 "$script_dir/resolve-credential-envelope.py" \
|
||||
"$HOME/.config/mosaic/secrets/gitea-tokens" "$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1
|
||||
resolution_path=identity
|
||||
trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic
|
||||
echo "username=${ident}"
|
||||
echo "password=$(cat "$idtok")"
|
||||
echo "password=${token}"
|
||||
exit 0
|
||||
fi
|
||||
if [ -e "$idtok" ] || [ -L "$idtok" ]; then
|
||||
token=$(python3 "$script_dir/resolve-legacy-token.py" \
|
||||
"$HOME/.config/mosaic/secrets/gitea-tokens" "$idtok") || exit 1
|
||||
resolution_path=identity
|
||||
trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic
|
||||
echo "username=${ident}"
|
||||
echo "password=${token}"
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then
|
||||
echo "quit=true"
|
||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=no-token-for-identity identity=%s host=%s shared_path_entered=false source=git-credential-mosaic path=%s\n' \
|
||||
"$ident" "$host" "$idtok" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -z "$ident" ]; then
|
||||
case "$host" in
|
||||
git.uscllc.com|git.mosaicstack.dev)
|
||||
echo "quit=true"
|
||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=identity-required identity=<unset> host=%s shared_path_entered=false source=git-credential-mosaic\n' "$host" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
case "$host" in
|
||||
git.uscllc.com) svc=gitea-usc;;
|
||||
git.mosaicstack.dev) svc=gitea-mosaicstack;;
|
||||
@@ -55,10 +105,11 @@ esac
|
||||
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
|
||||
# of where the framework installer places tools/ under $HOME — mirrors
|
||||
# detect-platform.sh's own cred_loader resolution in this same directory.
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=../_lib/credentials.sh
|
||||
source "$script_dir/../_lib/credentials.sh"
|
||||
load_credentials "$svc" >/dev/null 2>&1 || exit 0
|
||||
resolution_path=shared
|
||||
trace_resolution shared-credential-resolved '<interactive-shared>' "$host" credentials-loader
|
||||
# GITEA_USER is not populated by load_credentials (it only exports
|
||||
# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's
|
||||
# git-over-HTTP auth authenticates from the token itself (the password field),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -12,8 +12,8 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
MERGE_METHOD="squash"
|
||||
DELETE_BRANCH=false
|
||||
SKIP_QUEUE_GUARD=false
|
||||
DRY_RUN=false
|
||||
EXPECT_HEAD=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -25,15 +25,14 @@ Options:
|
||||
-n, --number NUMBER PR number to merge (required)
|
||||
-m, --method METHOD Merge method: squash only (default: squash)
|
||||
-d, --delete-branch Delete the head branch after merge
|
||||
--skip-queue-guard Skip CI queue guard wait before merge
|
||||
--dry-run Run metadata/login preflight without merging
|
||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
$(basename "$0") -n 42 # Merge PR #42
|
||||
$(basename "$0") -n 42 -m squash # Squash merge
|
||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
@@ -53,15 +52,14 @@ while [[ $# -gt 0 ]]; do
|
||||
DELETE_BRANCH=true
|
||||
shift
|
||||
;;
|
||||
--skip-queue-guard)
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
--expect-head)
|
||||
EXPECT_HEAD="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
;;
|
||||
@@ -86,18 +84,36 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
|
||||
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
||||
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
||||
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
||||
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
||||
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
|
||||
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
|
||||
echo "Error: PR head moved: expected $EXPECT_HEAD, found $HEAD_SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" != true ]]; then
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||
--purpose merge \
|
||||
-B "$BASE_BRANCH" \
|
||||
-B "$HEAD_BRANCH" \
|
||||
-R "$HEAD_REPO" \
|
||||
--sha "$HEAD_SHA" \
|
||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||
fi
|
||||
@@ -106,31 +122,22 @@ PLATFORM=$(detect_platform)
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
|
||||
is_known_tea_empty_identity_failure() {
|
||||
local error_file="$1"
|
||||
|
||||
python3 - "$error_file" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
|
||||
error = handle.read()
|
||||
|
||||
known_empty_identity = re.search(
|
||||
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
|
||||
error,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
raise SystemExit(0 if known_empty_identity else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
merge_gitea_with_api() {
|
||||
local host="$1" api_url token basic_auth body_file raw_code payload
|
||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
||||
payload='{"Do":"squash"}'
|
||||
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
head_sha, delete_branch = sys.argv[1:]
|
||||
payload = {"Do": "squash", "head_commit_id": head_sha}
|
||||
if delete_branch == "true":
|
||||
payload["delete_branch_after_merge"] = True
|
||||
print(json.dumps(payload, separators=(",", ":")))
|
||||
PY
|
||||
)
|
||||
|
||||
token=$(get_gitea_token "$host" || true)
|
||||
if [[ -n "$token" ]]; then
|
||||
@@ -202,7 +209,7 @@ fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
cmd=(gh pr merge "$PR_NUMBER" --squash)
|
||||
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
||||
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||
"${cmd[@]}"
|
||||
;;
|
||||
@@ -211,32 +218,9 @@ case "$PLATFORM" in
|
||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||
exit 1
|
||||
}
|
||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||
|
||||
if [[ -n "$TEA_LOGIN" ]]; then
|
||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
|
||||
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
|
||||
cat "$TEA_ERROR_FILE" >&2
|
||||
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
merge_gitea_with_api "$HOST"
|
||||
else
|
||||
cat "$TEA_ERROR_FILE" >&2
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
|
||||
merge_gitea_with_api "$HOST"
|
||||
fi
|
||||
|
||||
# Delete branch after merge if requested
|
||||
if [[ "$DELETE_BRANCH" == true ]]; then
|
||||
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
|
||||
fi
|
||||
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
||||
# tea cannot express it, so exact-head merges use the authenticated API path.
|
||||
merge_gitea_with_api "$HOST"
|
||||
;;
|
||||
*)
|
||||
echo "Error: Could not detect git platform" >&2
|
||||
|
||||
@@ -109,7 +109,7 @@ PY
|
||||
detect_platform > /dev/null
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||
write_metadata "$METADATA"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
OWNER=$(get_repo_owner)
|
||||
@@ -182,6 +182,25 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
|
||||
data.get('head_ref'),
|
||||
head_ref,
|
||||
)
|
||||
head_sha = first_non_empty(
|
||||
nested(data, 'head', 'sha'),
|
||||
nested(data, 'head', 'id'),
|
||||
data.get('head_sha'),
|
||||
)
|
||||
head_repo = first_non_empty(
|
||||
nested(data, 'head', 'repo', 'full_name'),
|
||||
nested(data, 'head', 'repo', 'name_with_owner'),
|
||||
)
|
||||
if not head_repo:
|
||||
head_repo_owner = first_non_empty(
|
||||
nested(data, 'head', 'repo', 'owner', 'login'),
|
||||
nested(data, 'head', 'repo', 'owner', 'username'),
|
||||
nested(data, 'head', 'repo', 'owner_name'),
|
||||
)
|
||||
head_repo_name = first_non_empty(nested(data, 'head', 'repo', 'name'))
|
||||
if head_repo_owner and head_repo_name:
|
||||
head_repo = f'{head_repo_owner}/{head_repo_name}'
|
||||
|
||||
base_ref = first_non_empty(
|
||||
nested(data, 'base', 'ref'),
|
||||
nested(data, 'base', 'name'),
|
||||
@@ -207,6 +226,8 @@ normalized = {
|
||||
'state': data.get('state'),
|
||||
'author': nested(data, 'user', 'login') or '',
|
||||
'headRefName': head_ref,
|
||||
'headRefOid': head_sha,
|
||||
'headRepository': head_repo,
|
||||
'baseRefName': base_ref,
|
||||
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
||||
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed reader for one governed Mosaic credential envelope."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
MAX_BYTES = 64 * 1024
|
||||
EXPECTED_KEYS = {
|
||||
"schemaVersion",
|
||||
"identity",
|
||||
"estate",
|
||||
"host",
|
||||
"providerLogin",
|
||||
"tokenName",
|
||||
"scopes",
|
||||
"createdAt",
|
||||
"tokenDigest",
|
||||
"token",
|
||||
}
|
||||
|
||||
|
||||
def refuse(message: str) -> None:
|
||||
print(f"credential envelope refused: {message}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if len(sys.argv) != 6:
|
||||
refuse("expected governed root, path, identity, estate, and host")
|
||||
root, path, identity, estate, host = sys.argv[1:]
|
||||
if os.path.abspath(os.path.dirname(path)) != os.path.abspath(root):
|
||||
refuse("credential is not a direct child of the governed root")
|
||||
if not estate:
|
||||
refuse("explicit estate is required")
|
||||
parent = os.path.dirname(path)
|
||||
try:
|
||||
parent_stat = os.stat(parent, follow_symlinks=False)
|
||||
except OSError:
|
||||
refuse("credential directory unavailable")
|
||||
if not stat.S_ISDIR(parent_stat.st_mode) or stat.S_ISLNK(parent_stat.st_mode):
|
||||
refuse("credential directory is not a real directory")
|
||||
if parent_stat.st_uid != os.getuid() or parent_stat.st_mode & 0o022:
|
||||
refuse("credential directory owner or mode is unsafe")
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
except OSError:
|
||||
refuse("credential file unavailable or symbolic")
|
||||
try:
|
||||
file_stat = os.fstat(fd)
|
||||
if not stat.S_ISREG(file_stat.st_mode):
|
||||
refuse("credential is not a regular file")
|
||||
if file_stat.st_uid != os.getuid() or file_stat.st_mode & 0o077:
|
||||
refuse("credential owner or mode is unsafe")
|
||||
content = os.read(fd, MAX_BYTES + 1)
|
||||
if len(content) > MAX_BYTES:
|
||||
refuse("credential exceeds size limit")
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
value = json.loads(content)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
refuse("credential is not valid JSON")
|
||||
if not isinstance(value, dict) or set(value) != EXPECTED_KEYS:
|
||||
refuse("credential schema is not exact")
|
||||
if (
|
||||
value.get("schemaVersion") != 1
|
||||
or value.get("identity") != identity
|
||||
or value.get("estate") != estate
|
||||
or value.get("host") != host
|
||||
or value.get("providerLogin") != identity
|
||||
):
|
||||
refuse("credential binding does not match requested identity, estate, host, and principal")
|
||||
token = value.get("token")
|
||||
if not isinstance(token, str) or not token or any(ch.isspace() for ch in token):
|
||||
refuse("credential token is invalid")
|
||||
if value.get("tokenDigest") != hashlib.sha256(token.encode()).hexdigest():
|
||||
refuse("credential digest does not match token")
|
||||
sys.stdout.write(token + "\n")
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed reader for one legacy per-seat token file."""
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
MAX_BYTES = 16 * 1024
|
||||
|
||||
|
||||
def refuse(message: str) -> None:
|
||||
print(f"legacy credential refused: {message}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if len(sys.argv) != 3:
|
||||
refuse("expected governed root and token path")
|
||||
root, path = sys.argv[1:]
|
||||
if os.path.abspath(os.path.dirname(path)) != os.path.abspath(root):
|
||||
refuse("credential is not a direct child of the governed root")
|
||||
parent = os.path.dirname(path)
|
||||
try:
|
||||
parent_stat = os.stat(parent, follow_symlinks=False)
|
||||
except OSError:
|
||||
refuse("credential directory unavailable")
|
||||
if not stat.S_ISDIR(parent_stat.st_mode) or stat.S_ISLNK(parent_stat.st_mode):
|
||||
refuse("credential directory is not a real directory")
|
||||
if parent_stat.st_uid != os.getuid() or parent_stat.st_mode & 0o022:
|
||||
refuse("credential directory owner or mode is unsafe")
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
except OSError:
|
||||
refuse("credential file unavailable or symbolic")
|
||||
try:
|
||||
file_stat = os.fstat(fd)
|
||||
if not stat.S_ISREG(file_stat.st_mode):
|
||||
refuse("credential is not a regular file")
|
||||
if file_stat.st_uid != os.getuid() or file_stat.st_mode & 0o077:
|
||||
refuse("credential owner or mode is unsafe")
|
||||
content = os.read(fd, MAX_BYTES + 1)
|
||||
if len(content) > MAX_BYTES:
|
||||
refuse("credential exceeds size limit")
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
token = content.decode("utf-8").strip()
|
||||
except UnicodeDecodeError:
|
||||
refuse("credential is not UTF-8")
|
||||
if not token or any(ch.isspace() for ch in token):
|
||||
refuse("credential token is invalid")
|
||||
sys.stdout.write(token + "\n")
|
||||
@@ -13,7 +13,7 @@
|
||||
# Covers:
|
||||
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
||||
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
|
||||
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
|
||||
# (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -62,7 +62,7 @@ case "$mode" in
|
||||
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
||||
500) code=500; body='{"message":"internal server error"}' ;;
|
||||
no-status) code=200; body='{}' ;;
|
||||
terminal-success) code=200; body='{"state":"success"}' ;;
|
||||
terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||
*)
|
||||
echo "curl stub: unknown mode=$mode" >&2
|
||||
exit 2
|
||||
@@ -91,6 +91,7 @@ run_ci_queue_wait() {
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export GITEA_TOKEN="stub-token"
|
||||
export GITEA_URL="https://git.example.test"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
|
||||
)
|
||||
}
|
||||
@@ -131,19 +132,26 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# (c) genuine API error (500) -> still fail-closed, exit nonzero.
|
||||
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
|
||||
set +e
|
||||
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
||||
status_c=$?
|
||||
set -e
|
||||
if [[ "$status_c" -eq 0 ]]; then
|
||||
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
|
||||
if [[ "$status_c" -ne 0 ]]; then
|
||||
echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ "$out_c" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL(c): expected a loud CANNOT_ASSERT diagnostic" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ "$out_c" == *"queue clear"* ]]; then
|
||||
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ ! -s "$WORK_DIR/audit/ci-queue-wait.jsonl" ]]; then
|
||||
echo "FAIL(c): expected a durable CANNOT_ASSERT audit record" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# GitHub Actions uses Checks API check-runs, not only legacy commit statuses.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-github-checks}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" checkout -q -b fix/github-checks
|
||||
git -C "$REPO_DIR" remote add origin https://github.com/acme/widgets.git
|
||||
|
||||
cat > "$STUB_DIR/gh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
endpoint=""
|
||||
for arg in "$@"; do
|
||||
[[ "$arg" == repos/* ]] && endpoint="$arg"
|
||||
done
|
||||
printf '%s\n' "$*" >> "${MOSAIC_GH_CALL_LOG:?}"
|
||||
case "$endpoint" in
|
||||
repos/acme/widgets/branches/fix/github-checks)
|
||||
printf '%s\n' '0123456789abcdef0123456789abcdef01234567'
|
||||
;;
|
||||
repos/acme/widgets/commits/*/statuses?per_page=100)
|
||||
printf '%s\n' '[[]]'
|
||||
;;
|
||||
repos/acme/widgets/commits/*/check-runs?per_page=100\&filter=latest)
|
||||
case "${MOSAIC_GH_CHECK_MODE:?}" in
|
||||
success) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"success"}]}]' ;;
|
||||
pending) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"in_progress","conclusion":null}]}]' ;;
|
||||
failure) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"failure"}]}]' ;;
|
||||
late-failure) printf '%s\n' '[{"total_count":2,"check_runs":[{"name":"first-page","status":"completed","conclusion":"success"}]},{"total_count":2,"check_runs":[{"name":"later-page","status":"completed","conclusion":"failure"}]}]' ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
*) echo "unexpected gh endpoint: $endpoint" >&2; exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$STUB_DIR/gh"
|
||||
|
||||
run_guard() {
|
||||
local mode="$1"
|
||||
(
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_GH_CHECK_MODE="$mode"
|
||||
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
|
||||
)
|
||||
}
|
||||
|
||||
failures=0
|
||||
assert_case() {
|
||||
local mode="$1" expected_rc="$2" expected_state="$3" output rc
|
||||
set +e
|
||||
output=$(run_guard "$mode" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
|
||||
echo "FAIL github-$mode: expected rc=0, got $rc" >&2
|
||||
failures=$((failures + 1))
|
||||
elif [[ "$expected_rc" == nonzero && "$rc" -eq 0 ]]; then
|
||||
echo "FAIL github-$mode: expected rc!=0, got 0" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$output" != *"state=$expected_state"* ]]; then
|
||||
echo "FAIL github-$mode: expected state=$expected_state, got:" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
set -e
|
||||
: > "$WORK_DIR/gh-calls.log"
|
||||
assert_case success zero terminal-success
|
||||
assert_case pending nonzero pending
|
||||
assert_case failure nonzero terminal-failure
|
||||
assert_case late-failure nonzero terminal-failure
|
||||
|
||||
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
|
||||
echo "FAIL: expected every case to query all Checks API pages" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "GitHub check-runs regression failed ($failures assertions)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
|
||||
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env bash
|
||||
# Exit-asserting RM-03 regression harness for ci-queue-wait.sh.
|
||||
# Every case is a process-level assertion: a classifier-only green cannot satisfy it.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||
STATUS_OBSERVED="$WORK_DIR/status-observed"
|
||||
CLOCK_LOG="$WORK_DIR/clock.log"
|
||||
WATCHDOG_PYTHON="/usr/bin/python3"
|
||||
WATCHDOG_SCRIPT="$WORK_DIR/real-clock-watchdog.py"
|
||||
WATCHDOG_TIMEOUT_SEC=5
|
||||
WATCHDOG_EXIT=90
|
||||
FEATURE_BRANCH="fix/rm-03-fixture"
|
||||
|
||||
if [[ ! -x "$WATCHDOG_PYTHON" ]]; then
|
||||
echo "FAIL setup: required real-clock watchdog runtime is unavailable at $WATCHDOG_PYTHON" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||
cat > "$WATCHDOG_SCRIPT" <<'PY'
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
raise SystemExit(2)
|
||||
|
||||
timeout_seconds = float(sys.argv[1])
|
||||
process = subprocess.Popen(sys.argv[2:], start_new_session=True)
|
||||
try:
|
||||
return_code = process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.wait()
|
||||
print(
|
||||
f"FAIL HANG watchdog: subject exceeded {timeout_seconds:g}s "
|
||||
"before completing its intended path",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(90)
|
||||
|
||||
if return_code < 0:
|
||||
raise SystemExit(128 - return_code)
|
||||
raise SystemExit(return_code)
|
||||
PY
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
|
||||
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||
|
||||
cat > "$STUB_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
url=""
|
||||
has_write_out=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-w) has_write_out=1 ;;
|
||||
http://*|https://*) url="$arg" ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
|
||||
|
||||
case "$url" in
|
||||
*/branches/*)
|
||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "hang-before-provider" ]]; then
|
||||
while :; do :; done
|
||||
fi
|
||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
|
||||
exit 7
|
||||
fi
|
||||
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
|
||||
if [[ "$has_write_out" -eq 1 ]]; then
|
||||
printf '%s\n200' "$body"
|
||||
else
|
||||
printf '%s' "$body"
|
||||
fi
|
||||
;;
|
||||
*/status)
|
||||
: > "${MOSAIC_STUB_STATUS_OBSERVED:?}"
|
||||
case "${MOSAIC_STUB_STATUS_MODE:?}" in
|
||||
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
|
||||
failure) printf '%s' '{"state":"failure","statuses":[{"status":"failure"}]}' ;;
|
||||
no-status) printf '%s' '{"state":"","statuses":[]}' ;;
|
||||
aggregate-success-no-status) printf '%s' '{"state":"success","statuses":[]}' ;;
|
||||
malformed) printf '%s' 'not-json' ;;
|
||||
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
|
||||
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
|
||||
large-success)
|
||||
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
|
||||
;;
|
||||
unreachable) exit 7 ;;
|
||||
*) echo "unknown status mode" >&2; exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
|
||||
cat > "$STUB_DIR/date" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$#" -ne 1 || "$1" != "+%s" ]]; then
|
||||
echo "unexpected date invocation: $*" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -e "${MOSAIC_STUB_STATUS_OBSERVED:?}" ]]; then
|
||||
printf 'date-phase=after-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||
printf '1002\n'
|
||||
else
|
||||
printf 'date-phase=before-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||
printf '1000\n'
|
||||
fi
|
||||
SH
|
||||
|
||||
cat > "$STUB_DIR/sleep" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'sleep-after-status=%s\n' "$*" >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl" "$STUB_DIR/date" "$STUB_DIR/sleep"
|
||||
|
||||
run_guard() {
|
||||
local status_mode="$1"
|
||||
local audit_log="${2:-$AUDIT_LOG}"
|
||||
shift 2 || true
|
||||
(
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
if [[ "$status_mode" == "credential-unresolvable" ]]; then
|
||||
export HOME="$WORK_DIR/empty-home"
|
||||
mkdir -p "$HOME"
|
||||
unset GITEA_TOKEN GITEA_URL MOSAIC_GIT_IDENTITY
|
||||
export MOSAIC_STUB_STATUS_MODE=success
|
||||
else
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_STUB_STATUS_MODE="$status_mode"
|
||||
fi
|
||||
rm -f "$STATUS_OBSERVED" "$CLOCK_LOG"
|
||||
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
|
||||
export MOSAIC_STUB_STATUS_OBSERVED="$STATUS_OBSERVED"
|
||||
export MOSAIC_STUB_CLOCK_LOG="$CLOCK_LOG"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
|
||||
# Provider observation is the synchronization event. The one-second
|
||||
# timeout is subject semantics under virtual time, never a wall wait.
|
||||
# The absolute Python runtime uses an internal monotonic wait and kills
|
||||
# the subject's isolated process group. Neither operation can resolve
|
||||
# to the virtual date/sleep stubs at the front of PATH.
|
||||
local subject_rc
|
||||
if "$WATCHDOG_PYTHON" "$WATCHDOG_SCRIPT" "$WATCHDOG_TIMEOUT_SEC" \
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 1 -i 1 "$@"; then
|
||||
subject_rc=0
|
||||
else
|
||||
subject_rc=$?
|
||||
fi
|
||||
return "$subject_rc"
|
||||
)
|
||||
}
|
||||
|
||||
failures=0
|
||||
assert_provider_observed() {
|
||||
local name="$1" require_expiration="${2:-0}"
|
||||
if [[ ! -e "$STATUS_OBSERVED" ]]; then
|
||||
echo "FAIL $name: status provider was not observed" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ ! -s "$CLOCK_LOG" ]] || ! grep -q '^date-phase=before-status$' "$CLOCK_LOG"; then
|
||||
echo "FAIL $name: virtual clock interception did not run before provider observation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$require_expiration" -eq 1 ]]; then
|
||||
if ! grep -q '^sleep-after-status=' "$CLOCK_LOG" || ! grep -q '^date-phase=after-status$' "$CLOCK_LOG"; then
|
||||
echo "FAIL $name: pending path did not expire after provider observation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
run_assertion() {
|
||||
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
|
||||
local output rc
|
||||
shift 4
|
||||
set +e
|
||||
output=$(run_guard "$status_mode" "$AUDIT_LOG" "$@" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
case "$expected_rc" in
|
||||
zero)
|
||||
if [[ "$rc" -ne 0 ]]; then
|
||||
echo "FAIL $name: expected rc=0, got rc=$rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
nonzero)
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
echo "FAIL $name: expected rc!=0, got rc=0" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
not126)
|
||||
if [[ "$rc" -eq 126 ]]; then
|
||||
echo "FAIL $name: payload transport hit ARG_MAX (rc=126)" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if [[ "$output" != *"$required_text"* ]]; then
|
||||
echo "FAIL $name: output missing '$required_text' (rc=$rc)" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$status_mode" != "credential-unresolvable" ]]; then
|
||||
if [[ "$status_mode" == "pending" ]]; then
|
||||
assert_provider_observed "$name" 1
|
||||
else
|
||||
assert_provider_observed "$name"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
set -e
|
||||
: > "$WORK_DIR/urls.log"
|
||||
run_assertion success zero success 'state=terminal-success'
|
||||
run_assertion pending nonzero pending 'ASSERTED_NOT_READY'
|
||||
run_assertion failure nonzero failure 'ASSERTED_NOT_READY'
|
||||
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY'
|
||||
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
|
||||
run_assertion large-payload not126 large-success 'state=terminal-success'
|
||||
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
||||
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
||||
|
||||
# Positive liveness control: a subject mutant hangs before the branch lookup
|
||||
# can reach the status provider. Only the independent real-clock watchdog may
|
||||
# terminate it, and its failure must be distinct from subject timeout rc=124.
|
||||
set +e
|
||||
watchdog_output=$(MOSAIC_STUB_BRANCH_MODE=hang-before-provider run_guard success "$AUDIT_LOG" 2>&1)
|
||||
watchdog_rc=$?
|
||||
set -e
|
||||
if [[ "$watchdog_rc" -ne "$WATCHDOG_EXIT" ]]; then
|
||||
echo "FAIL watchdog-control: expected hang-specific rc=$WATCHDOG_EXIT, got rc=$watchdog_rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$watchdog_output" != *"FAIL HANG watchdog:"* ]]; then
|
||||
echo "FAIL watchdog-control: expected distinct hang-specific diagnostic" >&2
|
||||
printf '%s\n' "$watchdog_output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ -e "$STATUS_OBSERVED" ]]; then
|
||||
echo "FAIL watchdog-control: hanging mutant unexpectedly reached the status provider" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
||||
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Merge cannot proceed without exact-head evidence. CANNOT_ASSERT is retryable exit 75,
|
||||
# distinct from ASSERTED_NOT_READY (3/124), and still writes its audit record.
|
||||
merge_audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
||||
set +e
|
||||
merge_unreachable_output=$(MOSAIC_TEST_PURPOSE=merge run_guard unreachable "$AUDIT_LOG" 2>&1)
|
||||
merge_unreachable_rc=$?
|
||||
set -e
|
||||
if [[ "$merge_unreachable_rc" -ne 75 ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected rc=75, got rc=$merge_unreachable_rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
assert_provider_observed merge-provider-unreachable
|
||||
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# A feature-branch push with no -B must inspect the checked-out feature branch.
|
||||
if ! grep -q "/branches/$FEATURE_BRANCH" "$WORK_DIR/urls.log"; then
|
||||
echo "FAIL implicit-branch: provider was not queried for $FEATURE_BRANCH" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Merge callers can pin both a fork repository and the exact reviewed head SHA.
|
||||
exact_sha=0123456789abcdef0123456789abcdef01234567
|
||||
: > "$WORK_DIR/urls.log"
|
||||
run_assertion exact-fork-head zero success 'state=terminal-success' \
|
||||
-B fix/rm-03-fixture -R contributor/widgets-fork --sha "$exact_sha"
|
||||
if ! grep -q "/repos/contributor/widgets-fork/commits/$exact_sha/status" "$WORK_DIR/urls.log"; then
|
||||
echo "FAIL exact-fork-head: status URL did not bind fork repository and exact SHA" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if grep -q '/branches/' "$WORK_DIR/urls.log"; then
|
||||
echo "FAIL exact-fork-head: explicit SHA must not be re-resolved through a branch" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Platform/repository discovery failures use the same audited CANNOT_ASSERT path.
|
||||
audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
||||
git -C "$REPO_DIR" remote set-url origin https://gitlab.com/acme/widgets.git
|
||||
set +e
|
||||
unsupported_output=$(run_guard success "$AUDIT_LOG" 2>&1)
|
||||
unsupported_rc=$?
|
||||
set -e
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.example.test/acme/widgets.git
|
||||
if [[ "$unsupported_rc" -ne 0 ]]; then
|
||||
echo "FAIL unsupported-platform: expected degraded rc=0, got rc=$unsupported_rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$unsupported_output" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL unsupported-platform: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||
if [[ "$audit_lines_after" -le "$audit_lines_before" ]]; then
|
||||
echo "FAIL unsupported-platform: expected an additional audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# A degraded pass is forbidden if the audit receipt cannot be written.
|
||||
mkdir -p "$WORK_DIR/not-a-directory"
|
||||
printf 'file' > "$WORK_DIR/not-a-directory/parent"
|
||||
set +e
|
||||
audit_failure_output=$(run_guard unreachable "$WORK_DIR/not-a-directory/parent/audit.jsonl" 2>&1)
|
||||
audit_failure_rc=$?
|
||||
set -e
|
||||
if [[ "$audit_failure_rc" -eq 0 ]]; then
|
||||
echo "FAIL audit-unavailable: expected rc!=0, got rc=0" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$audit_failure_output" != *"audit"* ]]; then
|
||||
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
assert_provider_observed audit-unavailable
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ci-queue-wait tri-state regression passed (all outcome classes)"
|
||||
@@ -16,6 +16,7 @@
|
||||
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
|
||||
@@ -34,6 +35,8 @@ mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
|
||||
"$REPO_DIR"
|
||||
|
||||
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
||||
cp "$SCRIPT_DIR/resolve-credential-envelope.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-credential-envelope.py"
|
||||
cp "$SCRIPT_DIR/resolve-legacy-token.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-legacy-token.py"
|
||||
chmod +x "$HELPER"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
@@ -84,6 +87,22 @@ git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||
out=$(run_helper "git.mosaicstack.dev" "")
|
||||
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_CREDENTIAL_TRACE=1 2>"$WORK_DIR/shared-trace.stderr")
|
||||
err=$(cat "$WORK_DIR/shared-trace.stderr")
|
||||
if [[ "$err" != *"resolution_path=shared"* || "$err" != *"shared_path_entered=true"* ]]; then
|
||||
echo "FAIL: shared credential materialization did not emit its computed path" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
set +e
|
||||
out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-unset.stderr")
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$WORK_DIR/fleet-unset.stderr")
|
||||
if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=identity-required"* || "$err" != *"shared_path_entered=false"* ]]; then
|
||||
echo "FAIL: fleet unset identity did not stop at the resolver marker" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. git-supplied username resolves to an identity WITH a per-slot token ->
|
||||
@@ -93,6 +112,21 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to
|
||||
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentA MOSAIC_CREDENTIAL_TRACE=1 2>"$WORK_DIR/identity-trace.stderr")
|
||||
err=$(cat "$WORK_DIR/identity-trace.stderr")
|
||||
if [[ "$err" != *"resolution_path=identity"* || "$err" != *"shared_path_entered=false"* ]]; then
|
||||
echo "FAIL: identity credential did not emit its computed path" >&2
|
||||
fail=1
|
||||
fi
|
||||
set +e
|
||||
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$WORK_DIR/fleet-mismatch.stderr")
|
||||
if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then
|
||||
echo "FAIL: fleet identity override was not refused before token resolution" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
|
||||
@@ -120,6 +154,16 @@ out=$(run_helper "git.mosaicstack.dev" "no-such-agent")
|
||||
assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
set +e
|
||||
out=$(run_helper "git.mosaicstack.dev" "no-such-agent" MOSAIC_AGENT_NAME=no-such-agent 2>"$WORK_DIR/fleet-missing.stderr")
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$WORK_DIR/fleet-missing.stderr")
|
||||
if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=no-token-for-identity"* || "$err" != *"shared_path_entered=false"* ]]; then
|
||||
echo "FAIL: fleet missing token did not stop at the resolver marker" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Correct per-slot token PATH is chosen per host: same agent id, different
|
||||
# host prefix (gitea-usc- vs gitea-mosaicstack-).
|
||||
@@ -135,14 +179,47 @@ assert_eq "host-scoped token path (cross-host must not leak): username" "usernam
|
||||
assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
|
||||
# 7. Governed envelopes use the same binding, owner, mode, and digest checks.
|
||||
# ---------------------------------------------------------------------------
|
||||
envelope="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentE.credential.json"
|
||||
python3 - "$envelope" <<'PY'
|
||||
import hashlib, json, sys
|
||||
secret = "agentE-envelope-token"
|
||||
json.dump({
|
||||
"schemaVersion": 1, "identity": "agentE", "estate": "homelab",
|
||||
"host": "git.mosaicstack.dev", "providerLogin": "agentE",
|
||||
"tokenName": "mosaic-agentE-1", "scopes": ["write:repository"],
|
||||
"createdAt": "2026-08-05T00:00:00.000Z",
|
||||
"tokenDigest": hashlib.sha256(secret.encode()).hexdigest(), "token": secret,
|
||||
}, open(sys.argv[1], "w", encoding="utf-8"))
|
||||
PY
|
||||
chmod 600 "$envelope"
|
||||
out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab)
|
||||
assert_eq "governed envelope: password" "password=agentE-envelope-token" "$(echo "$out" | grep '^password=')"
|
||||
echo -n "must-not-fallback-legacy" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentE.token"
|
||||
chmod 640 "$envelope"
|
||||
set +e
|
||||
out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab 2>"$WORK_DIR/envelope-mode.stderr")
|
||||
rc=$?
|
||||
set -e
|
||||
if [[ "$rc" -eq 0 || "$out" == *"password="* ]]; then
|
||||
echo "FAIL: permissive envelope was consumed" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
|
||||
# remotes, e.g. github.com via a different credential helper).
|
||||
# ---------------------------------------------------------------------------
|
||||
out=$(run_helper "github.com" "agentA")
|
||||
assert_eq "unknown host: no output" "" "$out"
|
||||
out=$(run_helper "github.com" "[email protected]")
|
||||
assert_eq "unknown host with non-Mosaic username: no output" "" "$out"
|
||||
out=$(run_helper "github.com" "github-user" MOSAIC_AGENT_NAME=agentA)
|
||||
assert_eq "unknown host in fleet context: no output" "" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
||||
# 9. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
||||
# protocol: this helper only implements get).
|
||||
# ---------------------------------------------------------------------------
|
||||
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
|
||||
@@ -85,7 +86,23 @@ call_get_gitea_token() {
|
||||
# ---------------------------------------------------------------------------
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||
assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out"
|
||||
assert_eq "interactive shared fallback (no identity)" "shared-mosaicstack-token" "$out"
|
||||
|
||||
# Fleet context with no explicit identity refuses before the shared path. This
|
||||
# is the marker-emission positive control for the fail-closed mechanism.
|
||||
set +e
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/stderr-fleet-unset.tmp")
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$WORK_DIR/stderr-fleet-unset.tmp")
|
||||
if [[ "$rc" -eq 0 || -n "$out" ]]; then
|
||||
echo "FAIL: fleet unset identity must refuse with empty stdout" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$err" != *"MOSAIC_CREDENTIAL_REFUSAL"* || "$err" != *"reason=identity-required"* || "$err" != *"shared_path_entered=false"* ]]; then
|
||||
echo "FAIL: fleet unset identity did not emit the stable resolver refusal marker: $err" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot
|
||||
@@ -93,8 +110,17 @@ assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out"
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity agentA
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||
assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out"
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA)
|
||||
assert_eq "confirmed fleet identity bypasses shared path" "agentA-mosaicstack-token" "$out"
|
||||
set +e
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA MOSAIC_GIT_IDENTITY=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$WORK_DIR/fleet-mismatch.stderr")
|
||||
if [[ "$rc" -eq 0 || -n "$out" || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then
|
||||
echo "FAIL: fleet identity override was not refused before token resolution" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||
@@ -143,13 +169,17 @@ assert_failloud() {
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$stderr" != *"MOSAIC_CREDENTIAL_REFUSAL"* || "$stderr" != *"reason=no-token-for-identity"* || "$stderr" != *"shared_path_entered=false"* ]]; then
|
||||
echo "FAIL: $desc — stable resolver refusal marker missing: $stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$stderr" != *"$expected_tok_path"* ]]; then
|
||||
echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$stderr" == *"shared"*"token"* ]]; then
|
||||
echo "FAIL: $desc — stderr unexpectedly mentions a shared token value:" >&2
|
||||
if [[ "$stderr" == *"shared-mosaicstack-token"* || "$stderr" == *"shared-usc-token"* ]]; then
|
||||
echo "FAIL: $desc — stderr unexpectedly contains a shared credential value:" >&2
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
|
||||
# Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -79,15 +79,24 @@ emit_response() {
|
||||
printf '200'
|
||||
fi
|
||||
}
|
||||
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef0123456789abcdef01234567/status"* ]]; then
|
||||
emit_response '{"state":"success","statuses":[{"context":"ci/test","status":"success"}]}'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
|
||||
echo "unexpected merge payload: $post_data" >&2
|
||||
exit 96
|
||||
fi
|
||||
POST_DATA="$post_data" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
payload = json.loads(os.environ["POST_DATA"])
|
||||
assert payload == {
|
||||
"Do": "squash",
|
||||
"head_commit_id": "0123456789abcdef0123456789abcdef01234567",
|
||||
}, payload
|
||||
PY
|
||||
emit_response '{"merged":true,"message":"mock merge complete"}'
|
||||
exit 0
|
||||
fi
|
||||
@@ -107,8 +116,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
|
||||
export GITEA_TOKEN="redacted-test-token"
|
||||
|
||||
OUTPUT="$SANDBOX/output.log"
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
|
||||
echo "--- output ---" >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
echo "--- mock log ---" >&2
|
||||
@@ -127,38 +136,6 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
||||
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
||||
if [[ "$*" == *"login list"* ]]; then
|
||||
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$*" == *"pr merge"* ]]; then
|
||||
echo 'tea network timeout' >&2
|
||||
exit 2
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
: > "$LOG_FILE"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected arbitrary tea failure to remain blocking." >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
|
||||
echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q 'tea network timeout' "$OUTPUT"; then
|
||||
echo "Expected arbitrary tea error to be preserved in output." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
@@ -177,8 +154,8 @@ EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
unset GITEA_LOGIN
|
||||
: > "$LOG_FILE"
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected the exact-head API path not to depend on a tea login." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||
exit 1
|
||||
@@ -215,7 +192,7 @@ cd "$REPO_DIR"
|
||||
git remote set-url origin https://github.com/mosaicstack/stack.git
|
||||
: > "$LOG_FILE"
|
||||
rm -f "$SENTINEL"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
@@ -240,7 +217,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||
: > "$LOG_FILE"
|
||||
rm -f "$SENTINEL"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
@@ -260,4 +237,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-merge.sh Gitea fallback regression passed"
|
||||
echo "pr-merge.sh Gitea exact-head API regression passed"
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck disable=SC2030,SC2031 # Provider arms isolate PATH/credentials in subshells.
|
||||
# The commit whose CI was guarded must be the commit the provider atomically merges.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-head-pin}"
|
||||
SHA=0123456789abcdef0123456789abcdef01234567
|
||||
|
||||
make_fixture() {
|
||||
local name="$1" remote="$2"
|
||||
local root="$WORK_DIR/$name"
|
||||
local tools="$root/tools/git"
|
||||
mkdir -p "$tools" "$root/repo"
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$tools/pr-merge.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
|
||||
git -C "$root/repo" init -q
|
||||
git -C "$root/repo" remote add origin "$remote"
|
||||
cat > "$tools/pr-metadata.sh" <<SH
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
||||
SH
|
||||
cat > "$tools/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$tools"/*.sh
|
||||
}
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
make_fixture gitea https://git.example.test/acme/widgets.git
|
||||
make_fixture github https://github.com/acme/widgets.git
|
||||
|
||||
cat > "$WORK_DIR/gitea/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
payload=""
|
||||
for ((i=1; i<=$#; i++)); do
|
||||
if [[ "${!i}" == "-d" ]]; then
|
||||
j=$((i + 1))
|
||||
payload="${!j}"
|
||||
fi
|
||||
done
|
||||
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
|
||||
printf '200'
|
||||
SH
|
||||
chmod +x "$WORK_DIR/gitea/curl"
|
||||
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123
|
||||
) >"$WORK_DIR/gitea.out" 2>&1
|
||||
gitea_rc=$?
|
||||
set -e
|
||||
if [[ "$gitea_rc" -ne 0 ]]; then
|
||||
echo "FAIL gitea-pin: merge fixture returned $gitea_rc" >&2
|
||||
cat "$WORK_DIR/gitea.out" >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 - "$WORK_DIR/gitea-payload.json" "$SHA" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
assert set(payload) <= {"Do", "head_commit_id", "delete_branch_after_merge"}, payload
|
||||
assert payload.get("Do") == "squash", payload
|
||||
assert payload.get("head_commit_id") == sys.argv[2], payload
|
||||
PY
|
||||
|
||||
# A merge-gate verdict is commit-bound. A stale expected head must fail before merge.
|
||||
wrong_sha=ffffffffffffffffffffffffffffffffffffffff
|
||||
rm -f "$WORK_DIR/gitea-payload-stale.json"
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-stale.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha"
|
||||
) >"$WORK_DIR/gitea-stale.out" 2>&1
|
||||
stale_rc=$?
|
||||
set -e
|
||||
if [[ "$stale_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-stale.json" ]]; then
|
||||
echo "FAIL stale-verdict: moved head was not refused before provider merge" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A merge-capable path cannot bypass the mandatory queue guard. The legacy
|
||||
# --skip-queue-guard option must be rejected before any provider merge call.
|
||||
cat > "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exit 99
|
||||
SH
|
||||
chmod +x "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh"
|
||||
rm -f "$WORK_DIR/gitea-payload-bypass.json"
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-bypass.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard
|
||||
) >"$WORK_DIR/gitea-bypass.out" 2>&1
|
||||
bypass_rc=$?
|
||||
set -e
|
||||
if [[ "$bypass_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-bypass.json" ]]; then
|
||||
echo "FAIL merge-bypass: --skip-queue-guard reached the provider merge path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Dry-run is the only path that may omit the guard because it exits before the
|
||||
# provider merge dispatch. Prove the exit and absence of a merge payload.
|
||||
rm -f "$WORK_DIR/gitea-payload-dry-run.json"
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-dry-run.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run
|
||||
) >"$WORK_DIR/gitea-dry-run.out" 2>&1
|
||||
if [[ -e "$WORK_DIR/gitea-payload-dry-run.json" ]]; then
|
||||
echo "FAIL dry-run: non-merging preflight reached the provider merge path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$WORK_DIR/github/gh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "$*" > "${MOSAIC_GH_MERGE_LOG:?}"
|
||||
SH
|
||||
chmod +x "$WORK_DIR/github/gh"
|
||||
(
|
||||
cd "$WORK_DIR/github/repo"
|
||||
export PATH="$WORK_DIR/github:$PATH"
|
||||
export MOSAIC_GH_MERGE_LOG="$WORK_DIR/github-call.log"
|
||||
"$WORK_DIR/github/tools/git/pr-merge.sh" -n 123
|
||||
) >"$WORK_DIR/github.out" 2>&1
|
||||
if ! grep -q -- "--match-head-commit $SHA" "$WORK_DIR/github-call.log"; then
|
||||
echo "FAIL github-pin: merge command omitted --match-head-commit $SHA" >&2
|
||||
cat "$WORK_DIR/github-call.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PR merge exact-head pin regression passed (Gitea + GitHub)"
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# RM-03: pr-merge must guard the PR head branch, not its main base branch.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-queue-branch}"
|
||||
FIXTURE_DIR="$WORK_DIR/tools/git"
|
||||
CALL_LOG="$WORK_DIR/queue-call.log"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FIXTURE_DIR"
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||
|
||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
|
||||
SH
|
||||
|
||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
|
||||
exit 42
|
||||
SH
|
||||
chmod +x "$FIXTURE_DIR"/*.sh
|
||||
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
|
||||
"$FIXTURE_DIR/pr-merge.sh" -n 123
|
||||
) >/dev/null 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
if [[ "$rc" -ne 42 ]]; then
|
||||
echo "FAIL: expected queue stub rc=42 to propagate, got $rc" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -s "$CALL_LOG" ]]; then
|
||||
echo "FAIL: merge wrapper did not invoke the queue guard" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '-B fix/rm-03-fixture' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive PR head branch" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q -- '-B main' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard still received the main base branch" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive the fork head repository" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '--sha 0123456789abcdef0123456789abcdef01234567' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive the exact PR head SHA" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-merge queue branch/repository/SHA regression passed"
|
||||
@@ -39,11 +39,12 @@ ORIG_PATH="$PATH"
|
||||
# loop — which would make the control a false negative. A root dotfile is
|
||||
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
|
||||
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
||||
SIGNALED="$FW/.install-signal-control.tmp.sh"
|
||||
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
||||
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
||||
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
||||
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||
rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||
|
||||
pass=0; fail=0
|
||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||
@@ -180,41 +181,86 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
|
||||
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
||||
# A bash signal trap that merely returns lets the script continue past the
|
||||
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
||||
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
|
||||
# fires and ONLY the signal path governs), and assert the shipped installer
|
||||
# restores AND exits without reporting success. The control strips `exit 1` from
|
||||
# the trap and shows the buggy resume-to-success.
|
||||
make_term_shim() {
|
||||
local dir="$1"
|
||||
cat > "$dir/cp" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
dest="\${@: -1}"
|
||||
case "\$dest" in
|
||||
*/$POISON_REL)
|
||||
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
|
||||
exec env PATH="$ORIG_PATH" cp "\$@" ;;
|
||||
esac
|
||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||
SHIM
|
||||
chmod +x "$dir/cp"
|
||||
# success. The earlier test used a child cp shim to signal its parent, making
|
||||
# child completion race Bash's interrupted wait. Concurrency is not part of the
|
||||
# guarded property: sync_framework_keep() runs in the installer's own Bash
|
||||
# process, and `kill` is a builtin. Generate two installer fixtures that signal
|
||||
# themselves at the same known mid-sync point. Their TERM handlers emit the same
|
||||
# observable before diverging, so missing signal delivery fails BOTH arms rather
|
||||
# than manufacturing a pass. The only semantic difference between fixtures is
|
||||
# the explicit `exit 1` whose load-bearing behavior this control proves.
|
||||
TERM_MARKER='[test-control] TERM handler entered'
|
||||
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
||||
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
||||
|
||||
make_signal_installer() {
|
||||
local output="$1" handler="$2"
|
||||
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
||||
local target_cp=' cp "$abs" "$dst/$rel"'
|
||||
local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
|
||||
local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION'
|
||||
local inject_close=' fi'
|
||||
|
||||
if ! awk \
|
||||
-v target_trap="$target_trap" -v target_cp="$target_cp" \
|
||||
-v handler="$handler" -v inject_open="$inject_open" \
|
||||
-v inject_kill="$inject_kill" -v inject_close="$inject_close" '
|
||||
$0 == target_cp {
|
||||
print inject_open
|
||||
print inject_kill
|
||||
print inject_close
|
||||
injection_sites++
|
||||
}
|
||||
{ print }
|
||||
$0 == target_trap {
|
||||
print handler
|
||||
handler_sites++
|
||||
}
|
||||
END {
|
||||
if (handler_sites != 1 || injection_sites != 1) exit 42
|
||||
}
|
||||
' "$INSTALL" > "$output"; then
|
||||
rm -f "$output"
|
||||
fail "Could not construct the self-TERM control installer at the exact trap/copy sites"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$output"
|
||||
}
|
||||
|
||||
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
|
||||
make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
|
||||
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
|
||||
signal_fixture_ready() {
|
||||
local fixture="$1" expected_handler="$2"
|
||||
[[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
|
||||
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
|
||||
&& grep -Fqx "$expected_handler" "$fixture"
|
||||
}
|
||||
signaled_fixture_ready() { signal_fixture_ready "$SIGNALED" "$HANDLER_WITH_EXIT"; }
|
||||
noexit_fixture_ready() { signal_fixture_ready "$NOEXIT" "$HANDLER_WITHOUT_EXIT"; }
|
||||
chk "[signal] shipped fixture has exactly one self-TERM injection and marked handler" \
|
||||
"signaled_fixture_ready"
|
||||
chk "[control] no-exit fixture has exactly one self-TERM injection and marked handler" \
|
||||
"noexit_fixture_ready"
|
||||
chk "[control] removing the explicit TERM exit changes the fixture" \
|
||||
"! cmp -s '$SIGNALED' '$NOEXIT'"
|
||||
|
||||
# Run one keep-mode upgrade whose own shell delivers SIGTERM synchronously at
|
||||
# the selected copy. Echoes "<exit>\t<out>\t<home>".
|
||||
run_signal_upgrade() {
|
||||
local installer="$1" H OUT SHIM rc
|
||||
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
||||
local installer="$1" H OUT rc
|
||||
H=$(mktemp -d); OUT=$(mktemp)
|
||||
seed_home "$H"
|
||||
make_term_shim "$SHIM"
|
||||
set +e
|
||||
PATH="$SHIM:$ORIG_PATH" \
|
||||
PATH="$ORIG_PATH" \
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
set -e 2>/dev/null || true
|
||||
rm -rf "$SHIM"
|
||||
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
||||
}
|
||||
|
||||
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
|
||||
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED")
|
||||
chk "[signal] TERM handler observable fires exactly once" \
|
||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
|
||||
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
||||
"[ '$rcC' -ne 0 ]"
|
||||
chk "[signal] restore_snapshot fires on the interrupt" \
|
||||
@@ -222,13 +268,13 @@ chk "[signal] restore_snapshot fires on the interrupt" \
|
||||
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
||||
"! grep -q 'file phase complete' '$OUTC'"
|
||||
|
||||
# Control: strip `exit 1` from the signal trap → the handler returns, the script
|
||||
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
|
||||
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
|
||||
"$INSTALL" > "$NOEXIT"
|
||||
chk "[control] the exit-strip actually changed the installer" \
|
||||
"! cmp -s '$INSTALL' '$NOEXIT'"
|
||||
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||
chk "[control] TERM handler observable fires exactly once" \
|
||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
|
||||
chk "[control] without 'exit 1' the handler restores before returning" \
|
||||
"grep -q 'restoring previous state from snapshot' '$OUTD'"
|
||||
chk "[control] without 'exit 1' the installer exits zero after resuming" \
|
||||
"[ '$rcD' -eq 0 ]"
|
||||
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
||||
"grep -q 'file phase complete' '$OUTD'"
|
||||
|
||||
@@ -309,10 +355,10 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe
|
||||
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
|
||||
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
|
||||
|
||||
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
|
||||
# Cleanup (generated installer controls are also removed by the EXIT trap).
|
||||
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
||||
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
||||
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
"$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
|
||||
echo
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaicstack/mosaic",
|
||||
"version": "0.0.48",
|
||||
"version": "0.0.49",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { registerTelemetryCommand } from './commands/telemetry.js';
|
||||
import { registerAgentCommand } from './commands/agent.js';
|
||||
import { registerInteractionCommand } from './commands/interaction.js';
|
||||
import { registerConfigCommand } from './commands/config.js';
|
||||
import { registerCredentialCommand } from './commands/cred.js';
|
||||
import { registerFleetCommand } from './commands/fleet.js';
|
||||
import { registerMissionCommand } from './commands/mission.js';
|
||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||
@@ -371,6 +372,10 @@ registerInteractionCommand(program);
|
||||
|
||||
registerFleetCommand(program);
|
||||
|
||||
// ─── credential governance ─────────────────────────────────────────────
|
||||
|
||||
registerCredentialCommand(program);
|
||||
|
||||
// ─── config ────────────────────────────────────────────────────────────
|
||||
|
||||
registerConfigCommand(program);
|
||||
|
||||
@@ -0,0 +1,795 @@
|
||||
import {
|
||||
chmod,
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { writeSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CredentialAuditJournal,
|
||||
CredentialJournalError,
|
||||
listCredentialJournals,
|
||||
} from '../credentials/audit-journal.js';
|
||||
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
||||
import { FileCredentialStore } from '../credentials/file-credential-store.js';
|
||||
import { credentialLifecycleLocksDirectory } from '../credentials/lifecycle.js';
|
||||
import { TeaLoginStore } from '../credentials/tea-login-store.js';
|
||||
import { executeCredentialGet, executeCredentialRotate, executeCredentialWire } from './cred.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
async function fixture(): Promise<{
|
||||
readonly mosaicHome: string;
|
||||
readonly registryPath: string;
|
||||
readonly tokenDirectory: string;
|
||||
readonly stateRoot: string;
|
||||
}> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-command-'));
|
||||
await chmod(cleanup, 0o700);
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const credentialDirectory = join(mosaicHome, 'cred');
|
||||
await mkdir(credentialDirectory, { recursive: true, mode: 0o700 });
|
||||
const registryPath = join(credentialDirectory, 'estates.json');
|
||||
await writeFile(
|
||||
registryPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return {
|
||||
mosaicHome,
|
||||
registryPath,
|
||||
tokenDirectory: join(mosaicHome, 'secrets', 'gitea-tokens'),
|
||||
stateRoot: join(cleanup, 'state'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('credential lifecycle command controls', (): void => {
|
||||
it('returns the visible open rotation journal when protected authority resolution fails', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
||||
await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 });
|
||||
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
||||
await store.put(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
providerLogin: 'seat-name',
|
||||
tokenName: 'old-generation',
|
||||
scopes: ['write:repository'],
|
||||
createdAt: '2026-08-05T00:00:00.000Z',
|
||||
},
|
||||
new TextEncoder().encode('old-token-canary'),
|
||||
);
|
||||
|
||||
const result = await executeCredentialRotate('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: '999',
|
||||
tokenName: 'new-generation',
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe('error');
|
||||
expect(result.mutation).toBe('none');
|
||||
expect(result.audit.state).toBe('open');
|
||||
expect(result.audit.journalId).not.toBeNull();
|
||||
await expect(listCredentialJournals(paths.stateRoot)).resolves.toContainEqual(
|
||||
expect.objectContaining({ id: result.audit.journalId, state: 'open' }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['partialWrite', 'shortWrite', 'recordMutation', 'seal'] as const)(
|
||||
'handles %s without overstating credential disclosure',
|
||||
async (method): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
||||
await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 });
|
||||
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
||||
await store.put(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
providerLogin: 'seat-name',
|
||||
tokenName: 'active-generation',
|
||||
scopes: ['write:repository'],
|
||||
createdAt: '2026-08-05T00:00:00.000Z',
|
||||
},
|
||||
new TextEncoder().encode('seat-token-canary'),
|
||||
);
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
const outputPath = join(cleanup!, 'credential.out');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'seat-token-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
async (): Promise<Response> =>
|
||||
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
if (method === 'recordMutation') {
|
||||
const original = CredentialAuditJournal.prototype.recordMutation;
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'recordMutation').mockImplementation(
|
||||
async function (this: CredentialAuditJournal, decision): Promise<void> {
|
||||
if (decision === 'credential-issued') {
|
||||
throw new CredentialJournalError('journal-unavailable', 'injected append failure');
|
||||
}
|
||||
await original.call(this, decision);
|
||||
},
|
||||
);
|
||||
} else if (method === 'seal') {
|
||||
const original = CredentialAuditJournal.prototype.seal;
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
|
||||
this: CredentialAuditJournal,
|
||||
outcome,
|
||||
reason,
|
||||
): Promise<string> {
|
||||
if (outcome === 'ok') {
|
||||
throw new CredentialJournalError('journal-unavailable', 'injected seal failure');
|
||||
}
|
||||
return original.call(this, outcome, reason);
|
||||
});
|
||||
}
|
||||
let writes = 0;
|
||||
const authority = await open(authorityPath, 'r');
|
||||
const output = await open(outputPath, 'w+', 0o600);
|
||||
try {
|
||||
const result = await executeCredentialGet('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
outputFd: output.fd.toString(),
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
credentialWrite:
|
||||
method === 'partialWrite'
|
||||
? (fd, data): number => {
|
||||
writes += 1;
|
||||
if (writes === 2) throw new Error('injected partial write failure');
|
||||
return writeSync(fd, data);
|
||||
}
|
||||
: method === 'shortWrite'
|
||||
? (fd, data): number =>
|
||||
writeSync(fd, data.subarray(0, Math.max(1, Math.floor(data.byteLength / 2))))
|
||||
: undefined,
|
||||
});
|
||||
if (method === 'shortWrite') {
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'ok',
|
||||
mutation: 'none',
|
||||
reason: { code: 'get-verified' },
|
||||
});
|
||||
} else {
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'indeterminate',
|
||||
mutation: method === 'partialWrite' ? 'unknown' : 'applied',
|
||||
reason: { code: 'credential-issuance-indeterminate' },
|
||||
});
|
||||
}
|
||||
const emitted = await readFile(outputPath, 'utf8');
|
||||
if (method === 'partialWrite') {
|
||||
expect(emitted).toContain('username=seat-name');
|
||||
expect(emitted).not.toContain('seat-token-canary');
|
||||
} else {
|
||||
expect(emitted).toContain('password=seat-token-canary');
|
||||
}
|
||||
} finally {
|
||||
await output.close();
|
||||
await authority.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['absent', 'divergent'] as const)(
|
||||
'restores an independently %s Tea pre-state after rotation journal failure',
|
||||
async (teaState): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
||||
await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 });
|
||||
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
||||
await store.put(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
providerLogin: 'seat-name',
|
||||
tokenName: 'old-generation',
|
||||
scopes: ['write:repository'],
|
||||
createdAt: '2026-08-05T00:00:00.000Z',
|
||||
},
|
||||
new TextEncoder().encode('old-token'),
|
||||
);
|
||||
const teaConfig = join(cleanup!, 'tea', 'config.yml');
|
||||
const teaStore = new TeaLoginStore(teaConfig);
|
||||
if (teaState === 'divergent') {
|
||||
await teaStore.put(
|
||||
'seat-name',
|
||||
'git.example.invalid',
|
||||
new TextEncoder().encode('divergent-tea-token'),
|
||||
);
|
||||
}
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
let replacementRevoked = false;
|
||||
vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
|
||||
const method = init?.method ?? 'GET';
|
||||
if (url.pathname === '/api/v1/user') {
|
||||
return new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url.pathname.endsWith('/tokens') && method === 'POST') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
name: 'new-generation',
|
||||
sha1: 'replacement-token',
|
||||
scopes: ['write:repository'],
|
||||
}),
|
||||
{ status: 201, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
if (url.pathname.endsWith('/tokens') && method === 'GET') {
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
replacementRevoked ? [] : [{ name: 'new-generation', scopes: ['write:repository'] }],
|
||||
),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
if (url.pathname.endsWith('/tokens/new-generation') && method === 'DELETE') {
|
||||
replacementRevoked = true;
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
throw new Error(`unexpected provider request: ${method} ${url.pathname}`);
|
||||
});
|
||||
let mintRecords = 0;
|
||||
const recordMutation = CredentialAuditJournal.prototype.recordMutation;
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'recordMutation').mockImplementation(
|
||||
async function (this: CredentialAuditJournal, decision): Promise<void> {
|
||||
if (decision === 'token-mint-applied') {
|
||||
mintRecords += 1;
|
||||
if (mintRecords === 2) {
|
||||
throw new CredentialJournalError('journal-unavailable', 'injected rotation failure');
|
||||
}
|
||||
}
|
||||
await recordMutation.call(this, decision);
|
||||
},
|
||||
);
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
const result = await executeCredentialRotate('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
tokenName: 'new-generation',
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
teaConfig,
|
||||
});
|
||||
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
|
||||
if (teaState === 'absent') {
|
||||
expect(teaStore.snapshot('seat-name', 'git.example.invalid')).toBeUndefined();
|
||||
} else {
|
||||
expect(
|
||||
teaStore.matchesSecret(
|
||||
'seat-name',
|
||||
'git.example.invalid',
|
||||
new TextEncoder().encode('divergent-tea-token'),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves an open nested provision journal in the rotate result', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
||||
await mkdir(paths.tokenDirectory, { recursive: true, mode: 0o700 });
|
||||
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
||||
await store.put(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
providerLogin: 'seat-name',
|
||||
tokenName: 'old-generation',
|
||||
scopes: ['write:repository'],
|
||||
createdAt: '2026-08-05T00:00:00.000Z',
|
||||
},
|
||||
new TextEncoder().encode('old-token'),
|
||||
);
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const snapshot = TeaLoginStore.prototype.snapshot;
|
||||
let snapshotCalls = 0;
|
||||
vi.spyOn(TeaLoginStore.prototype, 'snapshot').mockImplementation(function (
|
||||
this: TeaLoginStore,
|
||||
identity,
|
||||
host,
|
||||
) {
|
||||
snapshotCalls += 1;
|
||||
if (snapshotCalls === 2) throw new Error('injected nested snapshot failure');
|
||||
return snapshot.call(this, identity, host);
|
||||
});
|
||||
const seal = CredentialAuditJournal.prototype.seal;
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
|
||||
this: CredentialAuditJournal,
|
||||
outcome,
|
||||
reasonCode,
|
||||
): Promise<string> {
|
||||
if (reasonCode === 'credential-snapshot-unavailable') {
|
||||
throw new CredentialJournalError('journal-unavailable', 'injected nested seal failure');
|
||||
}
|
||||
return seal.call(this, outcome, reasonCode);
|
||||
});
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
const result = await executeCredentialRotate('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
tokenName: 'new-generation',
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
teaConfig: join(cleanup!, 'tea', 'config.yml'),
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
operation: 'rotate',
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
audit: { state: 'open' },
|
||||
});
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the journal failure diagnosis when rotate lock failure cannot be sealed', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
||||
await mkdir(paths.tokenDirectory, { recursive: true, mode: 0o700 });
|
||||
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
||||
await store.put(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
providerLogin: 'seat-name',
|
||||
tokenName: 'old-generation',
|
||||
scopes: ['write:repository'],
|
||||
createdAt: '2026-08-05T00:00:00.000Z',
|
||||
},
|
||||
new TextEncoder().encode('old-token'),
|
||||
);
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const locksDirectory = credentialLifecycleLocksDirectory();
|
||||
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
|
||||
const lockPath = join(locksDirectory, 'homelab--git.example.invalid--seat-name.lock');
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
await symlink('/dev/null', lockPath);
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockRejectedValue(
|
||||
new CredentialJournalError('journal-recovery-required', 'injected final seal failure'),
|
||||
);
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
const result = await executeCredentialRotate('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
tokenName: 'new-generation',
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
teaConfig: join(cleanup!, 'tea', 'config.yml'),
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
operation: 'rotate',
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
reason: { code: 'journal-recovery-required' },
|
||||
audit: { state: 'open' },
|
||||
});
|
||||
} finally {
|
||||
await authority.close();
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses an unauthenticated actor before rewriting another seat environment', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||
const before = 'MOSAIC_AGENT_NAME=seat-name\nMOSAIC_AGENT_CLASS=coder\n';
|
||||
await writeFile(seatEnvironment, before, { mode: 0o600 });
|
||||
|
||||
const result = await executeCredentialWire('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'intruder-seat',
|
||||
seatEnv: seatEnvironment,
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe('refused');
|
||||
expect(result.mutation).toBe('none');
|
||||
await expect(readFile(seatEnvironment, 'utf8')).resolves.toBe(before);
|
||||
});
|
||||
|
||||
it('authenticates the exact seat and rewrites its roster-derived projection idempotently', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\nMOSAIC_AGENT_CLASS=coder\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
async (): Promise<Response> =>
|
||||
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const invoke = async () => {
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
return await executeCredentialWire('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
seatEnv: seatEnvironment,
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
});
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
};
|
||||
|
||||
const first = await invoke();
|
||||
const afterFirst = await readFile(seatEnvironment, 'utf8');
|
||||
const second = await invoke();
|
||||
const afterSecond = await readFile(seatEnvironment, 'utf8');
|
||||
|
||||
expect(first.outcome).toBe('ok');
|
||||
expect(second.outcome).toBe('ok');
|
||||
expect(afterSecond).toBe(afterFirst);
|
||||
expect(afterSecond).toContain('MOSAIC_GIT_IDENTITY=seat-name\n');
|
||||
expect(afterSecond).toContain('MOSAIC_CREDENTIAL_ESTATE=homelab\n');
|
||||
expect(afterSecond).toContain('GITEA_LOGIN=seat-name--git.example.invalid\n');
|
||||
});
|
||||
|
||||
it.each(['recordMutation', 'seal'] as const)(
|
||||
'reports an applied wire as indeterminate when audit %s fails after rename',
|
||||
async (method): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
async (): Promise<Response> =>
|
||||
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.spyOn(CredentialAuditJournal.prototype, method).mockRejectedValueOnce(
|
||||
new CredentialJournalError('journal-unavailable', 'injected audit failure'),
|
||||
);
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
const result = await executeCredentialWire('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
seatEnv: seatEnvironment,
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
});
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.mutation).toBe('applied');
|
||||
expect(await readFile(seatEnvironment, 'utf8')).toContain('MOSAIC_GIT_IDENTITY=seat-name');
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('refuses to overwrite a roster projection replaced after validation', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
async (): Promise<Response> =>
|
||||
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
const result = await executeCredentialWire('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
seatEnv: seatEnvironment,
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
wireBeforeRename: async (): Promise<void> => {
|
||||
const replacement = join(agents, 'replacement');
|
||||
await writeFile(replacement, 'MOSAIC_AGENT_NAME=seat-name\nNEW=value\n', { mode: 0o600 });
|
||||
await rename(replacement, seatEnvironment);
|
||||
},
|
||||
});
|
||||
expect(result.outcome).toBe('error');
|
||||
expect(result.mutation).toBe('none');
|
||||
expect(await readFile(seatEnvironment, 'utf8')).toBe(
|
||||
'MOSAIC_AGENT_NAME=seat-name\nNEW=value\n',
|
||||
);
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports directory-sync failure after rename as applied and indeterminate', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
async (): Promise<Response> =>
|
||||
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
const result = await executeCredentialWire('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
seatEnv: seatEnvironment,
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
wireDirectorySync: async (): Promise<void> => {
|
||||
throw new Error('injected directory sync failure');
|
||||
},
|
||||
});
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.mutation).toBe('applied');
|
||||
expect(await readFile(seatEnvironment, 'utf8')).toContain('MOSAIC_GIT_IDENTITY=seat-name');
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('removes a temporary projection when directory revalidation fails before rename', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
vi.stubGlobal('fetch', async (): Promise<Response> => {
|
||||
await chmod(agents, 0o777);
|
||||
return new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
const result = await executeCredentialWire('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
seatEnv: seatEnvironment,
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
});
|
||||
expect(result.outcome).toBe('error');
|
||||
expect(await readdir(agents)).toEqual(['seat-name.env.generated']);
|
||||
} finally {
|
||||
await authority.close();
|
||||
await chmod(agents, 0o700);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a caller-selected seat filename that is not bound to the requested identity', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||
const seatEnvironment = join(agents, 'other-seat.env.generated');
|
||||
const before = 'MOSAIC_AGENT_NAME=other-seat\nMOSAIC_AGENT_CLASS=coder\n';
|
||||
await writeFile(seatEnvironment, before, { mode: 0o600 });
|
||||
|
||||
const result = await executeCredentialWire('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: '999',
|
||||
seatEnv: seatEnvironment,
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe('refused');
|
||||
expect(result.reason.code).toBe('credential-binding-mismatch');
|
||||
await expect(readFile(seatEnvironment, 'utf8')).resolves.toBe(before);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
import type { FileHandle } from 'node:fs/promises';
|
||||
|
||||
export type CredentialJournalOperation =
|
||||
| 'provision'
|
||||
| 'wire'
|
||||
| 'grant'
|
||||
| 'get'
|
||||
| 'validate'
|
||||
| 'rotate'
|
||||
| 'revoke'
|
||||
| 'whoami'
|
||||
| 'list'
|
||||
| 'audit';
|
||||
|
||||
export interface CredentialJournalContextDto {
|
||||
readonly operation: CredentialJournalOperation;
|
||||
readonly actor: string;
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
readonly repo: string | null;
|
||||
}
|
||||
|
||||
export interface CredentialProviderJournalEvidenceDto {
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
readonly decision: string;
|
||||
}
|
||||
|
||||
export interface CredentialJournalCorrectionDto {
|
||||
readonly supersedesJournalId: string;
|
||||
readonly correctedByJournalId: string;
|
||||
readonly previousReason: string;
|
||||
readonly correctedReason: string;
|
||||
readonly previousOutcome?: 'ok' | 'refused' | 'error' | 'indeterminate';
|
||||
readonly correctedOutcome?: 'ok' | 'refused' | 'error' | 'indeterminate';
|
||||
}
|
||||
|
||||
export interface CredentialPopulationCorrectionDto {
|
||||
readonly entries: readonly {
|
||||
readonly identity: string;
|
||||
readonly supersedesJournalIds: readonly string[];
|
||||
readonly settledByJournalId: string;
|
||||
readonly capability: 'confirmed';
|
||||
readonly identityBinding: 'not-measured';
|
||||
readonly mechanism: 'identity-scope-forbidden-in-scope-capability-confirmed';
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CredentialJournalRuntimeOptionsDto {
|
||||
readonly id?: string;
|
||||
readonly now?: () => string;
|
||||
readonly syncDirectory?: (path: string) => Promise<void>;
|
||||
readonly rename?: (source: string, destination: string) => Promise<void>;
|
||||
readonly write?: (
|
||||
handle: FileHandle,
|
||||
data: Uint8Array,
|
||||
offset: number,
|
||||
length: number,
|
||||
) => Promise<number>;
|
||||
}
|
||||
|
||||
export interface CredentialJournalSummaryDto {
|
||||
readonly id: string;
|
||||
readonly state: 'open' | 'sealed';
|
||||
readonly path: string;
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
open,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
symlink,
|
||||
truncate,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { CredentialAuditJournal, listCredentialJournals } from './audit-journal.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
|
||||
async function stateRoot(): Promise<string> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-journal-'));
|
||||
return join(cleanup, 'state');
|
||||
}
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
describe('credential durable audit journal', (): void => {
|
||||
it('opens before mutation, appends provider evidence, and seals durably', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
const journal = await CredentialAuditJournal.open(
|
||||
root,
|
||||
{
|
||||
operation: 'grant',
|
||||
actor: 'provisioner',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
},
|
||||
{ id: 'journal-id', now: (): string => '2026-08-05T00:00:00.000Z' },
|
||||
);
|
||||
|
||||
await journal.recordIntent('provider-grant');
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: 'GET /api/v1/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
decision: 'permission-write',
|
||||
});
|
||||
const sealedPath = await journal.seal('ok', 'grant-verified');
|
||||
|
||||
expect(sealedPath).toMatch(/journal-id\.sealed\.jsonl$/);
|
||||
const records = (await readFile(sealedPath, 'utf8')).trim().split('\n');
|
||||
expect(records).toHaveLength(4);
|
||||
expect(records[0]).toContain('"phase":"opened"');
|
||||
expect(records[1]).toContain('"phase":"intent"');
|
||||
expect(records[2]).toContain('"phase":"provider-evidence"');
|
||||
expect(records[3]).toContain('"phase":"sealed"');
|
||||
});
|
||||
|
||||
it('writes every journal record completely when each write makes one-byte progress', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
let writeCalls = 0;
|
||||
const journal = await CredentialAuditJournal.open(
|
||||
root,
|
||||
{
|
||||
operation: 'grant',
|
||||
actor: 'provisioner',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
},
|
||||
{
|
||||
id: 'short-write',
|
||||
now: (): string => '2026-08-05T00:00:00.000Z',
|
||||
write: async (handle, data, offset, length): Promise<number> => {
|
||||
writeCalls += 1;
|
||||
const result = await handle.write(data, offset, Math.min(1, length), null);
|
||||
return result.bytesWritten;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await journal.recordIntent('provider-grant');
|
||||
const sealedPath = await journal.seal('ok', 'grant-verified');
|
||||
const records = (await readFile(sealedPath, 'utf8')).trim().split('\n');
|
||||
|
||||
expect(writeCalls).toBeGreaterThan(3);
|
||||
expect(records).toHaveLength(3);
|
||||
expect(records[0]).toContain('"phase":"opened"');
|
||||
expect(records[1]).toContain('"phase":"intent"');
|
||||
expect(records[2]).toContain('"phase":"sealed"');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['zero', (_remaining: number): number => 0],
|
||||
['oversized', (remaining: number): number => remaining + 1],
|
||||
] as const)(
|
||||
'rejects %s journal write progress before reporting an opened journal',
|
||||
async (_label, progress): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
await expect(
|
||||
CredentialAuditJournal.open(
|
||||
root,
|
||||
{
|
||||
operation: 'grant',
|
||||
actor: 'provisioner',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
},
|
||||
{
|
||||
id: `invalid-progress-${_label}`,
|
||||
write: async (_handle, _data, _offset, length): Promise<number> => progress(length),
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/journal-unavailable/);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a final seal non-accepting while directory durability is pending', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
let directorySyncs = 0;
|
||||
let signalFinalSync: (() => void) | undefined;
|
||||
let releaseFinalSync: (() => void) | undefined;
|
||||
const finalSyncEntered = new Promise<void>((resolve): void => {
|
||||
signalFinalSync = resolve;
|
||||
});
|
||||
const finalSyncRelease = new Promise<void>((resolve): void => {
|
||||
releaseFinalSync = resolve;
|
||||
});
|
||||
const journal = await CredentialAuditJournal.open(
|
||||
root,
|
||||
{
|
||||
operation: 'get',
|
||||
actor: 'seat-name',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: null,
|
||||
},
|
||||
{
|
||||
id: 'seal-pending',
|
||||
syncDirectory: async (path): Promise<void> => {
|
||||
directorySyncs += 1;
|
||||
if (directorySyncs === 3) {
|
||||
signalFinalSync?.();
|
||||
await finalSyncRelease;
|
||||
}
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const sealing = journal.seal('ok', 'get-verified');
|
||||
await finalSyncEntered;
|
||||
await expect(listCredentialJournals(root)).resolves.toContainEqual(
|
||||
expect.objectContaining({ id: 'seal-pending', state: 'open' }),
|
||||
);
|
||||
releaseFinalSync?.();
|
||||
await sealing;
|
||||
await expect(listCredentialJournals(root)).resolves.toContainEqual(
|
||||
expect.objectContaining({ id: 'seal-pending', state: 'sealed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reverts a failed final seal commit to visible open state', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
let directorySyncs = 0;
|
||||
const journal = await CredentialAuditJournal.open(
|
||||
root,
|
||||
{
|
||||
operation: 'get',
|
||||
actor: 'seat-name',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: null,
|
||||
},
|
||||
{
|
||||
id: 'seal-fault',
|
||||
syncDirectory: async (path): Promise<void> => {
|
||||
directorySyncs += 1;
|
||||
if (directorySyncs === 3) throw new Error('injected final directory sync failure');
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await expect(journal.seal('ok', 'get-verified')).rejects.toThrow(/journal-unavailable/);
|
||||
const [entry] = await listCredentialJournals(root);
|
||||
expect(entry).toMatchObject({ id: 'seal-fault', state: 'open' });
|
||||
expect(await readFile(entry?.path ?? '', 'utf8')).not.toContain('"phase":"sealed"');
|
||||
});
|
||||
|
||||
it('uses a non-accepting recovery path when the compensating rename fails', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
let directorySyncs = 0;
|
||||
let renames = 0;
|
||||
const journal = await CredentialAuditJournal.open(
|
||||
root,
|
||||
{
|
||||
operation: 'get',
|
||||
actor: 'seat-name',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: null,
|
||||
},
|
||||
{
|
||||
id: 'seal-recovery-fault',
|
||||
syncDirectory: async (path): Promise<void> => {
|
||||
directorySyncs += 1;
|
||||
if (directorySyncs === 3) throw new Error('injected final directory sync failure');
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
rename: async (source, destination): Promise<void> => {
|
||||
renames += 1;
|
||||
if (renames === 3) throw new Error('injected compensating rename failure');
|
||||
await rename(source, destination);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await expect(journal.seal('ok', 'get-verified')).rejects.toThrow(/journal-recovery-required/);
|
||||
const [entry] = await listCredentialJournals(root);
|
||||
expect(entry).toMatchObject({ id: 'seal-recovery-fault', state: 'open' });
|
||||
expect(entry?.path).toMatch(/\.recovery\.jsonl$/);
|
||||
expect(await readFile(entry?.path ?? '', 'utf8')).not.toContain('"phase":"sealed"');
|
||||
});
|
||||
|
||||
it.each(['symlink', 'oversized'] as const)(
|
||||
'classifies an unsafe %s sealed-looking journal as open without consuming it',
|
||||
async (kind): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
const journal = await CredentialAuditJournal.open(root, {
|
||||
operation: 'audit',
|
||||
actor: 'seat-name',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: null,
|
||||
});
|
||||
await journal.closeIncomplete();
|
||||
const journalsDirectory = join(root, 'journals');
|
||||
const candidate = join(journalsDirectory, `unsafe-${kind}.sealed.jsonl`);
|
||||
if (kind === 'symlink') {
|
||||
if (cleanup === undefined) throw new Error('test fixture root is unavailable');
|
||||
const outside = join(cleanup, 'outside-journal');
|
||||
await writeFile(outside, '{"phase":"sealed","outcome":"ok"}\n', { mode: 0o600 });
|
||||
await symlink(outside, candidate);
|
||||
} else {
|
||||
await writeFile(candidate, '', { mode: 0o600 });
|
||||
await truncate(candidate, 4 * 1024 * 1024 + 1);
|
||||
}
|
||||
|
||||
const entry = (await listCredentialJournals(root)).find(
|
||||
(value): boolean => value.id === `unsafe-${kind}`,
|
||||
);
|
||||
expect(entry).toMatchObject({ state: 'open' });
|
||||
},
|
||||
);
|
||||
|
||||
it('leaves an unsealed journal visible for recovery', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
const journal = await CredentialAuditJournal.open(root, {
|
||||
operation: 'rotate',
|
||||
actor: 'provisioner',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: null,
|
||||
});
|
||||
|
||||
const journals = await listCredentialJournals(root);
|
||||
|
||||
expect(journals).toHaveLength(1);
|
||||
expect(journals[0]?.state).toBe('open');
|
||||
await journal.closeIncomplete();
|
||||
});
|
||||
|
||||
it('fails fatally when the durable journal root cannot be created', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
await writeFile(root, 'not-a-directory', { mode: 0o600 });
|
||||
|
||||
await expect(
|
||||
CredentialAuditJournal.open(root, {
|
||||
operation: 'grant',
|
||||
actor: 'provisioner',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
}),
|
||||
).rejects.toThrow(/journal-unavailable/);
|
||||
});
|
||||
|
||||
it('rejects secret-shaped evidence instead of writing it', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
const journal = await CredentialAuditJournal.open(root, {
|
||||
operation: 'validate',
|
||||
actor: 'seat-name',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
});
|
||||
|
||||
await expect(
|
||||
journal.recordProviderEvidence({
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
decision: 'seeded-secret-canary',
|
||||
}),
|
||||
).rejects.toThrow(/unsafe-audit-value/);
|
||||
const journals = await listCredentialJournals(root);
|
||||
const source = await readFile(journals[0]?.path ?? '', 'utf8');
|
||||
expect(source).not.toContain('seeded-secret-canary');
|
||||
await journal.closeIncomplete();
|
||||
});
|
||||
|
||||
it('refuses a group-writable journal root before opening evidence', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
await mkdir(root, { mode: 0o700 });
|
||||
await chmod(root, 0o770);
|
||||
await expect(
|
||||
CredentialAuditJournal.open(root, {
|
||||
operation: 'grant',
|
||||
actor: 'provisioner',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
}),
|
||||
).rejects.toThrow(/journal-unavailable/);
|
||||
});
|
||||
|
||||
it('supersedes a false sealed classification without editing the original journal', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
const journal = await CredentialAuditJournal.open(
|
||||
root,
|
||||
{
|
||||
operation: 'validate',
|
||||
actor: 'be-coder-06',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
},
|
||||
{ id: 'correction-1' },
|
||||
);
|
||||
await journal.recordIntent('classification-correction');
|
||||
await journal.recordCorrection({
|
||||
supersedesJournalId: 'old-sealed-id',
|
||||
correctedByJournalId: 'new-validation-id',
|
||||
previousReason: 'identity-not-found',
|
||||
correctedReason: 'credential-rejected',
|
||||
previousOutcome: 'indeterminate',
|
||||
correctedOutcome: 'refused',
|
||||
});
|
||||
const path = await journal.seal('indeterminate', 'credential-rejected');
|
||||
const source = await readFile(path, 'utf8');
|
||||
expect(source).toContain('"phase":"classification-correction"');
|
||||
expect(source).toContain('"supersedesJournalId":"old-sealed-id"');
|
||||
});
|
||||
|
||||
it('records one settled population correction across a classification chain', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
const journal = await CredentialAuditJournal.open(root, {
|
||||
operation: 'validate',
|
||||
actor: 'be-coder-06',
|
||||
identity: 'fleet-reconciliation',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
});
|
||||
await journal.recordIntent('classification-correction');
|
||||
await journal.recordPopulationCorrection({
|
||||
entries: [
|
||||
{
|
||||
identity: 'seat-name',
|
||||
supersedesJournalIds: ['v1-2-id', 'v1-4-id', 'v1-4-1-id'],
|
||||
settledByJournalId: 'v1-5-id',
|
||||
capability: 'confirmed',
|
||||
identityBinding: 'not-measured',
|
||||
mechanism: 'identity-scope-forbidden-in-scope-capability-confirmed',
|
||||
},
|
||||
],
|
||||
});
|
||||
const path = await journal.seal('ok', 'classification-corrected');
|
||||
const source = await readFile(path, 'utf8');
|
||||
expect(source).toContain('"phase":"population-classification-correction"');
|
||||
expect(source).toContain('"capability":"confirmed"');
|
||||
expect(source).toContain('"identityBinding":"not-measured"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,467 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { constants, lstatSync } from 'node:fs';
|
||||
import { open, readdir, rename } from 'node:fs/promises';
|
||||
import type { FileHandle } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
|
||||
import type {
|
||||
CredentialJournalContextDto,
|
||||
CredentialJournalCorrectionDto,
|
||||
CredentialJournalRuntimeOptionsDto,
|
||||
CredentialPopulationCorrectionDto,
|
||||
CredentialJournalSummaryDto,
|
||||
CredentialProviderJournalEvidenceDto,
|
||||
} from './audit-journal.dto.js';
|
||||
|
||||
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
const SAFE_ESTATE = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const SAFE_HOST = /^[a-z0-9][a-z0-9.-]*$/;
|
||||
const SAFE_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
const SAFE_ENDPOINT = /^(?:GET|PUT|POST|DELETE) \/[A-Za-z0-9_./{}:-]+$/;
|
||||
const SAFE_CONTENT_TYPE = /^[A-Za-z0-9!#$&^_.+/-]+(?:;[A-Za-z0-9=._+-]+)*$/;
|
||||
const MAX_JOURNAL_BYTES = 4 * 1024 * 1024;
|
||||
const SAFE_DECISIONS = new Set<string>([
|
||||
'provider-grant',
|
||||
'permission-none',
|
||||
'permission-read',
|
||||
'permission-write',
|
||||
'permission-admin',
|
||||
'identity-verified',
|
||||
'inventory-authority-verified',
|
||||
'scope-verified',
|
||||
'grant-verified',
|
||||
'revoke-verified',
|
||||
'rotate-verified',
|
||||
'validation-requested',
|
||||
'whoami-requested',
|
||||
'provision-requested',
|
||||
'rotate-requested',
|
||||
'revoke-requested',
|
||||
'wire-requested',
|
||||
'get-requested',
|
||||
'validation-verified',
|
||||
'team-member-present',
|
||||
'team-member-absent',
|
||||
'team-repository-present',
|
||||
'team-repository-absent',
|
||||
'team-repository-set-verified',
|
||||
'organization-member-present',
|
||||
'organization-member-absent',
|
||||
'collaborator-grant-applied',
|
||||
'team-member-applied',
|
||||
'team-member-rollback-applied',
|
||||
'team-repository-applied',
|
||||
'team-repository-rollback-applied',
|
||||
'transport-write-verified',
|
||||
'token-mint-applied',
|
||||
'token-binding-stored',
|
||||
'tea-login-stored',
|
||||
'tea-login-removed',
|
||||
'provision-rollback-verified',
|
||||
'rotate-rollback-verified',
|
||||
'token-revoke-applied',
|
||||
'wire-applied',
|
||||
'credential-issuance-authorized',
|
||||
'credential-issuance-started',
|
||||
'credential-issuance-possibly-issued',
|
||||
'credential-issued',
|
||||
'classification-correction',
|
||||
]);
|
||||
|
||||
export class CredentialJournalError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(`Credential audit journal failed: code=${code} ${message}`);
|
||||
this.name = 'CredentialJournalError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertPrivateDirectory(path: string): void {
|
||||
const stat = lstatSync(path);
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== process.getuid?.() ||
|
||||
(stat.mode & 0o022) !== 0
|
||||
) {
|
||||
throw new CredentialJournalError(
|
||||
'journal-unavailable',
|
||||
'journal directory owner or write permissions are unsafe',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertContext(context: CredentialJournalContextDto): void {
|
||||
if (
|
||||
!SAFE_NAME.test(context.actor) ||
|
||||
!SAFE_NAME.test(context.identity) ||
|
||||
!SAFE_ESTATE.test(context.estate) ||
|
||||
!SAFE_HOST.test(context.host) ||
|
||||
(context.repo !== null && !SAFE_REPO.test(context.repo))
|
||||
) {
|
||||
throw new CredentialJournalError(
|
||||
'unsafe-audit-value',
|
||||
'journal context is outside the non-secret allowlist grammar',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEvidence(evidence: CredentialProviderJournalEvidenceDto): void {
|
||||
if (
|
||||
!SAFE_ENDPOINT.test(evidence.endpoint) ||
|
||||
!SAFE_CONTENT_TYPE.test(evidence.contentType) ||
|
||||
!SAFE_DECISIONS.has(evidence.decision)
|
||||
) {
|
||||
throw new CredentialJournalError(
|
||||
'unsafe-audit-value',
|
||||
'provider evidence is outside the non-secret allowlist',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJournalBytes(
|
||||
handle: FileHandle,
|
||||
data: Uint8Array,
|
||||
offset: number,
|
||||
length: number,
|
||||
): Promise<number> {
|
||||
const result = await handle.write(data, offset, length, null);
|
||||
return result.bytesWritten;
|
||||
}
|
||||
|
||||
async function syncDirectory(path: string): Promise<void> {
|
||||
const directory = await open(path, 'r');
|
||||
try {
|
||||
await directory.sync();
|
||||
} finally {
|
||||
await directory.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireJournalLock(
|
||||
path: string,
|
||||
mode: 'exclusive' | 'shared',
|
||||
): Promise<FileHandle | undefined> {
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
handle = await open(path, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) {
|
||||
throw new Error('journal lock file is unsafe');
|
||||
}
|
||||
const acquired = spawnSync('/usr/bin/flock', ['-n', mode === 'exclusive' ? '-x' : '-s', '3'], {
|
||||
stdio: ['ignore', 'ignore', 'ignore', handle.fd],
|
||||
});
|
||||
if (acquired.error !== undefined || acquired.status !== 0) {
|
||||
await handle.close();
|
||||
return undefined;
|
||||
}
|
||||
return handle;
|
||||
} catch {
|
||||
await handle?.close().catch((): void => undefined);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class CredentialAuditJournal {
|
||||
private closed = false;
|
||||
|
||||
private constructor(
|
||||
private readonly handle: FileHandle,
|
||||
private readonly lockHandle: FileHandle,
|
||||
private readonly openPath: string,
|
||||
private readonly journalsDirectory: string,
|
||||
private readonly id: string,
|
||||
private readonly now: () => string,
|
||||
private readonly syncJournalDirectory: (path: string) => Promise<void>,
|
||||
private readonly renameJournal: (source: string, destination: string) => Promise<void>,
|
||||
private readonly writeJournal: (
|
||||
handle: FileHandle,
|
||||
data: Uint8Array,
|
||||
offset: number,
|
||||
length: number,
|
||||
) => Promise<number>,
|
||||
) {}
|
||||
|
||||
static async open(
|
||||
stateRoot: string,
|
||||
context: CredentialJournalContextDto,
|
||||
runtime: CredentialJournalRuntimeOptionsDto = {},
|
||||
): Promise<CredentialAuditJournal> {
|
||||
assertContext(context);
|
||||
const id = runtime.id ?? randomUUID();
|
||||
if (!SAFE_NAME.test(id)) {
|
||||
throw new CredentialJournalError('unsafe-audit-value', 'journal id is outside the grammar');
|
||||
}
|
||||
const now = runtime.now ?? ((): string => new Date().toISOString());
|
||||
const journalsDirectory = join(stateRoot, 'journals');
|
||||
const locksDirectory = join(stateRoot, 'journal-locks');
|
||||
let handle: FileHandle | undefined;
|
||||
let lockHandle: FileHandle | undefined;
|
||||
try {
|
||||
ensureManagedDirectory(stateRoot, journalsDirectory);
|
||||
ensureManagedDirectory(stateRoot, locksDirectory);
|
||||
assertPrivateDirectory(stateRoot);
|
||||
assertPrivateDirectory(journalsDirectory);
|
||||
assertPrivateDirectory(locksDirectory);
|
||||
const openPath = join(journalsDirectory, `${id}.open.jsonl`);
|
||||
const lockPath = join(locksDirectory, `${id}.lock`);
|
||||
handle = await open(openPath, 'wx', 0o600);
|
||||
lockHandle = await acquireJournalLock(lockPath, 'exclusive');
|
||||
if (lockHandle === undefined) {
|
||||
throw new CredentialJournalError(
|
||||
'journal-unavailable',
|
||||
'journal lock could not be acquired',
|
||||
);
|
||||
}
|
||||
const syncJournalDirectory = runtime.syncDirectory ?? syncDirectory;
|
||||
const renameJournal = runtime.rename ?? rename;
|
||||
const journal = new CredentialAuditJournal(
|
||||
handle,
|
||||
lockHandle,
|
||||
openPath,
|
||||
journalsDirectory,
|
||||
id,
|
||||
now,
|
||||
syncJournalDirectory,
|
||||
renameJournal,
|
||||
runtime.write ?? writeJournalBytes,
|
||||
);
|
||||
await journal.append({ phase: 'opened', at: now(), context });
|
||||
await syncJournalDirectory(journalsDirectory);
|
||||
return journal;
|
||||
} catch (error: unknown) {
|
||||
if (handle !== undefined) await handle.close().catch((): void => undefined);
|
||||
if (lockHandle !== undefined) await lockHandle.close().catch((): void => undefined);
|
||||
if (error instanceof CredentialJournalError) throw error;
|
||||
throw new CredentialJournalError(
|
||||
'journal-unavailable',
|
||||
'durable journal could not be opened and fsynced',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async append(record: object): Promise<void> {
|
||||
if (this.closed) {
|
||||
throw new CredentialJournalError('journal-unavailable', 'journal is already closed');
|
||||
}
|
||||
try {
|
||||
const data = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8');
|
||||
let offset = 0;
|
||||
while (offset < data.byteLength) {
|
||||
const remaining = data.byteLength - offset;
|
||||
const written = await this.writeJournal(this.handle, data, offset, remaining);
|
||||
if (!Number.isSafeInteger(written) || written <= 0 || written > remaining) {
|
||||
throw new Error('journal write made invalid progress');
|
||||
}
|
||||
offset += written;
|
||||
}
|
||||
await this.handle.sync();
|
||||
} catch {
|
||||
throw new CredentialJournalError(
|
||||
'journal-unavailable',
|
||||
'durable journal append or fsync failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
journalId(): string {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
async recordIntent(decision: string): Promise<void> {
|
||||
if (!SAFE_DECISIONS.has(decision)) {
|
||||
throw new CredentialJournalError(
|
||||
'unsafe-audit-value',
|
||||
'intent decision is outside the non-secret allowlist',
|
||||
);
|
||||
}
|
||||
await this.append({ phase: 'intent', at: this.now(), decision });
|
||||
}
|
||||
|
||||
async recordProviderEvidence(evidence: CredentialProviderJournalEvidenceDto): Promise<void> {
|
||||
assertEvidence(evidence);
|
||||
await this.append({ phase: 'provider-evidence', at: this.now(), evidence });
|
||||
}
|
||||
|
||||
async recordMutation(decision: string): Promise<void> {
|
||||
if (!SAFE_DECISIONS.has(decision)) {
|
||||
throw new CredentialJournalError(
|
||||
'unsafe-audit-value',
|
||||
'mutation decision is outside the non-secret allowlist',
|
||||
);
|
||||
}
|
||||
await this.append({ phase: 'mutation', at: this.now(), decision });
|
||||
}
|
||||
|
||||
async recordCorrection(correction: CredentialJournalCorrectionDto): Promise<void> {
|
||||
if (
|
||||
!SAFE_NAME.test(correction.supersedesJournalId) ||
|
||||
!SAFE_NAME.test(correction.correctedByJournalId) ||
|
||||
!SAFE_NAME.test(correction.previousReason) ||
|
||||
!SAFE_NAME.test(correction.correctedReason) ||
|
||||
(correction.previousOutcome === undefined) !== (correction.correctedOutcome === undefined)
|
||||
) {
|
||||
throw new CredentialJournalError(
|
||||
'unsafe-audit-value',
|
||||
'classification correction is outside the non-secret grammar',
|
||||
);
|
||||
}
|
||||
await this.append({ phase: 'classification-correction', at: this.now(), correction });
|
||||
}
|
||||
|
||||
async recordPopulationCorrection(correction: CredentialPopulationCorrectionDto): Promise<void> {
|
||||
if (
|
||||
correction.entries.length === 0 ||
|
||||
correction.entries.some(
|
||||
(entry): boolean =>
|
||||
!SAFE_NAME.test(entry.identity) ||
|
||||
!SAFE_NAME.test(entry.settledByJournalId) ||
|
||||
entry.supersedesJournalIds.length === 0 ||
|
||||
entry.supersedesJournalIds.some((id): boolean => !SAFE_NAME.test(id)),
|
||||
)
|
||||
) {
|
||||
throw new CredentialJournalError(
|
||||
'unsafe-audit-value',
|
||||
'population correction is outside the non-secret grammar',
|
||||
);
|
||||
}
|
||||
await this.append({
|
||||
phase: 'population-classification-correction',
|
||||
at: this.now(),
|
||||
correction,
|
||||
});
|
||||
}
|
||||
|
||||
async seal(
|
||||
outcome: 'ok' | 'refused' | 'error' | 'indeterminate',
|
||||
reasonCode: string,
|
||||
): Promise<string> {
|
||||
if (!SAFE_NAME.test(reasonCode)) {
|
||||
throw new CredentialJournalError(
|
||||
'unsafe-audit-value',
|
||||
'reason code is outside the non-secret grammar',
|
||||
);
|
||||
}
|
||||
const preSealSize = (await this.handle.stat()).size;
|
||||
const sealingPath = join(this.journalsDirectory, `${this.id}.sealing.jsonl`);
|
||||
const sealedPath = join(this.journalsDirectory, `${this.id}.sealed.jsonl`);
|
||||
const recoveryPath = join(this.journalsDirectory, `${this.id}.recovery.jsonl`);
|
||||
let currentPath = this.openPath;
|
||||
try {
|
||||
await this.renameJournal(currentPath, sealingPath);
|
||||
currentPath = sealingPath;
|
||||
await this.syncJournalDirectory(this.journalsDirectory);
|
||||
await this.append({ phase: 'sealed', at: this.now(), outcome, reasonCode });
|
||||
await this.renameJournal(currentPath, sealedPath);
|
||||
currentPath = sealedPath;
|
||||
await this.syncJournalDirectory(this.journalsDirectory);
|
||||
await this.handle.close();
|
||||
await this.lockHandle.close();
|
||||
this.closed = true;
|
||||
return sealedPath;
|
||||
} catch {
|
||||
let recovered = true;
|
||||
try {
|
||||
await this.handle.truncate(preSealSize);
|
||||
await this.handle.sync();
|
||||
} catch {
|
||||
recovered = false;
|
||||
}
|
||||
if (currentPath !== this.openPath) {
|
||||
try {
|
||||
await this.renameJournal(currentPath, this.openPath);
|
||||
currentPath = this.openPath;
|
||||
} catch {
|
||||
recovered = false;
|
||||
try {
|
||||
await this.renameJournal(currentPath, recoveryPath);
|
||||
currentPath = recoveryPath;
|
||||
} catch {
|
||||
// The non-success return below remains authoritative; the path is reported by audit scan.
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.syncJournalDirectory(this.journalsDirectory);
|
||||
} catch {
|
||||
recovered = false;
|
||||
}
|
||||
await this.handle.close().catch((): void => undefined);
|
||||
await this.lockHandle.close().catch((): void => undefined);
|
||||
this.closed = true;
|
||||
throw new CredentialJournalError(
|
||||
recovered ? 'journal-unavailable' : 'journal-recovery-required',
|
||||
'sealed journal could not be committed durably',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async closeIncomplete(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
await this.handle.close();
|
||||
await this.lockHandle.close();
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listCredentialJournals(
|
||||
stateRoot: string,
|
||||
): Promise<readonly CredentialJournalSummaryDto[]> {
|
||||
const journalsDirectory = join(stateRoot, 'journals');
|
||||
const locksDirectory = join(stateRoot, 'journal-locks');
|
||||
let names: string[];
|
||||
try {
|
||||
assertPrivateDirectory(stateRoot);
|
||||
assertPrivateDirectory(journalsDirectory);
|
||||
ensureManagedDirectory(stateRoot, locksDirectory);
|
||||
assertPrivateDirectory(locksDirectory);
|
||||
names = await readdir(journalsDirectory);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return [];
|
||||
throw new CredentialJournalError('journal-unavailable', 'journal directory could not be read');
|
||||
}
|
||||
return Promise.all(
|
||||
names
|
||||
.filter((name: string): boolean => /\.(?:open|sealing|recovery|sealed)\.jsonl$/.test(name))
|
||||
.sort()
|
||||
.map(async (name: string): Promise<CredentialJournalSummaryDto> => {
|
||||
const path = join(journalsDirectory, name);
|
||||
const id = name.replace(/\.(?:open|sealing|recovery|sealed)\.jsonl$/, '');
|
||||
let committedSeal = false;
|
||||
let scanLock: FileHandle | undefined;
|
||||
if (name.endsWith('.sealed.jsonl') && SAFE_NAME.test(id)) {
|
||||
try {
|
||||
scanLock = await acquireJournalLock(join(locksDirectory, `${id}.lock`), 'shared');
|
||||
if (scanLock === undefined) throw new Error('journal seal is still in progress');
|
||||
const snapshot = readRegularFileSecure(path, {
|
||||
root: journalsDirectory,
|
||||
maxBytes: MAX_JOURNAL_BYTES,
|
||||
});
|
||||
if (snapshot.uid !== process.getuid?.() || (snapshot.mode & 0o077) !== 0) {
|
||||
throw new Error('journal owner or mode is unsafe');
|
||||
}
|
||||
const records = snapshot.content.toString('utf8').trim().split('\n');
|
||||
const finalRecord: unknown = JSON.parse(records.at(-1) ?? 'null');
|
||||
committedSeal =
|
||||
typeof finalRecord === 'object' &&
|
||||
finalRecord !== null &&
|
||||
'phase' in finalRecord &&
|
||||
finalRecord.phase === 'sealed';
|
||||
} catch {
|
||||
committedSeal = false;
|
||||
} finally {
|
||||
await scanLock?.close().catch((): void => undefined);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id,
|
||||
state: committedSeal ? 'sealed' : 'open',
|
||||
path,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface CredentialBindingMetadataDto {
|
||||
readonly schemaVersion?: 1;
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
readonly providerLogin: string;
|
||||
readonly tokenName: string;
|
||||
readonly scopes: readonly string[];
|
||||
readonly createdAt: string;
|
||||
readonly tokenDigest?: string;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
ProviderIdentityEvidenceDto,
|
||||
ReceivePackEvidenceDto,
|
||||
RepositoryPermission,
|
||||
RepositoryPermissionEvidenceDto,
|
||||
} from './credential-result.dto.js';
|
||||
|
||||
export interface ResolvedCredential {
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
readonly resolutionId: string;
|
||||
readonly secret: Uint8Array;
|
||||
}
|
||||
|
||||
export interface CredentialResolver {
|
||||
resolve(identity: string, estate: string, host: string): Promise<ResolvedCredential | undefined>;
|
||||
}
|
||||
|
||||
export interface GiteaCredentialProvider {
|
||||
readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidenceDto>;
|
||||
readRepositoryPermission(
|
||||
resolved: ResolvedCredential,
|
||||
repo: string,
|
||||
): Promise<RepositoryPermissionEvidenceDto>;
|
||||
probeReceivePack(
|
||||
resolved: ResolvedCredential | undefined,
|
||||
repo: string,
|
||||
): Promise<ReceivePackEvidenceDto>;
|
||||
}
|
||||
|
||||
export interface CredentialEstateRegistry {
|
||||
matches(estate: string, host: string): boolean;
|
||||
}
|
||||
|
||||
export interface CredentialValidationDependencies {
|
||||
readonly resolver: CredentialResolver;
|
||||
readonly provider: GiteaCredentialProvider;
|
||||
readonly estateRegistry: CredentialEstateRegistry;
|
||||
}
|
||||
|
||||
export interface GiteaReadValidationRequestDto {
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
readonly repo: string;
|
||||
readonly requiredPermission?: RepositoryPermission;
|
||||
}
|
||||
|
||||
export interface GiteaWriteValidationRequestDto extends GiteaReadValidationRequestDto {
|
||||
readonly readOnlyControlIdentity: string;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
export type CredentialOutcome = 'ok' | 'refused' | 'error' | 'indeterminate';
|
||||
export type CredentialMutationState = 'none' | 'not-started' | 'applied' | 'unknown';
|
||||
export type RepositoryPermission = 'none' | 'read' | 'write' | 'admin';
|
||||
export type ReceivePackState = 'advertised' | 'refused';
|
||||
|
||||
export interface CredentialReasonDto {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export interface CredentialSubjectDto {
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
readonly repo: string;
|
||||
}
|
||||
|
||||
export interface ProviderIdentityEvidenceDto {
|
||||
readonly login: string;
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export interface RepositoryPermissionEvidenceDto {
|
||||
readonly effective: RepositoryPermission;
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export interface ReceivePackEvidenceDto {
|
||||
readonly state: ReceivePackState;
|
||||
readonly principal: string | null;
|
||||
readonly resolutionId: string | null;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export interface ReadOnlyControlEvidenceDto {
|
||||
readonly identity: string;
|
||||
readonly providerPermission: RepositoryPermission;
|
||||
readonly receivePack: ReceivePackState;
|
||||
}
|
||||
|
||||
export interface WriteDifferentialEvidenceDto {
|
||||
readonly state: 'can-write';
|
||||
readonly credentialBinding: 'same-resolution';
|
||||
readonly transportPrincipal: string;
|
||||
readonly authenticatedReceivePack: 'advertised';
|
||||
readonly readOnlyControl: ReadOnlyControlEvidenceDto;
|
||||
readonly unauthenticatedReceivePack: 'refused';
|
||||
readonly artifactCreated: false;
|
||||
readonly proves: string;
|
||||
readonly doesNotProve: string;
|
||||
}
|
||||
|
||||
export interface TokenCapabilitiesEvidenceDto {
|
||||
readonly state: 'measured' | 'not-measured';
|
||||
readonly scopes: readonly string[];
|
||||
readonly source: 'provider-token-object' | 'runtime-not-authorized';
|
||||
}
|
||||
|
||||
export interface CredentialValidationEvidenceDto {
|
||||
readonly providerIdentity: ProviderIdentityEvidenceDto | null;
|
||||
readonly tokenCapabilities: TokenCapabilitiesEvidenceDto;
|
||||
readonly repositoryPermission: RepositoryPermissionEvidenceDto | null;
|
||||
readonly writeDifferential: WriteDifferentialEvidenceDto | null;
|
||||
}
|
||||
|
||||
export interface CredentialAuditResultDto {
|
||||
readonly journalId: string | null;
|
||||
readonly state: 'not-started' | 'open' | 'sealed';
|
||||
}
|
||||
|
||||
export interface CredentialValidationResultDto {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'validate' | 'whoami';
|
||||
readonly outcome: CredentialOutcome;
|
||||
readonly exitCode: 0 | 10 | 20 | 30;
|
||||
readonly retryable: boolean;
|
||||
readonly subject: CredentialSubjectDto;
|
||||
readonly mutation: CredentialMutationState;
|
||||
readonly reason: CredentialReasonDto;
|
||||
readonly evidence: CredentialValidationEvidenceDto;
|
||||
readonly audit: CredentialAuditResultDto;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
||||
import type {
|
||||
CredentialValidationDependencies,
|
||||
GiteaReadValidationRequestDto,
|
||||
GiteaWriteValidationRequestDto,
|
||||
} from './credential-provider.dto.js';
|
||||
import type {
|
||||
CredentialValidationResultDto,
|
||||
RepositoryPermission,
|
||||
} from './credential-result.dto.js';
|
||||
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
|
||||
|
||||
export interface CredentialValidationServiceOptions {
|
||||
readonly stateRoot: string;
|
||||
readonly actor: string;
|
||||
readonly operation?: 'validate' | 'whoami';
|
||||
}
|
||||
|
||||
function permissionDecision(permission: RepositoryPermission): string {
|
||||
if (permission === 'none') return 'permission-none';
|
||||
if (permission === 'admin') return 'permission-admin';
|
||||
if (permission === 'write') return 'permission-write';
|
||||
return 'permission-read';
|
||||
}
|
||||
|
||||
async function openValidationJournal(
|
||||
request: GiteaReadValidationRequestDto,
|
||||
options: CredentialValidationServiceOptions,
|
||||
): Promise<CredentialAuditJournal> {
|
||||
const journal = await CredentialAuditJournal.open(options.stateRoot, {
|
||||
operation: options.operation ?? 'validate',
|
||||
actor: options.actor,
|
||||
identity: request.identity,
|
||||
estate: request.estate,
|
||||
host: request.host,
|
||||
repo: request.repo,
|
||||
});
|
||||
await journal.recordIntent(
|
||||
options.operation === 'whoami' ? 'whoami-requested' : 'validation-requested',
|
||||
);
|
||||
return journal;
|
||||
}
|
||||
|
||||
async function recordAndSealValidation(
|
||||
journal: CredentialAuditJournal,
|
||||
validation: CredentialValidationResultDto,
|
||||
): Promise<CredentialValidationResultDto> {
|
||||
if (validation.evidence.providerIdentity !== null) {
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: validation.evidence.providerIdentity.endpoint,
|
||||
contentType: validation.evidence.providerIdentity.contentType,
|
||||
decision: 'identity-verified',
|
||||
});
|
||||
}
|
||||
if (validation.evidence.repositoryPermission !== null) {
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: validation.evidence.repositoryPermission.endpoint,
|
||||
contentType: validation.evidence.repositoryPermission.contentType,
|
||||
decision: permissionDecision(validation.evidence.repositoryPermission.effective),
|
||||
});
|
||||
}
|
||||
await journal.seal(validation.outcome, validation.reason.code);
|
||||
return {
|
||||
...validation,
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
};
|
||||
}
|
||||
|
||||
function journalFailureResult(
|
||||
request: GiteaReadValidationRequestDto,
|
||||
journal: CredentialAuditJournal,
|
||||
error: CredentialJournalError,
|
||||
operation: 'validate' | 'whoami',
|
||||
): CredentialValidationResultDto {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
outcome: 'error',
|
||||
exitCode: 20,
|
||||
retryable: false,
|
||||
subject: {
|
||||
identity: request.identity,
|
||||
estate: request.estate,
|
||||
host: request.host,
|
||||
repo: request.repo,
|
||||
},
|
||||
mutation: 'none',
|
||||
reason: {
|
||||
code: error.code,
|
||||
message: 'Validation audit persistence failed; inspect the durable open journal.',
|
||||
},
|
||||
evidence: {
|
||||
providerIdentity: null,
|
||||
tokenCapabilities: {
|
||||
state: 'not-measured',
|
||||
scopes: [],
|
||||
source: 'runtime-not-authorized',
|
||||
},
|
||||
repositoryPermission: null,
|
||||
writeDifferential: null,
|
||||
},
|
||||
audit: { journalId: journal.journalId(), state: 'open' },
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCredentialReadValidation(
|
||||
request: GiteaReadValidationRequestDto,
|
||||
dependencies: CredentialValidationDependencies,
|
||||
options: CredentialValidationServiceOptions,
|
||||
): Promise<CredentialValidationResultDto> {
|
||||
const journal = await openValidationJournal(request, options);
|
||||
try {
|
||||
const validation = await evaluateGiteaReadValidation(request, dependencies);
|
||||
return await recordAndSealValidation(journal, {
|
||||
...validation,
|
||||
operation: options.operation ?? 'validate',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof CredentialJournalError) {
|
||||
return journalFailureResult(request, journal, error, options.operation ?? 'validate');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCredentialValidation(
|
||||
request: GiteaWriteValidationRequestDto,
|
||||
dependencies: CredentialValidationDependencies,
|
||||
options: CredentialValidationServiceOptions,
|
||||
): Promise<CredentialValidationResultDto> {
|
||||
const journal = await openValidationJournal(request, options);
|
||||
try {
|
||||
const validation = await evaluateGiteaWriteValidation(request, dependencies);
|
||||
return await recordAndSealValidation(journal, validation);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof CredentialJournalError) {
|
||||
return journalFailureResult(request, journal, error, 'validate');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { mkdtemp, open, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { readDelegatedCredentialFromFd } from './delegated-credential.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
describe('protected delegated credential channel', (): void => {
|
||||
it('reads authority from an inherited fd number without putting the secret in argv or env', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-'));
|
||||
const path = join(cleanup, 'authority');
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
identity: 'provisioner',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'seeded-authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
const resolved = await readDelegatedCredentialFromFd(
|
||||
handle.fd,
|
||||
'provisioner',
|
||||
'homelab',
|
||||
'git.example.invalid',
|
||||
);
|
||||
expect(resolved.identity).toBe('provisioner');
|
||||
expect(Buffer.from(resolved.secret).toString('utf8')).toBe('seeded-authority-canary');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a regular-file authority fd with group or other access', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-'));
|
||||
const path = join(cleanup, 'authority');
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
identity: 'provisioner',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'seeded-authority-canary',
|
||||
}),
|
||||
{ mode: 0o644 },
|
||||
);
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
await expect(
|
||||
readDelegatedCredentialFromFd(handle.fd, 'provisioner', 'homelab', 'git.example.invalid'),
|
||||
).rejects.toMatchObject({ code: 'delegated-authority-unavailable' });
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an authority identity or estate mismatch without echoing the secret', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-'));
|
||||
const path = join(cleanup, 'authority');
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
identity: 'other',
|
||||
estate: 'usc',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'seeded-authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
let message = '';
|
||||
try {
|
||||
await readDelegatedCredentialFromFd(
|
||||
handle.fd,
|
||||
'provisioner',
|
||||
'homelab',
|
||||
'git.example.invalid',
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
expect(message).toContain('delegated-authority-mismatch');
|
||||
expect(message).not.toContain('seeded-authority-canary');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createReadStream, fstatSync } from 'node:fs';
|
||||
import { z } from 'zod';
|
||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
||||
|
||||
const authoritySchema = z
|
||||
.object({
|
||||
identity: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*$/),
|
||||
estate: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
||||
host: z.string().regex(/^[a-z0-9][a-z0-9.-]*$/),
|
||||
secret: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(16 * 1024)
|
||||
.regex(/^\S+$/),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export class DelegatedCredentialError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(`Delegated credential rejected: code=${code} ${message}`);
|
||||
this.name = 'DelegatedCredentialError';
|
||||
}
|
||||
}
|
||||
|
||||
async function readProtectedFd(fd: number): Promise<Buffer> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout((): void => controller.abort(), 5_000);
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
const stream = createReadStream(`/proc/self/fd/${fd}`, {
|
||||
highWaterMark: 4 * 1024,
|
||||
signal: controller.signal,
|
||||
});
|
||||
for await (const chunk of stream) {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += bytes.byteLength;
|
||||
if (total > 32 * 1024) {
|
||||
stream.destroy();
|
||||
throw new Error('protected credential payload exceeded the bound');
|
||||
}
|
||||
chunks.push(bytes);
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readDelegatedCredentialFromFd(
|
||||
fd: number,
|
||||
expectedIdentity: string,
|
||||
expectedEstate: string,
|
||||
expectedHost: string,
|
||||
): Promise<ResolvedCredential> {
|
||||
if (!Number.isSafeInteger(fd) || fd < 3 || fd > 1024) {
|
||||
throw new DelegatedCredentialError('delegated-authority-unavailable', 'invalid inherited fd');
|
||||
}
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
if (!stat.isFile() && !stat.isFIFO()) {
|
||||
throw new Error('fd is not a regular file or pipe');
|
||||
}
|
||||
const currentUid = process.getuid?.();
|
||||
if (currentUid === undefined || stat.uid !== currentUid || (stat.mode & 0o077) !== 0) {
|
||||
throw new Error('fd owner or permissions are unsafe');
|
||||
}
|
||||
bytes = await readProtectedFd(fd);
|
||||
} catch {
|
||||
throw new DelegatedCredentialError(
|
||||
'delegated-authority-unavailable',
|
||||
'protected inherited credential fd could not be read',
|
||||
);
|
||||
}
|
||||
if (bytes.byteLength > 32 * 1024) {
|
||||
bytes.fill(0);
|
||||
throw new DelegatedCredentialError(
|
||||
'delegated-authority-unavailable',
|
||||
'protected credential payload exceeded the bound',
|
||||
);
|
||||
}
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
||||
} catch {
|
||||
bytes.fill(0);
|
||||
throw new DelegatedCredentialError(
|
||||
'delegated-authority-unavailable',
|
||||
'protected credential payload was invalid',
|
||||
);
|
||||
}
|
||||
bytes.fill(0);
|
||||
const parsed = authoritySchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new DelegatedCredentialError(
|
||||
'delegated-authority-unavailable',
|
||||
'protected credential payload did not match the schema',
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsed.data.identity !== expectedIdentity ||
|
||||
parsed.data.estate !== expectedEstate ||
|
||||
parsed.data.host !== expectedHost
|
||||
) {
|
||||
throw new DelegatedCredentialError(
|
||||
'delegated-authority-mismatch',
|
||||
'protected credential does not match the explicit actor, estate, and host',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
identity: parsed.data.identity,
|
||||
estate: parsed.data.estate,
|
||||
host: parsed.data.host,
|
||||
resolutionId: randomUUID(),
|
||||
secret: new TextEncoder().encode(parsed.data.secret),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type CredentialProviderKind = 'gitea';
|
||||
|
||||
export interface CredentialHostConfigDto {
|
||||
readonly host: string;
|
||||
readonly provider: CredentialProviderKind;
|
||||
readonly apiBaseUrl: string;
|
||||
readonly tokenPrefix: string;
|
||||
}
|
||||
|
||||
export interface CredentialEstateConfigDto {
|
||||
readonly name: string;
|
||||
readonly readOnlyControlIdentity?: string;
|
||||
readonly inventoryAuthorityIdentity?: string;
|
||||
readonly hosts: readonly CredentialHostConfigDto[];
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCredentialEstateRegistry } from './estate-registry.js';
|
||||
|
||||
const validRegistry = JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('credential estate registry', (): void => {
|
||||
it('requires an exact declared estate-host pair', (): void => {
|
||||
const registry = parseCredentialEstateRegistry(validRegistry);
|
||||
|
||||
expect(registry.matches('homelab', 'git.example.invalid')).toBe(true);
|
||||
expect(registry.matches('usc', 'git.example.invalid')).toBe(false);
|
||||
expect(registry.matches('homelab', 'other.example.invalid')).toBe(false);
|
||||
expect(registry.resolveByHost('git.example.invalid')).toMatchObject({
|
||||
estate: 'homelab',
|
||||
host: { host: 'git.example.invalid', provider: 'gitea' },
|
||||
});
|
||||
expect(registry.resolveByHost('other.example.invalid')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a provider URL whose host differs from the declared host', (): void => {
|
||||
const source = validRegistry.replace(
|
||||
'https://git.example.invalid',
|
||||
'https://other.example.invalid',
|
||||
);
|
||||
|
||||
expect(() => parseCredentialEstateRegistry(source)).toThrow(/api-host-mismatch/);
|
||||
});
|
||||
|
||||
it('rejects one host assigned to multiple estates', (): void => {
|
||||
const source = JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'other',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-other',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(() => parseCredentialEstateRegistry(source)).toThrow(/duplicate-host/);
|
||||
});
|
||||
|
||||
it('rejects URLs with userinfo, path, query, fragment, or non-HTTPS scheme', (): void => {
|
||||
for (const apiBaseUrl of [
|
||||
'http://git.example.invalid',
|
||||
'https://[email protected]',
|
||||
'https://git.example.invalid/api',
|
||||
'https://git.example.invalid?x=1',
|
||||
'https://git.example.invalid#x',
|
||||
]) {
|
||||
const source = validRegistry.replace('https://git.example.invalid', apiBaseUrl);
|
||||
expect(() => parseCredentialEstateRegistry(source), apiBaseUrl).toThrow(/invalid-api-url/);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires a configured read-only control for write validation', (): void => {
|
||||
const registry = parseCredentialEstateRegistry(validRegistry);
|
||||
const withoutControl = parseCredentialEstateRegistry(
|
||||
validRegistry.replace('"readOnlyControlIdentity":"read-control",', ''),
|
||||
);
|
||||
|
||||
expect(registry.readOnlyControl('homelab')).toBe('read-control');
|
||||
expect(() => withoutControl.readOnlyControl('homelab')).toThrow(/read-only-control-missing/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { z } from 'zod';
|
||||
import type { CredentialEstateRegistry } from './credential-provider.dto.js';
|
||||
import type { CredentialEstateConfigDto, CredentialHostConfigDto } from './estate-registry.dto.js';
|
||||
|
||||
const NAME = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
const HOST = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
|
||||
|
||||
const hostSchema = z
|
||||
.object({
|
||||
host: z.string().regex(HOST),
|
||||
provider: z.literal('gitea'),
|
||||
apiBaseUrl: z.string(),
|
||||
tokenPrefix: z.string().regex(NAME),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const estateSchema = z
|
||||
.object({
|
||||
name: z.string().regex(NAME),
|
||||
readOnlyControlIdentity: z.string().regex(IDENTITY).optional(),
|
||||
inventoryAuthorityIdentity: z.string().regex(IDENTITY).optional(),
|
||||
hosts: z.array(hostSchema).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const registrySchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
estates: z.array(estateSchema).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export class CredentialEstateRegistryError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(`Credential estate registry rejected: code=${code} ${message}`);
|
||||
this.name = 'CredentialEstateRegistryError';
|
||||
}
|
||||
}
|
||||
|
||||
function validateApiUrl(host: CredentialHostConfigDto): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(host.apiBaseUrl);
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new CredentialEstateRegistryError('invalid-api-url', detail);
|
||||
}
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username !== '' ||
|
||||
url.password !== '' ||
|
||||
url.pathname !== '/' ||
|
||||
url.search !== '' ||
|
||||
url.hash !== ''
|
||||
) {
|
||||
throw new CredentialEstateRegistryError(
|
||||
'invalid-api-url',
|
||||
'provider API URL must be an HTTPS origin without userinfo, path, query, or fragment',
|
||||
);
|
||||
}
|
||||
if (url.hostname !== host.host) {
|
||||
throw new CredentialEstateRegistryError(
|
||||
'api-host-mismatch',
|
||||
'provider API URL hostname does not equal the declared host',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry {
|
||||
private readonly estates: ReadonlyMap<string, CredentialEstateConfigDto>;
|
||||
|
||||
constructor(estates: readonly CredentialEstateConfigDto[]) {
|
||||
this.estates = new Map(
|
||||
estates.map(
|
||||
(estate: CredentialEstateConfigDto): readonly [string, CredentialEstateConfigDto] => [
|
||||
estate.name,
|
||||
estate,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
matches(estate: string, host: string): boolean {
|
||||
return this.resolve(estate, host) !== undefined;
|
||||
}
|
||||
|
||||
resolve(estate: string, host: string): CredentialHostConfigDto | undefined {
|
||||
return this.estates
|
||||
.get(estate)
|
||||
?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host);
|
||||
}
|
||||
|
||||
resolveByHost(
|
||||
host: string,
|
||||
): { readonly estate: string; readonly host: CredentialHostConfigDto } | undefined {
|
||||
for (const [estate, config] of this.estates) {
|
||||
const match = config.hosts.find(
|
||||
(candidate: CredentialHostConfigDto): boolean => candidate.host === host,
|
||||
);
|
||||
if (match !== undefined) return { estate, host: match };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
inventoryAuthority(estate: string): string {
|
||||
const identity = this.estates.get(estate)?.inventoryAuthorityIdentity;
|
||||
if (identity === undefined) {
|
||||
throw new CredentialEstateRegistryError(
|
||||
'inventory-authority-missing',
|
||||
`estate ${estate} has no delegated inventory authority identity`,
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
readOnlyControl(estate: string): string {
|
||||
const identity = this.estates.get(estate)?.readOnlyControlIdentity;
|
||||
if (identity === undefined) {
|
||||
throw new CredentialEstateRegistryError(
|
||||
'read-only-control-missing',
|
||||
`estate ${estate} has no provider-confirmed read-only control identity`,
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCredentialEstateRegistry(source: string): ParsedCredentialEstateRegistry {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(source);
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new CredentialEstateRegistryError('invalid-json', detail);
|
||||
}
|
||||
|
||||
const parsed = registrySchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new CredentialEstateRegistryError(
|
||||
'invalid-schema',
|
||||
parsed.error.issues[0]?.message ?? 'invalid',
|
||||
);
|
||||
}
|
||||
|
||||
const estateNames = new Set<string>();
|
||||
const hostNames = new Set<string>();
|
||||
for (const estate of parsed.data.estates) {
|
||||
if (estateNames.has(estate.name)) {
|
||||
throw new CredentialEstateRegistryError('duplicate-estate', estate.name);
|
||||
}
|
||||
estateNames.add(estate.name);
|
||||
for (const host of estate.hosts) {
|
||||
validateApiUrl(host);
|
||||
if (hostNames.has(host.host)) {
|
||||
throw new CredentialEstateRegistryError('duplicate-host', host.host);
|
||||
}
|
||||
hostNames.add(host.host);
|
||||
}
|
||||
}
|
||||
|
||||
return new ParsedCredentialEstateRegistry(parsed.data.estates);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { chmod, copyFile, mkdir, symlink, unlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { parseCredentialEstateRegistry } from './estate-registry.js';
|
||||
import { FileCredentialResolver, FileCredentialStore } from './file-credential-store.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
|
||||
async function fixtureRoot(): Promise<string> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-store-'));
|
||||
const root = join(cleanup, 'tokens');
|
||||
await mkdir(root, { mode: 0o700 });
|
||||
return root;
|
||||
}
|
||||
|
||||
function registry(): ReturnType<typeof parseCredentialEstateRegistry> {
|
||||
return parseCredentialEstateRegistry(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
describe('phase-1 governed file credential resolver', (): void => {
|
||||
it('resolves only the exact estate/host/identity token at a test-overridable root', async (): Promise<void> => {
|
||||
const root = await fixtureRoot();
|
||||
await writeFile(join(root, 'gitea-example-seat.token'), 'canary-token', { mode: 0o600 });
|
||||
const resolver = new FileCredentialResolver(root, registry());
|
||||
|
||||
const resolved = await resolver.resolve('seat', 'homelab', 'git.example.invalid');
|
||||
const wrongEstate = await resolver.resolve('seat', 'usc', 'git.example.invalid');
|
||||
|
||||
expect(resolved?.identity).toBe('seat');
|
||||
expect(Buffer.from(resolved?.secret ?? []).toString('utf8')).toBe('canary-token');
|
||||
expect(wrongEstate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a group-writable token directory even when the token file is private', async (): Promise<void> => {
|
||||
const root = await fixtureRoot();
|
||||
await writeFile(join(root, 'gitea-example-seat-name.token'), 'private-token', {
|
||||
mode: 0o600,
|
||||
});
|
||||
await chmod(root, 0o770);
|
||||
const resolver = new FileCredentialResolver(root, registry());
|
||||
|
||||
await expect(resolver.resolve('seat-name', 'homelab', 'git.example.invalid')).rejects.toThrow(
|
||||
/insecure-token-owner/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a token file with group or other permissions', async (): Promise<void> => {
|
||||
const root = await fixtureRoot();
|
||||
const path = join(root, 'gitea-example-seat.token');
|
||||
await writeFile(path, 'canary-token', { mode: 0o600 });
|
||||
await chmod(path, 0o640);
|
||||
const resolver = new FileCredentialResolver(root, registry());
|
||||
|
||||
await expect(resolver.resolve('seat', 'homelab', 'git.example.invalid')).rejects.toThrow(
|
||||
/insecure-token-mode/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a symlinked token instead of following it', async (): Promise<void> => {
|
||||
const root = await fixtureRoot();
|
||||
const target = join(cleanup ?? root, 'outside-token');
|
||||
await writeFile(target, 'canary-token', { mode: 0o600 });
|
||||
await symlink(target, join(root, 'gitea-example-seat.token'));
|
||||
const resolver = new FileCredentialResolver(root, registry());
|
||||
|
||||
await expect(resolver.resolve('seat', 'homelab', 'git.example.invalid')).rejects.toThrow(
|
||||
/symbolic link|unavailable/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects traversal-shaped identities before touching storage', async (): Promise<void> => {
|
||||
const root = await fixtureRoot();
|
||||
const resolver = new FileCredentialResolver(root, registry());
|
||||
|
||||
await expect(resolver.resolve('../other', 'homelab', 'git.example.invalid')).rejects.toThrow(
|
||||
/invalid-identity/,
|
||||
);
|
||||
});
|
||||
|
||||
it('stores one governed envelope, lists and reads it, then removes all credential artifacts', async (): Promise<void> => {
|
||||
const root = await fixtureRoot();
|
||||
const store = new FileCredentialStore(root, registry());
|
||||
await store.put(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
providerLogin: 'seat',
|
||||
tokenName: 'mosaic-seat-1',
|
||||
scopes: ['write:repository'],
|
||||
createdAt: '2026-08-05T00:00:00.000Z',
|
||||
},
|
||||
new TextEncoder().encode('new-private-token'),
|
||||
);
|
||||
|
||||
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual(['seat']);
|
||||
await expect(
|
||||
store.readBinding('seat', 'homelab', 'git.example.invalid'),
|
||||
).resolves.toMatchObject({
|
||||
providerLogin: 'seat',
|
||||
tokenName: 'mosaic-seat-1',
|
||||
});
|
||||
await expect(
|
||||
new FileCredentialResolver(root, registry()).resolve(
|
||||
'seat',
|
||||
'homelab',
|
||||
'git.example.invalid',
|
||||
),
|
||||
).resolves.toMatchObject({ identity: 'seat' });
|
||||
await copyFile(
|
||||
join(root, 'gitea-example-seat.credential.json'),
|
||||
join(root, 'gitea-example-other.credential.json'),
|
||||
);
|
||||
await expect(
|
||||
new FileCredentialResolver(root, registry()).resolve(
|
||||
'other',
|
||||
'homelab',
|
||||
'git.example.invalid',
|
||||
),
|
||||
).rejects.toThrow(/credential-binding-mismatch/);
|
||||
await unlink(join(root, 'gitea-example-other.credential.json'));
|
||||
await store.remove('seat', 'homelab', 'git.example.invalid');
|
||||
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('changes generation for metadata-only rebinding and rejects stale replacement or removal', async (): Promise<void> => {
|
||||
const root = await fixtureRoot();
|
||||
const store = new FileCredentialStore(root, registry());
|
||||
const secret = new TextEncoder().encode('same-private-token');
|
||||
const originalBinding = {
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
providerLogin: 'seat',
|
||||
tokenName: 'mosaic-seat-original',
|
||||
scopes: ['write:repository'],
|
||||
createdAt: '2026-08-05T00:00:00.000Z',
|
||||
};
|
||||
await store.put(originalBinding, secret);
|
||||
const original = await store.snapshot('seat', 'homelab', 'git.example.invalid');
|
||||
if (original === undefined) throw new Error('original generation was not stored');
|
||||
try {
|
||||
await store.put(
|
||||
{
|
||||
...originalBinding,
|
||||
tokenName: 'mosaic-seat-rebound',
|
||||
createdAt: '2026-08-05T00:00:01.000Z',
|
||||
},
|
||||
secret,
|
||||
original.generation,
|
||||
);
|
||||
const rebound = await store.snapshot('seat', 'homelab', 'git.example.invalid');
|
||||
try {
|
||||
expect(rebound?.generation).not.toBe(original.generation);
|
||||
await expect(
|
||||
store.put(
|
||||
{ ...originalBinding, tokenName: 'mosaic-seat-stale' },
|
||||
secret,
|
||||
original.generation,
|
||||
),
|
||||
).rejects.toThrow(/credential-generation-mismatch/);
|
||||
await expect(
|
||||
store.remove('seat', 'homelab', 'git.example.invalid', original.generation),
|
||||
).rejects.toThrow(/credential-generation-mismatch/);
|
||||
await expect(
|
||||
store.readBinding('seat', 'homelab', 'git.example.invalid'),
|
||||
).resolves.toMatchObject({ tokenName: 'mosaic-seat-rebound' });
|
||||
} finally {
|
||||
rebound?.secret.fill(0);
|
||||
}
|
||||
} finally {
|
||||
original.secret.fill(0);
|
||||
secret.fill(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns undefined for an absent token without borrowing another identity', async (): Promise<void> => {
|
||||
const root = await fixtureRoot();
|
||||
await writeFile(join(root, 'gitea-example-shared.token'), 'shared-canary', { mode: 0o600 });
|
||||
const resolver = new FileCredentialResolver(root, registry());
|
||||
|
||||
const resolved = await resolver.resolve('missing-seat', 'homelab', 'git.example.invalid');
|
||||
|
||||
expect(resolved).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,549 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { lstatSync } from 'node:fs';
|
||||
import { open, readdir, rename, unlink } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ensureManagedDirectory,
|
||||
readRegularFileSecure,
|
||||
type SecureFileSnapshot,
|
||||
} from '../fleet/secure-file.js';
|
||||
import type { CredentialBindingMetadataDto } from './credential-binding.dto.js';
|
||||
import type { CredentialResolver, ResolvedCredential } from './credential-provider.dto.js';
|
||||
import type { ParsedCredentialEstateRegistry } from './estate-registry.js';
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
const MAX_TOKEN_BYTES = 16 * 1024;
|
||||
const bindingSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
identity: z.string().regex(IDENTITY),
|
||||
estate: z.string().min(1),
|
||||
host: z.string().min(1),
|
||||
providerLogin: z.string().regex(IDENTITY),
|
||||
tokenName: z.string().regex(IDENTITY),
|
||||
scopes: z.array(z.string().regex(/^[a-z]+(?::[a-z]+)?$/)).max(32),
|
||||
createdAt: z.string().datetime(),
|
||||
tokenDigest: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
const credentialEnvelopeSchema = bindingSchema.extend({
|
||||
token: z.string().min(1).max(MAX_TOKEN_BYTES).regex(/^\S+$/),
|
||||
});
|
||||
|
||||
export class CredentialStoreError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(`Credential store rejected: code=${code} ${message}`);
|
||||
this.name = 'CredentialStoreError';
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code === 'ENOENT'
|
||||
);
|
||||
}
|
||||
|
||||
export function credentialBindingGeneration(
|
||||
metadata: CredentialBindingMetadataDto,
|
||||
secret: Uint8Array,
|
||||
): string {
|
||||
const tokenDigest = createHash('sha256').update(secret).digest('hex');
|
||||
const canonicalState = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
identity: metadata.identity,
|
||||
estate: metadata.estate,
|
||||
host: metadata.host,
|
||||
providerLogin: metadata.providerLogin,
|
||||
tokenName: metadata.tokenName,
|
||||
scopes: [...metadata.scopes].sort(),
|
||||
createdAt: metadata.createdAt,
|
||||
tokenDigest,
|
||||
});
|
||||
return createHash('sha256').update(canonicalState).digest('hex');
|
||||
}
|
||||
|
||||
function validateSecret(content: Buffer): Uint8Array {
|
||||
if (content.byteLength === 0 || content.byteLength > MAX_TOKEN_BYTES) {
|
||||
throw new CredentialStoreError('invalid-token-size', 'token file size is outside bounds');
|
||||
}
|
||||
for (const byte of content) {
|
||||
if (byte <= 0x20 || byte === 0x7f) {
|
||||
throw new CredentialStoreError(
|
||||
'invalid-token-bytes',
|
||||
'token file contains whitespace or control bytes',
|
||||
);
|
||||
}
|
||||
}
|
||||
return new Uint8Array(content);
|
||||
}
|
||||
|
||||
export class FileCredentialResolver implements CredentialResolver {
|
||||
constructor(
|
||||
private readonly tokenDirectory: string,
|
||||
private readonly estateRegistry: ParsedCredentialEstateRegistry,
|
||||
) {}
|
||||
|
||||
async resolve(
|
||||
identity: string,
|
||||
estate: string,
|
||||
host: string,
|
||||
): Promise<ResolvedCredential | undefined> {
|
||||
if (!IDENTITY.test(identity)) {
|
||||
throw new CredentialStoreError(
|
||||
'invalid-identity',
|
||||
'identity is outside the allowlist grammar',
|
||||
);
|
||||
}
|
||||
const hostConfig = this.estateRegistry.resolve(estate, host);
|
||||
if (hostConfig === undefined) return undefined;
|
||||
|
||||
const currentUid = process.getuid?.();
|
||||
if (currentUid === undefined) {
|
||||
throw new CredentialStoreError('insecure-token-owner', 'runtime uid is unavailable');
|
||||
}
|
||||
const directory = lstatSync(this.tokenDirectory);
|
||||
if (
|
||||
!directory.isDirectory() ||
|
||||
directory.isSymbolicLink() ||
|
||||
directory.uid !== currentUid ||
|
||||
(directory.mode & 0o022) !== 0
|
||||
) {
|
||||
throw new CredentialStoreError(
|
||||
'insecure-token-owner',
|
||||
'token directory ownership or write permissions are unsafe',
|
||||
);
|
||||
}
|
||||
|
||||
const envelopePath = join(
|
||||
this.tokenDirectory,
|
||||
`${hostConfig.tokenPrefix}-${identity}.credential.json`,
|
||||
);
|
||||
try {
|
||||
const envelopeSnapshot = readRegularFileSecure(envelopePath, {
|
||||
root: this.tokenDirectory,
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
const envelope = credentialEnvelopeSchema.safeParse(
|
||||
JSON.parse(envelopeSnapshot.content.toString('utf8')),
|
||||
);
|
||||
if (
|
||||
!envelope.success ||
|
||||
envelopeSnapshot.uid !== process.getuid?.() ||
|
||||
(envelopeSnapshot.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new CredentialStoreError(
|
||||
'invalid-binding',
|
||||
'credential envelope failed schema, owner, or mode validation',
|
||||
);
|
||||
}
|
||||
if (
|
||||
envelope.data.identity !== identity ||
|
||||
envelope.data.estate !== estate ||
|
||||
envelope.data.host !== host ||
|
||||
envelope.data.providerLogin !== identity
|
||||
) {
|
||||
throw new CredentialStoreError(
|
||||
'credential-binding-mismatch',
|
||||
'credential envelope does not match the requested identity, estate, host, and principal',
|
||||
);
|
||||
}
|
||||
const secret = validateSecret(Buffer.from(envelope.data.token, 'utf8'));
|
||||
const digest = createHash('sha256').update(secret).digest('hex');
|
||||
if (envelope.data.tokenDigest !== digest) {
|
||||
throw new CredentialStoreError(
|
||||
'credential-generation-mismatch',
|
||||
'credential envelope digest does not match its token',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
identity,
|
||||
estate,
|
||||
host,
|
||||
resolutionId: randomUUID(),
|
||||
secret,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
}
|
||||
|
||||
const path = join(this.tokenDirectory, `${hostConfig.tokenPrefix}-${identity}.token`);
|
||||
let snapshot: SecureFileSnapshot;
|
||||
try {
|
||||
snapshot = readRegularFileSecure(path, {
|
||||
root: this.tokenDirectory,
|
||||
maxBytes: MAX_TOKEN_BYTES,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (isMissingFile(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const bindingPath = join(
|
||||
this.tokenDirectory,
|
||||
`${hostConfig.tokenPrefix}-${identity}.binding.json`,
|
||||
);
|
||||
try {
|
||||
const bindingSnapshot = readRegularFileSecure(bindingPath, {
|
||||
root: this.tokenDirectory,
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
const binding = bindingSchema.safeParse(JSON.parse(bindingSnapshot.content.toString('utf8')));
|
||||
const digest = createHash('sha256').update(snapshot.content).digest('hex');
|
||||
if (
|
||||
!binding.success ||
|
||||
binding.data.tokenDigest !== digest ||
|
||||
binding.data.identity !== identity ||
|
||||
binding.data.estate !== estate ||
|
||||
binding.data.host !== host ||
|
||||
binding.data.providerLogin !== identity
|
||||
) {
|
||||
throw new CredentialStoreError(
|
||||
'credential-generation-mismatch',
|
||||
'token and binding metadata are not one committed identity-bound generation',
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
// Legacy token files predate binding metadata and remain readable until rotated.
|
||||
}
|
||||
|
||||
const permissions = snapshot.mode & 0o777;
|
||||
if (snapshot.uid !== currentUid) {
|
||||
throw new CredentialStoreError(
|
||||
'insecure-token-owner',
|
||||
'token file is not owned by the runtime uid',
|
||||
);
|
||||
}
|
||||
if ((permissions & 0o077) !== 0) {
|
||||
throw new CredentialStoreError(
|
||||
'insecure-token-mode',
|
||||
'token file grants group or other access',
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
identity,
|
||||
estate,
|
||||
host,
|
||||
resolutionId: randomUUID(),
|
||||
secret: validateSecret(snapshot.content),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertPrivateTokenDirectory(path: string): {
|
||||
readonly dev: number | bigint;
|
||||
readonly ino: number | bigint;
|
||||
} {
|
||||
const stat = lstatSync(path);
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== process.getuid?.() ||
|
||||
(stat.mode & 0o022) !== 0
|
||||
) {
|
||||
throw new CredentialStoreError(
|
||||
'insecure-token-owner',
|
||||
'token directory ownership or write permissions are unsafe',
|
||||
);
|
||||
}
|
||||
return { dev: stat.dev, ino: stat.ino };
|
||||
}
|
||||
|
||||
async function syncDirectory(path: string): Promise<void> {
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class FileCredentialStore {
|
||||
constructor(
|
||||
private readonly tokenDirectory: string,
|
||||
private readonly estateRegistry: ParsedCredentialEstateRegistry,
|
||||
) {}
|
||||
|
||||
private paths(
|
||||
identity: string,
|
||||
estate: string,
|
||||
host: string,
|
||||
): {
|
||||
readonly token: string;
|
||||
readonly binding: string;
|
||||
readonly envelope: string;
|
||||
readonly prefix: string;
|
||||
} {
|
||||
if (!IDENTITY.test(identity)) {
|
||||
throw new CredentialStoreError(
|
||||
'invalid-identity',
|
||||
'identity is outside the allowlist grammar',
|
||||
);
|
||||
}
|
||||
const config = this.estateRegistry.resolve(estate, host);
|
||||
if (config === undefined) {
|
||||
throw new CredentialStoreError('estate-host-mismatch', 'estate and host do not match');
|
||||
}
|
||||
const prefix = `${config.tokenPrefix}-${identity}`;
|
||||
return {
|
||||
token: join(this.tokenDirectory, `${prefix}.token`),
|
||||
binding: join(this.tokenDirectory, `${prefix}.binding.json`),
|
||||
envelope: join(this.tokenDirectory, `${prefix}.credential.json`),
|
||||
prefix,
|
||||
};
|
||||
}
|
||||
|
||||
async put(
|
||||
metadata: CredentialBindingMetadataDto,
|
||||
secret: Uint8Array,
|
||||
expectedGeneration?: string | null,
|
||||
): Promise<void> {
|
||||
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
|
||||
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
|
||||
const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory);
|
||||
const token = validateSecret(Buffer.from(secret));
|
||||
const envelope = credentialEnvelopeSchema.parse({
|
||||
...metadata,
|
||||
schemaVersion: 1,
|
||||
tokenDigest: createHash('sha256').update(token).digest('hex'),
|
||||
token: Buffer.from(token).toString('utf8'),
|
||||
});
|
||||
const suffix = randomUUID();
|
||||
const envelopeTemp = `${paths.envelope}.${suffix}.tmp`;
|
||||
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
|
||||
let lock;
|
||||
try {
|
||||
lock = await open(lockPath, 'wx', 0o600);
|
||||
} catch {
|
||||
throw new CredentialStoreError(
|
||||
'conflicting-credential-mutation',
|
||||
'another mutation owns the identity lock',
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (expectedGeneration !== undefined) {
|
||||
const current = await this.snapshot(metadata.identity, metadata.estate, metadata.host);
|
||||
try {
|
||||
const actualGeneration = current?.generation ?? null;
|
||||
if (actualGeneration !== expectedGeneration) {
|
||||
throw new CredentialStoreError(
|
||||
'credential-generation-mismatch',
|
||||
'credential generation changed before replacement',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
current?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
const handle = await open(envelopeTemp, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(envelope)}\n`, 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
const beforeCommit = assertPrivateTokenDirectory(this.tokenDirectory);
|
||||
if (
|
||||
beforeCommit.dev !== directoryIdentity.dev ||
|
||||
beforeCommit.ino !== directoryIdentity.ino
|
||||
) {
|
||||
throw new CredentialStoreError(
|
||||
'insecure-token-owner',
|
||||
'token directory changed during credential commit',
|
||||
);
|
||||
}
|
||||
await rename(envelopeTemp, paths.envelope);
|
||||
await syncDirectory(this.tokenDirectory);
|
||||
} finally {
|
||||
await lock.close();
|
||||
await unlink(envelopeTemp).catch((): void => undefined);
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
await syncDirectory(this.tokenDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
async snapshot(
|
||||
identity: string,
|
||||
estate: string,
|
||||
host: string,
|
||||
): Promise<
|
||||
| {
|
||||
readonly binding: CredentialBindingMetadataDto;
|
||||
readonly secret: Uint8Array;
|
||||
readonly generation: string;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
const binding = await this.readBinding(identity, estate, host);
|
||||
if (binding === undefined) return undefined;
|
||||
const resolved = await new FileCredentialResolver(
|
||||
this.tokenDirectory,
|
||||
this.estateRegistry,
|
||||
).resolve(identity, estate, host);
|
||||
if (resolved === undefined) {
|
||||
throw new CredentialStoreError(
|
||||
'invalid-binding',
|
||||
'binding metadata exists without its token generation',
|
||||
);
|
||||
}
|
||||
const secret = new Uint8Array(resolved.secret);
|
||||
const generation = credentialBindingGeneration(binding, resolved.secret);
|
||||
resolved.secret.fill(0);
|
||||
return { binding, secret, generation };
|
||||
}
|
||||
|
||||
async readBinding(
|
||||
identity: string,
|
||||
estate: string,
|
||||
host: string,
|
||||
): Promise<CredentialBindingMetadataDto | undefined> {
|
||||
const paths = this.paths(identity, estate, host);
|
||||
let snapshot: SecureFileSnapshot;
|
||||
try {
|
||||
const envelope = readRegularFileSecure(paths.envelope, {
|
||||
root: this.tokenDirectory,
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
const parsed = credentialEnvelopeSchema.safeParse(
|
||||
JSON.parse(envelope.content.toString('utf8')),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
throw new CredentialStoreError('invalid-binding', 'credential envelope is malformed');
|
||||
}
|
||||
const verified = await new FileCredentialResolver(
|
||||
this.tokenDirectory,
|
||||
this.estateRegistry,
|
||||
).resolve(identity, estate, host);
|
||||
if (verified === undefined) {
|
||||
throw new CredentialStoreError('invalid-binding', 'credential envelope was not resolvable');
|
||||
}
|
||||
verified.secret.fill(0);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
identity: parsed.data.identity,
|
||||
estate: parsed.data.estate,
|
||||
host: parsed.data.host,
|
||||
providerLogin: parsed.data.providerLogin,
|
||||
tokenName: parsed.data.tokenName,
|
||||
scopes: parsed.data.scopes,
|
||||
createdAt: parsed.data.createdAt,
|
||||
tokenDigest: parsed.data.tokenDigest,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
}
|
||||
try {
|
||||
snapshot = readRegularFileSecure(paths.binding, {
|
||||
root: this.tokenDirectory,
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (isMissingFile(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
|
||||
throw new CredentialStoreError('insecure-token-owner', 'binding metadata is not private');
|
||||
}
|
||||
const parsed = bindingSchema.safeParse(JSON.parse(snapshot.content.toString('utf8')));
|
||||
if (!parsed.success) {
|
||||
throw new CredentialStoreError(
|
||||
'invalid-binding',
|
||||
'binding metadata failed schema validation',
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async list(estate: string, host: string): Promise<readonly string[]> {
|
||||
const config = this.estateRegistry.resolve(estate, host);
|
||||
if (config === undefined) return [];
|
||||
const names = await readdir(this.tokenDirectory);
|
||||
const prefix = `${config.tokenPrefix}-`;
|
||||
return [
|
||||
...new Set(
|
||||
names.flatMap((name): string[] => {
|
||||
if (!name.startsWith(prefix)) return [];
|
||||
if (name.endsWith('.token')) {
|
||||
return [name.slice(prefix.length, -'.token'.length)];
|
||||
}
|
||||
if (name.endsWith('.credential.json')) {
|
||||
return [name.slice(prefix.length, -'.credential.json'.length)];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
),
|
||||
]
|
||||
.filter((identity): boolean => IDENTITY.test(identity))
|
||||
.sort();
|
||||
}
|
||||
|
||||
async remove(
|
||||
identity: string,
|
||||
estate: string,
|
||||
host: string,
|
||||
expectedTokenDigest?: string,
|
||||
): Promise<void> {
|
||||
const paths = this.paths(identity, estate, host);
|
||||
const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory);
|
||||
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
|
||||
let lock;
|
||||
try {
|
||||
lock = await open(lockPath, 'wx', 0o600);
|
||||
} catch {
|
||||
throw new CredentialStoreError(
|
||||
'conflicting-credential-mutation',
|
||||
'another mutation owns the identity lock',
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (expectedTokenDigest !== undefined) {
|
||||
const current = await this.snapshot(identity, estate, host);
|
||||
try {
|
||||
if (current === undefined || current.generation !== expectedTokenDigest) {
|
||||
throw new CredentialStoreError(
|
||||
'credential-generation-mismatch',
|
||||
'credential generation changed before removal',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
current?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
const beforeRemoval = assertPrivateTokenDirectory(this.tokenDirectory);
|
||||
if (
|
||||
beforeRemoval.dev !== directoryIdentity.dev ||
|
||||
beforeRemoval.ino !== directoryIdentity.ino
|
||||
) {
|
||||
throw new CredentialStoreError(
|
||||
'insecure-token-owner',
|
||||
'token directory changed during credential removal',
|
||||
);
|
||||
}
|
||||
await unlink(paths.token).catch((error: unknown): void => {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
});
|
||||
await unlink(paths.binding).catch((error: unknown): void => {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
});
|
||||
await unlink(paths.envelope).catch((error: unknown): void => {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
});
|
||||
await syncDirectory(this.tokenDirectory);
|
||||
} finally {
|
||||
await lock.close();
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
await syncDirectory(this.tokenDirectory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GiteaCredentialProviderAdapter, GiteaTeamGrantProviderAdapter } from './gitea-provider.js';
|
||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
||||
|
||||
const credential: ResolvedCredential = Object.freeze({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
resolutionId: 'resolution-1',
|
||||
secret: new TextEncoder().encode('seeded-secret-canary'),
|
||||
});
|
||||
|
||||
function jsonResponse(body: object, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json;charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('Gitea credential provider transport', (): void => {
|
||||
it('reads the provider identity with the fixed transport and no secret in the URL', async (): Promise<void> => {
|
||||
const calls: Array<{ readonly input: string; readonly init?: RequestInit }> = [];
|
||||
const adapter = new GiteaCredentialProviderAdapter(
|
||||
'https://git.example.invalid',
|
||||
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
calls.push({ input: String(input), ...(init === undefined ? {} : { init }) });
|
||||
return jsonResponse({ id: 21, login: 'seat-name' });
|
||||
},
|
||||
);
|
||||
|
||||
const evidence = await adapter.readIdentity(credential);
|
||||
|
||||
expect(evidence).toEqual({
|
||||
login: 'seat-name',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json;charset=utf-8',
|
||||
});
|
||||
expect(calls[0]?.input).toBe('https://git.example.invalid/api/v1/user');
|
||||
expect(calls[0]?.input).not.toContain('seeded-secret-canary');
|
||||
expect(new Headers(calls[0]?.init?.headers).get('user-agent')).toBe('mosaic-cred/1');
|
||||
});
|
||||
|
||||
it('maps the authenticated provider repository object to effective permission', async (): Promise<void> => {
|
||||
const adapter = new GiteaCredentialProviderAdapter(
|
||||
'https://git.example.invalid',
|
||||
async (): Promise<Response> =>
|
||||
jsonResponse({
|
||||
id: 99,
|
||||
full_name: 'owner/repo',
|
||||
permissions: { admin: false, push: true, pull: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const evidence = await adapter.readRepositoryPermission(credential, 'owner/repo');
|
||||
|
||||
expect(evidence.effective).toBe('write');
|
||||
expect(evidence.endpoint).toBe('GET /api/v1/repos/owner/repo');
|
||||
});
|
||||
|
||||
it('binds an authenticated receive-pack advertisement to the supplied credential handle', async (): Promise<void> => {
|
||||
const adapter = new GiteaCredentialProviderAdapter(
|
||||
'https://git.example.invalid',
|
||||
async (): Promise<Response> =>
|
||||
new Response('001f# service=git-receive-pack\n0000', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/x-git-receive-pack-advertisement',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const evidence = await adapter.probeReceivePack(credential, 'owner/repo');
|
||||
|
||||
expect(evidence).toEqual({
|
||||
state: 'advertised',
|
||||
principal: 'seat-name',
|
||||
resolutionId: 'resolution-1',
|
||||
contentType: 'application/x-git-receive-pack-advertisement',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports authenticated and unauthenticated receive-pack refusals without inventing success', async (): Promise<void> => {
|
||||
const adapter = new GiteaCredentialProviderAdapter(
|
||||
'https://git.example.invalid',
|
||||
async (): Promise<Response> =>
|
||||
new Response('denied', { status: 403, headers: { 'content-type': 'text/plain' } }),
|
||||
);
|
||||
|
||||
await expect(adapter.probeReceivePack(credential, 'owner/repo')).resolves.toMatchObject({
|
||||
state: 'refused',
|
||||
principal: 'seat-name',
|
||||
resolutionId: 'resolution-1',
|
||||
});
|
||||
await expect(adapter.probeReceivePack(undefined, 'owner/repo')).resolves.toMatchObject({
|
||||
state: 'refused',
|
||||
principal: null,
|
||||
resolutionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call a scope-forbidden identity read a dead credential', async (): Promise<void> => {
|
||||
const adapter = new GiteaCredentialProviderAdapter(
|
||||
'https://git.example.invalid',
|
||||
async (): Promise<Response> => jsonResponse({ message: 'forbidden' }, 403),
|
||||
);
|
||||
|
||||
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
||||
code: 'identity-read-forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies only the supplied credential as rejected without inferring identity absence', async (): Promise<void> => {
|
||||
let calls = 0;
|
||||
const adapter = new GiteaCredentialProviderAdapter(
|
||||
'https://git.example.invalid',
|
||||
async (): Promise<Response> => {
|
||||
calls += 1;
|
||||
return jsonResponse({ message: 'unauthorized' }, 401);
|
||||
},
|
||||
);
|
||||
|
||||
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
||||
code: 'credential-rejected',
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it('classifies a rejected credential separately when the declared identity exists', async (): Promise<void> => {
|
||||
let call = 0;
|
||||
const adapter = new GiteaCredentialProviderAdapter(
|
||||
'https://git.example.invalid',
|
||||
async (): Promise<Response> => {
|
||||
call += 1;
|
||||
if (call === 1) return jsonResponse({ message: 'unauthorized' }, 401);
|
||||
return jsonResponse({ id: 21, login: 'seat-name' });
|
||||
},
|
||||
);
|
||||
|
||||
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
||||
code: 'credential-rejected',
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels an undeclared oversized streaming provider response before buffering it all', async (): Promise<void> => {
|
||||
let pulls = 0;
|
||||
let cancelled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller): void {
|
||||
pulls += 1;
|
||||
controller.enqueue(new Uint8Array(64 * 1024));
|
||||
if (pulls === 100) controller.close();
|
||||
},
|
||||
cancel(): void {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
const adapter = new GiteaCredentialProviderAdapter(
|
||||
'https://git.example.invalid',
|
||||
async (): Promise<Response> =>
|
||||
new Response(stream, { status: 200, headers: { 'content-type': 'application/json' } }),
|
||||
);
|
||||
|
||||
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
||||
code: 'unexpected-provider-shape',
|
||||
});
|
||||
expect(pulls).toBeLessThan(100);
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a 200 HTML identity response as unexpected content type', async (): Promise<void> => {
|
||||
const adapter = new GiteaCredentialProviderAdapter(
|
||||
'https://git.example.invalid',
|
||||
async (): Promise<Response> =>
|
||||
new Response('<html>not an API object</html>', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/html' },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
||||
code: 'unexpected-content-type',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads team permission, member attachment, and repository attachment separately', async (): Promise<void> => {
|
||||
let memberRemoved = false;
|
||||
let repositoryDetached = false;
|
||||
const adapter = new GiteaTeamGrantProviderAdapter(
|
||||
'https://git.example.invalid',
|
||||
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/api/v1/orgs/owner/teams')) {
|
||||
return jsonResponse([{ id: 7, name: 'writers', permission: 'write' }]);
|
||||
}
|
||||
if (init?.method === 'PUT') return new Response(null, { status: 204 });
|
||||
if (init?.method === 'DELETE') {
|
||||
if (url.includes('/members/')) memberRemoved = true;
|
||||
if (url.includes('/repos/')) repositoryDetached = true;
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (url.includes('/members/seat-name')) {
|
||||
return jsonResponse({ id: 21, login: 'seat-name' });
|
||||
}
|
||||
if (url.includes('/repos/owner/repo')) {
|
||||
return jsonResponse({
|
||||
id: 4,
|
||||
full_name: 'owner/repo',
|
||||
permissions: { admin: false, push: true, pull: true },
|
||||
});
|
||||
}
|
||||
return jsonResponse({ message: 'unexpected' }, 500);
|
||||
},
|
||||
);
|
||||
|
||||
const team = await adapter.resolveTeam(credential, 'owner', 'writers');
|
||||
await adapter.addTeamMember(credential, team.id, 'seat-name');
|
||||
await adapter.attachTeamRepository(credential, team.id, 'owner/repo');
|
||||
await expect(adapter.readTeamMember(credential, team.id, 'seat-name')).resolves.toMatchObject({
|
||||
state: 'present',
|
||||
});
|
||||
await expect(
|
||||
adapter.readTeamRepository(credential, team.id, 'owner/repo'),
|
||||
).resolves.toMatchObject({ state: 'present' });
|
||||
await adapter.removeTeamMember(credential, team.id, 'seat-name');
|
||||
await adapter.detachTeamRepository(credential, team.id, 'owner/repo');
|
||||
expect(memberRemoved).toBe(true);
|
||||
expect(repositoryDetached).toBe(true);
|
||||
expect(team).toMatchObject({ id: 7, name: 'writers', permission: 'write' });
|
||||
});
|
||||
|
||||
it('rejects a successful team read-back that names the wrong object', async (): Promise<void> => {
|
||||
const adapter = new GiteaTeamGrantProviderAdapter(
|
||||
'https://git.example.invalid',
|
||||
async (): Promise<Response> => jsonResponse({ id: 99, login: 'other-seat' }),
|
||||
);
|
||||
|
||||
await expect(adapter.readTeamMember(credential, 7, 'seat-name')).rejects.toMatchObject({
|
||||
code: 'unexpected-provider-shape',
|
||||
});
|
||||
});
|
||||
|
||||
it('bounds a provider that never returns response headers', async (): Promise<void> => {
|
||||
const adapter = new GiteaCredentialProviderAdapter(
|
||||
'https://git.example.invalid',
|
||||
async (_input: string | URL | Request, init?: RequestInit): Promise<Response> =>
|
||||
new Promise<Response>((_resolve, reject): void => {
|
||||
init?.signal?.addEventListener('abort', (): void => {
|
||||
reject(new Error('aborted'));
|
||||
});
|
||||
}),
|
||||
10,
|
||||
);
|
||||
|
||||
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
||||
code: 'provider-unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('never includes seeded secret material in provider error messages', async (): Promise<void> => {
|
||||
const adapter = new GiteaCredentialProviderAdapter(
|
||||
'https://git.example.invalid',
|
||||
async (): Promise<Response> => {
|
||||
throw new Error('connection reset');
|
||||
},
|
||||
);
|
||||
|
||||
let message = '';
|
||||
try {
|
||||
await adapter.readIdentity(credential);
|
||||
} catch (error: unknown) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
expect(message).not.toContain('seeded-secret-canary');
|
||||
expect(message).toContain('provider-unavailable');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,943 @@
|
||||
import { z } from 'zod';
|
||||
import type { GiteaCredentialProvider, ResolvedCredential } from './credential-provider.dto.js';
|
||||
import type { GiteaGrantProvider } from './grant.js';
|
||||
import type { GiteaLifecycleProvider, MintedToken } from './lifecycle.js';
|
||||
import type { TokenObjectEvidenceDto } from './lifecycle.dto.js';
|
||||
import type {
|
||||
GiteaTeamGrantProvider,
|
||||
PresenceEvidence,
|
||||
TeamRepositorySetEvidence,
|
||||
TeamResolutionEvidence,
|
||||
} from './team-grant.js';
|
||||
import type {
|
||||
CollaboratorPermissionEvidenceDto,
|
||||
OrganizationMembershipEvidenceDto,
|
||||
} from './grant.dto.js';
|
||||
import type {
|
||||
ProviderIdentityEvidenceDto,
|
||||
ReceivePackEvidenceDto,
|
||||
RepositoryPermission,
|
||||
RepositoryPermissionEvidenceDto,
|
||||
} from './credential-result.dto.js';
|
||||
|
||||
const MAX_PROVIDER_BYTES = 1024 * 1024;
|
||||
const USER_AGENT = 'mosaic-cred/1';
|
||||
const JSON_CONTENT_TYPE = 'application/json';
|
||||
const RECEIVE_PACK_CONTENT_TYPE = 'application/x-git-receive-pack-advertisement';
|
||||
const REPO_COMPONENT = /^[A-Za-z0-9_.-]+$/;
|
||||
|
||||
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
const userSchema = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
login: z.string().min(1),
|
||||
is_admin: z.boolean().optional(),
|
||||
visibility: z.enum(['public', 'limited', 'private']).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const collaboratorPermissionSchema = z
|
||||
.object({
|
||||
permission: z.enum(['read', 'write', 'admin']),
|
||||
user: z.object({ login: z.string().min(1) }).passthrough(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const organizationSchema = z.object({ username: z.string().min(1) }).passthrough();
|
||||
const tokenObjectSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
sha1: z.string().min(1).optional(),
|
||||
token: z.string().min(1).optional(),
|
||||
scopes: z.array(z.string()).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
const teamSchema = z
|
||||
.object({
|
||||
id: z.number().int().positive(),
|
||||
name: z.string().min(1),
|
||||
permission: z.enum(['read', 'write', 'admin']),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const repoSchema = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
full_name: z.string().min(3),
|
||||
permissions: z
|
||||
.object({
|
||||
admin: z.boolean(),
|
||||
push: z.boolean(),
|
||||
pull: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export class CredentialProviderEvidenceError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(`Gitea credential evidence unavailable: code=${code} ${message}`);
|
||||
this.name = 'CredentialProviderEvidenceError';
|
||||
}
|
||||
}
|
||||
|
||||
function contentType(response: Response): string {
|
||||
return response.headers.get('content-type') ?? '';
|
||||
}
|
||||
|
||||
function isJson(response: Response): boolean {
|
||||
return contentType(response).toLowerCase().startsWith(JSON_CONTENT_TYPE);
|
||||
}
|
||||
|
||||
async function boundedBody(response: Response): Promise<Uint8Array> {
|
||||
const declared = response.headers.get('content-length');
|
||||
if (declared !== null) {
|
||||
if (!/^\d+$/.test(declared)) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'provider response declared an invalid content length',
|
||||
);
|
||||
}
|
||||
const bytes = Number(declared);
|
||||
if (!Number.isSafeInteger(bytes) || bytes > MAX_PROVIDER_BYTES) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'provider response exceeded the bounded size',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (response.body === null) return new Uint8Array();
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
total += next.value.byteLength;
|
||||
if (total > MAX_PROVIDER_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'provider response exceeded the bounded size',
|
||||
);
|
||||
}
|
||||
chunks.push(next.value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
if (declared !== null && total !== Number(declared)) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'provider response length contradicted its declaration',
|
||||
);
|
||||
}
|
||||
const body = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function jsonObject(response: Response): Promise<unknown> {
|
||||
if (!isJson(response)) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-content-type',
|
||||
'provider response was not JSON',
|
||||
);
|
||||
}
|
||||
const bytes = await boundedBody(response);
|
||||
try {
|
||||
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
||||
} catch {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'provider JSON could not be parsed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function tokenText(resolved: ResolvedCredential): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(resolved.secret);
|
||||
} catch {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'credential bytes were not valid text',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function apiAuthorization(resolved: ResolvedCredential): string {
|
||||
return `token ${tokenText(resolved)}`;
|
||||
}
|
||||
|
||||
function gitAuthorization(resolved: ResolvedCredential): string {
|
||||
const basic = Buffer.from(`${resolved.identity}:${tokenText(resolved)}`, 'utf8').toString(
|
||||
'base64',
|
||||
);
|
||||
return `Basic ${basic}`;
|
||||
}
|
||||
|
||||
function repoPath(repo: string): { readonly owner: string; readonly name: string } {
|
||||
const pieces = repo.split('/');
|
||||
const owner = pieces[0];
|
||||
const name = pieces[1];
|
||||
if (
|
||||
pieces.length !== 2 ||
|
||||
owner === undefined ||
|
||||
name === undefined ||
|
||||
!REPO_COMPONENT.test(owner) ||
|
||||
!REPO_COMPONENT.test(name)
|
||||
) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'repository must be exactly owner/name in the allowlist grammar',
|
||||
);
|
||||
}
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
function effectivePermission(permissions: {
|
||||
readonly admin: boolean;
|
||||
readonly push: boolean;
|
||||
readonly pull: boolean;
|
||||
}): RepositoryPermission {
|
||||
if (permissions.admin) return 'admin';
|
||||
if (permissions.push) return 'write';
|
||||
if (permissions.pull) return 'read';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider {
|
||||
protected readonly origin: string;
|
||||
|
||||
constructor(
|
||||
apiBaseUrl: string,
|
||||
private readonly fetchImpl: FetchLike = fetch,
|
||||
private readonly requestTimeoutMs = 10_000,
|
||||
) {
|
||||
const parsed = new URL(apiBaseUrl);
|
||||
this.origin = parsed.origin;
|
||||
if (
|
||||
!Number.isSafeInteger(requestTimeoutMs) ||
|
||||
requestTimeoutMs < 1 ||
|
||||
requestTimeoutMs > 30_000
|
||||
) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'invalid-input',
|
||||
'provider request timeout is outside the bounded range',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected async request(url: string, init: RequestInit): Promise<Response> {
|
||||
const deadline = AbortSignal.timeout(this.requestTimeoutMs);
|
||||
const signal = init.signal == null ? deadline : AbortSignal.any([init.signal, deadline]);
|
||||
try {
|
||||
return await this.fetchImpl(url, { ...init, signal });
|
||||
} catch {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'provider-unavailable',
|
||||
'provider request failed before evidence was available',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async classifyRejectedIdentity(rejected: Response): Promise<never> {
|
||||
if (!isJson(rejected)) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-content-type',
|
||||
'provider credential rejection was not JSON',
|
||||
);
|
||||
}
|
||||
const status = rejected.status;
|
||||
await boundedBody(rejected);
|
||||
if (status === 403 || status === 404) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'identity-read-forbidden',
|
||||
'provider denied the identity endpoint; credential capability must be tested in scope',
|
||||
);
|
||||
}
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'credential-rejected',
|
||||
'provider rejected the supplied credential; account existence was not inferred',
|
||||
);
|
||||
}
|
||||
|
||||
async readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidenceDto> {
|
||||
const endpoint = 'GET /api/v1/user';
|
||||
const response = await this.request(`${this.origin}/api/v1/user`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: apiAuthorization(resolved),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
if (response.status === 401 || response.status === 403 || response.status === 404) {
|
||||
return this.classifyRejectedIdentity(response);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'provider-unavailable',
|
||||
`provider identity request returned HTTP ${response.status.toString()}`,
|
||||
);
|
||||
}
|
||||
const parsed = userSchema.safeParse(await jsonObject(response));
|
||||
if (!parsed.success) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'provider identity object lacked required fields',
|
||||
);
|
||||
}
|
||||
return {
|
||||
login: parsed.data.login,
|
||||
endpoint,
|
||||
contentType: contentType(response),
|
||||
};
|
||||
}
|
||||
|
||||
async readRepositoryPermission(
|
||||
resolved: ResolvedCredential,
|
||||
repo: string,
|
||||
): Promise<RepositoryPermissionEvidenceDto> {
|
||||
const { owner, name } = repoPath(repo);
|
||||
const endpoint = `GET /api/v1/repos/${owner}/${name}`;
|
||||
const response = await this.request(`${this.origin}/api/v1/repos/${owner}/${name}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: apiAuthorization(resolved),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'provider-unavailable',
|
||||
`provider repository request returned HTTP ${response.status.toString()}`,
|
||||
);
|
||||
}
|
||||
const parsed = repoSchema.safeParse(await jsonObject(response));
|
||||
if (!parsed.success || parsed.data.full_name !== repo) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'provider repository object did not identify the requested repository',
|
||||
);
|
||||
}
|
||||
return {
|
||||
effective: effectivePermission(parsed.data.permissions),
|
||||
endpoint,
|
||||
contentType: contentType(response),
|
||||
};
|
||||
}
|
||||
|
||||
async probeReceivePack(
|
||||
resolved: ResolvedCredential | undefined,
|
||||
repo: string,
|
||||
): Promise<ReceivePackEvidenceDto> {
|
||||
const { owner, name } = repoPath(repo);
|
||||
const headers = new Headers({
|
||||
Accept: RECEIVE_PACK_CONTENT_TYPE,
|
||||
'User-Agent': USER_AGENT,
|
||||
});
|
||||
if (resolved !== undefined) headers.set('Authorization', gitAuthorization(resolved));
|
||||
const response = await this.request(
|
||||
`${this.origin}/${owner}/${name}.git/info/refs?service=git-receive-pack`,
|
||||
{ method: 'GET', headers },
|
||||
);
|
||||
const responseType = contentType(response);
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
await boundedBody(response);
|
||||
return {
|
||||
state: 'refused',
|
||||
principal: resolved?.identity ?? null,
|
||||
resolutionId: resolved?.resolutionId ?? null,
|
||||
contentType: responseType,
|
||||
};
|
||||
}
|
||||
if (!response.ok || !responseType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE)) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
response.ok ? 'unexpected-content-type' : 'provider-unavailable',
|
||||
`receive-pack response was not an advertisement (HTTP ${response.status.toString()})`,
|
||||
);
|
||||
}
|
||||
const body = new TextDecoder('utf-8', { fatal: true }).decode(await boundedBody(response));
|
||||
if (!body.includes('# service=git-receive-pack')) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'receive-pack advertisement lacked the protocol service preamble',
|
||||
);
|
||||
}
|
||||
return {
|
||||
state: 'advertised',
|
||||
principal: resolved?.identity ?? null,
|
||||
resolutionId: resolved?.resolutionId ?? null,
|
||||
contentType: responseType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class GiteaGrantProviderAdapter
|
||||
extends GiteaCredentialProviderAdapter
|
||||
implements GiteaGrantProvider
|
||||
{
|
||||
async readBasicIdentity(authority: ResolvedCredential): Promise<ProviderIdentityEvidenceDto> {
|
||||
const endpoint = 'GET /api/v1/user';
|
||||
const response = await this.request(`${this.origin}/api/v1/user`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
await boundedBody(response);
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'credential-rejected',
|
||||
'delegated Basic authority was rejected',
|
||||
);
|
||||
}
|
||||
const parsed = userSchema.safeParse(await jsonObject(response));
|
||||
if (!parsed.success) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'delegated Basic identity response was malformed',
|
||||
);
|
||||
}
|
||||
return { login: parsed.data.login, endpoint, contentType: contentType(response) };
|
||||
}
|
||||
|
||||
async grantCollaborator(
|
||||
authority: ResolvedCredential,
|
||||
identity: string,
|
||||
repo: string,
|
||||
permission: RepositoryPermission,
|
||||
): Promise<void> {
|
||||
const { owner, name } = repoPath(repo);
|
||||
const response = await this.request(
|
||||
`${this.origin}/api/v1/repos/${owner}/${name}/collaborators/${encodeURIComponent(identity)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'Content-Type': JSON_CONTENT_TYPE,
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
body: JSON.stringify({ permission }),
|
||||
},
|
||||
);
|
||||
await boundedBody(response);
|
||||
if (!response.ok) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
response.status === 401 || response.status === 403
|
||||
? 'credential-rejected'
|
||||
: 'provider-unavailable',
|
||||
`provider grant request returned HTTP ${response.status.toString()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async readCollaboratorPermission(
|
||||
authority: ResolvedCredential,
|
||||
identity: string,
|
||||
repo: string,
|
||||
): Promise<CollaboratorPermissionEvidenceDto> {
|
||||
const { owner, name } = repoPath(repo);
|
||||
const endpoint = `GET /api/v1/repos/${owner}/${name}/collaborators/${identity}/permission`;
|
||||
const response = await this.request(
|
||||
`${this.origin}/api/v1/repos/${owner}/${name}/collaborators/${encodeURIComponent(identity)}/permission`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
await boundedBody(response);
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'readback-missing',
|
||||
`collaborator permission read-back returned HTTP ${response.status.toString()}`,
|
||||
);
|
||||
}
|
||||
const parsed = collaboratorPermissionSchema.safeParse(await jsonObject(response));
|
||||
if (!parsed.success || parsed.data.user.login !== identity) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'collaborator permission object did not identify the declared subject',
|
||||
);
|
||||
}
|
||||
return {
|
||||
identity: parsed.data.user.login,
|
||||
permission: parsed.data.permission,
|
||||
endpoint,
|
||||
contentType: contentType(response),
|
||||
};
|
||||
}
|
||||
|
||||
async readOrganizationMembership(
|
||||
subject: ResolvedCredential,
|
||||
organization: string,
|
||||
): Promise<OrganizationMembershipEvidenceDto> {
|
||||
const endpoint = `GET /api/v1/users/${subject.identity}/orgs`;
|
||||
const response = await this.request(
|
||||
`${this.origin}/api/v1/users/${encodeURIComponent(subject.identity)}/orgs`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: apiAuthorization(subject),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
await boundedBody(response);
|
||||
throw new CredentialProviderEvidenceError(
|
||||
response.status === 401 || response.status === 403
|
||||
? 'scope-not-evaluable'
|
||||
: 'provider-unavailable',
|
||||
`organization membership read-back returned HTTP ${response.status.toString()}`,
|
||||
);
|
||||
}
|
||||
const parsed = z.array(organizationSchema).safeParse(await jsonObject(response));
|
||||
if (!parsed.success) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'organization membership response was not an organization array',
|
||||
);
|
||||
}
|
||||
return {
|
||||
state: parsed.data.some((entry): boolean => entry.username === organization)
|
||||
? 'present'
|
||||
: 'absent',
|
||||
endpoint,
|
||||
contentType: contentType(response),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class GiteaTeamGrantProviderAdapter
|
||||
extends GiteaGrantProviderAdapter
|
||||
implements GiteaTeamGrantProvider
|
||||
{
|
||||
async resolveTeam(
|
||||
authority: ResolvedCredential,
|
||||
organization: string,
|
||||
team: string,
|
||||
): Promise<TeamResolutionEvidence> {
|
||||
const endpoint = `GET /api/v1/orgs/${organization}/teams`;
|
||||
const response = await this.request(
|
||||
`${this.origin}/api/v1/orgs/${encodeURIComponent(organization)}/teams`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
await boundedBody(response);
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'provider-unavailable',
|
||||
'team list was unavailable',
|
||||
);
|
||||
}
|
||||
const parsed = z.array(teamSchema).safeParse(await jsonObject(response));
|
||||
const matches = parsed.success
|
||||
? parsed.data.filter((entry): boolean => entry.name === team)
|
||||
: [];
|
||||
if (matches.length !== 1 || matches[0] === undefined) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'readback-missing',
|
||||
'team did not resolve uniquely',
|
||||
);
|
||||
}
|
||||
return { ...matches[0], endpoint, contentType: contentType(response) };
|
||||
}
|
||||
|
||||
async listTeamRepositories(
|
||||
authority: ResolvedCredential,
|
||||
teamId: number,
|
||||
): Promise<TeamRepositorySetEvidence> {
|
||||
const endpoint = `GET /api/v1/teams/${teamId.toString()}/repos`;
|
||||
const repositories: string[] = [];
|
||||
let observedType = '';
|
||||
for (let page = 1; page <= 100; page += 1) {
|
||||
const response = await this.request(
|
||||
`${this.origin}/api/v1/teams/${teamId.toString()}/repos?limit=50&page=${page.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
await boundedBody(response);
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'readback-missing',
|
||||
'team repository set was unavailable',
|
||||
);
|
||||
}
|
||||
observedType = contentType(response);
|
||||
const parsed = z.array(repoSchema).safeParse(await jsonObject(response));
|
||||
if (!parsed.success) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'team repository set was not a repository array',
|
||||
);
|
||||
}
|
||||
repositories.push(...parsed.data.map((repo): string => repo.full_name));
|
||||
if (parsed.data.length < 50) {
|
||||
return { repositories, endpoint, contentType: observedType };
|
||||
}
|
||||
}
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'readback-missing',
|
||||
'team repository set exceeded the pagination bound',
|
||||
);
|
||||
}
|
||||
|
||||
async addTeamMember(
|
||||
authority: ResolvedCredential,
|
||||
teamId: number,
|
||||
identity: string,
|
||||
): Promise<void> {
|
||||
await this.putTeamPath(
|
||||
authority,
|
||||
`/api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async removeTeamMember(
|
||||
authority: ResolvedCredential,
|
||||
teamId: number,
|
||||
identity: string,
|
||||
): Promise<void> {
|
||||
const response = await this.request(
|
||||
`${this.origin}/api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
);
|
||||
await boundedBody(response);
|
||||
if (!response.ok) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'provider-unavailable',
|
||||
'team member rollback failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async attachTeamRepository(
|
||||
authority: ResolvedCredential,
|
||||
teamId: number,
|
||||
repo: string,
|
||||
): Promise<void> {
|
||||
const { owner, name } = repoPath(repo);
|
||||
await this.putTeamPath(authority, `/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`);
|
||||
}
|
||||
|
||||
async detachTeamRepository(
|
||||
authority: ResolvedCredential,
|
||||
teamId: number,
|
||||
repo: string,
|
||||
): Promise<void> {
|
||||
const { owner, name } = repoPath(repo);
|
||||
const response = await this.request(
|
||||
`${this.origin}/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
);
|
||||
await boundedBody(response);
|
||||
if (!response.ok) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'provider-unavailable',
|
||||
'team repository rollback failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async putTeamPath(authority: ResolvedCredential, path: string): Promise<void> {
|
||||
const response = await this.request(`${this.origin}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
await boundedBody(response);
|
||||
if (!response.ok) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'provider-unavailable',
|
||||
'team grant mutation failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async readTeamMember(
|
||||
authority: ResolvedCredential,
|
||||
teamId: number,
|
||||
identity: string,
|
||||
): Promise<PresenceEvidence> {
|
||||
return this.readPresence(
|
||||
authority,
|
||||
`GET /api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
|
||||
(value: unknown): boolean => {
|
||||
const parsed = userSchema.safeParse(value);
|
||||
return parsed.success && parsed.data.login === identity;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async readTeamRepository(
|
||||
authority: ResolvedCredential,
|
||||
teamId: number,
|
||||
repo: string,
|
||||
): Promise<PresenceEvidence> {
|
||||
const { owner, name } = repoPath(repo);
|
||||
return this.readPresence(
|
||||
authority,
|
||||
`GET /api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`,
|
||||
(value: unknown): boolean => {
|
||||
const parsed = repoSchema.safeParse(value);
|
||||
return parsed.success && parsed.data.full_name === repo;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async readPresence(
|
||||
authority: ResolvedCredential,
|
||||
endpoint: string,
|
||||
matchesExpectedObject: (value: unknown) => boolean,
|
||||
): Promise<PresenceEvidence> {
|
||||
const response = await this.request(`${this.origin}${endpoint.slice(4)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
if (response.status === 404) {
|
||||
await boundedBody(response);
|
||||
return { state: 'absent', endpoint, contentType: contentType(response) };
|
||||
}
|
||||
if (!response.ok || !isJson(response)) {
|
||||
await boundedBody(response);
|
||||
throw new CredentialProviderEvidenceError('readback-missing', 'team read-back failed');
|
||||
}
|
||||
if (!matchesExpectedObject(await jsonObject(response))) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'team read-back did not identify the requested object',
|
||||
);
|
||||
}
|
||||
return { state: 'present', endpoint, contentType: contentType(response) };
|
||||
}
|
||||
}
|
||||
|
||||
function basicAuthorization(authority: ResolvedCredential): string {
|
||||
const prefix = Buffer.from(`${authority.identity}:`, 'utf8');
|
||||
const material = Buffer.concat([prefix, Buffer.from(authority.secret)]);
|
||||
try {
|
||||
return `Basic ${material.toString('base64')}`;
|
||||
} finally {
|
||||
prefix.fill(0);
|
||||
material.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export class GiteaLifecycleProviderAdapter
|
||||
extends GiteaCredentialProviderAdapter
|
||||
implements GiteaLifecycleProvider
|
||||
{
|
||||
async readBasicIdentity(authority: ResolvedCredential): Promise<ProviderIdentityEvidenceDto> {
|
||||
const endpoint = 'GET /api/v1/user';
|
||||
const response = await this.request(`${this.origin}/api/v1/user`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
await boundedBody(response);
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'credential-rejected',
|
||||
'delegated Basic authority was rejected',
|
||||
);
|
||||
}
|
||||
const parsed = userSchema.safeParse(await jsonObject(response));
|
||||
if (!parsed.success) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'delegated Basic identity response was malformed',
|
||||
);
|
||||
}
|
||||
return { login: parsed.data.login, endpoint, contentType: contentType(response) };
|
||||
}
|
||||
|
||||
async mintToken(
|
||||
authority: ResolvedCredential,
|
||||
identity: string,
|
||||
name: string,
|
||||
scopes: readonly string[],
|
||||
): Promise<MintedToken> {
|
||||
const endpoint = `POST /api/v1/users/${identity}/tokens`;
|
||||
const response = await this.request(
|
||||
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
'Content-Type': JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
body: JSON.stringify({ name, scopes }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
await boundedBody(response);
|
||||
throw new CredentialProviderEvidenceError('provider-unavailable', 'token mint failed');
|
||||
}
|
||||
const parsed = tokenObjectSchema.safeParse(await jsonObject(response));
|
||||
const secret = parsed.success ? (parsed.data.sha1 ?? parsed.data.token) : undefined;
|
||||
if (!parsed.success || secret === undefined || parsed.data.name !== name) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'minted token object lacked the requested name or secret',
|
||||
);
|
||||
}
|
||||
return {
|
||||
secret: new TextEncoder().encode(secret),
|
||||
evidence: {
|
||||
name: parsed.data.name,
|
||||
scopes: parsed.data.scopes,
|
||||
endpoint,
|
||||
contentType: contentType(response),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async readToken(
|
||||
authority: ResolvedCredential,
|
||||
identity: string,
|
||||
name: string,
|
||||
): Promise<TokenObjectEvidenceDto> {
|
||||
const endpoint = `GET /api/v1/users/${identity}/tokens`;
|
||||
const response = await this.request(
|
||||
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
await boundedBody(response);
|
||||
throw new CredentialProviderEvidenceError('readback-missing', 'token list read-back failed');
|
||||
}
|
||||
const parsed = z.array(tokenObjectSchema).safeParse(await jsonObject(response));
|
||||
const matches = parsed.success
|
||||
? parsed.data.filter((token): boolean => token.name === name)
|
||||
: [];
|
||||
if (matches.length !== 1 || matches[0] === undefined) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'readback-missing',
|
||||
'minted token did not resolve uniquely by name',
|
||||
);
|
||||
}
|
||||
return {
|
||||
name: matches[0].name,
|
||||
scopes: matches[0].scopes,
|
||||
endpoint,
|
||||
contentType: contentType(response),
|
||||
};
|
||||
}
|
||||
|
||||
async tokenExists(
|
||||
authority: ResolvedCredential,
|
||||
identity: string,
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
const response = await this.request(
|
||||
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
await boundedBody(response);
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'readback-missing',
|
||||
'token absence read-back failed',
|
||||
);
|
||||
}
|
||||
const parsed = z.array(tokenObjectSchema).safeParse(await jsonObject(response));
|
||||
if (!parsed.success) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'token absence read-back was malformed',
|
||||
);
|
||||
}
|
||||
return parsed.data.some((token): boolean => token.name === name);
|
||||
}
|
||||
|
||||
async revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void> {
|
||||
const response = await this.request(
|
||||
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens/${encodeURIComponent(name)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
);
|
||||
await boundedBody(response);
|
||||
if (!response.ok) {
|
||||
throw new CredentialProviderEvidenceError('mutation-state-unknown', 'token revoke failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type {
|
||||
CredentialAuditResultDto,
|
||||
CredentialMutationState,
|
||||
CredentialOutcome,
|
||||
CredentialReasonDto,
|
||||
CredentialSubjectDto,
|
||||
CredentialValidationEvidenceDto,
|
||||
RepositoryPermission,
|
||||
} from './credential-result.dto.js';
|
||||
|
||||
export interface CollaboratorPermissionEvidenceDto {
|
||||
readonly identity: string;
|
||||
readonly permission: RepositoryPermission;
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export interface OrganizationMembershipEvidenceDto {
|
||||
readonly state: 'present' | 'absent';
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export interface CredentialGrantEvidenceDto extends CredentialValidationEvidenceDto {
|
||||
readonly collaboratorPermission: CollaboratorPermissionEvidenceDto | null;
|
||||
readonly organizationMembership: OrganizationMembershipEvidenceDto | null;
|
||||
}
|
||||
|
||||
export interface CredentialGrantResultDto {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'grant';
|
||||
readonly outcome: CredentialOutcome;
|
||||
readonly exitCode: 0 | 10 | 20 | 30;
|
||||
readonly retryable: boolean;
|
||||
readonly subject: CredentialSubjectDto;
|
||||
readonly mutation: CredentialMutationState;
|
||||
readonly reason: CredentialReasonDto;
|
||||
readonly evidence: CredentialGrantEvidenceDto;
|
||||
readonly audit: CredentialAuditResultDto;
|
||||
}
|
||||
|
||||
export interface DirectGrantRequestDto extends CredentialSubjectDto {
|
||||
readonly permission: RepositoryPermission;
|
||||
readonly readOnlyControlIdentity: string;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { listCredentialJournals } from './audit-journal.js';
|
||||
import { grantDirectRepositoryPermission } from './grant.js';
|
||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
||||
import type { GiteaGrantProvider } from './grant.js';
|
||||
import type { CredentialValidationDependencies } from './validate.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
const authority: ResolvedCredential = Object.freeze({
|
||||
identity: 'provisioner',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
resolutionId: 'authority',
|
||||
secret: new TextEncoder().encode('authority-canary'),
|
||||
});
|
||||
|
||||
async function stateRoot(): Promise<string> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-grant-'));
|
||||
return join(cleanup, 'state');
|
||||
}
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
function validationDependencies(permission: 'read' | 'write'): CredentialValidationDependencies {
|
||||
const subject: ResolvedCredential = Object.freeze({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
resolutionId: 'subject',
|
||||
secret: new TextEncoder().encode('subject-canary'),
|
||||
});
|
||||
const control: ResolvedCredential = Object.freeze({
|
||||
identity: 'read-control',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
resolutionId: 'control',
|
||||
secret: new TextEncoder().encode('control-canary'),
|
||||
});
|
||||
return {
|
||||
estateRegistry: { matches: (): boolean => true },
|
||||
resolver: {
|
||||
async resolve(identity: string): Promise<ResolvedCredential | undefined> {
|
||||
if (identity === 'seat-name') return subject;
|
||||
if (identity === 'read-control') return control;
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
async readIdentity(resolved: ResolvedCredential) {
|
||||
return {
|
||||
login: resolved.identity,
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readRepositoryPermission(resolved: ResolvedCredential) {
|
||||
return {
|
||||
effective: resolved.identity === 'seat-name' ? permission : 'read',
|
||||
endpoint: 'GET /api/v1/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async probeReceivePack(resolved: ResolvedCredential | undefined) {
|
||||
const subjectWrite = resolved?.identity === 'seat-name' && permission === 'write';
|
||||
return {
|
||||
state: subjectWrite ? 'advertised' : 'refused',
|
||||
principal: resolved?.identity ?? null,
|
||||
resolutionId: resolved?.resolutionId ?? null,
|
||||
contentType: subjectWrite ? 'application/x-git-receive-pack-advertisement' : 'text/plain',
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('direct repository grant', (): void => {
|
||||
it('opens the journal before mutation and accepts only matching provider read-back', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
const provider: GiteaGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async grantCollaborator(): Promise<void> {
|
||||
expect((await listCredentialJournals(root))[0]?.state).toBe('open');
|
||||
},
|
||||
async readCollaboratorPermission() {
|
||||
return {
|
||||
identity: 'seat-name',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/repos/owner/repo/collaborators/seat-name/permission',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'absent',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const result = await grantDirectRepositoryPermission(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
},
|
||||
authority,
|
||||
provider,
|
||||
validationDependencies('write'),
|
||||
{ stateRoot: root, actor: 'provisioner' },
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('ok');
|
||||
expect(result.mutation).toBe('applied');
|
||||
expect(result.evidence.repositoryPermission?.effective).toBe('write');
|
||||
expect(result.evidence.organizationMembership?.state).toBe('absent');
|
||||
expect(result.audit.state).toBe('sealed');
|
||||
const [sealed] = await listCredentialJournals(root);
|
||||
const source = await readFile(sealed?.path ?? '', 'utf8');
|
||||
expect(source).toContain('"phase":"mutation"');
|
||||
expect(source).toContain('"decision":"collaborator-grant-applied"');
|
||||
expect(source).toContain('"decision":"identity-verified"');
|
||||
expect(source).toContain('"decision":"organization-member-absent"');
|
||||
expect(source).toContain('"decision":"transport-write-verified"');
|
||||
});
|
||||
|
||||
it('preserves applied mutation and journal context when post-grant read-back fails', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
const provider: GiteaGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async grantCollaborator(): Promise<void> {},
|
||||
async readCollaboratorPermission() {
|
||||
throw new Error('read-back unavailable');
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
throw new Error('must not be reached');
|
||||
},
|
||||
};
|
||||
|
||||
const result = await grantDirectRepositoryPermission(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
},
|
||||
authority,
|
||||
provider,
|
||||
validationDependencies('write'),
|
||||
{ stateRoot: root, actor: 'provisioner' },
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.mutation).toBe('applied');
|
||||
expect(result.reason.code).toBe('readback-missing');
|
||||
expect(result.audit.journalId).not.toBeNull();
|
||||
expect(result.audit.state).toBe('sealed');
|
||||
});
|
||||
|
||||
it('is indeterminate when grant read-back disagrees with the requested permission', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
const provider: GiteaGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async grantCollaborator(): Promise<void> {},
|
||||
async readCollaboratorPermission() {
|
||||
return {
|
||||
identity: 'seat-name',
|
||||
permission: 'read',
|
||||
endpoint: 'GET /api/v1/repos/owner/repo/collaborators/seat-name/permission',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'absent',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const result = await grantDirectRepositoryPermission(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
},
|
||||
authority,
|
||||
provider,
|
||||
validationDependencies('read'),
|
||||
{ stateRoot: root, actor: 'provisioner' },
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.reason.code).toBe('permission-evidence-disagrees');
|
||||
expect(result.mutation).toBe('applied');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
||||
import type {
|
||||
CredentialValidationDependencies,
|
||||
ResolvedCredential,
|
||||
} from './credential-provider.dto.js';
|
||||
import type { RepositoryPermission } from './credential-result.dto.js';
|
||||
import type {
|
||||
CollaboratorPermissionEvidenceDto,
|
||||
CredentialGrantResultDto,
|
||||
DirectGrantRequestDto,
|
||||
OrganizationMembershipEvidenceDto,
|
||||
} from './grant.dto.js';
|
||||
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
|
||||
|
||||
export interface GiteaGrantProvider {
|
||||
readBasicIdentity(authority: ResolvedCredential): Promise<{
|
||||
readonly login: string;
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
}>;
|
||||
grantCollaborator(
|
||||
authority: ResolvedCredential,
|
||||
identity: string,
|
||||
repo: string,
|
||||
permission: RepositoryPermission,
|
||||
): Promise<void>;
|
||||
readCollaboratorPermission(
|
||||
authority: ResolvedCredential,
|
||||
identity: string,
|
||||
repo: string,
|
||||
): Promise<CollaboratorPermissionEvidenceDto>;
|
||||
readOrganizationMembership(
|
||||
subject: ResolvedCredential,
|
||||
organization: string,
|
||||
): Promise<OrganizationMembershipEvidenceDto>;
|
||||
}
|
||||
|
||||
export class CredentialGrantExecutionError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
public readonly mutation: 'none' | 'unknown' | 'applied',
|
||||
public readonly journalId: string,
|
||||
) {
|
||||
super(`Credential grant control failed: code=${code}`);
|
||||
this.name = 'CredentialGrantExecutionError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface CredentialGrantServiceOptions {
|
||||
readonly stateRoot: string;
|
||||
readonly actor: string;
|
||||
}
|
||||
|
||||
function exitFor(outcome: CredentialGrantResultDto['outcome']): 0 | 10 | 20 | 30 {
|
||||
if (outcome === 'ok') return 0;
|
||||
if (outcome === 'refused') return 10;
|
||||
if (outcome === 'error') return 20;
|
||||
return 30;
|
||||
}
|
||||
|
||||
export async function grantDirectRepositoryPermission(
|
||||
request: DirectGrantRequestDto,
|
||||
authority: ResolvedCredential,
|
||||
grantProvider: GiteaGrantProvider,
|
||||
validationDependencies: CredentialValidationDependencies,
|
||||
options: CredentialGrantServiceOptions,
|
||||
): Promise<CredentialGrantResultDto> {
|
||||
const journal = await CredentialAuditJournal.open(options.stateRoot, {
|
||||
operation: 'grant',
|
||||
actor: options.actor,
|
||||
identity: request.identity,
|
||||
estate: request.estate,
|
||||
host: request.host,
|
||||
repo: request.repo,
|
||||
});
|
||||
await journal.recordIntent('provider-grant');
|
||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||
try {
|
||||
const authorityIdentity = await grantProvider.readBasicIdentity(authority);
|
||||
if (authorityIdentity.login !== options.actor) {
|
||||
await journal.seal('refused', 'provider-identity-mismatch');
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'grant',
|
||||
outcome: 'refused',
|
||||
exitCode: 10,
|
||||
retryable: false,
|
||||
subject: {
|
||||
identity: request.identity,
|
||||
estate: request.estate,
|
||||
host: request.host,
|
||||
repo: request.repo,
|
||||
},
|
||||
mutation: 'none',
|
||||
reason: {
|
||||
code: 'provider-identity-mismatch',
|
||||
message: 'Delegated grant authority did not authenticate as the explicit audit actor.',
|
||||
},
|
||||
evidence: {
|
||||
providerIdentity: authorityIdentity,
|
||||
tokenCapabilities: {
|
||||
state: 'not-measured',
|
||||
scopes: [],
|
||||
source: 'runtime-not-authorized',
|
||||
},
|
||||
repositoryPermission: null,
|
||||
writeDifferential: null,
|
||||
collaboratorPermission: null,
|
||||
organizationMembership: null,
|
||||
},
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
};
|
||||
}
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: authorityIdentity.endpoint,
|
||||
contentType: authorityIdentity.contentType,
|
||||
decision: 'identity-verified',
|
||||
});
|
||||
mutation = 'unknown';
|
||||
await grantProvider.grantCollaborator(
|
||||
authority,
|
||||
request.identity,
|
||||
request.repo,
|
||||
request.permission,
|
||||
);
|
||||
mutation = 'applied';
|
||||
await journal.recordMutation('collaborator-grant-applied');
|
||||
|
||||
const collaborator = await grantProvider.readCollaboratorPermission(
|
||||
authority,
|
||||
request.identity,
|
||||
request.repo,
|
||||
);
|
||||
const subject = await validationDependencies.resolver.resolve(
|
||||
request.identity,
|
||||
request.estate,
|
||||
request.host,
|
||||
);
|
||||
const organization = request.repo.split('/')[0] ?? '';
|
||||
const organizationMembership =
|
||||
subject === undefined
|
||||
? null
|
||||
: await grantProvider.readOrganizationMembership(subject, organization);
|
||||
const validation =
|
||||
request.permission === 'read'
|
||||
? await evaluateGiteaReadValidation(request, validationDependencies)
|
||||
: await evaluateGiteaWriteValidation(request, validationDependencies);
|
||||
|
||||
if (organizationMembership !== null) {
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: organizationMembership.endpoint,
|
||||
contentType: organizationMembership.contentType,
|
||||
decision:
|
||||
organizationMembership.state === 'present'
|
||||
? 'organization-member-present'
|
||||
: 'organization-member-absent',
|
||||
});
|
||||
}
|
||||
if (validation.evidence.providerIdentity !== null) {
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: validation.evidence.providerIdentity.endpoint,
|
||||
contentType: validation.evidence.providerIdentity.contentType,
|
||||
decision: 'identity-verified',
|
||||
});
|
||||
}
|
||||
if (validation.evidence.repositoryPermission !== null) {
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: validation.evidence.repositoryPermission.endpoint,
|
||||
contentType: validation.evidence.repositoryPermission.contentType,
|
||||
decision: `permission-${validation.evidence.repositoryPermission.effective}`,
|
||||
});
|
||||
}
|
||||
if (validation.evidence.writeDifferential !== null) {
|
||||
await journal.recordMutation('transport-write-verified');
|
||||
}
|
||||
|
||||
const readBackMatches =
|
||||
collaborator.identity === request.identity &&
|
||||
collaborator.permission === request.permission &&
|
||||
validation.outcome === 'ok' &&
|
||||
validation.evidence.repositoryPermission?.effective === request.permission;
|
||||
const outcome: CredentialGrantResultDto['outcome'] = readBackMatches ? 'ok' : 'indeterminate';
|
||||
const reasonCode = readBackMatches ? 'grant-verified' : 'permission-evidence-disagrees';
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: collaborator.endpoint,
|
||||
contentType: collaborator.contentType,
|
||||
decision: `permission-${collaborator.permission}`,
|
||||
});
|
||||
await journal.seal(outcome, reasonCode);
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'grant',
|
||||
outcome,
|
||||
exitCode: exitFor(outcome),
|
||||
retryable: false,
|
||||
subject: {
|
||||
identity: request.identity,
|
||||
estate: request.estate,
|
||||
host: request.host,
|
||||
repo: request.repo,
|
||||
},
|
||||
mutation: 'applied',
|
||||
reason: {
|
||||
code: reasonCode,
|
||||
message: readBackMatches
|
||||
? 'Grant matched every required provider read-back.'
|
||||
: 'Grant mutation completed but provider permission evidence disagreed.',
|
||||
},
|
||||
evidence: {
|
||||
...validation.evidence,
|
||||
collaboratorPermission: collaborator,
|
||||
organizationMembership,
|
||||
},
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof CredentialJournalError) {
|
||||
throw new CredentialGrantExecutionError(error.code, mutation, journal.journalId());
|
||||
}
|
||||
const reasonCode = mutation === 'applied' ? 'readback-missing' : 'mutation-state-unknown';
|
||||
try {
|
||||
await journal.seal('indeterminate', reasonCode);
|
||||
} catch (journalError: unknown) {
|
||||
if (journalError instanceof CredentialJournalError) {
|
||||
throw new CredentialGrantExecutionError(journalError.code, mutation, journal.journalId());
|
||||
}
|
||||
throw journalError;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'grant',
|
||||
outcome: 'indeterminate',
|
||||
exitCode: 30,
|
||||
retryable: false,
|
||||
subject: {
|
||||
identity: request.identity,
|
||||
estate: request.estate,
|
||||
host: request.host,
|
||||
repo: request.repo,
|
||||
},
|
||||
mutation,
|
||||
reason: {
|
||||
code: reasonCode,
|
||||
message: 'Grant mutation state was preserved after provider evidence failed.',
|
||||
},
|
||||
evidence: {
|
||||
providerIdentity: null,
|
||||
tokenCapabilities: {
|
||||
state: 'not-measured',
|
||||
scopes: [],
|
||||
source: 'runtime-not-authorized',
|
||||
},
|
||||
repositoryPermission: null,
|
||||
writeDifferential: null,
|
||||
collaboratorPermission: null,
|
||||
organizationMembership: null,
|
||||
},
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { CredentialOutcome } from './credential-result.dto.js';
|
||||
|
||||
export type CredentialLifecycleOperation =
|
||||
| 'provision'
|
||||
| 'wire'
|
||||
| 'get'
|
||||
| 'whoami'
|
||||
| 'list'
|
||||
| 'rotate'
|
||||
| 'revoke'
|
||||
| 'audit';
|
||||
|
||||
export interface TokenObjectEvidenceDto {
|
||||
readonly name: string;
|
||||
readonly scopes: readonly string[];
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export interface CredentialLifecycleResultDto {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: CredentialLifecycleOperation;
|
||||
readonly outcome: CredentialOutcome;
|
||||
readonly exitCode: 0 | 10 | 20 | 30;
|
||||
readonly retryable: boolean;
|
||||
readonly subject: {
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
readonly repo: null;
|
||||
};
|
||||
readonly mutation: 'none' | 'unknown' | 'applied';
|
||||
readonly reason: { readonly code: string; readonly message: string };
|
||||
readonly evidence: {
|
||||
readonly providerIdentity: string | null;
|
||||
readonly token: TokenObjectEvidenceDto | null;
|
||||
readonly teaLogin: {
|
||||
readonly name: string;
|
||||
readonly host: string;
|
||||
readonly state: 'registered' | 'not-measured';
|
||||
} | null;
|
||||
readonly identities: readonly string[];
|
||||
readonly journalIds: readonly string[];
|
||||
};
|
||||
readonly audit: {
|
||||
readonly journalId: string | null;
|
||||
readonly state: 'not-started' | 'open' | 'sealed';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, rm, symlink, unlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
||||
import { parseCredentialEstateRegistry } from './estate-registry.js';
|
||||
import { FileCredentialStore } from './file-credential-store.js';
|
||||
import {
|
||||
acquireCredentialLifecycleLock,
|
||||
CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL,
|
||||
credentialLifecycleLocksDirectory,
|
||||
provisionCredential,
|
||||
revokeCredential,
|
||||
type GiteaLifecycleProvider,
|
||||
} from './lifecycle.js';
|
||||
import { TeaLoginStore } from './tea-login-store.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
async function fixture(): Promise<{
|
||||
root: string;
|
||||
store: FileCredentialStore;
|
||||
teaStore: TeaLoginStore;
|
||||
}> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-lifecycle-'));
|
||||
const tokens = join(cleanup, 'tokens');
|
||||
await mkdir(tokens, { mode: 0o700 });
|
||||
const registry = parseCredentialEstateRegistry(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
readOnlyControlIdentity: 'control',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return {
|
||||
root: join(cleanup, 'state'),
|
||||
store: new FileCredentialStore(tokens, registry),
|
||||
teaStore: new TeaLoginStore(join(cleanup, 'tea', 'config.yml')),
|
||||
};
|
||||
}
|
||||
|
||||
const authority: ResolvedCredential = Object.freeze({
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
resolutionId: 'basic-authority',
|
||||
secret: new TextEncoder().encode('password-canary'),
|
||||
});
|
||||
|
||||
function provider(): GiteaLifecycleProvider {
|
||||
return {
|
||||
async readBasicIdentity() {
|
||||
return { login: 'seat', endpoint: 'GET /api/v1/user', contentType: 'application/json' };
|
||||
},
|
||||
async mintToken(_authority, _identity, name, scopes) {
|
||||
return {
|
||||
secret: new TextEncoder().encode('minted-token-canary'),
|
||||
evidence: {
|
||||
name,
|
||||
scopes,
|
||||
endpoint: 'POST /api/v1/users/seat/tokens',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
};
|
||||
},
|
||||
async readToken(_authority, _identity, name) {
|
||||
return {
|
||||
name,
|
||||
scopes: ['write:repository'],
|
||||
endpoint: 'GET /api/v1/users/seat/tokens',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async revokeToken(): Promise<void> {},
|
||||
async tokenExists(): Promise<boolean> {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('credential lifecycle', (): void => {
|
||||
it('pins lifecycle flock as cooperative serialization rather than authorization', (): void => {
|
||||
expect(CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL).toStrictEqual({
|
||||
purpose: 'cooperative-serialization',
|
||||
authorizationBoundary: 'provider-authority',
|
||||
generationPreconditions: 'optimistic-cooperating-mutators',
|
||||
hostileSameUidFilesystem: 'out-of-scope-deferred',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a concurrent same-identity lifecycle mutation across state roots', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
const lock = await acquireCredentialLifecycleLock('seat', 'homelab', 'git.example.invalid');
|
||||
try {
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'mosaic-seat-contended',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{
|
||||
stateRoot: `${root}-other`,
|
||||
actor: 'seat',
|
||||
lifecycleLock: {
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
async release(): Promise<void> {},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'refused',
|
||||
mutation: 'none',
|
||||
reason: { code: 'concurrent-mutation' },
|
||||
});
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps cooperative lifecycle locks below the validated user home, not shared tmp', (): void => {
|
||||
const locksDirectory = credentialLifecycleLocksDirectory();
|
||||
expect(locksDirectory).not.toMatch(/^\/tmp(?:\/|$)/);
|
||||
expect(locksDirectory).toMatch(/\/\.local\/state\/mosaic\/credential-lifecycle-locks$/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['../seat', 'homelab', 'git.example.invalid'],
|
||||
['seat', '../homelab', 'git.example.invalid'],
|
||||
['seat', 'homelab', '../git.example.invalid'],
|
||||
])(
|
||||
'rejects traversal before constructing a lifecycle lock path',
|
||||
async (identity, estate, host): Promise<void> => {
|
||||
await expect(acquireCredentialLifecycleLock(identity, estate, host)).rejects.toMatchObject({
|
||||
code: 'mutation-lock-unavailable',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('returns a structured error when the lifecycle lock path cannot be opened', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
const locksDirectory = credentialLifecycleLocksDirectory();
|
||||
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
|
||||
const lockPath = join(locksDirectory, 'homelab--git.example.invalid--open-failure-seat.lock');
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
await symlink('/dev/null', lockPath);
|
||||
try {
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'open-failure-seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'mosaic-open-failure-seat',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'open-failure-seat' },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
reason: { code: 'mutation-lock-unavailable' },
|
||||
});
|
||||
} finally {
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['provision', 'revoke'] as const)(
|
||||
'returns an open structured %s result when lock failure cannot be sealed',
|
||||
async (operation): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
const identity = `seal-failure-${operation}`;
|
||||
const locksDirectory = credentialLifecycleLocksDirectory();
|
||||
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
|
||||
const lockPath = join(locksDirectory, `homelab--git.example.invalid--${identity}.lock`);
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
await symlink('/dev/null', lockPath);
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockRejectedValue(
|
||||
new CredentialJournalError('journal-recovery-required', 'injected final seal failure'),
|
||||
);
|
||||
try {
|
||||
const result =
|
||||
operation === 'provision'
|
||||
? await provisionCredential(
|
||||
{
|
||||
identity,
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: `mosaic-${identity}`,
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
{ ...authority, identity },
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: identity },
|
||||
)
|
||||
: await revokeCredential(
|
||||
{ identity, estate: 'homelab', host: 'git.example.invalid' },
|
||||
{ ...authority, identity },
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: identity },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
reason: { code: 'journal-recovery-required' },
|
||||
audit: { state: 'open' },
|
||||
});
|
||||
} finally {
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('returns a sealed structured error when the Tea pre-state cannot be snapshotted', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
await teaStore.put('seat', 'git.example.invalid', new TextEncoder().encode('prior-tea-token'));
|
||||
await writeFile(join(cleanup!, 'tea', 'config.yml'), 'logins: not-an-array\n', { mode: 0o600 });
|
||||
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'mosaic-seat-snapshot-failure',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
reason: { code: 'credential-snapshot-unavailable' },
|
||||
audit: { state: 'sealed' },
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts provision only after exact principal and scope read-back', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'mosaic-seat-1',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat', now: (): string => '2026-08-05T00:00:00.000Z' },
|
||||
);
|
||||
expect(result.outcome).toBe('ok');
|
||||
await expect(
|
||||
store.readBinding('seat', 'homelab', 'git.example.invalid'),
|
||||
).resolves.toMatchObject({ providerLogin: 'seat', scopes: ['write:repository'] });
|
||||
expect(JSON.stringify(result)).not.toContain('minted-token-canary');
|
||||
});
|
||||
|
||||
it('rolls back a minted token when exact scope read-back disagrees', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
let revoked = false;
|
||||
const lifecycleProvider = provider();
|
||||
lifecycleProvider.readToken = async (_authority, _identity, name) => ({
|
||||
name,
|
||||
scopes: ['admin'],
|
||||
endpoint: 'GET /api/v1/users/seat/tokens',
|
||||
contentType: 'application/json',
|
||||
});
|
||||
lifecycleProvider.revokeToken = async (): Promise<void> => {
|
||||
revoked = true;
|
||||
};
|
||||
lifecycleProvider.tokenExists = async (): Promise<boolean> => false;
|
||||
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'mosaic-seat-bad',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
lifecycleProvider,
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('error');
|
||||
expect(result.mutation).toBe('none');
|
||||
expect(revoked).toBe(true);
|
||||
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['store', 'tea'] as const)(
|
||||
'removes %s state committed before a post-commit storage failure',
|
||||
async (target): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
if (target === 'store') {
|
||||
const put = store.put.bind(store);
|
||||
store.put = async (binding, secret): Promise<void> => {
|
||||
await put(binding, secret);
|
||||
throw new Error('injected store post-commit failure');
|
||||
};
|
||||
} else {
|
||||
const put = teaStore.put.bind(teaStore);
|
||||
teaStore.put = async (identity, host, secret): Promise<void> => {
|
||||
await put(identity, host, secret);
|
||||
throw new Error('injected Tea post-commit failure');
|
||||
};
|
||||
}
|
||||
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: `mosaic-seat-${target}-fault`,
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
|
||||
await expect(
|
||||
store.snapshot('seat', 'homelab', 'git.example.invalid'),
|
||||
).resolves.toBeUndefined();
|
||||
expect(teaStore.readBack('seat', 'git.example.invalid')).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a stale lifecycle write when a competing generation bypasses serialization', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
const put = store.put.bind(store);
|
||||
let injected = false;
|
||||
store.put = async (binding, secret, expectedGeneration): Promise<void> => {
|
||||
if (!injected) {
|
||||
injected = true;
|
||||
await put(
|
||||
{
|
||||
...binding,
|
||||
tokenName: 'competing-generation',
|
||||
createdAt: '2026-08-05T00:00:01.000Z',
|
||||
},
|
||||
new TextEncoder().encode('competing-token'),
|
||||
expectedGeneration,
|
||||
);
|
||||
}
|
||||
await put(binding, secret, expectedGeneration);
|
||||
};
|
||||
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'stale-generation',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'indeterminate',
|
||||
mutation: 'applied',
|
||||
reason: { code: 'rollback-incomplete' },
|
||||
});
|
||||
const current = await store.snapshot('seat', 'homelab', 'git.example.invalid');
|
||||
try {
|
||||
expect(current?.binding.tokenName).toBe('competing-generation');
|
||||
expect(Buffer.from(current?.secret ?? []).toString('utf8')).toBe('competing-token');
|
||||
} finally {
|
||||
current?.secret.fill(0);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['absent', 'divergent'] as const)(
|
||||
'restores an independently %s pre-operation Tea state exactly',
|
||||
async (teaState): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
await store.put(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
providerLogin: 'seat',
|
||||
tokenName: 'old-generation',
|
||||
scopes: ['write:repository'],
|
||||
createdAt: '2026-08-05T00:00:00.000Z',
|
||||
},
|
||||
new TextEncoder().encode('old-store-token'),
|
||||
);
|
||||
if (teaState === 'divergent') {
|
||||
await teaStore.put(
|
||||
'seat',
|
||||
'git.example.invalid',
|
||||
new TextEncoder().encode('divergent-tea-token'),
|
||||
);
|
||||
}
|
||||
const journal = await CredentialAuditJournal.open(root, {
|
||||
operation: 'provision',
|
||||
actor: 'seat',
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: null,
|
||||
});
|
||||
let evidenceWrites = 0;
|
||||
const recordProviderEvidence = journal.recordProviderEvidence.bind(journal);
|
||||
journal.recordProviderEvidence = async (evidence): Promise<void> => {
|
||||
evidenceWrites += 1;
|
||||
if (evidenceWrites === 2) throw new Error('injected post-registration failure');
|
||||
await recordProviderEvidence(evidence);
|
||||
};
|
||||
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'replacement-generation',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat', journal, allowReplace: true },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
|
||||
if (teaState === 'absent') {
|
||||
expect(teaStore.readBack('seat', 'git.example.invalid')).toBeUndefined();
|
||||
} else {
|
||||
expect(
|
||||
teaStore.matchesSecret(
|
||||
'seat',
|
||||
'git.example.invalid',
|
||||
new TextEncoder().encode('divergent-tea-token'),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('reports an incomplete rollback when Tea removal cannot be verified', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
const journal = await CredentialAuditJournal.open(root, {
|
||||
operation: 'provision',
|
||||
actor: 'seat',
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: null,
|
||||
});
|
||||
let evidenceWrites = 0;
|
||||
const recordProviderEvidence = journal.recordProviderEvidence.bind(journal);
|
||||
journal.recordProviderEvidence = async (evidence): Promise<void> => {
|
||||
evidenceWrites += 1;
|
||||
if (evidenceWrites === 2) throw new Error('injected post-registration failure');
|
||||
await recordProviderEvidence(evidence);
|
||||
};
|
||||
teaStore.restore = async (): Promise<void> => {
|
||||
throw new Error('injected Tea restoration failure');
|
||||
};
|
||||
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'mosaic-seat-rollback',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat', journal },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'indeterminate',
|
||||
mutation: 'applied',
|
||||
reason: { code: 'rollback-incomplete' },
|
||||
});
|
||||
expect(teaStore.readBack('seat', 'git.example.invalid')).toBeDefined();
|
||||
});
|
||||
|
||||
it('revokes at provider before removing the local binding', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'mosaic-seat-1',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
let revoked = false;
|
||||
const lifecycleProvider = provider();
|
||||
lifecycleProvider.revokeToken = async (): Promise<void> => {
|
||||
revoked = true;
|
||||
};
|
||||
const result = await revokeCredential(
|
||||
{ identity: 'seat', estate: 'homelab', host: 'git.example.invalid' },
|
||||
authority,
|
||||
lifecycleProvider,
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
expect(result.outcome).toBe('ok');
|
||||
expect(revoked).toBe(true);
|
||||
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('releases an internally owned journal lock after an append failure', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'mosaic-seat-lock-release',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
const recordProviderEvidence = CredentialAuditJournal.prototype.recordProviderEvidence;
|
||||
CredentialAuditJournal.prototype.recordProviderEvidence = async (): Promise<void> => {
|
||||
throw new CredentialJournalError('journal-unavailable', 'injected append failure');
|
||||
};
|
||||
try {
|
||||
const result = await revokeCredential(
|
||||
{ identity: 'seat', estate: 'homelab', host: 'git.example.invalid' },
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'indeterminate',
|
||||
mutation: 'none',
|
||||
audit: { state: 'open' },
|
||||
});
|
||||
if (result.audit.journalId === null) throw new Error('journal id was not returned');
|
||||
const lockPath = join(root, 'journal-locks', `${result.audit.journalId}.lock`);
|
||||
const lockProbe = spawnSync('/usr/bin/flock', ['-n', lockPath, '/bin/true']);
|
||||
expect(lockProbe.status).toBe(0);
|
||||
} finally {
|
||||
CredentialAuditJournal.prototype.recordProviderEvidence = recordProviderEvidence;
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the local recovery binding when provider revocation read-back still finds the token', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'mosaic-seat-1',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
const lifecycleProvider = provider();
|
||||
lifecycleProvider.tokenExists = async (): Promise<boolean> => true;
|
||||
const result = await revokeCredential(
|
||||
{ identity: 'seat', estate: 'homelab', host: 'git.example.invalid' },
|
||||
authority,
|
||||
lifecycleProvider,
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual(['seat']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,736 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { constants, lstatSync } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { ensureManagedDirectory } from '../fleet/secure-file.js';
|
||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
||||
import { credentialBindingGeneration, type FileCredentialStore } from './file-credential-store.js';
|
||||
import type { TeaLoginStore } from './tea-login-store.js';
|
||||
import type {
|
||||
CredentialLifecycleOperation,
|
||||
CredentialLifecycleResultDto,
|
||||
TokenObjectEvidenceDto,
|
||||
} from './lifecycle.dto.js';
|
||||
|
||||
export interface MintedToken {
|
||||
readonly secret: Uint8Array;
|
||||
readonly evidence: TokenObjectEvidenceDto;
|
||||
}
|
||||
|
||||
export interface GiteaLifecycleProvider {
|
||||
readBasicIdentity(authority: ResolvedCredential): Promise<{
|
||||
readonly login: string;
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
}>;
|
||||
mintToken(
|
||||
authority: ResolvedCredential,
|
||||
identity: string,
|
||||
name: string,
|
||||
scopes: readonly string[],
|
||||
): Promise<MintedToken>;
|
||||
readToken(
|
||||
authority: ResolvedCredential,
|
||||
identity: string,
|
||||
name: string,
|
||||
): Promise<TokenObjectEvidenceDto>;
|
||||
revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void>;
|
||||
tokenExists(authority: ResolvedCredential, identity: string, name: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface LifecycleRequest {
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
}
|
||||
|
||||
export interface ProvisionRequest extends LifecycleRequest {
|
||||
readonly tokenName: string;
|
||||
readonly scopes: readonly string[];
|
||||
}
|
||||
|
||||
export interface LifecycleOptions {
|
||||
readonly stateRoot: string;
|
||||
readonly actor: string;
|
||||
readonly now?: () => string;
|
||||
readonly allowReplace?: boolean;
|
||||
readonly journal?: CredentialAuditJournal;
|
||||
readonly deferSuccessSeal?: boolean;
|
||||
readonly lifecycleLock?: CredentialLifecycleLock;
|
||||
readonly expectedStoreGeneration?: string | null;
|
||||
readonly expectedTeaGeneration?: string | null;
|
||||
}
|
||||
|
||||
export interface CredentialLifecycleLock {
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
release(): Promise<void>;
|
||||
}
|
||||
|
||||
const activeLifecycleLocks = new WeakSet<CredentialLifecycleLock>();
|
||||
|
||||
/**
|
||||
* This flock coordinates cooperating `mosaic cred` processes only. A process
|
||||
* sharing the Unix uid can replace the pathname/inode, so the lock is never
|
||||
* authorization evidence. Provider authority is the authorization boundary;
|
||||
* generation preconditions are optimistic concurrency for cooperating store
|
||||
* mutators, not atomic CAS against a hostile same-uid filesystem writer.
|
||||
*/
|
||||
export const CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL = Object.freeze({
|
||||
purpose: 'cooperative-serialization',
|
||||
authorizationBoundary: 'provider-authority',
|
||||
generationPreconditions: 'optimistic-cooperating-mutators',
|
||||
hostileSameUidFilesystem: 'out-of-scope-deferred',
|
||||
} as const);
|
||||
|
||||
export class CredentialLifecycleLockError extends Error {
|
||||
constructor(public readonly code: 'concurrent-mutation' | 'mutation-lock-unavailable') {
|
||||
super(code);
|
||||
this.name = 'CredentialLifecycleLockError';
|
||||
}
|
||||
}
|
||||
|
||||
// Do not promote this advisory lock into a same-uid authorization boundary;
|
||||
// CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL is a tested public invariant.
|
||||
export function credentialLifecycleLocksDirectory(): string {
|
||||
return join(homedir(), '.local', 'state', 'mosaic', 'credential-lifecycle-locks');
|
||||
}
|
||||
|
||||
export async function acquireCredentialLifecycleLock(
|
||||
identity: string,
|
||||
estate: string,
|
||||
host: string,
|
||||
): Promise<CredentialLifecycleLock> {
|
||||
if (
|
||||
!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(identity) ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(estate) ||
|
||||
!/^[a-z0-9][a-z0-9.-]*$/.test(host)
|
||||
) {
|
||||
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
||||
}
|
||||
const uid = process.getuid?.();
|
||||
if (uid === undefined) throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
||||
const home = homedir();
|
||||
const homeDirectory = lstatSync(home);
|
||||
if (
|
||||
!homeDirectory.isDirectory() ||
|
||||
homeDirectory.isSymbolicLink() ||
|
||||
homeDirectory.uid !== uid ||
|
||||
(homeDirectory.mode & 0o022) !== 0
|
||||
) {
|
||||
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
||||
}
|
||||
const locksDirectory = credentialLifecycleLocksDirectory();
|
||||
ensureManagedDirectory(home, locksDirectory);
|
||||
const directory = lstatSync(locksDirectory);
|
||||
if (
|
||||
!directory.isDirectory() ||
|
||||
directory.isSymbolicLink() ||
|
||||
directory.uid !== uid ||
|
||||
(directory.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
||||
}
|
||||
const lockPath = join(locksDirectory, `${estate}--${host}--${identity}.lock`);
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(
|
||||
lockPath,
|
||||
constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
const file = await handle.stat();
|
||||
if (!file.isFile() || file.uid !== uid || (file.mode & 0o077) !== 0) {
|
||||
throw new Error('credential lifecycle lock file is unsafe');
|
||||
}
|
||||
} catch {
|
||||
await handle?.close().catch((): void => undefined);
|
||||
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
||||
}
|
||||
if (handle === undefined) throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
||||
const acquired = spawnSync('/usr/bin/flock', ['-n', '3'], {
|
||||
stdio: ['ignore', 'ignore', 'ignore', handle.fd],
|
||||
});
|
||||
if (acquired.error !== undefined || acquired.status !== 0) {
|
||||
await handle.close().catch((): void => undefined);
|
||||
throw new CredentialLifecycleLockError(
|
||||
acquired.status === 1 ? 'concurrent-mutation' : 'mutation-lock-unavailable',
|
||||
);
|
||||
}
|
||||
let released = false;
|
||||
const lock: CredentialLifecycleLock = {
|
||||
identity,
|
||||
estate,
|
||||
host,
|
||||
async release(): Promise<void> {
|
||||
if (released) return;
|
||||
released = true;
|
||||
activeLifecycleLocks.delete(lock);
|
||||
await handle.close();
|
||||
},
|
||||
};
|
||||
activeLifecycleLocks.add(lock);
|
||||
return lock;
|
||||
}
|
||||
|
||||
function holdsCredentialLifecycleLock(
|
||||
lock: CredentialLifecycleLock | undefined,
|
||||
identity: string,
|
||||
estate: string,
|
||||
host: string,
|
||||
): boolean {
|
||||
return (
|
||||
lock !== undefined &&
|
||||
activeLifecycleLocks.has(lock) &&
|
||||
lock.identity === identity &&
|
||||
lock.estate === estate &&
|
||||
lock.host === host
|
||||
);
|
||||
}
|
||||
|
||||
function lifecycleResult(
|
||||
operation: CredentialLifecycleOperation,
|
||||
request: LifecycleRequest,
|
||||
options: {
|
||||
readonly outcome: CredentialLifecycleResultDto['outcome'];
|
||||
readonly mutation: CredentialLifecycleResultDto['mutation'];
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly journalId: string | null;
|
||||
readonly auditState: 'not-started' | 'open' | 'sealed';
|
||||
readonly providerIdentity?: string | null;
|
||||
readonly token?: TokenObjectEvidenceDto | null;
|
||||
readonly teaLogin?: CredentialLifecycleResultDto['evidence']['teaLogin'];
|
||||
},
|
||||
): CredentialLifecycleResultDto {
|
||||
const exits = { ok: 0, refused: 10, error: 20, indeterminate: 30 } as const;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
outcome: options.outcome,
|
||||
exitCode: exits[options.outcome],
|
||||
retryable: false,
|
||||
subject: { ...request, repo: null },
|
||||
mutation: options.mutation,
|
||||
reason: { code: options.code, message: options.message },
|
||||
evidence: {
|
||||
providerIdentity: options.providerIdentity ?? null,
|
||||
token: options.token ?? null,
|
||||
teaLogin: options.teaLogin ?? null,
|
||||
identities: [],
|
||||
journalIds: [],
|
||||
},
|
||||
audit: { journalId: options.journalId, state: options.auditState },
|
||||
};
|
||||
}
|
||||
|
||||
async function lifecycleLockFailureResult(
|
||||
operation: 'provision' | 'revoke',
|
||||
request: LifecycleRequest,
|
||||
journal: CredentialAuditJournal,
|
||||
code: 'concurrent-mutation' | 'mutation-lock-unavailable',
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
try {
|
||||
await journal.seal(code === 'concurrent-mutation' ? 'refused' : 'error', code);
|
||||
return lifecycleResult(operation, request, {
|
||||
outcome: code === 'concurrent-mutation' ? 'refused' : 'error',
|
||||
mutation: 'none',
|
||||
code,
|
||||
message: 'Credential lifecycle mutation lock could not be acquired.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
} catch (sealError: unknown) {
|
||||
await journal.closeIncomplete().catch((): void => undefined);
|
||||
return lifecycleResult(operation, request, {
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
|
||||
message: 'Mutation lock failure could not be sealed durably.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'open',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function openLifecycleJournal(
|
||||
operation: 'provision' | 'rotate' | 'revoke',
|
||||
request: LifecycleRequest,
|
||||
options: LifecycleOptions,
|
||||
): Promise<CredentialAuditJournal> {
|
||||
const journal = await CredentialAuditJournal.open(options.stateRoot, {
|
||||
operation,
|
||||
actor: options.actor,
|
||||
identity: request.identity,
|
||||
estate: request.estate,
|
||||
host: request.host,
|
||||
repo: null,
|
||||
});
|
||||
try {
|
||||
await journal.recordIntent(`${operation}-requested`);
|
||||
return journal;
|
||||
} catch (error: unknown) {
|
||||
await journal.closeIncomplete().catch((): void => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function provisionCredential(
|
||||
request: ProvisionRequest,
|
||||
authority: ResolvedCredential,
|
||||
provider: GiteaLifecycleProvider,
|
||||
store: FileCredentialStore,
|
||||
teaStore: TeaLoginStore,
|
||||
options: LifecycleOptions,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
const ownsJournal = options.journal === undefined;
|
||||
const journal = options.journal ?? (await openLifecycleJournal('provision', request, options));
|
||||
let ownedLock: CredentialLifecycleLock | undefined;
|
||||
if (
|
||||
!holdsCredentialLifecycleLock(
|
||||
options.lifecycleLock,
|
||||
request.identity,
|
||||
request.estate,
|
||||
request.host,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
ownedLock = await acquireCredentialLifecycleLock(
|
||||
request.identity,
|
||||
request.estate,
|
||||
request.host,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const code =
|
||||
error instanceof CredentialLifecycleLockError ? error.code : 'mutation-lock-unavailable';
|
||||
return lifecycleLockFailureResult('provision', request, journal, code);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await provisionCredentialLocked(
|
||||
request,
|
||||
authority,
|
||||
provider,
|
||||
store,
|
||||
teaStore,
|
||||
options,
|
||||
journal,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (ownsJournal) await journal.closeIncomplete().catch((): void => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
await ownedLock?.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function provisionCredentialLocked(
|
||||
request: ProvisionRequest,
|
||||
authority: ResolvedCredential,
|
||||
provider: GiteaLifecycleProvider,
|
||||
store: FileCredentialStore,
|
||||
teaStore: TeaLoginStore,
|
||||
options: LifecycleOptions,
|
||||
journal: CredentialAuditJournal,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||
let minted: MintedToken | undefined;
|
||||
let failureCode = 'mutation-state-unknown';
|
||||
let mintedStoreGeneration: string | undefined;
|
||||
let prior: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
|
||||
let priorTea: ReturnType<TeaLoginStore['snapshot']> = undefined;
|
||||
try {
|
||||
prior = await store.snapshot(request.identity, request.estate, request.host);
|
||||
priorTea = teaStore.snapshot(request.identity, request.host);
|
||||
} catch {
|
||||
prior?.secret.fill(0);
|
||||
priorTea?.secret.fill(0);
|
||||
try {
|
||||
await journal.seal('error', 'credential-snapshot-unavailable');
|
||||
return lifecycleResult('provision', request, {
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
code: 'credential-snapshot-unavailable',
|
||||
message: 'Pre-operation credential state could not be snapshotted safely.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
} catch (sealError: unknown) {
|
||||
await journal.closeIncomplete().catch((): void => undefined);
|
||||
return lifecycleResult('provision', request, {
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
|
||||
message: 'Credential snapshot failure could not be sealed durably.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'open',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
(options.expectedStoreGeneration !== undefined &&
|
||||
(prior?.generation ?? null) !== options.expectedStoreGeneration) ||
|
||||
(options.expectedTeaGeneration !== undefined &&
|
||||
(priorTea?.generation ?? null) !== options.expectedTeaGeneration)
|
||||
) {
|
||||
prior?.secret.fill(0);
|
||||
priorTea?.secret.fill(0);
|
||||
await journal.seal('refused', 'concurrent-mutation');
|
||||
return lifecycleResult('provision', request, {
|
||||
outcome: 'refused',
|
||||
mutation: 'none',
|
||||
code: 'concurrent-mutation',
|
||||
message: 'Credential generation changed before the lifecycle transaction began.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
}
|
||||
if (prior !== undefined && options.allowReplace !== true) {
|
||||
try {
|
||||
await journal.seal('refused', 'credential-already-exists');
|
||||
return lifecycleResult('provision', request, {
|
||||
outcome: 'refused',
|
||||
mutation: 'none',
|
||||
code: 'credential-already-exists',
|
||||
message: 'A governed credential already exists; use rotate.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
} finally {
|
||||
prior.secret.fill(0);
|
||||
priorTea?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const identity = await provider.readBasicIdentity(authority);
|
||||
if (identity.login !== request.identity || authority.identity !== request.identity) {
|
||||
await journal.seal('refused', 'provider-identity-mismatch');
|
||||
return lifecycleResult('provision', request, {
|
||||
outcome: 'refused',
|
||||
mutation: 'none',
|
||||
code: 'provider-identity-mismatch',
|
||||
message: 'Delegated Basic authority did not bind the requested principal.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
providerIdentity: identity.login,
|
||||
});
|
||||
}
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: identity.endpoint,
|
||||
contentType: identity.contentType,
|
||||
decision: 'identity-verified',
|
||||
});
|
||||
mutation = 'unknown';
|
||||
minted = await provider.mintToken(
|
||||
authority,
|
||||
request.identity,
|
||||
request.tokenName,
|
||||
request.scopes,
|
||||
);
|
||||
mutation = 'applied';
|
||||
await journal.recordMutation('token-mint-applied');
|
||||
const readBack = await provider.readToken(authority, request.identity, request.tokenName);
|
||||
const expected = [...request.scopes].sort();
|
||||
const actual = [...readBack.scopes].sort();
|
||||
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
|
||||
failureCode = 'scope-not-evaluable';
|
||||
throw new Error('scope read-back disagreed');
|
||||
}
|
||||
const mintedBinding = {
|
||||
identity: request.identity,
|
||||
estate: request.estate,
|
||||
host: request.host,
|
||||
providerLogin: identity.login,
|
||||
tokenName: request.tokenName,
|
||||
scopes: readBack.scopes,
|
||||
createdAt: options.now?.() ?? new Date().toISOString(),
|
||||
};
|
||||
mintedStoreGeneration = credentialBindingGeneration(mintedBinding, minted.secret);
|
||||
await store.put(mintedBinding, minted.secret, prior?.generation ?? null);
|
||||
await journal.recordMutation('token-binding-stored');
|
||||
await teaStore.put(request.identity, request.host, minted.secret, priorTea?.generation ?? null);
|
||||
const teaLogin = teaStore.readBack(request.identity, request.host);
|
||||
if (
|
||||
teaLogin === undefined ||
|
||||
!teaStore.matchesSecret(request.identity, request.host, minted.secret)
|
||||
) {
|
||||
failureCode = 'tea-login-missing';
|
||||
throw new Error('Tea login did not resolve exactly');
|
||||
}
|
||||
await journal.recordMutation('tea-login-stored');
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: readBack.endpoint,
|
||||
contentType: readBack.contentType,
|
||||
decision: 'scope-verified',
|
||||
});
|
||||
if (options.deferSuccessSeal !== true) {
|
||||
await journal.seal('ok', 'provision-verified');
|
||||
}
|
||||
return lifecycleResult('provision', request, {
|
||||
outcome: 'ok',
|
||||
mutation: 'applied',
|
||||
code: options.deferSuccessSeal === true ? 'replacement-staged' : 'provision-verified',
|
||||
message:
|
||||
options.deferSuccessSeal === true
|
||||
? 'Replacement was read back and staged under the open rotation transaction.'
|
||||
: 'Provider principal and exact token scopes were read back and stored.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: options.deferSuccessSeal === true ? 'open' : 'sealed',
|
||||
providerIdentity: identity.login,
|
||||
token: readBack,
|
||||
teaLogin: { ...teaLogin, state: 'registered' },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const journalFailure = error instanceof CredentialJournalError;
|
||||
if (minted === undefined) {
|
||||
if (journalFailure) throw error;
|
||||
await journal.seal('indeterminate', 'provider-unavailable');
|
||||
return lifecycleResult('provision', request, {
|
||||
outcome: 'indeterminate',
|
||||
mutation,
|
||||
code: 'provider-unavailable',
|
||||
message: 'Provider token mint did not complete.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
}
|
||||
let rollbackComplete = false;
|
||||
try {
|
||||
await provider.revokeToken(authority, request.identity, request.tokenName);
|
||||
if (await provider.tokenExists(authority, request.identity, request.tokenName)) {
|
||||
throw new Error('minted token still exists after rollback');
|
||||
}
|
||||
const expectedStoreGeneration = prior?.generation ?? null;
|
||||
const current = await store.snapshot(request.identity, request.estate, request.host);
|
||||
try {
|
||||
if ((current?.generation ?? null) !== expectedStoreGeneration) {
|
||||
if (
|
||||
mintedStoreGeneration === undefined ||
|
||||
current?.generation !== mintedStoreGeneration
|
||||
) {
|
||||
throw new Error('credential generation changed during rollback');
|
||||
}
|
||||
if (prior === undefined) {
|
||||
await store.remove(
|
||||
request.identity,
|
||||
request.estate,
|
||||
request.host,
|
||||
mintedStoreGeneration,
|
||||
);
|
||||
} else {
|
||||
await store.put(prior.binding, prior.secret, mintedStoreGeneration);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
current?.secret.fill(0);
|
||||
}
|
||||
const expectedTeaGeneration = priorTea?.generation ?? null;
|
||||
const currentTea = teaStore.snapshot(request.identity, request.host);
|
||||
try {
|
||||
if ((currentTea?.generation ?? null) !== expectedTeaGeneration) {
|
||||
if (
|
||||
currentTea === undefined ||
|
||||
currentTea.secret.byteLength !== minted.secret.byteLength ||
|
||||
!timingSafeEqual(Buffer.from(currentTea.secret), Buffer.from(minted.secret))
|
||||
) {
|
||||
throw new Error('Tea login generation changed during rollback');
|
||||
}
|
||||
await teaStore.restore(request.identity, request.host, priorTea, currentTea.generation);
|
||||
}
|
||||
} finally {
|
||||
currentTea?.secret.fill(0);
|
||||
}
|
||||
const restored = await store.snapshot(request.identity, request.estate, request.host);
|
||||
try {
|
||||
const storeRestored =
|
||||
prior === undefined
|
||||
? restored === undefined
|
||||
: restored !== undefined &&
|
||||
JSON.stringify(restored.binding) === JSON.stringify(prior.binding) &&
|
||||
restored.secret.byteLength === prior.secret.byteLength &&
|
||||
timingSafeEqual(Buffer.from(restored.secret), Buffer.from(prior.secret));
|
||||
if (!storeRestored || !teaStore.matchesSnapshot(request.identity, request.host, priorTea)) {
|
||||
throw new Error('pre-operation credential state was not restored exactly');
|
||||
}
|
||||
} finally {
|
||||
restored?.secret.fill(0);
|
||||
}
|
||||
rollbackComplete = true;
|
||||
} catch {
|
||||
rollbackComplete = false;
|
||||
} finally {
|
||||
prior?.secret.fill(0);
|
||||
priorTea?.secret.fill(0);
|
||||
}
|
||||
if (journalFailure) {
|
||||
if (rollbackComplete) {
|
||||
await journal.recordMutation('provision-rollback-verified').catch((): void => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const code = rollbackComplete ? failureCode : 'rollback-incomplete';
|
||||
if (rollbackComplete) await journal.recordMutation('provision-rollback-verified');
|
||||
await journal.seal(rollbackComplete ? 'error' : 'indeterminate', code);
|
||||
return lifecycleResult('provision', request, {
|
||||
outcome: rollbackComplete ? 'error' : 'indeterminate',
|
||||
mutation: rollbackComplete ? 'none' : mutation,
|
||||
code,
|
||||
message: rollbackComplete
|
||||
? 'Provisioning failed and every completed mutation was rolled back.'
|
||||
: 'Provisioning failed and rollback could not be proven complete.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
} finally {
|
||||
minted?.secret.fill(0);
|
||||
prior?.secret.fill(0);
|
||||
priorTea?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeCredential(
|
||||
request: LifecycleRequest,
|
||||
authority: ResolvedCredential,
|
||||
provider: GiteaLifecycleProvider,
|
||||
store: FileCredentialStore,
|
||||
teaStore: TeaLoginStore,
|
||||
options: LifecycleOptions,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
const journal = await openLifecycleJournal('revoke', request, options);
|
||||
let ownedLock: CredentialLifecycleLock | undefined;
|
||||
if (
|
||||
!holdsCredentialLifecycleLock(
|
||||
options.lifecycleLock,
|
||||
request.identity,
|
||||
request.estate,
|
||||
request.host,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
ownedLock = await acquireCredentialLifecycleLock(
|
||||
request.identity,
|
||||
request.estate,
|
||||
request.host,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const code =
|
||||
error instanceof CredentialLifecycleLockError ? error.code : 'mutation-lock-unavailable';
|
||||
return lifecycleLockFailureResult('revoke', request, journal, code);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await revokeCredentialLocked(request, authority, provider, store, teaStore, journal);
|
||||
} finally {
|
||||
await ownedLock?.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeCredentialLocked(
|
||||
request: LifecycleRequest,
|
||||
authority: ResolvedCredential,
|
||||
provider: GiteaLifecycleProvider,
|
||||
store: FileCredentialStore,
|
||||
teaStore: TeaLoginStore,
|
||||
journal: CredentialAuditJournal,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||
let credentialSnapshot: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
|
||||
let teaSnapshot: ReturnType<TeaLoginStore['snapshot']> = undefined;
|
||||
try {
|
||||
credentialSnapshot = await store.snapshot(request.identity, request.estate, request.host);
|
||||
if (credentialSnapshot === undefined) {
|
||||
await journal.seal('refused', 'no-token-for-identity');
|
||||
return lifecycleResult('revoke', request, {
|
||||
outcome: 'refused',
|
||||
mutation: 'none',
|
||||
code: 'no-token-for-identity',
|
||||
message: 'No governed token binding exists for the identity.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
}
|
||||
const binding = credentialSnapshot.binding;
|
||||
teaSnapshot = teaStore.snapshot(request.identity, request.host);
|
||||
const identity = await provider.readBasicIdentity(authority);
|
||||
if (identity.login !== request.identity || authority.identity !== request.identity) {
|
||||
await journal.seal('refused', 'provider-identity-mismatch');
|
||||
return lifecycleResult('revoke', request, {
|
||||
outcome: 'refused',
|
||||
mutation: 'none',
|
||||
code: 'provider-identity-mismatch',
|
||||
message: 'Delegated Basic authority did not bind the requested principal.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
providerIdentity: identity.login,
|
||||
});
|
||||
}
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: identity.endpoint,
|
||||
contentType: identity.contentType,
|
||||
decision: 'identity-verified',
|
||||
});
|
||||
mutation = 'unknown';
|
||||
await provider.revokeToken(authority, request.identity, binding.tokenName);
|
||||
mutation = 'applied';
|
||||
await journal.recordMutation('token-revoke-applied');
|
||||
if (await provider.tokenExists(authority, request.identity, binding.tokenName)) {
|
||||
await journal.seal('indeterminate', 'revoke-readback-missing');
|
||||
return lifecycleResult('revoke', request, {
|
||||
outcome: 'indeterminate',
|
||||
mutation,
|
||||
code: 'revoke-readback-missing',
|
||||
message: 'Provider still returned the token after revocation acknowledgement.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
}
|
||||
await teaStore.remove(request.identity, request.host, teaSnapshot?.generation ?? null);
|
||||
if (teaStore.readBack(request.identity, request.host) !== undefined) {
|
||||
throw new Error('Tea login still exists after revocation');
|
||||
}
|
||||
await journal.recordMutation('tea-login-removed');
|
||||
await store.remove(
|
||||
request.identity,
|
||||
request.estate,
|
||||
request.host,
|
||||
credentialSnapshot.generation,
|
||||
);
|
||||
await journal.seal('ok', 'revoke-verified');
|
||||
return lifecycleResult('revoke', request, {
|
||||
outcome: 'ok',
|
||||
mutation,
|
||||
code: 'revoke-verified',
|
||||
message: 'Provider token revocation completed before local binding removal.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof CredentialJournalError) {
|
||||
await journal.closeIncomplete().catch((): void => undefined);
|
||||
return lifecycleResult('revoke', request, {
|
||||
outcome: 'indeterminate',
|
||||
mutation,
|
||||
code: error.code,
|
||||
message: 'Audit persistence failed; inspect the durable open journal before recovery.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'open',
|
||||
});
|
||||
}
|
||||
await journal.seal('indeterminate', 'mutation-state-unknown');
|
||||
return lifecycleResult('revoke', request, {
|
||||
outcome: 'indeterminate',
|
||||
mutation,
|
||||
code: 'mutation-state-unknown',
|
||||
message: 'Revocation mutation state could not be established completely.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
} finally {
|
||||
credentialSnapshot?.secret.fill(0);
|
||||
teaSnapshot?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { TeaLoginStore, TeaLoginStoreError } from './tea-login-store.js';
|
||||
|
||||
let root: string | undefined;
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true });
|
||||
root = undefined;
|
||||
});
|
||||
|
||||
describe('host-bound Tea login store', (): void => {
|
||||
it('coordinates concurrent cooperating updates and preserves one identity on two hosts', async (): Promise<void> => {
|
||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
||||
const store = new TeaLoginStore(join(root, 'tea', 'config.yml'));
|
||||
await Promise.all([
|
||||
store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
|
||||
store.put('seat', 'git.two.invalid', new TextEncoder().encode('token-two')),
|
||||
]);
|
||||
|
||||
expect(
|
||||
store.matchesSecret('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
store.matchesSecret('seat', 'git.two.invalid', new TextEncoder().encode('token-two')),
|
||||
).toBe(true);
|
||||
await store.remove('seat', 'git.one.invalid');
|
||||
expect(store.readBack('seat', 'git.one.invalid')).toBeUndefined();
|
||||
expect(store.readBack('seat', 'git.two.invalid')).toEqual({
|
||||
name: 'seat--git.two.invalid',
|
||||
host: 'git.two.invalid',
|
||||
});
|
||||
});
|
||||
|
||||
it('snapshots and restores the exact host-bound Tea record fields', async (): Promise<void> => {
|
||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
||||
const configPath = join(root, 'tea', 'config.yml');
|
||||
const store = new TeaLoginStore(configPath);
|
||||
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('original-token'));
|
||||
const customized = (await readFile(configPath, 'utf8')).replace(
|
||||
'default: false',
|
||||
'default: true\n extension-field: preserved',
|
||||
);
|
||||
await writeFile(configPath, customized, { mode: 0o600 });
|
||||
const snapshot = store.snapshot('seat', 'git.one.invalid');
|
||||
expect(snapshot).toBeDefined();
|
||||
|
||||
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('replacement-token'));
|
||||
await store.restore('seat', 'git.one.invalid', snapshot);
|
||||
|
||||
expect(store.matchesSnapshot('seat', 'git.one.invalid', snapshot)).toBe(true);
|
||||
expect(await readFile(configPath, 'utf8')).toContain('extension-field: preserved');
|
||||
snapshot?.secret.fill(0);
|
||||
});
|
||||
|
||||
it('keeps generation stable across recursive metadata key reordering', async (): Promise<void> => {
|
||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
||||
const configPath = join(root, 'tea', 'config.yml');
|
||||
const store = new TeaLoginStore(configPath);
|
||||
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
|
||||
const baseline = await readFile(configPath, 'utf8');
|
||||
await writeFile(
|
||||
configPath,
|
||||
baseline.replace(
|
||||
'default: false',
|
||||
'default: false\n extension:\n zebra: last\n __proto__: one\n nested:\n second: 2\n first: 1',
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const first = store.snapshot('seat', 'git.one.invalid');
|
||||
await writeFile(
|
||||
configPath,
|
||||
baseline.replace(
|
||||
'default: false',
|
||||
'extension:\n nested:\n first: 1\n second: 2\n __proto__: one\n zebra: last\n default: false',
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const reordered = store.snapshot('seat', 'git.one.invalid');
|
||||
await writeFile(
|
||||
configPath,
|
||||
baseline.replace(
|
||||
'default: false',
|
||||
'extension:\n nested:\n first: 9\n second: 2\n zebra: last\n default: false',
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const changed = store.snapshot('seat', 'git.one.invalid');
|
||||
await writeFile(
|
||||
configPath,
|
||||
baseline.replace(
|
||||
'default: false',
|
||||
'extension:\n nested:\n first: 1\n second: 2\n __proto__: two\n zebra: last\n default: false',
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const prototypeNamedFieldChanged = store.snapshot('seat', 'git.one.invalid');
|
||||
|
||||
expect(reordered?.generation).toBe(first?.generation);
|
||||
expect(changed?.generation).not.toBe(first?.generation);
|
||||
expect(prototypeNamedFieldChanged?.generation).not.toBe(first?.generation);
|
||||
first?.secret.fill(0);
|
||||
reordered?.secret.fill(0);
|
||||
changed?.secret.fill(0);
|
||||
prototypeNamedFieldChanged?.secret.fill(0);
|
||||
});
|
||||
|
||||
it.each(['.nan', '.inf', '-.inf'] as const)(
|
||||
'rejects non-finite YAML metadata value %s while accepting explicit null',
|
||||
async (nonFiniteValue): Promise<void> => {
|
||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
||||
const configPath = join(root, 'tea', 'config.yml');
|
||||
const store = new TeaLoginStore(configPath);
|
||||
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
|
||||
const baseline = await readFile(configPath, 'utf8');
|
||||
await writeFile(
|
||||
configPath,
|
||||
baseline.replace(
|
||||
'default: false',
|
||||
`default: false\n extension-field: ${nonFiniteValue}`,
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
expect(() => store.snapshot('seat', 'git.one.invalid')).toThrowError(TeaLoginStoreError);
|
||||
expect(() => store.snapshot('seat', 'git.one.invalid')).toThrow(/code=tea-config-invalid/);
|
||||
|
||||
await writeFile(
|
||||
configPath,
|
||||
baseline.replace('default: false', 'default: false\n extension-field: null'),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const explicitNull = store.snapshot('seat', 'git.one.invalid');
|
||||
expect(explicitNull).toBeDefined();
|
||||
explicitNull?.secret.fill(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['put', 'remove'] as const)(
|
||||
'removes secret-bearing temporary files when %s fails before rename',
|
||||
async (operation): Promise<void> => {
|
||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
||||
const configPath = join(root, 'tea', 'config.yml');
|
||||
const baseline = new TeaLoginStore(configPath);
|
||||
if (operation === 'remove') {
|
||||
await baseline.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
|
||||
}
|
||||
const store = new TeaLoginStore(configPath, {
|
||||
beforeRename: async (candidate): Promise<void> => {
|
||||
if (candidate === operation) throw new Error('injected pre-rename failure');
|
||||
},
|
||||
});
|
||||
|
||||
if (operation === 'put') {
|
||||
await expect(
|
||||
store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
|
||||
).rejects.toThrow('injected pre-rename failure');
|
||||
} else {
|
||||
await expect(store.remove('seat', 'git.one.invalid')).rejects.toThrow(
|
||||
'injected pre-rename failure',
|
||||
);
|
||||
}
|
||||
|
||||
expect((await readdir(join(root, 'tea'))).filter((name) => name.endsWith('.tmp'))).toEqual(
|
||||
[],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves unrelated Tea configuration and rejects permissive secret reads', async (): Promise<void> => {
|
||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
||||
const configPath = join(root, 'tea', 'config.yml');
|
||||
const store = new TeaLoginStore(configPath);
|
||||
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
|
||||
const original = await readFile(configPath, 'utf8');
|
||||
await writeFile(configPath, `preferences:\n color: true\n${original}`, { mode: 0o600 });
|
||||
|
||||
await store.put('seat', 'git.two.invalid', new TextEncoder().encode('token-two'));
|
||||
await store.remove('seat', 'git.one.invalid');
|
||||
expect(await readFile(configPath, 'utf8')).toContain('color: true');
|
||||
|
||||
await chmod(configPath, 0o644);
|
||||
expect(() => store.resolve('seat', 'homelab', 'git.two.invalid')).toThrow(
|
||||
/tea-config-insecure/,
|
||||
);
|
||||
expect(() => store.readBack('seat', 'git.two.invalid')).toThrow(/tea-config-insecure/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,468 @@
|
||||
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { open, rename, unlink } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { parse, stringify } from 'yaml';
|
||||
import { z } from 'zod';
|
||||
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
|
||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
||||
|
||||
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
|
||||
export class TeaLoginStoreError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(`Tea login store rejected: code=${code} ${message}`);
|
||||
this.name = 'TeaLoginStoreError';
|
||||
}
|
||||
}
|
||||
|
||||
interface TeaLoginRecord {
|
||||
readonly name: string;
|
||||
readonly url: string;
|
||||
readonly token: string;
|
||||
readonly user: string;
|
||||
readonly default: boolean;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TeaConfig {
|
||||
readonly logins: TeaLoginRecord[];
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
const loginSchema = z
|
||||
.object({
|
||||
name: z.string().regex(SAFE_NAME),
|
||||
url: z.string().url(),
|
||||
token: z.string().min(1),
|
||||
user: z.string().regex(SAFE_NAME),
|
||||
default: z.boolean().default(false),
|
||||
})
|
||||
.passthrough();
|
||||
const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).passthrough();
|
||||
|
||||
async function syncDirectory(path: string): Promise<void> {
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open>>> {
|
||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||
try {
|
||||
return await open(path, 'wx', 0o600);
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error;
|
||||
await new Promise<void>((resolve): void => {
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
}
|
||||
}
|
||||
throw new TeaLoginStoreError(
|
||||
'conflicting-credential-mutation',
|
||||
'Tea configuration lock did not become available',
|
||||
);
|
||||
}
|
||||
|
||||
function loginName(identity: string, host: string): string {
|
||||
return `${identity}--${host}`;
|
||||
}
|
||||
|
||||
function assertPrivate(snapshot: { readonly mode: number; readonly uid: number }): void {
|
||||
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
|
||||
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
|
||||
}
|
||||
}
|
||||
|
||||
function missing(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
export interface TeaLoginSnapshot {
|
||||
readonly fields: Readonly<Record<string, unknown>>;
|
||||
readonly secret: Uint8Array;
|
||||
readonly generation: string;
|
||||
}
|
||||
|
||||
type CanonicalJsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| CanonicalJsonValue[]
|
||||
| CanonicalJsonObject;
|
||||
|
||||
interface CanonicalJsonObject {
|
||||
[key: string]: CanonicalJsonValue;
|
||||
}
|
||||
|
||||
function canonicalizeGenerationValue(value: unknown): CanonicalJsonValue {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new TeaLoginStoreError(
|
||||
'tea-config-invalid',
|
||||
'Tea login metadata contains a non-finite number',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry: unknown): CanonicalJsonValue => canonicalizeGenerationValue(entry));
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const entries = Object.entries(value)
|
||||
.sort(([left], [right]): number => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.flatMap(([key, entry]): [string, CanonicalJsonValue][] =>
|
||||
entry === undefined ? [] : [[key, canonicalizeGenerationValue(entry)]],
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
throw new TeaLoginStoreError(
|
||||
'tea-config-invalid',
|
||||
'Tea login metadata is outside canonical JSON values',
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalGenerationJson(value: object): string {
|
||||
return JSON.stringify(canonicalizeGenerationValue(value));
|
||||
}
|
||||
|
||||
function snapshotFromConfig(
|
||||
config: TeaConfig,
|
||||
identity: string,
|
||||
host: string,
|
||||
): TeaLoginSnapshot | undefined {
|
||||
const matches = config.logins.filter(
|
||||
(login): boolean => login.name === loginName(identity, host) && login.url === `https://${host}`,
|
||||
);
|
||||
if (matches.length === 0) return undefined;
|
||||
if (matches.length !== 1 || matches[0] === undefined) {
|
||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea login binding is ambiguous');
|
||||
}
|
||||
const { token, ...fields } = matches[0];
|
||||
const secret = new TextEncoder().encode(token);
|
||||
const generation = createHash('sha256')
|
||||
.update(canonicalGenerationJson(fields))
|
||||
.update('\0')
|
||||
.update(secret)
|
||||
.digest('hex');
|
||||
return { fields: structuredClone(fields), secret, generation };
|
||||
}
|
||||
|
||||
function assertExpectedGeneration(
|
||||
config: TeaConfig,
|
||||
identity: string,
|
||||
host: string,
|
||||
expectedGeneration: string | null | undefined,
|
||||
): void {
|
||||
if (expectedGeneration === undefined) return;
|
||||
const current = snapshotFromConfig(config, identity, host);
|
||||
try {
|
||||
if ((current?.generation ?? null) !== expectedGeneration) {
|
||||
throw new TeaLoginStoreError(
|
||||
'tea-generation-mismatch',
|
||||
'Tea login generation changed before mutation',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
current?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export interface TeaLoginStoreRuntimeOptions {
|
||||
readonly beforeRename?: (
|
||||
operation: 'put' | 'remove' | 'restore',
|
||||
tempPath: string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export class TeaLoginStore {
|
||||
constructor(
|
||||
private readonly configPath: string,
|
||||
private readonly runtime: TeaLoginStoreRuntimeOptions = {},
|
||||
) {}
|
||||
|
||||
private async commit(
|
||||
operation: 'put' | 'remove' | 'restore',
|
||||
config: TeaConfig,
|
||||
directory: string,
|
||||
): Promise<void> {
|
||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
const handle = await open(temp, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(stringify(config), 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await this.runtime.beforeRename?.(operation, temp);
|
||||
await rename(temp, this.configPath);
|
||||
await syncDirectory(directory);
|
||||
} finally {
|
||||
await unlink(temp).catch((): void => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async put(
|
||||
identity: string,
|
||||
host: string,
|
||||
secret: Uint8Array,
|
||||
expectedGeneration?: string | null,
|
||||
): Promise<void> {
|
||||
if (!SAFE_NAME.test(identity) || !/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
|
||||
throw new TeaLoginStoreError('invalid-input', 'identity or host is outside the grammar');
|
||||
}
|
||||
const directory = dirname(this.configPath);
|
||||
ensureManagedDirectory(directory, directory);
|
||||
const lockPath = `${this.configPath}.lock`;
|
||||
const lock = await acquireLock(lockPath);
|
||||
try {
|
||||
let current: TeaConfig = { logins: [] };
|
||||
try {
|
||||
const snapshot = readRegularFileSecure(this.configPath, {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
assertPrivate(snapshot);
|
||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||
if (!decoded.success) {
|
||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
||||
}
|
||||
current = decoded.data;
|
||||
} catch (error: unknown) {
|
||||
if (!missing(error)) throw error;
|
||||
}
|
||||
assertExpectedGeneration(current, identity, host, expectedGeneration);
|
||||
const token = Buffer.from(secret).toString('utf8');
|
||||
const record: TeaLoginRecord = {
|
||||
name: loginName(identity, host),
|
||||
url: `https://${host}`,
|
||||
token,
|
||||
user: identity,
|
||||
default: false,
|
||||
};
|
||||
const logins = current.logins.filter(
|
||||
(login): boolean =>
|
||||
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
|
||||
);
|
||||
logins.push(record);
|
||||
await this.commit('put', { ...current, logins }, directory);
|
||||
} finally {
|
||||
await lock.close();
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(identity: string, host: string): TeaLoginSnapshot | undefined {
|
||||
const directory = dirname(this.configPath);
|
||||
let snapshot;
|
||||
try {
|
||||
snapshot = readRegularFileSecure(this.configPath, {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (missing(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
assertPrivate(snapshot);
|
||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||
if (!decoded.success) {
|
||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
||||
}
|
||||
return snapshotFromConfig(decoded.data, identity, host);
|
||||
}
|
||||
|
||||
matchesSnapshot(identity: string, host: string, expected: TeaLoginSnapshot | undefined): boolean {
|
||||
const actual = this.snapshot(identity, host);
|
||||
try {
|
||||
if (actual === undefined || expected === undefined) return actual === expected;
|
||||
return (
|
||||
isDeepStrictEqual(actual.fields, expected.fields) &&
|
||||
actual.secret.byteLength === expected.secret.byteLength &&
|
||||
timingSafeEqual(Buffer.from(actual.secret), Buffer.from(expected.secret))
|
||||
);
|
||||
} finally {
|
||||
actual?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async restore(
|
||||
identity: string,
|
||||
host: string,
|
||||
snapshot: TeaLoginSnapshot | undefined,
|
||||
expectedGeneration?: string | null,
|
||||
): Promise<void> {
|
||||
if (!SAFE_NAME.test(identity) || !/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
|
||||
throw new TeaLoginStoreError('invalid-input', 'identity or host is outside the grammar');
|
||||
}
|
||||
const directory = dirname(this.configPath);
|
||||
ensureManagedDirectory(directory, directory);
|
||||
const lockPath = `${this.configPath}.lock`;
|
||||
const lock = await acquireLock(lockPath);
|
||||
try {
|
||||
let current: TeaConfig = { logins: [] };
|
||||
try {
|
||||
const currentSnapshot = readRegularFileSecure(this.configPath, {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
assertPrivate(currentSnapshot);
|
||||
const decoded = configSchema.safeParse(parse(currentSnapshot.content.toString('utf8')));
|
||||
if (!decoded.success) {
|
||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
||||
}
|
||||
current = decoded.data;
|
||||
} catch (error: unknown) {
|
||||
if (!missing(error)) throw error;
|
||||
if (
|
||||
snapshot === undefined &&
|
||||
(expectedGeneration === undefined || expectedGeneration === null)
|
||||
)
|
||||
return;
|
||||
}
|
||||
assertExpectedGeneration(current, identity, host, expectedGeneration);
|
||||
const logins = current.logins.filter(
|
||||
(login): boolean =>
|
||||
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
|
||||
);
|
||||
if (snapshot !== undefined) {
|
||||
const restored = loginSchema.parse({
|
||||
...structuredClone(snapshot.fields),
|
||||
token: Buffer.from(snapshot.secret).toString('utf8'),
|
||||
});
|
||||
if (restored.name !== loginName(identity, host) || restored.url !== `https://${host}`) {
|
||||
throw new TeaLoginStoreError(
|
||||
'tea-config-invalid',
|
||||
'Tea snapshot does not match the requested identity and host',
|
||||
);
|
||||
}
|
||||
logins.push(restored);
|
||||
}
|
||||
await this.commit('restore', { ...current, logins }, directory);
|
||||
} finally {
|
||||
await lock.close();
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
resolve(identity: string, estate: string, host: string): ResolvedCredential | undefined {
|
||||
const directory = dirname(this.configPath);
|
||||
let snapshot;
|
||||
try {
|
||||
snapshot = readRegularFileSecure(this.configPath, {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (missing(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
assertPrivate(snapshot);
|
||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||
if (!decoded.success) return undefined;
|
||||
const matches = decoded.data.logins.filter(
|
||||
(login): boolean =>
|
||||
login.name === loginName(identity, host) &&
|
||||
login.url === `https://${host}` &&
|
||||
login.user === identity,
|
||||
);
|
||||
if (matches.length !== 1 || matches[0] === undefined) return undefined;
|
||||
return Object.freeze({
|
||||
identity,
|
||||
estate,
|
||||
host,
|
||||
resolutionId: randomUUID(),
|
||||
secret: new TextEncoder().encode(matches[0].token),
|
||||
});
|
||||
}
|
||||
|
||||
matchesSecret(identity: string, host: string, secret: Uint8Array): boolean {
|
||||
const resolved = this.resolve(identity, 'binding-check', host);
|
||||
if (resolved === undefined || resolved.secret.byteLength !== secret.byteLength) return false;
|
||||
return timingSafeEqual(Buffer.from(resolved.secret), Buffer.from(secret));
|
||||
}
|
||||
|
||||
async remove(identity: string, host: string, expectedGeneration?: string | null): Promise<void> {
|
||||
const directory = dirname(this.configPath);
|
||||
const lockPath = `${this.configPath}.lock`;
|
||||
const lock = await acquireLock(lockPath);
|
||||
try {
|
||||
let snapshot;
|
||||
try {
|
||||
snapshot = readRegularFileSecure(this.configPath, {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (missing(error)) {
|
||||
if (expectedGeneration !== undefined && expectedGeneration !== null) {
|
||||
throw new TeaLoginStoreError(
|
||||
'tea-generation-mismatch',
|
||||
'Tea login generation changed before removal',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
assertPrivate(snapshot);
|
||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||
if (!decoded.success) {
|
||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
||||
}
|
||||
assertExpectedGeneration(decoded.data, identity, host, expectedGeneration);
|
||||
const logins = decoded.data.logins.filter(
|
||||
(login): boolean =>
|
||||
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
|
||||
);
|
||||
await this.commit('remove', { ...decoded.data, logins }, directory);
|
||||
} finally {
|
||||
await lock.close();
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
readBack(
|
||||
identity: string,
|
||||
host: string,
|
||||
): { readonly name: string; readonly host: string } | undefined {
|
||||
const directory = dirname(this.configPath);
|
||||
let snapshot;
|
||||
try {
|
||||
snapshot = readRegularFileSecure(this.configPath, { root: directory, maxBytes: 1024 * 1024 });
|
||||
} catch (error: unknown) {
|
||||
if (missing(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
assertPrivate(snapshot);
|
||||
const decoded: unknown = parse(snapshot.content.toString('utf8'));
|
||||
if (
|
||||
typeof decoded !== 'object' ||
|
||||
decoded === null ||
|
||||
!('logins' in decoded) ||
|
||||
!Array.isArray(decoded.logins)
|
||||
)
|
||||
return undefined;
|
||||
const matches = decoded.logins.filter((value: unknown): value is TeaLoginRecord => {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
return (
|
||||
'name' in value &&
|
||||
value.name === loginName(identity, host) &&
|
||||
'url' in value &&
|
||||
value.url === `https://${host}` &&
|
||||
'user' in value &&
|
||||
value.user === identity
|
||||
);
|
||||
});
|
||||
return matches.length === 1 ? { name: loginName(identity, host), host } : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,926 @@
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
||||
import { grantTeamRepositoryPermission, type GiteaTeamGrantProvider } from './team-grant.js';
|
||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
||||
import type { CredentialValidationDependencies } from './validate.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
const authority: ResolvedCredential = Object.freeze({
|
||||
identity: 'provisioner',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
resolutionId: 'authority',
|
||||
secret: new TextEncoder().encode('authority-canary'),
|
||||
});
|
||||
const subject: ResolvedCredential = Object.freeze({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
resolutionId: 'subject',
|
||||
secret: new TextEncoder().encode('subject-canary'),
|
||||
});
|
||||
const control: ResolvedCredential = Object.freeze({
|
||||
identity: 'read-control',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
resolutionId: 'control',
|
||||
secret: new TextEncoder().encode('control-canary'),
|
||||
});
|
||||
|
||||
function validation(): CredentialValidationDependencies {
|
||||
return {
|
||||
estateRegistry: { matches: (): boolean => true },
|
||||
resolver: {
|
||||
async resolve(identity: string) {
|
||||
return identity === 'seat-name' ? subject : control;
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
async readIdentity(resolved: ResolvedCredential) {
|
||||
return {
|
||||
login: resolved.identity,
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readRepositoryPermission(resolved: ResolvedCredential) {
|
||||
return {
|
||||
effective: resolved.identity === 'seat-name' ? 'write' : 'read',
|
||||
endpoint: 'GET /api/v1/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async probeReceivePack(resolved: ResolvedCredential | undefined) {
|
||||
const write = resolved?.identity === 'seat-name';
|
||||
return {
|
||||
state: write ? 'advertised' : 'refused',
|
||||
principal: resolved?.identity ?? null,
|
||||
resolutionId: resolved?.resolutionId ?? null,
|
||||
contentType: write ? 'application/x-git-receive-pack-advertisement' : 'text/plain',
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('team repository grant', (): void => {
|
||||
it('reads team permission, org membership, member attachment, repo attachment, and effective subject permission', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let repositoryReads = 0;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
repositoryReads += 1;
|
||||
return {
|
||||
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {},
|
||||
async removeTeamMember(): Promise<void> {},
|
||||
async attachTeamRepository(): Promise<void> {},
|
||||
async detachTeamRepository(): Promise<void> {},
|
||||
async readTeamMember() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readTeamRepository() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const result = await grantTeamRepositoryPermission(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write',
|
||||
team: 'writers',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
},
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
{ stateRoot: join(cleanup, 'state'), actor: 'provisioner' },
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('ok');
|
||||
expect(result.evidence.organizationMembership?.state).toBe('present');
|
||||
expect(result.evidence.teamMembership?.state).toBe('present');
|
||||
expect(result.evidence.teamRepository?.state).toBe('present');
|
||||
});
|
||||
|
||||
it('durably reports indeterminate when team-lock release cannot be verified', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let repositoryReads = 0;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
repositoryReads += 1;
|
||||
return {
|
||||
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {},
|
||||
async removeTeamMember(): Promise<void> {},
|
||||
async attachTeamRepository(): Promise<void> {},
|
||||
async detachTeamRepository(): Promise<void> {},
|
||||
async readTeamMember() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readTeamRepository() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
const stateRoot = join(cleanup, 'state');
|
||||
|
||||
const result = await grantTeamRepositoryPermission(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write',
|
||||
team: 'writers',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
},
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
{
|
||||
stateRoot,
|
||||
actor: 'provisioner',
|
||||
acquireTeamLock: async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
throw new Error('injected close failure');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.reason.code).toBe('mutation-lock-release-failed');
|
||||
expect(result.mutation).toBe('applied');
|
||||
const [journalName] = await readdir(join(stateRoot, 'journals'));
|
||||
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
|
||||
expect(journal).toContain('"outcome":"indeterminate"');
|
||||
expect(journal).toContain('"reasonCode":"mutation-lock-release-failed"');
|
||||
expect(journal).toContain('"decision":"permission-write"');
|
||||
});
|
||||
|
||||
it('never compensates a later cooperating mutation after release when final seal fails', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let repositoryReads = 0;
|
||||
let memberPresent = false;
|
||||
let repositoryPresent = false;
|
||||
let memberRemovals = 0;
|
||||
let repositoryDetachments = 0;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
repositoryReads += 1;
|
||||
return {
|
||||
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {
|
||||
memberPresent = true;
|
||||
},
|
||||
async removeTeamMember(): Promise<void> {
|
||||
memberRemovals += 1;
|
||||
memberPresent = false;
|
||||
},
|
||||
async attachTeamRepository(): Promise<void> {
|
||||
repositoryPresent = true;
|
||||
},
|
||||
async detachTeamRepository(): Promise<void> {
|
||||
repositoryDetachments += 1;
|
||||
repositoryPresent = false;
|
||||
},
|
||||
async readTeamMember() {
|
||||
return {
|
||||
state: memberPresent ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readTeamRepository() {
|
||||
return {
|
||||
state: repositoryPresent ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
const originalSeal = CredentialAuditJournal.prototype.seal;
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
|
||||
this: CredentialAuditJournal,
|
||||
outcome,
|
||||
reasonCode,
|
||||
): Promise<string> {
|
||||
if (outcome === 'ok') {
|
||||
throw new CredentialJournalError('journal-unavailable', 'injected final seal failure');
|
||||
}
|
||||
return originalSeal.call(this, outcome, reasonCode);
|
||||
});
|
||||
|
||||
await expect(
|
||||
grantTeamRepositoryPermission(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write',
|
||||
team: 'writers',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
},
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
{
|
||||
stateRoot: join(cleanup, 'state'),
|
||||
actor: 'provisioner',
|
||||
acquireTeamLock: async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
// Simulate the next cooperating owner establishing the same state
|
||||
// immediately after acquiring the released lock.
|
||||
memberPresent = true;
|
||||
repositoryPresent = true;
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'journal-unavailable', mutation: 'applied' });
|
||||
expect(memberRemovals).toBe(0);
|
||||
expect(repositoryDetachments).toBe(0);
|
||||
expect(memberPresent).toBe(true);
|
||||
expect(repositoryPresent).toBe(true);
|
||||
});
|
||||
|
||||
it('never compensates successor state after lock release begins but release and audit sealing fail', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let repositoryReads = 0;
|
||||
let memberPresent = false;
|
||||
let repositoryPresent = false;
|
||||
let memberRemovals = 0;
|
||||
let repositoryDetachments = 0;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
repositoryReads += 1;
|
||||
return {
|
||||
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {
|
||||
memberPresent = true;
|
||||
},
|
||||
async removeTeamMember(): Promise<void> {
|
||||
memberRemovals += 1;
|
||||
memberPresent = false;
|
||||
},
|
||||
async attachTeamRepository(): Promise<void> {
|
||||
repositoryPresent = true;
|
||||
},
|
||||
async detachTeamRepository(): Promise<void> {
|
||||
repositoryDetachments += 1;
|
||||
repositoryPresent = false;
|
||||
},
|
||||
async readTeamMember() {
|
||||
return {
|
||||
state: memberPresent ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readTeamRepository() {
|
||||
return {
|
||||
state: repositoryPresent ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
const originalSeal = CredentialAuditJournal.prototype.seal;
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
|
||||
this: CredentialAuditJournal,
|
||||
outcome,
|
||||
reasonCode,
|
||||
): Promise<string> {
|
||||
if (reasonCode === 'mutation-lock-release-failed') {
|
||||
throw new CredentialJournalError('journal-unavailable', 'injected release audit failure');
|
||||
}
|
||||
return originalSeal.call(this, outcome, reasonCode);
|
||||
});
|
||||
|
||||
await expect(
|
||||
grantTeamRepositoryPermission(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write',
|
||||
team: 'writers',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
},
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
{
|
||||
stateRoot: join(cleanup, 'state'),
|
||||
actor: 'provisioner',
|
||||
acquireTeamLock: async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
memberPresent = true;
|
||||
repositoryPresent = true;
|
||||
throw new Error('injected release failure after successor mutation');
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'journal-unavailable', mutation: 'applied' });
|
||||
expect(memberRemovals).toBe(0);
|
||||
expect(repositoryDetachments).toBe(0);
|
||||
expect(memberPresent).toBe(true);
|
||||
expect(repositoryPresent).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a shared team already attached to any repository outside the request', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let mutated = false;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
return {
|
||||
repositories: ['owner/unrelated'],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {
|
||||
mutated = true;
|
||||
},
|
||||
async removeTeamMember(): Promise<void> {
|
||||
mutated = true;
|
||||
},
|
||||
async attachTeamRepository(): Promise<void> {
|
||||
mutated = true;
|
||||
},
|
||||
async detachTeamRepository(): Promise<void> {
|
||||
mutated = true;
|
||||
},
|
||||
async readTeamMember() {
|
||||
return {
|
||||
state: 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readTeamRepository() {
|
||||
return {
|
||||
state: 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'absent',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const result = await grantTeamRepositoryPermission(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write',
|
||||
team: 'writers',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
},
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
{ stateRoot: join(cleanup, 'state'), actor: 'provisioner' },
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('refused');
|
||||
expect(result.reason.code).toBe('team-scope-exceeds-request');
|
||||
expect(mutated).toBe(false);
|
||||
});
|
||||
|
||||
it('journals absent team objects as absent rather than present', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let repositoryReads = 0;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
repositoryReads += 1;
|
||||
return {
|
||||
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {},
|
||||
async removeTeamMember(): Promise<void> {},
|
||||
async attachTeamRepository(): Promise<void> {},
|
||||
async detachTeamRepository(): Promise<void> {},
|
||||
async readTeamMember() {
|
||||
return {
|
||||
state: 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readTeamRepository() {
|
||||
return {
|
||||
state: 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const stateRoot = join(cleanup, 'state');
|
||||
const result = await grantTeamRepositoryPermission(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write',
|
||||
team: 'writers',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
},
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
{ stateRoot, actor: 'provisioner' },
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
const [journalName] = await readdir(join(stateRoot, 'journals'));
|
||||
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
|
||||
expect(journal).toContain('team-member-absent');
|
||||
expect(journal).toContain('team-repository-absent');
|
||||
expect(journal).not.toContain('"decision":"team-member-present"');
|
||||
expect(journal).not.toContain('"decision":"team-repository-present"');
|
||||
});
|
||||
|
||||
it('fails closed and removes newly added membership when team scope changes during mutation', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let repositoryReads = 0;
|
||||
let membershipReads = 0;
|
||||
let removed = false;
|
||||
let detached = false;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
repositoryReads += 1;
|
||||
return {
|
||||
repositories: repositoryReads === 1 ? [] : ['owner/repo', 'owner/concurrent-attachment'],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {},
|
||||
async removeTeamMember(): Promise<void> {
|
||||
removed = true;
|
||||
},
|
||||
async attachTeamRepository(): Promise<void> {},
|
||||
async detachTeamRepository(): Promise<void> {
|
||||
detached = true;
|
||||
},
|
||||
async readTeamMember() {
|
||||
membershipReads += 1;
|
||||
return {
|
||||
state: membershipReads === 1 || removed ? 'absent' : 'present',
|
||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readTeamRepository() {
|
||||
return {
|
||||
state: detached ? 'absent' : 'present',
|
||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const stateRoot = join(cleanup, 'state');
|
||||
const result = await grantTeamRepositoryPermission(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write',
|
||||
team: 'writers',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
},
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
{ stateRoot, actor: 'provisioner' },
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.reason.code).toBe('team-scope-changed-during-grant');
|
||||
expect(result.evidence.teamRepositorySet?.repositories).toEqual([
|
||||
'owner/repo',
|
||||
'owner/concurrent-attachment',
|
||||
]);
|
||||
expect(removed).toBe(true);
|
||||
expect(detached).toBe(true);
|
||||
const [journalName] = await readdir(join(stateRoot, 'journals'));
|
||||
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
|
||||
expect(journal.indexOf('team-member-absent')).toBeLessThan(
|
||||
journal.indexOf('team-member-applied'),
|
||||
);
|
||||
expect(journal).toContain('team-repository-rollback-applied');
|
||||
expect(journal).toContain('team-repository-absent');
|
||||
});
|
||||
|
||||
it('compensates provider changes when repository attachment fails after applying', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let memberPresent = false;
|
||||
let repositoryPresent = false;
|
||||
let memberRemoved = false;
|
||||
let repositoryDetached = false;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
return {
|
||||
repositories: repositoryPresent ? ['owner/repo'] : [],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {
|
||||
memberPresent = true;
|
||||
},
|
||||
async removeTeamMember(): Promise<void> {
|
||||
memberPresent = false;
|
||||
memberRemoved = true;
|
||||
},
|
||||
async attachTeamRepository(): Promise<void> {
|
||||
repositoryPresent = true;
|
||||
throw new Error('provider response lost after attachment');
|
||||
},
|
||||
async detachTeamRepository(): Promise<void> {
|
||||
repositoryPresent = false;
|
||||
repositoryDetached = true;
|
||||
},
|
||||
async readTeamMember() {
|
||||
return {
|
||||
state: memberPresent ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readTeamRepository() {
|
||||
return {
|
||||
state: repositoryPresent ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const result = await grantTeamRepositoryPermission(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write',
|
||||
team: 'writers',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
},
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
{ stateRoot: join(cleanup, 'state'), actor: 'provisioner' },
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(memberPresent).toBe(false);
|
||||
expect(repositoryPresent).toBe(false);
|
||||
expect(memberRemoved).toBe(true);
|
||||
expect(repositoryDetached).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a second governed mutation while the same team lock is held', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let releaseFirst!: () => void;
|
||||
const firstMayFinish = new Promise<void>((resolve): void => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
let markFirstEntered!: () => void;
|
||||
const firstEntered = new Promise<void>((resolve): void => {
|
||||
markFirstEntered = resolve;
|
||||
});
|
||||
let addCalls = 0;
|
||||
let memberAdded = false;
|
||||
let repositoryAttached = false;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
return {
|
||||
repositories: repositoryAttached ? ['owner/repo'] : [],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {
|
||||
addCalls += 1;
|
||||
markFirstEntered();
|
||||
await firstMayFinish;
|
||||
memberAdded = true;
|
||||
},
|
||||
async removeTeamMember(): Promise<void> {
|
||||
memberAdded = false;
|
||||
},
|
||||
async attachTeamRepository(): Promise<void> {
|
||||
repositoryAttached = true;
|
||||
},
|
||||
async detachTeamRepository(): Promise<void> {
|
||||
repositoryAttached = false;
|
||||
},
|
||||
async readTeamMember() {
|
||||
return {
|
||||
state: memberAdded ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readTeamRepository() {
|
||||
return {
|
||||
state: repositoryAttached ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
const request = {
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write' as const,
|
||||
team: 'writers',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
};
|
||||
const options = { stateRoot: join(cleanup, 'state-one'), actor: 'provisioner' };
|
||||
const secondOptions = { stateRoot: join(cleanup, 'state-two'), actor: 'provisioner' };
|
||||
|
||||
const first = grantTeamRepositoryPermission(
|
||||
request,
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
options,
|
||||
);
|
||||
await firstEntered;
|
||||
const second = await grantTeamRepositoryPermission(
|
||||
request,
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
secondOptions,
|
||||
);
|
||||
releaseFirst();
|
||||
const completedFirst = await first;
|
||||
|
||||
expect(completedFirst.outcome).toBe('ok');
|
||||
expect(second.outcome).toBe('indeterminate');
|
||||
expect(second.reason.code).toBe('concurrent-mutation');
|
||||
expect(second.mutation).toBe('none');
|
||||
expect(addCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,589 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { constants, lstatSync } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { ensureManagedDirectory } from '../fleet/secure-file.js';
|
||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
||||
import type {
|
||||
CredentialValidationDependencies,
|
||||
ResolvedCredential,
|
||||
} from './credential-provider.dto.js';
|
||||
import type {
|
||||
CredentialGrantResultDto,
|
||||
DirectGrantRequestDto,
|
||||
OrganizationMembershipEvidenceDto,
|
||||
} from './grant.dto.js';
|
||||
import type { RepositoryPermission } from './credential-result.dto.js';
|
||||
import { CredentialGrantExecutionError } from './grant.js';
|
||||
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
|
||||
|
||||
export interface TeamResolutionEvidence {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly permission: RepositoryPermission;
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
}
|
||||
export interface PresenceEvidence {
|
||||
readonly state: 'present' | 'absent';
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
}
|
||||
export interface TeamRepositorySetEvidence {
|
||||
readonly repositories: readonly string[];
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
}
|
||||
export interface TeamGrantRequest extends DirectGrantRequestDto {
|
||||
readonly team: string;
|
||||
}
|
||||
export interface TeamGrantResult extends CredentialGrantResultDto {
|
||||
readonly evidence: CredentialGrantResultDto['evidence'] & {
|
||||
readonly team: TeamResolutionEvidence | null;
|
||||
readonly teamMembership: PresenceEvidence | null;
|
||||
readonly teamRepository: PresenceEvidence | null;
|
||||
readonly teamRepositorySet: TeamRepositorySetEvidence | null;
|
||||
};
|
||||
}
|
||||
export interface GiteaTeamGrantProvider {
|
||||
readBasicIdentity(
|
||||
authority: ResolvedCredential,
|
||||
): Promise<{ readonly login: string; readonly endpoint: string; readonly contentType: string }>;
|
||||
resolveTeam(
|
||||
authority: ResolvedCredential,
|
||||
organization: string,
|
||||
team: string,
|
||||
): Promise<TeamResolutionEvidence>;
|
||||
listTeamRepositories(
|
||||
authority: ResolvedCredential,
|
||||
teamId: number,
|
||||
): Promise<TeamRepositorySetEvidence>;
|
||||
addTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
|
||||
removeTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
|
||||
attachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
|
||||
detachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
|
||||
readTeamMember(
|
||||
authority: ResolvedCredential,
|
||||
teamId: number,
|
||||
identity: string,
|
||||
): Promise<PresenceEvidence>;
|
||||
readTeamRepository(
|
||||
authority: ResolvedCredential,
|
||||
teamId: number,
|
||||
repo: string,
|
||||
): Promise<PresenceEvidence>;
|
||||
readOrganizationMembership(
|
||||
subject: ResolvedCredential,
|
||||
organization: string,
|
||||
): Promise<OrganizationMembershipEvidenceDto>;
|
||||
}
|
||||
type ReleaseTeamGrantLock = () => Promise<void>;
|
||||
type AcquireTeamGrantLock = (
|
||||
estate: string,
|
||||
host: string,
|
||||
teamId: number,
|
||||
) => Promise<ReleaseTeamGrantLock>;
|
||||
|
||||
export interface TeamGrantOptions {
|
||||
readonly stateRoot: string;
|
||||
readonly actor: string;
|
||||
readonly acquireTeamLock?: AcquireTeamGrantLock;
|
||||
}
|
||||
|
||||
interface TeamGrantFinalization {
|
||||
readonly outcome: 'ok' | 'refused' | 'indeterminate';
|
||||
readonly reasonCode: string;
|
||||
}
|
||||
|
||||
class TeamGrantLockError extends Error {
|
||||
constructor(public readonly code: 'concurrent-mutation' | 'mutation-lock-unavailable') {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireTeamGrantLock(
|
||||
estate: string,
|
||||
host: string,
|
||||
teamId: number,
|
||||
): Promise<() => Promise<void>> {
|
||||
const uid = process.getuid?.();
|
||||
if (uid === undefined) throw new TeamGrantLockError('mutation-lock-unavailable');
|
||||
const locksDirectory = `/tmp/mosaic-cred-team-locks-${uid.toString()}`;
|
||||
ensureManagedDirectory(locksDirectory, locksDirectory);
|
||||
const directory = lstatSync(locksDirectory);
|
||||
if (
|
||||
!directory.isDirectory() ||
|
||||
directory.isSymbolicLink() ||
|
||||
directory.uid !== uid ||
|
||||
(directory.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new TeamGrantLockError('mutation-lock-unavailable');
|
||||
}
|
||||
const lockPath = join(locksDirectory, `${estate}--${host}--team-${teamId.toString()}.lock`);
|
||||
let handle: Awaited<ReturnType<typeof open>>;
|
||||
try {
|
||||
handle = await open(
|
||||
lockPath,
|
||||
constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
} catch {
|
||||
throw new TeamGrantLockError('mutation-lock-unavailable');
|
||||
}
|
||||
try {
|
||||
const file = await handle.stat();
|
||||
if (!file.isFile() || file.uid !== uid || (file.mode & 0o077) !== 0) {
|
||||
throw new Error('team mutation lock file is unsafe');
|
||||
}
|
||||
} catch {
|
||||
await handle.close().catch((): void => undefined);
|
||||
throw new TeamGrantLockError('mutation-lock-unavailable');
|
||||
}
|
||||
// The child's fd 3 is a dup of the parent's open file description. Linux
|
||||
// flock(2) associates the lock with that description, so it remains held
|
||||
// after the helper exits until this process closes `handle` below.
|
||||
const acquired = spawnSync('/usr/bin/flock', ['-n', '3'], {
|
||||
stdio: ['ignore', 'ignore', 'ignore', handle.fd],
|
||||
});
|
||||
if (acquired.error !== undefined || acquired.status !== 0) {
|
||||
await handle.close().catch((): void => undefined);
|
||||
throw new TeamGrantLockError(
|
||||
acquired.status === 1 ? 'concurrent-mutation' : 'mutation-lock-unavailable',
|
||||
);
|
||||
}
|
||||
return async (): Promise<void> => {
|
||||
await handle.close();
|
||||
};
|
||||
}
|
||||
|
||||
export async function grantTeamRepositoryPermission(
|
||||
request: TeamGrantRequest,
|
||||
authority: ResolvedCredential,
|
||||
provider: GiteaTeamGrantProvider,
|
||||
dependencies: CredentialValidationDependencies,
|
||||
options: TeamGrantOptions,
|
||||
): Promise<TeamGrantResult> {
|
||||
const journal = await CredentialAuditJournal.open(options.stateRoot, {
|
||||
operation: 'grant',
|
||||
actor: options.actor,
|
||||
identity: request.identity,
|
||||
estate: request.estate,
|
||||
host: request.host,
|
||||
repo: request.repo,
|
||||
});
|
||||
await journal.recordIntent('provider-grant');
|
||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||
let releaseTeamLock: (() => Promise<void>) | undefined;
|
||||
let teamLockReleaseStarted = false;
|
||||
let rollbackTeam: TeamResolutionEvidence | undefined;
|
||||
let membershipBeforeMutation: PresenceEvidence | undefined;
|
||||
let repositoryAttachedBeforeMutation = false;
|
||||
let memberMutationAttempted = false;
|
||||
let repositoryMutationAttempted = false;
|
||||
const sealFinalVerdict = async (
|
||||
providerOutcome: 'ok' | 'refused' | 'indeterminate',
|
||||
providerReasonCode: string,
|
||||
): Promise<TeamGrantFinalization> => {
|
||||
const release = releaseTeamLock;
|
||||
releaseTeamLock = undefined;
|
||||
if (release !== undefined) {
|
||||
teamLockReleaseStarted = true;
|
||||
try {
|
||||
await release();
|
||||
} catch {
|
||||
await journal.seal('indeterminate', 'mutation-lock-release-failed');
|
||||
return { outcome: 'indeterminate', reasonCode: 'mutation-lock-release-failed' };
|
||||
}
|
||||
}
|
||||
await journal.seal(providerOutcome, providerReasonCode);
|
||||
return { outcome: providerOutcome, reasonCode: providerReasonCode };
|
||||
};
|
||||
try {
|
||||
const authorityIdentity = await provider.readBasicIdentity(authority);
|
||||
const organization = request.repo.split('/')[0] ?? '';
|
||||
const team = await provider.resolveTeam(authority, organization, request.team);
|
||||
rollbackTeam = team;
|
||||
if (authorityIdentity.login !== options.actor || team.permission !== request.permission) {
|
||||
await journal.seal('refused', 'provider-identity-mismatch');
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
'refused',
|
||||
'none',
|
||||
'provider-identity-mismatch',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: authorityIdentity.endpoint,
|
||||
contentType: authorityIdentity.contentType,
|
||||
decision: 'identity-verified',
|
||||
});
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: team.endpoint,
|
||||
contentType: team.contentType,
|
||||
decision: `permission-${team.permission}`,
|
||||
});
|
||||
try {
|
||||
releaseTeamLock = await (options.acquireTeamLock ?? acquireTeamGrantLock)(
|
||||
request.estate,
|
||||
request.host,
|
||||
team.id,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof TeamGrantLockError)) throw error;
|
||||
await journal.seal('indeterminate', error.code);
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
'indeterminate',
|
||||
'none',
|
||||
error.code,
|
||||
null,
|
||||
team,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
const teamRepositorySet = await provider.listTeamRepositories(authority, team.id);
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: teamRepositorySet.endpoint,
|
||||
contentType: teamRepositorySet.contentType,
|
||||
decision: 'team-repository-set-verified',
|
||||
});
|
||||
if (teamRepositorySet.repositories.some((repo): boolean => repo !== request.repo)) {
|
||||
const finalization = await sealFinalVerdict('refused', 'team-scope-exceeds-request');
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
finalization.outcome,
|
||||
'none',
|
||||
finalization.reasonCode,
|
||||
null,
|
||||
team,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
teamRepositorySet,
|
||||
);
|
||||
}
|
||||
const repositoryAttachedBefore = teamRepositorySet.repositories.includes(request.repo);
|
||||
repositoryAttachedBeforeMutation = repositoryAttachedBefore;
|
||||
const membershipBefore = await provider.readTeamMember(authority, team.id, request.identity);
|
||||
membershipBeforeMutation = membershipBefore;
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: membershipBefore.endpoint,
|
||||
contentType: membershipBefore.contentType,
|
||||
decision: membershipBefore.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
||||
});
|
||||
mutation = 'unknown';
|
||||
memberMutationAttempted = true;
|
||||
await provider.addTeamMember(authority, team.id, request.identity);
|
||||
mutation = 'applied';
|
||||
await journal.recordMutation('team-member-applied');
|
||||
repositoryMutationAttempted = true;
|
||||
await provider.attachTeamRepository(authority, team.id, request.repo);
|
||||
await journal.recordMutation('team-repository-applied');
|
||||
let teamMembership = await provider.readTeamMember(authority, team.id, request.identity);
|
||||
let teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
|
||||
const finalTeamRepositorySet = await provider.listTeamRepositories(authority, team.id);
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: teamMembership.endpoint,
|
||||
contentType: teamMembership.contentType,
|
||||
decision: teamMembership.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
||||
});
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: teamRepository.endpoint,
|
||||
contentType: teamRepository.contentType,
|
||||
decision:
|
||||
teamRepository.state === 'present' ? 'team-repository-present' : 'team-repository-absent',
|
||||
});
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: finalTeamRepositorySet.endpoint,
|
||||
contentType: finalTeamRepositorySet.contentType,
|
||||
decision: 'team-repository-set-verified',
|
||||
});
|
||||
const scopeRemainedExact =
|
||||
finalTeamRepositorySet.repositories.length === 1 &&
|
||||
finalTeamRepositorySet.repositories[0] === request.repo;
|
||||
if (!scopeRemainedExact) {
|
||||
if (membershipBefore.state === 'absent') {
|
||||
await provider.removeTeamMember(authority, team.id, request.identity);
|
||||
await journal.recordMutation('team-member-rollback-applied');
|
||||
teamMembership = await provider.readTeamMember(authority, team.id, request.identity);
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: teamMembership.endpoint,
|
||||
contentType: teamMembership.contentType,
|
||||
decision:
|
||||
teamMembership.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
||||
});
|
||||
if (teamMembership.state !== 'absent') throw new Error('team member rollback disagreed');
|
||||
}
|
||||
if (!repositoryAttachedBefore) {
|
||||
await provider.detachTeamRepository(authority, team.id, request.repo);
|
||||
await journal.recordMutation('team-repository-rollback-applied');
|
||||
teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: teamRepository.endpoint,
|
||||
contentType: teamRepository.contentType,
|
||||
decision:
|
||||
teamRepository.state === 'present'
|
||||
? 'team-repository-present'
|
||||
: 'team-repository-absent',
|
||||
});
|
||||
if (teamRepository.state !== 'absent') {
|
||||
throw new Error('team repository rollback disagreed');
|
||||
}
|
||||
}
|
||||
const finalization = await sealFinalVerdict(
|
||||
'indeterminate',
|
||||
'team-scope-changed-during-grant',
|
||||
);
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
finalization.outcome,
|
||||
'applied',
|
||||
finalization.reasonCode,
|
||||
null,
|
||||
team,
|
||||
teamMembership,
|
||||
teamRepository,
|
||||
null,
|
||||
finalTeamRepositorySet,
|
||||
);
|
||||
}
|
||||
const subject = await dependencies.resolver.resolve(
|
||||
request.identity,
|
||||
request.estate,
|
||||
request.host,
|
||||
);
|
||||
const organizationMembership =
|
||||
subject === undefined
|
||||
? null
|
||||
: await provider.readOrganizationMembership(subject, organization);
|
||||
const validation =
|
||||
request.permission === 'read'
|
||||
? await evaluateGiteaReadValidation(request, dependencies)
|
||||
: await evaluateGiteaWriteValidation(request, dependencies);
|
||||
if (organizationMembership !== null) {
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: organizationMembership.endpoint,
|
||||
contentType: organizationMembership.contentType,
|
||||
decision:
|
||||
organizationMembership.state === 'present'
|
||||
? 'organization-member-present'
|
||||
: 'organization-member-absent',
|
||||
});
|
||||
}
|
||||
if (validation.evidence.providerIdentity !== null) {
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: validation.evidence.providerIdentity.endpoint,
|
||||
contentType: validation.evidence.providerIdentity.contentType,
|
||||
decision: 'identity-verified',
|
||||
});
|
||||
}
|
||||
if (validation.evidence.repositoryPermission !== null) {
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: validation.evidence.repositoryPermission.endpoint,
|
||||
contentType: validation.evidence.repositoryPermission.contentType,
|
||||
decision: `permission-${validation.evidence.repositoryPermission.effective}`,
|
||||
});
|
||||
}
|
||||
if (validation.evidence.writeDifferential !== null) {
|
||||
await journal.recordMutation('transport-write-verified');
|
||||
}
|
||||
const ok =
|
||||
teamMembership.state === 'present' &&
|
||||
teamRepository.state === 'present' &&
|
||||
organizationMembership?.state === 'present' &&
|
||||
validation.outcome === 'ok' &&
|
||||
validation.evidence.repositoryPermission?.effective === request.permission;
|
||||
const finalization = await sealFinalVerdict(
|
||||
ok ? 'ok' : 'indeterminate',
|
||||
ok ? 'grant-verified' : 'permission-evidence-disagrees',
|
||||
);
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
finalization.outcome,
|
||||
'applied',
|
||||
finalization.reasonCode,
|
||||
validation,
|
||||
team,
|
||||
teamMembership,
|
||||
teamRepository,
|
||||
organizationMembership,
|
||||
finalTeamRepositorySet,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
let compensationError: unknown;
|
||||
try {
|
||||
if (
|
||||
!teamLockReleaseStarted &&
|
||||
rollbackTeam !== undefined &&
|
||||
membershipBeforeMutation?.state === 'absent' &&
|
||||
memberMutationAttempted
|
||||
) {
|
||||
let current = await provider.readTeamMember(authority, rollbackTeam.id, request.identity);
|
||||
if (current.state === 'present') {
|
||||
await provider.removeTeamMember(authority, rollbackTeam.id, request.identity);
|
||||
await journal.recordMutation('team-member-rollback-applied');
|
||||
current = await provider.readTeamMember(authority, rollbackTeam.id, request.identity);
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: current.endpoint,
|
||||
contentType: current.contentType,
|
||||
decision: current.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
||||
});
|
||||
if (current.state !== 'absent') throw new Error('team member rollback disagreed');
|
||||
}
|
||||
}
|
||||
if (
|
||||
!teamLockReleaseStarted &&
|
||||
rollbackTeam !== undefined &&
|
||||
!repositoryAttachedBeforeMutation &&
|
||||
repositoryMutationAttempted
|
||||
) {
|
||||
let current = await provider.readTeamRepository(authority, rollbackTeam.id, request.repo);
|
||||
if (current.state === 'present') {
|
||||
await provider.detachTeamRepository(authority, rollbackTeam.id, request.repo);
|
||||
await journal.recordMutation('team-repository-rollback-applied');
|
||||
current = await provider.readTeamRepository(authority, rollbackTeam.id, request.repo);
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: current.endpoint,
|
||||
contentType: current.contentType,
|
||||
decision:
|
||||
current.state === 'present' ? 'team-repository-present' : 'team-repository-absent',
|
||||
});
|
||||
if (current.state !== 'absent') throw new Error('team repository rollback disagreed');
|
||||
}
|
||||
}
|
||||
} catch (rollbackError: unknown) {
|
||||
compensationError = rollbackError;
|
||||
}
|
||||
const auditError =
|
||||
compensationError instanceof CredentialJournalError
|
||||
? compensationError
|
||||
: error instanceof CredentialJournalError
|
||||
? error
|
||||
: undefined;
|
||||
if (auditError !== undefined) {
|
||||
throw new CredentialGrantExecutionError(auditError.code, mutation, journal.journalId());
|
||||
}
|
||||
const reasonCode =
|
||||
compensationError !== undefined
|
||||
? 'rollback-incomplete'
|
||||
: mutation === 'applied'
|
||||
? 'readback-missing'
|
||||
: 'mutation-state-unknown';
|
||||
let finalization: Awaited<ReturnType<typeof sealFinalVerdict>>;
|
||||
try {
|
||||
finalization = await sealFinalVerdict('indeterminate', reasonCode);
|
||||
} catch (journalError: unknown) {
|
||||
if (journalError instanceof CredentialJournalError) {
|
||||
throw new CredentialGrantExecutionError(journalError.code, mutation, journal.journalId());
|
||||
}
|
||||
throw journalError;
|
||||
}
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
finalization.outcome,
|
||||
mutation,
|
||||
finalization.reasonCode,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
const release = releaseTeamLock;
|
||||
releaseTeamLock = undefined;
|
||||
if (release !== undefined) {
|
||||
teamLockReleaseStarted = true;
|
||||
try {
|
||||
await release();
|
||||
} catch {
|
||||
try {
|
||||
await journal.seal('indeterminate', 'mutation-lock-release-failed');
|
||||
} catch (journalError: unknown) {
|
||||
if (journalError instanceof CredentialJournalError) {
|
||||
throw new CredentialGrantExecutionError(
|
||||
journalError.code,
|
||||
mutation,
|
||||
journal.journalId(),
|
||||
);
|
||||
}
|
||||
throw journalError;
|
||||
}
|
||||
throw new CredentialGrantExecutionError(
|
||||
'mutation-lock-release-failed',
|
||||
mutation,
|
||||
journal.journalId(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function result(
|
||||
request: TeamGrantRequest,
|
||||
journal: CredentialAuditJournal,
|
||||
outcome: 'ok' | 'refused' | 'indeterminate',
|
||||
mutation: 'none' | 'unknown' | 'applied',
|
||||
code: string,
|
||||
validation: Awaited<ReturnType<typeof evaluateGiteaWriteValidation>> | null,
|
||||
team: TeamResolutionEvidence | null,
|
||||
teamMembership: PresenceEvidence | null,
|
||||
teamRepository: PresenceEvidence | null,
|
||||
organizationMembership: OrganizationMembershipEvidenceDto | null,
|
||||
teamRepositorySet: TeamRepositorySetEvidence | null,
|
||||
): TeamGrantResult {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'grant',
|
||||
outcome,
|
||||
exitCode: outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30,
|
||||
retryable: false,
|
||||
subject: {
|
||||
identity: request.identity,
|
||||
estate: request.estate,
|
||||
host: request.host,
|
||||
repo: request.repo,
|
||||
},
|
||||
mutation,
|
||||
reason: {
|
||||
code,
|
||||
message:
|
||||
outcome === 'ok'
|
||||
? 'Team grant matched every provider read-back.'
|
||||
: 'Team grant was refused or could not be established.',
|
||||
},
|
||||
evidence: {
|
||||
providerIdentity: validation?.evidence.providerIdentity ?? null,
|
||||
tokenCapabilities: validation?.evidence.tokenCapabilities ?? {
|
||||
state: 'not-measured',
|
||||
scopes: [],
|
||||
source: 'runtime-not-authorized',
|
||||
},
|
||||
repositoryPermission: validation?.evidence.repositoryPermission ?? null,
|
||||
writeDifferential: validation?.evidence.writeDifferential ?? null,
|
||||
collaboratorPermission: null,
|
||||
organizationMembership,
|
||||
team,
|
||||
teamMembership,
|
||||
teamRepository,
|
||||
teamRepositorySet,
|
||||
},
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CredentialProviderEvidenceError } from './gitea-provider.js';
|
||||
import {
|
||||
evaluateGiteaReadValidation,
|
||||
evaluateGiteaWriteValidation,
|
||||
type CredentialResolver,
|
||||
type CredentialValidationDependencies,
|
||||
type GiteaCredentialProvider,
|
||||
type ProviderIdentityEvidence,
|
||||
type ReceivePackEvidence,
|
||||
type RepositoryPermissionEvidence,
|
||||
type ResolvedCredential,
|
||||
} from './validate.js';
|
||||
|
||||
interface FixtureOptions {
|
||||
readonly subjectProviderIdentity?: string;
|
||||
readonly subjectPermission?: 'none' | 'read' | 'write' | 'admin';
|
||||
readonly subjectTransportState?: 'advertised' | 'refused';
|
||||
readonly subjectTransportPrincipal?: string;
|
||||
readonly subjectTransportResolutionId?: string;
|
||||
readonly controlProviderIdentity?: string;
|
||||
readonly controlPermission?: 'none' | 'read' | 'write' | 'admin';
|
||||
readonly controlTransportState?: 'advertised' | 'refused';
|
||||
readonly controlTransportPrincipal?: string;
|
||||
readonly unauthenticatedTransportState?: 'advertised' | 'refused';
|
||||
readonly omitControl?: boolean;
|
||||
readonly requiredPermission?: 'read' | 'write' | 'admin';
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
readonly dependencies: CredentialValidationDependencies;
|
||||
readonly resolverCalls: string[];
|
||||
readonly identityHandles: ResolvedCredential[];
|
||||
readonly permissionHandles: ResolvedCredential[];
|
||||
readonly receivePackHandles: Array<ResolvedCredential | undefined>;
|
||||
}
|
||||
|
||||
const SUBJECT = 'seat-name';
|
||||
const CONTROL = 'read-only-control';
|
||||
const ESTATE = 'homelab';
|
||||
const HOST = 'git.example.invalid';
|
||||
const REPO = 'owner/repo';
|
||||
|
||||
function credential(identity: string, resolutionId: string): ResolvedCredential {
|
||||
return Object.freeze({
|
||||
identity,
|
||||
estate: ESTATE,
|
||||
host: HOST,
|
||||
resolutionId,
|
||||
secret: new Uint8Array([99, 97, 110, 97, 114, 121]),
|
||||
});
|
||||
}
|
||||
|
||||
function fixture(options: FixtureOptions = {}): Fixture {
|
||||
const subjectCredential = credential(SUBJECT, 'subject-resolution');
|
||||
const controlCredential = credential(CONTROL, 'control-resolution');
|
||||
const resolverCalls: string[] = [];
|
||||
const identityHandles: ResolvedCredential[] = [];
|
||||
const permissionHandles: ResolvedCredential[] = [];
|
||||
const receivePackHandles: Array<ResolvedCredential | undefined> = [];
|
||||
|
||||
const resolver: CredentialResolver = {
|
||||
async resolve(identity: string): Promise<ResolvedCredential | undefined> {
|
||||
resolverCalls.push(identity);
|
||||
if (identity === SUBJECT) return subjectCredential;
|
||||
if (identity === CONTROL && options.omitControl !== true) return controlCredential;
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
const provider: GiteaCredentialProvider = {
|
||||
async readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidence> {
|
||||
identityHandles.push(resolved);
|
||||
const login =
|
||||
resolved.identity === SUBJECT
|
||||
? (options.subjectProviderIdentity ?? SUBJECT)
|
||||
: (options.controlProviderIdentity ?? CONTROL);
|
||||
return {
|
||||
login,
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readRepositoryPermission(
|
||||
resolved: ResolvedCredential,
|
||||
): Promise<RepositoryPermissionEvidence> {
|
||||
permissionHandles.push(resolved);
|
||||
const effective =
|
||||
resolved.identity === SUBJECT
|
||||
? (options.subjectPermission ?? 'write')
|
||||
: (options.controlPermission ?? 'read');
|
||||
return {
|
||||
effective,
|
||||
endpoint: `GET /api/v1/repos/${REPO}`,
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async probeReceivePack(resolved: ResolvedCredential | undefined): Promise<ReceivePackEvidence> {
|
||||
receivePackHandles.push(resolved);
|
||||
if (resolved === undefined) {
|
||||
return {
|
||||
state: options.unauthenticatedTransportState ?? 'refused',
|
||||
principal: null,
|
||||
resolutionId: null,
|
||||
contentType: 'text/plain',
|
||||
};
|
||||
}
|
||||
if (resolved.identity === SUBJECT) {
|
||||
return {
|
||||
state: options.subjectTransportState ?? 'advertised',
|
||||
principal: options.subjectTransportPrincipal ?? SUBJECT,
|
||||
resolutionId: options.subjectTransportResolutionId ?? resolved.resolutionId,
|
||||
contentType: 'application/x-git-receive-pack-advertisement',
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: options.controlTransportState ?? 'refused',
|
||||
principal: options.controlTransportPrincipal ?? CONTROL,
|
||||
resolutionId: resolved.resolutionId,
|
||||
contentType: 'text/plain',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
dependencies: {
|
||||
resolver,
|
||||
provider,
|
||||
estateRegistry: {
|
||||
matches(estate: string, host: string): boolean {
|
||||
return estate === ESTATE && host === HOST;
|
||||
},
|
||||
},
|
||||
},
|
||||
resolverCalls,
|
||||
identityHandles,
|
||||
permissionHandles,
|
||||
receivePackHandles,
|
||||
};
|
||||
}
|
||||
|
||||
async function validate(options: FixtureOptions = {}): Promise<{
|
||||
readonly result: Awaited<ReturnType<typeof evaluateGiteaWriteValidation>>;
|
||||
readonly observed: Fixture;
|
||||
}> {
|
||||
const observed = fixture(options);
|
||||
const result = await evaluateGiteaWriteValidation(
|
||||
{
|
||||
identity: SUBJECT,
|
||||
estate: ESTATE,
|
||||
host: HOST,
|
||||
repo: REPO,
|
||||
readOnlyControlIdentity: CONTROL,
|
||||
requiredPermission: options.requiredPermission,
|
||||
},
|
||||
observed.dependencies,
|
||||
);
|
||||
return { result, observed };
|
||||
}
|
||||
|
||||
describe('Gitea read validation', (): void => {
|
||||
it('reads the explicit provider identity and repository permission without a write control', async (): Promise<void> => {
|
||||
const observed = fixture({ subjectPermission: 'read' });
|
||||
const result = await evaluateGiteaReadValidation(
|
||||
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
|
||||
observed.dependencies,
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('ok');
|
||||
expect(result.evidence.providerIdentity?.login).toBe(SUBJECT);
|
||||
expect(result.evidence.repositoryPermission?.effective).toBe('read');
|
||||
expect(result.evidence.writeDifferential).toBeNull();
|
||||
expect(observed.resolverCalls).toEqual([SUBJECT]);
|
||||
});
|
||||
|
||||
it('refuses a repository object whose permission flags establish no read access', async (): Promise<void> => {
|
||||
const observed = fixture({ subjectPermission: 'none' });
|
||||
const result = await evaluateGiteaReadValidation(
|
||||
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
|
||||
observed.dependencies,
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('refused');
|
||||
expect(result.reason.code).toBe('permission-denied');
|
||||
});
|
||||
|
||||
it('classifies the provider rejecting the subject credential as an authoritative refusal', async (): Promise<void> => {
|
||||
const observed = fixture({ subjectPermission: 'read' });
|
||||
observed.dependencies.provider.readIdentity = async (): Promise<ProviderIdentityEvidence> => {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'credential-rejected',
|
||||
'provider rejected the supplied credential',
|
||||
);
|
||||
};
|
||||
const result = await evaluateGiteaReadValidation(
|
||||
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
|
||||
observed.dependencies,
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('refused');
|
||||
expect(result.exitCode).toBe(10);
|
||||
expect(result.reason.code).toBe('credential-rejected');
|
||||
});
|
||||
|
||||
it('confirms in-scope capability while reporting identity as not measured', async (): Promise<void> => {
|
||||
const observed = fixture({ subjectPermission: 'write' });
|
||||
observed.dependencies.provider.readIdentity = async (): Promise<ProviderIdentityEvidence> => {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'identity-read-forbidden',
|
||||
'identity endpoint requires a scope this token does not hold',
|
||||
);
|
||||
};
|
||||
const result = await evaluateGiteaReadValidation(
|
||||
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
|
||||
observed.dependencies,
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.reason.code).toBe('identity-not-measured');
|
||||
expect(result.evidence.providerIdentity).toBeNull();
|
||||
expect(result.evidence.repositoryPermission?.effective).toBe('write');
|
||||
});
|
||||
|
||||
it('refuses a shared fallback rather than reporting a different principal as the subject', async (): Promise<void> => {
|
||||
const observed = fixture({
|
||||
subjectProviderIdentity: 'shared-owner',
|
||||
subjectPermission: 'read',
|
||||
});
|
||||
const result = await evaluateGiteaReadValidation(
|
||||
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
|
||||
observed.dependencies,
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('refused');
|
||||
expect(result.reason.code).toBe('provider-identity-mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('principal-bound Gitea write validation contract v1.1', (): void => {
|
||||
it('confirms write capability when identity is scope-forbidden without exposing the internal reason', async (): Promise<void> => {
|
||||
const observed = fixture();
|
||||
const readIdentity = observed.dependencies.provider.readIdentity.bind(
|
||||
observed.dependencies.provider,
|
||||
);
|
||||
observed.dependencies.provider.readIdentity = async (
|
||||
resolved,
|
||||
): Promise<ProviderIdentityEvidence> => {
|
||||
if (resolved.identity === SUBJECT) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'identity-read-forbidden',
|
||||
'identity endpoint scope forbidden',
|
||||
);
|
||||
}
|
||||
return readIdentity(resolved);
|
||||
};
|
||||
const result = await evaluateGiteaWriteValidation(
|
||||
{
|
||||
identity: SUBJECT,
|
||||
estate: ESTATE,
|
||||
host: HOST,
|
||||
repo: REPO,
|
||||
readOnlyControlIdentity: CONTROL,
|
||||
},
|
||||
observed.dependencies,
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.reason.code).toBe('identity-not-measured');
|
||||
expect(result.evidence.repositoryPermission?.effective).toBe('write');
|
||||
expect(observed.receivePackHandles).toEqual([
|
||||
expect.objectContaining({ identity: SUBJECT }),
|
||||
expect.objectContaining({ identity: CONTROL }),
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
it('uses one immutable subject credential handle for identity, permission, and receive-pack', async (): Promise<void> => {
|
||||
const { result, observed } = await validate();
|
||||
|
||||
expect(result.outcome).toBe('ok');
|
||||
expect(observed.resolverCalls).toEqual([SUBJECT, CONTROL]);
|
||||
expect(observed.identityHandles[0]).toBe(observed.permissionHandles[0]);
|
||||
expect(observed.identityHandles[0]).toBe(observed.receivePackHandles[0]);
|
||||
});
|
||||
|
||||
it('refuses a subject credential whose provider identity is a shared fallback', async (): Promise<void> => {
|
||||
const { result } = await validate({ subjectProviderIdentity: 'shared-owner' });
|
||||
|
||||
expect(result.outcome).toBe('refused');
|
||||
expect(result.reason.code).toBe('provider-identity-mismatch');
|
||||
expect(result.mutation).toBe('none');
|
||||
});
|
||||
|
||||
it('routes a transport principal mismatch to indeterminate, not refused', async (): Promise<void> => {
|
||||
const { result } = await validate({ subjectTransportPrincipal: 'shared-owner' });
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.reason.code).toBe('transport-principal-mismatch');
|
||||
});
|
||||
|
||||
it('routes a transport credential-handle mismatch to indeterminate', async (): Promise<void> => {
|
||||
const { result } = await validate({ subjectTransportResolutionId: 'fallback-resolution' });
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.reason.code).toBe('transport-principal-mismatch');
|
||||
});
|
||||
|
||||
it('refuses when the provider repository object authoritatively denies write', async (): Promise<void> => {
|
||||
const { result } = await validate({ subjectPermission: 'read' });
|
||||
|
||||
expect(result.outcome).toBe('refused');
|
||||
expect(result.reason.code).toBe('permission-denied');
|
||||
});
|
||||
|
||||
it('is indeterminate when repo permission says write but receive-pack refuses', async (): Promise<void> => {
|
||||
const { result } = await validate({ subjectTransportState: 'refused' });
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.reason.code).toBe('permission-evidence-disagrees');
|
||||
});
|
||||
|
||||
it('refuses write permission when admin permission is explicitly required', async (): Promise<void> => {
|
||||
const { result } = await validate({
|
||||
requiredPermission: 'admin',
|
||||
subjectPermission: 'write',
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe('refused');
|
||||
expect(result.reason.code).toBe('permission-denied');
|
||||
});
|
||||
|
||||
it('accepts admin permission when admin is explicitly required', async (): Promise<void> => {
|
||||
const { result } = await validate({
|
||||
requiredPermission: 'admin',
|
||||
subjectPermission: 'admin',
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe('ok');
|
||||
});
|
||||
|
||||
it('makes a write-capable read-only control invalidate the entire result', async (): Promise<void> => {
|
||||
const { result } = await validate({ controlPermission: 'write' });
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.reason.code).toBe('read-only-control-invalid');
|
||||
});
|
||||
|
||||
it('makes an identity-mismatched read-only control invalidate the entire result', async (): Promise<void> => {
|
||||
const { result } = await validate({ controlProviderIdentity: 'other-control' });
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.reason.code).toBe('read-only-control-invalid');
|
||||
});
|
||||
|
||||
it('makes a read-only control that receives write transport invalidate the result', async (): Promise<void> => {
|
||||
const { result } = await validate({ controlTransportState: 'advertised' });
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.reason.code).toBe('read-only-control-invalid');
|
||||
});
|
||||
|
||||
it('is indeterminate when the configured read-only control credential is absent', async (): Promise<void> => {
|
||||
const { result } = await validate({ omitControl: true });
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.reason.code).toBe('read-only-control-invalid');
|
||||
});
|
||||
|
||||
it('keeps the unauthenticated arm and rejects an advertisement there', async (): Promise<void> => {
|
||||
const { result } = await validate({ unauthenticatedTransportState: 'advertised' });
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.reason.code).toBe('permission-evidence-disagrees');
|
||||
});
|
||||
|
||||
it('refuses an estate-host mismatch before resolving any credential', async (): Promise<void> => {
|
||||
const observed = fixture();
|
||||
const result = await evaluateGiteaWriteValidation(
|
||||
{
|
||||
identity: SUBJECT,
|
||||
estate: 'usc',
|
||||
host: HOST,
|
||||
repo: REPO,
|
||||
readOnlyControlIdentity: CONTROL,
|
||||
},
|
||||
observed.dependencies,
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('refused');
|
||||
expect(result.reason.code).toBe('estate-host-mismatch');
|
||||
expect(observed.resolverCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns structured proof bounds only after every principal-bound arm passes', async (): Promise<void> => {
|
||||
const { result } = await validate();
|
||||
|
||||
expect(result.outcome).toBe('ok');
|
||||
expect(result.evidence.writeDifferential).toMatchObject({
|
||||
state: 'can-write',
|
||||
credentialBinding: 'same-resolution',
|
||||
transportPrincipal: SUBJECT,
|
||||
authenticatedReceivePack: 'advertised',
|
||||
readOnlyControl: {
|
||||
identity: CONTROL,
|
||||
providerPermission: 'read',
|
||||
receivePack: 'refused',
|
||||
},
|
||||
unauthenticatedReceivePack: 'refused',
|
||||
artifactCreated: false,
|
||||
});
|
||||
expect(result.evidence.writeDifferential?.proves).toContain('declared subject credential');
|
||||
expect(result.evidence.writeDifferential?.doesNotProve).toContain('branch protection');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
import { CredentialProviderEvidenceError } from './gitea-provider.js';
|
||||
import type {
|
||||
CredentialValidationDependencies,
|
||||
GiteaReadValidationRequestDto,
|
||||
GiteaWriteValidationRequestDto,
|
||||
ResolvedCredential,
|
||||
} from './credential-provider.dto.js';
|
||||
import type {
|
||||
CredentialOutcome,
|
||||
CredentialReasonDto,
|
||||
CredentialValidationEvidenceDto,
|
||||
CredentialValidationResultDto,
|
||||
ProviderIdentityEvidenceDto,
|
||||
ReceivePackEvidenceDto,
|
||||
RepositoryPermissionEvidenceDto,
|
||||
WriteDifferentialEvidenceDto,
|
||||
} from './credential-result.dto.js';
|
||||
|
||||
export type {
|
||||
CredentialResolver,
|
||||
CredentialValidationDependencies,
|
||||
GiteaCredentialProvider,
|
||||
GiteaReadValidationRequestDto,
|
||||
GiteaWriteValidationRequestDto,
|
||||
ResolvedCredential,
|
||||
} from './credential-provider.dto.js';
|
||||
export type {
|
||||
ProviderIdentityEvidenceDto as ProviderIdentityEvidence,
|
||||
ReceivePackEvidenceDto as ReceivePackEvidence,
|
||||
RepositoryPermissionEvidenceDto as RepositoryPermissionEvidence,
|
||||
} from './credential-result.dto.js';
|
||||
|
||||
const JSON_CONTENT_TYPE = 'application/json';
|
||||
const RUNTIME_SCOPE_NOT_MEASURED = {
|
||||
state: 'not-measured' as const,
|
||||
scopes: [] as readonly string[],
|
||||
source: 'runtime-not-authorized' as const,
|
||||
};
|
||||
const RECEIVE_PACK_CONTENT_TYPE = 'application/x-git-receive-pack-advertisement';
|
||||
|
||||
interface ResultOptions {
|
||||
readonly outcome: CredentialOutcome;
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly retryable?: boolean;
|
||||
readonly evidence?: CredentialValidationEvidenceDto;
|
||||
}
|
||||
|
||||
function subject(request: GiteaReadValidationRequestDto): CredentialValidationResultDto['subject'] {
|
||||
return {
|
||||
identity: request.identity,
|
||||
estate: request.estate,
|
||||
host: request.host,
|
||||
repo: request.repo,
|
||||
};
|
||||
}
|
||||
|
||||
function result(
|
||||
request: GiteaReadValidationRequestDto,
|
||||
options: ResultOptions,
|
||||
): CredentialValidationResultDto {
|
||||
const exits: Readonly<Record<CredentialOutcome, 0 | 10 | 20 | 30>> = {
|
||||
ok: 0,
|
||||
refused: 10,
|
||||
error: 20,
|
||||
indeterminate: 30,
|
||||
};
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'validate',
|
||||
outcome: options.outcome,
|
||||
exitCode: exits[options.outcome],
|
||||
retryable: options.retryable ?? false,
|
||||
subject: subject(request),
|
||||
mutation: 'none',
|
||||
reason: { code: options.code, message: options.message },
|
||||
evidence: options.evidence ?? {
|
||||
providerIdentity: null,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission: null,
|
||||
writeDifferential: null,
|
||||
},
|
||||
audit: { journalId: null, state: 'not-started' },
|
||||
};
|
||||
}
|
||||
|
||||
function refused(
|
||||
request: GiteaReadValidationRequestDto,
|
||||
reason: CredentialReasonDto,
|
||||
evidence?: CredentialValidationEvidenceDto,
|
||||
): CredentialValidationResultDto {
|
||||
return result(request, {
|
||||
outcome: 'refused',
|
||||
code: reason.code,
|
||||
message: reason.message,
|
||||
...(evidence === undefined ? {} : { evidence }),
|
||||
});
|
||||
}
|
||||
|
||||
function indeterminate(
|
||||
request: GiteaReadValidationRequestDto,
|
||||
reason: CredentialReasonDto,
|
||||
evidence?: CredentialValidationEvidenceDto,
|
||||
): CredentialValidationResultDto {
|
||||
return result(request, {
|
||||
outcome: 'indeterminate',
|
||||
code: reason.code,
|
||||
message: reason.message,
|
||||
...(evidence === undefined ? {} : { evidence }),
|
||||
});
|
||||
}
|
||||
|
||||
function providerEvidenceFailure(
|
||||
request: GiteaReadValidationRequestDto,
|
||||
error: CredentialProviderEvidenceError,
|
||||
): CredentialValidationResultDto {
|
||||
if (error.code === 'credential-rejected') {
|
||||
return refused(request, {
|
||||
code: error.code,
|
||||
message: 'The provider authoritatively rejected the supplied subject credential.',
|
||||
});
|
||||
}
|
||||
return indeterminate(request, {
|
||||
code: error.code,
|
||||
message: 'Provider evidence could not be evaluated completely.',
|
||||
});
|
||||
}
|
||||
|
||||
function identityContentTypeValid(evidence: ProviderIdentityEvidenceDto): boolean {
|
||||
return evidence.contentType.toLowerCase().startsWith(JSON_CONTENT_TYPE);
|
||||
}
|
||||
|
||||
function permissionContentTypeValid(evidence: RepositoryPermissionEvidenceDto): boolean {
|
||||
return evidence.contentType.toLowerCase().startsWith(JSON_CONTENT_TYPE);
|
||||
}
|
||||
|
||||
function advertised(evidence: ReceivePackEvidenceDto): boolean {
|
||||
return (
|
||||
evidence.state === 'advertised' &&
|
||||
evidence.contentType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE)
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveCredential(
|
||||
request: GiteaWriteValidationRequestDto,
|
||||
identity: string,
|
||||
dependencies: CredentialValidationDependencies,
|
||||
): Promise<ResolvedCredential | undefined> {
|
||||
return dependencies.resolver.resolve(identity, request.estate, request.host);
|
||||
}
|
||||
|
||||
function successfulEvidence(
|
||||
subjectLogin: string,
|
||||
subjectIdentity: ProviderIdentityEvidenceDto | null,
|
||||
subjectPermission: RepositoryPermissionEvidenceDto,
|
||||
subjectReceivePack: ReceivePackEvidenceDto,
|
||||
controlIdentity: ProviderIdentityEvidenceDto,
|
||||
controlPermission: RepositoryPermissionEvidenceDto,
|
||||
controlReceivePack: ReceivePackEvidenceDto,
|
||||
): CredentialValidationEvidenceDto {
|
||||
const writeDifferential: WriteDifferentialEvidenceDto = {
|
||||
state: 'can-write',
|
||||
credentialBinding: 'same-resolution',
|
||||
transportPrincipal: subjectLogin,
|
||||
authenticatedReceivePack: 'advertised',
|
||||
readOnlyControl: {
|
||||
identity: controlIdentity.login,
|
||||
providerPermission: controlPermission.effective,
|
||||
receivePack: controlReceivePack.state,
|
||||
},
|
||||
unauthenticatedReceivePack: 'refused',
|
||||
artifactCreated: false,
|
||||
proves:
|
||||
'The declared subject credential authenticated provider identity, repository permission, and write transport while a distinct provider-confirmed read-only principal and an unauthenticated caller were refused.',
|
||||
doesNotProve:
|
||||
'A particular ref update will pass branch protection, hooks, races, or content policy.',
|
||||
};
|
||||
return {
|
||||
providerIdentity: subjectIdentity,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission: subjectPermission,
|
||||
writeDifferential,
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluateGiteaReadValidationUnsafe(
|
||||
request: GiteaReadValidationRequestDto,
|
||||
dependencies: CredentialValidationDependencies,
|
||||
): Promise<CredentialValidationResultDto> {
|
||||
if (!dependencies.estateRegistry.matches(request.estate, request.host)) {
|
||||
return refused(request, {
|
||||
code: 'estate-host-mismatch',
|
||||
message: 'The declared estate does not contain the declared host.',
|
||||
});
|
||||
}
|
||||
const resolved = await dependencies.resolver.resolve(
|
||||
request.identity,
|
||||
request.estate,
|
||||
request.host,
|
||||
);
|
||||
if (resolved === undefined) {
|
||||
return refused(request, {
|
||||
code: 'no-token-for-identity',
|
||||
message: 'The explicit identity has no credential in the declared estate.',
|
||||
});
|
||||
}
|
||||
let providerIdentity: ProviderIdentityEvidenceDto | null;
|
||||
try {
|
||||
providerIdentity = await dependencies.provider.readIdentity(resolved);
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error instanceof CredentialProviderEvidenceError &&
|
||||
error.code === 'identity-read-forbidden'
|
||||
) {
|
||||
const repositoryPermission = await dependencies.provider.readRepositoryPermission(
|
||||
resolved,
|
||||
request.repo,
|
||||
);
|
||||
const evidence: CredentialValidationEvidenceDto = {
|
||||
providerIdentity: null,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission,
|
||||
writeDifferential: null,
|
||||
};
|
||||
if (!permissionContentTypeValid(repositoryPermission)) {
|
||||
return indeterminate(
|
||||
request,
|
||||
{
|
||||
code: 'unexpected-content-type',
|
||||
message: 'In-scope capability evidence was not JSON.',
|
||||
},
|
||||
evidence,
|
||||
);
|
||||
}
|
||||
if (repositoryPermission.effective === 'none') {
|
||||
return refused(
|
||||
request,
|
||||
{
|
||||
code: 'permission-denied',
|
||||
message: 'The in-scope provider object denies repository access.',
|
||||
},
|
||||
evidence,
|
||||
);
|
||||
}
|
||||
return indeterminate(
|
||||
request,
|
||||
{
|
||||
code: 'identity-not-measured',
|
||||
message:
|
||||
'Repository capability was confirmed, but identity was not measured because this least-privilege token cannot read /user.',
|
||||
},
|
||||
evidence,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const repositoryPermission = await dependencies.provider.readRepositoryPermission(
|
||||
resolved,
|
||||
request.repo,
|
||||
);
|
||||
const evidence: CredentialValidationEvidenceDto = {
|
||||
providerIdentity,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission,
|
||||
writeDifferential: null,
|
||||
};
|
||||
if (
|
||||
!identityContentTypeValid(providerIdentity) ||
|
||||
!permissionContentTypeValid(repositoryPermission)
|
||||
) {
|
||||
return indeterminate(
|
||||
request,
|
||||
{
|
||||
code: 'unexpected-content-type',
|
||||
message: 'Provider read evidence was not JSON.',
|
||||
},
|
||||
evidence,
|
||||
);
|
||||
}
|
||||
if (repositoryPermission.effective === 'none') {
|
||||
return refused(
|
||||
request,
|
||||
{
|
||||
code: 'permission-denied',
|
||||
message: 'The provider repository object denies read permission.',
|
||||
},
|
||||
evidence,
|
||||
);
|
||||
}
|
||||
if (providerIdentity.login !== request.identity) {
|
||||
return refused(
|
||||
request,
|
||||
{
|
||||
code: 'provider-identity-mismatch',
|
||||
message: 'The provider credential identity does not equal the declared subject.',
|
||||
},
|
||||
evidence,
|
||||
);
|
||||
}
|
||||
return result(request, {
|
||||
outcome: 'ok',
|
||||
code: 'validation-verified',
|
||||
message: 'Provider identity and repository permission were read back.',
|
||||
evidence,
|
||||
});
|
||||
}
|
||||
|
||||
export async function evaluateGiteaReadValidation(
|
||||
request: GiteaReadValidationRequestDto,
|
||||
dependencies: CredentialValidationDependencies,
|
||||
): Promise<CredentialValidationResultDto> {
|
||||
try {
|
||||
return await evaluateGiteaReadValidationUnsafe(request, dependencies);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof CredentialProviderEvidenceError) {
|
||||
return providerEvidenceFailure(request, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function evaluateGiteaWriteValidationUnsafe(
|
||||
request: GiteaWriteValidationRequestDto,
|
||||
dependencies: CredentialValidationDependencies,
|
||||
): Promise<CredentialValidationResultDto> {
|
||||
if (!dependencies.estateRegistry.matches(request.estate, request.host)) {
|
||||
return refused(request, {
|
||||
code: 'estate-host-mismatch',
|
||||
message: 'The declared estate does not contain the declared host.',
|
||||
});
|
||||
}
|
||||
|
||||
const resolved = await resolveCredential(request, request.identity, dependencies);
|
||||
if (resolved === undefined) {
|
||||
return refused(request, {
|
||||
code: 'no-token-for-identity',
|
||||
message: 'The explicit identity has no credential in the declared estate.',
|
||||
});
|
||||
}
|
||||
|
||||
let subjectIdentity: ProviderIdentityEvidenceDto | null = null;
|
||||
try {
|
||||
subjectIdentity = await dependencies.provider.readIdentity(resolved);
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
!(error instanceof CredentialProviderEvidenceError) ||
|
||||
error.code !== 'identity-read-forbidden'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const subjectPermission = await dependencies.provider.readRepositoryPermission(
|
||||
resolved,
|
||||
request.repo,
|
||||
);
|
||||
const subjectReceivePack = await dependencies.provider.probeReceivePack(resolved, request.repo);
|
||||
const baseEvidence: CredentialValidationEvidenceDto = {
|
||||
providerIdentity: subjectIdentity,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission: subjectPermission,
|
||||
writeDifferential: null,
|
||||
};
|
||||
|
||||
if (subjectIdentity !== null && !identityContentTypeValid(subjectIdentity)) {
|
||||
return indeterminate(
|
||||
request,
|
||||
{
|
||||
code: 'unexpected-content-type',
|
||||
message: 'The provider identity response was not JSON.',
|
||||
},
|
||||
baseEvidence,
|
||||
);
|
||||
}
|
||||
if (subjectIdentity !== null && subjectIdentity.login !== request.identity) {
|
||||
return refused(
|
||||
request,
|
||||
{
|
||||
code: 'provider-identity-mismatch',
|
||||
message: 'The provider credential identity does not equal the declared subject.',
|
||||
},
|
||||
baseEvidence,
|
||||
);
|
||||
}
|
||||
if (!permissionContentTypeValid(subjectPermission)) {
|
||||
return indeterminate(
|
||||
request,
|
||||
{
|
||||
code: 'unexpected-content-type',
|
||||
message: 'The provider repository response was not JSON.',
|
||||
},
|
||||
baseEvidence,
|
||||
);
|
||||
}
|
||||
if (request.requiredPermission === 'admin' && subjectPermission.effective !== 'admin') {
|
||||
return refused(
|
||||
request,
|
||||
{
|
||||
code: 'permission-denied',
|
||||
message: 'The provider repository object denies required admin permission.',
|
||||
},
|
||||
baseEvidence,
|
||||
);
|
||||
}
|
||||
if (subjectPermission.effective === 'read' || subjectPermission.effective === 'none') {
|
||||
return refused(
|
||||
request,
|
||||
{
|
||||
code: 'permission-denied',
|
||||
message: 'The provider repository object denies write permission.',
|
||||
},
|
||||
baseEvidence,
|
||||
);
|
||||
}
|
||||
if (
|
||||
subjectReceivePack.principal !== request.identity ||
|
||||
subjectReceivePack.resolutionId !== resolved.resolutionId
|
||||
) {
|
||||
return indeterminate(
|
||||
request,
|
||||
{
|
||||
code: 'transport-principal-mismatch',
|
||||
message: 'The write transport evidence is not bound to the declared subject credential.',
|
||||
},
|
||||
baseEvidence,
|
||||
);
|
||||
}
|
||||
if (!advertised(subjectReceivePack)) {
|
||||
return indeterminate(
|
||||
request,
|
||||
{
|
||||
code: 'permission-evidence-disagrees',
|
||||
message: 'Repository permission and write transport evidence disagree.',
|
||||
},
|
||||
baseEvidence,
|
||||
);
|
||||
}
|
||||
|
||||
const control = await resolveCredential(request, request.readOnlyControlIdentity, dependencies);
|
||||
if (control === undefined) {
|
||||
return indeterminate(request, {
|
||||
code: 'read-only-control-invalid',
|
||||
message: 'The configured read-only control credential could not be resolved.',
|
||||
});
|
||||
}
|
||||
const controlIdentity = await dependencies.provider.readIdentity(control);
|
||||
const controlPermission = await dependencies.provider.readRepositoryPermission(
|
||||
control,
|
||||
request.repo,
|
||||
);
|
||||
const controlReceivePack = await dependencies.provider.probeReceivePack(control, request.repo);
|
||||
|
||||
const controlIsDistinct =
|
||||
request.readOnlyControlIdentity !== request.identity &&
|
||||
control.resolutionId !== resolved.resolutionId;
|
||||
const controlIdentityMatches =
|
||||
identityContentTypeValid(controlIdentity) &&
|
||||
controlIdentity.login === request.readOnlyControlIdentity;
|
||||
const controlPermissionIsReadOnly =
|
||||
permissionContentTypeValid(controlPermission) && controlPermission.effective === 'read';
|
||||
const controlTransportIsBoundAndRefused =
|
||||
controlReceivePack.state === 'refused' &&
|
||||
controlReceivePack.principal === request.readOnlyControlIdentity &&
|
||||
controlReceivePack.resolutionId === control.resolutionId &&
|
||||
!controlReceivePack.contentType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE);
|
||||
if (
|
||||
!controlIsDistinct ||
|
||||
!controlIdentityMatches ||
|
||||
!controlPermissionIsReadOnly ||
|
||||
!controlTransportIsBoundAndRefused
|
||||
) {
|
||||
return indeterminate(request, {
|
||||
code: 'read-only-control-invalid',
|
||||
message:
|
||||
'The read-only control was absent, identity-mismatched, write-capable, unbound, or admitted to write transport.',
|
||||
});
|
||||
}
|
||||
|
||||
const unauthenticated = await dependencies.provider.probeReceivePack(undefined, request.repo);
|
||||
if (
|
||||
unauthenticated.state !== 'refused' ||
|
||||
unauthenticated.contentType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE)
|
||||
) {
|
||||
return indeterminate(request, {
|
||||
code: 'permission-evidence-disagrees',
|
||||
message: 'The unauthenticated write-transport control was not refused.',
|
||||
});
|
||||
}
|
||||
|
||||
const evidence = successfulEvidence(
|
||||
request.identity,
|
||||
subjectIdentity,
|
||||
subjectPermission,
|
||||
subjectReceivePack,
|
||||
controlIdentity,
|
||||
controlPermission,
|
||||
controlReceivePack,
|
||||
);
|
||||
return result(request, {
|
||||
outcome: subjectIdentity === null ? 'indeterminate' : 'ok',
|
||||
code: subjectIdentity === null ? 'identity-not-measured' : 'validation-verified',
|
||||
message:
|
||||
subjectIdentity === null
|
||||
? 'Write capability and both controls were confirmed, but identity was not measured because this least-privilege token cannot read /user.'
|
||||
: 'Every required provider evidence layer agreed.',
|
||||
evidence,
|
||||
});
|
||||
}
|
||||
|
||||
export async function evaluateGiteaWriteValidation(
|
||||
request: GiteaWriteValidationRequestDto,
|
||||
dependencies: CredentialValidationDependencies,
|
||||
): Promise<CredentialValidationResultDto> {
|
||||
try {
|
||||
return await evaluateGiteaWriteValidationUnsafe(request, dependencies);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof CredentialProviderEvidenceError) {
|
||||
return providerEvidenceFailure(request, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export interface SecureFileSnapshot {
|
||||
mode: number;
|
||||
dev: number | bigint;
|
||||
ino: number | bigint;
|
||||
uid: number;
|
||||
gid: number;
|
||||
}
|
||||
|
||||
function sameIdentity(
|
||||
@@ -235,6 +237,8 @@ export function readRegularFileSecure(
|
||||
mode: Number(opened.mode),
|
||||
dev: opened.dev,
|
||||
ino: opened.ino,
|
||||
uid: opened.uid,
|
||||
gid: opened.gid,
|
||||
};
|
||||
} finally {
|
||||
closeDescriptors(openedFile.descriptors);
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { verifyRegistry } from './gate-verify.mjs';
|
||||
|
||||
const root = process.cwd();
|
||||
const manifest = JSON.parse(await readFile(path.join(root, 'gates/gates.manifest.json'), 'utf8'));
|
||||
manifest.gateRoots = ['.mosaic-empty-gate-root'];
|
||||
manifest.criteria = [];
|
||||
manifest.gates = [];
|
||||
manifest.proseClaims = [];
|
||||
manifest.compatibilityScenarios = [];
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'gate-empty-population-'));
|
||||
try {
|
||||
const manifestPath = path.join(directory, 'manifest.json');
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`);
|
||||
const result = await verifyRegistry({
|
||||
root,
|
||||
manifest: manifestPath,
|
||||
structureOnly: true,
|
||||
fixtureProfile: false,
|
||||
});
|
||||
const required = ['criteria', 'gates', 'proseClaims', 'compatibilityScenarios'];
|
||||
const missing = required.filter(
|
||||
(population) =>
|
||||
!result.failures.some((failure) =>
|
||||
failure.includes(`${population} population must be non-empty and anchored`),
|
||||
),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
process.stdout.write(`empty populations were not rejected: ${missing.join(', ')}\n`);
|
||||
process.exitCode = 0;
|
||||
} else {
|
||||
process.stderr.write(
|
||||
'empty universally quantified registry populations rejected before evaluation\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { assertCurrentTreeObservation } from './gate-verify.mjs';
|
||||
|
||||
const prohibitedReports = [
|
||||
'HISTORY PROVENANCE VERIFIED local-ref',
|
||||
'COMMIT ANCESTRY VERIFIED origin/main',
|
||||
'PROVIDER LINEAGE SUCCESS pipeline-7',
|
||||
];
|
||||
const accepted = prohibitedReports.filter((report) => {
|
||||
try {
|
||||
assertCurrentTreeObservation(report);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const verifier = await readFile(path.join(process.cwd(), 'scripts', 'gate-verify.mjs'), 'utf8');
|
||||
const stdoutWrites = [...verifier.matchAll(/process\.stdout\.write\s*\(/g)].length;
|
||||
const stderrWrites = [...verifier.matchAll(/process\.stderr\.write\s*\(/g)].length;
|
||||
const gatedErrorWrites = [...verifier.matchAll(/process\.stderr\.write\(`GATE VERIFY FAILED:/g)]
|
||||
.length;
|
||||
const currentTreeWriterCalls = [...verifier.matchAll(/writeCurrentTreeOutput\s*\(/g)].length;
|
||||
const productionRendererWired =
|
||||
stdoutWrites === 1 &&
|
||||
stderrWrites === 2 &&
|
||||
gatedErrorWrites === 2 &&
|
||||
currentTreeWriterCalls === 3 &&
|
||||
/function writeCurrentTreeOutput\(output\) \{\s*assertCurrentTreeObservation\(output\);\s*process\.stdout\.write/s.test(
|
||||
verifier,
|
||||
) &&
|
||||
/for \(const observation of observations\) \{\s*writeCurrentTreeOutput\(observation\);\s*\}/s.test(
|
||||
verifier,
|
||||
) &&
|
||||
/writeCurrentTreeOutput\(\s*`REGISTRY SUMMARY open behavior deltas:/s.test(verifier) &&
|
||||
!/\bconsole\.(?:log|info|debug|error|warn)\s*\(/.test(verifier);
|
||||
|
||||
if (accepted.length > 0 || !productionRendererWired) {
|
||||
process.stderr.write(
|
||||
`HISTORY_PROVENANCE_FORBIDDEN: closed current-tree observation renderer rejected=${prohibitedReports.length - accepted.length}/${prohibitedReports.length} production-wired=${productionRendererWired}\n`,
|
||||
);
|
||||
process.exit(79);
|
||||
}
|
||||
process.stdout.write('history provenance reporting capability is absent; owner RM-60\n');
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const boundary =
|
||||
"Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.";
|
||||
const artifacts = [
|
||||
'gates/required-gates.baseline.json',
|
||||
'gates/gates.manifest.json',
|
||||
'docs/PRD.md',
|
||||
'docs/ADMIN-GUIDE/quality-gate-registry.md',
|
||||
'docs/DEVELOPER-GUIDE/quality-gate-registry.md',
|
||||
'docs/remediation/GATE-CLAIMS.md',
|
||||
'docs/plans/2026-08-01-rm-02-gate-registry.md',
|
||||
'docs/SITEMAP.md',
|
||||
];
|
||||
const forbidden = [
|
||||
/\bindependent\b[^\n.]{0,80}\bbaseline\b/i,
|
||||
/independently baselined/i,
|
||||
/independently anchored inventory/i,
|
||||
/protected (?:inventory )?(?:anchor|baseline)/i,
|
||||
/inventory (?:anchor|anchored)/i,
|
||||
];
|
||||
const failures = [];
|
||||
for (const relativePath of artifacts) {
|
||||
const contents = await readFile(path.join(process.cwd(), relativePath), 'utf8');
|
||||
const claims =
|
||||
relativePath === 'gates/gates.manifest.json'
|
||||
? JSON.parse(contents)
|
||||
.criteria.map((criterion) => criterion.currentText)
|
||||
.join('\n')
|
||||
: contents;
|
||||
if (!claims.includes(boundary)) failures.push(`${relativePath}: narrowed boundary is missing`);
|
||||
const overclaim = forbidden.find((pattern) => pattern.test(claims));
|
||||
if (overclaim) failures.push(`${relativePath}: inventory protection is overstated`);
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) {
|
||||
process.stderr.write(`INVENTORY_CLAIM_OVERSTATED: ${failure}\n`);
|
||||
}
|
||||
process.exit(84);
|
||||
}
|
||||
process.stdout.write('inventory drift boundary is stated in both directions; owner RM-60\n');
|
||||
@@ -1,119 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const root = process.cwd();
|
||||
const removedGateId = 'hook-pre-push';
|
||||
const inventoryEntry = " ['hook-pre-push', '.husky/pre-push'],\n";
|
||||
|
||||
function shrinkManifest(manifest) {
|
||||
const removedGate = manifest.gates.find((gate) => gate.id === removedGateId);
|
||||
assert.ok(removedGate);
|
||||
const removedCaseRefs = new Set(
|
||||
removedGate.cases.map((gateCase) => `${removedGateId}/${gateCase.id}`),
|
||||
);
|
||||
const removedCriterionIds = new Set(
|
||||
manifest.criteria
|
||||
.filter(
|
||||
(criterion) =>
|
||||
criterion.caseRefs.length > 0 &&
|
||||
criterion.caseRefs.every((caseRef) => removedCaseRefs.has(caseRef)),
|
||||
)
|
||||
.map((criterion) => criterion.id),
|
||||
);
|
||||
manifest.gates = manifest.gates.filter((gate) => gate.id !== removedGateId);
|
||||
manifest.criteria = manifest.criteria
|
||||
.filter((criterion) => !removedCriterionIds.has(criterion.id))
|
||||
.map((criterion) => ({
|
||||
...criterion,
|
||||
caseRefs: criterion.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||
...(criterion.gateRefs
|
||||
? { gateRefs: criterion.gateRefs.filter((gateId) => gateId !== removedGateId) }
|
||||
: {}),
|
||||
}));
|
||||
manifest.proseClaims = manifest.proseClaims.filter(
|
||||
(claim) => !removedCriterionIds.has(claim.criterionId) && !removedCaseRefs.has(claim.caseRef),
|
||||
);
|
||||
manifest.compatibilityScenarios = manifest.compatibilityScenarios
|
||||
.map((scenario) => ({
|
||||
...scenario,
|
||||
caseRefs: scenario.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||
}))
|
||||
.filter((scenario) => scenario.caseRefs.length > 0);
|
||||
for (const gate of manifest.gates) {
|
||||
for (const gateCase of gate.cases) {
|
||||
gateCase.criterionIds = gateCase.criterionIds.filter(
|
||||
(criterionId) => !removedCriterionIds.has(criterionId),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function attack(mode) {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), `gate-inventory-${mode}-`));
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||
const source = await readFile(path.join(root, 'scripts', 'gate-verify.mjs'), 'utf8');
|
||||
if (source.split(inventoryEntry).length - 1 !== 1) {
|
||||
throw new Error('source inventory fixture drifted');
|
||||
}
|
||||
await writeFile(
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
mode === 'source-manifest' ? source.replace(inventoryEntry, '') : source,
|
||||
);
|
||||
const baseline = JSON.parse(
|
||||
await readFile(path.join(root, 'gates', 'required-gates.baseline.json'), 'utf8'),
|
||||
);
|
||||
if (mode === 'baseline-manifest') {
|
||||
baseline.gates = baseline.gates.filter((gate) => gate.id !== removedGateId);
|
||||
}
|
||||
await writeFile(
|
||||
path.join(fixture, 'gates', 'required-gates.baseline.json'),
|
||||
`${JSON.stringify(baseline)}\n`,
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(root, 'gates', 'gates.manifest.json'), 'utf8'),
|
||||
);
|
||||
shrinkManifest(manifest);
|
||||
await writeFile(
|
||||
path.join(fixture, 'gates', 'gates.manifest.json'),
|
||||
`${JSON.stringify(manifest)}\n`,
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
'--root',
|
||||
fixture,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
'--structure-only',
|
||||
],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
return (
|
||||
result.status !== 0 &&
|
||||
new RegExp(`(?:baseline|verifier inventory).*${removedGateId}`, 'i').test(combined)
|
||||
);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const sourceManifestRejected = await attack('source-manifest');
|
||||
const baselineManifestRejected = await attack('baseline-manifest');
|
||||
if (sourceManifestRejected && baselineManifestRejected) {
|
||||
process.stderr.write(
|
||||
'INVENTORY_SHRINK_REJECTED: source+manifest and baseline+manifest shrink attacks detected\n',
|
||||
);
|
||||
process.exit(83);
|
||||
}
|
||||
process.stdout.write(
|
||||
`inventory shrink attack escaped: source-manifest=${sourceManifestRejected} baseline-manifest=${baselineManifestRejected}\n`,
|
||||
);
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { verifyRegistry } from './gate-verify.mjs';
|
||||
|
||||
const mode = process.argv[2];
|
||||
const root = process.cwd();
|
||||
const source = JSON.parse(await readFile(path.join(root, 'gates/gates.manifest.json'), 'utf8'));
|
||||
const expectedGateIds = source.gates.map((gate) => gate.id);
|
||||
if (expectedGateIds.length === 0) {
|
||||
process.stderr.write('gate population control requires a non-empty anchored inventory\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
async function rejectedForEveryGate(mutate, diagnostic) {
|
||||
for (const gateId of expectedGateIds) {
|
||||
const manifest = structuredClone(source);
|
||||
const gate = manifest.gates.find((candidate) => candidate.id === gateId);
|
||||
mutate(gate);
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'gate-population-control-'));
|
||||
const manifestPath = path.join(directory, 'manifest.json');
|
||||
try {
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`);
|
||||
const result = await verifyRegistry({
|
||||
root,
|
||||
manifest: manifestPath,
|
||||
structureOnly: true,
|
||||
fixtureProfile: false,
|
||||
});
|
||||
if (!result.failures.some((failure) => diagnostic(failure, gateId))) return false;
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let rejected;
|
||||
if (mode === 'evidence-subject') {
|
||||
rejected = true;
|
||||
for (const gateId of expectedGateIds) {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'gate-evidence-consumption-'));
|
||||
try {
|
||||
await mkdir(path.join(directory, 'gates'), { recursive: true });
|
||||
const probe = path.join(directory, 'gates', 'probe.sh');
|
||||
await writeFile(probe, '#!/bin/sh\necho EVIDENCE_PROBE >&2\nexit 7\n');
|
||||
await chmod(probe, 0o755);
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
gateRoots: ['gates'],
|
||||
governingClaimFiles: [],
|
||||
coverageBoundary: { included: ['evidence fixture'], excluded: [], trackedBy: 'RM-02' },
|
||||
criteria: [
|
||||
{
|
||||
id: 'EVIDENCE-CONSUMPTION',
|
||||
originalText: 'Consumed evidence stays bound to its gate.',
|
||||
currentText: 'Consumed evidence stays bound to its gate.',
|
||||
claimType: 'integrity',
|
||||
source: 'gate-population-control',
|
||||
meaningChanges: [],
|
||||
caseRefs: [`${gateId}/probe`],
|
||||
},
|
||||
],
|
||||
proseClaims: [],
|
||||
compatibilityScenarios: [],
|
||||
gates: [
|
||||
{
|
||||
id: gateId,
|
||||
source: 'gates/probe.sh',
|
||||
invocation: ['gates/probe.sh'],
|
||||
deployment: { kind: 'none', reason: 'population fixture' },
|
||||
inertMutation: {
|
||||
file: 'gates/probe.sh',
|
||||
find: 'exit 7',
|
||||
replace: 'exit 0',
|
||||
caseId: 'probe',
|
||||
expected: { exitCode: 0 },
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
id: 'probe',
|
||||
criterionIds: ['EVIDENCE-CONSUMPTION'],
|
||||
mustFail: true,
|
||||
required: { exitCode: 7 },
|
||||
actual: { exitCode: 7 },
|
||||
evidence: { subject: 'different-gate-subject' },
|
||||
reasonPattern: 'EVIDENCE_PROBE',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const manifestPath = path.join(directory, 'gates', 'gates.manifest.json');
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`);
|
||||
const result = await verifyRegistry({
|
||||
root: directory,
|
||||
manifest: manifestPath,
|
||||
structureOnly: false,
|
||||
fixtureProfile: true,
|
||||
});
|
||||
if (
|
||||
!result.failures.some(
|
||||
(failure) =>
|
||||
failure.includes(`gate ${gateId}: consumed evidence subject`) &&
|
||||
failure.includes('does not match gate definition'),
|
||||
)
|
||||
) {
|
||||
rejected = false;
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
} else if (mode === 'type-strict') {
|
||||
rejected = await rejectedForEveryGate(
|
||||
(gate) => {
|
||||
gate.cases[0].actual.exitCode = '0';
|
||||
},
|
||||
(failure, gateId) =>
|
||||
failure.includes(
|
||||
`${gateId}/${source.gates.find((gate) => gate.id === gateId).cases[0].id}.actual.exitCode`,
|
||||
) && failure.includes('expected an integer'),
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(`unknown gate population control ${String(mode)}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (!rejected) {
|
||||
process.stdout.write(`${mode} population control did not reject every registered gate\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write(`${mode} population control rejected every registered gate\n`);
|
||||
process.exit(1);
|
||||
@@ -1,283 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = process.cwd();
|
||||
const verifierPath = path.join(root, 'scripts', 'gate-verify.mjs');
|
||||
const manifestPath = path.join(root, 'gates', 'gates.manifest.json');
|
||||
const requiredGateId = 'hook-pre-push';
|
||||
|
||||
function output(result) {
|
||||
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
}
|
||||
|
||||
function shrinkManifest(manifest, removedGateId) {
|
||||
const removedGate = manifest.gates.find((gate) => gate.id === removedGateId);
|
||||
assert.ok(removedGate, `fixture gate ${removedGateId} must exist`);
|
||||
const removedCaseRefs = new Set(
|
||||
removedGate.cases.map((gateCase) => `${removedGateId}/${gateCase.id}`),
|
||||
);
|
||||
const removedCriterionIds = new Set(
|
||||
manifest.criteria
|
||||
.filter(
|
||||
(criterion) =>
|
||||
criterion.caseRefs.length > 0 &&
|
||||
criterion.caseRefs.every((caseRef) => removedCaseRefs.has(caseRef)),
|
||||
)
|
||||
.map((criterion) => criterion.id),
|
||||
);
|
||||
|
||||
manifest.gates = manifest.gates.filter((gate) => gate.id !== removedGateId);
|
||||
manifest.criteria = manifest.criteria
|
||||
.filter((criterion) => !removedCriterionIds.has(criterion.id))
|
||||
.map((criterion) => ({
|
||||
...criterion,
|
||||
caseRefs: criterion.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||
...(criterion.gateRefs
|
||||
? { gateRefs: criterion.gateRefs.filter((gateId) => gateId !== removedGateId) }
|
||||
: {}),
|
||||
}));
|
||||
manifest.proseClaims = manifest.proseClaims.filter(
|
||||
(claim) => !removedCriterionIds.has(claim.criterionId) && !removedCaseRefs.has(claim.caseRef),
|
||||
);
|
||||
manifest.compatibilityScenarios = manifest.compatibilityScenarios
|
||||
.map((scenario) => ({
|
||||
...scenario,
|
||||
caseRefs: scenario.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||
}))
|
||||
.filter((scenario) => scenario.caseRefs.length > 0);
|
||||
for (const gate of manifest.gates) {
|
||||
for (const gateCase of gate.cases) {
|
||||
gateCase.criterionIds = gateCase.criterionIds.filter(
|
||||
(criterionId) => !removedCriterionIds.has(criterionId),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('shrinking the verifier inventory and manifest together is rejected by a same-checkout baseline', async () => {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-shrink-both-'));
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||
const verifier = await readFile(verifierPath, 'utf8');
|
||||
const inventoryEntry = " ['hook-pre-push', '.husky/pre-push'],\n";
|
||||
assert.equal(verifier.split(inventoryEntry).length - 1, 1, 'source inventory fixture drifted');
|
||||
await writeFile(
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
verifier.replace(inventoryEntry, ''),
|
||||
);
|
||||
await copyFile(
|
||||
path.join(root, 'gates', 'required-gates.baseline.json'),
|
||||
path.join(fixture, 'gates', 'required-gates.baseline.json'),
|
||||
);
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
shrinkManifest(manifest, requiredGateId);
|
||||
await writeFile(
|
||||
path.join(fixture, 'gates', 'gates.manifest.json'),
|
||||
`${JSON.stringify(manifest)}\n`,
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
'--root',
|
||||
fixture,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
'--structure-only',
|
||||
],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
assert.notEqual(result.status, 0, 'shrinking source anchor and manifest together must go red');
|
||||
assert.match(output(result), /same-checkout required-gate baseline.*hook-pre-push/i);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('shrinking the same-checkout baseline and manifest together is rejected by verifier inventory', async () => {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-shrink-baseline-manifest-'));
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||
await copyFile(verifierPath, path.join(fixture, 'scripts', 'gate-verify.mjs'));
|
||||
const baseline = JSON.parse(
|
||||
await readFile(path.join(root, 'gates', 'required-gates.baseline.json'), 'utf8'),
|
||||
);
|
||||
baseline.gates = baseline.gates.filter((gate) => gate.id !== requiredGateId);
|
||||
await writeFile(
|
||||
path.join(fixture, 'gates', 'required-gates.baseline.json'),
|
||||
`${JSON.stringify(baseline)}\n`,
|
||||
);
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
shrinkManifest(manifest, requiredGateId);
|
||||
await writeFile(
|
||||
path.join(fixture, 'gates', 'gates.manifest.json'),
|
||||
`${JSON.stringify(manifest)}\n`,
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
'--root',
|
||||
fixture,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
'--structure-only',
|
||||
],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
assert.notEqual(result.status, 0, 'shrinking baseline and manifest together must go red');
|
||||
assert.match(output(result), /verifier inventory.*hook-pre-push.*baseline/i);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('every gate carries an evidence-side subject distinct from its definition', async () => {
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
for (const gate of manifest.gates) {
|
||||
assert.ok(gate.cases.length > 0, `${gate.id} must have consumable evidence`);
|
||||
for (const gateCase of gate.cases) {
|
||||
assert.equal(
|
||||
gateCase.evidence?.subject,
|
||||
gate.id,
|
||||
`${gate.id}/${gateCase.id} must source its subject from the evidence record`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('inventory claim control rejects qualified independence wording', async () => {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-inventory-overclaim-'));
|
||||
const artifacts = [
|
||||
'gates/required-gates.baseline.json',
|
||||
'gates/gates.manifest.json',
|
||||
'docs/PRD.md',
|
||||
'docs/ADMIN-GUIDE/quality-gate-registry.md',
|
||||
'docs/DEVELOPER-GUIDE/quality-gate-registry.md',
|
||||
'docs/remediation/GATE-CLAIMS.md',
|
||||
'docs/plans/2026-08-01-rm-02-gate-registry.md',
|
||||
'docs/SITEMAP.md',
|
||||
];
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
await copyFile(
|
||||
path.join(root, 'scripts', 'gate-inventory-claim-control.mjs'),
|
||||
path.join(fixture, 'scripts', 'gate-inventory-claim-control.mjs'),
|
||||
);
|
||||
for (const relativePath of artifacts) {
|
||||
const target = path.join(fixture, relativePath);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await copyFile(path.join(root, relativePath), target);
|
||||
}
|
||||
const prd = path.join(fixture, 'docs', 'PRD.md');
|
||||
await writeFile(
|
||||
prd,
|
||||
`${await readFile(prd, 'utf8')}\nThis provides an independent seven-gate baseline comparison.\n`,
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(fixture, 'scripts', 'gate-inventory-claim-control.mjs')],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 84, output(result));
|
||||
assert.match(output(result), /INVENTORY_CLAIM_OVERSTATED.*docs\/PRD\.md/i);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('evidence population control depends on production result consumption wiring', async () => {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-evidence-consumer-inert-'));
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||
const verifier = await readFile(verifierPath, 'utf8');
|
||||
const consumer = ` const subjectFailure = consumeEvidenceSubject(gate, result.evidence);\n if (subjectFailure) failures.push(subjectFailure);\n`;
|
||||
assert.equal(verifier.split(consumer).length - 1, 1, 'consumer fixture drifted');
|
||||
await writeFile(
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
verifier.replace(consumer, ''),
|
||||
);
|
||||
await copyFile(
|
||||
path.join(root, 'scripts', 'gate-population-control.mjs'),
|
||||
path.join(fixture, 'scripts', 'gate-population-control.mjs'),
|
||||
);
|
||||
await copyFile(manifestPath, path.join(fixture, 'gates', 'gates.manifest.json'));
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(fixture, 'scripts', 'gate-population-control.mjs'), 'evidence-subject'],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, output(result));
|
||||
assert.match(output(result), /did not reject every registered gate/i);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('production verification has a closed current-tree observation renderer', async () => {
|
||||
const verifier = await readFile(verifierPath, 'utf8');
|
||||
assert.doesNotMatch(verifier, /from ['"]\.\/gate-history\.mjs['"]/);
|
||||
assert.doesNotMatch(verifier, /\bverifyHistory\s*\(/);
|
||||
assert.doesNotMatch(verifier, /history[-_ ]?provenance/i);
|
||||
const attacks = [
|
||||
[
|
||||
'history wording',
|
||||
' /^META-NEGATIVE-CONTROL /,',
|
||||
' /^HISTORY PROVENANCE VERIFIED /,\n /^META-NEGATIVE-CONTROL /,',
|
||||
],
|
||||
[
|
||||
'renamed ancestry wording',
|
||||
' /^META-NEGATIVE-CONTROL /,',
|
||||
' /^COMMIT ANCESTRY VERIFIED /,\n /^META-NEGATIVE-CONTROL /,',
|
||||
],
|
||||
[
|
||||
'provider lineage wording',
|
||||
' /^META-NEGATIVE-CONTROL /,',
|
||||
' /^PROVIDER LINEAGE SUCCESS /,\n /^META-NEGATIVE-CONTROL /,',
|
||||
],
|
||||
['writer assertion bypass', ' assertCurrentTreeObservation(output);\n', ''],
|
||||
[
|
||||
'final success stdout bypass',
|
||||
' writeCurrentTreeOutput(\n `REGISTRY SUMMARY open behavior deltas: ${defects}; required-behavior conformance is not asserted while deltas remain`,\n );',
|
||||
" process.stdout.write('COMMIT ANCESTRY VERIFIED\\n');",
|
||||
],
|
||||
[
|
||||
'direct success stderr bypass',
|
||||
" const defects = observations.filter((line) => line.startsWith('DEFECT ')).length;",
|
||||
" process.stderr.write('PROVIDER LINEAGE SUCCESS\\n');\n const defects = observations.filter((line) => line.startsWith('DEFECT ')).length;",
|
||||
],
|
||||
];
|
||||
for (const [name, find, replace] of attacks) {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-history-renderer-'));
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
assert.equal(verifier.split(find).length - 1, 1, `${name}: fixture drifted`);
|
||||
await writeFile(
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
verifier.replace(find, replace),
|
||||
);
|
||||
await copyFile(
|
||||
path.join(root, 'scripts', 'gate-history-exclusion-control.mjs'),
|
||||
path.join(fixture, 'scripts', 'gate-history-exclusion-control.mjs'),
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(fixture, 'scripts', 'gate-history-exclusion-control.mjs')],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 79, `${name}: ${output(result)}`);
|
||||
assert.match(output(result), /HISTORY_PROVENANCE_FORBIDDEN/);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,785 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmod, copyFile, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
|
||||
const verifier = path.join(process.cwd(), 'scripts', 'gate-verify.mjs');
|
||||
const fixtureRunner = path.join(
|
||||
process.cwd(),
|
||||
'scripts',
|
||||
'test-support',
|
||||
'gate-verify-fixture-runner.mjs',
|
||||
);
|
||||
const fixtureBase = path.join(process.cwd(), '.mosaic-test-work', `gate-verify-${process.pid}`);
|
||||
|
||||
async function fixture(name = 'case') {
|
||||
const root = path.join(fixtureBase, name);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
function baseManifest() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
gateRoots: ['gates'],
|
||||
governingClaimFiles: [],
|
||||
coverageBoundary: { included: ['meta fixture'], excluded: [], trackedBy: 'RM-54' },
|
||||
criteria: [
|
||||
{
|
||||
id: 'META-CRIT-1',
|
||||
originalText: 'The fixture rejects its bad input.',
|
||||
currentText: 'The fixture rejects its bad input.',
|
||||
claimType: 'integrity',
|
||||
source: 'fixture',
|
||||
meaningChanges: [],
|
||||
caseRefs: ['meta-fixture/rejects-bad-input'],
|
||||
},
|
||||
],
|
||||
compatibilityScenarios: [],
|
||||
proseClaims: [],
|
||||
gates: [
|
||||
{
|
||||
id: 'meta-fixture',
|
||||
source: 'gates/meta-fixture.sh',
|
||||
invocation: ['gates/meta-fixture.sh'],
|
||||
deployment: { kind: 'none', reason: 'test fixture only' },
|
||||
inertMutation: {
|
||||
file: 'gates/meta-fixture.sh',
|
||||
find: 'exit 7',
|
||||
replace: 'exit 0',
|
||||
expected: { exitCode: 0 },
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
id: 'rejects-bad-input',
|
||||
criterionIds: ['META-CRIT-1'],
|
||||
mustFail: true,
|
||||
invocation: ['gates/meta-fixture.sh'],
|
||||
required: { exitCode: 7 },
|
||||
actual: { exitCode: 7 },
|
||||
evidence: { subject: 'meta-fixture' },
|
||||
reasonPattern: 'META_REJECT',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function writeGate(root, contents = '#!/bin/sh\necho META_REJECT >&2\nexit 7\n') {
|
||||
const target = path.join(root, 'gates', 'meta-fixture.sh');
|
||||
await writeFile(target, contents);
|
||||
await chmod(target, 0o755);
|
||||
}
|
||||
|
||||
async function writeManifest(root, manifest) {
|
||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), `${JSON.stringify(manifest)}\n`);
|
||||
}
|
||||
|
||||
function verify(root, extraArgs = []) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[fixtureRunner, '--root', root, '--manifest', 'gates/gates.manifest.json', ...extraArgs],
|
||||
{ cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } },
|
||||
);
|
||||
}
|
||||
|
||||
function verifyProductionStructure(root, extraArgs = []) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
verifier,
|
||||
'--root',
|
||||
root,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
'--structure-only',
|
||||
...extraArgs,
|
||||
],
|
||||
{ cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } },
|
||||
);
|
||||
}
|
||||
|
||||
function output(result) {
|
||||
return `${result.stdout}\n${result.stderr}`;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('universally quantified registry checks reject empty populations before evaluation', async () => {
|
||||
const root = await fixture('empty-registry-populations');
|
||||
await mkdir(path.join(root, 'empty-gate-root'), { recursive: true });
|
||||
const manifest = baseManifest();
|
||||
manifest.gateRoots = ['empty-gate-root'];
|
||||
manifest.criteria = [];
|
||||
manifest.proseClaims = [];
|
||||
manifest.compatibilityScenarios = [];
|
||||
manifest.gates = [];
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verifyProductionStructure(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /criteria population.*non-empty.*anchored/i);
|
||||
assert.match(output(result), /gates population.*non-empty.*anchored/i);
|
||||
assert.match(output(result), /proseClaims population.*non-empty.*anchored/i);
|
||||
assert.match(output(result), /compatibilityScenarios population.*non-empty.*anchored/i);
|
||||
});
|
||||
|
||||
test('production verifier exposes no fixture-profile population bypass', async () => {
|
||||
const root = await fixture('no-production-fixture-profile');
|
||||
const result = verifyProductionStructure(root, ['--fixture-profile']);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /unknown option: --fixture-profile/i);
|
||||
});
|
||||
|
||||
test('anchored gate inventory and population criteria cannot shrink together', async () => {
|
||||
const source = JSON.parse(
|
||||
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
|
||||
);
|
||||
const root = await fixture('shrunken-gate-population');
|
||||
await copyFile(
|
||||
path.join(process.cwd(), 'gates', 'required-gates.baseline.json'),
|
||||
path.join(root, 'gates', 'required-gates.baseline.json'),
|
||||
);
|
||||
source.gates = source.gates.filter((gate) => gate.id !== 'hook-pre-push');
|
||||
for (const criterion of source.criteria) {
|
||||
if (criterion.gateRefs) {
|
||||
criterion.gateRefs = criterion.gateRefs.filter((gateId) => gateId !== 'hook-pre-push');
|
||||
}
|
||||
criterion.caseRefs = criterion.caseRefs.filter(
|
||||
(caseRef) => !caseRef.startsWith('hook-pre-push/'),
|
||||
);
|
||||
}
|
||||
await writeManifest(root, source);
|
||||
|
||||
const result = verifyProductionStructure(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(
|
||||
output(result),
|
||||
/same-checkout required-gate baseline rejects manifest drift at hook-pre-push/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('general population criteria cannot delete their gateRefs binding', async () => {
|
||||
const requiredCriteria = [
|
||||
'RM02-EVIDENCE-SUBJECT-BINDING',
|
||||
'RM02-TYPE-STRICT-SCHEMA',
|
||||
'RM02-NONEMPTY-ANCHORED-QUANTIFICATION',
|
||||
];
|
||||
for (const criterionId of requiredCriteria) {
|
||||
const source = JSON.parse(
|
||||
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
|
||||
);
|
||||
const root = await fixture(`missing-gate-refs-${criterionId}`);
|
||||
delete source.criteria.find((criterion) => criterion.id === criterionId).gateRefs;
|
||||
await writeManifest(root, source);
|
||||
const result = verifyProductionStructure(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(
|
||||
output(result),
|
||||
new RegExp(`${criterionId}.*gateRefs.*required`, 'i'),
|
||||
criterionId,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('general population criteria must span every registered gate', async () => {
|
||||
const source = JSON.parse(
|
||||
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
|
||||
);
|
||||
const root = await fixture('incomplete-gate-refs');
|
||||
source.criteria.find((criterion) => criterion.id === 'RM02-EVIDENCE-SUBJECT-BINDING').gateRefs =
|
||||
source.criteria
|
||||
.find((criterion) => criterion.id === 'RM02-EVIDENCE-SUBJECT-BINDING')
|
||||
.gateRefs.filter((gateId) => gateId !== 'quality-lint');
|
||||
await writeManifest(root, source);
|
||||
|
||||
const result = verifyProductionStructure(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(
|
||||
output(result),
|
||||
/RM02-EVIDENCE-SUBJECT-BINDING.*gate population binding is missing quality-lint/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('an externally inerted failure branch makes verification nonzero and names the gate', async () => {
|
||||
const root = await fixture('external-inert');
|
||||
await writeGate(root, '#!/bin/sh\nexit 0\n');
|
||||
await writeManifest(root, baseManifest());
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture/);
|
||||
});
|
||||
|
||||
test('the verifier applies the declared inert mutation and observes its own control red', async () => {
|
||||
const root = await fixture('internal-meta');
|
||||
await writeGate(root);
|
||||
await writeManifest(root, baseManifest());
|
||||
|
||||
const result = verify(root);
|
||||
assert.equal(result.status, 0, output(result));
|
||||
assert.match(output(result), /META-NEGATIVE-CONTROL.*meta-fixture.*observed red/i);
|
||||
});
|
||||
|
||||
test('a mutation crash is rejected instead of counted as an observed-red control', async () => {
|
||||
const root = await fixture('mutation-crash');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].inertMutation.replace = 'this is not valid shell (';
|
||||
manifest.gates[0].inertMutation.expected = { exitCode: 0 };
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*mutation.*unexpected outcome/i);
|
||||
assert.doesNotMatch(output(result), /META-NEGATIVE-CONTROL.*observed red/i);
|
||||
});
|
||||
|
||||
test('a stale declared mutation case id is rejected instead of falling back', async () => {
|
||||
const root = await fixture('stale-case-id');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].inertMutation.caseId = 'case-that-does-not-exist';
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*case-that-does-not-exist.*not found/i);
|
||||
});
|
||||
|
||||
test('duplicate stable ids and unsupported schema versions are rejected', async () => {
|
||||
const root = await fixture('closed-schema');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.schemaVersion = 99;
|
||||
manifest.criteria.push({ ...manifest.criteria[0] });
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /unsupported schemaVersion 99/i);
|
||||
assert.match(output(result), /duplicate criterion id META-CRIT-1/i);
|
||||
});
|
||||
|
||||
test('misspelled nested assertion fields are rejected instead of becoming optional', async () => {
|
||||
const root = await fixture('nested-schema-typo');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].required.outputPatern = 'META_REJECT';
|
||||
manifest.gates[0].cases[0].actual.outputPatern = 'META_REJECT';
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /required.*unknown field outputPatern/i);
|
||||
assert.match(output(result), /actual.*unknown field outputPatern/i);
|
||||
});
|
||||
|
||||
test('present outcome patterns cannot be empty assertion bypasses', async () => {
|
||||
const root = await fixture('nested-schema-empty-patterns');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].required.outputPattern = '';
|
||||
manifest.gates[0].cases[0].actual.notOutputPattern = ' ';
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root, ['--structure-only']);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /required.outputPattern: expected a non-empty pattern/i);
|
||||
assert.match(output(result), /actual.notOutputPattern: expected a non-empty pattern/i);
|
||||
});
|
||||
|
||||
test('nested discriminator and comparison fields reject wrong types', async () => {
|
||||
const root = await fixture('nested-schema-types');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].mustFail = 'true';
|
||||
manifest.gates[0].cases[0].required.exitCode = '7';
|
||||
manifest.gates[0].cases[0].actual.outputPattern = 7;
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root, ['--structure-only']);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /mustFail: expected a boolean/i);
|
||||
assert.match(output(result), /required.exitCode: expected an integer/i);
|
||||
assert.match(output(result), /actual.outputPattern: expected a string/i);
|
||||
});
|
||||
|
||||
test('recursive closed-schema guards reject unknown fields in every nested assertion object', async () => {
|
||||
const source = JSON.parse(
|
||||
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
|
||||
);
|
||||
const checkout = source.gates.find((gate) => gate.id === 'checkout-preflight');
|
||||
const stale = checkout.cases.find((gateCase) => gateCase.id === 'stale-build-lock');
|
||||
const queue = source.gates.find((gate) => gate.id === 'ci-queue-wait');
|
||||
const queueCase = queue.cases.find((gateCase) => gateCase.id === 'terminal-success');
|
||||
const targets = [
|
||||
['coverageBoundary', (manifest) => manifest.coverageBoundary],
|
||||
['mergeAssertions', (manifest) => manifest.mergeAssertions],
|
||||
[
|
||||
'meaningChanges',
|
||||
(manifest) =>
|
||||
manifest.criteria.find((criterion) => criterion.meaningChanges.length).meaningChanges[0],
|
||||
],
|
||||
['proseClaims', (manifest) => manifest.proseClaims[0]],
|
||||
['compatibility expected', (manifest) => manifest.compatibilityScenarios[0].expected],
|
||||
[
|
||||
'deployment',
|
||||
(manifest) => manifest.gates.find((gate) => gate.id === 'ci-queue-wait').deployment,
|
||||
],
|
||||
['inertMutation', (manifest) => manifest.gates[0].inertMutation],
|
||||
['inert expected', (manifest) => manifest.gates[0].inertMutation.expected],
|
||||
['required', (manifest) => manifest.gates[0].cases[0].required],
|
||||
['actual', (manifest) => manifest.gates[0].cases[0].actual],
|
||||
[
|
||||
'fixture',
|
||||
(manifest) =>
|
||||
manifest.gates
|
||||
.find((gate) => gate.id === 'checkout-preflight')
|
||||
.cases.find((gateCase) => gateCase.id === 'stale-build-lock').fixture,
|
||||
],
|
||||
[
|
||||
'write entry',
|
||||
(manifest) =>
|
||||
manifest.gates
|
||||
.find((gate) => gate.id === 'checkout-preflight')
|
||||
.cases.find((gateCase) => gateCase.id === 'stale-build-lock').fixture.writeFiles[0],
|
||||
],
|
||||
[
|
||||
'replace entry',
|
||||
(manifest) =>
|
||||
manifest.gates
|
||||
.find((gate) => gate.id === 'checkout-preflight')
|
||||
.cases.find((gateCase) => gateCase.id === 'criterion-misbinding').fixture.replaceFiles[0],
|
||||
],
|
||||
[
|
||||
'defect',
|
||||
(manifest) =>
|
||||
manifest.gates
|
||||
.find((gate) => gate.id === 'ci-queue-wait')
|
||||
.cases.find((gateCase) => gateCase.id === 'terminal-success').defect,
|
||||
],
|
||||
];
|
||||
assert.ok(stale.fixture && queueCase.defect);
|
||||
for (const [name, select] of targets) {
|
||||
const root = await fixture(`recursive-${name.replaceAll(' ', '-')}`);
|
||||
const manifest = structuredClone(source);
|
||||
select(manifest).unexpectedNestedField = true;
|
||||
await writeManifest(root, manifest);
|
||||
const result = verify(root, ['--structure-only']);
|
||||
assert.notEqual(result.status, 0, `${name}: ${output(result)}`);
|
||||
assert.match(output(result), /unknown field unexpectedNestedField/i, name);
|
||||
}
|
||||
});
|
||||
|
||||
test('manifest-controlled fixture paths cannot escape the sandbox', async () => {
|
||||
const root = await fixture('path-traversal');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].fixture = {
|
||||
writeFiles: [{ path: '../../escaped-by-manifest', content: 'bad' }],
|
||||
};
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /path escapes sandbox/i);
|
||||
});
|
||||
|
||||
test('fixture writes reject a final symlink and preserve its outside target', async () => {
|
||||
const root = await fixture('final-symlink');
|
||||
await writeGate(root);
|
||||
const outside = path.join(fixtureBase, 'outside-sentinel');
|
||||
await writeFile(outside, 'preserve-me\n');
|
||||
const linked = path.join(root, 'linked-sentinel');
|
||||
await symlink(outside, linked);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].fixture = {
|
||||
writeFiles: [{ path: 'linked-sentinel', content: 'overwritten\n' }],
|
||||
};
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /fixture write.*symbolic link/i);
|
||||
assert.equal(await readFile(outside, 'utf8'), 'preserve-me\n');
|
||||
});
|
||||
|
||||
test('an executable below a gate root without an entry is rejected', async () => {
|
||||
const root = await fixture('unregistered');
|
||||
await writeGate(root);
|
||||
const extra = path.join(root, 'gates', 'forgotten.sh');
|
||||
await writeFile(extra, '#!/bin/sh\nexit 1\n');
|
||||
await chmod(extra, 0o755);
|
||||
await writeManifest(root, baseManifest());
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /unregistered gate.*forgotten\.sh/i);
|
||||
});
|
||||
|
||||
test('a must-fail case without a reason diagnostic is rejected', async () => {
|
||||
const root = await fixture('missing-reason');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].reasonPattern = '';
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*rejects-bad-input.*reasonPattern/i);
|
||||
});
|
||||
|
||||
test('a gate with zero must-fail cases is rejected', async () => {
|
||||
const root = await fixture('no-negative');
|
||||
await writeGate(root, '#!/bin/sh\nexit 0\n');
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].inertMutation = {
|
||||
file: 'gates/meta-fixture.sh',
|
||||
find: 'exit 0',
|
||||
replace: 'exit 1',
|
||||
};
|
||||
manifest.gates[0].cases = [
|
||||
{
|
||||
id: 'positive',
|
||||
criterionIds: ['META-CRIT-1'],
|
||||
mustFail: false,
|
||||
invocation: ['gates/meta-fixture.sh'],
|
||||
required: { exitCode: 0 },
|
||||
actual: { exitCode: 0 },
|
||||
reasonPattern: '',
|
||||
},
|
||||
];
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*no negative control/i);
|
||||
});
|
||||
|
||||
test('reordered but equivalent outcome fields do not create a false behavior delta', async () => {
|
||||
const root = await fixture('reordered-outcomes');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].required = { exitCode: 7, outputPattern: 'META_REJECT' };
|
||||
manifest.gates[0].cases[0].actual = { outputPattern: 'META_REJECT', exitCode: 7 };
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.equal(result.status, 0, output(result));
|
||||
assert.doesNotMatch(output(result), /behavior delta requires|DEFECT \(owner:/);
|
||||
});
|
||||
|
||||
test('a required-versus-actual delta without a tracked owner is rejected', async () => {
|
||||
const root = await fixture('ownerless-delta');
|
||||
await writeGate(root, '#!/bin/sh\nexit 0\n');
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].inertMutation.find = 'exit 0';
|
||||
manifest.gates[0].inertMutation.replace = 'exit 7';
|
||||
manifest.gates[0].cases[0].actual.exitCode = 0;
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*delta.*tracked owner/i);
|
||||
});
|
||||
|
||||
test('moving criterion bindings to unrelated cases is rejected', async () => {
|
||||
const root = await fixture('semantic-misbinding');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.criteria.push({
|
||||
id: 'META-CRIT-2',
|
||||
originalText: 'The fixture reports the second rejection reason.',
|
||||
currentText: 'The fixture reports the second rejection reason.',
|
||||
claimType: 'integrity',
|
||||
source: 'fixture',
|
||||
meaningChanges: [],
|
||||
caseRefs: ['meta-fixture/rejects-second-input'],
|
||||
});
|
||||
manifest.gates[0].cases.push({
|
||||
...manifest.gates[0].cases[0],
|
||||
id: 'rejects-second-input',
|
||||
criterionIds: ['META-CRIT-1'],
|
||||
});
|
||||
manifest.gates[0].cases[0].criterionIds = ['META-CRIT-2'];
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /META-CRIT-1.*rejects-bad-input.*not bound/i);
|
||||
assert.match(output(result), /META-CRIT-2.*rejects-second-input.*not bound/i);
|
||||
});
|
||||
|
||||
test('canonical verification preserves binding diagnostics when a case fixture is also stale', async () => {
|
||||
const root = await fixture('misbinding-plus-stale-fixture');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.criteria.push({
|
||||
id: 'META-CRIT-2',
|
||||
originalText: 'The second case rejects its own bad input.',
|
||||
currentText: 'The second case rejects its own bad input.',
|
||||
claimType: 'integrity',
|
||||
source: 'fixture',
|
||||
meaningChanges: [],
|
||||
caseRefs: ['meta-fixture/rejects-second-input'],
|
||||
});
|
||||
manifest.gates[0].cases.push({
|
||||
...manifest.gates[0].cases[0],
|
||||
id: 'rejects-second-input',
|
||||
criterionIds: ['META-CRIT-1'],
|
||||
});
|
||||
manifest.gates[0].cases[0].criterionIds = ['META-CRIT-2'];
|
||||
manifest.gates[0].cases[0].fixture = {
|
||||
replaceFiles: [
|
||||
{
|
||||
path: 'gates/meta-fixture.sh',
|
||||
find: 'text that is not present',
|
||||
replace: 'irrelevant',
|
||||
},
|
||||
],
|
||||
};
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /META-CRIT-1.*rejects-bad-input.*not bound/i);
|
||||
assert.match(output(result), /META-CRIT-2.*rejects-second-input.*not bound/i);
|
||||
assert.match(output(result), /fixture replace.*stale or ambiguous/i);
|
||||
});
|
||||
|
||||
test('moving meaning and prose criteria to an unrelated type error is rejected', async () => {
|
||||
const root = await fixture('real-manifest-misbinding');
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
|
||||
);
|
||||
for (const gate of manifest.gates) {
|
||||
for (const gateCase of gate.cases) {
|
||||
gateCase.criterionIds = gateCase.criterionIds.filter(
|
||||
(id) => !['RM02-MEANING-PROVENANCE', 'RM02-PROSE-CONTROL'].includes(id),
|
||||
);
|
||||
}
|
||||
}
|
||||
const typeError = manifest.gates
|
||||
.find((gate) => gate.id === 'quality-typecheck')
|
||||
.cases.find((gateCase) => gateCase.id === 'type-error');
|
||||
typeError.criterionIds.push('RM02-MEANING-PROVENANCE', 'RM02-PROSE-CONTROL');
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root, ['--structure-only']);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /RM02-MEANING-PROVENANCE.*missing-meaning-provenance.*not bound/i);
|
||||
assert.match(output(result), /RM02-PROSE-CONTROL.*prose-claim-misbinding.*not bound/i);
|
||||
assert.match(
|
||||
output(result),
|
||||
/RM02-(?:MEANING-PROVENANCE|PROSE-CONTROL).*undeclared exercising case quality-typecheck\/type-error/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('a criterion with no bound case is rejected', async () => {
|
||||
const root = await fixture('unbound-criterion');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.criteria.push({
|
||||
id: 'ORPHAN',
|
||||
originalText: 'This criterion is not exercised.',
|
||||
currentText: 'This criterion is not exercised.',
|
||||
claimType: 'quality',
|
||||
source: 'fixture',
|
||||
meaningChanges: [],
|
||||
});
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /ORPHAN.*no bound case/i);
|
||||
});
|
||||
|
||||
test('an unbound governing prose marker is rejected', async () => {
|
||||
const root = await fixture('unbound-prose');
|
||||
await writeGate(root);
|
||||
await mkdir(path.join(root, 'docs'), { recursive: true });
|
||||
await writeFile(path.join(root, 'docs', 'governing.md'), 'GATE-CLAIM:UNBOUND\n');
|
||||
const manifest = baseManifest();
|
||||
manifest.governingClaimFiles = ['docs/governing.md'];
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /GATE-CLAIM:UNBOUND.*unbound/i);
|
||||
});
|
||||
|
||||
test('directly contradictory modeled scenarios are rejected', async () => {
|
||||
const root = await fixture('conflict');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.compatibilityScenarios = [
|
||||
{
|
||||
id: 'ONE',
|
||||
construction: 'same-input',
|
||||
caseRefs: ['meta-fixture/rejects-bad-input'],
|
||||
invocation: ['sh', '-c', 'exit 0'],
|
||||
expected: { exitCode: 0 },
|
||||
},
|
||||
{
|
||||
id: 'TWO',
|
||||
construction: 'same-input',
|
||||
caseRefs: ['meta-fixture/rejects-bad-input'],
|
||||
invocation: ['sh', '-c', 'exit 1'],
|
||||
expected: { exitCode: 1 },
|
||||
},
|
||||
];
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /ONE.*TWO.*conflict/i);
|
||||
});
|
||||
|
||||
test('compatibility scenarios execute referenced conditions as one construction', async () => {
|
||||
const root = await fixture('combined-compatibility');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases.push({
|
||||
id: 'second-condition',
|
||||
criterionIds: ['META-CRIT-1'],
|
||||
mustFail: true,
|
||||
invocation: ['sh', '-c', 'echo "$SECOND_REASON" >&2; exit 7'],
|
||||
fixture: { writeFiles: [{ path: 'conditions/second', content: 'present\n' }] },
|
||||
required: { exitCode: 7 },
|
||||
actual: { exitCode: 7 },
|
||||
evidence: { subject: 'meta-fixture' },
|
||||
reasonPattern: 'SECOND_REASON',
|
||||
environment: { SECOND_REASON: 'SECOND_REASON' },
|
||||
});
|
||||
manifest.criteria[0].caseRefs.push('meta-fixture/second-condition');
|
||||
manifest.gates[0].cases[0].fixture = {
|
||||
writeFiles: [{ path: 'conditions/first', content: 'present\n' }],
|
||||
};
|
||||
manifest.compatibilityScenarios = [
|
||||
{
|
||||
id: 'BOTH-CONDITIONS',
|
||||
construction: 'both-fixtures',
|
||||
caseRefs: ['meta-fixture/rejects-bad-input', 'meta-fixture/second-condition'],
|
||||
invocation: ['sh', '-c', 'test -f conditions/first && test -f conditions/second'],
|
||||
expected: { exitCode: 0 },
|
||||
},
|
||||
];
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.equal(result.status, 0, output(result));
|
||||
assert.match(output(result), /COMPATIBILITY BOTH-CONDITIONS: observed expected outcome/i);
|
||||
});
|
||||
|
||||
test('a restated criterion without provenance is rejected', async () => {
|
||||
const root = await fixture('provenance');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.criteria[0].currentText = 'The fixture rejects only malformed input.';
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /META-CRIT-1.*meaning-change provenance/i);
|
||||
});
|
||||
|
||||
test('a security criterion bound only to a positive case is unregistered in substance', async () => {
|
||||
const root = await fixture('positive-only-criterion');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.criteria.push({
|
||||
id: 'POSITIVE-ONLY',
|
||||
originalText: 'A security property.',
|
||||
currentText: 'A security property.',
|
||||
claimType: 'security',
|
||||
source: 'fixture',
|
||||
meaningChanges: [],
|
||||
});
|
||||
manifest.gates[0].cases.push({
|
||||
id: 'positive-only',
|
||||
criterionIds: ['POSITIVE-ONLY'],
|
||||
mustFail: false,
|
||||
invocation: ['gates/meta-fixture.sh'],
|
||||
required: { exitCode: 7 },
|
||||
actual: { exitCode: 7 },
|
||||
reasonPattern: '',
|
||||
});
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /POSITIVE-ONLY.*no must-fail case/i);
|
||||
});
|
||||
|
||||
test('a registered prose claim whose marker is absent is rejected', async () => {
|
||||
const root = await fixture('missing-prose-marker');
|
||||
await writeGate(root);
|
||||
await mkdir(path.join(root, 'docs'), { recursive: true });
|
||||
await writeFile(path.join(root, 'docs', 'governing.md'), 'No marker here.\n');
|
||||
const manifest = baseManifest();
|
||||
manifest.governingClaimFiles = ['docs/governing.md'];
|
||||
manifest.proseClaims = [{ id: 'MISSING-MARKER', criterionId: 'META-CRIT-1' }];
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /GATE-CLAIM:MISSING-MARKER.*missing/i);
|
||||
});
|
||||
|
||||
test('source-versus-deployed byte drift is rejected and names the gate', async () => {
|
||||
const root = await fixture('deployment-drift');
|
||||
await writeGate(root);
|
||||
await mkdir(path.join(root, 'deployed'), { recursive: true });
|
||||
await writeFile(path.join(root, 'deployed', 'meta-fixture.sh'), '#!/bin/sh\nexit 0\n');
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].deployment = { kind: 'file', path: 'deployed/meta-fixture.sh' };
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*source.*deployed.*drift/i);
|
||||
});
|
||||
|
||||
test('deployment drift meta-control fails if the shared comparator is made inert', async () => {
|
||||
const root = await fixture('deployment-comparator-inert');
|
||||
await writeGate(root);
|
||||
await mkdir(path.join(root, 'deployed'), { recursive: true });
|
||||
await copyFile(
|
||||
path.join(root, 'gates', 'meta-fixture.sh'),
|
||||
path.join(root, 'deployed', 'meta-fixture.sh'),
|
||||
);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].deployment = { kind: 'file', path: 'deployed/meta-fixture.sh' };
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const alteredScripts = path.join(root, 'verifier-scripts');
|
||||
await mkdir(alteredScripts, { recursive: true });
|
||||
const verifierSource = await readFile(verifier, 'utf8');
|
||||
const inertSource = verifierSource.replace(
|
||||
'return source.equals(deployed);',
|
||||
'return true; // deliberate test-only inert comparator',
|
||||
);
|
||||
assert.notEqual(inertSource, verifierSource, 'shared deployment comparator mutation went stale');
|
||||
await writeFile(path.join(alteredScripts, 'gate-verify.mjs'), inertSource);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(alteredScripts, 'gate-verify.mjs'),
|
||||
'--root',
|
||||
root,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
],
|
||||
{ cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } },
|
||||
);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*drift negative control was ineffective/i);
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = process.cwd();
|
||||
const expectedTriggers = `when:
|
||||
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
|
||||
# push CI is restricted to protected branches (main) so a feature-branch push no
|
||||
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
|
||||
# CI load on the storage-constrained runner with zero loss of gating (branch
|
||||
# protection requires no push/ci status context; main still gets full push CI).
|
||||
- event: [pull_request, manual]
|
||||
- event: push
|
||||
branch: main`;
|
||||
const expectedGateStep = ` image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm gate:verify
|
||||
depends_on:
|
||||
- install
|
||||
- sanitization
|
||||
- upgrade-guard`;
|
||||
|
||||
export function assertUnprivilegedGateStep(pipeline) {
|
||||
assert.doesNotMatch(
|
||||
pipeline,
|
||||
/privileged/i,
|
||||
'no pull-request pipeline step may declare privilege',
|
||||
);
|
||||
for (const line of pipeline.split('\n')) {
|
||||
const candidate = line.trimStart().replace(/^-\s+/, '');
|
||||
if (/^(?:["'!<].*|[A-Za-z_][A-Za-z0-9_-]*\s+):(?:\s|$)/.test(candidate)) {
|
||||
assert.fail(`non-canonical or merged YAML key is forbidden: ${candidate}`);
|
||||
}
|
||||
}
|
||||
const triggerMatches = [...pipeline.matchAll(/^when:\n([\s\S]*?)(?=\n\n)/gm)];
|
||||
assert.equal(triggerMatches.length, 1, 'exactly one top-level trigger is required');
|
||||
assert.equal(
|
||||
`when:\n${triggerMatches[0][1].trimEnd()}`,
|
||||
expectedTriggers,
|
||||
'top-level triggers must match closed PR/main construction',
|
||||
);
|
||||
|
||||
const matches = [
|
||||
...pipeline.matchAll(/\n gate-verify:\n([\s\S]*?)(?=\n [a-z][a-z0-9-]+:|\nservices:|$)/g),
|
||||
];
|
||||
assert.equal(matches.length, 1, 'exactly one gate-verify step is required');
|
||||
// Closed textual construction by design: accepting arbitrary YAML syntax here
|
||||
// would require a duplicate-key-preserving parser. Exact equality rejects all
|
||||
// extra keys, quoted/escaped key spellings, aliases, and mapping merges.
|
||||
assert.equal(
|
||||
matches[0][1].trimEnd(),
|
||||
expectedGateStep,
|
||||
'gate-verify step must match closed unprivileged construction',
|
||||
);
|
||||
}
|
||||
|
||||
test('package.json exposes the canonical gate:verify command', async () => {
|
||||
const packageJson = JSON.parse(await readFile(`${root}/package.json`, 'utf8'));
|
||||
assert.equal(packageJson.scripts['gate:verify'], 'node scripts/gate-verify.mjs');
|
||||
});
|
||||
|
||||
test('Woodpecker runs the closed unprivileged gate construction on every pipeline', async () => {
|
||||
const pipeline = await readFile(`${root}/.woodpecker/ci.yml`, 'utf8');
|
||||
assertUnprivilegedGateStep(pipeline);
|
||||
});
|
||||
|
||||
test('gate wiring rejects privilege syntax, merges, duplicate keys, and trigger narrowing', async () => {
|
||||
const pipeline = await readFile(`${root}/.woodpecker/ci.yml`, 'utf8');
|
||||
const additions = [
|
||||
' privileged: *enabled\n',
|
||||
' "privileged": true\n',
|
||||
" 'privileged': true\n",
|
||||
' privileged : true\n',
|
||||
' "priv\\u0069leged": true\n',
|
||||
' <<: *privileged-step\n',
|
||||
' "<<": *privileged-step\n',
|
||||
];
|
||||
for (const addition of additions) {
|
||||
const changed = pipeline.replace(
|
||||
' gate-verify:\n image:',
|
||||
` gate-verify:\n${addition} image:`,
|
||||
);
|
||||
assert.throws(() => assertUnprivilegedGateStep(changed));
|
||||
}
|
||||
const privilegedInstall = pipeline.replace(
|
||||
' install:\n image:',
|
||||
' install:\n privileged: true\n image:',
|
||||
);
|
||||
const duplicate = `${pipeline}\n gate-verify:\n image: *node_image\n`;
|
||||
const noPullRequest = pipeline.replace(' - event: [pull_request, manual]', ' - event: manual');
|
||||
const filteredPullRequest = pipeline.replace(
|
||||
' - event: [pull_request, manual]',
|
||||
' - event: [pull_request, manual]\n path: [scripts/**]',
|
||||
);
|
||||
|
||||
assert.throws(() => assertUnprivilegedGateStep(privilegedInstall), /privilege/i);
|
||||
assert.throws(() => assertUnprivilegedGateStep(duplicate), /exactly one gate-verify/);
|
||||
assert.throws(() => assertUnprivilegedGateStep(noPullRequest), /closed PR\/main construction/);
|
||||
assert.throws(
|
||||
() => assertUnprivilegedGateStep(filteredPullRequest),
|
||||
/closed PR\/main construction/,
|
||||
);
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
import { verifyRegistry } from '../gate-verify.mjs';
|
||||
|
||||
let root;
|
||||
let manifest = 'gates/gates.manifest.json';
|
||||
let structureOnly = false;
|
||||
for (let index = 0; index < process.argv.slice(2).length; index += 1) {
|
||||
const args = process.argv.slice(2);
|
||||
const value = args[index];
|
||||
if (value === '--root') root = path.resolve(args[++index]);
|
||||
else if (value === '--manifest') manifest = args[++index];
|
||||
else if (value === '--structure-only') structureOnly = true;
|
||||
else throw new Error(`unknown fixture-runner option: ${value}`);
|
||||
}
|
||||
if (!root) throw new Error('fixture runner requires --root');
|
||||
const { failures, observations } = await verifyRegistry({
|
||||
root,
|
||||
manifest,
|
||||
structureOnly,
|
||||
fixtureProfile: true,
|
||||
});
|
||||
for (const observation of observations) process.stdout.write(`${observation}\n`);
|
||||
for (const failure of failures) process.stderr.write(`GATE VERIFY FAILED: ${failure}\n`);
|
||||
if (failures.length > 0) process.exitCode = 1;
|
||||
Reference in New Issue
Block a user