@raishin/vanguard-frontier-agentic 3.14.0 → 3.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.github/plugin/marketplace.json +1 -1
- package/agents/cross-functional/revenue-critical-journey-integrity-agent/harnesses/codex.toml +1 -1
- package/agents/dotnet/dotnet-aspire-cloud-native-review-agent/harnesses/codex.toml +1 -1
- package/agents/dotnet/dotnet-aspnetcore-api-review-agent/harnesses/codex.toml +1 -1
- package/agents/dotnet/dotnet-aspnetcore-identity-authz-review-agent/harnesses/codex.toml +1 -1
- package/agents/dotnet/dotnet-csharp-runtime-review-agent/harnesses/codex.toml +1 -1
- package/agents/dotnet/dotnet-efcore-data-access-review-agent/harnesses/codex.toml +1 -1
- package/agents/dotnet/dotnet-maestro-agent/harnesses/codex.toml +1 -1
- package/agents/dotnet/dotnet-observability-otel-review-agent/harnesses/codex.toml +1 -1
- package/agents/dotnet/dotnet-performance-aot-review-agent/harnesses/codex.toml +1 -1
- package/agents/dotnet/dotnet-supply-chain-review-agent/harnesses/codex.toml +1 -1
- package/agents/dotnet/dotnet-testing-quality-review-agent/harnesses/codex.toml +1 -1
- package/agents/frontend/nextjs-specialist-agent/harnesses/codex.toml +1 -1
- package/agents/frontend/svelte-sveltekit-specialist-agent/harnesses/codex.toml +1 -1
- package/agents/hr/hr-analytics-people-data-agent/harnesses/codex.toml +1 -1
- package/agents/hr/hr-benefits-payroll-agent/harnesses/codex.toml +1 -1
- package/agents/hr/hr-compensation-equity-agent/harnesses/codex.toml +1 -1
- package/agents/hr/hr-culture-dei-agent/harnesses/codex.toml +1 -1
- package/agents/hr/hr-employee-relations-agent/harnesses/codex.toml +1 -1
- package/agents/hr/hr-hris-process-controls-agent/harnesses/codex.toml +1 -1
- package/agents/hr/hr-learning-policy-agent/harnesses/codex.toml +1 -1
- package/agents/hr/hr-leave-accommodation-agent/harnesses/codex.toml +1 -1
- package/agents/hr/hr-maestro-agent/harnesses/codex.toml +1 -1
- package/agents/hr/hr-performance-management-agent/harnesses/codex.toml +1 -1
- package/agents/hr/hr-recruiting-selection-agent/harnesses/codex.toml +1 -1
- package/agents/hr/hr-risk-triage-review-agent/harnesses/codex.toml +1 -1
- package/agents/hr/hr-termination-readiness-agent/harnesses/codex.toml +1 -1
- package/agents/hr/hr-workforce-planning-rif-agent/harnesses/codex.toml +1 -1
- package/agents/hr/hr-workplace-investigations-agent/harnesses/codex.toml +1 -1
- package/agents/java/java-application-server-exit-agent/harnesses/codex.toml +1 -1
- package/agents/java/java-concurrency-and-virtual-thread-agent/harnesses/codex.toml +1 -1
- package/agents/java/java-container-and-kubernetes-readiness-agent/harnesses/codex.toml +1 -1
- package/agents/java/java-database-migration-safety-agent/harnesses/codex.toml +1 -1
- package/agents/java/java-deserialization-and-parser-security-agent/harnesses/codex.toml +1 -1
- package/agents/java/java-framework-production-readiness-agent/harnesses/codex.toml +1 -1
- package/agents/java/java-jdk-lifecycle-and-upgrade-agent/harnesses/codex.toml +1 -1
- package/agents/java/java-jpa-hibernate-performance-agent/harnesses/codex.toml +1 -1
- package/agents/java/java-jvm-performance-and-gc-agent/harnesses/codex.toml +1 -1
- package/agents/java/java-kafka-reliability-agent/harnesses/codex.toml +1 -1
- package/agents/java/java-maestro-agent/harnesses/codex.toml +1 -1
- package/agents/java/java-resilience-pattern-agent/harnesses/codex.toml +1 -1
- package/agents/java/java-spring-security-agent/harnesses/codex.toml +1 -1
- package/agents/java/java-test-architecture-agent/harnesses/codex.toml +1 -1
- package/agents/java/java-transaction-and-consistency-agent/harnesses/codex.toml +1 -1
- package/agents/legal/legal-contract-review-agent/harnesses/codex.toml +1 -1
- package/agents/legal/legal-counsel-review-agent/harnesses/codex.toml +1 -1
- package/agents/legal/legal-employment-law-risk-agent/harnesses/codex.toml +1 -1
- package/agents/legal/legal-ethics-investigations-agent/harnesses/codex.toml +1 -1
- package/agents/legal/legal-ip-open-source-agent/harnesses/codex.toml +1 -1
- package/agents/legal/legal-knowledge-management-agent/harnesses/codex.toml +1 -1
- package/agents/legal/legal-litigation-discovery-hold-agent/harnesses/codex.toml +1 -1
- package/agents/legal/legal-maestro-agent/harnesses/codex.toml +1 -1
- package/agents/legal/legal-policy-governance-agent/harnesses/codex.toml +1 -1
- package/agents/legal/legal-privacy-data-protection-agent/harnesses/codex.toml +1 -1
- package/agents/legal/legal-public-disclosure-agent/harnesses/codex.toml +1 -1
- package/agents/legal/legal-regulatory-compliance-agent/harnesses/codex.toml +1 -1
- package/agents/legal/legal-vendor-procurement-risk-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-administrator-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-ai-connector-mcp-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-ai-foundations-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-application-developer-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-audit-controls-sox-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-bi-reporting-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-data-governance-privacy-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-enterprise-architecture-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-erp-consultant-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-evidence-release-drift-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-financial-foundations-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-identity-access-role-permission-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-integration-migration-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-live-org-mutation-guard-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-maestro-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-oneworld-multisubsidiary-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-sandbox-nonproduction-governance-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-saved-searches-workbook-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-sdf-devops-release-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-sso-oauth-tba-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-suitecloud-developer-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-suiteflow-automation-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-suitefoundation-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-suitescript-secure-code-review-agent/harnesses/codex.toml +1 -1
- package/agents/netsuite/netsuite-web-services-integration-agent/harnesses/codex.toml +1 -1
- package/agents/qa/ci-test-pipeline-review-agent/harnesses/codex.toml +1 -1
- package/agents/qa/helm-chart-quality-review-agent/harnesses/codex.toml +1 -1
- package/agents/qa/kubernetes-manifest-quality-review-agent/harnesses/codex.toml +1 -1
- package/agents/qa/llm-ai-pipeline-test-review-agent/harnesses/codex.toml +1 -1
- package/agents/qa/playwright-e2e-suite-review-agent/harnesses/codex.toml +1 -1
- package/agents/qa/plc-control-logic-safety-review-agent/harnesses/codex.toml +1 -1
- package/agents/qa/rpa-workflow-resilience-review-agent/harnesses/codex.toml +1 -1
- package/agents/qa/test-coverage-quality-review-agent/harnesses/codex.toml +1 -1
- package/agents/qa/test-flakiness-triage-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-adaptive-access-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-agentforce-ai-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-analytics-tableau-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-app-builder-automation-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-business-analyst-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-certificate-lifecycle-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-change-impact-analyst-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-code-analyzer-orchestrator-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-compliance-privacy-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-continuous-verification-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-data-architecture-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-development-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-devops-release-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-enterprise-architect-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-experience-cloud-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-hyperforce-security-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-industry-cloud-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-integration-mulesoft-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-live-guard-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-maestro-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-marketing-cloud-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-network-policy-architect-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-platform-admin-review-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-sales-cloud-revenue-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-sandbox-governance-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-sandbox-isolation-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-security-identity-access-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-service-field-service-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-session-governance-agent/harnesses/codex.toml +1 -1
- package/agents/salesforce/salesforce-slack-collaboration-agent/harnesses/codex.toml +1 -1
- package/catalog/AGENTS.md +2 -1
- package/catalog/asset-integrity.json +723 -276
- package/catalog/index.json +3 -2
- package/catalog/model-assignments.json +121 -121
- package/catalog/model-policy.json +9 -9
- package/catalog/model-registry.json +28 -13
- package/catalog/mods.json +131 -0
- package/mods/README.md +220 -0
- package/mods/audit-trail/README.md +70 -0
- package/mods/audit-trail/bin/hook.mjs +65 -0
- package/mods/audit-trail/claude-code/UNSUPPORTED.md +11 -0
- package/mods/audit-trail/claude-code/settings.hooks.json +14 -0
- package/mods/audit-trail/codex/hooks.json +14 -0
- package/mods/audit-trail/kiro/UNSUPPORTED.md +7 -0
- package/mods/audit-trail/kiro/audit-trail-post-tool-use-hook.json +14 -0
- package/mods/audit-trail/lib/common.mjs +213 -0
- package/mods/audit-trail/lib/core.mjs +50 -0
- package/mods/audit-trail/lib/runtime.mjs +140 -0
- package/mods/audit-trail/mod.toml +37 -0
- package/mods/blast-radius-guard/.claude-plugin/plugin.json +5 -0
- package/mods/blast-radius-guard/README.md +53 -0
- package/mods/blast-radius-guard/bin/hook.mjs +18 -0
- package/mods/blast-radius-guard/claude-code/settings.hooks.json +14 -0
- package/mods/blast-radius-guard/codex/UNSUPPORTED.md +7 -0
- package/mods/blast-radius-guard/codex/hooks.json +14 -0
- package/mods/blast-radius-guard/hooks/hooks.json +5 -0
- package/mods/blast-radius-guard/hooks/register.test.ts +97 -0
- package/mods/blast-radius-guard/hooks/register.ts +13 -0
- package/mods/blast-radius-guard/kiro/UNSUPPORTED.md +7 -0
- package/mods/blast-radius-guard/kiro/blast-radius-guard-pre-tool-use-hook.json +14 -0
- package/mods/blast-radius-guard/lib/common.mjs +213 -0
- package/mods/blast-radius-guard/lib/core.mjs +66 -0
- package/mods/blast-radius-guard/lib/patterns.mjs +102 -0
- package/mods/blast-radius-guard/lib/runtime.mjs +140 -0
- package/mods/blast-radius-guard/mod.toml +34 -0
- package/mods/blast-radius-guard/patterns.json +101 -0
- package/mods/context-burn/.claude-plugin/plugin.json +5 -0
- package/mods/context-burn/README.md +66 -0
- package/mods/context-burn/bin/hook.mjs +38 -0
- package/mods/context-burn/claude-code/UNSUPPORTED.md +7 -0
- package/mods/context-burn/claude-code/settings.hooks.json +24 -0
- package/mods/context-burn/codex/UNSUPPORTED.md +7 -0
- package/mods/context-burn/codex/hooks.json +24 -0
- package/mods/context-burn/hooks/hooks.json +5 -0
- package/mods/context-burn/hooks/register.test.ts +151 -0
- package/mods/context-burn/hooks/register.ts +35 -0
- package/mods/context-burn/kiro/UNSUPPORTED.md +7 -0
- package/mods/context-burn/kiro/context-burn-post-tool-use-hook.json +14 -0
- package/mods/context-burn/kiro/context-burn-session-start-hook.json +14 -0
- package/mods/context-burn/lib/common.mjs +213 -0
- package/mods/context-burn/lib/core.mjs +79 -0
- package/mods/context-burn/lib/runtime.mjs +140 -0
- package/mods/context-burn/mod.toml +37 -0
- package/mods/live-guard-gate/.claude-plugin/plugin.json +5 -0
- package/mods/live-guard-gate/README.md +43 -0
- package/mods/live-guard-gate/bin/hook.mjs +12 -0
- package/mods/live-guard-gate/claude-code/settings.hooks.json +14 -0
- package/mods/live-guard-gate/codex/UNSUPPORTED.md +7 -0
- package/mods/live-guard-gate/codex/hooks.json +14 -0
- package/mods/live-guard-gate/hooks/hooks.json +5 -0
- package/mods/live-guard-gate/hooks/register.test.ts +59 -0
- package/mods/live-guard-gate/hooks/register.ts +11 -0
- package/mods/live-guard-gate/kiro/UNSUPPORTED.md +7 -0
- package/mods/live-guard-gate/kiro/live-guard-gate-pre-tool-use-hook.json +14 -0
- package/mods/live-guard-gate/lib/common.mjs +213 -0
- package/mods/live-guard-gate/lib/core.mjs +24 -0
- package/mods/live-guard-gate/lib/live-agents.mjs +65 -0
- package/mods/live-guard-gate/lib/patterns.mjs +523 -0
- package/mods/live-guard-gate/lib/runtime.mjs +140 -0
- package/mods/live-guard-gate/mod.toml +34 -0
- package/mods/live-guard-gate/patterns.json +522 -0
- package/mods/secret-redactor/.claude-plugin/plugin.json +5 -0
- package/mods/secret-redactor/README.md +86 -0
- package/mods/secret-redactor/bin/hook.mjs +35 -0
- package/mods/secret-redactor/claude-code/UNSUPPORTED.md +7 -0
- package/mods/secret-redactor/claude-code/settings.hooks.json +24 -0
- package/mods/secret-redactor/codex/UNSUPPORTED.md +11 -0
- package/mods/secret-redactor/codex/hooks.json +14 -0
- package/mods/secret-redactor/hooks/hooks.json +5 -0
- package/mods/secret-redactor/hooks/register.test.ts +150 -0
- package/mods/secret-redactor/hooks/register.ts +19 -0
- package/mods/secret-redactor/kiro/UNSUPPORTED.md +11 -0
- package/mods/secret-redactor/kiro/secret-redactor-pre-tool-use-hook.json +14 -0
- package/mods/secret-redactor/lib/common.mjs +213 -0
- package/mods/secret-redactor/lib/core.mjs +61 -0
- package/mods/secret-redactor/lib/patterns.mjs +75 -0
- package/mods/secret-redactor/lib/runtime.mjs +140 -0
- package/mods/secret-redactor/mod.toml +43 -0
- package/mods/secret-redactor/patterns.json +74 -0
- package/package.json +9 -5
- package/plugins/vanguard-frontier-agentic/.codex-plugin/plugin.json +1 -1
- package/scripts/generate-mods.mjs +784 -0
- package/scripts/mods/common.mjs +213 -0
- package/scripts/mods/runtime.mjs +140 -0
- package/tests/fixtures/README.md +1 -1
- package/tests/fuzz-properties.test.mjs +254 -0
- package/tests/mods.test.mjs +768 -0
- package/tests/validate-asset-integrity.py +1 -0
- package/tests/validate-mods.py +821 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validation gate for the cross-harness mods under `mods/`.
|
|
3
|
+
|
|
4
|
+
Every mod is `mods/<id>/` with a `mod.toml` source of truth. This gate checks the
|
|
5
|
+
contract in `.claude/plan/cross-harness-mods.md`: the declared footprint matches
|
|
6
|
+
the derived reach level, every event is covered or declared unsupported per
|
|
7
|
+
harness, the generated manifests are well formed and in sync, the hand-written
|
|
8
|
+
code stays inside its declared capabilities, and the pattern data compiles.
|
|
9
|
+
|
|
10
|
+
Failures print `FAIL [mods] <path> <description>` and warnings `WARN [mods] ...`.
|
|
11
|
+
Stdlib only, deterministic (no clock, no network); `node` is the only subprocess.
|
|
12
|
+
The capability and env scans are line-oriented heuristics, not a JS parser: they
|
|
13
|
+
catch accidents, not deliberate obfuscation.
|
|
14
|
+
|
|
15
|
+
Usage: python3 tests/validate-mods.py [--root <dir>]
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import posixpath
|
|
23
|
+
import re
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tomllib
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
HARNESSES = ("claude-code", "kiro", "codex")
|
|
31
|
+
EVENTS = ("PreToolUse", "PostToolUse", "SessionStart")
|
|
32
|
+
SURFACES = ("native", "classic")
|
|
33
|
+
EV_KEBAB = {"PreToolUse": "pre-tool-use", "PostToolUse": "post-tool-use", "SessionStart": "session-start"}
|
|
34
|
+
NATIVE_EVENT = {"PreToolUse": "tool.check", "PostToolUse": "tool.call", "SessionStart": "session.start"}
|
|
35
|
+
KIRO_HOOK_KEYS = {"name", "description", "trigger", "matcher", "action", "timeout", "enabled", "confirm"}
|
|
36
|
+
CATALOGS = ("agents", "skills", "rules", "mcp-references", "workflows")
|
|
37
|
+
TIMEOUT = 60
|
|
38
|
+
|
|
39
|
+
# Capability tokens: (label, regex, exemption). exemption "net" is lifted by
|
|
40
|
+
# egress_hosts, "spawn" by spawns; None is never allowed. `Function\(` also
|
|
41
|
+
# covers `new Function(`. `\b` cannot precede `$`, hence the bare `\$`.
|
|
42
|
+
CAPS = (
|
|
43
|
+
("fetch(", r"\bfetch\s*\(", "net"),
|
|
44
|
+
("WebSocket", r"\bWebSocket\b", "net"),
|
|
45
|
+
("XMLHttpRequest", r"\bXMLHttpRequest\b", "net"),
|
|
46
|
+
("node:net", r"node:net\b", "net"),
|
|
47
|
+
("node:http", r"node:http\b", "net"),
|
|
48
|
+
("node:https", r"node:https\b", "net"),
|
|
49
|
+
("node:tls", r"node:tls\b", "net"),
|
|
50
|
+
("node:dgram", r"node:dgram\b", "net"),
|
|
51
|
+
("$.network", r"\$\.network\b", "net"),
|
|
52
|
+
("child_process", r"\bchild_process\b", "spawn"),
|
|
53
|
+
("$.process", r"\$\.process\b", "spawn"),
|
|
54
|
+
("node:worker_threads", r"node:worker_threads\b", None),
|
|
55
|
+
("eval(", r"\beval\s*\(", None),
|
|
56
|
+
("Function(", r"\bFunction\s*\(", None),
|
|
57
|
+
("dynamic import(", r"\bimport\s*\(", None),
|
|
58
|
+
("require(", r"\brequire\s*\(", None),
|
|
59
|
+
)
|
|
60
|
+
# The spec list plus the promise/callback and other mutating siblings, so
|
|
61
|
+
# `fs.promises.writeFile` is not a free bypass.
|
|
62
|
+
WRITE_API = re.compile(
|
|
63
|
+
r"\b(?:writeFileSync|appendFileSync|writeSync|writevSync|mkdirSync|renameSync|unlinkSync|rmSync|rmdirSync"
|
|
64
|
+
r"|copyFileSync|truncateSync|ftruncateSync|symlinkSync|linkSync|createWriteStream|writeFile|appendFile)\b"
|
|
65
|
+
r"|\$\.fs\.write\w*"
|
|
66
|
+
)
|
|
67
|
+
OPEN_SYNC = re.compile(r"\bopenSync\s*\(")
|
|
68
|
+
IMPORT_RE = re.compile(r"""\b(?:import|export)\b(?:[^;'"`]*?\bfrom)?\s*(['"])([^'"\n]+)\1""")
|
|
69
|
+
ON_RE = re.compile(r"""\bon\(\s*(['"])([\w.]+)\1""")
|
|
70
|
+
ENV_LITERAL = (
|
|
71
|
+
(re.compile(r"\bprocess\s*\.\s*env\s*\.\s*([A-Za-z_$][\w$]*)"), 1),
|
|
72
|
+
(re.compile(r"""\bprocess\s*\.\s*env\s*\[\s*(['"])([^'"\n]*)\1\s*\]"""), 2),
|
|
73
|
+
(re.compile(r"""\$\.env\.get\(\s*(['"])([^'"\n]*)\1\s*\)"""), 2),
|
|
74
|
+
)
|
|
75
|
+
ENV_ANY = re.compile(r"""\bprocess\s*(?:\.\s*env\b|\[\s*['"]env['"]\s*\])|\$\.env\b""")
|
|
76
|
+
INLINE_FLAG = re.compile(r"(?<!\\)(?:\\\\)*\(\?[-aiLmsux]+[:)]")
|
|
77
|
+
NET_MODULES = {"node:net", "node:http", "node:https", "node:tls", "node:dgram"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Report:
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
self.lines: list[str] = []
|
|
83
|
+
self.failed = 0
|
|
84
|
+
|
|
85
|
+
def _add(self, level: str, path: str, desc: str) -> None:
|
|
86
|
+
head, *rest = (desc.strip().splitlines() or [""])
|
|
87
|
+
self.lines.append(f"{level} [mods] {path} {head}".rstrip())
|
|
88
|
+
self.lines.extend(f" {line}" for line in rest)
|
|
89
|
+
|
|
90
|
+
def fail(self, path: str, desc: str) -> None:
|
|
91
|
+
self.failed += 1
|
|
92
|
+
self._add("FAIL", path, desc)
|
|
93
|
+
|
|
94
|
+
def warn(self, path: str, desc: str) -> None:
|
|
95
|
+
self._add("WARN", path, desc)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class Mod:
|
|
100
|
+
id: str
|
|
101
|
+
dir: Path
|
|
102
|
+
rel: str
|
|
103
|
+
t: dict
|
|
104
|
+
|
|
105
|
+
def h(self, name: str) -> dict:
|
|
106
|
+
return self.t["harnesses"][name]
|
|
107
|
+
|
|
108
|
+
def sup(self, name: str) -> bool:
|
|
109
|
+
return self.h(name)["supported"]
|
|
110
|
+
|
|
111
|
+
def ev(self, name: str) -> list[str]:
|
|
112
|
+
return self.h(name).get("events", []) if self.sup(name) else []
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def native(self) -> bool:
|
|
116
|
+
return self.sup("claude-code") and "native" in self.h("claude-code").get("surfaces", [])
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def classic(self) -> bool:
|
|
120
|
+
return self.sup("claude-code") and "classic" in self.h("claude-code").get("surfaces", [])
|
|
121
|
+
|
|
122
|
+
def fp(self, key: str) -> list[str]:
|
|
123
|
+
return self.t["footprint"][key]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_strs(v: object) -> bool:
|
|
127
|
+
return isinstance(v, list) and all(isinstance(x, str) for x in v)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def exact(v: object, keys: set[str]) -> bool:
|
|
131
|
+
return isinstance(v, dict) and set(v) == keys
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def jdump(v: object) -> str:
|
|
135
|
+
# sort_keys + strict types: `1` and `true` stay distinct, unlike dict equality.
|
|
136
|
+
return json.dumps(v, sort_keys=True, default=str)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def run_node(root: Path, *args: str) -> tuple[int | None, str, str]:
|
|
140
|
+
"""Run node with an argument list; return (returncode, stdout, stderr).
|
|
141
|
+
|
|
142
|
+
returncode is None, with the reason in stderr, when node could not run at all.
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
p = subprocess.run(
|
|
146
|
+
["node", *args], cwd=root, capture_output=True, text=True, errors="replace", timeout=TIMEOUT
|
|
147
|
+
)
|
|
148
|
+
except FileNotFoundError:
|
|
149
|
+
return None, "", "node executable not found"
|
|
150
|
+
except subprocess.TimeoutExpired:
|
|
151
|
+
return None, "", f"timed out after {TIMEOUT}s"
|
|
152
|
+
return p.returncode, p.stdout, p.stderr
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def tail(text: str, n: int = 20) -> str:
|
|
156
|
+
return "\n".join(text.strip().splitlines()[-n:])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------- loading
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def shape_errors(t: dict) -> list[str]:
|
|
163
|
+
"""Structural problems that would make the policy checks crash."""
|
|
164
|
+
mod, fp, hs = t.get("mod"), t.get("footprint"), t.get("harnesses")
|
|
165
|
+
if not (isinstance(mod, dict) and isinstance(fp, dict) and isinstance(hs, dict)):
|
|
166
|
+
return ["[mod], [footprint] and [harnesses] tables are all required"]
|
|
167
|
+
errs = [f"[mod].{k} must be a string" for k in ("id", "name", "description", "version") if not isinstance(mod.get(k), str)]
|
|
168
|
+
rl = mod.get("reach_level")
|
|
169
|
+
if not isinstance(rl, int) or isinstance(rl, bool):
|
|
170
|
+
errs.append("[mod].reach_level must be an integer")
|
|
171
|
+
if not is_strs(mod.get("events")) or not mod["events"]:
|
|
172
|
+
errs.append("[mod].events must be a non-empty array of strings")
|
|
173
|
+
errs += [f"[footprint].{k} must be an array of strings" for k in ("env_reads", "file_writes", "spawns", "egress_hosts") if not is_strs(fp.get(k))]
|
|
174
|
+
for name in HARNESSES:
|
|
175
|
+
ht = hs.get(name)
|
|
176
|
+
if not isinstance(ht, dict) or not isinstance(ht.get("supported"), bool):
|
|
177
|
+
errs.append(f"[harnesses.{name}] with boolean `supported` is required")
|
|
178
|
+
continue
|
|
179
|
+
if ht["supported"]:
|
|
180
|
+
if not is_strs(ht.get("events")):
|
|
181
|
+
errs.append(f"[harnesses.{name}].events must be an array of strings")
|
|
182
|
+
if name == "claude-code" and not is_strs(ht.get("surfaces")):
|
|
183
|
+
errs.append("[harnesses.claude-code].surfaces must be an array of strings")
|
|
184
|
+
elif not isinstance(ht.get("reason"), str) or not ht["reason"].strip():
|
|
185
|
+
errs.append(f"[harnesses.{name}] has supported = false but no `reason`")
|
|
186
|
+
for tbl in ("unsupported", "degraded"):
|
|
187
|
+
v = ht.get(tbl, {})
|
|
188
|
+
if not (isinstance(v, dict) and all(isinstance(x, str) for x in v.values())):
|
|
189
|
+
errs.append(f"[harnesses.{name}.{tbl}] must map strings to strings")
|
|
190
|
+
return errs
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def load_mod(r: Report, d: Path) -> Mod | None:
|
|
194
|
+
rel = f"mods/{d.name}"
|
|
195
|
+
p = d / "mod.toml"
|
|
196
|
+
if not p.is_file():
|
|
197
|
+
r.fail(f"{rel}/mod.toml", "is missing")
|
|
198
|
+
return None
|
|
199
|
+
try:
|
|
200
|
+
with p.open("rb") as f:
|
|
201
|
+
t = tomllib.load(f)
|
|
202
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError, OSError) as exc:
|
|
203
|
+
r.fail(f"{rel}/mod.toml", f"does not parse: {exc}")
|
|
204
|
+
return None
|
|
205
|
+
errs = shape_errors(t)
|
|
206
|
+
for e in errs:
|
|
207
|
+
r.fail(f"{rel}/mod.toml", e)
|
|
208
|
+
if errs:
|
|
209
|
+
return None
|
|
210
|
+
if t["mod"]["id"] != d.name:
|
|
211
|
+
r.fail(f"{rel}/mod.toml", f"[mod].id {t['mod']['id']!r} does not equal directory name {d.name!r}")
|
|
212
|
+
return Mod(d.name, d, rel, t)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------- mod.toml policy
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def check_toml_policy(r: Report, m: Mod) -> None:
|
|
219
|
+
path = f"{m.rel}/mod.toml"
|
|
220
|
+
mod = m.t["mod"]
|
|
221
|
+
# 2. derived reach
|
|
222
|
+
derived = 3 if m.fp("egress_hosts") else 2 if m.fp("spawns") else 1 if m.fp("file_writes") else 0
|
|
223
|
+
if mod["reach_level"] != derived:
|
|
224
|
+
r.fail(path, f"reach_level {mod['reach_level']} does not equal derived level {derived}")
|
|
225
|
+
if derived == 3:
|
|
226
|
+
r.warn(path, "reach level 3: declares network egress")
|
|
227
|
+
if not re.fullmatch(r"\d+\.\d+\.\d+", mod["version"]):
|
|
228
|
+
r.fail(path, f"version {mod['version']!r} must be MAJOR.MINOR.PATCH")
|
|
229
|
+
# 7. env prefix
|
|
230
|
+
prefix = "VANGUARD_" + m.id.upper().replace("-", "_") + "_"
|
|
231
|
+
for name in m.fp("env_reads"):
|
|
232
|
+
if not name.startswith(prefix):
|
|
233
|
+
r.fail(path, f"env read {name!r} must start with {prefix}")
|
|
234
|
+
# 3-5. events, surfaces, degraded
|
|
235
|
+
mod_events = mod["events"]
|
|
236
|
+
for e in mod_events:
|
|
237
|
+
if e not in EVENTS:
|
|
238
|
+
r.fail(path, f"unknown event {e!r} in [mod].events")
|
|
239
|
+
if len(set(mod_events)) != len(mod_events):
|
|
240
|
+
r.fail(path, "[mod].events has duplicates")
|
|
241
|
+
for h in HARNESSES:
|
|
242
|
+
if not m.sup(h):
|
|
243
|
+
continue
|
|
244
|
+
ht, ev = m.h(h), m.ev(h)
|
|
245
|
+
un = set(ht.get("unsupported", {}))
|
|
246
|
+
un_events = un - set(SURFACES) if h == "claude-code" else un
|
|
247
|
+
if len(set(ev)) != len(ev):
|
|
248
|
+
r.fail(path, f"harnesses.{h}.events has duplicates")
|
|
249
|
+
for e in ev:
|
|
250
|
+
if e not in mod_events:
|
|
251
|
+
r.fail(path, f"harnesses.{h}.events lists {e}, which is not in [mod].events")
|
|
252
|
+
for e in sorted(un_events - set(mod_events)):
|
|
253
|
+
r.fail(path, f"harnesses.{h}.unsupported key {e!r} is not a [mod].events entry" + (" or surface" if h == "claude-code" else ""))
|
|
254
|
+
for e in mod_events:
|
|
255
|
+
n = (e in ev) + (e in un_events)
|
|
256
|
+
if n == 0:
|
|
257
|
+
r.fail(path, f"event {e} is in neither harnesses.{h}.events nor harnesses.{h}.unsupported")
|
|
258
|
+
elif n == 2:
|
|
259
|
+
r.fail(path, f"event {e} is in both harnesses.{h}.events and harnesses.{h}.unsupported")
|
|
260
|
+
surfaces = set(ht.get("surfaces", [])) if h == "claude-code" else set()
|
|
261
|
+
if h == "claude-code":
|
|
262
|
+
for s in sorted(surfaces - set(SURFACES)):
|
|
263
|
+
r.fail(path, f"unknown surface {s!r} in harnesses.claude-code.surfaces")
|
|
264
|
+
un_surfaces = un & set(SURFACES)
|
|
265
|
+
for s in sorted(surfaces & un_surfaces):
|
|
266
|
+
r.fail(path, f"surface {s} is in both surfaces and harnesses.claude-code.unsupported")
|
|
267
|
+
if (surfaces | un_surfaces) != set(SURFACES):
|
|
268
|
+
r.fail(path, "harnesses.claude-code surfaces plus unsupported surfaces must be exactly {native, classic}")
|
|
269
|
+
for k in ht.get("degraded", {}):
|
|
270
|
+
if k not in ev and k not in surfaces:
|
|
271
|
+
r.fail(path, f"harnesses.{h}.degraded key {k!r} is not a supported event" + (" or surface" if h == "claude-code" else ""))
|
|
272
|
+
# An event no harness runs is dead weight in [mod].events, not a portable feature.
|
|
273
|
+
for e in mod_events:
|
|
274
|
+
if not any(e in m.ev(h) for h in HARNESSES):
|
|
275
|
+
r.fail(path, f"event {e} is supported by no harness")
|
|
276
|
+
# 6. UNSUPPORTED.md iff
|
|
277
|
+
for h in HARNESSES:
|
|
278
|
+
ht = m.h(h)
|
|
279
|
+
need = (not ht["supported"]) or bool(ht.get("unsupported")) or bool(ht.get("degraded"))
|
|
280
|
+
exists = (m.dir / h / "UNSUPPORTED.md").is_file()
|
|
281
|
+
if need and not exists:
|
|
282
|
+
r.fail(f"{m.rel}/{h}/UNSUPPORTED.md", "is missing (harness is unsupported, or has unsupported/degraded entries)")
|
|
283
|
+
elif exists and not need:
|
|
284
|
+
r.fail(f"{m.rel}/{h}/UNSUPPORTED.md", "must not exist: nothing is unsupported or degraded")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------- source scans
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def strip_comments(src: str) -> str:
|
|
291
|
+
"""Drop `//` and `/* */` comments, leaving string contents alone.
|
|
292
|
+
|
|
293
|
+
ponytail: quote-aware but not regex-literal-aware; a regex literal holding a
|
|
294
|
+
quote can hide the rest of its line. Upgrade to a real JS tokenizer if that bites.
|
|
295
|
+
"""
|
|
296
|
+
out: list[str] = []
|
|
297
|
+
i, n, q = 0, len(src), ""
|
|
298
|
+
while i < n:
|
|
299
|
+
c = src[i]
|
|
300
|
+
if q:
|
|
301
|
+
out.append(c)
|
|
302
|
+
if c == "\\" and i + 1 < n:
|
|
303
|
+
out.append(src[i + 1])
|
|
304
|
+
i += 2
|
|
305
|
+
continue
|
|
306
|
+
if c == q or (c == "\n" and q != "`"):
|
|
307
|
+
q = ""
|
|
308
|
+
i += 1
|
|
309
|
+
elif src.startswith("//", i):
|
|
310
|
+
j = src.find("\n", i)
|
|
311
|
+
i = n if j < 0 else j
|
|
312
|
+
elif src.startswith("/*", i):
|
|
313
|
+
j = src.find("*/", i + 2)
|
|
314
|
+
i = n if j < 0 else j + 2
|
|
315
|
+
out.append(" ")
|
|
316
|
+
else:
|
|
317
|
+
if c in "'\"`":
|
|
318
|
+
q = c
|
|
319
|
+
out.append(c)
|
|
320
|
+
i += 1
|
|
321
|
+
return "".join(out)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def call_args(text: str, start: int) -> list[str]:
|
|
325
|
+
"""Top-level comma-split arguments of the call whose `(` is just before `start`."""
|
|
326
|
+
args, depth, cur, q, i = [], 0, [], "", start
|
|
327
|
+
while i < len(text):
|
|
328
|
+
c = text[i]
|
|
329
|
+
if q:
|
|
330
|
+
cur.append(c)
|
|
331
|
+
if c == "\\":
|
|
332
|
+
cur.append(text[i + 1 : i + 2])
|
|
333
|
+
i += 1
|
|
334
|
+
elif c == q:
|
|
335
|
+
q = ""
|
|
336
|
+
elif c in "'\"`":
|
|
337
|
+
q = c
|
|
338
|
+
cur.append(c)
|
|
339
|
+
elif c in "([{":
|
|
340
|
+
depth += 1
|
|
341
|
+
cur.append(c)
|
|
342
|
+
elif c in ")]}":
|
|
343
|
+
if depth == 0:
|
|
344
|
+
break
|
|
345
|
+
depth -= 1
|
|
346
|
+
cur.append(c)
|
|
347
|
+
elif c == "," and depth == 0:
|
|
348
|
+
args.append("".join(cur).strip())
|
|
349
|
+
cur = []
|
|
350
|
+
else:
|
|
351
|
+
cur.append(c)
|
|
352
|
+
i += 1
|
|
353
|
+
args.append("".join(cur).strip())
|
|
354
|
+
return args
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def write_hits(text: str) -> list[str]:
|
|
358
|
+
hits = [m.group(0) for m in WRITE_API.finditer(text)]
|
|
359
|
+
for m in OPEN_SYNC.finditer(text):
|
|
360
|
+
args = call_args(text, m.end())
|
|
361
|
+
if len(args) < 2:
|
|
362
|
+
continue # openSync(path) defaults to read-only
|
|
363
|
+
lit = re.fullmatch(r"""(['"`])(.*)\1""", args[1])
|
|
364
|
+
# a flag that is not a string literal cannot be judged, so it counts as a write
|
|
365
|
+
if lit is None or set(lit.group(2)) & set("wa+"):
|
|
366
|
+
hits.append(f"openSync(.., {args[1]})")
|
|
367
|
+
return hits
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def scan_files(m: Mod) -> dict[str, str]:
|
|
371
|
+
"""relpath-in-mod -> comment-stripped source for lib/*.mjs, bin/*.mjs, hooks/*.ts (not tests)."""
|
|
372
|
+
found = sorted([*m.dir.glob("lib/*.mjs"), *m.dir.glob("bin/*.mjs"), *m.dir.glob("hooks/*.ts")])
|
|
373
|
+
return {
|
|
374
|
+
p.relative_to(m.dir).as_posix(): strip_comments(p.read_text(encoding="utf-8", errors="replace"))
|
|
375
|
+
for p in found
|
|
376
|
+
if p.is_file() and not p.name.endswith(".test.ts")
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def check_env_scan(r: Report, m: Mod, files: dict[str, str]) -> None:
|
|
381
|
+
declared = set(m.fp("env_reads"))
|
|
382
|
+
used: dict[str, str] = {}
|
|
383
|
+
for rel, text in files.items():
|
|
384
|
+
for am in ENV_ANY.finditer(text):
|
|
385
|
+
name = None
|
|
386
|
+
for rx, grp in ENV_LITERAL:
|
|
387
|
+
lm = rx.match(text, am.start())
|
|
388
|
+
if lm:
|
|
389
|
+
name = lm.group(grp)
|
|
390
|
+
break
|
|
391
|
+
if name is None:
|
|
392
|
+
snippet = " ".join(text[am.start() : am.start() + 40].split())
|
|
393
|
+
r.fail(f"{m.rel}/{rel}", f"non-literal env access `{snippet}`; use process.env.NAME or $.env.get('NAME')")
|
|
394
|
+
else:
|
|
395
|
+
used.setdefault(name, rel)
|
|
396
|
+
for name, rel in sorted(used.items()):
|
|
397
|
+
if name not in declared:
|
|
398
|
+
r.fail(f"{m.rel}/{rel}", f"reads env {name} which is not declared in [footprint].env_reads")
|
|
399
|
+
for name in sorted(declared - set(used)):
|
|
400
|
+
r.fail(f"{m.rel}/mod.toml", f"declared env read {name} is not used in lib/, bin/ or hooks/")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def import_allowed(m: Mod, rel: str, spec: str) -> bool:
|
|
404
|
+
"""Import policy per file role (see spec check 14 and 13)."""
|
|
405
|
+
# runtime.mjs imports node:fs, so nothing the Node-free native runtime loads may import it.
|
|
406
|
+
if rel.startswith("hooks/"):
|
|
407
|
+
return spec == "claude-code" or (spec != "../lib/runtime.mjs" and re.fullmatch(r"\.\./lib/[a-z0-9-]+\.mjs", spec) is not None)
|
|
408
|
+
if rel.startswith("bin/") or rel == "lib/runtime.mjs":
|
|
409
|
+
ok = {"node:fs", "node:path"}
|
|
410
|
+
if m.fp("spawns"):
|
|
411
|
+
ok.add("node:child_process")
|
|
412
|
+
if m.fp("egress_hosts"):
|
|
413
|
+
ok |= NET_MODULES
|
|
414
|
+
if spec in ok:
|
|
415
|
+
return True
|
|
416
|
+
if spec.startswith("./") or spec.startswith("../"):
|
|
417
|
+
return not posixpath.normpath(posixpath.join(posixpath.dirname(rel), spec)).startswith("..")
|
|
418
|
+
return False
|
|
419
|
+
return spec != "./runtime.mjs" and re.fullmatch(r"\./[A-Za-z0-9._-]+\.mjs", spec) is not None # other lib/*.mjs: Node-free runtime
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def check_capabilities(r: Report, m: Mod, files: dict[str, str]) -> None:
|
|
423
|
+
exempt = {"net": bool(m.fp("egress_hosts")), "spawn": bool(m.fp("spawns")), None: False}
|
|
424
|
+
can_write = bool(m.fp("file_writes"))
|
|
425
|
+
for rel, text in files.items():
|
|
426
|
+
path = f"{m.rel}/{rel}"
|
|
427
|
+
for im in IMPORT_RE.finditer(text):
|
|
428
|
+
spec = im.group(2)
|
|
429
|
+
if not import_allowed(m, rel, spec):
|
|
430
|
+
hint = f" (use node:{spec})" if spec in ("fs", "path") else ""
|
|
431
|
+
r.fail(path, f"import of {spec!r} is not allowed here{hint}")
|
|
432
|
+
for label, rx, ex in CAPS:
|
|
433
|
+
if re.search(rx, text) and not exempt[ex]:
|
|
434
|
+
need = {"net": " without egress_hosts", "spawn": " without spawns", None: ""}[ex]
|
|
435
|
+
r.fail(path, f"uses {label}{need}")
|
|
436
|
+
hits = write_hits(text)
|
|
437
|
+
if hits and rel in ("lib/common.mjs", "lib/runtime.mjs"):
|
|
438
|
+
r.fail(path, f"shared module must not use write APIs: {', '.join(sorted(set(hits)))}")
|
|
439
|
+
elif hits and not can_write:
|
|
440
|
+
r.fail(path, f"uses write API {', '.join(sorted(set(hits)))} without file_writes")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def check_register(r: Report, m: Mod, text: str) -> None:
|
|
444
|
+
path = f"{m.rel}/hooks/register.ts"
|
|
445
|
+
if not re.search(r"\bexport\s+const\s+register\b", text):
|
|
446
|
+
r.fail(path, "must contain `export const register`")
|
|
447
|
+
registered = [x.group(2) for x in ON_RE.finditer(text)]
|
|
448
|
+
want = {NATIVE_EVENT[e] for e in m.ev("claude-code") if e in NATIVE_EVENT}
|
|
449
|
+
if set(registered) != want:
|
|
450
|
+
r.fail(path, f"registers native events {sorted(set(registered))}, expected {sorted(want)} from harnesses.claude-code.events")
|
|
451
|
+
guarded = sum(e in ("tool.check", "tool.call") for e in registered)
|
|
452
|
+
catches = len(re.findall(r"\.catch\(", text))
|
|
453
|
+
if catches < guarded:
|
|
454
|
+
r.fail(path, f"has {catches} .catch( for {guarded} tool.check/tool.call registrations")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def check_sources(r: Report, root: Path, m: Mod) -> None:
|
|
458
|
+
files = scan_files(m)
|
|
459
|
+
check_env_scan(r, m, files)
|
|
460
|
+
check_capabilities(r, m, files)
|
|
461
|
+
if "hooks/register.ts" in files:
|
|
462
|
+
check_register(r, m, files["hooks/register.ts"])
|
|
463
|
+
# 11. syntax
|
|
464
|
+
for p in sorted(m.dir.rglob("*.mjs")):
|
|
465
|
+
rc, out, err = run_node(root, "--check", str(p))
|
|
466
|
+
rel = f"{m.rel}/{p.relative_to(m.dir).as_posix()}"
|
|
467
|
+
if rc is None:
|
|
468
|
+
r.fail(rel, f"node --check could not run: {err}")
|
|
469
|
+
elif rc != 0:
|
|
470
|
+
r.fail(rel, f"node --check failed:\n{tail(err or out, 8)}")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def check_required_files(r: Report, m: Mod) -> None:
|
|
474
|
+
need = ["lib/common.mjs", "lib/runtime.mjs", "lib/core.mjs", "bin/hook.mjs", "README.md"]
|
|
475
|
+
if m.native:
|
|
476
|
+
need += ["hooks/hooks.json", "hooks/register.ts", "hooks/register.test.ts"]
|
|
477
|
+
for rel in need:
|
|
478
|
+
if not (m.dir / rel).is_file():
|
|
479
|
+
r.fail(f"{m.rel}/{rel}", "is missing")
|
|
480
|
+
if not m.native and (m.dir / "hooks").exists():
|
|
481
|
+
r.fail(f"{m.rel}/hooks", "must not exist: the native surface is not supported")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# ---------------------------------------------------------------- generated shapes
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def read_json(r: Report, path: Path, rel: str) -> object | None:
|
|
488
|
+
try:
|
|
489
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
490
|
+
except (json.JSONDecodeError, UnicodeDecodeError, OSError) as exc:
|
|
491
|
+
r.fail(rel, f"is not valid JSON: {exc}")
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def check_command(r: Report, rel: str, cmd: str, harness: str, event: str) -> None:
|
|
496
|
+
if "bin/hook.mjs" not in cmd:
|
|
497
|
+
r.fail(rel, f"{event}: command does not run bin/hook.mjs")
|
|
498
|
+
if re.findall(r"--harness\s+(\S+)", cmd) != [harness]:
|
|
499
|
+
r.fail(rel, f"{event}: command must carry exactly `--harness {harness}`")
|
|
500
|
+
if re.findall(r"--event\s+(\S+)", cmd) != [event]:
|
|
501
|
+
r.fail(rel, f"{event}: command must carry exactly `--event {event}`")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def check_hook_map(r: Report, rel: str, data: object, harness: str, events: list[str]) -> None:
|
|
505
|
+
if not exact(data, {"hooks"}) or not isinstance(data["hooks"], dict):
|
|
506
|
+
r.fail(rel, 'must be {"hooks": {<Event>: [{"hooks": [{"type": "command", "command": ...}]}]}}')
|
|
507
|
+
return
|
|
508
|
+
hooks = data["hooks"]
|
|
509
|
+
if set(hooks) != set(events):
|
|
510
|
+
r.fail(rel, f"event keys {sorted(hooks)} must equal harnesses.{harness}.events {sorted(events)}")
|
|
511
|
+
for ev, groups in sorted(hooks.items()):
|
|
512
|
+
ok = (
|
|
513
|
+
isinstance(groups, list)
|
|
514
|
+
and len(groups) == 1
|
|
515
|
+
and exact(groups[0], {"hooks"})
|
|
516
|
+
and isinstance(groups[0]["hooks"], list)
|
|
517
|
+
and len(groups[0]["hooks"]) == 1
|
|
518
|
+
and exact(groups[0]["hooks"][0], {"type", "command"})
|
|
519
|
+
and groups[0]["hooks"][0]["type"] == "command"
|
|
520
|
+
and isinstance(groups[0]["hooks"][0]["command"], str)
|
|
521
|
+
)
|
|
522
|
+
if ok:
|
|
523
|
+
check_command(r, rel, groups[0]["hooks"][0]["command"], harness, ev)
|
|
524
|
+
else:
|
|
525
|
+
r.fail(rel, f'{ev}: must be exactly [{{"hooks": [{{"type": "command", "command": <string>}}]}}]')
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def check_kiro(r: Report, m: Mod) -> None:
|
|
529
|
+
d = m.dir / "kiro"
|
|
530
|
+
files = sorted(p.name for p in d.glob("*.json")) if d.is_dir() else []
|
|
531
|
+
events = m.ev("kiro")
|
|
532
|
+
seen: set[str] = set()
|
|
533
|
+
for name in files:
|
|
534
|
+
rel = f"{m.rel}/kiro/{name}"
|
|
535
|
+
if not m.sup("kiro"):
|
|
536
|
+
r.fail(rel, "must not exist: kiro is not supported")
|
|
537
|
+
continue
|
|
538
|
+
data = read_json(r, d / name, rel)
|
|
539
|
+
if data is None:
|
|
540
|
+
continue
|
|
541
|
+
if not (
|
|
542
|
+
exact(data, {"version", "hooks"})
|
|
543
|
+
and data["version"] == "v1"
|
|
544
|
+
and isinstance(data["hooks"], list)
|
|
545
|
+
and len(data["hooks"]) == 1
|
|
546
|
+
and isinstance(data["hooks"][0], dict)
|
|
547
|
+
):
|
|
548
|
+
r.fail(rel, 'must be {"version": "v1", "hooks": [<exactly one hook>]}')
|
|
549
|
+
continue
|
|
550
|
+
hook = data["hooks"][0]
|
|
551
|
+
extra = set(hook) - KIRO_HOOK_KEYS
|
|
552
|
+
if extra:
|
|
553
|
+
r.fail(rel, f"hook has unknown keys {sorted(extra)}")
|
|
554
|
+
trig = hook.get("trigger")
|
|
555
|
+
if trig not in events:
|
|
556
|
+
r.fail(rel, f"trigger {trig!r} is not in harnesses.kiro.events")
|
|
557
|
+
continue
|
|
558
|
+
seen.add(trig)
|
|
559
|
+
if name != f"{m.id}-{EV_KEBAB[trig]}-hook.json":
|
|
560
|
+
r.fail(rel, f"file name must be {m.id}-{EV_KEBAB[trig]}-hook.json")
|
|
561
|
+
if hook.get("name") != f"{m.id}:{trig}":
|
|
562
|
+
r.fail(rel, f"hook name must be {m.id}:{trig}, got {hook.get('name')!r}")
|
|
563
|
+
act = hook.get("action")
|
|
564
|
+
if not (exact(act, {"type", "command"}) and act["type"] == "command" and isinstance(act["command"], str)):
|
|
565
|
+
r.fail(rel, 'action must be {"type": "command", "command": <string>}')
|
|
566
|
+
else:
|
|
567
|
+
check_command(r, rel, act["command"], "kiro", trig)
|
|
568
|
+
for e in events:
|
|
569
|
+
if e not in seen and m.sup("kiro") and e in EV_KEBAB:
|
|
570
|
+
r.fail(f"{m.rel}/kiro/{m.id}-{EV_KEBAB[e]}-hook.json", "is missing")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def check_generated(r: Report, m: Mod) -> None:
|
|
574
|
+
check_kiro(r, m)
|
|
575
|
+
for h, rel, want in (
|
|
576
|
+
("codex", "codex/hooks.json", m.sup("codex")),
|
|
577
|
+
("claude-code", "claude-code/settings.hooks.json", m.classic),
|
|
578
|
+
):
|
|
579
|
+
path = m.dir / rel
|
|
580
|
+
if not want:
|
|
581
|
+
if path.exists():
|
|
582
|
+
r.fail(f"{m.rel}/{rel}", f"must not exist: {h} is not supported here")
|
|
583
|
+
elif not path.is_file():
|
|
584
|
+
r.fail(f"{m.rel}/{rel}", "is missing")
|
|
585
|
+
elif (data := read_json(r, path, f"{m.rel}/{rel}")) is not None:
|
|
586
|
+
check_hook_map(r, f"{m.rel}/{rel}", data, h, m.ev(h))
|
|
587
|
+
plugin = m.dir / ".claude-plugin" / "plugin.json"
|
|
588
|
+
rel = f"{m.rel}/.claude-plugin/plugin.json"
|
|
589
|
+
if not m.native:
|
|
590
|
+
if plugin.exists():
|
|
591
|
+
r.fail(rel, "must not exist: the native surface is not supported")
|
|
592
|
+
elif not plugin.is_file():
|
|
593
|
+
r.fail(rel, "is missing")
|
|
594
|
+
elif (data := read_json(r, plugin, rel)) is not None:
|
|
595
|
+
mod = m.t["mod"]
|
|
596
|
+
for key, want in (("name", m.id), ("version", mod["version"]), ("description", mod["description"])):
|
|
597
|
+
if not isinstance(data, dict) or data.get(key) != want:
|
|
598
|
+
r.fail(rel, f"{key} must equal {want!r}")
|
|
599
|
+
if m.native and (m.dir / "hooks" / "hooks.json").is_file():
|
|
600
|
+
data = read_json(r, m.dir / "hooks" / "hooks.json", f"{m.rel}/hooks/hooks.json")
|
|
601
|
+
if data is not None and data != {"modules": ["./register.ts"]}:
|
|
602
|
+
r.fail(f"{m.rel}/hooks/hooks.json", 'must be {"modules": ["./register.ts"]}')
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# ---------------------------------------------------------------- patterns
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def check_patterns(r: Report, m: Mod) -> None:
|
|
609
|
+
p = m.dir / "patterns.json"
|
|
610
|
+
if not p.is_file():
|
|
611
|
+
return
|
|
612
|
+
rel = f"{m.rel}/patterns.json"
|
|
613
|
+
data = read_json(r, p, rel)
|
|
614
|
+
if data is None:
|
|
615
|
+
return
|
|
616
|
+
if not isinstance(data, list):
|
|
617
|
+
r.fail(rel, "must be an array of pattern objects")
|
|
618
|
+
return
|
|
619
|
+
required = {"id": str, "name": str, "description": str, "regex": str, "flags": str, "context": list}
|
|
620
|
+
optional = {"severity": str, "agents": list}
|
|
621
|
+
ids: set[str] = set()
|
|
622
|
+
for i, e in enumerate(data):
|
|
623
|
+
if not isinstance(e, dict):
|
|
624
|
+
r.fail(rel, f"entry {i} is not an object")
|
|
625
|
+
continue
|
|
626
|
+
pid = e.get("id", f"#{i}")
|
|
627
|
+
bad = [k for k, ty in required.items() if not isinstance(e.get(k), ty)]
|
|
628
|
+
bad += [k for k, ty in optional.items() if k in e and not isinstance(e[k], ty)]
|
|
629
|
+
for k in bad:
|
|
630
|
+
r.fail(rel, f"pattern {pid}: field {k!r} is missing or has the wrong type")
|
|
631
|
+
for k in sorted(set(e) - set(required) - set(optional)):
|
|
632
|
+
r.fail(rel, f"pattern {pid}: unknown field {k!r}")
|
|
633
|
+
if bad:
|
|
634
|
+
continue
|
|
635
|
+
for k in ("context", "agents"):
|
|
636
|
+
if k in e and not is_strs(e[k]):
|
|
637
|
+
r.fail(rel, f"pattern {pid}: {k} must be an array of strings")
|
|
638
|
+
if pid in ids:
|
|
639
|
+
r.fail(rel, f"duplicate pattern id {pid!r}")
|
|
640
|
+
ids.add(pid)
|
|
641
|
+
if e["flags"] not in ("", "i"):
|
|
642
|
+
r.fail(rel, f"pattern {pid}: flags {e['flags']!r} must be '' or 'i'")
|
|
643
|
+
continue
|
|
644
|
+
if INLINE_FLAG.search(e["regex"]):
|
|
645
|
+
r.fail(rel, f"pattern {pid}: inline flag group in regex; use the `flags` field")
|
|
646
|
+
try:
|
|
647
|
+
re.compile(e["regex"], re.ASCII | (re.I if e["flags"] == "i" else 0))
|
|
648
|
+
except re.error as exc:
|
|
649
|
+
r.fail(rel, f"pattern {pid}: regex does not compile under re.ASCII: {exc}")
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
# ---------------------------------------------------------------- repo-level checks
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def check_generator(r: Report, root: Path, mods: list[Mod], mod_dirs: list[str]) -> None:
|
|
656
|
+
gen = "scripts/generate-mods.mjs"
|
|
657
|
+
if not (root / gen).is_file():
|
|
658
|
+
r.fail(gen, "is missing")
|
|
659
|
+
return
|
|
660
|
+
rc, out, err = run_node(root, gen, "--check")
|
|
661
|
+
if rc is None:
|
|
662
|
+
r.fail(gen, f"--check could not run: {err}")
|
|
663
|
+
return
|
|
664
|
+
if rc != 0:
|
|
665
|
+
r.fail(gen, f"--check exited {rc}:\n{tail(err or out)}")
|
|
666
|
+
rc, out, err = run_node(root, gen, "--print-manifests")
|
|
667
|
+
if rc != 0:
|
|
668
|
+
r.fail(gen, f"--print-manifests exited {rc}:\n{tail(err or out)}")
|
|
669
|
+
return
|
|
670
|
+
try:
|
|
671
|
+
printed = json.loads(out)
|
|
672
|
+
except json.JSONDecodeError as exc:
|
|
673
|
+
r.fail(gen, f"--print-manifests output is not JSON: {exc}")
|
|
674
|
+
return
|
|
675
|
+
if not isinstance(printed, dict):
|
|
676
|
+
r.fail(gen, "--print-manifests output must be a JSON object keyed by mod id")
|
|
677
|
+
return
|
|
678
|
+
for m in mods:
|
|
679
|
+
if m.id not in printed:
|
|
680
|
+
r.fail(f"{m.rel}/mod.toml", "missing from generator --print-manifests output")
|
|
681
|
+
elif jdump(printed[m.id]) != jdump(m.t):
|
|
682
|
+
r.fail(f"{m.rel}/mod.toml", "generator parse differs from tomllib parse")
|
|
683
|
+
for extra in sorted(set(printed) - set(mod_dirs)):
|
|
684
|
+
r.fail(gen, f"--print-manifests lists {extra!r}, which has no mods/{extra}/")
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def check_catalog_mods(r: Report, root: Path, mods: list[Mod], mod_dirs: list[str]) -> None:
|
|
688
|
+
rel = "catalog/mods.json"
|
|
689
|
+
p = root / rel
|
|
690
|
+
if not p.is_file():
|
|
691
|
+
r.fail(rel, "is missing")
|
|
692
|
+
return
|
|
693
|
+
cat = read_json(r, p, rel)
|
|
694
|
+
if cat is None:
|
|
695
|
+
return
|
|
696
|
+
if not isinstance(cat, list) or not all(isinstance(e, dict) for e in cat):
|
|
697
|
+
r.fail(rel, "must be an array of objects")
|
|
698
|
+
return
|
|
699
|
+
ids = [e.get("id") for e in cat]
|
|
700
|
+
if ids != sorted(ids, key=str):
|
|
701
|
+
r.fail(rel, "is not sorted by id")
|
|
702
|
+
for i in sorted({x for x in ids if ids.count(x) > 1}, key=str):
|
|
703
|
+
r.fail(rel, f"duplicate entry for {i!r}")
|
|
704
|
+
for i in sorted(set(mod_dirs) - set(ids)):
|
|
705
|
+
r.fail(rel, f"has no entry for mods/{i}")
|
|
706
|
+
for i in sorted(set(ids) - set(mod_dirs), key=str):
|
|
707
|
+
r.fail(rel, f"has an entry for {i!r}, which has no mods/ directory")
|
|
708
|
+
by_id = {e.get("id"): e for e in cat}
|
|
709
|
+
for m in mods:
|
|
710
|
+
mod = m.t["mod"]
|
|
711
|
+
want = {
|
|
712
|
+
"id": m.id,
|
|
713
|
+
"name": mod["name"],
|
|
714
|
+
"version": mod["version"],
|
|
715
|
+
"description": mod["description"],
|
|
716
|
+
"reach_level": mod["reach_level"],
|
|
717
|
+
"events": mod["events"],
|
|
718
|
+
"harnesses": sorted(h for h in HARNESSES if m.sup(h)),
|
|
719
|
+
"path": m.rel,
|
|
720
|
+
"footprint": {key: m.fp(key) for key in ("env_reads", "file_writes", "spawns", "egress_hosts")},
|
|
721
|
+
}
|
|
722
|
+
got = by_id.get(m.id)
|
|
723
|
+
if got is not None and jdump(got) != jdump(want):
|
|
724
|
+
keys = sorted(k for k in set(want) | set(got) if jdump(want.get(k, "<absent>")) != jdump(got.get(k, "<absent>")))
|
|
725
|
+
r.fail(rel, f"entry {m.id!r} differs from mod.toml in: {', '.join(keys)}")
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def check_catalog_ids(r: Report, root: Path, mods: list[Mod]) -> None:
|
|
729
|
+
for name in CATALOGS:
|
|
730
|
+
rel = f"catalog/{name}.json"
|
|
731
|
+
p = root / rel
|
|
732
|
+
data = read_json(r, p, rel) if p.is_file() else None
|
|
733
|
+
if data is None:
|
|
734
|
+
if not p.is_file():
|
|
735
|
+
r.fail(rel, "is missing, so mod id uniqueness cannot be checked")
|
|
736
|
+
continue
|
|
737
|
+
if name == "workflows":
|
|
738
|
+
data = data.get("workflows") if isinstance(data, dict) else None
|
|
739
|
+
if not isinstance(data, list):
|
|
740
|
+
r.fail(rel, "must contain a workflows array")
|
|
741
|
+
continue
|
|
742
|
+
if not isinstance(data, list):
|
|
743
|
+
r.fail(rel, "must be a top-level array")
|
|
744
|
+
continue
|
|
745
|
+
taken = {e.get("id") for e in data if isinstance(e, dict)}
|
|
746
|
+
for m in mods:
|
|
747
|
+
if m.id in taken:
|
|
748
|
+
r.fail(f"{m.rel}/mod.toml", f"id {m.id!r} collides with an id in {rel}")
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def check_write_overlap(r: Report, mods: list[Mod]) -> None:
|
|
752
|
+
def prefix(glob: str) -> str:
|
|
753
|
+
return re.split(r"[*?\[{]", glob, maxsplit=1)[0]
|
|
754
|
+
|
|
755
|
+
globs = [(m, g) for m in mods for g in m.fp("file_writes")]
|
|
756
|
+
for i, (ma, ga) in enumerate(globs):
|
|
757
|
+
for mb, gb in globs[i + 1 :]:
|
|
758
|
+
if ma.id == mb.id:
|
|
759
|
+
continue
|
|
760
|
+
pa, pb = prefix(ga), prefix(gb)
|
|
761
|
+
if pa.startswith(pb) or pb.startswith(pa):
|
|
762
|
+
r.fail(f"{ma.rel}/mod.toml", f"file_writes {ga!r} overlaps {gb!r} in {mb.rel}")
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def check_node_tests(r: Report, root: Path) -> None:
|
|
766
|
+
rel = "tests/mods.test.mjs"
|
|
767
|
+
if not (root / rel).is_file():
|
|
768
|
+
r.fail(rel, "is missing")
|
|
769
|
+
return
|
|
770
|
+
rc, out, err = run_node(root, "--test", rel)
|
|
771
|
+
if rc is None:
|
|
772
|
+
r.fail(rel, f"could not run: {err}")
|
|
773
|
+
elif rc != 0:
|
|
774
|
+
r.fail(rel, f"node --test exited {rc}:\n{tail(out + err)}")
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def main(argv: list[str] | None = None) -> int:
|
|
778
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
779
|
+
ap.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1], help="repository root (default: this repo)")
|
|
780
|
+
root = ap.parse_args(argv).root.resolve()
|
|
781
|
+
r = Report()
|
|
782
|
+
|
|
783
|
+
mods_dir = root / "mods"
|
|
784
|
+
if not mods_dir.is_dir():
|
|
785
|
+
r.fail("mods/", "directory is missing")
|
|
786
|
+
print("\n".join(r.lines))
|
|
787
|
+
return 1
|
|
788
|
+
dirs = sorted(d for d in mods_dir.iterdir() if d.is_dir() and not d.name.startswith("."))
|
|
789
|
+
if not dirs:
|
|
790
|
+
r.fail("mods/", "contains no mod directories")
|
|
791
|
+
if not (mods_dir / "README.md").is_file():
|
|
792
|
+
r.fail("mods/README.md", "is missing")
|
|
793
|
+
|
|
794
|
+
mods: list[Mod] = []
|
|
795
|
+
for d in dirs:
|
|
796
|
+
m = load_mod(r, d)
|
|
797
|
+
if m is None:
|
|
798
|
+
continue
|
|
799
|
+
mods.append(m)
|
|
800
|
+
check_toml_policy(r, m)
|
|
801
|
+
check_required_files(r, m)
|
|
802
|
+
check_generated(r, m)
|
|
803
|
+
check_sources(r, root, m)
|
|
804
|
+
check_patterns(r, m)
|
|
805
|
+
|
|
806
|
+
mod_dirs = [d.name for d in dirs]
|
|
807
|
+
check_generator(r, root, mods, mod_dirs)
|
|
808
|
+
check_catalog_mods(r, root, mods, mod_dirs)
|
|
809
|
+
check_catalog_ids(r, root, mods)
|
|
810
|
+
check_write_overlap(r, mods)
|
|
811
|
+
check_node_tests(r, root)
|
|
812
|
+
|
|
813
|
+
if r.failed == 0:
|
|
814
|
+
n = {h: sum(m.sup(h) for m in mods) for h in HARNESSES}
|
|
815
|
+
r.lines.append(f"OK: {len(mods)} mods valid (claude-code {n['claude-code']}, kiro {n['kiro']}, codex {n['codex']})")
|
|
816
|
+
print("\n".join(r.lines))
|
|
817
|
+
return 1 if r.failed else 0
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
if __name__ == "__main__":
|
|
821
|
+
sys.exit(main())
|