@getripple/cli 1.0.12 → 1.0.14-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/index.js +621 -79
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -24,10 +24,12 @@ var __importStar = (this && this.__importStar) || function (mod) {
|
|
|
24
24
|
return result;
|
|
25
25
|
};
|
|
26
26
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
const os = __importStar(require("os"));
|
|
27
28
|
const fs = __importStar(require("fs"));
|
|
28
29
|
const path = __importStar(require("path"));
|
|
29
30
|
const child_process_1 = require("child_process");
|
|
30
31
|
const core_1 = require("@getripple/core");
|
|
32
|
+
const crypto = __importStar(require("crypto"));
|
|
31
33
|
const CONTROL_MODES = ["brainstorm", "function", "file", "task", "pr"];
|
|
32
34
|
function usage() {
|
|
33
35
|
return [
|
|
@@ -65,6 +67,7 @@ function usage() {
|
|
|
65
67
|
" ripple agent",
|
|
66
68
|
" ripple agent setup [--print] [--force]",
|
|
67
69
|
" ripple hook install [--print] [--force]",
|
|
70
|
+
" ripple demo [--publish] [--json]",
|
|
68
71
|
"",
|
|
69
72
|
"Options:",
|
|
70
73
|
" --json, -j Print machine-readable JSON",
|
|
@@ -88,6 +91,7 @@ function usage() {
|
|
|
88
91
|
" --github-annotations Emit GitHub Actions annotations for CI findings",
|
|
89
92
|
" --print Print generated setup content instead of writing files",
|
|
90
93
|
" --force Overwrite existing generated setup files",
|
|
94
|
+
" --publish ripple demo only: send the run to Ripple Cloud and print a PUBLIC share link (needs RIPPLE_API_KEY)",
|
|
91
95
|
"",
|
|
92
96
|
"Examples:",
|
|
93
97
|
" ripple init",
|
|
@@ -319,6 +323,7 @@ function parseCliArgs(argv) {
|
|
|
319
323
|
githubAnnotations: false,
|
|
320
324
|
force: false,
|
|
321
325
|
print: false,
|
|
326
|
+
publish: false,
|
|
322
327
|
};
|
|
323
328
|
for (let i = 0; i < argv.length; i++) {
|
|
324
329
|
const token = argv[i];
|
|
@@ -362,6 +367,10 @@ function parseCliArgs(argv) {
|
|
|
362
367
|
options.print = true;
|
|
363
368
|
continue;
|
|
364
369
|
}
|
|
370
|
+
if (token === "--publish") {
|
|
371
|
+
options.publish = true;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
365
374
|
if (token === "--last") {
|
|
366
375
|
const value = argv[i + 1];
|
|
367
376
|
if (!value || value.startsWith("-")) {
|
|
@@ -639,13 +648,15 @@ function rippleDirectRunnerHookLines() {
|
|
|
639
648
|
}
|
|
640
649
|
const GITHUB_ACTIONS_WORKFLOW_PATH = core_1.RIPPLE_CI_WORKFLOW_PATH;
|
|
641
650
|
function githubActionsWorkflow() {
|
|
651
|
+
// This function generates the definitive, user-friendly, public-package-based workflow.
|
|
652
|
+
// It correctly uses `npx` with `@latest` to ensure the CI runner always gets
|
|
653
|
+
// the most recently published, working version of the CLI.
|
|
642
654
|
return [
|
|
643
655
|
"name: Ripple Enterprise Gate",
|
|
644
656
|
"",
|
|
645
657
|
"on:",
|
|
646
658
|
" pull_request:",
|
|
647
|
-
"
|
|
648
|
-
" branches: [main, master]",
|
|
659
|
+
" types: [opened, synchronize, reopened]",
|
|
649
660
|
"",
|
|
650
661
|
"permissions:",
|
|
651
662
|
" contents: read",
|
|
@@ -653,23 +664,27 @@ function githubActionsWorkflow() {
|
|
|
653
664
|
" checks: write",
|
|
654
665
|
"",
|
|
655
666
|
"jobs:",
|
|
656
|
-
" ripple:",
|
|
667
|
+
" ripple-gate:",
|
|
657
668
|
" name: Ripple authorization gate",
|
|
658
669
|
" runs-on: ubuntu-latest",
|
|
659
670
|
" steps:",
|
|
660
|
-
" - name: Checkout",
|
|
671
|
+
" - name: Checkout PR Code",
|
|
661
672
|
" uses: actions/checkout@v4",
|
|
662
673
|
" with:",
|
|
663
674
|
" fetch-depth: 0",
|
|
664
|
-
"
|
|
675
|
+
" ",
|
|
676
|
+
" - name: Setup Node.js",
|
|
665
677
|
" uses: actions/setup-node@v4",
|
|
666
678
|
" with:",
|
|
667
|
-
" node-version:
|
|
679
|
+
" node-version: '22.x' # Use a current LTS version",
|
|
680
|
+
"",
|
|
668
681
|
" - name: Ripple CI gate",
|
|
669
|
-
` run: npx -y ${rippleCliPackageSpec()} ci --base origin/\\\${{ github.base_ref || 'main' }} --github-annotations --sha \\\${{ github.event.pull_request.head.sha || github.sha }}`,
|
|
670
682
|
" env:",
|
|
671
683
|
" RIPPLE_API_KEY: ${{ secrets.RIPPLE_API_KEY }}",
|
|
672
684
|
" RIPPLE_CLOUD_URL: ${{ secrets.RIPPLE_CLOUD_URL }}",
|
|
685
|
+
" run: |",
|
|
686
|
+
" # Use npx to fetch the same published CLI version that generated this workflow.",
|
|
687
|
+
` npx -y ${rippleCliPackageSpec()} ci --base origin/\${{ github.base_ref }} --github-annotations --sha \${{ github.event.pull_request.head.sha || github.sha }}`,
|
|
673
688
|
"",
|
|
674
689
|
].join("\n");
|
|
675
690
|
}
|
|
@@ -3411,8 +3426,6 @@ async function auditCommand(options) {
|
|
|
3411
3426
|
}
|
|
3412
3427
|
applyStrictExit(options.strict && strictAuditShouldFail(audit));
|
|
3413
3428
|
}
|
|
3414
|
-
// in: ripple-main/packages/cli/src/index.ts
|
|
3415
|
-
// REPLACE THE ENTIRE OLD `gateCommand` FUNCTION WITH THIS:
|
|
3416
3429
|
async function gateCommand(options) {
|
|
3417
3430
|
if (selectedChangeModeCount(options) > 1) {
|
|
3418
3431
|
throw new Error("Choose one gate/audit mode: --staged, --worktree, or --changed --base <ref>");
|
|
@@ -3456,60 +3469,13 @@ async function gateCommand(options) {
|
|
|
3456
3469
|
else {
|
|
3457
3470
|
printGateSummary(gate);
|
|
3458
3471
|
}
|
|
3459
|
-
//
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
fs.renameSync(intentPath, consumedIntentCachePath);
|
|
3467
|
-
console.log("\n[Ripple] Intent validated and consumed by gate. Ready for commit.");
|
|
3468
|
-
}
|
|
3469
|
-
catch (err) {
|
|
3470
|
-
const errorDetail = err instanceof Error ? err.message : String(err);
|
|
3471
|
-
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}`);
|
|
3472
|
-
}
|
|
3473
|
-
}
|
|
3474
|
-
// Sync a redacted, privacy-safe audit receipt to the cloud.
|
|
3475
|
-
if (process.env.RIPPLE_API_KEY) {
|
|
3476
|
-
const cloudDecision = gate.canContinue ? "continue" :
|
|
3477
|
-
gate.needsHuman ? "human-review" : "blocked";
|
|
3478
|
-
const commitSha = options.sha ?? (0, core_1.getCurrentCommitSha)(child_process_1.execSync);
|
|
3479
|
-
const branch = (0, core_1.getCurrentBranch)(child_process_1.execSync);
|
|
3480
|
-
const actor = (0, core_1.getCurrentActor)(child_process_1.execSync);
|
|
3481
|
-
const redactedPayload = {
|
|
3482
|
-
decision: gate.decision,
|
|
3483
|
-
status: gate.status,
|
|
3484
|
-
risk: {
|
|
3485
|
-
level: gate.risk.level,
|
|
3486
|
-
score: gate.risk.score,
|
|
3487
|
-
summary: gate.risk.summary,
|
|
3488
|
-
},
|
|
3489
|
-
auditStatus: audit.status,
|
|
3490
|
-
approvalStatus: audit.approvalStatus.status,
|
|
3491
|
-
intent: audit.intent,
|
|
3492
|
-
mode: audit.mode,
|
|
3493
|
-
baseRef: audit.baseRef,
|
|
3494
|
-
blockingReasons: audit.blockingReasons,
|
|
3495
|
-
};
|
|
3496
|
-
const cloudResult = await (0, core_1.syncAuditToCloud)({
|
|
3497
|
-
intentId: audit.intent.id,
|
|
3498
|
-
decision: cloudDecision,
|
|
3499
|
-
commitSha,
|
|
3500
|
-
branch,
|
|
3501
|
-
actor,
|
|
3502
|
-
source: "cli",
|
|
3503
|
-
payload: redactedPayload,
|
|
3504
|
-
});
|
|
3505
|
-
if (cloudResult.sent) {
|
|
3506
|
-
const icon = cloudDecision === "continue" ? "✓" : "⛔";
|
|
3507
|
-
console.log(`\n[Ripple Cloud] ${icon} Gate decision recorded: ${cloudDecision} (${commitSha.slice(0, 7)})`);
|
|
3508
|
-
}
|
|
3509
|
-
else if (cloudResult.error) {
|
|
3510
|
-
console.warn(`\n[Ripple Cloud] ⚠ Sync failed (non-blocking): ${cloudResult.error}`);
|
|
3511
|
-
}
|
|
3512
|
-
}
|
|
3472
|
+
// The gate is a pure, read-only check: it validates staged changes against
|
|
3473
|
+
// the saved intent and never mutates it. Consuming the intent here would
|
|
3474
|
+
// destroy it on any manual/preview run even when no commit follows. Intent
|
|
3475
|
+
// consumption happens in the post-commit hook, which only fires on a real
|
|
3476
|
+
// commit — see ripplePostCommitHookBlock. The local gate's only job is to
|
|
3477
|
+
// block or allow the commit locally; CI is the sole authority for the cloud
|
|
3478
|
+
// audit trail.
|
|
3513
3479
|
applyStrictExit(options.strict && !gate.canContinue);
|
|
3514
3480
|
}
|
|
3515
3481
|
function approveCommand(options) {
|
|
@@ -3714,20 +3680,61 @@ function approvalCommand(options) {
|
|
|
3714
3680
|
}
|
|
3715
3681
|
printApprovalStatus(status);
|
|
3716
3682
|
}
|
|
3717
|
-
// REPLACE
|
|
3718
|
-
// in: ripple-main/packages/cli/src/index.ts
|
|
3719
|
-
// REPLACE THE ENTIRE OLD `ciCommand` FUNCTION WITH THIS:
|
|
3683
|
+
// THEN, REPLACE THE ENTIRE ciCommand with this:
|
|
3720
3684
|
async function ciCommand(options) {
|
|
3685
|
+
// --- FIX 1 & 2: Define workspaceRoot BEFORE it is used ---
|
|
3721
3686
|
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
3687
|
+
// --- FIX 3: This logic is now correctly placed and will find workspaceRoot ---
|
|
3688
|
+
// Verify policy file has not been tampered with
|
|
3689
|
+
const policyPath = path.join(workspaceRoot, '.ripple', 'policy.json');
|
|
3690
|
+
if (fs.existsSync(policyPath)) {
|
|
3691
|
+
const localPolicyHash = crypto
|
|
3692
|
+
.createHash('sha256')
|
|
3693
|
+
.update(fs.readFileSync(policyPath, 'utf8'))
|
|
3694
|
+
.digest('hex');
|
|
3695
|
+
// --- NOTE: You will need to implement this function and its API endpoint ---
|
|
3696
|
+
// Fetch registered policy hash from cloud
|
|
3697
|
+
// const registeredHash = await fetchRegisteredPolicyHash();
|
|
3698
|
+
// if (registeredHash && registeredHash !== localPolicyHash) {
|
|
3699
|
+
// console.error('[Ripple] ⛔ SECURITY: policy.json has been modified since registration.');
|
|
3700
|
+
// console.error('[Ripple] ⛔ Run `ripple policy register` to legitimately update the policy.');
|
|
3701
|
+
// process.exit(1);
|
|
3702
|
+
// }
|
|
3703
|
+
}
|
|
3704
|
+
// The rest of the function proceeds as before
|
|
3722
3705
|
const baseRef = options.base ?? defaultCiBaseRef();
|
|
3723
3706
|
const files = (0, core_1.listGitChangedFiles)(workspaceRoot, baseRef);
|
|
3724
3707
|
const emitGithubAnnotations = shouldEmitGithubAnnotations(options);
|
|
3725
3708
|
const commitSha = options.sha ?? (0, core_1.getCurrentCommitSha)(child_process_1.execSync);
|
|
3709
|
+
if (options.intent) {
|
|
3710
|
+
const audit = await buildAuditFromCliOptions({
|
|
3711
|
+
...options,
|
|
3712
|
+
changed: true,
|
|
3713
|
+
staged: false,
|
|
3714
|
+
worktree: false,
|
|
3715
|
+
base: baseRef,
|
|
3716
|
+
});
|
|
3717
|
+
if (options.json) {
|
|
3718
|
+
printJson({ ...audit, gate: (0, core_1.buildRippleGateSummary)(audit) });
|
|
3719
|
+
}
|
|
3720
|
+
else if (options.agent) {
|
|
3721
|
+
printAgentAuditSummary(audit);
|
|
3722
|
+
}
|
|
3723
|
+
else {
|
|
3724
|
+
printAuditSummary(audit);
|
|
3725
|
+
}
|
|
3726
|
+
if (emitGithubAnnotations && !options.json) {
|
|
3727
|
+
printGithubAuditAnnotations(audit);
|
|
3728
|
+
}
|
|
3729
|
+
writeGithubAuditStepSummary(audit);
|
|
3730
|
+
applyStrictExit(options.strict && !audit.canProceed);
|
|
3731
|
+
return;
|
|
3732
|
+
}
|
|
3726
3733
|
// FETCH THE AUTHORITATIVE INTENT FROM THE CLOUD.
|
|
3727
3734
|
const intent = await (0, core_1.fetchActiveIntentForCommit)(commitSha);
|
|
3728
3735
|
if (!intent) {
|
|
3729
3736
|
// If no active intent is found in the cloud, run a policy-only audit.
|
|
3730
|
-
|
|
3737
|
+
console.log("Ripple CI policy audit");
|
|
3731
3738
|
console.log("[Ripple CI] No active cloud intent found. Running in policy-only audit mode.");
|
|
3732
3739
|
const summary = await buildCheckSummaryForFiles({
|
|
3733
3740
|
workspaceRoot,
|
|
@@ -3737,6 +3744,9 @@ async function ciCommand(options) {
|
|
|
3737
3744
|
tokenBudget: options.budget,
|
|
3738
3745
|
});
|
|
3739
3746
|
const policySync = buildPolicySyncSummary(workspaceRoot);
|
|
3747
|
+
console.log(`Policy sync: ${policySync.status}`);
|
|
3748
|
+
console.log("Blocking: false");
|
|
3749
|
+
console.log("Intent: none (local intents are not required in CI audit mode)");
|
|
3740
3750
|
if (emitGithubAnnotations) {
|
|
3741
3751
|
printGithubPolicyAuditAnnotations(summary, policySync);
|
|
3742
3752
|
printGithubNoticeAnnotation({
|
|
@@ -3745,7 +3755,22 @@ async function ciCommand(options) {
|
|
|
3745
3755
|
});
|
|
3746
3756
|
}
|
|
3747
3757
|
writeGithubPolicyAuditStepSummary(summary, policySync);
|
|
3748
|
-
|
|
3758
|
+
if (process.env.RIPPLE_API_KEY) {
|
|
3759
|
+
const branch = (0, core_1.getCurrentBranch)(child_process_1.execSync);
|
|
3760
|
+
const actor = (0, core_1.getCurrentActor)(child_process_1.execSync);
|
|
3761
|
+
const cloudResult = await (0, core_1.syncAuditToCloud)({
|
|
3762
|
+
intentId: `ci-policy-${commitSha.slice(0, 16)}`,
|
|
3763
|
+
decision: 'continue',
|
|
3764
|
+
commitSha,
|
|
3765
|
+
branch,
|
|
3766
|
+
actor,
|
|
3767
|
+
source: 'ci',
|
|
3768
|
+
payload: { mode: 'policy-audit', commitSha, branch, actor },
|
|
3769
|
+
});
|
|
3770
|
+
if (cloudResult.sent) {
|
|
3771
|
+
console.log(`[Ripple Cloud] ✓ Policy audit synced (${commitSha.slice(0, 7)})`);
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3749
3774
|
applyStrictExit(options.strict && summary.requiresAttention);
|
|
3750
3775
|
return;
|
|
3751
3776
|
}
|
|
@@ -3773,7 +3798,6 @@ async function ciCommand(options) {
|
|
|
3773
3798
|
printGithubAuditAnnotations(audit);
|
|
3774
3799
|
}
|
|
3775
3800
|
writeGithubAuditStepSummary(audit);
|
|
3776
|
-
// Sync the final audit result back to the cloud for the webhook to read.
|
|
3777
3801
|
if (process.env.RIPPLE_API_KEY) {
|
|
3778
3802
|
const gate = (0, core_1.buildRippleGateSummary)(audit);
|
|
3779
3803
|
const cloudDecision = gate.canContinue ? "continue" :
|
|
@@ -3811,7 +3835,6 @@ async function ciCommand(options) {
|
|
|
3811
3835
|
console.warn(`[Ripple Cloud] ⚠ Sync failed (non-blocking): ${cloudResult.error}`);
|
|
3812
3836
|
}
|
|
3813
3837
|
}
|
|
3814
|
-
// Exit with a non-zero code if the audit did not pass, failing the CI job.
|
|
3815
3838
|
applyStrictExit(!audit.canProceed);
|
|
3816
3839
|
}
|
|
3817
3840
|
async function doctorCommand(options) {
|
|
@@ -4082,6 +4105,56 @@ fi
|
|
|
4082
4105
|
|
|
4083
4106
|
set +e
|
|
4084
4107
|
|
|
4108
|
+
# ── SECURITY: Block tracked Ripple governance file modifications ─────────
|
|
4109
|
+
# AI agents and automated processes must never modify policy or agent
|
|
4110
|
+
# instruction files after they become part of the repository contract. Initial
|
|
4111
|
+
# additions are allowed so humans can commit the first ripple init setup.
|
|
4112
|
+
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)
|
|
4113
|
+
RIPPLE_PROTECTED_BLOCKED=""
|
|
4114
|
+
|
|
4115
|
+
if [ -n "$RIPPLE_PROTECTED_STAGED" ]; then
|
|
4116
|
+
if git rev-parse --verify HEAD >/dev/null 2>&1; then
|
|
4117
|
+
for f in $RIPPLE_PROTECTED_STAGED; do
|
|
4118
|
+
if git cat-file -e "HEAD:$f" 2>/dev/null; then
|
|
4119
|
+
RIPPLE_PROTECTED_BLOCKED="$RIPPLE_PROTECTED_BLOCKED
|
|
4120
|
+
$f"
|
|
4121
|
+
fi
|
|
4122
|
+
done
|
|
4123
|
+
fi
|
|
4124
|
+
fi
|
|
4125
|
+
|
|
4126
|
+
if [ -n "$RIPPLE_PROTECTED_BLOCKED" ]; then
|
|
4127
|
+
echo ""
|
|
4128
|
+
echo "╔══════════════════════════════════════════════════════════════╗"
|
|
4129
|
+
echo "║ [RIPPLE SECURITY] PROTECTED FILE MODIFICATION DETECTED ║"
|
|
4130
|
+
echo "╚══════════════════════════════════════════════════════════════╝"
|
|
4131
|
+
echo ""
|
|
4132
|
+
echo " The following Ripple configuration files were staged for commit:"
|
|
4133
|
+
echo ""
|
|
4134
|
+
echo "$RIPPLE_PROTECTED_BLOCKED" | while IFS= read -r f; do
|
|
4135
|
+
if [ -z "$f" ]; then
|
|
4136
|
+
continue
|
|
4137
|
+
fi
|
|
4138
|
+
echo " ⛔ $f"
|
|
4139
|
+
done
|
|
4140
|
+
echo ""
|
|
4141
|
+
echo " Ripple configuration files define the rules of governance."
|
|
4142
|
+
echo " AI agents and automated processes cannot modify them."
|
|
4143
|
+
echo ""
|
|
4144
|
+
echo " If you are an AI agent:"
|
|
4145
|
+
echo " - DO NOT attempt to modify these files."
|
|
4146
|
+
echo " - Ask the human developer to make this change manually."
|
|
4147
|
+
echo ""
|
|
4148
|
+
echo " If you are a human developer making a legitimate policy change:"
|
|
4149
|
+
echo " - Unstage the config file: git reset HEAD <file>"
|
|
4150
|
+
echo " - Commit your other changes first"
|
|
4151
|
+
echo " - Then commit the policy change alone in a separate commit"
|
|
4152
|
+
echo " - The cloud will flag it for review in the PR"
|
|
4153
|
+
echo ""
|
|
4154
|
+
exit 1
|
|
4155
|
+
fi
|
|
4156
|
+
# ── End security protection ───────────────────────────────────────────────
|
|
4157
|
+
|
|
4085
4158
|
ripple_run() {
|
|
4086
4159
|
${rippleDirectRunnerHookLines().join("\n")}
|
|
4087
4160
|
}
|
|
@@ -4140,15 +4213,19 @@ function ripplePreCommitHookScript() {
|
|
|
4140
4213
|
function ripplePostCommitHookBlock() {
|
|
4141
4214
|
return [
|
|
4142
4215
|
RIPPLE_POST_COMMIT_HOOK_START,
|
|
4143
|
-
`# After a successful commit, this hook
|
|
4144
|
-
#
|
|
4145
|
-
#
|
|
4216
|
+
`# After a successful commit, this hook consumes the active intent so an old,
|
|
4217
|
+
# already-committed approval cannot silently gate a later unrelated commit
|
|
4218
|
+
# ("ghost intent"). Consumption happens here — only on a real commit — and not
|
|
4219
|
+
# in 'ripple gate', so manual/preview gate runs never destroy the intent.
|
|
4220
|
+
# The consumed intent is archived (not deleted) so it can be recovered.
|
|
4146
4221
|
set +e
|
|
4147
4222
|
|
|
4223
|
+
ACTIVE_INTENT_FILE=".ripple/intents/latest.json"
|
|
4148
4224
|
CONSUMED_INTENT_FILE=".ripple/.cache/consumed-intent.json"
|
|
4149
|
-
if [ -f "$
|
|
4150
|
-
|
|
4151
|
-
|
|
4225
|
+
if [ -f "$ACTIVE_INTENT_FILE" ]; then
|
|
4226
|
+
mkdir -p ".ripple/.cache"
|
|
4227
|
+
mv "$ACTIVE_INTENT_FILE" "$CONSUMED_INTENT_FILE"
|
|
4228
|
+
echo "[Ripple] Consumed and cleared local intent after commit (archived to $CONSUMED_INTENT_FILE)."
|
|
4152
4229
|
fi`,
|
|
4153
4230
|
RIPPLE_POST_COMMIT_HOOK_END,
|
|
4154
4231
|
"",
|
|
@@ -4174,7 +4251,7 @@ function preferredHookPath(workspaceRoot, hookName) {
|
|
|
4174
4251
|
return path.join(workspaceRoot, ".git", "hooks", hookName);
|
|
4175
4252
|
}
|
|
4176
4253
|
function installRippleHookBlock(input) {
|
|
4177
|
-
const { hookPath, fullScript, block, marker } = input;
|
|
4254
|
+
const { hookPath, fullScript, block, marker, endMarker } = input;
|
|
4178
4255
|
if (!fs.existsSync(hookPath)) {
|
|
4179
4256
|
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
|
|
4180
4257
|
fs.writeFileSync(hookPath, fullScript, { encoding: "utf8", mode: 0o755 });
|
|
@@ -4187,8 +4264,30 @@ function installRippleHookBlock(input) {
|
|
|
4187
4264
|
return "created";
|
|
4188
4265
|
}
|
|
4189
4266
|
const existing = fs.readFileSync(hookPath, "utf8");
|
|
4190
|
-
|
|
4191
|
-
|
|
4267
|
+
// An existing Ripple block is replaced in place when its content changed, so
|
|
4268
|
+
// upgrades actually land instead of silently reporting "already-present".
|
|
4269
|
+
const startIndex = existing.indexOf(marker);
|
|
4270
|
+
if (startIndex !== -1) {
|
|
4271
|
+
const endIndex = existing.indexOf(endMarker, startIndex);
|
|
4272
|
+
if (endIndex === -1) {
|
|
4273
|
+
// Start marker without a matching end marker: leave the file untouched.
|
|
4274
|
+
return "already-present";
|
|
4275
|
+
}
|
|
4276
|
+
const blockEnd = endIndex + endMarker.length;
|
|
4277
|
+
const currentBlock = existing.slice(startIndex, blockEnd);
|
|
4278
|
+
const newBlock = block.trim();
|
|
4279
|
+
if (currentBlock === newBlock) {
|
|
4280
|
+
return "already-present";
|
|
4281
|
+
}
|
|
4282
|
+
const updated = existing.slice(0, startIndex) + newBlock + existing.slice(blockEnd);
|
|
4283
|
+
fs.writeFileSync(hookPath, updated, "utf8");
|
|
4284
|
+
try {
|
|
4285
|
+
fs.chmodSync(hookPath, 0o755);
|
|
4286
|
+
}
|
|
4287
|
+
catch {
|
|
4288
|
+
// chmod is best-effort on Windows.
|
|
4289
|
+
}
|
|
4290
|
+
return "updated";
|
|
4192
4291
|
}
|
|
4193
4292
|
const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
4194
4293
|
fs.writeFileSync(hookPath, `${existing}${separator}\n${block}\n`, "utf8");
|
|
@@ -4213,12 +4312,14 @@ function installRippleHooks(workspaceRoot) {
|
|
|
4213
4312
|
fullScript: content,
|
|
4214
4313
|
block: ripplePreCommitHookBlock(),
|
|
4215
4314
|
marker: RIPPLE_PRE_COMMIT_HOOK_START,
|
|
4315
|
+
endMarker: RIPPLE_PRE_COMMIT_HOOK_END,
|
|
4216
4316
|
});
|
|
4217
4317
|
const postCommitAction = installRippleHookBlock({
|
|
4218
4318
|
hookPath: postCommitHookPath,
|
|
4219
4319
|
fullScript: postCommitContent,
|
|
4220
4320
|
block: ripplePostCommitHookBlock(),
|
|
4221
4321
|
marker: RIPPLE_POST_COMMIT_HOOK_START,
|
|
4322
|
+
endMarker: RIPPLE_POST_COMMIT_HOOK_END,
|
|
4222
4323
|
});
|
|
4223
4324
|
const wroteSomething = preCommitAction !== "already-present" || postCommitAction !== "already-present";
|
|
4224
4325
|
return {
|
|
@@ -4282,12 +4383,14 @@ function hookInstallCommand(subcommand, options) {
|
|
|
4282
4383
|
fullScript: content,
|
|
4283
4384
|
block: ripplePreCommitHookBlock(),
|
|
4284
4385
|
marker: RIPPLE_PRE_COMMIT_HOOK_START,
|
|
4386
|
+
endMarker: RIPPLE_PRE_COMMIT_HOOK_END,
|
|
4285
4387
|
});
|
|
4286
4388
|
const postCommitAction = installRippleHookBlock({
|
|
4287
4389
|
hookPath: postCommitHookPath,
|
|
4288
4390
|
fullScript: postCommitContent,
|
|
4289
4391
|
block: ripplePostCommitHookBlock(),
|
|
4290
4392
|
marker: RIPPLE_POST_COMMIT_HOOK_START,
|
|
4393
|
+
endMarker: RIPPLE_POST_COMMIT_HOOK_END,
|
|
4291
4394
|
});
|
|
4292
4395
|
const wroteSomething = preCommitAction !== "already-present" || postCommitAction !== "already-present";
|
|
4293
4396
|
const summary = {
|
|
@@ -4905,9 +5008,448 @@ async function focusCommand(filePath, options) {
|
|
|
4905
5008
|
engine.dispose();
|
|
4906
5009
|
}
|
|
4907
5010
|
}
|
|
5011
|
+
// ========================================================================
|
|
5012
|
+
// FINAL RIPPLE DEMO CODE BLOCK
|
|
5013
|
+
// Replace all previous demo-related code in `index.ts` with this.
|
|
5014
|
+
// ========================================================================
|
|
5015
|
+
// This is our "UI" library for the terminal.
|
|
5016
|
+
const demoUi = {
|
|
5017
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
5018
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
5019
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
5020
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
5021
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
5022
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
5023
|
+
magenta: (s) => `\x1b[35m${s}\x1b[0m`,
|
|
5024
|
+
underline: (s) => `\x1b[4m${s}\x1b[0m`,
|
|
5025
|
+
/** Solid red block, white text — used once, for the rejection banner. */
|
|
5026
|
+
bgRed: (s) => `\x1b[41m\x1b[37m${s}\x1b[0m`,
|
|
5027
|
+
};
|
|
5028
|
+
// ========================================================================
|
|
5029
|
+
// REPLACE YOUR OLD printDemoGateResult WITH THIS NEW, ROBUST VERSION
|
|
5030
|
+
// ========================================================================
|
|
5031
|
+
/** Prints a simplified, story-focused gate verdict for the demo. */
|
|
5032
|
+
function printDemoGateResult(gate) {
|
|
5033
|
+
const { bold, dim, green, red } = demoUi;
|
|
5034
|
+
// Helper to safely shorten a symbol name.
|
|
5035
|
+
const shortSymbol = (symbol) => (symbol ? symbol.split("::").pop() ?? symbol : "unknown");
|
|
5036
|
+
// Helper to safely create a list from an array that might be undefined.
|
|
5037
|
+
const list = (symbols) => {
|
|
5038
|
+
if (!symbols || symbols.length === 0) {
|
|
5039
|
+
return dim("none");
|
|
5040
|
+
}
|
|
5041
|
+
return symbols.map(shortSymbol).join(", ");
|
|
5042
|
+
};
|
|
5043
|
+
const statusLabel = gate.canContinue ? green(bold("CONTINUE")) : red(bold("STOP"));
|
|
5044
|
+
console.log(`${bold("Ripple gate:")} ${statusLabel}`);
|
|
5045
|
+
const approvedSymbols = list(gate.allowedSymbols);
|
|
5046
|
+
const changedSymbols = list(gate.reviewPacket?.actualChanges?.changedSymbols);
|
|
5047
|
+
const outsideSymbols = list(gate.changedOutsideBoundarySymbols);
|
|
5048
|
+
console.log(` Approved to change : ${approvedSymbols}`);
|
|
5049
|
+
// For the story, only show "Actually changed" if it's a violation.
|
|
5050
|
+
if (!gate.canContinue && changedSymbols !== approvedSymbols) {
|
|
5051
|
+
console.log(` Actually changed : ${red(changedSymbols)}`);
|
|
5052
|
+
}
|
|
5053
|
+
console.log(` Outside boundary : ${(gate.changedOutsideBoundarySymbols?.length ?? 0) > 0 ? red(outsideSymbols) : dim("none")}`);
|
|
5054
|
+
// Always the engine's real score. A demo that prints a friendlier number than
|
|
5055
|
+
// the tool produces is the one thing a prospect can catch us on.
|
|
5056
|
+
const riskLabel = `${gate.risk.level.toUpperCase()} ${gate.risk.score}/100`;
|
|
5057
|
+
if (gate.canContinue) {
|
|
5058
|
+
console.log(` Risk : ${riskLabel}`);
|
|
5059
|
+
if (gate.risk?.reasons?.[0]) {
|
|
5060
|
+
console.log(dim(` ${gate.risk.reasons[0].message}`));
|
|
5061
|
+
}
|
|
5062
|
+
}
|
|
5063
|
+
else {
|
|
5064
|
+
console.log(` Risk : ${red(riskLabel)}`);
|
|
5065
|
+
if (gate.risk?.reasons?.[0]) {
|
|
5066
|
+
console.log(dim(` ${gate.risk.reasons[0].message}`));
|
|
5067
|
+
}
|
|
5068
|
+
if (gate.fixNow?.[0]) {
|
|
5069
|
+
// The engine's fix text uses fully-qualified symbols; shorten them to
|
|
5070
|
+
// match the rest of this panel, then wrap under the value column.
|
|
5071
|
+
const fixText = gate.fixNow[0].replace(/[\w./-]+::([A-Za-z_][A-Za-z0-9_]*)/g, (_match, name) => name);
|
|
5072
|
+
const indent = " ".repeat(" Fix : ".length);
|
|
5073
|
+
wrapDemoText(fixText, 46).forEach((line, index) => {
|
|
5074
|
+
console.log(index === 0 ? ` Fix : ${red(line)}` : `${indent}${red(line)}`);
|
|
5075
|
+
});
|
|
5076
|
+
}
|
|
5077
|
+
}
|
|
5078
|
+
}
|
|
5079
|
+
/** Greedy word wrap so long engine text stays inside the demo panel. */
|
|
5080
|
+
function wrapDemoText(text, width) {
|
|
5081
|
+
const lines = [];
|
|
5082
|
+
let current = "";
|
|
5083
|
+
for (const word of text.split(/\s+/)) {
|
|
5084
|
+
if (!current) {
|
|
5085
|
+
current = word;
|
|
5086
|
+
}
|
|
5087
|
+
else if (`${current} ${word}`.length <= width) {
|
|
5088
|
+
current += ` ${word}`;
|
|
5089
|
+
}
|
|
5090
|
+
else {
|
|
5091
|
+
lines.push(current);
|
|
5092
|
+
current = word;
|
|
5093
|
+
}
|
|
5094
|
+
}
|
|
5095
|
+
if (current) {
|
|
5096
|
+
lines.push(current);
|
|
5097
|
+
}
|
|
5098
|
+
return lines;
|
|
5099
|
+
}
|
|
5100
|
+
/**
|
|
5101
|
+
* Opt-in publish of a demo run to Ripple Cloud, returning a public share URL.
|
|
5102
|
+
*
|
|
5103
|
+
* Deliberately conservative: it is skipped unless --publish was passed AND an
|
|
5104
|
+
* API key exists, it never throws, and it never changes the exit code. A demo
|
|
5105
|
+
* that cannot reach the network must still be a working demo.
|
|
5106
|
+
*
|
|
5107
|
+
* Demo receipts are tagged `source: "demo"` because they describe a synthetic
|
|
5108
|
+
* commit in a scratch repo, not a real project's history — the receipt page
|
|
5109
|
+
* relies on that field to label them.
|
|
5110
|
+
*/
|
|
5111
|
+
async function publishDemoReceipt(input) {
|
|
5112
|
+
const { dim, yellow } = demoUi;
|
|
5113
|
+
if (!input.publish) {
|
|
5114
|
+
return undefined;
|
|
5115
|
+
}
|
|
5116
|
+
// stderr, so --json stdout stays a single valid JSON document.
|
|
5117
|
+
const warn = (message) => {
|
|
5118
|
+
if (input.jsonMode) {
|
|
5119
|
+
console.error(message);
|
|
5120
|
+
}
|
|
5121
|
+
else {
|
|
5122
|
+
input.log(message);
|
|
5123
|
+
}
|
|
5124
|
+
};
|
|
5125
|
+
if (!process.env.RIPPLE_API_KEY) {
|
|
5126
|
+
warn(`\n${yellow("--publish skipped:")} set RIPPLE_API_KEY to publish this run to Ripple Cloud.`);
|
|
5127
|
+
return undefined;
|
|
5128
|
+
}
|
|
5129
|
+
input.log(dim("\n⏳ Publishing this run to Ripple Cloud..."));
|
|
5130
|
+
try {
|
|
5131
|
+
// Same shape `ripple ci` sends, so the receipt renderer has one schema.
|
|
5132
|
+
const result = await (0, core_1.syncAuditToCloud)({
|
|
5133
|
+
intentId: input.gate.intent.id,
|
|
5134
|
+
decision: input.gate.canContinue ? "continue" : input.gate.needsHuman ? "human-review" : "blocked",
|
|
5135
|
+
commitSha: input.readHeadSha(),
|
|
5136
|
+
branch: "demo",
|
|
5137
|
+
actor: "demo@ripple.dev",
|
|
5138
|
+
source: "demo",
|
|
5139
|
+
payload: {
|
|
5140
|
+
decision: input.gate.decision,
|
|
5141
|
+
status: input.gate.status,
|
|
5142
|
+
risk: {
|
|
5143
|
+
level: input.gate.risk.level,
|
|
5144
|
+
score: input.gate.risk.score,
|
|
5145
|
+
summary: input.gate.risk.summary,
|
|
5146
|
+
},
|
|
5147
|
+
auditStatus: input.gate.auditStatus,
|
|
5148
|
+
approvalStatus: input.gate.approvalStatus,
|
|
5149
|
+
intent: input.gate.intent,
|
|
5150
|
+
mode: input.gate.mode,
|
|
5151
|
+
baseRef: input.gate.baseRef,
|
|
5152
|
+
blockingReasons: input.gate.why,
|
|
5153
|
+
},
|
|
5154
|
+
share: { redaction: "minimal" },
|
|
5155
|
+
});
|
|
5156
|
+
if (!result.sent) {
|
|
5157
|
+
warn(`\n${yellow("--publish failed")} (demo unaffected): ${result.error ?? "unknown error"}`);
|
|
5158
|
+
return undefined;
|
|
5159
|
+
}
|
|
5160
|
+
if (!result.share?.url) {
|
|
5161
|
+
warn(`\n${yellow("--publish:")} run recorded, but this Ripple Cloud deployment returned no share link.`);
|
|
5162
|
+
return undefined;
|
|
5163
|
+
}
|
|
5164
|
+
return result.share.url;
|
|
5165
|
+
}
|
|
5166
|
+
catch (err) {
|
|
5167
|
+
warn(`\n${yellow("--publish failed")} (demo unaffected): ${err instanceof Error ? err.message : String(err)}`);
|
|
5168
|
+
return undefined;
|
|
5169
|
+
}
|
|
5170
|
+
}
|
|
5171
|
+
async function demoCommand(options) {
|
|
5172
|
+
const { bold, dim, cyan, green, yellow, red, magenta, underline, bgRed } = demoUi;
|
|
5173
|
+
const jsonMode = options.json;
|
|
5174
|
+
const fast = jsonMode ||
|
|
5175
|
+
options.agent ||
|
|
5176
|
+
!process.stdout.isTTY ||
|
|
5177
|
+
process.env.RIPPLE_DEMO_FAST === "1";
|
|
5178
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, fast ? 0 : ms));
|
|
5179
|
+
const log = jsonMode ? () => { } : (message) => console.log(message);
|
|
5180
|
+
const rule = () => log(dim("──────────────────────────────────────────────────"));
|
|
5181
|
+
const thick = () => log(cyan("══════════════════════════════════════════════════"));
|
|
5182
|
+
thick();
|
|
5183
|
+
log(bold(cyan("🚀 Welcome to the Ripple Demo")));
|
|
5184
|
+
log("A real AI boundary violation, caught by the real Ripple engine.");
|
|
5185
|
+
thick();
|
|
5186
|
+
await sleep(3000);
|
|
5187
|
+
const demoDir = fs.mkdtempSync(path.join(os.tmpdir(), "ripple-demo-"));
|
|
5188
|
+
// Two files: cart.ts is the low-risk file the boundary actually covers, so
|
|
5189
|
+
// the authorized edit reads LOW risk with no human-gate friction. checkout.ts
|
|
5190
|
+
// is payments code (critical by Ripple's own path rules) — untouched until
|
|
5191
|
+
// the rogue edit reaches into it, which is the reveal in Scene 4.
|
|
5192
|
+
const cartFile = "src/store/cart.ts";
|
|
5193
|
+
const checkoutFile = "src/billing/checkout.ts";
|
|
5194
|
+
try {
|
|
5195
|
+
// The demo repo must be self-contained: never inherit the user's git
|
|
5196
|
+
// identity, signing config, or hooks, and fail loudly if git does not run.
|
|
5197
|
+
// Without this, `git commit` silently fails on machines with no global
|
|
5198
|
+
// identity and the demo reports a verdict against the wrong repo state.
|
|
5199
|
+
const run = (args) => {
|
|
5200
|
+
const result = (0, child_process_1.spawnSync)("git", [
|
|
5201
|
+
"-c", "user.email=demo@ripple.dev",
|
|
5202
|
+
"-c", "user.name=Ripple Demo",
|
|
5203
|
+
"-c", "commit.gpgsign=false",
|
|
5204
|
+
...args,
|
|
5205
|
+
], { cwd: demoDir, stdio: "pipe", encoding: "utf8" });
|
|
5206
|
+
if (result.status !== 0) {
|
|
5207
|
+
throw new Error(`Demo git command failed: git ${args.join(" ")}\n${result.stderr ?? ""}`);
|
|
5208
|
+
}
|
|
5209
|
+
return result;
|
|
5210
|
+
};
|
|
5211
|
+
/** Same as run(), but returns the result instead of throwing. Used where a
|
|
5212
|
+
* non-zero exit is the point being demonstrated (a blocked commit). */
|
|
5213
|
+
const runAllowFail = (args) => (0, child_process_1.spawnSync)("git", [
|
|
5214
|
+
"-c", "user.email=demo@ripple.dev",
|
|
5215
|
+
"-c", "user.name=Ripple Demo",
|
|
5216
|
+
"-c", "commit.gpgsign=false",
|
|
5217
|
+
...args,
|
|
5218
|
+
], { cwd: demoDir, stdio: "pipe", encoding: "utf8" });
|
|
5219
|
+
const writeDemoFile = (relativePath, contents) => fs.writeFileSync(path.join(demoDir, relativePath), contents);
|
|
5220
|
+
/** Creates a genuine saved intent exactly the way `ripple plan --save` does,
|
|
5221
|
+
* so humanGate/boundaryRisk/protectedContracts are engine-derived rather
|
|
5222
|
+
* than asserted by the demo. */
|
|
5223
|
+
const realPlan = async (targetFile, symbol, task) => {
|
|
5224
|
+
const engine = createCliEngine(demoDir);
|
|
5225
|
+
try {
|
|
5226
|
+
await runWithQuietEngine(() => engine.initialScan());
|
|
5227
|
+
const summary = engine.planContext(task, targetFile, 4000);
|
|
5228
|
+
if (!summary) {
|
|
5229
|
+
throw new Error(`Demo file is not in the Ripple graph: ${targetFile}`);
|
|
5230
|
+
}
|
|
5231
|
+
const loadedPolicy = (0, core_1.loadRipplePolicy)(demoDir);
|
|
5232
|
+
const policyExplanation = (0, core_1.explainRipplePolicyForTarget)(loadedPolicy, targetFile, {
|
|
5233
|
+
controlMode: "function",
|
|
5234
|
+
});
|
|
5235
|
+
const planned = (0, core_1.buildChangeIntent)(summary, {
|
|
5236
|
+
controlMode: "function",
|
|
5237
|
+
allowedSymbols: [symbol],
|
|
5238
|
+
policy: (0, core_1.resolveRipplePolicyForTarget)(loadedPolicy, targetFile),
|
|
5239
|
+
policyExplanation,
|
|
5240
|
+
});
|
|
5241
|
+
planned.readinessSnapshot = (0, core_1.buildChangeIntentReadinessSnapshot)((0, core_1.buildRippleReadinessSummary)(demoDir, engine));
|
|
5242
|
+
(0, core_1.saveChangeIntent)(demoDir, planned, (0, core_1.defaultChangeIntentPath)(demoDir));
|
|
5243
|
+
return planned;
|
|
5244
|
+
}
|
|
5245
|
+
finally {
|
|
5246
|
+
engine.dispose();
|
|
5247
|
+
}
|
|
5248
|
+
};
|
|
5249
|
+
// --- SCENE 1: THE SETUP ---
|
|
5250
|
+
log(`\n${yellow("SCENE 1: THE SETUP")}`);
|
|
5251
|
+
rule();
|
|
5252
|
+
run(["init", "--initial-branch=main"]);
|
|
5253
|
+
fs.mkdirSync(path.join(demoDir, "src", "store"), { recursive: true });
|
|
5254
|
+
fs.mkdirSync(path.join(demoDir, "src", "billing"), { recursive: true });
|
|
5255
|
+
// Not exported: this keeps the authorized edit's contract-risk reason from
|
|
5256
|
+
// firing, so the real risk score reads LOW instead of MEDIUM. That's an
|
|
5257
|
+
// honest characterization, not a fudge — an unexported helper genuinely
|
|
5258
|
+
// has no external callers to break.
|
|
5259
|
+
const initialCartCode = `// ${cartFile}
|
|
5260
|
+
function applyDiscount(price: number, discountCode: string): number {
|
|
5261
|
+
if (discountCode === 'SAVE10') {
|
|
5262
|
+
return price * 0.9;
|
|
5263
|
+
}
|
|
5264
|
+
return price;
|
|
5265
|
+
}`;
|
|
5266
|
+
const initialCheckoutCode = `// ${checkoutFile}
|
|
5267
|
+
export function processCharge(price: number, user: string): { success: boolean } {
|
|
5268
|
+
// CRITICAL: This function charges the user's credit card.
|
|
5269
|
+
console.log(\`Charging \${user} for \${price}\`);
|
|
5270
|
+
return { success: true };
|
|
5271
|
+
}`;
|
|
5272
|
+
writeDemoFile(cartFile, initialCartCode);
|
|
5273
|
+
writeDemoFile(checkoutFile, initialCheckoutCode);
|
|
5274
|
+
run(["add", "."]);
|
|
5275
|
+
run(["commit", "-m", "Initial commit: add cart and billing logic"]);
|
|
5276
|
+
log(`✓ A cart file exists: ${bold(cartFile)}`);
|
|
5277
|
+
log(`✓ A payment processing file exists: ${bold(checkoutFile)} ${dim("(untouched, for now)")}`);
|
|
5278
|
+
// A real pre-commit hook, so every "blocked" claim below is enforced by
|
|
5279
|
+
// Ripple rather than narrated by this script.
|
|
5280
|
+
installRippleHooks(demoDir);
|
|
5281
|
+
log(dim(` [ripple] pre-commit hook installed — commits are now gated.`));
|
|
5282
|
+
await sleep(2000);
|
|
5283
|
+
// --- SCENE 2: THE TASK ---
|
|
5284
|
+
log(`\n${yellow("SCENE 2: THE TASK")}`);
|
|
5285
|
+
rule();
|
|
5286
|
+
log("Developer declares what the AI is allowed to change:");
|
|
5287
|
+
log(dim(`\n $ ripple plan --file ${cartFile} \\`));
|
|
5288
|
+
log(dim(` --symbol applyDiscount \\`));
|
|
5289
|
+
log(dim(` --mode function --save`));
|
|
5290
|
+
const approvedSymbol = `${cartFile}::applyDiscount`;
|
|
5291
|
+
let intent = await realPlan(cartFile, approvedSymbol, "Add 'SAVE20' discount code");
|
|
5292
|
+
await sleep(2000);
|
|
5293
|
+
log(green(`\n✓ Boundary set. Only the 'applyDiscount' function can be changed.`));
|
|
5294
|
+
log(dim(` boundary risk: ${intent.boundaryRisk} · human gate: ${intent.humanGate}`));
|
|
5295
|
+
await sleep(2000);
|
|
5296
|
+
// cart.ts is low-risk by Ripple's own path rules, so this normally reads
|
|
5297
|
+
// "none" and the demo flows straight to the edit. The check stays real
|
|
5298
|
+
// (not hardcoded) — if the engine ever did require sign-off here, the demo
|
|
5299
|
+
// would still show it rather than silently skip a real gate.
|
|
5300
|
+
if (intent.humanGate !== "none") {
|
|
5301
|
+
log(`\nRipple requires human sign-off on this path first:`);
|
|
5302
|
+
log(dim(`\n $ ripple approve --gate before-risky-edit \\`));
|
|
5303
|
+
log(dim(` --reason "discount copy change only"`));
|
|
5304
|
+
(0, core_1.recordRippleApproval)(demoDir, intent, {
|
|
5305
|
+
gate: "before-risky-edit",
|
|
5306
|
+
reason: "discount copy change only",
|
|
5307
|
+
approvedBy: "Demo Human",
|
|
5308
|
+
});
|
|
5309
|
+
log(green(`\n✓ Human approval recorded.`));
|
|
5310
|
+
await sleep(2500);
|
|
5311
|
+
}
|
|
5312
|
+
const runGate = async () => {
|
|
5313
|
+
const audit = await buildAuditForFiles({
|
|
5314
|
+
workspaceRoot: demoDir,
|
|
5315
|
+
files: (0, core_1.listGitStagedFiles)(demoDir),
|
|
5316
|
+
mode: "staged",
|
|
5317
|
+
tokenBudget: 4000,
|
|
5318
|
+
intent,
|
|
5319
|
+
// Must match how `ripple gate` computes this, or the intent's saved
|
|
5320
|
+
// policy snapshot looks drifted and the gate stops for the wrong reason.
|
|
5321
|
+
currentPolicyExplanation: (0, core_1.explainRipplePolicyForIntent)((0, core_1.loadRipplePolicy)(demoDir), intent),
|
|
5322
|
+
});
|
|
5323
|
+
return (0, core_1.buildRippleGateSummary)(audit);
|
|
5324
|
+
};
|
|
5325
|
+
// --- SCENE 3: THE AUTHORIZED EDIT ---
|
|
5326
|
+
log(`\n${yellow("SCENE 3: THE AI WORKS (SAFELY)")}`);
|
|
5327
|
+
rule();
|
|
5328
|
+
log("AI adds the SAVE20 discount code to applyDiscount... " + dim("done."));
|
|
5329
|
+
const authorizedCartCode = initialCartCode.replace("return price;", " if (discountCode === 'SAVE20') return price * 0.8;\n return price;");
|
|
5330
|
+
writeDemoFile(cartFile, authorizedCartCode);
|
|
5331
|
+
run(["add", cartFile]);
|
|
5332
|
+
await sleep(2000);
|
|
5333
|
+
log(dim("\n⏳ Running Ripple gate on authorized-only changes...\n"));
|
|
5334
|
+
await sleep(1500);
|
|
5335
|
+
const authorizedGate = await runGate();
|
|
5336
|
+
if (!jsonMode)
|
|
5337
|
+
printDemoGateResult(authorizedGate);
|
|
5338
|
+
const commitResult = run(["commit", "-m", "Add SAVE20 discount code"]);
|
|
5339
|
+
log(green(`\n✓ Commit passes. The SAVE20 discount is in git history.`));
|
|
5340
|
+
// Read the file count back out of git rather than asserting it, so the
|
|
5341
|
+
// line stays true if the demo's edits ever change.
|
|
5342
|
+
const commitStat = /(\d+ files? changed)/.exec(commitResult.stdout ?? "");
|
|
5343
|
+
if (commitStat) {
|
|
5344
|
+
log(dim(`\n [git] ${commitStat[1]} — commit recorded cleanly.`));
|
|
5345
|
+
}
|
|
5346
|
+
await sleep(3500);
|
|
5347
|
+
// --- SCENE 4: THE ROGUE EDIT ---
|
|
5348
|
+
log(`\n${yellow("SCENE 4: THE ROGUE EDIT")}`);
|
|
5349
|
+
rule();
|
|
5350
|
+
log(`Now, the AI also "refactors" ${bold(checkoutFile)} — a file it was never authorized to touch at all.`);
|
|
5351
|
+
log("");
|
|
5352
|
+
log(red(` - console.log(\`Charging \${user} for \${price}\`);`));
|
|
5353
|
+
log(red(` + console.log(\`Charging \${user} for \${price * 1.1}\`);`));
|
|
5354
|
+
log("");
|
|
5355
|
+
log(dim(" A silent 10% surcharge on every payment. Nobody asked for this."));
|
|
5356
|
+
// The authorized commit consumed the first intent, so the developer opens
|
|
5357
|
+
// the next task on the same approved function before the AI continues.
|
|
5358
|
+
intent = await realPlan(cartFile, approvedSymbol, "Continue discount work");
|
|
5359
|
+
if (intent.humanGate !== "none") {
|
|
5360
|
+
(0, core_1.recordRippleApproval)(demoDir, intent, {
|
|
5361
|
+
gate: "before-risky-edit",
|
|
5362
|
+
reason: "discount copy change only",
|
|
5363
|
+
approvedBy: "Demo Human",
|
|
5364
|
+
});
|
|
5365
|
+
}
|
|
5366
|
+
const rogueCode = initialCheckoutCode.replace('price}', 'price * 1.1}');
|
|
5367
|
+
writeDemoFile(checkoutFile, rogueCode);
|
|
5368
|
+
run(["add", checkoutFile]);
|
|
5369
|
+
await sleep(3500);
|
|
5370
|
+
log("\nDeveloper trusts the AI and runs " + bold("git commit") + ".");
|
|
5371
|
+
await sleep(2000);
|
|
5372
|
+
log(dim("\n⏳ Ripple pre-commit hook runs...\n"));
|
|
5373
|
+
await sleep(1500);
|
|
5374
|
+
const blockedGate = await runGate();
|
|
5375
|
+
if (!jsonMode)
|
|
5376
|
+
printDemoGateResult(blockedGate);
|
|
5377
|
+
// Actually attempt the commit and let the real hook decide. Nothing below
|
|
5378
|
+
// is asserted by the demo: the exit code and git log come from git.
|
|
5379
|
+
const rogueCommit = runAllowFail(["commit", "-m", "refactor processCharge"]);
|
|
5380
|
+
const headSubject = run(["log", "-1", "--pretty=%s"]).stdout?.trim();
|
|
5381
|
+
if (rogueCommit.status !== 0) {
|
|
5382
|
+
log("");
|
|
5383
|
+
log(bgRed(` ⛔ git commit REJECTED by Ripple (exit ${rogueCommit.status}) `));
|
|
5384
|
+
log("");
|
|
5385
|
+
log(dim(` [git] HEAD is still: "${headSubject}"`));
|
|
5386
|
+
log(bold(red(` The 10% surcharge never entered git history.`)));
|
|
5387
|
+
}
|
|
5388
|
+
else {
|
|
5389
|
+
log(bold(red(`\n⚠ Commit was NOT blocked (exit 0). HEAD: "${headSubject}"`)));
|
|
5390
|
+
}
|
|
5391
|
+
await sleep(4000);
|
|
5392
|
+
// --- OPTIONAL: PUBLISH A PUBLIC RECEIPT ---
|
|
5393
|
+
// Opt-in only. Must run before the finally block deletes the temp repo,
|
|
5394
|
+
// because the commit SHA is read out of it.
|
|
5395
|
+
const receiptUrl = await publishDemoReceipt({
|
|
5396
|
+
publish: options.publish,
|
|
5397
|
+
jsonMode,
|
|
5398
|
+
demoDir,
|
|
5399
|
+
gate: blockedGate,
|
|
5400
|
+
readHeadSha: () => run(["rev-parse", "HEAD"]).stdout?.trim() ?? "unknown",
|
|
5401
|
+
log,
|
|
5402
|
+
});
|
|
5403
|
+
// --- THE POINT ---
|
|
5404
|
+
log("");
|
|
5405
|
+
rule();
|
|
5406
|
+
log("Prompts tell AI agents what to do.");
|
|
5407
|
+
log(bold("Ripple enforces what they are allowed to do."));
|
|
5408
|
+
log("The difference is a compliance trail your auditors can read.");
|
|
5409
|
+
log("");
|
|
5410
|
+
if (receiptUrl) {
|
|
5411
|
+
log(`Public receipt: ${bold(underline(receiptUrl))}`);
|
|
5412
|
+
}
|
|
5413
|
+
log(`Try it in your repo: ${bold("npx @getripple/cli@latest init")}`);
|
|
5414
|
+
log(`Dashboard: ${bold(underline("https://ripple-cloud.vercel.app"))}`);
|
|
5415
|
+
log("");
|
|
5416
|
+
thick();
|
|
5417
|
+
if (jsonMode) {
|
|
5418
|
+
printJson({
|
|
5419
|
+
protocol: "ripple-demo",
|
|
5420
|
+
version: 1,
|
|
5421
|
+
scenarios: [
|
|
5422
|
+
{
|
|
5423
|
+
name: "authorized-edit",
|
|
5424
|
+
decision: authorizedGate.decision,
|
|
5425
|
+
canContinue: authorizedGate.canContinue,
|
|
5426
|
+
},
|
|
5427
|
+
{
|
|
5428
|
+
name: "rogue-edit",
|
|
5429
|
+
decision: blockedGate.decision,
|
|
5430
|
+
canContinue: blockedGate.canContinue,
|
|
5431
|
+
},
|
|
5432
|
+
],
|
|
5433
|
+
...(receiptUrl ? { receiptUrl } : {}),
|
|
5434
|
+
});
|
|
5435
|
+
}
|
|
5436
|
+
}
|
|
5437
|
+
finally {
|
|
5438
|
+
fs.rmSync(demoDir, { recursive: true, force: true });
|
|
5439
|
+
if (!jsonMode) {
|
|
5440
|
+
console.log(dim("\n(Demo complete. Temporary files cleaned up.)"));
|
|
5441
|
+
}
|
|
5442
|
+
}
|
|
5443
|
+
}
|
|
4908
5444
|
async function main() {
|
|
4909
5445
|
const { command, args, options } = parseCliArgs(process.argv.slice(2));
|
|
4910
5446
|
const [arg] = args;
|
|
5447
|
+
// --- ADD THIS NEW CASE ---
|
|
5448
|
+
if (command === "demo") {
|
|
5449
|
+
await demoCommand(options);
|
|
5450
|
+
return;
|
|
5451
|
+
}
|
|
5452
|
+
// --- END NEW CASE ---
|
|
4911
5453
|
if (!command || command === "--help" || command === "-h") {
|
|
4912
5454
|
console.log(usage());
|
|
4913
5455
|
return;
|