loki-mode 9.59.0 → 9.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/autonomy/run.sh CHANGED
@@ -1228,6 +1228,12 @@ if [ -f "$LOCK_LIB" ]; then
1228
1228
  source "$LOCK_LIB"
1229
1229
  fi
1230
1230
 
1231
+ # Guarded browser opener (loki_open_url): the only way run.sh opens a browser.
1232
+ if [ -f "$SCRIPT_DIR/lib/browser-open.sh" ]; then
1233
+ # shellcheck source=lib/browser-open.sh
1234
+ source "$SCRIPT_DIR/lib/browser-open.sh"
1235
+ fi
1236
+
1231
1237
  # Git PR advisory (shared print-only helper for create_session_pr and loki deploy)
1232
1238
  GIT_PR_ADVISORY_LIB="$SCRIPT_DIR/lib/git-pr-advisory.sh"
1233
1239
  if [ -f "$GIT_PR_ADVISORY_LIB" ]; then
@@ -5339,7 +5345,13 @@ except Exception:
5339
5345
  }
5340
5346
 
5341
5347
  #===============================================================================
5342
- # Rule of Two (moat P9): a GitHub token never reaches an agent session.
5348
+ # Rule of Two (moat P9): a GitHub token never reaches an agent session via the
5349
+ # IMPLICIT resolution paths this module withholds (env vars, gh config store,
5350
+ # git credential.helper, SSH agent/ssh command). Not an absolute claim -- see
5351
+ # the disclosed residuals at the end of this header (an explicit named-account
5352
+ # keyring read, a direct ssh/hosts.yml/keychain read outside git); the actual
5353
+ # security boundary is a CI job holding no write token and no SSH agent while
5354
+ # the agent runs.
5343
5355
  #
5344
5356
  # The run reads untrusted issue text and spawns agents that act on it, so a
5345
5357
  # token in the exported environment is one prompt injection away from a push.
@@ -5350,26 +5362,581 @@ except Exception:
5350
5362
  # - _loki_with_github_tokens <cmd...>, for the post-session push/PR paths.
5351
5363
  # LOKI_ALLOW_AGENT_GITHUB_TOKEN=1 (exact value) restores the old inheritance
5352
5364
  # and prints one stderr warning that the agent holds the token.
5365
+ #
5366
+ # BACKLOG 149 (round 2, REJECT rework): the previous fix scoped GH_CONFIG_DIR
5367
+ # to a fresh empty directory but a reviewer confirmed, live, that this does not
5368
+ # close the vulnerability. Two bypasses, both reproduced empirically against
5369
+ # real gh 2.92 + a real macOS Keychain-backed `gh auth login` on 2026-09-27:
5370
+ #
5371
+ # 1. `GH_CONFIG_DIR=<empty dir> gh auth token` (real $HOME, no env token)
5372
+ # still exits 0: gh's resolution is config-dir file, THEN OS keyring
5373
+ # fallback (Keychain on macOS, libsecret on Linux). An empty config dir
5374
+ # does not stop the keyring lookup.
5375
+ # 2. git's own `credential.helper` (`gh auth setup-git` wires
5376
+ # `credential.https://github.com.helper = !gh auth git-credential`, and a
5377
+ # plain `osxkeychain`/`libsecret`/etc. helper may ALSO be configured,
5378
+ # unscoped, ahead of or behind it) is invoked directly by git on any
5379
+ # `git push`/`git credential fill` over HTTPS. This path is git-invoked,
5380
+ # not GH_CONFIG_DIR-mediated at all -- scoping GH_CONFIG_DIR does nothing
5381
+ # to it, and it resolves through the same keyring fallback OR a
5382
+ # completely separate OS-keychain entry that git's own helper (not gh's)
5383
+ # maintains independently.
5384
+ #
5385
+ # Fix, verified against `gh help environment` and empirically against both
5386
+ # bypasses (see the S-18 rework session transcript for the repro/counter-repro
5387
+ # pairs; no real credential value was ever printed during that verification):
5388
+ #
5389
+ # (a) GH_TOKEN/GITHUB_TOKEN/GH_ENTERPRISE_TOKEN/GITHUB_ENTERPRISE_TOKEN are
5390
+ # no longer merely unset -- each is set to a fresh per-process garbage
5391
+ # value (SENTINEL below). `gh help environment` documents the env token
5392
+ # as taking "precedence over previously stored credentials", and this
5393
+ # was confirmed live: with the sentinel present, `gh auth token` AND
5394
+ # `gh auth git-credential get` both echo back the garbage value, never
5395
+ # falling through to the keyring. `gh auth git-credential get` is a pure
5396
+ # read/print in this path -- it does not write to GH_CONFIG_DIR or touch
5397
+ # the keychain, so planting a sentinel has no destructive side effect;
5398
+ # a `git push` that ends up using it simply gets a 401 from GitHub, the
5399
+ # same as any other wrong password.
5400
+ # (b) The sentinel alone does not close a plain `osxkeychain` (or similar)
5401
+ # helper that is configured UNSCOPED (`credential.helper = osxkeychain`,
5402
+ # no gh involved at all) and holds its own independently-cached
5403
+ # credential -- confirmed present as a THIRD, gh-independent store on
5404
+ # the verification machine (git's own keychain entry, distinct from
5405
+ # gh's). GH_TOKEN only feeds gh's own resolution; it does not touch
5406
+ # git's credential-helper chain. So the session additionally gets
5407
+ # `credential.helper=` (empty string) injected via
5408
+ # GIT_CONFIG_COUNT/GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n, appended after
5409
+ # any GIT_CONFIG_COUNT the operator already has set. Per gitcredentials(7):
5410
+ # "If credential.helper is configured to the empty string, this resets
5411
+ # the helper list to empty" -- this is why the reset must be UNSCOPED
5412
+ # (plain `credential.helper`, not `credential.<url>.helper`, since a
5413
+ # URL-scoped reset does not necessarily out-order every already
5414
+ # configured helper) and appended LAST (env-var config values override
5415
+ # every config FILE, per `git help config` on GIT_CONFIG_COUNT, and later
5416
+ # GIT_CONFIG_KEY_n entries are read after earlier ones). Confirmed live:
5417
+ # with this override present, `git credential fill` against github.com
5418
+ # fails closed ("could not read Username") instead of returning a
5419
+ # password line, with no fallback to any other helper. This closes the
5420
+ # git-invoked path directly, independent of GH_CONFIG_DIR or any
5421
+ # GH_TOKEN-family value.
5422
+ #
5423
+ # GH_CONFIG_DIR is still scoped to a fresh empty directory (unconditionally,
5424
+ # same as before) -- it remains correct for the plaintext-hosts.yml-read case
5425
+ # and for any gh subcommand that does not consult GH_TOKEN. $HOME itself stays
5426
+ # untouched, so Claude's own OAuth (F49) is unaffected -- nothing here reads
5427
+ # or writes $HOME/.claude or touches any Anthropic credential path.
5428
+ #
5429
+ # BACKLOG 149 (round 3, REJECT rework): round 2's fix is HTTPS-only. A
5430
+ # round-3 reviewer confirmed, live, that none of the above touches
5431
+ # SSH_AUTH_SOCK or GIT_SSH_COMMAND -- the provider session's real, untouched
5432
+ # environment always has a reachable SSH agent (by this design's own stated
5433
+ # intent of keeping the environment live for Claude's own OAuth), so
5434
+ # `git remote set-url origin git@github.com:<owner>/<repo>.git && git push`
5435
+ # authenticates exactly as an unrestricted session. Reproduced with a
5436
+ # synthetic-only ssh-agent and a throwaway key (never a real credential):
5437
+ # `ssh-add -l` against the inherited SSH_AUTH_SOCK lists the identity, and any
5438
+ # ssh-transport git operation would authenticate with it.
5439
+ #
5440
+ # Fix: SSH_AUTH_SOCK is unset (not sentineled) for the withheld session.
5441
+ # Unlike the GH_TOKEN-family vars, a present-but-wrong SSH_AUTH_SOCK does NOT
5442
+ # stop ssh from falling back: with no usable agent, ssh(1) still tries the
5443
+ # default key files (~/.ssh/id_*) and any `IdentityAgent` socket named in
5444
+ # ~/.ssh/config (and on macOS a launchd agent socket is rediscoverable). A
5445
+ # sentinel therefore buys nothing over unset, so unset is the simpler choice
5446
+ # for THIS var. Those fallbacks are why GIT_SSH_COMMAND=false (below) is the
5447
+ # part that actually closes git's ssh transport: git never runs ssh at all.
5448
+ # An agent that explicitly removes it (`env -u GIT_SSH_COMMAND git push
5449
+ # git@...`) gets ssh back with those fallbacks -- same class as `env -u
5450
+ # GH_TOKEN`, disclosed in the residual list at the end of this header. That
5451
+ # does not contradict round 2's reasoning for sentineling
5452
+ # the token vars, it applies that same reasoning to a var with different
5453
+ # fallback semantics. GIT_SSH_COMMAND is set to `false` (a POSIX builtin that
5454
+ # ignores all arguments and always exits 1): git invokes it in place of ssh
5455
+ # for any ssh-transport fetch/push, and git's own docs (`git help git-config`)
5456
+ # confirm the env var "is overridden when the environment variable is set" --
5457
+ # i.e. GIT_SSH_COMMAND (env) unconditionally outranks core.sshCommand
5458
+ # (config), so setting only the env var closes both an operator-configured
5459
+ # core.sshCommand AND an agent-configured one, with no separate
5460
+ # GIT_CONFIG_COUNT-based core.sshCommand reset needed. This means even an
5461
+ # explicit `ssh -i <path>` invocation made THROUGH git (git fetch/push/clone
5462
+ # over an ssh:// or git@ URL) fails; it does NOT stop an agent from invoking
5463
+ # the real `/usr/bin/ssh` binary directly outside of git, which is a
5464
+ # disclosed residual (see below), the same class as the already-disclosed
5465
+ # `gh auth token -u <username>` and on-disk hosts.yml/keychain reads.
5466
+ #
5467
+ # Loki's own trusted calls (gh() below, _loki_with_github_tokens) restore the
5468
+ # REAL env values (or absence) for the 4 token vars, the real GH_CONFIG_DIR
5469
+ # (or absence), the real GIT_CONFIG_COUNT/KEY/VALUE state (or absence), and
5470
+ # the real SSH_AUTH_SOCK/GIT_SSH_COMMAND state (or absence) around the one
5471
+ # trusted command, then re-apply the withhold afterward -- this repo's own
5472
+ # trusted git operations (create_session_pr, on_run_complete's push,
5473
+ # proof-check.sh) push to an https or ssh GitHub origin, so an SSH-origin
5474
+ # user's Loki-initiated push must still work; the SSH sentinel needs the same
5475
+ # re-grant path as the token vars, not a simpler one.
5476
+ #
5477
+ # Restoring credentials is only safe if the credentialed process does not
5478
+ # also load code the agent controls (round 5). Until round 5 it did: the
5479
+ # trusted push was a `git push` inside the agent's working tree, so the
5480
+ # agent's repo config (.git/hooks, core.hooksPath, core.sshCommand, a
5481
+ # repo-local credential.helper, core.fsmonitor, url.*insteadOf, include/
5482
+ # includeIf) ran holding every restored credential. Now the push goes through
5483
+ # _loki_trusted_push (fresh Loki-owned repo, origin URL read as data and
5484
+ # accepted only as a github.com https/ssh URL) and every trusted gh call runs
5485
+ # from / with GH_REPO (_loki_run_neutral), so no credentialed git or gh process
5486
+ # loads the agent's repo config. What the trusted step still trusts:
5487
+ # - the operator's GLOBAL and SYSTEM git config (they carry the real
5488
+ # credential helper). An agent that writes ~/.gitconfig, /etc/gitconfig or
5489
+ # an included file there gets the same code execution back; this is the
5490
+ # same-user filesystem class as editing ~/.ssh or a shell rc file.
5491
+ # - the branch contents, which are the agent's work by design (Loki pushes
5492
+ # them for review; nothing in them executes during the push).
5493
+ # - the origin OWNER/REPO the agent's config names: the agent can point the
5494
+ # push at another github.com repository. GitHub still enforces the
5495
+ # operator token's permissions there.
5496
+ # A non-GitHub origin is no longer pushed at all (LOKI_AUTO_PR=1 used to push
5497
+ # to any origin).
5498
+ #
5353
5499
  # This is hygiene against a naive injection, not an isolation boundary: code
5354
5500
  # running as the same user can still read the parent's environment block
5355
- # (/proc/<pid>/environ on Linux, sudo on a hosted runner). The boundary is a
5356
- # CI job that holds no write token while the agent runs (see
5501
+ # (/proc/<pid>/environ on Linux, sudo on a hosted runner), the hosts.yml file
5502
+ # directly off disk (F49 keeps $HOME live), or invoke `git -c
5503
+ # credential.helper=...` / `security find-generic-password` /
5504
+ # `env -u GH_TOKEN -u GITHUB_TOKEN gh auth token` / `gh auth token -u
5505
+ # <username>` / `env -u GIT_SSH_COMMAND git push git@...` (ssh then falls back
5506
+ # to ~/.ssh/id_* default keys and any ~/.ssh/config IdentityAgent) / the real
5507
+ # `ssh` binary directly with an explicit `-i` key path or a re-discovered agent
5508
+ # socket (e.g. `launchctl getenv SSH_AUTH_SOCK` on macOS) / git's `ext::`
5509
+ # transport (`git -c protocol.ext.allow=always` with an `ext::<command>` URL,
5510
+ # which runs an arbitrary command as the transport) / GIT_ASKPASS (VS Code's
5511
+ # integrated terminal sets it to a helper that can answer git's credential
5512
+ # prompt over IPC; it is left untouched here) explicitly to route around
5513
+ # this. The boundary is a CI job that holds
5514
+ # no write token and no SSH agent while the agent runs (see
5357
5515
  # .github/workflows/loki-issue-to-pr.yml).
5358
5516
  #===============================================================================
5359
5517
  _LOKI_WITHHELD_TOKENS=""
