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/16 07:12:35 UTC
(magpie) branch main updated: Add JetBrains Junie harness support (agent-guard --junie adapter, adapter guide) (#1666)
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 6c24f30c Add JetBrains Junie harness support (agent-guard --junie adapter, adapter guide) (#1666)
6c24f30c is described below
commit 6c24f30cbca684234fd6caa028bc7e4ede908b90
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Fri Oct 16 08:12:30 2026 +0100
Add JetBrains Junie harness support (agent-guard --junie adapter, adapter guide) (#1666)
Generated-by: GitHub Copilot (Claude Sonnet 5.5)
---
CONTRIBUTING.md | 6 +-
docs/adapters/README.md | 2 +
docs/adapters/junie.md | 104 +++++++++++++++++++++
docs/adapters/registry.md | 2 +-
docs/setup/marketplace.md | 8 +-
docs/vendor-neutrality.md | 4 +-
.../tools/agent-guard/src/agent_guard/__init__.py | 44 +++++++++
plugins/magpie-setup/skills/setup/agents.md | 2 +-
.../setup/setup_preflight/isolated_fingerprint.py | 2 +-
tools/agent-guard/README.md | 17 +++-
tools/agent-guard/src/agent_guard/__init__.py | 44 +++++++++
tools/agent-guard/tests/test_junie.py | 104 +++++++++++++++++++++
.../src/vendor_neutrality_score/__init__.py | 1 +
13 files changed, 326 insertions(+), 14 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a64afb8d..9bb4e165 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -399,13 +399,13 @@ authoritative agent harness matrix. The current state per harness:
| GitHub Copilot CLI + Coding Agent | Standalone CLI and server-side Coding Agent; Draft PR boundary and clean-env isolation — see the [harness guide](docs/adapters/copilot.md) | [#318](https://github.com/apache/magpie/issues/318) |
| Goose (Block) | Open-source agent CLI; reads `.agents/skills/` natively, developer tool execution, and recipe workflows — see the [harness guide](docs/adapters/goose.md) | [#319](https://github.com/apache/magpie/issues/319) |
| Amazon Q Developer CLI | Not yet ported | [#320](https://github.com/apache/magpie/issues/320) |
-| JetBrains Junie | Not yet ported | [#321](https://github.com/apache/magpie/issues/321) |
+| JetBrains Junie (CLI + IDE plugin) | Reads `.agents/skills/` and `AGENTS.md` natively; `PreToolUse` action guard (hooks are Early Access) — see the [harness guide](docs/adapters/junie.md) | [#321](https://github.com/apache/magpie/issues/321) |
| OpenHands | Not yet ported | [#322](https://github.com/apache/magpie/issues/322) |
The table lists **harnesses** — the agent that reads the skills — not the
editors they run in. A JetBrains IDE, for instance, hosts an agent rather than
-being one: `JetBrains Junie` above is JetBrains' *own* agent and is genuinely
-not ported, but running Claude Code inside IntelliJ IDEA or PyCharm is the
+being one: `JetBrains Junie` above is JetBrains' *own* agent, ported separately from
+running Claude Code inside IntelliJ IDEA or PyCharm is the
fully-supported Claude Code row, with nothing extra to install. See
[the JetBrains section of `marketplace.md`](docs/setup/marketplace.md#jetbrains-ides-intellij-idea-pycharm-goland-).
diff --git a/docs/adapters/README.md b/docs/adapters/README.md
index 98cf1cea..694f9c69 100644
--- a/docs/adapters/README.md
+++ b/docs/adapters/README.md
@@ -35,6 +35,7 @@ One page per supported agentic harness, each declaring
- [**Gemini CLI**](gemini.md) — extension install, `BeforeTool` guard, tool
sandboxing and policies. Experimental.
- [**Grok Build**](grok.md) — `PreToolUse` action-guard integration.
+- [**JetBrains Junie**](junie.md) — native `.agents/skills/` and `AGENTS.md`, plus an Early Access `PreToolUse` action guard.
- [**Goose**](goose.md) — Block's open-source agent CLI and desktop environment.
- [**Kiro CLI**](kiro.md) — per-skill installs, no marketplace; guard on
`preToolUse`.
@@ -54,6 +55,7 @@ reaches six of ten.
| **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` |
+| **JetBrains Junie** | `junie --sandbox` (srt; MCP and hooks unsandboxed) | `/brave` levels and `~/.junie/allowlist.json` | ✅ `PreToolUse` (Early Access) |
| **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** |
diff --git a/docs/adapters/junie.md b/docs/adapters/junie.md
new file mode 100644
index 00000000..9f47d480
--- /dev/null
+++ b/docs/adapters/junie.md
@@ -0,0 +1,104 @@
+<!-- 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)*
+
+- [JetBrains Junie](#jetbrains-junie)
+ - [Harness contract](#harness-contract)
+ - [Installation](#installation)
+ - [Action guard](#action-guard)
+ - [Verify](#verify)
+ - [Known limitations](#known-limitations)
+ - [Upstream references](#upstream-references)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# JetBrains Junie
+
+**Capability:** platform:harness
+
+Junie is JetBrains' own coding agent, available as a CLI and as a JetBrains IDE plugin.
+It is a different harness from Claude Code running inside a JetBrains IDE, which needs nothing extra (see [`marketplace.md`](../setup/marketplace.md#jetbrains-ides-intellij-idea-pycharm-goland-)).
+Tracking issue: [#321](https://github.com/apache/magpie/issues/321).
+
+## Harness contract
+
+| Surface | Junie contract | Magpie integration |
+|---|---|---|
+| Skills | Open Agent Skills format; reads `<project>/.agents/skills/` (trusted projects), `~/.agents/skills/`, `<project>/.junie/skills/`, `~/.junie/skills/` | The `universal` registry row; no Junie-specific relay |
+| Invoke a skill | Automatically, as `/<skill-name>`, or as `$<skill-name>` | Same skill files as every other harness |
+| Instructions | `AGENTS.md` in the project root, `.junie/rules/*.md`, `~/.junie/AGENTS.md` | `AGENTS.md` is read as is |
+| MCP | `.junie/mcp/mcp.json` (project) with the usual `mcpServers` shape | Adopter-owned; Magpie writes none |
+| Action guard | `PreToolUse` hook in `~/.junie/config.json` (Early Access) | `agent-guard --junie` |
+| Sandbox | `junie --sandbox` (sandbox-runtime, macOS and Linux) | Not managed by Magpie |
+| Approvals | `/brave` levels and `~/.junie/allowlist.json` | Not managed by Magpie |
+
+## Installation
+
+Install an adopted project with the `universal` target, which is the default.
+Junie loads `.agents/skills/` only in a trusted project; trust it from Junie itself, Magpie never grants it.
+
+## Action guard
+
+Junie's hook payload is Claude-shaped (`tool_name`, `tool_input.command`) but its block semantics differ.
+Junie honours exit code 2 and a top-level `decision`, and ignores Claude's `hookSpecificOutput`.
+The no-argument `agent-guard` would route the payload to the Claude adapter, exit 0, and let every command through.
+Hooks must therefore pass `--junie`.
+
+Add to `~/.junie/config.json` (or a file passed with `--config-location`):
+
+```json
+{
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 <framework>/tools/agent-guard/src/agent_guard/__init__.py --junie",
+ "timeout": 30
+ }
+ ]
+ }
+ ]
+ }
+}
+```
+
+Replace `<framework>` with the absolute path of the checkout, or of `.apache-magpie/` for a snapshot adopter.
+Junie hooks live in user configuration, so Magpie does not write the file; the adopter adds the entry.
+
+The adapter blocks with exit 2, the denial reason on stderr, and a `{"decision": "deny", "reason": ...}` object on stdout.
+Malformed events, other tools, and allowed commands exit 0 silently.
+Other non-zero exits only warn in Junie and let the tool run, so the adapter never uses them.
+
+## Verify
+
+1. Confirm the hook is listed in `~/.junie/config.json` and the command path exists.
+2. Run `git status` through Junie and confirm it runs.
+3. Run `git commit --no-verify --dry-run` and confirm Junie reports a block from `agent-guard[no-verify]`.
+4. Run `echo '{"tool_name":"Bash","tool_input":{"command":"git commit --no-verify"}}' | python3 <framework>/tools/agent-guard/src/agent_guard/__init__.py --junie; echo $?` and expect exit 2.
+
+A static check of the config does not replace the live denial probe.
+
+## Known limitations
+
+- Hooks are in Junie's Early Access Program; the schema may change, so re-verify after upgrades.
+- The guard covers shell commands only.
+ Native file tools, MCP servers, and hooks themselves are outside it, and outside `junie --sandbox` too.
+- The sandbox policy is the global `~/.junie/sandbox.json`; a project-local one is ignored, and network is unrestricted unless `allowedDomains` is set.
+- The payload shape was taken from the Junie documentation, not a captured live event.
+ Capture one and add it to `tools/agent-guard/tests/test_junie.py` when a build is available.
+- There is no spec-loop runner profile for Junie yet.
+
+## Upstream references
+
+- Junie skills: https://junie.jetbrains.com/docs/agent-skills.html
+- Junie hooks: https://junie.jetbrains.com/docs/junie-cli-hooks.html
+- Junie sandbox: https://junie.jetbrains.com/docs/junie-cli-sandbox.html
diff --git a/docs/adapters/registry.md b/docs/adapters/registry.md
index 0be51837..6c28f40a 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 | 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.md b/docs/setup/marketplace.md
index 152b4ad5..9738491d 100644
--- a/docs/setup/marketplace.md
+++ b/docs/setup/marketplace.md
@@ -473,11 +473,9 @@ elsewhere is a separate project and installs separately.
> [!NOTE]
> This is about running *Claude Code* (or another agent with a JetBrains
> plugin) inside a JetBrains IDE. **JetBrains' own agent, Junie, is a separate
-> harness port** — tracked as
-> [#321](https://github.com/apache/magpie/issues/321) and listed *Not yet
-> ported* in [`CONTRIBUTING.md`](../../CONTRIBUTING.md) and
-> [`vendor-neutrality.md`](../vendor-neutrality.md). Junie does not read
-> Magpie's skills today.
+> harness** — see the [Junie guide](../adapters/junie.md)
+> ([#321](https://github.com/apache/magpie/issues/321)). It reads the
+> canonical `.agents/skills/` path, so no marketplace plugin is involved.
### Not supported
diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md
index 39730a6e..98102043 100644
--- a/docs/vendor-neutrality.md
+++ b/docs/vendor-neutrality.md
@@ -348,7 +348,6 @@ Draft PR review gating; see the
The remaining extension points are
already open, labelled `good first issue`:
[Amazon Q](https://github.com/apache/magpie/issues/320),
-[JetBrains Junie](https://github.com/apache/magpie/issues/321),
[OpenHands](https://github.com/apache/magpie/issues/322).
The step-by-step wiring recipe for any new runtime is
[`docs/adapters/add-a-harness.md`](adapters/add-a-harness.md).
@@ -620,7 +619,7 @@ Organization scope (declared, orthogonal to vendor): ASF = 16, agnostic = 64.
| Substrate tool | Substrate | Harness support | Verdict |
|---|---|---|---|
| `adversarial-review` | review | any | ✅ agnostic |
-| `agent-guard` | action-guard | Claude Code, Codex, Copilot, Gemini CLI, Grok, Kiro, OpenCode | ✅ portable |
+| `agent-guard` | action-guard | Claude Code, Codex, Copilot, Gemini CLI, Grok, Junie, Kiro, OpenCode | ✅ portable |
| `agent-isolation` | sandbox | any | ✅ agnostic |
| `container-gateway` | sandbox | any | ✅ agnostic |
| `contributor-metrics` | analytics | any | ✅ agnostic |
@@ -660,6 +659,7 @@ Harness → substrate tools it supports:
- **Cursor** (2): `sandbox-lint`, `spec-loop`
- **Gemini CLI** (3): `agent-guard`, `sandbox-lint`, `spec-loop`
- **Grok** (3): `agent-guard`, `sandbox-lint`, `spec-loop`
+- **Junie** (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/tools/agent-guard/src/agent_guard/__init__.py b/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__init__.py
index 555b41bb..05cb7691 100644
--- a/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__init__.py
+++ b/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__init__.py
@@ -35,6 +35,8 @@ pre-tool hook to/from the core, so every wired harness enforces one rule set:
* :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:`junie_main` — Junie CLI ``PreToolUse`` hook (``--junie``): reads the
+ snake_case event and blocks with a ``decision`` JSON and exit 2.
* :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
@@ -1633,6 +1635,45 @@ def grok_main() -> int:
return _grok_event(event)
+def _junie_event(event: object) -> int:
+ """Handle one parsed Junie CLI ``PreToolUse`` event."""
+ if not isinstance(event, dict) or event.get("tool_name") != "Bash":
+ return ALLOW_EXIT
+ tool_input = event.get("tool_input")
+ 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")
+ sys.stderr.write(reason + "\n")
+ return DENY_EXIT
+
+ return ALLOW_EXIT
+
+
+def junie_main() -> int:
+ """Translate Junie CLI ``PreToolUse`` input to the shared guard core.
+
+ Junie sends snake_case ``tool_name`` / ``tool_input`` like Claude Code but
+ does not honour Claude's ``hookSpecificOutput`` deny: it reads a top-level
+ ``decision`` and blocks unconditionally on exit code 2. The adapter emits
+ both, so the block holds whichever the runtime checks first. It is selected
+ explicitly with ``--junie`` because the no-argument shape sniff would route
+ this payload to the Claude adapter and fail open.
+ """
+ try:
+ event = json.load(sys.stdin)
+ except (json.JSONDecodeError, ValueError):
+ return ALLOW_EXIT
+ return _junie_event(event)
+
+
def check_main(argv: list[str]) -> int:
"""Harness-neutral check-only entry point (``--check``).
@@ -1758,6 +1799,7 @@ def cli(argv: list[str] | None = None) -> int:
``--copilot`` → the Copilot CLI adapter (:func:`copilot_main`).
``--codex`` → the Codex native hook adapter (:func:`codex_main`).
``--grok`` → the Grok Build adapter (:func:`grok_main`).
+ ``--junie`` → the Junie CLI adapter (:func:`junie_main`).
``--check <cmd…>`` → harness-neutral check-only (:func:`check_main`).
``--exec <cmd…>`` → harness-neutral check-then-exec (:func:`exec_main`).
@@ -1778,6 +1820,8 @@ def cli(argv: list[str] | None = None) -> int:
return copilot_main()
if args and args[0] == "--grok":
return grok_main()
+ if args and args[0] == "--junie":
+ return junie_main()
if not args:
try:
event = json.load(sys.stdin)
diff --git a/plugins/magpie-setup/skills/setup/agents.md b/plugins/magpie-setup/skills/setup/agents.md
index 2dfd4221..a8fc67e2 100644
--- a/plugins/magpie-setup/skills/setup/agents.md
+++ b/plugins/magpie-setup/skills/setup/agents.md
@@ -22,7 +22,7 @@ go"*, never as *"what files contain"*.
| Target id | Project skills dir | Kind | Reads it |
|---|---|---|---|
-| `universal` | `.agents/skills/` | universal **(canonical)** | Codex, Cursor, Gemini CLI, GitHub Copilot, Goose, OpenCode, Cline, Zed, Warp, Amp, and the rest of the cluster that converged on the shared path |
+| `universal` | `.agents/skills/` | universal **(canonical)** | Codex, Cursor, Gemini CLI, GitHub Copilot, Goose, Junie, OpenCode, Cline, Zed, Warp, Amp, and the rest of the cluster that converged on the shared path |
| `claude-code` | `.claude/skills/` | native (relay) | Claude Code |
| `github` | `.github/skills/` | native (relay) | GitHub's skill loader |
| `windsurf` | `.windsurf/skills/` | native (relay) | Windsurf |
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 402e0c84..5ef2c1f3 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:62a4cc7092dfcad7"
+FRAMEWORK_FINGERPRINT = "sha256:b510123a49eb72f3"
diff --git a/tools/agent-guard/README.md b/tools/agent-guard/README.md
index 3849c039..8b2123b7 100644
--- a/tools/agent-guard/README.md
+++ b/tools/agent-guard/README.md
@@ -18,6 +18,7 @@
- [Gemini CLI](#gemini-cli)
- [Copilot CLI](#copilot-cli)
- [Grok](#grok)
+ - [Junie CLI](#junie-cli)
- [Harness-neutral path (any runtime)](#harness-neutral-path-any-runtime)
- [Contributing guards](#contributing-guards)
- [Tests](#tests)
@@ -31,7 +32,7 @@
**Capability:** substrate:action-guard
-**Harness:** Claude Code, Codex, OpenCode, Kiro, Gemini CLI, Copilot, Grok
+**Harness:** Claude Code, Codex, OpenCode, Kiro, Gemini CLI, Copilot, Grok, Junie
A deterministic pre-execution guard dispatcher. It inspects every shell command
**before it runs** and **denies** the ones that would break a hard framework
@@ -60,6 +61,9 @@ so every wired harness enforces an identical rule set from one source of truth:
Source/snapshot project hooks use `--grok`; marketplace plugin hooks are
selected automatically from Grok's incoming hook payload shape.
See [Grok](#grok).
+- **Junie CLI** — a `PreToolUse` hook (Early Access) on the `Bash` matcher,
+ using `--junie` (exit `2` plus a `decision` JSON and a stderr reason).
+ See [Junie CLI](#junie-cli).
- **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).
@@ -409,6 +413,17 @@ together.
See the [Grok adapter contract](../../docs/adapters/grok.md).
+### Junie CLI
+
+Junie's `PreToolUse` payload is Claude-shaped (`tool_name`, `tool_input.command`),
+so the no-argument dispatcher would route it to the Claude adapter,
+whose `hookSpecificOutput` deny Junie ignores and exit 0 would let the command run.
+Junie hooks must therefore invoke `--junie` explicitly.
+The adapter blocks with exit `2`, the one signal Junie honours regardless of output,
+and also prints a `decision` JSON and the reason on stderr.
+Other non-zero exits only warn in Junie, so the adapter never uses them.
+See [the Junie adapter](../../docs/adapters/junie.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 555b41bb..05cb7691 100644
--- a/tools/agent-guard/src/agent_guard/__init__.py
+++ b/tools/agent-guard/src/agent_guard/__init__.py
@@ -35,6 +35,8 @@ pre-tool hook to/from the core, so every wired harness enforces one rule set:
* :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:`junie_main` — Junie CLI ``PreToolUse`` hook (``--junie``): reads the
+ snake_case event and blocks with a ``decision`` JSON and exit 2.
* :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
@@ -1633,6 +1635,45 @@ def grok_main() -> int:
return _grok_event(event)
+def _junie_event(event: object) -> int:
+ """Handle one parsed Junie CLI ``PreToolUse`` event."""
+ if not isinstance(event, dict) or event.get("tool_name") != "Bash":
+ return ALLOW_EXIT
+ tool_input = event.get("tool_input")
+ 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")
+ sys.stderr.write(reason + "\n")
+ return DENY_EXIT
+
+ return ALLOW_EXIT
+
+
+def junie_main() -> int:
+ """Translate Junie CLI ``PreToolUse`` input to the shared guard core.
+
+ Junie sends snake_case ``tool_name`` / ``tool_input`` like Claude Code but
+ does not honour Claude's ``hookSpecificOutput`` deny: it reads a top-level
+ ``decision`` and blocks unconditionally on exit code 2. The adapter emits
+ both, so the block holds whichever the runtime checks first. It is selected
+ explicitly with ``--junie`` because the no-argument shape sniff would route
+ this payload to the Claude adapter and fail open.
+ """
+ try:
+ event = json.load(sys.stdin)
+ except (json.JSONDecodeError, ValueError):
+ return ALLOW_EXIT
+ return _junie_event(event)
+
+
def check_main(argv: list[str]) -> int:
"""Harness-neutral check-only entry point (``--check``).
@@ -1758,6 +1799,7 @@ def cli(argv: list[str] | None = None) -> int:
``--copilot`` → the Copilot CLI adapter (:func:`copilot_main`).
``--codex`` → the Codex native hook adapter (:func:`codex_main`).
``--grok`` → the Grok Build adapter (:func:`grok_main`).
+ ``--junie`` → the Junie CLI adapter (:func:`junie_main`).
``--check <cmd…>`` → harness-neutral check-only (:func:`check_main`).
``--exec <cmd…>`` → harness-neutral check-then-exec (:func:`exec_main`).
@@ -1778,6 +1820,8 @@ def cli(argv: list[str] | None = None) -> int:
return copilot_main()
if args and args[0] == "--grok":
return grok_main()
+ if args and args[0] == "--junie":
+ return junie_main()
if not args:
try:
event = json.load(sys.stdin)
diff --git a/tools/agent-guard/tests/test_junie.py b/tools/agent-guard/tests/test_junie.py
new file mode 100644
index 00000000..388106ae
--- /dev/null
+++ b/tools/agent-guard/tests/test_junie.py
@@ -0,0 +1,104 @@
+# 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 the Junie CLI PreToolUse adapter."""
+
+from __future__ import annotations
+
+import io
+import json
+
+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))
+
+
+def _event(command: object = "git status", tool: str = "Bash") -> dict[str, object]:
+ return {
+ "hook_event_name": "PreToolUse",
+ "tool_name": tool,
+ "tool_input": {"command": command, "run_in_background": False, "timeout": 30},
+ }
+
+
+def test_denied_command_exits_2_with_reason_on_stderr_and_decision(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+) -> None:
+ monkeypatch.setattr(agent_guard, "dispatch", lambda command, cwd=None: "blocked by test")
+ _feed(monkeypatch, _event("git push"))
+
+ assert agent_guard.junie_main() == agent_guard.DENY_EXIT == 2
+
+ captured = capsys.readouterr()
+ assert json.loads(captured.out) == {"decision": "deny", "reason": "blocked by test"}
+ assert captured.err == "blocked by test\n"
+
+
+def test_allowed_command_is_silent(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+) -> None:
+ monkeypatch.setattr(agent_guard, "dispatch", lambda command, cwd=None: None)
+ _feed(monkeypatch, _event())
+
+ assert agent_guard.junie_main() == agent_guard.ALLOW_EXIT
+
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+
+def test_cwd_is_forwarded_when_a_string(monkeypatch: pytest.MonkeyPatch) -> None:
+ seen: list[object] = []
+ monkeypatch.setattr(agent_guard, "dispatch", lambda command, cwd=None: seen.append(cwd))
+ event = _event()
+ event["cwd"] = "/work"
+ _feed(monkeypatch, event)
+
+ agent_guard.junie_main()
+
+ assert seen == ["/work"]
+
+
+@pytest.mark.parametrize(
+ "payload",
+ [
+ "not json",
+ "[]",
+ _event(tool="Read"),
+ {"tool_name": "Bash"},
+ {"tool_name": "Bash", "tool_input": {"command": 5}},
+ ],
+)
+def test_malformed_or_unrelated_input_fails_open(monkeypatch: pytest.MonkeyPatch, payload: object) -> None:
+ def _boom(command: str, cwd: str | None = None) -> str:
+ raise AssertionError("dispatch must not run")
+
+ monkeypatch.setattr(agent_guard, "dispatch", _boom)
+ _feed(monkeypatch, payload)
+
+ assert agent_guard.junie_main() == agent_guard.ALLOW_EXIT
+
+
+def test_cli_routes_junie_flag(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(agent_guard, "dispatch", lambda command, cwd=None: "no")
+ _feed(monkeypatch, _event("git push"))
+
+ assert agent_guard.cli(["--junie"]) == agent_guard.DENY_EXIT
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 60b5a8e2..a0c66b81 100644
--- a/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py
+++ b/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py
@@ -208,6 +208,7 @@ HARNESS_VOCAB = {
"OpenCode",
"Kiro",
"Grok",
+ "Junie",
}
AGNOSTIC_HARNESS = "agnostic"
_HARNESS_RE = re.compile(r"^\*\*Harness:\*\*[ \t]+(.+?)[ \t]*$", re.MULTILINE)