Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d18695c
fix(guardrails): detect the new ghs_<APPID>_<JWT> GitHub App token fo…
kyle-sexton Oct 2, 2026
46f1815
feat(source-control): merge through GitHub's async merge API, with me…
kyle-sexton Oct 2, 2026
86aaa75
feat(ci): run ci-status on merge_group and flag behind-base PRs in mo…
kyle-sexton Oct 2, 2026
94c52e7
Merge remote-tracking branch 'origin/main' into feat/github-platform-…
kyle-sexton Oct 2, 2026
764b99f
fix(guardrails): keep the new GitHub App token patterns linear-time
kyle-sexton Oct 2, 2026
d7826bd
fix(source-control): pin every async merge to the vetted head and ver…
kyle-sexton Oct 2, 2026
0200a5d
Merge remote-tracking branch 'origin/main' into feat/github-platform-…
kyle-sexton Oct 2, 2026
3645308
fix(disk-hygiene): redact secrets cut by the guard-log scan bound, an…
kyle-sexton Oct 2, 2026
d6399f9
Merge remote-tracking branch 'origin/main' into feat/github-platform-…
kyle-sexton Oct 2, 2026
24da46c
fix(autonomy): keep the runner lifecycle reference vendor-neutral
kyle-sexton Oct 3, 2026
3896519
Merge remote-tracking branch 'origin/main' into feat/github-platform-…
kyle-sexton Oct 3, 2026
1638315
fix: address review-lane findings on async merge and morning-brief
kyle-sexton Oct 3, 2026
a795e3a
Merge remote-tracking branch 'origin/main' into feat/github-platform-…
kyle-sexton Oct 3, 2026
c9c1cb7
Merge remote-tracking branch 'origin/main' into feat/github-platform-…
kyle-sexton Oct 3, 2026
9d37a3d
Merge remote-tracking branch 'origin/main' into feat/github-platform-…
kyle-sexton Oct 3, 2026
a2c2965
Merge remote-tracking branch 'origin/main' into feat/github-platform-…
kyle-sexton Oct 3, 2026
01b6e74
fix(source-control): restore the babysit_stacked_prs userConfig key l…
kyle-sexton Oct 3, 2026
bb2ebe9
Merge remote-tracking branch 'origin/main' into feat/github-platform-…
kyle-sexton Oct 3, 2026
2a0e2ed
Merge remote-tracking branch 'origin/main' into feat/github-platform-…
kyle-sexton Oct 3, 2026
fc0a479
Merge remote-tracking branch 'origin/main' into feat/github-platform-…
kyle-sexton Oct 3, 2026
1808cf4
fix: carry the rebumped harness-config and source-control manifest ve…
kyle-sexton Oct 3, 2026
ecba564
Merge remote-tracking branch 'origin/main' into feat/github-platform-…
kyle-sexton Oct 3, 2026
6ca2318
Merge remote-tracking branch 'origin/main' into feat/github-platform-…
kyle-sexton Oct 3, 2026
d153281
Merge remote-tracking branch 'origin/main' into feat/github-platform-…
kyle-sexton Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ on:
# `pull_request` whatever `permissions:` requests, so it cannot record lane
# state — and runs the full workflow on every event exactly as before.
types: [opened, synchronize, reopened, ready_for_review, edited, labeled, unlabeled]
# A merge queue tests each queued merge commit through this event alone; a
# required check that never reports on it leaves the queued pull request
# unmergeable. A merge-group run carries no `github.event.pull_request`, so
# every expression below that reads one takes its non-PR branch, the same one
# a push to main takes: the contract-only predicate is false, so every lane
# runs in full; the concurrency group falls back to `github.run_id`, so no
# queued run cancels another; the docs-only detector does not run and the
# change-detection action fails open, so the whole suite runs; the
# base-diff gates decline as they do on a push; `pr-contract` finds no pull
# request number and reports `skipped`, because the title, label and linkage
# were already checked on the pull request before it could enter the queue;
# and `ci-status` aggregates the lanes and records `ci-lanes` on the
# merge-group commit (`github.sha`), a SHA no contract-only run ever reads.
merge_group:
types: [checks_requested]

permissions:
contents: read
Expand All @@ -52,10 +67,10 @@ permissions:
# until someone re-ran that specific run (melodic-software/github-iac#378).
#
# The full branch keeps one group per pull request, so a `synchronize` still
# cancels everything on the old SHA. Push runs fall back to the unique
# `github.run_id` and are never cancelled; the PR number scopes cancellation to
# exactly one pull request, where `head_ref` would collide across same-named
# fork branches.
# cancels everything on the old SHA. Push and merge-group runs fall back to the
# unique `github.run_id` and are never cancelled; the PR number scopes
# cancellation to exactly one pull request, where `head_ref` would collide
# across same-named fork branches.
concurrency:
group: >-
${{ (github.event.pull_request.head.repo.full_name == github.repository &&
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/test-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ name: test-windows
# Draft pull requests run nothing: both jobs gate on the draft flag, and
# `ready_for_review` starts the run when the draft flips. Contract-only events
# (`edited`, `labeled`, `unlabeled`) are not triggers, since this lane reads
# nothing from the pull request body or labels.
# nothing from the pull request body or labels. Nor is `merge_group`: a merge
# queue waits only on required checks, and this lane is not one.

on:
# Present for parity with ci.yml, which takes a dispatch to run its bench
Expand Down
2 changes: 1 addition & 1 deletion plugins/disk-hygiene/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "disk-hygiene",
"version": "0.42.10",
"version": "0.42.11",
"description": "Context-aware disk hygiene for arbitrary directory trees: inventories orphaned and temporary artifacts, classifies evidence into review tiers, and offers exact-path cleanup only after a fresh safety preview and explicit per-tier approval. The target is read-only by default; OS-managed paths, links and mount points, VCS-tracked content without the complete checkout evidence bundle, changed entries, and live-handle uncertainty fail closed.",
"author": {
"name": "Melodic Software",
Expand Down
14 changes: 14 additions & 0 deletions plugins/disk-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
All notable changes to the `disk-hygiene` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.42.11] - 2026-10-03

### Fixed

- The guard decision log redacts GitHub App installation tokens in the `ghs_<APPID>_<JWT>` format
GitHub rolls out from 2026-04-27. The old pattern stopped at the `_` after the app ID, so the
token was written to the log in full.
- Redacting a command for the guard decision log no longer stalls the guard hook. The
credential-name rule (`FOO_KEY=...`) backtracked in cubic time, so a 4 KB command of repeated
`KEY` took about 40 seconds; it now makes one attempt per name. A value longer than 4096
characters is scanned only to that bound, and the kept text is narrowed by what redaction
removed, so a secret cut at the bound is never shown. A private key or JWT that starts in the
kept text and runs past the bound is redacted from its start.

## [0.42.10] - 2026-10-02

### Fixed
Expand Down
41 changes: 37 additions & 4 deletions plugins/disk-hygiene/lib/guard_decision_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
# Per generation; two generations are kept, so the bound is about 2 MiB.
MAX_BYTES = 1_048_576
MAX_TEXT_CHARS = 400
# Redaction cost grows faster than linearly on some shapes, so a longer value
# is scanned only up to here; a secret starting in the kept text and longer
# than the scan bound less MAX_TEXT_CHARS is not seen.
MAX_SCAN_CHARS = 4096

FILE_MODE = 0o600
DIR_MODE = 0o700
Expand Down Expand Up @@ -94,7 +98,12 @@
re.DOTALL,
),
re.compile(r"\b(?:sk|rk|pk)-[A-Za-z0-9_-]{16,}"),
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}"),
# The bounded `eyJ` header and the spelled-out segments keep this linear;
# an unbounded first segment is quadratic on a repeated `ghs_1_-`.
re.compile(
r"\b(?:ghs_[0-9]+_eyJ[A-Za-z0-9_-]{0,512}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"
r"|gh[pousr]_[A-Za-z0-9]{20,})"
),
re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"),
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"),
Expand All @@ -104,10 +113,12 @@
r"['\"]?\s*[:=]\s*['\"]?[A-Za-z0-9._+/=-]{8,}"
),
re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s:@/]+:[^\s:@/]+@[^\s]+"),
# One attempt per name, consumed possessively: two open-ended runs around the
# keyword backtrack in cubic time on a name like `KEYKEY...`.
re.compile(
r"(?i)(?:\$env:)?[A-Za-z_][A-Za-z0-9_]*"
r"(?:SECRET|KEY|TOKEN|PASSWORD|PASSWD|PWD|CREDENTIAL)[A-Za-z0-9_]*"
r"\s*[=:]\s*['\"]?[^\s'\"]+"
r"(?i)(?:\$env:)?(?<![A-Za-z0-9_])[0-9]*+"
r"(?=[A-Za-z_][A-Za-z0-9_]*?(?:SECRET|KEY|TOKEN|PASSWORD|PASSWD|PWD|CREDENTIAL))"
r"[A-Za-z0-9_]++\s*[=:]\s*['\"]?[^\s'\"]+"
),
)

