You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/10/10 15:23:40 UTC
(magpie) branch main updated: feat(agent-guard): add Grok PreToolUse integration (#1516)
This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/magpie.git
The following commit(s) were added to refs/heads/main by this push:
new faa87668 feat(agent-guard): add Grok PreToolUse integration (#1516)
faa87668 is described below
commit faa87668aee8b8539fdbd7b21ee9482a0d2a4a8d
Author: Kavya Katal <KA...@GMAIL.COM>
AuthorDate: Sat Oct 10 20:53:34 2026 +0530
feat(agent-guard): add Grok PreToolUse integration (#1516)
* feat(agent-guard): add Grok PreToolUse integration
Add a native Grok hook adapter that translates PreToolUse events into the shared agent-guard dispatcher. Wire the project hook and snapshot setup lifecycle without modifying Grok trust state or unrelated configuration.
Register Grok in harness metadata and vendor-neutrality reporting, with end-to-end coverage for the committed hook.
Refs #1416
* fix(agent-guard): verify Grok hook contract
* fix(agent-guard): verify Grok plugin failure paths
* fix(agent-guard): address Grok review follow-up
* chore(agent-guard): regenerate the packaged guard after rebasing onto main
main changed tools/agent-guard (the mention guard's target-repository
parsing), so the copy the magpie-agent-guard plugin ships was out of
sync. Regenerated with `check-family-plugins.py --fix`; the copy is
byte-identical to tools/agent-guard again.
Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01GTPsZ5aEE5bUVYqp47Dpvv
---------
Co-authored-by: Jarek Potiuk <po...@apache.org>
---
.rat-excludes | 4 +
docs/adapters/README.md | 8 +-
docs/adapters/grok.md | 199 ++++++++++++++
docs/adapters/registry.md | 2 +-
docs/setup/marketplace-install.md | 21 ++
docs/setup/marketplace.md | 8 +-
docs/vendor-neutrality.md | 4 +-
.../magpie-agent-guard/.claude-plugin/plugin.json | 16 +-
plugins/magpie-agent-guard/hooks/hooks.json | 17 ++
plugins/magpie-agent-guard/tools/agent-guard | 1 -
.../tools}/agent-guard/src/agent_guard/__init__.py | 84 +++++-
.../tools/agent-guard/src/agent_guard/__main__.py} | 10 +-
.../src/agent_guard/guards.d/no_verify_commit.py | 53 ++++
plugins/magpie-setup/skills/setup/SKILL.md | 4 +-
plugins/magpie-setup/skills/setup/install.md | 22 ++
.../setup/setup_preflight/isolated_fingerprint.py | 2 +-
plugins/magpie-setup/skills/setup/uninstall.md | 8 +
plugins/magpie-setup/skills/setup/upgrade.md | 9 +
plugins/magpie-setup/skills/setup/verify.md | 23 +-
plugins/magpie-setup/skills/setup/worktree-init.md | 8 +-
.../templates/grok-agent-guard-hook.json | 16 ++
tools/agent-guard/README.md | 60 ++++-
tools/agent-guard/src/agent_guard/__init__.py | 84 +++++-
tools/agent-guard/tests/test_grok.py | 285 +++++++++++++++++++++
tools/dev/check-family-plugins.py | 151 +++++++++--
tools/dev/tests/test_check_family_plugins.py | 57 +++--
tools/spec-loop/specs/agent-isolation-sandbox.md | 3 +-
.../src/vendor_neutrality_score/__init__.py | 1 +
.../tests/test_vendor_neutrality_score.py | 4 +-
29 files changed, 1055 insertions(+), 109 deletions(-)
diff --git a/.rat-excludes b/.rat-excludes
index 366c7831..19273817 100644
--- a/.rat-excludes
+++ b/.rat-excludes
@@ -62,3 +62,7 @@ tools/release-verify/tests/fixtures/gpg/OTHER.pub
tools/release-verify/tests/fixtures/gpg/FINGERPRINTS
tools/release-verify/tests/fixtures/gpg/*.tar.gz
tools/release-verify/tests/fixtures/gpg/*.tar.gz.asc
+
+# Grok project hook configuration — strict JSON cannot carry a license comment.
+plugins/magpie-setup/templates/grok-agent-guard-hook.json
+plugins/magpie-agent-guard/hooks/hooks.json
diff --git a/docs/adapters/README.md b/docs/adapters/README.md
index c91463f9..98cf1cea 100644
--- a/docs/adapters/README.md
+++ b/docs/adapters/README.md
@@ -34,6 +34,7 @@ One page per supported agentic harness, each declaring
- [**Cursor**](cursor.md) — Composer and the Agent CLI.
- [**Gemini CLI**](gemini.md) — extension install, `BeforeTool` guard, tool
sandboxing and policies. Experimental.
+- [**Grok Build**](grok.md) — `PreToolUse` action-guard integration.
- [**Goose**](goose.md) — Block's open-source agent CLI and desktop environment.
- [**Kiro CLI**](kiro.md) — per-skill installs, no marketplace; guard on
`preToolUse`.
@@ -43,8 +44,8 @@ One page per supported agentic harness, each declaring
### What isolation each harness actually gets
Not the same thing, and the differences matter more than the similarities.
-Every harness gets the clean-environment layer; the action guard reaches four
-of nine.
+Nine of ten harnesses get the clean-environment layer; the action guard
+reaches six of ten.
| Harness | Clean environment | Filesystem sandbox | Action guard |
|---|---|---|---|
@@ -52,11 +53,12 @@ of nine.
| **Gemini CLI** | `agent-iso gemini` | tool sandboxing + policies | ✅ `BeforeTool` |
| **OpenCode** | `opencode-iso` | from the OS-level sandbox | ✅ `tool.execute.before` |
| **Kiro CLI** | `kiro-iso` | from the OS-level sandbox | ✅ `preToolUse` |
+| **Grok Build** | ❌ **none** | ❌ **none** | ✅ `PreToolUse` |
| **OpenAI Codex CLI** | `agent-iso codex` | Codex's own sandbox and exec policy, statically validated by [sandbox-lint](../../tools/sandbox-lint/README.md) | ❌ **none** |
| **Cursor** | `agent-iso cursor` | Cursor's own policy | ❌ **none** |
| **Goose (Block)** | `agent-iso goose` | Goose developer mode / approval prompts | ❌ **none** |
| **Aider** | `agent-iso aider` | Aider's own policy / git repository map | ❌ **none** |
-| **Copilot CLI** | `agent-iso copilot` | Copilot's own approval prompts | ❌ **none** |
+| **Copilot CLI** | `agent-iso copilot` | Copilot's own approval prompts | ✅ `preToolUse` |
**What the last column costs.** The action guard is what deterministically
refuses a command that would break a hard framework rule — pinging maintainers,
diff --git a/docs/adapters/grok.md b/docs/adapters/grok.md
new file mode 100644
index 00000000..81b498fb
--- /dev/null
+++ b/docs/adapters/grok.md
@@ -0,0 +1,199 @@
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+<!-- START doctoc generated TOC please keep comment here to allow auto update -->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
+
+- [Grok Build](#grok-build)
+ - [Harness contract](#harness-contract)
+ - [Verified runtime](#verified-runtime)
+ - [Installation](#installation)
+ - [Marketplace plugin](#marketplace-plugin)
+ - [Framework checkout](#framework-checkout)
+ - [Pinned snapshot](#pinned-snapshot)
+ - [Trust and security boundary](#trust-and-security-boundary)
+ - [Guard behavior](#guard-behavior)
+ - [Verify](#verify)
+ - [Update and uninstall](#update-and-uninstall)
+ - [Known limitations](#known-limitations)
+ - [Developer checks](#developer-checks)
+ - [Upstream references](#upstream-references)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+# Grok Build
+
+**Capability:** platform:harness
+
+Grok Build is supported for the deterministic Magpie action guard.
+This does not add Grok spec-loop, headless-runner, or dedicated sandbox-profile support.
+
+## Harness contract
+
+| Surface | Grok contract | Magpie integration |
+|---|---|---|
+| Hook event | `PreToolUse` | `agent-guard` |
+| Shell matcher | `Bash` | marketplace plugin or setup-installed project hook |
+| Captured shell tool | `run_terminal_command` | reads `toolInput.command` |
+| Workspace path | `GROK_WORKSPACE_ROOT` | source/snapshot project hook |
+| Plugin path | `GROK_PLUGIN_ROOT` | marketplace/plugin hook |
+| Deny | JSON decision with reason | shared `dispatch()` result |
+| Allow | exit 0 | shared `dispatch()` result |
+
+## Verified runtime
+
+The `PreToolUse` payload contract was captured against:
+
+`grok 1.0.46 (2765805b9442)`
+
+Marketplace/plugin loading and failure semantics were additionally verified
+against:
+
+`grok 1.0.50` (`c58f321264ba`)
+
+The shared marketplace hook was also live-verified on Claude Code 2.1.287.
+With the rebuilt `magpie-agent-guard` plugin installed from this checkout,
+`git commit --no-verify --dry-run` was denied by
+`agent-guard[no-verify]` before Git executed.
+
+A real `PreToolUse` payload captured on 2026-10-08 is retained in
+`tools/agent-guard/tests/test_grok.py`.
+The capture was triggered by asking Grok to run `pwd`.
+The captured event reported `toolName: "run_terminal_command"` and placed the
+shell command in `toolInput.command`.
+
+## Installation
+
+### Marketplace plugin
+
+Grok reads Claude-compatible marketplaces, plugins, and hooks without extra compatibility setup.
+The `magpie-agent-guard` plugin registers its hook through `hooks/hooks.json`.
+Its command resolves the bundled guard through
+`${GROK_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}`.
+The no-argument dispatcher selects the Grok adapter from the incoming
+camelCase `toolName` / `toolInput` payload shape rather than from environment
+variables.
+
+The guard runtime is materialized as real files inside the plugin rather than
+through an out-of-root symlink, because Grok's local plugin installation does
+not preserve that symlink target.
+
+### Framework checkout
+
+This repository enables `magpie-agent-guard` through `.claude/settings.json`,
+which Grok also reads, so the Magpie checkout intentionally does not commit an
+active `.grok/hooks/magpie-agent-guard.json`.
+That avoids running the same guard twice.
+
+Source-style adopters that do not use the marketplace plugin may install the
+project-hook template from
+`plugins/magpie-setup/templates/grok-agent-guard-hook.json`.
+It resolves the guard through `GROK_WORKSPACE_ROOT` and invokes `--grok`.
+
+### Pinned snapshot
+
+A snapshot adopter may install the same single Magpie-owned project hook.
+Its executable path points through
+`$GROK_WORKSPACE_ROOT/.apache-magpie/tools/agent-guard/`.
+
+Magpie owns only that exact hook file.
+It does not own `.grok/hooks/`, `.grok/config.toml`, unrelated hooks, or project trust.
+
+## Trust and security boundary
+
+Project `.grok/hooks/` entries require an explicit Grok trust decision.
+Magpie must never grant or reset that trust on the operator's behalf.
+
+The action guard is separate from Grok permissions and from the operating-system sandbox.
+It inspects shell commands before execution and rejects commands that violate a
+hard Magpie framework rule.
+It does not turn native file tools, MCP tools, or the rest of the process into a sandbox.
+
+## Guard behavior
+
+For a relevant `run_terminal_command` event, the adapter passes the command and
+optional string `cwd` to the harness-neutral `dispatch()` core.
+
+A denial emits a Grok deny decision containing the shared guard reason and exits
+with the guard deny code.
+An allowed shell command exits 0 without emitting a decision.
+
+Malformed input, unrelated tools, or an invalid command field handled by the
+adapter are ignored and fail open by returning exit 0.
+
+Hook-process failure has different semantics. On Grok 1.0.50, a hook process
+that exits 2 blocks the tool call even without deny JSON. A missing Python hook
+target likewise produced exit 2 and blocked the shell call. Installation
+verification should still exercise a known allow and a known denial rather than
+treating configuration presence as sufficient.
+
+## Verify
+
+Use:
+
+`grok inspect --json`
+
+Confirm that the intended Magpie action-guard hook is active and that the
+selected installation method has not introduced a duplicate project hook.
+
+In the Magpie framework checkout, `grok inspect --json` should report the
+enabled `magpie-agent-guard` plugin's `hooks/hooks.json` as the single active
+Magpie action-guard hook; the checkout intentionally carries no active
+`.grok/hooks/magpie-agent-guard.json`.
+
+For a source or snapshot project hook, verify that its command resolves to an
+existing `agent_guard/__init__.py`.
+For a marketplace installation, verify that the installed
+`magpie-agent-guard` plugin supplies the hook and that no duplicate project hook
+was added merely to reach the executable.
+
+Then perform harmless live probes:
+one command that Magpie permits and one known guard denial.
+A static configuration check does not replace the live denial probe.
+
+## Update and uninstall
+
+Marketplace installations follow the plugin manager lifecycle.
+Updating the plugin updates its bundled guard implementation.
+
+Source and snapshot installs compare only
+`.grok/hooks/magpie-agent-guard.json` with the expected shape from
+`plugins/magpie-setup/templates/grok-agent-guard-hook.json`.
+A stock stale copy may be refreshed.
+A hand-edited copy must be shown for review before replacement or removal.
+
+Repository uninstall removes only the Magpie-owned project hook.
+It does not uninstall a user's marketplace plugin and does not change Grok trust.
+
+## Known limitations
+
+This integration is action-guard only.
+It does not add a Grok-specific spec-loop runner or headless execution profile.
+
+Malformed or unrelated input handled inside the adapter is deliberately
+fail-open. Hook-process exit 2 is fail-closed on the Grok 1.0.50 runtime tested:
+both a bare `exit 2` hook and a missing Python hook target blocked the shell
+tool call.
+
+Verification must still include a real denial probe so configuration,
+packaging, and runtime behavior are checked together.
+
+The captured payload contract comes from Grok Build 1.0.46.
+Marketplace loading, bundled-runtime execution, policy denial, and exit-2
+failure behavior were verified on Grok Build 1.0.50.
+Future Grok hook-schema changes require a fresh runtime capture before changing
+the adapter contract.
+
+## Developer checks
+
+`uv run --directory tools/agent-guard --group dev pytest`
+
+`uv run --directory tools/agent-guard --group dev python -m ruff check src tests`
+
+## Upstream references
+
+- xAI Hooks: https://docs.x.ai/build/features/hooks
+- xAI Skills, Plugins & Marketplaces: https://docs.x.ai/build/features/skills-plugins-marketplaces
+- xAI Permissions: https://docs.x.ai/build/features/permissions
+- xAI CLI Reference: https://docs.x.ai/build/cli/reference
diff --git a/docs/adapters/registry.md b/docs/adapters/registry.md
index d683df3e..0259f5de 100644
--- a/docs/adapters/registry.md
+++ b/docs/adapters/registry.md
@@ -56,7 +56,7 @@ extension point = a documented, labelled slot with a tracking issue.
| [`tools/vcs`](../../tools/vcs/) | Git, Mercurial, Fossil | Subversion [\#602](https://github.com/apache/magpie/issues/602), Jujutsu [\#603](https://github.com/apache/magpie/issues/603), Perforce [\#605](https://github.com/apache/magpie/issues/605) |
| Forge / tracker | [`github`](../../tools/github/), [`forgejo`](../../tools/forgejo/) `partial` foundation, [`jira`](../../tools/jira/), [`bitbucket`](../../tools/bitbucket/) `partial-read-only` foundation, [`sourcehut`](../../tools/sourcehut/), [`fossil`](../../tools/fossil/), [`gitlab`](../../tools/gitlab/) `partial-read-only` foundation | Forgejo/Gitea [\#310](https://github.com/apache/magpie/issues/310), Pagure [\#312](https://github.com/apache/magpie/issues/312), deeper Bitbucket/J [...]
| [`tools/chat`](../../tools/chat/) | [`chat-slack`](../../tools/chat-slack/), [`chat-discord`](../../tools/chat-discord/) | Matrix [\#309](https://github.com/apache/magpie/issues/309), Zulip [\#308](https://github.com/apache/magpie/issues/308) |
-| Agent harness | Claude Code, [Codex](codex.md) `experimental` ([#313](https://github.com/apache/magpie/issues/313)), [Gemini CLI](gemini.md) `experimental` ([#314](https://github.com/apache/magpie/issues/314)), [Local LLM (Ollama / llama.cpp / vLLM)](local-llm.md) ([#315](https://github.com/apache/magpie/issues/315)), [Cursor](cursor.md) ([#316](https://github.com/apache/magpie/issues/316)), [Goose](goose.md) `guide only` ([#319](https://github.com/apache/magpie/issues/319)), [Aider](a [...]
+| Agent harness | Claude Code, [Codex](codex.md) `experimental` ([#313](https://github.com/apache/magpie/issues/313)), [Gemini CLI](gemini.md) `experimental` ([#314](https://github.com/apache/magpie/issues/314)), [Local LLM (Ollama / llama.cpp / vLLM)](local-llm.md) ([#315](https://github.com/apache/magpie/issues/315)), [Cursor](cursor.md) ([#316](https://github.com/apache/magpie/issues/316)), [Goose](goose.md) `guide only` ([#319](https://github.com/apache/magpie/issues/319)), [Aider](a [...]
| Security cross-ref | [`tools/osv`](../../tools/osv/) | — |
| [`tools/typed-decision`](../../tools/typed-decision/) | TypeSafe Jev API, in-tree local provider (Ollama / llama.cpp / vLLM) ([#1431](https://github.com/apache/magpie/issues/1431)) | — |
| [`tools/people`](../../tools/people/) | [`github`](../../tools/github/), [`jira`](../../tools/jira/) (profile lookup) | GitLab, Forgejo, Bitbucket |
diff --git a/docs/setup/marketplace-install.md b/docs/setup/marketplace-install.md
index c51d9c81..f3ae26ef 100644
--- a/docs/setup/marketplace-install.md
+++ b/docs/setup/marketplace-install.md
@@ -10,6 +10,7 @@
- [Claude Code](#claude-code)
- [OpenAI Codex CLI](#openai-codex-cli)
- [VS Code / GitHub Copilot](#vs-code--github-copilot)
+ - [Grok Build](#grok-build)
- [Google Gemini CLI](#google-gemini-cli)
- [Cursor](#cursor)
- [microsoft/apm](#microsoftapm)
@@ -144,6 +145,26 @@ https://github.com/apache/magpie
You can also add `apache/magpie` as a plugin marketplace and install
individual families from it.
+## Grok Build
+
+Grok Build reads Claude-compatible marketplaces, plugins, and hooks.
+When `magpie-agent-guard` is installed from the Apache Magpie marketplace, Grok can use the plugin's bundled `PreToolUse` hook and executable directly.
+Do not create an `.apache-magpie/` snapshot or a second project hook merely to reach the marketplace-installed guard.
+
+The plugin hook uses the existing `Bash` matcher.
+The shared dispatcher selects the Grok adapter automatically from Grok's camelCase hook payload shape.
+
+Verify the loaded hook with:
+
+```bash
+grok inspect --json
+```
+
+Then exercise one harmless allow and one known Magpie denial.
+Project-hook trust remains an explicit operator action whenever a repository-local `.grok/hooks/` hook is used.
+
+See [the Grok adapter](../adapters/grok.md).
+
## Google Gemini CLI
```bash
diff --git a/docs/setup/marketplace.md b/docs/setup/marketplace.md
index cdf695c7..9cab1bc4 100644
--- a/docs/setup/marketplace.md
+++ b/docs/setup/marketplace.md
@@ -139,9 +139,11 @@ rather than skills and are installed independently of the choice below:
`magpie-agent-guard` (a `PreToolUse` hook that denies shell commands breaking a
hard framework rule) and `magpie-vetted-ops` (a dispatcher for fixed,
policy-scoped forge operations, so a session needs one allowlist entry instead
-of a dozen wildcard `ask` rules). Both run from the installed plugin, so no
-repository or worktree needs a local copy — and neither adds always-on skill
-context. Pick based on the trade-off between install simplicity and
+of a dozen wildcard `ask` rules).
+Both run from the installed plugin, so no repository or worktree needs a local copy.
+Grok Build can consume the existing Claude-compatible `magpie-agent-guard` plugin hook.
+The dispatcher selects the Grok protocol from the incoming hook payload shape.
+Neither substrate plugin adds always-on skill context. Pick based on the trade-off between install simplicity and
always-on token cost (each installed skill advertises a short description to
the model on **every** turn — see ["always-on" cost](#versioning) below).
diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md
index b8ef8a96..84eaab4b 100644
--- a/docs/vendor-neutrality.md
+++ b/docs/vendor-neutrality.md
@@ -623,7 +623,7 @@ Organization scope (declared, orthogonal to vendor): ASF = 16, agnostic = 63.
| Substrate tool | Substrate | Harness support | Verdict |
|---|---|---|---|
| `adversarial-review` | review | any | ✅ agnostic |
-| `agent-guard` | action-guard | Claude Code, Gemini CLI, Kiro, OpenCode | ✅ portable |
+| `agent-guard` | action-guard | Claude Code, Copilot, Gemini CLI, Grok, Kiro, OpenCode | ✅ portable |
| `agent-isolation` | sandbox | any | ✅ agnostic |
| `container-gateway` | sandbox | any | ✅ agnostic |
| `contributor-metrics` | analytics | any | ✅ agnostic |
@@ -659,8 +659,10 @@ Harness → substrate tools it supports:
- **Claude Code** (3): `agent-guard`, `sandbox-lint`, `spec-loop`
- **Codex** (2): `sandbox-lint`, `spec-loop`
+- **Copilot** (1): `agent-guard`
- **Cursor** (2): `sandbox-lint`, `spec-loop`
- **Gemini CLI** (3): `agent-guard`, `sandbox-lint`, `spec-loop`
+- **Grok** (1): `agent-guard`
- **Kiro** (3): `agent-guard`, `sandbox-lint`, `spec-loop`
- **OpenCode** (3): `agent-guard`, `sandbox-lint`, `spec-loop`
- **any harness** (29): `adversarial-review`, `agent-isolation`, `container-gateway`, `contributor-metrics`, `dashboard-generator`, `dev`, `egress-gateway`, `maven-artifact-verify`, `permission-audit`, `pilot-report-validator`, `pr-management`, `preflight-audit`, `privacy-llm`, `probe-templates`, `release-config`, `release-verify`, `reproducible-archive`, `security-tracker-stats-dashboard`, `setup-preflight`, `skill-and-tool-validator`, `skill-evals`, `skill-reconciler-diff`, `skill-toke [...]
diff --git a/plugins/magpie-agent-guard/.claude-plugin/plugin.json b/plugins/magpie-agent-guard/.claude-plugin/plugin.json
index f3d6931e..d5b1ae48 100644
--- a/plugins/magpie-agent-guard/.claude-plugin/plugin.json
+++ b/plugins/magpie-agent-guard/.claude-plugin/plugin.json
@@ -8,19 +8,5 @@
},
"homepage": "https://magpie.apache.org/",
"repository": "https://github.com/apache/magpie",
- "license": "Apache-2.0",
- "hooks": {
- "PreToolUse": [
- {
- "matcher": "Bash",
- "hooks": [
- {
- "type": "command",
- "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/tools/agent-guard/src/agent_guard/__init__.py\"",
- "timeout": 30
- }
- ]
- }
- ]
- }
+ "license": "Apache-2.0"
}
diff --git a/plugins/magpie-agent-guard/hooks/hooks.json b/plugins/magpie-agent-guard/hooks/hooks.json
new file mode 100644
index 00000000..0fd1b36c
--- /dev/null
+++ b/plugins/magpie-agent-guard/hooks/hooks.json
@@ -0,0 +1,17 @@
+{
+ "description": "Apache Magpie agent guard",
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 \"${GROK_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}/tools/agent-guard/src/agent_guard/__init__.py\"",
+ "timeout": 30
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/plugins/magpie-agent-guard/tools/agent-guard b/plugins/magpie-agent-guard/tools/agent-guard
deleted file mode 120000
index c353d2ac..00000000
--- a/plugins/magpie-agent-guard/tools/agent-guard
+++ /dev/null
@@ -1 +0,0 @@
-../../../tools/agent-guard
\ No newline at end of file
diff --git a/tools/agent-guard/src/agent_guard/__init__.py b/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__init__.py
similarity index 96%
copy from tools/agent-guard/src/agent_guard/__init__.py
copy to plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__init__.py
index 6ab4b466..f9b4fc83 100644
--- a/tools/agent-guard/src/agent_guard/__init__.py
+++ b/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__init__.py
@@ -30,6 +30,9 @@ pre-tool hook to/from the core, so every wired harness enforces one rule set:
non-zero exit so the plugin can throw and abort the tool call.
* :func:`gemini_main` — Gemini CLI ``BeforeTool`` hook (``--gemini``):
matches ``run_shell_command`` and blocks with exit 2 and a stderr reason.
+* :func:`grok_main` — Grok Build ``PreToolUse`` adapter (``--grok`` or
+ automatic no-argument payload-shape detection): reads the documented
+ camelCase event and emits Grok's JSON allow/deny decision.
* :func:`check_main` — Harness-neutral check-only entry point (``--check``):
takes the command as remaining CLI args, exits ``DENY_EXIT`` with the reason
on stdout on a deny, ``ALLOW_EXIT`` silently on allow, ``USAGE_EXIT`` when no
@@ -1322,20 +1325,28 @@ def _emit_deny(reason: str) -> None:
sys.stdout.write("\n")
-def main() -> int:
- """Claude Code ``PreToolUse`` entry point (the default invocation)."""
- try:
- event = json.load(sys.stdin)
- except (json.JSONDecodeError, ValueError):
- return 0 # Malformed event — never break the user's shell; allow.
+def _claude_event(event: object) -> int:
+ """Handle one parsed Claude Code ``PreToolUse`` event."""
if not isinstance(event, dict) or event.get("tool_name") != "Bash":
- return 0
- command = str(event.get("tool_input", {}).get("command", ""))
+ return ALLOW_EXIT
+ tool_input = event.get("tool_input")
+ if not isinstance(tool_input, dict):
+ return ALLOW_EXIT
+ command = str(tool_input.get("command", ""))
cwd = event.get("cwd")
reason = dispatch(command, cwd if isinstance(cwd, str) else None)
if reason:
_emit_deny(reason)
- return 0
+ return ALLOW_EXIT
+
+
+def main() -> int:
+ """Claude Code ``PreToolUse`` entry point."""
+ try:
+ event = json.load(sys.stdin)
+ except (json.JSONDecodeError, ValueError):
+ return ALLOW_EXIT
+ return _claude_event(event)
# Exit codes for the harness-neutral entry point (``--opencode`` and any future
@@ -1557,6 +1568,45 @@ def copilot_main() -> int:
return ALLOW_EXIT
+def _grok_event(event: object) -> int:
+ """Handle one parsed Grok Build ``PreToolUse`` event."""
+ if not isinstance(event, dict):
+ return ALLOW_EXIT
+ if event.get("toolName") != "run_terminal_command":
+ return ALLOW_EXIT
+ tool_input = event.get("toolInput")
+ if not isinstance(tool_input, dict):
+ return ALLOW_EXIT
+ command = tool_input.get("command")
+ if not isinstance(command, str):
+ return ALLOW_EXIT
+
+ cwd = event.get("cwd")
+ reason = dispatch(command, cwd if isinstance(cwd, str) else None)
+ if reason:
+ json.dump({"decision": "deny", "reason": reason}, sys.stdout)
+ sys.stdout.write("\n")
+ return DENY_EXIT
+
+ return ALLOW_EXIT
+
+
+def grok_main() -> int:
+ """Translate Grok Build ``PreToolUse`` input to the shared guard core.
+
+ Verified with Grok Build 1.0.46 (2765805b9442). ``PreToolUse`` supplies
+ camelCase ``toolName`` / ``toolInput`` fields; the captured shell event uses
+ ``run_terminal_command`` and carries the command in ``toolInput.command``.
+ Relevant calls emit Grok's explicit JSON decision. Malformed or unrelated
+ events stay silent and fail open inside the adapter.
+ """
+ try:
+ event = json.load(sys.stdin)
+ except (json.JSONDecodeError, ValueError):
+ return ALLOW_EXIT
+ return _grok_event(event)
+
+
def check_main(argv: list[str]) -> int:
"""Harness-neutral check-only entry point (``--check``).
@@ -1672,11 +1722,15 @@ def exec_main(argv: list[str]) -> int:
def cli(argv: list[str] | None = None) -> int:
"""Route to the harness adapter named on the command line.
- No argument → the Claude Code ``PreToolUse`` hook (:func:`main`).
+ No argument → inspect the parsed ``PreToolUse`` payload shape: Grok's
+ camelCase ``toolName`` / ``toolInput`` routes to :func:`_grok_event`;
+ Claude Code's snake_case ``tool_name`` / ``tool_input`` routes to
+ :func:`_claude_event`.
``--opencode`` → the OpenCode adapter (:func:`opencode_main`).
``--kiro`` → the Kiro CLI adapter (:func:`kiro_main`).
``--gemini`` → the Gemini CLI adapter (:func:`gemini_main`).
``--copilot`` → the Copilot CLI adapter (:func:`copilot_main`).
+ ``--grok`` → the Grok Build adapter (:func:`grok_main`).
``--check <cmd…>`` → harness-neutral check-only (:func:`check_main`).
``--exec <cmd…>`` → harness-neutral check-then-exec (:func:`exec_main`).
@@ -1693,6 +1747,16 @@ def cli(argv: list[str] | None = None) -> int:
return gemini_main()
if args and args[0] == "--copilot":
return copilot_main()
+ if args and args[0] == "--grok":
+ return grok_main()
+ if not args:
+ try:
+ event = json.load(sys.stdin)
+ except (json.JSONDecodeError, ValueError):
+ return ALLOW_EXIT
+ if isinstance(event, dict) and event.get("toolName") == "run_terminal_command":
+ return _grok_event(event)
+ return _claude_event(event)
if args and args[0] == "--check":
return check_main(args[1:])
if args and args[0] == "--exec":
diff --git a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py b/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__main__.py
similarity index 73%
copy from plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py
copy to plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__main__.py
index 7e19cb9e..4617dc7e 100644
--- a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py
+++ b/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__main__.py
@@ -15,10 +15,10 @@
# specific language governing permissions and limitations
# under the License.
-"""Generated by `python -m setup_preflight.isolated write-constant`. Do not edit.
+# SPDX-License-Identifier: Apache-2.0
-The isolated-setup fingerprint of the framework this package shipped with,
-for installs that do not carry the framework source (see `isolated.py`).
-"""
+import sys
-FRAMEWORK_FINGERPRINT = "sha256:84d4ceb6f07b799e"
+from agent_guard import cli
+
+sys.exit(cli())
diff --git a/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/guards.d/no_verify_commit.py b/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/guards.d/no_verify_commit.py
new file mode 100644
index 00000000..0b82a657
--- /dev/null
+++ b/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/guards.d/no_verify_commit.py
@@ -0,0 +1,53 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Bundled example contributed guard: block ``git commit --no-verify``.
+
+This is the **template** for skill-contributed guards. To add a guard, drop a
+file shaped like this into any discovered ``guards.d`` directory — no edit to
+``agent_guard`` and no change to the ``settings.json`` hook wiring is needed; the
+dispatcher discovers it automatically.
+
+The contract:
+
+* ``TRIGGERS`` — optional list of command families this guard cares about
+ (``"gh"``, ``"git:commit"``, ``"git:push"``, …). Omit to run on every guarded
+ command. The dispatcher uses it as a cheap pre-filter.
+* ``guard(ctx)`` — return a deny-reason string to block the command, or ``None``
+ to allow it. ``ctx`` is the ``GuardContext`` (see ``agent_guard``); the guard
+ imports nothing.
+
+This guard blocks ``--no-verify`` / ``-n`` (which skips the prek hooks — license
+headers, placeholders, lint, the validator) unless the maintainer overrides it.
+"""
+
+TRIGGERS = ["git:commit"]
+
+
+def guard(ctx):
+ if ctx.git_subcommand() != "commit":
+ return None
+ if not any(tok in ("--no-verify", "-n") for tok in ctx.argv):
+ return None
+ if ctx.override("MAGPIE_ALLOW_NO_VERIFY"):
+ return None
+ return (
+ "agent-guard[no-verify]: `git commit --no-verify` skips the prek hooks "
+ "(license headers, placeholder check, lint, the skill/tool validator). "
+ "Commit without --no-verify so the checks run, or override with "
+ "MAGPIE_ALLOW_NO_VERIFY=1 if you are certain."
+ )
diff --git a/plugins/magpie-setup/skills/setup/SKILL.md b/plugins/magpie-setup/skills/setup/SKILL.md
index ef9c7eaf..b944d985 100644
--- a/plugins/magpie-setup/skills/setup/SKILL.md
+++ b/plugins/magpie-setup/skills/setup/SKILL.md
@@ -19,9 +19,9 @@ when_to_use: >-
contributor and is not an install.
argument-hint: "[install|config|adopt|unadopt|upgrade|worktree-init|verify|reconcile|override skill-name|uninstall]"
capability: capability:platform
-surface_hash: sha256:a351597d6ed55eaf
+surface_hash: sha256:5660f77d249eb1e7
license: Apache-2.0
-measured_tokens: 4600
+measured_tokens: 4602
---
<!-- SPDX-License-Identifier: Apache-2.0
diff --git a/plugins/magpie-setup/skills/setup/install.md b/plugins/magpie-setup/skills/setup/install.md
index d89bcebc..0ee7544e 100644
--- a/plugins/magpie-setup/skills/setup/install.md
+++ b/plugins/magpie-setup/skills/setup/install.md
@@ -1923,6 +1923,28 @@ Four passes, in this order:
Follow [the Gemini install lifecycle](../../../../docs/adapters/gemini.md#install) to propose and merge its workspace profile and guard registration from this snapshot.
Preserve unrelated configuration and review conflicts before applying changes.
Validate the merged profile and hand off live verification to Gemini.
+ **Grok action guard (when Grok is the selected runtime).**
+ First distinguish a marketplace/plugin install from a source or pinned-snapshot install.
+ When `magpie-agent-guard` is already available as an enabled marketplace plugin, use that bundled hook and executable.
+ Do not create a second project hook or `.apache-magpie/` snapshot merely to reach the guard.
+
+ For a framework checkout or pinned snapshot, install exactly one Magpie-owned project hook at
+ `<repo-root>/.grok/hooks/magpie-agent-guard.json`.
+ Create `.grok/hooks/` when absent, but never treat that directory as Magpie-owned and never modify or remove unrelated files already present there.
+ Derive the adopter hook from the framework's committed `plugins/magpie-setup/templates/grok-agent-guard-hook.json`.
+ Preserve its event, `Bash` matcher, handler type, timeout, and `--grok` invocation.
+ For a pinned snapshot, change only the executable path to
+ `$GROK_WORKSPACE_ROOT/.apache-magpie/tools/agent-guard/src/agent_guard/__init__.py`.
+
+ If that exact hook file already exists and differs, surface the diff before replacement.
+ Do not overwrite hand-edited content silently.
+ Preserve every other `.grok/hooks/*` file and do not modify `.grok/config.toml`.
+
+ Grok project hooks require an explicit operator trust decision.
+ Tell the operator to review and trust the hook in Grok after installation.
+ Never invoke or automate Grok's hook-trust action on their behalf.
+
+ See [the Grok adapter lifecycle](../../../../docs/adapters/grok.md#installation).
Other selected runtimes do not install this profile.
2. **Propagate to every worktree (run `worktree-init`
diff --git a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py
index 7e19cb9e..c139c23c 100644
--- a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py
+++ b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py
@@ -21,4 +21,4 @@ The isolated-setup fingerprint of the framework this package shipped with,
for installs that do not carry the framework source (see `isolated.py`).
"""
-FRAMEWORK_FINGERPRINT = "sha256:84d4ceb6f07b799e"
+FRAMEWORK_FINGERPRINT = "sha256:ff481b500c640dd1"
diff --git a/plugins/magpie-setup/skills/setup/uninstall.md b/plugins/magpie-setup/skills/setup/uninstall.md
index 67a6045b..a4029779 100644
--- a/plugins/magpie-setup/skills/setup/uninstall.md
+++ b/plugins/magpie-setup/skills/setup/uninstall.md
@@ -312,6 +312,14 @@ pointing at a deleted snapshot.
[the Gemini cleanup contract](../../../../docs/adapters/gemini.md#setup-isolated-lifecycle)
before deleting the snapshot, including guard references in linked worktrees.
Preserve unrelated configuration and show hand-edited policy values for review.
+
+ For Grok, remove only a repository-local
+ `<repo-root>/.grok/hooks/magpie-agent-guard.json` when it matches the Magpie-owned hook shape.
+ If that exact file is hand-edited, surface the diff before removal.
+ Never delete `.grok/hooks/` itself when other files remain.
+ Never remove unrelated `.grok/hooks/*`, modify `.grok/config.toml`, or change Grok's project-hook trust state.
+ A repository uninstall does not uninstall or disable a user's marketplace `magpie-agent-guard` plugin; that plugin remains under the client's own plugin-manager lifecycle.
+
4. **Snapshot directory.** `rm -rf <snapshot-dir>/`.
5. **Local lock.** `rm <local-lock>`.
6. **`.gitignore` entries.** Read `<repo-root>/.gitignore`,
diff --git a/plugins/magpie-setup/skills/setup/upgrade.md b/plugins/magpie-setup/skills/setup/upgrade.md
index cca6d87e..f54b6cc0 100644
--- a/plugins/magpie-setup/skills/setup/upgrade.md
+++ b/plugins/magpie-setup/skills/setup/upgrade.md
@@ -582,6 +582,15 @@ rather than pulls in via symlink. Examples:
- An installed Gemini workspace profile and guard registration:
follow [the Gemini update lifecycle](../../../../docs/adapters/gemini.md#update) to report drift, then use its install merge for approved repairs.
Preserve unrelated configuration and hand edits, and verify after restarting the runtime.
+- Grok action-guard wiring, when Grok is an adopted runtime.
+ For a marketplace/plugin install, leave repository hook state alone and let the plugin manager update the bundled `magpie-agent-guard` executable and hook.
+ Do not create a project hook or snapshot merely because the plugin was upgraded.
+ For a source or pinned-snapshot install, compare only `<repo-root>/.grok/hooks/magpie-agent-guard.json` with the current expected hook shape.
+ Preserve every other `.grok/hooks/*` entry and never modify `.grok/config.toml`.
+ If the Magpie hook is stock-but-stale, refresh it.
+ If it appears hand-edited, surface the diff and ask before replacing it.
+ Do not grant or reset Grok project-hook trust during upgrade.
+ Trust remains an explicit operator decision.
- Any future hook or local config the framework adds.
These can drift independently of the snapshot — an
diff --git a/plugins/magpie-setup/skills/setup/verify.md b/plugins/magpie-setup/skills/setup/verify.md
index faad2e28..04965b98 100644
--- a/plugins/magpie-setup/skills/setup/verify.md
+++ b/plugins/magpie-setup/skills/setup/verify.md
@@ -433,8 +433,8 @@ that the engine path it names **resolves**; a wired hook pointing at
a file that is not there is the failure mode worth catching, because
a guard that never loads does not raise — it silently stops denying.
-1. **Plugin install** — the `magpie-agent-guard` plugin is
- installed. Its manifest carries the hook, so there is nothing
+1. **Plugin install** — the `magpie-agent-guard` plugin is installed.
+ Its `hooks/hooks.json` carries the hook, so there is nothing
repository-local to check and nothing to remediate per worktree.
- ⚠ if no agent-guard install of any kind is found: print
`/plugin install magpie-agent-guard@apache-magpie`.
@@ -889,6 +889,25 @@ Report missing components and configuration drift without modifying files.
An absent profile is skipped unless Gemini secure setup was requested; in that case, point to `setup-isolated-setup-install`.
A static pass does not replace live verification in Gemini.
+### 8h.1. Grok action guard (if configured)
+
+When Grok is a selected runtime, distinguish marketplace/plugin wiring from a source or pinned-snapshot project hook.
+For a marketplace install, confirm that `magpie-agent-guard` is installed and enabled and that no duplicate `.grok/hooks/magpie-agent-guard.json` was added merely to reach the executable.
+For a source or pinned snapshot, confirm that `.grok/hooks/magpie-agent-guard.json` exists, uses the `Bash` matcher, invokes `--grok`, and resolves to an existing `agent_guard/__init__.py`.
+
+Run `grok inspect --json` and report the loaded Magpie hook source.
+Warn when no Magpie guard is active or when more than one Magpie guard would inspect the same shell command.
+
+Static wiring is not sufficient.
+Ask the operator to perform one harmless allowed shell command and one known Magpie denial in Grok.
+Malformed input handled inside the adapter is fail-open, while Grok Build
+1.0.50 was observed to block hook-process exit 2. A live denial is still the
+verification that the configured hook, packaged runtime, and Magpie policy are
+actually enforcing together.
+
+Never grant, reset, or otherwise change Grok project-hook trust during verification.
+See [the Grok verification contract](../../../../docs/adapters/grok.md#verify).
+
### 8i. Adversarial reviewers (if configured)
When `adversarial-review.md` resolves (the personal layer first, then
diff --git a/plugins/magpie-setup/skills/setup/worktree-init.md b/plugins/magpie-setup/skills/setup/worktree-init.md
index bb195a88..88378094 100644
--- a/plugins/magpie-setup/skills/setup/worktree-init.md
+++ b/plugins/magpie-setup/skills/setup/worktree-init.md
@@ -264,10 +264,10 @@ The deterministic guard
from wherever it is installed **once**, never from a per-worktree
copy:
-- **Plugin install** (`magpie-agent-guard`) — the plugin's own
- manifest registers the `PreToolUse` hook and resolves the engine
- under `${CLAUDE_PLUGIN_ROOT}`. Nothing is repository-local, so a
- worktree is covered the moment it exists.
+- **Plugin install** (`magpie-agent-guard`) — the plugin's
+ `hooks/hooks.json` registers the `PreToolUse` hook and resolves the engine
+ with `${GROK_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}`.
+ Nothing is repository-local, so a worktree is covered the moment it exists.
- **Snapshot install** — the `settings.local.json` wiring resolves
the engine inside the snapshot
(`$CLAUDE_PROJECT_DIR/.apache-magpie/tools/agent-guard/src/agent_guard/__init__.py`),
diff --git a/plugins/magpie-setup/templates/grok-agent-guard-hook.json b/plugins/magpie-setup/templates/grok-agent-guard-hook.json
new file mode 100644
index 00000000..c8561629
--- /dev/null
+++ b/plugins/magpie-setup/templates/grok-agent-guard-hook.json
@@ -0,0 +1,16 @@
+{
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 \"$GROK_WORKSPACE_ROOT/tools/agent-guard/src/agent_guard/__init__.py\" --grok",
+ "timeout": 30
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/tools/agent-guard/README.md b/tools/agent-guard/README.md
index 29169e29..6705187c 100644
--- a/tools/agent-guard/README.md
+++ b/tools/agent-guard/README.md
@@ -16,6 +16,7 @@
- [Kiro CLI](#kiro-cli)
- [Gemini CLI](#gemini-cli)
- [Copilot CLI](#copilot-cli)
+ - [Grok](#grok)
- [Harness-neutral path (any runtime)](#harness-neutral-path-any-runtime)
- [Contributing guards](#contributing-guards)
- [Tests](#tests)
@@ -29,7 +30,7 @@
**Capability:** substrate:action-guard
-**Harness:** Claude Code, OpenCode, Kiro, Gemini CLI
+**Harness:** Claude Code, OpenCode, Kiro, Gemini CLI, Copilot, Grok
A deterministic pre-execution guard dispatcher. It inspects every shell command
**before it runs** and **denies** the ones that would break a hard framework
@@ -52,6 +53,12 @@ so every wired harness enforces an identical rule set from one source of truth:
hook on `run_shell_command`, using `--gemini` (exit `2`, reason on stderr).
The repository's `.gemini/settings.json` wires this hook; snapshot
adopters register it in their own settings. See [Gemini CLI](#gemini-cli).
+- **Grok Build** — a `PreToolUse` hook on the `Bash` matcher.
+ Grok Build 1.0.46 was observed emitting `run_terminal_command` with the shell
+ command in `toolInput.command`.
+ Source/snapshot project hooks use `--grok`; marketplace plugin hooks are
+ selected automatically from Grok's incoming hook payload shape.
+ See [Grok](#grok).
- **Any other runtime** — the `--check` and `--exec` CLI modes let any
harness or shell wrapper enforce guard rules without a harness-specific hook
adapter. See [Harness-neutral path (any runtime)](#harness-neutral-path-any-runtime).
@@ -145,10 +152,12 @@ Install the `magpie-agent-guard` plugin:
/plugin install magpie-agent-guard@apache-magpie
```
-That is the whole installation. The plugin's manifest
-([`plugins/magpie-agent-guard/.claude-plugin/plugin.json`](../../plugins/magpie-agent-guard/.claude-plugin/plugin.json))
-registers the `PreToolUse` hook itself and resolves the engine under
-`${CLAUDE_PLUGIN_ROOT}`, so the guard runs out of the installed plugin. **No
+That is the whole installation.
+The plugin's
+[`hooks/hooks.json`](../../plugins/magpie-agent-guard/hooks/hooks.json)
+registers the `PreToolUse` hook and resolves the engine from the plugin root
+with `${GROK_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}`, so the guard runs out of
+the installed plugin. **No
file is copied into any repository, no `settings.local.json` entry is written,
and a git worktree needs no seeding** — it is an ordinary checkout, and the
guard is active in it the moment the plugin is installed.
@@ -344,6 +353,47 @@ Manual registration, with the path replaced by the resolved framework directory:
See [the Copilot install lifecycle](../../docs/adapters/copilot.md#install).
+### Grok
+
+The setup project-hook template
+[`plugins/magpie-setup/templates/grok-agent-guard-hook.json`](../../plugins/magpie-setup/templates/grok-agent-guard-hook.json)
+uses Grok's `PreToolUse` event with the `Bash` matcher and invokes `--grok`.
+The Magpie repository itself relies on the enabled marketplace plugin instead,
+so it does not also commit the active project hook.
+The payload contract was verified with Grok Build 1.0.46
+(`2765805b9442`) using a real captured event whose concrete shell tool was
+`run_terminal_command`. Marketplace/plugin loading and failure behavior were
+additionally verified on Grok Build 1.0.50 (`c58f321264ba`).
+
+Grok sends the command in `toolInput.command`.
+The adapter forwards that command and an optional string `cwd` to the shared
+`dispatch()` core.
+A guard hit returns Grok's deny decision with the shared reason.
+An allowed shell command exits 0 without emitting a decision.
+
+Marketplace installs do not need a second repository-local hook merely to reach
+the executable.
+The plugin registers its Grok-compatible hook in `hooks/hooks.json`, resolves
+the bundled runtime with `${GROK_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}`, and
+selects the Grok adapter from the incoming camelCase hook payload.
+The runtime is materialized as real files inside the plugin because Grok's
+local plugin installation does not preserve the former out-of-root symlink.
+
+Framework checkouts and pinned snapshots may use the Magpie-owned project hook.
+Project hooks remain trust-gated by Grok.
+Magpie must never grant or reset that trust on the operator's behalf.
+
+Malformed or unrelated events handled inside the adapter fail open by
+returning exit 0.
+
+Hook-process failure is different. On Grok Build 1.0.50, a bare exit 2 with no
+deny JSON blocked the shell tool call, and a missing Python hook target that
+exited 2 was also blocked. Installation verification should still include a
+known live denial so wiring, packaging, and runtime policy are exercised
+together.
+
+See the [Grok adapter contract](../../docs/adapters/grok.md).
+
### Harness-neutral path (any runtime)
For runtimes without a wired pre-tool hook adapter, the engine ships two
diff --git a/tools/agent-guard/src/agent_guard/__init__.py b/tools/agent-guard/src/agent_guard/__init__.py
index 6ab4b466..f9b4fc83 100644
--- a/tools/agent-guard/src/agent_guard/__init__.py
+++ b/tools/agent-guard/src/agent_guard/__init__.py
@@ -30,6 +30,9 @@ pre-tool hook to/from the core, so every wired harness enforces one rule set:
non-zero exit so the plugin can throw and abort the tool call.
* :func:`gemini_main` — Gemini CLI ``BeforeTool`` hook (``--gemini``):
matches ``run_shell_command`` and blocks with exit 2 and a stderr reason.
+* :func:`grok_main` — Grok Build ``PreToolUse`` adapter (``--grok`` or
+ automatic no-argument payload-shape detection): reads the documented
+ camelCase event and emits Grok's JSON allow/deny decision.
* :func:`check_main` — Harness-neutral check-only entry point (``--check``):
takes the command as remaining CLI args, exits ``DENY_EXIT`` with the reason
on stdout on a deny, ``ALLOW_EXIT`` silently on allow, ``USAGE_EXIT`` when no
@@ -1322,20 +1325,28 @@ def _emit_deny(reason: str) -> None:
sys.stdout.write("\n")
-def main() -> int:
- """Claude Code ``PreToolUse`` entry point (the default invocation)."""
- try:
- event = json.load(sys.stdin)
- except (json.JSONDecodeError, ValueError):
- return 0 # Malformed event — never break the user's shell; allow.
+def _claude_event(event: object) -> int:
+ """Handle one parsed Claude Code ``PreToolUse`` event."""
if not isinstance(event, dict) or event.get("tool_name") != "Bash":
- return 0
- command = str(event.get("tool_input", {}).get("command", ""))
+ return ALLOW_EXIT
+ tool_input = event.get("tool_input")
+ if not isinstance(tool_input, dict):
+ return ALLOW_EXIT
+ command = str(tool_input.get("command", ""))
cwd = event.get("cwd")
reason = dispatch(command, cwd if isinstance(cwd, str) else None)
if reason:
_emit_deny(reason)
- return 0
+ return ALLOW_EXIT
+
+
+def main() -> int:
+ """Claude Code ``PreToolUse`` entry point."""
+ try:
+ event = json.load(sys.stdin)
+ except (json.JSONDecodeError, ValueError):
+ return ALLOW_EXIT
+ return _claude_event(event)
# Exit codes for the harness-neutral entry point (``--opencode`` and any future
@@ -1557,6 +1568,45 @@ def copilot_main() -> int:
return ALLOW_EXIT
+def _grok_event(event: object) -> int:
+ """Handle one parsed Grok Build ``PreToolUse`` event."""
+ if not isinstance(event, dict):
+ return ALLOW_EXIT
+ if event.get("toolName") != "run_terminal_command":
+ return ALLOW_EXIT
+ tool_input = event.get("toolInput")
+ if not isinstance(tool_input, dict):
+ return ALLOW_EXIT
+ command = tool_input.get("command")
+ if not isinstance(command, str):
+ return ALLOW_EXIT
+
+ cwd = event.get("cwd")
+ reason = dispatch(command, cwd if isinstance(cwd, str) else None)
+ if reason:
+ json.dump({"decision": "deny", "reason": reason}, sys.stdout)
+ sys.stdout.write("\n")
+ return DENY_EXIT
+
+ return ALLOW_EXIT
+
+
+def grok_main() -> int:
+ """Translate Grok Build ``PreToolUse`` input to the shared guard core.
+
+ Verified with Grok Build 1.0.46 (2765805b9442). ``PreToolUse`` supplies
+ camelCase ``toolName`` / ``toolInput`` fields; the captured shell event uses
+ ``run_terminal_command`` and carries the command in ``toolInput.command``.
+ Relevant calls emit Grok's explicit JSON decision. Malformed or unrelated
+ events stay silent and fail open inside the adapter.
+ """
+ try:
+ event = json.load(sys.stdin)
+ except (json.JSONDecodeError, ValueError):
+ return ALLOW_EXIT
+ return _grok_event(event)
+
+
def check_main(argv: list[str]) -> int:
"""Harness-neutral check-only entry point (``--check``).
@@ -1672,11 +1722,15 @@ def exec_main(argv: list[str]) -> int:
def cli(argv: list[str] | None = None) -> int:
"""Route to the harness adapter named on the command line.
- No argument → the Claude Code ``PreToolUse`` hook (:func:`main`).
+ No argument → inspect the parsed ``PreToolUse`` payload shape: Grok's
+ camelCase ``toolName`` / ``toolInput`` routes to :func:`_grok_event`;
+ Claude Code's snake_case ``tool_name`` / ``tool_input`` routes to
+ :func:`_claude_event`.
``--opencode`` → the OpenCode adapter (:func:`opencode_main`).
``--kiro`` → the Kiro CLI adapter (:func:`kiro_main`).
``--gemini`` → the Gemini CLI adapter (:func:`gemini_main`).
``--copilot`` → the Copilot CLI adapter (:func:`copilot_main`).
+ ``--grok`` → the Grok Build adapter (:func:`grok_main`).
``--check <cmd…>`` → harness-neutral check-only (:func:`check_main`).
``--exec <cmd…>`` → harness-neutral check-then-exec (:func:`exec_main`).
@@ -1693,6 +1747,16 @@ def cli(argv: list[str] | None = None) -> int:
return gemini_main()
if args and args[0] == "--copilot":
return copilot_main()
+ if args and args[0] == "--grok":
+ return grok_main()
+ if not args:
+ try:
+ event = json.load(sys.stdin)
+ except (json.JSONDecodeError, ValueError):
+ return ALLOW_EXIT
+ if isinstance(event, dict) and event.get("toolName") == "run_terminal_command":
+ return _grok_event(event)
+ return _claude_event(event)
if args and args[0] == "--check":
return check_main(args[1:])
if args and args[0] == "--exec":
diff --git a/tools/agent-guard/tests/test_grok.py b/tools/agent-guard/tests/test_grok.py
new file mode 100644
index 00000000..ee4b8256
--- /dev/null
+++ b/tools/agent-guard/tests/test_grok.py
@@ -0,0 +1,285 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for Grok Build's PreToolUse adapter."""
+
+from __future__ import annotations
+
+import io
+import json
+import os
+import subprocess
+from pathlib import Path
+
+import pytest
+
+import agent_guard
+
+
+def _feed(monkeypatch: pytest.MonkeyPatch, payload: object) -> None:
+ text = payload if isinstance(payload, str) else json.dumps(payload)
+ monkeypatch.setattr("sys.stdin", io.StringIO(text))
+
+
+# Captured verbatim from Grok Build 1.0.46 (2765805b9442) on 2026-10-08.
+# Trigger: "Run pwd in the terminal and tell me the output."
+# No payload fields were normalized or reconstructed.
+_REAL_PRE_TOOL_USE = r"""{"hookEventName":"pre_tool_use","sessionId":"01a11c20-860b-7ea1-b883-16fdf069a238","cwd":"/private/tmp/magpie-grok-hook-capture","workspaceRoot":"/private/tmp/magpie-grok-hook-capture/","timestamp":"2026-10-08T15:27:41.895713+00:00","transcriptPath":"/Users/kavyakatal/.grok/sessions/%2Fprivate%2Ftmp%2Fmagpie-grok-hook-capture/01a11c20-860b-7ea1-b883-16fdf069a238/updates.jsonl","permissionMode":"default","toolName":"run_terminal_command","toolUseId":"call-7804e01f [...]
+
+
+def _captured_event(
+ *,
+ command: str = "pwd",
+ cwd: object = "/private/tmp/magpie-grok-hook-capture",
+) -> dict[str, object]:
+ """Return the captured event with only test-specific command/cwd changes."""
+ event = json.loads(_REAL_PRE_TOOL_USE)
+ event["toolInput"]["command"] = command
+ if cwd is None:
+ event.pop("cwd", None)
+ else:
+ event["cwd"] = cwd
+ return event
+
+
+def test_denied_command_emits_exact_grok_decision(
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ monkeypatch.setattr(
+ agent_guard,
+ "dispatch",
+ lambda command, cwd=None: "blocked by test",
+ )
+ _feed(monkeypatch, _captured_event(command="git push"))
+
+ assert agent_guard.grok_main() == agent_guard.DENY_EXIT
+
+ captured = capsys.readouterr()
+ assert captured.out == '{"decision": "deny", "reason": "blocked by test"}\n'
+ assert captured.err == ""
+
+
+def test_allowed_command_emits_exact_grok_decision(
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ monkeypatch.setattr(agent_guard, "dispatch", lambda command, cwd=None: None)
+ _feed(monkeypatch, _captured_event(command="git status"))
+
+ assert agent_guard.grok_main() == agent_guard.ALLOW_EXIT
+
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+
+def test_real_captured_pre_tool_use_payload_reaches_dispatch(monkeypatch):
+ """The verbatim Grok 1.0.46 capture reaches the shared dispatcher unchanged."""
+ seen = []
+
+ def fake_dispatch(command, cwd=None):
+ seen.append((command, cwd))
+ return None
+
+ monkeypatch.setattr("agent_guard.dispatch", fake_dispatch)
+ _feed(monkeypatch, _REAL_PRE_TOOL_USE)
+
+ assert agent_guard.grok_main() == agent_guard.ALLOW_EXIT
+ assert seen == [
+ ("pwd", "/private/tmp/magpie-grok-hook-capture"),
+ ]
+
+
+def test_captured_shell_tool_reaches_dispatch(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ seen: list[tuple[str, str | None]] = []
+
+ def fake_dispatch(command: str, cwd: str | None = None) -> str | None:
+ seen.append((command, cwd))
+ return None
+
+ monkeypatch.setattr(agent_guard, "dispatch", fake_dispatch)
+ _feed(
+ monkeypatch,
+ _captured_event(command="git status", cwd="/repo"),
+ )
+
+ assert agent_guard.grok_main() == agent_guard.ALLOW_EXIT
+ assert seen == [("git status", "/repo")]
+
+
+@pytest.mark.parametrize("cwd", [None, [], 42])
+def test_invalid_or_missing_cwd_forwards_none(
+ monkeypatch: pytest.MonkeyPatch,
+ cwd: object,
+) -> None:
+ seen: list[str | None] = []
+
+ def fake_dispatch(command: str, cwd: str | None = None) -> str | None:
+ seen.append(cwd)
+ return None
+
+ monkeypatch.setattr(agent_guard, "dispatch", fake_dispatch)
+
+ event = _captured_event(command="git status", cwd=cwd)
+
+ _feed(monkeypatch, event)
+
+ assert agent_guard.grok_main() == agent_guard.ALLOW_EXIT
+ assert seen == [None]
+
+
+@pytest.mark.parametrize(
+ "payload",
+ [
+ "not JSON",
+ "[]",
+ "null",
+ json.dumps(
+ {
+ "toolName": "read_file",
+ "toolInput": {"path": "README.md"},
+ }
+ ),
+ json.dumps({"toolName": "run_terminal_command"}),
+ json.dumps(
+ {
+ "toolName": "run_terminal_command",
+ "toolInput": [],
+ }
+ ),
+ json.dumps(
+ {
+ "toolName": "run_terminal_command",
+ "toolInput": {},
+ }
+ ),
+ json.dumps(
+ {
+ "toolName": "run_terminal_command",
+ "toolInput": {"command": None},
+ }
+ ),
+ json.dumps(
+ {
+ "toolName": "run_terminal_command",
+ "toolInput": {"command": 42},
+ }
+ ),
+ ],
+)
+def test_irrelevant_or_malformed_event_fails_open_silently(
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+ payload: str,
+) -> None:
+ def unexpected_dispatch(*args: object) -> None:
+ pytest.fail("Malformed or irrelevant Grok event reached dispatch")
+
+ monkeypatch.setattr(agent_guard, "dispatch", unexpected_dispatch)
+ _feed(monkeypatch, payload)
+
+ assert agent_guard.grok_main() == agent_guard.ALLOW_EXIT
+ assert capsys.readouterr() == ("", "")
+
+
+def test_cli_routes_grok_flag_to_adapter(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(agent_guard, "grok_main", lambda: 37)
+
+ assert agent_guard.cli(["--grok"]) == 37
+
+
+def test_default_invocation_routes_real_grok_payload_by_shape(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _feed(monkeypatch, _REAL_PRE_TOOL_USE)
+ monkeypatch.setattr(agent_guard, "_grok_event", lambda event: 41)
+ monkeypatch.setattr(
+ agent_guard,
+ "_claude_event",
+ lambda event: pytest.fail("Grok payload must not route to Claude"),
+ )
+
+ assert agent_guard.cli([]) == 41
+
+
+def test_default_invocation_routes_claude_payload_by_shape(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _feed(
+ monkeypatch,
+ json.dumps({"tool_name": "Bash", "tool_input": {"command": "git status"}}),
+ )
+ monkeypatch.setenv("GROK_HOOK_EVENT", "pre_tool_use")
+ monkeypatch.setattr(agent_guard, "_claude_event", lambda event: 43)
+ monkeypatch.setattr(
+ agent_guard,
+ "_grok_event",
+ lambda event: pytest.fail("Inherited GROK_HOOK_EVENT must not reroute a Claude payload"),
+ )
+
+ assert agent_guard.cli([]) == 43
+
+
+def test_project_hook_template_runs_grok_adapter_end_to_end() -> None:
+ """The setup template must invoke this adapter, not the tool command."""
+ repo_root = Path(agent_guard.__file__).resolve().parents[4]
+ hook_file = repo_root / "plugins" / "magpie-setup" / "templates" / "grok-agent-guard-hook.json"
+
+ config = json.loads(hook_file.read_text())
+ groups = config["hooks"]["PreToolUse"]
+
+ assert len(groups) == 1
+ assert groups[0]["matcher"] == "Bash"
+
+ handlers = groups[0]["hooks"]
+ assert len(handlers) == 1
+
+ handler = handlers[0]
+ assert handler["type"] == "command"
+ assert handler["timeout"] == 30
+ assert handler["command"].endswith('tools/agent-guard/src/agent_guard/__init__.py" --grok')
+
+ event = _captured_event(
+ command="git commit -m 'x\\n\\nCo-Authored-By: A <a...@b.c>'",
+ cwd=str(repo_root),
+ )
+
+ result = subprocess.run(
+ handler["command"],
+ shell=True,
+ cwd=repo_root,
+ env={
+ **os.environ,
+ "GROK_WORKSPACE_ROOT": str(repo_root),
+ },
+ input=json.dumps(event),
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+
+ assert result.returncode == agent_guard.DENY_EXIT
+ assert result.stderr == ""
+
+ response = json.loads(result.stdout)
+ assert response["decision"] == "deny"
+ assert "commit-trailer" in response["reason"]
diff --git a/tools/dev/check-family-plugins.py b/tools/dev/check-family-plugins.py
index 43fdec75..221690cc 100644
--- a/tools/dev/check-family-plugins.py
+++ b/tools/dev/check-family-plugins.py
@@ -246,23 +246,33 @@ SUBSTRATE_PLUGINS: dict[str, dict] = {
"that denies shell commands which would break a hard framework rule. Runs from "
"the installed plugin, so no repository or worktree needs a local copy."
),
- # <link path under the plugin root> -> <tools/ subdirectory it exposes>
- "links": {"tools/agent-guard": "agent-guard"},
- # Files that must resolve *through* those links for the hook to fire.
+ # Materialize the runtime package inside the plugin. Marketplace clients
+ # may drop symlinks whose targets escape the plugin root.
+ "copies": {
+ "tools/agent-guard/src/agent_guard": "tools/agent-guard/src/agent_guard",
+ },
+ "links": {},
+ # Files that must exist inside the generated plugin for the hook to fire.
"must_resolve": (AGENT_GUARD_ENGINE,),
- "hooks": {
- "PreToolUse": [
- {
- "matcher": "Bash",
- "hooks": [
- {
- "type": "command",
- "command": f'python3 "${{CLAUDE_PLUGIN_ROOT}}/{AGENT_GUARD_ENGINE}"',
- "timeout": 30,
- }
- ],
- }
- ]
+ "hook_config": {
+ "description": "Apache Magpie agent guard",
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ {
+ "type": "command",
+ "command": (
+ f'python3 "${{GROK_PLUGIN_ROOT:-${{CLAUDE_PLUGIN_ROOT}}}}/'
+ f'{AGENT_GUARD_ENGINE}"'
+ ),
+ "timeout": 30,
+ }
+ ],
+ }
+ ]
+ },
},
},
"magpie-vetted-ops": {
@@ -343,9 +353,9 @@ def substrate_manifest(name: str, shared: dict) -> dict:
truth `check` compares the on-disk file against."""
spec = SUBSTRATE_PLUGINS[name]
manifest = {"name": name, "description": spec["description"], **shared}
- # A substrate plugin exists to publish a tool from the installed plugin root.
- # Wiring a hook is one reason to need that, not the only one, so a spec
- # without `hooks` emits a manifest without the key rather than an empty one.
+ # Some substrate plugins still publish inline manifest hooks. Others, such
+ # as magpie-agent-guard, use hook_config so Claude and Grok share a single
+ # hooks/hooks.json registration source.
if "hooks" in spec:
manifest["hooks"] = spec["hooks"]
return manifest
@@ -393,10 +403,27 @@ def check_substrate(name: str, shared: dict) -> list[str]:
errors.append(f"{path}: missing/empty 'description'")
if "skills" in data:
errors.append(f"{path}: substrate plugins ship a tool, not skills — drop 'skills'")
- if data.get("hooks") != spec.get("hooks"):
+ hook_config = spec.get("hook_config")
+ if hook_config is not None and "hooks" in data:
+ errors.append(f"{path}: substrate hook wiring belongs in hooks/hooks.json, not the manifest")
+ elif hook_config is None and data.get("hooks") != spec.get("hooks"):
errors.append(
f"{path}: 'hooks' does not match the wiring the framework expects (regenerate with --fix)"
)
+
+ hook_path = pdir / "hooks" / "hooks.json"
+ if hook_config is not None:
+ if not hook_path.is_file():
+ errors.append(f"{hook_path}: missing substrate hook configuration")
+ else:
+ hook_data, hook_err = load_json(hook_path)
+ if hook_err:
+ errors.append(hook_err)
+ elif hook_data != hook_config:
+ errors.append(
+ f"{hook_path}: hook wiring does not match the framework expectation "
+ "(regenerate with --fix)"
+ )
for key, want in (shared or {}).items():
if data.get(key) != want:
errors.append(
@@ -411,13 +438,40 @@ def check_substrate(name: str, shared: dict) -> list[str]:
elif link.readlink() != want:
errors.append(f"{name}: {link} -> {link.readlink()} (expected {want})")
- # A manifest and a symlink that both look right still leave the hook dead if
+ for dest_rel, src_rel in spec.get("copies", {}).items():
+ dest = pdir / dest_rel
+ src = Path(src_rel)
+ if not dest.is_dir():
+ errors.append(f"{name}: {dest} is missing copied runtime tree")
+ elif not src.is_dir():
+ errors.append(f"{name}: source runtime tree {src} is missing")
+ else:
+ src_files = sorted(
+ path.relative_to(src)
+ for path in src.rglob("*")
+ if path.is_file() and "__pycache__" not in path.parts
+ )
+ dest_files = sorted(
+ path.relative_to(dest)
+ for path in dest.rglob("*")
+ if path.is_file() and "__pycache__" not in path.parts
+ )
+ if dest_files != src_files:
+ errors.append(f"{name}: copied runtime tree {dest} is out of sync (regenerate with --fix)")
+ else:
+ for rel in src_files:
+ if (dest / rel).read_bytes() != (src / rel).read_bytes():
+ errors.append(
+ f"{name}: copied runtime file {dest / rel} is out of sync (regenerate with --fix)"
+ )
+
+ # A manifest and its packaged runtime can both look right while the hook is dead if
# the file the command names is not reachable from the plugin root.
for rel in spec["must_resolve"]:
if not (pdir / rel).is_file():
consequence = (
- "the hook command names it, so the hook would silently never run"
- if "hooks" in spec
+ "the hook command names it, so the guard cannot execute"
+ if "hook_config" in spec
else "the tool's entry point names it, so every call would fail to start"
)
errors.append(f"{name}: {pdir / rel} does not resolve — {consequence}")
@@ -438,9 +492,47 @@ def write_substrate(name: str, shared: dict) -> None:
continue
link.unlink()
link.symlink_to(want)
+
+ for dest_rel, src_rel in spec.get("copies", {}).items():
+ dest_rel_path = Path(dest_rel)
+ dest = pdir / dest_rel_path
+ src = Path(src_rel)
+
+ # A previous substrate layout may have exposed this destination through
+ # a symlink whose target lives outside the plugin. Remove that ancestor
+ # symlink itself before touching any descendant, otherwise rmtree()
+ # would follow it and delete source files in the framework checkout.
+ for depth in range(1, len(dest_rel_path.parts)):
+ ancestor = pdir.joinpath(*dest_rel_path.parts[:depth])
+ if ancestor.is_symlink():
+ ancestor.unlink()
+ break
+
+ if dest.exists() or dest.is_symlink():
+ if dest.is_symlink() or dest.is_file():
+ dest.unlink()
+ else:
+ shutil.rmtree(dest)
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copytree(
+ src,
+ dest,
+ ignore=shutil.ignore_patterns(
+ "__pycache__",
+ "*.pyc",
+ ".pytest_cache",
+ ".ruff_cache",
+ ".mypy_cache",
+ ),
+ )
+
(pdir / ".claude-plugin" / "plugin.json").write_text(
json.dumps(substrate_manifest(name, shared), indent=2) + "\n", encoding="utf-8"
)
+ if hook_config := spec.get("hook_config"):
+ hook_dir = pdir / "hooks"
+ hook_dir.mkdir(parents=True, exist_ok=True)
+ (hook_dir / "hooks.json").write_text(json.dumps(hook_config, indent=2) + "\n", encoding="utf-8")
def pyproject_version() -> tuple[str | None, list[str]]:
@@ -872,19 +964,30 @@ def unowned_entries(pdir: Path) -> list[str]:
if not pdir.is_dir():
return []
mdir = pdir / ".claude-plugin"
- # A substrate plugin owns its tool-symlink parents instead of `skills/`.
+ # A substrate plugin owns its generated tool parents instead of `skills/`.
+ hook_dir = None
if spec := SUBSTRATE_PLUGINS.get(pdir.name):
owned = {mdir} | {pdir / Path(link).parts[0] for link in spec["links"]}
+ owned |= {pdir / Path(dest).parts[0] for dest in spec.get("copies", {})}
link_dirs = [pdir / Path(link).parent for link in spec["links"]]
+ if "hook_config" in spec:
+ hook_dir = pdir / "hooks"
+ owned.add(hook_dir)
else:
owned = {mdir, pdir / "skills"}
link_dirs = [pdir / "skills"]
+
unexpected = sorted(p for p in pdir.iterdir() if p not in owned)
+
if mdir.is_dir():
unexpected += sorted(p for p in mdir.iterdir() if p.name != "plugin.json")
+
for sdir in link_dirs:
if sdir.is_dir():
unexpected += sorted(p for p in sdir.iterdir() if not p.is_symlink())
+
+ if hook_dir is not None and hook_dir.is_dir():
+ unexpected += sorted(p for p in hook_dir.iterdir() if p.name != "hooks.json")
if not unexpected:
return []
return [
diff --git a/tools/dev/tests/test_check_family_plugins.py b/tools/dev/tests/test_check_family_plugins.py
index 2cf018ba..1dbfb921 100644
--- a/tools/dev/tests/test_check_family_plugins.py
+++ b/tools/dev/tests/test_check_family_plugins.py
@@ -77,6 +77,10 @@ def _manifest(tree: Path) -> Path:
return tree / "plugins" / NAME / ".claude-plugin" / "plugin.json"
+def _hooks(tree: Path) -> Path:
+ return tree / "plugins" / NAME / "hooks" / "hooks.json"
+
+
def _rewrite(path: Path, **changes) -> None:
data = json.loads(path.read_text(encoding="utf-8"))
data.update(changes)
@@ -87,18 +91,31 @@ def test_generated_substrate_plugin_passes_its_own_check(tree):
assert mod.check_substrate(NAME, SHARED) == []
-def test_generated_manifest_wires_the_engine_under_the_plugin_root(tree):
+def test_generated_hook_config_wires_the_engine_under_the_plugin_root(tree):
manifest = json.loads(_manifest(tree).read_text(encoding="utf-8"))
- command = manifest["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
- assert "${CLAUDE_PLUGIN_ROOT}" in command
+ assert "hooks" not in manifest
+
+ hooks = json.loads(_hooks(tree).read_text(encoding="utf-8"))
+ command = hooks["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
+ assert "${GROK_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}" in command
assert mod.AGENT_GUARD_ENGINE in command
- # The path the command names must be reachable through the generated symlink.
+ # The path the command names must exist inside the generated plugin.
assert (tree / "plugins" / NAME / mod.AGENT_GUARD_ENGINE).is_file()
def test_altered_hook_wiring_is_reported(tree):
+ _rewrite(_hooks(tree), hooks={"PreToolUse": []})
+ assert any("hook wiring does not match" in e for e in mod.check_substrate(NAME, SHARED))
+
+
+def test_missing_hook_config_is_reported(tree):
+ _hooks(tree).unlink()
+ assert any("missing substrate hook configuration" in e for e in mod.check_substrate(NAME, SHARED))
+
+
+def test_inline_manifest_hook_is_reported(tree):
_rewrite(_manifest(tree), hooks={"PreToolUse": []})
- assert any("'hooks' does not match" in e for e in mod.check_substrate(NAME, SHARED))
+ assert any("belongs in hooks/hooks.json" in e for e in mod.check_substrate(NAME, SHARED))
def test_a_substrate_plugin_declaring_skills_is_reported(tree):
@@ -111,24 +128,24 @@ def test_metadata_not_inherited_from_the_root_manifest_is_reported(tree):
assert any("'version' is '0.0.1'" in e for e in mod.check_substrate(NAME, SHARED))
-def test_a_repointed_tool_symlink_is_reported(tree):
- link = tree / "plugins" / NAME / "tools" / "agent-guard"
- link.unlink()
- link.symlink_to("../../../tools/something-else")
- assert any("expected ../../../tools/agent-guard" in e for e in mod.check_substrate(NAME, SHARED))
+def test_altered_copied_runtime_is_reported(tree):
+ runtime = tree / "plugins" / NAME / "tools" / "agent-guard" / "src" / "agent_guard" / "__init__.py"
+ runtime.write_text("# altered\n", encoding="utf-8")
+ assert any("copied runtime file" in e and "out of sync" in e for e in mod.check_substrate(NAME, SHARED))
-def test_a_symlink_replaced_by_a_real_directory_is_reported(tree):
- link = tree / "plugins" / NAME / "tools" / "agent-guard"
- link.unlink()
- link.mkdir()
- assert any("not a symlink" in e for e in mod.check_substrate(NAME, SHARED))
+def test_missing_copied_runtime_is_reported(tree):
+ import shutil
+
+ runtime = tree / "plugins" / NAME / "tools" / "agent-guard" / "src" / "agent_guard"
+ shutil.rmtree(runtime)
+ assert any("missing copied runtime tree" in e for e in mod.check_substrate(NAME, SHARED))
def test_an_engine_that_does_not_resolve_is_reported(tree):
- """The manifest and the symlink can both be right while the file the hook
- command names has moved — the case where the guard silently never runs."""
- (tree / "tools" / "agent-guard" / "src" / "agent_guard" / "__init__.py").unlink()
+ """The generated plugin can be wired correctly while the packaged engine
+ named by the hook is missing."""
+ (tree / "plugins" / NAME / mod.AGENT_GUARD_ENGINE).unlink()
assert any("does not resolve" in e for e in mod.check_substrate(NAME, SHARED))
@@ -188,7 +205,9 @@ def test_fix_regenerates_a_deleted_substrate_plugin(tree, monkeypatch):
shutil.rmtree(tree / "plugins" / NAME)
mod.write_substrate(NAME, SHARED)
assert mod.check_substrate(NAME, SHARED) == []
- assert os.path.islink(tree / "plugins" / NAME / "tools" / "agent-guard")
+ runtime = tree / "plugins" / NAME / "tools" / "agent-guard" / "src" / "agent_guard" / "__init__.py"
+ assert runtime.is_file()
+ assert not os.path.islink(tree / "plugins" / NAME / "tools" / "agent-guard")
# ---------------------------------------------------------------------------
diff --git a/tools/spec-loop/specs/agent-isolation-sandbox.md b/tools/spec-loop/specs/agent-isolation-sandbox.md
index 6052c25b..1031385b 100644
--- a/tools/spec-loop/specs/agent-isolation-sandbox.md
+++ b/tools/spec-loop/specs/agent-isolation-sandbox.md
@@ -144,7 +144,8 @@ existing sandbox grants can widen the baseline. See `docs/adapters/gemini.md`.
(`stdlib`-only). Wired as a `PreToolUse` hook (Claude Code) or a
`tool.execute.before` plugin (OpenCode), with a `--gemini` adapter for
Gemini CLI's `BeforeTool` event (wired in the repository's
- `.gemini/settings.json`; registration for snapshot adopters); inspects every shell command
+ `.gemini/settings.json`; registration for snapshot adopters) and a `--grok`
+ adapter for Grok Build's `PreToolUse` event; inspects every shell command
before it runs and denies the ones that break a hard framework rule,
independent of model memory. The guard decisions live in a single
harness-agnostic `dispatch()` core so every wired harness enforces
diff --git a/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py b/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py
index fbb84294..f9255180 100644
--- a/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py
+++ b/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py
@@ -203,6 +203,7 @@ HARNESS_VOCAB = {
"Copilot",
"OpenCode",
"Kiro",
+ "Grok",
}
AGNOSTIC_HARNESS = "agnostic"
_HARNESS_RE = re.compile(r"^\*\*Harness:\*\*[ \t]+(.+?)[ \t]*$", re.MULTILINE)
diff --git a/tools/vendor-neutrality-score/tests/test_vendor_neutrality_score.py b/tools/vendor-neutrality-score/tests/test_vendor_neutrality_score.py
index e69b5b95..de9e15b5 100644
--- a/tools/vendor-neutrality-score/tests/test_vendor_neutrality_score.py
+++ b/tools/vendor-neutrality-score/tests/test_vendor_neutrality_score.py
@@ -333,13 +333,13 @@ def _write_substrate(root, name: str, capability: str, harness: str) -> None:
def test_harness_verdicts(tmp_path) -> None:
_write_substrate(tmp_path, "guard", "substrate:action-guard", "Claude Code")
- _write_substrate(tmp_path, "multi", "substrate:sandbox", "Claude Code, Codex")
+ _write_substrate(tmp_path, "multi", "substrate:sandbox", "Claude Code, Codex, Grok")
_write_substrate(tmp_path, "checker", "substrate:framework-dev", "agnostic")
by_tool = {r.tool: r for r in vns.load_substrate_harnesses(tmp_path)}
assert by_tool["guard"].verdict == "coupled" # single harness
assert by_tool["multi"].verdict == "portable" # two harnesses
assert by_tool["checker"].verdict == "agnostic" # no harness dependency
- assert by_tool["multi"].harnesses == ("Claude Code", "Codex")
+ assert by_tool["multi"].harnesses == ("Claude Code", "Codex", "Grok")
def test_harness_missing_field_raises(tmp_path) -> None: