@node9/proxy 1.0.2 → 1.0.4

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/dist/cli.mjs CHANGED
@@ -329,6 +329,23 @@ function extractShellCommand(toolName, args, toolInspection) {
329
329
  const value = getNestedValue(args, fieldPath);
330
330
  return typeof value === "string" ? value : null;
331
331
  }
332
+ function isSqlTool(toolName, toolInspection) {
333
+ const patterns = Object.keys(toolInspection);
334
+ const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
335
+ if (!matchingPattern) return false;
336
+ const fieldName = toolInspection[matchingPattern];
337
+ return fieldName === "sql" || fieldName === "query";
338
+ }
339
+ var SQL_DML_KEYWORDS = /* @__PURE__ */ new Set(["select", "insert", "update", "delete", "merge", "upsert"]);
340
+ function checkDangerousSql(sql) {
341
+ const norm = sql.replace(/\s+/g, " ").trim().toLowerCase();
342
+ const hasWhere = /\bwhere\b/.test(norm);
343
+ if (/^delete\s+from\s+\S+/.test(norm) && !hasWhere)
344
+ return "DELETE without WHERE \u2014 full table wipe";
345
+ if (/^update\s+\S+\s+set\s+/.test(norm) && !hasWhere)
346
+ return "UPDATE without WHERE \u2014 updates every row";
347
+ return null;
348
+ }
332
349
  async function analyzeShellCommand(command) {
333
350
  const actions = [];
334
351
  const paths = [];
@@ -535,9 +552,20 @@ async function evaluatePolicy(toolName, args, agent) {
535
552
  if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
536
553
  return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
537
554
  }
555
+ if (isSqlTool(toolName, config.policy.toolInspection)) {
556
+ const sqlDanger = checkDangerousSql(shellCommand);
557
+ if (sqlDanger) return { decision: "review", blockedByLabel: `SQL Safety: ${sqlDanger}` };
558
+ allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
559
+ actionTokens = actionTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
560
+ }
538
561
  } else {
539
562
  allTokens = tokenize(toolName);
540
563
  actionTokens = [toolName];
564
+ if (args && typeof args === "object") {
565
+ const flattenedArgs = JSON.stringify(args).toLowerCase();
566
+ const extraTokens = flattenedArgs.split(/[^a-zA-Z0-9]+/).filter((t) => t.length > 1);
567
+ allTokens.push(...extraTokens);
568
+ }
541
569
  }
542
570
  const isManual = agent === "Terminal";
543
571
  if (isManual) {
@@ -604,6 +632,285 @@ async function evaluatePolicy(toolName, args, agent) {
604
632
  }
605
633
  return { decision: "allow" };
606
634
  }