Expand All @@ -134,10 +145,32 @@ def _redact_secrets(text: str) -> str:
return text


# A private key or JWT that runs past the scan bound has no end inside the
# scanned text, so no complete-shape rule above can match it.
_KEY_RUNNING_TO_CUT = re.compile(r"-----BEGIN[^-]+PRIVATE KEY-----.*\Z", re.DOTALL)
_JWT_RUNNING_TO_CUT = re.compile(r"(?:ghs_[0-9]+_)?eyJ")
_TOKEN_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.+/=-"


def _redact_cut_tail(text: str) -> str:
text = _KEY_RUNNING_TO_CUT.sub(REDACTED, text)
# rstrip finds the run of token characters at the cut in linear time.
run_start = len(text.rstrip(_TOKEN_CHARS))
jwt = _JWT_RUNNING_TO_CUT.search(text, run_start)
return text[: jwt.start()] + REDACTED if jwt else text


def _clip(value: object) -> str | None:
if value is None:
return None
text = value if isinstance(value, str) else str(value)
if len(text) > MAX_SCAN_CHARS:
# Scan only a bounded prefix, and narrow the kept window by what
# redaction removed, so every kept character comes from the first
# MAX_TEXT_CHARS of the input and a secret cut at the bound stays out.
scanned = _redact_cut_tail(_redact_secrets(text[:MAX_SCAN_CHARS]))
window = MAX_TEXT_CHARS - max(0, MAX_SCAN_CHARS - len(scanned))
return scanned[: max(0, window)] + "..."
text = _redact_secrets(text)
if len(text) > MAX_TEXT_CHARS:
return text[:MAX_TEXT_CHARS] + "..."
Expand Down
53 changes: 53 additions & 0 deletions plugins/disk-hygiene/lib/test_guard_decision_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import os
import stat
import tempfile
import time
import unittest
from pathlib import Path
from unittest import mock
Expand Down Expand Up @@ -143,6 +144,58 @@ def test_secret_shaped_command_and_reason_are_redacted_before_clip(self) -> None
self.assertIn(decision_log.REDACTED, entry["command"])
self.assertIn(decision_log.REDACTED, entry["reason"])

def test_github_app_installation_token_jwt_form_is_redacted(self) -> None:
# ghs_<APPID>_<JWT>, about 520 characters; the segments spell FAKE.
payload = "FAKEpayload" + ("A" * 450)
token = "ghs" + "_1234567_eyJFAKE." + payload + ".FAKEsignatureNOTreal"
self.write_one(command="echo " + token)
(entry,) = self.read_records()
self.assertNotIn(payload[:40], entry["command"])
self.assertEqual("echo " + decision_log.REDACTED, entry["command"])

def test_redaction_of_adversarial_commands_finishes_promptly(self) -> None:
# Shapes that made a backtracking pattern take seconds to minutes.
for command in (
"ghs_1_-" * 50000,
"ghs_1_eyJ" * 30000,
"ghs_1_eyJa." * 30000,
"ghs_1_eyJ-" * 30000,
"KEY" * 1300,
"-eyJ" * 75000,
):
with self.subTest(command=command[:12]):
start = time.monotonic()
decision_log.build_record(
hook="destructive-guard", decision="deny", rule="r", command=command
)
self.assertLess(time.monotonic() - start, 1.0)

def test_secret_cut_at_the_scan_bound_stays_out_of_the_record(self) -> None:
# Two redacted assignments shrink the scanned text to a few dozen
# characters, which would pull a token cut at the bound into view.
assignments = ("token=" + "v" * 2000 + " ") * 2
token = "ghs" + "_1234567_eyJFAKE.FAKEpayload" + "A" * 450 + ".FAKEsig"
self.write_one(command=assignments + token)
(entry,) = self.read_records()
self.assertNotIn("FAKE", entry["command"])

def test_secret_starting_in_view_and_running_past_the_scan_bound_is_redacted(
self,
) -> None:
# Each starts in the first 400 characters and ends past the scan bound,
# so no complete-shape rule can match it inside the scanned prefix.
pem_header = "-----BEGIN " + "RSA PRIVATE KEY-----"
for label, secret in (
("pem", pem_header + "\nMIIJFAKEbody" + "Q" * 6000),
("jwt", "eyJ" + "FAKEheader" + "." + "FAKEpayload" + "Q" * 6000),
("ghs", "ghs" + "_1234567_eyJFAKE.FAKEpayload" + "Q" * 6000),
):
with self.subTest(label):
record = decision_log.build_record(
hook="h", decision="deny", rule="r", command="printf %s " + secret
)
self.assertNotIn("FAKE", record["command"])

def test_none_and_deny_by_default_persist_length_not_command_text(self) -> None:
secret = "$env:AZURE_CLIENT_SECRET='s3cretvalue'; Get-Process"
self.write_one(
Expand Down
2 changes: 1 addition & 1 deletion plugins/guardrails/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,5 +170,5 @@
"min": 1
}
},
"version": "0.46.10"
"version": "0.46.11"
}
11 changes: 11 additions & 0 deletions plugins/guardrails/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
All notable changes to the `guardrails` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.46.11] - 2026-10-03

### Fixed

- Secret detection catches GitHub App installation tokens in the `ghs_<APPID>_<JWT>` format
GitHub rolls out from 2026-04-27 (about 520 characters, length varies). The new pattern matches
`ghs_`, a numeric app ID, `_`, and three dot-separated base64url segments, the first starting
`eyJ` as every JWT header does; the 36-character `ghs_`/`ghu_` form is still detected. The scan
runs grep under `LC_ALL=C`: in a UTF-8 locale GNU grep took 25 to 60 seconds on a 300 KB line
against the combined pattern set.

## [0.46.10] - 2026-10-02

### Fixed
Expand Down
32 changes: 32 additions & 0 deletions plugins/guardrails/hooks/secret-pattern-detection.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ AWS_PREFIX='AKIA'
AWS_TOKEN="${AWS_PREFIX}IOSFODNN7EXAMPLE"
GH_PREFIX='ghp_'
GH_PAT="${GH_PREFIX}$(printf 'a%.0s' {1..36})"
GHS_PREFIX='ghs_'
GH_APP_TOKEN="${GHS_PREFIX}$(printf 'b%.0s' {1..36})"
# The ghs_<APPID>_<JWT> installation-token format GitHub rolls out from
# 2026-04-27, about 520 characters: a JWT's `eyJ` header start, then
# dot-separated segments that spell FAKE.
GH_APP_TOKEN_JWT="${GHS_PREFIX}1234567_eyJFAKE.FAKEpayload$(printf 'A%.0s' {1..450}).FAKEsignatureNOTreal"
SLACK_PREFIX='xoxb-'
SLACK_TOKEN="${SLACK_PREFIX}1234567890123-9876543210987"
STRIPE_PREFIX='sk_live_'
Expand Down Expand Up @@ -79,6 +85,32 @@ RC=$?
assert_exit "GitHub PAT → exit 2" 2 "$RC"
assert_contains "GH PAT → message" "$OUT" "GitHub PAT"

OUT=$(bash "$HOOK" <<<"$(write_json "$FIXTURE" "token = '$GH_APP_TOKEN'")" 2>&1)
RC=$?
assert_exit "GitHub App token, 36-char form → exit 2" 2 "$RC"
assert_contains "GH App token, 36-char form → message" "$OUT" "GitHub App Token"

OUT=$(bash "$HOOK" <<<"$(write_json "$FIXTURE" "token = '$GH_APP_TOKEN_JWT'")" 2>&1)
RC=$?
assert_exit "GitHub App token, ghs_<APPID>_<JWT> form → exit 2" 2 "$RC"
assert_contains "GH App token, ghs_<APPID>_<JWT> form → message" "$OUT" "GitHub App Token"

# One long line that once took GNU grep 25-60 s in a UTF-8 locale. `timeout 10`
# is the backstop: a slow scan reads as rc 124, a finished one as 0.
SLOW_SHAPES=(
"${GHS_PREFIX}1_-:50000" "${GHS_PREFIX}1_eyJ:30000" "${GHS_PREFIX}1_eyJa.:30000" "${GHS_PREFIX}1_eyJ-:30000"
)
for shape in "${SLOW_SHAPES[@]}"; do
unit="${shape%:*}" count="${shape##*:}"
printf -v content '%*s' "$count" ''
printf '%s' "${content// /$unit}" >"$TEST_TMPDIR/slow.txt"
rc=0
# shellcheck disable=SC2016 # expanded by the inner bash
LC_ALL=C.UTF-8 timeout 10 bash -c 'source "$1"; secrets::scan_text "$(<"$2")" >/dev/null || :' \
_ "$HOOK_DIR/../lib/secret-detection/secret-patterns.sh" "$TEST_TMPDIR/slow.txt" || rc=$?
assert_exit "scan of '${unit}' x${count} finishes" 0 "$rc"
done

OUT=$(bash "$HOOK" <<<"$(write_json "$FIXTURE" "SLACK='$SLACK_TOKEN'")" 2>&1)
RC=$?
assert_exit "Slack Bot Token → exit 2" 2 "$RC"
Expand Down
38 changes: 22 additions & 16 deletions plugins/guardrails/lib/secret-detection/secret-patterns.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@
# Callers handle I/O, exemptions, and exit-code mapping.
#
# Pattern selection: only HIGH-confidence patterns with distinctive prefixes
# and fixed lengths. Generic patterns (password=, api_key=, secret=) are
# and fixed lengths or a fixed structure (the ghs_<APPID>_<JWT> installation
# token varies in length, so it is matched by its `eyJ` JWT header and
# dot-separated segments). Generic patterns (password=, api_key=, secret=) are
# excluded — too many false positives for a real-time blocking hook. Sourced
# from gitleaks, TruffleHog, and secrets-patterns-db. grep -E (POSIX ERE) only.
# from gitleaks, TruffleHog, and secrets-patterns-db. grep -E (POSIX ERE) only,
# run under LC_ALL=C: in a UTF-8 locale GNU grep takes quadratic time on a long
# line against the combined set.

# (label, ERE) parallel arrays — one combined grep can fast-reject the common
# (no-secret) case in a single process. Index alignment is load-bearing: the
Expand All @@ -19,6 +23,7 @@ SECRET_LABELS=(
"GitHub PAT"
"GitHub OAuth Token"
"GitHub App Token"
"GitHub App Token"
"GitHub Fine-grained PAT"
"GitLab PAT"
"Slack Bot Token"
Expand All @@ -29,18 +34,19 @@ SECRET_LABELS=(
"Private Key (PEM)"
)
SECRET_PATTERNS=(
'(AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}' # AWS (AKIA/ASIA/ABIA/ACCA + 16)
'ghp_[0-9a-zA-Z]{36}' # GitHub PAT
'gho_[0-9a-zA-Z]{36}' # GitHub OAuth
'gh[us]_[0-9a-zA-Z]{36}' # GitHub app (ghu_/ghs_)
'github_pat_[0-9a-zA-Z_]{82}' # GitHub fine-grained PAT
'glpat-[0-9a-zA-Z_-]{20}' # GitLab PAT
'xoxb-[0-9]{10,13}-[0-9]{10,13}' # Slack bot token
'xox[pe]-[0-9]{10,13}-' # Slack user/app token
'[sr]k_(test|live|prod)_[0-9a-zA-Z]{10,99}' # Stripe key
'sk-(proj|svcacct|admin)-[A-Za-z0-9_-]{20,}' # OpenAI prefixed API key
'sk-[A-Za-z0-9]{20,}' # OpenAI legacy bare sk- key
'-----BEGIN [A-Z ]*PRIVATE KEY-----' # PEM private key header
'(AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}' # AWS (AKIA/ASIA/ABIA/ACCA + 16)
'ghp_[0-9a-zA-Z]{36}' # GitHub PAT
'gho_[0-9a-zA-Z]{36}' # GitHub OAuth
'gh[us]_[0-9a-zA-Z]{36}' # GitHub app (ghu_/ghs_)
'ghs_[0-9]+_eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' # GitHub app ghs_<APPID>_<JWT>
'github_pat_[0-9a-zA-Z_]{82}' # GitHub fine-grained PAT
'glpat-[0-9a-zA-Z_-]{20}' # GitLab PAT
'xoxb-[0-9]{10,13}-[0-9]{10,13}' # Slack bot token
'xox[pe]-[0-9]{10,13}-' # Slack user/app token
'[sr]k_(test|live|prod)_[0-9a-zA-Z]{10,99}' # Stripe key
'sk-(proj|svcacct|admin)-[A-Za-z0-9_-]{20,}' # OpenAI prefixed API key
'sk-[A-Za-z0-9]{20,}' # OpenAI legacy bare sk- key
'-----BEGIN [A-Z ]*PRIVATE KEY-----' # PEM private key header
)

# secrets::scan_text <content>
Expand All @@ -64,13 +70,13 @@ secrets::scan_text() {
for pattern in "${SECRET_PATTERNS[@]}"; do
grep_e_args+=(-e "$pattern")
done
if ! grep -qE "${grep_e_args[@]}" < <(printf '%s' "$content") 2>/dev/null; then
if ! LC_ALL=C grep -qE "${grep_e_args[@]}" < <(printf '%s' "$content") 2>/dev/null; then
return 0
fi
for i in "${!SECRET_PATTERNS[@]}"; do
label="${SECRET_LABELS[$i]}"
pattern="${SECRET_PATTERNS[$i]}"
lines=$(grep -nE -- "$pattern" < <(printf '%s' "$content") 2>/dev/null |
lines=$(LC_ALL=C grep -nE -- "$pattern" < <(printf '%s' "$content") 2>/dev/null |
head -3 | cut -d: -f1 | tr '\n' ',' | sed 's/,$//')
if [[ -n "$lines" ]]; then
printf '%s (line %s)\n' "$label" "$lines"
Expand Down
2 changes: 1 addition & 1 deletion plugins/harness-config/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "harness-config",
"version": "1.5.2",
"version": "1.5.3",
"description": "Configuration health for a repo's Claude Code: audit (settings, .mcp.json, hooks, plugins, permission drift), audit-automation-gaps, audit-permission-grants, audit-permission-state (effective rules with provenance), draft-auto-mode-rules (prints an autoMode block), audit-instructions (instructions the model no longer needs, conflicts), audit-prompting-postures, audit-pass (one ordered, resumable pass), unhobble (strip instructions, re-add what evidence earns), and setup.",
"author": {
"name": "Melodic Software",
Expand Down
6 changes: 6 additions & 0 deletions plugins/harness-config/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to the `harness-config` plugin are documented here. Format f

Versions 0.51.8 to 0.51.9 and 0.51.11 to 0.51.14 were reserved by parallel branches and never released.

## [1.5.3] - 2026-10-03

### Fixed

- The audit engine's secret-shape check (`SECRET_RE`, which flags a token in tracked `settings.json` and redacts hook commands) covers GitHub OAuth, user, server and refresh tokens (`gho_`, `ghu_`, `ghs_`, `ghr_`) and the `ghs_<APPID>_<JWT>` installation-token format GitHub rolls out from 2026-04-27, whose JWT header starts `eyJ`. Before, only `ghp_` and `github_pat_` were matched. The check runs grep under `LC_ALL=C`, because in a UTF-8 locale GNU grep took 25 to 60 seconds on a long line against the widened pattern.

## [1.5.2] - 2026-10-02

### Fixed
Expand Down
Loading
Loading