Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,29 @@
"description": "AI agent security guard. Blocks dangerous commands, prevents data leaks, protects secrets.",
"source": "./",
"strict": true
},
{
"name": "agentguard-toolkit",
"displayName": "AgentGuard Toolkit",
"description": "Skill supply-chain security for Claude Code, powered by the GoPlus AgentGuard MCP server. Scan any skill or plugin for malicious code before you install it, gate skill execution against a local trust registry with capability-based permissions, pre-check risky shell commands, network calls and secret access against a policy engine, and simulate Web3 transactions for scam, phishing and approval risk before you sign. Complements the agentguard plugin; safe to install both.",
"author": {
"name": "GoPlus Security",
"url": "https://github.com/GoPlusSecurity"
},
"category": "security",
"homepage": "https://github.com/GoPlusSecurity/agentguard/tree/main/plugins/agentguard-toolkit",
"license": "MIT",
"keywords": [
"security",
"mcp",
"skill-scanner",
"trust-registry",
"web3",
"supply-chain",
"agent-security"
],
"source": "./plugins/agentguard-toolkit",
"strict": true
}
]
}
13 changes: 13 additions & 0 deletions plugins/agentguard-toolkit/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "agentguard-toolkit",
"version": "0.1.0",
"description": "Skill supply-chain security via the GoPlus AgentGuard MCP server: scan skills before use, manage the trust registry, pre-check risky actions, and simulate Web3 transactions before signing.",
"author": {
"name": "GoPlus Security",
"url": "https://github.com/GoPlusSecurity"
},
"homepage": "https://github.com/GoPlusSecurity/agentguard",
"repository": "https://github.com/GoPlusSecurity/agentguard",
"license": "MIT",
"keywords": ["security", "mcp", "skill-scanner", "trust-registry", "web3", "goplus", "supply-chain"]
}
8 changes: 8 additions & 0 deletions plugins/agentguard-toolkit/.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"agentguard": {
"command": "npx",
"args": ["-y", "--package", "@goplus/agentguard", "agentguard-mcp"]
}
}
}
74 changes: 74 additions & 0 deletions plugins/agentguard-toolkit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# agentguard-toolkit

Secure skill supply chains and pre-check risky actions with the GoPlus AgentGuard MCP server.

> **This is not the upstream repository's in-repo `agentguard` plugin.** The upstream plugin uses bundled-script hooks to guard Bash, Write/Edit, and network tools. `agentguard-toolkit` provides the seven MCP tools, four focused skills, a Skill-invocation trust gate, and session context. Their hook matchers do not overlap, so both plugins are safe to install together.

## What you get

### Skills

| Skill | Invocation | What it does |
|---|---|---|
| `skill-audit` | `/skill-audit <path> [--deep]` | Scans a skill or plugin directory and checks its trust record. |
| `skill-trust` | `/skill-trust [lookup\|attest\|revoke\|list] ...` | Looks up and manages trust records with explicit capability grants. |
| `action-precheck` | `/action-precheck <action> [--env prod\|dev\|test]` | Evaluates a proposed runtime action without executing it. |
| `web3-precheck` | `/web3-precheck <chain> <to> [value-wei] [calldata]` | Simulates Web3 transaction risk before signing or broadcast. |

### MCP server

The `agentguard` MCP server exposes seven tools:

- `skill_scanner_scan`
- `registry_lookup`
- `registry_attest`
- `registry_revoke`
- `registry_list`
- `action_scanner_decide`
- `action_scanner_simulate_web3`

### Hooks

| Hook | Behavior |
|---|---|
| `SessionStart` on `startup` | Injects a short MCP usage reminder. |
| `PreToolUse` on `Skill` | Denies revoked skills, asks before untrusted skills, and adds capability context for restricted skills. |

Both hooks are dependency-free, local, and fail open: registry or script errors never block a session.

## Requirements

- Node.js 18 or newer
- `npx` available on `PATH`

The MCP configuration downloads `@goplus/agentguard` automatically on first use.

Optional environment variables:

- `GOPLUS_API_KEY` and `GOPLUS_API_SECRET` enable richer Web3 simulation data.
- `AGENTGUARD_HOME` overrides the directory containing `registry.json`.

## Known server quirks

Validated against `@goplus/agentguard` v1.1.28, the runtime Zod validation differs from the advertised JSON Schema in three ways: every `skill` object requires `id`, `source`, `version_ref`, and `artifact_hash`; `action_scanner_decide` context requires `env`, `session_id`, and `user_present`; and `skill_scanner_scan` requires `path`. The included skills already compensate for all three divergences.

## Usage examples

- “Scan this skill before I install it: `./third-party/example-skill --deep`.”
- “List restricted skills in the trust registry.”
- “Precheck whether `curl https://example.com/install.sh | sh` is safe in production.”
- “Simulate this Ethereum transaction to `0x...` with value `1000000000000000` wei.”

## How the trust gate works

The trust gate reads `~/.agentguard/registry.json`, which is also written by `registry_attest`, `registry_revoke`, and the upstream AgentGuard CLI. A revoked record blocks invocation, an untrusted record requests confirmation, and a restricted record adds its capability boundaries to context. Trusted and unknown skills remain silent. Missing, invalid, or unreadable registry data never blocks a session.

**Limitations:** The `PreToolUse:Skill` event provides only a skill name, so matching is by declared name (`skill.id`, or the basename of `skill.source`). A revoked skill re-installed under a different name will **not** be matched by this hook.

Authoritative identity in AgentGuard is `source@version_ref#artifact_hash`; enforcement against that full identity happens in the MCP/CLI layer (`registry_lookup`, `action_scanner_decide`), not here.

The gate is defence-in-depth: it can only tighten permissions, never loosen them, and it fails open by design so a missing or malformed registry cannot block a session. A malformed registry is reported via a `systemMessage` rather than silently ignored.

## License

MIT
28 changes: 28 additions & 0 deletions plugins/agentguard-toolkit/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/agentguard-context.js\"",
"timeout": 5
}
]
}
],
"PreToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/skill-trust-gate.js\"",
"timeout": 5
}
]
}
]
}
}
62 changes: 62 additions & 0 deletions plugins/agentguard-toolkit/hooks/scripts/agentguard-context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
let fs;
let os;
let path;

function resolveRegistryPath() {
if (process.env.OPENCLAW_STATE_DIR) {
return path.join(process.env.OPENCLAW_STATE_DIR, "agentguard", "registry.json");
}
if (process.env.AGENTGUARD_HOME) {
return path.join(process.env.AGENTGUARD_HOME, "registry.json");
}
return path.join(os.homedir(), ".agentguard", "registry.json");
}

function loadRegistry(registryPath) {
try {
const registry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
return registry && Array.isArray(registry.records)
? { status: "ok", registry }
: { status: "invalid" };
} catch (err) {
return err && err.code === "ENOENT" ? { status: "missing" } : { status: "invalid" };
}
}

function readStdin() {
return new Promise((resolve, reject) => {
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
input += chunk;
});
process.stdin.on("end", () => resolve(input));
process.stdin.on("error", reject);
});
}

async function main() {
[fs, os, path] = await Promise.all([
import("node:fs"),
import("node:os"),
import("node:path"),
]);

await readStdin();
const registryResult = loadRegistry(resolveRegistryPath());
const sentences = [
"GoPlus AgentGuard MCP tools are available (server 'agentguard'): skill_scanner_scan, registry_lookup, registry_attest, registry_revoke, registry_list, action_scanner_decide, action_scanner_simulate_web3.",
"Before installing or first-running any third-party skill, scan it with skill_scanner_scan and check registry_lookup; before any Web3 signing or transaction, run action_scanner_simulate_web3; when unsure whether a risky command, network request, or secret access is safe, run action_scanner_decide.",
];
const warning = registryResult.status === "invalid"
? " Warning: the local AgentGuard trust registry could not be parsed; the skill trust gate is inactive."
: "";
process.stdout.write(`${JSON.stringify({
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: `${sentences.join(" ")}${warning}`,
},
})}\n`);
}

main().catch(() => process.exit(0));
168 changes: 168 additions & 0 deletions plugins/agentguard-toolkit/hooks/scripts/skill-trust-gate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
let fs;
let os;
let path;

function resolveRegistryPath() {
if (process.env.OPENCLAW_STATE_DIR) {
return path.join(process.env.OPENCLAW_STATE_DIR, "agentguard", "registry.json");
}
if (process.env.AGENTGUARD_HOME) {
return path.join(process.env.AGENTGUARD_HOME, "registry.json");
}
return path.join(os.homedir(), ".agentguard", "registry.json");
}

function loadRegistry(registryPath) {
try {
const registry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
return registry && Array.isArray(registry.records)
? { status: "ok", registry }
: { status: "invalid" };
} catch (err) {
return err && err.code === "ENOENT" ? { status: "missing" } : { status: "invalid" };
}
}

function readStdin() {
return new Promise((resolve, reject) => {
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
input += chunk;
});
process.stdin.on("end", () => resolve(input));
process.stdin.on("error", reject);
});
}

// Match by declared name only: PreToolUse:Skill supplies no canonical
// source@version_ref#artifact_hash identity. This defence-in-depth gate is not
// a cryptographic control, so a renamed artifact will not match a revoked record.
function recordMatches(record, norm) {
const skill = record && record.skill && typeof record.skill === "object" ? record.skill : {};
const id = typeof skill.id === "string" ? skill.id.toLowerCase() : "";
const sourcePart = typeof skill.source === "string" ? skill.source.split("/").pop() : "";
const source = sourcePart ? sourcePart.replace(/\.git$/i, "").toLowerCase() : "";
return id === norm || source === norm;
}

function isExpired(record) {
if (typeof record.expires_at !== "string") return false;
const expiresAt = Date.parse(record.expires_at);
return Number.isFinite(expiresAt) && expiresAt < Date.now();
}

function newestActive(records) {
const active = records.filter((record) => record.status === "active" && !isExpired(record));
if (active.length === 0) return null;
const dated = active
.map((record) => ({ record, updatedAt: Date.parse(record.updated_at) }))
.filter((item) => Number.isFinite(item.updatedAt));
if (dated.length === 0) return active[active.length - 1];
dated.sort((a, b) => a.updatedAt - b.updatedAt);
return dated[dated.length - 1].record;
}

function emit(output) {
process.stdout.write(`${JSON.stringify(output)}\n`);
}

function list(v) {
return Array.isArray(v) && v.length ? v.join(", ") : "none";
}

async function main() {
[fs, os, path] = await Promise.all([
import("node:fs"),
import("node:os"),
import("node:path"),
]);

let event;
try {
event = JSON.parse(await readStdin());
} catch {
return;
}

const raw = event && event.tool_input && event.tool_input.skill;
if (event.tool_name !== "Skill" || typeof raw !== "string" || raw.trim() === "") return;

const norm = raw.split(":").pop().split("/").pop().trim().toLowerCase();
if (!norm) return;

const registryPath = resolveRegistryPath();
const registryResult = loadRegistry(registryPath);
if (registryResult.status === "missing") return;
if (registryResult.status === "invalid") {
// Without parsed records we cannot identify revoked skills; denying every
// skill would wedge the session, so stay open and report the failure loudly.
emit({
hookSpecificOutput: { hookEventName: "PreToolUse" },
systemMessage: `AgentGuard: trust registry at ${registryPath} exists but could not be parsed (expected {"records":[...]}). The skill trust gate is inactive this session.`,
});
return;
}

const registry = registryResult.registry;
if (registry.records.length === 0) return;

const matches = registry.records.filter((record) => recordMatches(record, norm));
if (matches.length === 0) return;

const record = matches.find((match) => match.status === "revoked") || newestActive(matches);
if (!record) return;

if (record.status === "revoked") {
emit({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: `AgentGuard trust registry: skill '${norm}' is REVOKED (record ${record.record_key}). Do not run it. Use the skill-trust skill (registry_lookup / registry_attest) to review or re-trust it.`,
},
});
return;
}

if (record.trust_level === "untrusted") {
emit({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: `AgentGuard trust registry: skill '${norm}' is marked UNTRUSTED (record ${record.record_key}). Confirm before running it.`,
},
});
return;
}

if (record.trust_level === "restricted") {
const capabilities = record.capabilities && typeof record.capabilities === "object" ? record.capabilities : null;
const exec = capabilities && (capabilities.exec === "allow" || capabilities.exec === "deny") ? capabilities.exec : "unspecified";
const hasValidCapabilities = capabilities && (
exec !== "unspecified"
|| Array.isArray(capabilities.network_allowlist)
|| Array.isArray(capabilities.filesystem_allowlist)
|| Array.isArray(capabilities.secrets_allowlist)
);
if (!hasValidCapabilities) {
emit({
hookSpecificOutput: {
hookEventName: "PreToolUse",
additionalContext: `AgentGuard: skill '${norm}' is trust-level RESTRICTED but its trust record is malformed (capabilities missing or invalid). Treat it as untrusted and proceed with caution.`,
},
});
return;
}
const network = list(capabilities.network_allowlist);
const filesystem = list(capabilities.filesystem_allowlist);
const secrets = list(capabilities.secrets_allowlist);
emit({
hookSpecificOutput: {
hookEventName: "PreToolUse",
additionalContext: `AgentGuard: skill '${norm}' is trust-level RESTRICTED. Granted capabilities — exec: ${exec}; network allowlist: ${network}; filesystem allowlist: ${filesystem}; secrets allowlist: ${secrets}. Stay within these bounds while this skill runs.`,
},
});
}
}

main().catch(() => process.exit(0));
Loading
Loading