Compare commits

..
Author SHA1 Message Date
be-coder-06andcoder-mos1 e56f5947ac test(fleet): bound stale heartbeat observation (#1043)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-06 02:20:34 -05:00
be-coder-06andcoder-mos1 d52a3ecf25 test(fleet): make stale marker BusyBox-safe (#1043) 2026-08-06 02:20:34 -05:00
be-coder-06andcoder-mos1 5fde2da592 test(fleet): carry identity regression in CI (#1043) 2026-08-06 02:20:34 -05:00
be-coder-06andcoder-mos1 821eb80b91 fix(fleet): propagate roster git identity (#1043) 2026-08-06 02:20:05 -05:00
be-coder-08andMos 80a45b1e1c feat(pr-merge): preserve linked authors in squash messages (#1066)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: be-coder-08 <[email protected]>
2026-08-06 05:36:59 +00:00
64 changed files with 1560 additions and 11857 deletions
+30 -53
View File
@@ -146,6 +146,36 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR. are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
### Fleet git identity launch propagation (#1043)
#### Problem and objective
A fleet seat can have a registered per-agent Git credential while its launched runtime process lacks
`MOSAIC_GIT_IDENTITY`. The credential resolver then cannot select the seat identity reliably, which
blocks repository operations on fail-closed estates and can fall through to an unrelated identity on
estates where that refusal is not active. The objective is to make Git identity a deterministic,
roster-derived part of the generated launch projection and prove it reaches the launched process.
#### Normative requirements
1. `FGI-REQ-01`: Every generated fleet agent projection SHALL declare
`MOSAIC_GIT_IDENTITY=<MOSAIC_AGENT_NAME>`; a differing or unsafe identity SHALL fail closed before
tmux launch.
2. `FGI-REQ-02`: The clean `/usr/bin/env -i` pane boundary SHALL pass every variable declared by the
generated projection, including `MOSAIC_GIT_IDENTITY`, to the launched runtime process.
3. `FGI-REQ-03`: A behavioral integration test SHALL set-compare the complete generated projection
against the launched process environment. Source-text/string-presence assertions are insufficient.
4. `FGI-REQ-04`: Verification SHALL include RED-first evidence and a delete-the-subject mutation that
removes Git-identity pane propagation and makes the behavioral test fail.
#### Acceptance criteria
1. `AC-FGI-01`: A launched seat process contains every key/value pair declared by its generated
environment projection, including the roster-derived Git identity.
2. `AC-FGI-02`: Missing, unsafe, or split Git identity is rejected before a tmux session is created.
3. `AC-FGI-03`: Focused launcher and generated-environment tests, repository quality gates,
independent review, and the required RED/green/R7 evidence are recorded before push.
--- ---
## Exact Cross-Harness Fleet Communications Contract (#766) ## Exact Cross-Harness Fleet Communications Contract (#766)
@@ -437,59 +467,6 @@ 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 ## Architecture
### High-Level System Diagram ### High-Level System Diagram
-204
View File
@@ -1,204 +0,0 @@
# `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.
+13 -10
View File
@@ -5,14 +5,14 @@ Generated environment files are rebuildable projections, not an operator-editabl
## Launch chain ## Launch chain
| Layer | Responsibility | | Layer | Responsibility |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. | | Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket; Git identity is derived from the exact agent name. |
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. | | Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. | | Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. | | systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. | | session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. | | runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing, command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
@@ -24,6 +24,7 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
```dotenv ```dotenv
MOSAIC_AGENT_NAME=<roster name> MOSAIC_AGENT_NAME=<roster name>
MOSAIC_GIT_IDENTITY=<roster name>
MOSAIC_AGENT_CLASS=<roster class> MOSAIC_AGENT_CLASS=<roster class>
MOSAIC_AGENT_RUNTIME=<roster runtime> MOSAIC_AGENT_RUNTIME=<roster runtime>
MOSAIC_AGENT_MODEL=<roster model hint> MOSAIC_AGENT_MODEL=<roster model hint>
@@ -33,8 +34,10 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
MOSAIC_TMUX_SOCKET=<roster socket or empty> MOSAIC_TMUX_SOCKET=<roster socket or empty>
``` ```
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add `MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state. split runtime and repository identity authority. The generated launch contract supports `claude`,
`codex`, `opencode`, and `pi`. mosaic fleet add rejects another runtime before it writes the roster or
modifies generated, local, or quarantine state.
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket; The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
it has no generated-launch adapter and cannot be added through this path. it has no generated-launch adapter and cannot be added through this path.
@@ -3,11 +3,12 @@
The launcher consumes validated data, not shell configuration. The launcher consumes validated data, not shell configuration.
1. Read and validate the canonical roster. 1. Read and validate the canonical roster.
2. Render deterministic <name>.env.generated data from that roster. 2. Render deterministic <name>.env.generated data from that roster, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name.
3. Parse optional <name>.env.local through a strict allowlist. 3. Parse optional <name>.env.local through a strict allowlist.
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides. 4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
5. Derive the runtime command from validated runtime/model/reasoning data. 5. Reject a Git identity that is unsafe or differs from the generated agent name.
6. Target only the exact configured tmux socket and roster session after ownership checks. 6. Derive the runtime command from validated runtime/model/reasoning data and pass every generated projection entry through the clean process environment boundary.
7. Target only the exact configured tmux socket and roster session after ownership checks.
## File precedence and ownership ## File precedence and ownership
@@ -35,6 +35,7 @@ values, credential material, or command text.
```dotenv ```dotenv
MOSAIC_AGENT_NAME=<roster name> MOSAIC_AGENT_NAME=<roster name>
MOSAIC_GIT_IDENTITY=<roster name>
MOSAIC_AGENT_CLASS=<roster class> MOSAIC_AGENT_CLASS=<roster class>
MOSAIC_AGENT_RUNTIME=<roster runtime> MOSAIC_AGENT_RUNTIME=<roster runtime>
MOSAIC_AGENT_MODEL=<roster model hint> MOSAIC_AGENT_MODEL=<roster model hint>
@@ -44,8 +45,9 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
MOSAIC_TMUX_SOCKET=<roster socket or empty> MOSAIC_TMUX_SOCKET=<roster socket or empty>
``` ```
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add `MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate
uses that same runtime authority and rejects any other runtime before it writes the roster or changes operator-controlled identity authority. The generated launch contract supports only `claude`, `codex`,
`opencode`, and `pi`. fleet add uses that same runtime authority and rejects any other runtime before it writes the roster or changes
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory` projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
socket remains an observability canary; it has no generated-launch adapter and cannot be added through socket remains an observability canary; it has no generated-launch adapter and cannot be added through
this projection path. this projection path.
+229
View File
@@ -0,0 +1,229 @@
# #1043 — Fleet pane git-identity propagation
## Objective
Ensure a fleet seat's launched runtime process receives its roster-derived `MOSAIC_GIT_IDENTITY`, and lock the complete generated-environment propagation boundary with an enumerated set comparison.
## Tracking
- External issue: `mosaicstack/stack#1043`
- Branch: `fix/1043-pane-git-identity`
- Coordinator: `tl-mosaic`
- `docs/TASKS.md`: read-only by project worker contract; not modified.
## Constraints
- RED-first bug reproducer is mandatory.
- R7 delete-the-subject mutation must turn the behavioral test red.
- Assert launched-process environment, not source text.
- One push only; do not poll CI after push.
- Run the CI queue guard immediately before push and report its `state=` line as state, not evidence.
- Do not modify a live host launcher or obtain/copy another credential.
- Self-post the PR, verify provider attribution, then stop.
- Final status wording: `believed-fixed, pending jarvis validation`.
## Scope inventory
Re-derived against `origin/main` at `85d2108e`:
- Launch consumer: `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
- Behavioral launch test: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
- Generated-environment contract/parser: `packages/mosaic/src/fleet/generated-env-boundary.ts`
- Roster projection producers:
- `packages/mosaic/src/commands/fleet.ts`
- `packages/mosaic/src/fleet/fleet-reconciler.ts`
- `packages/mosaic/src/fleet/fleet-agent-crud.ts`
- `packages/mosaic/src/fleet/v1-v2-migration.ts`
- Contract and producer tests discovered by repository search.
- Generated-environment operator/developer docs and their executable documentation contract test.
Discrepancy sent to `tl-mosaic`: current main no longer contains the charter's `PANE_SHELL_SNIPPET`; #772 replaced it with an `/usr/bin/env -i` argv launch boundary, and current generated projections do not declare git identity. Code-read inventory is **NOT MEASURED** behavior.
## Plan
1. Add the process-environment set-comparison regression first and record RED.
2. Add roster-derived `MOSAIC_GIT_IDENTITY=<agent name>` to the complete generated projection contract.
3. Validate identity syntax and equality with `MOSAIC_AGENT_NAME`; pass it through the clean pane environment.
4. Update affected projection tests and generated-environment docs.
5. Run focused and baseline gates.
6. Perform R7 by deleting the pane propagation entry, prove RED, restore, and prove GREEN.
7. Run independent review, remediate, commit, queue guard, one push, self-post PR, verify provider attribution, and stop without CI polling.
## Budget
No explicit token cap was provided. Working cap: one narrow logical unit, no dependency installation unless existing tooling requires it, no unrelated refactor.
## Evidence log
### TDD and mutation evidence
- RED-first, repository launcher: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` exited 64 on pre-fix source with `code=unknown-key key=MOSAIC_GIT_IDENTITY`. The generated seat could not launch with the required declared identity.
- GREEN: the same repository launcher test emitted `ok - start-agent-session generated environment boundary`.
- R7 delete-the-subject: removed only `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"` from the repository launch array; the same test exited 1 with `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
- R7 restoration: restored that launch entry; the same test returned green.
- Launcher under test is explicitly `packages/mosaic/framework/tools/fleet/start-agent-session.sh` through the test's `$START`, **not** the stale installed host copy.
### Situational and focused tests
- Repository launcher boundary: green, including set comparison of all nine generated projection entries and fail-before-tmux cases for missing, unsafe, mismatched, and local-shadow Git identity.
- Fleet systemd launcher integration: `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — green.
- Focused Mosaic Vitest set: 6 files, 311 tests — green.
- `bash -n` on changed shell files — green.
- `git diff --check` — green.
### Baseline gates
- `pnpm typecheck` — 45/45 tasks green.
- `pnpm lint` — 25/25 tasks green.
- `pnpm format:check` — green.
- `pnpm test:checkout` — green.
- Repository-wide Vitest under a hermetic current-version npm prefix: Mosaic 81/81 files and 1510/1510 tests green; other workspace test tasks shown green before the framework-shell phase.
- Canonical `pnpm test` is not fully green on this host for unrelated environment-sensitive gates:
1. the first two runs exposed the globally installed Mosaic 0.0.48 update banner in three CLI smoke tests expecting empty stderr;
2. after isolating that global-version input, the framework wake assertion aborted at the known `#973` Bash `BASH_LINENO` convention check (exit 97; observed `[3 5]`, expected `[3 4]`).
No tests were weakened or bypassed; focused changed-surface tests are green. CI remains the canonical clean-environment result and is intentionally not polled after push per charter.
### Independent review
- Codex code review first pass: request changes for missing shell rejection-path coverage.
- Remediation: added table-driven missing/unsafe/mismatch/local-shadow launcher cases, each asserting no tmux call.
- Codex code re-review: **approve**, no findings, confidence 0.88.
- Codex security review: risk `none`, no findings, confidence 0.97.
### Acceptance criteria mapping
| Acceptance criterion | Evidence |
| --- | --- |
| AC-FGI-01: launched process receives every generated key/value | Repository launcher process-environment `comm -23` set comparison; GREEN and R7 RED evidence above |
| AC-FGI-02: missing, unsafe, or split identity fails before tmux | Table-driven shell cases plus TypeScript generated-boundary tests |
| AC-FGI-03: focused/baseline/review evidence recorded | Commands and review outcomes above; host-sensitive full-suite limitations stated explicitly |
### Documentation checklist
- PRD updated with #1043 requirements and acceptance criteria.
- Fleet launch runbook, generated-env concept, and generated-env reference updated.
- No API/OpenAPI, sitemap, user publishing target, deployment, or external docs publication change applies.
- `docs/TASKS.md` remains unmodified per its single-writer project contract.
## Round 2 — PR #1073 review 97 remediation
### Review blocker
The launched-process suite was signed-excluded from CI enumeration. Manual GREEN/R7 evidence therefore did not prove a PR workflow could detect regression.
### RED-first and canonical wiring
1. Removed the suite's signed exclusion before adding a CI execution path.
2. `check-test-enumeration.sh` went RED with exact `UNENUMERATED` output for `test-start-agent-session.sh`: population 49, enumerated 30, excluded 18.
3. Added both `framework/tools/fleet/test-start-agent-session.sh` and `framework/systemd/user/test-fleet-units.sh` to `@mosaicstack/mosaic`'s canonical `test:framework-shell` chain.
4. The guard returned GREEN: population 49, enumerated 32, excluded 18, surfaces 45. The systemd suite is outside the guard's tools-only population but now has the same explicit canonical execution disposition.
### Workflow-level R7
- Deleted only the pane launch entry `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"`.
- Ran the exact `.woodpecker/ci.yml` test-step command, `pnpm test`, with only a temporary PATH-scoped npm shim reporting the checkout's current 0.0.49 version so the unrelated global 0.0.48 banner could not preempt the shell chain.
- Result: exit 1 at `@mosaicstack/mosaic#test`, with the enumeration guard GREEN followed by `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
- Restored the launch entry. The canonical `test:framework-shell` chain then reached both newly wired suites and printed both GREEN markers before the known unrelated #973 host-only `BASH_LINENO` abort.
- An actual provider PR workflow on the intentionally broken mutant is **NOT MEASURED**: the one-push constraint forbids pushing a red mutant and then a repaired head. Local execution proves the exact PR workflow command and dependency chain go RED on the subject deletion; CI on the repaired pushed head remains canonical.
### Workflow population
- **DEFINED:** 3 workflows (`ci.yml`, `ci-image.yml`, `publish.yml`).
- **ELIGIBLE for `pull_request`:** 1/3 (`ci.yml`), based on top-level `when:` clauses.
- **REPORTED:** Round-1 exact-head provider read reported 1/1 eligible context (`ci/woodpecker/pr/ci`). Post-remediation-head reported count is **NOT MEASURED** by this seat because CI polling is prohibited; workflow definitions and eligibility did not change.
### Independent remediation review
- First Round-2 review identified a CI-image blocker: the newly wired launcher suite used Perl, which the Alpine CI base does not install.
- Replaced the suite's three Perl-only fixture mutations with POSIX/BusyBox-compatible `sed -i` substitutions; production behavior and assertions are unchanged.
- Codex re-review: **APPROVE**, confidence 0.93, no findings.
### Vitest denominator reconciliation
The PR's `311/311` is correct for its explicitly named six-file command at both the original and remediation worktrees:
- generated environment boundary: 24
- fleet documentation: 23
- Tess service profile: 6
- fleet regen command: 27
- fleet agent CRUD command: 22
- fleet command: 209
- total: **311**
Review 97 reported 312/312 without naming its six files. That is a different or miscounted population and cannot replace the command-scoped 311 denominator; the PR follow-up will name the exact files and arithmetic.
## Round 3 — Alpine stale-marker portability
### Objective and plan
- Replace the GNU-only relative-date fixture with a deterministic POSIX/BusyBox timestamp while preserving the required stale-marker assertion.
- Re-run the launcher suite in the canonical `ci-base:latest` Alpine image, then run applicable repository gates and independent review.
- Update the PR body to name the repeated GNU-host/Alpine-CI portability pattern, run the mandatory queue guard, push once, verify provider attribution, and stop without CI polling.
- Working budget: 8K tokens; scope is one fixture line plus delivery evidence. No production behavior changes.
### RED-first evidence
Before the fix, the canonical CI image command
`docker run --rm -v "$PWD:/work" -w /work git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
exited 1 at the stale-marker setup with exact BusyBox output
`touch: invalid date '10 seconds ago'`. The prior fresh-marker assertions had already executed, matching pipeline 2233's failure location.
### Root cause and fix
The test used GNU `touch -d` relative-date parsing although the PR workflow runs on Alpine/BusyBox. The fixture now uses POSIX `touch -t 200001010000.00`, a fixed timestamp that is unconditionally stale; the stale assertion remains mandatory and was not made tolerant of missing timestamp metadata.
### Structural pattern
This is the third GNU-host/Alpine-CI portability defect in the lane: GNU `grep` multi-match counting, Perl-only fixture mutation, and GNU `touch -d` date parsing. The repeated cause is shell suites authored on a GNU host but executed in an Alpine CI image; durable prevention belongs in CI-image execution or portability lint, not assertion weakening.
### GREEN and quality evidence
- Focused launcher suite in `ci-base:latest`: exit 0, `ok - start-agent-session generated environment boundary`.
- Canonical test step in `ci-base:latest` with the pipeline's `pgvector/pgvector:pg17` service, readiness check, migration, and `pnpm test`: exit 0; 46/46 Turbo tasks; Mosaic 81/81 files and 1510/1510 tests; Gateway 57 passed/5 skipped files and 629 passed/11 skipped tests; enumeration 49 population / 32 enumerated / 18 signed exclusions / 45 named surfaces.
- The first image-only `pnpm test` attempt lacked the pipeline PostgreSQL service and failed only on connection refusal after the launcher suite was GREEN. The rerun supplied the canonical service precondition and passed.
- Canonical-image baseline: typecheck 45/45 tasks, lint 25/25 tasks, format check GREEN; `git diff --check` GREEN.
- Independent Codex code review: APPROVE, confidence 0.96, 2/2 Round-3 files, no findings.
- Independent Codex security review: risk none, confidence 0.99, 2/2 Round-3 files, no findings.
### Re-derived inventory and denominators
- Round-3 git delta: **2/2 files** — launcher suite and task scratchpad; 25 insertions / 1 deletion before evidence finalization.
- Full PR path inventory against `origin/main` at `85d2108e`: **19/19 changed paths**; Round 3 adds no new PR path.
- Workflow definition population: **1/3 pull-request-eligible** (`ci.yml` of `ci.yml`, `ci-image.yml`, `publish.yml`).
- Do not re-litigate the settled 311/312 populations; both are valid for their separately named Tess6 and CRUD-core7 sets.
## Round 4 — bound stale-marker observation
### Objective and plan
- Make the heartbeat assertion discriminate an initially stale native marker from a fresh marker without changing the production staleness threshold or shortening the polling window.
- Freeze only the sidecar's numeric observation clock during the stale-fixture arm so elapsed assertion time cannot turn a fresh mutant stale.
- Prove two independent mutants RED: disable production stale-marker detection while retaining the stale fixture; replace the stale fixture with a fresh marker. Restore the tree and prove GREEN in the canonical Alpine image.
- Re-derive the changed-path inventory, run applicable quality and independent review gates, commit with environment-only author/committer identity, queue-guard, push once, verify provider attribution using curl stdin config, and stop without CI polling.
- Working budget: 8K tokens. Scope is the launcher test and its scratchpad evidence; production launcher behavior remains unchanged.
### Root cause and bounded observation
The 30 × 0.1-second assertion window overlaps the production `now - marker > interval * 2 + 1` threshold at interval 1. Depending on second boundaries and load, a fresh marker can age past the threshold before the assertion ends. A focused pre-fix fresh-mutant attempt returned RED while Review 101's full-suite run returned GREEN; the differing result is itself timing dependence, not a discriminating assertion.
The test now supplies a fixed numeric epoch only to the stale-fixture sidecar. Its real marker mtime is still read from the filesystem, but assertion runtime cannot advance `now`. Date formatting still delegates to the image's real `/bin/date`. Neither the production threshold nor the 30 × 0.1-second polling window changed.
### Two-mutant RED / restored GREEN
All three runs used `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`:
1. **Stale-detection mutant RED:** replaced only the production stale-age predicate with `false` while retaining the fixed stale marker; suite exit 1 with `FAIL: heartbeat sidecar did not resume after native marker became stale or absent`.
2. **Fresh-marker mutant RED:** replaced only `touch -t 200001010000.00` with fresh `touch`; suite exit 1 with the same failed stale-resumption assertion. The fixed observation epoch kept the mutant fresh throughout all 30 polls.
3. **Restored tree GREEN:** suite exit 0 with `ok - start-agent-session generated environment boundary`.
### Re-derived inventory
- Round-4 delta: **2/2 files** — launcher test plus task scratchpad; production launcher delta is empty.
- Full PR inventory against `origin/main`: **19/19 paths**; Round 4 adds no path.
- Production stale threshold remains `now - marker > iv * 2 + 1`; assertion polling remains 30 × 0.1 seconds.
- Review 101's confirmed enumeration/workflow/CI and attribution evidence is accepted without re-polling or re-derivation.
## Residual risk
- Landing on `main` does not update the currently installed host launcher. Host framework installation/reseed and Jarvis live-seat validation are separate downstream events.
- Canonical CI result is pending and will not be polled by this seat.
-110
View File
@@ -1,110 +0,0 @@
# 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,99 @@
# PR merge squash message field
- **Charter:** `/home/hermes/agent-work/CHARTER-PRMERGE-MESSAGE-FIELD.md`
- **Owner:** `be-coder-08`
- **Branch:** `fix/pr-merge-message-field`
- **Base:** remote `main` / local `origin/main` at `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`
- **Estate:** HOMELAB tooling shared by HOMELAB and USC
## Objective
Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genuine multi-author PRs retain non-poster branch authors without weakening hardcoded squash behavior.
## Binding requirements
1. `Do` remains hardcoded to `squash`; no provider/repository default may select merge style.
2. A verified trailer uses a PR commit's linked `author.login` and that same commit's author email. No `/users/{login}` primary-email lookup occurs. Recorded rationale: this asks only what the provider can answer.
3. A commit with `author.login` null blocks before merge, prints both the null provider fact and commit email fact, and names the escalation principal.
4. The BLOCK arm must be observed firing; a normal canonical single-author API payload remains explicit squash plus its reviewed `head_commit_id`.
5. Every provider mutation is read back from the provider; no real PR is merged during tests.
## Derived interface decisions
- Add `--co-author-trailers` rather than accepting arbitrary message text. The wrapper enumerates PR commits and constructs trailers, making an unchecked `Co-authored-by` line unexpressible.
- Require `--escalate-to PRINCIPAL` with `--co-author-trailers`, so the BLOCK diagnostic always names a principal rather than a generic role.
- Do not expose `MergeTitleField` separately. When trailers exist, set it from the provider PR title and set `MergeMessageField` only to construction-generated trailers. This preserves one provider source for the title and avoids an unrelated caller-controlled degree of freedom.
- Preserve first-commit order and emit one trailer per distinct non-poster `author.login`, using that first linked commit's own email.
## Canonical delivery plan
1. Port the capability into the installed source of truth, `packages/mosaic/framework/tools/git/pr-merge.sh`; do not retain `infra/fleet/tools/git` as a second copy.
2. Preserve canonical `--expect-head`, exact head branch/repository/SHA queue inspection, Gitea atomic head pinning, GitHub `--match-head-commit`, and delete-after-merge semantics.
3. Do not port the deployed-only `--skip-queue-guard` bypass. Add the focused harness to the canonical framework-shell suite and re-establish RED/GREEN on the packaged baseline.
4. Deliver through a reviewed package release followed by `mosaic update` with its default framework reseed. The installer snapshots, manifest-syncs framework-owned `tools/**`, and rolls back on failure.
5. Before either estate relies on the change, require installed/package hash equality, `MergeMessageField` presence, and a green focused harness. Release/reseed ownership is currently unassigned and blocks activation after source merge.
## Evidence
- RED against the byte-identical deployed baseline (`sha256 08a65e8584c5…`): rc 1 with eight named failures. The wrapper rejected `--co-author-trailers`; the null-login path emitted none of the required BLOCK facts/principal; and both verified/ordinary API paths failed the stdin-config credential assertion (ordinary path exposed the fixture token through curl argv). Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-red.log`.
- GREEN on the deployed-baseline candidate: verified linked multi-author payload, null-login BLOCK, required named principal, explicit squash, stdin-config token transport, and absence of `/users` lookup all passed. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-green.log`.
- RED against canonical packaged baseline `c581ef48…`: rc 1 with 32 assertions. It rejects the new option, and the first harness version did not satisfy canonical head branch/repository/SHA metadata. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-packaged-baseline-red.log`. The port adapts the fixture rather than weakening canonical head controls.
- Provider capability probe against `git.mosaicstack.dev`: authenticated `be-coder-08` POST to deliberately nonexistent PR `2147483647` with both message fields returned JSON HTTP 404; the unauthenticated same request returned JSON HTTP 401 (not the charter's predicted 403). The authenticated-vs-unauthenticated differential proves write authorization resolved while no mergeable subject existed. `tl-mosaic` ruled the literal non-load-bearing: preserve the observed 404/401 pair and do not manufacture a 403 case. No cause was inferred and no real PR was targeted.
- Provider-generated trailer behavior is not treated as exclusive or absent. The wrapper's VERIFIED/BLOCK decision binds each requested non-poster trailer to commit `author.login` plus that commit's email; it does not assume `MergeMessageField` is the squash's only trailer source. The poster is omitted from the constructed list because the resulting squash author already records the poster; any additional provider-generated trailer is outside this change's unmeasured mechanism.
- An early candidate SHA-256 `5de32876990e4f26920448cb3220cc7f1146d558b4dd2bc1ee1a2abee2f2cbe6` passed the initial harness, then author-side review found credential-fallback and argv-exposure defects. The live deployed wrapper was atomically restored to baseline SHA-256 `08a65e8584c52c6d41ea1c686f8b95585c21e4b37320a2447eba09359a0e02c1`; the remediated candidate remains only in the worktree.
## Remediation and current review state
1. Token and Basic Auth now use stdin curl configuration, not argv. PR title, contributor email, and the JSON payload also remain out of child argv.
2. Each credential attempt binds commit inspection and merge. A token failure during either inspection or mutation causes Basic fallback to repeat inspection before mutation; the payload pins the inspected `head_commit_id`.
3. Focused tests cover token-resolution fail-closed behavior, both HTTP-401 fallback seams, metadata/credential argv absence, null-login BLOCK, explicit squash, canonical reviewed-head binding, unchanged ordinary payload, and retained log-safe provider diagnostics. Token-resolution RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-token-resolution-red.log`.
4. Codex review rounds 35 requested retained provider error text, log-safe provider diagnostics, fail-closed credential fallback, stable value-option parsing, and PR-title trailer-injection prevention. These are remediated with regression assertions. A post-remediation independent review is still required.
5. **Accepted linkage limitation:** `author.login` resolution proves that the commit address maps to a registered provider account. It does not prove that the named principal authored the commit because Git author metadata is self-asserted. This gate checks attribution linkage, not authorship; commit signing is out of scope and currently unadopted. Coordinators explicitly ruled that this does not add a third state.
6. Codex's sandbox could not execute the harness because its checkout was read-only; that environmental limitation is recorded separately from host-side test results.
## Disposable provider fixture acceptance
- Use a retained scratch repository only, with two branch authors and `author != committer` on at least one commit.
- Arm A supplies a message-field trailer for one non-poster; record whether that value lands without forcing the partial-pair result into under-specified `APPENDS`/`REPLACES` labels. Demonstrate an absence control.
- Arm B includes a registered trailer for a different non-poster on a branch commit; record whether it survives or drops. Verify identity through an existing commit whose `author.login` resolves and demonstrate an absence control.
- Parse landed trailers key-agnostically with `^[A-Za-z-]+-[Bb]y:` and record generated poster pair presence/absence plus resulting poster attribution.
- Record `/users/<login>` status and raw email only as non-gating estate telemetry. Never read `active`, `visibility`, or any profile field as an identity gate.
- Use distinct principals: poster `be-coder-08`, merger `Mos`, Arm A `be-coder-07`, and Arm B `be-coder-06`. Capture every trailer-shaped line verbatim and in order. Zero trailer lines means the generator did not fire and the run is `VOID`, not evidence that either arm dropped.
- Report the same read-back evidence to `mos-claude` on socket `default` and `tl-mosaic` on socket `mosaic-fleet`. Report values rather than mechanism inferences and stop on any poster-attribution regression.
## Fixture preflight
- Retained public repository: `mosaicstack/prmerge-trailer-fixture`; PR `#1`, posted by `be-coder-08` and reserved for merge by `Mos`.
- Existing `mosaicstack/stack` commits resolve `be-coder-07` and `be-coder-06` through `author.login`; exact addresses are `[email protected]` and `[email protected]`.
- Non-gating HOMELAB telemetry for authenticated reader `be-coder-08`: `/api/v1/users/be-coder-06` returned HTTP 200 with raw `email` value `[email protected]`.
- Provider preflight showed PR commit enumeration is newest-first. A new RED test proved that deriving `head_commit_id` from the final array element selected the wrong commit. The candidate now reads `.head.sha` from the authenticated PR endpoint before enumeration, verifies it appears in the commit set, and atomically pins that SHA in the explicit squash payload. RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-head-order-red.log`.
- Fixture PR head `f6ba6e5105031fa21f5ff7bd8e4379d99c16e1de` has `author.login=be-coder-07`, `committer.login=be-coder-08`, and branch-message trailer `Co-authored-by: be-coder-06 <[email protected]>`.
## Fixture result
- `Mos` merged retained fixture PR `#1` through staged candidate SHA-256 `60e779a85fd13b729d859ea7c986d1e9b1641b97991611329226c1b3113ffb6e`; resulting squash commit: `3f550715d9bc716426fd355a65fe997b3a90fa7d` with one parent.
- Provider read-back: poster/commit author `be-coder-08`, committer/merger `Mos`. The run is non-void.
- Trailer-shaped lines, verbatim and in order:
1. `Co-authored-by: be-coder-07 <[email protected]>`
2. `Co-authored-by: be-coder-08 <[email protected]>`
- Arm A supplied field value (`be-coder-07`) landed. Arm B branch trailer (`be-coder-06`) dropped. Both fabricated absence controls remained absent. No `Co-committed-by:` line landed.
- The candidate payload construction explicitly excludes the poster and supplied only the Arm A `be-coder-07` line. Therefore the landed poster line was provider-generated, not candidate-composed. The raw result supports `FIELD LANDS`, `BRANCH DROPS`, and `POSTER GENERATED`; it does not support a claim that candidate code supplied the poster. Evidence: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-fixture-readback.log` and the retained provider object.
- Retained fixture PR `#2` measured the N=2 shape needed by `#1030`: supplied `be-coder-07` then `be-coder-06`; both landed in that order, followed by the provider-generated poster line. No truncation or dedup occurred at N=2. Resulting squash: `39db9d13aed0…`.
## Current hold point
PR `mosaicstack/stack#1066` is open. Its first frozen head `f4b162fa…` was terminal-green in Woodpecker `mosaic` pipeline `#2225`, but that evidence becomes stale when the canonical port moves the head. The deployed wrapper remains baseline `08a65e85…`; no manual copy will occur. Canonical port tests, commit amendment, rebase, one guarded force-with-lease, exact-head CI, and new independent review remain. Even after source merge, activation remains blocked on an assigned package-release/reseed owner and installed-byte read-back.
## Security review 96 remediation
Exact reviewed predecessor head: `1ceb11058f64dd7f4a817ceb2124f980a1c4dd23`.
RED-first focused harness produced 10 named failures: all curl calls lacked size/time/connect bounds; raw ESC email reached mutation; oversized and stalled curl failures were discarded and reached mutation; nonempty Basic output with resolver rc 91 authorized mutation.
Security remediation:
- Removed the cross-principal HTTP-401 Basic fallback. Both inspection-401 and merge-401 paths now refuse without Basic resolution or mutation; `get_gitea_basic_auth` references in the merge subject are 0.
- Applied `--max-filesize`, `--max-time`, and `--connect-timeout` to all 3/3 provider curl sites and fail closed on curl transport rc at all 3/3 sites.
- Required linked email bytes to be ASCII and printable before constructing `MergeMessageField`; guarded construction sites 1/1.
GREEN: message-field, exact-head, empty-UID/API, queue branch/repository/SHA, bash syntax, ShellCheck, and diff check pass. R7 total-removal mutants went RED: email guard 3 rows; bound switches 1 row; transport-rc guards 4 rows; HTTP-401 refusal 3 rows. R7 bound: mutants prove total removal only; explicit denominators above prove site coverage.
@@ -112,6 +112,7 @@ EOF
chmod 700 "$AGENT_HOME/fleet/agents" chmod 700 "$AGENT_HOME/fleet/agents"
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
MOSAIC_AGENT_NAME=$AGENT_NAME MOSAIC_AGENT_NAME=$AGENT_NAME
MOSAIC_GIT_IDENTITY=$AGENT_NAME
MOSAIC_AGENT_CLASS=code MOSAIC_AGENT_CLASS=code
MOSAIC_AGENT_RUNTIME=pi MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL= MOSAIC_AGENT_MODEL=
@@ -97,7 +97,7 @@ is_sensitive_key() {
is_generated_key() { is_generated_key() {
case "$1" in case "$1" in
MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;; MOSAIC_AGENT_NAME|MOSAIC_GIT_IDENTITY|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
*) return 1 ;; *) return 1 ;;
esac esac
} }
@@ -114,6 +114,7 @@ validate_generated_value() {
local value="$2" local value="$2"
case "$key" in case "$key" in
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;; MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
MOSAIC_GIT_IDENTITY) safe_agent_name "$value" || fail_env unsafe-git-identity "$key" "$value" ;;
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;; MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
MOSAIC_AGENT_RUNTIME) MOSAIC_AGENT_RUNTIME)
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
@@ -175,7 +176,7 @@ load_environment_file() {
load_environment_file "$GENERATED_ENV" generated load_environment_file "$GENERATED_ENV" generated
for required_key in \ for required_key in \
MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \ MOSAIC_AGENT_NAME MOSAIC_GIT_IDENTITY MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" '' [ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
done done
@@ -183,12 +184,15 @@ load_environment_file "$LOCAL_ENV" local
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \ [ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
[ "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}" = "$AGENT_NAME" ] || \
fail_env git-identity-mismatch MOSAIC_GIT_IDENTITY "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}"
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]} MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]} MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]} MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]} MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]} MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
MOSAIC_GIT_IDENTITY=${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]} MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]} MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-} MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
@@ -343,6 +347,7 @@ LAUNCH_ENV=(
"PATH=$PANE_PATH" "PATH=$PANE_PATH"
"MOSAIC_HOME=$MOSAIC_HOME" "MOSAIC_HOME=$MOSAIC_HOME"
"MOSAIC_AGENT_NAME=$AGENT_NAME" "MOSAIC_AGENT_NAME=$AGENT_NAME"
"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS" "MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME" "MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL" "MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
@@ -62,6 +62,19 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
SHIM SHIM
chmod +x "$FAKE_BIN/mosaic" chmod +x "$FAKE_BIN/mosaic"
# Freeze numeric epoch reads only when a test arm supplies an observation bound.
# Formatting reads still use the real BusyBox/POSIX date implementation.
cat > "$FAKE_BIN/date" <<'SHIM'
#!/usr/bin/env bash
set -euo pipefail
if [ -n "${MOSAIC_TEST_FIXED_EPOCH:-}" ] && [ "${1:-}" = '+%s' ]; then
printf '%s\n' "$MOSAIC_TEST_FIXED_EPOCH"
exit 0
fi
exec /bin/date "$@"
SHIM
chmod +x "$FAKE_BIN/date"
write_generated() { write_generated() {
local home="$1" local home="$1"
local agent="$2" local agent="$2"
@@ -71,6 +84,7 @@ write_generated() {
chmod 600 "$home/fleet/run/holder-owner" chmod 600 "$home/fleet/run/holder-owner"
cat > "$home/fleet/agents/$agent.env.generated" <<EOF cat > "$home/fleet/agents/$agent.env.generated" <<EOF
MOSAIC_AGENT_NAME=$agent MOSAIC_AGENT_NAME=$agent
MOSAIC_GIT_IDENTITY=$agent
MOSAIC_AGENT_CLASS=code MOSAIC_AGENT_CLASS=code
MOSAIC_AGENT_RUNTIME=pi MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
@@ -88,6 +102,7 @@ run_start() {
local agent="$2" local agent="$2"
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \ HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \ MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
MOSAIC_TEST_FIXED_EPOCH="${MOSAIC_TEST_FIXED_EPOCH:-}" \
MOSAIC_TEST_HOME="$home" \ MOSAIC_TEST_HOME="$home" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$home" "$START" "$agent" MOSAIC_HOME="$home" "$START" "$agent"
@@ -114,6 +129,42 @@ fi
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env" echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared" echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared"
# Git identity is generated authority, not an optional or independently mutable
# local value. Each invalid form must fail before fake tmux receives a call.
assert_git_identity_rejected() {
local case_name="$1"
local expected_code="$2"
local home="$ROOT/git-identity-$case_name"
local agent="coder-git-identity-$case_name"
local generated="$home/fleet/agents/$agent.env.generated"
write_generated "$home" "$agent"
case "$case_name" in
missing) grep -v '^MOSAIC_GIT_IDENTITY=' "$generated" > "$generated.next" && mv "$generated.next" "$generated" ;;
unsafe) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=bad/identity|' "$generated" ;;
mismatch) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=other-agent|' "$generated" ;;
local-shadow)
printf 'MOSAIC_GIT_IDENTITY=%s\n' "$agent" > "$home/fleet/agents/$agent.env.local"
chmod 600 "$home/fleet/agents/$agent.env.local"
;;
*) fail "unknown Git identity rejection case: $case_name" ;;
esac
chmod 600 "$generated"
: > "$TMUX_CALLS"
if output=$(run_start "$home" "$agent" 2>&1); then
fail "Git identity case $case_name was accepted"
fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before Git identity $case_name rejection"
echo "$output" | grep -qF "code=$expected_code" || \
fail "Git identity $case_name diagnostic omitted code $expected_code"
}
assert_git_identity_rejected missing missing-key
assert_git_identity_rejected unsafe unsafe-git-identity
assert_git_identity_rejected mismatch git-identity-mismatch
assert_git_identity_rejected local-shadow generated-key-shadow
# The generated-file parent is a security boundary too: even a private regular # The generated-file parent is a security boundary too: even a private regular
# file is untrusted if its parent can be replaced or written by another user. # file is untrusted if its parent can be replaced or written by another user.
# Validation must happen before fake tmux receives even a has-session call. # Validation must happen before fake tmux receives even a has-session call.
@@ -274,6 +325,18 @@ after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cu
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \ printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
fail "pane command did not clear its environment" fail "pane command did not clear its environment"
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment") pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
# Exercise the repository launcher at $START, not the independently installed
# host copy. Set-compare every declared generated projection entry with the
# launched process environment so a newly declared identity cannot be omitted
# by a hand-maintained per-variable assertion.
declared_generated_environment=$(sort "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.generated")
missing_or_changed_generated_environment=$(comm -23 \
<(printf '%s\n' "$declared_generated_environment") \
<(printf '%s\n' "$pane_environment" | sort))
if [ -n "$missing_or_changed_generated_environment" ]; then
missing_or_changed_keys=$(printf '%s\n' "$missing_or_changed_generated_environment" | cut -d= -f1 | paste -sd, -)
fail "runtime pane omitted or changed generated environment keys: $missing_or_changed_keys"
fi
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \ echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
fail "runtime pane did not receive trusted HOME" fail "runtime pane did not receive trusted HOME"
echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH" echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
@@ -290,6 +353,7 @@ write_interaction_generated() {
chmod 600 "$home/fleet/run/holder-owner" chmod 600 "$home/fleet/run/holder-owner"
cat > "$home/fleet/agents/$agent.env.generated" <<EOF cat > "$home/fleet/agents/$agent.env.generated" <<EOF
MOSAIC_AGENT_NAME=$agent MOSAIC_AGENT_NAME=$agent
MOSAIC_GIT_IDENTITY=$agent
MOSAIC_AGENT_CLASS=operator-interaction MOSAIC_AGENT_CLASS=operator-interaction
MOSAIC_AGENT_RUNTIME=pi MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
@@ -352,8 +416,12 @@ write_generated "$HOME_NATIVE_STALE" "coder-native-stale"
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale" write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb" STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB" printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
touch -d '10 seconds ago' "$STALE_HB.native" touch -t 200001010000.00 "$STALE_HB.native"
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale # Hold the sidecar's observation epoch constant: assertion runtime must not age
# a fresh-marker mutant into the stale state that this fixture must distinguish.
STALE_OBSERVATION_EPOCH=$(date +%s)
MOSAIC_TEST_FIXED_EPOCH="$STALE_OBSERVATION_EPOCH" \
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
wait_for_sidecar_status "$STALE_HB" wait_for_sidecar_status "$STALE_HB"
HOME_NATIVE_ABSENT="$ROOT/native-absent" HOME_NATIVE_ABSENT="$ROOT/native-absent"
@@ -381,7 +449,7 @@ echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use sh
: > "$TMUX_CALLS" : > "$TMUX_CALLS"
HOME_INTERACTION_POLICY="$ROOT/interaction-policy" HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy" write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
perl -0pi -e 's/MOSAIC_AGENT_RUNTIME=pi/MOSAIC_AGENT_RUNTIME=codex/' \ sed -i 's|^MOSAIC_AGENT_RUNTIME=pi$|MOSAIC_AGENT_RUNTIME=codex|' \
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated" "$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
fail "interaction wrapper accepted a policy-incompatible projection" fail "interaction wrapper accepted a policy-incompatible projection"
@@ -499,17 +499,8 @@ get_gitea_url_for_host() {
# Resolve a Gitea API token for the given host. # Resolve a Gitea API token for the given host.
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials # 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() { get_gitea_token() {
local host="$1" _resolution_path=unresolved local host="$1"
local script_dir local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local cred_loader="$script_dir/../_lib/credentials.sh" local cred_loader="$script_dir/../_lib/credentials.sh"
@@ -525,16 +516,6 @@ get_gitea_token() {
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)" _ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
_ident_src="git config mosaic.gitIdentity" _ident_src="git config mosaic.gitIdentity"
fi 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 if [[ -n "$_ident" ]]; then
local _idpfx="" local _idpfx=""
case "$host" in case "$host" in
@@ -543,23 +524,8 @@ get_gitea_token() {
esac esac
if [[ -n "$_idpfx" ]]; then if [[ -n "$_idpfx" ]]; then
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token" local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json" if [[ -r "$_idtok" ]]; then
if [[ -e "$_idcred" || -L "$_idcred" ]]; then cat "$_idtok"
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 return 0
fi fi
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host, # FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
@@ -568,21 +534,12 @@ get_gitea_token() {
# would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed # 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 # to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the
# caller aborts loudly rather than acting as the wrong identity. # caller aborts loudly rather than acting as the wrong identity.
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=no-token-for-identity identity=%s host=%s shared_path_entered=false source=%s path=%s\n' \ echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
"$_ident" "$host" "$_ident_src" "$_idtok" >&2 echo " Refusing to borrow another slot's token. Provision the per-slot token, or unset the identity to use shared credentials." >&2
return 1 return 1
fi fi
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) # 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
if [[ -f "$cred_loader" ]]; then if [[ -f "$cred_loader" ]]; then
local token local token
@@ -614,8 +571,6 @@ get_gitea_token() {
echo "${GITEA_TOKEN:-}" echo "${GITEA_TOKEN:-}"
) )
if [[ -n "$token" ]]; then if [[ -n "$token" ]]; then
_resolution_path=shared
_trace_credential_resolution shared-credential-resolved '<interactive-shared>' "$host" credentials-loader
echo "$token" echo "$token"
return 0 return 0
fi fi
@@ -624,8 +579,6 @@ get_gitea_token() {
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host) # 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
if [[ -n "${GITEA_TOKEN:-}" ]]; then if [[ -n "${GITEA_TOKEN:-}" ]]; then
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; 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" echo "$GITEA_TOKEN"
return 0 return 0
fi fi
@@ -637,8 +590,6 @@ get_gitea_token() {
local token local token
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1) token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
if [[ -n "$token" ]]; then if [[ -n "$token" ]]; then
_resolution_path=shared
_trace_credential_resolution shared-credential-resolved '<interactive-shared>' "$host" git-credentials
echo "$token" echo "$token"
return 0 return 0
fi fi
@@ -24,79 +24,29 @@ while IFS= read -r line; do
username=*) username_in=${line#username=};; username=*) username_in=${line#username=};;
esac esac
done 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). # Per-agent identity resolution (Gate-16 author≠reviewer separation).
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree, # Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
# survives across non-persistent shells) > git-supplied username (credential.username # 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 # / URL). When the resolved identity has a matching per-agent token, use it instead of
# the shared account. Backward-compatible: nothing resolvable → shared token. # 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" ident="$MOSAIC_GIT_IDENTITY"
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null) [ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
[ -z "$ident" ] && ident="$username_in" [ -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 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 if [ -n "$idpfx" ]; then
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token" idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json" if [ -r "$idtok" ]; then
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 "username=${ident}"
echo "password=${token}" echo "password=$(cat "$idtok")"
exit 0 exit 0
fi 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
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 case "$host" in
git.uscllc.com) svc=gitea-usc;; git.uscllc.com) svc=gitea-usc;;
git.mosaicstack.dev) svc=gitea-mosaicstack;; git.mosaicstack.dev) svc=gitea-mosaicstack;;
@@ -105,11 +55,10 @@ esac
# Script-relative (not $HOME-absolute) so this resolves correctly regardless # Script-relative (not $HOME-absolute) so this resolves correctly regardless
# of where the framework installer places tools/ under $HOME — mirrors # of where the framework installer places tools/ under $HOME — mirrors
# detect-platform.sh's own cred_loader resolution in this same directory. # 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 # shellcheck source=../_lib/credentials.sh
source "$script_dir/../_lib/credentials.sh" source "$script_dir/../_lib/credentials.sh"
load_credentials "$svc" >/dev/null 2>&1 || exit 0 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_USER is not populated by load_credentials (it only exports
# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's # 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), # git-over-HTTP auth authenticates from the token itself (the password field),
+469 -59
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub # pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] # Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
set -euo pipefail set -euo pipefail
@@ -14,6 +14,8 @@ MERGE_METHOD="squash"
DELETE_BRANCH=false DELETE_BRANCH=false
DRY_RUN=false DRY_RUN=false
EXPECT_HEAD="" EXPECT_HEAD=""
CO_AUTHOR_TRAILERS=false
ESCALATE_TO=""
usage() { usage() {
cat <<EOF cat <<EOF
@@ -27,12 +29,16 @@ Options:
-d, --delete-branch Delete the head branch after merge -d, --delete-branch Delete the head branch after merge
--dry-run Run metadata/login preflight without merging --dry-run Run metadata/login preflight without merging
--expect-head SHA Refuse unless the PR head matches this full commit SHA --expect-head SHA Refuse unless the PR head matches this full commit SHA
--co-author-trailers Build verified trailers from linked PR commit authors
--escalate-to NAME Named principal for an unresolved-author BLOCK
-h, --help Show this help message -h, --help Show this help message
Examples: Examples:
$(basename "$0") -n 42 # Merge PR #42 $(basename "$0") -n 42 # Merge PR #42
$(basename "$0") -n 42 -m squash # Squash merge $(basename "$0") -n 42 -m squash # Squash merge
$(basename "$0") -n 42 -d # Squash merge and delete branch $(basename "$0") -n 42 -d # Squash merge and delete branch
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
EOF EOF
exit "${1:-1}" exit "${1:-1}"
} }
@@ -57,9 +63,25 @@ while [[ $# -gt 0 ]]; do
shift shift
;; ;;
--expect-head) --expect-head)
if [[ $# -lt 2 ]]; then
echo "Error: --expect-head requires one full commit SHA." >&2
exit 1
fi
EXPECT_HEAD="$2" EXPECT_HEAD="$2"
shift 2 shift 2
;; ;;
--co-author-trailers)
CO_AUTHOR_TRAILERS=true
shift
;;
--escalate-to)
if [[ $# -lt 2 ]]; then
echo "Error: --escalate-to requires one principal name." >&2
exit 1
fi
ESCALATE_TO="$2"
shift 2
;;
-h|--help) -h|--help)
usage 0 usage 0
;; ;;
@@ -88,17 +110,30 @@ 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 echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
exit 1 exit 1
fi fi
if [[ "$CO_AUTHOR_TRAILERS" == true && -z "$ESCALATE_TO" ]]; then
echo "Error: --co-author-trailers requires --escalate-to with a named principal." >&2
exit 1
fi
if [[ -n "$ESCALATE_TO" && ! "$ESCALATE_TO" =~ ^[A-Za-z0-9_.-]+$ ]]; then
echo "Error: --escalate-to must be one exact principal name." >&2
exit 1
fi
if [[ "$CO_AUTHOR_TRAILERS" != true && -n "$ESCALATE_TO" ]]; then
echo "Error: --escalate-to is valid only with --co-author-trailers." >&2
exit 1
fi
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")" 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())')" 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_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_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())')" 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())')"
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
if [[ "$BASE_BRANCH" != "main" ]]; then if [[ "$BASE_BRANCH" != "main" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2 echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
exit 1 exit 1
fi fi
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; 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 echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
exit 1 exit 1
@@ -122,70 +157,442 @@ PLATFORM=$(detect_platform)
OWNER=$(get_repo_owner) OWNER=$(get_repo_owner)
REPO=$(get_repo_name) REPO=$(get_repo_name)
merge_gitea_with_api() { write_curl_auth_config() {
local host="$1" api_url token basic_auth body_file raw_code payload local mode="$1" credential="$2"
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge" printf '%s' "$credential" | python3 -c '
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}" import sys
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX") mode = sys.argv[1]
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY' credential = sys.stdin.read()
if not credential or any(char in credential for char in "\r\n"):
raise SystemExit(1)
escaped = credential.replace("\\", "\\\\").replace("\"", "\\\"")
if mode == "token":
print(f"header = \"Authorization: token {escaped}\"")
elif mode == "basic":
print(f"user = \"{escaped}\"")
else:
raise SystemExit(1)
' "$mode"
}
LAST_GITEA_HTTP_CODE="000"
LAST_GITEA_ERROR=""
MERGE_TEMP_DIRS=()
GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}"
GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}"
GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}"
for bound in "$GITEA_CURL_MAX_BYTES" "$GITEA_CURL_MAX_TIME" "$GITEA_CURL_CONNECT_TIMEOUT"; do
if [[ ! "$bound" =~ ^[1-9][0-9]*$ ]]; then
echo "Error: Gitea curl bounds must be positive integers; refusing request." >&2
exit 1
fi
done
GITEA_CURL_BOUNDS=(
--max-filesize "$GITEA_CURL_MAX_BYTES"
--max-time "$GITEA_CURL_MAX_TIME"
--connect-timeout "$GITEA_CURL_CONNECT_TIMEOUT"
)
format_gitea_error_response() {
local response_file="$1"
python3 - "$response_file" <<'PY'
import json import json
import sys import sys
head_sha, delete_branch = sys.argv[1:] with open(sys.argv[1], "rb") as handle:
raw = handle.read(65536)
try:
response = json.loads(raw.decode("utf-8", errors="replace"))
except (UnicodeDecodeError, json.JSONDecodeError):
message = "non-JSON response omitted"
else:
if isinstance(response, dict):
message = response.get("message") or response.get("error")
if not message and response.get("errors") is not None:
message = json.dumps(response["errors"], separators=(",", ":"))
else:
message = None
if not message:
message = "JSON response contained no error message"
message = str(message)
if len(message) > 500:
message = message[:500] + "..."
print(ascii(message))
PY
}
cleanup_merge_temp_dirs() {
local path
for path in "${MERGE_TEMP_DIRS[@]}"; do
[[ -n "$path" ]] && rm -rf -- "$path"
done
}
trap cleanup_merge_temp_dirs EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
fetch_gitea_pr_head() {
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
local response_file raw_code api_url auth_config curl_rc
response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX")
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
rm -f "$response_file"
return 1
fi
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$response_file" \
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
curl_rc=$?
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
if [[ "$curl_rc" -ne 0 ]]; then
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
rm -f "$response_file"
return 1
fi
if [[ ! "$raw_code" =~ ^2 ]]; then
LAST_GITEA_ERROR=$(format_gitea_error_response "$response_file")
rm -f "$response_file"
return 1
fi
if ! python3 - "$response_file" <<'PY'
import json
import re
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
pull = json.load(handle)
head = pull.get("head") if isinstance(pull, dict) else None
sha = str(head.get("sha") or "") if isinstance(head, dict) else ""
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
raise SystemExit(1)
print(sha)
PY
then
echo "Error: Gitea PR response has no valid head SHA; refusing merge." >&2
rm -f "$response_file"
return 1
fi
rm -f "$response_file"
}
fetch_gitea_pr_commits() {
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
local page page_file combined_file merged_file raw_code page_count api_url auth_config curl_rc
mkdir -p "$work_root"
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
return 1
fi
combined_file=$(mktemp "$work_root/pr-merge-commits.XXXXXX")
printf '[]' > "$combined_file"
page=1
while true; do
page_file=$(mktemp "$work_root/pr-merge-commits-page.XXXXXX")
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/commits?limit=50&page=${page}"
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$page_file" \
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
curl_rc=$?
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
if [[ "$curl_rc" -ne 0 ]]; then
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
rm -f "$page_file" "$combined_file"
return 1
fi
if [[ ! "$raw_code" =~ ^2 ]]; then
LAST_GITEA_ERROR=$(format_gitea_error_response "$page_file")
rm -f "$page_file" "$combined_file"
return 1
fi
if ! page_count=$(python3 - "$page_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
page = json.load(handle)
if not isinstance(page, list):
raise SystemExit(1)
print(len(page))
PY
); then
echo "Error: Gitea PR commits response is not a JSON array; refusing merge." >&2
rm -f "$page_file" "$combined_file"
return 1
fi
merged_file=$(mktemp "$work_root/pr-merge-commits-merged.XXXXXX")
if ! python3 - "$combined_file" "$page_file" > "$merged_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
combined = json.load(handle)
with open(sys.argv[2], encoding="utf-8") as handle:
page = json.load(handle)
json.dump(combined + page, sys.stdout, separators=(",", ":"))
PY
then
echo "Error: Could not combine paginated PR commit metadata; refusing merge." >&2
rm -f "$page_file" "$combined_file" "$merged_file"
return 1
fi
mv "$merged_file" "$combined_file"
rm -f "$page_file"
if [[ "$page_count" -lt 50 ]]; then
break
fi
page=$((page + 1))
if [[ "$page" -gt 1000 ]]; then
echo "Error: PR commit pagination exceeded 1000 pages; refusing merge." >&2
rm -f "$combined_file"
return 1
fi
done
cat "$combined_file"
rm -f "$combined_file"
}
# LIMITATION: author.login resolution proves the commit address maps to a registered account.
# It does NOT prove the named principal authored the commit — git author metadata is self-asserted.
# This gate checks ATTRIBUTION LINKAGE, not AUTHORSHIP. Commit signing is out of scope and unadopted.
build_coauthor_message_fields() {
local commits_file="$1" context_file="$2" head_file="$3"
python3 - "$commits_file" "$context_file" "$head_file" <<'PY'
import json
import re
import sys
commits_path, context_path, head_path = sys.argv[1:]
with open(commits_path, encoding="utf-8") as handle:
commits = json.load(handle)
head_sha = open(head_path, encoding="utf-8").read().strip()
context_parts = open(context_path, "rb").read().split(b"\0")
if len(context_parts) != 4 or context_parts[-1] != b"":
raise SystemExit(1)
poster, title, principal = (part.decode("utf-8") for part in context_parts[:3])
if not isinstance(commits, list) or not commits:
print(
f"BLOCK: provider returned no PR commits; author identity is unmeasurable. "
f"Refusing merge; escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not poster:
print(
f"BLOCK: PR poster login is empty; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not re.fullmatch(r"[0-9a-fA-F]{40}", head_sha):
print(
f"BLOCK: inspected PR head SHA is invalid; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
seen = set()
trailers = []
head_seen = False
for item in commits:
if not isinstance(item, dict):
print(f"BLOCK: malformed PR commit metadata; escalate to named principal '{principal}'.", file=sys.stderr)
raise SystemExit(75)
sha = str(item.get("sha") or "<unknown>")
if sha == head_sha:
head_seen = True
commit = item.get("commit") if isinstance(item.get("commit"), dict) else {}
commit_author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
email = str(commit_author.get("email") or "").strip()
provider_author = item.get("author") if isinstance(item.get("author"), dict) else {}
login = str(provider_author.get("login") or "").strip()
if not login:
diagnostic_email = email or "<missing>"
print(
f"BLOCK: commit {sha!r} has author.login=NULL while "
f"commit.author.email={diagnostic_email!r}; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if (
not email.isascii()
or not email.isprintable()
or not re.fullmatch(r"[A-Za-z0-9_.-]+", login)
or not re.fullmatch(r"[^<>\s]+@[^<>\s]+", email)
):
print(
f"BLOCK: commit {sha!r} has unusable linked identity "
f"author.login={login!r}, commit.author.email={email!r}; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if login == poster or login in seen:
continue
seen.add(login)
trailers.append(f"Co-authored-by: {login} <{email}>")
if not head_seen:
print(
f"BLOCK: inspected PR head is absent from commit enumeration; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not trailers:
print("{}")
raise SystemExit(0)
if not title:
print(
f"BLOCK: PR title is empty; refusing merge; escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not title.isprintable() or re.match(r"^[A-Za-z-]+-[Bb]y:", title):
print(
f"BLOCK: PR title is not one printable, non-trailer line; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
print(json.dumps({
"MergeTitleField": title,
"MergeMessageField": "\n".join(trailers),
}, separators=(",", ":")))
PY
}
merge_gitea_api_attempt() {
local host="$1" auth_mode="$2" credential="$3"
local api_url attempt_dir body_file raw_code commits_file fields_file context_file head_file payload_file work_root attempt_rc auth_config curl_rc
LAST_GITEA_HTTP_CODE="000"
LAST_GITEA_ERROR=""
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
work_root="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
mkdir -p "$work_root"
attempt_dir=$(mktemp -d "$work_root/pr-merge-attempt.XXXXXX")
chmod 0700 "$attempt_dir"
MERGE_TEMP_DIRS+=("$attempt_dir")
body_file=$(mktemp "$attempt_dir/api-response.XXXXXX")
fields_file=$(mktemp "$attempt_dir/message-fields.XXXXXX")
payload_file=$(mktemp "$attempt_dir/payload.XXXXXX")
printf '{}' > "$fields_file"
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
commits_file=$(mktemp "$attempt_dir/pr-merge-commits-input.XXXXXX")
context_file=$(mktemp "$attempt_dir/pr-merge-message-context.XXXXXX")
head_file=$(mktemp "$attempt_dir/pr-merge-head-input.XXXXXX")
printf '%s\0%s\0%s\0' "$PR_AUTHOR" "$PR_TITLE" "$ESCALATE_TO" > "$context_file"
if fetch_gitea_pr_head "$host" "$auth_mode" "$credential" "$attempt_dir" > "$head_file"; then
:
else
attempt_rc=$?
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return "$attempt_rc"
fi
if [[ "$(<"$head_file")" != "$HEAD_SHA" ]]; then
echo "BLOCK: authenticated PR head moved from reviewed $HEAD_SHA to $(<"$head_file"); refusing merge; escalate to named principal '$ESCALATE_TO'." >&2
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return 75
fi
if fetch_gitea_pr_commits "$host" "$auth_mode" "$credential" "$attempt_dir" > "$commits_file"; then
:
else
attempt_rc=$?
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return "$attempt_rc"
fi
if build_coauthor_message_fields "$commits_file" "$context_file" "$head_file" > "$fields_file"; then
:
else
attempt_rc=$?
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return "$attempt_rc"
fi
rm -f "$commits_file" "$context_file" "$head_file"
fi
if ! python3 - "$fields_file" "$HEAD_SHA" "$DELETE_BRANCH" > "$payload_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
fields = json.load(handle)
head_sha, delete_branch = sys.argv[2:]
payload = {"Do": "squash", "head_commit_id": head_sha} payload = {"Do": "squash", "head_commit_id": head_sha}
if delete_branch == "true": if delete_branch == "true":
payload["delete_branch_after_merge"] = True payload["delete_branch_after_merge"] = True
payload.update(fields)
allowed = {"Do", "head_commit_id", "delete_branch_after_merge", "MergeTitleField", "MergeMessageField"}
if payload.get("Do") != "squash" or set(payload) - allowed:
raise SystemExit(1)
print(json.dumps(payload, separators=(",", ":"))) print(json.dumps(payload, separators=(",", ":")))
PY PY
) then
rm -f "$body_file" "$fields_file" "$payload_file"
token=$(get_gitea_token "$host" || true) return 1
if [[ -n "$token" ]]; then
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
-X POST \
-H "User-Agent: curl/8" \
-H "Authorization: token $token" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$api_url" || true)
if [[ "$raw_code" =~ ^2 ]]; then
rm -f "$body_file"
return 0
fi
fi fi
rm -f "$fields_file"
basic_auth=$(get_gitea_basic_auth "$host" || true) if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
if [[ -n "$basic_auth" ]]; then echo "Error: Could not construct Gitea authentication config; refusing request." >&2
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \ rm -f "$body_file" "$payload_file"
-X POST \ return 1
-u "$basic_auth" \
-H "User-Agent: curl/8" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$api_url" || true)
if [[ "$raw_code" =~ ^2 ]]; then
rm -f "$body_file"
return 0
fi
fi fi
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$body_file" \
-X POST -H "User-Agent: curl/8" \
-H 'Content-Type: application/json' \
--data-binary "@$payload_file" "$api_url" <<<"$auth_config")
curl_rc=$?
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
if [[ "$curl_rc" -ne 0 ]]; then
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
rm -f "$body_file" "$payload_file"
rm -rf -- "$attempt_dir"
return 1
fi
if [[ ! "$raw_code" =~ ^2 ]]; then
LAST_GITEA_ERROR=$(format_gitea_error_response "$body_file")
fi
rm -f "$body_file" "$payload_file"
rm -rf -- "$attempt_dir"
[[ "$raw_code" =~ ^2 ]]
}
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2 merge_gitea_with_api() {
import json local host="$1" token attempt_rc
import sys
code, path = sys.argv[1], sys.argv[2] if ! token=$(get_gitea_token "$host"); then
try: echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
with open(path, encoding="utf-8", errors="replace") as handle: return 1
raw = handle.read(500) fi
data = json.loads(raw) if raw else {} if [[ -z "$token" ]]; then
message = data.get("message") or data.get("error") or raw or "empty response" echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
except Exception: return 1
try: fi
message = open(path, encoding="utf-8", errors="replace").read(500) or "empty response" if merge_gitea_api_attempt "$host" token "$token"; then
except Exception: return 0
message = "unreadable response" else
print(f"Error: Gitea API merge failed with HTTP {code}: {message}") attempt_rc=$?
PY fi
rm -f "$body_file" if [[ "$attempt_rc" -eq 75 ]]; then
return 75
fi
if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then
echo "Error: Gitea API merge failed with the identity-bound token (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2
return 1
fi
echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2
return 1 return 1
} }
@@ -195,11 +602,10 @@ if [[ "$DRY_RUN" == true ]]; then
echo "Error: Cannot determine host from origin remote URL" >&2 echo "Error: Cannot determine host from origin remote URL" >&2
exit 1 exit 1
} }
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)" if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
if [[ -n "$TEA_LOGIN" ]]; then echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
else else
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (base=$BASE_BRANCH, method=squash)." echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
fi fi
else else
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)." echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
@@ -209,6 +615,10 @@ fi
case "$PLATFORM" in case "$PLATFORM" in
github) github)
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
echo "Error: --co-author-trailers currently requires the Gitea REST message-field contract." >&2
exit 1
fi
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA") cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch) [[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
"${cmd[@]}" "${cmd[@]}"
@@ -219,7 +629,7 @@ case "$PLATFORM" in
exit 1 exit 1
} }
# Gitea's API head_commit_id is an atomic compare-and-merge precondition. # 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. # tea cannot express it, so every Gitea merge uses the authenticated API path.
merge_gitea_with_api "$HOST" merge_gitea_with_api "$HOST"
;; ;;
*) *)
@@ -1,80 +0,0 @@
#!/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")
@@ -1,51 +0,0 @@
#!/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")
@@ -16,7 +16,6 @@
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets. # NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
set -euo pipefail set -euo pipefail
umask 077
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
@@ -35,8 +34,6 @@ mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
"$REPO_DIR" "$REPO_DIR"
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER" 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" chmod +x "$HELPER"
git -C "$REPO_DIR" init -q git -C "$REPO_DIR" init -q
@@ -87,22 +84,6 @@ git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
out=$(run_helper "git.mosaicstack.dev" "") out=$(run_helper "git.mosaicstack.dev" "")
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')" assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" 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 -> # 2. git-supplied username resolves to an identity WITH a per-slot token ->
@@ -112,21 +93,6 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to
out=$(run_helper "git.mosaicstack.dev" "agentA") out=$(run_helper "git.mosaicstack.dev" "agentA")
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')" 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=')" 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. # 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
@@ -154,16 +120,6 @@ 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: username" "username=git" "$(echo "$out" | grep '^username=')"
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" 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 # 6. Correct per-slot token PATH is chosen per host: same agent id, different
# host prefix (gitea-usc- vs gitea-mosaicstack-). # host prefix (gitea-usc- vs gitea-mosaicstack-).
@@ -179,47 +135,14 @@ 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=')" assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 7. Governed envelopes use the same binding, owner, mode, and digest checks. # 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
# ---------------------------------------------------------------------------
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). # remotes, e.g. github.com via a different credential helper).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
out=$(run_helper "github.com" "agentA") out=$(run_helper "github.com" "agentA")
assert_eq "unknown host: no output" "" "$out" 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"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 9. Non-"get" verb (store/erase) -> exit 0, no output (git-credential # 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
# protocol: this helper only implements get). # protocol: this helper only implements get).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
@@ -28,7 +28,6 @@
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets. # HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
set -euo pipefail set -euo pipefail
umask 077
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
@@ -86,23 +85,7 @@ call_get_gitea_token() {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
out=$(call_get_gitea_token "git.mosaicstack.dev") out=$(call_get_gitea_token "git.mosaicstack.dev")
assert_eq "interactive shared fallback (no identity)" "shared-mosaicstack-token" "$out" assert_eq "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 # 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot
@@ -110,17 +93,8 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token" echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
git -C "$REPO_DIR" config mosaic.gitIdentity agentA git -C "$REPO_DIR" config mosaic.gitIdentity agentA
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA) out=$(call_get_gitea_token "git.mosaicstack.dev")
assert_eq "confirmed fleet identity bypasses shared path" "agentA-mosaicstack-token" "$out" assert_eq "git-config identity token" "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. # 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
@@ -169,17 +143,13 @@ assert_failloud() {
echo "$stderr" >&2 echo "$stderr" >&2
fail=1 fail=1
fi 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 if [[ "$stderr" != *"$expected_tok_path"* ]]; then
echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2 echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2
echo "$stderr" >&2 echo "$stderr" >&2
fail=1 fail=1
fi fi
if [[ "$stderr" == *"shared-mosaicstack-token"* || "$stderr" == *"shared-usc-token"* ]]; then if [[ "$stderr" == *"shared"*"token"* ]]; then
echo "FAIL: $desc — stderr unexpectedly contains a shared credential value:" >&2 echo "FAIL: $desc — stderr unexpectedly mentions a shared token value:" >&2
echo "$stderr" >&2 echo "$stderr" >&2
fail=1 fail=1
fi fi
@@ -51,22 +51,23 @@ for arg in "$@"; do
prev="" prev=""
continue continue
fi fi
if [[ "$prev" == "-d" ]]; then if [[ "$prev" == "data" ]]; then
post_data="$arg" post_data="$arg"
[[ "$post_data" == @* ]] && post_data=$(<"${post_data#@}")
prev="" prev=""
continue continue
fi fi
if [[ "$arg" == "-o" ]]; then if [[ "$prev" == "config" ]]; then
prev="-o" [[ "$arg" == "-" ]] && cat >/dev/null
prev=""
continue continue
fi fi
if [[ "$arg" == "-d" ]]; then case "$arg" in
prev="-d" -o) prev="-o" ;;
continue -d|--data|--data-binary) prev="data" ;;
fi -K|--config) prev="config" ;;
if [[ "$arg" == "-w" ]]; then -w) write_code=true ;;
write_code=true esac
fi
done done
emit_response() { emit_response() {
local body="$1" local body="$1"
@@ -36,13 +36,30 @@ cat > "$WORK_DIR/gitea/curl" <<'SH'
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
payload="" payload=""
for ((i=1; i<=$#; i++)); do out_file=""
if [[ "${!i}" == "-d" ]]; then while [[ $# -gt 0 ]]; do
j=$((i + 1)) case "$1" in
payload="${!j}" -d|--data|--data-binary)
fi payload="$2"
[[ "$payload" == @* ]] && payload=$(<"${payload#@}")
shift 2
;;
-o)
out_file="$2"
shift 2
;;
-K|--config)
[[ "$2" == "-" ]] && cat >/dev/null
shift 2
;;
-w|-X|-H)
shift 2
;;
*) shift ;;
esac
done done
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}" printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
[[ -n "$out_file" ]] && printf '{}' > "$out_file"
printf '200' printf '200'
SH SH
chmod +x "$WORK_DIR/gitea/curl" chmod +x "$WORK_DIR/gitea/curl"
@@ -0,0 +1,541 @@
#!/usr/bin/env bash
# Regression harness for the optional, identity-checked Gitea squash message.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SUBJECT="${MOSAIC_TEST_SUBJECT:-$SCRIPT_DIR/pr-merge.sh}"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-message-field}"
ORIG_PATH="$PATH"
failures=0
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
fail() {
echo "FAIL $1" >&2
failures=$((failures + 1))
}
make_case() {
local name="$1" case_dir
case_dir="$WORK_DIR/$name"
mkdir -p "$case_dir/bin" "$case_dir/agent"
cp "$SUBJECT" "$case_dir/pr-merge.sh"
chmod +x "$case_dir/pr-merge.sh"
cat > "$case_dir/detect-platform.sh" <<'SH'
#!/usr/bin/env bash
detect_platform() { PLATFORM=gitea; printf 'gitea\n'; }
get_repo_owner() { printf 'acme\n'; }
get_repo_name() { printf 'widgets\n'; }
get_remote_host() { printf 'git.example.test\n'; }
get_gitea_token() {
printf 'resolved\n' >> "${MOSAIC_TEST_TOKEN_RESOLUTION_LOG:?}"
if [[ "${MOSAIC_TEST_TOKEN_AVAILABLE:-true}" != "true" ]]; then
return 1
fi
printf 'fixture-token\n'
}
get_gitea_basic_auth() {
printf 'resolved\n' >> "${MOSAIC_TEST_BASIC_RESOLUTION_LOG:?}"
if [[ "${MOSAIC_TEST_BASIC_AVAILABLE:-false}" == "true" ]]; then
printf 'fixture-user:fixture-password\n'
return "${MOSAIC_TEST_BASIC_RC:-0}"
fi
return 1
}
get_gitea_login_for_host() { return 1; }
SH
cat > "$case_dir/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
if [[ "${MOSAIC_TEST_TITLE_MODE:-safe}" == "injection" ]]; then
title='Preserve authors\n\nCo-authored-by: victim <[email protected]>'
else
title='Preserve both branch authors'
fi
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
verified) head_sha=2222222222222222222222222222222222222222 ;;
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
single) head_sha=1111111111111111111111111111111111111111 ;;
*) echo "unknown commits mode" >&2; exit 2 ;;
esac
printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha"
SH
cat > "$case_dir/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
exit 0
SH
cat > "$case_dir/bin/python3" <<'SH'
#!/usr/bin/env bash
for arg in "$@"; do
case "$arg" in
*"Preserve both branch authors"*|*"[email protected]"*)
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
;;
esac
done
exec "${MOSAIC_TEST_REAL_PYTHON:?}" "$@"
SH
cat > "$case_dir/bin/curl" <<'SH'
#!/usr/bin/env bash
set -eu
for arg in "$@"; do
case "$arg" in
*"Preserve both branch authors"*|*"[email protected]"*)
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
;;
esac
done
url=""
method="GET"
out_file=""
data=""
config=""
auth_mode="none"
has_max_filesize=0
has_max_time=0
has_connect_timeout=0
while [[ $# -gt 0 ]]; do
case "$1" in
-o)
out_file="$2"
shift 2
;;
-w)
shift 2
;;
-X)
method="$2"
shift 2
;;
-d|--data|--data-binary)
data="$2"
if [[ "$data" == @* ]]; then
data=$(<"${data#@}")
fi
shift 2
;;
-K|--config)
if [[ "$2" == "-" ]]; then
config=$(cat)
fi
shift 2
;;
--max-filesize)
has_max_filesize=1
shift 2
;;
--max-time)
has_max_time=1
shift 2
;;
--connect-timeout)
has_connect_timeout=1
shift 2
;;
-H|--header|-u|--user)
if [[ "$2" == *"fixture-token"* ]]; then
: > "${MOSAIC_TEST_TOKEN_ARGV_MARKER:?}"
fi
if [[ "$2" == *"fixture-password"* ]]; then
: > "${MOSAIC_TEST_BASIC_ARGV_MARKER:?}"
fi
shift 2
;;
http://*|https://*)
url="$1"
shift
;;
*)
shift
;;
esac
done
if [[ "$config" == *"Authorization: token fixture-token"* ]]; then
auth_mode="token"
: > "${MOSAIC_TEST_AUTH_CONFIG_MARKER:?}"
elif [[ "$config" == *"user = \"fixture-user:fixture-password\""* ]]; then
auth_mode="basic"
: > "${MOSAIC_TEST_BASIC_CONFIG_MARKER:?}"
fi
printf '%s %s %s\n' "$method" "$auth_mode" "$url" >> "${MOSAIC_TEST_CURL_LOG:?}"
printf '%s:%s:%s\n' "$has_max_filesize" "$has_max_time" "$has_connect_timeout" >> "${MOSAIC_TEST_CURL_BOUNDS_LOG:?}"
case "$url" in
*/pulls/42)
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
verified) head_sha=2222222222222222222222222222222222222222 ;;
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
single) head_sha=1111111111111111111111111111111111111111 ;;
*) echo "unknown commits mode" >&2; exit 2 ;;
esac
if [[ "${MOSAIC_TEST_HEAD_MODE:-stable}" == "moved" ]]; then
head_sha=4444444444444444444444444444444444444444
fi
body="{\"head\":{\"sha\":\"$head_sha\"}}"
code=200
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
body='{"message":"token rejected"}'
code=401
fi
;;
*/pulls/42/commits*)
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
verified)
if [[ "${MOSAIC_TEST_EMAIL_MODE:-safe}" == "escape" ]]; then
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+\u001b[[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
else
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
fi
;;
null-login)
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Unresolved Author","email":"[email protected]\n\u001b[31m"}},"author":null}]'
;;
unsafe-identity)
body='[{"sha":"unsafe\n\u001b[31m","commit":{"author":{"name":"Unsafe","email":"not-an-email"}},"author":{"login":"unsafe"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
;;
single)
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
;;
*)
echo "unknown commits mode" >&2
exit 2
;;
esac
code=200
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
body='{"message":"token rejected"}'
code=401
fi
;;
*/pulls/42/merge)
body='{}'
code=200
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "merge" && "$auth_mode" == "token" ]]; then
body='{"message":"token rejected"}'
code=401
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "provider-error" ]]; then
body='{"message":"branch policy rejected\n\u001b[31m"}'
code=409
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "forbidden" ]]; then
body='{"message":"permission denied"}'
code=403
else
printf '%s' "$data" > "${MOSAIC_TEST_MERGE_PAYLOAD:?}"
fi
;;
*/users/*)
body='{"message":"not found"}'
code=404
;;
*)
body='{"message":"unexpected URL"}'
code=500
;;
esac
if [[ -n "$out_file" ]]; then
printf '%s' "$body" > "$out_file"
else
printf '%s' "$body"
fi
printf '%s' "$code"
case "${MOSAIC_TEST_CURL_FAILURE:-none}" in
oversize) exit 63 ;;
stalled) exit 28 ;;
esac
SH
chmod +x "$case_dir/detect-platform.sh" "$case_dir/pr-metadata.sh" \
"$case_dir/ci-queue-wait.sh" "$case_dir/bin/curl" "$case_dir/bin/python3"
printf '%s\n' "$case_dir"
}
run_case() {
local case_dir="$1" mode="$2"
shift 2
MOSAIC_TEST_COMMITS_MODE="$mode" \
MOSAIC_TEST_CURL_LOG="$case_dir/curl.log" \
MOSAIC_TEST_CURL_BOUNDS_LOG="$case_dir/curl-bounds.log" \
MOSAIC_TEST_MERGE_PAYLOAD="$case_dir/merge-payload.json" \
MOSAIC_TEST_TOKEN_ARGV_MARKER="$case_dir/token-in-argv" \
MOSAIC_TEST_BASIC_ARGV_MARKER="$case_dir/basic-in-argv" \
MOSAIC_TEST_AUTH_CONFIG_MARKER="$case_dir/auth-via-config" \
MOSAIC_TEST_BASIC_CONFIG_MARKER="$case_dir/basic-via-config" \
MOSAIC_TEST_TOKEN_RESOLUTION_LOG="$case_dir/token-resolution.log" \
MOSAIC_TEST_BASIC_RESOLUTION_LOG="$case_dir/basic-resolution.log" \
MOSAIC_TEST_METADATA_ARGV_MARKER="$case_dir/metadata-in-argv" \
MOSAIC_TEST_REAL_PYTHON="$(command -v python3)" \
AGENT_WORK_ROOT="$case_dir/agent" \
PATH="$case_dir/bin:$ORIG_PATH" \
"$case_dir/pr-merge.sh" -n 42 "$@"
}
# Verified multi-author path: the non-poster trailer is built from one commit's
# linked author.login and that same commit's author email. No /users lookup.
verified_dir=$(make_case verified)
set +e
verified_output=$(run_case "$verified_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
verified_rc=$?
set -e
if [[ "$verified_rc" -ne 0 ]]; then
fail "verified multi-author merge expected rc=0, got rc=$verified_rc: $verified_output"
elif [[ ! -s "$verified_dir/merge-payload.json" ]]; then
fail "verified multi-author merge did not reach the API payload"
else
python3 - "$verified_dir/merge-payload.json" <<'PY' || fail "verified payload did not preserve squash and exact message fields"
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload == {
"Do": "squash",
"head_commit_id": "2222222222222222222222222222222222222222",
"MergeTitleField": "Preserve both branch authors",
"MergeMessageField": "Co-authored-by: alice <[email protected]>",
}, payload
PY
fi
[[ -e "$verified_dir/auth-via-config" ]] || fail "verified path did not authenticate curl through stdin config"
[[ ! -e "$verified_dir/token-in-argv" ]] || fail "verified path placed the Gitea token in curl argv"
[[ ! -e "$verified_dir/metadata-in-argv" ]] || fail "verified path placed PR title or contributor email in child argv"
[[ "$(wc -l < "$verified_dir/token-resolution.log")" -eq 1 ]] || fail "verified path did not bind inspection and merge to one credential resolution"
if grep -q '/users/' "$verified_dir/curl.log" 2>/dev/null; then
fail "verified path performed a forbidden second /users lookup"
fi
if grep -qv '^1:1:1$' "$verified_dir/curl-bounds.log"; then
fail "verified path did not apply size/max-time/connect-time bounds to every provider download"
fi
# A linked email containing a terminal escape must block before mutation.
escape_email_dir=$(make_case escape-email)
set +e
escape_email_output=$(MOSAIC_TEST_EMAIL_MODE=escape run_case "$escape_email_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
escape_email_rc=$?
set -e
[[ "$escape_email_rc" -ne 0 ]] || fail "control-byte email unexpectedly passed"
[[ "$escape_email_output" == *"unusable linked identity"* ]] || fail "control-byte email refusal lost its diagnostic"
[[ ! -e "$escape_email_dir/merge-payload.json" ]] || fail "control-byte email reached the merge API"
# Curl transfer and duration failures must remain failures even with HTTP 200.
for failure_mode in oversize stalled; do
failure_dir=$(make_case "curl-$failure_mode")
set +e
failure_output=$(MOSAIC_TEST_CURL_FAILURE="$failure_mode" run_case "$failure_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
failure_rc=$?
set -e
[[ "$failure_rc" -ne 0 ]] || fail "curl $failure_mode failure was discarded: $failure_output"
[[ ! -e "$failure_dir/merge-payload.json" ]] || fail "curl $failure_mode failure reached the merge API"
done
# The authenticated head is re-read under the mutation credential but cannot
# replace the canonical preflight/review head. A move blocks before enumeration
# or mutation even though the provider returned a valid new SHA.
moved_dir=$(make_case moved-head)
set +e
moved_output=$(MOSAIC_TEST_HEAD_MODE=moved \
run_case "$moved_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
moved_rc=$?
set -e
[[ "$moved_rc" -ne 0 ]] || fail "moved authenticated head unexpectedly passed"
[[ "$moved_output" == *"authenticated PR head moved from reviewed"* ]] || fail "moved head refusal lost its diagnostic"
[[ "$moved_output" == *"tl-mosaic"* ]] || fail "moved head refusal omitted the named escalation principal"
[[ ! -e "$moved_dir/merge-payload.json" ]] || fail "moved head refusal reached the merge API"
moved_sequence=$(awk '{print $1 ":" $2}' "$moved_dir/curl.log" | paste -sd, -)
[[ "$moved_sequence" == "GET:token" ]] || fail "moved head refusal performed post-move inspection/mutation (calls=$moved_sequence)"
# Token resolution failure is not an authentication response. It must fail
# closed instead of borrowing a Basic credential under a different principal.
token_missing_dir=$(make_case token-missing)
set +e
token_missing_output=$(MOSAIC_TEST_TOKEN_AVAILABLE=false MOSAIC_TEST_BASIC_AVAILABLE=true \
run_case "$token_missing_dir" single 2>&1)
token_missing_rc=$?
set -e
[[ "$token_missing_rc" -ne 0 ]] || fail "missing token unexpectedly borrowed Basic Auth"
[[ "$token_missing_output" == *"required Gitea token"* ]] || fail "missing token refusal lost its diagnostic"
[[ ! -e "$token_missing_dir/basic-resolution.log" ]] || fail "missing token resolved Basic Auth after identity failure"
[[ ! -e "$token_missing_dir/curl.log" ]] || fail "missing token reached a provider request"
# A failed Basic resolver must never use its nonempty output or reach mutation.
basic_rc_dir=$(make_case basic-resolver-rc)
set +e
basic_rc_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_BASIC_RC=91 MOSAIC_TEST_FALLBACK_MODE=inspection \
run_case "$basic_rc_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
basic_rc_rc=$?
set -e
[[ "$basic_rc_rc" -ne 0 ]] || fail "failed Basic resolver output unexpectedly authorized a merge: $basic_rc_output"
[[ ! -e "$basic_rc_dir/merge-payload.json" ]] || fail "failed Basic resolver reached the merge API"
# HTTP 401 never changes principals: inspection rejection fails closed without
# resolving or attempting Basic Auth.
fallback_inspect_dir=$(make_case fallback-inspection)
set +e
fallback_inspect_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=inspection \
run_case "$fallback_inspect_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
fallback_inspect_rc=$?
set -e
[[ "$fallback_inspect_rc" -ne 0 ]] || fail "inspection token rejection unexpectedly changed principals"
[[ "$fallback_inspect_output" == *"refusing cross-principal credential fallback"* ]] || fail "inspection token rejection lost its refusal diagnostic"
[[ ! -e "$fallback_inspect_dir/basic-resolution.log" ]] || fail "inspection token rejection resolved Basic Auth"
[[ ! -e "$fallback_inspect_dir/merge-payload.json" ]] || fail "inspection token rejection reached merge mutation"
inspect_sequence=$(awk '{print $1 ":" $2}' "$fallback_inspect_dir/curl.log" | paste -sd, -)
[[ "$inspect_sequence" == "GET:token" ]] || fail "inspection rejection made unexpected provider calls (calls=$inspect_sequence)"
# Token rejection at merge likewise fails closed without cross-principal retry.
fallback_merge_dir=$(make_case fallback-merge)
set +e
fallback_merge_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=merge \
run_case "$fallback_merge_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
fallback_merge_rc=$?
set -e
[[ "$fallback_merge_rc" -ne 0 ]] || fail "merge token rejection unexpectedly changed principals"
[[ "$fallback_merge_output" == *"refusing cross-principal credential fallback"* ]] || fail "merge token rejection lost its refusal diagnostic"
[[ ! -e "$fallback_merge_dir/basic-resolution.log" ]] || fail "merge token rejection resolved Basic Auth"
[[ ! -e "$fallback_merge_dir/merge-payload.json" ]] || fail "merge token rejection recorded a successful payload"
merge_sequence=$(awk '{print $1 ":" $2}' "$fallback_merge_dir/curl.log" | paste -sd, -)
[[ "$merge_sequence" == "GET:token,GET:token,POST:token" ]] || fail "merge rejection made unexpected provider calls (calls=$merge_sequence)"
# BLOCK path: a commit email exists but author.login is null. It must name both
# facts, name the escalation principal, and never reach the merge endpoint.
null_dir=$(make_case null-login)
set +e
null_output=$(run_case "$null_dir" null-login --co-author-trailers --escalate-to tl-mosaic 2>&1)
null_rc=$?
set -e
[[ "$null_rc" -ne 0 ]] || fail "null-login author expected a non-zero BLOCK"
[[ "$null_output" == *"BLOCK"* ]] || fail "null-login author omitted BLOCK diagnostic"
[[ "$null_output" == *"author.login=NULL"* ]] || fail "null-login author omitted the null provider fact"
[[ "$null_output" == *"[email protected]"* ]] || fail "null-login author omitted the commit email fact"
[[ "$null_output" == *'\n\x1b[31m'* ]] || fail "null-login author diagnostic did not escape control characters"
[[ "$null_output" != *$'\033'* ]] || fail "null-login author diagnostic emitted a raw terminal escape"
[[ "$(printf '%s\n' "$null_output" | wc -l)" -eq 1 ]] || fail "null-login author diagnostic permitted newline injection"
[[ "$null_output" == *"tl-mosaic"* ]] || fail "null-login author omitted the named escalation principal"
[[ ! -e "$null_dir/merge-payload.json" ]] || fail "null-login BLOCK still reached the merge API"
# Every provider-derived field in alternate BLOCK diagnostics is log-safe too,
# including an invalid non-head SHA that contains control characters.
unsafe_dir=$(make_case unsafe-identity)
set +e
unsafe_output=$(run_case "$unsafe_dir" unsafe-identity --co-author-trailers --escalate-to tl-mosaic 2>&1)
unsafe_rc=$?
set -e
[[ "$unsafe_rc" -ne 0 ]] || fail "unsafe identity expected a non-zero BLOCK"
[[ "$unsafe_output" == *"unusable linked identity"* ]] || fail "unsafe identity omitted its BLOCK reason"
[[ "$unsafe_output" == *'\n\x1b[31m'* ]] || fail "unsafe identity SHA did not escape control characters"
[[ "$unsafe_output" != *$'\033'* ]] || fail "unsafe identity diagnostic emitted a raw terminal escape"
[[ "$(printf '%s\n' "$unsafe_output" | wc -l)" -eq 1 ]] || fail "unsafe identity diagnostic permitted newline injection"
[[ ! -e "$unsafe_dir/merge-payload.json" ]] || fail "unsafe identity BLOCK still reached the merge API"
# The provider PR title cannot add an unchecked trailer outside the constructed
# message field: multi-line and trailer-shaped titles block before mutation.
title_dir=$(make_case title-injection)
set +e
title_output=$(MOSAIC_TEST_TITLE_MODE=injection \
run_case "$title_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
title_rc=$?
set -e
[[ "$title_rc" -ne 0 ]] || fail "title trailer injection unexpectedly passed"
[[ "$title_output" == *"not one printable, non-trailer line"* ]] || fail "title injection refusal lost its diagnostic"
[[ ! -e "$title_dir/merge-payload.json" ]] || fail "title injection reached the merge API"
# Provider failures remain diagnosable after their temporary response file is
# removed, but provider-controlled control characters stay log-safe.
error_dir=$(make_case provider-error)
set +e
error_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=provider-error \
run_case "$error_dir" single 2>&1)
error_rc=$?
set -e
[[ "$error_rc" -ne 0 ]] || fail "provider error unexpectedly passed"
[[ "$error_output" == *"HTTP 409"* ]] || fail "provider error omitted the HTTP status"
[[ "$error_output" == *"branch policy rejected"* ]] || fail "provider error response was discarded"
[[ "$error_output" == *'\n\x1b[31m'* ]] || fail "provider error response did not escape control characters"
[[ "$error_output" != *$'\033'* ]] || fail "provider error response emitted a raw terminal escape"
[[ "$error_output" != *"Basic Auth fallback"* ]] || fail "provider error advertised removed Basic Auth fallback"
[[ ! -e "$error_dir/basic-resolution.log" ]] || fail "HTTP 409 policy denial incorrectly triggered Basic Auth fallback"
# Authorization denials likewise fail closed instead of changing principals.
forbidden_dir=$(make_case forbidden)
set +e
forbidden_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=forbidden \
run_case "$forbidden_dir" single 2>&1)
forbidden_rc=$?
set -e
[[ "$forbidden_rc" -ne 0 ]] || fail "HTTP 403 authorization denial unexpectedly passed"
[[ "$forbidden_output" == *"HTTP 403"* ]] || fail "authorization denial omitted the HTTP status"
[[ "$forbidden_output" != *"Basic Auth fallback"* ]] || fail "authorization denial advertised removed Basic Auth fallback"
[[ ! -e "$forbidden_dir/basic-resolution.log" ]] || fail "HTTP 403 authorization denial incorrectly triggered Basic Auth fallback"
# The BLOCK destination cannot be generic or inferred after failure: opting in
# without a named principal is refused before any provider operation.
principal_dir=$(make_case missing-principal)
set +e
principal_output=$(run_case "$principal_dir" verified --co-author-trailers 2>&1)
principal_rc=$?
set -e
[[ "$principal_rc" -ne 0 ]] || fail "co-author mode without a named principal unexpectedly passed"
[[ "$principal_output" == *"requires --escalate-to with a named principal"* ]] || fail "missing-principal refusal lost its diagnostic"
[[ ! -e "$principal_dir/merge-payload.json" ]] || fail "missing-principal refusal reached the merge API"
# A trailing value-taking option receives a stable CLI diagnostic instead of a
# set -u unbound-variable crash.
value_dir=$(make_case missing-principal-value)
set +e
value_output=$(run_case "$value_dir" verified --co-author-trailers --escalate-to 2>&1)
value_rc=$?
set -e
[[ "$value_rc" -ne 0 ]] || fail "missing --escalate-to value unexpectedly passed"
[[ "$value_output" == *"--escalate-to requires one principal name"* ]] || fail "missing --escalate-to value lost its diagnostic"
[[ "$value_output" != *"unbound variable"* ]] || fail "missing --escalate-to value crashed under set -u"
[[ ! -e "$value_dir/merge-payload.json" ]] || fail "missing --escalate-to value reached the merge API"
# Negative control: ordinary single-author merge remains byte-for-byte payload
# compatible and hardcoded to squash, with no optional message fields.
single_dir=$(make_case single)
set +e
single_output=$(run_case "$single_dir" single 2>&1)
single_rc=$?
set -e
if [[ "$single_rc" -ne 0 ]]; then
fail "ordinary single-author merge expected rc=0, got rc=$single_rc: $single_output"
elif [[ ! -s "$single_dir/merge-payload.json" ]]; then
fail "ordinary single-author merge did not reach the API payload"
else
python3 - "$single_dir/merge-payload.json" <<'PY' || fail "ordinary single-author payload changed"
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload == {
"Do": "squash",
"head_commit_id": "1111111111111111111111111111111111111111",
}, payload
PY
fi
[[ -e "$single_dir/auth-via-config" ]] || fail "ordinary path did not authenticate curl through stdin config"
[[ ! -e "$single_dir/token-in-argv" ]] || fail "ordinary path placed the Gitea token in curl argv"
[[ "$(wc -l < "$single_dir/token-resolution.log")" -eq 1 ]] || fail "ordinary path did not use exactly one credential resolution"
# Squash is not defaultable: an explicit non-squash method must remain refused.
method_dir=$(make_case method-refusal)
set +e
method_output=$(run_case "$method_dir" single -m merge 2>&1)
method_rc=$?
set -e
[[ "$method_rc" -ne 0 ]] || fail "non-squash method unexpectedly passed"
[[ "$method_output" == *"enforces squash merge only"* ]] || fail "non-squash refusal lost its policy diagnostic"
[[ ! -e "$method_dir/merge-payload.json" ]] || fail "non-squash refusal reached the merge API"
if [[ "$failures" -ne 0 ]]; then
echo "pr-merge message-field regression failed ($failures assertions)" >&2
exit 1
fi
echo "pr-merge message-field regression passed (verified, BLOCK, and unchanged squash control)"
@@ -32,7 +32,6 @@ packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a re
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling) packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
# --- single-suite directories: unmeasured in CI --- # --- single-suite directories: unmeasured in CI ---
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | unmeasured in CI image; stubs tmux via a fake bin dir, likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell", "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-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" "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/systemd/user/test-fleet-units.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-pr-merge-message-field.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": { "dependencies": {
"@mosaicstack/brain": "workspace:*", "@mosaicstack/brain": "workspace:*",
-5
View File
@@ -14,7 +14,6 @@ import { registerTelemetryCommand } from './commands/telemetry.js';
import { registerAgentCommand } from './commands/agent.js'; import { registerAgentCommand } from './commands/agent.js';
import { registerInteractionCommand } from './commands/interaction.js'; import { registerInteractionCommand } from './commands/interaction.js';
import { registerConfigCommand } from './commands/config.js'; import { registerConfigCommand } from './commands/config.js';
import { registerCredentialCommand } from './commands/cred.js';
import { registerFleetCommand } from './commands/fleet.js'; import { registerFleetCommand } from './commands/fleet.js';
import { registerMissionCommand } from './commands/mission.js'; import { registerMissionCommand } from './commands/mission.js';
import { registerUninstallCommand } from './commands/uninstall.js'; import { registerUninstallCommand } from './commands/uninstall.js';
@@ -372,10 +371,6 @@ registerInteractionCommand(program);
registerFleetCommand(program); registerFleetCommand(program);
// ─── credential governance ─────────────────────────────────────────────
registerCredentialCommand(program);
// ─── config ──────────────────────────────────────────────────────────── // ─── config ────────────────────────────────────────────────────────────
registerConfigCommand(program); registerConfigCommand(program);
-795
View File
@@ -1,795 +0,0 @@
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
@@ -131,13 +131,14 @@ async function exists(path: string): Promise<boolean> {
} }
describe('projectRosterV2AgentGeneratedEnv', (): void => { describe('projectRosterV2AgentGeneratedEnv', (): void => {
it('maps a roster-v2 agent to exactly the eight generated projection keys', (): void => { it('maps a roster-v2 agent to exactly the nine generated projection keys', (): void => {
const roster = parseRosterV2(rosterYaml, 'yaml'); const roster = parseRosterV2(rosterYaml, 'yaml');
const agent = roster.agents.find((candidate) => candidate.name === 'coder0'); const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
expect(agent).toBeDefined(); expect(agent).toBeDefined();
const values = projectRosterV2AgentGeneratedEnv(roster, agent!); const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
expect(values).toEqual({ expect(values).toEqual({
MOSAIC_AGENT_NAME: 'coder0', MOSAIC_AGENT_NAME: 'coder0',
MOSAIC_GIT_IDENTITY: 'coder0',
MOSAIC_AGENT_CLASS: 'code', MOSAIC_AGENT_CLASS: 'code',
MOSAIC_AGENT_RUNTIME: 'pi', MOSAIC_AGENT_RUNTIME: 'pi',
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol', MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
@@ -422,6 +422,7 @@ describe('fleet roster parsing', () => {
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe( expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
[ [
'MOSAIC_AGENT_NAME=coder0', 'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_GIT_IDENTITY=coder0',
// Reflects the roster's canonicalized compatibility class (A3a). // Reflects the roster's canonicalized compatibility class (A3a).
'MOSAIC_AGENT_CLASS=code', 'MOSAIC_AGENT_CLASS=code',
'MOSAIC_AGENT_RUNTIME=codex', 'MOSAIC_AGENT_RUNTIME=codex',
@@ -3799,6 +3800,7 @@ describe('fleet add command', () => {
'utf8', 'utf8',
); );
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0'); expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
expect(envContent).toContain('MOSAIC_GIT_IDENTITY=coder0');
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex'); expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
}); });
+1
View File
@@ -484,6 +484,7 @@ function generateAgentEnvValues(
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory; const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
return { return {
MOSAIC_AGENT_NAME: agent.name, MOSAIC_AGENT_NAME: agent.name,
MOSAIC_GIT_IDENTITY: agent.name,
MOSAIC_AGENT_CLASS: agent.className, MOSAIC_AGENT_CLASS: agent.className,
MOSAIC_AGENT_RUNTIME: agent.runtime, MOSAIC_AGENT_RUNTIME: agent.runtime,
MOSAIC_AGENT_MODEL: agent.modelHint ?? '', MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
@@ -1,67 +0,0 @@
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;
}
@@ -1,415 +0,0 @@
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"');
});
});
@@ -1,467 +0,0 @@
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,
};
}),
);
}
@@ -1,11 +0,0 @@
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;
}
@@ -1,52 +0,0 @@
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;
}
@@ -1,84 +0,0 @@
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;
}
@@ -1,141 +0,0 @@
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;
}
}
@@ -1,97 +0,0 @@
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();
}
});
});
@@ -1,122 +0,0 @@
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),
});
}
@@ -1,15 +0,0 @@
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[];
}
@@ -1,99 +0,0 @@
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/);
});
});
@@ -1,166 +0,0 @@
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);
}
@@ -1,211 +0,0 @@
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();
});
});
@@ -1,549 +0,0 @@
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);
}
}
}
@@ -1,276 +0,0 @@
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');
});
});
@@ -1,943 +0,0 @@
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');
}
}
}
@@ -1,45 +0,0 @@
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;
}
@@ -1,230 +0,0 @@
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');
});
});
-262
View File
@@ -1,262 +0,0 @@
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' },
};
}
}
@@ -1,49 +0,0 @@
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';
};
}
@@ -1,645 +0,0 @@
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']);
});
});
@@ -1,736 +0,0 @@
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);
}
}
@@ -1,189 +0,0 @@
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/);
});
});
@@ -1,468 +0,0 @@
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;
}
}
@@ -1,926 +0,0 @@
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);
});
});
@@ -1,589 +0,0 @@
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' },
};
}
@@ -1,413 +0,0 @@
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');
});
});
-521
View File
@@ -1,521 +0,0 @@
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;
}
}
@@ -358,6 +358,7 @@ function generatedValues(
): Readonly<Record<string, string>> { ): Readonly<Record<string, string>> {
return { return {
MOSAIC_AGENT_NAME: agent.name, MOSAIC_AGENT_NAME: agent.name,
MOSAIC_GIT_IDENTITY: agent.name,
MOSAIC_AGENT_CLASS: agent.className, MOSAIC_AGENT_CLASS: agent.className,
MOSAIC_AGENT_RUNTIME: agent.runtime, MOSAIC_AGENT_RUNTIME: agent.runtime,
MOSAIC_AGENT_MODEL: agent.model, MOSAIC_AGENT_MODEL: agent.model,
@@ -380,7 +380,7 @@ const COMMAND_RECORDS: Readonly<Record<string, RegExp>> = {
const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = { const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
'DATA.DOTENV.FLEET_LAUNCH': 'DATA.DOTENV.FLEET_LAUNCH':
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>', 'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
'DATA.TEXT_TABLE.FLEET_TASKS': 'DATA.TEXT_TABLE.FLEET_TASKS':
'| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |', '| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |',
'DATA.TEXT_DIAGRAM.BACKLOG_FLOW': 'DATA.TEXT_DIAGRAM.BACKLOG_FLOW':
@@ -406,7 +406,7 @@ const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
'DATA.JSON.MUTATION_RESULT': 'DATA.JSON.MUTATION_RESULT':
'{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}', '{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}',
'DATA.DOTENV.GENERATED_ENV': 'DATA.DOTENV.GENERATED_ENV':
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>', 'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
'DATA.YAML.ROSTER_FIELDS': 'DATA.YAML.ROSTER_FIELDS':
'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true', 'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true',
}; };
@@ -922,8 +922,8 @@ describe('fleet operator documentation', (): void => {
); );
expect( expect(
surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'), surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'),
).toHaveLength(858); ).toHaveLength(863);
expect(surfaces).toHaveLength(882); expect(surfaces).toHaveLength(887);
const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8'); const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
const auxiliary: CodeSurface = { const auxiliary: CodeSurface = {
@@ -597,6 +597,7 @@ export function projectRosterV2AgentGeneratedEnv(
): Readonly<Record<string, string>> { ): Readonly<Record<string, string>> {
return { return {
MOSAIC_AGENT_NAME: agent.name, MOSAIC_AGENT_NAME: agent.name,
MOSAIC_GIT_IDENTITY: agent.name,
MOSAIC_AGENT_CLASS: agent.className, MOSAIC_AGENT_CLASS: agent.className,
MOSAIC_AGENT_RUNTIME: agent.runtime, MOSAIC_AGENT_RUNTIME: agent.runtime,
MOSAIC_AGENT_MODEL: agent.model, MOSAIC_AGENT_MODEL: agent.model,
@@ -22,6 +22,7 @@ import {
const generatedValues = { const generatedValues = {
MOSAIC_AGENT_NAME: 'coder0', MOSAIC_AGENT_NAME: 'coder0',
MOSAIC_GIT_IDENTITY: 'coder0',
MOSAIC_AGENT_CLASS: 'code', MOSAIC_AGENT_CLASS: 'code',
MOSAIC_AGENT_RUNTIME: 'pi', MOSAIC_AGENT_RUNTIME: 'pi',
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol', MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
@@ -45,6 +46,7 @@ describe('generated fleet agent environment boundary', (): void => {
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe( expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
[ [
'MOSAIC_AGENT_NAME=coder0', 'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_GIT_IDENTITY=coder0',
'MOSAIC_AGENT_CLASS=code', 'MOSAIC_AGENT_CLASS=code',
'MOSAIC_AGENT_RUNTIME=pi', 'MOSAIC_AGENT_RUNTIME=pi',
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol', 'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
@@ -78,6 +80,22 @@ describe('generated fleet agent environment boundary', (): void => {
expect(String(error)).toMatch(/key=.*sha256=/); expect(String(error)).toMatch(/key=.*sha256=/);
}); });
it.each([
['unsafe-git-identity', 'other/identity'],
['git-identity-mismatch', 'reviewer0'],
])('rejects %s before any launch consumer can use it', (code: string, identity: string): void => {
expect((): void => {
renderGeneratedAgentEnvironment({
...generatedValues,
MOSAIC_GIT_IDENTITY: identity,
});
}).toThrow(
expect.objectContaining({
diagnostic: expect.objectContaining({ code, key: 'MOSAIC_GIT_IDENTITY' }),
}),
);
});
it('rejects unsafe generated paths before any launch consumer can use them', (): void => { it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
expect((): void => { expect((): void => {
renderGeneratedAgentEnvironment({ renderGeneratedAgentEnvironment({
@@ -73,6 +73,7 @@ export class AgentEnvBoundaryError extends Error {
export const GENERATED_AGENT_ENV_KEYS = [ export const GENERATED_AGENT_ENV_KEYS = [
'MOSAIC_AGENT_NAME', 'MOSAIC_AGENT_NAME',
'MOSAIC_GIT_IDENTITY',
'MOSAIC_AGENT_CLASS', 'MOSAIC_AGENT_CLASS',
'MOSAIC_AGENT_RUNTIME', 'MOSAIC_AGENT_RUNTIME',
'MOSAIC_AGENT_MODEL', 'MOSAIC_AGENT_MODEL',
@@ -402,6 +403,7 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, ''); if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
} }
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME'); const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
const gitIdentity = requiredGeneratedValue(values, 'MOSAIC_GIT_IDENTITY');
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS'); const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME'); const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL'); const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
@@ -412,6 +414,12 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
if (!AGENT_NAME.test(name)) if (!AGENT_NAME.test(name))
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name); throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
if (!AGENT_NAME.test(gitIdentity)) {
throw new AgentEnvBoundaryError('unsafe-git-identity', 'MOSAIC_GIT_IDENTITY', gitIdentity);
}
if (gitIdentity !== name) {
throw new AgentEnvBoundaryError('git-identity-mismatch', 'MOSAIC_GIT_IDENTITY', gitIdentity);
}
if (!POLICY_NAME.test(className)) { if (!POLICY_NAME.test(className)) {
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className); throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
} }
-4
View File
@@ -22,8 +22,6 @@ export interface SecureFileSnapshot {
mode: number; mode: number;
dev: number | bigint; dev: number | bigint;
ino: number | bigint; ino: number | bigint;
uid: number;
gid: number;
} }
function sameIdentity( function sameIdentity(
@@ -237,8 +235,6 @@ export function readRegularFileSecure(
mode: Number(opened.mode), mode: Number(opened.mode),
dev: opened.dev, dev: opened.dev,
ino: opened.ino, ino: opened.ino,
uid: opened.uid,
gid: opened.gid,
}; };
} finally { } finally {
closeDescriptors(openedFile.descriptors); closeDescriptors(openedFile.descriptors);
@@ -1405,6 +1405,7 @@ function generatedValues(
): Readonly<Record<string, string>> { ): Readonly<Record<string, string>> {
return { return {
MOSAIC_AGENT_NAME: agent.name, MOSAIC_AGENT_NAME: agent.name,
MOSAIC_GIT_IDENTITY: agent.name,
MOSAIC_AGENT_CLASS: agent.className, MOSAIC_AGENT_CLASS: agent.className,
MOSAIC_AGENT_RUNTIME: agent.runtime, MOSAIC_AGENT_RUNTIME: agent.runtime,
MOSAIC_AGENT_MODEL: agent.model, MOSAIC_AGENT_MODEL: agent.model,