635
+ async function explainPolicy(toolName, args) {
636
+ const steps = [];
637
+ const globalPath = path.join(os.homedir(), ".node9", "config.json");
638
+ const projectPath = path.join(process.cwd(), "node9.config.json");
639
+ const credsPath = path.join(os.homedir(), ".node9", "credentials.json");
640
+ const waterfall = [
641
+ {
642
+ tier: 1,
643
+ label: "Env vars",
644
+ status: "env",
645
+ note: process.env.NODE9_MODE ? `NODE9_MODE=${process.env.NODE9_MODE}` : "not set"
646
+ },
647
+ {
648
+ tier: 2,
649
+ label: "Cloud policy",
650
+ status: fs.existsSync(credsPath) ? "active" : "missing",
651
+ note: fs.existsSync(credsPath) ? "credentials found (not evaluated in explain mode)" : "not connected \u2014 run: node9 login"
652
+ },
653
+ {
654
+ tier: 3,
655
+ label: "Project config",
656
+ status: fs.existsSync(projectPath) ? "active" : "missing",
657
+ path: projectPath
658
+ },
659
+ {
660
+ tier: 4,
661
+ label: "Global config",
662
+ status: fs.existsSync(globalPath) ? "active" : "missing",
663
+ path: globalPath
664
+ },
665
+ {
666
+ tier: 5,
667
+ label: "Defaults",
668
+ status: "active",
669
+ note: "always active"
670
+ }
671
+ ];
672
+ const config = getConfig();
673
+ if (matchesPattern(toolName, config.policy.ignoredTools)) {
674
+ steps.push({
675
+ name: "Ignored tools",
676
+ outcome: "allow",
677
+ detail: `"${toolName}" matches ignoredTools pattern \u2192 fast-path allow`,
678
+ isFinal: true
679
+ });
680
+ return { tool: toolName, args, waterfall, steps, decision: "allow" };
681
+ }
682
+ steps.push({
683
+ name: "Ignored tools",
684
+ outcome: "checked",
685
+ detail: `"${toolName}" not in ignoredTools list`
686
+ });
687
+ let allTokens = [];
688
+ let actionTokens = [];
689
+ let pathTokens = [];
690
+ const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
691
+ if (shellCommand) {
692
+ const analyzed = await analyzeShellCommand(shellCommand);
693
+ allTokens = analyzed.allTokens;
694
+ actionTokens = analyzed.actions;
695
+ pathTokens = analyzed.paths;
696
+ const patterns = Object.keys(config.policy.toolInspection);
697
+ const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
698
+ const fieldName = matchingPattern ? config.policy.toolInspection[matchingPattern] : "command";
699
+ steps.push({
700
+ name: "Input parsing",
701
+ outcome: "checked",
702
+ detail: `Shell command via toolInspection["${matchingPattern ?? toolName}"] \u2192 field "${fieldName}": "${shellCommand}"`
703
+ });
704
+ const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
705
+ if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
706
+ steps.push({
707
+ name: "Inline execution",
708
+ outcome: "review",
709
+ detail: 'Inline code execution detected (e.g. "bash -c ...") \u2014 always requires review',
710
+ isFinal: true
711
+ });
712
+ return {
713
+ tool: toolName,
714
+ args,
715
+ waterfall,
716
+ steps,
717
+ decision: "review",
718
+ blockedByLabel: "Node9 Standard (Inline Execution)"
719
+ };
720
+ }
721
+ steps.push({
722
+ name: "Inline execution",
723
+ outcome: "checked",
724
+ detail: "No inline execution pattern detected"
725
+ });
726
+ if (isSqlTool(toolName, config.policy.toolInspection)) {
727
+ const sqlDanger = checkDangerousSql(shellCommand);
728
+ if (sqlDanger) {
729
+ steps.push({
730
+ name: "SQL safety",
731
+ outcome: "review",
732
+ detail: sqlDanger,
733
+ isFinal: true
734
+ });
735
+ return {
736
+ tool: toolName,
737
+ args,
738
+ waterfall,
739
+ steps,
740
+ decision: "review",
741
+ blockedByLabel: `SQL Safety: ${sqlDanger}`
742
+ };
743
+ }
744
+ allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
745
+ actionTokens = actionTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
746
+ steps.push({
747
+ name: "SQL safety",
748
+ outcome: "checked",
749
+ detail: "DELETE/UPDATE have a WHERE clause \u2014 scoped mutation, safe"
750
+ });
751
+ }
752
+ } else {
753
+ allTokens = tokenize(toolName);
754
+ actionTokens = [toolName];
755
+ let detail = `No toolInspection match for "${toolName}" \u2014 tokens: [${allTokens.join(", ")}]`;
756
+ if (args && typeof args === "object") {
757
+ const flattenedArgs = JSON.stringify(args).toLowerCase();
758
+ const extraTokens = flattenedArgs.split(/[^a-zA-Z0-9]+/).filter((t) => t.length > 1);
759
+ allTokens.push(...extraTokens);
760
+ const preview = extraTokens.slice(0, 8).join(", ") + (extraTokens.length > 8 ? "\u2026" : "");
761
+ detail += ` + deep scan of args: [${preview}]`;
762
+ }
763
+ steps.push({ name: "Input parsing", outcome: "checked", detail });
764
+ }
765
+ const uniqueTokens = [...new Set(allTokens)];
766
+ steps.push({
767
+ name: "Tokens scanned",
768
+ outcome: "checked",
769
+ detail: `[${uniqueTokens.join(", ")}]`
770
+ });
771
+ if (pathTokens.length > 0 && config.policy.sandboxPaths.length > 0) {
772
+ const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
773
+ if (allInSandbox) {
774
+ steps.push({
775
+ name: "Sandbox paths",
776
+ outcome: "allow",
777
+ detail: `[${pathTokens.join(", ")}] all match sandbox patterns \u2192 auto-allow`,
778
+ isFinal: true
779
+ });
780
+ return { tool: toolName, args, waterfall, steps, decision: "allow" };
781
+ }
782
+ const unmatched = pathTokens.filter((p) => !matchesPattern(p, config.policy.sandboxPaths));
783
+ steps.push({
784
+ name: "Sandbox paths",
785
+ outcome: "checked",
786
+ detail: `[${unmatched.join(", ")}] not in sandbox \u2014 not auto-allowed`
787
+ });
788
+ } else {
789
+ steps.push({
790
+ name: "Sandbox paths",
791
+ outcome: "skip",
792
+ detail: pathTokens.length === 0 ? "No path tokens found in input" : "No sandbox paths configured"
793
+ });
794
+ }
795
+ let ruleMatched = false;
796
+ for (const action of actionTokens) {
797
+ const rule = config.policy.rules.find(
798
+ (r) => r.action === action || matchesPattern(action, r.action)
799
+ );
800
+ if (rule) {
801
+ ruleMatched = true;
802
+ if (pathTokens.length > 0) {
803
+ const anyBlocked = pathTokens.some((p) => matchesPattern(p, rule.blockPaths || []));
804
+ if (anyBlocked) {
805
+ steps.push({
806
+ name: "Policy rules",
807
+ outcome: "review",
808
+ detail: `Rule "${rule.action}" matched + path is in blockPaths`,
809
+ isFinal: true
810
+ });
811
+ return {
812
+ tool: toolName,
813
+ args,
814
+ waterfall,
815
+ steps,
816
+ decision: "review",
817
+ blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)`
818
+ };
819
+ }
820
+ const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
821
+ if (allAllowed) {
822
+ steps.push({
823
+ name: "Policy rules",
824
+ outcome: "allow",
825
+ detail: `Rule "${rule.action}" matched + all paths are in allowPaths`,
826
+ isFinal: true
827
+ });
828
+ return { tool: toolName, args, waterfall, steps, decision: "allow" };
829
+ }
830
+ }
831
+ steps.push({
832
+ name: "Policy rules",
833
+ outcome: "review",
834
+ detail: `Rule "${rule.action}" matched \u2014 default block (no path exception)`,
835
+ isFinal: true
836
+ });
837
+ return {
838
+ tool: toolName,
839
+ args,
840
+ waterfall,
841
+ steps,
842
+ decision: "review",
843
+ blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)`
844
+ };
845
+ }
846
+ }
847
+ if (!ruleMatched) {
848
+ steps.push({
849
+ name: "Policy rules",
850
+ outcome: "skip",
851
+ detail: config.policy.rules.length === 0 ? "No rules configured" : `No rule matched [${actionTokens.join(", ")}]`
852
+ });
853
+ }
854
+ let matchedDangerousWord;
855
+ const isDangerous = uniqueTokens.some(
856
+ (token) => config.policy.dangerousWords.some((word) => {
857
+ const w = word.toLowerCase();
858
+ const hit = token === w || (() => {
859
+ try {
860
+ return new RegExp(`\\b${w}\\b`, "i").test(token);
861
+ } catch {
862
+ return false;
863
+ }
864
+ })();
865
+ if (hit && !matchedDangerousWord) matchedDangerousWord = word;
866
+ return hit;
867
+ })
868
+ );
869
+ if (isDangerous) {
870
+ steps.push({
871
+ name: "Dangerous words",
872
+ outcome: "review",
873
+ detail: `"${matchedDangerousWord}" found in token list`,
874
+ isFinal: true
875
+ });
876
+ return {
877
+ tool: toolName,
878
+ args,
879
+ waterfall,
880
+ steps,
881
+ decision: "review",
882
+ blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`,
883
+ matchedToken: matchedDangerousWord
884
+ };
885
+ }
886
+ steps.push({
887
+ name: "Dangerous words",
888
+ outcome: "checked",
889
+ detail: `No dangerous words matched`
890
+ });
891
+ if (config.settings.mode === "strict") {
892
+ steps.push({
893
+ name: "Strict mode",
894
+ outcome: "review",
895
+ detail: 'Mode is "strict" \u2014 all tools require approval unless explicitly allowed',
896
+ isFinal: true
897
+ });
898
+ return {
899
+ tool: toolName,
900
+ args,
901
+ waterfall,
902
+ steps,
903
+ decision: "review",
904
+ blockedByLabel: "Global Config (Strict Mode Active)"
905
+ };
906
+ }
907
+ steps.push({
908
+ name: "Strict mode",
909
+ outcome: "skip",
910
+ detail: `Mode is "${config.settings.mode}" \u2014 no catch-all review`
911
+ });
912
+ return { tool: toolName, args, waterfall, steps, decision: "allow" };
913
+ }
607
914
  function isIgnoredTool(toolName) {
608
915
  const config = getConfig();
609
916
  return matchesPattern(toolName, config.policy.ignoredTools);
@@ -766,8 +1073,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
766
1073
  const cloudEnforced = approvers.cloud && !!creds?.apiKey;
767
1074
  if (cloudEnforced) {
768
1075
  try {
769
- const envConfig = getActiveEnvironment(getConfig());
770
- const initResult = await initNode9SaaS(toolName, args, creds, envConfig?.slackChannel, meta);
1076
+ const initResult = await initNode9SaaS(toolName, args, creds, meta);
771
1077
  if (!initResult.pending) {
772
1078
  return {
773
1079
  approved: !!initResult.approved,
@@ -1021,6 +1327,7 @@ function getConfig() {
1021
1327
  if (s.enableHookLogDebug !== void 0)
1022
1328
  mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
1023
1329
  if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
1330
+ if (s.environment !== void 0) mergedSettings.environment = s.environment;
1024
1331
  if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
1025
1332
  if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
1026
1333
  if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
@@ -1050,7 +1357,7 @@ function tryLoadConfig(filePath) {
1050
1357
  }
1051
1358
  }
1052
1359
  function getActiveEnvironment(config) {
1053
- const env = process.env.NODE_ENV || "development";
1360
+ const env = config.settings.environment || process.env.NODE_ENV || "development";
1054
1361
  return config.environments[env] ?? null;
1055
1362
  }
1056
1363
  function getCredentials() {
@@ -1106,7 +1413,7 @@ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
1106
1413
  }).catch(() => {
1107
1414
  });
1108
1415
  }
1109
- async function initNode9SaaS(toolName, args, creds, slackChannel, meta) {
1416
+ async function initNode9SaaS(toolName, args, creds, meta) {
1110
1417
  const controller = new AbortController();
1111
1418
  const timeout = setTimeout(() => controller.abort(), 1e4);
1112
1419
  try {
@@ -1116,7 +1423,6 @@ async function initNode9SaaS(toolName, args, creds, slackChannel, meta) {
1116
1423
  body: JSON.stringify({
1117
1424
  toolName,
1118
1425
  args,
1119
- slackChannel,
1120
1426
  context: {
1121
1427
  agent: meta?.agent,
1122
1428
  mcpServer: meta?.mcpServer,
@@ -2914,8 +3220,34 @@ import { spawnSync } from "child_process";
2914
3220
  import fs4 from "fs";
2915
3221
  import path4 from "path";
2916
3222
  import os4 from "os";
3223
+ var SNAPSHOT_STACK_PATH = path4.join(os4.homedir(), ".node9", "snapshots.json");
2917
3224
  var UNDO_LATEST_PATH = path4.join(os4.homedir(), ".node9", "undo_latest.txt");
2918
- async function createShadowSnapshot() {
3225
+ var MAX_SNAPSHOTS = 10;
3226
+ function readStack() {
3227
+ try {
3228
+ if (fs4.existsSync(SNAPSHOT_STACK_PATH))
3229
+ return JSON.parse(fs4.readFileSync(SNAPSHOT_STACK_PATH, "utf-8"));
3230
+ } catch {
3231
+ }
3232
+ return [];
3233
+ }
3234
+ function writeStack(stack) {
3235
+ const dir = path4.dirname(SNAPSHOT_STACK_PATH);
3236
+ if (!fs4.existsSync(dir)) fs4.mkdirSync(dir, { recursive: true });
3237
+ fs4.writeFileSync(SNAPSHOT_STACK_PATH, JSON.stringify(stack, null, 2));
3238
+ }
3239
+ function buildArgsSummary(tool, args) {
3240
+ if (!args || typeof args !== "object") return "";
3241
+ const a = args;
3242
+ const filePath = a.file_path ?? a.path ?? a.filename;
3243
+ if (typeof filePath === "string") return filePath;
3244
+ const cmd = a.command ?? a.cmd;
3245
+ if (typeof cmd === "string") return cmd.slice(0, 80);
3246
+ const sql = a.sql ?? a.query;
3247
+ if (typeof sql === "string") return sql.slice(0, 80);
3248
+ return tool;
3249
+ }
3250
+ async function createShadowSnapshot(tool = "unknown", args = {}) {
2919
3251
  try {
2920
3252
  const cwd = process.cwd();
2921
3253
  if (!fs4.existsSync(path4.join(cwd, ".git"))) return null;
@@ -2933,30 +3265,59 @@ async function createShadowSnapshot() {
2933
3265
  `Node9 AI Snapshot: ${(/* @__PURE__ */ new Date()).toISOString()}`
2934
3266
  ]);
2935
3267
  const commitHash = commitRes.stdout.toString().trim();
2936
- if (commitHash && commitRes.status === 0) {
2937
- const dir = path4.dirname(UNDO_LATEST_PATH);
2938
- if (!fs4.existsSync(dir)) fs4.mkdirSync(dir, { recursive: true });
2939
- fs4.writeFileSync(UNDO_LATEST_PATH, commitHash);
2940
- return commitHash;
2941
- }
3268
+ if (!commitHash || commitRes.status !== 0) return null;
3269
+ const stack = readStack();
3270
+ const entry = {
3271
+ hash: commitHash,
3272
+ tool,
3273
+ argsSummary: buildArgsSummary(tool, args),
3274
+ cwd,
3275
+ timestamp: Date.now()
3276
+ };
3277
+ stack.push(entry);
3278
+ if (stack.length > MAX_SNAPSHOTS) stack.splice(0, stack.length - MAX_SNAPSHOTS);
3279
+ writeStack(stack);
3280
+ fs4.writeFileSync(UNDO_LATEST_PATH, commitHash);
3281
+ return commitHash;
2942
3282
  } catch (err) {
2943
- if (process.env.NODE9_DEBUG === "1") {
2944
- console.error("[Node9 Undo Engine Error]:", err);
2945
- }
3283
+ if (process.env.NODE9_DEBUG === "1") console.error("[Node9 Undo Engine Error]:", err);
2946
3284
  }
2947
3285
  return null;
2948
3286
  }
2949
- function applyUndo(hash) {
3287
+ function getSnapshotHistory() {
3288
+ return readStack();
3289
+ }
3290
+ function computeUndoDiff(hash, cwd) {
2950
3291
  try {
2951
- const restore = spawnSync("git", ["restore", "--source", hash, "--staged", "--worktree", "."]);
3292
+ const result = spawnSync("git", ["diff", hash, "--stat", "--", "."], { cwd });
3293
+ const stat = result.stdout.toString().trim();
3294
+ if (!stat) return null;
3295
+ const diff = spawnSync("git", ["diff", hash, "--", "."], { cwd });
3296
+ const raw = diff.stdout.toString();
3297
+ if (!raw) return null;
3298
+ const lines = raw.split("\n").filter(
3299
+ (l) => !l.startsWith("diff --git") && !l.startsWith("index ") && !l.startsWith("Binary")
3300
+ );
3301
+ return lines.join("\n") || null;
3302
+ } catch {
3303
+ return null;
3304
+ }
3305
+ }
3306
+ function applyUndo(hash, cwd) {
3307
+ try {
3308
+ const dir = cwd ?? process.cwd();
3309
+ const restore = spawnSync("git", ["restore", "--source", hash, "--staged", "--worktree", "."], {
3310
+ cwd: dir
3311
+ });
2952
3312
  if (restore.status !== 0) return false;
2953
- const lsTree = spawnSync("git", ["ls-tree", "-r", "--name-only", hash]);
3313
+ const lsTree = spawnSync("git", ["ls-tree", "-r", "--name-only", hash], { cwd: dir });
2954
3314
  const snapshotFiles = new Set(lsTree.stdout.toString().trim().split("\n").filter(Boolean));
2955
- const tracked = spawnSync("git", ["ls-files"]).stdout.toString().trim().split("\n").filter(Boolean);
2956
- const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard"]).stdout.toString().trim().split("\n").filter(Boolean);
3315
+ const tracked = spawnSync("git", ["ls-files"], { cwd: dir }).stdout.toString().trim().split("\n").filter(Boolean);
3316
+ const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard"], { cwd: dir }).stdout.toString().trim().split("\n").filter(Boolean);
2957
3317
  for (const file of [...tracked, ...untracked]) {
2958
- if (!snapshotFiles.has(file) && fs4.existsSync(file)) {
2959
- fs4.unlinkSync(file);
3318
+ const fullPath = path4.join(dir, file);
3319
+ if (!snapshotFiles.has(file) && fs4.existsSync(fullPath)) {
3320
+ fs4.unlinkSync(fullPath);
2960
3321
  }
2961
3322
  }
2962
3323
  return true;
@@ -2964,10 +3325,6 @@ function applyUndo(hash) {
2964
3325
  return false;
2965
3326
  }
2966
3327
  }
2967
- function getLatestSnapshotHash() {
2968
- if (!fs4.existsSync(UNDO_LATEST_PATH)) return null;
2969
- return fs4.readFileSync(UNDO_LATEST_PATH, "utf-8").trim();
2970
- }
2971
3328
 
2972
3329
  // src/cli.ts
2973
3330
  import { confirm as confirm3 } from "@inquirer/prompts";
@@ -2994,6 +3351,59 @@ function parseDuration(str) {
2994
3351
  function sanitize(value) {
2995
3352
  return value.replace(/[\x00-\x1F\x7F]/g, "");
2996
3353
  }
3354
+ function buildNegotiationMessage(blockedByLabel, isHumanDecision, humanReason) {
3355
+ if (isHumanDecision) {
3356
+ return `NODE9: The human user rejected this action.
3357
+ REASON: ${humanReason || "No specific reason provided."}
3358
+ INSTRUCTIONS:
3359
+ - Do NOT retry this exact command.
3360
+ - Acknowledge the block to the user and ask if there is an alternative approach.
3361
+ - If you believe this action is critical, explain your reasoning and ask them to run "node9 pause 15m" to proceed.`;
3362
+ }
3363
+ const label = blockedByLabel.toLowerCase();
3364
+ if (label.includes("sql safety") && label.includes("delete without where")) {
3365
+ return `NODE9: Blocked \u2014 DELETE without WHERE clause would wipe the entire table.
3366
+ INSTRUCTION: Add a WHERE clause to scope the deletion (e.g. WHERE id = <value>).
3367
+ Do NOT retry without a WHERE clause.`;
3368
+ }
3369
+ if (label.includes("sql safety") && label.includes("update without where")) {
3370
+ return `NODE9: Blocked \u2014 UPDATE without WHERE clause would update every row.
3371
+ INSTRUCTION: Add a WHERE clause to scope the update (e.g. WHERE id = <value>).
3372
+ Do NOT retry without a WHERE clause.`;
3373
+ }
3374
+ if (label.includes("dangerous word")) {
3375
+ const match = blockedByLabel.match(/dangerous word: "([^"]+)"/i);
3376
+ const word = match?.[1] ?? "a dangerous keyword";
3377
+ return `NODE9: Blocked \u2014 command contains forbidden keyword "${word}".
3378
+ INSTRUCTION: Do NOT use "${word}". Use a non-destructive alternative.
3379
+ Do NOT attempt to bypass this with shell tricks or aliases \u2014 it will be blocked again.`;
3380
+ }
3381
+ if (label.includes("path blocked") || label.includes("sandbox")) {
3382
+ return `NODE9: Blocked \u2014 operation targets a path outside the allowed sandbox.
3383
+ INSTRUCTION: Move your output to an allowed directory such as /tmp/ or the project directory.
3384
+ Do NOT retry on the same path.`;
3385
+ }
3386
+ if (label.includes("inline execution")) {
3387
+ return `NODE9: Blocked \u2014 inline code execution (e.g. bash -c "...") is not allowed.
3388
+ INSTRUCTION: Use individual tool calls instead of embedding code in a shell string.`;
3389
+ }
3390
+ if (label.includes("strict mode")) {
3391
+ return `NODE9: Blocked \u2014 strict mode is active. All tool calls require explicit human approval.
3392
+ INSTRUCTION: Inform the user this action is pending approval. Wait for them to approve via the dashboard or run "node9 pause".`;
3393
+ }
3394
+ if (label.includes("rule") && label.includes("default block")) {
3395
+ const match = blockedByLabel.match(/rule "([^"]+)"/i);
3396
+ const rule = match?.[1] ?? "a policy rule";
3397
+ return `NODE9: Blocked \u2014 action "${rule}" is forbidden by security policy.
3398
+ INSTRUCTION: Do NOT use "${rule}". Find a read-only or non-destructive alternative.
3399
+ Do NOT attempt to bypass this rule.`;
3400
+ }
3401
+ return `NODE9: Action blocked by security policy [${blockedByLabel}].
3402
+ INSTRUCTIONS:
3403
+ - Do NOT retry this exact command or attempt to bypass the rule.
3404
+ - Pivot to a non-destructive or read-only alternative.
3405
+ - Inform the user which security rule was triggered and ask how to proceed.`;
3406
+ }
2997
3407
  function openBrowserLocal() {
2998
3408
  const url = `http://${DAEMON_HOST2}:${DAEMON_PORT2}/`;
2999
3409
  try {
@@ -3046,7 +3456,7 @@ async function runProxy(targetCommand) {
3046
3456
  const child = spawn3(executable, args, {
3047
3457
  stdio: ["pipe", "pipe", "inherit"],
3048
3458
  // We control STDIN and STDOUT
3049
- shell: true,
3459
+ shell: false,
3050
3460
  env: { ...process.env, FORCE_COLOR: "1" }
3051
3461
  });
3052
3462
  const agentIn = readline.createInterface({ input: process.stdin, terminal: false });
@@ -3067,12 +3477,24 @@ async function runProxy(targetCommand) {
3067
3477
  agent: "Proxy/MCP"
3068
3478
  });
3069
3479
  if (!result.approved) {
3480
+ console.error(chalk5.red(`
3481
+ \u{1F6D1} Node9 Sudo: Action Blocked`));
3482
+ console.error(chalk5.gray(` Tool: ${name}`));
3483
+ console.error(chalk5.gray(` Reason: ${result.reason || "Security Policy"}
3484
+ `));
3485
+ const blockedByLabel = result.blockedByLabel ?? result.reason ?? "Security Policy";
3486
+ const isHuman = blockedByLabel.toLowerCase().includes("user") || blockedByLabel.toLowerCase().includes("daemon") || blockedByLabel.toLowerCase().includes("decision");
3487
+ const aiInstruction = buildNegotiationMessage(blockedByLabel, isHuman, result.reason);
3070
3488
  const errorResponse = {
3071
3489
  jsonrpc: "2.0",
3072
- id: message.id,
3490
+ id: message.id ?? null,
3073
3491
  error: {
3074
3492
  code: -32e3,
3075
- message: `Node9: Action denied. ${result.reason || ""}`
3493
+ message: aiInstruction,
3494
+ data: {
3495
+ reason: result.reason,
3496
+ blockedBy: result.blockedByLabel
3497
+ }
3076
3498
  }
3077
3499
  };
3078
3500
  process.stdout.write(JSON.stringify(errorResponse) + "\n");
@@ -3160,6 +3582,237 @@ program.command("addto").description("Integrate Node9 with an AI agent").addHelp
3160
3582
  console.error(chalk5.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
3161
3583
  process.exit(1);
3162
3584
  });
3585
+ program.command("setup").description('Alias for "addto" \u2014 integrate Node9 with an AI agent').addHelpText("after", "\n Supported targets: claude gemini cursor").argument("[target]", "The agent to protect: claude | gemini | cursor").action(async (target) => {
3586
+ if (!target) {
3587
+ console.log(chalk5.cyan("\n\u{1F6E1}\uFE0F Node9 Setup \u2014 integrate with your AI agent\n"));
3588
+ console.log(" Usage: " + chalk5.white("node9 setup <target>") + "\n");
3589
+ console.log(" Targets:");
3590
+ console.log(" " + chalk5.green("claude") + " \u2014 Claude Code (hook mode)");
3591
+ console.log(" " + chalk5.green("gemini") + " \u2014 Gemini CLI (hook mode)");
3592
+ console.log(" " + chalk5.green("cursor") + " \u2014 Cursor (hook mode)");
3593
+ console.log("");
3594
+ return;
3595
+ }
3596
+ const t = target.toLowerCase();
3597
+ if (t === "gemini") return await setupGemini();
3598
+ if (t === "claude") return await setupClaude();
3599
+ if (t === "cursor") return await setupCursor();
3600
+ console.error(chalk5.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
3601
+ process.exit(1);
3602
+ });
3603
+ program.command("doctor").description("Check that Node9 is installed and configured correctly").action(() => {
3604
+ const homeDir2 = os5.homedir();
3605
+ let failures = 0;
3606
+ function pass(msg) {
3607
+ console.log(chalk5.green(" \u2705 ") + msg);
3608
+ }
3609
+ function fail(msg, hint) {
3610
+ console.log(chalk5.red(" \u274C ") + msg);
3611
+ if (hint) console.log(chalk5.gray(" " + hint));
3612
+ failures++;
3613
+ }
3614
+ function warn(msg, hint) {
3615
+ console.log(chalk5.yellow(" \u26A0\uFE0F ") + msg);
3616
+ if (hint) console.log(chalk5.gray(" " + hint));
3617
+ }
3618
+ function section(title) {
3619
+ console.log("\n" + chalk5.bold(title));
3620
+ }
3621
+ console.log(chalk5.cyan.bold(`
3622
+ \u{1F6E1}\uFE0F Node9 Doctor v${version}
3623
+ `));
3624
+ section("Binary");
3625
+ try {
3626
+ const which = execSync("which node9", { encoding: "utf-8" }).trim();
3627
+ pass(`node9 found at ${which}`);
3628
+ } catch {
3629
+ warn("node9 not found in $PATH \u2014 hooks may not find it", "Run: npm install -g @node9/proxy");
3630
+ }
3631
+ const nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
3632
+ if (nodeMajor >= 18) {
3633
+ pass(`Node.js ${process.versions.node}`);
3634
+ } else {
3635
+ fail(
3636
+ `Node.js ${process.versions.node} (requires \u226518)`,
3637
+ "Upgrade Node.js: https://nodejs.org"
3638
+ );
3639
+ }
3640
+ try {
3641
+ const gitVersion = execSync("git --version", { encoding: "utf-8" }).trim();
3642
+ pass(gitVersion);
3643
+ } catch {
3644
+ warn(
3645
+ "git not found \u2014 Undo Engine will be disabled",
3646
+ "Install git to enable snapshot-based undo"
3647
+ );
3648
+ }
3649
+ section("Configuration");
3650
+ const globalConfigPath = path5.join(homeDir2, ".node9", "config.json");
3651
+ if (fs5.existsSync(globalConfigPath)) {
3652
+ try {
3653
+ JSON.parse(fs5.readFileSync(globalConfigPath, "utf-8"));
3654
+ pass("~/.node9/config.json found and valid");
3655
+ } catch {
3656
+ fail("~/.node9/config.json is invalid JSON", "Run: node9 init --force");
3657
+ }
3658
+ } else {
3659
+ warn("~/.node9/config.json not found (using defaults)", "Run: node9 init");
3660
+ }
3661
+ const projectConfigPath = path5.join(process.cwd(), "node9.config.json");
3662
+ if (fs5.existsSync(projectConfigPath)) {
3663
+ try {
3664
+ JSON.parse(fs5.readFileSync(projectConfigPath, "utf-8"));
3665
+ pass("node9.config.json found and valid (project)");
3666
+ } catch {
3667
+ fail("node9.config.json is invalid JSON", "Fix the JSON or delete it and run: node9 init");
3668
+ }
3669
+ }
3670
+ const credsPath = path5.join(homeDir2, ".node9", "credentials.json");
3671
+ if (fs5.existsSync(credsPath)) {
3672
+ pass("Cloud credentials found (~/.node9/credentials.json)");
3673
+ } else {
3674
+ warn(
3675
+ "No cloud credentials \u2014 running in local-only mode",
3676
+ "Run: node9 login <apiKey> (or skip for local-only)"
3677
+ );
3678
+ }
3679
+ section("Agent Hooks");
3680
+ const claudeSettingsPath = path5.join(homeDir2, ".claude", "settings.json");
3681
+ if (fs5.existsSync(claudeSettingsPath)) {
3682
+ try {
3683
+ const cs = JSON.parse(fs5.readFileSync(claudeSettingsPath, "utf-8"));
3684
+ const hasHook = cs.hooks?.PreToolUse?.some(
3685
+ (m) => m.hooks.some((h) => h.command?.includes("node9") || h.command?.includes("cli.js"))
3686
+ );
3687
+ if (hasHook) pass("Claude Code \u2014 PreToolUse hook active");
3688
+ else
3689
+ fail("Claude Code \u2014 hooks file found but node9 hook missing", "Run: node9 setup claude");
3690
+ } catch {
3691
+ fail("Claude Code \u2014 ~/.claude/settings.json is invalid JSON");
3692
+ }
3693
+ } else {
3694
+ warn("Claude Code \u2014 not configured", "Run: node9 setup claude");
3695
+ }
3696
+ const geminiSettingsPath = path5.join(homeDir2, ".gemini", "settings.json");
3697
+ if (fs5.existsSync(geminiSettingsPath)) {
3698
+ try {
3699
+ const gs = JSON.parse(fs5.readFileSync(geminiSettingsPath, "utf-8"));
3700
+ const hasHook = gs.hooks?.BeforeTool?.some(
3701
+ (m) => m.hooks.some((h) => h.command?.includes("node9") || h.command?.includes("cli.js"))
3702
+ );
3703
+ if (hasHook) pass("Gemini CLI \u2014 BeforeTool hook active");
3704
+ else
3705
+ fail("Gemini CLI \u2014 hooks file found but node9 hook missing", "Run: node9 setup gemini");
3706
+ } catch {
3707
+ fail("Gemini CLI \u2014 ~/.gemini/settings.json is invalid JSON");
3708
+ }
3709
+ } else {
3710
+ warn("Gemini CLI \u2014 not configured", "Run: node9 setup gemini (skip if not using Gemini)");
3711
+ }
3712
+ const cursorHooksPath = path5.join(homeDir2, ".cursor", "hooks.json");
3713
+ if (fs5.existsSync(cursorHooksPath)) {
3714
+ try {
3715
+ const cur = JSON.parse(fs5.readFileSync(cursorHooksPath, "utf-8"));
3716
+ const hasHook = cur.hooks?.preToolUse?.some(
3717
+ (h) => h.command?.includes("node9") || h.command?.includes("cli.js")
3718
+ );
3719
+ if (hasHook) pass("Cursor \u2014 preToolUse hook active");
3720
+ else
3721
+ fail("Cursor \u2014 hooks file found but node9 hook missing", "Run: node9 setup cursor");
3722
+ } catch {
3723
+ fail("Cursor \u2014 ~/.cursor/hooks.json is invalid JSON");
3724
+ }
3725
+ } else {
3726
+ warn("Cursor \u2014 not configured", "Run: node9 setup cursor (skip if not using Cursor)");
3727
+ }
3728
+ section("Daemon (optional)");
3729
+ if (isDaemonRunning()) {
3730
+ pass(`Browser dashboard running \u2192 http://${DAEMON_HOST2}:${DAEMON_PORT2}/`);
3731
+ } else {
3732
+ warn("Daemon not running \u2014 browser approvals unavailable", "Run: node9 daemon --background");
3733
+ }
3734
+ console.log("");
3735
+ if (failures === 0) {
3736
+ console.log(chalk5.green.bold(" All checks passed. Node9 is ready.\n"));
3737
+ } else {
3738
+ console.log(chalk5.red.bold(` ${failures} check(s) failed. See hints above.
3739
+ `));
3740
+ process.exit(1);
3741
+ }
3742
+ });
3743
+ program.command("explain").description(
3744
+ "Show exactly how Node9 evaluates a tool call \u2014 waterfall + step-by-step policy trace"
3745
+ ).argument("<tool>", "Tool name (e.g. bash, str_replace_based_edit_tool, execute_query)").argument("[args]", "Tool arguments as JSON, or a plain command string for shell tools").action(async (tool, argsRaw) => {
3746
+ let args = {};
3747
+ if (argsRaw) {
3748
+ const trimmed = argsRaw.trim();
3749
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
3750
+ try {
3751
+ args = JSON.parse(trimmed);
3752
+ } catch {
3753
+ console.error(chalk5.red(`
3754
+ \u274C Invalid JSON: ${trimmed}
3755
+ `));
3756
+ process.exit(1);
3757
+ }
3758
+ } else {
3759
+ args = { command: trimmed };
3760
+ }
3761
+ }
3762
+ const result = await explainPolicy(tool, args);
3763
+ console.log("");
3764
+ console.log(chalk5.cyan.bold("\u{1F6E1}\uFE0F Node9 Explain"));
3765
+ console.log("");
3766
+ console.log(` ${chalk5.bold("Tool:")} ${chalk5.white(result.tool)}`);
3767
+ if (argsRaw) {
3768
+ const preview = argsRaw.length > 80 ? argsRaw.slice(0, 77) + "\u2026" : argsRaw;
3769
+ console.log(` ${chalk5.bold("Input:")} ${chalk5.gray(preview)}`);
3770
+ }
3771
+ console.log("");
3772
+ console.log(chalk5.bold("Config Sources (Waterfall):"));
3773
+ for (const tier of result.waterfall) {
3774
+ const num = chalk5.gray(` ${tier.tier}.`);
3775
+ const label = tier.label.padEnd(16);
3776
+ let statusStr;
3777
+ if (tier.tier === 1) {
3778
+ statusStr = chalk5.gray(tier.note ?? "");
3779
+ } else if (tier.status === "active") {
3780
+ const loc = tier.path ? chalk5.gray(tier.path) : "";
3781
+ const note = tier.note ? chalk5.gray(`(${tier.note})`) : "";
3782
+ statusStr = chalk5.green("\u2713 active") + (loc ? " " + loc : "") + (note ? " " + note : "");
3783
+ } else {
3784
+ statusStr = chalk5.gray("\u25CB " + (tier.note ?? "not found"));
3785
+ }
3786
+ console.log(`${num} ${chalk5.white(label)} ${statusStr}`);
3787
+ }
3788
+ console.log("");
3789
+ console.log(chalk5.bold("Policy Evaluation:"));
3790
+ for (const step of result.steps) {
3791
+ const isFinal = step.isFinal;
3792
+ let icon;
3793
+ if (step.outcome === "allow") icon = chalk5.green(" \u2705");
3794
+ else if (step.outcome === "review") icon = chalk5.red(" \u{1F534}");
3795
+ else if (step.outcome === "skip") icon = chalk5.gray(" \u2500 ");
3796
+ else icon = chalk5.gray(" \u25CB ");
3797
+ const name = step.name.padEnd(18);
3798
+ const nameStr = isFinal ? chalk5.white.bold(name) : chalk5.white(name);
3799
+ const detail = isFinal ? chalk5.white(step.detail) : chalk5.gray(step.detail);
3800
+ const arrow = isFinal ? chalk5.yellow(" \u2190 STOP") : "";
3801
+ console.log(`${icon} ${nameStr} ${detail}${arrow}`);
3802
+ }
3803
+ console.log("");
3804
+ if (result.decision === "allow") {
3805
+ console.log(chalk5.green.bold(" Decision: \u2705 ALLOW") + chalk5.gray(" \u2014 no approval needed"));
3806
+ } else {
3807
+ console.log(
3808
+ chalk5.red.bold(" Decision: \u{1F534} REVIEW") + chalk5.gray(" \u2014 human approval required")
3809
+ );
3810
+ if (result.blockedByLabel) {
3811
+ console.log(chalk5.gray(` Reason: ${result.blockedByLabel}`));
3812
+ }
3813
+ }
3814
+ console.log("");
3815
+ });
3163
3816
  program.command("init").description("Create ~/.node9/config.json with default policy (safe to run multiple times)").option("--force", "Overwrite existing config").option("-m, --mode <mode>", "Set initial security mode (standard, strict, audit)", "standard").action((options) => {
3164
3817
  const configPath = path5.join(os5.homedir(), ".node9", "config.json");
3165
3818
  if (fs5.existsSync(configPath) && !options.force) {
@@ -3297,7 +3950,6 @@ RAW: ${raw}
3297
3950
  );
3298
3951
  }
3299
3952
  process.exit(0);
3300
- return;
3301
3953
  }
3302
3954
  if (payload.cwd) {
3303
3955
  try {
@@ -3327,26 +3979,7 @@ RAW: ${raw}
3327
3979
  console.error(chalk5.gray(` Triggered by: ${blockedByContext}`));
3328
3980
  if (result2?.changeHint) console.error(chalk5.cyan(` To change: ${result2.changeHint}`));
3329
3981
  console.error("");
3330
- let aiFeedbackMessage = "";
3331
- if (isHumanDecision) {
3332
- aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: The human user specifically REJECTED this action.
3333
- REASON: ${msg || "No specific reason provided by user."}
3334
-
3335
- INSTRUCTIONS FOR AI AGENT:
3336
- - Do NOT retry this exact command immediately.
3337
- - Explain to the user that you understand they blocked the action.
3338
- - Ask the user if there is an alternative approach they would prefer, or if they intended to block this action entirely.
3339
- - If you believe this action is critical, explain your reasoning to the user and ask them to run 'node9 pause 15m' to allow you to proceed.`;
3340
- } else {
3341
- aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${blockedByContext}].
3342
- REASON: ${msg}
3343
-
3344
- INSTRUCTIONS FOR AI AGENT:
3345
- - This command violates the current security configuration.
3346
- - Do NOT attempt to bypass this rule with bash syntax tricks; it will be blocked again.
3347
- - Pivot to a non-destructive or read-only alternative.
3348
- - Inform the user which security rule was triggered.`;
3349
- }
3982
+ const aiFeedbackMessage = buildNegotiationMessage(blockedByContext, isHumanDecision, msg);
3350
3983
  console.error(chalk5.dim(` (Detailed instructions sent to AI agent)`));
3351
3984
  process.stdout.write(
3352
3985
  JSON.stringify({
@@ -3368,17 +4001,16 @@ INSTRUCTIONS FOR AI AGENT:
3368
4001
  }
3369
4002
  const meta = { agent, mcpServer };
3370
4003
  const STATE_CHANGING_TOOLS_PRE = [
3371
- "bash",
3372
- "shell",
3373
4004
  "write_file",
3374
4005
  "edit_file",
4006
+ "edit",
3375
4007
  "replace",
3376
4008
  "terminal.execute",
3377
4009
  "str_replace_based_edit_tool",
3378
4010
  "create_file"
3379
4011
  ];
3380
4012
  if (config.settings.enableUndo && STATE_CHANGING_TOOLS_PRE.includes(toolName.toLowerCase())) {
3381
- await createShadowSnapshot();
4013
+ await createShadowSnapshot(toolName, toolInput);
3382
4014
  }
3383
4015
  const result = await authorizeHeadless(toolName, toolInput, false, meta);
3384
4016
  if (result.approved) {
@@ -3566,24 +4198,77 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
3566
4198
  program.help();
3567
4199
  }
3568
4200
  });
3569
- program.command("undo").description("Revert the project to the state before the last AI action").action(async () => {
3570
- const hash = getLatestSnapshotHash();
3571
- if (!hash) {
3572
- console.log(chalk5.yellow("\n\u2139\uFE0F No Undo snapshot found for this machine.\n"));
4201
+ program.command("undo").description(
4202
+ "Revert files to a pre-AI snapshot. Shows a diff and asks for confirmation before reverting. Use --steps N to go back N actions."
4203
+ ).option("--steps <n>", "Number of snapshots to go back (default: 1)", "1").action(async (options) => {
4204
+ const steps = Math.max(1, parseInt(options.steps, 10) || 1);
4205
+ const history = getSnapshotHistory();
4206
+ if (history.length === 0) {
4207
+ console.log(chalk5.yellow("\n\u2139\uFE0F No undo snapshots found.\n"));
4208
+ return;
4209
+ }
4210
+ const idx = history.length - steps;
4211
+ if (idx < 0) {
4212
+ console.log(
4213
+ chalk5.yellow(
4214
+ `
4215
+ \u2139\uFE0F Only ${history.length} snapshot(s) available, cannot go back ${steps}.
4216
+ `
4217
+ )
4218
+ );
3573
4219
  return;
3574
4220
  }
3575
- console.log(chalk5.magenta.bold("\n\u23EA NODE9 UNDO ENGINE"));
3576
- console.log(chalk5.white(`Target Snapshot: ${chalk5.gray(hash.slice(0, 7))}`));
4221
+ const snapshot = history[idx];
4222
+ const age = Math.round((Date.now() - snapshot.timestamp) / 1e3);
4223
+ const ageStr = age < 60 ? `${age}s ago` : age < 3600 ? `${Math.round(age / 60)}m ago` : `${Math.round(age / 3600)}h ago`;
4224
+ console.log(chalk5.magenta.bold(`
4225
+ \u23EA Node9 Undo${steps > 1 ? ` (${steps} steps back)` : ""}`));
4226
+ console.log(
4227
+ chalk5.white(
4228
+ ` Tool: ${chalk5.cyan(snapshot.tool)}${snapshot.argsSummary ? chalk5.gray(" \u2192 " + snapshot.argsSummary) : ""}`
4229
+ )
4230
+ );
4231
+ console.log(chalk5.white(` When: ${chalk5.gray(ageStr)}`));
4232
+ console.log(chalk5.white(` Dir: ${chalk5.gray(snapshot.cwd)}`));
4233
+ if (steps > 1)
4234
+ console.log(
4235
+ chalk5.yellow(` Note: This will also undo the ${steps - 1} action(s) after it.`)
4236
+ );
4237
+ console.log("");
4238
+ const diff = computeUndoDiff(snapshot.hash, snapshot.cwd);
4239
+ if (diff) {
4240
+ const lines = diff.split("\n");
4241
+ for (const line of lines) {
4242
+ if (line.startsWith("+++") || line.startsWith("---")) {
4243
+ console.log(chalk5.bold(line));
4244
+ } else if (line.startsWith("+")) {
4245
+ console.log(chalk5.green(line));
4246
+ } else if (line.startsWith("-")) {
4247
+ console.log(chalk5.red(line));
4248
+ } else if (line.startsWith("@@")) {
4249
+ console.log(chalk5.cyan(line));
4250
+ } else {
4251
+ console.log(chalk5.gray(line));
4252
+ }
4253
+ }
4254
+ console.log("");
4255
+ } else {
4256
+ console.log(
4257
+ chalk5.gray(" (no diff available \u2014 working tree may already match snapshot)\n")
4258
+ );
4259
+ }
3577
4260
  const proceed = await confirm3({
3578
- message: "Revert all files to the state before the last AI action?",
4261
+ message: `Revert to this snapshot?`,
3579
4262
  default: false
3580
4263
  });
3581
4264
  if (proceed) {
3582
- if (applyUndo(hash)) {
3583
- console.log(chalk5.green("\u2705 Project reverted successfully.\n"));
4265
+ if (applyUndo(snapshot.hash, snapshot.cwd)) {
4266
+ console.log(chalk5.green("\n\u2705 Reverted successfully.\n"));
3584
4267
  } else {
3585
- console.error(chalk5.red("\u274C Undo failed. Ensure you are in a Git repository.\n"));
4268
+ console.error(chalk5.red("\n\u274C Undo failed. Ensure you are in a Git repository.\n"));
3586
4269
  }
4270
+ } else {
4271
+ console.log(chalk5.gray("\nCancelled.\n"));
3587
4272
  }
3588
4273
  });
3589
4274
  process.on("unhandledRejection", (reason) => {