@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,140 @@
|
|
|
1
|
+
// Command-hook runtime for the mods under mods/. scripts/generate-mods.mjs copies this file
|
|
2
|
+
// byte-for-byte to mods/<id>/lib/runtime.mjs, so edit it here and run `npm run mods:write`.
|
|
3
|
+
//
|
|
4
|
+
// A mod's bin/hook.mjs hands its handlers to runHook, which reads the harness payload from
|
|
5
|
+
// stdin and renders the handler's decision in that harness's protocol. It writes no files
|
|
6
|
+
// and reads no environment; Claude Code's native runtime never imports it.
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { isAbsolute, resolve, sep } from 'node:path'
|
|
9
|
+
|
|
10
|
+
const HARNESSES = new Set(['claude-code', 'kiro', 'codex'])
|
|
11
|
+
const EVENTS = new Set(['PreToolUse', 'PostToolUse', 'SessionStart'])
|
|
12
|
+
const DECISIONS = new Set(['allow', 'deny', 'ask', 'replace', 'context'])
|
|
13
|
+
|
|
14
|
+
const nodeIo = () => ({
|
|
15
|
+
argv: process.argv.slice(2),
|
|
16
|
+
readStdin: () => readFileSync(0, 'utf8'),
|
|
17
|
+
out: (s) => process.stdout.write(s),
|
|
18
|
+
err: (s) => process.stderr.write(s),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const message = (err) => (err instanceof Error ? err.message : String(err))
|
|
22
|
+
|
|
23
|
+
// JSON.parse errors quote the text they failed on, and stdin can carry a tool's input or
|
|
24
|
+
// output: report the failure without echoing any of it.
|
|
25
|
+
function parseStdin(text) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(text)
|
|
28
|
+
} catch {
|
|
29
|
+
throw new Error('stdin is not valid JSON')
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseArgs(argv) {
|
|
34
|
+
const usage = 'usage: hook.mjs --harness <claude-code|kiro|codex> --event <PreToolUse|PostToolUse|SessionStart>'
|
|
35
|
+
if (argv.length !== 4) throw new Error(usage)
|
|
36
|
+
const args = {}
|
|
37
|
+
for (let i = 0; i < 4; i += 2) {
|
|
38
|
+
const key = { '--harness': 'harness', '--event': 'event' }[argv[i]]
|
|
39
|
+
if (!key || key in args) throw new Error(usage)
|
|
40
|
+
args[key] = argv[i + 1]
|
|
41
|
+
}
|
|
42
|
+
if (!HARNESSES.has(args.harness)) throw new Error(`unknown harness ${args.harness}; ${usage}`)
|
|
43
|
+
if (!EVENTS.has(args.event)) throw new Error(`unknown event ${args.event}; ${usage}`)
|
|
44
|
+
return args
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Where an observer writes `path`. A relative path resolves against the project root when
|
|
48
|
+
// the hook runs from its install location <root>/.<harness dir>/vanguard-mods/<id>/bin/,
|
|
49
|
+
// otherwise against the working directory. Never CLAUDE_PROJECT_DIR: mods read only their
|
|
50
|
+
// own VANGUARD_<ID>_* variables.
|
|
51
|
+
export function projectPath(path, harness, binDir) {
|
|
52
|
+
if (isAbsolute(path)) return path
|
|
53
|
+
const parts = resolve(binDir).split(sep)
|
|
54
|
+
const n = parts.length
|
|
55
|
+
const dir = { 'claude-code': '.claude', kiro: '.kiro', codex: '.codex' }[harness]
|
|
56
|
+
const installed = n >= 5 && parts[n - 1] === 'bin' && parts[n - 3] === 'vanguard-mods' && parts[n - 4] === dir
|
|
57
|
+
return resolve(installed ? parts.slice(0, n - 4).join(sep) || sep : process.cwd(), path)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Rejects decisions the harness cannot express, so a handler bug surfaces as an error
|
|
61
|
+
// instead of a silently dropped verdict.
|
|
62
|
+
function normalize(d, { harness, event }) {
|
|
63
|
+
if (d === undefined) return { decision: 'allow', log: [] }
|
|
64
|
+
if (d === null || typeof d !== 'object' || !DECISIONS.has(d.decision)) {
|
|
65
|
+
throw new Error(`handler returned an invalid decision: ${JSON.stringify(d)}`)
|
|
66
|
+
}
|
|
67
|
+
const log = d.log ?? []
|
|
68
|
+
if (!Array.isArray(log) || !log.every((l) => typeof l === 'string')) throw new Error('decision log must be a string array')
|
|
69
|
+
const needs = { deny: 'reason', ask: 'reason', context: 'text' }[d.decision]
|
|
70
|
+
if (needs && (typeof d[needs] !== 'string' || d[needs] === '')) throw new Error(`${d.decision} needs a ${needs}`)
|
|
71
|
+
if ((d.decision === 'deny' || d.decision === 'ask') && event !== 'PreToolUse') {
|
|
72
|
+
throw new Error(`${d.decision} is only valid for PreToolUse, not ${event}`)
|
|
73
|
+
}
|
|
74
|
+
if (d.decision === 'replace' && (harness !== 'claude-code' || event !== 'PostToolUse')) {
|
|
75
|
+
throw new Error(`replace is only valid for claude-code PostToolUse, not ${harness} ${event}`)
|
|
76
|
+
}
|
|
77
|
+
return { ...d, log }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function render(d, { harness, event }, io) {
|
|
81
|
+
const json = (hookSpecificOutput) => io.out(JSON.stringify({ hookSpecificOutput: { hookEventName: event, ...hookSpecificOutput } }) + '\n')
|
|
82
|
+
for (const line of d.log) io.err(line + '\n')
|
|
83
|
+
switch (d.decision) {
|
|
84
|
+
case 'allow':
|
|
85
|
+
return 0
|
|
86
|
+
case 'deny':
|
|
87
|
+
case 'ask':
|
|
88
|
+
if (harness === 'claude-code') {
|
|
89
|
+
json({ permissionDecision: d.decision, permissionDecisionReason: d.reason })
|
|
90
|
+
return 0
|
|
91
|
+
}
|
|
92
|
+
// Kiro and Codex have no confirmation step; exit 2 blocks the call in both.
|
|
93
|
+
io.err(d.decision === 'ask' ? `${d.reason} (confirmation unsupported on ${harness}; denied)\n` : `${d.reason}\n`)
|
|
94
|
+
return 2
|
|
95
|
+
case 'replace':
|
|
96
|
+
json({ updatedToolOutput: d.output })
|
|
97
|
+
return 0
|
|
98
|
+
case 'context':
|
|
99
|
+
if (harness === 'kiro') io.err(d.text + '\n')
|
|
100
|
+
else json({ additionalContext: d.text })
|
|
101
|
+
return 0
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Runs one hook invocation and returns the exit code; bin/hook.mjs sets process.exitCode
|
|
106
|
+
// with it rather than calling process.exit, so buffered stdout is never cut short.
|
|
107
|
+
//
|
|
108
|
+
// - Bad arguments, or an event this mod has no handler for: exit 2, which blocks a
|
|
109
|
+
// PreToolUse call in every harness (a broken install must not allow silently).
|
|
110
|
+
// - Invalid stdin or a throwing handler: `onError(err, payload, ctx)` supplies the decision
|
|
111
|
+
// (guards deny, the redactor withholds). Without onError: stderr and exit 1, which is
|
|
112
|
+
// loud but does not block.
|
|
113
|
+
// - An onError that throws or returns an invalid decision: exit 2.
|
|
114
|
+
export async function runHook({ name, handlers, onError }, io = nodeIo()) {
|
|
115
|
+
let ctx
|
|
116
|
+
try {
|
|
117
|
+
ctx = parseArgs(io.argv)
|
|
118
|
+
if (!Object.hasOwn(handlers, ctx.event)) throw new Error(`no handler for ${ctx.event}`)
|
|
119
|
+
} catch (err) {
|
|
120
|
+
io.err(`${name}: ${message(err)}\n`)
|
|
121
|
+
return 2
|
|
122
|
+
}
|
|
123
|
+
let payload
|
|
124
|
+
let decision
|
|
125
|
+
try {
|
|
126
|
+
payload = parseStdin(io.readStdin())
|
|
127
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('stdin is not a JSON object')
|
|
128
|
+
decision = normalize(await handlers[ctx.event](payload, ctx), ctx)
|
|
129
|
+
} catch (err) {
|
|
130
|
+
io.err(`${name}: ${message(err)}\n`)
|
|
131
|
+
if (typeof onError !== 'function') return 1
|
|
132
|
+
try {
|
|
133
|
+
decision = normalize(await onError(err, payload, ctx), ctx)
|
|
134
|
+
} catch (err2) {
|
|
135
|
+
io.err(`${name}: error handler failed: ${message(err2)}\n`)
|
|
136
|
+
return 2
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return render(decision, ctx, io)
|
|
140
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[mod]
|
|
2
|
+
id = "blast-radius-guard"
|
|
3
|
+
name = "Blast Radius Guard"
|
|
4
|
+
description = "Asks before, or blocks, destructive shell commands and writes to system paths."
|
|
5
|
+
version = "1.0.0"
|
|
6
|
+
reach_level = 0
|
|
7
|
+
events = ["PreToolUse"]
|
|
8
|
+
|
|
9
|
+
[footprint]
|
|
10
|
+
env_reads = ["VANGUARD_BLAST_RADIUS_GUARD_MODE", "VANGUARD_BLAST_RADIUS_GUARD_PATTERNS"]
|
|
11
|
+
file_writes = []
|
|
12
|
+
spawns = []
|
|
13
|
+
egress_hosts = []
|
|
14
|
+
|
|
15
|
+
[harnesses.claude-code]
|
|
16
|
+
supported = true
|
|
17
|
+
surfaces = ["native", "classic"]
|
|
18
|
+
events = ["PreToolUse"]
|
|
19
|
+
|
|
20
|
+
[harnesses.kiro]
|
|
21
|
+
supported = true
|
|
22
|
+
requires = ["node"]
|
|
23
|
+
events = ["PreToolUse"]
|
|
24
|
+
|
|
25
|
+
[harnesses.kiro.degraded]
|
|
26
|
+
PreToolUse = "Kiro documents no confirmation protocol or stdin field names; the whole payload is scanned, the system-path rule applies to the path keys whatever the tool, and confirm mode denies."
|
|
27
|
+
|
|
28
|
+
[harnesses.codex]
|
|
29
|
+
supported = true
|
|
30
|
+
requires = ["node"]
|
|
31
|
+
events = ["PreToolUse"]
|
|
32
|
+
|
|
33
|
+
[harnesses.codex.degraded]
|
|
34
|
+
PreToolUse = "Codex rejects ask; confirm mode denies with a reason."
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "rm-rf-root-or-home",
|
|
4
|
+
"name": "rm -r on /, /*, ~, ~/, $HOME or a top-level system dir",
|
|
5
|
+
"description": "rm with a recursive flag (any of -r -R -rf -fr -Rf -rfv, split -r -f, --recursive) AND a whole-token target of / /* ~ ~/ ~/* $HOME ${HOME} (optionally quoted) or a top-level system dir (/etc /usr /var ...). Flag/target order-independent (lookahead AND). Force flag deliberately not required: rm -r / is as destructive. Known FP: the text inside echo/grep/commit messages.",
|
|
6
|
+
"regex": "(?:^|[^A-Za-z0-9_.-])rm(?=[ \\t])(?=[^;&|\\n]*[ \\t](?:-[A-Za-z]*[rR][A-Za-z]*|--recursive)(?![^ \\t\\r\\n;&|)]))(?=[^;&|\\n]*[ \\t][\"']?(?:/(?:(?:bin|boot|dev|etc|home|lib|lib32|lib64|opt|proc|root|sbin|srv|sys|usr|var)/?)?\\*?|~/?\\*?|\\$HOME/?\\*?|\\$\\{HOME\\}/?\\*?)[\"']?(?![^ \\t\\r\\n;&|)]))",
|
|
7
|
+
"flags": "",
|
|
8
|
+
"severity": "critical",
|
|
9
|
+
"context": [
|
|
10
|
+
"bash"
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "sql-drop-object",
|
|
15
|
+
"name": "SQL DROP TABLE/DATABASE/SCHEMA",
|
|
16
|
+
"description": "DROP followed by whitespace/newline and TABLE|DATABASE|SCHEMA, case-insensitive, left/right word-bounded. Matches the SQL text inside psql -c / mysql -e / sqlcmd -Q strings. Does not see SQL inside files (psql -f x.sql) nor the dropdb CLI.",
|
|
17
|
+
"regex": "(?:^|[^A-Za-z0-9_])drop[ \\t\\r\\n]+(?:table|database|schema)(?![A-Za-z0-9_])",
|
|
18
|
+
"flags": "i",
|
|
19
|
+
"severity": "critical",
|
|
20
|
+
"context": [
|
|
21
|
+
"bash"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "git-force-push-protected",
|
|
26
|
+
"name": "git push --force / -f / --force-with-lease / +refspec to protected branch",
|
|
27
|
+
"description": "git [global opts] push with (a force flag AND a protected-branch token) OR a +refspec of a protected branch. Protected = main, master, release/*, prod, production, prod-*, prod_*, prod/*. Branch token may be bare, src:dst, or refs/heads/*. --force-with-lease IS included: the lease only guards against clobbering unseen commits, it still rewrites protected history. 'prod*' from the spec narrowed so product-page does not match. Bare 'git push -f' (current branch unknown) is NOT matched.",
|
|
28
|
+
"regex": "(?:^|[^A-Za-z0-9_.-])git(?:[ \\t]+-[^ \\t\\r\\n;&|]+(?:[ \\t]+[^ \\t\\r\\n;&|-][^ \\t\\r\\n;&|]*)?){0,6}[ \\t]+push(?![A-Za-z0-9_-])(?:(?=[^;&|\\n]*[ \\t](?:--force(?:-with-lease|-if-includes)?(?:=[^ \\t\\r\\n;&|]*)?|-[A-Za-z]*f[A-Za-z]*)(?![^ \\t\\r\\n;&|)]))(?=[^;&|\\n]*[ \\t][\"']?\\+?(?:[^ \\t\\r\\n;&|:]*:)?(?:refs/heads/)?(?:main|master|release/[^ \\t\\r\\n;&|\"']+|prod(?:uction)?|prod[-_/][^ \\t\\r\\n;&|\"']*)[\"']?(?![^ \\t\\r\\n;&|)]))|(?=[^;&|\\n]*[ \\t][\"']?\\+(?:[^ \\t\\r\\n;&|:]*:)?(?:refs/heads/)?(?:main|master|release/[^ \\t\\r\\n;&|\"']+|prod(?:uction)?|prod[-_/][^ \\t\\r\\n;&|\"']*)[\"']?(?![^ \\t\\r\\n;&|)])))",
|
|
29
|
+
"flags": "",
|
|
30
|
+
"severity": "high",
|
|
31
|
+
"context": [
|
|
32
|
+
"bash"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "pipe-remote-to-shell",
|
|
37
|
+
"name": "curl|wget piped to a shell",
|
|
38
|
+
"description": "curl or wget whose output is piped (possibly through intermediate filters) into sh/bash/zsh/dash/ksh/csh, optionally via sudo/env and an absolute path. '||' is not treated as a pipe. Interpreters other than shells (python, node, perl) are not covered.",
|
|
39
|
+
"regex": "(?:^|[^A-Za-z0-9_.-])(?:curl|wget)(?![A-Za-z0-9_-])[^;&\\n|]*(?:\\|(?!\\|)[^;&\\n|]*)*\\|(?!\\|)[ \\t]*(?:sudo(?:[ \\t]+-[^ \\t\\r\\n;&|]+(?:[ \\t]+[^ \\t\\r\\n;&|-][^ \\t\\r\\n;&|]*)?){0,4}[ \\t]+|env[ \\t]+)?(?:/[A-Za-z0-9_./-]*/)?(?:ba|z|da|k|c)?sh(?![A-Za-z0-9_.-])",
|
|
40
|
+
"flags": "",
|
|
41
|
+
"severity": "high",
|
|
42
|
+
"context": [
|
|
43
|
+
"bash"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"id": "remote-script-substitution",
|
|
48
|
+
"name": "shell/source/eval of curl|wget command substitution",
|
|
49
|
+
"description": "bash <(curl ...), sh -c \"$(curl ...)\", eval \"$(wget ...)\", source <(curl ...), . <(curl ...). Same threat as pipe-to-shell via process/command substitution.",
|
|
50
|
+
"regex": "(?:^|[^A-Za-z0-9_.-])(?:(?:ba|z|da|k)?sh(?:[ \\t]+-[A-Za-z]+)*|source|eval|\\.)[ \\t]+[\"']?(?:<\\(|\\$\\()[ \\t]*(?:curl|wget)(?![A-Za-z0-9_-])",
|
|
51
|
+
"flags": "",
|
|
52
|
+
"severity": "high",
|
|
53
|
+
"context": [
|
|
54
|
+
"bash"
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"id": "terraform-destroy-auto-approve",
|
|
59
|
+
"name": "terraform/tofu/terragrunt destroy with -auto-approve",
|
|
60
|
+
"description": "terraform|tofu|terragrunt with a destroy token (destroy or -destroy, so 'apply -destroy' is included) AND -auto-approve/--auto-approve, in either order. Plain 'terraform destroy' (interactive confirm; no TTY in agent shells) and plain 'terraform apply -auto-approve' are deliberately NOT matched: apply is covered by live-guard-gate, and matching all auto-approved applies would double-prompt and flag routine CI-style usage.",
|
|
61
|
+
"regex": "(?:^|[^A-Za-z0-9_.-])(?:terraform|tofu|terragrunt)(?![A-Za-z0-9_-])(?=[^;&|\\n]*[ \\t]-?destroy(?![^ \\t\\r\\n;&|)]))(?=[^;&|\\n]*[ \\t]--?auto-approve(?:=[^ \\t\\r\\n;&|]*)?(?![^ \\t\\r\\n;&|)]))",
|
|
62
|
+
"flags": "",
|
|
63
|
+
"severity": "critical",
|
|
64
|
+
"context": [
|
|
65
|
+
"bash"
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"id": "kubectl-delete-namespace",
|
|
70
|
+
"name": "kubectl/oc delete namespace",
|
|
71
|
+
"description": "kubectl|oc ... delete [flags] namespace|namespaces|ns (also ns/<name>). Flags between delete and the resource kind are allowed (--force --grace-period=0).",
|
|
72
|
+
"regex": "(?:^|[^A-Za-z0-9_.-])(?:kubectl|oc)(?![A-Za-z0-9_-])(?:[ \\t]+-[^ \\t\\r\\n;&|]+(?:[ \\t]+[^ \\t\\r\\n;&|-][^ \\t\\r\\n;&|]*)?){0,8}[ \\t]+delete(?:[ \\t]+-[^ \\t\\r\\n;&|]+(?:[ \\t]+[^ \\t\\r\\n;&|-][^ \\t\\r\\n;&|]*)?){0,6}[ \\t]+(?:namespaces?|ns)(?:/|,|(?![^ \\t\\r\\n;&|)]))",
|
|
73
|
+
"flags": "",
|
|
74
|
+
"severity": "critical",
|
|
75
|
+
"context": [
|
|
76
|
+
"bash"
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"id": "kubectl-delete-all",
|
|
81
|
+
"name": "kubectl/oc delete ... --all",
|
|
82
|
+
"description": "kubectl|oc with a delete token AND --all (or --all=true). --all-namespaces is excluded by the terminator check.",
|
|
83
|
+
"regex": "(?:^|[^A-Za-z0-9_.-])(?:kubectl|oc)(?![A-Za-z0-9_-])(?:[ \\t]+-[^ \\t\\r\\n;&|]+(?:[ \\t]+[^ \\t\\r\\n;&|-][^ \\t\\r\\n;&|]*)?){0,8}[ \\t]+delete(?![^ \\t\\r\\n;&|)])(?=[^;&|\\n]*[ \\t]--all(?:=[^ \\t\\r\\n;&|]*)?(?![^ \\t\\r\\n;&|)]))",
|
|
84
|
+
"flags": "",
|
|
85
|
+
"severity": "high",
|
|
86
|
+
"context": [
|
|
87
|
+
"bash"
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"id": "file-write-system-path",
|
|
92
|
+
"name": "File write into a system directory",
|
|
93
|
+
"description": "Anchored at path start: /etc/ /usr/ /bin/ /sbin/ /boot/ /sys/ /proc/ (leading // tolerated). Anchoring avoids matching myproject/etc/x or /home/u/usr/x, which the spec's unanchored /(etc|usr|...)/ would. The caller MUST resolve the path (path.resolve / realpath -m) first; ../../etc/passwd or symlinks are not caught by the pattern.",
|
|
94
|
+
"regex": "^/+(?:etc|usr|bin|sbin|boot|sys|proc)/",
|
|
95
|
+
"flags": "",
|
|
96
|
+
"severity": "critical",
|
|
97
|
+
"context": [
|
|
98
|
+
"file-write"
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# context-burn
|
|
2
|
+
|
|
3
|
+
Counts tool calls per session in a small status file. Where the harness reports token use,
|
|
4
|
+
it also warns once at 70% and once at 90% of the context window. It only observes: it never
|
|
5
|
+
blocks a tool and never changes a tool result.
|
|
6
|
+
|
|
7
|
+
## Reach and declared footprint
|
|
8
|
+
|
|
9
|
+
**L1 (file writes).** The manifest declares default writes to
|
|
10
|
+
`.claude/context-burn-status/*.json`, `.kiro/context-burn-status/*.json` and
|
|
11
|
+
`.codex/context-burn-status/*.json`, and reads `VANGUARD_CONTEXT_BURN_STATUS_PATH`.
|
|
12
|
+
It declares no subprocess spawns or network egress. This is capability disclosure,
|
|
13
|
+
not isolation; the status-path override can write outside these defaults.
|
|
14
|
+
|
|
15
|
+
## Status file
|
|
16
|
+
|
|
17
|
+
Fields (pretty-printed JSON, rewritten in full on every event):
|
|
18
|
+
|
|
19
|
+
- `session_id`: the session the counts belong to.
|
|
20
|
+
- `tool_call_count`: tool calls seen in that session.
|
|
21
|
+
- `last_updated`: ISO 8601 time of the last event.
|
|
22
|
+
- `threshold_events`: `{ threshold, tokens, window, at }` for each threshold crossed, at most
|
|
23
|
+
one per threshold per session. Empty where no token data exists.
|
|
24
|
+
|
|
25
|
+
Default paths, relative to the project:
|
|
26
|
+
|
|
27
|
+
| Harness | Path |
|
|
28
|
+
|---|---|
|
|
29
|
+
| Claude Code | `.claude/context-burn-status/<session-hash>.json` |
|
|
30
|
+
| Kiro | `.kiro/context-burn-status/<session-hash>.json` |
|
|
31
|
+
| Codex | `.codex/context-burn-status/<session-hash>.json` |
|
|
32
|
+
|
|
33
|
+
`<session-hash>` is the full lowercase SHA-256 of the UTF-8 `session_id`; raw session IDs
|
|
34
|
+
never appear in the path. Missing or empty Node session IDs use `unsessioned`, so those
|
|
35
|
+
events share one fallback file rather than isolated session state.
|
|
36
|
+
|
|
37
|
+
`VANGUARD_CONTEXT_BURN_STATUS_PATH` (when non-empty) is an exact path override. Using the
|
|
38
|
+
same override across sessions opts out of default isolation. It can point outside the
|
|
39
|
+
declared `file_writes` globs. Old single-file state is not migrated automatically.
|
|
40
|
+
|
|
41
|
+
## Per harness
|
|
42
|
+
|
|
43
|
+
| Harness | Behavior |
|
|
44
|
+
|---|---|
|
|
45
|
+
| Claude Code native | `session.start` resets the file. After each tool call it reads context use from the session, toasts the 70% warning and the 90% critical message once each, and updates the file. |
|
|
46
|
+
| Claude Code classic | Counter only. No token data, so no warnings. |
|
|
47
|
+
| Kiro | Counter only, as above. See `kiro/UNSUPPORTED.md`. |
|
|
48
|
+
| Codex | Counter only, as above. See `codex/UNSUPPORTED.md`. |
|
|
49
|
+
|
|
50
|
+
Messages: `[context-burn] WARNING: 70% context used (<tokens> / <window> tokens)` and
|
|
51
|
+
`[context-burn] CRITICAL: 90% context used (<tokens> / <window> tokens)`.
|
|
52
|
+
|
|
53
|
+
## Limitations
|
|
54
|
+
|
|
55
|
+
- Install either the native or the classic surface, not both: they share
|
|
56
|
+
the same session status file and would double count.
|
|
57
|
+
- Updates are unlocked read-modify-write operations, so same-session parallel hooks can
|
|
58
|
+
lose increments. There is no automatic cleanup of old status files.
|
|
59
|
+
- Classic PostToolUse fires only after a tool succeeds, so failed calls are not counted.
|
|
60
|
+
- An unreadable or invalid status file, including malformed threshold event objects or
|
|
61
|
+
fields, starts a fresh count; the Node hook says so on stderr.
|
|
62
|
+
- The native API was verified against Claude Code 2.1.296. The Node hooks need Node 22+.
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
See `mods/README.md`.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Command hook for classic Claude Code, Kiro and Codex: counter only, no token data.
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
import { defaultStatusPath, freshStatus, parseStatus, recordCall, serializeStatus } from '../lib/core.mjs'
|
|
5
|
+
import { projectPath, runHook } from '../lib/runtime.mjs'
|
|
6
|
+
|
|
7
|
+
const target = async (ctx, sessionId) =>
|
|
8
|
+
projectPath(process.env.VANGUARD_CONTEXT_BURN_STATUS_PATH || await defaultStatusPath(ctx.harness, sessionId), ctx.harness, import.meta.dirname)
|
|
9
|
+
const sessionOf = (p) => (typeof p.session_id === 'string' && p.session_id !== '' ? p.session_id : 'unsessioned')
|
|
10
|
+
|
|
11
|
+
function save(file, status) {
|
|
12
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
13
|
+
writeFileSync(file, serializeStatus(status))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const handlers = {
|
|
17
|
+
async SessionStart(payload, ctx) {
|
|
18
|
+
const sessionId = sessionOf(payload)
|
|
19
|
+
save(await target(ctx, sessionId), freshStatus(sessionId, new Date().toISOString()))
|
|
20
|
+
},
|
|
21
|
+
// ponytail: parallel hooks can lose an increment (read-modify-write, no lock); add the
|
|
22
|
+
// audit-trail lock if exact counts matter.
|
|
23
|
+
async PostToolUse(payload, ctx) {
|
|
24
|
+
const sessionId = sessionOf(payload)
|
|
25
|
+
const file = await target(ctx, sessionId)
|
|
26
|
+
const log = []
|
|
27
|
+
let prev
|
|
28
|
+
try {
|
|
29
|
+
prev = parseStatus(readFileSync(file, 'utf8'))
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (err?.code !== 'ENOENT') log.push(`[context-burn] status file unreadable; starting a fresh count: ${err.message}`)
|
|
32
|
+
}
|
|
33
|
+
save(file, recordCall(prev, sessionId, new Date().toISOString(), undefined).status)
|
|
34
|
+
return log.length ? { decision: 'allow', log } : undefined
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
process.exitCode = await runHook({ name: 'context-burn', handlers })
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Context Burn on Claude Code
|
|
2
|
+
|
|
3
|
+
<!-- Generated by scripts/generate-mods.mjs from mod.toml. Do not edit. -->
|
|
4
|
+
|
|
5
|
+
## Degraded
|
|
6
|
+
|
|
7
|
+
- `classic`: Classic hooks receive no token counts, so only the tool-call counter is kept and no threshold warnings are emitted; PostToolUse fires only after a tool succeeds, so failed calls are not counted.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PostToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/vanguard-mods/context-burn/bin/hook.mjs\" --harness claude-code --event PostToolUse"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
],
|
|
13
|
+
"SessionStart": [
|
|
14
|
+
{
|
|
15
|
+
"hooks": [
|
|
16
|
+
{
|
|
17
|
+
"type": "command",
|
|
18
|
+
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/vanguard-mods/context-burn/bin/hook.mjs\" --harness claude-code --event SessionStart"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PostToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "node .codex/vanguard-mods/context-burn/bin/hook.mjs --harness codex --event PostToolUse"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
],
|
|
13
|
+
"SessionStart": [
|
|
14
|
+
{
|
|
15
|
+
"hooks": [
|
|
16
|
+
{
|
|
17
|
+
"type": "command",
|
|
18
|
+
"command": "node .codex/vanguard-mods/context-burn/bin/hook.mjs --harness codex --event SessionStart"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { test, expect, mock } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
const ENV = 'VANGUARD_CONTEXT_BURN_STATUS_PATH'
|
|
4
|
+
const DEFAULT = '.claude/context-burn-status/e8bc163c82eee18733288c7d4ac636db3a6deb013ef2d37b68322be20edc45cc.json'
|
|
5
|
+
const RESULT = { result: { stdout: 'x', stderr: '', interrupted: false } }
|
|
6
|
+
const call = { tool: 'Bash', command: 'echo x' }
|
|
7
|
+
const status = (over: object = {}) => ({
|
|
8
|
+
session_id: 's1',
|
|
9
|
+
tool_call_count: 4,
|
|
10
|
+
last_updated: '1970-01-01T00:00:00.000Z',
|
|
11
|
+
threshold_events: [],
|
|
12
|
+
...over,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// Wires every `$` noun a tool.call touches. Returns what the mod wrote and toasted.
|
|
16
|
+
function rig(on: any, o: { env?: object; prev?: object | null; readFails?: boolean; writeFails?: boolean; context?: object; session?: { id: string }; files?: Map<string, string> } = {}) {
|
|
17
|
+
const writes: { path: string; text: string }[] = []
|
|
18
|
+
const toasts: string[] = []
|
|
19
|
+
mock.env(on, o.env ?? {})
|
|
20
|
+
mock.clock(on, { now: 0 })
|
|
21
|
+
on('session.id', () => ({ value: o.session?.id ?? 's1' }))
|
|
22
|
+
on('session.usage', () => ({
|
|
23
|
+
value: { startedAt: 0, context: o.context ?? { window: 10000 }, rateLimits: [] },
|
|
24
|
+
}))
|
|
25
|
+
on('fs.read', ($: any, e: any) => {
|
|
26
|
+
if (o.files) {
|
|
27
|
+
if (!o.files.has(e.path)) throw new Error('ENOENT')
|
|
28
|
+
return { value: o.files.get(e.path) }
|
|
29
|
+
}
|
|
30
|
+
if (o.readFails || !o.prev) throw new Error('ENOENT')
|
|
31
|
+
return { value: JSON.stringify(o.prev) }
|
|
32
|
+
})
|
|
33
|
+
on('fs.write', ($: any, e: any) => {
|
|
34
|
+
if (o.writeFails) throw new Error('disk full')
|
|
35
|
+
writes.push({ path: e.path, text: e.text })
|
|
36
|
+
o.files?.set(e.path, e.text)
|
|
37
|
+
return { value: undefined }
|
|
38
|
+
})
|
|
39
|
+
on('ui.toast', ($: any, e: any) => {
|
|
40
|
+
toasts.push(e.text)
|
|
41
|
+
return { value: undefined }
|
|
42
|
+
})
|
|
43
|
+
on('tool.call', () => RESULT)
|
|
44
|
+
on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
|
|
45
|
+
return { writes, toasts, last: () => JSON.parse(writes[writes.length - 1].text) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('session.start writes a fresh status', async ($, on) => {
|
|
49
|
+
const r = rig(on)
|
|
50
|
+
await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true })
|
|
51
|
+
expect(r.writes[0].path.endsWith(DEFAULT)).toBe(true)
|
|
52
|
+
expect(r.last()).toEqual({ session_id: 's1', tool_call_count: 0, last_updated: '1970-01-01T00:00:00.000Z', threshold_events: [] })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('session.start honors the env override', async ($, on) => {
|
|
56
|
+
const r = rig(on, { env: { [ENV]: 'custom/out.json' } })
|
|
57
|
+
await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true })
|
|
58
|
+
expect(r.writes[0].path.endsWith('custom/out.json')).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('tool.call passes the result through and increments the count', async ($, on) => {
|
|
62
|
+
const r = rig(on, { prev: status() })
|
|
63
|
+
expect(await $.tool.call(call)).toEqual(RESULT)
|
|
64
|
+
expect(r.last().tool_call_count).toBe(5)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('a status for another session restarts at 1', async ($, on) => {
|
|
68
|
+
const r = rig(on, { prev: status({ session_id: 'other' }) })
|
|
69
|
+
await $.tool.call(call)
|
|
70
|
+
expect(r.last().session_id).toBe('s1')
|
|
71
|
+
expect(r.last().tool_call_count).toBe(1)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('75 percent toasts one warning and records the event', async ($, on) => {
|
|
75
|
+
const r = rig(on, { prev: status(), context: { tokens: 7500, window: 10000, percent: 75 } })
|
|
76
|
+
await $.tool.call(call)
|
|
77
|
+
expect(r.toasts).toEqual(['[context-burn] WARNING: 70% context used (7500 / 10000 tokens)'])
|
|
78
|
+
expect(r.last().threshold_events.length).toBe(1)
|
|
79
|
+
expect(r.last().threshold_events[0].threshold).toBe(70)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('an already recorded 70 event does not toast again', async ($, on) => {
|
|
83
|
+
const prev = status({ threshold_events: [{ threshold: 70, tokens: 7500, window: 10000, at: 'x' }] })
|
|
84
|
+
const r = rig(on, { prev, context: { tokens: 7600, window: 10000, percent: 76 } })
|
|
85
|
+
await $.tool.call(call)
|
|
86
|
+
expect(r.toasts).toEqual([])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('95 percent toasts warning then critical', async ($, on) => {
|
|
90
|
+
const r = rig(on, { prev: status(), context: { tokens: 9500, window: 10000, percent: 95 } })
|
|
91
|
+
await $.tool.call(call)
|
|
92
|
+
expect(r.toasts).toEqual([
|
|
93
|
+
'[context-burn] WARNING: 70% context used (9500 / 10000 tokens)',
|
|
94
|
+
'[context-burn] CRITICAL: 90% context used (9500 / 10000 tokens)',
|
|
95
|
+
])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('percent without tokens computes and rounds tokens', async ($, on) => {
|
|
99
|
+
const r = rig(on, { prev: status(), context: { window: 3333, percent: 71.5 } })
|
|
100
|
+
await $.tool.call(call)
|
|
101
|
+
expect(r.toasts).toEqual(['[context-burn] WARNING: 70% context used (2383 / 3333 tokens)'])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('no token data means no toast but the count still rises', async ($, on) => {
|
|
105
|
+
const r = rig(on, { prev: status(), context: { window: 10000 } })
|
|
106
|
+
await $.tool.call(call)
|
|
107
|
+
expect(r.toasts).toEqual([])
|
|
108
|
+
expect(r.last().tool_call_count).toBe(5)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('a failing read starts a fresh count', async ($, on) => {
|
|
112
|
+
const r = rig(on, { readFails: true })
|
|
113
|
+
await $.tool.call(call)
|
|
114
|
+
expect(r.last().tool_call_count).toBe(1)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('malformed threshold events restart counting and still toast the threshold', async ($, on) => {
|
|
118
|
+
const r = rig(on, { prev: status({ threshold_events: [null] }), context: { tokens: 7500, window: 10000 } })
|
|
119
|
+
expect(await $.tool.call(call)).toEqual(RESULT)
|
|
120
|
+
expect(r.last().tool_call_count).toBe(1)
|
|
121
|
+
expect(r.last().threshold_events.length).toBe(1)
|
|
122
|
+
expect(r.toasts.length).toBe(1)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('interleaved sessions retain independent counts and warn only once per session', async ($, on) => {
|
|
126
|
+
const session = { id: '../first' }
|
|
127
|
+
const files = new Map<string, string>()
|
|
128
|
+
const r = rig(on, { session, files, context: { tokens: 7500, window: 10000 } })
|
|
129
|
+
for (const id of ['../first', '..\\second']) {
|
|
130
|
+
session.id = id
|
|
131
|
+
await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true })
|
|
132
|
+
expect(await $.tool.call(call)).toEqual(RESULT)
|
|
133
|
+
}
|
|
134
|
+
for (const id of ['../first', '..\\second']) {
|
|
135
|
+
session.id = id
|
|
136
|
+
expect(await $.tool.call(call)).toEqual(RESULT)
|
|
137
|
+
}
|
|
138
|
+
expect(files.size).toBe(2)
|
|
139
|
+
for (const [path, text] of files) {
|
|
140
|
+
expect(/\/context-burn-status\/[a-f0-9]{64}\.json$/.test(path)).toBe(true)
|
|
141
|
+
const saved = JSON.parse(text)
|
|
142
|
+
expect(saved.tool_call_count).toBe(2)
|
|
143
|
+
expect(saved.threshold_events.length).toBe(1)
|
|
144
|
+
}
|
|
145
|
+
expect(r.toasts.length).toBe(2)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('a failing write still returns the tool result', async ($, on) => {
|
|
149
|
+
rig(on, { prev: status(), writeFails: true })
|
|
150
|
+
expect(await $.tool.call(call)).toEqual(RESULT)
|
|
151
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Register } from 'claude-code'
|
|
2
|
+
import { defaultStatusPath, freshStatus, parseStatus, recordCall, serializeStatus } from '../lib/core.mjs'
|
|
3
|
+
|
|
4
|
+
async function statusPath($: any, sessionId: string): Promise<string> {
|
|
5
|
+
return (await $.env.get('VANGUARD_CONTEXT_BURN_STATUS_PATH')) || await defaultStatusPath('claude-code', sessionId)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const register: Register = (on) => {
|
|
9
|
+
// A new session resets the file; a failure must never block startup.
|
|
10
|
+
on('session.start', async ($, e, next) => {
|
|
11
|
+
const at = new Date(await $.clock.now()).toISOString()
|
|
12
|
+
const sessionId = await $.session.id()
|
|
13
|
+
await $.fs.write(await statusPath($, sessionId), serializeStatus(freshStatus(sessionId, at)))
|
|
14
|
+
return next(e)
|
|
15
|
+
}).catch(($, e, next) => next(e))
|
|
16
|
+
|
|
17
|
+
// Observer: the tool runs first and its result is returned untouched, whatever happens here.
|
|
18
|
+
on('tool.call', async ($, e, next) => {
|
|
19
|
+
const r = await next(e)
|
|
20
|
+
const sessionId = await $.session.id()
|
|
21
|
+
const path = await statusPath($, sessionId)
|
|
22
|
+
let prev
|
|
23
|
+
try {
|
|
24
|
+
prev = parseStatus(await $.fs.read(path))
|
|
25
|
+
} catch {
|
|
26
|
+
prev = undefined
|
|
27
|
+
}
|
|
28
|
+
const at = new Date(await $.clock.now()).toISOString()
|
|
29
|
+
const { context } = await $.session.usage()
|
|
30
|
+
const { status, messages } = recordCall(prev, sessionId, at, context)
|
|
31
|
+
for (const m of messages) await $.ui.toast(m)
|
|
32
|
+
await $.fs.write(path, serializeStatus(status))
|
|
33
|
+
return r
|
|
34
|
+
}).catch(($, e, next) => next(e))
|
|
35
|
+
}
|