Pre-fill the alert permissions and test the plugin manifest parsing
Two of the three permissions treated as manual are in fact pre-fillable. The earlier check scraped the rendered docs page, whose table splits those rows in a way the parse missed; the docs source lists secret_scanning_alerts and vulnerability_alerts as supported query parameters. Only checks is genuinely absent, so the manual list shrinks to that one entry. That entry now names what breaks without it. Checks: Read governs the status rollup behind gh pr checks and the annotations behind gh run view, and both degrade to empty results rather than permission errors, so an unticked box reads as a broken CI integration rather than a missing scope. The marketplace and enabled-plugin extraction move out of the install function into host_marketplaces and host_enabled_plugins so they can be exercised directly. The tests cover the pipe separator that keeps an absent repo from shifting a url leftwards, rejection of marketplace sources that are neither github nor git, disabled plugins being excluded, and the allowlist refusing to carry credentials, transcripts or history.
This commit is contained in:
@@ -183,15 +183,18 @@ target_name CareEvolution
|
|||||||
expires_in 30
|
expires_in 30
|
||||||
metadata=read contents=write pull_requests=write issues=write
|
metadata=read contents=write pull_requests=write issues=write
|
||||||
workflows=write actions=write statuses=read security_events=write
|
workflows=write actions=write statuses=read security_events=write
|
||||||
|
secret_scanning_alerts=read vulnerability_alerts=read
|
||||||
```
|
```
|
||||||
|
|
||||||
Two things the form cannot pre-fill, so the task prints them as a checklist:
|
Two things the form cannot pre-fill, so the task prints them as a checklist:
|
||||||
|
|
||||||
1. **Repository access → Only select repositories → `api-portal`.** GitHub has no
|
1. **Repository access → Only select repositories → `api-portal`.** GitHub has no
|
||||||
query parameter for repository selection.
|
query parameter for repository selection.
|
||||||
2. **Checks: Read, Dependabot alerts: Read, Secret scanning alerts: Read.** These
|
2. **Checks: Read.** The only permission the agent needs that is absent from
|
||||||
three are absent from GitHub's pre-fill parameter list. Skip them if the agent
|
GitHub's pre-fill parameter list. Without it `gh pr checks` reports no status
|
||||||
does not need to read CI status or triage security alerts.
|
rollup and `gh run view` returns no annotations — both fail as empty results
|
||||||
|
rather than as permission errors, so a missed tick is easy to misread as a
|
||||||
|
broken CI integration.
|
||||||
|
|
||||||
Generate the token and paste it at the prompt. It is read with the terminal echo off
|
Generate the token and paste it at the prompt. It is read with the terminal echo off
|
||||||
and piped straight into the `sbx` secret store, so it never reaches your shell history.
|
and piped straight into the `sbx` secret store, so it never reaches your shell history.
|
||||||
|
|||||||
+31
-22
@@ -36,14 +36,17 @@ TOKEN_URL_PERMISSIONS=(
|
|||||||
actions=write
|
actions=write
|
||||||
statuses=read
|
statuses=read
|
||||||
security_events=write
|
security_events=write
|
||||||
|
secret_scanning_alerts=read
|
||||||
|
vulnerability_alerts=read
|
||||||
)
|
)
|
||||||
|
|
||||||
# GitHub omits these from the pre-fill parameters, so they can only be ticked
|
# "checks" is the one permission the agent needs that GitHub omits from the
|
||||||
# on the form itself.
|
# pre-fill parameters, so it has to be ticked by hand. Each entry names what
|
||||||
|
# breaks without it, because an unticked box fails later as an empty result or
|
||||||
|
# a 403 rather than as a permission error.
|
||||||
TOKEN_MANUAL_PERMISSIONS=(
|
TOKEN_MANUAL_PERMISSIONS=(
|
||||||
"Checks: Read"
|
"Checks: Read - without it 'gh pr checks' reports no status rollup and"
|
||||||
"Dependabot alerts: Read"
|
" 'gh run view' returns no annotations"
|
||||||
"Secret scanning alerts: Read"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
@@ -202,7 +205,8 @@ Create a fine-grained token for $REPOSITORY.
|
|||||||
Everything except the repository is pre-filled. On the page:
|
Everything except the repository is pre-filled. On the page:
|
||||||
|
|
||||||
1. Repository access -> Only select repositories -> ${REPOSITORY#*/}
|
1. Repository access -> Only select repositories -> ${REPOSITORY#*/}
|
||||||
2. Tick the permissions the form cannot pre-fill:
|
2. Under Permissions -> Repository permissions, tick the one the form
|
||||||
|
cannot pre-fill:
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
local permission
|
local permission
|
||||||
@@ -557,6 +561,25 @@ EOF
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Not @tsv: tab is an IFS whitespace character, so read collapses the empty
|
||||||
|
# field an entry without a repo produces and shifts the URL into it.
|
||||||
|
host_marketplaces() {
|
||||||
|
jq -r '
|
||||||
|
to_entries[]
|
||||||
|
| [
|
||||||
|
.key,
|
||||||
|
(.value.source.source // ""),
|
||||||
|
(.value.source.repo // ""),
|
||||||
|
(.value.source.url // "")
|
||||||
|
]
|
||||||
|
| join("|")
|
||||||
|
' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
host_enabled_plugins() {
|
||||||
|
jq -r '(.enabledPlugins // {}) | to_entries[] | select(.value) | .key' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
marketplace_url() {
|
marketplace_url() {
|
||||||
local source_kind="$1"
|
local source_kind="$1"
|
||||||
local repo="$2"
|
local repo="$2"
|
||||||
@@ -683,18 +706,7 @@ install_sandbox_claude_plugins() {
|
|||||||
</dev/null >/dev/null 2>&1 ||
|
</dev/null >/dev/null 2>&1 ||
|
||||||
printf 'Could not add marketplace %s (%s).\n' \
|
printf 'Could not add marketplace %s (%s).\n' \
|
||||||
"$name" "$marketplace" >&2
|
"$name" "$marketplace" >&2
|
||||||
done < <(
|
done < <(host_marketplaces "$known")
|
||||||
jq -r '
|
|
||||||
to_entries[]
|
|
||||||
| [
|
|
||||||
.key,
|
|
||||||
(.value.source.source // ""),
|
|
||||||
(.value.source.repo // ""),
|
|
||||||
(.value.source.url // "")
|
|
||||||
]
|
|
||||||
| join("|")
|
|
||||||
' "$known"
|
|
||||||
)
|
|
||||||
|
|
||||||
local plugin
|
local plugin
|
||||||
while read -r plugin; do
|
while read -r plugin; do
|
||||||
@@ -706,10 +718,7 @@ install_sandbox_claude_plugins() {
|
|||||||
else
|
else
|
||||||
printf 'Could not install plugin %s\n' "$plugin" >&2
|
printf 'Could not install plugin %s\n' "$plugin" >&2
|
||||||
fi
|
fi
|
||||||
done < <(
|
done < <(host_enabled_plugins "$settings")
|
||||||
jq -r '(.enabledPlugins // {}) | to_entries[] | select(.value) | .key' \
|
|
||||||
"$settings"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
install_sandbox_mise() {
|
install_sandbox_mise() {
|
||||||
|
|||||||
Executable
+110
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# shellcheck source-path=SCRIPTDIR
|
||||||
|
# shellcheck source=tasks/ai/sbx
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/../tasks/ai/sbx"
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'FAIL: %s\n' "$1" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_url() {
|
||||||
|
local kind="$1" repo="$2" url="$3" expected="$4" actual
|
||||||
|
|
||||||
|
if actual="$(marketplace_url "$kind" "$repo" "$url")"; then
|
||||||
|
[[ "$actual" == "$expected" ]] ||
|
||||||
|
fail "marketplace_url $kind '$repo' '$url' gave '$actual', expected '$expected'"
|
||||||
|
else
|
||||||
|
[[ "$expected" == "<fail>" ]] ||
|
||||||
|
fail "marketplace_url $kind '$repo' '$url' failed, expected '$expected'"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_url github obra/superpowers-marketplace "" \
|
||||||
|
"https://github.com/obra/superpowers-marketplace.git"
|
||||||
|
assert_url git "" https://git.example.com/x.git "https://git.example.com/x.git"
|
||||||
|
|
||||||
|
assert_url github "" "" "<fail>"
|
||||||
|
assert_url git "" "" "<fail>"
|
||||||
|
assert_url local /some/path "" "<fail>"
|
||||||
|
|
||||||
|
cat >"$work/known_marketplaces.json" <<'EOF'
|
||||||
|
{
|
||||||
|
"superpowers-marketplace": {
|
||||||
|
"source": { "source": "github", "repo": "obra/superpowers-marketplace" }
|
||||||
|
},
|
||||||
|
"mroberts": {
|
||||||
|
"source": { "source": "git", "url": "https://git.mroberts.dev/mroberts/claude-plugin.git" }
|
||||||
|
},
|
||||||
|
"bundled": {
|
||||||
|
"source": { "source": "local" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
mapfile -t lines < <(host_marketplaces "$work/known_marketplaces.json")
|
||||||
|
|
||||||
|
((${#lines[@]} == 3)) ||
|
||||||
|
fail "expected 3 marketplace lines, got ${#lines[@]}"
|
||||||
|
|
||||||
|
IFS='|' read -r name kind repo url <<<"${lines[1]}"
|
||||||
|
[[ "$name" == "mroberts" ]] || fail "name mis-parsed: $name"
|
||||||
|
[[ "$kind" == "git" ]] || fail "source kind mis-parsed: $kind"
|
||||||
|
[[ -z "$repo" ]] || fail "absent repo should be empty, got '$repo'"
|
||||||
|
[[ "$url" == "https://git.mroberts.dev/mroberts/claude-plugin.git" ]] ||
|
||||||
|
fail "url shifted into the wrong field: '$url'"
|
||||||
|
|
||||||
|
IFS='|' read -r name kind repo url <<<"${lines[2]}"
|
||||||
|
[[ "$kind" == "local" ]] || fail "unsupported kind mis-parsed: $kind"
|
||||||
|
marketplace_url "$kind" "$repo" "$url" >/dev/null 2>&1 &&
|
||||||
|
fail "a local marketplace should be rejected, not turned into a URL"
|
||||||
|
|
||||||
|
cat >"$work/settings.json" <<'EOF'
|
||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"caveman@caveman": true,
|
||||||
|
"ponytail@ponytail": true,
|
||||||
|
"disabled-thing@somewhere": false
|
||||||
|
},
|
||||||
|
"other": "ignored"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
mapfile -t plugins < <(host_enabled_plugins "$work/settings.json")
|
||||||
|
|
||||||
|
((${#plugins[@]} == 2)) ||
|
||||||
|
fail "expected 2 enabled plugins, got ${#plugins[@]}: ${plugins[*]}"
|
||||||
|
|
||||||
|
printf '%s\n' "${plugins[@]}" | grep -qx 'disabled-thing@somewhere' &&
|
||||||
|
fail "a disabled plugin was treated as enabled"
|
||||||
|
|
||||||
|
printf '%s\n' "${plugins[@]}" | grep -qx 'caveman@caveman' ||
|
||||||
|
fail "an enabled plugin is missing"
|
||||||
|
|
||||||
|
printf '{}\n' >"$work/empty.json"
|
||||||
|
mapfile -t none < <(host_enabled_plugins "$work/empty.json")
|
||||||
|
((${#none[@]} == 0)) || fail "empty settings produced ${#none[@]} plugins"
|
||||||
|
|
||||||
|
for forbidden in .credentials.json projects transcripts history.jsonl file-history cache backups; do
|
||||||
|
printf '%s\n' "${CLAUDE_CONFIG_ALLOW[@]}" | grep -qx "$forbidden" &&
|
||||||
|
fail "CLAUDE_CONFIG_ALLOW must not carry $forbidden"
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '%s\n' "${CLAUDE_CONFIG_ALLOW[@]}" | grep -qx skills &&
|
||||||
|
fail "skills must not be copied; it is seeded with 'sbx skills import'"
|
||||||
|
|
||||||
|
printf '%s\n' "${CLAUDE_CONFIG_ALLOW[@]}" | grep -qx CLAUDE.md ||
|
||||||
|
fail "CLAUDE_CONFIG_ALLOW should carry CLAUDE.md"
|
||||||
|
|
||||||
|
if ((failures)); then
|
||||||
|
printf '%d assertion(s) failed\n' "$failures" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'All Claude manifest assertions passed.\n'
|
||||||
+11
-3
@@ -59,13 +59,21 @@ done < <(printf '%s\n' "${TOKEN_URL_PERMISSIONS[@]}" | cut -d= -f2)
|
|||||||
printf '%s\n' "${TOKEN_URL_PERMISSIONS[@]}" | grep -qx 'workflows=write' ||
|
printf '%s\n' "${TOKEN_URL_PERMISSIONS[@]}" | grep -qx 'workflows=write' ||
|
||||||
fail "workflows must be requested at write"
|
fail "workflows must be requested at write"
|
||||||
|
|
||||||
for unsupported in checks= vulnerability_alerts= secret_scanning_alerts= repository=; do
|
for unsupported in checks= repository=; do
|
||||||
[[ "$query" != *"$unsupported"* ]] ||
|
[[ "$query" != *"$unsupported"* ]] ||
|
||||||
fail "URL sends a parameter the form ignores: $unsupported"
|
fail "URL sends a parameter the form ignores: $unsupported"
|
||||||
done
|
done
|
||||||
|
|
||||||
[[ ${#TOKEN_MANUAL_PERMISSIONS[@]} -eq 3 ]] ||
|
for expected in secret_scanning_alerts=read vulnerability_alerts=read statuses=read actions=write; do
|
||||||
fail "expected 3 manually-ticked permissions, found ${#TOKEN_MANUAL_PERMISSIONS[@]}"
|
[[ "$query" == *"&$expected"* ]] ||
|
||||||
|
fail "permission dropped out of the pre-filled URL: $expected"
|
||||||
|
done
|
||||||
|
|
||||||
|
((${#TOKEN_MANUAL_PERMISSIONS[@]})) ||
|
||||||
|
fail "the manual checklist is empty; checks is not pre-fillable and must be listed"
|
||||||
|
|
||||||
|
printf '%s\n' "${TOKEN_MANUAL_PERMISSIONS[@]}" | grep -q 'Checks' ||
|
||||||
|
fail "the manual checklist must name Checks"
|
||||||
|
|
||||||
if ((failures)); then
|
if ((failures)); then
|
||||||
printf '%d assertion(s) failed\n' "$failures" >&2
|
printf '%d assertion(s) failed\n' "$failures" >&2
|
||||||
|
|||||||
Reference in New Issue
Block a user