diff --git a/.gitignore b/.gitignore index e76ef50b092..cbae8a2e69f 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,5 @@ apps/*/ .claude/settings.local.json .claude/agent-memory/* +# sqlite +dev-sqlite/database.sqlite \ No newline at end of file diff --git a/audit.jsonl b/audit.jsonl new file mode 100644 index 00000000000..c42c134f8b5 --- /dev/null +++ b/audit.jsonl @@ -0,0 +1,37 @@ +{"ts":"2026-05-25T02:41:09.951Z","step":"session_end","output":"[REJECTED BY HUMAN]","toolCallCount":1,"sessionId":"2b420969-191c-4170-b656-5fcd1df838c8","chatId":"2b420969-191c-4170-b656-5fcd1df838c8","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:41:09.927Z","step":"hitl","tool":"send_email","args":{"to":"alice@example.com","subject":"Quarterly Report Ready","body":"Hello Alice,\n\nThe quarterly report is now ready. Please let me know if you need anything further.\n\nBest regards,\n[Your Name]"},"humanDecision":"reject","ruleId":"escalate-external-email","feedback":"","sessionId":"2b420969-191c-4170-b656-5fcd1df838c8","chatId":"2b420969-191c-4170-b656-5fcd1df838c8","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:41:09.926Z","step":"session_start","input":"Reject","sessionId":"2b420969-191c-4170-b656-5fcd1df838c8","chatId":"2b420969-191c-4170-b656-5fcd1df838c8","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:41:07.834Z","step":"session_end","output":"\n\n**Policy escalation** (rule: `escalate-external-email`): Sending email to an external address requires human approval.\n\nAttempting to use tool:\n```json\n{\n \"name\": \"send_email\",\n \"args\": {\n \"to\": \"alice@example.com\",\n \"subject\": \"Quarterly Report Ready\",\n \"body\": \"Hello Alice,\\n\\nThe quarterly report is now ready. Please let me know if you need anything further.\\n\\nBest regards,\\n[Your Name]\"\n },\n \"id\": \"chatcmpl-tool-b77dfe144c780d6a\",\n \"type\": \"tool_call\"\n}\n```","toolCallCount":0,"sessionId":"2b420969-191c-4170-b656-5fcd1df838c8","chatId":"2b420969-191c-4170-b656-5fcd1df838c8","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:41:07.805Z","traceId":"cf5effac81909b5f","step":"policy_decision","tool":"send_email","args":{"to":"alice@example.com","subject":"Quarterly Report Ready","body":"Hello Alice,\n\nThe quarterly report is now ready. Please let me know if you need anything further.\n\nBest regards,\n[Your Name]"},"ruleId":"escalate-external-email","effect":"escalate","message":"Sending email to an external address requires human approval.","sessionId":"2b420969-191c-4170-b656-5fcd1df838c8","chatId":"2b420969-191c-4170-b656-5fcd1df838c8","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:41:07.803Z","traceId":"cf5effac81909b5f","step":"propose","tool":"send_email","args":{"to":"alice@example.com","subject":"Quarterly Report Ready","body":"Hello Alice,\n\nThe quarterly report is now ready. Please let me know if you need anything further.\n\nBest regards,\n[Your Name]"},"sessionId":"2b420969-191c-4170-b656-5fcd1df838c8","chatId":"2b420969-191c-4170-b656-5fcd1df838c8","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:41:06.888Z","step":"session_start","input":"Send an email to alice@example.com saying the quarterly report is ready","sessionId":"2b420969-191c-4170-b656-5fcd1df838c8","chatId":"2b420969-191c-4170-b656-5fcd1df838c8","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:39:49.073Z","step":"session_end","output":"Email sent to hello@internal.tech: subject=\"Quarterly Report Ready\", body=\"The quarterly report is ready.\" (simulated).","toolCallCount":1,"sessionId":"2ce71447-485c-4b59-8f32-a0eee640899c","chatId":"2ce71447-485c-4b59-8f32-a0eee640899c","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:39:49.045Z","traceId":"29cfe16641549d05","step":"observe","tool":"send_email","observation":"Email sent to hello@internal.tech: subject=\"Quarterly Report Ready\", body=\"The quarterly report is ready.\" (simulated).","sessionId":"2ce71447-485c-4b59-8f32-a0eee640899c","chatId":"2ce71447-485c-4b59-8f32-a0eee640899c","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:39:49.038Z","traceId":"29cfe16641549d05","step":"execute","tool":"send_email","args":{"input":"To: hello@internal.tech\nSubject: Quarterly Report Ready\n\nThe quarterly report is ready."},"observation":"Email sent to hello@internal.tech: subject=\"Quarterly Report Ready\", body=\"The quarterly report is ready.\" (simulated).","sessionId":"2ce71447-485c-4b59-8f32-a0eee640899c","chatId":"2ce71447-485c-4b59-8f32-a0eee640899c","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:39:49.021Z","traceId":"29cfe16641549d05","step":"policy_decision","tool":"send_email","args":{"input":"To: hello@internal.tech\nSubject: Quarterly Report Ready\n\nThe quarterly report is ready."},"ruleId":"allow-internal-email","effect":"allow","message":"Internal @internal.tech addresses are always permitted.","sessionId":"2ce71447-485c-4b59-8f32-a0eee640899c","chatId":"2ce71447-485c-4b59-8f32-a0eee640899c","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:39:49.019Z","traceId":"29cfe16641549d05","step":"propose","tool":"send_email","args":{"input":"To: hello@internal.tech\nSubject: Quarterly Report Ready\n\nThe quarterly report is ready."},"sessionId":"2ce71447-485c-4b59-8f32-a0eee640899c","chatId":"2ce71447-485c-4b59-8f32-a0eee640899c","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:39:45.982Z","step":"hitl","tool":"send_email","args":{"input":"{\"to\":\"alice@example.com\",\"subject\":\"Quarterly Report Ready\",\"body\":\"The quarterly report is ready.\"}"},"humanDecision":"proceed","ruleId":"escalate-external-email","feedback":"modify the to email to hello@internal.tech","sessionId":"2ce71447-485c-4b59-8f32-a0eee640899c","chatId":"2ce71447-485c-4b59-8f32-a0eee640899c","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:39:45.981Z","step":"session_start","input":"Proceed","sessionId":"2ce71447-485c-4b59-8f32-a0eee640899c","chatId":"2ce71447-485c-4b59-8f32-a0eee640899c","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:39:09.229Z","step":"session_end","output":"\n\n**Policy escalation** (rule: `escalate-external-email`): Sending email to an external address requires human approval.\n\nAttempting to use tool:\n```json\n{\n \"name\": \"send_email\",\n \"args\": {\n \"input\": \"{\\\"to\\\":\\\"alice@example.com\\\",\\\"subject\\\":\\\"Quarterly Report Ready\\\",\\\"body\\\":\\\"The quarterly report is ready.\\\"}\"\n },\n \"id\": \"chatcmpl-tool-8ca97646aff7e671\",\n \"type\": \"tool_call\"\n}\n```","toolCallCount":0,"sessionId":"2ce71447-485c-4b59-8f32-a0eee640899c","chatId":"2ce71447-485c-4b59-8f32-a0eee640899c","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:39:08.944Z","traceId":"fea46c92249860d1","step":"policy_decision","tool":"send_email","args":{"input":"{\"to\":\"alice@example.com\",\"subject\":\"Quarterly Report Ready\",\"body\":\"The quarterly report is ready.\"}"},"ruleId":"escalate-external-email","effect":"escalate","message":"Sending email to an external address requires human approval.","sessionId":"2ce71447-485c-4b59-8f32-a0eee640899c","chatId":"2ce71447-485c-4b59-8f32-a0eee640899c","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:39:08.943Z","traceId":"fea46c92249860d1","step":"propose","tool":"send_email","args":{"input":"{\"to\":\"alice@example.com\",\"subject\":\"Quarterly Report Ready\",\"body\":\"The quarterly report is ready.\"}"},"sessionId":"2ce71447-485c-4b59-8f32-a0eee640899c","chatId":"2ce71447-485c-4b59-8f32-a0eee640899c","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:39:07.650Z","step":"session_start","input":"Send an email to alice@example.com saying the quarterly report is ready","sessionId":"2ce71447-485c-4b59-8f32-a0eee640899c","chatId":"2ce71447-485c-4b59-8f32-a0eee640899c","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:38:19.255Z","step":"session_end","output":"Email sent to alice@example.com: subject=\"Quarterly Report Ready\", body=\"The quarterly report is ready. Please review it at your earliest convenience.\" (simulated).","toolCallCount":1,"sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:38:19.228Z","step":"observe","tool":"send_email","observation":"Email sent to alice@example.com: subject=\"Quarterly Report Ready\", body=\"The quarterly report is ready. Please review it at your earliest convenience.\" (simulated).","sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:38:19.228Z","step":"execute","tool":"send_email","args":{"input":"To: alice@example.com\nSubject: Quarterly Report Ready\n\nThe quarterly report is ready. Please review it at your earliest convenience."},"observation":"Email sent to alice@example.com: subject=\"Quarterly Report Ready\", body=\"The quarterly report is ready. Please review it at your earliest convenience.\" (simulated).","sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:38:19.224Z","step":"hitl","tool":"send_email","args":{"input":"To: alice@example.com\nSubject: Quarterly Report Ready\n\nThe quarterly report is ready. Please review it at your earliest convenience."},"humanDecision":"proceed","ruleId":"escalate-external-email","feedback":"","sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:38:19.224Z","step":"session_start","input":"Proceed","sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:37:32.124Z","step":"session_end","output":"\n\n**Policy escalation** (rule: `escalate-external-email`): Sending email to an external address requires human approval.\n\nAttempting to use tool:\n```json\n{\n \"name\": \"send_email\",\n \"args\": {\n \"input\": \"To: alice@example.com\\nSubject: Quarterly Report Ready\\n\\nThe quarterly report is ready. Please review it at your earliest convenience.\"\n },\n \"id\": \"chatcmpl-tool-831d5eb011240250\",\n \"type\": \"tool_call\"\n}\n```","toolCallCount":0,"sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:37:32.076Z","traceId":"c361ffddabe354bf","step":"policy_decision","tool":"send_email","args":{"input":"To: alice@example.com\nSubject: Quarterly Report Ready\n\nThe quarterly report is ready. Please review it at your earliest convenience."},"ruleId":"escalate-external-email","effect":"escalate","message":"Sending email to an external address requires human approval.","sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:37:32.075Z","traceId":"c361ffddabe354bf","step":"propose","tool":"send_email","args":{"input":"To: alice@example.com\nSubject: Quarterly Report Ready\n\nThe quarterly report is ready. Please review it at your earliest convenience."},"sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:37:31.265Z","step":"session_start","input":"Send an email to alice@example.com saying the quarterly report is ready","sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:35:40.652Z","step":"session_end","output":"[POLICY_DENIED] Destructive DB mutations are forbidden by policy.","toolCallCount":1,"sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:35:40.617Z","traceId":"36a0e5a754b04952","step":"policy_decision","tool":"delete_database","args":{"input":"users"},"ruleId":"deny-destructive","effect":"deny","message":"Destructive DB mutations are forbidden by policy.","sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:35:40.616Z","traceId":"36a0e5a754b04952","step":"propose","tool":"delete_database","args":{"input":"users"},"sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:35:39.860Z","step":"session_start","input":"delete all users from db","sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:34:37.564Z","step":"session_end","output":"Weather in Chennai: sunny, 72°F","toolCallCount":1,"sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:34:37.522Z","traceId":"5e1922ad393c4739","step":"observe","tool":"get_weather","observation":"Weather in Chennai: sunny, 72°F","sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:34:37.521Z","traceId":"5e1922ad393c4739","step":"execute","tool":"get_weather","args":{"input":"Chennai"},"observation":"Weather in Chennai: sunny, 72°F","sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:34:37.511Z","traceId":"5e1922ad393c4739","step":"policy_decision","tool":"get_weather","args":{"input":"Chennai"},"ruleId":"allow-safe-read","effect":"allow","message":"Read-only weather lookup is always permitted.","sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:34:37.511Z","traceId":"5e1922ad393c4739","step":"propose","tool":"get_weather","args":{"input":"Chennai"},"sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} +{"ts":"2026-05-25T02:34:36.971Z","step":"session_start","input":"whats the chennai weather","sessionId":"c2324908-bdd3-4692-8908-4fe89cd35841","chatId":"c2324908-bdd3-4692-8908-4fe89cd35841","nodeId":"agentAgentflow_0"} diff --git a/hackathon/README.md b/hackathon/README.md new file mode 100644 index 00000000000..e8734487ce2 --- /dev/null +++ b/hackathon/README.md @@ -0,0 +1,153 @@ +# Governance-First Agent (Agent Flow v2) + +Hackathon prototype: policy checks **inside** the Agent node ReAct loop (`agentAgentflow`), before any tool executes. + +## Quick demo (no UI needed) + +```bash +# from repo root — requires pnpm build to have been run +node hackathon/demo.mjs +``` + +Runs 5 scenarios end-to-end and prints a formatted audit log summary to stdout. +Writes entries to `./audit-demo.jsonl`. + +## Prerequisites (full UI demo) + +- Flowise running from repo root (`pnpm install`, `pnpm build`, `pnpm dev`) +- Use **Agent Flow v2** canvas only (`/v2/agentcanvas`) — v1 is deprecated +- Chat model with tool calling (e.g. OpenAI `gpt-4o-mini`) + +## Policy file + +[`agent-policies.json`](./agent-policies.json) — loaded at runtime, hot-reloaded on file change (no restart needed). + +Rules are evaluated **first-match-wins**. More specific rules must appear before broader ones. + +| Rule | Tool | Effect | +| --------------------------- | ------------------------------------------------ | ------------------------------------------------------- | +| `allow-safe-read` | `get_weather` | allow — runs immediately, no pause | +| `deny-destructive` | `delete_database` | deny — blocked outright, agent re-reasons | +| `allow-internal-email` | `send_email` (recipient ends with `@aivar.tech`) | allow — internal addresses bypass escalation | +| `escalate-external-email` | `send_email` (any other address) | escalate → UI Proceed / Reject | +| `escalate-transfer-review` | `transfer_funds` | escalate → UI Proceed / Reject (reviewer can edit args) | +| `escalate-post-to-external` | `post_message` (channel contains `#external`) | escalate → UI Proceed / Reject | +| `deny-post-to-public` | `post_message` (channel contains `#public`) | deny — blocked outright | +| `escalate-all-writes` | `write_*` (prefix wildcard) | escalate — all write-class tools require review | +| `prod-escalate-all` | `*` when `context.environment == "production"` | escalate — every tool requires approval in prod | + +## Setup demo flow (UI) + +1. Create Agent Flow v2: **Add New** (ensure V2 toggle) → opens `/v2/agentcanvas`. +2. Canvas: **Start** → **Agent** (optionally connect to End). +3. On **Agent** node: + - **Enable Governance**: `true` + - **Policy File Path**: `./hackathon/agent-policies.json` + - **Audit Log Path**: `./audit.jsonl` + - **Governance Context** (optional): `{"user":"demo","environment":"dev"}` + - Add model + system message instructing tool use. +4. Add **Custom Function** tools under Agent → **Tools** (names must match policy): + +### `get_weather` + +Schema: one string input named `input` (location). + +```javascript +const location = $input || 'NYC' +return `Weather in ${location}: sunny, 72°F` +``` + +### `delete_database` + +No inputs required. + +```javascript +return 'All customer records deleted (simulated).' +``` + +### `send_email` + +Schema: string inputs `to` (email address) and `body` (email body). + +```javascript +const recipient = typeof $to !== 'undefined' ? $to : 'unknown' +const message = typeof $body !== 'undefined' ? $body : '' +return `Email sent to ${recipient}: "${message}" (simulated).` +``` + +### `transfer_funds` + +Schema: number `amount`, string `account`. + +```javascript +const amt = typeof $amount !== 'undefined' ? $amount : 0 +const acc = typeof $account !== 'undefined' ? $account : 'unknown' +return `Transferred $${amt} to account ${acc} (simulated).` +``` + +## Demo scenarios + +| # | User message | Expected | +| --- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| 1 | What's the weather in Tokyo? | **allow** → `get_weather` runs, no pause | +| 2 | Delete all customer records | Policy **deny** → synthetic tool observation → agent re-reasons and explains | +| 3 | Send an email to alice@example.com saying the report is ready | Policy **escalate** → chat shows **Proceed** / **Reject** → on Proceed, email sends | +| 4 | Transfer $50,000 to account 9988776655 | Policy **escalate** → reviewer can lower amount before approving | +| 5 | Send an email to team@aivar.tech with the status update | **allow** (internal address) → runs immediately | + +After each run, inspect `./audit.jsonl` at repo root (or path configured on the node). + +## Audit log + +Each tool invocation produces a chain of entries: + +``` +propose → policy_decision → [hitl] → execute → observe +``` + +| Step | When written | +| ----------------- | ----------------------------------------------------------- | +| `propose` | Agent decides to call a tool (before policy check) | +| `policy_decision` | Policy evaluated — effect is `allow`, `deny`, or `escalate` | +| `hitl` | Human responds (Proceed or Reject) to an escalation | +| `execute` | Tool actually runs — includes tool output | +| `observe` | Tool result fed back into the agent loop | + +A `traceId` field correlates all steps for a single tool invocation across the log. + +The `hitl` entry captures: + +- `humanDecision`: `"proceed"` or `"reject"` +- `feedback`: optional reviewer note +- `originalArgs` / `modifiedArgs`: present when the reviewer edited the tool arguments before approving + +## Code hook (for judges) + +[`packages/components/nodes/agentflow/Agent/Agent.ts`](../packages/components/nodes/agentflow/Agent/Agent.ts) — `handleToolCalls`, immediately before `selectedTool.call()`: + +1. `auditPropose()` — logs the agent's intent (tool name + args) +2. `gateToolCall()` — loads policy from JSON file (hot-reloaded on mtime change), evaluates, writes `policy_decision` audit entry +3. **deny** → synthetic `role: tool` message with `[POLICY_DENIED]` prefix → LLM re-reasons without executing the tool +4. **escalate** → `isWaitingForHumanInput: true` → UI shows Proceed/Reject buttons +5. **allow** → `selectedTool.call()` → `auditExecute()` → `auditObserve()` — only execution path + +On resume (`handleResumedToolCalls`): + +- **reject** → `auditHitl(..., 'reject', { ruleId, feedback })` → tool removed, agent re-reasons +- **proceed** → `auditHitl(..., 'proceed', { ruleId, originalArgs?, modifiedArgs?, feedback })` → re-checks for hard deny → executes → `auditExecute()` → `auditObserve()` + +Shared module: [`packages/components/src/governance/`](../packages/components/src/governance/). + +### Why the hook is at this exact line + +The governance gate sits between `response.tool_calls` (the LLM's decision) and `selectedTool.call()` (actual execution). Placing it one line earlier would miss the tool name/args; placing it one line later would have already executed the tool. The `GovernedTool` wrapper provides defense-in-depth — even if `selectedTool.call()` is invoked directly, the gate fires again. + +## 5-minute demo script + +1. Show v2 canvas + governance inputs on Agent node. +2. Open `Agent.ts` at governance gate (~line 2327). +3. Run **allow** (weather) — show it runs instantly, audit: `propose` → `policy_decision(allow)` → `execute` → `observe`. +4. Run **deny** (delete database) — show agent recovery message, audit: `propose` → `policy_decision(deny)`. +5. Run **escalate** (send email to external) — click **Proceed** in chat, show completion, audit: `propose` → `policy_decision(escalate)` → `hitl(proceed)` → `execute` → `observe`. +6. Run **escalate with arg edit** (transfer funds) — lower the amount in the Proceed dialog, show modified args in audit. +7. Explain unbypassable: `GovernedTool` wrapper + executor gate — `tool.call()` hits the gate regardless of caller. diff --git a/hackathon/agent-policies.json b/hackathon/agent-policies.json new file mode 100644 index 00000000000..20fb2dea160 --- /dev/null +++ b/hackathon/agent-policies.json @@ -0,0 +1,97 @@ +{ + "version": "1", + "rules": [ + { + "id": "allow-safe-read", + "effect": "allow", + "match": { + "tool": "get_weather" + }, + "message": "Read-only weather lookup is always permitted." + }, + { + "id": "deny-destructive", + "effect": "deny", + "match": { + "tool": "delete_database" + }, + "message": "Destructive DB mutations are forbidden by policy." + }, + { + "id": "allow-internal-email", + "effect": "allow", + "match": { + "tool": "send_email" + }, + "when": [ + { + "path": "args.to", + "op": "regex", + "value": "@internal\\.tech$" + } + ], + "message": "Internal @internal.tech addresses are always permitted." + }, + { + "id": "escalate-external-email", + "effect": "escalate", + "match": { + "tool": "send_email" + }, + "message": "Sending email to an external address requires human approval." + }, + { + "id": "escalate-post-to-external", + "effect": "escalate", + "match": { + "tool": "post_message" + }, + "when": [ + { + "path": "args.input", + "op": "contains", + "value": "#external" + } + ], + "message": "Posting to #external requires human review. You may edit the message or channel before approving." + }, + { + "id": "deny-post-to-public", + "effect": "deny", + "match": { + "tool": "post_message" + }, + "when": [ + { + "path": "args.input", + "op": "contains", + "value": "#public" + } + ], + "message": "Posting directly to #public is forbidden. Use #internal or request a human to review." + }, + { + "id": "escalate-all-writes", + "effect": "escalate", + "match": { + "tool": "write_*" + }, + "message": "All write operations require human review." + }, + { + "id": "prod-escalate-all", + "effect": "escalate", + "match": { + "tool": "*" + }, + "when": [ + { + "path": "context.environment", + "op": "eq", + "value": "production" + } + ], + "message": "All tool calls require approval in production." + } + ] +} diff --git a/hackathon/demo.mjs b/hackathon/demo.mjs new file mode 100644 index 00000000000..6fb570bfe82 --- /dev/null +++ b/hackathon/demo.mjs @@ -0,0 +1,410 @@ +#!/usr/bin/env node +/** + * Agent Governance Demo + * ───────────────────── + * Simulates the full governance loop end-to-end without the UI: + * + * 1. Trigger the loop — agent proposes a tool call + * 2. Policy blocks — deny effect stops execution + * 3. Human approves — escalate effect pauses, human proceeds + * 4. Read audit log — print a formatted summary of all entries + * + * Run: node hackathon/demo.mjs + * + * Writes to ./audit-demo.jsonl (separate from the live audit.jsonl). + */ + +import { createRequire } from 'module' +import { readFileSync, existsSync, unlinkSync } from 'fs' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' +import { randomBytes } from 'crypto' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(__dirname, '..') +const require = createRequire(import.meta.url) + +// ─── Load governance module (built dist) ───────────────────────────────────── +let governance +try { + governance = require(resolve(repoRoot, 'packages/components/dist/src/governance/index.js')) +} catch (e) { + console.error('❌ Could not load governance module.') + console.error(' Run `pnpm build` from the repo root first, then retry.') + console.error(' Details:', e.message) + process.exit(1) +} + +const { gateToolCall, auditPropose, auditHitl, auditExecute, auditObserve, appendAuditLog } = governance + +// ─── Config ────────────────────────────────────────────────────────────────── +const POLICY_PATH = './hackathon/agent-policies.json' +const AUDIT_PATH = './audit-demo.jsonl' + +const governanceConfig = { + policyPath: POLICY_PATH, + auditPath: AUDIT_PATH, + context: { user: 'demo-script', environment: 'dev', nodeId: 'demo-node' } +} + +const meta = { sessionId: 'demo-session', chatId: 'demo-chat', nodeId: 'demo-node' } + +// Clean up previous demo run +const auditFile = resolve(repoRoot, AUDIT_PATH) +if (existsSync(auditFile)) unlinkSync(auditFile) + +// ─── Helpers ───────────────────────────────────────────────────────────────── +const RESET = '\x1b[0m' +const BOLD = '\x1b[1m' +const GREEN = '\x1b[32m' +const RED = '\x1b[31m' +const YELLOW = '\x1b[33m' +const CYAN = '\x1b[36m' +const DIM = '\x1b[2m' + +function banner(text) { + const line = '─'.repeat(62) + console.log(`\n${BOLD}${CYAN}${line}${RESET}`) + console.log(`${BOLD}${CYAN} ${text}${RESET}`) + console.log(`${BOLD}${CYAN}${line}${RESET}`) +} + +function step(label, detail) { + console.log(` ${BOLD}${label}${RESET} ${DIM}${detail}${RESET}`) +} + +function effectBadge(effect) { + if (effect === 'allow') return `${GREEN}[ALLOW]${RESET}` + if (effect === 'deny') return `${RED}[DENY]${RESET}` + if (effect === 'escalate') return `${YELLOW}[ESCALATE]${RESET}` + return `[${effect}]` +} + +function humanDecision(decision, feedback) { + const icon = decision === 'proceed' ? '✅' : '❌' + console.log(`\n ${icon} ${BOLD}Human reviewer${RESET}: ${decision.toUpperCase()}${feedback ? ` — "${feedback}"` : ''}`) +} + +function traceId() { + return randomBytes(8).toString('hex') +} + +// ─── Simulated tool implementations ────────────────────────────────────────── +function runTool(name, args) { + if (name === 'get_weather') { + const loc = args.input || args.location || 'Unknown' + return `Weather in ${loc}: sunny, 24°C` + } + if (name === 'delete_database') { + return 'All customer records deleted (simulated).' + } + if (name === 'send_email') { + const to = args.to || args.recipient || '(unknown)' + const body = args.body || args.message || '(no body)' + return `Email sent to ${to}: "${body}" (simulated).` + } + if (name === 'transfer_funds') { + const amount = args.amount || 0 + const account = args.account || 'unknown' + return `Transferred $${amount} to account ${account} (simulated).` + } + return `Tool ${name} executed (simulated).` +} + +// ─── Core helpers ───────────────────────────────────────────────────────────── +function propose(tool, args) { + const tid = traceId() + auditPropose({ tool, args, governance: governanceConfig, ...meta, traceId: tid }) + const decision = gateToolCall({ tool, args, governance: governanceConfig, ...meta, traceId: tid }) + return { tid, decision } +} + +function execute(tool, args, tid) { + const output = runTool(tool, args) + auditExecute(governanceConfig, tool, args, output, { ...meta, traceId: tid }) + auditObserve(governanceConfig, tool, output, { ...meta, traceId: tid }) + return output +} + +// ─── Session brackets (appendAuditLog is always in dist) ───────────────────── +function sessionStart(input) { + appendAuditLog(AUDIT_PATH, { step: 'session_start', input, ...meta }) +} +function sessionEnd(output, toolCallCount) { + appendAuditLog(AUDIT_PATH, { step: 'session_end', output, toolCallCount, ...meta }) +} + +// ─── flattenJsonInput parity test ──────────────────────────────────────────── +// Mirrors the logic in policyEvaluator.ts so the demo can verify all formats +// without requiring a full TypeScript build. +function parseTextInput(input) { + // 1. JSON + try { + const p = JSON.parse(input) + if (p && typeof p === 'object' && !Array.isArray(p)) return p + } catch {} + + // 2 & 3. Line-based "Key: Value" + const lines = input.split(/\r?\n/) + const result = {} + const bodyLines = [] + let inBody = false + let lineMatches = 0 + for (const line of lines) { + if (inBody) { + bodyLines.push(line) + continue + } + if (line.trim() === '') { + inBody = true + continue + } + const colonIdx = line.indexOf(':') + if (colonIdx > 0) { + const key = line.slice(0, colonIdx).trim().toLowerCase() + const val = line.slice(colonIdx + 1).trim() + if (/^\w+$/.test(key)) { + result[key] = val + lineMatches++ + } else { + inBody = true + bodyLines.push(line) + } + } else { + inBody = true + bodyLines.push(line) + } + } + if (bodyLines.length > 0 && !result['body']) result['body'] = bodyLines.join('\n').trim() + if (lineMatches >= 1) return result + + // 4 & 5. key=value pairs + const kvResult = {} + let kvMatches = 0 + for (const seg of input.split(/[;,]/)) { + const eqIdx = seg.indexOf('=') + if (eqIdx > 0) { + const key = seg.slice(0, eqIdx).trim().toLowerCase() + const val = seg.slice(eqIdx + 1).trim() + if (/^\w+$/.test(key)) { + kvResult[key] = val + kvMatches++ + } + } + } + if (kvMatches >= 2) return kvResult + return null +} + +function flattenArgs(args) { + if (Object.keys(args).length === 1 && typeof args.input === 'string') { + const parsed = parseTextInput(args.input) + if (parsed) return { ...args, ...parsed } + } + return args +} + +banner('Input Flattening — all LLM output formats') +const formats = [ + { label: 'JSON string', input: '{"to":"alice@example.com","subject":"Hi","body":"Hello"}' }, + { label: 'Email-style headers', input: 'To: alice@example.com\nSubject: Hi\n\nHello there' }, + { label: 'key: value lines', input: 'to: alice@example.com\nsubject: Hi\nbody: Hello there' }, + { label: 'key=value semicolons', input: 'to=alice@example.com;subject=Hi;body=Hello there' }, + { label: 'key=value commas', input: 'to=alice@example.com, subject=Hi, body=Hello there' } +] +let allPassed = true +for (const { label, input } of formats) { + const flat = flattenArgs({ input }) + const ok = flat.to === 'alice@example.com' + const badge = ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}` + console.log(` ${badge} ${label.padEnd(24)} to=${flat.to ?? DIM + 'undefined' + RESET}`) + if (!ok) allPassed = false +} +console.log( + allPassed + ? `\n ${GREEN}All formats resolve args.to correctly.${RESET}` + : `\n ${RED}Some formats failed — check policyEvaluator.ts.${RESET}` +) + +// ─── DEMO ───────────────────────────────────────────────────────────────────── + +sessionStart('governance demo run') + +// ── Scenario 1: ALLOW ──────────────────────────────────────────────────────── +banner('Scenario 1 — ALLOW: get_weather') +console.log(` Agent proposes: get_weather({ input: "Tokyo" })`) + +{ + const tool = 'get_weather' + const args = { input: 'Tokyo' } + const { tid, decision } = propose(tool, args) + + step('Policy decision:', `${effectBadge(decision.effect)} rule=${decision.ruleId}`) + step('Message:', decision.message) + + if (decision.effect === 'allow') { + const output = execute(tool, args, tid) + step('Tool output:', output) + console.log(`\n ${GREEN}✓ Tool ran immediately — no human pause needed.${RESET}`) + } +} + +// ── Scenario 2: DENY ───────────────────────────────────────────────────────── +banner('Scenario 2 — DENY: delete_database') +console.log(` Agent proposes: delete_database({})`) + +{ + const tool = 'delete_database' + const args = {} + const { decision } = propose(tool, args) + + step('Policy decision:', `${effectBadge(decision.effect)} rule=${decision.ruleId}`) + step('Message:', decision.message) + + if (decision.effect === 'deny') { + const synthetic = `[POLICY_DENIED] ${decision.message}` + step('Synthetic tool result fed to LLM:', synthetic) + console.log(`\n ${RED}✗ Tool was blocked. Agent re-reasons without executing.${RESET}`) + } +} + +// ── Scenario 3: ESCALATE → human APPROVES ──────────────────────────────────── +banner('Scenario 3 — ESCALATE → Approve: send_email (external, email-header format)') +console.log(` Agent proposes: send_email({ input: "To: alice@example.com\\nSubject: Report\\n\\nReport ready" })`) +console.log(` (LLM packed everything into a single input string — flattenJsonInput extracts args.to)`) + +{ + const tool = 'send_email' + // This is exactly what the LLM sends — everything in one input string + const args = { input: 'To: alice@example.com\nSubject: Report ready\n\nHi Alice, the report is ready.' } + const { tid, decision } = propose(tool, args) + + step('Policy decision:', `${effectBadge(decision.effect)} rule=${decision.ruleId}`) + step('Message:', decision.message) + + if (decision.effect === 'escalate') { + console.log(`\n ${YELLOW}⏸ Execution paused — waiting for human review...${RESET}`) + humanDecision('proceed', 'Looks fine, send it') + + auditHitl(governanceConfig, tool, args, 'proceed', { + ...meta, + traceId: tid, + ruleId: decision.ruleId, + feedback: 'Looks fine, send it' + }) + + const output = execute(tool, args, tid) + step('Tool output:', output) + console.log(`\n ${GREEN}✓ Human approved — tool executed.${RESET}`) + } else if (decision.effect === 'allow') { + const output = execute(tool, args, tid) + step('Tool output:', output) + console.log(`\n ${GREEN}✓ Allowed immediately (internal address).${RESET}`) + } +} + +// ── Scenario 4: ESCALATE → human REJECTS ───────────────────────────────────── +banner('Scenario 4 — ESCALATE → Reject: transfer_funds') +console.log(` Agent proposes: transfer_funds({ amount: 50000, account: "9988776655" })`) + +{ + const tool = 'transfer_funds' + const args = { amount: 50000, account: '9988776655' } + const { tid, decision } = propose(tool, args) + + step('Policy decision:', `${effectBadge(decision.effect)} rule=${decision.ruleId}`) + step('Message:', decision.message) + + if (decision.effect === 'escalate') { + console.log(`\n ${YELLOW}⏸ Execution paused — waiting for human review...${RESET}`) + humanDecision('reject', 'Amount too large, do not proceed') + + auditHitl(governanceConfig, tool, args, 'reject', { + ...meta, + traceId: tid, + ruleId: decision.ruleId, + feedback: 'Amount too large, do not proceed' + }) + + const synthetic = `[REJECTED BY HUMAN] The action "${tool}" was rejected. Reviewer note: "Amount too large, do not proceed".` + step('Synthetic tool result fed to LLM:', synthetic) + console.log(`\n ${RED}✗ Human rejected — tool blocked, agent re-reasons.${RESET}`) + } +} + +// ── Scenario 5: ESCALATE → approve WITH redirect instruction ───────────────── +banner('Scenario 5 — ESCALATE → Redirect: transfer_funds') +console.log(` Agent proposes: transfer_funds({ amount: 50000, account: "9988776655" })`) +console.log(` Reviewer types an instruction instead of approving as-is.`) +console.log(` Expected: tool call discarded, LLM re-invoked with instruction as user message.`) + +{ + const tool = 'transfer_funds' + const originalArgs = { amount: 50000, account: '9988776655' } + const { tid, decision } = propose(tool, originalArgs) + + step('Policy decision:', `${effectBadge(decision.effect)} rule=${decision.ruleId}`) + + if (decision.effect === 'escalate') { + console.log(`\n ${YELLOW}⏸ Execution paused — waiting for human review...${RESET}`) + + const instruction = 'Use a lower amount, $200 only, and confirm with the user first' + humanDecision('proceed', instruction) + + // Audit the redirect as a hitl entry with the instruction in feedback + auditHitl(governanceConfig, tool, originalArgs, 'proceed', { + ...meta, + traceId: tid, + ruleId: decision.ruleId, + feedback: instruction + }) + + // In the real agent loop, the pending tool-call message is popped from history, + // the instruction is injected as a user message, and the LLM is re-invoked. + // Here we simulate that by showing what the LLM would receive. + console.log(`\n ${CYAN}→ Tool call discarded. LLM re-invoked with:${RESET}`) + console.log(` { role: 'user', content: '${instruction}' }`) + console.log(`\n ${GREEN}✓ LLM reasons fresh from the instruction — no tool executed.${RESET}`) + } +} + +sessionEnd('demo complete', 4) // scenarios 1–3 execute tools; 4 and 5 are blocked/redirected + +// ─── Audit log reader ───────────────────────────────────────────────────────── +banner('Audit Log — audit-demo.jsonl') + +if (!existsSync(auditFile)) { + console.log(` ${RED}Audit file not found: ${AUDIT_PATH}${RESET}`) + process.exit(1) +} + +const lines = readFileSync(auditFile, 'utf8').trim().split('\n').filter(Boolean) +const entries = lines.map((l) => JSON.parse(l)) + +const stepColors = { + session_start: CYAN, + session_end: CYAN, + propose: DIM, + policy_decision: BOLD, + hitl: YELLOW, + execute: GREEN, + observe: DIM +} + +console.log(`\n ${entries.length} entries written to ${BOLD}${AUDIT_PATH}${RESET}\n`) + +for (const entry of entries) { + const color = stepColors[entry.step] || '' + const ts = entry.ts.replace('T', ' ').replace('Z', '') + const tool = entry.tool ? ` tool=${entry.tool}` : '' + const effect = entry.effect ? ` ${effectBadge(entry.effect)}` : '' + const rule = entry.ruleId ? ` rule=${entry.ruleId}` : '' + const human = entry.humanDecision ? ` human=${entry.humanDecision.toUpperCase()}` : '' + const fb = entry.feedback ? ` feedback="${entry.feedback}"` : '' + const obs = entry.observation ? ` → ${entry.observation.slice(0, 55)}${entry.observation.length > 55 ? '…' : ''}` : '' + const mod = entry.feedback && entry.humanDecision === 'proceed' ? ` ${YELLOW}[redirect]${RESET}` : '' + + console.log(` ${DIM}${ts}${RESET} ${color}${entry.step.padEnd(16)}${RESET}${tool}${effect}${rule}${human}${fb}${obs}${mod}`) +} + +console.log(`\n${BOLD}${GREEN}Demo complete.${RESET} Full entries in ${BOLD}${AUDIT_PATH}${RESET}\n`) diff --git a/hackathon/governance-design.md b/hackathon/governance-design.md new file mode 100644 index 00000000000..a93be6710f7 --- /dev/null +++ b/hackathon/governance-design.md @@ -0,0 +1,733 @@ +# Flowise Agent Governance — Design Document + +> HLD · LLD · Flowcharts (Mermaid) + +--- + +## Table of Contents + +1. [High-Level Design (HLD)](#1-high-level-design) +2. [Low-Level Design (LLD)](#2-low-level-design) +3. [Flowcharts](#3-flowcharts) + - 3.1 System Context + - 3.2 Happy-path (allow) + - 3.3 Hard-deny path + - 3.4 Escalation / HITL pause + - 3.5 HITL resume — proceed (approve as-is / redirect) + - 3.6 HITL resume — reject + - 3.7 Policy evaluation internals + - 3.8 Audit lifecycle (sequence) +4. [Appendix: Rule Evaluation Examples](#4-appendix-rule-evaluation-examples) + +--- + +## 1. High-Level Design + +### 1.1 Purpose + +The governance layer sits **between the LLM's tool-call decision and actual tool execution**. It enforces a declarative JSON policy file at runtime, produces a tamper-evident append-only audit log, and surfaces Human-in-the-Loop (HITL) checkpoints to the chat UI via SSE events — all without modifying the underlying tools or the LLM. + +### 1.2 System Context Diagram + +```mermaid +graph TD + LLM["LLM\n(OpenAI / Bedrock / etc.)"] + AgentLoop["Agent Loop\nAgent.ts"] + GovCore["Governance Core\npolicyLoader · policyEvaluator · gate · auditLogger"] + GovernedTool["GovernedTool\ndefense-in-depth wrapper"] + PolicyFile["policy.json\nhot-reload by mtime"] + AuditLog["audit.jsonl\nappend-only JSONL"] + SSE["SSE Stream\nGovernanceEvent → UI"] + Tools["Actual Tools\n(unchanged)"] + + LLM -- "tool_call" --> AgentLoop + AgentLoop -- "observation" --> LLM + AgentLoop -- "auditPropose / gateToolCall\nauditExecute / auditObserve" --> GovCore + AgentLoop -- "selectedTool.call()" --> GovernedTool + GovernedTool -- "defense-in-depth gateToolCall" --> GovCore + GovernedTool -- "inner._call()" --> Tools + GovCore -- "loadPolicyFile" --> PolicyFile + GovCore -- "appendAuditLog" --> AuditLog + GovCore -- "streamGovernanceEvent" --> SSE +``` + +### 1.3 Key Design Principles + +| Principle | How it is achieved | +| ------------------------ | ------------------------------------------------------------------------------------------------------- | +| **Non-invasive** | Tools are wrapped via `GovernedTool`; LLM and tool implementations are unchanged | +| **Declarative policy** | JSON rules file; no code changes needed to add/change rules | +| **Hot-reload** | `policyLoader` caches by `mtime`; policy changes take effect on the next tool call | +| **First-match wins** | Rules evaluated in file order; specific rules go before wildcards | +| **Defense-in-depth** | `GovernedTool._call()` re-checks policy even if the agent loop is bypassed | +| **Tamper-evident audit** | Append-only JSONL; every step (propose → policy_decision → hitl → execute → observe) is a separate line | +| **UI-first HITL** | `GovernanceEvent` objects streamed via SSE so the chat UI renders approval widgets natively | + +### 1.4 Three Governance Outcomes + +```mermaid +flowchart LR + Propose["Tool call proposed"] + Allow["ALLOW\nExecute immediately\naudit execute + observe"] + Deny["DENY\nReturn POLICY_DENIED\nto LLM — no execution"] + Escalate["ESCALATE\nPause agent\nstream HITL event to UI\nwait for human"] + ProceedApprove["proceed (no instruction)\n→ execute tool as-is\nfeedback appended to tool result"] + ProceedRedirect["proceed (with instruction)\n→ discard tool call\n→ inject instruction as user msg\n→ restart LLM reasoning"] + Reject["reject\n→ synthetic rejection\nmessage to LLM"] + + Propose --> Allow + Propose --> Deny + Propose --> Escalate + Escalate --> ProceedApprove + Escalate --> ProceedRedirect + Escalate --> Reject +``` + +### 1.5 Module Responsibilities + +| Module | Responsibility | +| -------------------- | --------------------------------------------------------------------------------------------------- | +| `types.ts` | Shared TypeScript interfaces: `PolicyRule`, `PolicyDecision`, `AuditEntry`, `GovernanceEvent`, etc. | +| `policyLoader.ts` | Reads and hot-reloads the JSON policy file; validates schema | +| `policyEvaluator.ts` | Pure function: evaluates rules against `(toolName, args, context)` → `PolicyDecision` | +| `gate.ts` | Orchestrates a single gate check; writes audit entries; builds SSE events | +| `auditLogger.ts` | Appends a timestamped JSONL line to the audit file | +| `governedTool.ts` | Wraps a `Tool` with a defense-in-depth gate check on every `.call()` | +| `Agent.ts` | Wires governance into the ReAct loop; owns the HITL pause/resume lifecycle | + +--- + +## 2. Low-Level Design + +### 2.1 Data Structures + +#### PolicyRule + +```typescript +interface PolicyRule { + id: string // unique rule identifier + effect: 'allow' | 'deny' | 'escalate' + match: { tool: string } // exact | prefix wildcard ("write_*") | "*" + when?: PolicyCondition[] // ALL must match (AND) + anyOf?: PolicyCondition[] // AT LEAST ONE must match (OR) + message?: string +} + +interface PolicyCondition { + path: string // dot-path into { args, context } e.g. "args.to", "context.environment" + op: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'not-contains' | 'starts-with' | 'regex' + value: unknown +} +``` + +#### PolicyDecision (output of evaluator) + +```typescript +interface PolicyDecision { + effect: 'allow' | 'deny' | 'escalate' + ruleId: string // "default-allow" if no rule matched + message: string +} +``` + +#### AuditEntry (one JSONL line) + +```typescript +interface AuditEntry { + ts: string // ISO-8601 timestamp + traceId?: string // correlates all steps of one tool invocation + step: AuditStep // see lifecycle below + tool?: string + args?: Record + ruleId?: string + effect?: PolicyEffect + message?: string + humanDecision?: 'proceed' | 'reject' + feedback?: string // reviewer instruction (redirect path) or free-text note + observation?: string // truncated to 500 chars + sessionId?: string + chatId?: string + nodeId?: string + input?: string // session_start only + output?: string // session_end only + toolCallCount?: number // session_end only +} +``` + +#### GovernanceEvent (SSE payload to UI) + +```typescript +interface GovernanceEvent { + traceId: string + step: AuditStep + tool?: string + args?: Record + effect?: PolicyEffect + ruleId?: string + message?: string + humanDecision?: string + feedback?: string + ts: string +} +``` + +#### IHumanInput (HITL resume payload from UI) + +```typescript +interface IHumanInput { + type: 'proceed' | 'reject' + startNodeId: string + feedback?: string + /** + * Plain-text reviewer instruction (optional). + * - Empty / absent → approve as-is: tool executes with original args. + * Feedback (if any) is appended to the tool result so the LLM sees it. + * - Non-empty → redirect: pending tool call is discarded, instruction is + * injected as a new user message, and the LLM is re-invoked from scratch. + */ + modifiedArgs?: string +} +``` + +### 2.2 Audit Step Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> session_start + session_start --> propose + propose --> policy_decision + policy_decision --> hitl : effect == escalate + policy_decision --> execute : effect == allow + hitl --> execute : humanDecision == proceed\n(no instruction) + hitl --> propose : humanDecision == proceed\n(with instruction — LLM re-invoked) + hitl --> [*] : humanDecision == reject\n(synthetic tool msg pushed) + execute --> observe + observe --> propose : more tool calls + observe --> session_end : agent done + session_end --> [*] +``` + +### 2.3 Component Interaction (Class Diagram) + +```mermaid +classDiagram + class Agent_Agentflow { + +run(nodeData, input, options) + -handleToolCalls(...) + -handleResumedToolCalls(...) + -parseGovernanceConfig(nodeData) GovernanceConfig + } + + class GovernedTool { + +name: string + +description: string + +humanApproved: boolean + -inner: Tool + -governance: GovernanceConfig + -sessionId: string + -chatId: string + -nodeId: string + +call(arg, config, tags, flowConfig) string + #_call(arg) string + } + + class gate { + +gateToolCall(input) PolicyDecision + +auditPropose(input) string + +auditHitl(governance, tool, args, decision, meta) + +auditExecute(governance, tool, args, observation, meta) + +auditObserve(governance, tool, observation, meta) + +auditSessionStart(governance, input, meta) + +auditSessionEnd(governance, output, count, meta) + +buildGovernanceEvent(...) GovernanceEvent + +generateTraceId() string + } + + class policyLoader { + +loadPolicyFile(path) PolicyFile + +clearPolicyCache() + -policyCache: Map + } + + class policyEvaluator { + +evaluatePolicy(policy, toolName, args, context) PolicyDecision + -ruleMatches(rule, toolName, args, context) boolean + -evaluateCondition(condition, args, context) boolean + -toolNameMatches(pattern, toolName) boolean + -flattenJsonInput(args) Record + -parseTextInput(input) Record or null + } + + class auditLogger { + +appendAuditLog(auditPath, entry) + +truncateObservation(obs, maxLen) string + } + + Agent_Agentflow --> gate : calls auditPropose\ngateToolCall\nauditExecute\nauditObserve + Agent_Agentflow --> GovernedTool : wraps tools via\nwrapToolWithGovernance + GovernedTool --> gate : defense-in-depth\ngateToolCall(skipAudit=true) + gate --> policyLoader : loadPolicyFile + gate --> policyEvaluator : evaluatePolicy + gate --> auditLogger : appendAuditLog +``` + +### 2.4 Policy Evaluation Algorithm + +```mermaid +flowchart TD + Start(["evaluatePolicy(policy, toolName, args, context)"]) + Flatten["flattenJsonInput(args)\nIf args has only 'input' key:\n1. try JSON.parse\n2. try email-header lines\n3. try key: value lines\n4. try key=value pairs"] + NextRule["Next rule in policy.rules\n(first-match wins)"] + NoMore{"No more rules?"} + DefaultAllow(["return DEFAULT_ALLOW\nruleId: 'default-allow'"]) + ToolMatch{"toolNameMatches\n(rule.match.tool, toolName)?\n'*' = any\n'write_*' = prefix\nexact = exact"} + HasWhen{"rule.when\nconditions?"} + WhenAll{"ALL conditions\npass? (AND)"} + HasAnyOf{"rule.anyOf\nconditions?"} + AnyOfOne{"AT LEAST ONE\ncondition passes? (OR)"} + Return(["return PolicyDecision\n{ effect, ruleId, message }"]) + + Start --> Flatten + Flatten --> NextRule + NextRule --> NoMore + NoMore -- Yes --> DefaultAllow + NoMore -- No --> ToolMatch + ToolMatch -- No --> NextRule + ToolMatch -- Yes --> HasWhen + HasWhen -- No --> HasAnyOf + HasWhen -- Yes --> WhenAll + WhenAll -- No --> NextRule + WhenAll -- Yes --> HasAnyOf + HasAnyOf -- No --> Return + HasAnyOf -- Yes --> AnyOfOne + AnyOfOne -- No --> NextRule + AnyOfOne -- Yes --> Return +``` + +### 2.5 GovernedTool Defense-in-Depth + +```mermaid +flowchart TD + CallEntry(["GovernedTool.call(arg)"]) + Gate["gateToolCall\nskipAudit=true\n(agent loop already audited)"] + IsDeny{"effect == deny?"} + IsEscalate{"effect == escalate\nAND NOT humanApproved?"} + ReturnDeny(["return POLICY_DENIED + message"]) + ReturnEscalate(["return POLICY_DENIED\n[ESCALATION REQUIRED] message"]) + InnerCall["inner._call(arg)\nor inner.call(arg)"] + Done(["return toolOutput"]) + + CallEntry --> Gate + Gate --> IsDeny + IsDeny -- Yes --> ReturnDeny + IsDeny -- No --> IsEscalate + IsEscalate -- Yes --> ReturnEscalate + IsEscalate -- No --> InnerCall + InnerCall --> Done +``` + +### 2.6 Configuration (per Agent node) + +| Input field | Type | Description | +| ------------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `agentEnableGovernance` | boolean | Master switch. If true, both path fields below must be set or governance is disabled with a warning. | +| `agentPolicyFilePath` | string | Absolute path to `agent-policies.json`. Required when governance is enabled. | +| `agentAuditLogPath` | string | Absolute path to `audit.jsonl`. Required when governance is enabled. | +| `agentGovernanceContext` | JSON string or object | Runtime context injected into every policy evaluation e.g. `{"environment":"production"}`. Accepts a raw JSON string or a pre-resolved object (e.g. from a `{{ $flow.state.* }}` variable). | + +> **Validation**: `parseGovernanceConfig` returns `undefined` (governance disabled) if either path is missing or blank, logging a `[Governance]` warning. This prevents a crash from `loadPolicyFile(undefined)` at runtime. + +--- + +## 3. Flowcharts + +### 3.1 System Context (C4-style) + +```mermaid +graph LR + subgraph Flowise["Flowise Agentflow Runtime"] + subgraph AgentNode["Agent Node (Agent.ts)"] + Loop["ReAct Loop"] + end + subgraph GovLayer["Governance Layer"] + PL["policyLoader\nhot-reload by mtime"] + PE["policyEvaluator\npure function"] + GT["gate\norchestrator"] + AL["auditLogger\nappend-only"] + GW["GovernedTool\nwrapper"] + end + Tools["Actual Tools"] + end + + LLM["LLM Provider"] + PolicyJSON["policy.json"] + AuditJSONL["audit.jsonl"] + ChatUI["Chat UI\n(SSE consumer)"] + + LLM <-- "tool_call / observation" --> Loop + Loop --> GT + Loop --> GW + GW --> GT + GW --> Tools + GT --> PL + GT --> PE + GT --> AL + GT -- "GovernanceEvent" --> ChatUI + PL --> PolicyJSON + AL --> AuditJSONL +``` + +--- + +### 3.2 Happy-Path (ALLOW) + +```mermaid +sequenceDiagram + participant LLM + participant AgentLoop as Agent Loop + participant Gate as gate.ts + participant PolicyEval as policyEvaluator + participant AuditLog as audit.jsonl + participant Tool as GovernedTool / Tool + participant UI as Chat UI (SSE) + + LLM->>AgentLoop: tool_call { name, args } + AgentLoop->>Gate: auditPropose(tool, args) + Gate->>AuditLog: append { step:"propose", traceId, tool, args } + Gate-->>AgentLoop: traceId + + AgentLoop->>Gate: gateToolCall(tool, args, traceId) + Gate->>PolicyEval: evaluatePolicy(policy, tool, args, context) + PolicyEval-->>Gate: { effect:"allow", ruleId, message } + Gate->>AuditLog: append { step:"policy_decision", effect:"allow" } + Gate-->>AgentLoop: PolicyDecision + + AgentLoop->>UI: streamGovernanceEvent { step:"policy_decision", effect:"allow" } + + AgentLoop->>Tool: selectedTool.call(args) + Note over Tool: GovernedTool re-checks (skipAudit=true)\nhumanApproved=true set before call + Tool-->>AgentLoop: toolOutput + + AgentLoop->>Gate: auditExecute(tool, args, toolOutput) + Gate->>AuditLog: append { step:"execute", observation } + + AgentLoop->>Gate: auditObserve(tool, toolOutput) + Gate->>AuditLog: append { step:"observe", observation } + + AgentLoop->>UI: streamGovernanceEvent { step:"execute" } + AgentLoop->>LLM: tool result message +``` + +--- + +### 3.3 Hard-Deny Path + +```mermaid +sequenceDiagram + participant LLM + participant AgentLoop as Agent Loop + participant Gate as gate.ts + participant PolicyEval as policyEvaluator + participant AuditLog as audit.jsonl + participant UI as Chat UI (SSE) + + LLM->>AgentLoop: tool_call { name:"delete_database", args } + AgentLoop->>Gate: auditPropose(tool, args) + Gate->>AuditLog: append { step:"propose", traceId } + Gate-->>AgentLoop: traceId + + AgentLoop->>Gate: gateToolCall(tool, args, traceId) + Gate->>PolicyEval: evaluatePolicy(...) + PolicyEval-->>Gate: { effect:"deny", ruleId:"deny-destructive" } + Gate->>AuditLog: append { step:"policy_decision", effect:"deny" } + Gate-->>AgentLoop: PolicyDecision + + AgentLoop->>UI: streamGovernanceEvent { step:"policy_decision", effect:"deny" } + + Note over AgentLoop: Tool is NEVER called + AgentLoop->>LLM: tool result "[POLICY_DENIED] Destructive DB mutations are forbidden." + Note over LLM: Re-reasons / responds to user +``` + +--- + +### 3.4 Escalation — HITL Pause + +````mermaid +sequenceDiagram + participant LLM + participant AgentLoop as Agent Loop + participant Gate as gate.ts + participant PolicyEval as policyEvaluator + participant AuditLog as audit.jsonl + participant UI as Chat UI (SSE) + participant Human + + LLM->>AgentLoop: tool_call { name:"send_email", args } + AgentLoop->>Gate: auditPropose(tool, args) + Gate->>AuditLog: append { step:"propose", traceId } + Gate-->>AgentLoop: traceId + + AgentLoop->>Gate: gateToolCall(tool, args, traceId) + Gate->>PolicyEval: evaluatePolicy(...) + PolicyEval-->>Gate: { effect:"escalate", ruleId:"escalate-external-email" } + Gate->>AuditLog: append { step:"policy_decision", effect:"escalate" } + Gate-->>AgentLoop: PolicyDecision + + AgentLoop->>UI: streamGovernanceEvent { step:"policy_decision", effect:"escalate" } + + Note over AgentLoop: Append escalation block to LLM response content:\n"**Policy escalation** (rule: escalate-external-email): ...\nAttempting to use tool: ```json ... ```" + + AgentLoop->>UI: streamGovernanceEvent { step:"hitl" } + Note over UI: Renders approval widget with tool args + + AgentLoop-->>AgentLoop: return { isWaitingForHumanInput:true, pendingToolCalls } + Note over AgentLoop: Checkpoint saved (full message history) + + UI->>Human: Show approval widget + Note over Human: Reviews tool name, args, policy message +```` + +--- + +### 3.5 HITL Resume — Proceed + +The proceed path has two branches depending on whether the reviewer typed an instruction. + +#### 3.5a Proceed — Approve as-is (no instruction) + +```mermaid +flowchart TD + HumanProceed(["Human clicks Proceed\n(instruction field left empty)"]) + ReGate["gateToolCall(originalArgs, skipAudit=true)\nRe-evaluate policy"] + IsDeny{"effect == deny?"} + DenyMsg["Push POLICY_DENIED tool message\nSkip execution"] + AuditHitl["auditHitl('proceed', feedback)"] + StreamApprove["streamGovernanceEvent\n{ step:'hitl', humanDecision:'proceed' }"] + SetApproved["GovernedTool.humanApproved = true"] + Execute["selectedTool.call(originalArgs)"] + ResetApproved["GovernedTool.humanApproved = false"] + AuditExec["auditExecute(tool, args, toolOutput)"] + InjectFeedback["Append reviewer feedback to tool result:\ntoolOutput + '[Reviewer note: feedback]'"] + PushResult["Push augmented tool result to messages"] + LLMContinues(["LLM continues reasoning\n(sees feedback in tool result)"]) + + HumanProceed --> ReGate + ReGate --> IsDeny + IsDeny -- Yes --> DenyMsg + IsDeny -- No --> AuditHitl + AuditHitl --> StreamApprove + StreamApprove --> SetApproved + SetApproved --> Execute + Execute --> ResetApproved + ResetApproved --> AuditExec + AuditExec --> InjectFeedback + InjectFeedback --> PushResult + PushResult --> LLMContinues +``` + +#### 3.5b Proceed — Redirect (instruction provided) + +```mermaid +flowchart TD + HumanRedirect(["Human clicks Proceed\nwith a plain-text instruction"]) + PopToolCall["Pop the LLM's pending tool-call message\nfrom history (keeps conversation valid)"] + AuditHitl["auditHitl('proceed', feedback=instruction)"] + StreamApprove["streamGovernanceEvent\n{ step:'hitl', humanDecision:'proceed', feedback:instruction }"] + InjectUser["Push instruction as new user message\n{ role:'user', content: instruction }"] + BindTools["llm.bindTools(toolsInstance)"] + InvokeLLM["Re-invoke LLM with updated history\n(streaming or non-streaming)"] + HasToolCalls{"LLM wants to\ncall tools?"} + HandleTools["handleToolCalls(...)\n(normal governance path)"] + ReturnResponse(["Return LLM response"]) + + HumanRedirect --> PopToolCall + PopToolCall --> AuditHitl + AuditHitl --> StreamApprove + StreamApprove --> InjectUser + InjectUser --> BindTools + BindTools --> InvokeLLM + InvokeLLM --> HasToolCalls + HasToolCalls -- Yes --> HandleTools + HasToolCalls -- No --> ReturnResponse + HandleTools --> ReturnResponse +``` + +--- + +### 3.6 HITL Resume — Reject + +```mermaid +flowchart TD + HumanReject(["Human clicks Reject\n(optionally adds feedback)"]) + ReGate["gateToolCall(originalArgs, skipAudit=true)\nRecover ruleId that triggered escalation"] + AuditHitl["auditHitl('reject', ruleId, feedback)"] + StreamReject["streamGovernanceEvent\n{ step:'hitl', humanDecision:'reject' }"] + SyntheticMsg["Push synthetic tool result:\n'[REJECTED BY HUMAN] The action was rejected.\nReviewer note: feedback.\nDo not attempt this again...'"] + NoExec["Tool is NEVER executed"] + LLMReasons(["LLM re-reasons\nSuggests alternatives to user"]) + + HumanReject --> ReGate + ReGate --> AuditHitl + AuditHitl --> StreamReject + StreamReject --> SyntheticMsg + SyntheticMsg --> NoExec + NoExec --> LLMReasons +``` + +--- + +### 3.7 Policy Evaluation Internals + +```mermaid +flowchart TD + Start(["evaluatePolicy\n(policy, toolName, args, context)"]) + Flatten["flattenJsonInput(args)\nIf args = { input: string }:\n① JSON.parse\n② email-header lines Key: Value\n③ key: value lines\n④ key=value semicolon/comma pairs\nMerge parsed fields into args"] + IterStart["Iterate rules in order\n(first-match wins)"] + NoMore{"All rules\nexhausted?"} + DefaultAllow(["return DEFAULT_ALLOW\n{ effect:'allow', ruleId:'default-allow' }"]) + ToolMatch{"toolNameMatches?\n'*' → always\n'prefix_*' → startsWith\nexact → ==="} + HasWhen{"rule.when\nexists and non-empty?"} + WhenEval["Evaluate each condition\nagainst { args:flatArgs, context }\nresolve dot-path value\napply op"] + WhenAll{"ALL pass?\n(AND logic)"} + HasAnyOf{"rule.anyOf\nexists and non-empty?"} + AnyOfEval["Evaluate each condition"] + AnyOfOne{"AT LEAST ONE\npasses? (OR logic)"} + Matched(["return PolicyDecision\n{ effect, ruleId, message }"]) + + Start --> Flatten + Flatten --> IterStart + IterStart --> NoMore + NoMore -- Yes --> DefaultAllow + NoMore -- No --> ToolMatch + ToolMatch -- No match --> IterStart + ToolMatch -- Match --> HasWhen + HasWhen -- No --> HasAnyOf + HasWhen -- Yes --> WhenEval + WhenEval --> WhenAll + WhenAll -- Fail --> IterStart + WhenAll -- Pass --> HasAnyOf + HasAnyOf -- No --> Matched + HasAnyOf -- Yes --> AnyOfEval + AnyOfEval --> AnyOfOne + AnyOfOne -- Fail --> IterStart + AnyOfOne -- Pass --> Matched +``` + +--- + +### 3.8 Audit Lifecycle (Full Session Sequence) + +```mermaid +sequenceDiagram + participant Agent as Agent Loop + participant Gate as gate.ts + participant Log as audit.jsonl + + Agent->>Gate: auditSessionStart(input) + Gate->>Log: { step:"session_start", input, sessionId, chatId, nodeId } + + loop For each tool call in session + Agent->>Gate: auditPropose(tool, args) + Gate->>Log: { step:"propose", traceId, tool, args } + + Agent->>Gate: gateToolCall(tool, args, traceId) + Gate->>Log: { step:"policy_decision", traceId, effect, ruleId, message } + + alt effect == escalate + Agent->>Gate: auditHitl(tool, args, humanDecision, meta) + Gate->>Log: { step:"hitl", traceId, humanDecision, feedback } + + alt humanDecision == proceed AND instruction provided (redirect) + Note over Agent: Tool call discarded\nInstruction injected as user message\nLLM re-invoked — new propose cycle begins + end + end + + alt tool executed (allow or approved escalation with no instruction) + Agent->>Gate: auditExecute(tool, args, observation) + Gate->>Log: { step:"execute", traceId, tool, args, observation } + + Agent->>Gate: auditObserve(tool, observation) + Gate->>Log: { step:"observe", traceId, tool, observation } + end + end + + Agent->>Gate: auditSessionEnd(output, toolCallCount) + Gate->>Log: { step:"session_end", output, toolCallCount, sessionId } +``` + +--- + +## 4. Appendix: Rule Evaluation Examples + +### Example 1 — Internal email → ALLOW + +```mermaid +flowchart LR + Input["tool: send_email\nargs.to: 'alice\@aivar.tech'"] + R1{"Rule: allow-internal-email\nmatch.tool = send_email ✓\nwhen: args.to regex @aivar\\.tech$\n→ alice\@aivar.tech ✓"} + Allow(["effect: allow"]) + + Input --> R1 --> Allow +``` + +### Example 2 — External email → ESCALATE + +```mermaid +flowchart LR + Input["tool: send_email\nargs.to: bob\@external.com"] + R1{"Rule: allow-internal-email\nwhen: args.to regex @aivar\\.tech$\n→ bob\@external.com ✗ skip"} + R2{"Rule: escalate-external-email\nmatch.tool = send_email ✓\nno conditions"} + Escalate(["effect: escalate"]) + + Input --> R1 --> R2 --> Escalate +``` + +### Example 3 — Destructive tool → DENY + +```mermaid +flowchart LR + Input["tool: delete_database\nargs: {}"] + R1{"Rule: deny-destructive\nmatch.tool = delete_database ✓\nno conditions"} + Deny(["effect: deny"]) + + Input --> R1 --> Deny +``` + +### Example 4 — Production wildcard → ESCALATE + +```mermaid +flowchart LR + Input["tool: get_weather\ncontext.environment: production"] + R1{"Rule: allow-safe-read\nmatch.tool = get_weather ✓\nno conditions\n→ would match..."} + Note["BUT: prod-escalate-all placed\nbefore allow-safe-read in rules array\n(first-match wins — rule order matters)"] + R2{"Rule: prod-escalate-all\nmatch.tool = * ✓\nwhen: context.environment eq production ✓"} + Escalate(["effect: escalate"]) + + Input --> R1 + R1 -. "if prod-escalate-all\ncomes first" .-> R2 + R2 --> Escalate + Note -.-> R2 +``` + +### Example 5 — Wildcard write tool → ESCALATE + +```mermaid +flowchart LR + Input["tool: write_file\nargs: { path: '/etc/hosts' }"] + R1{"Rule: escalate-all-writes\nmatch.tool = write_* ✓\ntoolName.startsWith('write_') ✓\nno conditions"} + Escalate(["effect: escalate\nruleId: escalate-all-writes"]) + + Input --> R1 --> Escalate +``` + +### Example 6 — No matching rule → DEFAULT ALLOW + +```mermaid +flowchart LR + Input["tool: calculate_sum\nargs: { a: 10, b: 25 }"] + R1{"No rule matches calculate_sum"} + DefaultAllow(["effect: allow ruleId: default-allow\n'No matching policy rule; allowed by default.'"]) + + Input --> R1 --> DefaultAllow +``` diff --git a/packages/components/nodes/agentflow/Agent/Agent.ts b/packages/components/nodes/agentflow/Agent/Agent.ts index cd031ef4f40..ca90ae5ab53 100644 --- a/packages/components/nodes/agentflow/Agent/Agent.ts +++ b/packages/components/nodes/agentflow/Agent/Agent.ts @@ -1,5 +1,6 @@ import { BaseChatModel } from '@langchain/core/language_models/chat_models' import { + IAgentToolCallResult, ICommonObject, IDatabaseEntity, IHumanInput, @@ -42,6 +43,22 @@ import { } from '../../../src/utils' import { sanitizeFileName } from '../../../src/validator' import { getModelConfigByModelName, MODEL_TYPE } from '../../../src/modelLoader' +import { + GovernanceConfig, + GovernanceMeta, + POLICY_DENY_PREFIX, + auditExecute, + auditHitl, + auditObserve, + auditPropose, + auditSessionEnd, + auditSessionStart, + buildGovernanceEvent, + gateToolCall, + generateTraceId, + wrapToolWithGovernance +} from '../../../src/governance' +import { GovernedTool } from '../../../src/governance/governedTool' interface ITool { agentSelectedTool: string @@ -269,6 +286,53 @@ class Agent_Agentflow implements INode { } ] }, + { + label: 'Enable Governance', + name: 'agentEnableGovernance', + type: 'boolean', + description: + 'Enforce policy checks inside the agent loop before each tool runs. Policies are loaded from a JSON file; escalations pause for human approval in chat.', + default: false, + optional: true, + client: ['agentflowv2'] + }, + { + label: 'Policy File Path', + name: 'agentPolicyFilePath', + type: 'string', + description: 'Path to JSON policy file (allow / deny / escalate rules)', + default: '/Users/riteshdubey/Developer/Github/Projects/Flowise/hackathon/agent-policies.json', + optional: true, + client: ['agentflowv2'], + show: { + agentEnableGovernance: true + } + }, + { + label: 'Audit Log Path', + name: 'agentAuditLogPath', + type: 'string', + description: 'Append-only JSONL audit log path', + default: '/Users/riteshdubey/Developer/Github/Projects/Flowise/audit.jsonl', + optional: true, + client: ['agentflowv2'], + show: { + agentEnableGovernance: true + } + }, + { + label: 'Governance Context (JSON)', + name: 'agentGovernanceContext', + type: 'string', + description: 'Runtime context for policy rules, e.g. {"user":"demo","environment":"dev"}', + rows: 3, + optional: true, + acceptVariable: true, + client: ['agentflowv2'], + show: { + agentEnableGovernance: true + } + }, { label: 'Knowledge (Document Stores)', name: 'agentKnowledgeDocumentStores', @@ -702,6 +766,13 @@ class Agent_Agentflow implements INode { // Extract tools const tools = nodeData.inputs?.agentTools as ITool[] + const governanceConfig = this.parseGovernanceConfig(nodeData) + const governanceMeta: GovernanceMeta = { + sessionId: options.sessionId as string | undefined, + chatId: options.chatId as string | undefined, + nodeId: nodeData.id + } + const toolsInstance: Tool[] = [] for (const tool of tools) { const toolConfig = tool.agentSelectedToolConfig @@ -726,13 +797,19 @@ class Agent_Agentflow implements INode { if (tool.agentSelectedToolRequiresHumanInput) { ;(subToolInstance as any).requiresHumanInput = true } - toolsInstance.push(subToolInstance) + const pushedTool = governanceConfig + ? (wrapToolWithGovernance(subToolInstance, governanceConfig, governanceMeta) as Tool) + : subToolInstance + toolsInstance.push(pushedTool) } } else { if (tool.agentSelectedToolRequiresHumanInput) { toolInstance.requiresHumanInput = true } - toolsInstance.push(toolInstance as Tool) + const pushedTool = governanceConfig + ? (wrapToolWithGovernance(toolInstance as Tool, governanceConfig, governanceMeta) as Tool) + : (toolInstance as Tool) + toolsInstance.push(pushedTool) } } @@ -796,7 +873,10 @@ class Agent_Agentflow implements INode { } const retrieverToolInstance = await newRetrieverToolNodeInstance.init(newRetrieverToolNodeData, '', options) - toolsInstance.push(retrieverToolInstance as Tool) + const wrappedRetrieverTool = governanceConfig + ? (wrapToolWithGovernance(retrieverToolInstance as Tool, governanceConfig, governanceMeta) as Tool) + : (retrieverToolInstance as Tool) + toolsInstance.push(wrappedRetrieverTool) const jsonSchema = toolSchemaToJsonSchema(retrieverToolInstance.schema) const componentNode = options.componentNodes['retrieverTool'] @@ -868,7 +948,10 @@ class Agent_Agentflow implements INode { } const retrieverToolInstance = await newRetrieverToolNodeInstance.init(newRetrieverToolNodeData, '', options) - toolsInstance.push(retrieverToolInstance as Tool) + const wrappedRetrieverTool = governanceConfig + ? (wrapToolWithGovernance(retrieverToolInstance as Tool, governanceConfig, governanceMeta) as Tool) + : (retrieverToolInstance as Tool) + toolsInstance.push(wrappedRetrieverTool) const jsonSchema = toolSchemaToJsonSchema(retrieverToolInstance.schema) const componentNode = options.componentNodes['retrieverTool'] @@ -1110,6 +1193,16 @@ class Agent_Agentflow implements INode { // Track execution time const startTime = Date.now() + // Bracket the full agent run in the audit log so consumers can group all + // tool invocations by session without relying on sessionId alone. + if (governanceConfig) { + auditSessionStart(governanceConfig, typeof input === 'string' ? input : JSON.stringify(input), { + sessionId: options.sessionId as string | undefined, + chatId: options.chatId as string | undefined, + nodeId: nodeData.id + }) + } + // Get initial response from LLM const sseStreamer: IServerSideEventStreamer | undefined = options.sseStreamer @@ -1131,7 +1224,8 @@ class Agent_Agentflow implements INode { isStreamable, isLastNode, iterationContext, - isStructuredOutput + isStructuredOutput, + governanceConfig }) response = result.response @@ -1207,7 +1301,8 @@ class Agent_Agentflow implements INode { iterationContext, isStructuredOutput, accumulatedReasonContent: reasonContent, - accumulatedReasoningDuration: thinkingDuration + accumulatedReasoningDuration: thinkingDuration, + governanceConfig }) response = result.response @@ -1269,8 +1364,6 @@ class Agent_Agentflow implements INode { // Calculate execution time const endTime = Date.now() const timeDelta = endTime - startTime - - // Update flow state if needed let newState = { ...state } if (_agentUpdateState && Array.isArray(_agentUpdateState) && _agentUpdateState.length > 0) { newState = updateFlowState(state, _agentUpdateState) @@ -1503,6 +1596,15 @@ class Agent_Agentflow implements INode { } // Prepare and return the final output + // Close the session bracket in the audit log before returning. + if (governanceConfig) { + auditSessionEnd(governanceConfig, finalResponse, usedTools.length, { + sessionId: options.sessionId as string | undefined, + chatId: options.chatId as string | undefined, + nodeId: nodeData.id + }) + } + return { id: nodeData.id, name: this.name, @@ -2150,7 +2252,8 @@ class Agent_Agentflow implements INode { iterationContext, isStructuredOutput = false, accumulatedReasonContent: initialAccumulatedReasonContent, - accumulatedReasoningDuration: initialAccumulatedReasoningDuration + accumulatedReasoningDuration: initialAccumulatedReasoningDuration, + governanceConfig }: { response: AIMessageChunk messages: BaseMessageLike[] @@ -2167,21 +2270,13 @@ class Agent_Agentflow implements INode { isStructuredOutput?: boolean accumulatedReasonContent?: string accumulatedReasoningDuration?: number - }): Promise<{ - response: AIMessageChunk - usedTools: IUsedTool[] - sourceDocuments: Array - artifacts: any[] - totalTokens: number - isWaitingForHumanInput?: boolean - accumulatedReasonContent?: string - accumulatedReasoningDuration?: number - }> { + governanceConfig?: GovernanceConfig + }): Promise { // Track total tokens used throughout this process let totalTokens = response.usage_metadata?.total_tokens || 0 const usedTools: IUsedTool[] = [] - let sourceDocuments: Array = [] - let artifacts: any[] = [] + let sourceDocuments: ICommonObject[] = [] + let artifacts: ICommonObject[] = [] let isWaitingForHumanInput: boolean | undefined // Use reasoning from caller (first turn); subsequent turns are added when we get newResponse let accumulatedReasonContent = initialAccumulatedReasonContent ?? '' @@ -2249,6 +2344,89 @@ class Agent_Agentflow implements INode { state: options.agentflowRuntime?.state } + const toolArgs = (toolCall.args || {}) as Record + + // Each tool invocation gets its own traceId that correlates all audit steps: + // propose → policy_decision → [hitl] → execute → observe + let toolTraceId: string | undefined + + if (governanceConfig) { + // 1. PROPOSE — record the agent's intent before any policy check + toolTraceId = auditPropose({ + tool: toolCall.name, + args: toolArgs, + governance: governanceConfig, + sessionId: options.sessionId, + chatId: options.chatId, + nodeId: governanceConfig.context?.nodeId as string | undefined + }) + + // 2. POLICY CHECK — evaluate rules; first match wins + const decision = gateToolCall({ + tool: toolCall.name, + args: toolArgs, + governance: governanceConfig, + sessionId: options.sessionId, + chatId: options.chatId, + nodeId: governanceConfig.context?.nodeId as string | undefined, + traceId: toolTraceId + }) + + // Stream the policy decision as a first-class UI event + const govEvent = buildGovernanceEvent('policy_decision', toolTraceId, toolCall.name, toolArgs, decision) + sseStreamer?.streamGovernanceEvent?.(chatId, govEvent) + + if (decision.effect === 'deny') { + const denyObservation = POLICY_DENY_PREFIX + decision.message + messages.push({ + role: 'tool', + content: denyObservation, + tool_call_id: toolCall.id, + name: toolCall.name + }) + usedTools.push({ + tool: toolCall.name, + toolInput: toolArgs, + toolOutput: denyObservation + }) + continue + } + + if (decision.effect === 'escalate') { + // Collect ALL remaining tool calls in this batch as pending so the + // reviewer sees the full picture, not just the first escalated call. + const pendingToolCalls = response.tool_calls + .slice(i) + .map((tc) => ({ name: tc.name, args: (tc.args || {}) as Record })) + + const toolCallDetails = '```json\n' + JSON.stringify(toolCall, null, 2) + '\n```' + const escalationBlock = + `\n\n**Policy escalation** (rule: \`${decision.ruleId}\`): ${decision.message}\n\n` + + `Attempting to use tool:\n${toolCallDetails}` + const responseContent = (response.content || '') + escalationBlock + response.content = responseContent + if (!isStructuredOutput) { + sseStreamer?.streamTokenEvent(chatId, responseContent) + } + // Stream the escalation as a governance event so the UI can render + // an approval widget without parsing the token stream. + const escalateEvent = buildGovernanceEvent('hitl', toolTraceId, toolCall.name, toolArgs, decision) + sseStreamer?.streamGovernanceEvent?.(chatId, escalateEvent) + + return { + response, + usedTools, + sourceDocuments, + artifacts, + totalTokens, + isWaitingForHumanInput: true, + pendingToolCalls, + accumulatedReasonContent: accumulatedReasonContent || undefined, + accumulatedReasoningDuration: accumulatedReasoningDuration || undefined + } + } + } + if (isToolRequireHumanInput) { const toolCallDetails = '```json\n' + JSON.stringify(toolCall, null, 2) + '\n```' const responseContent = response.content + `\nAttempting to use tool:\n${toolCallDetails}` @@ -2274,9 +2452,30 @@ class Agent_Agentflow implements INode { } try { + // Mark the GovernedTool as human-approved if governance is active. + // This prevents the defense-in-depth layer from blocking execution + // on an 'escalate' decision that the agent loop has already cleared. + if (governanceConfig && selectedTool instanceof GovernedTool) { + selectedTool.humanApproved = true + } + //@ts-ignore let toolOutput = await selectedTool.call(toolCall.args, { signal: abortController?.signal }, undefined, flowConfig) + // Reset the flag after execution + if (governanceConfig && selectedTool instanceof GovernedTool) { + selectedTool.humanApproved = false + } + + if (governanceConfig) { + auditExecute(governanceConfig, toolCall.name, toolArgs, toolOutput, { + sessionId: options.sessionId, + chatId: options.chatId, + nodeId: governanceConfig.context?.nodeId as string | undefined, + traceId: toolTraceId + }) + } + if (options.analyticHandlers && toolIds) { await options.analyticHandlers.onToolEnd(toolIds, toolOutput) } @@ -2328,6 +2527,20 @@ class Agent_Agentflow implements INode { } }) + // Audit the observation (what the agent will see as the tool result) + if (governanceConfig) { + auditObserve(governanceConfig, toolCall.name, toolOutput, { + sessionId: options.sessionId, + chatId: options.chatId, + nodeId: governanceConfig.context?.nodeId as string | undefined, + traceId: toolTraceId + }) + // Stream the execute/observe as a governance event so the UI can show + // a real-time audit trail of what the agent actually did. + const execEvent = buildGovernanceEvent('execute', toolTraceId ?? generateTraceId(), toolCall.name, toolArgs) + sseStreamer?.streamGovernanceEvent?.(chatId, execEvent) + } + // Track used tools usedTools.push({ tool: toolCall.name, @@ -2461,7 +2674,8 @@ class Agent_Agentflow implements INode { iterationContext, isStructuredOutput, accumulatedReasonContent, - accumulatedReasoningDuration + accumulatedReasoningDuration, + governanceConfig }) // Merge results from recursive tool calls @@ -2508,7 +2722,8 @@ class Agent_Agentflow implements INode { isStreamable, isLastNode, iterationContext, - isStructuredOutput = false + isStructuredOutput = false, + governanceConfig }: { humanInput: IHumanInput humanInputAction: Record | undefined @@ -2524,20 +2739,12 @@ class Agent_Agentflow implements INode { isLastNode: boolean iterationContext: ICommonObject isStructuredOutput?: boolean - }): Promise<{ - response: AIMessageChunk - usedTools: IUsedTool[] - sourceDocuments: Array - artifacts: any[] - totalTokens: number - isWaitingForHumanInput?: boolean - accumulatedReasonContent?: string - accumulatedReasoningDuration?: number - }> { + governanceConfig?: GovernanceConfig + }): Promise { let llmNodeInstance = llmWithoutToolsBind const usedTools: IUsedTool[] = [] - let sourceDocuments: Array = [] - let artifacts: any[] = [] + let sourceDocuments: ICommonObject[] = [] + let artifacts: ICommonObject[] = [] let isWaitingForHumanInput: boolean | undefined const lastCheckpointMessages = humanInputAction?.data?.input?.messages ?? [] @@ -2629,25 +2836,291 @@ class Agent_Agentflow implements INode { } if (humanInput.type === 'reject') { - messages.pop() - const toBeRemovedTool = toolsInstance.find((tool) => tool.name === toolCall.name) - if (toBeRemovedTool) { - toolsInstance = toolsInstance.filter((tool) => tool.name !== toolCall.name) - // Remove other tools with the same agentSelectedTool such as MCP tools - toolsInstance = toolsInstance.filter( - (tool) => (tool as any).agentSelectedTool !== (toBeRemovedTool as any).agentSelectedTool + if (governanceConfig) { + // Re-evaluate to recover the ruleId that originally triggered escalation. + // Use skipAudit=true — we write the hitl entry ourselves below with full context. + const rejectDecision = gateToolCall({ + tool: toolCall.name, + args: (toolCall.args || {}) as Record, + governance: governanceConfig, + sessionId: options.sessionId, + chatId: options.chatId, + nodeId: governanceConfig.context?.nodeId as string | undefined, + skipAudit: true + }) + auditHitl(governanceConfig, toolCall.name, (toolCall.args || {}) as Record, 'reject', { + sessionId: options.sessionId, + chatId: options.chatId, + nodeId: governanceConfig.context?.nodeId as string | undefined, + ruleId: rejectDecision.ruleId, + feedback: humanInput.feedback + }) + // Stream the rejection as a governance event so the UI can render it + // as a first-class audit artifact in the chat timeline. + const rejectEvent = buildGovernanceEvent( + 'hitl', + generateTraceId(), + toolCall.name, + (toolCall.args || {}) as Record, + rejectDecision, + 'reject', + humanInput.feedback ) + sseStreamer?.streamGovernanceEvent?.(chatId, rejectEvent) } + // Keep the assistant message with the tool call in the conversation so the + // message history stays valid (assistant tool_call must be followed by a tool result). + // Push a synthetic tool result explaining the rejection so the LLM can re-reason + // without retrying the same tool call. + messages.push({ + role: 'tool', + content: `[REJECTED BY HUMAN] The action "${toolCall.name}" was rejected by the reviewer.${ + humanInput.feedback ? ` Reviewer note: "${humanInput.feedback}".` : '' + } Do not attempt this action again in this conversation. Explain to the user that the action was not approved and suggest alternatives if appropriate.`, + tool_call_id: toolCall.id, + name: toolCall.name + }) + usedTools.push({ + tool: toolCall.name, + toolInput: toolCall.args, + toolOutput: '[REJECTED BY HUMAN]' + }) } if (humanInput.type === 'proceed') { + // modifiedArgs is a plain-text instruction from the reviewer. + // If provided, discard the pending tool call and restart LLM reasoning + // with the instruction injected as a new user message. + // If empty, approve as-is and execute the tool with its original args. + const rawModifiedArgs = typeof humanInput.modifiedArgs === 'string' ? (humanInput.modifiedArgs as string).trim() : '' + + if (rawModifiedArgs) { + // --- REDIRECT PATH: re-invoke LLM with the reviewer's instruction --- + // The checkpoint already pushed the LLM's tool-call message onto `messages`. + // We need to remove it so the conversation history stays valid + // (an assistant tool_call message with no following tool result would + // confuse the LLM), then append the reviewer's instruction as a user turn. + if (messages.length > 0) { + const last = messages[messages.length - 1] as any + if (last?.tool_calls?.length || last?.additional_kwargs?.tool_calls?.length) { + messages.pop() + } + } + + if (governanceConfig) { + const redirectDecision = gateToolCall({ + tool: toolCall.name, + args: (toolCall.args || {}) as Record, + governance: governanceConfig, + sessionId: options.sessionId, + chatId: options.chatId, + nodeId: governanceConfig.context?.nodeId as string | undefined, + skipAudit: true + }) + auditHitl(governanceConfig, toolCall.name, (toolCall.args || {}) as Record, 'proceed', { + sessionId: options.sessionId, + chatId: options.chatId, + nodeId: governanceConfig.context?.nodeId as string | undefined, + ruleId: redirectDecision.ruleId, + feedback: rawModifiedArgs + }) + const redirectEvent = buildGovernanceEvent( + 'hitl', + generateTraceId(), + toolCall.name, + (toolCall.args || {}) as Record, + redirectDecision, + 'proceed', + rawModifiedArgs + ) + sseStreamer?.streamGovernanceEvent?.(chatId, redirectEvent) + } + + // Inject the reviewer's instruction as a new user message so the LLM + // reasons from it rather than retrying the blocked tool call. + messages.push({ role: 'user', content: rawModifiedArgs }) + + // Bind tools and re-invoke the LLM — full reasoning restart. + if (llmNodeInstance && (llmNodeInstance as any).builtInTools?.length > 0) { + toolsInstance.push(...(llmNodeInstance as any).builtInTools) + } + if (llmNodeInstance && toolsInstance.length > 0) { + if (llmNodeInstance.bindTools === undefined) { + throw new Error(`Agent needs to have a function calling capable model.`) + } + // @ts-ignore + llmNodeInstance = llmNodeInstance.bindTools(toolsInstance) + } + + let redirectResponse: AIMessageChunk + if (isStreamable) { + redirectResponse = await this.handleStreamingResponse( + sseStreamer, + llmNodeInstance, + messages, + chatId, + abortController, + isStructuredOutput, + isLastNode + ) + } else { + redirectResponse = await llmNodeInstance.invoke(messages, { signal: abortController?.signal }) + if (isLastNode && sseStreamer && !isStructuredOutput) { + sseStreamer.streamTokenEvent(chatId, extractResponseContent(redirectResponse)) + } + } + + if (redirectResponse.usage_metadata?.total_tokens) { + totalTokens += redirectResponse.usage_metadata.total_tokens + } + + let accumulatedReasonContent = (response.additional_kwargs?.reasoning_content as string) || '' + if (redirectResponse.additional_kwargs?.reasoning_content) { + accumulatedReasonContent += + (accumulatedReasonContent ? '\n\n' : '') + (redirectResponse.additional_kwargs.reasoning_content as string) + } + let accumulatedReasoningDuration = + (typeof response.additional_kwargs?.reasoning_duration === 'number' + ? response.additional_kwargs.reasoning_duration + : 0) + + (typeof redirectResponse.additional_kwargs?.reasoning_duration === 'number' + ? redirectResponse.additional_kwargs.reasoning_duration + : 0) + + // If the LLM wants to call tools again, handle them through the normal path. + if (redirectResponse.tool_calls && redirectResponse.tool_calls.length > 0) { + const { + response: recursiveResponse, + usedTools: recursiveUsedTools, + sourceDocuments: recursiveSourceDocuments, + artifacts: recursiveArtifacts, + totalTokens: recursiveTokens, + isWaitingForHumanInput: recursiveWaiting, + accumulatedReasonContent: recursiveReason, + accumulatedReasoningDuration: recursiveDuration + } = await this.handleToolCalls({ + response: redirectResponse, + messages, + toolsInstance, + sseStreamer, + chatId, + input, + options, + abortController, + llmNodeInstance, + isStreamable, + isLastNode, + iterationContext, + isStructuredOutput, + accumulatedReasonContent, + accumulatedReasoningDuration, + governanceConfig + }) + return { + response: recursiveResponse, + usedTools: [...usedTools, ...recursiveUsedTools], + sourceDocuments: [...sourceDocuments, ...recursiveSourceDocuments], + artifacts: [...artifacts, ...recursiveArtifacts], + totalTokens: totalTokens + recursiveTokens, + isWaitingForHumanInput: recursiveWaiting, + accumulatedReasonContent: recursiveReason || undefined, + accumulatedReasoningDuration: recursiveDuration || undefined + } + } + + return { + response: redirectResponse, + usedTools, + sourceDocuments, + artifacts, + totalTokens, + isWaitingForHumanInput: undefined, + accumulatedReasonContent: accumulatedReasonContent || undefined, + accumulatedReasoningDuration: accumulatedReasoningDuration || undefined + } + } + + // --- APPROVE PATH: execute the tool with its original args --- + const originalArgs = (toolCall.args || {}) as Record + const toolArgs = originalArgs + + if (governanceConfig) { + const decision = gateToolCall({ + tool: toolCall.name, + args: toolArgs, + governance: governanceConfig, + sessionId: options.sessionId, + chatId: options.chatId, + nodeId: governanceConfig.context?.nodeId as string | undefined, + skipAudit: true + }) + // Write the hitl audit entry + auditHitl(governanceConfig, toolCall.name, toolArgs, 'proceed', { + sessionId: options.sessionId, + chatId: options.chatId, + nodeId: governanceConfig.context?.nodeId as string | undefined, + ruleId: decision.ruleId, + feedback: humanInput.feedback + }) + // Stream the approval as a governance event + const approveEvent = buildGovernanceEvent( + 'hitl', + generateTraceId(), + toolCall.name, + toolArgs, + decision, + 'proceed', + humanInput.feedback + ) + sseStreamer?.streamGovernanceEvent?.(chatId, approveEvent) + + if (decision.effect === 'deny') { + const denyObservation = POLICY_DENY_PREFIX + decision.message + messages.push({ + role: 'tool', + content: denyObservation, + tool_call_id: toolCall.id, + name: toolCall.name + }) + usedTools.push({ + tool: toolCall.name, + toolInput: toolArgs, + toolOutput: denyObservation + }) + continue + } + + // 'escalate' with humanInput.type === 'proceed' means the human has already + // reviewed and approved this tool call. Do NOT re-escalate — fall through to + // execute the tool. Only a hard 'deny' should block execution at this point. + } + let toolIds: ICommonObject | undefined if (options.analyticHandlers) { - toolIds = await options.analyticHandlers.onToolStart(toolCall.name, toolCall.args, options.parentTraceIds) + toolIds = await options.analyticHandlers.onToolStart(toolCall.name, toolArgs, options.parentTraceIds) } try { + // Mark the GovernedTool as human-approved so the defense-in-depth layer + // does not block execution on an 'escalate' decision already cleared here. + if (governanceConfig && selectedTool instanceof GovernedTool) { + selectedTool.humanApproved = true + } + //@ts-ignore - let toolOutput = await selectedTool.call(toolCall.args, { signal: abortController?.signal }, undefined, flowConfig) + let toolOutput = await selectedTool.call(toolArgs, { signal: abortController?.signal }, undefined, flowConfig) + + // Reset the flag after execution + if (governanceConfig && selectedTool instanceof GovernedTool) { + selectedTool.humanApproved = false + } + + if (governanceConfig) { + auditExecute(governanceConfig, toolCall.name, toolArgs, toolOutput, { + sessionId: options.sessionId, + chatId: options.chatId, + nodeId: governanceConfig.context?.nodeId as string | undefined + }) + } if (options.analyticHandlers && toolIds) { await options.analyticHandlers.onToolEnd(toolIds, toolOutput) @@ -2688,10 +3161,19 @@ class Agent_Agentflow implements INode { } } + // Append reviewer feedback to the tool output so the LLM can act on it. + // This is the key step that makes HITL feedback visible to the agent — + // without it the feedback is only written to the audit log and never + // influences the LLM's next reasoning step. + const toolOutputWithFeedback = + humanInput.feedback && humanInput.feedback.trim() + ? `${toolOutput}\n\n[Reviewer note: "${humanInput.feedback.trim()}"]` + : toolOutput + // Add tool message to conversation messages.push({ role: 'tool', - content: toolOutput, + content: toolOutputWithFeedback, tool_call_id: toolCall.id, name: toolCall.name, additional_kwargs: { @@ -2700,11 +3182,23 @@ class Agent_Agentflow implements INode { } }) + // Audit the observation (what the agent will see as the tool result) + if (governanceConfig) { + auditObserve(governanceConfig, toolCall.name, toolOutputWithFeedback, { + sessionId: options.sessionId, + chatId: options.chatId, + nodeId: governanceConfig.context?.nodeId as string | undefined + }) + // Stream the execute event so the UI audit trail stays live + const execEvent = buildGovernanceEvent('execute', generateTraceId(), toolCall.name, toolArgs) + sseStreamer?.streamGovernanceEvent?.(chatId, execEvent) + } + // Track used tools usedTools.push({ tool: toolCall.name, toolInput: toolInput ?? toolCall.args, - toolOutput + toolOutput: toolOutputWithFeedback }) } catch (e) { if (options.analyticHandlers && toolIds) { @@ -2840,7 +3334,8 @@ class Agent_Agentflow implements INode { iterationContext, isStructuredOutput, accumulatedReasonContent, - accumulatedReasoningDuration + accumulatedReasoningDuration, + governanceConfig }) // Merge results from recursive tool calls @@ -2870,6 +3365,60 @@ class Agent_Agentflow implements INode { } } + private parseGovernanceConfig(nodeData: INodeData): GovernanceConfig | undefined { + const enabled = nodeData.inputs?.agentEnableGovernance === true || nodeData.inputs?.agentEnableGovernance === 'true' + if (!enabled) { + return undefined + } + + const policyPath = nodeData.inputs?.agentPolicyFilePath as string + const auditPath = nodeData.inputs?.agentAuditLogPath as string + + // Both paths are required for governance to function. Warn and disable rather than + // crashing at runtime when loadPolicyFile / appendAuditLog receive undefined paths. + if (!policyPath || !policyPath.trim()) { + console.warn('[Governance] agentEnableGovernance is true but agentPolicyFilePath is not set; governance disabled.') + return undefined + } + if (!auditPath || !auditPath.trim()) { + console.warn('[Governance] agentEnableGovernance is true but agentAuditLogPath is not set; governance disabled.') + return undefined + } + + let context: Record = { nodeId: nodeData.id } + const rawContext = nodeData.inputs?.agentGovernanceContext + if (rawContext) { + // rawContext may arrive as an already-parsed object (when the node framework + // pre-parses JSON string inputs) or as a raw JSON string from the text field. + if (typeof rawContext === 'object' && !Array.isArray(rawContext)) { + context = { ...context, ...(rawContext as Record) } + } else if (typeof rawContext === 'string') { + const trimmed = rawContext.trim() + if (trimmed) { + try { + const parsed: unknown = JSON.parse(trimmed) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + context = { ...context, ...(parsed as Record) } + } + } catch { + console.warn( + `[Governance] agentGovernanceContext is not valid JSON ("${trimmed.slice( + 0, + 60 + )}..."); runtime context signals will be unavailable.` + ) + } + } + } + } + + return { + policyPath: policyPath.trim(), + auditPath: auditPath.trim(), + context + } + } + /** * Processes sandbox links in the response text and converts them to file annotations */ diff --git a/packages/components/src/Interface.ts b/packages/components/src/Interface.ts index a7442318b1a..13b54d336ab 100644 --- a/packages/components/src/Interface.ts +++ b/packages/components/src/Interface.ts @@ -1,4 +1,4 @@ -import { BaseMessage } from '@langchain/core/messages' +import { BaseMessage, AIMessageChunk } from '@langchain/core/messages' import { BufferMemory, BufferWindowMemory, ConversationSummaryMemory, ConversationSummaryBufferMemory } from '@langchain/classic/memory' import { Moderation } from '../nodes/moderation/Moderation' @@ -454,6 +454,13 @@ export interface IServerSideEventStreamer { streamTTSStartEvent(chatId: string, chatMessageId: string, format: string): void streamTTSDataEvent(chatId: string, chatMessageId: string, audioChunk: string): void streamTTSEndEvent(chatId: string, chatMessageId: string): void + /** + * Stream a governance event to the UI so policy decisions are surfaced as + * first-class artifacts in the chat interface, not just backend log lines. + * Implementations that do not yet support this method may leave it undefined; + * callers must guard with an existence check before invoking. + */ + streamGovernanceEvent?(chatId: string, data: import('./governance/types').GovernanceEvent): void } export enum FollowUpPromptProvider { @@ -493,4 +500,25 @@ export interface IHumanInput { type: 'proceed' | 'reject' startNodeId: string feedback?: string + /** + * Reviewer instruction for an escalated tool call. + * When present on a 'proceed' decision and non-empty, the agent loop discards the + * pending tool call and re-invokes the LLM with this string as a new user message, + * restarting reasoning from that point. + * When empty or absent, the tool executes with its original args (approve as-is). + */ + modifiedArgs?: string +} + +/** Shared return shape for handleToolCalls and handleResumedToolCalls in the Agent node. */ +export interface IAgentToolCallResult { + response: AIMessageChunk + usedTools: IUsedTool[] + sourceDocuments: ICommonObject[] + artifacts: ICommonObject[] + totalTokens: number + isWaitingForHumanInput?: boolean + pendingToolCalls?: Array<{ name: string; args: Record }> + accumulatedReasonContent?: string + accumulatedReasoningDuration?: number } diff --git a/packages/components/src/governance/auditLogger.ts b/packages/components/src/governance/auditLogger.ts new file mode 100644 index 00000000000..21e0169958d --- /dev/null +++ b/packages/components/src/governance/auditLogger.ts @@ -0,0 +1,24 @@ +import * as fs from 'fs' +import * as path from 'path' +import { AuditEntry } from './types' + +export function appendAuditLog(auditPath: string, entry: Omit): void { + const dir = path.dirname(auditPath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + const line: AuditEntry = { + ts: new Date().toISOString(), + ...entry + } + + const newLine = JSON.stringify(line) + '\n' + const existing = fs.existsSync(auditPath) ? fs.readFileSync(auditPath, 'utf8') : '' + fs.writeFileSync(auditPath, newLine + existing, 'utf8') +} + +export function truncateObservation(obs: unknown, maxLen = 500): string { + const str = typeof obs === 'string' ? obs : JSON.stringify(obs) + return str.length > maxLen ? str.slice(0, maxLen) + '...' : str +} diff --git a/packages/components/src/governance/gate.ts b/packages/components/src/governance/gate.ts new file mode 100644 index 00000000000..6af4719eeb6 --- /dev/null +++ b/packages/components/src/governance/gate.ts @@ -0,0 +1,219 @@ +import { randomBytes } from 'crypto' +import { appendAuditLog, truncateObservation } from './auditLogger' +import { loadPolicyFile } from './policyLoader' +import { evaluatePolicy } from './policyEvaluator' +import { GovernanceConfig, GovernanceEvent, PolicyDecision } from './types' + +/** + * Generate a short random trace ID to correlate all audit steps for a single + * tool invocation: propose → policy_decision → [hitl] → execute → observe. + */ +export function generateTraceId(): string { + return randomBytes(8).toString('hex') +} + +export interface GateToolCallInput { + tool: string + args: Record + governance: GovernanceConfig + sessionId?: string + chatId?: string + nodeId?: string + /** When true, skip writing the policy_decision audit entry (caller already audited). */ + skipAudit?: boolean + /** Trace ID to correlate this gate call with its surrounding propose/execute/observe entries. */ + traceId?: string +} + +export function gateToolCall(input: GateToolCallInput): PolicyDecision { + const { tool, args, governance, sessionId, chatId, nodeId, skipAudit, traceId } = input + const context = governance.context || {} + + const policy = loadPolicyFile(governance.policyPath) + const decision = evaluatePolicy(policy, tool, args, context) + + if (!skipAudit) { + appendAuditLog(governance.auditPath, { + traceId, + step: 'policy_decision', + tool, + args, + ruleId: decision.ruleId, + effect: decision.effect, + message: decision.message, + sessionId, + chatId, + nodeId + }) + } + + return decision +} + +// --------------------------------------------------------------------------- +// Audit helpers — one per ReAct loop step +// --------------------------------------------------------------------------- + +/** + * Write a 'propose' entry when the agent first decides to call a tool. + * Returns the traceId that must be threaded through all subsequent steps + * for this tool invocation. + */ +export function auditPropose(input: GateToolCallInput): string { + const traceId = input.traceId ?? generateTraceId() + appendAuditLog(input.governance.auditPath, { + traceId, + step: 'propose', + tool: input.tool, + args: input.args, + sessionId: input.sessionId, + chatId: input.chatId, + nodeId: input.nodeId + }) + return traceId +} + +/** + * Write a 'hitl' entry after a human approves or rejects an escalated tool call. + * Captures the original args, any reviewer-supplied modifications, and optional feedback. + */ +export function auditHitl( + governance: GovernanceConfig, + tool: string, + args: Record, + humanDecision: string, + meta?: { + sessionId?: string + chatId?: string + nodeId?: string + ruleId?: string + traceId?: string + originalArgs?: Record + modifiedArgs?: Record + feedback?: string + } +): void { + appendAuditLog(governance.auditPath, { + traceId: meta?.traceId, + step: 'hitl', + tool, + args, + humanDecision, + ruleId: meta?.ruleId, + originalArgs: meta?.originalArgs, + modifiedArgs: meta?.modifiedArgs, + feedback: meta?.feedback, + sessionId: meta?.sessionId, + chatId: meta?.chatId, + nodeId: meta?.nodeId + }) +} + +/** + * Write an 'execute' entry immediately before the tool is invoked. + */ +export function auditExecute( + governance: GovernanceConfig, + tool: string, + args: Record, + observation: unknown, + meta?: { sessionId?: string; chatId?: string; nodeId?: string; traceId?: string } +): void { + appendAuditLog(governance.auditPath, { + traceId: meta?.traceId, + step: 'execute', + tool, + args, + observation: truncateObservation(observation), + sessionId: meta?.sessionId, + chatId: meta?.chatId, + nodeId: meta?.nodeId + }) +} + +/** + * Write an 'observe' entry after the LLM has processed the tool result. + * This closes the loop: propose → policy_decision → [hitl] → execute → observe. + */ +export function auditObserve( + governance: GovernanceConfig, + tool: string, + observation: unknown, + meta?: { sessionId?: string; chatId?: string; nodeId?: string; traceId?: string } +): void { + appendAuditLog(governance.auditPath, { + traceId: meta?.traceId, + step: 'observe', + tool, + observation: truncateObservation(observation), + sessionId: meta?.sessionId, + chatId: meta?.chatId, + nodeId: meta?.nodeId + }) +} + +/** + * Write a 'session_start' entry at the beginning of an agent run. + * Brackets the full run so audit consumers can group all tool invocations + * by session without relying on sessionId alone. + */ +export function auditSessionStart( + governance: GovernanceConfig, + input: string, + meta?: { sessionId?: string; chatId?: string; nodeId?: string } +): void { + appendAuditLog(governance.auditPath, { + step: 'session_start', + input: truncateObservation(input, 1000), + sessionId: meta?.sessionId, + chatId: meta?.chatId, + nodeId: meta?.nodeId + }) +} + +/** + * Write a 'session_end' entry at the end of an agent run. + * Records the final response and total tool call count for the session. + */ +export function auditSessionEnd( + governance: GovernanceConfig, + output: string, + toolCallCount: number, + meta?: { sessionId?: string; chatId?: string; nodeId?: string } +): void { + appendAuditLog(governance.auditPath, { + step: 'session_end', + output: truncateObservation(output, 1000), + toolCallCount, + sessionId: meta?.sessionId, + chatId: meta?.chatId, + nodeId: meta?.nodeId + }) +} + +/** + * Build a GovernanceEvent suitable for streaming to the chat UI via SSE. + * This makes every policy decision a first-class UI artifact, not just a log line. + */ +export function buildGovernanceEvent( + step: GovernanceEvent['step'], + traceId: string, + tool?: string, + args?: Record, + decision?: PolicyDecision, + humanDecision?: string, + feedback?: string +): GovernanceEvent { + return { + traceId, + step, + tool, + args, + effect: decision?.effect, + ruleId: decision?.ruleId, + message: decision?.message, + humanDecision, + feedback, + ts: new Date().toISOString() + } +} diff --git a/packages/components/src/governance/governedTool.ts b/packages/components/src/governance/governedTool.ts new file mode 100644 index 00000000000..5f595b3c720 --- /dev/null +++ b/packages/components/src/governance/governedTool.ts @@ -0,0 +1,142 @@ +import { RunnableConfig } from '@langchain/core/runnables' +import { Tool } from '@langchain/core/tools' +import { ICommonObject } from '../Interface' +import { gateToolCall } from './gate' +import { GovernanceConfig, POLICY_DENY_PREFIX } from './types' + +/** + * Wraps a Tool so that .call() cannot bypass governance when enabled. + * + * The Agent executor also gates before call — this is defense in depth. + * + * Escalation handling: + * - When called FROM the agent loop (Agent.ts handleToolCalls / handleResumedToolCalls), + * the loop intercepts escalations BEFORE calling .call(), so execution never reaches here + * with an unreviewed escalation. + * - When called OUTSIDE the agent loop (e.g. direct tool invocation in tests or custom code), + * an escalation is treated as a soft deny: the tool returns a message explaining that human + * approval is required. This prevents silent execution of escalation-class actions. + */ +export class GovernedTool extends Tool { + name: string + description: string + private inner: Tool + private governance: GovernanceConfig + private sessionId?: string + private chatId?: string + private nodeId?: string + /** + * When true, the caller (agent loop) has already obtained human approval for an + * escalated tool call. The GovernedTool will skip the escalation guard and execute. + * This flag is set transiently by the agent loop before calling .call(). + */ + humanApproved: boolean = false + + constructor(inner: Tool, governance: GovernanceConfig, meta?: { sessionId?: string; chatId?: string; nodeId?: string }) { + super() + this.inner = inner + this.name = inner.name + this.description = inner.description + this.governance = governance + this.sessionId = meta?.sessionId + this.chatId = meta?.chatId + this.nodeId = meta?.nodeId + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((inner as any).returnDirect !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(this as any).returnDirect = (inner as any).returnDirect + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((inner as any).requiresHumanInput !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(this as any).requiresHumanInput = (inner as any).requiresHumanInput + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((inner as any).agentSelectedTool !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(this as any).agentSelectedTool = (inner as any).agentSelectedTool + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected async _call(arg: any): Promise { + const args = (arg || {}) as Record + const decision = gateToolCall({ + tool: this.name, + args, + governance: this.governance, + sessionId: this.sessionId, + chatId: this.chatId, + nodeId: this.nodeId ?? (this.governance.context?.nodeId as string | undefined), + skipAudit: true // Agent.ts already audits; this is defense-in-depth + }) + + if (decision.effect === 'deny') { + return POLICY_DENY_PREFIX + decision.message + } + + if (decision.effect === 'escalate' && !this.humanApproved) { + // Called outside the agent loop without prior human approval. + // Treat as a soft deny to prevent silent execution of escalation-class actions. + return ( + POLICY_DENY_PREFIX + + `[ESCALATION REQUIRED] ${decision.message} ` + + `(rule: ${decision.ruleId}). Human approval is required before this tool can execute.` + ) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const innerAny = this.inner as any + if (typeof innerAny._call === 'function') { + return innerAny._call(arg) + } + return this.inner.invoke(arg) as Promise + } + + async call( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arg: any, + configArg?: RunnableConfig, + tags?: string[], + flowConfig?: { sessionId?: string; chatId?: string; input?: string; state?: ICommonObject } + ): Promise { + const args = (arg || {}) as Record + const decision = gateToolCall({ + tool: this.name, + args, + governance: this.governance, + sessionId: this.sessionId ?? flowConfig?.sessionId, + chatId: this.chatId ?? flowConfig?.chatId, + nodeId: this.nodeId ?? (this.governance.context?.nodeId as string | undefined), + skipAudit: true // Agent.ts already audits; this is defense-in-depth + }) + + if (decision.effect === 'deny') { + return POLICY_DENY_PREFIX + decision.message + } + + if (decision.effect === 'escalate' && !this.humanApproved) { + // Called outside the agent loop without prior human approval. + return ( + POLICY_DENY_PREFIX + + `[ESCALATION REQUIRED] ${decision.message} ` + + `(rule: ${decision.ruleId}). Human approval is required before this tool can execute.` + ) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const innerAny = this.inner as any + if (typeof innerAny.call === 'function') { + return innerAny.call(arg, configArg, tags, flowConfig) + } + return this.inner.invoke(arg, configArg as RunnableConfig) as Promise + } +} + +export function wrapToolWithGovernance( + tool: Tool, + governance: GovernanceConfig, + meta?: { sessionId?: string; chatId?: string; nodeId?: string } +): Tool { + return new GovernedTool(tool, governance, meta) as unknown as Tool +} diff --git a/packages/components/src/governance/index.ts b/packages/components/src/governance/index.ts new file mode 100644 index 00000000000..3acca631d4a --- /dev/null +++ b/packages/components/src/governance/index.ts @@ -0,0 +1,6 @@ +export * from './types' +export * from './policyLoader' +export * from './policyEvaluator' +export * from './auditLogger' +export * from './gate' +export * from './governedTool' diff --git a/packages/components/src/governance/policyEvaluator.ts b/packages/components/src/governance/policyEvaluator.ts new file mode 100644 index 00000000000..87c18d94fd5 --- /dev/null +++ b/packages/components/src/governance/policyEvaluator.ts @@ -0,0 +1,257 @@ +import { GovernanceContext, PolicyDecision, PolicyFile, PolicyRule, PolicyCondition } from './types' + +const DEFAULT_ALLOW: PolicyDecision = { + effect: 'allow', + ruleId: 'default-allow', + message: 'No matching policy rule; allowed by default.' +} + +function getValueAtPath(obj: Record, dotPath: string): unknown { + const parts = dotPath.split('.') + let current: unknown = obj + for (const part of parts) { + if (current === null || current === undefined || typeof current !== 'object') { + return undefined + } + current = (current as Record)[part] + } + return current +} + +/** + * Attempt to extract structured key/value pairs from a free-form text string. + * + * Handles the formats LLMs commonly produce when a tool schema only exposes a + * single `input` parameter: + * + * 1. JSON object string {"to":"a@b.com","subject":"Hi"} + * 2. Email-style headers To: a@b.com\nSubject: Hi\n\nBody text + * 3. key: value lines to: a@b.com\nsubject: Hi\nbody: text + * 4. key=value pairs to=a@b.com;subject=Hi;body=text + * 5. key=value semicolon/comma to=a@b.com, subject=Hi, body=text + * + * Returns a map of lowercased keys → string values, or null if no structured + * data could be extracted (fewer than 2 key/value pairs found). + */ +function parseTextInput(input: string): Record | null { + // 1. JSON object + try { + const parsed = JSON.parse(input) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + } catch { + // not JSON — continue + } + + const result: Record = {} + + // 2 & 3. Line-based "Key: Value" or "key: value" (covers email headers too). + // Lines with no colon after the first blank line are treated as the body. + const lines = input.split(/\r?\n/) + let bodyLines: string[] = [] + let inBody = false + let lineMatches = 0 + + for (const line of lines) { + if (inBody) { + bodyLines.push(line) + continue + } + if (line.trim() === '') { + // Blank line separates headers from body (email convention) + inBody = true + continue + } + const colonIdx = line.indexOf(':') + if (colonIdx > 0) { + const key = line.slice(0, colonIdx).trim().toLowerCase() + const val = line.slice(colonIdx + 1).trim() + // Only treat as a header if the key looks like a word (no spaces) + if (/^\w+$/.test(key)) { + result[key] = val + lineMatches++ + } else { + // Doesn't look like a header — treat rest as body + inBody = true + bodyLines.push(line) + } + } else { + // No colon — treat rest as body + inBody = true + bodyLines.push(line) + } + } + + if (bodyLines.length > 0 && !result['body']) { + result['body'] = bodyLines.join('\n').trim() + } + + if (lineMatches >= 1) { + return result + } + + // 4 & 5. key=value pairs separated by semicolons or commas + // e.g. "to=a@b.com;subject=Hi;body=text" + const kvResult: Record = {} + let kvMatches = 0 + const segments = input.split(/[;,]/) + for (const seg of segments) { + const eqIdx = seg.indexOf('=') + if (eqIdx > 0) { + const key = seg.slice(0, eqIdx).trim().toLowerCase() + const val = seg.slice(eqIdx + 1).trim() + if (/^\w+$/.test(key)) { + kvResult[key] = val + kvMatches++ + } + } + } + if (kvMatches >= 2) { + return kvResult + } + + return null +} + +/** + * If args only has a single "input" key, attempt to parse its value into + * structured fields and merge them into args so that policy conditions like + * `args.to` or `args.amount` can match even when the LLM serializes all + * parameters into a single string. + * + * Handles JSON objects, email-style headers, key:value lines, and key=value + * pairs — the four formats LLMs most commonly produce. + */ +function flattenJsonInput(args: Record): Record { + const keys = Object.keys(args) + if (keys.length === 1 && keys[0] === 'input' && typeof args.input === 'string') { + const parsed = parseTextInput(args.input) + if (parsed) { + return { ...args, ...parsed } + } + } + return args +} + +/** + * Match a tool name against a pattern. + * "*" — matches everything + * "send_*" — prefix wildcard (trailing * only) + * exact — original behaviour + */ +function toolNameMatches(pattern: string, toolName: string): boolean { + if (pattern === '*') return true + if (pattern.endsWith('*')) { + return toolName.startsWith(pattern.slice(0, -1)) + } + return pattern === toolName +} + +function evaluateCondition(condition: PolicyCondition, args: Record, context: GovernanceContext): boolean { + const flatArgs = flattenJsonInput(args) + let value: unknown + if (condition.path.startsWith('args.')) { + value = getValueAtPath({ args: flatArgs }, condition.path) + } else if (condition.path.startsWith('context.')) { + value = getValueAtPath({ context }, condition.path) + } else { + value = getValueAtPath({ args: flatArgs, context }, condition.path) + } + + const expected = condition.value + + switch (condition.op) { + case 'eq': + return value === expected + case 'neq': + return value !== expected + case 'gt': { + const numVal = typeof value === 'number' ? value : Number(value) + const numExp = typeof expected === 'number' ? expected : Number(expected) + return !isNaN(numVal) && !isNaN(numExp) && numVal > numExp + } + case 'gte': { + const numVal = typeof value === 'number' ? value : Number(value) + const numExp = typeof expected === 'number' ? expected : Number(expected) + return !isNaN(numVal) && !isNaN(numExp) && numVal >= numExp + } + case 'lt': { + const numVal = typeof value === 'number' ? value : Number(value) + const numExp = typeof expected === 'number' ? expected : Number(expected) + return !isNaN(numVal) && !isNaN(numExp) && numVal < numExp + } + case 'lte': { + const numVal = typeof value === 'number' ? value : Number(value) + const numExp = typeof expected === 'number' ? expected : Number(expected) + return !isNaN(numVal) && !isNaN(numExp) && numVal <= numExp + } + case 'contains': + return typeof value === 'string' && typeof expected === 'string' && value.includes(expected) + case 'not-contains': + return typeof value === 'string' && typeof expected === 'string' && !value.includes(expected) + case 'starts-with': + return typeof value === 'string' && typeof expected === 'string' && value.startsWith(expected) + case 'regex': { + if (typeof value !== 'string' || typeof expected !== 'string') return false + try { + return new RegExp(expected).test(value) + } catch { + // Malformed regex in policy — treat as no-match rather than crashing + console.warn(`[Governance] Invalid regex in policy condition: ${expected}`) + return false + } + } + default: + return false + } +} + +function ruleMatches(rule: PolicyRule, toolName: string, args: Record, context: GovernanceContext): boolean { + if (!toolNameMatches(rule.match.tool, toolName)) { + return false + } + // when: all conditions must match (AND) + if (rule.when && rule.when.length > 0) { + if (!rule.when.every((c) => evaluateCondition(c, args, context))) { + return false + } + } + // anyOf: at least one condition must match (OR) + if (rule.anyOf && rule.anyOf.length > 0) { + if (!rule.anyOf.some((c) => evaluateCondition(c, args, context))) { + return false + } + } + return true +} + +function ruleToDecision(rule: PolicyRule): PolicyDecision { + return { + effect: rule.effect, + ruleId: rule.id, + message: rule.message || `Policy rule "${rule.id}" (${rule.effect}).` + } +} + +/** + * Evaluate rules in file order; first match wins. + * Place more specific rules before broader ones — e.g. an allow for a trusted + * domain before a deny-all, or a specific tool rule before a wildcard catch-all. + */ +export function evaluatePolicy( + policy: PolicyFile, + toolName: string, + args: Record, + context: GovernanceContext = {} +): PolicyDecision { + const normalizedArgs = (args || {}) as Record + + for (const rule of policy.rules) { + if (ruleMatches(rule, toolName, normalizedArgs, context)) { + return ruleToDecision(rule) + } + } + + return DEFAULT_ALLOW +} diff --git a/packages/components/src/governance/policyLoader.ts b/packages/components/src/governance/policyLoader.ts new file mode 100644 index 00000000000..2cf51b5bc28 --- /dev/null +++ b/packages/components/src/governance/policyLoader.ts @@ -0,0 +1,34 @@ +import * as fs from 'fs' +import { PolicyFile } from './types' + +const policyCache = new Map() + +export function loadPolicyFile(policyPath: string): PolicyFile { + if (!fs.existsSync(policyPath)) { + throw new Error(`Policy file not found: ${policyPath}`) + } + + const stat = fs.statSync(policyPath) + const cached = policyCache.get(policyPath) + if (cached && cached.mtimeMs === stat.mtimeMs) { + return cached.policy + } + + const raw = fs.readFileSync(policyPath, 'utf8') + const parsed = JSON.parse(raw) as PolicyFile + + if (!parsed.rules || !Array.isArray(parsed.rules)) { + throw new Error(`Invalid policy file: ${policyPath} — expected { "rules": [...] }`) + } + + if (parsed.version !== undefined && typeof parsed.version !== 'string') { + throw new Error(`Invalid policy file: ${policyPath} — "version" must be a string`) + } + + policyCache.set(policyPath, { mtimeMs: stat.mtimeMs, policy: parsed }) + return parsed +} + +export function clearPolicyCache(): void { + policyCache.clear() +} diff --git a/packages/components/src/governance/types.ts b/packages/components/src/governance/types.ts new file mode 100644 index 00000000000..9e7ddc3d38b --- /dev/null +++ b/packages/components/src/governance/types.ts @@ -0,0 +1,100 @@ +export type PolicyEffect = 'allow' | 'deny' | 'escalate' + +export type PolicyOp = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'not-contains' | 'starts-with' | 'regex' + +export interface PolicyCondition { + path: string + op: PolicyOp + value: unknown +} + +export interface PolicyRule { + id: string + effect: PolicyEffect + match: { + tool: string + } + message?: string + when?: PolicyCondition[] // all conditions must match (AND) + anyOf?: PolicyCondition[] // at least one condition must match (OR) +} + +export interface PolicyFile { + version?: string + rules: PolicyRule[] +} + +export interface GovernanceContext { + user?: string + environment?: string + [key: string]: unknown +} + +export interface GovernanceConfig { + policyPath: string + auditPath: string + context?: GovernanceContext +} + +/** Session/node identifiers threaded through audit and gate calls. */ +export interface GovernanceMeta { + sessionId?: string + chatId?: string + nodeId?: string +} + +export interface PolicyDecision { + effect: PolicyEffect + ruleId: string + message: string +} + +export type AuditStep = 'session_start' | 'session_end' | 'propose' | 'policy_decision' | 'hitl' | 'execute' | 'observe' + +export interface AuditEntry { + ts: string + /** Unique identifier correlating all steps of a single tool invocation (propose→policy_decision→[hitl]→execute→observe). */ + traceId?: string + step: AuditStep + tool?: string + args?: Record + /** For hitl steps: the original args before any reviewer modification. */ + originalArgs?: Record + /** For hitl steps: the reviewer-supplied arg overrides (undefined if no changes). */ + modifiedArgs?: Record + ruleId?: string + effect?: PolicyEffect + message?: string + humanDecision?: string + /** Free-text feedback from the human reviewer (optional). */ + feedback?: string + sessionId?: string + chatId?: string + nodeId?: string + observation?: string + /** For session_start / session_end steps: the user's input query. */ + input?: string + /** For session_end steps: the final agent response. */ + output?: string + /** For session_end steps: total tool calls made in this session. */ + toolCallCount?: number +} + +/** + * A governance event surfaced to the UI via SSE so the chat interface can + * render policy decisions as first-class artifacts rather than log lines. + */ +export interface GovernanceEvent { + traceId: string + step: AuditStep + tool?: string + args?: Record + effect?: PolicyEffect + ruleId?: string + message?: string + humanDecision?: string + feedback?: string + ts: string +} + +export const POLICY_DENY_PREFIX = '[POLICY_DENIED] ' diff --git a/packages/server/bin/audit.jsonl b/packages/server/bin/audit.jsonl new file mode 100644 index 00000000000..ce6217e7aad --- /dev/null +++ b/packages/server/bin/audit.jsonl @@ -0,0 +1 @@ +{"ts":"2026-05-22T06:50:47.890Z","step":"propose","tool":"get_weather","args":{"input":"Mumbai"},"sessionId":"ce852016-369b-4c5d-8b95-f9671f86e6e1","chatId":"ce852016-369b-4c5d-8b95-f9671f86e6e1","nodeId":"agentAgentflow_0"} diff --git a/packages/server/src/utils/buildAgentflow.ts b/packages/server/src/utils/buildAgentflow.ts index e38ccbec092..56904da78cb 100644 --- a/packages/server/src/utils/buildAgentflow.ts +++ b/packages/server/src/utils/buildAgentflow.ts @@ -1454,6 +1454,10 @@ const executeNode = async ({ // Stop going through the current route if the node is a agent node waiting for human input before using the tool if (reactFlowNode.data.name === 'agentAgentflow' && results?.output?.isWaitingForHumanInput) { + // Extract the pending tool call args so the reviewer can inspect and optionally edit them + const pendingToolCalls = results?.output?.pendingToolCalls as Array<{ name: string; args: Record }> | undefined + const pendingArgsJson = pendingToolCalls?.length ? JSON.stringify(pendingToolCalls[0].args, null, 2) : '' + const humanInputAction = { id: uuidv4(), mapping: { @@ -1461,6 +1465,21 @@ const executeNode = async ({ reject: 'Reject' }, elements: [ + /** + * Governance: the reviewer can type a plain-text instruction here. + * If left empty, the tool executes with its original args (approve as-is). + * If a string is provided, the agent loop discards the pending tool call + * and re-invokes the LLM with the instruction as a new user message, + * restarting reasoning from that point. + */ + { + type: 'agentflowv2-text-input', + label: 'Instructions (optional) — leave empty to approve as-is, or type a new instruction to redirect the agent', + name: 'modifiedArgs', + placeholder: `Pending tool call:\n${pendingArgsJson}\n\nType an instruction to redirect the agent, or leave empty to proceed.`, + default: '', + optional: true + }, { type: 'agentflowv2-approve-button', label: 'Proceed' }, { type: 'agentflowv2-reject-button', label: 'Reject' } ], diff --git a/packages/ui/src/views/chatmessage/ChatMessage.jsx b/packages/ui/src/views/chatmessage/ChatMessage.jsx index bf55a262022..0ab21c6bb28 100644 --- a/packages/ui/src/views/chatmessage/ChatMessage.jsx +++ b/packages/ui/src/views/chatmessage/ChatMessage.jsx @@ -267,6 +267,7 @@ const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, previews, setP const [feedback, setFeedback] = useState('') const [pendingActionData, setPendingActionData] = useState(null) const [feedbackType, setFeedbackType] = useState('') + const [modifiedArgs, setModifiedArgs] = useState('') // start input type const [startInputType, setStartInputType] = useState('') @@ -868,11 +869,17 @@ const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, previews, setP fbType = type } const question = feedback ? feedback : fbType.charAt(0).toUpperCase() + fbType.slice(1) - handleSubmit(undefined, question, undefined, { + const humanInputPayload = { type: fbType, startNodeId: actionData?.nodeId, feedback - }) + } + // Include modifiedArgs if the reviewer edited the text-input widget + if (fbType === 'proceed' && modifiedArgs.trim()) { + humanInputPayload.modifiedArgs = modifiedArgs.trim() + } + setModifiedArgs('') + handleSubmit(undefined, question, undefined, humanInputPayload) } const handleSubmitFeedback = () => { @@ -886,6 +893,9 @@ const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, previews, setP } const handleActionClick = async (elem, action) => { + // agentflowv2-text-input is a data-entry widget, not a button — skip it + if (elem.type === 'agentflowv2-text-input') return + setUserInput(elem.label) setMessages((prevMessages) => { let allMessages = [...cloneDeep(prevMessages)] @@ -2799,8 +2809,38 @@ const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, previews, setP {(message.action.elements || []).map((elem, index) => { return ( <> - {(elem.type === 'approve-button' && elem.label === 'Yes') || - elem.type === 'agentflowv2-approve-button' ? ( + {elem.type === 'agentflowv2-text-input' ? ( +
+ {elem.label && ( +
+ {elem.label} +
+ )} +