Skip to content

feat(claude-ops): script deterministic lane-cycle mechanics (#538)#803

Merged
kyle-sexton merged 5 commits into
mainfrom
feat/538-script-deterministic-lane-mechanics
Jul 21, 2026
Merged

feat(claude-ops): script deterministic lane-cycle mechanics (#538)#803
kyle-sexton merged 5 commits into
mainfrom
feat/538-script-deterministic-lane-mechanics

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

Standing operator principle: what needs no reasoning gets a script. Every /loop
lane currently hand-assembles two pure-mechanics artifacts every session — the
MACHINE-BEHAVIOR telemetry block and the "maintain ONE telemetry comment, edit in
place" upsert. This PR extracts both into scripts the lane prompt references, so
the outputs are deterministic and testable and the lane prompt shrinks (context
minimalism — long prompts micromanage; Fable-class models need fewer steers).

Both scripts live under plugins/claude-ops/skills/lanes/scripts/ and follow the
existing lane-launcher.sh conventions (jq CRLF wrapper for native-Windows jq,
the --help header as the full contract, explicit exit codes, per-field graceful
degradation for telemetry).

Fix

Part 1 — machine-behavior.sh (issue item 1). Emits the MACHINE-BEHAVIOR
block as a verbatim-printable text block: gh identity (gh api user), clone path

  • worktree inventory (root, count, per-worktree branch, detached-HEAD flag, from
    git worktree list --porcelain), and installed plugin versions.
  • Determinism contract: the script emits ONLY mechanically unambiguous facts.
    It deliberately does not compute "deviations from standing rules" — the
    standing rules are prose the script cannot read, so a deviation is a model
    judgment made by reading these facts against those rules. Emitting a fabricated
    deviations verdict would put guessing inside the "deterministic" script; the
    block ends with a one-line pointer reminding the lane to make that assessment.
    For the same reason there is no heuristic "worktree pattern" field — the
    worktrees legitimately span different parents, so the raw path list is the
    pattern.
  • Installed, not repo-HEAD, versions. The block describes the environment the
    lane is actually running in. Per the lanes skill's context/refresh.md, a
    running lane's installed plugin version can lag repo HEAD mid-session — the
    installed record (installed_plugins.json) is the honest number, the checkout
    is not. --plugin <id> (repeatable) scopes the block to the plugins a lane runs.

Part 2 — telemetry-upsert.sh (issue item 2; interim home of the #502 contract).
--issue N --marker STR --body-file PATH maintains exactly ONE marker-identified
comment on a tracking issue, editing it in place instead of posting a second.

  • Marker / detection convention (reusable across lanes and issues): the script
    writes a machine-detectable sentinel as the comment's first line —
    <!-- claude-ops:lane-telemetry marker=STR -->. STR is constrained to
    [A-Za-z0-9:._-]+ so it can never contain the > that would close the HTML
    comment early. The sentinel is invisible in the rendered issue and distinct per
    marker, so N lanes can each own one comment on the same issue without
    colliding.
  • Two-tier detection (mirrors the issue spec): (1) primary — the newest comment
    carrying the exact sentinel; (2) fallback — on first migration off a
    hand-authored comment, the most recent comment by the authenticated user
    carrying the raw marker text (PATCHing it adopts it, so the next cycle takes the
    primary path). The comment list is fetched with gh api --paginate and slurped;
    without pagination an existing comment on page 2 of a busy tracking issue is
    invisible and the script would POST a duplicate — the exact bug it exists to
    prevent. REST numeric comment ids are used for the PATCH (the GraphQL
    gh issue view --json comments node ids the read-side uses are not accepted by
    the REST issues/comments/{id} endpoint).

Part 3 — wire re-anchor:script-the-deterministic-work into the prompt-authoring
ritual (issue item 3): DEFERRED — blocked on #480.
The issue scopes this to
"#480's future skill." #480 (a loop-prompt-authoring skill) does not exist yet
as an implemented skill — it is an open, unimplemented issue — so there is no
ritual to hook into, and building #480's skill is out of scope for this PR. A repo
grep for an existing prompt-versioning ritual/checklist (prompt-version,
prompt-authoring, script-the-deterministic) found only the re-anchor skill
itself and the existing #480 prompt-storage forward-dependency note in the
lanes SKILL.md / context/config.md — which is about storage, not the authoring
ritual, so wiring the re-anchor step there would be a stretch. Per the task's
stated scope boundary, no new ritual/checklist file is invented; Part 3 lands when
#480 does.

Verification

Tests follow this repo's convention (a sibling .test.sh per script, PATH-stubbed
external commands + JSON fixtures, hermetic — no network/real CLI), discovered by
scripts/run-plugin-tests.sh (plugins/**/*.test.sh).

$ bash plugins/claude-ops/skills/lanes/scripts/machine-behavior.test.sh
machine-behavior.test: PASS — 22 cases

$ bash plugins/claude-ops/skills/lanes/scripts/telemetry-upsert.test.sh
telemetry-upsert.test: PASS — 21 cases

$ bash plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh   # regression, unbroken
lane-launcher.test: PASS — 88 cases

machine-behavior.test.sh covers: gh identity rendering + degradation to
unavailable when gh is missing/unauthenticated; worktree count, per-worktree
branch, detached-HEAD flag, and clone-path from the common git dir; installed
plugin versions filtered by --plugin, with the scope-divergent marker when a
name has >1 installed version; missing/malformed installed_plugins.json
degradation; a --plugin naming no installed plugin reported (not silently empty);
argument/repo exit codes.

telemetry-upsert.test.sh covers: create when no match (POST); update-in-place on
the sentinel (PATCH to that id); fallback adoption of the user's raw-marker comment;
fallback refusing another user's marker comment (creates instead); pagination —
a sentinel comment on a second page is found (no duplicate POST)
; marker
>-injection rejection; non-numeric issue + missing body-file rejection; --dry-run
performs no write; - reads the body from stdin.

Repo quality gates run locally (all clean):

shellcheck -x --rcfile .shellcheckrc  <all four scripts>   → clean (0 findings)
shfmt -d  <all four scripts>                                → clean (no diff)
scripts/check-changelog-parity.sh --check-bump origin/main → PASS
scripts/validate-plugins.sh                                → PASS
scripts/check-orphaned-fixtures.sh                         → PASS
scripts/check-changed-skills.sh origin/main                → PASS (7 trigger phrases preserved; 3 script tests pass)
markdownlint-cli2 CHANGELOG.md SKILL.md                    → 0 errors

Closes #538

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_01K1V3gkrfSf75isB8MiDy3o

Two /loop lane mechanics that need no reasoning are otherwise hand-assembled
every session. Extract them into scripts the lane prompt references, making the
output deterministic and testable and shrinking the prompt.

- machine-behavior.sh: emits the MACHINE-BEHAVIOR block (gh identity, clone path,
  worktree inventory, INSTALLED plugin versions) as a verbatim-printable text
  block. Facts only — "deviations from standing rules" stays a model judgment,
  not a scripted field. Installed versions are the honest number for a running
  lane (they can lag repo HEAD mid-session per context/refresh.md).
- telemetry-upsert.sh: maintains ONE marker-identified telemetry comment on a
  tracking issue, editing it in place via a machine-detectable sentinel found
  across all comments (paginated) — never posting a second. Interim home of the
  #502 telemetry contract.

Both follow lane-launcher.sh conventions (jq CRLF wrapper, --help header as the
full contract, explicit exit codes) and ship a sibling .test.sh with PATH-stubbed
gh/git and JSON fixtures (no network). Documented in the lanes SKILL.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1V3gkrfSf75isB8MiDy3o
@kyle-sexton kyle-sexton added the automated Opened by automation. label Jul 21, 2026
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 46s —— View job


Code Review

  • Load tools
  • Read all changed files
  • Review machine-behavior.sh
  • Review machine-behavior.test.sh
  • Review telemetry-upsert.sh
  • Review telemetry-upsert.test.sh
  • Review supporting file changes
  • Post findings

Summary

Overall this is a solid PR — determinism contract is well-reasoned, graceful degradation throughout, pagination bug is correctly identified and fixed, marker charset validation is tight, and the hermetic test harness is thorough. One correctness bug in machine-behavior.sh and the test gap that hides it are the primary findings.


Findings

1. Bug — clone-path is . when script runs from the main worktree

machine-behavior.sh lines 157–164

git -C "$REPO" rev-parse --git-common-dir returns .git (a relative path) when $REPO is the main worktree. The */.git case pattern requires at least one character before /.git, so .git falls through to the * arm, giving CLONE_PATH="$(dirname ".git")" = ..

In practice loop lanes run from a linked worktree so --git-common-dir is absolute, but the script is also usable from the main checkout (e.g. during setup or on a bare machine), and SKILL.md documents it without a "linked-worktree only" caveat.

The test fixture hard-codes the absolute path for --git-common-dir, so all 22 test cases pass while the production regression remains hidden.

Fix — normalize common_dir to absolute before the case statement:

# resolve relative common-dir (e.g. ".git" in the main worktree) to absolute
[[ "$common_dir" = /* ]] || common_dir="$REPO/$common_dir"
case "$common_dir" in
  */.git) CLONE_PATH="${common_dir%/.git}" ;;
  *)      CLONE_PATH="$(dirname "$common_dir")" ;;
esac

A companion test case with the stub returning ".git" (no leading /) would lock this in.

Fix this →


2. Minor — seen_api guard in the telemetry-upsert test stub is vacuously true

telemetry-upsert.test.sh lines 51–57

seen_api is initialized to "0" (non-empty string), so [[ -n "$seen_api" ]] is always true from the first iteration. The guard is intended to require api to appear before a repos/* token, but as written it provides no filtering. This doesn't affect test outcomes because all real invocations have api before the URL token, but a future stub extension that adds a repos/* argument in a non-api path (e.g. repo view --json...) could silently misroute.

Correct initialization is seen_api=0 with the check changed to (( seen_api )).


3. Minor — version ordering is lexicographic, not semver

machine-behavior.sh line 212

jq's unique sorts lexicographically. This means 0.9.0, 0.10.0 would appear as 0.10.0, 0.9.0 in the output. For telemetry display this is cosmetic, not a correctness issue, but worth knowing before a plugin hits double-digit minor versions.


What's well done

  • Pagination correctnessjq -s 'add // []' is exactly right. The PR description explicitly calls out the scenario (sentinel on page 2 → duplicate POST) and the test covers it. This is the most important invariant in telemetry-upsert.sh and it's locked down.
  • Marker injection guard[A-Za-z0-9:._-]+ correctly excludes > and --, preventing both early HTML-comment closure and any semantic ambiguity with the sentinel format.
  • Graceful degradation in machine-behavior.shgh absent/unauthenticated, missing or malformed installed_plugins.json, and git failures all degrade to unavailable fields rather than hard failures. Appropriate for a telemetry emitter.
  • Determinism contract — clearly stated that deviations-from-rules are explicitly not computed by the script. The comment in the output reminding the lane to make that assessment itself is a clean design choice.
  • Two-tier fallback in telemetry-upsert.sh — primary sentinel → user-scoped raw-marker fallback (for migration off hand-authored comments) → create. The user-scope restriction on the fallback correctly prevents the script from ever PATCHing another author's comment.
  • CRLF wrapper — consistent with lane-launcher.sh and fleet-state.sh; every jq call is routed through the wrapper.
  • Exit codes — explicit and documented in the header contract for both scripts.
  • Test hermetics — PATH-stubbing and JSON fixtures; no network or real CLI state. The 22+21 case counts are solid.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 10s —— View job


Security Review — PR #803

Tasks:


Reviewed the 7 changed files. Security-only findings below; no style or coverage commentary.


IMPORTANT — Unvalidated --repo enables GitHub API URL path injection

Severity: IMPORTANT | Confidence: PLAUSIBLE

telemetry-upsert.sh embeds $REPO directly into GitHub API URL paths without validating that it matches the expected owner/repo format:

# line 164
gh api --paginate "repos/$REPO/issues/$ISSUE/comments"
# line 196
gh api --method PATCH "repos/$REPO/issues/comments/$target_id" -f body="$new_body"
# line 207
gh api --method POST "repos/$REPO/issues/$ISSUE/comments" -f body="$new_body"

telemetry-upsert.sh L151–L164
telemetry-upsert.sh L196
telemetry-upsert.sh L207

Attack scenario: A caller (or a lane prompt with prompt-injected content) passes --repo 'foo/bar/../../org2/target'. HTTP clients and GitHub's API server typically normalize .. path segments before routing, so repos/foo/bar/../../org2/target/issues/502/comments becomes repos/org2/target/issues/502/comments. This redirects the GET (comment list), PATCH, and POST to a different repository's issue thread — potentially one the attacker controls or one the authenticated token can reach. The default path (gh repo view) is safe; only the explicit --repo arg and whatever a lane prompt constructs are at risk.

The --issue parameter is correctly validated to ^[0-9]+$ (line 122); --repo has no equivalent check.

Fix: Add a format guard before first use of $REPO:

[[ "$REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || {
  err "--repo must be owner/repo (got: '${REPO}')"
  exit 3
}

IMPORTANT — --body-file accepts arbitrary filesystem paths; enables credential exfiltration via prompt injection

Severity: IMPORTANT | Confidence: PLAUSIBLE

telemetry-upsert.sh reads the body file with cat and no path restrictions:

# lines 136–144
if [[ "$BODY_FILE" == "-" ]]; then
  body_text="$(cat)"
else
  [[ -f "$BODY_FILE" ]] || { err "body file not found: $BODY_FILE"; exit 3; }
  body_text="$(cat "$BODY_FILE")"
fi

telemetry-upsert.sh L136–L144

Attack scenario (prompt injection): This script is called from AI-driven lane prompts. A compromised or prompt-injected lane invocation of:

telemetry-upsert.sh --issue 502 --marker lane:triage --body-file ~/.config/gh/hosts.yml

…would POST the gh CLI credential file (which contains OAuth tokens) as a public GitHub issue comment. ~/.claude/settings.json, ~/.ssh/id_rsa, and .env files in the working directory are equally reachable by the same technique.

The PR description acknowledges the intended scope ("Kept small — a telemetry block, not an essay"), but nothing in the script enforces this. The normal caller is the authenticated user's own AI model, which is the exact surface that prompt injection targets.

Fix options:

  1. Constrain the file to a known-safe prefix: e.g., require $BODY_FILE to resolve under $CLAUDE_PLUGIN_DATA or a caller-specified temp dir.
  2. Add a max-size guard (e.g., [[ $(wc -c < "$BODY_FILE") -le 65536 ]]) — won't prevent all credential files but blocks the large-file edge-case and makes the intended scope machine-enforceable.
  3. Document the risk explicitly in --help and require an explicit --allow-arbitrary-paths flag for paths outside a safe prefix — lowest-friction option.

No findings in: machine-behavior.sh, machine-behavior.test.sh, telemetry-upsert.test.sh, SKILL.md, plugin.json

The marker injection defense (^[A-Za-z0-9:._-]+$, which correctly prevents > from closing the HTML comment sentinel early) is correct. The jq --arg / --argjson usage is safe throughout — no jq code injection is possible. Git porcelain parsing and the worktree display (%%|* / #*| operators) handle branch names with embedded | correctly. The gh api user + jq comparison in the fallback detection is safe. No hardcoded credentials or secrets were found.

…refs

CI on #803 flagged two mechanical gates:
- exec-bit: the four new lane scripts carried shebangs but non-executable git
  modes; set 100755 in the index.
- comment-hygiene: two header comments cited issue numbers directly (not the
  sanctioned TODO(#issue) form). Reworded to describe the why/constraint without
  the numeric back-reference, and changed a sample-output line to a placeholder.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1V3gkrfSf75isB8MiDy3o
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 41s —— View job


Security Review — PR #803

  • Load tools
  • Read changed files (machine-behavior.sh, telemetry-upsert.sh, test files)
  • Analyze for injection, path traversal, credential exposure, authorization gaps
  • Post findings

Two security findings in telemetry-upsert.sh. No findings in machine-behavior.sh, the test files, SKILL.md, or plugin.json.


IMPORTANT — --repo is not format-validated before embedding in GitHub API URL paths

Severity: IMPORTANT | Confidence: PLAUSIBLE

$REPO is interpolated directly into three gh api URL paths with no format check:

# line 164
gh api --paginate "repos/$REPO/issues/$ISSUE/comments"
# line 196
gh api --method PATCH "repos/$REPO/issues/comments/$target_id"
# line 207
gh api --method POST "repos/$REPO/issues/$ISSUE/comments"

telemetry-upsert.sh L164
telemetry-upsert.sh L196
telemetry-upsert.sh L207

--issue is correctly validated to ^[0-9]+$ (line 122); --repo is not.

Attack scenario: A caller or prompt-injected lane invocation passes --repo 'foo/bar/../../org2/target'. Most HTTP client stacks and GitHub's API server normalize .. segments before routing, so repos/foo/bar/../../org2/target/issues/502/comments becomes repos/org2/target/issues/502/comments. The GET (listing that org's comments), PATCH, and POST are all redirected to a repository the attacker controls or that the authenticated token has write access to. Default (gh repo view) is safe because that value originates from GitHub's own API; only explicit --repo input and anything a lane prompt constructs are at risk.

Fix: Validate before first use of $REPO:

[[ "$REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || {
  err "--repo must match owner/repo (got: '${REPO}')"
  exit 3
}

Place this after the REPO resolution block (after line 155) so both the explicit --repo path and the auto-detected path are covered. Add a corresponding test case asserting exit 3 on a path-traversal --repo value.

Fix this →


IMPORTANT — --body-file reads any path the process can reach; enables silent credential exfiltration via prompt injection

Severity: IMPORTANT | Confidence: PLAUSIBLE

The body-file read is unconditional beyond an existence check:

# lines 136–143
if [[ "$BODY_FILE" == "-" ]]; then
  body_text="$(cat)"
else
  [[ -f "$BODY_FILE" ]] || { err "body file not found: $BODY_FILE"; exit 3; }
  body_text="$(cat "$BODY_FILE")"
fi

telemetry-upsert.sh L136–L143

Attack scenario (prompt injection): This script is designed to be invoked from AI-driven lane prompts. A prompt-injected instruction such as:

call telemetry-upsert.sh --issue 502 --marker lane:triage --body-file ~/.config/gh/hosts.yml

…would read the gh CLI OAuth token file (which is YAML, but also parseable as text by cat) and POST it as a public GitHub issue comment. ~/.claude/settings.json, ~/.ssh/id_rsa, and .env files in the working directory are equally reachable. The comment body is public and the content is visible to anyone who can read the issue.

The normal invocation is the authenticated user's own AI model — exactly the surface that prompt injection targets.

Fix options (in order of preference):

  1. Constrain path to a known-safe prefix. Require $BODY_FILE to resolve under $CLAUDE_PLUGIN_DATA or a caller-provided temp directory. This is the strongest control and fits the plugin-form-safe design rule already in CLAUDE.md.

  2. Max-size guard. [[ $(wc -c < "$BODY_FILE") -le 65536 ]] — won't block targeted single-secret files but prevents bulk exfiltration of large key stores.

  3. Document and require an explicit opt-in flag. Add --allow-arbitrary-path and fail if it's absent for paths outside a safe prefix. Lowest code change; surfaces the risk to the lane author.

Fix this →


No findings in: machine-behavior.sh, machine-behavior.test.sh, telemetry-upsert.test.sh, SKILL.md, plugin.json

The marker injection defense (^[A-Za-z0-9:._-]+$) correctly excludes > and prevents early HTML-comment closure. All jq invocations use --arg / --argjson — no jq code injection is possible. $REPO in machine-behavior.sh is canonicalized via cd … && pwd and git is called with -C "$REPO", which is safe. The MACHINE_BEHAVIOR_INSTALLED_JSON env override is test-scoped and not exploitable for data exfiltration because the jq filter only extracts .plugins entries — a pointed credential file would produce empty output, not leak data. REST comment IDs ($target_id) are extracted from jq .id (numeric type) and produce only digit strings, so no path injection there.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 7s —— View job


Re-review after fix(claude-ops): mark lane scripts executable, drop in-comment issue refs

  • Load tools
  • Read changed files and latest diff
  • Review machine-behavior.sh — clone-path bug status
  • Review machine-behavior.test.sh — test gap status
  • Review telemetry-upsert.sh — security findings status
  • Review telemetry-upsert.test.shseen_api guard status
  • Synthesize findings and post

What the new commit does

The latest commit (1978219) is a mechanical CI fix only — it sets executable bits on the four new scripts and removes inline issue-number references from two header comments. It does not address any of the correctness or security findings raised in the prior review passes. All five open findings remain exactly as described below.


Open findings (status: unchanged)

1. Correctness Bug — clone-path emits . when the script runs from the main worktree

machine-behavior.sh L156–L163

git rev-parse --git-common-dir returns the relative path .git when invoked inside the main worktree. The */.git case pattern requires at least one character before /.git, so .git falls through to *) CLONE_PATH="$(dirname "$common_dir")" — and dirname ".git" is ..

The git stub in the test always returns the absolute path /repos/claude-code-plugins/.git (line 83 of the test), so all 22 cases pass while the main-worktree regression is hidden.

Fix — normalize before the case:

# resolve relative common-dir (.git in the main worktree) to absolute
[[ "$common_dir" = /* ]] || common_dir="$REPO/$common_dir"
case "$common_dir" in
  */.git) CLONE_PATH="${common_dir%/.git}" ;;
  *)      CLONE_PATH="$(dirname "$common_dir")" ;;
esac

Add a companion test case where the git stub returns ".git" (no leading /) and assert clone-path equals the absolute $REPO path, not ..

Fix this →


2. Security — --repo value is embedded in GitHub API URL paths without format validation

telemetry-upsert.sh L164, L196, L207

$REPO is interpolated directly into gh api URL paths (repos/$REPO/issues/...) with no check that it is owner/name form. A caller or a prompt-injected lane can pass --repo 'foo/bar/../../org2/target' to redirect API calls to a different repository. The --issue arg is correctly validated to ^[0-9]+$; --repo has no equivalent guard.

Fix — add a format check after argument parsing (before $REPO is first used):

[[ "$REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || {
  err "--repo must be owner/repo (got: '${REPO}')"
  exit 3
}

Fix this →


3. Security — --body-file reads any filesystem path; enables credential exfiltration via prompt injection

telemetry-upsert.sh L136–L144

cat "$BODY_FILE" reads any path the caller supplies with no restrictions. Since this script is driven by AI lane prompts, a prompt-injected invocation can pass --body-file ~/.config/gh/hosts.yml (or any credential file) and POST its contents as a public GitHub issue comment.

Minimum mitigation — add a file-size cap so the sentinel format for "a telemetry block, not an essay" is machine-enforced and large credential files are rejected:

[[ $(wc -c < "$BODY_FILE") -le 65536 ]] || {
  err "body file exceeds 64 KiB; telemetry blocks should be small"
  exit 3
}

A stronger fix constrains $BODY_FILE to resolve under $CLAUDE_PLUGIN_DATA or a caller-specified safe directory. Document the path-restriction model in --help either way.

Fix this →


4. Minor — seen_api guard in the stub is vacuously true

telemetry-upsert.test.sh L50–L55

seen_api is initialized to 0 (a non-empty string), so [[ -n "$seen_api" ]] is always true from the first token. The guard is meant to require api to appear before a repos/* or user token, but as written it provides no filtering. Tests still pass because all real invocations call gh api before the URL arg; the risk is that a future stub extension with a repos/* argument in a non-api path (e.g. gh repo view ...) could silently misroute.

Fix: seen_api=0 with (( seen_api )) as the check (arithmetic check on the integer flag).


5. Minor — plugin versions sort lexicographically, not by semver

machine-behavior.sh L212

jq's unique sorts lexicographically. 0.9.0, 0.10.0 would display as 0.10.0, 0.9.0. Cosmetic for telemetry, but worth noting before any plugin crosses a double-digit minor version.


What's well done

The prior review's summary stands: pagination correctness, marker injection guard, graceful degradation, determinism contract, two-tier fallback, CRLF wrapper, exit codes, and test hermetics are all solid. This is good work; findings 1–3 are the blockers before merge.

@kyle-sexton kyle-sexton added the area: security Security-relevant: vulnerability, hardening, or disclosure follow-up. label Jul 21, 2026
…worktree bug

Review of the lane scripts surfaced two IMPORTANT security findings and one
correctness bug, all fixed on-branch.

Security (telemetry-upsert.sh is prompt-driven, so its inputs are attacker-
reachable):
- Validate --repo as owner/repo BEFORE interpolating it into any gh api URL
  path. An unvalidated value with `..` segments could otherwise redirect the
  GET/PATCH/POST to a different repo.
- Constrain a real --body-file to resolve under the safe dir (--body-dir, else
  $CLAUDE_PLUGIN_DATA), reject a symlink leaf, and cap the body at 64 KiB.
  Without this, a prompt-injected --body-file pointing at a secret (an ssh key,
  a token store, a .env) would be cat'd and posted as a public comment. Pipe an
  in-memory body via `-` (stdin), which is exempt from the path check.

Correctness:
- machine-behavior.sh emitted "." as the clone path from the main worktree:
  `rev-parse --git-common-dir` returns a relative `.git` there, and
  dirname(".git") is ".". Anchor a relative common-dir to the repo root first.

Also fixes a vacuous seen_api guard in the telemetry-upsert test stub. New test
cases cover each fix (traversal --repo, out-of-safe-dir body, symlink leaf,
oversized body, no-safe-dir fail-closed, relative git-common-dir clone-path);
the body-file contract is documented in SKILL.md and the CHANGELOG entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1V3gkrfSf75isB8MiDy3o
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 44s —— View job


Security Review — PR #803 (re-review after a75fd8a)

  • Load tools
  • Read changed files at HEAD
  • Check prior findings (clone-path bug, --repo validation, --body-file path restriction, seen_api guard)
  • Hunt for new security issues
  • Post findings

All five prior findings are resolved. No new IMPORTANT or CRITICAL findings. One SUGGESTION-level edge case below.


Prior finding status

1. Correctness Bug — clone-path . in main worktree → FIXED

machine-behavior.sh L162–L170

Now correctly anchors relative common_dir values (e.g. bare .git) to $REPO before the case pattern match, including a Windows drive-letter branch. Companion test at machine-behavior.test.sh L131–L140 sets STUB_COMMON_DIR=".git" and asserts clone-path equals the absolute $TMP path, not ".". ✅

2. Security — --repo format validation → FIXED

telemetry-upsert.sh L232–L235

^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ guard is placed after REPO resolution and covers both the explicit --repo value and the auto-detected one from gh repo view. A traversal value like foo/bar/../../org2/target fails validation before any gh api call. Test at telemetry-upsert.test.sh L201–L204 asserts exit 3. ✅

3. Security — --body-file arbitrary path read → FIXED (comprehensively)

telemetry-upsert.sh L162–L213

Three-layer defense:

  1. Symlink leaf rejection[[ -L "$BODY_FILE" ]] exits 3 before cat. The comment correctly explains why parent-dir canonicalization alone can't stop a symlink leaf inside the safe dir that targets a secret outside it.
  2. Directory containment — both the safe dir and the body file's parent are resolved to physical paths via cd … && pwd -P. Re-appending basename raw (after the symlink check) is safe. The trailing-slash pattern ("$safe_canon"/*) enforces a segment boundary so a sibling /safe-evil/… path can't collide with /safe/….
  3. Size cap — 64 KiB hard limit applied to both file and stdin bodies after reading.

New --body-dir parameter lets callers specify an explicit safe prefix; the script fails closed (exit 4) when neither --body-dir nor $CLAUDE_PLUGIN_DATA is set. Extensive test coverage at telemetry-upsert.test.sh L207–L257 including: outside-safe-dir rejection, ..-traversal rejection, symlink rejection, no-safe-dir fail-closed, and oversized-body rejection. ✅

4. Minor — seen_api guard vacuously true → FIXED

telemetry-upsert.test.sh L50–L55

Now seen_api=0 (unquoted integer) with ((seen_api)) arithmetic check — the stub correctly requires the api subcommand to appear before routing a repos/* or user token as the URL. ✅

5. Minor — lexicographic version sort → not addressed (acceptable)

machine-behavior.sh L214

unique still sorts lexicographically. Cosmetic for telemetry display; no correctness or security impact. No objection to leaving this as-is.


New finding

SUGGESTION — case path-containment pattern inherits glob characters from safe_canon

Severity: SUGGESTION | Confidence: PLAUSIBLE

telemetry-upsert.sh L198–L204

case "$body_canon" in
"$safe_canon"/*) : ;;
*)
  err "..."
  exit 3
  ;;
esac

In bash case patterns, glob metacharacters (*, ?, […]) inside the double-quoted variable expansion are still interpreted as pattern syntax. If safe_canon contains a path component with [, ?, or * — e.g. a $CLAUDE_PLUGIN_DATA value like /tmp/test[dev] — the case pattern becomes /tmp/test[dev]/*, which matches /tmp/testd/*, /tmp/teste/*, and /tmp/testv/* as well as the literal /tmp/test[dev]/*. A crafted safe-dir name could therefore widen the accepted body-file prefix.

Practical scope: The attacker would need to control CLAUDE_PLUGIN_DATA or the --body-dir argument. Any caller who controls either value can already pass --body-dir / to bypass containment entirely, so this does not introduce a new exploitation path — it is a defense-in-depth gap rather than a standalone vulnerability. The threat model for this script is prompt injection of the --body-file argument while the safe-dir values are trusted; that model is not broken here.

Fix (one line): replace the case with a string-prefix test that treats safe_canon as a literal:

[[ "$body_canon" == "$safe_canon"/* ]] || {
  err "body file must resolve under the safe dir ($safe_canon); refusing to read $body_canon"
  exit 3
}

[[ == ]] with a double-quoted right-hand side treats the value as a literal string, so no glob expansion occurs. The /* suffix (outside the quotes) remains a pattern wildcard for the path separator check, which is the intended behavior.

Fix this →


Summary

The three IMPORTANT findings from prior passes (clone-path bug, --repo injection, --body-file exfiltration) and the seen_api guard have all been thoroughly addressed. The body-file containment implementation in particular is notably careful — symlink guard, physical-path canonicalization, fail-closed on missing safe dir, and comprehensive test coverage for each bypass scenario. One suggestion above ([[ == ]] vs case for the prefix check) would tighten the containment but does not represent a standalone exploitable path in the current threat model.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 32s —— View job


Re-review after fix(claude-ops): harden telemetry-upsert inputs; fix clone-path main-worktree bug

  • Load tools
  • Read changed files at latest SHA
  • Verify clone-path bug fix (finding 1)
  • Verify --repo format validation fix (finding 2)
  • Verify --body-file path restriction fix (finding 3)
  • Check seen_api test guard (finding 4)
  • Check version sort (finding 5)
  • Post findings

All five findings from the prior review passes are verified. Summary below.


Finding 1 — clone-path = . from main worktree ✅ FIXED

machine-behavior.sh L162–L171

The fix inserts a normalization block before the case statement, anchoring a relative common_dir to $REPO. The Windows drive-letter case ([A-Za-z]:[\\/]*) is covered alongside the POSIX absolute path, which is a thoughtful addition. A companion test case (STUB_COMMON_DIR=".git") was added at lines 131–140 of the test file, asserting that clone-path equals the absolute $TMP path and not .. Regression is locked in.


Finding 2 — --repo not format-validated ✅ FIXED

telemetry-upsert.sh L232–L235

The ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ guard sits after both the explicit --repo path and the gh repo view auto-detect path, so both flows are covered. The comment above it explains the ..-normalization redirect attack. Test case (lines 201–204) asserts exit 3 on foo/bar/../../org2/target.


Finding 3 — --body-file reads any filesystem path ✅ FIXED (exceeded minimum)

telemetry-upsert.sh L158–L213

The fix is layered and thorough:

  1. Symlink leaf rejection (L174–177) — -L check before any read. Since pwd -P canonicalizes ancestor directories but not the leaf itself, the explicit check is needed and placed correctly.
  2. Containment via pwd -P (L187–204) — canonicalizes both the safe dir and the body file's parent directory independently, then compares prefixes. This catches symlinks anywhere in the ancestor chain of the body file's directory (not just the leaf). The trailing-slash pattern in the case ("$safe_canon"/*) correctly enforces a path-segment boundary so /safe never matches /safe-evil/....
  3. 64 KiB size cap (L208–213) — applied to both file and stdin paths as a defense-in-depth layer.
  4. New --body-dir parameter — makes the safe dir caller-configurable; falls back to $CLAUDE_PLUGIN_DATA; fails closed (exit 4) if neither is set.
  5. Documentation--help text, SKILL.md, and CHANGELOG all updated to describe the security model and the recommended --body-file - (stdin) workflow for in-memory bodies.

Test coverage: outside safe dir, ..-traversal escape, symlink leaf (with MSYS-aware skip gate), no safe dir configured, oversized body. All paths covered.

One design note on the symlink check: the check is on the final leaf. A symlink anywhere in the parent chain (e.g., /safe/linked_dir//evil/) is handled by the pwd -P canonicalization. The combined approach is correct.


Finding 4 — seen_api guard vacuously true ✅ FIXED

telemetry-upsert.test.sh L55

Changed from [[ -n "$seen_api" && -z "$url" ]] (always true — "0" is non-empty) to ((seen_api)) && [[ -z "$url" ]] (arithmetic — evaluates 0 as false). The guard now correctly requires api to appear before a repos/* or user token.


Finding 5 — version sort is lexicographic, not semver — not addressed (no action needed)

machine-behavior.sh L214

unique in jq is still lexicographic. This remains cosmetic — no plugin in this repo is near a double-digit minor version, and the output is telemetry display only. Not a merge blocker; noting for awareness.


Verdict

All IMPORTANT and correctness blockers are resolved. The --body-file containment is particularly well-executed — layered correctly (symlink check before read, pwd -P for ancestors, size cap as defense-in-depth) and documented in all the right places. Ready to merge.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Post-green review classification (work lane) — final round:

Finding Round Classification Disposition
clone-path = . in main worktree 1, confirmed 2 Correctness bug Fixeda75fd8a
--repo not format-validated → GitHub API path-traversal redirect 1, confirmed 2 IMPORTANT, security-posture, DEFER-FORBIDDEN Fixeda75fd8a
--body-file reads arbitrary paths → credential exfiltration via prompt injection 1, confirmed 2 IMPORTANT, security-posture + silently-wrong-output-feeding-an-irreversible-action, DEFER-FORBIDDEN Fixed comprehensivelya75fd8a: symlink-leaf rejection, pwd -P ancestor canonicalization, path-segment-boundary containment check, 64 KiB size cap, fail-closed when no safe dir configured, full test coverage of every bypass class
seen_api test guard vacuously true 1, confirmed 2 Minor test-quality Fixeda75fd8a
Lexicographic (not semver) version sort in telemetry display 1, confirmed 2 Cosmetic, no correctness/security impact Left alone (reviewer: "no objection to leaving this as-is")
Glob metacharacters in the case-based safe-dir prefix check could theoretically widen containment if safe_canon itself contains [/?/* 3 (new, post-fix re-review) SUGGESTION — reviewer explicitly confirms "does not introduce a new exploitation path" (an attacker who controls CLAUDE_PLUGIN_DATA/--body-dir already has stronger bypasses); second independent reviewer's verdict is "Ready to merge" without flagging this as blocking Noted, not fixed — genuinely non-blocking per both reviews, not a DEFER-FORBIDDEN class (no exploitable path in the stated threat model)

Both IMPORTANT/DEFER-FORBIDDEN findings were fixed on-branch before this PR went further, per rule 8's carve-out — never deferred to a follow-up issue.

No inline review threads exist on this PR (GraphQL query returned empty) — nothing to resolve.

Handing off to the babysit lane for ongoing watch.

@kyle-sexton
kyle-sexton marked this pull request as ready for review July 21, 2026 07:26
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 19s —— View job


Security Review — PR #803 (fresh pass at HEAD a75fd8a)

  • Load tools
  • Read changed files at HEAD
  • Review machine-behavior.sh
  • Review telemetry-upsert.sh
  • Review test files and supporting changes
  • Post findings

Independent fresh-pass review of all 7 changed files at a75fd8a. No prior-review summaries were used to form conclusions — I read every file from scratch.


No new CRITICAL or IMPORTANT findings

The three IMPORTANT issues from earlier passes are all correctly resolved at this SHA. Below I confirm each closure and note two residual edges — one SUGGESTION already on record and one new SUGGESTION.


Prior IMPORTANT findings — status at HEAD

1 — clone-path = . in main worktree (machine-behavior.sh L162–170) — RESOLVED

The two-stage case correctly anchors a relative common_dir to $REPO before the pattern match. The Windows drive-letter branch ([A-Za-z]:[\\/]*) is also handled. The companion test at machine-behavior.test.sh L135–139 sets STUB_COMMON_DIR=".git" and asserts the absolute path, not ".".

2 — --repo embedding in GitHub API URL without format validation (telemetry-upsert.sh L232–235) — RESOLVED

The ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ guard sits after both the explicit --repo and the gh repo view auto-detect path, covering both flows before any gh api URL construction. A test case at telemetry-upsert.test.sh L201–204 asserts exit 3 on a traversal value.

3 — --body-file reads any filesystem path (telemetry-upsert.sh L162–213) — RESOLVED (layered)

Three independent controls, each blocking a distinct bypass class:

  • Symlink leaf rejected via -L before any cat — handles the case where a symlink sits inside the safe dir but targets a secret outside it. Placed correctly before the containment check.
  • Parent-dir containment via cd … && pwd -P on both sides — resolves .. and symlinked ancestors. The /* suffix enforces a segment boundary so /safe never matches /safe-evil/….
  • 64 KiB size cap applied to both file and stdin bodies.

The --body-dir parameter and the fail-closed behavior when neither --body-dir nor $CLAUDE_PLUGIN_DATA is set (exit 4) are correct. Test coverage spans outside-safe-dir, ..-traversal, symlink leaf, no-safe-dir, and oversized-body cases.


Findings

SUGGESTION — case pattern-match inherits glob metacharacters from $safe_canon

Confidence: PLAUSIBLE | Severity: SUGGESTION

Severity: SUGGESTION | Confidence: PLAUSIBLE

telemetry-upsert.sh L198–204

case "$body_canon" in
"$safe_canon"/*) : ;;

In bash case, metacharacters inside a double-quoted variable expansion are still interpreted as glob pattern syntax. If $safe_canon contains [, ?, or * — possible when $CLAUDE_PLUGIN_DATA is a path like /tmp/test[dev] — the expanded pattern becomes /tmp/test[dev]/*, which matches /tmp/testd/…, /tmp/teste/…, and /tmp/testv/… in addition to the literal path. An adversary would need to control CLAUDE_PLUGIN_DATA or --body-dir to exploit this; both values are trusted in the stated threat model and controlling either already enables stronger bypasses (e.g., --body-dir /). This is defense-in-depth only.

The one-line fix closes the gap with no behavior change on well-formed paths:

[[ "$body_canon" == "$safe_canon"/* ]] || {
  err "body file must resolve under the safe dir ($safe_canon); refusing to read $body_canon"
  exit 3
}

In [[ == ]] with a double-quoted right-hand variable, the expanded value is treated as a literal string; only the /* suffix (outside the quotes) acts as a pattern wildcard, which is the intended behavior.

Fix this →%20...%20esac%60%20with%20%60%5B%5B%20%22%24body_canon%22%20%3D%3D%20%22%24safe_canon%22%2F*%20%5D%5D%20%7C%7C%20%7B%20err%20%22body%20file%20must%20resolve%20under%20the%20safe%20dir...%22%3B%20exit%203%3B%20%7D%60%20so%20that%20glob%20metacharacters%20in%20safe_canon%20(e.g.%20%5B%20%3F%20*%20in%20a%20CLAUDE_PLUGIN_DATA%20path)%20are%20not%20interpreted%20as%20case-pattern%20syntax.&repo=melodic-software/claude-code-plugins)


SUGGESTION — --repo regex admits .. and . as individual path components

Severity: SUGGESTION | Confidence: PLAUSIBLE

telemetry-upsert.sh L232

[[ "$REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]

The character class [A-Za-z0-9._-] includes ., so a value like foo/.. or ../repo passes validation. This produces URL paths like repos/foo/../issues/N/comments which most HTTP clients and GitHub's routing layer would normalize. repos/foo/../issues/N/comments normalizes to repos/issues/N/comments — not a valid GitHub API endpoint — so the call 404s rather than redirecting to a meaningful resource. The multi-segment traversal attack (foo/bar/../../org2/target) that the guard was designed to block is still correctly rejected (the regex allows exactly one /).

In practice this is unexploitable, but the guard's intent is clearest if single-component ./.. are also excluded. Anchoring to [A-Za-z0-9_-] for the first character of each component (GitHub disallows leading dots in org/repo names) tightens the gap:

[[ "$REPO" =~ ^[A-Za-z0-9_-][A-Za-z0-9._-]*/[A-Za-z0-9_-][A-Za-z0-9._-]*$ ]] || {
  err "--repo must be owner/repo (got: '$REPO')"
  exit 3
}

What's well done

  • -f body="$new_body" argument construction — correctly double-quoted so $new_body expands as a single bash argument regardless of embedded spaces, newlines, or glob characters. No shell injection through the comment body.
  • All jq variable injection surfaces--arg, --argjson, and --jq are used consistently; no user-controlled value is interpolated into a jq program string.
  • target_id from .id // empty — jq's .id returns a JSON integer type; the result is either a digit string or empty. No path injection through the PATCH URL even if a crafted API response were considered.
  • Symlink + containment ordering-L check runs before cat, and the pwd -P canonicalization covers symlinks anywhere in the ancestor chain of the body file's directory. The combined approach is correct.
  • Fail-closed on missing safe dir — exit 4 when neither --body-dir nor $CLAUDE_PLUGIN_DATA is set. The script cannot be invoked in an unconstrained state by accident.
  • Pagination via jq -s 'add // []' — correctly handles multi-page comment lists; the // [] fallback handles empty slurp output.
  • Marker charset restriction[A-Za-z0-9:._-]+ correctly excludes >, preventing early HTML-comment closure and sentinel format ambiguity.

Verdict: No new blockers. The two SUGGESTION-level items above are improvements worth considering but do not represent exploitable paths in the stated threat model. Ready to merge.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 56s —— View job


PR #803 Review

Tasks:

  • Load tools
  • Read all 7 changed files at HEAD (a75fd8a)
  • Review machine-behavior.sh
  • Review machine-behavior.test.sh
  • Review telemetry-upsert.sh
  • Review telemetry-upsert.test.sh
  • Review supporting file changes (plugin.json, CHANGELOG.md, SKILL.md)
  • Verify prior finding resolutions
  • Post findings

Independent Review — a75fd8a

This is a fresh read of all 7 changed files. Prior rounds have been thorough; I focused on independent verification of the three correctness/security blockers and a fresh look for anything not yet flagged.


Prior findings — verified status

1. clone-path = . in main worktree → FIXED

machine-behavior.sh L162–L170

The two-step normalization is correct: first a case guards whether common_dir is absolute (POSIX /… or Windows drive [A-Za-z]:[\\/]*), then anchors relative paths to $REPO. A second case strips /.git or calls dirname. The companion test at machine-behavior.test.sh L135–L139 sets STUB_COMMON_DIR=".git" and asserts clone-path equals the absolute $TMP path, locking in the regression.

2. --repo format not validated → FIXED

telemetry-upsert.sh L232–L235

^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ guard is placed after both the explicit --repo path and the gh repo view auto-detect path, covering both flows before any gh api interpolation. The comment explains the ..-normalization redirect attack. Test at telemetry-upsert.test.sh L201–L204 asserts exit 3 on foo/bar/../../org2/target.

3. --body-file reads arbitrary paths → FIXED comprehensively

telemetry-upsert.sh L162–L213

Three correctly-ordered layers:

  1. Symlink leaf rejection-L before cat, with an accurate comment explaining why pwd -P alone doesn't catch it
  2. Directory containment via pwd -P — canonicalizes both the safe dir and the body file's parent independently; re-appending basename raw after the symlink check is safe
  3. 64 KiB cap — applied to file and stdin bodies alike as defense-in-depth

Fail-closed when neither --body-dir nor $CLAUDE_PLUGIN_DATA is set (exit 4). Test coverage at telemetry-upsert.test.sh L207–L257 covers every bypass class: outside safe dir, ..-traversal, symlink leaf (with platform-aware MSYS skip gate), no-safe-dir fail-closed, and oversized body.

4. seen_api guard vacuously true → FIXED

telemetry-upsert.test.sh L50–L55

Now seen_api=0 (unquoted integer) with ((seen_api)) arithmetic check. The stub correctly guards repos/*/user routing on api appearing first.

5. Lexicographic version sort → left as-is (cosmetic, acceptable)

machine-behavior.sh L214

jq's unique remains lexicographic. Telemetry display only; no plugin in this repo is near double-digit minor versions. No objection.

6. case glob metacharacters in safe_canon (SUGGESTION from final security pass) → left as-is, correctly non-blocking

telemetry-upsert.sh L198–L204

I independently confirm: an attacker who controls CLAUDE_PLUGIN_DATA or --body-dir already has --body-dir / as a stronger bypass; this does not introduce a new exploitation path in the stated threat model. Using [[ "$body_canon" == "$safe_canon"/* ]] instead of the case would be a one-line tightening, but it is not required for merge.


New observations

These are net-new from my pass; none are blockers.

gh api user called twice in gh_identity() — micro-inefficiency

machine-behavior.sh L133–L134

login="$(gh api user --jq '.login' 2>/dev/null | tr -d '\r')"
id="$(gh api user --jq '.id' 2>/dev/null | tr -d '\r')"

Two round-trips to the same endpoint. A single call like gh api user --jq '"\(.login) \(.id)"' would halve the API calls. No correctness issue — two calls is safe and gh caches auth tokens in memory — but worth a future cleanup.

case "$body_canon" in "$safe_canon"/* — suggestion still open, correctly deferred

As noted in finding 6 above. Documenting here so the deferred state is explicit.


What's well done (fresh read confirms)

  • Pagination correctnessjq -s 'add // []' correctly slurps multi-page responses; the comment explicitly names the duplicate-POST failure it prevents; the pagination test at telemetry-upsert.test.sh L165–L179 uses two separate JSON arrays (simulating --paginate output) to confirm the page-2 sentinel is found.

  • Primary/fallback jq filters — both handle empty inputs correctly: last on [] returns null, .id on null returns null, // empty produces no output in -r mode, so target_id degrades to "" rather than crashing.

  • Body containment layer ordering — symlink check before cat, pwd -P canonicalization for ancestors, then size cap. Ordering matters (symlink check must precede cat; it is); correct.

  • SKILL.md §"Per-cycle deterministic scripts" at L116–L148 accurately summarizes both scripts and documents the security model for --body-file inline. The body-dir constraint is surfaced where a lane author will encounter it.

  • CHANGELOG and plugin version0.16.x → 0.17.0 is an appropriate minor bump for new public script surface. CHANGELOG entry at [0.17.0] matches, correctly notes the exfiltration hardening.

  • Determinism contract — the comment at L251 of machine-behavior.sh (deviations: assess these facts against the lane standing rules (not scripted — see machine-behavior.sh header)) correctly places the judgment call with the model, not the script.


Verdict

All three IMPORTANT/correctness blockers from prior rounds are verified fixed. No new IMPORTANT or CRITICAL findings from this pass. The body-file containment in particular is well-executed — layered correctly, documented in --help, SKILL.md, and CHANGELOG. Ready to merge.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a75fd8a8b5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/claude-ops/skills/lanes/scripts/telemetry-upsert.sh Outdated
Comment thread plugins/claude-ops/skills/lanes/scripts/telemetry-upsert.sh
…k boundary

A space-form option passed as the final argument (e.g. `--issue` with no value)
left `shift 2` unable to consume it, spinning the parse loop forever instead of
returning exit 3; each value option now checks for its argument first.

The marker-fallback used a plain substring match, so a shorter marker
(`lane:triage`) would adopt and overwrite a longer lane's comment
(`lane:triage-old`). It now boundary-matches the marker as a whole token.

New test cases cover both: a trailing value-less option exiting 3 without
hanging, and a prefix-collision marker creating its own comment rather than
PATCHing the longer lane's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1V3gkrfSf75isB8MiDy3o
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 12s —— View job


Security Review — PR #803 (HEAD 7552a1a)

  • Load tools
  • Read PR diff at HEAD (7552a1a)
  • Read telemetry-upsert.sh at current HEAD
  • Read telemetry-upsert.test.sh at current HEAD
  • Read machine-behavior.sh at current HEAD
  • Verify Codex P2 findings (prefix-match fallback, missing-value shift loop)
  • Verify prior SUGGESTION findings (case glob, repo regex)
  • Hunt for new security issues
  • Post findings

Fresh security-only read of all files at 7552a1a. No prior review summaries used to form conclusions.


Codex P2 findings — status at HEAD

P2 — Missing option value hangs the parse loop → FIXED

telemetry-upsert.sh L91–L162

Every space-form option (--issue, --marker, --body-file, --body-dir, --repo) now checks [[ $# -ge 2 ]] before assigning $2 and calling shift 2. When the option is the final argument with no value, the check fires, emits "--X requires a value", and exits 3. The test at telemetry-upsert.test.sh L219–L227 guards the exit with timeout 10 where available so a regression fails the case instead of hanging CI.

P2 — Prefix-collision in marker fallback → FIXED

telemetry-upsert.sh L282–L293

The fallback changed from contains($m) (plain substring) to a regex boundary match:

($m | gsub("[.]"; "\\.")) as $mre
| test("(?<![A-Za-z0-9:._-])" + $mre + "(?![A-Za-z0-9:._-])")

The negative lookbehind and lookahead use the marker charset [A-Za-z0-9:._-] as the boundary, preventing lane:triage from matching inside lane:triage-old. The only regex metacharacter in that charset is ., which gsub("[.]"; "\\.") correctly escapes to \. so dots in marker names stay literal. The marker validator ^[A-Za-z0-9:._-]+$ guarantees no other metacharacters can appear in $MARKER. The test at telemetry-upsert.test.sh L165–L177 confirms that lane:triage against a lane:triage-old body POSTs a new comment rather than PATCHing the longer lane's.


Prior IMPORTANT findings — confirmed still fixed

All three IMPORTANT/correctness blockers from earlier passes (clone-path = ".", --repo URL injection, --body-file exfiltration) and the seen_api guard are unchanged from a75fd8a and remain correctly fixed. No regression introduced by 7552a1a.


SUGGESTION-level items — status unchanged

Both previously accepted suggestions remain unaddressed (as agreed):

  • case glob metacharacters in safe_canon (telemetry-upsert.sh L218–L224): the case "$body_canon" in "$safe_canon"/* pattern interprets glob metacharacters inside the quoted expansion if $safe_canon contains [, ?, or *. An attacker controlling CLAUDE_PLUGIN_DATA already has --body-dir / as a stronger bypass, so this doesn't introduce a new exploitation path. [[ "$body_canon" == "$safe_canon"/* ]] would close it in one line if desired.
  • --repo regex admits . and .. as single-segment components (telemetry-upsert.sh L252): ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ allows foo/.. but the resulting URL repos/foo/../issues/N/comments normalizes to a 404, not a meaningful resource. The multi-segment traversal attack (which produced a valid redirect) is correctly blocked by the single-slash constraint.

No new CRITICAL or IMPORTANT findings

The argument-parsing change in 7552a1a is correct and tightly scoped. The boundary-regex construction in the fallback is safe: $MARKER arrives via --arg (never interpolated into the jq program), the only metacharacter in the allowed charset is escaped, and the regex is fixed-length on each lookbehind side (single character class). No new attack surface introduced.

Verdict: all security blockers closed. Ready to merge.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 16s —— View job


Review — PR #803 (HEAD 7552a1a)

  • Read changed files at HEAD 7552a1a
  • Diff a75fd8a..7552a1a to isolate new commit
  • Review telemetry-upsert.sh changes
  • Review telemetry-upsert.test.sh changes
  • Evaluate Codex findings (fallback boundary, option-shift hang)
  • Post findings

What the new commit does

Commit 7552a1a addresses the two Codex findings that were open after the prior review rounds. No other files were changed.


Codex Finding 1 — fallback contains($m) matches a superstring marker → FIXED

telemetry-upsert.sh L285–L292

contains($m) in the fallback arm was a plain substring match: searching for lane:triage would also match a comment body containing lane:triage-old (because lane:triage is a prefix of lane:triage-old). This run would have PATCHed the other lane's comment, overwriting its only telemetry entry.

The fix replaces contains($m) with a jq test() call using negative lookahead/lookbehind anchored to the marker charset:

($m | gsub("[.]"; "\\.")) as $mre
| select(… and ((.body // "") | test("(?<![A-Za-z0-9:._-])" + $mre + "(?![A-Za-z0-9:._-])")))

Correctness analysis:

  • jq uses Oniguruma, which supports negative lookbehind/lookahead — the regex is valid.
  • Only . from the marker charset [A-Za-z0-9:._-] has special regex meaning outside a character class. gsub("[.]"; "\\.") correctly escapes it to \.; :, _, and - (outside […]) need no escaping.
  • With the lookarounds in place, lane:triage in the regex fails to match lane:triage-old because the - after triage is in the charset → the negative lookahead fires. ✓
  • The primary detection path (contains($SENTINEL)) was never affected: the sentinel includes the closing -->, so is not a substring of. The fix is correctly scoped to the fallback.

Test coverage: New case at telemetry-upsert.test.sh L161–L174 — fixture has a lane:triage-old comment owned by octocat; script runs as STUB_ME=octocat with MARKER=lane:triage; asserts POST (create), not PATCH to id 555. ✓


Codex Finding 2 — trailing space-form option with no value causes infinite loop → FIXED

telemetry-upsert.sh L88–L163

Without set -e, shift 2 when $# == 1 fails silently without consuming $1, so the while (($#)) loop would re-enter on the same unmatched token indefinitely.

Each of the five space-form options (--issue, --marker, --body-file, --body-dir, --repo) now guards with [[ $# -ge 2 ]] before the shift 2, exiting 3 with a clear error if no value follows. The check is structurally identical across all five arms, which is the correct approach — there is no asymmetry to introduce a gap.

Test coverage: New case at telemetry-upsert.test.sh L216–L229 exercises --issue as the trailing option with no value, wrapped in timeout 10 where available. On a regression (infinite loop) timeout returns 124, which fails the assert_eq 3 check. The graceful timeout-absent path is acknowledged in a comment — reasonable for portability. The --issue requires a value message is asserted. ✓


Remaining deferred items (unchanged, non-blocking)

  • case "$body_canon" in "$safe_canon"/* glob-in-pattern — the SUGGESTION from the prior security pass is still present. Both prior security reviewers explicitly confirmed it does not introduce a new exploitable path (an attacker who controls CLAUDE_PLUGIN_DATA/--body-dir already has --body-dir / as a stronger bypass). Not a blocker.
  • Lexicographic version sort in machine-behavior.sh — cosmetic; no plugin near double-digit minor versions.

Verdict

Both Codex findings are correctly fixed. All prior IMPORTANT/correctness blockers were resolved in a75fd8a. No new IMPORTANT or CRITICAL findings from this pass. Ready to merge.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Classification — genuinely-open findings from the two fresh-at-HEAD reviews (security #803 comment 5031262206, code review 5031262309). The two codex P2 inline findings were fixed in 7552a1a (see the threaded replies); the three IMPORTANT/correctness blockers and the lexicographic-sort item remain resolved and were classified in the earlier post-green comment. The remaining items below are all SUGGESTION/advisory and non-blocking (both fresh reviews: "Ready to merge").

# Finding Severity Classification Evidence
1 case "$body_canon" in "$safe_canon"/* inherits glob metachars from $safe_canon (telemetry-upsert.sh L198-204) SUGGESTION VALID (defer) Already publicly dispositioned ("noted, not fixed"). Both fresh reviews independently confirm no new exploitation path — an actor controlling CLAUDE_PLUGIN_DATA/--body-dir already has a stronger bypass (--body-dir /). Not reversing a standing disposition on a security containment check both reviewers cleared.
2 --repo regex [A-Za-z0-9._-] admits ./.. as a single path component (telemetry-upsert.sh L232) SUGGESTION VALID (defer) The class includes ., so foo/.. passes; but repos/foo/../issues/... normalizes to a non-existent endpoint (404), not a cross-repo redirect. The multi-segment traversal the guard targets is still rejected (regex allows exactly one /). Unexploitable in the stated threat model; deferred.
3 gh api user called twice in gh_identity() (machine-behavior.sh L133-134) observation VALID (defer) Two round-trips to the same endpoint; correctness-neutral, gh caches the token in-process. Micro-inefficiency; deferred.

No fix pushed for these three — deferred, not INCORRECT.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7552a1a6e3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/claude-ops/skills/lanes/scripts/telemetry-upsert.sh
Comment thread plugins/claude-ops/skills/lanes/scripts/machine-behavior.sh
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 52s —— View job


PR #803 Re-review after merge-of-main (aae5c9e)

Tasks:

  • Load tools
  • Read PR diff and changed files at HEAD
  • Verify status of open/deferred findings from prior rounds
  • Fresh review pass for new issues
  • Post findings

What the new commit does

Commit aae5c9e is a straight merge of main into the branch. None of the 7 PR-specific files were modified by the merge — diff shows only other-plugin and docs changes (education/quiz-me, playwright, source-control/stuck-checks, skill-quality). Every finding below reflects the scripts at the same SHA as the prior "Ready to merge" verdict.


All prior findings — confirmed unchanged

Finding Status
clone-path = . from main worktree (machine-behavior.sh L162–170) FIXED — normalization block anchors relative common_dir to $REPO; companion test asserts absolute path on STUB_COMMON_DIR=".git"
--repo URL path injection (telemetry-upsert.sh L252) FIXED^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ guard after both explicit and auto-detected paths; traversal test asserts exit 3
--body-file arbitrary path / credential exfiltration (telemetry-upsert.sh L182–225) FIXED — symlink leaf rejection + pwd -P ancestor canonicalization + 64 KiB cap + fail-closed on missing safe dir; coverage spans all bypass classes
seen_api guard vacuously true (test stub) FIXEDseen_api=0 (integer) with ((seen_api)) arithmetic check
Fallback contains($m) prefix collision — lane:triage adopts lane:triage-old FIXED — boundary regex with Oniguruma lookarounds; . escaped to \.; regression test
Trailing space-form option spins parse loop FIXED[[ $# -ge 2 ]] guard on all five space-form options; timeout-guarded test asserts exit 3

Deferred items — status unchanged (non-blocking)

1. jq() wrapper defined before command -v jq prereq guard (both scripts)

machine-behavior.sh L57 before L63, telemetry-upsert.sh L69 before L73

In bash, command -v jq finds shell functions, so the guard passes even when no jq binary is installed. Subsequent command jq calls inside the wrapper then fail silently: in telemetry-upsert.sh the || comments='[]' fallback fires and the script POSTs a duplicate comment; in machine-behavior.sh individual fields degrade to empty instead of the documented exit 4. The fix is either type -P jq (binary-only lookup) or reordering the prereq check above the wrapper definition. Classified P2 / non-blocking in prior rounds; tracked in #812.

2. case "$body_canon" in "$safe_canon"/* — glob metacharacters in $safe_canon

telemetry-upsert.sh L218–L224

Glob metacharacters in a bash case pattern are expanded even inside double quotes. A $CLAUDE_PLUGIN_DATA path containing [, ?, or * would widen the accepted prefix. An actor who controls CLAUDE_PLUGIN_DATA or --body-dir already has --body-dir / as a stronger bypass — no new exploitation path. One-line fix: [[ "$body_canon" == "$safe_canon"/* ]]. Deferred; accepted by both prior security reviewers.

3. --repo regex admits foo/.. as a single-segment value

telemetry-upsert.sh L252

[A-Za-z0-9._-] includes ., so foo/.. passes validation. The resulting URL repos/foo/../issues/N/comments normalizes to a non-existent endpoint (404). Multi-segment traversal (foo/bar/../../org2/target) is correctly rejected by the single-slash constraint. Unexploitable; deferred.

4. gh api user called twice in gh_identity() — micro-inefficiency

machine-behavior.sh L133–L134

Two round-trips to the same endpoint for login and id. No correctness impact; deferred.


No new findings

The merge brought in main-branch content (education, playwright, source-control, skill-quality plugins) with no overlap on the 7 changed files. No conflicts, no behavioral changes to the scripts or tests.


Verdict

All IMPORTANT and correctness blockers remain fixed at HEAD. Deferred items are correctly classified as non-blocking. Ready to merge.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 2s —— View job


Security Review — PR #803 (HEAD aae5c9e)

  • Load tools
  • Get PR diff at current HEAD
  • Read changed files (machine-behavior.sh, telemetry-upsert.sh, test files)
  • Review machine-behavior.sh
  • Review telemetry-upsert.sh
  • Review test files and supporting changes
  • Post findings

The merge commit (aae5c9e) brought in changes from main that touch no claude-ops files — the scripts are byte-for-byte identical to 7552a1a. Fresh pass at all 7 PR-changed files follows.


No new CRITICAL or IMPORTANT findings

All three IMPORTANT blockers and the seen_api guard from prior rounds remain correctly fixed. The two Codex P2 findings fixed in 7552a1a (option-value infinite loop, marker-fallback prefix collision) are confirmed intact. No new exploitable paths found.


Prior IMPORTANT findings — confirmed still fixed at HEAD

1 — clone-path = . in main worktree (machine-behavior.sh L162–L170) ✅

Two-stage case correctly anchors a relative common_dir (e.g., bare .git) to $REPO before pattern-matching. Windows drive-letter path ([A-Za-z]:[\\/]*) is also handled.

2 — --repo embedding in GitHub API URL without format validation (telemetry-upsert.sh L252–L255) ✅

^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ guard sits after both the explicit --repo path and the gh repo view auto-detect path, covering both flows before any URL interpolation. Path-traversal values like foo/bar/../../org2/target are rejected.

3 — --body-file reads arbitrary filesystem paths (telemetry-upsert.sh L182–L225) ✅

Three-layer defense remains intact: symlink-leaf rejection (-L before cat), parent-dir containment via cd … && pwd -P, and a 64 KiB size cap on both file and stdin bodies. Fail-closed (exit 4) when neither --body-dir nor $CLAUDE_PLUGIN_DATA is set.


Open deferred items — status unchanged

These were classified and dispositioned in the prior post-green classification comment; no change at this SHA.

# Finding Location Severity Status
1 jq() function is defined before command -v jq prereq check; the guard resolves the function, not the binary, so the check passes on a host with no jq binary. Downstream jq calls then fail silently inside $(…), producing empty/default values rather than exit 4. In telemetry-upsert.sh this causes a duplicate comment POST instead of editing the existing one — the "one comment, edit in place" invariant is violated. machine-behavior.sh L57/L63, telemetry-upsert.sh L69/L73 P2 (deferred) Tracked in #812. Fix: move the prereq check above the jq() wrapper, or use type -P jq which skips shell functions.
2 case "$body_canon" in "$safe_canon"/* interprets glob metacharacters inside $safe_canon as pattern syntax (bash case expands [, ?, * in quoted variable expansions). An actor controlling CLAUDE_PLUGIN_DATA/--body-dir already has --body-dir / as a stronger bypass. telemetry-upsert.sh L218–L224 SUGGESTION (deferred) Replace case with [[ "$body_canon" == "$safe_canon"/* ]] to treat $safe_canon as a literal.
3 --repo regex [A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ allows . as a single path component (e.g. foo/..), but the resulting URL repos/foo/../issues/N/comments normalizes to a non-existent endpoint (404, not a cross-repo redirect). The multi-segment traversal attack is correctly blocked by the single-slash constraint. telemetry-upsert.sh L252 SUGGESTION (deferred) Unexploitable in stated threat model.

Fresh observations (no prior classification)

SUGGESTION — stdin body is buffered in full before the size cap is applied

Severity: SUGGESTION | Confidence: PLAUSIBLE

telemetry-upsert.sh L182–L183

if [[ "$BODY_FILE" == "-" ]]; then
  body_text="$(cat)"

The $(cat) command substitution reads stdin to completion and holds it in memory before the 64 KiB cap (L229–L233) is checked. The file-path path correctly limits what can be read (safe dir + size cap), but the stdin path has no read-time bound. A prompt-injected lane constructing cat /dev/zero | telemetry-upsert.sh … --body-file - would buffer into process memory indefinitely before the cap fires.

Practical scope: The attacker must control both the stdin source and the --body-file - argument — in the prompt-injection threat model that means the AI model is piping its own output, so the attacker needs fairly deep prompt control. OS process memory limits also constrain the blast radius. This is DoS-class, not credential-exfiltration, and recoverable. Noting for awareness.

Fix option: Read up to MAX_BODY_BYTES + 1 bytes and reject early:

body_text="$(dd bs=1 count=$((MAX_BODY_BYTES + 1)) 2>/dev/null)"

(Or head -c if portability is the concern — head -c $((MAX_BODY_BYTES + 1)) is POSIX on Linux/macOS.)

Fix this →


What's well done

  • Marker boundary fix (telemetry-upsert.sh L285–L291) — gsub("[.]"; "\\.") correctly escapes only . from the marker charset before building the Oniguruma regex; no other chars in [A-Za-z0-9:._-] have special meaning outside a character class. The lookbehind and lookahead are fixed-width (single character class), so no ReDoS risk.
  • target_id path.id // empty in jq returns a JSON integer type rendered as a digit string by -r; even if an API response were somehow crafted, the PATCH URL can only be poisoned with digits, producing a 404 not a redirect.
  • -f body="$new_body" in the PATCH and POST calls passes the comment body as form data, not a shell token; shell metacharacters in $new_body are not further interpreted.
  • Fail-closed on missing safe dir — exit 4 when neither --body-dir nor $CLAUDE_PLUGIN_DATA is set; the script cannot be called in an unconstrained state by accident.

Verdict: No new CRITICAL or IMPORTANT findings. The one fresh observation (stdin buffered before size cap) is SUGGESTION-level — DoS-class, not credential-exfiltration, and gated on the attacker controlling both stdin source and the --body-file - flag. Prior blockers are all closed. Ready to merge.

@kyle-sexton
kyle-sexton merged commit 71eca62 into main Jul 21, 2026
22 checks passed
@kyle-sexton
kyle-sexton deleted the feat/538-script-deterministic-lane-mechanics branch July 21, 2026 08:50
@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Unblock summary (tower-dispatched worker)

Result: merged via squash (71eca62) at 2026-07-21T08:50:24Z. Branch updated from main first (signed merge commit, behind_by 8 → CLEAN, no conflict); CI green; all review threads resolved.

Review threads (4, all resolved)

# Thread Finding Disposition
1 telemetry-upsert.sh L260 Marker-fallback contains($m) adopts a longer lane's comment when the marker is a prefix (lane:triage vs lane:triage-old) — data-loss Fixed in 7552a1a, independently verified: boundary-match regex (?<![A-Za-z0-9:._-])…(?![A-Za-z0-9:._-]) — the - in lane:triage-old trips the negative lookahead, no match.
2 telemetry-upsert.sh L92 Trailing space-form option with no value spins the parse loop instead of exiting 3 — hang Fixed in 7552a1a, verified: [[ $# -ge 2 ]] guard before each shift 2, exits 3.
3 telemetry-upsert.sh L69 jq() wrapper defined before command -v jq prereq → guard passes with no jq binary → fail-open, POSTs duplicate telemetry comment instead of exit 4 Deferred → #812. New finding (posted 07:56:23Z, after the earlier classification). Confirmed empirically. Deferrable: advisory telemetry, not a merge/safety gate, recoverable, no data-loss.
4 machine-behavior.sh L57 Same jq-shadow root cause → degraded block + exit 0 instead of exit 4 Deferred → #812 (shared fix). Lowest-stakes; block is explicitly non-authoritative.

New findings

Threads 3 & 4 surfaced after the pre-existing "ready to merge" snapshot and were not rubber-stamped: independently verified (PATH=/nonexistent command -v jq returns 0 because the function shadows the binary; type -P jq is the correct detection), classified deferrable, and tracked in #812 with repro + one-line fix direction. Team lead was flagged before the merge landed.

Refused

Nothing refused — no finding fell in the never-defer classes (data-loss / fail-open safety-or-merge-gate / security / silently-wrong-into-irreversible-action). Signed-commit-only constraint honored (update-branch API, no contents-PUT).

This was generated by AI (tower-dispatched unblock worker).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: security Security-relevant: vulnerability, hardening, or disclosure follow-up. automated Opened by automation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

loops: script the deterministic lane mechanics — machine-behavior block, #502 telemetry upsert, wire script-the-deterministic-work into prompt ritual

1 participant