@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/README.md +30 -6
- package/dist/cli.js +748 -63
- package/dist/cli.mjs +748 -63
- package/dist/index.js +32 -5
- package/dist/index.mjs +32 -5
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -352,6 +352,23 @@ function extractShellCommand(toolName, args, toolInspection) {
|
|
|
352
352
|
const value = getNestedValue(args, fieldPath);
|
|
353
353
|
return typeof value === "string" ? value : null;
|
|
354
354
|
}
|
|
355
|
+
function isSqlTool(toolName, toolInspection) {
|
|
356
|
+
const patterns = Object.keys(toolInspection);
|
|
357
|
+
const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
|
|
358
|
+
if (!matchingPattern) return false;
|
|
359
|
+
const fieldName = toolInspection[matchingPattern];
|
|
360
|
+
return fieldName === "sql" || fieldName === "query";
|
|
361
|
+
}
|
|
362
|
+
var SQL_DML_KEYWORDS = /* @__PURE__ */ new Set(["select", "insert", "update", "delete", "merge", "upsert"]);
|
|
363
|
+
function checkDangerousSql(sql) {
|
|
364
|
+
const norm = sql.replace(/\s+/g, " ").trim().toLowerCase();
|
|
365
|
+
const hasWhere = /\bwhere\b/.test(norm);
|
|
366
|
+
if (/^delete\s+from\s+\S+/.test(norm) && !hasWhere)
|
|
367
|
+
return "DELETE without WHERE \u2014 full table wipe";
|
|
368
|
+
if (/^update\s+\S+\s+set\s+/.test(norm) && !hasWhere)
|
|
369
|
+
return "UPDATE without WHERE \u2014 updates every row";
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
355
372
|
async function analyzeShellCommand(command) {
|
|
356
373
|
const actions = [];
|
|
357
374
|
const paths = [];
|
|
@@ -558,9 +575,20 @@ async function evaluatePolicy(toolName, args, agent) {
|
|
|
558
575
|
if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
|
|
559
576
|
return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
|
|
560
577
|
}
|
|
578
|
+
if (isSqlTool(toolName, config.policy.toolInspection)) {
|
|
579
|
+
const sqlDanger = checkDangerousSql(shellCommand);
|
|
580
|
+
if (sqlDanger) return { decision: "review", blockedByLabel: `SQL Safety: ${sqlDanger}` };
|
|
581
|
+
allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
|
|
582
|
+
actionTokens = actionTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
|
|
583
|
+
}
|
|
561
584
|
} else {
|
|
562
585
|
allTokens = tokenize(toolName);
|
|
563
586
|
actionTokens = [toolName];
|
|
587
|
+
if (args && typeof args === "object") {
|
|
588
|
+
const flattenedArgs = JSON.stringify(args).toLowerCase();
|
|
589
|
+
const extraTokens = flattenedArgs.split(/[^a-zA-Z0-9]+/).filter((t) => t.length > 1);
|
|
590
|
+
allTokens.push(...extraTokens);
|
|
591
|
+
}
|
|
564
592
|
}
|
|
565
593
|
const isManual = agent === "Terminal";
|
|
566
594
|
if (isManual) {
|
|
@@ -627,6 +655,285 @@ async function evaluatePolicy(toolName, args, agent) {
|
|
|
627
655
|
}
|
|
628
656
|
return { decision: "allow" };
|
|
629
657
|
}
|
|
658
|
+
async function explainPolicy(toolName, args) {
|
|
659
|
+
const steps = [];
|
|
660
|
+
const globalPath = import_path.default.join(import_os.default.homedir(), ".node9", "config.json");
|
|
661
|
+
const projectPath = import_path.default.join(process.cwd(), "node9.config.json");
|
|
662
|
+
const credsPath = import_path.default.join(import_os.default.homedir(), ".node9", "credentials.json");
|
|
663
|
+
const waterfall = [
|
|
664
|
+
{
|
|
665
|
+
tier: 1,
|
|
666
|
+
label: "Env vars",
|
|
667
|
+
status: "env",
|
|
668
|
+
note: process.env.NODE9_MODE ? `NODE9_MODE=${process.env.NODE9_MODE}` : "not set"
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
tier: 2,
|
|
672
|
+
label: "Cloud policy",
|
|
673
|
+
status: import_fs.default.existsSync(credsPath) ? "active" : "missing",
|
|
674
|
+
note: import_fs.default.existsSync(credsPath) ? "credentials found (not evaluated in explain mode)" : "not connected \u2014 run: node9 login"
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
tier: 3,
|
|
678
|
+
label: "Project config",
|
|
679
|
+
status: import_fs.default.existsSync(projectPath) ? "active" : "missing",
|
|
680
|
+
path: projectPath
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
tier: 4,
|
|
684
|
+
label: "Global config",
|
|
685
|
+
status: import_fs.default.existsSync(globalPath) ? "active" : "missing",
|
|
686
|
+
path: globalPath
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
tier: 5,
|
|
690
|
+
label: "Defaults",
|
|
691
|
+
status: "active",
|
|
692
|
+
note: "always active"
|
|
693
|
+
}
|
|
694
|
+
];
|
|
695
|
+
const config = getConfig();
|
|
696
|
+
if (matchesPattern(toolName, config.policy.ignoredTools)) {
|
|
697
|
+
steps.push({
|
|
698
|
+
name: "Ignored tools",
|
|
699
|
+
outcome: "allow",
|
|
700
|
+
detail: `"${toolName}" matches ignoredTools pattern \u2192 fast-path allow`,
|
|
701
|
+
isFinal: true
|
|
702
|
+
});
|
|
703
|
+
return { tool: toolName, args, waterfall, steps, decision: "allow" };
|
|
704
|
+
}
|
|
705
|
+
steps.push({
|
|
706
|
+
name: "Ignored tools",
|
|
707
|
+
outcome: "checked",
|
|
708
|
+
detail: `"${toolName}" not in ignoredTools list`
|
|
709
|
+
});
|
|
710
|
+
let allTokens = [];
|
|
711
|
+
let actionTokens = [];
|
|
712
|
+
let pathTokens = [];
|
|
713
|
+
const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
|
|
714
|
+
if (shellCommand) {
|
|
715
|
+
const analyzed = await analyzeShellCommand(shellCommand);
|
|
716
|
+
allTokens = analyzed.allTokens;
|
|
717
|
+
actionTokens = analyzed.actions;
|
|
718
|
+
pathTokens = analyzed.paths;
|
|
719
|
+
const patterns = Object.keys(config.policy.toolInspection);
|
|
720
|
+
const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
|
|
721
|
+
const fieldName = matchingPattern ? config.policy.toolInspection[matchingPattern] : "command";
|
|
722
|
+
steps.push({
|
|
723
|
+
name: "Input parsing",
|
|
724
|
+
outcome: "checked",
|
|
725
|
+
detail: `Shell command via toolInspection["${matchingPattern ?? toolName}"] \u2192 field "${fieldName}": "${shellCommand}"`
|
|
726
|
+
});
|
|
727
|
+
const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
|
|
728
|
+
if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
|
|
729
|
+
steps.push({
|
|
730
|
+
name: "Inline execution",
|
|
731
|
+
outcome: "review",
|
|
732
|
+
detail: 'Inline code execution detected (e.g. "bash -c ...") \u2014 always requires review',
|
|
733
|
+
isFinal: true
|
|
734
|
+
});
|
|
735
|
+
return {
|
|
736
|
+
tool: toolName,
|
|
737
|
+
args,
|
|
738
|
+
waterfall,
|
|
739
|
+
steps,
|
|
740
|
+
decision: "review",
|
|
741
|
+
blockedByLabel: "Node9 Standard (Inline Execution)"
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
steps.push({
|
|
745
|
+
name: "Inline execution",
|
|
746
|
+
outcome: "checked",
|
|
747
|
+
detail: "No inline execution pattern detected"
|
|
748
|
+
});
|
|
749
|
+
if (isSqlTool(toolName, config.policy.toolInspection)) {
|
|
750
|
+
const sqlDanger = checkDangerousSql(shellCommand);
|
|
751
|
+
if (sqlDanger) {
|
|
752
|
+
steps.push({
|
|
753
|
+
name: "SQL safety",
|
|
754
|
+
outcome: "review",
|
|
755
|
+
detail: sqlDanger,
|
|
756
|
+
isFinal: true
|
|
757
|
+
});
|
|
758
|
+
return {
|
|
759
|
+
tool: toolName,
|
|
760
|
+
args,
|
|
761
|
+
waterfall,
|
|
762
|
+
steps,
|
|
763
|
+
decision: "review",
|
|
764
|
+
blockedByLabel: `SQL Safety: ${sqlDanger}`
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
|
|
768
|
+
actionTokens = actionTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
|
|
769
|
+
steps.push({
|
|
770
|
+
name: "SQL safety",
|
|
771
|
+
outcome: "checked",
|
|
772
|
+
detail: "DELETE/UPDATE have a WHERE clause \u2014 scoped mutation, safe"
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
} else {
|
|
776
|
+
allTokens = tokenize(toolName);
|
|
777
|
+
actionTokens = [toolName];
|
|
778
|
+
let detail = `No toolInspection match for "${toolName}" \u2014 tokens: [${allTokens.join(", ")}]`;
|
|
779
|
+
if (args && typeof args === "object") {
|
|
780
|
+
const flattenedArgs = JSON.stringify(args).toLowerCase();
|
|
781
|
+
const extraTokens = flattenedArgs.split(/[^a-zA-Z0-9]+/).filter((t) => t.length > 1);
|
|
782
|
+
allTokens.push(...extraTokens);
|
|
783
|
+
const preview = extraTokens.slice(0, 8).join(", ") + (extraTokens.length > 8 ? "\u2026" : "");
|
|
784
|
+
detail += ` + deep scan of args: [${preview}]`;
|
|
785
|
+
}
|
|
786
|
+
steps.push({ name: "Input parsing", outcome: "checked", detail });
|
|
787
|
+
}
|
|
788
|
+
const uniqueTokens = [...new Set(allTokens)];
|
|
789
|
+
steps.push({
|
|
790
|
+
name: "Tokens scanned",
|
|
791
|
+
outcome: "checked",
|
|
792
|
+
detail: `[${uniqueTokens.join(", ")}]`
|
|
793
|
+
});
|
|
794
|
+
if (pathTokens.length > 0 && config.policy.sandboxPaths.length > 0) {
|
|
795
|
+
const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
|
|
796
|
+
if (allInSandbox) {
|
|
797
|
+
steps.push({
|
|
798
|
+
name: "Sandbox paths",
|
|
799
|
+
outcome: "allow",
|
|
800
|
+
detail: `[${pathTokens.join(", ")}] all match sandbox patterns \u2192 auto-allow`,
|
|
801
|
+
isFinal: true
|
|
802
|
+
});
|
|
803
|
+
return { tool: toolName, args, waterfall, steps, decision: "allow" };
|
|
804
|
+
}
|
|
805
|
+
const unmatched = pathTokens.filter((p) => !matchesPattern(p, config.policy.sandboxPaths));
|
|
806
|
+
steps.push({
|
|
807
|
+
name: "Sandbox paths",
|
|
808
|
+
outcome: "checked",
|
|
809
|
+
detail: `[${unmatched.join(", ")}] not in sandbox \u2014 not auto-allowed`
|
|
810
|
+
});
|
|
811
|
+
} else {
|
|
812
|
+
steps.push({
|
|
813
|
+
name: "Sandbox paths",
|
|
814
|
+
outcome: "skip",
|
|
815
|
+
detail: pathTokens.length === 0 ? "No path tokens found in input" : "No sandbox paths configured"
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
let ruleMatched = false;
|
|
819
|
+
for (const action of actionTokens) {
|
|
820
|
+
const rule = config.policy.rules.find(
|
|
821
|
+
(r) => r.action === action || matchesPattern(action, r.action)
|
|
822
|
+
);
|
|
823
|
+
if (rule) {
|
|
824
|
+
ruleMatched = true;
|
|
825
|
+
if (pathTokens.length > 0) {
|
|
826
|
+
const anyBlocked = pathTokens.some((p) => matchesPattern(p, rule.blockPaths || []));
|
|
827
|
+
if (anyBlocked) {
|
|
828
|
+
steps.push({
|
|
829
|
+
name: "Policy rules",
|
|
830
|
+
outcome: "review",
|
|
831
|
+
detail: `Rule "${rule.action}" matched + path is in blockPaths`,
|
|
832
|
+
isFinal: true
|
|
833
|
+
});
|
|
834
|
+
return {
|
|
835
|
+
tool: toolName,
|
|
836
|
+
args,
|
|
837
|
+
waterfall,
|
|
838
|
+
steps,
|
|
839
|
+
decision: "review",
|
|
840
|
+
blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)`
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
|
|
844
|
+
if (allAllowed) {
|
|
845
|
+
steps.push({
|
|
846
|
+
name: "Policy rules",
|
|
847
|
+
outcome: "allow",
|
|
848
|
+
detail: `Rule "${rule.action}" matched + all paths are in allowPaths`,
|
|
849
|
+
isFinal: true
|
|
850
|
+
});
|
|
851
|
+
return { tool: toolName, args, waterfall, steps, decision: "allow" };
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
steps.push({
|
|
855
|
+
name: "Policy rules",
|
|
856
|
+
outcome: "review",
|
|
857
|
+
detail: `Rule "${rule.action}" matched \u2014 default block (no path exception)`,
|
|
858
|
+
isFinal: true
|
|
859
|
+
});
|
|
860
|
+
return {
|
|
861
|
+
tool: toolName,
|
|
862
|
+
args,
|
|
863
|
+
waterfall,
|
|
864
|
+
steps,
|
|
865
|
+
decision: "review",
|
|
866
|
+
blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)`
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (!ruleMatched) {
|
|
871
|
+
steps.push({
|
|
872
|
+
name: "Policy rules",
|
|
873
|
+
outcome: "skip",
|
|
874
|
+
detail: config.policy.rules.length === 0 ? "No rules configured" : `No rule matched [${actionTokens.join(", ")}]`
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
let matchedDangerousWord;
|
|
878
|
+
const isDangerous = uniqueTokens.some(
|
|
879
|
+
(token) => config.policy.dangerousWords.some((word) => {
|
|
880
|
+
const w = word.toLowerCase();
|
|
881
|
+
const hit = token === w || (() => {
|
|
882
|
+
try {
|
|
883
|
+
return new RegExp(`\\b${w}\\b`, "i").test(token);
|
|
884
|
+
} catch {
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
})();
|
|
888
|
+
if (hit && !matchedDangerousWord) matchedDangerousWord = word;
|
|
889
|
+
return hit;
|
|
890
|
+
})
|
|
891
|
+
);
|
|
892
|
+
if (isDangerous) {
|
|
893
|
+
steps.push({
|
|
894
|
+
name: "Dangerous words",
|
|
895
|
+
outcome: "review",
|
|
896
|
+
detail: `"${matchedDangerousWord}" found in token list`,
|
|
897
|
+
isFinal: true
|
|
898
|
+
});
|
|
899
|
+
return {
|
|
900
|
+
tool: toolName,
|
|
901
|
+
args,
|
|
902
|
+
waterfall,
|
|
903
|
+
steps,
|
|
904
|
+
decision: "review",
|
|
905
|
+
blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`,
|
|
906
|
+
matchedToken: matchedDangerousWord
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
steps.push({
|
|
910
|
+
name: "Dangerous words",
|
|
911
|
+
outcome: "checked",
|
|
912
|
+
detail: `No dangerous words matched`
|
|
913
|
+
});
|
|
914
|
+
if (config.settings.mode === "strict") {
|
|
915
|
+
steps.push({
|
|
916
|
+
name: "Strict mode",
|
|
917
|
+
outcome: "review",
|
|
918
|
+
detail: 'Mode is "strict" \u2014 all tools require approval unless explicitly allowed',
|
|
919
|
+
isFinal: true
|
|
920
|
+
});
|
|
921
|
+
return {
|
|
922
|
+
tool: toolName,
|
|
923
|
+
args,
|
|
924
|
+
waterfall,
|
|
925
|
+
steps,
|
|
926
|
+
decision: "review",
|
|
927
|
+
blockedByLabel: "Global Config (Strict Mode Active)"
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
steps.push({
|
|
931
|
+
name: "Strict mode",
|
|
932
|
+
outcome: "skip",
|
|
933
|
+
detail: `Mode is "${config.settings.mode}" \u2014 no catch-all review`
|
|
934
|
+
});
|
|
935
|
+
return { tool: toolName, args, waterfall, steps, decision: "allow" };
|
|
936
|
+
}
|
|
630
937
|
function isIgnoredTool(toolName) {
|
|
631
938
|
const config = getConfig();
|
|
632
939
|
return matchesPattern(toolName, config.policy.ignoredTools);
|
|
@@ -789,8 +1096,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
789
1096
|
const cloudEnforced = approvers.cloud && !!creds?.apiKey;
|
|
790
1097
|
if (cloudEnforced) {
|
|
791
1098
|
try {
|
|
792
|
-
const
|
|
793
|
-
const initResult = await initNode9SaaS(toolName, args, creds, envConfig?.slackChannel, meta);
|
|
1099
|
+
const initResult = await initNode9SaaS(toolName, args, creds, meta);
|
|
794
1100
|
if (!initResult.pending) {
|
|
795
1101
|
return {
|
|
796
1102
|
approved: !!initResult.approved,
|
|
@@ -1044,6 +1350,7 @@ function getConfig() {
|
|
|
1044
1350
|
if (s.enableHookLogDebug !== void 0)
|
|
1045
1351
|
mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
|
|
1046
1352
|
if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
|
|
1353
|
+
if (s.environment !== void 0) mergedSettings.environment = s.environment;
|
|
1047
1354
|
if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
|
|
1048
1355
|
if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
|
|
1049
1356
|
if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
|
|
@@ -1073,7 +1380,7 @@ function tryLoadConfig(filePath) {
|
|
|
1073
1380
|
}
|
|
1074
1381
|
}
|
|
1075
1382
|
function getActiveEnvironment(config) {
|
|
1076
|
-
const env = process.env.NODE_ENV || "development";
|
|
1383
|
+
const env = config.settings.environment || process.env.NODE_ENV || "development";
|
|
1077
1384
|
return config.environments[env] ?? null;
|
|
1078
1385
|
}
|
|
1079
1386
|
function getCredentials() {
|
|
@@ -1129,7 +1436,7 @@ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
|
|
|
1129
1436
|
}).catch(() => {
|
|
1130
1437
|
});
|
|
1131
1438
|
}
|
|
1132
|
-
async function initNode9SaaS(toolName, args, creds,
|
|
1439
|
+
async function initNode9SaaS(toolName, args, creds, meta) {
|
|
1133
1440
|
const controller = new AbortController();
|
|
1134
1441
|
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
1135
1442
|
try {
|
|
@@ -1139,7 +1446,6 @@ async function initNode9SaaS(toolName, args, creds, slackChannel, meta) {
|
|
|
1139
1446
|
body: JSON.stringify({
|
|
1140
1447
|
toolName,
|
|
1141
1448
|
args,
|
|
1142
|
-
slackChannel,
|
|
1143
1449
|
context: {
|
|
1144
1450
|
agent: meta?.agent,
|
|
1145
1451
|
mcpServer: meta?.mcpServer,
|
|
@@ -2937,8 +3243,34 @@ var import_child_process3 = require("child_process");
|
|
|
2937
3243
|
var import_fs4 = __toESM(require("fs"));
|
|
2938
3244
|
var import_path4 = __toESM(require("path"));
|
|
2939
3245
|
var import_os4 = __toESM(require("os"));
|
|
3246
|
+
var SNAPSHOT_STACK_PATH = import_path4.default.join(import_os4.default.homedir(), ".node9", "snapshots.json");
|
|
2940
3247
|
var UNDO_LATEST_PATH = import_path4.default.join(import_os4.default.homedir(), ".node9", "undo_latest.txt");
|
|
2941
|
-
|
|
3248
|
+
var MAX_SNAPSHOTS = 10;
|
|
3249
|
+
function readStack() {
|
|
3250
|
+
try {
|
|
3251
|
+
if (import_fs4.default.existsSync(SNAPSHOT_STACK_PATH))
|
|
3252
|
+
return JSON.parse(import_fs4.default.readFileSync(SNAPSHOT_STACK_PATH, "utf-8"));
|
|
3253
|
+
} catch {
|
|
3254
|
+
}
|
|
3255
|
+
return [];
|
|
3256
|
+
}
|
|
3257
|
+
function writeStack(stack) {
|
|
3258
|
+
const dir = import_path4.default.dirname(SNAPSHOT_STACK_PATH);
|
|
3259
|
+
if (!import_fs4.default.existsSync(dir)) import_fs4.default.mkdirSync(dir, { recursive: true });
|
|
3260
|
+
import_fs4.default.writeFileSync(SNAPSHOT_STACK_PATH, JSON.stringify(stack, null, 2));
|
|
3261
|
+
}
|
|
3262
|
+
function buildArgsSummary(tool, args) {
|
|
3263
|
+
if (!args || typeof args !== "object") return "";
|
|
3264
|
+
const a = args;
|
|
3265
|
+
const filePath = a.file_path ?? a.path ?? a.filename;
|
|
3266
|
+
if (typeof filePath === "string") return filePath;
|
|
3267
|
+
const cmd = a.command ?? a.cmd;
|
|
3268
|
+
if (typeof cmd === "string") return cmd.slice(0, 80);
|
|
3269
|
+
const sql = a.sql ?? a.query;
|
|
3270
|
+
if (typeof sql === "string") return sql.slice(0, 80);
|
|
3271
|
+
return tool;
|
|
3272
|
+
}
|
|
3273
|
+
async function createShadowSnapshot(tool = "unknown", args = {}) {
|
|
2942
3274
|
try {
|
|
2943
3275
|
const cwd = process.cwd();
|
|
2944
3276
|
if (!import_fs4.default.existsSync(import_path4.default.join(cwd, ".git"))) return null;
|
|
@@ -2956,30 +3288,59 @@ async function createShadowSnapshot() {
|
|
|
2956
3288
|
`Node9 AI Snapshot: ${(/* @__PURE__ */ new Date()).toISOString()}`
|
|
2957
3289
|
]);
|
|
2958
3290
|
const commitHash = commitRes.stdout.toString().trim();
|
|
2959
|
-
if (commitHash
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
3291
|
+
if (!commitHash || commitRes.status !== 0) return null;
|
|
3292
|
+
const stack = readStack();
|
|
3293
|
+
const entry = {
|
|
3294
|
+
hash: commitHash,
|
|
3295
|
+
tool,
|
|
3296
|
+
argsSummary: buildArgsSummary(tool, args),
|
|
3297
|
+
cwd,
|
|
3298
|
+
timestamp: Date.now()
|
|
3299
|
+
};
|
|
3300
|
+
stack.push(entry);
|
|
3301
|
+
if (stack.length > MAX_SNAPSHOTS) stack.splice(0, stack.length - MAX_SNAPSHOTS);
|
|
3302
|
+
writeStack(stack);
|
|
3303
|
+
import_fs4.default.writeFileSync(UNDO_LATEST_PATH, commitHash);
|
|
3304
|
+
return commitHash;
|
|
2965
3305
|
} catch (err) {
|
|
2966
|
-
if (process.env.NODE9_DEBUG === "1")
|
|
2967
|
-
console.error("[Node9 Undo Engine Error]:", err);
|
|
2968
|
-
}
|
|
3306
|
+
if (process.env.NODE9_DEBUG === "1") console.error("[Node9 Undo Engine Error]:", err);
|
|
2969
3307
|
}
|
|
2970
3308
|
return null;
|
|
2971
3309
|
}
|
|
2972
|
-
function
|
|
3310
|
+
function getSnapshotHistory() {
|
|
3311
|
+
return readStack();
|
|
3312
|
+
}
|
|
3313
|
+
function computeUndoDiff(hash, cwd) {
|
|
2973
3314
|
try {
|
|
2974
|
-
const
|
|
3315
|
+
const result = (0, import_child_process3.spawnSync)("git", ["diff", hash, "--stat", "--", "."], { cwd });
|
|
3316
|
+
const stat = result.stdout.toString().trim();
|
|
3317
|
+
if (!stat) return null;
|
|
3318
|
+
const diff = (0, import_child_process3.spawnSync)("git", ["diff", hash, "--", "."], { cwd });
|
|
3319
|
+
const raw = diff.stdout.toString();
|
|
3320
|
+
if (!raw) return null;
|
|
3321
|
+
const lines = raw.split("\n").filter(
|
|
3322
|
+
(l) => !l.startsWith("diff --git") && !l.startsWith("index ") && !l.startsWith("Binary")
|
|
3323
|
+
);
|
|
3324
|
+
return lines.join("\n") || null;
|
|
3325
|
+
} catch {
|
|
3326
|
+
return null;
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
function applyUndo(hash, cwd) {
|
|
3330
|
+
try {
|
|
3331
|
+
const dir = cwd ?? process.cwd();
|
|
3332
|
+
const restore = (0, import_child_process3.spawnSync)("git", ["restore", "--source", hash, "--staged", "--worktree", "."], {
|
|
3333
|
+
cwd: dir
|
|
3334
|
+
});
|
|
2975
3335
|
if (restore.status !== 0) return false;
|
|
2976
|
-
const lsTree = (0, import_child_process3.spawnSync)("git", ["ls-tree", "-r", "--name-only", hash]);
|
|
3336
|
+
const lsTree = (0, import_child_process3.spawnSync)("git", ["ls-tree", "-r", "--name-only", hash], { cwd: dir });
|
|
2977
3337
|
const snapshotFiles = new Set(lsTree.stdout.toString().trim().split("\n").filter(Boolean));
|
|
2978
|
-
const tracked = (0, import_child_process3.spawnSync)("git", ["ls-files"]).stdout.toString().trim().split("\n").filter(Boolean);
|
|
2979
|
-
const untracked = (0, import_child_process3.spawnSync)("git", ["ls-files", "--others", "--exclude-standard"]).stdout.toString().trim().split("\n").filter(Boolean);
|
|
3338
|
+
const tracked = (0, import_child_process3.spawnSync)("git", ["ls-files"], { cwd: dir }).stdout.toString().trim().split("\n").filter(Boolean);
|
|
3339
|
+
const untracked = (0, import_child_process3.spawnSync)("git", ["ls-files", "--others", "--exclude-standard"], { cwd: dir }).stdout.toString().trim().split("\n").filter(Boolean);
|
|
2980
3340
|
for (const file of [...tracked, ...untracked]) {
|
|
2981
|
-
|
|
2982
|
-
|
|
3341
|
+
const fullPath = import_path4.default.join(dir, file);
|
|
3342
|
+
if (!snapshotFiles.has(file) && import_fs4.default.existsSync(fullPath)) {
|
|
3343
|
+
import_fs4.default.unlinkSync(fullPath);
|
|
2983
3344
|
}
|
|
2984
3345
|
}
|
|
2985
3346
|
return true;
|
|
@@ -2987,10 +3348,6 @@ function applyUndo(hash) {
|
|
|
2987
3348
|
return false;
|
|
2988
3349
|
}
|
|
2989
3350
|
}
|
|
2990
|
-
function getLatestSnapshotHash() {
|
|
2991
|
-
if (!import_fs4.default.existsSync(UNDO_LATEST_PATH)) return null;
|
|
2992
|
-
return import_fs4.default.readFileSync(UNDO_LATEST_PATH, "utf-8").trim();
|
|
2993
|
-
}
|
|
2994
3351
|
|
|
2995
3352
|
// src/cli.ts
|
|
2996
3353
|
var import_prompts3 = require("@inquirer/prompts");
|
|
@@ -3017,6 +3374,59 @@ function parseDuration(str) {
|
|
|
3017
3374
|
function sanitize(value) {
|
|
3018
3375
|
return value.replace(/[\x00-\x1F\x7F]/g, "");
|
|
3019
3376
|
}
|
|
3377
|
+
function buildNegotiationMessage(blockedByLabel, isHumanDecision, humanReason) {
|
|
3378
|
+
if (isHumanDecision) {
|
|
3379
|
+
return `NODE9: The human user rejected this action.
|
|
3380
|
+
REASON: ${humanReason || "No specific reason provided."}
|
|
3381
|
+
INSTRUCTIONS:
|
|
3382
|
+
- Do NOT retry this exact command.
|
|
3383
|
+
- Acknowledge the block to the user and ask if there is an alternative approach.
|
|
3384
|
+
- If you believe this action is critical, explain your reasoning and ask them to run "node9 pause 15m" to proceed.`;
|
|
3385
|
+
}
|
|
3386
|
+
const label = blockedByLabel.toLowerCase();
|
|
3387
|
+
if (label.includes("sql safety") && label.includes("delete without where")) {
|
|
3388
|
+
return `NODE9: Blocked \u2014 DELETE without WHERE clause would wipe the entire table.
|
|
3389
|
+
INSTRUCTION: Add a WHERE clause to scope the deletion (e.g. WHERE id = <value>).
|
|
3390
|
+
Do NOT retry without a WHERE clause.`;
|
|
3391
|
+
}
|
|
3392
|
+
if (label.includes("sql safety") && label.includes("update without where")) {
|
|
3393
|
+
return `NODE9: Blocked \u2014 UPDATE without WHERE clause would update every row.
|
|
3394
|
+
INSTRUCTION: Add a WHERE clause to scope the update (e.g. WHERE id = <value>).
|
|
3395
|
+
Do NOT retry without a WHERE clause.`;
|
|
3396
|
+
}
|
|
3397
|
+
if (label.includes("dangerous word")) {
|
|
3398
|
+
const match = blockedByLabel.match(/dangerous word: "([^"]+)"/i);
|
|
3399
|
+
const word = match?.[1] ?? "a dangerous keyword";
|
|
3400
|
+
return `NODE9: Blocked \u2014 command contains forbidden keyword "${word}".
|
|
3401
|
+
INSTRUCTION: Do NOT use "${word}". Use a non-destructive alternative.
|
|
3402
|
+
Do NOT attempt to bypass this with shell tricks or aliases \u2014 it will be blocked again.`;
|
|
3403
|
+
}
|
|
3404
|
+
if (label.includes("path blocked") || label.includes("sandbox")) {
|
|
3405
|
+
return `NODE9: Blocked \u2014 operation targets a path outside the allowed sandbox.
|
|
3406
|
+
INSTRUCTION: Move your output to an allowed directory such as /tmp/ or the project directory.
|
|
3407
|
+
Do NOT retry on the same path.`;
|
|
3408
|
+
}
|
|
3409
|
+
if (label.includes("inline execution")) {
|
|
3410
|
+
return `NODE9: Blocked \u2014 inline code execution (e.g. bash -c "...") is not allowed.
|
|
3411
|
+
INSTRUCTION: Use individual tool calls instead of embedding code in a shell string.`;
|
|
3412
|
+
}
|
|
3413
|
+
if (label.includes("strict mode")) {
|
|
3414
|
+
return `NODE9: Blocked \u2014 strict mode is active. All tool calls require explicit human approval.
|
|
3415
|
+
INSTRUCTION: Inform the user this action is pending approval. Wait for them to approve via the dashboard or run "node9 pause".`;
|
|
3416
|
+
}
|
|
3417
|
+
if (label.includes("rule") && label.includes("default block")) {
|
|
3418
|
+
const match = blockedByLabel.match(/rule "([^"]+)"/i);
|
|
3419
|
+
const rule = match?.[1] ?? "a policy rule";
|
|
3420
|
+
return `NODE9: Blocked \u2014 action "${rule}" is forbidden by security policy.
|
|
3421
|
+
INSTRUCTION: Do NOT use "${rule}". Find a read-only or non-destructive alternative.
|
|
3422
|
+
Do NOT attempt to bypass this rule.`;
|
|
3423
|
+
}
|
|
3424
|
+
return `NODE9: Action blocked by security policy [${blockedByLabel}].
|
|
3425
|
+
INSTRUCTIONS:
|
|
3426
|
+
- Do NOT retry this exact command or attempt to bypass the rule.
|
|
3427
|
+
- Pivot to a non-destructive or read-only alternative.
|
|
3428
|
+
- Inform the user which security rule was triggered and ask how to proceed.`;
|
|
3429
|
+
}
|
|
3020
3430
|
function openBrowserLocal() {
|
|
3021
3431
|
const url = `http://${DAEMON_HOST2}:${DAEMON_PORT2}/`;
|
|
3022
3432
|
try {
|
|
@@ -3069,7 +3479,7 @@ async function runProxy(targetCommand) {
|
|
|
3069
3479
|
const child = (0, import_child_process4.spawn)(executable, args, {
|
|
3070
3480
|
stdio: ["pipe", "pipe", "inherit"],
|
|
3071
3481
|
// We control STDIN and STDOUT
|
|
3072
|
-
shell:
|
|
3482
|
+
shell: false,
|
|
3073
3483
|
env: { ...process.env, FORCE_COLOR: "1" }
|
|
3074
3484
|
});
|
|
3075
3485
|
const agentIn = import_readline.default.createInterface({ input: process.stdin, terminal: false });
|
|
@@ -3090,12 +3500,24 @@ async function runProxy(targetCommand) {
|
|
|
3090
3500
|
agent: "Proxy/MCP"
|
|
3091
3501
|
});
|
|
3092
3502
|
if (!result.approved) {
|
|
3503
|
+
console.error(import_chalk5.default.red(`
|
|
3504
|
+
\u{1F6D1} Node9 Sudo: Action Blocked`));
|
|
3505
|
+
console.error(import_chalk5.default.gray(` Tool: ${name}`));
|
|
3506
|
+
console.error(import_chalk5.default.gray(` Reason: ${result.reason || "Security Policy"}
|
|
3507
|
+
`));
|
|
3508
|
+
const blockedByLabel = result.blockedByLabel ?? result.reason ?? "Security Policy";
|
|
3509
|
+
const isHuman = blockedByLabel.toLowerCase().includes("user") || blockedByLabel.toLowerCase().includes("daemon") || blockedByLabel.toLowerCase().includes("decision");
|
|
3510
|
+
const aiInstruction = buildNegotiationMessage(blockedByLabel, isHuman, result.reason);
|
|
3093
3511
|
const errorResponse = {
|
|
3094
3512
|
jsonrpc: "2.0",
|
|
3095
|
-
id: message.id,
|
|
3513
|
+
id: message.id ?? null,
|
|
3096
3514
|
error: {
|
|
3097
3515
|
code: -32e3,
|
|
3098
|
-
message:
|
|
3516
|
+
message: aiInstruction,
|
|
3517
|
+
data: {
|
|
3518
|
+
reason: result.reason,
|
|
3519
|
+
blockedBy: result.blockedByLabel
|
|
3520
|
+
}
|
|
3099
3521
|
}
|
|
3100
3522
|
};
|
|
3101
3523
|
process.stdout.write(JSON.stringify(errorResponse) + "\n");
|
|
@@ -3183,6 +3605,237 @@ program.command("addto").description("Integrate Node9 with an AI agent").addHelp
|
|
|
3183
3605
|
console.error(import_chalk5.default.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
|
|
3184
3606
|
process.exit(1);
|
|
3185
3607
|
});
|
|
3608
|
+
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) => {
|
|
3609
|
+
if (!target) {
|
|
3610
|
+
console.log(import_chalk5.default.cyan("\n\u{1F6E1}\uFE0F Node9 Setup \u2014 integrate with your AI agent\n"));
|
|
3611
|
+
console.log(" Usage: " + import_chalk5.default.white("node9 setup <target>") + "\n");
|
|
3612
|
+
console.log(" Targets:");
|
|
3613
|
+
console.log(" " + import_chalk5.default.green("claude") + " \u2014 Claude Code (hook mode)");
|
|
3614
|
+
console.log(" " + import_chalk5.default.green("gemini") + " \u2014 Gemini CLI (hook mode)");
|
|
3615
|
+
console.log(" " + import_chalk5.default.green("cursor") + " \u2014 Cursor (hook mode)");
|
|
3616
|
+
console.log("");
|
|
3617
|
+
return;
|
|
3618
|
+
}
|
|
3619
|
+
const t = target.toLowerCase();
|
|
3620
|
+
if (t === "gemini") return await setupGemini();
|
|
3621
|
+
if (t === "claude") return await setupClaude();
|
|
3622
|
+
if (t === "cursor") return await setupCursor();
|
|
3623
|
+
console.error(import_chalk5.default.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
|
|
3624
|
+
process.exit(1);
|
|
3625
|
+
});
|
|
3626
|
+
program.command("doctor").description("Check that Node9 is installed and configured correctly").action(() => {
|
|
3627
|
+
const homeDir2 = import_os5.default.homedir();
|
|
3628
|
+
let failures = 0;
|
|
3629
|
+
function pass(msg) {
|
|
3630
|
+
console.log(import_chalk5.default.green(" \u2705 ") + msg);
|
|
3631
|
+
}
|
|
3632
|
+
function fail(msg, hint) {
|
|
3633
|
+
console.log(import_chalk5.default.red(" \u274C ") + msg);
|
|
3634
|
+
if (hint) console.log(import_chalk5.default.gray(" " + hint));
|
|
3635
|
+
failures++;
|
|
3636
|
+
}
|
|
3637
|
+
function warn(msg, hint) {
|
|
3638
|
+
console.log(import_chalk5.default.yellow(" \u26A0\uFE0F ") + msg);
|
|
3639
|
+
if (hint) console.log(import_chalk5.default.gray(" " + hint));
|
|
3640
|
+
}
|
|
3641
|
+
function section(title) {
|
|
3642
|
+
console.log("\n" + import_chalk5.default.bold(title));
|
|
3643
|
+
}
|
|
3644
|
+
console.log(import_chalk5.default.cyan.bold(`
|
|
3645
|
+
\u{1F6E1}\uFE0F Node9 Doctor v${version}
|
|
3646
|
+
`));
|
|
3647
|
+
section("Binary");
|
|
3648
|
+
try {
|
|
3649
|
+
const which = (0, import_child_process4.execSync)("which node9", { encoding: "utf-8" }).trim();
|
|
3650
|
+
pass(`node9 found at ${which}`);
|
|
3651
|
+
} catch {
|
|
3652
|
+
warn("node9 not found in $PATH \u2014 hooks may not find it", "Run: npm install -g @node9/proxy");
|
|
3653
|
+
}
|
|
3654
|
+
const nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
|
|
3655
|
+
if (nodeMajor >= 18) {
|
|
3656
|
+
pass(`Node.js ${process.versions.node}`);
|
|
3657
|
+
} else {
|
|
3658
|
+
fail(
|
|
3659
|
+
`Node.js ${process.versions.node} (requires \u226518)`,
|
|
3660
|
+
"Upgrade Node.js: https://nodejs.org"
|
|
3661
|
+
);
|
|
3662
|
+
}
|
|
3663
|
+
try {
|
|
3664
|
+
const gitVersion = (0, import_child_process4.execSync)("git --version", { encoding: "utf-8" }).trim();
|
|
3665
|
+
pass(gitVersion);
|
|
3666
|
+
} catch {
|
|
3667
|
+
warn(
|
|
3668
|
+
"git not found \u2014 Undo Engine will be disabled",
|
|
3669
|
+
"Install git to enable snapshot-based undo"
|
|
3670
|
+
);
|
|
3671
|
+
}
|
|
3672
|
+
section("Configuration");
|
|
3673
|
+
const globalConfigPath = import_path5.default.join(homeDir2, ".node9", "config.json");
|
|
3674
|
+
if (import_fs5.default.existsSync(globalConfigPath)) {
|
|
3675
|
+
try {
|
|
3676
|
+
JSON.parse(import_fs5.default.readFileSync(globalConfigPath, "utf-8"));
|
|
3677
|
+
pass("~/.node9/config.json found and valid");
|
|
3678
|
+
} catch {
|
|
3679
|
+
fail("~/.node9/config.json is invalid JSON", "Run: node9 init --force");
|
|
3680
|
+
}
|
|
3681
|
+
} else {
|
|
3682
|
+
warn("~/.node9/config.json not found (using defaults)", "Run: node9 init");
|
|
3683
|
+
}
|
|
3684
|
+
const projectConfigPath = import_path5.default.join(process.cwd(), "node9.config.json");
|
|
3685
|
+
if (import_fs5.default.existsSync(projectConfigPath)) {
|
|
3686
|
+
try {
|
|
3687
|
+
JSON.parse(import_fs5.default.readFileSync(projectConfigPath, "utf-8"));
|
|
3688
|
+
pass("node9.config.json found and valid (project)");
|
|
3689
|
+
} catch {
|
|
3690
|
+
fail("node9.config.json is invalid JSON", "Fix the JSON or delete it and run: node9 init");
|
|
3691
|
+
}
|
|
3692
|
+
}
|
|
3693
|
+
const credsPath = import_path5.default.join(homeDir2, ".node9", "credentials.json");
|
|
3694
|
+
if (import_fs5.default.existsSync(credsPath)) {
|
|
3695
|
+
pass("Cloud credentials found (~/.node9/credentials.json)");
|
|
3696
|
+
} else {
|
|
3697
|
+
warn(
|
|
3698
|
+
"No cloud credentials \u2014 running in local-only mode",
|
|
3699
|
+
"Run: node9 login <apiKey> (or skip for local-only)"
|
|
3700
|
+
);
|
|
3701
|
+
}
|
|
3702
|
+
section("Agent Hooks");
|
|
3703
|
+
const claudeSettingsPath = import_path5.default.join(homeDir2, ".claude", "settings.json");
|
|
3704
|
+
if (import_fs5.default.existsSync(claudeSettingsPath)) {
|
|
3705
|
+
try {
|
|
3706
|
+
const cs = JSON.parse(import_fs5.default.readFileSync(claudeSettingsPath, "utf-8"));
|
|
3707
|
+
const hasHook = cs.hooks?.PreToolUse?.some(
|
|
3708
|
+
(m) => m.hooks.some((h) => h.command?.includes("node9") || h.command?.includes("cli.js"))
|
|
3709
|
+
);
|
|
3710
|
+
if (hasHook) pass("Claude Code \u2014 PreToolUse hook active");
|
|
3711
|
+
else
|
|
3712
|
+
fail("Claude Code \u2014 hooks file found but node9 hook missing", "Run: node9 setup claude");
|
|
3713
|
+
} catch {
|
|
3714
|
+
fail("Claude Code \u2014 ~/.claude/settings.json is invalid JSON");
|
|
3715
|
+
}
|
|
3716
|
+
} else {
|
|
3717
|
+
warn("Claude Code \u2014 not configured", "Run: node9 setup claude");
|
|
3718
|
+
}
|
|
3719
|
+
const geminiSettingsPath = import_path5.default.join(homeDir2, ".gemini", "settings.json");
|
|
3720
|
+
if (import_fs5.default.existsSync(geminiSettingsPath)) {
|
|
3721
|
+
try {
|
|
3722
|
+
const gs = JSON.parse(import_fs5.default.readFileSync(geminiSettingsPath, "utf-8"));
|
|
3723
|
+
const hasHook = gs.hooks?.BeforeTool?.some(
|
|
3724
|
+
(m) => m.hooks.some((h) => h.command?.includes("node9") || h.command?.includes("cli.js"))
|
|
3725
|
+
);
|
|
3726
|
+
if (hasHook) pass("Gemini CLI \u2014 BeforeTool hook active");
|
|
3727
|
+
else
|
|
3728
|
+
fail("Gemini CLI \u2014 hooks file found but node9 hook missing", "Run: node9 setup gemini");
|
|
3729
|
+
} catch {
|
|
3730
|
+
fail("Gemini CLI \u2014 ~/.gemini/settings.json is invalid JSON");
|
|
3731
|
+
}
|
|
3732
|
+
} else {
|
|
3733
|
+
warn("Gemini CLI \u2014 not configured", "Run: node9 setup gemini (skip if not using Gemini)");
|
|
3734
|
+
}
|
|
3735
|
+
const cursorHooksPath = import_path5.default.join(homeDir2, ".cursor", "hooks.json");
|
|
3736
|
+
if (import_fs5.default.existsSync(cursorHooksPath)) {
|
|
3737
|
+
try {
|
|
3738
|
+
const cur = JSON.parse(import_fs5.default.readFileSync(cursorHooksPath, "utf-8"));
|
|
3739
|
+
const hasHook = cur.hooks?.preToolUse?.some(
|
|
3740
|
+
(h) => h.command?.includes("node9") || h.command?.includes("cli.js")
|
|
3741
|
+
);
|
|
3742
|
+
if (hasHook) pass("Cursor \u2014 preToolUse hook active");
|
|
3743
|
+
else
|
|
3744
|
+
fail("Cursor \u2014 hooks file found but node9 hook missing", "Run: node9 setup cursor");
|
|
3745
|
+
} catch {
|
|
3746
|
+
fail("Cursor \u2014 ~/.cursor/hooks.json is invalid JSON");
|
|
3747
|
+
}
|
|
3748
|
+
} else {
|
|
3749
|
+
warn("Cursor \u2014 not configured", "Run: node9 setup cursor (skip if not using Cursor)");
|
|
3750
|
+
}
|
|
3751
|
+
section("Daemon (optional)");
|
|
3752
|
+
if (isDaemonRunning()) {
|
|
3753
|
+
pass(`Browser dashboard running \u2192 http://${DAEMON_HOST2}:${DAEMON_PORT2}/`);
|
|
3754
|
+
} else {
|
|
3755
|
+
warn("Daemon not running \u2014 browser approvals unavailable", "Run: node9 daemon --background");
|
|
3756
|
+
}
|
|
3757
|
+
console.log("");
|
|
3758
|
+
if (failures === 0) {
|
|
3759
|
+
console.log(import_chalk5.default.green.bold(" All checks passed. Node9 is ready.\n"));
|
|
3760
|
+
} else {
|
|
3761
|
+
console.log(import_chalk5.default.red.bold(` ${failures} check(s) failed. See hints above.
|
|
3762
|
+
`));
|
|
3763
|
+
process.exit(1);
|
|
3764
|
+
}
|
|
3765
|
+
});
|
|
3766
|
+
program.command("explain").description(
|
|
3767
|
+
"Show exactly how Node9 evaluates a tool call \u2014 waterfall + step-by-step policy trace"
|
|
3768
|
+
).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) => {
|
|
3769
|
+
let args = {};
|
|
3770
|
+
if (argsRaw) {
|
|
3771
|
+
const trimmed = argsRaw.trim();
|
|
3772
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
3773
|
+
try {
|
|
3774
|
+
args = JSON.parse(trimmed);
|
|
3775
|
+
} catch {
|
|
3776
|
+
console.error(import_chalk5.default.red(`
|
|
3777
|
+
\u274C Invalid JSON: ${trimmed}
|
|
3778
|
+
`));
|
|
3779
|
+
process.exit(1);
|
|
3780
|
+
}
|
|
3781
|
+
} else {
|
|
3782
|
+
args = { command: trimmed };
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3785
|
+
const result = await explainPolicy(tool, args);
|
|
3786
|
+
console.log("");
|
|
3787
|
+
console.log(import_chalk5.default.cyan.bold("\u{1F6E1}\uFE0F Node9 Explain"));
|
|
3788
|
+
console.log("");
|
|
3789
|
+
console.log(` ${import_chalk5.default.bold("Tool:")} ${import_chalk5.default.white(result.tool)}`);
|
|
3790
|
+
if (argsRaw) {
|
|
3791
|
+
const preview = argsRaw.length > 80 ? argsRaw.slice(0, 77) + "\u2026" : argsRaw;
|
|
3792
|
+
console.log(` ${import_chalk5.default.bold("Input:")} ${import_chalk5.default.gray(preview)}`);
|
|
3793
|
+
}
|
|
3794
|
+
console.log("");
|
|
3795
|
+
console.log(import_chalk5.default.bold("Config Sources (Waterfall):"));
|
|
3796
|
+
for (const tier of result.waterfall) {
|
|
3797
|
+
const num = import_chalk5.default.gray(` ${tier.tier}.`);
|
|
3798
|
+
const label = tier.label.padEnd(16);
|
|
3799
|
+
let statusStr;
|
|
3800
|
+
if (tier.tier === 1) {
|
|
3801
|
+
statusStr = import_chalk5.default.gray(tier.note ?? "");
|
|
3802
|
+
} else if (tier.status === "active") {
|
|
3803
|
+
const loc = tier.path ? import_chalk5.default.gray(tier.path) : "";
|
|
3804
|
+
const note = tier.note ? import_chalk5.default.gray(`(${tier.note})`) : "";
|
|
3805
|
+
statusStr = import_chalk5.default.green("\u2713 active") + (loc ? " " + loc : "") + (note ? " " + note : "");
|
|
3806
|
+
} else {
|
|
3807
|
+
statusStr = import_chalk5.default.gray("\u25CB " + (tier.note ?? "not found"));
|
|
3808
|
+
}
|
|
3809
|
+
console.log(`${num} ${import_chalk5.default.white(label)} ${statusStr}`);
|
|
3810
|
+
}
|
|
3811
|
+
console.log("");
|
|
3812
|
+
console.log(import_chalk5.default.bold("Policy Evaluation:"));
|
|
3813
|
+
for (const step of result.steps) {
|
|
3814
|
+
const isFinal = step.isFinal;
|
|
3815
|
+
let icon;
|
|
3816
|
+
if (step.outcome === "allow") icon = import_chalk5.default.green(" \u2705");
|
|
3817
|
+
else if (step.outcome === "review") icon = import_chalk5.default.red(" \u{1F534}");
|
|
3818
|
+
else if (step.outcome === "skip") icon = import_chalk5.default.gray(" \u2500 ");
|
|
3819
|
+
else icon = import_chalk5.default.gray(" \u25CB ");
|
|
3820
|
+
const name = step.name.padEnd(18);
|
|
3821
|
+
const nameStr = isFinal ? import_chalk5.default.white.bold(name) : import_chalk5.default.white(name);
|
|
3822
|
+
const detail = isFinal ? import_chalk5.default.white(step.detail) : import_chalk5.default.gray(step.detail);
|
|
3823
|
+
const arrow = isFinal ? import_chalk5.default.yellow(" \u2190 STOP") : "";
|
|
3824
|
+
console.log(`${icon} ${nameStr} ${detail}${arrow}`);
|
|
3825
|
+
}
|
|
3826
|
+
console.log("");
|
|
3827
|
+
if (result.decision === "allow") {
|
|
3828
|
+
console.log(import_chalk5.default.green.bold(" Decision: \u2705 ALLOW") + import_chalk5.default.gray(" \u2014 no approval needed"));
|
|
3829
|
+
} else {
|
|
3830
|
+
console.log(
|
|
3831
|
+
import_chalk5.default.red.bold(" Decision: \u{1F534} REVIEW") + import_chalk5.default.gray(" \u2014 human approval required")
|
|
3832
|
+
);
|
|
3833
|
+
if (result.blockedByLabel) {
|
|
3834
|
+
console.log(import_chalk5.default.gray(` Reason: ${result.blockedByLabel}`));
|
|
3835
|
+
}
|
|
3836
|
+
}
|
|
3837
|
+
console.log("");
|
|
3838
|
+
});
|
|
3186
3839
|
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) => {
|
|
3187
3840
|
const configPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "config.json");
|
|
3188
3841
|
if (import_fs5.default.existsSync(configPath) && !options.force) {
|
|
@@ -3320,7 +3973,6 @@ RAW: ${raw}
|
|
|
3320
3973
|
);
|
|
3321
3974
|
}
|
|
3322
3975
|
process.exit(0);
|
|
3323
|
-
return;
|
|
3324
3976
|
}
|
|
3325
3977
|
if (payload.cwd) {
|
|
3326
3978
|
try {
|
|
@@ -3350,26 +4002,7 @@ RAW: ${raw}
|
|
|
3350
4002
|
console.error(import_chalk5.default.gray(` Triggered by: ${blockedByContext}`));
|
|
3351
4003
|
if (result2?.changeHint) console.error(import_chalk5.default.cyan(` To change: ${result2.changeHint}`));
|
|
3352
4004
|
console.error("");
|
|
3353
|
-
|
|
3354
|
-
if (isHumanDecision) {
|
|
3355
|
-
aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: The human user specifically REJECTED this action.
|
|
3356
|
-
REASON: ${msg || "No specific reason provided by user."}
|
|
3357
|
-
|
|
3358
|
-
INSTRUCTIONS FOR AI AGENT:
|
|
3359
|
-
- Do NOT retry this exact command immediately.
|
|
3360
|
-
- Explain to the user that you understand they blocked the action.
|
|
3361
|
-
- Ask the user if there is an alternative approach they would prefer, or if they intended to block this action entirely.
|
|
3362
|
-
- 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.`;
|
|
3363
|
-
} else {
|
|
3364
|
-
aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${blockedByContext}].
|
|
3365
|
-
REASON: ${msg}
|
|
3366
|
-
|
|
3367
|
-
INSTRUCTIONS FOR AI AGENT:
|
|
3368
|
-
- This command violates the current security configuration.
|
|
3369
|
-
- Do NOT attempt to bypass this rule with bash syntax tricks; it will be blocked again.
|
|
3370
|
-
- Pivot to a non-destructive or read-only alternative.
|
|
3371
|
-
- Inform the user which security rule was triggered.`;
|
|
3372
|
-
}
|
|
4005
|
+
const aiFeedbackMessage = buildNegotiationMessage(blockedByContext, isHumanDecision, msg);
|
|
3373
4006
|
console.error(import_chalk5.default.dim(` (Detailed instructions sent to AI agent)`));
|
|
3374
4007
|
process.stdout.write(
|
|
3375
4008
|
JSON.stringify({
|
|
@@ -3391,17 +4024,16 @@ INSTRUCTIONS FOR AI AGENT:
|
|
|
3391
4024
|
}
|
|
3392
4025
|
const meta = { agent, mcpServer };
|
|
3393
4026
|
const STATE_CHANGING_TOOLS_PRE = [
|
|
3394
|
-
"bash",
|
|
3395
|
-
"shell",
|
|
3396
4027
|
"write_file",
|
|
3397
4028
|
"edit_file",
|
|
4029
|
+
"edit",
|
|
3398
4030
|
"replace",
|
|
3399
4031
|
"terminal.execute",
|
|
3400
4032
|
"str_replace_based_edit_tool",
|
|
3401
4033
|
"create_file"
|
|
3402
4034
|
];
|
|
3403
4035
|
if (config.settings.enableUndo && STATE_CHANGING_TOOLS_PRE.includes(toolName.toLowerCase())) {
|
|
3404
|
-
await createShadowSnapshot();
|
|
4036
|
+
await createShadowSnapshot(toolName, toolInput);
|
|
3405
4037
|
}
|
|
3406
4038
|
const result = await authorizeHeadless(toolName, toolInput, false, meta);
|
|
3407
4039
|
if (result.approved) {
|
|
@@ -3589,24 +4221,77 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
|
|
|
3589
4221
|
program.help();
|
|
3590
4222
|
}
|
|
3591
4223
|
});
|
|
3592
|
-
program.command("undo").description(
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
4224
|
+
program.command("undo").description(
|
|
4225
|
+
"Revert files to a pre-AI snapshot. Shows a diff and asks for confirmation before reverting. Use --steps N to go back N actions."
|
|
4226
|
+
).option("--steps <n>", "Number of snapshots to go back (default: 1)", "1").action(async (options) => {
|
|
4227
|
+
const steps = Math.max(1, parseInt(options.steps, 10) || 1);
|
|
4228
|
+
const history = getSnapshotHistory();
|
|
4229
|
+
if (history.length === 0) {
|
|
4230
|
+
console.log(import_chalk5.default.yellow("\n\u2139\uFE0F No undo snapshots found.\n"));
|
|
4231
|
+
return;
|
|
4232
|
+
}
|
|
4233
|
+
const idx = history.length - steps;
|
|
4234
|
+
if (idx < 0) {
|
|
4235
|
+
console.log(
|
|
4236
|
+
import_chalk5.default.yellow(
|
|
4237
|
+
`
|
|
4238
|
+
\u2139\uFE0F Only ${history.length} snapshot(s) available, cannot go back ${steps}.
|
|
4239
|
+
`
|
|
4240
|
+
)
|
|
4241
|
+
);
|
|
3596
4242
|
return;
|
|
3597
4243
|
}
|
|
3598
|
-
|
|
3599
|
-
|
|
4244
|
+
const snapshot = history[idx];
|
|
4245
|
+
const age = Math.round((Date.now() - snapshot.timestamp) / 1e3);
|
|
4246
|
+
const ageStr = age < 60 ? `${age}s ago` : age < 3600 ? `${Math.round(age / 60)}m ago` : `${Math.round(age / 3600)}h ago`;
|
|
4247
|
+
console.log(import_chalk5.default.magenta.bold(`
|
|
4248
|
+
\u23EA Node9 Undo${steps > 1 ? ` (${steps} steps back)` : ""}`));
|
|
4249
|
+
console.log(
|
|
4250
|
+
import_chalk5.default.white(
|
|
4251
|
+
` Tool: ${import_chalk5.default.cyan(snapshot.tool)}${snapshot.argsSummary ? import_chalk5.default.gray(" \u2192 " + snapshot.argsSummary) : ""}`
|
|
4252
|
+
)
|
|
4253
|
+
);
|
|
4254
|
+
console.log(import_chalk5.default.white(` When: ${import_chalk5.default.gray(ageStr)}`));
|
|
4255
|
+
console.log(import_chalk5.default.white(` Dir: ${import_chalk5.default.gray(snapshot.cwd)}`));
|
|
4256
|
+
if (steps > 1)
|
|
4257
|
+
console.log(
|
|
4258
|
+
import_chalk5.default.yellow(` Note: This will also undo the ${steps - 1} action(s) after it.`)
|
|
4259
|
+
);
|
|
4260
|
+
console.log("");
|
|
4261
|
+
const diff = computeUndoDiff(snapshot.hash, snapshot.cwd);
|
|
4262
|
+
if (diff) {
|
|
4263
|
+
const lines = diff.split("\n");
|
|
4264
|
+
for (const line of lines) {
|
|
4265
|
+
if (line.startsWith("+++") || line.startsWith("---")) {
|
|
4266
|
+
console.log(import_chalk5.default.bold(line));
|
|
4267
|
+
} else if (line.startsWith("+")) {
|
|
4268
|
+
console.log(import_chalk5.default.green(line));
|
|
4269
|
+
} else if (line.startsWith("-")) {
|
|
4270
|
+
console.log(import_chalk5.default.red(line));
|
|
4271
|
+
} else if (line.startsWith("@@")) {
|
|
4272
|
+
console.log(import_chalk5.default.cyan(line));
|
|
4273
|
+
} else {
|
|
4274
|
+
console.log(import_chalk5.default.gray(line));
|
|
4275
|
+
}
|
|
4276
|
+
}
|
|
4277
|
+
console.log("");
|
|
4278
|
+
} else {
|
|
4279
|
+
console.log(
|
|
4280
|
+
import_chalk5.default.gray(" (no diff available \u2014 working tree may already match snapshot)\n")
|
|
4281
|
+
);
|
|
4282
|
+
}
|
|
3600
4283
|
const proceed = await (0, import_prompts3.confirm)({
|
|
3601
|
-
message:
|
|
4284
|
+
message: `Revert to this snapshot?`,
|
|
3602
4285
|
default: false
|
|
3603
4286
|
});
|
|
3604
4287
|
if (proceed) {
|
|
3605
|
-
if (applyUndo(hash)) {
|
|
3606
|
-
console.log(import_chalk5.default.green("\u2705
|
|
4288
|
+
if (applyUndo(snapshot.hash, snapshot.cwd)) {
|
|
4289
|
+
console.log(import_chalk5.default.green("\n\u2705 Reverted successfully.\n"));
|
|
3607
4290
|
} else {
|
|
3608
|
-
console.error(import_chalk5.default.red("\u274C Undo failed. Ensure you are in a Git repository.\n"));
|
|
4291
|
+
console.error(import_chalk5.default.red("\n\u274C Undo failed. Ensure you are in a Git repository.\n"));
|
|
3609
4292
|
}
|
|
4293
|
+
} else {
|
|
4294
|
+
console.log(import_chalk5.default.gray("\nCancelled.\n"));
|
|
3610
4295
|
}
|
|
3611
4296
|
});
|
|
3612
4297
|
process.on("unhandledRejection", (reason) => {
|