loki-mode 7.71.0 → 7.73.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
@@ -156,9 +156,11 @@
156
156
  # Set to "true" only in trusted environments
157
157
  #
158
158
  # Branch Protection (agent isolation):
159
- # LOKI_BRANCH_PROTECTION - Create feature branch for agent changes (default: false)
159
+ # LOKI_BRANCH_PROTECTION - Create feature branch for agent changes (default: true)
160
160
  # Agent works on loki/session-<timestamp>-<pid> branch
161
- # Creates PR on session end if gh CLI is available
161
+ # Set to "false" to opt out and work on the current branch
162
+ # LOKI_AUTO_PR - Auto push + open a PR on session end (default: off)
163
+ # Default behavior PRINTS the PR command (advisory, no push)
162
164
  #
163
165
  # Process Supervision (opt-in):
164
166
  # LOKI_WATCHDOG - Enable process health monitoring (default: false)
@@ -640,6 +642,13 @@ if [ -f "$LOCK_LIB" ]; then
640
642
  source "$LOCK_LIB"
641
643
  fi
642
644
 
645
+ # Git PR advisory (shared print-only helper for create_session_pr and loki deploy)
646
+ GIT_PR_ADVISORY_LIB="$SCRIPT_DIR/lib/git-pr-advisory.sh"
647
+ if [ -f "$GIT_PR_ADVISORY_LIB" ]; then
648
+ # shellcheck source=lib/git-pr-advisory.sh
649
+ source "$GIT_PR_ADVISORY_LIB"
650
+ fi
651
+
643
652
  # Completion Council (v5.25.0) - Multi-agent completion verification
644
653
  # Source completion council module
645
654
  COUNCIL_SCRIPT="$SCRIPT_DIR/completion-council.sh"
@@ -4908,15 +4917,40 @@ compute_codebase_signature() {
4908
4917
  local dir="${1:-.}"
4909
4918
  ( cd "$dir" 2>/dev/null || exit 0
4910
4919
  if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
4911
- local head dirty porcelain
4912
- head=$(git rev-parse HEAD 2>/dev/null || echo "nohead")
4913
- porcelain=$(git status --porcelain 2>/dev/null | grep -vE '(^...?\.loki/|/\.loki/| \.loki/|\.git/)' || true)
4914
- if [ -z "$porcelain" ]; then
4915
- dirty="clean"
4920
+ # Content-identity signature (gitc:): the hash is over the WORKING-TREE
4921
+ # CONTENT of every tracked + untracked-not-ignored file, independent of
4922
+ # the commit boundary. Whether a file is committed or sitting dirty in
4923
+ # the worktree yields the SAME value. This is what makes reuse robust to
4924
+ # Loki's OWN session commit (commit_session_changes, default-on as of
4925
+ # v7.73.0): a rerun whose only "change" is that the prior run committed
4926
+ # work it had already analyzed still classifies as reuse, not a spurious
4927
+ # "codebase changed -> update". A genuine source edit (committed OR
4928
+ # uncommitted) changes a blob hash and is still detected, and a new
4929
+ # untracked file is detected too. .loki/ is excluded (runtime state).
4930
+ # Paths are enumerated NUL-safe, .loki dropped, sorted, then hashed in
4931
+ # ONE batched `git hash-object --stdin-paths` pass (order-preserving),
4932
+ # so cost is one git process regardless of file count.
4933
+ local gitc gitc_paths gitc_deleted
4934
+ # Tracked files removed from the worktree (but not staged): they have no
4935
+ # content to hash and would make the batched hash-object abort mid-list,
4936
+ # truncating the output and misaligning the path<->hash pairing. Drop
4937
+ # them; their removal from the list is itself the detected change.
4938
+ gitc_deleted=$(git ls-files --deleted -z 2>/dev/null | tr '\0' '\n')
4939
+ gitc_paths=$( { git ls-files -z 2>/dev/null; git ls-files --others --exclude-standard -z 2>/dev/null; } \
4940
+ | tr '\0' '\n' | grep -vE '(^|/)\.loki(/|$)' | LC_ALL=C sort -u )
4941
+ if [ -n "$gitc_deleted" ]; then
4942
+ gitc_paths=$(printf '%s\n' "$gitc_paths" | grep -vxF -f <(printf '%s\n' "$gitc_deleted") || true)
4943
+ fi
4944
+ if [ -z "$gitc_paths" ]; then
4945
+ # No tracked or untracked content (empty/fresh repo): a stable
4946
+ # constant so two empty-tree runs still compare equal (reuse).
4947
+ gitc=$(printf '' | _loki_hash_stdin)
4916
4948
  else
4917
- dirty=$(printf '%s' "$porcelain" | _loki_hash_stdin)
4949
+ gitc=$(printf '%s\n' "$gitc_paths" | git hash-object --stdin-paths 2>/dev/null \
4950
+ | paste -d'\t' - <(printf '%s\n' "$gitc_paths") \
4951
+ | LC_ALL=C sort | _loki_hash_stdin)
4918
4952
  fi
4919
- echo "git:${head}:${dirty}"
4953
+ echo "gitc:${gitc}"
4920
4954
  else
4921
4955
  local listing count total_sz budget maxfiles
4922
4956
  listing=$(find . \
@@ -4968,6 +5002,27 @@ compute_codebase_signature() {
4968
5002
  )
4969
5003
  }
4970
5004
 
5005
+ # Recompute the PRE-content-hash git-mode signature ("git:<HEAD>:<dirty>") for a
5006
+ # one-time format transition: a signature recorded by an older Loki (HEAD +
5007
+ # porcelain) must still be comparable on the first run after the upgrade to the
5008
+ # new content-identity "gitc:" format, or decide would falsely flip to "update".
5009
+ # Echoes the legacy-format value, or "" when not inside a git work tree.
5010
+ _loki_compute_legacy_git_signature() {
5011
+ local dir="${1:-.}"
5012
+ ( cd "$dir" 2>/dev/null || exit 0
5013
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
5014
+ local head dirty porcelain
5015
+ head=$(git rev-parse HEAD 2>/dev/null || echo "nohead")
5016
+ porcelain=$(git status --porcelain 2>/dev/null | grep -vE '(^...?\.loki/|/\.loki/| \.loki/|\.git/)' || true)
5017
+ if [ -z "$porcelain" ]; then
5018
+ dirty="clean"
5019
+ else
5020
+ dirty=$(printf '%s' "$porcelain" | _loki_hash_stdin)
5021
+ fi
5022
+ echo "git:${head}:${dirty}"
5023
+ )
5024
+ }
5025
+
4971
5026
  # Content hash of the generated PRD file itself (NOT the codebase). Used to
4972
5027
  # detect that a user hand-edited the generated PRD: when the file no longer
4973
5028
  # matches the prd_sha Loki recorded after it last wrote the file, the PRD is
@@ -5097,6 +5152,27 @@ except Exception:
5097
5152
  esac
5098
5153
  fi
5099
5154
  ;;
5155
+ git:*)
5156
+ # git-mode format transition: a stored pre-content-hash signature
5157
+ # ("git:<HEAD>:<dirty>") cannot be compared directly against the
5158
+ # new content-identity "gitc:" format, so the first run after the
5159
+ # upgrade would falsely claim "codebase changed". Recompute the
5160
+ # OLD-format signature and compare against the stored value: if it
5161
+ # matches, the tree is unchanged at the old format's trust level
5162
+ # (HEAD + dirty porcelain) -> reuse, honestly. The next persist
5163
+ # upgrades the stored format to "gitc:". Only honor this when the
5164
+ # current signature is the new git format (a real format change),
5165
+ # never when both are old git: (that path already matched above).
5166
+ case "$current" in
5167
+ gitc:*)
5168
+ local legacy
5169
+ legacy=$(_loki_compute_legacy_git_signature "${TARGET_DIR:-.}")
5170
+ if [ -n "$legacy" ] && [ "$legacy" = "$stored" ]; then
5171
+ echo "reuse"; return 0
5172
+ fi
5173
+ ;;
5174
+ esac
5175
+ ;;
5100
5176
  esac
5101
5177
  echo "update"
5102
5178
  fi
@@ -5125,18 +5201,45 @@ persist_prd_signature_if_present() {
5125
5201
  sig=$(compute_codebase_signature "${TARGET_DIR:-.}")
5126
5202
  [ -n "$sig" ] || return 0
5127
5203
  mkdir -p "$loki_dir/state" 2>/dev/null || return 0
5128
- local mode="files"; case "$sig" in git:*) mode="git" ;; esac
5204
+ local mode="files"; case "$sig" in git:*|gitc:*) mode="git" ;; esac
5129
5205
  # Record the content hash of the PRD file Loki just wrote so a later
5130
5206
  # hand-edit by the user is detectable (decide_generated_prd_action). This
5131
5207
  # runs AFTER the agent's own PRD writes, so Loki's updates are not mistaken
5132
5208
  # for user edits.
5133
5209
  local prd_sha; prd_sha=$(_loki_prd_file_hash "${TARGET_DIR:-.}")
5134
5210
  local tmp="$loki_dir/state/.prd-signature.json.tmp.$$"
