#!/bin/bash # sf-deploy-gate — Pre-deploy/destructive-action safety gate for the salesforce-development plugin. # # Commands: # prod-check - Block deploys/quicks targeting a Production org without explicit confirmation # (PreToolUse hook on `sf project deploy start|quick|cancel`) # destructive - Warn before destructive operations (PreToolUse on `sf project delete`) # auto-deploy - Opt-in (SFDX_AUTO_DEPLOY=1) deploy of a just-saved source file to a # NON-production org (PostToolUse on Edit/Write under force-app/**). # No-ops silently on prod/unknown orgs or when the feature is off. # classify - Read `sf org display --json` output on stdin and print the org bucket # (production|sandbox|scratch|trial|devhub|unknown). Pure function, no # network — used by the gate and by the offline classification test. set -uo pipefail # --- Org classification ------------------------------------------------------ # Pure classifier: reads `sf org display --json` shape on stdin, prints one bucket. # Kept as a standalone python block so it can be exercised offline with fixtures # (see test/classify.test.sh) without a live org. # # Buckets: production | sandbox | scratch | trial | devhub | unknown # Only `production` is gated; everything else is a non-prod target. classify_org_json() { python3 -c " import json, sys try: data = json.load(sys.stdin).get('result', {}) except Exception: print('unknown'); sys.exit(0) # No usable org info (empty result, or no host and no flags) → unknown. # The gate only blocks on 'production', so unknown fails open (allow), matching # the pre-#259 behavior of never blocking when org info can't be resolved. if not data or ( not (data.get('instanceUrl') or '') and not data.get('isSandbox') and not data.get('isScratch') and not data.get('isDevHub') ): print('unknown'); sys.exit(0) is_sandbox = bool(data.get('isSandbox')) is_scratch = bool(data.get('isScratch')) is_devhub = bool(data.get('isDevHub')) instance = (data.get('instanceUrl') or '').lower() # Trials carry an expiration date in sf org display output. has_trial_exp = bool(data.get('trialExpirationDate')) # Sandbox host markers: My Domain sandboxes embed '--'; classic uses test.salesforce.com. is_sandbox_host = 'test.salesforce.com' in instance or '--' in instance # Trial / dev-edition host markers. These are NOT production even when # isSandbox/isScratch come back null (so the booleans above are False) AND the # host has no '--' sandbox marker — the exact gap that mis-classified OrgFarm # trials as production (see issue #259). TRIAL_HOST_MARKERS = ( 'orgfarm-', # OrgFarm-provisioned trials/dev orgs '.pc-rnd.', # internal pc-rnd dev hosts '.develop.my.salesforce.com', # Developer Edition / trial My Domain hosts '.scratch.my.salesforce.com', # scratch My Domain hosts (belt-and-suspenders) ) is_trial_host = has_trial_exp or any(m in instance for m in TRIAL_HOST_MARKERS) # Order matters: explicit scratch/sandbox signals win, then trial hosts, then # DevHub (treated non-prod, preserving prior behavior), else production. if is_scratch: print('scratch') elif is_sandbox or is_sandbox_host: print('sandbox') elif is_trial_host: print('trial') elif is_devhub: print('devhub') else: print('production') " } # Detect a destructive-changes deploy. A `sf project deploy` carrying a # destructiveChanges manifest (via --manifest or the dedicated # --pre/--post-destructive-changes flags) has the same blast radius as # `sf project delete`, but reaches the gate as an ordinary deploy (#407). # Echoes "true" when the command is a destructive deploy, "false" otherwise. is_destructive_deploy() { python3 -c " import json, sys try: cmd = json.loads(sys.argv[1]).get('tool_input', {}).get('command', '') except Exception: cmd = '' # The dedicated flags are unambiguous; a bare 'destructivechanges' substring # also catches '--manifest destructiveChanges.xml' and pre/post manifest paths. markers = ('--pre-destructive-changes', '--post-destructive-changes', 'destructivechanges') print('true' if any(m in cmd.lower() for m in markers) else 'false') " "$INPUT" 2>/dev/null } # Echoes "true" when the executed command ACTUALLY INVOKES the given sf subcommand # spec (e.g. "sf project deploy start|quick" — the last word may be an a|b # alternation), "false" otherwise. Used to SELF-GATE the deploy/delete gates: some # Claude Code builds ignore the plugin.json `if:` matcher and fire every PreToolUse # Bash hook on every command, so without this a plain `cd`/`ls` would trigger org # classification (two `sf` calls) and print a spurious "Deploy gate ... allowing" line. # # Matching is quote- and command-position-aware, NOT a raw regex/substring over the # whole command line (#1030 review): a plain search matched a merely QUOTED mention # like `grep -r "sf project deploy start" .`, wrongly classifying an org and even # denying the grep. Instead the line is tokenized with shlex (honoring quotes) and # split on shell operators (& | ; < > ( )); the spec must match the FIRST executable # token of a segment (after skipping leading VAR=val assignments), with argv[0] # normalized to its basename minus a .cmd/.exe/.bat suffix. This still gates the # real invocations the old regex caught — `sf project deploy start` (arbitrary # spacing, mirroring sf_context.py's `\bsf\s+project\s+...`) and # `CONFIRM_PROD=1 sf project deploy start` — while ignoring quoted/argument mentions. # On an unparseable line (unbalanced quotes) it fails safe toward matching via a # collapsed-substring check so a genuine deploy can't slip the gate. # # Two under-match bypasses the first shlex pass still allowed were closed in a # #1030-review follow-up: (a) shlex's default commenters='#' truncated the line at # an unquoted '#', dropping a trailing '&& sf project deploy start' — commenters is # now cleared; (b) a heredoc-delivered deploy ('bash <f sf ...\`. # All are also outside the plugin.json \`if:\` prefix matcher and have no ergonomic # reason to appear. Chasing arbitrary shell obfuscation with string matching is # unwinnable; the real prod-deploy control is the skill-first workflow + org # classification, not this self-gate. _WRAPPERS = {'command', 'exec', 'builtin', 'time', 'env', 'nohup', 'setsid', 'sudo', 'nice'} _CONTROL = {'if', 'elif', 'while', 'until', 'then', 'else', 'do'} # condition (if/elif/while/until) + body (then/else/do); a deploy used as a control condition still executes def _seg_matches(tokens): i = 0 while i < len(tokens): t = tokens[i] if re.match('^[A-Za-z_][A-Za-z0-9_]*=', t): i += 1 elif _basename(t) in _WRAPPERS or _basename(t) in _CONTROL: i += 1 else: break exe = tokens[i:] if len(exe) < len(spec): return False exe = [_basename(exe[0])] + exe[1:] for tok, want in zip(exe, spec): if isinstance(want, set): if tok not in want: return False elif tok != want: return False return True # Scan ONE text blob for the spec in first-command position. Tokenize with shlex # (quote-aware), then split on shell operators and require the spec at the first # executable token of a segment. Two shlex-vs-bash LEXING gaps are normalized first # so a plain command-position deploy can't diverge from what the shell actually runs # (#1030 review + adversarial verification): # - commenters is cleared, so a bare '#' is NOT read as a comment that truncates the # line (else 'curl http://x/a#b && sf project deploy start' would lose the deploy); # - the leading '\$' of bash ANSI-C (dollar-single-quote) and locale (dollar-double- # quote) quoting is stripped: bash removes it when building argv (so \$'start' runs # as start), while shlex keeps the token as '\$start' and would miss the match. # chr() spells the '\$' and quote characters to avoid this shell heredoc's own quoting. # This mirrors bash command-line LEXING (quote removal + the caller's line-continuation # join) only — NOT expansion (\$var, \${x:-start}) or execution (eval / sh -c / \`...\`), # which are the documented KNOWN RESIDUAL above. def _scan(text): text = text.replace(chr(36) + chr(39), chr(39)).replace(chr(36) + chr(34), chr(34)) try: lex = shlex.shlex(text, posix=True, punctuation_chars=True) lex.whitespace_split = True lex.commenters = '' tokens = list(lex) except ValueError: # Unbalanced quotes etc. → fail safe toward matching (collapsed substring). flat = ' '.join(text.lower().split()) words = sys.argv[2].split() head = ' '.join(words[:-1]) return any((head + ' ' + alt).strip() in flat for alt in words[-1].split('|')) punct = set('();<>|&') seg = [] for t in tokens: if not t.strip(): continue # defensive: never let a whitespace-only token join a segment if all(c in punct for c in t): if seg and _seg_matches(seg): return True seg = [] else: seg.append(t) return bool(seg and _seg_matches(seg)) # Strip bash line continuations (backslash-newline) FIRST: bash deletes the # backslash+newline pair when reading the line, joining tokens even MID-WORD # ('del\ete' -> 'delete', 'deploy \start' -> 'deploy start'), so we must too # or the spec words split apart and the deploy slips the gate. Then scan the joined # command AND each physical line: shlex(whitespace_split) flattens newlines, so a # heredoc body ('bash </dev/null } # True (exit 0) if ANY argument contains a cmd.exe metacharacter. Mirrors the # Python resolver's _CMD_ARG_METACHARACTERS set so the two guards don't diverge: # & | < > ^ % " ! ( ) and CR/LF. Checked per-argument (not the joined "$*"). _has_cmd_metachars() { local a for a in "$@"; do case "$a" in *'&'*|*'|'*|*'<'*|*'>'*|*'^'*|*'%'*|*'"'*|*'!'*|*'('*|*')'*|*$'\n'*|*$'\r'*) return 0 ;; esac done return 1 } # Cross-platform `sf` invocation (WIN-026). This gate shells out to `sf` from # bash; on Windows `sf` is a `sf.cmd` batch shim that Git Bash won't resolve as a # bare `sf` and that cmd.exe must actually run. Mirror the Python resolver # (scripts/sf_context.py resolve_executable/build_command): find the real path # (trying the .cmd/.exe variants), and for a .cmd/.bat shim run it through COMSPEC. # Passing separate argv words preserves argv boundaries, but that is NOT enough # for injection safety on a batch shim — cmd.exe re-parses its command line — so # we REFUSE metacharacter args (fail closed) via _has_cmd_metachars rather than # attempt cmd quoting. On POSIX this resolves to a plain `sf` on PATH and execs it # directly, leaving macOS/Linux behavior (and the stubbed offline tests) # unchanged. Kept bash 3.2-compatible (macOS default) — no lowercase expansion. sf_cli() { local bin="" if bin=$(command -v sf 2>/dev/null); then : elif bin=$(command -v sf.cmd 2>/dev/null); then : elif bin=$(command -v sf.exe 2>/dev/null); then : else return 127 fi case "$bin" in *.cmd|*.CMD|*.bat|*.BAT) if _has_cmd_metachars "$@"; then echo "sf-deploy-gate: refusing to run batch shim with shell metacharacters in args" >&2 return 1 fi MSYS_NO_PATHCONV=1 "${COMSPEC:-cmd.exe}" /c "$bin" "$@" ;; *) "$bin" "$@" ;; esac } # Resolve target-org from CLI command args first, fall back to config. extract_target_org_from_command() { python3 -c " import json, re, sys try: data = json.loads(sys.argv[1]) cmd = data.get('tool_input', {}).get('command', '') m = re.search(r'--target-org[= ]([\\S]+)', cmd) or re.search(r'-o[= ]([\\S]+)', cmd) print(m.group(1) if m else '') except Exception: pass " "$INPUT" 2>/dev/null } # Extract the edited/written file path from a PostToolUse Edit/Write/MultiEdit # payload (tool_input.file_path). Falls back to the CLAUDE_TOOL_INPUT_FILE_PATH # env var (set by some Claude Code versions) when stdin carries no path. get_edited_file() { python3 -c " import json, sys try: ti = json.loads(sys.argv[1]).get('tool_input', {}) print(ti.get('file_path') or ti.get('filePath') or '') except Exception: pass " "$INPUT" 2>/dev/null } get_default_org() { sf_cli config get target-org --json 2>/dev/null | python3 -c " import json, sys try: data = json.load(sys.stdin) for r in data.get('result', []): if r.get('name') == 'target-org': print(r.get('value', '')) break except Exception: pass " 2>/dev/null } # Print the bucket for a given org alias by piping live org info through the classifier. get_org_bucket() { local org_alias="$1" sf_cli org display --target-org "$org_alias" --json 2>/dev/null | classify_org_json } # All emissions carry this source tag so a denial is never misattributed to # Claude Code's auto-mode classifier (the two gates have overlapping symptoms — # see the guard-rail note in README.md). If you see this prefix, it was THIS # plugin's deploy gate, which fires only on `sf project deploy|delete`. GATE_TAG="[salesforce-development · deploy-gate]" deny() { local reason="$1" python3 -c " import json, sys print(json.dumps({ 'hookSpecificOutput': { 'hookEventName': 'PreToolUse', 'permissionDecision': 'deny', 'permissionDecisionReason': sys.argv[1] + ' ' + sys.argv[2] } })) " "$GATE_TAG" "$reason" exit 0 } # Allow the action, surfacing the classification decision so the org bucket is # visible to the developer (issue #259 acceptance: "state the classification aloud"). allow() { local note="${1:-}" if [ -n "$note" ]; then python3 -c " import json, sys print(json.dumps({'continue': True, 'systemMessage': sys.argv[1] + ' ' + sys.argv[2]})) " "$GATE_TAG" "$note" else echo '{"continue": true}' fi exit 0 } # Only dispatch when executed directly. When sourced (e.g. by the offline guard # test to unit-test _has_cmd_metachars), define the functions but don't read # stdin or run a command. if [ "${BASH_SOURCE[0]}" != "${0}" ]; then return 0 2>/dev/null || true fi COMMAND="${1:-prod-check}" # Preflight: org classification and JSON I/O below all shell out to python3. # If it's missing, fail open rather than let every "python3 -c" call below # break silently — this matches the existing "unresolvable org info -> unknown # -> allow" behavior (see classify_org_json), just triggered by a missing # interpreter instead of missing org data. if ! command -v python3 &> /dev/null; then echo "sf-deploy-gate: python3 not found on PATH — skipping org classification, failing open (see README Requirements)" >&2 if [ "$COMMAND" = "classify" ]; then echo "unknown" else echo '{"continue": true, "systemMessage": "'"$GATE_TAG"' python3 not found — org classification skipped, allowing."}' fi exit 0 fi # Read PreToolUse input from stdin (Claude Code passes the tool args in JSON). # `classify` reads org JSON on stdin instead, so only slurp here for the hook commands. if [ "$COMMAND" != "classify" ]; then INPUT=$(cat 2>/dev/null || echo '{}') fi case "$COMMAND" in classify) # Pure classifier path: org JSON on stdin → bucket on stdout. No network. classify_org_json ;; prod-check) # Self-gate: only classify/gate an actual prod-MUTATING deploy — start/quick, # matching the plugin.json `if:` scope. A check-only `sf project deploy validate` # is safe against prod and must NOT be denied here. Whitespace-flexible so # `sf project deploy start` can't slip the gate (see command_matches). [ "$(command_matches 'sf project deploy start|quick')" = "true" ] || allow TARGET_ORG=$(extract_target_org_from_command) if [ -z "$TARGET_ORG" ]; then TARGET_ORG=$(get_default_org) fi if [ -z "$TARGET_ORG" ]; then deny "No target org configured. Run 'sf config set target-org ' before deploying." fi BUCKET=$(get_org_bucket "$TARGET_ORG") IS_DESTRUCTIVE=$(is_destructive_deploy) # A destructive-changes deploy to Production is gated exactly like # `sf project delete` — no CONFIRM_PROD override, route to the skill that # validates first and requires confirmation (#407). if [ "$BUCKET" = "production" ] && [ "$IS_DESTRUCTIVE" = "true" ]; then deny "BLOCKED: destructive deploy (destructiveChanges manifest) against Production org '${TARGET_ORG}'. This DELETES metadata and has the same blast radius as 'sf project delete'. Use 'platform-destructive-deploy' skill which validates first and requires explicit confirmation." fi # Detect explicit-confirmation flags the user may have set in the command itself. HAS_CONFIRMATION=$(python3 -c " import json, sys try: data = json.loads(sys.argv[1]) cmd = data.get('tool_input', {}).get('command', '') print('true' if any(t in cmd for t in ['--I-CONFIRM-PROD', 'CONFIRM_PROD=1']) else 'false') except Exception: print('false') " "$INPUT" 2>/dev/null) if [ "$BUCKET" = "production" ] && [ "$HAS_CONFIRMATION" != "true" ]; then deny "BLOCKED: target org '${TARGET_ORG}' appears to be a Production org. Production deploys must be explicitly confirmed by the developer. Recommended workflow: 1. Use 'platform-deploy-validate' skill to validate (returns a 10-day job ID) 2. Use 'platform-quick-deploy' skill which gates with explicit user confirmation Or, to override this hook, prepend 'CONFIRM_PROD=1' to your command (NOT recommended)." fi # Non-prod destructive deploys are advised, not blocked (parity with the # `destructive` subcommand's non-prod path). if [ "$IS_DESTRUCTIVE" = "true" ]; then allow "Deploy gate: target org '${TARGET_ORG}' classified as ${BUCKET} — non-production. ⚠️ This is a DESTRUCTIVE deploy (destructiveChanges manifest) — it deletes metadata. The 'platform-destructive-deploy' skill validates the deletion set first; allowing on a non-prod org." fi allow "Deploy gate: target org '${TARGET_ORG}' classified as ${BUCKET} — non-production, allowing deploy." ;; destructive) # Self-gate: only gate an actual delete (any `sf project delete` form, matching # the plugin.json `if:` scope). Whitespace-flexible (see command_matches). [ "$(command_matches 'sf project delete')" = "true" ] || allow TARGET_ORG=$(extract_target_org_from_command) [ -z "$TARGET_ORG" ] && TARGET_ORG=$(get_default_org) if [ -z "$TARGET_ORG" ]; then deny "No target org configured." fi BUCKET=$(get_org_bucket "$TARGET_ORG") if [ "$BUCKET" = "production" ]; then deny "BLOCKED: destructive operation against Production org '${TARGET_ORG}'. Use 'platform-destructive-deploy' skill which validates first and requires confirmation." fi allow "Deploy gate: target org '${TARGET_ORG}' classified as ${BUCKET} — non-production, allowing destructive op." ;; auto-deploy) # Opt-in convenience: after a source file under force-app/ is saved, deploy # just that file to a NON-production org. Off unless SFDX_AUTO_DEPLOY=1. # Fails safe — never deploys to production or to an org we can't classify. [ "${SFDX_AUTO_DEPLOY:-}" != "1" ] && exit 0 FILE=$(get_edited_file) [ -z "$FILE" ] && exit 0 ORG="${SFDX_AUTO_DEPLOY_ORG:-}" [ -z "$ORG" ] && ORG=$(get_default_org) [ -z "$ORG" ] && exit 0 BUCKET=$(get_org_bucket "$ORG") # Never auto-deploy to production; fail safe on unknown/unresolved orgs. if [ "$BUCKET" = "production" ] || [ "$BUCKET" = "unknown" ]; then exit 0 fi RESULT=$(sf_cli project deploy start --source-dir "$FILE" --target-org "$ORG" --wait 30 --json 2>&1) STATUS=$? if [ "$STATUS" -eq 0 ]; then allow "Auto-deploy: '${FILE}' → ${ORG} (${BUCKET}) succeeded." else allow "Auto-deploy: '${FILE}' → ${ORG} (${BUCKET}) FAILED (exit ${STATUS}). Fix and redeploy manually; auto-deploy will not retry." fi ;; *) echo "Unknown command: $COMMAND" >&2 echo "Usage: sf-deploy-gate [prod-check|destructive|auto-deploy|classify]" >&2 exit 1 ;; esac