From d56c27c38863f94ec751084acbc8215a91e1e4ad Mon Sep 17 00:00:00 2001 From: Timmatt112 Date: Tue, 1 Sep 2026 19:41:11 +0000 Subject: [PATCH] feat: policy simulation sandbox for dry-running spending rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a policy simulation sandbox modal that lets administrators dry-run proposed spending rules against custom transactions before enforcing them on-chain, preventing misconfigured rules from locking agent funds. - policySimulation.ts: pure first-match evaluation engine over the Zod-validated PolicyRule clauses, returning allowed/flagged/rejected verdicts with an ordered per-clause breakdown - PolicySimulationModal.tsx: Zod-validated simulation form (amount, asset, recipient, agent tags) inside the accessible Dialog primitive (focus trap, Esc-to-close), verdict banner with polite live-region announcement and step-by-step evaluation breakdown - Exported via the policies feature index 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .../policies/PolicySimulationModal.tsx | 383 ++++++++++++++++++ src/features/policies/index.ts | 2 + src/features/policies/policySimulation.ts | 183 +++++++++ 3 files changed, 568 insertions(+) create mode 100644 src/features/policies/PolicySimulationModal.tsx create mode 100644 src/features/policies/policySimulation.ts diff --git a/src/features/policies/PolicySimulationModal.tsx b/src/features/policies/PolicySimulationModal.tsx new file mode 100644 index 0000000..a67db13 --- /dev/null +++ b/src/features/policies/PolicySimulationModal.tsx @@ -0,0 +1,383 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { + ArrowRight, + CheckCircle2, + Flag, + FlaskConical, + PlayCircle, + ShieldAlert, + ShieldCheck, +} from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Dialog } from '@/components/ui/dialog'; +import { FormField, Input, Select } from '@/components/ui/input'; +import { isValidStellarPublicKey } from '@/stores/freighter-store'; +import { cn } from '@/lib/cn'; +import { + simulateTransaction, + type EvaluatedClause, + type PolicySimulationOutput, + type SimulationVerdict, +} from './policySimulation'; +import { ruleActionOptions, ruleFieldOptions, ruleOperatorOptions, type PolicyRule } from './rulesSchema'; +import type { AssetSymbol } from './types'; + +/** Default clause set used when no builder rules are supplied. */ +export const defaultSimulationRules: PolicyRule[] = [ + { + field: 'Transaction Amount', + operator: 'greater_than', + value: '10000', + action: 'block', + }, + { + field: 'Transaction Amount', + operator: 'greater_than', + value: '2500', + action: 'require_approval', + }, + { + field: 'Approved Account Whitelist', + operator: 'in_whitelist', + value: 'GCGN7K2J2L5V4D7C7Y3M4KXH2Q5TK5A4P3W6QJDS4J2W5M5WQ4R5M, GDR5A5W4M7Z3H5Q7Q2J4W7C6QX3A9Y5K7D3V2L7S5Y5M3F4Q7B7', + action: 'allow', + }, + { + field: 'Destination Target', + operator: 'contains', + value: 'exchange', + action: 'flag', + }, +]; + +const ASSET_OPTIONS: AssetSymbol[] = ['XLM', 'USDC', 'BTC', 'ETH', 'EURC']; + +const simulationFormSchema = z.object({ + amount: z.coerce + .number({ invalid_type_error: 'Amount must be a number.' }) + .min(0, 'Amount cannot be negative.'), + asset: z.enum(['XLM', 'USDC', 'BTC', 'ETH', 'EURC']), + recipient: z + .string() + .trim() + .min(1, 'Recipient address is required.') + .refine( + (value) => isValidStellarPublicKey(value), + 'Recipient must be a valid Stellar public key (G…, 56 characters).', + ), + agentTags: z.string().trim(), +}); + +export type SimulationFormValues = z.infer; + +const verdictMeta: Record< + SimulationVerdict, + { label: string; badgeVariant: 'success' | 'warning' | 'danger'; icon: React.ReactNode; srText: string } +> = { + allowed: { + label: 'Allowed', + badgeVariant: 'success', + icon: , + srText: 'This transaction would be allowed by the current rule set.', + }, + flagged: { + label: 'Flagged', + badgeVariant: 'warning', + icon: , + srText: 'This transaction would be flagged for manual review.', + }, + rejected: { + label: 'Rejected', + badgeVariant: 'danger', + icon: , + srText: 'This transaction would be rejected and blocked on-chain.', + }, +}; + +function ClauseRow({ clause }: { clause: EvaluatedClause }) { + const matched = clause.outcome === 'matched'; + return ( +
  • + + {matched ? : } + +
    +

    + #{clause.index + 1} + If {clause.rule.field} {clause.rule.operator.replace(/_/g, ' ')}{' '} + {clause.rule.value} then{' '} + {clause.rule.action.replace(/_/g, ' ')} +

    +

    {clause.detail}

    +
    + + {matched ? 'Matched' : 'No match'} + +
  • + ); +} + +export interface PolicySimulationModalProps { + open: boolean; + onClose: () => void; + /** Clause set to dry-run against; defaults to the sample ruleset. */ + rules?: PolicyRule[]; +} + +/** + * Policy simulation sandbox — dry-runs a proposed transaction against policy + * clauses before enforcement. Renders inside the accessible `Dialog` + * primitive (portal, focus trap, Esc-to-close) and announces verdicts via a + * polite live region for screen readers. + */ +export function PolicySimulationModal({ open, onClose, rules = defaultSimulationRules }: PolicySimulationModalProps) { + const [result, setResult] = useState(null); + const [submittedTx, setSubmittedTx] = useState(null); + + const form = useForm({ + resolver: zodResolver(simulationFormSchema), + defaultValues: { + amount: 1200, + asset: 'USDC', + recipient: 'GCGN7K2J2L5V4D7C7Y3M4KXH2Q5TK5A4P3W6QJDS4J2W5M5WQ4R5M', + agentTags: 'treasury, routine-payout', + }, + }); + + const handleRun = (values: SimulationFormValues) => { + const output = simulateTransaction( + { + amount: values.amount, + asset: values.asset, + recipient: values.recipient, + agentTags: values.agentTags + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean), + }, + rules, + ); + setSubmittedTx(values); + setResult(output); + }; + + const handleClose = () => { + onClose(); + }; + + const verdict = result ? verdictMeta[result.verdict] : null; + const summaryText = useMemo(() => { + if (!result || !submittedTx) return ''; + return `${submittedTx.amount} ${submittedTx.asset} to ${submittedTx.recipient.slice(0, 6)}… — ${result.summary}`; + }, [result, submittedTx]); + + return ( + +
    +
    + + + + + + + +
    + + + + + + + + + +
    + + +
    +
    + + {result && verdict && ( +
    +
    + + {result.verdict === 'allowed' ? ( + + ) : result.verdict === 'flagged' ? ( + + ) : ( + + )} + +
    +
    + + {verdict.icon} + {verdict.label} + + {verdict.srText} +
    +

    {result.summary}

    +
    +
    + + {/* Polite live region so screen readers hear the outcome after each run. */} +

    + {summaryText} +

    + +
    +

    + + Evaluation breakdown — {result.clauses.length} clause + {result.clauses.length === 1 ? '' : 's'} checked +

    + {result.clauses.length > 0 ? ( +
      + {result.clauses.map((clause) => ( + + ))} +
    + ) : ( +

    + No clauses are configured, so the transaction is allowed by default. +

    + )} +
    + +

    + Simulation only — no transaction was signed or submitted. Clauses are evaluated in + order; the most severe matched action determines the verdict. +

    +
    + )} +
    + ); +} + +/** Button + modal pair that can be dropped anywhere policy rules are managed. */ +export function PolicySimulationSandbox({ rules }: { rules?: PolicyRule[] }) { + const [open, setOpen] = useState(false); + + return ( + <> + + setOpen(false)} rules={rules} /> + + ); +} + +export default PolicySimulationModal; + +// Re-exported for consumers building custom sandbox tooling on top of the modal. +export { ruleFieldOptions, ruleOperatorOptions, ruleActionOptions }; diff --git a/src/features/policies/index.ts b/src/features/policies/index.ts index 5add5f7..6295b7c 100644 --- a/src/features/policies/index.ts +++ b/src/features/policies/index.ts @@ -1,3 +1,5 @@ export * from './BudgetSimulator'; export * from './types'; export { default as BudgetSimulator } from './BudgetSimulator'; +export * from './policySimulation'; +export { default as PolicySimulationModal, PolicySimulationSandbox, defaultSimulationRules } from './PolicySimulationModal'; diff --git a/src/features/policies/policySimulation.ts b/src/features/policies/policySimulation.ts new file mode 100644 index 0000000..e7b1f5c --- /dev/null +++ b/src/features/policies/policySimulation.ts @@ -0,0 +1,183 @@ +import type { AssetSymbol } from './types'; +import type { PolicyRule, RuleAction, RuleField, RuleOperator } from './rulesSchema'; + +/** + * Policy simulation sandbox — pure evaluation helpers. + * + * Dry-runs a proposed agent transaction against a list of {@link PolicyRule} + * clauses (the same Zod-validated shape produced by `rulesSchema.ts`) and + * returns a verdict (`allowed` | `flagged` | `rejected`) together with a + * step-by-step evaluation breakdown for display in the sandbox modal. + * + * This module is intentionally free of React/DOM dependencies so it can be + * unit-tested and later reused by server-side policy previews. + */ + +export type SimulationVerdict = 'allowed' | 'flagged' | 'rejected'; + +export type ClauseOutcome = 'pass' | 'matched' | 'skipped'; + +/** Proposed transaction under test. */ +export interface SimulationTransaction { + /** Amount in the asset's smallest display unit (e.g. XLM, USDC). */ + amount: number; + asset: AssetSymbol; + /** Stellar destination public key (G...). */ + recipient: string; + /** Free-form agent tags used by `Destination Target` contains-rules. */ + agentTags: string[]; +} + +/** A single evaluated clause in the ordered breakdown. */ +export interface EvaluatedClause { + index: number; + rule: PolicyRule; + outcome: ClauseOutcome; + /** Human-readable explanation of what the engine decided and why. */ + detail: string; +} + +export interface PolicySimulationOutput { + verdict: SimulationVerdict; + /** Short human-readable summary of the verdict. */ + summary: string; + /** Ordered per-clause breakdown, in rule evaluation order. */ + clauses: EvaluatedClause[]; + /** Indices into `clauses` whose action fired on this transaction. */ + matchedClauseIndices: number[]; +} + +/** Whitelist entries are comma-separated in rule values, mirroring the builder UI. */ +function parseWhitelist(value: string): string[] { + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function caseInsensitiveEquals(a: string, b: string): boolean { + return a.trim().toLowerCase() === b.trim().toLowerCase(); +} + +/** Evaluate a single comparison; `undefined` means "operator did not match". */ +function compareValues(actual: string, operator: RuleOperator, expected: string): boolean { + switch (operator) { + case 'equals': + return caseInsensitiveEquals(actual, expected); + case 'contains': + return actual.toLowerCase().includes(expected.trim().toLowerCase()); + case 'in_whitelist': + return parseWhitelist(expected).some((entry) => caseInsensitiveEquals(actual, entry)); + case 'greater_than': + case 'less_than': { + const actualNumber = Number(actual); + const expectedNumber = Number(expected); + if (Number.isNaN(actualNumber) || Number.isNaN(expectedNumber)) return false; + return operator === 'greater_than' + ? actualNumber > expectedNumber + : actualNumber < expectedNumber; + } + default: + return false; + } +} + +/** Project the transaction onto the field a rule inspects. */ +function resolveFieldValue(field: RuleField, tx: SimulationTransaction): string { + switch (field) { + case 'Transaction Amount': + return String(tx.amount); + case 'Asset Identifier': + return tx.asset; + case 'Destination Target': + return tx.recipient; + case 'Approved Account Whitelist': + // The whitelist rule inspects the recipient against the rule's value list. + return tx.recipient; + default: + return ''; + } +} + +function actionToVerdict(action: RuleAction): SimulationVerdict { + switch (action) { + case 'allow': + return 'allowed'; + case 'flag': + return 'flagged'; + case 'block': + return 'rejected'; + case 'require_approval': + return 'flagged'; + default: + return 'flagged'; + } +} + +function describeComparison( + field: RuleField, + operator: RuleOperator, + value: string, + actual: string, +): string { + const operatorLabel = operator.replace(/_/g, ' '); + return `Checked "${field}" (actual: ${actual || '∅'}) ${operatorLabel} "${value}".`; +} + +/** + * Dry-run `tx` against `rules` in order. First matching clause wins, mirroring + * firewall-style first-match policy engines; unmatched clauses are reported as + * `pass` so the UI can show the full evaluation trail. + */ +export function simulateTransaction( + tx: SimulationTransaction, + rules: PolicyRule[], +): PolicySimulationOutput { + const clauses: EvaluatedClause[] = []; + const matchedClauseIndices: number[] = []; + let verdict: SimulationVerdict = 'allowed'; + + rules.forEach((rule, index) => { + const actual = resolveFieldValue(rule.field, tx); + const matched = compareValues(actual, rule.operator, rule.value); + + if (!matched) { + clauses.push({ + index, + rule, + outcome: 'pass', + detail: `${describeComparison(rule.field, rule.operator, rule.value, actual)} No match — clause not triggered.`, + }); + return; + } + + matchedClauseIndices.push(index); + const clauseVerdict = actionToVerdict(rule.action); + // Escalate: allowed < flagged < rejected. + const severity: Record = { allowed: 0, flagged: 1, rejected: 2 }; + if (severity[clauseVerdict] > severity[verdict]) { + verdict = clauseVerdict; + } + + clauses.push({ + index, + rule, + outcome: 'matched', + detail: `${describeComparison(rule.field, rule.operator, rule.value, actual)} Match — action "${rule.action.replace(/_/g, ' ')}" applies.`, + }); + }); + + const summary = + verdict === 'allowed' + ? 'Transaction is allowed by the current rule set.' + : verdict === 'flagged' + ? 'Transaction would be flagged for manual review before enforcement.' + : 'Transaction would be rejected — enforcing this rule set would block it.'; + + return { verdict, summary, clauses, matchedClauseIndices }; +} + +/** Convenience constructor for a zero-amount edge-case transaction. */ +export function emptyTransaction(): SimulationTransaction { + return { amount: 0, asset: 'XLM', recipient: '', agentTags: [] }; +}