diff --git a/.vibeflow.json b/.vibeflow.json index 93fd227..09bfb6f 100644 --- a/.vibeflow.json +++ b/.vibeflow.json @@ -9,6 +9,10 @@ "VF006": "error", "VF007": "warning", "VF008": "warning", - "VF009": "warning" + "VF009": "warning", + "VF010": "error", + "VF011": "warning", + "VF012": "warning", + "VF013": "warning" } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 367307e..c1b485f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.9.0 - 2026-07-24 + +- Added outcome-aware policies VF010-VF013 for money, customer, privileged, and destructive-data actions. +- Added structurally verified outcome contracts for approval, durable audit, idempotency, amount and counterparty limits, failure notification, and recovery. +- Added detection of silent connected error paths and high-confidence classification of ordinary nodes such as HTTP requests by their real-world action. +- Added safe and unsafe refund fixtures, configuration schema support, Codex plugin guidance, and adversarial regression tests. +- Documented the static-analysis ceiling: runtime authorization, limits, audit durability, and recovery still belong in the executing system. + ## 0.8.0 - 2026-07-22 - Repositioned Vibeflow as a safety and contract gate for AI-generated n8n workflows. diff --git a/README.md b/README.md index 5b85a6a..3f44f04 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,14 @@ Vibeflow answers one question before deployment: **does this workflow deserve to reach production?** -It inspects exported n8n JSON for embedded secrets, dangerous nodes, exposed webhooks, missing failure paths, absent idempotency, unsafe AI paths, unbounded execution, and risky retries. +It inspects exported n8n JSON for embedded secrets, dangerous nodes, exposed webhooks, missing failure paths, absent idempotency, unsafe AI paths, unbounded execution, risky retries, and unguarded real-world outcomes such as refunds, customer messages, privileged actions, and destructive writes. Vibeflow is not another workflow builder or MCP server. It is a deterministic quality gate for workflows built by people or agents. ## Quick start ```bash -npx --yes github:domfelipe/vibeflow-n8n#4998605ed7dc12b9b867d69d7005d25778c7e109 check workflow.json +npx --yes github:domfelipe/vibeflow-n8n#v0.9.0 check workflow.json ``` Or from a checkout: @@ -55,9 +55,44 @@ node bin/vibeflow.mjs check examples/unsafe-support-agent.workflow.json --fail-o | VF007 | warning | AI paths without a reachable external human handoff | | VF008 | warning | Workflows without a 1-3600 second execution timeout | | VF009 | warning | Retries without idempotency, bounds, or backoff | +| VF010 | error | Money or privileged actions without a verified outcome contract | +| VF011 | warning | Customer communications or destructive writes without an outcome contract | +| VF012 | warning | Connected error paths that terminate without operator notification | +| VF013 | warning | High-impact writes without compensation, rollback, or replay evidence | Static analysis cannot prove runtime correctness. Configure severity and domain vocabulary in `.vibeflow.json`; document every waiver. +## Outcome contracts + +Vibeflow v0.9 separates a dangerous node from a dangerous outcome. A normal HTTP node can issue a refund, notify a customer, or change access. High-confidence cases are detected from the action's name, type, operation, URL, and parameters. Teams can classify other actions explicitly. + +Contracts are keyed by the exact action node name: + +```json +{ + "outcomeContracts": { + "Issue refund": { + "impact": "money", + "approvalNode": "Approve refund", + "auditNode": "Record refund audit", + "failureNotificationNode": "Notify refund failure", + "amountGuard": { "node": "Limit refund amount", "maximum": 500, "currency": "USD" }, + "counterpartyGuard": { "node": "Allow refund account", "allowed": ["merchant-primary"] }, + "recovery": { "strategy": "compensate", "node": "Compensate transaction" } + } + } +} +``` + +The checker verifies that referenced nodes exist, required gates dominate every entry path, approval/limit allow and deny branches are structural, a durable audit happens before the action, an atomic idempotency claim cannot be bypassed, failure notification is on the error branch, and recovery is represented in the graph. + +Run the outcome demo: + +```bash +node bin/vibeflow.mjs check examples/unsafe-refund.workflow.json --fail-on never +node bin/vibeflow.mjs check examples/safe-refund.workflow.json --config examples/outcome-contracts.vibeflow.json +``` + Use `--locked` in untrusted CI. It rejects disabled or downgraded rules, changed vocabulary, and removal of default banned node types. The bundled GitHub Action always enables it. ## Automation @@ -74,7 +109,7 @@ Directories are searched recursively for `*.workflow.json` files. ```yaml - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 -- uses: domfelipe/vibeflow-n8n@4998605ed7dc12b9b867d69d7005d25778c7e109 # v0.8.0 code +- uses: domfelipe/vibeflow-n8n@v0.9.0 with: path: workflows/ output: vibeflow.sarif @@ -83,7 +118,7 @@ Directories are searched recursively for `*.workflow.json` files. ## Codex plugin ```bash -codex plugin marketplace add domfelipe/vibeflow-n8n --ref 4998605ed7dc12b9b867d69d7005d25778c7e109 +codex plugin marketplace add domfelipe/vibeflow-n8n --ref v0.9.0 ``` Install **Vibeflow** from the Plugins Directory, then ask: @@ -96,17 +131,19 @@ Use $vibeflow to audit this n8n workflow and fix blocking findings. No hosted service, new MCP server, workflow generation, telemetry, secret collection, or live n8n mutation. The CLI uses only the Node.js 20+ standard library. +An outcome contract is structural evidence, not runtime enforcement. Money and customer-facing actions still need server-side amount and counterparty checks, atomic idempotency, approval authorization, durable audit storage, and tested recovery behavior at runtime. + ## Documentation - [Product brief](docs/product-brief.md) - [Architecture and limitations](docs/architecture.md) - [Reproducible demo](docs/demo.md) -- [v0.8.0 release audit](docs/release-audit.md) +- [Release audit](docs/release-audit.md) - [Roadmap](docs/roadmap.md) - [Codex for Open Source application gate](docs/codex-for-oss-application.md) - [Contributing](CONTRIBUTING.md) - [Security](SECURITY.md) -`v0.8.0` is the first executable release. The original documentation prototype is preserved at `legacy-v0.7.0`. +`v0.9.0` adds outcome-aware preflight checks. `v0.8.0` remains the first executable release, and the original documentation prototype is preserved at `legacy-v0.7.0`. MIT licensed. diff --git a/docs/architecture.md b/docs/architecture.md index b666629..c4ab728 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,6 +24,18 @@ Rules have a stable ID, default severity, description, and remediation. Trusted Graph policies use the validated n8n `main` connection shape, not node order; AI resource wiring is not control flow. VF005 requires a high-confidence atomic ledger gate that emits no item for duplicate claims and dominates inbound paths to side effects. VF006 rejects any AI entry path that bypasses a positive IF check against a direct agent-status reference. VF007 requires a reachable external handoff action. +VF010-VF013 introduce outcome contracts. High-confidence classification inspects a side-effect node's name, type, operation, URL, and parameters for money, customer communication, privileged access, and destructive-data semantics. Explicit `outcomeContracts` cover domain actions the built-in vocabulary does not recognize. + +For a contracted action, the analyzer verifies graph evidence rather than labels alone: + +- an atomic idempotency claim dominates the action; +- durable audit storage dominates the action; +- approval, amount, and counterparty guards are IF/Switch nodes with both an allowed route and a route that cannot reach the action; +- the declared failure notification is reachable from the action's error output; +- compensation and rollback nodes are downstream from the action; replay nodes must exist and carry recovery semantics. + +Money and privileged outcomes default to blocking VF010. Customer communications and destructive writes default to advisory VF011. Missing recovery and silent error paths are separate findings so teams can triage them independently. + `--locked` rejects configuration that weakens built-in severity, changes safety vocabulary, or removes a default banned node. The GitHub Action always enables locked mode so a pull request cannot silence its own findings by editing `.vibeflow.json`. ## Trust boundaries @@ -35,6 +47,8 @@ Output paths are selected by the caller. The GitHub Action passes inputs as quot ## Known limitations - Static structure cannot prove that a condition, SQL claim, or handoff works at runtime. +- Outcome contracts cannot prove caller authorization, amount calculation, counterparty identity, audit durability, or successful compensation at runtime. +- Automatic outcome classification is intentionally conservative and can miss domain-specific actions; declare them explicitly in configuration. - Secret detection can miss unusual key names and can produce false positives. - Community nodes unknown to the side-effect catalog need explicit policy additions. - Locked mode protects policy content, but repository owners must still review changes to the CI workflow itself. diff --git a/docs/codex-for-oss-application.md b/docs/codex-for-oss-application.md index ba062db..82c6d21 100644 --- a/docs/codex-for-oss-application.md +++ b/docs/codex-for-oss-application.md @@ -12,7 +12,7 @@ Prepared: 2026-07-22 Submit in **English**, even through the PT-BR form. OpenAI publishes no language requirement and no evidence that language changes selection odds. English is recommended only to reduce translation friction for a global technical review. -Submit now. Stars, forks, downloads, repository usage, ecosystem importance, and active maintenance are evaluation signals, not published minimum thresholds. Vibeflow's strongest evidence is its Codex-native engineering and its first real 92-node audit, not early popularity metrics. +Submit immediately after the v0.9.0 release on 2026-07-24. Do not wait for arbitrary star, fork, or PR targets. Vibeflow's strongest evidence is its Codex-native engineering, first real 92-node audit, and a public maintenance loop that turned community feedback into tested policy features. ## Copy-and-paste form @@ -42,9 +42,9 @@ Select: **Principal maintainer**. ### Why is this repository eligible? -Character count: **420/500**. +Recount before submission; keep under 500 characters. -> Vibeflow is an MIT-licensed safety and contract gate for AI-generated n8n workflows, rebuilt end-to-end with Codex. Its v0.8.0 plugin audited a real 92-node workflow in read-only mode and produced provenance-backed results: 3 blocking AI-safety findings, 57 prioritized warnings, and concrete revalidation gates. It fills a growing n8n need: deterministic pre-production checks for workflows created by people or agents. +> Vibeflow is an MIT-licensed safety gate for AI-generated n8n workflows, rebuilt end-to-end with Codex. It audited a real 92-node workflow in read-only mode, finding 3 blocking risks and 57 warnings. Community feedback then shaped v0.9: outcome-aware checks for refunds, customer actions, silent failures, audit, idempotency, approval, limits, and recovery. ### Interests @@ -67,9 +67,9 @@ Character count: **390/500**. ### Anything else we should know? -Character count: **431/500**. +Recount before submission; keep under 500 characters. -> Vibeflow is a public case study in Codex-native OSS development. Codex helped reposition an old prototype, implement the CLI, run repeated Red Team reviews, fix regressions, package a GitHub Action and Codex plugin, open and merge PRs, and publish v0.8.0. Its first production-scale audit exposed workflow risks and analyzer limitations, creating a concrete maintenance backlog. Adoption is early; the engineering evidence is real. +> Vibeflow is a public case study in Codex-native OSS development. Codex drove the project from product repositioning through implementation, adversarial review, remediation, CI, plugin packaging, releases, a real-workflow audit, and the v0.9 response to user feedback. Adoption is early, but the engineering, maintenance history, and real-world evidence are public and reproducible. ## PT-BR review translation @@ -77,7 +77,7 @@ These translations are for review only. Paste the English versions above into th ### Repository eligibility -> Vibeflow é um gate MIT de segurança e contratos para workflows n8n gerados por IA, reconstruído de ponta a ponta com Codex. O plugin v0.8.0 auditou em modo somente leitura um workflow real de 92 nós e produziu resultados com proveniência: 3 bloqueios de segurança de IA, 57 avisos priorizados e gates concretos de revalidação. Ele atende a uma necessidade crescente do ecossistema n8n: checks determinísticos antes da produção para workflows criados por pessoas ou agentes. +> Vibeflow é um gate MIT de segurança para workflows n8n gerados por IA, reconstruído de ponta a ponta com Codex. Ele auditou em modo somente leitura um workflow real de 92 nós, encontrando 3 riscos bloqueantes e 57 avisos. O feedback da comunidade então moldou a v0.9: checks de resultados reais para reembolsos, ações com clientes, falhas silenciosas, auditoria, idempotência, aprovação, limites e recuperação. ### API-credit use @@ -85,14 +85,14 @@ These translations are for review only. Paste the English versions above into th ### Additional context -> Vibeflow é um estudo de caso público de desenvolvimento open source nativo em Codex. O Codex ajudou a reposicionar um protótipo antigo, implementar a CLI, executar Red Teams repetidos, corrigir regressões, empacotar uma GitHub Action e um plugin Codex, mesclar PRs e publicar a v0.8.0. A primeira auditoria em escala de produção revelou riscos do workflow e limitações do analisador, criando um backlog concreto. A adoção ainda é inicial; a evidência de engenharia é real. +> Vibeflow é um estudo de caso público de desenvolvimento open source nativo em Codex. O Codex conduziu o projeto desde o reposicionamento e implementação até revisão adversarial, correções, CI, plugin, releases, auditoria de workflow real e a resposta da v0.9 ao feedback de usuários. A adoção ainda é inicial, mas a engenharia, o histórico de manutenção e a evidência real são públicos e reproduzíveis. ## Evidence map Use these links only if OpenAI requests verification; the form has no dedicated evidence field. - Public MIT repository: -- Executable release: +- Executable release after Friday launch: - Reproducible demo: - Release and Red Team audit: - CI history: @@ -101,16 +101,16 @@ Use these links only if OpenAI requests verification; the form has no dedicated ## Evidence snapshot -Captured on 2026-07-22: +Refresh this section on 2026-07-24 immediately before submission. Current candidate evidence: - public repository with MIT license; -- `v0.8.0` release; -- 26 adversarial tests on Node.js 20, 22, and 24; +- `v0.9.0` release candidate, with v0.8.0 already public; +- 36 local adversarial tests; remote Node.js 20, 22, and 24 CI remains a release gate; - dependency-free CLI, GitHub Action, and installable Codex plugin; -- 3 maintainer PRs merged with green CI; +- 3 prior maintainer PRs merged with green CI; add the v0.9 PR after merge; - 2 stars, 0 forks, and no verified external contributor yet; - first real audit: anonymized 92-node workflow, 3 blocking findings, 57 warnings, no workflow mutation; -- Codex used across product repositioning, implementation, review, Red Team, remediation, packaging, CI, release, and real-workflow audit. +- Codex used across product repositioning, implementation, review, Red Team, remediation, packaging, CI, release, real-workflow audit, and the community-feedback-driven v0.9 cycle. Do not describe maintainer PRs, the maintainer's own workflow, clones, or unattributed stars as external adoption. @@ -144,7 +144,7 @@ The approved public description is: **“a real, production-scale 92-node conver Continue collecting organic evidence without delaying the application: -1. publish the r/n8n post; +1. publish the v0.9 follow-up in r/n8n; 2. invite users to report anonymized false positives and missed unsafe cases; 3. respond to every issue or discussion with reproducible evidence; 4. keep releases, CI, and the public maintenance trail current; diff --git a/docs/demo.md b/docs/demo.md index e47b5fe..e63e36d 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -16,6 +16,23 @@ node bin/vibeflow.mjs check examples/unsafe-support-agent.workflow.json --fail-o Expected result: VF001-VF009 findings covering secrets, dangerous nodes, webhook authentication, error handling, idempotency, AI safety, timeouts, and retries. +## Dangerous outcome + +```bash +node bin/vibeflow.mjs check examples/unsafe-refund.workflow.json --fail-on never +``` + +Expected result: the ordinary HTTP refund node triggers blocking VF010 plus VF012 and VF013, even though its node type is not inherently dangerous. + +## Contracted outcome + +```bash +node bin/vibeflow.mjs check examples/safe-refund.workflow.json \ + --config examples/outcome-contracts.vibeflow.json +``` + +Expected result: exit `0`, zero findings. The graph contains a dominating atomic claim and durable audit, structural approval/amount/counterparty gates, error notification, and compensation evidence. + ## Automation output ```bash diff --git a/docs/launch.md b/docs/launch.md index 2c24a99..7796771 100644 --- a/docs/launch.md +++ b/docs/launch.md @@ -1,22 +1,28 @@ -# Launch checklist +# v0.9.0 launch checklist + +Target: Friday, 2026-07-24 ## Release gate -- [x] `npm run verify` passes on Node.js 20, 22, and 24 in CI. -- [x] Official skill and plugin validators pass. -- [x] Safe fixture exits 0; unsafe fixture exits 1. -- [x] SARIF is valid JSON and uploaded by CI. -- [x] Repository description and topics match the new product. -- [x] `v0.8.0` release notes match `CHANGELOG.md`. -- [x] Remote CLI and pinned Codex marketplace install successfully. -- [x] Private vulnerability reporting and Discussions are enabled. +- [x] VF010-VF013 implementation and configuration schema are complete. +- [x] Safe and unsafe refund fixtures are reproducible. +- [x] Local `npm run verify` and `npm audit --omit=dev` pass. +- [x] QA, adversarial Red Team, and Guardião reviews are documented. +- [ ] Pull request CI passes on Node.js 20, 22, and 24. +- [ ] Release commit is merged and tagged `v0.9.0`. +- [ ] Released CLI and pinned Codex marketplace install successfully. +- [ ] GitHub release is published on Friday. -Release: +## Positioning -Launch discussion: +The launch message is: **dangerous outcomes are not limited to dangerous nodes**. + +A normal HTTP, database, or messaging node can refund money, contact a customer, change access, or destroy data. Vibeflow v0.9 adds a preflight contract for the controls that should surround those outcomes: atomic idempotency, approval, amount and counterparty limits, durable audit, operator-visible failure paths, and recovery. ## Announcement -> Vibeflow is now an executable safety gate for AI-generated n8n workflows. It checks exported JSON for secrets, exposed webhooks, missing kill switches and handoffs, idempotency, failure paths, timeouts, and unsafe retries. It is dependency-free, runs locally or in GitHub Actions, and includes a Codex plugin. +> Vibeflow v0.9 asks a more useful preflight question for generated n8n workflows: not only “is this valid JSON?” or “does it use a dangerous node?”, but “what can this workflow do in the real world if the input is messy or the model is wrong?” +> +> The new outcome contracts detect money, customer, privileged, and destructive-data actions — including ordinary HTTP nodes — and verify structural evidence for idempotency, approval, limits, durable audit, failure notification, and recovery. It is local, dependency-free, CI-friendly, and explicit about what still needs runtime enforcement. -Link to the repository and the safe/unsafe demo. Ask users for anonymized false-positive cases and real workflow fixtures, not stars alone. +Link to the repository and the safe/unsafe refund demo. Ask users for anonymized workflows, classifier false positives/negatives, and missing domain actions. diff --git a/docs/product-brief.md b/docs/product-brief.md index d9ce161..80ead6f 100644 --- a/docs/product-brief.md +++ b/docs/product-brief.md @@ -6,7 +6,7 @@ Vibeflow is an open-source safety and contract gate for exported n8n workflows, ## Problem -Natural-language workflow generation is now common. The remaining failure is operational: a structurally valid workflow can still leak a credential, answer while disabled, duplicate a side effect, lack a human fallback, retry unsafely, or run forever. +Natural-language workflow generation is now common. The remaining failure is operational: a structurally valid workflow can still leak a credential, answer while disabled, duplicate a side effect, lack a human fallback, retry unsafely, run forever, issue a refund, notify a customer, or destroy data without the required controls. Existing builders and MCP servers should keep building. Vibeflow checks the result before production. @@ -21,12 +21,14 @@ Existing builders and MCP servers should keep building. Vibeflow checks the resu Given an exported workflow, produce a reproducible pass/fail report with concrete remediation and no network access. -## Version 0.8 scope +## Version 0.9 scope - dependency-free Node.js CLI; - text, JSON, and SARIF output; -- configurable VF000-VF009 policies; -- safe and unsafe fixtures; +- configurable VF000-VF013 policies; +- outcome contracts for money, customer, privileged, and destructive-data actions; +- structural checks for approval, durable audit, idempotency, limits, failure notification, and recovery; +- safe and unsafe support and refund fixtures; - GitHub Action; - Codex skill and plugin package. @@ -40,7 +42,7 @@ Given an exported workflow, produce a reproducible pass/fail report with concret ## Differentiation -Vibeflow starts with policies learned from real conversational-agent operations: an off switch before inference, human handoff after low-confidence output, duplicate-event protection, explicit error paths, bounded retries, and trust-boundary hygiene. +Vibeflow starts with policies learned from real operations: an off switch before inference, human handoff after low-confidence output, duplicate-event protection, explicit error paths, bounded retries, trust-boundary hygiene, and declared controls around real-world outcomes. It asks not only whether the workflow is valid JSON or uses a suspicious node, but what it can do when input is messy or a model is wrong. ## Evidence gate diff --git a/docs/release-audit.md b/docs/release-audit.md index 6dc7d23..169b389 100644 --- a/docs/release-audit.md +++ b/docs/release-audit.md @@ -1,54 +1,70 @@ -# Release audit — v0.8.0 +# Release audit — v0.9.0 candidate -Date: 2026-07-22 +Target release: 2026-07-24 ## Decision -**APTO COM RESSALVAS** for the first public executable release. +**APTO COM RESSALVAS** for the Friday release, pending the remote CI and released-install checks listed below. -No blocking defect remains in the reviewed static-analysis boundary. The remaining caveats require runtime or node-specific knowledge that an exported JSON gate cannot prove. +No blocking defect remains in the reviewed static-analysis boundary. Vibeflow can verify structural evidence in an exported workflow, but it cannot enforce authorization, amount limits, counterparty identity, audit durability, or recovery behavior in the executing systems. ## QA evidence -- 26 automated tests pass, including one safe and one intentionally unsafe workflow. -- The safe fixture produces zero findings; the unsafe fixture produces VF001-VF009. -- Text, JSON, and SARIF output paths are exercised. -- Node 20, 22, and 24 are required in CI; local verification used the available Node runtime and the remote matrix is the release gate. -- JSON/YAML parsing, package dry-run, official skill/plugin validators, `npm audit`, and `git diff --check` are part of the final gate. +- 36 automated tests pass, including safe and unsafe support workflows and a fully contracted refund workflow. +- The unsafe refund fixture detects an ordinary HTTP node as a money outcome and produces VF010, VF012, and VF013. +- The contracted refund fixture exits 0 in normal and `--locked` modes. +- Text, JSON, SARIF, configuration validation, package packing, and CLI exit behavior are exercised. +- `npm run verify`, `npm audit --omit=dev`, JSON parsing, and `git diff --check` pass locally. +- The package remains dependency-free and targets Node.js 20+. ## Red Team -The first implementation was rejected. Regression tests now cover the reproduced bypasses: +The first v0.9 implementation was hardened after adversarial review. Regression tests now cover: -- declared webhook authentication without the matching credential reference; -- literal secrets in raw headers, URLs, expressions, pinned data, and static data; -- disconnected, nominal, or non-gating idempotency controls; -- parallel AI paths that bypass a kill switch, inverted or constant conditions, and decorative Code/NoOp nodes; -- resource connections misread as control flow and fabricated connection shapes; -- disconnected or cyclic error handling; -- read-only HTTP requests mislabeled as handoff; -- excessive or non-numeric timeouts and unsafe retry settings; -- policy weakening from an untrusted checkout; -- terminal-control injection, deep JSON, excessive nodes/edges/files/config terms, finding amplification, and quadratic traversal. +1. ordinary HTTP refunds that would evade a dangerous-node-only policy; +2. read-only refund queries, preventing an obvious classifier false positive; +3. named or disconnected approval, amount, counterparty, audit, and idempotency controls; +4. constant approval conditions and decorative amount values; +5. read-only or fail-open audit nodes, including an audit error branch that still reaches the action; +6. fail-open idempotency nodes and idempotency error branches that still reach the side effect; +7. prototype-like contract keys, self-referential notification/recovery evidence, and audit-like labels used to hide a money action; +8. connected error paths that terminate without a recognized operator notification. -At the 5,000-node limit, the corrected linear traversal completed the synthetic chain in tens of milliseconds on the development machine. Finding truncation always adds blocking VF000. +The graph checks use actual `main` edges, require dominating controls, and reject evidence that reaches the action after the control itself fails. -## Supply chain +## Guardião security review -- GitHub-owned actions are pinned to full verified commit SHAs. -- Checkout credentials are not persisted; workflow permissions are `contents: read`. -- Jobs have a ten-minute timeout and package installation ignores scripts. -- The package has no runtime dependencies and uses a publish allowlist. -- The bundled action always enables `--locked`. +### In scope -The first merge SHA, `4998605ed7dc12b9b867d69d7005d25778c7e109`, pins the CLI, GitHub Action, and Codex marketplace examples before the release tag is created. +- local CLI parsing of untrusted workflow/configuration JSON; +- graph and parameter analysis; +- text, JSON, and SARIF output; +- npm package contents, GitHub Action boundary, and Codex plugin instructions. -## Residual limitations +### Controls confirmed -- Static analysis cannot prove a referenced credential exists or works. -- SQL and IF checks are high-confidence structural evidence, not runtime execution proofs. -- An HTTP POST labeled as a ticket or handoff may still fail or target the wrong service. -- Unknown community nodes may require a new side-effect adapter and regression fixture. -- Repository owners must still review changes to the CI workflow itself. +- no runtime dependencies, telemetry, hosted service, or live n8n mutation; +- no workflow expressions or embedded code are evaluated; +- no URLs found in a workflow are contacted; +- file, node, edge, contract, vocabulary, and finding budgets are bounded; +- terminal text is sanitized and prototype-like contract keys are handled as data; +- the GitHub Action uses locked policy mode so a pull request cannot disable its own built-in checks; +- examples contain credential references and reserved invalid domains, not live secrets. -The official Codex Security workspace was opened, but its setup was never submitted through the app interface; no result from that scanner is claimed here. The release decision is based on the direct QA, independent Red Team, supply-chain review, and regression evidence above. +### Residual limitations + +- an exported graph cannot prove a referenced credential, approval identity, SQL policy, external API limit, notification, or compensation works at runtime; +- custom/community nodes may need explicit impact declarations or new regression-backed adapters; +- repository owners may intentionally weaken policy outside `--locked` mode; +- remote Node 20/22/24 CI and released `npx`/plugin installation can only be confirmed after the candidate is pushed and tagged. + +## Release blockers + +- [ ] Pull request CI passes on Node.js 20, 22, and 24. +- [ ] Release commit is merged without unrelated changes. +- [ ] `v0.9.0` tag and GitHub release are published on 2026-07-24. +- [ ] Released CLI and Codex plugin install paths are smoke-tested. + +## Final gate + +There are zero known critical or high security findings in the local candidate. The release remains **APTO COM RESSALVAS** until the four remote gates above are complete; a failed gate blocks publication or requires an immediate corrective release. diff --git a/docs/release-notes-v0.9.md b/docs/release-notes-v0.9.md new file mode 100644 index 0000000..c4a3198 --- /dev/null +++ b/docs/release-notes-v0.9.md @@ -0,0 +1,24 @@ +# Vibeflow v0.9.0 — Outcome-aware preflight for n8n + +Generated workflows need more than valid JSON. A normal HTTP node can still issue a refund, notify a customer, change access, or delete production data. + +Vibeflow v0.9 introduces outcome contracts and four policies: + +- **VF010** blocks uncontracted money and privileged actions. +- **VF011** warns on uncontracted customer communications and destructive writes. +- **VF012** detects connected error paths that notify nobody. +- **VF013** requires a compensation, rollback, or replay story. + +For contracted actions, Vibeflow checks whether atomic idempotency and durable audit dominate the action, approval and limit nodes have real allow/deny branches, failure notification is connected to the error output, and recovery is represented in the graph. + +Try the unsafe case: + +```bash +npx --yes github:domfelipe/vibeflow-n8n#v0.9.0 check examples/unsafe-refund.workflow.json --fail-on never +``` + +Then inspect the passing contract in `examples/outcome-contracts.vibeflow.json` and `examples/safe-refund.workflow.json`. + +This is a static preflight, not a runtime policy engine. Authorization, amount/counterparty enforcement, audit durability, and tested recovery still belong in the services that execute the action. + +Full changelog: diff --git a/docs/roadmap.md b/docs/roadmap.md index d9ad87a..91ce4ac 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,17 +4,23 @@ Ship the executable reset: CLI, nine configurable policies, fixtures, tests, SARIF, GitHub Action, and Codex plugin. +## 0.9.0 — target 2026-07-24 + +Separate dangerous nodes from dangerous outcomes. Add VF010-VF013, explicit outcome contracts, graph evidence for policy gates, safe/unsafe refund fixtures, and clear runtime boundaries. + ## Next release gate Do not add another integration by default. Prioritize evidence from real workflows: -1. Measure false positives by rule. +1. Measure false positives by rule and impact classifier. 2. Accept anonymized fixtures from external users. 3. Add a node type or policy only with a failing fixture. 4. Improve GitHub annotations if SARIF users request it. 5. Package for npm only when GitHub installation creates material friction. 6. Add node-specific handoff and side-effect adapters only with adversarial safe/unsafe fixtures. 7. Model additional atomic idempotency gates only when their duplicate path is proven to stop downstream items. +8. Add impact categories and adapters only with a real anonymized workflow and an adversarial fixture. +9. Explore optional runtime attestations only after users demonstrate that static contracts are insufficient. ## Explicitly deferred diff --git a/examples/outcome-contracts.vibeflow.json b/examples/outcome-contracts.vibeflow.json new file mode 100644 index 0000000..362f4c6 --- /dev/null +++ b/examples/outcome-contracts.vibeflow.json @@ -0,0 +1,14 @@ +{ + "$schema": "../schemas/vibeflow-config.schema.json", + "outcomeContracts": { + "Issue refund": { + "impact": "money", + "approvalNode": "Approve refund", + "auditNode": "Record refund audit", + "failureNotificationNode": "Notify refund failure", + "amountGuard": { "node": "Limit refund amount", "maximum": 500, "currency": "USD" }, + "counterpartyGuard": { "node": "Allow refund account", "allowed": ["merchant-primary"] }, + "recovery": { "strategy": "compensate", "node": "Compensate transaction" } + } + } +} diff --git a/examples/safe-refund.workflow.json b/examples/safe-refund.workflow.json new file mode 100644 index 0000000..7d0e225 --- /dev/null +++ b/examples/safe-refund.workflow.json @@ -0,0 +1,85 @@ +{ + "name": "Safe refund with outcome contract", + "nodes": [ + { + "id": "webhook", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "parameters": { "authentication": "headerAuth", "path": "refund" }, + "credentials": { "httpHeaderAuth": { "id": "credential-reference", "name": "Webhook Header Auth" } } + }, + { + "id": "idempotency", + "name": "Claim idempotency event", + "type": "n8n-nodes-base.postgres", + "parameters": { + "operation": "executeQuery", + "query": "INSERT INTO event_ledger (event_id) VALUES ($1) ON CONFLICT (event_id) DO NOTHING RETURNING event_id" + } + }, + { + "id": "audit", + "name": "Record refund audit", + "type": "n8n-nodes-base.postgres", + "parameters": { "operation": "insert", "table": "refund_audit" } + }, + { + "id": "approval", + "name": "Approve refund", + "type": "n8n-nodes-base.if", + "parameters": { "conditions": { "boolean": [{ "value1": "={{ $json.approved }}", "value2": true }] } } + }, + { + "id": "amount", + "name": "Limit refund amount", + "type": "n8n-nodes-base.if", + "parameters": { + "conditions": { "number": [{ "value1": "={{ $json.amount }}", "operation": "smallerEqual", "value2": 500 }] }, + "currency": "USD" + } + }, + { + "id": "counterparty", + "name": "Allow refund account", + "type": "n8n-nodes-base.switch", + "parameters": { "value": "={{ $json.counterparty }}", "rules": [{ "value": "merchant-primary" }] } + }, + { + "id": "refund", + "name": "Issue refund", + "type": "n8n-nodes-base.httpRequest", + "onError": "continueErrorOutput", + "parameters": { "method": "POST", "url": "https://payments.invalid/refund" } + }, + { + "id": "failure-alert", + "name": "Notify refund failure", + "type": "n8n-nodes-base.slack", + "parameters": { "channel": "operations", "text": "Notify failure for refund {{ $json.event_id }}" } + }, + { + "id": "compensation", + "name": "Compensate transaction", + "type": "n8n-nodes-base.httpRequest", + "parameters": { "method": "POST", "url": "https://payments.invalid/reverse", "operation": "compensate" } + } + ], + "connections": { + "Webhook": { "main": [[{ "node": "Claim idempotency event", "type": "main", "index": 0 }]] }, + "Claim idempotency event": { "main": [[{ "node": "Record refund audit", "type": "main", "index": 0 }]] }, + "Record refund audit": { "main": [[{ "node": "Approve refund", "type": "main", "index": 0 }]] }, + "Approve refund": { "main": [[{ "node": "Limit refund amount", "type": "main", "index": 0 }], []] }, + "Limit refund amount": { "main": [[{ "node": "Allow refund account", "type": "main", "index": 0 }], []] }, + "Allow refund account": { "main": [[{ "node": "Issue refund", "type": "main", "index": 0 }], []] }, + "Issue refund": { + "main": [ + [], + [ + { "node": "Notify refund failure", "type": "main", "index": 0 }, + { "node": "Compensate transaction", "type": "main", "index": 0 } + ] + ] + } + }, + "settings": { "executionTimeout": 120, "errorWorkflow": "global-error-handler" } +} diff --git a/examples/safe-support-agent.workflow.json b/examples/safe-support-agent.workflow.json index d9f1d3d..1a5e492 100644 --- a/examples/safe-support-agent.workflow.json +++ b/examples/safe-support-agent.workflow.json @@ -64,6 +64,13 @@ "type": "n8n-nodes-base.noOp", "parameters": {} }, + { + "id": "failure-alert", + "name": "Operator error alert", + "type": "n8n-nodes-base.slack", + "onError": "continueErrorOutput", + "parameters": { "channel": "operations", "text": "Failure alert: {{ $json.error.message }}" } + }, { "id": "response", "name": "Respond to Webhook", @@ -76,7 +83,7 @@ "Claim idempotency event": { "main": [ [{ "node": "Agent enabled kill switch", "type": "main", "index": 0 }], - [{ "node": "Ledger failure stop", "type": "main", "index": 0 }] + [{ "node": "Operator error alert", "type": "main", "index": 0 }] ] }, "Agent enabled kill switch": { "main": [[{ "node": "AI Agent", "type": "main", "index": 0 }], []] }, @@ -89,6 +96,12 @@ ] }, "Human handoff ticket": { + "main": [ + [], + [{ "node": "Operator error alert", "type": "main", "index": 0 }] + ] + }, + "Operator error alert": { "main": [ [], [{ "node": "Handoff failure stop", "type": "main", "index": 0 }] diff --git a/examples/unsafe-refund.workflow.json b/examples/unsafe-refund.workflow.json new file mode 100644 index 0000000..f370e0e --- /dev/null +++ b/examples/unsafe-refund.workflow.json @@ -0,0 +1,29 @@ +{ + "name": "Unsafe refund without outcome contract", + "nodes": [ + { + "id": "webhook", + "name": "Public refund webhook", + "type": "n8n-nodes-base.webhook", + "parameters": { "authentication": "none", "path": "refund" } + }, + { + "id": "refund", + "name": "Issue refund", + "type": "n8n-nodes-base.httpRequest", + "onError": "continueErrorOutput", + "parameters": { "method": "POST", "url": "https://payments.invalid/refund" } + }, + { + "id": "swallow", + "name": "Swallow refund failure", + "type": "n8n-nodes-base.noOp", + "parameters": {} + } + ], + "connections": { + "Public refund webhook": { "main": [[{ "node": "Issue refund", "type": "main", "index": 0 }]] }, + "Issue refund": { "main": [[], [{ "node": "Swallow refund failure", "type": "main", "index": 0 }]] } + }, + "settings": {} +} diff --git a/package-lock.json b/package-lock.json index a6a7289..cf39e90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vibeflow-n8n", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vibeflow-n8n", - "version": "0.8.0", + "version": "0.9.0", "license": "MIT", "bin": { "vibeflow": "bin/vibeflow.mjs" diff --git a/package.json b/package.json index a27923b..e3b717a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vibeflow-n8n", - "version": "0.8.0", + "version": "0.9.0", "description": "Safety and contract checks for AI-generated n8n workflows", "type": "module", "bin": { diff --git a/plugins/vibeflow/.codex-plugin/plugin.json b/plugins/vibeflow/.codex-plugin/plugin.json index 4529029..834dd76 100644 --- a/plugins/vibeflow/.codex-plugin/plugin.json +++ b/plugins/vibeflow/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vibeflow", - "version": "0.8.0", + "version": "0.9.0", "description": "Audit and repair AI-generated n8n workflows before production", "author": { "name": "Felipe Domingues", @@ -13,8 +13,8 @@ "skills": "./skills/", "interface": { "displayName": "Vibeflow", - "shortDescription": "Safety checks for AI-generated n8n workflows", - "longDescription": "Run deterministic safety and contract checks on n8n workflow exports, explain findings, and repair blocking issues before deployment.", + "shortDescription": "Preflight checks for real-world n8n outcomes", + "longDescription": "Run deterministic safety and outcome-contract checks on n8n workflow exports, explain findings, and repair blocking issues before deployment.", "developerName": "Felipe Domingues", "category": "Productivity", "capabilities": ["Read", "Write"], diff --git a/plugins/vibeflow/skills/vibeflow/SKILL.md b/plugins/vibeflow/skills/vibeflow/SKILL.md index 16d1ca1..7e8a06c 100644 --- a/plugins/vibeflow/skills/vibeflow/SKILL.md +++ b/plugins/vibeflow/skills/vibeflow/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeflow -description: Audit and repair exported n8n workflow JSON with deterministic safety and contract checks. Use when reviewing AI-generated or customer-facing n8n workflows before deployment, investigating Vibeflow VF000-VF009 findings, adding kill switches or human handoffs, checking secrets, retries, and webhook exposure, or preparing workflow changes for CI and pull requests. +description: Audit and repair exported n8n workflow JSON with deterministic safety and outcome-contract checks. Use when reviewing AI-generated or customer-facing n8n workflows before deployment, investigating Vibeflow VF000-VF013 findings, adding kill switches or human handoffs, checking secrets, retries, webhook exposure, money or customer side effects, recovery paths, or preparing workflow changes for CI and pull requests. --- # Vibeflow @@ -19,7 +19,7 @@ Use the CLI as the source of truth. Do not infer that a workflow is safe from it For a released version without a checkout: ```bash - npx --yes github:domfelipe/vibeflow-n8n#4998605ed7dc12b9b867d69d7005d25778c7e109 check path/to/workflow.json + npx --yes github:domfelipe/vibeflow-n8n#v0.9.0 check path/to/workflow.json ``` 3. Read [references/policies.md](references/policies.md) when interpreting or repairing a finding. @@ -32,6 +32,8 @@ Use the CLI as the source of truth. Do not infer that a workflow is safe from it - Never copy literal credentials into a workflow to silence `VF001`. - Never disable `VF006` for customer-facing agents without explicit user approval; an off switch must block inference and all AI responses. - Treat static analysis as a preflight, not proof of runtime correctness. +- Treat `VF010` and `VF011` as outcome risks, even when the underlying node type is ordinary HTTP, database, or messaging. +- Do not claim an outcome contract enforces runtime authorization, limits, audit durability, or recovery; verify those separately in the executing system. - Prefer fixing a shared upstream node over duplicating guards across branches. - Keep fixes local to the exported workflow until the user authorizes deployment. diff --git a/plugins/vibeflow/skills/vibeflow/references/policies.md b/plugins/vibeflow/skills/vibeflow/references/policies.md index 7864016..67e7596 100644 --- a/plugins/vibeflow/skills/vibeflow/references/policies.md +++ b/plugins/vibeflow/skills/vibeflow/references/policies.md @@ -12,5 +12,11 @@ | VF007 | warning | AI Agent without a reachable external handoff action | Add a downstream ticket, chat, email, or equivalent human escalation action. | | VF008 | warning | Missing or excessive execution timeout | Set the workflow timeout between 1 and 3600 seconds. | | VF009 | warning | Unsafe retry policy | Add idempotency, bound attempts, and configure backoff. | +| VF010 | error | Money or privileged action lacks verified policy evidence | Declare the action and connect dominating approval, durable audit, atomic idempotency, limits where applicable, failure notification, and recovery. | +| VF011 | warning | Customer communication or destructive write lacks an outcome contract | Declare the impact and connect durable audit, atomic idempotency, failure notification, and recovery evidence. | +| VF012 | warning | Connected error path terminates silently | Route the action's error output to a recognized operator alert, ticket, incident, or escalation node. | +| VF013 | warning | High-impact write has no recovery contract | Declare compensation, rollback, or replay and reference the implementing node. | Configuration changes severity or domain vocabulary; it does not prove the suppressed risk is safe. Keep waivers visible in `.vibeflow.json` and explain them in the pull request. + +Outcome contracts are static evidence. Runtime systems must still authorize approvers, enforce limits and counterparties server-side, create durable audit entries before acting, deduplicate atomically, and test recovery behavior. diff --git a/schemas/vibeflow-config.schema.json b/schemas/vibeflow-config.schema.json index c653f7e..5578fc7 100644 --- a/schemas/vibeflow-config.schema.json +++ b/schemas/vibeflow-config.schema.json @@ -18,7 +18,11 @@ "VF006": { "$ref": "#/$defs/severity" }, "VF007": { "$ref": "#/$defs/severity" }, "VF008": { "$ref": "#/$defs/severity" }, - "VF009": { "$ref": "#/$defs/severity" } + "VF009": { "$ref": "#/$defs/severity" }, + "VF010": { "$ref": "#/$defs/severity" }, + "VF011": { "$ref": "#/$defs/severity" }, + "VF012": { "$ref": "#/$defs/severity" }, + "VF013": { "$ref": "#/$defs/severity" } } }, "terms": { @@ -27,7 +31,15 @@ "properties": { "killSwitch": { "$ref": "#/$defs/terms" }, "humanHandoff": { "$ref": "#/$defs/terms" }, - "idempotency": { "$ref": "#/$defs/terms" } + "idempotency": { "$ref": "#/$defs/terms" }, + "approval": { "$ref": "#/$defs/terms" }, + "audit": { "$ref": "#/$defs/terms" }, + "failureNotification": { "$ref": "#/$defs/terms" }, + "outcomeMoney": { "$ref": "#/$defs/terms" }, + "outcomeCustomer": { "$ref": "#/$defs/terms" }, + "outcomePrivileged": { "$ref": "#/$defs/terms" }, + "outcomeData": { "$ref": "#/$defs/terms" }, + "recovery": { "$ref": "#/$defs/terms" } } }, "bannedNodeTypes": { @@ -35,6 +47,13 @@ "items": { "type": "string", "minLength": 1, "maxLength": 200 }, "maxItems": 1000, "uniqueItems": true + }, + "outcomeContracts": { + "type": "object", + "description": "Static policy evidence keyed by the exact high-impact action node name.", + "maxProperties": 1000, + "propertyNames": { "minLength": 1, "maxLength": 200 }, + "additionalProperties": { "$ref": "#/$defs/outcomeContract" } } }, "$defs": { @@ -45,6 +64,52 @@ "minItems": 1, "maxItems": 100, "uniqueItems": true + }, + "nodeReference": { "type": "string", "minLength": 1, "maxLength": 200 }, + "outcomeContract": { + "type": "object", + "additionalProperties": false, + "required": ["impact"], + "properties": { + "impact": { "enum": ["money", "customer", "privileged", "data"] }, + "approvalNode": { "$ref": "#/$defs/nodeReference" }, + "auditNode": { "$ref": "#/$defs/nodeReference" }, + "failureNotificationNode": { "$ref": "#/$defs/nodeReference" }, + "amountGuard": { + "type": "object", + "additionalProperties": false, + "required": ["node", "maximum", "currency"], + "properties": { + "node": { "$ref": "#/$defs/nodeReference" }, + "maximum": { "type": "number", "exclusiveMinimum": 0 }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$" } + } + }, + "counterpartyGuard": { + "type": "object", + "additionalProperties": false, + "required": ["node", "allowed"], + "properties": { + "node": { "$ref": "#/$defs/nodeReference" }, + "allowed": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 100 } + } + } + }, + "recovery": { + "type": "object", + "additionalProperties": false, + "required": ["strategy", "node"], + "properties": { + "strategy": { "enum": ["compensate", "rollback", "replay"] }, + "node": { "$ref": "#/$defs/nodeReference" } + } + } + } } } } diff --git a/src/vibeflow.mjs b/src/vibeflow.mjs index 460a53a..3ba9f09 100644 --- a/src/vibeflow.mjs +++ b/src/vibeflow.mjs @@ -1,7 +1,7 @@ import { access, readFile, readdir, stat } from "node:fs/promises"; import path from "node:path"; -export const VERSION = "0.8.0"; +export const VERSION = "0.9.0"; export const MAX_WORKFLOW_BYTES = 25 * 1024 * 1024; export const MAX_CONFIG_BYTES = 256 * 1024; export const MAX_WORKFLOW_FILES = 1_000; @@ -71,6 +71,30 @@ export const RULES = Object.freeze({ description: "Retries need idempotency, bounded attempts, and backoff.", remediation: "Add a deduplication guard, keep maxTries between 2 and 5, and wait at least 100 ms.", }, + VF010: { + name: "uncontracted-critical-outcome", + severity: "error", + description: "Money and privileged actions need an explicit, structurally verified outcome contract.", + remediation: "Define an outcomeContracts entry with approval, audit, idempotency, limits, failure notification, and recovery evidence.", + }, + VF011: { + name: "uncontracted-external-outcome", + severity: "warning", + description: "Customer communications and destructive data writes need an explicit outcome contract.", + remediation: "Define an outcomeContracts entry that identifies the impact and its audit, notification, and recovery nodes.", + }, + VF012: { + name: "silent-error-path", + severity: "warning", + description: "A connected error path should notify or escalate to an operator.", + remediation: "Route the error output to a recognized alert, incident, ticket, or human escalation node.", + }, + VF013: { + name: "missing-recovery-contract", + severity: "warning", + description: "High-impact writes need a declared compensation, rollback, or replay path.", + remediation: "Declare a recovery strategy and reference the workflow node that implements it.", + }, }); const DEFAULT_CONFIG = Object.freeze({ @@ -79,6 +103,14 @@ const DEFAULT_CONFIG = Object.freeze({ killSwitch: ["agent-off", "kill switch", "agent enabled", "ai enabled", "agent status", "pause ai"], humanHandoff: ["human handoff", "handoff", "human review", "escalate", "manual review", "chatwoot", "ticket"], idempotency: ["idempotency", "idempotent", "dedupe", "deduplicate", "duplicate", "event ledger", "event id"], + approval: ["approval", "approved", "authorize", "authorized", "human review"], + audit: ["audit", "ledger", "journal", "event log", "history"], + failureNotification: ["error alert", "failure alert", "notify failure", "incident", "pager", "escalate error"], + outcomeMoney: ["refund", "payment", "payout", "charge", "transfer funds", "withdraw"], + outcomeCustomer: ["customer message", "customer notification", "notify customer", "client message", "patient message"], + outcomePrivileged: ["delete account", "revoke access", "grant access", "change role", "disable user", "publish"], + outcomeData: ["delete record", "drop table", "truncate", "purge", "overwrite data"], + recovery: ["compensate", "rollback", "replay", "reverse", "restore", "recovery"], }, bannedNodeTypes: [ "n8n-nodes-base.executecommand", @@ -86,6 +118,7 @@ const DEFAULT_CONFIG = Object.freeze({ "n8n-nodes-base.readwritefile", "n8n-nodes-base.localfiletrigger", ], + outcomeContracts: {}, }); const SIDE_EFFECT_SUFFIXES = [ @@ -150,6 +183,34 @@ const HANDOFF_SUFFIXES = [ ".jira", ]; +const DURABLE_AUDIT_SUFFIXES = [ + ".postgres", + ".mysql", + ".microsoftsql", + ".supabase", + ".datatable", + ".mongodb", + ".dynamodb", + ".s3", + ".airtable", +]; + +const COMMUNICATION_SUFFIXES = [ + ".httprequest", + ".slack", + ".gmail", + ".emailsend", + ".telegram", + ".twilio", + ".microsoftteams", + ".discord", + ".zendesk", + ".freshdesk", + ".intercom", + ".servicenow", + ".jira", +]; + const TRIGGER_SUFFIXES = [".webhook", ".formtrigger", ".chattrigger", ".telegramtrigger", ".stripetrigger"]; const SAFE_LITERAL = /^(?:<[^>]+>|redacted|change[-_ ]?me|your[-_ ].*|example(?:[-_ ].*)?|placeholder(?:[-_ ].*)?)$/i; @@ -183,7 +244,7 @@ export function normalizeConfig(input = {}, { locked = false } = {}) { if (!input || typeof input !== "object" || Array.isArray(input)) { throw new Error("Vibeflow config must be a JSON object"); } - const allowedKeys = new Set(["$schema", "rules", "terms", "bannedNodeTypes"]); + const allowedKeys = new Set(["$schema", "rules", "terms", "bannedNodeTypes", "outcomeContracts"]); const unknownKey = Object.keys(input).find((key) => !allowedKeys.has(key)); if (unknownKey) throw new Error(`Unknown config key: ${unknownKey}`); const config = cloneDefaults(); @@ -232,6 +293,10 @@ export function normalizeConfig(input = {}, { locked = false } = {}) { config.bannedNodeTypes = normalizedTypes; } + if (input.outcomeContracts !== undefined) { + config.outcomeContracts = normalizeOutcomeContracts(input.outcomeContracts); + } + return config; } @@ -278,6 +343,7 @@ export function inspectWorkflow(workflow, config = cloneDefaults()) { if (shapeError) return [invalidFinding(shapeError)]; const nodes = workflow.nodes; const { adjacency, reverse } = buildGraph(workflow.connections ?? {}); + const nodesByName = new Map(nodes.map((node) => [node.name, node])); const aiNodes = nodes.filter(isAiAgent); const triggerNodes = nodes.filter(isInboundTrigger); const sideEffectNodes = nodes.filter(isSideEffect); @@ -288,13 +354,14 @@ export function inspectWorkflow(workflow, config = cloneDefaults()) { .map((node) => node.name)); const handoffNames = nodes.filter((node) => isHumanHandoff(node, config.terms.humanHandoff)).map((node) => node.name); const idempotencyNames = new Set(nodes.filter((node) => isAtomicIdempotencyGuard(node, config.terms.idempotency)).map((node) => node.name)); + const failureNotificationNames = new Set(nodes + .filter((node) => isFailureNotification(node, config.terms.failureNotification)) + .map((node) => node.name)); const allReachable = traverseGraph(adjacency, entryNames); const reachableWithoutKillSwitch = traverseGraph(adjacency, entryNames, killSwitchNames); const canReachHandoff = traverseGraph(reverse, handoffNames); const triggerNames = triggerNodes.map((node) => node.name); const triggerReachable = traverseGraph(adjacency, triggerNames); - const triggerReachableWithoutIdempotency = traverseGraph(adjacency, triggerNames, idempotencyNames); - const entryReachableWithoutIdempotency = traverseGraph(adjacency, entryNames, idempotencyNames); const workflowHasErrorHandler = typeof workflow.settings?.errorWorkflow === "string" && workflow.settings.errorWorkflow.trim(); const findings = []; let findingsTruncated = false; @@ -339,10 +406,19 @@ export function inspectWorkflow(workflow, config = cloneDefaults()) { add("VF004", "External-action node has no connected error output or workflow error handler", node); } + if (isSideEffect(node) + && !failureNotificationNames.has(node.name) + && !workflowHasErrorHandler + && hasConnectedErrorPath(node, workflow.connections ?? {}) + && !errorBranchCanReach(node.name, failureNotificationNames, workflow.connections ?? {}, adjacency)) { + add("VF012", "Connected error output terminates without a recognized operator notification or escalation", node); + } + if (isSideEffect(node) && node.retryOnFail === true) { const maxTries = Number(node.maxTries ?? 3); const waitBetweenTries = Number(node.waitBetweenTries ?? 0); - if (!idempotencyNames.has(node.name) && (!allReachable.has(node.name) || entryReachableWithoutIdempotency.has(node.name))) { + if (!idempotencyNames.has(node.name) + && !hasProtectedIdempotencyPath(node.name, entryNames, idempotencyNames, adjacency, workflow.connections ?? {})) { add("VF009", "Retry is enabled on a path without an atomic idempotency guard", node); } if (!Number.isFinite(maxTries) || maxTries < 2 || maxTries > 5) { @@ -374,13 +450,27 @@ export function inspectWorkflow(workflow, config = cloneDefaults()) { } for (const sideEffectNode of sideEffectNodes) { - if (!idempotencyNames.has(sideEffectNode.name) + if (!failureNotificationNames.has(sideEffectNode.name) + && !idempotencyNames.has(sideEffectNode.name) && triggerReachable.has(sideEffectNode.name) - && triggerReachableWithoutIdempotency.has(sideEffectNode.name)) { + && !hasProtectedIdempotencyPath(sideEffectNode.name, triggerNames, idempotencyNames, adjacency, workflow.connections ?? {})) { add("VF005", "Inbound path reaches this external side effect without an atomic idempotency guard", sideEffectNode); } } + inspectOutcomeContracts({ + nodes, + nodesByName, + config, + adjacency, + connections: workflow.connections ?? {}, + entryNames, + allReachable, + idempotencyNames, + failureNotificationNames, + add, + }); + for (const aiNode of aiNodes) { if (!allReachable.has(aiNode.name) || reachableWithoutKillSwitch.has(aiNode.name)) { add("VF006", "At least one entry path reaches the AI Agent without a structural kill-switch gate", aiNode); @@ -462,9 +552,82 @@ function cloneDefaults() { rules: { ...DEFAULT_CONFIG.rules }, terms: Object.fromEntries(Object.entries(DEFAULT_CONFIG.terms).map(([key, values]) => [key, [...values]])), bannedNodeTypes: [...DEFAULT_CONFIG.bannedNodeTypes], + outcomeContracts: {}, }; } +function normalizeOutcomeContracts(input) { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new Error("config.outcomeContracts must be an object keyed by exact node name"); + } + const entries = Object.entries(input); + if (entries.length > 1_000) throw new Error("config.outcomeContracts must contain at most 1000 entries"); + const normalizedEntries = []; + for (const [nodeName, value] of entries) { + if (!nodeName.trim() || nodeName.length > 200) { + throw new Error("Outcome contract node names must contain 1 to 200 characters"); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`Outcome contract for ${safeDisplay(nodeName)} must be an object`); + } + const allowedKeys = new Set([ + "impact", "approvalNode", "auditNode", "failureNotificationNode", + "amountGuard", "counterpartyGuard", "recovery", + ]); + const unknownKey = Object.keys(value).find((key) => !allowedKeys.has(key)); + if (unknownKey) throw new Error(`Unknown outcome contract key for ${safeDisplay(nodeName)}: ${safeDisplay(unknownKey)}`); + if (!["money", "customer", "privileged", "data"].includes(value.impact)) { + throw new Error(`Outcome contract for ${safeDisplay(nodeName)} must declare impact as money, customer, privileged, or data`); + } + for (const field of ["approvalNode", "auditNode", "failureNotificationNode"]) { + if (value[field] !== undefined) validateNodeReference(value[field], `${nodeName}.${field}`); + } + if (value.amountGuard !== undefined) { + validateNestedContract(value.amountGuard, `${nodeName}.amountGuard`, ["node", "maximum", "currency"]); + validateNodeReference(value.amountGuard.node, `${nodeName}.amountGuard.node`); + if (typeof value.amountGuard.maximum !== "number" || !Number.isFinite(value.amountGuard.maximum) || value.amountGuard.maximum <= 0) { + throw new Error(`Outcome contract ${safeDisplay(nodeName)}.amountGuard.maximum must be a positive finite number`); + } + if (typeof value.amountGuard.currency !== "string" || !/^[A-Z]{3}$/.test(value.amountGuard.currency)) { + throw new Error(`Outcome contract ${safeDisplay(nodeName)}.amountGuard.currency must be a three-letter uppercase currency code`); + } + } + if (value.counterpartyGuard !== undefined) { + validateNestedContract(value.counterpartyGuard, `${nodeName}.counterpartyGuard`, ["node", "allowed"]); + validateNodeReference(value.counterpartyGuard.node, `${nodeName}.counterpartyGuard.node`); + if (!Array.isArray(value.counterpartyGuard.allowed) + || !value.counterpartyGuard.allowed.length + || value.counterpartyGuard.allowed.length > 100 + || value.counterpartyGuard.allowed.some((item) => typeof item !== "string" || !item.trim() || item.length > 100)) { + throw new Error(`Outcome contract ${safeDisplay(nodeName)}.counterpartyGuard.allowed must contain 1 to 100 non-empty strings`); + } + } + if (value.recovery !== undefined) { + validateNestedContract(value.recovery, `${nodeName}.recovery`, ["strategy", "node"]); + validateNodeReference(value.recovery.node, `${nodeName}.recovery.node`); + if (!["compensate", "rollback", "replay"].includes(value.recovery.strategy)) { + throw new Error(`Outcome contract ${safeDisplay(nodeName)}.recovery.strategy must be compensate, rollback, or replay`); + } + } + normalizedEntries.push([nodeName, structuredClone(value)]); + } + return Object.fromEntries(normalizedEntries); +} + +function validateNestedContract(value, pathName, allowedKeys) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`Outcome contract ${safeDisplay(pathName)} must be an object`); + } + const unknownKey = Object.keys(value).find((key) => !allowedKeys.includes(key)); + if (unknownKey) throw new Error(`Unknown outcome contract key ${safeDisplay(pathName)}.${safeDisplay(unknownKey)}`); +} + +function validateNodeReference(value, pathName) { + if (typeof value !== "string" || !value.trim() || value.length > 200) { + throw new Error(`Outcome contract ${safeDisplay(pathName)} must be a non-empty node name of at most 200 characters`); + } +} + async function collectWorkflowFiles(inputs) { const files = new Set(); for (const input of inputs) { @@ -697,6 +860,237 @@ function hasConnectedErrorPath(node, connections) { && output.some((connection) => reachesTerminalWithout(connection.node, node.name, connections))); } +function inspectOutcomeContracts({ + nodes, + nodesByName, + config, + adjacency, + connections, + entryNames, + allReachable, + idempotencyNames, + failureNotificationNames, + add, +}) { + const contracts = config.outcomeContracts ?? {}; + const evidenceNodeNames = new Set(Object.values(contracts).flatMap((contract) => [ + contract.auditNode, + contract.failureNotificationNode, + contract.recovery?.node, + ]).filter(Boolean)); + const inspected = new Set(); + + for (const node of nodes) { + const contract = contracts[node.name]; + const impact = contract?.impact ?? (evidenceNodeNames.has(node.name) ? null : classifyOutcome(node, config.terms)); + if (!impact) continue; + inspected.add(node.name); + const ruleId = ["money", "privileged"].includes(impact) ? "VF010" : "VF011"; + if (!contract) { + add(ruleId, `Detected ${impact} outcome has no outcomeContracts entry`, node); + add("VF013", `Detected ${impact} outcome has no declared recovery strategy`, node); + continue; + } + + const issues = []; + const recoveryIssues = []; + const auditNode = nodesByName.get(contract.auditNode); + if (!auditNode) { + issues.push("missing auditNode reference"); + } else if (!isDurableAuditNode(auditNode, config.terms.audit)) { + issues.push(`auditNode ${contract.auditNode} is not recognized as a durable audit write`); + } else if (!dominatesTarget(contract.auditNode, node.name, adjacency, entryNames, allReachable)) { + issues.push(`auditNode ${contract.auditNode} does not guard every entry path to the action`); + } else if (errorBranchCanReach(contract.auditNode, new Set([node.name]), connections, adjacency)) { + issues.push(`auditNode ${contract.auditNode} can reach the action after an audit failure`); + } + + const notificationNode = nodesByName.get(contract.failureNotificationNode); + if (!notificationNode || contract.failureNotificationNode === node.name) { + issues.push("missing failureNotificationNode reference"); + } else if (!failureNotificationNames.has(contract.failureNotificationNode)) { + issues.push(`failureNotificationNode ${contract.failureNotificationNode} is not recognized as an operator alert`); + } else if (!errorBranchCanReach(node.name, new Set([contract.failureNotificationNode]), connections, adjacency)) { + issues.push(`failureNotificationNode ${contract.failureNotificationNode} is not reachable from the action error output`); + } + + if (!hasProtectedIdempotencyPath(node.name, entryNames, idempotencyNames, adjacency, connections)) { + issues.push("an entry path reaches the action without an atomic idempotency claim"); + } + + if (["money", "privileged"].includes(impact)) { + const approvalNode = nodesByName.get(contract.approvalNode); + if (!approvalNode) { + issues.push("missing approvalNode reference"); + } else if (!isStructuralGuard(approvalNode, node.name, adjacency, connections, entryNames, allReachable) + || !lowerType(approvalNode).endsWith(".if") + || !hasPositiveDynamicBooleanCondition(approvalNode.parameters ?? {}, config.terms.approval)) { + issues.push(`approvalNode ${contract.approvalNode} is not a dominating approval gate with a deny branch`); + } + } + + if (impact === "money") { + const amountNode = nodesByName.get(contract.amountGuard?.node); + if (!amountNode) { + issues.push("missing amountGuard node reference"); + } else if (!isStructuralGuard(amountNode, node.name, adjacency, connections, entryNames, allReachable) + || !hasDynamicUpperBound(amountNode.parameters ?? {}, contract.amountGuard.maximum) + || !nodeContainsExactValue(amountNode.parameters ?? {}, contract.amountGuard.currency)) { + issues.push(`amountGuard ${contract.amountGuard.node} does not structurally enforce the declared maximum and currency`); + } + + const counterpartyNode = nodesByName.get(contract.counterpartyGuard?.node); + if (!counterpartyNode) { + issues.push("missing counterpartyGuard node reference"); + } else if (!isStructuralGuard(counterpartyNode, node.name, adjacency, connections, entryNames, allReachable) + || !valueContainsDirectDynamicReference(counterpartyNode.parameters ?? {}) + || !contract.counterpartyGuard.allowed.every((value) => nodeContainsExactValue(counterpartyNode.parameters ?? {}, value))) { + issues.push(`counterpartyGuard ${contract.counterpartyGuard.node} does not structurally enforce every allowed counterparty`); + } + } + + const recoveryNode = nodesByName.get(contract.recovery?.node); + if (!recoveryNode || contract.recovery?.node === node.name) { + recoveryIssues.push("missing recovery node reference"); + } else if (!nodeContainsTerms(recoveryNode, config.terms.recovery)) { + recoveryIssues.push(`recovery node ${contract.recovery.node} is not recognized as compensation, rollback, or replay logic`); + } else if (["compensate", "rollback"].includes(contract.recovery.strategy) + && !traverseGraph(adjacency, [node.name]).has(contract.recovery.node)) { + recoveryIssues.push(`${contract.recovery.strategy} node ${contract.recovery.node} is not downstream from the action`); + } + + if (issues.length) add(ruleId, `Outcome contract is incomplete: ${issues.join("; ")}`, node); + if (recoveryIssues.length) add("VF013", `Recovery contract is incomplete: ${recoveryIssues.join("; ")}`, node); + } + + for (const [nodeName, contract] of Object.entries(contracts)) { + if (inspected.has(nodeName) || nodesByName.has(nodeName)) continue; + const ruleId = ["money", "privileged"].includes(contract.impact) ? "VF010" : "VF011"; + add(ruleId, `Outcome contract references missing action node: ${nodeName}`); + } +} + +function classifyOutcome(node, terms) { + if (!isOutcomeAction(node)) return null; + if (nodeContainsTerms(node, terms.outcomeMoney)) return "money"; + if (nodeContainsTerms(node, terms.outcomePrivileged)) return "privileged"; + if (isDestructiveDataAction(node) || nodeContainsTerms(node, terms.outcomeData)) return "data"; + if (isCommunicationNode(node) && nodeContainsTerms(node, terms.outcomeCustomer)) return "customer"; + return null; +} + +function isCommunicationNode(node) { + return COMMUNICATION_SUFFIXES.some((suffix) => lowerType(node).endsWith(suffix)); +} + +function isOutcomeAction(node) { + if (!isSideEffect(node)) return false; + if (!lowerType(node).endsWith(".httprequest")) return true; + return ["POST", "PUT", "PATCH", "DELETE"].includes(String(node.parameters?.method ?? "GET").toUpperCase()); +} + +function isDestructiveDataAction(node) { + if (!DURABLE_AUDIT_SUFFIXES.some((suffix) => lowerType(node).endsWith(suffix))) return false; + const operation = String(node.parameters?.operation ?? node.parameters?.resourceOperation ?? "").toLowerCase(); + if (["delete", "remove", "truncate", "drop"].includes(operation)) return true; + return nodeContainsSqlStatement(node.parameters ?? {}, /^\s*(?:delete\s+from|drop\s+(?:table|schema|database)|truncate\b)/i); +} + +function isFailureNotification(node, terms) { + if (!isCommunicationNode(node) || !nodeContainsTerms(node, terms)) return false; + if (!lowerType(node).endsWith(".httprequest")) return true; + return ["POST", "PUT", "PATCH"].includes(String(node.parameters?.method ?? "GET").toUpperCase()); +} + +function errorBranchCanReach(nodeName, targetNames, connections, adjacency) { + const branches = connections[nodeName]?.main; + if (!Array.isArray(branches)) return false; + const starts = branches.slice(1).flatMap((branch) => (branch ?? [])) + .filter((connection) => connection.type === "main") + .map((connection) => connection.node); + if (!starts.length) return false; + const reachable = traverseGraph(adjacency, starts); + return [...targetNames].some((target) => reachable.has(target)); +} + +function isDurableAuditNode(node, terms) { + if (!DURABLE_AUDIT_SUFFIXES.some((suffix) => lowerType(node).endsWith(suffix)) + || !nodeContainsTerms(node, terms) + || node.continueOnFail === true + || node.onError === "continueRegularOutput") return false; + const operation = String(node.parameters?.operation ?? node.parameters?.resourceOperation ?? "").toLowerCase(); + if (["create", "update", "append", "insert", "upsert", "add", "put"].includes(operation)) return true; + if (operation && operation !== "executequery") return false; + return nodeContainsSqlStatement(node.parameters ?? {}, /^\s*(?:insert|update|merge)\b/i); +} + +function dominatesTarget(blockerName, targetName, adjacency, entryNames, allReachable) { + if (blockerName === targetName || !allReachable.has(targetName)) return false; + return !traverseGraph(adjacency, entryNames, new Set([blockerName])).has(targetName); +} + +function isStructuralGuard(node, targetName, adjacency, connections, entryNames, allReachable) { + if (![".if", ".switch"].some((suffix) => lowerType(node).endsWith(suffix))) return false; + if (!dominatesTarget(node.name, targetName, adjacency, entryNames, allReachable)) return false; + const branches = connections[node.name]?.main; + if (!Array.isArray(branches) || branches.length < 2) return false; + const reachesTarget = branches.map((branch) => { + const starts = (branch ?? []).filter((connection) => connection.type === "main").map((connection) => connection.node); + return starts.length > 0 && traverseGraph(adjacency, starts).has(targetName); + }); + return reachesTarget.some(Boolean) && reachesTarget.some((value) => !value); +} + +function nodeContainsExactValue(input, expected) { + const normalizedExpected = typeof expected === "string" ? expected.trim().toLowerCase() : expected; + const stack = [input]; + while (stack.length) { + const value = stack.pop(); + if (typeof value === "string" && typeof normalizedExpected === "string" && value.trim().toLowerCase() === normalizedExpected) return true; + if (typeof value === "number" && typeof normalizedExpected === "number" && value === normalizedExpected) return true; + if (Array.isArray(value)) { + for (const child of value) stack.push(child); + } else if (value && typeof value === "object") { + for (const child of Object.values(value)) stack.push(child); + } + } + return false; +} + +function hasDynamicUpperBound(input, maximum) { + const stack = [input]; + const allowedOperations = new Set(["smaller", "smallerequal", "lessthan", "lessthanorequal", "lte"]); + while (stack.length) { + const value = stack.pop(); + if (Array.isArray(value)) { + for (const child of value) stack.push(child); + continue; + } + if (!value || typeof value !== "object") continue; + const values = Object.values(value); + const operation = String(value.operation ?? "").toLowerCase().replace(/[^a-z]/g, ""); + if (allowedOperations.has(operation) + && values.some((child) => typeof child === "string" && isDirectDynamicReference(child)) + && values.some((child) => typeof child === "number" && child === maximum)) return true; + for (const child of values) stack.push(child); + } + return false; +} + +function valueContainsDirectDynamicReference(input) { + const stack = [input]; + while (stack.length) { + const value = stack.pop(); + if (typeof value === "string" && isDirectDynamicReference(value)) return true; + if (Array.isArray(value)) { + for (const child of value) stack.push(child); + } else if (value && typeof value === "object") { + for (const child of Object.values(value)) stack.push(child); + } + } + return false; +} + function reachesTerminalWithout(start, excluded, connections) { const stack = [start]; const seen = new Set(); @@ -762,13 +1156,20 @@ function isHumanHandoff(node, terms) { } function isAtomicIdempotencyGuard(node, terms) { - if (!nodeContainsTerms(node, terms)) return false; + if (!nodeContainsTerms(node, terms) || node.continueOnFail === true || node.onError === "continueRegularOutput") return false; const type = lowerType(node); const parameters = node.parameters ?? {}; return type.endsWith(".postgres") && nodeContainsSqlStatement(parameters, /^\s*insert\b[\s\S]*\bon\s+conflict\b[\s\S]*\bdo\s+nothing\b[\s\S]*\breturning\b/i); } +function hasProtectedIdempotencyPath(targetName, starts, idempotencyNames, adjacency, connections) { + const validGuards = new Set([...idempotencyNames].filter((guardName) => + !errorBranchCanReach(guardName, new Set([targetName]), connections, adjacency))); + if (!validGuards.size || !traverseGraph(adjacency, starts).has(targetName)) return false; + return !traverseGraph(adjacency, starts, validGuards).has(targetName); +} + function nodeContainsSqlStatement(value, pattern) { let matches = false; walk({ value }, "", (child) => { diff --git a/test/vibeflow.test.mjs b/test/vibeflow.test.mjs index 6cac8b5..2fa1379 100644 --- a/test/vibeflow.test.mjs +++ b/test/vibeflow.test.mjs @@ -89,7 +89,7 @@ test("plugin and skill manifests contain no scaffold placeholders", async () => const plugin = JSON.parse(await readFile(pluginPath, "utf8")); const skill = await readFile(skillPath, "utf8"); assert.equal(plugin.name, "vibeflow"); - assert.equal(plugin.version, "0.8.0"); + assert.equal(plugin.version, "0.9.0"); assert.match(skill, /^---\nname: vibeflow\ndescription: .+\n---/); assert.doesNotMatch(`${JSON.stringify(plugin)}\n${skill}`, /\[TODO:/); }); @@ -176,6 +176,20 @@ test("idempotency must be atomic and dominate inbound side-effect paths", () => Webhook: { main: [[connection(guard.name)]] }, [guard.name]: { main: [[connection("Send")]] }, }), "VF005", "Send"), true); + + guard.parameters.query = "INSERT INTO event_ledger VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id"; + guard.continueOnFail = true; + assert.equal(findingFor(workflowWith([nodes[0], guard, nodes[1]], { + Webhook: { main: [[connection(guard.name)]] }, + [guard.name]: { main: [[connection("Send")]] }, + }), "VF005", "Send"), true); + + guard.continueOnFail = false; + guard.onError = "continueErrorOutput"; + assert.equal(findingFor(workflowWith([nodes[0], guard, nodes[1]], { + Webhook: { main: [[connection(guard.name)]] }, + [guard.name]: { main: [[connection("Send")], [connection("Send")]] }, + }), "VF005", "Send"), true); }); test("kill switches must be structural and dominate every AI entry path", () => { @@ -303,6 +317,175 @@ test("error handling must be connected or configured at workflow level", () => { assert.equal(findingFor(indirectLoop, "VF004", request.name), true); }); +test("ordinary HTTP money actions require a critical outcome contract", () => { + const refund = { + name: "Issue refund", + type: "n8n-nodes-base.httpRequest", + parameters: { method: "POST", url: "https://payments.invalid/refund" }, + }; + const workflow = workflowWith([refund], {}, { executionTimeout: 120, errorWorkflow: "global-errors" }); + assert.equal(findingFor(workflow, "VF010", refund.name), true); + assert.equal(findingFor(workflow, "VF013", refund.name), true); + + refund.parameters.method = "GET"; + assert.equal(findingFor(workflow, "VF010", refund.name), false); +}); + +test("complete money outcome contract passes VF010 through VF013", () => { + const { workflow, config } = moneyOutcomeFixture(); + const findings = inspectWorkflow(workflow, config); + for (const ruleId of ["VF010", "VF011", "VF012", "VF013"]) { + assert.equal(findings.some((finding) => finding.ruleId === ruleId), false, ruleId); + } +}); + +test("incomplete money contracts report missing structural policy evidence", () => { + const { workflow } = moneyOutcomeFixture(); + workflow.connections.Webhook = { main: [[connection("Record refund audit")]] }; + const config = normalizeConfig({ outcomeContracts: { "Issue refund": { impact: "money" } } }); + const findings = inspectWorkflow(workflow, config); + const critical = findings.find((finding) => finding.ruleId === "VF010" && finding.node?.name === "Issue refund"); + assert.match(critical.message, /auditNode/); + assert.match(critical.message, /idempotency/); + assert.match(critical.message, /approvalNode/); + assert.match(critical.message, /amountGuard/); + assert.match(critical.message, /counterpartyGuard/); + assert.equal(findingForWithConfig(workflow, config, "VF013", "Issue refund"), true); +}); + +test("customer communications and destructive writes require external outcome contracts", () => { + const customerMessage = { + name: "Notify customer", + type: "n8n-nodes-base.httpRequest", + parameters: { method: "POST", url: "https://messaging.invalid/customer-message" }, + }; + const destructiveWrite = { + name: "Execute maintenance query", + type: "n8n-nodes-base.postgres", + parameters: { operation: "executeQuery", query: "DELETE FROM records WHERE id = $1" }, + }; + const workflow = workflowWith([customerMessage, destructiveWrite], {}, { + executionTimeout: 120, + errorWorkflow: "global-errors", + }); + assert.equal(findingFor(workflow, "VF011", customerMessage.name), true); + assert.equal(findingFor(workflow, "VF011", destructiveWrite.name), true); +}); + +test("audit-like labels cannot hide an uncontracted money action", () => { + const paymentHistory = { + name: "Update payment history", + type: "n8n-nodes-base.postgres", + parameters: { operation: "update", table: "payment_history" }, + }; + const workflow = workflowWith([paymentHistory], {}, { executionTimeout: 120, errorWorkflow: "global-errors" }); + assert.equal(findingFor(workflow, "VF010", paymentHistory.name), true); +}); + +test("connected error paths must reach a recognized operator notification", () => { + const request = { + name: "Create external record", + type: "n8n-nodes-base.httpRequest", + onError: "continueErrorOutput", + parameters: { method: "POST" }, + }; + const stop = { name: "Failure stop", type: "n8n-nodes-base.noOp", parameters: {} }; + const silent = workflowWith([request, stop], { + [request.name]: { main: [[], [connection(stop.name)]] }, + }); + assert.equal(findingFor(silent, "VF012", request.name), true); + + const alert = { + name: "Operator error alert", + type: "n8n-nodes-base.slack", + onError: "continueErrorOutput", + parameters: { channel: "operations", text: "Failure alert" }, + }; + const notified = workflowWith([request, alert, stop], { + [request.name]: { main: [[], [connection(alert.name)]] }, + [alert.name]: { main: [[], [connection(stop.name)]] }, + }); + assert.equal(findingFor(notified, "VF012", request.name), false); +}); + +test("named but disconnected policy nodes cannot satisfy an outcome contract", () => { + const { workflow, config } = moneyOutcomeFixture(); + workflow.connections.Webhook = { main: [[connection("Issue refund")]] }; + assert.equal(findingForWithConfig(workflow, config, "VF010", "Issue refund"), true); +}); + +test("constant approvals and read-only audit labels do not satisfy outcome contracts", () => { + const constantApproval = moneyOutcomeFixture(); + constantApproval.workflow.nodes.find((node) => node.name === "Approve refund").parameters = { + conditions: { boolean: [{ value1: true, value2: true }] }, + note: "approved", + }; + assert.equal(findingForWithConfig(constantApproval.workflow, constantApproval.config, "VF010", "Issue refund"), true); + + const readOnlyAudit = moneyOutcomeFixture(); + readOnlyAudit.workflow.nodes.find((node) => node.name === "Record refund audit").parameters = { + operation: "executeQuery", + query: "SELECT * FROM refund_audit", + }; + assert.equal(findingForWithConfig(readOnlyAudit.workflow, readOnlyAudit.config, "VF010", "Issue refund"), true); + + const decorativeAmount = moneyOutcomeFixture(); + decorativeAmount.workflow.nodes.find((node) => node.name === "Limit refund amount").parameters = { + conditions: { boolean: [{ value1: true, value2: true }] }, + note: "={{ $json.amount }}", + maximum: 500, + currency: "USD", + }; + assert.equal(findingForWithConfig(decorativeAmount.workflow, decorativeAmount.config, "VF010", "Issue refund"), true); + + const failOpenAudit = moneyOutcomeFixture(); + const auditNode = failOpenAudit.workflow.nodes.find((node) => node.name === "Record refund audit"); + auditNode.onError = "continueErrorOutput"; + failOpenAudit.workflow.connections["Record refund audit"] = { + main: [[connection("Approve refund")], [connection("Issue refund")]], + }; + assert.equal(findingForWithConfig(failOpenAudit.workflow, failOpenAudit.config, "VF010", "Issue refund"), true); +}); + +test("outcome contracts resist prototype keys and self-referential evidence", () => { + const prototypeConfig = normalizeConfig(JSON.parse(`{ + "outcomeContracts": { + "__proto__": { "impact": "customer" } + } + }`)); + assert.equal(Object.hasOwn(prototypeConfig.outcomeContracts, "__proto__"), true); + assert.equal(Object.getPrototypeOf(prototypeConfig.outcomeContracts), Object.prototype); + + const { workflow } = moneyOutcomeFixture(); + const config = normalizeConfig({ + outcomeContracts: { + "Issue refund": { + impact: "money", + approvalNode: "Approve refund", + auditNode: "Record refund audit", + failureNotificationNode: "Issue refund", + amountGuard: { node: "Limit refund amount", maximum: 500, currency: "USD" }, + counterpartyGuard: { node: "Allow refund account", allowed: ["merchant-primary"] }, + recovery: { strategy: "compensate", node: "Issue refund" }, + }, + }, + }); + const findings = inspectWorkflow(workflow, config); + assert.equal(findings.some((finding) => finding.ruleId === "VF010" && /failureNotificationNode/.test(finding.message)), true); + assert.equal(findings.some((finding) => finding.ruleId === "VF013"), true); +}); + +test("outcome contract configuration rejects ambiguous shapes", () => { + assert.throws(() => normalizeConfig({ outcomeContracts: [] }), /must be an object/); + assert.throws(() => normalizeConfig({ outcomeContracts: { Refund: { impact: "physical" } } }), /must declare impact/); + assert.throws(() => normalizeConfig({ + outcomeContracts: { Refund: { impact: "money", amountGuard: { node: "Limit", maximum: -1, currency: "usd" } } }, + }), /positive finite number/); + assert.throws(() => normalizeConfig({ + outcomeContracts: { Refund: { impact: "money", recovery: { node: "Undo", strategy: "hope" } } }, + }), /must be compensate, rollback, or replay/); +}); + test("malformed connection metadata cannot fabricate graph edges", () => { const workflow = workflowWith([ { name: "AI Agent", type: "@n8n/n8n-nodes-langchain.agent", parameters: {} }, @@ -459,6 +642,86 @@ function findingFor(workflow, ruleId, nodeName) { return inspectWorkflow(workflow).some((finding) => finding.ruleId === ruleId && finding.node?.name === nodeName); } +function findingForWithConfig(workflow, config, ruleId, nodeName) { + return inspectWorkflow(workflow, config).some((finding) => finding.ruleId === ruleId && finding.node?.name === nodeName); +} + +function moneyOutcomeFixture() { + const nodes = [ + { + name: "Webhook", + type: "n8n-nodes-base.webhook", + parameters: { authentication: "headerAuth" }, + credentials: { httpHeaderAuth: { id: "credential-reference" } }, + }, + { + name: "Claim idempotency event", + type: "n8n-nodes-base.postgres", + parameters: { query: "INSERT INTO event_ledger VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id" }, + }, + { + name: "Record refund audit", + type: "n8n-nodes-base.postgres", + parameters: { operation: "insert", table: "refund_audit" }, + }, + { + name: "Approve refund", + type: "n8n-nodes-base.if", + parameters: { conditions: { boolean: [{ value1: "={{ $json.approved }}", value2: true }] } }, + }, + { + name: "Limit refund amount", + type: "n8n-nodes-base.if", + parameters: { conditions: { number: [{ value1: "={{ $json.amount }}", operation: "smallerEqual", value2: 500 }] }, currency: "USD" }, + }, + { + name: "Allow refund account", + type: "n8n-nodes-base.switch", + parameters: { value: "={{ $json.counterparty }}", rules: [{ value: "merchant-primary" }] }, + }, + { + name: "Issue refund", + type: "n8n-nodes-base.httpRequest", + onError: "continueErrorOutput", + parameters: { method: "POST", url: "https://payments.invalid/refund" }, + }, + { + name: "Notify refund failure", + type: "n8n-nodes-base.slack", + parameters: { channel: "operations", text: "Notify failure for refund" }, + }, + { + name: "Compensate transaction", + type: "n8n-nodes-base.httpRequest", + parameters: { method: "POST", url: "https://payments.invalid/reverse", operation: "compensate" }, + }, + ]; + const connections = { + Webhook: { main: [[connection("Claim idempotency event")]] }, + "Claim idempotency event": { main: [[connection("Record refund audit")]] }, + "Record refund audit": { main: [[connection("Approve refund")]] }, + "Approve refund": { main: [[connection("Limit refund amount")], []] }, + "Limit refund amount": { main: [[connection("Allow refund account")], []] }, + "Allow refund account": { main: [[connection("Issue refund")], []] }, + "Issue refund": { main: [[], [connection("Notify refund failure"), connection("Compensate transaction")]] }, + }; + const workflow = workflowWith(nodes, connections, { executionTimeout: 120, errorWorkflow: "global-errors" }); + const config = normalizeConfig({ + outcomeContracts: { + "Issue refund": { + impact: "money", + approvalNode: "Approve refund", + auditNode: "Record refund audit", + failureNotificationNode: "Notify refund failure", + amountGuard: { node: "Limit refund amount", maximum: 500, currency: "USD" }, + counterpartyGuard: { node: "Allow refund account", allowed: ["merchant-primary"] }, + recovery: { strategy: "compensate", node: "Compensate transaction" }, + }, + }, + }); + return { workflow, config }; +} + function runCli(args) { return spawnSync(process.execPath, [path.join(root, "bin/vibeflow.mjs"), ...args], { cwd: root,