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/README.md +1 -1
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/git-pr-advisory.sh +112 -0
- package/autonomy/loki +810 -37
- package/autonomy/run.sh +416 -28
- package/autonomy/verify.sh +7 -1
- package/dashboard/__init__.py +1 -1
- package/docs/BRANCH-LIFECYCLE-PLAN.md +354 -0
- package/docs/DEPLOY-PLAN.md +302 -0
- package/docs/INSTALLATION.md +2 -2
- package/docs/PREVIEW-LINK-PLAN.md +77 -0
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
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:
|
|
159
|
+
# LOKI_BRANCH_PROTECTION - Create feature branch for agent changes (default: true)
|
|
160
160
|
# Agent works on loki/session-<timestamp>-<pid> branch
|
|
161
|
-
#
|
|
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
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
|
6235
|
-
#
|
|
6236
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
6272
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
package/autonomy/verify.sh
CHANGED
|
@@ -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
|