5211
+ # git-mode format upgrade (old "git:<HEAD>:<dirty>" -> new content-identity
5212
+ # "gitc:..."): when this run is a reuse honored via the decide transition
5213
+ # (the recomputed legacy signature still matches the stored one), the PRD
5214
+ # content did not change, so the generated_at date must be preserved across
5215
+ # the upgrade, exactly like the files: -> files-sampled: upgrade clauses.
5216
+ # Recompute the legacy value once and pass a match flag to the persist below.
5217
+ local git_upgrade_match=""
5218
+ case "$sig" in
5219
+ gitc:*)
5220
+ local _stored_sig
5221
+ _stored_sig=$(LOKI_SIG_FILE="$loki_dir/state/prd-signature.json" python3 -c "
5222
+ import json, os
5223
+ try:
5224
+ print(json.load(open(os.environ['LOKI_SIG_FILE'])).get('signature',''))
5225
+ except Exception:
5226
+ print('')
5227
+ " 2>/dev/null)
5228
+ case "$_stored_sig" in
5229
+ git:*)
5230
+ local _legacy
5231
+ _legacy=$(_loki_compute_legacy_git_signature "${TARGET_DIR:-.}")
5232
+ [ -n "$_legacy" ] && [ "$_legacy" = "$_stored_sig" ] && git_upgrade_match=1
5233
+ ;;
5234
+ esac
5235
+ ;;
5236
+ esac
5135
5237
  # Preserve generated_at when the codebase signature is unchanged so the
5136
5238
  # reuse disclosure ("generated on <date>") stays honest across reuse runs;
5137
5239
  # only stamp a new date when the PRD content actually changed (sig differs).
5138
5240
  LOKI_SIG="$sig" LOKI_SIG_MODE="$mode" LOKI_SIG_VER="$(get_version 2>/dev/null || echo unknown)" \
5139
5241
  LOKI_PRD_SHA="$prd_sha" LOKI_SIG_FILE="$loki_dir/state/prd-signature.json" \
5242
+ LOKI_GIT_UPGRADE_MATCH="$git_upgrade_match" \
5140
5243
  python3 -c "
5141
5244
  import json, os, datetime
5142
5245
  sig = os.environ['LOKI_SIG']
@@ -5164,7 +5267,16 @@ _sampled_upgrade = (
5164
5267
  and prev_sig.count(':') == 2
5165
5268
  and sig.startswith('files-sampled:' + prev_sig[len('files-shallow:'):] + ':')
5166
5269
  )
5167
- if prev_at and (prev_sig == sig or _legacy_upgrade or _sampled_upgrade):
5270
+ # git-mode format upgrade (old 'git:<HEAD>:<dirty>' -> new content-identity
5271
+ # 'gitc:...'): the caller recomputed the legacy signature and confirmed it still
5272
+ # matches the stored one (decide returned reuse), so the PRD content did not
5273
+ # change: preserve the date across the one-time upgrade.
5274
+ _git_upgrade = (
5275
+ bool(os.environ.get('LOKI_GIT_UPGRADE_MATCH'))
5276
+ and isinstance(prev_sig, str) and prev_sig.startswith('git:')
5277
+ and sig.startswith('gitc:')
5278
+ )
5279
+ if prev_at and (prev_sig == sig or _legacy_upgrade or _sampled_upgrade or _git_upgrade):
5168
5280
  generated_at = prev_at
5169
5281
  else:
5170
5282
  generated_at = datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00','Z')
@@ -5219,7 +5331,7 @@ persist_user_prd() {
5219
5331
  local prd_sha sig mode
5220
5332
  prd_sha=$(_loki_prd_file_hash "${TARGET_DIR:-.}")
5221
5333
  sig=$(compute_codebase_signature "${TARGET_DIR:-.}")
5222
- mode="files"; case "$sig" in git:*) mode="git" ;; esac
5334
+ mode="files"; case "$sig" in git:*|gitc:*) mode="git" ;; esac
5223
5335
 
5224
5336
  local sig_tmp="$loki_dir/state/.prd-signature.json.tmp.$$"
5225
5337
  LOKI_SIG="$sig" LOKI_SIG_MODE="$mode" \
@@ -6230,27 +6342,80 @@ audit_log() {
6230
6342
  #===============================================================================
6231
6343
 
6232
6344
  setup_agent_branch() {
6233
- # Create an isolated feature branch for agent changes.
6234
- # This prevents agents from committing directly to the main branch.
6235
- # Controlled by LOKI_BRANCH_PROTECTION env var (default: false).
6236
- local branch_protection="${LOKI_BRANCH_PROTECTION:-false}"
6345
+ # Create an isolated feature branch for agent changes off the branch Loki
6346
+ # was run from. This keeps the user's working branch clean and leaves work
6347
+ # on a feature branch ready to PR.
6348
+ # Controlled by LOKI_BRANCH_PROTECTION env var (default: true). Set it to
6349
+ # "false" to opt out fully and work on the current branch (back-compat).
6350
+ local branch_protection="${LOKI_BRANCH_PROTECTION:-true}"
6237
6351
 
6238
6352
  if [ "$branch_protection" != "true" ]; then
6239
6353
  log_info "Branch protection disabled (LOKI_BRANCH_PROTECTION=${branch_protection})"
6240
6354
  return 0
6241
6355
  fi
6242
6356
 
6357
+ # Need git to do anything here.
6358
+ command -v git >/dev/null 2>&1 || { log_warn "git not available - skipping branch protection"; return 0; }
6359
+
6243
6360
  # Ensure we are inside a git repository
6244
- if ! git rev-parse --is-inside-work-tree &>/dev/null; then
6361
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
6245
6362
  log_warn "Not a git repository - skipping branch protection"
6246
6363
  return 0
6247
6364
  fi
6248
6365
 
6366
+ # Self-ignore .loki/ so NO git add (ours or the user's own `git add -A`)
6367
+ # can ever stage runtime state (checkpoints, semantic memory, etc.). This is
6368
+ # robust regardless of the repo's own .gitignore and applies brownfield and
6369
+ # greenfield. Idempotent: write only when missing. Never fatal.
6370
+ mkdir -p .loki 2>/dev/null || true
6371
+ [ -f .loki/.gitignore ] || printf '*\n' > .loki/.gitignore 2>/dev/null || true
6372
+
6373
+ # Capture the ref Loki was run from. Detached HEAD yields the literal "HEAD".
6374
+ local cur=""
6375
+ cur="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
6376
+
6377
+ # Detached HEAD: do NOT branch, do NOT fabricate a base (LOCK A2/A6).
6378
+ if [ "$cur" = "HEAD" ]; then
6379
+ log_info "Detached HEAD; staying on current commit, no feature branch created"
6380
+ return 0
6381
+ fi
6382
+
6383
+ # Already on a loki branch (session-* or delegate-*): idempotent reuse, do
6384
+ # not nest a branch off a loki branch (LOCK A5/A7).
6385
+ case "$cur" in
6386
+ loki/*)
6387
+ log_info "Already on loki branch ${cur}"
6388
+ mkdir -p .loki/state 2>/dev/null || true
6389
+ printf '%s\n' "$cur" > .loki/state/agent-branch.txt 2>/dev/null || true
6390
+ return 0
6391
+ ;;
6392
+ esac
6393
+
6394
+ # Resume reuse: if a prior session recorded a checkout-able branch, reuse it
6395
+ # instead of minting a new one (LOCK A5).
6396
+ local recorded=""
6397
+ if [ -s .loki/state/agent-branch.txt ]; then
6398
+ recorded="$(cat .loki/state/agent-branch.txt 2>/dev/null || true)"
6399
+ if [ -n "$recorded" ] && git rev-parse --verify "$recorded" >/dev/null 2>&1; then
6400
+ if git checkout "$recorded" >/dev/null 2>&1; then
6401
+ log_info "Resuming on recorded agent branch: ${recorded}"
6402
+ return 0
6403
+ fi
6404
+ log_warn "Recorded agent branch ${recorded} could not be checked out - creating a new one"
6405
+ fi
6406
+ fi
6407
+
6408
+ # Fresh run: persist the base branch (fresh-run-only) BEFORE branching, then
6409
+ # mint and check out the feature branch (LOCK A2).
6249
6410
  local timestamp
6250
6411
  timestamp=$(date +%s)
6251
6412
  local branch_name="loki/session-${timestamp}-$$"
6252
6413
 
6253
- log_info "Branch protection enabled - creating agent branch: $branch_name"
6414
+ mkdir -p .loki/state 2>/dev/null || true
6415
+ # Persist the base only once per run tree; never overwrite an existing base.
6416
+ [ ! -s .loki/state/base-branch.txt ] && printf '%s\n' "$cur" > .loki/state/base-branch.txt 2>/dev/null
6417
+
6418
+ log_info "Branch protection enabled - creating agent branch: $branch_name (base: $cur)"
6254
6419
 
6255
6420
  # Create and checkout the feature branch
6256
6421
  if ! git checkout -b "$branch_name" 2>/dev/null; then
@@ -6259,17 +6424,217 @@ setup_agent_branch() {
6259
6424
  fi
6260
6425
 
6261
6426
  # Store the branch name for later use (PR creation, cleanup)
6262
- mkdir -p .loki/state
6263
- echo "$branch_name" > .loki/state/agent-branch.txt
6427
+ printf '%s\n' "$branch_name" > .loki/state/agent-branch.txt 2>/dev/null
6264
6428
 
6265
6429
  log_info "Agent branch created: $branch_name"
6266
6430
  audit_log "BRANCH_PROTECTION" "branch=$branch_name"
6267
6431
  echo "$branch_name"
6268
6432
  }
6269
6433
 
6434
+ _commit_scan_secret_file() {
6435
+ # Two-tier secret matcher. Returns 0 if a high-confidence secret is found in
6436
+ # the file, 1 otherwise. Patterns copied verbatim from the shipped scanner
6437
+ # (autonomy/verify.sh verify_secret_scan_file) so the commit-time gate matches
6438
+ # the verification gate's behavior. Top-level (not nested) so tests can
6439
+ # override it for the mutation/non-vacuity proof.
6440
+ local file="${1:-}"
6441
+ [ -n "$file" ] && [ -f "$file" ] || return 1
6442
+
6443
+ # TIER 1: specific formats. No deny filter -- a format match is a finding.
6444
+ local tier1=(
6445
+ 'AKIA[0-9A-Z]{16}' # AWS access key id
6446
+ 'ASIA[0-9A-Z]{16}' # AWS temporary (STS) key id
6447
+ '-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----' # PEM private key block
6448
+ 'gh[pousr]_[A-Za-z0-9]{36,}' # GitHub token (ghp_/gho_/...)
6449
+ 'github_pat_[A-Za-z0-9_]{60,}' # GitHub fine-grained PAT
6450
+ 'xox[baprs]-[A-Za-z0-9-]{10,}' # Slack token (xoxb-/xoxp-/...)
6451
+ 'sk-[A-Za-z0-9]{20,}' # OpenAI-style secret key
6452
+ 'AIza[0-9A-Za-z_-]{35}' # Google API key
6453
+ 'glpat-[A-Za-z0-9_-]{20,}' # GitLab personal access token
6454
+ )
6455
+ local p
6456
+ for p in "${tier1[@]}"; do
6457
+ # -e terminates option parsing so a pattern beginning with '-' (the PEM
6458
+ # block) is not mistaken for a flag.
6459
+ if LC_ALL=C grep -Eq -e "$p" "$file" 2>/dev/null; then
6460
+ return 0
6461
+ fi
6462
+ done
6463
+
6464
+ # Deny filter for TIER 2: a matched line is IGNORED if it is plainly a
6465
+ # placeholder or an environment-variable reference rather than a literal.
6466
+ local deny='(\$\{|\$[A-Za-z_]|process\.env|os\.(environ|getenv)|%[A-Za-z_]+%|your[-_]|redacted|changeme|change[-_]me|placeholder|example|dummy|sample|fake|<[^>]*>|x{4,}|\*{4,})'
6467
+
6468
+ # TIER 2: generic assignments + bearer tokens + connection-string creds.
6469
+ local tier2='(api[_-]?key|secret|token|password|passwd|access[_-]?key|client[_-]?secret|auth)[A-Za-z0-9_]*[[:space:]]*[:=][[:space:]]*["'"'"']?[A-Za-z0-9_/+.=-]{16,}'
6470
+ local bearer='[Bb]earer[[:space:]]+[A-Za-z0-9_.\-]{20,}'
6471
+ # URI-embedded credentials: scheme://user:password@host. The #1 leak vector
6472
+ # in 12-factor apps (DATABASE_URL=postgres://u:pass@h, mongodb+srv://, redis://).
6473
+ # Runs through the deny filter below, so ${VAR}-ref URIs are correctly ignored.
6474
+ # Username segment is optional (*) so the password-only form redis://:pass@host
6475
+ # (Redis < 6 / Heroku Redis / Redis Cloud emit exactly this) is caught too.
6476
+ local uricred='[a-z][a-z0-9+.\-]*://[^/[:space:]:@]*:[^/[:space:]:@]+@'
6477
+
6478
+ local surviving
6479
+ surviving="$(LC_ALL=C grep -EiI "$tier2|$bearer|$uricred" "$file" 2>/dev/null \
6480
+ | LC_ALL=C grep -Eiv "$deny" 2>/dev/null)"
6481
+ if [ -n "$surviving" ]; then
6482
+ return 0
6483
+ fi
6484
+ return 1
6485
+ }
6486
+
6487
+ _commit_path_looks_secret() {
6488
+ # Filename/path heuristic. Returns 0 if the path looks like a credential or
6489
+ # secret file ANYWHERE in the tree (basename OR any directory component),
6490
+ # 1 otherwise. This is the PRIMARY commit-time guard: it catches likely-secret
6491
+ # files regardless of where they sit and regardless of how weak the value
6492
+ # inside looks, closing the nested-path gap that a top-level glob (':!credentials*')
6493
+ # and a content-pattern scan both miss (e.g. secrets/credentials.json holding
6494
+ # {"key":"sk-secret"}). The content scan (_commit_scan_secret_file) remains the
6495
+ # complementary layer 2 for strong secrets hiding in non-obvious filenames.
6496
+ #
6497
+ # Safe-default bias: this runs only for the session-end AUTO-commit. A false
6498
+ # positive merely leaves the file uncommitted for the user to commit by hand,
6499
+ # which is acceptable and honest. So we err toward caution.
6500
+ #
6501
+ # Top-level (not nested) so tests can override it for the non-vacuity proof.
6502
+ local p="${1:-}"
6503
+ [ -n "$p" ] || return 1
6504
+ # Case-insensitive match: lower the full path AND the basename, test both.
6505
+ local lower base
6506
+ lower="$(printf '%s' "$p" | tr '[:upper:]' '[:lower:]')"
6507
+ base="${lower##*/}"
6508
+ local cand
6509
+ for cand in "$lower" "$base"; do
6510
+ case "$cand" in
6511
+ # dotenv files (basename or any path component ending in them)
6512
+ .env|.env.*|*/.env|*/.env.*|*.env) return 0 ;;
6513
+ # credential(s) anywhere (basename or any segment): secrets/credentials.json,
6514
+ # aws-credentials, my-credential.txt, .git-credentials
6515
+ *credential*) return 0 ;;
6516
+ # a "secret"/"secrets" segment anywhere: secrets/anything, config/secret.json
6517
+ *secret*) return 0 ;;
6518
+ # private-key / keystore / cert material (extension-anchored so we do
6519
+ # NOT match innocuous names like config.js or monkey.js)
6520
+ *.pem|*.key|*.p12|*.keystore|*.pfx|*.jks|*.ppk) return 0 ;;
6521
+ id_rsa|id_rsa.*|*/id_rsa|*/id_rsa.*) return 0 ;;
6522
+ id_ed25519*|*/id_ed25519*) return 0 ;;
6523
+ # token files: extension (*.token) OR "token" as a whole word/segment
6524
+ # (delimited by /, -, _, or .). Deliberately NOT a bare *token*: that
6525
+ # would flag ubiquitous innocuous frontend/parser names (tokenizer.js,
6526
+ # tokens.css, design-tokens.json), and since the scan aborts the WHOLE
6527
+ # session auto-commit on any single offender, one such file would block
6528
+ # committing all of the user's work. Segment-style still catches real
6529
+ # token files: api.token, auth_token, id-token, github.token, oauth-token.json.
6530
+ *.token) return 0 ;;
6531
+ token|token.*|token-*|token_*) return 0 ;;
6532
+ *-token|*_token|*.token.*) return 0 ;;
6533
+ *-token.*|*_token.*|*/token|*/token.*) return 0 ;;
6534
+ *-token-*|*_token_*|*-token_*|*_token-*) return 0 ;;
6535
+ # package/registry/cloud credential configs
6536
+ .npmrc|*/.npmrc|.pypirc|*/.pypirc|.netrc|*/.netrc) return 0 ;;
6537
+ *.kubeconfig|kubeconfig|*/kubeconfig) return 0 ;;
6538
+ .dockercfg|*/.dockercfg|.docker/config.json|*/.docker/config.json) return 0 ;;
6539
+ # service-account / gcp key json
6540
+ service-account*.json|*/service-account*.json|*serviceaccount*) return 0 ;;
6541
+ gcp-key*.json|*/gcp-key*.json) return 0 ;;
6542
+ esac
6543
+ done
6544
+ return 1
6545
+ }
6546
+
6547
+ commit_session_changes() {
6548
+ # Squash the session's work into one honest session-end commit on the agent
6549
+ # branch (LOCK A3/A4/A8). Commit-always (incl. failed runs) so the user is
6550
+ # left with committed work to inspect/PR. Clean no-op when nothing changed.
6551
+ command -v git >/dev/null 2>&1 || return 0
6552
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
6553
+
6554
+ # Only act when a session feature branch was set up. This preserves the
6555
+ # LOCK A1 opt-out contract (LOKI_BRANCH_PROTECTION=false -> no agent-branch.txt
6556
+ # -> we never commit on the user's own branch), and also no-ops the detached
6557
+ # -HEAD case (setup writes no agent-branch.txt there).
6558
+ [ -s .loki/state/agent-branch.txt ] || return 0
6559
+
6560
+ # The worktree/parallel path already commits and merges back (run.sh:3403);
6561
+ # skip the squash commit there to avoid a redundant commit on the merge.
6562
+ [ "${PARALLEL_MODE:-false}" = "true" ] && return 0
6563
+
6564
+ # Only auto-commit on a branch Loki itself MINTED (loki/session-<ts>-<pid>).
6565
+ # If the user manually checked out a self-named loki/* branch (e.g.
6566
+ # loki/experiment), recorded it via the idempotent-reuse path, do NOT
6567
+ # auto-commit on their behalf. Honest skip.
6568
+ # symbolic-ref resolves the branch name even on an UNBORN branch (fresh
6569
+ # greenfield `git init` with zero commits, where rev-parse HEAD fails);
6570
+ # fall back to rev-parse for older edge cases. Detached HEAD yields nothing.
6571
+ local cur=""
6572
+ cur="$(git symbolic-ref --short -q HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
6573
+ case "$cur" in
6574
+ loki/session-*) : ;; # Loki-minted: proceed.
6575
+ *)
6576
+ log_info "Not on a Loki-minted session branch (${cur}); skipping auto-commit"
6577
+ return 0
6578
+ ;;
6579
+ esac
6580
+
6581
+ # Stage everything except .loki/ runtime state and a secret-path denylist.
6582
+ # These excludes are defense-in-depth ONLY for the top-level cases git
6583
+ # pathspec handles cleanly. We deliberately do NOT add nested globs like
6584
+ # ':!secrets/**' or ':!**/credentials*' here: in this `git add -A` context a
6585
+ # leading ':!**/' / ':!secrets/**' exclude WOULD drop the nested file before
6586
+ # it is ever staged, which would mask the file from the scan loop below and
6587
+ # make the scan-loop's nested-secret guarantee untestable (the file must be
6588
+ # STAGED so the loop can prove it catches it). The scan-and-abort loop below
6589
+ # (_commit_path_looks_secret + _commit_scan_secret_file over EVERY staged
6590
+ # file) is the actual guarantee for nested/weak secrets; these excludes are a
6591
+ # cheap first cut for the obvious top-level files only.
6592
+ git add -A \
6593
+ ':!.loki' ':!.loki/' \
6594
+ ':!.env' ':!.env.*' ':!*.env' \
6595
+ ':!*.key' ':!*.pem' ':!*.p12' ':!*.keystore' \
6596
+ ':!id_rsa*' ':!*.token' ':!credentials*' 2>/dev/null || true
6597
+
6598
+ # Nothing staged = clean no-op, never an error.
6599
+ if git diff --cached --quiet 2>/dev/null; then
6600
+ return 0
6601
+ fi
6602
+
6603
+ # Secret scan the STAGED files. If ANY staged file matches a secret pattern,
6604
+ # ABORT: unstage (git reset -- keeps the working tree changes), print an
6605
+ # honest message naming the offending file(s), and return 0 so the run
6606
+ # continues with the work PRESERVED uncommitted (safe default: never commit
6607
+ # a possible secret). -z handles paths with spaces/newlines.
6608
+ # Two complementary layers, OR-ed per staged file:
6609
+ # layer 1 (path heuristic, cheaper, runs first): catches likely-secret
6610
+ # files anywhere in the tree regardless of value strength
6611
+ # (e.g. secrets/credentials.json with {"key":"sk-secret"}).
6612
+ # layer 2 (content scan): catches strong secrets in non-obvious filenames.
6613
+ local offenders=""
6614
+ local f
6615
+ while IFS= read -r -d '' f; do
6616
+ [ -f "$f" ] || continue
6617
+ if _commit_path_looks_secret "$f" || _commit_scan_secret_file "$f"; then
6618
+ offenders="${offenders}${offenders:+, }${f}"
6619
+ fi
6620
+ done < <(git diff --cached --name-only -z 2>/dev/null)
6621
+
6622
+ if [ -n "$offenders" ]; then
6623
+ git reset >/dev/null 2>&1 || true
6624
+ log_warn "Left uncommitted: possible secret detected in ${offenders}. Review and commit manually."
6625
+ audit_agent_action "git_commit_aborted" "Aborted session commit; possible secret" "files=${offenders}" || true
6626
+ return 0
6627
+ fi
6628
+
6629
+ git commit -m "Loki Mode session changes (${ITERATION_COUNT:-0} iterations, result=${result:-0})" 2>/dev/null || true
6630
+ audit_agent_action "git_commit" "Committed session changes" "iterations=${ITERATION_COUNT:-0},result=${result:-0}" || true
6631
+ return 0
6632
+ }
6633
+
6270
6634
  create_session_pr() {
6271
- # Push the agent branch and create a PR if gh CLI is available.
6272
- # Called during session cleanup to submit agent changes for review.
6635
+ # Advise the user how to open a PR for the agent branch. PRINT-ONLY by
6636
+ # default (no push, no PR). LOKI_AUTO_PR=1 restores the legacy auto behavior.
6637
+ # Called during session cleanup, after commit_session_changes.
6273
6638
  local branch_file=".loki/state/agent-branch.txt"
6274
6639
 
6275
6640
  if [ ! -f "$branch_file" ]; then
@@ -6284,18 +6649,37 @@ create_session_pr() {
6284
6649
  return 0
6285
6650
  fi
6286
6651
 
6287
- log_info "Pushing agent branch: $branch_name"
6652
+ # Read the base branch captured at session start. Do NOT fabricate one.
6653
+ local base=""
6654
+ if [ -s .loki/state/base-branch.txt ]; then
6655
+ base="$(cat .loki/state/base-branch.txt 2>/dev/null || true)"
6656
+ fi
6657
+ if [ -z "$base" ]; then
6658
+ log_info "No recorded base branch; skipping PR advice"
6659
+ return 0
6660
+ fi
6288
6661
 
6289
- # Check if there are any commits on this branch beyond the base
6662
+ # Count commits relative to the CAPTURED base (not a hardcoded main).
6290
6663
  local commit_count
6291
- commit_count=$(git rev-list --count HEAD ^"$(git merge-base HEAD main 2>/dev/null || echo HEAD)" 2>/dev/null || echo "0")
6664
+ commit_count=$(git rev-list --count HEAD ^"$(git merge-base HEAD "$base" 2>/dev/null || echo HEAD)" 2>/dev/null || echo "0")
6292
6665
 
6293
6666
  if [ "$commit_count" = "0" ]; then
6294
- log_info "No commits on agent branch - skipping PR creation"
6667
+ log_info "No commits to PR on agent branch ${branch_name}"
6668
+ return 0
6669
+ fi
6670
+
6671
+ # DEFAULT: advisory only. Print the exact commands; never push, never PR.
6672
+ if [ "${LOKI_AUTO_PR:-0}" != "1" ]; then
6673
+ if declare -f print_pr_advice >/dev/null 2>&1; then
6674
+ print_pr_advice "$base" "$branch_name"
6675
+ else
6676
+ log_info "To open a pull request: git push -u origin ${branch_name}, then open a PR (base: ${base})"
6677
+ fi
6295
6678
  return 0
6296
6679
  fi
6297
6680
 
6298
- # Push the branch
6681
+ # OPT-IN (LOKI_AUTO_PR=1): legacy auto push + PR, now with the correct base.
6682
+ log_info "Pushing agent branch: $branch_name"
6299
6683
  if ! git push -u origin "$branch_name" 2>/dev/null; then
6300
6684
  log_warn "Failed to push agent branch: $branch_name"
6301
6685
  return 1
@@ -6311,6 +6695,7 @@ create_session_pr() {
6311
6695
  Branch: \`$branch_name\`
6312
6696
  Session PID: $$
6313
6697
  Created: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
6698
+ --base "$base" \
6314
6699
  --head "$branch_name" 2>/dev/null) || true
6315
6700
 
6316
6701
  if [ -n "$pr_url" ]; then
@@ -17460,7 +17845,10 @@ main() {
17460
17845
  print_ttfv_next_steps "${LOKI_TTFV}" "$result" || true
17461
17846
  fi
17462
17847
 
17463
- # Create PR from agent branch if branch protection was enabled
17848
+ # Commit the session's work to the agent branch (squashed, honest message),
17849
+ # then advise the user how to open a PR. Both are no-ops when no agent branch
17850
+ # was set up (LOKI_BRANCH_PROTECTION=false) or nothing changed.
17851
+ commit_session_changes
17464
17852
  create_session_pr
17465
17853
  audit_agent_action "session_stop" "Session ended" "result=$result,iterations=$ITERATION_COUNT"
17466
17854
 
@@ -541,9 +541,15 @@ verify_secret_scan_file() {
541
541
  local tier2='(api[_-]?key|secret|token|password|passwd|access[_-]?key|client[_-]?secret|auth)[A-Za-z0-9_]*[[:space:]]*[:=][[:space:]]*["'"'"']?[A-Za-z0-9_/+.=-]{16,}'
542
542
  # Bearer tokens: "Bearer <>=20 high-entropy chars>".
543
543
  local bearer='[Bb]earer[[:space:]]+[A-Za-z0-9_.\-]{20,}'
544
+ # URI-embedded credentials: scheme://user:password@host (DATABASE_URL=
545
+ # postgres://u:pass@h, mongodb+srv://, redis://). The #1 12-factor leak
546
+ # vector. Runs through the deny filter so ${VAR}-ref URIs are ignored.
547
+ # Username segment is optional (*) so the password-only form redis://:pass@host
548
+ # (Redis < 6 / Heroku Redis / Redis Cloud emit exactly this) is caught too.
549
+ local uricred='[a-z][a-z0-9+.\-]*://[^/[:space:]:@]*:[^/[:space:]:@]+@'
544
550
 
545
551
  local surviving
546
- surviving="$(LC_ALL=C grep -EiI "$tier2|$bearer" "$file" 2>/dev/null \
552
+ surviving="$(LC_ALL=C grep -EiI "$tier2|$bearer|$uricred" "$file" 2>/dev/null \
547
553
  | LC_ALL=C grep -Eiv "$deny" 2>/dev/null)"
548
554
  if [ -n "$surviving" ]; then
549
555
  return 0
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.71.0"
10
+ __version__ = "7.73.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try: