diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9bdd444..2273f99 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -27,7 +27,7 @@ { "name": "agentic-engineering", "description": "The autonomous engineering system for repository portfolios — engineer, read-only surveyor, and meta-engineer agents; portfolio, product, spend, and improvement workflows; cross-tool instruction architecture and skill discovery; configured by the consumer AGENTS.md", - "version": "4.4.25", + "version": "4.4.26", "source": "./plugins/agentic-engineering" }, { diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 9bdd444..2273f99 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -27,7 +27,7 @@ { "name": "agentic-engineering", "description": "The autonomous engineering system for repository portfolios — engineer, read-only surveyor, and meta-engineer agents; portfolio, product, spend, and improvement workflows; cross-tool instruction architecture and skill discovery; configured by the consumer AGENTS.md", - "version": "4.4.25", + "version": "4.4.26", "source": "./plugins/agentic-engineering" }, { diff --git a/plugins/agentic-engineering/.claude-plugin/plugin.json b/plugins/agentic-engineering/.claude-plugin/plugin.json index e85f663..158630a 100644 --- a/plugins/agentic-engineering/.claude-plugin/plugin.json +++ b/plugins/agentic-engineering/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agentic-engineering", "description": "The autonomous engineering system for repository portfolios — engineer, read-only surveyor, and meta-engineer agents; portfolio, product, spend, and improvement workflows; cross-tool instruction architecture and skill discovery; configured by the consumer AGENTS.md", - "version": "4.4.25", + "version": "4.4.26", "author": { "name": "devantler-tech", "url": "https://github.com/devantler-tech" diff --git a/plugins/agentic-engineering/plugin.json b/plugins/agentic-engineering/plugin.json index e85f663..158630a 100644 --- a/plugins/agentic-engineering/plugin.json +++ b/plugins/agentic-engineering/plugin.json @@ -1,7 +1,7 @@ { "name": "agentic-engineering", "description": "The autonomous engineering system for repository portfolios — engineer, read-only surveyor, and meta-engineer agents; portfolio, product, spend, and improvement workflows; cross-tool instruction architecture and skill discovery; configured by the consumer AGENTS.md", - "version": "4.4.25", + "version": "4.4.26", "author": { "name": "devantler-tech", "url": "https://github.com/devantler-tech" diff --git a/plugins/agentic-engineering/resources/provider-neutral.desired-state.json b/plugins/agentic-engineering/resources/provider-neutral.desired-state.json index b5c344e..54ff88d 100644 --- a/plugins/agentic-engineering/resources/provider-neutral.desired-state.json +++ b/plugins/agentic-engineering/resources/provider-neutral.desired-state.json @@ -18,7 +18,7 @@ }, { "path": "scripts/forge-readonly-guard.sh", - "sha256": "2e0754b6e003e739ced37b27bf35f3afd6caed2a257afdc535f9d04948525e83", + "sha256": "9591835448efc0d8f08f83da48deacc9a50079f4df79e9db7ed91d9146dd3649", "executable": true }, { diff --git a/plugins/agentic-engineering/scripts/forge-readonly-guard.sh b/plugins/agentic-engineering/scripts/forge-readonly-guard.sh index a8ef0fb..3ff108b 100755 --- a/plugins/agentic-engineering/scripts/forge-readonly-guard.sh +++ b/plugins/agentic-engineering/scripts/forge-readonly-guard.sh @@ -192,6 +192,28 @@ GH_VERB_VALUE_FLAGS=" -R --repo --state --limit -L --json --jq -q --search --aut # independent rules that fire earlier, and neither is widened by an explicit GET. GH_API_FIELD_FLAGS=" -f --raw-field -F --field --input " +# Value grammars that legitimately begin with a dash. A value-taking flag consumes the +# next word whatever it looks like — gh's flag parser and git's alike — so `--repo --web` +# never reaches gh as a flag. That inertness lives in a parser the guard does not assert, +# and it stops holding the day any value flag grows an optional-value grammar. So the +# guard classifies the consumed word itself (check_consumed_value): a flag-shaped value +# is denied by name unless its flag is listed here, where a leading dash is part of the +# value's own grammar and the value can name no program, host, or file: +# --search a server-side query expression (`-label:foo` negates a qualifier) +# --label, --milestone free-form server-side filter strings — a label or milestone +# title may itself begin with a dash (`--label -bug`) +# --jq / -q a jq program (`-1`, `-(.x)`) run by gh's embedded jq; its reach into +# the process environment is refused separately by check_gh_flag_value +# --template/-t a Go template rendered locally, with no exec or file primitive +# git --grep, -S, -G, --author, --committer a regex or pickaxe string matched +# against history (`--author -bot` matches authors containing `-bot`) +# git --since, --until, --after, --before a date expression, which git's +# approxidate parser accepts in free form (`--since -yesterday`) +# Everything else — a count, a path, a ref, a format name — has a grammar no +# dash-letter word can satisfy, so a flag-shaped value there is denied by name. +GH_DASH_VALUE_FLAGS=" --search --label --milestone --jq -q --template -t " +GIT_DASH_VALUE_FLAGS=" --grep -S -G --author --committer --since --until --after --before " + # git options. Read verbs are not enough on their own: several options make git # write a file or execute a program without any shell syntax for the scanner to # catch, so options are allowlisted like everything else. @@ -743,6 +765,7 @@ classify_gh_api() { if [ "$FLAG_HAS_VALUE" -eq 0 ]; then if [ $((i + 1)) -ge "$n" ]; then deny "gh api $name needs a value"; fi val=${WORDS[$((i + 1))]} + check_consumed_value 'gh api' "$GH_DASH_VALUE_FLAGS" "$name" "$val" i=$((i + 1)) fi @@ -849,6 +872,29 @@ check_gh_flag_value() { esac } +# A word consumed as a flag's value that is itself flag-shaped. Only a flag whose value +# grammar begins with a dash ($2 names that family's set) may take it; for every other +# flag the guard would be handing a flag it never classified to a parser it does not +# assert. Denied by name, so the message points at the word to remove. +# +# Flag-shaped means a dash followed by a letter or a second dash. A dash followed by a +# digit is a NUMBER, never a flag — `--max-count -1` (unlimited), `-n -1`, and the +# relative date `--since -1.day` are all documented, read-only git grammars — so it is +# admitted for every value flag: no flag's name starts with a digit, so nothing the +# guard classifies can hide behind one. +check_consumed_value() { + local prog=$1 dash_flags=$2 name=$3 val=$4 + case "$val" in + -[0-9]*) ;; + -?*) + case "$dash_flags" in + *" $name "*) ;; + *) deny "$prog $name consumed '$val' as its value, but that word is flag-shaped and $name takes no dash-leading value; the guard classifies it as a flag rather than trusting the parser to keep it inert" ;; + esac + ;; + esac +} + check_gh_verb_flags() { local i=1 w name val local n=${#WORDS[@]} @@ -903,6 +949,7 @@ check_gh_verb_flags() { fi if [ $((i + 1)) -ge "$n" ]; then deny "gh $name needs a value"; fi val=${WORDS[$((i + 1))]} + check_consumed_value gh "$GH_DASH_VALUE_FLAGS" "$name" "$val" check_gh_flag_value "$name" "$val" i=$((i + 1)) ;; @@ -1192,7 +1239,12 @@ classify_git() { if [ "$FLAG_HAS_VALUE" -eq 0 ]; then case "$GIT_OK_VALUE_FLAGS" in - *" $name "*) i=$((i + 1)) ;; + *" $name "*) + if [ $((i + 1)) -lt "${#WORDS[@]}" ]; then + check_consumed_value git "$GIT_DASH_VALUE_FLAGS" "$name" "${WORDS[$((i + 1))]}" + fi + i=$((i + 1)) + ;; esac fi i=$((i + 1)) diff --git a/plugins/agentic-engineering/scripts/forge-readonly-guard.test.sh b/plugins/agentic-engineering/scripts/forge-readonly-guard.test.sh index 5a006cc..836e99b 100755 --- a/plugins/agentic-engineering/scripts/forge-readonly-guard.test.sh +++ b/plugins/agentic-engineering/scripts/forge-readonly-guard.test.sh @@ -107,6 +107,57 @@ expect_deny 'trailing --json does not license redirection' "gh pr list --json > expect_deny 'a different value flag left bare is still denied' "gh pr list --repo" expect_deny 'trailing --jq left bare is still denied' "gh pr list --json number --jq" expect_deny 'the probe does not make a mutation verb readable' "gh pr merge --json" + +# A value-taking flag must not swallow a flag-shaped word (#181). gh's parser does +# consume the next word as the value whatever it looks like, so `--repo --web` never +# opens a browser — but that inertness belongs to a downstream parser the guard does +# not assert. The guard classifies the word itself: consumed by a flag whose value +# grammar cannot begin with a dash, a flag-shaped word is denied by name. +expect_deny_names 'a value flag does not swallow --web (pr list --repo)' \ + "gh pr list --repo --web" "--web" +expect_deny_names 'a value flag does not swallow --web (pr list --state)' \ + "gh pr list --state --web" "--web" +expect_deny_names 'a value flag does not swallow --web (pr view --limit)' \ + "gh pr view 1 --limit --web" "--web" +expect_deny_names 'gh api --method does not swallow --web' \ + "gh api repos/devantler-tech/monorepo/pulls --method --web" "--web" +expect_deny_names 'gh api -H does not swallow --hostname' \ + "gh api repos/devantler-tech/monorepo/pulls -H --hostname" "--hostname" +expect_deny_names 'git --max-count does not swallow --work-tree' \ + "git log --oneline --max-count --work-tree" "--work-tree" +# The rule is per flag family, not a blanket ban on dash-leading values: a search +# expression, a jq program and a git grep pattern legitimately begin with a dash, and +# none of them can name a program, a host, or a file. +expect_allow 'a --search expression may begin with a dash' \ + "gh pr list --repo devantler-tech/platform --search -label:blocked --limit 10" +expect_allow 'a --jq program may begin with a dash' \ + "gh api repos/devantler-tech/platform/pulls --jq -1" +# A label or milestone title is a free-form server-side filter and may itself begin +# with a dash; neither can name a program, a host, or a file. +expect_allow 'a --label filter may begin with a dash' \ + "gh issue list --repo devantler-tech/platform --label -bug --limit 10" +expect_allow 'a --milestone filter may begin with a dash' \ + "gh pr list --repo devantler-tech/platform --milestone -v2 --limit 10" +expect_allow 'a git --grep pattern may begin with a dash' \ + "git log --oneline --grep -x" +# A dash followed by a digit is a number, not a flag: git documents `--max-count -1` +# and `-n -1` as "no limit", and `--since -1.day` as a relative date. No flag name +# starts with a digit, so admitting these hides nothing the guard classifies. +expect_allow 'git --max-count takes a negative number' "git log --oneline --max-count -1" +expect_allow 'git -n takes a negative number' "git log --oneline -n -1" +expect_allow 'git --since takes a relative date beginning with a dash' \ + "git log --oneline --since -1.day" +expect_deny_names 'a negative number does not widen the rule to letters' \ + "git log --oneline --max-count -x --work-tree" "-x" +# git's pattern and date flags take free-form values that may begin with a dash: +# `--author -bot` is a regex over authors and `--since -yesterday` is an approxidate +# expression, both accepted by git as separated forms. A count is not free-form. +expect_allow 'git --author takes a dash-leading regex' "git log --oneline --author -bot" +expect_allow 'git --committer takes a dash-leading regex' "git log --oneline --committer -bot" +expect_allow 'git --since takes a dash-leading date expression' \ + "git log --oneline --since -yesterday" +expect_allow 'git --until takes a dash-leading date expression' \ + "git log --oneline --until -yesterday" expect_allow 'issue list' "gh issue list --repo devantler-tech/ksail --state open --limit 200" expect_allow 'search issues' "gh search issues --owner devantler-tech --state open --limit 300" expect_allow 'search prs' "gh search prs --owner devantler-tech --state open"