mirror of
https://github.com/getcargohq/cargo-skills.git
synced 2026-09-14 19:17:15 +08:00
1cb3ee755b
* Add Claude Code plugin channel and cargo-ai auto-approve hook - .claude-plugin/marketplace.json + plugin.json: the repo root doubles as a Claude Code plugin (skills scan root "./" discovers all 15 top-level skill dirs with no restructuring; plugin version mirrors the router's, now linter-enforced). - hooks/approve-cli.sh: allow-only PreToolUse hook that auto-approves cargo-ai / npx @cargo-ai/cli calls and read-only pipe helpers after a quote-aware structural pass. Never auto-approves: login/logout, workspaceManagement token/report, cdk deploy/destroy, or any remove/delete/destroy token. Verified with a 49-case harness plus a live end-to-end test (clean permissions: whoami auto-approved, token list fell through to prompt). - README: plugin install path + pick-one-channel note; prerequisites.md gains a "Permission prompts" section. cargo 1.9.0, plugin 1.9.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Pin the CLI version in cargo/cli-version - New cargo/cli-version (1.0.29): single-line semver pin, the source of truth for which @cargo-ai/cli these skills were written against. Lives inside the router skill so it ships with skills add and the ClawHub bundle; session hooks read it locally with a latest fallback, so the pin can never block an install. - Linter asserts the pin exists and is bare semver, and that openclaw install blocks stay @cargo-ai/cli@latest (bootstrap only; the session hook converges to the pin). - Docs: router session job 1 reordered (skills add -> pinned npm install) with a "why the pin" note; prerequisites.md and README refresh snippets use the pinned form. cargo 1.10.0, plugin 1.10.0. The producer side (CLI release pipeline PRs pin bumps here; install.sh consumes the raw pin URL) lands in getcargohq/cargo separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(approve-cli): position-independent gating closes token-shift bypass Review finding: gated checks only examined the first two non-flag tokens after the binary/package. Valid invocation prefixes shift the real subcommand out of those slots - `npx -p @cargo-ai/cli cargo-ai login` puts a literal cargo-ai before login (confirmed auto-approved), and a global flag with a value would shift the domain word the same way. Fix: normalize the cargo segment's token stream once, drop leading `cargo-ai` binary tokens, and gate position-independently - credential tokens (login/logout), destructive verbs (remove/delete/destroy), and gated adjacent pairs (workspaceManagement token/report, cdk deploy/destroy) anywhere in the segment refuse approval. Accepted cost, documented: a gated word as a literal inside a quoted argument (SQL containing "login") now falls through to the prompt - the safe direction for an allow-only hook. Harness extended to 62 cases: all -p/token-shift/flag-shift bypass shapes prompt; safe -p usage still auto-approves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
270 lines
12 KiB
Bash
Executable File
270 lines
12 KiB
Bash
Executable File
#!/bin/sh
|
|
# Approval hook for the Cargo plugin across Claude Code, Cursor, and Codex.
|
|
# Each agent prompts before running shell commands; this auto-approves a
|
|
# `cargo-ai` CLI call (or its `npx @cargo-ai/cli` form, optionally piped through
|
|
# a small set of read-only helpers on either side) so the plugin stops asking on
|
|
# every invocation. The verdict shape differs per agent, selected by the first
|
|
# argument: claude | cursor | codex.
|
|
#
|
|
# claude -> PreToolUse (input .tool_input.command)
|
|
# codex -> PermissionRequest (input .tool_input.command)
|
|
# cursor -> beforeShellExecution (input .command)
|
|
#
|
|
# "allow" only skips the prompt; deny/ask rules (including managed deny lists)
|
|
# still take precedence, so this can't punch through an admin block. Conservative
|
|
# by design: any command that chains, redirects, or substitutes another program
|
|
# or expands the environment (`;`, `&`, `&&`, `||`, `>`, `<`, backticks, `$(…)`,
|
|
# any `$` parameter expansion like `$VAR`/`${VAR}`, or a backslash escape `\`)
|
|
# falls through to the normal prompt rather than being approved.
|
|
|
|
agent="${1:-claude}"
|
|
|
|
# Pipeline helpers allowed to follow `cargo-ai`. Read-only by intent; this is
|
|
# the security-relevant surface, kept in one place for review. `echo`/`printf`
|
|
# only emit their literal arguments (they never open a file or socket), so they
|
|
# are exempt from the path/file-flag guards below -- see the awk pass.
|
|
allowed_helpers="jq cat head tail wc grep sort uniq column tr echo printf"
|
|
|
|
# `cargo-ai` invocations that never auto-approve, so credentials, egress of
|
|
# session data, destruction, and deploys always reach the user:
|
|
# - first words: credential/session commands
|
|
# - pairs: token minting, report egress (consent stays explicit -- reports can
|
|
# carry session traces), and CDK deploy/destroy
|
|
# - tokens: destructive verbs anywhere in the invocation (`storage model
|
|
# remove`, `hosting app remove`, ...)
|
|
gated_first="login logout"
|
|
gated_pairs="workspaceManagement token,workspaceManagement report,cdk deploy,cdk destroy"
|
|
gated_tokens="remove delete destroy"
|
|
|
|
# Harden: no globbing, and unset variables are errors so a typo can't silently
|
|
# widen approval.
|
|
set -fu
|
|
|
|
# Fail open to the normal prompt if we lack the tools we rely on to parse the
|
|
# event (jq) or to vet the command (awk). Without awk the structure/operand
|
|
# checks can't run, so we must not approve.
|
|
command -v jq > /dev/null 2>&1 || exit 0
|
|
command -v awk > /dev/null 2>&1 || exit 0
|
|
|
|
input="$(cat)"
|
|
|
|
case "$agent" in
|
|
cursor)
|
|
# Cursor's beforeShellExecution event is shell-specific; command is top-level.
|
|
cmd="$(printf '%s' "$input" | jq -r '.command // empty' 2> /dev/null)"
|
|
;;
|
|
*)
|
|
# Claude PreToolUse / Codex PermissionRequest both gate the Bash tool. One
|
|
# jq pass yields the command only for Bash; anything else comes back empty
|
|
# and falls through to the normal prompt below.
|
|
cmd="$(printf '%s' "$input" | jq -r 'if .tool_name == "Bash" then .tool_input.command // empty else empty end' 2> /dev/null)"
|
|
;;
|
|
esac
|
|
|
|
[ -n "$cmd" ] || exit 0
|
|
|
|
# Strip harmless redirections before the safety checks so they don't block
|
|
# otherwise-valid cargo-ai pipelines. Only two shapes are removed: redirects
|
|
# whose target is /dev/null (anchored to a word boundary so we never eat a
|
|
# prefix of a real path like /dev/nullX), and fd-to-fd duplications (2>&1,
|
|
# 1>&2). Any redirection to a real file is intentionally left intact so it
|
|
# still falls through to the prompt.
|
|
cmd_stripped="$(printf '%s' "$cmd" | sed -E '
|
|
s#([0-9]*|&)>>?[[:space:]]*/dev/null([[:space:]]|$)#\2#g
|
|
s/[0-9]*>&[0-9]+//g
|
|
')"
|
|
|
|
# Reject command chaining / redirection / substitution outright. Runs on the raw
|
|
# (quoted) string so even a quoted `;`/`>`/etc. is conservatively refused. The
|
|
# backslash is rejected too: the segment splitter below is not backslash-aware,
|
|
# so a `\"`/`\|` would let awk and the shell disagree on where segments start
|
|
# and end (a total allowlist bypass). Refusing any `\` closes that desync.
|
|
case "$cmd_stripped" in
|
|
*';'* | *'&'* | *'<'* | *'>'* | *'`'* | *'$'* | *'\'*) exit 0 ;;
|
|
esac
|
|
|
|
# Reject multi-line commands (heredocs, embedded scripts).
|
|
[ "$(printf '%s' "$cmd_stripped" | wc -l | tr -d ' ')" = "0" ] || exit 0
|
|
|
|
# Bound the input so the char-by-char awk pass below can't be forced to scan an
|
|
# unbounded string.
|
|
[ "${#cmd_stripped}" -le 10000 ] || exit 0
|
|
|
|
# Validate the pipeline in a single quote-aware pass. awk splits on unquoted
|
|
# pipes (so a `|` inside jq/grep args or a quoted cargo-ai arg is not a
|
|
# separator; the `\`-reject guard above keeps this splitter in sync with the
|
|
# shell), trims surrounding whitespace per segment, then:
|
|
# - any segment that leads with a `VAR=value` env-var assignment is rejected
|
|
# outright -- we never vet the variable, so a prefix like `LD_PRELOAD=` or
|
|
# `CARGO_API_TOKEN=` must not ride in as a plain call;
|
|
# - at least one segment must be a cargo CLI call -- `cargo-ai …` or
|
|
# `npx [-y|--yes] @cargo-ai/cli[@<version>] …` (its own path/URL args are
|
|
# left alone), so the pipeline stays anchored to a Cargo call wherever it
|
|
# sits; an `npx` of any other package is refused;
|
|
# - the cargo segment is gated POSITION-INDEPENDENTLY: a credential command
|
|
# token (login, logout), a destructive verb token (remove, delete,
|
|
# destroy), or a gated adjacent pair (workspaceManagement token,
|
|
# workspaceManagement report, cdk deploy, cdk destroy) ANYWHERE in the
|
|
# segment refuses it. Positional checks alone are bypassable -- valid
|
|
# prefixes like `npx -p @cargo-ai/cli cargo-ai login` or a global flag
|
|
# with a value shift the real subcommand out of the first argument slots,
|
|
# so leading `cargo-ai` binary tokens are dropped and every token is
|
|
# scanned. Ordinary read/write subcommands (whoami, storage,
|
|
# orchestration, billing, ...) still auto-approve; the accepted cost is
|
|
# that a gated word appearing as a literal inside a quoted argument (a SQL
|
|
# string containing `login`) also falls through to the prompt;
|
|
# - every other segment's command must be in the allowlist (membership is an
|
|
# exact key lookup, so a token like `*` can't wildcard its way in); and
|
|
# - every other segment must not reference a path (`/`, `~`) or a read/write
|
|
# file flag (long `--output`/`--file` or short clusters containing `o`/`f`,
|
|
# attached value or not) -- helpers must transform stdin, not open files.
|
|
# These checks apply to helpers on both sides of the cargo call, so neither
|
|
# `cat /etc/passwd | cargo-ai …` nor `cargo-ai … | cat /etc/passwd` slips
|
|
# by, and `cargo-ai … | sort -oPWNED.txt` can't write a cwd file; quoted
|
|
# text is scanned too. `echo` and `printf` are exempt from these path/flag
|
|
# guards: they only print literal arguments and can't open a file, so a `/`
|
|
# or `~` in their args is data (JSON, a URL) being fed to cargo-ai's stdin,
|
|
# not a file read. The `$`-reject guard above is what makes those args
|
|
# literal -- otherwise `printf "$SECRET"` would expand an env var into the
|
|
# CLI's stdin under this exemption.
|
|
# Residual, knowingly accepted: bare cwd-relative names (e.g. `cat .env`) aren't
|
|
# caught; since no allowlisted helper can reach the network or redirect, such a
|
|
# read stays in the agent's context and still can't be exfiltrated without a
|
|
# separate, non-approved (prompted) command.
|
|
verdict="$(printf '%s' "$cmd_stripped" | awk -v helpers="$allowed_helpers" -v gated_first="$gated_first" -v gated_pairs="$gated_pairs" -v gated_tokens="$gated_tokens" '
|
|
BEGIN {
|
|
n = split(helpers, a, " ")
|
|
for (i = 1; i <= n; i++) H[a[i]] = 1
|
|
nd = split(gated_first, d, " ")
|
|
for (i = 1; i <= nd; i++) D[d[i]] = 1
|
|
np = split(gated_pairs, p, ",")
|
|
for (i = 1; i <= np; i++) P[p[i]] = 1
|
|
ng = split(gated_tokens, g, " ")
|
|
for (i = 1; i <= ng; i++) G[g[i]] = 1
|
|
sq = sprintf("%c", 39)
|
|
}
|
|
{
|
|
inq = ""; nseg = 0; cur = ""
|
|
for (i = 1; i <= length($0); i++) {
|
|
c = substr($0, i, 1)
|
|
if (inq != "") { cur = cur c; if (c == inq) inq = ""; continue }
|
|
if (c == sq || c == "\"") { inq = c; cur = cur c; continue }
|
|
if (c == "|") { seg[nseg++] = cur; cur = ""; continue }
|
|
cur = cur c
|
|
}
|
|
seg[nseg++] = cur
|
|
|
|
saw_cargo = 0
|
|
for (s = 0; s < nseg; s++) {
|
|
t = seg[s]
|
|
sub(/^[ \t]+/, "", t)
|
|
sub(/[ \t]+$/, "", t)
|
|
if (t ~ /^[A-Za-z_][A-Za-z0-9_]*=/) exit
|
|
tok = t
|
|
sub(/[ \t].*$/, "", tok)
|
|
|
|
# Resolve whether this segment is a cargo CLI call, and if so capture the
|
|
# argument tail (everything after the binary/package token) in `rest`.
|
|
is_cargo = 0
|
|
rest = ""
|
|
if (tok == "cargo-ai") {
|
|
is_cargo = 1
|
|
rest = t
|
|
sub(/^cargo-ai([ \t]+|$)/, "", rest)
|
|
} else if (tok == "npx") {
|
|
# `npx [-y|--yes|other flags] @cargo-ai/cli[@<version>] …` -- resolve
|
|
# the first non-flag word as the package and require it to be the
|
|
# Cargo CLI; an npx of anything else must not auto-approve.
|
|
body = t
|
|
sub(/^npx([ \t]+|$)/, "", body)
|
|
m = split(body, w, /[ \t]+/)
|
|
pkg = ""; pidx = 0
|
|
for (j = 1; j <= m; j++) {
|
|
if (w[j] == "") continue
|
|
if (substr(w[j], 1, 1) == "-") continue
|
|
pkg = w[j]; pidx = j; break
|
|
}
|
|
gsub(/"/, "", pkg); gsub(sq, "", pkg)
|
|
if (pkg ~ /[][*?]/) exit
|
|
if (pkg == "@cargo-ai/cli" || index(pkg, "@cargo-ai/cli@") == 1) {
|
|
is_cargo = 1
|
|
rest = ""
|
|
for (j = pidx + 1; j <= m; j++) rest = rest w[j] " "
|
|
} else {
|
|
exit
|
|
}
|
|
}
|
|
|
|
if (is_cargo) {
|
|
saw_cargo = 1
|
|
# Gate credential/egress/destructive invocations. Normalize the token
|
|
# stream once (strip quotes, drop empties), then check gated tokens
|
|
# POSITION-INDEPENDENTLY: real subcommands can be shifted out of the
|
|
# first argument positions by valid invocation prefixes — e.g.
|
|
# `npx -p @cargo-ai/cli cargo-ai login` puts a literal `cargo-ai`
|
|
# before `login`, and a global flag with a value would shift the
|
|
# domain word too — so positional-only checks are bypassable. Refusing
|
|
# a gated token anywhere in the segment over-gates rare literals in
|
|
# quoted args (a SQL string containing the word `login` falls through
|
|
# to the prompt); that is the safe direction for an allow-only hook.
|
|
m2 = split(rest, v, /[ \t]+/)
|
|
n2 = 0
|
|
for (j = 1; j <= m2; j++) {
|
|
tj = v[j]
|
|
gsub(/"/, "", tj); gsub(sq, "", tj)
|
|
if (tj == "") continue
|
|
n2++; ct[n2] = tj
|
|
}
|
|
# Drop leading `cargo-ai` binary tokens (`npx -p @cargo-ai/cli
|
|
# cargo-ai …` runs the same CLI; the extra token must not consume a
|
|
# checked position).
|
|
s2 = 1
|
|
while (s2 <= n2 && ct[s2] == "cargo-ai") s2++
|
|
# First two non-flag words: refuse globbable command tokens.
|
|
w1 = ""; w2 = ""
|
|
for (j = s2; j <= n2; j++) {
|
|
if (substr(ct[j], 1, 1) == "-") continue
|
|
if (w1 == "") { w1 = ct[j]; continue }
|
|
w2 = ct[j]
|
|
break
|
|
}
|
|
if (w1 ~ /[][*?]/ || w2 ~ /[][*?]/) exit
|
|
# Gates, anywhere in the segment: credential first-words (login,
|
|
# logout), destructive verbs (remove, delete, destroy), and gated
|
|
# pairs as adjacent tokens (workspaceManagement token/report,
|
|
# cdk deploy/destroy).
|
|
for (j = s2; j <= n2; j++) {
|
|
if (ct[j] in D) exit
|
|
if (ct[j] in G) exit
|
|
if (j < n2 && ((ct[j] " " ct[j + 1]) in P)) exit
|
|
}
|
|
} else {
|
|
if (!(tok in H)) exit
|
|
if (tok != "echo" && tok != "printf") {
|
|
if (index(t, "/") > 0) exit
|
|
if (index(t, "~") > 0) exit
|
|
if (t ~ /(^|[ \t])--(output|file)([ \t]|=|$)/) exit
|
|
if (t ~ /(^|[ \t])-[A-Za-z]*[of]/) exit
|
|
}
|
|
}
|
|
}
|
|
if (saw_cargo) print "allow"
|
|
}
|
|
')"
|
|
|
|
[ "$verdict" = "allow" ] || exit 0
|
|
|
|
case "$agent" in
|
|
cursor)
|
|
printf '%s\n' '{"continue":true,"permission":"allow"}'
|
|
;;
|
|
codex)
|
|
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
|
|
;;
|
|
claude | *)
|
|
# claude (PreToolUse) is the default; an unknown agent also lands here, which
|
|
# is safe because we only ever emit an allow after passing the checks above.
|
|
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"cargo-ai CLI is allowlisted by the Cargo plugin"}}'
|
|
;;
|
|
esac
|