5518
+ _LOKI_GIT_CONFIG_COUNT_UNSUPPORTED=""
5519
+ _LOKI_GH_TOKEN_REAL_HAD=""
5520
+ _LOKI_GH_TOKEN_REAL_VAL=""
5521
+ _LOKI_GITHUB_TOKEN_REAL_HAD=""
5522
+ _LOKI_GITHUB_TOKEN_REAL_VAL=""
5523
+ _LOKI_GH_ENT_TOKEN_REAL_HAD=""
5524
+ _LOKI_GH_ENT_TOKEN_REAL_VAL=""
5525
+ _LOKI_GITHUB_ENT_TOKEN_REAL_HAD=""
5526
+ _LOKI_GITHUB_ENT_TOKEN_REAL_VAL=""
5527
+ _LOKI_GH_CONFIG_SCOPED=""
5528
+ _LOKI_GH_CONFIG_DIR_HAD=""
5529
+ _LOKI_GH_CONFIG_DIR_OLD=""
5530
+ _LOKI_GIT_CRED_SCOPED=""
5531
+ _LOKI_GIT_CONFIG_COUNT_OLD=""
5532
+ _LOKI_GIT_CONFIG_COUNT_HAD=""
5533
+ _LOKI_GIT_CRED_INDEX=""
5534
+ _LOKI_GH_SENTINEL=""
5535
+ _LOKI_SSH_AUTH_SOCK_HAD=""
5536
+ _LOKI_SSH_AUTH_SOCK_OLD=""
5537
+ _LOKI_GIT_SSH_COMMAND_HAD=""
5538
+ _LOKI_GIT_SSH_COMMAND_OLD=""
5539
+ _LOKI_SSH_WITHHELD=""
5540
+ _LOKI_GH_INHERITED=""
5541
+
5542
+ # Restore the real value (or absence) of one of the 4 token vars, using the
5543
+ # per-var _HAD/_VAL pair captured by _loki_gh_capture. Bash has no portable
5544
+ # array-of-names-by-reference here, so this is spelled out per var rather than
5545
+ # looped over a dynamic name. Also used by the --bg relaunch path to hand the
5546
+ # REAL values to the relaunched child (see call site below).
5547
+ _loki_restore_one_token() {
5548
+ local _var="$1" _had="$2" _val="$3"
5549
+ if [ -n "$_had" ]; then
5550
+ export "$_var=$_val"
5551
+ else
5552
+ unset "$_var"
5553
+ fi
5554
+ }
5555
+
5556
+ # Capture the REAL pre-withhold state exactly once per process: the 4 token
5557
+ # vars, GH_CONFIG_DIR, and GIT_CONFIG_COUNT. Idempotent via _LOKI_WITHHELD_TOKENS
5558
+ # as the guard, so a later _loki_gh_apply (called after a trusted command
5559
+ # restores and re-applies) never re-captures its own sentinel/scoped state as
5560
+ # if it were the original.
5561
+ _loki_gh_capture() {
5562
+ [ -z "$_LOKI_WITHHELD_TOKENS" ] || return 0
5563
+ local _v
5564
+ # GIT_CONFIG_COUNT/GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n were introduced in
5565
+ # git 2.31.0 (2021-03-15); an older git silently ignores them, leaving the
5566
+ # credential.helper reset a no-op with no error -- the exact
5567
+ # fail-open-with-no-signal this fix must not have. Detected once per
5568
+ # process (not on every apply) from `git --version`; a `git version
5569
+ # X.Y.Z` line below 2.31 sets the flag so _loki_withhold_github_tokens can
5570
+ # warn once. Ubuntu 24.04 (2.43) and Debian bookworm (2.39), the two base
5571
+ # images this project ships (Dockerfile, Dockerfile.sandbox), are both
5572
+ # well above this floor; the warning matters for an operator's own older
5573
+ # git (e.g. RHEL 8 ships 2.18).
5574
+ if command -v git >/dev/null 2>&1; then
5575
+ local _gv _gv_major _gv_minor
5576
+ _gv="$(git --version 2>/dev/null)"
5577
+ case "$_gv" in
5578
+ git\ version\ [0-9]*.[0-9]*.*)
5579
+ _gv="${_gv#git version }"
5580
+ _gv_major="${_gv%%.*}"
5581
+ _gv="${_gv#*.}"
5582
+ _gv_minor="${_gv%%.*}"
5583
+ case "$_gv_major" in *[!0-9]*) _gv_major="" ;; esac
5584
+ case "$_gv_minor" in *[!0-9]*) _gv_minor="" ;; esac
5585
+ if [ -n "$_gv_major" ] && [ -n "$_gv_minor" ]; then
5586
+ if [ "$_gv_major" -lt 2 ] || { [ "$_gv_major" -eq 2 ] && [ "$_gv_minor" -lt 31 ]; }; then
5587
+ _LOKI_GIT_CONFIG_COUNT_UNSUPPORTED=1
5588
+ fi
5589
+ fi
5590
+ ;;
5591
+ esac
5592
+ fi
5593
+ # Cross-runtime/nested-`loki start` guard: a bash child spawned by an
5594
+ # ALREADY-withheld process (Bun's delegateToBash forwarding
5595
+ # {...process.env}, or an agent literally invoking `loki start` inside its
5596
+ # own withheld session) inherits the PARENT's sentinel/scoped state as
5597
+ # ordinary env vars. Without this guard this process would capture that
5598
+ # inherited state as if it were the operator's real original and restore
5599
+ # to it for its own trusted calls -- i.e. Loki's own push/PR would use the
5600
+ # inherited garbage sentinel instead of a real credential. Detect the
5601
+ # sentinel shape (see _LOKI_GH_SENTINEL below) and treat it, and the
5602
+ # scoped GH_CONFIG_DIR/credential.helper reset that came with it, as
5603
+ # ABSENT rather than as the real original.
5604
+ local _inherited_sentinel=""
5605
+ case "${GH_TOKEN:-}" in ghp_LOKIWITHHELDsentinel*INVALID) _inherited_sentinel=1 ;; esac
5606
+ # Nested-run rule (BACKLOG 149 round 4): an inherited sentinel means this
5607
+ # run was started from INSIDE an already-withheld session -- an agent
5608
+ # running `loki start` in its own session, or a build started from the
5609
+ # dashboard that start_dashboard spawned after the withhold. Such a run has
5610
+ # no real credentials to restore on ANY channel, and reopening any of them
5611
+ # for its "trusted" push would hand an agent-chosen repo/branch the
5612
+ # operator's credential helper or default SSH keys. So it fails CLOSED on
5613
+ # every channel: _loki_gh_restore becomes a no-op and Loki's own push/PR
5614
+ # from a nested run runs with the withheld state (and fails). Before this
5615
+ # rule the channels disagreed (HTTPS helper chain and default SSH keys
5616
+ # reopened, agent socket closed).
5617
+ [ -z "$_inherited_sentinel" ] || _LOKI_GH_INHERITED=1
5618
+ if [ -n "$_inherited_sentinel" ]; then
5619
+ : # GH_TOKEN/GITHUB_TOKEN/GH_ENTERPRISE_TOKEN/GITHUB_ENTERPRISE_TOKEN
5620
+ # all get the SAME sentinel per _loki_gh_apply, so one check covers
5621
+ # all 4 -- _LOKI_*_REAL_HAD stays unset for each, meaning "restore to
5622
+ # absent", not "restore to this garbage value". The same inherited
5623
+ # process also had SSH_AUTH_SOCK unset and GIT_SSH_COMMAND=false by
5624
+ # this same apply, so _LOKI_SSH_AUTH_SOCK_HAD/_LOKI_GIT_SSH_COMMAND_HAD
5625
+ # stay unset too (restore to absent, not to `false`) -- see the SSH
5626
+ # capture below, gated on this same flag.
5627
+ else
5628
+ if [ -n "${GH_TOKEN+x}" ]; then _LOKI_GH_TOKEN_REAL_HAD=1; _LOKI_GH_TOKEN_REAL_VAL="$GH_TOKEN"; fi
5629
+ if [ -n "${GITHUB_TOKEN+x}" ]; then _LOKI_GITHUB_TOKEN_REAL_HAD=1; _LOKI_GITHUB_TOKEN_REAL_VAL="$GITHUB_TOKEN"; fi
5630
+ if [ -n "${GH_ENTERPRISE_TOKEN+x}" ]; then _LOKI_GH_ENT_TOKEN_REAL_HAD=1; _LOKI_GH_ENT_TOKEN_REAL_VAL="$GH_ENTERPRISE_TOKEN"; fi
5631
+ if [ -n "${GITHUB_ENTERPRISE_TOKEN+x}" ]; then _LOKI_GITHUB_ENT_TOKEN_REAL_HAD=1; _LOKI_GITHUB_ENT_TOKEN_REAL_VAL="$GITHUB_ENTERPRISE_TOKEN"; fi
5632
+ fi
5633
+ # SSH sentinel (BACKLOG 149 round 3): capture the real pre-withhold state
5634
+ # of SSH_AUTH_SOCK and GIT_SSH_COMMAND, same inherited-sentinel guard as
5635
+ # the token vars above (GIT_SSH_COMMAND=false is this fix's own withheld
5636
+ # value, never an operator's real config -- an operator who genuinely
5637
+ # wants `false` as their ssh command is not a real scenario worth
5638
+ # preserving across the guard).
5639
+ if [ -z "$_inherited_sentinel" ]; then
5640
+ if [ -n "${SSH_AUTH_SOCK+x}" ]; then _LOKI_SSH_AUTH_SOCK_HAD=1; _LOKI_SSH_AUTH_SOCK_OLD="$SSH_AUTH_SOCK"; fi
5641
+ if [ -n "${GIT_SSH_COMMAND+x}" ] && [ "$GIT_SSH_COMMAND" != "false" ]; then
5642
+ _LOKI_GIT_SSH_COMMAND_HAD=1
5643
+ _LOKI_GIT_SSH_COMMAND_OLD="$GIT_SSH_COMMAND"
5644
+ fi
5645
+ fi
5646
+ for _v in GH_TOKEN GITHUB_TOKEN GH_ENTERPRISE_TOKEN GITHUB_ENTERPRISE_TOKEN; do
5647
+ _LOKI_WITHHELD_TOKENS="${_LOKI_WITHHELD_TOKENS:+$_LOKI_WITHHELD_TOKENS }$_v"
5648
+ done
5649
+ # An inherited scoped GH_CONFIG_DIR (bash's loki-gh-config.* or the Bun
5650
+ # route's loki-gh-config-*) is likewise not the operator's real original.
5651
+ # TMPDIR is normalized first: on macOS it ends in "/", so bash's own
5652
+ # mktemp path has "//" while Node's tmpdir() (Bun route) strips the slash.
5653
+ # Without accepting both, Bun's scoped dir was mistaken for the operator's.
5654
+ local _tmp_root="${TMPDIR:-/tmp}"
5655
+ _tmp_root="${_tmp_root%/}"
5656
+ case "${GH_CONFIG_DIR:-}" in
5657
+ "$_tmp_root"/loki-gh-config.* | "$_tmp_root"//loki-gh-config.* \
5658
+ | "$_tmp_root"/loki-gh-config-* | "$_tmp_root"//loki-gh-config-*) ;;
5659
+ *)
5660
+ if [ -n "${GH_CONFIG_DIR+x}" ]; then
5661
+ _LOKI_GH_CONFIG_DIR_HAD=1
5662
+ _LOKI_GH_CONFIG_DIR_OLD="$GH_CONFIG_DIR"
5663
+ fi
5664
+ ;;
5665
+ esac
5666
+ # An inherited credential.helper reset: if the sentinel was inherited AND
5667
+ # the LAST GIT_CONFIG_KEY/VALUE pair is exactly this fix's reset
5668
+ # (credential.helper=""), treat the count as if that pair were never
5669
+ # added -- i.e. capture the state BENEATH it as the real original, so
5670
+ # restore correctly removes only the inherited pair rather than an
5671
+ # operator-configured one at the same index.
5672
+ if [ -n "${GIT_CONFIG_COUNT:-}" ] && [ "${GIT_CONFIG_COUNT}" -eq "${GIT_CONFIG_COUNT}" ] 2>/dev/null; then
5673
+ local _n=$((GIT_CONFIG_COUNT - 1)) _last_key_var _last_val_var
5674
+ _last_key_var="GIT_CONFIG_KEY_${_n}"
5675
+ _last_val_var="GIT_CONFIG_VALUE_${_n}"
5676
+ if [ -n "$_inherited_sentinel" ] && [ "$_n" -ge 0 ] \
5677
+ && [ "${!_last_key_var:-}" = "credential.helper" ] && [ -z "${!_last_val_var:-}" ]; then
5678
+ if [ "$_n" -gt 0 ]; then
5679
+ _LOKI_GIT_CONFIG_COUNT_HAD=1
5680
+ _LOKI_GIT_CONFIG_COUNT_OLD="$_n"
5681
+ fi
5682
+ _LOKI_GIT_CRED_INDEX="$_n"
5683
+ else
5684
+ _LOKI_GIT_CONFIG_COUNT_HAD=1
5685
+ _LOKI_GIT_CONFIG_COUNT_OLD="$GIT_CONFIG_COUNT"
5686
+ _LOKI_GIT_CRED_INDEX="$GIT_CONFIG_COUNT"
5687
+ fi
5688
+ else
5689
+ _LOKI_GIT_CRED_INDEX=0
5690
+ fi
5691
+ # Fixed for the process lifetime once captured, so restore-then-reapply
5692
+ # (the trusted-call cycle) always re-exports the SAME sentinel rather than
5693
+ # minting a new one that a caller mid-command could not have seen.
5694
+ # Alphanumeric-only after the ghp_ prefix (no underscores): matches the
5695
+ # real gh token shape and the existing token-redaction regex in
5696
+ # autonomy/lib/proof_redact.py (gh[pousr]_[A-Za-z0-9]{20,}), so if this
5697
+ # sentinel ever leaked into a proof/receipt artifact it would still be
5698
+ # caught by the existing redaction filter rather than passing it by shape.
5699
+ _LOKI_GH_SENTINEL="ghp_LOKIWITHHELDsentinel$$${RANDOM}${RANDOM}INVALID"
5700
+ }
5701
+
5702
+ # Unconditionally (re-)export the withheld/scoped state. Never captures --
5703
+ # _loki_gh_capture must run first. Silent: callers that want the one-time
5704
+ # operator-facing log line print it themselves.
5705
+ _loki_gh_apply() {
5706
+ export GH_TOKEN="$_LOKI_GH_SENTINEL" GITHUB_TOKEN="$_LOKI_GH_SENTINEL" \
5707
+ GH_ENTERPRISE_TOKEN="$_LOKI_GH_SENTINEL" GITHUB_ENTERPRISE_TOKEN="$_LOKI_GH_SENTINEL"
5708
+ if [ -z "$_LOKI_GH_CONFIG_SCOPED" ]; then
5709
+ local _empty_cfg
5710
+ _empty_cfg="$(mktemp -d "${TMPDIR:-/tmp}/loki-gh-config.XXXXXX" 2>/dev/null)" || _empty_cfg=""
5711
+ [ -n "$_empty_cfg" ] && _LOKI_GH_CONFIG_SCOPED="$_empty_cfg"
5712
+ fi
5713
+ [ -n "$_LOKI_GH_CONFIG_SCOPED" ] && export GH_CONFIG_DIR="$_LOKI_GH_CONFIG_SCOPED"
5714
+ export "GIT_CONFIG_KEY_${_LOKI_GIT_CRED_INDEX}=credential.helper"
5715
+ export "GIT_CONFIG_VALUE_${_LOKI_GIT_CRED_INDEX}="
5716
+ export GIT_CONFIG_COUNT=$((_LOKI_GIT_CRED_INDEX + 1))
5717
+ _LOKI_GIT_CRED_SCOPED=1
5718
+ # SSH sentinel (BACKLOG 149 round 3): no reachable SSH agent, and any
5719
+ # ssh-transport git operation fails closed via GIT_SSH_COMMAND=false
5720
+ # (env var, so it overrides any core.sshCommand -- see the header comment
5721
+ # above _LOKI_WITHHELD_TOKENS for the git-docs citation).
5722
+ unset SSH_AUTH_SOCK
5723
+ export GIT_SSH_COMMAND=false
5724
+ _LOKI_SSH_WITHHELD=1
5725
+ if command -v gh >/dev/null 2>&1; then
5726
+ # Repo read as data BEFORE the credentials come back; gh then runs
5727
+ # from / (round 5: gh's own git calls must not load the agent's repo
5728
+ # config while holding the real credentials).
5729
+ gh() {
5730
+ local _gh_repo
5731
+ _gh_repo="$(_loki_trusted_repo)"
5732
+ _loki_with_github_tokens _loki_run_neutral "$_gh_repo" command gh "$@"
5733
+ }
5734
+ fi
5735
+ }
5360
5736
 
5737
+ # Put back the REAL pre-withhold state around one trusted command. A no-op in
5738
+ # a run that inherited the sentinel: there is no real state to put back, and
5739
+ # the nested-run rule in _loki_gh_capture is to fail closed on every channel.
5740
+ _loki_gh_restore() {
5741
+ [ -z "$_LOKI_GH_INHERITED" ] || return 0
5742
+ _loki_restore_one_token GH_TOKEN "$_LOKI_GH_TOKEN_REAL_HAD" "$_LOKI_GH_TOKEN_REAL_VAL"
5743
+ _loki_restore_one_token GITHUB_TOKEN "$_LOKI_GITHUB_TOKEN_REAL_HAD" "$_LOKI_GITHUB_TOKEN_REAL_VAL"
5744
+ _loki_restore_one_token GH_ENTERPRISE_TOKEN "$_LOKI_GH_ENT_TOKEN_REAL_HAD" "$_LOKI_GH_ENT_TOKEN_REAL_VAL"
5745
+ _loki_restore_one_token GITHUB_ENTERPRISE_TOKEN "$_LOKI_GITHUB_ENT_TOKEN_REAL_HAD" "$_LOKI_GITHUB_ENT_TOKEN_REAL_VAL"
5746
+ if [ -n "$_LOKI_GH_CONFIG_DIR_HAD" ]; then
5747
+ export GH_CONFIG_DIR="$_LOKI_GH_CONFIG_DIR_OLD"
5748
+ else
5749
+ unset GH_CONFIG_DIR
5750
+ fi
5751
+ if [ -n "$_LOKI_GIT_CONFIG_COUNT_HAD" ]; then
5752
+ export GIT_CONFIG_COUNT="$_LOKI_GIT_CONFIG_COUNT_OLD"
5753
+ else
5754
+ unset GIT_CONFIG_COUNT
5755
+ fi
5756
+ unset "GIT_CONFIG_KEY_${_LOKI_GIT_CRED_INDEX}" "GIT_CONFIG_VALUE_${_LOKI_GIT_CRED_INDEX}"
5757
+ # SSH sentinel restore: give the trusted command back the real SSH agent
5758
+ # and ssh command, so Loki's own push/PR still works over an SSH origin.
5759
+ _loki_restore_one_token SSH_AUTH_SOCK "$_LOKI_SSH_AUTH_SOCK_HAD" "$_LOKI_SSH_AUTH_SOCK_OLD"
5760
+ _loki_restore_one_token GIT_SSH_COMMAND "$_LOKI_GIT_SSH_COMMAND_HAD" "$_LOKI_GIT_SSH_COMMAND_OLD"
5761
+ }
5762
+
5763
+ _LOKI_GH_TRUSTED_DEPTH=0
5764
+
5765
+ # Reentrancy: a trusted function (create_session_pr) calls `gh` directly one
5766
+ # or more times; since the gh() wrapper (_loki_gh_apply) IS
5767
+ # _loki_with_github_tokens, each of those calls re-enters this function while
5768
+ # the OUTER call is still in progress. Without depth tracking, the FIRST
5769
+ # nested `gh` call's own apply-on-exit would re-scope the environment before
5770
+ # the outer caller's second `gh` call (or any bare `git` call) runs, breaking
5771
+ # it silently -- caught in this session before landing: create_session_pr
5772
+ # calls `gh pr list` then `gh pr create`, and the nested apply between them
5773
+ # would have handed the second call sentineled/scoped state instead of real
5774
+ # credentials. Only the OUTERMOST call restores on entry and re-applies on
5775
+ # exit; a nested call finds the depth already > 0 and is a pure passthrough
5776
+ # (real credentials are already in place from the outer restore).
5361
5777
  _loki_with_github_tokens() {
5362
- local _v _rc=0
5363
- for _v in $_LOKI_WITHHELD_TOKENS; do export "${_v?}"; done
5778
+ local _rc=0 _outermost=0
5779
+ if [ "$_LOKI_GH_TRUSTED_DEPTH" -eq 0 ]; then
5780
+ _outermost=1
5781
+ if [ -n "$_LOKI_WITHHELD_TOKENS" ]; then
5782
+ _loki_gh_restore
5783
+ fi
5784
+ fi
5785
+ _LOKI_GH_TRUSTED_DEPTH=$((_LOKI_GH_TRUSTED_DEPTH + 1))
5364
5786
  "$@" || _rc=$?
5365
- for _v in $_LOKI_WITHHELD_TOKENS; do export -n "${_v?}"; done
5787
+ _LOKI_GH_TRUSTED_DEPTH=$((_LOKI_GH_TRUSTED_DEPTH - 1))
5788
+ if [ "$_outermost" -eq 1 ] && [ -n "$_LOKI_WITHHELD_TOKENS" ]; then
5789
+ _loki_gh_apply
5790
+ fi
5791
+ return "$_rc"
5792
+ }
5793
+
5794
+ #-------------------------------------------------------------------------------
5795
+ # BACKLOG 149 round 5: a credentialed git or gh process never loads the agent's
5796
+ # repo config.
5797
+ #
5798
+ # _loki_with_github_tokens hands the real credentials back for one command. If
5799
+ # that command is `git push` run inside the agent's working tree, git loads the
5800
+ # agent-writable repo config first: .git/hooks/pre-push, core.hooksPath,
5801
+ # core.sshCommand, a repo-local credential.helper, core.fsmonitor,
5802
+ # url.insteadOf/pushInsteadOf, include/includeIf. Two reviewers reproduced a
5803
+ # planted pre-push hook reading the real GH_TOKEN and SSH_AUTH_SOCK, and
5804
+ # `git credential fill` returning the operator's password. `-c
5805
+ # core.hooksPath=/dev/null` alone does not stop core.sshCommand, and a list of
5806
+ # per-key overrides would have to stay complete forever.
5807
+ #
5808
+ # So the credentialed step never runs with that config loaded:
5809
+ # - push: read remote.origin.url as DATA (no credentials), accept only a
5810
+ # github.com https/ssh URL, fetch the branch into a fresh Loki-owned repo
5811
+ # (the fetch runs upload-pack in the agent repo WITHOUT credentials), then
5812
+ # push from that repo with the credentials restored.
5813
+ # - gh: run from / with GH_REPO set to the validated origin, so gh's own git
5814
+ # calls find no repository. An explicit --repo/-R still overrides GH_REPO.
5815
+ # The operator's global/system git config still loads (it holds the real
5816
+ # credential helper); an agent that can write ~/.gitconfig is a same-user
5817
+ # filesystem residual, disclosed in the header above.
5818
+ #-------------------------------------------------------------------------------
5819
+
5820
+ # Echo OWNER/REPO for a github.com origin URL, else fail. Accepted forms:
5821
+ # https://github.com/O/R[.git][/], git@github.com:O/R[.git][/],
5822
+ # ssh://git@github.com/O/R[.git][/], host matched case-insensitively. Anything
5823
+ # else (other hosts, SSH host aliases such as github.com-work, embedded
5824
+ # credentials, extra path segments, odd characters) is rejected;
5825
+ # _loki_origin_refusal says why.
5826
+ _loki_github_repo_from_url() {
5827
+ local _u="${1:-}" _lc _pre _p
5828
+ _lc="$(printf '%s' "$_u" | tr '[:upper:]' '[:lower:]')"
5829
+ case "$_lc" in
5830
+ https://github.com/*) _pre="https://github.com/" ;;
5831
+ git@github.com:*) _pre="git@github.com:" ;;
5832
+ ssh://git@github.com/*) _pre="ssh://git@github.com/" ;;
5833
+ *) return 1 ;;
5834
+ esac
5835
+ _p="${_u:${#_pre}}"
5836
+ _p="${_p%/}"
5837
+ _p="${_p%.git}"
5838
+ case "$_p" in
5839
+ */*/* | /* | */ | */.* | .* | *..* | *[!A-Za-z0-9._/-]*) return 1 ;;
5840
+ ?*/?*) printf '%s\n' "$_p" ;;
5841
+ *) return 1 ;;
5842
+ esac
5843
+ }
5844
+
5845
+ # One line saying why an origin is refused. Never echoes the URL itself (it
5846
+ # may carry embedded credentials).
5847
+ _loki_origin_refusal() {
5848
+ local _lc
5849
+ _lc="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')"
5850
+ case "$_lc" in
5851
+ '') echo "this repository has no origin remote" ;;
5852
+ *@github.com[!:/]*|*://github.com[!/]*|*@github.com[!:/]*:*)
5853
+ echo "origin uses an SSH host alias or look-alike of github.com (for example github.com-work from ~/.ssh/config); Loki pushes only to a literal github.com origin, so the push never depends on agent-editable ssh config" ;;
5854
+ *github.com*)
5855
+ echo "origin names github.com but is not a plain https or ssh OWNER/REPO URL (embedded credentials, extra path segments or unusual characters are refused)" ;;
5856
+ *)
5857
+ echo "origin is not on github.com; Loki currently pushes only to github.com (GitHub Enterprise and GitLab are planned)" ;;
5858
+ esac
5859
+ }
5860
+
5861
+ # Origin pin (round 6, reviewer F1): the agent could otherwise repoint origin
5862
+ # at another github.com repository and have Loki push its branch there with
5863
+ # the operator's credentials. Loki never changes origin itself, so the value
5864
+ # recorded before the first iteration (possibly "no origin") is the only one
5865
+ # a trusted push accepts. Recorded once per process.
5866
+ _LOKI_ORIGIN_PINNED=""
5867
+ _LOKI_PINNED_ORIGIN=""
5868
+ _loki_pin_origin() {
5869
+ [ -z "$_LOKI_ORIGIN_PINNED" ] || return 0
5870
+ _LOKI_PINNED_ORIGIN="$(git -C "${TARGET_DIR:-.}" config --get remote.origin.url 2>/dev/null || true)"
5871
+ _LOKI_ORIGIN_PINNED=1
5872
+ }
5873
+
5874
+ # OWNER/REPO of TARGET_DIR's origin, read as data, or nothing.
5875
+ _loki_trusted_repo() {
5876
+ _loki_github_repo_from_url "$(git -C "${TARGET_DIR:-.}" config --get remote.origin.url 2>/dev/null)" 2>/dev/null || true
5877
+ }
5878
+
5879
+ # _loki_run_neutral <owner/repo|""> <cmd...>: run cmd from / so no repository
5880
+ # (and so no repo config) is discovered, with GH_REPO naming the repo for gh.
5881
+ # A subshell, so functions stay callable; callers pass `command gh` to reach
5882
+ # the binary.
5883
+ _loki_run_neutral() {
5884
+ local _repo="$1"
5885
+ shift
5886
+ (
5887
+ cd / || exit 1
5888
+ unset GIT_DIR GIT_WORK_TREE
5889
+ if [ -n "$_repo" ] && [ -z "${GH_REPO:-}" ]; then
5890
+ export GH_REPO="$_repo"
5891
+ fi
5892
+ "$@"
5893
+ )
5894
+ }
5895
+
5896
+ # _loki_trusted_push <runner> <repo-dir> <branch>: push <branch> of the agent
5897
+ # repo to its github.com origin without loading the agent repo's config in
5898
+ # the credentialed process. <runner> is the credential re-grant wrapper
5899
+ # (_loki_with_github_tokens, or on_run_complete's timed _loki_net). The agent
5900
+ # repo's branch gets no upstream set (it did with `push -u`); nothing reads it.
5901
+ _loki_trusted_push() {
5902
+ local _runner="$1" _dir="$2" _branch="$3" _url _tmp _rc=1
5903
+ _dir="$(cd "$_dir" 2>/dev/null && pwd -P)" || return 1
5904
+ git check-ref-format --branch "$_branch" >/dev/null 2>&1 || return 1
5905
+ _url="$(git -C "$_dir" config --get remote.origin.url 2>/dev/null)" || _url=""
5906
+ if ! _loki_github_repo_from_url "$_url" >/dev/null; then
5907
+ log_warn "Not pushing branch '$_branch': $(_loki_origin_refusal "$_url")."
5908
+ return 2
5909
+ fi
5910
+ if [ -n "$_LOKI_ORIGIN_PINNED" ] && [ "$_url" != "$_LOKI_PINNED_ORIGIN" ]; then
5911
+ log_warn "Not pushing branch '$_branch': origin changed during the run (it was $(_loki_github_repo_from_url "$_LOKI_PINNED_ORIGIN" 2>/dev/null || echo "not a GitHub repository") when the run started). Loki does not push to a destination the agent could have chosen."
5912
+ return 2
5913
+ fi
5914
+ _tmp="$(mktemp -d "${TMPDIR:-/tmp}/loki-push.XXXXXX")" || return 1
5915
+ # --update-shallow: a shallow agent repo's history ends in shallow roots,
5916
+ # which a plain fetch rejects while still exiting 0. The rev-parse makes a
5917
+ # silently empty fetch fail here instead of at the push.
5918
+ if git init -q --template= "$_tmp" >/dev/null 2>&1 \
5919
+ && git -C "$_tmp" fetch -q --no-tags --update-shallow "$_dir" "+refs/heads/$_branch:refs/heads/$_branch" >/dev/null 2>&1 \
5920
+ && git -C "$_tmp" rev-parse -q --verify "refs/heads/$_branch" >/dev/null 2>&1; then
5921
+ _rc=0
5922
+ # `-C "$_tmp"`: git starts repository discovery in the fresh repo, so
5923
+ # the agent repo is never found, whatever the caller's cwd.
5924
+ "$_runner" git -C "$_tmp" push -q "$_url" "refs/heads/$_branch:refs/heads/$_branch" || _rc=$?
5925
+ fi
5926
+ rm -rf "$_tmp"
5366
5927
  return "$_rc"
5367
5928
  }
