@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,768 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { closeSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { dirname, join, resolve } from 'node:path'
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
9
|
+
import { canonicalJson, candidates, compilePatterns, LIMITS, maskValue } from '../scripts/mods/common.mjs'
|
|
10
|
+
import { parseArgs, projectPath, runHook } from '../scripts/mods/runtime.mjs'
|
|
11
|
+
import { loadMods, parseToml, renderAll, renderMod } from '../scripts/generate-mods.mjs'
|
|
12
|
+
import { checkInput, loadPatterns, redactOutput, redactResult, WITHHELD } from '../mods/secret-redactor/lib/core.mjs'
|
|
13
|
+
import { checkToolUse as blastCheck, compileGuardPatterns, modeOf, normalizePosix, toolUseFromPayload } from '../mods/blast-radius-guard/lib/core.mjs'
|
|
14
|
+
import { checkToolUse as liveCheck } from '../mods/live-guard-gate/lib/core.mjs'
|
|
15
|
+
import livePatterns from '../mods/live-guard-gate/lib/patterns.mjs'
|
|
16
|
+
import { defaultStatusPath, freshStatus, parseStatus, recordCall, serializeStatus } from '../mods/context-burn/lib/core.mjs'
|
|
17
|
+
import { buildRecord, lastSequence, rawSessionId, safeSessionId } from '../mods/audit-trail/lib/core.mjs'
|
|
18
|
+
|
|
19
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
20
|
+
const HARNESSES = ['claude-code', 'kiro', 'codex']
|
|
21
|
+
const GUARDS = ['secret-redactor', 'blast-radius-guard', 'live-guard-gate']
|
|
22
|
+
const IDS = ['audit-trail', 'blast-radius-guard', 'context-burn', 'live-guard-gate', 'secret-redactor']
|
|
23
|
+
// Construct fake credentials at runtime; never place a credential-shaped literal in Git.
|
|
24
|
+
const ACCESS = 'AKIA' + 'Z'.repeat(16)
|
|
25
|
+
const TEMP_ACCESS = 'ASIA' + 'Y'.repeat(16)
|
|
26
|
+
const PAT = 'ghp_' + 'a'.repeat(36)
|
|
27
|
+
const FINE_PAT = 'github_pat_' + 'b'.repeat(24)
|
|
28
|
+
const SECRET = 'c'.repeat(40)
|
|
29
|
+
const BEARER = 'Bearer ' + 'd'.repeat(24)
|
|
30
|
+
const fixtureUrl = new URL('https://example.test/path')
|
|
31
|
+
fixtureUrl.username = 'reader'
|
|
32
|
+
fixtureUrl.password = 'e'.repeat(16)
|
|
33
|
+
const URL_SECRET = fixtureUrl.href
|
|
34
|
+
const AT = '2000-01-01T00:00:00.000Z'
|
|
35
|
+
const pattern = (overrides = {}) => ({ id: 'extra-check', name: 'Extra check', regex: 'extra_[a-z]{8}', flags: '', context: ['bash'], ...overrides })
|
|
36
|
+
const digest = (value) => createHash('sha256').update(canonicalJson(value)).digest('hex')
|
|
37
|
+
const hookPath = (id) => join(ROOT, 'mods', id, 'bin', 'hook.mjs')
|
|
38
|
+
|
|
39
|
+
test('synthetic credential URL retains its exact wire value', () => {
|
|
40
|
+
const expected = ['https://', 'reader', ':', 'e'.repeat(16), '@example.test/path'].join('')
|
|
41
|
+
assert.equal(URL_SECRET, expected)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
function temp(t) {
|
|
45
|
+
const dir = mkdtempSync(join(tmpdir(), 'vfa-mods-test-'))
|
|
46
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }))
|
|
47
|
+
return dir
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hook(id, harness, event, payload, { cwd = ROOT, env = {}, raw, args } = {}) {
|
|
51
|
+
const dir = mkdtempSync(join(tmpdir(), 'vfa-hook-input-')), file = join(dir, 'stdin.json')
|
|
52
|
+
writeFileSync(file, raw ?? JSON.stringify(payload))
|
|
53
|
+
const fd = openSync(file, 'r')
|
|
54
|
+
try {
|
|
55
|
+
const r = spawnSync(process.execPath, [hookPath(id), ...(args ?? ['--harness', harness, '--event', event])], {
|
|
56
|
+
cwd, env, encoding: 'utf8', stdio: [fd, 'pipe', 'pipe'], timeout: 5000, maxBuffer: 2 ** 20,
|
|
57
|
+
})
|
|
58
|
+
assert.ifError(r.error)
|
|
59
|
+
assert.equal(r.signal, null)
|
|
60
|
+
return r
|
|
61
|
+
} finally {
|
|
62
|
+
closeSync(fd)
|
|
63
|
+
rmSync(dir, { recursive: true, force: true })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hookAsync(id, harness, event, payload, options) {
|
|
68
|
+
return new Promise((resolveRun, reject) => {
|
|
69
|
+
const dir = mkdtempSync(join(tmpdir(), 'vfa-hook-input-')), file = join(dir, 'stdin.json')
|
|
70
|
+
writeFileSync(file, JSON.stringify(payload))
|
|
71
|
+
const fd = openSync(file, 'r')
|
|
72
|
+
const child = spawn(process.execPath, [hookPath(id), '--harness', harness, '--event', event], { ...options, stdio: [fd, 'pipe', 'pipe'], timeout: 5000 })
|
|
73
|
+
closeSync(fd)
|
|
74
|
+
let stdout = '', stderr = ''
|
|
75
|
+
child.stdout.setEncoding('utf8').on('data', (s) => { stdout += s })
|
|
76
|
+
child.stderr.setEncoding('utf8').on('data', (s) => { stderr += s })
|
|
77
|
+
child.on('error', reject)
|
|
78
|
+
child.on('close', (status, signal) => {
|
|
79
|
+
rmSync(dir, { recursive: true, force: true })
|
|
80
|
+
signal ? reject(new Error(`hook killed by ${signal}`)) : resolveRun({ status, stdout, stderr })
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function generatorFixture(dir) {
|
|
86
|
+
for (const rel of ['scripts/mods/common.mjs', 'scripts/mods/runtime.mjs', 'catalog/agents.json', ...IDS.flatMap((id) => [`mods/${id}/mod.toml`, ...(GUARDS.includes(id) ? [`mods/${id}/patterns.json`] : [])])]) {
|
|
87
|
+
mkdirSync(dirname(join(dir, rel)), { recursive: true })
|
|
88
|
+
cpSync(join(ROOT, rel), join(dir, rel))
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function generator(root, args) {
|
|
93
|
+
const source = `import { main } from ${JSON.stringify(pathToFileURL(join(ROOT, 'scripts/generate-mods.mjs')).href)}; process.exitCode = main(${JSON.stringify(args)}, ${JSON.stringify(root)})`
|
|
94
|
+
const r = spawnSync(process.execPath, ['--input-type=module', '-e', source], { encoding: 'utf8', env: {}, stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 })
|
|
95
|
+
assert.ifError(r.error)
|
|
96
|
+
return r
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const output = (r) => JSON.parse(r.stdout).hookSpecificOutput
|
|
100
|
+
function denied(r, harness, decision = 'deny') {
|
|
101
|
+
assert.equal(r.status, harness === 'claude-code' ? 0 : 2, r.stderr)
|
|
102
|
+
if (harness === 'claude-code') assert.equal(output(r).permissionDecision, decision)
|
|
103
|
+
else { assert.equal(r.stdout, ''); assert.match(r.stderr, /denied|live mutation|blast-radius-guard/) }
|
|
104
|
+
}
|
|
105
|
+
function clean(r) {
|
|
106
|
+
assert.equal(r.status, 0, r.stderr)
|
|
107
|
+
assert.equal(r.stdout, '')
|
|
108
|
+
assert.equal(r.stderr, '')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
test('support matrix is explicit for all five mods', () => {
|
|
112
|
+
const { mods, errors } = loadMods(ROOT)
|
|
113
|
+
assert.deepEqual(errors, [])
|
|
114
|
+
assert.deepEqual(mods.map((m) => m.id), IDS)
|
|
115
|
+
for (const { id, manifest } of mods) {
|
|
116
|
+
for (const h of HARNESSES) {
|
|
117
|
+
const events = id === 'context-burn' ? ['PostToolUse', 'SessionStart'] : id === 'audit-trail' ? ['PostToolUse'] : id === 'secret-redactor' && h === 'claude-code' ? ['PreToolUse', 'PostToolUse'] : ['PreToolUse']
|
|
118
|
+
assert.equal(manifest.harnesses[h].supported, true)
|
|
119
|
+
assert.deepEqual([...manifest.harnesses[h].events].sort(), [...events].sort())
|
|
120
|
+
}
|
|
121
|
+
assert.equal(manifest.mod.reach_level, ['audit-trail', 'context-burn'].includes(id) ? 1 : 0)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
for (const harness of HARNESSES) {
|
|
126
|
+
for (const id of GUARDS) {
|
|
127
|
+
test(`${id} ${harness} PreToolUse: clean allows; unsafe input asks or denies`, () => {
|
|
128
|
+
clean(hook(id, harness, 'PreToolUse', { tool_name: 'Bash', tool_input: { command: 'printf hello' } }))
|
|
129
|
+
const unsafe = id === 'secret-redactor' ? ACCESS : id === 'blast-radius-guard' ? 'rm -rf /' : 'terraform apply'
|
|
130
|
+
const r = hook(id, harness, 'PreToolUse', { tool_name: 'Bash', tool_input: { command: unsafe } })
|
|
131
|
+
denied(r, harness, 'ask')
|
|
132
|
+
if (id === 'secret-redactor') assert.ok(!(r.stdout + r.stderr).includes(ACCESS))
|
|
133
|
+
if (id === 'live-guard-gate') assert.match(r.stdout + r.stderr, /aws-live-iac-change-guard-agent/)
|
|
134
|
+
})
|
|
135
|
+
test(`${id} ${harness} malformed JSON and non-object payloads fail closed`, () => {
|
|
136
|
+
for (const raw of ['{"input":' + JSON.stringify(ACCESS), 'null', '[]', '"ordinary string"']) {
|
|
137
|
+
const r = hook(id, harness, 'PreToolUse', null, { raw })
|
|
138
|
+
denied(r, harness)
|
|
139
|
+
assert.ok(!(r.stdout + r.stderr).includes(ACCESS))
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
test(`${id} ${harness} missing tool_input still scans the whole payload`, () => {
|
|
143
|
+
const text = id === 'secret-redactor' ? PAT : id === 'blast-radius-guard' ? 'rm -r /' : 'sam deploy'
|
|
144
|
+
denied(hook(id, harness, 'PreToolUse', { undocumented_input: { argv: [text] } }), harness, 'ask')
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
test(`context-burn ${harness}: both events write counters, reset sessions, and report invalid input`, (t) => {
|
|
149
|
+
const dir = temp(t), file = join(dir, 'status.json')
|
|
150
|
+
const options = { cwd: dir, env: { VANGUARD_CONTEXT_BURN_STATUS_PATH: file } }
|
|
151
|
+
clean(hook('context-burn', harness, 'SessionStart', { session_id: 'first' }, options))
|
|
152
|
+
assert.equal(parseStatus(readFileSync(file, 'utf8')).tool_call_count, 0)
|
|
153
|
+
for (let n = 1; n <= 2; n++) {
|
|
154
|
+
clean(hook('context-burn', harness, 'PostToolUse', { session_id: 'first' }, options))
|
|
155
|
+
const status = parseStatus(readFileSync(file, 'utf8'))
|
|
156
|
+
assert.equal(status.tool_call_count, n)
|
|
157
|
+
assert.deepEqual(status.threshold_events, [])
|
|
158
|
+
assert.match(status.last_updated, /^\d{4}-\d{2}-\d{2}T.*Z$/)
|
|
159
|
+
}
|
|
160
|
+
clean(hook('context-burn', harness, 'PostToolUse', { session_id: 'second' }, options))
|
|
161
|
+
assert.equal(parseStatus(readFileSync(file, 'utf8')).tool_call_count, 1)
|
|
162
|
+
clean(hook('context-burn', harness, 'SessionStart', { session_id: 'second' }, options))
|
|
163
|
+
assert.equal(parseStatus(readFileSync(file, 'utf8')).tool_call_count, 0)
|
|
164
|
+
for (const event of ['SessionStart', 'PostToolUse']) {
|
|
165
|
+
const r = hook('context-burn', harness, event, null, { ...options, raw: '{' })
|
|
166
|
+
assert.equal(r.status, 1)
|
|
167
|
+
assert.match(r.stderr, /not valid JSON/)
|
|
168
|
+
assert.equal(parseStatus(readFileSync(file, 'utf8')).tool_call_count, 0)
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test(`context-burn ${harness}: interleaved sessions keep independent default counters`, (t) => {
|
|
173
|
+
const dir = temp(t), dot = harness === 'claude-code' ? '.claude' : `.${harness}`
|
|
174
|
+
const sessions = ['../first', '..\\second']
|
|
175
|
+
const options = { cwd: dir }
|
|
176
|
+
for (const session_id of sessions) {
|
|
177
|
+
clean(hook('context-burn', harness, 'SessionStart', { session_id }, options))
|
|
178
|
+
clean(hook('context-burn', harness, 'PostToolUse', { session_id }, options))
|
|
179
|
+
}
|
|
180
|
+
for (const session_id of sessions) clean(hook('context-burn', harness, 'PostToolUse', { session_id }, options))
|
|
181
|
+
const statusDir = join(dir, dot, 'context-burn-status')
|
|
182
|
+
assert.ok(existsSync(statusDir), 'default state must be stored per session')
|
|
183
|
+
const files = readdirSync(statusDir)
|
|
184
|
+
assert.equal(files.length, 2)
|
|
185
|
+
for (const file of files) assert.match(file, /^[a-f0-9]{64}\.json$/)
|
|
186
|
+
const statuses = files.map((file) => parseStatus(readFileSync(join(statusDir, file), 'utf8')))
|
|
187
|
+
assert.deepEqual(statuses.map((s) => [s.session_id, s.tool_call_count]).sort(), sessions.map((id) => [id, 2]).sort())
|
|
188
|
+
clean(hook('context-burn', harness, 'SessionStart', { session_id: sessions[0] }, options))
|
|
189
|
+
const reset = files.map((file) => parseStatus(readFileSync(join(statusDir, file), 'utf8')))
|
|
190
|
+
assert.deepEqual(reset.map((s) => [s.session_id, s.tool_call_count]).sort(), [[sessions[0], 0], [sessions[1], 2]].sort())
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test(`context-burn ${harness}: malformed persisted threshold events restart the count`, (t) => {
|
|
194
|
+
const dir = temp(t), file = join(dir, 'status.json')
|
|
195
|
+
for (const event of [null, { threshold: 70 }]) {
|
|
196
|
+
writeFileSync(file, serializeStatus({ ...freshStatus('recover', AT), tool_call_count: 4, threshold_events: [event] }))
|
|
197
|
+
const r = hook('context-burn', harness, 'PostToolUse', { session_id: 'recover' }, { env: { VANGUARD_CONTEXT_BURN_STATUS_PATH: file } })
|
|
198
|
+
assert.equal(r.status, 0, r.stderr)
|
|
199
|
+
assert.match(r.stderr, /starting a fresh count/)
|
|
200
|
+
const status = parseStatus(readFileSync(file, 'utf8'))
|
|
201
|
+
assert.equal(status.tool_call_count, 1)
|
|
202
|
+
assert.deepEqual(status.threshold_events, [])
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test(`audit-trail ${harness}: real append hashes payloads without logging raw arguments`, async (t) => {
|
|
207
|
+
const dir = temp(t), logs = join(dir, 'logs')
|
|
208
|
+
const payload = { session_id: 'matrix', tool_name: 'Bash', tool_input: { key: ACCESS, b: 2, a: 1 }, tool_response: { stdout: PAT } }
|
|
209
|
+
const options = { cwd: dir, env: { VANGUARD_AUDIT_TRAIL_LOG_DIR: logs, VANGUARD_AUDIT_TRAIL_AGENT_ID: 'test-agent' } }
|
|
210
|
+
clean(hook('audit-trail', harness, 'PostToolUse', payload, options))
|
|
211
|
+
const day = readdirSync(logs)[0], file = join(logs, day, 'matrix.jsonl')
|
|
212
|
+
const before = readFileSync(file, 'utf8')
|
|
213
|
+
clean(hook('audit-trail', harness, 'PostToolUse', payload, options))
|
|
214
|
+
const text = readFileSync(file, 'utf8'), records = text.trimEnd().split('\n').map(JSON.parse)
|
|
215
|
+
assert.ok(text.startsWith(before))
|
|
216
|
+
assert.ok(text.endsWith('\n'))
|
|
217
|
+
assert.ok(!text.includes(ACCESS) && !text.includes(PAT))
|
|
218
|
+
assert.deepEqual(records.map((r) => r.sequence_number), [1, 2])
|
|
219
|
+
for (const r of records) {
|
|
220
|
+
assert.equal(r.session_id, 'matrix')
|
|
221
|
+
assert.equal(r.agent_id, 'test-agent')
|
|
222
|
+
assert.equal(r.tool_name, 'Bash')
|
|
223
|
+
assert.equal(r.args_hash, digest(harness === 'kiro' ? payload : payload.tool_input))
|
|
224
|
+
assert.equal(r.result_hash, digest(harness === 'kiro' ? null : payload.tool_response))
|
|
225
|
+
assert.match(r.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
|
|
226
|
+
}
|
|
227
|
+
const bad = hook('audit-trail', harness, 'PostToolUse', null, { ...options, raw: '{' })
|
|
228
|
+
assert.equal(bad.status, 1)
|
|
229
|
+
assert.match(bad.stderr, /not valid JSON/)
|
|
230
|
+
assert.equal(readFileSync(file, 'utf8'), text)
|
|
231
|
+
assert.equal(existsSync(join(logs, 'matrix.lock')), false)
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
test('secret-redactor classic PostToolUse preserves output shape and logs only pattern names', () => {
|
|
236
|
+
const response = { stdout: `before ${ACCESS} after`, stderr: PAT, interrupted: false, lines: [null, 2, { nested: FINE_PAT }], empty: '' }
|
|
237
|
+
const r = hook('secret-redactor', 'claude-code', 'PostToolUse', { tool_name: 'Bash', tool_response: response })
|
|
238
|
+
assert.equal(r.status, 0, r.stderr)
|
|
239
|
+
const updated = output(r).updatedToolOutput
|
|
240
|
+
assert.deepEqual(Object.keys(updated), Object.keys(response))
|
|
241
|
+
assert.equal(updated.stdout, 'before [REDACTED:aws-access-key] after')
|
|
242
|
+
assert.equal(updated.interrupted, false)
|
|
243
|
+
assert.equal(updated.lines[0], null)
|
|
244
|
+
assert.equal(updated.lines[1], 2)
|
|
245
|
+
assert.match(updated.lines[2].nested, /^\[REDACTED:/)
|
|
246
|
+
assert.match(r.stderr, /\[secret-redactor\] redacted .* in Bash PostToolUse/)
|
|
247
|
+
assert.ok(![ACCESS, PAT, FINE_PAT].some((s) => (r.stdout + r.stderr).includes(s)))
|
|
248
|
+
clean(hook('secret-redactor', 'claude-code', 'PostToolUse', { tool_response: 'ordinary text' }))
|
|
249
|
+
const malformed = hook('secret-redactor', 'claude-code', 'PostToolUse', null, { raw: '{' })
|
|
250
|
+
assert.equal(malformed.status, 0)
|
|
251
|
+
assert.equal(output(malformed).updatedToolOutput, WITHHELD)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('secret-redactor refuses unsupported PostToolUse even when output is clean', () => {
|
|
255
|
+
for (const harness of ['kiro', 'codex']) for (const tool_response of ['ordinary text', ACCESS]) {
|
|
256
|
+
const r = hook('secret-redactor', harness, 'PostToolUse', { tool_response })
|
|
257
|
+
assert.equal(r.status, 2, `${harness}: unsupported event must never allow`)
|
|
258
|
+
assert.equal(r.stdout, '')
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('unknown harness, event, flags, and missing handlers exit 2 for every mod', () => {
|
|
263
|
+
for (const id of IDS) {
|
|
264
|
+
for (const args of [[], ['--harness', 'unknown', '--event', 'PreToolUse'], ['--harness', 'codex', '--event', 'Unknown'], ['--harness', 'codex', '--harness', 'kiro']]) {
|
|
265
|
+
const r = hook(id, 'codex', 'PreToolUse', {}, { args })
|
|
266
|
+
assert.equal(r.status, 2)
|
|
267
|
+
assert.match(r.stderr, /usage:/)
|
|
268
|
+
}
|
|
269
|
+
const r = hook(id, 'codex', id === 'context-burn' ? 'PreToolUse' : 'SessionStart', {})
|
|
270
|
+
assert.equal(r.status, 2)
|
|
271
|
+
assert.match(r.stderr, /no handler/)
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('runtime rejects invalid decisions and an error handler that also fails', async () => {
|
|
276
|
+
const logs = []
|
|
277
|
+
const io = { argv: ['--harness', 'codex', '--event', 'PreToolUse'], readStdin: () => '{}', out: (s) => logs.push(s), err: (s) => logs.push(s) }
|
|
278
|
+
assert.equal(await runHook({ name: 'test', handlers: { PreToolUse: () => ({ decision: 'bogus' }) } }, io), 1)
|
|
279
|
+
assert.equal(await runHook({ name: 'test', handlers: { PreToolUse: () => { throw new Error('boom') } }, onError: () => ({ decision: 'deny' }) }, io), 2)
|
|
280
|
+
assert.match(logs.join(''), /invalid decision/)
|
|
281
|
+
assert.match(logs.join(''), /error handler failed/)
|
|
282
|
+
assert.deepEqual(parseArgs(['--event', 'SessionStart', '--harness', 'kiro']), { event: 'SessionStart', harness: 'kiro' })
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('P1 built-in credential types and split context reach a shape-preserving fixed point', () => {
|
|
286
|
+
const patterns = loadPatterns()
|
|
287
|
+
for (const secret of [ACCESS, TEMP_ACCESS, PAT, FINE_PAT, BEARER, URL_SECRET]) {
|
|
288
|
+
const original = { text: `prefix ${secret} suffix`, values: [false, null, 42, { clean: 'retained' }] }
|
|
289
|
+
const redacted = redactOutput(original, patterns)
|
|
290
|
+
assert.ok(redacted.hits.length > 0)
|
|
291
|
+
assert.deepEqual(Object.keys(redacted.value), Object.keys(original))
|
|
292
|
+
assert.deepEqual(redacted.value.values, original.values)
|
|
293
|
+
assert.ok(!JSON.stringify(redacted.value).includes(secret))
|
|
294
|
+
assert.deepEqual(redactOutput(redacted.value, patterns), { value: redacted.value, hits: [] })
|
|
295
|
+
assert.equal(checkInput({ command: secret }, patterns).decision, 'ask')
|
|
296
|
+
}
|
|
297
|
+
for (const split of [{ aws_secret_access_key: SECRET }, ['--aws-secret-access-key', SECRET], { name: 'SecretAccessKey', value: SECRET }, { key: 'SecretAccessKey', value: SECRET }, ['Bearer', 'd'.repeat(24)]]) {
|
|
298
|
+
const result = redactOutput(split, patterns)
|
|
299
|
+
assert.ok(result.hits.length > 0)
|
|
300
|
+
assert.ok(!JSON.stringify(result.value).includes(SECRET))
|
|
301
|
+
assert.deepEqual(redactOutput(result.value, patterns).value, result.value)
|
|
302
|
+
}
|
|
303
|
+
for (const value of ['f'.repeat(40), 'bearer of news', 'ordinary/path:note@host']) assert.equal(checkInput(value, patterns), undefined)
|
|
304
|
+
const proto = JSON.parse('{"__proto__":"' + ACCESS + '","ordinary":"safe"}')
|
|
305
|
+
const protectedOutput = redactOutput(proto, patterns).value
|
|
306
|
+
assert.equal(Object.hasOwn(protectedOutput, '__proto__'), true)
|
|
307
|
+
assert.equal(protectedOutput.ordinary, 'safe')
|
|
308
|
+
assert.deepEqual(maskValue([ACCESS, { value: PAT, count: 1 }], WITHHELD), [WITHHELD, { value: WITHHELD, count: 1 }])
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('native redactor core retains clean output but rebuilds or withholds secret-bearing results', () => {
|
|
312
|
+
const patterns = loadPatterns()
|
|
313
|
+
const cleanResult = { result: { stdout: 'clean' }, text: 'clean', ref: 'reference', context: ['note'] }
|
|
314
|
+
assert.equal(redactResult(cleanResult, patterns), cleanResult)
|
|
315
|
+
assert.deepEqual(redactResult({ ...cleanResult, result: { stdout: ACCESS }, text: ACCESS }, patterns), { result: { stdout: '[REDACTED:aws-access-key]' }, context: ['note'] })
|
|
316
|
+
for (const result of [{ result: 'clean', text: PAT }, { result: 'clean', context: [PAT] }, { isError: true, result: PAT, text: PAT }]) {
|
|
317
|
+
const r = redactResult(result, patterns)
|
|
318
|
+
assert.equal(typeof r.deny, 'string')
|
|
319
|
+
assert.ok(!JSON.stringify(r).includes(PAT))
|
|
320
|
+
assert.equal(Object.hasOwn(r, 'ref'), false)
|
|
321
|
+
assert.equal(Object.hasOwn(r, 'text'), false)
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
test('pattern extensions append to built-ins and malformed patterns are rejected', () => {
|
|
326
|
+
const extra = pattern({ context: ['tool-input', 'tool-output'] })
|
|
327
|
+
const patterns = loadPatterns(JSON.stringify([extra]))
|
|
328
|
+
assert.equal(checkInput('extra_abcdefgh', patterns).decision, 'ask')
|
|
329
|
+
assert.equal(checkInput(ACCESS, patterns).decision, 'ask')
|
|
330
|
+
assert.equal(blastCheck({ input: { command: 'extra_abcdefgh' } }, compileGuardPatterns(JSON.stringify([pattern()])), 'confirm').decision, 'ask')
|
|
331
|
+
for (const invalid of [null, {}, [pattern({ flags: 'u' })], [pattern({ regex: '[' })], [pattern({ unexpected: true })], [pattern(), pattern()]]) assert.throws(() => compilePatterns(invalid))
|
|
332
|
+
assert.throws(() => loadPatterns(JSON.stringify([pattern({ context: ['bash'] })])), /context/)
|
|
333
|
+
assert.throws(() => loadPatterns(JSON.stringify([pattern({ id: 'aws-access-key', context: ['tool-output'] })])), /built-in/)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test('anchored redaction extensions guard and redact nested leaves without changing clean input', () => {
|
|
337
|
+
const patterns = loadPatterns(JSON.stringify([pattern({ id: 'anchored-check', regex: '^extra_[a-z]{8}$', context: ['tool-input', 'tool-output'] })]))
|
|
338
|
+
const values = ['extra_abcdefgh', { token: 'extra_' + 'abcdefgh' }, ['prefix', 'extra_abcdefgh'], { nested: [{ stdout: 'extra_abcdefgh' }] }]
|
|
339
|
+
assert.equal(values[1].token, 'extra_abcdefgh')
|
|
340
|
+
for (const value of values) {
|
|
341
|
+
const before = JSON.stringify(value)
|
|
342
|
+
assert.equal(checkInput(value, patterns)?.decision, 'ask')
|
|
343
|
+
const redacted = redactOutput(value, patterns)
|
|
344
|
+
assert.deepEqual(redacted.hits.map((p) => p.id), ['anchored-check'])
|
|
345
|
+
assert.ok(!JSON.stringify(redacted.value).includes('extra_abcdefgh'))
|
|
346
|
+
assert.deepEqual(redactOutput(redacted.value, patterns), { value: redacted.value, hits: [] })
|
|
347
|
+
assert.equal(JSON.stringify(value), before)
|
|
348
|
+
}
|
|
349
|
+
const cleanValue = { token: 'prefix extra_abcdefgh suffix', count: 1, values: [false, null] }
|
|
350
|
+
assert.equal(checkInput(cleanValue, patterns), undefined)
|
|
351
|
+
assert.deepEqual(redactOutput(cleanValue, patterns), { value: cleanValue, hits: [] })
|
|
352
|
+
const prefixPatterns = loadPatterns(JSON.stringify([pattern({ id: 'anchored-prefix', regex: '^z', context: ['tool-input', 'tool-output'] })]))
|
|
353
|
+
// Context must see the original secret before a shorter raw match replaces its prefix.
|
|
354
|
+
for (const secret of ['z'.repeat(40), 'AKIA' + 'Z'.repeat(36)]) {
|
|
355
|
+
assert.deepEqual(redactOutput({ aws_secret_access_key: secret }, prefixPatterns).value, { aws_secret_access_key: '[REDACTED:aws-secret-key]' })
|
|
356
|
+
assert.deepEqual(redactOutput(['aws_secret_access_key', secret], prefixPatterns).value, ['aws_secret_access_key', '[REDACTED:aws-secret-key]'])
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('anchored extensions reach the real command hooks for input and successful classic output', (t) => {
|
|
361
|
+
const file = join(temp(t), 'anchored.json')
|
|
362
|
+
writeFileSync(file, JSON.stringify([pattern({ id: 'anchored-check', regex: '^extra_[a-z]{8}$', context: ['tool-input', 'tool-output'] })]))
|
|
363
|
+
const options = { env: { VANGUARD_SECRET_REDACTOR_PATTERNS: file } }
|
|
364
|
+
for (const harness of HARNESSES) {
|
|
365
|
+
denied(hook('secret-redactor', harness, 'PreToolUse', { tool_input: { argv: ['prefix', 'extra_abcdefgh'] } }, options), harness, 'ask')
|
|
366
|
+
}
|
|
367
|
+
const post = hook('secret-redactor', 'claude-code', 'PostToolUse', { tool_name: 'Bash', tool_response: { stdout: 'extra_abcdefgh', stderr: '', interrupted: false } }, options)
|
|
368
|
+
assert.equal(post.status, 0)
|
|
369
|
+
assert.deepEqual(output(post).updatedToolOutput, { stdout: '[REDACTED:anchored-check]', stderr: '', interrupted: false })
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
test('extension file failures deny guards and withhold classic redaction without disclosing file contents', (t) => {
|
|
373
|
+
const dir = temp(t)
|
|
374
|
+
for (const id of ['secret-redactor', 'blast-radius-guard']) {
|
|
375
|
+
const key = `VANGUARD_${id.replaceAll('-', '_').toUpperCase()}_PATTERNS`
|
|
376
|
+
const file = join(dir, `${id}.json`)
|
|
377
|
+
const cases = [undefined, ACCESS + ' not-json', JSON.stringify([pattern({ regex: '[' })])]
|
|
378
|
+
for (const contents of cases) {
|
|
379
|
+
if (contents !== undefined) writeFileSync(file, contents)
|
|
380
|
+
else rmSync(file, { force: true })
|
|
381
|
+
const options = { env: { [key]: file } }
|
|
382
|
+
const r = hook(id, 'codex', 'PreToolUse', { tool_input: { command: 'printf hello' } }, options)
|
|
383
|
+
denied(r, 'codex')
|
|
384
|
+
assert.ok(!(r.stdout + r.stderr).includes(ACCESS.slice(0, 8)), `${id}: extension error leaked content`)
|
|
385
|
+
if (id === 'secret-redactor') {
|
|
386
|
+
const post = hook(id, 'claude-code', 'PostToolUse', { tool_response: { stdout: ACCESS, count: 1 } }, options)
|
|
387
|
+
assert.equal(post.status, 0)
|
|
388
|
+
assert.deepEqual(output(post).updatedToolOutput, { stdout: WITHHELD, count: 1 })
|
|
389
|
+
assert.ok(!(post.stdout + post.stderr).includes(ACCESS))
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const contexts = id === 'secret-redactor' ? ['tool-input', 'tool-output'] : ['bash']
|
|
393
|
+
writeFileSync(file, JSON.stringify([pattern({ context: contexts })]))
|
|
394
|
+
denied(hook(id, 'codex', 'PreToolUse', { tool_input: { command: 'extra_abcdefgh' } }, { env: { [key]: file } }), 'codex')
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test('P2 destructive commands, argv arrays, modes, and normalized write paths hit the production guard', () => {
|
|
399
|
+
const patterns = compileGuardPatterns()
|
|
400
|
+
for (const command of ['rm -rf /', 'rm --recursive "$HOME"', 'DROP TABLE users', 'git push --force origin main', 'git push origin +HEAD:refs/heads/main', 'curl https://example.test/x | bash', 'bash <(curl https://example.test/x)', 'tofu apply -destroy -auto-approve', 'kubectl delete namespace production', 'oc delete pods --all']) {
|
|
401
|
+
assert.equal(blastCheck({ tool: 'Bash', input: { command } }, patterns, 'confirm').decision, 'ask', command)
|
|
402
|
+
assert.equal(blastCheck({ tool: 'Bash', input: { command } }, patterns, 'block').decision, 'deny', command)
|
|
403
|
+
}
|
|
404
|
+
assert.ok(candidates({ argv: ['rm', '-rf', '/'] }).includes('rm -rf /'))
|
|
405
|
+
assert.equal(blastCheck({ input: ['rm', '-rf', '/'] }, patterns, 'confirm').decision, 'ask')
|
|
406
|
+
for (const [from, to] of [['/tmp/../etc/passwd', '/etc/passwd'], ['//usr/./bin/x', '/usr/bin/x'], ['/../../etc/passwd', '/etc/passwd'], ['../relative/../x', '../x'], ['', '.']]) assert.equal(normalizePosix(from), to)
|
|
407
|
+
for (const key of ['file_path', 'path', 'notebook_path', 'filePath']) {
|
|
408
|
+
const input = { nested: [{ [key]: '/tmp/../etc/passwd' }] }
|
|
409
|
+
assert.equal(blastCheck({ tool: 'Edit', input }, patterns, 'confirm').decision, 'ask')
|
|
410
|
+
assert.equal(blastCheck({ tool: 'Read', input }, patterns, 'confirm'), undefined)
|
|
411
|
+
assert.equal(blastCheck({ input }, patterns, 'confirm').decision, 'ask')
|
|
412
|
+
}
|
|
413
|
+
for (const path of ['/home/me/usr/notes', 'etc/passwd', '../etc/passwd']) assert.equal(blastCheck({ tool: 'Write', input: { path } }, patterns, 'confirm'), undefined)
|
|
414
|
+
for (const value of [undefined, 'confirm']) assert.equal(modeOf(value), 'confirm')
|
|
415
|
+
for (const value of ['', 'block', 'CONFIRM', 'typo']) assert.equal(modeOf(value), 'block')
|
|
416
|
+
for (const command of ['git push -f origin product-page', 'curl https://example.test/x || bash', 'terraform plan', 'rm -rf /tmp/vfa-fixture']) assert.equal(blastCheck({ input: { command } }, patterns, 'block'), undefined, command)
|
|
417
|
+
assert.deepEqual(toolUseFromPayload({ undocumented: 'rm -rf /' }).input, { undocumented: 'rm -rf /' })
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
test('blast-radius CLI applies block and unknown modes, not just the pure helper', () => {
|
|
421
|
+
for (const mode of ['block', 'typo', '']) denied(hook('blast-radius-guard', 'claude-code', 'PreToolUse', { tool_name: 'Edit', tool_input: { path: '/tmp/../etc/passwd' } }, { env: { VANGUARD_BLAST_RADIUS_GUARD_MODE: mode } }), 'claude-code')
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
test('oversized single and aggregate candidates fail closed in both guards', () => {
|
|
425
|
+
for (const input of [{ command: 'x'.repeat(LIMITS.maxCandidateChars + 1) }, Array.from({ length: 5 }, () => ({ text: 'x'.repeat(60000) }))]) {
|
|
426
|
+
const a = blastCheck({ input }, compileGuardPatterns(), 'block')
|
|
427
|
+
const b = liveCheck({ input })
|
|
428
|
+
assert.equal(a.decision, 'deny')
|
|
429
|
+
assert.equal(b.decision, 'ask')
|
|
430
|
+
assert.match(a.reason, /too large/)
|
|
431
|
+
assert.match(b.reason, /too large/)
|
|
432
|
+
}
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
test('every live pattern has a concrete production-core probe naming its expected agents', () => {
|
|
436
|
+
const vectors = {
|
|
437
|
+
'live-iac-terraform-pulumi': 'terraform apply', 'live-aws-cloudformation-cdk': 'cdk deploy', 'live-aws-ecs': 'aws ecs update-service',
|
|
438
|
+
'live-aws-lambda-apigw-serverless': 'aws lambda update-function-code', 'live-aws-sam': 'sam deploy', 'live-azure-arm-stack': 'az deployment group create',
|
|
439
|
+
'live-azure-aks': 'az aks scale', 'live-azure-slot-swap': 'az webapp deployment slot swap', 'live-azure-keyvault': 'az keyvault secret delete',
|
|
440
|
+
'live-azure-role-assignment': 'az role assignment create', 'live-azure-budget': 'az consumption budget create', 'live-gcloud-gke': 'gcloud container clusters resize',
|
|
441
|
+
'live-gcloud-run': 'gcloud run deploy', 'live-gcloud-iam': 'gcloud projects add-iam-policy-binding', 'live-gcloud-kms': 'gcloud kms keys versions destroy',
|
|
442
|
+
'live-gcloud-billing-budget': 'gcloud billing budgets create', 'live-bq-destructive': 'bq rm dataset', 'live-k8s-rbac': 'kubectl delete role example',
|
|
443
|
+
'live-k8s-network-policy': 'kubectl delete networkpolicy example', 'live-k8s-admission-policy': 'kubectl delete validatingwebhookconfiguration example',
|
|
444
|
+
'live-k8s-mesh-policy': 'kubectl delete virtualservice example', 'live-argocd-sync': 'argocd app sync example', 'live-velero-restore': 'velero restore create example',
|
|
445
|
+
'live-kubectl-mutate': 'kubectl apply -f example.yaml', 'live-helm-mutate': 'helm upgrade example chart', 'live-oci-network-security-rule': 'oci network nsg rules add',
|
|
446
|
+
'live-oci-iam-policy-compartment': 'oci iam policy create', 'live-oci-autonomous-db': 'oci db autonomous-database restore', 'live-oci-budget-quota': 'oci budgets budget update',
|
|
447
|
+
'live-snowflake-rbac': 'snow sql -q "GRANT SELECT ON TABLE example TO ROLE reader"', 'live-snowflake-warehouse': 'snow sql -q "ALTER WAREHOUSE example SUSPEND"',
|
|
448
|
+
'live-snowflake-failover': 'snow sql -q "ALTER FAILOVER GROUP example PRIMARY"', 'live-snowflake-auth-network-policy': 'snow sql -q "ALTER NETWORK POLICY example SET COMMENT=x"',
|
|
449
|
+
'live-snowflake-pipeline': 'snow sql -q "ALTER PIPE example REFRESH"', 'live-snowflake-data-protection': 'snow sql -q "ALTER TABLE example SET MASKING POLICY policy"',
|
|
450
|
+
'live-aws-cli-mutation': 'aws s3api delete-object', 'live-az-cli-mutation': 'az vm restart', 'live-gcloud-cli-mutation': 'gcloud compute instances delete example',
|
|
451
|
+
}
|
|
452
|
+
assert.deepEqual(Object.keys(vectors).sort(), livePatterns.map((p) => p.id).sort())
|
|
453
|
+
for (const p of livePatterns) {
|
|
454
|
+
assert.ok(new RegExp(p.regex, p.flags).test(vectors[p.id]), p.id)
|
|
455
|
+
const verdict = liveCheck({ input: { argv: vectors[p.id].split(' ') } })
|
|
456
|
+
assert.equal(verdict.decision, 'ask', p.id)
|
|
457
|
+
for (const agent of p.agents) assert.ok(verdict.reason.includes(agent), `${p.id}: ${agent}`)
|
|
458
|
+
}
|
|
459
|
+
for (const command of ['terraform plan', 'aws ecs list-services', 'az aks show', 'gcloud run services describe example', 'kubectl get pods', 'helm list', 'oci iam policy list', 'snow sql -q "SELECT 1"', 'kubectl apply --dry-run=client -f example.yaml']) assert.equal(liveCheck({ input: { command } }), undefined, command)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
test('context thresholds fire once per session and support tokens or percent without mutating previous state', () => {
|
|
463
|
+
const initial = freshStatus('first', AT)
|
|
464
|
+
assert.deepEqual(parseStatus(serializeStatus(initial)), initial)
|
|
465
|
+
let r = recordCall(initial, 'first', AT, { tokens: 699, window: 1000 })
|
|
466
|
+
assert.deepEqual(r.messages, [])
|
|
467
|
+
r = recordCall(r.status, 'first', AT, { tokens: 700, window: 1000 })
|
|
468
|
+
assert.deepEqual(r.messages, ['[context-burn] WARNING: 70% context used (700 / 1000 tokens)'])
|
|
469
|
+
r = recordCall(r.status, 'first', AT, { percent: 90, window: 1000 })
|
|
470
|
+
assert.deepEqual(r.messages, ['[context-burn] CRITICAL: 90% context used (900 / 1000 tokens)'])
|
|
471
|
+
assert.deepEqual(r.status.threshold_events.map((e) => e.threshold), [70, 90])
|
|
472
|
+
assert.deepEqual(recordCall(r.status, 'first', AT, { percent: 100, window: 1000 }).messages, [])
|
|
473
|
+
assert.equal(recordCall(r.status, 'second', AT).status.tool_call_count, 1)
|
|
474
|
+
assert.deepEqual(recordCall(r.status, 'second', AT).status.threshold_events, [])
|
|
475
|
+
assert.equal(initial.tool_call_count, 0)
|
|
476
|
+
assert.deepEqual(initial.threshold_events, [])
|
|
477
|
+
for (const context of [undefined, {}, { tokens: 900, window: 0 }, { percent: 90 }]) assert.deepEqual(recordCall(initial, 'first', AT, context).messages, [])
|
|
478
|
+
for (const text of ['{}', 'null', '{', JSON.stringify({ ...initial, tool_call_count: -1 })]) assert.throws(() => parseStatus(text))
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
test('context status rejects malformed threshold event objects and required fields', () => {
|
|
482
|
+
const event = { threshold: 70, tokens: 750, window: 1000, at: AT }
|
|
483
|
+
const status = { ...freshStatus('first', AT), threshold_events: [event] }
|
|
484
|
+
assert.deepEqual(parseStatus(serializeStatus(status)), status)
|
|
485
|
+
for (const bad of [null, [], 'event', {}, { ...event, threshold: '70' }, { ...event, threshold: 80 }, { ...event, tokens: undefined }, { ...event, tokens: '750' }, { ...event, tokens: -1 }, { ...event, window: 0 }, { ...event, window: '1000' }, { ...event, at: undefined }, { ...event, at: 0 }]) {
|
|
486
|
+
assert.throws(() => parseStatus(serializeStatus({ ...status, threshold_events: [bad] })), /not a context-burn status/)
|
|
487
|
+
}
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
test('context default paths use the complete session hash under the harness directory', async () => {
|
|
491
|
+
for (const harness of HARNESSES) {
|
|
492
|
+
const dot = harness === 'claude-code' ? '.claude' : `.${harness}`
|
|
493
|
+
for (const sessionId of ['installed', '../first', '..\\second', 'session-\u2603', 'unsessioned']) {
|
|
494
|
+
const hash = createHash('sha256').update(sessionId).digest('hex')
|
|
495
|
+
assert.equal(await defaultStatusPath(harness, sessionId), `${dot}/context-burn-status/${hash}.json`)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
test('context corrupted state starts fresh loudly, while observer write failures remain non-blocking errors', (t) => {
|
|
501
|
+
const dir = temp(t), file = join(dir, 'status.json')
|
|
502
|
+
writeFileSync(file, 'not-json')
|
|
503
|
+
const r = hook('context-burn', 'codex', 'PostToolUse', { session_id: 'recover' }, { env: { VANGUARD_CONTEXT_BURN_STATUS_PATH: file } })
|
|
504
|
+
assert.equal(r.status, 0)
|
|
505
|
+
assert.match(r.stderr, /starting a fresh count/)
|
|
506
|
+
assert.equal(parseStatus(readFileSync(file, 'utf8')).tool_call_count, 1)
|
|
507
|
+
const blocker = join(dir, 'regular-file')
|
|
508
|
+
writeFileSync(blocker, 'unchanged')
|
|
509
|
+
for (const [id, event, env] of [['context-burn', 'SessionStart', { VANGUARD_CONTEXT_BURN_STATUS_PATH: join(blocker, 'status.json') }], ['context-burn', 'PostToolUse', { VANGUARD_CONTEXT_BURN_STATUS_PATH: join(blocker, 'status.json') }], ['audit-trail', 'PostToolUse', { VANGUARD_AUDIT_TRAIL_LOG_DIR: blocker }]]) {
|
|
510
|
+
const failed = hook(id, 'codex', event, { session_id: 'failure' }, { env })
|
|
511
|
+
assert.equal(failed.status, 1)
|
|
512
|
+
assert.match(failed.stderr, /ENOTDIR|EEXIST/)
|
|
513
|
+
assert.equal(failed.stdout, '')
|
|
514
|
+
assert.equal(readFileSync(blocker, 'utf8'), 'unchanged')
|
|
515
|
+
}
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
test('observer defaults resolve to the installed project, not the invocation directory', async (t) => {
|
|
519
|
+
const dir = temp(t)
|
|
520
|
+
for (const harness of HARNESSES) {
|
|
521
|
+
const dot = harness === 'claude-code' ? '.claude' : `.${harness}`
|
|
522
|
+
for (const id of ['context-burn', 'audit-trail']) {
|
|
523
|
+
const bin = join(dir, dot, 'vanguard-mods', id, 'bin')
|
|
524
|
+
const status = await defaultStatusPath(harness, 'installed')
|
|
525
|
+
assert.equal(projectPath(status, harness, bin), join(dir, status))
|
|
526
|
+
assert.equal(projectPath('/absolute/status.json', harness, bin), '/absolute/status.json')
|
|
527
|
+
const install = dirname(bin)
|
|
528
|
+
mkdirSync(install, { recursive: true })
|
|
529
|
+
cpSync(join(ROOT, 'mods', id, 'bin'), bin, { recursive: true })
|
|
530
|
+
cpSync(join(ROOT, 'mods', id, 'lib'), join(install, 'lib'), { recursive: true })
|
|
531
|
+
const input = join(dir, 'stdin.json')
|
|
532
|
+
writeFileSync(input, '{"session_id":"installed"}')
|
|
533
|
+
const fd = openSync(input, 'r')
|
|
534
|
+
try {
|
|
535
|
+
const child = spawnSync(process.execPath, [join(bin, 'hook.mjs'), '--harness', harness, '--event', id === 'context-burn' ? 'SessionStart' : 'PostToolUse'], { cwd: tmpdir(), env: {}, encoding: 'utf8', stdio: [fd, 'pipe', 'pipe'], timeout: 5000 })
|
|
536
|
+
assert.ifError(child.error)
|
|
537
|
+
assert.equal(child.status, 0, child.stderr)
|
|
538
|
+
} finally { closeSync(fd) }
|
|
539
|
+
if (id === 'context-burn') assert.equal(parseStatus(readFileSync(join(dir, status), 'utf8')).session_id, 'installed')
|
|
540
|
+
else assert.ok(readdirSync(join(dir, '.audit')).length > 0)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
test('audit record core uses canonical hashes and safe stable session identifiers', async () => {
|
|
546
|
+
assert.equal(rawSessionId('override', { session_id: 'payload' }), 'override')
|
|
547
|
+
assert.equal(rawSessionId('', { session_id: 'payload' }), 'payload')
|
|
548
|
+
assert.equal(rawSessionId(undefined, {}), 'unsessioned')
|
|
549
|
+
assert.equal(await safeSessionId('simple._-123'), 'simple._-123')
|
|
550
|
+
const unsafe = '../outside/path'
|
|
551
|
+
assert.equal(await safeSessionId(unsafe), createHash('sha256').update(unsafe).digest('hex').slice(0, 32))
|
|
552
|
+
assert.equal(await safeSessionId('x'.repeat(129)), createHash('sha256').update('x'.repeat(129)).digest('hex').slice(0, 32))
|
|
553
|
+
const record = await buildRecord({ timestamp: AT, sessionId: 's', payload: { tool_input: { b: 2, a: 1 }, tool_response: null }, harness: 'codex', sequence: 1 })
|
|
554
|
+
assert.equal(record.args_hash, digest({ a: 1, b: 2 }))
|
|
555
|
+
assert.equal(record.result_hash, digest(null))
|
|
556
|
+
assert.equal(record.agent_id, null)
|
|
557
|
+
assert.equal(lastSequence(''), 0)
|
|
558
|
+
assert.equal(lastSequence('{"sequence_number":3}\n'), 3)
|
|
559
|
+
for (const text of ['bad\n', '{}\n', '{"sequence_number":0}\n', '{"sequence_number":1.5}\n']) assert.throws(() => lastSequence(text), /corrupt/)
|
|
560
|
+
assert.throws(() => lastSequence('{"sequence_number":1}'), /newline|unterminated|corrupt/)
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
test('audit parallel appends preserve all records and reuse an earlier day across midnight', async (t) => {
|
|
564
|
+
const dir = temp(t), day = join(dir, '2000-01-01'), file = join(day, 'parallel.jsonl')
|
|
565
|
+
mkdirSync(day)
|
|
566
|
+
writeFileSync(file, '')
|
|
567
|
+
const runs = await Promise.all(Array.from({ length: 8 }, (_, n) => hookAsync('audit-trail', 'codex', 'PostToolUse', { session_id: 'parallel', tool_input: { n } }, { cwd: dir, env: { VANGUARD_AUDIT_TRAIL_LOG_DIR: dir } })))
|
|
568
|
+
runs.forEach(clean)
|
|
569
|
+
const records = readFileSync(file, 'utf8').trimEnd().split('\n').map(JSON.parse)
|
|
570
|
+
assert.deepEqual(records.map((r) => r.sequence_number), [1, 2, 3, 4, 5, 6, 7, 8])
|
|
571
|
+
assert.deepEqual(new Set(records.map((r) => r.args_hash)), new Set(Array.from({ length: 8 }, (_, n) => digest({ n }))))
|
|
572
|
+
assert.deepEqual(readdirSync(dir), ['2000-01-01'])
|
|
573
|
+
assert.equal(existsSync(join(dir, 'parallel.lock')), false)
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
test('audit never steals active or stale locks and leaves corrupt logs untouched', (t) => {
|
|
577
|
+
const dir = temp(t), day = join(dir, '2000-01-01'), file = join(day, 'locked.jsonl'), lock = join(dir, 'locked.lock')
|
|
578
|
+
mkdirSync(day)
|
|
579
|
+
writeFileSync(file, '')
|
|
580
|
+
const options = { env: { VANGUARD_AUDIT_TRAIL_LOG_DIR: dir } }, payload = { session_id: 'locked' }
|
|
581
|
+
for (const marker of ['active', 'stale']) {
|
|
582
|
+
writeFileSync(lock, marker)
|
|
583
|
+
if (marker === 'stale') utimesSync(lock, new Date(AT), new Date(AT))
|
|
584
|
+
const r = hook('audit-trail', 'codex', 'PostToolUse', payload, options)
|
|
585
|
+
assert.equal(r.status, 1)
|
|
586
|
+
assert.match(r.stderr, /could not lock/)
|
|
587
|
+
assert.equal(readFileSync(file, 'utf8'), '')
|
|
588
|
+
assert.equal(readFileSync(lock, 'utf8'), marker)
|
|
589
|
+
rmSync(lock)
|
|
590
|
+
}
|
|
591
|
+
writeFileSync(file, 'corrupt\n')
|
|
592
|
+
const corrupt = hook('audit-trail', 'codex', 'PostToolUse', payload, options)
|
|
593
|
+
assert.equal(corrupt.status, 1)
|
|
594
|
+
assert.match(corrupt.stderr, /corrupt last line/)
|
|
595
|
+
assert.equal(readFileSync(file, 'utf8'), 'corrupt\n')
|
|
596
|
+
assert.equal(existsSync(lock), false)
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
test('audit refuses a valid JSON tail without newline instead of corrupting append framing', (t) => {
|
|
600
|
+
const dir = temp(t), day = join(dir, '2000-01-01'), file = join(day, 'tail.jsonl')
|
|
601
|
+
mkdirSync(day)
|
|
602
|
+
const tail = '{"sequence_number":1}'
|
|
603
|
+
writeFileSync(file, tail)
|
|
604
|
+
const r = hook('audit-trail', 'codex', 'PostToolUse', { session_id: 'tail' }, { env: { VANGUARD_AUDIT_TRAIL_LOG_DIR: dir } })
|
|
605
|
+
assert.equal(r.status, 1, 'unterminated JSONL record must fail without appending')
|
|
606
|
+
assert.match(r.stderr, /newline|unterminated|corrupt/)
|
|
607
|
+
assert.equal(readFileSync(file, 'utf8'), tail)
|
|
608
|
+
assert.equal(existsSync(join(dir, 'tail.lock')), false)
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
test('strict TOML parser preserves supported values and rejects unsupported or ambiguous forms', () => {
|
|
612
|
+
const text = '# comment\n[mod]\ntext = "a\\n\\t\\u0041\\\"\\\\#" # comment\nflag = false\ncount = -12\nnames = [\n "first", # comment\n "second",\n]\n[harnesses.codex]\nsupported = true\n'
|
|
613
|
+
assert.deepEqual(parseToml(text, 'fixture.toml'), { mod: { text: 'a\n\tA"\\#', flag: false, count: -12, names: ['first', 'second'] }, harnesses: { codex: { supported: true } } })
|
|
614
|
+
for (const bad of ["a = 'literal'", 'a = 1.2', 'a = 01', 'a = 2026-01-01', 'a = {x=1}', 'a = [1]', 'a = [["x"]]', '[[mod]]', 'a.b = "x"', '"quoted" = "x"', 'a = "\\q"', 'a = "\\uD800"', 'a = 9007199254740992', 'a = "unterminated', '[mod]\nx = 1\nx = 2', '[mod]\n[mod]', '__proto__ = "x"', '[constructor]\nx = "x"']) {
|
|
615
|
+
assert.throws(() => parseToml(bad, 'negative.toml'), /^Error: negative\.toml:\d+:/, bad)
|
|
616
|
+
}
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
test('catalog footprints mirror all four manifest arrays', () => {
|
|
620
|
+
const { mods, ctx, errors } = loadMods(ROOT)
|
|
621
|
+
assert.deepEqual(errors, [])
|
|
622
|
+
const catalog = JSON.parse(renderAll(mods, ctx).get('catalog/mods.json'))
|
|
623
|
+
for (const entry of catalog) {
|
|
624
|
+
const manifest = mods.find((m) => m.id === entry.id).manifest
|
|
625
|
+
assert.deepEqual(entry.footprint, manifest.footprint)
|
|
626
|
+
assert.deepEqual(Object.keys(entry.footprint).sort(), ['egress_hosts', 'env_reads', 'file_writes', 'spawns'])
|
|
627
|
+
}
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
test('catalog validator rejects missing or mismatched footprint metadata', (t) => {
|
|
631
|
+
const dir = temp(t)
|
|
632
|
+
mkdirSync(join(dir, 'catalog'))
|
|
633
|
+
const { mods, ctx } = loadMods(ROOT)
|
|
634
|
+
const entries = JSON.parse(renderAll(mods, ctx).get('catalog/mods.json'))
|
|
635
|
+
const source = `import importlib.util, json, pathlib, sys, tomllib
|
|
636
|
+
spec = importlib.util.spec_from_file_location('vfa_mod_validator', ${JSON.stringify(join(ROOT, 'tests/validate-mods.py'))})
|
|
637
|
+
module = importlib.util.module_from_spec(spec)
|
|
638
|
+
sys.modules[spec.name] = module
|
|
639
|
+
spec.loader.exec_module(module)
|
|
640
|
+
root = pathlib.Path(sys.argv[1])
|
|
641
|
+
source_root = pathlib.Path(${JSON.stringify(ROOT)})
|
|
642
|
+
mods = [module.Mod(p.parent.name, p.parent, 'mods/' + p.parent.name, tomllib.loads(p.read_text())) for p in sorted(source_root.glob('mods/*/mod.toml'))]
|
|
643
|
+
report = module.Report()
|
|
644
|
+
module.check_catalog_mods(report, root, mods, [m.id for m in mods])
|
|
645
|
+
print(json.dumps({'failed': report.failed, 'lines': report.lines}))`
|
|
646
|
+
const check = () => {
|
|
647
|
+
writeFileSync(join(dir, 'catalog/mods.json'), JSON.stringify(entries))
|
|
648
|
+
const r = spawnSync('python3', ['-c', source, dir], { encoding: 'utf8', timeout: 5000 })
|
|
649
|
+
assert.ifError(r.error)
|
|
650
|
+
assert.equal(r.status, 0, r.stderr)
|
|
651
|
+
return JSON.parse(r.stdout)
|
|
652
|
+
}
|
|
653
|
+
assert.equal(check().failed, 0)
|
|
654
|
+
const footprint = entries[0].footprint
|
|
655
|
+
delete entries[0].footprint
|
|
656
|
+
const missing = check()
|
|
657
|
+
assert.ok(missing.failed > 0)
|
|
658
|
+
assert.match(missing.lines.join('\n'), /FAIL.*footprint/)
|
|
659
|
+
entries[0].footprint = { ...footprint, spawns: ['unexpected-process'] }
|
|
660
|
+
const mismatched = check()
|
|
661
|
+
assert.ok(mismatched.failed > 0)
|
|
662
|
+
assert.match(mismatched.lines.join('\n'), /FAIL.*footprint/)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
test('mod ids cannot collide with MCP or workflow entities or existing catalog types', (t) => {
|
|
666
|
+
const dir = temp(t)
|
|
667
|
+
mkdirSync(join(dir, 'catalog'))
|
|
668
|
+
const names = ['agents', 'skills', 'rules', 'mcp-references', 'workflows']
|
|
669
|
+
const write = (name, entries) => writeFileSync(join(dir, 'catalog', `${name}.json`), JSON.stringify(name === 'workflows' ? { workflows: entries } : entries))
|
|
670
|
+
for (const name of names) write(name, [])
|
|
671
|
+
const source = `import importlib.util, json, pathlib, sys, types
|
|
672
|
+
spec = importlib.util.spec_from_file_location('vfa_mod_validator', ${JSON.stringify(join(ROOT, 'tests/validate-mods.py'))})
|
|
673
|
+
module = importlib.util.module_from_spec(spec)
|
|
674
|
+
sys.modules[spec.name] = module
|
|
675
|
+
spec.loader.exec_module(module)
|
|
676
|
+
report = module.Report()
|
|
677
|
+
module.check_catalog_ids(report, pathlib.Path(sys.argv[1]), [types.SimpleNamespace(id='probe-mod', rel='mods/probe-mod')])
|
|
678
|
+
print(json.dumps({'failed': report.failed, 'lines': report.lines}))`
|
|
679
|
+
const check = () => {
|
|
680
|
+
const r = spawnSync('python3', ['-c', source, dir], { encoding: 'utf8', timeout: 5000 })
|
|
681
|
+
assert.ifError(r.error)
|
|
682
|
+
assert.equal(r.status, 0, r.stderr)
|
|
683
|
+
return JSON.parse(r.stdout)
|
|
684
|
+
}
|
|
685
|
+
assert.equal(check().failed, 0)
|
|
686
|
+
for (const name of names) {
|
|
687
|
+
write(name, [{ id: 'probe-mod' }])
|
|
688
|
+
const report = check()
|
|
689
|
+
assert.equal(report.failed, 1, name)
|
|
690
|
+
assert.match(report.lines.join('\n'), new RegExp(`collides with an id in catalog/${name}\\.json`))
|
|
691
|
+
write(name, [])
|
|
692
|
+
}
|
|
693
|
+
write('workflows', [{ id: 'different-workflow', phases: [{ id: 'probe-mod' }] }])
|
|
694
|
+
assert.equal(check().failed, 0, 'nested references are not entity ids')
|
|
695
|
+
write('workflows', {})
|
|
696
|
+
assert.equal(check().failed, 1, 'malformed workflow entities must fail closed')
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
test('generation is byte-identical under reordered mods and rejects unknown live-agent references', () => {
|
|
700
|
+
const { mods, ctx, errors } = loadMods(ROOT)
|
|
701
|
+
assert.deepEqual(errors, [])
|
|
702
|
+
const expected = [...renderAll(mods, ctx)]
|
|
703
|
+
assert.deepEqual([...renderAll([...mods].reverse(), ctx)], expected)
|
|
704
|
+
assert.deepEqual([...renderAll([...mods.slice(2), ...mods.slice(0, 2)], ctx)], expected)
|
|
705
|
+
assert.deepEqual(expected.map(([path]) => path), expected.map(([path]) => path).sort())
|
|
706
|
+
const live = mods.find((m) => m.id === 'live-guard-gate')
|
|
707
|
+
assert.throws(() => renderMod({ ...live, patterns: [pattern({ agents: ['missing-live-change-guard-agent'] })] }, ctx), /not a live guard agent/)
|
|
708
|
+
const catalog = JSON.parse(new Map(expected).get('catalog/mods.json'))
|
|
709
|
+
assert.deepEqual(catalog.map((m) => m.id), IDS)
|
|
710
|
+
assert.ok(catalog.every((m) => !Object.hasOwn(m, 'official_docs')))
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
test('generator --check detects temp-root drift without writing and restores only generated assets', (t) => {
|
|
714
|
+
const dir = temp(t)
|
|
715
|
+
generatorFixture(dir)
|
|
716
|
+
const handWritten = join(dir, 'mods', 'audit-trail', 'README.md')
|
|
717
|
+
writeFileSync(handWritten, 'owned by an author\n')
|
|
718
|
+
const generated = generator(dir, [])
|
|
719
|
+
assert.equal(generated.status, 0, generated.stderr)
|
|
720
|
+
assert.match(generated.stdout, /Generated 5 mods for 3 harnesses/)
|
|
721
|
+
assert.equal(generator(dir, ['--check']).status, 0)
|
|
722
|
+
const target = join(dir, 'mods', 'context-burn', 'codex', 'hooks.json')
|
|
723
|
+
const original = readFileSync(target, 'utf8')
|
|
724
|
+
writeFileSync(target, 'drift\n')
|
|
725
|
+
const drift = generator(dir, ['--check'])
|
|
726
|
+
assert.equal(drift.status, 1)
|
|
727
|
+
assert.match(drift.stderr, /FAIL: mods out of sync/)
|
|
728
|
+
assert.match(drift.stderr, /mods\/context-burn\/codex\/hooks\.json/)
|
|
729
|
+
assert.equal(readFileSync(target, 'utf8'), 'drift\n')
|
|
730
|
+
assert.equal(generator(dir, []).status, 0)
|
|
731
|
+
assert.equal(readFileSync(target, 'utf8'), original)
|
|
732
|
+
assert.equal(readFileSync(handWritten, 'utf8'), 'owned by an author\n')
|
|
733
|
+
const invalid = generator(dir, ['--unknown'])
|
|
734
|
+
assert.equal(invalid.status, 2)
|
|
735
|
+
assert.match(invalid.stderr, /Usage:/)
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
test('generator refuses symlinked source manifests without dereferencing an external target', (t) => {
|
|
739
|
+
const dir = temp(t), root = join(dir, 'repo'), outside = join(dir, 'outside.toml')
|
|
740
|
+
generatorFixture(root)
|
|
741
|
+
writeFileSync(outside, 'private outside data\n')
|
|
742
|
+
const source = join(root, 'mods', 'audit-trail', 'mod.toml')
|
|
743
|
+
rmSync(source)
|
|
744
|
+
symlinkSync(outside, source)
|
|
745
|
+
for (const mode of ['--check', '--print-manifests']) {
|
|
746
|
+
const r = generator(root, [mode])
|
|
747
|
+
assert.equal(r.status, 1)
|
|
748
|
+
assert.match(r.stderr, /symlink|outside|escapes/)
|
|
749
|
+
assert.ok(!(r.stdout + r.stderr).includes('private outside data'))
|
|
750
|
+
assert.equal(readFileSync(outside, 'utf8'), 'private outside data\n')
|
|
751
|
+
}
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
test('generator refuses symlinked generated assets in check and write modes', (t) => {
|
|
755
|
+
const dir = temp(t), root = join(dir, 'repo'), outside = join(dir, 'outside.mjs')
|
|
756
|
+
generatorFixture(root)
|
|
757
|
+
assert.equal(generator(root, []).status, 0)
|
|
758
|
+
writeFileSync(outside, 'external data must stay unchanged\n')
|
|
759
|
+
const target = join(root, 'mods', 'audit-trail', 'lib', 'common.mjs')
|
|
760
|
+
rmSync(target)
|
|
761
|
+
symlinkSync(outside, target)
|
|
762
|
+
for (const args of [['--check'], []]) {
|
|
763
|
+
const r = generator(root, args)
|
|
764
|
+
assert.equal(r.status, 1)
|
|
765
|
+
assert.match(r.stderr, /symlink|outside|escapes/)
|
|
766
|
+
assert.equal(readFileSync(outside, 'utf8'), 'external data must stay unchanged\n')
|
|
767
|
+
}
|
|
768
|
+
})
|