fix(mosaic): close credential evidence gaps

This commit is contained in:
2026-08-05 18:01:41 -05:00
parent d18e49e8f4
commit 26203bd92c
16 changed files with 373 additions and 85 deletions
+2 -2
View File
@@ -470,8 +470,8 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist
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. 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 register atomically, exact token scopes are read back from the provider token object, and rollback removes partial local registration. 2. `AC-CRED-02`: Provisioning against a provider fixture proves Basic Auth is required, bearer-only token minting is refused, both identity axes register atomically, exact token scopes are read back from the provider token object, and rollback removes partial local registration.
3. `AC-CRED-03`: Direct and team grant tests read all applicable permission layers back from provider objects. Deliberately divergent token scope, repo grant, org membership, and team membership cases cannot return `ok`. 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, provider outage, or unavailable exact scope returns `indeterminate`, never success or policy refusal. 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. 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.
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. 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, conflicting concurrent mutation, and production-store leakage into fixture tests. Existing canonical per-seat token consumers continue through the governed adapter. 7. `AC-CRED-07`: Storage tests reject symlinked roots/files, non-regular files, permissive modes, traversal, conflicting concurrent mutation, and production-store leakage into fixture tests. Existing canonical per-seat token consumers continue through the governed adapter.
+16 -8
View File
@@ -69,7 +69,11 @@ Rules:
"endpoint": "GET /api/v1/user", "endpoint": "GET /api/v1/user",
"contentType": "application/json" "contentType": "application/json"
}, },
"tokenCapabilities": [], "tokenCapabilities": {
"state": "not-measured",
"scopes": [],
"source": "runtime-not-authorized"
},
"repositoryPermission": { "repositoryPermission": {
"requested": "write", "requested": "write",
"effective": "write", "effective": "write",
@@ -109,12 +113,12 @@ Fields may be `null` only when their enclosing evidence state explains why. Miss
## Terminal classes ## Terminal classes
| Outcome | Exit | Meaning | Mutation guarantee | Caller action | | 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. | | `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. | | `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. | | `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, stale or absent scope read-back, permission and receive-pack disagreement, missing post-grant read-back, or unknown mutation acknowledgement. | `none`, `applied`, or `unknown`, stated explicitly. Never infer. | Fail closed at the calling gate. Investigate/re-evaluate; do not label the subject refused. | | `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. 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.
@@ -142,7 +146,11 @@ A refusal is intentionally recognizable without prose:
}, },
"evidence": { "evidence": {
"providerIdentity": null, "providerIdentity": null,
"tokenCapabilities": [], "tokenCapabilities": {
"state": "not-measured",
"scopes": [],
"source": "runtime-not-authorized"
},
"repositoryPermission": null, "repositoryPermission": null,
"organizationMembership": null, "organizationMembership": null,
"teamMembership": null, "teamMembership": null,
@@ -499,6 +499,15 @@ 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 resolution_path="$1" reason="$2" identity="$3" host="$4" source="$5"
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" local host="$1"
local script_dir local script_dir
@@ -532,26 +541,14 @@ get_gitea_token() {
local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json" local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json"
if [[ -r "$_idcred" ]]; then if [[ -r "$_idcred" ]]; then
local _resolved_token local _resolved_token
_resolved_token=$(python3 - "$_idcred" <<'PY' _resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \
import json, sys "$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1
value = json.load(open(sys.argv[1], encoding="utf-8")).get("token") _trace_credential_resolution identity credential-resolved "$_ident" "$host" "$_ident_src"
if not isinstance(value, str) or not value or any(ch.isspace() for ch in value):
raise SystemExit(1)
print(value)
PY
) || return 1
if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \
"$_ident" "$host" "$_ident_src" >&2
fi
printf '%s\n' "$_resolved_token" printf '%s\n' "$_resolved_token"
return 0 return 0
fi fi
if [[ -r "$_idtok" ]]; then if [[ -r "$_idtok" ]]; then
if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then _trace_credential_resolution identity credential-resolved "$_ident" "$host" "$_ident_src"
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \
"$_ident" "$host" "$_ident_src" >&2
fi
cat "$_idtok" cat "$_idtok"
return 0 return 0
fi fi
@@ -607,6 +604,7 @@ PY
echo "${GITEA_TOKEN:-}" echo "${GITEA_TOKEN:-}"
) )
if [[ -n "$token" ]]; then if [[ -n "$token" ]]; then
_trace_credential_resolution shared shared-credential-resolved '<interactive-shared>' "$host" credentials-loader
echo "$token" echo "$token"
return 0 return 0
fi fi
@@ -615,6 +613,7 @@ PY
# 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
_trace_credential_resolution shared shared-credential-resolved '<interactive-shared>' "$host" environment
echo "$GITEA_TOKEN" echo "$GITEA_TOKEN"
return 0 return 0
fi fi
@@ -626,6 +625,7 @@ PY
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
_trace_credential_resolution shared shared-credential-resolved '<interactive-shared>' "$host" git-credentials
echo "$token" echo "$token"
return 0 return 0
fi fi
@@ -24,6 +24,15 @@ 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
resolution_path="$1" reason="$2" trace_identity="$3" trace_host="$4" source="$5"
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
}
# 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
@@ -48,27 +57,15 @@ if [ -n "$ident" ]; 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" idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
if [ -r "$idcred" ]; then if [ -r "$idcred" ]; then
token=$(python3 - "$idcred" <<'PY' token=$(python3 "$script_dir/resolve-credential-envelope.py" \
import json, sys "$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1
value = json.load(open(sys.argv[1], encoding="utf-8")).get("token") trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic
if not isinstance(value, str) or not value or any(ch.isspace() for ch in value):
raise SystemExit(1)
print(value)
PY
) || exit 1
if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
"$ident" "$host" >&2
fi
echo "username=${ident}" echo "username=${ident}"
echo "password=${token}" echo "password=${token}"
exit 0 exit 0
fi fi
if [ -r "$idtok" ]; then if [ -r "$idtok" ]; then
if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
"$ident" "$host" >&2
fi
echo "username=${ident}" echo "username=${ident}"
echo "password=$(cat "$idtok")" echo "password=$(cat "$idtok")"
exit 0 exit 0
@@ -98,10 +95,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
trace_resolution shared 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),
@@ -0,0 +1,78 @@
#!/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) != 5:
refuse("expected path, identity, estate, and host")
path, identity, estate, host = sys.argv[1:]
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")
@@ -34,6 +34,7 @@ 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"
chmod +x "$HELPER" chmod +x "$HELPER"
git -C "$REPO_DIR" init -q git -C "$REPO_DIR" init -q
@@ -84,6 +85,12 @@ 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 set +e
out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-unset.stderr") out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-unset.stderr")
@@ -103,6 +110,12 @@ 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 set +e
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentB 2>"$WORK_DIR/fleet-mismatch.stderr") out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
rc=$? rc=$?
@@ -164,14 +177,42 @@ 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. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea # 7. Governed envelopes use the same binding, owner, mode, and digest checks.
# ---------------------------------------------------------------------------
envelope="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentE.credential.json"
python3 - "$envelope" <<'PY'
import hashlib, json, sys
secret = "agentE-envelope-token"
json.dump({
"schemaVersion": 1, "identity": "agentE", "estate": "homelab",
"host": "git.mosaicstack.dev", "providerLogin": "agentE",
"tokenName": "mosaic-agentE-1", "scopes": ["write:repository"],
"createdAt": "2026-08-05T00:00:00.000Z",
"tokenDigest": hashlib.sha256(secret.encode()).hexdigest(), "token": secret,
}, open(sys.argv[1], "w", encoding="utf-8"))
PY
chmod 600 "$envelope"
out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab)
assert_eq "governed envelope: password" "password=agentE-envelope-token" "$(echo "$out" | grep '^password=')"
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"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential # 9. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
# protocol: this helper only implements get). # 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
+113 -33
View File
@@ -131,6 +131,11 @@ function errorResult(
}, },
evidence: { evidence: {
providerIdentity: null, providerIdentity: null,
tokenCapabilities: {
state: 'not-measured',
scopes: [],
source: 'runtime-not-authorized',
},
repositoryPermission: null, repositoryPermission: null,
writeDifferential: null, writeDifferential: null,
}, },
@@ -220,6 +225,11 @@ function grantErrorResult(
}, },
evidence: { evidence: {
providerIdentity: null, providerIdentity: null,
tokenCapabilities: {
state: 'not-measured',
scopes: [],
source: 'runtime-not-authorized',
},
repositoryPermission: null, repositoryPermission: null,
writeDifferential: null, writeDifferential: null,
collaboratorPermission: null, collaboratorPermission: null,
@@ -586,6 +596,22 @@ export async function executeCredentialWire(
}); });
} }
const locations = lifecycleLocations(options); const locations = lifecycleLocations(options);
try {
const context = await lifecycleContext(options);
if (context.registry.resolve(options.estate, options.host) === undefined) {
return localLifecycleResult('wire', identity, options, {
outcome: 'refused',
code: 'estate-host-mismatch',
message: 'Declared estate does not map exactly to the declared host.',
});
}
} catch {
return localLifecycleResult('wire', identity, options, {
outcome: 'refused',
code: 'estate-host-mismatch',
message: 'Declared estate and host could not be resolved before mutation.',
});
}
const journal = await CredentialAuditJournal.open(locations.stateRoot, { const journal = await CredentialAuditJournal.open(locations.stateRoot, {
operation: 'wire', operation: 'wire',
actor: options.actor, actor: options.actor,
@@ -613,9 +639,15 @@ export async function executeCredentialWire(
.split(/\r?\n/) .split(/\r?\n/)
.filter( .filter(
(line): boolean => (line): boolean =>
!line.startsWith('MOSAIC_GIT_IDENTITY=') && !line.startsWith('GITEA_LOGIN='), !line.startsWith('MOSAIC_GIT_IDENTITY=') &&
!line.startsWith('MOSAIC_CREDENTIAL_ESTATE=') &&
!line.startsWith('GITEA_LOGIN='),
); );
lines.push(`MOSAIC_GIT_IDENTITY=${identity}`, `GITEA_LOGIN=${identity}`); lines.push(
`MOSAIC_GIT_IDENTITY=${identity}`,
`MOSAIC_CREDENTIAL_ESTATE=${options.estate}`,
`GITEA_LOGIN=${identity}`,
);
const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`; const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`;
const handle = await open(temp, 'wx', 0o600); const handle = await open(temp, 'wx', 0o600);
try { try {
@@ -724,43 +756,88 @@ export async function executeCredentialGet(
} }
} }
async function executeAuthorizedInventoryRead(
operation: 'list' | 'audit',
options: CredentialLifecycleCommandOptions,
): Promise<CredentialLifecycleResultDto> {
const locations = lifecycleLocations(options);
const journal = await CredentialAuditJournal.open(locations.stateRoot, {
operation,
actor: options.actor,
identity: options.actor,
estate: options.estate,
host: options.host,
repo: null,
});
await journal.recordIntent(`${operation}-requested`);
try {
if (options.authorityFd === undefined) {
await journal.seal('refused', 'authority-required');
return localLifecycleResult(operation, 'all', options, {
outcome: 'refused',
code: 'authority-required',
message: 'A protected delegated inventory authority is required.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
}
const context = await lifecycleContext(options);
const authority = await lifecycleAuthority(options.actor, options);
const providerIdentity = await context.provider.readIdentity(authority);
if (providerIdentity.login !== options.actor) {
await journal.seal('refused', 'provider-identity-mismatch');
return localLifecycleResult(operation, 'all', options, {
outcome: 'refused',
code: 'provider-identity-mismatch',
message: 'Delegated inventory authority did not match the explicit actor.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
}
const identities =
operation === 'list' ? await context.store.list(options.estate, options.host) : [];
const journals = operation === 'audit' ? await listCredentialJournals(locations.stateRoot) : [];
await journal.recordProviderEvidence({
endpoint: providerIdentity.endpoint,
contentType: providerIdentity.contentType,
decision: 'inventory-authority-verified',
});
await journal.seal('ok', `${operation}-verified`);
return localLifecycleResult(operation, 'all', options, {
outcome: 'ok',
code: `${operation}-verified`,
message:
operation === 'list'
? 'Governed credential bindings were listed under delegated authority.'
: 'Durable credential journal identifiers were read under delegated authority.',
evidence: {
providerIdentity: providerIdentity.login,
token: null,
teaLogin: null,
identities,
journalIds: journals.map((entry): string => entry.id),
},
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch {
await journal.seal('error', 'inventory-read-failed');
return localLifecycleResult(operation, 'all', options, {
outcome: 'error',
code: 'inventory-read-failed',
message: 'Authorized credential inventory read failed.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
}
}
export async function executeCredentialList( export async function executeCredentialList(
options: CredentialLifecycleCommandOptions, options: CredentialLifecycleCommandOptions,
): Promise<CredentialLifecycleResultDto> { ): Promise<CredentialLifecycleResultDto> {
try { return executeAuthorizedInventoryRead('list', options);
const context = await lifecycleContext(options);
const identities = await context.store.list(options.estate, options.host);
return localLifecycleResult('list', 'all', options, {
outcome: 'ok',
code: 'list-verified',
message: 'Governed credential bindings were listed without secrets.',
evidence: { providerIdentity: null, token: null, teaLogin: null, identities, journalIds: [] },
});
} catch {
return localLifecycleResult('list', 'all', options, {
outcome: 'error',
code: 'internal-invariant',
message: 'Credential listing failed.',
});
}
} }
export async function executeCredentialAudit( export async function executeCredentialAudit(
options: CredentialLifecycleCommandOptions, options: CredentialLifecycleCommandOptions,
): Promise<CredentialLifecycleResultDto> { ): Promise<CredentialLifecycleResultDto> {
const journals = await listCredentialJournals(lifecycleLocations(options).stateRoot); return executeAuthorizedInventoryRead('audit', options);
return localLifecycleResult('audit', 'all', options, {
outcome: 'ok',
code: 'audit-verified',
message: 'Durable journal index was read.',
evidence: {
providerIdentity: null,
token: null,
teaLogin: null,
identities: [],
journalIds: journals.map((journal): string => journal.id),
},
});
} }
type PrintableCredentialResult = Pick< type PrintableCredentialResult = Pick<
@@ -885,7 +962,8 @@ export function registerCredentialCommand(parent: Command): void {
.description('List governed identities without reading or printing secret material') .description('List governed identities without reading or printing secret material')
.requiredOption('--estate <estate>', 'Explicit target estate') .requiredOption('--estate <estate>', 'Explicit target estate')
.requiredOption('--host <host>', 'Explicit provider host') .requiredOption('--host <host>', 'Explicit provider host')
.requiredOption('--actor <identity>', 'Explicit audit actor') .requiredOption('--actor <identity>', 'Explicit delegated inventory authority identity')
.requiredOption('--authority-fd <fd>', 'Inherited protected Basic credential fd')
.option('--registry <path>', 'Strict non-secret estate registry') .option('--registry <path>', 'Strict non-secret estate registry')
.option('--token-dir <path>', 'Governed token directory') .option('--token-dir <path>', 'Governed token directory')
.option('--state-dir <path>', 'Durable credential journal root') .option('--state-dir <path>', 'Durable credential journal root')
@@ -901,7 +979,9 @@ export function registerCredentialCommand(parent: Command): void {
.description('List durable credential journals without secret-bearing payloads') .description('List durable credential journals without secret-bearing payloads')
.requiredOption('--estate <estate>', 'Explicit target estate') .requiredOption('--estate <estate>', 'Explicit target estate')
.requiredOption('--host <host>', 'Explicit provider host') .requiredOption('--host <host>', 'Explicit provider host')
.requiredOption('--actor <identity>', 'Explicit audit actor') .requiredOption('--actor <identity>', 'Explicit delegated inventory authority identity')
.requiredOption('--authority-fd <fd>', 'Inherited protected Basic credential fd')
.option('--registry <path>', 'Strict non-secret estate registry')
.option('--state-dir <path>', 'Durable credential journal root') .option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object') .option('--json', 'Emit one machine result object')
.action(async (options: CredentialLifecycleCommandOptions): Promise<void> => { .action(async (options: CredentialLifecycleCommandOptions): Promise<void> => {
@@ -25,6 +25,7 @@ const SAFE_DECISIONS = new Set<string>([
'permission-write', 'permission-write',
'permission-admin', 'permission-admin',
'identity-verified', 'identity-verified',
'inventory-authority-verified',
'scope-verified', 'scope-verified',
'grant-verified', 'grant-verified',
'revoke-verified', 'revoke-verified',
@@ -52,8 +52,15 @@ export interface WriteDifferentialEvidenceDto {
readonly doesNotProve: 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 { export interface CredentialValidationEvidenceDto {
readonly providerIdentity: ProviderIdentityEvidenceDto | null; readonly providerIdentity: ProviderIdentityEvidenceDto | null;
readonly tokenCapabilities: TokenCapabilitiesEvidenceDto;
readonly repositoryPermission: RepositoryPermissionEvidenceDto | null; readonly repositoryPermission: RepositoryPermissionEvidenceDto | null;
readonly writeDifferential: WriteDifferentialEvidenceDto | null; readonly writeDifferential: WriteDifferentialEvidenceDto | null;
} }
@@ -1,4 +1,4 @@
import { chmod, mkdir, symlink, writeFile } from 'node:fs/promises'; import { chmod, copyFile, mkdir, symlink, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { mkdtemp } from 'node:fs/promises'; import { mkdtemp } from 'node:fs/promises';
@@ -132,6 +132,18 @@ describe('phase-1 governed file credential resolver', (): void => {
'git.example.invalid', 'git.example.invalid',
), ),
).resolves.toMatchObject({ identity: 'seat' }); ).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 store.remove('seat', 'homelab', 'git.example.invalid');
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]); await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
}); });
@@ -127,6 +127,17 @@ export class FileCredentialResolver implements CredentialResolver {
'credential envelope failed schema, owner, or mode validation', '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 secret = validateSecret(Buffer.from(envelope.data.token, 'utf8'));
const digest = createHash('sha256').update(secret).digest('hex'); const digest = createHash('sha256').update(secret).digest('hex');
if (envelope.data.tokenDigest !== digest) { if (envelope.data.tokenDigest !== digest) {
+10
View File
@@ -98,6 +98,11 @@ export async function grantDirectRepositoryPermission(
}, },
evidence: { evidence: {
providerIdentity: authorityIdentity, providerIdentity: authorityIdentity,
tokenCapabilities: {
state: 'not-measured',
scopes: [],
source: 'runtime-not-authorized',
},
repositoryPermission: null, repositoryPermission: null,
writeDifferential: null, writeDifferential: null,
collaboratorPermission: null, collaboratorPermission: null,
@@ -241,6 +246,11 @@ export async function grantDirectRepositoryPermission(
}, },
evidence: { evidence: {
providerIdentity: null, providerIdentity: null,
tokenCapabilities: {
state: 'not-measured',
scopes: [],
source: 'runtime-not-authorized',
},
repositoryPermission: null, repositoryPermission: null,
writeDifferential: null, writeDifferential: null,
collaboratorPermission: null, collaboratorPermission: null,
@@ -1,4 +1,4 @@
import { mkdtemp, rm } from 'node:fs/promises'; import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
@@ -32,4 +32,23 @@ describe('host-bound Tea login store', (): void => {
host: 'git.two.invalid', host: 'git.two.invalid',
}); });
}); });
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/);
});
}); });
@@ -28,6 +28,7 @@ interface TeaLoginRecord {
interface TeaConfig { interface TeaConfig {
readonly logins: TeaLoginRecord[]; readonly logins: TeaLoginRecord[];
readonly [key: string]: unknown;
} }
const loginSchema = z const loginSchema = z
@@ -58,6 +59,12 @@ async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open
); );
} }
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 { function missing(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT'; return error instanceof Error && 'code' in error && error.code === 'ENOENT';
} }
@@ -80,14 +87,12 @@ export class TeaLoginStore {
root: directory, root: directory,
maxBytes: 1024 * 1024, maxBytes: 1024 * 1024,
}); });
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) { assertPrivate(snapshot);
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
}
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8'))); const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) { if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation'); throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
} }
current = { logins: decoded.data.logins }; current = decoded.data;
} catch (error: unknown) { } catch (error: unknown) {
if (!missing(error)) throw error; if (!missing(error)) throw error;
} }
@@ -130,6 +135,7 @@ export class TeaLoginStore {
if (missing(error)) return undefined; if (missing(error)) return undefined;
throw error; throw error;
} }
assertPrivate(snapshot);
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8'))); const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) return undefined; if (!decoded.success) return undefined;
const matches = decoded.data.logins.filter( const matches = decoded.data.logins.filter(
@@ -161,6 +167,7 @@ export class TeaLoginStore {
root: directory, root: directory,
maxBytes: 1024 * 1024, maxBytes: 1024 * 1024,
}); });
assertPrivate(snapshot);
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8'))); const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) { if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation'); throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
@@ -171,7 +178,7 @@ export class TeaLoginStore {
const temp = `${this.configPath}.${randomUUID()}.tmp`; const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600); const handle = await open(temp, 'wx', 0o600);
try { try {
await handle.writeFile(stringify({ logins }), 'utf8'); await handle.writeFile(stringify({ ...decoded.data, logins }), 'utf8');
await handle.sync(); await handle.sync();
} finally { } finally {
await handle.close(); await handle.close();
@@ -195,6 +202,7 @@ export class TeaLoginStore {
if (missing(error)) return undefined; if (missing(error)) return undefined;
throw error; throw error;
} }
assertPrivate(snapshot);
const decoded: unknown = parse(snapshot.content.toString('utf8')); const decoded: unknown = parse(snapshot.content.toString('utf8'));
if ( if (
typeof decoded !== 'object' || typeof decoded !== 'object' ||
@@ -289,6 +289,11 @@ function result(
}, },
evidence: { evidence: {
providerIdentity: validation?.evidence.providerIdentity ?? null, providerIdentity: validation?.evidence.providerIdentity ?? null,
tokenCapabilities: validation?.evidence.tokenCapabilities ?? {
state: 'not-measured',
scopes: [],
source: 'runtime-not-authorized',
},
repositoryPermission: validation?.evidence.repositoryPermission ?? null, repositoryPermission: validation?.evidence.repositoryPermission ?? null,
writeDifferential: validation?.evidence.writeDifferential ?? null, writeDifferential: validation?.evidence.writeDifferential ?? null,
collaboratorPermission: null, collaboratorPermission: null,
@@ -32,6 +32,11 @@ export type {
} from './credential-result.dto.js'; } from './credential-result.dto.js';
const JSON_CONTENT_TYPE = 'application/json'; 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'; const RECEIVE_PACK_CONTENT_TYPE = 'application/x-git-receive-pack-advertisement';
interface ResultOptions { interface ResultOptions {
@@ -72,6 +77,7 @@ function result(
reason: { code: options.code, message: options.message }, reason: { code: options.code, message: options.message },
evidence: options.evidence ?? { evidence: options.evidence ?? {
providerIdentity: null, providerIdentity: null,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission: null, repositoryPermission: null,
writeDifferential: null, writeDifferential: null,
}, },
@@ -186,6 +192,7 @@ function successfulEvidence(
}; };
return { return {
providerIdentity: subjectIdentity, providerIdentity: subjectIdentity,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission: subjectPermission, repositoryPermission: subjectPermission,
writeDifferential, writeDifferential,
}; };
@@ -226,6 +233,7 @@ async function evaluateGiteaReadValidationUnsafe(
); );
const evidence: CredentialValidationEvidenceDto = { const evidence: CredentialValidationEvidenceDto = {
providerIdentity: null, providerIdentity: null,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission, repositoryPermission,
writeDifferential: null, writeDifferential: null,
}; };
@@ -267,6 +275,7 @@ async function evaluateGiteaReadValidationUnsafe(
); );
const evidence: CredentialValidationEvidenceDto = { const evidence: CredentialValidationEvidenceDto = {
providerIdentity, providerIdentity,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission, repositoryPermission,
writeDifferential: null, writeDifferential: null,
}; };
@@ -359,6 +368,7 @@ async function evaluateGiteaWriteValidationUnsafe(
const receivePack = await dependencies.provider.probeReceivePack(resolved, request.repo); const receivePack = await dependencies.provider.probeReceivePack(resolved, request.repo);
const evidence: CredentialValidationEvidenceDto = { const evidence: CredentialValidationEvidenceDto = {
providerIdentity: null, providerIdentity: null,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission: permission, repositoryPermission: permission,
writeDifferential: null, writeDifferential: null,
}; };
@@ -400,6 +410,7 @@ async function evaluateGiteaWriteValidationUnsafe(
} }
const baseEvidence: CredentialValidationEvidenceDto = { const baseEvidence: CredentialValidationEvidenceDto = {
providerIdentity: subjectEvidence.identity, providerIdentity: subjectEvidence.identity,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission: subjectEvidence.permission, repositoryPermission: subjectEvidence.permission,
writeDifferential: null, writeDifferential: null,
}; };