fix(cli): Mask a shadowed secret in the rule trace

What this MR does and why?

--show-variables prints a job's rule trace above its variables table. The two disagreed about whether a value was a secret, and the trace was the one that got it wrong.

secretKeys decided what to mask from the winning variable layer only. When a masked variable was shadowed by a same-named value from a later layer, the winner was not secret, so the key never entered the predicate's set and nothing redacted it. The trace, meanwhile, resolves its refs from the pipeline-wide set — which omits the dotenv, job and registry layers — so it was still holding the masked value. It printed it in clear, on stderr and in rule_trace.outcomes[].refs under --json.

The predicate now takes the value the trace resolved, not just the key:

  • a secret winning layer masks every value shown for that key, as before. Its recorded value may have been rewritten after merging by token forwarding or ref expansion, so it cannot be matched on reliably.
  • otherwise the ref is masked only when its value matches one contributed by a secret layer that this key shadowed. Those values are recorded raw by variables.Resolve, so the match is exact.

Matching on the value rather than the name matters, and not only for tidiness. --env, --env-file, preset env: and .glci.env all outrank the API layers and are part of the set rules are evaluated against, so for those the trace really did test the user's own value. Masking by name would have hidden it — glci run --show-variables --env DEPLOY_TOKEN=local-dev would print $DEPLOY_TOKEN ([masked]) while the same string appeared in clear as the literal it was compared against. GitLab draws the same value-not-name distinction: mask_variables_from redacts a masked variable's value wherever that string appears. glci's match here is narrower — per key, and exact — so this does not claim parity beyond the case at hand.

Display only. Rule evaluation, job selection, and --unmask are unchanged.

Steps to reproduce

Needs a masked project CI/CD variable shadowed by a same-named value from a layer the rule trace does not see. Below, DEPLOY_TOKEN is masked at project level and its value is written as <project secret>; the job's own variables: shadow it.

control:
  script: echo c

shadowed:
  variables:
    DEPLOY_TOKEN: "placeholder"
  script: echo s
  rules:
    - if: '$DEPLOY_TOKEN != ""'
glci run --simulate --show-variables --secrets project

Before — the two jobs in one run disagree, and the real value is on screen:

control ── variables ──
  Rules:
    (none — runs by default)
  Variables:
    KEY            VALUE      SOURCE
    DEPLOY_TOKEN   [masked]   api:project

shadowed ── variables ──
  Rules:
    #0  $DEPLOY_TOKEN (<project secret>) != ""  → true
  Variables:
    KEY            VALUE         SOURCE
    DEPLOY_TOKEN   placeholder   job

After:

shadowed ── variables ──
  Rules:
    #0  $DEPLOY_TOKEN ([masked]) != ""  → true

The same holds for --json: rule_trace.outcomes[].refs now carries [masked], and the raw value appears nowhere in the stream. --unmask still prints it, as the deliberate opt-in.

The converse case, which must keep working — a value supplied on the command line shadowing the same masked variable:

glci run --simulate --show-variables --secrets project --env DEPLOY_TOKEN=local-dev
  Rules:
    #0  $DEPLOY_TOKEN (local-dev) != ""  → true

That is the value the rule was tested against, so it is shown. The masked project value is not printed anywhere.

  • Closes #170
  • #172 is the sibling leak — the same omission reaching RuleOutcome.Reason instead of Refs. Out of scope here; separate fix.
  • Independent of !175 (merged). That change drops API-defined keys from the job layer, so the trace's lookup falls through to the API value exactly as before.

Merge request reports

Loading
Loading