Skip to content

Gate hooks to Salesforce projects only - #13

Open
tobinsouth wants to merge 2 commits into
SalesforceAIResearch:mainfrom
tobinsouth:gate-hooks-to-sf-projects
Open

Gate hooks to Salesforce projects only#13
tobinsouth wants to merge 2 commits into
SalesforceAIResearch:mainfrom
tobinsouth:gate-hooks-to-sf-projects

Conversation

@tobinsouth

Copy link
Copy Markdown

Summary

Adds a project-relevance gate to both lifecycle hooks so they exit immediately when the working directory is not a Salesforce project.

Why

When this plugin is installed globally (which is the typical install mode for Claude Code plugins), the PreToolUse:Bash hook (guardrails.py) currently runs on every Bash command in every session — including projects with no Salesforce relevance. The existing is_sf_context(command) check is a text-regex gate on the command string, applied after the hook has already read the tool input.

This means the plugin observes Bash tool I/O in unrelated Node/Python/Rust/etc. projects. The hook is local-only (no network, no logging), so there's no data exfiltration — but it's still broader than the plugin's stated scope, and the install description doesn't disclose it.

The same applies to agent-validator.py (PostToolUse:Write|Edit), though that one is already effectively gated by the .agent file-extension check.

Change

Both hook scripts now call is_salesforce_project() at the top of main(), which walks up from cwd looking for any of:

  • sfdx-project.json
  • force-app/
  • aiAuthoringBundles/

If none are found, the hook returns allow immediately without reading stdin. These are the same markers CLAUDE.md already documents as the project-detection heuristic.

Behavior inside Salesforce projects is unchanged.

Verified

# Outside SF project — early-allow, command never inspected
$ cd /tmp && echo '{"tool_name":"Bash","tool_input":{"command":"sf project deploy start --target-org prod"}}' | python3 guardrails.py
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}

# Inside SF project — guardrails fire as before
$ cd /tmp/sftest && touch sfdx-project.json && echo '...' | python3 guardrails.py
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Production deployment without --dry-run", ...}}

guardrails.py (PreToolUse:Bash) and agent-validator.py
(PostToolUse:Write|Edit) now check for Salesforce project markers
(sfdx-project.json, force-app/, aiAuthoringBundles/) in cwd or an
ancestor and exit immediately if none are found.

Previously, when the plugin was installed globally, the PreToolUse
hook ran on every Bash command in every session — including projects
with no Salesforce relevance — before the is_sf_context() text-regex
gate. This change makes the hooks complete no-ops outside Salesforce
projects, so they do not observe tool I/O in unrelated work.

Behavior inside Salesforce projects is unchanged.
@salesforce-cla

salesforce-cla Bot commented May 8, 2026

Copy link
Copy Markdown

Thanks for the contribution! Before we can merge this, we need @tobinsouth to sign the Salesforce Inc. Contributor License Agreement.

@tobinsouth

Copy link
Copy Markdown
Author

CLA done as an individual contributor

@almandsky

Copy link
Copy Markdown
Contributor

Thanks for the change — the gate is correctly placed (before any stdin/tool-input is read), the markers match the heuristic already documented in CLAUDE.md, and the output contract for PreToolUse is preserved. Behavior inside SF projects is genuinely unchanged.

Two requests before merging:

1. Add unit tests

tests/test_hooks.py already imports both modules — adding a few cases is cheap and prevents regressions:

  • is_salesforce_project() returns True when cwd has sfdx-project.json (use tmp_path + monkeypatch.chdir)
  • True when an ancestor (not cwd itself) has force-app/
  • False when neither cwd nor any ancestor has any marker
  • Integration: invoke guardrails.main() outside an SF project with a would-be-blocked command (e.g., DELETE FROM Account) and assert it returns allow (i.e., the early-exit wins over the CRITICAL pattern check)

2. Consolidate is_salesforce_project() into a shared module

SF_PROJECT_MARKERS and is_salesforce_project() are byte-identical in guardrails.py and agent-validator.py. The project already shares helpers via stdin_utils (same try/except ImportError fallback pattern) — please extract to something like shared/hooks/scripts/sf_project.py and import from both. Otherwise a future marker change (e.g., adding aiAgentBundles/) silently drifts between the two scripts.

Optional / follow-ups (not blockers)

  • Walk cost on every Bash call: Path.cwd().resolve() + ancestor stat-walk runs on every Bash tool invocation globally. Negligible on local FS, noticeable on network mounts. A tiny per-process cache keyed by os.getcwd() would help if this ever shows up in profiling.
  • agent-validator.py gate is near-redundant because the .agent extension check already gates it — keeping it for symmetry/defense-in-depth is fine, just worth noting in the PR description that the meaningful scope reduction is in guardrails.py.
  • force-app/ is the loosest of the three markers — a non-SF project could plausibly have a directory named that. sfdx-project.json is the most specific. Not changing behavior here, just flagging if you ever want to tighten.

Approve once tests + shared-module extraction land. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants