Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .github/workflows/reusable-code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,6 @@ jobs:
if: steps.validate.outputs.skipped != 'true'
env:
AGENT_PREFIX: CODE_
CODE_GH_TOKEN: ${{ steps.app-token.outputs.token }}
CODE_TARGET_REPO_DIR: target-repo
CODE_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
CODE_CLOUD_ML_REGION: ${{ inputs.gcp_region }}
Expand All @@ -177,8 +176,6 @@ jobs:
GITHUB_ISSUE_URL: ${{ fromJSON(inputs.event_payload).issue.html_url }}
ISSUE_NUMBER: ${{ fromJSON(inputs.event_payload).issue.number }}
REPO_FULL_NAME: ${{ inputs.source_repo }}
PUSH_TOKEN: ${{ steps.app-token.outputs.token }}
PUSH_TOKEN_SOURCE: github-app
TARGET_BRANCH: main
with:
agent: code
Expand Down
3 changes: 0 additions & 3 deletions .github/workflows/reusable-fix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,6 @@ jobs:
- name: Setup agent environment
env:
AGENT_PREFIX: FIX_
FIX_GH_TOKEN: ${{ steps.app-token.outputs.token }}
FIX_TARGET_REPO_DIR: target-repo
FIX_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
FIX_CLOUD_ML_REGION: ${{ inputs.gcp_region }}
Expand All @@ -369,8 +368,6 @@ jobs:
env:
PR_NUMBER: ${{ steps.context.outputs.pr_number }}
REPO_FULL_NAME: ${{ inputs.source_repo }}
PUSH_TOKEN: ${{ steps.app-token.outputs.token }}
PUSH_TOKEN_SOURCE: github-app
TARGET_BRANCH: ${{ steps.context.outputs.base_ref }}
TRIGGER_SOURCE: ${{ inputs.trigger_source }}
HUMAN_INSTRUCTION: ${{ steps.context.outputs.instruction }}
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/reusable-prioritize.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@ jobs:
- name: Setup agent environment
env:
AGENT_PREFIX: PRIORITIZE_
PRIORITIZE_GH_TOKEN: ${{ steps.app-token.outputs.token }}
PRIORITIZE_ORG: ${{ github.repository_owner }}
PRIORITIZE_PROJECT_NUMBER: ${{ inputs.project_number }}
PRIORITIZE_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
Expand All @@ -144,6 +143,8 @@ jobs:
uses: ./.defaults/
env:
GITHUB_ISSUE_URL: ${{ fromJSON(inputs.event_payload).issue.html_url }}
REPO_FULL_NAME: ${{ inputs.source_repo }}
with:
agent: prioritize
version: ${{ inputs.fullsend_version }}
mint-url: ${{ inputs.mint_url }}
Comment thread
ggallen marked this conversation as resolved.
2 changes: 1 addition & 1 deletion .github/workflows/reusable-retro.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ jobs:
- name: Setup agent environment
env:
AGENT_PREFIX: RETRO_
RETRO_GH_TOKEN: ${{ steps.app-token.outputs.token }}
RETRO_TARGET_REPO_DIR: target-repo
RETRO_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
RETRO_CLOUD_ML_REGION: ${{ inputs.gcp_region }}
Expand All @@ -147,6 +146,7 @@ jobs:
ORIGINATING_URL: ${{ fromJSON(inputs.event_payload).pull_request.html_url || fromJSON(inputs.event_payload).issue.html_url }}
RETRO_COMMENT: ${{ fromJSON(inputs.event_payload).comment.body || '' }}
REPO_FULL_NAME: ${{ inputs.source_repo }}
MINT_REPOS: ${{ steps.repo-parts.outputs.name != '' && (inputs.install_mode == 'per-repo' && steps.repo-parts.outputs.name || format('{0},.fullsend', steps.repo-parts.outputs.name)) || '' }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] maintenance β€” MINT_REPOS expression duplicates logic from mint-token step

This expression is a copy of the repos: input on the mint-token action step (~line 128). If the repo-scoping logic changes in one place (e.g., adding another repo, changing the per-repo vs per-org logic), both must be updated in lockstep. The prior review finding about the empty repo-parts.outputs.name guard was fixed, but the underlying DRY violation remains.

Suggestion: Compute the repos value once in the repo-parts step (or a new dedicated step) and reference the output in both places:

# In repo-parts step:
echo "mint_repos=${REPOS}" >> "$GITHUB_OUTPUT"

# In mint-token:
repos: ${{ steps.repo-parts.outputs.mint_repos }}

# In MINT_REPOS env:
MINT_REPOS: ${{ steps.repo-parts.outputs.mint_repos }}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged β€” the DRY concern is valid. Moving the repos computation to repo-parts output would require changes to the validate-enrollment action (shared infrastructure). Deferring to a follow-up PR to avoid scope creep.

with:
agent: retro
version: ${{ inputs.fullsend_version }}
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/reusable-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,6 @@ jobs:
- name: Setup agent environment
env:
AGENT_PREFIX: REVIEW_
REVIEW_GH_TOKEN: ${{ steps.app-token.outputs.token }}
REVIEW_TARGET_REPO_DIR: target-repo
REVIEW_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
REVIEW_CLOUD_ML_REGION: ${{ inputs.gcp_region }}
Expand All @@ -158,7 +157,6 @@ jobs:
env:
GITHUB_ISSUE_URL: ${{ fromJSON(inputs.event_payload).issue.html_url }}
GITHUB_PR_URL: ${{ fromJSON(inputs.event_payload).pull_request.html_url || fromJSON(inputs.event_payload).issue.html_url }}
REVIEW_TOKEN: ${{ steps.app-token.outputs.token }}
REPO_FULL_NAME: ${{ inputs.source_repo }}
PR_NUMBER: ${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }}
PRIOR_REVIEW_SHA: ${{ steps.prior-review.outputs.prior_sha }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/reusable-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ jobs:
- name: Setup agent environment
env:
AGENT_PREFIX: TRIAGE_
TRIAGE_GH_TOKEN: ${{ steps.app-token.outputs.token }}
TRIAGE_TARGET_REPO_DIR: target-repo
TRIAGE_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
TRIAGE_CLOUD_ML_REGION: ${{ inputs.gcp_region }}
Expand All @@ -145,6 +144,7 @@ jobs:
uses: ./.defaults/
env:
GITHUB_ISSUE_URL: ${{ fromJSON(inputs.event_payload).issue.html_url }}
REPO_FULL_NAME: ${{ inputs.source_repo }}
with:
agent: triage
version: ${{ inputs.fullsend_version }}
Expand Down
10 changes: 6 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -375,15 +375,17 @@ runs:
if [[ -n "${STATUS_RUN_URL}" ]]; then
STATUS_FLAGS+=(--run-url "${STATUS_RUN_URL}")
fi
if [[ -n "${MINT_URL}" ]]; then
STATUS_FLAGS+=(--mint-url "${MINT_URL}")
fi
fi
MINT_FLAGS=()
if [[ -n "${MINT_URL}" ]]; then
MINT_FLAGS+=(--mint-url "${MINT_URL}")
fi
fullsend run "${AGENT}" \
--fullsend-dir "${FULLSEND_DIR}" \
--output-dir "${GITHUB_WORKSPACE}/output" \
--target-repo "${TARGET_REPO}" \
"${STATUS_FLAGS[@]+"${STATUS_FLAGS[@]}"}"
"${STATUS_FLAGS[@]+"${STATUS_FLAGS[@]}"}" \
"${MINT_FLAGS[@]+"${MINT_FLAGS[@]}"}"

- name: Finalize orphaned status comment
if: always() && inputs.agent != '__install_only__' && inputs.status-repo != '' && inputs.status-number != '' && inputs.mint-url != ''
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,8 @@ GitHub event ──► SHIM WORKFLOW (fullsend.yml in enrolled repo)
β•‘ β”‚ β”‚ β•‘
β•‘ β”‚ Post-agent secret scan (redact from extracted output). β”‚ β•‘
β•‘ β”‚ β”‚ β•‘
β•‘ β”‚ Post-script (scripts/post-code.sh, with PUSH_TOKEN): β”‚ β•‘
β•‘ β”‚ Post-script (scripts/post-code.sh, with PUSH_TOKEN, β”‚ β•‘
β•‘ β”‚ minted by the binary via --mint-url): β”‚ β•‘
β•‘ β”‚ 1. Verify feature branch (not main/master) β”‚ β•‘
β•‘ β”‚ 2. Protected-path check β”‚ β•‘
β•‘ β”‚ 3. gitleaks secret scan β”‚ β•‘
Expand Down
4 changes: 4 additions & 0 deletions docs/guides/user/running-agents-locally.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ Add to an env file:

```bash
# fullsend-review.env
# In CI, REVIEW_TOKEN is auto-minted by the binary when --mint-url is provided.
# For local runs, supply a GitHub PAT manually:
REVIEW_TOKEN={github-pat}
GITHUB_PR_URL="https://github.com/{org}/{repo}/pull/{pr_number}"
PR_NUMBER="{pr_number}"
Expand All @@ -166,6 +168,8 @@ Add to an env file:

```bash
# fullsend-code.env
# In CI, GH_TOKEN and PUSH_TOKEN are auto-minted by the binary when --mint-url is provided.
# For local runs, supply GitHub PATs manually:
GH_TOKEN={github-pat}
PUSH_TOKEN={github-pat}
PUSH_TOKEN_SOURCE=github-app
Expand Down
167 changes: 163 additions & 4 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/fullsend-ai/fullsend/internal/harness"
"github.com/fullsend-ai/fullsend/internal/lock"
"github.com/fullsend-ai/fullsend/internal/mintclient"
"github.com/fullsend-ai/fullsend/internal/mintcore"
"github.com/fullsend-ai/fullsend/internal/resolve"
agentruntime "github.com/fullsend-ai/fullsend/internal/runtime"
"github.com/fullsend-ai/fullsend/internal/sandbox"
Expand All @@ -46,6 +47,9 @@ const (
// agentWorkingDirExcludes lists directory patterns that agents may create
// during execution but must never commit. These are added to
// .git/info/exclude before the agent runs so git ignores them entirely.
// statusMintToken is the test seam for minting tokens. Shared by both
// setupStatusNotifier (status comment tokens) and mintAgentToken (agent
// runtime tokens). Tests that override it affect both paths.
var statusMintToken = mintclient.MintToken

var agentWorkingDirExcludes = []string{
Expand Down Expand Up @@ -301,6 +305,24 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
h.Image = resolved
}

// Mint agent token when a mint URL and harness role are both available.
Comment thread
ggallen marked this conversation as resolved.
// Runs before env expansion so minted tokens flow into RunnerEnv and
// host_files via os.Getenv automatically.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] test-gap β€” No test coverage for FULLSEND_MINT_URL env var fallback

This fallback path (os.Getenv("FULLSEND_MINT_URL") when sOpts.mintURL is empty) is not tested. All existing tests pass mintURL directly via sOpts or to mintAgentToken. If the env var name is misspelled or the fallback logic changes, no test would catch it.

Suggestion: Add a test:

func TestRunAgent_FallsBackToFULLSEND_MINT_URL(t *testing.T) {
    t.Setenv("FULLSEND_MINT_URL", "https://mint.example.com")
    // ... setup with empty sOpts.mintURL ...
    // Verify minting is attempted (or appropriate warning emitted)
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1819f92 β€” added TestRunAgent_FallsBackToFULLSEND_MINT_URL that sets the env var with empty sOpts.mintURL, verifies mint is called with the env var URL, and confirms the fallback path works.

mintURL := sOpts.mintURL
if mintURL == "" {
mintURL = os.Getenv("FULLSEND_MINT_URL")
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
}
minted, mintCleanup, err := mintAgentToken(ctx, h.Role, mintURL, printer)
if err != nil {
return fmt.Errorf("agent token minting failed: %w", err)
}
Comment thread
ggallen marked this conversation as resolved.
if mintCleanup != nil {
defer mintCleanup()
}
if !minted && mintURL == "" {
printer.StepWarn("No --mint-url provided; skipping token minting for role " + h.Role)
}

// Expand env vars in runner_env values. FULLSEND_DIR is injected so
// harness configs can reference files relative to the fullsend directory
// (e.g., ${FULLSEND_DIR}/schemas/triage-result.schema.json).
Expand Down Expand Up @@ -408,7 +430,7 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
// post-script β€” and can report cancellation/failure even when the
Comment thread
ggallen marked this conversation as resolved.
// sandbox never starts. See #1859.
if sOpts.statusRepo != "" && sOpts.statusNum > 0 {
notifier, notifyErr := setupStatusNotifier(absFullsendDir, agentName, sOpts, printer)
notifier, notifyErr := setupStatusNotifier(absFullsendDir, h.Role, sOpts, printer)
if notifyErr != nil {
printer.StepWarn("Status notifications disabled: " + notifyErr.Error())
} else {
Expand Down Expand Up @@ -1898,7 +1920,7 @@ func titleCase(s string) string {
return strings.Join(words, " ")
}

func setupStatusNotifier(fullsendDir string, agentName string, sOpts statusOpts, printer *ui.Printer) (*statuscomment.Notifier, error) {
func setupStatusNotifier(fullsendDir string, role string, sOpts statusOpts, printer *ui.Printer) (*statuscomment.Notifier, error) {
parts := strings.SplitN(sOpts.statusRepo, "/", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("--status-repo must be in owner/repo format, got %q", sOpts.statusRepo)
Expand Down Expand Up @@ -1943,11 +1965,11 @@ func setupStatusNotifier(fullsendDir string, agentName string, sOpts statusOpts,
printer.StepWarn(fmt.Sprintf(format, args...))
})

role := resolveRole(agentName)
canonRole := resolveRole(role)
n.SetClientFactory(func(ctx context.Context) (forge.Client, error) {
result, err := statusMintToken(ctx, mintclient.MintRequest{
MintURL: mintURL,
Role: role,
Role: canonRole,
Repos: []string{repo},
})
if err != nil {
Expand Down Expand Up @@ -2019,3 +2041,140 @@ func emitDiagnosticWithContext(printer *ui.Printer, context string, diag harness
printer.StepWarn(msg)
}
}

Comment thread
ggallen marked this conversation as resolved.
type tokenVar struct {
Name string
Value string // empty = use minted token
}

// roleTokenVars maps canonical role names to the additional env vars they
// require beyond GH_TOKEN. These match the vars declared in
// forge.github.runner_env across the harness YAML files.
Comment thread
ggallen marked this conversation as resolved.
var roleTokenVars = map[string][]tokenVar{
"coder": {{Name: "PUSH_TOKEN"}, {Name: "PUSH_TOKEN_SOURCE", Value: "github-app"}},
"review": {{Name: "REVIEW_TOKEN"}},
}

// mintAgentToken mints a GitHub App installation token for the agent's role
// and sets the appropriate env vars so RunnerEnv expansion and host_files
// expansion pick them up. Returns (minted bool, cleanup func, err).
// The caller should defer cleanup() to clear tokens from the process env.
func mintAgentToken(ctx context.Context, role, mintURL string, printer *ui.Printer) (bool, func(), error) {
if mintURL == "" || role == "" {
return false, func() {}, nil
}

repos, err := resolveMintRepos()
if err != nil {
return false, nil, fmt.Errorf("resolving mint repos for role %s: %w", role, err)
}

canonicalRole := resolveRole(role)
if err := mintcore.ValidateRoleName(canonicalRole); err != nil {
return false, nil, fmt.Errorf("invalid role %q: %w", canonicalRole, err)
}
printer.StepStart("Minting agent token (role: " + canonicalRole + ")")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] design β€” statusMintToken test seam shared between status notifier and agent token minting

mintAgentToken reuses the statusMintToken package-level function variable, which was originally a test seam for setupStatusNotifier's client factory (line 1975). These are semantically different operations β€” status comment tokens vs agent runtime tokens β€” that happen to use the same mint API.

Sharing the seam means any test that overrides statusMintToken for status comment behavior also changes agent token minting behavior. All 10+ new mint tests save/restore statusMintToken, which works, but the coupling becomes a problem if status and agent minting ever need different mock behavior in the same test.

Suggestion: Introduce a separate function variable for the agent minting path:

var agentMintToken = mintclient.MintToken

Or, if a shared seam is intentional, add a comment documenting that both paths share this variable and tests must account for it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1819f92 β€” added documentation comment on statusMintToken explaining it is shared between setupStatusNotifier and mintAgentToken, and that tests overriding it affect both paths.


result, err := statusMintToken(ctx, mintclient.MintRequest{
MintURL: mintURL,
Role: canonicalRole,
Repos: repos,
})
if err != nil {
return false, nil, fmt.Errorf("minting agent token for role %s: %w", canonicalRole, err)
}

if !mintTokenPattern.MatchString(result.Token) {
return false, nil, fmt.Errorf("mint returned token with unexpected characters for role %s", canonicalRole)
}

// TODO(ADR-0045 R22): use forge platform context instead of raw env check.
if os.Getenv("GITHUB_ACTIONS") == "true" {
fmt.Fprintf(os.Stderr, "::add-mask::%s\n", result.Token)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] safety β€” os.Setenv mutates process-global env without synchronization documentation

os.Setenv is not safe for concurrent use with os.Getenv in other goroutines. In the current flow, minting completes before sandbox goroutines launch (~line 826), so this is safe today. But the ordering dependency is undocumented and fragile β€” if future changes add parallelism or move minting later, this becomes a data race.

This is a refinement of the prior security finding (which focused on token persistence and was addressed with cleanup). The remaining concern is the implicit ordering contract.

Suggestion: Add a comment above the os.Setenv block:

// NOTE: os.Setenv is not goroutine-safe. Minting MUST complete
// before any goroutines that read env vars (sandbox streaming,
// post-script execution) are launched.

Or, for defense-in-depth, pass tokens through a map/struct to expandRunnerEnv instead of via process env.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1819f92 β€” added the exact comment you suggested above the os.Setenv block documenting the ordering contract: minting must complete before goroutines that read env vars are launched.

// NOTE: os.Setenv is not goroutine-safe. Minting MUST complete
// before any goroutines that read env vars (sandbox streaming,
// post-script execution) are launched.
originals := make(map[string]string)
envVars := []string{"GH_TOKEN"}
if v, ok := os.LookupEnv("GH_TOKEN"); ok {
originals["GH_TOKEN"] = v
}
os.Setenv("GH_TOKEN", result.Token)
Comment thread
ggallen marked this conversation as resolved.

for _, tv := range roleTokenVars[canonicalRole] {
if v, ok := os.LookupEnv(tv.Name); ok {
originals[tv.Name] = v
}
if tv.Value != "" {
os.Setenv(tv.Name, tv.Value)
} else {
Comment thread
ggallen marked this conversation as resolved.
os.Setenv(tv.Name, result.Token)
}
envVars = append(envVars, tv.Name)
}

cleanup := func() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] bug β€” Cleanup destroys pre-existing env var values instead of restoring them

The cleanup function unconditionally calls os.Unsetenv(v) for all token vars. If GH_TOKEN, PUSH_TOKEN, etc. were already set before mintAgentToken was called (e.g., a user-provided PAT for local development, or a workflow-level token), cleanup destroys the original value instead of restoring it.

This is distinct from the prior security finding about token persistence (which was addressed by adding cleanup). The issue here is that cleanup is destructive β€” it doesn't preserve original state.

Scenario: User sets GH_TOKEN=ghp_myPAT in their shell, runs fullsend run code --mint-url .... The mint call fails partway through (e.g., OIDC exchange error). The defer fires, unsetting GH_TOKEN. The user's shell now has no GH_TOKEN at all.

Suggestion: Capture original values before overwriting:

originals := make(map[string]string)
for _, name := range []string{"GH_TOKEN"} {
    if v, ok := os.LookupEnv(name); ok {
        originals[name] = v
    }
}
// ... set new values ...
cleanup := func() {
    for _, v := range envVars {
        if orig, ok := originals[v]; ok {
            os.Setenv(v, orig)
        } else {
            os.Unsetenv(v)
        }
    }
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1819f92 β€” cleanup now captures original values via os.LookupEnv before overwriting and restores them (or unsets if not previously set). Added TestMintAgentToken_CleanupRestoresOriginals that verifies pre-existing GH_TOKEN, PUSH_TOKEN, and PUSH_TOKEN_SOURCE values are restored after cleanup.

for _, v := range envVars {
if orig, ok := originals[v]; ok {
os.Setenv(v, orig)
} else {
os.Unsetenv(v)
}
}
}

expiresAt := strings.Map(func(r rune) rune {
if (r >= '0' && r <= '9') || r == '-' || r == ':' || r == 'T' || r == 'Z' || r == '+' {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] bug β€” ExpiresAt sanitizer strips . β€” mangles fractional-second timestamps

The allow-list permits 0-9, -, :, T, Z, + but omits . (dot). RFC 3339 timestamps may include fractional seconds (e.g., 2026-06-15T12:00:00.123Z). The sanitizer would produce 2026-06-15T12:00:00123Z β€” garbled output shown to the user.

The . character is not a GitHub Actions workflow command metacharacter, so adding it poses no injection risk.

Suggestion: Add . to the allow-list:

if (r >= '0' && r <= '9') || r == '-' || r == ':' || r == 'T' || r == 'Z' || r == '+' || r == '.' {

Flagged by 3/8 agents (Claude, Gemini β€” consensus)

return r
}
return -1
}, result.ExpiresAt)
printer.StepDone("Agent token minted (expires " + expiresAt + ")")
return true, cleanup, nil
}

// resolveMintRepos determines which repos to request token access for.
// MINT_REPOS (comma-separated) takes precedence, falling back to extracting
// the repo name from REPO_FULL_NAME (owner/repo β†’ repo).
func resolveMintRepos() ([]string, error) {
if v := os.Getenv("MINT_REPOS"); v != "" {
var repos []string
for _, r := range strings.Split(v, ",") {
if trimmed := strings.TrimSpace(r); trimmed != "" {
repos = append(repos, trimmed)
}
}
if len(repos) > 0 {
if err := validateRepoNames(repos); err != nil {
return nil, err
}
return repos, nil
}
}

fullName := os.Getenv("REPO_FULL_NAME")
if fullName == "" {
return nil, fmt.Errorf("MINT_REPOS or REPO_FULL_NAME must be set for token minting")
}

parts := strings.SplitN(fullName, "/", 2)
if len(parts) != 2 || parts[1] == "" {
return nil, fmt.Errorf("REPO_FULL_NAME must be in owner/repo format, got %q", fullName)
}
repo := parts[1]
if !mintcore.RepoNamePattern.MatchString(repo) {
return nil, fmt.Errorf("invalid repo name %q from REPO_FULL_NAME: must match %s", repo, mintcore.RepoNamePattern.String())
}
return []string{repo}, nil
}

func validateRepoNames(repos []string) error {
for _, r := range repos {
if !mintcore.RepoNamePattern.MatchString(r) {
return fmt.Errorf("invalid repo name %q in MINT_REPOS: must match %s", r, mintcore.RepoNamePattern.String())
}
}
return nil
}
Loading
Loading