5368
5929
 
5369
5930
  _loki_withhold_github_tokens() {
5370
5931
  local _v _held=""
5932
+ # Before any provider runs, so the pin is the operator's origin, not one
5933
+ # an agent set. Also under the opt-out: it guards the push destination.
5934
+ _loki_pin_origin
5371
5935
  # Operator opt-out (exact value 1): keep the earlier behavior, where the
5372
- # agent inherits the token. That is a Rule of Two exposure, so say so.
5936
+ # agent inherits the token and the real gh config. That is a Rule of Two
5937
+ # exposure, so say so. No GH_CONFIG_DIR/credential.helper scoping under
5938
+ # the opt-out either -- the point of this knob is the old, fully-inherited
5939
+ # behavior.
5373
5940
  if [ "${LOKI_ALLOW_AGENT_GITHUB_TOKEN:-}" = "1" ]; then
5374
5941
  for _v in GH_TOKEN GITHUB_TOKEN GH_ENTERPRISE_TOKEN GITHUB_ENTERPRISE_TOKEN; do
5375
5942
  [ -n "${!_v:-}" ] && _held="${_held:+$_held }$_v"
@@ -5377,21 +5944,30 @@ _loki_withhold_github_tokens() {
5377
5944
  [ -z "$_held" ] || printf '%s\n' "WARNING: LOKI_ALLOW_AGENT_GITHUB_TOKEN=1: the agent session holds the GitHub token ($_held); an injected prompt can push with it (Rule of Two exposure)." >&2
5378
5945
  return 0
5379
5946
  fi
5380
- for _v in GH_TOKEN GITHUB_TOKEN GH_ENTERPRISE_TOKEN GITHUB_ENTERPRISE_TOKEN; do
5381
- [ -n "${!_v:-}" ] || continue
5382
- export -n "${_v?}"
5383
- case " $_LOKI_WITHHELD_TOKENS " in
5384
- *" $_v "*) ;;
5385
- *) _LOKI_WITHHELD_TOKENS="${_LOKI_WITHHELD_TOKENS:+$_LOKI_WITHHELD_TOKENS }$_v" ;;
5386
- esac
5387
- done
5388
- [ -n "$_LOKI_WITHHELD_TOKENS" ] || return 0
5389
- # Only when the binary exists, so `command -v gh` keeps meaning "gh is
5390
- # installed" for every caller that checks it.
5391
- if command -v gh >/dev/null 2>&1; then
5392
- gh() { _loki_with_github_tokens command gh "$@"; }
5947
+ local _first_time=""
5948
+ [ -n "$_LOKI_WITHHELD_TOKENS" ] || _first_time=1
5949
+ # gh documents (`gh help environment`) that an env token "takes precedence
5950
+ # over previously stored credentials", so a garbage-but-present value
5951
+ # makes gh's own resolution (both `gh auth token` and `gh auth
5952
+ # git-credential`, the latter reachable directly by git's
5953
+ # credential.helper) return the garbage value instead of falling through
5954
+ # to a keyring/hosts.yml lookup. GH_CONFIG_DIR stays scoped to a fresh
5955
+ # empty dir (the plaintext-hosts.yml-read case). credential.helper is
5956
+ # additionally reset to empty (gitcredentials(7)): this is git-invoked,
5957
+ # not gh-mediated, so the sentinel does not reach a plain
5958
+ # osxkeychain/libsecret helper on its own.
5959
+ _loki_gh_capture
5960
+ _loki_gh_apply
5961
+ if [ -n "$_first_time" ]; then
5962
+ if [ -n "$_LOKI_GH_INHERITED" ]; then
5963
+ printf '%s\n' "WARNING: this run was started from inside an already-withheld Loki session (inherited Rule of Two sentinel). It has no real GitHub or SSH credentials to restore, so its own push and PR steps will fail closed. Start builds that need to push from a normal shell." >&2
5964
+ else
5965
+ log_info "Withheld from agent sessions (Rule of Two): $_LOKI_WITHHELD_TOKENS (sentineled, not merely unset), gh config store, git credential.helper, SSH agent (SSH_AUTH_SOCK unset, GIT_SSH_COMMAND=false). Loki's own push and PR steps still use the real credentials."
5966
+ fi
5967
+ if [ -n "$_LOKI_GIT_CONFIG_COUNT_UNSUPPORTED" ]; then
5968
+ printf '%s\n' "WARNING: git < 2.31 detected -- GIT_CONFIG_COUNT/GIT_CONFIG_KEY/GIT_CONFIG_VALUE (used to reset credential.helper) are silently ignored on this git version. The git-invoked credential-helper bypass (BACKLOG 149 round 2) is NOT closed on this host; upgrade git to 2.31+ to close it. The GH_TOKEN-family sentinel, GH_CONFIG_DIR scoping, and the SSH_AUTH_SOCK/GIT_SSH_COMMAND withhold above are unaffected and still apply (GIT_SSH_COMMAND is a plain env var, not a GIT_CONFIG_* mechanism, so it needs no version floor)." >&2
5969
+ fi
5393
5970
  fi
5394
- log_info "Withheld from agent sessions (Rule of Two): $_LOKI_WITHHELD_TOKENS. Loki's own push and PR steps still use it."
5395
5971
  }
5396
5972
 
5397
5973
  #===============================================================================
@@ -5462,11 +6038,16 @@ on_run_complete() {
5462
6038
  # The push and gh calls below are the trusted post-session step, so they
5463
6039
  # get the withheld GitHub token back (a plain `timeout 30 gh` would exec
5464
6040
  # the binary and bypass the gh() wrapper).
6041
+ # Round 5: every call runs from / (_loki_run_neutral) with GH_REPO read as
6042
+ # data first, so neither gh nor git loads the agent's repo config while
6043
+ # holding the credentials; the push goes through _loki_trusted_push.
5465
6044
  _loki_net() {
6045
+ local _net_repo
6046
+ _net_repo="$(_loki_trusted_repo)"
5466
6047
  if command -v timeout >/dev/null 2>&1; then
5467
- _loki_with_github_tokens timeout 30 "$@"
6048
+ _loki_with_github_tokens _loki_run_neutral "$_net_repo" timeout 30 "$@"
5468
6049
  else
5469
- _loki_with_github_tokens "$@"
6050
+ _loki_with_github_tokens _loki_run_neutral "$_net_repo" "$@"
5470
6051
  fi
5471
6052
  }
5472
6053
  # Require gh + auth.
@@ -5492,14 +6073,25 @@ on_run_complete() {
5492
6073
  log_info "LOKI_DELEGATE_PR=1: opening a local pull request for branch '$branch'..."
5493
6074
  # Push, then create. Non-interactive (no tty in --bg). Best-effort, each
5494
6075
  # network call bounded by the timeout guard above.
5495
- (cd "${TARGET_DIR:-.}" && _loki_net git push -u origin "$branch") >/dev/null 2>&1 || true
6076
+ # A refused or failed push is reported (the refusal names its reason on
6077
+ # stderr) and ends the delegate step: there is nothing to open a PR from.
6078
+ local _push_rc=0
6079
+ _loki_trusted_push _loki_net "${TARGET_DIR:-.}" "$branch" >/dev/null || _push_rc=$?
6080
+ if [ "$_push_rc" -ne 0 ]; then
6081
+ [ "$_push_rc" -eq 2 ] || log_warn "LOKI_DELEGATE_PR=1: pushing branch '$branch' failed; not opening a pull request."
6082
+ return 0
6083
+ fi
6084
+ # Explicit --repo on every gh call (not only GH_REPO): the repo the push
6085
+ # just validated, so no gh subcommand can fall back to cwd detection.
6086
+ local _pr_repo
6087
+ _pr_repo="$(_loki_trusted_repo)"
5496
6088
  local pr_title
5497
6089
  pr_title="Loki Mode: ${branch}"
5498
6090
  local pr_url=""
5499
6091
  # ENT-4 (idempotent PR): reuse an existing OPEN PR for this head instead of
5500
6092
  # attempting a second create on a platform retry / resume.
5501
6093
  local existing_pr
5502
- existing_pr="$( (cd "${TARGET_DIR:-.}" && _loki_net gh pr list --head "$branch" --state open --json url --jq '.[0].url') 2>/dev/null || true )"
6094
+ existing_pr="$( (cd "${TARGET_DIR:-.}" && _loki_net gh pr list --repo "$_pr_repo" --head "$branch" --state open --json url --jq '.[0].url') 2>/dev/null || true )"
5503
6095
  if [ -n "$existing_pr" ]; then
5504
6096
  _LOKI_DELEGATE_PR_URL="$existing_pr"
5505
6097
  export _LOKI_DELEGATE_PR_URL
@@ -5530,7 +6122,7 @@ ${_del_receipt}"
5530
6122
  fi
5531
6123
  fi
5532
6124
  fi
5533
- pr_url="$( (cd "${TARGET_DIR:-.}" && _loki_net gh pr create --title "$pr_title" --body "$_del_body" --head "$branch") 2>/dev/null || true )"
6125
+ pr_url="$( (cd "${TARGET_DIR:-.}" && _loki_net gh pr create --repo "$_pr_repo" --title "$pr_title" --body "$_del_body" --head "$branch") 2>/dev/null || true )"
5534
6126
  if [ -n "$pr_url" ]; then
5535
6127
  # Export so build_completion_summary folds the url into the summary.
5536
6128
  _LOKI_DELEGATE_PR_URL="$pr_url"
@@ -10611,10 +11203,18 @@ create_session_pr() {
10611
11203
 
10612
11204
  # OPT-IN (LOKI_AUTO_PR=1): legacy auto push + PR, now with the correct base.
10613
11205
  log_info "Pushing agent branch: $branch_name"
10614
- if ! git push -u origin "$branch_name" 2>/dev/null; then
10615
- log_warn "Failed to push agent branch: $branch_name"
11206
+ # Round 5: never a credentialed `git push` inside the agent's repo (its
11207
+ # config -- hooks, sshCommand, credential.helper -- would run holding the
11208
+ # operator's credentials). See _loki_trusted_push.
11209
+ # stderr is left visible: a refusal names its reason there.
11210
+ local _push_rc=0
11211
+ _loki_trusted_push _loki_with_github_tokens . "$branch_name" >/dev/null || _push_rc=$?
11212
+ if [ "$_push_rc" -ne 0 ]; then
11213
+ [ "$_push_rc" -eq 2 ] || log_warn "Failed to push agent branch: $branch_name"
10616
11214
  return 1
10617
11215
  fi
11216
+ local _pr_repo
11217
+ _pr_repo="$(_loki_trusted_repo)"
10618
11218
 
10619
11219
  # Create PR if gh CLI is available
10620
11220
  if command -v gh &>/dev/null; then
@@ -10626,7 +11226,7 @@ create_session_pr() {
10626
11226
  # guarantee explicit and the log honest: if an OPEN PR already exists for
10627
11227
  # this head, reuse its URL instead of attempting a second create.
10628
11228
  local existing_pr
10629
- existing_pr=$(gh pr list --head "$branch_name" --state open --json url --jq '.[0].url' 2>/dev/null || true)
11229
+ existing_pr=$(gh pr list --repo "$_pr_repo" --head "$branch_name" --state open --json url --jq '.[0].url' 2>/dev/null || true)
10630
11230
  if [ -n "$existing_pr" ]; then
10631
11231
  log_info "PR already exists for branch $branch_name: $existing_pr (skipping create)"
10632
11232
  audit_log "PR_EXISTS" "branch=$branch_name,url=$existing_pr"
@@ -10667,6 +11267,7 @@ ${_auto_receipt}"
10667
11267
  fi
10668
11268
  fi
10669
11269
  pr_url=$(gh pr create \
11270
+ --repo "$_pr_repo" \
10670
11271
  --title "Loki Mode: Agent session changes ($branch_name)" \
10671
11272
  --body "$_auto_body" \
10672
11273
  --base "$base" \
@@ -18588,21 +19189,10 @@ start_dashboard() {
18588
19189
  log_info "Dashboard: ${CYAN}${url_scheme}://127.0.0.1:$DASHBOARD_PORT/${NC}"
18589
19190
 
18590
19191
  # Auto-open the dashboard in the browser, but ONLY for an interactive
18591
- # foreground session. Gated on: a TTY on stdout ([ -t 1 ]), not
18592
- # background/detached mode, and not explicitly opted out via
18593
- # LOKI_NO_AUTO_OPEN=1. This keeps CI, --detach, SSH-no-TTY, and piped
18594
- # runs from spawning a browser. Cross-platform: open / xdg-open / start.
18595
- if [ -t 1 ] && [ "${BACKGROUND_MODE:-false}" != "true" ] && [ "${LOKI_NO_AUTO_OPEN:-0}" != "1" ]; then
18596
- local _dash_url="${url_scheme}://127.0.0.1:$DASHBOARD_PORT/"
18597
- if command -v open >/dev/null 2>&1; then
18598
- open "$_dash_url" 2>/dev/null || true
18599
- elif command -v xdg-open >/dev/null 2>&1; then
18600
- xdg-open "$_dash_url" 2>/dev/null || true
18601
- elif command -v cmd.exe >/dev/null 2>&1; then
18602
- # Windows (Git Bash/WSL): `start` is a cmd builtin, not on PATH,
18603
- # so invoke it via cmd.exe. The empty "" is start's title arg.
18604
- cmd.exe /c start "" "$_dash_url" 2>/dev/null || true
18605
- fi
19192
+ # foreground session. loki_open_url (lib/browser-open.sh) refuses on
19193
+ # no TTY, CI, test runners, LOKI_NO_BROWSER=1 or LOKI_NO_AUTO_OPEN=1.
19194
+ if [ "${BACKGROUND_MODE:-false}" != "true" ]; then
19195
+ loki_open_url "${url_scheme}://127.0.0.1:$DASHBOARD_PORT/" 2>/dev/null || true
18606
19196
  fi
18607
19197
  return 0
18608
19198
  else
@@ -27498,6 +28088,15 @@ _loki_session_exit_cleanup() {
27498
28088
  # completion-path reap for the rest of the run.
27499
28089
  _loki_remove_pgid_file
27500
28090
  _loki_remove_temp_self_copy
28091
+ # BACKLOG 149: remove the per-run empty GH_CONFIG_DIR scoped by
28092
+ # _loki_withhold_github_tokens. Same subshell guard as
28093
+ # _loki_remove_pgid_file -- a subshell's EXIT trap must not delete the
28094
+ # live parent's directory ($$ is stable across subshells, BASHPID is not).
28095
+ if [ -n "${_LOKI_GH_CONFIG_SCOPED:-}" ] && [ "${BASHPID:-$$}" = "$$" ]; then
28096
+ case "$_LOKI_GH_CONFIG_SCOPED" in
28097
+ "${TMPDIR:-/tmp}"/loki-gh-config.*) rm -rf "$_LOKI_GH_CONFIG_SCOPED" 2>/dev/null || true ;;
28098
+ esac
28099
+ fi
27501
28100
  return "$exit_code"
27502
28101
  }
27503
28102
 
@@ -27953,10 +28552,19 @@ main() {
27953
28552
 
27954
28553
  # Handle background mode
27955
28554
  if [ "$BACKGROUND_MODE" = "true" ]; then
27956
- # The relaunched runner withholds the tokens itself; it needs them in
27957
- # its environment to do so. This process exits right after launching.
27958
- local _bg_tok
27959
- for _bg_tok in $_LOKI_WITHHELD_TOKENS; do export "${_bg_tok?}"; done
28555
+ # The relaunched runner withholds the tokens itself; it needs the REAL
28556
+ # pre-withhold state in its inherited environment to capture correctly,
28557
+ # not this process's own scoped/sentineled state (BACKLOG 149 round 2:
28558
+ # if the child inherited THIS process's scoped GH_CONFIG_DIR/
28559
+ # GIT_CONFIG_COUNT as if they were the operator's originals, its own
28560
+ # trusted gh/git calls would run with credential.helper reset and
28561
+ # GH_CONFIG_DIR pointed at a directory this process's EXIT trap deletes
28562
+ # seconds later). Full restore, not just the 4 token vars, exactly like
28563
+ # the pre-trusted-call restore. This process exits right after
28564
+ # launching, so nothing here needs to re-apply the withhold afterward.
28565
+ if [ -n "$_LOKI_WITHHELD_TOKENS" ]; then
28566
+ _loki_gh_restore
28567
+ fi
27960
28568
  # Initialize .loki directory first
27961
28569
  mkdir -p .loki/logs
27962
28570
 
@@ -28592,9 +29200,12 @@ main() {
28592
29200
  # then advise the user how to open a PR. Both are no-ops when no agent branch
28593
29201
  # was set up (LOKI_BRANCH_PROTECTION=false) or nothing changed.
28594
29202
  commit_session_changes
28595
- # Trusted post-session step: it gets the GitHub token main() withheld from
28596
- # agent sessions (its push may authenticate through the env token).
28597
- _loki_with_github_tokens create_session_pr
29203
+ # Trusted post-session step. NOT wrapped in _loki_with_github_tokens as a
29204
+ # whole (round 5): that ran every git call inside it (rev-list, merge-base,
29205
+ # push) with the real credentials AND the agent's repo config loaded. Its
29206
+ # push (_loki_trusted_push) and gh calls (gh wrapper) each re-grant the
29207
+ # credentials themselves, outside the agent's repo.
29208
+ create_session_pr
28598
29209
  audit_agent_action "session_stop" "Session ended" "result=$result,iterations=$ITERATION_COUNT"
28599
29210
 
28600
29211
  # The first terminal summary is written before session changes are committed.