mirror of
https://github.com/domfelipe/vibeflow-n8n.git
synced 2026-08-07 05:56:46 +00:00
Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3f7b784de | ||
|
|
4a4f103ddc | ||
|
|
cb4d664e98 | ||
|
|
7552eeca7c | ||
|
|
f62a78faaf | ||
|
|
b312f18251 | ||
|
|
e738a5e070 | ||
|
|
b73c95b8ac | ||
|
|
5d2db1de87 |
25 changed files with 1417 additions and 106 deletions
48
.forgejo/workflows/quality.yml
Normal file
48
.forgejo/workflows/quality.yml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Roda no Forgejo (homelab). GitHub Actions ignora .forgejo/
|
||||
# Source of truth continua no GitHub — só faça push no GH.
|
||||
name: quality
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect stack
|
||||
id: stack
|
||||
run: |
|
||||
set -e
|
||||
echo "repo=$(basename "$GITHUB_REPOSITORY")"
|
||||
if [ -f package.json ]; then echo "has_node=true" >> "$GITHUB_OUTPUT"; else echo "has_node=false" >> "$GITHUB_OUTPUT"; fi
|
||||
if [ -f pyproject.toml ] || [ -f requirements.txt ] || [ -f setup.py ]; then echo "has_py=true" >> "$GITHUB_OUTPUT"; else echo "has_py=false" >> "$GITHUB_OUTPUT"; fi
|
||||
if [ -f Cargo.toml ]; then echo "has_rust=true" >> "$GITHUB_OUTPUT"; else echo "has_rust=false" >> "$GITHUB_OUTPUT"; fi
|
||||
if [ -f go.mod ]; then echo "has_go=true" >> "$GITHUB_OUTPUT"; else echo "has_go=false" >> "$GITHUB_OUTPUT"; fi
|
||||
ls -la
|
||||
|
||||
- name: Node check (if any)
|
||||
if: steps.stack.outputs.has_node == 'true'
|
||||
run: |
|
||||
if command -v node >/dev/null 2>&1; then node -v; else echo "node n/a in image — skip"; fi
|
||||
if [ -f package-lock.json ] || [ -f pnpm-lock.yaml ] || [ -f yarn.lock ] || [ -f bun.lockb ] || [ -f bun.lock ]; then
|
||||
echo "lockfile present"
|
||||
fi
|
||||
# ponytail: no install/test yet — add when repo has standard scripts
|
||||
if [ -f package.json ] && command -v node >/dev/null 2>&1; then
|
||||
node -e "const p=require('./package.json'); console.log('name=', p.name||'(none)', 'scripts=', Object.keys(p.scripts||{}).join(','))"
|
||||
fi
|
||||
|
||||
- name: Python check (if any)
|
||||
if: steps.stack.outputs.has_py == 'true'
|
||||
run: |
|
||||
if command -v python3 >/dev/null 2>&1; then python3 --version; else echo "python n/a"; fi
|
||||
|
||||
- name: Quality gate (smoke)
|
||||
run: |
|
||||
echo "quality OK on Forgejo runner"
|
||||
echo "sha=${GITHUB_SHA}"
|
||||
echo "ref=${GITHUB_REF}"
|
||||
|
|
@ -9,6 +9,10 @@
|
|||
"VF006": "error",
|
||||
"VF007": "warning",
|
||||
"VF008": "warning",
|
||||
"VF009": "warning"
|
||||
"VF009": "warning",
|
||||
"VF010": "error",
|
||||
"VF011": "warning",
|
||||
"VF012": "warning",
|
||||
"VF013": "warning"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
# Changelog
|
||||
|
||||
## 0.9.0 - 2026-07-23
|
||||
|
||||
- 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.
|
||||
|
|
|
|||
50
README.md
50
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,20 @@ 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)
|
||||
- [Community launch pack](docs/community-launch-v0.9.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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,53 +1,176 @@
|
|||
# Codex for Open Source application gate
|
||||
# Codex for Open Source — submission pack
|
||||
|
||||
Application: <https://openai.com/pt-BR/form/codex-for-oss/>
|
||||
|
||||
## Current position
|
||||
Program overview: <https://developers.openai.com/community/codex-for-oss>
|
||||
|
||||
Vibeflow is public, MIT-licensed, owned by its principal maintainer, and aligned with Codex maintainer workflows. Version 0.8.0 establishes active engineering evidence but does not manufacture adoption.
|
||||
Program terms: <https://learn.chatgpt.com/docs/codex-for-oss-terms>
|
||||
|
||||
Do not submit until the live evidence section is refreshed and contains external usage.
|
||||
Prepared: 2026-07-22
|
||||
|
||||
## Practical submission gate
|
||||
Official form rechecked: 2026-07-22
|
||||
|
||||
These are internal quality targets, not official OpenAI thresholds:
|
||||
Submission reported complete: 2026-07-24
|
||||
|
||||
- a tagged public release with green CI;
|
||||
- at least three unrelated external users or teams with verifiable feedback;
|
||||
- at least one external issue, discussion, or pull request with maintainer activity;
|
||||
- current traffic, clone, installation, or dependent-project evidence;
|
||||
- no confidential information in the application.
|
||||
The maintainer reported that the application was submitted. The ChatGPT-account email, OpenAI Organization ID, confirmation page, and exact submission timestamp remain private and are not stored in this repository.
|
||||
|
||||
## Live evidence
|
||||
## Recommendation
|
||||
|
||||
Refresh immediately before submission:
|
||||
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.
|
||||
|
||||
- GitHub stars: `[refresh]`
|
||||
- forks: `[refresh]`
|
||||
- unique clones in the latest available period: `[refresh]`
|
||||
- releases and latest release date: `[refresh]`
|
||||
- external contributors: `[refresh]`
|
||||
- external users or public references: `[refresh]`
|
||||
- maintainer examples: `[refresh issues, reviews, and releases]`
|
||||
Submit now that v0.9.0 is public. 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.
|
||||
|
||||
## Form draft
|
||||
## Copy-and-paste form
|
||||
|
||||
### Role
|
||||
### First name
|
||||
|
||||
Principal maintainer.
|
||||
`Felipe`
|
||||
|
||||
### Last name
|
||||
|
||||
`Domingues`
|
||||
|
||||
### Email
|
||||
|
||||
`[FILL PRIVATELY: email associated with the ChatGPT account]`
|
||||
|
||||
### GitHub username
|
||||
|
||||
`domfelipe`
|
||||
|
||||
### GitHub repository URL
|
||||
|
||||
`https://github.com/domfelipe/vibeflow-n8n`
|
||||
|
||||
### Maintainer role
|
||||
|
||||
Select: **Primary maintainer**.
|
||||
|
||||
### Why is this repository eligible?
|
||||
|
||||
> Vibeflow is an MIT-licensed safety gate for AI-generated n8n workflows. It catches embedded secrets, unsafe webhooks, missing kill switches, human handoffs, idempotency, error paths, timeouts, and risky retries before deployment. It is maintained as a dependency-free CLI, GitHub Action, and Codex plugin. [Add refreshed external usage evidence before submitting.]
|
||||
Character count: **356/500**.
|
||||
|
||||
> 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
|
||||
|
||||
Select both:
|
||||
|
||||
- **Codex Security**
|
||||
- **API credits for my project**
|
||||
|
||||
### OpenAI Organization ID
|
||||
|
||||
`[FILL PRIVATELY: org-...]`
|
||||
|
||||
Use the organization that should receive the API credits. Confirm it in the OpenAI Platform before submitting; do not place it in this public document.
|
||||
|
||||
### How will API credits be used?
|
||||
|
||||
> Credits will support OSS maintenance: generate adversarial workflow fixtures, run reproducible policy evaluations, review contributed rules in pull requests, explain regressions, and prepare release reports. They will not fund a hosted commercial runtime or process private customer workflows.
|
||||
Character count: **390/500**.
|
||||
|
||||
### Anything else?
|
||||
> API credits would power OSS maintenance workflows: turn anonymized failures into adversarial fixtures, evaluate new policies, reproduce false positives, review contributed rules, triage issues, and generate release audits. Codex would assist these workflows; the Vibeflow scanner will remain local, dependency-free, telemetry-free, and will not upload or process private customer workflows.
|
||||
|
||||
> Vibeflow comes from production lessons operating conversational automations in Brazil. It is deliberately interoperable with n8n and existing MCP tooling: it does not replace builders, it checks their output. The project ships without telemetry and keeps workflow analysis local.
|
||||
### Anything else we should know?
|
||||
|
||||
## Final verification
|
||||
Character count: **381/500**.
|
||||
|
||||
Before submitting, confirm the GitHub profile and repository are public, replace every `[refresh]` marker, verify each form answer remains under 500 characters, and use accurate current evidence only.
|
||||
> 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
|
||||
|
||||
These translations are for review only. Paste the English versions above into the form.
|
||||
|
||||
### Repository eligibility
|
||||
|
||||
> 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
|
||||
|
||||
> Os créditos financiariam fluxos de manutenção OSS: transformar falhas anonimizadas em fixtures adversariais, avaliar novas políticas, reproduzir falsos positivos, revisar regras contribuídas, triar issues e gerar auditorias de release. O Codex auxiliaria esses fluxos; o scanner Vibeflow permanecerá local, sem dependências ou telemetria, e não enviará nem processará workflows privados de clientes.
|
||||
|
||||
### Additional context
|
||||
|
||||
> 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: <https://github.com/domfelipe/vibeflow-n8n>
|
||||
- Executable release: <https://github.com/domfelipe/vibeflow-n8n/releases/tag/v0.9.0>
|
||||
- Reproducible demo: <https://github.com/domfelipe/vibeflow-n8n/blob/main/docs/demo.md>
|
||||
- Release and Red Team audit: <https://github.com/domfelipe/vibeflow-n8n/blob/main/docs/release-audit.md>
|
||||
- CI history: <https://github.com/domfelipe/vibeflow-n8n/actions/workflows/ci.yml>
|
||||
- Launch discussion: <https://github.com/domfelipe/vibeflow-n8n/discussions/3>
|
||||
- Public r/n8n feedback that shaped v0.9: <https://www.reddit.com/r/n8n/comments/1v3is1w/i_built_an_opensource_safety_gate_for_aigenerated/>
|
||||
- Public runtime feedback and anonymized-corpus offer: <https://www.reddit.com/r/n8n/comments/1v3is1w/comment/ozbyh0f/>
|
||||
- Recorded static/runtime evidence boundary: <https://github.com/domfelipe/vibeflow-n8n/blob/main/docs/community-launch-v0.9.md#post-release-runtime-feedback--2026-07-24>
|
||||
- Engineering PRs: <https://github.com/domfelipe/vibeflow-n8n/pulls?q=is%3Apr+is%3Amerged>
|
||||
|
||||
## Evidence snapshot
|
||||
|
||||
Captured on 2026-07-23 after release:
|
||||
|
||||
- public repository with MIT license;
|
||||
- public `v0.9.0` release, with v0.8.0 preserved in release history;
|
||||
- 36 adversarial tests passing locally and in remote Node.js 20, 22, and 24 CI;
|
||||
- dependency-free CLI, GitHub Action, and installable Codex plugin;
|
||||
- 7 maintainer PRs merged with green CI, including the v0.9 implementation and release-evidence PR;
|
||||
- 4 stars, 0 forks, and no verified external contributor yet;
|
||||
- public r/n8n launch thread with several substantive comments that directly shaped VF010-VF013;
|
||||
- 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, 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.
|
||||
|
||||
## Post-submission evidence snapshot
|
||||
|
||||
Captured on 2026-07-24:
|
||||
|
||||
- an external Reddit commenter publicly validated the static-preflight/runtime-observability split;
|
||||
- the commenter reported fail-open Code execution, error-as-data, and AI-output truncation cases from a community-workflow corpus;
|
||||
- the commenter offered to run anonymized failing workflows through Vibeflow and share false positives;
|
||||
- the linked Pisama node and runtime project were independently verified, including their separate MIT and fair-code licensing boundaries;
|
||||
- no workflow fixture, false-positive result, PR, contributor relationship, partnership, or integration had been received or established at capture time.
|
||||
|
||||
This is evidence of substantive community engagement and a prospective evaluation path, not evidence of adoption or contribution.
|
||||
|
||||
## Confidentiality boundary
|
||||
|
||||
Do not submit or link the full real-workflow audit. Application materials must not include:
|
||||
|
||||
- client, clinic, or workflow names;
|
||||
- remote workflow IDs, version IDs, hashes, or timestamps;
|
||||
- node names that reveal business logic;
|
||||
- private workflow JSON, credentials, customer data, or infrastructure identifiers.
|
||||
|
||||
The approved public description is: **“a real, production-scale 92-node conversational workflow audited in read-only mode.”**
|
||||
|
||||
## Submission checklist
|
||||
|
||||
- [x] Repository is public and not archived.
|
||||
- [x] GitHub username is public.
|
||||
- [x] Role is primary maintainer.
|
||||
- [x] Repository URL is correct.
|
||||
- [x] Both requested benefits are selected.
|
||||
- [x] All narrative answers are under 500 characters.
|
||||
- [x] Real-workflow evidence is anonymized.
|
||||
- [x] Early adoption is described honestly.
|
||||
- [x] Supply the exact ChatGPT-account email privately in the form; do not retain it in the repository.
|
||||
- [x] Supply the OpenAI Organization ID privately in the form; do not retain it in the repository.
|
||||
- [ ] Re-read the current Program Terms immediately before submission.
|
||||
- [ ] Save the confirmation page and submission timestamp privately.
|
||||
- [x] Application submission reported complete by the maintainer on 2026-07-24.
|
||||
|
||||
## After submission
|
||||
|
||||
Continue collecting organic evidence without delaying the application:
|
||||
|
||||
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;
|
||||
5. never request artificial stars, forks, or empty PRs.
|
||||
|
||||
OpenAI reviews applications continuously. If additional evidence is requested, provide a fresh public snapshot and the anonymized outcome of subsequent real-world audits.
|
||||
|
|
|
|||
114
docs/community-launch-v0.9.md
Normal file
114
docs/community-launch-v0.9.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Community launch pack — v0.9.0
|
||||
|
||||
Publish only after the public `v0.9.0` release URL works.
|
||||
|
||||
## Feedback provenance
|
||||
|
||||
The v0.9 scope came from the public [original r/n8n launch thread](https://www.reddit.com/r/n8n/comments/1v3is1w/i_built_an_opensource_safety_gate_for_aigenerated/), not a speculative roadmap.
|
||||
|
||||
| Community feedback | v0.9 response |
|
||||
|---|---|
|
||||
| Separate dangerous nodes from dangerous outcomes | VF010 and VF011 classify money, customer, privileged, and destructive-data actions. |
|
||||
| Require idempotency, approval, amount/counterparty limits, and durable audit | `outcomeContracts` verifies graph evidence for those controls. |
|
||||
| Error paths can exist but notify nobody | VF012 requires an operator-visible failure path. |
|
||||
| Writes need rollback or replay stories | VF013 requires compensation, rollback, or replay evidence. |
|
||||
| Static checks cannot replace runtime guardrails | The CLI and documentation explicitly preserve that boundary. |
|
||||
|
||||
Captured on 2026-07-22: the thread had 3 votes and several substantive comments. Treat the comments as product evidence; do not present the vote count as broad adoption.
|
||||
|
||||
## Post-release runtime feedback — 2026-07-24
|
||||
|
||||
Reddit user [`Fit_Preference_1795`](https://www.reddit.com/r/n8n/comments/1v3is1w/comment/ozbyh0f/) publicly described the runtime counterpart to Vibeflow's static checks and offered to test anonymized failing workflows against Vibeflow. The report identified three failure classes:
|
||||
|
||||
- a `continueOnFail` Code node crash that leaves the execution marked successful;
|
||||
- an error object flowing through an item's JSON as ordinary data;
|
||||
- truncated AI output reaching downstream nodes despite valid JSON and a successful node status.
|
||||
|
||||
The commenter also noted that intentional token caps must be distinguished from accidental truncation to avoid false positives. These cases reinforce the product boundary: Vibeflow can detect deterministic fail-open configuration or require a structural completeness guard, but it cannot observe runtime output or prove that an alert is watched.
|
||||
|
||||
The linked [`n8n-nodes-pisama`](https://github.com/Pisama-AI/n8n-nodes-pisama) community node is MIT-licensed and forwards execution telemetry. Runtime detection is implemented by the separate, self-hostable [`pisama-n8n`](https://github.com/Pisama-AI/pisama-n8n) service under a fair-code license. Its public [July 2026 corpus campaign](https://github.com/Pisama-AI/pisama-n8n/blob/main/eval/campaigns/2026-07-guard-campaign.md) records 70 committed workflows, 67 real executions, and 19 observed-and-detected failures. The comment's 69-workflow report and the repository campaign are different snapshots and must not be combined into one metric.
|
||||
|
||||
Evidence status at capture time:
|
||||
|
||||
- verified: substantive public technical feedback and a public offer to provide anonymized false-positive evidence;
|
||||
- not yet verified: receipt of the workflows, Vibeflow verdicts, false-positive count, or a shared interoperability contract;
|
||||
- not claimed: partnership, external contribution, external user adoption, or a shipped Pisama integration.
|
||||
|
||||
The smallest useful follow-up is three anonymized cases: fail-open Code execution, error-as-data, and intentional versus accidental truncation. A new Vibeflow rule should be added only when paired unsafe/safe fixtures expose a deterministic static signal.
|
||||
|
||||
## Reddit — r/n8n
|
||||
|
||||
### Title
|
||||
|
||||
I built your feedback into Vibeflow v0.9: outcome-aware preflight checks for n8n workflows
|
||||
|
||||
### Post
|
||||
|
||||
I shared Vibeflow here earlier and two pieces of feedback changed the direction of the project:
|
||||
|
||||
1. A dangerous outcome is not the same thing as a dangerous node. A normal HTTP node can still issue a refund, send a payment, notify a customer, change access, or delete data.
|
||||
2. A connected error branch is not enough if it silently terminates, and a write is not production-ready without a rollback, compensation, or replay story.
|
||||
|
||||
That feedback is now implemented in Vibeflow v0.9.
|
||||
|
||||
Vibeflow is a local, dependency-free preflight checker for exported n8n workflow JSON. The new release adds:
|
||||
|
||||
- `VF010`: blocks money and privileged actions without an outcome contract;
|
||||
- `VF011`: warns about uncontracted customer communications and destructive writes;
|
||||
- `VF012`: detects error paths that notify nobody;
|
||||
- `VF013`: requires compensation, rollback, or replay evidence.
|
||||
|
||||
For a contracted action, the checker verifies structural evidence in the workflow graph:
|
||||
|
||||
- an atomic idempotency claim cannot be bypassed;
|
||||
- a durable audit write happens before the action;
|
||||
- approval, amount, and counterparty checks have real allow/deny branches;
|
||||
- the failure notification is connected to the action's error output;
|
||||
- recovery is represented in the graph.
|
||||
|
||||
The important boundary: this is static preflight, not runtime enforcement. The payment/customer system must still enforce authorization, limits, counterparties, durable audit, and recovery at runtime.
|
||||
|
||||
There is a reproducible unsafe refund workflow and a passing contracted version in the repository:
|
||||
|
||||
https://github.com/domfelipe/vibeflow-n8n
|
||||
|
||||
Release: https://github.com/domfelipe/vibeflow-n8n/releases/tag/v0.9.0
|
||||
|
||||
I am especially looking for anonymized examples of:
|
||||
|
||||
- a real-world action the classifier misses;
|
||||
- a false positive where the workflow is demonstrably safe;
|
||||
- a control that looks present in JSON but can still be bypassed;
|
||||
- a recovery pattern that does not fit compensate/rollback/replay.
|
||||
|
||||
Please remove credentials and customer data before sharing workflow fragments. The best reports will become paired unsafe/safe regression fixtures.
|
||||
|
||||
## Short reply to the original commenters
|
||||
|
||||
Your distinction between dangerous nodes and dangerous outcomes became the core of v0.9. The release now detects ordinary HTTP/database/message nodes by impact and validates idempotency, approval, amount/counterparty limits, durable audit, operator-visible failures, and recovery evidence. I kept runtime enforcement explicitly outside the claim. Thank you — this materially improved the project.
|
||||
|
||||
## GitHub Discussion
|
||||
|
||||
### Title
|
||||
|
||||
Vibeflow v0.9: help test outcome contracts against real n8n workflows
|
||||
|
||||
### Body
|
||||
|
||||
Vibeflow v0.9 adds outcome-aware preflight policies for money, customer, privileged, and destructive-data actions. The implementation was driven by community feedback that ordinary nodes can still produce dangerous real-world outcomes.
|
||||
|
||||
Please test an exported workflow and report anonymized false positives, missed actions, bypassable controls, or recovery patterns. Useful reports need a minimal unsafe case, the expected safe case, and the exported JSON fields that distinguish them.
|
||||
|
||||
- Release: https://github.com/domfelipe/vibeflow-n8n/releases/tag/v0.9.0
|
||||
- Demo: https://github.com/domfelipe/vibeflow-n8n/blob/v0.9.0/docs/demo.md
|
||||
- False-positive report: https://github.com/domfelipe/vibeflow-n8n/issues/new?template=false-positive.yml
|
||||
- Policy proposal: https://github.com/domfelipe/vibeflow-n8n/issues/new?template=rule-proposal.yml
|
||||
|
||||
Never attach credentials, customer data, or a private production workflow.
|
||||
|
||||
## Evidence rules
|
||||
|
||||
- Do not ask for artificial stars, forks, or empty PRs.
|
||||
- Record only public, attributable usage or anonymized audit outcomes.
|
||||
- Convert actionable feedback into an issue and paired unsafe/safe fixture.
|
||||
- Never publish customer workflow names, IDs, node names, credentials, or infrastructure identifiers.
|
||||
17
docs/demo.md
17
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
|
||||
|
|
|
|||
|
|
@ -1,16 +1,32 @@
|
|||
# Launch checklist
|
||||
# v0.9.0 launch checklist
|
||||
|
||||
Released: 2026-07-23
|
||||
|
||||
## Release gate
|
||||
|
||||
- `npm run verify` passes on Node.js 20, 22, and 24.
|
||||
- Official skill and plugin validators pass.
|
||||
- Safe fixture exits 0; unsafe fixture exits 1.
|
||||
- SARIF is valid JSON and uploaded by CI.
|
||||
- Repository description and topics match the new product.
|
||||
- `v0.8.0` release notes match `CHANGELOG.md`.
|
||||
- [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.
|
||||
- [x] Pull request CI passes on Node.js 20, 22, and 24.
|
||||
- [x] Release commit is merged and tagged `v0.9.0`.
|
||||
- [x] Released CLI and pinned Codex marketplace install successfully.
|
||||
- [x] GitHub release is public and marked latest.
|
||||
|
||||
Release: <https://github.com/domfelipe/vibeflow-n8n/releases/tag/v0.9.0>
|
||||
|
||||
## Positioning
|
||||
|
||||
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.
|
||||
|
||||
Ready-to-post Reddit and GitHub Discussion copy: [community-launch-v0.9.md](community-launch-v0.9.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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,54 +1,73 @@
|
|||
# Release audit — v0.8.0
|
||||
# Release audit — v0.9.0
|
||||
|
||||
Date: 2026-07-22
|
||||
Released: 2026-07-23
|
||||
|
||||
## Decision
|
||||
|
||||
**APTO COM RESSALVAS** for the first public executable release.
|
||||
**APTO** within Vibeflow's documented static-preflight boundary.
|
||||
|
||||
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+.
|
||||
- The public `v0.9.0` tag resolves to release commit `7552eeca7cfdd56376eab2007988b81a9726fba4`.
|
||||
- The released GitHub CLI package passes the safe refund fixture with zero findings and reports VF010, VF012, and VF013 for the unsafe refund fixture.
|
||||
- The released Codex marketplace installs `vibeflow@vibeflow` version `0.9.0` in an isolated `CODEX_HOME`.
|
||||
|
||||
## 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 passed.
|
||||
|
||||
## Release blockers
|
||||
|
||||
- [x] Pull request CI passes on Node.js 20, 22, and 24.
|
||||
- [x] Release commit is merged without unrelated changes.
|
||||
- [x] `v0.9.0` tag and GitHub release are public.
|
||||
- [x] Released CLI and Codex plugin install paths are smoke-tested.
|
||||
|
||||
## Final gate
|
||||
|
||||
There are zero known critical or high security findings in the released static-analysis boundary. Runtime enforcement remains explicitly outside the product claim.
|
||||
|
|
|
|||
24
docs/release-notes-v0.9.md
Normal file
24
docs/release-notes-v0.9.md
Normal file
|
|
@ -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: <https://github.com/domfelipe/vibeflow-n8n/blob/v0.9.0/CHANGELOG.md>
|
||||
|
|
@ -4,17 +4,23 @@
|
|||
|
||||
Ship the executable reset: CLI, nine configurable policies, fixtures, tests, SARIF, GitHub Action, and Codex plugin.
|
||||
|
||||
## 0.9.0 — shipped 2026-07-23
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
14
examples/outcome-contracts.vibeflow.json
Normal file
14
examples/outcome-contracts.vibeflow.json
Normal file
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
85
examples/safe-refund.workflow.json
Normal file
85
examples/safe-refund.workflow.json
Normal file
|
|
@ -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" }
|
||||
}
|
||||
|
|
@ -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 }]
|
||||
|
|
|
|||
29
examples/unsafe-refund.workflow.json
Normal file
29
examples/unsafe-refund.workflow.json
Normal file
|
|
@ -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": {}
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
417
src/vibeflow.mjs
417
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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue