From 26c650f6be32057d77419262ebc9d3740d302194 Mon Sep 17 00:00:00 2001 From: Raushan Kumar <155806959+raushankcode@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:35:21 +0530 Subject: [PATCH] demo add in our tool --- README.md | 59 +-- docs/ripple-gate-demo.tape | 33 ++ package.json | 2 +- packages/cli/CHANGELOG.md | 13 + packages/cli/package.json | 4 +- packages/cli/src/index.ts | 668 +++++++++++++++++++++++++++-- packages/core/CHANGELOG.md | 11 + packages/core/package.json | 2 +- packages/core/src/audit.ts | 1 + packages/core/src/change-intent.ts | 70 ++- packages/core/src/cloud.ts | 53 ++- packages/core/src/risk.ts | 89 +++- packages/core/src/staged-check.ts | 84 +++- packages/mcp/package.json | 4 +- packages/mcp/src/server.ts | 5 +- scripts/build-vhs-gate-demo.js | 101 +++++ scripts/prepare-vhs-gate-demo.js | 89 ++++ tsconfig.json | 1 + 18 files changed, 1193 insertions(+), 96 deletions(-) create mode 100644 docs/ripple-gate-demo.tape create mode 100644 scripts/build-vhs-gate-demo.js create mode 100644 scripts/prepare-vhs-gate-demo.js diff --git a/README.md b/README.md index 16054c6..188a723 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ # Ripple -**A local authorization gate for AI coding agents.** +\*_A local authorization gate for AI coding agents._ + +run command for demo in your terminal + +```txt +npx @getripple/cli demo +``` Ripple is a local authorization gate for AI coding agents that defines what an agent may change, checks the real Git diff, and returns continue, repair, or @@ -42,6 +48,7 @@ The local CLI remains free and open-source. The cloud service provides the tampe [Learn more at ripple-cloud.vercel.app](https://ripple-cloud.vercel.app) --- + [![npm cli](https://img.shields.io/npm/v/@getripple/cli.svg)](https://www.npmjs.com/package/@getripple/cli) [![npm mcp](https://img.shields.io/npm/v/@getripple/mcp.svg)](https://www.npmjs.com/package/@getripple/mcp) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) @@ -183,14 +190,14 @@ the agent crosses the boundary, Ripple stops and gives a concrete review packet. Ripple is one local engine exposed through MCP, CLI, hooks, CI, and VS Code. -| Layer | What it means | -| --- | --- | -| Policy | Permanent repo rules in `.ripple/policy.json` | -| Intent | Temporary approved boundary for the current task | -| Git diff | The staged or changed files Ripple checks | -| MCP | Structured tools for AI agents | -| Hook | Local pre-commit gate before code enters history | -| CI | Pull request gate before merge | +| Layer | What it means | +| -------- | ------------------------------------------------ | +| Policy | Permanent repo rules in `.ripple/policy.json` | +| Intent | Temporary approved boundary for the current task | +| Git diff | The staged or changed files Ripple checks | +| MCP | Structured tools for AI agents | +| Hook | Local pre-commit gate before code enters history | +| CI | Pull request gate before merge | The model is intentionally small: @@ -205,13 +212,13 @@ Gate decides whether the agent may continue. Ripple stores the freedom level the agent was given before editing. -| Mode | Agent is allowed to | -| --- | --- | -| `brainstorm` | Suggest and explain only. No edits. | -| `function` | Edit only the approved symbol. | -| `file` | Edit only the approved file. | -| `task` | Edit files in the saved task plan. | -| `pr` | Complete low-risk PR work for human review before merge. | +| Mode | Agent is allowed to | +| ------------ | -------------------------------------------------------- | +| `brainstorm` | Suggest and explain only. No edits. | +| `function` | Edit only the approved symbol. | +| `file` | Edit only the approved file. | +| `task` | Edit files in the saved task plan. | +| `pr` | Complete low-risk PR work for human review before merge. | When an agent calls `ripple_plan_context`, it chooses one of these control modes and can save that boundary as the active local intent. @@ -295,12 +302,12 @@ Human reviews only when the boundary breaks. ## Interfaces -| Interface | Use it for | -| --- | --- | -| `@getripple/mcp` | Direct AI-agent access through MCP tools | -| `@getripple/cli` | Terminal, Git hooks, CI, local proofs | -| `@getripple/core` | Custom integrations | -| `rippleai.ripple` | Optional VS Code visual context | +| Interface | Use it for | +| ----------------- | ---------------------------------------- | +| `@getripple/mcp` | Direct AI-agent access through MCP tools | +| `@getripple/cli` | Terminal, Git hooks, CI, local proofs | +| `@getripple/core` | Custom integrations | +| `rippleai.ripple` | Optional VS Code visual context | ## Git Hooks @@ -361,10 +368,10 @@ whether to commit them. ## Language Support -| Language | Status | -| --- | --- | -| TypeScript / JavaScript | Deep support for imports, exports, symbols, callers, staged drift, and blast radius | -| Python | Basic support for imports, functions, classes, methods, and file-level staged checks | +| Language | Status | +| ----------------------- | ------------------------------------------------------------------------------------ | +| TypeScript / JavaScript | Deep support for imports, exports, symbols, callers, staged drift, and blast radius | +| Python | Basic support for imports, functions, classes, methods, and file-level staged checks | Ripple uses static analysis. It can miss runtime-only behavior, dynamic imports, reflection, decorators, generated code, and framework-specific magic. diff --git a/docs/ripple-gate-demo.tape b/docs/ripple-gate-demo.tape new file mode 100644 index 0000000..f92d9e2 --- /dev/null +++ b/docs/ripple-gate-demo.tape @@ -0,0 +1,33 @@ +# VHS tape that records REAL `ripple demo` output. +# +# This replaces the previous hand-drawn GIF renderer. Every frame produced here +# is genuine terminal output from the built CLI running the real engine against +# a real temporary git repository — including the pre-commit hook rejecting the +# rogue commit. Nothing in the recording is scripted or re-enacted. +# +# Build with: npm run demo:gif (see scripts/build-vhs-gate-demo.js) + +Output resources/ripple-gate-demo.gif + +Set Shell bash +Set FontSize 15 +Set Width 1200 +Set Height 820 +Set Padding 24 +Set Framerate 12 +Set Theme "Catppuccin Mocha" + +# The demo paces itself (it detects a TTY, which VHS provides), so the tape does +# not need Sleep directives between scenes — only a settle at each end. +Hide +Type "clear" +Enter +Show + +Sleep 800ms +Type "ripple demo" +Sleep 600ms +Enter + +# The demo's own pacing runs ~30s; wait past it, then hold on the final frame. +Sleep 40s diff --git a/package.json b/package.json index 11b0a40..0d7ccfe 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "ripple", "displayName": "Ripple — Local Authorization Gate", "description": "VS Code interface for Ripple's local authorization gate: live context, focus files, blast-radius signals, and safer AI-agent workflow prompts.", - "version": "1.0.13", + "version": "1.0.14", "publisher": "rippleai", "author": { "name": "Raushan Soni" diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 2287be8..9e01087 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,18 @@ # @getripple/cli Changelog +## [1.0.14] - 2026-07-21 + +### Fixed +- `ripple gate` no longer consumes the saved intent on a passing preview run; consumption now happens only in the post-commit hook, after a real commit. +- `ripple gate --json` no longer prints a trailing plain-text message after the JSON payload. +- `ripple demo --json` now honors `--json` instead of printing ANSI prose, and no longer calls `console.clear()` (which wiped terminal scrollback). +- Installing Ripple's git hooks now updates an existing hook block in place when its contents changed, instead of reporting "already-present" forever. +- `ripple demo` now runs against a real installed pre-commit hook and a real `git commit` for every scenario shown, including the blocked one, instead of narrating a result. + +### Added +- `ripple demo --publish`: opt-in publish of a demo run to Ripple Cloud, printing a public share link. +- Update package metadata to depend on `@getripple/core@^1.0.14`. + ## [1.0.9] - 2026-06-13 ### Changed diff --git a/packages/cli/package.json b/packages/cli/package.json index 0416160..5a4d1dc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getripple/cli", - "version": "1.0.13", + "version": "1.0.14-beta.0", "description": "CLI, Git hook, and CI enforcer for Ripple's local authorization gate for AI coding agents.", "license": "MIT", "type": "commonjs", @@ -44,7 +44,7 @@ "build": "tsc -p tsconfig.json" }, "dependencies": { - "@getripple/core": "^1.0.13" + "@getripple/core": "^1.0.14-beta.0" }, "devDependencies": { "@types/node": "^18.0.0", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 14d508a..6ff950c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import * as os from 'os'; import * as fs from "fs"; import * as path from "path"; @@ -112,6 +113,8 @@ type CliOptions = { githubAnnotations: boolean; force: boolean; print: boolean; + /** Opt-in: publish a run to Ripple Cloud and print a public share link. */ + publish: boolean; sha?: string; }; @@ -180,7 +183,7 @@ type RippleAgentSetupSummary = { nextSteps: string[]; }; -type RippleHookInstallAction = "created" | "appended" | "already-present"; +type RippleHookInstallAction = "created" | "appended" | "updated" | "already-present"; type RippleHookInstallSummary = { protocol: "ripple-hook-install"; @@ -358,6 +361,7 @@ function usage(): string { " ripple agent", " ripple agent setup [--print] [--force]", " ripple hook install [--print] [--force]", + " ripple demo [--publish] [--json]", "", "Options:", " --json, -j Print machine-readable JSON", @@ -381,6 +385,7 @@ function usage(): string { " --github-annotations Emit GitHub Actions annotations for CI findings", " --print Print generated setup content instead of writing files", " --force Overwrite existing generated setup files", + " --publish ripple demo only: send the run to Ripple Cloud and print a PUBLIC share link (needs RIPPLE_API_KEY)", "", "Examples:", " ripple init", @@ -640,6 +645,7 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { githubAnnotations: false, force: false, print: false, + publish: false, }; for (let i = 0; i < argv.length; i++) { @@ -684,6 +690,10 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { options.print = true; continue; } + if (token === "--publish") { + options.publish = true; + continue; + } if (token === "--last") { const value = argv[i + 1]; if (!value || value.startsWith("-")) { @@ -1007,8 +1017,8 @@ function githubActionsWorkflow(): string { " RIPPLE_API_KEY: ${{ secrets.RIPPLE_API_KEY }}", " RIPPLE_CLOUD_URL: ${{ secrets.RIPPLE_CLOUD_URL }}", " run: |", - " # Use npx to securely fetch and run the latest version from the public npm registry.", - ` npx -y @getripple/cli@latest ci --base origin/\\\${{ github.base_ref || 'main' }} --github-annotations --sha \\\${{ github.event.pull_request.head.sha || github.sha }}`, + " # Use npx to fetch the same published CLI version that generated this workflow.", + ` npx -y ${rippleCliPackageSpec()} ci --base origin/\${{ github.base_ref }} --github-annotations --sha \${{ github.event.pull_request.head.sha || github.sha }}`, "", ].join("\n"); } @@ -1096,13 +1106,6 @@ function intentLoadFailureMessage(intentRef: string, error: unknown): string { ].join(" "); } -function errorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - function defaultCiBaseRef(): string { const githubBaseRef = process.env.GITHUB_BASE_REF?.trim(); if (githubBaseRef) { @@ -4311,26 +4314,13 @@ async function gateCommand(options: CliOptions): Promise { printGateSummary(gate); } - // Consume intent on pass to prevent "ghost intents" if commit is aborted. - if (gate.canContinue && intentRef === "latest" && mode === "staged") { - try { - const consumedIntentCachePath = path.join(workspaceRoot, ".ripple", ".cache", "consumed-intent.json"); - if (fs.existsSync(consumedIntentCachePath)) { - fs.unlinkSync(consumedIntentCachePath); - } - fs.renameSync(intentPath, consumedIntentCachePath); - console.log("\n[Ripple] Intent validated and consumed by gate. Ready for commit."); - } catch (err) { - // Use the 'errorMessage' helper we created earlier - const errorDetail = errorMessage(err); - console.warn(`[Ripple] CRITICAL WARNING: Could not consume intent file. To prevent a future 'ghost intent' block, manually delete '${intentPath}' after your commit. Error: ${errorDetail}`); - } - } - - // NO CLOUD SYNC BLOCK HERE. This is the fix. - // The local gate's only job is to block or allow the commit locally. - // The CI job is the sole authority for the cloud audit trail. - + // The gate is a pure, read-only check: it validates staged changes against + // the saved intent and never mutates it. Consuming the intent here would + // destroy it on any manual/preview run even when no commit follows. Intent + // consumption happens in the post-commit hook, which only fires on a real + // commit — see ripplePostCommitHookBlock. The local gate's only job is to + // block or allow the commit locally; CI is the sole authority for the cloud + // audit trail. applyStrictExit(options.strict && !gate.canContinue); } @@ -4600,11 +4590,37 @@ async function ciCommand(options: CliOptions): Promise { const emitGithubAnnotations = shouldEmitGithubAnnotations(options); const commitSha = options.sha ?? getCurrentCommitSha(execSync); + if (options.intent) { + const audit = await buildAuditFromCliOptions({ + ...options, + changed: true, + staged: false, + worktree: false, + base: baseRef, + }); + + if (options.json) { + printJson({ ...audit, gate: buildRippleGateSummary(audit) }); + } else if (options.agent) { + printAgentAuditSummary(audit); + } else { + printAuditSummary(audit); + } + + if (emitGithubAnnotations && !options.json) { + printGithubAuditAnnotations(audit); + } + writeGithubAuditStepSummary(audit); + applyStrictExit(options.strict && !audit.canProceed); + return; + } + // FETCH THE AUTHORITATIVE INTENT FROM THE CLOUD. const intent = await fetchActiveIntentForCommit(commitSha); if (!intent) { // If no active intent is found in the cloud, run a policy-only audit. + console.log("Ripple CI policy audit"); console.log("[Ripple CI] No active cloud intent found. Running in policy-only audit mode."); const summary = await buildCheckSummaryForFiles({ @@ -4615,6 +4631,9 @@ async function ciCommand(options: CliOptions): Promise { tokenBudget: options.budget, }); const policySync = buildPolicySyncSummary(workspaceRoot); + console.log(`Policy sync: ${policySync.status}`); + console.log("Blocking: false"); + console.log("Intent: none (local intents are not required in CI audit mode)"); if (emitGithubAnnotations) { printGithubPolicyAuditAnnotations(summary, policySync); @@ -5034,12 +5053,25 @@ fi set +e -# ── SECURITY: Block any staged changes to Ripple configuration files ───── +# ── SECURITY: Block tracked Ripple governance file modifications ───────── # AI agents and automated processes must never modify policy or agent -# instruction files. These are the rules. They cannot rewrite the rules. +# instruction files after they become part of the repository contract. Initial +# additions are allowed so humans can commit the first ripple init setup. RIPPLE_PROTECTED_STAGED=$(git diff --cached --name-only 2>/dev/null | grep -E '^\.ripple/policy\.json$|^\.ripple/policy/|^CLAUDE\.md$|^\.cursorrules$|^AGENTS\.md$|^\.github/workflows/ripple\.yml$' || true) +RIPPLE_PROTECTED_BLOCKED="" if [ -n "$RIPPLE_PROTECTED_STAGED" ]; then + if git rev-parse --verify HEAD >/dev/null 2>&1; then + for f in $RIPPLE_PROTECTED_STAGED; do + if git cat-file -e "HEAD:$f" 2>/dev/null; then + RIPPLE_PROTECTED_BLOCKED="$RIPPLE_PROTECTED_BLOCKED +$f" + fi + done + fi +fi + +if [ -n "$RIPPLE_PROTECTED_BLOCKED" ]; then echo "" echo "╔══════════════════════════════════════════════════════════════╗" echo "║ [RIPPLE SECURITY] PROTECTED FILE MODIFICATION DETECTED ║" @@ -5047,7 +5079,10 @@ if [ -n "$RIPPLE_PROTECTED_STAGED" ]; then echo "" echo " The following Ripple configuration files were staged for commit:" echo "" - echo "$RIPPLE_PROTECTED_STAGED" | while IFS= read -r f; do + echo "$RIPPLE_PROTECTED_BLOCKED" | while IFS= read -r f; do + if [ -z "$f" ]; then + continue + fi echo " ⛔ $f" done echo "" @@ -5130,15 +5165,19 @@ function ripplePreCommitHookScript(): string { function ripplePostCommitHookBlock(): string { return [ RIPPLE_POST_COMMIT_HOOK_START, - `# After a successful commit, this hook cleans up the consumed intent marker -# left by the 'ripple gate' command. This prevents old intents from being -# reused accidentally if a commit is amended or rebased. + `# After a successful commit, this hook consumes the active intent so an old, +# already-committed approval cannot silently gate a later unrelated commit +# ("ghost intent"). Consumption happens here — only on a real commit — and not +# in 'ripple gate', so manual/preview gate runs never destroy the intent. +# The consumed intent is archived (not deleted) so it can be recovered. set +e +ACTIVE_INTENT_FILE=".ripple/intents/latest.json" CONSUMED_INTENT_FILE=".ripple/.cache/consumed-intent.json" -if [ -f "$CONSUMED_INTENT_FILE" ]; then - rm "$CONSUMED_INTENT_FILE" - echo "[Ripple] Cleaned up consumed intent marker." +if [ -f "$ACTIVE_INTENT_FILE" ]; then + mkdir -p ".ripple/.cache" + mv "$ACTIVE_INTENT_FILE" "$CONSUMED_INTENT_FILE" + echo "[Ripple] Consumed and cleared local intent after commit (archived to $CONSUMED_INTENT_FILE)." fi`, RIPPLE_POST_COMMIT_HOOK_END, "", @@ -5171,8 +5210,9 @@ function installRippleHookBlock(input: { fullScript: string; block: string; marker: string; + endMarker: string; }): RippleHookInstallAction { - const { hookPath, fullScript, block, marker } = input; + const { hookPath, fullScript, block, marker, endMarker } = input; if (!fs.existsSync(hookPath)) { fs.mkdirSync(path.dirname(hookPath), { recursive: true }); fs.writeFileSync(hookPath, fullScript, { encoding: "utf8", mode: 0o755 }); @@ -5185,8 +5225,30 @@ function installRippleHookBlock(input: { } const existing = fs.readFileSync(hookPath, "utf8"); - if (existing.includes(marker)) { - return "already-present"; + + // An existing Ripple block is replaced in place when its content changed, so + // upgrades actually land instead of silently reporting "already-present". + const startIndex = existing.indexOf(marker); + if (startIndex !== -1) { + const endIndex = existing.indexOf(endMarker, startIndex); + if (endIndex === -1) { + // Start marker without a matching end marker: leave the file untouched. + return "already-present"; + } + const blockEnd = endIndex + endMarker.length; + const currentBlock = existing.slice(startIndex, blockEnd); + const newBlock = block.trim(); + if (currentBlock === newBlock) { + return "already-present"; + } + const updated = existing.slice(0, startIndex) + newBlock + existing.slice(blockEnd); + fs.writeFileSync(hookPath, updated, "utf8"); + try { + fs.chmodSync(hookPath, 0o755); + } catch { + // chmod is best-effort on Windows. + } + return "updated"; } const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n"; @@ -5213,12 +5275,14 @@ function installRippleHooks(workspaceRoot: string): RippleHookInstallSummary { fullScript: content, block: ripplePreCommitHookBlock(), marker: RIPPLE_PRE_COMMIT_HOOK_START, + endMarker: RIPPLE_PRE_COMMIT_HOOK_END, }); const postCommitAction = installRippleHookBlock({ hookPath: postCommitHookPath, fullScript: postCommitContent, block: ripplePostCommitHookBlock(), marker: RIPPLE_POST_COMMIT_HOOK_START, + endMarker: RIPPLE_POST_COMMIT_HOOK_END, }); const wroteSomething = preCommitAction !== "already-present" || postCommitAction !== "already-present"; @@ -5287,12 +5351,14 @@ function hookInstallCommand(subcommand: string | undefined, options: CliOptions) fullScript: content, block: ripplePreCommitHookBlock(), marker: RIPPLE_PRE_COMMIT_HOOK_START, + endMarker: RIPPLE_PRE_COMMIT_HOOK_END, }); const postCommitAction = installRippleHookBlock({ hookPath: postCommitHookPath, fullScript: postCommitContent, block: ripplePostCommitHookBlock(), marker: RIPPLE_POST_COMMIT_HOOK_START, + endMarker: RIPPLE_POST_COMMIT_HOOK_END, }); const wroteSomething = preCommitAction !== "already-present" || postCommitAction !== "already-present"; @@ -5981,10 +6047,528 @@ async function focusCommand(filePath: string | undefined, options: CliOptions): } } + + +// ======================================================================== +// FINAL RIPPLE DEMO CODE BLOCK +// Replace all previous demo-related code in `index.ts` with this. +// ======================================================================== + +// This is our "UI" library for the terminal. +const demoUi = { + bold: (s: string) => `\x1b[1m${s}\x1b[0m`, + dim: (s: string) => `\x1b[2m${s}\x1b[0m`, + cyan: (s: string) => `\x1b[36m${s}\x1b[0m`, + green: (s: string) => `\x1b[32m${s}\x1b[0m`, + yellow: (s: string) => `\x1b[33m${s}\x1b[0m`, + red: (s: string) => `\x1b[31m${s}\x1b[0m`, + magenta: (s: string) => `\x1b[35m${s}\x1b[0m`, + underline: (s: string) => `\x1b[4m${s}\x1b[0m`, + /** Solid red block, white text — used once, for the rejection banner. */ + bgRed: (s: string) => `\x1b[41m\x1b[37m${s}\x1b[0m`, +}; +// ======================================================================== +// REPLACE YOUR OLD printDemoGateResult WITH THIS NEW, ROBUST VERSION +// ======================================================================== + +/** Prints a simplified, story-focused gate verdict for the demo. */ +function printDemoGateResult(gate: RippleGateSummary): void { + const { bold, dim, green, red } = demoUi; + + // Helper to safely shorten a symbol name. + const shortSymbol = (symbol: string) => (symbol ? symbol.split("::").pop() ?? symbol : "unknown"); + + // Helper to safely create a list from an array that might be undefined. + const list = (symbols: string[] | undefined) => { + if (!symbols || symbols.length === 0) { + return dim("none"); + } + return symbols.map(shortSymbol).join(", "); + }; + + const statusLabel = gate.canContinue ? green(bold("CONTINUE")) : red(bold("STOP")); + + console.log(`${bold("Ripple gate:")} ${statusLabel}`); + + const approvedSymbols = list(gate.allowedSymbols); + const changedSymbols = list(gate.reviewPacket?.actualChanges?.changedSymbols); + const outsideSymbols = list(gate.changedOutsideBoundarySymbols); + + console.log(` Approved to change : ${approvedSymbols}`); + + // For the story, only show "Actually changed" if it's a violation. + if (!gate.canContinue && changedSymbols !== approvedSymbols) { + console.log(` Actually changed : ${red(changedSymbols)}`); + } + + console.log( + ` Outside boundary : ${(gate.changedOutsideBoundarySymbols?.length ?? 0) > 0 ? red(outsideSymbols) : dim("none")}` + ); + + // Always the engine's real score. A demo that prints a friendlier number than + // the tool produces is the one thing a prospect can catch us on. + const riskLabel = `${gate.risk.level.toUpperCase()} ${gate.risk.score}/100`; + if (gate.canContinue) { + console.log(` Risk : ${riskLabel}`); + if (gate.risk?.reasons?.[0]) { + console.log(dim(` ${gate.risk.reasons[0].message}`)); + } + } else { + console.log(` Risk : ${red(riskLabel)}`); + if (gate.risk?.reasons?.[0]) { + console.log(dim(` ${gate.risk.reasons[0].message}`)); + } + if (gate.fixNow?.[0]) { + // The engine's fix text uses fully-qualified symbols; shorten them to + // match the rest of this panel, then wrap under the value column. + const fixText = gate.fixNow[0].replace( + /[\w./-]+::([A-Za-z_][A-Za-z0-9_]*)/g, + (_match, name: string) => name + ); + const indent = " ".repeat(" Fix : ".length); + wrapDemoText(fixText, 46).forEach((line, index) => { + console.log(index === 0 ? ` Fix : ${red(line)}` : `${indent}${red(line)}`); + }); + } + } +} + +/** Greedy word wrap so long engine text stays inside the demo panel. */ +function wrapDemoText(text: string, width: number): string[] { + const lines: string[] = []; + let current = ""; + for (const word of text.split(/\s+/)) { + if (!current) { + current = word; + } else if (`${current} ${word}`.length <= width) { + current += ` ${word}`; + } else { + lines.push(current); + current = word; + } + } + if (current) { + lines.push(current); + } + return lines; +} +/** + * Opt-in publish of a demo run to Ripple Cloud, returning a public share URL. + * + * Deliberately conservative: it is skipped unless --publish was passed AND an + * API key exists, it never throws, and it never changes the exit code. A demo + * that cannot reach the network must still be a working demo. + * + * Demo receipts are tagged `source: "demo"` because they describe a synthetic + * commit in a scratch repo, not a real project's history — the receipt page + * relies on that field to label them. + */ +async function publishDemoReceipt(input: { + publish: boolean; + jsonMode: boolean; + demoDir: string; + gate: RippleGateSummary; + readHeadSha: () => string; + log: (message: string) => void; +}): Promise { + const { dim, yellow } = demoUi; + if (!input.publish) { + return undefined; + } + + // stderr, so --json stdout stays a single valid JSON document. + const warn = (message: string) => { + if (input.jsonMode) { + console.error(message); + } else { + input.log(message); + } + }; + + if (!process.env.RIPPLE_API_KEY) { + warn( + `\n${yellow("--publish skipped:")} set RIPPLE_API_KEY to publish this run to Ripple Cloud.` + ); + return undefined; + } + + input.log(dim("\n⏳ Publishing this run to Ripple Cloud...")); + try { + // Same shape `ripple ci` sends, so the receipt renderer has one schema. + const result = await syncAuditToCloud({ + intentId: input.gate.intent.id, + decision: input.gate.canContinue ? "continue" : input.gate.needsHuman ? "human-review" : "blocked", + commitSha: input.readHeadSha(), + branch: "demo", + actor: "demo@ripple.dev", + source: "demo", + payload: { + decision: input.gate.decision, + status: input.gate.status, + risk: { + level: input.gate.risk.level, + score: input.gate.risk.score, + summary: input.gate.risk.summary, + }, + auditStatus: input.gate.auditStatus, + approvalStatus: input.gate.approvalStatus, + intent: input.gate.intent, + mode: input.gate.mode, + baseRef: input.gate.baseRef, + blockingReasons: input.gate.why, + }, + share: { redaction: "minimal" }, + }); + + if (!result.sent) { + warn(`\n${yellow("--publish failed")} (demo unaffected): ${result.error ?? "unknown error"}`); + return undefined; + } + if (!result.share?.url) { + warn( + `\n${yellow("--publish:")} run recorded, but this Ripple Cloud deployment returned no share link.` + ); + return undefined; + } + return result.share.url; + } catch (err) { + warn( + `\n${yellow("--publish failed")} (demo unaffected): ${err instanceof Error ? err.message : String(err)}` + ); + return undefined; + } +} + +async function demoCommand(options: CliOptions): Promise { + const { bold, dim, cyan, green, yellow, red, magenta, underline, bgRed } = demoUi; + + const jsonMode = options.json; + const fast = + jsonMode || + options.agent || + !process.stdout.isTTY || + process.env.RIPPLE_DEMO_FAST === "1"; + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, fast ? 0 : ms)); + const log = jsonMode ? () => {} : (message: string) => console.log(message); + const rule = () => log(dim("──────────────────────────────────────────────────")); + const thick = () => log(cyan("══════════════════════════════════════════════════")); + + thick(); + log(bold(cyan("🚀 Welcome to the Ripple Demo"))); + log("A real AI boundary violation, caught by the real Ripple engine."); + thick(); + await sleep(3000); + + const demoDir = fs.mkdtempSync(path.join(os.tmpdir(), "ripple-demo-")); + // Two files: cart.ts is the low-risk file the boundary actually covers, so + // the authorized edit reads LOW risk with no human-gate friction. checkout.ts + // is payments code (critical by Ripple's own path rules) — untouched until + // the rogue edit reaches into it, which is the reveal in Scene 4. + const cartFile = "src/store/cart.ts"; + const checkoutFile = "src/billing/checkout.ts"; + + try { + // The demo repo must be self-contained: never inherit the user's git + // identity, signing config, or hooks, and fail loudly if git does not run. + // Without this, `git commit` silently fails on machines with no global + // identity and the demo reports a verdict against the wrong repo state. + const run = (args: string[]) => { + const result = spawnSync( + "git", + [ + "-c", "user.email=demo@ripple.dev", + "-c", "user.name=Ripple Demo", + "-c", "commit.gpgsign=false", + ...args, + ], + { cwd: demoDir, stdio: "pipe", encoding: "utf8" } + ); + if (result.status !== 0) { + throw new Error(`Demo git command failed: git ${args.join(" ")}\n${result.stderr ?? ""}`); + } + return result; + }; + + /** Same as run(), but returns the result instead of throwing. Used where a + * non-zero exit is the point being demonstrated (a blocked commit). */ + const runAllowFail = (args: string[]) => + spawnSync( + "git", + [ + "-c", "user.email=demo@ripple.dev", + "-c", "user.name=Ripple Demo", + "-c", "commit.gpgsign=false", + ...args, + ], + { cwd: demoDir, stdio: "pipe", encoding: "utf8" } + ); + + const writeDemoFile = (relativePath: string, contents: string) => + fs.writeFileSync(path.join(demoDir, relativePath), contents); + + /** Creates a genuine saved intent exactly the way `ripple plan --save` does, + * so humanGate/boundaryRisk/protectedContracts are engine-derived rather + * than asserted by the demo. */ + const realPlan = async (targetFile: string, symbol: string, task: string): Promise => { + const engine = createCliEngine(demoDir); + try { + await runWithQuietEngine(() => engine.initialScan()); + const summary = engine.planContext(task, targetFile, 4000); + if (!summary) { + throw new Error(`Demo file is not in the Ripple graph: ${targetFile}`); + } + const loadedPolicy = loadRipplePolicy(demoDir); + const policyExplanation = explainRipplePolicyForTarget(loadedPolicy, targetFile, { + controlMode: "function", + }); + const planned = buildChangeIntent(summary, { + controlMode: "function", + allowedSymbols: [symbol], + policy: resolveRipplePolicyForTarget(loadedPolicy, targetFile), + policyExplanation, + }); + planned.readinessSnapshot = buildChangeIntentReadinessSnapshot( + buildRippleReadinessSummary(demoDir, engine) + ); + saveChangeIntent(demoDir, planned, defaultChangeIntentPath(demoDir)); + return planned; + } finally { + engine.dispose(); + } + }; + + // --- SCENE 1: THE SETUP --- + log(`\n${yellow("SCENE 1: THE SETUP")}`); + rule(); + run(["init", "--initial-branch=main"]); + fs.mkdirSync(path.join(demoDir, "src", "store"), { recursive: true }); + fs.mkdirSync(path.join(demoDir, "src", "billing"), { recursive: true }); + + // Not exported: this keeps the authorized edit's contract-risk reason from + // firing, so the real risk score reads LOW instead of MEDIUM. That's an + // honest characterization, not a fudge — an unexported helper genuinely + // has no external callers to break. + const initialCartCode = `// ${cartFile} +function applyDiscount(price: number, discountCode: string): number { + if (discountCode === 'SAVE10') { + return price * 0.9; + } + return price; +}`; + const initialCheckoutCode = `// ${checkoutFile} +export function processCharge(price: number, user: string): { success: boolean } { + // CRITICAL: This function charges the user's credit card. + console.log(\`Charging \${user} for \${price}\`); + return { success: true }; +}`; + writeDemoFile(cartFile, initialCartCode); + writeDemoFile(checkoutFile, initialCheckoutCode); + run(["add", "."]); + run(["commit", "-m", "Initial commit: add cart and billing logic"]); + log(`✓ A cart file exists: ${bold(cartFile)}`); + log(`✓ A payment processing file exists: ${bold(checkoutFile)} ${dim("(untouched, for now)")}`); + // A real pre-commit hook, so every "blocked" claim below is enforced by + // Ripple rather than narrated by this script. + installRippleHooks(demoDir); + log(dim(` [ripple] pre-commit hook installed — commits are now gated.`)); + await sleep(2000); + + // --- SCENE 2: THE TASK --- + log(`\n${yellow("SCENE 2: THE TASK")}`); + rule(); + log("Developer declares what the AI is allowed to change:"); + log(dim(`\n $ ripple plan --file ${cartFile} \\`)); + log(dim(` --symbol applyDiscount \\`)); + log(dim(` --mode function --save`)); + + const approvedSymbol = `${cartFile}::applyDiscount`; + let intent = await realPlan(cartFile, approvedSymbol, "Add 'SAVE20' discount code"); + await sleep(2000); + log(green(`\n✓ Boundary set. Only the 'applyDiscount' function can be changed.`)); + log(dim(` boundary risk: ${intent.boundaryRisk} · human gate: ${intent.humanGate}`)); + await sleep(2000); + + // cart.ts is low-risk by Ripple's own path rules, so this normally reads + // "none" and the demo flows straight to the edit. The check stays real + // (not hardcoded) — if the engine ever did require sign-off here, the demo + // would still show it rather than silently skip a real gate. + if (intent.humanGate !== "none") { + log(`\nRipple requires human sign-off on this path first:`); + log(dim(`\n $ ripple approve --gate before-risky-edit \\`)); + log(dim(` --reason "discount copy change only"`)); + recordRippleApproval(demoDir, intent, { + gate: "before-risky-edit", + reason: "discount copy change only", + approvedBy: "Demo Human", + }); + log(green(`\n✓ Human approval recorded.`)); + await sleep(2500); + } + + const runGate = async (): Promise => { + const audit = await buildAuditForFiles({ + workspaceRoot: demoDir, + files: listGitStagedFiles(demoDir), + mode: "staged", + tokenBudget: 4000, + intent, + // Must match how `ripple gate` computes this, or the intent's saved + // policy snapshot looks drifted and the gate stops for the wrong reason. + currentPolicyExplanation: explainRipplePolicyForIntent( + loadRipplePolicy(demoDir), + intent + ), + }); + return buildRippleGateSummary(audit); + }; + + // --- SCENE 3: THE AUTHORIZED EDIT --- + log(`\n${yellow("SCENE 3: THE AI WORKS (SAFELY)")}`); + rule(); + log("AI adds the SAVE20 discount code to applyDiscount... " + dim("done.")); + + const authorizedCartCode = initialCartCode.replace( + "return price;", + " if (discountCode === 'SAVE20') return price * 0.8;\n return price;" + ); + writeDemoFile(cartFile, authorizedCartCode); + run(["add", cartFile]); + await sleep(2000); + + log(dim("\n⏳ Running Ripple gate on authorized-only changes...\n")); + await sleep(1500); + const authorizedGate = await runGate(); + if (!jsonMode) printDemoGateResult(authorizedGate); + const commitResult = run(["commit", "-m", "Add SAVE20 discount code"]); + log(green(`\n✓ Commit passes. The SAVE20 discount is in git history.`)); + // Read the file count back out of git rather than asserting it, so the + // line stays true if the demo's edits ever change. + const commitStat = /(\d+ files? changed)/.exec(commitResult.stdout ?? ""); + if (commitStat) { + log(dim(`\n [git] ${commitStat[1]} — commit recorded cleanly.`)); + } + await sleep(3500); + + // --- SCENE 4: THE ROGUE EDIT --- + log(`\n${yellow("SCENE 4: THE ROGUE EDIT")}`); + rule(); + log(`Now, the AI also "refactors" ${bold(checkoutFile)} — a file it was never authorized to touch at all.`); + log(""); + log(red(` - console.log(\`Charging \${user} for \${price}\`);`)); + log(red(` + console.log(\`Charging \${user} for \${price * 1.1}\`);`)); + log(""); + log(dim(" A silent 10% surcharge on every payment. Nobody asked for this.")); + + // The authorized commit consumed the first intent, so the developer opens + // the next task on the same approved function before the AI continues. + intent = await realPlan(cartFile, approvedSymbol, "Continue discount work"); + if (intent.humanGate !== "none") { + recordRippleApproval(demoDir, intent, { + gate: "before-risky-edit", + reason: "discount copy change only", + approvedBy: "Demo Human", + }); + } + + const rogueCode = initialCheckoutCode.replace('price}', 'price * 1.1}'); + writeDemoFile(checkoutFile, rogueCode); + run(["add", checkoutFile]); + await sleep(3500); + + log("\nDeveloper trusts the AI and runs " + bold("git commit") + "."); + await sleep(2000); + log(dim("\n⏳ Ripple pre-commit hook runs...\n")); + await sleep(1500); + + const blockedGate = await runGate(); + if (!jsonMode) printDemoGateResult(blockedGate); + + // Actually attempt the commit and let the real hook decide. Nothing below + // is asserted by the demo: the exit code and git log come from git. + const rogueCommit = runAllowFail(["commit", "-m", "refactor processCharge"]); + const headSubject = run(["log", "-1", "--pretty=%s"]).stdout?.trim(); + if (rogueCommit.status !== 0) { + log(""); + log(bgRed(` ⛔ git commit REJECTED by Ripple (exit ${rogueCommit.status}) `)); + log(""); + log(dim(` [git] HEAD is still: "${headSubject}"`)); + log(bold(red(` The 10% surcharge never entered git history.`))); + } else { + log(bold(red(`\n⚠ Commit was NOT blocked (exit 0). HEAD: "${headSubject}"`))); + } + await sleep(4000); + + // --- OPTIONAL: PUBLISH A PUBLIC RECEIPT --- + // Opt-in only. Must run before the finally block deletes the temp repo, + // because the commit SHA is read out of it. + const receiptUrl = await publishDemoReceipt({ + publish: options.publish, + jsonMode, + demoDir, + gate: blockedGate, + readHeadSha: () => run(["rev-parse", "HEAD"]).stdout?.trim() ?? "unknown", + log, + }); + + // --- THE POINT --- + log(""); + rule(); + log("Prompts tell AI agents what to do."); + log(bold("Ripple enforces what they are allowed to do.")); + log("The difference is a compliance trail your auditors can read."); + log(""); + if (receiptUrl) { + log(`Public receipt: ${bold(underline(receiptUrl))}`); + } + log(`Try it in your repo: ${bold("npx @getripple/cli@latest init")}`); + log(`Dashboard: ${bold(underline("https://ripple-cloud.vercel.app"))}`); + log(""); + thick(); + + if (jsonMode) { + printJson({ + protocol: "ripple-demo", + version: 1, + scenarios: [ + { + name: "authorized-edit", + decision: authorizedGate.decision, + canContinue: authorizedGate.canContinue, + }, + { + name: "rogue-edit", + decision: blockedGate.decision, + canContinue: blockedGate.canContinue, + }, + ], + ...(receiptUrl ? { receiptUrl } : {}), + }); + } + } finally { + fs.rmSync(demoDir, { recursive: true, force: true }); + if (!jsonMode) { + console.log(dim("\n(Demo complete. Temporary files cleaned up.)")); + } + } +} + + + + + + + async function main(): Promise { const { command, args, options } = parseCliArgs(process.argv.slice(2)); const [arg] = args; + // --- ADD THIS NEW CASE --- + if (command === "demo") { + await demoCommand(options); + return; + } + // --- END NEW CASE --- + if (!command || command === "--help" || command === "-h") { console.log(usage()); return; diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 9abc7c9..0f564c1 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,16 @@ # @getripple/core Changelog +## [1.0.14] - 2026-07-21 + +### Fixed +- Detect changed symbols in files outside the approved boundary (`changedOutsideBoundarySymbols` previously only checked symbols inside already-allowed files, silently missing edits to unauthorized files entirely). +- Detect deletions of unapproved symbols during a staged/worktree check (previously invisible to the gate). +- Stamp and verify a tamper-evidence fingerprint on saved change intents, rejecting hand-edited `.ripple/intents/latest.json` files. +- Fix Windows quoting bug in cloud audit actor lookup (`git log --pretty=format:'%ae'` returned a literal-quoted string on cmd.exe). +- Fix risk evidence loss: contract-risk and file-risk evidence lines were deduplicated as plain strings, so two changed symbols/files sharing the same caller count, exported flag, or importer count would silently lose one entry's evidence. +- Recognize Python module-level assignments and decorator changes as tracked symbols (previously invisible to the staged-diff parser). +- Two-axis risk scoring: clean, boundary-respecting changes are capped at score 50 (LOW/MEDIUM); any real violation is floored at 51+ (HIGH/CRITICAL), so risk score now reliably discriminates authorized from unauthorized changes. + ## [1.0.9] - 2026-06-13 ### Changed diff --git a/packages/core/package.json b/packages/core/package.json index b262acd..af71d90 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@getripple/core", - "version": "1.0.13", + "version": "1.0.14-beta.0", "description": "Core engine for Ripple's local authorization gate: context planning, drift checks, and trust boundaries.", "license": "MIT", "type": "commonjs", diff --git a/packages/core/src/audit.ts b/packages/core/src/audit.ts index f046782..b7e7e54 100644 --- a/packages/core/src/audit.ts +++ b/packages/core/src/audit.ts @@ -472,6 +472,7 @@ function classifyIntentLoadError(message: string): RippleGateIntentState { if ( message.includes("Invalid Ripple change intent") || message.includes("Malformed Ripple change intent") || + message.includes("Tampered or unverifiable Ripple change intent") || message.includes("Found protocol") ) { return "invalid"; diff --git a/packages/core/src/change-intent.ts b/packages/core/src/change-intent.ts index 0fa29c0..96d1112 100644 --- a/packages/core/src/change-intent.ts +++ b/packages/core/src/change-intent.ts @@ -39,6 +39,12 @@ export type ChangeIntent = { verificationEvidence: RippleVerificationEvidence[]; readinessSnapshot: ChangeIntentReadinessSnapshot; why: string; + // Tamper-evidence fingerprint over the boundary-defining fields, stamped on + // save and verified on load. It catches accidental corruption and naive + // hand-editing of the saved boundary; it is not a defense against an agent + // that can read this code and recompute it — that requires the server to be + // authoritative over intents. See computeIntentFingerprint. + integrity?: string; }; export type RippleVerificationStatus = "passed" | "failed" | "skipped" | "unknown"; @@ -1108,6 +1114,48 @@ function uniqueRepairActions(actions: IntentDriftRepairAction[]): IntentDriftRep }); } +/** + * Canonical serialization of the boundary-defining fields of an intent. This is + * deliberately narrow: it excludes verificationEvidence (which legitimately + * mutates via `ripple verify`) and the integrity field itself, so that + * re-saving after recording evidence does not invalidate the fingerprint. + */ +function intentIntegrityPayload(source: { + id?: unknown; + createdAt?: unknown; + controlMode?: unknown; + targetFile?: unknown; + humanGate?: unknown; + boundaryRisk?: unknown; + allowedSymbols?: unknown; + allowedFiles?: unknown; + editableFiles?: unknown; + expectedFiles?: unknown; + expectedSymbols?: unknown; + protectedContracts?: unknown; + contextFiles?: unknown; +}): string { + return JSON.stringify({ + id: source.id ?? null, + createdAt: source.createdAt ?? null, + controlMode: source.controlMode ?? null, + targetFile: source.targetFile ?? null, + humanGate: source.humanGate ?? null, + boundaryRisk: source.boundaryRisk ?? null, + allowedSymbols: source.allowedSymbols ?? [], + allowedFiles: source.allowedFiles ?? [], + editableFiles: source.editableFiles ?? [], + expectedFiles: source.expectedFiles ?? [], + expectedSymbols: source.expectedSymbols ?? [], + protectedContracts: source.protectedContracts ?? [], + contextFiles: source.contextFiles ?? [], + }); +} + +export function computeIntentFingerprint(source: Parameters[0]): string { + return crypto.createHash("sha256").update(intentIntegrityPayload(source)).digest("hex"); +} + export function saveChangeIntent( workspaceRoot: string, intent: ChangeIntent, @@ -1117,7 +1165,8 @@ export function saveChangeIntent( ? resolveIntentPath(workspaceRoot, intentPath) : defaultChangeIntentPath(workspaceRoot); fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.writeFileSync(targetPath, `${JSON.stringify(intent, null, 2)}\n`, "utf8"); + const stamped: ChangeIntent = { ...intent, integrity: computeIntentFingerprint(intent) }; + fs.writeFileSync(targetPath, `${JSON.stringify(stamped, null, 2)}\n`, "utf8"); return targetPath; } @@ -2114,7 +2163,14 @@ function buildBoundaryVerdict(input: { if (input.intent.controlMode !== "function") { return false; } - return allowedFileSet.has(symbol.file) && !allowedSymbolSet.has(symbol.symbol); + // A symbol is outside the boundary if it isn't an approved symbol — + // whether because its file was never approved at all, or because it's + // an unapproved symbol inside an approved file. Previously this only + // checked the latter, so a changed symbol in a wholly unauthorized + // file (a stronger violation, not a weaker one) was silently omitted + // from this list even though changedOutsideBoundaryFiles caught the + // file itself. + return !allowedSymbolSet.has(symbol.symbol); }) .map((symbol) => symbol.symbol) ); @@ -2614,9 +2670,19 @@ function assertChangeIntent(value: unknown, sourcePath: string): ChangeIntent { `Malformed Ripple change intent at ${sourcePath}. Ask the human to inspect or recreate the saved boundary before continuing.` ); } + assertIntentIntegrity(value, sourcePath); return normalizeChangeIntent(value as RawChangeIntent); } +function assertIntentIntegrity(value: Record, sourcePath: string): void { + const expected = computeIntentFingerprint(value); + if (typeof value.integrity !== "string" || value.integrity !== expected) { + throw new Error( + `Tampered or unverifiable Ripple change intent at ${sourcePath}. The saved boundary's integrity fingerprint does not match its contents, so Ripple cannot prove it is the boundary a human approved. Create a fresh plan with ripple plan --file --task "" --agent --save before the agent continues.` + ); + } +} + function closedIntentErrorMessage(value: Record, sourcePath: string): string { const reason = typeof value.reason === "string" && value.reason.trim().length > 0 ? ` Reason: ${value.reason.trim()}` diff --git a/packages/core/src/cloud.ts b/packages/core/src/cloud.ts index 862ac2c..5e87da7 100644 --- a/packages/core/src/cloud.ts +++ b/packages/core/src/cloud.ts @@ -51,8 +51,11 @@ export class RippleCloudClient { const branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8", stdio: "pipe" }).trim(); let actor = "unknown_actor"; try { - actor = commitSha - ? execSync(`git log -1 --pretty=format:'%ae' ${commitSha}`, { encoding: "utf8", stdio: "pipe" }).trim() + actor = commitSha + // Double quotes, not single: on Windows (cmd.exe) single quotes are + // not string delimiters, so 'git log --pretty=format:'%ae'' returns + // the address wrapped in literal quotes and corrupts the audit actor. + ? execSync(`git log -1 --pretty=format:"%ae" ${commitSha}`, { encoding: "utf8", stdio: "pipe" }).trim() : execSync("git config user.email", { encoding: "utf8", stdio: "pipe" }).trim(); } catch { actor = execSync("git config user.email", { encoding: "utf8", stdio: "pipe" }).trim(); @@ -180,6 +183,18 @@ export function getCurrentActor(execSync: any): string { } +/** Requests that the cloud mint a public share link for this audit event. */ +export interface CloudSharePayload { + /** Defaults to the safest level ("minimal") server-side when omitted. */ + redaction?: "minimal" | "paths" | "full"; + expiresInDays?: number; +} + +export interface CloudShareResult { + url: string; + expiresAt?: string; +} + export interface CloudAuditPayload { protocol?: string; intentId: string; @@ -189,9 +204,17 @@ export interface CloudAuditPayload { actor: string; source: string; payload: any; + /** Opt-in only. Omitted entirely unless the caller asked to publish. */ + share?: CloudSharePayload; } -export async function syncAuditToCloud(data: CloudAuditPayload): Promise<{ sent: boolean; error?: string }> { +export type CloudAuditResult = { + sent: boolean; + error?: string; + share?: CloudShareResult; +}; + +export async function syncAuditToCloud(data: CloudAuditPayload): Promise { const apiKey = process.env.RIPPLE_API_KEY; const apiUrl = process.env.RIPPLE_CLOUD_URL ?? "https://ripple-cloud.vercel.app"; @@ -213,6 +236,9 @@ export async function syncAuditToCloud(data: CloudAuditPayload): Promise<{ sent: payloadHash: crypto.createHash("sha256").update(JSON.stringify(data.payload)).digest("hex"), decision: data.decision, payload: data.payload, + // Only present when the caller explicitly opted into publishing. Older + // cloud deployments ignore the field, so this stays backward compatible. + ...(data.share ? { share: data.share } : {}), }; const response = await fetch(`${apiUrl}/api/audit`, { @@ -230,13 +256,32 @@ export async function syncAuditToCloud(data: CloudAuditPayload): Promise<{ sent: return { sent: false, error: `HTTP ${response.status}: ${body}` }; } - return { sent: true }; + // The audit itself already succeeded, so a missing or unparseable body must + // never turn into a failed sync — the share link is strictly a bonus. + return { sent: true, share: await parseShareResult(response) }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { sent: false, error: message }; } } +async function parseShareResult(response: Response): Promise { + try { + const body = await response.json() as { share?: { url?: unknown; expiresAt?: unknown } }; + const url = body?.share?.url; + if (typeof url !== "string" || url.length === 0) { + return undefined; + } + const expiresAt = body.share?.expiresAt; + return { + url, + expiresAt: typeof expiresAt === "string" ? expiresAt : undefined, + }; + } catch { + return undefined; + } +} + /** Pushes a validated change intent to Ripple Cloud, making it the active boundary. */ export async function pushIntentToCloud(intent: ChangeIntent): Promise<{ sent: boolean; error?: string }> { const apiKey = process.env.RIPPLE_API_KEY; diff --git a/packages/core/src/risk.ts b/packages/core/src/risk.ts index 1daf500..e1381c3 100644 --- a/packages/core/src/risk.ts +++ b/packages/core/src/risk.ts @@ -9,6 +9,7 @@ export type RippleRiskLevel = "low" | "medium" | "high" | "critical"; export type RippleRiskReasonKind = | "boundary-crossed" | "intent-drift" + | "approval-missing" | "risky-path" | "blast-radius" | "public-contract" @@ -42,6 +43,8 @@ export type BuildRippleRiskSummaryInput = { changedOutsideBoundarySymbols: string[]; unplannedFiles?: string[]; unplannedSymbols?: string[]; + /** Saved intent requires human sign-off that has not been recorded yet. */ + humanGateUnsatisfied?: boolean; verificationTargets: string[]; nextSteps: string[]; stagedFiles: StagedCheckFileSummary[]; @@ -108,12 +111,13 @@ export function buildRippleRiskSummary(input: BuildRippleRiskSummaryInput): Ripp addBoundaryReasons(input, reasons); addIntentReasons(input, reasons); + addApprovalReason(input, reasons); addPolicyReason(input, reasons); addFileRiskReasons(input, reasons, affectedFiles); addContractReasons(input, reasons, affectedSymbols); addVerificationReason(input, reasons); - const score = Math.min(100, reasons.reduce((total, reason) => total + reason.weight, 0)); + const score = rippleRiskScore(reasons); const level = riskLevelForScore(score); const requiredActions = buildRequiredActions(input, reasons); @@ -128,6 +132,53 @@ export function buildRippleRiskSummary(input: BuildRippleRiskSummaryInput): Ripp }; } +/** + * Reasons that mean "the agent did something it was not authorized to do", as + * opposed to "this code is sensitive". Only these can push a change into the + * high/critical bands. + */ +const VIOLATION_REASON_KINDS: ReadonlySet = new Set([ + "boundary-crossed", + "intent-drift", + // Not yet a breach, but keeping the change would be unauthorized, so it + // belongs on the violation axis rather than the "this code is sensitive" one. + "approval-missing", +]); + +/** Highest score a change with no boundary violation may reach (stays MEDIUM). */ +const NO_VIOLATION_SCORE_CAP = 50; +/** Lowest score a change with a boundary violation may report (at least HIGH). */ +const VIOLATION_SCORE_FLOOR = 51; + +/** + * Risk is scored on two axes so the number discriminates. + * + * Exposure (how sensitive this code is: policy risk, risky path, blast radius, + * contracts, missing tests) is real signal, but on its own it is not a finding — + * editing a payments file correctly is not the same as breaching a boundary. + * Summing both axes made an authorized edit to a sensitive file score the same + * as an unauthorized one, so the score carried no information. + * + * Clean changes are therefore capped in the medium band, and any boundary or + * intent violation floors into the high band before exposure is added on top. + */ +function rippleRiskScore(reasons: WeightedRiskReason[]): number { + let violation = 0; + let exposure = 0; + for (const reason of reasons) { + if (VIOLATION_REASON_KINDS.has(reason.kind)) { + violation += reason.weight; + } else { + exposure += reason.weight; + } + } + + if (violation === 0) { + return Math.min(NO_VIOLATION_SCORE_CAP, exposure); + } + return Math.min(100, Math.max(VIOLATION_SCORE_FLOOR, violation + exposure)); +} + function addBoundaryReasons(input: BuildRippleRiskSummaryInput, reasons: WeightedRiskReason[]): void { if (input.changedOutsideBoundaryFiles.length > 0) { reasons.push({ @@ -181,6 +232,19 @@ function addIntentReasons(input: BuildRippleRiskSummaryInput, reasons: WeightedR } } +function addApprovalReason(input: BuildRippleRiskSummaryInput, reasons: WeightedRiskReason[]): void { + if (!input.humanGateUnsatisfied) { + return; + } + reasons.push({ + kind: "approval-missing", + severity: "high", + weight: 30, + message: "Saved intent requires human approval that has not been recorded.", + evidence: ["human approval: missing"], + }); +} + function addPolicyReason(input: BuildRippleRiskSummaryInput, reasons: WeightedRiskReason[]): void { if (input.boundaryRisk === "high" || input.boundaryRisk === "critical") { reasons.push({ @@ -208,9 +272,13 @@ function addFileRiskReasons( severity: file.modificationRisk === "dangerous" ? "high" : "medium", weight: graphWeight, message: `${file.file} is marked ${file.modificationRisk} by Ripple graph risk.`, + // Prefixed with the file: two changed files sharing the same importer + // or symbol count would otherwise produce identical evidence strings + // that collide when the CLI later flattens/dedupes evidence across + // every reason for the whole gate summary. evidence: [ - `importer count: ${file.importerCount}`, - `symbol count: ${file.symbolCount}`, + `${file.file} importer count: ${file.importerCount}`, + `${file.file} symbol count: ${file.symbolCount}`, ], }); } @@ -221,7 +289,7 @@ function addFileRiskReasons( severity: "high", weight: 26, message: `${file.file} has a large downstream blast radius.`, - evidence: [`${file.importerCount} direct importers may be affected`], + evidence: [`${file.file}: ${file.importerCount} direct importers may be affected`], }); } else if (file.importerCount >= 2) { reasons.push({ @@ -229,7 +297,7 @@ function addFileRiskReasons( severity: "medium", weight: 14, message: `${file.file} is shared by multiple downstream files.`, - evidence: [`${file.importerCount} direct importers may be affected`], + evidence: [`${file.file}: ${file.importerCount} direct importers may be affected`], }); } @@ -269,12 +337,17 @@ function addContractReasons( severity: highestRisk === "high" ? "high" : "medium", weight: Math.max(...contractRisks.map((contractRisk) => CONTRACT_RISK_WEIGHT[contractRisk.risk])), message: "Changed exported/public symbols may affect callers or external contracts.", + // Each line is prefixed with the symbol it describes. Two changed symbols + // very commonly share the same caller count or exported flag — plain + // `callers: 0` / `exported: true` strings would collide and get silently + // dropped by uniqueItems below (and again by the CLI's own cross-reason + // evidence dedup), making it look like only the first symbol has data. evidence: uniqueItems( contractRisks.flatMap((contractRisk) => [ `symbol: ${contractRisk.symbol}`, - `callers: ${contractRisk.callers}`, - `exported: ${contractRisk.exported}`, - `reason: ${contractRisk.reason}`, + `${contractRisk.symbol} callers: ${contractRisk.callers}`, + `${contractRisk.symbol} exported: ${contractRisk.exported}`, + `${contractRisk.symbol} reason: ${contractRisk.reason}`, ]) ), }); diff --git a/packages/core/src/staged-check.ts b/packages/core/src/staged-check.ts index ba23118..86f2587 100644 --- a/packages/core/src/staged-check.ts +++ b/packages/core/src/staged-check.ts @@ -25,7 +25,7 @@ export type StagedCheckSymbolChangeKind = | "return-shape-review"; export type StagedCheckSymbolContractRisk = "none" | "review" | "high"; -export type StagedCheckSymbolStatus = "created" | "modified"; +export type StagedCheckSymbolStatus = "created" | "modified" | "deleted"; export type StagedCheckChangedSymbol = { symbol: string; @@ -768,13 +768,83 @@ function getChangedSymbolsForFile( ? new Map() : symbolsByName(parseSymbolRanges(engine, workspaceRoot, projectPath, previousContent)); - return symbols + const changed = symbols .filter((symbol) => rangesIntersectSymbol(diff.changedLineRanges, symbol)) - .map((symbol) => buildChangedSymbol(symbol, diff, previousSymbols.get(symbol.name))) - .sort((a, b) => { - const riskDelta = contractRiskRank(b.contractRisk) - contractRiskRank(a.contractRisk); - return riskDelta || a.symbol.localeCompare(b.symbol); - }); + .map((symbol) => buildChangedSymbol(symbol, diff, previousSymbols.get(symbol.name))); + + // Deleted symbols never appear in the new content, so the range-intersection + // pass above cannot see them. Detect them as a set difference: any symbol + // present at HEAD but absent from the new file was removed (or renamed, in + // which case the new name is reported separately as "created"). Without this, + // an agent scoped to one symbol could silently delete any other export. + const currentNames = new Set(symbols.map((symbol) => symbol.name)); + const hasRemovedLines = diff.changedLines.some((line) => line.kind === "removed"); + if (hasRemovedLines) { + for (const [name, previousSymbol] of previousSymbols) { + if (!currentNames.has(name)) { + changed.push(buildDeletedSymbol(previousSymbol)); + } + } + } + + return changed.sort((a, b) => { + const riskDelta = contractRiskRank(b.contractRisk) - contractRiskRank(a.contractRisk); + return riskDelta || a.symbol.localeCompare(b.symbol); + }); +} + +function buildDeletedSymbol(previousSymbol: ParsedSymbolRange): StagedCheckChangedSymbol { + // Removing an exported symbol or one with callers breaks its contract for + // every downstream caller, so it is always at least a contract change. + const contractChanged = previousSymbol.exported || previousSymbol.callers > 0; + const contractRisk: StagedCheckSymbolContractRisk = + previousSymbol.exported && previousSymbol.callers > 0 + ? "high" + : contractChanged + ? "review" + : "none"; + + return { + symbol: previousSymbol.symbol, + file: previousSymbol.file, + name: previousSymbol.name, + kind: previousSymbol.kind, + layer: previousSymbol.layer, + exported: previousSymbol.exported, + callers: previousSymbol.callers, + calls: previousSymbol.calls, + symbolStatus: "deleted", + changeKind: "signature-or-contract", + contractRisk, + signatureTouched: true, + signatureChanged: true, + contractChanged, + returnLineChanged: false, + changedLines: [], + lineRange: { + start: previousSymbol.startLine, + end: previousSymbol.endLine, + }, + reason: deletedSymbolReason(previousSymbol, contractRisk), + adapterSignals: [], + }; +} + +function deletedSymbolReason( + previousSymbol: ParsedSymbolRange, + contractRisk: StagedCheckSymbolContractRisk +): string { + const signals: string[] = ["symbol removed compared with HEAD"]; + if (previousSymbol.exported) { + signals.push("exported symbol"); + } + if (previousSymbol.callers > 0) { + signals.push(`${previousSymbol.callers} caller(s)`); + } + const detail = signals.join("; "); + return contractRisk === "none" + ? detail + : `${detail}; deleting it breaks downstream callers — contract review required`; } function readWorkingTreeFileContent(workspaceRoot: string, projectPath: string): string | null { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index b9c1378..d72e9f7 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@getripple/mcp", - "version": "1.0.13", + "version": "1.0.14-beta.0", "description": "MCP stdio server for Ripple's local authorization gate for AI coding agents.", "license": "MIT", "type": "commonjs", @@ -45,7 +45,7 @@ "build": "tsc -p tsconfig.json" }, "dependencies": { - "@getripple/core": "^1.0.13" + "@getripple/core": "^1.0.14-beta.0" }, "devDependencies": { "@types/node": "^18.0.0", diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index a976a1f..4c4cbdf 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -344,7 +344,10 @@ function resolveWorkspaceRoot(argv: string[]): string { if (require.main === module) { try { - void runStdioServer(resolveWorkspaceRoot(process.argv.slice(2))); + runStdioServer(resolveWorkspaceRoot(process.argv.slice(2))).catch((err: unknown) => { + console.error(`Ripple MCP error: ${errorMessage(err)}`); + process.exitCode = 1; + }); } catch (err) { console.error(`Ripple MCP error: ${errorMessage(err)}`); process.exitCode = 1; diff --git a/scripts/build-vhs-gate-demo.js b/scripts/build-vhs-gate-demo.js new file mode 100644 index 0000000..fcdb044 --- /dev/null +++ b/scripts/build-vhs-gate-demo.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node +"use strict"; + +/** + * Records docs/ripple-gate-demo.tape with VHS and publishes the result to both + * places the GIF is referenced: resources/ (README) and docs/media/ (docs site). + * + * This replaces scripts/build-gate-demo-gif.ps1, which was a hand-drawn mock + * renderer that painted fake terminal frames. Every frame produced here is real + * `ripple demo` output. + * + * Requires the `vhs` binary: https://github.com/charmbracelet/vhs + * Run with: npm run demo:gif + */ + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const prepare = require("./prepare-vhs-gate-demo.js"); + +const repoRoot = prepare.repoRoot; +const tapePath = path.join(repoRoot, "docs", "ripple-gate-demo.tape"); +const primaryOut = path.join(repoRoot, "resources", "ripple-gate-demo.gif"); +const docsOut = path.join(repoRoot, "docs", "media", "ripple-gate-demo.gif"); + +function assertVhsAvailable() { + const probe = spawnSync("vhs", ["--version"], { + encoding: "utf8", + shell: process.platform === "win32", + }); + if (probe.status !== 0) { + throw new Error( + "vhs was not found on PATH. Install it from https://github.com/charmbracelet/vhs " + + "(e.g. `winget install charmbracelet.vhs` or `brew install vhs`)." + ); + } + console.log(`[vhs] using ${probe.stdout.trim()}`); +} + +function record(binDir) { + if (!fs.existsSync(tapePath)) { + throw new Error(`Tape not found: ${tapePath}`); + } + fs.mkdirSync(path.dirname(primaryOut), { recursive: true }); + + // Put the ripple shim first on PATH so the tape's bare `ripple demo` resolves + // to the build we just made. + const env = { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + // Never publish to the cloud while recording a public asset. + RIPPLE_API_KEY: "", + RIPPLE_CLOUD_URL: "", + }; + + console.log("[vhs] recording (this takes ~40s of real demo runtime)..."); + // Pass a repo-relative path: an absolute path containing spaces gets split by + // the shell on Windows ("accepts at most 1 arg(s), received 2"). + const tapeArg = path.relative(repoRoot, tapePath).split(path.sep).join("/"); + const result = spawnSync("vhs", [tapeArg], { + cwd: repoRoot, + stdio: "inherit", + env, + shell: process.platform === "win32", + }); + if (result.status !== 0) { + throw new Error(`vhs exited with code ${result.status}`); + } + if (!fs.existsSync(primaryOut)) { + throw new Error(`vhs did not produce ${primaryOut}`); + } +} + +function publish() { + fs.mkdirSync(path.dirname(docsOut), { recursive: true }); + fs.copyFileSync(primaryOut, docsOut); + const sizeMb = (fs.statSync(primaryOut).size / (1024 * 1024)).toFixed(1); + console.log(`[vhs] wrote ${path.relative(repoRoot, primaryOut)} (${sizeMb} MB)`); + console.log(`[vhs] wrote ${path.relative(repoRoot, docsOut)}`); + if (Number(sizeMb) > 12) { + console.warn( + `[vhs] warning: ${sizeMb} MB is large for a README asset. ` + + "Lower Framerate or Height in docs/ripple-gate-demo.tape to shrink it." + ); + } +} + +function main() { + assertVhsAvailable(); + const binDir = prepare.main(); + record(binDir); + publish(); +} + +try { + main(); +} catch (err) { + console.error(`[vhs] build failed: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; +} diff --git a/scripts/prepare-vhs-gate-demo.js b/scripts/prepare-vhs-gate-demo.js new file mode 100644 index 0000000..8b482e7 --- /dev/null +++ b/scripts/prepare-vhs-gate-demo.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node +"use strict"; + +/** + * Prepares the environment for recording docs/ripple-gate-demo.tape. + * + * The tape invokes a bare `ripple` command, so this script builds the CLI from + * the current source and puts a `ripple` shim on PATH that points at the freshly + * built dist. That guarantees the recording shows the code in this working tree, + * not a globally installed or previously published version. + * + * Run directly with `npm run demo:vhs-setup`, or let `npm run demo:gif` call it. + */ + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const repoRoot = path.resolve(__dirname, ".."); +const cliDist = path.join(repoRoot, "packages", "cli", "dist", "index.js"); +const binDir = path.join(repoRoot, "node_modules", ".vhs-bin"); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: repoRoot, + stdio: "inherit", + shell: process.platform === "win32", + ...options, + }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}`); + } +} + +function buildCli() { + console.log("[vhs] building @getripple/core and @getripple/cli..."); + run("npm", ["run", "build"], { cwd: path.join(repoRoot, "packages", "core") }); + run("npm", ["run", "build"], { cwd: path.join(repoRoot, "packages", "cli") }); + if (!fs.existsSync(cliDist)) { + throw new Error(`CLI build did not produce ${cliDist}`); + } +} + +/** + * Writes a `ripple` shim so the tape can type a natural-looking command. + * Both a POSIX shim and a .cmd shim are written so the tape works whichever + * shell VHS ends up using. + */ +function writeRippleShim() { + fs.mkdirSync(binDir, { recursive: true }); + + const posixShim = path.join(binDir, "ripple"); + fs.writeFileSync( + posixShim, + `#!/bin/sh\nexec "${process.execPath.replace(/\\/g, "/")}" "${cliDist.replace(/\\/g, "/")}" "$@"\n`, + "utf8" + ); + try { + fs.chmodSync(posixShim, 0o755); + } catch { + // chmod is best-effort on Windows. + } + + fs.writeFileSync( + path.join(binDir, "ripple.cmd"), + `@echo off\r\n"${process.execPath}" "${cliDist}" %*\r\n`, + "utf8" + ); + + return binDir; +} + +function main() { + buildCli(); + const dir = writeRippleShim(); + console.log(`[vhs] ripple shim ready at ${dir}`); + return dir; +} + +if (require.main === module) { + try { + main(); + } catch (err) { + console.error(`[vhs] setup failed: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } +} + +module.exports = { main, binDir, cliDist, repoRoot }; diff --git a/tsconfig.json b/tsconfig.json index b15cacb..8741161 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "outDir": "./out", "rootDir": ".", "baseUrl": ".", + "ignoreDeprecations": "6.0", "paths": { "@getripple/core": ["packages/core/src/index"] },