From d0662dbffe801b98de2b495dcf23490b2b18e150 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 6 Sep 2026 18:29:46 +0200 Subject: [PATCH 1/2] fix(agentic-engineering): expose the guarded classifier path --- .claude-plugin/marketplace.json | 2 +- .github/plugin/marketplace.json | 2 +- .../.claude-plugin/plugin.json | 2 +- plugins/agentic-engineering/README.md | 10 ++- .../agents/portfolio-surveyor.agent.md | 12 +++- plugins/agentic-engineering/plugin.json | 2 +- .../provider-neutral.desired-state.json | 4 +- .../scripts/forge-readonly-guard.sh | 14 ++++ .../scripts/surveyor-forge-readonly.test.sh | 65 +++++++++++++++++++ scripts/validate-manifests.sh | 2 +- scripts/validate-manifests.test.sh | 2 +- 11 files changed, 105 insertions(+), 12 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4d8e52f..13035f9 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": "5.0.0", + "version": "5.0.1", "source": "./plugins/agentic-engineering" }, { diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 4d8e52f..13035f9 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": "5.0.0", + "version": "5.0.1", "source": "./plugins/agentic-engineering" }, { diff --git a/plugins/agentic-engineering/.claude-plugin/plugin.json b/plugins/agentic-engineering/.claude-plugin/plugin.json index 600ee60..4cdfbfd 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": "5.0.0", + "version": "5.0.1", "author": { "name": "devantler-tech", "url": "https://github.com/devantler-tech" diff --git a/plugins/agentic-engineering/README.md b/plugins/agentic-engineering/README.md index 67ad429..cfc1cbf 100644 --- a/plugins/agentic-engineering/README.md +++ b/plugins/agentic-engineering/README.md @@ -296,7 +296,15 @@ provider-neutral desired-state entry pins the plugin-relative path, reviewed SHA requirement. The guard accepts only the exact classifier beside itself and only `--repo`, `--branch`, and a full `--head-sha`; it refuses the helper's offline `--input` mode. Resolve both scripts from the same -installed, reviewed plugin directory. The classifier captures its fixed paginated API GET in memory, +installed, reviewed plugin directory. Preflight may supply the literal absolute classifier path. +Otherwise, one bare `classify-default-branch-ci-runs.sh` probe through the active guard is a denied +discovery request: it executes nothing and returns `classifier-path-json:` with a JSON string naming +the guard's own executable sibling. The adapter preserves this record in its denial reason and +stderr. Decode it as data, quote the decoded path as one literal shell argument, and submit the +remote-mode command through the same guard. Never evaluate the record or use JSON double quotes as +shell quoting. Missing JSON tooling, a missing executable, or an absent, malformed, ambiguous, or +unusable hint leaves classification `QUERY-UNKNOWN`; directory searches and fallback roots are not +part of discovery. The classifier captures its fixed paginated API GET in memory, so this exception neither writes an intermediate file nor permits an arbitrary local executable. **Three residues the guard cannot close from argv alone — the calling runtime must.** They are stated diff --git a/plugins/agentic-engineering/agents/portfolio-surveyor.agent.md b/plugins/agentic-engineering/agents/portfolio-surveyor.agent.md index a41f1c3..481b11f 100644 --- a/plugins/agentic-engineering/agents/portfolio-surveyor.agent.md +++ b/plugins/agentic-engineering/agents/portfolio-surveyor.agent.md @@ -472,13 +472,19 @@ Judge the default branch by **its current head**, and only by runs that represen health. Resolve the head first, using the **full-length sha** — a runs endpoint typically returns an empty set for an abbreviated one, which reads exactly like "nothing failed". Then invoke the shipped [`../scripts/classify-default-branch-ci-runs.sh`](../scripts/classify-default-branch-ci-runs.sh), -resolving it from the installed, reviewed plugin path. **Invoke the classifier only in its flag -form, by its resolved installed path:** +using the literal absolute path supplied by preflight. If preflight supplied no path, submit +**one** bare `classify-default-branch-ci-runs.sh` probe through the active read-only guard. The +probe stays denied and executes nothing; the refusal carries one `classifier-path-json:` record +encoding the executable sibling of that guard. Decode that JSON string as path data, quote the +decoded path as one literal shell argument, and submit the resulting call through the same guard. +Never evaluate the record or reuse JSON double quotes as shell quoting. A missing, malformed, +ambiguous, or unusable hint means `QUERY-UNKNOWN`; do not hunt directories or try other roots. +**Invoke the classifier only in its flag form, by its resolved installed path:** `/scripts/classify-default-branch-ci-runs.sh --repo OWNER/REPO --branch BRANCH --head-sha FULL_SHA`. The helper and the read-only guard accept nothing else: the guard admits only that exact installed sibling path — never a bare basename, a `PATH` lookup, or a relative `../scripts/` form — and a positional `OWNER/REPO BRANCH SHA` is denied as `not the guarded remote-mode shape` while the helper -itself exits 2 on it, so the first invocation must already carry the resolved path and all three +itself exits 2 on it, so every executable invocation must carry the resolved path and all three flags. **Do not reimplement the helper** inline. It owns the paginated API call in memory as well as classification, so a later-page API failure cannot be masked by a successful consumer of partial output and the read-only role never writes an intermediate file. diff --git a/plugins/agentic-engineering/plugin.json b/plugins/agentic-engineering/plugin.json index 600ee60..4cdfbfd 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": "5.0.0", + "version": "5.0.1", "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 4bc8987..44105c9 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": "9591835448efc0d8f08f83da48deacc9a50079f4df79e9db7ed91d9146dd3649", + "sha256": "519b689c4a1bd7fcc5137c2c9d0abb828181ab2204d44932b525c03a6bf20702", "executable": true }, { @@ -61,7 +61,7 @@ "portfolio-surveyor": { "enabled": true, "mode": "delegated-read-only", - "definitionSha256": "f671faa85d7749bc22ee29fde2fe737dbf409c9bc17c55a4dc324e3ee966f504" + "definitionSha256": "e3a88b11ceb111ffae86f250f9fd468441bad575db57c2329b191affbf756563" }, "agent-improver": { "enabledWhen": "Both optional consumer contract sections are present", diff --git a/plugins/agentic-engineering/scripts/forge-readonly-guard.sh b/plugins/agentic-engineering/scripts/forge-readonly-guard.sh index 3ff108b..c0354f7 100755 --- a/plugins/agentic-engineering/scripts/forge-readonly-guard.sh +++ b/plugins/agentic-engineering/scripts/forge-readonly-guard.sh @@ -1595,6 +1595,20 @@ classify_segment() { if [ "$index" -eq 0 ]; then case "$prog" in gh | git | "$DEFAULT_BRANCH_CLASSIFIER") ;; + classify-default-branch-ci-runs.sh | */classify-default-branch-ci-runs.sh) + # Discovery remains a denial. Only the guard's own executable sibling + # supplies the hint; caller text is never reflected into this record. + # JSON keeps path characters as data. Missing jq or encoding failure + # leaves discovery unknown without changing command admission. + local path_json='' + if [ -x "$DEFAULT_BRANCH_CLASSIFIER" ]; then + path_json=$(jq -cn --arg path "$DEFAULT_BRANCH_CLASSIFIER" '$path' 2>/dev/null) || path_json='' + fi + if [ -n "$path_json" ]; then + deny "$(printf 'default-branch classifier requires its installed absolute path\nclassifier-path-json: %s' "$path_json")" + fi + deny 'default-branch classifier path is unavailable; report QUERY-UNKNOWN' + ;; *) deny "a read must begin with a forge command, not '$prog'" ;; esac fi diff --git a/plugins/agentic-engineering/scripts/surveyor-forge-readonly.test.sh b/plugins/agentic-engineering/scripts/surveyor-forge-readonly.test.sh index 0eb736b..0c72b00 100755 --- a/plugins/agentic-engineering/scripts/surveyor-forge-readonly.test.sh +++ b/plugins/agentic-engineering/scripts/surveyor-forge-readonly.test.sh @@ -229,6 +229,71 @@ else fi fi +# Discover through the real adapter, then admit and execute the returned literal +# path. Relocating the installation must not require knowing a versioned root. +# Only the external forge response is stubbed; neither guard nor classifier is. +mkdir -p "$TMP/forge-bin" +cat >"$TMP/forge-bin/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +[ "$*" = 'api --paginate --slurp --method GET repos/owner/repo/actions/runs -f head_sha=1111111111111111111111111111111111111111 -f branch=main -F per_page=100' ] || exit 1 +printf '%s\n' '{"total_count":1,"workflow_runs":[{"id":10,"workflow_id":11,"event":"push","conclusion":"failure","created_at":"2026-07-14T09:00:00Z","html_url":"https://example.test/fail","name":"CI"}]}' +EOF +chmod +x "$TMP/forge-bin/gh" +for install_dir in "$TMP/install-v1" "$TMP/relocated plugin 'quoted' \$literal"; do + mkdir -p "$install_dir" + install_dir=$(CDPATH='' cd -- "$install_dir" && pwd -P) + cp "$GUARD" "$WRAPPER" "$HERE/classify-default-branch-ci-runs.sh" "$install_dir/" + for probe in 'classify-default-branch-ci-runs.sh' '/incorrect/install/classify-default-branch-ci-runs.sh'; do + st=0 + out=$(run_wrapper "$(hook_stdin "$probe")" "$install_dir/surveyor-forge-readonly.sh" 2>"$TMP/discovery.err") || st=$? + reason=$(printf '%s' "$out" | jq -r '.hookSpecificOutput.permissionDecisionReason') + resolved=$(printf '%s' "$reason" | jq -Rse ' + split("\n") | map(select(startswith("classifier-path-json: "))) + | if length == 1 then .[0] | ltrimstr("classifier-path-json: ") | fromjson + else error("missing or ambiguous classifier path") end' -r 2>/dev/null) || resolved='' + if [ "$st" -eq 2 ] && [ "$resolved" = "$install_dir/classify-default-branch-ci-runs.sh" ] && + [ "$(cat "$TMP/discovery.err")" = "$reason" ]; then + pass + else + fail "discovery must deny and carry the exact relocated path in JSON and stderr (st=$st)" + continue + fi + # @sh produces one literal shell word, preserving quotes and dollar signs. + # This is not eval: only the complete command that passed the guard runs. + quoted=$(printf '%s' "$resolved" | jq -Rs '@sh' -r) + cmd="$quoted --repo owner/repo --branch main --head-sha 1111111111111111111111111111111111111111" + if run_wrapper "$(hook_stdin "$cmd")" "$install_dir/surveyor-forge-readonly.sh" >/dev/null 2>&1; then + st=0 + result=$(PATH="$TMP/forge-bin:$PATH" bash -c "$cmd" 2>"$TMP/classifier.err") || st=$? + if [ "$st" -eq 0 ] && [ "$result" = $'11\tfailure\thttps://example.test/fail\tCI\tpush\t\t2026-07-14T09:00:00Z\t10' ]; then + pass + else + fail "discovered guarded classifier must return the named red workflow (st=$st)" + fi + else + fail "the discovered literal remote-mode command must be admitted" + fi + st=0 + run_wrapper "$(hook_stdin "$quoted --input -")" "$install_dir/surveyor-forge-readonly.sh" >/dev/null 2>&1 || st=$? + if [ "$st" -eq 2 ]; then pass; else fail 'discovery must not admit offline input'; fi + done + for availability in nonexecutable missing; do + if [ "$availability" = nonexecutable ]; then + chmod -x "$install_dir/classify-default-branch-ci-runs.sh" + else + rm "$install_dir/classify-default-branch-ci-runs.sh" + fi + st=0 + out=$(run_wrapper "$(hook_stdin 'classify-default-branch-ci-runs.sh')" "$install_dir/surveyor-forge-readonly.sh" 2>/dev/null) || st=$? + if [ "$st" -eq 2 ] && ! printf '%s' "$out" | grep -q 'classifier-path-json:'; then + pass + else + fail "a $availability classifier must deny without a usable path hint" + fi + done +done + # --- agent scoping (opt-in): SURVEYOR_FORGE_READONLY_SCOPE --- # # A PreToolUse `matcher` filters on tool name only, so a Bash matcher fires for diff --git a/scripts/validate-manifests.sh b/scripts/validate-manifests.sh index ee2bfdf..81c43cf 100755 --- a/scripts/validate-manifests.sh +++ b/scripts/validate-manifests.sh @@ -468,7 +468,7 @@ validate_desired_state_resources() { # merged-PR channel silently loses the field, since the two rows carry the same token. local portfolio_survey_comment_disclosure_row="CANDIDATE-MAINTAINER-COMMENT # (draft?, merged?) — disclosure=" local portfolio_survey_call_shape_contract="**Every forge read is one command in one call.** The read-only guard refuses on shape before it ever inspects intent: output redirection, \`;\`, \`&\`, \`&&\`, a newline, command substitution, and any leading program that is neither a forge command nor a reviewed helper this definition names are all denied, so an ordinary shell idiom silently costs the read. Emit exactly one forge command per call and reduce it in-band with \`--paginate\` and \`--jq\`, or a pipe into the allowlisted read-only filters; never redirect to a scratch file. Sweep repositories with one call per repository or one org-wide search, never a \`for\` loop. Take every timestamp from a payload you already read, never from \`date\`. Select with \`--jq\` rather than \`grep -oE\` or \`xargs\`. A shape denial is a lost read that reads exactly like no evidence: mark the affected evidence \`QUERY-UNKNOWN\` and reissue in the admitted shape — never work around the guard." - local portfolio_survey_classifier_argv_contract="**Invoke the classifier only in its flag form, by its resolved installed path:** \`/scripts/classify-default-branch-ci-runs.sh --repo OWNER/REPO --branch BRANCH --head-sha FULL_SHA\`. The helper and the read-only guard accept nothing else: the guard admits only that exact installed sibling path — never a bare basename, a \`PATH\` lookup, or a relative \`../scripts/\` form — and a positional \`OWNER/REPO BRANCH SHA\` is denied as \`not the guarded remote-mode shape\` while the helper itself exits 2 on it, so the first invocation must already carry the resolved path and all three flags." + local portfolio_survey_classifier_argv_contract="**Invoke the classifier only in its flag form, by its resolved installed path:** \`/scripts/classify-default-branch-ci-runs.sh --repo OWNER/REPO --branch BRANCH --head-sha FULL_SHA\`. The helper and the read-only guard accept nothing else: the guard admits only that exact installed sibling path — never a bare basename, a \`PATH\` lookup, or a relative \`../scripts/\` form — and a positional \`OWNER/REPO BRANCH SHA\` is denied as \`not the guarded remote-mode shape\` while the helper itself exits 2 on it, so every executable invocation must carry the resolved path and all three flags." if [ -d plugins/agentic-engineering ]; then if [ ! -f "$canonical_resource" ]; then diff --git a/scripts/validate-manifests.test.sh b/scripts/validate-manifests.test.sh index f43ffd1..71b20d8 100755 --- a/scripts/validate-manifests.test.sh +++ b/scripts/validate-manifests.test.sh @@ -696,7 +696,7 @@ An incomplete candidate can never be classified clean: no `CLEAR`, `MERGE-READY` **Every forge read is one command in one call.** The read-only guard refuses on shape before it ever inspects intent: output redirection, `;`, `&`, `&&`, a newline, command substitution, and any leading program that is neither a forge command nor a reviewed helper this definition names are all denied, so an ordinary shell idiom silently costs the read. Emit exactly one forge command per call and reduce it in-band with `--paginate` and `--jq`, or a pipe into the allowlisted read-only filters; never redirect to a scratch file. Sweep repositories with one call per repository or one org-wide search, never a `for` loop. Take every timestamp from a payload you already read, never from `date`. Select with `--jq` rather than `grep -oE` or `xargs`. A shape denial is a lost read that reads exactly like no evidence: mark the affected evidence `QUERY-UNKNOWN` and reissue in the admitted shape — never work around the guard. -**Invoke the classifier only in its flag form, by its resolved installed path:** `/scripts/classify-default-branch-ci-runs.sh --repo OWNER/REPO --branch BRANCH --head-sha FULL_SHA`. The helper and the read-only guard accept nothing else: the guard admits only that exact installed sibling path — never a bare basename, a `PATH` lookup, or a relative `../scripts/` form — and a positional `OWNER/REPO BRANCH SHA` is denied as `not the guarded remote-mode shape` while the helper itself exits 2 on it, so the first invocation must already carry the resolved path and all three flags. +**Invoke the classifier only in its flag form, by its resolved installed path:** `/scripts/classify-default-branch-ci-runs.sh --repo OWNER/REPO --branch BRANCH --head-sha FULL_SHA`. The helper and the read-only guard accept nothing else: the guard admits only that exact installed sibling path — never a bare basename, a `PATH` lookup, or a relative `../scripts/` form — and a positional `OWNER/REPO BRANCH SHA` is denied as `not the guarded remote-mode shape` while the helper itself exits 2 on it, so every executable invocation must carry the resolved path and all three flags. EOF awk -v name="$name" ' index($0, "[`" name "`](plugins/" name "/)") { From 966c9422150d3df210053c9c9258b35c66c792d1 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 6 Sep 2026 18:40:09 +0200 Subject: [PATCH 2/2] docs(agentic-engineering): describe segment classification --- .../resources/provider-neutral.desired-state.json | 2 +- plugins/agentic-engineering/scripts/forge-readonly-guard.sh | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/agentic-engineering/resources/provider-neutral.desired-state.json b/plugins/agentic-engineering/resources/provider-neutral.desired-state.json index 44105c9..dc18972 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": "519b689c4a1bd7fcc5137c2c9d0abb828181ab2204d44932b525c03a6bf20702", + "sha256": "80bd20ba36537dc33ca21c003e4708731a0e0ab9f52f4ac320be322cb348ca6b", "executable": true }, { diff --git a/plugins/agentic-engineering/scripts/forge-readonly-guard.sh b/plugins/agentic-engineering/scripts/forge-readonly-guard.sh index c0354f7..d1950b5 100755 --- a/plugins/agentic-engineering/scripts/forge-readonly-guard.sh +++ b/plugins/agentic-engineering/scripts/forge-readonly-guard.sh @@ -1579,6 +1579,9 @@ classify_consumer_classifier() { return 0 } +# Classify one parsed pipeline segment. $1 is its command text and $2 its +# zero-based position. Return only for an admitted read; deny exits the guard. +# A classifier discovery hint supplies path data while keeping the probe denied. classify_segment() { local seg=$1 local index=$2